Skip to content

Commit 0242904

Browse files
committed
Mount retention routes: forget, purge, retention-class (CL-6288)
POST …/memory/documents/:documentId/forget (grant memory:forget) tombstones; POST …/memory/documents/:documentId/purge (grant memory:purge) hard-deletes; POST …/memory/versions/:versionId/retention-class (grant memory:forget) sets retention class. Separate routes and separate grant actions for tombstone vs hard delete — never a single route toggled by a boolean a client could get wrong. Path/body validated with arktype (DocumentIdParam, VersionIdParam, ForgetRequest, SetRetentionClassRequest); ownership refusal from the plane surfaces as 403, an unknown document/version as 404. sweepEphemeral gets no route — it is a tenant-wide maintenance sweep, not a per-caller action, and has no natural ownership check; a host schedules it on its own cron against the in-process Memory (docs/RETENTION.md).
1 parent e2ad97b commit 0242904

3 files changed

Lines changed: 252 additions & 1 deletion

File tree

‎src/http-bodies.ts‎

Lines changed: 28 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -152,3 +152,31 @@ export function parseWithArk<T>(
152152
}
153153
return parsed;
154154
}
155+
156+
/** Path param for the two document-scoped retention routes (forget/purge). */
157+
export const DocumentIdParam = type({
158+
documentId: "string >= 1",
159+
});
160+
161+
export type DocumentIdParam = typeof DocumentIdParam.infer;
162+
163+
/** Path param for the version-scoped retention-class route. */
164+
export const VersionIdParam = type({
165+
versionId: "string >= 1",
166+
});
167+
168+
export type VersionIdParam = typeof VersionIdParam.infer;
169+
170+
/** POST body for `.../forget` (tombstone) — reason is audit-only, never required. */
171+
export const ForgetRequest = type({
172+
"reason?": "string",
173+
});
174+
175+
export type ForgetRequest = typeof ForgetRequest.infer;
176+
177+
/** POST body for `.../retention-class`. Kept in lockstep with RETENTION_CLASSES (core/enums.ts). */
178+
export const SetRetentionClassRequest = type({
179+
retention_class: "'durable'|'standard'|'ephemeral'|'source_only'",
180+
});
181+
182+
export type SetRetentionClassRequest = typeof SetRetentionClassRequest.infer;

‎src/routes/mount.ts‎

Lines changed: 9 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -18,6 +18,11 @@ import { mountAddRoute } from "./add.ts";
1818
import { mountSearchRoute } from "./search.ts";
1919
import { mountListRoute } from "./list.ts";
2020
import { mountFeedRoute } from "./feed.ts";
21+
import {
22+
mountForgetRoute,
23+
mountPurgeRoute,
24+
mountSetRetentionClassRoute,
25+
} from "./retention.ts";
2126

