From f0e163a2d90014ae67b3f7ddbb0bcb10aa3b6b9c Mon Sep 17 00:00:00 2001 From: hhyykk <4408344+hhyykk@users.noreply.github.com> Date: Mon, 2 Nov 2026 12:16:03 +0800 Subject: [PATCH] refactor(control-plane): retire dead local snapshot and standing-decision wrapper - Delete coordination/local_snapshot.py: no caller has remained since #6396 removed the last Todo mutation consumer of todo_snapshot_from_mapping. - Delete build_standing_decision_authority() and its standalone todo.standing_decision.project handler and request decoder. Production reads already reach the shared TS owner through the composed todo.read batch; tests and the example now exercise that path with the same chronology, revocation, scope and invalid-item assertions, plus a TS case for contradictory authority without explicit legacy order. - Correct the stale retention row in the retirement ledger and record the wrapper retirement in the TS migration checkpoint (EN and zh-CN). Co-authored-by: factory-droid[bot] <138933559+factory-droid[bot]@users.noreply.github.com> Signed-off-by: hhyykk <4408344+hhyykk@users.noreply.github.com> Signed-off-by: LoopX Agent <337587101+loopx-agent@users.noreply.github.com> --- .../2026-09-28-retirement-cadence.md | 2 +- .../2026-09-28-retirement-cadence.zh-CN.md | 2 +- .../typescript-control-plane-migration-v0.md | 9 ++- ...script-control-plane-migration-v0.zh-CN.md | 5 +- .../todo-standing-decision-authority-smoke.py | 17 ++++- .../coordination/local_snapshot.py | 67 ------------------- .../control_plane/effect_runtime_handlers.ts | 1 - .../control_plane/todos/standing_decision.py | 35 +--------- .../control_plane/todos/standing_decision.ts | 9 --- .../test_standing_decision_order.py | 19 ++++-- .../test_todo_decision_scope_consistency.py | 19 ++++-- .../effect_runtime_fairness.test.ts | 6 +- .../standing_decision.test.ts | 25 +++++-- 13 files changed, 79 insertions(+), 137 deletions(-) delete mode 100644 loopx/control_plane/coordination/local_snapshot.py diff --git a/docs/architecture/rfcs/ledger/shared-goal-authority-state-provider-v0/2026-09-28-retirement-cadence.md b/docs/architecture/rfcs/ledger/shared-goal-authority-state-provider-v0/2026-09-28-retirement-cadence.md index 0ea81a9971..9762f7b782 100644 --- a/docs/architecture/rfcs/ledger/shared-goal-authority-state-provider-v0/2026-09-28-retirement-cadence.md +++ b/docs/architecture/rfcs/ledger/shared-goal-authority-state-provider-v0/2026-09-28-retirement-cadence.md @@ -109,7 +109,7 @@ default-entry adoption. | --- | --- | --- | | `authority_core.py` acquire/renew/transfer/release, owner-eligibility and handoff-transition command facades | Only the old core tests; real lease and handoff adapters already use whole native transactions | No persisted command format or public CLI schema changes. Retain independent native generation, replay, conflict, cleanup and quiescence tests; exercise real File/SQLite entrypoints. | | `task_lease.acquire.decide`, `task_lease.lifecycle.decide`, `coordination.handoff_mode.plan` RPC registrations | Only those retired facades / handler tests; native transactions call the same typed rules directly | Obsolete private RPCs now reject unsupported methods. Keep `task_lease.owner_eligibility` and write-scope overlap: actual Python callers remain. | -| Lease-only `local_snapshot.py` normalization and error projection | No remaining caller; native executors own lease facts and errors | Keep `todo_snapshot_from_mapping`, used by live Todo mutation authorization. No store, receipt, backup or migration reader is removed. | +| Lease-only `local_snapshot.py` normalization and error projection | No remaining caller; native executors own lease facts and errors | After #6396 removed the last caller, the module and its remaining `todo_snapshot_from_mapping` adapter are now removed. No store, receipt, backup or migration reader is removed. | `authority_core.py` remains a live Todo bridge. At the #5395 boundary, `LeaseAction` / `LeaseModeGateCommand` remained registered until M4 review. diff --git a/docs/architecture/rfcs/ledger/shared-goal-authority-state-provider-v0/2026-09-28-retirement-cadence.zh-CN.md b/docs/architecture/rfcs/ledger/shared-goal-authority-state-provider-v0/2026-09-28-retirement-cadence.zh-CN.md index 3d195fa270..203faf862f 100644 --- a/docs/architecture/rfcs/ledger/shared-goal-authority-state-provider-v0/2026-09-28-retirement-cadence.zh-CN.md +++ b/docs/architecture/rfcs/ledger/shared-goal-authority-state-provider-v0/2026-09-28-retirement-cadence.zh-CN.md @@ -94,7 +94,7 @@ Python 退役收益。调用方清单如下: | --- | --- | --- | | `authority_core.py` 的 acquire/renew/transfer/release、owner eligibility、handoff transition command facade | 只剩旧 core 测试;真实 lease/handoff adapter 已直接使用完整 native 事务 | 不改持久化命令格式或公共 CLI schema。保留独立的原生 generation、重放、冲突、清理、静止规则测试,并走真实 File/SQLite 入口。 | | `task_lease.acquire.decide`、`task_lease.lifecycle.decide`、`coordination.handoff_mode.plan` RPC 注册 | 只剩这些旧 facade/handler 测试;原生事务直接复用同一 TS 规则 | 废弃私有 RPC 明确拒绝;保留仍有 Python 调用方的 `task_lease.owner_eligibility` 和 write-scope overlap。 | -| `local_snapshot.py` 中仅供 lease 的规范化和错误投影 | 已无调用方;原生执行器拥有 lease 事实与错误 | 保留真实 Todo mutation authorization 使用的 `todo_snapshot_from_mapping`;不删 store、回执、备份或迁移 reader。 | +| `local_snapshot.py` 中仅供 lease 的规范化和错误投影 | 已无调用方;原生执行器拥有 lease 事实与错误 | #6396 移除最后一个调用方后,现已删除该模块及其剩余的 `todo_snapshot_from_mapping` 适配器;不删 store、回执、备份或迁移 reader。 | `authority_core.py` 仍是活跃 Todo bridge;#5395 当时将 `LeaseAction`/`LeaseModeGateCommand` 保留到 M4 评审。有界 M4 包现退役无人 diff --git a/docs/architecture/rfcs/typescript-control-plane-migration-v0.md b/docs/architecture/rfcs/typescript-control-plane-migration-v0.md index 09b7b159af..3819b26645 100644 --- a/docs/architecture/rfcs/typescript-control-plane-migration-v0.md +++ b/docs/architecture/rfcs/typescript-control-plane-migration-v0.md @@ -1493,9 +1493,12 @@ migrate all T3 consumers or grant amendment commit/whole-Goal promotion authorit Standing-decision consumer closure: `todos/standing_decision.ts` owns reusable receipt eligibility and chronology, shared by status/quota reads and archive -selection. Python decodes legacy metadata and submits one batch; its old receipt -selector and the archive-local TS eligibility copy are removed. Canonical reads -use the complete Todo snapshot, including retained archived decisions, before +selection. Python decodes legacy metadata for the composed `todo.read` batch; +its old receipt selector and the archive-local TS eligibility copy are removed. +The unused `build_standing_decision_authority` wrapper and its standalone +`todo.standing_decision.project` handler/request decoder are now removed; tests +and the example use the composed read, while the shared TS owner remains. +Canonical reads use the complete Todo snapshot, including retained archived decisions, before assigning display indexes. Later rejection/cancellation is ordered by decision time, not Todo ID; contradictory unresolved chronology yields a diagnostic and no active receipt. Explicit user-gate metadata replaces notification heuristics 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 c060e707ea..3835ca6295 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 @@ -1077,7 +1077,10 @@ T3 完成,也不授予 amendment commit/整 Goal promotion 权限。 Standing decision consumer 收口:`todos/standing_decision.ts` 统一可复用决策的 资格与先后关系,供 status/quota 读取和 archive selection 共用。Python 只解码旧 -metadata 并批量调用,删除旧 receipt selector 与 TS archive 内的重复资格判断。 +metadata 并交给组合后的 `todo.read` 批量调用,删除旧 receipt selector 与 TS archive +内的重复资格判断。现已删除无调用方的 `build_standing_decision_authority` 包装及其 +独立 `todo.standing_decision.project` handler/请求解码器;测试与示例改用组合读取, +共享 TS owner 保留。 Canonical 读取在生成展示 index 前使用完整 Todo 快照,包括保留的归档决策。 后续拒绝/取消按决策时间覆盖旧批准,不再依赖 Todo ID;矛盾历史无法定序时给出 诊断且不提供 active receipt。此授权面必须有显式 user-gate metadata,不再借用 diff --git a/examples/control_plane/todo-standing-decision-authority-smoke.py b/examples/control_plane/todo-standing-decision-authority-smoke.py index 842fceeb27..4a8979f38e 100755 --- a/examples/control_plane/todo-standing-decision-authority-smoke.py +++ b/examples/control_plane/todo-standing-decision-authority-smoke.py @@ -26,7 +26,7 @@ standing_decision_authority_for_agent, ) from loopx.control_plane.todos.standing_decision import ( # noqa: E402 - build_standing_decision_authority, + standing_decision_source_items, ) from loopx.quota import build_quota_should_run # noqa: E402 @@ -36,6 +36,17 @@ SCOPE = "write_scope:goal:benchmark_pr_self_merge" +def project_standing_authority(items, *, legacy_source_order=True, canonical_records=False): + """Exercise the production source decoder and composed Todo read transaction.""" + from loopx.control_plane.todos.goal_todo_projection import source_todo_fields + + return source_todo_fields( + [], lineage=[], + standing_items=standing_decision_source_items(items, canonical_records=canonical_records), + legacy_source_order=legacy_source_order and not canonical_records, + ).get("standing_decision_authority") + + def user_receipt( todo_id: str, *, @@ -122,7 +133,7 @@ def assert_receipt_is_agent_scoped_and_revocable(authority: dict) -> None: assert consistency["ok"] is True, consistency assert consistency["standing_authority_match_count"] == 1, consistency - revoked = build_standing_decision_authority( + revoked = project_standing_authority( [ user_receipt("todo_standing_approve"), user_receipt("todo_standing_reject", outcome="reject"), @@ -145,7 +156,7 @@ def assert_receipt_is_agent_scoped_and_revocable(authority: dict) -> None: def assert_one_shot_and_non_done_gates_are_not_standing() -> None: - authority = build_standing_decision_authority( + authority = project_standing_authority( [ user_receipt( "todo_linked_approve", diff --git a/loopx/control_plane/coordination/local_snapshot.py b/loopx/control_plane/coordination/local_snapshot.py deleted file mode 100644 index 5d21bd7dc5..0000000000 --- a/loopx/control_plane/coordination/local_snapshot.py +++ /dev/null @@ -1,67 +0,0 @@ -"""Normalize current local Todo records for the typed authority bridge. - -This is the behavior-preserving edge between the existing Markdown/JSON -writers and the provider-neutral domain model. It performs no I/O and owns no -authority decision. -""" - -from __future__ import annotations - -from collections.abc import Mapping -from typing import Any - -from ..todos.contract import ( - normalize_todo_blocks_agent, - normalize_todo_bound_agent, - normalize_todo_claimed_by, - normalize_todo_decision_scope, - normalize_todo_excluded_agents, - normalize_todo_id, - normalize_todo_required_decision_scopes, -) -from .authority_core import TodoSnapshot - - -def _scope_identity(scope: Any) -> tuple[str, str, str] | None: - normalized = normalize_todo_decision_scope(scope) - if not normalized: - return None - return ( - normalized["kind"], - normalized["granularity"], - normalized["scope_key"], - ) - - -def todo_snapshot_from_mapping( - todo: Mapping[str, Any] | None, - *, - infer_status_from_done: bool = False, -) -> TodoSnapshot | None: - if todo is None: - return None - status = str(todo.get("status") or "").strip().lower() - if not status and infer_status_from_done: - status = "done" if todo.get("done") is True else "open" - required_scopes = frozenset( - identity - for scope in normalize_todo_required_decision_scopes( - todo.get("required_decision_scopes") - ) - if (identity := _scope_identity(scope)) is not None - ) - return TodoSnapshot( - todo_id=normalize_todo_id(todo.get("todo_id")) or "", - status=status, - role=str(todo.get("role") or ""), - task_class=str(todo.get("task_class") or "") or None, - claimed_by=normalize_todo_claimed_by(todo.get("claimed_by")), - excluded_agents=frozenset( - normalize_todo_excluded_agents(todo.get("excluded_agents")) - ), - bound_agent=normalize_todo_bound_agent(todo.get("bound_agent")), - blocks_agent=normalize_todo_blocks_agent(todo.get("blocks_agent")), - decision_scope=_scope_identity(todo.get("decision_scope")), - required_decision_scopes=required_scopes, - unblocks_todo_id=normalize_todo_id(todo.get("unblocks_todo_id")), - ) diff --git a/loopx/control_plane/effect_runtime_handlers.ts b/loopx/control_plane/effect_runtime_handlers.ts index 353fcce77e..258d0972ac 100644 --- a/loopx/control_plane/effect_runtime_handlers.ts +++ b/loopx/control_plane/effect_runtime_handlers.ts @@ -271,7 +271,6 @@ export function createEffectRuntimeHandlers( ["turn_journal.observed_capabilities", lazyHandler(() => import("./turn_driver/turn_journal_query.ts"), ({readTurnJournalCapabilities}) => readTurnJournalCapabilities)], ["todo.completion_fence.evaluate", lazyHandler(() => import("./todos/completion_fence.ts"), ({evaluateTodoCompletionFence}) => evaluateTodoCompletionFence)], ["todo.priority.plan", lazyHandler(() => import("./todos/priority.ts"), ({evaluateTodoPriority}) => evaluateTodoPriority)], - ["todo.standing_decision.project", lazyHandler(() => import("./todos/standing_decision.ts"), ({evaluateStandingDecisionProjection}) => evaluateStandingDecisionProjection)], ["todo.read", lazyHandler(() => import("./todos/read_transport.ts"), ({readTodoRuntime}) => readTodoRuntime)], ["capabilities.periodic_report.progress.select", lazyHandler(() => import("./capabilities/periodic_report_progress.ts"), ({selectPeriodicReportProgress}) => selectPeriodicReportProgress)], ["capabilities.periodic_report.approval_retry.select", lazyHandler(() => import("./capabilities/periodic_report_progress.ts"), ({selectPeriodicReportApprovalRetry}) => selectPeriodicReportApprovalRetry)], diff --git a/loopx/control_plane/todos/standing_decision.py b/loopx/control_plane/todos/standing_decision.py index 4d163fb1c0..6777eb071d 100644 --- a/loopx/control_plane/todos/standing_decision.py +++ b/loopx/control_plane/todos/standing_decision.py @@ -1,10 +1,7 @@ -"""Legacy metadata decoding and one batch call to the typed receipt owner.""" +"""Decode source metadata for the composed typed Todo read projection.""" from __future__ import annotations -from typing import Any - -from ..effect_runtime import effect_runtime_result from .contract import ( normalize_todo_blocks_agent, normalize_todo_decision_outcome, @@ -15,36 +12,6 @@ ) -def build_standing_decision_authority( - user_items: list[dict[str, Any]] | None, - *, - legacy_source_order: bool = True, - canonical_records: bool = False, -) -> dict[str, Any] | None: - """Decode legacy metadata; canonical records are already decoded. - - Canonical IDs obey their provider contract, not the Markdown token codec. - Both sources use the same typed eligibility and chronology owner. - """ - items = standing_decision_source_items(user_items, canonical_records=canonical_records) - if not items: - return None - result = effect_runtime_result( - "todo.standing_decision.project", - { - "schema_version": "standing_decision_projection_request_v0", - "items": items, - "legacy_source_order": legacy_source_order and not canonical_records, - }, - ) - if result is not None and ( - not isinstance(result, dict) - or result.get("schema_version") != "standing_decision_authority_v0" - ): - raise ValueError("invalid typed standing decision projection") - return result - - def standing_decision_source_items(user_items, *, canonical_records=False): items = [] for item in user_items or []: diff --git a/loopx/control_plane/todos/standing_decision.ts b/loopx/control_plane/todos/standing_decision.ts index e0cf5d9930..bfda1e2e78 100644 --- a/loopx/control_plane/todos/standing_decision.ts +++ b/loopx/control_plane/todos/standing_decision.ts @@ -1,12 +1,10 @@ /** Read-only standing decisions. Storage order is not decision chronology; * this projection neither grants a lease nor commits an owner decision. */ import type {JsonObject} from "../effect_program.ts"; -import {requireJsonObject} from "../runtime_decode.ts"; import {parseTodoTimestampMicros} from "../runtime_timestamp.ts"; import {authorityUnicodeCompare} from "../coordination/authority_store_codec.ts"; import {todoPresentationMetadata} from "../coordination/todo_presentation.ts"; -export const STANDING_DECISION_REQUEST_SCHEMA = "standing_decision_projection_request_v0"; const SCOPE_KINDS = new Set(["private_read", "write_scope", "resource", "production", "public_claim", "direction", "other"]); const BROAD_SCOPES = new Set(["goal", "project", "global"]); // The existing decision_scope_v0 wire grammar; not a prose classifier. @@ -110,10 +108,3 @@ export function projectStandingDecisions(items: readonly JsonObject[], legacySou inactive_count: entries.filter(e => !e.active).length, entries, ...(conflicts.length ? {conflict_count: conflicts.length, conflicts} : {})}; } - -export function evaluateStandingDecisionProjection(value: unknown): JsonObject | null { - const request = requireJsonObject(value, "standing decision projection request"); - if (request.schema_version !== STANDING_DECISION_REQUEST_SCHEMA || !Array.isArray(request.items) || - typeof request.legacy_source_order !== "boolean") throw new TypeError("invalid standing decision projection request"); - return projectStandingDecisions(request.items.map(item => requireJsonObject(item, "standing decision item")), request.legacy_source_order); -} diff --git a/tests/control_plane/test_standing_decision_order.py b/tests/control_plane/test_standing_decision_order.py index f54c939212..1e5b73d8b9 100644 --- a/tests/control_plane/test_standing_decision_order.py +++ b/tests/control_plane/test_standing_decision_order.py @@ -28,10 +28,21 @@ build_required_decision_scope_repair_hint, ) from loopx.control_plane.todos.standing_decision import ( - build_standing_decision_authority, + standing_decision_source_items, ) +def project_standing_authority(items, *, legacy_source_order=True, canonical_records=False): + """Exercise the production source decoder and composed Todo read transaction.""" + from loopx.control_plane.todos.goal_todo_projection import source_todo_fields + + return source_todo_fields( + [], lineage=[], + standing_items=standing_decision_source_items(items, canonical_records=canonical_records), + legacy_source_order=legacy_source_order and not canonical_records, + ).get("standing_decision_authority") + + def decision(todo_id: str, outcome: str, completed_at: str) -> dict: return { "todo_id": todo_id, @@ -52,7 +63,7 @@ def test_later_rejection_wins_even_when_older_approval_sorts_last() -> None: decision("todo_zzz_approve", "approve", "2026-09-10T01:00:00Z"), ] for ordered in [items, list(reversed(items))]: - result = build_standing_decision_authority(ordered) + result = project_standing_authority(ordered) assert result["active_count"] == 0 assert result["entries"][0]["source_todo_id"] == "todo_aaa_reject" @@ -66,7 +77,7 @@ def test_conflicts_survive_lane_filtering_without_granting_cross_agent_authority ] for item in items: item.update(global_gate=False, blocks_agent="agent-a") - result = build_standing_decision_authority(items, legacy_source_order=False) + result = project_standing_authority(items, legacy_source_order=False) scoped = standing_decision_authority_for_agent(result, agent_id="agent-a") assert scoped["active_count"] == 0 assert scoped["entries"] == [] @@ -113,7 +124,7 @@ def test_canonical_identifiers_are_not_redecoded_as_markdown_tokens() -> None: "scope_key": "release", }, ) - result = build_standing_decision_authority(items, canonical_records=True) + result = project_standing_authority(items, canonical_records=True) assert result["active_count"] == 0 assert result["entries"][0]["source_todo_id"] == "receipt-revoke" diff --git a/tests/control_plane/test_todo_decision_scope_consistency.py b/tests/control_plane/test_todo_decision_scope_consistency.py index 551f0a2005..a9d4a686b6 100644 --- a/tests/control_plane/test_todo_decision_scope_consistency.py +++ b/tests/control_plane/test_todo_decision_scope_consistency.py @@ -12,7 +12,7 @@ build_required_decision_scope_repair_hint, standing_decision_authority_for_agent, ) -from loopx.control_plane.todos.standing_decision import build_standing_decision_authority +from loopx.control_plane.todos.standing_decision import standing_decision_source_items from loopx.quota import build_quota_should_run AGENT_ID = "codex-quality-qualification" @@ -24,6 +24,17 @@ } +def project_standing_authority(items, *, legacy_source_order=True, canonical_records=False): + """Exercise the production source decoder and composed Todo read transaction.""" + from loopx.control_plane.todos.goal_todo_projection import source_todo_fields + + return source_todo_fields( + [], lineage=[], + standing_items=standing_decision_source_items(items, canonical_records=canonical_records), + legacy_source_order=legacy_source_order and not canonical_records, + ).get("standing_decision_authority") + + def test_explicit_gate_scope_is_not_cancelled_by_another_claimant() -> None: gate = {"todo_id": "todo_scoped_gate", "status": "open", "task_class": "user_gate", "blocks_agent": AGENT_ID, "claimed_by": "agent-b", "decision_scope": SCOPE} @@ -291,7 +302,7 @@ def test_standing_approval_satisfies_matching_agent_scope() -> None: "granularity": "goal", "scope_key": "benchmark_pr_self_merge", } - authority = build_standing_decision_authority( + authority = project_standing_authority( [ { "todo_id": "todo_standing_approval", @@ -338,7 +349,7 @@ def test_latest_rejection_revokes_exact_standing_approval() -> None: "blocks_agent": AGENT_ID, "decision_scope": "write_scope:goal:benchmark_pr_self_merge", } - authority = build_standing_decision_authority( + authority = project_standing_authority( [ { **base, @@ -393,7 +404,7 @@ def test_latest_rejection_revokes_exact_standing_approval() -> None: def test_non_authoritative_completed_items_do_not_become_standing( invalid_item: dict, ) -> None: - assert build_standing_decision_authority([invalid_item]) is None + assert project_standing_authority([invalid_item]) is None @pytest.mark.parametrize("decision_outcome", ["reject", "cancel"]) diff --git a/tests/control_plane_ts/effect_runtime_fairness.test.ts b/tests/control_plane_ts/effect_runtime_fairness.test.ts index 8a4fc69f6e..cd4929be5e 100644 --- a/tests/control_plane_ts/effect_runtime_fairness.test.ts +++ b/tests/control_plane_ts/effect_runtime_fairness.test.ts @@ -78,8 +78,8 @@ test("a real runtime serves lightweight work during cold File history verificati await delay(100); const binding = {id: "review", agent_id: "reviewer", todo_id: "todo_review", workspace: root, requesters: ["coordinator"], host_args: ["--host", "dsh"], timeout_seconds: 60, output_refs: ["output.json"]}; - const [ping, decisions, scope, ownership, selectedBinding] = await Promise.all([rpc("runtime.ping"), rpc("todo.standing_decision.project", { - schema_version: "standing_decision_projection_request_v0", items: [], legacy_source_order: false, + const [ping, decisions, scope, ownership, selectedBinding] = await Promise.all([rpc("runtime.ping"), rpc("todo.read", { + schema_version: "todo_read_request_v0", kind: "source_fields", groups: [], standing_items: [], legacy_source_order: false, }), rpc("todo.authoring_scope.plan", { schema_version: "todo_authoring_scope_request_v0", command: "class", role: "agent", todo: {task_class: "continuous_monitor"}, intent: {}, @@ -93,7 +93,7 @@ test("a real runtime serves lightweight work during cold File history verificati assert.equal(scope.schema_version, "todo_authoring_scope_result_v0"); assert.equal(ownership.ownership_gate, "require_holder"); assert.ok(ping.pid); - assert.equal(decisions, null, "no standing decisions means no authority projection"); + assert.equal((decisions.fields as Record).standing_decision_authority, undefined, "no standing decisions means no authority projection"); assert.equal(heavyFinished, false, "a full history proof must not monopolize other requests"); const receipt = await heavy; assert.equal(receipt.status, "found"); diff --git a/tests/control_plane_ts/standing_decision.test.ts b/tests/control_plane_ts/standing_decision.test.ts index 034938b880..0b20bd9b5b 100644 --- a/tests/control_plane_ts/standing_decision.test.ts +++ b/tests/control_plane_ts/standing_decision.test.ts @@ -3,7 +3,8 @@ import test from "node:test"; import type {JsonObject} from "../../loopx/control_plane/effect_program.ts"; import {TODO_DOMAIN_ITEM_SCHEMA} from "../../loopx/control_plane/coordination/coordination_state_contract.ts"; import {selectCoordinationTodoArchive} from "../../loopx/control_plane/coordination/todo_archive_selection.ts"; -import {evaluateStandingDecisionProjection, isStandingDecisionReceipt, projectStandingDecisions} from "../../loopx/control_plane/todos/standing_decision.ts"; +import {isStandingDecisionReceipt, projectStandingDecisions} from "../../loopx/control_plane/todos/standing_decision.ts"; +import {readTodo} from "../../loopx/control_plane/todos/read_projection.ts"; const decision = (todo_id: string, decision_outcome: string, extra: JsonObject = {}): JsonObject => ({ schema_version: TODO_DOMAIN_ITEM_SCHEMA, todo_id, decision_outcome, role: "user", task_class: "user_gate", @@ -97,10 +98,22 @@ test("retained archive revocation stays effective; all decision outcomes survive assert.equal(archive.retained_standing_decision_count, 3); }); -test("projection wire rejects malformed requests and accepts an empty inventory", () => { - const request = {schema_version: "standing_decision_projection_request_v0", items: [], legacy_source_order: false}; - assert.equal(evaluateStandingDecisionProjection(request), null); - for (const extra of [{schema_version: "unknown"}, {items: {}}, {items: [null]}, {legacy_source_order: "false"}]) { - assert.throws(() => evaluateStandingDecisionProjection({...request, ...extra})); +test("composed Todo read rejects malformed requests and accepts an empty standing inventory", async () => { + const request = {schema_version: "todo_read_request_v0", kind: "source_fields", groups: [], standing_items: [], legacy_source_order: false}; + const result = await readTodo(request); + assert.equal((result.fields as JsonObject).standing_decision_authority, undefined); + for (const extra of [{schema_version: "unknown"}, {kind: "unknown"}, {groups: {}}, {standing_items: [null]}]) { + await assert.rejects(() => readTodo({...request, ...extra})); + } +}); + +test("composed read refuses contradictory authority when legacy source order is not explicitly true", async () => { + for (const legacy_source_order of [false, "false"]) { + const result = await readTodo({schema_version: "todo_read_request_v0", kind: "source_fields", groups: [], + standing_items: [decision("todo_approve", "approve"), decision("todo_revoke", "reject")], legacy_source_order}); + const authority = (result.fields as JsonObject).standing_decision_authority as JsonObject; + assert.equal(authority.active_count, 0); + assert.deepEqual(authority.entries, []); + assert.equal(authority.conflict_count, 1); } });