Docs/Get startedv0.0.1-preview

Core concepts

Ledgers, accounts, transfers, holds, operation identity, and outcomes — the six ideas everything else builds on.

On this page
  1. Ledgers
  2. Accounts
  3. Balances: posted, pending, available
  4. Transfers and holds
  5. Operation identity
  6. Outcomes are records
  7. Amounts

TulaDB has a small vocabulary, used precisely. Five minutes here makes every other page faster to read.

Ledgers

A ledger is a namespace of accounts sharing a default currency and a mode. You register a ledger explicitly (ledger_id = 0 asks the engine to allocate the next id); registration is what gives it a mode, a reservation expiry and an amount cap, and ledger-scoped admin operations refuse an unregistered ledger. Ledgers come in two modes:

  • Default — plain double-entry between accounts.
  • Position–settlement — accounts belong to tiers (position and settlement), and tiered transfers move both tiers atomically in one transaction.

Alongside ledgers, the engine keeps small registries of sources (integration channels submitting operations), currencies (ISO 4217 numeric codes with per-currency scale), and per-ledger source allow-lists.

When registration is mandatory. By default the engine admits transfers and account creates on an unregistered ledger and in an unregistered currency, and admits any source until the first one is registered (from then on, unregistered sources decline ERR_SOURCE_UNKNOWN). Registration becomes mandatory in two ways:

  • registry_strict = true on the node — every ledger, currency and source must be registered and active. The shipped secure profile sets it, so there a sample must first register its currency, ledger and source, or it declines ERR_UNKNOWN_CURRENCY (23), ERR_UNKNOWN_LEDGER (15) or ERR_SOURCE_UNKNOWN (24).
  • tulactl activate-feature --feature ledger-registry-enforce --yes — refuses transfers and account creates on unregistered ledgers cluster-wide. It is one-way, and refused while any account still sits on an unregistered ledger; see tulactl.

Accounts

An account is a 128-bit id plus balances, limits, and flags inside one ledger and currency, and it is named by that full key: (ledger_id, currency, id). Every account-addressed read and update — balance, history, limit changes, activation — names all three. In this release an id is still unique across the engine: creating an id that already exists under another ledger or currency, or with different creation attributes, answers ERR_ACCOUNT_CONFLICT. There is deliberately no delete — accounts (and transfers) are permanent records; deactivation and closure are flags, not erasure.

Account behaviour is declared at creation:

Flag Effect
DEBIT_NORMAL / CREDIT_NORMAL The account’s normal balance side
CONTROL A control account for funding/defunding value into the ledger
SETTLEMENT A settlement-tier account (immutable once set)
ALLOW_NEGATIVE Permits a negative posted balance
NO_DEBIT / NO_CREDIT One-way accounts
INACTIVE Temporarily refuses new activity (reversible)
CLOSED Terminal

Per-account limit_low and limit_high are enforced by the engine on every mutation — not by whichever service happened to write.

Balances: posted, pending, available

Every account carries four counters — credits_posted, debits_posted, credits_pending, debits_pending — and two derived balances:

posted_balance    = credits_posted - debits_posted
available_balance = posted_balance - debits_pending

A hold reduces what’s available without touching what’s posted. Your wallet UI, risk checks, and payout logic can each read the number that means what they need.

Transfers and holds

A transfer debits one account and credits another atomically — both legs or neither. Single-phase transfers post immediately. Two-phase transfers split intent from settlement:

  • reserve places a hold on the debit side (phase PENDING);
  • commit releases the hold and posts both legs (POSTED);
  • rollback releases the hold and posts nothing (VOIDED).

Each transition is a durable, ordered outcome. See Holds.

Operation identity

Every write carries a caller-chosen identity: the tuple (source_id, ledger_id, currency, txn_id). The engine derives the transfer’s permanent identity from it with SHA3-256: by default the first 208 bits, exposed as a 35-character Base62 reference that fits ISO 20022 fields, or — under the full-digest policy — all 256 bits as a 64-character hex digest.

Submit the same tuple twice and you get the original result back with status OK_DUPLICATE — durably, across restarts and failovers, for as long as the ledger retains that transfer’s history (forever, by default). This single mechanism is what makes retries, at-least-once queues, and crash-recovery loops safe. See Idempotency.

txn_id is yours: up to 36 printable ASCII characters, compared exactly (no trimming, no case folding). Use your order id, payment reference, or saga step id.

Outcomes are records

Every decision the engine makes is durable and queryable:

Outcome What it means
POSTED Balanced legs applied
PENDING Hold in place, awaiting commit or rollback
VOIDED Hold released, nothing posted
EXPIRED Reservation lapsed under an expiry policy
REJECTED The engine declined — and recorded the decline

That last row matters more than it looks: a transfer declined for insufficient funds or a limit breach is a record, not a dropped error. Operations teams can list rejected transfers, support can answer “what happened to payment X”, and a retried rejection replays deterministically instead of being re-decided. See Errors and rejections.

Amounts

Amounts are 64-bit integers in minor units — no floats, anywhere. amount=80_000 in CAD is 80,000 cents: 800.00 CAD. The engine never applies decimals; arithmetic is exact and overflow-checked, and every admitted movement balances, or no movement is committed.

A currency’s scale (0–8 fractional digits) is set when it is registered in the engine’s currency registry and cannot change afterwards — registering an existing currency with a different scale is refused. That makes the registry the one place every edge converts with:

  • The console, the REST gateways and the SDK helpers turn "800.00" into 80,000 with the registered scale, and turn 80,000 back into "800.00" for display.
  • Conversion is exact and never rounds: "800.000" at scale 2 is refused, not trimmed, and so are signs, exponents, spaces and separators. Every SDK and gateway passes the same shared test vectors.
  • A currency that isn’t registered has no scale, so decimal input in it is refused rather than guessed. A new engine starts with no currencies registered — register each currency you use (with its scale) first.
  • Creating a ledger registers its currency automatically. In the default open registry mode, the first ledger created for an unregistered currency registers it permanently — at its ISO 4217 scale for an ISO currency (USD, CAD, EUR 2; JPY 0; BHD 3; CLF 4) and at scale 0 for any other number. A transfer never registers a currency. Register custom units — or any unit you want labelled or at another scale — before first use, or run registry_strict = true so an unregistered currency is refused instead. (ISO scales on auto-registration ship with the next engine release; in the released 0.0.1-preview every auto-registered unit gets scale 0. A unit already registered at 0 keeps that scale — scales never change.)

Integers stay authoritative: decimal fields are added alongside them, never instead of them. The engine cannot tell a caller that meant dollars from one that meant cents when both send valid integers — set a per-ledger max_txn_amount as the backstop against unit mistakes.