From 0fd6762730ae296e12c1df974e9715de9d9d6c4c Mon Sep 17 00:00:00 2001 From: huangruiteng <14976749+huangruiteng@users.noreply.github.com> Date: Sun, 13 Sep 2026 00:16:30 +0800 Subject: [PATCH 1/5] refactor(todo): normalize canonical update intent Signed-off-by: huangruiteng <14976749+huangruiteng@users.noreply.github.com> --- .../control_plane/coordination/todo_update.ts | 68 ++++++-- loopx/control_plane/todos/authoring_scope.ts | 6 +- loopx/control_plane/todos/field_update.ts | 26 +++- loopx/control_plane/todos/line_update.py | 18 ++- .../control_plane/todos/native_update_plan.ts | 19 ++- loopx/control_plane/todos/provider_update.py | 2 +- loopx/control_plane/todos/public_update.ts | 6 +- loopx/control_plane/todos/update_intent.py | 147 ++++++++++++++++++ loopx/todos.py | 56 ++++--- 9 files changed, 291 insertions(+), 57 deletions(-) create mode 100644 loopx/control_plane/todos/update_intent.py diff --git a/loopx/control_plane/coordination/todo_update.ts b/loopx/control_plane/coordination/todo_update.ts index 768b9ed3db..7d89e5a074 100644 --- a/loopx/control_plane/coordination/todo_update.ts +++ b/loopx/control_plane/coordination/todo_update.ts @@ -18,12 +18,13 @@ import { import { normalizeRegisteredTodoAgents, normalizeTodoAgent } from "./todo_agents.ts"; import { evaluateCoordinationTerminalFence, COORDINATION_TERMINAL_FENCE_REQUEST_SCHEMA, - registeredTodoMutationRejection } + evaluateCoordinationTodoMutationDecision, + COORDINATION_TODO_MUTATION_DECISION_REQUEST_SCHEMA } from "./todo_lifecycle_decision.ts"; import { leaseEpoch } from "../work_items/task_lease_acquire.ts"; import { parseIsoTimestamp } from "../runtime_timestamp.ts"; import { normalizeNativePlanningIntent, planNativeTodoUpdate } from "../todos/native_update_plan.ts"; -import {CoordinationCommandReceipt} from "./command_receipt.ts"; +import { CoordinationCommandReceipt } from "./command_receipt.ts"; export const COORDINATION_TODO_UPDATE_REQUEST_SCHEMA = "loopx_local_coordination_todo_update_request_v0"; @@ -167,25 +168,49 @@ function targetRejection( if (todo.archive_state !== "active") { return failure("todo_archived", "Todo update requires an active Todo"); } - if (todo.role !== "agent" || todo.status === "done") { + if (todo.status === "done") { return failure("unsupported_todo_update_target", - "native metadata update currently requires a non-completed agent Todo"); - } - const actorRejection = registeredTodoMutationRejection(todo, input.actor_agent_id, input.registered_agents); - if (actorRejection !== null) { - if (actorRejection === "claim_owner_mismatch") { - // Keep the public adapter's stable diagnostic while the typed predicate - // remains provider-neutral and reusable by lifecycle admission. - return failure("update_owner_mismatch", "Todo update cannot edit another claim owner's work"); - } - return failure(actorRejection, - "Todo update requires a registered, non-excluded actor within the existing owner/binding scope"); + "native metadata update cannot complete a Todo; use the terminal lifecycle command"); } 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"); } + const intent = input.planning_intent ?? {}; + const ownershipMutation = ["claimed_by", "clear_claim", "excluded_agents", "bound_agent", + "goal_bound", "blocks_agent", "clear_blocks_agent", "global_gate", "clear_global_gate"] + .some(field => Object.hasOwn(intent, field)); + const authorityDecision = evaluateCoordinationTodoMutationDecision({ + schema_version: COORDINATION_TODO_MUTATION_DECISION_REQUEST_SCHEMA, + command: "update", handoff_mode: mode, registered_agents: input.registered_agents, + lifecycle_grants: [], authority_action: "update", + actor_agent_id: input.actor_agent_id, + requested_claimed_by: intent.claimed_by ?? null, + clear_claim: intent.clear_claim === true, + ownership_mutation: ownershipMutation, + todo: { + ...todo, role: todo.role, status: todo.status, + excluded_agents: todo.excluded_agents ?? [], + required_decision_scopes: todo.required_decision_scopes ?? [], + }, + }); + if (authorityDecision.outcome !== "apply") { + const decisionCode = String(authorityDecision.code ?? "mutation_rejected"); + const code = decisionCode === "claim_owner_mismatch" + ? "update_owner_mismatch" : decisionCode; + return failure(code, "Todo update is outside the actor's registered owner/binding scope"); + } + // Preserve the single-agent compatibility path only for genuinely + // unowned work. An empty registry is not evidence that an arbitrary actor + // may rewrite an already-owned Todo. + if (input.registered_agents.length === 0 && input.actor_agent_id !== null) { + return failure("actor_not_registered", "Todo update requires a registered actor"); + } + if (input.actor_agent_id === null && (todo.claimed_by !== undefined || + todo.bound_agent !== undefined || todo.blocks_agent !== undefined)) { + return failure("actor_required", "owned or bound Todo updates require an actor"); + } // A retained lease, even expired/released, has execution lineage. Ownership // and exclusions must not change beneath it through a metadata operation. if ((lease !== undefined || mode === "hard_lease") && TODO_OWNERSHIP_INTENT_FIELDS.some(field => @@ -259,7 +284,10 @@ function prepareUpdatedTodo( const updates = planNativeTodoUpdate(todo, input.planning_intent!, head, input.actor_agent_id, input.registered_agents, String(next.updated_at)); for (const [field, value] of Object.entries(updates)) { - if (value === null) { delete next[field]; clearFields.add(field); } + // Markdown compatibility omits empty scalar metadata. Treat an + // explicit empty planning scalar as a clear in the canonical record as + // well; omission and clear are no longer conflated by the planner. + if (value === null || value === "") { delete next[field]; clearFields.add(field); } else next[field] = value; } next.done = next.status === "done" || next.status === "deferred"; @@ -297,7 +325,15 @@ export async function executeCoordinationTodoUpdate( const receipt = updateReceipt(input, requestSha); const replay = await receipt.read(store); if (replay !== null) return replay; - if (input.actor_agent_id === null || !input.registered_agents.includes(input.actor_agent_id)) { + // Legacy single-agent callers historically omitted actor_agent_id for an + // unowned Todo. Keep that narrow compatibility path, while retaining the + // registered-actor requirement for multi-agent or explicitly-owned work. + if (input.actor_agent_id === null) { + if (input.registered_agents.length > 1) { + return failure("actor_not_registered", "Todo update requires a registered actor"); + } + } else if (input.registered_agents.length === 0 || + !input.registered_agents.includes(input.actor_agent_id)) { return failure("actor_not_registered", "Todo update requires a registered actor"); } const head = await store.loadAuthority(); diff --git a/loopx/control_plane/todos/authoring_scope.ts b/loopx/control_plane/todos/authoring_scope.ts index 65726dca42..4753bb95a9 100644 --- a/loopx/control_plane/todos/authoring_scope.ts +++ b/loopx/control_plane/todos/authoring_scope.ts @@ -126,7 +126,11 @@ function planScope(command: string, role: string, taskClass: string | null, todo } const creating = command === "create"; let blocks = intent.clear_blocks_agent ? null : requestedBlocks || string(todo.blocks_agent, "blocks_agent"); - const global = intent.clear_global_gate ? null : intent.global_gate ? true : todo.global_gate as boolean | null ?? null; + const global = intent.clear_global_gate + ? null + : Object.hasOwn(intent, "global_gate") + ? intent.global_gate === true + : todo.global_gate as boolean | null ?? null; let bound = requestedBound || (intent.goal_bound ? null : string(todo.bound_agent, "bound_agent")); let goal = intent.goal_bound ? true : requestedBound ? false : todo.goal_bound as boolean | null ?? null; // Gate scope can determine continuation scope, but never overwrite a diff --git a/loopx/control_plane/todos/field_update.ts b/loopx/control_plane/todos/field_update.ts index c47e8cf3cf..c1f842993b 100644 --- a/loopx/control_plane/todos/field_update.ts +++ b/loopx/control_plane/todos/field_update.ts @@ -104,7 +104,11 @@ function bindingUpdates(block: JsonObject, intent: JsonObject, todoId: string): else if (intent.clear_blocks_agent) updates.blocks_agent = null; if (present(intent.excluded_agents)) updates.excluded_agents = intent.excluded_agents; if (intent.clear_global_gate) updates.global_gate = null; - else if (present(intent.global_gate)) updates.global_gate = intent.global_gate; + else if (Object.hasOwn(intent, "global_gate")) { + // The public record is presence-based: false means the gate is cleared, + // never a second persisted state that can shadow a scoped gate. + updates.global_gate = intent.global_gate === true ? true : null; + } return updates; } @@ -165,10 +169,6 @@ export function planTodoFieldUpdate(value: unknown): TodoFieldUpdatePlan { const updates: JsonObject = {todo_id: todoId, status: targetStatus}; if (normalizedStatus === "done" && !block.completed_at) updates.completed_at = updatedAt; else if (normalizedStatus && normalizedStatus !== "done") updates.completed_at = null; - // The public editing contract distinguishes omitted/empty text metadata from - // present collections and booleans. Never turn [] or false into omission. - for (const field of STRING_FIELDS) if (intent[field]) updates[field] = intent[field]; - for (const field of PRESENT_FIELDS) if (present(intent[field])) updates[field] = intent[field]; Object.assign(updates, bindingUpdates(block, intent, todoId)); if (intent.unblocks_todo_id) updates.unblocks_todo_id = intent.unblocks_todo_id; if (present(intent.successor_todo_ids)) updates.successor_todo_ids = intent.successor_todo_ids; @@ -182,6 +182,22 @@ export function planTodoFieldUpdate(value: unknown): TodoFieldUpdatePlan { } if (present(intent.no_followup)) updates.no_followup = intent.no_followup; Object.assign(updates, completionUpdates(block, intent, targetStatus, normalizedStatus)); + // Presence, rather than truthiness, is the mutation contract. An explicitly + // empty scalar clears the compatibility field; omitted values remain + // untouched. This fixes the old `if (intent[field])` conflation of omission + // and an intentional clear. + for (const field of STRING_FIELDS) { + if (Object.hasOwn(intent, field)) { + // `note` is a display annotation whose historical empty-input contract + // is omission/preservation. Other scalar metadata uses empty text as an + // explicit clear once it crosses this typed boundary. + if (field === "note" && typeof intent[field] === "string" && !intent[field].trim()) continue; + updates[field] = intent[field]; + } + } + for (const field of PRESENT_FIELDS) { + if (Object.hasOwn(intent, field)) updates[field] = intent[field]; + } // Public update carries the effective scope and raw observation once. The // field plan composes validation and generation without another RPC. const monitorPlan = request.monitor_context == null ? null : planMonitorMetadata({ diff --git a/loopx/control_plane/todos/line_update.py b/loopx/control_plane/todos/line_update.py index 4327cdb967..eac71671b1 100644 --- a/loopx/control_plane/todos/line_update.py +++ b/loopx/control_plane/todos/line_update.py @@ -285,9 +285,7 @@ def apply_todo_update_to_lines( if public_context is not None: public_context = {**public_context, "items": todo_update_snapshot(lines) if resume_when or block.get("resume_when") else []} - plan = _field_update_plan( - {**block, "role": resolved_role}, - { + raw_intent = { "status": status, "note": note, "evidence": evidence, @@ -327,7 +325,19 @@ def apply_todo_update_to_lines( "monitor_metadata": monitor_metadata, "clear_claim": clear_claim, "claim_only": claim_only, - }, + } + # Python's compatibility API uses None (and blank note text) for + # omission. Strip those sentinels before crossing the typed planner; an + # actual empty scalar such as reason="" remains an explicit clear. + intent = { + key: value for key, value in raw_intent.items() + if value is not None and not ( + key == "note" and isinstance(value, str) and not value.strip() + ) + } + plan = _field_update_plan( + {**block, "role": resolved_role}, + intent, updated_at, monitor_context, public_context, diff --git a/loopx/control_plane/todos/native_update_plan.ts b/loopx/control_plane/todos/native_update_plan.ts index 3cbd5fee52..48d9cdc7e8 100644 --- a/loopx/control_plane/todos/native_update_plan.ts +++ b/loopx/control_plane/todos/native_update_plan.ts @@ -9,10 +9,12 @@ import { planPublicTodoUpdate, TODO_PUBLIC_UPDATE_REQUEST_SCHEMA } from "./publi import { normalizeTodoWorkRequirements, TODO_WORK_REQUIREMENT_FIELDS } from "./work_requirements.ts"; import {normalizeTodoOwnershipIntent, TODO_OWNERSHIP_INTENT_FIELDS} from "./authoring_scope.ts"; -const STRINGS = new Set(["status", "evidence", "reason", "resume_when", "unblocks_todo_id"]); -const BOOLEANS = new Set(["clear_resume_when", "no_followup"]); -const FIELDS = new Set([...STRINGS, ...BOOLEANS, "successor_todo_ids", ...TODO_WORK_REQUIREMENT_FIELDS, - ...TODO_OWNERSHIP_INTENT_FIELDS]); +const STRINGS = new Set(["status", "evidence", "reason", "task_class", "continuation_policy", + "resume_when", "unblocks_todo_id", "bound_agent", "blocks_agent"]); +const BOOLEANS = new Set(["clear_resume_when", "no_followup", "goal_bound", "clear_blocks_agent", + "global_gate", "clear_global_gate"]); +const FIELDS = new Set([...STRINGS, ...BOOLEANS, "successor_todo_ids", + ...TODO_WORK_REQUIREMENT_FIELDS, ...TODO_OWNERSHIP_INTENT_FIELDS]); /** A separate intent namespace preserves the shipped text/note patch and its * historical receipt encoding. Raw field patches do not gain new authority. */ @@ -24,11 +26,16 @@ export function normalizeNativePlanningIntent(value: unknown): JsonObject { if (!FIELDS.has(field)) throw new AuthorityStoreProtocolError(`Todo planning update does not own ${field}`); if ((TODO_WORK_REQUIREMENT_FIELDS as readonly string[]).includes(field)) continue; if ((TODO_OWNERSHIP_INTENT_FIELDS as readonly string[]).includes(field)) continue; - if (value === null) continue; + if (value === null) { + // Null is an explicit clear for scalar planning metadata. Ownership + // fields use their dedicated clear switches and are normalized above. + if (STRINGS.has(field)) intent[field] = null; + continue; + } if (STRINGS.has(field)) { if (typeof value !== "string") throw new AuthorityStoreProtocolError(`${field} must be a string`); const text = compactPythonWhitespace(value); - if (text) intent[field] = field === "unblocks_todo_id" ? normalizeTodoId(text, field) : text; + intent[field] = field === "unblocks_todo_id" && text ? normalizeTodoId(text, field) : text; } else if (BOOLEANS.has(field)) { if (typeof value !== "boolean") throw new AuthorityStoreProtocolError(`${field} must be a boolean`); if (field !== "clear_resume_when" || value) intent[field] = value; diff --git a/loopx/control_plane/todos/provider_update.py b/loopx/control_plane/todos/provider_update.py index 24a2eb16a9..1f94f2a25d 100644 --- a/loopx/control_plane/todos/provider_update.py +++ b/loopx/control_plane/todos/provider_update.py @@ -96,7 +96,7 @@ def update_canonical_todo_if_promoted( ) return settle_canonical_todo_projection( {"ok": True, "goal_id": goal_id, "todo_id": todo_id, - "role": "agent", "dry_run": dry_run, **result}, + "role": role, "dry_run": dry_run, **result}, registry_path=registry_path, runtime_root=runtime_root, goal_id=goal_id, project=project, state_file=state_file, ) diff --git a/loopx/control_plane/todos/public_update.ts b/loopx/control_plane/todos/public_update.ts index 5e948d6c0f..669d656476 100644 --- a/loopx/control_plane/todos/public_update.ts +++ b/loopx/control_plane/todos/public_update.ts @@ -61,7 +61,11 @@ export function planPublicTodoUpdate(value: unknown): JsonObject { const role = context.role; const effectiveIntent = {...intent, bound_agent: role === "user" ? scope.bound_agent : null, - goal_bound: role === "user" && scope.goal_bound ? true : null, + // Carry an explicit false when a gate changes from goal-wide to an + // agent-bound continuation; omission would leave stale goal_bound=true in + // the canonical record. + goal_bound: role === "user" && scope.goal_bound !== null + ? scope.goal_bound : null, clear_user_binding: scope.clear_user_binding, resume_when: scope.normalized_resume_when, resume_monitor_generation: metadata?.resume_monitor_generation ?? null}; diff --git a/loopx/control_plane/todos/update_intent.py b/loopx/control_plane/todos/update_intent.py new file mode 100644 index 0000000000..4c952b640a --- /dev/null +++ b/loopx/control_plane/todos/update_intent.py @@ -0,0 +1,147 @@ +"""Build the one canonical Todo-update intent sent to the TS authority. + +The CLI has a deliberately wide signature for backwards compatibility. Keep +the translation in one place so a newly added option cannot accidentally take +the Markdown path while its siblings use the provider transaction. +""" + +from __future__ import annotations + +from typing import Any + + +# Keep governance decisions and monitor effects on their owning paths. These +# are exactly the planning fields accepted by native_update_plan.ts; the set +# is intentionally duplicated here as a boundary check, not as a second rule +# implementation. Unsupported fields remain on the legacy/effect path until +# their canonical transaction has a typed contract. +_CANONICAL_INTENT_FIELDS = frozenset( + { + "status", + "evidence", + "reason", + "task_class", + "action_kind", + "task_domain", + "task_repository", + "continuation_policy", + "required_write_scopes", + "required_capabilities", + "target_capabilities", + "explore_result_node_refs", + "claimed_by", + "bound_agent", + "goal_bound", + "blocks_agent", + "clear_blocks_agent", + "excluded_agents", + "global_gate", + "clear_global_gate", + "unblocks_todo_id", + "successor_todo_ids", + "resume_when", + "clear_resume_when", + "no_followup", + "clear_claim", + } +) + + +def build_canonical_update_intent( + *, + status: str | None = None, + evidence: str | None = None, + reason: str | None = None, + task_class: str | None = None, + action_kind: str | None = None, + task_domain: str | None = None, + task_repository: str | None = None, + continuation_policy: str | None = None, + required_write_scopes: list[str] | None = None, + required_capabilities: list[str] | None = None, + target_capabilities: list[str] | None = None, + explore_result_node_refs: list[str] | None = None, + decision_scope: Any = None, + required_decision_scopes: Any = None, + claimed_by: str | None = None, + bound_agent: str | None = None, + goal_bound: bool = False, + blocks_agent: str | None = None, + clear_blocks_agent: bool = False, + excluded_agents: list[str] | None = None, + clear_excluded_agents: bool = False, + global_gate: bool = False, + clear_global_gate: bool = False, + unblocks_todo_id: str | None = None, + successor_todo_ids: list[str] | None = None, + resume_when: str | None = None, + clear_resume_when: bool = False, + no_followup: bool | None = None, + clear_claim: bool = False, +) -> dict[str, Any]: + """Return only explicitly requested fields, retaining explicit clears. + + Empty lists are intentional clears. Boolean clear switches are emitted as + explicit values so the TypeScript transaction can reject contradictory + intent before it opens a provider write. + """ + + values: dict[str, Any] = { + "status": status, + "evidence": evidence, + "reason": reason, + "task_class": task_class, + "action_kind": action_kind, + "task_domain": task_domain, + "task_repository": task_repository, + "continuation_policy": continuation_policy, + "required_write_scopes": required_write_scopes, + "required_capabilities": required_capabilities, + "target_capabilities": target_capabilities, + "explore_result_node_refs": explore_result_node_refs, + "decision_scope": decision_scope, + "required_decision_scopes": required_decision_scopes, + "claimed_by": claimed_by, + "bound_agent": bound_agent, + "goal_bound": goal_bound if goal_bound else None, + "blocks_agent": blocks_agent, + "clear_blocks_agent": clear_blocks_agent if clear_blocks_agent else None, + "excluded_agents": [] if clear_excluded_agents else excluded_agents, + "global_gate": global_gate if global_gate else None, + "clear_global_gate": clear_global_gate if clear_global_gate else None, + "unblocks_todo_id": unblocks_todo_id, + "successor_todo_ids": successor_todo_ids, + "resume_when": resume_when, + "clear_resume_when": clear_resume_when if clear_resume_when else None, + "no_followup": no_followup, + "clear_claim": clear_claim if clear_claim else None, + } + return {key: value for key, value in values.items() if value is not None} + + +def canonical_update_is_supported( + *, + text: str | None, + note: str | None, + intent: dict[str, Any], + monitor_metadata: Any, + authority_reason: str | None, + status: str | None, +) -> bool: + """Whether an ordinary update can use the canonical transaction. + + Terminal completion and monitor polling retain their effect-owned paths. + They must not silently fall back to Markdown after authority promotion. + """ + + if monitor_metadata or authority_reason: + return False + if status is not None and status.strip().lower() == "done": + return False + if any(field not in _CANONICAL_INTENT_FIELDS for field in intent): + return False + # Empty notes are the long-standing compatibility spelling for omission; + # routing them to the canonical adapter would produce an empty patch and a + # less useful protocol error. Text still uses the normal non-empty text + # validator at the provider boundary. + return text is not None or (note is not None and bool(note.strip())) or bool(intent) diff --git a/loopx/todos.py b/loopx/todos.py index 06428c4128..913d4f9cd4 100644 --- a/loopx/todos.py +++ b/loopx/todos.py @@ -121,6 +121,10 @@ read_canonical_todos_if_promoted, ) from .control_plane.todos.provider_update import update_canonical_todo_if_promoted +from .control_plane.todos.update_intent import ( + build_canonical_update_intent, + canonical_update_is_supported, +) from .control_plane.todos.provider_create import create_canonical_todo_if_promoted from .control_plane.todos.path_resolution import resolve_todo_state_path from .control_plane.todos.provider_terminal_lifecycle import provider_first_terminal_lifecycle @@ -1112,29 +1116,35 @@ def update_goal_todo( ) if canonical_claim is not None: return canonical_claim - # Transport explicit planning intent; TS owns its meaning at the canonical - # commit revision. Unsupported fields still encounter the promotion fence. - planning_intent = {key: value for key, value in { - "status": status, "evidence": evidence, "reason": reason, - "resume_when": resume_when, "clear_resume_when": clear_resume_when or None, - "unblocks_todo_id": unblocks_todo_id, "successor_todo_ids": successor_todo_ids, - "no_followup": no_followup, - "action_kind": action_kind, "task_domain": task_domain, - "task_repository": task_repository, "required_write_scopes": required_write_scopes, - "required_capabilities": required_capabilities, "target_capabilities": target_capabilities, - "explore_result_node_refs": explore_result_node_refs, - "claimed_by": claimed_by, "clear_claim": clear_claim or None, - "excluded_agents": [] if clear_excluded_agents else excluded_agents, - }.items() if value is not None} - if not claim_only and (text is not None or note is not None or planning_intent) and not any(( - monitor_metadata, - goal_bound, clear_blocks_agent, global_gate, - clear_global_gate, authority_reason, - )) and all(value is None for value in ( - task_class, continuation_policy, - decision_scope, required_decision_scopes, bound_agent, - blocks_agent, authority_reason, - )): + # Translate the compatibility-sized CLI signature exactly once. The + # canonical transaction now owns ordinary role/binding/work-declaration + # edits as well as text/note corrections; monitor observations and terminal + # completion remain effect-owned and therefore stay off this route. + planning_intent = build_canonical_update_intent( + status=status, evidence=evidence, reason=reason, task_class=task_class, + action_kind=action_kind, task_domain=task_domain, + task_repository=task_repository, continuation_policy=continuation_policy, + required_write_scopes=required_write_scopes, + required_capabilities=required_capabilities, + target_capabilities=target_capabilities, + explore_result_node_refs=explore_result_node_refs, + decision_scope=decision_scope, + required_decision_scopes=required_decision_scopes, + claimed_by=claimed_by, bound_agent=bound_agent, goal_bound=goal_bound, + blocks_agent=blocks_agent, clear_blocks_agent=clear_blocks_agent, + excluded_agents=excluded_agents, + clear_excluded_agents=clear_excluded_agents, + global_gate=global_gate, clear_global_gate=clear_global_gate, + unblocks_todo_id=unblocks_todo_id, + successor_todo_ids=successor_todo_ids, resume_when=resume_when, + clear_resume_when=clear_resume_when, no_followup=no_followup, + clear_claim=clear_claim, + ) + if not claim_only and canonical_update_is_supported( + text=text, note=note, intent=planning_intent, + monitor_metadata=monitor_metadata, authority_reason=authority_reason, + status=status, + ): canonical_edit = update_canonical_todo_if_promoted( registry_path=registry_path, runtime_root=shadow_runtime_root, goal_id=goal_id, todo_id=normalize_todo_id(todo_id) or todo_id, From 23e4837100350360e5dab4bf010c5bdd08929026 Mon Sep 17 00:00:00 2001 From: huangruiteng <14976749+huangruiteng@users.noreply.github.com> Date: Sun, 13 Sep 2026 00:16:47 +0800 Subject: [PATCH 2/5] test(todo): cover update intent compatibility matrix Signed-off-by: huangruiteng <14976749+huangruiteng@users.noreply.github.com> --- .../control_plane/test_todo_update_intent.py | 79 +++++++++++++++++++ .../native_planning_update_conformance.ts | 73 +++++++++++++++++ .../production_scale_coordination_fixture.ts | 3 + .../coordination_production_scale_v0.json | 36 +++++++++ 4 files changed, 191 insertions(+) create mode 100644 tests/control_plane/test_todo_update_intent.py diff --git a/tests/control_plane/test_todo_update_intent.py b/tests/control_plane/test_todo_update_intent.py new file mode 100644 index 0000000000..488d4d626c --- /dev/null +++ b/tests/control_plane/test_todo_update_intent.py @@ -0,0 +1,79 @@ +from loopx.control_plane.todos.update_intent import ( + build_canonical_update_intent, + canonical_update_is_supported, +) + + +def test_update_intent_keeps_explicit_clears_and_empty_scalars() -> None: + intent = build_canonical_update_intent( + reason="", + required_capabilities=[], + clear_claim=True, + clear_global_gate=True, + ) + + assert intent == { + "reason": "", + "required_capabilities": [], + "clear_global_gate": True, + "clear_claim": True, + } + + +def test_update_route_only_promotes_fields_owned_by_native_transaction() -> None: + supported = build_canonical_update_intent( + action_kind="publish", + task_domain="delivery", + task_repository="git:github.com/example/project", + required_write_scopes=["src/**"], + ) + assert canonical_update_is_supported( + text=None, + note=None, + intent=supported, + monitor_metadata=None, + authority_reason=None, + status=None, + ) + + # Decision-scope governance remains on its owning effect path. It must + # not be silently reinterpreted as an ordinary metadata transaction. + governance = build_canonical_update_intent( + decision_scope={"kind": "write_scope", "granularity": "action"}, + ) + assert not canonical_update_is_supported( + text=None, + note=None, + intent=governance, + monitor_metadata=None, + authority_reason=None, + status=None, + ) + + +def test_terminal_and_monitor_updates_stay_off_canonical_route() -> None: + intent = build_canonical_update_intent(reason="ordinary") + assert not canonical_update_is_supported( + text=None, + note=None, + intent=intent, + monitor_metadata={"material_change": True}, + authority_reason=None, + status=None, + ) + assert not canonical_update_is_supported( + text=None, + note=None, + intent=intent, + monitor_metadata=None, + authority_reason=None, + status="done", + ) + assert not canonical_update_is_supported( + text=None, + note=" ", + intent={}, + monitor_metadata=None, + authority_reason=None, + status=None, + ) diff --git a/tests/control_plane_ts/native_planning_update_conformance.ts b/tests/control_plane_ts/native_planning_update_conformance.ts index 62dcbfaf49..0f3668f507 100644 --- a/tests/control_plane_ts/native_planning_update_conformance.ts +++ b/tests/control_plane_ts/native_planning_update_conformance.ts @@ -175,4 +175,77 @@ export function registerNativePlanningUpdateConformance(provider: string, factor assert.equal((await store.readReceipt(operation_id)).status, "missing"); } }); + + for (const native of [false, true]) test(`${provider}: update intent matrix preserves compatibility clears and user scope`, async t => { + const {store} = await factory(t); + const fixture = productionScaleCoordinationFixture("update-matrix"); + // Authority projections require deterministic todo_id ordering. Keep the + // fixture declaration readable while making its synthesized head valid. + const cases = Object.values(fixture.update_cases) + .sort((left, right) => String(left.todo_id).localeCompare(String(right.todo_id))); + const todos = cases.map((item, index) => ({ + schema_version: native ? TODO_DOMAIN_ITEM_SCHEMA : TODO_ITEM_SCHEMA, + todo_id: item.todo_id, + role: item.role, + status: item.status, + done: false, + text: "Synthetic update matrix Todo", + archive_state: "active", + ...(native ? {} : {source_section: item.role === "user" ? "User Todo" : "Agent Todo", index: index + 1}), + ...(item.task_class ? {task_class: item.task_class} : {}), + ...(item.claimed_by ? {claimed_by: item.claimed_by} : {}), + ...(item.reason ? {reason: item.reason} : {}), + ...(item.goal_bound ? {goal_bound: true} : {}), + ...(item.global_gate ? {global_gate: true} : {}), + } as JsonObject)); + const projection = { + goal_id: "update-matrix", handoff_mode: "soft_claim", todos, leases: [], + todo_read_model: { + schema_version: native ? TODO_DOMAIN_READ_RECORD_SCHEMA : TODO_CANONICAL_READ_RECORD_SCHEMA, + todo_count: todos.length, records_sha256: canonicalAuthoritySha256(todos), + contract_fields: native ? [...TODO_DOMAIN_RECORD_CONTRACT.fields] : [...TODO_CANONICAL_READ_RECORD_FIELDS], + }, + }; + assert.equal((await store.commitAuthority({operation_id: "update-matrix-seed", + expected_provider_revision: null, next_projection: projection, events: [], receipts: []})).status, "applied"); + const run = async (name: string, item: Record) => { + const expected = item.expected as Record; + const todoId = String(item.todo_id); + const expectedRole = item.role == null ? null : String(item.role); + const actor = item.actor_agent_id == null ? null : String(item.actor_agent_id); + const registered = Array.isArray(item.registered_agents) + ? item.registered_agents.map(value => String(value)) : []; + const result = await executeCoordinationTodoUpdate(store, { + goal_id: "update-matrix", todo_id: todoId, expected_role: expectedRole, + actor_agent_id: actor, registered_agents: registered, operation_id: `update-${name}`, + patch: {}, clear_fields: [], planning_intent: item.intent as JsonObject, dry_run: false, + now: new Date("2026-09-10T00:00:00Z"), + }); + assert.equal(result.status, expected.status, JSON.stringify(result)); + if (expected.reason_value !== undefined) { + const current = await head(store); + const row = (current.head.todos as JsonObject[]).find(todo => todo.todo_id === todoId)!; + assert.equal(row.reason, expected.reason_value); + } + if (expected.blocks_agent !== undefined || expected.global_gate === null) { + const current = await head(store); + const row = (current.head.todos as JsonObject[]).find(todo => todo.todo_id === todoId)!; + assert.equal(row.blocks_agent, expected.blocks_agent); + assert.equal(Object.hasOwn(row, "global_gate"), false); + // Agent-scoped gate repairs remove the old goal-wide marker rather + // than persisting a second false-valued gate state. + assert.notEqual(row.goal_bound, true); + } + }; + for (const [name, item] of Object.entries(fixture.update_cases)) await run(name, item); + const before = await head(store); + const rejected = await executeCoordinationTodoUpdate(store, { + goal_id: "update-matrix", todo_id: String(cases[0]!.todo_id), expected_role: "agent", + actor_agent_id: "agent-a", registered_agents: ["agent-a"], operation_id: "update-owned-null", + patch: {}, clear_fields: [], planning_intent: {}, dry_run: false, + now: new Date("2026-09-10T00:01:00Z"), + }); + assert.equal(rejected.status, "failed"); + assert.deepEqual(await head(store), before); + }); } diff --git a/tests/control_plane_ts/production_scale_coordination_fixture.ts b/tests/control_plane_ts/production_scale_coordination_fixture.ts index c320a7ebdc..198db74814 100644 --- a/tests/control_plane_ts/production_scale_coordination_fixture.ts +++ b/tests/control_plane_ts/production_scale_coordination_fixture.ts @@ -29,6 +29,7 @@ const envelope = JSON.parse(readFileSync(new URL( supersede_target_index: number; semantic_cases: Record>; presentation_cases: Record>; + update_cases: Record>; }; export const PRODUCTION_SCALE_FIXTURE_SCHEMA = @@ -56,6 +57,7 @@ export interface ProductionScaleCoordinationFixture { readonly expected_standing_user_decision_count: number; readonly semantic_cases: Readonly>>; readonly presentation_cases: Readonly>>; + readonly update_cases: Readonly>>; } function statusSeries( @@ -239,6 +241,7 @@ export function productionScaleCoordinationFixture( expected_standing_user_decision_count: expectedStanding, semantic_cases: envelope.semantic_cases, presentation_cases: envelope.presentation_cases, + update_cases: envelope.update_cases, }; } diff --git a/tests/fixtures/control_plane/coordination_production_scale_v0.json b/tests/fixtures/control_plane/coordination_production_scale_v0.json index 67b6bc9656..a752bfb016 100644 --- a/tests/fixtures/control_plane/coordination_production_scale_v0.json +++ b/tests/fixtures/control_plane/coordination_production_scale_v0.json @@ -84,5 +84,41 @@ "archive_state": "archive", "updated_at": "2025-01-02T00:00:00Z" } + }, + "update_cases": { + "single_agent_unowned_copy": { + "todo_id": "todo_fixture_update_single_agent", + "role": "agent", + "status": "open", + "task_class": "advancement_task", + "claimed_by": null, + "actor_agent_id": null, + "registered_agents": ["agent-a"], + "intent": {"reason": "actorless single-agent correction"}, + "expected": {"status": "applied", "reason": null, "reason_value": "actorless single-agent correction"} + }, + "user_gate_scope_repair": { + "todo_id": "todo_fixture_update_user_gate", + "role": "user", + "status": "open", + "task_class": "user_gate", + "goal_bound": true, + "global_gate": true, + "actor_agent_id": "agent-a", + "registered_agents": ["agent-a", "agent-b"], + "intent": {"global_gate": false, "blocks_agent": "agent-a"}, + "expected": {"status": "applied", "reason": null, "blocks_agent": "agent-a", "global_gate": null} + }, + "explicit_scalar_clear": { + "todo_id": "todo_fixture_update_clear", + "role": "agent", + "status": "open", + "task_class": "advancement_task", + "reason": "stale reason", + "actor_agent_id": "agent-a", + "registered_agents": ["agent-a", "agent-b"], + "intent": {"reason": ""}, + "expected": {"status": "applied", "reason": null} + } } } From 92ca4f37b2215c0360ed83380d69801ff8246b37 Mon Sep 17 00:00:00 2001 From: huangruiteng <14976749+huangruiteng@users.noreply.github.com> Date: Sun, 13 Sep 2026 00:50:48 +0800 Subject: [PATCH 3/5] test(control-plane): align todo mutation coverage Signed-off-by: huangruiteng <14976749+huangruiteng@users.noreply.github.com> --- examples/shared-goal-authority-e2e/mutants.py | 8 ++++---- loopx/control_plane/coordination/todo_update.ts | 7 ++++++- 2 files changed, 10 insertions(+), 5 deletions(-) diff --git a/examples/shared-goal-authority-e2e/mutants.py b/examples/shared-goal-authority-e2e/mutants.py index 187dd0579f..d7df02bb10 100644 --- a/examples/shared-goal-authority-e2e/mutants.py +++ b/examples/shared-goal-authority-e2e/mutants.py @@ -48,8 +48,8 @@ def command(self) -> list[str]: CASES = [ Case('todo_global_gate_inferred', (('loopx/control_plane/todos/authoring_scope.ts', replacement( - 'intent.global_gate ? true : todo.global_gate', - '(intent.global_gate || intent.goal_bound) ? true : todo.global_gate')),), + ' : todo.global_gate as boolean | null ?? null;', + ' : (todo.global_gate || intent.goal_bound) as boolean | null ?? null;')),), 'tests/control_plane_ts/todo_authoring_scope.test.ts', 'global blocking is never inferred'), Case('todo_explicit_scope_overwritten', (('loopx/control_plane/todos/authoring_scope.ts', replacement( 'if (requestedBound) fail(', 'if (false) fail(')),), @@ -96,8 +96,8 @@ def command(self) -> list[str]: ' if (todo.claimed_by === null || todo.claimed_by !== actor) return "claim_owner_mismatch";')),), 'tests/control_plane/test_shadow_observable_native_e2e.py::test_native_unclaimed_edit_and_explicit_note_clear[disabled]'), Case('native_diagnostic_truncated', ((COORDINATION + 'todo_update.ts', replacement( - 'return failure("update_owner_mismatch", "Todo update cannot edit another claim owner\'s work");', - 'return failure("update_owner_mismatch", "Update rejected");')),), + '? "Todo update cannot edit another claim owner\'s work"', + '? "Update rejected"')),), 'tests/control_plane/test_shadow_observable_native_e2e.py::test_canonical_argument_intent_and_atomic_claim[disabled]'), Case('cursor_baseline_digest', ((COORDINATION + 'local_authority_shadow_adapter.py', replacement( ' return None if marker is None else marker["partition_digest"]', diff --git a/loopx/control_plane/coordination/todo_update.ts b/loopx/control_plane/coordination/todo_update.ts index 7d89e5a074..535972e7d2 100644 --- a/loopx/control_plane/coordination/todo_update.ts +++ b/loopx/control_plane/coordination/todo_update.ts @@ -199,7 +199,12 @@ function targetRejection( const decisionCode = String(authorityDecision.code ?? "mutation_rejected"); const code = decisionCode === "claim_owner_mismatch" ? "update_owner_mismatch" : decisionCode; - return failure(code, "Todo update is outside the actor's registered owner/binding scope"); + // Keep the public owner-mismatch diagnostic stable while other shared + // admission failures use a provider-neutral explanation. + const reason = code === "update_owner_mismatch" + ? "Todo update cannot edit another claim owner's work" + : "Todo update is outside the actor's registered owner/binding scope"; + return failure(code, reason); } // Preserve the single-agent compatibility path only for genuinely // unowned work. An empty registry is not evidence that an arbitrary actor From b604001ef007b588af3a8e603247c1562deaaab8 Mon Sep 17 00:00:00 2001 From: huangruiteng <14976749+huangruiteng@users.noreply.github.com> Date: Sun, 13 Sep 2026 17:44:51 +0800 Subject: [PATCH 4/5] fix(todo): preserve single-agent ownership fences Signed-off-by: huangruiteng <14976749+huangruiteng@users.noreply.github.com> --- .../coordination/todo_lifecycle_decision.ts | 10 ++++-- tests/control_plane_ts/todo_update.test.ts | 34 +++++++++++++++++++ 2 files changed, 42 insertions(+), 2 deletions(-) diff --git a/loopx/control_plane/coordination/todo_lifecycle_decision.ts b/loopx/control_plane/coordination/todo_lifecycle_decision.ts index 324edc7f92..22984e0ff2 100644 --- a/loopx/control_plane/coordination/todo_lifecycle_decision.ts +++ b/loopx/control_plane/coordination/todo_lifecycle_decision.ts @@ -365,8 +365,14 @@ function authority(request: LifecycleDecisionRequest): | CoordinationTodoTerminalDecisionResult { const { todo, actor_agent_id: actor, registered_agents: registered } = request; if (registered.length <= 1) { - if (actor !== null && registered.length > 0 && !registered.includes(actor)) { - return result("rejected", "actor_not_registered"); + if (actor !== null) { + const rejection = registeredTodoMutationRejection(todo, actor, registered); + if (rejection !== null) return result("rejected", rejection); + } else if (todo.claimed_by !== null || todo.bound_agent !== null || + todo.blocks_agent !== null || todo.excluded_agents.length > 0) { + // Actorless compatibility is limited to genuinely unowned Todo records; + // ownership, binding, and exclusion facts require an accountable actor. + return result("rejected", "actor_required"); } return { mode: "single_agent_compatibility", ownershipGate: "not_required" }; } diff --git a/tests/control_plane_ts/todo_update.test.ts b/tests/control_plane_ts/todo_update.test.ts index 42a48a2276..c37457a6bc 100644 --- a/tests/control_plane_ts/todo_update.test.ts +++ b/tests/control_plane_ts/todo_update.test.ts @@ -212,6 +212,40 @@ test("unclaimed edits preserve actor exclusion and binding fences", async () => } }); +test("single-agent compatibility preserves ownership, exclusion, and binding fences", async () => { + for (const [label, overrides, reason] of [ + ["claimed", {claimed_by: "agent-b"}, "update_owner_mismatch"], + ["excluded", {claimed_by: null, excluded_agents: ["agent-a"]}, "actor_excluded"], + ["bound", {claimed_by: null, bound_agent: "agent-b"}, "bound_agent_mismatch"], + ] as const) { + const {store, request} = await seeded(overrides); + const before = await store.loadAuthority(); + const result = await executeCoordinationTodoUpdate(store, { + ...request, + operation_id: `single-agent-${label}`, + registered_agents: ["agent-a"], + actor_agent_id: "agent-a", + }); + assert.equal(result.reason_code, reason, JSON.stringify(result)); + assert.deepEqual(await store.loadAuthority(), before); + assert.equal((await store.readReceipt(`single-agent-${label}`)).status, "missing"); + } +}); + +test("single-agent actorless compatibility rejects excluded work", async () => { + const {store, request} = await seeded({claimed_by: null, excluded_agents: ["agent-a"]}); + const before = await store.loadAuthority(); + const result = await executeCoordinationTodoUpdate(store, { + ...request, + operation_id: "single-agent-actorless-excluded", + registered_agents: ["agent-a"], + actor_agent_id: null, + }); + assert.equal(result.reason_code, "actor_required", JSON.stringify(result)); + assert.deepEqual(await store.loadAuthority(), before); + assert.equal((await store.readReceipt("single-agent-actorless-excluded")).status, "missing"); +}); + test("provider-first update rejects authority and lifecycle escalation", async () => { const {store, request} = await seeded(); assert.equal((await executeCoordinationTodoUpdate(store, {...request, From bebab16de606835517a1c0264e2501e9ee105caf Mon Sep 17 00:00:00 2001 From: huangruiteng <14976749+huangruiteng@users.noreply.github.com> Date: Sun, 13 Sep 2026 19:02:55 +0800 Subject: [PATCH 5/5] fix(todo): preserve safe legacy actor attribution Signed-off-by: huangruiteng <14976749+huangruiteng@users.noreply.github.com> --- .../control_plane/todos/mutation_authority.py | 24 ++++++++++++++++--- 1 file changed, 21 insertions(+), 3 deletions(-) diff --git a/loopx/control_plane/todos/mutation_authority.py b/loopx/control_plane/todos/mutation_authority.py index 9456c73603..2bbadba504 100644 --- a/loopx/control_plane/todos/mutation_authority.py +++ b/loopx/control_plane/todos/mutation_authority.py @@ -298,13 +298,31 @@ def authorize_todo_lifecycle_mutation( infer_status_from_done=True, ) requested_owner = normalize_todo_claimed_by(requested_claimed_by) + # Legacy single-agent callers historically omitted ``agent_id`` while the + # Todo itself carried the sole registered owner/binding. Attribute those + # calls to that unambiguous owner before entering the shared decision + # function; genuinely unowned actorless records remain compatibility-safe. + if normalized_actor is None and len(registered_agents) == 1: + sole_agent = registered_agents[0] + bound_agent = core_todo.bound_agent or ( + core_todo.blocks_agent if core_todo.role == "user" else None + ) + if requested_owner == sole_agent or core_todo.claimed_by == sole_agent or bound_agent == sole_agent: + normalized_actor = sole_agent + # A pre-coordination goal has no declared registration list, but explicit + # legacy callers still supplied an actor. Preserve that behavior without + # weakening the shared ownership/exclusion checks: the supplied actor is + # the only admissible peer for this decision. + decision_registered_agents = registered_agents or ( + [normalized_actor] if normalized_actor is not None else [] + ) effective_action = str(authority_action or command).strip().lower() if command == "claim": return _authorize_typescript_claim( goal_id=goal_id, todo=todo, core_todo=core_todo, - registered_agents=registered_agents, + registered_agents=decision_registered_agents, actor=normalized_actor, requested_owner=requested_owner, ) @@ -322,7 +340,7 @@ def authorize_todo_lifecycle_mutation( decision_outcome=normalized_decision_outcome, ) core_snapshot = CoordinationSnapshot( - registered_agents=tuple(registered_agents), + registered_agents=tuple(decision_registered_agents), todo=core_todo, decision_target=core_target, ) @@ -334,7 +352,7 @@ def authorize_todo_lifecycle_mutation( coordination.get("todo_lifecycle_authority") if isinstance(coordination, Mapping) else None, - registered_agents=registered_agents, + registered_agents=decision_registered_agents, ) plan = decide( CoordinationSnapshot(