Skip to content

ADR-0009: Stand-in policy, parameters and governance

Status: Accepted · Date: 2026-10-19 · Resolves: OQ-3 · Refines: ADR-0002

Context

ADR-0002 builds stand-in but leaves it off. The floor limit, exposure caps, account-standing rule and sign-off owner were open (OQ-3). Without them the policy cannot be implemented or tested unambiguously.

Decision

Ownership. Enabling stand-in in any environment except tests requires written approval from the Head of Risk (role) in the change ticket. Engineering ships defaults that make stand-in inert: enabled=false, floor-limit-minor=0, both caps 0. Code never changes these defaults (AGENTS.md rule 9).

When the policy is consulted. The degradation policy runs only when the decision call returned Unavailable. Ambiguous never goes to stand-in, because money may already have moved. Its output is exactly one of STAND_IN_APPROVE or DECLINE_RETRYABLE. (The PRD's former "decline hard" output is dropped, because we cannot know the account standing while the wallet is down.)

STAND_IN_APPROVE iff all of:

# Condition
1 stand-in.enabled is true (runtime switch, ADR-0012) and floor-limit-minor > 0
2 currency == stand-in.currency (single currency, A10)
3 amount ≤ floor-limit-minor
4 Known account: ≥ 1 payment for this account was DEBITED or CAPTURED in our ledger in the last 30 days
5 No ACCOUNT_BLOCKED or ACCOUNT_NOT_FOUND decline for this account in the last 24 h
6 Account exposure + amount ≤ per-account-exposure-cap-minor, and global exposure + amount ≤ global-exposure-cap-minor. Both are reserved atomically in the same transaction as the decision

Exposure = the sum of stand-in approvals whose debit has not yet been posted. It is stored in standin_exposure (scope_key PK: 'GLOBAL' or accountNo, outstanding_minor), and reserved by UPDATE … SET outstanding_minor = outstanding_minor + :amt WHERE scope_key = :k AND outstanding_minor + :amt <= :cap. Zero rows updated → cap reached → DECLINE_RETRYABLE. Exposure is released when the post-recovery debit succeeds, the payment is reversed, or the debit is rejected.

States. A stand-in approval persists decision_mode = STAND_IN, state STANDIN_APPROVED (external APPROVED), and enqueues POST_STANDIN_DEBIT. The worker posts POST /debits {reference: paymentId} only if the state is still STANDIN_APPROVED: - Success → DEBITED. - Rejected (NSF, blocked, not found) → STANDIN_UNRECOVERED (terminal for us, external APPROVED), outbox StandInUnrecovered, alert. Collection is a business process.

A reversal of a stand-in payment follows ADR-0008 (mode STAND_IN compensates like DIRECT_DEBIT).

Reference values for tests only (not defaults): floor 500,000 minor units, per-account cap 1,000,000, global cap 50,000,000, currency NGN.

Consequences

  • + Every condition is a row in a table test. Inert by default. One switch turns it off within 5 s.
  • − Unknown accounts never get stand-in, even when trustworthy. That is deliberate.
  • Revisit trigger: Risk provides different limits or standing rules. That is a config change, plus an ADR only if the rule set changes.