#!/usr/bin/env python3 """Plan and atomically apply project-first vault authority transitions.""" from __future__ import annotations import argparse import copy import hashlib import json import os from pathlib import Path import re import shutil import subprocess import sys import tempfile from typing import Any, Iterable, Mapping from contract_markdown import parse_frontmatter from fs_transaction import ReplacementValue, SymlinkValue, replace_many import layout_check DEFAULT_ROOT = Path(__file__).resolve().parents[2] DEFAULT_LAYOUT = Path("harness/source/vault-layout.json") DEFAULT_RELATIONS = Path("harness/source/document-relations.json") SCHEMA_VERSION = "vault-migration-plan/v1" RESULT_SCHEMA = "vault-migration-result/v1" HEX_SHA256 = re.compile(r"^[0-9a-f]{64}$") # Obsidian table cells commonly escape the alias separator as ``\|``. Keep # the separator in its own group so the target lookup never receives the # trailing escape character and preserve the author's original spelling when # replacing only the authority path. WIKILINK = re.compile(r"\[\[([^\]|#]+?)(#[^\]|]+)?((?:\\)?\|[^\]]+)?\]\]") class MigrationError(ValueError): def __init__(self, code: str, message: str, location: str = "") -> None: self.code = code self.location = location super().__init__(message) class MigrationPlan(dict[Path, ReplacementValue]): def __init__( self, values: Mapping[Path, ReplacementValue], *, expected: Mapping[Path, str | None], forbidden: Iterable[Path], result: Mapping[str, Any], ) -> None: super().__init__(values) self.expected = dict(expected) self.forbidden = set(forbidden) self.result = dict(result) def _sha256(data: bytes) -> str: return hashlib.sha256(data).hexdigest() def _lexical_absolute(path: Path) -> Path: """Return an absolute path without following a compatibility symlink.""" return Path(os.path.abspath(path)) def _entry_fingerprint(path: Path) -> str | None: if path.is_symlink(): return _sha256(("symlink\0" + os.readlink(path)).encode("utf-8")) if path.is_file(): return _sha256(path.read_bytes()) return None def _replacement_fingerprint(value: ReplacementValue) -> str: if isinstance(value, SymlinkValue): return _sha256(("symlink\0" + value.target).encode("utf-8")) return _sha256(value) def _config_path(root: Path, value: Path) -> Path: return value if value.is_absolute() else root / value def _load_json(path: Path, schema: str) -> dict[str, Any]: try: document = json.loads(path.read_text(encoding="utf-8")) except (OSError, UnicodeError, json.JSONDecodeError) as exc: raise MigrationError("CONFIG_IO_ERROR", str(exc), str(path)) from exc if not isinstance(document, dict) or document.get("schema_version") != schema: raise MigrationError("CONFIG_SCHEMA_MISMATCH", f"expected {schema}", str(path)) return document def _document_bytes(document: Mapping[str, Any]) -> bytes: return (json.dumps(document, ensure_ascii=False, indent=2, sort_keys=True) + "\n").encode("utf-8") def _assignments(config: Mapping[str, Any]) -> dict[str, str]: areas = config.get("areas") if not isinstance(areas, dict): raise MigrationError("INVALID_LAYOUT_SCHEMA", "areas must be an object", "areas") result: dict[str, str] = {} for area, roots in areas.items(): if not isinstance(area, str) or not isinstance(roots, list): raise MigrationError("INVALID_LAYOUT_SCHEMA", "area roots must be arrays", f"areas.{area}") for raw_root in roots: source = Path(str(raw_root)).as_posix().rstrip("/") if source in result: raise MigrationError("DUPLICATE_LAYOUT_OWNER", source, source) result[source] = area return result def _content_paths(root: Path, assignments: Mapping[str, str], overrides: Mapping[Path, bytes]) -> set[Path]: paths: set[Path] = set() for source in assignments: base = root / source if base.is_dir(): paths.update(path for path in base.rglob("*") if path.is_file() and path.name != ".gitkeep") paths.update(path for path in overrides if path.name != ".gitkeep") return paths def _source_owner(root: Path, legacy: Path, assignments: Mapping[str, str]) -> tuple[str, Path]: owners: list[tuple[int, str, Path]] = [] for source, area in assignments.items(): base = (root / source).resolve() try: legacy.resolve().relative_to(base) except ValueError: continue owners.append((len(base.parts), area, base)) if len(owners) != 1: relative = legacy.relative_to(root).as_posix() code = "MISSING_LAYOUT_OWNER" if not owners else "AMBIGUOUS_LAYOUT_OWNER" raise MigrationError(code, f"expected one assigned source root, observed {len(owners)}", relative) _length, area, base = owners[0] return area, base def _mapping_policy(config: Mapping[str, Any]) -> dict[str, str]: policy = config.get("canonical_mapping") expected = { "schema_version": "project-first-paths/v1", "project_relation": "branch-to-project", "project_member_pattern": "{vault_root}/{area}/{project}/{category}/{relative_path}", "default_pattern": "{vault_root}/{area}/{category}/{relative_path}", } if policy != expected: raise MigrationError( "INVALID_CANONICAL_MAPPING_POLICY", "canonical_mapping must declare the project-first-paths/v1 deterministic patterns", "canonical_mapping", ) return expected def _project_contract(relations: Mapping[str, Any], relation_id: str) -> tuple[set[str], dict[str, str]]: project_roots: set[str] = set() members: dict[str, str] = {} rows = relations.get("relations") if not isinstance(rows, list): raise MigrationError("INVALID_RELATION_SCHEMA", "relations must be an array", "relations") for row in rows: if not isinstance(row, dict): continue if row.get("id") != relation_id: continue parent_roots = row.get("parent_roots") child_roots = row.get("child_roots") field = row.get("parent_field") if not isinstance(parent_roots, list) or not isinstance(child_roots, list) or not isinstance(field, str): raise MigrationError("INVALID_RELATION_SCHEMA", "branch-to-project relation is incomplete") project_roots.update(Path(str(value)).as_posix().rstrip("/") for value in parent_roots) for child in child_roots: source = Path(str(child)).as_posix().rstrip("/") if source in members and members[source] != field: raise MigrationError("AMBIGUOUS_PROJECT_RELATION", source, source) members[source] = field if not project_roots or not members: raise MigrationError("PROJECT_RELATION_MISSING", f"{relation_id} relation is required") return project_roots, members def _frontmatter_for(path: Path, overrides: Mapping[Path, bytes]) -> dict[str, Any]: try: data = overrides[path] if path in overrides else path.read_bytes() return parse_frontmatter(data.decode("utf-8")) except (OSError, UnicodeError) as exc: raise MigrationError("PROJECT_METADATA_UNREADABLE", str(exc), str(path)) from exc def _canonical_path( root: Path, legacy: Path, config: Mapping[str, Any], assignments: Mapping[str, str], project_roots: set[str], project_members: Mapping[str, str], overrides: Mapping[Path, bytes], ) -> Path: area, source_base = _source_owner(root, legacy, assignments) source = source_base.relative_to(root).as_posix() relative = legacy.relative_to(source_base) vault = root / str(config.get("vault_root", "vault")) / area category_parts = Path(source).parts if category_parts and category_parts[0] in {"raw", "wiki"}: category_parts = category_parts[1:] category = Path(*category_parts) project: str | None = None if source in project_roots: if relative.parent != Path(".") or legacy.suffix != ".md": raise MigrationError("INVALID_PROJECT_OWNER_PATH", "project notes must be top-level Markdown files", legacy.relative_to(root).as_posix()) project = legacy.stem elif source in project_members: metadata = _frontmatter_for(legacy, overrides) raw_project = metadata.get(project_members[source]) if not isinstance(raw_project, str) or not re.fullmatch(r"[a-z0-9]+(?:-[a-z0-9]+)*", raw_project): raise MigrationError("PROJECT_OWNER_MISSING", f"{project_members[source]} must name exactly one project", legacy.relative_to(root).as_posix()) project = raw_project if not any((root / parent_root / f"{project}.md").is_file() for parent_root in project_roots): raise MigrationError("PROJECT_OWNER_NOT_FOUND", project, legacy.relative_to(root).as_posix()) if project is not None: return vault / project / category / relative return vault / category / relative def build_mapping( root: Path, config: Mapping[str, Any], relations: Mapping[str, Any], *, overrides: Mapping[Path, bytes] | None = None, ) -> dict[Path, Path]: """Derive one canonical owner for every configured content file.""" root = root.resolve() overrides = {path.resolve(): data for path, data in (overrides or {}).items()} assignments = _assignments(config) policy = _mapping_policy(config) project_roots, project_members = _project_contract(relations, policy["project_relation"]) mapping: dict[Path, Path] = {} reverse: dict[Path, Path] = {} for legacy in sorted(_content_paths(root, assignments, overrides)): legacy = legacy.resolve() canonical = _canonical_path( root, legacy, config, assignments, project_roots, project_members, overrides, ).resolve() if canonical in reverse: first = reverse[canonical].relative_to(root).as_posix() second = legacy.relative_to(root).as_posix() raise MigrationError("CANONICAL_PATH_COLLISION", f"{first}, {second}", canonical.relative_to(root).as_posix()) mapping[legacy] = canonical reverse[canonical] = legacy return mapping def _embedded_documents(config: dict[str, Any]) -> tuple[dict[str, Any], dict[str, Any]]: migration = config.get("migration_manifest") rollback = config.get("rollback_mapping") if not isinstance(migration, dict) or not isinstance(rollback, dict): raise MigrationError("EXTERNAL_CUTOVER_MANIFEST_UNSUPPORTED", "writer updates require embedded migration and rollback documents") if migration.get("schema_version") != "vault-migration/v1" or rollback.get("schema_version") != "vault-rollback/v1": raise MigrationError("INVALID_CUTOVER_SCHEMA", "invalid embedded migration or rollback schema") return migration, rollback def _entries( mapping: Mapping[Path, Path], root: Path, payloads: Mapping[Path, ReplacementValue], ) -> tuple[list[dict[str, str]], list[dict[str, str]]]: migration: list[dict[str, str]] = [] rollback: list[dict[str, str]] = [] for legacy, canonical in sorted(mapping.items(), key=lambda item: item[0].relative_to(root).as_posix()): content = payloads.get(canonical) if isinstance(content, SymlinkValue): raise MigrationError( "CANONICAL_OWNER_SYMLINK", "canonical owner must contain bytes", canonical.relative_to(root).as_posix(), ) if content is None: if not canonical.is_file(): raise MigrationError("CANONICAL_CONTENT_MISSING", "canonical owner does not exist", canonical.relative_to(root).as_posix()) content = canonical.read_bytes() migration.append({ "legacy_path": legacy.relative_to(root).as_posix(), "canonical_path": canonical.relative_to(root).as_posix(), "sha256": _sha256(content), }) rollback.append({ "canonical_path": canonical.relative_to(root).as_posix(), "legacy_path": legacy.relative_to(root).as_posix(), }) return migration, rollback def _configured_mapping(root: Path, config: Mapping[str, Any]) -> dict[Path, Path]: migration = config.get("migration_manifest") if not isinstance(migration, dict) or migration.get("schema_version") != "vault-migration/v1": raise MigrationError("INVALID_CUTOVER_SCHEMA", "embedded migration manifest required") rows = migration.get("entries") if not isinstance(rows, list): raise MigrationError("INVALID_CUTOVER_SCHEMA", "migration entries must be an array") result: dict[Path, Path] = {} for index, row in enumerate(rows): if not isinstance(row, dict) or set(row) != {"legacy_path", "canonical_path", "sha256"}: raise MigrationError("INVALID_MIGRATION_ENTRY", str(index)) legacy = _lexical_absolute(root / str(row["legacy_path"])) canonical = (root / str(row["canonical_path"])).resolve() try: legacy.relative_to(root) canonical.relative_to(root) except ValueError as exc: raise MigrationError("INVALID_MIGRATION_PATH", str(index)) from exc if legacy in result: raise MigrationError("DUPLICATE_LEGACY_OWNER", row["legacy_path"]) result[legacy] = canonical return result def expand_authoritative_changes( root: Path, changes: Mapping[Path, bytes], *, layout_path: Path = DEFAULT_LAYOUT, relations_path: Path = DEFAULT_RELATIONS, ) -> tuple[dict[Path, bytes], dict[str, Any]]: """Enforce active roots and add shadow mirrors plus manifest hash updates.""" root = root.resolve(strict=True) normalized = {path.resolve(): data for path, data in changes.items()} try: authority = layout_check.resolve_authority(root, layout_path) except layout_check.LayoutContractError as exc: raise MigrationError(exc.code, str(exc), exc.location) from exc planner_surface: set[Path] = set() if authority["mode"] == "canonical": # 기존 문서는 legacy 경로가 심링크라 위의 resolve() 가 정본으로 번역해 준다. # 신규 문서에는 그 심링크가 아직 없어 raw/… 그대로 남고, write root 집행에 # 걸려 **문서 생성 자체가 막혀 있었다**. 여기서 목적지를 계산해 정본·호환 # 심링크·manifest 2행을 한 트랜잭션으로 만든다(셋 중 하나만 빠져도 layout FAIL). pending = { path: data for path, data in normalized.items() if isinstance(data, bytes) and not path.exists() and not path.is_symlink() and _is_legacy_content_path(root, path, layout_path) } if pending: planned = plan_new_documents( root, pending, layout_path=layout_path, relations_path=relations_path ) normalized = { path: data for path, data in normalized.items() if path not in pending } normalized.update(planned) # write-root 집행 대상은 caller 가 고른 목적지다. planner 가 layout 계약 # (①~③ 동시 성립)을 위해 스스로 도출한 ②호환 심링크(raw/…)와 ③manifest # (harness/source/…)는 canonical write root 밖에 있는 것이 정상이므로 # 집행에서 면제한다 — 면제 없이는 신규 문서 생성 전체가 여기서 # WRITE_ROOT_VIOLATION 으로 죽는다(2026-07-23 branch_from_project 실측). # ①정본(bytes)은 계속 집행 대상이다 — vault 밖이면 여전히 위반. planner_surface = { path for path, value in planned.items() if isinstance(value, SymlinkValue) } planner_surface.add(_config_path(root, layout_path).resolve()) try: layout_check.enforce_write_paths( root, [path for path in normalized if path not in planner_surface], authority, ) except layout_check.LayoutContractError as exc: raise MigrationError(exc.code, str(exc), exc.location) from exc if authority["mode"] != "shadow": return dict(normalized), authority config_path = _config_path(root, layout_path).resolve() config = _load_json(config_path, "vault-layout/v1") relations = _load_json(_config_path(root, relations_path), "document-relations/v1") derived = build_mapping(root, config, relations, overrides=normalized) configured = _configured_mapping(root, config) for legacy, canonical in configured.items(): if derived.get(legacy) != canonical: raise MigrationError("MAPPING_POLICY_DRIFT", canonical.relative_to(root).as_posix(), legacy.relative_to(root).as_posix()) expanded = dict(normalized) for legacy, content in normalized.items(): canonical = derived.get(legacy) if canonical is None: raise MigrationError("SHADOW_MAPPING_MISSING", "write has no canonical mirror", legacy.relative_to(root).as_posix()) if canonical in expanded and expanded[canonical] != content: raise MigrationError("SHADOW_WRITE_CONFLICT", "legacy and mirror bytes differ", canonical.relative_to(root).as_posix()) expanded[canonical] = content updated = copy.deepcopy(config) migration, rollback = _embedded_documents(updated) migration_entries, rollback_entries = _entries(derived, root, expanded) migration["entries"] = migration_entries rollback["entries"] = rollback_entries expanded[config_path] = _document_bytes(updated) authority = dict(authority) authority["control_plane_updates"] = [config_path.relative_to(root).as_posix()] return expanded, authority def _rewrite_links(text: str, root: Path, mapping: Mapping[Path, Path]) -> str: by_value: dict[str, str] = {} for legacy, canonical in mapping.items(): legacy_value = legacy.relative_to(root).as_posix() canonical_value = canonical.relative_to(root).as_posix() by_value[legacy_value] = canonical_value if legacy.suffix == ".md": by_value[legacy_value[:-3]] = canonical_value[:-3] if canonical.suffix == ".md" else canonical_value def replace(match: re.Match[str]) -> str: target, anchor, alias = match.groups() rewritten = by_value.get(target, target) return f"[[{rewritten}{anchor or ''}{alias or ''}]]" return WIKILINK.sub(replace, text) def _stub(legacy: Path, canonical: Path, root: Path) -> bytes: title = legacy.stem canonical_value = canonical.relative_to(root).as_posix() return ( "---\n" f"title: {title} (compatibility stub)\n" "status: stale\n" f"canonical_path: {canonical_value}\n" "---\n\n" f"# {title}\n\n" f"Canonical document: [[{canonical_value[:-3] if canonical_value.endswith('.md') else canonical_value}]]\n" ).encode("utf-8") def _is_legacy_content_path(root: Path, path: Path, layout_path: Path) -> bool: """Is this a legacy content path (raw/… · wiki/…) that a new document could claim?""" try: config = _load_json(_config_path(root, layout_path).resolve(), "vault-layout/v1") assignments = _assignments(config) except MigrationError: return False if path.suffix != ".md": return False for source in assignments: base = _lexical_absolute(root / source) try: path.relative_to(base) except ValueError: continue return True return False def plan_new_documents( root: Path, documents: Mapping[Path, bytes], *, layout_path: Path = DEFAULT_LAYOUT, relations_path: Path = DEFAULT_RELATIONS, ) -> dict[Path, ReplacementValue]: """Batch form of :func:`plan_new_document`. manifest 항목은 누적돼야 한다 — 문서마다 디스크의 config 를 새로 읽으면 두 번째가 첫 번째의 항목을 덮어써 한쪽이 조용히 사라진다. """ root = root.resolve(strict=True) config_path = _config_path(root, layout_path).resolve() config = _load_json(config_path, "vault-layout/v1") changes: dict[Path, ReplacementValue] = {} for legacy, content in sorted(documents.items(), key=lambda item: item[0].as_posix()): planned, config = _plan_new_document( root, legacy, content, config=config, relations_path=relations_path ) changes.update(planned) changes[config_path] = _document_bytes(config) return changes def plan_new_document( root: Path, legacy: Path, content: bytes, *, layout_path: Path = DEFAULT_LAYOUT, relations_path: Path = DEFAULT_RELATIONS, ) -> dict[Path, ReplacementValue]: """Return the atomic change set that creates one new document under canonical authority. canonical 모드에서 문서 하나를 새로 만들려면 세 가지가 *동시에* 있어야 한다 (2026-07-22 실측: 하나라도 빠지면 layout_check 가 FAIL): ① 정본 파일 vault////.md 없으면 MIGRATION_ENTRY_MISSING ② 호환 심링크 raw//.md → 정본 없으면 CANONICAL_OWNER_MISSING ③ manifest 2행 migration_manifest + rollback_mapping 기존 문서가 그냥 되는 건 권한이 있어서가 아니라 ②가 이미 있어서 ``resolve()`` 가 번역기 노릇을 하기 때문이다. 신규 문서는 그 번역기가 없으므로 여기서 목적지를 계산해 준다. ``build_mapping`` 을 그대로 쓰지 못하는 이유는 canonical 모드에서 legacy 가 심링크라 ``legacy.resolve()`` 가 canonical 로 접혀 키가 무너지기 때문이다 — 그래서 신규 경로 하나만 ``_canonical_path`` 로 직접 계산한다. """ root = root.resolve(strict=True) config_path = _config_path(root, layout_path).resolve() config = _load_json(config_path, "vault-layout/v1") changes, updated = _plan_new_document( root, legacy, content, config=config, relations_path=relations_path ) changes[config_path] = _document_bytes(updated) return changes def _plan_new_document( root: Path, legacy: Path, content: bytes, *, config: Mapping[str, Any], relations_path: Path, ) -> tuple[dict[Path, ReplacementValue], dict[str, Any]]: """Compute one document's change set and the manifest it leaves behind.""" legacy = _lexical_absolute(legacy if legacy.is_absolute() else root / legacy) try: legacy_rel = legacy.relative_to(root) except ValueError as exc: raise MigrationError("PATH_OUTSIDE_REPO", "legacy path escapes repository", str(legacy)) from exc if legacy.exists() or legacy.is_symlink(): raise MigrationError("LEGACY_ALREADY_EXISTS", "document already exists", legacy_rel.as_posix()) relations = _load_json(_config_path(root, relations_path), "document-relations/v1") assignments = _assignments(config) policy = _mapping_policy(config) project_roots, project_members = _project_contract(relations, policy["project_relation"]) overrides = {legacy: content} canonical = _canonical_path( root, legacy, config, assignments, project_roots, project_members, overrides ) canonical = _lexical_absolute(canonical) canonical_rel = canonical.relative_to(root) if canonical.exists(): raise MigrationError("CANONICAL_ALREADY_EXISTS", "canonical owner already exists", canonical_rel.as_posix()) updated = copy.deepcopy(config) migration, rollback = _embedded_documents(updated) for document, extra in ((migration, {"sha256": _sha256(content)}), (rollback, {})): rows = document.get("entries") if not isinstance(rows, list): raise MigrationError("INVALID_CUTOVER_SCHEMA", "entries must be an array") rows.append({ "canonical_path": canonical_rel.as_posix(), "legacy_path": legacy_rel.as_posix(), **extra, }) document["entries"] = sorted(rows, key=lambda row: row["canonical_path"]) link_target = os.path.relpath(canonical, start=legacy.parent) return { canonical: content, legacy: SymlinkValue(Path(link_target).as_posix()), }, updated def _stage_repository(root: Path, destination: Path) -> None: ignored = shutil.ignore_patterns(".git", "__pycache__", "*.pyc", ".DS_Store") for child in root.iterdir(): if child.name == ".git": continue target = destination / child.name if child.is_dir(): shutil.copytree(child, target, ignore=ignored, symlinks=True) elif child.is_file(): shutil.copy2(child, target) def _plan_hash( root: Path, current_mode: str, target_mode: str, expected: Mapping[Path, str | None], changes: Mapping[Path, ReplacementValue], authority: Mapping[str, Any], ) -> str: document = { "schema_version": SCHEMA_VERSION, "from_mode": current_mode, "to_mode": target_mode, "active_layout": { "authority": authority["authority"], "manifest_sha256": authority["manifest_sha256"], "mode": authority["mode"], "write_roots": authority["write_roots"], }, "preconditions": [ { "path": path.relative_to(root).as_posix(), "expected_sha256": expected[path], } for path in sorted(expected) ], "writes": [ { "path": path.relative_to(root).as_posix(), "kind": "symlink" if isinstance(changes[path], SymlinkValue) else "bytes", "sha256": _replacement_fingerprint(changes[path]), "expected_sha256": expected[path], } for path in sorted(changes) ], } return _sha256(_document_bytes(document)) def _validate_stage( stage: Path, layout_path: Path, target_mode: str, *, run_release_gate: bool = True, ) -> None: result = layout_check.check_layout(stage, layout_path) if result.get("status") != "PASS": codes = ",".join(sorted({item.get("code", "UNKNOWN") for item in result.get("findings", [])})) sample = json.dumps(result.get("findings", [])[:20], ensure_ascii=False, sort_keys=True) raise MigrationError("LAYOUT_POSTFLIGHT_FAILED", f"{codes}; sample={sample}") release_gate = stage / "harness/runtime/release_gate.py" if run_release_gate and release_gate.is_file(): level = "r3" if target_mode == "canonical" else "r2" completed = subprocess.run( [sys.executable, str(release_gate), "--root", str(stage), "--level", level], check=False, capture_output=True, text=True, ) if completed.returncode != 0: try: release_result = json.loads(completed.stdout) nonpass = [ { "name": item.get("name"), "status": item.get("status"), "exit_code": item.get("exit_code"), } for item in release_result.get("checks", []) if item.get("status") not in {"PASS", "SKIP"} ] detail = json.dumps( { "summary": release_result.get("summary"), "nonpass": nonpass, }, ensure_ascii=False, sort_keys=True, ) except (json.JSONDecodeError, AttributeError): detail = completed.stdout[-4000:] raise MigrationError(f"{level.upper()}_POSTFLIGHT_FAILED", detail) def prepare( root: Path, target_mode: str, *, layout_path: Path = DEFAULT_LAYOUT, relations_path: Path = DEFAULT_RELATIONS, ) -> MigrationPlan: root = root.resolve(strict=True) if target_mode not in {"shadow", "canonical"}: raise MigrationError("INVALID_TARGET_MODE", "target mode must be shadow or canonical") try: # A shadow tree is expected to become temporarily stale when the # authoritative legacy corpus gains a document or changes outside a # harness-aware writer. Permit only that repairable class of drift # for an explicit shadow refresh; every other transition remains # fail-closed on a clean layout. requested_config = _load_json(_config_path(root, layout_path), "vault-layout/v1") requested_mode = requested_config.get("mode") refresh_shadow = requested_mode == "shadow" and target_mode == "shadow" authority = layout_check.resolve_authority(root, layout_path, require_clean=not refresh_shadow) if refresh_shadow: layout_result = layout_check.check_layout(root, layout_path) recoverable = {"MIGRATION_ENTRY_MISSING", "SHADOW_MIRROR_DRIFT", "MIGRATION_HASH_MISMATCH"} observed = {str(item.get("code")) for item in layout_result.get("findings", [])} unsupported = sorted(observed - recoverable) if unsupported: raise MigrationError( "LAYOUT_NOT_REFRESHABLE", ",".join(unsupported), str(layout_path), ) except layout_check.LayoutContractError as exc: raise MigrationError(exc.code, str(exc), exc.location) from exc current_mode = authority["mode"] if (current_mode, target_mode) not in { ("compatibility", "shadow"), ("shadow", "shadow"), ("shadow", "canonical"), ("canonical", "shadow"), }: raise MigrationError("INVALID_MODE_TRANSITION", f"{current_mode} -> {target_mode}") config_path = _config_path(root, layout_path).resolve() config = _load_json(config_path, "vault-layout/v1") relations = _load_json(_config_path(root, relations_path), "document-relations/v1") configured = _configured_mapping(root, config) derived: dict[Path, Path] = {} changes: dict[Path, ReplacementValue] = {} if current_mode == "compatibility" or (current_mode, target_mode) == ("shadow", "shadow"): derived = build_mapping(root, config, relations) mapping = derived for legacy, canonical in mapping.items(): changes[canonical] = legacy.read_bytes() else: if current_mode == "shadow": derived = build_mapping(root, config, relations) if configured != derived: raise MigrationError("MAPPING_POLICY_DRIFT", "configured migration mapping differs from deterministic project-first mapping") mapping = configured if current_mode == "shadow" and target_mode == "canonical": for legacy, canonical in mapping.items(): # Preserve document bytes across an authority-only cutover. # A compatibility symlink keeps external wikilinks and # repository-owned readers of rules/templates functional, # while write-root enforcement still rejects old-path writes. # Because bytes do not change, semantic certificates remain # current under their stable logical (legacy) subject IDs. changes[canonical] = legacy.read_bytes() relative_target = os.path.relpath(canonical, start=legacy.parent) changes[legacy] = SymlinkValue(Path(relative_target).as_posix()) elif current_mode == "canonical" and target_mode == "shadow": for legacy, canonical in mapping.items(): changes[legacy] = canonical.read_bytes() updated = copy.deepcopy(config) updated["mode"] = target_mode migration, rollback = _embedded_documents(updated) migration_entries, rollback_entries = _entries(mapping, root, changes) migration["entries"] = migration_entries rollback["entries"] = rollback_entries changes[config_path] = _document_bytes(updated) expected = {path: _entry_fingerprint(path) for path in changes} for legacy in mapping: expected.setdefault(legacy, _entry_fingerprint(legacy)) relations_file = _config_path(root, relations_path).resolve() expected.setdefault(relations_file, _entry_fingerprint(relations_file)) forbidden = {path for path in changes if expected[path] is None} with tempfile.TemporaryDirectory(prefix="vault-migration-stage-") as directory: stage = Path(directory) / "repo" stage.mkdir() _stage_repository(root, stage) for path, content in changes.items(): staged = stage / path.relative_to(root) staged.parent.mkdir(parents=True, exist_ok=True) if isinstance(content, SymlinkValue): staged.unlink(missing_ok=True) os.symlink(content.target, staged) else: # 실제 apply 는 replace_many(follow_symlinks=False) 로 *경로 자체* 를 실파일로 # 만든다(롤백 시 심링크→실파일). 스테이지는 심링크를 보존(_stage_repository # symlinks=True)하므로, 심링크 위에 write_bytes 하면 정본으로 write-through 돼 # 스테이지가 실제 apply 와 어긋난다(롤백 후에도 legacy 가 심링크로 남는 것처럼 # 보임). 링크를 먼저 끊어 apply 의미를 그대로 재현한다. if staged.is_symlink(): staged.unlink() staged.write_bytes(content) _validate_stage( stage, layout_path, target_mode, # A same-mode refresh only restores the shadow invariant. R2 is # still the mandatory prerequisite for the later canonical # transition, but must not make the repair operation depend on # unrelated semantic certificates or corpus-level gates. run_release_gate=(current_mode, target_mode) != ("shadow", "shadow"), ) plan_sha256 = _plan_hash(root, current_mode, target_mode, expected, changes, authority) result = { "from_mode": current_mode, "to_mode": target_mode, "plan_sha256": plan_sha256, "migration_entries": len(mapping), "changed_paths": [path.relative_to(root).as_posix() for path in sorted(changes)], "rollback_mapping_complete": True, } return MigrationPlan(changes, expected=expected, forbidden=forbidden, result=result) def apply(plan: MigrationPlan) -> None: for path, expected in plan.expected.items(): observed = _entry_fingerprint(path) if observed != expected: raise MigrationError("CONCURRENT_MODIFICATION", f"expected {expected}, observed {observed}", str(path)) # cutover/rollback 은 legacy 경로의 *엔트리 종류 자체* 를 바꾸는 것이 목적이다 # (실파일 → 심링크, 롤백 시 심링크 → 실파일). 여기서 심링크를 따라가면 롤백이 # 정본만 덮어쓰고 심링크는 남겨 마이그레이션이 성립하지 않는다. replace_many(plan, must_not_exist=plan.forbidden, follow_symlinks=False) def _emit(document: Mapping[str, Any]) -> None: json.dump(document, sys.stdout, ensure_ascii=False, indent=2, sort_keys=True) sys.stdout.write("\n") def main(argv: list[str] | None = None) -> int: parser = argparse.ArgumentParser(description=__doc__) parser.add_argument("--root", type=Path, default=DEFAULT_ROOT) parser.add_argument("--layout", type=Path, default=DEFAULT_LAYOUT) parser.add_argument("--relations", type=Path, default=DEFAULT_RELATIONS) parser.add_argument("--to", choices=("shadow", "canonical"), required=True) 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: plan = prepare(args.root, args.to, layout_path=args.layout, relations_path=args.relations) if args.apply: expected = args.expected_plan_sha256 if expected is None: raise MigrationError("EXPECTED_PLAN_SHA256_REQUIRED", "apply requires --expected-plan-sha256") if not HEX_SHA256.fullmatch(expected): raise MigrationError("INVALID_PLAN_SHA256", "expected plan hash must be 64 lowercase hex characters") if expected != plan.result["plan_sha256"]: raise MigrationError("PLAN_HASH_MISMATCH", f"expected {expected}, current {plan.result['plan_sha256']}") apply(plan) _emit({"schema_version": RESULT_SCHEMA, "status": "APPLIED" if args.apply else "DRY_RUN", **plan.result}) return 0 except (MigrationError, layout_check.LayoutContractError) as exc: _emit({ "schema_version": RESULT_SCHEMA, "status": "FAIL", "errors": [{"code": getattr(exc, "code", "MIGRATION_FAILED"), "location": getattr(exc, "location", ""), "message": str(exc)}], }) return 1 except (OSError, UnicodeError, json.JSONDecodeError) as exc: _emit({"schema_version": RESULT_SCHEMA, "status": "ERROR", "errors": [{"code": "IO_ERROR", "location": "", "message": str(exc)}]}) return 2 if __name__ == "__main__": raise SystemExit(main())