feat(step7): the merge order becomes a predicate on the target tree - #357
Merged
Merged
Conversation
The frozen T0 names an accepted harness identity that, on its own branch, does
not exist yet: `104c384d01bf` arrives with the memory-semantics repair, which is
a different pull request. That is fine while the merge order holds — and an
order held by agreement is one stray click from becoming an archaeological
artifact, with a document saying FROZEN in a tree where its referent is absent.
So the order is checked instead of promised, and checked against the TARGET
TREE rather than against pull-request numbers: a number proves someone pressed a
button and says nothing about what the merged tree contains. The gate refuses
unless, at the commit a merge would produce:
- T0 declares FROZEN *and* collection_authorized: true;
- the harness digest recomputed from that tree's own instrument sources, by
the frozen formula, equals the one T0 names;
- the policy freeze, the design constants and the training preregistration all
bind that same digest — present is not the same as re-bound;
- the capture, qualification and binding tools exist and actually enforce the
campaign link and the authority state;
- the step-7 note still revokes the automatic collection authority, so hosts
plus a binding cannot again be enough to start a clock.
The expected digest is read out of the frozen T0 and recomputed from the tree; a
control proves no digest literal lives in the gate's own source, because a gate
trusting its own constant would be checking itself.
Exercised against real merges, not only fixtures. The full chain
(#355 -> #356 -> #353 -> #354) is allowed. #354 alone is refused on three
predicates at once — the instrument hashes to 562a7f7232da where T0 names
104c384d01bf, the bindings are stale, the machinery is absent. #355 plus #354
without the machinery is refused on one. T0-0 turns out to need no predicate of
its own: #353 is an ancestor of the freeze commit, so it cannot be skipped.
merge gate controls: 7 passed, 0 failed.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_018xhcg5opoFbSTdYHpkSXCh
|
Important Review skippedAuto reviews are disabled on base/target branches other than the default branch. Please check the settings in the CodeRabbit UI or the ⚙️ Run configurationConfiguration used: Path: .coderabbit.yaml Review profile: CHILL Plan: Advanced Run ID: You can disable this status message by setting the Use the checkbox below for a quick retry:
Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out. Comment |
The first revision proved words. `step7_machinery_present` asked whether the two tool files existed and whether certain identifiers appeared in them, which a file containing `def check_campaign_link(): pass` satisfies — and the fixture that was supposed to exclude that world was exactly that file. A check for the name of a mechanism is not a check of the mechanism. `step7_machinery_enforces` now extracts the target tree's own hostqual and execbinding, runs them in a subprocess against a synthetic campaign, and requires all four attacks to be refused: a link naming another execution binding, a freeze edited after the link was made, FROZEN with collection_authorized false (on both readers), and a campaign swapped between preflight and postflight. A tool that accepts any of them fails the gate, and the refusal says which attack got through. `steps_4_5_6_rebound` searched each artifact's text for the digest. The right digest sitting in any field — a comment, a history entry, a field nobody binds — was read as a binding. It now walks the exact path each artifact actually binds at: measurement_harness_digest in the policy freeze, bound_measurement_harness_digest in the design constants, bindings.measurement_harness_digest in the training preregistration. Anywhere else is not a binding, and the refusal names the path and both digests. Fixtures ship the real tools; the two attack controls mutate one enforcement point each and require the gate to notice. Twelve controls, four of them new: a permissive link check is refused, an authority check that always passes is refused, a stale binding with the right digest in a decoy field is refused, and correct exact fields are allowed. Against the real merges: the full chain is allowed (rc 0); #354 without #355 and #356 is refused on three predicates (rc 1); #354 with the instrument but without the step-7 tools is refused on one (rc 1). 30d2f32 is untouched. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_018xhcg5opoFbSTdYHpkSXCh
…thing Two holes in the proof, not in the mechanism. The authority table exercised three of the four states. R15 defines all four, and `NOT_FROZEN+false` was the one nobody checked — so a future tree in which a reader quietly authorised it would have passed the gate. It is in the table now, and `DEAD_AUTHORITY` has a twin that breaks `execbinding.t0_at` instead of `hostqual.bind_t0`: the gate's message claims enforcement on both readers, and a control that only breaks the first left half that claim resting on nothing. Both mutations must now be caught naming all three forbidden states. The campaign-swap witness asserted only that a swapped campaign is inadmissible. A `session_admissibility` returning `admissible: False` unconditionally would have satisfied it — the witness would have read "nothing is admissible" as "the swap was caught". It now requires the unchanged campaign to survive preflight to postflight first, and requires the swapped one to be refused by a reason naming the campaign link, so a broken closing probe cannot stand in for the continuity check. Two mutations prove each half bites: removing only the preflight/current campaign-link comparison while leaving `check_campaign_link` intact, and a postflight that refuses everything. A mutation that changes no bytes now raises rather than passing as a second positive control nobody reads as one. 15 controls, 0 failed. The probes are unchanged: full chain rc 0, #354 alone rc 1 on three predicates, #354 without the tools rc 1 on one. 30d2f32 is untouched. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_018xhcg5opoFbSTdYHpkSXCh
#357 built a gate that refuses the wrong merge. Nothing ran it. A weapon in a safe is not a guard at the door, so this is the wiring: a workflow that runs it against the commit a merge would produce, under two names a ruleset can require. Wiring it exposed two things the gate could not answer on its own. **Applicability.** A required check runs on every pull request in the repository. `mergegate.py` refuses a tree with no T0 in it — correctly, as a question about a contract that must be there — so as a required check today it would refuse every pull request, including this one. The wrapper settles it first: no frozen T0 at the merge commit means no merge can make the contract reachable, the check passes and says so. The moment a tree carries T0 every predicate applies in full. **Co-change.** The workflow that runs on a pull request is the one on that pull request. A branch could otherwise carry the freeze and a weakened gate together and be judged by the gate it brought with it. So the gate's own files — `mergegate.py`, this wrapper, the workflow — may not change in the same merge that introduces or changes the frozen contract. Repairing the gate on its own stays ordinary work; that distinction is a control, not a promise. The applicability decision lives in the tool that owns `T0_PATH`, never a second copy in YAML, and a control fails if the workflow ever grows one. Another fails if the contexts the workflow declares stop matching the ones a ruleset is told to require — a required check nothing reports waits forever and reads as protection. Exit codes are the step-7 three: 0 proceed or not applicable, 1 refused, 2 the question could not be asked. 11 wiring controls, 0 failed; the 15 gate controls unchanged. Both suites also run green on Linux, which is where the job will run. Against real commits: this branch's head is not applicable, the full chain is allowed, #354 alone is refused on three predicates. This is half the wiring. Until a ruleset requires these two contexts, the job reports and nothing is prevented. #354 stays a draft until it does. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_018xhcg5opoFbSTdYHpkSXCh
ruff allows a sys.path insertion before an import, so the E402 suppression was covering nothing and RUF100 said so. Caught by running the CI-pinned ruff (0.15.8) rather than the one that happened to be installed. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_018xhcg5opoFbSTdYHpkSXCh
Three ruff findings, all mine: an unsorted import block, one over-long line inside the witness source, and an unpacked name a control never reads. The long line is inside WITNESS_SOURCE, so wrapping it edits the script the gate runs against the target tree's tools. Hoisting the identity into a local changes nothing it does, and the controls and probes were re-run to say so rather than assumed: 15 merge gate controls pass, host qualification 26/26, and the three real merge probes still come back 0 / 1 / 1 with the same predicates. This is a lint pass only. No predicate, no attack and no message moved, and 30d2f32 is untouched. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_018xhcg5opoFbSTdYHpkSXCh
If `--base` named a commit the clone does not carry, `blob_sha` returned None for every gate file on that side, the co-change rule read all of them as rewritten, and the merge was REFUSED — for a fact about the checkout's depth rather than about the merge. The workflow sets fetch-depth 0, so this would not have fired today; it would have fired the first time someone ran the wrapper by hand, or the first time a shallow clone was used to save a minute, and the refusal would have been about the gate being rewritten. A false refusal teaches people that the gate is noise, which is how a required check dies. The base is now validated like the merge commit, and an unreachable one is exit 2 with a message that names the reason. A control builds exactly that case and requires 2, refusing to accept 1. 12 wiring controls, 0 failed. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_018xhcg5opoFbSTdYHpkSXCh
tests/run_tests.py imports every test_*.py and calls `run()` on it. The new suite called its entry point `main()`, so the runner reported it as a module with no run() and ended the whole test job — after my own controls had printed 15 green lines, which is exactly the shape of a failure that looks like someone else's. Found by reading CI rather than by running the file directly: `python tests/test_step7_mergegate_ci.py` passes either way, and that is precisely why the local run said nothing. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_018xhcg5opoFbSTdYHpkSXCh
The previous commit gave this module a run() for tests/run_tests.py to call — onto a name a local helper already had. The helper won, the entry point was shadowed, and eight controls started invoking themselves recursively instead of the wrapper. Caught immediately by ruff (F811) and by the suite dropping to 4 passed, 8 failed, which is what a repository's own checks are for. The helper is `invoke` now and says in its docstring why it is not `run`. 12 wiring controls, 0 failed; ruff clean. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_018xhcg5opoFbSTdYHpkSXCh
ci(step7): the merge gate becomes a check, not a script in a drawer
19 tasks
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Stacked on #356. The last structural piece before #263-B: the merge order stops being an agreement and becomes a predicate on the target tree.
The problem this closes
The frozen T0 (#354,
30d2f32) names an accepted harness identity —104c384d01bf— that arrives with the memory-semantics repair in #355. On the T0 branch alone,perf_baseline.pyis still the pre-repair version and hashes to562a7f7232da. Nothing is wrong with the contract: it describes the state of the merged history. What is wrong is that the order holding it together lives in a conversation, and an order held by agreement is one stray Merge click from becoming an archaeological artifact — a document saying FROZEN in a tree where its referent does not exist.What the first revision got wrong
Two defects, both found by the owner against the live PR rather than by these controls, and both of the same shape: the gate read descriptions of enforcement instead of enforcement.
step7_machinery_presentasked whether the two tool files existed and whether certain identifiers appeared in them. A file containingdef check_campaign_link(): passsatisfies that — and the fixture meant to exclude that world was precisely that file. The control demonstrated the false positive it existed to prevent.steps_4_5_6_reboundsearched each artifact's text for the digest. The right digest anywhere in the file — a comment, a history entry, a field nobody binds — was read as a binding.Neither was a slip in a corner. A gate that checks for the name of a mechanism licenses exactly the tree it was built to refuse.
What the gate checks now
Against the commit a merge would produce, never against PR numbers. A number proves someone pressed a button; it says nothing about what the merged tree contains.
Exit 0 only when all hold; exit 1 on any refusal. A refusal means the frozen contract would become reachable from a tree that cannot satisfy it, and the merge is refused however mergeable the forge believes it to be.
The binding is read at one path per artifact, and only there
p022-263a-policy-freeze.jsonmeasurement_harness_digestp022-263a-design-constants.jsonbound_measurement_harness_digestp022-263a-training-preregistration.jsonbindings.measurement_harness_digestAnywhere else is not a binding. The refusal names the path and both digests:
p022-263a-policy-freeze.json: measurement_harness_digest is 562a7f7232da, not 104c384d01bf.The machinery is run, not recognised
step7_machinery_enforcesextractshostqual.pyandexecbinding.pyfrom the target commit, runs them in a subprocess against a synthetic campaign, and drives them through the whole authority table and the whole campaign path — negative and positive, because a refusal is only evidence if acceptance was possible.hostqual.bind_t0execbinding.t0_atFROZEN+falseNOT_FROZEN+trueNOT_FROZEN+falseFROZEN+trueThen, on the campaign:
session_eligibility→session_admissibility;Step 2 is what stops the witness reading "nothing is admissible" as "the swap was caught". A tool that fails any of this fails the gate, and the refusal says what happened —
hostqual accepted NOT_FROZEN+false, orthe swapped campaign was refused, but by no reason naming the campaign link.The digest is not hard-coded. It is read out of the frozen T0 and recomputed from the target tree, and a control proves no 64-hex literal exists in the gate's own source — a gate trusting its own constant would be checking itself.
Controls
Fixtures now ship the real tools; each attack control mutates exactly one enforcement point and requires the gate to notice. A mutation that changes no bytes raises, rather than passing quietly as a second positive control nobody reads as one.
The last two mutations are the sensitivity proof for the campaign witness: one shows the negative half is specific to continuity, the other shows the positive half is load-bearing.
merge gate controls: 15 passed, 0 failed, includingcontrol-inventory-complete(the documented list and the executed set are the same set).Exercised against real merges, not only fixtures
#355 → #356 → #353 → #354(270e3c1)#354alone (8c87118)562a7f7232da, all three bindings stale, machinery absent#355 + #354, no machinery (2e1a6c7)T0-0 needs no predicate of its own: #353 is an ancestor of the freeze commit, so it cannot be skipped.
Known limits
The witness proves behavioural compatibility at the named enforcement points, not byte identity:
hostqual.pyandexecbinding.pyare deliberately not pinned by SHA, so a comment or a diagnostic can be fixed without turning the gate into a museum alarm. It is also not a complete threat model — it proves these attacks are refused, not that no other attack exists.The gate is a script, not yet a required check. Branch protection on this repository has no ruleset, so nothing currently prevents a merge that this gate refuses. Wiring
mergegate.pyas a required status check is an owner act and is not part of this PR.Scope
scripts/step7/mergegate.pyand its controls only. No instrument change, no digest movement, and #354 is untouched —30d2f32is terminal and is not to be amended, rebased or force-pushed.Regression on this host: step-7 qualification 26/26, envcapture 11/11, perf instrument 16/16, calibration policy 10/10, calibration freeze 7/7, calibration constants 4/4, training preregistration 9/9. Four unrelated suites (
byte_variants,repro_fixtures,cli_ownir_fixtures,checkpoint_status) fail identically on the frozen T0 branch — they need a built Rust engine and dotnet, which this host does not have.🤖 Generated with Claude Code
https://claude.ai/code/session_018xhcg5opoFbSTdYHpkSXCh