0061 — A gate in pr-merge fails open on a failed train and fails CLOSED on an unread arm
- Status: Proposed. Ruled 2026-10-16 by decider ruling
f3d37d0ec(verdict RESOLVED) on the question raised by driver281c6038while promotingarmgateout of a task directory. It is Proposed and not Accepted deliberately: ADR-0043 is Accepted because Gera said "option 2", and a decider ruling is authority for an agent to ACT, not a founder's decision wearing one. The work it governs proceeds on the ruling; the ADR records it for a founder to accept or correct. Companion to ADR-0043, which it does not amend: it names the premise ADR-0043 stated inline and says what happens when that premise does not hold. - Deciders: the
deciderladder. Three returned passes chose it independently — fable reversed (medium), astra as-given (medium), astra reversed (high) — every one withneeds_human_why: None. Fable's as-given pass timed out at 600 s, so the ruling is recorded as answered by Astra under the two-pass reversal discipline, not as a Fable verdict.
The problem
ADR-0043 put the fleet's first pre-arm gate in ~/.local/bin/pr-merge, where the arm actually happens, and ruled that it fails open:
Fail open, hold on one thing. The cost of a wrong hold is a blocked merge verb for the whole fleet; the cost of a wrong arm is one failed train, which is today. So the gate holds only on
failure, and every failure of its own instrument arms with a warning that says so.
That is a cost comparison with its premise stated inline, and the premise is load-bearing: a wrong arm costs one failed train. It was true of the pre-queue type check, whose wrong arm is caught downstream by the merge queue's own CI.
A second gate then arrived with a different cost. armgate refuses an arm for reasons the queue cannot catch: a held or draft PR armed anyway (#6719 shipped its wording to production that way on 2026-09-01, auto-merge armed, the last review objection cleared by a later worker); a live CHANGES_REQUESTED as the newest verdict; an absent required review check read as a pass; an unreadable merge-queue count read as zero (the line hit 60 on 2026-10-03, every entry a full CI run on top of main). A wrong arm there puts unreviewed code into production, and in this fleet merge IS the production deploy. Nothing downstream catches it.
So the question was not "which polarity does a pr-merge gate have". It was: does ADR-0043's rule follow from its premise, or from its position?
Decision
1. The polarity is a property of the gate's own blast radius, not of pr-merge. A gate whose wrong arm costs a failed CI train fails open: it holds only on a substantive red, and every failure of its own instrument arms with a warning naming the failure. A gate whose wrong arm puts code into production without a decision fails CLOSED: an unreadable read refuses the arm. ADR-0043's rule for the pre-queue type check is unchanged and correct; it was never a blanket law for the file it lives in.
2. A fail-closed gate reports could-not-look distinctly from a refusal. Three outcomes, not two: armable · a substantive HOLD · COULD NOT LOOK (a missing key, a malformed read). Both non-arming, so safety holds either way — naming which is which is what makes the printed reason true, and it is what tells an operator whether to fix a PR or fix an instrument. Per ADR-0009, a gate that cannot read says so rather than saying nothing. A null VALUE is a HOLD; a missing KEY is a could-not-look.
3. A fail-closed gate ships with a ledgered second door in the same change. --skip-<gate> <reason>, recorded in the merge-actor ledger and in the gate snapshot, exactly as --skip-typecheck <reason> already is. The recorded reaction to a blocked merge verb is lanes going around it to bare gh pr merge --auto, which carries no gate and leaves no receipt — a refusal with no second door buys less protection than it costs.
4. Every gate in pr-merge runs before the provenance receipt, and its verdict is against the head at arm time. pr-merge's header permits a degraded provenance write (a comment API hiccup must not block the merge verb); that licence does not extend to eligibility. And because the type-check gate may wait up to 300 s for its verdict, the head can move inside that window: the arm's authority is a verdict on the SHA being armed, per the repo's hard floor — "a verdict from three commits ago is not a verdict."
5. Each gate writes its own liveness snapshot. ADR-0004 requires liveness evidence, and the systems catalog reads signals, not promises (ADR-0003). Fixtures are not liveness: a green fixture suite proves the gate's rules, and says nothing about whether anything calls it.
Why this shape
- A rule whose premise is stated inline is scoped by that premise. ADR-0043 said the cost of a wrong arm is one failed train in that sentence. Reading the conclusion forward onto a gate with a different cost is reading the position and dropping the reasoning — the same defect as a
file:linecitation that proves a claim was sourced but not that the source says it. - Fail-open goes silent exactly when it is most needed. A stale query is the measured case, not a hypothetical:
armgate's owngate.graphqlonce gained two fields read inside its hard-failure path, and until the fixtures were repaired every stale input refused. Fail-open would turn that into a permanent warning into the void; fail-closed makes it loud and fixed in minutes. - The protection this gate carries was measured, not asserted. GitHub rewrites an already-submitted review and moves its
commit_idforward, so an old objection reads as live against code the reviewer never saw: 111 of 111 moved records carriedupdatedAt != submittedAt, 0 of 183 un-moved ones did, 294/294 concordance, smallest real gap 12 seconds — which is why the tolerance is zero and why clearing an objection requires both markers while an unreadable one keeps holding. - The asymmetry is the point. A fail-closed gate's worst case is finished work waiting for a person; a fail-open one's worst case is unreviewed code in production. Those are not the same kind of cost, and a single polarity for the whole file would have to pick one of them to get wrong.
Relation to other ADRs
- ADR-0043: unchanged and still correct for the pre-queue type check. Where the arm is, the gate is applies to every gate; fail open applies to gates whose wrong arm is caught downstream.
- ADR-0004: this changes how the merge verb, the merge queue and the review gate relate, so it is written at decision time.
- ADR-0009: could-not-look is a reported state, never an implicit pass.
- ADR-0030 §1: one canonical path — the gate is wired into the shared arm path rather than kept as a standalone command a session may remember to run.
Stated gaps
- A forge outage or a schema drift stops every covered arm until the second door is used or the query is fixed. That is the accepted cost, and item 3 is what makes it survivable.
- No moved
CHANGES_REQUESTEDhas been observed in the wild. The 111/183 measurement is of the marker across moved and un-moved records; the clearing rule's own fixtures are constructed, including the one-marker decider that fails the rejected design. The gate's refusal direction is the safe one either way. guard-bash's rule-30 refusal marker is typecheck-only per the decider's reading. That is unverified by me and is tracked as its own row, not folded in here: a bypass of a fail-closed gate should be denied rather than nudged, and a guard is not edited on a second-hand reading.
Build
| Part | Repo | What | State |
|---|---|---|---|
| 1 | local-bin | armgate promoted out of a task directory and wired into pr-merge ahead of the receipt, fail-closed, with a ledgered --skip-armgate <reason> door, a 73-case caller suite, five sabotage controls, a live read-only invocation on the real path, and its own liveness snapshot |
local-bin#615 — OPEN at head e46a0b4d05eb, hold-for-review applied at creation, auto-merge null. The merge is a person's: local-bin has no armed state, land-on-green.yml lands green PRs unattended, and that label is the only brake. Not green yet when this was written (review PENDING). |
| 2 | agentflow | this ADR + the SYSTEMS.md entry with liveness signals |
this PR |
| 3 | local-bin | #603 — the same protection inside pr-autoqueue, which is OFF (~/.claude/jobs/pr-autoqueue/STOP, 2026-10-15) |
held draft, a human press owed; untouched by this ADR, and made redundant by part 1 only if that loop is turned on |
What part 1 actually looks like, because "wire a gate in" hides the two decisions that matter
Three outcomes reach pr-merge, not two, and it exits a different code for each: 0 armable · 1 HOLD, which becomes exit 7 ("this PR's own records say it may not be armed") · 2 COULD NOT LOOK, which becomes exit 8 ("the evidence was unreadable, which is NOT a verdict about the PR"). A gate program that is absent or unrunnable is an exit 8, never a pass — "the protection is not installed" must not read as "armable".
Both verdicts must be about the head that is armed. The arm gate reads first (one read, no dispatch, no wait, so a held or draft PR costs no runner), the type check gate runs second, the arm gate is re-read if that gate actually waited (it can wait 300 s), and the two heads are compared: a head that moved under the gates refuses. Residual, stated rather than implied: the head can still move between the final read and the arm, which is the irreducible window gh pr merge --auto has too.
A dry run exits the gate's verdict, not 0. An exit 0 over a printed GATE: HOLD names what was requested, which is the one thing pr-merge's own docstring forbids — and the gate's README documents && chaining, so pr-merge X --dry-run && pr-merge X armed exactly what the dry run had refused. Verified before changing it: nothing reads that exit code programmatically — pr-autoqueue prints "eligible (dry run: pr-merge not called)" and its suite pins that with a sabotage control. A dry run still writes nothing and still does not run the type check gate, whose no-status path would POST a pending status and dispatch a billed check.
The evidence that is not fixtures, because this ADR's own ruling says fixtures are not enough
ff0d49510 is explicit that fixture success supplies neither liveness nor landed evidence, and that merging a file alone "would create another unused protection". So, read on the real path, one unlooped call, no writes:
$ pr-merge PropFlow-Technologies/propflowai#20373 --dry-run
GATE: HOLD
- ALREADY MERGED at 2026-10-16T09:08:43Z — nothing to arm
… and this run exits 7 — the verdict, not the attempt.
EXIT=7
The verdict is 1 (HOLD), not 2 (could-not-look), and that is the load-bearing part: every field the gate reads inside its hard-failure guard was present in a real response, so a live invocation cannot refuse everything. An already-merged PR was chosen deliberately as the subject — it cannot possibly be armed.
Landed evidence is still outstanding by design. Part 1 is open and held, so the row it closes stays open until #615 shows mergedAt as an instant. An ADR recording a decision is not a claim that the decision has shipped.