Skip to content

fix(perf): the memory number carries the name of what it measures (S8) - #355

Merged
PhysShell merged 29 commits into
claude/p-022-analysis-wiring-mwhqlwfrom
claude/p022-s8-memory-metric
Sep 17, 2026
Merged

PhysShell merged 29 commits into
claude/p-022-analysis-wiring-mwhqlwfrom
claude/p022-s8-memory-metric

Conversation

@PhysShell

Copy link
Copy Markdown
Owner

S8 from the hostile freeze review of #354. Instrument semantics repair — no policy, no threshold, no authorization.

What a witness established

peak_rss_bytes was false on Windows, and the witness settles it rather than a manual page.

Windows, job object as the harness uses it (CreateJobObjectW + AssignProcessToJobObject):
  child writes 128 MiB                      PeakProcessMemoryUsed  134.3 MiB
  child COMMITS 256 MiB and touches nothing PeakProcessMemoryUsed  262.5 MiB   <-- not resident
  light control                             PeakProcessMemoryUsed    7.3 MiB

Linux, os.wait4 on the immediate child as the harness uses it:
  heavy IS the waited child                 ru_maxrss  260.2 MiB
  shell -> heavy child                      ru_maxrss  260.2 MiB
  shell -> light -> heavy (production shape) ru_maxrss  260.2 MiB
  shell -> shell -> heavy (grandchild)      ru_maxrss  260.3 MiB
  light shell only                          ru_maxrss   13.3 MiB

The job object counts committed memory — job and process memory limits are defined on committed virtual memory — while ru_maxrss counts resident pages. One field name claimed both.

The same witness refuted the other half of the suspicion: wait4 does not stop at a wrapping shell. The POSIX path already measures the tree it should.

The repair

The quantity now travels with the number. Every sample and every cell carries memory_metric from a closed set:

  • max_process_peak_resident — POSIX wait4, and the /usr/bin/time -v fallback
  • max_process_peak_commit — the Windows job object

A value produced under any other kind raises InstrumentError rather than being recorded. No downstream reader infers semantics from sys.platform, and no alias lets Windows commit go on being read as RSS.

Renamed because the old nouns lied the same way: peak_rsspeak_memory, rss_mechanismmemory_mechanism, rss_unavailable_reasonmemory_unavailable_reason, raw_peak_rss_bytesraw_peak_memory_bytes, RssProbeMemoryProbe.

Deliberately not done: Windows resident-set sampling. PeakWorkingSetSize is the resident analogue, but obtaining it for a tree of processes that come and go needs handle tracking or polling. This instrument does not sample, and will not perturb the elapsed-time interval so two operating systems can pronounce the same noun.

The digest moved, on purpose

562a7f7232dad2f4c79c6adfe0e1e7e25680b4b6f3444d54824bf0405e3c14b3   before
104c384d01bf6060bdec1e7c916053ddb04b97fcbd0b39f8a4fc57b8f139672f   after

Three controls said so by name — freeze-harness-untouched, training-prereg-bindings, envcapture-frozen-untouched — which is the provenance system delivering its inconvenience on purpose. Steps 4/5/6 are re-bound in their own commit (including design_constants_blob_sha1, which moved because the design-constants artifact was itself re-bound).

Committed evidence of runs that actually happened is not rewritten. The sizing, calibration and round-7 datasets record what the old instrument produced and still carry peak_rss; rewriting them would forge a record rather than re-accept a binding.

Controls

calibration constants 4/4 · calibration freeze 7/7 · calibration policy 10/10 · perf instrument 16/16 · round 7 apparatus 10/10 · step 7 environment capture 11/11 · training preregistration 9/9 · evidence coverage 12/12.

The repository runner also reports 17 FAIL[...] lines from variant-corpus, variant-ledger and artifact-golden. Those are pre-existing on this Windows checkout — the same 7 + 10 appear at the base commit 6194d7e — and are byte-exact fixtures against a CRLF working tree, unrelated to this change.

🤖 Generated with Claude Code

https://claude.ai/code/session_018xhcg5opoFbSTdYHpkSXCh

`peak_rss_bytes` was false on Windows. A witness settles it rather than a manual
page: a child that commits 256 MiB and never touches a page is reported as
262.5 MiB by the job object's PeakProcessMemoryUsed, while on Linux nothing
becomes resident. The job object counts COMMITTED memory — job and process
memory limits are defined on committed virtual memory — and `ru_maxrss` counts
RESIDENT pages. One field name claimed both.

