UK national build onto the shared gate battery (#611 A2) - #662
Conversation
Digests of release inputs the gates themselves do not consume (for the UK, the calibration-diagnostics digest) need a signed home once the legacy schema-3 attestation retires. The slot rides in the report body and the signed attestation, defaults to an empty mapping so the envelope key set is stable, and refuses non-string entries. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Exclusion receipts carry approval and expiry dates, and the release contract requires every gate in one report to evaluate them on the same date. The three exclusion-consuming bindings (degenerate surface, input mass, tail concentration) now require an exclusions_evaluated_on artifact — one date, computed once by the caller — instead of each gate defaulting its own clock across a possible midnight. The degenerate binding also gains the review-time override the driver offers (--degenerate-exclusions): a supplied artifact replaces the committed register for that run, and a new evidence hook digests whichever records actually ran into the signed report's evidence_sha256, so an overridden run self-describes. The committed register stays the policy of record; the spec pin test now covers its declared resource name. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
The consumer flip the tooling was built for: build_uk_national_dataset constructs one GateBatteryRun over the declared uk/gates.json spec and runs both phases under BLOCKS_ARTIFACT — preflight before the frame loads (the two raising assertions become declared, persisted verdicts), terminal after the last stage and immediately before the staging writer. Every declared entry now appears in the schema-4 report at the existing diagnostic path; evidence the build cannot supply is a named evidence_absent gap that blocks only release candidates, which is the chartered semantic change: omission stops vanishing. release_candidate (default False, refused on a sampled rung — the #627 non-publishability coupling), a shared now clock, and a gate_registry test seam replace the module-attribute monkeypatch seams. A full-scale build still refuses to stage unsigned (report on disk first); a rung may proceed with an honest shippable: false. The schema-1 input-coverage alias keeps its byte-compatible last-write order through a try/finally around enforce. Deleted: _UKGateEvidence/_uk_gate_evidence (battery_bindings' _UKGateSurface is the one surviving copy) and terminal_gates' verbatim _evaluate_gate (the shared executor's copy is now imported by the retained uk_terminal_gate_report oracle). GateBatteryRun gains a public phase_report accessor so the build result carries its phase reports without reaching into private state. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
The driver catches GateBatteryBlockedError instead of string-matching the "Release gates failed:" prefix (stage reports are written only for a terminal block — a preflight block ran no stage), gains --release-candidate with a parser refusal on any sampled rung, and forwards the flag to the build. The stdout payload (schema 4 -> 5) and the build record (schema 2 -> 3) embed the schema-4 gate report, and the record mirrors calibration_diagnostics_sha256 top-level from the report's signed release_evidence slot. _is_final_release_gate_failure is deleted; the canonical-release-id fence stays as defense in depth over the id namespace. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
vahid-ahmadi
left a comment
There was a problem hiding this comment.
Reviewing this as a draft, since the body reads as review-ready. The delete-or-justify section is the thing I'd want in every migration PR of this shape — naming each retained artifact with its retirement trigger is what stops a migration window becoming permanent. Keeping uk_terminal_gate_report explicitly as the differential oracle, rather than as "we didn't get to it", is the right call.
Enumerating the eight behaviour deltas with a named test each also made this reviewable at a fraction of the cost it would otherwise have been. Two of them I probed; one has a real gap.
1. The signing env-var rename is code-complete but leaves the operator docs pointing at the old variable.
gate_signing_key_env("uk") moves the variable to MICROCOSM_UK_TERMINAL_GATE_SIGNING_KEY, but the two places a human is told what to export still name the old one, and neither is in this PR's file list:
tools/release.env.example:18—export POPULACE_UK_TERMINAL_GATE_SIGNING_KEY=, under a comment saying to source it "before both the UK build and publish steps"README.md:144— "Canonical UK exact-k builds also require a stable, base64-encoded 32-bytePOPULACE_UK_TERMINAL_GATE_SIGNING_KEY"
So after this lands, an operator who follows either exports a variable nothing reads, and per delta 4 the full-scale build then refuses to stage — report on disk, H5 never written. That fails safe, which is right, but the diagnostic points away from the cause because the checked-in instructions are wrong.
Worth noting the retention in contract.py / test_contract.py is correct — the POPULACE variable stays with the schema-3 vintage path, as #663 says. So this isn't "rename everywhere"; it's specifically the two operator-facing docs, and during the migration window they probably need to name both and say which path uses which.
No CI or workflow file is touched either. If the build runs anywhere automated, that environment needs the new variable before this merges, not after.
2. "The A3 verifier must land before any exact-k release assembly" is a note, not a guard.
Delta 3 changes the report at the existing path from schema 3 to schema 4, and the safety argument is that the schema-3 checker fires only for exact-k release ids and no published release exercises it. #663 says merge order between the two is free because there's no file overlap. Both are true, and together they leave a window where #662 is on main, #663 isn't, and an exact-k assembly would produce a schema-4 report that the contract has no dispatch for.
The window is probably short and the team small enough that it holds. But the protection is currently a sentence in a PR body, and PR bodies stop being read the moment the PR merges. If there's a cheap mechanical guard — the assembly path refusing an unknown schema_version by name, which #663 adds anyway — it might be worth having it land with this PR rather than the other one, so the ordering constraint can't be violated silently.
3. Smaller
- Delta 1 deletes
_is_final_release_gate_failureand its string-match. You say no out-of-repo consumers exist; agreed nothing in-repo matches the old"Release gates failed: "prefix. Worth a changelog line flagging the typed-exception change for anyone catching it downstream, since a string-match break is silent at import time. - Delta 6 (one exclusion clock computed once per build, rather than per-gate
today()defaults that could straddle midnight) directly closes the cross-gate same-date rule #658 added. Good that the coupling was noticed rather than discovered in a midnight build. - Delta 8 moving configuration refusals ahead of the sidecar unlinks is the same class of fix as the
--degenerate-exclusionsordering bug from #658's adversarial pass. Might be worth a single invariant somewhere — "no destructive step precedes argument validation" — since that's now twice.
Net ~40 lines shorter in national_build.py with the orchestration collapsing to construct → run_phase → enforce is a good outcome for a change this size.
…, alias posture Findings from the review round (adversarial pass + Vahid on #662): The override label follows content, not artifact presence. The driver materialized the committed register even without --degenerate-exclusions and passed it through the artifact channel, so every default run's signed evidence self-described as an override — the loud-override signal inverted, and the build record contradicted the report. The driver now preflights the committed register without passing it, and the binding labels by comparing the resolved records' policy payloads to the committed register: byte-identical content is the committed policy whichever route delivered it. The no-destruction-before-validation fence now covers every validation: the exclusion clock and the battery construction (release identity, spec parameters, release_evidence values) moved above the sidecar unlinks, so an empty release id, an empty diagnostics digest, or a datetime clock can no longer destroy a previous build's artifacts before refusing. --release-candidate with the schema-1 alias is refused at both layers: in alias mode the alias is last-written over the report path, and a release candidate must keep its signed schema-4 report. The alias writer is now atomic like every other writer on this surface, and a failing alias write during a terminal block chains under the typed GateBatteryBlockedError instead of displacing it. From Vahid's review: both operator docs (release.env.example, README) now name both signing variables and which verification path reads each; the changelog flags the silent string-match break for downstream catchers; the ordering invariant is named in code. Also: the release_evidence signature test asserts baseline equality before tamper inequality, the clock's arm-time (build-start) semantics are documented, the stale consolidation note in battery_bindings is updated, and a dead shippable conjunct is removed. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
vahid-ahmadi
left a comment
There was a problem hiding this comment.
a3355a7 closes all three of my points, two of them better than I asked.
- Env vars — README and
release.env.examplenow name both variables, say which reads which, and state they carry the same key material during the migration. Documenting the retirement trigger on the POPULACE line is the part that'll matter in six months. - Typed exception — the changelog now spells out that a downstream consumer matching
"Release gates failed:"breaks silently and must catch the typed error. That's the right place for it. - The unlink ordering — you turned my throwaway suggestion into a named invariant with a comment at the fence, and then
test_every_identity_refusal_precedes_the_sidecar_unlinksparametrized over every identity refusal rather than the one case I'd have written. That's the version that actually holds.
On the schema-3/schema-4 ordering: moving the constraint from the PR body into the changelog is a reasonable middle ground and I won't push further, though it's worth being clear-eyed that it's still a note. The thing that would make it a guard — refusing an unknown schema_version by name at assembly — lives in #663, so the window between the two merges is covered by discipline rather than by code. Fine if the window is short.
One new thing, and I think it's a real conflict rather than a nit.
The --degenerate-exclusions label now means opposite things in two artifacts of the same build.
_resolve_degenerate_exclusions labels by content:
if resolved_payload == committed_payload:
return committed, "committed"
return resolved, "override"The label follows the content, not the artifact's presence: records identical to the committed register are the committed policy whichever route delivered them.
But the driver's build record, which #658 landed one day ago and this PR does not touch, labels by presence — tools/build_uk_national_dataset.py:745:
# content-identical --degenerate-exclusions override would be
# invisible there; the record keeps the provenance honest
"degenerate_exclusions_register": (
"override" if degenerate_exclusions_override else "committed"
),fed by degenerate_exclusions_override=args.degenerate_exclusions is not None.
So a build run with --degenerate-exclusions <copy-of-the-committed-register> now emits a build record saying "override" and a signed gate report saying "committed", and each carries a comment explaining why its own semantics is the honest one. #658's argument was explicitly that content-identity is what makes an override invisible, so presence must be recorded; this PR's argument is explicitly that content-identity means no deviation occurred. Both are defensible in isolation — the problem is shipping both under the same name in the same build.
Worth deciding which question the field answers:
- What policy governed this build? → content, and #658's build-record line should follow.
- Did the operator invoke an override path? → presence, and this binding should follow.
If both are genuinely wanted — and I think they might be, since they answer different audit questions — then they need distinct names, because exclusions_register / degenerate_exclusions_register reading "override" in one artifact and "committed" in the other is the kind of discrepancy that costs someone an afternoon during an incident.
Your test_resupplying_the_committed_register_is_not_an_override pins the new semantics deliberately, so this is a decision rather than an accident — it just doesn't look like it was made with #658's line in view.
Vahid's follow-up on the override-label fix: the signed report's evidence
labeled by content ("committed" for a register-identical review file)
while the #658 build record labeled by presence ("override" whenever the
flag was passed) — opposite answers under near-identical names in two
artifacts of the same build. Both questions are real, so each now carries
its own name: the evidence payload's exclusions_policy answers "which
register content governed this run", and the build record's boolean
degenerate_exclusions_override_supplied answers "did the operator invoke
the override path". The two can honestly disagree, and both artifacts now
say so in their comments.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
|
Taken — you're right that it was a decision made without #658's line in view. Both questions survive, each under its own name (93735e2): the signed evidence payload's key is now On the window between the merges: agreed it's discipline, not code, until #663's dispatch lands — and #663 now also pins the committed register's evidence digest, so post-merge an overridden register is unreleasable by contract, not just self-describing. An independent adversarial pass on this branch also landed in a3355a7, one finding of which sharpened the same override seam you caught: the driver was materializing the committed register even without the flag, so every default run's signed evidence self-described as an override — the label inversion your follow-up would have collided with. The driver now preflights the register without passing it. The same pass moved the exclusion clock and battery construction inside the no-destruction fence (an empty release id could previously delete a prior report before refusing), refused |
…l marker The strict xfail on test_a_real_report_survives_every_mirror_check said "whichever PR merges second must delete this marker — the round trip can never stay silently un-run." The consumer PR merged first, so this merge deletes the marker as its semantic conflict resolution: GateBatteryRun now carries the release_evidence slot and the producer→verifier round trip runs live (a plain merge commit would fail as a strict XPASS by design). Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
The UK consumer flip of microcosm#611 increment 1 (A2 of the plan on #611):
build_uk_national_datasetstops running the hand-rolleduk_terminal_gate_reportbatch and becomes the sharedGateBatteryRunexecutor's first production caller. Preflight runs before the frame loads, terminal immediately before the staging writer, both underBLOCKS_ARTIFACT; one schema-4 report lands at the existingterminal_gates.jsonpath with every declared entry present.The chartered semantic change
Omission stops vanishing. The legacy report silently omitted gates whose evidence was absent; the battery records each as a named
evidence_absentgap with the missing keys spelled out. Blocking is two-axis:FAILEDblocks every build exactly as today,evidence_absentblocks only under the new--release-candidateposture (default off — the staging build has no calibration, so its parity trio is legitimately absent and the default build's blocking behavior is verdict-identical to the legacy path).--release-candidateis refused on a sampled rung: a rung is structurally non-releasable, the durable #627 coupling the #656 fence comment deferred to this increment. The id-namespace fence stays as defense in depth.Behavior deltas a reviewer should probe (each pinned by a named test)
RuntimeError("Release gates failed: ...")→ typedGateBatteryBlockedErrorwith entry-id-prefixed failure lines; the driver's string-match (_is_final_release_gate_failure) is deleted. Out-of-repo consumers matching the old prefix would break — none exist in-repo. (test_national_build_real_terminal_batch_writes_all_findings_before_raise, driver re-raise tests.)blocked_at_phase: "preflight", terminal entriesunreached) where the legacy assertions raised bare. (test_national_build_manifest_failure_blocks_before_stages_with_a_report.)passed→shippable+ five-state statuses, gates keyed by spec entry id). Safe today because the schema-3 release checker fires only for exact-k release ids and no published release exercises it; the schema-4 verifier (A3, concurrent PR) must land before any exact-k release assembly. (test_national_build_real_terminal_batch_passes_before_staging.)MICROCOSM_UK_TERMINAL_GATE_SIGNING_KEY(gate_signing_key_env("uk")). A full-scale build still refuses to stage unsigned — report on disk first, H5 never written; a rung proceeds with an honestshippable: false(reviewed decision: rung operability during the env-var migration). CI/local build environments need the new variable exported. (test_full_scale_build_refuses_to_stage_unsigned,test_rung_build_proceeds_unsigned_with_an_honest_report.)calibration_diagnostics_sha256moves from the legacy schema-3 attestation into the battery's new signedrelease_evidenceslot (small additive executor extension) and is mirrored top-level in the build record (schema 2 → 3); the stdout payload bumps 4 → 5. (TestReleaseEvidence, driver seam test.)exclusions_evaluated_onartifact — one date computed once per build — instead of per-gatetoday()defaults that could straddle midnight and violate the cross-gate same-date rule source_year reviewed exclusion + schema-2 approval receipts with expiry (#630, #610) #658 added to the contract. (TestExclusionDiscipline.)--degenerate-exclusionsunder the battery is a loud override: the spec-derivedpolicy_sha256cannot move at runtime, so the degenerate binding's new evidence hook digests the resolved register records intoevidence_sha256— an overridden run self-describes in the signed report, and the committed register stays the policy of record. (test_review_override_is_loud_in_the_evidence_payload.)ValueErrorbefore any report exists, where the legacy path produced a FAILED gate inside the report — deliberate: it is a configuration error, not gate evidence. Configuration refusals now all precede the sidecar unlinks, so a misconfigured run cannot destroy a previous report. (test_release_candidate_is_refused_on_a_rung_before_any_unlink.)Delete-or-justify (the legacy sweep)
Deleted, zero references remain:
_UKGateEvidence/_uk_gate_evidence(battery_bindings'_UKGateSurfaceis the one surviving copy of the duck-attr surface), terminal_gates' verbatim_evaluate_gate(the shared executor's copy is imported by the retained oracle), the driver's_is_final_release_gate_failure, theuk_release_input_coverage_gatemodule alias, and everymonkeypatch.setattr(national_build, ...)seam — replaced by an injectablegate_registryparameter.Retained deliberately, each with its retirement trigger:
uk_terminal_gate_report+ writer +_AttestedUKTerminalGateReport+ the POPULACE env var (the differential-test oracle — retires after S3's 10% old-vs-new real-data comparison, together with theiruk_runtime/__init__re-exports); the schema-1--input-coverage-jsonalias (byte-compatible via the in-memoryGateOutcome.result, kept through a try/finally so the blocked path preserves the legacy last-write order); the driver's canonical-id fence (defense in depth behind--release-candidate).Net orchestration complexity shrinks: the report/write/raise sequence and its attestation plumbing collapse into construct →
run_phase→enforce, andnational_build.pyis ~40 lines shorter.Verification
The differential suite (battery ≡ legacy oracle, gate for gate, over identical synthetic evidence — now including a shared expiry clock and an expired-register case) stays green through the whole change; the executor-level write-then-block, alias byte-compat in pass and block paths, candidate blocking, and both unsigned behaviors are pinned by new orchestration tests. Fixtures remain synthetic throughout (UKDS licence).
Part of #611. The A3 verifier PR lands the schema-4 verification path in microcosm-data; whichever merges second flips the round-trip xfail noted there.
🤖 Generated with Claude Code