{"protocol":"clank-doc/1","frameworkVersion":"0.24.0","slug":"retention-administration","title":"Retention administration and holds","description":"@clank.run/framework/retention administration provides one scoped operator inventory, reviewed purge batches, durable holds and periodic cleanup rules for import source metadata, collaboration operations/receipts and signed platform audit e","group":{"id":"full-stack","title":"Full stack"},"url":"https://docs.clank.run/docs/retention-administration","source":"docs/retention-administration.md","headings":["Declare application sources and authority","Inventory, review and accept","What the initial policy retires","Holds and schedules","Platform integration and capacity","Upgrade, rollback and recovery"],"tableOfContents":[{"id":"declare-application-sources-and-authority","title":"Declare application sources and authority","level":2},{"id":"inventory-review-and-accept","title":"Inventory, review and accept","level":2},{"id":"what-the-initial-policy-retires","title":"What the initial policy retires","level":2},{"id":"holds-and-schedules","title":"Holds and schedules","level":2},{"id":"platform-integration-and-capacity","title":"Platform integration and capacity","level":2},{"id":"upgrade-rollback-and-recovery","title":"Upgrade, rollback and recovery","level":2}],"markdown":"# Retention administration and holds\n\n`@clank.run/framework/retention-administration` provides one scoped operator inventory,\nreviewed purge batches, durable holds and periodic cleanup rules for import source metadata,\ncollaboration operations/receipts and signed platform audit exports. Application sources share\none SQLite database; the platform's audit inventory remains in its own control database.\nA browser-supplied scope is never an ownership assertion: the server resolves every resource\nfrom persisted source identity and checks the current session and operator policy.\n\n## Declare application sources and authority\n\nOpen the source services and retention service with the same application schema, auth definition,\nSQLite path and collaboration character limit. Keep scope and authorization callbacks synchronous\nand read their decisions from trusted persisted data. Declare a new `policyRevision` whenever the\nresolver or authorization policy changes; active schedules pause until reviewed under that revision.\n\n```ts\nimport { openRetentionAdministration } from \"@clank.run/framework/retention-administration\";\n\nconst retention = await openRetentionAdministration({\n  path: \"app.sqlite\", auth, schema,\n  sources: { imports: true, collaboration: { maxCharacters: 200000 } },\n  policyRevision: \"retention-policy/1\", intervalMs: false,\n  scope({ db }, resource) {\n    if (resource.kind === \"import\") return db.table(\"importScopes\").query()\n      .where(\"ownerId\", resource.ownerId!).first()?.workspaceId ?? null;\n    return db.table(\"documentScopes\").query()\n      .where(\"documentId\", resource.id).first()?.workspaceId ?? null;\n  },\n  authorize({ auth, db }, workspaceId, operation) {\n    const member = db.table(\"memberships\").query().where(\"workspaceId\", workspaceId)\n      .where(\"userId\", auth.requireUser().id).first();\n    return member?.role === \"owner\" || member?.role === \"admin\";\n  },\n});\n```\n\nMount `retention.handle` at `/__clank/retention` beside the existing auth/source handlers.\nThe service exposes browser queries and mutations; it does not expose agent actions. The native\nSQLite controller and scoped writer are private implementation details, not arbitrary-table SQL\nor cross-owner application mutation APIs. Async, rejected or non-boolean policies fail closed.\nThe controller refreshes the persisted session and reads current authorization inside the host\ntransaction, including when returning an exact previously accepted receipt.\n\n## Inventory, review and accept\n\n```ts\nimport { createRetentionAdministrationClient, mountRetentionAdministration }\n  from \"@clank.run/framework/retention-administration\";\n\nconst client = createRetentionAdministrationClient({ auth: browserAuth });\nconst dispose = mountRetentionAdministration(document.querySelector(\"#retention\")!, {\n  client, currentUser: () => currentUserId, scope: () => currentWorkspaceId,\n  kinds: [\"import\", \"collaboration\"], maxDeletes: 1000,\n});\n```\n\nThe widget displays current payload, receipt and history counts, the protected subset and active\nor expired holds. Select resources, choose a past cutoff and review the explicit batch before\naccepting it. Keep the same operation ID and exact preview when retrying an ambiguous result.\nThe server hashes a private snapshot of source data, policy, hold revision, current versions and\nselected rows. Changed input rejects with `RETENTION_STALE`; a fresh preview is required.\nCurrent authorization is checked again before either execution or receipt replay. Source removal\nand the accepted receipt commit together. Disconnects and process death cannot expose a partial\npurge. The browser preserves uncertain operation IDs while mounted, clears private forms/data\non detected account or scope changes/revocation, and drops responses after disposal.\n\nInventory pages contain at most 100 resources (default 50). Cursors pin the authorized inventory\nand policy generation. A changed authorized resource invalidates the cursor; denied resources do\nnot appear in counts, bytes or cursor contents. The global resource admission ceiling still\nincludes denied sources and can refuse a database that exceeds its declared capacity.\n\n## What the initial policy retires\n\n- Imports: only completed or cancelled jobs qualify. Retire retained chunks, row corrections and\n  their document-history copies; replace full correction/apply operation results with compact\n  expired identities. Job metadata, original source identity and operation fingerprint remain.\n  Replaying an expired accepted import operation returns `410 IMPORT_OPERATION_EXPIRED`; it\n  cannot execute again. Application records created or updated by the import remain unchanged.\n- Collaboration: retire old edit operations/receipts and their associated history. Advance the\n  persisted retry floor so old revisions cannot recreate a retired edit. Current text, document\n  identity, branches and their histories remain protected. Reconnect against current text;\n  edits older than the floor reject with `COLLAB_EDIT_CONFLICT`.\n- Platform audit: only independently acknowledged signed events qualify. Retire the local raw\n  event and any held signed envelope together. Unacknowledged events remain blocked even when\n  no signer is configured. Capture/export checkpoints and the increasing audit sequence remain,\n  so independent archive verification continues after local retirement.\n\nA record and its history group retire as one unit. If that group exceeds `maxDeletes`, the batch\nleaves it intact; choose a larger permitted bound. Protected rows are a subset of the displayed\ncurrent/history/identity totals, rather than an additional disjoint storage category. Byte counts are UTF-8\nlogical stored data, not file allocation. Purges do not erase SQLite pages, WAL, backups, independent\narchives, PITR journals, imported application values or protected document text/history.\n\n## Holds and schedules\n\n`hold(scope, resource, expectedVersion, reason, expiresAt, operationId)` creates or replaces a\nhold; use version 0 only for a missing hold. Expiry is a future UTC millisecond timestamp or null\nfor a continuing hold. `release` requires the inspected version. Hold versions increase across\nrelease/recreation, so a stale release cannot remove a replacement hold. Expired holds remain\ninspectable and consume capacity until explicitly released. A hold suppresses source-service\npayload/history pruning, collaboration receipt pruning and acknowledged-envelope removal across\nalready-open upgraded connections and restarts, including the database's global and per-document\nhistory cleanup. Existing source admission limits still apply. Held source history has a fixed\nper-database ceiling of 100,000 snapshots and 128 MiB of UTF-8 snapshot data. New holds and\napplication writes roll back with `RETENTION_CAPACITY` when they would exceed that bound;\ncleanup never drops held snapshots to admit more work. Release/expire holds and review the\nretention policy before resuming writes. Current source rows have their own source-service bounds.\n\nHolds follow the source identity after a trusted scope transfer and keep blocking retirement.\nTheir reasons are only visible in the original scope. Release requires the original hold scope and\ncurrent source scope to match; resolve a transfer under the application's trusted administrative\npolicy before releasing. Changing the resolver alone cannot silently clear evidence holds.\n\n`saveSchedule(input, operationId)` persists a versioned rule with kinds, minimum age, cadence,\nmaximum deletions and active/paused state. The creating browser session and operator identity\nremain its execution principal. `runDue()` is a trusted server entry point and returns the number\nof accepted occurrences. Each occurrence refreshes that session and current scope permissions;\nrevocation, capacity exhaustion or a changed policy pauses the rule with a bounded error code.\nReauthenticate, inspect and save the current version before resuming. Competing processes accept\none occurrence, and purge/receipt/next due time commit atomically. Missed cadences coalesce into\none current batch. A persisted cursor rotates through batches so held early resources cannot\nstarve later resources. At most eight due rules and 100 resources per rule run in one call.\n\nBackground execution is opt-in: set `intervalMs` between 1 second and 1 hour, call `start()`\n(default 60 seconds), or invoke `runDue()` from an existing trusted scheduler. Close the service\non shutdown. UTC timestamps avoid server timezone interpretation; the widget previews cutoff\nand hold expiry using the operator browser's local time.\n\n## Platform integration and capacity\n\nSet `ClankPlatformOptions.retention` with `policyRevision` and optional bounds/cadence.\nUse the same client with `url: \"/api/retention\"` and `kinds: [\"audit\"]`. Platform browser session,\nCSRF and recent-authentication middleware remain in force. Account operators access their own\naccount; organization owners/admins access their current organization; configured platform admins\nuse the existing administrator role. Scoped tokens, impersonation and machine credentials cannot\nuse this operator API. The standalone widget is available for an operator page; the existing\nplatform dashboard and CLI do not automatically mount these controls.\n\nPer database, defaults/maxima are 10,000/50,000 source resources, 100,000/100,000 accepted\nretention receipts, 64/128 MiB of receipt results, 10,000/50,000 holds and 1,000/10,000 rules.\nAccepted operation identities are never silently evicted. Receipt exhaustion rolls back the\nwhole operation. Choose capacity for lifetime accepted work and monitor growth. Schedule listing\nhas a 1 MiB stored-payload bound. Purge snapshots admit at most 16 MiB of source/history payload\nbefore loading it; each batch allows 1–10,000 current/history records. Oversized inventories or\nsnapshots return `503 RETENTION_CAPACITY` without partial deletion.\n\nAudit exporter outbox defaults/maxima are 10,000/100,000 envelopes and 64/128 MiB. Held\nacknowledged envelopes count toward these limits but are not redelivered. A full outbox still\ndelivers pending entries; when held acknowledgements occupy all capacity, release and explicitly\nretire the reviewed local data before capture resumes. Malformed or oversized new events remain\nexplicit failures while already signed pending entries can still be acknowledged.\n\n## Upgrade, rollback and recovery\n\nUpgrade every source writer before enabling retention. Shared schemas add defaulted import\n`expired` identities and collaboration `retiredThrough` floors; metadata remains registered with\nthe source services. Install these schemas before sealing a PITR schema. Ordinary application\nCRUD stays compatible, but old binaries that ignore holds/floors or reject new metadata must not\nwrite while retention is enabled. For rollback, pause schedules, unmount administration and keep\nupgraded source services while holds, expired identities and floors remain authoritative.\n\nNative retention tables are versioned with protocol 1. An unsupported protocol or malformed\npersisted hold/source metadata fails closed. A consistent database backup preserves them;\nrestoring a historical backup can revive pre-retirement retry identities. Reconcile holds, floors\nand accepted identities against the current trusted operator record before reopening restored\nsources. An independent audit archive keeps its own checkpoint and retention policy. No\ncross-database atomic restore or physical-erasure guarantee is provided.\n"}