From 2be63b915268b232dbc8a673ae199e9bc305e6d3 Mon Sep 17 00:00:00 2001 From: Codex Date: Sun, 19 Jul 2026 01:43:07 +0200 Subject: [PATCH] Fix stable Statbel release identity --- CHANGELOG.md | 3 +++ .../test_sprint228_statbel_release_management.py | 8 ++++++++ docs/CODEX_EXECUTION_LOG.md | 4 ++++ scripts/manage_statbel_population_release.py | 14 +++++++++++++- 4 files changed, 28 insertions(+), 1 deletion(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 9c8885e8..92f63bd8 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -27,6 +27,9 @@ water-volume or fabricated bathymetry metric was added. - Added Docker, single-container Unraid and Dockerman template controls for SPW/PICC and UrbIS. +- Fixed the governed Statbel review gate so a new probe timestamp does not + masquerade as a changed publication; release URL, identifier, version and + catalog content hash remain mandatory stable identity fields. ## Autonomous Belgium and North Sea RC program (2026-07-17) diff --git a/backend/tests/test_sprint228_statbel_release_management.py b/backend/tests/test_sprint228_statbel_release_management.py index 1b2eedcb..c89c7a83 100644 --- a/backend/tests/test_sprint228_statbel_release_management.py +++ b/backend/tests/test_sprint228_statbel_release_management.py @@ -340,6 +340,14 @@ def test_tampered_source_or_review_is_rejected(tmp_path: Path) -> None: def test_catalog_drift_and_outside_evidence_path_are_rejected(tmp_path: Path) -> None: args = arguments(tmp_path) original = decision(args) + checked_later = json.loads(json.dumps(original)) + checked_later["catalog_identity"]["catalog_checked_at"] = "2026-07-19T01:00:00Z" + + MANAGER.require_catalog_unchanged( + {"release": original["release"], "catalog_identity": original["catalog_identity"]}, + checked_later, + ) + changed = json.loads(json.dumps(original)) changed["catalog_identity"]["capabilities_sha256"] = "b" * 64 diff --git a/docs/CODEX_EXECUTION_LOG.md b/docs/CODEX_EXECUTION_LOG.md index 04f7ea23..4201b84b 100644 --- a/docs/CODEX_EXECUTION_LOG.md +++ b/docs/CODEX_EXECUTION_LOG.md @@ -20,6 +20,10 @@ - Rechecked MDK bathymetry. Strict hostname validation still fails because the configured official hostname presents a `*.l27powered.eu` certificate, so acquisition remains truthfully disabled. +- Live national Statbel staging exposed and fixed a release-review blocker: + `catalog_checked_at` was incorrectly compared as publication identity. + Reviews now tolerate a later probe timestamp while still blocking any + identifier, release URL/version or catalog content-hash change. - Focused backend and frontend suites passed before full release validation; full local and Tower evidence follows in the final P5 gate. diff --git a/scripts/manage_statbel_population_release.py b/scripts/manage_statbel_population_release.py index 47a77341..1b4525ed 100644 --- a/scripts/manage_statbel_population_release.py +++ b/scripts/manage_statbel_population_release.py @@ -42,6 +42,12 @@ DEFAULT_EVIDENCE_ROOT = Path("/app/storage/operator-evidence/statbel-population- YEAR_PATTERN = re.compile(r"^20[0-9]{2}$") SHA256_PATTERN = re.compile(r"^[0-9a-f]{64}$") ACTIONABLE_STATUSES = {"update_available", "not_loaded"} +STABLE_CATALOG_IDENTITY_FIELDS = ( + "metadata_identifier", + "metadata_url", + "remote_version", + "capabilities_sha256", +) def parse_args() -> argparse.Namespace: @@ -431,7 +437,13 @@ def load_staged_plan( def require_catalog_unchanged(plan: dict[str, Any], decision: dict[str, Any]) -> None: - if plan.get("release") != decision.get("release") or plan.get("catalog_identity") != decision.get("catalog_identity"): + planned_identity = plan.get("catalog_identity") or {} + current_identity = decision.get("catalog_identity") or {} + identity_changed = any( + planned_identity.get(field) != current_identity.get(field) + for field in STABLE_CATALOG_IDENTITY_FIELDS + ) + if plan.get("release") != decision.get("release") or identity_changed: raise RuntimeError("The official Statbel release evidence changed; create and review a new staged plan")