Skip to content

Commit 7fcbd00

Browse files
Mount retention routes so a host can offer forget and purge (CL-6288)
tombstoneDocument, hardDeleteDocument and setRetentionClass were implemented, tested, exported — and reachable by no host over HTTP. A memory UI could show a user everything the system remembered about them and offer no way to take any of it back. Three routes, each guarded on the same grant model as add/search and usable by browser and machine callers alike: POST .../memory/documents/:documentId/forget memory:forget POST .../memory/documents/:documentId/purge memory:purge POST .../memory/versions/:versionId/retention-class memory:forget forget and purge stay distinct routes with distinct grant actions, never a boolean flag, so a host can wire "forget this" broadly while restricting the irreversible one. Ownership is enforced by resolving the document's true creator before any destructive call: a share grant that lets a peer read a document never lets them destroy it, and grants only ever carry action "search". Honest about what forget does: it stops the document appearing in search and overwrites chunk text with [redacted]. The content is destroyed, not archived, and there is no un-tombstone verb — earlier wording claiming reversibility was wrong and is corrected here. Grant metadata renamed from source to installHint, because nothing reads it at request time. ARCHITECTURE.md now states plainly that this package has two authorization mechanisms — grant tags for capability and visibility, and the imperative ownership check as the sole source of truth for whose document it is. sweepEphemeral is deliberately not routed: it is tenant-wide maintenance with no per-caller ownership, so a host schedules it against the in-process plane. Known gap, filed: CL-6294 — purge deletes the version rows carrying deprecatedAt/reason, so it destroys its own audit trail.
1 parent 5595092 commit 7fcbd00

21 files changed

Lines changed: 1281 additions & 42 deletions

‎AGENTS.md‎

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -22,7 +22,8 @@ CI runs `typecheck` + `test` — both must pass before any push.
2222
- `src/index.ts` — public surface: `createMemory` (optional `app` registers HTTP), `registerMemoryRoutes`
2323

2424
- `src/mount-config.ts` / `src/config.ts` — mount config + engine config
25-
- `src/routes/` — Hono routes (`add`, `search`, `list`)
25+
- `src/routes/` — Hono routes (`add`, `search`, `list`, `feed`, retention
26+
`forget`/`purge`/`retention-class`)
2627
- `src/tools/` — Interchange `defineTool` factories (`@corbits/memory/tools`);
2728
HTTP clients for mounted routes (env credentials; no in-process plane)
2829
- `src/services/` — capture / search / transform internals (not public verbs)

‎ARCHITECTURE.md‎

Lines changed: 31 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -56,6 +56,20 @@ helpers are optional multi-writer / backfill — not the primary path.
5656
anything from the request body, is what `grantGuard` authorizes.
5757
- **Grants delegate to the host.** Pass `grantStore` + `conditionRegistry`;
5858
routes use `createRequireGrant("memory", action)`.
59+
- **Two authorization mechanisms, not one — know which is source of truth
60+
for what.** (1) Grant tags decide *capability* (may this principal call
61+
`add`/`search`/`forget`/`purge` at all — `requireGrant`) and *visibility*
62+
(which documents a principal may see — `accessTags` + `canAccessDocument`
63+
in `grant-tags.ts`, where a share grant legitimately widens who can find a
64+
document). (2) A separate, imperative **ownership** check — the creator
65+
lookup in `services/retention-ownership.ts`, called from `memory.ts` —
66+
decides who may *forget or purge* a specific document, and is the sole
67+
source of truth for "whose document is this": it is never derived from
68+
grant tags and a share grant never satisfies it. `MemoryGrantRequirement.
69+
installHint` (`grant-requirements.ts`) looks adjacent to this but is not:
70+
it is advisory metadata for install tooling sizing a capability grant,
71+
read by nothing at request time. Do not extend mechanism (1) expecting it
72+
to cover ownership — extend `retention-ownership.ts` instead.
5973
- **Dependencies**: `@intx/hub-api`, `@intx/authz`, `@intx/log`, Hono, Drizzle,
6074
arktype, `postgres`, `hono-openapi`. LGPL-2.1 — see `LICENSE`.
6175

