From cb1a7444c8e6dd0628e9400803780ef00f054442 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Mar=C3=ADa=20Juaristi?= <127882282+juaristi22@users.noreply.github.com> Date: Fri, 28 Aug 2026 15:39:11 +0200 Subject: [PATCH 1/7] Assemble the certified UK national candidate into a publishable release (#806) The release-cut certification decided shippability but nothing packaged it: no UK tool wrote the two release manifests, the constant national id escaped the contract's UK-keyed checks, and the id ruling collided with the tag-equals-release-id revision scheme. This closes all three for the inspect lane (publication stays --no-latest until the loader/pointer follow-up). tools/assemble_uk_release_dir.py verifies the certification's closed identity join against the measured bytes before writing anything, mints the calibration weight NPZ (household_weight + initial_household_weight, the warm-start shape), writes build_manifest.json and release_manifest.json with uniform per-cut artifact revisions, copies the signed evidence byte-for-byte, validates its own output, and prints the exact publish command. The contract now applies the UK diagnostics block and the policyengine-uk model pin to microcosm-uk-2024-25-national (the June legacy release keeps its exclusion) and accepts that id's per-cut artifact revisions (release-id-prefixed, non-empty suffix). publish_release's tag guards generalize from release-id-pinned revisions to any pinned revisions: a tag must be created and every declared revision must equal the tag being created, so a forgotten --tag-name or a typo refuses instead of publishing dangling revision pins. The green-certification test machinery moves to a shared fixture module used by the certification and assembler suites. The runbook documents the calibrate -> certify -> assemble -> inspect-publish sequence, the stranded release-staging branch recovery, and why promotion waits. Co-Authored-By: Claude Fable 5 --- .../806-national-contract-coverage.changed.md | 3 + changelog.d/806-publish-tag-guards.changed.md | 2 + changelog.d/806-uk-release-assembler.added.md | 4 + ...k-national-release-assembly-runbook-806.md | 149 +++++ .../tests/test_uk_release_assembler.py | 345 +++++++++++ .../tests/test_uk_release_certification.py | 204 +------ .../tests/uk_certification_fixtures.py | 204 +++++++ .../src/microcosm/data/contract.py | 15 +- .../src/microcosm/data/release.py | 10 +- .../microcosm-data/tests/test_contract.py | 125 +++- packages/microcosm-data/tests/test_release.py | 57 +- tools/assemble_uk_release_dir.py | 565 ++++++++++++++++++ 12 files changed, 1484 insertions(+), 199 deletions(-) create mode 100644 changelog.d/806-national-contract-coverage.changed.md create mode 100644 changelog.d/806-publish-tag-guards.changed.md create mode 100644 changelog.d/806-uk-release-assembler.added.md create mode 100644 docs/uk-national-release-assembly-runbook-806.md create mode 100644 packages/microcosm-build/tests/test_uk_release_assembler.py create mode 100644 packages/microcosm-build/tests/uk_certification_fixtures.py create mode 100644 tools/assemble_uk_release_dir.py diff --git a/changelog.d/806-national-contract-coverage.changed.md b/changelog.d/806-national-contract-coverage.changed.md new file mode 100644 index 0000000000..e501cf5d73 --- /dev/null +++ b/changelog.d/806-national-contract-coverage.changed.md @@ -0,0 +1,3 @@ +Extended the release contract to apply UK diagnostics and PolicyEngine UK +model-package checks to `microcosm-uk-2024-25-national`, while accepting its +non-empty per-cut artifact revisions and retaining the June legacy exclusion. diff --git a/changelog.d/806-publish-tag-guards.changed.md b/changelog.d/806-publish-tag-guards.changed.md new file mode 100644 index 0000000000..c0ceb45292 --- /dev/null +++ b/changelog.d/806-publish-tag-guards.changed.md @@ -0,0 +1,2 @@ +Hardened publication so every artifact-pinned release must create a tag and +all declared artifact revisions must uniformly match the tag being created. diff --git a/changelog.d/806-uk-release-assembler.added.md b/changelog.d/806-uk-release-assembler.added.md new file mode 100644 index 0000000000..37f80a57f3 --- /dev/null +++ b/changelog.d/806-uk-release-assembler.added.md @@ -0,0 +1,4 @@ +Added `tools/assemble_uk_release_dir.py` to verify the certified UK national +identity join, mint the calibration weight NPZ, assemble and self-validate the +publishable release directory, and print the immutable inspect-lane publish +command. diff --git a/docs/uk-national-release-assembly-runbook-806.md b/docs/uk-national-release-assembly-runbook-806.md new file mode 100644 index 0000000000..9fc72d7e74 --- /dev/null +++ b/docs/uk-national-release-assembly-runbook-806.md @@ -0,0 +1,149 @@ +# UK national release assembly runbook (#806) + +This runbook turns a green UK national calibration candidate into an +inspectable release without promoting it to `latest.json`. The release id is +constant across cuts (`microcosm-uk-2024-25-national`); each cut gets an +immutable tag derived from the calibration attempt id. + +Do not run this sequence in PR CI. Calibration, certification, assembly, and +publication consume licensed data and operator credentials. + +## Prerequisites + +Before starting, export: + +- `MICROCOSM_UK_TERMINAL_GATE_SIGNING_KEY`, containing the stable release key + as base64-encoded 32 bytes. Certification signs with it and assembly uses it + to re-verify the copied certification. +- `HF_TOKEN`, authenticated for write access to + `policyengine/populace-uk-private`. +- `SLACK_WEBHOOK_POPULACE_UK`, for the eventual promoted-release alert. The + inspect publication below uses `--no-latest`, so it does not announce a new + latest release. + +Keep the spine H5, its sibling `.build.json` and `.spine_gates.json`, the +Ledger consumer artifact and manifest digest, and the licensed input-mass +reference together through the run. + +## 1. Calibrate the national candidate + +Use the national calibration driver and record the input digest rather than +relying on a mutable path: + +```bash +uv run --no-sync python tools/calibrate_uk_national_dataset.py \ + --input-h5 \ + --input-sha256 \ + --ledger-facts \ + --ledger-facts-sha256 \ + --ledger-manifest-sha256 \ + --staging-h5 /microcosm_uk_2024.h5 \ + --diagnostics-json /calibration_diagnostics.json \ + --build-record-json /build_record.json \ + --terminal-gate-json /terminal_gates.json \ + --release-id dev-uk-national-calibration +``` + +Use only the campaign doctrine overrides that were separately adjudicated. +The build record's id has the form +`uk-frs-calibration-attempt--`; assembly derives the +per-cut tag from that suffix. + +## 2. Score and certify the cut + +First create the rule-1 score receipt against the pinned incumbent, following +the scoring section of +`docs/uk-national-calibration-runbook-623.md`. Then run the release-cut battery +and compose the signed certification: + +```bash +uv run --no-sync python tools/certify_uk_release_cut.py \ + --candidate-h5 /microcosm_uk_2024.h5 \ + --candidate-sha256 \ + --candidate-name microcosm_uk_2024 \ + --spine-h5 \ + --diagnostics-json /calibration_diagnostics.json \ + --build-record-json /build_record.json \ + --seam-gate-report /terminal_gates.json \ + --ledger-facts \ + --ledger-facts-sha256 \ + --ledger-manifest-sha256 \ + --input-mass-reference \ + --score-receipt /score_vs_enhanced_frs.json \ + --release-id microcosm-uk-2024-25-national +``` + +With the default paths, this writes +`microcosm_uk_2024.release_cut_gates.json` and +`microcosm_uk_2024.release_certification.json` next to the candidate. Continue +only when the certification says `shippable: true`. + +## 3. Assemble the release directory + +Assembly verifies the complete hash join before writing, mints the calibration +NPZ from the candidate and spine weights, copies signed evidence byte-for-byte, +and validates the finished directory: + +```bash +uv run --no-sync python tools/assemble_uk_release_dir.py \ + --candidate-h5 /microcosm_uk_2024.h5 \ + --spine-h5 \ + --certification-json /microcosm_uk_2024.release_certification.json \ + --build-record-json /build_record.json \ + --diagnostics-json /calibration_diagnostics.json \ + --seam-gate-report /terminal_gates.json \ + --release-cut-gate-json /microcosm_uk_2024.release_cut_gates.json \ + --score-receipt /score_vs_enhanced_frs.json \ + --out-dir releases +``` + +The output is +`releases/microcosm-uk-2024-25-national/`. The JSON summary records every +digest, the derived cut tag, and the exact publication command. Use +`--cut-tag microcosm-uk-2024-25-national-` only to override the derived +tag deliberately. `--runtime-version PACKAGE=VERSION` is available when a +package's runtime version cannot be resolved from the assembly environment. + +## 4. Publish for inspection + +Run the command printed by the assembler. Its shape is: + +```bash +uv run python -m microcosm.data.publish_cli \ + releases/microcosm-uk-2024-25-national \ + --repo-id policyengine/populace-uk-private \ + --artifact-root \ + --no-latest \ + --tag-name microcosm-uk-2024-25-national-- +``` + +Do not omit `--tag-name`, and do not pass `--no-create-tag`: every artifact in +the manifest is pinned to that immutable per-cut tag. `--no-latest` is +mandatory for this inspect lane. + +If tag creation returns HTTP 409 after the staging commit, publication can +leave the constant branch +`release-staging/microcosm-uk-2024-25-national` behind. Delete that branch +manually in the private Hugging Face repository before retrying the same cut. +Do not delete the immutable cut tag. + +## 5. Inspect on the dashboard + +Open the calibration-diagnostics dashboard with: + +```text +?country=uk&release=microcosm-uk-2024-25-national +``` + +Adjudicate the release using the copied certification, scoped gate reports, +calibration diagnostics, and score receipt. The release remains inspect-only +until that review is complete. + +## Promotion is a separate change + +Do not write `latest.json` for this line yet. The current pointer cannot name a +per-cut tag, while the certified loader expects the artifact revision to equal +the release id and fetches the manifest from a tag named by that id. Promotion +needs the follow-up loader/pointer design before a reviewed cut can become the +default. Until then, publish every national cut with `--no-latest` and its +explicit per-cut tag. diff --git a/packages/microcosm-build/tests/test_uk_release_assembler.py b/packages/microcosm-build/tests/test_uk_release_assembler.py new file mode 100644 index 0000000000..b7f8cbd27d --- /dev/null +++ b/packages/microcosm-build/tests/test_uk_release_assembler.py @@ -0,0 +1,345 @@ +"""UK national release-directory assembly and refusal coverage.""" + +from __future__ import annotations + +import importlib.util +import json +from pathlib import Path + +import numpy as np +import pandas as pd +import pytest + +from microcosm.build.uk_runtime.calibration_run import resign_uk_gate_report +from microcosm.build.uk_runtime.national_frame import ( + uk_national_frame, + write_uk_national_frame, +) +from microcosm.build.uk_runtime.release_certification import ( + compose_uk_release_certification, +) +from microcosm.build.uk_runtime.release_identity import UK_NATIONAL_RELEASE_ID +from microcosm.data.contract import validate_release_dir +from microcosm.frame import WeightKind + + +def _load_fixture_module(): + path = Path(__file__).with_name("uk_certification_fixtures.py") + spec = importlib.util.spec_from_file_location("uk_certification_fixtures", path) + module = importlib.util.module_from_spec(spec) + assert spec.loader is not None + spec.loader.exec_module(module) + return module + + +_FIXTURES = _load_fixture_module() +_green_certification_inputs_fixture = _FIXTURES.green_certification_inputs +_signing_key_fixture = _FIXTURES.signing_key +sha256 = _FIXTURES.sha256 + +_SHARED_FIXTURES = ( + _green_certification_inputs_fixture, + _signing_key_fixture, +) +_CODE_PIN = "5fa48f07436a806ad75ff76fd22cfb8613bddbe0" +_ATTEMPT_ID = "uk-frs-calibration-attempt-20260828T101112Z-1a2b3c4d" +_CUT_TAG = f"{UK_NATIONAL_RELEASE_ID}-20260828T101112Z-1a2b3c4d" + + +def _load_driver_module(): + root = Path(__file__).resolve().parents[3] + path = root / "tools" / "assemble_uk_release_dir.py" + spec = importlib.util.spec_from_file_location("assemble_uk_release_dir", path) + module = importlib.util.module_from_spec(spec) + assert spec.loader is not None + spec.loader.exec_module(module) + return module + + +def _frame(weights: list[float], *, weight_kind: WeightKind): + ids = np.arange(len(weights), dtype="int64") + return uk_national_frame( + person=pd.DataFrame( + { + "person_id": ids, + "person_benunit_id": ids, + "person_household_id": ids, + } + ), + benunit=pd.DataFrame({"benunit_id": ids}), + household=pd.DataFrame( + { + "household_id": ids, + "household_weight": np.asarray(weights, dtype="float64"), + } + ), + time_period="2024", + weight_kind=weight_kind, + ) + + +def _diagnostics() -> dict: + return { + "schema_version": 6, + "weight_entity": "household", + "options": {"epochs": 2}, + "target_surface": { + "schema_version": 1, + "weight_entity": "household", + "n_targets": 1, + "n_records": 2, + "constraint_matrix": {"rows": 1, "columns": 2, "nnz": 1}, + "sha256": "a" * 64, + "names_sha256": "b" * 64, + "values_sha256": "c" * 64, + }, + "target_registry": { + "country": "uk", + "version": "registry-test-v1", + "n_specs": 1, + }, + "targets": [ + { + "name": "ons.population@2024", + "target_name": "ons.population", + "period": 2024, + "entity": "household", + "target": 2.0, + "compiled_target": 2.0, + "initial_estimate": 2.0, + "final_estimate": 2.0, + "relative_error": 0.0, + "source": "synthetic test", + "measure": {"column": "household_id"}, + "filter": None, + "metadata": {}, + } + ], + "loss_trajectory": [1.0, 0.0], + "skipped": [], + "n_records": 2, + "effective_sample_size": 1.0, + "top_1pct_weight_share": 1.0, + "uk_diagnostics": { + "schema_version": 1, + "weights": { + "n_records": 2, + "positive_weight_records": 1, + "zero_weight_records": 1, + "total_weight": 4.0, + "effective_sample_size": 1.0, + "ess_fraction": 0.5, + "median_positive_weight": 4.0, + "max_weight": 4.0, + "max_to_median_positive_weight": 1.0, + "top_1pct_weight_share": 1.0, + }, + "zero_weight_rows_by_stratum": [ + { + "stratum": {"household_is_spi_synthetic": False}, + "rows": 2, + "positive_weight_rows": 1, + "zero_weight_rows": 1, + "weight_sum": 4.0, + } + ], + "target_pass_rates_by_geography_level": [ + { + "geography_level": level, + "n_targets": 1 if level == "national" else 0, + "n_scored": 1 if level == "national" else 0, + "n_skipped": 0, + "n_within_10pct": 1 if level == "national" else 0, + "pass_rate": 1.0 if level == "national" else None, + } + for level in ( + "national", + "region", + "country", + "local_authority", + "constituency", + ) + ], + }, + "build": {"code_pin": _CODE_PIN}, + } + + +@pytest.fixture +def assembler_inputs(green_certification_inputs, tmp_path: Path): + candidate = green_certification_inputs["candidate_path"] + spine = tmp_path / "spine.h5" + write_uk_national_frame( + _frame([0.0, 4.0], weight_kind=WeightKind.CALIBRATED), candidate + ) + write_uk_national_frame(_frame([1.0, 2.0], weight_kind=WeightKind.DESIGN), spine) + + diagnostics_path = tmp_path / "calibration_diagnostics.json" + diagnostics_path.write_text(json.dumps(_diagnostics()), encoding="utf-8") + diagnostics_sha = sha256(diagnostics_path) + for report_name in ("seam_report_path", "release_cut_report_path"): + report_path = green_certification_inputs[report_name] + report = json.loads(report_path.read_text(encoding="utf-8")) + report["release_evidence"]["calibration_diagnostics_sha256"] = diagnostics_sha + if report_name == "release_cut_report_path": + report["release_id"] = UK_NATIONAL_RELEASE_ID + resign_uk_gate_report(report) + report_path.write_text( + json.dumps(report, indent=2, sort_keys=True), encoding="utf-8" + ) + + candidate_sha = sha256(candidate) + score_path = green_certification_inputs["score_receipt_path"] + score = json.loads(score_path.read_text(encoding="utf-8")) + score["artifacts"]["candidate"].update( + sha256=candidate_sha, size_bytes=candidate.stat().st_size + ) + score_path.write_text(json.dumps(score), encoding="utf-8") + + build_record = green_certification_inputs["build_record"] + build_record.update( + build_id=_ATTEMPT_ID, + input_posture={"sha256": sha256(spine)}, + gate_summary={"uk_target_fit": "passed"}, + ) + build_record["spine_provenance"]["rules_engine"] = { + "name": "policyengine-uk", + "version": "2.89.0", + } + build_record["artifacts"] = { + "staging_h5": {"sha256": candidate_sha}, + "diagnostics_json": {"sha256": diagnostics_sha}, + "terminal_gate_json": { + "sha256": sha256(green_certification_inputs["seam_report_path"]) + }, + } + + certification_path = tmp_path / "release_certification.json" + compose_inputs = { + **green_certification_inputs, + "release_id": UK_NATIONAL_RELEASE_ID, + "candidate_name": candidate.stem, + "candidate_sha256": candidate_sha, + "certification_path": certification_path, + } + compose_uk_release_certification(**compose_inputs) + build_record_path = tmp_path / "build_record.json" + build_record_path.write_text(json.dumps(build_record), encoding="utf-8") + out_dir = tmp_path / "releases" + argv = [ + "--candidate-h5", + str(candidate), + "--spine-h5", + str(spine), + "--certification-json", + str(certification_path), + "--build-record-json", + str(build_record_path), + "--diagnostics-json", + str(diagnostics_path), + "--seam-gate-report", + str(green_certification_inputs["seam_report_path"]), + "--release-cut-gate-json", + str(green_certification_inputs["release_cut_report_path"]), + "--score-receipt", + str(score_path), + "--out-dir", + str(out_dir), + "--runtime-version", + "policyengine-core=3.19.0", + ] + return { + "argv": argv, + "candidate": candidate, + "spine": spine, + "diagnostics": diagnostics_path, + "certification": certification_path, + "score": score_path, + "out_dir": out_dir, + } + + +def test_assemble_green_release_dir(assembler_inputs, capsys) -> None: + driver = _load_driver_module() + assert driver.main(assembler_inputs["argv"]) == 0 + summary = json.loads(capsys.readouterr().out) + release_dir = assembler_inputs["out_dir"] / UK_NATIONAL_RELEASE_ID + + validate_release_dir(release_dir) + calibration_path = assembler_inputs["candidate"].with_name( + "microcosm_uk_2024_calibration.npz" + ) + with np.load(calibration_path) as calibration: + np.testing.assert_array_equal( + calibration.files, + [ + "household_weight", + "initial_household_weight", + ], + ) + np.testing.assert_array_equal(calibration["household_weight"], [0.0, 4.0]) + np.testing.assert_array_equal( + calibration["initial_household_weight"], [1.0, 2.0] + ) + + build_manifest = json.loads((release_dir / "build_manifest.json").read_text()) + release_manifest = json.loads((release_dir / "release_manifest.json").read_text()) + assert "staging" not in build_manifest + assert build_manifest["attempt_id"] == _ATTEMPT_ID + assert build_manifest["cut_tag"] == _CUT_TAG + assert {entry["revision"] for entry in release_manifest["artifacts"].values()} == { + _CUT_TAG + } + assert "dataset_role" not in release_manifest + assert ( + release_dir / "calibration_diagnostics.json" + ).read_bytes() == assembler_inputs["diagnostics"].read_bytes() + assert ( + release_dir / "release_certification.json" + ).read_bytes() == assembler_inputs["certification"].read_bytes() + assert summary["cut_tag"] == _CUT_TAG + assert summary["release_dir"] == str(release_dir) + assert summary["publish_command"] == ( + f"uv run python -m microcosm.data.publish_cli {release_dir} " + "--repo-id policyengine/populace-uk-private " + f"--artifact-root {assembler_inputs['candidate'].parent} --no-latest " + f"--tag-name {_CUT_TAG}" + ) + + +def test_assemble_refuses_candidate_sha_mismatch(assembler_inputs) -> None: + assembler_inputs["candidate"].write_bytes( + assembler_inputs["candidate"].read_bytes() + b"tampered" + ) + with pytest.raises(SystemExit, match="candidate bytes"): + _load_driver_module().main(assembler_inputs["argv"]) + + +def test_assemble_refuses_unshippable_certification(assembler_inputs) -> None: + certification = json.loads(assembler_inputs["certification"].read_text()) + certification["shippable"] = False + assembler_inputs["certification"].write_text(json.dumps(certification)) + with pytest.raises(SystemExit, match="shippable must be true"): + _load_driver_module().main(assembler_inputs["argv"]) + + +def test_assemble_refuses_missing_score_receipt(assembler_inputs) -> None: + assembler_inputs["score"].unlink() + with pytest.raises(FileNotFoundError, match="score_vs_enhanced_frs"): + _load_driver_module().main(assembler_inputs["argv"]) + + +def test_assemble_refuses_unprefixed_cut_tag(assembler_inputs) -> None: + with pytest.raises(SystemExit, match="must start with"): + _load_driver_module().main( + [*assembler_inputs["argv"], "--cut-tag", "not-a-national-cut"] + ) + + +def test_assemble_refuses_reserialized_diagnostics(assembler_inputs) -> None: + diagnostics = json.loads(assembler_inputs["diagnostics"].read_text()) + assembler_inputs["diagnostics"].write_text( + json.dumps(diagnostics, indent=4), encoding="utf-8" + ) + with pytest.raises(SystemExit, match="diagnostics bytes"): + _load_driver_module().main(assembler_inputs["argv"]) diff --git a/packages/microcosm-build/tests/test_uk_release_certification.py b/packages/microcosm-build/tests/test_uk_release_certification.py index 3877ce842f..516bdceacd 100644 --- a/packages/microcosm-build/tests/test_uk_release_certification.py +++ b/packages/microcosm-build/tests/test_uk_release_certification.py @@ -4,6 +4,7 @@ import base64 import hashlib +import importlib.util import json from datetime import date from pathlib import Path @@ -11,22 +12,13 @@ import pytest from microcosm.build.country_spec import load_country_spec -from microcosm.build.gate_battery import ( - BlockingMode, - EvidenceContext, - FunctionBinding, - GateBatteryRun, - gate_signing_key_env, -) -from microcosm.build.gates import GateResult +from microcosm.build.gate_battery import gate_signing_key_env from microcosm.build.uk_runtime import release_certification from microcosm.build.uk_runtime.calibration_run import ( UK_CALIBRATION_GATE_SCOPE, UK_NATIONAL_GATE_SCOPE, UK_SHARED_GATE_IDS, UK_SPINE_GATE_SCOPE, - resign_uk_gate_report, - uk_scoped_gate_manifest, ) from microcosm.build.uk_runtime.release_certification import ( UKReleaseCertificationError, @@ -36,184 +28,27 @@ uk_release_cut_scope_exclusions, ) -_TEST_KEY = base64.b64encode(bytes(range(32))).decode("ascii") - - -@pytest.fixture(autouse=True) -def _signing_key(monkeypatch): - monkeypatch.setenv(gate_signing_key_env("uk"), _TEST_KEY) - -def _stub_registry(): - """A registry that passes every declared gate, over the real spec. +def _load_fixture_module(): + path = Path(__file__).with_name("uk_certification_fixtures.py") + spec = importlib.util.spec_from_file_location("uk_certification_fixtures", path) + module = importlib.util.module_from_spec(spec) + assert spec.loader is not None + spec.loader.exec_module(module) + return module - Digests derive from the committed manifest alone, so parts built with - this registry carry the same ``gates_manifest_sha256`` / ``policy_sha256`` - the production registry would produce — which is exactly what the - composer verifies. - """ - spec = load_country_spec("uk").gates - parameter_keys: dict[str, set[str]] = {} - for entry in spec.gates: - parameter_keys.setdefault(entry.gate, set()).update(entry.parameters) +_FIXTURES = _load_fixture_module() +_TEST_KEY = _FIXTURES.TEST_KEY +_green_certification_inputs_fixture = _FIXTURES.green_certification_inputs +_signing_key_fixture = _FIXTURES.signing_key +_sha = _FIXTURES.sha256 +_stub_registry = _FIXTURES.stub_registry - def _passing(name): - def _gate(**_kwargs): - return GateResult(name=name, passed=True) - - return _gate - - return { - gate: FunctionBinding( - name=gate, - gate=_passing(gate), - parameter_keys=frozenset(keys), - ) - for gate, keys in parameter_keys.items() - } - - -def _write_part( - path: Path, - scope, - phases, - *, - release_id, - release_candidate, - release_evidence=None, - augment=None, - block_phase=None, -): - registry = _stub_registry() - manifest = uk_scoped_gate_manifest( - frozenset(scope), - phases=tuple(phases), - policy_suffix={ - frozenset(UK_SPINE_GATE_SCOPE): "spine_build_scope", - frozenset(UK_CALIBRATION_GATE_SCOPE): "calibration_seam_scope", - frozenset(UK_NATIONAL_GATE_SCOPE): "release_cut_scope", - }[frozenset(scope)], - ) - battery = GateBatteryRun( - manifest, - release_id=release_id, - report_path=path, - release_candidate=release_candidate, - registry=registry, - release_evidence=release_evidence or {}, - ) - for phase in phases: - battery.run_phase(phase, EvidenceContext(artifacts={})) - battery.enforce(phase, mode=BlockingMode.MARKS_ARTIFACT) - payload = battery.report_payload() - if augment: - payload.update(augment) - resign_uk_gate_report(payload) - path.write_text(json.dumps(payload, indent=2, sort_keys=True), encoding="utf-8") - return payload - - -def _sha(path: Path) -> str: - return hashlib.sha256(path.read_bytes()).hexdigest() - - -@pytest.fixture -def green_certification_inputs(tmp_path: Path): - """Three green signed parts plus a closed identity join.""" - - candidate = tmp_path / "microcosm_uk_2024.h5" - candidate.write_bytes(b"candidate-bytes") - candidate_sha = _sha(candidate) - diagnostics = tmp_path / "calibration_diagnostics.json" - diagnostics.write_text('{"targets": []}', encoding="utf-8") - diagnostics_sha = _sha(diagnostics) - - spine_report = tmp_path / "spine.spine_gates.json" - _write_part( - spine_report, - UK_SPINE_GATE_SCOPE, - ("assembled", "transferred"), - release_id="uk-frs-spine-test", - release_candidate=True, - ) - seam_report = tmp_path / "terminal_gates.json" - _write_part( - seam_report, - UK_CALIBRATION_GATE_SCOPE, - ("terminal",), - release_id="dev-seam-test", - release_candidate=False, - release_evidence={"calibration_diagnostics_sha256": diagnostics_sha}, - augment={ - "posture": "calibration_seam", - "scope_exclusions": {}, - "aggregate_admin_measurement": {}, - }, - ) - release_cut_report = tmp_path / "release_cut_gates.json" - _write_part( - release_cut_report, - UK_NATIONAL_GATE_SCOPE, - ("preflight", "terminal"), - release_id="uk-757-first-certified-cut", - release_candidate=True, - release_evidence={"calibration_diagnostics_sha256": diagnostics_sha}, - augment={ - "posture": "release_cut", - "scope_exclusions": uk_release_cut_scope_exclusions(), - "aggregate_admin_measurement": {}, - }, - ) - seam_report_sha = _sha(seam_report) - sidecar = { - "stages": ["frs_spine"], - "spine_gate_report": { - "path": str(spine_report), - "sha256": _sha(spine_report), - }, - } - build_record = { - "run_config": { - "doctrine": {"epochs": 1500}, - "doctrine_overrides": {"epochs": {"default": 256, "effective": 1500}}, - }, - "spine_provenance": { - "spine_gate_report": {"sha256": _sha(spine_report)}, - }, - "artifacts": { - "staging_h5": {"sha256": candidate_sha}, - "diagnostics_json": {"sha256": diagnostics_sha}, - "terminal_gate_json": {"sha256": seam_report_sha}, - }, - } - score_receipt = tmp_path / "score_vs_enhanced_frs.json" - score_receipt.write_text( - json.dumps( - { - "artifacts": { - "candidate": {"sha256": candidate_sha, "size_bytes": 15}, - "incumbent": {"sha256": "9" * 64, "size_bytes": 1}, - }, - "candidate_target_wins": 293, - } - ), - encoding="utf-8", - ) - return { - "release_id": "uk-757-first-certified-cut", - "candidate_name": "microcosm_uk_2024", - "candidate_path": candidate, - "candidate_sha256": candidate_sha, - "spine_report_path": spine_report, - "seam_report_path": seam_report, - "release_cut_report_path": release_cut_report, - "spine_sidecar": sidecar, - "build_record": build_record, - "score_receipt_path": score_receipt, - "exclusions_evaluated_on": date(2026, 8, 27), - "certification_path": tmp_path / "release_certification.json", - } +_SHARED_FIXTURES = ( + _green_certification_inputs_fixture, + _signing_key_fixture, +) def test_scope_exclusions_cover_every_non_national_gate(): @@ -465,7 +300,6 @@ def test_release_cut_battery_runs_and_signs(tmp_path: Path, monkeypatch): coverage_engine=object(), build_stage_names=("frs_spine",), ledger_registries={2023: object(), 2025: object()}, - local_ledger_registries={2025: object()}, parity_evidence=object(), fit_weight_records=None, input_mass_reference={}, diff --git a/packages/microcosm-build/tests/uk_certification_fixtures.py b/packages/microcosm-build/tests/uk_certification_fixtures.py new file mode 100644 index 0000000000..c2f810369b --- /dev/null +++ b/packages/microcosm-build/tests/uk_certification_fixtures.py @@ -0,0 +1,204 @@ +"""Shared signed green-certification fixtures for UK release tests.""" + +from __future__ import annotations + +import base64 +import hashlib +import json +from datetime import date +from pathlib import Path + +import pytest + +from microcosm.build.country_spec import load_country_spec +from microcosm.build.gate_battery import ( + BlockingMode, + EvidenceContext, + FunctionBinding, + GateBatteryRun, + gate_signing_key_env, +) +from microcosm.build.gates import GateResult +from microcosm.build.uk_runtime.calibration_run import ( + UK_CALIBRATION_GATE_SCOPE, + UK_NATIONAL_GATE_SCOPE, + UK_SPINE_GATE_SCOPE, + resign_uk_gate_report, + uk_scoped_gate_manifest, +) +from microcosm.build.uk_runtime.release_certification import ( + uk_release_cut_scope_exclusions, +) + +TEST_KEY = base64.b64encode(bytes(range(32))).decode("ascii") + + +@pytest.fixture(name="uk_certification_signing_key", autouse=True) +def signing_key(monkeypatch): + monkeypatch.setenv(gate_signing_key_env("uk"), TEST_KEY) + + +def stub_registry(): + """Return a registry that passes every declared gate over the real spec.""" + + spec = load_country_spec("uk").gates + parameter_keys: dict[str, set[str]] = {} + for entry in spec.gates: + parameter_keys.setdefault(entry.gate, set()).update(entry.parameters) + + def passing(name): + def gate(**_kwargs): + return GateResult(name=name, passed=True) + + return gate + + return { + gate: FunctionBinding( + name=gate, + gate=passing(gate), + parameter_keys=frozenset(keys), + ) + for gate, keys in parameter_keys.items() + } + + +def write_part( + path: Path, + scope, + phases, + *, + release_id, + release_candidate, + release_evidence=None, + augment=None, + block_phase=None, +): + registry = stub_registry() + manifest = uk_scoped_gate_manifest( + frozenset(scope), + phases=tuple(phases), + policy_suffix={ + frozenset(UK_SPINE_GATE_SCOPE): "spine_build_scope", + frozenset(UK_CALIBRATION_GATE_SCOPE): "calibration_seam_scope", + frozenset(UK_NATIONAL_GATE_SCOPE): "release_cut_scope", + }[frozenset(scope)], + ) + battery = GateBatteryRun( + manifest, + release_id=release_id, + report_path=path, + release_candidate=release_candidate, + registry=registry, + release_evidence=release_evidence or {}, + ) + for phase in phases: + battery.run_phase(phase, EvidenceContext(artifacts={})) + battery.enforce(phase, mode=BlockingMode.MARKS_ARTIFACT) + payload = battery.report_payload() + if augment: + payload.update(augment) + resign_uk_gate_report(payload) + path.write_text(json.dumps(payload, indent=2, sort_keys=True), encoding="utf-8") + return payload + + +def sha256(path: Path) -> str: + return hashlib.sha256(path.read_bytes()).hexdigest() + + +@pytest.fixture(name="green_certification_inputs") +def green_certification_inputs(tmp_path: Path): + """Three green signed parts plus a closed identity join.""" + + candidate = tmp_path / "microcosm_uk_2024.h5" + candidate.write_bytes(b"candidate-bytes") + candidate_sha = sha256(candidate) + diagnostics = tmp_path / "calibration_diagnostics.json" + diagnostics.write_text('{"targets": []}', encoding="utf-8") + diagnostics_sha = sha256(diagnostics) + + spine_report = tmp_path / "spine.spine_gates.json" + write_part( + spine_report, + UK_SPINE_GATE_SCOPE, + ("assembled", "transferred"), + release_id="uk-frs-spine-test", + release_candidate=True, + ) + seam_report = tmp_path / "terminal_gates.json" + write_part( + seam_report, + UK_CALIBRATION_GATE_SCOPE, + ("terminal",), + release_id="dev-seam-test", + release_candidate=False, + release_evidence={"calibration_diagnostics_sha256": diagnostics_sha}, + augment={ + "posture": "calibration_seam", + "scope_exclusions": {}, + "aggregate_admin_measurement": {}, + }, + ) + release_cut_report = tmp_path / "release_cut_gates.json" + write_part( + release_cut_report, + UK_NATIONAL_GATE_SCOPE, + ("preflight", "terminal"), + release_id="uk-757-first-certified-cut", + release_candidate=True, + release_evidence={"calibration_diagnostics_sha256": diagnostics_sha}, + augment={ + "posture": "release_cut", + "scope_exclusions": uk_release_cut_scope_exclusions(), + "aggregate_admin_measurement": {}, + }, + ) + seam_report_sha = sha256(seam_report) + sidecar = { + "stages": ["frs_spine"], + "spine_gate_report": { + "path": str(spine_report), + "sha256": sha256(spine_report), + }, + } + build_record = { + "run_config": { + "doctrine": {"epochs": 1500}, + "doctrine_overrides": {"epochs": {"default": 256, "effective": 1500}}, + }, + "spine_provenance": { + "spine_gate_report": {"sha256": sha256(spine_report)}, + }, + "artifacts": { + "staging_h5": {"sha256": candidate_sha}, + "diagnostics_json": {"sha256": diagnostics_sha}, + "terminal_gate_json": {"sha256": seam_report_sha}, + }, + } + score_receipt = tmp_path / "score_vs_enhanced_frs.json" + score_receipt.write_text( + json.dumps( + { + "artifacts": { + "candidate": {"sha256": candidate_sha, "size_bytes": 15}, + "incumbent": {"sha256": "9" * 64, "size_bytes": 1}, + }, + "candidate_target_wins": 293, + } + ), + encoding="utf-8", + ) + return { + "release_id": "uk-757-first-certified-cut", + "candidate_name": "microcosm_uk_2024", + "candidate_path": candidate, + "candidate_sha256": candidate_sha, + "spine_report_path": spine_report, + "seam_report_path": seam_report, + "release_cut_report_path": release_cut_report, + "spine_sidecar": sidecar, + "build_record": build_record, + "score_receipt_path": score_receipt, + "exclusions_evaluated_on": date(2026, 8, 27), + "certification_path": tmp_path / "release_certification.json", + } diff --git a/packages/microcosm-data/src/microcosm/data/contract.py b/packages/microcosm-data/src/microcosm/data/contract.py index 8c0ef39f28..ee5cf3af17 100644 --- a/packages/microcosm-data/src/microcosm/data/contract.py +++ b/packages/microcosm-data/src/microcosm/data/contract.py @@ -1097,7 +1097,13 @@ def _check_release_manifest( f"release_manifest.json artifact {key!r} is missing {field!r}." ) revision = entry.get("revision") - if isinstance(revision, str) and revision != release_id: + revision_matches_release = revision == release_id or ( + release_id == _UK_NATIONAL_RELEASE_ID + and isinstance(revision, str) + and revision.startswith(release_id + "-") + and len(revision) > len(release_id) + 1 + ) + if isinstance(revision, str) and not revision_matches_release: failures.append( f"release_manifest.json artifact {key!r} revision is " f"{revision!r}, expected the release id {release_id!r}." @@ -1425,6 +1431,8 @@ def _check_compatible_package_entries( def _expected_model_package(release_id: str) -> str | None: if release_id.startswith("populace-us-"): return "policyengine-us" + if release_id == _UK_NATIONAL_RELEASE_ID: + return "policyengine-uk" if release_id.startswith("populace-uk-"): return "policyengine-uk" return None @@ -4322,8 +4330,11 @@ def validate_release_dir(release_dir: Path | str) -> None: failures, grandfathered_uk_june=release_id == _UK_JUNE_RELEASE_ID, ) - if _is_uk_exact_k_release_id(release_id): + if _is_uk_exact_k_release_id( + release_id + ) or release_id == _UK_NATIONAL_RELEASE_ID: _check_uk_calibration_diagnostics(diagnostics, failures) + if _is_uk_exact_k_release_id(release_id): _check_uk_exact_k_diagnostics_identity( diagnostics, release_id, failures ) diff --git a/packages/microcosm-data/src/microcosm/data/release.py b/packages/microcosm-data/src/microcosm/data/release.py index de1dc2f99c..dac11945a5 100644 --- a/packages/microcosm-data/src/microcosm/data/release.py +++ b/packages/microcosm-data/src/microcosm/data/release.py @@ -324,16 +324,16 @@ def publish_release( ) artifact_revisions = _release_manifest_artifact_revisions(release_dir) tag = tag_name or release_id - if release_id in artifact_revisions and not create_tag: + if artifact_revisions and not create_tag: raise ValueError( - "release_manifest.json pins artifacts to the release id; " + "release_manifest.json pins artifacts to revisions; " "publish_release must create the matching Hugging Face tag before " "updating latest.json." ) - if tag != release_id and release_id in artifact_revisions: + if artifact_revisions and artifact_revisions != {tag}: raise ValueError( - "release_manifest.json pins artifacts to the release id; tag_name " - "must match the release id." + "release_manifest.json pins artifacts to revisions; tag_name must " + "match the release id or uniform per-cut artifact revision." ) if root_artifacts and artifact_root is None: raise ValueError( diff --git a/packages/microcosm-data/tests/test_contract.py b/packages/microcosm-data/tests/test_contract.py index 7badf0575e..efeaf8c312 100644 --- a/packages/microcosm-data/tests/test_contract.py +++ b/packages/microcosm-data/tests/test_contract.py @@ -31,6 +31,8 @@ RELEASE_ID = "populace-us-2024-9f1260b-20260611" UK_RELEASE_ID = "populace-uk-2023-dd68c73-4aa4b14-20260619T023711Z" UK_EXACT_K_RELEASE_ID = "populace-uk-2023-frs-k535080" +UK_NATIONAL_RELEASE_ID = "microcosm-uk-2024-25-national" +UK_NATIONAL_CUT_TAG = f"{UK_NATIONAL_RELEASE_ID}-20260828T101112Z-1a2b3c4d" UK_RECORD_COUNT = 535_080 UK_TERMINAL_GATE_REPORT_FILE = "terminal_gates.json" UK_TERMINAL_GATE_PRODUCER = ( @@ -312,7 +314,7 @@ def _trusted_gate_battery_signing_key(monkeypatch) -> None: def _model_package(release_id: str) -> tuple[str, str]: - if release_id.startswith("populace-uk-"): + if release_id.startswith("populace-uk-") or release_id == UK_NATIONAL_RELEASE_ID: return ("policyengine-uk", "2.89.0") return ("policyengine-us", "1.729.0") @@ -1415,6 +1417,42 @@ def _copy_real_uk_june_release(tmp_path: Path) -> Path: return directory +def _write_uk_national_release_dir(tmp_path: Path) -> Path: + """Build the constant-id national fixture from the green exact-k shape.""" + + directory = _write_uk_release_dir(tmp_path, UK_EXACT_K_RELEASE_ID, tier="frs") + national = directory.with_name(UK_NATIONAL_RELEASE_ID) + directory.replace(national) + + build_path = national / "build_manifest.json" + build = json.loads(build_path.read_text()) + build["build_id"] = UK_NATIONAL_RELEASE_ID + build_path.write_text(json.dumps(build)) + + release_path = national / "release_manifest.json" + release = json.loads(release_path.read_text()) + release["build"]["build_id"] = UK_NATIONAL_RELEASE_ID + for artifact in release["artifacts"].values(): + artifact["revision"] = UK_NATIONAL_CUT_TAG + + certification = _green_uk_certification( + TEST_UK_TERMINAL_GATE_SIGNING_KEY_BYTES, + release_id=UK_NATIONAL_RELEASE_ID, + diagnostics_sha256=_sha256(national / "calibration_diagnostics.json"), + ) + certification_path = national / "release_certification.json" + certification_path.write_text(json.dumps(certification)) + release["artifacts"]["release_certification"] = { + "kind": "diagnostics", + "path": "release_certification.json", + "repo_id": "policyengine/populace-uk-private", + "revision": UK_NATIONAL_CUT_TAG, + "sha256": _sha256(certification_path), + } + release_path.write_text(json.dumps(release)) + return national + + def _rewrite_exact_k_fixture_to_two_records(directory: Path) -> None: """Reproduce Sol's internally consistent k535080/n_records=2 probe.""" @@ -2035,6 +2073,78 @@ def test_real_june_release_validates_with_legacy_schema_and_selector_shapes( ) +def test_uk_national_release_dir_validates(tmp_path: Path) -> None: + validate_release_dir(_write_uk_national_release_dir(tmp_path)) + + +def test_uk_national_release_requires_uk_diagnostics(tmp_path: Path) -> None: + directory = _write_uk_national_release_dir(tmp_path) + diagnostics_path = directory / "calibration_diagnostics.json" + diagnostics = json.loads(diagnostics_path.read_text()) + diagnostics.pop("uk_diagnostics") + diagnostics_path.write_text(json.dumps(diagnostics)) + + with pytest.raises(ReleaseContractError, match="require a 'uk_diagnostics'"): + validate_release_dir(directory) + + +def test_uk_national_release_requires_policyengine_uk_runtime( + tmp_path: Path, +) -> None: + directory = _write_uk_national_release_dir(tmp_path) + build_path = directory / "build_manifest.json" + build = json.loads(build_path.read_text()) + build["runtime"].pop("policyengine-uk") + build_path.write_text(json.dumps(build)) + + with pytest.raises(ReleaseContractError, match="runtime.policyengine-uk"): + validate_release_dir(directory) + + +def test_uk_national_release_requires_policyengine_uk_model_pin( + tmp_path: Path, +) -> None: + directory = _write_uk_national_release_dir(tmp_path) + release_path = directory / "release_manifest.json" + release = json.loads(release_path.read_text()) + release["build"]["built_with_model_package"]["name"] = "policyengine-us" + release_path.write_text(json.dumps(release)) + + with pytest.raises( + ReleaseContractError, + match="built_with_model_package.name.*policyengine-uk", + ): + validate_release_dir(directory) + + +@pytest.mark.parametrize("revision", ["main", UK_NATIONAL_RELEASE_ID + "-"]) +def test_uk_national_release_rejects_invalid_artifact_revisions( + tmp_path: Path, + revision: str, +) -> None: + directory = _write_uk_national_release_dir(tmp_path) + release_path = directory / "release_manifest.json" + release = json.loads(release_path.read_text()) + for artifact in release["artifacts"].values(): + artifact["revision"] = revision + release_path.write_text(json.dumps(release)) + + with pytest.raises(ReleaseContractError, match="revision"): + validate_release_dir(directory) + + +def test_us_release_rejects_dash_suffixed_artifact_revision( + release_dir: Path, +) -> None: + release_path = release_dir / "release_manifest.json" + release = json.loads(release_path.read_text()) + release["artifacts"]["populace_us_2024"]["revision"] = RELEASE_ID + "-cut" + release_path.write_text(json.dumps(release)) + + with pytest.raises(ReleaseContractError, match="revision"): + validate_release_dir(release_dir) + + @pytest.mark.parametrize( ("tier", "accepted"), [(None, True), ("frs", True), ("cps-transfer", False)], @@ -4950,13 +5060,18 @@ def test_breach_acknowledgment_matching_is_name_delimited() -> None: # --- UK release certification (microcosm#757 B5) --------------------------- -def _green_uk_certification(key: bytes) -> dict: +def _green_uk_certification( + key: bytes, + *, + release_id: str = "uk-757-first-certified-cut", + diagnostics_sha256: str = "c" * 64, +) -> dict: parts = {} for part_name, scope in contract._UK_CERTIFICATION_PART_SCOPES.items(): parts[part_name] = { "path": f"{part_name}.json", "sha256": "a" * 64, - "release_id": "uk-757-first-certified-cut", + "release_id": release_id, "phases": list(contract._UK_CERTIFICATION_PART_PHASES[part_name]), "entry_ids": sorted(scope), "gates_manifest_sha256": contract._UK_CERTIFICATION_PART_DIGESTS[part_name][ @@ -4971,7 +5086,7 @@ def _green_uk_certification(key: bytes) -> dict: "schema_version": 1, "kind": "uk_release_certification", "country": "uk", - "release_id": "uk-757-first-certified-cut", + "release_id": release_id, "candidate": { "name": "microcosm_uk_2024", "filename": "microcosm_uk_2024.h5", @@ -4988,7 +5103,7 @@ def _green_uk_certification(key: bytes) -> dict: "shared_gate_ids": sorted(contract._UK_CERTIFICATION_SHARED_GATE_IDS), }, "doctrine": {"payload": {"epochs": 1500}, "overrides": {}}, - "diagnostics_sha256": "c" * 64, + "diagnostics_sha256": diagnostics_sha256, "score_receipt": {"filename": "score_vs_enhanced_frs.json", "sha256": "d" * 64}, "exclusions_evaluated_on": "2026-08-27", "shippable": True, diff --git a/packages/microcosm-data/tests/test_release.py b/packages/microcosm-data/tests/test_release.py index 017ab5d184..59f1379c58 100644 --- a/packages/microcosm-data/tests/test_release.py +++ b/packages/microcosm-data/tests/test_release.py @@ -15,6 +15,7 @@ import pytest from microcosm.data import ReleaseContractError +from microcosm.data import release as release_module from microcosm.data.contract import ( EVIDENCE_RELEASE_MANIFEST_SCHEMA_VERSION, US_SOURCE_COVERAGE_DIAGNOSTICS_FILE, @@ -1078,7 +1079,7 @@ def test_release_tag_is_created_before_pointer( def test_release_id_artifact_revision_requires_release_tag( hub: FakeHub, release_dir: Path, artifact_root: Path ) -> None: - with pytest.raises(ValueError, match="must create the matching Hugging Face tag"): + with pytest.raises(ValueError, match="pins artifacts to revisions.*must create"): publish_release( release_dir, "policyengine/populace-us", @@ -1093,7 +1094,7 @@ def test_release_id_artifact_revision_requires_release_tag( def test_release_id_artifact_revision_rejects_tag_name_override( hub: FakeHub, release_dir: Path, artifact_root: Path ) -> None: - with pytest.raises(ValueError, match="tag_name must match the release id"): + with pytest.raises(ValueError, match="tag_name must match.*artifact revision"): publish_release( release_dir, "policyengine/populace-us", @@ -1105,6 +1106,58 @@ def test_release_id_artifact_revision_rejects_tag_name_override( assert hub.tags == [] +def test_per_cut_artifact_revision_publishes_matching_tag_without_latest( + hub: FakeHub, + release_dir: Path, + artifact_root: Path, + monkeypatch, +) -> None: + cut_tag = RELEASE_ID + "-20260828T101112Z-1a2b3c4d" + manifest_path = release_dir / "release_manifest.json" + manifest = json.loads(manifest_path.read_text()) + for artifact in manifest["artifacts"].values(): + artifact["revision"] = cut_tag + manifest_path.write_text(json.dumps(manifest)) + monkeypatch.setattr(release_module, "validate_release_dir", lambda _path: None) + + publish_release( + release_dir, + "policyengine/populace-us", + api=hub, + artifact_root=artifact_root, + tag_name=cut_tag, + update_latest=False, + ) + + assert hub.tags == [{"tag": cut_tag, "revision": "commit-1"}] + assert all(path != LATEST_POINTER_PATH for path, _payload in hub.uploads) + + +def test_per_cut_artifact_revision_refuses_dangling_default_tag( + hub: FakeHub, + release_dir: Path, + artifact_root: Path, + monkeypatch, +) -> None: + cut_tag = RELEASE_ID + "-20260828T101112Z-1a2b3c4d" + manifest_path = release_dir / "release_manifest.json" + manifest = json.loads(manifest_path.read_text()) + for artifact in manifest["artifacts"].values(): + artifact["revision"] = cut_tag + manifest_path.write_text(json.dumps(manifest)) + monkeypatch.setattr(release_module, "validate_release_dir", lambda _path: None) + + with pytest.raises(ValueError, match="uniform per-cut artifact revision"): + publish_release( + release_dir, + "policyengine/populace-us", + api=hub, + artifact_root=artifact_root, + ) + assert hub.uploads == [] + assert hub.tags == [] + + def test_invalid_release_uploads_nothing(hub: FakeHub, release_dir: Path) -> None: (release_dir / "build_manifest.json").unlink() with pytest.raises(ReleaseContractError): diff --git a/tools/assemble_uk_release_dir.py b/tools/assemble_uk_release_dir.py new file mode 100644 index 0000000000..3c71ed413d --- /dev/null +++ b/tools/assemble_uk_release_dir.py @@ -0,0 +1,565 @@ +"""Assemble a certified UK national candidate into a publishable release. + +This derivative packaging step closes the certification/build-record identity +join, mints the calibration weight bundle, writes the two release manifests, +copies the signed evidence byte-for-byte, and validates the finished release +directory before printing the inspect-lane publication command. +""" + +from __future__ import annotations + +import argparse +import hashlib +import json +import platform +import re +import shutil +from collections.abc import Mapping +from datetime import UTC, datetime +from importlib.metadata import PackageNotFoundError, version +from pathlib import Path + +import numpy as np + +from microcosm.build.uk_runtime.national_frame import load_uk_national_frame +from microcosm.build.uk_runtime.release_identity import UK_NATIONAL_RELEASE_ID +from microcosm.data.contract import validate_release_dir + +_ATTEMPT_PREFIX = "uk-frs-calibration-attempt-" +_ATTEMPT_SUFFIX = re.compile(r"(?P\d{8}T\d{6}Z)-(?P[0-9a-f]{8})") +_GIT_COMMIT = re.compile(r"[0-9a-f]{40}") +_REPO_ID = "policyengine/populace-uk-private" +_RUNTIME_PACKAGES = ( + "python", + "policyengine-core", + "policyengine-uk", + "microcosm-data", + "microcosm-build", + "microcosm-calibrate", +) + + +def main(argv: list[str] | None = None) -> int: + args = _parse_args(argv) + summary = _assemble(args) + print(json.dumps(summary, indent=2, sort_keys=True)) + return 0 + + +def _assemble(args: argparse.Namespace) -> dict[str, object]: + certification_bytes = args.certification_json.read_bytes() + certification = _load_json_bytes( + certification_bytes, label="--certification-json" + ) + build_record = _load_json(args.build_record_json, label="--build-record-json") + diagnostics_bytes = args.diagnostics_json.read_bytes() + diagnostics = _load_json_bytes(diagnostics_bytes, label="--diagnostics-json") + + spine_report_path = args.spine_h5.with_suffix(".spine_gates.json") + inputs = { + "candidate": args.candidate_h5, + "certification": args.certification_json, + "diagnostics": args.diagnostics_json, + "calibration seam report": args.seam_gate_report, + "release-cut report": args.release_cut_gate_json, + "spine report": spine_report_path, + "score receipt": args.score_receipt, + "spine H5": args.spine_h5, + } + measured = {name: _sha256(path) for name, path in inputs.items()} + + candidate = _mapping(certification.get("candidate"), "certification.candidate") + artifacts = _mapping(build_record.get("artifacts"), "build_record.artifacts") + staging_h5 = _mapping( + artifacts.get("staging_h5"), "build_record.artifacts.staging_h5" + ) + diagnostics_artifact = _mapping( + artifacts.get("diagnostics_json"), + "build_record.artifacts.diagnostics_json", + ) + terminal_artifact = _mapping( + artifacts.get("terminal_gate_json"), + "build_record.artifacts.terminal_gate_json", + ) + parts = _mapping(certification.get("parts"), "certification.parts") + spine_part = _mapping(parts.get("spine"), "certification.parts.spine") + seam_part = _mapping( + parts.get("calibration_seam"), "certification.parts.calibration_seam" + ) + release_cut_part = _mapping( + parts.get("release_cut"), "certification.parts.release_cut" + ) + score_binding = _mapping( + certification.get("score_receipt"), "certification.score_receipt" + ) + input_posture = _mapping( + build_record.get("input_posture"), "build_record.input_posture" + ) + + _require_equal( + "candidate bytes vs certification.candidate.sha256", + measured["candidate"], + candidate.get("sha256"), + ) + _require_equal( + "candidate bytes vs build_record.artifacts.staging_h5.sha256", + measured["candidate"], + staging_h5.get("sha256"), + ) + _require_equal( + "diagnostics bytes vs certification.diagnostics_sha256", + measured["diagnostics"], + certification.get("diagnostics_sha256"), + ) + _require_equal( + "diagnostics bytes vs build_record.artifacts.diagnostics_json.sha256", + measured["diagnostics"], + diagnostics_artifact.get("sha256"), + ) + _require_equal( + "calibration seam report vs build_record.artifacts.terminal_gate_json.sha256", + measured["calibration seam report"], + terminal_artifact.get("sha256"), + ) + _require_equal( + "calibration seam report vs certification.parts.calibration_seam.sha256", + measured["calibration seam report"], + seam_part.get("sha256"), + ) + _require_equal( + "release-cut report vs certification.parts.release_cut.sha256", + measured["release-cut report"], + release_cut_part.get("sha256"), + ) + _require_equal( + "spine report vs certification.parts.spine.sha256", + measured["spine report"], + spine_part.get("sha256"), + ) + _require_equal( + "score receipt vs certification.score_receipt.sha256", + measured["score receipt"], + score_binding.get("sha256"), + ) + _require_equal( + "spine H5 vs build_record.input_posture.sha256", + measured["spine H5"], + input_posture.get("sha256"), + ) + _require_equal( + "certification.release_id", + certification.get("release_id"), + UK_NATIONAL_RELEASE_ID, + ) + if certification.get("shippable") is not True: + raise SystemExit( + "error: certification.shippable must be true before release assembly" + ) + + attempt_id = build_record.get("build_id") + if not isinstance(attempt_id, str): + raise SystemExit("error: build_record.build_id must be a string") + cut_tag = _cut_tag(attempt_id, args.cut_tag) + runtime = _runtime_versions(build_record, dict(args.runtime_version)) + code_pin = _diagnostics_code_pin(diagnostics) + candidate_filename = candidate.get("filename") + if not isinstance(candidate_filename, str) or not candidate_filename: + raise SystemExit("error: certification.candidate.filename must be non-empty") + dataset_key = Path(candidate_filename).stem + calibration_filename = f"{dataset_key}_calibration.npz" + calibration_path = args.candidate_h5.parent / calibration_filename + target_surface = _mapping( + diagnostics.get("target_surface"), "diagnostics.target_surface" + ) + target_registry = _mapping( + diagnostics.get("target_registry"), "diagnostics.target_registry" + ) + candidate_frame, _candidate_provenance = load_uk_national_frame( + args.candidate_h5 + ) + spine_frame, _spine_provenance = load_uk_national_frame(args.spine_h5) + household_weight = np.asarray( + candidate_frame.weights_for("household").values, dtype=np.float64 + ) + initial_household_weight = np.asarray( + spine_frame.weights_for("household").values, dtype=np.float64 + ) + + np.savez( + calibration_path, + household_weight=household_weight, + initial_household_weight=initial_household_weight, + ) + calibration_sha = _sha256(calibration_path) + + created_at = datetime.now(UTC).isoformat() + release_dir = args.out_dir / UK_NATIONAL_RELEASE_ID + release_dir.mkdir(parents=True, exist_ok=True) + build_manifest = { + "build_id": UK_NATIONAL_RELEASE_ID, + "code": { + "repository": "PolicyEngine/microcosm", + "git_commit": code_pin, + "git_dirty": False, + }, + "build_sha": code_pin[:7], + "runtime": runtime, + "dataset": { + "filename": candidate_filename, + "sha256": measured["candidate"], + }, + "calibration": { + "filename": calibration_filename, + "sha256": calibration_sha, + "target_surface": { + "sha256": target_surface.get("sha256"), + "n_targets": target_surface.get("n_targets"), + }, + "target_registry": { + "version": target_registry.get("version"), + "n_specs": target_registry.get("n_specs"), + }, + }, + "gates": { + "spine": spine_part.get("statuses"), + "calibration_seam": seam_part.get("statuses"), + "release_cut": release_cut_part.get("statuses"), + "gate_summary": build_record.get("gate_summary"), + }, + "attempt_id": attempt_id, + "cut_tag": cut_tag, + "created_at": created_at, + } + build_manifest_path = release_dir / "build_manifest.json" + _write_json(build_manifest_path, build_manifest) + + evidence = { + "calibration_diagnostics": ( + args.diagnostics_json, + "calibration_diagnostics.json", + ), + "release_certification": ( + args.certification_json, + "release_certification.json", + ), + "terminal_gates": (args.seam_gate_report, "terminal_gates.json"), + "release_cut_gates": ( + args.release_cut_gate_json, + "release_cut_gates.json", + ), + "spine_gates": (spine_report_path, "spine_gates.json"), + "score_vs_enhanced_frs": ( + args.score_receipt, + "score_vs_enhanced_frs.json", + ), + } + + def artifact(kind: str, path: str, sha256: str) -> dict[str, str]: + return { + "kind": kind, + "path": path, + "repo_id": _REPO_ID, + "revision": cut_tag, + "sha256": sha256, + } + + release_manifest = { + "schema_version": 1, + "data_package": { + "name": "microcosm-data", + "version": runtime["microcosm-data"], + }, + "default_datasets": {"national": dataset_key}, + "build": { + "build_id": UK_NATIONAL_RELEASE_ID, + "built_at": created_at, + "built_with_core_package": { + "name": "policyengine-core", + "version": runtime["policyengine-core"], + }, + "built_with_model_package": { + "name": "policyengine-uk", + "version": runtime["policyengine-uk"], + }, + "attempt_id": attempt_id, + "cut_tag": cut_tag, + }, + "compatible_core_packages": [ + { + "name": "policyengine-core", + "specifier": f"=={runtime['policyengine-core']}", + } + ], + "compatible_model_packages": [ + { + "name": "policyengine-uk", + "specifier": f"=={runtime['policyengine-uk']}", + } + ], + "artifacts": { + dataset_key: artifact( + "microdata", candidate_filename, measured["candidate"] + ), + f"{dataset_key}_calibration": artifact( + "calibration", calibration_filename, calibration_sha + ), + **{ + key: artifact("diagnostics", filename, measured[name]) + for key, (_source, filename), name in ( + ( + "calibration_diagnostics", + evidence["calibration_diagnostics"], + "diagnostics", + ), + ( + "release_certification", + evidence["release_certification"], + "certification", + ), + ( + "terminal_gates", + evidence["terminal_gates"], + "calibration seam report", + ), + ( + "release_cut_gates", + evidence["release_cut_gates"], + "release-cut report", + ), + ( + "spine_gates", + evidence["spine_gates"], + "spine report", + ), + ( + "score_vs_enhanced_frs", + evidence["score_vs_enhanced_frs"], + "score receipt", + ), + ) + }, + }, + "description": ( + "UK national calibration pipeline release assembled from attempt " + f"{attempt_id} at immutable cut {cut_tag}." + ), + "publisher_labels": { + "obr": "Office for Budget Responsibility", + "hmrc": "HM Revenue and Customs", + "ons": "Office for National Statistics", + "dwp": "Department for Work and Pensions", + }, + } + release_manifest_path = release_dir / "release_manifest.json" + _write_json(release_manifest_path, release_manifest) + + evidence_summary: dict[str, dict[str, str]] = {} + for key, (source, filename) in evidence.items(): + destination = release_dir / filename + shutil.copyfile(source, destination) + evidence_summary[key] = { + "path": str(destination), + "sha256": _sha256(destination), + } + + validate_release_dir(release_dir) + publish_command = ( + f"uv run python -m microcosm.data.publish_cli {release_dir} " + f"--repo-id {_REPO_ID} --artifact-root {args.candidate_h5.parent} " + f"--no-latest --tag-name {cut_tag}" + ) + return { + "release_id": UK_NATIONAL_RELEASE_ID, + "release_dir": str(release_dir), + "cut_tag": cut_tag, + "dataset": { + "path": str(args.candidate_h5), + "sha256": measured["candidate"], + }, + "calibration": { + "path": str(calibration_path), + "sha256": calibration_sha, + }, + "build_manifest": { + "path": str(build_manifest_path), + "sha256": _sha256(build_manifest_path), + }, + "release_manifest": { + "path": str(release_manifest_path), + "sha256": _sha256(release_manifest_path), + }, + "evidence": evidence_summary, + "publish_command": publish_command, + } + + +def _parse_args(argv: list[str] | None) -> argparse.Namespace: + parser = argparse.ArgumentParser(description=__doc__) + parser.add_argument("--candidate-h5", required=True, type=Path) + parser.add_argument("--spine-h5", required=True, type=Path) + parser.add_argument("--certification-json", type=Path) + parser.add_argument("--build-record-json", required=True, type=Path) + parser.add_argument("--diagnostics-json", required=True, type=Path) + parser.add_argument("--seam-gate-report", required=True, type=Path) + parser.add_argument("--release-cut-gate-json", type=Path) + parser.add_argument("--score-receipt", required=True, type=Path) + parser.add_argument("--out-dir", type=Path, default=Path("releases")) + parser.add_argument("--cut-tag") + parser.add_argument( + "--runtime-version", + action="append", + default=[], + type=_runtime_version, + metavar="PACKAGE=VERSION", + ) + args = parser.parse_args(argv) + args.certification_json = ( + args.certification_json + or args.candidate_h5.with_suffix(".release_certification.json") + ) + args.release_cut_gate_json = ( + args.release_cut_gate_json + or args.candidate_h5.with_suffix(".release_cut_gates.json") + ) + distinct = { + "--candidate-h5": args.candidate_h5, + "--spine-h5": args.spine_h5, + "--certification-json": args.certification_json, + "--build-record-json": args.build_record_json, + "--diagnostics-json": args.diagnostics_json, + "--seam-gate-report": args.seam_gate_report, + "--release-cut-gate-json": args.release_cut_gate_json, + "--score-receipt": args.score_receipt, + } + resolved: dict[Path, str] = {} + for flag, path in distinct.items(): + canonical = path.resolve() + if canonical in resolved: + parser.error(f"{flag} aliases {resolved[canonical]}: {path}") + resolved[canonical] = flag + return args + + +def _runtime_version(value: str) -> tuple[str, str]: + package, separator, package_version = value.partition("=") + if not separator or package not in _RUNTIME_PACKAGES or not package_version: + raise argparse.ArgumentTypeError( + "expected PACKAGE=VERSION for one of " + ", ".join(_RUNTIME_PACKAGES) + ) + return package, package_version + + +def _runtime_versions( + build_record: Mapping[str, object], overrides: dict[str, str] +) -> dict[str, str]: + spine_provenance = _mapping( + build_record.get("spine_provenance"), "build_record.spine_provenance" + ) + rules_engine = _mapping( + spine_provenance.get("rules_engine"), + "build_record.spine_provenance.rules_engine", + ) + policyengine_uk = overrides.get("policyengine-uk") or rules_engine.get("version") + if not isinstance(policyengine_uk, str) or not policyengine_uk: + raise SystemExit( + "error: build_record.spine_provenance.rules_engine.version is required" + ) + runtime = { + "python": overrides.get("python", platform.python_version()), + "policyengine-core": _distribution_version("policyengine-core", overrides), + "policyengine-uk": policyengine_uk, + "microcosm-data": _distribution_version("microcosm-data", overrides), + "microcosm-build": _distribution_version("microcosm-build", overrides), + "microcosm-calibrate": _distribution_version( + "microcosm-calibrate", overrides + ), + } + return runtime + + +def _distribution_version(package: str, overrides: Mapping[str, str]) -> str: + if package in overrides: + return overrides[package] + try: + return version(package) + except PackageNotFoundError as error: + raise SystemExit( + f"error: runtime version for {package} is unavailable; pass " + f"--runtime-version {package}=VERSION" + ) from error + + +def _diagnostics_code_pin(diagnostics: Mapping[str, object]) -> str: + build = _mapping(diagnostics.get("build"), "diagnostics.build") + code_pin = build.get("code_pin") + if not isinstance(code_pin, str) or not _GIT_COMMIT.fullmatch(code_pin): + raise SystemExit( + "error: diagnostics.build.code_pin must be a 40-character lowercase " + "git commit" + ) + return code_pin + + +def _cut_tag(attempt_id: str, override: str | None) -> str: + prefix = UK_NATIONAL_RELEASE_ID + "-" + if override is not None: + if not override.startswith(prefix) or len(override) == len(prefix): + raise SystemExit( + f"error: --cut-tag must start with {prefix!r} and include a suffix" + ) + return override + if not attempt_id.startswith(_ATTEMPT_PREFIX): + raise SystemExit( + f"error: build_record.build_id must start with {_ATTEMPT_PREFIX!r}" + ) + suffix = attempt_id.removeprefix(_ATTEMPT_PREFIX) + if not _ATTEMPT_SUFFIX.fullmatch(suffix): + raise SystemExit( + "error: build_record.build_id must end with " + "-" + ) + return f"{UK_NATIONAL_RELEASE_ID}-{suffix}" + + +def _load_json(path: Path, *, label: str) -> Mapping[str, object]: + return _load_json_bytes(path.read_bytes(), label=label) + + +def _load_json_bytes(raw: bytes, *, label: str) -> Mapping[str, object]: + try: + payload = json.loads(raw) + except (UnicodeDecodeError, json.JSONDecodeError) as error: + raise SystemExit(f"error: {label} is not valid JSON: {error}") from error + return _mapping(payload, label) + + +def _mapping(value: object, label: str) -> Mapping[str, object]: + if not isinstance(value, Mapping): + raise SystemExit(f"error: {label} must be a JSON object") + return value + + +def _require_equal(label: str, actual: object, expected: object) -> None: + if actual != expected: + raise SystemExit(f"error: {label} mismatch: {actual!r} != {expected!r}") + + +def _sha256(path: Path) -> str: + digest = hashlib.sha256() + with path.open("rb") as stream: + for chunk in iter(lambda: stream.read(1 << 20), b""): + digest.update(chunk) + return digest.hexdigest() + + +def _write_json(path: Path, payload: Mapping[str, object]) -> None: + path.parent.mkdir(parents=True, exist_ok=True) + temporary = path.with_name(path.name + ".tmp") + temporary.write_text( + json.dumps(payload, indent=1, allow_nan=False), encoding="utf-8" + ) + temporary.replace(path) + + +if __name__ == "__main__": + raise SystemExit(main()) From 2bb9518231376f34b29366e3fbb18f8421da66ac Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Mar=C3=ADa=20Juaristi?= <127882282+juaristi22@users.noreply.github.com> Date: Fri, 28 Aug 2026 16:47:49 +0200 Subject: [PATCH 2/7] Skip the assembler tests where the pandas HDF backend is absent The wheels lane runs the suite from installed wheels in a clean venv without pytables, and the assembler fixture writes UK national frames through pandas HDF. Guard the fixture with the same importorskip the UK national-frame tests use, so the wheels battery skips instead of erroring six times. Co-Authored-By: Claude Fable 5 --- packages/microcosm-build/tests/test_uk_release_assembler.py | 2 ++ 1 file changed, 2 insertions(+) diff --git a/packages/microcosm-build/tests/test_uk_release_assembler.py b/packages/microcosm-build/tests/test_uk_release_assembler.py index b7f8cbd27d..59581c1c6a 100644 --- a/packages/microcosm-build/tests/test_uk_release_assembler.py +++ b/packages/microcosm-build/tests/test_uk_release_assembler.py @@ -167,6 +167,8 @@ def _diagnostics() -> dict: @pytest.fixture def assembler_inputs(green_certification_inputs, tmp_path: Path): + pytest.importorskip("tables") # pandas HDF backend + pytest.importorskip("h5py") candidate = green_certification_inputs["candidate_path"] spine = tmp_path / "spine.h5" write_uk_national_frame( From e34c4bd2baeb2ea0bf0ac2ef547fad4b3796aa70 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Mar=C3=ADa=20Juaristi?= <127882282+juaristi22@users.noreply.github.com> Date: Fri, 28 Aug 2026 19:19:04 +0200 Subject: [PATCH 3/7] Close the review round: fail the publication gate closed, pin the pairing Vahid's #812 review, findings 2-4 (finding 1 is answered on the PR: the id-keyed required-files rule landed in #793 and refuses both omissions by name). A present-but-non-string artifact revision now fails the contract instead of sliding past the isinstance guard, and the publish guards no longer go vacuous on an empty revision set: unreadable pins refuse naming the artifacts, an empty set refuses outright, and the create-tag and uniformity requirements apply unconditionally. The national per-cut revision suffix is validated against the same - grammar the assembler mints, on both sides: the contract rejects hand-edited suffixes and the --cut-tag override may re-tag a cut but never leave the grammar. The assembler refuses to write the calibration NPZ when the candidate and spine household axes are misaligned - equal ids in equal order, checked before the pairing is digested. A digest over mis-paired bytes authenticates the bytes, not the pairing. Co-Authored-By: Claude Fable 5 --- ...k-national-release-assembly-runbook-806.md | 5 +- .../tests/test_uk_release_assembler.py | 62 ++++++++++++++++++- .../src/microcosm/data/contract.py | 28 +++++++-- .../src/microcosm/data/release.py | 47 +++++++++++--- .../microcosm-data/tests/test_contract.py | 17 ++++- packages/microcosm-data/tests/test_release.py | 50 +++++++++++++++ tools/assemble_uk_release_dir.py | 28 ++++++++- 7 files changed, 218 insertions(+), 19 deletions(-) diff --git a/docs/uk-national-release-assembly-runbook-806.md b/docs/uk-national-release-assembly-runbook-806.md index 9fc72d7e74..97c2c542f7 100644 --- a/docs/uk-national-release-assembly-runbook-806.md +++ b/docs/uk-national-release-assembly-runbook-806.md @@ -100,8 +100,9 @@ uv run --no-sync python tools/assemble_uk_release_dir.py \ The output is `releases/microcosm-uk-2024-25-national/`. The JSON summary records every digest, the derived cut tag, and the exact publication command. Use -`--cut-tag microcosm-uk-2024-25-national-` only to override the derived -tag deliberately. `--runtime-version PACKAGE=VERSION` is available when a +`--cut-tag microcosm-uk-2024-25-national--` only to +override the derived tag deliberately; the override must keep that grammar, +which the contract validates on every artifact revision. `--runtime-version PACKAGE=VERSION` is available when a package's runtime version cannot be resolved from the assembly environment. ## 4. Publish for inspection diff --git a/packages/microcosm-build/tests/test_uk_release_assembler.py b/packages/microcosm-build/tests/test_uk_release_assembler.py index 59581c1c6a..14a592e810 100644 --- a/packages/microcosm-build/tests/test_uk_release_assembler.py +++ b/packages/microcosm-build/tests/test_uk_release_assembler.py @@ -332,12 +332,72 @@ def test_assemble_refuses_missing_score_receipt(assembler_inputs) -> None: def test_assemble_refuses_unprefixed_cut_tag(assembler_inputs) -> None: - with pytest.raises(SystemExit, match="must start with"): + with pytest.raises(SystemExit, match="--cut-tag must be"): _load_driver_module().main( [*assembler_inputs["argv"], "--cut-tag", "not-a-national-cut"] ) +def _rewrite_spine(assembler_inputs, frame) -> None: + """Replace the spine H5 and re-pin its digest in the build record.""" + write_uk_national_frame(frame, assembler_inputs["spine"]) + record_path = assembler_inputs["diagnostics"].parent / "build_record.json" + record = json.loads(record_path.read_text(encoding="utf-8")) + record["input_posture"]["sha256"] = sha256(assembler_inputs["spine"]) + record_path.write_text(json.dumps(record), encoding="utf-8") + + +def test_assemble_refuses_spine_with_different_household_count( + assembler_inputs, +) -> None: + _rewrite_spine( + assembler_inputs, + _frame([1.0, 2.0, 3.0], weight_kind=WeightKind.DESIGN), + ) + with pytest.raises(SystemExit, match="misaligned.*2 vs 3 household rows"): + _load_driver_module().main(assembler_inputs["argv"]) + + +def test_assemble_refuses_spine_with_different_household_ids( + assembler_inputs, +) -> None: + ids = np.asarray([5, 6], dtype="int64") + _rewrite_spine( + assembler_inputs, + uk_national_frame( + person=pd.DataFrame( + { + "person_id": ids, + "person_benunit_id": ids, + "person_household_id": ids, + } + ), + benunit=pd.DataFrame({"benunit_id": ids}), + household=pd.DataFrame( + { + "household_id": ids, + "household_weight": np.asarray([1.0, 2.0], dtype="float64"), + } + ), + time_period="2024", + weight_kind=WeightKind.DESIGN, + ), + ) + with pytest.raises(SystemExit, match="misaligned.*household ids differ"): + _load_driver_module().main(assembler_inputs["argv"]) + + +def test_assemble_refuses_cut_tag_outside_the_grammar(assembler_inputs) -> None: + with pytest.raises(SystemExit, match="YYYYMMDDTHHMMSSZ"): + _load_driver_module().main( + [ + *assembler_inputs["argv"], + "--cut-tag", + f"{UK_NATIONAL_RELEASE_ID}-hotfix", + ] + ) + + def test_assemble_refuses_reserialized_diagnostics(assembler_inputs) -> None: diagnostics = json.loads(assembler_inputs["diagnostics"].read_text()) assembler_inputs["diagnostics"].write_text( diff --git a/packages/microcosm-data/src/microcosm/data/contract.py b/packages/microcosm-data/src/microcosm/data/contract.py index ee5cf3af17..4d04a1e210 100644 --- a/packages/microcosm-data/src/microcosm/data/contract.py +++ b/packages/microcosm-data/src/microcosm/data/contract.py @@ -537,6 +537,11 @@ # microcosm.build.uk_runtime.release_identity.UK_NATIONAL_RELEASE_ID (the # data shard cannot import the build shard); lockstep-tested. _UK_NATIONAL_RELEASE_ID = "microcosm-uk-2024-25-national" +# The per-cut tag grammar the assembler mints from the calibration attempt id +# (tools/assemble_uk_release_dir.py). The contract validates the same shape so +# a hand-edited or stale revision cannot claim a cut the attempt chain never +# produced. +_UK_NATIONAL_REVISION_SUFFIX_RE = re.compile(r"[0-9]{8}T[0-9]{6}Z-[0-9a-f]{8}") _UK_RELEASE_CERTIFICATION_SCHEMA_VERSION = 1 _UK_RELEASE_CERTIFICATION_KIND = "uk_release_certification" _UK_CERTIFICATION_SHARED_GATE_IDS = frozenset({"uk_aggregate_admin"}) @@ -1101,12 +1106,27 @@ def _check_release_manifest( release_id == _UK_NATIONAL_RELEASE_ID and isinstance(revision, str) and revision.startswith(release_id + "-") - and len(revision) > len(release_id) + 1 - ) - if isinstance(revision, str) and not revision_matches_release: + and _UK_NATIONAL_REVISION_SUFFIX_RE.fullmatch( + revision[len(release_id) + 1 :] + ) + is not None + ) + # A present-but-non-string revision must fail here rather than + # slide past the isinstance guard: publish collects only string + # revisions, so a numeric revision would otherwise vanish into an + # empty pin set and publish under a dangling tag. + if revision and ( + not isinstance(revision, str) or not revision_matches_release + ): + expected = ( + f"the release id {release_id!r} or a " + f"'{release_id}--' per-cut tag" + if release_id == _UK_NATIONAL_RELEASE_ID + else f"the release id {release_id!r}" + ) failures.append( f"release_manifest.json artifact {key!r} revision is " - f"{revision!r}, expected the release id {release_id!r}." + f"{revision!r}, expected {expected}." ) if isinstance(entry, Mapping): _check_sha256_field( diff --git a/packages/microcosm-data/src/microcosm/data/release.py b/packages/microcosm-data/src/microcosm/data/release.py index dac11945a5..b07fad7f27 100644 --- a/packages/microcosm-data/src/microcosm/data/release.py +++ b/packages/microcosm-data/src/microcosm/data/release.py @@ -322,15 +322,32 @@ def publish_release( "latest-evidence.json are written only by the publisher itself, " "never as release artifacts." ) - artifact_revisions = _release_manifest_artifact_revisions(release_dir) + artifact_revisions, unreadable_revisions = _release_manifest_artifact_revisions( + release_dir + ) tag = tag_name or release_id - if artifact_revisions and not create_tag: + # These guards must fail closed: an empty revision set may only ever mean + # "no artifacts declared" (which the contract refuses anyway), never + # "artifacts whose pins could not be read" — either silence would publish + # artifacts under a tag their manifest never pinned. + if unreadable_revisions: + raise ValueError( + "release_manifest.json artifacts have missing or non-string " + f"revisions: {sorted(unreadable_revisions)}; every artifact must " + "pin a revision string." + ) + if not artifact_revisions: + raise ValueError( + "release_manifest.json declares no artifact revisions; releases " + "publish only revision-pinned artifacts." + ) + if not create_tag: raise ValueError( "release_manifest.json pins artifacts to revisions; " "publish_release must create the matching Hugging Face tag before " "updating latest.json." ) - if artifact_revisions and artifact_revisions != {tag}: + if artifact_revisions != {tag}: raise ValueError( "release_manifest.json pins artifacts to revisions; tag_name must " "match the release id or uniform per-cut artifact revision." @@ -648,19 +665,31 @@ def _ordered_unique(names: tuple[str, ...]) -> list[str]: return ordered -def _release_manifest_artifact_revisions(release_dir: Path) -> set[str]: +def _release_manifest_artifact_revisions( + release_dir: Path, +) -> tuple[set[str], list[str]]: + """Every artifact's pinned revision, and the artifacts whose pin is unreadable. + + A missing, empty, or non-string revision lands in the unreadable list + instead of silently shrinking the revision set: an empty set must mean "no + artifacts declared", never "artifacts whose pins could not be read". + """ manifest = json.loads((release_dir / "release_manifest.json").read_text()) artifacts = manifest.get("artifacts", {}) - if not isinstance(artifacts, dict): # pragma: no cover - validated already - return set() + if not isinstance(artifacts, dict): + return set(), ["artifacts"] revisions: set[str] = set() - for artifact in artifacts.values(): - if not isinstance(artifact, dict): # pragma: no cover - validated already + unreadable: list[str] = [] + for key, artifact in artifacts.items(): + if not isinstance(artifact, dict): + unreadable.append(str(key)) continue revision = artifact.get("revision") if isinstance(revision, str) and revision: revisions.add(revision) - return revisions + else: + unreadable.append(str(key)) + return revisions, unreadable def _commit_revision(commit_info: Any) -> str | None: diff --git a/packages/microcosm-data/tests/test_contract.py b/packages/microcosm-data/tests/test_contract.py index efeaf8c312..12f7f03381 100644 --- a/packages/microcosm-data/tests/test_contract.py +++ b/packages/microcosm-data/tests/test_contract.py @@ -2117,7 +2117,22 @@ def test_uk_national_release_requires_policyengine_uk_model_pin( validate_release_dir(directory) -@pytest.mark.parametrize("revision", ["main", UK_NATIONAL_RELEASE_ID + "-"]) +@pytest.mark.parametrize( + "revision", + [ + "main", + UK_NATIONAL_RELEASE_ID + "-", + # Prefixed but outside the attempt-derived cut-tag grammar: the + # contract validates the same - shape the + # assembler mints, so a hand-edited suffix cannot claim a cut. + UK_NATIONAL_RELEASE_ID + "-hotfix", + UK_NATIONAL_RELEASE_ID + "-20260828t101112Z-1a2b3c4d", + UK_NATIONAL_RELEASE_ID + "-20260828T101112Z-1A2B3C4D", + # Present but non-string: must fail loudly rather than vanish from + # the publish layer's string-only revision collection. + 123, + ], +) def test_uk_national_release_rejects_invalid_artifact_revisions( tmp_path: Path, revision: str, diff --git a/packages/microcosm-data/tests/test_release.py b/packages/microcosm-data/tests/test_release.py index 59f1379c58..1d1a997c9d 100644 --- a/packages/microcosm-data/tests/test_release.py +++ b/packages/microcosm-data/tests/test_release.py @@ -1158,6 +1158,56 @@ def test_per_cut_artifact_revision_refuses_dangling_default_tag( assert hub.tags == [] +def test_unreadable_artifact_revisions_refuse_instead_of_vanishing( + hub: FakeHub, + release_dir: Path, + artifact_root: Path, + monkeypatch, +) -> None: + # A non-string revision must not shrink the pin set into vacuous guards: + # the empty set may only ever mean "no artifacts declared", never + # "artifacts whose pins could not be read". + manifest_path = release_dir / "release_manifest.json" + manifest = json.loads(manifest_path.read_text()) + for artifact in manifest["artifacts"].values(): + artifact["revision"] = 123 + manifest_path.write_text(json.dumps(manifest)) + monkeypatch.setattr(release_module, "validate_release_dir", lambda _path: None) + + with pytest.raises(ValueError, match="missing or non-string revisions"): + publish_release( + release_dir, + "policyengine/populace-us", + api=hub, + artifact_root=artifact_root, + ) + assert hub.uploads == [] + assert hub.tags == [] + + +def test_empty_artifact_revisions_refuse_publication( + hub: FakeHub, + release_dir: Path, + artifact_root: Path, + monkeypatch, +) -> None: + manifest_path = release_dir / "release_manifest.json" + manifest = json.loads(manifest_path.read_text()) + manifest["artifacts"] = {} + manifest_path.write_text(json.dumps(manifest)) + monkeypatch.setattr(release_module, "validate_release_dir", lambda _path: None) + + with pytest.raises(ValueError, match="declares no artifact revisions"): + publish_release( + release_dir, + "policyengine/populace-us", + api=hub, + artifact_root=artifact_root, + ) + assert hub.uploads == [] + assert hub.tags == [] + + def test_invalid_release_uploads_nothing(hub: FakeHub, release_dir: Path) -> None: (release_dir / "build_manifest.json").unlink() with pytest.raises(ReleaseContractError): diff --git a/tools/assemble_uk_release_dir.py b/tools/assemble_uk_release_dir.py index 3c71ed413d..0fc2a94ee9 100644 --- a/tools/assemble_uk_release_dir.py +++ b/tools/assemble_uk_release_dir.py @@ -178,6 +178,25 @@ def _assemble(args: argparse.Namespace) -> dict[str, object]: args.candidate_h5 ) spine_frame, _spine_provenance = load_uk_national_frame(args.spine_h5) + # The NPZ pairs the two weight vectors row by row, so the household axes + # must be identical — same ids, same order — before the pairing is + # digested and shipped as authoritative. A digest over mis-paired bytes + # authenticates the bytes, not the pairing. + candidate_ids = candidate_frame.table("household")["household_id"].to_numpy() + spine_ids = spine_frame.table("household")["household_id"].to_numpy() + if candidate_ids.shape != spine_ids.shape or not np.array_equal( + candidate_ids, spine_ids + ): + detail = ( + f"{candidate_ids.shape[0]} vs {spine_ids.shape[0]} household rows" + if candidate_ids.shape != spine_ids.shape + else "household ids differ" + ) + raise SystemExit( + f"error: candidate and spine household axes are misaligned " + f"({detail}); the calibration NPZ would pair weights across " + "different households" + ) household_weight = np.asarray( candidate_frame.weights_for("household").values, dtype=np.float64 ) @@ -503,9 +522,14 @@ def _diagnostics_code_pin(diagnostics: Mapping[str, object]) -> str: def _cut_tag(attempt_id: str, override: str | None) -> str: prefix = UK_NATIONAL_RELEASE_ID + "-" if override is not None: - if not override.startswith(prefix) or len(override) == len(prefix): + # The override may re-tag a cut but never leave the grammar the + # contract validates: prefix plus an attempt-shaped suffix. + if not override.startswith(prefix) or not _ATTEMPT_SUFFIX.fullmatch( + override[len(prefix) :] + ): raise SystemExit( - f"error: --cut-tag must start with {prefix!r} and include a suffix" + f"error: --cut-tag must be " + f"{prefix}-" ) return override if not attempt_id.startswith(_ATTEMPT_PREFIX): From ca356f085c5ff3de3e22e5c3f5993c8b1dfc70f1 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Mar=C3=ADa=20Juaristi?= <127882282+juaristi22@users.noreply.github.com> Date: Fri, 28 Aug 2026 19:38:14 +0200 Subject: [PATCH 4/7] Port the local-registries argument the rebase resolution dropped Rebasing the fixture extraction over #795 took our side of test_uk_release_certification.py wholesale, which lost main's one semantic change among the rewraps: run_uk_release_cut_battery grew a required local_ledger_registries argument with the local-contracts join. Restore it at the only call site. Co-Authored-By: Claude Fable 5 --- packages/microcosm-build/tests/test_uk_release_certification.py | 1 + 1 file changed, 1 insertion(+) diff --git a/packages/microcosm-build/tests/test_uk_release_certification.py b/packages/microcosm-build/tests/test_uk_release_certification.py index 516bdceacd..69316ae5da 100644 --- a/packages/microcosm-build/tests/test_uk_release_certification.py +++ b/packages/microcosm-build/tests/test_uk_release_certification.py @@ -300,6 +300,7 @@ def test_release_cut_battery_runs_and_signs(tmp_path: Path, monkeypatch): coverage_engine=object(), build_stage_names=("frs_spine",), ledger_registries={2023: object(), 2025: object()}, + local_ledger_registries={2025: object()}, parity_evidence=object(), fit_weight_records=None, input_mass_reference={}, From 08cd3608daf68d22ea547d83ca2c4cf30d37734f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Mar=C3=ADa=20Juaristi?= <127882282+juaristi22@users.noreply.github.com> Date: Sun, 30 Aug 2026 14:37:12 +0200 Subject: [PATCH 5/7] Round 2: only signed provenance names the release, and the gate fails closed MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The #812 program review found four fail-open release-integrity paths and three hardening gaps. All seven close here. C1: publication refuses to move latest.json for any tag that is not the release id itself — a per-cut inspect tag with the default update_latest now fails before any remote mutation instead of promoting. C2: the assembler derives release identity — attempt id (and so the cut tag), spine digest, runtime pins — only from the signed diagnostics build block, and requires the unsigned build record to agree with every authenticated field. A tampered build id or a substituted spine with a re-pinned record digest now refuses at the signed pin. The row-alignment check stays as defense in depth, tested through coherently-signed fixtures with genuinely mismatched spines. C3: the contract binds every certification-signed evidence file — the three part reports and the score receipt — to its local bytes: a removed or rewritten copy refuses instead of validating on digest shape alone. C4: the seam records the calibrating environment's package versions into the signed diagnostics build block, and the assembler pins manifests only from that provenance; --runtime-version may re-assert a signed value but refuses to replace one. A1: assembly stages into a private directory, validates there, and atomically renames only an already-validated release into destinations proven empty first — a late failure leaves no partial release and no NPZ. A2: the printed publish command is rendered with shlex.join and asserted by parsing. A3: the contract refuses a manifest pinning more than one revision, so two grammar-valid cuts cannot mix. Co-Authored-By: Claude Fable 5 --- ...6-release-integrity-fail-closed.changed.md | 9 + .../806-seam-runtime-provenance.added.md | 6 + ...k-national-release-assembly-runbook-806.md | 15 +- .../build/uk_runtime/calibration_run.py | 31 +++ .../tests/test_uk_release_assembler.py | 146 ++++++++++-- .../src/microcosm/data/contract.py | 82 +++++++ .../src/microcosm/data/release.py | 10 + .../microcosm-data/tests/test_contract.py | 66 +++++- packages/microcosm-data/tests/test_release.py | 30 +++ tools/assemble_uk_release_dir.py | 222 +++++++++++++----- 10 files changed, 530 insertions(+), 87 deletions(-) create mode 100644 changelog.d/806-release-integrity-fail-closed.changed.md create mode 100644 changelog.d/806-seam-runtime-provenance.added.md diff --git a/changelog.d/806-release-integrity-fail-closed.changed.md b/changelog.d/806-release-integrity-fail-closed.changed.md new file mode 100644 index 0000000000..48e6cd7eae --- /dev/null +++ b/changelog.d/806-release-integrity-fail-closed.changed.md @@ -0,0 +1,9 @@ +The national release-integrity paths fail closed (#812 review round 2): +publication refuses to move `latest.json` for any per-cut tag; the contract +requires every certification-signed evidence file to exist in the release +directory with exactly the signed bytes, and refuses a manifest pinning more +than one revision; the assembler derives release identity (attempt id, spine +digest, runtime pins) only from the signed diagnostics build block, requires +the build record to agree, stages into a private directory, and atomically +renames only an already-validated release into empty destinations. The +printed publish command is rendered shell-safe. diff --git a/changelog.d/806-seam-runtime-provenance.added.md b/changelog.d/806-seam-runtime-provenance.added.md new file mode 100644 index 0000000000..ccb5b9f06a --- /dev/null +++ b/changelog.d/806-seam-runtime-provenance.added.md @@ -0,0 +1,6 @@ +The UK calibration seam records the calibrating environment's package +versions (`python`, `policyengine-core`, `policyengine-uk`, and the microcosm +shards) into the signed diagnostics build block. The release assembler pins +manifests only from that authenticated provenance — a `--runtime-version` +override may re-assert a signed value but never replace it — so a release +can no longer describe an invented assembly environment. diff --git a/docs/uk-national-release-assembly-runbook-806.md b/docs/uk-national-release-assembly-runbook-806.md index 97c2c542f7..719b02faa5 100644 --- a/docs/uk-national-release-assembly-runbook-806.md +++ b/docs/uk-national-release-assembly-runbook-806.md @@ -102,8 +102,15 @@ The output is digest, the derived cut tag, and the exact publication command. Use `--cut-tag microcosm-uk-2024-25-national--` only to override the derived tag deliberately; the override must keep that grammar, -which the contract validates on every artifact revision. `--runtime-version PACKAGE=VERSION` is available when a -package's runtime version cannot be resolved from the assembly environment. +which the contract validates on every artifact revision. + +Assembly stages into a private directory, validates there, and atomically +renames into empty destinations: re-assembling a cut requires removing the +previous `releases/microcosm-uk-2024-25-national/` directory and the +previously minted calibration NPZ first. Release identity — the attempt id, +spine digest, and every runtime pin — comes only from the signed diagnostics +build block; `--runtime-version PACKAGE=VERSION` may re-assert a signed value +as an operator cross-check but refuses to replace one. ## 4. Publish for inspection @@ -120,7 +127,9 @@ uv run python -m microcosm.data.publish_cli \ Do not omit `--tag-name`, and do not pass `--no-create-tag`: every artifact in the manifest is pinned to that immutable per-cut tag. `--no-latest` is -mandatory for this inspect lane. +mandatory for this inspect lane — and enforced: publication refuses to move +`latest.json` for any tag that is not the release id itself, so omitting the +flag fails closed instead of promoting an inspect cut. If tag creation returns HTTP 409 after the staging commit, publication can leave the constant branch diff --git a/packages/microcosm-build/src/microcosm/build/uk_runtime/calibration_run.py b/packages/microcosm-build/src/microcosm/build/uk_runtime/calibration_run.py index 87dd54073e..7680727f1c 100644 --- a/packages/microcosm-build/src/microcosm/build/uk_runtime/calibration_run.py +++ b/packages/microcosm-build/src/microcosm/build/uk_runtime/calibration_run.py @@ -8,11 +8,13 @@ import hmac import json import os +import platform import time import uuid from collections.abc import Mapping from dataclasses import dataclass from datetime import UTC, datetime +from importlib import metadata from pathlib import Path from types import SimpleNamespace from typing import Any @@ -438,6 +440,10 @@ def _run_uk_calibration_attempt( build_block = { "build_id": state.build_id, "code_pin": code_pin, + # Captured at solve time and signed with the diagnostics bytes, so a + # release assembler can only ever pin the environment that actually + # calibrated the candidate — never an invented one. + "runtime": _runtime_provenance(), "source_pins": dict(source_pins), "ledger": run_config["ledger"], "input_posture": { @@ -894,6 +900,31 @@ def _ledger_provenance(artifact: Any) -> dict[str, object]: return provenance +#: Packages whose versions the seam pins into the signed diagnostics build +#: block. The release assembler treats these as the only legitimate source +#: for manifest runtime/compatibility pins. +_RUNTIME_PROVENANCE_PACKAGES = ( + "policyengine-core", + "policyengine-uk", + "microcosm-frame", + "microcosm-calibrate", + "microcosm-build", + "microcosm-data", +) + + +def _runtime_provenance() -> dict[str, str]: + """The calibrating environment's package versions, for the build block.""" + + runtime = {"python": platform.python_version()} + for package in _RUNTIME_PROVENANCE_PACKAGES: + try: + runtime[package] = metadata.version(package) + except metadata.PackageNotFoundError: + runtime[package] = "unavailable" + return runtime + + def _register_census( registry: TargetRegistry, exclusions: Mapping[str, Mapping[str, str]] ) -> dict[str, object]: diff --git a/packages/microcosm-build/tests/test_uk_release_assembler.py b/packages/microcosm-build/tests/test_uk_release_assembler.py index 14a592e810..f3a223a495 100644 --- a/packages/microcosm-build/tests/test_uk_release_assembler.py +++ b/packages/microcosm-build/tests/test_uk_release_assembler.py @@ -4,6 +4,7 @@ import importlib.util import json +import shlex from pathlib import Path import numpy as np @@ -78,7 +79,7 @@ def _frame(weights: list[float], *, weight_kind: WeightKind): ) -def _diagnostics() -> dict: +def _diagnostics(spine_sha256: str) -> dict: return { "schema_version": 6, "weight_entity": "household", @@ -161,12 +162,36 @@ def _diagnostics() -> dict: ) ], }, - "build": {"code_pin": _CODE_PIN}, + "build": { + "code_pin": _CODE_PIN, + "build_id": _ATTEMPT_ID, + "input_posture": {"tier": "staging_candidate", "sha256": spine_sha256}, + "runtime": dict(_SIGNED_RUNTIME), + }, } +_SIGNED_RUNTIME = { + "python": "3.13.5", + "policyengine-core": "3.19.0", + "policyengine-uk": "2.89.0", + "microcosm-data": "0.1.0", + "microcosm-build": "0.1.0", + "microcosm-calibrate": "0.1.0", +} + + @pytest.fixture def assembler_inputs(green_certification_inputs, tmp_path: Path): + return _build_assembler_inputs(green_certification_inputs, tmp_path) + + +def _build_assembler_inputs( + green_certification_inputs, + tmp_path: Path, + *, + spine_frame=None, +): pytest.importorskip("tables") # pandas HDF backend pytest.importorskip("h5py") candidate = green_certification_inputs["candidate_path"] @@ -174,10 +199,14 @@ def assembler_inputs(green_certification_inputs, tmp_path: Path): write_uk_national_frame( _frame([0.0, 4.0], weight_kind=WeightKind.CALIBRATED), candidate ) - write_uk_national_frame(_frame([1.0, 2.0], weight_kind=WeightKind.DESIGN), spine) + if spine_frame is None: + spine_frame = _frame([1.0, 2.0], weight_kind=WeightKind.DESIGN) + write_uk_national_frame(spine_frame, spine) diagnostics_path = tmp_path / "calibration_diagnostics.json" - diagnostics_path.write_text(json.dumps(_diagnostics()), encoding="utf-8") + diagnostics_path.write_text( + json.dumps(_diagnostics(sha256(spine))), encoding="utf-8" + ) diagnostics_sha = sha256(diagnostics_path) for report_name in ("seam_report_path", "release_cut_report_path"): report_path = green_certification_inputs[report_name] @@ -301,12 +330,23 @@ def test_assemble_green_release_dir(assembler_inputs, capsys) -> None: ).read_bytes() == assembler_inputs["certification"].read_bytes() assert summary["cut_tag"] == _CUT_TAG assert summary["release_dir"] == str(release_dir) - assert summary["publish_command"] == ( - f"uv run python -m microcosm.data.publish_cli {release_dir} " - "--repo-id policyengine/populace-uk-private " - f"--artifact-root {assembler_inputs['candidate'].parent} --no-latest " - f"--tag-name {_CUT_TAG}" - ) + # The command is rendered shell-safe; parse it back rather than comparing + # one quoting of it. + assert shlex.split(summary["publish_command"]) == [ + "uv", + "run", + "python", + "-m", + "microcosm.data.publish_cli", + str(release_dir), + "--repo-id", + "policyengine/populace-uk-private", + "--artifact-root", + str(assembler_inputs["candidate"].parent), + "--no-latest", + "--tag-name", + _CUT_TAG, + ] def test_assemble_refuses_candidate_sha_mismatch(assembler_inputs) -> None: @@ -338,33 +378,53 @@ def test_assemble_refuses_unprefixed_cut_tag(assembler_inputs) -> None: ) -def _rewrite_spine(assembler_inputs, frame) -> None: - """Replace the spine H5 and re-pin its digest in the build record.""" - write_uk_national_frame(frame, assembler_inputs["spine"]) +def test_assemble_refuses_substituted_spine(assembler_inputs) -> None: + # A same-shape spine with different weights, with only the unsigned build + # record's digest updated to match: the signed diagnostics pin must catch + # the substitution before any NPZ pairing happens. + write_uk_national_frame( + _frame([9.0, 9.0], weight_kind=WeightKind.DESIGN), + assembler_inputs["spine"], + ) record_path = assembler_inputs["diagnostics"].parent / "build_record.json" record = json.loads(record_path.read_text(encoding="utf-8")) record["input_posture"]["sha256"] = sha256(assembler_inputs["spine"]) record_path.write_text(json.dumps(record), encoding="utf-8") + with pytest.raises(SystemExit, match="signed diagnostics.build.input_posture"): + _load_driver_module().main(assembler_inputs["argv"]) + + +def test_assemble_refuses_build_record_attempt_id_tamper(assembler_inputs) -> None: + record_path = assembler_inputs["diagnostics"].parent / "build_record.json" + record = json.loads(record_path.read_text(encoding="utf-8")) + record["build_id"] = "uk-frs-calibration-attempt-20260901T000000Z-deadbeef" + record_path.write_text(json.dumps(record), encoding="utf-8") + with pytest.raises(SystemExit, match="signed diagnostics.build.build_id"): + _load_driver_module().main(assembler_inputs["argv"]) def test_assemble_refuses_spine_with_different_household_count( - assembler_inputs, + green_certification_inputs, tmp_path: Path ) -> None: - _rewrite_spine( - assembler_inputs, - _frame([1.0, 2.0, 3.0], weight_kind=WeightKind.DESIGN), + # The whole fixture chain signs the mismatched spine coherently, so the + # row-alignment check itself is what refuses. + inputs = _build_assembler_inputs( + green_certification_inputs, + tmp_path, + spine_frame=_frame([1.0, 2.0, 3.0], weight_kind=WeightKind.DESIGN), ) with pytest.raises(SystemExit, match="misaligned.*2 vs 3 household rows"): - _load_driver_module().main(assembler_inputs["argv"]) + _load_driver_module().main(inputs["argv"]) def test_assemble_refuses_spine_with_different_household_ids( - assembler_inputs, + green_certification_inputs, tmp_path: Path ) -> None: ids = np.asarray([5, 6], dtype="int64") - _rewrite_spine( - assembler_inputs, - uk_national_frame( + inputs = _build_assembler_inputs( + green_certification_inputs, + tmp_path, + spine_frame=uk_national_frame( person=pd.DataFrame( { "person_id": ids, @@ -384,7 +444,47 @@ def test_assemble_refuses_spine_with_different_household_ids( ), ) with pytest.raises(SystemExit, match="misaligned.*household ids differ"): - _load_driver_module().main(assembler_inputs["argv"]) + _load_driver_module().main(inputs["argv"]) + + +def test_assemble_refuses_runtime_override_contradicting_provenance( + assembler_inputs, +) -> None: + with pytest.raises(SystemExit, match="contradicts the signed provenance"): + _load_driver_module().main( + [ + *assembler_inputs["argv"], + "--runtime-version", + "policyengine-uk=9.99.0", + ] + ) + + +def test_assemble_refuses_existing_destination(assembler_inputs, capsys) -> None: + driver = _load_driver_module() + assert driver.main(assembler_inputs["argv"]) == 0 + capsys.readouterr() + with pytest.raises(SystemExit, match="already exists"): + driver.main(assembler_inputs["argv"]) + + +def test_assemble_late_failure_leaves_no_partial_release( + assembler_inputs, monkeypatch +) -> None: + driver = _load_driver_module() + + def _explode(_release_dir): + raise RuntimeError("late validation failure") + + monkeypatch.setattr(driver, "validate_release_dir", _explode) + with pytest.raises(RuntimeError, match="late validation failure"): + driver.main(assembler_inputs["argv"]) + out_dir = assembler_inputs["out_dir"] + assert not (out_dir / UK_NATIONAL_RELEASE_ID).exists() + assert not list(out_dir.glob(".assemble-*")) + assert not list( + assembler_inputs["candidate"].parent.glob("*_calibration.npz") + ) def test_assemble_refuses_cut_tag_outside_the_grammar(assembler_inputs) -> None: diff --git a/packages/microcosm-data/src/microcosm/data/contract.py b/packages/microcosm-data/src/microcosm/data/contract.py index 4d04a1e210..73df3cd4a3 100644 --- a/packages/microcosm-data/src/microcosm/data/contract.py +++ b/packages/microcosm-data/src/microcosm/data/contract.py @@ -542,6 +542,16 @@ # a hand-edited or stale revision cannot claim a cut the attempt chain never # produced. _UK_NATIONAL_REVISION_SUFFIX_RE = re.compile(r"[0-9]{8}T[0-9]{6}Z-[0-9a-f]{8}") +# The canonical release-dir filenames of the evidence the certification signs +# (tools/assemble_uk_release_dir.py copies each byte-for-byte). Validation +# binds every local file to its signed digest: a certification whose evidence +# was removed or rewritten must refuse, not validate around the gap. +_UK_CERTIFICATION_PART_EVIDENCE_FILES: Mapping[str, str] = { + "spine": "spine_gates.json", + "calibration_seam": "terminal_gates.json", + "release_cut": "release_cut_gates.json", +} +_UK_CERTIFICATION_SCORE_RECEIPT_FILE = "score_vs_enhanced_frs.json" _UK_RELEASE_CERTIFICATION_SCHEMA_VERSION = 1 _UK_RELEASE_CERTIFICATION_KIND = "uk_release_certification" _UK_CERTIFICATION_SHARED_GATE_IDS = frozenset({"uk_aggregate_admin"}) @@ -1135,6 +1145,23 @@ def _check_release_manifest( value=entry.get("sha256"), failures=failures, ) + # One release pins one revision: individually grammar-valid revisions + # from two different cuts must refuse here, not later at publish. + distinct_revisions = sorted( + { + entry.get("revision") + for entry in artifacts.values() + if isinstance(entry, Mapping) + and isinstance(entry.get("revision"), str) + and entry.get("revision") + } + ) + if len(distinct_revisions) > 1: + failures.append( + "release_manifest.json artifacts pin more than one revision " + f"({distinct_revisions}); every artifact must pin the same " + "release revision." + ) if release_id.startswith("populace-us-"): _check_us_release_has_no_split_microdata_artifacts( artifacts, @@ -3011,6 +3038,56 @@ def _check_uk_release_certification( ) +def _check_uk_certification_evidence_binding( + certification: Mapping, + release_dir: Path, + failures: list[str], +) -> None: + """Bind every signed evidence digest to the local file's actual bytes. + + The certification signs the part-report and score-receipt digests; a + release directory whose copies were removed or rewritten must refuse + here, not validate on the digest fields alone. + """ + + parts = certification.get("parts") + parts = parts if isinstance(parts, Mapping) else {} + bindings: list[tuple[str, str, object]] = [] + for part, filename in _UK_CERTIFICATION_PART_EVIDENCE_FILES.items(): + part_payload = parts.get(part) + signed_sha = ( + part_payload.get("sha256") if isinstance(part_payload, Mapping) else None + ) + bindings.append((f"parts.{part}", filename, signed_sha)) + score_receipt = certification.get("score_receipt") + bindings.append( + ( + "score_receipt", + _UK_CERTIFICATION_SCORE_RECEIPT_FILE, + score_receipt.get("sha256") + if isinstance(score_receipt, Mapping) + else None, + ) + ) + for owner, filename, signed_sha in bindings: + path = release_dir / filename + if not path.is_file(): + failures.append( + f"{_UK_RELEASE_CERTIFICATION_FILE} signs {owner} but the " + f"release directory is missing {filename!r}." + ) + continue + if not isinstance(signed_sha, str): + # The certification shape check already reported the malformed + # digest; without it no binding is possible. + continue + if _sha256(path) != signed_sha: + failures.append( + f"{filename} does not match the certification's signed " + f"{owner}.sha256." + ) + + def _check_calibration_diagnostics( diagnostics: Mapping, failures: list[str], @@ -4442,6 +4519,11 @@ def validate_release_dir(release_dir: Path | str) -> None: calibration_diagnostics_sha256=calibration_diagnostics_sha256, failures=failures, ) + _check_uk_certification_evidence_binding( + certification, + release_dir, + failures, + ) _check_cross_manifest_consistency( build_manifest, diff --git a/packages/microcosm-data/src/microcosm/data/release.py b/packages/microcosm-data/src/microcosm/data/release.py index b07fad7f27..4bab1172b1 100644 --- a/packages/microcosm-data/src/microcosm/data/release.py +++ b/packages/microcosm-data/src/microcosm/data/release.py @@ -352,6 +352,16 @@ def publish_release( "release_manifest.json pins artifacts to revisions; tag_name must " "match the release id or uniform per-cut artifact revision." ) + # A pointer may only ever name a release whose immutable tag IS the + # release id: per-cut tags are the inspect lane, and the pointer/loader + # contract cannot follow them. Refusing here makes that a publisher + # invariant rather than a runbook convention. + if update_latest and tag != release_id: + raise ValueError( + "per-cut tags publish inspect-only: the release pointer cannot " + f"name tag {tag!r} for release {release_id!r}. Pass " + "update_latest=False (--no-latest)." + ) if root_artifacts and artifact_root is None: raise ValueError( "release_manifest.json declares root artifacts; pass artifact_root " diff --git a/packages/microcosm-data/tests/test_contract.py b/packages/microcosm-data/tests/test_contract.py index 12f7f03381..c704b52b88 100644 --- a/packages/microcosm-data/tests/test_contract.py +++ b/packages/microcosm-data/tests/test_contract.py @@ -1435,10 +1435,27 @@ def _write_uk_national_release_dir(tmp_path: Path) -> Path: for artifact in release["artifacts"].values(): artifact["revision"] = UK_NATIONAL_CUT_TAG + # The certification signs the evidence copies' actual bytes: the seam + # report already sits in the directory (terminal_gates.json from the + # exact-k shape); the other three are written here so every signed digest + # binds to a real local file. + (national / "spine_gates.json").write_text(json.dumps({"fixture": "spine"})) + (national / "release_cut_gates.json").write_text( + json.dumps({"fixture": "release_cut"}) + ) + (national / "score_vs_enhanced_frs.json").write_text( + json.dumps({"fixture": "score"}) + ) certification = _green_uk_certification( TEST_UK_TERMINAL_GATE_SIGNING_KEY_BYTES, release_id=UK_NATIONAL_RELEASE_ID, diagnostics_sha256=_sha256(national / "calibration_diagnostics.json"), + part_shas={ + "spine": _sha256(national / "spine_gates.json"), + "calibration_seam": _sha256(national / "terminal_gates.json"), + "release_cut": _sha256(national / "release_cut_gates.json"), + }, + score_receipt_sha256=_sha256(national / "score_vs_enhanced_frs.json"), ) certification_path = national / "release_certification.json" certification_path.write_text(json.dumps(certification)) @@ -2148,6 +2165,46 @@ def test_uk_national_release_rejects_invalid_artifact_revisions( validate_release_dir(directory) +def test_uk_national_release_rejects_mixed_cut_revisions(tmp_path: Path) -> None: + # Two individually grammar-valid cut tags are still two cuts: the + # contract refuses the mixture, not just publish. + directory = _write_uk_national_release_dir(tmp_path) + release_path = directory / "release_manifest.json" + release = json.loads(release_path.read_text()) + first_key = next(iter(release["artifacts"])) + release["artifacts"][first_key]["revision"] = ( + UK_NATIONAL_RELEASE_ID + "-20260901T000000Z-deadbeef" + ) + release_path.write_text(json.dumps(release)) + + with pytest.raises(ReleaseContractError, match="more than one revision"): + validate_release_dir(directory) + + +def test_uk_national_release_binds_signed_evidence_bytes(tmp_path: Path) -> None: + # Rewriting a copied part report must refuse: the certification signs the + # evidence bytes, not just the digest fields' shapes. + directory = _write_uk_national_release_dir(tmp_path) + (directory / "release_cut_gates.json").write_text( + json.dumps({"fixture": "tampered"}) + ) + + with pytest.raises( + ReleaseContractError, match="does not match the certification's signed" + ): + validate_release_dir(directory) + + +def test_uk_national_release_requires_signed_evidence_files(tmp_path: Path) -> None: + directory = _write_uk_national_release_dir(tmp_path) + (directory / "terminal_gates.json").unlink() + + with pytest.raises( + ReleaseContractError, match="missing 'terminal_gates.json'" + ): + validate_release_dir(directory) + + def test_us_release_rejects_dash_suffixed_artifact_revision( release_dir: Path, ) -> None: @@ -5080,12 +5137,14 @@ def _green_uk_certification( *, release_id: str = "uk-757-first-certified-cut", diagnostics_sha256: str = "c" * 64, + part_shas: dict[str, str] | None = None, + score_receipt_sha256: str = "d" * 64, ) -> dict: parts = {} for part_name, scope in contract._UK_CERTIFICATION_PART_SCOPES.items(): parts[part_name] = { "path": f"{part_name}.json", - "sha256": "a" * 64, + "sha256": (part_shas or {}).get(part_name, "a" * 64), "release_id": release_id, "phases": list(contract._UK_CERTIFICATION_PART_PHASES[part_name]), "entry_ids": sorted(scope), @@ -5119,7 +5178,10 @@ def _green_uk_certification( }, "doctrine": {"payload": {"epochs": 1500}, "overrides": {}}, "diagnostics_sha256": diagnostics_sha256, - "score_receipt": {"filename": "score_vs_enhanced_frs.json", "sha256": "d" * 64}, + "score_receipt": { + "filename": "score_vs_enhanced_frs.json", + "sha256": score_receipt_sha256, + }, "exclusions_evaluated_on": "2026-08-27", "shippable": True, } diff --git a/packages/microcosm-data/tests/test_release.py b/packages/microcosm-data/tests/test_release.py index 1d1a997c9d..25c0af0c9d 100644 --- a/packages/microcosm-data/tests/test_release.py +++ b/packages/microcosm-data/tests/test_release.py @@ -1208,6 +1208,36 @@ def test_empty_artifact_revisions_refuse_publication( assert hub.tags == [] +def test_per_cut_tag_refuses_latest_promotion( + hub: FakeHub, + release_dir: Path, + artifact_root: Path, + monkeypatch, +) -> None: + # The pointer may only ever name a release whose tag IS the release id: + # a per-cut inspect tag with the default update_latest must refuse before + # any remote mutation, not promote. + cut_tag = RELEASE_ID + "-20260828T101112Z-1a2b3c4d" + manifest_path = release_dir / "release_manifest.json" + manifest = json.loads(manifest_path.read_text()) + for artifact in manifest["artifacts"].values(): + artifact["revision"] = cut_tag + manifest_path.write_text(json.dumps(manifest)) + monkeypatch.setattr(release_module, "validate_release_dir", lambda _path: None) + + with pytest.raises(ValueError, match="inspect-only"): + publish_release( + release_dir, + "policyengine/populace-us", + api=hub, + artifact_root=artifact_root, + tag_name=cut_tag, + ) + assert hub.uploads == [] + assert hub.tags == [] + assert hub.events == [] + + def test_invalid_release_uploads_nothing(hub: FakeHub, release_dir: Path) -> None: (release_dir / "build_manifest.json").unlink() with pytest.raises(ReleaseContractError): diff --git a/tools/assemble_uk_release_dir.py b/tools/assemble_uk_release_dir.py index 0fc2a94ee9..d872462d3c 100644 --- a/tools/assemble_uk_release_dir.py +++ b/tools/assemble_uk_release_dir.py @@ -11,12 +11,12 @@ import argparse import hashlib import json -import platform import re +import shlex import shutil +import uuid from collections.abc import Mapping from datetime import UTC, datetime -from importlib.metadata import PackageNotFoundError, version from pathlib import Path import numpy as np @@ -156,18 +156,55 @@ def _assemble(args: argparse.Namespace) -> dict[str, object]: "error: certification.shippable must be true before release assembly" ) - attempt_id = build_record.get("build_id") - if not isinstance(attempt_id, str): - raise SystemExit("error: build_record.build_id must be a string") + # Release identity comes only from the signed diagnostics build block + # (its bytes are pinned by certification.diagnostics_sha256). The + # separately supplied build record is convenience input and must agree + # with the authenticated fields, never substitute for them. + build_block = _mapping(diagnostics.get("build"), "diagnostics.build") + attempt_id = build_block.get("build_id") + if not isinstance(attempt_id, str) or not attempt_id: + raise SystemExit( + "error: diagnostics.build.build_id must be a non-empty string" + ) + _require_equal( + "build_record.build_id vs signed diagnostics.build.build_id", + build_record.get("build_id"), + attempt_id, + ) + signed_input_posture = _mapping( + build_block.get("input_posture"), "diagnostics.build.input_posture" + ) + _require_equal( + "spine H5 vs signed diagnostics.build.input_posture.sha256", + measured["spine H5"], + signed_input_posture.get("sha256"), + ) cut_tag = _cut_tag(attempt_id, args.cut_tag) - runtime = _runtime_versions(build_record, dict(args.runtime_version)) + runtime = _runtime_versions(build_block, dict(args.runtime_version)) code_pin = _diagnostics_code_pin(diagnostics) candidate_filename = candidate.get("filename") if not isinstance(candidate_filename, str) or not candidate_filename: raise SystemExit("error: certification.candidate.filename must be non-empty") dataset_key = Path(candidate_filename).stem calibration_filename = f"{dataset_key}_calibration.npz" - calibration_path = args.candidate_h5.parent / calibration_filename + # Nothing is written in place: assembly stages into a private directory, + # validates there, and only then atomically renames into empty + # destinations — a late failure can never leave a plausible partial + # release or corrupt a previous assembly. + destination = args.out_dir / UK_NATIONAL_RELEASE_ID + if destination.exists() or destination.is_symlink(): + raise SystemExit( + f"error: {destination} already exists; remove the previous " + "assembly before re-assembling" + ) + npz_destination = args.candidate_h5.parent / calibration_filename + if npz_destination.exists() or npz_destination.is_symlink(): + raise SystemExit( + f"error: {npz_destination} already exists; remove it before " + "re-assembling" + ) + staging_parent = args.out_dir / f".assemble-{uuid.uuid4().hex}" + calibration_path = staging_parent / calibration_filename target_surface = _mapping( diagnostics.get("target_surface"), "diagnostics.target_surface" ) @@ -204,6 +241,59 @@ def _assemble(args: argparse.Namespace) -> dict[str, object]: spine_frame.weights_for("household").values, dtype=np.float64 ) + staging_parent.mkdir(parents=True) + try: + return _stage_and_finalize( + args=args, + staging_parent=staging_parent, + destination=destination, + npz_destination=npz_destination, + calibration_path=calibration_path, + calibration_filename=calibration_filename, + household_weight=household_weight, + initial_household_weight=initial_household_weight, + measured=measured, + candidate_filename=candidate_filename, + dataset_key=dataset_key, + cut_tag=cut_tag, + attempt_id=attempt_id, + runtime=runtime, + code_pin=code_pin, + target_surface=target_surface, + target_registry=target_registry, + spine_part=spine_part, + seam_part=seam_part, + release_cut_part=release_cut_part, + spine_report_path=spine_report_path, + ) + finally: + shutil.rmtree(staging_parent, ignore_errors=True) + + +def _stage_and_finalize( + *, + args: argparse.Namespace, + staging_parent: Path, + destination: Path, + npz_destination: Path, + calibration_path: Path, + calibration_filename: str, + household_weight: np.ndarray, + initial_household_weight: np.ndarray, + measured: Mapping[str, str], + candidate_filename: str, + dataset_key: str, + cut_tag: str, + attempt_id: str, + runtime: Mapping[str, str], + code_pin: str, + target_surface: Mapping[str, object], + target_registry: Mapping[str, object], + spine_part: Mapping[str, object], + seam_part: Mapping[str, object], + release_cut_part: Mapping[str, object], + spine_report_path: Path, +) -> dict[str, object]: np.savez( calibration_path, household_weight=household_weight, @@ -212,8 +302,8 @@ def _assemble(args: argparse.Namespace) -> dict[str, object]: calibration_sha = _sha256(calibration_path) created_at = datetime.now(UTC).isoformat() - release_dir = args.out_dir / UK_NATIONAL_RELEASE_ID - release_dir.mkdir(parents=True, exist_ok=True) + release_dir = staging_parent / UK_NATIONAL_RELEASE_ID + release_dir.mkdir(parents=True) build_manifest = { "build_id": UK_NATIONAL_RELEASE_ID, "code": { @@ -239,11 +329,13 @@ def _assemble(args: argparse.Namespace) -> dict[str, object]: "n_specs": target_registry.get("n_specs"), }, }, + # Signed sources only: the certification's part statuses. The build + # record's gate summary is unsigned convenience input and deliberately + # does not reach the manifest. "gates": { "spine": spine_part.get("statuses"), "calibration_seam": seam_part.get("statuses"), "release_cut": release_cut_part.get("statuses"), - "gate_summary": build_record.get("gate_summary"), }, "attempt_id": attempt_id, "cut_tag": cut_tag, @@ -374,38 +466,56 @@ def artifact(kind: str, path: str, sha256: str) -> dict[str, str]: evidence_summary: dict[str, dict[str, str]] = {} for key, (source, filename) in evidence.items(): - destination = release_dir / filename - shutil.copyfile(source, destination) + copy_path = release_dir / filename + shutil.copyfile(source, copy_path) evidence_summary[key] = { - "path": str(destination), - "sha256": _sha256(destination), + "path": str(destination / filename), + "sha256": _sha256(copy_path), } validate_release_dir(release_dir) - publish_command = ( - f"uv run python -m microcosm.data.publish_cli {release_dir} " - f"--repo-id {_REPO_ID} --artifact-root {args.candidate_h5.parent} " - f"--no-latest --tag-name {cut_tag}" + + # Only a directory that already validated moves into place, and only into + # the destinations proven empty before staging began. + release_dir.rename(destination) + calibration_path.rename(npz_destination) + + publish_command = shlex.join( + [ + "uv", + "run", + "python", + "-m", + "microcosm.data.publish_cli", + str(destination), + "--repo-id", + _REPO_ID, + "--artifact-root", + str(args.candidate_h5.parent), + "--no-latest", + "--tag-name", + cut_tag, + ] ) return { "release_id": UK_NATIONAL_RELEASE_ID, - "release_dir": str(release_dir), + "release_dir": str(destination), "cut_tag": cut_tag, "dataset": { "path": str(args.candidate_h5), "sha256": measured["candidate"], }, "calibration": { - "path": str(calibration_path), + "path": str(npz_destination), "sha256": calibration_sha, }, "build_manifest": { - "path": str(build_manifest_path), - "sha256": _sha256(build_manifest_path), + "path": str(destination / "build_manifest.json"), + "sha256": _sha256(destination / "build_manifest.json"), }, "release_manifest": { - "path": str(release_manifest_path), - "sha256": _sha256(release_manifest_path), + "path": str(destination / "release_manifest.json"), + "sha256": _sha256(destination / "release_manifest.json"), }, "evidence": evidence_summary, "publish_command": publish_command, @@ -469,45 +579,39 @@ def _runtime_version(value: str) -> tuple[str, str]: def _runtime_versions( - build_record: Mapping[str, object], overrides: dict[str, str] + build_block: Mapping[str, object], overrides: dict[str, str] ) -> dict[str, str]: - spine_provenance = _mapping( - build_record.get("spine_provenance"), "build_record.spine_provenance" - ) - rules_engine = _mapping( - spine_provenance.get("rules_engine"), - "build_record.spine_provenance.rules_engine", - ) - policyengine_uk = overrides.get("policyengine-uk") or rules_engine.get("version") - if not isinstance(policyengine_uk, str) or not policyengine_uk: - raise SystemExit( - "error: build_record.spine_provenance.rules_engine.version is required" - ) - runtime = { - "python": overrides.get("python", platform.python_version()), - "policyengine-core": _distribution_version("policyengine-core", overrides), - "policyengine-uk": policyengine_uk, - "microcosm-data": _distribution_version("microcosm-data", overrides), - "microcosm-build": _distribution_version("microcosm-build", overrides), - "microcosm-calibrate": _distribution_version( - "microcosm-calibrate", overrides - ), - } + """Runtime pins from the signed diagnostics build block, and only there. + + The seam captures the calibrating environment's versions at solve time + (``diagnostics.build.runtime``), signed with the diagnostics bytes. An + override may re-assert an authenticated value but never replace it: a + manifest pin that contradicts the signed provenance is an invented + environment, which is exactly what the release contract cannot detect. + """ + + signed_runtime = _mapping( + build_block.get("runtime"), "diagnostics.build.runtime" + ) + runtime: dict[str, str] = {} + for package in _RUNTIME_PACKAGES: + value = signed_runtime.get(package) + if not isinstance(value, str) or not value or value == "unavailable": + raise SystemExit( + f"error: diagnostics.build.runtime.{package!r} is missing or " + "unresolved; the candidate must be re-cut by a seam that " + "records runtime provenance" + ) + override = overrides.get(package) + if override is not None and override != value: + raise SystemExit( + f"error: --runtime-version {package}={override} contradicts " + f"the signed provenance {value}" + ) + runtime[package] = value return runtime -def _distribution_version(package: str, overrides: Mapping[str, str]) -> str: - if package in overrides: - return overrides[package] - try: - return version(package) - except PackageNotFoundError as error: - raise SystemExit( - f"error: runtime version for {package} is unavailable; pass " - f"--runtime-version {package}=VERSION" - ) from error - - def _diagnostics_code_pin(diagnostics: Mapping[str, object]) -> str: build = _mapping(diagnostics.get("build"), "diagnostics.build") code_pin = build.get("code_pin") From 425185f79c536e9bc65334aec05bb392e114db52 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Mar=C3=ADa=20Juaristi?= <127882282+juaristi22@users.noreply.github.com> Date: Sun, 30 Aug 2026 15:00:46 +0200 Subject: [PATCH 6/7] Cite microcosm#823 as the promotion follow-up in the runbook Co-Authored-By: Claude Fable 5 --- docs/uk-national-release-assembly-runbook-806.md | 7 ++++--- 1 file changed, 4 insertions(+), 3 deletions(-) diff --git a/docs/uk-national-release-assembly-runbook-806.md b/docs/uk-national-release-assembly-runbook-806.md index 719b02faa5..8a7ba5db73 100644 --- a/docs/uk-national-release-assembly-runbook-806.md +++ b/docs/uk-national-release-assembly-runbook-806.md @@ -154,6 +154,7 @@ until that review is complete. Do not write `latest.json` for this line yet. The current pointer cannot name a per-cut tag, while the certified loader expects the artifact revision to equal the release id and fetches the manifest from a tag named by that id. Promotion -needs the follow-up loader/pointer design before a reviewed cut can become the -default. Until then, publish every national cut with `--no-latest` and its -explicit per-cut tag. +needs the loader/pointer design tracked in microcosm#823 before a reviewed cut +can become the default; publication enforces this by refusing a pointer move +for any per-cut tag. Until then, publish every national cut with `--no-latest` +and its explicit per-cut tag. From 3f4e3d6139181a81c958015a1e67c6223108eff3 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Mar=C3=ADa=20Juaristi?= <127882282+juaristi22@users.noreply.github.com> Date: Sun, 30 Aug 2026 15:30:47 +0200 Subject: [PATCH 7/7] Close the re-review's two residuals: same-tree NPZ rename, bound score digest MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The NPZ now stages beside its own destination (dotted .tmp.npz — np.savez appends .npz to any other name), so its finalizing rename can never cross a filesystem boundary and be destroyed by staging cleanup; it lands first and the release-directory rename is the commit point, rolling the NPZ back on failure between the two. The evidence-binding loop refuses instead of skipping when a signed digest is not a sha256 — score_receipt.sha256 is the one digest outside the shape-checked parts block, and a malformed value must not leave its evidence file unbound. Co-Authored-By: Claude Fable 5 --- .../tests/test_uk_release_assembler.py | 4 +++ .../src/microcosm/data/contract.py | 11 +++++-- .../microcosm-data/tests/test_contract.py | 32 +++++++++++++++++++ tools/assemble_uk_release_dir.py | 24 ++++++++++++-- 4 files changed, 65 insertions(+), 6 deletions(-) diff --git a/packages/microcosm-build/tests/test_uk_release_assembler.py b/packages/microcosm-build/tests/test_uk_release_assembler.py index f3a223a495..af616abba2 100644 --- a/packages/microcosm-build/tests/test_uk_release_assembler.py +++ b/packages/microcosm-build/tests/test_uk_release_assembler.py @@ -312,6 +312,7 @@ def test_assemble_green_release_dir(assembler_inputs, capsys) -> None: np.testing.assert_array_equal( calibration["initial_household_weight"], [1.0, 2.0] ) + assert not list(assembler_inputs["candidate"].parent.glob(".*.tmp.npz")) build_manifest = json.loads((release_dir / "build_manifest.json").read_text()) release_manifest = json.loads((release_dir / "release_manifest.json").read_text()) @@ -485,6 +486,9 @@ def _explode(_release_dir): assert not list( assembler_inputs["candidate"].parent.glob("*_calibration.npz") ) + # The NPZ stages beside its destination as a dotted temp file; a late + # failure must clean that up too. + assert not list(assembler_inputs["candidate"].parent.glob(".*.tmp.npz")) def test_assemble_refuses_cut_tag_outside_the_grammar(assembler_inputs) -> None: diff --git a/packages/microcosm-data/src/microcosm/data/contract.py b/packages/microcosm-data/src/microcosm/data/contract.py index 73df3cd4a3..9b93a6d9a6 100644 --- a/packages/microcosm-data/src/microcosm/data/contract.py +++ b/packages/microcosm-data/src/microcosm/data/contract.py @@ -3077,9 +3077,14 @@ def _check_uk_certification_evidence_binding( f"release directory is missing {filename!r}." ) continue - if not isinstance(signed_sha, str): - # The certification shape check already reported the malformed - # digest; without it no binding is possible. + if not isinstance(signed_sha, str) or not _SHA256_RE.fullmatch(signed_sha): + # Refuse, never skip: the parts block is shape-checked elsewhere + # but score_receipt.sha256 is not, and a malformed digest must + # not leave its evidence file unbound. + failures.append( + f"{_UK_RELEASE_CERTIFICATION_FILE} {owner}.sha256 is not a " + f"sha256 digest; {filename!r} cannot be bound." + ) continue if _sha256(path) != signed_sha: failures.append( diff --git a/packages/microcosm-data/tests/test_contract.py b/packages/microcosm-data/tests/test_contract.py index c704b52b88..d6e75396e6 100644 --- a/packages/microcosm-data/tests/test_contract.py +++ b/packages/microcosm-data/tests/test_contract.py @@ -2205,6 +2205,38 @@ def test_uk_national_release_requires_signed_evidence_files(tmp_path: Path) -> N validate_release_dir(directory) +def test_uk_national_release_refuses_unbindable_score_digest( + tmp_path: Path, +) -> None: + # score_receipt.sha256 is the one signed digest outside the shape-checked + # parts block: a malformed value must refuse the binding, never skip it. + directory = _write_uk_national_release_dir(tmp_path) + certification = _green_uk_certification( + TEST_UK_TERMINAL_GATE_SIGNING_KEY_BYTES, + release_id=UK_NATIONAL_RELEASE_ID, + diagnostics_sha256=_sha256(directory / "calibration_diagnostics.json"), + part_shas={ + "spine": _sha256(directory / "spine_gates.json"), + "calibration_seam": _sha256(directory / "terminal_gates.json"), + "release_cut": _sha256(directory / "release_cut_gates.json"), + }, + score_receipt_sha256="not-a-digest", + ) + certification_path = directory / "release_certification.json" + certification_path.write_text(json.dumps(certification)) + release_path = directory / "release_manifest.json" + release = json.loads(release_path.read_text()) + release["artifacts"]["release_certification"]["sha256"] = _sha256( + certification_path + ) + release_path.write_text(json.dumps(release)) + + with pytest.raises( + ReleaseContractError, match=r"score_receipt\.sha256 is not a" + ): + validate_release_dir(directory) + + def test_us_release_rejects_dash_suffixed_artifact_revision( release_dir: Path, ) -> None: diff --git a/tools/assemble_uk_release_dir.py b/tools/assemble_uk_release_dir.py index d872462d3c..439469e39b 100644 --- a/tools/assemble_uk_release_dir.py +++ b/tools/assemble_uk_release_dir.py @@ -204,7 +204,15 @@ def _assemble(args: argparse.Namespace) -> dict[str, object]: "re-assembling" ) staging_parent = args.out_dir / f".assemble-{uuid.uuid4().hex}" - calibration_path = staging_parent / calibration_filename + # The NPZ stages beside its own destination, not under staging_parent: + # its finalizing rename must never cross a filesystem boundary, where + # Path.rename raises and the staging cleanup would delete the artifact + # out from under an already-renamed release directory. + # The temp name must keep the .npz suffix: np.savez appends it to any + # other filename, silently writing beside the path the code then hashes. + calibration_path = npz_destination.with_name( + f".{calibration_filename}.{uuid.uuid4().hex}.tmp.npz" + ) target_surface = _mapping( diagnostics.get("target_surface"), "diagnostics.target_surface" ) @@ -268,6 +276,7 @@ def _assemble(args: argparse.Namespace) -> dict[str, object]: ) finally: shutil.rmtree(staging_parent, ignore_errors=True) + calibration_path.unlink(missing_ok=True) def _stage_and_finalize( @@ -476,9 +485,18 @@ def artifact(kind: str, path: str, sha256: str) -> dict[str, str]: validate_release_dir(release_dir) # Only a directory that already validated moves into place, and only into - # the destinations proven empty before staging began. - release_dir.rename(destination) + # the destinations proven empty before staging began. Both renames stay + # inside their own directory tree (the NPZ staged beside its + # destination), so neither can fail on a filesystem boundary. The NPZ + # lands first and the release directory is the commit point: a failure + # between the two rolls the NPZ back rather than leaving a published + # directory without its artifact. calibration_path.rename(npz_destination) + try: + release_dir.rename(destination) + except BaseException: + npz_destination.unlink(missing_ok=True) + raise publish_command = shlex.join( [