Disciplines · Conventions

Shared Persistence Conventions

The workbench platform is about to acquire shared entities that several domains read and write: revisions, gate runs, waivers, signoffs, staleness marks, audit events.

12sections13 minread

On this page

Status: Adopted for new shared workbench entities (agent-recorded) — data-platform and security ownership approval pending (§10) Date: 2026-08-02 Ledger: V1_DOMAIN_WORKBENCHES_TODOS_2026-07-23.md §S3.1

Normative for every entity introduced by Phase S3 and after. Descriptive, not retroactive, for what already exists — see §9, which says exactly how far the tree is from this document rather than implying it conforms.

Machine-checked companion: evidence/v1-workbenches/persistence-conformance.json, produced by scripts/v1-workbenches/generate-persistence-conformance.mjs.

0. What this document is for, and the state it was written in#

The workbench platform is about to acquire shared entities that several domains read and write: revisions, gate runs, waivers, signoffs, staleness marks, audit events. Those are the rows an incident review reads. A convention that arrives after them arrives after the decisions that matter.

The tree it lands in is not uniform, and the document is worth nothing if it pretends otherwise. As of this writing:

Fact Count
Prisma schemas 16
Drizzle configs 11
Models across the Prisma schemas 439
Models carrying a tenant or workspace column 129
Models carrying a creation instant 324
Prisma schemas with a migrations/ directory 13/16
Domains carrying both schema languages 3

The last row is the one to read twice. A domain with two schema languages has two migration stories, two type generators, and two answers to every rule below. This document does not resolve that; it names it, because a convention that silently assumes one dialect is a convention that is wrong in three domains and does not know it.


1. Data model (§S3.1.a)#

Naming. Entities are singular PascalCase in the schema and map to snake_case plural tables. A table name is a fact in a query plan and a log line; it is not a place for a domain prefix, because the schema is already namespaced by database.

Identity. Every row has one primary key, and it is opaque. The kit's @oshun/workbench-kit/identity types are the shared vocabulary (TenantId, WorkbenchId, EntityId, RevisionId, …) — namespaced strings with a strict parser, not bare UUIDs, so idEquals(workbench, tenant) is a compile error rather than a comparison that returns false. Sequential integer keys are prohibited on anything a client can see: a number that reveals how many rows exist is a fact about the business shipped in a URL.

Scope. Every row carries the scope it is isolated by — tenantId always, plus workspaceId where the entity is workspace-owned. Both are columns, never derived at read time through a join, because an isolation rule enforced by a join is one a missing join disables. A global reference table may omit them, and must say so in a schema comment naming this section; an undeclared omission is indistinguishable from a forgotten one.

Actor and revision metadata. Every mutable entity carries createdAt, createdById, updatedAt, updatedById. Append-only entities carry only the first two and are documented as append-only. updatedById is a column rather than a join to an audit table: an audit trail that has to join to answer "who" stops answering when the join breaks, and it breaks exactly when somebody is looking.

Tombstones. Soft deletion is deletedAt and deletedById and deletionReason. A deletedAt alone is a row nobody can attribute and nobody can restore with confidence. §S3.3 governs the lifecycle; this section governs the columns.

Relationships. Foreign keys are declared and enforced in the database. ON DELETE CASCADE is prohibited on anything carrying history: a cascade deletes an audit trail to keep a pointer tidy. Use RESTRICT and delete deliberately.

Extension fields. A domain may add columns to a shared entity only through an owned side table keyed by the shared row's id. Adding a column to a shared table for one domain makes every other domain's migration wait on that domain.


2. Columns versus payloads (§S3.1.b)#

A field needs an explicit typed column when any of these is true:

  1. a query filters, sorts, joins or aggregates on it;
  2. a constraint depends on it (uniqueness, a check, a foreign key);
  3. an authorization decision reads it;
  4. it is part of a lifecycle state, a version, or an identity;
  5. a retention, legal-hold or purge rule computes from it;
  6. an operator would grep for it during an incident.

JSON and blob payloads are permitted for exactly three things:

  • opaque third-party responses, stored verbatim for provenance, never read by application logic;
  • schema-versioned document content whose shape belongs to a domain plugin rather than to the platform, carrying its own schemaVersion column beside it;
  • denormalised read-model projections, which are rebuildable and therefore disposable (§S2.14 — a projection that cannot be thrown away is not a projection).

Everything else in a payload is a field somebody will need to query within a year, and the migration that extracts it then is strictly more expensive than the column that would have been declared now. The scanner's S3.1.b/json rule catches only the crudest version of this failure — a model that is majority payload — and passes 439/439 today; whether a given payload is genuinely opaque is a review obligation (§11), not a machine decision.


