diff --git a/docs/architecture/rfcs/complete-state-recovery-v0.md b/docs/architecture/rfcs/complete-state-recovery-v0.md
index a6a27c1f34..9bd22fbbf6 100644
--- a/docs/architecture/rfcs/complete-state-recovery-v0.md
+++ b/docs/architecture/rfcs/complete-state-recovery-v0.md
@@ -63,6 +63,72 @@ lost/unknown work and human interventions recorded. Keep service and additional
platform profiles with M4/M5. This coordination checkpoint implements no M2/M3
behavior and starts no recovery or activation.
+
+
+### Planned agent transfer: proposed product refinement
+
+The ordinary user request is **move my Agent to another environment and continue
+its unfinished work**. Preparing that move while the source is reachable can be
+prioritized before disaster recovery. This proposed delivery refinement reuses
+the owners below; it does not rename M1 inspection as migration, relax M2/M3's
+accepted disaster-recovery gates, or claim a working transfer today.
+
+Start with one populated local Goal and its responsible Agent on a declared
+packaged POSIX File/SQLite profile. Establish that profile's required owners and
+source-stop support, then deliver this whole journey through the existing
+Settings/Capability Center and shared CLI/backend:
+
+1. Prepare the source, its complete requirements and required materials; let
+ their owners settle or visibly hold in-flight work and establish quiescence.
+2. Produce a separately reviewed portable artifact. The raw private backup is
+ not that artifact: apply the encryption/credential-custody decision below,
+ preserve content and history, and identify dependencies that need rebind.
+3. On the destination, preview paths, Goal/Agent identity mapping, dependencies,
+ compatibility, missing context and work outside the captured boundary. The
+ Agent prepares these facts; users do not edit registries or stitch owner
+ commands together. Ask only for missing consequential choices or credentials.
+4. Confirm the prepared cutover once. Existing Goal, authority, session, lease,
+ quota and effect owners must admit the destination and exclude stale source
+ execution. A copied Host/session id or released lease is not that admission.
+5. Continue a task left unfinished before transfer; independently accept its
+ result and read it back from the initiating product entry. Preserve later
+ writes, and expose interruption/retry/rollback there as part of the journey.
+
+The transferred work includes Goal intent/constraints, full active and archived
+Todos and relations, Agent role and necessary decision context, required
+artifacts/materials and capability configuration. Use a supported Host export
+or reviewed continuation context; do not claim that copying a third-party
+session database resumes that session. Identity continuity versus a new lifetime
+must be explicit in the Goal owner's plan; historical receipt bodies stay
+unaltered and do not become fresh execution proof. Unknown external effects
+hold with their original operation identity instead of being redispatched.
+
+The implementation boundary is the **prepared transfer through destination
+continuation**, including its product entry and real failure/recovery path.
+Select repairs by a demonstrated break in that journey. Do not schedule a
+manifest-field series first or require every cloud/platform profile and D2's
+ten-day default qualification before delivering the first supported transfer.
+The selected profile still needs all of its actual dependencies and safety
+gates; an unused provider needs owner-confirmed absence, not an exclude flag.
+
+Current pieces and the remaining integration are concrete:
+
+| Existing owner / entry | Reuse and remaining work |
+| --- | --- |
+| `backup-state`, configuration backup and authority archive | Reuse actual content export, safe extraction and original-receipt audit. Build the portable transfer composition over them; an inert destination is not a registered, executable Goal. |
+| `migrate-state` | Legacy Goal Harness copy only. Its preview and execution both reject canonical source/target authority through the existing fence owner; it is not a modern Agent transfer shortcut. |
+| Goal/session/Host and Task/lease owners | Prepare destination bindings and executable Task declarations through their actual lifecycle. Retain old lineage, reject stale executors, and rebind supported sessions; metadata copying cannot repair or mint a grant. |
+| Settings/Capability Center and CLI | One prepared operation with preview, confirmation, visible holds, same-operation retry and continued-work readback. Do not expose eight independent owner runbooks as the ordinary user workflow. |
+
+Before the drill, freeze the supported profile, data/effect disposition and
+targets for elapsed time, required user input, navigation and recovery effort.
+Use two isolated packaged environments with completed and unfinished work plus
+real required materials. Cover stale/changed source, damaged input, missing
+materials, occupied destination, interruption and duplicate confirmation;
+protected duplicate operations must remain zero. A successful planned transfer
+does not qualify unavailable-source M3 recovery. This refinement adds no
+activation, credential-transfer, paid experiment or release authorization.
+
### First supported user outcome
The first bounded user promise is recovery of one local Goal after its source
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 19302c2efa..eeca48b820 100644
--- a/docs/architecture/rfcs/complete-state-recovery-v0.zh-CN.md
+++ b/docs/architecture/rfcs/complete-state-recovery-v0.zh-CN.md
@@ -48,6 +48,57 @@ held,未知状态不改称可恢复,执行权限始终为 false。限制与
未完成工作结果,记录已恢复、丢失/未知工作和人工介入。服务及额外平台 profile
继续由 M4/M5 承接。本协调检查点未实现 M2/M3,也不启动恢复或激活。
+
+
+### 计划内 Agent 迁移:产品交付细化提案
+
+普通用户要完成的是**把我的 Agent 搬到另一个环境,并继续原来未完成的工作**。
+源端仍可联系时的计划内迁移,可以优先于灾难恢复交付。本提案复用下述既有 owner,
+不把 M1 检查改称迁移,不降低 M2/M3 已接受的灾难恢复门,也不声称现在已经能完成迁移。
+
+从一个有真实内容的本地 Goal 及其负责 Agent 开始,明确正常安装的 POSIX
+File/SQLite profile、实际必要 owner 和源端停止支持,然后通过现有设置/能力中心
+及共享 CLI/backend 交付完整旅程:
+
+1. 准备源端、完整要求和必要材料,由各自 owner 结算或明确 hold 进行中的工作,
+ 并建立静止边界。
+2. 生成单独评审的可携产物。原始私有备份不等于这个产物;落实下文的加密与凭证
+ 保管决定,保留内容和历史,并识别需重绑的依赖。
+3. 目标端预览路径、Goal/Agent 身份映射、依赖、兼容性、缺失上下文和捕获边界外的
+ 工作。Agent 先准备这些事实,不让用户编辑 registry 或拼接各 owner 命令;只为
+ 缺失的重要选择或凭证请求输入。
+4. 对已准备的切换进行一次确认。既有 Goal、authority、session、lease、quota 与
+ effect owner 必须准入目标并排除源端过期执行;复制 Host/session id 或已释放
+ lease 不等于取得准入。
+5. 继续完成迁移前留下的任务,独立验收其结果,并从发起产品入口读回。保留后写,
+ 同入口提供中断、重试和回退,这些都属于迁移旅程。
+
+迁移内容包括 Goal 意图/约束、完整活动与归档 Todo 及关系、Agent 角色和必要决策
+上下文、必要产物/材料和能力配置。采用受支持的 Host 导出或经评审的接续上下文,
+不声称复制第三方 session 数据库就能恢复该 session。延续身份还是建立新 lifetime
+须由 Goal owner 的计划明确;原历史 receipt 正文不变,也不成为新的 execution proof。
+外部 effect 结果未知时,携原 operation identity 保持 hold,不重新派发。
+
+实现边界是**已准备的迁移到目标继续工作**,包括产品入口和真实失败/恢复路径。
+按这条旅程的实证断点选择修复,不先排一串 manifest 字段,也不要求所有云/平台
+profile 及 D2 十日默认资格先完成。所选 profile 自身的实际依赖和安全门仍须成立;
+未用 provider 需要 owner 证实不存在依赖,不能用 exclude flag 代替。
+
+既有基础与剩余整合的归属如下:
+
+| 既有 owner / 入口 | 复用及剩余工作 |
+| --- | --- |
+| `backup-state`、configuration backup 与 authority archive | 复用实际内容导出、安全解包和原 receipt 审计,在其上组合可携迁移。隔离可读目标不等于已注册、可执行的 Goal。 |
+| `migrate-state` | 只复制旧 Goal Harness 状态。预览与执行均通过既有 fence owner 拒绝 canonical 源/目标,不是现代 Agent 迁移捷径。 |
+| Goal/session/Host 与 Task/lease owner | 通过真实生命周期准备目标绑定和可执行 Task 声明,保留旧 lineage、拒绝过期 executor、重绑受支持 session;复制元数据不能修复或铸造 grant。 |
+| 设置/能力中心与 CLI | 一次已准备的 operation,包含预览、确认、可见 hold、原操作重试和继续工作读回。不把八套 owner runbook 作为普通用户流程。 |
+
+演练前冻结支持 profile、数据/effect 处置,以及耗时、必要用户输入、导航和恢复
+负担目标。使用两个隔离的正常安装环境,包含已完成/未完成工作及真实必要材料。
+覆盖源变化/过期、输入损坏、缺材料、目标占用、中断和重复确认;受保护重复操作
+必须为零。计划内迁移成功不证明原源不可用的 M3 恢复合格。本提案不新增激活、
+凭证转移、付费实验或发布授权。
+
### 首个支持的用户结果
首个有界用户承诺是:一个本地 Goal 的来源工作区不可用或不再可信后,用户可以恢复
diff --git a/docs/architecture/rfcs/loopx-overall-roadmap-v0.md b/docs/architecture/rfcs/loopx-overall-roadmap-v0.md
index fd420e5180..f5ec93c78e 100644
--- a/docs/architecture/rfcs/loopx-overall-roadmap-v0.md
+++ b/docs/architecture/rfcs/loopx-overall-roadmap-v0.md
@@ -985,7 +985,8 @@ The dated source checkpoints below must not regenerate completed writer work.
| Existing milestone / priority | Next independently reviewable outcome | Acceptance and continuation |
| --- | --- | --- |
-| T4/C1 + D1 · P1, first | Qualify one pinned final-main source and its normal installed CLI/App/affected local Host upgrade journey, including #6597 restart recovery | Compare complete active/archive records, claims, lease epochs, original receipts and later writes. Exercise expired-but-unreleased leases, stale preview/proof, unavailable provider and interrupted apply; rejection leaves state unchanged. Exit through reviewed migration preserving new writes. Reuse the installed repair evidence only after source/caller applicability checks. |
+| Planned Agent transfer · proposed P0 product refinement | A user prepares a reachable source, transfers one populated Goal and its responsible Agent to a declared packaged local profile, and continues unfinished work through the initiating product entry | Follow the [prepared-transfer journey](complete-state-recovery-v0.md#planned-agent-transfer), including required context/materials, destination admission, stale-source exclusion and same-operation failure recovery. Qualify actual profile dependencies and portable-artifact protection; do not wait for all cloud/platform profiles or turn backup inspection, TS deletion or D2 default qualification into the user result. This is a proposed next delivery, not an implemented transfer or activation grant. |
+| T4/C1 + D1 · P1, companion | Qualify one pinned final-main source and its normal installed CLI/App/affected local Host upgrade journey, including #6597 restart recovery | Compare complete active/archive records, claims, lease epochs, original receipts and later writes. Exercise expired-but-unreleased leases, stale preview/proof, unavailable provider and interrupted apply; rejection leaves state unchanged. Exit through reviewed migration preserving new writes. Reuse the installed repair evidence only after source/caller applicability checks. |
| Complete recovery M2 → M3 · P1 | Declare capture consistency for one supported local profile, then recover one Goal whose source is unavailable and continue its unfinished work | Reuse manifest/registry/Goal/provider/session/effect owners. Freeze the decision-owner matrix and RPO/RTO before the drill; reconcile pending effects and destination identities. M1 inspection remains inert; M3 requires admitted owners, zero protected duplicate operations, visible holds and a separately accepted result at the initiating product entry. |
| Goal lifetime M2–M5 / #5206 · P1 dependency only where exercised | Finish the selected profile's remaining old/warm-writer and effect-drain fences, then its orphan/retirement-recreation journey | A removed source writer does not exclude installed 1.x binaries or cached executors. Old A must not write, deliver, debit or settle same-alias B; B and unrelated work continue. Keep activation held until the profile's required owner rows pass; do not rebuild the shipped codec or source-session transaction. |
| D2 · P1 alongside the relevant implementation | Explain the remaining complete-command/history-growth costs on matched File/SQLite workloads and reconcile formal volume/elapsed evidence | Separate caller/bridge, shared typed transaction and provider costs. Preserve current-release and pre-migration controls, frozen failures/missing axes, durability, recovery space and ten-day/volume requirements. Do not restart valid elapsed evidence for an unrelated change or launch a new soak from this plan. |
diff --git a/docs/architecture/rfcs/loopx-overall-roadmap-v0.zh-CN.md b/docs/architecture/rfcs/loopx-overall-roadmap-v0.zh-CN.md
index cf483597d2..abe64edcaf 100644
--- a/docs/architecture/rfcs/loopx-overall-roadmap-v0.zh-CN.md
+++ b/docs/architecture/rfcs/loopx-overall-roadmap-v0.zh-CN.md
@@ -678,7 +678,8 @@ writer 工作。
| 既有里程碑/优先级 | 下一独立可评审结果 | 验收与后续 |
| --- | --- | --- |
-| T4/C1 + D1 · P1,先做 | 冻结最终 main 源码及正常安装的 CLI/App/受影响本地 Host 升级旅程,包含 #6597 重启恢复 | 比较完整 active/archive、claim、lease epoch、原 receipt 和后写;覆盖过期未释放租约、旧 preview/proof、provider 不可用、中断 apply,拒绝时状态不变;通过审核迁移退出且保留新写。既有安装修复证据先核对源码/caller 适用性再复用。 |
+| 计划内 Agent 迁移 · P0 产品细化提案 | 用户准备可联系的源端,把一个有真实内容的 Goal 及其负责 Agent 搬到明确的正常安装本地 profile,并从发起产品入口继续未完成工作 | 按[已准备的迁移旅程](complete-state-recovery-v0.zh-CN.md#planned-agent-transfer)交付必要上下文/材料、目标准入、过期源排除及原操作失败恢复。所选 profile 的实际依赖和可携产物保护须合格;不等待全部云/平台 profile,不把备份检查、TS 删行或 D2 默认资格当用户结果。这是下一交付提案,不代表迁移已实现或授予激活。 |
+| T4/C1 + D1 · P1,伴随 | 冻结最终 main 源码及正常安装的 CLI/App/受影响本地 Host 升级旅程,包含 #6597 重启恢复 | 比较完整 active/archive、claim、lease epoch、原 receipt 和后写;覆盖过期未释放租约、旧 preview/proof、provider 不可用、中断 apply,拒绝时状态不变;通过审核迁移退出且保留新写。既有安装修复证据先核对源码/caller 适用性再复用。 |
| 完整恢复 M2 → M3 · P1 | 为一个受支持本地 profile 声明捕获一致性,再从不可用来源恢复一个 Goal 并继续未完成工作 | 复用 manifest/registry/Goal/provider/session/effect owner;演练前冻结 decision-owner 矩阵和 RPO/RTO,对账 pending effect 与目标身份。M1 检查仍隔离无执行权;M3 须 owner 准入、受保护重复操作为零、hold 可见,并在发起产品入口返回独立验收的结果。 |
| Goal lifetime M2–M5/#5206 · 仅涉及的 P1 依赖 | 补齐所选 profile 剩余旧/warm writer 和 effect drain fence,再做其孤儿恢复/退役同名重建旅程 | 删除源码 writer 不排除已安装 1.x 二进制或缓存 executor。旧 A 不得写入、投递、扣额或结算同名 B;B 和无关工作仍推进。profile 的必要 owner 行通过前保持 activation hold;不重建已交付 codec/source-session 事务。 |
| D2 · P1,与相关实现并行 | 同负载 File/SQLite 对照归因剩余完整命令/历史增长成本,对账正式容量和经过时间证据 | 分开 caller/bridge、共享 typed 事务和 provider 成本;保留当前 release 与迁移前对照、冻结失败/缺失轴、durability、恢复空间及十日/事务量要求。无关改动不重置有效经过时间证据,本计划不启动新 soak。 |
diff --git a/docs/architecture/rfcs/typescript-control-plane-migration-v0.md b/docs/architecture/rfcs/typescript-control-plane-migration-v0.md
index bd961ef337..42939aec8b 100644
--- a/docs/architecture/rfcs/typescript-control-plane-migration-v0.md
+++ b/docs/architecture/rfcs/typescript-control-plane-migration-v0.md
@@ -63,6 +63,14 @@ failed/untested cases retain their original revisions and applicability.
Follow the [existing R5 delivery sequence](loopx-overall-roadmap-v0.md#post-6396-delivery-plan):
+The [planned Agent transfer refinement](complete-state-recovery-v0.md#planned-agent-transfer)
+sets the proposed product priority: move work and continue it in the destination,
+with the necessary installed upgrade/admission companions below. Select TS
+changes from actual breaks in that whole journey; do not advance a language or
+capture-field series as a substitute. Source-available transfer and unavailable-
+source disaster recovery keep distinct qualification. This does not authorize
+either activation or claim that a transfer is implemented.
+
1. Close T4/C1 and D1 adoption on pinned final-main source and normal installed
CLI/App/affected Host entrypoints: complete data, fresh and stale proofs,
original-operation retry, interruption, later writes and reversible migration.
diff --git a/docs/architecture/rfcs/typescript-control-plane-migration-v0.zh-CN.md b/docs/architecture/rfcs/typescript-control-plane-migration-v0.zh-CN.md
index 9e416648be..8f8555a477 100644
--- a/docs/architecture/rfcs/typescript-control-plane-migration-v0.zh-CN.md
+++ b/docs/architecture/rfcs/typescript-control-plane-migration-v0.zh-CN.md
@@ -55,6 +55,12 @@ tree 相同;后续 main 同步与自动合并结果相同,并包含 #6597
沿用[既有 R5 交付顺序](loopx-overall-roadmap-v0.zh-CN.md#post-6396-delivery-plan):
+[计划内 Agent 迁移细化](complete-state-recovery-v0.zh-CN.md#planned-agent-transfer)
+提出产品优先级:把工作迁到目标并继续完成,下述安装升级/准入作为必要伴随。
+按完整旅程的实证断点选择 TS 改动,不用语言迁移或捕获字段系列代替用户结果。
+源端可用的迁移与原源不可用的灾难恢复分别资格化;这里不授予任何一方 activation,
+也不声称迁移已经实现。
+
1. 在冻结的最终 main 源码和正常安装的 CLI/App/受影响 Host 上收尾 T4/C1、
D1 采用:完整数据、新旧 proof、原操作重试、中断、后写及可逆迁移。剩余 transport
按真实 caller 判断;native 进程内接管时删除废弃跨界,不新增逐字段 bridge。
diff --git a/docs/reference/configuration-backup.md b/docs/reference/configuration-backup.md
index eb49f1de1c..0adf569235 100644
--- a/docs/reference/configuration-backup.md
+++ b/docs/reference/configuration-backup.md
@@ -264,6 +264,35 @@ an owner export, a consistent capture profile nor permission to reactivate.
包含更多私有原始字节;有界 migration 备份不变。这些仍是各自见证的历史文件,
不证明 owner export、一致性 profile 或再激活权限。
+## Moving an Agent to another environment
+
+The desired result is continued work with the Goal, full Todo/history, necessary
+Agent context, materials and capability configuration available at the
+destination. The current backup/inspection entry does not deliver that result:
+it creates readable isolated evidence and grants no destination execution.
+The [planned-transfer proposal](../architecture/rfcs/complete-state-recovery-v0.md#planned-agent-transfer)
+defines one prepared source-to-destination journey and its required product,
+identity, credential and failure-recovery boundaries. It is not an available
+transfer command or a qualification claim.
+
+`migrate-state` is the retained legacy Goal Harness copy tool. Both its dry-run
+and execution now check source and destination eligibility, including the old
+runtime of an existing target registry. A canonical provider, promotion fence
+or protected shadow source refuses instead of producing a successful copy
+preview. This is a change to preview behavior; execution keeps its existing
+locked recheck. A valid preview can become stale and grants no future write.
+Do not use this command or an isolated `authority-archive restore` as a shortcut
+to resume a modern Agent from a backup.
+
+中文:目标是让 Agent 带着目标、完整待办/历史、必要上下文、材料和能力配置,在
+目标环境继续工作。当前备份检查入口只提供隔离可读证据,尚未完成这个结果。
+[计划内迁移提案](../architecture/rfcs/complete-state-recovery-v0.zh-CN.md#planned-agent-transfer)
+定义完整旅程及产品、身份、凭证和失败恢复边界,不是已经可用的迁移命令。
+`migrate-state` 仍只复制旧 Goal Harness 状态;现在 dry-run 和执行都会检查源端、
+目标端及目标 registry 原 runtime 的准入,canonical/fence/受保护 shadow 均拒绝。
+这是预览行为变更;执行时仍在原锁内重验,成功预览不保证以后可写。该旧工具和
+隔离 `authority-archive restore` 都不能作为备份后恢复现代 Agent 执行的捷径。
+
## Full-state recovery after cold import
Keep the reviewed pre-import backup as well as a new full-state backup after
diff --git a/loopx/state_migration.py b/loopx/state_migration.py
index aba2f5148a..cbd9fc2a18 100644
--- a/loopx/state_migration.py
+++ b/loopx/state_migration.py
@@ -337,13 +337,13 @@ def migrate_legacy_state(
target_payload.pop("registry_role", None)
target_payload["goals"] = merge_goals(existing_goals, incoming_goals)
+ endpoints: dict[tuple[str, str], tuple[Path, str, dict[str, Any] | None, Path | None]] = {}
+ for source_goal, target_goal in selected_pairs:
+ old_id, new_id = str(source_goal["id"]), str(target_goal["id"])
+ endpoints[(str(legacy_runtime_root.resolve()), old_id)] = (legacy_runtime_root, old_id, source_goal, resolve_goal_state(source_goal.get("repo"), source_goal.get("state_file")))
+ endpoints[(str(target_runtime_root.resolve()), new_id)] = (target_runtime_root, new_id, find_registry_goal(existing_registry, new_id), resolve_goal_state(target_goal.get("repo"), target_goal.get("state_file")))
+ ordered = [endpoints[key] for key in sorted(endpoints)]
if execute:
- endpoints: dict[tuple[str, str], tuple[Path, str, dict[str, Any] | None, Path | None]] = {}
- for source_goal, target_goal in selected_pairs:
- old_id, new_id = str(source_goal["id"]), str(target_goal["id"])
- endpoints[(str(legacy_runtime_root.resolve()), old_id)] = (legacy_runtime_root, old_id, source_goal, resolve_goal_state(source_goal.get("repo"), source_goal.get("state_file")))
- endpoints[(str(target_runtime_root.resolve()), new_id)] = (target_runtime_root, new_id, find_registry_goal(existing_registry, new_id), resolve_goal_state(target_goal.get("repo"), target_goal.get("state_file")))
- ordered = [endpoints[key] for key in sorted(endpoints)]
for root, identity, _goal, _state in ordered:
locks.enter_context(exclusive_cross_runtime_file_lock(legacy_coordination_todo_lock_path(runtime_root=root, goal_id=identity), operation="state_migration_todo"))
for state_path in sorted({entry[3].resolve() for entry in ordered if entry[3] is not None}, key=str):
@@ -355,12 +355,15 @@ def migrate_legacy_state(
locks.enter_context(exclusive_cross_runtime_file_lock(target, operation="state_migration_leases"))
for root, identity, _goal, _state in ordered:
locks.enter_context(exclusive_cross_runtime_file_lock(task_lease_lock_path(runtime_root=root, goal_id=identity), operation="state_migration_leases"))
- for root, identity, goal, _state in ordered:
- require_legacy_state_replacement_allowed(runtime_root=root, goal_id=identity, goal=goal)
- previous_root = resolve_runtime_root(existing_registry, None, registry_path=target_registry_path)
- if previous_root != target_runtime_root:
- for previous_goal in registry_goals(existing_registry):
- require_legacy_state_replacement_allowed(runtime_root=previous_root, goal_id=str(previous_goal["id"]), goal=previous_goal)
+ # Preview uses the same read-only authority admission as execution. It
+ # must not advertise a legacy copy that cannot migrate the live source.
+ # Execution still rechecks these facts under its existing writer locks.
+ for root, identity, goal, _state in ordered:
+ require_legacy_state_replacement_allowed(runtime_root=root, goal_id=identity, goal=goal)
+ previous_root = resolve_runtime_root(existing_registry, None, registry_path=target_registry_path)
+ if previous_root != target_runtime_root:
+ for previous_goal in registry_goals(existing_registry):
+ require_legacy_state_replacement_allowed(runtime_root=previous_root, goal_id=str(previous_goal["id"]), goal=previous_goal)
active_state_results = (
copy_active_state_files(
diff --git a/tests/control_plane/test_state_migration_authority_shadow.py b/tests/control_plane/test_state_migration_authority_shadow.py
index 9237fd3025..edbb6bde83 100644
--- a/tests/control_plane/test_state_migration_authority_shadow.py
+++ b/tests/control_plane/test_state_migration_authority_shadow.py
@@ -2,10 +2,13 @@
import json
from pathlib import Path
+import subprocess
+import sys
import pytest
from loopx.state_migration import migrate_legacy_state, render_state_migration_markdown
+from loopx.control_plane.coordination.legacy_writer_fence import LegacyCoordinationWriterFenced
OLD_GOAL_ID = "legacy-goal"
@@ -146,3 +149,92 @@ def test_migration_preserves_old_store_without_reseeding_retired_lineage(tmp_pat
assert json.loads(lease.read_text())["goal_id"] == NEW_GOAL_ID
else:
assert not paths["target_registry"].exists()
+
+
+@pytest.mark.parametrize("provider", ["file", "sqlite"])
+@pytest.mark.parametrize("endpoint", ["source", "target", "previous_runtime"])
+def test_preview_refuses_canonical_endpoints_without_copying(
+ tmp_path: Path, monkeypatch, provider: str, endpoint: str,
+) -> None:
+ from canonical_authority_fixture import initialize_canonical_authority, isolate_sqlite_runtime
+
+ isolate_sqlite_runtime(tmp_path, monkeypatch)
+ paths = _migration_fixture(tmp_path)
+ runtime, goal_id, state = paths["legacy_runtime"], OLD_GOAL_ID, paths["source_repo"] / "ACTIVE_GOAL_STATE.md"
+ if endpoint != "source":
+ runtime = paths["target_runtime"] if endpoint == "target" else tmp_path / "previous-runtime"
+ goal_id = NEW_GOAL_ID if endpoint == "target" else "retained-goal"
+ state = paths["target_repo"] / "RETAINED_STATE.md"
+ state.write_text("# Existing destination work\n")
+ paths["target_registry"].write_text(json.dumps({
+ "schema_version": 1, "common_runtime_root": str(runtime),
+ "goals": [{"id": goal_id, "repo": str(paths["target_repo"]), "state_file": state.name}],
+ }))
+ initialize_canonical_authority(runtime, goal_id, {"goal_id": goal_id, "retained": True},
+ state_path=state, provider=provider)
+ # The preview's validity must come from the real selected authority/fence,
+ # not from recognizing a particular registry spelling or a copied label.
+ def retained_files() -> dict[str, bytes]:
+ # The shared Effect transport may start a helper in the test's TMPDIR.
+ # Compare every source/target file, not that unrelated helper locator.
+ roots = [*paths.values(), tmp_path / "previous-runtime"]
+ return {str(path.relative_to(tmp_path)): path.read_bytes()
+ for root in roots for path in ([root] if root.is_file() else root.rglob("*"))
+ if path.is_file()}
+
+ before = retained_files()
+ with pytest.raises(LegacyCoordinationWriterFenced):
+ _migrate(paths, execute=False)
+ assert retained_files() == before
+ assert not (paths["target_repo"] / "ACTIVE_GOAL_STATE.md").exists()
+
+
+def test_execute_rechecks_authority_after_an_eligible_preview(tmp_path: Path, monkeypatch) -> None:
+ from canonical_authority_fixture import initialize_canonical_authority, isolate_sqlite_runtime
+
+ isolate_sqlite_runtime(tmp_path, monkeypatch)
+ paths = _migration_fixture(tmp_path)
+ assert _migrate(paths, execute=False)["ok"] is True
+ state = paths["source_repo"] / "ACTIVE_GOAL_STATE.md"
+ initialize_canonical_authority(paths["legacy_runtime"], OLD_GOAL_ID,
+ {"goal_id": OLD_GOAL_ID, "retained": True}, state_path=state)
+ before = state.read_bytes()
+ with pytest.raises(LegacyCoordinationWriterFenced):
+ _migrate(paths, execute=True)
+ assert state.read_bytes() == before
+ assert not (paths["target_repo"] / state.name).exists()
+ assert not paths["target_registry"].exists()
+ assert not (paths["target_runtime"] / "authority").exists()
+
+
+@pytest.mark.parametrize("provider", [None, "file", "sqlite"])
+def test_public_migration_preview_reports_actual_source_eligibility(
+ tmp_path: Path, monkeypatch, provider: str | None,
+) -> None:
+ from canonical_authority_fixture import initialize_canonical_authority, isolate_sqlite_runtime
+
+ isolate_sqlite_runtime(tmp_path, monkeypatch)
+ paths = _migration_fixture(tmp_path)
+ if provider is not None:
+ initialize_canonical_authority(paths["legacy_runtime"], OLD_GOAL_ID,
+ {"goal_id": OLD_GOAL_ID, "retained": True},
+ state_path=paths["source_repo"] / "ACTIVE_GOAL_STATE.md", provider=provider)
+ command = [sys.executable, "-I", "-m", "loopx.cli", "--format", "json",
+ "--registry", str(paths["target_registry"]), "migrate-state",
+ "--legacy-registry", str(paths["legacy_registry"]),
+ "--legacy-runtime-root", str(paths["legacy_runtime"]),
+ "--target-runtime-root", str(paths["target_runtime"]), "--goal-id", OLD_GOAL_ID,
+ "--goal-id-map", f"{OLD_GOAL_ID}={NEW_GOAL_ID}",
+ "--path-map", f"{paths['source_repo']}={paths['target_repo']}",
+ "--copy-active-state", "--copy-runtime"]
+ child = subprocess.run(command, cwd=tmp_path, capture_output=True, text=True, timeout=60)
+ result = json.loads(child.stdout)
+ assert result["dry_run"] is True
+ assert result["ok"] is (provider is None), result
+ assert child.returncode == (0 if provider is None else 1)
+ if provider is not None:
+ assert result["write_check"]["status"] == "blocked"
+ assert result["error_code"] == result["write_check"]["reason_code"]
+ assert not paths["target_registry"].exists()
+ assert not (paths["target_repo"] / "ACTIVE_GOAL_STATE.md").exists()
+ assert not paths["target_runtime"].exists()