@@ -85,9 +99,23 @@ exposes the same three verbs.
8599
authority/recency → MMR); optional live `SourceProvider` merge (fail-soft).
86100
- `GET /api/tenants/:tenantId/memory/list` — recent documents, same grant-tag filter as local
87101
search.
88-
89-
Returns an in-process `Memory` (`add`, `search`, `list`, `close`) for host
90-
workers and ingestion modules that already resolved identity.
102+
- `POST /api/tenants/:tenantId/memory/documents/:documentId/forget` — tombstone
103+
(grant `memory:forget`; creator-only, see below).
104+
- `POST /api/tenants/:tenantId/memory/documents/:documentId/purge` — hard
105+
delete (grant `memory:purge`; creator-only; irreversible).
106+
- `POST /api/tenants/:tenantId/memory/versions/:versionId/retention-class` —
107+
set retention class (grant `memory:forget`; creator-only).
108+
109+
Forget and purge are deliberately separate routes and separate grant actions
110+
(never one route with a boolean flag) — a host wiring a "forget this" button
111+
cannot accidentally wire up permanent deletion. `sweepEphemeral` (TTL
112+
auto-deprecation) is **not** HTTP-routed: it is a maintenance sweep a host
113+
schedules on its own cron, not a user action; call it in-process against the
114+
returned `Memory`. See docs/RETENTION.md.
115+
116+
Returns an in-process `Memory` (`add`, `search`, `list`, `close`, plus the
117+
optional retention writes) for host workers and ingestion modules that
118+
already resolved identity.
91119

92120
**Agent tools live in this package** as thin HTTP clients
93121
(`@corbits/memory/tools` / `interchange.tools`): `defineTool` factories that

‎CHANGELOG.md‎

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,18 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
99

1010
### Added
1111

12+
- Retention HTTP routes (CL-6288): `POST …/memory/documents/:documentId/forget`
13+
(tombstone, grant `memory:forget`), `POST …/memory/documents/:documentId/purge`
14+
(hard delete, grant `memory:purge`), and
15+
`POST …/memory/versions/:versionId/retention-class` (grant `memory:forget`).
16+
Forget and purge are separate routes with separate grant actions — never one
17+
route with a boolean flag — and both are refused with 403 unless the caller
18+
is the document/version's creator, independent of any share grant that lets
19+
them merely see it. `sweepEphemeral` stays off the HTTP surface (maintenance
20+
sweep, not a user action); a host schedules it on its own cron against the
21+
in-process `Memory`. New `memory:forget` / `memory:purge` grant requirements
22+
(`source: "creator"`) and `capabilityIdsForSurface()` so distiller/tools
23+
installs no longer pick up routes-only capabilities by accident.
1224
- `RouteDeps.callerResolver` / `createMemory({ callerResolver })` — an
1325
optional host-supplied resolver from a request to a `{ tenantId,
1426
principalId }` scope, for a caller that never goes through the host's

‎IMPLEMENTATION.md‎

Lines changed: 10 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -633,8 +633,13 @@ surface, or a migrating host silently loses them.
633633
| `POST /api/tenants/:tenantId/memory/add` | `add` | `{ title, text, access_tags?, share? }` | `200 { documentId, versionId }`; `400` on validation |
634634
| `POST /api/tenants/:tenantId/memory/search` | `search` | `{ query, limit?, kinds?, entity_ids?, sources?, includeEvidence? }` (limit 1–50; `kinds`/`entity_ids`/`sources` narrow retrieval before fusion; unset or `[]` = unfiltered; `includeEvidence` adds a short evidence string when true) | `200 { items[], evidence?, degraded? }`; `400` on bad input |
635635
| `GET /api/tenants/:tenantId/memory/list` | `search` | query `?limit=` (1–100, string on the wire) | `200 { events: [{ at, title, source, tenantId, principalId }] }` — durable recent documents for the caller's scope, filtered with grant-tag access (`canAccessDocument`). One event per document (active live version). |
636+
| `GET /api/tenants/:tenantId/memory/feed` | `search` | query `?after=&limit=&exclude_generator=` | `200 { entries[], nextCursor }` — cursor pull of new live versions. See `docs/FEED.md`. |
637+
| `POST /api/tenants/:tenantId/memory/documents/:documentId/forget` | `forget` | `{ reason? }` | `200 { documentId, versions }`; `403` unless caller is the document's creator; `404` unknown document. Tombstones — content is redacted, not archived; see docs/RETENTION.md. |
638+
| `POST /api/tenants/:tenantId/memory/documents/:documentId/purge` | `purge` | none | `200 { documentId, deleted, reason? }`; `403` unless caller is the document's creator; `404` unknown document. Hard-deletes the row — irreversible; refused while a `durable` version is untombstoned. |
639+
| `POST /api/tenants/:tenantId/memory/versions/:versionId/retention-class` | `forget` | `{ retention_class }` | `200 { versionId, documentId, status }`; `400` invalid class; `403` unless caller is the version's creator; `404` unknown version. |
636640

