Accepted
ADR 0007 — The versioned-write helper is the only mutation path
Every mutation of a versioned entity goes through one helper, enforced by a lint rule and a runtime guard
Complete versioning of every entity is a load-bearing promise in Cusp. To keep it, mutating a versioned entity must do three things together: update the live row, insert a snapshot into entity_versions with an incremented version number, and spill the snapshot to R2 if it exceeds the size threshold. These steps succeed or fail as a unit. One function owns that composition, and nothing else is allowed to mutate a versioned table.
The decision
All writes to a versioned entity go through versionedWrite() in src/shared/db/versioned-write.ts, called only from the repository modules under src/shared/db/repos/. The helper runs the caller's live-row statements and the snapshot insert inside a single D1 batch, so both commit or neither does. The version number is computed inside the insert with INSERT ... SELECT COALESCE(MAX(version_number), 0) + 1, so the assignment is atomic and the unique index on (entity_type, entity_id, version_number) always holds.
The versioned entity types are the canonical list in src/shared/db/versioned-tables.ts:
- user, role, permission, user_role — accounts and authorisation.
- setting, plugin_config — configuration.
- collection, field_definition, block_definition — content schema.
- document — page and entry content (documents also carry a revisions path).
- theme, plugin — installed packages, versioned by release string.
- api_token, media_metadata — credentials and media records.
Two layers of enforcement
Code review alone is fragile, so enforcement has teeth. A custom ESLint rule, cusp/no-direct-versioned-write, flags any db.prepare() whose SQL is an UPDATE, INSERT, DELETE, or REPLACE against a versioned table outside the repository layer, the migration runner, and tests. It catches static violations in the contributor's editor before they reach review.
A runtime guard catches what static analysis cannot — dynamically built SQL, generated code, an accidental import. versionedWrite() takes a REPO_CONTEXT symbol as its first argument and throws if the caller cannot supply it. Only the repository modules can reach the symbol, so application code that tries to call the helper directly fails loudly rather than silently skipping the snapshot.
if (context !== REPO_CONTEXT) {
throw new Error(
"versionedWrite() called from outside the repository layer. " +
"All mutations of versioned entities must go through src/shared/db/repos/* (see ADR 0007).",
);
}The one column-aware exception
api_tokens.last_used_at is written on every authenticated request. Minting a version snapshot per request would be wasteful and noisy, so a single sanctioned non-versioned write exists: touchLastUsedAt. The lint rule parses the SET clause and permits an UPDATE on api_tokens only when its columns are a subset of {last_used_at, updated_at} and include last_used_at. Every other mutation of api_tokens — mint, edit, revoke — goes through versionedWrite() like everything else. The exception is exactly one shape; widening it requires its own decision.
Consequences
- Positive — both layers fail loudly. Static analysis catches the common mistake at lint time; the runtime guard catches the long tail. The boundary is an actual error, not a documentation rule.
- Positive — the insert-only history this produces is what every downstream feature reads: version browsing, single-entity restore, site-wide restore, and pruning all rely on it.
- Negative — the ESLint rule is a small piece of code the project maintains, exercised by its own unit tests.
- Neutral — a contributor's first write to a versioned entity hits the lint error if they bypass the repository. The message points them at the right path. That is the intended teaching moment.
Reads are not constrained — the invariant is about writes. Non-versioned operational tables (sessions, migration_runs, restore_runs, backup_runs, import_runs) are deliberately outside the rule's table list and may take direct writes.
Read the versioning guide