3. Temporal semantics (§S3.1.c)#

Instants are DateTime columns stored as timestamptz, always UTC. They are never String: a timestamp in a text column sorts as text, and two spellings of the same instant both exist in the table.

Serialisation at every boundary is ISO-8601 with a literal Z, second or millisecond precision — the kit's Instant shape. Offsets and local times are refused by the parser, so that string order is time order: every ordered log and every keyset pagination cursor depends on it.

Local dates and times — a publication date in an editorial calendar, an embargo local to a region — are a different type, stored as a date or time column plus a separate IANA time-zone column. A local date coerced to an instant is a date that moves when the server does.

Durations are integer columns with the unit in the name (maxAgeSeconds, respondWithinHours). A bare duration is a unit argument waiting to happen.

Clocks. Every timestamp written by the platform comes from the database (now() in the transaction) or from a single injected clock, never from an application server's wall clock. §S2.14 makes the reason concrete: two writers a hundred milliseconds apart on skewed clocks produce an audit trail that reorders itself, and both orders read as plausible — which is why ordering there comes from a sequence and the timestamp is data.

Precision is declared, not inherited. timestamptz(3) where milliseconds matter, timestamptz(0) where they do not; a column whose precision nobody chose is one whose round-trip equality nobody can predict.


4. Integrity and performance (§S3.1.d)#

Constraints belong in the database. Application-level checks run where somebody remembered to call them; a migration, a support script and a bulk importer all reach the table directly. The kit already proves this for its own invariants against a real engine (pnpm nx history-constraints, pnpm nx outbox-atomicity), and those harnesses are the template: express the constraint in DDL, then prove the engine refuses the broken row.

Uniqueness that matters is a UNIQUE constraint, not a SELECT before an INSERT. A read-then-write uniqueness check is decided by concurrency, and the kit's revision race harness exists because that lesson cost a run.

Indexes are declared with the query they serve named in a comment. An index with no named query is one nobody can remove.

Pagination is keyset, ordered by (sortColumn, id) with the id as the tie break. Offset pagination over a table that receives writes returns duplicates and skips rows, silently, and only under load.

Query plans are reviewed for any query on a table expected to exceed a million rows, and the plan is attached to the change. "It is fast on my machine" is a statement about the row count on that machine.

Row-level authorization is enforced by the scope columns in §1 and by a query layer that cannot construct a query without them. Enforcement that depends on every call site remembering a where clause is enforcement that has already failed somewhere in the codebase.

Encryption and sensitive fields. Secrets are never columns: they are references to a secret store. Personal data that must be stored is stored in named columns so that a retention rule and an erasure request can find it — a personal identifier inside a JSON payload is one no erasure request will reach. Free-text columns that cross a boundary are scanned with the kit's S1.10 detectors (scanText), which is what §S2.14 does for audit details.


5. Migrations (§S3.1.e)#

Every schema change is expand → backfill → verify → contract, as four separate deployable steps:

  1. Expand — add the new column, table or index, nullable and unused. Old and new code both run against it.
  2. Backfill — populate in bounded batches, resumable, with progress recorded. A backfill that cannot be resumed is one that cannot be run twice, and it will be.
  3. Verify — a query that proves the backfill is complete and correct, run against production data, with its output attached to the change.
  4. Contract — remove the old column or constraint, in a later deploy than the one that stopped writing to it.

Compatibility. Every intermediate state must be one where the previously deployed application version still works, because that is the state a rollback lands in. A migration that is only correct after the deploy completes is a migration that cannot be rolled back.

Destructive steps (DROP COLUMN, DROP TABLE, a narrowing type change) ship alone, in their own deploy, after the contract step's predecessor has been in production long enough to be confident. There is no compensating transaction for a dropped column; there is a restore from backup and an outage.

Rehearsal. Any migration touching more than a million rows is rehearsed against a production-sized copy, and the wall-clock time is recorded in the change. Lock duration is the number that matters, not row count.

Evidence. A migration is not complete until its verify query output, its rehearsal timing, and its rollback plan are attached. 13/16 Prisma schemas have a migrations directory today; the three without are the ones where none of this is currently possible, and they are the first to fix.


6. Generated clients (§S3.1.f)#

The schema owns the types. Generated clients and models are derived artifacts: prisma generate and drizzle-kit generate produce them, and the commands live in the project's targets so nobody has to remember the flags.

Generated output is checked in — 460 generated files are tracked today, and that is the right call for a monorepo where a consumer must be able to typecheck without running a code generator first. It comes with one obligation, which is the whole of the rest of this section.

