From fde6ed29f3ec5febf8e92f8a14f0594c7a79fc55 Mon Sep 17 00:00:00 2001 From: huangruiteng Date: Sat, 12 Sep 2026 13:14:58 +0800 Subject: [PATCH 1/3] feat(todos): close canonical ownership update boundary Signed-off-by: huangruiteng --- .../coordination/todo_lifecycle_decision.ts | 25 ++++--- .../control_plane/coordination/todo_update.ts | 27 ++++---- loopx/control_plane/todos/authoring_scope.ts | 34 +++++++++- .../control_plane/todos/native_update_plan.ts | 7 +- loopx/control_plane/todos/public_update.ts | 7 +- loopx/todos.py | 10 +-- .../test_native_todo_ownership_update.py | 66 +++++++++++++++++++ .../test_native_todo_planning_update.py | 6 +- tests/control_plane_ts/todo_update.test.ts | 45 ++++++++++++- 9 files changed, 192 insertions(+), 35 deletions(-) create mode 100644 tests/control_plane/test_native_todo_ownership_update.py diff --git a/loopx/control_plane/coordination/todo_lifecycle_decision.ts b/loopx/control_plane/coordination/todo_lifecycle_decision.ts index 7c4f83f34e..324edc7f92 100644 --- a/loopx/control_plane/coordination/todo_lifecycle_decision.ts +++ b/loopx/control_plane/coordination/todo_lifecycle_decision.ts @@ -346,6 +346,20 @@ function result( }; } +/** Registered-actor restrictions, independent of single-agent compatibility or + * delegated authority. Native edits and multi-agent lifecycle admission share it. */ +export function registeredTodoMutationRejection(raw: JsonObject, actor: string | null, + registered: readonly string[]): string | null { + const todo = todoFact(raw, "todo"); + if (actor === null) return "actor_required"; + if (!registered.includes(actor)) return "actor_not_registered"; + if (todo.excluded_agents.includes(actor)) return "actor_excluded"; + const boundAgent = todo.bound_agent ?? (todo.role === "user" ? todo.blocks_agent : null); + if (boundAgent !== null && boundAgent !== actor) return "bound_agent_mismatch"; + if (todo.claimed_by !== null && todo.claimed_by !== actor) return "claim_owner_mismatch"; + return null; +} + function authority(request: LifecycleDecisionRequest): | { mode: string; ownershipGate: CoordinationTodoTerminalDecisionResult["ownership_gate"] } | CoordinationTodoTerminalDecisionResult { @@ -359,14 +373,9 @@ function authority(request: LifecycleDecisionRequest): if (exactUserGateOverride(request)) { return { mode: "exact_user_gate_decision_scope_override", ownershipGate: "not_required" }; } - if (actor === null) return result("rejected", "actor_required"); - if (!registered.includes(actor)) return result("rejected", "actor_not_registered"); - if (todo.excluded_agents.includes(actor)) return result("rejected", "actor_excluded"); - const boundAgent = todo.bound_agent ?? (todo.role === "user" ? todo.blocks_agent : null); - if (boundAgent !== null && boundAgent !== actor) { - return result("rejected", "bound_agent_mismatch"); - } - if (todo.claimed_by !== null && todo.claimed_by !== actor) { + const rejection = registeredTodoMutationRejection(todo, actor, registered); + if (rejection !== null && rejection !== "claim_owner_mismatch") return result("rejected", rejection); + if (rejection === "claim_owner_mismatch") { const grant = request.lifecycle_grants.find((candidate) => candidate.agent_id === actor); if (grant === undefined) return result("rejected", "claim_owner_mismatch"); if (!grant.actions.includes(request.authority_action)) { diff --git a/loopx/control_plane/coordination/todo_update.ts b/loopx/control_plane/coordination/todo_update.ts index b24f7168fa..8d1f543cb6 100644 --- a/loopx/control_plane/coordination/todo_update.ts +++ b/loopx/control_plane/coordination/todo_update.ts @@ -1,5 +1,6 @@ import type { JsonObject } from "../effect_program.ts"; import { TODO_WORK_REQUIREMENT_FIELDS } from "../todos/work_requirements.ts"; +import { TODO_OWNERSHIP_INTENT_FIELDS } from "../todos/authoring_scope.ts"; import type { AuthorityStore, AuthorityStoreCommit, AuthorityStoreReceiptResult } from "./authority_store.ts"; import { AuthorityStoreProtocolError, @@ -20,7 +21,8 @@ import { } from "./coordination_projection.ts"; import { normalizeRegisteredTodoAgents, normalizeTodoAgent } from "./todo_agents.ts"; -import { evaluateCoordinationTerminalFence, COORDINATION_TERMINAL_FENCE_REQUEST_SCHEMA } +import { evaluateCoordinationTerminalFence, COORDINATION_TERMINAL_FENCE_REQUEST_SCHEMA, + registeredTodoMutationRejection } from "./todo_lifecycle_decision.ts"; import { leaseEpoch } from "../work_items/task_lease_acquire.ts"; import { parseIsoTimestamp } from "../runtime_timestamp.ts"; @@ -187,22 +189,23 @@ function targetRejection( return failure("unsupported_todo_update_target", "native metadata update currently requires a non-completed agent Todo"); } - if (Array.isArray(todo.excluded_agents) && todo.excluded_agents.includes(input.actor_agent_id)) { - return failure("actor_excluded", "Todo update actor is excluded from this Todo"); - } - if (todo.bound_agent && todo.bound_agent !== input.actor_agent_id) { - return failure("bound_agent_mismatch", "Todo update requires the bound agent"); - } - // Text/note correction is not a claim or an execution transition. Registered - // peers may edit unclaimed work, but must not edit another owner's work. - 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"); + const actorRejection = registeredTodoMutationRejection(todo, input.actor_agent_id, input.registered_agents); + if (actorRejection !== null) { + return failure(actorRejection === "claim_owner_mismatch" ? "update_owner_mismatch" : actorRejection, + "Todo update requires a registered, non-excluded actor within the existing owner/binding scope"); } 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"); } + // 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 => + Object.hasOwn(input.planning_intent ?? {}, field))) { + return failure("update_lease_ownership_transition_unsupported", + "Ownership/exclusion edits require a lease lifecycle transaction; metadata update cannot rewrite an execution grant"); + } if (lease !== undefined || mode === "hard_lease" || input.lease_idempotency_key != null || input.lease_expected_version != null) { try { @@ -259,7 +262,7 @@ function prepareUpdatedTodo( const rawCopyChanged = Object.entries(input.patch).some(([field, value]) => !Object.hasOwn(todo, field) || !canonicalAuthorityBytes(todo[field]).equals(canonicalAuthorityBytes(value))) || input.clear_fields.some(field => Object.hasOwn(todo, field)); - if (rawCopyChanged) { + if (rawCopyChanged || TODO_OWNERSHIP_INTENT_FIELDS.some(field => Object.hasOwn(input.planning_intent ?? {}, field))) { next.last_actor_agent_id = input.actor_agent_id; } next.updated_at = input.now.toISOString().replace(/\.\d{3}Z$/u, "Z"); diff --git a/loopx/control_plane/todos/authoring_scope.ts b/loopx/control_plane/todos/authoring_scope.ts index 2e17a2c3d5..65726dca42 100644 --- a/loopx/control_plane/todos/authoring_scope.ts +++ b/loopx/control_plane/todos/authoring_scope.ts @@ -14,10 +14,29 @@ export const TODO_AUTHORING_SCOPE_REQUEST_SCHEMA = "todo_authoring_scope_request export const TODO_AUTHORING_SCOPE_RESULT_SCHEMA = "todo_authoring_scope_result_v0"; export const USER_TODO_TASK_CLASSES: ReadonlySet = new Set(["user_action", "user_gate"]); export const AGENT_TODO_TASK_CLASSES: ReadonlySet = new Set(["advancement_task", "continuous_monitor", "blocker"]); +export const TODO_OWNERSHIP_INTENT_FIELDS = ["claimed_by", "clear_claim", "excluded_agents"] as const; + +/** Normalize explicit execution-owner intent before binding its replay identity. + * This does not authorize the actor or manufacture a new execution lease. */ +export function normalizeTodoOwnershipIntent(raw: JsonObject): JsonObject { + const intent: JsonObject = {}; + if (raw.claimed_by != null) { + if (typeof raw.claimed_by !== "string") fail("claimed_by must be a string"); + if (stripPythonWhitespace(raw.claimed_by)) intent.claimed_by = normalizeTodoAgent(raw.claimed_by, "claimed_by"); + } + if (raw.clear_claim != null && typeof raw.clear_claim !== "boolean") fail("clear_claim must be boolean"); + if (raw.clear_claim === true) intent.clear_claim = true; + if (intent.claimed_by && intent.clear_claim) fail("todo update accepts either claimed_by or clear_claim, not both"); + if (raw.excluded_agents != null) { + if (!Array.isArray(raw.excluded_agents)) fail("excluded_agents must be an array"); + intent.excluded_agents = [...new Set(raw.excluded_agents.map(value => normalizeTodoAgent(value, "excluded_agents")))]; + } + return intent; +} function fail(message: string): never { throw new EffectRuntimeRequestError(message); } const INTENT_FIELDS = new Set(["task_class", "status", "actor_agent_id", "claimed_by", "bound_agent", "goal_bound", "blocks_agent", "global_gate", "clear_global_gate", "clear_blocks_agent", "excluded_agents", - "task_repository", "task_domain", "capability_binding_ref", "resume_when", "clear_resume_when"]); + "task_repository", "task_domain", "capability_binding_ref", "resume_when", "clear_resume_when", "clear_claim"]); function string(value: unknown, field: string): string | null { if (value === null || value === undefined) return null; @@ -89,6 +108,10 @@ function planScope(command: string, role: string, taskClass: string | null, todo }; const requestedBound = registered("bound_agent"); const requestedBlocks = registered("blocks_agent"); + registered("claimed_by"); + for (const excluded of (intent.excluded_agents ?? []) as string[]) { + if (!agents.includes(excluded)) fail(`excluded_agents='${excluded}' is not registered for goal '${goalId}'`); + } const actor = registered("actor_agent_id"); if (requestedBound && intent.goal_bound) fail("todo update accepts either bound_agent or goal_bound, not both"); if (requestedBlocks && intent.clear_blocks_agent) fail("todo update accepts either blocks_agent or clear_blocks_agent, not both"); @@ -132,7 +155,8 @@ export function planTodoAuthoringScope(value: unknown): JsonObject { if (!["class", "create", "update"].includes(command ?? "")) fail("unsupported Todo authoring scope command"); const role = string(request.role, "role"); if (role !== "agent" && role !== "user") fail("todo role must be one of: user, agent"); - const intent = requireJsonObject(request.intent, "Todo authoring intent"); + const rawIntent = requireJsonObject(request.intent, "Todo authoring intent"); + const intent = {...rawIntent, ...normalizeTodoOwnershipIntent(rawIntent)}; for (const key of Object.keys(intent)) if (!INTENT_FIELDS.has(key)) fail(`Todo authoring scope does not own ${key}`); const todo = requireJsonObject(request.todo, "Todo authoring source"); for (const object of [intent, todo]) for (const field of ["goal_bound", "global_gate", "clear_global_gate", "clear_blocks_agent", "clear_resume_when"]) { @@ -153,6 +177,12 @@ export function planTodoAuthoringScope(value: unknown): JsonObject { "(CLI: `loopx todo complete`) so completion policy, successor, and no-follow-up contracts are enforced"); const scope = planScope(command ?? "", role, taskClass, todo, intent, agents, string(request.goal_id, "goal_id") ?? ""); const exclusions = intent.excluded_agents ?? todo.excluded_agents; + if (TODO_OWNERSHIP_INTENT_FIELDS.some(field => intent[field] != null && intent[field] !== false)) { + const claim = intent.clear_claim ? null : intent.claimed_by || todo.claimed_by; + if (claim && Array.isArray(exclusions) && exclusions.includes(claim)) { + fail("claimed_by cannot also appear in excluded_agents; clear or transfer the claim in the same update"); + } + } if (role !== "agent" && Array.isArray(exclusions) && exclusions.length) fail("excluded_agents is only valid for agent todos; clear exclusions before moving this todo to a user role"); // Completed history remains repairable; it does not create an active gate. if (status !== "done") { diff --git a/loopx/control_plane/todos/native_update_plan.ts b/loopx/control_plane/todos/native_update_plan.ts index 2ec1cd3244..3cbd5fee52 100644 --- a/loopx/control_plane/todos/native_update_plan.ts +++ b/loopx/control_plane/todos/native_update_plan.ts @@ -7,10 +7,12 @@ import { compactPythonWhitespace } from "../coordination/todo_agents.ts"; import { normalizeTodoId } from "../work_items/task_lease_acquire.ts"; import { planPublicTodoUpdate, TODO_PUBLIC_UPDATE_REQUEST_SCHEMA } from "./public_update.ts"; 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]); +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. */ @@ -21,6 +23,7 @@ export function normalizeNativePlanningIntent(value: unknown): JsonObject { for (const [field, value] of Object.entries(raw)) { 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 (STRINGS.has(field)) { if (typeof value !== "string") throw new AuthorityStoreProtocolError(`${field} must be a string`); @@ -34,7 +37,7 @@ export function normalizeNativePlanningIntent(value: unknown): JsonObject { intent[field] = [...new Set(value.map(item => normalizeTodoId(item, "successor_todo_id")))]; } } - return {...intent, ...normalizeTodoWorkRequirements(raw)}; + return {...intent, ...normalizeTodoWorkRequirements(raw), ...normalizeTodoOwnershipIntent(raw)}; } export function planNativeTodoUpdate(todo: JsonObject, intent: JsonObject, diff --git a/loopx/control_plane/todos/public_update.ts b/loopx/control_plane/todos/public_update.ts index ac26ba887a..5e948d6c0f 100644 --- a/loopx/control_plane/todos/public_update.ts +++ b/loopx/control_plane/todos/public_update.ts @@ -3,7 +3,8 @@ import type { JsonObject } from "../effect_program.ts"; import { requireJsonObject } from "../runtime_decode.ts"; import { EffectRuntimeRequestError } from "../effect_runtime_errors.ts"; -import { planTodoAuthoringScope, TODO_AUTHORING_SCOPE_REQUEST_SCHEMA } from "./authoring_scope.ts"; +import { planTodoAuthoringScope, TODO_AUTHORING_SCOPE_REQUEST_SCHEMA, + normalizeTodoOwnershipIntent, TODO_OWNERSHIP_INTENT_FIELDS } from "./authoring_scope.ts"; import { planTodoFieldUpdate, TODO_FIELD_UPDATE_REQUEST_SCHEMA } from "./field_update.ts"; import { planTodoExternalWaitTransition, TODO_EXTERNAL_WAIT_REQUEST_SCHEMA_VERSION } from "./resume_condition.ts"; import { normalizeTodoWorkRequirements, TODO_WORK_REQUIREMENT_FIELDS } from "./work_requirements.ts"; @@ -12,7 +13,7 @@ export const TODO_PUBLIC_UPDATE_REQUEST_SCHEMA = "todo_public_update_request_v0" const SCOPE_INTENT_FIELDS = ["task_class", "status", "claimed_by", "bound_agent", "goal_bound", "blocks_agent", "clear_blocks_agent", "global_gate", "clear_global_gate", "excluded_agents", - "task_repository", "task_domain", "resume_when", "clear_resume_when"] as const; + "task_repository", "task_domain", "resume_when", "clear_resume_when", "clear_claim"] as const; function externalWait(todo: JsonObject, intent: JsonObject, scope: JsonObject, context: JsonObject): JsonObject | null { @@ -46,7 +47,9 @@ export function planPublicTodoUpdate(value: unknown): JsonObject { const rawIntent = requireJsonObject(request.intent, "public Todo update intent"); const intent: JsonObject = {...rawIntent}; for (const field of TODO_WORK_REQUIREMENT_FIELDS) delete intent[field]; + for (const field of TODO_OWNERSHIP_INTENT_FIELDS) delete intent[field]; Object.assign(intent, normalizeTodoWorkRequirements(rawIntent)); + Object.assign(intent, normalizeTodoOwnershipIntent(rawIntent)); const context = requireJsonObject(request.context, "public Todo update context"); const scope = planTodoAuthoringScope({schema_version: TODO_AUTHORING_SCOPE_REQUEST_SCHEMA, command: "update", role: context.role, todo, diff --git a/loopx/todos.py b/loopx/todos.py index 27ed09f3fa..06428c4128 100644 --- a/loopx/todos.py +++ b/loopx/todos.py @@ -1123,15 +1123,17 @@ def update_goal_todo( "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, clear_excluded_agents, global_gate, - clear_global_gate, clear_claim, authority_reason, + 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, claimed_by, bound_agent, - blocks_agent, excluded_agents, authority_reason, + decision_scope, required_decision_scopes, bound_agent, + blocks_agent, authority_reason, )): canonical_edit = update_canonical_todo_if_promoted( registry_path=registry_path, runtime_root=shadow_runtime_root, diff --git a/tests/control_plane/test_native_todo_ownership_update.py b/tests/control_plane/test_native_todo_ownership_update.py new file mode 100644 index 0000000000..ccf5c984cb --- /dev/null +++ b/tests/control_plane/test_native_todo_ownership_update.py @@ -0,0 +1,66 @@ +"""Ownership edits use the ordinary public command, not a test-only authority API.""" +import pytest + +from canonical_authority_fixture import isolate_sqlite_runtime +from test_native_todo_planning_update import fixture, records, update +from loopx.todos import update_goal_todo + + +@pytest.mark.parametrize("provider", ["legacy", "file", "sqlite"]) +def test_public_transfer_clear_exclusions_and_replay(tmp_path, monkeypatch, provider): + isolate_sqlite_runtime(tmp_path, monkeypatch) + promoted = provider != "legacy" + registry, state = fixture(tmp_path, promoted, provider) + before = records(registry) + transfer = ["--claimed-by", "Agent B"] + if promoted: + transfer += ["--update-operation-id", "transfer-first"] + update(registry, *transfer, "--dry-run") + assert records(registry) == before + update(registry, *transfer) + assert records(registry)["todo_target"]["claimed_by"] == "agent-b" + update(registry, "--text", "Former owner cannot write", ok=False) + update(registry, "--agent-id", "agent-b", "--clear-claim") + cleared = records(registry) + assert not cleared["todo_target"].get("claimed_by") + if promoted: + assert update(registry, *transfer)["status"] == "replayed" + assert records(registry) == cleared + update(registry, "--excluded-agent", "agent-b") + excluded = records(registry) + assert excluded["todo_target"]["excluded_agents"] == ["agent-b"] + update(registry, "--agent-id", "agent-b", "--clear-excluded-agents", ok=False) + assert records(registry) == excluded + update(registry, "--clear-excluded-agents") + assert not records(registry)["todo_target"].get("excluded_agents") + assert records(registry)["todo_other"] == before["todo_other"] + assert state.exists(), "canonical commit still delivers its permanent Markdown projection" + + +@pytest.mark.parametrize("promoted", [False, True]) +@pytest.mark.parametrize("intent", [ + {"claimed_by": "unregistered"}, {"excluded_agents": ["agent-b", "unregistered"]}, + {"claimed_by": "agent-b", "clear_claim": True}, + {"claimed_by": "agent-b", "excluded_agents": ["agent-b"]}, + {"excluded_agents": ["agent-a"]}, +]) +def test_invalid_ownership_intent_never_partially_writes_copy(tmp_path, promoted, intent): + registry, state = fixture(tmp_path, promoted) + before = records(registry) + with pytest.raises((ValueError, RuntimeError)): + update_goal_todo(registry_path=registry, goal_id="goal-a", todo_id="todo_target", + agent_id="agent-a", text="Must not partially commit", **intent) + assert records(registry) == before + if promoted: + assert not state.exists() + + +@pytest.mark.parametrize("promoted", [False, True]) +def test_clear_claim_and_exclude_former_holder_in_one_edit(tmp_path, promoted): + registry, _ = fixture(tmp_path, promoted) + result = update_goal_todo(registry_path=registry, goal_id="goal-a", todo_id="todo_target", + agent_id="agent-a", clear_claim=True, excluded_agents=["agent-a"]) + assert result["ok"] + todo = records(registry)["todo_target"] + assert not todo.get("claimed_by") + assert todo["excluded_agents"] == ["agent-a"] diff --git a/tests/control_plane/test_native_todo_planning_update.py b/tests/control_plane/test_native_todo_planning_update.py index 9a44747d1c..057d5bcea1 100644 --- a/tests/control_plane/test_native_todo_planning_update.py +++ b/tests/control_plane/test_native_todo_planning_update.py @@ -14,7 +14,7 @@ from loopx.todos import list_goal_todos, update_goal_todo -def fixture(tmp_path: Path, promoted: bool) -> tuple[Path, Path]: +def fixture(tmp_path: Path, promoted: bool, provider: str = "file") -> tuple[Path, Path]: project = tmp_path / "project" state = project / ".codex/goals/goal-a/ACTIVE_GOAL_STATE.md" state.parent.mkdir(parents=True) @@ -39,7 +39,7 @@ def fixture(tmp_path: Path, promoted: bool) -> tuple[Path, Path]: if promoted: todos = list_goal_todos(registry_path=registry, goal_id="goal-a")["todos"] projection = build_todo_runtime_shadow_projection(goal_id="goal-a", todos=todos, handoff_mode="soft_claim") - initialize_canonical_authority(runtime, "goal-a", projection, state_path=state) + initialize_canonical_authority(runtime, "goal-a", projection, state_path=state, provider=provider) state.unlink() # The update must neither require nor import a Markdown authority source. return registry, state @@ -210,7 +210,7 @@ def test_public_cli_nonterminal_wait_update_and_clear(tmp_path: Path, promoted: @pytest.mark.parametrize("args", [ - ["--status", "done"], ["--status", "deferred"], ["--claimed-by", "agent-b"], + ["--status", "done"], ["--status", "deferred"], ["--claimed-by", "unknown-agent"], ["--task-class", "continuous_monitor"], ["--status", "blocked", "--agent-id", "agent-b"], ]) def test_promoted_unsupported_or_unauthorized_update_never_falls_back(tmp_path: Path, args: list[str]) -> None: diff --git a/tests/control_plane_ts/todo_update.test.ts b/tests/control_plane_ts/todo_update.test.ts index 32c52fd6c5..42a48a2276 100644 --- a/tests/control_plane_ts/todo_update.test.ts +++ b/tests/control_plane_ts/todo_update.test.ts @@ -56,10 +56,10 @@ test("native planning edit commits nonterminal state and clears its wait atomica assert.equal(next.resume_monitor_generation, undefined); }); -test("planning intent cannot smuggle terminal, ownership or observation writes", async () => { +test("planning intent cannot smuggle terminal, decision or observation writes", async () => { const {store, request} = await seeded({task_class: "advancement_task"}); for (const planning_intent of [ - {status: "done"}, {claimed_by: "agent-b"}, {clear_claim: true}, + {status: "done"}, {decision_outcome: "approve"}, {global_gate: true}, {monitor_metadata: {material_change: "true"}}, {completion_metadata_updates_override: {completion_continuation: "no_followup"}}, {status: "deferred"}, {successor_todo_ids: "todo_other"}, @@ -72,6 +72,47 @@ test("planning intent cannot smuggle terminal, ownership or observation writes", } }); +test("owner can transfer and clear a claim; retry cannot restore an old owner", async () => { + const {store, request} = await seeded({task_class: "advancement_task"}); + const transfer = {...request, patch: {}, clear_fields: [], planning_intent: {claimed_by: "Agent B"}}; + const before = await store.loadAuthority(); + assert.equal((await executeCoordinationTodoUpdate(store, {...transfer, dry_run: true})).status, "planned"); + assert.deepEqual(await store.loadAuthority(), before); + assert.equal((await executeCoordinationTodoUpdate(store, transfer)).status, "applied"); + const transferred = await store.loadAuthority(); + assert.equal(transferred.status, "loaded"); + if (transferred.status !== "loaded") return; + const row = (transferred.head.todos as Record[])[0]!; + assert.equal(row.claimed_by, "agent-b"); + assert.equal(row.last_actor_agent_id, "agent-a"); + assert.equal((await executeCoordinationTodoUpdate(store, {...request, operation_id: "old-owner-edit"})).reason_code, + "update_owner_mismatch"); + assert.equal((await executeCoordinationTodoUpdate(store, {...transfer, operation_id: "clear-owner", + actor_agent_id: "agent-b", planning_intent: {clear_claim: true}})).status, "applied"); + const cleared = await store.loadAuthority(); + assert.equal((await executeCoordinationTodoUpdate(store, {...transfer, + planning_intent: {claimed_by: "agent-b"}})).status, "replayed"); + assert.deepEqual(await store.loadAuthority(), cleared); +}); + +test("exclusion edits are atomic, normalized, and cannot exempt their excluded author", async () => { + const {store, request} = await seeded({task_class: "advancement_task"}); + const edit = {...request, patch: {}, clear_fields: [], planning_intent: {excluded_agents: ["Agent B", "agent-b"]}}; + assert.equal((await executeCoordinationTodoUpdate(store, edit)).status, "applied"); + assert.equal((await executeCoordinationTodoUpdate(store, {...edit, operation_id: "excluded-author", + actor_agent_id: "agent-b", planning_intent: {excluded_agents: []}})).reason_code, "actor_excluded"); + const before = await store.loadAuthority(); + for (const planning_intent of [{claimed_by: "unregistered"}, {excluded_agents: ["agent-b", "unregistered"]}, + {claimed_by: "agent-b", clear_claim: true}, {excluded_agents: "agent-b"}]) { + const rejected = await executeCoordinationTodoUpdate(store, {...request, operation_id: "invalid-owner", planning_intent}); + assert.equal(rejected.status, "failed"); + assert.deepEqual(await store.loadAuthority(), before); + assert.equal((await store.readReceipt("invalid-owner")).status, "missing"); + } + assert.equal((await executeCoordinationTodoUpdate(store, {...edit, operation_id: "clear-exclusions", + planning_intent: {excluded_agents: []}})).status, "applied"); +}); + function todo(overrides: Record = {}) { return {schema_version: TODO_DOMAIN_ITEM_SCHEMA, todo_id: "todo_a", role: "agent", status: "open", done: false, text: "Old text", archive_state: "active", From 76cf16af279637c16a85414b40579f97f703a718 Mon Sep 17 00:00:00 2001 From: huangruiteng Date: Sat, 12 Sep 2026 13:21:16 +0800 Subject: [PATCH 2/3] docs(rfc): record ownership update closure Signed-off-by: huangruiteng --- .../rfcs/shared-goal-authority-state-provider-v0.md | 5 +++++ .../rfcs/shared-goal-authority-state-provider-v0.zh-CN.md | 4 ++++ .../rfcs/typescript-control-plane-migration-v0.md | 7 +++++++ .../rfcs/typescript-control-plane-migration-v0.zh-CN.md | 6 ++++++ 4 files changed, 22 insertions(+) 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 562b6cb1bf..4a7dff1f19 100644 --- a/docs/architecture/rfcs/shared-goal-authority-state-provider-v0.md +++ b/docs/architecture/rfcs/shared-goal-authority-state-provider-v0.md @@ -2708,6 +2708,11 @@ claim divergence and exclusion; a read result is not a lease grant or a commit receipt. An empty canonical lease set stays empty. This read closure and removal of duplicate eligibility rules do not qualify a provider, alter CAS/replay or relax D1–D3; permanent Markdown display and the remaining roadmap stay intact. +The ownership-edit slice now uses the same typed authoring and lifecycle boundary +after promotion as the existing update transaction. It preserves claim/exclusion +fences and rejects leased ownership rewrites; legacy Markdown writing remains a +compatibility path before promotion. This removes a duplicate decision route but +does not qualify a provider, change promotion defaults, or relax D1–D3. Capability-gap consumers now share the TS requirement/resolution owner across legacy and canonical inputs, including quota's Monitor capability partition. 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 e1e02e9ba1..17124b99e3 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 @@ -2148,6 +2148,10 @@ promotion 后不再读取本地旧 lease 文件;canonical 空租约集合保 当前 acquire/lifecycle 共用 TS owner,包含 claim 分歧和 exclusion;读取结果不是 租约授权,也不是 commit receipt。该 reader 闭合和重复规则删除不代表 provider 资格化,不改变 CAS/replay 或 D1–D3;永久 Markdown 展示与后续规划继续保留。 +ownership 编辑在 promotion 后现在与现有 update transaction 共用 typed authoring +和 lifecycle 边界。claim/exclusion 门禁保留,带 lease 的 ownership 重写继续拒绝; +promotion 前仍保留 Markdown writer 兼容路径。这删除了一条重复决策路径,但不代表 +provider 已资格化、不改变 promotion 默认值,也不放宽 D1–D3。 命令清单、update/monitor 事务和 consumer 删除统一按 [TS 执行卡](typescript-control-plane-migration-v0.zh-CN.md#当前-stack-合入后的执行卡) diff --git a/docs/architecture/rfcs/typescript-control-plane-migration-v0.md b/docs/architecture/rfcs/typescript-control-plane-migration-v0.md index 350469dc22..b01f4d79af 100644 --- a/docs/architecture/rfcs/typescript-control-plane-migration-v0.md +++ b/docs/architecture/rfcs/typescript-control-plane-migration-v0.md @@ -403,6 +403,13 @@ the shared plan, not another per-agent checklist database. **T1 — close the public Todo update transaction.** +The current ownership slice closes promoted claim transfer, claim clearing and +executor-exclusion edits through this typed update planner. Normalization is +part of request identity, so replay cannot restore a superseded claim. A +lease-bearing ownership change remains a lifecycle operation, not metadata +authority; the legacy writer remains for unpromoted Goals. This is a bounded T1 +closure, not completion of all Todo fields or Goal promotion. + Bounded prerequisite: `todos/public_update.ts` now composes authoring scope, external-wait topology and Monitor/field planning over one locked source. The public Python writer no longer sequences their leaf RPCs or derives the 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 08bf982cec..9d536804c1 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 @@ -312,6 +312,12 @@ commit。#4121(SQLite 候选)和 #4101(投影 receipt 保留)是独立 **T1 — 闭合公开 Todo update 事务。** +当前 ownership slice 已将 promoted 路径的 claim 转交、清除和执行排除编辑接入 +typed update planner。规范化参与请求身份,因此重放不能恢复已被后续操作取代的 +claim。带 lease 的 ownership 变化仍必须走 lifecycle,不是 metadata 授权;未 +promotion 的 Goal 继续使用旧 writer。这是有边界的 T1 闭合,不代表所有 Todo +字段或 Goal promotion 已完成。 + 已闭合的前置项:`todos/public_update.ts` 在同一锁内快照上组合 authoring scope、 external-wait 拓扑和 Monitor/field 规划。公开 Python writer 不再逐个调用这些 leaf RPC,也不推导 Monitor 等待基线。`update_source.py` 只输送完整、紧凑的 From 538c9c82826e85fcc59cf0835623c1ce4daf6530 Mon Sep 17 00:00:00 2001 From: huangruiteng Date: Sat, 12 Sep 2026 18:13:27 +0800 Subject: [PATCH 3/3] fix(todos): preserve public owner diagnostics Signed-off-by: huangruiteng --- examples/shared-goal-authority-e2e/mutants.py | 6 +++--- loopx/control_plane/coordination/todo_update.ts | 7 ++++++- 2 files changed, 9 insertions(+), 4 deletions(-) diff --git a/examples/shared-goal-authority-e2e/mutants.py b/examples/shared-goal-authority-e2e/mutants.py index aef1a9a424..187dd0579f 100644 --- a/examples/shared-goal-authority-e2e/mutants.py +++ b/examples/shared-goal-authority-e2e/mutants.py @@ -91,9 +91,9 @@ def command(self) -> list[str]: ' const next: JsonObject = {...todo, ...input.patch};', ' const next: JsonObject = {...todo, ...input.patch};\n if ("note" in input.patch) next.note = todo.note;')),), 'tests/control_plane/test_shadow_observable_native_e2e.py::test_native_unclaimed_edit_and_explicit_note_clear[disabled]'), - Case('native_unclaimed_edit_rejected', ((COORDINATION + 'todo_update.ts', replacement( - ' if (todo.claimed_by && todo.claimed_by !== input.actor_agent_id) {', - ' if (!todo.claimed_by || todo.claimed_by !== input.actor_agent_id) {')),), + Case('native_unclaimed_edit_rejected', ((COORDINATION + 'todo_lifecycle_decision.ts', replacement( + ' if (todo.claimed_by !== null && todo.claimed_by !== actor) return "claim_owner_mismatch";', + ' 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");', diff --git a/loopx/control_plane/coordination/todo_update.ts b/loopx/control_plane/coordination/todo_update.ts index 8d1f543cb6..11756d7202 100644 --- a/loopx/control_plane/coordination/todo_update.ts +++ b/loopx/control_plane/coordination/todo_update.ts @@ -191,7 +191,12 @@ function targetRejection( } const actorRejection = registeredTodoMutationRejection(todo, input.actor_agent_id, input.registered_agents); if (actorRejection !== null) { - return failure(actorRejection === "claim_owner_mismatch" ? "update_owner_mismatch" : actorRejection, + 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"); } const lease = leases.get(input.todo_id);