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 byscripts/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:
- a query filters, sorts, joins or aggregates on it;
- a constraint depends on it (uniqueness, a check, a foreign key);
- an authorization decision reads it;
- it is part of a lifecycle state, a version, or an identity;
- a retention, legal-hold or purge rule computes from it;
- 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
schemaVersioncolumn 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:
- Expand — add the new column, table or index, nullable and unused. Old and new code both run against it.
- 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.
- Verify — a query that proves the backfill is complete and correct, run against production data, with its output attached to the change.
- 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
Jsoncolumn, 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.