The same witness refuted the other half of the suspicion. `wait4` on the
immediate child does NOT stop at the shell: shell -> heavy child and
shell -> shell -> heavy child both return the descendant's 260 MiB against
13 MiB for a light control, so the POSIX path already measures the tree it
should.

So the quantity now travels with the number. Each sample and each cell carries
`memory_metric`, drawn from a closed set — `max_process_peak_resident` on the
wait4 and `/usr/bin/time -v` paths, `max_process_peak_commit` on the Windows
job object — and a value produced under any other kind raises rather than being
recorded. No downstream reader infers semantics from `sys.platform`, and no
alias lets Windows commit go on being read as RSS.

Renamed with it, because the old nouns lied in the same way: `peak_rss` ->
`peak_memory`, `rss_mechanism` -> `memory_mechanism`, `rss_unavailable_reason`
-> `memory_unavailable_reason`, `raw_peak_rss_bytes` -> `raw_peak_memory_bytes`,
`RssProbe` -> `MemoryProbe`.

Deliberately NOT done: Windows resident-set sampling. `PeakWorkingSetSize` is
the resident analogue, but obtaining it for a tree of processes that come and go
needs handle tracking or polling, and this instrument does not sample. Two
operating systems answering different, honest questions beats a profiler added
so they can pronounce the same noun.

This moves `measurement_harness_digest` 562a7f7232da -> 104c384d01bf. The
bindings that name the old digest are re-bound in the next commit; nothing here
tries to preserve it.

perf instrument controls 16/16, round 7 apparatus controls 10/10.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_018xhcg5opoFbSTdYHpkSXCh
The memory-semantics repair moved `measurement_harness_digest`

    562a7f7232dad2f4c79c6adfe0e1e7e25680b4b6f3444d54824bf0405e3c14b3   before
    104c384d01bf6060bdec1e7c916053ddb04b97fcbd0b39f8a4fc57b8f139672f   after

and three controls said so, each by name: `freeze-harness-untouched`,
`training-prereg-bindings` and `envcapture-frozen-untouched`. That is the
provenance system delivering its inconvenience on purpose, so nothing here tries
to preserve the old digest.

Re-bound: the digest in the policy freeze, in the ratified design constants, and
in the training preregistration's bindings — and with it
`design_constants_blob_sha1`, which moved because the design-constants artifact
itself was re-bound. A chain of bindings re-bound in the order the chain runs.

Not touched: the committed evidence of runs that actually happened. The sizing,
calibration and round-7 datasets were produced by the old instrument and record
what it produced; rewriting them would be forging a record, not re-accepting a
binding. They carry `peak_rss` because that is the field the instrument had when
they were taken.

This is a re-acceptance of the step-4/5/6 bindings, not a code change; it is a
separate commit so it can be reviewed as one. Controls after re-binding:
calibration constants 4/4, calibration freeze 7/7, calibration policy 10/10,
perf instrument 16/16, round 7 apparatus 10/10, step 7 environment capture
11/11, training preregistration 9/9.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_018xhcg5opoFbSTdYHpkSXCh
The section was called "Peak RSS" and described two mechanisms as though they
produced one number. They do not: POSIX `ru_maxrss` is a peak resident set over
the waited-for descendant chain, and the Windows job object's
`PeakProcessMemoryUsed` is peak committed memory over the processes of the job.
The witness that settles it is recorded here rather than left in a session log —
256 MiB committed and never touched reads as 262.5 MiB on Windows and as nothing
resident on Linux — as is the half of the suspicion the same witness refuted:
`wait4` does not stop at a wrapping shell.

Recorded with it: the strata answer different honest questions and may not be
pooled across platforms; Windows resident sampling was considered and refused
because this instrument does not sample; and the digest moved 562a7f7232da ->
104c384d01bf, with steps 4/5/6 re-bound in their own commit and the committed
evidence of past runs deliberately left alone.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_018xhcg5opoFbSTdYHpkSXCh
@coderabbitai

coderabbitai Bot commented Sep 16, 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: 961c4310-9c2f-4afa-a71c-6cd3f4c612df

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.

