Skip to content

Schema-4 gate-battery verification in the release contract (#611 A3) - #663

Open
juaristi22 wants to merge 2 commits into
mainfrom
uk-schema4-verifier-a3
Open

Schema-4 gate-battery verification in the release contract (#611 A3)#663
juaristi22 wants to merge 2 commits into
mainfrom
uk-schema4-verifier-a3

Conversation

@juaristi22

@juaristi22 juaristi22 commented Aug 11, 2026

Copy link
Copy Markdown
Collaborator

The verifier half of microcosm#611's UK consumer flip (A3 of the plan on #611, concurrent with #662): releases whose terminal_gates.json is a schema-4 gate-battery report become verifiable. microcosm-data only; no file overlap with the consumer PR, so merge order between them is free.

What the dispatch does

Exact-k UK releases dispatch on the report's own schema_version: 3 runs the legacy aggregator checker untouched, 4 runs the new battery checker, anything else is refused by name. The published June release keeps its grandfathered path unchanged (its id is not exact-k, so its report was never checked — preserved exactly). This reuses the vintage-aware pin pattern #658 established.

What the schema-4 checker enforces

Nothing it can recompute or pin is trusted from the report:

  • Mirror constants (the data shard still never imports the build shard): producer microcosm.build.gate_battery, schema 4 / attestation 6, the MICROCOSM_UK_TERMINAL_GATE_SIGNING_KEY trust root (the legacy POPULACE variable stays with the schema-3 path), and vintage pins over the committed uk/gates.jsonpolicy_sha256, gates_manifest_sha256, spec_fingerprint, and the exact 13-entry membership.
  • Recomputed shippability: every release_blocking entry must be passed/not_applicable; the shippable flag is asserted but never load-bearing. release_candidate must be true — a staging report produced off the candidate posture excused absent evidence and can never be promoted into a release dir. blocked_at_phase must be null and every declared phase evaluated; unreached entries are a contradiction and refused.
  • Observables, reused not rewritten: the battery re-keys the report by spec entry id but runs the same gate implementations, so the evaluated entries are projected back onto the legacy names and the existing detail-schema checks — including source_year reviewed exclusion + schema-2 approval receipts with expiry (#630, #610) #658's one-exclusion-clock rule — apply verbatim.
  • Evidence discipline: evidence_sha256 may carry only the three evidence-bearing entries, each present exactly when its entry evaluated; the input-mass line must equal the canonical wrap of the reviewed enhanced-FRS reference digest (derived pin); when the build manifest carries terminal_gate_evidence it must match exactly. Build manifests may use either evidence vocabulary — legacy stage keys or battery entry ids — never a mixture.
  • Diagnostics linkage: release_evidence.calibration_diagnostics_sha256 (the executor's signed slot) must match the shipped calibration_diagnostics.json bytes — the schema-3 attestation field's successor.
  • Authentication: attestation key set is exact (signing_error is deliberately outside it — an unsigned report can never verify), identity fields must equal their body counterparts, gate_outcomes_sha256 is recomputed, and the signature is verified over the canonical payload with the signature slot nulled.

Keeping the mirrors honest

test_gate_battery_contract_pins.py (build shard, where tests may import both packages) holds every mirror to the live producer: executor constants, spec-derived vintage pins from a freshly constructed GateBatteryRun, entry membership from load_country_spec("uk"), the wrapped input-mass digest, and — the load-bearing one — a byte-level check that the data shard's canonical JSON reproduces the producer's signed bytes. The full producer→verifier round trip is a strict xfail: it constructs with release_evidence unconditionally, xfails until the consumer PR (#662) lands the slot, and then becomes a loud XPASS failure — whichever PR merges second deletes the marker, so the round trip can never stay silently un-run. This is the single cross-PR coordination point.

The tamper matrix in test_contract.py exercises one named refusal per check over a valid signed fixture: unknown schema, non-candidate posture, blocked phase, failed/excused blocking entries, missing entry, moved policy/manifest/fingerprint pins, broken diagnostics link, mixed exclusion clock, unsigned tamper, forged key, absent trust root, recorded signing error, unpinned input-mass evidence, mixed manifest vocabulary. The June fixture and the full schema-3 suite pass unchanged.

Delete-or-justify

Nothing is deleted: the schema-3 checker, its hand-mirrored gate-name tuples, and the POPULACE env-var read all stay for vintage dispatch (retirement trigger: when the legacy report format is formally retired, after S3's real-data comparison and the first schema-4 release). The hand-mirrored tuples play no role on the schema-4 path — membership follows the pinned manifest there.

Part of #611. Concurrent with #662 (the consumer flip); an exact-k release assembly requires both.

🤖 Generated with Claude Code

Exact-k UK releases now dispatch on the terminal report's own schema
version: 3 keeps the legacy aggregator checker byte-for-byte (and the
published June release keeps its grandfathered identity path), 4 runs a
new checker for the shared executor's reports, and anything else is
refused by name.

The schema-4 checker trusts nothing it can recompute or pin: the
executor's identity (producer, schema numbers, signature algorithm, the
MICROCOSM_UK_TERMINAL_GATE_SIGNING_KEY trust root) and the committed UK
spec's digests (policy, manifest, fingerprint, entry membership) are
hand-mirrored vintage constants — the data shard still never imports the
build shard — shippability is recomputed from the recorded outcomes
instead of trusting the shippable flag, the release-candidate posture is
required so a staging report can never be promoted, the legacy
observable detail checks (including the one-exclusion-clock rule) are
re-applied through a projection onto the shared gate names, the
diagnostics linkage rides the signed release_evidence slot, and the
complete report is authenticated against the canonical payload with the
signature slot nulled. Build manifests may carry either evidence
vocabulary — legacy stage keys or battery entry ids — never a mixture.

Cross-shard sync tests (in the build shard, where tests may import both
packages) hold every mirror to the live producer: constants, spec-derived
pins, the wrapped input-mass evidence digest, and the canonical-JSON
signature scheme; a full producer-to-verifier round trip arms itself the
day the consumer PR's release_evidence slot lands. The tamper matrix
exercises one named refusal per check over a valid signed fixture.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

@vahid-ahmadi vahid-ahmadi left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Reviewing as a draft. The framing that makes this PR good is stated in one line — "nothing it can recompute or pin is trusted from the report" — and the implementation actually holds to it: shippable asserted but never load-bearing, gate_outcomes_sha256 recomputed, shippability re-derived from the entries rather than read. Requiring release_candidate to be true so a staging report that excused absent evidence can never be promoted into a release dir is the sharp one; that's the failure a verifier exists to catch.

Keeping the data shard from importing the build shard, and then putting the mirror-drift tests in the build shard where both imports are legal, is the right division. The byte-level check that the data shard's canonical JSON reproduces the producer's signed bytes is the load-bearing one, as you say — everything else is downstream of the two shards agreeing on canonicalisation.

One thing to change, and it's the piece you've identified as most important.

The single cross-PR coordination point is implemented with the one construct that fails silently.

if "release_evidence" in inspect.signature(GateBatteryRun.__init__).parameters:
    arguments["release_evidence"] = {...}
else:  # pragma: no cover - pre-consumer-flip executors only
    pytest.skip(
        "GateBatteryRun has no release_evidence slot yet; flip when "
        "the consumer PR (uk-battery-consumer-a2) merges."
    )

A skip is invisible in a green run. If the probe is ever subtly wrong — #662 lands release_evidence through **kwargs, or a decorator wraps __init__ so inspect.signature doesn't report the parameter, or the slot arrives under a different name — this test stays skipped forever and the full producer→verifier round trip, the thing that proves the two halves actually compose, silently never runs. The # pragma: no cover means coverage won't surface it either.

pytest.mark.xfail(strict=True) inverts that: the day the capability appears the test starts passing, strict xfail turns a pass into a failure, and someone must come remove the marker. That converts "remember to flip this" into a build error rather than an intention.

Related, and probably worth reconciling before either merges: #662's body says whichever merges second "flips the round-trip xfail noted there", while this is a skip. Two descriptions of one coordination point suggests it hasn't been pinned down between the PRs — and it's the one place where getting it wrong is invisible rather than loud.

Smaller

  • The tamper matrix is the right shape — one named refusal per check over a valid signed fixture, so a check that stops biting fails its own test rather than quietly passing everything. Fifteen named refusals against roughly that many checks is good coverage.
  • signing_error deliberately outside the exact attestation key set, so an unsigned report can never verify, is worth the comment it has. That's the kind of exclusion that looks like an oversight to the next reader.
  • "Build manifests may use either evidence vocabulary — legacy stage keys or battery entry ids — never a mixture" is a good rule. Is the mixture refusal keyed on the manifest, or could a manifest legitimately span a build that straddled the flip? If the latter is possible even in principle, the refusal message should say which vocabulary it saw first.
  • Vintage dispatch on the report's own schema_version, with anything unknown refused by name, reuses #658's pattern well. Since that's now the second consumer of "dispatch on a declared vintage, refuse unknowns", it might deserve a shared helper rather than a second hand-rolled branch.

Nothing here blocks; the xfail change is the one I'd want before merge given what it protects.

… trip

The adversarial pass confirmed no keyless bypass and one real weakening
versus schema-3: the verifier recomputed shippability but trusted the
producer-declared per-entry metadata. Each entry's gate and phase are now
pinned to the committed spec, criticality must be release_blocking (every
entry in this vintage blocks — a diagnostic relabel would dodge the
recompute), and not_applicable is refused outright (no entry declares an
excuse), which also closes its side doors past the observables projection
and the evidence-presence rule. The degenerate register's evidence digest
becomes a vintage pin like the input-mass one — an overridden register is
never releasable — and schema versions must be exact integers at dispatch
and in both version checks (4.0 no longer routes).

From Vahid's review: the producer-to-verifier round trip swaps its
capability skip for a strict xfail — the release_evidence slot's arrival
turns it into a loud XPASS failure, so whichever PR merges second must
delete the marker and run the round trip live; a silent forever-skip is
no longer possible. The mixed-vocabulary refusal now names both
vocabularies it saw. New sync tests hold the per-entry metadata and the
degenerate evidence pin to the committed spec and register; new tamper
tests cover the relabel, the excuse, the identity pins, the float
vintage, and the overridden register.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
@juaristi22

Copy link
Copy Markdown
Collaborator Author

All taken (1fd14f7), and an independent adversarial pass on this branch landed in the same commit.

The skip is gone. The round trip is now pytest.mark.xfail(strict=True) with no capability probe at all — it constructs with release_evidence unconditionally, xfails today, and the slot's arrival turns it into a loud XPASS failure. Whichever PR merges second must delete the marker, so the round trip can never stay silently un-run; the # pragma: no cover went with the probe. The two PR bodies now describe the same mechanism (this one's updated; #662's already said xfail).

Mixture refusal: keyed on the manifest object, and legitimately so — one release dir binds exactly one report (the gates.uk_terminal.sha256 triangle), one report carries one schema, so a manifest can never honestly straddle the flip. The message now names both subsets it saw and says why.

Shared vintage-dispatch helper: answered rather than implemented. The two dispatches select on different axes — #658's picks a pin constant by release id, this one picks a checker by the report's own schema — and share no code shape beyond "refuse unknowns by name". A common helper would couple the legacy pin path to the battery path for no deduplication; if a third vintage dispatch appears, that's the moment to extract it.

From the adversarial pass (it confirmed no keyless bypass — signature over the whole re-canonicalized body, no split-view, replay refused): the real finding was that the shippability recompute still trusted producer-declared per-entry metadata. A key-holder report could relabel a failed blocking entry diagnostic, or excuse anything with not_applicable — which also slipped past the observables projection and dropped the entry's evidence requirement. Each entry's gate and phase are now pinned to the committed spec, criticality must be release_blocking (every entry in this vintage blocks), and not_applicable is refused outright (no entry declares an excuse). The degenerate register's evidence digest joins the input-mass one as a vintage pin — an overridden register is never releasable, which also closes the loop with #662's content-based exclusions_policy label — and schema versions are exact-integer-checked at dispatch and in both version fields (a 4.0 no longer routes). Sync tests hold the new per-entry metadata and the register pin to the committed spec; one named tamper test per new refusal.

@juaristi22
juaristi22 marked this pull request as ready for review August 12, 2026 10:05

@vahid-ahmadi vahid-ahmadi left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

1fd14f76 does what I asked and the adversarial pass found something better than my point.

The skip is properly gone: strict=True, no capability probe, release_evidence passed unconditionally, # pragma: no cover removed with it. That's the version where forgetting is impossible rather than merely discouraged.

The trigger has already fired — the marker is stale as of about two hours ago.

#662 merged at 09:03 today; this branch's head is 08:29. release_evidence is on main now (gate_battery.py:768). So the moment this rebases, the strict xfail XPASSes and fails the build — which is precisely the design working, just sooner than the marker's wording anticipates ("whichever PR merges second must delete this marker" — that's now this one). Worth deleting it in the same push as the rebase rather than discovering it in CI.

The per-entry pins are the real find. Shippability recomputed from producer-declared per-entry metadata was a genuine hole: a key-holding report could relabel a failed blocking entry diagnostic, or excuse anything with not_applicable, and the second one also slipped the observables projection and dropped the entry's evidence requirement. Pinning each entry's gate and phase to the committed spec and refusing both escapes closes a bypass that the signature check alone could never have caught — the report was authentic, it just lied about what the entries were. That's the right class of attack to have gone looking for.

I like that the vintage-wide rules (criticality must be release_blocking, not_applicable refused outright) are backed by sync tests asserting the spec actually has that shape. That converts "true today" into a tripwire rather than a landmine — if a future UK spec adds a diagnostic gate, the sync test fails before the checker starts refusing legitimate reports.

On the shared vintage-dispatch helper — your answer is better than my suggestion. Selecting a pin constant by release id and selecting a checker by the report's own schema genuinely don't share a shape beyond "refuse unknowns by name", and coupling the legacy pin path to the battery path to deduplicate four lines would be a bad trade. Extracting on the third occurrence is the right rule.

Mixture refusal — accepted, and the "one release dir binds exactly one report via the gates.uk_terminal.sha256 triangle, one report carries one schema" argument is the part that makes it safe. Good that the message now names both subsets it saw.

Exact-integer schema checks so a 4.0 doesn't route is a nice touch; that's the kind of thing that bites once, years later, through a JSON round-trip in another language.

Nothing further from me beyond deleting the marker.

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