diff --git a/docs/architecture/rfcs/complete-state-recovery-v0.md b/docs/architecture/rfcs/complete-state-recovery-v0.md index 066dcaad8e..e552e251df 100644 --- a/docs/architecture/rfcs/complete-state-recovery-v0.md +++ b/docs/architecture/rfcs/complete-state-recovery-v0.md @@ -512,6 +512,57 @@ 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) | Raw source registry and discovered routes are copied; a separate configuration envelope is captured later. Its digest verifies its own contents. | Bind the actual source configuration revision to the capture interval and dependent components. A valid envelope does not prove agreement with the earlier raw registry. | +| 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 documents are traversed; 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.** The real producer copies roots and the raw +registry before constructing `configuration-backup.json`. The File/SQLite +regression in [the recovery suite](../../../tests/test_state_recovery.py) +performs a real Todo transaction and a registry update between those steps. +The resulting archive contains the earlier work/history and raw registry plus +the later configuration; archive/member digests and the configuration check +still pass. After all source routes disappear, M1 correctly reads the earlier +work and returns `held`, `legacy_best_effort`, `legacy_manifest_incomplete` and +no execution authority. The unchanged-source cases remain the positive control. +This demonstrates the boundary of v0 byte verification, not a newly granted +execution path or a qualified `online` profile. + +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..13c9499ce8 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,47 @@ 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) | 先复制原始 source registry 与发现的 route,稍后再捕获独立配置 envelope;其 digest 验证自身内容。 | 将真实配置来源 revision 绑定到捕获 interval 与依赖组件;有效 envelope 不证明它与先前复制的原始 registry 一致。 | +| 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 文档;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 在构造 `configuration-backup.json` 前, +已经复制 root 与原始 registry。[恢复测试](../../../tests/test_state_recovery.py) +中的 File/SQLite 回归在两步之间执行真实 Todo transaction 和 registry 更新。 +归档因此包含较早的工作/历史与原始 registry,以及较晚的配置;archive/member +digest 和配置验证仍然通过。所有来源 route 消失后,M1 正确读回较早的工作,返回 +`held`、`legacy_best_effort`、`legacy_manifest_incomplete`,且不授予执行权。 +来源不变的测试保留为正向对照。这证明 v0 字节验证的边界,不代表新增执行路径, +也不代表 `online` profile 合格。 + +下一实现切片归属既有 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 41dda7c982..d9bc62f9a5 100644 --- a/tests/test_state_recovery.py +++ b/tests/test_state_recovery.py @@ -69,6 +69,50 @@ 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_valid_backup_with_capture_skew_stays_inert(tmp_path, monkeypatch, provider): + original_add = tarfile.TarFile.add + writes = [] + + def add_then_write(tar, source, *args, **kwargs): + result = original_add(tar, source, *args, **kwargs) + if kwargs.get("arcname") == "configuration/registry.source.json": + # Real source writes after the earlier store/registry copy, before + # the producer's later configuration capture. Do not mock verifiers. + registry = tmp_path / "registry.json" + code, added = _cli("--registry", registry, "--runtime-root", tmp_path / "runtime", + "todo", "add", "--goal-id", "goal-a", "--role", "agent", + "--claimed-by", "agent-a", "--operation-id", "during-capture", + "--text", "Later work belongs to a later capture") + assert code == 0, added + writes.append(added) + configuration = json.loads(registry.read_text()) + configuration["goals"][0]["description"] = "Later configuration" + registry.write_text(json.dumps(configuration)) + return result + + with monkeypatch.context() as capture: + capture.setattr(tarfile.TarFile, "add", add_then_write) + archive, manifest = _backup(tmp_path, provider) + assert len(writes) == 1 + with tarfile.open(archive, "r:gz") as tar: + earlier = json.load(tar.extractfile("configuration/registry.source.json")) + later = json.load(tar.extractfile("configuration-backup.json")) + assert "description" not in earlier["goals"][0] + assert later["data"]["goals"][0]["goal_configuration"]["description"] == "Later configuration" + 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) + assert audit["manifest_verified"] is True and audit["file_witnesses_verified"] is True + [goal] = [row for row in audit["components"] if row["owner_kind"] == "coordination_authority"] + assert goal["state"] == "readable" and goal["facts"]["todo_count"] == 1 + assert goal["facts"]["history_commits"] == 2 + 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 + + @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)