Retention routes: forget, purge, retention-class (CL-6288) - #36
Merged
Conversation
TheGreatAxios
force-pushed
the
cl-6288-retention-routes
branch
2 times, most recently
from
August 19, 2026 00:32
2179b31 to
f1b9881
Compare
Retention routes need capability grants distinct from search, and a distiller/tools install must not inherit routes-only capabilities. Assert the forget/purge shape and a surface-filtered capability id helper ahead of adding either.
…ties by surface (CL-6288) Retention writes get their own capability grants (source: "creator", pairing with the ownership check the routes enforce) instead of riding on memory:search. Add capabilityIdsForSurface() and use it in the resident distiller so a routes-only capability never leaks into its agent definition — a bug the previous unconditional MEMORY_CAPABILITY_IDS spread would have introduced the moment forget/purge existed.
canAccessDocument (grant-tags.ts) answers "can this principal see the document" — including via a share grant — which is the wrong question for forget/purge/retention-class. Exercise the creator-lookup helpers those routes will use, against a mocked raw sql client (same pattern as the search ACL post-filter), ahead of adding the module.
resolveDocumentOwner / resolveVersionOwner load a document's or version's creator directly (created_by_principal_id — first version for a document, own row for a version), independent of accessTags. isOwner() is the single place "may this caller forget/purge this" gets decided, so the memory.ts plane wiring and the HTTP routes both call through it rather than re-deriving ownership.
tombstoneDocument/hardDeleteDocument/setRetentionClass must refuse a non-creator caller with 403, and an unknown document/version with 404, before ever touching services/retention.ts — a share grant that lets a peer search a document must not let them forget or purge it. Mock the raw sql client and services/retention.ts the same way the existing search ACL tests do.
…ership (CL-6288) Memory.tombstoneDocument / hardDeleteDocument / setRetentionClass now take the caller's principalId and check it against resolveDocumentOwner / resolveVersionOwner before delegating to services/retention.ts — 404 for an unknown document/version, 403 for anyone but its creator. deprecateVersion and sweepEphemeral are unchanged (not HTTP-routed; see docs/RETENTION.md).
Extend the stub plane with the same creator/documentId fixtures the routes will call through: grant-guard coverage for forget/purge/retention-class (memory:search must not authorize any of them), the cross-principal ownership refusal (mirrors CL-6286's cross-tenant test — a share grant that lets a peer see a document must not let them forget or purge it), 404s for unknown documents/versions, and that forget and purge stay distinct routes rather than one call with a boolean flag.
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).
AGENTS.md/ARCHITECTURE.md route lists, docs/RETENTION.md (route table, grant actions, ownership gate, sweepEphemeral decision), and CHANGELOG.
tombstoneDocument overwrites chunk.text with '[redacted]' — there is no history/audit table and no un-tombstone verb, so the original content does not survive a forget request; only version metadata does. The route summary and docs/RETENTION.md previously said "reversible in principle," which would lead a host to build an "undo forget" button with nothing to undo to. Describe what forget actually does (stops appearing in search, content redacted, row kept for audit) instead. purge remains genuinely irreversible (the row itself is removed) — that claim was already correct. Drive-by: docs/RETENTION.md said the ephemeral sweeper "hard-deletes" past valid_until; sweepEphemeral only sets status=deprecated and never deletes. Pre-existing inaccuracy, fixed while already editing this file.
…n mechanisms (CL-6288 review)
`source: "creator"` sat next to `source: "tenant"` and read like an
enforcement switch the grant store resolves — it is not. Nothing reads this
field; it is advisory sizing metadata for install tooling. Renamed the field
to `installHint` and the type to `MemoryGrantInstallHint`, with a doc comment
that says plainly this is not enforced and points at
services/retention-ownership.ts as the actual mechanism. Added a test
asserting the requirement shape carries no enforcement field, so a future
tightening of grant-requirements.ts trips on this comment rather than
assuming installHint is load-bearing.
ARCHITECTURE.md's Boundaries section previously described one authorization
model ("document access is grant tags + creator"). The library now runs two:
grant tags for capability + visibility, and a separate imperative ownership
check for forget/purge. Stated the split plainly, including which mechanism
is the source of truth for "whose document is it" (ownership — never grant
tags; a share grant never satisfies it).
DocumentIdParam/VersionIdParam use "string >= 1" — a length constraint, not a content one, so " " (length 1) passes and reaches the plane instead of being rejected as invalid input. #35 already hit and fixed this exact bug for the resolved-caller trust boundary (NonBlankId, routes/deps.ts); it regrew here because the fix lived in a comment instead of a shared schema. Extract NonBlankId to core/schemas/non-blank-id.ts (deps.ts now imports it too, unchanged behavior) ahead of reusing it for the path params.
DocumentIdParam/VersionIdParam now validate with the shared NonBlankId schema instead of "string >= 1", so a whitespace-only documentId/versionId is rejected at the trust boundary (400) instead of reaching the plane as a normal-looking (nonexistent) id. routes/deps.ts imports the same schema for the resolved-caller boundary it originally shipped with — behavior there is unchanged (src/routes/deps.test.ts has zero diff against origin's tip).
resolveDocumentOwner/resolveVersionOwner tests never varied tenantId, so tenant scoping was inferred from the destructive queries rather than proven on the ownership lookup itself. Add a scoped fake sql that filters by both bound values and asserts exactly two are bound (tenantId, id) — a dropped tenant_id predicate fails loudly here instead of silently returning whatever a coincidental positional match produces. Verified by temporarily stripping the tenant_id filter from the real query and confirming these tests go red for that reason, then restoring it.
…review) The most likely real-world caller of forget/purge is a workflow-run child resolved through #35's callerResolver, retiring memory it wrote itself. If a resolver's principalId ever drifted from created_by_principal_id (different derivation, casing, run-address vs principal-address), every automated retention call would 403 in production with nothing catching it first. Extend stubMachinePlane with the same creator-check fixtures stubPlane uses and cover: forget/purge/retention-class succeeding for the resolved run's own document/version, and still refused for one it does not own.
IMPLEMENTATION.md's route table and "register the three HTTP routes"
sentence predated feed and now this PR's three retention routes — the
package mounts seven. Added feed, forget, purge, and retention-class rows
and corrected the sentence; also listed the optional retention plane
methods alongside the existing transform ones.
src/memory.test.ts's ownership-wiring fake sql branched on
`text.includes("document_id")` with both arms returning an identical row —
it read as distinguishing the document-owner and version-owner queries but
did not. Dropped the branch; a comment explains real per-query scoping is
covered separately in retention-ownership.test.ts.
TheGreatAxios
force-pushed
the
cl-6288-retention-routes
branch
from
August 19, 2026 00:42
f1b9881 to
96d4a70
Compare
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Summary
tombstoneDocument,hardDeleteDocument, andsetRetentionClasswereexported, tested, and completely unreachable over HTTP —
registerMemoryRoutesonly mounted
add/search/list/feed. This closes CL-6288.Stacked on #35 (CL-6286, machine-caller identity resolution) — target branch
is
cl-6286-machine-auth, notmain. Rebased onto its current tip(
bb0a31c);src/routes/deps.test.tsshows zero diff from that tip, andsrc/routes/deps.tsshows only the intentional NonBlankId-sharing refactorfrom review item 4 below (no behavior change — all of #35's own tests pass
unmodified).
Route surface
POST /api/tenants/:tenantId/memory/documents/:documentId/forgetmemory:forgettombstoneDocumentPOST /api/tenants/:tenantId/memory/documents/:documentId/purgememory:purgehardDeleteDocumentPOST /api/tenants/:tenantId/memory/versions/:versionId/retention-classmemory:forgetsetRetentionClassAll three run behind
resolveCaller→requirePrincipal→grantGuard—identical guard chain to
add/search/list, so a machine caller fromCL-6286's
callerResolverseam works here too (now covered explicitly, seereview item 2).
Tombstone vs. hard delete stay distinct — and forget is NOT reversible
Separate routes, separate grant actions, no boolean flag.
forget(tombstone)stops a document appearing in search/feed and overwrites its chunk text with
[redacted]— there is no history table and no un-tombstone verb, so theoriginal content does not survive; only version metadata remains for audit.
purge(hard delete) removes the row entirely and is refused while adurable-class version is untombstoned — that one really is irreversible. Anearlier draft of this PR called
forget"reversible in principle" — that waswrong and has been corrected everywhere it appeared (route summaries,
docs/RETENTION.md).
Grant design — two authorization mechanisms, not one
Added
memory:forget/memory:purgetoMEMORY_GRANT_REQUIREMENTS.memory:searchnever authorizes any retention write — tested explicitly.Review correction: the requirement shape originally carried
source: "creator"sitting next tosource: "tenant", which read like anenforcement switch the grant store resolves. It is not — nothing reads that
field. Renamed it to
installHint(typeMemoryGrantInstallHint) with acomment stating plainly it is advisory-only sizing metadata for install
tooling, and added a test asserting the requirement shape carries no
enforcement field. ARCHITECTURE.md's Boundaries section now states the split
explicitly: grant tags decide capability + visibility (a share grant can
legitimately widen who can search/see a document); a separate imperative
ownership check (
services/retention-ownership.ts, wired intomemory.ts) is the sole source of truth for who may forget/purge a specificdocument, and a share grant never satisfies it.
Also fixed a latent surface leak: the resident distiller was spreading
every registered capability id into its agent definition, which would have
silently handed it
memory:forget/memory:purge. AddedcapabilityIdsForSurface(surface)and switched the distiller tocapabilityIdsForSurface("distiller").Ownership — the security property of this ticket
New
src/services/retention-ownership.tsresolves the actual creator(
created_by_principal_id— a document's first version for forget/purge, aversion's own row for retention-class) independent of
accessTags, and theplane wiring in
memory.tsrefuses the call (403) for anyone but thatcreator, 404 for an unknown document/version, before ever calling into
services/retention.ts.src/routes/routes.test.tshas a document owned by "alice" thatPRINCIPALcan be granted
forget/purgefor (capability) but is refused on(ownership) — the mirror of CL-6286's cross-tenant test. Tests assert the
destructive call did not happen, not just the status code.
workflow-run child resolved through
callerResolver, retiring memory itwrote itself — previously untested. Added coverage for a resolved machine
caller succeeding on its own document/version and still being refused on
one it doesn't own.
tenantId.Added a scoped fake sql that filters by both bound values and asserts
exactly two are bound (tenantId, id) — verified by temporarily stripping
the tenant_id predicate from the real query and confirming the new tests
go red for that reason, then restoring it.
sweepEphemeral: no route
Left off the HTTP surface — a tenant-wide TTL sweep with no
principalIdand no natural ownership check. A host schedules its own cron calling
memory.sweepEphemeral({ tenantId })in-process. Documented indocs/RETENTION.md, which also had a pre-existing inaccuracy (fixed in thisPR since already editing the file): the ephemeral sweeper only ever
deprecates past
valid_until, it never hard-deletes.Other review fixes
DocumentIdParam/VersionIdParamused"string >= 1"— a lengthconstraint that lets
" "through — for the same bug Machine-auth: caller resolver for workflow-run children (CL-6286) #35 had already hitand fixed for the resolved-caller boundary (
NonBlankId). ExtractedNonBlankIdtocore/schemas/non-blank-id.ts(shared by bothdeps.tsand
http-bodies.tsnow) and reused it for the path params. Addedred-then-green tests for whitespace-only
documentId/versionId.IMPLEMENTATION.md's route table and "register the three HTTP routes"sentence predated
feedand this PR's three routes — the package mountsseven. Fixed.
src/memory.test.tslooked like it distinguished thedocument-owner vs version-owner query but both arms returned an identical
row. Dropped the dead branch.
Test plan
bun run typecheck— cleanbun test ./src— 465 pass, 0 fail (410 pre-CL-6288 baseline; +18 fromCL-6286's rebased commits; +37 from this PR across both review rounds)
memory:search/no-grant is 403 on forget/purge/retention-classcapability grant) is 403, unknown id is 404 — status code AND that the
destructive call never happened
from the destructive queries)
callerResolver) coverage: resolved run succeeds on itsown document/version, refused on one it doesn't own
retention_classvalue and whitespace-only path params are 400cl-6286-machine-auth@bb0a31c;src/routes/deps.test.tsdiff against that tip is empty;
src/routes/deps.tsdiff is only theNonBlankId-sharing refactor