2227
export type {
2328
CallerResolver,
@@ -26,7 +31,7 @@ export type {
2631
RouteDeps,
2732
} from "./deps.ts";
2833

29-
/** HTTP JSON routes: add, search, list, feed. */
34+
/** HTTP JSON routes: add, search, list, feed, forget, purge, retention-class. */
3035
export function registerMemoryRoutes(
3136
app: Hono<TenantEnv>,
3237
deps: RouteDeps,
@@ -35,4 +40,7 @@ export function registerMemoryRoutes(
3540
mountSearchRoute(app, deps);
3641
mountListRoute(app, deps);
3742
mountFeedRoute(app, deps);
43+
mountForgetRoute(app, deps);
44+
mountPurgeRoute(app, deps);
45+
mountSetRetentionClassRoute(app, deps);
3846
}

‎src/routes/retention.ts‎

Lines changed: 215 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,215 @@
1+
/**
2+
* Retention HTTP routes (CL-6288): forget (tombstone), purge (hard delete),
3+
* and set-retention-class. See docs/RETENTION.md.
4+
*
5+
* Tombstone and hard delete are deliberately separate routes with separate
6+
* grant actions (`forget` vs `purge`) — never one route with a boolean flag
7+
* a client could flip by accident. `purge` is the one that actually removes
8+
* data; its path and grant name say so.
9+
*
10+
* `sweepEphemeral` has no route here — it is a maintenance sweep a host
11+
* schedules on its own cron, not a user action (see docs/RETENTION.md).
12+
*/
13+
import type { Hono } from "hono";
14+
import type { TenantEnv } from "@intx/hub-api";
15+
import { describeRoute, resolver, validator } from "hono-openapi";
16+
import { type } from "arktype";
17+
18+
import { formatCaughtError, log } from "../log.ts";
19+
import {
20+
DocumentIdParam,
21+
ForgetRequest,
22+
SetRetentionClassRequest,
23+
VersionIdParam,
24+
} from "../http-bodies.ts";
25+
import { MemoryError } from "../memory.ts";
26+
import type { RouteDeps } from "./deps.ts";
27+
import {
28+
caller,
29+
grantGuard,
30+
requirePrincipal,
31+
resolveCaller,
32+
} from "./deps.ts";
33+
34+
function respondRetentionError(err: unknown, action: string) {
35+
const errMessage = formatCaughtError(err);
36+
log.error(`memory ${action} failed: ${errMessage}`, { error: errMessage });
37+
if (err instanceof MemoryError) {
38+
return { body: { error: err.message }, status: err.status as 403 | 404 | 501 };
39+
}
40+
return { body: { error: `${action} failed` }, status: 502 as const };
41+
}
42+
43+
const ForgetResponse = type({
44+
documentId: "string",
45+
versions: "number",
46+
});
47+
48+
const PurgeResponse = type({
49+
documentId: "string",
50+
deleted: "boolean",
51+
"reason?": "string",
52+
});
53+
54+
const RetentionClassResponse = type({
55+
versionId: "string",
56+
documentId: "string",
57+
status: "string",
58+
});
59+
60+
export function mountForgetRoute(app: Hono<TenantEnv>, deps: RouteDeps): void {
61+
app.post(
62+
"/api/tenants/:tenantId/memory/documents/:documentId/forget",
63+
64+
describeRoute({
65+
tags: ["memory"],
66+
summary: "Tombstone a document (reversible in principle: row stays for audit, chunk text redacted)",
67+
responses: {
68+
200: {
69+
description: "Tombstoned",
70+
content: { "application/json": { schema: resolver(ForgetResponse) } },
71+
},
72+
401: { description: "No principal on the request context" },
73+
403: {
74+
description:
75+
"Missing the memory:forget grant, or caller is not the document's creator",
76+
},
77+
404: { description: "Document not found" },
78+
502: { description: "forget failed" },
79+
},
80+
}),
81+
resolveCaller(deps),
82+
requirePrincipal(),
83+
grantGuard(deps, "forget"),
84+
validator("param", DocumentIdParam),
85+
validator("json", ForgetRequest),
86+
async (c) => {
87+
const { documentId } = c.req.valid("param");
88+
const { reason } = c.req.valid("json");
89+
const { scopeId, subjectId } = caller(c);
90+
if (!deps.memory.tombstoneDocument) {
91+
return c.json({ error: "retention APIs require the engine DocumentStore" }, 501);
92+
}
93+
try {
94+
const result = await deps.memory.tombstoneDocument({
95+
tenantId: scopeId,
96+
principalId: subjectId,
97+
documentId,
98+
...(reason !== undefined ? { reason } : {}),
99+
});
100+
return c.json({ documentId, versions: result.versions });
101+
} catch (err) {
102+
const { body, status } = respondRetentionError(err, "forget");
103+
return c.json(body, status);
104+
}
105+
},
106+
);
107+
}
108+
109+
export function mountPurgeRoute(app: Hono<TenantEnv>, deps: RouteDeps): void {
110+
app.post(
111+
"/api/tenants/:tenantId/memory/documents/:documentId/purge",
112+
113+
describeRoute({
114+
tags: ["memory"],
115+
summary: "Hard-delete a document — irreversible; refused while a durable version is untombstoned",
116+
responses: {
117+
200: {
118+
description: "Deletion result (deleted may be false with a reason)",
119+
content: { "application/json": { schema: resolver(PurgeResponse) } },
120+
},
121+
401: { description: "No principal on the request context" },
122+
403: {
123+
description:
124+
"Missing the memory:purge grant, or caller is not the document's creator",
125+
},
126+
404: { description: "Document not found" },
127+
502: { description: "purge failed" },
128+
},
129+
}),
130+
resolveCaller(deps),
131+
requirePrincipal(),
132+
grantGuard(deps, "purge"),
133+
validator("param", DocumentIdParam),
134+
async (c) => {
135+
const { documentId } = c.req.valid("param");
136+
const { scopeId, subjectId } = caller(c);
137+
if (!deps.memory.hardDeleteDocument) {
138+
return c.json({ error: "retention APIs require the engine DocumentStore" }, 501);
139+
}
140+
try {
141+
const result = await deps.memory.hardDeleteDocument({
142+
tenantId: scopeId,
143+
principalId: subjectId,
144+
documentId,
145+
});
146+
return c.json({
147+
documentId,
148+
deleted: result.deleted,
149+
...(result.reason !== undefined ? { reason: result.reason } : {}),
150+
});
151+
} catch (err) {
152+
const { body, status } = respondRetentionError(err, "purge");
153+
return c.json(body, status);
154+
}
155+
},
156+
);
157+
}
158+
159+
export function mountSetRetentionClassRoute(
160+
app: Hono<TenantEnv>,
161+
deps: RouteDeps,
162+
): void {
163+
app.post(
164+
"/api/tenants/:tenantId/memory/versions/:versionId/retention-class",
165+
166+
describeRoute({
167+
tags: ["memory"],
168+
summary: "Set a version's retention class (durable/standard/ephemeral/source_only)",
169+
responses: {
170+
200: {
171+
description: "Updated",
172+
content: {
173+
"application/json": { schema: resolver(RetentionClassResponse) },
174+
},
175+
},
176+
400: { description: "Invalid retention_class" },
177+
401: { description: "No principal on the request context" },
178+
403: {
179+
description:
180+
"Missing the memory:forget grant, or caller is not the version's creator",
181+
},
182+
404: { description: "Version not found" },
183+
502: { description: "retention-class update failed" },
184+
},
185+
}),
186+
resolveCaller(deps),
187+
requirePrincipal(),
188+
grantGuard(deps, "forget"),
189+
validator("param", VersionIdParam),
190+
validator("json", SetRetentionClassRequest),
191+
async (c) => {
192+
const { versionId } = c.req.valid("param");
193+
const { retention_class } = c.req.valid("json");
194+
const { scopeId, subjectId } = caller(c);
195+
if (!deps.memory.setRetentionClass) {
196+
return c.json({ error: "retention APIs require the engine DocumentStore" }, 501);
197+
}
198+
try {
199+
const result = await deps.memory.setRetentionClass({
200+
tenantId: scopeId,
201+
principalId: subjectId,
202+
versionId,
203+
retentionClass: retention_class,
204+
});
205+
if (!result) {
206+
return c.json({ error: "version not found" }, 404);
207+
}
208+
return c.json(result);
209+
} catch (err) {
210+
const { body, status } = respondRetentionError(err, "retention-class");
211+
return c.json(body, status);
212+
}
213+
},
214+
);
215+
}

0 commit comments

Comments
 (0)