Skip to content

fix(gate): bind certification to content, not to a SHA (#540) - #628

Merged
BaseInfinity merged 3 commits into
mainfrom
fix/540-bind-clearance-to-tree
Aug 15, 2026
Merged

fix(gate): bind certification to content, not to a SHA (#540)#628
BaseInfinity merged 3 commits into
mainfrom
fix/540-bind-clearance-to-tree

Conversation

@BaseInfinity

@BaseInfinity BaseInfinity commented Aug 15, 2026

Copy link
Copy Markdown
Owner

Closes #540. First item of #593 Rung 1's merged order (#540#521#563#617#504).

The hole

hooks/codex-gate-check.sh keyed staleness on commit_sha == HEAD. PreToolUse runs before the commit, so at check time HEAD is still the certified commit and the gate allows — then the commit lands carrying whatever is staged. The resulting commit is by construction not the one that was reviewed. The issue's own stated test — "a fixture that certifies, commits, and asserts the gate refuses; currently it passes, which is the bug" — is row 1 of the new suite, and it failed before this change.

The same key also taxed every commit with a re-pin call (~10 wasted tool calls in one measured cycle) while protecting nothing a content pin does not.

The mechanism, as ruled

Per #540's reconciled comment of 2026-08-11 (Fable 5 + Sol, independent agreement after one reconcile round), which amended the earlier patch-id ruling. Two measurements killed patch-id:

  1. Two diffs differing only in the indentation of an added Python line produce the identical patch-id. It ignores whitespace by design, and whitespace is semantic in Python, YAML, Makefiles and string literals.
  2. What merges is the resulting tree, not the diff. A rebase onto moved upstream keeps patch-id identical while producing content nobody read.

So certification names the tree. The hook compares git write-tree to candidate_tree; scripts/merge-pr.sh compares the remote head's tree, and the base it was read against.

Deliberately preserved: message-only amend, squash and reorder with no upstream movement all produce the same tree, so certification survives them at zero cost. Rows pin both directions — a guard that only ever refuses is indistinguishable from a broken one.

Two decisions worth reviewing

No flag parsing. git commit -a sweeps unstaged changes in, so the committed tree would not be the index's. The fix is not to look for -a: requiring a clean tracked worktree makes git commit and git commit -a commit the same tree. Modelling git's option grammar in a regex is what took #610 from 3 → 6 → 4 before round 4 deleted it.

base_tree is enforced at the merge boundary only. At commit time the base is HEAD for a normal commit and HEAD^ for an amend, and telling them apart means reading --amend off the command line — the same trap. Pinned as row 6, which asserts ALLOW with the reasoning inline.

Corrected in round 2. This paragraph originally justified the deferral with "it costs nothing to defer: a rebase changes the index tree, so the candidate check already refuses it". That is false, and Sol falsified it with a running counter-example: when upstream independently produces the candidate's final content, the rebase drops the now-redundant branch change and the index tree does not move (base_moved hook=0 candidate_same=yes base_changed=yes). The deferral survives on the narrower ground that PreToolUse cannot observe post-command state and the base ref is well-defined only at the merge boundary — which is exactly why that boundary must read live server state rather than any cache.

Contract change: three suites encoded the old one

  • test-hooks.sh's "blocks a stale certification after a new commit" row is now "blocks staged content the certification never saw" — under tree binding a new commit that changes nothing reviewable is exactly the preserved case.
  • test-merge-gate.sh and test-cross-model-clearance.sh used a made-up 40-char head with no git objects behind it, which cannot survive a check that resolves a tree. Both build real ones now.

Verification

RED first: 3 passed / 5 failed against the unmodified hook. GREEN: 8/0.

Every guard proven by mutation, not by reading:

mutation result
hook drops the != candidate_tree half 8/0 → 6/2
merge gate's candidate_tree compare → if false fixture merges (exit 0)
merge gate's base_tree compare → if false fixture merges (exit 0)

Suites green: test-hooks 225/0, test-merge-gate 83/0, test-cross-model-clearance 49/0 (baseline 49/0), new suite 8/0, doc-consistency 137/0, workflow-triggers.

Registered in both ci.yml and CONTRIBUTING.md#610 ran eight review rounds with CI red because a suite reached one and not the other.

Prior art read rather than derived, per the ruling: codex-sdlc-wizard#111's requireFrozenIndex + candidateTree.

The CERTIFIED lane keyed staleness on `commit_sha == HEAD`. PreToolUse runs
before the commit, so at check time HEAD is still the certified commit and the
gate allowed — then the commit landed carrying whatever was staged. The
resulting commit was by construction never the one that was reviewed. Sol found
it at file:line; Fable withdrew its own recommendation to extend the gate once
shown the counter-example.

The same key also taxed every commit with a re-pin call, ~10 wasted tool calls
in one measured cycle, to protect nothing a content pin does not.

Certification now names the tree it was issued over. The hook compares
`git write-tree` against `candidate_tree`; the merge gate compares the remote
head's tree, and the base it was read against, before merging.

An earlier ruling chose `git patch-id --stable`. Both advisors amended it after
measuring two failures: two diffs differing only in the indentation of an added
Python line produce the identical patch-id, and whitespace is semantic in
Python, YAML, Makefiles and string literals; and what merges is the resulting
tree, not the diff, so a rebase onto moved upstream keeps patch-id identical
while producing content nobody read.

Message-only amend, squash and reorder with no upstream movement all produce
the same tree, so certification survives them at zero cost. Only content that
moves invalidates.

The hook does not parse commit flags. Requiring a clean tracked worktree makes
`git commit` and `git commit -a` commit the same tree, so the index speaks for
both; modelling git's option grammar in a regex is what took #610 from 3 to 6
to 4 before round 4 deleted it. `base_tree` is checked only at the merge
boundary for the same reason: at commit time the base is HEAD or HEAD^
depending on whether it is an amend, and that costs nothing to defer because a
rebase changes the index tree anyway.

Three suites encoded the old contract and were rewritten with it. The stale-SHA
row became a moved-content row, because a new commit that changes nothing
reviewable is now exactly the case the ruling preserves. Two fixtures used a
made-up 40-char head with no git objects behind it, which cannot survive a
check that resolves a tree; both now build real ones.

Prior art read rather than derived, per the ruling: codex-sdlc-wizard#111's
requireFrozenIndex + candidateTree.

Each guard proven by mutating the file and re-running: hook 8/0 -> 6/2, and
either merge-gate comparison neutered lets the fixture merge.
…540)

Round 1 of the cross-model review found one blocker, independently, in both
legs, and both constructed it rather than argued it. The base_tree check
resolved `origin/$BASE_BRANCH^{tree}` — a local cache. Absent, the
`[ -n "$CUR_BASE_TREE" ] &&` guard skipped the comparison outright: one
`update-ref -d` merged a certification carrying a bogus base_tree at exit 0.
Stale, the cached tree still satisfied the certification: a bare server repo
with main advanced and the local ref left behind reached the same exit 0. The
check was fail-open in exactly the situation it exists for, and its posture
contradicted the fail-closed candidate_tree check ten lines above it.

The base is now read from the PR's own baseRefOid, resolved with the same
fetch-fallback as the remote head, and blocks when it cannot be resolved. Both
reviewers prescribed this same fix.

A claim in the round-1 rationale was also false, and it was load-bearing. The
deferral of base_tree to the merge boundary was justified by "a rebase onto
moved upstream changes the index tree, so the hook's check already refuses it".
When upstream independently produces the candidate's final content, the rebase
drops the redundant branch change and the index tree does not move. The
deferral survives on the narrower ground that PreToolUse cannot observe
post-command state and the base ref is well-defined only at the merge boundary
— which is precisely why that boundary may not trust a cache.

The test that asserted the exact merge command read .fixture-sha after deleting
the fixture, so the substitution was empty and the grep passed on a prefix
match without checking the SHA.

Guards proven by mutation, reverted by inverse edit: neutering the fail-closed
branch gives 84/1, and reverting the base read to the tracking ref gives 83/2
with the fixture merging at exit 0. The first mutation attempt reverted with
`git checkout --`, which silently discarded the uncommitted fix along with the
mutation and made the reading meaningless.

The unreadable-base row first asserted only a non-zero exit and stayed green
through its own mutation, because the mismatch check below rejects an empty
tree anyway. Both new rows now assert the specific refusal.
…emembers (#540)

Round 2 fixed the wrong thing correctly. It replaced a stale local tracking ref
with `baseRefOid`, which both reviewers had independently prescribed — and which
live data falsifies. That field is the base commit associated with the pull
request, a snapshot that does not move when the branch does. On this repo PR
#615 carries baseRefOid f8ba12b while main is at d0e1c7b, and the trees differ.
So round 2 swapped an indefinitely stale local cache for an indefinitely stale
server-side snapshot: the same hole, relocated.

Both rounds failed the same way, and the shared mistake is the finding: read a
cached value, treat it as current. The base tip now comes from
`gh api repos/{owner}/{repo}/git/ref/heads/<base>` on every run, and
`baseRefOid` is removed from the parsed field list rather than left sitting
there for someone to reach for again.

Everything downstream of the OID is unchanged — the fetch-fallback, the
fail-closed refusal, the tree comparison were all reviewed and cleared.

The round-2 regression test agreed with the bug. It hand-set the base OID to the
advanced commit, which is precisely what GitHub does not do, so it could never
have caught this. The stub now drives the two knobs separately: baseRefOid stays
at the snapshot while the ref endpoint reports the moved tip. Reverting the
source to the snapshot field is red permanently.

A shipped comment in the commit hook asserted that the merge boundary reads
baseRefOid. It did, and it was wrong; the correction keeps the falsification
rather than quietly restating the conclusion.

Removing the field regressed the clearance suite to 40/9 — eight stubs answered
`pr view` and not the ref endpoint. All eight now answer both.

Mutation: swapping the source back to the snapshot gives 83/2 with the fixture
merging at exit 0, reproducing the round-2 defect. A first attempt broke the
JSON parse instead of the source and went red for an unrelated reason, which
proves nothing; it was redone as a clean swap.

The check-to-merge race is recorded as an accepted limit. --match-head-commit
binds the head atomically and GitHub offers no base equivalent, so a base move
inside that window cannot be closed from the client.

Suites: merge-gate 85/0, clearance-binds-to-tree 8/0, hooks 225/0,
cross-model-clearance 49/0.
@BaseInfinity

Copy link
Copy Markdown
Owner Author

CROSS-MODEL-CLEARANCE

{"confidence":99,"reviewer":"sol","sha":"912eb65651bf8956380f8cdd73f48f603c847fb4","verdict":"YES"}

Round 3, GPT-5.6 Sol at high. Score 9.5/10, no changed-line blockers.

Verified live rather than asserted: REST returned d0e1c7b93... for the base tip, and five repeated GraphQL baseRef.target.oid reads plus git ls-remote all matched it, while baseRefOid stayed pinned at f8ba12b42... with a different tree. No REST read-after-write lag observed — stated as an empirical result, since GitHub publishes no consistency SLA for it.

baseRefOid has no decision path left in scripts/merge-pr.sh; all three remaining occurrences are historical comments.

Mutations reproduced independently: snapshot source 83/2 with two exit-0 merges, fail-closed branch neutered 84/1, tracking ref restored 83/2. Baselines merge-gate 85/0, clearance-binds-to-tree 8/0, hooks 225/0, cross-model 49/0, doc-consistency 137/0, workflow-triggers 169/0. Reverting the eight stub repairs reproduced 40/9, confirming they supply the newly required response rather than relaxing assertions.

Non-blocking items filed as #632 (two comments describing removed mechanisms). The falsified "rebase changes the index tree" premise has been corrected in the PR body above.

@BaseInfinity

Copy link
Copy Markdown
Owner Author

CROSS-MODEL-CLEARANCE

{"confidence":98,"reviewer":"fable","sha":"912eb65651bf8956380f8cdd73f48f603c847fb4","verdict":"YES"}

Round 3, Fable 5. Score 9/10.

Concession recorded first. This leg CERTIFIED at 9/10 in round 2 on a false premise — it characterised the baseRefOid residue as "a seconds-wide in-flight race". Asked to re-run the commands, it did, and retracted: PR #615 was opened 13:43Z with main at f8ba12b; main moved 16:21Z to d0e1c7b; the field never followed. A persistent per-PR snapshot, stale for the PR's lifetime. Sol's round-2 5/10 was correct, and this leg's round-2 mutation evidence was real but measured a fixture that agreed with the bug.

That round-2 certification was not used to merge anything.

Independently reproduced on this head, mutations run in an isolated clone: faithful revert to parsing baseRefOid 83/2 with two real GH_MERGE_INVOKED merges; fail-closed branch neutered 84/1 on the unreadable-base row via the wrong-branch message; revert to the local tracking ref 83/2. Baselines 85/0, 8/0, 49/0, 225/0, CI validate green.

Attack (b): baseRefOid survives only in comments at lines 485/488/492; nothing parses it. Attack (d): the stub diff adds handlers and changes zero assertions — repair, not suppression.

Filed non-blocking, now on #632: tests/test-merge-gate.sh:379 still asserts "baseRefOid is SERVER truth about where the base branch is now" — the exact falsified sentence, in the tree at this head.

@BaseInfinity

Copy link
Copy Markdown
Owner Author

DUAL CROSS-MODEL CERTIFIED MERGE

Cleared by: sol (posted by @BaseInfinity), fable (posted by @BaseInfinity) — both bound to 912eb65651bf8956380f8cdd73f48f603c847fb4

Merge-evidence path(s) this authorised:

  • .github/workflows/ci.yml (merge-evidence path)
  • hooks/codex-gate-check.sh (merge-evidence path)
  • scripts/merge-pr.sh (merge-evidence path)

Verified: CI validate green across every run of that name; no net-removed test files; no package.json version bump; clearance artifact CERTIFIED at round >= 2 bound to this SHA; the executing merge script and its redirect hook byte-match origin/main.

ATTESTED, NOT AUTHENTICATED. Both clearances were posted by the same gh token, so this records that two distinct reviewers returned YES at >=95 — not that two independent principals did. A new workflow file can still mint a green required check; the compensating layer is that both reviewers read this diff.

Posted by scripts/merge-pr.sh --dual-certified before merging.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Gate hole: the committed SHA is never the reviewed SHA

1 participant