Accepted
ADR 0040 — Pruning policies for versioned entities
A daily PrunePolicies workflow caps version history per entity type so D1 growth stays bounded
Insert-only history means version rows accumulate forever unless something trims them. A document edited daily for a year is hundreds of version rows; settings version on every menu reorder and every schema edit. Unchecked, that growth crashes against D1's storage ceiling on a timescale of months, silently until it is sudden. The PrunePolicies workflow caps history per entity type so the ceiling becomes an enforced invariant rather than a hope.
The decision
A PrunePolicies workflow runs daily and, for each versioned entity type, keeps only the most recent versions up to a cap. Defaults are tuned to the entity's edit cadence:
- document — 200 versions per document. Enough history for "restore last week's draft" while bounded.
- setting, plugin_config — 100 versions each. Settings change infrequently; 100 covers years.
- collection, field_definition, block_definition, media_metadata — 50 versions each. Schema and media metadata change rarely.
- theme, plugin — 50 versions each. These version by release string, so 50 is a multi-year history.
- user, role, permission, user_role, api_token — unlimited. These are an audit trail; see below.
- revisions (document drafts and autosaves) — 1000 rows per document. Autosaves are aggressive, so this cap is too.
Every cap is overridable through typed settings, reachable from the admin without editing config or touching a terminal. The keys are pruning.{entity_type}.max_versions and pruning.revisions.max_per_document, where 0 means unlimited. The workflow's entity-type loop is driven by the canonical versioned-types list, so a new entity type picks up a default policy without a workflow code change.
Hard delete with R2 cleanup
Pruning hard-deletes rows. There is no tombstone column and no soft-delete filter on every read path — the cap is the policy, and a tombstoned row whose R2 blob is gone has no consumer. For each deleted version row, the workflow checks state_r2_key (and a revision's blocks/data R2 keys) and best-effort deletes the R2 object. A failed R2 delete leaves a harmless orphan blob; the row is gone regardless. This mirrors the project's "the live row is the canonical state" posture.
Workflow shape and scheduling
One PrunePolicies workflow instance per run, with one step per entity type plus a final step for revisions. Per-entity-type isolation means a step that exhausts its retries leaves the other types untouched; the next run picks up the missed type. Within a step the walk is recomputed each run — the policy is monotonic, so no cursor needs persisting.
Scheduling is a long-lived workflow instance that uses step.sleepUntil to wake daily at 04:00 UTC — one hour after the daily backup, so a backup taken before the prune captures pre-prune state and stays the recovery escape hatch from a misconfigured cap. There is no cron trigger anywhere; sleeping instances are free and the Workflows API gives per-instance observability a cron entry cannot. The instance is created at install time by the setup handler, unconditionally — every deployment needs pruning, regardless of whether the operator seeded example content.
Why some entities are unlimited
user, role, permission, user_role, and api_token versions are unlimited by default because they are an audit trail. A breach investigation a year later needs to know who held admin on a given date; capping that chain erases the answer. A site with noisy permission automation can cap these through the override surface. The default favours preserving the audit trail; the choice is explicit and overridable, never silent.
Consequences
- Positive — the D1 storage ceiling becomes operationally enforceable rather than a best wish.
- Positive — per-entity-type overrides keep the policy honest across a quiet docs site and a high-velocity editorial site.
- Negative — the unlimited audit-trail default is a cost-creep surface under misuse; the per-entity override is the mitigation.
- Negative — best-effort R2 deletes can leave orphan blobs; a future sweep handles them if blob growth becomes observable.
Rejected alternatives: soft-delete tombstones (a column on a load-bearing table for a recovery use case with no consumer), per-write pruning (couples every write to a delete cost in the hot path), a single chronological pass (loses per-type retry isolation and per-type counts), and a cron trigger (mixes scheduling patterns and loses instance observability).