637-
`registerMemoryRoutes` and `createMemory({ app })` register the three HTTP routes.
641+
`registerMemoryRoutes` and `createMemory({ app })` register these seven HTTP
642+
routes (add, search, list, feed, forget, purge, retention-class).
638643
Agent tools ship in this package as Interchange `defineTool` factories
639644
(`@corbits/memory/tools` / `interchange.tools`): thin HTTP clients that call the
640645
mounted routes with install env (`memoryBaseUrl`, `memoryTenantId`,
@@ -645,7 +650,10 @@ protection — the client has no default timeout. OpenAPI→MCP remains an optio
645650
host bridge. The plane surface is `add` / `search` / `list` / `close`, plus
646651
optional transform methods when backed by the engine DocumentStore
647652
(`createTransformConfig`, `listTransformConfigs`, `runTransform`,
648-
`promoteGeneration`, `demoteGeneration`). Inference stays on the host.
653+
`promoteGeneration`, `demoteGeneration`) and optional retention methods
654+
(`tombstoneDocument`, `hardDeleteDocument`, `setRetentionClass`,
655+
`sweepEphemeral`, `deprecateVersion`) — see docs/RETENTION.md. Inference stays
656+
on the host.
649657

650658
### Share materialization (CL-5873)
651659

‎docs/RETENTION.md‎

Lines changed: 45 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -7,7 +7,7 @@ Versions carry a **retention class** orthogonal to temporal ranking class
77
| --- | --- |
88
| `durable` | Long-lived claims; hard-delete blocked until tombstoned |
99
| `standard` | Default working memory |
10-
| `ephemeral` | Short TTL; sweeper hard-deletes past `valid_until` (or 7d from `ingested_at`) |
10+
| `ephemeral` | Short TTL; sweeper deprecates past `valid_until` (or 7d from `ingested_at`) — hard delete is a separate explicit step |
1111
| `source_only` | Keep raw capture; derived versions may be dropped by host policy |
1212

1313
Schema: `memory.version.retention_class` (migration `0007_retention.sql`).
@@ -21,7 +21,7 @@ CHECK constraint `version_retention_class_check` stays lockstep with
2121
| Deprecate | `memory.deprecateVersion` | `status=deprecated`, `deprecated_at` / reason |
2222
| Tombstone | `memory.tombstoneDocument` | All active/deprecated/superseded versions → `tombstoned`; chunk text redacted to `[redacted]` |
2323
| Hard delete | `memory.hardDeleteDocument` | Deletes document row (cascade); **refuses** if any non-tombstoned version is `durable` |
24-
| Sweep | `memory.sweepEphemeral` | Auto-deprecates ephemeral versions past `valid_until` (or 7d from `ingested_at`); host schedules, core is cron-free |
24+
| Sweep | `memory.sweepEphemeral` | Auto-**deprecates** (never deletes) ephemeral versions past `valid_until` (or 7d from `ingested_at`); host schedules, core is cron-free |
2525
| Set class | `memory.setRetentionClass` | Update `retention_class` on a version |
2626

2727
Search and feed exclude non-active (and non-superseded for feed) rows by
@@ -30,3 +30,46 @@ versions intentionally (ops / audit). Hard-delete is a separate explicit
3030
verb — TTL never hard-deletes.
3131

