PostgreSQL schema
Status: design draft for review · decided, not built
PostgreSQL is the system of record: one transaction commits the signed journal event, the metadata
rows it implies, and a durable outbox row. The rules that make that true — the per-tenant hash
chain, the synchronous journal durability domain, the outbox protocol — are declared in
storage; tenancy and RLS posture in
security. This page owns the table shapes. Every DDL block below is
a design sketch, not a migration: column names and types are normative in shape, and the
implementation may adjust spellings (e.g. enum types vs CHECK constraints) without a design
change. Nothing here is built or benchmarked.
Conventions used throughout:
- Every table carries
tenant_idand an RLS policy (sketched below). The journal's chain head gives each tenant an independent sequence; nothing orders events across tenants. - Digest columns are
byteaholding SHA-256 values. Journal and outbox payloads reference content only as digests or ciphertext references — never body plaintext (D1, declared in storage). uuididentifiers are server-assigned. The journal is the source of truth; most other tables are folds of it (classified in the last section).
journal and chain heads
The append-only event log, one hash chain per tenant. Event types and payload shapes are catalogued in event-catalog.
CREATE TABLE journal (
tenant_id uuid NOT NULL,
seq bigint NOT NULL, -- per-tenant; assigned under the chain-head lock
event_id uuid NOT NULL,
event_type text NOT NULL, -- see event-catalog.md
prev_hash bytea NOT NULL,
hash bytea NOT NULL,
sig bytea NOT NULL, -- ed25519 over signed_bytes (deployment key)
signed_bytes bytea NOT NULL, -- the envelope; covers digests, never bodies
payload jsonb NOT NULL, -- digest / ciphertext references only
principal text NOT NULL,
api_key_id uuid,
at timestamptz NOT NULL DEFAULT now(),
PRIMARY KEY (tenant_id, seq),
UNIQUE (event_id)
);
CREATE TABLE journal_chain_heads (
tenant_id uuid PRIMARY KEY,
last_seq bigint NOT NULL,
last_hash bytea NOT NULL,
last_sig bytea NOT NULL -- verified by the startup fork check
);
Chain assignment serializes on pg_advisory_xact_lock over the tenant's chain-head row, held to
commit, so commit visibility order equals seq order per tenant. The cost is stated plainly: a
tenant's journal writes are single-file; per-tenant write throughput is bounded by that lock and is
unmeasured (roadmap FR: Postgres scale ceiling).
Append-only enforcement is layered — privileges first, a guard trigger second:
REVOKE UPDATE, DELETE, TRUNCATE ON journal FROM PUBLIC;
REVOKE UPDATE, DELETE, TRUNCATE ON journal FROM oml_server, oml_lakewriter,
oml_reconciler, oml_indexer;
CREATE FUNCTION journal_guard() RETURNS trigger
LANGUAGE plpgsql AS $$
BEGIN
RAISE EXCEPTION 'journal is append-only';
END $$;
CREATE TRIGGER journal_append_only
BEFORE UPDATE OR DELETE ON journal
FOR EACH ROW EXECUTE FUNCTION journal_guard();
CREATE TRIGGER journal_no_truncate
BEFORE TRUNCATE ON journal
FOR EACH STATEMENT EXECUTE FUNCTION journal_guard();
A superuser can still bypass both; the design treats superuser access as deployment-key-equivalent compromise, stated in security.
memories
The fold of submission, review and lifecycle events into the current state of every claim version.
A memory asserts a claim; claim_key groups its versions
(audit-and-lineage owns the chain and tip semantics).
CREATE TABLE memories (
artifact_id uuid NOT NULL,
tenant_id uuid NOT NULL,
workarea_id uuid NOT NULL REFERENCES workareas (id),
claim_key text NOT NULL,
version integer NOT NULL,
state text NOT NULL
-- 'rejected' is the fold of review.reject; review.reopen returns the
-- row to 'held'. Rejected rows never reach the lake, so silver's
-- state list is unchanged.
CHECK (state IN ('held', 'promoted', 'demoted', 'rejected', 'erased')),
is_tip boolean NOT NULL DEFAULT false,
self_signed boolean NOT NULL DEFAULT false, -- constitution development exception (ADR-0001) only
writer_identity text NOT NULL,
producer text,
provenance text NOT NULL
CHECK (provenance IN ('agent', 'human', 'connector', 'simulation')),
simulation_id uuid REFERENCES simulations (id),
sim_derived_ids uuid[] NOT NULL DEFAULT '{}', -- taint: simulations in lineage closure
plaintext_digest bytea, -- journalled in the submission event: content identity
ciphertext_digest bytea, -- journalled in the same event: the key-less reconciler's comparand
body_ref jsonb NOT NULL, -- {class: quarantine|object|lake, ref: …}
kind text NOT NULL, -- memory | derived_fact | steering | …
created_at timestamptz NOT NULL,
promoted_at timestamptz,
PRIMARY KEY (tenant_id, artifact_id, version),
CHECK (provenance <> 'simulation' OR simulation_id IS NOT NULL)
);
-- claim_key scope: unique per (tenant, workarea) over non-erased rows;
-- a version joins exactly one claim lineage in exactly one workarea.
CREATE UNIQUE INDEX memories_claim_version
ON memories (tenant_id, workarea_id, claim_key, version)
WHERE state <> 'erased';
-- the default served tip: at most one version of a claim is what
-- non-audience readers get. Taint never enters this index: a tainted
-- version shadows the tip only for its simulation's audience (below).
CREATE UNIQUE INDEX memories_served_tip
ON memories (tenant_id, workarea_id, claim_key)
WHERE is_tip AND state = 'promoted'
AND provenance <> 'simulation' AND sim_derived_ids = '{}';
-- audience overlay tips: per simulation, a tainted promoted version may
-- shadow a claim's tip for that simulation's audience only. Simulation end
-- demotes overlay rows; the default tip above never moved.
-- (a lineage-tainted row with NULL simulation_id keys its overlay by the
-- sole live simulation in sim_derived_ids, resolved at promotion time)
CREATE UNIQUE INDEX memories_overlay_tip
ON memories (tenant_id, workarea_id, claim_key, simulation_id)
WHERE is_tip AND state = 'promoted'
AND (provenance = 'simulation' OR sim_derived_ids <> '{}');
CREATE INDEX memories_workarea ON memories (tenant_id, workarea_id, state);
The workarea-qualified index above is the enforceable spelling of the one-served-tip rule:
claim_key is namespaced by workarea, and a reassignment that would collide on claim_key is
refused unless the move event records a re-key, so (tenant, workarea_id, claim_key) is the
identity a tip is unique under — a (tenant, claim_key) shorthand would be either too strict
(one tip across workareas) or unenforceable.
Two invariants live in transaction logic, not DDL: a supersession promotion CAS-checks that its
target is still the current tip before flipping is_tip, and a claim moved to a stricter-policy
workarea re-enters held.
workareas and edges
The governed tree (one per tenant, rooted at universe) plus the two edge tables. The algebra —
node-id grants, deny semantics, status ladder — is declared in
workareas; this page holds only the shapes.
CREATE EXTENSION IF NOT EXISTS ltree;
CREATE TABLE workareas (
id uuid PRIMARY KEY,
tenant_id uuid NOT NULL,
parent_id uuid REFERENCES workareas (id), -- NULL only for universe
path ltree NOT NULL, -- DERIVED; rewritten in one transaction on re-parent
status text NOT NULL
CHECK (status IN ('development', 'committed', 'validated')),
crew jsonb, -- {name, charter_uri, external_manager}
created_at timestamptz NOT NULL
);
-- exactly one universe root per tenant; it is permanently 'committed'.
CREATE UNIQUE INDEX workareas_universe ON workareas (tenant_id)
WHERE parent_id IS NULL;
CREATE INDEX workareas_path ON workareas USING gist (path);
CREATE TABLE workarea_edges (
id uuid PRIMARY KEY,
tenant_id uuid NOT NULL,
src_id uuid NOT NULL REFERENCES workareas (id),
dst_id uuid NOT NULL REFERENCES workareas (id),
label text NOT NULL
CHECK (label IN ('relates_to', 'continues', 'derived_from', 'mirrors')),
state text NOT NULL CHECK (state IN ('pending', 'ratified')),
proposer_key_id uuid NOT NULL REFERENCES api_keys (id),
ratifier_key_id uuid REFERENCES api_keys (id),
CHECK (src_id <> dst_id),
CHECK (ratifier_key_id IS NULL OR ratifier_key_id <> proposer_key_id)
);
CREATE TABLE claim_edges (
id uuid PRIMARY KEY,
tenant_id uuid NOT NULL,
src_artifact_id uuid NOT NULL,
dst_artifact_id uuid NOT NULL,
label text NOT NULL, -- derives_from | relates_to | …
state text NOT NULL CHECK (state IN ('pending', 'ratified')),
proposer_key_id uuid NOT NULL,
ratifier_key_id uuid
);
The key-inequality CHECK is the cheap spelling of R2 on edges; the full rule — ratifier principal
distinct from proposer principal, resolved at ratification time — is enforced in the ratification
transaction, because a principal can hold several keys and a column constraint cannot see that.
path is derived: grants bind to node ids, and every path is recomputable from parent_id, so a
re-parent is a single-transaction descendant rewrite rather than a grant migration.
api_keys, grants, key_denials, bans
Authorization state. Key values are never stored — only their hash; issuance is journalled as issued-without-value (D9, declared in roles-and-keys).
CREATE TABLE api_keys (
id uuid PRIMARY KEY,
tenant_id uuid, -- NULL for provider-level operator keys
hash bytea NOT NULL UNIQUE,
role text NOT NULL
CHECK (role IN ('reader', 'writer', 'dreamer', 'admin', 'operator')),
principal text NOT NULL,
issued_by uuid REFERENCES api_keys (id), -- issuer ceiling checked at issuance
rotated_from uuid REFERENCES api_keys (id), -- successor link; predecessor revoked at grace end
expiry timestamptz,
revoked_at timestamptz
);
CREATE TABLE grants (
id uuid PRIMARY KEY,
tenant_id uuid NOT NULL,
key_id uuid NOT NULL REFERENCES api_keys (id),
role text NOT NULL,
workarea_node_id uuid NOT NULL REFERENCES workareas (id), -- node id, never a path
deny jsonb NOT NULL DEFAULT '[]', -- subtree masks inside THIS grant only
epoch bigint NOT NULL -- permission epoch invalidates caches
);
CREATE TABLE key_denials ( -- global per-key deny: deny-wins across all grants
id uuid PRIMARY KEY,
tenant_id uuid NOT NULL,
key_id uuid NOT NULL REFERENCES api_keys (id),
workarea_node_id uuid NOT NULL REFERENCES workareas (id),
epoch bigint NOT NULL,
created_by uuid NOT NULL,
created_at timestamptz NOT NULL
);
CREATE TABLE bans (
id uuid PRIMARY KEY,
tenant_id uuid NOT NULL,
subject_kind text NOT NULL CHECK (subject_kind IN ('principal', 'source')),
subject text NOT NULL,
banned_by uuid NOT NULL,
banned_at timestamptz NOT NULL,
lifted_by uuid,
lifted_at timestamptz
);
-- one active ban per subject; a lifted ban stays as history.
CREATE UNIQUE INDEX bans_active ON bans (tenant_id, subject_kind, subject)
WHERE lifted_at IS NULL;
Effective access is computed, never stored: union of a key's grants, each attenuated by its own
deny entries, then masked by any key_denials row. The epoch column is what makes cached
computations invalidatable and every historical computation reproducible from journalled grants
(workareas owns the permission-epoch semantics).
Bans are checked at admission; a ban's reach is previewable (--dry-run) before it is imposed.
simulations
The fence's mechanical existence (D10, declared in simulations).
CREATE TABLE simulations (
id uuid PRIMARY KEY,
tenant_id uuid NOT NULL,
state text NOT NULL CHECK (state IN ('open', 'ended')),
audience jsonb NOT NULL, -- journalled list of key ids / grant refs
opened_at timestamptz NOT NULL,
ended_at timestamptz
);
Every read path applies the predicate provenance <> 'simulation' OR key ∈ audience; the
memories.simulation_id and sim_derived_ids columns above carry the taint this predicate and
end-of-simulation demotion act on.
outbox, dead_letter, idempotency
The delivery machinery between the commit point and the lake, plus request replay protection.
CREATE TABLE outbox (
seq bigint GENERATED ALWAYS AS IDENTITY PRIMARY KEY, -- single drain stream;
-- assigned at insert, visible only at commit
tenant_id uuid NOT NULL,
kind text NOT NULL
CHECK (kind IN ('bronze-event', 'materialize', 'retract', 'export')),
journal_event_id uuid NOT NULL REFERENCES journal (event_id),
payload jsonb NOT NULL, -- digest / ciphertext references only
created_at timestamptz NOT NULL DEFAULT now()
);
CREATE TABLE lake_frontier ( -- CACHE of the per-table Delta txn version; never authority
table_name text PRIMARY KEY,
last_applied_seq bigint NOT NULL,
updated_at timestamptz NOT NULL
);
CREATE TABLE dead_letter (
outbox_seq bigint PRIMARY KEY,
tenant_id uuid NOT NULL,
kind text NOT NULL,
payload jsonb NOT NULL,
attempts integer NOT NULL,
last_error text NOT NULL,
skipped_event_id uuid NOT NULL, -- the journalled materialize-skipped event
dead_lettered_at timestamptz NOT NULL
);
CREATE TABLE idempotency (
key_id uuid NOT NULL,
workarea_id uuid NOT NULL, -- the nil UUID (00000000-0000-0000-0000-000000000000)
-- for endpoints that name no workarea
idem_key text NOT NULL,
request_digest bytea NOT NULL,
state text NOT NULL CHECK (state IN ('in_progress', 'committed')),
response jsonb,
created_at timestamptz NOT NULL,
PRIMARY KEY (key_id, workarea_id, idem_key)
);
Every outbox insert happens inside the same serialized transaction as its journal event, so outbox
seq order is consistent with each tenant's chain order. Across tenants nothing is serialized, and
seq is assigned at insert while visibility arrives only at commit — so a smaller seq can become
visible after a larger one has been read, and the identity column has permanent gaps (aborted
transactions) as well as transient ones (still-in-flight transactions). The lake-writer therefore
drains only the stable prefix: rows whose inserting transaction is older than every transaction
still active at scan time (below the oldest active txid of the scan's snapshot), as declared in
storage — a late-committing row is cut into a later batch, never
skipped. The Delta transaction version — not lake_frontier — is the recovery authority, which is
why the frontier table is labelled a cache (D2, declared in
storage). An idempotency record is claimed in
its own transaction before any side effect: same idem_key with a different request_digest is a
typed error; replay with the same digest returns the stored response.
read_events
The read log lives outside the hash chain, partitioned by time, retention-bounded, with per-batch digests anchored into the journal (D12, declared in retrieval).
CREATE TABLE read_events (
tenant_id uuid NOT NULL,
key_id uuid NOT NULL,
role text NOT NULL,
workarea_id uuid,
memory_ids uuid[], -- what was served, OR:
query_digest bytea, -- digest of the query for search reads
at timestamptz NOT NULL
) PARTITION BY RANGE (at);
-- e.g. monthly partitions; the retention act is DROP PARTITION, which is why
-- the table is partitioned at all. Partition granularity is an implementation choice.
CREATE TABLE read_events_2026_11 PARTITION OF read_events
FOR VALUES FROM ('2026-11-01') TO ('2026-12-01');
CREATE INDEX ON read_events_2026_11 (tenant_id, key_id, at);
The retention window is tenant policy; evidence exports scope their read log to that window. Dropping a partition removes read events wholesale — the digest anchors in the journal remain, so an auditor can see that a batch existed and was intact even after its rows are gone, but not what it contained. That trade (bounded storage against bounded reconstructability) is deliberate and stated in audit-and-lineage.
artifact_keys
Per-artifact DEKs, wrapped by the KEK. Erasure is DEK destruction (D4, declared in audit-and-lineage).
CREATE TABLE artifact_keys (
tenant_id uuid NOT NULL,
artifact_id uuid NOT NULL,
wrapped_dek bytea, -- NULL after destruction
kek_id text NOT NULL, -- which KEK wrapped it (rotation handle)
destroyed_at timestamptz, -- set by the journalled erase event's fold
PRIMARY KEY (tenant_id, artifact_id),
CHECK (wrapped_dek IS NOT NULL OR destroyed_at IS NOT NULL)
);
The erase transaction journals the erase event, sets wrapped_dek to NULL and destroyed_at, and
removes plaintext-derived index rows (FTS, embeddings) in the same commit. Ciphertext copies
elsewhere (lake parquet versions, object-store versions, Postgres backups) become unreadable, not
absent; the full copy-class inventory and its honest boundary live in
audit-and-lineage.
Full-text search and embeddings
The serving tier's two indexes over promoted rows. Both are plaintext-derived, so both are in the erasure inventory and are removed at erase time.
CREATE TABLE memories_fts (
tenant_id uuid NOT NULL,
artifact_id uuid NOT NULL,
version integer NOT NULL,
tsv tsvector NOT NULL,
PRIMARY KEY (tenant_id, artifact_id, version)
);
CREATE INDEX memories_fts_gin ON memories_fts USING gin (tsv);
CREATE TABLE memory_embeddings (
tenant_id uuid NOT NULL,
artifact_id uuid NOT NULL,
version integer NOT NULL,
workarea_path ltree NOT NULL, -- filter column for subtree-scoped queries
embedding vector NOT NULL, -- dimension bound to the embedding model: open RS
embedder text NOT NULL, -- plugin identity+version that produced the row (ADR-0019)
PRIMARY KEY (tenant_id, artifact_id, version)
) PARTITION BY LIST (tenant_id);
-- per tenant partition, created with the tenant:
CREATE TABLE memory_embeddings_t_<tenant> PARTITION OF memory_embeddings
FOR VALUES IN ('<tenant_id>');
CREATE INDEX ON memory_embeddings_t_<tenant>
USING hnsw (embedding vector_cosine_ops);
List-partitioning by tenant gives each tenant its own HNSW index (bounded build cost and blast
radius) at the cost of a partition-per-tenant management obligation; RLS still applies on top.
Iterative index scans are enabled so a post-filtered query keeps scanning rather than returning
short. Recall quality, latency, and exact-vs-approximate behaviour are unmeasured — tracked as
roadmap FR rows with exit criteria, and no number is asserted until that measurement runs. The
embedding model (and therefore the vector dimension and distance operator) is an open RS in the
roadmap. Simulation rows never enter these shared indexes; they are
indexed per simulation, which keeps the read-path predicate out of the hot index at the cost of
duplicate index machinery per open simulation.
upload_sessions
Resumable attachment uploads (D3, declared in storage).
CREATE TABLE upload_sessions (
id uuid PRIMARY KEY,
tenant_id uuid NOT NULL,
key_id uuid NOT NULL REFERENCES api_keys (id),
expected_digest bytea NOT NULL, -- SHA-256 of plaintext, declared up front
state text NOT NULL
CHECK (state IN ('open', 'complete', 'aborted', 'expired')),
parts jsonb NOT NULL DEFAULT '[]', -- [{part_number, size_bytes, part_digest}]
created_at timestamptz NOT NULL,
expires_at timestamptz NOT NULL
);
A retry resumes the session or restarts it; a session completes only when the assembled bytes match
expected_digest, at which point the digest — not the session — is what the submission envelope
binds.
Row-level security
Every table above carries tenant RLS; the posture (session roles, no BYPASSRLS, privileged-service scoping) is declared in security. The sketch, repeated per table:
ALTER TABLE memories ENABLE ROW LEVEL SECURITY;
ALTER TABLE memories FORCE ROW LEVEL SECURITY; -- binds the table owner too
CREATE POLICY tenant_isolation ON memories
USING (tenant_id = current_setting('oml.tenant_id')::uuid)
WITH CHECK (tenant_id = current_setting('oml.tenant_id')::uuid);
oml-serversetsoml.tenant_idfrom the API key at the start of every transaction — tenant is derived server-side, never from the request.oml-lakewriteriterates tenants one at a time under a SET-tenant session role; it never sees two tenants in one statement.oml-reconcilerruns read-only under the same per-tenant discipline and emits repairs through the outbox viaoml-server, so it needs no write policy at all.- Provider-level rows (operator keys with
tenant_id IS NULL) are reachable only by a separate policy onapi_keysfor the operator path; no tenant session can see them.
A current_setting-based policy is simple and index-friendly; its cost is that a service bug that
sets the wrong tenant GUC becomes a cross-tenant read. That risk and its mitigations (one
code path sets the GUC; the STRIDE model treats hostile co-tenants) are owned by
security.
Folds of the journal vs operational tables
Two kinds of table live in this schema, and the distinction decides what reconciliation (reconciliation) may repair by re-folding:
Source of truth — journal, journal_chain_heads. Never rebuilt from anything.
Folds of the journal — deterministically recomputable by replaying events from genesis (or incrementally from a cursor); a metadata repair is exactly such a journalled re-fold:
| Table | Folded from |
|---|---|
memories |
submission, review, lifecycle, simulation events |
workareas |
workarea create / re-parent / status / crew events |
workarea_edges, claim_edges |
relate / ratify events |
grants, key_denials, bans |
keys/grants family events |
simulations |
simulation open / end events |
api_keys (state columns) |
key issue / revoke events |
One honest exception inside that last row: api_keys.hash is operational secret material. The
journal records issuance without the key value (show-once ceremony), so a re-fold reconstructs
every key's existence, role, issuer and revocation — but not its hash. Losing the api_keys table
means re-issuing keys, not re-folding them.
Operational — state the journal does not define; losing it costs availability or history, not
truth: outbox (deliveries not yet applied are re-derivable from the journal fold, which is what a
lake repair does), lake_frontier (cache), dead_letter, idempotency (replay protection; loss
re-opens a replay window bounded by key behaviour), read_events (retention-bounded by design),
artifact_keys (key material — loss is unintended erasure, which is why its durability posture
matters), memories_fts and memory_embeddings (rebuildable from promoted bodies while DEKs
exist), upload_sessions (in-flight uploads restart).