ADR-0070 — A late key reaches the chart¶
- Status: Accepted
- Date: 2026-09-15
- Refines: ADR-0057 (where projections are dispatched), ADR-0052 (the custody plane)
Context¶
#584: custody that lands after its event was admitted never reached the chart.
ADR-0057 decision 1 dispatches every projection from one AFTER INSERT trigger on event_log
(cairn_projection_dispatch_trg, db/005). Under ADR-0052 decision 5 both write doors write custody — the
wrapped DEK in event_dek and the clear shadow in event_clear — in their step 9, before their
event_log INSERT, so that the trigger can read the clear view in the same transaction. Every one of those
inserts is ON CONFLICT DO NOTHING, and ADR-0052 decision 4 lets a node without custody admit a sealed row
it cannot read.
Put together, a sealed event first admitted without its key, whose key arrives later, never reaches
the chart. The second apply writes event_clear, its event_log INSERT is a no-op, and the trigger does not
fire again. The body opens; the medication list stays empty. Every medication applier returned early on the
first arrival, because cairn_clear_payload(e) was NULL, and nothing ever ran it again.
Four entrances¶
Three were named in the issue; the fourth was found while designing.
| # | Entrance | Signal before this decision |
|---|---|---|
| 1 | cairn-sync pull --full re-offers events a peer first served without custody |
decide_custody's operator line named a second step, cairn_reproject() |
| 2 | cairn-sync requeue lands a retained pen row's key on an already-admitted event |
reproject_owed, exit 3, reported by that one run only |
| 3 | cairn-node restore applies the keyless copy of an event before its keyed copy |
none — exit 0, and a report identical to the harmless order |
| 4 | submit_event re-submits the bytes of an event already admitted without custody, with its DEK |
none — the strict door's step 9 has the identical shape |
Entrance 3 is why the remedy lives in the doors and not in the callers. restore's pre-door probe
answers only whether the event is in the log, never whether the copy that put it there carried a key, so
telling a late landing apart would mean adding a custody read before the door — in restore, and again in
every other caller, each agreeing on what the pair of readings means. A door is the only place that
knows, at the moment it happens, that the custody it just wrote belongs to an event already in the log.
What the audit established¶
Two read-only audits walked the live definition (the highest-numbered db/NNN that defines it) of every
applier registered in cairn_projection_apply, and the load-bearing claims were re-read by hand
(2026-09-15).
- Every medication applier is custody-gated and writes nothing without custody.
medication_statement_applyandmedication_cessation_apply(db/031),medication_dose_seed_initialandmedication_dose_change_apply(db/032),medication_dose_correction_apply(live indb/035),medication_reconciliation_apply(db/033),medication_attestation_apply(db/034), andmedication_coding_applyandmedication_coding_correction_apply(db/042) all open withp jsonb := cairn_clear_payload(e); … IF p IS NULL THEN RETURN;— no row, no flag, no placeholder. All areheal_safe = TRUE. Their cross-event lookups go through projection tables only (cairn_medication_thread_patient,medication_reconciliation, the read-time views); none keys on the existence of anevent_logorevent_clearrow. So "the target has no custody here" and "the target is absent" were already handled identically — the out-of-order case set-union sync had to solve anyway — and re-running only the late event's own heal-safe appliers is enough for every projection. - Every non-medication applier ignores custody by design. ADR-0052 decision 2 lawfully seals only
clinical.*bodies (the seal-robustness comment indb/005'ssubmit_event;db/002'spatient_chart_apply), so every demographic, identity, patient and sensitivity applier readse.bodybehindIF e.sealed THEN RETURN— or, forsensitivity_assertion_apply(db/048), projects a deliberately'unreadable'MAX-ranked row. None readsevent_clear, andevent_log.sealednever changes, so a late key cannot change anything these appliers read and re-running them is an idempotent no-op. That includes the onlyheal_safe = falseregistration in the tree,note.added → patient_chart_apply(thenote_countcounter): it sits behind the sealed guard, so a late key owes it nothing. The residual #584 asked to keep reporting — a custody-dependent projection that a heal could not re-run — is empty by construction, which is what licenses decision 3.
Rejected alternatives¶
- An
AFTER INSERTtrigger onevent_clear. It would cover everyevent_clearinsert automatically, but it fires inside step 9, before the substitution guard, so both doors would first have to move their guards ahead of their custody writes. - A call in
apply_remote_event(db/020) only. It leaves entrance 4 open. - A durable ledger of owed projections. Machinery for a case the audit's second finding shows cannot occur.
- A narrow owner-granted heal door that callers invoke. Entrance 3 has no caller-visible signal to
invoke it on unless
restorefirst grows a pre-door custody read of its own.
Decision¶
1. Custody arriving is a projection-relevant event¶
When a door newly writes event_clear for an event whose event_log row already exists, it runs that
event's heal-safe registered appliers once, over the stored row — after its substitution guard, in the
door's own posture, and only when the row is replay-eligible.
A door detects the case from its own statement counts: step 9's event_clear insert wrote a row, and the
event_log insert did not. The stored row is the row as first admitted, carrying the attestation
columns that admission stored — never a row rebuilt from this call's arguments. Each qualifier is
load-bearing:
- After the substitution guard. Step 9 writes
event_clearbefore the guard compares content addresses. Dispatching earlier would run appliers over a rival body filed under an existingevent_id. The guard's later RAISE rolls that back, but the refusal a caller reads could become whatever an applier raised first instead ofsubstitution refused— the reasonrestorepens and tests assert. - In the door's own posture. At
apply_remote_eventthe call runs whilecairn.remote_applyis stillon; the substitution guard moved above the marker clear to make room for it. Three medication projection checks — the only readers of that marker a late key can reach, out of the eight reader sites indb/— each admits a first arrival on the remote path:cairn_guard_medication_patient(db/031) writes amedication_patient_conflict_flag, the oversize-group check (db/033) writes amedication_projection_flag, and the cross-patient reconciliation refusal (db/033) is skipped, its contradiction surfaced at read time by themedication_group_cross_patientview. After the clear, all three RAISE, so a late key dispatched there would be refused and could never land. Atsubmit_eventthe marker is off and stays off: a late landing there is judged in the strict posture, exactly as a first arrival there would be. - Only when replay-eligible. A row carrying an
event_deferredmarker — admitted uninterpreted, or failed re-adjudication, whose marker stays until a later pass promotes the event — must never project (ADR-0056 decision 4, through ADR-0057 decision 3'scairn_replay_eligibleseam).
By position the call also comes after step 8's clear-view floor: the custody-less first admission skipped the per-type floor, and the keyed re-apply runs it on the clear view before anything is written.
2. One expression, called from both doors¶
The "run this row's heal-safe appliers" loop exists once in SQL, beside the dispatcher in db/005:
cairn_projection_dispatch_heal_safe(event_log). Re-adjudication's gate 4 (db/043), which carried its own
copy, now calls it too. It holds no eligibility filter, deliberately: gate 4 runs appliers over a row
whose marker is still present, as its proof that promotion is safe. The filter lives in
cairn_project_late_custody(uuid), which loads the stored row, returns unless the row exists and is
replay-eligible, and dispatches. Both doors call that one function. Both functions take the dispatcher's
shape — non-definer PL/pgSQL with search_path pinned, since every caller already runs as the owner — and
the appliers' grant posture, EXECUTE revoked from PUBLIC, because they write projections.
3. A projection that reads custody must be heal-safe¶
Enforced by a catalog guard, crates/cairn-node/tests/late_custody_guards.rs: every registered applier
whose body mentions cairn_clear_payload or event_clear is registered heal_safe = TRUE. With that
invariant, on every sequential path, a late key can never leave a debt that neither a door nor
re-adjudication pays — a deferred event's chart is owed to gate 4 by construction, never to a heal
command — so
requeue's reproject_owed — its field, its message and its exit-3 arm — is retired, not narrowed. A
signal that is zero by construction on every sequential path reads as a measurement; deleting it is the
honest form. Two cross-transaction races escape it, and are named as residuals under Consequences.
4. The healed state is arrival at custody time¶
After a late landing the chart equals "the event arrived at the moment its key landed", not "at its first admission". That is what set-union already guarantees for these projections; nothing stronger is promised. The residue this leaves is named under Consequences.
Paper-parity benchmark (§1.2)¶
Paper counterpart: a page that reached the ward before the chart it belongs in — held at the nurses' station, then filed once the chart turns up. Recovery is N = 1 act: file the page.
Counted on the recovery path once the underlying fault is repaired — an unregistered key, an unadmitted peer. That repair is a provisioning act with no paper counterpart, owned where the fault is: ADR-0066 and #512 for restore's keys, pairing for a peer.
| Entrance | Architecture-forced M, before | After |
|---|---|---|
requeue |
2 — requeue, then an owner-privileged cairn-node reproject |
1 |
pull --full |
2 — pull --full, then cairn_reproject() as the database owner |
1 |
restore, keyless copy first |
the record silently missing — worse than any M | 0 extra |
submit_event re-submit |
the record silently missing | 0 extra |
UI bundling target K = 1. M = N on every entrance: the decision removes an act rather than adding one.
Time budget: a late landing costs what the same event's first arrival costs — the identical registered
appliers, run once — plus one GET DIAGNOSTICS per custody write. No new runnable surface is exposed,
so no measurement is owed; the ordinary sealed-write cost is already measured (median 222 ms node-tier,
Slice 61) and gains no query.
Consequences¶
- ADR-0057's
AFTER INSERTdispatcher gains a decided WRITE-TIME dispatch site beside the trigger. The replay sites already existed —cairn_reprojectfeeds events through "the identical dispatch the live trigger uses" (ADR-0057 decision 2) and re-adjudication's gate 4 runs appliers over a stored row (ADR-0056) — so what is new is a dispatch inside a door's own write, not dispatch outside the trigger. It runs registered appliers only, so "a projection lives only in its registered apply function" still holds: the late-custody path adds a moment at which appliers run, never a place where projection logic lives. §9.4's projection bullet, which said "AFTER INSERTonly", now names this path. - ADR-0052's custody plane gains its missing half. A node without custody still admits a sealed row it cannot read; when the key arrives, the row now reaches the chart with no operator act.
- The operator signals that described the debt are gone.
requeue'sreproject_owed, its message and its exit-3 cause are retired; exit 3 now means only that rows were retained in the pen or are still refused by the door.pull --fullis one step:decide_custody's operator line names only it, and no longer namescairn_reproject().restore'sCustodyDidNotLandremedy ends atcairn-sync requeue. - Three tests that pinned the defect now pin the heal: a keyless-first
restorereaches the chart (restore_one_event_id_one_body.rs);pull --fullalone brings the chart back (clinical_pull.rs);requeuearm 1 exits 0 with the chart populated (requeue_retains_unlanded_custody.rs). The doors themselves are pinned bylate_custody_reaches_the_chart.rs, the two functions byheal_safe_dispatch.rs. - The healed state is arrival at custody time, and three projections carry arrival-order residue: which
event's
content_addressamedication_patient_conflict_flagnames; thepatient_idsnapshot on amedication_codingrow written before its statement was readable (coalesce(thread patient, e.patient_id),db/042— it differs from the statement's patient only when the coding event's envelope names a different patient, which is #192's cross-patient contradiction and is flagged in either order); and the reconciliation oversize clamp, measured at apply time. Each is the same residue the ordinary out-of-order case already has, none is widened here, and none is filed. The heal is not time travel. - A second invariant is pinned beside decision 3: the PL/pgSQL and SQL functions that
INSERT INTO event_clearare exactly the two doors, and each callscairn_project_late_custody. A third such function is a decision, not a drift — without the call it would reopen #584 through its own entrance. The shred'sDELETE FROM event_clear(cairn_execute_shred,db/037) is outside the rule: it removes custody, so it owes no projection. - The guard has residuals. Both of its rules read a function's own body (
pg_proc.prosrc), so a custody read reached only through a helper the applier calls is invisible. Every custody reader today callscairn_clear_payloaddirectly, and a positive control asserts the guard sees them, so it cannot pass vacuously. The second rule matches literal text: it recognisesINSERT INTO event_clearandINSERT INTO public.event_clear, but not aMERGE INTO event_clearor a write run through a dynamicEXECUTE format(...). None exists today; a new one must be reviewed by hand for the late-custody call. Its comment stripper removes--and/* ... */comments but is literal-blind: a call written after a comment marker inside a string literal would be missed. - Residual: a late key racing re-adjudication can leave a promoted record off the chart
(#603, filed, not fixed here). Decision 3 holds on
every sequential path; this is one of two concurrent interleavings under READ COMMITTED that escape it,
found by reasoning in the final review and not reproduced. A late key lands on a deferred event while
connect-time
cairn_readjudicate_deferredpromotes it.cairn_project_late_custodystill sees theevent_deferredmarker, because the re-adjudication's delete is uncommitted, so it skips the row; gate 4 cannot see the late key's uncommitted clear view, so its appliers project nothing and it promotes. Both commit with custody held and the chart empty, andrequeuethen exits 0 — where, before this decision, it would have reportedreproject_owed. - Residual: a shred racing a late key can resurrect custody, and since this decision the projection
too (#604, filed, not fixed here). Step 9's
anti-resurrection check (
NOT EXISTSovererasure_shred_log) takes no lock. A key landing concurrently withcairn_execute_shredsees no shred-log row yet and writes custody; the shred cannot see those uncommitted rows, so it scrubs nothing of them; and the late-custody call rebuilds the chart from the body the shred meant to destroy. The race predates this decision; this decision widens what it brings back. - Charts already missing a record on an existing database are not healed by upgrading. There is no
migration and no
SCHEMA_GENERATIONbump, so the loader's generation-change heal does not run;cairn-node reprojectstill heals such a chart. Under the pre-clinical posture no deployment holds one. - No wire-format change. The event core is untouched.
- A neighbouring weakness in the same marker is tracked separately:
#602, any client can set
cairn.remote_applybefore callingsubmit_event. It predates this decision, affects a first arrival at that door exactly as it affects a late landing, and is neither widened nor narrowed here. - How we would know the bet failed:
late_custody_guards.rsfails — an applier reads custody without being heal-safe, or a function that inserts intoevent_cleardoes not callcairn_project_late_custody— or a chart is found empty afterrequeueexits 0 by any path other than the #603 race.
The design and the implementation plan are
docs/superpowers/specs/2026-09-15-late-custody-reaches-the-chart-584-design.md and
docs/superpowers/plans/2026-09-15-late-custody-reaches-the-chart-584.md (working scaffolding, excluded
from the published site).