G0~G8 성과·동맹 측정 OS 작업 일괄 고정
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 산출물은 커밋에서 제외했다.
This commit is contained in:
parent
93dd8f82d7
commit
16e791e044
390 changed files with 243188 additions and 499 deletions
502
scripts/check-outcome-os-release-manifest.py
Normal file
502
scripts/check-outcome-os-release-manifest.py
Normal file
|
|
@ -0,0 +1,502 @@
|
|||
#!/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())
|
||||
Loading…
Add table
Add a link
Reference in a new issue