Delta tables
Status: design draft for review · decided, not built
This page is the column-level specification of the lake: every Delta table, its Delta and Arrow types, its partitioning, its per-table commit protocol, and the maintenance that keeps it compact and erasable. The decisions these tables implement — Postgres as the sole authority (D1), the lake as derived body lakehouse with a single lake-writer and the outbox protocol (D2), pure-Rust delta-rs as the writer (D5), per-tenant isolation (D6) — are declared in architecture/storage.md and architecture/security.md and only instantiated here. The Postgres side of every row is in postgres-schema.md; the journal events the lake mirrors are in event-catalog.md.
One rule governs everything on this page: no zone serves client reads. Recall, search, fetch and graph reads are answered from Postgres (architecture/retrieval.md); the lake's consumers are the reconciler, lineage and metadata analytics, and future journalled per-audience exports. A consequence worth stating up front: Delta time travel makes superseded row versions readable to anything holding lake credentials until VACUUM removes them, which is one reason lake credentials are service-only and no client path touches these tables.
delta-rs capability citations (D17). Every delta-rs capability this page relies on — application transaction ids with check-and-record semantics,
MERGE,OPTIMIZE(bin-pack and Z-order),VACUUM, explicit checkpoint creation, conditional put throughobject_store— is a claim about a third-party library and must be cited from delta-rs documentation with a fetch date at writing time; as of this draft each is unverified. Markers below read (delta-rs — D17 note). One capability is cited negatively and is load-bearing: deletion vectors are read-only in delta-rs, so no table here assumes DV-based deletes — every retract and erase is a copy-on-write file rewrite. The exit criterion for the exactly-once claims is the delta-rs conformance-test FR in product/roadmap.md.
What each zone is for
| Zone / table | What it is FOR | What never enters it | Mutated by |
|---|---|---|---|
bronze.events |
Digest-only columnar mirror of the per-tenant journal: the substrate for event analytics and for cross-checking the lake against the chain without loading Postgres | Body plaintext, body ciphertext, held content of any kind — payloads are digests and ciphertext references only | Append only; never MERGEd |
silver.claims |
One row per promoted claim version with ciphertext body and full attribution: the reconciliation target (architecture/reconciliation.md) and the materialized record of who wrote what, when, in which workarea | Held bodies (materialization is enqueued by promotion only); self_signed development rows (the constitution's development exception, ADR-0001); plaintext anywhere |
Append on promotion; MERGE on retract |
gold.lineage |
Ratified edges of the claim graph and the workarea relation graph, for lineage analytics and the evidence export | Pending edges (the promotion-time lineage closure over pending + ratified edges is computed in Postgres, never here) | Append on ratification; MERGE on retract |
gold.areas |
Current snapshot of the workarea tree — node, parent, path, status, crew metadata — so lineage and analytics can join claims to governance context | Grants, denies, keys (authorization state stays in Postgres only) | MERGE-maintained snapshot |
gold.curated_<table> |
One table per validated workarea: the frozen area's promoted claim versions, ciphertext-bodied, as a stable curated set; the substrate for future per-audience decrypted exports (deferred — FR in product/roadmap.md) | New writes, promotions, reassignments in — refused by the status ladder itself (architecture/workareas.md); simulation-tainted rows (refused promotion into validated, architecture/simulations.md) | Created at validation; thereafter MERGE on demote/erase only |
The medallion words name these physical zones and nothing else; the workarea status ladder keeps its own words (architecture/workareas.md).
Type conventions
delta-rs writes through Arrow, so every column is specified in both systems. The mapping used throughout:
| Delta type | Arrow type | Used for |
|---|---|---|
STRING |
utf8 |
ids (UUIDs as lowercase text), enums, paths, hex digests, JSON payload text |
LONG |
int64 |
sequence numbers, versions, sizes |
BINARY |
binary |
chain hashes, signatures, signed envelope bytes, body ciphertext |
TIMESTAMP |
timestamp[µs, UTC] |
all instants — microsecond precision, UTC; nanosecond Arrow inputs are cast down at the writer (delta-rs — D17 note) |
DATE |
date32 |
partition columns derived from an instant |
ARRAY<T> |
list<T> |
taint id lists, attachment lists |
STRUCT |
struct |
attachment descriptors |
Two deliberate choices with their costs:
- Digests are lowercase hex
STRING, notBINARY. They read in any tool, and comparison against Postgres'byteadigest columns is a one-lineencode(digest, 'hex')on the Postgres side; the cost is 64 bytes where 32 would do, accepted for a column that is the lake's primary cross-system join key. - Statistics are disabled on
body_ciphertextcolumns (the writer restricts data-skipping stats to non-body columns — delta-rs — D17 note), so min/max fragments of ciphertext do not leak into the Delta log and its checkpoints. The erasure inventory still treats log and checkpoint files as ciphertext-bearing copy classes (defense in depth; see maintenance) rather than relying on this exclusion being perfect.
Per-tenant prefix layout
Each tenant gets its own physical Delta tables under its own prefix — the lake is not one shared table partitioned by tenant:
oml-lake/<tenant_id>/bronze/events/
oml-lake/<tenant_id>/silver/claims/
oml-lake/<tenant_id>/gold/lineage/
oml-lake/<tenant_id>/gold/areas/
oml-lake/<tenant_id>/gold/curated/<workarea_id>/
The benefit is mechanical tenant isolation: a tenant's lake is a prefix, its
erasure bound is a per-table property, per-tenant storage credentials can be
attached when storage is externalized (D6), and no query can cross tenants by
forgetting a filter. The cost is table-count proportional to tenants (and to
validated workareas, for curated tables) and maintenance that must iterate
tenants — accepted because the lake-writer already iterates per tenant under
an RLS-scoped session role
(architecture/security.md). Every table still
carries tenant_id in-row, redundantly with the prefix, so the key-less
reconciler can detect a row written under the wrong prefix as a finding rather
than trusting path layout.
Conditional put on S3/MinIO
Delta commits are files named by version; two writers putting the same commit
file is the lost-update failure that breaks the log. On S3 and MinIO, every
commit therefore requires conditional put (put-if-absent) through the
object_store layer (delta-rs — D17 note). The deployment pins a MinIO
version that supports If-None-Match
(operations/deployment.md); an object store
without conditional put is not a supported lake target, full stop — the
design refuses the "mostly single writer, probably fine" posture because the
lake-writer leader lease can and will overlap during failover, and conditional
put plus the per-table transaction-version conflict below is exactly the fence
that makes that overlap safe.
The commit protocol, instantiated per table
architecture/storage.md declares the outbox protocol; this section fixes how it lands on each physical table. The application transaction id (delta-rs — D17 note) is recorded on every commit:
| Table | txn appId |
txn version |
Skip rule on replay |
|---|---|---|---|
bronze.events |
oml-lakewriter |
batch_end (global outbox seq) |
skip if batch_end ≤ last committed version for the appId |
silver.claims |
oml-lakewriter |
batch_end |
same |
gold.lineage |
oml-lakewriter |
batch_end |
same |
gold.areas |
oml-lakewriter |
batch_end |
same |
gold.curated_<id> |
oml-lakewriter |
batch_end |
same; a table that does not yet exist is treated as version 0 |
Notes that only make sense at this level:
- The check is per physical table, which with per-tenant prefixes means
per (tenant, table). The global outbox
seqis monotonic across tenants, so one version counter per table stays monotonic no matter which tenants a batch touches. - The commit's transaction action also records the batch bounds
(frontier, batch_end); recovery reconstructs the identical batch from the outbox by seq range, never by time or size. Reconstruction is exact because everybatch_endis cut inside the outbox's stable prefix — bounded by the Postgres snapshot horizon, so no row with seq ≤batch_endwas still in flight at cut time and none can appear inside recorded bounds later (architecture/storage.md declares the cut rule). The Postgres frontier column is a cache; the Delta log's transaction version is the sole recovery authority. - A
gold.curated_<id>table is created by the batch that carries the area's validation event: table creation and the initial population commit carry that batch'sbatch_endas their transaction version, so a crash between creation and population replays deterministically like any other half-applied batch. - Reconciler repairs re-enter through the outbox as ordinary operations
carrying a journal fold seq; the lake-writer refuses an operation older
than the artifact's last applied
fold_seq(architecture/reconciliation.md), which is whysilver.claimscarries that column.
bronze.events
Append-only, digest-only mirror of the journal, one row per journal event. Its job is to make the event stream queryable in columnar form — event analytics, lag and volume studies, and keyless cross-checking of the chain — without ever holding content. Because payloads are digest-only by D1, bronze never participates in erasure: the D4 inventory lists it as "never needs touching", and no retract ever MERGEs it. A retract is visible in bronze the same way everything else is: as another event row.
| Column | Delta | Arrow | Null | Meaning |
|---|---|---|---|---|
tenant_id |
STRING | utf8 | no | Owning tenant; redundant with the prefix, kept for cross-checks |
journal_seq |
LONG | int64 | no | Per-tenant chain sequence |
event_id |
STRING | utf8 | no | Journal event UUID |
event_type |
STRING | utf8 | no | From the event catalog |
at |
TIMESTAMP | timestamp[µs, UTC] | no | Chain timestamp (real time; simulated time is a payload field, never this column) |
event_date |
DATE | date32 | no | at truncated to UTC date — partition column |
prev_hash |
BINARY | binary | no | Chain predecessor hash |
hash |
BINARY | binary | no | This event's chain hash |
sig |
BINARY | binary | no | ed25519 signature over signed_bytes |
signed_bytes |
BINARY | binary | no | The signed digest/ciphertext envelope — lets the key-less reconciler re-verify chain segments from bronze alone |
payload |
STRING | utf8 | no | Canonical JSON, digest/ciphertext references only — the same bytes the journal holds, never more |
principal |
STRING | utf8 | no | Acting principal |
api_key_id |
STRING | utf8 | no | Key that performed the act |
artifact_id |
STRING | utf8 | yes | Extracted for analytics when the event concerns a claim |
version |
LONG | int64 | yes | Claim version, when applicable |
workarea_id |
STRING | utf8 | yes | Workarea node id, when applicable |
simulation_id |
STRING | utf8 | yes | Indelible simulation provenance (D10) |
provenance |
STRING | utf8 | yes | agent | human | connector | simulation |
outbox_seq |
LONG | int64 | no | Outbox row that carried this event — joins bronze to the frontier |
Partitioning: by event_date. Reconciliation and analytics scans are
time-bounded, so daily pruning pays for itself; the cost is one small file
per day per tenant at low volume, repaired by bin-pack OPTIMIZE rather than
avoided.
silver.claims
One row per promoted claim version. The row exists because a promotion
event was drained from the outbox — held bodies never reach the lake, and
self_signed development rows never enter silver at all (the constitution's development exception, ADR-0001).
This table is the lake's purpose made physical: pure memory data with full
attribution — who and when wrote it, what space — with bodies encrypted at
rest, exactly as ADR-0003
commits.
| Column | Delta | Arrow | Null | Meaning |
|---|---|---|---|---|
tenant_id |
STRING | utf8 | no | Owning tenant |
artifact_id |
STRING | utf8 | no | Claim artifact UUID — MERGE key, with version |
version |
LONG | int64 | no | Version within the claim_key lineage — MERGE key |
claim_key |
STRING | utf8 | no | Groups versions of one claim |
kind |
STRING | utf8 | no | memory | derived_fact | steering | … |
state |
STRING | utf8 | no | promoted | demoted | erased | held — held is reachable only by the reassign-to-stricter-policy retract below, never by admission |
workarea_id |
STRING | utf8 | no | Stable workarea node id; updated by reassign |
workarea_path_at_write |
STRING | utf8 | no | ltree path as derived at write time — history, not identity; immutable, moves are read from bronze and gold.areas |
writer_identity |
STRING | utf8 | no | Writing identity |
principal |
STRING | utf8 | no | Acting principal |
producer |
STRING | utf8 | yes | Producing agent/crew identifier, when declared |
provenance |
STRING | utf8 | no | agent | human | connector | simulation — indelible |
simulation_id |
STRING | utf8 | yes | Set on every simulation row (D10) |
sim_derived_ids |
ARRAY<STRING> | list<utf8> | yes | Simulation ids in the row's lineage closure — the taint record |
plaintext_digest |
STRING | utf8 | no | SHA-256 of plaintext, lowercase hex, folded from the submission event — survives erasure; proves what the erased row was about |
ciphertext_digest |
STRING | utf8 | no | SHA-256 of ciphertext, folded from the submission event's ciphertext_digest (event-catalog.md) — what the key-less reconciler verifies against the journal fold |
body_ciphertext |
BINARY | binary | yes | Per-artifact-DEK ciphertext of the claim body; NULL when body_ref = object, and nulled by the erase MERGE; no column statistics collected |
body_ref |
STRING | utf8 | no | lake | object — where the ciphertext body lives; attachments are always object (architecture/storage.md) |
attachments |
ARRAY<STRUCT> | list<struct> | yes | Per attachment: plaintext_digest STRING, ciphertext_digest STRING, media_type STRING, size_bytes LONG |
created_at |
TIMESTAMP | timestamp[µs, UTC] | no | Submission time |
promoted_at |
TIMESTAMP | timestamp[µs, UTC] | no | Ratification time |
retracted_at |
TIMESTAMP | timestamp[µs, UTC] | yes | Set by the retract MERGE |
applied_seq |
LONG | int64 | no | Outbox seq of the last operation applied to this row — the per-row frontier column |
fold_seq |
LONG | int64 | yes | Journal fold seq of the last reconciler repair; the lake-writer refuses operations older than it |
Partitioning: none. The tempting partition column, workarea_id, is
updated by the reassign retract — a MERGE that changes a partition column is a
cross-partition row move, which turns the cheapest retract into the most
expensive one. Instead the table is kept unpartitioned (it is already
per-tenant) and clustered by periodic OPTIMIZE with Z-order on
(workarea_id, artifact_id) (delta-rs — D17 note), which serves both the
reconciler's per-artifact lookups and per-workarea analytics scans. Row
volumes are unmeasured (D17); the revisit trigger is a measured table size at
which a single OPTIMIZE run can no longer rewrite the working set within its
maintenance window.
gold.lineage
Ratified edges only, from both graphs: claim↔claim
(derives_from, supersession) and workarea↔workarea labelled relations
(relates_to, continues, derived_from, mirrors — zero access conferred,
architecture/workareas.md). Pending edges stay
in Postgres: the gate changes visibility, and an unratified edge is not
visible anywhere, the lake included. The promotion-time lineage closure (which
spans pending and ratified edges) is computed in Postgres at promotion
time; this table is for after-the-fact lineage analytics and the evidence
export, not for enforcement.
| Column | Delta | Arrow | Null | Meaning |
|---|---|---|---|---|
tenant_id |
STRING | utf8 | no | Owning tenant |
edge_id |
STRING | utf8 | no | Edge UUID — MERGE key |
graph |
STRING | utf8 | no | claim | workarea |
edge_label |
STRING | utf8 | no | derives_from, supersedes, relates_to, continues, derived_from, mirrors, … |
src_id |
STRING | utf8 | no | Source artifact or workarea node id |
src_version |
LONG | int64 | yes | Claim version; NULL for workarea endpoints |
dst_id |
STRING | utf8 | no | Destination id |
dst_version |
LONG | int64 | yes | As above |
state |
STRING | utf8 | no | ratified | demoted |
proposer |
STRING | utf8 | no | Proposing key id |
ratifier |
STRING | utf8 | no | Ratifying key id — never the proposer (R2 extends to edges) |
simulation_id |
STRING | utf8 | yes | Set when either endpoint is simulation-tainted |
proposed_at |
TIMESTAMP | timestamp[µs, UTC] | no | |
ratified_at |
TIMESTAMP | timestamp[µs, UTC] | no | |
applied_seq |
LONG | int64 | no | Per-row frontier column |
Partitioning: none; edge volume is bounded by claim volume and the table
is per-tenant. MERGE key is edge_id.
gold.areas
MERGE-maintained current snapshot of the workarea tree, one row per node.
History — status transitions, re-parenting, crew-metadata updates — is not
versioned here; it is read from bronze.events, where every one of those acts
is a journalled event. A snapshot plus an event mirror is cheaper and harder
to get wrong than a slowly-changing-dimension table, at the cost that
"what was the tree on date X" is a bronze replay, not a gold query.
| Column | Delta | Arrow | Null | Meaning |
|---|---|---|---|---|
tenant_id |
STRING | utf8 | no | Owning tenant |
workarea_id |
STRING | utf8 | no | Node id — MERGE key |
parent_id |
STRING | utf8 | yes | NULL only for the tenant's universe root |
path |
STRING | utf8 | no | Current derived ltree path; rewritten by the re-parent MERGE |
status |
STRING | utf8 | no | development | committed | validated |
crew_name |
STRING | utf8 | yes | Recorded, not operated (D7) |
crew_charter_uri |
STRING | utf8 | yes | |
crew_external_manager |
STRING | utf8 | yes | External manager identifier |
created_at |
TIMESTAMP | timestamp[µs, UTC] | no | |
updated_at |
TIMESTAMP | timestamp[µs, UTC] | no | Last applied change |
applied_seq |
LONG | int64 | no | Per-row frontier column |
Partitioning: none; workarea counts are governance-bounded.
gold.curated_<table>
One Delta table per validated workarea, at
gold/curated/<workarea_id>/, created by the batch that carries the area's
validation event. Its contents at creation: the area's silver.claims rows in
state promoted (simulation-tainted rows cannot exist in a validated area —
promotion into validated refuses them, D10). Its schema is identical to
silver.claims plus one column:
| Column | Delta | Arrow | Null | Meaning |
|---|---|---|---|---|
curated_at |
TIMESTAMP | timestamp[µs, UTC] | no | When the validation batch materialized this row into the curated table |
Keeping the schema identical means every tool, every reconciler comparison and
every maintenance job written for silver runs unmodified against curated
tables; the cost is carrying columns that are constant within one table
(workarea_id) or vestigial after freeze. After creation the only operations
the status ladder admits for a validated area are demote and erase
(architecture/workareas.md), so the only
writes this table ever sees again are those two MERGEs — applied to both
the curated table and silver.claims by the same outbox operation, under the
same transaction version, so the two can never disagree for longer than one
in-flight batch (and the reconciler reports it if they do).
Bodies here are ciphertext, exactly as in silver. The decrypted per-audience export of a curated table is a deferred, journalled feature — the FR row in product/roadmap.md — and until it exists, nothing reads a curated table but the reconciler and metadata analytics.
Retracts: MERGE semantics per table
A retract is a Delta MERGE (delta-rs — D17 note) applied in strict
outbox seq order, in the same single stream as materialization — no separate
retract lane, so ordering is free and head-of-line blocking is the accepted
cost (architecture/storage.md). Because
deletion vectors are read-only in delta-rs, every MERGE is copy-on-write: the
parquet files containing matched rows are rewritten, and the superseded files
become unreferenced — which is what the erasure section below is about.
| Retract | Trigger event | silver.claims |
gold.curated_<id> |
gold.lineage |
gold.areas |
bronze.events |
|---|---|---|---|---|---|---|
| Demote | demotion journalled | MERGE on (artifact_id, version): state = demoted, retracted_at set |
Same MERGE when the row's area is validated | — | — | New event row |
| Erase | crypto-shred journalled (D4) | MERGE: state = erased, body_ciphertext = NULL, attachments = NULL, retracted_at set; digests and attribution remain |
Same | — | — | New event row (digest-only; nothing to erase) |
| Simulation end | simulation-end journalled | MERGE demoting every row with that simulation_id; taint closure (sim_derived_ids containing it) demoted or flagged for re-review as the event records |
Cannot match (no sim rows in validated areas) | MERGE: state = demoted on edges carrying the simulation_id |
— | New event rows |
| Reassign | lifecycle.reassign (both policy snapshots journalled) |
MERGE: workarea_id = destination; workarea_path_at_write untouched (history); when the move records re-entry into held (stricter destination policy), state = held |
Reassignment into a validated area is refused upstream; out of one is refused by freeze | — | — | New event row |
| Re-parent / status / crew update | journalled admin act | — | — | — | MERGE on workarea_id: path rewrite, status, crew fields |
New event row |
Two honesty notes on this table:
state = heldin silver exists only via reassign. The invariant "no held body ever reaches the lake" is about admission: a held body never enters. A promoted body whose claim is moved into a stricter-policy workarea re-enters held in Postgres; its silver row already lawfully exists, so the retract marks itheldrather than pretending it is still promoted or silently deleting it (R4). Re-promotion MERGEs it back topromoted.- An erase that dead-letters leaves ciphertext in the live table version
until the journalled re-enqueue repairs it. The reconciler reports
dead-letteredas a named finding class andoml doctorsurfaces the count; the erasure SLO clock does not stop for a dead-lettered erase — it is a breach in progress and is treated as an operator condition (architecture/reconciliation.md).
Maintenance: OPTIMIZE, VACUUM, checkpoints
All lake maintenance runs inside the lake-writer process (D5) — same leader lease, same conditional-put fence, no second writer identity to reason about. The cost is that maintenance competes with the drain loop for the singleton's time; the schedule below is sized for that, and every cadence here is a configuration default of an unbuilt system, not a measured optimum (D17).
| Job | Tables | Default cadence | What it does |
|---|---|---|---|
OPTIMIZE bin-pack (delta-rs — D17 note) |
all | daily, per tenant | Coalesces small files (micro-batches and daily bronze partitions produce them by design) |
OPTIMIZE Z-order (delta-rs — D17 note) |
silver.claims on (workarea_id, artifact_id); gold.lineage on (src_id) |
weekly | Clusters for reconciler lookups and lineage scans |
| Checkpoint creation (delta-rs — D17 note) | all | explicit, every 100 commits per table | Bounds log-replay cost on open; delta-rs checkpointing is invoked by the writer, not assumed automatic |
VACUUM (delta-rs — D17 note) |
all | daily, retention = the table's erasure bound (below) | Deletes unreferenced parquet files older than the retention window |
| Log cleanup | all | with checkpointing, retention = same bound | Expires old Delta log entries and superseded checkpoints |
The erasure-SLO interaction
Erasure is crypto-shredding: the journalled erase event destroys the artifact's DEK, and from that instant the bytes are unreadable everywhere (architecture/audit-and-lineage.md owns D4 and the full copy-class inventory). What this section bounds is the narrower, honest remainder: how long ciphertext copies of an erased body persist in the lake before cleanup removes them. The copy classes here:
- The live table version stops holding the ciphertext when the erase
MERGE applies (it rewrites the row's files with
body_ciphertext = NULL). Bound: outbox drain lag, normally seconds to minutes; unbounded only in the dead-letter case called out above. - Superseded parquet files — every pre-erase version of the row's files,
including files rewritten by earlier OPTIMIZE runs — persist, time-travel
readable, until
VACUUMwith an expired retention window deletes them. - Delta log entries and checkpoints are treated as ciphertext-bearing until log cleanup expires them, even though body-column statistics are disabled — the inventory does not rest on a stats configuration being airtight.
One tenant-policy parameter, the lake erasure bound (default: 7 days),
sets both the VACUUM deleted-file retention and the log retention on every
body-bearing table (silver.claims, every gold.curated_<id>). The
documented erasure SLO for lake ciphertext is therefore:
drain lag + lake erasure bound + one maintenance cadence (the daily VACUUM/cleanup run that first fires after the bound expires).
With defaults: roughly eight days from erase event to no ciphertext copy in
the lake, stated as such in the evidence export rather than rounded down.
Shortening the bound tightens the SLO and shrinks the time-travel window the
reconciler can use for named-version comparisons
(architecture/reconciliation.md); that
trade is the tenant's to make, journalled as policy. bronze.events needs no
bound: it is digest-only by construction and the D4 inventory marks it "never
needs touching". VACUUM and log cleanup on bronze exist purely for storage
hygiene.
Decision record
This page instantiates D2, D5 and D6 at column level; the owning declarations are architecture/storage.md and architecture/security.md, and the register is adr/index.md (ADR-0003, ADR-0006, ADR-0007).
Lake write latency, maintenance cost and table-size ceilings are unmeasured, and no number is asserted for these tables until one is measured (D17).