#!/usr/bin/env python3 """Fail-closed validator for the Outcome/Alliance OS clean-release manifest.""" from __future__ import annotations import argparse import hashlib import json import os import subprocess import sys import tempfile from collections import Counter from pathlib import Path, PurePosixPath from typing import Any REPO_ROOT = Path(__file__).resolve().parents[1] DEFAULT_MANIFEST = ( REPO_ROOT / "docs/ops/outcome-os-clean-release-manifest-2026-08-07.json" ) EXPECTED_GOALS = {f"G{number}" for number in range(9)} ALLOWED_RELATED_GOALS = EXPECTED_GOALS | {"release-governance"} EXPECTED_DISPOSITIONS = { "related": "candidate-whole-file", "mixed": "hunk-review-required", "excluded": "exclude-from-outcome-release", } G7_GATE_ID = "g7-external-proof" G7_ALLOWED_STATUSES = {"blocked", "satisfied-by-validated-g7-proof"} REQUIRED_SATISFIED_GATES = { "mixed-files-cleared": "satisfied-by-verified-release-patch", "g8-agentic-runtime-promotion": "satisfied-by-isolated-nas-release", "g8-public-proof": "satisfied-by-public-release", "aos-011-public-deployment-proof": "satisfied-by-public-release", "aos-012-final-ssot-sync": "satisfied-by-final-checkers", } def count_patch_files(patch_bytes: bytes) -> int: """Count canonical git file sections without trusting manifest metadata.""" return sum( 1 for line in patch_bytes.splitlines() if line.startswith(b"diff --git ") ) def validate_satisfied_gate_evidence( gate_map: dict[str, dict[str, Any]], assembly: dict[str, Any], errors: list[str], ) -> None: """Bind a satisfied release gate to the exact verified patch artifact.""" gate = gate_map.get("mixed-files-cleared") if not isinstance(gate, dict): return evidence = gate.get("evidence") if not isinstance(evidence, str) or not evidence.strip(): errors.append("release gate mixed-files-cleared must record patch evidence") return patch_sha256 = assembly.get("patch_sha256") if not isinstance(patch_sha256, str) or len(patch_sha256) != 64: errors.append("release_assembly.patch_sha256 must be a SHA-256 hex digest") return if patch_sha256.lower() not in evidence.lower(): errors.append( "release gate mixed-files-cleared evidence does not reference " "the current release_assembly.patch_sha256" ) def verify_patch_against_base( patch_path: Path, base_commit: str, errors: list[str], ) -> None: head = subprocess.run( ["git", "rev-parse", "HEAD"], cwd=REPO_ROOT, stdout=subprocess.PIPE, stderr=subprocess.PIPE, check=False, ) current_head = head.stdout.decode("ascii", errors="replace").strip() if head.returncode != 0: errors.append("cannot resolve current HEAD for release base guard") return if current_head != base_commit: errors.append( f"release base HEAD drift: expected {base_commit}, current {current_head}" ) with tempfile.TemporaryDirectory(prefix="vignette-release-index-check-") as temp: index_env = os.environ.copy() index_env["GIT_INDEX_FILE"] = str(Path(temp) / "canonical.index") read_tree = subprocess.run( ["git", "read-tree", base_commit], cwd=REPO_ROOT, env=index_env, stdout=subprocess.PIPE, stderr=subprocess.PIPE, check=False, ) if read_tree.returncode != 0: detail = read_tree.stderr.decode("utf-8", errors="replace").strip() errors.append(f"cannot materialize canonical release index: {detail}") return apply_check = subprocess.run( [ "git", "apply", "--cached", "--check", "--whitespace=nowarn", str(patch_path), ], cwd=REPO_ROOT, env=index_env, stdout=subprocess.PIPE, stderr=subprocess.PIPE, check=False, ) if apply_check.returncode != 0: detail = apply_check.stderr.decode("utf-8", errors="replace").strip() errors.append( "release patch does not apply to canonical base index: " + detail[:1000] ) def parse_args() -> argparse.Namespace: parser = argparse.ArgumentParser(description=__doc__) parser.add_argument( "--manifest", type=Path, default=DEFAULT_MANIFEST, help="manifest path (default: repository canonical manifest)", ) parser.add_argument( "--json", action="store_true", help="emit machine-readable result" ) return parser.parse_args() def load_manifest(path: Path, errors: list[str]) -> dict[str, Any]: try: payload = json.loads(path.read_text(encoding="utf-8")) except FileNotFoundError: errors.append(f"manifest missing: {path}") return {} except (OSError, json.JSONDecodeError) as exc: errors.append(f"manifest unreadable: {exc}") return {} if not isinstance(payload, dict): errors.append("manifest root must be an object") return {} return payload def git_dirty_paths(errors: list[str]) -> dict[str, str]: result = subprocess.run( ["git", "status", "--porcelain=v1", "-z", "--untracked-files=all"], cwd=REPO_ROOT, stdout=subprocess.PIPE, stderr=subprocess.PIPE, check=False, ) if result.returncode != 0: detail = result.stderr.decode("utf-8", errors="replace").strip() errors.append(f"git status failed ({result.returncode}): {detail}") return {} records = result.stdout.split(b"\0") paths: dict[str, str] = {} index = 0 while index < len(records): record = records[index] index += 1 if not record: continue decoded = record.decode("utf-8", errors="surrogateescape") if len(decoded) < 4 or decoded[2] != " ": errors.append(f"unexpected git porcelain record: {decoded!r}") continue status = decoded[:2] path = decoded[3:].replace("\\", "/") if status[0] in {"R", "C"} or status[1] in {"R", "C"}: if index >= len(records) or not records[index]: errors.append(f"rename/copy record has no source path: {decoded!r}") continue index += 1 paths[path] = "untracked" if status == "??" else "tracked-change" return paths def valid_repo_path(value: Any) -> bool: if not isinstance(value, str) or not value or "\\" in value: return False candidate = PurePosixPath(value) return ( not candidate.is_absolute() and ".." not in candidate.parts and str(candidate) == value ) def flatten_classifications( manifest: dict[str, Any], errors: list[str] ) -> tuple[dict[str, str], Counter[str], dict[str, list[str]]]: classifications = manifest.get("classifications") if not isinstance(classifications, dict): errors.append("classifications must be an object") return {}, Counter(), {} declared: dict[str, str] = {} counts: Counter[str] = Counter() goals_to_paths: dict[str, list[str]] = {goal: [] for goal in EXPECTED_GOALS} for classification in ("related", "mixed", "excluded"): entries = classifications.get(classification) if not isinstance(entries, list): errors.append(f"classifications.{classification} must be an array") continue if classification == "mixed" and not entries: errors.append( "mixed classification must be non-empty in this dirty worktree" ) for entry_index, entry in enumerate(entries): label = f"classifications.{classification}[{entry_index}]" if not isinstance(entry, dict): errors.append(f"{label} must be an object") continue if entry.get("disposition") != EXPECTED_DISPOSITIONS[classification]: errors.append( f"{label}.disposition must be " f"{EXPECTED_DISPOSITIONS[classification]!r}" ) rationale = entry.get("rationale") if not isinstance(rationale, str) or not rationale.strip(): errors.append(f"{label}.rationale must be non-empty") goals = entry.get("goals", []) if not isinstance(goals, list) or any( not isinstance(goal, str) for goal in goals ): errors.append(f"{label}.goals must be an array of strings") goals = [] goal_set = set(goals) if classification in {"related", "mixed"} and not goal_set: errors.append( f"{label}.goals must identify G0-G8 or release-governance" ) if classification == "excluded" and goal_set: errors.append(f"{label}.goals must be empty for unrelated work") unknown_goals = goal_set - ALLOWED_RELATED_GOALS if unknown_goals: errors.append( f"{label}.goals contains unknown values: {sorted(unknown_goals)}" ) if classification == "mixed": if entry.get("release_eligible") is not False: errors.append(f"{label}.release_eligible must be false") required_action = entry.get("required_action") if not isinstance(required_action, str) or not required_action.strip(): errors.append(f"{label}.required_action must be non-empty") paths_value = entry.get("paths") if not isinstance(paths_value, list) or not paths_value: errors.append(f"{label}.paths must be a non-empty array") continue for path_index, path in enumerate(paths_value): if not valid_repo_path(path): errors.append( f"{label}.paths[{path_index}] is not a canonical repo path: {path!r}" ) continue if path in declared: errors.append( f"duplicate path {path!r}: {declared[path]} and {classification}" ) continue declared[path] = classification counts[classification] += 1 for goal in goal_set & EXPECTED_GOALS: goals_to_paths[goal].append(path) return declared, counts, goals_to_paths def validate_manifest(manifest: dict[str, Any], errors: list[str]) -> dict[str, Any]: if manifest.get("schema_version") != "1.0": errors.append("schema_version must be '1.0'") if manifest.get("manifest_id") != "outcome-os-clean-release-2026-08-07": errors.append("manifest_id is not canonical") scope = manifest.get("scope") if not isinstance(scope, dict): errors.append("scope must be an object") scope = {} if set(scope.get("goals", [])) != EXPECTED_GOALS: errors.append("scope.goals must contain exactly G0-G8") if scope.get("release_readiness") not in {"blocked", "ready"}: errors.append("scope.release_readiness must be 'blocked' or 'ready'") if scope.get("selection_mode") != "fail-closed": errors.append("scope.selection_mode must be 'fail-closed'") declared, counts, goals_to_paths = flatten_classifications(manifest, errors) for goal, paths in sorted(goals_to_paths.items()): if not paths: errors.append(f"{goal} has no related or mixed file coverage") current = git_dirty_paths(errors) missing = sorted(set(current) - set(declared)) stale = sorted(set(declared) - set(current)) if missing: errors.append("unclassified dirty paths: " + ", ".join(missing)) if stale: errors.append("manifest paths not currently dirty: " + ", ".join(stale)) for path, classification in declared.items(): if classification != "excluded" and not (REPO_ROOT / path).exists(): errors.append(f"declared {classification} path does not exist: {path}") captured = manifest.get("captured_status") if not isinstance(captured, dict): errors.append("captured_status must be an object") captured = {} expected_counts = { "dirty_files": len(declared), "related": counts["related"], "mixed": counts["mixed"], "excluded": counts["excluded"], } for key, value in expected_counts.items(): if captured.get(key) != value: errors.append(f"captured_status.{key} must be {value}") gates = manifest.get("release_gates") if not isinstance(gates, list): errors.append("release_gates must be an array") gates = [] gate_map = { gate.get("id"): gate for gate in gates if isinstance(gate, dict) and isinstance(gate.get("id"), str) } g7_gate = gate_map.get(G7_GATE_ID) if g7_gate is None: errors.append(f"required release gate missing: {G7_GATE_ID}") g7_status = "missing" else: g7_status = g7_gate.get("status") if g7_status not in G7_ALLOWED_STATUSES: errors.append( f"release gate {G7_GATE_ID} must be one of " f"{sorted(G7_ALLOWED_STATUSES)!r}" ) if g7_status == "satisfied-by-validated-g7-proof": evidence = g7_gate.get("evidence") if not isinstance(evidence, str) or not evidence.strip(): errors.append(f"release gate {G7_GATE_ID} must record evidence") elif "check-g7-external-proof.py" not in evidence: errors.append( f"release gate {G7_GATE_ID} evidence must name the canonical checker" ) expected_readiness = ( "ready" if g7_status == "satisfied-by-validated-g7-proof" else "blocked" ) if scope.get("release_readiness") != expected_readiness: errors.append( "scope.release_readiness must match the G7 external proof gate: " f"{expected_readiness!r}" ) for gate_id, expected_status in sorted(REQUIRED_SATISFIED_GATES.items()): gate = gate_map.get(gate_id) if gate is None: errors.append(f"required release gate missing: {gate_id}") elif gate.get("status") != expected_status: errors.append(f"release gate {gate_id} must be {expected_status!r}") elif not isinstance(gate.get("evidence"), str) or not gate["evidence"].strip(): errors.append(f"release gate {gate_id} must record non-empty evidence") assembly = manifest.get("release_assembly") if not isinstance(assembly, dict): errors.append("release_assembly must be an object") else: expected_assembly = { "status": "verified", "base_commit": "76d0b9ae9bb11b62af86ccd9a55e798bbedc9e31", "hunk_map": "docs/ops/outcome-os-release-hunk-map-2026-08-07.json", "builder": "scripts/build-outcome-os-release-patch.py", "output_patch": "docs/ops/evidence/outcome-os-release-only-2026-08-07.patch", "git_apply_check": "passed-on-clean-temporary-head", "git_apply_cached_check": "passed-on-canonical-clean-index", "patch_line_endings": "lf-only", "artifact_publish": "atomic-replace-after-all-guards", "source_worktree": "unchanged-except-output-patch", "mixed_whole_files_release_eligible": False, } for key, value in expected_assembly.items(): if assembly.get(key) != value: errors.append(f"release_assembly.{key} must be {value!r}") if assembly.get("deterministic_runs", 0) < 2: errors.append("release_assembly.deterministic_runs must be at least 2") patch_path_value = assembly.get("output_patch") if isinstance(patch_path_value, str): patch_path = REPO_ROOT / patch_path_value if not patch_path.is_file(): errors.append(f"release patch missing: {patch_path_value}") else: patch_bytes = patch_path.read_bytes() actual_hash = hashlib.sha256(patch_bytes).hexdigest() if assembly.get("patch_sha256") != actual_hash: errors.append( "release_assembly.patch_sha256 does not match output patch" ) if assembly.get("patch_bytes") != len(patch_bytes): errors.append( f"release_assembly.patch_bytes must be {len(patch_bytes)}" ) actual_patch_files = count_patch_files(patch_bytes) if assembly.get("patch_files") != actual_patch_files: errors.append( f"release_assembly.patch_files must be {actual_patch_files}" ) if b"\r\n" in patch_bytes or b"\r" in patch_bytes: errors.append( "release patch must use canonical LF-only line endings" ) base_commit = assembly.get("base_commit") if isinstance(base_commit, str): verify_patch_against_base(patch_path, base_commit, errors) validate_satisfied_gate_evidence(gate_map, assembly, errors) verification = manifest.get("verification") if not isinstance(verification, dict): errors.append("verification must be an object") else: if ( verification.get("command") != "py -3.11 -X utf8 scripts/check-outcome-os-release-manifest.py --json" ): errors.append("verification.command is not canonical") if verification.get("result") != "passed": errors.append("verification.result must record 'passed'") return { "manifest": str(DEFAULT_MANIFEST.relative_to(REPO_ROOT)).replace("\\", "/"), "dirty_files": len(current), "classified_files": len(declared), "counts": dict(sorted(counts.items())), "blocking_gates": ( [G7_GATE_ID] if g7_status != "satisfied-by-validated-g7-proof" else [] ), "satisfied_gates": dict(sorted(REQUIRED_SATISFIED_GATES.items())), "errors": errors, } def main() -> int: args = parse_args() errors: list[str] = [] manifest_path = args.manifest if not manifest_path.is_absolute(): manifest_path = REPO_ROOT / manifest_path manifest = load_manifest(manifest_path, errors) report = ( validate_manifest(manifest, errors) if manifest else { "manifest": str(manifest_path), "dirty_files": 0, "classified_files": 0, "counts": {}, "blocking_gates": [G7_GATE_ID], "satisfied_gates": dict(sorted(REQUIRED_SATISFIED_GATES.items())), "errors": errors, } ) report["ok"] = not errors if args.json: print(json.dumps(report, ensure_ascii=False, indent=2, sort_keys=True)) elif errors: print("FAIL outcome-os release manifest", file=sys.stderr) for error in errors: print(f"- {error}", file=sys.stderr) else: counts = report["counts"] print( "PASS outcome-os release manifest: " f"{report['classified_files']} dirty files covered " f"(related={counts.get('related', 0)}, mixed={counts.get('mixed', 0)}, " f"excluded={counts.get('excluded', 0)}); release remains blocked" ) return 0 if not errors else 1 if __name__ == "__main__": raise SystemExit(main())