Real-time payments ·
Instant rails meet batch cores
Real-time rails settle in seconds, around the clock. Most cores post in batches. The posting layer in between is where it breaks.
An instant-payment rail changes one thing about a participant’s books: the balance has to be right now, at any hour, for every message. FedNow in the US, and Canada’s Real-Time Rail as it launches in phases from late 2026, move a payment end to end in seconds, every day of the year. Most participants still post to a core that was built for batches, end-of-day runs, and maintenance windows.
The rail does not wait for the core. Something has to sit between them and keep each account’s balance exact in real time. That is the posting layer, and it is where most of the risk of joining an instant rail actually lives.
TulaDB was designed against the participant requirements of Payments Canada’s Real-Time Rail, by an architect who has worked on real-time payments, clearing and settlement. This post describes the problem as we see it, and the guarantees we built the engine to give.
Developer-preview note: TulaDB is in developer preview. The engine behaviour below is shipped and was checked against a running engine; rail adapters (ISO 20022 message parsing) are not part of the engine and are next on the roadmap. See what is not built yet.
What the rail asks of your ledger
Strip away the message formats and an instant rail asks a participant’s ledger for seven things:
- Post now, any hour. A payment the rail accepts must move the customer’s balance immediately — including at 3 a.m. on a holiday, during the core’s batch window.
- Exactly once per message. Networks time out. Messages get resent. A resent payment must never post twice.
- Hold, then settle. An outbound payment is sent, then accepted or rejected. The funds must be held in between, not spent twice and not lost.
- Refusals with the rail’s own reason. A rejected payment is a business outcome that support, disputes, and the regulator will ask about, with the code the rail used.
- Never below the floor. No payment may take a customer or the participant’s settlement position below its limit — even when two arrive in the same millisecond.
- Keep running through failures. A node dying cannot stop posting.
- Reconcile. At the end of the day, every rail, the core, and finance have to agree to the unit.
Why the usual answers struggle
The usual answer is a memo-posting table: rows in a general-purpose database, written by the payment hub, with the core catching up later. Each requirement then becomes application code that every team re-implements — and the edge cases show up in production:
- Retries: duplicate detection is a cache or a unique index the hub maintains. A cache expires; an index is per table, not per rail.
- Holds: a “pending” row is a flag. Nothing stops a second spend from reading the balance before the flag lands.
- Limits: a check-then-write in the application is a race unless every writer takes the same lock, in the same order, every time.
- Failover: a replica that lags by one transaction will happily approve a payment against a stale balance.
None of this is exotic. It is simply the part of the system that has to be perfect, built as the part of the system nobody owns.
The posting layer as a ledger
TulaDB moves each of these guarantees into the ledger engine itself, so they hold for every writer and through every failure. The outbound flow of an instant payment maps onto the engine’s two-phase lifecycle directly:
RTR = 1 # one source_id per rail
e2e = "E2E-20261029-000042" # the message's end-to-end id
pay = TransferCreate(source_id=RTR, ledger_id=L, currency=124,
debit_account_id=MEMBER, credit_account_id=RTR_SETTLE,
amount=80_000, txn_id=e2e)
r = c.reserve(pay) # sent: funds held, available balance drops now
c.reserve(pay) # timeout, resent: OK_DUPLICATE, same transfer_id
c.commit_by_key(RTR, L, 124, e2e) # rail accepted: the hold posts
# or
c.rollback_by_key(RTR, L, 124, e2e) # rail rejected: the hold is released
What the engine guarantees here, each checked against a running engine:
- The hold is real state. After the reserve, the member’s posted balance is unchanged and the available balance is down by the amount. A second spend sees the lower available balance — there is no window.
- A resent message is one payment. The identity is
(source_id, ledger_id, currency, txn_id). Resending returnsOK_DUPLICATEwith the originaltransfer_id, durably, not from a cache. - Settlement doesn’t need the original response. The service that receives the rail’s status report commits or releases by the message’s own id — it never needed to see the reserve’s reply.
- Rail ids fit.
txn_idholds up to 36 characters, which covers an ISO 20022 end-to-end id (35) or a UUID-form reference (36). The engine’s own transfer reference is 35 Base62 characters, sized for ISO 20022 fields.
The limit and failover guarantees come from the same engine: limit_low is enforced atomically on every mutation, and a cluster keeps posting through node kills and partitions — money balanced to the unit in every fault-injected cloud run that produced a verdict.
In front of the core, not instead of it
The point is not to replace the core. Replacing a core is a multi-year programme. A real-time posting layer goes in front of it:
The rail talks to the posting layer. The core keeps doing what it is good at — products, statements, interest — and is updated from the ledger’s history through a feed built for that core; postings that still originate in the core, such as fees and interest, flow the other way. Finance reconciles from the Iceberg export, where every committed record lands with its identity and, optionally, both accounts’ balances after it applied.
Start in shadow mode
The lowest-risk way to evaluate a posting layer is to run it beside the one you have. Feed it replayed or anonymised rail traffic in your own environment, let nothing customer-facing depend on it, and compare its balances and refusals with what your existing books recorded for the same payments. Disagreements are the interesting part: each one is either a defect in the new layer, or a payment the old one got wrong.
What is not built yet
Honesty about scope matters more than usual in payments:
- Rail adapters. The engine does not parse ISO 20022 messages or speak any rail’s network protocol. Mapping messages onto reserve, commit, rollback, and reject — as above — is an adapter’s job. A Real-Time Rail reference adapter is planned and will be built with design partners; it is not available today.
- Production status. The engine is a developer preview. We are looking for design partners who want to run it in shadow mode on replayed traffic.
If you are preparing for an instant rail and the posting layer is the part that worries you, become a design partner. The next two posts cover one balance across every rail and intraday liquidity you can enforce.
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.