8월 7일까지 워킹트리에만 남아 있던 미커밋 작업을 커밋한다. 여러 사본 폴더(worktree·clone)에 흩어져 있던 중간 스냅샷을 정리하기 전에 원본을 git 이력으로 고정하는 것이 목적이다. - contracts/routes/services: measurement, outcome_trajectory, rupture_repair, deliberate_practice, calibration_transfer, supervision_research, multimodal_alliance, continuous_improvement 계열 신규 모듈과 테스트 - infra/db/init: 07~16 마이그레이션(측정 기반~calibration transfer 실행) - apps/web: 세션 리뷰 카드·관리 화면·E2E 스펙 추가 - docs/ops: G0~G8 라이브 통합·배포·롤백 증거 문서와 evidence JSON/PNG - scripts: smoke·ledger·릴리스 에이전트·NAS 프리뷰 운영 스크립트 engine.public 로그 .bak과 apps/web/test-results 산출물은 커밋에서 제외했다.
502 lines
20 KiB
Python
502 lines
20 KiB
Python
#!/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())
|