Disciplines · Decisions (ADRs)

ADR-I1: Isis Aggregate Ownership and the Prohibition on Shadow Canonical Tables

Isis persists through a Postgres database reached by

Proposed · 2026-07-28
9sections7 minread

On this page

Status: Proposed Date: 2026-07-28 Authors: V1 Domain Workbenches persistence audit (Phase I, §I6.1) Reviewers: pending — Isis service owners (generation-api, output-registry, workflow-registry, ai-providers, operation-orchestrator) + data/platform architecture

This ADR records the owner per aggregate, the role split, the consistency decisions, and the prohibition that §I6.1 asks for. It is deliberately Proposed, not Accepted: ratification by the named service owners (I6.1.e) is a human governance step and remains open. Every quantitative claim below is drawn from deterministic evidence in evidence/v1-workbenches/, regenerated by pnpm inventory:v1-workbenches and drift-gated in CI.

Context#

Isis persists through a Postgres database reached by ISIS_DATABASE_URL ?? DATABASE_URL. An earlier inventory (I6.1.a) counted 24 Prisma aggregates from libs/isis/database/prisma/schema.prisma. That count is correct and the picture it gives is not.

schema.prisma is not the whole database. Two independent schema authorities are pointed at the same connection string:

  1. Prisma — 24 models, DDL in prisma/generated/schema.sql plus the migrations under prisma/migrations/.
  2. Service DDL — 23 tables created by CREATE TABLE IF NOT EXISTS executed by generation-api, output-registry, workflow-registry and ai-providers, plus the workflow-registry SQL migrations.

39 physical tables. Evidence: isis-aggregate-ownership.json.

The problem this ADR exists to stop#

Eight table names are declared by both authorities: lineage_edges, model_registry, provenance, retention_policies, workflows, workflow_versions, workflow_templates, workflow_stars.

CREATE TABLE IF NOT EXISTS is a silent no-op against whichever authority ran first, so one of the two writers is left querying columns that do not exist. The definitions do not agree:

Table Disagreement
provenance Prisma outputId/jobId/generatedAt vs service output_id/job_id/generated_at; foreign key to generated_outputs vs to outputs
retention_policies primary key id vs primary key name
workflow_stars zero column names in common across 4 Prisma columns and 6 service columns; surrogate id vs composite (workflow_id, user_id)
model_registry 19 columns matching in concept under two different physical names

Two further measurements shape everything else:

  • 18 of 24 Prisma models have no call site anywhere in non-test Isis code. The only Prisma family with a live access path is pipeline_state_*, reached through one repository — and it is also the only family missing from the committed generated/schema.sql. @isis/database's exported PrismaClient interface has no model delegates at all.
  • 10 independent connection pools target the one database (9 createPostgresClientFromUrl sites plus a Prisma client). A transaction belongs to a pool. Evidence: isis-consistency-seams.json.

Decision 1 — one system of record per record type#

Assigned by a recorded rule ladder, not by preference (I6.1.b). R1: a store nothing reads or writes owns nothing. R2: cascade-durability, as a filter, for records that must outlive their producer. R3: the live store that can hold most of the record's field vocabulary. R4: an owner in another domain's database. R5: unowned — a gap, not a choice.

Record type System of record Rule
plan service:workflows R3 field coverage
run service:jobs R3 field coverage
stage prisma:pipeline_state_phases R3 field coverage
output service:outputs R3 field coverage
gate verdict prisma:pipeline_state_checkpoint_decisions R1 live access path
provenance service:provenance R3 field coverage
release service:workflow_promotion_states R1 live access path
consent iris:consent_records (another database) R4 external
brief unowned R5 gap
variant unowned R5 gap
gate measurement unowned R5 gap
rights unowned, three declared-only claimants R1 no survivor

The costs of these choices are recorded rather than hidden. service:jobs wins the run record while carrying no tenancy column at all — no user, owner, organization, tenant or project — losing userId, organizationId, projectId and eight other fields the declared-only generation_jobs already models. Adding them back is I6.2 work. Consent is referenced from two Isis tables by consent_id with no foreign key, because the owner is in a different database; the oshun copy is a projection, not a second owner (it carries sourceRecordId and payloadHash).

Decision 2 — derived roles are owned separately, or the fact that they are not is recorded#

Evidence: isis-derived-ownership.json (I6.1.c). Of the six roles the ledger names, three are owned by a separate store (audit, outbox, read model) and two are carried inside the canonical row: derived index/cache (stats_runs, stats_stars, total_versions, access_count, a searchVector) and analytics (durationMs). A rollup living in the row it summarizes cannot be rebuilt or invalidated without writing to the system of record.

