Transaction Science open standards monorepo. Each subdirectory is a self-contained reference protocol with its own README and licence. - openpay/ Apache-2.0 payment-acceptance stack (Rust, 26 crates) - smart-byte/ CC-BY-4.0 content-addressed value carrier (spec) - eoc/ CC-BY-4.0 energy-optimised compute (spec) The wire format and the right to fork are public. The optional hosted services are the offer; the protocols are not.
16 KiB
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):
- Flips every entry's direction.
- Generates a new
external_idof{original_external_id}:reversal(or{original_id}:reversalif no external_id) so the idempotency store doesn't collide. - Adds a
reverses: {original_id}metadata entry. - 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:
pub trait LedgerStore: Send + Sync {
fn create_ledger(&self, ledger: Ledger) -> Result<LedgerId>;
fn get_ledger(&self, id: LedgerId) -> Result<Ledger>;
fn create_account(&self, account: Account) -> Result<AccountId>;
fn get_account(&self, id: AccountId) -> Result<Account>;
fn post_transaction(&self, t: Transaction) -> Result<TransactionId>;
fn get_transaction(&self, id: TransactionId) -> Result<Transaction>;
fn find_by_external_id(&self, external_id: &str) -> Result<Option<Transaction>>;
fn mark_posted(&self, id: TransactionId) -> Result<()>;
fn mark_archived(&self, id: TransactionId) -> Result<()>;
fn balance(&self, id: AccountId) -> Result<Balance>;
}
The InMemoryLedgerStore impl is honest about being for tests and
single-process kiosks: a coarse Mutex<Inner> 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:
- Same
ledger_id, - Same number of entries,
- 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
-
Uuid::Ordnot assumed. Initial draft ofsort_by_keyinsame_bodywas written before verifyingUuid: Ord. Confirmed via docs.rs/uuid that it has been since v1.0; then addedPartialOrd, Ordto derives ofAccountId,LedgerId,TransactionId,Directionso they're sortable too. -
Overflow in pending-sum. First draft computed
posted_debits + pending_debitswithoutchecked_add. Fixed to propagateop_core::Error::Overflow. -
sign_by_normalinfallible. First draft returnedi64directly;debits - creditscould overflow. Refactored tofn sign_by_normal(...) -> Result<i64>usingchecked_sub. -
Test smell.
setup()returns(store, lid, _cash, _rev)with leading underscores; one test pattern waslet real = _cash;. Code review caught it — fixed to use a non-underscore-prefixed binding throughout. -
metadataasVec<(String, String)>vsHashMap. Initial sketch usedHashMapfor metadata. Switched toVecso we preserve insertion order and accept duplicate keys (a real need — operators may emit multiplereversesentries for chained reversals). Trade-off: lookup is O(n), but metadata sizes are tiny (< 10 entries typical). -
find_by_external_idreturnsOption, notResult<Option>. First sketch returnedOption<Transaction>directly, but a poisoned mutex would have panicked. Wrapped inResultfor consistency with the rest of the trait (though the in-memory impl never actually returns a non-Ok value here).
What this crate does NOT do
- No persistence.
InMemoryLedgerStoreloses state on process exit. Production needs a Postgres / TigerBeetle backend. - No cross-instance consistency. A single
InMemoryLedgerStoreis correct for one process; multi-process deployments need a shared backing store. - No FX rate management. Multi-currency transactions record both sides as separate entries; the rate is the operator's metadata.
- No GAAP / IFRS classification.
AccountClassis informational. We don't produce trial balances or financial statements. - No hierarchical accounts (Categories). Modern Treasury models "Ledger Account Categories" as hierarchical aggregations of accounts. Listed as Phase 13+ future work.
- No balance locking. Modern Treasury's "balance locking" feature (used for credit limits) is not modeled. The pluggable-store interface admits a backend that implements it.
- No async wrapper. All operations are synchronous. Async
callers wrap with
tokio::task::spawn_blocking.
Composition with the orchestrator
The 10-test integration suite at tests/integration.rs shows the
operator-level pattern: an orchestration outcome maps to a
ledger transaction.
┌──────────────────────┐ ┌────────────────────────────┐
│ Orchestrator │ Approved│ Ledger │
│ run(intent) ────────┼─────────► post pending tx │
│ │ │ external_id = idem_key │
│ │ │ debit recv │
│ │ │ credit revenue │
└──────────────────────┘ └────────────────────────────┘
... settlement notification arrives ...
┌────────────────────────────┐
│ Ledger │
│ mark_posted(tx_id) │
└────────────────────────────┘
The same idempotency_key flows from the orchestrator's intent
into the ledger transaction's external_id, so duplicate
orchestration calls produce duplicate ledger posts — both of which
correctly return the existing transaction id. No double charge,
no double bookkeeping. Test: replay_with_same_external_id_does_not_double_count.
Critical: this composition does not require any code in
op-ledger that knows about the orchestrator. The crates are
decoupled; the integration is at the operator's layer.
Test count
| Module | Unit tests |
|---|---|
account.rs |
7 |
balance.rs |
3 |
entry.rs |
7 |
ledger.rs |
5 |
store.rs |
18 |
transaction.rs |
19 |
tests/integration.rs |
10 |
| Phase 12 total | 69 |
Each integration test:
approved_auth_creates_pending_transaction— Modern Treasury pattern: card auth → pending tx with pending balance set, posted balance zero.settlement_marks_posted_and_balances_settle— mark_posted moves pending → posted; both balances now reflect the amount.psp_fee_recorded_separately— fees go in their own transaction so reconciliation can compare gross vs net.replay_with_same_external_id_does_not_double_count— the no-double-charge guarantee at the ledger layer.refund_reverses_balance_to_zero— refund modeled as reversal; balance returns to zero.many_small_orders_sum_correctly— 10 orders accumulate to the expected sum (arithmetic sanity).archived_transactions_are_invisible_to_balance— voided auths don't appear in any balance view.multi_currency_ledger_supports_per_currency_accounts— USD and EUR accounts in the same ledger; their balances don't leak.kiosk_day_simulation_balances_remain_consistent— 5 orders × $10 + 5 × $0.30 fees + 1 refund; all four account balances independently verified.ledgers_are_isolated_balance_views— two merchants in different ledgers; movement in one is invisible to the other.
Cumulative state
| Phase | Tests | LOC |
|---|---|---|
| 1 op-core | 19 | ~600 |
| 2 op-iso20022 | 43 | ~1,400 |
| 3 op-emv | 50 | ~1,800 |
| 4 op-rails-card | 46 | ~2,100 |
| 5 op-rails-a2a | 73 | ~3,200 |
| 6 op-fraud | 65 | ~2,400 |
| 7 op-vault | 51 | ~2,600 |
| 8 op-ffi-swift | 44 | ~2,700 |
| 9 op-ffi-jni | 69 | ~2,950 |
| 10 op-wasm | 71 | ~2,200 |
| 11 op-orchestrator + kiosk + e2e | 90 | ~4,150 |
| 12 op-ledger | 69 | ~2,540 |
| Total | ~690 | ~28,640 |
What's next
Phase 13+ candidates (not committed):
op-webhook— async outbound webhook fanout for posting ledger events to operator-specified endpoints. Retry, signing, replay-protection.- Postgres-backed
LedgerStore— production-ready persistent backend. - TigerBeetle-backed
LedgerStore— high-throughput backend for high-volume merchants. op-reconciliation— diff a ledger against bank/PSP statements; surface discrepancies as a stream.- OpenTelemetry trace propagation — single trace ID flows from the orchestrator's intent into the ledger transaction's metadata, observable across the whole stack.
- Balance locking for credit-limit / overdraft-prevention flows.
- Ledger account categories for hierarchical aggregations.