walletflow· 03 operate
Chapter 03 · Operate

Cut over without ever being wrong.

Replacing a live wallet is a trust exercise. The plan is deliberate: stand up the ledger, load the chart of accounts, migrate opening balances, then prove the books foot four different ways before a single real payment is allowed through — and only decommission the old paths after repeated zero drift.

flag modefail_closed
recon gates4
rampshadow → partial → full
SLOp95 < 200ms
Fig. 1 · step through it
The migration order
01

✓ gate
1 / 11
03.1

What has to be running

walletflow is one stateless service plus a ledger, a database, and an async fabric. Everything scales independently; the service holds no balances, so it can be replicated freely.

ServiceRole
walletflow (Bun/Hono)API, use cases, read models, outbox relay, statement worker
Walletflow Postgrescommand state, idempotency, holds, outbox, COA, read models, flags
Formance Ledger + its Postgresimmutable postings — the system of record
AWS SNSnon-saga domain events
AWS Step Functions + SQS/DLQpayout saga orchestration
Provider HTTP endpointspayout submit / status rails

Production Formance is deployed via Helm + the operator (infra/formance); the payout saga is a CDK app (infra/payout-saga). The two workers — outbox:relay and statement:worker — run as long-lived processes or scheduled jobs.

03.2

The ramp

Traffic is moved in three phases, one currency and one provider path at a time. The old system keeps running until repeated zero-drift checks justify turning its write paths off.

Shadow mirror, compare, post nothing Partial one currency · one provider Full decommission legacy writes ↑ advance only on repeated zero drift across all four gates
Fig. 2Each phase is gated by reconciliation, not a calendar. The flag mode is set to fail_closed so only explicitly-enabled scopes transact.
Flags stop new exposure — not in-flight completion

One deliberate exception to fail_closed: saga completions (holds.settle / holds.release) bypass the gate by default. By the time a settle fires, the provider has usually already moved the money — the settle only records reality. Blocking it doesn't stop a payout; it makes the ledger lie, strands funds in holds:*, breaks provider reconciliation, and trips the holds-recon gate. Releasing a failed payout back to the merchant is never what a cutover flag means either. For a genuine hard freeze (suspected compromise, regulator order) set CORE_WALLET_COMPLETION_GATING=gate: the same flag rules then apply to completions, which return 409 and are retried by the saga once the freeze lifts.

03.3

Four ways to prove it foots

Reconciliation is not one check but four, each looking at the books from a different angle. All four must read zero before write cutover, and repeatedly before decommissioning the legacy paths.

EndpointChecksPass
/internal/reconciliation/driftread-model vs ledger balance0 drift
/internal/reconciliation/trial-balancedebits = credits per assetbalanced
/internal/reconciliation/holdsreserved holds vs ledger hold accounts0 drift
/internal/coa/driftCOA / GL / template coverageok

When a back-dated adjustment is needed, POST /internal/corrections with an effectiveAt posts a bi-temporal correction — recorded now, effective then — so the audit trail stays honest.

ALM & trial balance

The same posting data drives /internal/reporting/trial-balance and /internal/reporting/alm (provider cash position, merchant obligations, in-flight payouts, currency exposure) — all derived, never stored.

03.4

The env contract

A handful of variables decide safety-critical behaviour. The defaults are chosen so an un-configured deploy is safe; production overrides tighten them further. Expand each.

LEDGER_POSTING_MODEdefault · script
postings | script — default script
Script mode runs Numscript, so a send from a non-world source fails atomically on insufficient funds — the overdraft guard. Raw postings mode has no guard and is only for the balance-enforcing in-memory test double. Never run postings mode against Formance in production.
CORE_WALLET_FLAG_MODEdefault · fail_open
fail_open | fail_closed
fail_closed denies any scope without an explicitly-enabled core_wallet_enabled flag — set it during cutover so un-flagged merchants, currencies and providers are blocked by default.
CORE_WALLET_COMPLETION_GATINGdefault · allow
allow | gate
Flags normally stop only new exposure: saga completions (holds.settle/holds.release) always go through, because the provider may have already moved the money — blocking the recording strands reserved holds. gate extends the flag rules to completions for a hard freeze; blocked completions 409 and the saga retries them.
CURRENCY_SCALESJSON override
e.g. {"XOF":0}
The built-in table covers ISO-4217 + in-scope crypto. Unknown currencies are rejected — never silently assumed to be scale 2 — which is what stops XOF/XAF/UGX (zero-decimal) from being misposted 100×.
PROVIDER_REGISTRYper-rail routing
{"providus":{"baseUrl":"…","timeoutMs":4000}}
Routes each provider to its own payout rail, falling back to PROVIDER_HTTP_BASE_URL for anything unlisted.
FORMANCE_LEDGER_*ledger connection
BASE_URL · NAME · TOKEN · TIMEOUT_MS
Points the Formance adapter at the operational ledger. The book is created idempotently with bun run formance:ensure-ledger.
OUTBOX_* / AWS_*async fabric
SNS topic · saga ARN · relay batch/poll · credentials
The relay worker's dispatch targets and AWS credential source (static keys, EKS IRSA web identity, or ECS container credentials).
03.5

Verification & observability

The riskiest behaviours — overdraft rejection, zero-amount Numscript sends — are not verified by hope. They run automatically against a real Formance ledger booted by Testcontainers, and they're watched in production by Prometheus alerts.

The suite spins up an ephemeral Postgres and a real Formance container, wires the production adapter into the service in script mode, and proves the whole path end to end: pay-in with fee/VAT, a zero-fee/VAT pay-in (zero-amount sends), transfer/reserve/settle, a Numscript overdraft rejection, bank funding feeding a stablecoin onramp/offramp round trip, and a footing trial balance. Opt out with SKIP_FORMANCE_TESTCONTAINERS=true.

# offline gates
bun test            # 157 tests, incl. real Formance + Postgres
bun run ready       # fmt + lint + typecheck + build

# live gates (staging)
bun run formance:contract     # all 18 templates, direct
BASE_URL=… bun run load:k6    # p95 < 200ms · < 1% errors · ~1000 TPS

Alerts that matter

SignalMeans
Formance health downledger unreachable — stop writes
ledger tx failures ↑template or balance problem
outbox pending age ↑relay stalled — events not dispatching
DLQ depth > 0payouts stuck — manual review
reconciliation drift ≠ 0books don't foot — investigate now
template mismatch ≠ 0active DB templates ≠ bundled