Two artifacts, because they answer two questions. Qualification: does this one
environment satisfy the T0-7 predicate. Execution binding: which Linux and
Windows environments, which candidates and which instrument form THIS campaign.
A utility that checks a CPU governor must not become the root of campaign
identity, so `hostqual.py` neither issues nor owns the binding.

`hostqual.py` splits evidence into the two classes that are not the same thing.
DECLARED provisioning — dedication, absence of other users, hypervisor
configuration — is content-addressed, shape-checked and never called machine
proof: a guest OS cannot establish what the hypervisor is doing. MACHINE-OBSERVED
is what a checker actually asserts: `manifest.provenance.ci == false` (the field
is always present, so a predicate demanding its absence could never be met),
`performance` governor on every applicable CPU plus an identified turbo
mechanism, the Windows active plan with AC processor state pinned at 100/100,
and the stratum's memory mechanism actually existing on the host.

Quiesce is implemented exactly, not approximately: twelve 5 s intervals over the
final minute, `/proc/stat` on Linux and `GetSystemTimes` on Windows, mean below
5 % and no interval above 20 %. A missing sample, a counter that went backwards
or a zero denominator is NOT_ELIGIBLE rather than a skipped interval — an
unreadable machine is not a quiet machine. A quiesce failure starts no clock, so
it is not INVALID and costs no retry budget.

A qualification is not a certificate of perpetual quiet. Session eligibility is
proved again per session, against a fresh manifest, and the record names the
execution binding, the qualification and that manifest — so a qualified host
that this campaign never bound cannot be substituted into the middle of it.

`execbinding.py` carries references and identity, never copies: duplicating the
governor or the provisioning blob would create two copies of one fact, and two
copies drift. It requires both strata, refuses an unqualified or mislabelled
host, refuses a binding whose strata share one memory metric, and refuses to
overwrite an existing binding — a rebuild before the first clock is legitimate
but never silent.

Both platform branches were driven live, not only by fixture. On Windows the
first live run found a defect in this tool's own powercfg parse: it read the
range's minimum as the current setting and reported 0 % on a machine pinned at
100 %. The block ends with the two current indices, AC then DC, and everything
before them describes the possible range. Fixed here, with the labels never
parsed — on this Russian Windows every one of them is localized. On Linux the
tool correctly refuses WSL, where no cpufreq governor exists.

step 7 host qualification controls: 10 passed, 0 failed.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_018xhcg5opoFbSTdYHpkSXCh
The mini-review found a hole in the middle of a chain that was otherwise
content-addressed end to end: a qualification said "this host satisfies T0-7"
without naming WHICH T0. SHAs everywhere and the causation missing anyway.

Q1 — `--qualify` now requires the T0 path and commit, proves the commit exists,
that `blob_sha` really is `commit:path`, that the bytes hash to the recorded
sha256, and that the document declares FROZEN. A qualification against a
NOT_FROZEN T0 is refused, so today's reconnaissance cannot become campaign
evidence by reuse. `execbinding` refuses a host qualified under a different T0
than the campaign binds.

Q2 — `--environment-id` is gone. The id is the manifest's own observed value and
the provisioning declaration must agree with it: one identity, not three strings
that agree while everyone behaves.

Q3 — provisioning is checked by VALUE, not only by shape. `dedicated_to_p022`
and `no_concurrent_user_workload` must be true, `hosted_ci_runner` false,
`is_vm` a real boolean; a VM must promise fixed vCPU, fixed RAM, no live
migration and no dynamic memory, with `n/a` unavailable to it, and a physical
host must answer those four with an explicit `n/a: <reason>` rather than bare
booleans. A VM declaring `fixed_vcpu: false` could previously qualify.

Q4 — per-session operator facts left the host record. "No campaign workload",
"no interactive user", "no prohibited background job" are properties of a
moment, and now live in a session declaration bound by sha256 into eligibility.

Q5 — the 120 s window is waited, not asserted. The quiet minute really elapses
before the twelve 5 s intervals; a control drives an injected clock and requires
60 + 60. A constant nobody waits for is documentation.

Q6 — identity is compared as a whole. The qualification records a canonical hash
of the manifest's identity block and the session must reproduce it, so a changed
kernel, CPU count, RAM or toolchain can no longer walk past a matching
fingerprint.

Q7/Q8 — the candidate is hashed against the binding before the clock, and a new
`--session-postflight` pass re-checks identity, power and candidate afterwards
and requires closing-probe evidence. Preflight may not certify what a session
did after it started.

