diff --git a/docs/reference/glossary.md b/docs/reference/glossary.md index d290f3298..03a1fd41e 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 d00e8c73b..d919dc9b9 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 d27d934d1..868727ef3 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 c59be74f2..373b76ba1 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 1805be97f..df27a6d43 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 17ad1ec43..f639b155f 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,