Payment Rails & Settlement State Machine
How Anchita models each money-movement rail by its latency, finality, and reversibility — and why a debit isn’t “done” until the rail says so.
Table of Contents
- Why rails need a state machine
- The settlement-state principle
- Payment Authorization — Dual Control & Approval Limits
- Cutoff Windows & Business-Day Scheduling
Why rails need a state machine
Onboarding and provisioning set up a client’s capability to transact — a wire profile, an ACH origination agreement, an RTP enrolment. This section is what happens when money then actually moves over that capability: every movement is a balanced posting to the append-only ledger (see Data — Core Ledger), orchestrated as a durable workflow (see Temporal).
Money moves over several rails, and they differ in the three dimensions that matter most to a ledger: latency, finality (when the movement becomes irrevocable), and reversibility (whether the network can claw it back after the fact). The orchestration engine models each rail as a state machine with these semantics made explicit, rather than assuming every payment clears instantly.
| Rail | Execution / latency | Finality | Reversibility | Ledger handling on failure |
|---|---|---|---|---|
| Book transfer (internal) | Synchronous, real-time | Immediate | Irrevocable except by an offsetting entry | Atomic DB transaction — nothing posts unless both legs commit |
| Wire (Fedwire / RTGS) | Synchronous dispatch, near-real-time | Immediate on receipt | Irrevocable | Compensating credit if external dispatch fails |
| ACH (batch) | Asynchronous batch, T+1 to T+2 | Deferred (2–3 business days) | Reversible — NSF, unauthorized, administrative returns (R01–R29) | Post to a hold; keep PENDING_SETTLEMENT until the return window closes |
| RTP / FedNow (instant) | Sub-second (< 5 s) | Immediate | Irrevocable | Strict pre-flight balance check + sub-second timeout; no partial state |
The settlement-state principle
A debit leaving Anchita is not “done” until the rail says so. For deferred or reversible rails, funds post against a hold / clearing account — SUSPENSE_TRANSIT for real-time rails awaiting wire confirmation, ACH_CLEARING for batch rails inside the return window (see the chart of accounts) — and the movement carries a settlement state:
INITIATED → PENDING_SETTLEMENT → ( SETTLED | RETURNED | FAILED )
PENDING_SETTLEMENT— dispatched but not yet final (an ACH batch inside its return window; a wire awaiting acknowledgement). The customer balance reflects the hold; funds are not yet released to the counterparty.SETTLED— the rail confirmed finality; the hold is released by a balanced posting (see Data — Core Ledger).RETURNED/FAILED— the rail rejected or reversed the movement; a compensating entry unwinds the hold (see Temporal — Saga) and an exception event is raised (see Reconciliation).
How Temporal drives the lifecycle. Real-time gross-settlement rails (wire, RTP/FedNow) resolve synchronously inside the dispatch activity — the workflow learns finality (or a fatal rejection → compensation) in a single step. Deferred rails (ACH) are asynchronous: the workflow posts the hold, then parks on a durable Temporal timer for the return window — no polling, no worker consumed — while the settlement or return callback arrives as a signal that transitions PENDING_SETTLEMENT → SETTLED, or triggers a compensating reversal on a return (see Temporal — Saga). The same workflow code expresses both rails; only the waiting discipline differs.
Because each rail’s lifecycle is modelled explicitly, the ledger never shows money as available that a network can still reverse — the single most common correctness bug in naïve payment systems.
Scope note. Anchita orchestrates the lifecycle of each movement; it does not generate the rails’ wire-level formats (NACHA, ISO 20022) or connect to clearing networks directly. Each rail sits behind a versioned OpenAPI adapter — see the System Boundary.
Payment Authorization — Dual Control & Approval Limits
Capability to transact is not authority to move a given amount. In commercial banking no single person wires large sums alone: an initiator (maker) submits the payment and a second, different approver (checker) authorizes it. Anchita models this as a first-class step in the outbound-payment lifecycle, enforced by client entitlements and expressed — like every human decision point — as a Temporal Signal.
The full outbound-payment lifecycle, end to end:
- Submit — an authorized signer with an
INITIATEentitlement submits a transfer. - Hold — funds are earmarked against the account’s available balance via a
balance_holdsrow; the ledger balance is untouched. - Authorize (this section) — above the configured dual-control threshold, the workflow parks on
PENDING_APPROVALuntil a different signer with anAPPROVEentitlement and a sufficient limit signals approval. - Schedule — the release waits for the rail’s next cutoff / business day on a durable timer (see below).
- Release & transit — on dispatch the hold is captured and a balanced posting moves funds
COMMERCIAL_DDA → SUSPENSE_TRANSIT/ACH_CLEARING(see the chart of accounts). - Settle & reconcile — the settlement-state machine (above) drives it to
SETTLED(or a compensating reversal via Temporal — Saga), proven against clearing files (Reconciliation).
The dual-control decision.
%%{init: {'theme': 'base'}}%%
flowchart TD
classDef startEnd fill:#2d6a4f,stroke:#1b4332,color:#ffffff,font-weight:bold
classDef system fill:#1d3557,stroke:#a8dadc,color:#ffffff
classDef humanTask fill:#e76f51,stroke:#bc4749,color:#ffffff,font-weight:bold
classDef gateway fill:#457b9d,stroke:#1d3557,color:#ffffff
classDef complete fill:#1b4332,stroke:#52b788,color:#ffffff,font-weight:bold
Start(["▶ submitTransfer (initiator / maker)"]):::startEnd
Hold["Place hold on available balance\n(balance_holds)"]:::system
Thresh{"Amount over\napprovalThreshold?"}:::gateway
Wait["⏳ PENDING_APPROVAL\nSignal: approveTransfer\nDifferent signer · APPROVE entitlement · limit ≥ amount"]:::humanTask
Decide{"Approved?"}:::gateway
Release["Release → settlement\nCapture hold · post COMMERCIAL_DDA → transit"]:::system
Cancel["Cancel hold\nFunds remain in COMMERCIAL_DDA"]:::system
Done(["■ Dispatched to rail"]):::complete
Stop(["■ Not sent"]):::startEnd
Start --> Hold --> Thresh
Thresh -- "No — within single-auth limit" --> Release --> Done
Thresh -- "Yes" --> Wait --> Decide
Decide -- "Approved" --> Release
Decide -- "Rejected / timeout (approvalWindow)" --> Cancel --> Stop
Node key 🟦 Automated system activity · 🟧 Human task — Temporal signal required · 🔷 Decision gateway · 🟩 Terminal
- Threshold is configuration, not a constant.
config.approvalThreshold(clientSegment, rail)sets the amount above which a second approval is required — never a hardcoded figure, so policy changes without code changes (the same discipline as the configurable wire-activation window). - Segregation of duties. The approver’s signer id must differ from the initiator’s (
maker ≠ checker), must hold anAPPROVEentitlement for that product, and that entitlement’sapproval_limitmust be ≥ the transfer amount. A signer can never approve their own instruction. - Durable, bounded wait. The approval is a Signal, so the workflow waits indefinitely across restarts; a parallel durable timer (
config.approvalWindow()) auto-cancels the request and releases the hold if no approver acts in time — money is never left silently earmarked.
Dual-control invariant: a payment above the configured threshold cannot leave the ledger on one person’s authority. Release requires an
APPROVEsignal from a different entitled signer whose approval limit covers the amount; absent it, the hold expires and the funds stay put.
Cutoff Windows & Business-Day Scheduling
Payment rails do not run around the clock. Each rail has submission cutoffs and runs only on banking business days, so a transfer approved at 6 p.m. the Friday before a holiday does not dispatch immediately — it waits for the next valid window. Modelling this explicitly is what keeps the ledger’s settlement dates honest.
- Cutoffs and the calendar are configuration.
config.railCutoffs(rail)returns a rail’s ordered daily submission windows (for example, ACH’s same-day vs next-day deadlines);config.businessCalendar()supplies bank holidays and the weekend rule. Concrete clock times live in config, never in code — they differ by rail and institution and change over time. (Illustrative only: ACH same-day submission deadlines and a late-afternoon Fedwire cutoff; the platform treats these purely as data.) - The wait is a durable timer, not a poll. On release the workflow computes
nextDispatch = calendar.nextWindow(rail, now). If that is in the future, it sleeps on a durable Temporal timer until then — no worker consumed, survives restarts — exactly the mechanism the settlement-state machine (above) uses for ACH return windows. The funds remain on hold, or in a transit account once released, for the duration — so the position is always backed. - Effective date is derived, not assumed. Because dispatch is scheduled against the calendar, every movement carries a realistic settlement / effective date that Reconciliation can match against the clearing file — rather than optimistically assuming same-day finality.
Scheduling invariant: a movement dispatches only within its rail’s configured cutoff window on a business day; until then it waits on a durable timer with funds held, so no payment is ever “sent” into a closed network.