Hand-editing generated output is prohibited. Not discouraged — prohibited. A hand edit survives exactly until the next regeneration, and it fails silently at that moment, in a diff nobody reads. Generated directories carry a header saying so.

Drift is checked, not assumed. A CI step regenerates and fails on a diff. A checked-in artifact with no drift check is a cache with no invalidation.

Version skew. The generator version is pinned in the pnpm catalog, and the client and CLI versions must match — a client generated by a newer CLI against an older runtime fails at the first query, in production, with an error message about a field.


7. Verification (§S3.1.g)#

The following are required against a real PostgreSQL, not a mock, not an in-memory substitute:

What Why a fake cannot answer it
unit-of-work boundaries "these two writes commit together or neither commits" is a property of an engine
concurrency a unique constraint is only interesting under two real connections
constraints a CHECK a fake accepts is a CHECK that does not exist
migrations expand/backfill/contract has to be run forward and back on real data
query plans a plan is produced by a planner with statistics
isolation levels phantom reads and serialization failures are engine behaviour
backup and restore a restore nobody has performed is a hope
compatibility the previous application version against the new schema, both running

The kit already ships four such harnesses (revision-race, history-constraints, restore-transaction, outbox-atomicity), and they share a discipline worth restating as a rule: a harness must contain a negative control that fails. outbox-atomicity injects the same failure twice — once inside one transaction, where both tables must come back unchanged, and once across two, where the control must leave an orphaned row. A harness whose control does not break has proved that it ran, not that it can fail.

They also fail rather than skip when no database is reachable. A skipped check that reports success is the evidence shape this ledger refuses.


8. Applicability to shared entities#

Shared entity (phase) Owner scope Append-only Soft delete Notes
Revision history (S2.4) tenant + workbench yes tombstone only immutability enforced by trigger, not by code
Gate definition (S2.9) tenant no retire, not delete retirement names a replacement
Gate run result (S2.10) tenant yes no one result per gate per revision, enforced
Waiver grant (S2.11) tenant yes never a revoked waiver stays readable
Staleness mark (S2.12) tenant yes no cleared by a recompute, not by a delete
Dependency change event (S2.12) tenant yes never per-node gapless sequence, UNIQUE (node, seq)
Signoff record (S2.13) tenant yes never superseded, never updated
Audit event (S2.14) tenant yes never per-object gapless sequence
Audit projection (S2.14) tenant no rebuildable disposable by definition; may be truncated

Every row in that table except the last is append-only or effectively so, which is the single largest difference between these entities and most of the 439 already in the tree.


9. Where the tree stands against this document#

evidence/v1-workbenches/persistence-conformance.json is the measured baseline. The headline numbers, out of 439 models:

Rule Conforming
has a primary key 399
carries a tenant or workspace scope 129
records a creation instant 324
records who last wrote it (if mutable) 273
temporal columns are DateTime, not text 435
is not majority JSON payload 439
soft-delete records a requester 400

This is not a to-do list and it is not an embarrassment. These models were written before this document existed, mostly for entities that are not shared, and rewriting 310 of them to carry a tenant column would be a large change with no user visible in it. What the baseline is for is the ratchet: --ratchet fails when a schema's conforming count goes DOWN, which is the only enforcement that works on a tree this size. New shared entities conform; existing ones do not get worse.


10. Approval (§S3.1.h)#

This document is agent-recorded and is not yet ratified. Two named approvals are outstanding and neither can be given by an agent:

  • Data platform ownership — §1, §2, §5 and §6 commit the platform to an identity scheme, a payload policy, a four-step migration process and a checked-in generated-artifact policy with a drift gate.
  • Security ownership — §1's scope columns, §4's row-level authorization, encryption and sensitive-field rules, and §7's verification list are the controls a security review would sign.

Until both are recorded with a name, a date and a review date, the corresponding ledger item (S3.1.h) stays unchecked. An agent may prepare the packet; it may not approve it.


11. What this document says and the scanner cannot check#

Written down so the gap is visible rather than assumed:

  • whether a JSON payload is genuinely opaque (§2) — a machine sees a Json column, not whether application logic reads inside it;
  • whether an index matches the query that needs it (§4);
  • whether a migration's verify query actually proves the backfill (§5);
  • whether a rehearsal happened and what it measured (§5);
  • whether the negative control in a harness genuinely fails (§7) — checked by breaking it by hand, once, per harness;
  • whether a global reference table's omission of scope columns was declared or forgotten (§1) — the declaration is a comment, and a comment is prose.

Each is a review obligation. Listing them is not a substitute for automating them; it is a statement about which parts of this document are currently enforced by a person.