ADR-0052 — Born-sealed clinical bodies: erasability as the shipped default¶
- Status: Accepted
- Date: 2026-07-17
- Refines: ADR-0005 (crypto-shredding + the
severity ladder — this ADR turns 0005 §2's "reserved, off by default" per-record seal into the
shipped default for clinical bodies, so every rung stays reachable for every event);
ADR-0006 (the safety projection —
this ADR states its previously-unwritten erasure semantics); ADR-0026
(the keystore, backup-as-cold-peer, shred-aware backups — the node unwrap key rides its escrow);
ADR-0048 (the per-type twin/floor-check dispatcher — the seal
door runs the same checks on decrypted plaintext); ADR-0051
(the strict-submit / lenient-apply door precedent this ADR reuses); ADR-0013
(blob bytes inherit born-sealed via the per-blob DEK); ADR-0012
(additive-only wire — the custody sidecar and the sealed
schema_versionare its application). - Errata:
- E1, appended 2026-08-11 (under decision 4) — §4's "any admitted peer" described an intent the code did not implement until #231. The decision is unchanged; only the record of what was built is corrected.
- E2, appended 2026-08-24 (under decision 4) — decision 4's derivation clause (the node unwrap key HKDF-derived from the Ed25519 signing seed) is superseded by ADR-0066, which makes it an independent X25519 keypair. Where E1 corrected the record of what was built, E2 makes no change of its own — it records one made by a superseding ADR, which is what the errata rule requires. The rest of decision 4 — including the no-second-ceremony rationale — stands unchanged.
- Resolves: #189 (2026-07-15 review finding C1, Critical/window-closing — the seal-by-default posture), #92 (2026-07-02 — the ADR-0005 erasure-ladder composition collisions).
Context¶
ADR-0005 resolved erasure as key-custody redistribution — crypto-shred a body by destroying its DEK, never the row. But its rungs only work on a body sealed under a per-record DEK at write time, and §2 reserves that shape while leaving it off by default (default deployments use whole-storage encryption). Data-model §3.5 concedes the shape "cannot be retrofitted onto an append-only log without re-encrypting history." Three consequences the corpus never states, all window-closing now that production clinical (medication) events are being authored:
- The default forecloses ADR-0005's own headline triggers. In a plaintext-default deployment the retention ceiling (scheduled, clock-driven destruction) and no-retention-basis subject requests hit ordinary plaintext events that are permanently un-shreddable. Rungs 2–4 are unreachable for the whole record; retention-ceiling compliance is impossible.
- "Sensitivity recognized later" cannot be honored. The clinically common case — stigma realized weeks after the fact — cannot retro-seal a body already replicated in plaintext to N nodes. The §5.9 sensitivity stream can raise visibility overlays but never claw plaintext back.
- Three composition collisions (#92) hide behind ADR-0005's "resolved" label. (a) The legibility
twin's location under seal is implied, never stated — a future implementer materializing
plaintext_twininto an FTS column turns every seal into a full-content leak. (b) Every ADR-0048 check fn reads the plaintext body, so the seal path either bypasses the mandatory-twin floor or needs a structurally different door — unspecified. (c) The mandatory clear-text attachment descriptor survives figure-granular shred by design ("photo of self-harm scars, left forearm" outlives the pixels). Plus: rung-2's "no discoverable institutional record" is unachievable without day-one preconditions no ADR wires in, and no ADR states the safety projection's own erasure semantics.
The deciding argument is principle 9 (policy-neutral infrastructure): a plaintext default silently forecloses rungs 2–4 for the entire record — that is the system taking a policy stance (against erasability). Born-sealed under the node's own custody forecloses nothing and hides nothing; it is the only genuinely policy-neutral default. The window closes with the first production clinical event, so this is a pre-production wire fix, not a migration (ADR-0040/ADR-0051 precedent: pre-production is exactly when to fix wire-freeze items).
Decision¶
Every clinical JSONB body is born sealed — sealed at write under a per-event DEK wrapped for the node's own key. This is an erasability substrate, not confidentiality: the node reads its own data freely, projections and full-text search behave exactly as before, nothing is hidden from anyone. Its sole effect is that every ADR-0005 erasure rung stays reachable for every event, forever.
Rejected alternatives, recorded: status quo (plaintext default, seal opt-in — permanently forecloses rungs 2–4 for ordinary events) and category-sealed at write (blacklist-driven — everything the blacklist misses is foreclosed exactly as the status quo, and the "recognized later" case still dies). No new founding principle: this is principles 1 (append-only), 4 (acknowledged uncertainty / declared-not-guaranteed deletion), 9 (policy-neutral infrastructure) and 12 (the unbypassable in-DB floor) applied to erasability.
1. Erasable vs. sequestered — one word split into two properties¶
The word "sealed" has been conflating two independent properties; this ADR names them apart:
- Erasable (new, the shipped default, universal for clinical JSONB bodies): body ciphertext under a per-event DEK whose custody includes the node itself. Hides nothing — the node decrypts its own data at will. It exists so the erasure ladder stays reachable. This refines ADR-0005 §2: what §2 reserved as "per-record encryption, off by default" becomes born-sealed under node custody, on by default for clinical bodies.
- Sequestered (the existing ADR-0005 rung-1 / ADR-0006 posture — graded, opt-in): the same DEK with custody narrowed (node → named clinicians/patient), the safety projection coarsened, the twin under seal.
Because both use the same DEK, sequestering an already-erasable event is a key-custody change, not a rewrite. This dissolves "sensitivity recognized later": no retro-encryption — just re-wrap or withdraw keys, plus an honest declaration of any already-replicated plaintext derivatives (of which, born-sealed, the log holds none). Sequester/custody-narrowing implementation is deferred (§8); the posture is ratified now.
2. Scope, stated normatively¶
Born-sealed covers clinical JSONB bodies only. The following stay plaintext by necessity — the machinery binds on them, so the ADR says so plainly rather than leaving it implied:
- Demographic assertions — the typed columns the matcher (§5.2) and coherence checks bind on.
- Identity-algebra events — the link/unlink/reattribute/identify/dispute stream.
- Node-plane events — federation, actor-registry, sync-control.
- Erasure-plane events — the shred tombstone (§6) must outlive all keys, so it can never itself be sealed under a key it may have to survive.
Blob bytes inherit born-sealed via the per-blob DEK already reserved in ADR-0013/ADR-0042
(SealRef { alg, dek_wrap }); implementation is deferred to the blob-tier slice (§8), the posture
ratified now.
3. Seal-then-sign, per-event DEK, the twin under the seal¶
The author seals, then signs: the signed payload carries the sealed container and the signature covers the ciphertext — so the signature verifies on every node and after shred, exactly as ADR-0005 requires. AEAD makes decryption deterministic, so signature-over-ciphertext + DEK-in-hand is non-repudiation of the plaintext. The ratified sealed-payload container shape is:
{"sealed": true, "alg": "xchacha20poly1305", "nonce": "<hex 24B>", "ct": "<hex>"}
AEAD is XChaCha20-Poly1305 (the seal.rs house algorithm); the alg field keeps the shape crypto-agile
under additive evolution. The
AAD binds event_id, so a sealed container cannot be lifted from one event and replayed under
another's signature.
The legibility twin travels inside the sealed region under the same DEK — the one normative sentence
92(a) asked for. A sealed twin must never be materialized into any plaintext index. The outer¶
§3.13 plaintext_twin on
a sealed row is a signed mechanical stub naming only type + seal state (principle 11: the row stays
honestly self-describing as what it is; a custody-holding reader recovers the real twin by decrypt).
The operational twin (the FTS/RAG substrate) is derived at door time into a mutable derived-plaintext
table (event_clear, §4/§6), never a plaintext column on the append-only row — the structural answer to
collision (a): on a sealed row there is no plaintext twin column to leak from.
DEK granularity is per-event (finest shred granularity, simplest keystore). A per-episode key hierarchy remains the ADR-0005 measurement question, now with a concrete hook — the accompanying slice's perf bench (§8).
4. The key-custody plane¶
- Wrapped DEKs live in a mutable keystore table beside the log — never inside the signed bytes.
Custody changes must not touch the immutable artifact, and an append-only row cannot hold rotating,
multi-holder custody. The table is
event_dek(event_id,holder,dek_wrapped). The reserveddb/001event_log.dek_wrappedcolumn is retired unused by this design — it is exactly the append-only slot that cannot carry rotating custody. It is left in place (dropping it fires no trigger but breaks the replay-idempotence discipline for no gain) and documented as superseded byevent_dek.event_log.sealedstays — it is an immutable birth property. - The node unwrap key is X25519, derived from the node's Ed25519 signing seed by HKDF with
info = "cairn-node-unwrap-x25519-v1". Wrapping needs only the public half, so the DB holds nothing secret — an ordinary DB backup can never reconstruct a DEK (the ADR-0005 "keys must not be silently reconstructable from ordinary DB backups" requirement, now structural). Deriving the unwrap key from the signing seed means the existing ADR-0026 op-passphrase + recovery-code escrow already covers it — no new key-management mechanism, and KEK escrow is therefore mandatory (KEK loss = whole-record loss). The public half is published as a signed unwrap-key certificate: CBOR body{"kid": <hex ed25519 pub>, "x25519_pub": <hex 32B>}, signed under its own ADR-0040 signing contextCTX_UNWRAP_KEY("application/cairn-unwrap-key+cbor") so it can never be replayed as an event, attestation, or pairing bundle. - Sync: custody travels as a wrapped-DEK sidecar beside the event on the clinical wire (additive wire field, ADR-0012). For erasable-tier rows the sender re-wraps the DEK for any admitted peer (custody follows admission trust). Sequestered rows ship ciphertext + safety projection only; no sidecar unless the peer is a named holder. A peer without custody admits the row and cannot read it — set-union losslessness holds; confidentiality lives in key custody, never in withholding rows (ADR-0006 confirmed, not amended).
Erratum E1 (2026-08-11) — the deferred hardening landed; the decision is unchanged. "The sender re-wraps the DEK for any admitted peer (custody follows admission trust)" described the intent; from the born-sealed slice until #231 the code did not implement it.
cairn-sync serveverified a puller's unwrap certificate against its own signature and self-consistency only — never the cert'skidagainst the trust set — so the qualifier "admitted" was unenforced and transport (WireGuard / mTLS on the serve port) was the sole gate on read-custody. Any self-signed cert reaching the port obtained the DEKs, and a DEK is what populatesevent_clearand opens the sealed plaintext, so this conferred clinical-data read, not merely a later shred capability. Until it closed, born-sealed was an erasability substrate and not confidentiality — which is also what blocked §5.9 part C (sequester, #376): narrowing a body's custody to two named clinicians was defeated by asking the serve port for the DEK.
servenow pins thekidtotrust_peer(db/007) — the same setrefresh_trust_setsnapshots for the mTLS cert-pin verifier, under the samestatus = 'active'grading — and withholds custody, never the events: the bodies still replicate as sealed ciphertext, because refusing them would fork the event set and wedge replication for no confidentiality gain. Fail-closed on every uncertainty, including an un-provisioned node plane; the operator line names which cause applies, since their remedies differ, and travels to the puller (whose chart is the thing that goes blank) rather than stopping at the serving node's log.Erratum E2 (2026-08-24) — the derivation clause is superseded; the rest of decision 4 stands. This erratum differs from E1 in what it points at, and the distinction is the one the errata rule in README draws: an erratum never changes a decision. E1 corrected the record of what was built. E2 changes nothing of its own — it records a change made by a superseding ADR, which is the route the rule requires for anything that alters a decision. Without the pointer, the clause below would go on describing a system that no longer exists. The clause — "The node unwrap key is X25519, derived from the node's Ed25519 signing seed by HKDF with
info = "cairn-node-unwrap-x25519-v1"" — is superseded by ADR-0066: the node unwrap key is an independent X25519 keypair with its own lifecycle.Why the derivation could not stand. ADR-0026 decision 4 mints a fresh signing seed on restore and never backs the old one up — deliberately, so a stolen medium can never resurrect a signing identity. A secret derived from that seed therefore changes on every disaster recovery, and every
event_dekrow a restored solo node inherits is wrapped to a public half whose private half no longer exists. Because this ADR made every clinical body born-sealed, the blast radius is the whole clinical record (#495) — "the exact outcome crypto-shredding is designed to produce, by accident." Both ADRs were internally consistent; the contradiction existed only where they meet, which is code.What is unchanged — everything else in decision 4. The custody plane (
event_dekbeside the log, never inside the signed bytes), the wrap/unwrap mechanics, the public-half-only database (so an ordinary DB backup still can never reconstruct a DEK), theCTX_UNWRAP_KEYunwrap-key certificate, the wrapped-DEK sync sidecar, and mandatory KEK escrow all stand. So does this decision's stated rationale for deriving — "no new key-management mechanism", meaning no second ceremony for the operator: the independent key is sealed under the same op-passphrase and the same recovery code, so the operator's escrow ceremony does not move. Only the derivation is dropped. E1 is unaffected, and decision 8's deferred unwrap-key rotation remains deferred.
5. Two doors — the floor makes born-sealed unbypassable¶
The seal posture is enforced at the in-DB submit/apply floor (principle 12), reusing the ADR-0051 strict-submit / lenient-apply asymmetry:
- Submit door (strict): it refuses an UNSEALED
clinical.*body — this is what makes born-sealed the unbypassable posture, not a convention a client can skip. The caller passes the signed sealed bytes plus the DEK; the door verifies the signature over the ciphertext, decrypts in-DB (XChaCha20-Poly1305 viacairn_pgx— the ADR-0002 pgrx escape hatch exists for exactly this), runs the full existing ADR-0048 twin/floor checks on the plaintext, builds projections + theevent_clearoperational-twin row, wraps the DEK for the node, and stores it inevent_dek. The floor stays unbypassable because it decrypts and checks in the database. A sealed event without its DEK is refused at submit — a door only authors what it can stand behind. - Apply door (lenient): with a custody sidecar it does the same, leniently. A foreign plaintext body is admitted (set-union losslessness — an older/foreign node's unsealed event is never rejected). A sealed event arriving without custody is admitted on structural checks only — can't read → never reject. Confidentiality is preserved (no key, no read); losslessness is preserved (the row lands and converges).
6. Shred (rung 3) and its blast radius¶
An audited rung-3 crypto-shred is three coordinated acts:
- Destroy the wrapped-DEK rows for the event in
event_dek(the mutable keystore — never the log). - Scrub all derived plaintext: the
event_clearoperational-twin row, every projection, and any FTS/RAG index — #92(b)'s mandatory index invalidation. A shred that leaves the body's text searchable is not a shred. - Append the signed
erasure.shred.assertedevent — the audit tombstone (existed → destroyed, basis Z), itself plaintext-by-necessity (§2).
The log row is never touched: its signature still verifies, set-union still converges, a resurrected
opaque row is keyless noise. Restore/re-sync replays the shred log before projecting — a cold-peer
restore (ADR-0026) re-applies erasures before it
builds any projection, so a restored backup can no more resurrect an erased body than a sibling can.
Custody is never granted to an already-shredded event (arrival-order independence): if a DEK sidecar
arrives after the shred tombstone, the door refuses to populate event_dek — the shred wins
regardless of message order. The shred log table is erasure_shred_log.
7. The prose-only closures¶
- Safety-projection erasure semantics (#92(c)): the §5.9 safety projection coarsens but survives a rung-3 shred — the Rh-after-termination case requires the signal to outlive the body. It is shreddable only at rung-4 best-effort oblivion, and its survival is named in the honest-erasure ceiling (the declared "what remains" list of ADR-0005 §5).
- Attachment descriptor (#189(c)): the §3.14 public descriptor is graded, mirroring the safety-projection coarseness ladder — the precise descriptor lives under the seal, the public stub's coarseness follows the sensitivity grade. "Photo of self-harm scars, left forearm" never sits in plaintext on a sequestered event; what survives a pixel shred is the coarse stub, declared in the ceiling.
- Rung-2 preconditions (#92(d)): deniable deletion is only reachable for episodes born pseudonymous with abstracted routing (§5.6 + the ADR-0006 envelope-abstraction dial) — stated as day-one preconditions, never offered retroactively. A record that was born identified cannot later become deniably-deleted, because the plaintext envelope already proved its existence. Prose only; no code this slice.
8. Deferred, explicitly named¶
- Per-episode DEK hierarchy — the ADR-0005 keystore-overhead-vs-shred-precision measurement question; measure via the accompanying slice's perf bench against the Bet-B latency budget.
- Shred authorization policy hooks — the gravity/authorization gate around rung-3 (ADR-0005 names the keystore safety-critical; the who-may-shred policy surface is later).
- Sequester / custody-narrowing implementation — the ADR-0006 mechanism beyond the minimal seal path (re-wrap/withdraw, the sensitivity-stream code).
- Blob-byte sealing — born-sealing the ADR-0013 byte tier (posture ratified now, code in the blob-tier slice).
- Unwrap-key rotation — rotating the node X25519 unwrap key (the derivation and escrow are fixed now; rotation mechanics later).
9. Interaction with ADR-0049 (sign-off currency)¶
Born-sealing changes what the ADR-0049 thread commitment
measures. ADR-0049's staleness compare reads a sealed thread's content-event set through
cairn_clear_payload (§3 above) — i.e. only events this node holds custody of contribute to the
recomputed commitment. The commitment is therefore a function of local custody, not, as ADR-0049
Decision-3 states, a pure function of the append-only content-event set. This breaks ADR-0049's own
stated soundness argument — "sound because thread content is append-only / grow-only" — for the
sealed, partial-custody case only: a node that is missing custody of a later sealed content event
cannot see that the set has grown, so its recomputed commitment can coincidentally match the pinned one
for a thread that in fact grew — a false-fresh read, corrupting the staleness signal rather than
honestly degrading it.
To hold ADR-0049's soundness in that case, reviewed_count — ADR-0049's "legibility hint only, never a
staleness authority" — is promoted, for sealed threads, to a safe-direction withholding tripwire:
whenever a node's readable content-event count for a thread (cairn_medication_thread_readable_count) is
less than the attestation's reviewed_count, the sign-off is forced stale/unknown, never reported
"current," regardless of whether the (necessarily partial) commitment happens to match. This refines
ADR-0049's "never a staleness authority" statement for the sealed case; it does not retract it. On an
unsealed or full-custody thread the refinement is inert — nothing is ever unreadable there, so the
readable count can never fall short of reviewed_count, the tripwire's condition never fires, and the
commitment compare alone remains the sole staleness authority, exactly as ADR-0049 states.
The added direction is safe by construction: the tripwire can only withhold "fresh," never assert it in a case the plain commitment compare would have missed — the same "err toward re-review" posture ADR-0049 Decision-3 already commits to, and principle 4 (acknowledged uncertainty — an imprecise near-truth over a precise untruth) applied to a staleness signal specifically.
Residual, tracked as a follow-up, not closed here: the tripwire is blind to the case where the
readable count coincidentally equals reviewed_count while the thread has in fact grown by a sealed
content event this node cannot attribute to the thread at all — a sealed-no-custody row it cannot even
count. Closing that gap needs thread-membership metadata that survives custody loss — a separate design
decision, deferred.
The accompanying walking-skeleton seal slice lands on clinical.medication (the only live
clinical-content stream) — the thinnest end-to-end thread through seal-at-write, the two doors, the
event_dek/event_clear/erasure_shred_log custody plane, the sync sidecar, shred, and restore-replay,
proving the collisions closed while they are cheap. PR number pending.
Consequences¶
Easier / gained:
- Every erasure rung is reachable for every clinical event, permanently — retention-ceiling compliance and no-retention-basis erasure become possible in a default deployment, which the plaintext default made impossible.
- "Sensitivity recognized later" dissolves into a key-custody change — sequestering re-wraps the same DEK; there is never plaintext to claw back, because born-sealed leaves none in the log.
- The three #92 collisions are closed structurally, not by convention: the twin lives under the same DEK and there is no plaintext twin column on a sealed row to leak (a); the seal door runs the same ADR-0048 checks on decrypted plaintext, so the mandatory-twin floor is neither bypassed nor forked (b); the attachment descriptor is graded, so a precise descriptor never outlives a shred (c).
- DEKs are unreconstructable from ordinary DB backups by construction (public-half-only in the DB), and the unwrap key rides existing ADR-0026 escrow — no new key-management surface.
Harder / the bet:
- The submit door now decrypts in-DB. The seal/verify/decrypt/check/wrap path joins the reviewed
safety-critical floor (§9); its strict-submit/lenient-apply split (refuse
unsealed
clinical.*and sealed-without-DEK at submit; admit both leniently at apply) is a subtle contract a future maintainer must not "simplify" into symmetry — the door's doc comment must carry the warning, as ADR-0051's does. - Seal/unseal cost lands on the Pi-class latency budget (ADR-0001).
Per-event DEKs are the simplest keystore but the largest key count; whether a per-episode hierarchy is
needed is the deferred measurement, now with a bench hook.
Measurement (
cairn-sync bench-seal, ~1.5 KB medication body, N=10 000, release, Apple-Silicon dev box): seal ≈ 15 µs, wrap ≈ 42 µs, unwrap ≈ 40 µs, unseal ≈ 12 µs → whole seal→wrap→unwrap→unseal pipeline ≈ 0.11 ms/event, ~37× under the Bet-B ~4 ms budget; the X25519 DEK-wrap dominates, the symmetric body AEAD is cheapest. Per-event DEKs are comfortably affordable on a dev-class node — the Pi-class re-run (the actual budget target) stays the deferred per-episode-hierarchy question. - KEK escrow is mandatory — born-sealed means KEK loss is whole-record loss. This is a real operational obligation the default now carries (mitigated by riding ADR-0026's existing recovery machinery).
- A node without the seal-capable binary can admit but not project sealed events — honest degradation per ADR-0012's two planes (moot pre-production; the pre-ADR-0052 plaintext dev/PoC logs are wiped, never synced through, per the ADR-0051 pre-clinical-posture precedent).
How we'd know it's wrong: if the per-event seal cost blows the latency budget on a Pi-class node and a per-episode hierarchy still cannot recover it (born-sealed too expensive to be the universal default), or if a real deployment needs an erasability semantics that born-sealed's rungs still cannot express — then the granularity or the ladder, not the born-sealed principle, is what needs rework. The born-sealed-as-policy-neutral-default argument is load-bearing; the DEK granularity is the replaceable part.