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_UNKNOWNis enqueued withnext_attempt_at = decided_at + T_settle.REVERSEis enqueued withnext_attempt_at = nowwhen a wallet id (wallet_debit_idorwallet_hold_id) is stored, otherwisenow + T_settle.- Both kinds may exist for one payment. Compensation is idempotent, so running both is safe.
- When a payment becomes
REVERSED, everyreversalrow for it becomesCOMPLETEDin 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.