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
12 changes: 12 additions & 0 deletions docs/architecture/rfcs/typescript-control-plane-migration-v0.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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 权限。
Expand Down
9 changes: 9 additions & 0 deletions examples/shared-goal-authority-e2e/mutants.py
Original file line number Diff line number Diff line change
Expand Up @@ -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;')),),
Expand Down
146 changes: 146 additions & 0 deletions loopx/control_plane/todos/decision_metadata.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,146 @@
/** 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 task'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<string> = new Set(TODO_DECISION_SCOPE_KINDS);
export const TODO_DECISION_SCOPE_GRANULARITY_SET: ReadonlySet<string> = 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.slice(2).join(":")};
}
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) {
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}`);
}
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<string>();
rawValues.forEach((raw, 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)) {
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;
// 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");
}
}
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 {};
}
12 changes: 9 additions & 3 deletions loopx/control_plane/todos/decision_scope.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<Record<string, number>> = {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");
Expand All @@ -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)];
}
Expand Down
20 changes: 18 additions & 2 deletions loopx/control_plane/todos/native_update_plan.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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");
Expand All @@ -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.
Expand All @@ -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,
Expand Down
20 changes: 18 additions & 2 deletions loopx/control_plane/todos/public_update.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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";

Expand Down Expand Up @@ -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;
Expand Down
6 changes: 4 additions & 2 deletions loopx/control_plane/todos/update_intent.py
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand All @@ -29,6 +29,8 @@
"required_capabilities",
"target_capabilities",
"explore_result_node_refs",
"decision_scope",
"required_decision_scopes",
"claimed_by",
"bound_agent",
"goal_bound",
Expand Down
10 changes: 5 additions & 5 deletions tests/control_plane/test_todo_update_intent.py
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand All @@ -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,
Expand Down
Loading