ADR-0031 — Canonical identifiers and node-local surrogate keys: the dual-identifier discipline¶
Context¶
Cairn's federation/fractal nature requires globally-unique, offline-mintable identity: any node, partitioned and alone, must be able to create events and entities that will never collide with another node's, with no coordination. We satisfy that with UUIDv7 (event_id, patient_id — §3.2) and content-addresses (content_address, actor_id, blob_address — multihash BYTEA, ADR-0015). These are the right identifiers for identity. They are the wrong identifiers for physical join keys, and in an EHR that distinction is a safety issue, not a convenience one: a record that is slow to retrieve fails paper-parity (principle 3).
The cost is concrete and lands in three distinct places in PostgreSQL (which is heap-organized, not index-clustered — so the penalty sits in the indexes and WAL, not in heap reorganization):
- Random insertion order → B-tree page splits, low fill-factor, buffer-cache churn, WAL full-page-image bloat. UUIDv7 already fixes this half (it is k-sortable → right-hand-side appends). Content-addresses cannot be helped this way — they are hashes, uniformly random by design.
- Key width propagated into every referencing index. A 16-byte
patient_id(or a 34-byte multihash) repeated across every projection row and every foreign-key index. UUIDv7 does nothing for this; it is the dominant cost as the FK graph fans out. - Wide random
BYTEAkeys (the content-addresses) are both random and wide — the worst of both worlds for any index that references them.
The conflation to defuse is "the fractal nature demands UUIDs (so they must be our keys)." Federation demands globally-unique identity; it does not demand that identity be the physical join key. Cairn already separates the two layers that own these two jobs — the signed, synced event core versus the per-node, rebuildable projection (§3.1: "Current state … is a projection materialized per node — rebuildable, cacheable, never synced itself"). The performance fix is therefore an instance of an existing architectural seam, not a new concept. This is also a can't-retrofit decision: once projections and their foreign keys are built around one identifier choice, changing it is a migration across the whole physical schema — so it is fixed now, before further implementation.
Decision¶
Two identifier planes, one discipline.
-
Canonical plane (global, signed, synced) is unchanged.
event_id/patient_idstay UUIDv7; content-addresses/actor-IDs/blob-digests stay self-describing multihashes (ADR-0015). These are the only identifiers that ever appear in a signed event body, on the inter-node wire, as a content-addressing input, or as a stable API identity. They are immortal (principle 2). -
Projection plane may intern canonical IDs to node-local
bigintsurrogates. A node maintains a private interning dictionary mapping each canonical ID to a densebigint(bigserial), used as the physical foreign-key and join key inside projections.bigint, neverint— a busy facility over decades can pass 2.1 × 10⁹ events; int8 headroom (9.2 × 10¹⁸) is free. -
The hard rule (leakage is silent cross-node corruption). A surrogate must never appear in a signed body, on the wire, as a content-addressing input, or as a stable API identity. If a surrogate leaked into signed content, two nodes would assign different integers to the same entity → divergent digests/signatures → set-union sync silently breaks. Leakage is made hard, not merely forbidden:
- Distinct domain types — a
local_refdomain (bigint) distinct from theuuid/byteacanonical types, so a function that accepts a surrogate where a global ID belongs does not typecheck. - Mapping is confined to the floor functions.
submit_event(ADR-0022) resolves global→local on ingress; the projection-read and sync-emit paths rehydrate local→global on egress. No other code path interns or de-interns. -
API egress is always the global ID — never a surrogate (a client bookmark must use the UUID; see rule 6).
-
Bind the pair once per entity, at its home/anchor row; references carry only the surrogate. The "carry both the UUID and the internal id as two fields" instinct is correct at the anchor row and on the already-signed
event_log(where the canonical UUIDs are mandatory and present anyway), and self-defeating on every referencing row. Carrying a 16-byte UUID and an 8-byte ref on every referencing row makes references larger than the pure-UUID design while still needing an index — it re-imports exactly cost #2. So: the UUID↔ref binding lives once per entity (the dictionary / anchor row, the small extra storage — 8 bytes per entity, not per reference); downstream references carry only theref; the UUID is recovered by a join to the anchor, and only at egress (one join per distinct entity, not per row — rendering a chart, the patient UUID is known once). Where profiling later shows a specific hot read path that the egress join hurts, denormalizing the UUID there is a permitted targeted covering optimization — the exception, not the default shape. -
Scope by where the cost actually is, confirmed by measurement.
- Strongest case — wide random
BYTEAreferences (content_address,actor_id,blob_addresswherever used as a join/FK key): keep the canonical digest in exactly one column of one row; everything that references it uses anint8surrogate. (One unavoidable random unique index oncontent_addressremains, for sync dedup — but it is no longer propagated across the FK graph.) - Next —
patient_idin the projection plane: the highest-fan-out FK in the system; the single biggest measurable win. - Leave
event_idPKs as UUIDv7 — written once, must byte-match the signed body anyway, a friendly k-sortable index, and not a high-fan-out join target; interning it buys little and burdens the hottest write path. -
Final magnitude/scope is measured on Bet B of Spike 0001 (the Pi board), exactly as ADR-0001's compute bet is — a "no measurable win" result narrows the scope rather than failing the discipline.
-
Surrogates are not durable identity, even locally. They are not stable across a projection rebuild (replay can reassign them in a different order) and never portable across nodes (only the global-ID event stream syncs). This is fine — projections are rebuildable and surrogates never persist off-node — but anything durable that must outlive a rebuild (a saved query, an analytic snapshot, a local bookmark, any persisted access-control artifact) must reference the canonical ID, never the surrogate.
Canonical home: data-model §3.18.
Consequences¶
- Easier: foreign-key indexes shrink from 16/34 bytes to 8 (≈3× on the patient/content-address fan-out), denser and more cache-resident — directly the paper-parity retrieval floor on Pi-class hardware (principle 3). Joins compare 8-byte integers, not 16/34-byte values. The seam is the one Cairn already has — projections are local and rebuildable — so this adds no new architectural concept.
- Harder: the interning dictionary is a write-hot serialization point on ingress (needs a concurrency-safe
INSERT … ON CONFLICT … RETURNING, with the unique index as backstop) — N fat random FK indexes traded for one fat random dictionary index, a net win that Bet B must confirm. Indirection (a join to rehydrate the UUID at egress) is added to read paths. The leakage hazard is real and silent; thelocal_refdomain type plus the floor-function chokepoint are the standing guards. - The bet: the dual-identifier discipline holds — surrogates never escape the projection plane, and the interning cost on ingress stays below its budget on weak hardware. We would know the bet fails if Bet B shows no material FK-index/join win (→ narrow or drop interning, keep UUIDv7-only), or if a surrogate is ever found in a signed body / on the wire (→ a
local_ref-domain or floor-function discipline breach, fixed in-DB). - No new founding principle. This is principle 3 (paper-parity — retrieval speed is a safety floor) and the ADR-0021 layering (the local physical schema sits below the wire core) applied to identifier representation. The canonical, immortal identity (principle 2) is untouched — the surrogate is merely the canonical ID's local handle.