From 30acaf1ba9b42aea7a4986e53d2f5aeba8e6400f Mon Sep 17 00:00:00 2001 From: LoopX Agent <337587101+loopx-agent@users.noreply.github.com> Date: Fri, 16 Oct 2026 18:16:19 +0800 Subject: [PATCH 01/10] fix(backup): discover project routes through the registry owner Signed-off-by: LoopX Agent <337587101+loopx-agent@users.noreply.github.com> --- ...roject_registry_io_classifications_v1.json | 1 - loopx/state_backup.py | 16 +--- tests/test_state_backup.py | 91 +++++++++++++++++++ 3 files changed, 94 insertions(+), 14 deletions(-) diff --git a/loopx/semantics/project_registry_io_classifications_v1.json b/loopx/semantics/project_registry_io_classifications_v1.json index 1bac76d056..7d92e680dc 100644 --- a/loopx/semantics/project_registry_io_classifications_v1.json +++ b/loopx/semantics/project_registry_io_classifications_v1.json @@ -5,7 +5,6 @@ "loopx/control_plane/goals/deletion_service.py::._restore_locked_registry::direct_json_write:atomic_write_json#1": "global_registry_io", "loopx/control_plane/goals/deletion_service.py::._write_deletion::direct_json_read:read_json#1": "global_registry_io", "loopx/control_plane/goals/deletion_service.py::._write_locked_registry::direct_json_write:atomic_write_json#1": "global_registry_io", - "loopx/state_backup.py::._registry_goals::direct_json_read:json.loads#1": "global_registry_io", "loopx/state_migration.py::.legacy_registry_goal_ids::direct_json_read:read_json_object#1": "legacy_registry_source", "loopx/state_migration.py::.migrate_legacy_state::direct_json_read:read_json_object#1": "legacy_registry_source" } diff --git a/loopx/state_backup.py b/loopx/state_backup.py index 050e211b4e..55276937c3 100644 --- a/loopx/state_backup.py +++ b/loopx/state_backup.py @@ -16,7 +16,9 @@ from . import __version__ from .paths import _is_redirected_path, select_default_runtime_root from .presentation.markdown import markdown_scalar +from .registry import registry_goals from .control_plane.effect_runtime import effect_runtime_result +from .control_plane.projects.registry_codec import load_registry from .control_plane.runtime.document_io import fsync_state_directory @@ -53,18 +55,6 @@ def _archive_segment(value: Any, *, fallback: str) -> str: return f"{compact[:48]}-{digest}" -def _registry_goals(path: Path) -> list[dict[str, Any]]: - if not path.exists(): - return [] - payload = json.loads(path.read_text(encoding="utf-8")) - if not isinstance(payload, dict): - raise ValueError(f"{path} must contain a JSON object") - goals = payload.get("goals") - if not isinstance(goals, list): - return [] - return [goal for goal in goals if isinstance(goal, dict) and goal.get("id")] - - def _should_skip(path: Path, exclude_roots: list[Path]) -> bool: resolved = _resolved(path) return any(resolved == root or _is_relative_to(resolved, root) for root in exclude_roots) @@ -183,7 +173,7 @@ def add(key: str, source_path: Path, archive_path: str) -> None: registry_source_registry_included_count = 0 if include_registry_projects or discovery_registry.exists(): try: - goals = _registry_goals(discovery_registry) + goals = registry_goals(load_registry(discovery_registry)) except (OSError, ValueError, json.JSONDecodeError) as exc: goals = [] warnings.append(f"could not discover registry projects from {discovery_registry}: {exc}") diff --git a/tests/test_state_backup.py b/tests/test_state_backup.py index a51e535724..9a332b583d 100644 --- a/tests/test_state_backup.py +++ b/tests/test_state_backup.py @@ -14,6 +14,8 @@ import loopx.state_backup as state_backup from loopx.state_backup import build_state_backup_plan, execute_state_backup_plan +from loopx.control_plane.state_recovery_io import verify_state_backup +from tests.control_plane.canonical_authority_fixture import promoted_create_fixture pytestmark = pytest.mark.usefixtures("isolated_effect_runtime") @@ -205,6 +207,95 @@ def _backup_cli(root: Path) -> list[str]: "--current-project-only", "--execute"] +def _strict_registry(payload: dict, writer: str = "goal_instance_v1") -> list: + canonical = json.dumps(payload, ensure_ascii=False, allow_nan=False, + sort_keys=True, separators=(",", ":")).encode() + return [{"schema_version": "loopx_project_registry_envelope_v1", + "minimum_writer_protocol": writer, + "payload_sha256": "sha256:" + hashlib.sha256(canonical).hexdigest()}, payload] + + +@pytest.mark.parametrize("provider", ["file", "sqlite"]) +@pytest.mark.parametrize("writer", [None, "goal_instance_v1", "future_writer_v99"], + ids=["legacy", "strict-v1", "readable-future-writer"]) +def test_native_backup_retains_external_routes_for_readable_project_registries(tmp_path, provider, writer): + registry, runtime, old_state = promoted_create_fixture(tmp_path, provider=provider) + added = subprocess.run([sys.executable, "-I", "-m", "loopx.cli", "--format", "json", + "--registry", str(registry), "--runtime-root", str(runtime), "todo", "add", + "--goal-id", "goal-a", "--role", "agent", "--claimed-by", "agent-a", + "--operation-id", "retained-work", "--text", "Keep this unfinished commitment"], + capture_output=True, text=True, check=True, timeout=90) + assert json.loads(added.stdout)["ok"] is True + external = tmp_path / "external" + external.mkdir() + state = external / "full-goal.md" + state.write_text(old_state.read_text() + "\n## Original commitments\nCurrent full Goal beyond the stale conventional copy.\n") + source = external / "source-registry.json" + source.write_bytes(registry.read_bytes()) + payload = json.loads(registry.read_text()) + payload["goals"][0].update(state_file="../external/full-goal.md", source_registry=str(source)) + project_registry = tmp_path / "project/.loopx/registry.json" + project_registry.parent.mkdir() + project_registry.write_text(json.dumps(_strict_registry(payload, writer) if writer else payload)) + expected = {"registry_active_state:goal-a": state.read_bytes(), + "registry_source_registry:goal-a": source.read_bytes(), + "configuration_source_registry": project_registry.read_bytes()} + completed = subprocess.run(_backup_cli(tmp_path), capture_output=True, text=True, check=True, timeout=90) + backup = json.loads(completed.stdout) + assert backup["ok"] is True + captured = {row["key"]: row["archive_path"] for row in backup["included"]} + assert expected.keys() <= captured.keys(), backup["warnings"] + for name in ("project", "runtime", "external", "registry.json"): + (tmp_path / name).rename(tmp_path / f"unavailable-{name}") + destination = tmp_path / "inert" + audit = verify_state_backup(archive=Path(backup["archive_path"]), manifest=Path(backup["manifest_path"]), + destination=destination, execute=True) + assert audit["status"] == "held" and audit["activation_eligible"] is False + assert audit["execution_authority_granted"] is False + assert audit["workspace"] == {"isolation": "inert", "registered": False} + [authority] = [row for row in audit["components"] if row["owner_kind"] == "coordination_authority"] + assert authority["state"] == "readable", authority + assert authority["facts"]["provider"] == provider + assert authority["facts"]["todo_count"] == 1 and authority["facts"]["history_commits"] == 2 + for key, raw in expected.items(): + assert (destination / "data" / captured[key]).read_bytes() == raw + assert not (destination / "registry.global.json").exists() + + +@pytest.mark.parametrize("refusal", ["digest", "schema", "lifecycle", "global-envelope"]) +def test_native_backup_keeps_registry_compatibility_refusals_and_previous_pair(tmp_path, refusal): + project_registry = tmp_path / "project/.loopx/registry.json" + project_registry.parent.mkdir(parents=True) + project_registry.write_text('{"goals": []}') + previous = execute_state_backup_plan(backup_plan(tmp_path)) + paths = [Path(previous[key]) for key in ("archive_path", "manifest_path")] + original = [path.read_bytes() for path in paths] + payload = {"goals": []} + if refusal == "lifecycle": + payload["profile_id"] = "source_session_v1" + envelope = _strict_registry(payload) + expected_error = {"digest": "payload digest", "schema": "schema_version", + "lifecycle": "lifecycle-only", "global-envelope": "JSON object"}[refusal] + if refusal == "digest": + envelope[0]["payload_sha256"] = "sha256:" + "0" * 64 + elif refusal == "schema": + envelope[0]["schema_version"] = "unsupported_registry_v99" + elif refusal == "lifecycle": + envelope[0].update(schema_version="loopx_project_registry_envelope_v2", minimum_writer_protocol="goal_instance_v2") + command = _backup_cli(tmp_path) + if refusal == "global-envelope": + project_registry = tmp_path / "runtime/registry.global.json" + project_registry.parent.mkdir(exist_ok=True) + command.remove("--current-project-only") + project_registry.write_text(json.dumps(envelope)) + result = subprocess.run(command, capture_output=True, text=True, timeout=90) + assert result.returncode == 1, result.stdout + rejected = json.loads(result.stdout) + assert rejected["ok"] is False and expected_error in rejected["error"], rejected + assert [path.read_bytes() for path in paths] == original + assert set(paths[0].parent.iterdir()) == set(paths) + + def test_native_cli_refuses_a_reused_backup_id_and_keeps_original_data(tmp_path): runtime = tmp_path / "runtime" runtime.mkdir() From e3df43fec0292002ede80ff8bc13aa474f5b8917 Mon Sep 17 00:00:00 2001 From: LoopX Agent <337587101+loopx-agent@users.noreply.github.com> Date: Fri, 16 Oct 2026 18:16:19 +0800 Subject: [PATCH 02/10] docs(backup): clarify readable registry capture boundaries Signed-off-by: LoopX Agent <337587101+loopx-agent@users.noreply.github.com> --- docs/reference/configuration-backup.md | 15 +++++++++++++++ 1 file changed, 15 insertions(+) diff --git a/docs/reference/configuration-backup.md b/docs/reference/configuration-backup.md index 6de9c45516..6ed51c4e52 100644 --- a/docs/reference/configuration-backup.md +++ b/docs/reference/configuration-backup.md @@ -61,6 +61,21 @@ not prove every registered source was reachable. Full-state copies retain raw Markdown history and runtime files; inert extraction does not import their leases, pending effects or historical receipts as new execution authority. +Discovery uses the same registry reader as configuration capture. Legacy project +objects and valid strict-v1 project envelopes both retain their declared external +routes and the original registry bytes. A readable envelope's minimum writer +protocol does not authorize a writer: backup only preserves data. Invalid +envelopes and lifecycle-only `source_session_v1` project registries still refuse +publication; global discovery registries remain object-only. This does not +qualify lifecycle journals for capture or change the v0 consistency and recovery +holds below. + +中文:状态发现与配置捕获复用同一个 registry reader;旧对象格式及有效 strict-v1 +项目信封均保存声明的外置路径及原始 registry 字节。可读信封的最低 writer 协议 +不授予写入权,备份只保留数据。无效信封和仅供生命周期操作的 `source_session_v1` +项目仍拒绝发布,global registry 仍只接受对象。这不代表已完成生命周期日志捕获 +资格,也不改变下文 v0 的一致性限制与恢复 hold。 + Settings → Capability Center → **Configuration backup and recovery** offers download, file verification and isolated recovery through the same typed owner. Device scope includes all invoked Goals and machine settings; Goal scope From 087c47fe821cb76a2336440eac196dcbda496031 Mon Sep 17 00:00:00 2001 From: LoopX Agent <337587101+loopx-agent@users.noreply.github.com> Date: Fri, 16 Oct 2026 19:03:44 +0800 Subject: [PATCH 03/10] fix(backup): capture and inspect canonical Goal source runtimes Signed-off-by: LoopX Agent <337587101+loopx-agent@users.noreply.github.com> --- examples/state-backup-smoke.py | 14 ++- loopx/control_plane/state_recovery.ts | 38 +++++- loopx/control_plane/state_recovery_io.py | 1 + loopx/state_backup.py | 87 +++++++++---- tests/control_plane_ts/state_recovery.test.ts | 22 ++++ tests/test_state_backup.py | 48 +++++++ tests/test_state_recovery.py | 117 ++++++++++++++++++ 7 files changed, 298 insertions(+), 29 deletions(-) diff --git a/examples/state-backup-smoke.py b/examples/state-backup-smoke.py index 5b6d27256d..4ab7fc7455 100644 --- a/examples/state-backup-smoke.py +++ b/examples/state-backup-smoke.py @@ -15,7 +15,8 @@ REPO_ROOT = Path(__file__).resolve().parents[1] sys.path.insert(0, str(REPO_ROOT)) -from loopx.state_backup import ( +# Standalone smoke entrypoints resolve this checkout before importing LoopX. +from loopx.state_backup import ( # noqa: E402 build_state_backup_plan, execute_state_backup_plan, render_state_backup_markdown, @@ -37,7 +38,8 @@ def seed_fixture(root: Path) -> tuple[Path, Path, Path]: runtime = codex_home / "loopx" project = root / "project" - write_json(project / ".loopx" / "registry.json", {"schema_version": "0.1", "goals": [{"id": "fixture"}]}) + write_json(project / ".loopx" / "registry.json", {"schema_version": "0.1", "common_runtime_root": str(runtime), + "goals": [{"id": "fixture", "repo": str(project), "state_file": ".codex/goals/fixture/ACTIVE_GOAL_STATE.md"}]}) write_text(project / ".codex" / "goals" / "fixture" / "ACTIVE_GOAL_STATE.md", "# fixture\n") write_text(project / ".claude" / "goals" / "fixture" / "ACTIVE_GOAL_STATE.md", "# claude fixture\n") write_text(project / ".local" / "goals" / "fixture" / "ACTIVE_GOAL_STATE.md", "# local fixture\n") @@ -45,12 +47,14 @@ def seed_fixture(root: Path) -> tuple[Path, Path, Path]: remote_project = root / "remote-project" remote_registry = remote_project / ".loopx" / "registry.json" remote_state = remote_project / ".codex" / "goals" / "remote" / "ACTIVE_GOAL_STATE.md" - write_json(remote_registry, {"schema_version": "0.1", "goals": [{"id": "remote"}]}) + write_json(remote_registry, {"schema_version": "0.1", "common_runtime_root": str(runtime), + "goals": [{"id": "remote", "repo": str(remote_project), "state_file": ".codex/goals/remote/ACTIVE_GOAL_STATE.md"}]}) write_text(remote_state, "# remote fixture\n") write_json( runtime / "registry.global.json", { "schema_version": "0.1", + "common_runtime_root": str(runtime), "goals": [ { "id": "fixture", @@ -190,6 +194,7 @@ def main() -> int: "source_registry_route_count": 2, "source_registry_included_count": 2, "source_registry_missing_count": 0, + "goal_runtime_roots": {goal_id: "runtime-root" for goal_id in ("fixture", "remote", "missing-project")}, }, plan assert any( item.get("key") == "registry_project:missing-project" @@ -207,8 +212,9 @@ def main() -> int: ) assert current_only["registry_discovery"]["enabled"] is False, current_only assert current_only["summary"]["logical_source_bytes"] > 0, current_only + assert current_only["registry_discovery"]["goal_runtime_roots"] == {"fixture": "runtime-root"}, current_only assert not any( - str(item.get("key") or "").startswith("registry_") + Path(item["source_path"]).resolve().is_relative_to((root / "remote-project").resolve()) for item in current_only["included"] ), current_only finally: diff --git a/loopx/control_plane/state_recovery.ts b/loopx/control_plane/state_recovery.ts index 89f24d401e..1fa4946657 100644 --- a/loopx/control_plane/state_recovery.ts +++ b/loopx/control_plane/state_recovery.ts @@ -38,6 +38,28 @@ function manifestCore(value: JsonObject): JsonObject { return {...value, execution}; } +/** Optional v0 capture bindings; absence alone retains the legacy root. */ +function capturedRuntimeRoots(manifest: JsonObject): Map | null { + if (manifest.registry_discovery == null) return null; + const discovery = requireJsonObject(manifest.registry_discovery, "registry discovery"); + if (!Object.hasOwn(discovery, "goal_runtime_roots")) return null; + const roots = requireJsonObject(discovery.goal_runtime_roots, "captured Goal runtime roots"); + check(Array.isArray(manifest.included), "backup_manifest_invalid"); + const included = new Set(manifest.included.map(raw => + requireJsonObject(raw, "included target").archive_path)); + const result = new Map(); + for (const [goalId, root] of Object.entries(roots)) { + check(goalId.length > 0 && goalId.trim() === goalId, "backup_manifest_invalid"); + if (root !== null) { + check(typeof root === "string" && !root.includes("\\") && !root.includes("\0") && + !/^[A-Za-z]:/.test(root) && root.split("/").every(part => part && part !== "." && part !== "..") && + included.has(root), "backup_manifest_invalid"); + } + result.set(goalId, root); + } + return result; +} + function externalEvidence(request: JsonObject): {external: JsonObject; execution: JsonObject} { const external = requireJsonObject(request.external_manifest, "external backup manifest"); check(external.schema_version === "loopx_state_backup_v0", "backup_schema_unsupported"); @@ -77,6 +99,7 @@ export function verifyStateRecoveryManifest(value: unknown): JsonObject { "backup_schema_unsupported"); check(canonicalAuthoritySha256(manifestCore(external)) === canonicalAuthoritySha256(manifestCore(embedded)), "manifest_disagreement"); + capturedRuntimeRoots(external); check(Array.isArray(request.files), "member_inventory_invalid"); check(Number.isSafeInteger(request.sqlite_member_count) && Number(request.sqlite_member_count) >= 0 && Number(request.sqlite_member_count) <= MAX_SQLITE_FILES, "sqlite_inventory_capacity"); @@ -124,11 +147,15 @@ async function regular(path: string): Promise { check(stat.isFile() && !stat.isSymbolicLink(), "owner_source_not_regular"); } -async function inspectGoal(root: string, goalId: string, standaloneSqlite: Set): Promise { +async function inspectGoal(root: string | null, goalId: string, standaloneSqlite: Set): Promise { const id = `goal:${hash(goalId).slice(0, 20)}`; - const paths = localAuthorityProviderPaths(root, goalId); const goalFacts = recoveryGoalLabel(goalId) === null ? {} : {goal_id: goalId}; + if (root === null) return component(id, "coordination_authority", "unknown", "authority_source_unavailable", + "inspect_captured_authority_with_owner", goalFacts, ["configuration"]); + const paths = localAuthorityProviderPaths(root, goalId); try { + const directory = await lstat(root); + check(directory.isDirectory() && !directory.isSymbolicLink(), "owner_source_not_regular"); let selection: ReturnType | null = null; try { await regular(paths.marker); @@ -188,6 +215,7 @@ async function inspectGoal(root: string, goalId: string, standaloneSqlite: Set { const started = performance.now(); const request = requireJsonObject(value, "recovery owner audit"); + const runtimeRoots = capturedRuntimeRoots(request); const measurements = decodeRecoveryMeasurements({ ...requireJsonObject(request.measurements, "host recovery measurements"), owner_read_ms: 0}); const basis = requireJsonObject(request.verified, "verified recovery basis"); @@ -254,7 +282,11 @@ async function auditOwners(value: unknown): Promise { if (configuration !== null) { const goals = (configuration.data as JsonObject).goals as JsonObject[]; check(goals.length <= MAX_GOALS, "goal_inventory_capacity"); - for (const goal of goals) components.push(await inspectGoal(join(dataRoot, "runtime-root"), String(goal.goal_id), ownerReadableSqlite)); + for (const goal of goals) { + const goalId = String(goal.goal_id); + const root = runtimeRoots === null ? "runtime-root" : runtimeRoots.get(goalId); + components.push(await inspectGoal(root ? join(dataRoot, root) : null, goalId, ownerReadableSqlite)); + } } // A v0 file inventory does not identify every session/effect/extension owner. // Retained bytes must not masquerade as a successful owner-local read. diff --git a/loopx/control_plane/state_recovery_io.py b/loopx/control_plane/state_recovery_io.py index 60d56f6be9..94ea981898 100644 --- a/loopx/control_plane/state_recovery_io.py +++ b/loopx/control_plane/state_recovery_io.py @@ -378,6 +378,7 @@ def verify_state_backup( _require(copied == observation and _sha(archive_handle) == (archive_sha, archive_size), "recovery_input_changed") audit = _audit_owners({"data_root": str(data), "verified": verified, + "registry_discovery": external.get("registry_discovery"), "included": external.get("included"), "measurements": {"archive_bytes": archive_size, "regular_file_bytes": copied["logical_bytes"], "archive_processing_ms": (time.monotonic_ns() - started) // 1_000_000}, diff --git a/loopx/state_backup.py b/loopx/state_backup.py index 55276937c3..6f9979e6a3 100644 --- a/loopx/state_backup.py +++ b/loopx/state_backup.py @@ -20,6 +20,10 @@ from .control_plane.effect_runtime import effect_runtime_result from .control_plane.projects.registry_codec import load_registry from .control_plane.runtime.document_io import fsync_state_directory +from .control_plane.runtime.runtime_projection_route import ( + _resolved_source_registry, + resolve_goal_source_runtime_route, +) STATE_BACKUP_SCHEMA_VERSION = "loopx_state_backup_v0" @@ -128,14 +132,13 @@ def _discover_targets( targets: list[dict[str, Any]] = [] missing: list[dict[str, Any]] = [] warnings: list[str] = [] - seen_sources: set[str] = set() + seen_sources: dict[str, str | None] = {} exclude_roots = [_resolved(output_dir)] - def add(key: str, source_path: Path, archive_path: str) -> None: + def add(key: str, source_path: Path, archive_path: str) -> str | None: source_key = str(_resolved(source_path)) if source_key in seen_sources: - return - seen_sources.add(source_key) + return seen_sources[source_key] found, absent, target_warnings = _target( key=key, source_path=source_path.expanduser(), @@ -147,6 +150,9 @@ def add(key: str, source_path: Path, archive_path: str) -> None: if absent is not None: missing.append(absent) warnings.extend(target_warnings) + captured = archive_path if found is not None else None + seen_sources[source_key] = captured + return captured add("runtime_root", runtime_root, "runtime-root") # Import must bind the original registry bytes, even when its caller-owned @@ -171,9 +177,11 @@ def add(key: str, source_path: Path, archive_path: str) -> None: registry_active_state_included_count = 0 registry_source_registry_count = 0 registry_source_registry_included_count = 0 + goal_runtime_roots: dict[str, str | None] = {} if include_registry_projects or discovery_registry.exists(): try: - goals = registry_goals(load_registry(discovery_registry)) + registry = load_registry(discovery_registry) + goals = registry_goals(registry) except (OSError, ValueError, json.JSONDecodeError) as exc: goals = [] warnings.append(f"could not discover registry projects from {discovery_registry}: {exc}") @@ -187,8 +195,57 @@ def add(key: str, source_path: Path, archive_path: str) -> None: for goal in goals: goal_id = str(goal.get("id") or "").strip() goal_segment = _archive_segment(goal_id, fallback="goal") + if goal_id in goal_runtime_roots: + raise ValueError("backup discovery requires unique Goal ids") + goal_runtime_roots[goal_id] = None + source_registry: Path | None = None + if include_registry_projects: + try: + # Reuse the same source route as configuration capture. A + # cached global row cannot replace the source-owned Goal. + source_registry = _resolved_source_registry(goal, registry_path=discovery_registry) + route = resolve_goal_source_runtime_route( + registry_path=discovery_registry, goal_id=goal_id, registry=registry, + ) + source = Path(route["source_registry"]) + source_payload = registry if source == _resolved(discovery_registry) else load_registry(source) + matches = [row for row in registry_goals(source_payload) if row.get("id") == goal_id] + if len(matches) != 1: + raise ValueError("backup requires exactly one source-owned Goal") + goal = matches[0] + if source_registry is not None: + source_registry = source + source_runtime = Path(route["source_runtime_root"]) + if source_runtime != runtime_root and not source_payload.get("common_runtime_root"): + raise ValueError("external source runtime is undeclared; refusing implicit host-wide discovery") + runtime_segment = hashlib.sha256(str(source_runtime).encode()).hexdigest() + goal_runtime_roots[goal_id] = add( + f"registry_runtime:{goal_id}", source_runtime, f"source-runtimes/{runtime_segment}", + ) + except (OSError, ValueError) as exc: + missing.append({"key": f"registry_runtime:{goal_id}", "source_path": "", + "reason": f"source runtime route unavailable: {exc}"}) + warnings.append(f"could not capture source runtime for {goal_id}: {exc}") + else: + # Project-scoped recovery keeps its explicitly selected runtime. + goal_runtime_roots[goal_id] = seen_sources.get(str(_resolved(runtime_root))) repo_text = str(goal.get("repo") or "").strip() - if not repo_text: + repo = _resolved(Path(repo_text)) if repo_text else None + source_registry_text = str(goal.get("source_registry") or "").strip() + if not include_registry_projects and source_registry_text: + source_registry = Path(source_registry_text).expanduser() + if not source_registry.is_absolute(): + source_registry = repo / source_registry if repo is not None else None + if source_registry is not None: + add( + f"registry_source_registry:{goal_id}", + source_registry, + f"registry-goals/{goal_segment}/source-registry/{source_registry.name}", + ) + registry_source_registry_count += 1 + if source_registry.exists() or source_registry.is_symlink(): + registry_source_registry_included_count += 1 + if repo is None: missing.append( { "key": f"registry_project:{goal_id}", @@ -197,7 +254,6 @@ def add(key: str, source_path: Path, archive_path: str) -> None: } ) continue - repo = _resolved(Path(repo_text)) registry_project_roots.add(str(repo)) project_segment = _archive_segment(repo, fallback="project") if not repo.exists(): @@ -253,20 +309,6 @@ def add(key: str, source_path: Path, archive_path: str) -> None: } ) - source_registry_text = str(goal.get("source_registry") or "").strip() - if source_registry_text: - source_registry = Path(source_registry_text).expanduser() - if not source_registry.is_absolute(): - source_registry = repo / source_registry - add( - f"registry_source_registry:{goal_id}", - source_registry, - f"registry-goals/{goal_segment}/source-registry/{source_registry.name}", - ) - registry_source_registry_count += 1 - if source_registry.exists() or source_registry.is_symlink(): - registry_source_registry_included_count += 1 - codex_home = _codex_home() if include_automations: add("codex_automations", codex_home / "automations", "codex/automations") @@ -301,6 +343,7 @@ def add(key: str, source_path: Path, archive_path: str) -> None: "source_registry_missing_count": ( registry_source_registry_count - registry_source_registry_included_count ), + "goal_runtime_roots": goal_runtime_roots, } return targets, missing, warnings, discovery @@ -317,7 +360,7 @@ def _sum_target_stats(targets: list[dict[str, Any]]) -> dict[str, int]: def _target_category(key: str) -> str: - if key == "runtime_root": + if key == "runtime_root" or key.startswith("registry_runtime:"): return "runtime" if key.startswith("project_") or key.startswith("registry_project_"): return "project_state" diff --git a/tests/control_plane_ts/state_recovery.test.ts b/tests/control_plane_ts/state_recovery.test.ts index f8ecc23265..af95c97205 100644 --- a/tests/control_plane_ts/state_recovery.test.ts +++ b/tests/control_plane_ts/state_recovery.test.ts @@ -46,6 +46,28 @@ test("legacy absent member witnesses stay unqualified rather than becoming verif assert.equal(result.capture_profile, "legacy_best_effort"); }); +test("captured runtime bindings are optional data and must name included inert roots", () => { + for (const root of ["runtime-root", null]) { + const input = request(); + for (const manifest of [input.external_manifest, input.embedded_manifest]) { + Object.assign(manifest, {registry_discovery: {goal_runtime_roots: {"goal-a": root}}, + included: [{archive_path: "runtime-root"}]}); + } + const result = verifyStateRecoveryManifest(input); + assert.equal(result.ok, true); + assert.equal(result.execution_authority_granted, false); + assert.equal(JSON.stringify(result).includes("goal_runtime_roots"), false); + } + for (const root of ["/outside", "../outside", "runtime-root/../other", "runtime-root//other", "unlisted", 1, true]) { + const input = request(); + for (const manifest of [input.external_manifest, input.embedded_manifest]) { + Object.assign(manifest, {registry_discovery: {goal_runtime_roots: {"goal-a": root}}, + included: [{archive_path: "runtime-root"}]}); + } + assert.equal(verifyStateRecoveryManifest(input).ok, false, String(root)); + } +}); + function audit() { const body = {ok: true, schema_version: STATE_RECOVERY_AUDIT_SCHEMA, recovery_id: "e".repeat(64), status: "held", written: true, manifest_verified: true, diff --git a/tests/test_state_backup.py b/tests/test_state_backup.py index 9a332b583d..8b2815cb07 100644 --- a/tests/test_state_backup.py +++ b/tests/test_state_backup.py @@ -215,6 +215,54 @@ def _strict_registry(payload: dict, writer: str = "goal_instance_v1") -> list: "payload_sha256": "sha256:" + hashlib.sha256(canonical).hexdigest()}, payload] +def test_global_discovery_deduplicates_shared_canonical_runtimes(tmp_path): + runtime, source_runtime = tmp_path / "runtime", tmp_path / "canonical-runtime" + runtime.mkdir() + source_runtime.mkdir() + project = tmp_path / "project" + project.mkdir() + state = project / "current.md" + state.write_text("Original unfinished commitments") + goals = [{"id": goal_id, "repo": str(project), "state_file": "current.md"} + for goal_id in ("goal-a", "goal-b")] + source = tmp_path / "canonical.json" + source.write_text(json.dumps({"common_runtime_root": str(source_runtime), "goals": goals})) + (runtime / "registry.global.json").write_text(json.dumps({"registry_role": "global-local", + "common_runtime_root": str(runtime), "goals": [{**goal, "source_registry": "../canonical.json"} for goal in goals]})) + plan = build_state_backup_plan(project=project, runtime_root=runtime, output_dir=tmp_path / "backups", + include_automations=False, include_skills=False) + roots = plan["registry_discovery"]["goal_runtime_roots"] + assert roots["goal-a"] == roots["goal-b"] != "runtime-root" + assert sum(row["source_path"] == str(source_runtime) for row in plan["included"]) == 1 + assert sum(row["source_path"] == str(source) for row in plan["included"]) == 1 + + +@pytest.mark.parametrize("source_state", ["missing", "undeclared-runtime", "missing-repository"]) +def test_global_discovery_records_unavailable_sources_without_copying_implicit_host_state(tmp_path, monkeypatch, source_state): + from loopx import paths + + runtime, implicit = tmp_path / "runtime", tmp_path / "implicit-host-runtime" + runtime.mkdir() + implicit.mkdir() + (implicit / "unselected.txt").write_text("Outside the explicitly selected backup roots") + monkeypatch.setattr(paths, "select_default_runtime_root", lambda: implicit) + project = tmp_path / "project" + project.mkdir() + source = tmp_path / "source.json" + goal = {"id": "goal-a", "repo": str(project), "state_file": "current.md"} + if source_state != "missing": + source.write_text(json.dumps({"goals": [goal if source_state != "missing-repository" else {"id": "goal-a"}]})) + (runtime / "registry.global.json").write_text(json.dumps({"registry_role": "global-local", + "common_runtime_root": str(runtime), "goals": [{**goal, "source_registry": "../source.json"}]})) + plan = build_state_backup_plan(project=project, runtime_root=runtime, output_dir=tmp_path / "backups", + include_automations=False, include_skills=False) + assert plan["registry_discovery"]["goal_runtime_roots"] == {"goal-a": None} + assert any(row["key"] == "registry_runtime:goal-a" for row in plan["missing"]) + assert not any(row["source_path"] == str(implicit) for row in plan["included"]) + if source.exists(): + assert any(row["source_path"] == str(source) for row in plan["included"]) + + @pytest.mark.parametrize("provider", ["file", "sqlite"]) @pytest.mark.parametrize("writer", [None, "goal_instance_v1", "future_writer_v99"], ids=["legacy", "strict-v1", "readable-future-writer"]) diff --git a/tests/test_state_recovery.py b/tests/test_state_recovery.py index 41dda7c982..32ae3e3f1c 100644 --- a/tests/test_state_recovery.py +++ b/tests/test_state_recovery.py @@ -69,6 +69,123 @@ def _rewrite(archive: Path, manifest: Path, extra: tarfile.TarInfo | None = None manifest.write_text(json.dumps(data)) +def _global_source_backup(root: Path, provider: str) -> tuple[dict, dict[str, bytes]]: + registry, source_runtime, state = promoted_create_fixture(root / "host/canonical", provider=provider) + _, shared_runtime, _ = promoted_create_fixture(root / "host", provider="sqlite" if provider == "file" else "file") + for source, runtime, count in ((registry, source_runtime, 1), (root / "host/registry.json", shared_runtime, 3)): + for index in range(count): + code, result = _cli("--registry", source, "--runtime-root", runtime, "todo", "add", + "--goal-id", "goal-a", "--role", "agent", "--claimed-by", "agent-a", + "--operation-id", f"retained-{index}", "--text", f"Unfinished commitment {index}") + assert code == 0, result + current = registry.parent / "current-goal.md" + current.write_text(state.read_text() + "\n## Original commitments\nThe source-owned full Goal.\n") + payload = json.loads(registry.read_text()) + payload["goals"][0]["state_file"] = "../current-goal.md" + registry.write_text(json.dumps(payload)) + cached = {**payload["goals"][0], "state_file": ".codex/goals/goal-a/ACTIVE_GOAL_STATE.md", + "source_registry": "../canonical/registry.json"} + (shared_runtime / "registry.global.json").write_text(json.dumps({"registry_role": "global-local", + "common_runtime_root": str(shared_runtime), "goals": [cached]})) + wrong = registry.parent / "canonical/registry.json" + wrong.parent.mkdir() + wrong.write_bytes(b'{"wrong_repository_relative_copy": true}') + code, backup = _cli("--runtime-root", shared_runtime, "backup-state", "--project", state.parents[3], + "--output-dir", root / "backups", "--backup-id", "source", "--no-automations", "--no-skills", "--execute") + assert code == 0 and backup["ok"], backup + expected = {"registry": registry.read_bytes(), "goal": current.read_bytes(), "wrong": wrong.read_bytes()} + (root / "host").rename(root / "unavailable-host") + return backup, expected + + +@pytest.mark.parametrize("provider", ["file", "sqlite"]) +def test_global_backup_inspects_the_canonical_source_not_a_same_alias_shared_store(tmp_path, provider): + backup, expected = _global_source_backup(tmp_path, provider) + destination = tmp_path / "inert" + code, audit = _cli("backup-state", "verify", "--archive", backup["archive_path"], + "--manifest", backup["manifest_path"], "--destination", destination, "--execute") + assert code == 0, audit + [authority] = [row for row in audit["components"] if row["owner_kind"] == "coordination_authority"] + assert authority["state"] == "readable", authority + assert authority["facts"]["provider"] == provider + assert authority["facts"]["todo_count"] == 1 and authority["facts"]["history_commits"] == 2 + assert audit["status"] == "held" and audit["activation_eligible"] is False + assert audit["execution_authority_granted"] is False + assert audit["workspace"] == {"isolation": "inert", "registered": False} + assert str(tmp_path) not in json.dumps(audit) + with tarfile.open(backup["archive_path"]) as archive: + files = [archive.extractfile(row).read() for row in archive if row.isfile()] + assert expected["goal"] in files and expected["registry"] in files + assert expected["wrong"] not in files + roots = backup["registry_discovery"]["goal_runtime_roots"] + assert roots["goal-a"] != "runtime-root" + assert (destination / "data" / roots["goal-a"]).is_dir() + assert not (destination / "registry.global.json").exists() + + +def test_explicit_missing_source_bindings_never_fall_back_to_the_shared_store(tmp_path): + from loopx.control_plane.state_recovery_io import _audit_owners + + backup, _ = _global_source_backup(tmp_path, "file") + destination = tmp_path / "inert" + audit = verify_state_backup(archive=Path(backup["archive_path"]), manifest=Path(backup["manifest_path"]), + destination=destination, execute=True) + data = destination / "data" + sqlite_members = [path.relative_to(data).as_posix() for path in data.rglob("*.sqlite")] + request = {"data_root": str(data), "measurements": audit["measurements"], + "verified": {key: audit[key] for key in ("ok", "schema_version", "recovery_id", "backup", "file_count", + "file_witnesses_verified", "manifest_verified", "capture_profile", "execution_authority_granted")}, + "sqlite_members": sqlite_members, "standalone_sqlite_members": sqlite_members, + "included": backup["included"]} + for roots in ({}, {"goal-a": None}): + result = _audit_owners({**request, "registry_discovery": {"goal_runtime_roots": roots}}) + [authority] = [row for row in result["components"] if row["owner_kind"] == "coordination_authority"] + assert authority["state"] == "unknown", authority + assert result["execution_authority_granted"] is False + root = data / backup["registry_discovery"]["goal_runtime_roots"]["goal-a"] + root.rename(tmp_path / "unavailable-captured-source") + result = _audit_owners({**request, "registry_discovery": backup["registry_discovery"]}) + [authority] = [row for row in result["components"] if row["owner_kind"] == "coordination_authority"] + assert authority["state"] == "unknown", authority + assert not root.exists() + root.symlink_to(data / "runtime-root", target_is_directory=True) + result = _audit_owners({**request, "registry_discovery": backup["registry_discovery"]}) + [authority] = [row for row in result["components"] if row["owner_kind"] == "coordination_authority"] + assert authority["state"] == "unknown", authority + assert result["execution_authority_granted"] is False + + +def test_unsafe_source_binding_is_refused_before_extracting_any_files(tmp_path): + archive, manifest = _backup(tmp_path) + external = json.loads(manifest.read_text()) + with tarfile.open(archive) as source: + embedded = json.load(source.extractfile("manifest.json")) + for payload in (external, embedded): + payload["registry_discovery"]["goal_runtime_roots"] = {"goal-a": "../outside"} + payload["included"].append({"archive_path": "../outside"}) + manifest.write_text(json.dumps(external)) + _rewrite(archive, manifest, replacements={"manifest.json": json.dumps(embedded).encode()}) + destination = tmp_path / "inert" + with pytest.raises(StateRecoveryError, match="backup_manifest_invalid"): + verify_state_backup(archive=archive, manifest=manifest, destination=destination, execute=True) + assert not destination.exists() and not (tmp_path / "outside").exists() + + +def test_legacy_archive_without_source_bindings_keeps_original_runtime_readback(tmp_path): + archive, manifest = _backup(tmp_path, "file") + external = json.loads(manifest.read_text()) + with tarfile.open(archive) as source: + embedded = json.load(source.extractfile("manifest.json")) + for payload in (external, embedded): + payload["registry_discovery"].pop("goal_runtime_roots", None) + manifest.write_text(json.dumps(external)) + _rewrite(archive, manifest, replacements={"manifest.json": json.dumps(embedded).encode()}) + audit = verify_state_backup(archive=archive, manifest=manifest, destination=tmp_path / "inert", execute=True) + [authority] = [row for row in audit["components"] if row["owner_kind"] == "coordination_authority"] + assert authority["state"] == "readable" and authority["facts"]["todo_count"] == 1 + assert audit["status"] == "held" and audit["execution_authority_granted"] is False + + @pytest.mark.parametrize("provider", ["file", "sqlite"]) def test_real_backup_recovers_readable_authority_after_original_sources_disappear(tmp_path, provider): archive, manifest = _backup(tmp_path, provider) From b2ba7869771b61f4147b94b365632b47970fafe7 Mon Sep 17 00:00:00 2001 From: LoopX Agent <337587101+loopx-agent@users.noreply.github.com> Date: Fri, 16 Oct 2026 19:03:44 +0800 Subject: [PATCH 04/10] docs(backup): explain canonical capture scope and inert bindings Signed-off-by: LoopX Agent <337587101+loopx-agent@users.noreply.github.com> --- docs/reference/configuration-backup.md | 31 ++++++++++++++++++++++++++ 1 file changed, 31 insertions(+) diff --git a/docs/reference/configuration-backup.md b/docs/reference/configuration-backup.md index 6ed51c4e52..e210ee6598 100644 --- a/docs/reference/configuration-backup.md +++ b/docs/reference/configuration-backup.md @@ -56,6 +56,15 @@ paths outside conventional Goal directories. Relative routes resolve against the registered repository. The selected project's `.loopx/registry.json` owns this scope, even when `--registry` names another project. Other projects are not followed; default host-wide discovery continues to use the global registry. +For global discovery, the existing canonical source route owns the Goal: relative +`source_registry` values resolve against the global registry's parent, and the +source registry supplies the current repository, full `state_file` and declared +`common_runtime_root`. Distinct source runtimes are captured once and shared by +their Goals. Review the dry-run paths before execution: this can include more +private state than the shared runtime alone. An unavailable source or an +undeclared external runtime remains missing; discovery does not copy an implicit +host-wide default to fill that gap. + Inspect `included`, `missing` and `warnings`: successful publication alone does not prove every registered source was reachable. Full-state copies retain raw Markdown history and runtime files; inert extraction does not import their @@ -70,12 +79,34 @@ publication; global discovery registries remain object-only. This does not qualify lifecycle journals for capture or change the v0 consistency and recovery holds below. +New v0 archives record `registry_discovery.goal_runtime_roots` as archive-relative +capture bindings. Inert owner inspection reads each Goal from its captured source +root. An absent Goal binding, an explicit null, a missing directory or a symlink +root remains unknown; another runtime's same-named Goal is not a substitute. +Bindings must name safe included roots. Historical archives without the entire +binding map retain their original `runtime-root` readback. The map records capture +location, not cross-owner consistency, complete owner coverage or activation +eligibility. + 中文:状态发现与配置捕获复用同一个 registry reader;旧对象格式及有效 strict-v1 项目信封均保存声明的外置路径及原始 registry 字节。可读信封的最低 writer 协议 不授予写入权,备份只保留数据。无效信封和仅供生命周期操作的 `source_session_v1` 项目仍拒绝发布,global registry 仍只接受对象。这不代表已完成生命周期日志捕获 资格,也不改变下文 v0 的一致性限制与恢复 hold。 +全局发现沿用 canonical source 路由:相对 `source_registry` 以 global registry +父目录为基准,当前仓库、完整 `state_file` 与显式 `common_runtime_root` 均来自 +源 registry;多个 Goal 共用的 runtime 只捕获一次。执行前检查 dry-run 路径, +捕获范围可能比共享 runtime 更大,并包含私有状态。源不可达或外部 runtime 未声明 +时保留缺失,不擅自复制隐式主机默认目录。`--current-project-only` 仍保留所选 +runtime 及以注册仓库为基准的历史相对路径规则。 + +新 v0 清单的 `registry_discovery.goal_runtime_roots` 只记录归档内的源位置,隔离 +读回按此定位;缺少 Goal 条目、null、目录缺失或根目录是符号链接均为 unknown, +不能拿另一 runtime 的同名 Goal 代替。绑定必须指向安全且已列入清单的根路径。 +完全不含此映射的旧归档继续按 `runtime-root` 读回。映射不证明跨 owner 一致性、 +owner 完整覆盖或可激活性。 + Settings → Capability Center → **Configuration backup and recovery** offers download, file verification and isolated recovery through the same typed owner. Device scope includes all invoked Goals and machine settings; Goal scope From 307f836fd9912f8e07db834f32fa9abb6eaa9b08 Mon Sep 17 00:00:00 2001 From: LoopX Agent <337587101+loopx-agent@users.noreply.github.com> Date: Fri, 16 Oct 2026 19:46:12 +0800 Subject: [PATCH 05/10] fix(backup): refuse configuration from different copied source bytes Signed-off-by: LoopX Agent <337587101+loopx-agent@users.noreply.github.com> --- loopx/capabilities/configuration_backup.py | 28 +++++++++--- loopx/state_backup.py | 43 ++++++++++++++++-- tests/test_configuration_backup.py | 39 +++++++++++++++++ tests/test_state_recovery.py | 51 ++++++++++++++++++++++ 4 files changed, 151 insertions(+), 10 deletions(-) diff --git a/loopx/capabilities/configuration_backup.py b/loopx/capabilities/configuration_backup.py index 7a44f1e709..555b83c285 100644 --- a/loopx/capabilities/configuration_backup.py +++ b/loopx/capabilities/configuration_backup.py @@ -4,41 +4,55 @@ from ..control_plane.effect_runtime import effect_runtime_result from ..control_plane.runtime.runtime_projection_route import resolve_goal_source_runtime_route -from ..history import load_registry +from ..control_plane.projects.registry_codec import decode_registry_snapshot from ..registry import registry_goals from .machine_configuration.store import read_stored_machine_configuration def capture_configuration_backup( *, registry_path: Path, runtime_root: Path, goal_ids: list[str] | None = None, + registry_snapshots: dict[Path, bytes] | None = None, ) -> dict[str, Any]: - registry = load_registry(registry_path) if registry_path.exists() else {"goals": []} + # One immutable read per source supplies both the decoded values and, when + # requested by full backup, their exact IO provenance. A later path reread + # cannot witness the bytes used here (the source may change A -> B -> A). + sources = registry_snapshots if registry_snapshots is not None else {} + + def read_registry(path: Path) -> dict[str, Any]: + source = path.expanduser().resolve() + if source not in sources: + sources[source] = source.read_bytes() + return decode_registry_snapshot(path, sources[source]) + + registry = read_registry(registry_path) if registry_path.exists() else {"goals": []} selected = goal_ids if goal_ids is not None else [str(goal["id"]) for goal in registry_goals(registry)] if len(selected) != len(set(selected)): raise ValueError("configuration backup Goal ids must be unique") snapshots = [] for goal_id in selected: - route = resolve_goal_source_runtime_route(registry_path=registry_path, goal_id=goal_id) + route = resolve_goal_source_runtime_route(registry_path=registry_path, goal_id=goal_id, registry=registry) source = Path(route["source_registry"]) - matches = [goal for goal in registry_goals(load_registry(source)) if goal.get("id") == goal_id] + matches = [goal for goal in registry_goals(read_registry(source)) if goal.get("id") == goal_id] if len(matches) != 1: raise ValueError("configuration backup requires exactly one source-owned Goal") snapshots.append({"goal_id": goal_id, "goal_configuration": matches[0]}) data = { "machine_configuration": read_stored_machine_configuration(runtime_root), "goals": snapshots, } - backup = effect_runtime_result("configuration.backup", {"action": "capture", "data": data}) + backup: dict[str, Any] = effect_runtime_result("configuration.backup", {"action": "capture", "data": data}) if backup.get("data") != data: raise ValueError("configuration transport cannot preserve the complete source values") return backup def verify_configuration_backup(backup: dict[str, Any]) -> dict[str, Any]: - return effect_runtime_result("configuration.backup", {"action": "verify", "backup": backup}) + result: dict[str, Any] = effect_runtime_result("configuration.backup", {"action": "verify", "backup": backup}) + return result def restore_configuration_backup( backup: dict[str, Any], *, destination: Path, expected_sha256: str, execute: bool = False, ) -> dict[str, Any]: - return effect_runtime_result("configuration.backup", {"action": "restore", "backup": backup, + result: dict[str, Any] = effect_runtime_result("configuration.backup", {"action": "restore", "backup": backup, "destination": str(destination.absolute()), "expected_sha256": expected_sha256, "execute": execute}) + return result diff --git a/loopx/state_backup.py b/loopx/state_backup.py index 6f9979e6a3..775f65de98 100644 --- a/loopx/state_backup.py +++ b/loopx/state_backup.py @@ -6,7 +6,7 @@ import io import json import os -from pathlib import Path +from pathlib import Path, PurePosixPath import re import shutil import tarfile @@ -508,13 +508,13 @@ def build_scoped_state_backup_plan( missing: list[dict[str, Any]] = [] warnings: list[str] = [] seen_sources: set[str] = set() - for key, source, archive_path in sources: + for key, source, member_name in sources: source_key = str(_resolved(source)) if source_key in seen_sources: continue seen_sources.add(source_key) found, absent, target_warnings = _target(key=key, source_path=source.expanduser(), - archive_path=archive_path, exclude_roots=exclude_roots) + archive_path=member_name, exclude_roots=exclude_roots) if found is not None: included.append(found) if absent is not None: @@ -646,6 +646,33 @@ def _sha256_file(path: Path) -> str: return digest.hexdigest() +def _configuration_source_witnesses( + sources: dict[Path, bytes], included: list[dict[str, Any]], members: list[dict[str, Any]], +) -> list[dict[str, Any]]: + """Compare the actual decoded source bytes with every regular tar copy. + + Covered aliases may be hardlinks; at least one regular copied-byte witness + is required. Equality is an IO check, not cross-owner capture qualification. + """ + result = [] + for source, raw in sources.items(): + paths = set() + for target in included: + try: + relative = source.relative_to(_resolved(Path(target["source_path"]))) + except ValueError: + continue + paths.add(str(PurePosixPath(target["archive_path"]) / relative.as_posix())) + witnesses = [row for row in members if row["archive_path"] in paths] + sha256 = hashlib.sha256(raw).hexdigest() + if not witnesses: + raise ValueError("backup configuration registry has no copied bytes witness; include its regular source file") + if any(row["sha256"] != sha256 for row in witnesses): + raise ValueError("backup configuration registry differs from copied bytes; retry the backup") + result.extend(witnesses) + return result + + def _publish_backup_file(source: Path, destination: Path) -> None: """Create a new name, retaining exclusive creation on link-less filesystems.""" try: @@ -719,11 +746,21 @@ def execute_state_backup_plan(payload: dict[str, Any]) -> dict[str, Any]: capture_configuration_backup, verify_configuration_backup, ) + registry_snapshots: dict[Path, bytes] = {} + configuration_started_at = datetime.now(timezone.utc).isoformat() configuration = capture_configuration_backup( registry_path=Path(payload["configuration_source_registry"]), runtime_root=Path(payload["runtime_root"]), goal_ids=payload.get("configuration_goal_ids"), + registry_snapshots=registry_snapshots, ) + if tar.record_file_members: + updated["execution"]["configuration_capture"] = { + "started_at": configuration_started_at, + "completed_at": datetime.now(timezone.utc).isoformat(), + "source_revisions": _configuration_source_witnesses( + registry_snapshots, included, tar.file_members), + } configuration_bytes = json.dumps(configuration, ensure_ascii=False, indent=2).encode("utf-8") info = tarfile.TarInfo("configuration-backup.json") info.size, info.mode = len(configuration_bytes), 0o600 diff --git a/tests/test_configuration_backup.py b/tests/test_configuration_backup.py index c15db2e4fe..3672cb3409 100644 --- a/tests/test_configuration_backup.py +++ b/tests/test_configuration_backup.py @@ -5,6 +5,8 @@ import tarfile import threading import http.client +import hashlib +from datetime import datetime from pathlib import Path import venv @@ -142,6 +144,43 @@ def test_full_state_backup_has_first_class_configuration_component(environment, backup = json.load(archive.extractfile("configuration-backup.json")) assert backup["data"]["goals"][0]["goal_configuration"] == goal assert payload["execution"]["configuration_backup"]["readback_verified"] is True + capture = payload["execution"]["configuration_capture"] + assert datetime.fromisoformat(capture["started_at"]) <= datetime.fromisoformat(capture["completed_at"]) + with tarfile.open(payload["archive_path"]) as archive: + # The shared projection and the routed source each retain their actual + # copied bytes. Aliases can be links to the first regular member. + witnesses = capture["source_revisions"] + assert len(witnesses) >= 2 + assert {row["sha256"] for row in witnesses} == { + hashlib.sha256(source.read_bytes()).hexdigest(), hashlib.sha256(registry.read_bytes()).hexdigest(), + } + for row in witnesses: + raw = archive.extractfile(row["archive_path"]).read() + assert row["sha256"] == hashlib.sha256(raw).hexdigest() + assert row["size_bytes"] == len(raw) + + +def test_full_backup_refuses_a_changed_routed_registry_source(environment, tmp_path, monkeypatch): + source, runtime, _, _ = environment + original_add = tarfile.TarFile.add + changed = [] + + def after_copy(tar, path, *args, **kwargs): + result = original_add(tar, path, *args, **kwargs) + if str(kwargs.get("arcname", "")).endswith("/source-registry/registry.json"): + captured = json.loads(source.read_text()) + captured["goals"][0]["description"] = "Changed canonical source" + source.write_text(json.dumps(captured)) + changed.append(True) + return result + + monkeypatch.setattr(tarfile.TarFile, "add", after_copy) + plan = build_state_backup_plan(project=source.parent.parent, runtime_root=runtime, + output_dir=tmp_path / "backups", include_skills=False, include_automations=False) + with pytest.raises(ValueError, match="configuration registry differs from copied bytes"): + execute_state_backup_plan(plan) + assert changed == [True] + assert not list((tmp_path / "backups").iterdir()) def test_full_archive_explicit_configuration_cohort_retains_missing_source_inventory(environment, tmp_path): diff --git a/tests/test_state_recovery.py b/tests/test_state_recovery.py index 32ae3e3f1c..860b2ce393 100644 --- a/tests/test_state_recovery.py +++ b/tests/test_state_recovery.py @@ -51,6 +51,57 @@ def _backup(root: Path, provider: str | None = None) -> tuple[Path, Path]: return Path(result["archive_path"]), Path(result["manifest_path"]) +@pytest.mark.parametrize("provider", ["file", "sqlite"]) +@pytest.mark.parametrize("restore_source", [False, True], ids=["persistent-change", "aba"]) +def test_backup_refuses_configuration_from_different_copied_registry_bytes( + tmp_path, monkeypatch, provider, restore_source, +): + from loopx.capabilities import configuration_backup + + original_add = tarfile.TarFile.add + original_capture = configuration_backup.capture_configuration_backup + copied = [] + + def after_copy(tar, source, *args, **kwargs): + result = original_add(tar, source, *args, **kwargs) + if kwargs.get("arcname") == "configuration/registry.source.json": + registry = tmp_path / "registry.json" + copied.append(registry.read_bytes()) + value = json.loads(copied[0]) + value["goals"][0]["description"] = "Different source revision" + registry.write_text(json.dumps(value)) + return result + + def after_capture(**kwargs): + checkpoint = original_capture(**kwargs) + if restore_source: + # Restore A only after the real source owner captured B. Comparing + # the live path after capture would miss this A -> B -> A change. + (tmp_path / "registry.json").write_bytes(copied[0]) + return checkpoint + + monkeypatch.setattr(tarfile.TarFile, "add", after_copy) + monkeypatch.setattr(configuration_backup, "capture_configuration_backup", after_capture) + with pytest.raises(ValueError, match="configuration registry.*copied bytes"): + _backup(tmp_path, provider) + assert len(copied) == 1 + assert not list((tmp_path / "backups").iterdir()), "a mixed backup pair must not be published" + + +def test_backup_refuses_a_required_configuration_source_without_copied_bytes(tmp_path): + project, runtime, registry = tmp_path / "project", tmp_path / "runtime", tmp_path / "registry.json" + project.mkdir() + runtime.mkdir() + registry.write_text(json.dumps({"goals": []})) + plan = build_state_backup_plan(project=project, runtime_root=runtime, registry_path=registry, + output_dir=tmp_path / "backups", include_registry_projects=False, + include_automations=False, include_skills=False) + plan["included"] = [row for row in plan["included"] if row["key"] != "configuration_source_registry"] + with pytest.raises(ValueError, match="configuration registry has no copied bytes witness"): + execute_state_backup_plan(plan) + assert not list((tmp_path / "backups").iterdir()) + + def _rewrite(archive: Path, manifest: Path, extra: tarfile.TarInfo | None = None, body: bytes = b"", replacements: dict[str, bytes] | None = None) -> None: with tarfile.open(archive, "r:gz") as source: From 3fda960ad67242824d8e1b48b9c83eb309bcf809 Mon Sep 17 00:00:00 2001 From: LoopX Agent <337587101+loopx-agent@users.noreply.github.com> Date: Fri, 16 Oct 2026 19:46:12 +0800 Subject: [PATCH 06/10] docs(backup): explain registry source witness refusal Signed-off-by: LoopX Agent <337587101+loopx-agent@users.noreply.github.com> --- docs/reference/configuration-backup.md | 27 ++++++++++++++++++++++++++ 1 file changed, 27 insertions(+) diff --git a/docs/reference/configuration-backup.md b/docs/reference/configuration-backup.md index e210ee6598..6b37d24859 100644 --- a/docs/reference/configuration-backup.md +++ b/docs/reference/configuration-backup.md @@ -180,6 +180,33 @@ and Host quiescence, lease/outbox disposition, immutable source/target binding, explicit confirmation and original-operation recovery. Neither this manifest nor configuration verification authorizes an import or a new execution grant. +Full backups also compare every regular copied registry member with the exact +source snapshot decoded into `configuration-backup.json`. If a required snapshot +has no copied-byte witness, capture refuses publication and asks for its regular +source file. A revision mismatch reports +`backup configuration registry differs from copied bytes; retry the backup`. +This changes the earlier best-effort behavior that could publish an older raw +registry beside newer configuration. A later source-path reread cannot replace +the snapshot witness: an A → B → A change must still be caught when the checkpoint +used B. Unchanged sources retain the same configuration envelope and v0 archive +format. The bounded migration backup keeps its existing source-owner witness +contract. Older archives remain readable under the existing recovery holds. + +The private `execution.configuration_capture` records the configuration capture +interval and matching member revisions. It covers registry bytes used by that +checkpoint, including routed source registries; it does not qualify machine +configuration, authority, effects or other owners as a common point in time, or +make the M2 `online` profile eligible. + +中文:完整备份将配置解码时实际使用的 registry 快照,与归档中每份普通文件副本 +的字节见证比较。缺少见证或字节不同就停止发布,提示重试;原先可能发布“旧原始 +registry + 新配置”的 best-effort 行为因此收紧。比较的不是事后重读路径,所以配置 +使用了 B 时,即使源发生 A → B → A 回退也会拒绝。未变化来源、v0 格式及旧归档 +读回保持兼容;有界 migration backup 仍使用原 source owner 的见证契约。 +`execution.configuration_capture` 私有记录捕获区间与匹配成员 revision,包括路由到 +外部的源 registry;它不证明 machine 配置、authority、effect 等 owner 处于同一时刻, +也不代表 M2 `online` profile 合格。 + ## Full-state recovery after cold import Keep the reviewed pre-import backup as well as a new full-state backup after From e79f26eccfcf36983103a61cdaa32fbbb03c0c90 Mon Sep 17 00:00:00 2001 From: LoopX Agent <337587101+loopx-agent@users.noreply.github.com> Date: Fri, 16 Oct 2026 19:58:36 +0800 Subject: [PATCH 07/10] test(recovery): retain mixed-revision archive compatibility and owner gaps Signed-off-by: LoopX Agent <337587101+loopx-agent@users.noreply.github.com> --- .../rfcs/complete-state-recovery-v0.md | 57 +++++++++++++++++++ .../rfcs/complete-state-recovery-v0.zh-CN.md | 45 +++++++++++++++ tests/test_state_recovery.py | 41 +++++++++++++ 3 files changed, 143 insertions(+) diff --git a/docs/architecture/rfcs/complete-state-recovery-v0.md b/docs/architecture/rfcs/complete-state-recovery-v0.md index 066dcaad8e..8d4989bb31 100644 --- a/docs/architecture/rfcs/complete-state-recovery-v0.md +++ b/docs/architecture/rfcs/complete-state-recovery-v0.md @@ -512,6 +512,63 @@ NoKV or another provider is unsupported until its profile independently proves the same logical contract. A generic filesystem copy cannot stand in for a provider profile. +### Capture-owner inventory for the first local profile + +The source inventory below targets the first single-Goal File/SQLite profile. +It identifies where M2 must obtain evidence; it does **not** certify that a +particular backup contains every required component. An actual capture must +resolve the selected Goal's routes and dependency closure, distinguish an +owner-confirmed absence from an unreadable or unrecognized source, and retain +unresolved required owners as holds. Optional installation does not make an +active Goal dependency optional. + +| Mutable boundary and existing owner | Current capture/readback | Remaining capture or adoption evidence | +| --- | --- | --- | +| Registry routing and machine/Goal configuration: [configuration capture adapter](../../../loopx/capabilities/configuration_backup.py), [typed checkpoint envelope](../../../loopx/control_plane/configuration_backup.ts), [machine configuration store](../../../loopx/capabilities/machine_configuration/store.py) | Full backups compare the exact registry bytes used to decode configuration with their copied-byte witnesses and refuse missing or differing copies, including A → B → A. The private manifest records matching revisions and the configuration capture interval. | Bind machine configuration, authority and other required owners to their own intervals and dependencies. Registry byte agreement and a valid envelope do not prove cross-owner consistency. | +| Goal narrative, vision and authority material references: registry-resolved active state, [vision checkpoint owner](../../../loopx/control_plane/goals/vision_checkpoint.ts) | Discovered active-state paths and files beneath included roots are copied. External referenced material is not recursively resolved. | Preserve the full Goal and revision-bound required artifacts, classify unavailable dependencies, and keep derived Markdown projections subordinate to their owners. | +| Canonical work, leases and retained operation history: [local provider selection](../../../loopx/control_plane/coordination/local_authority_provider.ts), [authority archive owner](../../../loopx/control_plane/coordination/authority_archive.ts), [legacy writer fence](../../../loopx/control_plane/coordination/legacy_writer_fence.ts) | File head/history share an atomic envelope; SQLite uses the [existing physical snapshot owner](../../../loopx/control_plane/coordination/sqlite_backup.ts). M1 opens existing stores and verifies head/history without selecting a live destination. | Bind provider/store identity, selector, fence, revision and cursor; obtain an owner export or qualified snapshot. Component-local consistency cannot establish cross-owner consistency or make historical leases valid. | +| Quota accounting and Turn settlement: [accounting artifact transaction](../../../loopx/control_plane/quota/accounting_artifact_transaction.ts), [spend owner](../../../loopx/control_plane/quota/spend_commit.ts), [Turn journal commit](../../../loopx/control_plane/turn_driver/turn_journal_effects.ts) | Files under included runtime/project roots are retained; M1 does not reconcile their transaction lineage. | Capture committed receipts, indexes and in-flight journal inputs together with their exact settlement identity; let the original owner resolve replay or ambiguity without a second debit. | +| Goal lifetime and session/execution binding: [source-session lifetime owner](../../../loopx/control_plane/goals/source_session_lifetime.ts), [Chat session lifecycle](../../../loopx/control_plane/goals/chat_session_lifecycle.ts), [execution identity](../../../loopx/control_plane/runtime/execution_identity.ts) | Raw routing/journal bytes may be present. Native Host session stores outside discovered roots are not implicitly captured or adopted. | Resolve the actual entrypoint's owner, lifetime and generation. Prove stale executor exclusion and a qualified session recovery path before the original entrypoint can continue. | +| Pending effects, delivery and derived projection outboxes: [source Turn effect gate](../../../loopx/control_plane/goals/source_session_lifetime.ts), [return-delivery classifier](../../../loopx/control_plane/collaboration/return_delivery.ts), [authority shadow outbox](../../../loopx/control_plane/coordination/local_authority_shadow_outbox.ts) | Included journals/outboxes are retained as bytes; neither their external outcome nor their replay eligibility is inferred. | Each owning effect/transport resolves committed, absent or unknown outcomes and exact receipts. Distinguish a derived projection from an external effect; an unknown external result stays held. | +| Scheduler and optional Host automation: [scheduler state owner](../../../loopx/control_plane/scheduler/state_store.ts), [backup discovery](../../../loopx/state_backup.py) | Runtime scheduler files and explicitly included Codex automation/skill directories can be copied. A saved timer is not an observed active Host schedule. | Bind cadence/reset identity, reconcile actual Host state and exclude duplicate admission. Other Host/provider state needs its own explicit capture contract. | +| Knowledge/artifacts, extension state and credentials: the selected Goal's material and provider owners, including [decision-context recovery](../../../loopx/capabilities/decision_context/capture_recovery.py) where applicable | Only discovered roots are included; a physical SQLite snapshot proves no semantic provider completeness. Credentials may be present in private raw bytes. | Enumerate actual required sources and provider versions; obtain their own export/verify evidence. Credential custody, rotation/revocation and external access remain with their owners; copied bytes grant no access. | + +A concrete route-coverage gap is the [source-session lifecycle journal +root](../../../loopx/control_plane/goals/source_session_registry_state.py): it +is adjacent to the source registry under `.loopx/lifecycle/goal-instance`. +Copying an external registry file does not include that sibling directory when +it falls outside every included root. M2 must resolve this owner's actual +storage, rather than infer completeness from the registry file or archive size. + +**Observed ordering counterexample and bounded refusal.** The earlier producer +could copy raw registry A and later encode configuration B while every byte and +configuration digest passed. The current full producer retains the exact source +snapshots used for decoding, compares all mapped regular copies, and refuses +publication when they differ or lack a witness. File/SQLite tests cover both a +persistent change and A → B → A; a later live-path reread cannot prove this case. +Routed sources and missing witnesses have separate negative coverage. + +Historical v0 archives can still contain that combination. A legacy-archive +fixture in [the recovery suite](../../../tests/test_state_recovery.py) deliberately +retains earlier raw registry/work and later owner-verified configuration, without +new capture claims. After the original sources disappear, M1 reads the earlier +work and retains `held`, `legacy_best_effort`, `legacy_manifest_incomplete` and +no execution authority. This preserves historical readback while tightening new +publication; it does not qualify an `online` profile or the full M2 inventory. +The lifecycle journal gap above does not authorize generic reads of the +lifecycle-only `source_session_v1` profile, which remains refused. + +The next implementation slice belongs to the existing backup/recovery workflow: +resolve one Goal's required owner closure, capture configuration and canonical +authority through those owners, and record their source intervals/dependencies +before any profile can be qualified. Reuse the typed authority export/snapshot +and configuration envelope; Python remains the filesystem/transport adapter. +Exercise concurrent writes, a missing required owner and moved dependencies +through the real producer and inert verifier. Other rows remain explicit holds +until their owner evidence exists. M2's complete-inventory entry and +profile-specific exits, M3 admission, and the frozen RPO/RTO criteria stay open; +this inventory neither adds a parallel coordinator nor changes their criteria. + ### Implementation ownership and reuse evidence M3 implementation records a decision-owner matrix before code is admitted. For diff --git a/docs/architecture/rfcs/complete-state-recovery-v0.zh-CN.md b/docs/architecture/rfcs/complete-state-recovery-v0.zh-CN.md index 224b3bab4f..54737294b0 100644 --- a/docs/architecture/rfcs/complete-state-recovery-v0.zh-CN.md +++ b/docs/architecture/rfcs/complete-state-recovery-v0.zh-CN.md @@ -434,6 +434,51 @@ grant。协调器记录这些 receipt,但不能铸造或扩大它们。 identity;Agent 永远不获得数据库访问权。NoKV 或其他 provider 在独立证明同一 逻辑契约前不受支持。通用 filesystem copy 不能代替 provider profile。 +### 首个本地 profile 的捕获 owner 清单 + +下列源码清单面向首个单 Goal File/SQLite profile,标明 M2 应从哪里取得证据, +**不证明**某份备份已经包含全部必要组件。实际捕获必须解析所选 Goal 的 route 与 +dependency closure,区分 owner 确认不存在、来源不可读和来源无法识别,并让尚未 +解决的必要 owner 保持 hold。可选安装不意味着活跃 Goal 的依赖也是可选的。 + +| 可变边界与既有 owner | 当前捕获/读回 | 尚缺的捕获或 adoption 证据 | +| --- | --- | --- | +| Registry route 与 machine/Goal 配置:[配置捕获 adapter](../../../loopx/capabilities/configuration_backup.py)、[类型化 checkpoint envelope](../../../loopx/control_plane/configuration_backup.ts)、[machine 配置 store](../../../loopx/capabilities/machine_configuration/store.py) | 完整备份比较配置解码实际使用的 registry 字节与已复制字节见证;缺少副本或字节不同则拒绝,包括 A → B → A。私有清单记录匹配 revision 与配置捕获区间。 | 仍需绑定 machine 配置、authority 及其他必要 owner 的独立区间与依赖;registry 字节一致和有效 envelope 不证明跨 owner 一致性。 | +| Goal 正文、vision 与权威材料引用:registry 解析的 active state、[vision checkpoint owner](../../../loopx/control_plane/goals/vision_checkpoint.ts) | 复制发现的 active-state 路径及 included root 内文件,不递归解析外部材料引用。 | 保留完整 Goal 与绑定 revision 的必要 artifact,分类不可用依赖,并让派生 Markdown 投影服从其 owner。 | +| Canonical 工作、租约及保留的 operation history:[本地 provider selection](../../../loopx/control_plane/coordination/local_authority_provider.ts)、[authority archive owner](../../../loopx/control_plane/coordination/authority_archive.ts)、[legacy writer fence](../../../loopx/control_plane/coordination/legacy_writer_fence.ts) | File head/history 位于同一原子 envelope;SQLite 使用[既有 physical snapshot owner](../../../loopx/control_plane/coordination/sqlite_backup.ts)。M1 只打开已有 store 并验证 head/history,不选定 live 目标。 | 绑定 provider/store identity、selector、fence、revision 与 cursor,取得 owner export 或合格 snapshot。组件本地一致性不证明跨 owner 一致性,也不使历史租约有效。 | +| Quota 记账与 Turn settlement:[accounting artifact transaction](../../../loopx/control_plane/quota/accounting_artifact_transaction.ts)、[spend owner](../../../loopx/control_plane/quota/spend_commit.ts)、[Turn journal commit](../../../loopx/control_plane/turn_driver/turn_journal_effects.ts) | 保留 included runtime/project root 内文件;M1 不 reconcile 其 transaction lineage。 | 将已提交 receipt、index 与进行中 journal input 按精确 settlement identity 一起捕获;由原 owner 处置 replay 或歧义,避免第二次扣账。 | +| Goal lifetime 与 session/execution binding:[source-session lifetime owner](../../../loopx/control_plane/goals/source_session_lifetime.ts)、[Chat session lifecycle](../../../loopx/control_plane/goals/chat_session_lifecycle.ts)、[execution identity](../../../loopx/control_plane/runtime/execution_identity.ts) | 原始 route/journal 字节可能存在;发现范围外的原生 Host session store 不会被隐式捕获或采用。 | 解析真实入口的 owner、lifetime 与 generation,在原入口继续之前证明 stale executor 排除与合格 session recovery 路径。 | +| Pending effect、delivery 与派生投影 outbox:[source Turn effect gate](../../../loopx/control_plane/goals/source_session_lifetime.ts)、[return-delivery classifier](../../../loopx/control_plane/collaboration/return_delivery.ts)、[authority shadow outbox](../../../loopx/control_plane/coordination/local_authority_shadow_outbox.ts) | Included journal/outbox 只保留为字节,不推断外部结果或 replay 资格。 | 每个 effect/transport owner 处置 committed、absent 或 unknown 结果及精确 receipt;区分派生投影与外部 effect,未知外部结果保持 hold。 | +| Scheduler 与可选 Host automation:[scheduler state owner](../../../loopx/control_plane/scheduler/state_store.ts)、[备份发现](../../../loopx/state_backup.py) | 可复制 runtime scheduler 文件及显式纳入的 Codex automation/skill 目录;保存的 timer 不是观察到的活跃 Host schedule。 | 绑定 cadence/reset identity,reconcile 真实 Host 状态并排除重复 admission;其他 Host/provider 状态需要独立、显式的捕获契约。 | +| Knowledge/artifact、extension 状态与 credential:所选 Goal 的 material/provider owner,适用时包括 [decision-context recovery](../../../loopx/capabilities/decision_context/capture_recovery.py) | 只纳入发现的 root;physical SQLite snapshot 不证明 provider 语义完整性。私有原始字节可能含 credential。 | 枚举实际必要来源与 provider version,取得各自 export/verify 证据。Credential custody、轮换/撤销及外部访问仍归各自 owner;复制字节不授予访问权。 | + +一个具体 route 覆盖缺口是 [source-session lifecycle journal +root](../../../loopx/control_plane/goals/source_session_registry_state.py):它位于 +source registry 旁的 `.loopx/lifecycle/goal-instance`。外部 registry 的这一相邻目录 +若不在任何 included root 内,复制 registry 文件并不会纳入它。M2 必须解析该 owner +的实际 storage,不能从 registry 文件存在或归档大小推断完整性。 + +**顺序反例与有界拒绝。** 旧 producer 可能先复制 registry A、再编码配置 B, +同时通过所有字节与配置 digest 检查。当前完整 producer 保留解码实际使用的源 +快照,比较每份已映射普通文件副本;字节不同或缺少见证就停止发布。File/SQLite +覆盖持续变化与 A → B → A 回退,事后重读 live 路径不能证明这类情况;路由到 +外部的源 registry 与缺少见证各有负向覆盖。 + +历史 v0 归档仍可能包含上述组合。[恢复测试](../../../tests/test_state_recovery.py) +显式构造保留较早原始 registry/工作、较晚 owner 验证配置的旧归档,不携带新的 +捕获声明。原来源消失后,M1 读回较早工作并保留 `held`、`legacy_best_effort`、 +`legacy_manifest_incomplete` 及无执行权边界。这保留历史读回,同时收紧新发布; +不代表 `online` profile 或 M2 完整清单合格。上面的生命周期日志缺口也不授权 +通用备份绕过仅供生命周期操作的 `source_session_v1` 拒绝规则。 + +下一实现切片归属既有 backup/recovery workflow:解析一个 Goal 的必要 owner +closure,经各自 owner 捕获配置与 canonical authority,在任何 profile 合格前记录 +source interval/dependency。复用类型化 authority export/snapshot 与配置 envelope, +Python 保留 filesystem/transport adapter 角色。通过真实 producer 与惰性 verifier +覆盖并发写入、必要 owner 缺失和 dependency 移动;其他行在取得 owner 证据前显式 +保持 hold。M2 完整清单准入及各 profile 出口、M3 admission、冻结的 RPO/RTO 条件 +仍开放;本清单不新增平行 coordinator,也不改变这些条件。 + ### 实现归属与复用证据 M3 实现必须在代码准入前记录 decision-owner matrix。对于每个 Goal identity、 diff --git a/tests/test_state_recovery.py b/tests/test_state_recovery.py index 860b2ce393..d58d341658 100644 --- a/tests/test_state_recovery.py +++ b/tests/test_state_recovery.py @@ -120,6 +120,47 @@ def _rewrite(archive: Path, manifest: Path, extra: tarfile.TarInfo | None = None manifest.write_text(json.dumps(data)) +@pytest.mark.parametrize("provider", ["file", "sqlite"]) +def test_legacy_mixed_revision_archive_remains_readable_and_inert(tmp_path, provider): + from loopx.capabilities.configuration_backup import capture_configuration_backup, verify_configuration_backup + + archive, manifest = _backup(tmp_path, provider) + registry = tmp_path / "registry.json" + value = json.loads(registry.read_text()) + value["goals"][0]["description"] = "Later configuration in a historical archive" + registry.write_text(json.dumps(value)) + later = capture_configuration_backup(registry_path=registry, runtime_root=tmp_path / "runtime") + content = json.dumps(later, ensure_ascii=False, indent=2).encode() + # Construct the supported historical v0 case deliberately. The current + # producer refuses it; its new provenance claims must not enter this fixture. + legacy = json.loads(manifest.read_text()) + legacy["registry_discovery"].pop("goal_runtime_roots") + execution = legacy["execution"] + execution.pop("configuration_capture") + execution["configuration_backup"] = verify_configuration_backup(later) + execution["archive_sha256"] = execution["archive_size_bytes"] = None + execution.pop("archive_to_logical_ratio", None) + for row in execution["file_members"]: + if row["archive_path"] == "configuration-backup.json": + row.update(size_bytes=len(content), sha256=hashlib.sha256(content).hexdigest()) + manifest.write_text(json.dumps(legacy)) + _rewrite(archive, manifest, replacements={"configuration-backup.json": content, + "manifest.json": json.dumps(legacy).encode()}) + with tarfile.open(archive) as saved: + earlier = json.load(saved.extractfile("configuration/registry.source.json")) + assert "description" not in earlier["goals"][0] + for name in ("project", "runtime", "registry.json"): + (tmp_path / name).rename(tmp_path / f"unavailable-{name}") + audit = verify_state_backup(archive=archive, manifest=manifest, destination=tmp_path / "inert", execute=True) + [work] = [row for row in audit["components"] if row["owner_kind"] == "coordination_authority"] + assert work["state"] == "readable" and work["facts"]["todo_count"] == 1 + assert work["facts"]["history_commits"] == 2 + assert audit["manifest_verified"] is True and audit["file_witnesses_verified"] is True + assert audit["status"] == "held" and audit["capture_profile"] == "legacy_best_effort" + assert any(row["reason_code"] == "legacy_manifest_incomplete" for row in audit["findings"]) + assert audit["activation_eligible"] is False and audit["execution_authority_granted"] is False + + def _global_source_backup(root: Path, provider: str) -> tuple[dict, dict[str, bytes]]: registry, source_runtime, state = promoted_create_fixture(root / "host/canonical", provider=provider) _, shared_runtime, _ = promoted_create_fixture(root / "host", provider="sqlite" if provider == "file" else "file") From 7ad74e3f786ee3be70e15219fb9f970a9d6f14be Mon Sep 17 00:00:00 2001 From: LoopX Agent <337587101+loopx-agent@users.noreply.github.com> Date: Fri, 16 Oct 2026 20:45:40 +0800 Subject: [PATCH 08/10] fix(backup): bind machine configuration and absent sources to copied bytes Signed-off-by: LoopX Agent <337587101+loopx-agent@users.noreply.github.com> --- loopx/capabilities/configuration_backup.py | 22 +++-- .../machine_configuration/store.py | 26 +++++- loopx/state_backup.py | 16 ++-- tests/test_configuration_backup.py | 83 ++++++++++++++++++- tests/test_state_recovery.py | 23 +++-- 5 files changed, 147 insertions(+), 23 deletions(-) diff --git a/loopx/capabilities/configuration_backup.py b/loopx/capabilities/configuration_backup.py index 555b83c285..2391b35a43 100644 --- a/loopx/capabilities/configuration_backup.py +++ b/loopx/capabilities/configuration_backup.py @@ -11,20 +11,29 @@ def capture_configuration_backup( *, registry_path: Path, runtime_root: Path, goal_ids: list[str] | None = None, - registry_snapshots: dict[Path, bytes] | None = None, + source_snapshots: dict[Path, bytes | None] | None = None, ) -> dict[str, Any]: # One immutable read per source supplies both the decoded values and, when # requested by full backup, their exact IO provenance. A later path reread # cannot witness the bytes used here (the source may change A -> B -> A). - sources = registry_snapshots if registry_snapshots is not None else {} + sources = source_snapshots if source_snapshots is not None else {} def read_registry(path: Path) -> dict[str, Any]: source = path.expanduser().resolve() if source not in sources: - sources[source] = source.read_bytes() - return decode_registry_snapshot(path, sources[source]) + try: + sources[source] = source.read_bytes() + except FileNotFoundError: + sources[source] = None + raw = sources[source] + if raw is None: + raise FileNotFoundError(path) + return decode_registry_snapshot(path, raw) - registry = read_registry(registry_path) if registry_path.exists() else {"goals": []} + try: + registry = read_registry(registry_path) + except FileNotFoundError: + registry = {"goals": []} selected = goal_ids if goal_ids is not None else [str(goal["id"]) for goal in registry_goals(registry)] if len(selected) != len(set(selected)): raise ValueError("configuration backup Goal ids must be unique") @@ -37,7 +46,8 @@ def read_registry(path: Path) -> dict[str, Any]: raise ValueError("configuration backup requires exactly one source-owned Goal") snapshots.append({"goal_id": goal_id, "goal_configuration": matches[0]}) data = { - "machine_configuration": read_stored_machine_configuration(runtime_root), "goals": snapshots, + "machine_configuration": read_stored_machine_configuration( + runtime_root, source_snapshots=source_snapshots), "goals": snapshots, } backup: dict[str, Any] = effect_runtime_result("configuration.backup", {"action": "capture", "data": data}) if backup.get("data") != data: diff --git a/loopx/capabilities/machine_configuration/store.py b/loopx/capabilities/machine_configuration/store.py index 807100a66e..623b95bfc0 100644 --- a/loopx/capabilities/machine_configuration/store.py +++ b/loopx/capabilities/machine_configuration/store.py @@ -1,5 +1,6 @@ from __future__ import annotations +import json import re from collections.abc import Callable, Mapping from datetime import datetime, timezone @@ -115,7 +116,9 @@ def read_machine_configuration( return normalized -def read_stored_machine_configuration(runtime_root: Path) -> dict[str, Any] | None: +def read_stored_machine_configuration( + runtime_root: Path, *, source_snapshots: dict[Path, bytes | None] | None = None, +) -> dict[str, Any] | None: """Read the exact stored envelope for fenced repair and rollback. Normal reads remain fail-closed through ``read_machine_configuration``. @@ -124,9 +127,24 @@ def read_stored_machine_configuration(runtime_root: Path) -> dict[str, Any] | No """ path = machine_configuration_store_path(runtime_root) - if not path.is_file(): - return None - payload = read_json(path) + if source_snapshots is None: + if not path.is_file(): + return None + payload = read_json(path) + else: + # The backup caller needs the exact bytes this owner decoded, including + # a confirmed absence. A later live read cannot witness this snapshot. + if path not in source_snapshots: + try: + source_snapshots[path] = path.read_bytes() + except FileNotFoundError: + if path.is_symlink(): + raise ValueError("machine configuration source is a broken symbolic link") from None + source_snapshots[path] = None + raw = source_snapshots[path] + if raw is None: + return None + payload = json.loads(raw.decode("utf-8")) if not isinstance(payload, dict): raise ValueError("machine_configuration must be an object") return payload diff --git a/loopx/state_backup.py b/loopx/state_backup.py index 775f65de98..58d015b929 100644 --- a/loopx/state_backup.py +++ b/loopx/state_backup.py @@ -647,7 +647,7 @@ def _sha256_file(path: Path) -> str: def _configuration_source_witnesses( - sources: dict[Path, bytes], included: list[dict[str, Any]], members: list[dict[str, Any]], + sources: dict[Path, bytes | None], included: list[dict[str, Any]], members: list[dict[str, Any]], ) -> list[dict[str, Any]]: """Compare the actual decoded source bytes with every regular tar copy. @@ -664,11 +664,15 @@ def _configuration_source_witnesses( continue paths.add(str(PurePosixPath(target["archive_path"]) / relative.as_posix())) witnesses = [row for row in members if row["archive_path"] in paths] + if raw is None: + if witnesses: + raise ValueError("backup configuration source absence differs from copied bytes; retry the backup") + continue sha256 = hashlib.sha256(raw).hexdigest() if not witnesses: - raise ValueError("backup configuration registry has no copied bytes witness; include its regular source file") + raise ValueError("backup configuration source has no copied bytes witness; include its regular source file") if any(row["sha256"] != sha256 for row in witnesses): - raise ValueError("backup configuration registry differs from copied bytes; retry the backup") + raise ValueError("backup configuration source differs from copied bytes; retry the backup") result.extend(witnesses) return result @@ -746,20 +750,20 @@ def execute_state_backup_plan(payload: dict[str, Any]) -> dict[str, Any]: capture_configuration_backup, verify_configuration_backup, ) - registry_snapshots: dict[Path, bytes] = {} + source_snapshots: dict[Path, bytes | None] = {} configuration_started_at = datetime.now(timezone.utc).isoformat() configuration = capture_configuration_backup( registry_path=Path(payload["configuration_source_registry"]), runtime_root=Path(payload["runtime_root"]), goal_ids=payload.get("configuration_goal_ids"), - registry_snapshots=registry_snapshots, + source_snapshots=source_snapshots if tar.record_file_members else None, ) if tar.record_file_members: updated["execution"]["configuration_capture"] = { "started_at": configuration_started_at, "completed_at": datetime.now(timezone.utc).isoformat(), "source_revisions": _configuration_source_witnesses( - registry_snapshots, included, tar.file_members), + source_snapshots, included, tar.file_members), } configuration_bytes = json.dumps(configuration, ensure_ascii=False, indent=2).encode("utf-8") info = tarfile.TarInfo("configuration-backup.json") diff --git a/tests/test_configuration_backup.py b/tests/test_configuration_backup.py index 3672cb3409..d73985da1d 100644 --- a/tests/test_configuration_backup.py +++ b/tests/test_configuration_backup.py @@ -153,6 +153,7 @@ def test_full_state_backup_has_first_class_configuration_component(environment, assert len(witnesses) >= 2 assert {row["sha256"] for row in witnesses} == { hashlib.sha256(source.read_bytes()).hexdigest(), hashlib.sha256(registry.read_bytes()).hexdigest(), + hashlib.sha256((runtime / "machine/configuration.json").read_bytes()).hexdigest(), } for row in witnesses: raw = archive.extractfile(row["archive_path"]).read() @@ -160,6 +161,86 @@ def test_full_state_backup_has_first_class_configuration_component(environment, assert row["size_bytes"] == len(raw) +@pytest.mark.parametrize("change", ["changed", "removed", "created", "aba"]) +def test_full_backup_refuses_machine_configuration_from_another_capture( + environment, tmp_path, monkeypatch, change, +): + from loopx.capabilities import configuration_backup + + source, runtime, _, _ = environment + path = runtime / "machine/configuration.json" + original = path.read_bytes() + if change == "created": + path.unlink() + original_capture = configuration_backup.capture_configuration_backup + captured = [] + + def between_owner_captures(**kwargs): + # All tar members are already copied. Exercise the real configuration + # owner with a later complete envelope (or its confirmed absence). + if change == "removed": + path.unlink() + else: + replacement = json.loads(original) + replacement["namespaces"]["goal_storage"]["new_goal_provider"] = "file" + path.write_text(json.dumps(replacement)) + checkpoint = original_capture(**kwargs) + captured.append(checkpoint["data"]["machine_configuration"]) + if change == "aba": + path.write_bytes(original) + return checkpoint + + monkeypatch.setattr(configuration_backup, "capture_configuration_backup", between_owner_captures) + plan = build_state_backup_plan(project=source.parent.parent, runtime_root=runtime, + output_dir=tmp_path / "backups", include_skills=False, include_automations=False) + with pytest.raises(ValueError, match="configuration .*copied bytes"): + execute_state_backup_plan(plan) + assert len(captured) == 1 + assert not list((tmp_path / "backups").iterdir()), "contradictory configuration must not publish" + + +@pytest.mark.parametrize("invalid", ["directory", "malformed", "broken-link"]) +def test_full_backup_does_not_treat_unreadable_machine_source_as_absent( + environment, tmp_path, invalid, +): + source, runtime, _, _ = environment + path = runtime / "machine/configuration.json" + path.unlink() + if invalid == "directory": + path.mkdir() + elif invalid == "malformed": + path.write_bytes(b"not a configuration envelope") + else: + if os.name == "nt": + pytest.skip("native Windows symlink privilege is not qualified") + path.symlink_to("unavailable-configuration.json") + plan = build_state_backup_plan(project=source.parent.parent, runtime_root=runtime, + output_dir=tmp_path / "backups", include_skills=False, include_automations=False) + with pytest.raises((ValueError, OSError)): + execute_state_backup_plan(plan) + assert not list((tmp_path / "backups").iterdir()) + + +def test_full_backup_refuses_registry_absence_after_its_bytes_were_copied( + environment, tmp_path, monkeypatch, +): + from loopx.capabilities import configuration_backup + + source, runtime, registry, _ = environment + original_capture = configuration_backup.capture_configuration_backup + + def after_copy(**kwargs): + registry.unlink() + return original_capture(**kwargs) + + monkeypatch.setattr(configuration_backup, "capture_configuration_backup", after_copy) + plan = build_state_backup_plan(project=source.parent.parent, runtime_root=runtime, + output_dir=tmp_path / "backups", include_skills=False, include_automations=False) + with pytest.raises(ValueError, match="configuration source absence differs from copied bytes"): + execute_state_backup_plan(plan) + assert not list((tmp_path / "backups").iterdir()) + + def test_full_backup_refuses_a_changed_routed_registry_source(environment, tmp_path, monkeypatch): source, runtime, _, _ = environment original_add = tarfile.TarFile.add @@ -177,7 +258,7 @@ def after_copy(tar, path, *args, **kwargs): monkeypatch.setattr(tarfile.TarFile, "add", after_copy) plan = build_state_backup_plan(project=source.parent.parent, runtime_root=runtime, output_dir=tmp_path / "backups", include_skills=False, include_automations=False) - with pytest.raises(ValueError, match="configuration registry differs from copied bytes"): + with pytest.raises(ValueError, match="configuration source differs from copied bytes"): execute_state_backup_plan(plan) assert changed == [True] assert not list((tmp_path / "backups").iterdir()) diff --git a/tests/test_state_recovery.py b/tests/test_state_recovery.py index d58d341658..fe3d5209a4 100644 --- a/tests/test_state_recovery.py +++ b/tests/test_state_recovery.py @@ -82,7 +82,7 @@ def after_capture(**kwargs): monkeypatch.setattr(tarfile.TarFile, "add", after_copy) monkeypatch.setattr(configuration_backup, "capture_configuration_backup", after_capture) - with pytest.raises(ValueError, match="configuration registry.*copied bytes"): + with pytest.raises(ValueError, match="configuration source.*copied bytes"): _backup(tmp_path, provider) assert len(copied) == 1 assert not list((tmp_path / "backups").iterdir()), "a mixed backup pair must not be published" @@ -97,7 +97,7 @@ def test_backup_refuses_a_required_configuration_source_without_copied_bytes(tmp output_dir=tmp_path / "backups", include_registry_projects=False, include_automations=False, include_skills=False) plan["included"] = [row for row in plan["included"] if row["key"] != "configuration_source_registry"] - with pytest.raises(ValueError, match="configuration registry has no copied bytes witness"): + with pytest.raises(ValueError, match="configuration source has no copied bytes witness"): execute_state_backup_plan(plan) assert not list((tmp_path / "backups").iterdir()) @@ -121,14 +121,21 @@ def _rewrite(archive: Path, manifest: Path, extra: tarfile.TarInfo | None = None @pytest.mark.parametrize("provider", ["file", "sqlite"]) -def test_legacy_mixed_revision_archive_remains_readable_and_inert(tmp_path, provider): +@pytest.mark.parametrize("owner", ["registry", "machine"]) +def test_legacy_mixed_revision_archive_remains_readable_and_inert(tmp_path, provider, owner): from loopx.capabilities.configuration_backup import capture_configuration_backup, verify_configuration_backup archive, manifest = _backup(tmp_path, provider) registry = tmp_path / "registry.json" - value = json.loads(registry.read_text()) - value["goals"][0]["description"] = "Later configuration in a historical archive" - registry.write_text(json.dumps(value)) + if owner == "registry": + value = json.loads(registry.read_text()) + value["goals"][0]["description"] = "Later configuration in a historical archive" + registry.write_text(json.dumps(value)) + else: + machine = tmp_path / "runtime/machine/configuration.json" + machine.parent.mkdir(exist_ok=True) + machine.write_text(json.dumps({"schema_version": "loopx_machine_configuration_v0", "namespaces": { + "goal_storage": {"schema_version": "loopx_goal_storage_defaults_v0", "new_goal_provider": "sqlite"}}})) later = capture_configuration_backup(registry_path=registry, runtime_root=tmp_path / "runtime") content = json.dumps(later, ensure_ascii=False, indent=2).encode() # Construct the supported historical v0 case deliberately. The current @@ -149,6 +156,10 @@ def test_legacy_mixed_revision_archive_remains_readable_and_inert(tmp_path, prov with tarfile.open(archive) as saved: earlier = json.load(saved.extractfile("configuration/registry.source.json")) assert "description" not in earlier["goals"][0] + if owner == "machine": + assert later["data"]["machine_configuration"] is not None + with tarfile.open(archive) as saved: + assert "runtime-root/machine/configuration.json" not in saved.getnames() for name in ("project", "runtime", "registry.json"): (tmp_path / name).rename(tmp_path / f"unavailable-{name}") audit = verify_state_backup(archive=archive, manifest=manifest, destination=tmp_path / "inert", execute=True) From aba82bbe9bfe92e586d8cad6d0008515cd876f00 Mon Sep 17 00:00:00 2001 From: LoopX Agent <337587101+loopx-agent@users.noreply.github.com> Date: Fri, 16 Oct 2026 20:45:40 +0800 Subject: [PATCH 09/10] docs(backup): disclose complete configuration source refusal Signed-off-by: LoopX Agent <337587101+loopx-agent@users.noreply.github.com> --- .../rfcs/complete-state-recovery-v0.md | 9 +++- .../rfcs/complete-state-recovery-v0.zh-CN.md | 7 ++- docs/reference/configuration-backup.md | 52 ++++++++++--------- 3 files changed, 42 insertions(+), 26 deletions(-) diff --git a/docs/architecture/rfcs/complete-state-recovery-v0.md b/docs/architecture/rfcs/complete-state-recovery-v0.md index 8d4989bb31..4bdd2cee9b 100644 --- a/docs/architecture/rfcs/complete-state-recovery-v0.md +++ b/docs/architecture/rfcs/complete-state-recovery-v0.md @@ -524,7 +524,7 @@ active Goal dependency optional. | Mutable boundary and existing owner | Current capture/readback | Remaining capture or adoption evidence | | --- | --- | --- | -| Registry routing and machine/Goal configuration: [configuration capture adapter](../../../loopx/capabilities/configuration_backup.py), [typed checkpoint envelope](../../../loopx/control_plane/configuration_backup.ts), [machine configuration store](../../../loopx/capabilities/machine_configuration/store.py) | Full backups compare the exact registry bytes used to decode configuration with their copied-byte witnesses and refuse missing or differing copies, including A → B → A. The private manifest records matching revisions and the configuration capture interval. | Bind machine configuration, authority and other required owners to their own intervals and dependencies. Registry byte agreement and a valid envelope do not prove cross-owner consistency. | +| Registry routing and machine/Goal configuration: [configuration capture adapter](../../../loopx/capabilities/configuration_backup.py), [typed checkpoint envelope](../../../loopx/control_plane/configuration_backup.ts), [machine configuration store](../../../loopx/capabilities/machine_configuration/store.py) | Full backups compare the registry and stored machine-configuration bytes actually decoded by their owners with mapped copied-byte witnesses. Missing/differing copies, A → B → A and a captured absence beside an earlier copied file refuse publication. The private manifest records matching revisions and the configuration capture interval. | Bind authority and every other required owner to their own intervals and dependencies. Configuration-source agreement and a valid envelope do not prove cross-owner consistency. | | Goal narrative, vision and authority material references: registry-resolved active state, [vision checkpoint owner](../../../loopx/control_plane/goals/vision_checkpoint.ts) | Discovered active-state paths and files beneath included roots are copied. External referenced material is not recursively resolved. | Preserve the full Goal and revision-bound required artifacts, classify unavailable dependencies, and keep derived Markdown projections subordinate to their owners. | | Canonical work, leases and retained operation history: [local provider selection](../../../loopx/control_plane/coordination/local_authority_provider.ts), [authority archive owner](../../../loopx/control_plane/coordination/authority_archive.ts), [legacy writer fence](../../../loopx/control_plane/coordination/legacy_writer_fence.ts) | File head/history share an atomic envelope; SQLite uses the [existing physical snapshot owner](../../../loopx/control_plane/coordination/sqlite_backup.ts). M1 opens existing stores and verifies head/history without selecting a live destination. | Bind provider/store identity, selector, fence, revision and cursor; obtain an owner export or qualified snapshot. Component-local consistency cannot establish cross-owner consistency or make historical leases valid. | | Quota accounting and Turn settlement: [accounting artifact transaction](../../../loopx/control_plane/quota/accounting_artifact_transaction.ts), [spend owner](../../../loopx/control_plane/quota/spend_commit.ts), [Turn journal commit](../../../loopx/control_plane/turn_driver/turn_journal_effects.ts) | Files under included runtime/project roots are retained; M1 does not reconcile their transaction lineage. | Capture committed receipts, indexes and in-flight journal inputs together with their exact settlement identity; let the original owner resolve replay or ambiguity without a second debit. | @@ -548,6 +548,13 @@ publication when they differ or lack a witness. File/SQLite tests cover both a persistent change and A → B → A; a later live-path reread cannot prove this case. Routed sources and missing witnesses have separate negative coverage. +The same rule covers the existing stored machine-configuration owner. Its exact +read supplies the checkpoint and witness together, including confirmed absence; +changed, newly created or removed configuration cannot contradict the raw copy. +Unusable machine sources refuse capture rather than become an absent envelope. +This closes the configuration-source IO boundary, not the complete recovery +set's owner closure or consistency profile. + Historical v0 archives can still contain that combination. A legacy-archive fixture in [the recovery suite](../../../tests/test_state_recovery.py) deliberately retains earlier raw registry/work and later owner-verified configuration, without diff --git a/docs/architecture/rfcs/complete-state-recovery-v0.zh-CN.md b/docs/architecture/rfcs/complete-state-recovery-v0.zh-CN.md index 54737294b0..f004bdfd8b 100644 --- a/docs/architecture/rfcs/complete-state-recovery-v0.zh-CN.md +++ b/docs/architecture/rfcs/complete-state-recovery-v0.zh-CN.md @@ -443,7 +443,7 @@ dependency closure,区分 owner 确认不存在、来源不可读和来源无 | 可变边界与既有 owner | 当前捕获/读回 | 尚缺的捕获或 adoption 证据 | | --- | --- | --- | -| Registry route 与 machine/Goal 配置:[配置捕获 adapter](../../../loopx/capabilities/configuration_backup.py)、[类型化 checkpoint envelope](../../../loopx/control_plane/configuration_backup.ts)、[machine 配置 store](../../../loopx/capabilities/machine_configuration/store.py) | 完整备份比较配置解码实际使用的 registry 字节与已复制字节见证;缺少副本或字节不同则拒绝,包括 A → B → A。私有清单记录匹配 revision 与配置捕获区间。 | 仍需绑定 machine 配置、authority 及其他必要 owner 的独立区间与依赖;registry 字节一致和有效 envelope 不证明跨 owner 一致性。 | +| Registry route 与 machine/Goal 配置:[配置捕获 adapter](../../../loopx/capabilities/configuration_backup.py)、[类型化 checkpoint envelope](../../../loopx/control_plane/configuration_backup.ts)、[machine 配置 store](../../../loopx/capabilities/machine_configuration/store.py) | 完整备份比较各自 owner 实际解码的 registry 与 machine 配置字节和映射副本见证;缺失/不同副本、A → B → A,以及确认不存在却仍保留旧副本均拒绝发布。私有清单记录匹配 revision 与配置捕获区间。 | 仍需绑定 authority 和其他所有必要 owner 的独立区间与依赖;配置来源一致和有效 envelope 不证明跨 owner 一致性。 | | Goal 正文、vision 与权威材料引用:registry 解析的 active state、[vision checkpoint owner](../../../loopx/control_plane/goals/vision_checkpoint.ts) | 复制发现的 active-state 路径及 included root 内文件,不递归解析外部材料引用。 | 保留完整 Goal 与绑定 revision 的必要 artifact,分类不可用依赖,并让派生 Markdown 投影服从其 owner。 | | Canonical 工作、租约及保留的 operation history:[本地 provider selection](../../../loopx/control_plane/coordination/local_authority_provider.ts)、[authority archive owner](../../../loopx/control_plane/coordination/authority_archive.ts)、[legacy writer fence](../../../loopx/control_plane/coordination/legacy_writer_fence.ts) | File head/history 位于同一原子 envelope;SQLite 使用[既有 physical snapshot owner](../../../loopx/control_plane/coordination/sqlite_backup.ts)。M1 只打开已有 store 并验证 head/history,不选定 live 目标。 | 绑定 provider/store identity、selector、fence、revision 与 cursor,取得 owner export 或合格 snapshot。组件本地一致性不证明跨 owner 一致性,也不使历史租约有效。 | | Quota 记账与 Turn settlement:[accounting artifact transaction](../../../loopx/control_plane/quota/accounting_artifact_transaction.ts)、[spend owner](../../../loopx/control_plane/quota/spend_commit.ts)、[Turn journal commit](../../../loopx/control_plane/turn_driver/turn_journal_effects.ts) | 保留 included runtime/project root 内文件;M1 不 reconcile 其 transaction lineage。 | 将已提交 receipt、index 与进行中 journal input 按精确 settlement identity 一起捕获;由原 owner 处置 replay 或歧义,避免第二次扣账。 | @@ -464,6 +464,11 @@ source registry 旁的 `.loopx/lifecycle/goal-instance`。外部 registry 的这 覆盖持续变化与 A → B → A 回退,事后重读 live 路径不能证明这类情况;路由到 外部的源 registry 与缺少见证各有负向覆盖。 +同一规则也覆盖既有 machine 配置 store owner:checkpoint 和见证使用同一次 +实际读取,包括确认不存在。变更、新建或删除配置都不能与已复制文件矛盾; +不可用的 machine 来源拒绝捕获,不被标成不存在。这只关闭配置来源的 IO 边界, +尚未关闭完整恢复集的 owner closure 或一致性 profile。 + 历史 v0 归档仍可能包含上述组合。[恢复测试](../../../tests/test_state_recovery.py) 显式构造保留较早原始 registry/工作、较晚 owner 验证配置的旧归档,不携带新的 捕获声明。原来源消失后,M1 读回较早工作并保留 `held`、`legacy_best_effort`、 diff --git a/docs/reference/configuration-backup.md b/docs/reference/configuration-backup.md index 6b37d24859..00134ee0e0 100644 --- a/docs/reference/configuration-backup.md +++ b/docs/reference/configuration-backup.md @@ -180,32 +180,36 @@ and Host quiescence, lease/outbox disposition, immutable source/target binding, explicit confirmation and original-operation recovery. Neither this manifest nor configuration verification authorizes an import or a new execution grant. -Full backups also compare every regular copied registry member with the exact -source snapshot decoded into `configuration-backup.json`. If a required snapshot -has no copied-byte witness, capture refuses publication and asks for its regular -source file. A revision mismatch reports -`backup configuration registry differs from copied bytes; retry the backup`. -This changes the earlier best-effort behavior that could publish an older raw -registry beside newer configuration. A later source-path reread cannot replace -the snapshot witness: an A → B → A change must still be caught when the checkpoint -used B. Unchanged sources retain the same configuration envelope and v0 archive -format. The bounded migration backup keeps its existing source-owner witness -contract. Older archives remain readable under the existing recovery holds. +Full backups also compare the registry and stored machine-configuration bytes +actually decoded into `configuration-backup.json` with every mapped regular tar +copy. Missing witnesses or different bytes refuse publication; an owner-confirmed +absence also refuses when the archive contains that source's earlier file. +A revision mismatch reports +`backup configuration source differs from copied bytes; retry the backup`. +This tightens the earlier best-effort behavior that could publish older raw +configuration beside a newer checkpoint, or claim absence beside an earlier +file. A later source-path reread cannot replace the snapshot witness: an +A → B → A change must still be caught when the checkpoint used B. A malformed +or unreadable machine source is not a confirmed absence. Unchanged sources +retain the same configuration envelope and v0 archive format. The bounded +migration backup keeps its existing source-owner witness contract. Older +archives remain readable under the existing recovery holds. The private `execution.configuration_capture` records the configuration capture -interval and matching member revisions. It covers registry bytes used by that -checkpoint, including routed source registries; it does not qualify machine -configuration, authority, effects or other owners as a common point in time, or -make the M2 `online` profile eligible. - -中文:完整备份将配置解码时实际使用的 registry 快照,与归档中每份普通文件副本 -的字节见证比较。缺少见证或字节不同就停止发布,提示重试;原先可能发布“旧原始 -registry + 新配置”的 best-effort 行为因此收紧。比较的不是事后重读路径,所以配置 -使用了 B 时,即使源发生 A → B → A 回退也会拒绝。未变化来源、v0 格式及旧归档 -读回保持兼容;有界 migration backup 仍使用原 source owner 的见证契约。 -`execution.configuration_capture` 私有记录捕获区间与匹配成员 revision,包括路由到 -外部的源 registry;它不证明 machine 配置、authority、effect 等 owner 处于同一时刻, -也不代表 M2 `online` profile 合格。 +interval and matching member revisions, including routed registries and the +stored machine configuration when present. These are configuration-owner IO +facts; they do not establish a common point in time with authority, effects or +other owners, or qualify the M2 `online` profile. + +中文:完整备份将配置 owner 实际解码的 registry 与 machine 配置字节,同每份 +映射的普通 tar 副本比较。缺少见证或字节不同就停止发布;owner 确认源不存在而 +归档仍保留其旧文件,也会拒绝。原先可能发布“旧原始配置 + 新 checkpoint”或把 +旧文件误作不存在的 best-effort 行为因此收紧。比较的不是事后重读路径,所以配置 +使用 B 后即使源回退为 A 仍会拒绝。损坏或不可读的 machine 源不当作确认不存在。 +未变化来源、v0 格式和历史归档读回保持兼容;有界 migration backup 仍使用原 +source owner 的见证契约。私有 `execution.configuration_capture` 记录配置捕获 +区间与匹配成员 revision,包括外部 registry 和存在的 machine 配置;它不证明 +与 authority、effect 等其他 owner 的跨源同时刻,也不代表 M2 `online` 合格。 ## Full-state recovery after cold import From a2c661a3e6bd41462ae1f6a2312ae1a97ddf6a6e Mon Sep 17 00:00:00 2001 From: LoopX Agent <337587101+loopx-agent@users.noreply.github.com> Date: Fri, 16 Oct 2026 21:02:36 +0800 Subject: [PATCH 10/10] fix(backup): bind configuration aliases to their saved archive routes Signed-off-by: LoopX Agent <337587101+loopx-agent@users.noreply.github.com> --- .../rfcs/complete-state-recovery-v0.md | 3 + .../rfcs/complete-state-recovery-v0.zh-CN.md | 4 +- docs/reference/configuration-backup.md | 10 ++- loopx/state_backup.py | 29 ++++++- tests/test_configuration_backup.py | 78 +++++++++++++++++++ 5 files changed, 117 insertions(+), 7 deletions(-) diff --git a/docs/architecture/rfcs/complete-state-recovery-v0.md b/docs/architecture/rfcs/complete-state-recovery-v0.md index 4bdd2cee9b..2c9cac0853 100644 --- a/docs/architecture/rfcs/complete-state-recovery-v0.md +++ b/docs/architecture/rfcs/complete-state-recovery-v0.md @@ -552,6 +552,9 @@ The same rule covers the existing stored machine-configuration owner. Its exact read supplies the checkpoint and witness together, including confirmed absence; changed, newly created or removed configuration cannot contradict the raw copy. Unusable machine sources refuse capture rather than become an absent envelope. +Self-contained local aliases reuse the included regular target witness; a retained +tar link cannot masquerade as an owner-confirmed absence. Repointed aliases, +including A → B → A, are compared through the saved route. This closes the configuration-source IO boundary, not the complete recovery set's owner closure or consistency profile. diff --git a/docs/architecture/rfcs/complete-state-recovery-v0.zh-CN.md b/docs/architecture/rfcs/complete-state-recovery-v0.zh-CN.md index f004bdfd8b..fc544cd556 100644 --- a/docs/architecture/rfcs/complete-state-recovery-v0.zh-CN.md +++ b/docs/architecture/rfcs/complete-state-recovery-v0.zh-CN.md @@ -466,7 +466,9 @@ source registry 旁的 `.loopx/lifecycle/goal-instance`。外部 registry 的这 同一规则也覆盖既有 machine 配置 store owner:checkpoint 和见证使用同一次 实际读取,包括确认不存在。变更、新建或删除配置都不能与已复制文件矛盾; -不可用的 machine 来源拒绝捕获,不被标成不存在。这只关闭配置来源的 IO 边界, +不可用的 machine 来源拒绝捕获,不被标成不存在。自包含的本地链接复用归档内 +普通目标文件的见证;保留的 tar 链接不能伪装成 owner 确认不存在。这只关闭配置 +来源的 IO 边界;链接改向及 A → B → A 均按已保存的 route 核对。 尚未关闭完整恢复集的 owner closure 或一致性 profile。 历史 v0 归档仍可能包含上述组合。[恢复测试](../../../tests/test_state_recovery.py) diff --git a/docs/reference/configuration-backup.md b/docs/reference/configuration-backup.md index 00134ee0e0..0f6e43d81a 100644 --- a/docs/reference/configuration-backup.md +++ b/docs/reference/configuration-backup.md @@ -188,7 +188,10 @@ A revision mismatch reports `backup configuration source differs from copied bytes; retry the backup`. This tightens the earlier best-effort behavior that could publish older raw configuration beside a newer checkpoint, or claim absence beside an earlier -file. A later source-path reread cannot replace the snapshot witness: an +file. Self-contained local aliases resolve through saved tar metadata to an +included regular target witness; repointing a live symlink cannot select another +saved target. A retained tar link also prevents a false absence. A later +source-path reread cannot replace the snapshot witness: an A → B → A change must still be caught when the checkpoint used B. A malformed or unreadable machine source is not a confirmed absence. Unchanged sources retain the same configuration envelope and v0 archive format. The bounded @@ -204,7 +207,10 @@ other owners, or qualify the M2 `online` profile. 中文:完整备份将配置 owner 实际解码的 registry 与 machine 配置字节,同每份 映射的普通 tar 副本比较。缺少见证或字节不同就停止发布;owner 确认源不存在而 归档仍保留其旧文件,也会拒绝。原先可能发布“旧原始配置 + 新 checkpoint”或把 -旧文件误作不存在的 best-effort 行为因此收紧。比较的不是事后重读路径,所以配置 +旧文件误作不存在的 best-effort 行为因此收紧。自包含链接复用归档内普通目标文件: +硬链接与符号链接只按保存的 tar route 解析,live 链接改向不能重选别的见证;保留的 +tar 链接也不能 +被误判为不存在。比较的不是事后重读路径,所以配置 使用 B 后即使源回退为 A 仍会拒绝。损坏或不可读的 machine 源不当作确认不存在。 未变化来源、v0 格式和历史归档读回保持兼容;有界 migration backup 仍使用原 source owner 的见证契约。私有 `execution.configuration_capture` 记录配置捕获 diff --git a/loopx/state_backup.py b/loopx/state_backup.py index 58d015b929..9b5e2af237 100644 --- a/loopx/state_backup.py +++ b/loopx/state_backup.py @@ -7,6 +7,7 @@ import json import os from pathlib import Path, PurePosixPath +import posixpath import re import shutil import tarfile @@ -648,13 +649,25 @@ def _sha256_file(path: Path) -> str: def _configuration_source_witnesses( sources: dict[Path, bytes | None], included: list[dict[str, Any]], members: list[dict[str, Any]], + archive_members: list[tarfile.TarInfo], ) -> list[dict[str, Any]]: """Compare the actual decoded source bytes with every regular tar copy. - Covered aliases may be hardlinks; at least one regular copied-byte witness + Saved aliases resolve to their regular copied-byte witnesses; at least one is required. Equality is an IO check, not cross-owner capture qualification. """ result = [] + # Resolve only saved archive metadata. The live alias may already point at + # another included file whose bytes would otherwise pass independently. + link_targets: dict[str, set[str]] = {} + for member in archive_members: + if member.islnk(): + linked_path = member.linkname + elif member.issym() and not PurePosixPath(member.linkname).is_absolute(): + linked_path = posixpath.normpath(posixpath.join(posixpath.dirname(member.name), member.linkname)) + else: + continue + link_targets.setdefault(member.name, set()).add(linked_path) for source, raw in sources.items(): paths = set() for target in included: @@ -663,11 +676,19 @@ def _configuration_source_witnesses( except ValueError: continue paths.add(str(PurePosixPath(target["archive_path"]) / relative.as_posix())) - witnesses = [row for row in members if row["archive_path"] in paths] if raw is None: - if witnesses: + # A retained link is still a captured source, even though it has no + # separate regular-file byte witness of its own. + if any(member.name in paths for member in archive_members): raise ValueError("backup configuration source absence differs from copied bytes; retry the backup") continue + pending = list(paths) + while pending: + for target_path in link_targets.get(pending.pop(), ()): + if target_path not in paths: + paths.add(target_path) + pending.append(target_path) + witnesses = [row for row in members if row["archive_path"] in paths] sha256 = hashlib.sha256(raw).hexdigest() if not witnesses: raise ValueError("backup configuration source has no copied bytes witness; include its regular source file") @@ -763,7 +784,7 @@ def execute_state_backup_plan(payload: dict[str, Any]) -> dict[str, Any]: "started_at": configuration_started_at, "completed_at": datetime.now(timezone.utc).isoformat(), "source_revisions": _configuration_source_witnesses( - source_snapshots, included, tar.file_members), + source_snapshots, included, tar.file_members, tar.getmembers()), } configuration_bytes = json.dumps(configuration, ensure_ascii=False, indent=2).encode("utf-8") info = tarfile.TarInfo("configuration-backup.json") diff --git a/tests/test_configuration_backup.py b/tests/test_configuration_backup.py index d73985da1d..8de807d72b 100644 --- a/tests/test_configuration_backup.py +++ b/tests/test_configuration_backup.py @@ -199,6 +199,84 @@ def between_owner_captures(**kwargs): assert not list((tmp_path / "backups").iterdir()), "contradictory configuration must not publish" +@pytest.mark.parametrize("removed", [False, True], ids=["retained", "removed-after-copy"]) +@pytest.mark.parametrize("link_kind", ["hardlink", "internal-symlink"]) +def test_machine_configuration_alias_uses_copied_target_witness( + environment, tmp_path, monkeypatch, removed, link_kind, +): + from loopx.capabilities import configuration_backup + + source, runtime, _, _ = environment + machine = runtime / "machine/configuration.json" + # A regular copy can witness a supported local alias. Confirmed absence + # still cannot contradict either retained kind of link. + target = runtime / "earlier-machine.json" + if link_kind == "hardlink": + os.link(machine, target) + else: + if os.name == "nt": + pytest.skip("native Windows symlink privilege is not qualified") + machine.rename(target) + machine.symlink_to("../earlier-machine.json") + original_capture = configuration_backup.capture_configuration_backup + + def after_copy(**kwargs): + if removed: + machine.unlink() + return original_capture(**kwargs) + + monkeypatch.setattr(configuration_backup, "capture_configuration_backup", after_copy) + plan = build_state_backup_plan(project=source.parent.parent, runtime_root=runtime, + output_dir=tmp_path / "backups", include_skills=False, include_automations=False) + if removed: + with pytest.raises(ValueError, match="configuration source absence differs from copied bytes"): + execute_state_backup_plan(plan) + assert not list((tmp_path / "backups").iterdir()) + else: + payload = execute_state_backup_plan(plan) + with tarfile.open(payload["archive_path"]) as archive: + member = archive.getmember("runtime-root/machine/configuration.json") + assert member.islnk() if link_kind == "hardlink" else member.issym() + checkpoint = json.load(archive.extractfile("configuration-backup.json")) + assert json.load(archive.extractfile(member)) == checkpoint["data"]["machine_configuration"] + assert any(row["archive_path"] == "runtime-root/earlier-machine.json" + for row in payload["execution"]["configuration_capture"]["source_revisions"]) + + +@pytest.mark.skipif(os.name == "nt", reason="native Windows symlink privilege is not qualified") +@pytest.mark.parametrize("restore_alias", [False, True], ids=["repointed", "aba"]) +def test_machine_configuration_alias_route_must_match_saved_target( + environment, tmp_path, monkeypatch, restore_alias, +): + from loopx.capabilities import configuration_backup + + source, runtime, _, _ = environment + machine = runtime / "machine/configuration.json" + first, second = runtime / "first-machine.json", runtime / "second-machine.json" + machine.rename(first) + changed = json.loads(first.read_text()) + changed["namespaces"]["goal_storage"]["new_goal_provider"] = "file" + second.write_text(json.dumps(changed)) + machine.symlink_to("../first-machine.json") + original_capture = configuration_backup.capture_configuration_backup + + def after_copy(**kwargs): + machine.unlink() + machine.symlink_to("../second-machine.json") + checkpoint = original_capture(**kwargs) + if restore_alias: + machine.unlink() + machine.symlink_to("../first-machine.json") + return checkpoint + + monkeypatch.setattr(configuration_backup, "capture_configuration_backup", after_copy) + plan = build_state_backup_plan(project=source.parent.parent, runtime_root=runtime, + output_dir=tmp_path / "backups", include_skills=False, include_automations=False) + with pytest.raises(ValueError, match="configuration source differs from copied bytes"): + execute_state_backup_plan(plan) + assert not list((tmp_path / "backups").iterdir()) + + @pytest.mark.parametrize("invalid", ["directory", "malformed", "broken-link"]) def test_full_backup_does_not_treat_unreadable_machine_source_as_absent( environment, tmp_path, invalid,