0052 — Rule 16's gap closes through the shfmt walker, not a second regex
- Status: Accepted — the verdict is ASTRA's (gpt-6-astra), NOT Fable's; a single-rung answer under ADR-0019 §4, which no founder has read. Overrule channel open.
(The badge takes only the first PHYSICAL line —
propflow-docs/build.mjs:336matches/\*\*Status:\*\*\s*(.+)$/m, and.does not cross a newline — so the provenance is on that line, unwrapped and unbolded, rather than below it where it would be cut. Nothing live changes on this decision: it holds one PR unmerged and sequences two others. The provenance and its limits are expanded in Deciders, and they are part of the decision's weight, not a footnote to it.) - Date: 2026-10-13 (receipt stamped 2026-10-14T03:52:57Z). Written the same night, at decision time, per ADR-0004 — before anything merged, which is the point: the decision is to not merge something.
- Deciders — stated rather than softened, because a card implying Fable
answered would be a provenance lie:
- The verdict is Astra's (
gpt-6-astra), receiptf1b91a519(~/.claude/jobs/fable/f1b91a519.json), raised from theportfolio-architecture-phasesdock, rowbug-guard-bash-rule-16-misses-a-runner-built-by-command-substitution-or-nested-inside-bash-c-or-ssh-payloads-printf-npm-test. Astra ran both passes with the options reversed and held, confidence high / high. - Fable did not answer. Its as-given pass timed out after 600 s, so
the order-reversal check could not run and the ask fell through to Astra
(ADR-0016's fall-through). The receipt ledgers this:
fable_status: incomplete,fallback.from: fable. - So this is the shape ADR-0019 §4 calls
single_rung, not agreement. ADR-0019 §2 ("agreement drives") did not fire here; what drove is one rung that held under its own options-reversed guard. ADR-0019 §4 names exactly this shape and says it "never reads as agreement". (The receipt carries nosingle_rungfield — itsverdictreadsRESOLVED; the ledgered facts arefable_status: incompleteandfallback.from: fable, and the classification is this ADR applying §4 to them.) Recorded as Accepted on the ADR-0051 precedent — a non-human ruling is written as the operating decision with the overrule channel named — and a founder read is cheap, because the one Fable pass that did complete chose the opposite option (see Options considered → A). - Astra's inputs were limited, and the limit is on the record. It stated that live GitHub and the ledger were unavailable to it, so it read captured PR bodies and the diff rather than live PR state.
- The verdict is Astra's (
- Systems: dotclaude
hooks/guard-bash.shrule 16 (the local heavy-work fence),hooks/driver-reach-ast.py(the shfmt walker that already carries rules 20 and 15),hooks/tests/guard-bash-matrix.py(the case set). Rule 16's own mandate is ADR-0040 layer 1.
The problem
Rule 16 keeps a test / typecheck / build sweep off the shared mini (ADR-0040
layer 1). Codex's round 6 on dotclaude #219 found spellings it ALLOWS, and
the matrix measures all six as allowed on origin/main:
| Command | origin/main |
would be refused by |
|---|---|---|
`printf npm` test |
allowed | cmdsubst (new) |
env -S "`printf npm` test" |
allowed | env -S (#219's existing rule) |
bash -c 'env -S "npm test"' |
allowed | env -S (existing) |
ssh mini 'env -S "npm test"' |
allowed | env -S (existing) |
n\pm test |
allowed | cmdescape (new) |
env FOO=a\ b npm test |
allowed | the plain suite rule (existing) |
Each one runs the suite on a machine whose whole point is that the suite runs in the cloud. Two PRs exist, and they are different shapes of fix, not two drafts of one:
- dotclaude #245 closes all six live, with a second regex layer inside
rule 16: 492 insertions / 14 deletions over 3 files, 155 substantive lines.
Measured
1315/1315, exit0on the branch; the same harness onorigin/mainis exit 1 at1275/1315, and every failure is one of this PR's own new deny cases reportinggot=allow. Nothing pre-existing moved. - dotclaude #234 teaches the shfmt walker (
hooks/driver-reach-ast.py) the same forms in shadow, and decides nothing: its own body says "After this PR the four forms are still ALLOWED live." Rule 20 (#97) and rule 15 (#183) both shipped on that walker this way, shadow first.
#234 also states the intent that rule 16 "joins the same walker rather than gaining another regex". The walker cannot close the gap today: #234 is unmerged, so rule-16 shadow has zero lines of log, and rule 15's draft cutover is explicitly allow-only. So the question is which shape closes the gap, and in what order.
Options considered
The three options as the receipt put them, verbatim in substance:
A. Merge #245 as the stopgap, with its own PR body documenting what it cannot do, and let the walker supersede it later once #234 has shadow evidence. Ruled out — and this is the option Fable's one completed pass chose, at medium confidence, on an argument worth keeping: the rule-15 row itself recorded that "a regex cannot converge" and yet regex stopgaps #154/#155 merged while the walker shadowed, so "a stopgap under a documented replacement path" is the already-decided pattern here, and #234's "joins the walker" names an end state reached at cutover by deleting the regex. Astra's reading, which governs: #245's measured gains are real, but the construction record (below) makes another regex layer the wrong durable design, and the rule-15 row is the measured case against the stopgap too — nine shadow days, 870 lines, and the cutover still unwritten. A merged #245 removes the pressure to finish rule 16's walker and leaves its 155 substantive lines of marker-byte regex as a permanent zombie.
⚠️ ONE CITATION WITHDRAWN, AND THE ARGUMENT DOES NOT DEPEND ON IT. An earlier draft of this ADR added that the rule-15 row was "deferred by Gera's 2026-10-06 wrap-up ruling", citing receipt
f1ceef12a. That citation does not hold. Walking that receipt's JSON, every occurrence of the quoted strings is inroot.q— the QUESTION a previous driver asked — and in no answer:DEFERRED BY GERA'S 2026-10-06 WRAP-UP RULING (noted, not built; coordinator: keep open, never cancel) (13): … rule-15-shell-parser, …. Three separate defects follow: (1) the receipt is not Gera's ruling — itsanswered_byisfable, and the ruling survives only as a driver's characterisation inside the ask, which is the provenance shape this ADR's own Deciders section opens by refusing; (2) the source splits the quote — "keep open, never cancel" is attributed there to the coordinator, not to Gera, so rendering both halves as one Gera quote merged two speakers; and (3) that receipt is not an answer at all —verdict: NEEDS HUMAN,rec: None, because its two passes disagreed on option order alone. A deferral cannot be evidenced by an unresolved audit. The underlying claim may well be true — a 2026-10-06 wrap-up deferring thirteen named rows is too specific to be invented — but no primary source for it was found: no receipt under~/.claude/jobs/fable/carries a humananswered_by, and two earlier sessions (185ba028,66077e62) are on record hunting the same ruling text with no find recorded. It is therefore hearsay and carries no weight here. Astra's argument stands on the nine days, the 870 lines and the unwritten cutover alone — each independently checkable, and none of them a deferral claim.B (this decision). Hold #245 unmerged and close the gap only through the walker: merge #234 first, accrue shadow evidence, then write the cutover decision that lets the walker add a refusal.
C. Take Codex's "lexical floor" instead — refuse every command name the walk cannot read. Ruled out: it exceeds this row's policy and flips three long-standing ALLOW cases (see What is out of scope). ADR-0040 scopes the fence to heavy work, not to every unreadable command.
The decision
- #245 does not merge. Rule 16's gap closes through the shfmt walker.
- #234 merges first, in shadow, as rules 20 and 15 did. It changes no verdict and refuses nothing live; it produces the disagreement log the cutover needs.
- The cutover bar is evidence, and elapsed time is not evidence. The ask
was framed as "accrue shadow days", and that framing is corrected
here: authorization to let the walker add a refusal requires
classified shadow disagreements — each
regex=allow ast=refuseline read and labelled a real miss or walker noise — plus a deny/allow replay over the case set. A date on a calendar authorizes nothing. (Rule 15 is the measurement behind the correction: #235 rested its draft on nine days and 870 lines of log on 2026-10-12,~/.claude/jobs/host/rule15-shadow.jsonlreads 1,044 lines as of 2026-10-14T04:1xZ — a count that goes stale by design, which is the point — and the cutover is still unwritten, because days were never the missing input. The missing input is that log'sallow → fencedclass, 50 lines, still carrying the label "Unclassified: real misses or walker noise.") - This needs its own written cutover decision; rule 15's precedent does not authorise it. ⚠️ This paragraph is this ADR's reading of an UNMERGED DRAFT, not settled rule 15 doctrine — #235 is open and its cutover is explicitly labelled DRAFT, so what follows is an argument about why a draft cannot be borrowed, not a citation of a rule in force. Rule 15's draft cutover is allow-only — "the walker never removes a fence the regex asked for unless (1) holds, and never adds one in this step" — and rule 16's gap needs the walker to ADD a refusal, the opposite direction. The quote's "in this step" scopes the restraint to rule 15's own first step rather than forever — which is exactly why it cannot be stretched sideways onto rule 16: a step that may only widen what passes licenses nothing about narrowing it, in its own rule or in another. New decision shape, so ADR-0004 wants its own decision at that point, and this ADR is not it: this ADR authorizes the sequence, not the cutover.
- #245 is preserved as the evidence and the case set, not abandoned. It is
the measurement that justifies this decision, and whatever closes rule 16
through the walker inherits its matrix: 94 cases — 42 denies, 40 of
which flip (allow on
origin/main, deny on the branch) and 52 allow controls, including the one sanctioned local run (npx vitest run <changed file>, ADR-0040 layer 1's single exception) and the six line continuations that 1,306 prior cases never exercised. A walker that cannot pass that matrix has not closed this.
Why a second regex layer was rejected — the construction record
Codex (gpt-5.6-sol, high) ran four rounds on #245: 9 → 16 → 7 → 1
findings, and its standing verdict on the PR as a whole is BLOCK. (The PR's
own Review header says "three rounds" and then lists r1–r4; the counts are the
measured figures.)
The round shape is the signal, not the count. The author's own words name
rounds 2 and 3 as each fix creating the next defect in the same mechanism,
and r4's single finding (\. test, a command name that is only a marker byte)
is a residue of r3's continuation fix. The repo's shipping doctrine names that
shape as the one that needs a design change rather than another patch.
The branch shipped and then fixed its own false refusals during construction, and each is pinned as an allow control so the next widening trips on it:
- Eleven pinned allow controls exist because they were regressions this PR
shipped — a leading assignment walked into its own value (
FOO=$(date) lsand two more), rule 16's lane refusing three pre-existing15 quotedcases, three necessary escapes (./tool\(dev\) --versionand two more), and two with blast radius outside rule 16 entirely:git\badd -Abecamegit add -Afor rule 1, andcp "foo\bnode_modules" /tmp/xbecame a node_modules refusal, because the input-marker neutralise ran in both awk modes instead of only the marking one. - Plus six line continuations, found after r3 by reading the pattern rather
than by a run:
cd /tmp && \+newline+lsand five siblings. 1,306 passing cases did not see them, because not one of them put a trailing backslash after a separator — the absence was in the case set, not in the tree.
The honest conclusion, which the PR's own body reaches: a regex over a blanked copy cannot be made into a shell lexer by adding arms. That is the argument for the walker, and it is a better argument for #234 than #245 being absent.
What is out of scope
Refusing every command name the walk cannot read — Codex's "lexical floor" —
is not authorized here. It exceeds this row's policy, and it flips three
long-standing ALLOW cases in the matrix: `echo git` push,
$(echo git) push and `echo git` log — three commands rule 16 has no
business judging. ADR-0040 scopes the fence to heavy work.
The floor is real and stays open either way: a substitution can synthesise bytes
that appear nowhere in the command text, so
$(printf %s n p m) $(printf %s t e s t) is allowed on origin/main and on
#245's branch alike, and a lexical relevance bound cannot close it. Whether to
pay the three flipped cases to close it is a separate decision for a human, and
nothing here pre-empts it.
Consequences
The cost this decision accepts, in the decider's own words: "The six bypasses remain available while shadow evidence accumulates, permitting heavy runs that other enforcement layers may miss. A slow or stalled cutover prolongs shared-mini contention. Shadow deployment must therefore remain an open prerequisite, never count as closing the defect."
Read as rules:
- Shadow deployment is a prerequisite and must never be counted as closing the defect. Merging #234 does not close this row. A board or a report that reads #234's merge as the fix is wrong, and the six spellings above are the test of that claim.
- The gap is open in the meantime, and it is not uncovered. ADR-0040 documents seven layers; rule 16 is layer 1, and layers 3 (the pacer), 6 (the CPU ledger) and 7 (the runners' own refusal) still see work the hook misses. "Other enforcement layers may miss" is the honest statement — not "nothing else catches it", and not "something else catches it".
- A stalled cutover is the failure mode with no owner. Rule 15 is the measured precedent for it. This row stays open until the walker refuses these forms live and the matrix passes; it is not closed by #234, and it is not closed by this ADR.
- No option closes the live fence until the installed hook is reconciled.
Measured during #245's construction, as of 2026-10-13: the installed copy
is 3,403 lines against
origin/main's 3,708 — 305 lines behind (lines, not commits; the PR body's "305 behind" is that subtraction). This figure goes stale the moment the updater runs, and the claim that outlives it is the rule, not the number. A merge to dotclaudemainis not a change on this machine until the updater applies it, so "merged" and "enforced" are separate states here the same way "merged" and "deployed" are for this repo's own services. - If both #234 and #245 had landed there would be no behavioural conflict — they would simply agree on these six forms and stop producing the disagreement lines the shadow collects. That is not an argument for landing both; it is why holding #245 costs the shadow nothing.
SYSTEMS.md needs no edit, and this was derived rather than assumed. Neither
guard-bash.sh nor driver-reach-ast.py is a catalog entry — no id in the
catalog is a guard-bash system (the count is deliberately not quoted: this
ADR and its own PR body disagreed about it, and the reviewer's grep returned a
third figure, so the number is the unreliable part while the claim is checkable
by reading the id list); rule 16 appears only inside the pacer
entry's notes as a caller of the shared pace.json policy, and
guard-kill-rate measures the hook's kills. Both of those are unchanged by this
decision. The relation this ADR changes is intra-dotclaude (which layer of
one hook emits rule 16's refusal), so no system enters, changes or leaves the
catalog. Rule 16's anchor in the ledger is ADR-0040's Systems line, which is why
this ADR links it rather than minting a new entry.
Related
- ADR-0004 — why this file exists, and why it was written tonight rather than after the work.
- ADR-0019 §4 — a single-rung answer never reads as agreement; the provenance above.
- ADR-0040 — rule 16's mandate, its one exception, and the seven layers.
- Row:
portfolio-architecture-phases/bug-guard-bash-rule-16-misses-a-runner-built-by-command-substitution-or-nested-inside-bash-c-or-ssh-payloads-printf-npm-test. - Receipt:
f1b91a519(~/.claude/jobs/fable/f1b91a519.json). - gera-propflow/dotclaude #234
(rule 16 shadow on the walker — merges first),
#235 (rule 15 shadow +
the allow-only cutover draft),
#245 (the regex layer
held unmerged; the evidence and the case set),
#219 (rule 16's
env -Sfail-closed rule, whose round 6 found these spellings).