Docs/Ledger APIv0.0.1-preview
Transfers
Single-phase transfers, batches, tiered position-plus-settlement movement, and what a transfer result tells you.
A transfer debits one account and credits another in a single atomic step. There is no state in which one leg has applied and the other hasn’t — not mid-request, not after a crash, not on a replica.
Submitting a transfer
Every transfer carries the operation identity tuple plus the movement itself:
| Field | Meaning |
|---|---|
source_id |
The submitting channel (from the source registry) |
ledger_id |
Target ledger |
currency |
ISO 4217 numeric; must match both accounts |
txn_id |
Your reference — ≤ 36 printable ASCII chars, compared exactly |
debit_account_id / credit_account_id |
128-bit account ids |
amount |
64-bit integer, minor units, must be > 0 |
flags |
Optional behaviour flags |
result = c.transfer(TransferCreate(
source_id=1, ledger_id=1, currency=840,
debit_account_id=1001, credit_account_id=1002,
amount=2_500, txn_id="order-42",
))
Validation happens before anything is admitted: both accounts must exist, be active, allow the movement direction, share the transfer’s currency, and stay inside their limits, and the amount must not exceed the ledger’s max_txn_amount cap when one is set (ERR_AMOUNT_EXCEEDS_MAX). A failed check is a durable decline — see Errors and rejections.
When a request breaks more than one rule at once, the engine reports the consistency reason — wrong ledger, currency mismatch, a control-account rule — ahead of an account-eligibility one such as a closed account, on every path, batched or not. The durable rejection record carries the same reason.
The result
A TransferResult is a receipt with everything a caller needs to proceed without a follow-up read:
| Field | Meaning |
|---|---|
status |
0 accepted · 1 idempotent duplicate · else a decline code |
phase |
POSTED, PENDING, VOIDED, EXPIRED |
transfer_id |
The permanent derived identity of this transfer |
batch_seq, batch_index |
The transfer’s position in the ledger’s total order |
dr_posted_balance, dr_available_balance |
Debit account, after applying |
cr_posted_balance, cr_available_balance |
Credit account, after applying |
timestamp |
Hybrid logical clock — consistently ordered across nodes |
(batch_seq, batch_index) is load-bearing: it’s the transfer’s coordinate in the committed order, the cursor for history pagination, and the sequence you can gate a read on for read-your-writes.
Batches and pipelining
On the wire, every write is a batch: a single transfer call is sent as a batch of one and answered with that item’s result, so a single write and a batched one share one write path and one set of rules. For throughput, submit transfers in batches — up to 1,024 per frame through the SDKs, with up to 64 frames in flight per connection:
results = c.transfer_batch(transfers, pipeline_depth=4)
Each entry gets its own status and result, in input order; one declined transfer never poisons its neighbors. And because every entry carries its own idempotency tuple, a failed or interrupted batch is safe to resubmit whole — entries that already landed come back as OK_DUPLICATE.
Tiered transfers
Ledgers in position–settlement mode move two tiers in one atomic transaction — four accounts, one amount, one txn_id:
result = c.tiered_transfer(TieredTransferCreate(
source_id=1, ledger_id=2, currency=840, amount=10_000,
dr_position_id=p1, cr_position_id=p2,
dr_settlement_id=s1, cr_settlement_id=s2,
txn_id="settle-batch-0042",
))
All four legs validate together and apply together — a limit breach on any leg rejects the whole transaction. This is how position tracking and settlement movement stay in one correctness domain instead of drifting apart across two services and a reconciliation job. Tiered reserve/commit/rollback exist too, mirroring the two-phase lifecycle, and the engine can produce a per-tier reconciliation whose discrepancy is — by construction — zero.
Rejecting a transfer on purpose
Sometimes the decline is the business outcome you need on the books — a payment refused by an upstream risk system, recorded so support and audit can see it. The reject operation records a durable rejected transaction without moving value:
c.reject(RejectCreate(
source_id=1, currency=840, txn_id="order-43", ledger_id=1,
reason_code=4207, # your taxonomy — recorded verbatim on the record
# Optional context, recorded as given:
amount=2_500, debit_account_id=101, credit_account_id=202,
))
Because a reject moves no value, the only required fields are the four that
form the operation identity: source_id, ledger_id, currency and txn_id.
Always pass ledger_id explicitly: the Python SDK’s RejectCreate defaults it
to 1, so an omitted ledger records the reject under ledger 1.
Amount and account ids are optional context — supply what the declined request
carried and the record keeps it, so the decline can be reconciled against the
request that caused it. The accounts are not looked up on this path, so an
account id that does not exist is accepted; frequently that is the very reason
for the decline.
The division of authority is deliberate. The status is the engine’s: every explicit reject records result_code = ERR_UPSTREAM_REJECTED (41), so engine-validation statuses like ERR_LIMIT always mean the engine itself declined — callers can’t impersonate them. The reason is yours: reason_code is a free-form 32-bit value from your own taxonomy (a fraud-rule id, a compliance-policy code), recorded verbatim on the durable record and returned by every lookup and listing — so “every rejection under policy 4207 this week” is a query. For narrative detail beyond a code, your systems of record hold it, keyed by the same txn_id.
A rejected identity is remembered like any other: replaying it returns the original rejection rather than re-deciding it.