Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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-合入后的执行卡)
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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` 只输送完整、紧凑的
Expand Down
6 changes: 3 additions & 3 deletions examples/shared-goal-authority-e2e/mutants.py
Original file line number Diff line number Diff line change
Expand Up @@ -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");',
Expand Down
25 changes: 17 additions & 8 deletions loopx/control_plane/coordination/todo_lifecycle_decision.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand All @@ -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)) {
Expand Down
32 changes: 20 additions & 12 deletions loopx/control_plane/coordination/todo_update.ts
Original file line number Diff line number Diff line change
@@ -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,
Expand All @@ -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";
Expand Down Expand Up @@ -187,22 +189,28 @@ 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) {
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);
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 {
Expand Down Expand Up @@ -259,7 +267,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");
Expand Down
34 changes: 32 additions & 2 deletions loopx/control_plane/todos/authoring_scope.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<string> = new Set(["user_action", "user_gate"]);
export const AGENT_TODO_TASK_CLASSES: ReadonlySet<string> = 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;
Expand Down Expand Up @@ -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");
Expand Down Expand Up @@ -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"]) {
Expand All @@ -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") {
Expand Down
7 changes: 5 additions & 2 deletions loopx/control_plane/todos/native_update_plan.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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. */
Expand All @@ -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`);
Expand All @@ -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,
Expand Down
7 changes: 5 additions & 2 deletions loopx/control_plane/todos/public_update.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand All @@ -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 {
Expand Down Expand Up @@ -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,
Expand Down
Loading