Q9 — power is a structured snapshot, compared by equality through the session.
Owner ruling applied: Windows requires 100% on AC *and* DC, so a machine cannot
be compliant while plugged in and change policy when the power source does.

Q10/Q11 — the binding proves its inputs instead of trusting strings. The harness
digest is recomputed from the instrument sources at the bound commit by the
frozen formula without importing the harness; the workload manifest comes from
that commit's git object; `--verify` re-proves T0, instrument, manifest, both
qualifications and both candidates.

Q12 — a preflight and a postflight carrying two different
`execution_binding_sha256` values cannot meet in one session.

Q13 — a provisioning template lands in `scripts/step7/examples/`, outside any
evidence directory, marked EXAMPLE / NOT EVIDENCE, with no real identity in it,
and a control keeps it valid under the rules it teaches.

step 7 host qualification controls: 18 passed, 0 failed.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_018xhcg5opoFbSTdYHpkSXCh
…e campaign

Two findings from the second Phase-2 review, both reproduced before repair.

S-A — machine-produced artifacts were consumed as ordinary dicts. A document
whose kind was "NOT A BINDING AT ALL" was accepted as the execution binding, and
an arbitrary object carrying `identity` and `provenance` was accepted as an
environment manifest: the probe returned an ELIGIBLE session from two
handwritten files. The asymmetry was exactly backwards — the two DECLARED
artifacts were validated by kind and schema, and the three machine ones were
not.

There is now one boundary. `load_artifact()` parses, proves the artifact by its
own validator, and only then hands it over; no consumer reads a field before the
type is established, and a refusal raises rather than becoming "not eligible" —
an input that is not what it claims is an operator error, not a session that
failed a predicate, so it exits 2. The envcapture schema string is duplicated
rather than imported, with `hostqual-producer-schema` proving the copy still
equals what the capture tool emits.

One validator per artifact type, not five call sites: `validate_qualification`
now lives in `execbinding` — the module that binds campaigns is the lower one,
so `hostqual` reuses it without an import cycle. That immediately paid: the
required A4 attack found that binding never checked a qualification's SCHEMA,
only its kind. A wrong-schema qualification could be bound. It cannot now.

S-B — `--verify` narrowed silently to whatever it was handed. Called without the
strata it skipped the qualification and candidate checks and still printed
"binding verified". Owner ruling applied: no partial mode under that name. The
function refuses incomplete input maps — not only argparse, because the next
caller may be a script — and the command line refuses before running anything.
The string "binding verified" is now unreachable unless the full set was
attempted and passed.

P-A — the control inventory is checked by a control. The docstring list and the
executed set must be the same set, because this class of defect has now been
found twice and a third discovery wearing a new hat is not a surprise worth
paying for.

step 7 host qualification controls: 22 passed, 0 failed. Regression: envcapture
11/11, perf instrument 16/16, calibration freeze 7/7, training prereg 9/9.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_018xhcg5opoFbSTdYHpkSXCh
S-C, found by the review of the previous repair — and introduced by it. Closing
the artifact-boundary hole, I folded value judgements into the shape validator,
so `dedicated_to_p022: false` was refused exactly like a forged document. The
honest case that used to produce a record with `qualified: false` and
`single_tenant: fail` produced nothing at all: one line on stderr and no
artifact.

That erases negative attempts, which is how a laboratory ends up with machines
that pass on the first try because the other tries were never artifacts.

The boundary now separates the two questions it had merged:

    artifact validity   is this the artifact it claims to be — kind, schema,
                        types, applicability shape. Malformed is refused before
                        any record exists.
    predicate outcome   do the declared values satisfy the predicate. Every
                        failure reaches a real record.

So the string "false" is malformed, a missing key is malformed, `is_vm` answered
with "n/a" is malformed, a VM omitting a VM-only field is malformed, and a
physical host answering those with bare booleans is malformed — while the
boolean `false`, anywhere it is allowed, is a valid declaration that this host
does not qualify.

Exit codes now say which class occurred, and are written down rather than
implied:

    0  valid artifact, positive outcome
    1  valid artifact, NEGATIVE outcome — the record exists and says why
    2  malformed input or operational misuse — no record is produced

The same split applies to the session declaration: an operator who truthfully
records that a prohibited job is running gets `eligible: false` with the reason,
not an error message and no evidence.

