Skip to content

fix(hooks): the claim gate passed any commit whenever its own git read failed (BACKLOG #1383) - #686

Merged
wshallwshall merged 10 commits into
mainfrom
b1-1383-claimgate-landable
Sep 2, 2026
Merged

wshallwshall merged 10 commits into
mainfrom
b1-1383-claimgate-landable

Conversation

@wshallwshall

Copy link
Copy Markdown
Collaborator

Two commits. The first is a fail-open in a gate.

The defect, confirmed at the source

_git returned .stdout without checking returncode. So a failed git read was indistinguishable from an empty diff, and an empty diff takes the docs-only exit — meaning the claim gate passed any commit whenever its own read broke.

scripts/hooks/claim_check.py    'returncode' occurrences
  origin/main    0
  this branch    3

Zero on main is the whole finding: the function had no way to tell success from failure.

This is the class the fleet hit three separate times today — a control that cannot observe its own failure. ADR 0158 Class 2, and here it is in a gate rather than in a report.

Lander checks

merge-tree vs current main   rc=0, clean
files a merge would change   docs/BACKLOG.md, docs/SECURITY.md,
                             scripts/hooks/claim_check.py, tests/test_claim_check.py,
                             tests/test_docs_cite_no_refused_config_keys.py (added)
branch state                 2 ahead, 2 behind
push verified (after fetch)  local dcfa52e1c == remote dcfa52e1c
author's local run           347 tests, tree clean

One thing this PR's own landing should not obscure

The branch was rescued from a docs/BACKLOG.md collision by a rebase at about 17:4xZ. Per BACKLOG #1395 — filed and measured today — git never invokes pre-commit for a sequencer commit, so that rebase re-ran none of the eleven hooks. Reproduced independently: 11 gate result lines on an ordinary commit, 0 on a cherry-pick that really created a commit.

So CI is the only gate this branch has passed since the rebase. That is probably fine and was true before anyone knew, but it should be said rather than assumed — and which of the eleven have a CI twin is still unmeasured.

Neither the author nor the lander has reviewed the 347 tests on their merits. The lander verified the fail-open at the source, that the merge is clean, and what a merge would touch.

@wshallwshall
wshallwshall added this pull request to the merge queue Aug 29, 2026
@wshallwshall

Copy link
Copy Markdown
Collaborator Author

Dequeued deliberately — it conflicts with #684, which is ahead of it

Not a defect in this PR. It reported UNMERGEABLE in the merge queue while its own page reads MERGEABLE with zero failing required checks.

686 vs #684's branch   merge-tree rc=1   CONFLICT
686 vs #682's branch   merge-tree rc=0   clean
686 vs origin/main     merge-tree rc=0   clean

Both touch docs/BACKLOG.md and docs/SECURITY.md.

A PR can be MERGEABLE against main and UNMERGEABLE in the queue — the queue tests it against the provisional head, which includes everything ahead of it. Leaving it enqueued would have stalled #684 and #682 behind an entry that cannot form a group.

Order: #684 lands, then this needs a rebase onto the new main, then it re-queues. The rebase is mechanical (ledger tail plus a SECURITY.md overlap) and the branch carries no allocated row of its own, so it is mine to take rather than its author's.

Nothing about the fix itself has changed. The fail-open is still real and still confirmed at source — returncode 0 occurrences on main, 3 on this branch.

@wshallwshall
wshallwshall removed this pull request from the merge queue due to a manual request Aug 29, 2026
@wshallwshall

Copy link
Copy Markdown
Collaborator Author

Needs resolving by its author — the ledger gate refuses me, and I am not using the known bypass

#684 landed, so this now conflicts with main. Its only conflicting path is docs/BACKLOG.mddocs/SECURITY.md auto-merged cleanly.

I resolved it (main's five rows first, then #1383 appended; 394 items, no duplicates, zero conflict markers) and the commit was refused:

BLOCKED: BACKLOG item #1383 was not allocated to this worktree

That is correct and I reverted. An ordinary commit runs the gate, and the ownership check applies. #1383 belongs to your worktree.

Why I did not reach for the sequencer instead

Because it would have worked, and that is the problem. BACKLOG #1395 — filed and independently reproduced today — establishes that git never invokes pre-commit for a sequencer commit: 11 gate result lines on an ordinary commit, 0 on a replay that really created a commit. Replaying would have carried identical content past the same gate without it running.

Its sibling finding closes the other door: ledger_check.py guards ownership as elif not self.ci and not self.owns(...) at :296 and :385, while ci.yml:121 invokes it with --ci. CI never checks allocation ownership either. So a replayed commit has no enforcement path for this rule at all.

A gate that has just refused me is not one to reach around with a known bypass, however available it is. The refusal is telling me where the commit has to be made.

What is needed

Bring this onto current main from the worktree that allocated #1383, keeping both sides of the ledger tail — main's rows first, yours appended — and push. Then it lands.

The fix itself is unchanged and still verified: returncode appears 0 times on main and 3 times on this branch. The gate passed any commit whenever its own read failed.

@wshallwshall

Copy link
Copy Markdown
Collaborator Author

Conflicting again after the latest landings — and the resolve is yours, not mine

merge-tree against current main: the only conflicting path is docs/BACKLOG.md. Nothing else.

The ledger gate refuses me on a merge carrying a row I did not allocate — I tested that on this exact class earlier and got BACKLOG item #NNNN was not allocated to this worktree. A merge is an ordinary commit, so the gate runs and ownership applies.

I could have replayed instead and it would have worked — BACKLOG #1395 measures 11 gate result lines on an ordinary commit and zero on a sequencer commit, and CI never checks ownership either (ledger_check.py guards it behind not self.ci; ci.yml passes --ci). I have not used that on any branch today. A gate that just refused me is telling me where the commit belongs.

What is needed

From the worktree that allocated the row: bring the branch onto current main, keep both sides of the tail — main's rows first, yours appended — and push. Then it lands.

Controls worth running: parse_items reports no duplicate numbers, your rows present, and main's newest rows still present.

The structural note, because this is the fourth time today

One file serialises every row-carrying branch. docs/BACKLOG.md takes an append from each, they conflict pairwise, and each landing invalidates the next one in line. The Lander cannot break that up, because the ownership rule puts every resolve back with a different seat.

Three branches have now needed a resolve for this reason alone, none of them for a defect in the work.

wshallwshall added 2 commits August 30, 2026 07:17
…het the rest (BACKLOG #1383)

THE MECHANISM, READ FROM SOURCE RATHER THAN RELAYED. _RELOCATED_TO_SECURITY maps 15 legacy
[section] key spellings to their [security] replacements. _reject_relocated_keys RAISES ValueError
and is called at settings.py:4588, BEFORE _desugar_security at :4592, so a legacy spelling never
reaches the desugarer. Refusing is the whole behaviour, not a fallback -- worth checking rather
than assuming, because ADR 0118 is described as desugaring.

THE SEVERITY IS LOWER THAN THE ORIGINATING HANDOFF ASSERTED, AND THE ORIGINATOR WITHDREW THE CLAIM.
It said at least one line INSTRUCTS an operator, causing a load refusal on first start. Measured,
with the key list imported from the table so it cannot drift: 0 refused keys inside any fenced code
block, 0 imperative instructions, 3 TOML-shaped prose citations, 19 descriptive mentions, against a
positive control of 4 new-spelling hits. The line meant was :811, read with :810 it is declarative.

SO THE RESIDUAL RISK IS NARROWER AND STILL REAL: nobody hits a load refusal, because nothing tells
them to set the key. But a reader told "the documented org opt-out is X" may well write X, and X is
refused.

THREE FALSE-POSITIVE CLASSES, RECORDED BECAUSE MY OWN FIRST PASS RETURNED 40 -- looser than either
earlier estimate. create_app(serve_ui=True) is a PYTHON KEYWORD ARGUMENT, not a TOML key;
[approvals].enabled and [auth].oidc_enabled are DIFFERENT KEYS; and lines that CORRECTLY DOCUMENT
THE REFUSAL are not defects. SECURITY.md:186 says the [diagnostics].audit_all_authz spelling "is
refused at load" -- the one place the document is already right, and the originating scan counted it
as a defect. The discriminator that separates config from API surface is one character: TOML
booleans are lowercase.

THE CLAIMS WERE MEASURED, NOT TRANSCRIBED. An approval settles whether a change may be made, never
whether the words are true. require_sign_in defaults True and require_mfa_scope defaults
"every_local_account" in source, so the three sentences stay true after the respelling.

THE GATE IS A RATCHET, DELIBERATELY. 26 other documents carry 56 more citations and the agreed scope
was docs/SECURITY.md only, so a repo-wide check would land red and get disabled while a one-file
check would let the rest spread. Every affected file is pinned at its measured count, a file absent
from the table must be at zero, and new files or new citations fail immediately.

IT SELF-PRUNES, WHICH IS WHAT KEEPS A BASELINE FROM ROTTING INTO A SUPPRESSION LIST: fixing a file
below its number fails until the number is lowered, and fixing it entirely fails until the row is
deleted. BOTH DIRECTIONS PROVED BY MAKING IT FAIL ON PURPOSE -- a new doc carrying a citation fails,
and removing a citation from a baselined doc fails the self-pruning test.

Controls in the suite itself: the table must be populated (an empty import would make every check
pass vacuously), a planted bad line must be caught, and the two negative controls -- the
documented-refusal line and the Python kwarg -- must NOT be caught.

Scope: docs/SECURITY.md + docs/BACKLOG.md + tests/test_docs_cite_no_refused_config_keys.py.
Clearing the other 26 documents is separate and is not filed here.
…failed

THE GATE WAS DISARMED BY THE ONE CONDITION IT CANNOT DETECT, AND IT PRINTED NOTHING. _git returned
subprocess.stdout and never looked at returncode, so a failed read returned "" -- indistinguishable
from a genuinely empty diff. That flowed to _staged_paths() -> [] -> _touches_code([]), which is
False BY DESIGN so a message-only --amend is never blocked, and main() took its docs-only exit and
returned 0. A commit citing an UNCLAIMED item passed, silently.

RED-FIRST, BOTH VERSIONS RUN SIDE BY SIDE FROM A DIRECTORY GIT CANNOT ANSWER IN:
    OLD (pre-fix)    exit 0   PASSES the commit -- FAIL OPEN
    NEW (fixed)      exit 1   refuses

AND THE EMPTINESS IS THE FAILURE, NOT THE NORM, measured with a control on the same box: outside a
repository the staged-diff read exits 129 with empty stdout; inside the repository it exits 0. The
old code could not tell those apart, which is the whole defect.

FAIL CLOSED, AND ONLY FROM THE POINT WHERE A WRONG ANSWER BECOMES A SILENT PASS. Every exit above
that line is reached WITHOUT calling git, so a commit that does not cite a BACKLOG number is
untouched. Only a commit that DOES cite one, on a box where git cannot answer, is refused --
refusing costs a re-run, passing costs the duplicate build this gate exists to stop.

WHY FAIL-CLOSED IS SAFE HERE SPECIFICALLY, because it is not always: _git has THREE callers and all
of them are in this file. The sibling case that argued for care -- a board tool with sixty callers
written against a function that could not fail -- would trade a silent wrong number for a dead
board. A commit hook has no such surface.

LOW ODDS, AND THAT IS NOT A REASON TO SHRUG. git normally invokes this hook from inside a
repository. It fails on index.lock contention, a corrupt index, git missing from the hook's PATH, or
GIT_DIR oddities in a worktree -- rare states that arrive exactly when several sessions are
committing at once, which is when a duplicate-work gate matters most.

The refusal names the cause, the item, and the usual fixes, and says the check has not failed the
operator -- a gate that refuses without explaining is the next defect.

TWO TESTS, THE SECOND GUARDING THE FIRST: the regression drives the real hook from a directory git
cannot answer in and requires exit 1; its control requires that the SAME command inside a real repo
still refuses with the CLAIM message and NOT the read-failure one. Without the control, deleting the
staged-diff read entirely would satisfy the regression while breaking every real check, because a
refusal for the wrong reason reads identically to a refusal for the right one.

Found by playbooks-manager with a firing control; widened by the dispatcher, which measured the same
shape in the vault copy. THE VAULT HALF IS NOT FIXED HERE -- it is a separate repository.

Scope: scripts/hooks/claim_check.py + tests/test_claim_check.py.
@wshallwshall
wshallwshall force-pushed the b1-1383-claimgate-landable branch from 52690a6 to c0ed201 Compare August 30, 2026 12:18
wshallwshall added 2 commits August 30, 2026 07:32
Sixteen items landed on main since this branch was cut, several of them tail-appending
docs/BACKLOG.md, so this conflicted append-vs-append at the file tail. Only that one
path conflicted; everything else merged automatically.

Resolved keeping BOTH sides in append order, this file's convention rather than numeric
order, verified by reading main's existing tail rather than assumed.

Checked with a set difference rather than a count, because a merge can fold two items'
blocks into one and leave a correct-looking file short by one item while the totals still
agree:

  main 412   branch 397   merged 413   expected 413
  LOST  []   ADDED  []
  only-on-branch preserved [1383]
  positive control 1401 present on main; negative control 999999 absent
  backlog_status_check: OK, 649 items, exit 0

Committed from the worktree the #1383 claim records, which satisfies the ledger gate on
its path key. The claim's recorded branch key names b1-1375-install-gate-merge, which does
not exist on origin at all, so the PR 703 branch fallback cannot help here -- the path key
is the only route, and it is why this had to be committed from this worktree.
@github-actions github-actions Bot added the ci-red A required check went red. Attribute it before retrying. label Sep 1, 2026
wshallwshall added 3 commits September 1, 2026 16:30
…not stale

test_the_baseline_is_exact_and_self_pruning failed on every runner -- ubuntu, windows-2022 and
windows-2025 -- and the baseline was right. Two of its 27 rows name
docs/testing/master-test-plan documents that are UNTRACKED: `git ls-files` does not report them
and no checkout anywhere carries them. They were measured where they do exist, so their counts
are correct; CI simply cannot open the files. Treating "not in this checkout" as "stale row"
made the test unpassable while nothing about the corpus had drifted.

Warn and skip, which is the call two sibling tests already make for their own withheld
documents: test_threat_model_doc_drift.py raises ThreatModelDocUnenforced for docs/security/**
and test_crit2_inline_doc_drift.py raises CoveragePlanUnenforced for docs/testing/**. Both say
the assertion is inert in this run rather than failing. This adds BaselineRowUnenforced and
names the two paths in _WITHHELD_FROM_PUBLIC_CHECKOUTS.

THE EXEMPTION IS TWO NAMED PATHS, NOT A RULE ABOUT MISSING FILES. "Any absent file is fine"
would have deleted the self-pruning property the baseline exists to have. Any other missing row
is still a hard failure, a present row is still enforced exactly, and a floor of
len(_BASELINE) - len(_WITHHELD) stops a baseline decaying to all-absent and passing having read
nothing -- the vacuous green test_the_relocated_table_is_populated already guards upstream.
test_every_withheld_row_is_still_a_baseline_row keeps the exemption from outliving its rows.

Mutated three ways, each red disjoint from the others:
  a non-withheld row goes missing  -> "docs/NO-SUCH-DOC.md: listed but missing"
  a present row's count is wrong   -> "docs/CONNECTIONS.md: baseline says 8, now 9 -- lower it"
  an exempt path leaves _BASELINE  -> "exempted paths that are no longer baseline rows: [...]"

286 passed locally, 2 warnings, both naming the withheld rows. ruff format and ruff check clean.
…nd pass)

Main advanced while this branch waited -- PRs 718 and 723 landed -- so the docs/BACKLOG.md tail
collided again. Append against append, both sides kept in APPEND order; this file's tail is not
sorted by item number.

  main=413  branch=413  merged=414  expected=414
  items on main missing from the merge: []
  items appearing from nowhere        : []
  folded in from main : [1414]
  preserved on branch : [1383]
  positive control 1401 present: True   negative control 999999: False
  backlog_status_check: OK, 414 items, each declaring exactly one status

COMMITTED FROM THE WORKTREE THE ALLOCATION RECORDS. ledger_check.owns() keys on
alloc/backlog/1383.json, which names MessageFoundry-b1-1067-repo-governance; that path still
exists, so the ordinary path key applies and no recovery was needed. The earlier commit on this
branch went through from elsewhere only because it touched no ledger file -- the gate reads what
a commit ADDS, so a code-only commit never reached this check.
…ich is its whole population

BACKLOG #1383's new gate scans docs/**/*.md for config keys the loader refuses. It was reachable
only from the full test legs, and ci.yml's changes job sets code=false when every changed path
matches noncode -- a regex whose first alternative is \.md$. So a pull request that edits ONLY
documentation, the exact shape that can introduce one of these citations, skipped the legs
entirely. The docs-only lane runs DOC_GUARDS instead, and this module was not in it. The gate
existed and could not fail on its own subject.

tests/test_doc_guards_lane.py:83 records the identical hole for a different module: a citation
guard "was absent from DOC_GUARDS while TWO citation siblings were present, so the gate existed".
Same class, second instance, so the fix is membership rather than another one-off.

Added to DOC_GUARDS, which the lane test parses out of ci.yml rather than re-typing, so there is
no second copy to drift. The minimal docs-only install supports it: sibling members
test_crit2_inline_doc_drift.py and test_security_doc_rate_limits.py already import messagefoundry,
and this module imports _RELOCATED_TO_SECURITY from config.settings the same way. The lane is now
20 modules.

Also corrected a stale measurement in freshly written prose, which is the defect this branch's own
review caught elsewhere tonight: the comment above _BASELINE said "27 documents carry 58 of these
citations" while the dict directly beneath it sums to 59 over 27 rows. The table is the only
authority for that number and the line now says so. The count predates the Lander's edits to this
file -- commit 4e0ffc6 touched neither the comment nor any baseline row -- but it is wrong either
way.

290 passed locally across the lane test and the ratchet, 2 warnings, both naming withheld rows.
@wshallwshall wshallwshall added the reviewed A reviewer has read this. Removed automatically when new commits arrive. label Sep 1, 2026
@github-actions github-actions Bot removed the reviewed A reviewer has read this. Removed automatically when new commits arrive. label Sep 1, 2026
…d pass)

PR 726 landed while this branch waited, so the docs/BACKLOG.md tail collided again. Append against
append, both sides kept in APPEND order; this file's tail is not sorted by item number.

  main=414  branch=414  merged=415  expected=415
  items on main missing from the merge: []
  items appearing from nowhere        : []
  folded in from main : [1412]
  preserved on branch : [1383]
  positive control 1401 present: True   negative control 999999: False
  backlog_status_check: OK, 415 items, each declaring exactly one status -- run before this message
  was written.

COMMITTED FROM THE WORKTREE THE ALLOCATION RECORDS, and that is why it works on a detached HEAD.
ledger_check.owns() tries the alloc record's worktree path FIRST and only falls back to its branch;
this checkout IS the path alloc/backlog/1383.json names, so the path key matches and the detached
HEAD never has to be considered. PR 734 hit the opposite case in the same minute: its worktree is
not the recorded path and it was detached, so neither key could match -- a detached HEAD reports the
literal string "HEAD" and can never equal a recorded branch name.
@wshallwshall wshallwshall added the reviewed A reviewer has read this. Removed automatically when new commits arrive. label Sep 2, 2026
@github-actions github-actions Bot removed the reviewed A reviewer has read this. Removed automatically when new commits arrive. label Sep 2, 2026
@wshallwshall wshallwshall added the reviewed A reviewer has read this. Removed automatically when new commits arrive. label Sep 2, 2026
@wshallwshall
wshallwshall added this pull request to the merge queue Sep 2, 2026
Merged via the queue into main with commit 68f7cd6 Sep 2, 2026
63 of 66 checks passed
@wshallwshall
wshallwshall deleted the b1-1383-claimgate-landable branch September 2, 2026 01:12
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

ci-red A required check went red. Attribute it before retrying. reviewed A reviewer has read this. Removed automatically when new commits arrive.

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant