From ca1767724eaa97371d1cd966248604c57112a078 Mon Sep 17 00:00:00 2001 From: huangruiteng <14976749+huangruiteng@users.noreply.github.com> Date: Tue, 22 Sep 2026 17:37:02 +0800 Subject: [PATCH] fix(quota): give an unbound same-turn receipt its own failure kind 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> --- docs/reference/glossary.md | 2 +- loopx/control_plane/effect_program.py | 1 + loopx/control_plane/effect_program.ts | 1 + loopx/control_plane/quota/settlement_readback.ts | 12 +++++++++--- loopx/semantics/vocabulary_v0.json | 2 ++ .../quota_settlement_readback.test.ts | 4 +++- 6 files changed, 17 insertions(+), 5 deletions(-) diff --git a/docs/reference/glossary.md b/docs/reference/glossary.md index d290f32982..03a1fd41e8 100644 --- a/docs/reference/glossary.md +++ b/docs/reference/glossary.md @@ -182,7 +182,7 @@ Typed failure class of a settlement step. - Tier / 层级: `cross_runtime`; status / 状态: `canonical`. - python: [`SettlementFailureKind`](../../loopx/control_plane/effect_program.py). - typescript: [`SETTLEMENT_FAILURE_KINDS`](../../loopx/control_plane/effect_program.ts). -- Values / 值: `invalid_identity`, `receipt_missing`, `identity_mismatch`, `writeback_missing`, `writeback_rejected`, `quota_spend_rejected`, `terminal_closeout_rejected`, `cancelled`, `permission_denied`, `budget_rejected`, `effect_outcome_unknown`. +- 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`. ## settlement_step_kind diff --git a/loopx/control_plane/effect_program.py b/loopx/control_plane/effect_program.py index d00e8c73b8..d919dc9b93 100644 --- a/loopx/control_plane/effect_program.py +++ b/loopx/control_plane/effect_program.py @@ -185,6 +185,7 @@ class SettlementBindingKind(StrEnum): class SettlementFailureKind(StrEnum): INVALID_IDENTITY = "invalid_identity" RECEIPT_MISSING = "receipt_missing" + RECEIPT_UNBOUND = "receipt_unbound" IDENTITY_MISMATCH = "identity_mismatch" WRITEBACK_MISSING = "writeback_missing" WRITEBACK_REJECTED = "writeback_rejected" diff --git a/loopx/control_plane/effect_program.ts b/loopx/control_plane/effect_program.ts index d27d934d16..868727ef3b 100644 --- a/loopx/control_plane/effect_program.ts +++ b/loopx/control_plane/effect_program.ts @@ -108,6 +108,7 @@ export type SettlementBindingKind = (typeof SETTLEMENT_BINDING_KINDS)[number]; export const SETTLEMENT_FAILURE_KINDS = [ "invalid_identity", "receipt_missing", + "receipt_unbound", "identity_mismatch", "writeback_missing", "writeback_rejected", diff --git a/loopx/control_plane/quota/settlement_readback.ts b/loopx/control_plane/quota/settlement_readback.ts index c59be74f24..373b76ba1a 100644 --- a/loopx/control_plane/quota/settlement_readback.ts +++ b/loopx/control_plane/quota/settlement_readback.ts @@ -529,7 +529,11 @@ function inferPersistedIdentity( function failedIdentity( reason: string, - kind: "invalid_identity" | "identity_mismatch" | "receipt_missing", + kind: + | "invalid_identity" + | "identity_mismatch" + | "receipt_missing" + | "receipt_unbound", details?: JsonObject, ) { return settlementFailed({ @@ -615,13 +619,15 @@ function resolveIdentity( // the guard's own same-turn reconciliation owns that, so there is one // binder rather than two -- which means the caller has to be told the state // and the exact repair instead of being handed a binding mismatch it cannot - // act on. + // act on. The state also gets its own failure kind, so a consumer can branch + // on the missing binding without reading details.binding_kind: the receipt + // exists and is well-formed here, which is not what identity_mismatch means. return failedIdentity( "the quota should-run receipt for this turn carries no settlement binding " + `yet (turn_instance_id ${turnInstanceId}); rebind it through the guard's ` + "same-turn reconciliation, then settle: quota should-run --turn-instance-id " + `${turnInstanceId} --todo-id ${identity.todo_id ?? ""}`, - "identity_mismatch", + "receipt_unbound", { binding_kind: "unbound", requested_binding_kind: identity.binding_kind, diff --git a/loopx/semantics/vocabulary_v0.json b/loopx/semantics/vocabulary_v0.json index 1805be97ff..df27a6d433 100644 --- a/loopx/semantics/vocabulary_v0.json +++ b/loopx/semantics/vocabulary_v0.json @@ -581,6 +581,7 @@ "values": [ "invalid_identity", "receipt_missing", + "receipt_unbound", "identity_mismatch", "writeback_missing", "writeback_rejected", @@ -593,6 +594,7 @@ ], "value_notes": { "invalid_identity": "A validation-step rejection of the identity's own shape, judged in isolation before any comparison with a durable record: no identity object, a blank goal, agent or turn instance id, both or neither work-item binding, a turn instance id failing the public-safe pattern, a missing effect id, or an effect id that disagrees with the one recomputed from that same identity. Self-inconsistency inside one plan, as opposed to identity_mismatch which compares two records.", + "receipt_unbound": "A settlement identity was compared with this Turn's persisted guard receipt, and that receipt declares no binding of its own: the guard committed before any work item was chosen, or it declined the explicit choice it was handed, so there is nothing to compare against yet. identity_mismatch needs two records that disagree; receipt_unbound names the single-record case where the receipt exists but carries neither a Todo nor an autonomous replan binding.", "receipt_missing": "A receipt or phase record needed to proceed is absent or not durably committed and no more specific kind applies: completed phases are not an ordered prefix of the transaction phases, validation is required but not completed, a phase marked completed carries a payload that is not committed, or readback finds no heartbeat receipt, quota spend or terminal closeout event.", "identity_mismatch": "A well-formed identity compared against a second durable record names a different effect or binding: the journal's committed effect id differs from the plan's, a heartbeat receipt's binding or settlement effect id differs from the request, or a prepared attempt's effect reference does not match the effect id and step kind. The contrast with invalid_identity is that this failure needs two records to detect.", "writeback_missing": "Raised at exactly one site, in the writeback readback, when the accountable refresh-state run or its matching event is null for this identity: the durable writeback receipt was never found. It is absence, never refusal, which is what separates it from writeback_rejected.", diff --git a/tests/control_plane_ts/quota_settlement_readback.test.ts b/tests/control_plane_ts/quota_settlement_readback.test.ts index 17ad1ec430..f639b155f1 100644 --- a/tests/control_plane_ts/quota_settlement_readback.test.ts +++ b/tests/control_plane_ts/quota_settlement_readback.test.ts @@ -371,7 +371,9 @@ test("names the unbound same-turn receipt and the repair instead of a mismatch", const result = await readQuotaSettlement(request(runtimeRoot)); const failure = (result.settlement as any).result.failure; - assert.equal(failure.kind, "identity_mismatch"); + // The receipt exists and is well-formed, so the missing binding has its own + // kind instead of reading as a mismatch against a second record. + assert.equal(failure.kind, "receipt_unbound"); assert.match(failure.reason, /carries no settlement binding yet/); assert.match( failure.reason,