Three controls added, driven through the command line so the exit codes are
part of the proof: an honest provisioning negative, an honest VM negative and an
honest session negative each leave an artifact naming the failed check, while
`"false"` as a string leaves none and exits 2.

step 7 host qualification controls: 24 passed, 0 failed.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_018xhcg5opoFbSTdYHpkSXCh
PhysShell pushed a commit that referenced this pull request Sep 16, 2026
…gates

Phase 3, in one commit, on the four findings the freeze review left plus the
platform split that #355 made necessary.

S1 — the gate population belongs to T0, not to a function. The gates are
computed over exactly the decisive workloads of the D7-bound manifest that the
`launcher-e2e` rung admits — all thirteen at the current manifest — and
calibration workloads never enter a cutover gate. A missing decisive workload, a
missing engine side, an unexpected extra cell or a calibration cell inside the
set each make the attempt INVALID: a missing cell is not a smaller denominator.
The instrument's applicability rule implements that sentence; it is not the
source of it, because two readers must not be able to answer differently about
which cells a gate covers.

S2 — the numeric domain is checked BEFORE the two-dimensional rule, not inside
it. Every quantity a gate consumes must be defined, real and finite, with
medians strictly positive; None, NaN or an infinity in R or either coordinate
invalidates the attempt. Checked first because the rule's OR would otherwise let
`relative = NaN, absolute = 10 ms` pass on the strength of the other coordinate.
A comparison against an undefined number is not one that succeeded.

S3 — a primary cell holds exactly N samples, each finite, positive and carrying
its stratum's declared metric kind. One null, one missing sample or one
unexpected metric kind invalidates the session. A median over the surviving
subset is forbidden by name: the instrument's summariser drops absent memory
samples silently, so without this rule a cell that lost half its measurements
reports a confident median of the other half.

S8 — since the memory quantity is platform-local, gate identity carries its
stratum: eight primary gates, four per stratum, with `max_process_peak_resident`
on Linux and `max_process_peak_commit` on Windows. `resident == commit` is
asserted nowhere, the strata are never pooled, and the two memory budget
families carry identical numbers while remaining two budgets — they agree today
because both came from one product budget before any data existed, and either
may move without the other.

S7 — the replacement conditions are closed at six, with no "other environmental
reason" escape hatch, and the retry budget belongs to the STRATUM. Replacement
does not reset it; a valid outcome-bearing session closes the stratum and is
retained forever; re-measurement requires a previous INVALID and remaining
budget. Every replacement records both binding identities and the budget left,
so a reader can see what it did and did not buy.

Verified by enumeration rather than by reading: the eight-gate roll-up is total
and single-valued over all 6561 combinations, 108 boundary points across the
three budget families yield exactly one verdict each, and the domain check
rejects NaN and both infinities before the rule sees them.

Stale vocabulary swept from the normative text: no generic "peak RSS" gate, no
"four primary gates", no cross-platform R. The old names survive only where they
are explicitly named obsolete.

Status is unchanged and deliberately so: NOT_FROZEN, collection_authorized
false. This closes the content; the freeze is its own review and its own commit.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_018xhcg5opoFbSTdYHpkSXCh
…flag bite

Two freeze-blockers from the full T0 review, both of the same family: a chain
that reads correctly and does not actually hold.

F1 — the accepted D7 payload binds the reference, the harness digest and
version, and the workload manifest. It does NOT bind the execution binding:
`execution_binding_sha256` is absent from `D7_PAYLOAD_BINDING_KEYS`, the string
`execution_binding` does not occur in the instrument at all, and the gate only
checks that the listed keys are present — an extra key is tolerated and never
verified. Meanwhile this tool's own docstring promised D7 would bind it. One
accepted component was advertising a join another accepted component cannot see.

A campaign link now makes the join by bytes: it names the execution binding, the
D7 payload's sha256, its blob at a named commit, and the attestation's sha256.
Preflight and postflight both re-prove it — a link naming a different binding, a
freeze edited after the campaign was linked to it, or a campaign swapped between
preflight and postflight each refuse — and the link is recorded in both session
records. Admissibility under T0 is the existence of the postflight record, so
evidence produced outside a campaign cannot acquire one.

