Skip to content

ADR-0008: Settle window, compensation from wallet truth, work-queue policy

Status: Accepted · Date: 2026-10-19 · Resolves: OQ-2

Context

A timed-out wallet call may land later. We do not know whether the wallet abandons a request when the client disconnects (OQ-2). Workers need one rule for when "not found" is final, and one retry policy.

Decision

T_settle = 30 s (A4). A lookup that finds nothing is final only when at least T_settle has passed since the last call that could have moved money for this payment. The design works whether the wallet abandons or completes on disconnect, so OQ-2 does not need an answer. Any landing after T_settle is a violation of A4 and is surfaced by the reconciler (ADR-0011 event ReconciliationDriftDetected).

UNKNOWN is never persisted. It is an in-memory classification inside one request. After a request completes, a payment is DEBITED, HELD, STANDIN_APPROVED, DECLINED or REVERSAL_PENDING. PENDING survives only while the request runs, or after the instance crashed mid-request.

Compensation reads the wallet's truth, not our state. One Compensator per decision mode, used by both RESOLVE_UNKNOWN and REVERSE work items:

Mode Steps
DIRECT_DEBIT, STAND_IN Look up the debit by reference = paymentId. Found → POST /credits {reference: rev-{paymentId}} → REVERSED. Not found and past T_settle → REVERSED, no credit. Not found within T_settle → reschedule to T_settle.
HOLD_CAPTURE Find the hold (holdId if stored, otherwise by reference). HELD → release (release:{paymentId}) → REVERSED. CAPTURED → v1 credit rev-{paymentId} → REVERSED. RELEASED / EXPIRED / not found past T_settle → REVERSED.
  • RESOLVE_UNKNOWN is enqueued with next_attempt_at = decided_at + T_settle.
  • REVERSE is enqueued with next_attempt_at = now when a wallet id (wallet_debit_id or wallet_hold_id) is stored, otherwise now + T_settle.
  • Both kinds may exist for one payment. Compensation is idempotent, so running both is safe.
  • When a payment becomes REVERSED, every reversal row for it becomes COMPLETED in the same transaction.

Stale request sweeper. A payment still PENDING 10 s after deadline_at belongs to a crashed request. In one transaction: payment → REVERSAL_PENDING (decline_reason = WALLET_TIMEOUT), idempotency key → COMPLETE with the DECLINED WALLET_TIMEOUT retryable response, outbox PaymentDeclined, work item RESOLVE_UNKNOWN. It runs every 5 s.

Work-queue policy (property defaults under safeledger.work-queue):

Setting Value
Poll interval / batch size 500 ms / 10
Claim lease 60 s. A CLAIMED row older than the lease is claimable again
Backoff full jitter: random(0, min(5 min, 1 s × 2^attempts))
Max attempts 12. Then DEAD (worst case ≈ 24 min of retrying)
Success DONE
Rejected (permanent, e.g. 404 / 423 on a credit) DEAD immediately, last_error = code
Ambiguous / Unavailable attempts + 1, reschedule with backoff
Item for a payment whose state no longer needs it DONE without a wallet call

Consequences

  • + One compensation path for till reversals, ambiguous declines, crashed requests and stand-in.
  • + Races between capture, posting and reversal are safe, because compensation acts on what the wallet says happened.
  • − Customers wait ≥ 30 s for an ambiguous-decline refund. That is acceptable: no money moved in most cases, and the till was told DECLINED.
  • Revisit trigger: the Wallet team states a different settle guarantee, or drift events show landings after 30 s.