Retainer/DocsBASE SEPOLIA

The six failure modes

Every failed charge is classified from chain state before any gas is spent, and each class has its own disposition. This is the product's core idea, not an error appendix.

A card decline is one thing that happened, retried by one policy. A spend-permission charge can fail for six distinguishable reasons, and they call for different responses: some can never succeed again, some can only succeed at a known future instant, and one is worth retrying soon. Collapsing them into “failed” throws away exactly the information a merchant needs. Retainer keeps them apart from the first line of the schema. failure_mode enum

Classification order

The classifier reads chain state in a fixed order and returns the first condition that holds. The order matters: a revoked permission is revoked whatever else is true of it; an expired one is expired even if it was never registered.packages/chain/src/classify.js:25

#ModeWhat it meansHow it is detectedDispositionWhy that disposition
1REVOKEDThe customer (or the spender) revoked the permission on-chain.isRevoked() is true. packages/chain/src/classify.js:25TerminalRevocation is permanent in the contract. No retry can succeed; the merchant needs a new signature.
2EXPIREDThe permission's end has passed.now ≥ end. packages/chain/src/classify.js:30TerminalThe window is closed forever. Same remedy: a new permission.
3NOT_STARTEDThe permission's start is still in the future (a trial, or a future-dated plan).now < start. packages/chain/src/classify.js:33Deferred to startIt will succeed at a known instant and cannot succeed before it. Retrying earlier is waste.
4NOT_APPROVEDSigned, but never registered on-chain.isApproved() is false. packages/chain/src/classify.js:43DeferredThe cure is submitting approveWithSignature, not retrying the charge. Retainer can do that gaslessly for the customer.
5ALLOWANCE_EXHAUSTEDThis period's cap is already used up, or the amount exceeds what remains.allowance − getCurrentPeriod().spend < amount. packages/chain/src/classify.js:53Deferred to the period endThe allowance resets only at the boundary and unused allowance does not carry forward, so the next possible success is exactly then.
6INSUFFICIENT_BALANCEThe cap allows it; the customer's account cannot fund it.USDC balanceOf(account) < amount. packages/chain/src/classify.js:64Retrying with backoffThe customer may top up at any moment. A reverted charge consumes no allowance, so retrying is safe against the cap; it is capped by gas instead.

The dispositions, precisely

From dispositionFor in the charger: apps/worker/src/charger.js:4

apps/worker/src/charger.js — dispositionFor, abridged
REVOKED, EXPIRED            -> failed_terminal   (no next attempt)
INSUFFICIENT_BALANCE        -> failed_retryable  next = now + min(3600, 30 * 2^(attempts-1)) seconds
                               after 8 attempts  -> failed_terminal
ALLOWANCE_EXHAUSTED         -> failed_deferred   next = current period's end
NOT_STARTED                 -> failed_deferred   next = permission.start
NOT_APPROVED                -> failed_deferred   next = now + 5 minutes (pending registration)
UNKNOWN                     -> failed_retryable  next = now + 5 minutes

Three of the six carry a time, and it is shown on the dashboard because it is the whole content of the deferred promise: not “we'll try again” but “we'll try at 2026-09-08T21:31:09Z, because that is when it can work.”

When the pre-flight is wrong

State can change between simulation and inclusion — a revocation mined in the same block, a transfer out of the account. Then the transaction reverts on-chain and the classifier falls back to decoding the revert: the manager's custom error selectors are pinned by a Foundry test so an upstream change cannot silently break decoding, and Circle's USDC — which reverts with a plain string, not a custom error — is why insufficient balance must be caught by state rather than by selector.contracts/test/retainer/ErrorSelectors.t.sol packages/chain/src/errors.js

Each mode, triggered deliberately

In the Phase 1 drills every mode was forced on Base Sepolia and classified without a transaction being sent — six failures, zero gas. The dashboard's Failures view shows those six rows grouped by disposition, with the classifier's recorded detail (want=2000000 remaining=1000000 periodEnd=…) and the next-attempt time where one exists.

  • Terminal: charges #5 (EXPIRED) and #6 (REVOKED)
  • Deferred: #2 (ALLOWANCE_EXHAUSTED), #4 (NOT_STARTED), #7 (NOT_APPROVED)
  • Retrying: #3 (INSUFFICIENT_BALANCE, four attempts recorded)
What is deliberately not built
Nothing here re-asks the customer for anything. A terminal failure is recorded, not escalated; a cap that is too small is reported, not automatically renegotiated. Re-authorisation flows and usage forecasting are the next product decisions, and they wait on merchant conversations that have not happened.