What this deliberately does not do: make the instrument's firewall aware of the
campaign. `IdentityGate` still arms from the payload and attestation alone, so a
decisive clock can physically run with no binding in existence; what cannot
happen is that such a run becomes admissible. Teaching the firewall would edit
`perf_baseline.py`, move `measurement_harness_digest` and reopen steps 4/5/6 —
a price not paid for a property obtainable by gating admissibility instead of
execution.

F4 — `collection_authorized` was decoration. Both T0 readers accepted FROZEN
regardless of the flag, and the fixtures had quietly encoded the intended rule
all along. The two fields are now read as one authority state on both paths:
FROZEN+true may proceed to the remaining gates, while FROZEN+false,
NOT_FROZEN+false and NOT_FROZEN+true each refuse — the third by name, because an
authorisation without a fixed protocol is a contradiction and honouring the flag
over the contract is how a tool starts arguing with its own rules.

A fixture defect surfaced on the way: two campaigns built from identical content
in the same second produced the same commit sha, so the "different campaign"
attack was proving that a link equals itself. Campaigns are now distinct
documents.

step 7 host qualification controls: 26 passed, 0 failed. Regression: envcapture
11/11, perf instrument 16/16, calibration freeze 7/7, training prereg 9/9.
No instrument change; the harness digest does not move.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: 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
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
`ruff check .` flagged one over-long line in scripts/perf_baseline.py. Rewrapping
it is not a cosmetic edit: the file is an INSTRUMENT SOURCE, its bytes are hashed
into the measurement harness digest, and the frozen #263 preregistration names
that digest — 104c384d01bf — with steps 4, 5 and 6 bound to the same value.

Measured rather than assumed: the rewrap moved it to 533a921baf13. A merge
carrying that would have put the frozen contract in a tree that does not contain
the identity it names, which is the one thing scripts/step7/mergegate.py exists
to refuse. The linter would have been satisfied and the contract broken.

So the line stays and pyproject.toml records why, scoped to E501 in that one
file, with the condition for lifting it: a re-binding of steps 4/5/6.

perf instrument controls: 16 passed, 0 failed. ruff: all checks passed.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_018xhcg5opoFbSTdYHpkSXCh
Sixteen ruff findings, all introduced by this PR and none of them caught here
before, because `ruff check .` was never run against this branch. `main` is
clean, so every one of them is mine.

Behaviour-preserving throughout: `datetime.UTC` for the deprecated alias, list
unpacking for two concatenations, three long lines wrapped, and six unpacked
names the controls never read prefixed with an underscore.

The one that was almost a real defect is B023: three fixture paths were closed
over by a lambda inside a loop. It never bit, because the lambda is called in
the same iteration that builds it — but that is a property of today's control
body, not of the code, and a later `refuses(...)` that defers the call would
have made every attack in that loop test the last fixture three times. The names
are bound as defaults now, so the control cannot start lying quietly.

step-7 qualification 26/26, envcapture 11/11, perf instrument 16/16, ruff clean.

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
…r exists

Renaming RssProbe to MemoryProbe and the `rss` field to `memory` was the point
of this PR — the number now carries the name of what it measures. What the PR
did not do is follow the name out of the module. Three call sites still built
`pb.Harness(..., rss=pb.RssProbe())`, which is a TypeError and an AttributeError
the moment either is reached:

    scripts/round7/runner.py:369, :536
    scripts/round6/metrology.py:73

mypy --strict caught the two in round7 because that file is in its `files` list.
It said nothing about round6/metrology.py, which is not — that one was found by
grepping for the old names, and it is the more interesting of the two facts: the
type checker's silence there was never evidence.

No behaviour changes; the constructor and the field are spelled the way
perf_baseline.py now spells them. scripts/perf_baseline.py is untouched, so the
harness digest is still 104c384d01bf.

mypy --strict clean on Linux, which is the platform CI checks: on Windows the
same run reports `os.wait4` missing and several ignores unused, and those
findings must not be "fixed" — they are the POSIX branch being read on a host
that has no POSIX.

round 7 apparatus 10/10, perf instrument 16/16.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_018xhcg5opoFbSTdYHpkSXCh
claude and others added 4 commits September 17, 2026 09:24
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
feat(step7): the merge order becomes a predicate on the target tree
feat(step7): host qualification and the execution binding, kept apart
@PhysShell
PhysShell merged commit 2b2314e into claude/p-022-analysis-wiring-mwhqlw Sep 17, 2026
63 checks passed
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