From 80b5cd4d1f7af8089402b1cc4cc3f8b0fe51fe11 Mon Sep 17 00:00:00 2001 From: Lihua <1017343802@qq.com> Date: Wed, 9 Sep 2026 19:03:40 -0700 Subject: [PATCH 1/3] fix(todos): authorize canonical text edits with active lease proof Signed-off-by: Lihua <1017343802@qq.com> --- loopx/cli_commands/todo.py | 3 + .../cli_commands/todo_argument_validation.py | 7 +- loopx/cli_commands/todo_registration.py | 8 +- .../coordination/local_authority_runtime.ts | 3 + .../control_plane/coordination/todo_update.ts | 65 ++++++++++++-- .../todos/provider_compatibility_edit.py | 6 +- loopx/todos.py | 10 ++- .../test_local_coordination_authority.py | 26 ++++++ .../authority_store_conformance.ts | 84 +++++++++++++++++++ tests/control_plane_ts/todo_update.test.ts | 30 ++++++- 10 files changed, 228 insertions(+), 14 deletions(-) diff --git a/loopx/cli_commands/todo.py b/loopx/cli_commands/todo.py index 1fb999d3e6..70c894c2ae 100644 --- a/loopx/cli_commands/todo.py +++ b/loopx/cli_commands/todo.py @@ -407,6 +407,9 @@ def handle_todo_command( if value is not None }, clear_claim=bool(args.clear_claim), + update_operation_id=args.update_operation_id, + task_lease_idempotency_key=args.task_lease_idempotency_key, + task_lease_expected_version=args.task_lease_expected_version, **_todo_path_args(args), dry_run=bool(args.dry_run), ) diff --git a/loopx/cli_commands/todo_argument_validation.py b/loopx/cli_commands/todo_argument_validation.py index 74fc120d2e..f9038ef617 100644 --- a/loopx/cli_commands/todo_argument_validation.py +++ b/loopx/cli_commands/todo_argument_validation.py @@ -11,6 +11,7 @@ ("--follow-up", "followups"), ("--todo-id", "todo_id"), ("--claim-operation-id", "claim_operation_id"), + ("--update-operation-id", "update_operation_id"), ("--turn-instance-id", "turn_instance_id"), ("--completion-identity-key", "completion_identity_key"), ("--replan-obligation-id", "replan_obligation_id"), @@ -509,6 +510,8 @@ def validate_shared_todo_options(args: argparse.Namespace) -> None: raise ValueError( "--turn-instance-id is supported only by todo complete settlement" ) + if getattr(args, "update_operation_id", None) is not None and args.todo_command != "update": + raise ValueError("--update-operation-id is supported only by todo update") if getattr(args, "claim_operation_id", None) is not None and args.todo_command != "claim": raise ValueError("--claim-operation-id is supported only by todo claim") if ( @@ -526,7 +529,7 @@ def validate_shared_todo_options(args: argparse.Namespace) -> None: "--replan-obligation-id is supported only by todo add" ) if ( - args.todo_command not in {"claim", "complete", "supersede"} + args.todo_command not in {"claim", "update", "complete", "supersede"} and ( args.task_lease_idempotency_key or args.task_lease_expected_version is not None @@ -534,7 +537,7 @@ def validate_shared_todo_options(args: argparse.Namespace) -> None: ): raise ValueError( "--task-lease-idempotency-key and --task-lease-expected-version " - "are supported only by todo claim, todo complete, and todo supersede" + "are supported only by todo claim, todo update, todo complete, and todo supersede" ) if args.capability_binding_ref and args.todo_command != "add": raise ValueError( diff --git a/loopx/cli_commands/todo_registration.py b/loopx/cli_commands/todo_registration.py index 731e4388bc..d6f5910805 100644 --- a/loopx/cli_commands/todo_registration.py +++ b/loopx/cli_commands/todo_registration.py @@ -63,6 +63,10 @@ def register_todo_command( help="For capture-followups, append one public-safe agent follow-up todo. Repeat up to the requested batch.", ) todo_parser.add_argument("--todo-id", help="Structured todo id from status/quota, such as todo_ab12cd34ef56.") + todo_parser.add_argument( + "--update-operation-id", + help="For promoted text/note update, reuse this operation id after a lost response; changed intent is rejected.", + ) todo_parser.add_argument( "--claim-operation-id", help=( @@ -279,7 +283,7 @@ def register_todo_command( "--task-lease-idempotency-key", help=( "For todo claim on promoted hard-lease authority, atomically acquire " - "the canonical lease and claim; for complete and supersede, prove the " + "the canonical lease and claim; for promoted text/note update, complete and supersede, prove the " "execution instance that owns the active lease." ), ) @@ -288,7 +292,7 @@ def register_todo_command( type=int, help=( "For promoted todo claim, optionally compare-and-set the canonical " - "lease version; for complete and supersede, supply the active lease " + "lease version; for promoted text/note update, complete and supersede, supply the active lease " "version when it is effective." ), ) diff --git a/loopx/control_plane/coordination/local_authority_runtime.ts b/loopx/control_plane/coordination/local_authority_runtime.ts index aa70dee4bf..5b91c1772c 100644 --- a/loopx/control_plane/coordination/local_authority_runtime.ts +++ b/loopx/control_plane/coordination/local_authority_runtime.ts @@ -749,6 +749,9 @@ export async function updateLocalCoordinationTodo( registered_agents: input.registered_agents.map((agent) => claimAgentValue(agent, "registered agent")), operation_id: requireAuthorityStoreId(input.operation_id, "operation id"), + lease_idempotency_key: input.lease_idempotency_key == null ? null : + requireAuthorityStoreId(input.lease_idempotency_key, "lease_idempotency_key"), + lease_expected_version: optionalNonNegativeSafeInteger(input.lease_expected_version, "lease_expected_version"), patch: requireJsonObject(input.patch, "Todo update patch"), clear_fields: input.clear_fields.map((field) => claimAgentValue(field, "clear field")), dry_run: input.dry_run as boolean, diff --git a/loopx/control_plane/coordination/todo_update.ts b/loopx/control_plane/coordination/todo_update.ts index ee481c1b23..e2a6ccccaa 100644 --- a/loopx/control_plane/coordination/todo_update.ts +++ b/loopx/control_plane/coordination/todo_update.ts @@ -19,6 +19,10 @@ import { } from "./coordination_projection.ts"; import { normalizeRegisteredTodoAgents, normalizeTodoAgent } from "./todo_agents.ts"; +import { evaluateCoordinationTerminalFence, COORDINATION_TERMINAL_FENCE_REQUEST_SCHEMA } + from "./todo_lifecycle_decision.ts"; +import { leaseEpoch, parseLeaseTimestamp } from "../work_items/task_lease_acquire.ts"; + export const COORDINATION_TODO_UPDATE_REQUEST_SCHEMA = "loopx_local_coordination_todo_update_request_v0"; export const COORDINATION_TODO_UPDATE_RESULT_SCHEMA = @@ -39,6 +43,8 @@ export interface CoordinationTodoUpdateInput { readonly clear_fields: readonly string[]; readonly dry_run: boolean; readonly now: Date; + readonly lease_idempotency_key?: string | null; + readonly lease_expected_version?: number | null; } export type CoordinationTodoUpdateResult = JsonObject & { @@ -56,6 +62,14 @@ function isFailure(value: JsonObject): value is CoordinationTodoUpdateResult { } function normalizeInput(raw: CoordinationTodoUpdateInput): CoordinationTodoUpdateInput { + const key = raw.lease_idempotency_key ?? null; + const version = raw.lease_expected_version ?? null; + if (key !== null && (typeof key !== "string" || !key.trim() || key !== key.trim())) { + throw new AuthorityStoreProtocolError("lease_idempotency_key must be a non-empty unpadded string"); + } + if (version !== null && (!Number.isSafeInteger(version) || version < 0)) { + throw new AuthorityStoreProtocolError("lease_expected_version must be a non-negative safe integer"); + } const patch = canonicalAuthorityObject(raw.patch, "Todo update patch"); const clearFields = raw.clear_fields.map((field, index) => requireAuthorityStoreId(field, `clear_fields[${index}]`)); @@ -82,7 +96,7 @@ function normalizeInput(raw: CoordinationTodoUpdateInput): CoordinationTodoUpdat if (!(raw.now instanceof Date) || Number.isNaN(raw.now.valueOf())) { throw new AuthorityStoreProtocolError("now must be a valid Date"); } - return {...raw, + return {...raw, lease_idempotency_key: key, lease_expected_version: version, goal_id: requireAuthorityStoreId(raw.goal_id, "goal id"), todo_id: requireAuthorityStoreId(raw.todo_id, "todo id"), operation_id: requireAuthorityStoreId(raw.operation_id, "operation id"), @@ -122,7 +136,13 @@ function updateRequestSha(input: CoordinationTodoUpdateInput): string { return canonicalAuthoritySha256({goal_id: input.goal_id, todo_id: input.todo_id, expected_role: input.expected_role, actor_agent_id: input.actor_agent_id, patch: input.patch, - clear_fields: input.clear_fields, dry_run: input.dry_run}); + clear_fields: input.clear_fields, dry_run: input.dry_run, + // Preserve receipt identity for pre-proof requests already persisted in v0. + ...(input.lease_idempotency_key != null || input.lease_expected_version != null ? { + lease_idempotency_key: input.lease_idempotency_key, + lease_expected_version: input.lease_expected_version, + } : {}), + }); } function loadUpdateTarget( @@ -166,11 +186,42 @@ function targetRejection( if (todo.claimed_by && todo.claimed_by !== input.actor_agent_id) { return failure("update_owner_mismatch", "Todo update cannot edit another claim owner's work"); } - // Lease-bearing updates need an execution-instance fence in addition to the - // actor identity. Until the native request carries that proof, fail closed. - if (![undefined, "legacy", "soft_claim"].includes(head.handoff_mode as string | undefined) || - leases.has(input.todo_id)) { - return failure("update_lease_unsupported", "lease-bearing Todo updates are not yet supported"); + const lease = leases.get(input.todo_id); + const mode = head.handoff_mode === undefined ? "legacy" : head.handoff_mode; + if (typeof mode !== "string" || !["legacy", "soft_claim", "hard_lease"].includes(mode)) { + return failure("invalid_handoff_mode", "canonical handoff mode is invalid"); + } + if (lease !== undefined || mode === "hard_lease" || + input.lease_idempotency_key != null || input.lease_expected_version != null) { + try { + const expires = lease === undefined ? null : + typeof lease.expires_at === "string" ? parseLeaseTimestamp(lease.expires_at) : null; + if (lease?.status === "active" && expires === null) { + return failure("invalid_coordination_projection", "active lease expiry is invalid"); + } + const fence = evaluateCoordinationTerminalFence({ + schema_version: COORDINATION_TERMINAL_FENCE_REQUEST_SCHEMA, + todo, registered_agents: input.registered_agents, actor_agent_id: input.actor_agent_id, + // A historical lease never licenses an unfenced edit. No acquisition or override. + handoff_mode: lease !== undefined ? "hard_lease" : mode, + lease: lease === undefined ? null : {...lease, present: true, + active: lease.status === "active" && expires !== null && expires > input.now, + lease_epoch: leaseEpoch(lease)}, + lease_idempotency_key: input.lease_idempotency_key ?? null, + lease_expected_version: input.lease_expected_version ?? null, + allow_user_gate_auto_acquire: false, delegated_authority: false, + require_active_when_fence_supplied: true, + }); + if (fence.outcome !== "apply") { + return failure(String(fence.code), "Todo update requires the current active lease execution proof"); + } + if (lease !== undefined && todo.claimed_by !== input.actor_agent_id) { + return failure("update_owner_mismatch", "Leased Todo update requires the current claim owner"); + } + } catch (error) { + return failure("invalid_coordination_projection", + error instanceof Error ? error.message : "invalid lease facts"); + } } return null; } diff --git a/loopx/control_plane/todos/provider_compatibility_edit.py b/loopx/control_plane/todos/provider_compatibility_edit.py index 993d41c207..0f99c7b2ad 100644 --- a/loopx/control_plane/todos/provider_compatibility_edit.py +++ b/loopx/control_plane/todos/provider_compatibility_edit.py @@ -29,6 +29,8 @@ def edit_canonical_todo_if_promoted( actor_agent_id: str | None, role: str | None, text: str | None, note: str | None, dry_run: bool, project: Path | None = None, state_file: Path | None = None, + operation_id: str | None = None, task_lease_idempotency_key: str | None = None, + task_lease_expected_version: int | None = None, ) -> dict[str, Any] | None: canonical = read_canonical_todos_if_promoted(runtime_root=runtime_root, goal_id=goal_id) if canonical is None: @@ -60,7 +62,9 @@ def edit_canonical_todo_if_promoted( "runtime_root": str(runtime_root.resolve()), "goal_id": goal_id, "todo_id": todo_id, "role": role, "actor_agent_id": actor_agent_id, "registered_agents": registered_agent_ids_from_registry(registry_path, goal_id), - "operation_id": f"todo-update:{uuid4().hex}", + "operation_id": operation_id if operation_id is not None else f"todo-update:{uuid4().hex}", + "lease_idempotency_key": task_lease_idempotency_key, + "lease_expected_version": task_lease_expected_version, "patch": patch, "clear_fields": [], "dry_run": dry_run, "observed_at": now_local(), }) diff --git a/loopx/todos.py b/loopx/todos.py index 6927d5b4f1..5e973e7fe2 100644 --- a/loopx/todos.py +++ b/loopx/todos.py @@ -1040,6 +1040,7 @@ def update_goal_todo( clear_claim: bool = False, claim_only: bool = False, claim_operation_id: str | None = None, + update_operation_id: str | None = None, task_lease_idempotency_key: str | None = None, task_lease_expected_version: int | None = None, project: Path | None = None, @@ -1071,7 +1072,7 @@ def update_goal_todo( raise ValueError( "--task-lease-expected-version requires --task-lease-idempotency-key" ) - if task_lease_idempotency_key is not None and not promoted_claim: + if task_lease_idempotency_key is not None and claim_only and not promoted_claim: raise ValueError( "--task-lease-idempotency-key on todo claim requires promoted canonical authority; no legacy write attempted" ) @@ -1134,9 +1135,16 @@ def update_goal_todo( goal_id=goal_id, todo_id=normalize_todo_id(todo_id) or todo_id, actor_agent_id=agent_id, role=role, text=text, note=note, dry_run=dry_run, project=project, state_file=state_file, + operation_id=update_operation_id, + task_lease_idempotency_key=task_lease_idempotency_key, + task_lease_expected_version=task_lease_expected_version, ) if canonical_edit is not None: return canonical_edit + if update_operation_id is not None or (not claim_only and ( + task_lease_idempotency_key is not None or task_lease_expected_version is not None + )): + raise ValueError("update operation id and lease proof require promoted text/note-only update; no legacy write attempted") resolved_project, resolved_state_file = resolve_todo_state_path( registry_path=registry_path, goal_id=goal_id, diff --git a/tests/control_plane/test_local_coordination_authority.py b/tests/control_plane/test_local_coordination_authority.py index de9bd1d4d8..ef0f983063 100644 --- a/tests/control_plane/test_local_coordination_authority.py +++ b/tests/control_plane/test_local_coordination_authority.py @@ -1126,6 +1126,32 @@ def test_canonical_hard_lease_claim_cli_atomically_acquires_ownership( assert after["todos"][0]["claimed_by"] == "agent-a" assert not state_file.exists() + edit = [sys.executable, "-m", "loopx.cli", "--format", "json", "--registry", + str(registry_path), "todo", "update", "--goal-id", "goal-a", "--todo-id", + "todo_atomic_claim", "--agent-id", "agent-a", "--text", "Correct leased task", + "--note", "Updated note", "--update-operation-id", "cli-leased-edit", + "--task-lease-idempotency-key", "turn:atomic-cli-claim", + "--task-lease-expected-version", str(applied["lease"]["version"])] + def invoke_edit(argv): + return subprocess.run(argv, capture_output=True, text=True, timeout=30) + preview_edit = invoke_edit([*edit, "--dry-run"]) + assert preview_edit.returncode == 0, preview_edit.stdout + preview_edit.stderr + assert json.loads(preview_edit.stdout)["status"] == "planned" + assert list_goal_todos(registry_path=registry_path, goal_id="goal-a") == after + first_edit = invoke_edit(edit) + assert first_edit.returncode == 0, first_edit.stdout + first_edit.stderr + assert json.loads(first_edit.stdout)["status"] == "applied" + retry_edit = invoke_edit(edit) + assert retry_edit.returncode == 0, retry_edit.stdout + retry_edit.stderr + assert json.loads(retry_edit.stdout)["status"] == "replayed" + changed_edit = invoke_edit([*edit, "--note", "Different intent"]) + assert changed_edit.returncode != 0 + final = list_goal_todos(registry_path=registry_path, goal_id="goal-a") + assert final["todos"][0]["text"] == "Correct leased task" + assert final["todos"][0]["note"] == "Updated note" + assert final["todos"][0]["claimed_by"] == "agent-a" + assert not state_file.exists() + def test_promoted_terminal_lifecycle_commits_successors_and_archive_natively( tmp_path: Path, diff --git a/tests/control_plane_ts/authority_store_conformance.ts b/tests/control_plane_ts/authority_store_conformance.ts index e3fc4cbd44..a6a41fbf40 100644 --- a/tests/control_plane_ts/authority_store_conformance.ts +++ b/tests/control_plane_ts/authority_store_conformance.ts @@ -1157,6 +1157,90 @@ export function registerAuthorityStoreConformance( assert.equal(rejected.reason_code, "claim_owner_mismatch"); assert.deepEqual(await store.loadAuthority(), loaded); }); + test(`${providerName} conformance: lease-fenced text/note update (${native ? "native" : "v0"})`, async (t) => { + const {store, contender} = await factory(t); + const goalId = "goal-claim"; + const projection = {...todoClaimProjection(goalId, native), handoff_mode: "hard_lease"}; + await store.commitAuthority({operation_id: "seed-update", expected_provider_revision: null, + next_projection: projection, events: [], receipts: []}); + const initial = await store.loadAuthority(); + assert.equal(initial.status, "loaded"); + if (initial.status !== "loaded") return; + const todo = (initial.head.todos as Record[])[0]!; + const lease = {todo_id: "todo-claim", owner: "agent-a", status: "active", + idempotency_key: "execution-a", version: 4, lease_epoch: 2, + expires_at: "2026-09-05T06:00:00Z"}; + await store.commitAuthority(prepareCoordinationProjectionCommit({goal_id: goalId, + operation_id: "seed-lease", expected_provider_revision: initial.provider_revision, + projection: initial.head, mutations: [ + {kind: "todo_upsert", todo: {...todo, claimed_by: "agent-a"}}, + {kind: "lease_upsert", lease}, + ]})); + const request = {goal_id: goalId, todo_id: "todo-claim", expected_role: "agent", + actor_agent_id: "agent-a", registered_agents: ["agent-a", "agent-b"], + operation_id: "leased-edit", patch: {text: "Correct leased task", note: "Correction"}, + clear_fields: [], dry_run: false, now: new Date("2026-09-05T05:00:00Z"), + lease_idempotency_key: "execution-a", lease_expected_version: 4}; + const before = await store.loadAuthority(); + for (const invalid of [ + {...request, actor_agent_id: "agent-b"}, + {...request, lease_idempotency_key: "old-execution"}, + {...request, lease_expected_version: 3}, + {...request, lease_idempotency_key: null, lease_expected_version: null}, + {...request, now: new Date("2026-09-05T06:00:00Z")}, + ]) { + assert.equal((await executeCoordinationTodoUpdate(store, invalid)).status, "failed"); + assert.deepEqual(await store.loadAuthority(), before); + assert.equal((await store.readReceipt(request.operation_id)).status, "missing"); + } + assert.equal((await executeCoordinationTodoUpdate(store, {...request, dry_run: true})).status, "planned"); + assert.deepEqual(await store.loadAuthority(), before); + assert.equal((await executeCoordinationTodoUpdate(store, request)).status, "applied"); + const after = await store.loadAuthority(); + assert.equal(after.status, "loaded"); + if (after.status !== "loaded") return; + assert.deepEqual(after.head.leases, before.status === "loaded" ? before.head.leases : []); + assert.equal((after.head.todos as Record[])[0]!.note, "Correction"); + assert.equal((await executeCoordinationTodoUpdate(contender, {...request, + now: new Date("2026-09-06T05:00:00Z")})).status, "replayed"); + for (const changed of [{patch: {text: "Different"}}, {lease_expected_version: 5}, + {lease_idempotency_key: "different"}]) { + assert.equal((await executeCoordinationTodoUpdate(store, {...request, ...changed})).reason_code, + "coordination_operation_identity_mismatch"); + } + assert.deepEqual(await store.loadAuthority(), after); + for (const fault of ["lost_response", "lease_transfer"] as const) { + const fencedRequest = {...request, operation_id: fault, patch: {note: fault}}; + const intercepted: AuthorityStore = { + storeIdentity: () => store.storeIdentity(), loadAuthority: () => store.loadAuthority(), + readReceipt: (id) => store.readReceipt(id), + scanCommitted: (cursor, limit) => store.scanCommitted(cursor, limit), + commitAuthority: async (commit) => { + if (fault === "lease_transfer") { + const current = await contender.loadAuthority(); + assert.equal(current.status, "loaded"); + if (current.status !== "loaded") throw new Error("missing head"); + await contender.commitAuthority(prepareCoordinationProjectionCommit({goal_id: goalId, + operation_id: "transfer", expected_provider_revision: current.provider_revision, + projection: current.head, mutations: [{kind: "lease_upsert", + lease: {...lease, owner: "agent-b", version: 5, lease_epoch: 3}}]})); + return store.commitAuthority(commit); + } + assert.equal((await store.commitAuthority(commit)).status, "applied"); + return {status: "ambiguous", reason_code: "lost_response", reason: "response lost"}; + }, + }; + const outcome = await executeCoordinationTodoUpdate(intercepted, fencedRequest); + assert.equal(outcome.status, fault === "lost_response" ? "recovered" : "conflict"); + assert.equal((await store.readReceipt(fault)).status, + fault === "lost_response" ? "found" : "missing"); + } + const transferred = await store.loadAuthority(); + assert.equal((await executeCoordinationTodoUpdate(store, request)).status, "replayed"); + assert.equal((await executeCoordinationTodoUpdate(store, {...request, + operation_id: "stale-after-transfer"})).status, "failed"); + assert.deepEqual(await store.loadAuthority(), transferred); + }); for (const fault of ["lease_replaced", "lost_response"] as const) { test(`${providerName} conformance: hard-lease claim ${fault} (${native ? "native" : "v0"})`, async (t) => { const {store, contender} = await factory(t); diff --git a/tests/control_plane_ts/todo_update.test.ts b/tests/control_plane_ts/todo_update.test.ts index 98bfb81983..817f2fd50f 100644 --- a/tests/control_plane_ts/todo_update.test.ts +++ b/tests/control_plane_ts/todo_update.test.ts @@ -120,9 +120,10 @@ test(`provider-first update fails closed without a hard-lease execution proof ($ next_projection: {...head.head, handoff_mode: "hard_lease", leases: [{ todo_id: "todo_a", owner: "agent-a", status: "active", expires_at: "2026-09-06T00:00:00Z", + idempotency_key: "execution-a", version: 1, lease_epoch: 1, }]}}); const result = await executeCoordinationTodoUpdate(store, request); - assert.equal(result.reason_code, "update_lease_unsupported"); + assert.equal(result.reason_code, "lease_fence_required"); assert.equal((await store.readReceipt(request.operation_id)).status, "missing"); }); } @@ -150,3 +151,30 @@ test("provider-first update records no-change identity without state mutation", assert.equal((await executeCoordinationTodoUpdate(store, {...noChangeRequest, operation_id: "independent-reset"})).status, "applied"); }); + +for (const [label, leaseChange, todoChange, reason] of [ + ["released", {status: "released"}, {}, "handoff_mode_requires_lease"], + ["expired", {expires_at: "2026-01-01T00:00:00Z"}, {}, "handoff_mode_requires_lease"], + ["malformed expiry", {expires_at: "invalid"}, {}, "invalid_coordination_projection"], + ["malformed epoch", {lease_epoch: -1}, {}, "invalid_coordination_projection"], + ["unclaimed", {}, {claimed_by: null}, "update_owner_mismatch"], +] as const) { + test(`leased update rejects ${label} without writing`, async () => { + const {store, request} = await seeded(todoChange); + const head = await store.loadAuthority(); + assert.equal(head.status, "loaded"); + if (head.status !== "loaded") return; + await store.commitAuthority({operation_id: "lease-invalid-case", + expected_provider_revision: head.provider_revision, events: [], receipts: [], + next_projection: {...head.head, handoff_mode: "hard_lease", leases: [{ + todo_id: "todo_a", owner: "agent-a", status: "active", version: 2, lease_epoch: 2, + idempotency_key: "execution-a", expires_at: "2026-09-06T00:00:00Z", ...leaseChange, + }]}}); + const before = await store.loadAuthority(); + const result = await executeCoordinationTodoUpdate(store, {...request, + lease_idempotency_key: "execution-a", lease_expected_version: 2}); + assert.equal(result.reason_code, reason); + assert.deepEqual(await store.loadAuthority(), before); + assert.equal((await store.readReceipt(request.operation_id)).status, "missing"); + }); +} From 35762fb1de69d21ceb4a1822707b65951bd0f134 Mon Sep 17 00:00:00 2001 From: Lihua <1017343802@qq.com> Date: Wed, 9 Sep 2026 19:03:40 -0700 Subject: [PATCH 2/3] docs(todos): document lease-fenced edits and explicit retries Signed-off-by: Lihua <1017343802@qq.com> --- .../typescript-control-plane-migration-v0.md | 11 ++++++++ ...script-control-plane-migration-v0.zh-CN.md | 8 ++++++ docs/project-agent-todo-contract.md | 28 +++++++++++++++++++ 3 files changed, 47 insertions(+) diff --git a/docs/architecture/rfcs/typescript-control-plane-migration-v0.md b/docs/architecture/rfcs/typescript-control-plane-migration-v0.md index 127e423fdd..296a06d759 100644 --- a/docs/architecture/rfcs/typescript-control-plane-migration-v0.md +++ b/docs/architecture/rfcs/typescript-control-plane-migration-v0.md @@ -16,6 +16,17 @@ ## Current implementation checkpoint +Provider-first text/note updates now accept the active execution key and lease +version through the existing terminal fence, with automatic acquisition and +delegated overrides disabled. The same provider revision guards the edit and +receipt; the lease is never mutated. Explicit `--update-operation-id` supports +CLI retries with unchanged proof and intent, including historical replay after +expiry or transfer. Missing/stale proof and historical inactive leases fail +closed. No-proof receipt fingerprints remain compatible. This is the bounded +#4105 lease-fence slice, not full T1 metadata or T2 effect closure; legacy updates +without these options remain unchanged. See the [Todo contract](../../project-agent-todo-contract.md#lease-fenced-canonical-textnote-updates). + + Public Todo add/update now resolve role, continuation binding, gate scope and deferred-condition requirements through `todos/authoring_scope.ts`. Python's `write_policy.py` and duplicated scope selection in `todos.py` are retired; 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 4f26f45c45..02a111b474 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 @@ -15,6 +15,14 @@ ## 当前实现检查点 +Provider-first text/note 更新现可携带当前执行 key 和租约版本,复用 terminal fence, +禁用自动获取及委托覆盖。修改和回执受同一个 provider revision 保护,租约不变。 +显式 `--update-operation-id` 支持同凭证、同内容的 CLI 重试,过期或转交后仍可回放 +历史回执。缺失/陈旧凭证及历史非活跃租约拒绝;无凭证的旧回执指纹保持兼容。 +这是 #4105 的租约 fence 切片,不是完整 T1 metadata 或 T2 effect 闭合;不带新选项 +的 legacy 更新不变。用法见 [Todo 合同](../../project-agent-todo-contract.md#lease-fenced-canonical-textnote-updates)。 + + 公开 Todo add/update 现通过 `todos/authoring_scope.ts` 统一解析角色、continuation 绑定、gate 作用域与 deferred 条件要求。删除 Python `write_policy.py` 及 `todos.py` 重复的 scope 选择;Markdown codec 只保留早期 class 检查的适配调用。已物化的 terminal diff --git a/docs/project-agent-todo-contract.md b/docs/project-agent-todo-contract.md index 2f37b8851a..9eb01c17dd 100644 --- a/docs/project-agent-todo-contract.md +++ b/docs/project-agent-todo-contract.md @@ -886,3 +886,31 @@ The fourth verifies concurrent todo writers wait on the active-state lock and preserve both claim metadata and unrelated updates. The fifth verifies per-todo `required_capabilities`, including multiple P0/P1 candidate selection, bridge repair, and owner-gated capability misses. + +### Lease-fenced canonical text/note updates + +After explicit canonical-authority promotion, the active lease holder can edit +only `text` and `note` using the existing execution key and current lease version: + +```bash +loopx todo update --goal-id --todo-id --agent-id \ + --text 'Correct task description' --note 'Correction context' \ + --task-lease-idempotency-key --task-lease-expected-version \ + --update-operation-id +``` + +Reuse the update id, execution proof and edit intent after a lost response. +The original receipt can be replayed after lease expiry or transfer; it grants no +current execution authority. Changed proof or edit intent with that id conflicts. +Omitting the update id preserves a fresh id per CLI invocation. Preview writes +nothing and does not consume the id. Updates preserve the lease exactly: they +cannot acquire, renew, release or transfer it. Missing, stale or expired proof +fails closed. These options do not enable promotion or a legacy Markdown fallback; +legacy updates without the new options retain their existing behavior. + +显式切换到 canonical authority 后,当前租约持有者可使用执行 key 和当前租约版本 +修改 `text`/`note`。响应丢失后复用相同 `--update-operation-id`、凭证和修改内容; +历史回执可在租约过期或转交后回放,但不授予当前执行权。相同 ID 搭配不同凭证或 +内容会冲突;省略 ID 则每次 CLI 调用生成新 ID。Preview 不写入、不消耗 ID。 +更新不获取、续期、释放或转交租约;缺失、陈旧或过期凭证拒绝。此入口不自动 +promotion,也不回退 Markdown;不带新选项的 legacy 更新保持原行为。 From bf9a2f9867e20921f9795c88c87f63f395c644e6 Mon Sep 17 00:00:00 2001 From: huangruiteng Date: Thu, 10 Sep 2026 17:47:55 +0800 Subject: [PATCH 3/3] fix(todos): use the shared timestamp codec after main integration Signed-off-by: huangruiteng --- loopx/control_plane/coordination/todo_update.ts | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/loopx/control_plane/coordination/todo_update.ts b/loopx/control_plane/coordination/todo_update.ts index e2a6ccccaa..b53be6bff2 100644 --- a/loopx/control_plane/coordination/todo_update.ts +++ b/loopx/control_plane/coordination/todo_update.ts @@ -21,7 +21,8 @@ import { normalizeRegisteredTodoAgents, normalizeTodoAgent } from "./todo_agents import { evaluateCoordinationTerminalFence, COORDINATION_TERMINAL_FENCE_REQUEST_SCHEMA } from "./todo_lifecycle_decision.ts"; -import { leaseEpoch, parseLeaseTimestamp } from "../work_items/task_lease_acquire.ts"; +import { leaseEpoch } from "../work_items/task_lease_acquire.ts"; +import { parseIsoTimestamp } from "../runtime_timestamp.ts"; export const COORDINATION_TODO_UPDATE_REQUEST_SCHEMA = "loopx_local_coordination_todo_update_request_v0"; @@ -195,7 +196,7 @@ function targetRejection( input.lease_idempotency_key != null || input.lease_expected_version != null) { try { const expires = lease === undefined ? null : - typeof lease.expires_at === "string" ? parseLeaseTimestamp(lease.expires_at) : null; + typeof lease.expires_at === "string" ? parseIsoTimestamp(lease.expires_at) : null; if (lease?.status === "active" && expires === null) { return failure("invalid_coordination_projection", "active lease expiry is invalid"); }