All notes

Real-time payments ·

One balance across every rail

A customer has one balance. Most systems give each payment rail its own copy of it — and that gap is where money gets spent twice.

A customer has one balance. A participant that sends payments over an instant rail, an e-transfer network, ACH-style batch files, and card authorizations usually has four views of it — one per rail integration, each reconciled to the core after the fact.

The gap between those views is where money gets spent twice. An instant payment and an e-transfer arrive in the same second. Each rail’s integration checks “its” balance, each sees enough, and both go out. The core finds out at the next batch run.

Developer-preview note: the engine behaviour in this post is shipped and was checked against a running engine. Connectors for individual rails are not part of the engine; see the previous post.

The cross-rail double spend

The defect is structural, not a bug in any one integration:

  • Each rail checks a copy. A balance cached per rail, or a check-then-write against a shared table without a common lock, lets two rails approve against the same funds.
  • Holds don’t cross rails. An outbound instant payment waiting for the rail’s acceptance has reserved funds. If the e-transfer integration can’t see that reservation, it spends the money again.
  • Overdraft becomes a reconciliation item. Instead of a refusal at the moment of the payment, the participant discovers a negative balance hours later and has to recover it from the customer.

One balance, many rails

In TulaDB the customer’s balance is one account, and every rail posts to it. The engine admits writes to an account one at a time and enforces its limits on every mutation, so the check and the write are a single step for every rail.

Here an instant payment of 80,000 is waiting for the rail’s acceptance, and an e-transfer of 30,000 arrives against a balance of 100,000:

RTR, ETRANSFER = 1, 2          # one source_id per rail

c.reserve(TransferCreate(source_id=RTR, ledger_id=L, currency=124,
        debit_account_id=MEMBER, credit_account_id=RTR_SETTLE,
        amount=80_000, txn_id="E2E-20261029-000042"))
# member: posted 100,000 — available 20,000

c.transfer(TransferCreate(source_id=ETRANSFER, ledger_id=L, currency=124,
        debit_account_id=MEMBER, credit_account_id=ETRANSFER_SETTLE,
        amount=30_000, txn_id="ETR-CA1MRxyz9"))
# raises LedgerError: status 2, ERR_LIMIT — the account's floor is 0

The hold placed for one rail reduces the funds available to every rail, at the same instant. The e-transfer is refused with ERR_LIMIT, and the refusal is itself a durable record: support can see exactly what was refused, when, and on which rail.

Each rail keeps its own identity

One balance does not mean one identity space. A write’s identity is (source_id, ledger_id, currency, txn_id). Give each rail its own source_id and each rail’s references stay its own:

  • Two rails that happen to use the same reference string never collide.
  • Each rail’s resends are deduplicated against that rail’s history, by the reference the rail itself uses — the end-to-end id, the network reference, the RRN.
  • The settling service for each rail commits or releases by that rail’s reference, without coordinating with the others.

Each rail keeps its own refusal codes

When a participant refuses an incoming payment, the reason belongs to the rail’s vocabulary. ISO 20022 status reasons are four characters — AC04 for a closed account, AM04 for insufficient funds. Four ASCII characters pack exactly into the engine’s 32-bit caller reason code, and every such value sits below the reserved range (from 0xFFFF0000) that callers may not use:

code = int.from_bytes(b"AC04", "big")      # 0x41433034

c.reject(RejectCreate(source_id=RTR, ledger_id=L, currency=124,
        txn_id="E2E-INBOUND-77812", amount=12_500, reason_code=code))
# status 41: a durable reject, recorded with the rail's code verbatim

Replaying that reference returns the refusal again, not a fresh attempt — a resent payment you refused stays refused. And “did we refuse this, or did the engine?” is always answerable from the record: your refusals are explicit rejects (status 41, flagged as rejects, carrying your code), while the engine’s declines carry its own status, such as ERR_LIMIT.

Reconcile one rail at a time

End of day, each rail’s settlement report has to match the books. Because every record carries its rail’s source_id, one rail’s activity is a single search:

recs = q.search_transactions(TxnSearch(ledger_id=L, currency=124,
                                       source_id=RTR, limit=100))
# postings, holds, releases and refusals written for that rail — nothing else

The same filter works in the lake. The Iceberg export writes source_id, txn_id, result_code and the timestamp on every row, so per-rail reconciliation against each settlement file is a join, not a spreadsheet.

What this gives each team

  • Operations sees one balance that is right for every rail at every moment.
  • Risk gets a floor that no combination of rails can breach, because it is checked in the same step as the write.
  • Support and disputes get every refusal as a record, with the rail and the rail’s own reason.
  • Finance reconciles each rail from one export, filtered by rail.

Adding a rail adds a source_id and an adapter. The balance, the limits and the history stay where they are.

The next post applies the same idea to the participant’s own money: the intraday liquidity position behind all of those rails.

Preparing for an instant rail?

Prove a real-time posting layer in shadow mode on replayed or anonymised traffic, before anything depends on it. We reply within one business day.