Blob is scored pointer-against-payload: storage_key in a canonical row is correct and is not a defect. The one real case is pipeline_state_phase_artifacts.inlineData, which keeps payload in the row.

Coverage is the part that matters. Seven audit stores exist, yet four of seven owners have no audit trail reachable from their table. There is exactly one outbox, covering one owner, so six systems of record mutate with no transactional publication path. I6.4 builds that; it does not extend it.

Decision 3 — cross-authority invariants are eventually consistent, and say so#

A transaction belongs to a pool, so ten of the resolved owner pairs cannot be written atomically together however much they share a database. Where an invariant must hold across owners it is published through the outbox and reconciled — never asserted by a shared transaction.

Within a pool, the transaction handle stops being optional. recordAuditLog(entry, executor = this.db!) currently writes output_audit_logs through an executor that defaults to the non-transactional client, so a caller who forgets the handle gets an audit row that survives the rollback of what it was recording. The executor becomes a required parameter, so omitting it fails to compile rather than failing to roll back.

The remaining seam decisions — one identity per table, a version column on every owner, one tenancy column name present everywhere, deletion as a state rather than an absence, one declaring authority per table in a committed migration, an explicit terminal-or-retryable status, and recovery as a property of the record — are recorded per seam in isis-consistency-seams.json (I6.1.d) with the 13 unresolved conflicts they answer.

Decision 4 — the prohibition, and how it is enforced#

A physical table has exactly one schema authority and exactly one canonical writer. A second authority declaring the same table name is a shadow canonical table and is prohibited, whether or not the two definitions currently agree — CREATE TABLE IF NOT EXISTS makes disagreement silent. A table created by a running service rather than a committed migration is prohibited for the same reason: it cannot express a change and it races every other process on the database. A system of record with no live access path is prohibited because something else is already serving its reads.

A prohibition nobody can enforce is a sentence in a document. The enforcement is scripts/v1-workbenches/isis-shadow-table-gate.mjs, wired into verify:inventory:v1-workbenches and CI. It is a two-way ratchet over the committed inventory:

  • a new dual-authority table fails the build;
  • a new runtime-DDL table fails the build;
  • a system of record that loses its live access path fails the build;
  • paying down a baseline entry without tightening the list also fails, so the ceiling cannot quietly become permission.

The eight dual-authority tables and thirteen runtime-DDL tables are recorded as measured debt, not as a target. Every entry is expected to leave. The gate cannot delete them — that is I6.2's expand/backfill/verify/contract work — but it stops the number growing while that happens.

Considered options#

Option Why not chosen
Declare Prisma the single authority and delete the service DDL 18 of 24 Prisma models have no call site; the service tables are the ones actually serving traffic. This would delete the working half.
Declare the services the single authority and delete Prisma pipeline_state_* is genuinely Prisma-owned with a live repository, and the Prisma models carry fields the service tables lack (job tenancy, cost, queue state) that I6.2 needs to migrate, not discard.
Leave both and document the overlap The overlap is not stable: CREATE TABLE IF NOT EXISTS means a fresh environment and a migrated one diverge silently, so the same code passes in one and fails in the other.
Per-table authority + a ratchet (chosen) Keeps both halves where each is real, names one owner per record type, and makes the debt visible and non-growing while I6.2 unwinds it.

Consequences#

  • I6.2's schema work has a concrete input: four unowned record types (brief, variant, gate measurement, rights), the eleven fields the run owner lacks, a version column per owner, and a single tenancy column name.
  • I6.4's outbox work covers six owners, not one.
  • New tables must arrive through a committed migration under a single authority, or CI fails.
  • Cross-owner invariants are written as reconciliation, not as transactions.

Open (human) decisions#

  • Ratification by the named service owners (I6.1.e). Until then this ADR is agent-authored and Proposed; the gate is nevertheless enforcing, because the prohibition it encodes is a restatement of what the evidence already shows breaking.
  • Whether pipeline_state_* moves onto the service pool, or the services move onto Prisma, is a migration decision this ADR deliberately does not make — it records that the split exists and that no transaction spans it.

Evidence#

Artifact Covers
evidence/v1-workbenches/isis-persistence-inventory.json I6.1.a current state
evidence/v1-workbenches/isis-aggregate-ownership.json I6.1.b owner per record type
evidence/v1-workbenches/isis-derived-ownership.json I6.1.c role split + data-flow diagram
evidence/v1-workbenches/isis-consistency-seams.json I6.1.d seven seams
evidence/v1-workbenches/isis-shadow-table-gate.json I6.1.e schema inventory check