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 @@ -409,38 +409,33 @@ compatibility flag,都不能改变当前 agent 的最终 decision。

### 核心代码领读:可见提醒为什么不是 Authority

入口是
`loopx/control_plane/todos/decision_scope.py::build_required_decision_scope_consistency`:

```python
gates = [item for item in user_items if is_user_gate_todo_item(item)]
user_actions = [item for item in user_items if not is_user_gate_todo_item(item)]

matching_gates = [
gate
for gate in gates
if decision_scope_covers(gate.get("decision_scope"), required_scope)
]
compatible_gates = [
gate
for gate in matching_gates
if _gate_owner_compatible(gate, agent_id=effective_owner)
]
if compatible_gates:
continue
Python 入口 `decision_scope.py::build_required_decision_scope_consistency` 只负责
输入 codec 和返回 schema 检查。规则 owner 是同目录
`decision_scope.ts::decisionScopeConsistency`;它先区分 live gate 和非阻塞 action,
再检查 scope、owner 和精确目标:

```ts
const matching = gates.filter(gate => decisionScopeCovers(gate.decision_scope, scope));
const compatible = matching.filter(gate => addressed(gate, owner));
const conflicting = compatible.filter(gate =>
todoGateRelation(gate, item)?.state === "projection_repair_required");
if (conflicting.length) {
// 记录 required_decision_scope_target_mismatch;不得由另一 gate 掩盖。
continue;
}
if (compatible.length) continue;
```

只有 task class 正确、scope 覆盖且 owner compatible 的 gate 才满足依赖。随后
`matching_actions` 只用于产出错误归因:
只有 task class、scope、owner 和精确目标一致的 gate 才满足依赖;依赖一致不等于
批准或 lease。随后 `matchingActions` 只用于错误归因
`non_blocking_user_action_scope_collision`,不能成为 authority。只有不兼容 gate 时
归因 `required_decision_scope_gate_owner_mismatch`;没有匹配来源时归因
`dangling_required_decision_scope`。standing authority 的冲突与拒绝/取消后的阻塞要求
也由这个 TS owner 检查。

```python
if matching_actions:
reason_code = "non_blocking_user_action_scope_collision"
elif matching_gates:
reason_code = "required_decision_scope_gate_owner_mismatch"
else:
reason_code = "dangling_required_decision_scope"
```
覆盖范围和精确目标的内部 TS 函数仍被组合规则使用。它们已无生产调用方的三个
Python scalar adapter 与对应 RPC 操作已退役;生产调用继续使用 consistency、
组合 relation、批量 relations 和 gate scope projection,不能另建 Python 决策源。

沿 `build_required_decision_scope_repair_hint` 再读一步:repair 可以修 projection,但
`user_action remains non-blocking`,因此 repair route 也没有获得受限 delivery authority。
Expand Down
17 changes: 15 additions & 2 deletions docs/reference/protocols/decision-scope-v0.md
Original file line number Diff line number Diff line change
Expand Up @@ -9,8 +9,8 @@ whether a safe fallback may continue, or whether the projection itself needs
repair.

This contract turns the interaction catalog's Decision Scope Model into a
machine-facing schema. It does not implement the runtime migration by itself;
CLI/state/status/quota consumers should use this shape as the migration target.
machine-facing schema. The shared TypeScript decision-dependency owner evaluates
it for status/quota; Python adapters normalize inputs and validate responses.

## Fields

Expand Down Expand Up @@ -110,6 +110,19 @@ comparison and notification behavior.

## Status And Quota Rules

### Typed transport boundary

The internal `todo.decision_scope.evaluate` transport retains `consistency`,
`standing`, `relation`, `relations`, `fallback`, and `gate_scopes`. The unused
scalar operations `covers`, `scope_relation`, and `exact_relation`, and their
Python adapters, have been removed; those operation requests fail explicitly.
The underlying TypeScript coverage and exact-target rules remain part of the
combined evaluation, including rejection of conflicting exact targets.

This retirement changes an internal transport surface, not Todo metadata,
user-facing CLI operations, provider defaults, permissions, or approval
consumption. Existing input codecs and response schema/cardinality checks remain.

Status and quota should read decision scopes in this order:

1. explicit `decision_scope`, `required_decision_scopes`, and `safety_class`;
Expand Down
29 changes: 0 additions & 29 deletions loopx/control_plane/todos/decision_scope.py
Original file line number Diff line number Diff line change
Expand Up @@ -296,35 +296,6 @@ def build_required_decision_scope_repair_hint(
return result


def decision_scope_covers(gate_scope: Any, required_scope: Any) -> bool:
gate = normalize_todo_decision_scope(gate_scope)
required = normalize_todo_decision_scope(required_scope)
if not gate or not required:
return False
result = _evaluate("covers", gate_scope=gate, required_scope=required)
if not isinstance(result, bool):
raise TypeError("invalid typed decision scope covers projection")
return result


def decision_scope_gate_relation(gate: dict[str, Any], agent_item: dict[str, Any]) -> dict[str, Any] | None:
return _projection(
"scope_relation",
_evaluate("scope_relation", gate=_facts(gate), item=_facts(agent_item)),
schema_versions=frozenset({DECISION_SCOPE_RELATION_SCHEMA_VERSION}),
nullable=True,
)


def exact_todo_gate_relation(gate: dict[str, Any], agent_item: dict[str, Any]) -> dict[str, Any] | None:
return _projection(
"exact_relation",
_evaluate("exact_relation", gate=_facts(gate), item=_facts(agent_item)),
schema_versions=frozenset({TODO_GATE_RELATION_SCHEMA_VERSION}),
nullable=True,
)


def todo_gate_relation(gate: dict[str, Any], agent_item: dict[str, Any]) -> dict[str, Any] | None:
return _optional_relation(
"relation",
Expand Down
8 changes: 3 additions & 5 deletions loopx/control_plane/todos/decision_scope.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
/** Read-only decision dependency rules over one complete source snapshot.
* A consistent dependency is not approval, a lease, or a mutation receipt. */
import type {JsonObject} from "../effect_program.ts";
import {EffectRuntimeRequestError} from "../effect_runtime_errors.ts";
import {requireJsonObject, optionalNonEmptyString, requireBoolean, requireInteger} from "../runtime_decode.ts";
import {gateAddressesAgent} from "./agent_scope.ts";
import {
Expand Down Expand Up @@ -227,22 +228,19 @@ export function projectTodoGateScopes(request: JsonObject): JsonObject {
export function evaluateDecisionScope(value: unknown): JsonObject {
const request = requireJsonObject(value, "decision scope request");
if (request.schema_version !== DECISION_SCOPE_REQUEST_SCHEMA) throw new TypeError("decision scope request schema mismatch");
let result: JsonObject | boolean | null | (JsonObject | null)[][];
let result: JsonObject | null | (JsonObject | null)[][];
switch (request.operation) {
case "fallback": result = selectScopedGateFallback(request); break;
case "gate_scopes": result = projectTodoGateScopes(request); break;
case "consistency": result = decisionScopeConsistency(request); break;
case "standing": result = scopeStandingAuthority(request.authority, optionalNonEmptyString(request.agent_id, "agent_id")); break;
case "covers": result = decisionScopeCovers(request.gate_scope, request.required_scope); break;
case "relations": {
const items = rows(request.items);
result = rows(request.gates).map(gate => items.map(item => todoGateRelation(gate, item)));
break;
}
case "relation": result = todoGateRelation(requireJsonObject(request.gate, "gate"), requireJsonObject(request.item, "item")); break;
case "scope_relation": result = decisionScopeRelation(requireJsonObject(request.gate, "gate"), requireJsonObject(request.item, "item")); break;
case "exact_relation": result = exactTodoGateRelation(requireJsonObject(request.gate, "gate"), requireJsonObject(request.item, "item")); break;
default: throw new TypeError("unsupported decision scope operation");
default: throw new EffectRuntimeRequestError("unsupported decision scope operation");
}
return {schema_version: "todo_decision_scope_result_v0", result};
}
43 changes: 40 additions & 3 deletions tests/control_plane/test_quota_authority_settlement_journey.py
Original file line number Diff line number Diff line change
Expand Up @@ -28,19 +28,24 @@ def _row(todo_id: str, *, status: str = "open", extra: str = "") -> str:


def _source(root: Path, *, provider: str, status: str = "open", extra: str = "", empty: bool = False,
handoff_mode: str = "soft_claim"):
handoff_mode: str = "soft_claim", user_rows: str = ""):
project, runtime, registry = cli._write_fixture(root)
goal = json.loads(registry.read_text())["goals"][0]
path = project / goal["state_file"]
prefix = path.read_text().split("## Agent Todo")[0] + "## Agent Todo\n\n"
prefix = path.read_text().split("## Agent Todo")[0]
if user_rows:
prefix += "## User Todo / Owner Review Reading Queue\n\n" + user_rows + "\n"
prefix += "## Agent Todo\n\n"
rows = "".join(_row(f"todo_ready_{i}") for i in range(35))
path.write_text(prefix + ("" if empty else rows + _row(cli.TODO_ID, status=status, extra=extra)))
if provider != "legacy":
fields = parse_active_state_todos(path.read_text(), goal=goal, item_limit=None)
initialize_canonical_authority(
runtime, cli.GOAL_ID,
build_todo_runtime_shadow_projection(
goal_id=cli.GOAL_ID, todos=fields["agent_todos"]["items"], handoff_mode=handoff_mode,
goal_id=cli.GOAL_ID,
todos=fields["agent_todos"]["items"] + fields.get("user_todos", {}).get("items", []),
handoff_mode=handoff_mode,
),
state_path=path, provider=provider,
)
Expand Down Expand Up @@ -83,6 +88,38 @@ def test_exact_selection_reaches_work_beyond_display_limits(tmp_path, provider):
assert guard["heartbeat_receipt"]["settlement_identity"]["todo_id"] == cli.TODO_ID


@pytest.mark.parametrize("provider", ["file", "sqlite"])
@pytest.mark.parametrize("conflicting_target", [False, True])
def test_canonical_decision_gate_cannot_be_bypassed_by_exact_selection(
tmp_path, provider, conflicting_target,
):
target = "todo_other" if conflicting_target else cli.TODO_ID
user_rows = (
"- [ ] [P0] Decide whether the selected write may proceed.\n"
f" <!-- loopx:todo todo_id=todo_gate status=open task_class=user_gate "
f"blocks_agent={cli.AGENT_ID} decision_scope=write_scope:action:release "
f"unblocks_todo_id={target} -->\n"
)
project, runtime, registry, path, prefix = _source(
tmp_path, provider=provider,
extra=f"claimed_by={cli.AGENT_ID} required_decision_scopes=write_scope:action:release",
user_rows=user_rows,
)
# An apparently unblocked display cannot erase the canonical gate/requirement.
path.write_text(prefix.split("## User Todo")[0] + "## Agent Todo\n\n" + _row(cli.TODO_ID))
_, guard = _guard(project, runtime, registry)
assert guard["normal_delivery_allowed"] is False, guard
if conflicting_target:
consistency = guard["todo_decision_scope_consistency"]
assert consistency["ok"] is False
assert any(error["reason_code"] == "required_decision_scope_target_mismatch"
for error in consistency["errors"])
current = list_goal_todos(registry_path=registry, goal_id=cli.GOAL_ID,
runtime_root_arg=str(runtime), todo_id=cli.TODO_ID)["todo"]
assert current["required_decision_scopes"][0]["scope_key"] == "release"
assert cli._spend_run_count(runtime) == 0


@pytest.mark.parametrize("provider", ["legacy", "file", "sqlite"])
@pytest.mark.parametrize("extra", [f"excluded_agents={cli.AGENT_ID}", "claimed_by=agent-other",
"required_capabilities=production_access"])
Expand Down
28 changes: 16 additions & 12 deletions tests/control_plane/test_todo_decision_scope_runtime_boundary.py
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@
import pytest

from loopx.control_plane.todos import decision_scope
from loopx.control_plane.effect_runtime import EffectRuntimeRejected, effect_runtime_result

SCOPE = {
"schema_version": "decision_scope_v0",
Expand Down Expand Up @@ -38,7 +39,6 @@ def _response(value: object) -> dict[str, object]:
@pytest.mark.parametrize(
("invoke", "value"),
[
(lambda: decision_scope.decision_scope_covers(SCOPE, SCOPE), 1),
(
lambda: decision_scope.build_required_decision_scope_consistency(
{"first_open_items": [AGENT_ITEM]},
Expand All @@ -54,14 +54,7 @@ def _response(value: object) -> dict[str, object]:
),
{"schema_version": "wrong_standing_v0"},
),
(
lambda: decision_scope.decision_scope_gate_relation(GATE, AGENT_ITEM),
{"schema_version": "todo_gate_relation_v0"},
),
(
lambda: decision_scope.exact_todo_gate_relation(GATE, AGENT_ITEM),
{"schema_version": "decision_scope_relation_v0"},
),
(lambda: decision_scope.todo_gate_relation(GATE, AGENT_ITEM), True),
(
lambda: decision_scope.todo_gate_relation(GATE, AGENT_ITEM),
{"schema_version": "unknown_relation_v0"},
Expand Down Expand Up @@ -123,7 +116,7 @@ def test_outer_runtime_envelope_fails_closed(
)

with pytest.raises(TypeError, match="invalid typed decision scope projection"):
decision_scope.decision_scope_covers(SCOPE, SCOPE)
decision_scope.todo_gate_relation(GATE, AGENT_ITEM)


def test_nullable_operations_still_accept_explicit_null(
Expand All @@ -135,8 +128,6 @@ def test_nullable_operations_still_accept_explicit_null(
lambda *_args, **_kwargs: _response(None),
)

assert decision_scope.decision_scope_gate_relation(GATE, AGENT_ITEM) is None
assert decision_scope.exact_todo_gate_relation(GATE, AGENT_ITEM) is None
assert decision_scope.todo_gate_relation(GATE, AGENT_ITEM) is None
assert decision_scope.select_scoped_gate_fallback(
[GATE], [AGENT_ITEM], agent_id="agent-a", allow_unrelated_gate=True,
Expand All @@ -152,3 +143,16 @@ def test_fallback_runtime_result_fails_closed(monkeypatch, value):
[GATE], [AGENT_ITEM], agent_id="agent-a", allow_unrelated_gate=True,
monitor_debt_backoff_active=False,
)


@pytest.mark.parametrize("operation", ["covers", "scope_relation", "exact_relation"])
def test_retired_scalar_operations_are_rejected_by_real_runtime(operation: str) -> None:
with pytest.raises(EffectRuntimeRejected, match="unsupported decision scope operation"):
effect_runtime_result("todo.decision_scope.evaluate", {
"schema_version": "todo_decision_scope_request_v0",
"operation": operation,
"gate_scope": SCOPE,
"required_scope": SCOPE,
"gate": {**GATE, "is_gate": True},
"item": AGENT_ITEM,
})
13 changes: 12 additions & 1 deletion tests/control_plane_ts/decision_scope.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -67,10 +67,21 @@ test("complete production-scale history preserves the conflict beyond display li

test("batched relations preserve pair ordering and fail on unknown protocol operations", () => {
const result = evaluateDecisionScope({schema_version: "todo_decision_scope_request_v0", operation: "relations", gates: [gate], items: [item, {...item, required_decision_scopes: []}]});
assert.deepEqual(result.result, [[todoGateRelation(gate, item), todoGateRelation(gate, {...item, required_decision_scopes: []})]]);
const matrix = result.result as JsonObject[][];
assert.deepEqual(matrix.map(row => row.map(relation => relation.state)), [["gate_covers_action", "independent"]]);
assert.deepEqual(matrix.map(row => row.map(relation => relation.agent_todo_id)), [["todo_work", "todo_work"]]);
assert.throws(() => evaluateDecisionScope({schema_version: "todo_decision_scope_request_v0", operation: "approve"}), /unsupported/);
});

test("retired scalar transport operations fail closed while internal rules remain available", () => {
for (const operation of ["covers", "scope_relation", "exact_relation"]) {
assert.throws(() => evaluateDecisionScope({schema_version: "todo_decision_scope_request_v0",
operation, gate_scope: scope, required_scope: scope, gate, item}), /unsupported decision scope operation/);
}
assert.equal(decisionScopeCovers(scope, scope), true);
assert.equal(todoGateRelation({...gate, unblocks_todo_id: "todo_other"}, item)?.state, "projection_repair_required");
});


test("gate scope projection qualifies every addressed live dependency without granting authority", () => {
const independent = {...gate, decision_scope: undefined, unblocks_todo_id: "todo_other"};
Expand Down
Loading