From f1b6194631395304ff2542644c4c0e2a0e4d581e Mon Sep 17 00:00:00 2001 From: song Date: Mon, 12 Oct 2026 20:29:47 +0800 Subject: [PATCH] docs(semantics): define reserved cancellation and custom decision scope treatment Signed-off-by: song --- docs/architecture/rfcs/STATUS.md | 2 +- docs/architecture/rfcs/STATUS.zh-CN.md | 2 +- .../2026-10-12-reserved-value-disposition.md | 78 +++++++++++++++++++ ...-10-12-reserved-value-disposition.zh-CN.md | 58 ++++++++++++++ .../semantic-vocabulary-convergence-v0.md | 3 + ...emantic-vocabulary-convergence-v0.zh-CN.md | 3 + docs/reference/glossary.md | 1 + docs/reference/protocols/decision-scope-v0.md | 25 ++++++ loopx/semantics/vocabulary_v0.json | 10 ++- ...st_settlement_receipt_source_boundaries.py | 25 ++++++ .../test_todo_decision_scope_lifecycle.py | 42 ++++++++++ tests/control_plane_ts/decision_scope.test.ts | 14 ++++ 12 files changed, 259 insertions(+), 4 deletions(-) create mode 100644 docs/architecture/rfcs/ledger/semantic-vocabulary-convergence-v0/2026-10-12-reserved-value-disposition.md create mode 100644 docs/architecture/rfcs/ledger/semantic-vocabulary-convergence-v0/2026-10-12-reserved-value-disposition.zh-CN.md diff --git a/docs/architecture/rfcs/STATUS.md b/docs/architecture/rfcs/STATUS.md index 3b29da3930..e59f7ace37 100644 --- a/docs/architecture/rfcs/STATUS.md +++ b/docs/architecture/rfcs/STATUS.md @@ -61,7 +61,7 @@ appendix may keep dated history, but no dated log heading may precede it. | [RFC: Provider-Neutral Post-Writeback Capability Hooks v0](provider-neutral-post-writeback-capability-hooks-v0.md) | Accepted | none | — | | [RFC: Provider-Neutral Turn-Start Inbox Hook v0](provider-neutral-turn-start-inbox-hook-v0.md) | Accepted | none | — | | [RFC: Research Exploration Control Plane v0](research-exploration-control-plane-v0.md) | Accepted | none | — | -| [RFC: Semantic Vocabulary Convergence and Commit-Time Drift Checks (v0)](semantic-vocabulary-convergence-v0.md) | Accepted | none | [5 entries](ledger/semantic-vocabulary-convergence-v0/) | +| [RFC: Semantic Vocabulary Convergence and Commit-Time Drift Checks (v0)](semantic-vocabulary-convergence-v0.md) | Accepted | none | [6 entries](ledger/semantic-vocabulary-convergence-v0/) | | [RFC: Shared Goal Alignment and Governed Amendment Protocol (v0)](shared-goal-alignment-and-governed-amendment-v0.md) | Accepted | none | [2 entries](ledger/shared-goal-alignment-and-governed-amendment-v0/) | | [RFC: LoopX Shared Control-Plane Authority and Pluggable State Providers (v0)](shared-goal-authority-state-provider-v0.md) | Accepted | none | [24 entries](ledger/shared-goal-authority-state-provider-v0/) | | [RFC: Single-Owner Local Daemon (v0)](single-owner-local-daemon-v0.md) | Accepted | none | — | diff --git a/docs/architecture/rfcs/STATUS.zh-CN.md b/docs/architecture/rfcs/STATUS.zh-CN.md index 63aadb4925..2a887bc2dc 100644 --- a/docs/architecture/rfcs/STATUS.zh-CN.md +++ b/docs/architecture/rfcs/STATUS.zh-CN.md @@ -58,7 +58,7 @@ | [RFC:Provider-Neutral Post-Writeback Capability Hooks v0](provider-neutral-post-writeback-capability-hooks-v0.zh-CN.md) | 已接受 | 无 | — | | [RFC: Provider-Neutral Turn-Start Inbox Hook v0](provider-neutral-turn-start-inbox-hook-v0.md) | 已接受 | none | — | | [RFC:研究型探索控制面 v0](research-exploration-control-plane-v0.zh-CN.md) | 已接受 | 无 | — | -| [RFC:语义词表收敛与提交期漂移检查(v0)](semantic-vocabulary-convergence-v0.zh-CN.md) | 已接受 | 无 | [5 条](ledger/semantic-vocabulary-convergence-v0/) | +| [RFC:语义词表收敛与提交期漂移检查(v0)](semantic-vocabulary-convergence-v0.zh-CN.md) | 已接受 | 无 | [6 条](ledger/semantic-vocabulary-convergence-v0/) | | [RFC:共享 Goal 对齐与受治理 Amendment 协议(v0)](shared-goal-alignment-and-governed-amendment-v0.zh-CN.md) | 已接受 | 无 | [2 条](ledger/shared-goal-alignment-and-governed-amendment-v0/) | | [RFC:LoopX 共享控制面权威与可插拔状态 Provider(v0)](shared-goal-authority-state-provider-v0.zh-CN.md) | 已接受 | 无 | [24 条](ledger/shared-goal-authority-state-provider-v0/) | | [RFC: Single-Owner Local Daemon (v0)](single-owner-local-daemon-v0.md) | 已接受 | none | — | diff --git a/docs/architecture/rfcs/ledger/semantic-vocabulary-convergence-v0/2026-10-12-reserved-value-disposition.md b/docs/architecture/rfcs/ledger/semantic-vocabulary-convergence-v0/2026-10-12-reserved-value-disposition.md new file mode 100644 index 0000000000..e0a3287310 --- /dev/null +++ b/docs/architecture/rfcs/ledger/semantic-vocabulary-convergence-v0/2026-10-12-reserved-value-disposition.md @@ -0,0 +1,78 @@ +# Reserved settlement input and custom decision-scope disposition + +Source baseline: `323c3053128e41e16df27e6b6498f83c37e81f5e`. +Refs [#4447](https://github.com/loopx-project/loopx/issues/4447), original +Stage 2a/2b source treatment and Stage 3 owner disposition. This is a concrete +proposal for review and integration, not a record of an earlier maintainer +approval. It does not close the issue or replace the 26-entry inventory in +[#6270](https://github.com/loopx-project/loopx/pull/6270). + +## `settlement_failure_kind.cancelled` + +Keep `cancelled` as a reader-reserved wire value in the existing vocabulary. +The Python and TypeScript decoders already admit it. Current built-in settlement +branches do not produce it; cancellation in Host, chat, lease or usage contracts +must not be counted as its producer. No new runtime cancellation mechanism is +introduced to make the value appear live. + +The existing `compatibility_only` metadata now states why that admitted input +is retained and the exit condition. This is a compatibility commitment, not +proof that any historical producer emitted the value. A supplied `cancelled` +envelope remains a failure: bind must stop before the next callback and preserve +prior receipts and failure details. It neither erases an already committed +step nor proves an in-flight provider effect stopped. It grants no automatic +retry, rollback, resume or quota authority. A provider outcome that is actually +unknown continues to use its owning observation/reconciliation contract; +`cancelled` cannot settle that uncertainty by relabeling it. + +Replace the reservation only through one of these owner-reviewed changes: + +- a real typed cancellation contract that distinguishes before-dispatch + cancellation from ambiguous after-dispatch effects, with producer and real + caller witnesses plus updated source treatment; or +- explicit versioned reader migration/removal with caller and stored-envelope + compatibility evidence. + +There is no automatic expiry, invented external canceller, decoder narrowing, +or forced vocabulary retirement. F1/F2 remains the existing six-entry domain. + +## `todo_decision_scope_kind.other` + +`other` is explicitly authored custom decision authority when none of the six +named kinds describes the actual approval. Prefer a named kind whenever it +applies; uncertainty about the intended decision is not a reason to select +`other`. The existing task text explains the decision and a stable public-safe +scope key identifies it for both gate and dependent work. See the +[authoring rule and example](../../../../reference/protocols/decision-scope-v0.md#choosing-kindother). + +For example, `other:action:study_conflict_review` may name an owner-required +conflict-of-interest review. It is independent of private source access, +resource spend, direction changes or public publication, which keep their +named kinds. `other:action:study_method_review` is another custom decision, +not a synonym or fallback. + +Matching uses the same typed owner as every other kind. Exact kind is always +required, so even `other:global:*` cannot cover a named kind. Key, granularity, +recipient and exact Todo target rules remain intact. Unknown/misspelled kinds +still reject; no parser fallback converts them to `other`. Declaring a kind is +not approval: existing linked-gate completion or standing authority consumes +only the scopes it actually covers. + +## Evidence and remaining acceptance + +Reuse the existing all-value settlement decoder, unknown-input and bind +short-circuit witnesses. Extend their actual IPC/decoder path to preserve a +supplied reserved failure with prior committed receipts and details; this is +reader coverage, never internal producer evidence. Reuse existing Todo +lifecycle helpers and native TypeScript matching to exercise a custom approval +alongside a different custom requirement and a named permission requirement. +Only the selected custom scope may be consumed. A broad custom scope cannot +cover any named kind, and malformed input cannot become custom authority. + +The registry still has 26 entries and the same values/owners. No budgets, +formal proof domains, wire schemas, runtime defaults or persisted formats are +changed. Tests alone do not approve these dispositions. Final #4447 acceptance +still requires owner review/integration of this proposal, the current source +inventory, and passed/failed/untested evidence on the final integrated revision. +Earlier records of unresolved meaning remain historical; they are not rewritten +as if this decision had already existed. diff --git a/docs/architecture/rfcs/ledger/semantic-vocabulary-convergence-v0/2026-10-12-reserved-value-disposition.zh-CN.md b/docs/architecture/rfcs/ledger/semantic-vocabulary-convergence-v0/2026-10-12-reserved-value-disposition.zh-CN.md new file mode 100644 index 0000000000..9460960805 --- /dev/null +++ b/docs/architecture/rfcs/ledger/semantic-vocabulary-convergence-v0/2026-10-12-reserved-value-disposition.zh-CN.md @@ -0,0 +1,58 @@ +# 保留的 settlement 输入与自定义决策 scope 处置 + +源码基线:`323c3053128e41e16df27e6b6498f83c37e81f5e`。 +关联 [#4447](https://github.com/loopx-project/loopx/issues/4447) 原 Stage 2a/2b +来源处置与 Stage 3 owner 决定。这是待评审、待集成的具体提案,不是先前维护者批准的 +记录;不关闭 issue,也不替代 [#6270](https://github.com/loopx-project/loopx/pull/6270) +中的 26 项来源清单。 + +## `settlement_failure_kind.cancelled` + +将 `cancelled` 保留为既有词表中 reader-reserved 的 wire 值。Python 与 TypeScript +解码器已接纳它,当前内置 settlement 分支不产生它。Host、chat、lease 或 usage +契约中的取消不能算作它的 producer;不会为了让值看起来存活而新增取消机制。 + +既有 `compatibility_only` 元数据现在说明保留这一输入的理由与退出条件。这是兼容 +承诺,不证明历史上真的有 producer 发出过该值。传入 `cancelled` envelope 仍是失败: +bind 在下一 callback 前停止,保留之前的 receipt 和 failure details。它不抹除已提交 +步骤,也不证明在途 provider effect 已停止,不授予自动 retry、rollback、resume 或 +quota 权限。实际结果未知的 provider effect 仍由原 observation/reconciliation owner +处理,不能通过改名为 `cancelled` 消除未知。 + +只有经过 owner 评审的以下变更可以替代该保留处置: + +- 真实类型化取消契约,区分 dispatch 前取消与 dispatch 后不确定效果,提供 producer、 + 真实 caller 见证并同步来源说明;或 +- 明确的版本化 reader 迁移/移除,具备 caller 与 stored-envelope 兼容证据。 + +没有自动到期、虚构的外部 canceller、解码收窄或强制词表退休。F1/F2 保持既有六项域。 + +## `todo_decision_scope_kind.other` + +当六个命名 kind 均不能描述真实审批时,作者可显式选择 `other` 自定义决策 authority。 +命名 kind 适用时优先使用它;不清楚要决定什么时应澄清,不能猜测 `other`。既有任务 +文本说明 owner 要决定的事项,稳定 public-safe scope key 供 gate 和依赖工作共同使用。 +详见[编写规则与示例](../../../../reference/protocols/decision-scope-v0.md#choosing-kindother)。 + +例如 `other:action:study_conflict_review` 表示 owner 要求的利益冲突声明审核。 +读取私有数据、资源消耗、方向改变和公开发布分别保留它们的命名 kind,不被这一审批 +覆盖。`other:action:study_method_review` 是另一个自定义决定,不是同义项或 fallback。 + +匹配仍由与其他 kind 相同的类型化 owner 决定,始终要求 kind 相同,所以 +`other:global:*` 也不能覆盖命名 kind。key、granularity、recipient 与准确 Todo target +规则不变。未知或拼错的 kind 仍拒绝,parser 不会把它们转成 `other`。声明 kind 不是 +批准:既有 linked-gate completion 或 standing authority 只消费实际覆盖的 scope。 + +## 证据与剩余验收 + +复用已有 settlement 全值 decoder、未知输入与 bind 短路见证;扩展实际 IPC/decoder +路径,验证保留失败及之前已提交 receipt 和 details。这是 reader 覆盖,不是内部生产 +证据。复用既有 Todo lifecycle helper 与原生 TypeScript 匹配,在同一工作上同时放置 +指定 custom 审批、另一 custom requirement 和命名权限 requirement。只允许消费 +匹配的 custom scope。宽 custom scope 不能覆盖任何命名 kind,非法输入也不能变成 +自定义 authority。 + +registry 仍为 26 项,values/owners 不变;预算、形式证明域、wire schema、runtime +默认行为和存储格式均不变。测试不能批准这些处置。#4447 最终验收仍需 owner 评审并 +集成本提案、当前来源清单,以及最终集成 revision 上明确 passed/failed/untested 的 +证据。旧的 unresolved 记录保留历史意义,不追改成当时已有本决定。 diff --git a/docs/architecture/rfcs/semantic-vocabulary-convergence-v0.md b/docs/architecture/rfcs/semantic-vocabulary-convergence-v0.md index b6405d5528..a344bd60a7 100644 --- a/docs/architecture/rfcs/semantic-vocabulary-convergence-v0.md +++ b/docs/architecture/rfcs/semantic-vocabulary-convergence-v0.md @@ -1267,6 +1267,9 @@ owners and approval requirements. ## Appendix A: Execution ledger (non-normative) +- Reserved-value and custom-scope disposition proposal: [cancelled / other](ledger/semantic-vocabulary-convergence-v0/2026-10-12-reserved-value-disposition.md). + + New entries are files, not sections here. Each one lives in [`ledger//`](ledger/README.md) as `YYYY-MM-DD-slug.md` with a Chinese mirror beside it, and nothing enumerates them — the directory listing is the index. diff --git a/docs/architecture/rfcs/semantic-vocabulary-convergence-v0.zh-CN.md b/docs/architecture/rfcs/semantic-vocabulary-convergence-v0.zh-CN.md index e5237b33ca..b86774a8c7 100644 --- a/docs/architecture/rfcs/semantic-vocabulary-convergence-v0.zh-CN.md +++ b/docs/architecture/rfcs/semantic-vocabulary-convergence-v0.zh-CN.md @@ -1012,6 +1012,9 @@ PR review 保留这些层级。普通改动记录检查范围和理由,无共 ## 附录 A:执行账本(非规范) +- 保留值与自定义 scope 处置提案:[cancelled / other](ledger/semantic-vocabulary-convergence-v0/2026-10-12-reserved-value-disposition.zh-CN.md)。 + + 新条目是文件,不再写在这一节里。每条放在 [`ledger//`](ledger/README.zh-CN.md) 下,命名 `YYYY-MM-DD-slug.md` 并配 一份中文镜像;目录名即所属 RFC 文件名;跨 RFC 不设枚举——每个 RFC 的目录就是它的索引。 diff --git a/docs/reference/glossary.md b/docs/reference/glossary.md index 3abbf0a74d..bd036163ab 100644 --- a/docs/reference/glossary.md +++ b/docs/reference/glossary.md @@ -174,6 +174,7 @@ Typed failure class of a settlement step. - python: [`SettlementFailureKind`](../../loopx/control_plane/effect_program.py). - typescript: [`SETTLEMENT_FAILURE_KINDS`](../../loopx/control_plane/effect_program.ts). - Values / 值: `invalid_identity`, `receipt_missing`, `receipt_unbound`, `identity_mismatch`, `writeback_missing`, `writeback_rejected`, `quota_spend_rejected`, `terminal_closeout_rejected`, `cancelled`, `permission_denied`, `budget_rejected`, `effect_outcome_unknown`. +- Compatibility only / 兼容保留: `cancelled`. ## settlement_step_kind diff --git a/docs/reference/protocols/decision-scope-v0.md b/docs/reference/protocols/decision-scope-v0.md index 7aec00be05..3f656d9cac 100644 --- a/docs/reference/protocols/decision-scope-v0.md +++ b/docs/reference/protocols/decision-scope-v0.md @@ -27,6 +27,31 @@ Attached to a user todo, operator gate, or controller decision. | `expires_at` | no | Optional ISO timestamp for temporary authority. | | `reason_summary` | no | Public-safe one-line reason shown in status/UI. | +### Choosing `kind=other` + +Choose a named kind whenever it describes the decision. `other` is an explicit +custom decision category for an owner-defined approval that none of the six +named kinds describes. It is not an unknown-input fallback, uncertainty marker, +or shortcut around a named permission boundary. An author who cannot identify +the decision should clarify the requirement instead of guessing `other`. + +Describe what the owner must decide in the existing Todo text, and use a stable, +public-safe `scope_key` for that decision on both the user gate and its dependent +Agent work. For example, an owner-required approval of a study's conflict-of-interest +declaration may use `other:action:study_conflict_review`. Reading private data, +spending resources, changing direction and publishing the study still require +their named kinds; this custom approval covers none of them. Another custom +review, `other:action:study_method_review`, remains a different scope key. + +The existing key/granularity, recipient and exact-target rules still apply. +`other:global:*` remains an explicit broad **other-kind** scope; it cannot cover +`private_read`, `write_scope`, `resource`, `production`, `public_claim` or +`direction`. Broad scopes require the same owner declaration as all other kinds. +An unrecognized kind, including a misspelling such as `othre`, rejects at the +input boundary. Matching describes a dependency; actual approval consumption +continues through the existing linked-gate completion or standing-receipt owner. +No new metadata, automatic classifier or permission is introduced. + ### `required_decision_scopes` Attached to an agent todo, next action, handoff packet, or candidate runtime diff --git a/loopx/semantics/vocabulary_v0.json b/loopx/semantics/vocabulary_v0.json index 5b9a4df1d2..b66aaa51fe 100644 --- a/loopx/semantics/vocabulary_v0.json +++ b/loopx/semantics/vocabulary_v0.json @@ -589,10 +589,16 @@ "writeback_rejected": "Raised when the durable_writeback callback ran and returned a payload that is not a committed result, and in the task-lease verbs when a failure arose past the validation stage with a code outside the identity and permission sets. The writeback was attempted and refused, as opposed to writeback_missing where it was never observed.", "quota_spend_rejected": "Raised when a settlement callback returned a non-committed payload on a step that is neither durable_writeback nor terminal_closeout, and the rejection reason text does not contain the word budget. It is the same branch as budget_rejected, separated only by that substring test.", "terminal_closeout_rejected": "Raised on three branches: the terminal_closeout callback returned a non-committed payload; closeout was required but the turn's result kind is set and is not validated_completion, so there is no completed turn to close out; or a closeout payload was supplied when closeout was not required at all.", - "cancelled": "Unresolved: the value is declared in both owner modules and admitted by the decoders, but no branch under loopx/ selects it. A search of the literal, the enum member form and member value reads finds only the two definition sites; every other occurrence of the token in the tree belongs to an unrelated vocabulary such as the chat proposal status, the subagent execution status or a CI conclusion, and the only settlement-shaped uses are tests that fabricate it to check a failure is not erased. Missing evidence: a producing branch, or a compatibility_only declaration saying it is reserved for an external or future canceller. Because this vocabulary declares no producers and is therefore outside the F2 liveness check, a dead value here is not caught by the drift smoke, so whether this is reserved or actually dead is an open question for a maintainer.", + "cancelled": "Reader-reserved settlement failure input. Both decoders preserve an explicitly supplied cancelled result and bind stops before the next callback while retaining prior receipts. No built-in settlement branch currently produces this value; host/chat/lease cancellation belongs to separate contracts. This reservation preserves the admitted wire value, not evidence of historical use, effect absence, cancellation completion or permission to replay. See the reserved-value disposition in the semantic RFC ledger.", "permission_denied": "Raised when a task-lease verb was refused by an authority check before any durable write: the failure code is corrupt_lease or one of the registered owner and Todo eligibility codes, or a promoted-authority fence rejected a legacy writer. The invariant is that it was denied at the validation stage and nothing was written. These sites emit the bare string rather than a typed member, so the value is only checked when a decoder parses the envelope.", "budget_rejected": "Raised on the same branch as quota_spend_rejected, a non-committed callback payload on a step that is neither durable_writeback nor terminal_closeout, and separated from it only by the derived reason text containing the word budget, case-insensitively. That is a substring test on free-form callback text, not a structured budget signal, so a budget refusal phrased without the word is reported as quota_spend_rejected instead.", "effect_outcome_unknown": "Raised when the effect was prepared and dispatched but its outcome cannot be decided either way: the provider's observation for the step is explicitly unknown, or an observation exists that is neither unknown nor a committed payload, meaning the adapter never durably checkpointed it. It is the only failure kind asserting no outcome; every other kind asserts a known one, which is why it must neither be retried nor declared done." + }, + "compatibility_only": { + "cancelled": { + "reason": "Keep the already admitted settlement failure input readable without inventing an internal cancellation producer. A supplied cancelled envelope remains a failure and preserves prior receipts; it does not prove an in-flight effect stopped or can safely be retried.", + "retirement": "Replace this reservation only when the settlement owner adopts a typed cancellation contract with before-dispatch and ambiguous-after-dispatch witnesses and updates this source treatment, or removes the value through an explicit versioned reader migration with caller and stored-envelope compatibility evidence. No automatic expiry or vocabulary deletion." + } } }, "receipt_bound_monitor_phase": { @@ -893,7 +899,7 @@ "production": "Author-declared and only membership-checked. It names the production-action authority in the decision catalogue, the decision to actually run the producing action rather than to authorise its scope.", "public_claim": "Author-declared and only membership-checked. It names the public-submission authority in the decision catalogue, the decision to put a claim outside the system where it cannot be silently withdrawn.", "direction": "Author-declared and only membership-checked. It names the product-direction authority in the decision catalogue, the choice of what to aim at; the capability paths that assert an expected scope literal all expect this kind at action granularity.", - "other": "Unresolved: no branch selects it, nothing falls back to it, and a kind outside the set is rejected rather than coerced to it, so it is not a code-level catch-all; it is only an accepted member an author may write, and like every kind it covers nothing but an identical requirement. Missing evidence: any spec, schema description or authoring rule saying when an author should choose other rather than one of the six named kinds. The protocol reference lists it in the enum without a definition and the catalogue gloss enumerates only the other six." + "other": "Explicitly author-selected custom decision authority when none of private_read, write_scope, resource, production, public_claim or direction describes the decision. The owning task explains the decision and uses a stable public-safe scope_key shared by its gate and dependents; prefer a named kind whenever it applies. Unknown kinds reject rather than normalize to other. Coverage still requires identical kind and the existing key/granularity/recipient/target rules: even other:global:* cannot cover a named kind. Selecting other never itself grants permission. See decision-scope-v0.md for authoring examples." } }, "todo_decision_scope_granularity": { diff --git a/tests/architecture/test_settlement_receipt_source_boundaries.py b/tests/architecture/test_settlement_receipt_source_boundaries.py index 9b48ac3b76..57a5283e51 100644 --- a/tests/architecture/test_settlement_receipt_source_boundaries.py +++ b/tests/architecture/test_settlement_receipt_source_boundaries.py @@ -548,3 +548,28 @@ def test_terminal_compatibility_adapter_keeps_replay_semantics( ).value == expected ) + + +def test_reserved_cancelled_envelope_retains_prior_commits_and_failure_details(identity): + """Reader compatibility, deliberately not a cancellation producer witness.""" + envelope = { + "value": None, + "receipts": [ + {"step_kind": "durable_writeback", "status": "committed", + "effect_id": identity["effect_id"], "source_ref": "writeback:prior"}, + {"step_kind": "quota_spend", "status": "committed", + "effect_id": identity["effect_id"], "source_ref": "spend:prior"}, + ], + "failure": {"kind": "cancelled", "step_kind": "terminal_closeout", + "reason": "caller-supplied cancellation report", + "details": {"observation_ref": "caller:cancel-1"}}, + } + before = json.dumps(envelope, sort_keys=True) + observed = effect_runtime_result("settlement.bind_gate", {"result": envelope}) + assert observed == {"execute": False, "result": envelope} + value, receipts, failure = decode_settlement_result_payload(observed["result"]) + assert value is None + assert [receipt.step_kind.value for receipt in receipts] == ["durable_writeback", "quota_spend"] + assert failure.kind.value == "cancelled" + assert failure.details == {"observation_ref": "caller:cancel-1"} + assert json.dumps(envelope, sort_keys=True) == before diff --git a/tests/control_plane/test_todo_decision_scope_lifecycle.py b/tests/control_plane/test_todo_decision_scope_lifecycle.py index cb590ede2d..e47973ccd8 100644 --- a/tests/control_plane/test_todo_decision_scope_lifecycle.py +++ b/tests/control_plane/test_todo_decision_scope_lifecycle.py @@ -365,3 +365,45 @@ def test_user_gate_completion_requires_explicit_decision_outcome( assert state.read_text(encoding="utf-8") == before assert _todo(state, target["todo_id"])["status"] == "blocked" + + +def test_custom_gate_consumes_only_its_explicit_custom_requirement(tmp_path: Path) -> None: + from loopx.cli import main + from contextlib import redirect_stdout + from io import StringIO + + _, state, registry = _write_fixture(tmp_path) + custom = "other:action:study_conflict_review" + unrelated = "other:action:study_method_review" + named = "public_claim:action:publish_study" + target = add_goal_todo(registry_path=registry, goal_id=GOAL_ID, role="agent", + text="Prepare a study with separate owner reviews and publication permission.", + task_class="advancement_task", claimed_by=AGENT_ID, + required_decision_scopes=[custom, unrelated, named]) + common = ["--registry", str(registry), "--runtime-root", str(tmp_path / "runtime"), + "--format", "json", "todo"] + output = StringIO() + with redirect_stdout(output): + code = main([*common, "add", "--goal-id", GOAL_ID, "--role", "user", + "--text", "Review the study conflict-of-interest declaration.", + "--task-class", "user_gate", "--blocks-agent", AGENT_ID, + "--decision-scope", custom, "--unblocks-todo-id", target["todo_id"]]) + assert code == 0 + gate = json.loads(output.getvalue()) + assert _todo(state, gate["todo_id"])["decision_scope"]["kind"] == "other" + result = complete_goal_todo(registry_path=registry, goal_id=GOAL_ID, + todo_id=gate["todo_id"], role="user", decision_outcome="approve", + evidence="Owner approved the conflict-of-interest declaration only.") + resolution = result["decision_scope_resolution"] + assert [(scope["kind"], scope["scope_key"]) for scope in resolution["resolved_required_decision_scopes"]] == [ + ("other", "study_conflict_review")] + expected = [("other", "study_method_review"), ("public_claim", "publish_study")] + assert [(scope["kind"], scope["scope_key"]) for scope in resolution["remaining_required_decision_scopes"]] == expected + saved = _todo(state, target["todo_id"]) + assert [(scope["kind"], scope["scope_key"]) for scope in saved["required_decision_scopes"]] == expected + before = state.read_bytes() + with pytest.raises(ValueError, match="decision_scope"): + add_goal_todo(registry_path=registry, goal_id=GOAL_ID, role="user", + text="Unknown kind must not become custom authority.", task_class="user_gate", + blocks_agent=AGENT_ID, decision_scope="othre:action:study_conflict_review") + assert state.read_bytes() == before diff --git a/tests/control_plane_ts/decision_scope.test.ts b/tests/control_plane_ts/decision_scope.test.ts index 29d3154c73..b9f759ca6e 100644 --- a/tests/control_plane_ts/decision_scope.test.ts +++ b/tests/control_plane_ts/decision_scope.test.ts @@ -96,3 +96,17 @@ test("gate scope projection qualifies every addressed live dependency without gr ]) assert.equal(project([{...independent, ...patch}]).state, "clear"); assert.equal(project([{...independent, status: "blocked"}]).state, "independent"); }); + +test("an explicitly authored other scope is custom, never an unknown or cross-kind wildcard", () => { + const custom = {kind: "other", granularity: "action", scope_key: "study_conflict_review"}; + assert.equal(decisionScopeCovers(custom, custom), true); + assert.equal(decisionScopeCovers(custom, {...custom, scope_key: "study_method_review"}), false); + const broad = {...custom, granularity: "global", scope_key: "*"}; + assert.equal(decisionScopeCovers(broad, custom), true); + for (const kind of ["private_read", "write_scope", "resource", "production", "public_claim", "direction"]) { + assert.equal(decisionScopeCovers(broad, {...custom, kind}), false); + assert.equal(decisionScopeCovers({...broad, kind}, custom), false); + } + assert.equal(decisionScopeCovers({...broad, kind: "othre"}, custom), false); + assert.equal(decisionScopeCovers(broad, {...custom, kind: "othre"}), false); +});