Skip to content

feat(step7): the merge order becomes a predicate on the target tree - #357

Open
PhysShell wants to merge 6 commits into
claude/p022-hostqualfrom
claude/p022-merge-gate
Open

PhysShell wants to merge 6 commits into
claude/p022-hostqualfrom
claude/p022-merge-gate

Conversation

@PhysShell

@PhysShell PhysShell commented Sep 17, 2026

Copy link
Copy Markdown
Owner

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.py is still the pre-repair version and hashes to 562a7f7232da. 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_present asked whether the two tool files existed and whether certain identifiers appeared in them. A file containing def check_campaign_link(): pass satisfies 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_rebound searched 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.

t0_frozen_and_authorized   T0 declares FROZEN *and* collection_authorized: true
t0_names_a_digest          it names an accepted harness identity
instrument_matches_t0      that digest, recomputed from THIS tree's instrument
                           sources by the frozen formula, matches
steps_4_5_6_rebound        each artifact binds that digest at the exact path it
                           actually binds at — see below
step7_machinery_enforces   the target tree's own tools are executed and must
                           refuse four attacks — see below
t0_zero_prerequisite       the step-7 note still revokes the automatic collection
                           authority, so hosts plus a binding cannot again start a clock

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

artifact the one field that binds
p022-263a-policy-freeze.json measurement_harness_digest
p022-263a-design-constants.json bound_measurement_harness_digest
p022-263a-training-preregistration.json bindings.measurement_harness_digest

Anywhere 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_enforces extracts hostqual.py and execbinding.py from 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.

state hostqual.bind_t0 execbinding.t0_at
FROZEN + false must refuse must refuse
NOT_FROZEN + true must refuse must refuse
NOT_FROZEN + false must refuse must refuse
FROZEN + true must accept must accept

Then, on the campaign:

  1. a correct link must be accepted; one naming a different execution binding must be refused; and the same correct link must be refused once the freeze is edited underneath it;
  2. an unchanged campaign must survive session_eligibilitysession_admissibility;
  3. the same preflight with a swapped campaign must be inadmissible, and by a reason that names the campaign link — not merely by some reason.

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, or the 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.

noop_campaign_link_function        -> REFUSED   check_campaign_link stubbed permissive
dead_authority_check               -> REFUSED   hostqual.bind_t0 always authorised
dead_authority_check (execbinding) -> REFUSED   execbinding.t0_at never raises
dead_postflight_continuity         -> REFUSED   only the preflight/current campaign-link
                                                comparison removed, check_campaign_link intact
always_inadmissible                -> REFUSED   a postflight that refuses every campaign
digest_only_in_decoy_field         -> REFUSED   stale binding, right digest in a decoy field
exact_binding_fields_correct       -> allowed

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, including control-inventory-complete (the documented list and the executed set are the same set).

Exercised against real merges, not only fixtures

probe exit result
full chain #355 → #356 → #353 → #354 (270e3c1) 0 allowed: the merged tree's own tools walked the whole authority table on both readers and the whole campaign path, accepting what must be accepted and refusing what must be refused
#354 alone (8c87118) 1 refused, three predicates: instrument hashes to 562a7f7232da, all three bindings stale, machinery absent
#355 + #354, no machinery (2e1a6c7) 1 refused, one predicate

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.py and execbinding.py are 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.py as a required status check is an owner act and is not part of this PR.

Scope

scripts/step7/mergegate.py and its controls only. No instrument change, no digest movement, and #354 is untouched — 30d2f32 is 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

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
@coderabbitai

coderabbitai Bot commented Sep 17, 2026

Copy link
Copy Markdown

Important

Review skipped

Auto reviews are disabled on base/target branches other than the default branch.

Please check the settings in the CodeRabbit UI or the .coderabbit.yaml file in this repository. To trigger a single review, invoke the @coderabbitai review command.

⚙️ Run configuration

Configuration used: Path: .coderabbit.yaml

Review profile: CHILL

Plan: Advanced

Run ID: 0dd582ef-0725-4be5-a06f-e9613dc6374d

You can disable this status message by setting the reviews.review_status to false in the CodeRabbit configuration file.

Use the checkbox below for a quick retry:

  • 🔍 Trigger review

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.

❤️ Share

Comment @coderabbitai help to get the list of available commands.

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
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
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.

2 participants