Skip to content

UK national build onto the shared gate battery (#611 A2) - #662

Merged
juaristi22 merged 7 commits into
mainfrom
uk-battery-consumer-a2
Aug 12, 2026
Merged

UK national build onto the shared gate battery (#611 A2)#662
juaristi22 merged 7 commits into
mainfrom
uk-battery-consumer-a2

Conversation

@juaristi22

Copy link
Copy Markdown
Collaborator

The UK consumer flip of microcosm#611 increment 1 (A2 of the plan on #611): build_uk_national_dataset stops running the hand-rolled uk_terminal_gate_report batch and becomes the shared GateBatteryRun executor's first production caller. Preflight runs before the frame loads, terminal immediately before the staging writer, both under BLOCKS_ARTIFACT; one schema-4 report lands at the existing terminal_gates.json path 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_absent gap with the missing keys spelled out. Blocking is two-axis: FAILED blocks every build exactly as today, evidence_absent blocks only under the new --release-candidate posture (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-candidate is 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)

  1. Error type/message: RuntimeError("Release gates failed: ...") → typed GateBatteryBlockedError with 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.)
  2. Preflight failures now persist a report (blocked_at_phase: "preflight", terminal entries unreached) where the legacy assertions raised bare. (test_national_build_manifest_failure_blocks_before_stages_with_a_report.)
  3. Same path, schema 3 → 4 (passedshippable + 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.)
  4. Signing: env var becomes 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 honest shippable: 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.)
  5. calibration_diagnostics_sha256 moves from the legacy schema-3 attestation into the battery's new signed release_evidence slot (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.)
  6. One exclusion clock: the three exclusion-consuming bindings require an exclusions_evaluated_on artifact — one date computed once per build — instead of per-gate today() 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.)
  7. --degenerate-exclusions under the battery is a loud override: the spec-derived policy_sha256 cannot move at runtime, so the degenerate binding's new evidence hook digests the resolved register records into evidence_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.)
  8. Half-armed input-mass (reference without policy or vice versa) is refused with an early ValueError before 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' _UKGateSurface is 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, the uk_release_input_coverage_gate module alias, and every monkeypatch.setattr(national_build, ...) seam — replaced by an injectable gate_registry parameter.

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 their uk_runtime/__init__ re-exports); the schema-1 --input-coverage-json alias (byte-compatible via the in-memory GateOutcome.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_phaseenforce, and national_build.py is ~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

juaristi22 and others added 5 commits August 11, 2026 18:19
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 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 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:18export 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-byte POPULACE_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_failure and 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-exclusions ordering 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_phaseenforce 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 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.

a3355a7 closes all three of my points, two of them better than I asked.

  • Env vars — README and release.env.example now 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_unlinks parametrized 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>
@juaristi22

Copy link
Copy Markdown
Collaborator Author

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 exclusions_policy ("which register content governed this run", content-compared), and the build record's field is the boolean degenerate_exclusions_override_supplied ("did the operator invoke the override path", presence). A register-identical review file now reads exclusions_policy: "committed" beside override_supplied: true — an honest disagreement the comments in both artifacts explain, instead of one word meaning two things. The rename moves the degenerate evidence digest, so #663's vintage pin moves with it in its next push.

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 --release-candidate with the schema-1 alias at both layers (the alias last-writes over the report path, and a candidate must keep its signed report), made the alias writer atomic, and chained an alias-write failure under the typed block instead of letting it displace it.

@juaristi22
juaristi22 marked this pull request as ready for review August 12, 2026 08:31
@juaristi22
juaristi22 merged commit d1714a7 into main Aug 12, 2026
4 checks passed
juaristi22 added a commit that referenced this pull request Aug 12, 2026
…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>
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