#!/usr/bin/env python3 """Atomically commit one candidate document and all generated reverse views.""" from __future__ import annotations import argparse import hashlib import json from pathlib import Path import sys import tempfile from typing import Any, Callable, Mapping from fs_transaction import replace_many, stage_repository import contract_projection import layout_check import moc_indexer import proof_manifest import quality_gate import semantic_audit import semantic_certificate import semantic_surface_extractor import typed_contract_check import vault_migrate SCHEMA_VERSION = "document-commit/v1" RESULT_SCHEMA = "document-commit-result/v1" DEFAULT_ROOT = Path(__file__).resolve().parents[2] HEX_SHA256 = proof_manifest.HEX_SHA256 QualityRunner = Callable[..., dict[str, Any]] class DocumentCommitError(ValueError): def __init__(self, code: str, message: str, location: str = "") -> None: self.code = code self.location = location super().__init__(message) class PreparedChanges(dict[Path, bytes]): """Final bytes plus optimistic preconditions for concurrent-edit detection.""" def __init__(self, values: Mapping[Path, bytes], expected: Mapping[Path, str | None]) -> None: super().__init__(values) self.expected = dict(expected) def _projection_quality_result(staged_root: Path) -> dict[str, Any]: updates, result = contract_projection.build_updates(staged_root) current = result.get("status") == "CURRENT" and not updates return { "schema_version": contract_projection.RESULT_SCHEMA, "status": "PASS" if current else "FAIL", "findings": result.get("findings", []), } def _repo_path(root: Path, value: Any, location: str, *, must_exist: bool) -> Path: if not isinstance(value, str) or not value or "\\" in value: raise DocumentCommitError("INVALID_PATH", "path must be a non-empty repo-relative POSIX path", location) candidate = Path(value) if candidate.is_absolute(): raise DocumentCommitError("PATH_OUTSIDE_REPO", "absolute path is not allowed", location) resolved = (root / candidate).resolve() try: resolved.relative_to(root) except ValueError as exc: raise DocumentCommitError("PATH_OUTSIDE_REPO", "path escapes repository root", location) from exc if must_exist and not resolved.is_file(): raise DocumentCommitError("FILE_NOT_FOUND", "file does not exist", location) return resolved def _sha256(value: Any, location: str) -> str: if not isinstance(value, str) or not HEX_SHA256.fullmatch(value): raise DocumentCommitError("INVALID_SHA256", "expected 64 lowercase hexadecimal characters", location) return value def _plan_sha256( root: Path, changes: "PreparedChanges", *, candidate_hash: str, proof_hash: str, target: Path, authority: Mapping[str, Any], ) -> str: document = { "schema_version": "document-commit-plan/v1", "candidate_sha256": candidate_hash, "proof_manifest_sha256": proof_hash, "target": target.relative_to(root).as_posix(), "active_layout": { "authority": authority["authority"], "manifest_sha256": authority["manifest_sha256"], "mode": authority["mode"], "write_roots": authority["write_roots"], }, "writes": [ { "path": path.relative_to(root).as_posix(), "sha256": hashlib.sha256(changes[path]).hexdigest(), "expected_sha256": changes.expected[path], } for path in sorted(changes) ], } payload = (json.dumps(document, ensure_ascii=False, separators=(",", ":"), sort_keys=True) + "\n").encode("utf-8") return hashlib.sha256(payload).hexdigest() def _request_parts(root: Path, request: Any) -> tuple[Path, str, Path, bool, Path, str]: required = {"schema_version", "candidate", "target", "proof_manifest"} if not isinstance(request, dict) or not required.issubset(request) or set(request) - required - {"semantic_audit"}: raise DocumentCommitError("INVALID_REQUEST", "request has missing or unknown top-level fields") if request.get("schema_version") != SCHEMA_VERSION: raise DocumentCommitError("REQUEST_SCHEMA_MISMATCH", f"expected {SCHEMA_VERSION}") candidate = request.get("candidate") target = request.get("target") proof = request.get("proof_manifest") if not isinstance(candidate, dict) or set(candidate) != {"path", "sha256"}: raise DocumentCommitError("INVALID_CANDIDATE", "candidate must contain path and sha256", "candidate") if not isinstance(target, dict) or set(target) != {"path", "must_not_exist"}: raise DocumentCommitError("INVALID_TARGET", "target must contain path and must_not_exist", "target") if not isinstance(proof, dict) or set(proof) != {"path", "sha256"}: raise DocumentCommitError("INVALID_PROOF_MANIFEST", "proof_manifest must contain path and sha256", "proof_manifest") candidate_path = _repo_path(root, candidate.get("path"), "candidate.path", must_exist=True) candidate_hash = _sha256(candidate.get("sha256"), "candidate.sha256") target_path = _repo_path(root, target.get("path"), "target.path", must_exist=False) if target_path.suffix != ".md" or target_path == candidate_path: raise DocumentCommitError("INVALID_TARGET", "target must be a distinct Markdown path", "target.path") if not isinstance(target.get("must_not_exist"), bool): raise DocumentCommitError("INVALID_TARGET", "must_not_exist must be boolean", "target.must_not_exist") proof_path = _repo_path(root, proof.get("path"), "proof_manifest.path", must_exist=True) proof_hash = _sha256(proof.get("sha256"), "proof_manifest.sha256") return candidate_path, candidate_hash, target_path, target["must_not_exist"], proof_path, proof_hash def _semantic_parts(root: Path, request: Mapping[str, Any], run_root: Path | None) -> tuple[Path, str, Path, str] | None: value = request.get("semantic_audit") if value is None: return None if not isinstance(value, dict) or set(value) != {"request", "result"}: raise DocumentCommitError("INVALID_SEMANTIC_AUDIT", "semantic_audit must contain request and result", "semantic_audit") parsed: list[Path | str] = [] for key in ("request", "result"): reference = value.get(key) if not isinstance(reference, dict) or set(reference) not in ({"path", "sha256"}, {"namespace", "path", "sha256"}): raise DocumentCommitError("INVALID_SEMANTIC_AUDIT", f"semantic_audit.{key} must contain namespace/path/sha256", f"semantic_audit.{key}") namespace = reference.get("namespace", "repo") if namespace not in {"repo", "run"}: raise DocumentCommitError("INVALID_SEMANTIC_AUDIT", "semantic audit namespace must be repo or run", f"semantic_audit.{key}.namespace") source_root = root if namespace == "repo" else run_root if source_root is None: raise DocumentCommitError("SEMANTIC_RUN_ROOT_REQUIRED", "run namespace requires semantic_run_root", f"semantic_audit.{key}.namespace") parsed.extend(( _repo_path(source_root, reference.get("path"), f"semantic_audit.{key}.path", must_exist=True), _sha256(reference.get("sha256"), f"semantic_audit.{key}.sha256"), )) return parsed[0], parsed[1], parsed[2], parsed[3] # type: ignore[return-value] def _verify_hash(path: Path, expected: str, code: str, location: str) -> bytes: content = path.read_bytes() observed = hashlib.sha256(content).hexdigest() if observed != expected: raise DocumentCommitError(code, f"expected {expected}, observed {observed}", location) return content def _verify_proof(path: Path, expected_hash: str, root: Path, profiles_path: Path) -> dict[str, Any]: content = _verify_hash(path, expected_hash, "PROOF_MANIFEST_HASH_MISMATCH", "proof_manifest.sha256") try: manifest = json.loads(content.decode("utf-8")) except (UnicodeError, json.JSONDecodeError) as exc: raise DocumentCommitError("INVALID_PROOF_MANIFEST", str(exc), "proof_manifest.path") from exc verification = manifest.get("verification") if isinstance(manifest, dict) else None if not isinstance(verification, dict) or verification.get("status") != "PASS" or verification.get("fail_count") != 0: raise DocumentCommitError("PROOF_NOT_PASS", "persisted proof manifest must have PASS and fail_count=0") profiles = proof_manifest.load_allowed_profiles(profiles_path.resolve(strict=True)) try: verified = proof_manifest.verify_manifest(manifest, root, profiles) except proof_manifest.ManifestValidationError as exc: codes = ",".join(sorted({issue["code"] for issue in exc.issues})) raise DocumentCommitError("PROOF_REVALIDATION_FAILED", codes, "proof_manifest.path") from exc if verified["verification"]["fail_count"] != 0: raise DocumentCommitError("PROOF_NOT_PASS", "failed proofs block document completion") return verified def _stage_repository(root: Path, destination: Path) -> None: stage_repository(root, destination) def prepare( root: Path, request: Any, *, profiles_path: Path = proof_manifest.DEFAULT_PROFILES, relations_path: Path = moc_indexer.DEFAULT_RELATIONS, layout_path: Path = layout_check.DEFAULT_MANIFEST, quality_runner: QualityRunner = quality_gate.run, semantic_run_root: Path | None = None, ) -> tuple[PreparedChanges, dict[str, Any], set[Path]]: """Prepare verified final bytes without changing the repository.""" root = root.resolve(strict=True) candidate, candidate_hash, target, must_not_exist, proof_path, proof_hash = _request_parts(root, request) # 정체성 판정(TARGET_EXISTS 위치 표기·subject 대조·staging·touched/report)은 심링크를 # 따라가기 *전* 논리 경로로 한다 — canonical cutover 에서 target 을 resolve() 하면 # vault 철자가 되어 인증서의 logical(raw/…) subject 와 영구 mismatch 였다 # (branch_contract_check 가 고친 pre-resolve 규율의 쌍둥이, 2026-07-23 실측). # resolve() 된 `target` 은 실제 바이트가 쓰일 목적지로만 쓴다. logical_target = layout_check._lexical_absolute(root / Path(str(request["target"]["path"]))) logical_rel = logical_target.relative_to(root).as_posix() resolved_run_root = semantic_run_root.resolve(strict=True) if semantic_run_root is not None else None semantic_parts = _semantic_parts(root, request, resolved_run_root) try: authority = layout_check.resolve_authority(root, layout_path) enforcement_targets = [target] if ( authority["mode"] == "canonical" and not logical_target.exists() and not logical_target.is_symlink() and vault_migrate._is_legacy_content_path(root, logical_target, layout_path) ): # 신규 legacy 문서 — 목적지·호환 심링크·manifest 는 expand 의 planner 가 # 계산·검증한다(정본은 그 단계에서 계속 write-root 집행 대상). enforcement_targets = [] layout_check.enforce_write_paths(root, enforcement_targets, authority) except layout_check.LayoutContractError as exc: raise DocumentCommitError(exc.code, str(exc), exc.location) from exc if must_not_exist and target.exists(): raise DocumentCommitError("TARGET_EXISTS", "target already exists", logical_rel) candidate_bytes = _verify_hash(candidate, candidate_hash, "CANDIDATE_HASH_MISMATCH", "candidate.sha256") verified_proof = _verify_proof(proof_path, proof_hash, root, profiles_path) with tempfile.TemporaryDirectory(prefix="document-commit-stage-") as directory: stage = Path(directory) / "repo" stage.mkdir() _stage_repository(root, stage) staged_target = stage / logical_rel staged_target.parent.mkdir(parents=True, exist_ok=True) target_original_hash = hashlib.sha256(staged_target.read_bytes()).hexdigest() if staged_target.is_file() else None staged_target.write_bytes(candidate_bytes) moc_updates, moc_stats = moc_indexer.build_updates(stage, relations_path) moc_original_hashes = { path.relative_to(stage).as_posix(): hashlib.sha256(path.read_bytes()).hexdigest() for path in moc_updates } for path, text in moc_updates.items(): path.write_text(text, encoding="utf-8") remaining, _ = moc_indexer.build_updates(stage, relations_path) if remaining: raise DocumentCommitError("MOC_NOT_CONVERGED", "relation indexer did not converge") projection_updates, projection_result = contract_projection.build_updates(stage) if projection_result["status"] == "FAIL": codes = ",".join(sorted({str(item.get("code", "UNKNOWN")) for item in projection_result["findings"]})) raise DocumentCommitError("TYPED_CONTRACT_FAILED", codes) projection_original_hashes = { path.relative_to(stage).as_posix(): hashlib.sha256(path.read_bytes()).hexdigest() for path in projection_updates } for path, text in projection_updates.items(): path.write_text(text, encoding="utf-8") projection_remaining, projection_after = contract_projection.build_updates(stage) if projection_remaining or projection_after["status"] != "CURRENT": raise DocumentCommitError("CONTRACT_PROJECTION_NOT_CONVERGED", "typed projections did not converge") staged_policy = stage / semantic_surface_extractor.DEFAULT_POLICY target_frontmatter = semantic_surface_extractor.parse_frontmatter(staged_target.read_text(encoding="utf-8")) semantic_required = False if str(target_frontmatter.get("source_type", "")) in {"project-note", "branch-note"}: if not staged_policy.is_file(): raise DocumentCommitError("SEMANTIC_POLICY_SOURCE_MISSING", "design-bearing document requires semantic surface policy") policy = semantic_surface_extractor.load_policy(stage) semantic_required = semantic_surface_extractor.is_required(target_frontmatter, policy) certificate_stage_path: Path | None = None certificate_root_path: Path | None = None certificate_original_hash: str | None = None certificate_document: dict[str, Any] | None = None if semantic_parts is not None: audit_request_path, audit_request_hash, audit_result_path, audit_result_hash = semantic_parts request_bytes = _verify_hash(audit_request_path, audit_request_hash, "SEMANTIC_AUDIT_REQUEST_HASH_MISMATCH", "semantic_audit.request.sha256") result_bytes = _verify_hash(audit_result_path, audit_result_hash, "SEMANTIC_AUDIT_RESULT_HASH_MISMATCH", "semantic_audit.result.sha256") try: audit_request = json.loads(request_bytes.decode("utf-8")) audit_result = json.loads(result_bytes.decode("utf-8")) validated_audit = semantic_audit.validate_result( stage, audit_request, audit_result, profiles_path=profiles_path, run_root=resolved_run_root, ) certificate_stage_path, certificate_bytes, certificate_document = semantic_certificate.prepare_certificate( stage, validated_audit, audit_request=audit_request, audit_result=audit_result, run_root=resolved_run_root, ) except ( UnicodeError, json.JSONDecodeError, semantic_audit.SemanticAuditError, semantic_certificate.SemanticCertificateError, proof_manifest.ManifestValidationError, ) as exc: raise DocumentCommitError("SEMANTIC_AUDIT_FAILED", str(exc), "semantic_audit") from exc if certificate_document["subject"] != logical_rel: raise DocumentCommitError("SEMANTIC_AUDIT_SUBJECT_MISMATCH", "semantic audit subject must equal target path", "semantic_audit") if certificate_document["verdict"] != "PASS": raise DocumentCommitError("SEMANTIC_BLOCKING_VERDICT", "semantic audit contains blocking or readiness-blocking findings", "semantic_audit") certificate_stage_path.parent.mkdir(parents=True, exist_ok=True) certificate_stage_path.write_bytes(certificate_bytes) certificate_root_path = root / certificate_stage_path.relative_to(stage) certificate_original_hash = hashlib.sha256(certificate_root_path.read_bytes()).hexdigest() if certificate_root_path.is_file() else None elif semantic_required: raise DocumentCommitError("SEMANTIC_CERTIFICATE_MISSING", "required design-bearing candidate has no semantic audit", logical_rel) touched_rel = {logical_rel} touched_rel.update(path.relative_to(stage).as_posix() for path in moc_updates) touched_rel.update(path.relative_to(stage).as_posix() for path in projection_updates) extensions = [ quality_gate.QualityExtension( "typed-contract", lambda staged: typed_contract_check.check( staged, include_projection=False, ), ), quality_gate.QualityExtension( "contract-projection", _projection_quality_result, ), ] if certificate_stage_path is not None: extensions.append( quality_gate.QualityExtension( "semantic-certificate", lambda staged: semantic_certificate.quality_extension(staged, [staged_target]), ) ) try: gate = quality_runner( stage, sorted(touched_rel), structure_paths=[logical_rel], template_root=root, include_graph=True, require_moc_convergence=True, extensions=extensions, ) except quality_gate.QualityGateError as exc: raise DocumentCommitError("QUALITY_GATE_ERROR", str(exc)) from exc if not isinstance(gate, dict) or gate.get("schema_version") != "quality-gate-result/v1": raise DocumentCommitError("QUALITY_GATE_ERROR", "unexpected quality gate result schema") if gate.get("status") != "PASS": codes = ",".join(sorted({str(item.get("code", "UNKNOWN")) for item in gate.get("findings", [])})) raise DocumentCommitError("QUALITY_GATE_FAILED", codes) raw_changes = {root / rel: (stage / rel).read_bytes() for rel in sorted(touched_rel)} expected_before_expansion = { root / rel: ( target_original_hash if rel == logical_rel else moc_original_hashes.get(rel) if rel in moc_original_hashes else projection_original_hashes.get(rel) ) for rel in sorted(touched_rel) } try: expanded, authority = vault_migrate.expand_authoritative_changes( root, raw_changes, layout_path=layout_path, relations_path=relations_path, ) except vault_migrate.MigrationError as exc: raise DocumentCommitError(exc.code, str(exc), exc.location) from exc if certificate_stage_path is not None and certificate_root_path is not None: expanded[certificate_root_path] = certificate_stage_path.read_bytes() expected_before_expansion[certificate_root_path] = certificate_original_hash expected = { path: expected_before_expansion.get( path, hashlib.sha256(path.read_bytes()).hexdigest() if path.is_file() else None, ) for path in expanded } changes = PreparedChanges(expanded, expected) forbidden = {path for path, digest in changes.expected.items() if digest is None} if must_not_exist else set() plan_sha256 = _plan_sha256( root, changes, candidate_hash=candidate_hash, proof_hash=proof_hash, target=root / logical_rel, authority=authority, ) result = { "target": logical_rel, "candidate_sha256": candidate_hash, "proof_manifest": proof_path.relative_to(root).as_posix(), "proof_manifest_sha256": proof_hash, "proof_count": verified_proof["verification"]["proof_count"], "semantic_certificate": ( certificate_root_path.relative_to(root).as_posix() if certificate_root_path is not None else None ), "relation_edges": moc_stats["canonical_edges"], "changed_paths": [path.relative_to(root).as_posix() for path in sorted(changes)], "active_layout": { "mode": authority["mode"], "authority": authority["authority"], "write_roots": authority["write_roots"], "manifest_sha256": authority["manifest_sha256"], }, "plan_sha256": plan_sha256, } return changes, result, forbidden def commit(changes: PreparedChanges, forbidden: set[Path]) -> None: """Check snapshot preconditions, then perform exactly one multi-file replace.""" for path, expected_hash in changes.expected.items(): if expected_hash is None: if path.exists(): raise DocumentCommitError("CONCURRENT_MODIFICATION", "new target appeared after staging", path.as_posix()) continue if not path.is_file(): raise DocumentCommitError("CONCURRENT_MODIFICATION", "existing target disappeared after staging", path.as_posix()) observed = hashlib.sha256(path.read_bytes()).hexdigest() if observed != expected_hash: raise DocumentCommitError( "CONCURRENT_MODIFICATION", f"expected pre-commit hash {expected_hash}, observed {observed}", path.as_posix(), ) replace_many(changes, must_not_exist=forbidden) def _failure(exc: Exception) -> dict[str, Any]: return { "schema_version": RESULT_SCHEMA, "status": "FAIL", "error": { "code": getattr(exc, "code", "IO_ERROR"), "location": getattr(exc, "location", ""), "message": str(exc), }, } def main(argv: list[str] | None = None) -> int: parser = argparse.ArgumentParser(description=__doc__) parser.add_argument("request", type=Path) parser.add_argument("--root", type=Path, default=DEFAULT_ROOT) parser.add_argument("--profiles", type=Path, default=proof_manifest.DEFAULT_PROFILES) parser.add_argument("--relations", type=Path, default=moc_indexer.DEFAULT_RELATIONS) parser.add_argument("--layout", type=Path, default=layout_check.DEFAULT_MANIFEST) parser.add_argument("--semantic-run-root", type=Path) mode = parser.add_mutually_exclusive_group(required=True) mode.add_argument("--dry-run", action="store_true") mode.add_argument("--apply", action="store_true") parser.add_argument("--expected-plan-sha256") args = parser.parse_args(argv) try: root = args.root.resolve(strict=True) request = json.loads(args.request.read_text(encoding="utf-8")) changes, result, forbidden = prepare( root, request, profiles_path=args.profiles, relations_path=args.relations, layout_path=args.layout, semantic_run_root=args.semantic_run_root, ) if args.apply: if args.expected_plan_sha256 is not None: expected = args.expected_plan_sha256 if not HEX_SHA256.fullmatch(expected): raise DocumentCommitError("INVALID_PLAN_SHA256", "expected plan hash must be 64 lowercase hex characters") if expected != result["plan_sha256"]: raise DocumentCommitError( "PLAN_HASH_MISMATCH", f"expected {expected}, current plan is {result['plan_sha256']}", ) commit(changes, forbidden) json.dump( {"schema_version": RESULT_SCHEMA, "status": "APPLIED" if args.apply else "DRY_RUN", **result}, sys.stdout, ensure_ascii=False, indent=2, sort_keys=True, ) sys.stdout.write("\n") return 0 except (DocumentCommitError, proof_manifest.ManifestValidationError) as exc: json.dump(_failure(exc), sys.stdout, ensure_ascii=False, indent=2, sort_keys=True) sys.stdout.write("\n") return 1 except (OSError, UnicodeError, json.JSONDecodeError) as exc: json.dump(_failure(exc), sys.stdout, ensure_ascii=False, indent=2, sort_keys=True) sys.stdout.write("\n") return 2 if __name__ == "__main__": raise SystemExit(main())