Three-way
Each external source is reconciled independently. A bank record and a chain record for the same transfer are different facts about different legs; matching them against one another would compare a fiat payout to a stablecoin settlement.Break kinds
The settlement window
Rails settle asynchronously. Without a grace period every reconciliation run would raise a break for every in-flight transfer, and a report that is mostly noise gets ignored — which is worse than no report. Records younger than the window are countedpending, not broken. Past it, silence becomes a break.
Breaks become cases
Every break opens a case in thereconciliation queue, with severity mapped to priority and SLA:
A break nobody is assigned to is a break nobody resolves, and an unexplained difference is indistinguishable from fraud.
The ledger does not import the risk context.
CaseOpener is an interface the ledger declares and the composition root satisfies with the real ReviewQueue — the same port pattern used everywhere else.
Reporting
Trial balance
Debits equal credits, per currency. Proves internal consistency. Already enforced at post time and by the database.Float position — do we hold what we owe?
covered is false.
If you only ever check the trial balance, you will not notice becoming uncovered.
P&L
Per currency, never summed across currencies. Converting at a reporting rate would bake today’s FX into a historical figure and make last month’s profit move for reasons that have nothing to do with last month.Statements
An account statement with a running balance, ordered by posting time, so an auditor or a partner can follow the arithmetic line by line rather than being handed a total to trust.Corridor summary
Volume, gross revenue, direct expenses, and net per corridor. Journals carry areferenceId, not a corridor, so the caller supplies the mapping — tagging every journal with a corridor would push a product concept into the ledger, whose job is to record what happened rather than why.
Runbooks
Written as if on call, with the diagnosis before the fix:- Reconciliation break — triage by break kind, three resolutions, closing criteria
- Stuck settlement — mempool, reverts, finality depths per chain
- Chain reorg — expected depths, and the one question that matters
ledger_entry is append-only), and that refusal is the design, not an obstacle.
The reconciliation runbook explicitly forbids posting a difference to suspense and moving on. Suspense balances are where breaks go to be forgotten.
What the tests prove
The Phase 8 criterion was that an injected break is detected, cased, and resolvable by following the runbook alone. One test executes precisely that:- A real payout is posted; the trial balance is zero and nothing looks wrong.
- The bank statement arrives with no record of it.
- Reconciliation raises
missing_externalwith a €400.00 difference. - A case opens: urgent, 1-hour SLA, audit trail.
- Runbook Resolution A is applied — a reversing journal, no edit to anything.
- Re-reconciliation is clean; trial balance zero and float covered.
- The case closes citing the correcting journal id.
- The original settlement journal is still in the record — the history shows what happened and what corrected it.
What is not here yet
- No scheduled runs. Reconciliation is a function, not a job. Wiring it to the queue infrastructure is Phase 9 work.
- External feeds are supplied by the caller. Nothing parses a real SEPA or M-Pesa statement format.
- No break auto-resolution. Every break needs a human, which is correct today and would not scale.
- Corridor mapping is caller-supplied, so corridor P&L depends on the caller getting it right.