# Under the hood

> The decisions that make a balance survive concurrency: integer amounts, ordered locks, isolation enforced by the database, and reconciliation that reports rather than repairs

Source: https://aipril.dev/docs/ledger/under-the-hood

A register of balances is easy to write and hard to get right, and every hard part is a concurrency
problem.

```mermaid
flowchart TD
    C["A caller: a process transition,<br/>an application, an operator"] --> API["Ledger service"]
    API --> V["Validate: asset scope,<br/>actor, amounts"]
    V --> L["Lock the accounts,<br/>ordered by account id"]
    L --> J["Journal: a posting<br/>with legs summing to zero"]
    J --> B["Materialised balances"]
    J --> H["Holds<br/>available = balance - held"]
    B --> R["Scheduled reconciliation:<br/>sum per asset must be zero"]
    B --> DS["Dataset bridge<br/>to analytics"]
```

## Why Go

The register is written in Go, and the reason is that almost everything it does is transactional
database work under contention.

- **Explicit control of transactions and row locks.** The whole design rests on taking specific
  locks in a specific order at a specific moment. That has to be stated directly, not inferred by a
  layer that decides when to open a transaction on your behalf.
- **Pauses are bounded, and a lock held during a pause is a lock held.** Latency inside a locked
  transaction is not a performance question here; it is how long everyone else waits to post
  against the same account.
- **Per-organisation schema and session state are ordinary operations.** Setting the acting
  organisation on every transaction, and creating structures per organisation, are routine at this
  layer, not something worked around.

## Amounts are integers, and the API refuses to round

An amount is a whole number in the asset's minor unit, with the scale declared once in the asset
registry. It is **never a floating-point number on the wire** - a value arrives as an integer in
minor units or as an exact decimal string, and a value carrying more precision than the asset
supports is **rejected rather than rounded**.

That last clause is the one that matters. Rounding on input is how a register acquires a discrepancy
nobody can trace: each individual rounding is defensible and the sum is wrong. Refusing is louder
and correct.

## Locks are taken in a fixed order

Two transfers in opposite directions between the same pair of accounts will deadlock if each takes
its accounts in the order it happens to think of them. So accounts are locked ordered by their
identifier, always - and a batch **pre-collects and sorts every account it will touch before its
first write**, so a batch and a single posting, or two batches, cannot deadlock against each other
either.

This is a small thing that is either present from the first day or discovered in production.

## Holds are not postings

A reservation lives in its own table, not as a movement in the journal. Two reasons, both
structural:

- **The journal stays a pure record of what happened.** A cart that was abandoned should not leave
  two entries in the history of an account cancelling each other out; it should leave nothing,
  because nothing happened.
- **`available = balance − held`** is then a single, obvious subtraction instead of a query that
  has to know which postings were provisional.

## The past is immutable by construction

The posting time is set by the service and is **never supplied by the caller**. A closed period is
therefore closed for good: no posting can appear inside it after the fact, and no period snapshot
ever has to be recomputed because something arrived late.

A backdated correction is expressed the way accountants have always expressed it - a reversal of the
original plus a fresh posting in the current period - so what happened and what was corrected are
both still visible, in the order they actually occurred.

## Isolation is enforced by the database

Every table carries a row-level security policy and every transaction sets the organisation it is
acting for. Application-level scoping is not treated as sufficient here.

That is a deliberate divergence from how the [data engine](https://aipril.dev/docs/engine.md) does it: a scoping slip in
a query layer leaks a list of models, and a scoping slip here moves money between organisations. The two do not deserve the same level of defence.

For the same reason the acting person is never taken from the service credential the caller
presents. That token identifies the organisation, not the human; the actor arrives separately and is
honoured only when the service credential is the expected one.

An asset also carries a **scope** - belonging to one organisation, or to the platform - and that
scope is resolved before authorisation runs, so a tenant-scoped call cannot address a
platform-scoped asset even by accident.

## Reconciliation reports, and never repairs

On a schedule, the register checks itself: for every asset, the balances across all accounts must
sum to exactly zero. Double entry is what makes that check possible without any external source of
truth - no bank statement, no stock count, nothing to compare against.

**When it finds a divergence it raises it. It does not fix it.** A register that quietly corrects
itself is a register that hides the bug that caused the divergence, and the second occurrence is
then invisible too. The check exists to tell you that something is wrong while the evidence of what
did it is still in the journal.

## The account key, and the one thing to decide early

An account is identified by its asset, the kind of owner, the owner, and its dimensions - the
warehouse, the cost centre, whatever the balance is split by.

Those dimensions are part of the key, which means adding one later is not a settings change: it is
new accounts plus transfer postings plus a recorded cut-over date. That is a supported procedure,
not a migration nobody has done, but it is the decision worth spending an afternoon on before the
first posting, and the only one on this page with that property.

## Where it joins the rest of the platform

A register is only useful in company.

**The catalogue is [master data](https://aipril.dev/docs/master-data.md).** A balance is keyed by codes - an item, a
warehouse, a counterparty - and the register holds those codes as opaque strings. What the item is
called, what category it belongs to, which supplier it comes from lives in master data, as real
tables the same query can join. The register never resolves a code, which is why one register serves
loyalty points and warehouse stock without needing to know what either of them is.

**Prices and rates live in master data too, and are read at the moment of posting.** The rate is not
kept here; the price actually charged is recorded on the movement itself, because it describes that
movement and not the balance. So two different questions get two different owners: *what did
this cost when it moved* is answered from the journal, and *what does it cost today* from the price
list.

**Documents - carts, orders, bookings - are declared by the [application](https://aipril.dev/docs/apps.md).** Grouping
several reservations so they succeed or fail together is a domain idea, and it lives in a document
layer above the register. That is what lets a new case arrive without the ledger having to learn a
new noun.

**Movements are posted from [processes](https://aipril.dev/docs/processes.md).** A transition that completes - a shipment
confirmed, a return accepted, an approval granted - posts the movement as part of the work, so a
balance changes because something happened and not because somebody remembered to record it.

**Balances are read by [analytics](https://aipril.dev/docs/analytics.md) and shown by
[pages and portals](https://aipril.dev/docs/cms.md).** Movements and balances reach reporting through a dataset bridge,
and a balance on a screen is an ordinary widget bound to that data - so a new consumer gets its
interface from the same catalogue of blocks as everything else.

Every one of those joins is a place where a register usually starts growing a second product inside
itself: a half-built catalogue, a price list nobody else can see, a bespoke screen per use case.
Keeping them as joins is what lets one engine serve points, stock, settlements and limits at the
same time - and lets each neighbour stay good at the job it already has.
