ADR-0073 — The node plane refuses a substitution at both live doors, and pens it¶
- Status: Accepted
- Date: 2026-09-19
- Spec version at acceptance: 0.75
- Issues: #619 · #268 (one class carved out; the rest open)
- Relates to: ADR-0072 · ADR-0056 · ADR-0017
- Amends: ADR-0072's decision 1, whose shared refusal served three doors and now serves all five
event-log writers; and two premises of its #619 paragraph — that
db/009's tail-guard shape does not transpose todb/007, and that a RAISE on the node pull path can wedge the watermark (see Context). Takes on the residual ADR-0072 called its largest. Reverses nothing. The two statements in that paragraph that were false about the code — the wedge, and that set-union never re-offers the dropped rival — are corrected by errata E1–E2 appended to ADR-0072.
Context¶
ADR-0072 gave the write doors one shared substitution
refusal, cairn_refuse_substitution (db/053). A substitution is a second, different event
filed under an event_id the log already holds. The doors insert ON CONFLICT (…) DO NOTHING (every
arm but submit_node_event's genesis — see Residuals), so that a repeat of the same event stays a silent no-op — set-union, principle 1 — and that identical
no-op is what a substitution looks like from the INSERT's side. Without a comparison, the rival is
discarded in silence.
ADR-0072's own review found its census wrong: node_event has three writers, and only
restore_node_event (db/009) was guarded. db/007's submit_node_event (two
ON CONFLICT (node_event_id) DO NOTHING sites) and apply_remote_node_event (three) compared
nothing. ADR-0072 named that #619 and left it
open for two reasons. db/009's tail-guard shape did not transpose, since each db/007 arm had its
own RETURN. And choosing between refuse, skip-and-advance and quarantine at the live
admission gate looked like the open node-vs-clinical-plane divergence (#301 / #268), "where a RAISE
on the pull path can wedge the watermark." Decision 1 below answers the first by restructuring the
arms; the next section shows the second premise does not hold.
What the missing guard actually did — stated precisely¶
⚠️ #619's own failure scenario overstates it, and this record must not repeat it. Its scenario has a trusted peer B serve a rival
peer.revokedfor node C under an id A already holds; the rival is dropped, and "A keeps trusting C; B does not." That cannot happen.trust_peer(db/007) reads onlypeer/revokerows whoseauthor_node_idis this node's own (local_node), so no peer'speer.revoked— admitted, dropped or refused — has ever changed A's trust set. What B's rival does cost is below; A's trust in C is not part of it.
- The remote door (
apply_remote_node_event, the federation admission gate): a silent, permanent divergence of the replicated node plane. A holds X; a peer serves a different signed event under X's id, by an author A trusts; A's INSERT is a no-op, the function returns normally, the node puller counts itadmittedand advances past it, and every later full sweep repeats the same silent no-op. Two nodes hold different bytes under one id, and nothing says so. The sharpest case is a rival genesis.node_currentresolves a node's key fromenrollrows alone, so if a trusted peer's genesis is the one dropped, that peer's key never resolves on A, and every event it authors thereafter is refused as "author key … maps to no known node" — which the puller logs as "recoverable, non-fatal", and which it is not. - The local door (
submit_node_event): #615's shape. If A authors its ownpeer.revoked(C)under an id A already holds, the revocation is dropped, the door returns success, and A keeps trusting a peer it revoked. Reaching it needs A's signing key — the door refuses any signer but this node's own — so it is less reachable than the remote case, but what it corrupts is the trust set itself.
Both were pre-existing (db/007's doors predate db/053), and both were silent.
A RAISE does not wedge the node pull¶
619 (its point 2) and ADR-0072 both feared that refusing at the admission gate would wedge the pull¶
watermark. On the node plane it cannot. The admission gate's deliberate refusals are bare
RAISE EXCEPTIONs — SQLSTATE P0001, which is a contract: db/001 states it for the node pull
loop in the comment above cairn_decode_hex_or_raise (#228), and cairn-sync's
refusal_is_deliberate has relied on it since #267 — and the node puller's arm for a verifiable
event refused with P0001 skipped and advanced before this change. So the question was
never refuse or wedge. It was narrower: what should the puller do with a refusal it would
otherwise file as self-healing? (An error on a verifiable event that is not a deliberate RAISE
does freeze the node pull, and for a deterministic one the freeze is permanent: a verifiable event
whose event_id fails the gate's uuid cast raises 22P02 before any trust check. That
pre-existing case is
#621; nothing here changes it.)
The skip exists because a node-plane refusal is almost always scoping. stream_node_events
serves every row, so a puller routinely refuses events authored by nodes it does not peer with;
those are re-offered on the periodic full sweep and admitted once trust arrives. A substitution
never heals — the id is taken, so no sweep, trust change or upgrade makes it apply — and filing it
under skip-and-advance logs it as "recoverable, non-fatal" on each sweep and keeps no durable
trace that two different signed events exist under one id.
Decision¶
1. Both db/007 doors refuse a substitution — once each, after the branch. Each door is
restructured to the shape db/009 already has: every ON CONFLICT arm falls through to one shared
tail, which reads the stored content address unconditionally and calls
cairn_refuse_substitution once, naming its door. Two call sites, where there were five unguarded
ON CONFLICT sites. An arm that falls through to the tail inherits the guard; an arm that
RETURNs early bypasses it. submit_node_event's genesis arm does exactly that, and is safe only
because it has no ON CONFLICT: a colliding id raises unique_violation — loud, not silent. A
future arm written in its image with an ON CONFLICT would bypass the guard, and the catalogue
rule of decision 3 would not notice (see Residuals).
Each placement is one a later edit might "tidy" away, so each is stated:
- After the
IF/ELSE, never above it. Above the branch nothing is held yet,v_foundis NULL, andIS DISTINCT FROMrefuses — every clean write would be refused. It is the trap ADR-0072's mutation run exercised ondb/009(its M7), and this run's M4/M5 on the two doors here. - An unconditional read, no
GET DIAGNOSTICS ROW_COUNT—db/009's rule from ADR-0072. AROW_COUNTcheck is correct only while each INSERT stays the last statement of its arm; a later edit would disarm it silently. - The guard precedes the clock merge.
apply_remote_node_event's three copies ofcairn_node_hlc_mergefold into one in the tail, after the guard, so a refused rival never advances this node's clock. (The RAISE would roll the merge back regardless; the order says what is meant.) - Every existing refusal keeps its text and its order. Only the shared tail is new.
2. The node puller pens a substitution — classified by state, whichever check refused it. In
pull_into (crates/cairn-node/src/sync.rs), the arm for a verifiable event refused with P0001
now asks one question before it skips: does node_event already hold this event's event_id
under a different content address? The id comes from the body verify_self_described already
returned; the offered address is event_address(signed), byte-identical to the door's v_ca; the
held address is one primary-key read, held_content_address, in
crates/cairn-node/src/sync/substitution.rs. The decision itself is one pure function beside it,
substitution_reason(event_id, held, offered) — Some(reason) exactly when something is held and
it differs.
The lookup reads the event_id in the door's grammar, not the uuid crate's. The body carries
the id as a free string; the doors read it with Postgres's ::uuid, which accepts more spellings
than the crate — a hyphen after any group of four hex digits, braced or not. Read with the crate's
narrower parser, a rival whose id was the held one spelled differently was refused by the door yet
looked "not held" here, and was skipped: whoever minted a rival could switch the pen off by choosing
a spelling (found in PR #623's review). uuid_as_postgres_reads_it mirrors Postgres's
string_to_uuid; node_event_id_spellings.rs pins it against a live server. That one UUID has
many spellings at all is #624.
- Held, and different ⇒ a substitution ⇒ penned in
node_event_quarantine(db/022), through the same pen-or-freeze helper the unverifiable arm uses, with a reason that beginssubstitution:and names the id and both addresses. - Nothing held, or held and equal ⇒ skip-and-advance, unchanged. The routine scoping deny-all stays exactly where it was.
- The lookup fails ⇒ the cursor freezes, as it already does when a pen write fails: never advance past a refusal this loop could not classify.
Why by state:
- Not by SQLSTATE. Both pull loops route on
P0001. For the node loop,db/001's comment abovecairn_decode_hex_or_raise(#228) calls it a contract and forbidsUSING ERRCODEon that helper's refusals, because any other code freezes the cursor; for the clinical loop,cairn-sync'srefusal_is_deliberatepens a verifiable event's refusal only when it isP0001, since #267 (db/048restates that half). Andcairn_refuse_substitutionis shared with the two clinical doors, so a distinct code there would turncairn-sync's clinical pen into a freeze. - Not by the door's sentence. That would make English prose part of the protocol.
- State is what makes "whichever check refused it" true without reading any text. A rival signed by a key this node does not trust is refused by the author check before the door reaches its guard — and it is still a rival under a held id, still never applies, and is still penned. A classifier keyed on which sentence was raised would have filed it as scoping.
- The answer cannot go stale.
node_eventis append-only — its trigger refusesUPDATEandDELETE— so once a row holds an id, the content address under that id never changes.
What the pen then does is all existing behaviour. The row pins the derived re-offer floor, so the
rival is re-offered and re-refused every cycle, deduping onto its row; the cycle reports
pending > 0 and run logs its INTEGRITY line; the row never auto-releases, because
auto-release fires only when a re-offered event applies; and a human ack-quarantine silences it
for good (the dedupe bump leaves the ack in place). There is deliberately no per-event log line
— the unverifiable arm's convention, and for its reason: an acked row is still re-offered on every
full sweep, so a per-event line would keep printing after a human had decided. The loud signal is
the INTEGRITY line, which counts only unacked rows.
This is the first member of what sync §6.3 calls "genuinely refused history", to be told apart from the trust-graph deny-all — carved out because it alone can be recognised by state. It decides nothing about the rest of #268.
3. The inventory of guarded writers is derived from the catalogue, and pinned by name.
ADR-0072's census was a count made by hand, and so was the inventory test that followed it —
every_door_this_change_guards_still_calls_the_helper, a list of three migration files. A list
records what its author believed. It is replaced by
crates/cairn-node/tests/substitution_guard_covers_every_writer.rs, a rule over pg_proc in the
style of late_custody_guards.rs rule 2: every PL/pgSQL or SQL function in public whose body
(comments stripped) inserts into event_log or node_event must call
cairn_refuse_substitution. The writer set it derives is pinned by name — apply_remote_event,
apply_remote_node_event, restore_node_event, submit_event, submit_node_event — which makes
the pin the rule's own positive control (a rule that sees no writer passes over everything) and
turns a sixth writer into a decision rather than drift. The comment stripper both catalogue rules
use now lives once, in tests/common/sql_text.rs. The no-database
substitution_guard_is_single_source.rs stays: it proves nobody duplicates the refusal; the new
rule proves every writer calls it. db/053's COMMENT ON FUNCTION now names all five callers and
points at the new rule.
4. The published operator text moves with the code.
cairn-node quarantine --helpdescribed every row as a node_event refused "as UNVERIFIABLE". It now says unverifiable bytes or a substitution, and that a substitution never auto-releases.PullStats's doc describedquarantinedas "UNVERIFIABLE events penned"; it is widened the same way.run's INTEGRITY line told the operator to "fix trust/code orack-quarantine" — wrong for a substitution, which no trust or code change makes apply. It now says each row's reason tells which it is (unverifiable bytes, or a substitution) and to "fix the cause orack-quarantine".
Why the pull path pens where the restore path aborts¶
ADR-0072 decision 2 makes a substitution on a sneakernet medium abort the restore; this ADR
pens one on the pull path and carries on. The difference is what stopping would protect. A
restore that continued would bring a node back with a trust set decided by whichever copy the
medium ordered first — which whoever can append to the medium controls — and the operator's
remedy, finding another copy of the medium, is available. A pull that froze on a
substitution would hold every later event from that peer behind one rival, which is the reason
cairn-sync pens a door refusal rather than freezing (#267); a pen keeps the evidence durable and
loud and lets the rest of the stream through.
Alternatives rejected¶
- A distinct SQLSTATE for the substitution refusal, so the puller could route on it.
P0001is the contract both pull loops route on (db/001abovecairn_decode_hex_or_raisefor the node loop;refusal_is_deliberateincairn-sync), and the refusal lives in a helper the clinical doors share:cairn-syncwould read the new code as a fault and freeze where it pens today. - Matching the refusal's message text. It makes English prose part of the protocol, and it would still miss a rival refused by an earlier check.
- Five inline call sites, one per
ON CONFLICT. The smallest diff. Rejected by the maintainer's ruling for a single tail per door: two call sites, inherited by every arm that falls through to the tail, rather than one per arm to keep in step. - Refuse at the door, and skip on the pull path. It needs no puller change, and it files a record that can never apply under self-healing — logged as "recoverable, non-fatal" and kept nowhere durable. Rejected by the maintainer's ruling: pen it.
- All of #268 now — pen every genuinely-refused node event, not only substitutions. #268's own
obstacle stands:
stream_node_eventsserves every row, so penning the steady-state scoping refusals would hold the loud signal on permanently and exhaust the pen quota. The rest still needs the door to tell scoping from genuinely-refused history; a substitution needed no such distinction, because the table already says what it is. - Widening the catalogue rule to
actor_event.db/052'srestore_actor_registryhas the same silent-discard shape — that is #569, open — so the rule would fail on it today and pull #569 into this change. The test names it instead. - A new migration, to force a
SCHEMA_GENERATIONbump. It would have to re-declaredb/007's two functions in a later file — the stale-copy drift ADR-0048 removed — or exist only to move a number.
Consequences¶
- The cost, honestly. On the puller: at most one primary-key read per verifiable
P0001refusal on the node plane (none when Postgres could not read theevent_idas a UUID). A full sweep — every tenth cycle (FULL_SWEEP_EVERY), and whenever trust changes — re-offers every row a peer serves, so the scoping refusals, the routine case, pay it again each sweep. On the doors: one primary-key read per event through either door, re-applies included (the local genesis arm aside), since the read is unconditional. The node plane is small —db/007's own comment puts it at tens of events — and the clinical plane pays nothing:cairn-sync,db/005anddb/020are unchanged. - A new loud signal. A substitution keeps the pull loud until a human acks it. That is the
intent: it fires only when two different signed events exist under one
event_id— evidence that some signer minted an id already in use, or that a relay re-wrapped a signed event — the COSE unprotected header lies outside the signature (#620) — by bug or on purpose. - The guard makes a substitution loud; it does not decide which event is genuine. Whichever reached this node first holds the id; the pen row names both addresses so a human can find both. A rival genesis is now refused and penned rather than dropped in silence — but the id is still taken, so that peer's key still never resolves here and its events are still refused. This ADR makes that visible; nothing in it repairs it.
- Two pinned counts were checked, and one moved.
hlc_merge_helper.rsnow pinsdb/007at onePERFORM cairn_node_hlc_merge(site, down from three, because the three arms' merges folded into the shared tail;db/001's comment now counts three callers across the tree.hex_decode_helper.rs's pin of fourcairn_decode_hex_or_raisecalls indb/007survived the restructure unchanged. SCHEMA_GENERATIONstays 53.db/007is edited in place; there is no new migration. See #605 below.- Tested per arm, and by mutation. Every guarded arm has its own rival case
(
node_plane_one_event_id_one_body.rs: the local door's peer/revoke and supersede arms; the admission gate's enroll, supersede and peer/revoke arms), each door has an idempotence case — the same event twice still succeeds — and the pull path hasnode_substitution_is_penned.rs, which also pins the false-positive direction (a refused event held with the SAME bytes is skipped, never penned), that a pen lets the rest of the stream through, that an unacked row survives the next sweep as the same row, and a rival whose id is the held one spelled differently. The lookup-failure freeze is pinned bynode_substitution_lookup_freezes.rs, which runs the pull under a role granted everything it needs exceptSELECTonnode_event— and, with a second role that cannot insert a pen row, the pen-failure freeze.every_arm_of_the_admission_gate_merges_the_clockpins where the one shared clock merge sits, which a call count cannot. Sixteen mutations were run withscripts/mutations/2026-09-19-619.sh: deleting either door's guard (M1, M2, and M3 against the catalogue rule alone), hoisting either guard above itsIF/ELSE(M4, M5), inverting the pure decision (M6), removing the puller's question (M7), blinding the lookup (M8), deleting the shared clock merge (M9), turning the lookup-failure freeze into a skip (M10) and addressing the whole frame rather than the signed bytes, so that every refused re-offer of an event already held with the same bytes would be penned (M11), reading the id with theuuidcrate again (M12), turning the pen-failure freeze into a skip (M13), freezing after a successful pen (M14), widening the auto-release past the applied bytes (M15) and confining the clock merge to one arm (M16), were each killed at the assertion that names its claim. M12–M16 came from PR #623's review; M15 first survived — the deleted row was re-penned in the same sweep, so every count held — until the test asserted the row's identity. M10 was first declared a survivor, on the premise that nothing could make the lookup fail inside the self-pull; review found the seam —pull_intotakes the caller's connection, and both doors it calls areSECURITY DEFINER— and the test was written. - How we would know the bet failed:
substitution_guard_covers_every_writer.rsfails, naming a writer that does not call the helper or a sixth writer nobody decided on; or a node that has pulled from a peer holds different bytes from that peer's under onenode_event_id, with no pen row for the peer's version.
Residuals — named, not assumed away¶
- #605 — because
db/007is edited in place,SCHEMA_GENERATIONstays 53, and the #188 downgrade guard cannot tell this build from the one before it. An older generation-53cairn-node— PR #618's build — connecting afterwards replays its own embeddeddb/007andCREATE OR REPLACEs both doors back to their unguarded bodies, in silence. (cairn-syncdoes not loaddb/007.) Accepted as #601 accepted the same exposure for its in-place edits: pre-clinical, no mixed-version fleet. - #268's remaining classes. Every other genuinely-refused verifiable node event — oversized, a missing or malformed payload field, an HLC wall past the drift ceiling — still skips and advances, re-offered only on the full sweep.
- #301 — the node plane's remote door still fail-closes on a node event type it cannot map; ADR-0056's admit-uninterpreted is not live there.
- #569 —
restore_actor_registry(db/052) still discards a content conflict inactor_eventin silence. - #625 — the pen dedupes by content
digest across peers, but counts
pendingper peer: a rival penned under a peer that later leaves the pull set stops making any cycle loud while another peer keeps serving it. - #624 — nothing refuses a non-canonical
event_idspelling; the puller now reads every spelling the door does, but one id still has many byte spellings. - Node-plane completeness accounting still does not exist: there is no general "what did the node plane fail to apply" report.
- #608 —
cairn_project_late_custody's not-found arm, on the clinical plane, still returns silently; ADR-0072 narrowed #608 to it, and this change does not touch it. - The per-peer pen quota — 10 000 unacked rows or 64 MiB per peer — applies to substitutions exactly as to unverifiable bytes. A peer flooding substitutions fills it, and the cursor then freezes below the first rival it cannot pen: delayed, never lost, loud. It is the limit sync §6.3 already states for a hostile-but-credentialed peer.
- The catalogue rule's blind spots. As the test states, it reads a function's own body, so a
write through a helper, a
MERGEor a dynamicEXECUTEis not recognised. Two more are not stated there. It readspg_proc.prosrc, which is empty for aLANGUAGE sqlfunction written with a SQL-standardBEGIN ATOMICbody; none of those shapes writes an event log today (#622). And it asks whether a function calls the helper, not whether every INSERT path reaches the call: an arm that insertsON CONFLICT DO NOTHINGandRETURNs before the tail would pass it. Only a rival test written for that arm, or review, would catch it.
The design and the implementation plan — including the full mutation ledger — are
docs/superpowers/specs/2026-09-19-node-plane-substitution-guard-619-design.md and
docs/superpowers/plans/2026-09-19-node-plane-substitution-guard-619.md (working scaffolding,
excluded from the published site).