fix(quota): give an unbound same-turn receipt its own failure kind - #4893
Conversation
The readback refused a Turn whose guard receipt carries no settlement binding with identity_mismatch, so a consumer had to read details.binding_kind to tell "the receipt exists but declares no binding yet" from "two durable records disagree". Those are different states with different repairs, and the documented wake order (guard, then select) produces the first one routinely. Add receipt_unbound to the cross-runtime settlement_failure_kind vocabulary (Python enum, TypeScript list, vocabulary value plus its producing-condition note, regenerated glossary) and return it from the readback unbound branch. The failure type union in settlement_readback.ts gains the kind, and the typed details stay as they are; no new binder is introduced and identity_mismatch keeps naming the two-record case. Signed-off-by: huangruiteng <14976749+huangruiteng@users.noreply.github.com>
huangruiteng
left a comment
There was a problem hiding this comment.
Approval conclusion (author-owned PR; GitHub blocks formal self-approval)
Exact head: ca1767724eaa97371d1cd966248604c57112a078
动机
这次修复针对的是一个真实的诊断语义错误:同一 Turn 的 guard receipt 已经存在,但既没有 todo_id,也没有 autonomous replan binding。旧实现把这种“还没有可比较身份”的状态归类为 identity_mismatch,会让调用方和运维人员误以为有两条互相冲突的身份记录,并沿错误方向排查。真正需要的恢复动作是重新完成 selection/binding,而不是处理一个不存在的 identity conflict。
我对照了 base c4314d9b06a52073e12ec64fb76ff42e486bdc7e 与本 head,并检索了 SettlementFailureKind、identity_mismatch、序列化/反序列化和 Turn executor/driver 消费路径。这里不应只改报错文案:既然上层会按 failure.kind 做机器处理,最小完整方案就是在既有跨运行时 vocabulary 中增加一个精确的 typed kind。
改动思路
设计边界是正确的:不新增 receipt 状态、不改变 selection/quota/settlement authority,只从 canonical guard receipt 派生更精确的诊断结果。
receipt_unbound表示单条 receipt 没有任何 work binding;identity_mismatch继续表示 receipt 和请求两边都有具体身份、但彼此不一致;- Python/TypeScript enum、语义 vocabulary 和 glossary 同步扩展,避免跨运行时漂移;
- 修复分支仍然是只读 readback,没有写 Todo、花 quota 或改 writer fence 的副作用。
最强反对意见是:为了一个分支增加新 enum 会不会让消费者碎片化。这里答案是否定的,因为新值进入的是既有 SettlementFailureKind owner,并且所有 contract mirror 同步更新;只改 reason 文本反而会留下机器语义不准确的问题。
具体改动
文件职责和关键路径如下:
loopx/control_plane/quota/settlement_readback.ts:在 unbound guard 分支返回receipt_unbound;相邻的 concrete-binding mismatch 分支保持identity_mismatch,没有扩大新分类的覆盖面。loopx/control_plane/effect_program.ts/.py:给共享SettlementFailureKind增加同名值;既有 decoder 仍会拒绝未知/畸形值。loopx/semantics/vocabulary_v0.json与docs/reference/glossary.md:明确“无 binding”与“两条 binding 冲突”的语义差异及恢复动作。tests/control_plane_ts/quota_settlement_readback.test.ts:正向覆盖 unbound receipt,负向保留 another-Todo mismatch,并继续覆盖 malformed/missing/settled 路径。
正向 walkthrough:同一 Turn receipt 可归因但无 Todo/replan binding → readback 命中 unbound predicate → 返回 failure.kind=receipt_unbound 与重新 selection/binding 的 remediation → 不产生任何状态写入。
负向 walkthrough:receipt 已绑定到另一个 Todo → 两侧 concrete identity 被比较 → 仍返回 identity_mismatch;畸形 receipt 则在 schema/readback 边界先 fail closed,不会被当作“未绑定”。
对主干的风险
未发现阻塞实现问题。风险主要有两类:
- additive enum 的消费者兼容性:已通过 Python/TypeScript vocabulary、production/architecture 与 Turn caller 测试覆盖;没有 persisted-state migration。
- 合并态风险:当前分支
BEHINDmain;exact-head 的kernel-static-checks在 30 分钟被取消,导致checks/pytest/merge-gate聚合项红。实际执行的 4 个 test shard、两组 e2e、mutants、installed、Windows、Node forward/minimum、dashboard、PostgreSQL 等均为绿,但这仍是 merge hold,不能拿局部绿灯替代一次完整 required-check rerun。
我在独立 detached worktree 上验证:
quota_settlement_readback.test.ts:47 passed;- 选定的 cross-runtime vocabulary / semantic production / receipt boundary / Turn executor-driver Python suites:390 passed;
npx tsc --project tsconfig.control-plane.json --noEmit:通过;git diff --check:通过。
我的整体评价
APPROVE(实现结论),但暂不具备合并条件。这个 PR 用现有 typed owner 修正了一个会误导恢复路径的真实语义缺口,范围小、无额外 authority、正负路径清楚,也没有把诊断变成第二份持久状态。合并前必须先更新到最新 main,并让 required checks 在新 exact head 上完整跑完;取消的 kernel-static/红色聚合项不能被 review 结论豁免。
未来导向检查:本次已经完成最有价值且相关的边界收敛——复用共享 failure vocabulary,没有必要再引入新抽象。
English verdict: APPROVE - Exact-head implementation is sound and focused; merge remains on hold until the branch is updated and all required checks complete successfully on the new head.
Goal / gap
quotasettlement readback refused a Turn whose guard receipt carries no settlement binding withkind: "identity_mismatch"plusdetails.binding_kind: "unbound". A consumer therefore had to read insidedetailsto tell two different states apart:identity_mismatchmeans the second. PR #4595 had already typed the details; the failure kind itself stayed shared, soeffect_program'sSettlementFailureKindcould not express the state it was reporting.Change
loopx/semantics/vocabulary_v0.json:receipt_unboundjoins the canonicalcross_runtimesettlement_failure_kindvocabulary, with a value note that states the producing condition (a well-formed identity compared against a Turn receipt that declares no binding of its own) and contrasts it withidentity_mismatch.loopx/control_plane/effect_program.py/.ts: the enum and the literal list carry the value.docs/reference/glossary.md: regenerated byuv run python scripts/generate_semantic_bindings.py.loopx/control_plane/quota/settlement_readback.ts: the unbound branch returnsreceipt_unbound; the failure-type union and the typeddetails(binding_kind: "unbound", requested binding kind, turn id) are unchanged.tests/control_plane_ts/quota_settlement_readback.test.ts: the unbound assertion moves to the new kind.No new binder, no authority change, and
identity_mismatchstill names the two-record case. This is the (a) half of the settlement-binding signalling left by PR #4595; the guard payload already reportssettlement_binding_owed(b), and the spend-path message (c) is unchanged.Validation
node --experimental-strip-types --test tests/control_plane_ts/quota_settlement_readback.test.ts→ 47 passed.tsc --project tsconfig.control-plane.json --noEmit→ clean.uv run --extra test python -m pytest tests/architecture/test_cross_runtime_value_notes.py tests/architecture/test_semantic_vocabulary_drift.py tests/architecture/test_semantic_production.py -q→ 193 passed (the drift test is what requires the regenerated glossary).uv run --extra test python -m pytest tests/architecture/test_settlement_receipt_source_boundaries.py tests/test_loopx_turn_executor.py tests/test_loopx_turn_driver.py -q→ 197 passed.npm run test:control-plane: 2366 pass / 29 fail — all 29 are the pre-existing worktree-environment class (python3resolving to macOS CommandLineTools 3.9, which rejectsdataclass(slots=True)inloopx/file_lock.py), unrelated to this surface.Interaction with #4890
#4890 (open) edits the message of this same unbound branch; this PR edits its kind. Both touch
failedIdentity's call, so whichever merges second needs a small rebase — the two changes are independent and complementary (name the repair, and type the state).Control-plane change: proposed for review, not self-merged.