# THE governing-branch-protection-rule classifier, as ONE program with two callers (ersatztv#787). # # Input : the `GET /repos/{owner}/{repo}/branch_protections` array. Arg `$b`: the branch to resolve. # Output: {verdict:"exact", rule:} | {verdict:"undecidable"} | {verdict:"unreadable"} # | {verdict:"none"} # # It lived inline in `.claude/hooks/pretooluse-merge-consent.sh` until #787 needed the same question # answered for `main` from a second caller. It was EXTRACTED rather than copied: this is a security # predicate, and that hook's own history is the argument — the docs-only file enumeration was written # twice, four rounds of hardening landed on one copy and never reached the other, and the copy with # real authority ended up strictly weaker than the copy without. Two copies of a security predicate # drift; one cannot. # # DO NOT "simplify" the undecidable-first ordering. Gitea picks the governing rule with # `GetFirstMatched` over a list sorted by Priority, then by plain-name-ness, so a GLOB rule with a # better Priority outranks an exactly-named one. Preferring the exact rule would inspect a rule Gitea # might not be applying. `superset` deliberately over-approximates a glob (literal prefix + `.*` + # literal suffix) instead of reimplementing gobwas/glob: refusing whenever a glob COULD match is # sound without knowing the dialect, which is the only claim either caller makes. def esc: gsub("(?[.+?^${}()|\\[\\]\\\\])"; "\\" + .c); def offs: [match("[*?\\[\\]{}\\\\]"; "g").offset]; def superset: . as $n | (offs) as $o | ($n[0:$o[0]] | esc) + ".*" + ($n[($o[-1]+1):] | esc); def nonascii: explode | any(. > 127); # A USABLE name is a NON-EMPTY STRING and nothing else, and the two fields are tried in order. # `.branch_name // .rule_name // ""` got both halves of that wrong (ersatztv#859). # # ABSENT/NULL/FALSE became `""` — a VALID name that simply matches nothing — so a rule the program # could not read at all was counted as a rule that does not govern the base. That fed `none`, # whose whole authority is the claim "the full rule list was read and none matches", and on the # merge hook's caller `none` DENIES. A deny is the one arm that must not rest on a rule that was # never understood. # # A NUMBER did NOT take that path, and saying so would be the same overclaim this record keeps # catching: `//` fires on null and false only, so `{"branch_name":42}` yielded `42`, which then # made `match` throw. That reached a safe `ask` through the callers' catch-all — a correct # outcome resting on a crash rather than on a decision, which is why it is classified now. # # EMPTY STRING shadowed the fallback, because jq's `//` fires on null and false but NOT on `""`. # `{"branch_name":"","rule_name":"main"}` therefore resolved to `""` and answered `none` — a # DENY on the stated grounds that nothing can govern the base, with the governing rule sitting # right there in the field the program declined to read. So an empty string falls THROUGH to # the next field rather than winning it. # # THIS IS THE SHAPE GITEA ACTUALLY SENDS FOR A GLOB RULE, and it made the undecidable-first # ordering above a no-op for every one of them. Measured 2026-08-30 against Gitea 1.27.1 on a # scratch repo, creating one rule of each kind and reading the list back: # # {"branch_name":"", "rule_name":"release/*"} <- GLOB rule # {"branch_name":"main", "rule_name":"main"} <- plain rule # # A first probe against this repo saw only its single plain rule and concluded both fields are # always populated. That was an arbitrary sample generalised into a claim; the positive case had # to be CONSTRUCTED. Both defects below were then measured on the predecessor: # # FALSE-OPEN. Glob `m*` (not requiring `review-verdict/h10`) plus plain `main` (requiring it) # resolved to `exact` on `main`, so the gate read the plain rule's contexts and AUTO-GRANTED a # scheduled merge — while Gitea, which orders by Priority then plain-name-ness, may be applying # `m*`. That is precisely the hole the undecidable-first ordering is written to close, defeated # because the glob's name was invisible: `""` carries no metacharacter, so the glob test never # saw it. # FALSE-DENY. Glob `m*` alone resolved to `none`, denying with "the full rule list was read and # none matches" about a rule that provably governs the base. # # So the fall-through is not defence in depth against a hypothetical future payload; it repairs a # live misclassification of the payload this server sends today. # FIELD ORDER IS PRESERVED FROM THE `//` CHAIN — `branch_name` first — and it is unobservable on # the payloads Gitea 1.27.1 actually sends, which is why it stays rather than being "modernised" # to prefer the canonical `rule_name`. Measured 2026-08-30, both rule kinds on a scratch repo: a # PLAIN rule sets both fields to the same value, a GLOB rule sets `branch_name` to `""`. There is # no payload where both are non-empty AND different, so "first non-empty" selects the canonical # name under either ordering. # # This is a MEASUREMENT, not an appeal to the old code. That chain's order is not evidence the # order is right — precedent for a style is not evidence the behaviour is correct, and this chain # was wrong about everything else on this line. If a server ever sends two different non-empty # names, decide then, with the payload in hand. # THREE outcomes per field, not two, and collapsing the last two is a REGRESSION. # A field either supplies a NAME, or SKIPs (it is simply # not carrying one: absent, null, or the empty string Gitea sends for a glob rule), or POISONs # (it is PRESENT holding a type a name cannot have). # # Falling through to `rule_name` on anything that was not a usable string # makes a malformed field indistinguishable from an unsupplied one. Measured: with `branch_name` # a number, boolean or array beside `rule_name:"main"`, the predecessor THREW and the gate asked, # while that fallback answered `exact` and AUTO-GRANTED — and with `rule_name:"develop"` it answered # `none`, denying on "the full rule list was read and none matches" about a field it never read. # Both directions of the very defect this file is fixing, reintroduced one shape over. # # A malformed field is evidence the payload is not what this program thinks it is, so it poisons # the whole list rather than deferring to its sibling. def namefield($v): if ($v | type) == "string" then (if ($v | length) > 0 then {name: $v} else {skip: true} end) elif $v == null then {skip: true} else {poison: true} end; # POISON IS CHECKED ON BOTH FIELDS BEFORE EITHER NAME IS TAKEN, so the invariant above holds # field-agnostically. Short-circuiting on `branch_name` and only consulting # `rule_name` as a fallback makes the rule order-dependent: `{"branch_name":42, # "rule_name":"main"}` poisoned, while `{"branch_name":"main","rule_name":42}` answered `exact` # off the good field and never looked at the malformed one. Gitea 1.27.1 does not send that # second shape — a plain rule sets both fields equal, a glob rule sets `branch_name` to `""` — # so this buys no measured behaviour today. It is fixed because a comment claiming an invariant # the code does not hold is the failure this whole change is about, and "unreachable" is a # property of one server version, not of the program. def rulename: namefield(.branch_name) as $bn | namefield(.rule_name) as $rn | if ($bn.poison or $rn.poison) then null elif $bn.name then $bn.name elif $rn.name then $rn.name else null end; . as $rules | $b as $base | # THE SHORT-CIRCUIT IS STRUCTURAL, not merely first in the `if` chain, and that is load-bearing # rather than stylistic. jq binds `as` eagerly: written as one flat chain, `$exacts` and # `$unfoldable` are computed BEFORE any arm is tested, so an unreadable name reached `offs` and # `nonascii` anyway and the program died with "null cannot be matched" — the crash this arm was # added to replace, still happening, with the arm sitting unreached above it. Measured while # writing #859. Nesting the readable-name arms inside the `else` is what actually prevents it. if ($rules | any(rulename == null)) then {verdict:"unreadable"} else ($rules | map(select(rulename as $n | (($n|offs|length) == 0) and (($n|ascii_downcase) == ($base|ascii_downcase))))) as $exacts | (($base|nonascii) or ($rules | any(rulename as $n | ($n|offs|length) == 0 and ($n|nonascii)))) as $unfoldable | if ($rules | any(rulename as $n | (($n|offs|length) > 0) and ($base | test("^" + ($n|superset) + "$")))) then {verdict:"undecidable"} elif $unfoldable then {verdict:"undecidable"} elif ($exacts | length) > 1 then {verdict:"undecidable"} elif ($exacts | length) == 1 then {verdict:"exact", rule:($exacts | first)} else {verdict:"none"} end end