From b82f4b1de005101c06a0904d3795f19cf7d6192c Mon Sep 17 00:00:00 2001 From: huangruiteng <14976749+huangruiteng@users.noreply.github.com> Date: Sun, 13 Sep 2026 02:25:54 +0800 Subject: [PATCH 1/7] refactor(todo): centralize decision metadata planning Signed-off-by: huangruiteng <14976749+huangruiteng@users.noreply.github.com> --- .../control_plane/todos/decision_metadata.ts | 123 ++++++++++++++++++ loopx/control_plane/todos/decision_scope.ts | 12 +- .../control_plane/todos/native_update_plan.ts | 20 ++- loopx/control_plane/todos/public_update.ts | 20 ++- loopx/control_plane/todos/update_intent.py | 6 +- 5 files changed, 172 insertions(+), 9 deletions(-) create mode 100644 loopx/control_plane/todos/decision_metadata.ts diff --git a/loopx/control_plane/todos/decision_metadata.ts b/loopx/control_plane/todos/decision_metadata.ts new file mode 100644 index 0000000000..9cecdf73b8 --- /dev/null +++ b/loopx/control_plane/todos/decision_metadata.ts @@ -0,0 +1,123 @@ +/** Typed decision metadata for ordinary Todo planning updates. + * + * Decision outcomes remain terminal/effect-owned. This module only validates + * the two declarative fields that describe who may be waiting on what: a + * user-gate decision_scope and an agent Todo's required_decision_scopes. + */ +import type {JsonObject} from "../effect_program.ts"; +import {EffectRuntimeRequestError} from "../effect_runtime_errors.ts"; +import {requireJsonObject} from "../runtime_decode.ts"; +import {normalizeTodoId} from "../work_items/task_lease_acquire.ts"; +import {compactPythonWhitespace, stripPythonWhitespace} from "../coordination/todo_agents.ts"; + +export const TODO_DECISION_SCOPE_SCHEMA_VERSION = "decision_scope_v0"; +export const TODO_DECISION_SCOPE_KINDS = [ + "private_read", "write_scope", "resource", "production", "public_claim", "direction", "other", +] as const; +export const TODO_DECISION_SCOPE_GRANULARITIES = ["action", "lane", "goal", "project", "global"] as const; +export const TODO_DECISION_SCOPE_KIND_SET: ReadonlySet = new Set(TODO_DECISION_SCOPE_KINDS); +export const TODO_DECISION_SCOPE_GRANULARITY_SET: ReadonlySet = new Set(TODO_DECISION_SCOPE_GRANULARITIES); +export const TODO_DECISION_SCOPE_KEY_PATTERN = /^(?:\*|[a-z0-9][a-z0-9_.:@*/-]{0,95})$/u; + +export const TODO_DECISION_METADATA_FIELDS = ["decision_scope", "required_decision_scopes"] as const; + +export interface TodoDecisionScope extends JsonObject { + readonly schema_version: typeof TODO_DECISION_SCOPE_SCHEMA_VERSION; + readonly kind: string; + readonly granularity: string; + readonly scope_key: string; + readonly decision_id?: string; +} + +function fail(message: string): never { + throw new EffectRuntimeRequestError(message); +} + +function scopeObject(value: unknown, label: string): JsonObject { + if (typeof value === "string") { + const parts = compactPythonWhitespace(value).toLowerCase().split(":"); + if (parts.length !== 3) fail(`${label} must use kind:granularity:scope_key`); + return {kind: parts[0], granularity: parts[1], scope_key: parts[2]}; + } + return requireJsonObject(value, label); +} + +/** Normalize one public decision-scope value without preserving unknown keys. */ +export function normalizeTodoDecisionScope(value: unknown, label = "decision_scope"): TodoDecisionScope | null { + if (value === null || value === undefined) return null; + const raw = scopeObject(value, label); + const unknown = Object.keys(raw).filter(key => + !["schema_version", "kind", "granularity", "scope_key", "decision_id"].includes(key)); + if (unknown.length) fail(`${label} has unsupported fields: ${unknown.sort().join(", ")}`); + if (raw.schema_version !== undefined && raw.schema_version !== TODO_DECISION_SCOPE_SCHEMA_VERSION) { + fail(`${label}.schema_version must be ${TODO_DECISION_SCOPE_SCHEMA_VERSION}`); + } + const kind = typeof raw.kind === "string" ? compactPythonWhitespace(raw.kind).toLowerCase() : ""; + const granularity = typeof raw.granularity === "string" + ? compactPythonWhitespace(raw.granularity).toLowerCase() : ""; + const scopeKey = typeof raw.scope_key === "string" + ? compactPythonWhitespace(raw.scope_key).toLowerCase() : ""; + if (!TODO_DECISION_SCOPE_KIND_SET.has(kind)) fail(`${label}.kind is not a supported decision-scope kind`); + if (!TODO_DECISION_SCOPE_GRANULARITY_SET.has(granularity)) { + fail(`${label}.granularity is not a supported decision-scope granularity`); + } + if (!TODO_DECISION_SCOPE_KEY_PATTERN.test(scopeKey)) { + fail(`${label}.scope_key must be a public-safe decision-scope key`); + } + const result: JsonObject = { + schema_version: TODO_DECISION_SCOPE_SCHEMA_VERSION, kind, granularity, scope_key: scopeKey, + }; + if (raw.decision_id !== undefined) { + if (typeof raw.decision_id !== "string" || stripPythonWhitespace(raw.decision_id) !== raw.decision_id) { + fail(`${label}.decision_id must be a public-safe Todo id`); + } + result.decision_id = normalizeTodoId(raw.decision_id, `${label}.decision_id`); + } + return result as TodoDecisionScope; +} + +function identity(scope: TodoDecisionScope): string { + return `${scope.kind}\u0000${scope.granularity}\u0000${scope.scope_key}`; +} + +/** Normalize the list form while preserving first-seen order and removing exact duplicates. */ +export function normalizeTodoRequiredDecisionScopes( + value: unknown, + label = "required_decision_scopes", +): TodoDecisionScope[] | null { + if (value === null || value === undefined) return null; + const rawValues = typeof value === "string" + ? compactPythonWhitespace(value).split(/[,;|]/u).filter(Boolean) + : value; + if (!Array.isArray(rawValues)) fail(`${label} must be an array or compact scope list`); + const result: TodoDecisionScope[] = []; + const seen = new Set(); + rawValues.forEach((raw, index) => { + const scope = normalizeTodoDecisionScope(raw, `${label}[${index}]`); + if (scope === null) fail(`${label}[${index}] must be a decision scope`); + const key = identity(scope); + if (!seen.has(key)) { + seen.add(key); + result.push(scope); + } + }); + return result; +} + +/** Validate role ownership for ordinary planning edits; no permission is granted here. */ +export function validateTodoDecisionMetadata(todo: JsonObject, intent: JsonObject): JsonObject { + const role = todo.role; + const taskClass = Object.hasOwn(intent, "task_class") + ? intent.task_class : todo.task_class; + if (Object.hasOwn(intent, "decision_scope")) { + if (intent.decision_scope !== null && (role !== "user" || taskClass !== "user_gate")) { + fail("decision_scope is only valid for user_gate todos"); + } + } + if (Object.hasOwn(intent, "required_decision_scopes")) { + if (role !== "agent" && intent.required_decision_scopes !== null) { + fail("required_decision_scopes is only valid for agent todos"); + } + } + return {}; +} diff --git a/loopx/control_plane/todos/decision_scope.ts b/loopx/control_plane/todos/decision_scope.ts index 697766947d..7cc4ac78ee 100644 --- a/loopx/control_plane/todos/decision_scope.ts +++ b/loopx/control_plane/todos/decision_scope.ts @@ -3,10 +3,14 @@ import type {JsonObject} from "../effect_program.ts"; import {requireJsonObject, optionalNonEmptyString, requireBoolean, requireInteger} from "../runtime_decode.ts"; import {gateAddressesAgent} from "./gate_scope.ts"; +import { + TODO_DECISION_SCOPE_GRANULARITY_SET, + TODO_DECISION_SCOPE_KEY_PATTERN, + TODO_DECISION_SCOPE_KIND_SET, +} from "./decision_metadata.ts"; export const DECISION_SCOPE_REQUEST_SCHEMA = "todo_decision_scope_request_v0"; const RANK: Readonly> = {action: 0, lane: 1, goal: 2, project: 3, global: 4}; -const KINDS = new Set(["private_read", "write_scope", "resource", "production", "public_claim", "direction", "other"]); const object = (value: unknown): value is JsonObject => value !== null && typeof value === "object" && !Array.isArray(value); const rows = (value: unknown): JsonObject[] => { if (!Array.isArray(value)) throw new TypeError("decision scope rows must be an array"); @@ -16,8 +20,10 @@ const text = (value: unknown): string | null => typeof value === "string" && val export function decisionScopeCovers(gate: unknown, required: unknown): boolean { if (!object(gate) || !object(required)) return false; - const valid = (scope: JsonObject) => KINDS.has(String(scope.kind)) && Object.hasOwn(RANK, String(scope.granularity)) && - typeof scope.scope_key === "string" && /^(?:\*|[a-z0-9][a-z0-9_.:@*/-]{0,95})$/u.test(scope.scope_key); + const valid = (scope: JsonObject) => TODO_DECISION_SCOPE_KIND_SET.has(String(scope.kind)) && + TODO_DECISION_SCOPE_GRANULARITY_SET.has(String(scope.granularity)) && + Object.hasOwn(RANK, String(scope.granularity)) && typeof scope.scope_key === "string" && + TODO_DECISION_SCOPE_KEY_PATTERN.test(scope.scope_key); return valid(gate) && valid(required) && gate.kind === required.kind && (gate.scope_key === "*" || gate.scope_key === required.scope_key) && RANK[String(gate.granularity)] >= RANK[String(required.granularity)]; } diff --git a/loopx/control_plane/todos/native_update_plan.ts b/loopx/control_plane/todos/native_update_plan.ts index 48d9cdc7e8..0db221d426 100644 --- a/loopx/control_plane/todos/native_update_plan.ts +++ b/loopx/control_plane/todos/native_update_plan.ts @@ -8,16 +8,23 @@ 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"; +import { + normalizeTodoDecisionScope, + normalizeTodoRequiredDecisionScopes, + TODO_DECISION_METADATA_FIELDS, + validateTodoDecisionMetadata, +} from "./decision_metadata.ts"; 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]); + ...TODO_WORK_REQUIREMENT_FIELDS, ...TODO_OWNERSHIP_INTENT_FIELDS, ...TODO_DECISION_METADATA_FIELDS]); /** A separate intent namespace preserves the shipped text/note patch and its - * historical receipt encoding. Raw field patches do not gain new authority. */ + * historical receipt encoding. Raw field patches do not gain new authority; + * declarative decision metadata is admitted only through its typed codec. */ export function normalizeNativePlanningIntent(value: unknown): JsonObject { if (value === undefined || value === null) return {}; const raw = requireJsonObject(value, "Todo planning intent"); @@ -26,6 +33,14 @@ 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 (field === "decision_scope") { + intent[field] = normalizeTodoDecisionScope(value, field); + continue; + } + if (field === "required_decision_scopes") { + intent[field] = normalizeTodoRequiredDecisionScopes(value, field); + continue; + } if (value === null) { // Null is an explicit clear for scalar planning metadata. Ownership // fields use their dedicated clear switches and are normalized above. @@ -52,6 +67,7 @@ export function planNativeTodoUpdate(todo: JsonObject, intent: JsonObject, if (todo.task_class === "continuous_monitor") { throw new AuthorityStoreProtocolError("native Monitor planning updates require the atomic monitor writer; text/note correction remains supported"); } + validateTodoDecisionMetadata(todo, intent); const planned = planPublicTodoUpdate({schema_version: TODO_PUBLIC_UPDATE_REQUEST_SCHEMA, todo, intent, updated_at: updatedAt, context: {goal_id: head.goal_id, role: todo.role, actor_agent_id: actor, diff --git a/loopx/control_plane/todos/public_update.ts b/loopx/control_plane/todos/public_update.ts index 669d656476..5045cfe823 100644 --- a/loopx/control_plane/todos/public_update.ts +++ b/loopx/control_plane/todos/public_update.ts @@ -8,6 +8,11 @@ import { planTodoAuthoringScope, TODO_AUTHORING_SCOPE_REQUEST_SCHEMA, 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"; +import { + normalizeTodoDecisionScope, + normalizeTodoRequiredDecisionScopes, + validateTodoDecisionMetadata, +} from "./decision_metadata.ts"; export const TODO_PUBLIC_UPDATE_REQUEST_SCHEMA = "todo_public_update_request_v0"; @@ -50,11 +55,22 @@ export function planPublicTodoUpdate(value: unknown): JsonObject { for (const field of TODO_OWNERSHIP_INTENT_FIELDS) delete intent[field]; Object.assign(intent, normalizeTodoWorkRequirements(rawIntent)); Object.assign(intent, normalizeTodoOwnershipIntent(rawIntent)); + if (Object.hasOwn(intent, "decision_scope")) { + intent.decision_scope = normalizeTodoDecisionScope(intent.decision_scope); + } + if (Object.hasOwn(intent, "required_decision_scopes")) { + intent.required_decision_scopes = normalizeTodoRequiredDecisionScopes(intent.required_decision_scopes); + } + validateTodoDecisionMetadata(todo, intent); const context = requireJsonObject(request.context, "public Todo update context"); + // Preserve omission at the authoring-scope boundary. Filling every field + // with null made an unrelated metadata edit erase a retained gate scope. + const scopeIntent = Object.fromEntries(SCOPE_INTENT_FIELDS.flatMap(key => + Object.hasOwn(intent, key) ? [[key, intent[key]]] : [])); + if (context.actor_agent_id !== undefined) scopeIntent.actor_agent_id = context.actor_agent_id; const scope = planTodoAuthoringScope({schema_version: TODO_AUTHORING_SCOPE_REQUEST_SCHEMA, command: "update", role: context.role, todo, - intent: {...Object.fromEntries(SCOPE_INTENT_FIELDS.map(key => [key, intent[key] ?? null])), - actor_agent_id: context.actor_agent_id ?? null}, + intent: scopeIntent, goal_id: context.goal_id, registered_agents: context.registered_agents}); const transition = externalWait(todo, intent, scope, context); const metadata = transition ? requireJsonObject(transition.metadata_updates, "external wait updates") : null; diff --git a/loopx/control_plane/todos/update_intent.py b/loopx/control_plane/todos/update_intent.py index 4c952b640a..c283d95d3a 100644 --- a/loopx/control_plane/todos/update_intent.py +++ b/loopx/control_plane/todos/update_intent.py @@ -13,8 +13,8 @@ # 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. +# implementation. Decision outcomes remain effect-owned; only declarative +# decision scope metadata crosses this transaction boundary. _CANONICAL_INTENT_FIELDS = frozenset( { "status", @@ -29,6 +29,8 @@ "required_capabilities", "target_capabilities", "explore_result_node_refs", + "decision_scope", + "required_decision_scopes", "claimed_by", "bound_agent", "goal_bound", From 4038c4782b0a921ae584d96cc88d6dc9a57207d6 Mon Sep 17 00:00:00 2001 From: huangruiteng <14976749+huangruiteng@users.noreply.github.com> Date: Sun, 13 Sep 2026 02:25:59 +0800 Subject: [PATCH 2/7] test(todo): expand decision metadata conformance Signed-off-by: huangruiteng <14976749+huangruiteng@users.noreply.github.com> --- examples/shared-goal-authority-e2e/mutants.py | 9 ++ .../control_plane/test_todo_update_intent.py | 10 +- .../decision_metadata.test.ts | 93 +++++++++++++++++++ .../native_planning_update_conformance.ts | 12 ++- .../public_todo_update.test.ts | 9 ++ tests/control_plane_ts/todo_update.test.ts | 1 + .../coordination_production_scale_v0.json | 31 +++++++ 7 files changed, 159 insertions(+), 6 deletions(-) create mode 100644 tests/control_plane_ts/decision_metadata.test.ts diff --git a/examples/shared-goal-authority-e2e/mutants.py b/examples/shared-goal-authority-e2e/mutants.py index d7df02bb10..4ed37338b7 100644 --- a/examples/shared-goal-authority-e2e/mutants.py +++ b/examples/shared-goal-authority-e2e/mutants.py @@ -47,6 +47,15 @@ def command(self) -> list[str]: CASES = [ + Case("decision_scope_role_guard_removed", (("loopx/control_plane/todos/decision_metadata.ts", replacement( + ' if (intent.decision_scope !== null && (role !== "user" || taskClass !== "user_gate")) {', + ' if (false) {')),), + "tests/control_plane_ts/decision_metadata.test.ts", + "user gates own decision_scope and agent work owns required_decision_scopes"), + Case("decision_scope_dedup_removed", (("loopx/control_plane/todos/decision_metadata.ts", replacement( + " if (!seen.has(key)) {", " if (true) {")),), + "tests/control_plane_ts/decision_metadata.test.ts", + "decision metadata normalizes compact and object forms without duplicate scopes"), Case('todo_global_gate_inferred', (('loopx/control_plane/todos/authoring_scope.ts', replacement( ' : todo.global_gate as boolean | null ?? null;', ' : (todo.global_gate || intent.goal_bound) as boolean | null ?? null;')),), diff --git a/tests/control_plane/test_todo_update_intent.py b/tests/control_plane/test_todo_update_intent.py index 488d4d626c..fabe366706 100644 --- a/tests/control_plane/test_todo_update_intent.py +++ b/tests/control_plane/test_todo_update_intent.py @@ -20,7 +20,7 @@ def test_update_intent_keeps_explicit_clears_and_empty_scalars() -> None: } -def test_update_route_only_promotes_fields_owned_by_native_transaction() -> None: +def test_update_route_promotes_declarative_decision_metadata() -> None: supported = build_canonical_update_intent( action_kind="publish", task_domain="delivery", @@ -36,12 +36,12 @@ def test_update_route_only_promotes_fields_owned_by_native_transaction() -> None status=None, ) - # Decision-scope governance remains on its owning effect path. It must - # not be silently reinterpreted as an ordinary metadata transaction. + # Declarative scope metadata now crosses the same typed planning + # transaction; terminal outcomes remain on their effect-owned path. governance = build_canonical_update_intent( - decision_scope={"kind": "write_scope", "granularity": "action"}, + decision_scope={"kind": "write_scope", "granularity": "action", "scope_key": "release"}, ) - assert not canonical_update_is_supported( + assert canonical_update_is_supported( text=None, note=None, intent=governance, diff --git a/tests/control_plane_ts/decision_metadata.test.ts b/tests/control_plane_ts/decision_metadata.test.ts new file mode 100644 index 0000000000..ec136315b9 --- /dev/null +++ b/tests/control_plane_ts/decision_metadata.test.ts @@ -0,0 +1,93 @@ +import assert from "node:assert/strict"; +import test from "node:test"; +import type {JsonObject} from "../../loopx/control_plane/effect_program.ts"; +import { + normalizeTodoDecisionScope, + normalizeTodoRequiredDecisionScopes, +} from "../../loopx/control_plane/todos/decision_metadata.ts"; +import {normalizeNativePlanningIntent} from "../../loopx/control_plane/todos/native_update_plan.ts"; +import { + planPublicTodoUpdate, + TODO_PUBLIC_UPDATE_REQUEST_SCHEMA, +} from "../../loopx/control_plane/todos/public_update.ts"; + +const scope = {kind: "Direction", granularity: "Goal", scope_key: "release"}; +const base = (role: "user" | "agent", task_class: string): JsonObject => ({ + todo_id: `todo_${role}_scope`, role, task_class, status: "open", + text: "Decision metadata fixture", archive_state: "active", + ...(role === "user" && task_class === "user_gate" ? {global_gate: true, goal_bound: true} : {}), +}); + +function plan(todo: JsonObject, intent: JsonObject): JsonObject { + return planPublicTodoUpdate({schema_version: TODO_PUBLIC_UPDATE_REQUEST_SCHEMA, + todo, intent, updated_at: "2026-09-13T00:00:00Z", + context: {goal_id: "goal-scope", role: todo.role, actor_agent_id: "agent-a", + registered_agents: ["agent-a", "agent-b"], items: [todo], + enforce_monitor_boundedness: true}}); +} + +test("decision metadata normalizes compact and object forms without duplicate scopes", () => { + assert.deepEqual(normalizeTodoDecisionScope(scope), { + schema_version: "decision_scope_v0", kind: "direction", granularity: "goal", scope_key: "release", + }); + assert.deepEqual(normalizeTodoRequiredDecisionScopes([ + "direction:goal:release", {kind: "DIRECTION", granularity: "GOAL", scope_key: "release"}, + {kind: "write_scope", granularity: "action", scope_key: "publish"}, + ]), [ + {schema_version: "decision_scope_v0", kind: "direction", granularity: "goal", scope_key: "release"}, + {schema_version: "decision_scope_v0", kind: "write_scope", granularity: "action", scope_key: "publish"}, + ]); +}); + +for (const [label, value] of [ + ["unknown kind", {kind: "secret", granularity: "goal", scope_key: "release"}], + ["invalid key", {kind: "direction", granularity: "goal", scope_key: "../release"}], + ["unknown field", {kind: "direction", granularity: "goal", scope_key: "release", reason_summary: "owner"}], + ["wrong schema", {schema_version: "decision_scope_v1", kind: "direction", granularity: "goal", scope_key: "release"}], + ["invalid list member", ["direction:goal:release", "not-a-scope"]], +] as const) { + test(`invalid decision metadata fails closed: ${label}`, () => { + assert.throws(() => Array.isArray(value) + ? normalizeTodoRequiredDecisionScopes(value) + : normalizeTodoDecisionScope(value)); + }); +} + +test("user gates own decision_scope and agent work owns required_decision_scopes", () => { + const userGate = base("user", "user_gate"); + const gate = plan(userGate, {decision_scope: scope}); + assert.deepEqual((gate.metadata_updates as JsonObject).decision_scope, { + schema_version: "decision_scope_v0", kind: "direction", granularity: "goal", scope_key: "release", + }); + + const agent = base("agent", "advancement_task"); + const requirement = plan(agent, {required_decision_scopes: [scope]}); + assert.deepEqual((requirement.metadata_updates as JsonObject).required_decision_scopes, [{ + schema_version: "decision_scope_v0", kind: "direction", granularity: "goal", scope_key: "release", + }]); + assert.throws(() => plan(agent, {decision_scope: scope}), /only valid for user_gate/); + assert.throws(() => plan(userGate, {required_decision_scopes: [scope]}), /only valid for agent/); +}); + +test("explicit empty required scopes clear a stale dependency without touching outcomes", () => { + const agent = base("agent", "advancement_task"); + agent.required_decision_scopes = [{schema_version: "decision_scope_v0", ...scope}]; + const result = plan(agent, {required_decision_scopes: []}); + const updates = result.metadata_updates as JsonObject; + assert.deepEqual(updates.required_decision_scopes, []); + assert.equal(Object.hasOwn(updates, "decision_outcome"), false); + assert.equal(Object.hasOwn(updates, "decision_scope_outcomes"), false); +}); + +test("role repair may clear an invalid retained decision_scope without granting one", () => { + const agent = base("agent", "advancement_task"); + agent.decision_scope = {schema_version: "decision_scope_v0", ...scope}; + const result = plan(agent, {decision_scope: null}); + const updates = result.metadata_updates as JsonObject; + assert.equal(updates.decision_scope, null); +}); + +test("decision outcomes remain effect-owned and cannot cross the native planning boundary", () => { + assert.throws(() => normalizeNativePlanningIntent({decision_outcome: "approve"}), /does not own/); + assert.throws(() => normalizeNativePlanningIntent({decision_scope_outcomes: []}), /does not own/); +}); diff --git a/tests/control_plane_ts/native_planning_update_conformance.ts b/tests/control_plane_ts/native_planning_update_conformance.ts index 0f3668f507..7732198d45 100644 --- a/tests/control_plane_ts/native_planning_update_conformance.ts +++ b/tests/control_plane_ts/native_planning_update_conformance.ts @@ -114,7 +114,8 @@ export function registerNativePlanningUpdateConformance(provider: string, factor for (const planning_intent of [ {required_capabilities: ["valid", "bad/token"]}, {required_write_scopes: ["src/**", "../escape"]}, {task_repository: "https://user:password@example.com/project"}, - {decision_outcome: "approve"}, {required_decision_scopes: []}, + {decision_outcome: "approve"}, {required_decision_scopes: ["not-a-scope"]}, + {decision_scope: {kind: "direction", granularity: "goal", scope_key: "release"}}, ]) { const beforeInvalid = await head(store); const result = await executeCoordinationTodoUpdate(store, {...request, @@ -195,6 +196,8 @@ export function registerNativePlanningUpdateConformance(provider: string, factor ...(item.task_class ? {task_class: item.task_class} : {}), ...(item.claimed_by ? {claimed_by: item.claimed_by} : {}), ...(item.reason ? {reason: item.reason} : {}), + ...(item.decision_scope ? {decision_scope: item.decision_scope} : {}), + ...(item.required_decision_scopes ? {required_decision_scopes: item.required_decision_scopes} : {}), ...(item.goal_bound ? {goal_bound: true} : {}), ...(item.global_gate ? {global_gate: true} : {}), } as JsonObject)); @@ -227,6 +230,13 @@ export function registerNativePlanningUpdateConformance(provider: string, factor const row = (current.head.todos as JsonObject[]).find(todo => todo.todo_id === todoId)!; assert.equal(row.reason, expected.reason_value); } + for (const field of ["decision_scope", "required_decision_scopes"] as const) { + if (expected[field] !== undefined) { + const current = await head(store); + const row = (current.head.todos as JsonObject[]).find(todo => todo.todo_id === todoId)!; + assert.deepEqual(row[field], expected[field]); + } + } 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)!; diff --git a/tests/control_plane_ts/public_todo_update.test.ts b/tests/control_plane_ts/public_todo_update.test.ts index b82033125a..2514b4bad2 100644 --- a/tests/control_plane_ts/public_todo_update.test.ts +++ b/tests/control_plane_ts/public_todo_update.test.ts @@ -62,6 +62,15 @@ test("public scope and fields agree without inventing a global gate", () => { assert.throws(() => plan({status: "done"}), /complete_goal_todo/); }); +test("omitted decision scope preserves a retained user-gate scope", () => { + const gate = {todo_id: "todo_gate_scope", role: "user", task_class: "user_gate", status: "open", + global_gate: true, goal_bound: true, + decision_scope: {schema_version: "decision_scope_v0", kind: "direction", granularity: "goal", scope_key: "release"}}; + const updates = plan({note: "Clarified"}, gate).metadata_updates as JsonObject; + assert.equal(Object.hasOwn(updates, "decision_scope"), false); + assert.equal(updates.note, "Clarified"); +}); + test("monitor observations use the same effective task scope and cannot be raw-state overrides", () => { const observation = {generated_at: "2030-01-01T00:00:00Z", material_change: true, result_hash: "changed", monitor_effect_id: "effect-a"}; diff --git a/tests/control_plane_ts/todo_update.test.ts b/tests/control_plane_ts/todo_update.test.ts index c37457a6bc..101bad93fc 100644 --- a/tests/control_plane_ts/todo_update.test.ts +++ b/tests/control_plane_ts/todo_update.test.ts @@ -60,6 +60,7 @@ test("planning intent cannot smuggle terminal, decision or observation writes", const {store, request} = await seeded({task_class: "advancement_task"}); for (const planning_intent of [ {status: "done"}, {decision_outcome: "approve"}, + {decision_scope: {kind: "direction", granularity: "goal", scope_key: "release"}}, {global_gate: true}, {monitor_metadata: {material_change: "true"}}, {completion_metadata_updates_override: {completion_continuation: "no_followup"}}, {status: "deferred"}, {successor_todo_ids: "todo_other"}, diff --git a/tests/fixtures/control_plane/coordination_production_scale_v0.json b/tests/fixtures/control_plane/coordination_production_scale_v0.json index a752bfb016..72a5d03cff 100644 --- a/tests/fixtures/control_plane/coordination_production_scale_v0.json +++ b/tests/fixtures/control_plane/coordination_production_scale_v0.json @@ -119,6 +119,37 @@ "registered_agents": ["agent-a", "agent-b"], "intent": {"reason": ""}, "expected": {"status": "applied", "reason": null} + }, + "decision_scope_user_gate": { + "todo_id": "todo_fixture_update_decision_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": {"decision_scope": "direction:goal:release"}, + "expected": {"status": "applied", "decision_scope": { + "schema_version": "decision_scope_v0", "kind": "direction", + "granularity": "goal", "scope_key": "release" + }} + }, + "required_decision_scopes_agent": { + "todo_id": "todo_fixture_update_decision_agent", + "role": "agent", + "status": "open", + "task_class": "advancement_task", + "claimed_by": "agent-a", + "actor_agent_id": "agent-a", + "registered_agents": ["agent-a", "agent-b"], + "intent": {"required_decision_scopes": [ + "direction:goal:release", "direction:goal:release" + ]}, + "expected": {"status": "applied", "required_decision_scopes": [{ + "schema_version": "decision_scope_v0", "kind": "direction", + "granularity": "goal", "scope_key": "release" + }]} } } } From 8e0515aa202cd42fee31410a563c39d8830fc303 Mon Sep 17 00:00:00 2001 From: huangruiteng <14976749+huangruiteng@users.noreply.github.com> Date: Sun, 13 Sep 2026 02:26:02 +0800 Subject: [PATCH 3/7] docs(rfc): record decision metadata migration checkpoint Signed-off-by: huangruiteng <14976749+huangruiteng@users.noreply.github.com> --- .../rfcs/typescript-control-plane-migration-v0.md | 12 ++++++++++++ .../typescript-control-plane-migration-v0.zh-CN.md | 9 +++++++++ 2 files changed, 21 insertions(+) diff --git a/docs/architecture/rfcs/typescript-control-plane-migration-v0.md b/docs/architecture/rfcs/typescript-control-plane-migration-v0.md index 83ccb34044..1e4568d154 100644 --- a/docs/architecture/rfcs/typescript-control-plane-migration-v0.md +++ b/docs/architecture/rfcs/typescript-control-plane-migration-v0.md @@ -552,6 +552,18 @@ writer still have real callers; this slice does not retire them or complete T1. Next close ownership/decision metadata with their lifecycle admission and validation effects, then the remaining leased Monitor transaction in T2. +Declarative decision metadata is now part of the same v1 planning transaction. +`decision_scope` is accepted only on `user_gate` records and +`required_decision_scopes` only on Agent Todos; both are normalized to the +public `decision_scope_v0` shape, deduplicated in first-seen order, and rejected +atomically when malformed or attached to the wrong role. Explicit empty +`required_decision_scopes` clears a stale dependency. `decision_outcome` and +`decision_scope_outcomes` remain effect-owned terminal state and are rejected by +the native planning boundary. The public planner also preserves omitted scope +fields instead of materializing nulls, so an unrelated metadata correction no +longer erases a retained user-gate scope. This closes the declarative metadata +part of T1 without granting approval, lease, completion, or promotion authority. + - Reuse the current provider text/note transaction, lifecycle admission, field-plan and completion rules. Enumerate actual public metadata edits and explicit-clear behavior before implementation; this is not permission to 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 19925c8177..11c58394f9 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 @@ -432,6 +432,15 @@ planning 事务更新 `action_kind`、`task_domain`、`task_repository`、 不退役它们,也不宣称完整 T1。下一步结合 lifecycle admission 与 validation effect 闭合 ownership/decision metadata,再推进 T2 剩余带 lease Monitor 事务。 +声明式决策元数据现已进入同一个 v1 planning 事务:`decision_scope` 只能写入 +`user_gate`,`required_decision_scopes` 只能写入 Agent Todo;两者统一归一化为公开的 +`decision_scope_v0` 形状,按首次出现顺序去重,格式错误或角色不匹配时整笔原子拒绝。 +显式空的 `required_decision_scopes` 会清除旧依赖。`decision_outcome` 与 +`decision_scope_outcomes` 仍属于 effect-owned terminal state,native planning 边界会 +拒绝它们。公开 planner 也保留 scope 字段的省略语义,不再把省略物化成 null,因而无关 +metadata 修正不会擦掉保留的 user-gate scope。这闭合的是 T1 的声明式 metadata 部分, +不授予批准、lease、完成或 promotion 权限。 + - 复用现有 provider text/note 事务、lifecycle 准入、field-plan 和 completion 规则。先枚举公开 metadata 编辑与显式 clear,不把 `UPDATE_FIELDS` 扩成所有存储 字段,也不让 generic patch 获得 terminal transition 权限。 From cd3d068ea356691e4f52dd483118be257191dc12 Mon Sep 17 00:00:00 2001 From: huangruiteng <14976749+huangruiteng@users.noreply.github.com> Date: Sun, 13 Sep 2026 02:58:01 +0800 Subject: [PATCH 4/7] fix(todo): preserve aggregate scope validation errors Signed-off-by: huangruiteng <14976749+huangruiteng@users.noreply.github.com> --- loopx/control_plane/todos/decision_metadata.ts | 11 ++++++++++- 1 file changed, 10 insertions(+), 1 deletion(-) diff --git a/loopx/control_plane/todos/decision_metadata.ts b/loopx/control_plane/todos/decision_metadata.ts index 9cecdf73b8..db7d1bdd03 100644 --- a/loopx/control_plane/todos/decision_metadata.ts +++ b/loopx/control_plane/todos/decision_metadata.ts @@ -93,7 +93,16 @@ export function normalizeTodoRequiredDecisionScopes( const result: TodoDecisionScope[] = []; const seen = new Set(); rawValues.forEach((raw, index) => { - const scope = normalizeTodoDecisionScope(raw, `${label}[${index}]`); + let scope: TodoDecisionScope | null; + try { + scope = normalizeTodoDecisionScope(raw, `${label}[${index}]`); + } catch (error) { + // Keep the legacy CLI's aggregate validation contract while retaining + // the offending index for native callers and diagnostics. + const detail = error instanceof Error ? ` (${error.message})` : ""; + const aggregateLabel = label.replace(/\[\d+\]$/u, ""); + fail(`${aggregateLabel} must contain kind:granularity:scope_key tokens; invalid item ${index}${detail}`); + } if (scope === null) fail(`${label}[${index}] must be a decision scope`); const key = identity(scope); if (!seen.has(key)) { From 4f046a5e687020ba11ea0e6586e6a0ab5cfa9e80 Mon Sep 17 00:00:00 2001 From: huangruiteng <14976749+huangruiteng@users.noreply.github.com> Date: Sun, 13 Sep 2026 03:16:57 +0800 Subject: [PATCH 5/7] fix(todo): preserve scoped decision-key syntax Signed-off-by: huangruiteng <14976749+huangruiteng@users.noreply.github.com> --- loopx/control_plane/todos/decision_metadata.ts | 4 ++-- tests/control_plane_ts/decision_metadata.test.ts | 3 +++ 2 files changed, 5 insertions(+), 2 deletions(-) diff --git a/loopx/control_plane/todos/decision_metadata.ts b/loopx/control_plane/todos/decision_metadata.ts index db7d1bdd03..86eca5af15 100644 --- a/loopx/control_plane/todos/decision_metadata.ts +++ b/loopx/control_plane/todos/decision_metadata.ts @@ -36,8 +36,8 @@ function fail(message: string): never { function scopeObject(value: unknown, label: string): JsonObject { if (typeof value === "string") { const parts = compactPythonWhitespace(value).toLowerCase().split(":"); - if (parts.length !== 3) fail(`${label} must use kind:granularity:scope_key`); - return {kind: parts[0], granularity: parts[1], scope_key: parts[2]}; + if (parts.length < 3) fail(`${label} must use kind:granularity:scope_key`); + return {kind: parts[0], granularity: parts[1], scope_key: parts.slice(2).join(":")}; } return requireJsonObject(value, label); } diff --git a/tests/control_plane_ts/decision_metadata.test.ts b/tests/control_plane_ts/decision_metadata.test.ts index ec136315b9..2bde9fe255 100644 --- a/tests/control_plane_ts/decision_metadata.test.ts +++ b/tests/control_plane_ts/decision_metadata.test.ts @@ -30,6 +30,9 @@ test("decision metadata normalizes compact and object forms without duplicate sc assert.deepEqual(normalizeTodoDecisionScope(scope), { schema_version: "decision_scope_v0", kind: "direction", granularity: "goal", scope_key: "release", }); + assert.deepEqual(normalizeTodoDecisionScope("direction:goal:repo:release"), { + schema_version: "decision_scope_v0", kind: "direction", granularity: "goal", scope_key: "repo:release", + }); assert.deepEqual(normalizeTodoRequiredDecisionScopes([ "direction:goal:release", {kind: "DIRECTION", granularity: "GOAL", scope_key: "release"}, {kind: "write_scope", granularity: "action", scope_key: "publish"}, From ce3d058f327e9f5d7e8f595b9775b603407219bd Mon Sep 17 00:00:00 2001 From: huangruiteng <14976749+huangruiteng@users.noreply.github.com> Date: Sun, 13 Sep 2026 21:40:13 +0800 Subject: [PATCH 6/7] fix(todo): fence decision scopes across task-class changes Signed-off-by: huangruiteng <14976749+huangruiteng@users.noreply.github.com> --- loopx/control_plane/todos/decision_metadata.ts | 11 +++++++++++ .../control_plane_ts/decision_metadata.test.ts | 18 ++++++++++++++++-- 2 files changed, 27 insertions(+), 2 deletions(-) diff --git a/loopx/control_plane/todos/decision_metadata.ts b/loopx/control_plane/todos/decision_metadata.ts index 86eca5af15..7508bbacfa 100644 --- a/loopx/control_plane/todos/decision_metadata.ts +++ b/loopx/control_plane/todos/decision_metadata.ts @@ -118,6 +118,17 @@ export function validateTodoDecisionMetadata(todo: JsonObject, intent: JsonObjec const role = todo.role; const taskClass = Object.hasOwn(intent, "task_class") ? intent.task_class : todo.task_class; + // Omitted fields preserve existing metadata, but changing a user gate into + // an ordinary user action would otherwise retain a scope that is no longer + // valid for the effective record. Require an explicit clear so callers do + // not silently erase or carry governance metadata across task-class roles. + const changingAwayFromUserGate = todo.task_class === "user_gate" && + Object.hasOwn(intent, "task_class") && taskClass !== "user_gate" && + todo.decision_scope !== undefined && todo.decision_scope !== null && + !Object.hasOwn(intent, "decision_scope"); + if (changingAwayFromUserGate) { + fail("task_class transition away from user_gate requires an explicit decision_scope clear"); + } if (Object.hasOwn(intent, "decision_scope")) { if (intent.decision_scope !== null && (role !== "user" || taskClass !== "user_gate")) { fail("decision_scope is only valid for user_gate todos"); diff --git a/tests/control_plane_ts/decision_metadata.test.ts b/tests/control_plane_ts/decision_metadata.test.ts index 2bde9fe255..928e6e1cd7 100644 --- a/tests/control_plane_ts/decision_metadata.test.ts +++ b/tests/control_plane_ts/decision_metadata.test.ts @@ -18,11 +18,11 @@ const base = (role: "user" | "agent", task_class: string): JsonObject => ({ ...(role === "user" && task_class === "user_gate" ? {global_gate: true, goal_bound: true} : {}), }); -function plan(todo: JsonObject, intent: JsonObject): JsonObject { +function plan(todo: JsonObject, intent: JsonObject, registeredAgents = ["agent-a", "agent-b"]): JsonObject { return planPublicTodoUpdate({schema_version: TODO_PUBLIC_UPDATE_REQUEST_SCHEMA, todo, intent, updated_at: "2026-09-13T00:00:00Z", context: {goal_id: "goal-scope", role: todo.role, actor_agent_id: "agent-a", - registered_agents: ["agent-a", "agent-b"], items: [todo], + registered_agents: registeredAgents, items: [todo], enforce_monitor_boundedness: true}}); } @@ -90,6 +90,20 @@ test("role repair may clear an invalid retained decision_scope without granting assert.equal(updates.decision_scope, null); }); +test("task-class transition requires clearing a retained user-gate scope explicitly", () => { + const gate = base("user", "user_gate"); + // Keep this fixture a valid single-agent gate while isolating the + // decision-scope transition from gate-binding cleanup. + delete gate.global_gate; + delete gate.goal_bound; + gate.decision_scope = {schema_version: "decision_scope_v0", ...scope}; + assert.throws(() => plan(gate, {task_class: "user_action"}, ["agent-a"]), /explicit decision_scope clear/); + const result = plan(gate, {task_class: "user_action", decision_scope: null}, ["agent-a"]); + const updates = result.metadata_updates as JsonObject; + assert.equal(updates.task_class, "user_action"); + assert.equal(updates.decision_scope, null); +}); + test("decision outcomes remain effect-owned and cannot cross the native planning boundary", () => { assert.throws(() => normalizeNativePlanningIntent({decision_outcome: "approve"}), /does not own/); assert.throws(() => normalizeNativePlanningIntent({decision_scope_outcomes: []}), /does not own/); From 97c67707db27fc2f2b8ca481cc6d001bbd6d8af6 Mon Sep 17 00:00:00 2001 From: huangruiteng <14976749+huangruiteng@users.noreply.github.com> Date: Sun, 13 Sep 2026 22:20:33 +0800 Subject: [PATCH 7/7] fix(todo): satisfy decision metadata quality checks Signed-off-by: huangruiteng <14976749+huangruiteng@users.noreply.github.com> --- loopx/control_plane/todos/decision_metadata.ts | 9 ++++++--- 1 file changed, 6 insertions(+), 3 deletions(-) diff --git a/loopx/control_plane/todos/decision_metadata.ts b/loopx/control_plane/todos/decision_metadata.ts index 7508bbacfa..f79f67b12c 100644 --- a/loopx/control_plane/todos/decision_metadata.ts +++ b/loopx/control_plane/todos/decision_metadata.ts @@ -1,8 +1,8 @@ -/** Typed decision metadata for ordinary Todo planning updates. +/** Typed decision metadata for ordinary task planning updates. * * Decision outcomes remain terminal/effect-owned. This module only validates * the two declarative fields that describe who may be waiting on what: a - * user-gate decision_scope and an agent Todo's required_decision_scopes. + * user-gate decision_scope and an agent task's required_decision_scopes. */ import type {JsonObject} from "../effect_program.ts"; import {EffectRuntimeRequestError} from "../effect_runtime_errors.ts"; @@ -48,7 +48,10 @@ export function normalizeTodoDecisionScope(value: unknown, label = "decision_sco const raw = scopeObject(value, label); const unknown = Object.keys(raw).filter(key => !["schema_version", "kind", "granularity", "scope_key", "decision_id"].includes(key)); - if (unknown.length) fail(`${label} has unsupported fields: ${unknown.sort().join(", ")}`); + if (unknown.length) { + unknown.sort((left, right) => left.localeCompare(right)); + fail(`${label} has unsupported fields: ${unknown.join(", ")}`); + } if (raw.schema_version !== undefined && raw.schema_version !== TODO_DECISION_SCOPE_SCHEMA_VERSION) { fail(`${label}.schema_version must be ${TODO_DECISION_SCOPE_SCHEMA_VERSION}`); }