Uh oh!
There was an error while loading. Please reload this page.
feat(conformance): branch protection as an inventoried property (backend#1608 increment 2) - #196
Conversation
…end#1608 increment 2)
Increment 1 gave the harness teeth (conformance-gate.yml, required on develop).
This gives it a second thing to check. `repo-inventory.yml` grows from a caller
inventory into the per-repo CONTRACT, and the first property added is the one
backend#1276 just settled by hand: branch protection.
WHY THIS PROPERTY FIRST
#1276 armed required checks and closed admin bypass across 16 prod branches by
hand, one API call at a time. Nothing would have noticed any of it drifting back.
Both directions are now findings: someone weakening a setting, AND someone
"fixing" a deliberate choice (strict: false) back to a default.
THE SCHEMA
`protection_policy` states the fleet policy per branch ROLE (develop/staging/
prod); every repo carries a cell per role. Same rule as callers - absence is
never implicit, and an exemption without a written reason is rejected.
`divergent` takes a MAPPING here, not a bare string: it must name the specific
policy keys that differ. A blanket "this one is different" would switch off every
assertion at once, which is how an exemption written for one reason silently
covers an unrelated regression later. docs diverges on min_reviews alone, so its
force-push, deletion and admin posture are all still asserted.
`null` means NOT ASSERTED, which is deliberately different from asserting false.
enforce_admins on develop/staging is null because #1276 leaves that escape hatch
open; a repo that hardens its own develop must not be reported as drift.
BOTH PROTECTION SYSTEMS, OR THIS WOULD LIE
GitHub runs classic branch protection AND rulesets, and the classic endpoint is
blind to the second: a ruleset-only branch returns 404 "Branch not protected"
while the branch list says protected: true. On 2026-08-10 an audit that read only
the classic API reported docs/staging as unprotected and recommended fixing it;
the fleet-wide `promotion-branches-merge-commit-only` ruleset had been covering
it the whole time. read_protection() merges the two PER PROPERTY, because the
rules do not compose: a `pull_request` rule blocks direct and force pushes but
NOT deletion - that is a separate rule type.
Also: `prod` resolves from the BRANCH LIST, never by probing branches/master.
That call follows rename redirects and returns 200 for a branch that does not
exist - measured the same day, all 16 train repos reported a `master` and only 3
have one.
FAIL CLOSED
A 404 on the classic endpoint is a FACT ("no classic protection"). Any other
error, and any unreadable ruleset read, is UNREADABLE - never "unprotected".
An unreadable ruleset read fails closed even when the classic read succeeded,
because a partial view of protection is not a verdict.
VERIFIED
- selftest: 72 pass / 0 fail (was 47; +25 covering the schema, the merge, the
fail-closed paths and the branch-role resolution)
- full fleet audit: 20 of 20 repos, NO DRIFT - it lands green on today's fleet,
which is the bar increment 2 had to clear or be ignored
- negative control: mutating three inventory cells produced exactly the expected
findings, including "No ruleset covers it either" and a stale-exemption catch
NOT DONE HERE
The rename to repo-conformance.py the ticket describes. Renaming the file also
means touching conformance-gate.yml's GUARDED list and caller-drift.yml, and
coupling a rename to a behaviour change makes both harder to review. Mechanical,
separate, later.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>Uh oh!
There was an error while loading. Please reload this page.
…ad decide Bugbot, .github#196 - and it is the defect class this PR's own header documents, found in the change that documents it. The exempt path gated on `probe.error is None` BEFORE checking what was learned. So an unreadable probe silently meant "not stale" and the run stayed green: a read that can fail, deciding a negative. Worse in one specific case: read_protection() sets `error` when the RULESET call fails even though the classic read already succeeded, so `classic_present` could be known TRUE while the stale-exemption finding was suppressed anyway. Now: decide on what IS known first (classic_present -> stale finding, regardless of a ruleset read failure), and fall back to UNREADABLE only when nothing was established. A genuinely unprotected branch still leaves its exemption intact. Three selftests, one per branch of that decision. 75 pass / 0 fail. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
LukasWodka
commented
Aug 10, 2026
Bugbot finding — confirmed and fixed in |
Uh oh!
There was an error while loading. Please reload this page.
Uh oh!
There was an error while loading. Please reload this page.
…e ruleset read
Two Bugbot findings on .github#196, both real, both in the same family as the
first one: something that can be partial or wrong being trusted as complete.
1. DIVERGENT OVERRIDES SKIPPED VALUE CHECKS
_protection_entry validated that override KEY NAMES were overridable and
stopped there, while _policy_block validated values. So a cell that looked
like a narrow, documented divergence could neutralise the assertion it claims
merely to adjust: `min_reviews: -1` can never fail, and `enforce_admins: null`
stops asserting it at all. That defeats the entire reason `divergent` names
its keys instead of being a blanket switch.
Both paths now share _policy_value(). Overrides additionally REJECT null:
naming a key means stating a different value for it; not asserting something
is a protection_policy decision, not a per-repo one.
Also fixes a latent trap in the original check - bool is a subclass of int in
Python, so `min_reviews: true` passed a bare isinstance(value, int).
2. THE RULESET READ WAS NOT PAGINATED
rules/branches/{b} defaults to 30 items per page, so a rule on page 2 was
dropped with no unreadability signal - a PARTIAL view of a branch's protection
reported as a verdict. Precisely the failure mode read_protection()'s own
header describes, in the function that header is attached to.
New gh_json_array() streams elements with `--paginate --jq '.[]'` (element-
wise, not an aggregating filter, which --paginate would re-run per page) and
reassembles them.
VERIFIED
- selftest 84 pass / 0 fail (was 75; +9 covering override values, the bool-is-int
trap, the --paginate contract on the call itself, and multi-page reassembly)
- new call shape confirmed against live GitHub on a branch with rules and one
without
- full fleet audit re-run: 20 of 20, still no drift
- ruff clean
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>LukasWodka
commented
Aug 10, 2026
Both findings confirmed and fixed in |
| check | result |
|---|---|
| selftest | 84 pass / 0 fail (was 75) |
| new call shape, live GitHub | verified on a branch with rules and one without |
| full fleet audit, re-run | 20 of 20, still no drift |
| ruff | clean |
The +9 tests cover override values, the bool-is-int trap, the --paginate contract asserted on the call itself (pagination is gh's job and is stubbed out, so the call shape is what's testable), and multi-page reassembly.
The pattern, for the record
Three findings on this PR, all at read boundaries, none in the logic:
exemptprobe let a failed read decide "not stale"- divergent overrides validated names but not values
- the ruleset read took page 1 as the whole answer
Same rule each time — a read that can fail must not be the thing that decides a negative — and each was a different way to break it: wrong ordering, incomplete validation, missing pagination. Knowing the rule is not sufficient; that is the argument for the harness rather than for care.
Uh oh!
There was an error while loading. Please reload this page.
Uh oh!
There was an error while loading. Please reload this page.
…rt; correct a reason that overstated the checker Two more Bugbot findings on .github#196, both real. 3. PROTECTION FAILURES COULD ABORT THE WHOLE AUDIT evaluate_protection appended into the same `unreadable` list that drives the `evaluated <= 0` short-circuit. A fleet-wide protection read failure - rate limit, auth, a GitHub incident - would make every repo look unreadable, call die(), and DESTROY the report, including caller/copy findings that had already been collected successfully for those same repos. Fail-closed must mean the run goes RED. It must not mean the results are thrown away before anything is written. Protection failures now accumulate in their own list; `evaluated` is computed from repo-read failures only, and the two are merged afterwards so protection problems are still reported and still fail the run. 4. AN EXEMPTION REASON OVERSTATED WHAT THE CHECKER DOES `no_develop_branch_single_branch_repo` claimed that creating a `develop` would turn the exemption into a finding. It would not: the guard flags an exemption when the branch appears AND carries protection. An unprotected new `develop` is not flagged, because the checker cannot distinguish that from `develop_unprotected_non_train`, an exemption written precisely about an existing unprotected branch. The reason now states the rule exactly, and why the case it is actually about is still covered: enrolling in the train means a PROTECTED develop, which does trip the check. A written reason that overstates its own guard is worse than no reason - it is the audit trail lying. VERIFIED - selftest 86 pass / 0 fail (was 84; +2 asserting protection failures land in their own list and are still recorded as UNREADABLE rather than silenced) - full fleet audit re-run: 20 of 20, still no drift - ruff clean, inventory parses Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
LukasWodka
commented
Aug 10, 2026
Both confirmed and fixed in |
| check | result |
|---|---|
| selftest | 86 pass / 0 fail (was 84) |
| fleet audit, re-run | 20 of 20, no drift |
| ruff | clean |
| inventory | parses, 20 repos |
The +2 tests assert protection failures land in their own list and are still recorded as UNREADABLE — isolating them must not make them silent, which would have been an easy way to "fix" finding 3 by introducing a worse one.
Running tally
Four findings on this PR, all at read/report boundaries, none in the domain logic:
| # | finding | the rule it broke |
|---|---|---|
| 1 | exempt probe let a failed read decide "not stale" | a failed read must not decide a negative |
| 2 | divergent overrides validated names, not values | validate the thing that changes behaviour |
| 3 | ruleset read took page 1 as the whole answer | a partial read is not a verdict |
| 4 | protection failure could destroy the report | fail-closed means red, not amnesia |
Each is a different way to break the same underlying rule. That is the argument for the harness rather than for care — and it is exactly what this PR is building.
There was a problem hiding this comment.
Cursor Bugbot has reviewed your changes and found 1 potential issue.
❌ Bugbot Autofix is OFF. To automatically fix reported issues with cloud agents, enable autofix in the Cursor dashboard.
Reviewed by Cursor Bugbot for commit 3b8a317. Configure here.
Uh oh!
There was an error while loading. Please reload this page.
… annotation Bugbot, .github#196 - and it is the direct consequence of the previous fix, which is why it is worth its own commit rather than a quiet amend. Isolating protection failures from the audit abort meant `unreadable` now carries BOTH families by the time the run exits. The annotation still said "Caller state is UNKNOWN there" - so after a protection-only outage it named the wrong thing as broken, and told the reader caller results were lost when they had in fact been evaluated and preserved. That undoes the entire point of the separation: the whole reason to keep the lists apart is so the report can say which half is trustworthy. Both the step summary and the ::error:: annotation now count the families separately and say explicitly that caller/copy state WAS read when only protection failed. VERIFIED - selftest 86 pass / 0 fail - all three message shapes exercised (caller-only, protection-only, both) - full fleet audit re-run: 20 of 20, no drift - ruff clean Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
LukasWodka
commented
Aug 10, 2026
Confirmed and fixed in |
| # | finding | rule broken |
|---|---|---|
| 1 | exempt probe let a failed read decide "not stale" | a failed read must not decide a negative |
| 2 | divergent overrides validated names, not values | validate what changes behaviour |
| 3 | ruleset read took page 1 as the whole answer | a partial read is not a verdict |
| 4 | protection failure could destroy the report | fail-closed means red, not amnesia |
| 5 | the report then misnamed which half failed | isolating two things is pointless if you re-merge them before saying so |
Five findings, all at read/report boundaries, none in the domain logic — and #5 was introduced by the fix for #4. That is the honest case for this PR existing: the rule is easy to state and repeatedly easy to break, including while fixing a break of it.
shujaatTracebloc
left a comment
There was a problem hiding this comment.
Approving — reviewed the code, not just the green.
What it does: schema v2 adds a fleet protection_policy (develop/staging/prod roles) and a per-repo protection cell (required / exempt / divergent), and caller-drift.py audits effective branch protection across both GitHub systems (classic API + rulesets), failing closed on unreadable reads.
Verified locally against head 465f1627:
- Ran the selftest — 86/86 pass, including the behavioral paths that matter: ruleset-only branch merge,
--paginateelement reassembly across pages, exempt-staleness ordering (decides on what's known first, only falls back tounreadablewhen nothing was established), and the separateprotection_unreadablelist so a protection outage can't trigger the all-repos-unreadabledie()and discard real caller findings. - Spot-checked the design decisions and they're sound:
prodresolved from the branch list (notbranches/masterprobes, which follow rename redirects); classic 404 treated as fact not read-failure; allow→block flag inversion;strict/enforce_adminsnull= not-asserted vsfalse; and thebool-is-intguard onmin_reviews. - Every Bugbot finding is resolved with an inline
(Bugbot, .github#196.)annotation at the fix site.
CI all-green, Bugbot clean, 0 unresolved threads. Nicely scoped increment. LGTM.
Uh oh!
There was an error while loading. Please reload this page.

What this does
Increment 1 of #1608 gave the harness teeth (
conformance-gate.yml, now required ondevelop). This gives it a second thing to check, and turnsrepo-inventory.ymlfrom a caller inventory into the per-repo contract the ticket describes.The first property added is the one backend#1276 just settled by hand: branch protection.
Why this property first
#1276 armed required checks and closed admin bypass across 16 prod branches one API call at a time. Nothing in the org would have noticed any of it drifting back. Now both directions are findings — someone weakening a setting, and someone "fixing" a deliberate choice (
strict: false) back to a default.The schema
protection_policystates the policy per branch role; every repo carries a cell per role. Same headline rule as callers: absence is never implicit, and an exemption without a written reason is rejected.Two shapes worth calling out:
divergenttakes a mapping, not a bare string — it must name the policy keys that differ. A blanket "this one is different" would switch off every assertion at once, which is how an exemption written for one reason silently covers an unrelated regression later.docsdiverges onmin_reviewsalone, so its force-push, deletion and admin posture are all still asserted.nullmeans NOT ASSERTED, deliberately different from assertingfalse.enforce_adminson develop/staging isnullbecause #1276 leaves that escape hatch open — a repo that chooses to harden its owndevelopmust not be reported as drift for doing something stricter.Both protection systems, or this would lie
GitHub runs two independent protection systems and the classic API is blind to the second. A ruleset-only branch returns
404 "Branch not protected"frombranches/{b}/protectionwhileGET /branchesreportsprotected: true.On 2026-08-10 an audit that read only the classic endpoint reported
docs/stagingas unprotected and recommended fixing it. The fleet-widepromotion-branches-merge-commit-onlyruleset had been covering it the whole time. That wrong finding is whyread_protection()merges the two systems per property rather than per system — the rules do not compose:allow_force_pushes: falsepull_requestornon_fast_forwardallow_deletions: falsedeletiononly —pull_requestdoes NOT cover itpull_request.required_approving_review_count(max of both)Also:
prodresolves from the branch list, never by probingbranches/master. That call follows rename redirects and returns 200 for a branch that does not exist — measured the same day, all 16 train repos reported amaster; only 3 have one.Fail closed
A 404 on the classic endpoint is a fact ("no classic protection"). Any other error, and any unreadable ruleset read, is UNREADABLE — never "unprotected". An unreadable ruleset read fails closed even when the classic read succeeded, because a partial view of protection is not a verdict.
Verification
The fleet audit landing green is the bar increment 2 had to clear — a checker that arrives red gets ignored, which is the
code-qualitysoft-fail lesson.The negative control matters more than the green one. Mutating the inventory to claim things that are false produced:
Note the ruleset-aware wording — a branch with no classic layer says whether a ruleset covers it, so the reader is not sent to re-derive that by hand.
What the exemptions record
Nine
exemptand fivedivergentcells, each with a written reason:min_reviews: 0on staging + prod (the 2026-06-04 content-only exemption)enforce_admins: falseonmain: not on the train, so thatmainis a working branch, not a pipeline prod branch. Deliberate forrelease-trainspecifically — it is the tool used to repair the pipeline, so locking it hardest is self-defeating.develop— exists but unprotected. UNREMEDIATED, recorded with a name on it rather than quietly dropped.Not done here
The rename to
repo-conformance.py. That also means touchingconformance-gate.yml'sGUARDEDlist andcaller-drift.yml, and coupling a rename to a behaviour change makes both harder to review. Mechanical, separate, later.Parent: backend#1608 · Decisions encoded: backend#1276
🤖 Generated with Claude Code
Note
Medium Risk
Changes org-wide conformance checks and GitHub API reads for branch protection; mis-merged ruleset/classic logic or inventory mistakes could false-positive or miss drift on critical branches, though fail-closed and fleet green run reduce that risk.
Overview
Extends repo conformance beyond workflow callers/copies:
repo-inventory.ymlbecomes schema v2 with a fleetprotection_policy(develop/staging/prod roles, backend#1276) and aprotectioncell on every repo (required,exempt, ordivergentwith named key overrides so other assertions stay live).caller-drift.pynow audits branch protection via GitHub’s classic API and rulesets (paginatedrules/branches), merges effective settings per property, resolvesprodfrom the branch list (notbranches/masterprobes), and fails closed on unreadable reads. Protection API failures go to a separate unreadable list so a protection outage does not wipe caller findings or trigger the all-repos-unreadable abort. Reporting is labeled repo conformance drift.Selftests add ~25 cases for protection schema, ruleset-only branches, exempt staleness ordering, and pagination.
Reviewed by Cursor Bugbot for commit 465f162. Bugbot is set up for automated code reviews on this repo. Configure here.