Retention and legal holds

An auditor asks how long you keep things. Counsel asks you to stop deleting them. Both are answered from your own Worker: how long everything is kept is one block in its config, deployed like code, and a legal hold placed over the control plane stops every deletion that would touch its subject until somebody releases it.

One block says how long everything is kept

The retention block sits in the Worker’s pithy.config.ts, beside its capabilities:

retention: {
  purgeWindows: { user: "30d", organization: "90d" },
  audit: { default: "2y", prefixes: { "support/": "180d", "controlplane/": "7y" } },
  classAges: { pii: "3y" },
},

A duration is whole days as a number, or a count and a unit: 90d, 12w, 18m, 3y. A month is thirty days and a year is 365.

KeyWhat it governs
purgeWindowsHow long a deleted user or organization stays restorable before its purge. A subject left out keeps the window its own capability configures, retention.purgeAfterDays on auth() or organization()
auditHow long audit events are kept: default for any action no prefix names, absent or forever to keep them forever, and prefixes by action prefix, the longest match deciding. With no audit key nothing is pruned
classAgesThe longest an account may sit inactive, with no sign-in and no session refreshed, while it holds a privacy class. Past the strictest age among the classes it holds, it is deleted

It is code, so no route changes it. The Worker resolves the block when it assembles, and a block that does not parse, or that breaks the floor below, refuses to deploy. Nothing at runtime shortens it.

The kit holds a floor under the audit trail

The record of who managed the Worker is kept for at least a year. No rule keeps controlplane/*, or a lifecycle action a composed capability records (a deletion, a purge, a suspension, a reinstatement, a restore), for less than 365 days. A prefix rule that would is refused, naming the rule and the action it covers.

A default under a year is not refused. It applies to everything else, and the floored actions are held at a year, which the resolved policy lists as floor rules.

What each part does

Purge windows move the date a soft deletion comes due. Everything else about it stays as the account lifecycle and the organization lifecycle describe it: the mail, the restore, the purge.

The audit prune runs as a Workflow in your own Worker, daily at 02:00 UTC beside the purge passes, and removes each run of events past its window. It writes a tombstone for the run to the checkpoint bucket first, so pithy audit verify reads the chain as intact across the gap. Audit retention has the binding and what it never removes.

Class ages delete an inactive account the way any account is deleted. Auth marks an account’s name and email as pii, so a pii age reaches every account. Past its age the account is soft-deleted by a system actor, with a reason that says it was inactivity: the deletion-scheduled mail, the purge window, then the purge, and the erasure attestation proves it like any other. The clock is lastActiveAt on the account, stamped each time it opens or refreshes a session, and stamped on every existing account when the column arrived, so nobody is deleted the night you upgrade. A pass deletes at most 200 accounts.

POST /control-plane/holds
POST /control-plane/holds/:holdId/release
GET  /control-plane/holds

Placing one takes subjectType (user, organization or audit), the subjectId, and a required reason, at most 500 characters. An audit hold names the tenant whose trail it holds, or null for the untenanted trail. Releasing one takes a required reason. Releasing one already released is a 409, and an unknown id a 404. Both answer { hold }, and both record an audit event at warning, controlplane/hold_placed or controlplane/hold_released, with the subject as the resource. Those events are under the floor.

What a standing hold stops:

SubjectWhat it stops
A user or an organizationIts purge. The daily pass starts none, and a purge already running asks before every step and ends as held at the next one, with the steps that finished left finished. Once the hold is released, the next daily pass starts it again
A userThe inactivity deletion, which skips a held account
A tenant’s audit trailThe prune, which asks before every page of the chain, so a hold placed during a pass stops it at its next page

A hold can only preserve. No field on either route shortens a retention, and releasing a hold returns its subject to the policy the Worker was deployed with. Nothing deletes a hold: a released one is kept, with who released it, when and why.

The listing puts standing holds first and newest first within each, with active, the count standing. state filters to active or released, and limit defaults to 100, at most 500.

A hold names the person it preserves, so it answers for them on an access request. The control plane keeps its hold rows when a person is purged, because the record that a hold stood outlives the purge it allowed. An export says when each hold on the person was placed and released, never who placed it or why.

Reading it

The manifest reports the effective policy as retention, under manifest:read: every purge window in days with where it came from, the audit default, every rule longest prefix first with its source, what the floor covers, and each class age, strictest first. The control plane’s own manifest entry carries activeHolds, the count standing, behind retention:read. pithy doctor prints the policy per Worker and any hold standing in a deployed environment.

Two scopes, because reading holds and placing them are two powers. retention:read is a read, so pithy dashboard connect preselects it. retention:hold is a write: connect offers it and never grants it by default. A connection made before they existed holds neither, and adds them with --update, naming every scope it already holds beside the new ones. No CLI command places a hold, and the hosted dashboard does not yet either, so place one with a client of your own. The reference has the shapes.

ESC