diff --git a/apps/presentation/dashboard/src/features/personal-workspace/capability-localization.ts b/apps/presentation/dashboard/src/features/personal-workspace/capability-localization.ts index 0756239373..005a9afcad 100644 --- a/apps/presentation/dashboard/src/features/personal-workspace/capability-localization.ts +++ b/apps/presentation/dashboard/src/features/personal-workspace/capability-localization.ts @@ -12,7 +12,7 @@ type FieldCopy = Record> = { en: { - goal_storage: { displayName: "New Goal storage target", description: "Fixed at creation and used after reviewed promotion. Existing Goals require a separate backed-up migration." }, + goal_storage: { displayName: "New Goal authority", description: "Opt in to canonical creation and choose the storage and execution policy for future Goals. Existing Goals require a separate backed-up migration." }, manager_runtime: { displayName: "Runtime", description: "Selects the persistent host-tool profile used by owner manager conversations.", @@ -78,7 +78,7 @@ const capabilityCopy: Record> = { }, }, "zh-CN": { - goal_storage: { displayName: "新 Goal 的目标存储", description: "创建时固定,审核晋升后生效。已有 Goal 需要单独备份、迁移;更改这里不会迁移数据。" }, + goal_storage: { displayName: "新 Goal 的权威存储", description: "可启用 canonical 创建,选择之后新 Goal 的存储与执行策略。已有 Goal 仍需单独备份、迁移。" }, manager_runtime: { displayName: "运行环境", description: "选择管家会话持续生效的宿主工具模式。", @@ -147,7 +147,9 @@ const capabilityCopy: Record> = { const fieldCopy: Record = { en: { - new_goal_provider: { label: "New Goal storage target (after promotion)", description: "File or SQLite; this setting does not perform promotion or migration." }, + new_goal_provider: { label: "New Goal storage provider", description: "File or SQLite; frozen at creation, independently of execution policy." }, + canonical_creation: { label: "Create canonical authority", description: "Future Goals only. Disabled retains the post-promotion target; failures require the original creation retry." }, + new_goal_handoff_mode: { label: "New Goal execution policy", description: "soft_claim or hard_lease, used with canonical creation. Agents inherit the Goal policy; tool authority is unchanged." }, runtime_profile: { label: "Runtime profile", description: "Restricted keeps scoped LoopX reads only. Trusted owner enables normal host tools while protected operations retain separate checks." }, selection_policy: { label: "Selection policy", description: "Preferred allows an explicit user choice; pinned rejects another executor; flexible permits fallback only inside the eligible pool." }, executor_endpoint: { label: "Primary steward executor", description: "The preferred or pinned executor for this machine. In a flexible pool it is tried first when available." }, @@ -174,7 +176,9 @@ const fieldCopy: Record = { enabled_agents: { label: "Enabled Goal Agents", description: "Enter one registered Goal-local Agent id per line. A private binding currently accepts exactly one Agent." }, }, "zh-CN": { - new_goal_provider: { label: "新 Goal 的目标存储(晋升后生效)", description: "选择 File 或 SQLite;保存设置不会自动晋升,也不会迁移已有 Goal。" }, + new_goal_provider: { label: "新 Goal 的存储 provider", description: "选择 File 或 SQLite;创建时固定,与执行策略分别选择。" }, + canonical_creation: { label: "建立 canonical 权威存储", description: "仅影响此后新建的 Goal。关闭时仅固定晋升后的目标;失败须重试原创建操作。" }, + new_goal_handoff_mode: { label: "新 Goal 的执行策略", description: "canonical 创建使用 soft_claim 或 hard_lease。Agent 继承 Goal 策略;不授予工具权限。" }, runtime_profile: { label: "运行模式", description: "restricted 仅使用受限 LoopX 读取;trusted_owner 开放常规宿主工具,但受保护操作仍单独校验。" }, selection_policy: { label: "选择策略", description: "preferred 允许用户显式改选;pinned 拒绝其他执行器;flexible 只在已授权资源池内回退。" }, executor_endpoint: { label: "首选管家执行器", description: "本机首选或锁定的执行器;灵活池模式下优先尝试它。" }, diff --git a/apps/presentation/dashboard/src/features/personal-workspace/machine-configuration-settings.tsx b/apps/presentation/dashboard/src/features/personal-workspace/machine-configuration-settings.tsx index 7a2f06b44b..a42c44b650 100644 --- a/apps/presentation/dashboard/src/features/personal-workspace/machine-configuration-settings.tsx +++ b/apps/presentation/dashboard/src/features/personal-workspace/machine-configuration-settings.tsx @@ -54,8 +54,8 @@ function completeMachineConfiguration( // The guided steward editor owns the v1 selection-policy fields. Opening an // installed v0 preference in that form is an explicit migration preview; // JSON mode can still submit the legacy shape unchanged when needed. - if (capability.capability_id === "steward_executor" - && (Object.hasOwn(draft, "selection_policy") || Object.hasOwn(draft, "eligible_endpoints"))) { + if (capability.capability_id === "goal_storage" || (capability.capability_id === "steward_executor" + && (Object.hasOwn(draft, "selection_policy") || Object.hasOwn(draft, "eligible_endpoints")))) { complete.schema_version = configurationObject(capability.default).schema_version; } return complete; @@ -373,8 +373,8 @@ export function MachineConfigurationSettings({ section, onChanged }: { section:
{locale === "zh-CN" ? "仅影响此后创建的 Goal" : "Future Goals only"}

{locale === "zh-CN" - ? "创建时固定选择,审核晋升后生效。已有 Goal 不变;迁移需单独备份、停止写入并结算租约。" - : "Fixed at creation and used after reviewed promotion. Existing Goals are unchanged; migration requires a separate backup, stopped writers and settled leases."}

+ ? "启用 canonical 创建后,新 Goal 直接建立权威存储,并采用所选执行策略;失败时须重试原创建操作。关闭时仅固定晋升后的目标存储。已有 Goal 的升级仍需备份、停止写入和结算租约;这里不授予工具权限。" + : "With canonical creation enabled, new Goals establish authority with the selected execution policy; retry the original operation after failure. Disabled only freezes the post-promotion target. Existing Goals still require backup, stopped writers and settled leases for upgrade; this setting grants no tool permissions."}

) : null} diff --git a/docs/architecture/rfcs/shared-goal-authority-state-provider-v0.md b/docs/architecture/rfcs/shared-goal-authority-state-provider-v0.md index f6efd1a772..62efaed604 100644 --- a/docs/architecture/rfcs/shared-goal-authority-state-provider-v0.md +++ b/docs/architecture/rfcs/shared-goal-authority-state-provider-v0.md @@ -41,6 +41,18 @@ Frozen failures/missing evidence remain visible. T4 deletes proven redundant owners alongside implementation, without waiting for R6 or all Python to vanish. This replaces stale current-count estimates, not historical execution evidence. +**Fresh creation opt-in (2026-10-04, proposed).** The existing +[device setting and CLI/App creation owner](../../reference/local-authority-provider-selection.md#new-goal-authority-machine-setting) +can freeze a File/SQLite target and `soft_claim`/`hard_lease` policy, initialize +empty canonical authority and recover the original creation receipt. Isolated +real-provider CLI/HTTP and packaged settings checks cover original-operation +retry, writer fencing, lost completed authority and rejected policy recovery. +Completed retries no longer parse the Python Markdown source; unfinished fresh +creation still captures it only when the typed owner requests it. Default-off +and released v0 behavior remain; live legacy writers retain callers. This is a +bounded L9 prerequisite, not installed upgrade, whole-Goal recovery, D2, cohort +admission or release-default acceptance. Those existing exits remain open. + File retained-state storage now reuses the existing TS checkpoint/delta codec, stacked on #5063's verified read cache and RPC budgets. Original revisions, receipts and full historical projections survive the physical format upgrade. diff --git a/docs/architecture/rfcs/shared-goal-authority-state-provider-v0.zh-CN.md b/docs/architecture/rfcs/shared-goal-authority-state-provider-v0.zh-CN.md index 4d5eba9aee..485b125321 100644 --- a/docs/architecture/rfcs/shared-goal-authority-state-provider-v0.zh-CN.md +++ b/docs/architecture/rfcs/shared-goal-authority-state-provider-v0.zh-CN.md @@ -34,6 +34,15 @@ D2 已通过;冻结的失败/缺项保持可见。T4 随实现删除已证明重复的 owner,不等 R6 或所有 Python 消失。本节替代陈旧的当前数量估算,不覆盖历史执行证据。 +**新建 opt-in(2026-10-04,拟议)。** 既有 +[设备设置和 CLI/App 创建 owner](../../reference/local-authority-provider-selection.md#new-goal-authority-machine-setting) +可固定 File/SQLite 目标与 `soft_claim`/`hard_lease` 策略,初始化空 canonical +权威并恢复原创建回执。隔离真实 provider 的 CLI/HTTP 与打包设置验证覆盖原操作 +重试、旧 writer fence、已完成存储丢失和非法策略恢复。完成后的重试不再解析 +Python Markdown 源;未完成的新建仅在 typed owner 请求时捕获源。默认关闭及 +发布版 v0 行为保持,活跃 legacy writer 仍有调用方。这是有界 L9 前置,不代表 +安装态升级、整 Goal 恢复、D2、cohort 准入或发布默认验收;这些既有出口保持开放。 + **所有权精简阶段(2026-10-01)。** R5/T4 将存储晋升与策略迁移分开:新 CLI promote 默认保留策略,正常策略目标收敛为 soft/hard。canonical 策略迁移复用晋升 规则、完整归档和 command receipt owner,用一笔 CAS 保留 assignment、lease diff --git a/docs/reference/images/new-goal-authority/after.png b/docs/reference/images/new-goal-authority/after.png new file mode 100644 index 0000000000..e41b23124b Binary files /dev/null and b/docs/reference/images/new-goal-authority/after.png differ diff --git a/docs/reference/images/new-goal-authority/before.png b/docs/reference/images/new-goal-authority/before.png new file mode 100644 index 0000000000..723ea55513 Binary files /dev/null and b/docs/reference/images/new-goal-authority/before.png differ diff --git a/docs/reference/images/new-goal-authority/invalid-policy.png b/docs/reference/images/new-goal-authority/invalid-policy.png new file mode 100644 index 0000000000..205937536a Binary files /dev/null and b/docs/reference/images/new-goal-authority/invalid-policy.png differ diff --git a/docs/reference/images/new-goal-authority/mobile.png b/docs/reference/images/new-goal-authority/mobile.png new file mode 100644 index 0000000000..f11bbcfdf8 Binary files /dev/null and b/docs/reference/images/new-goal-authority/mobile.png differ diff --git a/docs/reference/local-authority-provider-selection.md b/docs/reference/local-authority-provider-selection.md index ae2ab6d2b2..2fb8bd79a4 100644 --- a/docs/reference/local-authority-provider-selection.md +++ b/docs/reference/local-authority-provider-selection.md @@ -59,64 +59,132 @@ contract; PostgreSQL's real-server qualification remains a separate gate. See [reviewed promotion and recovery](reviewed-coordination-promotion.md) for the explicit saved-plan CLI journey. -## New Goal storage target (machine setting) - -The **New Goal storage target** setting fixes a File or SQLite target at -creation. It is not live inheritance, automatic promotion, or an existing-Goal -migration. Until separately reviewed promotion, the existing legacy source is -still authoritative. After promotion the selected provider serves canonical -Todo/lease state; Run artifacts and other independently owned stores are not -moved by this preference. +## New Goal authority (machine setting) + +**Settings → Capability Center → Device defaults → New Goal authority** selects +File or SQLite independently of the execution policy. Enable **Create canonical +authority** to initialize future empty Goals directly with `soft_claim` or +`hard_lease`. Agents inherit the Goal policy; this grants no tool, repository, +scheduler, account or network permission and does not migrate existing data. + +Canonical creation is default-off. An absent namespace, the released v0 shape, +or v1 with `canonical_creation=false` retains the post-promotion target behavior. +Opening v0 in the guided editor previews a v1 envelope upgrade with creation +still disabled. The CLI continues to accept v0. + +Save this namespace document as `goal-storage.json`: + +```json +{ + "schema_version": "loopx_goal_storage_defaults_v1", + "new_goal_provider": "sqlite", + "canonical_creation": true, + "new_goal_handoff_mode": "hard_lease" +} +``` -Use **Settings → Capability Center → Device defaults → New Goal storage target** -or the revision-checked CLI: +Preview, apply the exact reviewed revision, then inspect and create: ```sh -# goal-storage.json: -# {"schema_version":"loopx_goal_storage_defaults_v0","new_goal_provider":"sqlite"} loopx machine-config preview --namespace goal_storage --config-json goal-storage.json loopx machine-config apply --namespace goal_storage --config-json goal-storage.json \ --expected-plan-revision PLAN_REVISION --execute loopx machine-config inspect loopx bootstrap --project ./new-project --goal-id new-project --dry-run +loopx bootstrap --project ./new-project --goal-id new-project +loopx todo list --goal-id new-project +``` + +Creation reports `storage_selection.authority_initialized=true`, its original +operation and provider receipt, and `legacy_writer_fenced=true`. Complete Todo +reads report canonical `source_authority` and `legacy_fallback_used=false`. +Saving a preference or publishing a registry entry alone is not successful +creation. Run artifacts and independently owned stores do not move. + +CLI and App reuse one typed creation owner. The registry atomically freezes the +original operation and target before initialization. The owner verifies the +registered source, complete empty Todo/lease inventory and current bytes under +the existing writer locks. It engages a creation fence, commits the native +projection and original receipt, and durably records completion before success. +Fresh creation has no shadow qualification and never fabricates capture events. +Nonempty or captured sources require reviewed migration. + +After interruption, rerun the same CLI bootstrap, or use **Retry original +operation** on the App card. Changed device defaults cannot retarget that +operation. Recovery must match its operation and workspace; a competing creator +cannot adopt it. The original receipt survives later native writes, so replay +cannot erase Todos or repeat their creation. An unavailable selected provider +fails visibly without Markdown fallback. Lost completed authority requires full +backup recovery and cannot be treated as empty creation. Generic forced +bootstrap cannot rebuild an opted-in Goal. + +
+Settings and recovery views / 设置与恢复界面 + +Synthetic workspace data; the settings use a real isolated backend. The first +view is the released v0 editor; the remaining views show the proposed v1 path. + +Before: the provider setting only chooses the post-promotion target. + +![Released target-only editor](images/new-goal-authority/before.png) + +After: provider, explicit canonical opt-in and execution policy, with applied +configuration readback. + +![Canonical creation settings and readback](images/new-goal-authority/after.png) + +An unsupported `legacy` policy is rejected before apply; the previous valid +configuration remains. Correcting the policy allows preview and apply again. + +![Invalid policy rejected](images/new-goal-authority/invalid-policy.png) + +The same device settings at a narrow viewport: + +![Narrow device settings](images/new-goal-authority/mobile.png) + +
+ +To disable future canonical creation, preview and apply the same v1 document +with `canonical_creation=false`. To remove the whole preference: + +```sh +loopx machine-config remove --namespace goal_storage +loopx machine-config remove --namespace goal_storage \ + --expected-plan-revision PLAN_REVISION --execute +loopx machine-config inspect ``` -The preview reports `storage_target`; creation reports `storage_selection` with -`promotion_performed=false`. CLI and App creation share the same bootstrap -owner. Creation stores its intent before provider initialization, so retry after -interruption uses the same target even if the machine preference changed. -If App creation fails during initialization, use **Retry original operation** -on that creation card. It resumes the recorded target before adding initial -Todos or starting a Turn. A persistent initialization failure remains an error; -the presence of a registry entry alone is not successful creation. Recovery must -match the original App operation and its validated workspace. Registration -records `creation_operation_id` atomically with the Goal; a competing creation -of the same id, even in the same workspace, is rejected before initialization -or initial Todos. The create-only check is repeated under the registry lock. -Older incomplete cards without this binding require inspection of the existing -Goal and its canonical bootstrap/recovery path; they cannot adopt it by id. -Already-applied cards continue to return their original receipt. -Reconnecting an existing Goal, including an implicit File Goal, does not adopt -a newer machine default. Importing existing Markdown does not count as a new -empty Goal. Explicit provider selection never falls back on failure. - -Without this namespace, existing behavior remains unchanged. To stop applying -the preference to future Goals, preview `loopx machine-config remove ---namespace goal_storage`, then use its returned plan revision with `--execute`. -Configuration rollback also affects future creation only. Neither operation -switches existing storage or removes data. A File target keeps implicit File -routing until a committed authority exists; it does not create a dangling -identity-bound selector for an empty File document. - -For already-promoted Goals use the [reviewed File/SQLite cutover](file-authority-state-log.md#reviewed-filesqlite-cutover): -stop writers, settle leases, review the saved plan, retain verified backups, -then migrate. Reverse migration must preserve newer writes. New-Goal defaults -and current-provider selection are separate facts. This opt-in setting does -not change the release default or complete D2/D3 qualification. - -### 新 Goal 的目标存储 - -这是创建时固定的目标,审核晋升后才接管 canonical Todo/lease;不是“所有数据 -已经存入 SQLite”。更改默认值只影响此后创建的空 Goal,既有 Goal、重新连接或 -导入已有 Markdown 均不自动切换。创建中断后重试沿用已记录的选择。关闭或回滚 -设置不迁回数据;已有 Goal 需停止写入、结算租约,走独立的备份和审核迁移流程。 +Use the removal preview's revision. Rollback also affects future creation only; +neither operation switches existing storage, removes its fence or reopens its +old writer. Reconnection and import keep their recorded route. Existing Markdown +Goals use [reviewed promotion and recovery](reviewed-coordination-promotion.md); +already-canonical Goals use the [reviewed File/SQLite cutover](file-authority-state-log.md#reviewed-filesqlite-cutover). +Retain verified backups, stop writers, settle leases and preserve newer writes +on reverse migration. Supported historical backup/format/receipt readers remain. + +This opt-in path does not close full existing-Goal upgrade, D2 sustained +qualification or the release-default decision. Trial admission and release +default admission remain separate; the existing RFC acceptance is unchanged. + +### 新 Goal 的权威存储 + +在“设置 → 能力中心 → 此设备默认 → 新 Goal 的权威存储”中,分别选择 File/SQLite +和 `soft_claim`/`hard_lease`,并显式启用 canonical 创建。默认关闭;旧 v0 或关闭 +状态仍只固定晋升后的目标。表单以关闭状态预览 v1 升级,CLI 继续接受旧格式。 +Agent 继承 Goal 策略;此设置不授予工具、仓库、账户或网络权限。 + +CLI 使用上面的完整 JSON 和 preview/apply/inspect/bootstrap 命令;App 用现有 +表单预览、应用并读回。成功须含 `authority_initialized=true`、原创建回执和已 +读回的写入 fence;Todo list 须显示 canonical provider。保存偏好或出现 registry +记录本身不算成功,也不代表 Run 等独立存储已经迁移。 + +失败时重新执行原 bootstrap,或在 App 创建卡上“重试原操作”。目标和身份已固定, +后续默认值不能改写它。旧写入先被 fence;原生提交和回执核对后才持久记录完成。 +已有 Todo、租约历史或 capture 的源须走独立审核迁移,不伪造 shadow 资格。完成 +后的存储丢失须恢复完整备份,不能重新创建空库;通用 force 不能重建。原回执在 +后续写入后仍可读回,重试不得丢失或重复 Todo。 + +关闭或按上面的 remove 预览/执行命令删除偏好,只影响之后新建;不会迁回已有数据、 +删除 fence 或重新开放旧 writer。既有 Goal 升级仍需备份、停止写入、结算租约和 +审核计划;反向迁移须保留新增写入。受支持的旧备份、格式和原回执恢复能力保留。 +此路径不代表完整升级、D2 长期资格或发布默认已通过。 diff --git a/loopx/bootstrap.py b/loopx/bootstrap.py index 628fea0a52..c375d36891 100644 --- a/loopx/bootstrap.py +++ b/loopx/bootstrap.py @@ -1,11 +1,13 @@ from __future__ import annotations import re +from uuid import uuid4 from pathlib import Path from .capabilities.machine_configuration.goal_storage import new_goal_storage_target, initialize_goal_storage_target from .registry import find_registry_goal from .control_plane.coordination.legacy_writer_fence import legacy_todo_write_transaction, require_legacy_state_replacement_allowed +from .control_plane.coordination.legacy_writer_fence import require_registry_source_write_allowed from .control_plane.coordination.runtime_shadow_writer_adapter import require_runtime_shadow_capture_prepared, begin_todo_runtime_shadow_capture, settle_todo_runtime_shadow_capture from .control_plane.projects.registry_codec import ( load_project_registry, @@ -390,15 +392,22 @@ def bootstrap_project( if creation_operation_id is not None: goal_entry["creation_operation_id"] = creation_operation_id previous_goal = find_registry_goal(registry, goal_id) + if creation_operation_id is not None and previous_goal is not None: + raise GoalCreationConflictError("Goal id was registered by another operation") storage_target = ((previous_goal or {}).get("coordination") or {}).get("storage_target") if previous_goal is None and not state_file.exists(): storage_target = new_goal_storage_target(runtime_root) if storage_target is not None: goal_entry.setdefault("coordination", {})["storage_target"] = storage_target + canonical_creation = (storage_target or {}).get("schema_version") == "loopx_new_goal_storage_target_v1" + if canonical_creation and previous_goal is not None and force: + raise ValueError("Canonical creation cannot rebuild existing state; restore or migrate through its owning operation") + if canonical_creation: + goal_entry["creation_operation_id"] = creation_operation_id or (previous_goal or {}).get("creation_operation_id") or f"goal-create:{uuid4().hex}" registry, registry_goal_action = merge_goal(registry, goal_entry, force=force) state_exists = state_file.exists() - state_action = "created" + state_action = "kept-existing" if canonical_creation and previous_goal is not None else "created" if state_exists and force and preserve_todos: state_action = "kept-existing-preserve-todos" elif state_exists and not force: @@ -408,7 +417,7 @@ def bootstrap_project( repaired_state_text: str | None = None repaired_todo_source_roles: list[str] = [] - if state_exists and state_action in { + if not canonical_creation and state_exists and state_action in { "kept-existing", "kept-existing-preserve-todos", }: @@ -433,7 +442,7 @@ def bootstrap_project( } force_bootstrap_warning = None declared_handoff_mode = HANDOFF_MODE_LEGACY - if state_exists and force: + if not canonical_creation and state_exists and force: # A forced rebuild replaces todos, never the goal's handoff contract: # the declared mode is carried into the rewritten front matter, and an # invalid declaration fails closed before anything is rewritten. @@ -524,12 +533,20 @@ def bootstrap_project( shadow_capture = None shadow_evidence: dict[str, Any] = {} if not dry_run: + # Reconnect enters the same typed receipt owner before compatibility + # reads or rebuild checks. It alone decides whether unfinished creation + # needs a full source capture; completed authority never needs Markdown. + if canonical_creation and previous_goal is not None: + storage_selection = initialize_goal_storage_target(runtime_root, + {**previous_goal, "state_file": str(state_file)}, registry_path=registry_path) + canonical_reconnect = storage_selection is not None and storage_selection.get("authority_initialized") is True + canonical_bootstrap_transport = canonical_creation with project_registry_transaction( registry_path, operation="bootstrap_registry", create=dict, ) as registry_transaction, legacy_todo_write_transaction( - registry_path, goal_id, state_file, None, "bootstrap_state", False, + registry_path, goal_id, state_file, None, "bootstrap_state", canonical_creation, runtime_root=runtime_root, ): current_registry = registry_transaction.payload_copy() @@ -544,6 +561,10 @@ def bootstrap_project( goal_entry.setdefault("coordination", {}).pop("storage_target", None) if frozen is not None: goal_entry["coordination"]["storage_target"] = frozen + frozen_target = goal_entry.get("coordination", {}).get("storage_target") or {} + canonical_creation = frozen_target.get("schema_version") == "loopx_new_goal_storage_target_v1" + if canonical_creation and force and (current_goal is not None or state_file.exists()): + raise ValueError("Canonical creation cannot rebuild existing state; restore or migrate through its owning operation") # A first explicit bootstrap has no previous Goal authority to fence. # Existing Goals still resolve and authorize their original route. @@ -554,14 +575,20 @@ def bootstrap_project( if isinstance(previous_goal, dict) and previous_goal.get("id"): require_legacy_state_replacement_allowed(runtime_root=previous_root, goal_id=str(previous_goal["id"]), goal=previous_goal) - original = state_file.read_text(encoding="utf-8") if state_file.exists() else "" - if force or not state_file.exists(): + original = state_file.read_text(encoding="utf-8") if not canonical_reconnect and state_file.exists() else "" + if not canonical_reconnect and (force or not state_file.exists()): require_legacy_state_replacement_allowed(runtime_root=runtime_root, goal_id=goal_id, goal=current_goal) - state_action = ("kept-existing-preserve-todos" if force and preserve_todos else "kept-existing") if state_file.exists() and (not force or preserve_todos) else "replaced" if state_file.exists() else "created" + if canonical_reconnect: + state_action = "kept-existing" + else: + state_action = ("kept-existing-preserve-todos" if force and preserve_todos else "kept-existing") if state_file.exists() and (not force or preserve_todos) else "replaced" if state_file.exists() else "created" + for action in actions: + if action.get("path") == str(state_file): + action["action"] = state_action planned = original if state_action in {"created", "replaced"}: - declared_handoff_mode = goal_handoff_mode(original) if original and force else HANDOFF_MODE_LEGACY + declared_handoff_mode = goal_handoff_mode(original) if original and force else frozen_target.get("handoff_mode", HANDOFF_MODE_LEGACY) planned = render_state_markdown( project=project, goal_id=goal_id, @@ -572,16 +599,21 @@ def bootstrap_project( execution_profile=execution_profile, handoff_mode=declared_handoff_mode, ) - else: + elif not canonical_creation: planned, repaired_todo_source_roles = repair_missing_todo_source_sections(original) if planned != original: - shadow_capture = begin_todo_runtime_shadow_capture(registry_path=registry_path, - runtime_root=runtime_root, goal_id=goal_id, state_path=state_file, - write_class="bootstrap_state", original_text=original) - shadow_capture.prepare(planned) - require_runtime_shadow_capture_prepared(shadow_capture, runtime_root=runtime_root, goal_id=goal_id) + if canonical_bootstrap_transport: + require_registry_source_write_allowed(registry_path=registry_path, runtime_root=runtime_root, + goal_id=goal_id, state_file=state_file) + if not canonical_creation: + shadow_capture = begin_todo_runtime_shadow_capture(registry_path=registry_path, + runtime_root=runtime_root, goal_id=goal_id, state_path=state_file, + write_class="bootstrap_state", original_text=original) + shadow_capture.prepare(planned) + require_runtime_shadow_capture_prepared(shadow_capture, runtime_root=runtime_root, goal_id=goal_id) atomic_write_state_text(state_file, planned) - shadow_capture.committed() + if shadow_capture is not None: + shadow_capture.committed() if todo_source_migration is not None: todo_source_migration["applied"] = True current_registry.setdefault("schema_version", "0.1") @@ -592,7 +624,7 @@ def bootstrap_project( # Registry intent survives an interrupted initialization. Reconnect retries # it outside the legacy/registry locks; changing machine defaults cannot # retarget that Goal. The TS owner refuses replacing an existing provider. - storage_selection = initialize_goal_storage_target(runtime_root, find_registry_goal(registry, goal_id) or {}) + storage_selection = initialize_goal_storage_target(runtime_root, find_registry_goal(registry, goal_id) or {}, registry_path=registry_path) if shadow_capture is not None: shadow_evidence = settle_todo_runtime_shadow_capture({}, registry_path=registry_path, runtime_root=runtime_root, goal_id=goal_id, capture=shadow_capture, emit_disabled=False) diff --git a/loopx/capabilities/configuration_ui.py b/loopx/capabilities/configuration_ui.py index da94e8e318..0d697d5f32 100644 --- a/loopx/capabilities/configuration_ui.py +++ b/loopx/capabilities/configuration_ui.py @@ -94,9 +94,14 @@ def capability_configuration_editor( }, "goal_storage": { "supported_scopes": ["machine"], "writable_scopes": ["machine"], - "fields": [_field("new_goal_provider", "New Goal storage target (after promotion)", "select", + "fields": [_field("new_goal_provider", "New Goal storage provider", "select", options=["file", "sqlite"], required=True, - description="Fixed at creation. Existing Goals need a separate backed-up migration; this setting does not promote them.")], + description="Fixed at creation. Existing Goals need a separate backed-up migration."), + _field("canonical_creation", "Create canonical authority", "boolean", required=True, + description="Explicit opt-in for future Goals. Disabled retains the post-promotion target behavior."), + _field("new_goal_handoff_mode", "New Goal execution policy", "select", + options=["soft_claim", "hard_lease"], required=True, + description="Used only with canonical creation. Agents inherit the Goal policy; this grants no tool permissions.")], }, "todo_replan_cadence": { "supported_scopes": ["machine", "goal"], diff --git a/loopx/capabilities/machine_configuration/goal_storage.py b/loopx/capabilities/machine_configuration/goal_storage.py index 8d3629fba5..28ca5a7ad3 100644 --- a/loopx/capabilities/machine_configuration/goal_storage.py +++ b/loopx/capabilities/machine_configuration/goal_storage.py @@ -9,27 +9,33 @@ from ...control_plane.effect_runtime import effect_runtime_result GOAL_STORAGE_DEFAULTS_SCHEMA = "loopx_goal_storage_defaults_v0" +CANONICAL_GOAL_STORAGE_DEFAULTS_SCHEMA = "loopx_goal_storage_defaults_v1" NEW_GOAL_STORAGE_METHOD = "coordination.local_authority.new_goal_storage" def normalize_goal_storage_defaults(raw: Mapping[str, Any]) -> dict[str, Any]: # Configuration-envelope validation only; target resolution/admission is TS-owned. - if set(raw) != {"schema_version", "new_goal_provider"} or raw.get("schema_version") != GOAL_STORAGE_DEFAULTS_SCHEMA: + canonical = raw.get("schema_version") == CANONICAL_GOAL_STORAGE_DEFAULTS_SCHEMA + fields = {"schema_version", "new_goal_provider", "canonical_creation", "new_goal_handoff_mode"} if canonical else {"schema_version", "new_goal_provider"} + if set(raw) != fields or (not canonical and raw.get("schema_version") != GOAL_STORAGE_DEFAULTS_SCHEMA): raise ValueError("goal_storage requires schema_version and new_goal_provider") if raw.get("new_goal_provider") not in ("file", "sqlite"): raise ValueError("new_goal_provider must be file or sqlite") + if canonical and (type(raw["canonical_creation"]) is not bool or raw["new_goal_handoff_mode"] not in ("soft_claim", "hard_lease")): + raise ValueError("canonical_creation must be boolean; new_goal_handoff_mode must be soft_claim or hard_lease") return dict(raw) def goal_storage_machine_configuration_namespace() -> MachineConfigurationNamespace: return MachineConfigurationNamespace( - namespace="goal_storage", schema_versions=frozenset({GOAL_STORAGE_DEFAULTS_SCHEMA}), + namespace="goal_storage", schema_versions=frozenset({GOAL_STORAGE_DEFAULTS_SCHEMA, CANONICAL_GOAL_STORAGE_DEFAULTS_SCHEMA}), normalize=normalize_goal_storage_defaults, project_public=dict, apply_public_update=lambda _current, update: dict(update), - title="New Goal storage target", - description=("Fixed when a new Goal is created; used after reviewed promotion. " - "Does not promote Goals or migrate existing data. Existing Goals keep their selection."), - default_configuration={"schema_version": GOAL_STORAGE_DEFAULTS_SCHEMA, "new_goal_provider": "file"}, + title="New Goal authority", + description=("Opt in to canonical creation and choose its execution policy. " + "Fixed for future Goals only; existing data requires a separate reviewed migration."), + default_configuration={"schema_version": CANONICAL_GOAL_STORAGE_DEFAULTS_SCHEMA, "new_goal_provider": "file", + "canonical_creation": False, "new_goal_handoff_mode": "hard_lease"}, documentation={"path": "docs/reference/local-authority-provider-selection.md", "url": "https://github.com/loopx-project/loopx/blob/main/docs/reference/local-authority-provider-selection.md"}, ) @@ -45,9 +51,29 @@ def new_goal_storage_target(runtime_root: Path) -> dict[str, Any] | None: return effect_runtime_result(NEW_GOAL_STORAGE_METHOD, {"action": "resolve", "configuration": raw}) -def initialize_goal_storage_target(runtime_root: Path, goal: Mapping[str, Any]) -> dict[str, Any] | None: +def initialize_goal_storage_target(runtime_root: Path, goal: Mapping[str, Any], *, registry_path: Path | None = None) -> dict[str, Any] | None: target = (goal.get("coordination") or {}).get("storage_target") if target is None: return None - return effect_runtime_result(NEW_GOAL_STORAGE_METHOD, {"action": "initialize", "runtime_root": str(runtime_root), - "goal_id": goal["id"], "target": target}) + request = {"action": "initialize", "runtime_root": str(runtime_root), "goal_id": goal["id"], "target": target} + if target.get("schema_version") == "loopx_new_goal_storage_target_v1": + from ...registry import resolve_state_file + from ...control_plane.coordination.authority_source_capture import authority_registry_source + from ...agent_registry import registered_agent_ids_for_goal + if registry_path is None: + raise ValueError("Canonical creation requires its registered source") + state_path = resolve_state_file(Path(goal["repo"]), goal["state_file"]) + with authority_registry_source(registry_path) as witness: + request.update(creation_operation_id=goal.get("creation_operation_id"), source_snapshot={ + "state_path": str(state_path.resolve()), + "registry_source": {**witness, "registered_agents": registered_agent_ids_for_goal(dict(goal))}}) + result = effect_runtime_result(NEW_GOAL_STORAGE_METHOD, request) + # Native receipt readback needs no Markdown parsing. Only the typed + # owner can request a complete source capture for unfinished creation. + if result.get("source_capture_required") is not True: + return result + from ...control_plane.coordination.runtime_shadow import build_runtime_shadow_source_snapshot + projection, snapshot = build_runtime_shadow_source_snapshot(goal=goal, runtime_root=runtime_root, + state_path=state_path, registry_path=registry_path) + request.update(creation_operation_id=goal.get("creation_operation_id"), projection=projection, source_snapshot=snapshot) + return effect_runtime_result(NEW_GOAL_STORAGE_METHOD, request) diff --git a/loopx/chat_actions.py b/loopx/chat_actions.py index 1e85e8cdd2..caa02ad3c9 100644 --- a/loopx/chat_actions.py +++ b/loopx/chat_actions.py @@ -602,7 +602,7 @@ def _apply_goal_create( # Registry publication precedes storage initialization. Resume the # frozen target through its TS owner before any downstream effects; # re-running Markdown bootstrap could overwrite a promoted Goal. - initialize_goal_storage_target(runtime_root, existing_goal) + initialize_goal_storage_target(runtime_root, existing_goal, registry_path=self.registry_path) result = {"ok": True} else: try: diff --git a/loopx/control_plane/coordination/coordination_state_contract.generated.ts b/loopx/control_plane/coordination/coordination_state_contract.generated.ts index 33bef8bdbe..79c81a5ee8 100644 --- a/loopx/control_plane/coordination/coordination_state_contract.generated.ts +++ b/loopx/control_plane/coordination/coordination_state_contract.generated.ts @@ -66,6 +66,7 @@ export const SHADOW_OUTBOX_MANIFEST_SCHEMA = "loopx_shadow_outbox_manifest_v1"; export const SHADOW_EXACT_OUTBOX_MANIFEST_SCHEMA = "loopx_shadow_outbox_manifest_v2"; export const LEGACY_COORDINATION_WRITER_FENCE_SCHEMA = "loopx_legacy_coordination_writer_fence_v0"; +export const NEW_GOAL_WRITER_FENCE_SCHEMA = "loopx_new_goal_writer_fence_v0"; export const LEGACY_COORDINATION_WRITER_FENCE_ENGAGE_REQUEST_SCHEMA = "loopx_legacy_coordination_writer_fence_engage_request_v0"; export const LEGACY_COORDINATION_WRITER_FENCE_RESULT_SCHEMA = "loopx_legacy_coordination_writer_fence_result_v0"; export const LEGACY_COORDINATION_WRITE_CHECK_REQUEST_SCHEMA = "loopx_legacy_coordination_write_check_request_v0"; @@ -305,6 +306,7 @@ export const COORDINATION_STATE_CONTRACT = deepFreeze({ }, "legacy_writer_fence_protocol": { "fence_schema": LEGACY_COORDINATION_WRITER_FENCE_SCHEMA, + "creation_fence_schema": NEW_GOAL_WRITER_FENCE_SCHEMA, "engage_request_schema": LEGACY_COORDINATION_WRITER_FENCE_ENGAGE_REQUEST_SCHEMA, "result_schema": LEGACY_COORDINATION_WRITER_FENCE_RESULT_SCHEMA, "write_check_request_schema": LEGACY_COORDINATION_WRITE_CHECK_REQUEST_SCHEMA, diff --git a/loopx/control_plane/coordination/coordination_state_contract_generated.py b/loopx/control_plane/coordination/coordination_state_contract_generated.py index f939d2483b..8fb413c7c8 100644 --- a/loopx/control_plane/coordination/coordination_state_contract_generated.py +++ b/loopx/control_plane/coordination/coordination_state_contract_generated.py @@ -161,6 +161,7 @@ def _freeze(value: Any) -> Any: 'outbox_manifest_schema': 'loopx_shadow_outbox_manifest_v1', 'exact_outbox_manifest_schema': 'loopx_shadow_outbox_manifest_v2'}, 'legacy_writer_fence_protocol': {'fence_schema': 'loopx_legacy_coordination_writer_fence_v0', + 'creation_fence_schema': 'loopx_new_goal_writer_fence_v0', 'engage_request_schema': 'loopx_legacy_coordination_writer_fence_engage_request_v0', 'result_schema': 'loopx_legacy_coordination_writer_fence_result_v0', 'write_check_request_schema': 'loopx_legacy_coordination_write_check_request_v0', @@ -271,6 +272,7 @@ def _freeze(value: Any) -> Any: SHADOW_EXACT_OUTBOX_MANIFEST_SCHEMA: Final[str] = 'loopx_shadow_outbox_manifest_v2' LEGACY_COORDINATION_WRITER_FENCE_SCHEMA: Final[str] = 'loopx_legacy_coordination_writer_fence_v0' +NEW_GOAL_WRITER_FENCE_SCHEMA: Final[str] = 'loopx_new_goal_writer_fence_v0' LEGACY_COORDINATION_WRITER_FENCE_ENGAGE_REQUEST_SCHEMA: Final[str] = 'loopx_legacy_coordination_writer_fence_engage_request_v0' LEGACY_COORDINATION_WRITER_FENCE_RESULT_SCHEMA: Final[str] = 'loopx_legacy_coordination_writer_fence_result_v0' LEGACY_COORDINATION_WRITE_CHECK_REQUEST_SCHEMA: Final[str] = 'loopx_legacy_coordination_write_check_request_v0' diff --git a/loopx/control_plane/coordination/coordination_state_contract_v0.json b/loopx/control_plane/coordination/coordination_state_contract_v0.json index 8b0de9886d..e8fde13ac4 100644 --- a/loopx/control_plane/coordination/coordination_state_contract_v0.json +++ b/loopx/control_plane/coordination/coordination_state_contract_v0.json @@ -171,6 +171,7 @@ }, "legacy_writer_fence_protocol": { "fence_schema": "loopx_legacy_coordination_writer_fence_v0", + "creation_fence_schema": "loopx_new_goal_writer_fence_v0", "engage_request_schema": "loopx_legacy_coordination_writer_fence_engage_request_v0", "result_schema": "loopx_legacy_coordination_writer_fence_result_v0", "write_check_request_schema": "loopx_legacy_coordination_write_check_request_v0", diff --git a/loopx/control_plane/coordination/legacy_writer_fence.ts b/loopx/control_plane/coordination/legacy_writer_fence.ts index 124fc74f30..2f9558a2b8 100644 --- a/loopx/control_plane/coordination/legacy_writer_fence.ts +++ b/loopx/control_plane/coordination/legacy_writer_fence.ts @@ -5,6 +5,7 @@ import { isAbsolute, join } from "node:path"; import type { JsonObject } from "../effect_program.ts"; import { atomicWriteJson, withFileMutationLock } from "../effect_runtime_io.ts"; import { requireJsonObject } from "../runtime_decode.ts"; +import {BARE_SHA256_PATTERN} from "../content_digest.ts"; import { readShadowBootstrapSourcePath, requireShadowPrimaryWriteAllowed, ShadowManagementError, shadowMaintenanceLockPath } from "./shadow_management.ts"; import { legacyCoordinationTodoLockPath, legacyCoordinationLeaseLockPath, taskLeaseLockPath } from "./legacy_writer_lock_paths.ts"; export { legacyCoordinationTodoLockPath, legacyCoordinationLeaseLockPath, @@ -13,12 +14,14 @@ export { legacyCoordinationTodoLockPath, legacyCoordinationLeaseLockPath, import { canonicalAuthorityBytes, canonicalAuthorityObject, + hasExactAuthorityKeys, requireAuthorityStoreId, } from "./authority_store_codec.ts"; import { LEGACY_COORDINATION_WRITER_FENCE_ENGAGE_REQUEST_SCHEMA, LEGACY_COORDINATION_WRITER_FENCE_RESULT_SCHEMA, LEGACY_COORDINATION_WRITER_FENCE_SCHEMA, + NEW_GOAL_WRITER_FENCE_SCHEMA, LEGACY_COORDINATION_WRITE_CHECK_REQUEST_SCHEMA, LEGACY_COORDINATION_WRITE_CHECK_RESULT_SCHEMA, } from "./coordination_state_contract.generated.ts"; @@ -27,6 +30,7 @@ export { LEGACY_COORDINATION_WRITER_FENCE_ENGAGE_REQUEST_SCHEMA, LEGACY_COORDINATION_WRITER_FENCE_RESULT_SCHEMA, LEGACY_COORDINATION_WRITER_FENCE_SCHEMA, + NEW_GOAL_WRITER_FENCE_SCHEMA, LEGACY_COORDINATION_WRITE_CHECK_REQUEST_SCHEMA, LEGACY_COORDINATION_WRITE_CHECK_RESULT_SCHEMA, }; @@ -103,6 +107,18 @@ export function legacyCoordinationWriterFencePath(root: string, goalId: string): export function decodeLegacyCoordinationWriterFence(value: unknown): JsonObject { const fence = canonicalAuthorityObject(value, "legacy coordination writer fence"); + if (fence.schema_version === NEW_GOAL_WRITER_FENCE_SCHEMA) { + if (fence.state !== "engaged" || !hasExactAuthorityKeys(fence, + ["schema_version", "state", "goal_id", "fence_id", "creation_operation_id", "creation_identity_sha256", "creation_completed"]) || + typeof fence.creation_completed !== "boolean") { + throw new Error("Invalid new Goal writer fence"); + } + for (const key of ["goal_id", "fence_id", "creation_operation_id", "creation_identity_sha256"]) { + requireAuthorityStoreId(fence[key], `new Goal writer fence ${key}`); + } + if (!BARE_SHA256_PATTERN.test(String(fence.creation_identity_sha256))) throw new Error("Invalid new Goal creation identity digest"); + return fence; + } if ( fence.schema_version !== LEGACY_COORDINATION_WRITER_FENCE_SCHEMA || fence.state !== "engaged" @@ -136,6 +152,27 @@ export function decodeLegacyCoordinationWriterFence(value: unknown): JsonObject }, "legacy coordination writer fence"); } +/** Record verified creation completion without reopening the legacy writer. + * Caller holds the same maintenance/source locks through receipt readback. + */ +export async function completeNewGoalWriterFenceUnderLocks(root: string, goalId: string, pending: JsonObject): Promise { + const path = legacyCoordinationWriterFencePath(root, goalId); + await withFileMutationLock(path, async () => { + const current = await loadLegacyCoordinationWriterFence(root, goalId); + if (current.status !== "loaded" || current.fence.schema_version !== NEW_GOAL_WRITER_FENCE_SCHEMA || + !canonicalAuthorityBytes({...current.fence, creation_completed: false}).equals(canonicalAuthorityBytes(pending))) { + throw new Error("New Goal creation fence changed before completion"); + } + if (current.fence.creation_completed === true) return; + const completed = {...pending, creation_completed: true}; + await atomicWriteJson(path, completed); + const checked = await loadLegacyCoordinationWriterFence(root, goalId); + if (checked.status !== "loaded" || !canonicalAuthorityBytes(checked.fence).equals(canonicalAuthorityBytes(completed))) { + throw new Error("New Goal creation completion fence readback failed"); + } + }); +} + export async function loadLegacyCoordinationWriterFence( root: string, goalId: string, diff --git a/loopx/control_plane/coordination/local_authority_defaults.ts b/loopx/control_plane/coordination/local_authority_defaults.ts index 0e11528360..2e9d76c48e 100644 --- a/loopx/control_plane/coordination/local_authority_defaults.ts +++ b/loopx/control_plane/coordination/local_authority_defaults.ts @@ -3,9 +3,13 @@ import type {JsonObject} from "../effect_program.ts"; import {requireJsonObject} from "../runtime_decode.ts"; import {requireAuthorityStoreId} from "./authority_store_codec.ts"; import {requireLocalAuthorityRuntimeRoot, selectLocalAuthorityTarget} from "./local_authority_provider.ts"; +import {EXECUTION_HANDOFF_MODES} from "./handoff_mode_vocabulary.ts"; +import {initializeNewGoalAuthority} from "./new_goal_initialization.ts"; const GOAL_STORAGE_DEFAULTS_SCHEMA = "loopx_goal_storage_defaults_v0"; const NEW_GOAL_STORAGE_TARGET_SCHEMA = "loopx_new_goal_storage_target_v0"; +export const CANONICAL_GOAL_STORAGE_DEFAULTS_SCHEMA = "loopx_goal_storage_defaults_v1"; +export const CANONICAL_NEW_GOAL_STORAGE_TARGET_SCHEMA = "loopx_new_goal_storage_target_v1"; function provider(value: unknown): "file" | "sqlite" { if (value !== "file" && value !== "sqlite") throw new Error("New Goal storage must be file or sqlite"); return value; @@ -13,13 +17,30 @@ function provider(value: unknown): "file" | "sqlite" { export async function manageNewGoalStorage(request: JsonObject): Promise { if (request.action === "resolve") { const config = requireJsonObject(request.configuration, "Goal storage defaults"); - if (config.schema_version !== GOAL_STORAGE_DEFAULTS_SCHEMA || Object.keys(config).some(key => !["schema_version", "new_goal_provider"].includes(key))) { + const canonical = config.schema_version === CANONICAL_GOAL_STORAGE_DEFAULTS_SCHEMA; + const keys = canonical ? ["schema_version", "new_goal_provider", "canonical_creation", "new_goal_handoff_mode"] : ["schema_version", "new_goal_provider"]; + if ((!canonical && config.schema_version !== GOAL_STORAGE_DEFAULTS_SCHEMA) || + Object.keys(config).some(key => !keys.includes(key)) || keys.some(key => !(key in config))) { throw new Error("Invalid Goal storage defaults"); } + if (canonical && (typeof config.canonical_creation !== "boolean" || + !EXECUTION_HANDOFF_MODES.some(mode => mode === config.new_goal_handoff_mode))) { + throw new Error("Canonical creation requires a boolean opt-in and soft_claim or hard_lease policy"); + } + if (canonical && config.canonical_creation === true) return { + schema_version: CANONICAL_NEW_GOAL_STORAGE_TARGET_SCHEMA, provider: provider(config.new_goal_provider), + handoff_mode: config.new_goal_handoff_mode, + }; return {schema_version: NEW_GOAL_STORAGE_TARGET_SCHEMA, provider: provider(config.new_goal_provider)}; } if (request.action !== "initialize") throw new Error("Unknown new Goal storage action"); const target = requireJsonObject(request.target, "New Goal storage target"); + if (target.schema_version === CANONICAL_NEW_GOAL_STORAGE_TARGET_SCHEMA) { + if (Object.keys(target).some(key => !["schema_version", "provider", "handoff_mode"].includes(key)) || + !EXECUTION_HANDOFF_MODES.some(mode => mode === target.handoff_mode)) throw new Error("Invalid canonical creation target"); + provider(target.provider); + return await initializeNewGoalAuthority(request); + } if (target.schema_version !== NEW_GOAL_STORAGE_TARGET_SCHEMA || Object.keys(target).some(key => !["schema_version", "provider"].includes(key))) { throw new Error("Invalid new Goal storage target"); } diff --git a/loopx/control_plane/coordination/new_goal_initialization.ts b/loopx/control_plane/coordination/new_goal_initialization.ts new file mode 100644 index 0000000000..7eb1daa96b --- /dev/null +++ b/loopx/control_plane/coordination/new_goal_initialization.ts @@ -0,0 +1,106 @@ +/** Fresh local authority creation; existing Goals use reviewed migration. + * The original creation receipt remains valid after later Todo writes. + */ +import {readFile} from "node:fs/promises"; +import {resolve} from "node:path"; +import type {JsonObject} from "../effect_program.ts"; +import {EffectRuntimeConflictError, EffectRuntimeRequestError} from "../effect_runtime_errors.ts"; +import {canonicalAuthorityBytes, canonicalAuthoritySha256, requireAuthorityStoreId} from "./authority_store_codec.ts"; +import {LocalAuthorityProviderOpenError, openLocalAuthorityStoreHandle, requireLocalAuthorityRuntimeRoot, selectLocalAuthorityTarget} from "./local_authority_provider.ts"; +import {engageLegacyCoordinationWriterFenceUnderLocks, completeNewGoalWriterFenceUnderLocks, loadLegacyCoordinationWriterFence, NEW_GOAL_WRITER_FENCE_SCHEMA} from "./legacy_writer_fence.ts"; +import {readShadowManagementState, withShadowMaintenanceLock} from "./shadow_management.ts"; +import {verifyShadowSourceSnapshot, withShadowSourceLocks, type ShadowRequest} from "./runtime_shadow.ts"; +import {projectCoordinationSource, SOURCE_PROJECTION_REQUEST_SCHEMA} from "./source_projection.ts"; +import {TODO_DOMAIN_READ_RECORD_SCHEMA} from "./coordination_state_contract.ts"; +import {commitPromotionAndReadBack, readPromotionReceipt} from "./promotion_receipt.ts"; +import {requireJsonObject} from "../runtime_decode.ts"; + +export async function initializeNewGoalAuthority(raw: JsonObject): Promise { + const root = requireLocalAuthorityRuntimeRoot(raw.runtime_root); + const goalId = requireAuthorityStoreId(raw.goal_id, "goal id"); + const operationId = requireAuthorityStoreId(raw.creation_operation_id, "creation operation id"); + const target = requireJsonObject(raw.target, "creation target"); + const snapshot = requireJsonObject(raw.source_snapshot, "creation source snapshot"); + const projection = projectCoordinationSource({schema_version: SOURCE_PROJECTION_REQUEST_SCHEMA, + kind: "snapshot", goal_id: goalId, handoff_mode: target.handoff_mode, todos: [], leases: [], + read_model_schema: TODO_DOMAIN_READ_RECORD_SCHEMA}).projection as JsonObject; + const capturedSource = raw.projection !== undefined; + const request: ShadowRequest = {runtime_root: root, goal_id: goalId, + projection: capturedSource ? requireJsonObject(raw.projection, "creation source") : projection, source_snapshot: snapshot}; + const identitySha = canonicalAuthoritySha256({goal_id: goalId, creation_operation_id: operationId, + target, state_path: snapshot.state_path, registry_path: (snapshot.registry_source as JsonObject)?.path}); + const identity = {operation_id: operationId, projection_sha256: canonicalAuthoritySha256(projection), + receipt: {schema_version: "loopx_new_goal_authority_creation_receipt_v0", operation_id: operationId, + goal_id: goalId, creation_identity_sha256: identitySha}}; + const fence = {schema_version: NEW_GOAL_WRITER_FENCE_SCHEMA, state: "engaged", goal_id: goalId, + fence_id: `new-goal:${operationId}`, creation_operation_id: operationId, creation_identity_sha256: identitySha, + creation_completed: false}; + const validateRegistration = async () => { + const registry = JSON.parse(await readFile(String((snapshot.registry_source as JsonObject).path), "utf8")); + const registered = registry.goals?.find((goal: JsonObject) => goal.id === goalId); + if (!registered || registered.creation_operation_id !== operationId || + !canonicalAuthorityBytes(registered.coordination?.storage_target).equals(canonicalAuthorityBytes(target)) || + typeof registered.repo !== "string" || typeof registered.state_file !== "string" || + resolve(registered.repo, registered.state_file) !== resolve(String(snapshot.state_path))) { + throw new EffectRuntimeRequestError("Canonical creation is not bound to the registered original operation, source and target"); + } + }; + await validateRegistration(); + const priorFence = await loadLegacyCoordinationWriterFence(root, goalId); + if (priorFence.status === "failed") throw new EffectRuntimeConflictError(priorFence.reason); + if (capturedSource && priorFence.status === "missing" && ((request.projection.todos as unknown[])?.length !== 0 || + (request.projection.leases as unknown[])?.length !== 0 || (snapshot.lease_inventory as unknown[])?.length !== 0)) { + throw new EffectRuntimeRequestError("Canonical creation requires an empty source without lease history; use reviewed migration"); + } + + // Selection retains the established provider/lineage checks. It cannot replace + // a selected store, and later reviewed migration wins over creation defaults. + if (capturedSource) await selectLocalAuthorityTarget(root, goalId, target.provider as "file" | "sqlite", true, "creation_retry"); + return await withShadowMaintenanceLock(root, goalId, () => withShadowSourceLocks(request, async () => { + await validateRegistration(); + const managed = await readShadowManagementState(root, goalId); + if (managed?.status === "active") throw new EffectRuntimeConflictError("Existing shadow capture requires reviewed migration"); + const opened = await openLocalAuthorityStoreHandle(root, goalId).catch((error: unknown) => { + if (error instanceof LocalAuthorityProviderOpenError) { + throw new EffectRuntimeConflictError(`${error.message}; restore the complete authority backup, never recreate it`); + } + throw error; + }); + const head = await opened.store.loadAuthority(); + const persisted = await loadLegacyCoordinationWriterFence(root, goalId); + if (persisted.status === "failed") throw new EffectRuntimeConflictError(persisted.reason); + if (persisted.status === "loaded" && !canonicalAuthorityBytes({...persisted.fence, creation_completed: false}).equals(canonicalAuthorityBytes(fence))) { + throw new EffectRuntimeConflictError("Canonical creation writer fence belongs to a different operation"); + } + let readback = await readPromotionReceipt(opened.store, identity); + if (!readback.matched) { + if (persisted.status === "loaded" && persisted.fence.creation_completed === true) { + throw new EffectRuntimeConflictError("Completed canonical creation authority is unavailable; restore its complete backup, never recreate it"); + } + if (head.status !== "missing") throw new EffectRuntimeConflictError(`Canonical creation cannot replace authority: ${readback.reason_code}`); + if (!capturedSource) return {source_capture_required: true}; + // A creation intent is never a migration waiver. Validate the complete + // real source and inventory under its locks; nonempty sources fail closed. + await verifyShadowSourceSnapshot(request); + if ((request.projection.todos as unknown[])?.length !== 0 || + (request.projection.leases as unknown[])?.length !== 0 || + (snapshot.lease_inventory as unknown[])?.length !== 0) { + throw new EffectRuntimeRequestError("Canonical creation requires an empty source without lease history; use reviewed migration"); + } + const fenced = await engageLegacyCoordinationWriterFenceUnderLocks(root, goalId, String(snapshot.state_path), fence); + if (fenced.status !== "applied" && fenced.status !== "replayed") throw new EffectRuntimeConflictError("Canonical creation writer fence could not be verified"); + const committed = await commitPromotionAndReadBack(opened.store, identity, projection, + {...identity.receipt, schema_version: "loopx_new_goal_authority_creation_event_v0"}); + readback = committed.readback; + if (!readback.matched) throw new EffectRuntimeConflictError(`Canonical creation interrupted; retry its original operation: ${readback.reason_code}`); + } + if (persisted.status === "missing" && head.status === "loaded") { + throw new EffectRuntimeConflictError("Canonical creation receipt exists but its writer fence is missing; restore the complete authority backup"); + } + await completeNewGoalWriterFenceUnderLocks(root, goalId, fence); + return {ok: true, provider: opened.provider, status: head.status === "missing" ? "created" : "replayed", + authority_initialized: true, handoff_mode: target.handoff_mode, applies_to: "canonical_authority", + promotion_performed: false, legacy_writer_fenced: true, legacy_fallback_used: false, + provider_revision: readback.provider_revision, cursor: readback.cursor, creation_operation_id: operationId}; + })); +} diff --git a/loopx/semantics/project_registry_io_manifest_v1.json b/loopx/semantics/project_registry_io_manifest_v1.json index 447353acd5..3bc84e714b 100644 --- a/loopx/semantics/project_registry_io_manifest_v1.json +++ b/loopx/semantics/project_registry_io_manifest_v1.json @@ -111,7 +111,7 @@ }, { "site": "loopx/bootstrap.py::.bootstrap_project::codec_transaction:project_registry_transaction#1", - "line": 527, + "line": 544, "column": 14, "kind": "codec_transaction", "api": "project_registry_transaction", @@ -119,7 +119,7 @@ }, { "site": "loopx/bootstrap.py::.read_json_if_exists::codec_read:load_project_registry#1", - "line": 84, + "line": 86, "column": 15, "kind": "codec_read", "api": "load_project_registry", diff --git a/scripts/generate_coordination_state_contract.py b/scripts/generate_coordination_state_contract.py index 66a503c8af..ec494cbde8 100644 --- a/scripts/generate_coordination_state_contract.py +++ b/scripts/generate_coordination_state_contract.py @@ -85,6 +85,7 @@ ) LEGACY_WRITER_FENCE_PROTOCOL_KEYS = ( "fence_schema", + "creation_fence_schema", "engage_request_schema", "result_schema", "write_check_request_schema", @@ -150,6 +151,7 @@ ) LEGACY_WRITER_FENCE_CONSTANT_NAMES = { "fence_schema": "LEGACY_COORDINATION_WRITER_FENCE_SCHEMA", + "creation_fence_schema": "NEW_GOAL_WRITER_FENCE_SCHEMA", "engage_request_schema": "LEGACY_COORDINATION_WRITER_FENCE_ENGAGE_REQUEST_SCHEMA", "result_schema": "LEGACY_COORDINATION_WRITER_FENCE_RESULT_SCHEMA", "write_check_request_schema": "LEGACY_COORDINATION_WRITE_CHECK_REQUEST_SCHEMA", diff --git a/tests/control_plane/test_new_goal_storage_defaults.py b/tests/control_plane/test_new_goal_storage_defaults.py index c77aec63f2..b5531396f0 100644 --- a/tests/control_plane/test_new_goal_storage_defaults.py +++ b/tests/control_plane/test_new_goal_storage_defaults.py @@ -5,6 +5,7 @@ import subprocess import sys import threading +from pathlib import Path from unittest.mock import patch import pytest @@ -24,14 +25,17 @@ def environment(tmp_path, monkeypatch): project.mkdir() config = runtime / "machine/configuration.json" config.parent.mkdir(parents=True) - def configure(provider): + def configure(provider, *, mode=None): + defaults = {"schema_version": "loopx_goal_storage_defaults_v0", "new_goal_provider": provider} + if mode is not None: + defaults.update(schema_version="loopx_goal_storage_defaults_v1", canonical_creation=True, new_goal_handoff_mode=mode) config.write_text(json.dumps({"schema_version": "loopx_machine_configuration_v0", "namespaces": { - "goal_storage": {"schema_version": "loopx_goal_storage_defaults_v0", "new_goal_provider": provider}}})) - def bootstrap(goal="first", *extra): + "goal_storage": defaults}})) + def bootstrap(goal="first", *extra, expected_code=0): result = subprocess.run([sys.executable, "-m", "loopx.entrypoint", "--registry", str(project / ".loopx/registry.json"), "--runtime-root", str(runtime), "--format", "json", "bootstrap", "--project", str(project), "--goal-id", goal, "--objective", "Validate a new project", "--no-global-sync", *extra], capture_output=True, text=True, timeout=60) - assert result.returncode == 0, result.stdout + result.stderr + assert result.returncode == expected_code, result.stdout + result.stderr return json.loads(result.stdout) def marker(goal="first"): return runtime / "authority" / f"provider-{hashlib.sha256(goal.encode()).hexdigest()}.json" @@ -62,6 +66,103 @@ def test_existing_implicit_file_goal_is_not_retargeted(environment): assert not marker().exists() +@pytest.mark.parametrize("provider", ["file", "sqlite"]) +@pytest.mark.parametrize("mode", ["soft_claim", "hard_lease"]) +def test_opted_in_creation_has_complete_canonical_authority_and_frozen_policy(environment, provider, mode): + from loopx.control_plane.coordination.local_authority import read_canonical_todos_if_promoted + + configure, bootstrap, _, _, runtime = environment + configuration = runtime / "machine/configuration.json" + configuration.write_text(json.dumps({"schema_version": "loopx_machine_configuration_v0", "namespaces": { + "goal_storage": {"schema_version": "loopx_goal_storage_defaults_v1", "new_goal_provider": provider, + "canonical_creation": True, "new_goal_handoff_mode": mode}}})) + preview = bootstrap("native", "--dry-run") + assert not (runtime / "authority-transition").exists() + actual = bootstrap("native") + source = read_canonical_todos_if_promoted(runtime_root=runtime, goal_id="native", include_leases=True) + assert source is not None + assert source["source_authority"] == f"{provider}_v0" + assert source["handoff_mode"] == mode + assert source["todos"] == [] + assert actual["storage_selection"]["authority_initialized"] is True + assert actual["storage_target"] == preview["storage_target"] + configure("file" if provider == "sqlite" else "sqlite") + restart_effect_runtime() + reconnect = bootstrap("native") + assert reconnect["storage_selection"]["authority_initialized"] is True + assert reconnect["storage_selection"]["handoff_mode"] == mode + + +@pytest.mark.parametrize("provider", ["file", "sqlite"]) +@pytest.mark.parametrize("mode", ["soft_claim", "hard_lease"]) +@pytest.mark.parametrize("compatibility_source", ["missing", "unreadable"]) +def test_completed_cli_creation_replays_without_compatibility_source(environment, provider, mode, compatibility_source): + from loopx.control_plane.coordination.local_authority import read_canonical_todos_if_promoted + + configure, bootstrap, _, project, runtime = environment + configure(provider, mode=mode) + created = bootstrap() + registry = project / ".loopx/registry.json" + added = subprocess.run([sys.executable, "-m", "loopx.entrypoint", "--registry", str(registry), + "--runtime-root", str(runtime), "--format", "json", "todo", "add", "--goal-id", "first", + "--role", "agent", "--text", "Preserve a later native Todo"], capture_output=True, text=True, timeout=60) + assert added.returncode == 0, added.stdout + added.stderr + before = read_canonical_todos_if_promoted(runtime_root=runtime, goal_id="first", include_leases=True) + assert len(before["todos"]) == 1 + state = Path(created["state_file"]) + if compatibility_source == "missing": + state.unlink() + else: + state.write_bytes(b"\xff") + configure("file" if provider == "sqlite" else "sqlite", mode="hard_lease" if mode == "soft_claim" else "soft_claim") + restart_effect_runtime() + recovered = bootstrap() + selection = recovered["storage_selection"] + assert selection["creation_operation_id"] == created["storage_selection"]["creation_operation_id"] + assert selection["provider_revision"] == created["storage_selection"]["provider_revision"] + assert selection["handoff_mode"] == mode + assert selection["provider"] == provider + assert selection["legacy_fallback_used"] is False + assert recovered["state_action"] == "kept-existing" + assert next(action for action in recovered["actions"] if action["path"] == str(state))["action"] == "kept-existing" + assert read_canonical_todos_if_promoted(runtime_root=runtime, goal_id="first", include_leases=True) == before + assert not state.exists() if compatibility_source == "missing" else state.read_bytes() == b"\xff" + assert bootstrap("first", "--dry-run")["state_action"] == "kept-existing" + rejected = bootstrap("first", "--force", expected_code=1) + assert "cannot rebuild" in rejected["error"] + assert read_canonical_todos_if_promoted(runtime_root=runtime, goal_id="first", include_leases=True) == before + + +@pytest.mark.parametrize("provider", ["file", "sqlite"]) +@pytest.mark.parametrize("failure", ["missing_authority", "changed_operation", "changed_source"]) +def test_cli_creation_recovery_cannot_recreate_or_adopt_authority(environment, provider, failure): + configure, bootstrap, _, project, runtime = environment + configure(provider, mode="hard_lease") + created = bootstrap() + restart_effect_runtime() + state = Path(created["state_file"]) + state.unlink() + digest = hashlib.sha256(b"first").hexdigest() + authority = runtime / "authority" / f"{provider}-v0" / ( + f"authority-{digest}.sqlite" if provider == "sqlite" else f"authority-store-{digest[:16]}.json") + before = authority.read_bytes() + if failure == "missing_authority": + authority.rename(authority.with_suffix(".unavailable")) + elif failure == "changed_operation": + registry = project / ".loopx/registry.json" + data = json.loads(registry.read_text()) + data["goals"][0]["creation_operation_id"] = "another-creation-operation" + registry.write_text(json.dumps(data)) + wrong_source = project / "another-source.md" + rejected = bootstrap("first", *( ["--state-file", str(wrong_source)] if failure == "changed_source" else []), expected_code=1) + assert rejected["ok"] is False + expected_error = {"missing_authority": "restore", "changed_operation": "different operation", "changed_source": "not bound"}[failure] + assert expected_error in rejected["error"], rejected + assert not authority.exists() if failure == "missing_authority" else authority.read_bytes() == before + assert not state.exists() + assert not wrong_source.exists() + + def test_pending_creation_uses_frozen_intent_after_machine_default_changes(environment): configure, bootstrap, marker, project, _ = environment # Independently model the durable boundary: registry/state published, selector absent. @@ -113,13 +214,14 @@ def request(path, body): @pytest.mark.parametrize("provider", ["file", "sqlite"]) @pytest.mark.parametrize("relative_runtime", [False, True]) -def test_app_creation_retries_storage_before_reporting_success(environment, app, monkeypatch, provider, relative_runtime): +@pytest.mark.parametrize("mode", [None, "soft_claim", "hard_lease"]) +def test_app_creation_retries_storage_before_reporting_success(environment, app, monkeypatch, provider, relative_runtime, mode): from loopx.capabilities.machine_configuration import goal_storage from loopx.todos import add_goal_todo configure, _, marker, project, _ = environment store, request = app - configure(provider) + configure(provider, mode=mode) registry = project / ".loopx/registry.json" # Registry-relative runtime routing must agree with CLI bootstrap, regardless # of the HTTP server process's working directory. @@ -177,6 +279,14 @@ def storage_effect(method, payload): assert recovered["proposal"]["receipt"]["outcome"] == "goal_created" assert selections[-1]["provider"] == provider assert selections[-1]["promotion_performed"] is False + if mode is not None: + from loopx.control_plane.coordination.local_authority import read_canonical_todos_if_promoted + source = read_canonical_todos_if_promoted(runtime_root=project.parent / "runtime", goal_id="recovery", include_leases=True) + assert source["handoff_mode"] == mode + assert len(source["todos"]) == 1 + assert source["todos"][0]["text"] == "Verify recovery" + assert source["source_authority"] == f"{provider}_v0" + assert selections[-1]["authority_initialized"] is True if provider == "sqlite": assert json.loads(marker("recovery").read_text())["provider"] == provider else: @@ -184,7 +294,20 @@ def storage_effect(method, payload): assert add.call_count == 1 assert request(apply_path, {})[1]["proposal"]["receipt"] == recovered["proposal"]["receipt"] assert add.call_count == 1 - assert len(selections) == 1 + assert len(selections) == (1 if mode is None else 2) + + +@pytest.mark.parametrize("provider", ["file", "sqlite"]) +def test_canonical_creation_force_rebuild_is_rejected_without_changing_todos(environment, provider): + configure, bootstrap, _, project, runtime = environment + configure(provider, mode="hard_lease") + created = bootstrap() + state = Path(created["state_file"]) + before = state.read_bytes() + rejected = bootstrap("first", "--force", expected_code=1) + assert "cannot rebuild" in rejected["error"] + assert state.read_bytes() == before + assert created["storage_selection"]["legacy_writer_fenced"] is True @pytest.mark.parametrize("provider", ["file", "sqlite"]) diff --git a/tests/control_plane_ts/content_digest_single_owner.test.ts b/tests/control_plane_ts/content_digest_single_owner.test.ts index 1992d56fe8..569e2ae329 100644 --- a/tests/control_plane_ts/content_digest_single_owner.test.ts +++ b/tests/control_plane_ts/content_digest_single_owner.test.ts @@ -100,6 +100,7 @@ const CANONICAL_CONSUMERS = [ "control_plane/collaboration/semantic_request.ts", "control_plane/coordination/authority_archive_read.ts", "control_plane/coordination/authority_source.ts", + "control_plane/coordination/legacy_writer_fence.ts", "control_plane/coordination/local_authority_migration.ts", "control_plane/coordination/local_authority_shadow.ts", "control_plane/coordination/local_authority_shadow_outbox.ts", diff --git a/tests/control_plane_ts/new_goal_initialization.test.ts b/tests/control_plane_ts/new_goal_initialization.test.ts new file mode 100644 index 0000000000..8cd1d1ff56 --- /dev/null +++ b/tests/control_plane_ts/new_goal_initialization.test.ts @@ -0,0 +1,111 @@ +import assert from "node:assert/strict"; +import {test} from "node:test"; +import {createHash} from "node:crypto"; +import {mkdtemp, writeFile, readFile, rm} from "node:fs/promises"; +import {tmpdir} from "node:os"; +import {join} from "node:path"; +import type {JsonObject} from "../../loopx/control_plane/effect_program.ts"; +import {manageNewGoalStorage} from "../../loopx/control_plane/coordination/local_authority_defaults.ts"; +import {FileAuthorityStore} from "../../loopx/control_plane/coordination/file_authority_store.ts"; +import {SqliteAuthorityStore} from "../../loopx/control_plane/coordination/sqlite_authority_store.ts"; +import {openLocalAuthorityStore, localAuthorityProviderPaths} from "../../loopx/control_plane/coordination/local_authority_provider.ts"; +import {loadLegacyCoordinationWriterFence, checkLegacyCoordinationWriteAllowed, LEGACY_COORDINATION_WRITE_CHECK_REQUEST_SCHEMA} from "../../loopx/control_plane/coordination/legacy_writer_fence.ts"; +import {projectCoordinationSource, SOURCE_PROJECTION_REQUEST_SCHEMA} from "../../loopx/control_plane/coordination/source_projection.ts"; +import {TODO_DOMAIN_READ_RECORD_SCHEMA} from "../../loopx/control_plane/coordination/coordination_state_contract.ts"; +import {canonicalAuthoritySha256} from "../../loopx/control_plane/coordination/authority_store_codec.ts"; + +async function fixture(provider: "file" | "sqlite") { + const root = await mkdtemp(join(tmpdir(), "loopx-new-authority-")); + const state = join(root, "state.md"), registryPath = join(root, "registry.json"); + const bytes = "---\ngoal_id: new-goal\nhandoff_mode: hard_lease\n---\n# Goal\n\n## Agent Todo\n"; + const target = {schema_version: "loopx_new_goal_storage_target_v1", provider, handoff_mode: "hard_lease"}; + const registry = {common_runtime_root: root, goals: [{id: "new-goal", repo: root, state_file: "state.md", + creation_operation_id: "create-native", coordination: {storage_target: target}}]}; + const registryBytes = JSON.stringify(registry); + await writeFile(state, bytes); await writeFile(registryPath, registryBytes); + const projection = projectCoordinationSource({schema_version: SOURCE_PROJECTION_REQUEST_SCHEMA, kind: "snapshot", + goal_id: "new-goal", handoff_mode: "hard_lease", todos: [], leases: [], read_model_schema: TODO_DOMAIN_READ_RECORD_SCHEMA}).projection as JsonObject; + const sha = (value: string) => createHash("sha256").update(value).digest("hex"); + const request: JsonObject = {action: "initialize", runtime_root: root, goal_id: "new-goal", target, + creation_operation_id: "create-native", projection, source_snapshot: {state_path: state, registered_state_path: state, + registered_runtime_root: root, state_bytes_sha256: `sha256:${sha(bytes)}`, lease_inventory: [], + projection_sha256: canonicalAuthoritySha256(projection), evidence_files: [], + registry_source: {path: registryPath, sha256: sha(registryBytes), registered_agents: []}}}; + return {root, state, registryPath, request}; +} + +for (const provider of ["file", "sqlite"] as const) { + test(`${provider}: interrupted creation fences old writers and retries the original native receipt`, async () => { + const f = await fixture(provider), Store = provider === "file" ? FileAuthorityStore : SqliteAuthorityStore; + const commit = Store.prototype.commitAuthority; + try { + Store.prototype.commitAuthority = async () => {throw new Error("lost before commit");}; + await assert.rejects(manageNewGoalStorage(f.request), /interrupted/); + const blocked = await checkLegacyCoordinationWriteAllowed({schema_version: LEGACY_COORDINATION_WRITE_CHECK_REQUEST_SCHEMA, + runtime_root: f.root, goal_id: "new-goal"}); + assert.equal(blocked.status, "blocked"); + Store.prototype.commitAuthority = commit; + const recovered = await manageNewGoalStorage(f.request); + assert.equal(recovered.authority_initialized, true); + const fence = await loadLegacyCoordinationWriterFence(f.root, "new-goal"); + assert.equal(fence.status, "loaded"); if (fence.status === "loaded") assert.equal(fence.fence.creation_completed, true); + const store = await openLocalAuthorityStore(f.root, "new-goal"), loaded = await store.loadAuthority(); + assert.equal(loaded.status, "loaded"); if (loaded.status !== "loaded") return; + await store.commitAuthority({expected_provider_revision: loaded.provider_revision, operation_id: "later-write", + next_projection: {...loaded.head, progress: "later native write"}, events: [], receipts: []}); + const replay = await manageNewGoalStorage(f.request); + assert.equal(replay.cursor, "1", "read the original creation receipt after later commits"); + const after = await store.loadAuthority(); + assert.equal(after.status, "loaded"); if (after.status === "loaded") assert.equal(after.head.progress, "later native write"); + const {projection: _projection, ...receiptRequest} = f.request; + await rm(f.state); + assert.equal((await manageNewGoalStorage(receiptRequest)).authority_initialized, true, + "completed native receipt must not depend on Markdown source parsing or availability"); + assert.equal((await store.scanCommitted(null, 10)).status, "page"); + } finally {Store.prototype.commitAuthority = commit; await rm(f.root, {recursive: true, force: true});} + }); + test(`${provider}: foreign operation and nonempty source do not grant creation or change selection`, async () => { + const f = await fixture(provider); + try { + await assert.rejects(manageNewGoalStorage({...f.request, creation_operation_id: "foreign"}), /original operation/); + const snapshot = f.request.source_snapshot as JsonObject; + await assert.rejects(manageNewGoalStorage({...f.request, source_snapshot: {...snapshot, + lease_inventory: [{name: "retained.json", bytes_sha256: "sha256:retained"}]}}), /lease history/); + assert.equal((await loadLegacyCoordinationWriterFence(f.root, "new-goal")).status, "missing"); + await assert.rejects(readFile(localAuthorityProviderPaths(f.root, "new-goal").marker), {code: "ENOENT"}); + await writeFile(f.state, "changed source"); + await assert.rejects(manageNewGoalStorage(f.request), /source_changed_retry/); + assert.equal((await loadLegacyCoordinationWriterFence(f.root, "new-goal")).status, "missing"); + } finally {await rm(f.root, {recursive: true, force: true});} + }); +} + +test("creation opt-in reuses execution policy vocabulary; legacy is only a compatibility target", async () => { + await assert.rejects(manageNewGoalStorage({action: "resolve", configuration: {schema_version: "loopx_goal_storage_defaults_v1", + new_goal_provider: "sqlite", canonical_creation: true, new_goal_handoff_mode: "legacy"}}), /soft_claim or hard_lease/); + assert.deepEqual(await manageNewGoalStorage({action: "resolve", configuration: {schema_version: "loopx_goal_storage_defaults_v1", + new_goal_provider: "sqlite", canonical_creation: false, new_goal_handoff_mode: "hard_lease"}}), + {schema_version: "loopx_new_goal_storage_target_v0", provider: "sqlite"}); +}); + +test("completed File creation cannot recreate a lost authority document", async () => { + const f = await fixture("file"); + try { + await manageNewGoalStorage(f.request); + const store = await openLocalAuthorityStore(f.root, "new-goal"); + assert.ok(store instanceof FileAuthorityStore); + await rm(store.path); + await assert.rejects(manageNewGoalStorage(f.request), /restore its complete backup/); + await assert.rejects(readFile(store.path), {code: "ENOENT"}); + } finally {await rm(f.root, {recursive: true, force: true});} +}); + +test("unfinished creation preflight requests source capture without selecting a provider or fencing writers", async () => { + const f = await fixture("sqlite"); + try { + const {projection: _projection, ...request} = f.request; + assert.deepEqual(await manageNewGoalStorage(request), {source_capture_required: true}); + assert.equal((await loadLegacyCoordinationWriterFence(f.root, "new-goal")).status, "missing"); + await assert.rejects(readFile(localAuthorityProviderPaths(f.root, "new-goal").marker), {code: "ENOENT"}); + } finally {await rm(f.root, {recursive: true, force: true});} +});