ADR-0074 — A deterministic door failure is a refusal, not a fault¶
- Status: Accepted
- Date: 2026-09-20
- Spec version at acceptance: 0.76
- Issues: #621 · #626 (the clinical plane's half, filed here) · #228 (the same class, closed for hex only)
- Relates to: ADR-0073 · ADR-0056 · ADR-0017
- Amends: nothing. It completes the P0001 contract stated above
cairn_decode_hex_or_raise(db/001) for the cases #228 did not reach, and corrects one sentence of ADR-0073's design reasoning (see Context).
Context¶
The node puller (crates/cairn-node/src/sync.rs, pull_into) reads a refusal's SQLSTATE to
decide what a failed apply means:
P0001— a bareRAISE EXCEPTION, i.e. the door reached a verdict. Skip past it and advance; a later full sweep re-offers it, and a later build may admit it (ADR-0056's self-healing deny-all). Since ADR-0073, one class of verdict is penned instead: a substitution, which can never apply because the id is taken.- anything else — "the door never got to decide": a deadlock, a statement timeout, a dropped connection. Freeze the cursor below that event and retry next cycle, because advancing past a valid event this node merely failed to store would silently lose it (the #111 review's A1).
That partition has a hole. A door can fail deterministically without reaching a verdict, and
db/007 did so in four places on caller-supplied bytes: (b ->> 'event_id')::uuid and
NULLIF(payload ->> 'target_event_id','')::uuid (22P02), the node_event_hlc_nonneg CHECK and
the node_event_role_check CHECK (23514). Each recurs identically on every retry, so the freeze
is permanent: the link holds every later event behind the poison one — including that peer's
own peer.revoked — nothing is penned, and so there is no ack remedy either. The operator sees
transient/unexpected error … — freezing, every cycle, for ever.
This is #228's failure exactly: a bare
decode() raising in the 22 class froze one peer's cursor permanently. #228 closed it for hex and
left it open for casts and constraints — and recorded the P0001 rule as a contract precisely so
this could not recur.
It also corrects one sentence of #619's design, which ADR-0073 carried: "a non-P0001 failure still freezes; the retry will reach the door's guard and come back as a P0001." An error raised before the guard never reaches it.
Who can trigger it — checked, not assumed¶
serve streams SELECT seq, signed_bytes FROM node_event WHERE seq > $1: only rows already in
the serving peer's own log, which passed that node's identical casts and CHECKs. An honest peer on
the same schema therefore cannot serve one of these, and #621's headline scenario ("any trusted peer
serving a stranger-signed event") is not reachable that way. Two triggers remain:
- A misbehaving or compromised trusted peer crafting frames. It wedges only its own link, and it could stall that link anyway by going silent — so the marginal harm is dishonesty rather than denial: the stall is reported as transient, never heals, and has no remedy.
- Cross-version CHECK-vocabulary skew — the one that matters under principle 11.
db/009already widened theopCHECK in place. The dayrole(or any other constrained column) is widened the same way, every older node pulling from a newer peer freezes that link permanently. A vocabulary widening must never be able to partition the fleet; that is the whole reason the vocabulary is now enforced at the door, where it refuses with a code the puller can skip.
Decisions¶
1. The node-plane doors are TOTAL for every field they CAST: each raises P0001¶
Three helpers, each naming field, door and reason, at all three signed-bytes doors
(submit_node_event, apply_remote_node_event, restore_node_event):
cairn_uuid_or_raise(field, value, door)(db/001), gating onpg_input_is_valid(value, 'uuid')— the cast's own grammar, asked of the same server. No second parser exists, so none can drift. A validator narrower than the cast would refuse events the log can already hold, which is the mirror image of the pen bypass PR #623's review found, where Rust'suuidcrate was narrower than the door's::uuid.cairn_hlc_nonneg_or_raise(wall, counter, door)(db/001), before the INSERT meets the CHECK.cairn_node_roles()(db/007) — the vocabulary as one function, which the table's CHECK itself calls — withcairn_node_role_or_raise(role, door)raising through it. One list, so the door and the floor cannot disagree.
Both shared helpers live in db/001 for cairn_decode_hex_or_raise's reason: cairn-sync replays a
subset containing db/001 but not db/007, and PL/pgSQL binds a call at first execution (#198).
The CHECK constraints stay. They are the floor for a caller with raw SQL (principle 12's
privilege gradient); the door's refusal is the legible, skippable one. The re-pointed role
constraint is added NOT VALID: connect_and_load_schema replays every migration on every
connect, so a validating ADD CONSTRAINT would re-scan node_event each time and one stored row
outside today's vocabulary — which is exactly what a downgrade after a widening leaves — would stop
the node STARTING, on an append-only table with no repair path. New rows are still checked, which
is what the floor is for.
Two honest limits of "total", both named rather than assumed away:
cairn_bodyraises22P05before every guard if any body string containsU+0000, becausejsonbcannot represent it while a CBOR text string can. The bytes verify, so this reaches the door and is deterministic (#628). Decision 2 catches it — the link keeps moving — but the event is penned where decision 3 says it should skip. Pinned end to end bya_body_string_carrying_a_nul_does_not_freeze_the_link, which asserts only the freeze-freedom, so closing #628 cannot break it.- The surviving
::bigint/::intcasts on the HLC are safe by TYPING, not by a helper:cairn_event::Hlcdeclaresi64/i32with no serde default, so a body that cannot produce them fails verification. That is a guarantee in another crate, and it is pinned at compile time bynode_door_input_guards.rs::the_hlc_casts_rest_on_cairn_events_types.
2. The puller partitions the non-P0001 space: deterministic ⇒ PEN, local ⇒ FREEZE¶
Decision 1 fixes four known raises. Decision 2 closes the class — including an XX000 out of a
pgrx function fed adversarial bytes, and any cast a future slice writes.
deterministic_apply_failure(sqlstate) claims the local classes explicitly — 08 40 42 53 55
57 58, plus no SQLSTATE at all — and answers "deterministic" for everything else. On a verifiable
event whose apply failed:
- local → freeze, exactly as before;
- deterministic → pen, with the reason in the database's vocabulary (SQLSTATE included),
never the door's — writing a non-verdict in the door's voice is #480's defect. The pen is durably
held, loud through
pending, ack-able, and auto-releases if a later build admits the event.
The list and its reasoning are cairn-sync's apply_failure_is_local, whose do_requeue already
routes on it one plane over; the two copies are held equal by a test until #626 merges them.
The default for an unknown code is deterministic, and the asymmetry is deliberate. A wrong "deterministic" pens a valid event — delayed, held, re-offered, auto-released. A wrong "local" freezes the link for ever with no remedy. The cheaper mistake is the one that keeps the link moving.
3. A malformed-field P0001 joins the SKIP class, not the pen¶
After decision 1 a garbage field is a door verdict, and verdicts skip-and-advance: re-offered on every full sweep, admitted the day this node's build understands the event. That is what lets a widened vocabulary heal on upgrade instead of needing an operator. ADR-0073 carved substitution out of the skip class because it can never apply; this carves nothing out — it puts malformed fields where the self-healing class belongs. The general refusal-class partition (#268) remains open.
4. The refusal does not get its own SQLSTATE¶
USING ERRCODE on any of these would turn cairn-sync's clinical pen into a freeze, since
refusal_is_deliberate reads P0001. The doors raise P0001 and the puller classifies by state,
never by sentence — ADR-0073's rule, restated because decision 2 makes a distinctive code look
newly attractive.
Consequences¶
- One poison event from a misbehaving peer no longer costs that link every later event, and the outcome is visible: a pen row an operator can read and ack, instead of a "transient" line.
- A future constrained column or widened vocabulary cannot partition the fleet: an older node refuses legibly and skips.
- An operator sees more pen rows than before, by design — a deterministic failure that used to be reported as transient is now recorded as what it is.
- Two planes, one classifier, not yet one function (#626).
What this deliberately does not do¶
The clinical plane (#626: db/020's raw casts and do_pull's freeze arm). Node-plane completeness
accounting, which still does not exist. #268's general partition. #620's
content-addressing-over-unsigned-bytes finding.
Evidence¶
crates/cairn-node/tests/node_door_refusals_are_p0001.rs (behaviour, all three doors × four
fields, with the odd-spelling positive control), node_door_input_guards.rs (the catalogue rules:
every door calls every guard; no bare ::uuid survives in any door body; the CHECK reads the one
vocabulary; the CHECK still refuses a raw INSERT), node_pull_refusal_class.rs (the pure
classifier, both directions and the unknown-code default), node_pull_deterministic_refusal.rs
(the three outcomes end to end over the real self-pull, plus the pen-write freeze, the 22P05
path above and an anti-vacuity control), sqlstate_classes_agree.rs (the two planes' lists).
Fifteen mutations, fifteen killed — M14/M15 added by the PR review, so its own two fixes are
pinned like the rest — ledger in
docs/superpowers/plans/2026-09-20-node-pull-deterministic-refusal-621.md.
What the PR review changed¶
XX001/XX002 (data_corrupted / index_corrupted) are claimed as local on both planes: class
XX is otherwise the adversarial-bytes case, but a corrupt page or index is this machine's disk,
and without the exception a corrupt index on node_event would have made the puller pen a peer's
entire log while writing "will fail on these bytes identically every time" onto every row — a
local catastrophe wearing the peer's name. Anything that breaks the pen table's writes too makes
pen_or_freeze freeze and say so, so corruption is the case that slips between — but the claim
that it is the only case of that shape is too strong, and the second review pass corrected it:
a defect confined to a DOOR touches neither table, so P0004 from an ASSERT, P0002/21000
from a SELECT … INTO STRICT or 22012 from arithmetic would each pen a peer's whole log under
this node's own bug. The bytes stay held, loud and ack-able throughout, so the cost is diagnosis
damage and quota exhaustion rather than loss; widening the claimed-local set is
#632.
A pen row of this kind leaves the pen by applying or by an ack — never by a later P0001 verdict about the same bytes, because the deny-all arm cannot tell which KIND of row it would be deleting without reading the reason TEXT (the one thing the loop never classifies on) and a substitution row must never auto-release. Every operator-facing sentence now says exactly that; before the review three of them still enumerated two pen causes and promised "fix the cause".
The second review pass¶
A four-reviewer pass over the finished branch found no defect in the shipped behaviour and four documentation faults that would have outlived it, all fixed here:
- a ~70-line header in
db/001— the one that carries the "never addUSING ERRCODE" contract — had been orphaned abovecairn_value_glimpseby the insertion, leavingcairn_decode_hex_or_raisewith no header and the contract pointing at a function that cannot raise; db/022, the pen's own table definition, still said "a refusal with any other SQLSTATE freezes instead" and still enumerated two reason kinds;sync.mdand this log's index row omittedXX001/XX002, documenting the pre-review behaviour as current;cairn_node_roles()was the only helper in the slice with neitherSET search_pathnorREVOKE EXECUTE … FROM PUBLIC. Both are now present and, more importantly, explained: theSETblocks SQL-function inlining, so the CHECK cannot hold a constant-folded copy of a superseded vocabulary, and theREVOKEis safe only while no non-owner role holds INSERT onnode_event— a CHECK executes its functions as the INSERTING user, so a future grant would turn the floor's honest23514into a42501the puller freezes on.
Two reasoning faults were corrected without changing behaviour: class 42 is claimed as local
because the remedy is local and available, not because it is transient (a revoked grant is as
permanent as any poison byte), and the role refusal echoes its value in full because a wire-level
routing label carries nothing secret — not because the vocabulary is closed, since the value being
echoed is by definition not in it.
Filed rather than repaired here: #630 (the pen's
dedupe is keyed on digest alone while its floor and pending are per-peer — pre-existing from
111, latent while run pulls one peer, but this slice widens what reaches it),¶
#631 (a bumped row keeps its original
reason, which can stop describing the current exclusion), #632 above,
#633 (nothing pins the USING ERRCODE
contract itself) and #634 (pen-at-quota,
auto-release of this pen kind, and the deliberate no-release-on-later-verdict invariant all
lack tests).
Note
Erratum E1 (2026-09-20, same day). #630 above was closed as a duplicate of
#625, which was filed a day earlier from
the PR #623 review and names the same root cause: quarantine_node_event's dedupe is keyed on
content_digest alone while the re-offer floor, pending and the quota are all per-peer. The
two observations #630 added that #625 did not state — a second peer advancing with no floor of
its own, and this slice widening the set of events that reach that path — were moved onto #625
as a comment before closing. Track the peer-blind pen at #625. This ADR's text is immutable,
so the reference above stands; the erratum is the correction.