3232
Service module: `src/services/retention.ts`.
33+
34+
## HTTP surface (CL-6288)
35+
36+
| Route | Grant action | Plane verb |
37+
| --- | --- | --- |
38+
| `POST …/memory/documents/:documentId/forget` | `memory:forget` | `tombstoneDocument` |
39+
| `POST …/memory/documents/:documentId/purge` | `memory:purge` | `hardDeleteDocument` |
40+
| `POST …/memory/versions/:versionId/retention-class` | `memory:forget` | `setRetentionClass` |
41+
42+
`deprecateVersion` and `sweepEphemeral` have no route (see below).
43+
44+
**Tombstone vs. hard delete stay distinct verbs, distinct grants.** A UI
45+
offering "forget this" must never be one flag away from "shred this" by
46+
accident. `forget` (tombstone) is **not** an undo-able action: the document
47+
stops appearing in search/feed and its chunk text is overwritten with
48+
`[redacted]` — the original content does not survive, there is no
49+
un-tombstone/restore verb, and only version metadata (status, timestamps,
50+
retention class) remains for audit. `purge` (hard delete) goes further and
51+
removes the document row itself; it has its own grant action and is refused
52+
outright while a `durable`-class version on the document is untombstoned. The
53+
distinction that matters is *what's still queryable*: after `forget` a
54+
document row and its metadata still exist (for audit) but its content is
55+
gone; after `purge` nothing does. A host can grant `forget` broadly (every
56+
user gets a "forget this" button) while keeping `purge` to an operator role —
57+
but should not describe `forget` to end users as reversible.
58+
59+
**Ownership, not just visibility.** `memory:search`/a document's `accessTags`
60+
say who can *see* a document — never who may forget or purge it. Every
61+
retention route additionally checks that the caller is the document's
62+
creator (`created_by_principal_id` — the document's first version for
63+
`forget`/`purge`, the specific version's own creator for `retention-class`),
64+
independent of any share grant. A peer who can search a shared document gets
65+
403 on `forget`/`purge`/`retention-class` for it. See
66+
`src/services/retention-ownership.ts` and the ownership tests in
67+
`src/memory.test.ts` / `src/routes/routes.test.ts`.
68+
69+
**`sweepEphemeral` stays off the HTTP surface.** It is a maintenance sweep —
70+
"deprecate every ephemeral version past its TTL for this tenant" — not
71+
something a single user requests about their own data, and it has no natural
72+
per-caller grant (it does not take a `principalId` and touches every
73+
matching row tenant-wide). A host that wants it schedules a cron job calling
74+
`memory.sweepEphemeral({ tenantId })` in-process (the returned `Memory`
75+
already exposes it); the engine stays cron-free per `ARCHITECTURE.md`.

‎package.json‎

Lines changed: 14 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -15,14 +15,26 @@
1515
{
1616
"resource": "memory",
1717
"action": "add",
18-
"source": "tenant",
18+
"installHint": "tenant",
1919
"surfaces": ["tools", "distiller", "routes"]
2020
},
2121
{
2222
"resource": "memory",
2323
"action": "search",
24-
"source": "tenant",
24+
"installHint": "tenant",
2525
"surfaces": ["tools", "distiller", "routes"]
26+
},
27+
{
28+
"resource": "memory",
29+
"action": "forget",
30+
"installHint": "creator",
31+
"surfaces": ["routes"]
32+
},
33+
{
34+
"resource": "memory",
35+
"action": "purge",
36+
"installHint": "creator",
37+
"surfaces": ["routes"]
2638
}
2739
]
2840
},

‎src/core/schemas/non-blank-id.ts‎

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,15 @@
1+
import { type } from "arktype";
2+
3+
/**
4+
* `"string >= 1"` is a LENGTH constraint, not a content one — `" "` has
5+
* length 1 and would pass it, seating/accepting a whitespace-only id exactly
6+
* like the empty-string case such a schema exists to reject. Require at
7+
* least one non-whitespace character instead.
8+
*
9+
* Shared by the resolved-caller trust boundary (`routes/deps.ts`, CL-6286)
10+
* and the retention path-param schemas (`http-bodies.ts`, CL-6288) so the
11+
* fix lives in one schema instead of a comment repeated at each call site.
12+
*/
13+
export const NonBlankId = type("string").narrow(
14+
(s, ctx) => s.trim().length > 0 || ctx.mustBe("non-blank (not just whitespace)"),
15+
);

