Repository navigation
[Bug]: a missing settlement binding is reported against the current Turn even when an earlier Turn owes its closeout #6110
Description
A missing settlement binding is reported against the current Turn even when an earlier Turn owes its closeout
Affected version: release 20261010T034436Z — loopx/control_plane/quota/settlement_readback.ts, the receipt_unbound refusal in resolveIdentity().
Impact: one unsettled Turn wedges every later Turn, and the message that explains it points at the wrong Turn. refresh-state and quota spend-slot keep failing with receipt_unbound for the current Turn, whose suggested repair (rebind through the guard) only replays, so an operator has no way forward from the message alone. Recovery is only possible after reading unsettled_host_turn_recovery.prior_turn_instance_id out of the guard payload by hand.
What happens
The documented wake order runs the guard before a work item is chosen, so a guard can commit a receipt with no settlement binding — that state is already handled on purpose (receipt_unbound + a named rebind command). There is a third state with the same receipt shape:
- Turn A runs
quota should-run --turn-instance-id A(no--todo-id) and is then settled under a different id (or not settled at all). - Turn A now owes
durable_writeback_receipt+quota_spend_receipt. - Every later Turn's guard answers
unsettled_host_turn_recovery:
{"state": "recovery_required",
"reason_code": "required_closeout_receipt_missing",
"prior_turn_instance_id": "2026-10-10T09:44:29Z",
"binding_id": "todo_06ebeb784d00",
"missing_receipts": ["durable_writeback_receipt", "quota_spend_receipt"]}refresh-state/spend-slotfor the current Turn fail with:
the quota should-run receipt for this turn carries no settlement binding yet
(turn_instance_id 2026-10-10T09:44:43Z); rebind it through the guard's same-turn
reconciliation, then settle: quota should-run --turn-instance-id
2026-10-10T09:44:43Z --todo-id todo_06ebeb784d00
- Executing that instruction returns the recovery decision again (
status: replayed), and never binds. The command that actually works is in the guard's compiled contract, not in any error:
interaction_contract.cli_channel.next_cli_actions = [
"... never fabricate progress to clear recovery",
"loopx ... quota should-run ... --turn-instance-id 2026-10-10T09:44:29Z --todo-id todo_06ebeb784d00",
"after the original Turn is legally settled, rerun the current Turn below; recovery itself does not spend quota",
"loopx ... quota should-run ... --turn-instance-id <current-turn> --todo-id ..."]The guard's own receipt_unbound comment already states the principle ("Naming the wrong command costs the caller a turn"), and this state is exactly that cost: the refusal names the current Turn's rebind, which cannot bind while an earlier Turn is owed.
Reproduction
quota should-run --turn-instance-id A --todo-id <todo>(records the Turn), then settle a different Turn id (or settle nothing).quota should-run --turn-instance-id B --todo-id <todo>→unsettled_host_turn_recovery,settlement_binding_owed: true.refresh-state/quota spend-slotfor B → thereceipt_unboundrefusal above, and repeating the suggested rebind only replays.
Verified against a live goal: the owed Turn was …09:44:29Z, the reporting Turn …09:44:43Z; settling the prior Turn with its own id cleared recovery in three commands.
Local fix (branch fix/quota-receipt-unbound-recovery-hint, commit 52dc3aa3a)
Keep the existing refusal and repair, and add the state it cannot cover: the guard can replay the recovery instead of binding, and the repair for that is the Turn named by the guard's own compiled actions.
const RECOVERY_ACTIONS_REF = "interaction_contract.cli_channel.next_cli_actions";
const RECOVERY_ACTIONS_LABEL = `the guard's ${RECOVERY_ACTIONS_REF}`;
...
const recoveryNote =
"; if that guard replayed at unsettled_host_turn_recovery instead of " +
"binding, an earlier Turn of this goal owes its closeout: settle the Turn " +
`named by ${RECOVERY_ACTIONS_LABEL} first, then settle this Turn`;
return failedIdentity(..., "receipt_unbound",
{ binding_kind: "unbound", requested_binding_kind: identity.binding_kind,
turn_instance_id: turnInstanceId,
recovery_actions_ref: RECOVERY_ACTIONS_REF, ... });tests/control_plane_ts/quota_settlement_readback.test.ts asserts the new sentence and detail field for both unbound states; node --experimental-strip-types --test tests/control_plane_ts/quota_settlement_readback.test.ts → 125 pass, and the adjacent settlement suites (turn_settlement, quota_settlement_plan, causal_blocked_wait, unsettled_host_turn_recovery) → 100 pass.
Follow-up options (not in that patch)
- Name the prior Turn id in the failure details by reusing
preflightPriorHostTurnCloseout()(it already returnscandidate.prior_turn_instance_id+missing_receipts).readQuotaSettlementFromRequestis synchronous and also called fromreadQuotaSettlementFromSnapshot, which two other modules consume, so this needs a deliberate async seam rather than a local edit. - Consider surfacing a typed hint when a guard commits a receipt with no binding at all, since that is the entry point to this state and it is currently silent.
Activity
fix(quota): name the earlier Turn that owes closeout in receipt_unbound