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

  1. Why rails need a state machine
  2. The settlement-state principle
  3. Payment Authorization — Dual Control & Approval Limits
  4. 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:

  1. Submit — an authorized signer with an INITIATE entitlement submits a transfer.
  2. Hold — funds are earmarked against the account’s available balance via a balance_holds row; the ledger balance is untouched.
  3. Authorize (this section) — above the configured dual-control threshold, the workflow parks on PENDING_APPROVAL until a different signer with an APPROVE entitlement and a sufficient limit signals approval.
  4. Schedule — the release waits for the rail’s next cutoff / business day on a durable timer (see below).
  5. 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).
  6. 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 an APPROVE entitlement for that product, and that entitlement’s approval_limit must 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 APPROVE signal 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.


↑ Back to top

Anchita Banking — Fictional Reference Architecture for Cloud-Native Institutional Banking