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
| # | Mode | What it means | How it is detected | Disposition | Why that disposition |
|---|---|---|---|---|---|
| 1 | REVOKED | The customer (or the spender) revoked the permission on-chain. | isRevoked() is true. packages/chain/src/classify.js:25 | Terminal | Revocation is permanent in the contract. No retry can succeed; the merchant needs a new signature. |
| 2 | EXPIRED | The permission's end has passed. | now ≥ end. packages/chain/src/classify.js:30 | Terminal | The window is closed forever. Same remedy: a new permission. |
| 3 | NOT_STARTED | The permission's start is still in the future (a trial, or a future-dated plan). | now < start. packages/chain/src/classify.js:33 | Deferred to start | It will succeed at a known instant and cannot succeed before it. Retrying earlier is waste. |
| 4 | NOT_APPROVED | Signed, but never registered on-chain. | isApproved() is false. packages/chain/src/classify.js:43 | Deferred | The cure is submitting approveWithSignature, not retrying the charge. Retainer can do that gaslessly for the customer. |
| 5 | ALLOWANCE_EXHAUSTED | This period's cap is already used up, or the amount exceeds what remains. | allowance − getCurrentPeriod().spend < amount. packages/chain/src/classify.js:53 | Deferred to the period end | The allowance resets only at the boundary and unused allowance does not carry forward, so the next possible success is exactly then. |
| 6 | INSUFFICIENT_BALANCE | The cap allows it; the customer's account cannot fund it. | USDC balanceOf(account) < amount. packages/chain/src/classify.js:64 | Retrying with backoff | The 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
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 minutesThree 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)