# Phase 12 — `op-ledger` double-entry append-only ledger **Status**: Draft v0.12 **Date**: 2026-05-17 ## What shipped A new crate `crates/op-ledger` plus an integration test suite that demonstrates composition with the orchestrator. **Total: 69 tests (59 unit + 10 integration), ~2,540 LOC.** The ledger is the missing **source of truth** for money movement. Phases 1-11 produce events (a card auth approved, a FedNow transfer settled); the orchestrator routes them; the ledger **records them** in a form that: - balances correctly under concurrency, - can be audited by a regulator, - can be reconciled against bank/PSP statements as a set difference, - survives software bugs (an unbalanced transaction is rejected at construction time, not silently committed), - gracefully models corrections (reversal transactions, not edits). ## Verified ground truth Researched live (May 2026 sources) before implementation: | Claim | Source | |---|---| | Double-entry: debits == credits *per currency* within a transaction | Modern Treasury docs, pgledger (Paul Gross 2025), Trio fintech architecture guide | | Append-only / immutable: posted transactions can never be edited, only reversed | Modern Treasury docs, Uber Gulfstream design, ISO 4217 accounting standards | | Account currency fixed at creation | Modern Treasury docs (`currency` is mandatory; `balances.currency` always matches) | | Account has a `normal_balance` (debit/credit) — Asset/Expense debit-normal; Liability/Equity/Revenue credit-normal | Standard double-entry accounting; Modern Treasury docs use this exact vocabulary | | Three balance views: posted, pending (= posted + pending), available | Modern Treasury "How to Think About Ledger Balances" | | Idempotency by `external_id`: matching body returns existing transaction, mismatched body returns 409-style error | Modern Treasury docs ("immutable, so your ledger records cannot be retroactively altered") + general API idempotency convention (Phase 11) | | Reversal pattern: original is left immutable; a separate "reversal" transaction with flipped entry directions is posted | Modern Treasury docs, FinLego ledger architecture | | `uuid::Uuid` implements `Ord`/`PartialOrd` natively | docs.rs/uuid | ## Architecture ``` crates/op-ledger/ ├── Cargo.toml — deps on op-core + serde + thiserror + uuid ├── src/ │ ├── lib.rs — module roots; pub re-exports │ ├── error.rs — 10-variant Error enum + #[from] op-core │ ├── ledger.rs — LedgerId (UUID v4), Ledger {id, name, description} │ ├── account.rs — AccountId, NormalBalance, AccountClass, Account │ ├── entry.rs — Direction, Entry (account_id, direction, amount) │ ├── transaction.rs — TransactionId, Status (Pending/Posted/Archived), Transaction with double-entry validation per currency │ ├── balance.rs — Balance {currency, posted, pending} │ └── store.rs — LedgerStore trait + InMemoryLedgerStore └── tests/ └── integration.rs — 10 tests proving composition with orchestrator ``` ### Data model ``` Ledger (1) ──< Account (N: same ledger) │ │ (referenced by) │ ▼ Ledger (1) ──< Transaction (N: same ledger) │ │ has-many ▼ Entry (account_id, direction, amount) ``` ### Key invariants enforced by code (not by convention) | # | Invariant | Where it's enforced | |---|---|---| | 1 | Double-entry per currency: ∀ currency c, Σ debits(c) = Σ credits(c) | `Transaction::validate_balanced()` | | 2 | Minimum 2 entries per transaction | `Transaction::construct()` | | 3 | Account currency matches entry currency | `InMemoryLedgerStore::post_transaction()` | | 4 | Account ledger matches transaction ledger | `InMemoryLedgerStore::post_transaction()` | | 5 | Account exists | `InMemoryLedgerStore::post_transaction()` | | 6 | Idempotency by `external_id`: same body → same id; different body → error | `InMemoryLedgerStore::post_transaction()` | | 7 | Terminal states are immutable: posted/archived cannot be re-transitioned | `Transaction::post() / archive()` | | 8 | Balance computation is overflow-safe | `InMemoryLedgerStore::balance()` (checked_add/sub everywhere) | ## Key design decisions ### 1. Status lifecycle is two terminal states, not three ``` Pending ──post()────► Posted (terminal — settled) │ └────archive()──► Archived (terminal — voided) ``` No "Reversed" status. Reversals are **separate** transactions that themselves go through the Pending → Posted lifecycle. The original posted transaction stays posted forever. This is the Modern Treasury convention and the auditable convention. ### 2. Reversal preserves an audit chain `Transaction::reversal_of(&original, effective_at)`: 1. Flips every entry's direction. 2. Generates a new `external_id` of `{original_external_id}:reversal` (or `{original_id}:reversal` if no external_id) so the idempotency store doesn't collide. 3. Adds a `reverses: {original_id}` metadata entry. 4. Returns a **pending** transaction (caller decides when to post). Auditors can grep for `:reversal` in `external_id` or `reverses` in metadata to enumerate all corrections. ### 3. Multi-currency invariant is per-currency A USD-only transaction needs USD debits == USD credits. A USD + EUR transaction (e.g. FX) needs: - USD debits == USD credits - EUR debits == EUR credits The crate does **not** enforce any cross-currency relationship (rates are metadata). Test: `multi_currency_balanced_per_currency` and `cross_currency_no_invariant_across_currencies`. ### 4. Balance is derived, never stored Three views computed by walking entries: - **Posted**: sum of entries from Posted transactions, signed by the account's normal balance. - **Pending**: sum of entries from Posted + Pending transactions, signed the same way. - **Available**: not implemented as a separate view in this phase. Equates to posted; left as a future extension for hold-aware models. Derivation respects the account's `NormalBalance`: - Debit-normal account (assets, expenses): balance = debits − credits - Credit-normal account (liabilities, equity, revenue): balance = credits − debits This means a healthy account always shows a positive balance, which is the natural reporting convention. ### 5. Pluggable storage via `LedgerStore` trait The 10-method trait separates the **what** from the **how**: ```rust pub trait LedgerStore: Send + Sync { fn create_ledger(&self, ledger: Ledger) -> Result; fn get_ledger(&self, id: LedgerId) -> Result; fn create_account(&self, account: Account) -> Result; fn get_account(&self, id: AccountId) -> Result; fn post_transaction(&self, t: Transaction) -> Result; fn get_transaction(&self, id: TransactionId) -> Result; fn find_by_external_id(&self, external_id: &str) -> Result>; fn mark_posted(&self, id: TransactionId) -> Result<()>; fn mark_archived(&self, id: TransactionId) -> Result<()>; fn balance(&self, id: AccountId) -> Result; } ``` The `InMemoryLedgerStore` impl is honest about being for tests and single-process kiosks: a coarse `Mutex` serializes all operations. Production deployments will write a Postgres-backed store (each method maps to a SQL transaction) or a TigerBeetle- backed one (each method maps to a TigerBeetle batch). ### 6. Oracle discipline preserved `Error::Core(#[from] op_core::Error)` wraps overflow errors verbatim. Phases 8-10 platform bridges collapse error discriminants for FFI exposure; the ledger crate exposes the rich inner variant for server-side telemetry, consistent with op-orchestrator (Phase 11). ### 7. Idempotency at construction is structural, not behavioral The `same_body()` check in `InMemoryLedgerStore` compares: 1. Same `ledger_id`, 2. Same number of entries, 3. Same `(account_id, direction, amount.minor_units)` triple after sorting (order-independent equality). Notably we do NOT compare `effective_at_unix_secs`, `description`, or `metadata`. Two re-tries with the same logical body but different descriptions (e.g. one was annotated by an operator) are still treated as the same transaction. This is the Modern Treasury convention; it's documented and tested (`idempotency_returns_existing_id_for_matching_body`). ## Bugs caught during this phase 1. **`Uuid::Ord` not assumed.** Initial draft of `sort_by_key` in `same_body` was written before verifying `Uuid: Ord`. Confirmed via docs.rs/uuid that it has been since v1.0; then added `PartialOrd, Ord` to derives of `AccountId`, `LedgerId`, `TransactionId`, `Direction` so they're sortable too. 2. **Overflow in pending-sum.** First draft computed `posted_debits + pending_debits` without `checked_add`. Fixed to propagate `op_core::Error::Overflow`. 3. **`sign_by_normal` infallible.** First draft returned `i64` directly; `debits - credits` could overflow. Refactored to `fn sign_by_normal(...) -> Result` using `checked_sub`. 4. **Test smell.** `setup()` returns `(store, lid, _cash, _rev)` with leading underscores; one test pattern was `let real = _cash;`. Code review caught it — fixed to use a non-underscore-prefixed binding throughout. 5. **`metadata` as `Vec<(String, String)>` vs `HashMap`.** Initial sketch used `HashMap` for metadata. Switched to `Vec` so we preserve insertion order and accept duplicate keys (a real need — operators may emit multiple `reverses` entries for chained reversals). Trade-off: lookup is O(n), but metadata sizes are tiny (< 10 entries typical). 6. **`find_by_external_id` returns `Option`, not `Result