‎src/distiller/workflow.ts‎

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -23,7 +23,7 @@ import { defineWorkflow, type WorkflowDefinition } from "@intx/workflow";
2323
import { memoryAdd } from "../tools/add.ts";
2424
import { memoryFeed } from "../tools/feed.ts";
2525
import { memorySearch } from "../tools/search.ts";
26-
import { MEMORY_CAPABILITY_IDS } from "../grant-requirements.ts";
26+
import { capabilityIdsForSurface } from "../grant-requirements.ts";
2727
import {
2828
RESIDENT_DISTILLER_AGENT_ID,
2929
RESIDENT_DISTILLER_CRON_DEFAULT,
@@ -97,7 +97,7 @@ export function createResidentDistiller(
9797
"Resident memory distiller — feed → classify → claim write",
9898
systemPrompt: opts.systemPrompt ?? DEFAULT_SYSTEM_PROMPT(generatorAgentId),
9999
tools,
100-
capabilities: [...MEMORY_CAPABILITY_IDS],
100+
capabilities: capabilityIdsForSurface("distiller"),
101101

102102
inference: opts.inference,
103103
tags: {

‎src/grant-requirements.test.ts‎

Lines changed: 56 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -3,27 +3,79 @@ import { readFileSync } from "node:fs";
33
import { join } from "node:path";
44

55
import {
6+
capabilityIdsForSurface,
67
MEMORY_CAPABILITY_IDS,
78
MEMORY_GRANT_REQUIREMENTS,
89
} from "./grant-requirements.ts";
910

1011
describe("MEMORY_GRANT_REQUIREMENTS", () => {
11-
test("covers add + search on memory resource", () => {
12+
test("covers add, search, forget, and purge on memory resource", () => {
1213
expect(MEMORY_GRANT_REQUIREMENTS.map((r) => r.action).sort()).toEqual([
1314
"add",
15+
"forget",
16+
"purge",
1417
"search",
1518
]);
1619
for (const r of MEMORY_GRANT_REQUIREMENTS) {
1720
expect(r.resource).toBe("memory");
18-
expect(r.source).toBe("tenant");
21+
}
22+
});
23+
24+
test("add/search hint tenant-wide and reach tools + distiller + routes", () => {
25+
for (const action of ["add", "search"]) {
26+
const r = MEMORY_GRANT_REQUIREMENTS.find((x) => x.action === action)!;
27+
expect(r.installHint).toBe("tenant");
1928
expect(r.surfaces).toContain("tools");
2029
expect(r.surfaces).toContain("distiller");
30+
expect(r.surfaces).toContain("routes");
31+
}
32+
});
33+
34+
test("forget/purge hint creator-scoped and are routes-only", () => {
35+
for (const action of ["forget", "purge"]) {
36+
const r = MEMORY_GRANT_REQUIREMENTS.find((x) => x.action === action)!;
37+
expect(r.installHint).toBe("creator");
38+
expect(r.surfaces).toEqual(["routes"]);
39+
}
40+
});
41+
42+
test("installHint is advisory only — the requirement shape carries no enforcement field", () => {
43+
// The actual ownership check lives in services/retention-ownership.ts,
44+
// wired imperatively into memory.ts, entirely independent of this hint.
45+
// This test exists so a future reader who tightens grant-requirements.ts
46+
// notices this comment rather than assuming installHint is load-bearing.
47+
for (const r of MEMORY_GRANT_REQUIREMENTS) {
48+
expect(Object.keys(r).sort()).toEqual([
49+
"action",
50+
"installHint",
51+
"resource",
52+
"surfaces",
53+
]);
2154
}
2255
});
2356

2457
test("capability ids are resource:action", () => {
2558
expect([...MEMORY_CAPABILITY_IDS].sort()).toEqual([
2659
"memory:add",
60+
"memory:forget",
61+
"memory:purge",
62+
"memory:search",
63+
]);
64+
});
65+
66+
test("capabilityIdsForSurface excludes routes-only actions from distiller/tools", () => {
67+
expect(capabilityIdsForSurface("distiller").sort()).toEqual([
68+
"memory:add",
69+
"memory:search",
70+
]);
71+
expect(capabilityIdsForSurface("tools").sort()).toEqual([
72+
"memory:add",
73+
"memory:search",
74+
]);
75+
expect(capabilityIdsForSurface("routes").sort()).toEqual([
76+
"memory:add",
77+
"memory:forget",
78+
"memory:purge",
2779
"memory:search",
2880
]);
2981
});
@@ -36,7 +88,7 @@ describe("MEMORY_GRANT_REQUIREMENTS", () => {
3688
grantRequirements?: Array<{
3789
resource: string;
3890
action: string;
39-
source: string;
91+
installHint: string;
4092
surfaces: string[];
4193
}>;
4294
};
@@ -46,7 +98,7 @@ describe("MEMORY_GRANT_REQUIREMENTS", () => {
4698
MEMORY_GRANT_REQUIREMENTS.map((r) => ({
4799
resource: r.resource,
48100
action: r.action,
49-
source: r.source,
101+
installHint: r.installHint,
50102
surfaces: [...r.surfaces],
51103
})),
52104
);

0 commit comments

Comments
 (0)