Three residuals from #787's cold review, in the merge-consent gate. What #859
described as a wrong STATED CAUSE turned out to be masking a live false-open.
#859 (1) — `(.branch_name // .rule_name // "")` collapsed an unreadable rule to the
empty string: a VALID name that matches nothing. That fed `none`, whose entire
authority is "the full rule list was read and none matches", and on the hook's caller
`none` DENIES. A rule with no usable name is now a distinct `unreadable` verdict, and
the short-circuit is STRUCTURAL rather than first-in-the-chain: jq binds `as` eagerly,
so the flat version still evaluated `offs`/`nonascii` on the bad name and died before
reaching the arm written to prevent that.
THE FALSE-OPEN, which the issue did not know about. jq's `//` fires on null and false
but NOT on `""`. Measured on a scratch repo against Gitea 1.27.1: a GLOB rule comes back
as `{"branch_name":"","rule_name":"release/*"}` — the canonical name is ONLY in
`rule_name`. So every glob rule reached the classifier as a name with no metacharacters
and the glob test never saw it. Measured on the predecessor: glob `m*` (not requiring
`review-verdict/h10`) beside plain `main` (requiring it) resolved to `exact` on `main`
and AUTO-GRANTED a scheduled merge, while Gitea — ordering by Priority then
plain-name-ness — may be applying `m*`. That is #622's hole, reached through the
undecidable-first ordering written to close it. The mirror case: a glob alone resolved
to `none` and DENIED about a rule that provably governs the base.
An earlier draft of this message called that shape unreachable, on a probe of THIS repo,
which has exactly one plain rule. An arbitrary sample generalised into a claim about all
rule shapes; the positive case had to be constructed.
The fall-through is typed, not permissive. Cold review caught the first draft treating a
PRESENT-but-malformed `branch_name` as absent: `{"branch_name":42,"rule_name":"main"}`
answered `exact` and auto-granted where the predecessor threw and asked. A field now
either supplies a name, SKIPs (absent/null/empty), or POISONs the list.
#859 (2) — `branch_protections` was fetched twice on a scheduled merge. The round trip is
the smaller half: it is MUTABLE config, so two reads can disagree and the two arms then
decide about different repo states with neither able to notice. One cached read per run
makes a run internally consistent by construction; the arms still classify independently,
so the bytes are shared and never a verdict.
#858 — `verdict_script` resolved from `$CLAUDE_PROJECT_DIR`, an env var selecting the code
that decides whether a merge is granted, while #787's arm below bound the same class of
input to `$repo_root`. Two answers to one question in one file is the state most likely to
be tidied toward the weaker one.
AND THE ONE THAT MATTERED MORE. The first draft exempted `ETV_HOOK_FIRE_LIB` as
"telemetry, not a predicate". Cold review refuted that by execution: the line is
`. `-SOURCED, so it is CODE, running before stdin is read and before `decide` exists — a
decoy `hook-fire-log.sh` in an env-var-named tree printing an `allow` and exiting 0 GRANTS
THE MERGE, having bypassed every check. Classify a path by how it is CONSUMED, never by
what it is called. This hook's copy is self-located now; the other twelve are #891, which
also records the reachable case (husky launches the prepush hooks by RELATIVE path, so
there the two roots genuinely diverge — measured).
Every fix ships a test witnessed RED against the real predecessor or a declared clause
mutation — 8 mutants, 8 detected, disjoint reddened sets. The env-var pairs each carry a
NEGATIVE CONTROL, because the passing outcome is also what an inert decoy produces: the
same decoy is re-run as genuine `$repo_root` and must flip the decision.
Docs: new record `process.hook-resolves-inputs-from-repo-root` (both inline sites cite it
rather than arguing it twice), regenerated catalog, and the `remote-state-inventory.md`
row that described the second read.
fixes#858fixes#859