소스 검색

Introduce Kuatia, an append-only, auditable multi-asset ledger

Kuatia tracks value as signed postings rather than mutable balance
fields, so every state change is an immutable record and the full history
is auditable. A transfer atomically consumes and creates postings and
must conserve value per asset (the sum of consumed equals the sum of
created), which is the double-entry safety invariant enforced on every
commit.

The public surface is intent-based. Callers describe movements
(pay/deposit/withdraw) through a builder; the core resolves them into a
concrete envelope of postings to consume and create, selecting inputs
greedily and computing change or an overdraft posting as the account
policy allows. Overdraft behavior is a per-account policy: NoOverdraft
forbids negative postings, capped and uncapped variants permit them down
to a floor or without bound, and system/external accounts model the
ledger boundary.

Commits run through a two-step saga, reserve then finalize, with
validation as the last thing before the writes, automatic retry, and LIFO
compensation. Transfers are content-addressed (a double SHA-256 of their
canonical bytes), which gives idempotency and tamper evidence. Storage is
deliberately dumb: each write primitive applies one conditional update and
returns the number of rows it changed, and the saga owns all
interpretation, idempotency, and compensation. Crash safety comes from a
phase-tracked write-ahead record plus a recover() that rolls a
half-applied commit forward rather than unwinding it.

The code separates a pure, sans-IO core from the async layer. The core is
deterministic and unit-testable; the async layer adds the Store trait and
the saga. Storage backends (in-memory and SQLite/PostgreSQL) share one
conformance suite, and concurrency tests pin the guarantees that matter:
double-spend prevention is exact, while the overdraft floor re-check is
best-effort under concurrency (documented, with conservation preserved).

Identifiers are snowflake-style i64 values generated in Rust, never by the
database, and are unique across threads. The monetary type hides its
backing integer, which is swappable from i64 to i128 at compile time. The
repository ships architecture and API docs plus a set of ADRs recording
the design decisions.
Cesar Rodas 2 주 전
커밋
ee2e0e2b12
69개의 변경된 파일15715개의 추가작업 그리고 0개의 파일을 삭제
  1. 52 0
      .github/workflows/ci.yml
  2. 2 0
      .gitignore
  3. 44 0
      CHANGELOG.md
  4. 94 0
      CLAUDE.md
  5. 1857 0
      Cargo.lock
  6. 40 0
      Cargo.toml
  7. 190 0
      LICENSE
  8. 80 0
      README.md
  9. 24 0
      crates/kuatia-core/Cargo.toml
  10. 31 0
      crates/kuatia-core/README.md
  11. 103 0
      crates/kuatia-core/src/hash.rs
  12. 18 0
      crates/kuatia-core/src/lib.rs
  13. 172 0
      crates/kuatia-core/src/posting_selection.rs
  14. 1121 0
      crates/kuatia-core/src/validate.rs
  15. 26 0
      crates/kuatia-money/Cargo.toml
  16. 27 0
      crates/kuatia-money/README.md
  17. 486 0
      crates/kuatia-money/src/lib.rs
  18. 33 0
      crates/kuatia-storage-sql/Cargo.toml
  19. 40 0
      crates/kuatia-storage-sql/README.md
  20. 936 0
      crates/kuatia-storage-sql/src/lib.rs
  21. 61 0
      crates/kuatia-storage-sql/src/migrations/postgres/001_init.sql
  22. 61 0
      crates/kuatia-storage-sql/src/migrations/sqlite/001_init.sql
  23. 33 0
      crates/kuatia-storage-sql/tests/sqlite.rs
  24. 29 0
      crates/kuatia-storage/Cargo.toml
  25. 37 0
      crates/kuatia-storage/README.md
  26. 49 0
      crates/kuatia-storage/src/error.rs
  27. 80 0
      crates/kuatia-storage/src/events.rs
  28. 10 0
      crates/kuatia-storage/src/lib.rs
  29. 393 0
      crates/kuatia-storage/src/mem_store.rs
  30. 261 0
      crates/kuatia-storage/src/store.rs
  31. 1007 0
      crates/kuatia-storage/src/store_tests.rs
  32. 73 0
      crates/kuatia-storage/tests/concurrency.rs
  33. 9 0
      crates/kuatia-storage/tests/store_conformance.rs
  34. 24 0
      crates/kuatia-types/Cargo.toml
  35. 25 0
      crates/kuatia-types/README.md
  36. 198 0
      crates/kuatia-types/src/autoid.rs
  37. 967 0
      crates/kuatia-types/src/lib.rs
  38. 37 0
      crates/kuatia/Cargo.toml
  39. 104 0
      crates/kuatia/README.md
  40. 74 0
      crates/kuatia/examples/create_accounts.rs
  41. 103 0
      crates/kuatia/examples/fund_and_trade.rs
  42. 78 0
      crates/kuatia/examples/withdraw.rs
  43. 102 0
      crates/kuatia/src/error.rs
  44. 1230 0
      crates/kuatia/src/ledger.rs
  45. 28 0
      crates/kuatia/src/lib.rs
  46. 451 0
      crates/kuatia/src/saga.rs
  47. 409 0
      crates/kuatia/tests/concurrency.rs
  48. 908 0
      crates/kuatia/tests/integration.rs
  49. 226 0
      crates/kuatia/tests/saga.rs
  50. 173 0
      doc/accounting-mapping.md
  51. 142 0
      doc/accounts.md
  52. 145 0
      doc/adr/0001-modified-utxo-signed-postings.md
  53. 134 0
      doc/adr/0002-saga-commit-pipeline.md
  54. 146 0
      doc/adr/0003-dumb-storage-saga-recovery.md
  55. 119 0
      doc/adr/0004-account-policies-overdraft-model.md
  56. 107 0
      doc/adr/0005-intent-api-movements-vs-envelopes.md
  57. 126 0
      doc/adr/0006-reservation-protocol-posting-lifecycle.md
  58. 111 0
      doc/adr/0007-reversal-via-compensating-transfers.md
  59. 119 0
      doc/adr/0008-conformance-tested-storage.md
  60. 155 0
      doc/adr/0009-monetary-representation-integer-minor-units.md
  61. 149 0
      doc/adr/0010-event-stream-vs-transfer-log.md
  62. 179 0
      doc/adr/0011-swappable-money-backing.md
  63. 60 0
      doc/adr/README.md
  64. 62 0
      doc/adr/template.md
  65. 430 0
      doc/architecture.md
  66. 308 0
      doc/crates.md
  67. 355 0
      doc/glossary.md
  68. 247 0
      doc/transfers.md
  69. 5 0
      rust-toolchain.toml

+ 52 - 0
.github/workflows/ci.yml

@@ -0,0 +1,52 @@
+name: CI
+
+on:
+  push:
+    branches: [master, main]
+  pull_request:
+
+env:
+  CARGO_TERM_COLOR: always
+  RUSTFLAGS: -Dwarnings
+
+jobs:
+  fmt:
+    name: Format
+    runs-on: ubuntu-latest
+    steps:
+      - uses: actions/checkout@v4
+      - uses: dtolnay/rust-toolchain@stable
+        with:
+          components: rustfmt
+      - run: cargo fmt --all --check
+
+  clippy:
+    name: Clippy
+    runs-on: ubuntu-latest
+    steps:
+      - uses: actions/checkout@v4
+      - uses: dtolnay/rust-toolchain@stable
+        with:
+          components: clippy
+      - uses: Swatinem/rust-cache@v2
+      - run: cargo clippy --all-targets --all-features
+
+  doc:
+    name: Docs
+    runs-on: ubuntu-latest
+    env:
+      RUSTDOCFLAGS: -Dwarnings
+    steps:
+      - uses: actions/checkout@v4
+      - uses: dtolnay/rust-toolchain@stable
+      - uses: Swatinem/rust-cache@v2
+      - run: cargo doc --workspace --all-features --no-deps
+
+  test:
+    name: Test
+    runs-on: ubuntu-latest
+    steps:
+      - uses: actions/checkout@v4
+      - uses: dtolnay/rust-toolchain@stable
+      - uses: Swatinem/rust-cache@v2
+      - run: cargo test --all

+ 2 - 0
.gitignore

@@ -0,0 +1,2 @@
+/target
+.DS_Store

+ 44 - 0
CHANGELOG.md

@@ -0,0 +1,44 @@
+# Changelog
+
+All notable changes to this project are documented in this file.
+
+The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/),
+and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
+
+## [0.1.0] - 2026-06-30
+
+Initial release.
+
+### Added
+
+- Append-only, multi-asset, UTXO-style ledger. Value is tracked as signed
+  postings with no mutable balance fields. Transfers atomically consume and
+  create postings, enforcing per-asset conservation (`sum(consumed) ==
+  sum(created)`).
+- Intent API: movements (`pay`, `deposit`, `withdraw`) resolved into concrete
+  postings by the core, committed through a single `reserve → finalize` saga
+  with automatic retry and LIFO compensation.
+- Content-addressed transfers (double-SHA-256 of canonical bytes) for
+  idempotency and tamper evidence.
+- Account policies: `NoOverdraft`, `CappedOverdraft`, `UncappedOverdraft`,
+  `SystemAccount`, `ExternalAccount`, with append-only versioned accounts and
+  snapshot pinning to guard against TOCTOU races.
+- Durable crash recovery via a phase-tracked write-ahead saga record and
+  `Ledger::recover()` (roll-forward, not rollback).
+- Dumb-storage `Store` trait split into focused sub-traits, with an in-memory
+  backend and a SQLite/PostgreSQL backend (`kuatia-storage-sql`).
+- A conformance test suite (`store_tests!`) applied to every storage backend.
+- Snowflake-style `i64` IDs generated in Rust; the database never assigns IDs.
+- Compile-time swappable monetary backing (`i64` default, `i128` via the
+  `i128` feature).
+
+### Crates
+
+- `kuatia-money` — monetary `Cent` type with swappable integer backing.
+- `kuatia-types` — domain types: accounts, postings, transfers, books.
+- `kuatia-core` — pure, sans-IO logic: validation, hashing, posting selection.
+- `kuatia-storage` — storage abstraction and conformance suite.
+- `kuatia-storage-sql` — SQLite/PostgreSQL backend.
+- `kuatia` — async `Ledger` resource and saga commit pipeline.
+
+[0.1.0]: https://github.com/crodas/kuatia/releases/tag/v0.1.0

+ 94 - 0
CLAUDE.md

@@ -0,0 +1,94 @@
+# Kuatia — Project Context
+
+## What is this
+
+Kuatia is an append-only, auditable, multi-asset UTXO-style ledger library in Rust. Value is tracked as signed postings — no mutable balance fields. Transfers atomically consume and create postings, enforcing per-asset conservation — the double-entry-style safety invariant (`sum(consumed) == sum(created)` per asset).
+
+## Crate layout
+
+```
+crates/
+  kuatia-money/     Cent monetary type + CentBacking trait; integer width (i64 default, i128 via feature) is hidden and swappable
+  kuatia-types/     Domain types: AccountId, Posting, Movement, AutoId, etc.; re-exports Cent/Amount from kuatia-money
+  kuatia-core/      Pure, sync, no-IO logic: validation, hashing, posting selection
+  kuatia-storage/   Store trait (7 sub-traits), InMemoryStore, conformance tests
+  kuatia-storage-sql/  SQL backend: SQLite/PostgreSQL via sqlx
+  kuatia/           Async layer: Ledger resource, saga pipeline, intent API
+doc/
+  architecture.md   Architecture decisions and rationale
+  crates.md         Crate reference: modules, types, APIs
+  accounts.md       Account model, policies, lifecycle
+  transfers.md      Transfer/Movement API, resolve algorithm
+  glossary.md       Terms, book design, exchange & supermarket examples
+  accounting-mapping.md  Classical double-entry ↔ Kuatia term mapping
+```
+
+## Key concepts
+
+- **Posting**: signed amount of one asset owned by one account. Lifecycle: Active → PendingInactive → Inactive.
+- **Movement**: `{ from, to, asset, amount }` — the fundamental unit of intent. All operations (pay, deposit, withdraw) are one or more movements.
+- **Envelope**: concrete postings to consume and create — the resolved form of movements.
+- **Conservation**: for each asset, `sum(consumed) == sum(created)`.
+- **Account policies**: NoOverdraft, CappedOverdraft, UncappedOverdraft, SystemAccount, ExternalAccount. Only `NoOverdraft` forbids negative postings; the other four permit them. An overdraft is a negative posting that covers a shortfall — down to the floor for `CappedOverdraft`, unbounded for `UncappedOverdraft`.
+- **Dumb storage**: the `Store` is a thin instruction follower. Write methods apply one update and return the **number of affected rows** (or an I/O error) — they never interpret counts, decide state, enforce idempotency, or compensate. The saga owns all of that. There is no monolithic `commit_transfer`; commit is a sequence of dumb primitives (`reserve_postings`, `deactivate_postings`, `insert_postings`, `store_transfer`, `append_event`), each idempotent. See [doc/adr/0003-dumb-storage-saga-recovery.md](doc/adr/0003-dumb-storage-saga-recovery.md).
+
+## Architecture
+
+- **Pure core / async layer separation**: kuatia-core has zero IO, fully deterministic, testable with golden vectors. kuatia adds async Store trait and saga pipeline.
+- **Saga commit pipeline**: every commit is the **two-step** envelope saga `reserve → finalize` (validation runs inside the finalize step, as the last thing before the writes), with automatic retry and LIFO compensation via the `legend` crate. `commit(transfer)` = resolve (read-only) then `commit_envelope`; `reverse()` builds a reversal envelope and runs the same path. There is one commit path, not a separate "atomic" one.
+- **Count interpretation**: the saga reads each primitive's affected-row count — full = continue; partial = error → compensate; zero = read state and continue only if this same envelope/reservation already applied it (idempotency). `finalize_envelope` additionally verifies every end-state (all consumed postings `Inactive`, created exist, transfer stored).
+- **Durable recovery**: a phase-tracked write-ahead `PendingSaga {envelope, reservation, phase}` is persisted via `SagaStore` before the saga mutates anything (`Reserving`), bumped to `Finalizing` once validation passed and the consumed postings are about to turn `Inactive`. `Ledger::recover()` (call on startup) branches on phase: a `Reserving` saga is **re-run and re-validated** (aborting cleanly if a posting was taken or an account frozen); a `Finalizing` saga is rolled forward through the verified `finalize_envelope`. Roll-forward, not rollback, so there are no orphaned `PendingInactive` postings to reconcile.
+- **Content-addressed transfers**: EnvelopeId = double-SHA-256 of canonical bytes. Provides idempotency and tamper evidence.
+- **Append-only accounts**: versioned, never modified in place. Snapshot pinning (validate-time) prevents TOCTOU races; under the dumb-storage model the overdraft-floor and freeze/close guards are validate-time and best-effort under concurrency.
+- **Store uses `Arc<dyn Store>`**: Ledger is non-generic, enabling concrete saga types.
+
+## Resolve algorithm
+
+Two-pass:
+1. For each movement, create output posting on `to` and accumulate net debit on `from`.
+2. For each (account, asset) with positive net debit, select postings (greedy largest-first) and compute change. If positive postings are insufficient: `CappedOverdraft`/`UncappedOverdraft` accounts consume all positives and create a negative posting for the shortfall (floor enforced in validation); other policies fail with `InsufficientFunds`.
+
+Deposit: two movements cancel to zero net debit on the system account — no posting selection needed.
+
+## Validation steps (validate_and_plan)
+
+1. Non-empty
+2. No duplicate consumed PostingIds
+3. Consumed postings exist
+4. Consumed postings Active or PendingInactive
+5. Referenced accounts exist, not frozen, not closed
+6. Account snapshot pinning
+7. Book policy (if a book is loaded): referenced assets/accounts/flags allowed by the book
+8. Per-asset conservation
+9. Negative postings forbidden only on `NoOverdraft` (allowed on overdraft/system/external)
+10. Policy enforcement (balance floor)
+
+## Testing
+
+```bash
+cargo test          # runs all tests across all crates
+cargo test -p kuatia-core   # pure core tests only
+cargo test -p kuatia        # integration + saga tests
+```
+
+## Conventions
+
+- Clarity over cleverness
+- **All arithmetic in Rust only** — the storage layer is a dumb record keeper. No SQL `SUM`, `MAX`, `MIN`, `AVG`, or any computation on monetary amounts or domain values in queries. `COUNT(*)` for pagination row totals is allowed (it counts rows, not domain values). Balances are always computed in Rust with checked arithmetic (`checked_add`, `checked_sub`, `checked_neg`) — no silent overflow
+- No `unwrap()`/`expect()` in production code — all errors bubble up via `Result`
+- Domain types for all identifiers — never raw integers or byte arrays in public APIs
+- Use "Posting" not "Coin" for accounting clarity
+- TransferBuilder convenience methods (`.pay()`, `.deposit()`, `.withdraw()`) over raw `.movement()` construction
+- Every Store sub-trait method must have a conformance test in `store_tests!` macro — new trait methods require new tests
+- `.deposit()` returns `Result<Self, OverflowError>` — callers must handle the error
+- **No AUTOINCREMENT / SERIAL in the database** — all IDs are generated in Rust. Use snowflake-style `i64` IDs with the following bit layout:
+  ```
+  [0][  40 bits: ms timestamp  ][ 23 bits: CRC32(data) ]
+   ^sign (always 0 = positive)
+  ```
+  - Bit 63: always 0 (keeps i64 positive)
+  - Bits 62–23: milliseconds since `KUATIA_EPOCH_MS` (2026-01-01T00:00:00Z), not the Unix epoch — 40 bits ≈ 34.8 years going forward (until ~2060)
+  - Bits 22–0: lower 23 bits of CRC32 of context-specific data (e.g. serialized event)
+  - When no data is provided, an internal atomic counter is used (wraps on 23-bit overflow)
+  - Implementation: `AutoId` in `kuatia-types/src/autoid.rs`, includes inline CRC32 (IEEE)
+  - Generated in Rust, stored as plain `BIGINT` — the DB never assigns IDs

+ 1857 - 0
Cargo.lock

@@ -0,0 +1,1857 @@
+# This file is automatically @generated by Cargo.
+# It is not intended for manual editing.
+version = 4
+
+[[package]]
+name = "allocator-api2"
+version = "0.2.21"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "683d7910e743518b0e34f1186f92494becacb047c7b6bf616c96772180fef923"
+
+[[package]]
+name = "async-trait"
+version = "0.1.89"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "9035ad2d096bed7955a320ee7e2230574d28fd3c3a0f186cbea1ff3c7eed5dbb"
+dependencies = [
+ "proc-macro2",
+ "quote",
+ "syn",
+]
+
+[[package]]
+name = "atoi"
+version = "2.0.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "f28d99ec8bfea296261ca1af174f24225171fea9664ba9003cbebee704810528"
+dependencies = [
+ "num-traits",
+]
+
+[[package]]
+name = "autocfg"
+version = "1.5.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "f2032f911046de80f0a198e0901378627c33f59ea0ac00e363d481118bd70a53"
+
+[[package]]
+name = "base64"
+version = "0.22.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "72b3254f16251a8381aa12e40e3c4d2f0199f8c6508fbecb9d91f575e0fbb8c6"
+
+[[package]]
+name = "base64ct"
+version = "1.8.3"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "2af50177e190e07a26ab74f8b1efbfe2ef87da2116221318cb1c2e82baf7de06"
+
+[[package]]
+name = "bitflags"
+version = "2.13.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "b4388bee8683e3d04af747c73422af53102d2bd24d9eadb6cbc100baef4b43f8"
+dependencies = [
+ "serde_core",
+]
+
+[[package]]
+name = "block-buffer"
+version = "0.10.4"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "3078c7629b62d3f0439517fa394996acacc5cbc91c5a20d8c658e77abd503a71"
+dependencies = [
+ "generic-array",
+]
+
+[[package]]
+name = "bumpalo"
+version = "3.20.3"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "72f5acc6cb2ba439de613abc23857ec3d78374d8ed5ac84e9d11336e87da8649"
+
+[[package]]
+name = "byteorder"
+version = "1.5.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "1fd0f2584146f6f2ef48085050886acf353beff7305ebd1ae69500e27c67f64b"
+
+[[package]]
+name = "bytes"
+version = "1.12.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "8ae3f5d315924270530207e2a68396c3cc547f6dca3fbdca317cfb1a51edb593"
+
+[[package]]
+name = "cc"
+version = "1.2.65"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "e228eec9be7c17ccb640b59b36a5cd805ea2a564a4c5e162c2f659fea30d3b96"
+dependencies = [
+ "find-msvc-tools",
+ "shlex",
+]
+
+[[package]]
+name = "cfg-if"
+version = "1.0.4"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "9330f8b2ff13f34540b44e946ef35111825727b38d33286ef986142615121801"
+
+[[package]]
+name = "concurrent-queue"
+version = "2.5.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "4ca0197aee26d1ae37445ee532fefce43251d24cc7c166799f4d46817f1d3973"
+dependencies = [
+ "crossbeam-utils",
+]
+
+[[package]]
+name = "const-oid"
+version = "0.9.6"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "c2459377285ad874054d797f3ccebf984978aa39129f6eafde5cdc8315b612f8"
+
+[[package]]
+name = "cpufeatures"
+version = "0.2.17"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "59ed5838eebb26a2bb2e58f6d5b5316989ae9d08bab10e0e6d103e656d1b0280"
+dependencies = [
+ "libc",
+]
+
+[[package]]
+name = "crc"
+version = "3.4.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "5eb8a2a1cd12ab0d987a5d5e825195d372001a4094a0376319d5a0ad71c1ba0d"
+dependencies = [
+ "crc-catalog",
+]
+
+[[package]]
+name = "crc-catalog"
+version = "2.5.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "217698eaf96b4a3f0bc4f3662aaa55bdf913cd54d7204591faa790070c6d0853"
+
+[[package]]
+name = "crossbeam-queue"
+version = "0.3.12"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "0f58bbc28f91df819d0aa2a2c00cd19754769c2fad90579b3592b1c9ba7a3115"
+dependencies = [
+ "crossbeam-utils",
+]
+
+[[package]]
+name = "crossbeam-utils"
+version = "0.8.21"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "d0a5c400df2834b80a4c3327b3aad3a4c4cd4de0629063962b03235697506a28"
+
+[[package]]
+name = "crypto-common"
+version = "0.1.7"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "78c8292055d1c1df0cce5d180393dc8cce0abec0a7102adb6c7b1eef6016d60a"
+dependencies = [
+ "generic-array",
+ "typenum",
+]
+
+[[package]]
+name = "der"
+version = "0.7.10"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "e7c1832837b905bbfb5101e07cc24c8deddf52f93225eee6ead5f4d63d53ddcb"
+dependencies = [
+ "const-oid",
+ "pem-rfc7468",
+ "zeroize",
+]
+
+[[package]]
+name = "digest"
+version = "0.10.7"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "9ed9a281f7bc9b7576e61468ba615a66a5c8cfdff42420a70aa82701a3b1e292"
+dependencies = [
+ "block-buffer",
+ "const-oid",
+ "crypto-common",
+ "subtle",
+]
+
+[[package]]
+name = "displaydoc"
+version = "0.2.6"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "1ac70aa55017e108007fbaf5aa0f54b021c98f92ff8af59d42eda9da96e3dd4f"
+dependencies = [
+ "proc-macro2",
+ "quote",
+ "syn",
+]
+
+[[package]]
+name = "dotenvy"
+version = "0.15.7"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "1aaf95b3e5c8f23aa320147307562d361db0ae0d51242340f558153b4eb2439b"
+
+[[package]]
+name = "either"
+version = "1.16.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "91622ff5e7162018101f2fea40d6ebf4a78bbe5a49736a2020649edf9693679e"
+dependencies = [
+ "serde",
+]
+
+[[package]]
+name = "equivalent"
+version = "1.0.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "877a4ace8713b0bcf2a4e7eec82529c029f1d0619886d18145fea96c3ffe5c0f"
+
+[[package]]
+name = "errno"
+version = "0.3.14"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "39cab71617ae0d63f51a36d69f866391735b51691dbda63cf6f96d042b63efeb"
+dependencies = [
+ "libc",
+ "windows-sys 0.61.2",
+]
+
+[[package]]
+name = "etcetera"
+version = "0.8.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "136d1b5283a1ab77bd9257427ffd09d8667ced0570b6f938942bc7568ed5b943"
+dependencies = [
+ "cfg-if",
+ "home",
+ "windows-sys 0.48.0",
+]
+
+[[package]]
+name = "event-listener"
+version = "5.4.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "e13b66accf52311f30a0db42147dadea9850cb48cd070028831ae5f5d4b856ab"
+dependencies = [
+ "concurrent-queue",
+ "parking",
+ "pin-project-lite",
+]
+
+[[package]]
+name = "find-msvc-tools"
+version = "0.1.9"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "5baebc0774151f905a1a2cc41989300b1e6fbb29aff0ceffa1064fdd3088d582"
+
+[[package]]
+name = "flume"
+version = "0.11.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "da0e4dd2a88388a1f4ccc7c9ce104604dab68d9f408dc34cd45823d5a9069095"
+dependencies = [
+ "futures-core",
+ "futures-sink",
+ "spin",
+]
+
+[[package]]
+name = "foldhash"
+version = "0.1.5"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "d9c4f5dac5e15c24eb999c26181a6ca40b39fe946cbe4c263c7209467bc83af2"
+
+[[package]]
+name = "form_urlencoded"
+version = "1.2.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "cb4cb245038516f5f85277875cdaa4f7d2c9a0fa0468de06ed190163b1581fcf"
+dependencies = [
+ "percent-encoding",
+]
+
+[[package]]
+name = "futures-channel"
+version = "0.3.32"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "07bbe89c50d7a535e539b8c17bc0b49bdb77747034daa8087407d655f3f7cc1d"
+dependencies = [
+ "futures-core",
+ "futures-sink",
+]
+
+[[package]]
+name = "futures-core"
+version = "0.3.32"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "7e3450815272ef58cec6d564423f6e755e25379b217b0bc688e295ba24df6b1d"
+
+[[package]]
+name = "futures-executor"
+version = "0.3.32"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "baf29c38818342a3b26b5b923639e7b1f4a61fc5e76102d4b1981c6dc7a7579d"
+dependencies = [
+ "futures-core",
+ "futures-task",
+ "futures-util",
+]
+
+[[package]]
+name = "futures-intrusive"
+version = "0.5.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "1d930c203dd0b6ff06e0201a4a2fe9149b43c684fd4420555b26d21b1a02956f"
+dependencies = [
+ "futures-core",
+ "lock_api",
+ "parking_lot",
+]
+
+[[package]]
+name = "futures-io"
+version = "0.3.32"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "cecba35d7ad927e23624b22ad55235f2239cfa44fd10428eecbeba6d6a717718"
+
+[[package]]
+name = "futures-sink"
+version = "0.3.32"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "c39754e157331b013978ec91992bde1ac089843443c49cbc7f46150b0fad0893"
+
+[[package]]
+name = "futures-task"
+version = "0.3.32"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "037711b3d59c33004d3856fbdc83b99d4ff37a24768fa1be9ce3538a1cde4393"
+
+[[package]]
+name = "futures-util"
+version = "0.3.32"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "389ca41296e6190b48053de0321d02a77f32f8a5d2461dd38762c0593805c6d6"
+dependencies = [
+ "futures-core",
+ "futures-io",
+ "futures-sink",
+ "futures-task",
+ "memchr",
+ "pin-project-lite",
+ "slab",
+]
+
+[[package]]
+name = "generic-array"
+version = "0.14.7"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "85649ca51fd72272d7821adaf274ad91c288277713d9c18820d8499a7ff69e9a"
+dependencies = [
+ "typenum",
+ "version_check",
+]
+
+[[package]]
+name = "getrandom"
+version = "0.2.17"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "ff2abc00be7fca6ebc474524697ae276ad847ad0a6b3faa4bcb027e9a4614ad0"
+dependencies = [
+ "cfg-if",
+ "libc",
+ "wasi",
+]
+
+[[package]]
+name = "getrandom"
+version = "0.4.3"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "300e883d756b2e4ec94e02791f39b04b522276138852cfc41d9fb7e904106099"
+dependencies = [
+ "cfg-if",
+ "libc",
+ "r-efi",
+]
+
+[[package]]
+name = "hashbrown"
+version = "0.15.5"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "9229cfe53dfd69f0609a49f65461bd93001ea1ef889cd5529dd176593f5338a1"
+dependencies = [
+ "allocator-api2",
+ "equivalent",
+ "foldhash",
+]
+
+[[package]]
+name = "hashbrown"
+version = "0.17.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "ed5909b6e89a2db4456e54cd5f673791d7eca6732202bbf2a9cc504fe2f9b84a"
+
+[[package]]
+name = "hashlink"
+version = "0.10.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "7382cf6263419f2d8df38c55d7da83da5c18aef87fc7a7fc1fb1e344edfe14c1"
+dependencies = [
+ "hashbrown 0.15.5",
+]
+
+[[package]]
+name = "heck"
+version = "0.5.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "2304e00983f87ffb38b55b444b5e3b60a884b5d30c0fca7d82fe33449bbe55ea"
+
+[[package]]
+name = "hex"
+version = "0.4.3"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "7f24254aa9a54b5c858eaee2f5bccdb46aaf0e486a595ed5fd8f86ba55232a70"
+
+[[package]]
+name = "hkdf"
+version = "0.12.4"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "7b5f8eb2ad728638ea2c7d47a21db23b7b58a72ed6a38256b8a1849f15fbbdf7"
+dependencies = [
+ "hmac",
+]
+
+[[package]]
+name = "hmac"
+version = "0.12.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "6c49c37c09c17a53d937dfbb742eb3a961d65a994e6bcdcf37e7399d0cc8ab5e"
+dependencies = [
+ "digest",
+]
+
+[[package]]
+name = "home"
+version = "0.5.12"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "cc627f471c528ff0c4a49e1d5e60450c8f6461dd6d10ba9dcd3a61d3dff7728d"
+dependencies = [
+ "windows-sys 0.61.2",
+]
+
+[[package]]
+name = "icu_collections"
+version = "2.2.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "2984d1cd16c883d7935b9e07e44071dca8d917fd52ecc02c04d5fa0b5a3f191c"
+dependencies = [
+ "displaydoc",
+ "potential_utf",
+ "utf8_iter",
+ "yoke",
+ "zerofrom",
+ "zerovec",
+]
+
+[[package]]
+name = "icu_locale_core"
+version = "2.2.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "92219b62b3e2b4d88ac5119f8904c10f8f61bf7e95b640d25ba3075e6cac2c29"
+dependencies = [
+ "displaydoc",
+ "litemap",
+ "tinystr",
+ "writeable",
+ "zerovec",
+]
+
+[[package]]
+name = "icu_normalizer"
+version = "2.2.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "c56e5ee99d6e3d33bd91c5d85458b6005a22140021cc324cea84dd0e72cff3b4"
+dependencies = [
+ "icu_collections",
+ "icu_normalizer_data",
+ "icu_properties",
+ "icu_provider",
+ "smallvec",
+ "zerovec",
+]
+
+[[package]]
+name = "icu_normalizer_data"
+version = "2.2.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "da3be0ae77ea334f4da67c12f149704f19f81d1adf7c51cf482943e84a2bad38"
+
+[[package]]
+name = "icu_properties"
+version = "2.2.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "bee3b67d0ea5c2cca5003417989af8996f8604e34fb9ddf96208a033901e70de"
+dependencies = [
+ "icu_collections",
+ "icu_locale_core",
+ "icu_properties_data",
+ "icu_provider",
+ "zerotrie",
+ "zerovec",
+]
+
+[[package]]
+name = "icu_properties_data"
+version = "2.2.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "8e2bbb201e0c04f7b4b3e14382af113e17ba4f63e2c9d2ee626b720cbce54a14"
+
+[[package]]
+name = "icu_provider"
+version = "2.2.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "139c4cf31c8b5f33d7e199446eff9c1e02decfc2f0eec2c8d71f65befa45b421"
+dependencies = [
+ "displaydoc",
+ "icu_locale_core",
+ "writeable",
+ "yoke",
+ "zerofrom",
+ "zerotrie",
+ "zerovec",
+]
+
+[[package]]
+name = "idna"
+version = "1.1.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "3b0875f23caa03898994f6ddc501886a45c7d3d62d04d2d90788d47be1b1e4de"
+dependencies = [
+ "idna_adapter",
+ "smallvec",
+ "utf8_iter",
+]
+
+[[package]]
+name = "idna_adapter"
+version = "1.2.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "cb68373c0d6620ef8105e855e7745e18b0d00d3bdb07fb532e434244cdb9a714"
+dependencies = [
+ "icu_normalizer",
+ "icu_properties",
+]
+
+[[package]]
+name = "indexmap"
+version = "2.14.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "d466e9454f08e4a911e14806c24e16fba1b4c121d1ea474396f396069cf949d9"
+dependencies = [
+ "equivalent",
+ "hashbrown 0.17.1",
+]
+
+[[package]]
+name = "itoa"
+version = "1.0.18"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "8f42a60cbdf9a97f5d2305f08a87dc4e09308d1276d28c869c684d7777685682"
+
+[[package]]
+name = "js-sys"
+version = "0.3.102"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "03d04c30968dffe80775bd4d7fb676131cd04a1fb46d2686dbffbaec2d9dfd31"
+dependencies = [
+ "cfg-if",
+ "futures-util",
+ "wasm-bindgen",
+]
+
+[[package]]
+name = "kuatia"
+version = "0.1.0"
+dependencies = [
+ "async-trait",
+ "kuatia-core",
+ "kuatia-storage",
+ "kuatia-storage-sql",
+ "kuatia-types",
+ "legend",
+ "serde",
+ "serde_json",
+ "sqlx",
+ "tokio",
+ "tracing",
+]
+
+[[package]]
+name = "kuatia-core"
+version = "0.1.0"
+dependencies = [
+ "kuatia-types",
+ "serde",
+ "sha2",
+]
+
+[[package]]
+name = "kuatia-money"
+version = "0.1.0"
+dependencies = [
+ "serde",
+ "serde_json",
+]
+
+[[package]]
+name = "kuatia-storage"
+version = "0.1.0"
+dependencies = [
+ "async-trait",
+ "kuatia-types",
+ "paste",
+ "serde",
+ "tokio",
+]
+
+[[package]]
+name = "kuatia-storage-sql"
+version = "0.1.0"
+dependencies = [
+ "async-trait",
+ "kuatia-storage",
+ "kuatia-types",
+ "paste",
+ "serde",
+ "serde_json",
+ "sqlx",
+ "tokio",
+]
+
+[[package]]
+name = "kuatia-types"
+version = "0.1.0"
+dependencies = [
+ "bitflags",
+ "kuatia-money",
+ "serde",
+]
+
+[[package]]
+name = "lazy_static"
+version = "1.5.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "bbd2bcb4c963f2ddae06a2efc7e9f3591312473c50c6685e1f298068316e66fe"
+dependencies = [
+ "spin",
+]
+
+[[package]]
+name = "legend"
+version = "0.1.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "ffc5750e79a4fb740923e73316c3176043859e24154bf874baeab765cefc806b"
+dependencies = [
+ "async-trait",
+ "parking_lot",
+ "paste",
+ "serde",
+ "serde_json",
+ "thiserror",
+ "tokio",
+ "uuid",
+]
+
+[[package]]
+name = "libc"
+version = "0.2.186"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "68ab91017fe16c622486840e4c83c9a37afeff978bd239b5293d61ece587de66"
+
+[[package]]
+name = "libm"
+version = "0.2.16"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "b6d2cec3eae94f9f509c767b45932f1ada8350c4bdb85af2fcab4a3c14807981"
+
+[[package]]
+name = "libredox"
+version = "0.1.17"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "f02ab6bace2054fb888a3c16f990117b579d14a3088e472d63c6011fa185c9d3"
+dependencies = [
+ "bitflags",
+ "libc",
+ "plain",
+ "redox_syscall 0.8.1",
+]
+
+[[package]]
+name = "libsqlite3-sys"
+version = "0.30.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "2e99fb7a497b1e3339bc746195567ed8d3e24945ecd636e3619d20b9de9e9149"
+dependencies = [
+ "cc",
+ "pkg-config",
+ "vcpkg",
+]
+
+[[package]]
+name = "litemap"
+version = "0.8.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "92daf443525c4cce67b150400bc2316076100ce0b3686209eb8cf3c31612e6f0"
+
+[[package]]
+name = "lock_api"
+version = "0.4.14"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "224399e74b87b5f3557511d98dff8b14089b3dadafcab6bb93eab67d3aace965"
+dependencies = [
+ "scopeguard",
+]
+
+[[package]]
+name = "log"
+version = "0.4.33"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "0ceec5bc11778974d1bcb055b18002eba7f4b3518b6a0081b3af5f21666da9ad"
+
+[[package]]
+name = "md-5"
+version = "0.10.6"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "d89e7ee0cfbedfc4da3340218492196241d89eefb6dab27de5df917a6d2e78cf"
+dependencies = [
+ "cfg-if",
+ "digest",
+]
+
+[[package]]
+name = "memchr"
+version = "2.8.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "88904434abc2901f197fe8cc55f0445e7ded921dba5911dad2e2b39b48e663c4"
+
+[[package]]
+name = "mio"
+version = "1.2.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "02bd0af71c67b473010cbbc60715ee815645a4dc942899111f494b4b737d6fda"
+dependencies = [
+ "libc",
+ "wasi",
+ "windows-sys 0.61.2",
+]
+
+[[package]]
+name = "num-bigint-dig"
+version = "0.8.6"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "e661dda6640fad38e827a6d4a310ff4763082116fe217f279885c97f511bb0b7"
+dependencies = [
+ "lazy_static",
+ "libm",
+ "num-integer",
+ "num-iter",
+ "num-traits",
+ "rand",
+ "smallvec",
+ "zeroize",
+]
+
+[[package]]
+name = "num-integer"
+version = "0.1.46"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "7969661fd2958a5cb096e56c8e1ad0444ac2bbcd0061bd28660485a44879858f"
+dependencies = [
+ "num-traits",
+]
+
+[[package]]
+name = "num-iter"
+version = "0.1.45"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "1429034a0490724d0075ebb2bc9e875d6503c3cf69e235a8941aa757d83ef5bf"
+dependencies = [
+ "autocfg",
+ "num-integer",
+ "num-traits",
+]
+
+[[package]]
+name = "num-traits"
+version = "0.2.19"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "071dfc062690e90b734c0b2273ce72ad0ffa95f0c74596bc250dcfd960262841"
+dependencies = [
+ "autocfg",
+ "libm",
+]
+
+[[package]]
+name = "once_cell"
+version = "1.21.4"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "9f7c3e4beb33f85d45ae3e3a1792185706c8e16d043238c593331cc7cd313b50"
+
+[[package]]
+name = "parking"
+version = "2.2.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "f38d5652c16fde515bb1ecef450ab0f6a219d619a7274976324d5e377f7dceba"
+
+[[package]]
+name = "parking_lot"
+version = "0.12.5"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "93857453250e3077bd71ff98b6a65ea6621a19bb0f559a85248955ac12c45a1a"
+dependencies = [
+ "lock_api",
+ "parking_lot_core",
+]
+
+[[package]]
+name = "parking_lot_core"
+version = "0.9.12"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "2621685985a2ebf1c516881c026032ac7deafcda1a2c9b7850dc81e3dfcb64c1"
+dependencies = [
+ "cfg-if",
+ "libc",
+ "redox_syscall 0.5.18",
+ "smallvec",
+ "windows-link",
+]
+
+[[package]]
+name = "paste"
+version = "1.0.15"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "57c0d7b74b563b49d38dae00a0c37d4d6de9b432382b2892f0574ddcae73fd0a"
+
+[[package]]
+name = "pem-rfc7468"
+version = "0.7.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "88b39c9bfcfc231068454382784bb460aae594343fb030d46e9f50a645418412"
+dependencies = [
+ "base64ct",
+]
+
+[[package]]
+name = "percent-encoding"
+version = "2.3.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "9b4f627cb1b25917193a259e49bdad08f671f8d9708acfd5fe0a8c1455d87220"
+
+[[package]]
+name = "pin-project-lite"
+version = "0.2.17"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "a89322df9ebe1c1578d689c92318e070967d1042b512afbe49518723f4e6d5cd"
+
+[[package]]
+name = "pkcs1"
+version = "0.7.5"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "c8ffb9f10fa047879315e6625af03c164b16962a5368d724ed16323b68ace47f"
+dependencies = [
+ "der",
+ "pkcs8",
+ "spki",
+]
+
+[[package]]
+name = "pkcs8"
+version = "0.10.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "f950b2377845cebe5cf8b5165cb3cc1a5e0fa5cfa3e1f7f55707d8fd82e0a7b7"
+dependencies = [
+ "der",
+ "spki",
+]
+
+[[package]]
+name = "pkg-config"
+version = "0.3.33"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "19f132c84eca552bf34cab8ec81f1c1dcc229b811638f9d283dceabe58c5569e"
+
+[[package]]
+name = "plain"
+version = "0.2.3"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "b4596b6d070b27117e987119b4dac604f3c58cfb0b191112e24771b2faeac1a6"
+
+[[package]]
+name = "potential_utf"
+version = "0.1.5"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "0103b1cef7ec0cf76490e969665504990193874ea05c85ff9bab8b911d0a0564"
+dependencies = [
+ "zerovec",
+]
+
+[[package]]
+name = "ppv-lite86"
+version = "0.2.21"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "85eae3c4ed2f50dcfe72643da4befc30deadb458a9b590d720cde2f2b1e97da9"
+dependencies = [
+ "zerocopy",
+]
+
+[[package]]
+name = "proc-macro2"
+version = "1.0.106"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "8fd00f0bb2e90d81d1044c2b32617f68fcb9fa3bb7640c23e9c748e53fb30934"
+dependencies = [
+ "unicode-ident",
+]
+
+[[package]]
+name = "quote"
+version = "1.0.45"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "41f2619966050689382d2b44f664f4bc593e129785a36d6ee376ddf37259b924"
+dependencies = [
+ "proc-macro2",
+]
+
+[[package]]
+name = "r-efi"
+version = "6.0.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "f8dcc9c7d52a811697d2151c701e0d08956f92b0e24136cf4cf27b57a6a0d9bf"
+
+[[package]]
+name = "rand"
+version = "0.8.6"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "5ca0ecfa931c29007047d1bc58e623ab12e5590e8c7cc53200d5202b69266d8a"
+dependencies = [
+ "libc",
+ "rand_chacha",
+ "rand_core",
+]
+
+[[package]]
+name = "rand_chacha"
+version = "0.3.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "e6c10a63a0fa32252be49d21e7709d4d4baf8d231c2dbce1eaa8141b9b127d88"
+dependencies = [
+ "ppv-lite86",
+ "rand_core",
+]
+
+[[package]]
+name = "rand_core"
+version = "0.6.4"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "ec0be4795e2f6a28069bec0b5ff3e2ac9bafc99e6a9a7dc3547996c5c816922c"
+dependencies = [
+ "getrandom 0.2.17",
+]
+
+[[package]]
+name = "redox_syscall"
+version = "0.5.18"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "ed2bf2547551a7053d6fdfafda3f938979645c44812fbfcda098faae3f1a362d"
+dependencies = [
+ "bitflags",
+]
+
+[[package]]
+name = "redox_syscall"
+version = "0.8.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "5b44b894f2a6e36457d665d1e08c3866add6ed5e70050c1b4ba8a8ddedb02ce7"
+dependencies = [
+ "bitflags",
+]
+
+[[package]]
+name = "rsa"
+version = "0.9.10"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "b8573f03f5883dcaebdfcf4725caa1ecb9c15b2ef50c43a07b816e06799bb12d"
+dependencies = [
+ "const-oid",
+ "digest",
+ "num-bigint-dig",
+ "num-integer",
+ "num-traits",
+ "pkcs1",
+ "pkcs8",
+ "rand_core",
+ "signature",
+ "spki",
+ "subtle",
+ "zeroize",
+]
+
+[[package]]
+name = "rustversion"
+version = "1.0.22"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "b39cdef0fa800fc44525c84ccb54a029961a8215f9619753635a9c0d2538d46d"
+
+[[package]]
+name = "ryu"
+version = "1.0.23"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "9774ba4a74de5f7b1c1451ed6cd5285a32eddb5cccb8cc655a4e50009e06477f"
+
+[[package]]
+name = "scopeguard"
+version = "1.2.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "94143f37725109f92c262ed2cf5e59bce7498c01bcc1502d7b9afe439a4e9f49"
+
+[[package]]
+name = "serde"
+version = "1.0.228"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "9a8e94ea7f378bd32cbbd37198a4a91436180c5bb472411e48b5ec2e2124ae9e"
+dependencies = [
+ "serde_core",
+ "serde_derive",
+]
+
+[[package]]
+name = "serde_core"
+version = "1.0.228"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "41d385c7d4ca58e59fc732af25c3983b67ac852c1a25000afe1175de458b67ad"
+dependencies = [
+ "serde_derive",
+]
+
+[[package]]
+name = "serde_derive"
+version = "1.0.228"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "d540f220d3187173da220f885ab66608367b6574e925011a9353e4badda91d79"
+dependencies = [
+ "proc-macro2",
+ "quote",
+ "syn",
+]
+
+[[package]]
+name = "serde_json"
+version = "1.0.150"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "e8014e44b4736ed0538adeecded0fce2a272f22dc9578a7eb6b2d9993c74cfb9"
+dependencies = [
+ "itoa",
+ "memchr",
+ "serde",
+ "serde_core",
+ "zmij",
+]
+
+[[package]]
+name = "serde_urlencoded"
+version = "0.7.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "d3491c14715ca2294c4d6a88f15e84739788c1d030eed8c110436aafdaa2f3fd"
+dependencies = [
+ "form_urlencoded",
+ "itoa",
+ "ryu",
+ "serde",
+]
+
+[[package]]
+name = "sha1"
+version = "0.10.6"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "e3bf829a2d51ab4a5ddf1352d8470c140cadc8301b2ae1789db023f01cedd6ba"
+dependencies = [
+ "cfg-if",
+ "cpufeatures",
+ "digest",
+]
+
+[[package]]
+name = "sha2"
+version = "0.10.9"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "a7507d819769d01a365ab707794a4084392c824f54a7a6a7862f8c3d0892b283"
+dependencies = [
+ "cfg-if",
+ "cpufeatures",
+ "digest",
+]
+
+[[package]]
+name = "shlex"
+version = "2.0.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "f8fadd59c855ef2080decdef8ff161eb6661b86933c9d82e5ba29dc602a55aba"
+
+[[package]]
+name = "signal-hook-registry"
+version = "1.4.8"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "c4db69cba1110affc0e9f7bcd48bbf87b3f4fc7c61fc9155afd4c469eb3d6c1b"
+dependencies = [
+ "errno",
+ "libc",
+]
+
+[[package]]
+name = "signature"
+version = "2.2.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "77549399552de45a898a580c1b41d445bf730df867cc44e6c0233bbc4b8329de"
+dependencies = [
+ "digest",
+ "rand_core",
+]
+
+[[package]]
+name = "slab"
+version = "0.4.12"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "0c790de23124f9ab44544d7ac05d60440adc586479ce501c1d6d7da3cd8c9cf5"
+
+[[package]]
+name = "smallvec"
+version = "1.15.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "8ed6a63f02c8539c91a8685a86f4099661ba3da017932f6ebbea6de3f0fa7c90"
+dependencies = [
+ "serde",
+]
+
+[[package]]
+name = "socket2"
+version = "0.6.4"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "52d1cfed4120b4d927bf7c0f86d2087a4a7d6027c906d9f9d525a80573b9be51"
+dependencies = [
+ "libc",
+ "windows-sys 0.61.2",
+]
+
+[[package]]
+name = "spin"
+version = "0.9.8"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "6980e8d7511241f8acf4aebddbb1ff938df5eebe98691418c4468d0b72a96a67"
+dependencies = [
+ "lock_api",
+]
+
+[[package]]
+name = "spki"
+version = "0.7.3"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "d91ed6c858b01f942cd56b37a94b3e0a1798290327d1236e4d9cf4eaca44d29d"
+dependencies = [
+ "base64ct",
+ "der",
+]
+
+[[package]]
+name = "sqlx"
+version = "0.8.6"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "1fefb893899429669dcdd979aff487bd78f4064e5e7907e4269081e0ef7d97dc"
+dependencies = [
+ "sqlx-core",
+ "sqlx-macros",
+ "sqlx-mysql",
+ "sqlx-postgres",
+ "sqlx-sqlite",
+]
+
+[[package]]
+name = "sqlx-core"
+version = "0.8.6"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "ee6798b1838b6a0f69c007c133b8df5866302197e404e8b6ee8ed3e3a5e68dc6"
+dependencies = [
+ "base64",
+ "bytes",
+ "crc",
+ "crossbeam-queue",
+ "either",
+ "event-listener",
+ "futures-core",
+ "futures-intrusive",
+ "futures-io",
+ "futures-util",
+ "hashbrown 0.15.5",
+ "hashlink",
+ "indexmap",
+ "log",
+ "memchr",
+ "once_cell",
+ "percent-encoding",
+ "serde",
+ "serde_json",
+ "sha2",
+ "smallvec",
+ "thiserror",
+ "tokio",
+ "tokio-stream",
+ "tracing",
+ "url",
+]
+
+[[package]]
+name = "sqlx-macros"
+version = "0.8.6"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "a2d452988ccaacfbf5e0bdbc348fb91d7c8af5bee192173ac3636b5fb6e6715d"
+dependencies = [
+ "proc-macro2",
+ "quote",
+ "sqlx-core",
+ "sqlx-macros-core",
+ "syn",
+]
+
+[[package]]
+name = "sqlx-macros-core"
+version = "0.8.6"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "19a9c1841124ac5a61741f96e1d9e2ec77424bf323962dd894bdb93f37d5219b"
+dependencies = [
+ "dotenvy",
+ "either",
+ "heck",
+ "hex",
+ "once_cell",
+ "proc-macro2",
+ "quote",
+ "serde",
+ "serde_json",
+ "sha2",
+ "sqlx-core",
+ "sqlx-postgres",
+ "sqlx-sqlite",
+ "syn",
+ "tokio",
+ "url",
+]
+
+[[package]]
+name = "sqlx-mysql"
+version = "0.8.6"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "aa003f0038df784eb8fecbbac13affe3da23b45194bd57dba231c8f48199c526"
+dependencies = [
+ "atoi",
+ "base64",
+ "bitflags",
+ "byteorder",
+ "bytes",
+ "crc",
+ "digest",
+ "dotenvy",
+ "either",
+ "futures-channel",
+ "futures-core",
+ "futures-io",
+ "futures-util",
+ "generic-array",
+ "hex",
+ "hkdf",
+ "hmac",
+ "itoa",
+ "log",
+ "md-5",
+ "memchr",
+ "once_cell",
+ "percent-encoding",
+ "rand",
+ "rsa",
+ "sha1",
+ "sha2",
+ "smallvec",
+ "sqlx-core",
+ "stringprep",
+ "thiserror",
+ "tracing",
+ "whoami",
+]
+
+[[package]]
+name = "sqlx-postgres"
+version = "0.8.6"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "db58fcd5a53cf07c184b154801ff91347e4c30d17a3562a635ff028ad5deda46"
+dependencies = [
+ "atoi",
+ "base64",
+ "bitflags",
+ "byteorder",
+ "crc",
+ "dotenvy",
+ "etcetera",
+ "futures-channel",
+ "futures-core",
+ "futures-util",
+ "hex",
+ "hkdf",
+ "hmac",
+ "home",
+ "itoa",
+ "log",
+ "md-5",
+ "memchr",
+ "once_cell",
+ "rand",
+ "serde",
+ "serde_json",
+ "sha2",
+ "smallvec",
+ "sqlx-core",
+ "stringprep",
+ "thiserror",
+ "tracing",
+ "whoami",
+]
+
+[[package]]
+name = "sqlx-sqlite"
+version = "0.8.6"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "c2d12fe70b2c1b4401038055f90f151b78208de1f9f89a7dbfd41587a10c3eea"
+dependencies = [
+ "atoi",
+ "flume",
+ "futures-channel",
+ "futures-core",
+ "futures-executor",
+ "futures-intrusive",
+ "futures-util",
+ "libsqlite3-sys",
+ "log",
+ "percent-encoding",
+ "serde",
+ "serde_urlencoded",
+ "sqlx-core",
+ "thiserror",
+ "tracing",
+ "url",
+]
+
+[[package]]
+name = "stable_deref_trait"
+version = "1.2.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "6ce2be8dc25455e1f91df71bfa12ad37d7af1092ae736f3a6cd0e37bc7810596"
+
+[[package]]
+name = "stringprep"
+version = "0.1.5"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "7b4df3d392d81bd458a8a621b8bffbd2302a12ffe288a9d931670948749463b1"
+dependencies = [
+ "unicode-bidi",
+ "unicode-normalization",
+ "unicode-properties",
+]
+
+[[package]]
+name = "subtle"
+version = "2.6.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "13c2bddecc57b384dee18652358fb23172facb8a2c51ccc10d74c157bdea3292"
+
+[[package]]
+name = "syn"
+version = "2.0.118"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "1b9ae57f904213ebb649ce6895b8a66c66f0203b9319718f69a5612a065b1422"
+dependencies = [
+ "proc-macro2",
+ "quote",
+ "unicode-ident",
+]
+
+[[package]]
+name = "synstructure"
+version = "0.13.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "728a70f3dbaf5bab7f0c4b1ac8d7ae5ea60a4b5549c8a5914361c99147a709d2"
+dependencies = [
+ "proc-macro2",
+ "quote",
+ "syn",
+]
+
+[[package]]
+name = "thiserror"
+version = "2.0.18"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "4288b5bcbc7920c07a1149a35cf9590a2aa808e0bc1eafaade0b80947865fbc4"
+dependencies = [
+ "thiserror-impl",
+]
+
+[[package]]
+name = "thiserror-impl"
+version = "2.0.18"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "ebc4ee7f67670e9b64d05fa4253e753e016c6c95ff35b89b7941d6b856dec1d5"
+dependencies = [
+ "proc-macro2",
+ "quote",
+ "syn",
+]
+
+[[package]]
+name = "tinystr"
+version = "0.8.3"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "c8323304221c2a851516f22236c5722a72eaa19749016521d6dff0824447d96d"
+dependencies = [
+ "displaydoc",
+ "zerovec",
+]
+
+[[package]]
+name = "tinyvec"
+version = "1.11.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "3e61e67053d25a4e82c844e8424039d9745781b3fc4f32b8d55ed50f5f667ef3"
+dependencies = [
+ "tinyvec_macros",
+]
+
+[[package]]
+name = "tinyvec_macros"
+version = "0.1.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "1f3ccbac311fea05f86f61904b462b55fb3df8837a366dfc601a0161d0532f20"
+
+[[package]]
+name = "tokio"
+version = "1.52.3"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "8fc7f01b389ac15039e4dc9531aa973a135d7a4135281b12d7c1bc79fd57fffe"
+dependencies = [
+ "bytes",
+ "libc",
+ "mio",
+ "parking_lot",
+ "pin-project-lite",
+ "signal-hook-registry",
+ "socket2",
+ "tokio-macros",
+ "windows-sys 0.61.2",
+]
+
+[[package]]
+name = "tokio-macros"
+version = "2.7.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "385a6cb71ab9ab790c5fe8d67f1645e6c450a7ce006a33de03daa956cf70a496"
+dependencies = [
+ "proc-macro2",
+ "quote",
+ "syn",
+]
+
+[[package]]
+name = "tokio-stream"
+version = "0.1.18"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "32da49809aab5c3bc678af03902d4ccddea2a87d028d86392a4b1560c6906c70"
+dependencies = [
+ "futures-core",
+ "pin-project-lite",
+ "tokio",
+]
+
+[[package]]
+name = "tracing"
+version = "0.1.44"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "63e71662fa4b2a2c3a26f570f037eb95bb1f85397f3cd8076caed2f026a6d100"
+dependencies = [
+ "log",
+ "pin-project-lite",
+ "tracing-attributes",
+ "tracing-core",
+]
+
+[[package]]
+name = "tracing-attributes"
+version = "0.1.31"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "7490cfa5ec963746568740651ac6781f701c9c5ea257c58e057f3ba8cf69e8da"
+dependencies = [
+ "proc-macro2",
+ "quote",
+ "syn",
+]
+
+[[package]]
+name = "tracing-core"
+version = "0.1.36"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "db97caf9d906fbde555dd62fa95ddba9eecfd14cb388e4f491a66d74cd5fb79a"
+dependencies = [
+ "once_cell",
+]
+
+[[package]]
+name = "typenum"
+version = "1.20.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "b6f5e870be6c3b371b77fe0ee0bafb859fa4964b4404c27de1d380043c4dda20"
+
+[[package]]
+name = "unicode-bidi"
+version = "0.3.18"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "5c1cb5db39152898a79168971543b1cb5020dff7fe43c8dc468b0885f5e29df5"
+
+[[package]]
+name = "unicode-ident"
+version = "1.0.24"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "e6e4313cd5fcd3dad5cafa179702e2b244f760991f45397d14d4ebf38247da75"
+
+[[package]]
+name = "unicode-normalization"
+version = "0.1.25"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "5fd4f6878c9cb28d874b009da9e8d183b5abc80117c40bbd187a1fde336be6e8"
+dependencies = [
+ "tinyvec",
+]
+
+[[package]]
+name = "unicode-properties"
+version = "0.1.4"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "7df058c713841ad818f1dc5d3fd88063241cc61f49f5fbea4b951e8cf5a8d71d"
+
+[[package]]
+name = "url"
+version = "2.5.8"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "ff67a8a4397373c3ef660812acab3268222035010ab8680ec4215f38ba3d0eed"
+dependencies = [
+ "form_urlencoded",
+ "idna",
+ "percent-encoding",
+ "serde",
+]
+
+[[package]]
+name = "utf8_iter"
+version = "1.0.4"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "b6c140620e7ffbb22c2dee59cafe6084a59b5ffc27a8859a5f0d494b5d52b6be"
+
+[[package]]
+name = "uuid"
+version = "1.23.3"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "144d6b123cef80b301b8f72a9e2ca4370ddec21950d0a103dd22c437006d2db7"
+dependencies = [
+ "getrandom 0.4.3",
+ "js-sys",
+ "serde_core",
+ "wasm-bindgen",
+]
+
+[[package]]
+name = "vcpkg"
+version = "0.2.15"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "accd4ea62f7bb7a82fe23066fb0957d48ef677f6eeb8215f372f52e48bb32426"
+
+[[package]]
+name = "version_check"
+version = "0.9.5"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "0b928f33d975fc6ad9f86c8f283853ad26bdd5b10b7f1542aa2fa15e2289105a"
+
+[[package]]
+name = "wasi"
+version = "0.11.1+wasi-snapshot-preview1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "ccf3ec651a847eb01de73ccad15eb7d99f80485de043efb2f370cd654f4ea44b"
+
+[[package]]
+name = "wasite"
+version = "0.1.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "b8dad83b4f25e74f184f64c43b150b91efe7647395b42289f38e50566d82855b"
+
+[[package]]
+name = "wasm-bindgen"
+version = "0.2.125"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "8ddb3f79143bced6de84270411622a2699cee572fc0875aeaf1e7867cf9fca1a"
+dependencies = [
+ "cfg-if",
+ "once_cell",
+ "rustversion",
+ "wasm-bindgen-macro",
+ "wasm-bindgen-shared",
+]
+
+[[package]]
+name = "wasm-bindgen-macro"
+version = "0.2.125"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "4e21a184b13fb19e157296e2c46056aec9092264fab83e4ba59e68c61b323c3d"
+dependencies = [
+ "quote",
+ "wasm-bindgen-macro-support",
+]
+
+[[package]]
+name = "wasm-bindgen-macro-support"
+version = "0.2.125"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "fecefd9c35bd935a20fc3fc344b5f29138961e4f47fb03297d88f2587afb5ebd"
+dependencies = [
+ "bumpalo",
+ "proc-macro2",
+ "quote",
+ "syn",
+ "wasm-bindgen-shared",
+]
+
+[[package]]
+name = "wasm-bindgen-shared"
+version = "0.2.125"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "23939e44bb9a5d7576fa2b563dc2e136628f1224e88a8deed09e04858b77871f"
+dependencies = [
+ "unicode-ident",
+]
+
+[[package]]
+name = "whoami"
+version = "1.6.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "5d4a4db5077702ca3015d3d02d74974948aba2ad9e12ab7df718ee64ccd7e97d"
+dependencies = [
+ "libredox",
+ "wasite",
+]
+
+[[package]]
+name = "windows-link"
+version = "0.2.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "f0805222e57f7521d6a62e36fa9163bc891acd422f971defe97d64e70d0a4fe5"
+
+[[package]]
+name = "windows-sys"
+version = "0.48.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "677d2418bec65e3338edb076e806bc1ec15693c5d0104683f2efe857f61056a9"
+dependencies = [
+ "windows-targets",
+]
+
+[[package]]
+name = "windows-sys"
+version = "0.61.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "ae137229bcbd6cdf0f7b80a31df61766145077ddf49416a728b02cb3921ff3fc"
+dependencies = [
+ "windows-link",
+]
+
+[[package]]
+name = "windows-targets"
+version = "0.48.5"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "9a2fa6e2155d7247be68c096456083145c183cbbbc2764150dda45a87197940c"
+dependencies = [
+ "windows_aarch64_gnullvm",
+ "windows_aarch64_msvc",
+ "windows_i686_gnu",
+ "windows_i686_msvc",
+ "windows_x86_64_gnu",
+ "windows_x86_64_gnullvm",
+ "windows_x86_64_msvc",
+]
+
+[[package]]
+name = "windows_aarch64_gnullvm"
+version = "0.48.5"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "2b38e32f0abccf9987a4e3079dfb67dcd799fb61361e53e2882c3cbaf0d905d8"
+
+[[package]]
+name = "windows_aarch64_msvc"
+version = "0.48.5"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "dc35310971f3b2dbbf3f0690a219f40e2d9afcf64f9ab7cc1be722937c26b4bc"
+
+[[package]]
+name = "windows_i686_gnu"
+version = "0.48.5"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "a75915e7def60c94dcef72200b9a8e58e5091744960da64ec734a6c6e9b3743e"
+
+[[package]]
+name = "windows_i686_msvc"
+version = "0.48.5"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "8f55c233f70c4b27f66c523580f78f1004e8b5a8b659e05a4eb49d4166cca406"
+
+[[package]]
+name = "windows_x86_64_gnu"
+version = "0.48.5"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "53d40abd2583d23e4718fddf1ebec84dbff8381c07cae67ff7768bbf19c6718e"
+
+[[package]]
+name = "windows_x86_64_gnullvm"
+version = "0.48.5"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "0b7b52767868a23d5bab768e390dc5f5c55825b6d30b86c844ff2dc7414044cc"
+
+[[package]]
+name = "windows_x86_64_msvc"
+version = "0.48.5"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "ed94fce61571a4006852b7389a063ab983c02eb1bb37b47f8272ce92d06d9538"
+
+[[package]]
+name = "writeable"
+version = "0.6.3"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "1ffae5123b2d3fc086436f8834ae3ab053a283cfac8fe0a0b8eaae044768a4c4"
+
+[[package]]
+name = "yoke"
+version = "0.8.3"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "709fe23a0424b6a435d82152b1bd3fdfb0833487d5fa90d05d42762a9891fef5"
+dependencies = [
+ "stable_deref_trait",
+ "yoke-derive",
+ "zerofrom",
+]
+
+[[package]]
+name = "yoke-derive"
+version = "0.8.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "de844c262c8848816172cef550288e7dc6c7b7814b4ee56b3e1553f275f1858e"
+dependencies = [
+ "proc-macro2",
+ "quote",
+ "syn",
+ "synstructure",
+]
+
+[[package]]
+name = "zerocopy"
+version = "0.8.52"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "ce1022995ff5ff5d841ad7d994facc23098cd40152f2c1d11cd607c6f530653f"
+dependencies = [
+ "zerocopy-derive",
+]
+
+[[package]]
+name = "zerocopy-derive"
+version = "0.8.52"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "1ae7f38b72ec2a254e2b87ef277cf2cd4fb97cbebf944faa6f33354da0867930"
+dependencies = [
+ "proc-macro2",
+ "quote",
+ "syn",
+]
+
+[[package]]
+name = "zerofrom"
+version = "0.1.8"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "0ec05a11813ea801ff6d75110ad09cd0824ddba17dfe17128ea0d5f68e6c5272"
+dependencies = [
+ "zerofrom-derive",
+]
+
+[[package]]
+name = "zerofrom-derive"
+version = "0.1.7"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "11532158c46691caf0f2593ea8358fed6bbf68a0315e80aae9bd41fbade684a1"
+dependencies = [
+ "proc-macro2",
+ "quote",
+ "syn",
+ "synstructure",
+]
+
+[[package]]
+name = "zeroize"
+version = "1.9.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "e13c156562582aa81c60cb29407084cdb54c4164760106ab78e6c5b0858cf64e"
+
+[[package]]
+name = "zerotrie"
+version = "0.2.4"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "0f9152d31db0792fa83f70fb2f83148effb5c1f5b8c7686c3459e361d9bc20bf"
+dependencies = [
+ "displaydoc",
+ "yoke",
+ "zerofrom",
+]
+
+[[package]]
+name = "zerovec"
+version = "0.11.6"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "90f911cbc359ab6af17377d242225f4d75119aec87ea711a880987b18cd7b239"
+dependencies = [
+ "yoke",
+ "zerofrom",
+ "zerovec-derive",
+]
+
+[[package]]
+name = "zerovec-derive"
+version = "0.11.3"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "625dc425cab0dca6dc3c3319506e6593dcb08a9f387ea3b284dbd52a92c40555"
+dependencies = [
+ "proc-macro2",
+ "quote",
+ "syn",
+]
+
+[[package]]
+name = "zmij"
+version = "1.0.21"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "b8848ee67ecc8aedbaf3e4122217aff892639231befc6a1b58d29fff4c2cabaa"

+ 40 - 0
Cargo.toml

@@ -0,0 +1,40 @@
+[workspace]
+resolver = "2"
+members = ["crates/kuatia-money", "crates/kuatia-types", "crates/kuatia-core", "crates/kuatia-storage", "crates/kuatia-storage-sql", "crates/kuatia"]
+
+[workspace.package]
+version = "0.1.0"
+edition = "2024"
+rust-version = "1.85"
+license = "Apache-2.0"
+repository = "https://github.com/crodas/kuatia"
+authors = ["Cesar Rodas <c@rm.com.py>"]
+keywords = ["ledger", "accounting", "double-entry", "utxo", "finance"]
+categories = ["finance", "database-implementations"]
+
+[workspace.dependencies]
+# Internal crates
+kuatia-money = { path = "crates/kuatia-money", version = "0.1.0" }
+kuatia-types = { path = "crates/kuatia-types", version = "0.1.0" }
+kuatia-core = { path = "crates/kuatia-core", version = "0.1.0" }
+kuatia-storage = { path = "crates/kuatia-storage", version = "0.1.0" }
+kuatia-storage-sql = { path = "crates/kuatia-storage-sql", version = "0.1.0" }
+
+# External crates
+serde = { version = "1", features = ["derive"] }
+serde_json = "1"
+sha2 = { version = "0.10", default-features = false }
+bitflags = { version = "2", features = ["serde"] }
+async-trait = "0.1"
+tokio = { version = "1" }
+paste = "1"
+sqlx = { version = "0.8", default-features = false, features = ["runtime-tokio", "any"] }
+legend = "0.1"
+tracing = "0.1"
+
+[workspace.lints.rust]
+arithmetic_overflow = "deny"
+missing_docs = "deny"
+
+[profile.release]
+overflow-checks = true

+ 190 - 0
LICENSE

@@ -0,0 +1,190 @@
+                                 Apache License
+                           Version 2.0, January 2004
+                        http://www.apache.org/licenses/
+
+   TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION
+
+   1. Definitions.
+
+      "License" shall mean the terms and conditions for use, reproduction,
+      and distribution as defined by Sections 1 through 9 of this document.
+
+      "Licensor" shall mean the copyright owner or entity authorized by
+      the copyright owner that is granting the License.
+
+      "Legal Entity" shall mean the union of the acting entity and all
+      other entities that control, are controlled by, or are under common
+      control with that entity. For the purposes of this definition,
+      "control" means (i) the power, direct or indirect, to cause the
+      direction or management of such entity, whether by contract or
+      otherwise, or (ii) ownership of fifty percent (50%) or more of the
+      outstanding shares, or (iii) beneficial ownership of such entity.
+
+      "You" (or "Your") shall mean an individual or Legal Entity
+      exercising permissions granted by this License.
+
+      "Source" form shall mean the preferred form for making modifications,
+      including but not limited to software source code, documentation
+      source, and configuration files.
+
+      "Object" form shall mean any form resulting from mechanical
+      transformation or translation of a Source form, including but
+      not limited to compiled object code, generated documentation,
+      and conversions to other media types.
+
+      "Work" shall mean the work of authorship, whether in Source or
+      Object form, made available under the License, as indicated by a
+      copyright notice that is included in or attached to the work
+      (an example is provided in the Appendix below).
+
+      "Derivative Works" shall mean any work, whether in Source or Object
+      form, that is based on (or derived from) the Work and for which the
+      editorial revisions, annotations, elaborations, or other modifications
+      represent, as a whole, an original work of authorship. For the purposes
+      of this License, Derivative Works shall not include works that remain
+      separable from, or merely link (or bind by name) to the interfaces of,
+      the Work and Derivative Works thereof.
+
+      "Contribution" shall mean any work of authorship, including
+      the original version of the Work and any modifications or additions
+      to that Work or Derivative Works thereof, that is intentionally
+      submitted to the Licensor for inclusion in the Work by the copyright owner
+      or by an individual or Legal Entity authorized to submit on behalf of
+      the copyright owner. For the purposes of this definition, "submitted"
+      means any form of electronic, verbal, or written communication sent
+      to the Licensor or its representatives, including but not limited to
+      communication on electronic mailing lists, source code control systems,
+      and issue tracking systems that are managed by, or on behalf of, the
+      Licensor for the purpose of discussing and improving the Work, but
+      excluding communication that is conspicuously marked or otherwise
+      designated in writing by the copyright owner as "Not a Contribution."
+
+      "Contributor" shall mean Licensor and any individual or Legal Entity
+      on behalf of whom a Contribution has been received by the Licensor and
+      subsequently incorporated within the Work.
+
+   2. Grant of Copyright License. Subject to the terms and conditions of
+      this License, each Contributor hereby grants to You a perpetual,
+      worldwide, non-exclusive, no-charge, royalty-free, irrevocable
+      copyright license to reproduce, prepare Derivative Works of,
+      publicly display, publicly perform, sublicense, and distribute the
+      Work and such Derivative Works in Source or Object form.
+
+   3. Grant of Patent License. Subject to the terms and conditions of
+      this License, each Contributor hereby grants to You a perpetual,
+      worldwide, non-exclusive, no-charge, royalty-free, irrevocable
+      (except as stated in this section) patent license to make, have made,
+      use, offer to sell, sell, import, and otherwise transfer the Work,
+      where such license applies only to those patent claims licensable
+      by such Contributor that are necessarily infringed by their
+      Contribution(s) alone or by combination of their Contribution(s)
+      with the Work to which such Contribution(s) was submitted. If You
+      institute patent litigation against any entity (including a
+      cross-claim or counterclaim in a lawsuit) alleging that the Work
+      or a Contribution incorporated within the Work constitutes direct
+      or contributory patent infringement, then any patent licenses
+      granted to You under this License for that Work shall terminate
+      as of the date such litigation is filed.
+
+   4. Redistribution. You may reproduce and distribute copies of the
+      Work or Derivative Works thereof in any medium, with or without
+      modifications, and in Source or Object form, provided that You
+      meet the following conditions:
+
+      (a) You must give any other recipients of the Work or
+          Derivative Works a copy of this License; and
+
+      (b) You must cause any modified files to carry prominent notices
+          stating that You changed the files; and
+
+      (c) You must retain, in the Source form of any Derivative Works
+          that You distribute, all copyright, patent, trademark, and
+          attribution notices from the Source form of the Work,
+          excluding those notices that do not pertain to any part of
+          the Derivative Works; and
+
+      (d) If the Work includes a "NOTICE" text file as part of its
+          distribution, then any Derivative Works that You distribute must
+          include a readable copy of the attribution notices contained
+          within such NOTICE file, excluding any notices that do not
+          pertain to any part of the Derivative Works, in at least one
+          of the following places: within a NOTICE text file distributed
+          as part of the Derivative Works; within the Source form or
+          documentation, if provided along with the Derivative Works; or,
+          within a display generated by the Derivative Works, if and
+          wherever such third-party notices normally appear. The contents
+          of the NOTICE file are for informational purposes only and
+          do not modify the License. You may add Your own attribution
+          notices within Derivative Works that You distribute, alongside
+          or as an addendum to the NOTICE text from the Work, provided
+          that such additional attribution notices cannot be construed
+          as modifying the License.
+
+      You may add Your own copyright statement to Your modifications and
+      may provide additional or different license terms and conditions
+      for use, reproduction, or distribution of Your modifications, or
+      for any such Derivative Works as a whole, provided Your use,
+      reproduction, and distribution of the Work otherwise complies with
+      the conditions stated in this License.
+
+   5. Submission of Contributions. Unless You explicitly state otherwise,
+      any Contribution intentionally submitted for inclusion in the Work
+      by You to the Licensor shall be under the terms and conditions of
+      this License, without any additional terms or conditions.
+      Notwithstanding the above, nothing herein shall supersede or modify
+      the terms of any separate license agreement you may have executed
+      with Licensor regarding such Contributions.
+
+   6. Trademarks. This License does not grant permission to use the trade
+      names, trademarks, service marks, or product names of the Licensor,
+      except as required for reasonable and customary use in describing the
+      origin of the Work and reproducing the content of the NOTICE file.
+
+   7. Disclaimer of Warranty. Unless required by applicable law or
+      agreed to in writing, Licensor provides the Work (and each
+      Contributor provides its Contributions) on an "AS IS" BASIS,
+      WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or
+      implied, including, without limitation, any warranties or conditions
+      of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A
+      PARTICULAR PURPOSE. You are solely responsible for determining the
+      appropriateness of using or redistributing the Work and assume any
+      risks associated with Your exercise of permissions under this License.
+
+   8. Limitation of Liability. In no event and under no legal theory,
+      whether in tort (including negligence), contract, or otherwise,
+      unless required by applicable law (such as deliberate and grossly
+      negligent acts) or agreed to in writing, shall any Contributor be
+      liable to You for damages, including any direct, indirect, special,
+      incidental, or consequential damages of any character arising as a
+      result of this License or out of the use or inability to use the
+      Work (including but not limited to damages for loss of goodwill,
+      work stoppage, computer failure or malfunction, or any and all
+      other commercial damages or losses), even if such Contributor
+      has been advised of the possibility of such damages.
+
+   9. Accepting Warranty or Additional Liability. While redistributing
+      the Work or Derivative Works thereof, You may choose to offer,
+      and charge a fee for, acceptance of support, warranty, indemnity,
+      or other liability obligations and/or rights consistent with this
+      License. However, in accepting such obligations, You may act only
+      on Your own behalf and on Your sole responsibility, not on behalf
+      of any other Contributor, and only if You agree to indemnify,
+      defend, and hold each Contributor harmless for any liability
+      incurred by, or claims asserted against, such Contributor by reason
+      of your accepting any such warranty or additional liability.
+
+   END OF TERMS AND CONDITIONS
+
+   Copyright 2025 Cesar Rodas
+
+   Licensed under the Apache License, Version 2.0 (the "License");
+   you may not use this file except in compliance with the License.
+   You may obtain a copy of the License at
+
+       http://www.apache.org/licenses/LICENSE-2.0
+
+   Unless required by applicable law or agreed to in writing, software
+   distributed under the License is distributed on an "AS IS" BASIS,
+   WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+   See the License for the specific language governing permissions and
+   limitations under the License.

+ 80 - 0
README.md

@@ -0,0 +1,80 @@
+# kuatia
+
+> **kuatia** (kuatiʼa) — Guaraní for *paper*, *document*, *writing*.
+> A fitting name for a small, append-only ledger library.
+
+Auditable, multi-asset UTXO-style ledger in Rust.
+
+## Overview
+
+kuatia models value as **postings** — signed amounts owned by exactly one account.
+Transfers atomically consume existing postings and create new ones, enforcing
+per-asset conservation. This gives the same safety guarantee as double-entry
+bookkeeping (`Σ debits = Σ credits`), expressed as `sum(consumed) == sum(created)`
+per asset over signed postings. There are no mutable balance fields; an account's
+balance is always the sum of its active postings.
+
+```
+┌─────────────────────────────────────────────────────┐
+│                   kuatia (async)                    │
+│                                                     │
+│  Intent layer:  TransferBuilder + commit · balance   │
+│  Saga pipeline: resolve → reserve → validate → fin.  │
+│  Raw pipeline:  load  →  plan  →  apply             │
+│  Saga steps:    legend step adapters                 │
+├─────────────────────────────────────────────────────┤
+│               kuatia-core (pure)                    │
+│                                                     │
+│  Types:         Account · Transfer · Posting · Cent │
+│  Validation:    validate_and_plan()                 │
+│  Hashing:       double-SHA256, content-addressed    │
+│  Selection:     greedy posting selection             │
+└─────────────────────────────────────────────────────┘
+```
+
+## Crates
+
+| Crate | Purpose |
+|-------|---------|
+| **kuatia-types** | Domain types — `AccountId`, `Posting`, `Transfer`, `Cent`, etc. |
+| **kuatia-core** | Pure, sans-IO decision logic — validation, hashing, posting selection. |
+| **kuatia-storage** | `Store` trait (7 sub-traits), `InMemoryStore`, `store_tests!` conformance macro. |
+| **kuatia-storage-sql** | SQL-backed `Store` — SQLite and PostgreSQL via sqlx. |
+| **kuatia** | Async resource layer — `Ledger`, saga commit pipeline, intent-layer API. |
+
+## Quick Example
+
+```rust
+use std::sync::Arc;
+use kuatia::ledger::Ledger;
+use kuatia::mem_store::InMemoryStore;
+use kuatia_core::*;
+
+#[tokio::main]
+async fn main() -> Result<(), Box<dyn std::error::Error>> {
+    let ledger = Arc::new(Ledger::new(InMemoryStore::new()));
+
+    let usd = AssetId::new(1);
+    let alice = AccountId::new(1);
+    let bob = AccountId::new(2);
+    let bank = AccountId::new(3); // external account
+
+    // Create accounts, deposit, pay...
+    // See doc/crates.md for the full API reference.
+
+    Ok(())
+}
+```
+
+## Documentation
+
+- [Architecture Decisions](doc/architecture.md) — why the ledger works the way it does
+- [Crate Reference](doc/crates.md) — modules, types, and APIs per crate
+- [Accounts](doc/accounts.md) — account model, policies, and lifecycle
+- [Transfers](doc/transfers.md) — Movement struct, resolve algorithm, and TransferBuilder API
+- [Glossary](doc/glossary.md) — terms, book scoping, and worked examples
+- [Accounting Mapping](doc/accounting-mapping.md) — how classical double-entry concepts map onto kuatia
+
+## License
+
+See [LICENSE](LICENSE) for details.

+ 24 - 0
crates/kuatia-core/Cargo.toml

@@ -0,0 +1,24 @@
+[package]
+name = "kuatia-core"
+description = "Pure, sans-IO core logic for the Kuatia ledger: validation, hashing, posting selection."
+version.workspace = true
+edition.workspace = true
+rust-version.workspace = true
+license.workspace = true
+repository.workspace = true
+authors.workspace = true
+keywords.workspace = true
+categories.workspace = true
+
+[lints]
+workspace = true
+
+[features]
+default = []
+# Pass through to kuatia-types: swap the Cent backing to i128.
+i128 = ["kuatia-types/i128"]
+
+[dependencies]
+kuatia-types.workspace = true
+sha2.workspace = true
+serde.workspace = true

+ 31 - 0
crates/kuatia-core/README.md

@@ -0,0 +1,31 @@
+# kuatia-core
+
+Pure, sans-IO decision logic for the kuatia ledger.
+
+No async runtime, no IO, near-zero dependencies. Deterministic and testable
+with golden vectors. Depends only on `kuatia-types` and `sha2`.
+
+## Modules
+
+| Module | Purpose |
+|--------|---------|
+| `validate` | `validate_and_plan()` — single entry point for all invariant checks |
+| `hash` | Double-SHA256, content-addressed transfer IDs, account snapshot hashing |
+| `posting_selection` | Greedy largest-first posting selection for the intent layer |
+
+## Validation invariants
+
+`validate_and_plan()` checks, in order:
+
+1. Non-empty transfer
+2. No duplicate consumed postings
+3. All consumed postings exist
+4. All consumed postings are Active or PendingInactive
+5. All accounts exist, not frozen, not closed
+6. Account snapshot pinning (OCC)
+7. Book policy (if a book is loaded): referenced assets/accounts/flags allowed
+8. Per-asset conservation: `sum(consumed) == sum(created)`
+9. Negative postings forbidden only on `NoOverdraft` (allowed on overdraft/system/external)
+10. Account policy enforcement (overdraft limits)
+
+Returns a `Plan` on success, or a `ValidationError` describing the violation.

+ 103 - 0
crates/kuatia-core/src/hash.rs

@@ -0,0 +1,103 @@
+//! Hashing and tamper-evidence for the ledger.
+//!
+//! Every transfer gets a content-addressed [`EnvelopeId`] (double-SHA256 of its
+//! canonical serialization), which serves as both the idempotency key and the
+//! tamper-evidence artifact.
+
+use sha2::{Digest, Sha256};
+
+use kuatia_types::{Account, AccountSnapshotId, Envelope, EnvelopeId, ToBytes};
+
+// ---------------------------------------------------------------------------
+// Double-SHA256
+// ---------------------------------------------------------------------------
+
+/// Double-SHA256 — the standard hash used throughout the ledger.
+/// Prevents length-extension attacks.
+pub fn double_sha256(data: &[u8]) -> [u8; 32] {
+    let first = Sha256::digest(data);
+    let second = Sha256::digest(first);
+    let mut out = [0u8; 32];
+    out.copy_from_slice(&second);
+    out
+}
+
+// ---------------------------------------------------------------------------
+// Transfer hashing
+// ---------------------------------------------------------------------------
+
+/// Deterministic binary serialization of an envelope.
+pub fn canonical_bytes(envelope: &Envelope) -> Vec<u8> {
+    envelope.to_bytes()
+}
+
+/// Double-SHA256 content hash. Returns a [`EnvelopeId`].
+pub fn content_hash(data: &[u8]) -> EnvelopeId {
+    EnvelopeId(double_sha256(data))
+}
+
+/// Convenience: `envelope.to_bytes()` → double-SHA256 → [`EnvelopeId`].
+pub fn envelope_id(envelope: &Envelope) -> EnvelopeId {
+    content_hash(&envelope.to_bytes())
+}
+
+// ---------------------------------------------------------------------------
+// Account hashing
+// ---------------------------------------------------------------------------
+
+/// Deterministic binary serialization of an account snapshot.
+pub fn account_canonical_bytes(account: &Account) -> Vec<u8> {
+    account.to_bytes()
+}
+
+/// Double-SHA256 of an account's canonical bytes.
+pub fn account_hash(account: &Account) -> [u8; 32] {
+    double_sha256(&account.to_bytes())
+}
+
+/// Compute the [`AccountSnapshotId`] for an account's current state.
+pub fn account_snapshot_id(account: &Account) -> AccountSnapshotId {
+    AccountSnapshotId {
+        account: account.id,
+        snapshot_id: account_hash(account),
+    }
+}
+
+#[cfg(test)]
+mod tests {
+    use super::*;
+    use kuatia_types::*;
+
+    fn sample_envelope() -> Envelope {
+        EnvelopeBuilder::new()
+            .creates(vec![NewPosting {
+                owner: AccountId::new(1),
+                asset: AssetId::new(1),
+                value: Cent::from(100),
+                payer: None,
+            }])
+            .build()
+    }
+
+    #[test]
+    fn content_hash_deterministic() {
+        let t = sample_envelope();
+        let id1 = envelope_id(&t);
+        let id2 = envelope_id(&t);
+        assert_eq!(id1, id2);
+    }
+
+    #[test]
+    fn different_envelopes_different_hashes() {
+        let t1 = sample_envelope();
+        let mut t2 = sample_envelope();
+        t2.creates[0].value = Cent::from(200);
+        assert_ne!(envelope_id(&t1), envelope_id(&t2));
+    }
+
+    #[test]
+    fn to_bytes_sha256_consistency() {
+        let t = sample_envelope();
+        assert_eq!(double_sha256(&t.to_bytes()), envelope_id(&t).0);
+    }
+}

+ 18 - 0
crates/kuatia-core/src/lib.rs

@@ -0,0 +1,18 @@
+//! Pure, sans-IO decision logic for the ledger.
+//!
+//! This crate contains no IO, no async runtime, and near-zero dependencies so that
+//! the auditable heart of the ledger can be tested with golden vectors, replayed
+//! deterministically, and embedded anywhere.
+
+pub mod hash;
+pub mod posting_selection;
+pub mod validate;
+
+pub use kuatia_types::*;
+
+pub use hash::{
+    account_canonical_bytes, account_hash, account_snapshot_id, canonical_bytes, content_hash,
+    double_sha256, envelope_id,
+};
+pub use posting_selection::{SelectionError, select_postings};
+pub use validate::{Plan, PlanInput, ValidationError, validate_and_plan};

+ 172 - 0
crates/kuatia-core/src/posting_selection.rs

@@ -0,0 +1,172 @@
+//! Posting selection for the intent layer.
+//!
+//! When a caller uses `pay` or `withdraw`, they specify an amount — not which
+//! postings to consume. This module picks the smallest set of postings that
+//! covers the requested amount, so the intent layer can build the transfer
+//! automatically without exposing UTXO mechanics to the caller.
+
+use kuatia_types::{AssetId, Cent, Posting, PostingId};
+
+/// Error returned when posting selection fails.
+#[derive(Debug, Clone, PartialEq, Eq)]
+pub enum SelectionError {
+    /// Available postings do not cover the requested amount.
+    InsufficientFunds {
+        /// Total value of eligible postings.
+        available: Cent,
+        /// Amount the caller asked for.
+        requested: Cent,
+    },
+    /// Summing posting values would overflow `Cent`.
+    Overflow,
+}
+
+impl std::fmt::Display for SelectionError {
+    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
+        match self {
+            Self::InsufficientFunds {
+                available,
+                requested,
+            } => {
+                write!(
+                    f,
+                    "insufficient funds: available {available}, requested {requested}"
+                )
+            }
+            Self::Overflow => write!(f, "monetary amount overflow"),
+        }
+    }
+}
+
+impl std::error::Error for SelectionError {}
+
+/// Picks postings to cover `target`, using largest-first greedy to minimise
+/// the number of postings consumed (and therefore the number of change postings
+/// created). Only active, positive postings of the right asset are considered.
+pub fn select_postings(
+    available: &[Posting],
+    asset: AssetId,
+    target: Cent,
+) -> Result<Vec<PostingId>, SelectionError> {
+    assert!(target.is_positive(), "target must be positive");
+
+    let mut candidates: Vec<&Posting> = available
+        .iter()
+        .filter(|p| p.is_active() && p.asset == asset && p.value.is_positive())
+        .collect();
+
+    // Largest first
+    candidates.sort_by_key(|p| std::cmp::Reverse(p.value));
+
+    let mut total_available = Cent::ZERO;
+    for p in &candidates {
+        total_available = total_available
+            .checked_add(p.value)
+            .map_err(|_| SelectionError::Overflow)?;
+    }
+    if total_available < target {
+        return Err(SelectionError::InsufficientFunds {
+            available: total_available,
+            requested: target,
+        });
+    }
+
+    let mut selected = Vec::new();
+    let mut sum = Cent::ZERO;
+    for posting in candidates {
+        selected.push(posting.id);
+        sum = sum
+            .checked_add(posting.value)
+            .map_err(|_| SelectionError::Overflow)?;
+        if sum >= target {
+            break;
+        }
+    }
+
+    Ok(selected)
+}
+
+#[cfg(test)]
+mod tests {
+    use super::*;
+    use kuatia_types::*;
+
+    fn make_posting(index: u16, value: i64) -> Posting {
+        Posting::new(
+            PostingId {
+                transfer: EnvelopeId([1; 32]),
+                index,
+            },
+            AccountId::new(1),
+            AssetId::new(1),
+            Cent::from(value),
+        )
+    }
+
+    #[test]
+    fn exact_match() {
+        let postings = vec![make_posting(0, 50), make_posting(1, 50)];
+        let result = select_postings(&postings, AssetId::new(1), Cent::from(100)).unwrap();
+        assert_eq!(result.len(), 2);
+    }
+
+    #[test]
+    fn largest_first() {
+        let postings = vec![
+            make_posting(0, 10),
+            make_posting(1, 90),
+            make_posting(2, 50),
+        ];
+        let result = select_postings(&postings, AssetId::new(1), Cent::from(80)).unwrap();
+        // Should pick 90 first (enough on its own)
+        assert_eq!(result.len(), 1);
+        assert_eq!(result[0].index, 1);
+    }
+
+    #[test]
+    fn insufficient_funds() {
+        let postings = vec![make_posting(0, 30), make_posting(1, 20)];
+        let err = select_postings(&postings, AssetId::new(1), Cent::from(100)).unwrap_err();
+        assert_eq!(
+            err,
+            SelectionError::InsufficientFunds {
+                available: Cent::from(50),
+                requested: Cent::from(100)
+            }
+        );
+    }
+
+    #[test]
+    fn ignores_inactive_and_wrong_asset() {
+        let mut inactive = make_posting(0, 1000);
+        inactive.status = PostingStatus::Inactive;
+
+        let mut wrong_asset = make_posting(1, 1000);
+        wrong_asset.asset = AssetId::new(2);
+
+        let good = make_posting(2, 50);
+
+        let postings = vec![inactive, wrong_asset, good];
+        let result = select_postings(&postings, AssetId::new(1), Cent::from(50)).unwrap();
+        assert_eq!(result.len(), 1);
+        assert_eq!(result[0].index, 2);
+    }
+
+    #[test]
+    fn ignores_negative_postings() {
+        let negative = Posting::new(
+            PostingId {
+                transfer: EnvelopeId([1; 32]),
+                index: 0,
+            },
+            AccountId::new(1),
+            AssetId::new(1),
+            Cent::from(-100),
+        );
+        let good = make_posting(1, 50);
+        let postings = vec![negative, good];
+        let result = select_postings(&postings, AssetId::new(1), Cent::from(50)).unwrap();
+        assert_eq!(result.len(), 1);
+        assert_eq!(result[0].index, 1);
+    }
+}

+ 1121 - 0
crates/kuatia-core/src/validate.rs

@@ -0,0 +1,1121 @@
+//! Pure, sync validation — the auditable heart of the ledger.
+//!
+//! [`validate_and_plan`] enforces every invariant (conservation, double-spend,
+//! ownership, account policy) and produces a [`Plan`] describing the effects to
+//! apply. It takes no IO, no clock, and no randomness, so it is deterministic
+//! and testable with golden vectors. The caller provides pre-loaded state via
+//! [`PlanInput`]; this module never touches storage.
+
+use std::collections::{HashMap, HashSet};
+
+use crate::hash::{account_hash, envelope_id};
+use kuatia_types::*;
+
+// ---------------------------------------------------------------------------
+// Input / Output
+// ---------------------------------------------------------------------------
+
+/// Pre-loaded state the caller must supply. Borrowing avoids copies on the
+/// hot path and keeps this module allocation-free for the validation itself.
+pub struct PlanInput<'a> {
+    /// The envelope to validate.
+    pub envelope: &'a Envelope,
+    /// Postings referenced by `transfer.consumes`.
+    pub consumed_postings: &'a [Posting],
+    /// All accounts referenced by the transfer.
+    pub accounts: &'a HashMap<AccountId, Account>,
+    /// Current balances keyed by (account, asset).
+    pub balances: &'a HashMap<(AccountId, AssetId), Cent>,
+    /// The book gating this transfer, if one is loaded. `Some` enforces the
+    /// book's [`BookPolicy`] (allowed assets/accounts/flags); `None` means the
+    /// implicit unrestricted default book. The async layer is responsible for
+    /// rejecting a *named* book id that has no row before reaching here.
+    pub book: Option<&'a Book>,
+}
+
+/// The validated effects to apply atomically. Produced only when every
+/// invariant holds, so the store can apply it without re-checking.
+#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)]
+pub struct Plan {
+    /// Content-addressed id of the validated transfer.
+    pub transfer_id: EnvelopeId,
+    /// Postings to mark as inactive (consumed).
+    pub postings_to_deactivate: Vec<PostingId>,
+    /// New postings to persist.
+    pub postings_to_create: Vec<Posting>,
+}
+
+// ---------------------------------------------------------------------------
+// Errors
+// ---------------------------------------------------------------------------
+
+/// An invariant violation detected during transfer validation.
+#[derive(Debug, Clone, PartialEq, Eq)]
+pub enum ValidationError {
+    /// Transfer has no consumptions and no creations.
+    EmptyTransfer,
+    /// The same posting id appears more than once in `consumes`.
+    DuplicateConsumedPosting(PostingId),
+    /// A consumed posting id does not exist in the store.
+    PostingNotFound(PostingId),
+    /// A consumed posting has already been spent.
+    PostingAlreadyConsumed(PostingId),
+    /// A consumed posting is not owned by the expected account.
+    OwnershipViolation {
+        /// The posting that failed the ownership check.
+        posting_id: PostingId,
+        /// The account that should own the posting.
+        expected: AccountId,
+        /// The account that actually owns the posting.
+        actual: AccountId,
+    },
+    /// A referenced account does not exist.
+    AccountNotFound(AccountId),
+    /// A referenced account is frozen.
+    AccountFrozen(AccountId),
+    /// A referenced account is closed.
+    AccountClosed(AccountId),
+    /// Per-asset conservation law violated: consumed sum != created sum.
+    ConservationViolation {
+        /// The asset whose sums differ.
+        asset: AssetId,
+        /// Total value of consumed postings for this asset.
+        consumed_sum: Cent,
+        /// Total value of created postings for this asset.
+        created_sum: Cent,
+    },
+    /// Projected balance would fall below the account's floor.
+    OverdraftExceeded {
+        /// The account that would be overdrawn.
+        account: AccountId,
+        /// The asset involved.
+        asset: AssetId,
+        /// The minimum allowed balance.
+        floor: Cent,
+        /// The balance that would result from this transfer.
+        projected: Cent,
+    },
+    /// Account snapshot hash does not match current state (stale read).
+    AccountVersionMismatch {
+        /// The account whose version was stale.
+        account: AccountId,
+        /// The snapshot hash the transfer expected.
+        expected: [u8; 32],
+        /// The actual current snapshot hash.
+        actual: [u8; 32],
+    },
+    /// A negative posting targets an account whose policy forbids offset positions.
+    NegativePostingOnNonSystemAccount {
+        /// The account that would receive the negative posting.
+        account: AccountId,
+        /// The asset involved.
+        asset: AssetId,
+        /// The negative value.
+        value: Cent,
+    },
+    /// An asset is not permitted by the transfer's book policy.
+    BookAssetNotAllowed {
+        /// The book whose policy rejected the asset.
+        book: BookId,
+        /// The disallowed asset.
+        asset: AssetId,
+    },
+    /// An account is not permitted to participate by the transfer's book policy.
+    BookAccountNotAllowed {
+        /// The book whose policy rejected the account.
+        book: BookId,
+        /// The disallowed account.
+        account: AccountId,
+    },
+    /// An arithmetic operation overflowed.
+    Overflow,
+}
+
+impl std::fmt::Display for ValidationError {
+    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
+        match self {
+            Self::EmptyTransfer => write!(f, "transfer has no postings"),
+            Self::DuplicateConsumedPosting(id) => write!(f, "duplicate consumed posting {id:?}"),
+            Self::PostingNotFound(id) => write!(f, "posting not found: {id:?}"),
+            Self::PostingAlreadyConsumed(id) => write!(f, "posting already consumed: {id:?}"),
+            Self::OwnershipViolation {
+                posting_id,
+                expected,
+                actual,
+            } => {
+                write!(
+                    f,
+                    "ownership violation on {posting_id:?}: expected {expected:?}, got {actual:?}"
+                )
+            }
+            Self::AccountNotFound(id) => write!(f, "account not found: {id:?}"),
+            Self::AccountFrozen(id) => write!(f, "account frozen: {id:?}"),
+            Self::AccountClosed(id) => write!(f, "account closed: {id:?}"),
+            Self::ConservationViolation {
+                asset,
+                consumed_sum,
+                created_sum,
+            } => {
+                write!(
+                    f,
+                    "conservation violated for {asset:?}: consumed {consumed_sum}, created {created_sum}"
+                )
+            }
+            Self::OverdraftExceeded {
+                account,
+                asset,
+                floor,
+                projected,
+            } => {
+                write!(
+                    f,
+                    "overdraft exceeded for {account:?}/{asset:?}: floor {floor}, projected {projected}"
+                )
+            }
+            Self::AccountVersionMismatch {
+                account,
+                expected,
+                actual,
+            } => {
+                write!(
+                    f,
+                    "account version mismatch for {account:?}: expected {expected:02x?}, got {actual:02x?}"
+                )
+            }
+            Self::NegativePostingOnNonSystemAccount {
+                account,
+                asset,
+                value,
+            } => {
+                write!(
+                    f,
+                    "negative posting ({value}) on account {account:?}/{asset:?} whose policy forbids offsets"
+                )
+            }
+            Self::BookAssetNotAllowed { book, asset } => {
+                write!(f, "asset {asset:?} not allowed by book {book:?}")
+            }
+            Self::BookAccountNotAllowed { book, account } => {
+                write!(f, "account {account:?} not allowed by book {book:?}")
+            }
+            Self::Overflow => write!(f, "monetary amount overflow"),
+        }
+    }
+}
+
+impl std::error::Error for ValidationError {}
+
+impl From<OverflowError> for ValidationError {
+    fn from(_: OverflowError) -> Self {
+        Self::Overflow
+    }
+}
+
+// ---------------------------------------------------------------------------
+// The pure decision function
+// ---------------------------------------------------------------------------
+
+/// The single entry point for all ledger invariant checks.
+///
+/// Pure, sync, deterministic — no IO, no clock, no randomness — so the
+/// invariants are testable with golden vectors and replay deterministically.
+/// Returns a [`Plan`] only when every invariant holds; otherwise returns the
+/// specific [`ValidationError`] that was violated.
+pub fn validate_and_plan(input: PlanInput<'_>) -> Result<Plan, ValidationError> {
+    let envelope = input.envelope;
+
+    // 1. Non-empty
+    if envelope.consumes().is_empty() && envelope.creates().is_empty() {
+        return Err(ValidationError::EmptyTransfer);
+    }
+
+    // 2. No duplicate consumed PostingIds
+    {
+        let mut seen = HashSet::with_capacity(envelope.consumes().len());
+        for pid in envelope.consumes() {
+            if !seen.insert(pid) {
+                return Err(ValidationError::DuplicateConsumedPosting(*pid));
+            }
+        }
+    }
+
+    // Index consumed postings by id for lookup
+    let consumed_by_id: HashMap<PostingId, &Posting> =
+        input.consumed_postings.iter().map(|p| (p.id, p)).collect();
+
+    // 3 & 4. Every consumed posting exists, is active, and we note ownership
+    for pid in envelope.consumes() {
+        let posting = consumed_by_id
+            .get(pid)
+            .ok_or(ValidationError::PostingNotFound(*pid))?;
+        if posting.status != PostingStatus::Active
+            && posting.status != PostingStatus::PendingInactive
+        {
+            return Err(ValidationError::PostingAlreadyConsumed(*pid));
+        }
+    }
+
+    // 5. Every referenced account exists, not FROZEN, not CLOSED
+    let mut all_account_ids: Vec<AccountId> = envelope.creates().iter().map(|p| p.owner).collect();
+    for pid in envelope.consumes() {
+        let posting = consumed_by_id[pid];
+        all_account_ids.push(posting.owner);
+    }
+    all_account_ids.sort();
+    all_account_ids.dedup();
+
+    for aid in &all_account_ids {
+        let account = input
+            .accounts
+            .get(aid)
+            .ok_or(ValidationError::AccountNotFound(*aid))?;
+        if account.is_frozen() {
+            return Err(ValidationError::AccountFrozen(*aid));
+        }
+        if account.is_closed() {
+            return Err(ValidationError::AccountClosed(*aid));
+        }
+    }
+
+    // 5b. Snapshot pinning: each account_snapshot must match current state.
+    for snap in envelope.account_snapshots() {
+        let account = input
+            .accounts
+            .get(&snap.account)
+            .ok_or(ValidationError::AccountNotFound(snap.account))?;
+        let actual = account_hash(account);
+        if snap.snapshot_id != actual {
+            return Err(ValidationError::AccountVersionMismatch {
+                account: snap.account,
+                expected: snap.snapshot_id,
+                actual,
+            });
+        }
+    }
+
+    // 5c. Book policy: gate which assets and accounts may participate. Enforced
+    //     only when a book is loaded; an empty policy field means "no restriction".
+    if let Some(book) = input.book {
+        let policy = &book.policy;
+
+        if !policy.allowed_assets.is_empty() {
+            let mut referenced_assets: HashSet<AssetId> = HashSet::new();
+            for pid in envelope.consumes() {
+                referenced_assets.insert(consumed_by_id[pid].asset);
+            }
+            for np in envelope.creates() {
+                referenced_assets.insert(np.asset);
+            }
+            for asset in &referenced_assets {
+                if !policy.allowed_assets.contains(asset) {
+                    return Err(ValidationError::BookAssetNotAllowed {
+                        book: book.id,
+                        asset: *asset,
+                    });
+                }
+            }
+        }
+
+        let no_account_restriction =
+            policy.allowed_accounts.is_empty() && policy.allowed_flags.is_empty();
+        if !no_account_restriction {
+            for aid in &all_account_ids {
+                let account = &input.accounts[aid];
+                let listed = policy.allowed_accounts.contains(aid);
+                let flag_match = !policy.allowed_flags.is_empty()
+                    && account.flags.intersects(policy.allowed_flags);
+                if !(listed || flag_match) {
+                    return Err(ValidationError::BookAccountNotAllowed {
+                        book: book.id,
+                        account: *aid,
+                    });
+                }
+            }
+        }
+    }
+
+    // 6. Per-asset conservation: Σ consumed == Σ created
+    let mut consumed_by_asset: HashMap<AssetId, Cent> = HashMap::new();
+    for pid in envelope.consumes() {
+        let posting = consumed_by_id[pid];
+        let entry = consumed_by_asset.entry(posting.asset).or_insert(Cent::ZERO);
+        *entry = entry.checked_add(posting.value)?;
+    }
+
+    let mut created_by_asset: HashMap<AssetId, Cent> = HashMap::new();
+    for np in envelope.creates() {
+        let entry = created_by_asset.entry(np.asset).or_insert(Cent::ZERO);
+        *entry = entry.checked_add(np.value)?;
+    }
+
+    // All assets must appear in both sides (or have sum 0 on the missing side)
+    let mut all_assets: HashSet<AssetId> = HashSet::new();
+    all_assets.extend(consumed_by_asset.keys());
+    all_assets.extend(created_by_asset.keys());
+
+    for asset in &all_assets {
+        let consumed_sum = consumed_by_asset.get(asset).copied().unwrap_or(Cent::ZERO);
+        let created_sum = created_by_asset.get(asset).copied().unwrap_or(Cent::ZERO);
+        if consumed_sum != created_sum {
+            return Err(ValidationError::ConservationViolation {
+                asset: *asset,
+                consumed_sum,
+                created_sum,
+            });
+        }
+    }
+
+    // 7. Negative postings (offset positions) may target system, external, or
+    //    overdraft accounts. Overdraft floors are enforced separately in step 8.
+    //    Only NoOverdraft forbids holding a negative posting.
+    for np in envelope.creates() {
+        if np.value.is_negative() {
+            let account = input
+                .accounts
+                .get(&np.owner)
+                .ok_or(ValidationError::AccountNotFound(np.owner))?;
+            match account.policy {
+                AccountPolicy::SystemAccount
+                | AccountPolicy::ExternalAccount
+                | AccountPolicy::UncappedOverdraft
+                | AccountPolicy::CappedOverdraft { .. } => {}
+                AccountPolicy::NoOverdraft => {
+                    return Err(ValidationError::NegativePostingOnNonSystemAccount {
+                        account: np.owner,
+                        asset: np.asset,
+                        value: np.value,
+                    });
+                }
+            }
+        }
+    }
+
+    // 8. Policy: projected balance satisfies account's floor
+    let mut deltas: HashMap<(AccountId, AssetId), Cent> = HashMap::new();
+    for pid in envelope.consumes() {
+        let posting = consumed_by_id[pid];
+        let entry = deltas
+            .entry((posting.owner, posting.asset))
+            .or_insert(Cent::ZERO);
+        *entry = entry.checked_sub(posting.value)?;
+    }
+    for np in envelope.creates() {
+        let entry = deltas.entry((np.owner, np.asset)).or_insert(Cent::ZERO);
+        *entry = entry.checked_add(np.value)?;
+    }
+
+    for ((account_id, asset_id), delta) in &deltas {
+        let current_balance = input
+            .balances
+            .get(&(*account_id, *asset_id))
+            .copied()
+            .unwrap_or(Cent::ZERO);
+        let projected = current_balance.checked_add(*delta)?;
+
+        let account = &input.accounts[account_id];
+        match &account.policy {
+            AccountPolicy::NoOverdraft => {
+                if projected.is_negative() {
+                    return Err(ValidationError::OverdraftExceeded {
+                        account: *account_id,
+                        asset: *asset_id,
+                        floor: Cent::ZERO,
+                        projected,
+                    });
+                }
+            }
+            AccountPolicy::CappedOverdraft { floor } => {
+                if projected < *floor {
+                    return Err(ValidationError::OverdraftExceeded {
+                        account: *account_id,
+                        asset: *asset_id,
+                        floor: *floor,
+                        projected,
+                    });
+                }
+            }
+            AccountPolicy::UncappedOverdraft
+            | AccountPolicy::SystemAccount
+            | AccountPolicy::ExternalAccount => {
+                // No floor check
+            }
+        }
+    }
+
+    // 8. Build the plan
+    let tid = envelope_id(envelope);
+
+    let postings_to_deactivate: Vec<PostingId> = envelope.consumes().to_vec();
+
+    let postings_to_create: Vec<Posting> = envelope
+        .creates
+        .iter()
+        .enumerate()
+        .map(|(i, np)| {
+            Posting::new(
+                PostingId {
+                    transfer: tid,
+                    index: i as u16,
+                },
+                np.owner,
+                np.asset,
+                np.value,
+            )
+        })
+        .collect();
+
+    Ok(Plan {
+        transfer_id: tid,
+        postings_to_deactivate,
+        postings_to_create,
+    })
+}
+
+#[cfg(test)]
+mod tests {
+    use super::*;
+    use std::collections::BTreeMap;
+
+    fn make_account(id: i64, policy: AccountPolicy) -> Account {
+        Account {
+            id: AccountId::new(id),
+            version: 1,
+            policy,
+            flags: AccountFlags::empty(),
+            book: BookId(0),
+            user_data: UserData::default(),
+            metadata: BTreeMap::new(),
+        }
+    }
+
+    fn accounts_map(accs: Vec<Account>) -> HashMap<AccountId, Account> {
+        accs.into_iter().map(|a| (a.id, a)).collect()
+    }
+
+    // -- Deposit: external(-100) + account1(+100) --------------------------
+
+    fn deposit_envelope() -> Envelope {
+        Envelope {
+            consumes: vec![],
+            creates: vec![
+                NewPosting {
+                    owner: AccountId::new(1),
+                    asset: AssetId::new(1),
+                    value: Cent::from(100),
+                    payer: None,
+                },
+                NewPosting {
+                    owner: AccountId::new(99),
+                    asset: AssetId::new(1),
+                    value: Cent::from(-100),
+                    payer: None,
+                },
+            ],
+            book: BookId(0),
+            user_data: UserData::default(),
+            account_snapshots: vec![],
+            metadata: BTreeMap::new(),
+        }
+    }
+
+    #[test]
+    fn valid_deposit() {
+        let envelope = deposit_envelope();
+        let accounts = accounts_map(vec![
+            make_account(1, AccountPolicy::NoOverdraft),
+            make_account(99, AccountPolicy::ExternalAccount),
+        ]);
+        let balances = HashMap::new();
+        let input = PlanInput {
+            envelope: &envelope,
+            consumed_postings: &[],
+            accounts: &accounts,
+            balances: &balances,
+            book: None,
+        };
+
+        let plan = validate_and_plan(input).unwrap();
+        assert_eq!(plan.postings_to_create.len(), 2);
+        assert!(plan.postings_to_deactivate.is_empty());
+    }
+
+    #[test]
+    fn empty_transfer_rejected() {
+        let envelope = Envelope {
+            consumes: vec![],
+            creates: vec![],
+            book: BookId(0),
+            user_data: UserData::default(),
+            account_snapshots: vec![],
+            metadata: BTreeMap::new(),
+        };
+        let accounts = HashMap::new();
+        let balances = HashMap::new();
+        let input = PlanInput {
+            envelope: &envelope,
+            consumed_postings: &[],
+            accounts: &accounts,
+            balances: &balances,
+            book: None,
+        };
+
+        assert_eq!(
+            validate_and_plan(input).unwrap_err(),
+            ValidationError::EmptyTransfer
+        );
+    }
+
+    #[test]
+    fn conservation_violation() {
+        let envelope = Envelope {
+            consumes: vec![],
+            creates: vec![NewPosting {
+                owner: AccountId::new(1),
+                asset: AssetId::new(1),
+                value: Cent::from(100),
+                payer: None,
+            }],
+            book: BookId(0),
+            user_data: UserData::default(),
+            account_snapshots: vec![],
+            metadata: BTreeMap::new(),
+        };
+        let accounts = accounts_map(vec![make_account(1, AccountPolicy::NoOverdraft)]);
+        let balances = HashMap::new();
+        let input = PlanInput {
+            envelope: &envelope,
+            consumed_postings: &[],
+            accounts: &accounts,
+            balances: &balances,
+            book: None,
+        };
+
+        match validate_and_plan(input) {
+            Err(ValidationError::ConservationViolation { .. }) => {}
+            other => panic!("expected ConservationViolation, got {other:?}"),
+        }
+    }
+
+    #[test]
+    fn posting_not_found() {
+        let missing_pid = PostingId {
+            transfer: EnvelopeId([0; 32]),
+            index: 0,
+        };
+        let envelope = Envelope {
+            consumes: vec![missing_pid],
+            creates: vec![],
+            book: BookId(0),
+            user_data: UserData::default(),
+            account_snapshots: vec![],
+            metadata: BTreeMap::new(),
+        };
+        let accounts = HashMap::new();
+        let balances = HashMap::new();
+        let input = PlanInput {
+            envelope: &envelope,
+            consumed_postings: &[],
+            accounts: &accounts,
+            balances: &balances,
+            book: None,
+        };
+
+        assert_eq!(
+            validate_and_plan(input).unwrap_err(),
+            ValidationError::PostingNotFound(missing_pid)
+        );
+    }
+
+    #[test]
+    fn double_spend_rejected() {
+        let pid = PostingId {
+            transfer: EnvelopeId([1; 32]),
+            index: 0,
+        };
+        let posting = Posting {
+            id: pid,
+            owner: AccountId::new(1),
+            asset: AssetId::new(1),
+            value: Cent::from(100),
+            status: PostingStatus::Inactive, // already consumed
+            reservation: None,
+        };
+        let envelope = Envelope {
+            consumes: vec![pid],
+            creates: vec![NewPosting {
+                owner: AccountId::new(2),
+                asset: AssetId::new(1),
+                value: Cent::from(100),
+                payer: None,
+            }],
+            book: BookId(0),
+            user_data: UserData::default(),
+            account_snapshots: vec![],
+            metadata: BTreeMap::new(),
+        };
+        let accounts = accounts_map(vec![
+            make_account(1, AccountPolicy::NoOverdraft),
+            make_account(2, AccountPolicy::NoOverdraft),
+        ]);
+        let balances = HashMap::new();
+        let input = PlanInput {
+            envelope: &envelope,
+            consumed_postings: &[posting],
+            accounts: &accounts,
+            balances: &balances,
+            book: None,
+        };
+
+        assert_eq!(
+            validate_and_plan(input).unwrap_err(),
+            ValidationError::PostingAlreadyConsumed(pid)
+        );
+    }
+
+    #[test]
+    fn account_frozen_rejected() {
+        let envelope = deposit_envelope();
+        let mut acc = make_account(1, AccountPolicy::NoOverdraft);
+        acc.flags = AccountFlags::FROZEN;
+        let accounts = accounts_map(vec![acc, make_account(99, AccountPolicy::ExternalAccount)]);
+        let balances = HashMap::new();
+        let input = PlanInput {
+            envelope: &envelope,
+            consumed_postings: &[],
+            accounts: &accounts,
+            balances: &balances,
+            book: None,
+        };
+
+        assert_eq!(
+            validate_and_plan(input).unwrap_err(),
+            ValidationError::AccountFrozen(AccountId::new(1))
+        );
+    }
+
+    #[test]
+    fn account_closed_rejected() {
+        let envelope = deposit_envelope();
+        let mut acc = make_account(1, AccountPolicy::NoOverdraft);
+        acc.flags = AccountFlags::CLOSED;
+        let accounts = accounts_map(vec![acc, make_account(99, AccountPolicy::ExternalAccount)]);
+        let balances = HashMap::new();
+        let input = PlanInput {
+            envelope: &envelope,
+            consumed_postings: &[],
+            accounts: &accounts,
+            balances: &balances,
+            book: None,
+        };
+
+        assert_eq!(
+            validate_and_plan(input).unwrap_err(),
+            ValidationError::AccountClosed(AccountId::new(1))
+        );
+    }
+
+    #[test]
+    fn no_overdraft_exceeded() {
+        let pid = PostingId {
+            transfer: EnvelopeId([1; 32]),
+            index: 0,
+        };
+        let posting = Posting {
+            id: pid,
+            owner: AccountId::new(1),
+            asset: AssetId::new(1),
+            value: Cent::from(50),
+            status: PostingStatus::Active,
+            reservation: None,
+        };
+        // Try to send 50 but create 100 for recipient (conservation will fail first,
+        // but let's test overdraft with a valid conservation)
+        let envelope = Envelope {
+            consumes: vec![pid],
+            creates: vec![NewPosting {
+                owner: AccountId::new(2),
+                asset: AssetId::new(1),
+                value: Cent::from(50),
+                payer: None,
+            }],
+            book: BookId(0),
+            user_data: UserData::default(),
+            account_snapshots: vec![],
+            metadata: BTreeMap::new(),
+        };
+        let accounts = accounts_map(vec![
+            make_account(1, AccountPolicy::NoOverdraft),
+            make_account(2, AccountPolicy::NoOverdraft),
+        ]);
+        // account1 has balance 50, consuming 50 leaves 0, that's fine.
+        // Let's test when balance is insufficient: balance=30, consuming 50-value posting
+        let mut balances = HashMap::new();
+        balances.insert((AccountId::new(1), AssetId::new(1)), Cent::from(30));
+        // projected = 30 - 50 = -20 < 0 → overdraft
+        let input = PlanInput {
+            envelope: &envelope,
+            consumed_postings: &[posting],
+            accounts: &accounts,
+            balances: &balances,
+            book: None,
+        };
+
+        match validate_and_plan(input) {
+            Err(ValidationError::OverdraftExceeded { account, .. }) => {
+                assert_eq!(account, AccountId::new(1));
+            }
+            other => panic!("expected OverdraftExceeded, got {other:?}"),
+        }
+    }
+
+    #[test]
+    fn capped_overdraft_within_limit() {
+        let pid = PostingId {
+            transfer: EnvelopeId([1; 32]),
+            index: 0,
+        };
+        let posting = Posting {
+            id: pid,
+            owner: AccountId::new(1),
+            asset: AssetId::new(1),
+            value: Cent::from(100),
+            status: PostingStatus::Active,
+            reservation: None,
+        };
+        let envelope = Envelope {
+            consumes: vec![pid],
+            creates: vec![NewPosting {
+                owner: AccountId::new(2),
+                asset: AssetId::new(1),
+                value: Cent::from(100),
+                payer: None,
+            }],
+            book: BookId(0),
+            user_data: UserData::default(),
+            account_snapshots: vec![],
+            metadata: BTreeMap::new(),
+        };
+        let accounts = accounts_map(vec![
+            make_account(
+                1,
+                AccountPolicy::CappedOverdraft {
+                    floor: Cent::from(-50),
+                },
+            ),
+            make_account(2, AccountPolicy::NoOverdraft),
+        ]);
+        // balance=80, consuming 100 → projected = 80 - 100 = -20 >= -50 → OK
+        let mut balances = HashMap::new();
+        balances.insert((AccountId::new(1), AssetId::new(1)), Cent::from(80));
+
+        let input = PlanInput {
+            envelope: &envelope,
+            consumed_postings: &[posting],
+            accounts: &accounts,
+            balances: &balances,
+            book: None,
+        };
+
+        // A CappedOverdraft spend within the floor validates and produces a plan.
+        let plan = validate_and_plan(input).unwrap();
+        assert!(!plan.postings_to_create.is_empty());
+    }
+
+    #[test]
+    fn capped_overdraft_exceeded() {
+        let pid = PostingId {
+            transfer: EnvelopeId([1; 32]),
+            index: 0,
+        };
+        let posting = Posting {
+            id: pid,
+            owner: AccountId::new(1),
+            asset: AssetId::new(1),
+            value: Cent::from(100),
+            status: PostingStatus::Active,
+            reservation: None,
+        };
+        let envelope = Envelope {
+            consumes: vec![pid],
+            creates: vec![NewPosting {
+                owner: AccountId::new(2),
+                asset: AssetId::new(1),
+                value: Cent::from(100),
+                payer: None,
+            }],
+            book: BookId(0),
+            user_data: UserData::default(),
+            account_snapshots: vec![],
+            metadata: BTreeMap::new(),
+        };
+        let accounts = accounts_map(vec![
+            make_account(
+                1,
+                AccountPolicy::CappedOverdraft {
+                    floor: Cent::from(-50),
+                },
+            ),
+            make_account(2, AccountPolicy::NoOverdraft),
+        ]);
+        // balance=30, consuming 100 → projected = 30 - 100 = -70 < -50 → FAIL
+        let mut balances = HashMap::new();
+        balances.insert((AccountId::new(1), AssetId::new(1)), Cent::from(30));
+
+        let input = PlanInput {
+            envelope: &envelope,
+            consumed_postings: &[posting],
+            accounts: &accounts,
+            balances: &balances,
+            book: None,
+        };
+
+        match validate_and_plan(input) {
+            Err(ValidationError::OverdraftExceeded {
+                floor, projected, ..
+            }) => {
+                assert_eq!(floor, Cent::from(-50));
+                assert_eq!(projected, Cent::from(-70));
+            }
+            other => panic!("expected OverdraftExceeded, got {other:?}"),
+        }
+    }
+
+    #[test]
+    fn uncapped_overdraft_allows_negative() {
+        let pid = PostingId {
+            transfer: EnvelopeId([1; 32]),
+            index: 0,
+        };
+        let posting = Posting {
+            id: pid,
+            owner: AccountId::new(1),
+            asset: AssetId::new(1),
+            value: Cent::from(100),
+            status: PostingStatus::Active,
+            reservation: None,
+        };
+        let envelope = Envelope {
+            consumes: vec![pid],
+            creates: vec![NewPosting {
+                owner: AccountId::new(2),
+                asset: AssetId::new(1),
+                value: Cent::from(100),
+                payer: None,
+            }],
+            book: BookId(0),
+            user_data: UserData::default(),
+            account_snapshots: vec![],
+            metadata: BTreeMap::new(),
+        };
+        let accounts = accounts_map(vec![
+            make_account(1, AccountPolicy::UncappedOverdraft),
+            make_account(2, AccountPolicy::NoOverdraft),
+        ]);
+        // balance=10, consuming 100 → projected = 10 - 100 = -90 → allowed
+        let mut balances = HashMap::new();
+        balances.insert((AccountId::new(1), AssetId::new(1)), Cent::from(10));
+
+        let input = PlanInput {
+            envelope: &envelope,
+            consumed_postings: &[posting],
+            accounts: &accounts,
+            balances: &balances,
+            book: None,
+        };
+
+        // UncappedOverdraft permits the negative projection; the plan validates.
+        let plan = validate_and_plan(input).unwrap();
+        assert!(!plan.postings_to_create.is_empty());
+    }
+
+    #[test]
+    fn duplicate_consumed_posting_rejected() {
+        let pid = PostingId {
+            transfer: EnvelopeId([1; 32]),
+            index: 0,
+        };
+        let envelope = Envelope {
+            consumes: vec![pid, pid], // duplicate
+            creates: vec![],
+            book: BookId(0),
+            user_data: UserData::default(),
+            account_snapshots: vec![],
+            metadata: BTreeMap::new(),
+        };
+        let accounts = HashMap::new();
+        let balances = HashMap::new();
+        let input = PlanInput {
+            envelope: &envelope,
+            consumed_postings: &[],
+            accounts: &accounts,
+            balances: &balances,
+            book: None,
+        };
+
+        assert_eq!(
+            validate_and_plan(input).unwrap_err(),
+            ValidationError::DuplicateConsumedPosting(pid)
+        );
+    }
+
+    #[test]
+    fn internal_transfer_with_change() {
+        // account1 has a 100 posting, sends 60 to account2, gets 40 change
+        let pid = PostingId {
+            transfer: EnvelopeId([1; 32]),
+            index: 0,
+        };
+        let posting = Posting {
+            id: pid,
+            owner: AccountId::new(1),
+            asset: AssetId::new(1),
+            value: Cent::from(100),
+            status: PostingStatus::Active,
+            reservation: None,
+        };
+        let envelope = Envelope {
+            consumes: vec![pid],
+            creates: vec![
+                NewPosting {
+                    owner: AccountId::new(2),
+                    asset: AssetId::new(1),
+                    value: Cent::from(60),
+                    payer: Some(AccountId::new(1)),
+                },
+                NewPosting {
+                    owner: AccountId::new(1),
+                    asset: AssetId::new(1),
+                    value: Cent::from(40),
+                    payer: None,
+                },
+            ],
+            book: BookId(0),
+            user_data: UserData::default(),
+            account_snapshots: vec![],
+            metadata: BTreeMap::new(),
+        };
+        let accounts = accounts_map(vec![
+            make_account(1, AccountPolicy::NoOverdraft),
+            make_account(2, AccountPolicy::NoOverdraft),
+        ]);
+        let mut balances = HashMap::new();
+        balances.insert((AccountId::new(1), AssetId::new(1)), Cent::from(100));
+
+        let input = PlanInput {
+            envelope: &envelope,
+            consumed_postings: &[posting],
+            accounts: &accounts,
+            balances: &balances,
+            book: None,
+        };
+
+        let plan = validate_and_plan(input).unwrap();
+        assert_eq!(plan.postings_to_deactivate.len(), 1);
+        assert_eq!(plan.postings_to_create.len(), 2);
+        // account1 projected: 100 - 100 + 40 = 40 >= 0 ✓
+        // account2 projected: 0 + 60 = 60 >= 0 ✓
+    }
+
+    #[test]
+    fn account_not_found() {
+        let envelope = Envelope {
+            consumes: vec![],
+            creates: vec![
+                NewPosting {
+                    owner: AccountId::new(999),
+                    asset: AssetId::new(1),
+                    value: Cent::from(100),
+                    payer: None,
+                },
+                NewPosting {
+                    owner: AccountId::new(99),
+                    asset: AssetId::new(1),
+                    value: Cent::from(-100),
+                    payer: None,
+                },
+            ],
+            book: BookId(0),
+            user_data: UserData::default(),
+            account_snapshots: vec![],
+            metadata: BTreeMap::new(),
+        };
+        // Only external account exists, account 999 doesn't
+        let accounts = accounts_map(vec![make_account(99, AccountPolicy::ExternalAccount)]);
+        let balances = HashMap::new();
+        let input = PlanInput {
+            envelope: &envelope,
+            consumed_postings: &[],
+            accounts: &accounts,
+            balances: &balances,
+            book: None,
+        };
+
+        assert_eq!(
+            validate_and_plan(input).unwrap_err(),
+            ValidationError::AccountNotFound(AccountId::new(999))
+        );
+    }
+
+    #[test]
+    fn negative_posting_rejected_on_regular_account() {
+        let envelope = Envelope {
+            consumes: vec![],
+            creates: vec![
+                NewPosting {
+                    owner: AccountId::new(1),
+                    asset: AssetId::new(1),
+                    value: Cent::from(-100),
+                    payer: None,
+                },
+                NewPosting {
+                    owner: AccountId::new(1),
+                    asset: AssetId::new(1),
+                    value: Cent::from(100),
+                    payer: None,
+                },
+            ],
+            book: BookId(0),
+            user_data: UserData::default(),
+            account_snapshots: vec![],
+            metadata: BTreeMap::new(),
+        };
+        let accounts = accounts_map(vec![make_account(1, AccountPolicy::NoOverdraft)]);
+        let balances = HashMap::new();
+        let input = PlanInput {
+            envelope: &envelope,
+            consumed_postings: &[],
+            accounts: &accounts,
+            balances: &balances,
+            book: None,
+        };
+
+        assert_eq!(
+            validate_and_plan(input).unwrap_err(),
+            ValidationError::NegativePostingOnNonSystemAccount {
+                account: AccountId::new(1),
+                asset: AssetId::new(1),
+                value: Cent::from(-100),
+            }
+        );
+    }
+
+    #[test]
+    fn negative_posting_allowed_on_system_account() {
+        let envelope = deposit_envelope();
+        let accounts = accounts_map(vec![
+            make_account(1, AccountPolicy::NoOverdraft),
+            make_account(99, AccountPolicy::SystemAccount),
+        ]);
+        let balances = HashMap::new();
+        let input = PlanInput {
+            envelope: &envelope,
+            consumed_postings: &[],
+            accounts: &accounts,
+            balances: &balances,
+            book: None,
+        };
+
+        let plan = validate_and_plan(input).unwrap();
+        assert_eq!(plan.postings_to_create.len(), 2);
+    }
+}

+ 26 - 0
crates/kuatia-money/Cargo.toml

@@ -0,0 +1,26 @@
+[package]
+name = "kuatia-money"
+description = "Monetary Cent type for the Kuatia ledger with a compile-time swappable integer backing."
+version.workspace = true
+edition.workspace = true
+rust-version.workspace = true
+license.workspace = true
+repository.workspace = true
+authors.workspace = true
+keywords.workspace = true
+categories.workspace = true
+
+[lints]
+workspace = true
+
+[features]
+default = []
+# Swap the integer backing from the default i64 to i128. The choice is
+# workspace-global (cargo feature unification) and changes no source.
+i128 = []
+
+[dependencies]
+serde.workspace = true
+
+[dev-dependencies]
+serde_json.workspace = true

+ 27 - 0
crates/kuatia-money/README.md

@@ -0,0 +1,27 @@
+# kuatia-money
+
+Monetary amounts for the kuatia ledger.
+
+`Cent` is a signed amount in an asset's smallest unit. It wraps an integer
+whose width is an internal detail: the public API never names the backing type,
+and no serialized form reveals it. The width is chosen once at compile time
+through the `Backing` alias, which defaults to `i64` and switches to `i128`
+under the `i128` cargo feature.
+
+All arithmetic is checked. Addition, subtraction, and negation return
+`OverflowError` instead of wrapping, so the ledger's per-asset conservation sum
+can never silently round or overflow.
+
+## Key types
+
+| Type | Description |
+|------|-------------|
+| `Cent` | Signed amount in an asset's smallest unit, checked arithmetic |
+| `Backing` | The integer type behind every `Cent` (`i64`, or `i128` via feature) |
+| `CentBacking` | Trait an integer must satisfy to back a `Cent` |
+| `OverflowError` | Returned when a checked operation would overflow |
+
+## Features
+
+- `i128` — swap the backing integer from `i64` to `i128` across the whole
+  dependency chain.

+ 486 - 0
crates/kuatia-money/src/lib.rs

@@ -0,0 +1,486 @@
+//! Monetary amounts for the Kuatia ledger.
+//!
+//! [`Cent`] is a signed amount in an asset's smallest unit. It wraps an integer
+//! whose width is an internal detail: the public API never names the backing
+//! type, and no serialized form reveals it. The backing is chosen once at
+//! compile time through the [`Backing`] alias, which defaults to `i64` and
+//! switches to `i128` under the `i128` cargo feature. Adding a new width is a
+//! single [`CentBacking`] impl plus one line on [`Backing`]; nothing downstream
+//! changes.
+//!
+//! All arithmetic is checked: addition, subtraction and negation return
+//! [`OverflowError`] rather than wrapping, so the ledger's conservation sum can
+//! never silently round or overflow.
+
+use serde::{Deserialize, Deserializer, Serialize, Serializer};
+use std::fmt;
+use std::str::FromStr;
+
+// ---------------------------------------------------------------------------
+// Backing selection
+// ---------------------------------------------------------------------------
+
+/// The integer type backing every [`Cent`]. `i64` by default; `i128` under the
+/// `i128` cargo feature. This is the single point where the money width is
+/// chosen, and it is never named in a public signature.
+#[cfg(not(feature = "i128"))]
+pub type Backing = i64;
+
+/// The integer type backing every [`Cent`]. `i64` by default; `i128` under the
+/// `i128` cargo feature. This is the single point where the money width is
+/// chosen, and it is never named in a public signature.
+#[cfg(feature = "i128")]
+pub type Backing = i128;
+
+// ---------------------------------------------------------------------------
+// CentBacking — the swap surface
+// ---------------------------------------------------------------------------
+
+/// The contract an integer must satisfy to back a [`Cent`]. Implemented for
+/// `i64` and `i128`; implement it for another integer to add a new money width.
+///
+/// It carries only the width-dependent primitives [`Cent`] needs (canonical
+/// 16-byte widening, decimal scaling, parsing, and absolute division). Plain
+/// checked add/sub/neg are used directly on the concrete backing.
+pub trait CentBacking: Copy + Ord + Default + fmt::Display {
+    /// The additive identity (zero) for this backing.
+    const ZERO: Self;
+
+    /// Ten raised to `exp`, or `None` on overflow. Used to scale decimals.
+    fn ten_pow(exp: u32) -> Option<Self>;
+
+    /// Parse a base-10 signed integer string, or `None` if it is not valid.
+    fn parse_str(s: &str) -> Option<Self>;
+
+    /// Widen to `i128` for the fixed-width canonical encoding.
+    fn to_i128(self) -> i128;
+
+    /// Narrow from `i128`, or `None` if the value does not fit this backing.
+    fn try_from_i128(v: i128) -> Option<Self>;
+
+    /// Divide the absolute value of `self` by the absolute value of `d`,
+    /// returning `(quotient, remainder)` as unsigned `u128`. Returns `(0, 0)`
+    /// when `d` is zero. Used to split a value into whole and fractional parts
+    /// for display.
+    fn div_rem_abs(self, d: Self) -> (u128, u128);
+}
+
+impl CentBacking for i64 {
+    const ZERO: Self = 0;
+
+    fn ten_pow(exp: u32) -> Option<Self> {
+        10i64.checked_pow(exp)
+    }
+
+    fn parse_str(s: &str) -> Option<Self> {
+        s.parse().ok()
+    }
+
+    fn to_i128(self) -> i128 {
+        self as i128
+    }
+
+    fn try_from_i128(v: i128) -> Option<Self> {
+        i64::try_from(v).ok()
+    }
+
+    fn div_rem_abs(self, d: Self) -> (u128, u128) {
+        let dd = d.unsigned_abs() as u128;
+        if dd == 0 {
+            return (0, 0);
+        }
+        let a = self.unsigned_abs() as u128;
+        (a / dd, a % dd)
+    }
+}
+
+impl CentBacking for i128 {
+    const ZERO: Self = 0;
+
+    fn ten_pow(exp: u32) -> Option<Self> {
+        10i128.checked_pow(exp)
+    }
+
+    fn parse_str(s: &str) -> Option<Self> {
+        s.parse().ok()
+    }
+
+    fn to_i128(self) -> i128 {
+        self
+    }
+
+    fn try_from_i128(v: i128) -> Option<Self> {
+        Some(v)
+    }
+
+    fn div_rem_abs(self, d: Self) -> (u128, u128) {
+        let dd = d.unsigned_abs();
+        if dd == 0 {
+            return (0, 0);
+        }
+        let a = self.unsigned_abs();
+        (a / dd, a % dd)
+    }
+}
+
+// ---------------------------------------------------------------------------
+// Cent — stored monetary amount
+// ---------------------------------------------------------------------------
+
+/// A monetary amount in the smallest unit of one asset (cents, satoshis, …).
+///
+/// The backing integer is private and its width is hidden: read a `Cent` with
+/// [`Display`](fmt::Display)/[`to_string`](ToString::to_string) or parse one
+/// with [`FromStr`], compare with [`Ord`], and do arithmetic only through the
+/// checked methods. Serde round-trips it as a string, so no serialized form
+/// reveals the width.
+#[derive(Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash, Default)]
+pub struct Cent(Backing);
+
+/// Returned when a [`Cent`] arithmetic operation would overflow or underflow,
+/// or when a value does not fit the active backing.
+#[derive(Debug, Clone, Copy, PartialEq, Eq)]
+pub struct OverflowError;
+
+impl fmt::Display for OverflowError {
+    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
+        write!(f, "monetary amount overflow")
+    }
+}
+
+impl std::error::Error for OverflowError {}
+
+/// Returned when a string cannot be parsed into a [`Cent`].
+#[derive(Debug, Clone, Copy, PartialEq, Eq)]
+pub struct ParseCentError;
+
+impl fmt::Display for ParseCentError {
+    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
+        write!(f, "invalid monetary amount")
+    }
+}
+
+impl std::error::Error for ParseCentError {}
+
+impl Cent {
+    /// The zero amount.
+    pub const ZERO: Cent = Cent(0);
+
+    /// Returns `true` if the amount is strictly positive.
+    pub fn is_positive(self) -> bool {
+        self.0 > 0
+    }
+
+    /// Returns `true` if the amount is strictly negative.
+    pub fn is_negative(self) -> bool {
+        self.0 < 0
+    }
+
+    /// Returns `true` if the amount is zero.
+    pub fn is_zero(self) -> bool {
+        self.0 == 0
+    }
+
+    /// Checked addition, returning [`OverflowError`] on overflow.
+    pub fn checked_add(self, rhs: Self) -> Result<Self, OverflowError> {
+        self.0.checked_add(rhs.0).map(Cent).ok_or(OverflowError)
+    }
+
+    /// Checked subtraction, returning [`OverflowError`] on underflow.
+    pub fn checked_sub(self, rhs: Self) -> Result<Self, OverflowError> {
+        self.0.checked_sub(rhs.0).map(Cent).ok_or(OverflowError)
+    }
+
+    /// Checked negation, returning [`OverflowError`] at the backing's minimum.
+    pub fn checked_neg(self) -> Result<Self, OverflowError> {
+        self.0.checked_neg().map(Cent).ok_or(OverflowError)
+    }
+
+    /// Sum an iterator of `Cent` values with overflow checking.
+    pub fn checked_sum(iter: impl IntoIterator<Item = Self>) -> Result<Self, OverflowError> {
+        let mut sum = Cent::ZERO;
+        for x in iter {
+            sum = sum.checked_add(x)?;
+        }
+        Ok(sum)
+    }
+
+    /// The canonical 16-byte big-endian encoding (sign-extended), used for
+    /// content-addressed hashing. The width is fixed regardless of the backing,
+    /// so the same amount hashes identically under any backing.
+    pub fn to_canonical_bytes(self) -> [u8; 16] {
+        self.0.to_i128().to_be_bytes()
+    }
+
+    /// Decode a [`Cent`] from its canonical 16-byte encoding, returning
+    /// [`OverflowError`] if the value does not fit the active backing.
+    pub fn from_canonical_bytes(bytes: &[u8; 16]) -> Result<Self, OverflowError> {
+        Backing::try_from_i128(i128::from_be_bytes(*bytes))
+            .map(Cent)
+            .ok_or(OverflowError)
+    }
+}
+
+impl From<i64> for Cent {
+    fn from(v: i64) -> Self {
+        Cent(v as Backing)
+    }
+}
+
+impl From<i32> for Cent {
+    fn from(v: i32) -> Self {
+        Cent(v as Backing)
+    }
+}
+
+impl From<u32> for Cent {
+    fn from(v: u32) -> Self {
+        Cent(v as Backing)
+    }
+}
+
+impl From<u8> for Cent {
+    fn from(v: u8) -> Self {
+        Cent(v as Backing)
+    }
+}
+
+impl From<i8> for Cent {
+    fn from(v: i8) -> Self {
+        Cent(v as Backing)
+    }
+}
+
+impl fmt::Debug for Cent {
+    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
+        write!(f, "Cent({})", self.0)
+    }
+}
+
+impl fmt::Display for Cent {
+    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
+        write!(f, "{}", self.0)
+    }
+}
+
+impl FromStr for Cent {
+    type Err = ParseCentError;
+
+    fn from_str(s: &str) -> Result<Self, Self::Err> {
+        Backing::parse_str(s).map(Cent).ok_or(ParseCentError)
+    }
+}
+
+impl Serialize for Cent {
+    fn serialize<S>(&self, serializer: S) -> Result<S::Ok, S::Error>
+    where
+        S: Serializer,
+    {
+        serializer.collect_str(&self.0)
+    }
+}
+
+impl<'de> Deserialize<'de> for Cent {
+    fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
+    where
+        D: Deserializer<'de>,
+    {
+        let s = String::deserialize(deserializer)?;
+        s.parse().map_err(serde::de::Error::custom)
+    }
+}
+
+// ---------------------------------------------------------------------------
+// Amount — human-friendly parser/formatter (not stored)
+// ---------------------------------------------------------------------------
+
+/// Parses and formats human-readable amounts with a fixed number of decimal
+/// places. NOT stored anywhere — used only to convert between strings and
+/// [`Cent`] values.
+pub struct Amount {
+    decimals: u8,
+}
+
+/// Error returned when parsing an amount string fails.
+#[derive(Debug, Clone, PartialEq, Eq)]
+pub enum ParseAmountError {
+    /// The input string is not a valid number.
+    InvalidFormat(String),
+    /// Too many decimal places for the configured precision.
+    TooManyDecimals {
+        /// Maximum allowed decimal places.
+        max: u8,
+        /// Number of decimal places found in the input.
+        found: usize,
+    },
+}
+
+impl fmt::Display for ParseAmountError {
+    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
+        match self {
+            Self::InvalidFormat(s) => write!(f, "invalid amount format: {s}"),
+            Self::TooManyDecimals { max, found } => {
+                write!(f, "too many decimals: max {max}, found {found}")
+            }
+        }
+    }
+}
+
+impl std::error::Error for ParseAmountError {}
+
+impl Amount {
+    /// Create an `Amount` formatter with the given number of decimal places.
+    pub fn new(decimals: u8) -> Self {
+        Self { decimals }
+    }
+
+    /// Parses a decimal string into a [`Cent`] value.
+    pub fn parse(&self, s: &str) -> Result<Cent, ParseAmountError> {
+        let s = s.trim();
+        let (negative, s) = if let Some(rest) = s.strip_prefix('-') {
+            (true, rest)
+        } else {
+            (false, s)
+        };
+
+        let (whole_str, frac_str) = if let Some((w, f)) = s.split_once('.') {
+            (w, f)
+        } else {
+            (s, "")
+        };
+
+        if whole_str.is_empty() && frac_str.is_empty() {
+            return Err(ParseAmountError::InvalidFormat(s.to_string()));
+        }
+
+        let whole: Backing = if whole_str.is_empty() {
+            Backing::ZERO
+        } else {
+            Backing::parse_str(whole_str)
+                .ok_or_else(|| ParseAmountError::InvalidFormat(s.to_string()))?
+        };
+
+        if frac_str.len() > self.decimals as usize {
+            return Err(ParseAmountError::TooManyDecimals {
+                max: self.decimals,
+                found: frac_str.len(),
+            });
+        }
+
+        if !frac_str.is_empty() && !frac_str.chars().all(|c| c.is_ascii_digit()) {
+            return Err(ParseAmountError::InvalidFormat(s.to_string()));
+        }
+
+        let frac: Backing = if frac_str.is_empty() {
+            Backing::ZERO
+        } else {
+            let padded = format!("{:0<width$}", frac_str, width = self.decimals as usize);
+            Backing::parse_str(&padded)
+                .ok_or_else(|| ParseAmountError::InvalidFormat(s.to_string()))?
+        };
+
+        let multiplier = Backing::ten_pow(self.decimals as u32)
+            .ok_or_else(|| ParseAmountError::InvalidFormat(s.to_string()))?;
+        let value = whole
+            .checked_mul(multiplier)
+            .and_then(|v| v.checked_add(frac))
+            .ok_or_else(|| ParseAmountError::InvalidFormat(s.to_string()))?;
+
+        let value = if negative {
+            value
+                .checked_neg()
+                .ok_or_else(|| ParseAmountError::InvalidFormat(s.to_string()))?
+        } else {
+            value
+        };
+        Ok(Cent(value))
+    }
+
+    /// Formats a [`Cent`] value as a decimal string.
+    pub fn format(&self, cent: Cent) -> String {
+        if self.decimals == 0 {
+            return cent.to_string();
+        }
+
+        let Some(multiplier) = Backing::ten_pow(self.decimals as u32) else {
+            return cent.to_string();
+        };
+
+        let negative = cent.is_negative();
+        let (whole, frac) = cent.0.div_rem_abs(multiplier);
+
+        let sign = if negative { "-" } else { "" };
+        format!(
+            "{sign}{whole}.{frac:0>width$}",
+            width = self.decimals as usize
+        )
+    }
+}
+
+#[cfg(test)]
+mod tests {
+    use super::*;
+
+    #[test]
+    fn checked_math_overflows_to_error() {
+        let max = Cent::from_str(&Backing::MAX.to_string()).unwrap();
+        assert_eq!(max.checked_add(Cent::from(1)), Err(OverflowError));
+        let min = Cent::from_str(&Backing::MIN.to_string()).unwrap();
+        assert_eq!(min.checked_neg(), Err(OverflowError));
+        assert_eq!(Cent::from(2).checked_sub(Cent::from(5)), Ok(Cent::from(-3)));
+    }
+
+    #[test]
+    fn string_round_trip() {
+        for v in [-1234i64, 0, 1, 500, i64::from(i32::MAX)] {
+            let c = Cent::from(v);
+            assert_eq!(Cent::from_str(&c.to_string()), Ok(c));
+        }
+    }
+
+    #[test]
+    fn serde_is_a_string() {
+        let c = Cent::from(-50);
+        let json = serde_json::to_string(&c).unwrap();
+        assert_eq!(json, "\"-50\"");
+        assert_eq!(serde_json::from_str::<Cent>(&json).unwrap(), c);
+    }
+
+    #[test]
+    fn canonical_bytes_are_16_and_round_trip() {
+        let c = Cent::from(500);
+        let bytes = c.to_canonical_bytes();
+        assert_eq!(bytes.len(), 16);
+        assert_eq!(Cent::from_canonical_bytes(&bytes), Ok(c));
+    }
+
+    #[test]
+    fn canonical_bytes_are_width_independent() {
+        // 500 encodes the same 16 bytes whether the backing is i64 or i128.
+        let expected = 500i128.to_be_bytes();
+        assert_eq!(Cent::from(500).to_canonical_bytes(), expected);
+    }
+
+    #[test]
+    fn from_canonical_bytes_rejects_out_of_range() {
+        // A value larger than i64::MAX only fits when the backing is i128.
+        let big = (i128::from(i64::MAX)) + 1;
+        let bytes = big.to_be_bytes();
+        let decoded = Cent::from_canonical_bytes(&bytes);
+        if cfg!(feature = "i128") {
+            assert!(decoded.is_ok());
+        } else {
+            assert_eq!(decoded, Err(OverflowError));
+        }
+    }
+
+    #[test]
+    fn amount_parse_format_round_trip() {
+        let amt = Amount::new(2);
+        assert_eq!(amt.parse("12.34").unwrap(), Cent::from(1234));
+        assert_eq!(amt.parse("-0.05").unwrap(), Cent::from(-5));
+        assert_eq!(amt.format(Cent::from(1234)), "12.34");
+        assert_eq!(amt.format(Cent::from(-5)), "-0.05");
+        assert_eq!(Amount::new(0).format(Cent::from(700)), "700");
+    }
+}

+ 33 - 0
crates/kuatia-storage-sql/Cargo.toml

@@ -0,0 +1,33 @@
+[package]
+name = "kuatia-storage-sql"
+description = "SQLite/PostgreSQL storage backend for the Kuatia ledger."
+version.workspace = true
+edition.workspace = true
+rust-version.workspace = true
+license.workspace = true
+repository.workspace = true
+authors.workspace = true
+keywords.workspace = true
+categories.workspace = true
+
+[lints]
+workspace = true
+
+[dependencies]
+kuatia-types.workspace = true
+kuatia-storage.workspace = true
+sqlx = { workspace = true, features = ["macros"] }
+async-trait.workspace = true
+serde.workspace = true
+serde_json.workspace = true
+
+[dev-dependencies]
+tokio = { workspace = true, features = ["full"] }
+paste.workspace = true
+
+[features]
+default = ["sqlite"]
+sqlite = ["sqlx/sqlite"]
+postgres = ["sqlx/postgres"]
+# Pass through to the domain crates: swap the Cent backing to i128.
+i128 = ["kuatia-types/i128", "kuatia-storage/i128"]

+ 40 - 0
crates/kuatia-storage-sql/README.md

@@ -0,0 +1,40 @@
+# kuatia-storage-sql
+
+SQL-backed `Store` implementation for the kuatia ledger.
+
+Uses `sqlx::Any` for database-agnostic queries. Enable features to select
+the backend:
+
+```toml
+[dependencies]
+kuatia-storage-sql = { features = ["sqlite"] }   # or "postgres"
+```
+
+## Backends
+
+| Feature | Backend | Status |
+|---------|---------|--------|
+| `sqlite` (default) | SQLite via sqlx | Conformance tests pass |
+| `postgres` | PostgreSQL via sqlx | Portable DDL/queries; needs a running instance to test |
+
+The backend is detected at migration time and the matching DDL is applied from
+`src/migrations/{sqlite,postgres}/` (SQLite uses `BLOB`, PostgreSQL uses
+`BYTEA`). Applied migrations are tracked in a `_migrations` table, so
+`migrate()` is idempotent. Upserts use portable `ON CONFLICT … DO UPDATE`, and
+all ids are generated in Rust (no `AUTOINCREMENT`/`SERIAL`).
+
+## Usage
+
+```rust
+use kuatia_storage_sql::SqlStore;
+
+let pool = sqlx::any::AnyPoolOptions::new()
+    .connect("sqlite::memory:").await?;
+let store = SqlStore::new(pool);
+store.migrate().await?;
+```
+
+## Schema
+
+Tables: `accounts`, `postings`, `transfers`, `transfer_accounts`, `sagas`,
+`events`, `books`. Migrations run via `store.migrate()`.

+ 936 - 0
crates/kuatia-storage-sql/src/lib.rs

@@ -0,0 +1,936 @@
+//! SQL-backed Store implementation for SQLite and PostgreSQL.
+//!
+//! Uses `sqlx::Any` for database-agnostic queries. Enable features
+//! `sqlite` or `postgres` to select the backend.
+//!
+//! ```text
+//! let pool = sqlx::any::Pool<Any>Options::new()
+//!     .connect("sqlite::memory:").await?;
+//! let store = SqlStore::new(pool);
+//! store.migrate().await?;
+//! ```
+
+use std::str::FromStr;
+
+use async_trait::async_trait;
+use sqlx::{Any, Pool, Row};
+
+use kuatia_storage::error::StoreError;
+use kuatia_storage::events::{EventStore, LedgerEvent};
+use kuatia_storage::store::*;
+use kuatia_types::*;
+
+/// SQL-backed [`Store`] implementation.
+pub struct SqlStore {
+    pool: Pool<Any>,
+    autoid: kuatia_types::autoid::AutoId,
+}
+
+impl SqlStore {
+    /// Create a new SQL store wrapping an existing connection pool.
+    pub fn new(pool: Pool<Any>) -> Self {
+        Self {
+            pool,
+            autoid: kuatia_types::autoid::AutoId::new(),
+        }
+    }
+
+    /// Run database migrations. Idempotent: a `_migrations` ledger records what
+    /// has been applied, so re-running is a no-op. The DDL is selected per
+    /// backend (SQLite uses `BLOB`, PostgreSQL uses `BYTEA`).
+    pub async fn migrate(&self) -> Result<(), StoreError> {
+        // Detect the backend. `sqlite_version()` exists only on SQLite.
+        let is_sqlite = sqlx::query("SELECT sqlite_version()")
+            .fetch_optional(&self.pool)
+            .await
+            .is_ok();
+
+        sqlx::query("CREATE TABLE IF NOT EXISTS _migrations (name TEXT PRIMARY KEY)")
+            .execute(&self.pool)
+            .await
+            .map_err(|e| StoreError::Internal(e.to_string()))?;
+
+        let migrations: &[(&str, &str)] = if is_sqlite {
+            &[("001_init", include_str!("migrations/sqlite/001_init.sql"))]
+        } else {
+            &[("001_init", include_str!("migrations/postgres/001_init.sql"))]
+        };
+
+        for (name, sql) in migrations {
+            let applied = sqlx::query("SELECT 1 FROM _migrations WHERE name = $1")
+                .bind(*name)
+                .fetch_optional(&self.pool)
+                .await
+                .map_err(|e| StoreError::Internal(e.to_string()))?;
+            if applied.is_some() {
+                continue;
+            }
+
+            for statement in sql.split(';') {
+                let trimmed = statement.trim();
+                if !trimmed.is_empty() {
+                    sqlx::query(trimmed)
+                        .execute(&self.pool)
+                        .await
+                        .map_err(|e| StoreError::Internal(e.to_string()))?;
+                }
+            }
+
+            sqlx::query("INSERT INTO _migrations (name) VALUES ($1)")
+                .bind(*name)
+                .execute(&self.pool)
+                .await
+                .map_err(|e| StoreError::Internal(e.to_string()))?;
+        }
+        Ok(())
+    }
+}
+
+// ---------------------------------------------------------------------------
+// Serialization helpers
+// ---------------------------------------------------------------------------
+
+fn serialize_policy(policy: &AccountPolicy) -> Result<String, StoreError> {
+    serde_json::to_string(policy)
+        .map_err(|e| StoreError::Internal(format!("policy serialization: {e}")))
+}
+
+fn deserialize_policy(s: &str) -> Result<AccountPolicy, StoreError> {
+    serde_json::from_str(s).map_err(|e| StoreError::Internal(format!("bad policy: {e}")))
+}
+
+fn serialize_blob<T: serde::Serialize>(val: &T) -> Result<Vec<u8>, StoreError> {
+    serde_json::to_vec(val).map_err(|e| StoreError::Internal(format!("blob serialization: {e}")))
+}
+
+fn deserialize_blob<T: serde::de::DeserializeOwned>(bytes: &[u8]) -> Result<T, StoreError> {
+    serde_json::from_slice(bytes).map_err(|e| StoreError::Internal(format!("bad blob: {e}")))
+}
+
+fn status_to_i16(s: PostingStatus) -> i16 {
+    match s {
+        PostingStatus::Active => 0,
+        PostingStatus::PendingInactive => 1,
+        PostingStatus::Inactive => 2,
+    }
+}
+
+fn status_from_i16(v: i16) -> Result<PostingStatus, StoreError> {
+    match v {
+        0 => Ok(PostingStatus::Active),
+        1 => Ok(PostingStatus::PendingInactive),
+        2 => Ok(PostingStatus::Inactive),
+        _ => Err(StoreError::Internal(format!("bad posting status: {v}"))),
+    }
+}
+
+fn row_to_account(row: &sqlx::any::AnyRow) -> Result<Account, StoreError> {
+    let id: i64 = row
+        .try_get("id")
+        .map_err(|e| StoreError::Internal(e.to_string()))?;
+    let version: i64 = row
+        .try_get("version")
+        .map_err(|e| StoreError::Internal(e.to_string()))?;
+    let policy_str: String = row
+        .try_get("policy")
+        .map_err(|e| StoreError::Internal(e.to_string()))?;
+    let flags_bits: i32 = row
+        .try_get("flags")
+        .map_err(|e| StoreError::Internal(e.to_string()))?;
+    let book: i64 = row
+        .try_get("book")
+        .map_err(|e| StoreError::Internal(e.to_string()))?;
+    let user_data_bytes: Vec<u8> = row
+        .try_get("user_data")
+        .map_err(|e| StoreError::Internal(e.to_string()))?;
+    let metadata_bytes: Vec<u8> = row
+        .try_get("metadata")
+        .map_err(|e| StoreError::Internal(e.to_string()))?;
+
+    Ok(Account {
+        id: AccountId::new(id),
+        version: version as u64,
+        policy: deserialize_policy(&policy_str)?,
+        flags: AccountFlags::from_bits_truncate(flags_bits as u32),
+        book: BookId::new(book),
+        user_data: deserialize_blob(&user_data_bytes)?,
+        metadata: deserialize_blob(&metadata_bytes)?,
+    })
+}
+
+fn row_to_posting(row: &sqlx::any::AnyRow) -> Result<Posting, StoreError> {
+    let transfer_id: Vec<u8> = row
+        .try_get("transfer_id")
+        .map_err(|e| StoreError::Internal(e.to_string()))?;
+    let idx: i16 = row
+        .try_get("idx")
+        .map_err(|e| StoreError::Internal(e.to_string()))?;
+    let owner: i64 = row
+        .try_get("owner")
+        .map_err(|e| StoreError::Internal(e.to_string()))?;
+    let asset: i32 = row
+        .try_get("asset")
+        .map_err(|e| StoreError::Internal(e.to_string()))?;
+    let value: String = row
+        .try_get("value")
+        .map_err(|e| StoreError::Internal(e.to_string()))?;
+    let value = Cent::from_str(&value).map_err(|e| StoreError::Internal(e.to_string()))?;
+    let status: i16 = row
+        .try_get("status")
+        .map_err(|e| StoreError::Internal(e.to_string()))?;
+    let reservation: Option<i64> = row
+        .try_get("reservation")
+        .map_err(|e| StoreError::Internal(e.to_string()))?;
+
+    let mut tid = [0u8; 32];
+    tid.copy_from_slice(&transfer_id);
+
+    Ok(Posting {
+        id: PostingId {
+            transfer: EnvelopeId(tid),
+            index: idx as u16,
+        },
+        owner: AccountId::new(owner),
+        asset: AssetId::new(asset as u32),
+        value,
+        status: status_from_i16(status)?,
+        reservation: reservation.map(ReservationId::new),
+    })
+}
+
+// ---------------------------------------------------------------------------
+// AccountStore
+// ---------------------------------------------------------------------------
+
+#[async_trait]
+impl AccountStore for SqlStore {
+    async fn get_account(&self, id: &AccountId) -> Result<Account, StoreError> {
+        let row = sqlx::query("SELECT * FROM accounts WHERE id = $1 ORDER BY version DESC LIMIT 1")
+            .bind(id.0)
+            .fetch_optional(&self.pool)
+            .await
+            .map_err(|e| StoreError::Internal(e.to_string()))?
+            .ok_or_else(|| StoreError::NotFound(format!("account {id:?}")))?;
+        row_to_account(&row)
+    }
+
+    async fn get_accounts(&self, ids: &[AccountId]) -> Result<Vec<Account>, StoreError> {
+        let mut result = Vec::with_capacity(ids.len());
+        for id in ids {
+            result.push(self.get_account(id).await?);
+        }
+        Ok(result)
+    }
+
+    async fn create_account(&self, account: Account) -> Result<(), StoreError> {
+        let exists = sqlx::query("SELECT 1 FROM accounts WHERE id = $1 LIMIT 1")
+            .bind(account.id.0)
+            .fetch_optional(&self.pool)
+            .await
+            .map_err(|e| StoreError::Internal(e.to_string()))?;
+        if exists.is_some() {
+            return Err(StoreError::AlreadyExists(format!(
+                "account {:?}",
+                account.id
+            )));
+        }
+
+        sqlx::query(
+            "INSERT INTO accounts (id, version, policy, flags, book, user_data, metadata) VALUES ($1, $2, $3, $4, $5, $6, $7)"
+        )
+            .bind(account.id.0)
+            .bind(account.version as i64)
+            .bind(serialize_policy(&account.policy)?)
+            .bind(account.flags.bits() as i32)
+            .bind(account.book.0)
+            .bind(serialize_blob(&account.user_data)?)
+            .bind(serialize_blob(&account.metadata)?)
+            .execute(&self.pool)
+            .await
+            .map_err(|e| StoreError::Internal(e.to_string()))?;
+        Ok(())
+    }
+
+    async fn append_account_version(&self, account: Account) -> Result<(), StoreError> {
+        let current =
+            sqlx::query("SELECT version FROM accounts WHERE id = $1 ORDER BY version DESC LIMIT 1")
+                .bind(account.id.0)
+                .fetch_optional(&self.pool)
+                .await
+                .map_err(|e| StoreError::Internal(e.to_string()))?
+                .ok_or_else(|| StoreError::NotFound(format!("account {:?}", account.id)))?;
+
+        let current_version: i64 = current
+            .try_get("version")
+            .map_err(|e| StoreError::Internal(e.to_string()))?;
+        let expected = current_version
+            .checked_add(1)
+            .ok_or_else(|| StoreError::Internal("account version overflow".to_string()))?;
+
+        if account.version as i64 != expected {
+            return Err(StoreError::VersionConflict {
+                account: account.id,
+                expected: expected as u64,
+                actual: account.version,
+            });
+        }
+
+        sqlx::query(
+            "INSERT INTO accounts (id, version, policy, flags, book, user_data, metadata) VALUES ($1, $2, $3, $4, $5, $6, $7)"
+        )
+            .bind(account.id.0)
+            .bind(account.version as i64)
+            .bind(serialize_policy(&account.policy)?)
+            .bind(account.flags.bits() as i32)
+            .bind(account.book.0)
+            .bind(serialize_blob(&account.user_data)?)
+            .bind(serialize_blob(&account.metadata)?)
+            .execute(&self.pool)
+            .await
+            .map_err(|e| StoreError::Internal(e.to_string()))?;
+        Ok(())
+    }
+
+    async fn get_account_history(&self, id: &AccountId) -> Result<Vec<Account>, StoreError> {
+        let rows = sqlx::query("SELECT * FROM accounts WHERE id = $1 ORDER BY version ASC")
+            .bind(id.0)
+            .fetch_all(&self.pool)
+            .await
+            .map_err(|e| StoreError::Internal(e.to_string()))?;
+        if rows.is_empty() {
+            return Err(StoreError::NotFound(format!("account {id:?}")));
+        }
+        rows.iter().map(row_to_account).collect()
+    }
+
+    async fn list_accounts(&self) -> Result<Vec<Account>, StoreError> {
+        let rows = sqlx::query("SELECT * FROM accounts ORDER BY id, version DESC")
+            .fetch_all(&self.pool)
+            .await
+            .map_err(|e| StoreError::Internal(e.to_string()))?;
+        let mut accounts: Vec<Account> =
+            rows.iter().map(row_to_account).collect::<Result<_, _>>()?;
+        accounts.dedup_by_key(|a| a.id);
+        Ok(accounts)
+    }
+}
+
+// ---------------------------------------------------------------------------
+// PostingStore
+// ---------------------------------------------------------------------------
+
+#[async_trait]
+impl PostingStore for SqlStore {
+    async fn get_postings(&self, ids: &[PostingId]) -> Result<Vec<Posting>, StoreError> {
+        let mut result = Vec::with_capacity(ids.len());
+        for id in ids {
+            let row = sqlx::query("SELECT * FROM postings WHERE transfer_id = $1 AND idx = $2")
+                .bind(id.transfer.0.as_slice())
+                .bind(id.index as i16)
+                .fetch_optional(&self.pool)
+                .await
+                .map_err(|e| StoreError::Internal(e.to_string()))?
+                .ok_or_else(|| StoreError::NotFound(format!("posting {id:?}")))?;
+            result.push(row_to_posting(&row)?);
+        }
+        Ok(result)
+    }
+
+    async fn get_postings_by_account(
+        &self,
+        account: &AccountId,
+        asset: Option<&AssetId>,
+        status: Option<PostingStatus>,
+    ) -> Result<Vec<Posting>, StoreError> {
+        let rows = match (asset, status) {
+            (Some(a), Some(s)) => {
+                sqlx::query(
+                    "SELECT * FROM postings WHERE owner = $1 AND asset = $2 AND status = $3",
+                )
+                .bind(account.0)
+                .bind(a.0 as i32)
+                .bind(status_to_i16(s))
+                .fetch_all(&self.pool)
+                .await
+            }
+            (Some(a), None) => {
+                sqlx::query("SELECT * FROM postings WHERE owner = $1 AND asset = $2")
+                    .bind(account.0)
+                    .bind(a.0 as i32)
+                    .fetch_all(&self.pool)
+                    .await
+            }
+            (None, Some(s)) => {
+                sqlx::query("SELECT * FROM postings WHERE owner = $1 AND status = $2")
+                    .bind(account.0)
+                    .bind(status_to_i16(s))
+                    .fetch_all(&self.pool)
+                    .await
+            }
+            (None, None) => {
+                sqlx::query("SELECT * FROM postings WHERE owner = $1")
+                    .bind(account.0)
+                    .fetch_all(&self.pool)
+                    .await
+            }
+        }
+        .map_err(|e| StoreError::Internal(e.to_string()))?;
+
+        rows.iter().map(row_to_posting).collect()
+    }
+
+    async fn query_postings(&self, query: &PostingQuery) -> Result<Page<Posting>, StoreError> {
+        let (where_clause, count_clause) = {
+            let mut w = String::from("WHERE owner = $1");
+            let mut idx = 2u32;
+            if query.asset.is_some() {
+                w.push_str(&format!(" AND asset = ${idx}"));
+                idx += 1;
+            }
+            if query.status.is_some() {
+                w.push_str(&format!(" AND status = ${idx}"));
+            }
+            let c = format!("SELECT COUNT(*) as cnt FROM postings {w}");
+            let limit = query.limit.unwrap_or(u32::MAX);
+            let offset = query.offset.unwrap_or(0);
+            w.push_str(&format!(" LIMIT {limit} OFFSET {offset}"));
+            (format!("SELECT * FROM postings {w}"), c)
+        };
+
+        // Build count query
+        let mut count_q = sqlx::query(&count_clause).bind(query.account.0);
+        if let Some(ref a) = query.asset {
+            count_q = count_q.bind(a.0 as i32);
+        }
+        if let Some(s) = query.status {
+            count_q = count_q.bind(status_to_i16(s));
+        }
+        let count_row = count_q
+            .fetch_one(&self.pool)
+            .await
+            .map_err(|e| StoreError::Internal(e.to_string()))?;
+        let total: i64 = count_row
+            .try_get("cnt")
+            .map_err(|e| StoreError::Internal(e.to_string()))?;
+
+        // Build data query
+        let mut data_q = sqlx::query(&where_clause).bind(query.account.0);
+        if let Some(ref a) = query.asset {
+            data_q = data_q.bind(a.0 as i32);
+        }
+        if let Some(s) = query.status {
+            data_q = data_q.bind(status_to_i16(s));
+        }
+        let rows = data_q
+            .fetch_all(&self.pool)
+            .await
+            .map_err(|e| StoreError::Internal(e.to_string()))?;
+
+        let items: Vec<Posting> = rows.iter().map(row_to_posting).collect::<Result<_, _>>()?;
+        Ok(Page {
+            items,
+            total: total as u64,
+        })
+    }
+
+    async fn reserve_postings(
+        &self,
+        ids: &[PostingId],
+        reservation: ReservationId,
+    ) -> Result<u64, StoreError> {
+        // Dumb instruction: each id flips Active → PendingInactive (the status
+        // precondition is in the WHERE so it is atomic). Return the count of rows
+        // changed; the caller decides what a short count means.
+        let mut tx = self
+            .pool
+            .begin()
+            .await
+            .map_err(|e| StoreError::Internal(e.to_string()))?;
+        let mut reserved: u64 = 0;
+        for id in ids {
+            let res = sqlx::query(
+                "UPDATE postings SET status = $1, reservation = $2 WHERE transfer_id = $3 AND idx = $4 AND status = $5",
+            )
+            .bind(status_to_i16(PostingStatus::PendingInactive))
+            .bind(reservation.0)
+            .bind(id.transfer.0.as_slice())
+            .bind(id.index as i16)
+            .bind(status_to_i16(PostingStatus::Active))
+            .execute(&mut *tx)
+            .await
+            .map_err(|e| StoreError::Internal(e.to_string()))?;
+            reserved += res.rows_affected();
+        }
+
+        tx.commit()
+            .await
+            .map_err(|e| StoreError::Internal(e.to_string()))?;
+        Ok(reserved)
+    }
+
+    async fn release_postings(
+        &self,
+        ids: &[PostingId],
+        reservation: ReservationId,
+    ) -> Result<u64, StoreError> {
+        // Dumb instruction: each id reserved by `reservation` flips
+        // PendingInactive → Active. Return the count released; an already-Active
+        // or differently-owned posting simply does not count.
+        let mut tx = self
+            .pool
+            .begin()
+            .await
+            .map_err(|e| StoreError::Internal(e.to_string()))?;
+        let mut released: u64 = 0;
+        for id in ids {
+            let res = sqlx::query("UPDATE postings SET status = $1, reservation = NULL WHERE transfer_id = $2 AND idx = $3 AND status = $4 AND reservation = $5")
+                .bind(status_to_i16(PostingStatus::Active))
+                .bind(id.transfer.0.as_slice())
+                .bind(id.index as i16)
+                .bind(status_to_i16(PostingStatus::PendingInactive))
+                .bind(reservation.0)
+                .execute(&mut *tx)
+                .await
+                .map_err(|e| StoreError::Internal(e.to_string()))?;
+            released += res.rows_affected();
+        }
+
+        tx.commit()
+            .await
+            .map_err(|e| StoreError::Internal(e.to_string()))?;
+        Ok(released)
+    }
+
+    async fn deactivate_postings(
+        &self,
+        ids: &[PostingId],
+        reservation: Option<ReservationId>,
+    ) -> Result<u64, StoreError> {
+        let mut tx = self
+            .pool
+            .begin()
+            .await
+            .map_err(|e| StoreError::Internal(e.to_string()))?;
+        let mut changed: u64 = 0;
+        for id in ids {
+            // The precondition is the instruction; the count is the result. The
+            // caller decides what a short count means.
+            let res = match reservation {
+                None => {
+                    sqlx::query("UPDATE postings SET status = $1, reservation = NULL WHERE transfer_id = $2 AND idx = $3 AND status = $4")
+                        .bind(status_to_i16(PostingStatus::Inactive))
+                        .bind(id.transfer.0.as_slice())
+                        .bind(id.index as i16)
+                        .bind(status_to_i16(PostingStatus::Active))
+                        .execute(&mut *tx)
+                        .await
+                }
+                Some(rid) => {
+                    sqlx::query("UPDATE postings SET status = $1, reservation = NULL WHERE transfer_id = $2 AND idx = $3 AND status = $4 AND reservation = $5")
+                        .bind(status_to_i16(PostingStatus::Inactive))
+                        .bind(id.transfer.0.as_slice())
+                        .bind(id.index as i16)
+                        .bind(status_to_i16(PostingStatus::PendingInactive))
+                        .bind(rid.0)
+                        .execute(&mut *tx)
+                        .await
+                }
+            }
+            .map_err(|e| StoreError::Internal(e.to_string()))?;
+            changed += res.rows_affected();
+        }
+        tx.commit()
+            .await
+            .map_err(|e| StoreError::Internal(e.to_string()))?;
+        Ok(changed)
+    }
+
+    async fn insert_postings(&self, postings: &[Posting]) -> Result<u64, StoreError> {
+        let mut tx = self
+            .pool
+            .begin()
+            .await
+            .map_err(|e| StoreError::Internal(e.to_string()))?;
+        let mut inserted: u64 = 0;
+        for posting in postings {
+            let res = sqlx::query(
+                "INSERT INTO postings (transfer_id, idx, owner, asset, value, status) VALUES ($1, $2, $3, $4, $5, $6) ON CONFLICT (transfer_id, idx) DO NOTHING"
+            )
+                .bind(posting.id.transfer.0.as_slice())
+                .bind(posting.id.index as i16)
+                .bind(posting.owner.0)
+                .bind(posting.asset.0 as i32)
+                .bind(posting.value.to_string())
+                .bind(status_to_i16(posting.status))
+                .execute(&mut *tx)
+                .await
+                .map_err(|e| StoreError::Internal(e.to_string()))?;
+            inserted += res.rows_affected();
+        }
+        tx.commit()
+            .await
+            .map_err(|e| StoreError::Internal(e.to_string()))?;
+        Ok(inserted)
+    }
+}
+
+// ---------------------------------------------------------------------------
+// TransferStore
+// ---------------------------------------------------------------------------
+
+#[async_trait]
+impl TransferStore for SqlStore {
+    async fn get_transfer(&self, id: &EnvelopeId) -> Result<Option<EnvelopeRecord>, StoreError> {
+        let row = sqlx::query("SELECT transfer, receipt, created_at FROM transfers WHERE id = $1")
+            .bind(id.0.as_slice())
+            .fetch_optional(&self.pool)
+            .await
+            .map_err(|e| StoreError::Internal(e.to_string()))?;
+
+        match row {
+            None => Ok(None),
+            Some(row) => {
+                let transfer_bytes: Vec<u8> = row
+                    .try_get("transfer")
+                    .map_err(|e| StoreError::Internal(e.to_string()))?;
+                let receipt_bytes: Vec<u8> = row
+                    .try_get("receipt")
+                    .map_err(|e| StoreError::Internal(e.to_string()))?;
+                let created_at: i64 = row
+                    .try_get("created_at")
+                    .map_err(|e| StoreError::Internal(e.to_string()))?;
+                Ok(Some(EnvelopeRecord {
+                    envelope: deserialize_blob(&transfer_bytes)?,
+                    receipt: deserialize_blob(&receipt_bytes)?,
+                    created_at,
+                }))
+            }
+        }
+    }
+
+    async fn store_transfer(
+        &self,
+        record: EnvelopeRecord,
+        involved: &[AccountId],
+    ) -> Result<u64, StoreError> {
+        let tid = record.receipt.transfer_id;
+        let transfer_bytes = serialize_blob(&record.envelope)?;
+        let receipt_bytes = serialize_blob(&record.receipt)?;
+
+        let mut tx = self
+            .pool
+            .begin()
+            .await
+            .map_err(|e| StoreError::Internal(e.to_string()))?;
+
+        let res = sqlx::query("INSERT INTO transfers (id, transfer, receipt, created_at, book) VALUES ($1, $2, $3, $4, $5) ON CONFLICT (id) DO NOTHING")
+            .bind(tid.0.as_slice())
+            .bind(&transfer_bytes)
+            .bind(&receipt_bytes)
+            .bind(record.created_at)
+            .bind(record.envelope.book().0)
+            .execute(&mut *tx)
+            .await
+            .map_err(|e| StoreError::Internal(e.to_string()))?;
+        let inserted = res.rows_affected();
+
+        // Index every involved account (caller supplies the set; storage does no
+        // computation). Idempotent so a replay is harmless.
+        for account in involved {
+            sqlx::query("INSERT INTO transfer_accounts (transfer_id, account_id) VALUES ($1, $2) ON CONFLICT (transfer_id, account_id) DO NOTHING")
+                .bind(tid.0.as_slice())
+                .bind(account.0)
+                .execute(&mut *tx)
+                .await
+                .map_err(|e| StoreError::Internal(e.to_string()))?;
+        }
+
+        tx.commit()
+            .await
+            .map_err(|e| StoreError::Internal(e.to_string()))?;
+        Ok(inserted)
+    }
+
+    async fn get_transfers_for_account(
+        &self,
+        account: &AccountId,
+    ) -> Result<Vec<EnvelopeRecord>, StoreError> {
+        let rows = sqlx::query(
+            "SELECT t.id, t.transfer, t.receipt, t.created_at FROM transfers t INNER JOIN transfer_accounts ta ON t.id = ta.transfer_id WHERE ta.account_id = $1 ORDER BY t.created_at"
+        )
+            .bind(account.0)
+            .fetch_all(&self.pool)
+            .await
+            .map_err(|e| StoreError::Internal(e.to_string()))?;
+
+        let mut result = Vec::with_capacity(rows.len());
+        for row in &rows {
+            let transfer_bytes: Vec<u8> = row
+                .try_get("transfer")
+                .map_err(|e| StoreError::Internal(e.to_string()))?;
+            let receipt_bytes: Vec<u8> = row
+                .try_get("receipt")
+                .map_err(|e| StoreError::Internal(e.to_string()))?;
+            let created_at: i64 = row
+                .try_get("created_at")
+                .map_err(|e| StoreError::Internal(e.to_string()))?;
+            result.push(EnvelopeRecord {
+                envelope: deserialize_blob(&transfer_bytes)?,
+                receipt: deserialize_blob(&receipt_bytes)?,
+                created_at,
+            });
+        }
+        Ok(result)
+    }
+
+    async fn query_transfers(
+        &self,
+        query: &TransferQuery,
+    ) -> Result<Page<EnvelopeRecord>, StoreError> {
+        // Load base records, using the account join when available.
+        let base_records = if let Some(ref account) = query.account {
+            self.get_transfers_for_account(account).await?
+        } else {
+            let rows = sqlx::query(
+                "SELECT transfer, receipt, created_at FROM transfers ORDER BY created_at",
+            )
+            .fetch_all(&self.pool)
+            .await
+            .map_err(|e| StoreError::Internal(e.to_string()))?;
+
+            let mut records = Vec::with_capacity(rows.len());
+            for row in &rows {
+                let transfer_bytes: Vec<u8> = row
+                    .try_get("transfer")
+                    .map_err(|e| StoreError::Internal(e.to_string()))?;
+                let receipt_bytes: Vec<u8> = row
+                    .try_get("receipt")
+                    .map_err(|e| StoreError::Internal(e.to_string()))?;
+                let created_at: i64 = row
+                    .try_get("created_at")
+                    .map_err(|e| StoreError::Internal(e.to_string()))?;
+                records.push(EnvelopeRecord {
+                    envelope: deserialize_blob(&transfer_bytes)?,
+                    receipt: deserialize_blob(&receipt_bytes)?,
+                    created_at,
+                });
+            }
+            records
+        };
+
+        // Filter in memory for remaining conditions.
+        let filtered: Vec<EnvelopeRecord> = base_records
+            .into_iter()
+            .filter(|r| {
+                if let Some(from) = query.from_ts
+                    && r.created_at < from
+                {
+                    return false;
+                }
+                if let Some(to) = query.to_ts
+                    && r.created_at >= to
+                {
+                    return false;
+                }
+                if let Some(book) = query.book
+                    && r.envelope.book() != book
+                {
+                    return false;
+                }
+                true
+            })
+            .collect();
+
+        let total = filtered.len() as u64;
+        let offset = query.offset.unwrap_or(0) as usize;
+        let limit = query.limit.unwrap_or(u32::MAX) as usize;
+        let items = filtered.into_iter().skip(offset).take(limit).collect();
+
+        Ok(Page { items, total })
+    }
+}
+
+// ---------------------------------------------------------------------------
+// SagaStore
+// ---------------------------------------------------------------------------
+
+#[async_trait]
+impl SagaStore for SqlStore {
+    async fn save_saga(&self, id: &i64, data: Vec<u8>) -> Result<(), StoreError> {
+        sqlx::query(
+            "INSERT INTO sagas (id, data) VALUES ($1, $2) \
+             ON CONFLICT (id) DO UPDATE SET data = EXCLUDED.data",
+        )
+        .bind(*id)
+        .bind(&data)
+        .execute(&self.pool)
+        .await
+        .map_err(|e| StoreError::Internal(e.to_string()))?;
+        Ok(())
+    }
+
+    async fn list_pending_sagas(&self) -> Result<Vec<(i64, Vec<u8>)>, StoreError> {
+        let rows = sqlx::query("SELECT id, data FROM sagas")
+            .fetch_all(&self.pool)
+            .await
+            .map_err(|e| StoreError::Internal(e.to_string()))?;
+        let mut result = Vec::with_capacity(rows.len());
+        for row in &rows {
+            let id: i64 = row
+                .try_get("id")
+                .map_err(|e| StoreError::Internal(e.to_string()))?;
+            let data: Vec<u8> = row
+                .try_get("data")
+                .map_err(|e| StoreError::Internal(e.to_string()))?;
+            result.push((id, data));
+        }
+        Ok(result)
+    }
+
+    async fn delete_saga(&self, id: &i64) -> Result<(), StoreError> {
+        sqlx::query("DELETE FROM sagas WHERE id = $1")
+            .bind(*id)
+            .execute(&self.pool)
+            .await
+            .map_err(|e| StoreError::Internal(e.to_string()))?;
+        Ok(())
+    }
+}
+
+// ---------------------------------------------------------------------------
+// EventStore
+// ---------------------------------------------------------------------------
+
+#[async_trait]
+impl EventStore for SqlStore {
+    async fn append_event(&self, event: &LedgerEvent) -> Result<u64, StoreError> {
+        let kind_str =
+            serde_json::to_string(&event.kind).map_err(|e| StoreError::Internal(e.to_string()))?;
+        let data = serialize_blob(event)?;
+        let seq = self.autoid.next() as u64;
+
+        // Idempotent on the dedup key: a replayed transfer event conflicts on
+        // `dedup_key` and returns the existing seq instead of a duplicate row.
+        match kuatia_storage::events::event_dedup_key(&event.kind) {
+            Some(eid) => {
+                let res = sqlx::query("INSERT INTO events (seq, timestamp, kind, data, dedup_key) VALUES ($1, $2, $3, $4, $5) ON CONFLICT (dedup_key) DO NOTHING")
+                    .bind(seq as i64)
+                    .bind(event.timestamp)
+                    .bind(&kind_str)
+                    .bind(&data)
+                    .bind(eid.0.as_slice())
+                    .execute(&self.pool)
+                    .await
+                    .map_err(|e| StoreError::Internal(e.to_string()))?;
+                if res.rows_affected() == 0 {
+                    let row = sqlx::query("SELECT seq FROM events WHERE dedup_key = $1")
+                        .bind(eid.0.as_slice())
+                        .fetch_one(&self.pool)
+                        .await
+                        .map_err(|e| StoreError::Internal(e.to_string()))?;
+                    let existing: i64 = row
+                        .try_get("seq")
+                        .map_err(|e| StoreError::Internal(e.to_string()))?;
+                    return Ok(existing as u64);
+                }
+                Ok(seq)
+            }
+            None => {
+                sqlx::query(
+                    "INSERT INTO events (seq, timestamp, kind, data) VALUES ($1, $2, $3, $4)",
+                )
+                .bind(seq as i64)
+                .bind(event.timestamp)
+                .bind(&kind_str)
+                .bind(&data)
+                .execute(&self.pool)
+                .await
+                .map_err(|e| StoreError::Internal(e.to_string()))?;
+                Ok(seq)
+            }
+        }
+    }
+
+    async fn get_events_since(
+        &self,
+        after_seq: u64,
+        limit: u32,
+    ) -> Result<Vec<LedgerEvent>, StoreError> {
+        let rows = sqlx::query("SELECT seq, data FROM events WHERE seq > $1 ORDER BY seq LIMIT $2")
+            .bind(after_seq as i64)
+            .bind(limit as i32)
+            .fetch_all(&self.pool)
+            .await
+            .map_err(|e| StoreError::Internal(e.to_string()))?;
+
+        let mut events = Vec::with_capacity(rows.len());
+        for row in &rows {
+            let seq: i64 = row
+                .try_get("seq")
+                .map_err(|e| StoreError::Internal(e.to_string()))?;
+            let data: Vec<u8> = row
+                .try_get("data")
+                .map_err(|e| StoreError::Internal(e.to_string()))?;
+            let mut event: LedgerEvent = deserialize_blob(&data)?;
+            event.seq = seq as u64;
+            events.push(event);
+        }
+        Ok(events)
+    }
+}
+
+// ---------------------------------------------------------------------------
+// BookStore
+// ---------------------------------------------------------------------------
+
+#[async_trait]
+impl BookStore for SqlStore {
+    async fn create_book(&self, book: Book) -> Result<(), StoreError> {
+        let exists = sqlx::query("SELECT 1 FROM books WHERE id = $1 LIMIT 1")
+            .bind(book.id.0)
+            .fetch_optional(&self.pool)
+            .await
+            .map_err(|e| StoreError::Internal(e.to_string()))?;
+        if exists.is_some() {
+            return Err(StoreError::AlreadyExists(format!("book {:?}", book.id)));
+        }
+
+        let data = serialize_blob(&book)?;
+        sqlx::query("INSERT INTO books (id, name, data) VALUES ($1, $2, $3)")
+            .bind(book.id.0)
+            .bind(&book.name)
+            .bind(&data)
+            .execute(&self.pool)
+            .await
+            .map_err(|e| StoreError::Internal(e.to_string()))?;
+        Ok(())
+    }
+
+    async fn get_book(&self, id: &BookId) -> Result<Book, StoreError> {
+        let row = sqlx::query("SELECT data FROM books WHERE id = $1")
+            .bind(id.0)
+            .fetch_optional(&self.pool)
+            .await
+            .map_err(|e| StoreError::Internal(e.to_string()))?
+            .ok_or_else(|| StoreError::NotFound(format!("book {id:?}")))?;
+        let data: Vec<u8> = row
+            .try_get("data")
+            .map_err(|e| StoreError::Internal(e.to_string()))?;
+        deserialize_blob(&data)
+    }
+
+    async fn list_books(&self) -> Result<Vec<Book>, StoreError> {
+        let rows = sqlx::query("SELECT data FROM books")
+            .fetch_all(&self.pool)
+            .await
+            .map_err(|e| StoreError::Internal(e.to_string()))?;
+        rows.iter()
+            .map(|row| {
+                let data: Vec<u8> = row
+                    .try_get("data")
+                    .map_err(|e| StoreError::Internal(e.to_string()))?;
+                deserialize_blob(&data)
+            })
+            .collect()
+    }
+}

+ 61 - 0
crates/kuatia-storage-sql/src/migrations/postgres/001_init.sql

@@ -0,0 +1,61 @@
+CREATE TABLE IF NOT EXISTS accounts (
+    id          BIGINT NOT NULL,
+    version     BIGINT NOT NULL,
+    policy      TEXT NOT NULL,
+    flags       INTEGER NOT NULL,
+    book        BIGINT NOT NULL,
+    user_data   BYTEA NOT NULL,
+    metadata    BYTEA NOT NULL,
+    PRIMARY KEY (id, version)
+);
+
+CREATE TABLE IF NOT EXISTS postings (
+    transfer_id BYTEA NOT NULL,
+    idx         SMALLINT NOT NULL,
+    owner       BIGINT NOT NULL,
+    asset       INTEGER NOT NULL,
+    value       TEXT NOT NULL,
+    status      SMALLINT NOT NULL,
+    reservation BIGINT,
+    PRIMARY KEY (transfer_id, idx)
+);
+
+CREATE INDEX IF NOT EXISTS idx_postings_owner ON postings(owner, asset, status);
+
+CREATE TABLE IF NOT EXISTS transfers (
+    id         BYTEA PRIMARY KEY,
+    transfer   BYTEA NOT NULL,
+    receipt    BYTEA NOT NULL,
+    created_at BIGINT NOT NULL DEFAULT 0,
+    book       BIGINT NOT NULL DEFAULT 0
+);
+
+CREATE INDEX IF NOT EXISTS idx_transfers_created_at ON transfers(created_at);
+CREATE INDEX IF NOT EXISTS idx_transfers_book ON transfers(book);
+
+CREATE TABLE IF NOT EXISTS transfer_accounts (
+    transfer_id BYTEA NOT NULL,
+    account_id  BIGINT NOT NULL,
+    PRIMARY KEY (transfer_id, account_id)
+);
+
+CREATE INDEX IF NOT EXISTS idx_xfer_acct ON transfer_accounts(account_id);
+
+CREATE TABLE IF NOT EXISTS sagas (
+    id   BIGINT PRIMARY KEY,
+    data BYTEA NOT NULL
+);
+
+CREATE TABLE IF NOT EXISTS events (
+    seq       BIGINT PRIMARY KEY,
+    timestamp BIGINT NOT NULL,
+    kind      TEXT NOT NULL,
+    data      BYTEA NOT NULL,
+    dedup_key BYTEA UNIQUE
+);
+
+CREATE TABLE IF NOT EXISTS books (
+    id   BIGINT PRIMARY KEY,
+    name TEXT NOT NULL,
+    data BYTEA NOT NULL
+);

+ 61 - 0
crates/kuatia-storage-sql/src/migrations/sqlite/001_init.sql

@@ -0,0 +1,61 @@
+CREATE TABLE IF NOT EXISTS accounts (
+    id          BIGINT NOT NULL,
+    version     BIGINT NOT NULL,
+    policy      TEXT NOT NULL,
+    flags       INTEGER NOT NULL,
+    book        BIGINT NOT NULL,
+    user_data   BLOB NOT NULL,
+    metadata    BLOB NOT NULL,
+    PRIMARY KEY (id, version)
+);
+
+CREATE TABLE IF NOT EXISTS postings (
+    transfer_id BLOB NOT NULL,
+    idx         SMALLINT NOT NULL,
+    owner       BIGINT NOT NULL,
+    asset       INTEGER NOT NULL,
+    value       TEXT NOT NULL,
+    status      SMALLINT NOT NULL,
+    reservation BIGINT,
+    PRIMARY KEY (transfer_id, idx)
+);
+
+CREATE INDEX IF NOT EXISTS idx_postings_owner ON postings(owner, asset, status);
+
+CREATE TABLE IF NOT EXISTS transfers (
+    id         BLOB PRIMARY KEY,
+    transfer   BLOB NOT NULL,
+    receipt    BLOB NOT NULL,
+    created_at BIGINT NOT NULL DEFAULT 0,
+    book       BIGINT NOT NULL DEFAULT 0
+);
+
+CREATE INDEX IF NOT EXISTS idx_transfers_created_at ON transfers(created_at);
+CREATE INDEX IF NOT EXISTS idx_transfers_book ON transfers(book);
+
+CREATE TABLE IF NOT EXISTS transfer_accounts (
+    transfer_id BLOB NOT NULL,
+    account_id  BIGINT NOT NULL,
+    PRIMARY KEY (transfer_id, account_id)
+);
+
+CREATE INDEX IF NOT EXISTS idx_xfer_acct ON transfer_accounts(account_id);
+
+CREATE TABLE IF NOT EXISTS sagas (
+    id   BIGINT PRIMARY KEY,
+    data BLOB NOT NULL
+);
+
+CREATE TABLE IF NOT EXISTS events (
+    seq       BIGINT PRIMARY KEY,
+    timestamp BIGINT NOT NULL,
+    kind      TEXT NOT NULL,
+    data      BLOB NOT NULL,
+    dedup_key BLOB UNIQUE
+);
+
+CREATE TABLE IF NOT EXISTS books (
+    id   BIGINT PRIMARY KEY,
+    name TEXT NOT NULL,
+    data BLOB NOT NULL
+);

+ 33 - 0
crates/kuatia-storage-sql/tests/sqlite.rs

@@ -0,0 +1,33 @@
+#![allow(missing_docs)]
+#![cfg(feature = "sqlite")]
+
+use kuatia_storage_sql::SqlStore;
+
+async fn new_store() -> SqlStore {
+    sqlx::any::install_default_drivers();
+    let pool = sqlx::any::AnyPoolOptions::new()
+        .max_connections(1)
+        .connect("sqlite::memory:")
+        .await
+        .unwrap();
+    let store = SqlStore::new(pool);
+    store.migrate().await.unwrap();
+    store
+}
+
+kuatia_storage::store_tests!(new_store);
+
+/// migrate() is idempotent: running it repeatedly on the same DB is a no-op.
+#[tokio::test]
+async fn migrate_is_idempotent() {
+    sqlx::any::install_default_drivers();
+    let pool = sqlx::any::AnyPoolOptions::new()
+        .max_connections(1)
+        .connect("sqlite::memory:")
+        .await
+        .unwrap();
+    let store = SqlStore::new(pool);
+    store.migrate().await.unwrap();
+    store.migrate().await.unwrap();
+    store.migrate().await.unwrap();
+}

+ 29 - 0
crates/kuatia-storage/Cargo.toml

@@ -0,0 +1,29 @@
+[package]
+name = "kuatia-storage"
+description = "Storage abstraction and conformance suite for the Kuatia ledger."
+version.workspace = true
+edition.workspace = true
+rust-version.workspace = true
+license.workspace = true
+repository.workspace = true
+authors.workspace = true
+keywords.workspace = true
+categories.workspace = true
+
+[lints]
+workspace = true
+
+[features]
+default = []
+# Pass through to kuatia-types: swap the Cent backing to i128.
+i128 = ["kuatia-types/i128"]
+
+[dependencies]
+kuatia-types.workspace = true
+async-trait.workspace = true
+tokio = { workspace = true, features = ["sync", "rt", "macros"] }
+serde.workspace = true
+paste.workspace = true
+
+[dev-dependencies]
+tokio = { workspace = true, features = ["full"] }

+ 37 - 0
crates/kuatia-storage/README.md

@@ -0,0 +1,37 @@
+# kuatia-storage
+
+Storage abstraction for the kuatia ledger.
+
+Defines the `Store` trait (composed of seven sub-traits), provides an
+in-memory implementation for tests, and exports a `store_tests!` conformance
+macro that any backend can use to validate its implementation.
+
+## Sub-traits
+
+| Trait | Purpose |
+|-------|---------|
+| `AccountStore` | Account CRUD and versioning |
+| `PostingStore` | Posting reads + lifecycle: `reserve`/`release`/`deactivate`/`insert` postings (reserve/release/deactivate carry a `ReservationId`) |
+| `TransferStore` | Transfer persistence (`store_transfer`) and queries |
+| `SagaStore` | Saga state for crash recovery |
+| `EventStore` | Append-only ledger event log (idempotent on a per-transfer dedup key) |
+| `BookStore` | Book (transfer policy scope) persistence |
+
+The store is a **dumb instruction follower**: write methods apply one update and
+return the **number of affected rows** (or an I/O error). They do not interpret
+counts, decide state, enforce idempotency, or compensate — the saga in the
+`kuatia` crate does. There is no `commit_transfer`; commit is a sequence of these
+primitives, each idempotent.
+
+`Store` is a blanket trait — any type implementing the sub-traits is a `Store`.
+
+## Conformance testing
+
+```rust
+use kuatia_storage::mem_store::InMemoryStore;
+
+async fn new_store() -> InMemoryStore { InMemoryStore::new() }
+kuatia_storage::store_tests!(new_store);
+```
+
+This generates a test for every Store method, run against any backend.

+ 49 - 0
crates/kuatia-storage/src/error.rs

@@ -0,0 +1,49 @@
+//! Error types for storage implementations.
+
+use kuatia_types::AccountId;
+
+/// Errors produced by [`Store`](crate::store::Store) implementations.
+///
+/// The store is a dumb instruction follower: writes report affected-row counts,
+/// not semantic verdicts, so there are no "posting not active"/"reservation
+/// mismatch"/"cas conflict" variants — the saga derives those from counts.
+#[derive(Debug)]
+pub enum StoreError {
+    /// The requested entity was not found.
+    NotFound(String),
+    /// The entity already exists (e.g. duplicate account creation).
+    AlreadyExists(String),
+    /// Optimistic version check failed on an account update.
+    VersionConflict {
+        /// Account that had a version mismatch.
+        account: AccountId,
+        /// Version the caller expected.
+        expected: u64,
+        /// Version the store actually had.
+        actual: u64,
+    },
+    /// Catch-all for unexpected internal errors.
+    Internal(String),
+}
+
+impl std::fmt::Display for StoreError {
+    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
+        match self {
+            Self::NotFound(msg) => write!(f, "not found: {msg}"),
+            Self::AlreadyExists(msg) => write!(f, "already exists: {msg}"),
+            Self::VersionConflict {
+                account,
+                expected,
+                actual,
+            } => {
+                write!(
+                    f,
+                    "version conflict for {account:?}: expected {expected}, got {actual}"
+                )
+            }
+            Self::Internal(msg) => write!(f, "internal error: {msg}"),
+        }
+    }
+}
+
+impl std::error::Error for StoreError {}

+ 80 - 0
crates/kuatia-storage/src/events.rs

@@ -0,0 +1,80 @@
+//! Ledger event types and storage trait.
+
+use async_trait::async_trait;
+use serde::{Deserialize, Serialize};
+
+use kuatia_types::{AccountId, EnvelopeId};
+
+use crate::error::StoreError;
+
+/// The kind of ledger event that occurred.
+#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
+pub enum LedgerEventKind {
+    /// A transfer was committed.
+    TransferCommitted {
+        /// The content-addressed id of the committed transfer.
+        transfer_id: EnvelopeId,
+    },
+    /// An account was created.
+    AccountCreated {
+        /// The id of the created account.
+        account_id: AccountId,
+    },
+    /// An account was frozen.
+    AccountFrozen {
+        /// The id of the frozen account.
+        account_id: AccountId,
+    },
+    /// An account was unfrozen.
+    AccountUnfrozen {
+        /// The id of the unfrozen account.
+        account_id: AccountId,
+    },
+    /// An account was closed.
+    AccountClosed {
+        /// The id of the closed account.
+        account_id: AccountId,
+    },
+}
+
+/// A ledger event with a sequence number and timestamp.
+#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
+pub struct LedgerEvent {
+    /// Monotonic sequence number (assigned by the store).
+    pub seq: u64,
+    /// Unix milliseconds when the event was created.
+    pub timestamp: i64,
+    /// The event payload.
+    pub kind: LedgerEventKind,
+}
+
+/// The idempotency key for an event, if it has a natural one. Replayable events
+/// (a committed transfer, re-driven by saga recovery) dedup on their transfer
+/// id; events with no natural identity (account lifecycle) return `None` and may
+/// recur.
+pub fn event_dedup_key(kind: &LedgerEventKind) -> Option<EnvelopeId> {
+    match kind {
+        LedgerEventKind::TransferCommitted { transfer_id } => Some(*transfer_id),
+        LedgerEventKind::AccountCreated { .. }
+        | LedgerEventKind::AccountFrozen { .. }
+        | LedgerEventKind::AccountUnfrozen { .. }
+        | LedgerEventKind::AccountClosed { .. } => None,
+    }
+}
+
+/// Persistent event log for ledger events.
+#[async_trait]
+pub trait EventStore: Send + Sync {
+    /// Append an event and return its sequence number. Idempotent on the event's
+    /// [`event_dedup_key`]: appending an event whose key already exists does not
+    /// insert a duplicate and returns the existing seq. The `seq` field on the
+    /// input is ignored -- the store assigns it.
+    async fn append_event(&self, event: &LedgerEvent) -> Result<u64, StoreError>;
+
+    /// Return events with sequence numbers greater than `after_seq`, up to `limit`.
+    async fn get_events_since(
+        &self,
+        after_seq: u64,
+        limit: u32,
+    ) -> Result<Vec<LedgerEvent>, StoreError>;
+}

+ 10 - 0
crates/kuatia-storage/src/lib.rs

@@ -0,0 +1,10 @@
+//! Storage abstraction for the ledger.
+//!
+//! Provides the [`Store`](store::Store) trait (composed of seven sub-traits),
+//! an in-memory implementation, and a conformance test suite macro.
+
+pub mod error;
+pub mod events;
+pub mod mem_store;
+pub mod store;
+pub mod store_tests;

+ 393 - 0
crates/kuatia-storage/src/mem_store.rs

@@ -0,0 +1,393 @@
+//! In-memory store for tests and single-process embeddings.
+//!
+//! Accounts are stored as append-only version logs keyed by `AccountId`.
+
+use async_trait::async_trait;
+use std::collections::HashMap;
+use tokio::sync::RwLock;
+
+use kuatia_types::autoid::AutoId;
+use kuatia_types::{
+    Account, AccountId, AssetId, Book, BookId, EnvelopeId, Posting, PostingId, PostingStatus,
+    ReservationId,
+};
+
+use crate::error::StoreError;
+use crate::events::{EventStore, LedgerEvent};
+use crate::store::{
+    AccountStore, BookStore, EnvelopeRecord, PostingStore, SagaStore, TransferStore,
+};
+
+/// In-memory [`Store`](crate::store::Store) implementation backed by `RwLock<HashMap>`.
+pub struct InMemoryStore {
+    postings: RwLock<HashMap<PostingId, Posting>>,
+    accounts: RwLock<HashMap<AccountId, Vec<Account>>>,
+    transfers: RwLock<HashMap<EnvelopeId, EnvelopeRecord>>,
+    sagas: RwLock<HashMap<i64, Vec<u8>>>,
+    events: RwLock<Vec<LedgerEvent>>,
+    books: RwLock<HashMap<BookId, Book>>,
+    autoid: AutoId,
+}
+
+impl Default for InMemoryStore {
+    fn default() -> Self {
+        Self::new()
+    }
+}
+
+impl InMemoryStore {
+    /// Create an empty in-memory store.
+    pub fn new() -> Self {
+        Self {
+            postings: RwLock::new(HashMap::new()),
+            accounts: RwLock::new(HashMap::new()),
+            transfers: RwLock::new(HashMap::new()),
+            sagas: RwLock::new(HashMap::new()),
+            events: RwLock::new(Vec::new()),
+            books: RwLock::new(HashMap::new()),
+            autoid: AutoId::new(),
+        }
+    }
+}
+
+// ---------------------------------------------------------------------------
+// AccountStore
+// ---------------------------------------------------------------------------
+
+#[async_trait]
+impl AccountStore for InMemoryStore {
+    async fn get_account(&self, id: &AccountId) -> Result<Account, StoreError> {
+        let accounts = self.accounts.read().await;
+        accounts
+            .get(id)
+            .and_then(|v| v.last())
+            .cloned()
+            .ok_or_else(|| StoreError::NotFound(format!("account {id:?}")))
+    }
+
+    async fn get_accounts(&self, ids: &[AccountId]) -> Result<Vec<Account>, StoreError> {
+        let accounts = self.accounts.read().await;
+        let mut result = Vec::with_capacity(ids.len());
+        for id in ids {
+            let account = accounts
+                .get(id)
+                .and_then(|v| v.last())
+                .cloned()
+                .ok_or_else(|| StoreError::NotFound(format!("account {id:?}")))?;
+            result.push(account);
+        }
+        Ok(result)
+    }
+
+    async fn create_account(&self, account: Account) -> Result<(), StoreError> {
+        let id = account.id;
+        let mut accounts = self.accounts.write().await;
+        if accounts.contains_key(&id) {
+            return Err(StoreError::AlreadyExists(format!("account {id:?}")));
+        }
+        accounts.insert(id, vec![account]);
+        Ok(())
+    }
+
+    async fn append_account_version(&self, account: Account) -> Result<(), StoreError> {
+        let id = account.id;
+        let mut accounts = self.accounts.write().await;
+        let versions = accounts
+            .get_mut(&id)
+            .ok_or_else(|| StoreError::NotFound(format!("account {id:?}")))?;
+        let current_version = versions.last().map(|a| a.version).unwrap_or(0);
+        let expected = current_version
+            .checked_add(1)
+            .ok_or_else(|| StoreError::Internal("account version overflow".to_string()))?;
+        if account.version != expected {
+            return Err(StoreError::VersionConflict {
+                account: account.id,
+                expected,
+                actual: account.version,
+            });
+        }
+        versions.push(account);
+        Ok(())
+    }
+
+    async fn get_account_history(&self, id: &AccountId) -> Result<Vec<Account>, StoreError> {
+        let accounts = self.accounts.read().await;
+        accounts
+            .get(id)
+            .cloned()
+            .ok_or_else(|| StoreError::NotFound(format!("account {id:?}")))
+    }
+
+    async fn list_accounts(&self) -> Result<Vec<Account>, StoreError> {
+        let accounts = self.accounts.read().await;
+        Ok(accounts
+            .values()
+            .filter_map(|v| v.last().cloned())
+            .collect())
+    }
+}
+
+// ---------------------------------------------------------------------------
+// PostingStore
+// ---------------------------------------------------------------------------
+
+#[async_trait]
+impl PostingStore for InMemoryStore {
+    async fn get_postings(&self, ids: &[PostingId]) -> Result<Vec<Posting>, StoreError> {
+        let postings = self.postings.read().await;
+        let mut result = Vec::with_capacity(ids.len());
+        for id in ids {
+            let posting = postings
+                .get(id)
+                .ok_or_else(|| StoreError::NotFound(format!("posting {id:?}")))?;
+            result.push(posting.clone());
+        }
+        Ok(result)
+    }
+
+    async fn get_postings_by_account(
+        &self,
+        account: &AccountId,
+        asset: Option<&AssetId>,
+        status: Option<PostingStatus>,
+    ) -> Result<Vec<Posting>, StoreError> {
+        let postings = self.postings.read().await;
+        Ok(postings
+            .values()
+            .filter(|p| {
+                p.owner == *account
+                    && asset.is_none_or(|a| p.asset == *a)
+                    && status.is_none_or(|s| p.status == s)
+            })
+            .cloned()
+            .collect())
+    }
+
+    async fn reserve_postings(
+        &self,
+        ids: &[PostingId],
+        reservation: ReservationId,
+    ) -> Result<u64, StoreError> {
+        let mut postings = self.postings.write().await;
+        let mut reserved: u64 = 0;
+        for id in ids {
+            let Some(posting) = postings.get_mut(id) else {
+                continue; // dumb: a missing row just doesn't count
+            };
+            if posting.status == PostingStatus::Active {
+                posting.status = PostingStatus::PendingInactive;
+                posting.reservation = Some(reservation);
+                reserved += 1;
+            }
+        }
+        Ok(reserved)
+    }
+
+    async fn release_postings(
+        &self,
+        ids: &[PostingId],
+        reservation: ReservationId,
+    ) -> Result<u64, StoreError> {
+        let mut postings = self.postings.write().await;
+        let mut released: u64 = 0;
+        for id in ids {
+            let Some(posting) = postings.get_mut(id) else {
+                continue;
+            };
+            if posting.status == PostingStatus::PendingInactive
+                && posting.reservation == Some(reservation)
+            {
+                posting.status = PostingStatus::Active;
+                posting.reservation = None;
+                released += 1;
+            }
+        }
+        Ok(released)
+    }
+
+    async fn deactivate_postings(
+        &self,
+        ids: &[PostingId],
+        reservation: Option<ReservationId>,
+    ) -> Result<u64, StoreError> {
+        let mut postings = self.postings.write().await;
+        let mut changed: u64 = 0;
+        for id in ids {
+            let Some(posting) = postings.get_mut(id) else {
+                continue; // dumb: a missing row just doesn't count
+            };
+            let matches = match reservation {
+                None => posting.status == PostingStatus::Active,
+                Some(rid) => {
+                    posting.status == PostingStatus::PendingInactive
+                        && posting.reservation == Some(rid)
+                }
+            };
+            if matches {
+                posting.status = PostingStatus::Inactive;
+                posting.reservation = None;
+                changed += 1;
+            }
+        }
+        Ok(changed)
+    }
+
+    async fn insert_postings(&self, postings: &[Posting]) -> Result<u64, StoreError> {
+        let mut store = self.postings.write().await;
+        let mut inserted: u64 = 0;
+        for posting in postings {
+            if let std::collections::hash_map::Entry::Vacant(e) = store.entry(posting.id) {
+                e.insert(posting.clone());
+                inserted += 1;
+            }
+        }
+        Ok(inserted)
+    }
+}
+
+// ---------------------------------------------------------------------------
+// TransferStore
+// ---------------------------------------------------------------------------
+
+#[async_trait]
+impl TransferStore for InMemoryStore {
+    async fn get_transfer(&self, id: &EnvelopeId) -> Result<Option<EnvelopeRecord>, StoreError> {
+        let transfers = self.transfers.read().await;
+        Ok(transfers.get(id).cloned())
+    }
+
+    async fn store_transfer(
+        &self,
+        record: EnvelopeRecord,
+        _involved: &[AccountId],
+    ) -> Result<u64, StoreError> {
+        // `_involved` is ignored here: `get_transfers_for_account` derives the
+        // involved accounts from the stored envelope (creates owners + consumed
+        // posting owners), which matches the set the caller passes. The SQL
+        // backend instead persists `involved` into its `transfer_accounts` index.
+        let mut transfers = self.transfers.write().await;
+        if transfers.contains_key(&record.receipt.transfer_id) {
+            return Ok(0);
+        }
+        transfers.insert(record.receipt.transfer_id, record);
+        Ok(1)
+    }
+
+    async fn get_transfers_for_account(
+        &self,
+        account: &AccountId,
+    ) -> Result<Vec<EnvelopeRecord>, StoreError> {
+        // Acquire postings → transfers in a consistent order to avoid an AB–BA
+        // deadlock with any reader that takes both.
+        let postings = self.postings.read().await;
+        let transfers = self.transfers.read().await;
+        let mut result: Vec<EnvelopeRecord> = transfers
+            .values()
+            .filter(|record| {
+                record
+                    .envelope
+                    .creates()
+                    .iter()
+                    .any(|np| np.owner == *account)
+                    || record
+                        .envelope
+                        .consumes()
+                        .iter()
+                        .any(|pid| postings.get(pid).is_some_and(|p| p.owner == *account))
+            })
+            .cloned()
+            .collect();
+        result.sort_by_key(|r| r.created_at);
+        Ok(result)
+    }
+}
+
+// ---------------------------------------------------------------------------
+// SagaStore
+// ---------------------------------------------------------------------------
+
+#[async_trait]
+impl SagaStore for InMemoryStore {
+    async fn save_saga(&self, id: &i64, data: Vec<u8>) -> Result<(), StoreError> {
+        let mut sagas = self.sagas.write().await;
+        sagas.insert(*id, data);
+        Ok(())
+    }
+
+    async fn list_pending_sagas(&self) -> Result<Vec<(i64, Vec<u8>)>, StoreError> {
+        let sagas = self.sagas.read().await;
+        Ok(sagas.iter().map(|(k, v)| (*k, v.clone())).collect())
+    }
+
+    async fn delete_saga(&self, id: &i64) -> Result<(), StoreError> {
+        let mut sagas = self.sagas.write().await;
+        sagas.remove(id);
+        Ok(())
+    }
+}
+
+// ---------------------------------------------------------------------------
+// EventStore
+// ---------------------------------------------------------------------------
+
+#[async_trait]
+impl EventStore for InMemoryStore {
+    async fn append_event(&self, event: &LedgerEvent) -> Result<u64, StoreError> {
+        let mut events = self.events.write().await;
+        // Idempotent on the dedup key: a replayed transfer event returns the
+        // existing seq instead of inserting a duplicate.
+        if let Some(key) = crate::events::event_dedup_key(&event.kind)
+            && let Some(existing) = events
+                .iter()
+                .find(|e| crate::events::event_dedup_key(&e.kind) == Some(key))
+        {
+            return Ok(existing.seq);
+        }
+        let seq = self.autoid.next() as u64;
+        events.push(LedgerEvent {
+            seq,
+            timestamp: event.timestamp,
+            kind: event.kind.clone(),
+        });
+        Ok(seq)
+    }
+
+    async fn get_events_since(
+        &self,
+        after_seq: u64,
+        limit: u32,
+    ) -> Result<Vec<LedgerEvent>, StoreError> {
+        let events = self.events.read().await;
+        Ok(events
+            .iter()
+            .filter(|e| e.seq > after_seq)
+            .take(limit as usize)
+            .cloned()
+            .collect())
+    }
+}
+
+#[async_trait]
+impl BookStore for InMemoryStore {
+    async fn create_book(&self, book: Book) -> Result<(), StoreError> {
+        let mut books = self.books.write().await;
+        if books.contains_key(&book.id) {
+            return Err(StoreError::AlreadyExists(format!("book {:?}", book.id)));
+        }
+        books.insert(book.id, book);
+        Ok(())
+    }
+
+    async fn get_book(&self, id: &BookId) -> Result<Book, StoreError> {
+        let books = self.books.read().await;
+        books
+            .get(id)
+            .cloned()
+            .ok_or_else(|| StoreError::NotFound(format!("book {id:?}")))
+    }
+
+    async fn list_books(&self) -> Result<Vec<Book>, StoreError> {
+        let books = self.books.read().await;
+        Ok(books.values().cloned().collect())
+    }
+}

+ 261 - 0
crates/kuatia-storage/src/store.rs

@@ -0,0 +1,261 @@
+//! Storage abstraction separating the pure decision logic from IO.
+//!
+//! The [`Store`] trait composes focused sub-traits, each a dumb instruction
+//! follower: writes apply one update and report an affected-row count (or an I/O
+//! error). The saga, not the store, interprets counts and owns idempotency and
+//! compensation.
+//! - [`AccountStore`] — account CRUD and versioning
+//! - [`PostingStore`] — posting reads and lifecycle transitions
+//! - [`TransferStore`] — transfer persistence and queries
+//! - [`SagaStore`] — saga state for crash recovery
+//! - [`EventStore`] — the ledger event log
+//! - [`BookStore`] — book persistence
+
+use async_trait::async_trait;
+use kuatia_types::{
+    Account, AccountId, AssetId, Book, BookId, Envelope, EnvelopeId, Posting, PostingId,
+    PostingStatus, Receipt, ReservationId,
+};
+
+use crate::error::StoreError;
+use crate::events::EventStore;
+
+/// Pairs a committed transfer with its receipt.
+#[derive(Debug, Clone)]
+pub struct EnvelopeRecord {
+    /// The envelope that was committed.
+    pub envelope: Envelope,
+    /// The receipt proving commitment.
+    pub receipt: Receipt,
+    /// Unix milliseconds when this record was created.
+    pub created_at: i64,
+}
+
+/// Pagination and filtering parameters for posting queries.
+#[derive(Debug, Clone)]
+pub struct PostingQuery {
+    /// Filter to postings owned by this account.
+    pub account: AccountId,
+    /// Filter by asset.
+    pub asset: Option<AssetId>,
+    /// Filter by posting status.
+    pub status: Option<PostingStatus>,
+    /// Max results to return.
+    pub limit: Option<u32>,
+    /// Number of results to skip.
+    pub offset: Option<u32>,
+}
+
+/// Pagination and filtering parameters for transfer queries.
+#[derive(Debug, Clone, Default)]
+pub struct TransferQuery {
+    /// Filter to transfers involving this account.
+    pub account: Option<AccountId>,
+    /// Inclusive lower bound (unix millis).
+    pub from_ts: Option<i64>,
+    /// Exclusive upper bound (unix millis).
+    pub to_ts: Option<i64>,
+    /// Filter by book.
+    pub book: Option<BookId>,
+    /// Max results to return.
+    pub limit: Option<u32>,
+    /// Number of results to skip.
+    pub offset: Option<u32>,
+}
+
+/// A page of results with total count for pagination.
+#[derive(Debug, Clone)]
+pub struct Page<T> {
+    /// The items in this page.
+    pub items: Vec<T>,
+    /// Total number of matching items (before pagination).
+    pub total: u64,
+}
+
+// ---------------------------------------------------------------------------
+// Sub-traits
+// ---------------------------------------------------------------------------
+
+/// Account persistence: create, version, query.
+#[async_trait]
+pub trait AccountStore: Send + Sync {
+    /// Fetch a single account by id.
+    async fn get_account(&self, id: &AccountId) -> Result<Account, StoreError>;
+    /// Fetch multiple accounts by id.
+    async fn get_accounts(&self, ids: &[AccountId]) -> Result<Vec<Account>, StoreError>;
+    /// Persist a new account (version 1).
+    async fn create_account(&self, account: Account) -> Result<(), StoreError>;
+    /// Append a new version to an existing account.
+    async fn append_account_version(&self, account: Account) -> Result<(), StoreError>;
+    /// Return the full version history for an account.
+    async fn get_account_history(&self, id: &AccountId) -> Result<Vec<Account>, StoreError>;
+    /// List all accounts (latest version of each).
+    async fn list_accounts(&self) -> Result<Vec<Account>, StoreError>;
+}
+
+/// Posting persistence: reads and lifecycle transitions.
+#[async_trait]
+pub trait PostingStore: Send + Sync {
+    /// Fetch postings by their ids.
+    async fn get_postings(&self, ids: &[PostingId]) -> Result<Vec<Posting>, StoreError>;
+    /// Return postings owned by an account, optionally filtered by asset and/or status.
+    async fn get_postings_by_account(
+        &self,
+        account: &AccountId,
+        asset: Option<&AssetId>,
+        status: Option<PostingStatus>,
+    ) -> Result<Vec<Posting>, StoreError>;
+    /// Reserve postings: `Active → PendingInactive`, stamping `reservation` as
+    /// the owner token. A dumb instruction — each id flips only if still `Active`;
+    /// returns the **number of rows reserved** (0 ≤ n ≤ ids.len()). It does not
+    /// error on a short count; the caller (saga) interprets it.
+    async fn reserve_postings(
+        &self,
+        ids: &[PostingId],
+        reservation: ReservationId,
+    ) -> Result<u64, StoreError>;
+    /// Release postings: `PendingInactive` owned by `reservation` → `Active`,
+    /// clearing the owner. A dumb instruction — only postings reserved by this
+    /// `reservation` flip; returns the **number of rows released**. Releasing an
+    /// `Active` (already released) or differently-owned posting simply does not
+    /// count. The caller interprets the result.
+    async fn release_postings(
+        &self,
+        ids: &[PostingId],
+        reservation: ReservationId,
+    ) -> Result<u64, StoreError>;
+
+    /// Deactivate postings: flip to `Inactive`. A dumb instruction — it applies
+    /// the conditional update and returns the **number of rows changed**; it does
+    /// not decide whether that count is correct. The caller (saga) interprets it.
+    /// - `reservation == None` (raw): only postings still `Active` flip.
+    /// - `reservation == Some(rid)`: only postings `PendingInactive` owned by
+    ///   `rid` flip.
+    /// Returns the count of postings actually transitioned (0 ≤ n ≤ ids.len()).
+    async fn deactivate_postings(
+        &self,
+        ids: &[PostingId],
+        reservation: Option<ReservationId>,
+    ) -> Result<u64, StoreError>;
+
+    /// Insert postings if absent (idempotent). A dumb instruction — inserts each
+    /// posting unless one with the same id already exists, and returns the
+    /// **number of rows inserted** (already-present postings contribute 0). The
+    /// caller decides what a short count means.
+    async fn insert_postings(&self, postings: &[Posting]) -> Result<u64, StoreError>;
+
+    /// Query postings with filtering and pagination.
+    async fn query_postings(&self, query: &PostingQuery) -> Result<Page<Posting>, StoreError> {
+        let all = self
+            .get_postings_by_account(&query.account, query.asset.as_ref(), query.status)
+            .await?;
+        let total = all.len() as u64;
+        let offset = query.offset.unwrap_or(0) as usize;
+        let limit = query.limit.unwrap_or(u32::MAX) as usize;
+        let items = all.into_iter().skip(offset).take(limit).collect();
+        Ok(Page { items, total })
+    }
+}
+
+/// Transfer persistence: store and query committed transfers.
+#[async_trait]
+pub trait TransferStore: Send + Sync {
+    /// Fetch a transfer record by its content-addressed id.
+    async fn get_transfer(&self, id: &EnvelopeId) -> Result<Option<EnvelopeRecord>, StoreError>;
+    /// Persist a transfer record if absent (idempotent) and index it under every
+    /// account in `involved` (both created and consumed owners — the caller
+    /// supplies the set so storage computes nothing). A dumb instruction:
+    /// returns **1** if the transfer row was newly inserted, **0** if it already
+    /// existed. The caller decides what `0` means.
+    async fn store_transfer(
+        &self,
+        record: EnvelopeRecord,
+        involved: &[AccountId],
+    ) -> Result<u64, StoreError>;
+    /// Return all transfers involving the given account.
+    async fn get_transfers_for_account(
+        &self,
+        account: &AccountId,
+    ) -> Result<Vec<EnvelopeRecord>, StoreError>;
+
+    /// Query transfers with filtering and pagination.
+    async fn query_transfers(
+        &self,
+        query: &TransferQuery,
+    ) -> Result<Page<EnvelopeRecord>, StoreError> {
+        // Default in-memory implementation
+        let all = if let Some(ref account) = query.account {
+            self.get_transfers_for_account(account).await?
+        } else {
+            return Err(StoreError::Internal(
+                "query_transfers requires account filter in default implementation".into(),
+            ));
+        };
+
+        let filtered: Vec<EnvelopeRecord> = all
+            .into_iter()
+            .filter(|r| {
+                if let Some(from) = query.from_ts
+                    && r.created_at < from
+                {
+                    return false;
+                }
+                if let Some(to) = query.to_ts
+                    && r.created_at >= to
+                {
+                    return false;
+                }
+                if let Some(book) = query.book
+                    && r.envelope.book() != book
+                {
+                    return false;
+                }
+                true
+            })
+            .collect();
+
+        let total = filtered.len() as u64;
+        let offset = query.offset.unwrap_or(0) as usize;
+        let limit = query.limit.unwrap_or(u32::MAX) as usize;
+        let items = filtered.into_iter().skip(offset).take(limit).collect();
+
+        Ok(Page { items, total })
+    }
+}
+
+/// Saga state persistence for crash recovery.
+#[async_trait]
+pub trait SagaStore: Send + Sync {
+    /// Persist a saga execution state.
+    async fn save_saga(&self, id: &i64, data: Vec<u8>) -> Result<(), StoreError>;
+    /// Load all pending (incomplete) saga states.
+    async fn list_pending_sagas(&self) -> Result<Vec<(i64, Vec<u8>)>, StoreError>;
+    /// Delete a completed saga state.
+    async fn delete_saga(&self, id: &i64) -> Result<(), StoreError>;
+}
+
+/// Book persistence.
+#[async_trait]
+pub trait BookStore: Send + Sync {
+    /// Create a new book.
+    async fn create_book(&self, book: Book) -> Result<(), StoreError>;
+    /// Fetch a book by id.
+    async fn get_book(&self, id: &BookId) -> Result<Book, StoreError>;
+    /// List all books.
+    async fn list_books(&self) -> Result<Vec<Book>, StoreError>;
+}
+
+// ---------------------------------------------------------------------------
+// Composite trait
+// ---------------------------------------------------------------------------
+
+/// Async storage abstraction composing all sub-traits.
+pub trait Store:
+    AccountStore + PostingStore + TransferStore + SagaStore + EventStore + BookStore
+{
+}
+
+impl<T: AccountStore + PostingStore + TransferStore + SagaStore + EventStore + BookStore> Store
+    for T
+{
+}

+ 1007 - 0
crates/kuatia-storage/src/store_tests.rs

@@ -0,0 +1,1007 @@
+//! Generic conformance test suite for [`Store`] implementations.
+//!
+//! Use the [`store_tests!`](crate::store_tests!) macro to generate the full suite for any Store impl.
+//!
+//! ```text
+//! async fn new_store() -> MyStore { MyStore::new() }
+//! kuatia_storage::store_tests!(new_store);
+//! ```
+
+use std::collections::BTreeMap;
+
+use kuatia_types::*;
+
+use crate::error::StoreError;
+use crate::events::{LedgerEvent, LedgerEventKind};
+use crate::store::*;
+
+// ---------------------------------------------------------------------------
+// Helpers
+// ---------------------------------------------------------------------------
+
+fn make_account(id: i64, policy: AccountPolicy) -> Account {
+    Account {
+        id: AccountId::new(id),
+        version: 1,
+        policy,
+        flags: AccountFlags::empty(),
+        book: BookId(0),
+        user_data: UserData::default(),
+        metadata: BTreeMap::new(),
+    }
+}
+
+fn make_posting(
+    transfer_hash: [u8; 32],
+    index: u16,
+    owner: i64,
+    asset: u32,
+    value: i64,
+) -> Posting {
+    Posting::new(
+        PostingId {
+            transfer: EnvelopeId(transfer_hash),
+            index,
+        },
+        AccountId::new(owner),
+        AssetId::new(asset),
+        Cent::from(value),
+    )
+}
+
+fn make_envelope_with_book(book: BookId) -> (Envelope, EnvelopeId) {
+    let t = EnvelopeBuilder::new()
+        .creates(vec![
+            NewPosting {
+                owner: AccountId::new(1),
+                asset: AssetId::new(1),
+                value: Cent::from(100),
+                payer: None,
+            },
+            NewPosting {
+                owner: AccountId::new(99),
+                asset: AssetId::new(1),
+                value: Cent::from(-100),
+                payer: None,
+            },
+        ])
+        .book(book)
+        .build();
+    // Use book id to create distinct EnvelopeIds.
+    let mut tid_bytes = [0u8; 32];
+    tid_bytes[0] = book.0 as u8;
+    tid_bytes[1] = 42;
+    (t, EnvelopeId(tid_bytes))
+}
+
+fn make_envelope() -> (Envelope, EnvelopeId) {
+    let t = EnvelopeBuilder::new()
+        .creates(vec![
+            NewPosting {
+                owner: AccountId::new(1),
+                asset: AssetId::new(1),
+                value: Cent::from(100),
+                payer: None,
+            },
+            NewPosting {
+                owner: AccountId::new(99),
+                asset: AssetId::new(1),
+                value: Cent::from(-100),
+                payer: None,
+            },
+        ])
+        .build();
+    // Use a fixed EnvelopeId — store tests don't need content-addressing.
+    let tid = EnvelopeId([42; 32]);
+    (t, tid)
+}
+
+/// Seed `create` as Active postings via the dumb `insert_postings` primitive.
+/// `tag` is unused now (kept so existing call sites read unchanged).
+async fn seed_active(store: &(impl Store + 'static), _tag: u8, create: &[Posting]) {
+    store.insert_postings(create).await.unwrap();
+}
+
+/// Persist `envelope` as a committed transfer, deriving its created postings the
+/// way the ledger does (`PostingId { transfer: tid, index }`) and indexing the
+/// created owners — the same shape the saga produces.
+async fn commit_envelope(
+    store: &(impl Store + 'static),
+    envelope: Envelope,
+    tid: EnvelopeId,
+    created_at: i64,
+) {
+    let create: Vec<Posting> = envelope
+        .creates()
+        .iter()
+        .enumerate()
+        .map(|(i, np)| {
+            Posting::new(
+                PostingId {
+                    transfer: tid,
+                    index: i as u16,
+                },
+                np.owner,
+                np.asset,
+                np.value,
+            )
+        })
+        .collect();
+    let mut involved: Vec<AccountId> = create.iter().map(|p| p.owner).collect();
+    involved.sort();
+    involved.dedup();
+    store.insert_postings(&create).await.unwrap();
+    store
+        .store_transfer(
+            EnvelopeRecord {
+                envelope,
+                receipt: Receipt { transfer_id: tid },
+                created_at,
+            },
+            &involved,
+        )
+        .await
+        .unwrap();
+}
+
+// ---------------------------------------------------------------------------
+// AccountStore tests
+// ---------------------------------------------------------------------------
+
+/// Create an account and retrieve it.
+pub async fn create_and_get_account(store: &(impl Store + 'static)) {
+    let acc = make_account(1, AccountPolicy::NoOverdraft);
+    store.create_account(acc.clone()).await.unwrap();
+    let got = store.get_account(&AccountId::new(1)).await.unwrap();
+    assert_eq!(got.id, acc.id);
+    assert_eq!(got.version, 1);
+}
+
+/// Duplicate account creation fails.
+pub async fn create_duplicate_account_fails(store: &(impl Store + 'static)) {
+    let acc = make_account(1, AccountPolicy::NoOverdraft);
+    store.create_account(acc.clone()).await.unwrap();
+    let err = store.create_account(acc).await.unwrap_err();
+    assert!(matches!(err, StoreError::AlreadyExists(_)));
+}
+
+/// Get non-existent account returns NotFound.
+pub async fn get_missing_account_fails(store: &(impl Store + 'static)) {
+    let err = store.get_account(&AccountId::new(999)).await.unwrap_err();
+    assert!(matches!(err, StoreError::NotFound(_)));
+}
+
+/// Fetch multiple accounts in one call.
+pub async fn get_accounts_batch(store: &(impl Store + 'static)) {
+    store
+        .create_account(make_account(1, AccountPolicy::NoOverdraft))
+        .await
+        .unwrap();
+    store
+        .create_account(make_account(2, AccountPolicy::NoOverdraft))
+        .await
+        .unwrap();
+    let accs = store
+        .get_accounts(&[AccountId::new(1), AccountId::new(2)])
+        .await
+        .unwrap();
+    assert_eq!(accs.len(), 2);
+}
+
+/// Append a new version and verify get returns the latest.
+pub async fn append_account_version(store: &(impl Store + 'static)) {
+    let acc = make_account(1, AccountPolicy::NoOverdraft);
+    store.create_account(acc.clone()).await.unwrap();
+
+    let mut v2 = acc.clone();
+    v2.version = 2;
+    v2.flags = AccountFlags::FROZEN;
+    store.append_account_version(v2).await.unwrap();
+
+    let got = store.get_account(&AccountId::new(1)).await.unwrap();
+    assert_eq!(got.version, 2);
+    assert!(got.is_frozen());
+}
+
+/// Appending with wrong version number fails.
+pub async fn append_version_conflict(store: &(impl Store + 'static)) {
+    let acc = make_account(1, AccountPolicy::NoOverdraft);
+    store.create_account(acc.clone()).await.unwrap();
+
+    let mut bad = acc.clone();
+    bad.version = 5;
+    let err = store.append_account_version(bad).await.unwrap_err();
+    assert!(matches!(err, StoreError::VersionConflict { .. }));
+}
+
+/// Account history returns all versions.
+pub async fn get_account_history(store: &(impl Store + 'static)) {
+    let acc = make_account(1, AccountPolicy::NoOverdraft);
+    store.create_account(acc.clone()).await.unwrap();
+
+    let mut v2 = acc.clone();
+    v2.version = 2;
+    store.append_account_version(v2).await.unwrap();
+
+    let history = store.get_account_history(&AccountId::new(1)).await.unwrap();
+    assert_eq!(history.len(), 2);
+    assert_eq!(history[0].version, 1);
+    assert_eq!(history[1].version, 2);
+}
+
+/// List accounts returns latest version of each.
+pub async fn list_accounts(store: &(impl Store + 'static)) {
+    store
+        .create_account(make_account(1, AccountPolicy::NoOverdraft))
+        .await
+        .unwrap();
+    store
+        .create_account(make_account(2, AccountPolicy::ExternalAccount))
+        .await
+        .unwrap();
+    let list = store.list_accounts().await.unwrap();
+    assert_eq!(list.len(), 2);
+}
+
+// ---------------------------------------------------------------------------
+// PostingStore tests
+// ---------------------------------------------------------------------------
+
+/// Committing with empty deactivate creates new postings.
+pub async fn commit_creates_postings(store: &(impl Store + 'static)) {
+    let p = make_posting([1; 32], 0, 1, 1, 100);
+    seed_active(store, 200, std::slice::from_ref(&p)).await;
+
+    let got = store.get_postings(&[p.id]).await.unwrap();
+    assert_eq!(got.len(), 1);
+    assert_eq!(got[0].value, Cent::from(100));
+}
+
+/// Get non-existent posting returns NotFound.
+pub async fn get_postings_missing_fails(store: &(impl Store + 'static)) {
+    let missing = PostingId {
+        transfer: EnvelopeId([0; 32]),
+        index: 0,
+    };
+    let err = store.get_postings(&[missing]).await.unwrap_err();
+    assert!(matches!(err, StoreError::NotFound(_)));
+}
+
+/// Filter postings by account, asset, and status.
+pub async fn get_postings_by_account_filters(store: &(impl Store + 'static)) {
+    let p1 = make_posting([1; 32], 0, 1, 1, 100);
+    let p2 = make_posting([1; 32], 1, 1, 2, 200);
+    let p3 = make_posting([1; 32], 2, 2, 1, 300);
+    seed_active(store, 200, &[p1, p2, p3]).await;
+
+    let all = store
+        .get_postings_by_account(&AccountId::new(1), None, None)
+        .await
+        .unwrap();
+    assert_eq!(all.len(), 2);
+
+    let filtered = store
+        .get_postings_by_account(&AccountId::new(1), Some(&AssetId::new(1)), None)
+        .await
+        .unwrap();
+    assert_eq!(filtered.len(), 1);
+    assert_eq!(filtered[0].value, Cent::from(100));
+
+    let active = store
+        .get_postings_by_account(&AccountId::new(1), None, Some(PostingStatus::Active))
+        .await
+        .unwrap();
+    assert_eq!(active.len(), 2);
+}
+
+/// Query postings with pagination.
+pub async fn query_postings_pagination(store: &(impl Store + 'static)) {
+    // Create 5 postings for account 1, asset 1
+    let postings: Vec<Posting> = (0..5)
+        .map(|i| make_posting([1; 32], i, 1, 1, (i as i64 + 1) * 100))
+        .collect();
+    seed_active(store, 200, &postings).await;
+
+    // Page 1: first 2
+    let page1 = store
+        .query_postings(&PostingQuery {
+            account: AccountId::new(1),
+            asset: None,
+            status: None,
+            limit: Some(2),
+            offset: Some(0),
+        })
+        .await
+        .unwrap();
+    assert_eq!(page1.items.len(), 2);
+    assert_eq!(page1.total, 5);
+
+    // Page 2: next 2
+    let page2 = store
+        .query_postings(&PostingQuery {
+            account: AccountId::new(1),
+            asset: None,
+            status: None,
+            limit: Some(2),
+            offset: Some(2),
+        })
+        .await
+        .unwrap();
+    assert_eq!(page2.items.len(), 2);
+    assert_eq!(page2.total, 5);
+
+    // Page 3: last 1
+    let page3 = store
+        .query_postings(&PostingQuery {
+            account: AccountId::new(1),
+            asset: None,
+            status: None,
+            limit: Some(2),
+            offset: Some(4),
+        })
+        .await
+        .unwrap();
+    assert_eq!(page3.items.len(), 1);
+    assert_eq!(page3.total, 5);
+
+    // With asset filter
+    let filtered = store
+        .query_postings(&PostingQuery {
+            account: AccountId::new(1),
+            asset: Some(AssetId::new(1)),
+            status: None,
+            limit: Some(10),
+            offset: None,
+        })
+        .await
+        .unwrap();
+    assert_eq!(filtered.total, 5);
+    assert_eq!(filtered.items.len(), 5);
+}
+
+/// Reserve a batch of postings: Active → PendingInactive.
+pub async fn reserve_postings_batch(store: &(impl Store + 'static)) {
+    let p1 = make_posting([1; 32], 0, 1, 1, 100);
+    let p2 = make_posting([1; 32], 1, 1, 1, 200);
+    seed_active(store, 200, &[p1.clone(), p2.clone()]).await;
+
+    store
+        .reserve_postings(&[p1.id, p2.id], ReservationId::new(1))
+        .await
+        .unwrap();
+
+    let got = store.get_postings(&[p1.id, p2.id]).await.unwrap();
+    assert!(
+        got.iter()
+            .all(|p| p.status == PostingStatus::PendingInactive)
+    );
+}
+
+/// Reserve only flips the still-Active postings and reports that count; an
+/// already-reserved posting in the batch is skipped (the saga interprets the
+/// short count).
+pub async fn reserve_skips_non_active(store: &(impl Store + 'static)) {
+    let p1 = make_posting([1; 32], 0, 1, 1, 100);
+    let p2 = make_posting([1; 32], 1, 1, 1, 200);
+    seed_active(store, 200, &[p1.clone(), p2.clone()]).await;
+
+    assert_eq!(
+        store
+            .reserve_postings(&[p1.id], ReservationId::new(1))
+            .await
+            .unwrap(),
+        1
+    );
+
+    // p1 already PendingInactive → only p2 (still Active) reserves.
+    assert_eq!(
+        store
+            .reserve_postings(&[p1.id, p2.id], ReservationId::new(1))
+            .await
+            .unwrap(),
+        1
+    );
+    assert_eq!(
+        store.get_postings(&[p2.id]).await.unwrap()[0].status,
+        PostingStatus::PendingInactive
+    );
+}
+
+/// Release reserved postings back to Active.
+pub async fn release_postings_batch(store: &(impl Store + 'static)) {
+    let p1 = make_posting([1; 32], 0, 1, 1, 100);
+    seed_active(store, 200, std::slice::from_ref(&p1)).await;
+    store
+        .reserve_postings(&[p1.id], ReservationId::new(1))
+        .await
+        .unwrap();
+
+    store
+        .release_postings(&[p1.id], ReservationId::new(1))
+        .await
+        .unwrap();
+
+    let got = store.get_postings(&[p1.id]).await.unwrap();
+    assert_eq!(got[0].status, PostingStatus::Active);
+}
+
+/// Releasing an Active posting is a no-op (succeeds silently).
+pub async fn release_active_is_noop(store: &(impl Store + 'static)) {
+    let p1 = make_posting([1; 32], 0, 1, 1, 100);
+    seed_active(store, 200, std::slice::from_ref(&p1)).await;
+
+    store
+        .release_postings(&[p1.id], ReservationId::new(1))
+        .await
+        .unwrap();
+
+    let got = store.get_postings(&[p1.id]).await.unwrap();
+    assert_eq!(got[0].status, PostingStatus::Active);
+}
+
+/// Releasing an Inactive (void) posting is a no-op: zero rows released.
+pub async fn release_inactive_zero(store: &(impl Store + 'static)) {
+    let p1 = make_posting([1; 32], 0, 1, 1, 100);
+    seed_active(store, 200, std::slice::from_ref(&p1)).await;
+
+    // Deactivate p1 (raw path: still Active) so the release sees a void posting.
+    assert_eq!(store.deactivate_postings(&[p1.id], None).await.unwrap(), 1);
+
+    assert_eq!(
+        store
+            .release_postings(&[p1.id], ReservationId::new(1))
+            .await
+            .unwrap(),
+        0
+    );
+    assert_eq!(
+        store.get_postings(&[p1.id]).await.unwrap()[0].status,
+        PostingStatus::Inactive
+    );
+}
+
+/// Deactivating a reserved posting (saga path) transitions it
+/// PendingInactive → Inactive while a separate insert adds the created posting.
+pub async fn commit_deactivates_postings(store: &(impl Store + 'static)) {
+    let p1 = make_posting([1; 32], 0, 1, 1, 100);
+    seed_active(store, 200, std::slice::from_ref(&p1)).await;
+    store
+        .reserve_postings(&[p1.id], ReservationId::new(1))
+        .await
+        .unwrap();
+
+    let p2 = make_posting([2; 32], 0, 1, 1, 100);
+    // Saga path: p1 is PendingInactive owned by reservation 1.
+    assert_eq!(
+        store
+            .deactivate_postings(&[p1.id], Some(ReservationId::new(1)))
+            .await
+            .unwrap(),
+        1
+    );
+    store
+        .insert_postings(std::slice::from_ref(&p2))
+        .await
+        .unwrap();
+
+    let got = store.get_postings(&[p1.id]).await.unwrap();
+    assert_eq!(got[0].status, PostingStatus::Inactive);
+
+    let got2 = store.get_postings(&[p2.id]).await.unwrap();
+    assert_eq!(got2[0].status, PostingStatus::Active);
+}
+
+// ---------------------------------------------------------------------------
+// Dumb count-returning primitives (storage reports counts, never interprets)
+// ---------------------------------------------------------------------------
+
+/// `insert_postings` reports how many rows were newly inserted; already-present
+/// postings contribute zero (idempotent).
+pub async fn insert_postings_counts(store: &(impl Store + 'static)) {
+    let p1 = make_posting([3; 32], 0, 1, 1, 100);
+    let p2 = make_posting([3; 32], 1, 1, 1, 200);
+    assert_eq!(
+        store
+            .insert_postings(std::slice::from_ref(&p1))
+            .await
+            .unwrap(),
+        1
+    );
+    // p1 already present, p2 new → 1
+    assert_eq!(
+        store
+            .insert_postings(&[p1.clone(), p2.clone()])
+            .await
+            .unwrap(),
+        1
+    );
+    // both present → 0
+    assert_eq!(store.insert_postings(&[p1, p2]).await.unwrap(), 0);
+}
+
+/// `deactivate_postings` (raw path) flips Active→Inactive and reports the count;
+/// a replay over already-Inactive postings reports zero.
+pub async fn deactivate_postings_counts(store: &(impl Store + 'static)) {
+    let p1 = make_posting([4; 32], 0, 1, 1, 100);
+    let p2 = make_posting([4; 32], 1, 1, 1, 200);
+    store
+        .insert_postings(&[p1.clone(), p2.clone()])
+        .await
+        .unwrap();
+
+    assert_eq!(
+        store
+            .deactivate_postings(&[p1.id, p2.id], None)
+            .await
+            .unwrap(),
+        2
+    );
+    // replay: already Inactive → 0
+    assert_eq!(
+        store
+            .deactivate_postings(&[p1.id, p2.id], None)
+            .await
+            .unwrap(),
+        0
+    );
+    assert_eq!(
+        store.get_postings(&[p1.id]).await.unwrap()[0].status,
+        PostingStatus::Inactive
+    );
+}
+
+/// `deactivate_postings` (saga path) only flips postings reserved by the given
+/// reservation; a non-matching reservation reports zero.
+pub async fn deactivate_postings_saga_path(store: &(impl Store + 'static)) {
+    let p1 = make_posting([5; 32], 0, 1, 1, 100);
+    store
+        .insert_postings(std::slice::from_ref(&p1))
+        .await
+        .unwrap();
+    store
+        .reserve_postings(&[p1.id], ReservationId::new(7))
+        .await
+        .unwrap();
+
+    // wrong reservation → 0 (storage doesn't error; the saga decides)
+    assert_eq!(
+        store
+            .deactivate_postings(&[p1.id], Some(ReservationId::new(8)))
+            .await
+            .unwrap(),
+        0
+    );
+    // right reservation → 1
+    assert_eq!(
+        store
+            .deactivate_postings(&[p1.id], Some(ReservationId::new(7)))
+            .await
+            .unwrap(),
+        1
+    );
+}
+
+/// `store_transfer` returns 1 when the record is newly inserted, 0 on replay,
+/// and indexes the involved accounts.
+pub async fn store_transfer_counts(store: &(impl Store + 'static)) {
+    let (envelope, tid) = make_envelope(); // creates owners 1 and 99
+    let record = EnvelopeRecord {
+        envelope,
+        receipt: Receipt { transfer_id: tid },
+        created_at: 1000,
+    };
+    let involved = [AccountId::new(1), AccountId::new(99)];
+
+    assert_eq!(
+        store
+            .store_transfer(record.clone(), &involved)
+            .await
+            .unwrap(),
+        1
+    );
+    // replay → 0
+    assert_eq!(store.store_transfer(record, &involved).await.unwrap(), 0);
+    assert!(store.get_transfer(&tid).await.unwrap().is_some());
+    assert_eq!(
+        store
+            .get_transfers_for_account(&AccountId::new(1))
+            .await
+            .unwrap()
+            .len(),
+        1
+    );
+}
+
+// ---------------------------------------------------------------------------
+// Reservation / double-spend regressions (sequential — the conformance harness
+// holds a single `&store`; the second attempt is what must report zero).
+// ---------------------------------------------------------------------------
+
+/// A posting reserved by one reservation cannot be reserved by another: the
+/// second reserve flips zero rows (the saga reads the count to know it lost).
+pub async fn reserve_twice_second_zero(store: &(impl Store + 'static)) {
+    let p1 = make_posting([1; 32], 0, 1, 1, 100);
+    seed_active(store, 200, std::slice::from_ref(&p1)).await;
+
+    assert_eq!(
+        store
+            .reserve_postings(&[p1.id], ReservationId::new(1))
+            .await
+            .unwrap(),
+        1
+    );
+    assert_eq!(
+        store
+            .reserve_postings(&[p1.id], ReservationId::new(2))
+            .await
+            .unwrap(),
+        0
+    );
+}
+
+/// A posting cannot be deactivated twice: once Inactive, a second raw deactivate
+/// reports zero — the double-spend guard at the storage layer.
+pub async fn deactivate_twice_second_zero(store: &(impl Store + 'static)) {
+    let consumed = make_posting([7; 32], 0, 1, 1, 100);
+    seed_active(store, 200, std::slice::from_ref(&consumed)).await;
+
+    assert_eq!(
+        store
+            .deactivate_postings(&[consumed.id], None)
+            .await
+            .unwrap(),
+        1
+    );
+    assert_eq!(
+        store
+            .deactivate_postings(&[consumed.id], None)
+            .await
+            .unwrap(),
+        0
+    );
+}
+
+/// `append_event` is idempotent on a transfer's dedup key: re-appending the same
+/// `TransferCommitted` returns the existing seq and does not duplicate the row.
+pub async fn append_event_idempotent(store: &(impl Store + 'static)) {
+    let event = LedgerEvent {
+        seq: 0,
+        timestamp: 1000,
+        kind: LedgerEventKind::TransferCommitted {
+            transfer_id: EnvelopeId([8; 32]),
+        },
+    };
+    let seq1 = store.append_event(&event).await.unwrap();
+    let seq2 = store.append_event(&event).await.unwrap();
+    assert_eq!(seq1, seq2);
+    assert_eq!(store.get_events_since(0, 10).await.unwrap().len(), 1);
+}
+
+// ---------------------------------------------------------------------------
+// TransferStore tests
+// ---------------------------------------------------------------------------
+
+/// Commit a transfer and retrieve it by id.
+pub async fn commit_and_get_transfer(store: &(impl Store + 'static)) {
+    let (envelope, tid) = make_envelope();
+    commit_envelope(store, envelope, tid, 1000).await;
+
+    let got = store.get_transfer(&tid).await.unwrap();
+    assert!(got.is_some());
+    assert_eq!(got.unwrap().receipt.transfer_id, tid);
+}
+
+/// Get non-existent transfer returns None.
+pub async fn get_missing_transfer(store: &(impl Store + 'static)) {
+    let got = store.get_transfer(&EnvelopeId([0; 32])).await.unwrap();
+    assert!(got.is_none());
+}
+
+/// Query transfers by account.
+pub async fn get_transfers_for_account(store: &(impl Store + 'static)) {
+    let (envelope, tid) = make_envelope();
+    commit_envelope(store, envelope, tid, 1000).await;
+
+    let records = store
+        .get_transfers_for_account(&AccountId::new(1))
+        .await
+        .unwrap();
+    assert_eq!(records.len(), 1);
+
+    let empty = store
+        .get_transfers_for_account(&AccountId::new(999))
+        .await
+        .unwrap();
+    assert!(empty.is_empty());
+}
+
+/// Verify that created_at roundtrips through commit/retrieve.
+pub async fn commit_preserves_created_at(store: &(impl Store + 'static)) {
+    let (envelope, tid) = make_envelope();
+    commit_envelope(store, envelope, tid, 1718000000000).await;
+
+    let got = store.get_transfer(&tid).await.unwrap().unwrap();
+    assert_eq!(got.created_at, 1718000000000);
+}
+
+// ---------------------------------------------------------------------------
+// TransferQuery tests
+// ---------------------------------------------------------------------------
+
+/// Query transfers by date range.
+pub async fn query_transfers_by_date_range(store: &(impl Store + 'static)) {
+    let (e1, t1) = make_envelope();
+    commit_envelope(store, e1, t1, 1000).await;
+
+    let (e2, t2) = make_envelope_with_book(BookId(1));
+    commit_envelope(store, e2, t2, 2000).await;
+
+    let page = store
+        .query_transfers(&TransferQuery {
+            account: Some(AccountId::new(1)),
+            from_ts: Some(1500),
+            ..Default::default()
+        })
+        .await
+        .unwrap();
+    assert_eq!(page.total, 1);
+    assert_eq!(page.items[0].created_at, 2000);
+}
+
+/// Query transfers with pagination.
+pub async fn query_transfers_pagination(store: &(impl Store + 'static)) {
+    // Store 3 transfers with different timestamps.
+    for i in 0..3u8 {
+        let mut tid_bytes = [0u8; 32];
+        tid_bytes[0] = i + 10;
+        let (envelope, _) = make_envelope();
+        let tid = EnvelopeId(tid_bytes);
+        commit_envelope(store, envelope, tid, (i as i64 + 1) * 1000).await;
+    }
+
+    let page = store
+        .query_transfers(&TransferQuery {
+            account: Some(AccountId::new(1)),
+            limit: Some(2),
+            offset: Some(0),
+            ..Default::default()
+        })
+        .await
+        .unwrap();
+    assert_eq!(page.items.len(), 2);
+    assert_eq!(page.total, 3);
+
+    let page2 = store
+        .query_transfers(&TransferQuery {
+            account: Some(AccountId::new(1)),
+            limit: Some(2),
+            offset: Some(2),
+            ..Default::default()
+        })
+        .await
+        .unwrap();
+    assert_eq!(page2.items.len(), 1);
+    assert_eq!(page2.total, 3);
+}
+
+/// Query transfers by book.
+pub async fn query_transfers_by_book(store: &(impl Store + 'static)) {
+    let (e1, t1) = make_envelope(); // book = 0
+    commit_envelope(store, e1, t1, 1000).await;
+
+    let (e2, t2) = make_envelope_with_book(BookId(5));
+    commit_envelope(store, e2, t2, 2000).await;
+
+    let page = store
+        .query_transfers(&TransferQuery {
+            account: Some(AccountId::new(1)),
+            book: Some(BookId(5)),
+            ..Default::default()
+        })
+        .await
+        .unwrap();
+    assert_eq!(page.total, 1);
+    assert_eq!(page.items[0].envelope.book(), BookId(5));
+}
+
+// ---------------------------------------------------------------------------
+// SagaStore tests
+// ---------------------------------------------------------------------------
+
+/// Save saga state and list it.
+pub async fn save_and_list_sagas(store: &(impl Store + 'static)) {
+    let id: i64 = 42;
+    let data = vec![1, 2, 3];
+    store.save_saga(&id, data.clone()).await.unwrap();
+
+    let pending = store.list_pending_sagas().await.unwrap();
+    assert_eq!(pending.len(), 1);
+    assert_eq!(pending[0].0, id);
+    assert_eq!(pending[0].1, data);
+}
+
+/// Delete a saga state.
+pub async fn delete_saga(store: &(impl Store + 'static)) {
+    let id: i64 = 42;
+    store.save_saga(&id, vec![1, 2, 3]).await.unwrap();
+    store.delete_saga(&id).await.unwrap();
+
+    let pending = store.list_pending_sagas().await.unwrap();
+    assert!(pending.is_empty());
+}
+
+// ---------------------------------------------------------------------------
+// EventStore tests
+// ---------------------------------------------------------------------------
+
+/// Append events and query them back.
+pub async fn append_and_query_events(store: &(impl Store + 'static)) {
+    let e1 = LedgerEvent {
+        seq: 0,
+        timestamp: 1000,
+        kind: LedgerEventKind::AccountCreated {
+            account_id: AccountId::new(1),
+        },
+    };
+    let e2 = LedgerEvent {
+        seq: 0,
+        timestamp: 2000,
+        kind: LedgerEventKind::TransferCommitted {
+            transfer_id: EnvelopeId([42; 32]),
+        },
+    };
+
+    let seq1 = store.append_event(&e1).await.unwrap();
+    let seq2 = store.append_event(&e2).await.unwrap();
+    assert!(seq2 > seq1);
+
+    let events = store.get_events_since(0, 100).await.unwrap();
+    assert_eq!(events.len(), 2);
+    assert_eq!(events[0].seq, seq1);
+    assert_eq!(events[1].seq, seq2);
+}
+
+/// Events are ordered by sequence number and support cursor-based pagination.
+pub async fn events_sequence_ordering(store: &(impl Store + 'static)) {
+    for i in 0..5u64 {
+        store
+            .append_event(&LedgerEvent {
+                seq: 0,
+                timestamp: (i as i64 + 1) * 1000,
+                kind: LedgerEventKind::AccountCreated {
+                    account_id: AccountId::new(i as i64 + 1),
+                },
+            })
+            .await
+            .unwrap();
+    }
+
+    let page1 = store.get_events_since(0, 3).await.unwrap();
+    assert_eq!(page1.len(), 3);
+
+    let page2 = store.get_events_since(page1[2].seq, 10).await.unwrap();
+    assert_eq!(page2.len(), 2);
+}
+
+// ---------------------------------------------------------------------------
+// BookStore
+// ---------------------------------------------------------------------------
+
+fn make_book(id: i64, name: &str) -> Book {
+    BookBuilder::new(name)
+        .id(BookId::new(id))
+        .allow_asset(AssetId::new(1))
+        .build()
+}
+
+/// Create a book and read it back.
+pub async fn create_and_get_book(store: &(impl Store + 'static)) {
+    let book = make_book(1, "sales");
+    store.create_book(book.clone()).await.unwrap();
+    let got = store.get_book(&BookId::new(1)).await.unwrap();
+    assert_eq!(got, book);
+}
+
+/// Duplicate book creation fails.
+pub async fn create_duplicate_book_fails(store: &(impl Store + 'static)) {
+    let book = make_book(1, "sales");
+    store.create_book(book.clone()).await.unwrap();
+    let err = store.create_book(book).await.unwrap_err();
+    assert!(matches!(err, StoreError::AlreadyExists(_)));
+}
+
+/// Get a non-existent book returns NotFound.
+pub async fn get_missing_book_fails(store: &(impl Store + 'static)) {
+    let err = store.get_book(&BookId::new(999)).await.unwrap_err();
+    assert!(matches!(err, StoreError::NotFound(_)));
+}
+
+/// List all books.
+pub async fn list_books(store: &(impl Store + 'static)) {
+    store.create_book(make_book(1, "sales")).await.unwrap();
+    store.create_book(make_book(2, "inventory")).await.unwrap();
+    let mut books = store.list_books().await.unwrap();
+    books.sort_by_key(|b| b.id.0);
+    assert_eq!(books.len(), 2);
+    assert_eq!(books[0].name, "sales");
+    assert_eq!(books[1].name, "inventory");
+}
+
+// ---------------------------------------------------------------------------
+// Macro
+// ---------------------------------------------------------------------------
+
+/// Generate the full Store conformance test suite.
+///
+/// `$factory` must be an async fn returning a value that implements [`Store`].
+///
+/// ```text
+/// async fn new_store() -> InMemoryStore { InMemoryStore::new() }
+/// kuatia_storage::store_tests!(new_store);
+/// ```
+#[macro_export]
+macro_rules! store_tests {
+    ($factory:path) => {
+        $crate::store_tests!(@tests $factory,
+            // AccountStore
+            create_and_get_account,
+            create_duplicate_account_fails,
+            get_missing_account_fails,
+            get_accounts_batch,
+            append_account_version,
+            append_version_conflict,
+            get_account_history,
+            list_accounts,
+            // PostingStore
+            commit_creates_postings,
+            get_postings_missing_fails,
+            get_postings_by_account_filters,
+            query_postings_pagination,
+            reserve_postings_batch,
+            reserve_skips_non_active,
+            release_postings_batch,
+            release_active_is_noop,
+            release_inactive_zero,
+            commit_deactivates_postings,
+            insert_postings_counts,
+            deactivate_postings_counts,
+            deactivate_postings_saga_path,
+            store_transfer_counts,
+            // Reservation / double-spend regressions
+            reserve_twice_second_zero,
+            deactivate_twice_second_zero,
+            append_event_idempotent,
+            // TransferStore
+            commit_and_get_transfer,
+            get_missing_transfer,
+            get_transfers_for_account,
+            commit_preserves_created_at,
+            // TransferQuery
+            query_transfers_by_date_range,
+            query_transfers_pagination,
+            query_transfers_by_book,
+            // SagaStore
+            save_and_list_sagas,
+            delete_saga,
+            // EventStore
+            append_and_query_events,
+            events_sequence_ordering,
+            // BookStore
+            create_and_get_book,
+            create_duplicate_book_fails,
+            get_missing_book_fails,
+            list_books,
+        );
+    };
+
+    (@tests $factory:path, $($test:ident),+ $(,)?) => {
+        ::paste::paste! {
+            $(
+                #[tokio::test]
+                async fn [< $test >]() {
+                    $crate::store_tests::$test(&$factory().await).await;
+                }
+            )+
+        }
+    };
+}

+ 73 - 0
crates/kuatia-storage/tests/concurrency.rs

@@ -0,0 +1,73 @@
+//! Concurrency tests for `InMemoryStore` primitives.
+//!
+//! The generated conformance suite drives the store through a single `&store`,
+//! so it never exercises two callers racing on the same rows. `reserve_postings`
+//! is the primitive the saga relies on to make double-spends impossible: it must
+//! flip each `Active` posting to `PendingInactive` for exactly one caller, even
+//! when many callers target the same postings at once.
+
+#![allow(missing_docs)]
+
+use std::sync::Arc;
+
+use kuatia_storage::mem_store::InMemoryStore;
+use kuatia_storage::store::PostingStore;
+use kuatia_types::*;
+
+fn posting(index: u16) -> Posting {
+    Posting::new(
+        PostingId {
+            transfer: EnvelopeId([1; 32]),
+            index,
+        },
+        AccountId::new(1),
+        AssetId::new(1),
+        Cent::from(100),
+    )
+}
+
+/// Many tasks concurrently reserve the same set of postings, each with its own
+/// reservation id. Reservation is a claim, so each posting may be reserved by
+/// exactly one task: the per-task counts sum to the number of postings, and
+/// every posting ends `PendingInactive`.
+#[tokio::test(flavor = "multi_thread", worker_threads = 4)]
+async fn concurrent_reserve_claims_each_posting_once() {
+    const POSTINGS: u16 = 32;
+    const TASKS: i64 = 8;
+
+    let store = Arc::new(InMemoryStore::new());
+    let all: Vec<Posting> = (0..POSTINGS).map(posting).collect();
+    store.insert_postings(&all).await.unwrap();
+
+    let ids: Vec<PostingId> = all.iter().map(|p| p.id).collect();
+
+    let mut handles = Vec::new();
+    for t in 0..TASKS {
+        let store = Arc::clone(&store);
+        let ids = ids.clone();
+        handles.push(tokio::spawn(async move {
+            store
+                .reserve_postings(&ids, ReservationId::new(t + 1))
+                .await
+                .unwrap()
+        }));
+    }
+
+    let mut total_reserved: u64 = 0;
+    for h in handles {
+        total_reserved += h.await.unwrap();
+    }
+
+    assert_eq!(
+        total_reserved, POSTINGS as u64,
+        "each posting is reserved by exactly one task"
+    );
+
+    let final_postings = store.get_postings(&ids).await.unwrap();
+    assert!(
+        final_postings
+            .iter()
+            .all(|p| p.status == PostingStatus::PendingInactive),
+        "every posting ends reserved"
+    );
+}

+ 9 - 0
crates/kuatia-storage/tests/store_conformance.rs

@@ -0,0 +1,9 @@
+#![allow(missing_docs)]
+
+use kuatia_storage::mem_store::InMemoryStore;
+
+async fn new_store() -> InMemoryStore {
+    InMemoryStore::new()
+}
+
+kuatia_storage::store_tests!(new_store);

+ 24 - 0
crates/kuatia-types/Cargo.toml

@@ -0,0 +1,24 @@
+[package]
+name = "kuatia-types"
+description = "Domain types for the Kuatia ledger: postings, accounts, transfers, books."
+version.workspace = true
+edition.workspace = true
+rust-version.workspace = true
+license.workspace = true
+repository.workspace = true
+authors.workspace = true
+keywords.workspace = true
+categories.workspace = true
+
+[lints]
+workspace = true
+
+[features]
+default = []
+# Pass through to kuatia-money: swap the Cent backing to i128.
+i128 = ["kuatia-money/i128"]
+
+[dependencies]
+kuatia-money.workspace = true
+serde.workspace = true
+bitflags.workspace = true

+ 25 - 0
crates/kuatia-types/README.md

@@ -0,0 +1,25 @@
+# kuatia-types
+
+Domain types for the kuatia ledger.
+
+Pure data structures with no IO, no async, and minimal dependencies (`serde`, `bitflags`).
+This crate is the foundation — every other kuatia crate depends on it.
+
+## Key types
+
+| Type | Description |
+|------|-------------|
+| `AccountId(i64)` | Stable account identity |
+| `AssetId(u32)` | Asset identifier — conservation boundary |
+| `EnvelopeId([u8; 32])` | Content-addressed transfer hash |
+| `PostingId { transfer, index }` | Posting identity within a transfer |
+| `Cent(i64)` | Smallest monetary unit, checked arithmetic |
+| `Posting` | Signed amount owned by one account (positive = held, negative = offset) |
+| `Transfer` | Atomic unit: consumes + creates postings |
+| `Account` | Versioned entity with policy, flags, and book |
+| `Book` / `BookId` | Transfer policy scope — gates which accounts/assets may participate |
+| `PostingStatus` | `Active` → `PendingInactive` → `Inactive` |
+
+## Traits
+
+- **`ToBytes`** — deterministic binary serialization for content-addressing

+ 198 - 0
crates/kuatia-types/src/autoid.rs

@@ -0,0 +1,198 @@
+//! Auto ID generator — snowflake-inspired i64 ID scheme.
+//!
+//! Inspired by Twitter's Snowflake ID format (2010), adapted for
+//! single-process embedded use with CRC32-based disambiguation.
+//!
+//! Bit layout:
+//! ```text
+//! [0][  40 bits: ms timestamp  ][ 23 bits: CRC32(data) or counter ]
+//!  ^sign (always 0 = positive)
+//! ```
+//!
+//! - Bit 63: always 0 (keeps i64 positive)
+//! - Bits 62–23: milliseconds since [`KUATIA_EPOCH_MS`] (40 bits ≈ 34.8 years)
+//! - Bits 22–0: lower 23 bits of CRC32 of context data, or an internal
+//!   counter that wraps on overflow when no data is provided.
+//!
+//! The millisecond field counts from a fixed recent epoch
+//! ([`KUATIA_EPOCH_MS`] = 2026-01-01T00:00:00Z) rather than the Unix epoch, so
+//! the 40-bit window gives ~34.8 years of range *going forward* (until ~2060)
+//! instead of a window already partly elapsed since 1970. Collision resistance
+//! within a millisecond comes from the CRC32 tail (for content-keyed ids).
+
+use std::sync::atomic::{AtomicU32, Ordering};
+use std::time::{SystemTime, UNIX_EPOCH};
+
+const TIMESTAMP_BITS: u32 = 40;
+const TAIL_BITS: u32 = 23;
+const TAIL_MASK: u32 = (1 << TAIL_BITS) - 1;
+
+/// Custom epoch for the timestamp field: 2026-01-01T00:00:00Z in Unix
+/// milliseconds. Ids generated before this instant clamp to 0.
+pub const KUATIA_EPOCH_MS: u64 = 1_767_225_600_000;
+
+/// Snowflake-style ID generator.
+///
+/// Each generator holds an internal counter used when no CRC32 data is
+/// provided. The counter wraps back to zero on overflow.
+pub struct AutoId {
+    counter: AtomicU32,
+}
+
+impl AutoId {
+    /// Create a new generator with counter starting at zero.
+    ///
+    /// `const` so a single generator can back a process-global `static` (see
+    /// `ReservationId::default`), giving ids that are unique across threads
+    /// rather than per-thread.
+    pub const fn new() -> Self {
+        Self {
+            counter: AtomicU32::new(0),
+        }
+    }
+
+    /// Generate an ID using the lower 23 bits of CRC32 of `data`.
+    pub fn next_with_data(&self, data: &[u8]) -> i64 {
+        let ms = Self::now_ms();
+        let crc = crc32(data);
+        Self::pack(ms, crc & TAIL_MASK)
+    }
+
+    /// Generate an ID using the internal auto-incrementing counter.
+    /// The counter wraps to zero on overflow of the 23-bit range.
+    pub fn next(&self) -> i64 {
+        let ms = Self::now_ms();
+        let seq = self.counter.fetch_add(1, Ordering::Relaxed) & TAIL_MASK;
+        Self::pack(ms, seq)
+    }
+
+    /// Extract the millisecond timestamp from an ID.
+    pub fn timestamp(id: i64) -> u64 {
+        ((id >> TAIL_BITS) & ((1i64 << TIMESTAMP_BITS) - 1)) as u64
+    }
+
+    /// Extract the tail (CRC32 or counter) from an ID.
+    pub fn tail(id: i64) -> u32 {
+        (id as u32) & TAIL_MASK
+    }
+
+    fn now_ms() -> u64 {
+        let unix_ms = SystemTime::now()
+            .duration_since(UNIX_EPOCH)
+            .unwrap_or_default()
+            .as_millis() as u64;
+        unix_ms.saturating_sub(KUATIA_EPOCH_MS)
+    }
+
+    fn pack(ms: u64, tail: u32) -> i64 {
+        let ts = (ms & ((1u64 << TIMESTAMP_BITS) - 1)) as i64;
+        (ts << TAIL_BITS) | (tail as i64)
+    }
+}
+
+impl Default for AutoId {
+    fn default() -> Self {
+        Self::new()
+    }
+}
+
+// ---------------------------------------------------------------------------
+// CRC32 (IEEE / ISO 3309)
+// ---------------------------------------------------------------------------
+
+const CRC32_TABLE: [u32; 256] = {
+    let mut table = [0u32; 256];
+    let mut i = 0u32;
+    while i < 256 {
+        let mut crc = i;
+        let mut j = 0;
+        while j < 8 {
+            if crc & 1 != 0 {
+                crc = (crc >> 1) ^ 0xEDB8_8320;
+            } else {
+                crc >>= 1;
+            }
+            j += 1;
+        }
+        table[i as usize] = crc;
+        i += 1;
+    }
+    table
+};
+
+/// Compute CRC32 (IEEE) of `data`.
+pub fn crc32(data: &[u8]) -> u32 {
+    let mut crc = 0xFFFF_FFFFu32;
+    for &byte in data {
+        let idx = ((crc ^ byte as u32) & 0xFF) as usize;
+        crc = (crc >> 8) ^ CRC32_TABLE[idx];
+    }
+    crc ^ 0xFFFF_FFFF
+}
+
+#[cfg(test)]
+mod tests {
+    use super::*;
+
+    #[test]
+    fn id_is_always_positive() {
+        let sf = AutoId::new();
+        for _ in 0..1000 {
+            assert!(sf.next() > 0);
+        }
+    }
+
+    #[test]
+    fn id_with_data_is_positive() {
+        let sf = AutoId::new();
+        assert!(sf.next_with_data(b"hello") > 0);
+        assert!(sf.next_with_data(b"") > 0);
+    }
+
+    #[test]
+    fn timestamp_round_trips() {
+        let sf = AutoId::new();
+        let mask = (1u64 << 40) - 1;
+        let before = AutoId::now_ms() & mask;
+        let id = sf.next();
+        let after = AutoId::now_ms() & mask;
+        let ts = AutoId::timestamp(id);
+        assert!(ts >= before && ts <= after);
+    }
+
+    #[test]
+    fn different_data_different_tails() {
+        let sf = AutoId::new();
+        let a = sf.next_with_data(b"alice");
+        let b = sf.next_with_data(b"bob");
+        assert_ne!(AutoId::tail(a), AutoId::tail(b));
+    }
+
+    #[test]
+    fn counter_increments() {
+        let sf = AutoId::new();
+        let a = sf.next();
+        let b = sf.next();
+        // Tails should differ by 1 (unless ms boundary crossed, but very unlikely)
+        let ta = AutoId::tail(a);
+        let tb = AutoId::tail(b);
+        assert_eq!(tb, ta + 1);
+    }
+
+    #[test]
+    fn counter_wraps() {
+        let sf = AutoId::new();
+        // Set counter just below the mask to test wrap
+        sf.counter.store(TAIL_MASK, Ordering::Relaxed);
+        let id = sf.next();
+        assert_eq!(AutoId::tail(id), TAIL_MASK);
+        let id2 = sf.next();
+        assert_eq!(AutoId::tail(id2), 0);
+    }
+
+    #[test]
+    fn crc32_known_vector() {
+        // CRC32 of "123456789" is 0xCBF43926
+        assert_eq!(crc32(b"123456789"), 0xCBF4_3926);
+    }
+}

+ 967 - 0
crates/kuatia-types/src/lib.rs

@@ -0,0 +1,967 @@
+//! Domain types for the ledger.
+//!
+//! These types model the UTXO-style ledger where value is held as **postings** —
+//! signed amounts owned by exactly one account. An account's balance is simply the
+//! sum of its active postings, which eliminates the need for running balance fields
+//! and makes the system trivially auditable by replaying the transfer log.
+
+pub mod autoid;
+
+use serde::{Deserialize, Serialize};
+use std::collections::BTreeMap;
+use std::fmt;
+
+// ---------------------------------------------------------------------------
+// ToBytes trait
+// ---------------------------------------------------------------------------
+
+/// Deterministic binary serialization. Every domain type can produce its
+/// canonical byte representation.
+pub trait ToBytes {
+    /// Returns the canonical byte representation of this value.
+    fn to_bytes(&self) -> Vec<u8>;
+}
+
+// ---------------------------------------------------------------------------
+// Binary encoding helpers — big-endian, deterministic
+// ---------------------------------------------------------------------------
+
+/// Version byte prepended to canonical serializations for forward compatibility.
+/// Bumped to 2 when `Cent` moved to a fixed 16-byte canonical encoding (ADR-0011).
+pub const CANONICAL_VERSION: u8 = 2;
+
+/// Append a `u16` in big-endian to `buf`.
+pub fn write_u16(buf: &mut Vec<u8>, v: u16) {
+    buf.extend_from_slice(&v.to_be_bytes());
+}
+
+/// Append a `u32` in big-endian to `buf`.
+pub fn write_u32(buf: &mut Vec<u8>, v: u32) {
+    buf.extend_from_slice(&v.to_be_bytes());
+}
+
+/// Append a `u64` in big-endian to `buf`.
+pub fn write_u64(buf: &mut Vec<u8>, v: u64) {
+    buf.extend_from_slice(&v.to_be_bytes());
+}
+
+/// Append an `i64` in big-endian to `buf`.
+pub fn write_i64(buf: &mut Vec<u8>, v: i64) {
+    buf.extend_from_slice(&v.to_be_bytes());
+}
+
+/// Append a `u128` in big-endian to `buf`.
+pub fn write_u128(buf: &mut Vec<u8>, v: u128) {
+    buf.extend_from_slice(&v.to_be_bytes());
+}
+
+// ---------------------------------------------------------------------------
+// Identifiers
+// ---------------------------------------------------------------------------
+
+/// Stable account identity. Used in all public APIs.
+#[derive(Clone, Copy, PartialEq, Eq, Hash, PartialOrd, Ord, Serialize, Deserialize)]
+pub struct AccountId(pub i64);
+
+/// Pairs an [`AccountId`] with a snapshot hash — the double-SHA256 of the
+/// account's state at a point in time. Stored on [`Transfer`] to record which
+/// account versions a transfer was executed against. Internal type — the
+/// public API uses [`AccountId`].
+#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
+pub struct AccountSnapshotId {
+    /// The account this snapshot belongs to.
+    pub account: AccountId,
+    /// Double-SHA256 of the account's state at the time of the snapshot.
+    pub snapshot_id: [u8; 32],
+}
+
+/// Identifies an asset (USD, EUR, BTC, …). Conservation is enforced per asset,
+/// so each asset is an independent conservation boundary.
+#[derive(Clone, Copy, PartialEq, Eq, Hash, PartialOrd, Ord, Serialize, Deserialize)]
+pub struct AssetId(pub u32);
+
+/// Content-addressed transfer identifier — the double-SHA256 of the canonical
+/// serialization. This makes the id both the idempotency key and the
+/// tamper-evidence artifact.
+#[derive(Clone, Copy, PartialEq, Eq, Hash, PartialOrd, Ord, Serialize, Deserialize)]
+pub struct EnvelopeId(pub [u8; 32]);
+
+/// Uniquely identifies a posting within the ledger. The `(transfer, index)` pair
+/// ties every posting back to the transfer that created it, which is the basis
+/// of the provenance graph.
+#[derive(Clone, Copy, PartialEq, Eq, Hash, PartialOrd, Ord, Serialize, Deserialize)]
+pub struct PostingId {
+    /// The transfer that created this posting.
+    pub transfer: EnvelopeId,
+    /// Zero-based position within the transfer's created postings.
+    pub index: u16,
+}
+
+// ---------------------------------------------------------------------------
+// Cent — re-exported from kuatia-money (swappable integer backing)
+// ---------------------------------------------------------------------------
+
+pub use kuatia_money::{Amount, Cent, OverflowError, ParseAmountError};
+
+impl ToBytes for Cent {
+    fn to_bytes(&self) -> Vec<u8> {
+        self.to_canonical_bytes().to_vec()
+    }
+}
+
+// ---------------------------------------------------------------------------
+// Debug / Display impls for identifiers
+// ---------------------------------------------------------------------------
+
+impl fmt::Debug for AccountId {
+    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
+        write!(f, "AccountId({})", self.0)
+    }
+}
+
+impl fmt::Debug for AssetId {
+    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
+        write!(f, "AssetId({:#010x})", self.0)
+    }
+}
+
+impl fmt::Debug for EnvelopeId {
+    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
+        write!(f, "EnvelopeId({})", hex(&self.0))
+    }
+}
+
+impl fmt::Debug for PostingId {
+    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
+        f.debug_struct("PostingId")
+            .field("transfer", &self.transfer)
+            .field("index", &self.index)
+            .finish()
+    }
+}
+
+fn hex(bytes: &[u8]) -> String {
+    bytes.iter().map(|b| format!("{b:02x}")).collect()
+}
+
+// ---------------------------------------------------------------------------
+// Identifier constructors
+// ---------------------------------------------------------------------------
+
+impl Default for AccountId {
+    fn default() -> Self {
+        // Process-global generator: a per-thread one could mint the same id on
+        // two threads within a millisecond, yielding duplicate account ids.
+        static GEN: crate::autoid::AutoId = crate::autoid::AutoId::new();
+        Self(GEN.next())
+    }
+}
+
+impl AccountId {
+    /// Create an `AccountId` from an `i64`.
+    pub const fn new(id: i64) -> Self {
+        Self(id)
+    }
+}
+
+impl From<AccountSnapshotId> for AccountId {
+    fn from(snap: AccountSnapshotId) -> Self {
+        snap.account
+    }
+}
+
+impl AssetId {
+    /// Create an `AssetId` from a `u32`.
+    pub const fn new(id: u32) -> Self {
+        Self(id)
+    }
+}
+
+/// Identifies a book — a named scope for transfers.
+#[derive(Clone, Copy, PartialEq, Eq, Hash, PartialOrd, Ord, Serialize, Deserialize)]
+pub struct BookId(pub i64);
+
+impl fmt::Debug for BookId {
+    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
+        write!(f, "BookId({})", self.0)
+    }
+}
+
+/// The implicit book used when a transfer does not name one. Fixed so that two
+/// otherwise-identical transfers hash to the same [`EnvelopeId`] — a random
+/// default would break content-addressed idempotency.
+pub const DEFAULT_BOOK: BookId = BookId(0);
+
+impl Default for BookId {
+    /// Deterministic: returns [`DEFAULT_BOOK`]. Use [`BookId::generate`] to mint
+    /// a fresh unique id for a real book.
+    fn default() -> Self {
+        DEFAULT_BOOK
+    }
+}
+
+impl BookId {
+    /// Create a `BookId` from an `i64`.
+    pub const fn new(id: i64) -> Self {
+        Self(id)
+    }
+
+    /// Mint a fresh, process-unique book id. Unlike [`Default`], this is not
+    /// stable across calls — use it when creating a new [`Book`], never for the
+    /// implicit book of a transfer.
+    pub fn generate() -> Self {
+        // Process-global so the "process-unique" contract holds across threads;
+        // a per-thread generator can repeat an id on another thread.
+        static GEN: crate::autoid::AutoId = crate::autoid::AutoId::new();
+        Self(GEN.next())
+    }
+}
+
+/// Identifies a reservation — the owner token stamped on a posting while it is
+/// `PendingInactive`, so only the saga that reserved it may finalize or release it.
+#[derive(Clone, Copy, PartialEq, Eq, Hash, PartialOrd, Ord, Serialize, Deserialize)]
+pub struct ReservationId(pub i64);
+
+impl fmt::Debug for ReservationId {
+    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
+        write!(f, "ReservationId({})", self.0)
+    }
+}
+
+impl ReservationId {
+    /// Create a `ReservationId` from an `i64`.
+    pub const fn new(id: i64) -> Self {
+        Self(id)
+    }
+}
+
+impl Default for ReservationId {
+    fn default() -> Self {
+        // One process-global generator, not one per thread: its atomic counter
+        // makes every reservation id unique across threads. A `thread_local`
+        // generator lets two sagas on different threads mint the same id within
+        // a millisecond, which collapses the reservation-ownership check and
+        // allows a double-spend under concurrency.
+        static GEN: crate::autoid::AutoId = crate::autoid::AutoId::new();
+        Self(GEN.next())
+    }
+}
+
+// ---------------------------------------------------------------------------
+// Book
+// ---------------------------------------------------------------------------
+
+/// A Book is a transfer policy scope: it gates which accounts and assets may
+/// participate in a transfer. It is **not** the chronological entry log (the
+/// transfer log plays that role), and it does **not** partition balances —
+/// balances are global; a Book only gates participation.
+#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
+pub struct Book {
+    /// Stable identity for this book.
+    pub id: BookId,
+    /// Human-readable name.
+    pub name: String,
+    /// Participation rules for this book.
+    pub policy: BookPolicy,
+}
+
+/// The participation rules for a [`Book`]. An empty field means "no restriction".
+#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
+pub struct BookPolicy {
+    /// If non-empty, only these assets may appear in movements.
+    pub allowed_assets: Vec<AssetId>,
+    /// If non-empty, accounts with ANY of these flags may participate.
+    pub allowed_flags: AccountFlags,
+    /// If non-empty, these specific accounts may participate (in addition to flag matches).
+    pub allowed_accounts: Vec<AccountId>,
+}
+
+/// Builder for constructing [`Book`] values.
+pub struct BookBuilder {
+    book: Book,
+}
+
+impl BookBuilder {
+    /// Create a new book builder with the given name.
+    pub fn new(name: impl Into<String>) -> Self {
+        Self {
+            book: Book {
+                id: BookId::generate(),
+                name: name.into(),
+                policy: BookPolicy {
+                    allowed_assets: Vec::new(),
+                    allowed_flags: AccountFlags::empty(),
+                    allowed_accounts: Vec::new(),
+                },
+            },
+        }
+    }
+
+    /// Set the book id explicitly.
+    pub fn id(mut self, id: BookId) -> Self {
+        self.book.id = id;
+        self
+    }
+
+    /// Add an allowed asset.
+    pub fn allow_asset(mut self, asset: AssetId) -> Self {
+        self.book.policy.allowed_assets.push(asset);
+        self
+    }
+
+    /// Set allowed account flags — accounts with ANY of these flags may participate.
+    pub fn allow_flags(mut self, flags: AccountFlags) -> Self {
+        self.book.policy.allowed_flags = flags;
+        self
+    }
+
+    /// Add a specific allowed account.
+    pub fn allow_account(mut self, account: AccountId) -> Self {
+        self.book.policy.allowed_accounts.push(account);
+        self
+    }
+
+    /// Consume the builder and return the [`Book`].
+    pub fn build(self) -> Book {
+        self.book
+    }
+}
+
+// ---------------------------------------------------------------------------
+// Posting
+// ---------------------------------------------------------------------------
+
+/// Lifecycle state of a [`Posting`].
+///
+/// ```text
+/// Active ──reserve──▶ PendingInactive ──finalize──▶ Inactive (void)
+///   ▲  ▲                    │
+///   │  └─── release (no-op) ┘
+///   └────── release ────────┘  (compensation)
+/// ```
+///
+/// `reserve_postings` and `release_postings` are batch operations:
+/// - **reserve**: all postings must be Active, otherwise the batch fails.
+/// - **release**: Active is a no-op, PendingInactive reverts to Active,
+///   Inactive (void) fails the batch.
+#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
+pub enum PostingStatus {
+    /// Available for consumption and counted in balance.
+    Active,
+    /// Reserved for a transfer; not available for other consumption.
+    /// Reverts to `Active` on compensation via `release_postings`.
+    PendingInactive,
+    /// Consumed by a committed transfer. Kept for audit trail (void).
+    /// Cannot be released.
+    Inactive,
+}
+
+/// A signed amount of one asset, owned by exactly one account.
+///
+/// A positive posting is value controlled by the account; a negative posting is
+/// an offset position (issuance, external flow, overdraft, or system balancing).
+/// Negative postings are allowed on every policy except `NoOverdraft`.
+#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
+pub struct Posting {
+    /// Unique identifier derived from the creating transfer.
+    pub id: PostingId,
+    /// The account that owns this posting.
+    pub owner: AccountId,
+    /// The asset this posting denominates.
+    pub asset: AssetId,
+    /// Signed: positive = value controlled by the account, negative = offset position.
+    pub value: Cent,
+    /// Lifecycle state — only `Active` postings count toward balance.
+    pub status: PostingStatus,
+    /// Owner token while `PendingInactive`. `Some(rid)` iff reserved by saga
+    /// `rid`; `None` when `Active` or `Inactive`. Only the holder of a matching
+    /// `ReservationId` may finalize or release a reserved posting.
+    pub reservation: Option<ReservationId>,
+}
+
+impl Posting {
+    /// Construct an `Active`, unreserved posting.
+    pub fn new(id: PostingId, owner: AccountId, asset: AssetId, value: Cent) -> Self {
+        Self {
+            id,
+            owner,
+            asset,
+            value,
+            status: PostingStatus::Active,
+            reservation: None,
+        }
+    }
+
+    /// Returns `true` if this posting's status is [`PostingStatus::Active`].
+    pub fn is_active(&self) -> bool {
+        self.status == PostingStatus::Active
+    }
+}
+
+/// A posting to be created — carries no id yet because the [`PostingId`] depends
+/// on the [`EnvelopeId`], which is computed during validation.
+#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
+pub struct NewPosting {
+    /// The account that will own the created posting.
+    pub owner: AccountId,
+    /// The asset this posting denominates.
+    pub asset: AssetId,
+    /// Signed amount: positive = value controlled by the account, negative = offset position.
+    pub value: Cent,
+    /// Informational provenance — who funded this posting.
+    pub payer: Option<AccountId>,
+}
+
+// ---------------------------------------------------------------------------
+// Transfer
+// ---------------------------------------------------------------------------
+
+/// Fixed-width secondary identifiers.
+#[derive(Debug, Clone, PartialEq, Eq, Default, Serialize, Deserialize)]
+pub struct UserData {
+    /// 128-bit user-defined slot (e.g. external UUID).
+    pub d128: u128,
+    /// 64-bit user-defined slot (e.g. correlation id).
+    pub d64: u64,
+    /// 32-bit user-defined slot (e.g. category code).
+    pub d32: u32,
+}
+
+/// Free-form key→value metadata.
+pub type Metadata = BTreeMap<String, Vec<u8>>;
+
+/// The unit of atomicity — all of its consumptions and creations apply together
+/// or not at all. This is the resolved, internal form produced by the saga
+/// pipeline from a [`Transfer`] intent.
+#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
+pub struct Envelope {
+    /// Posting ids consumed (spent) by this envelope.
+    pub consumes: Vec<PostingId>,
+    /// New postings created by this envelope.
+    pub creates: Vec<NewPosting>,
+    /// Account version pins for optimistic concurrency.
+    pub account_snapshots: Vec<AccountSnapshotId>,
+    /// Book this envelope belongs to.
+    pub book: BookId,
+    /// Fixed-width secondary identifiers.
+    pub user_data: UserData,
+    /// Free-form key-value metadata.
+    pub metadata: Metadata,
+}
+
+impl Envelope {
+    /// Posting ids consumed (spent) by this envelope.
+    pub fn consumes(&self) -> &[PostingId] {
+        &self.consumes
+    }
+
+    /// New postings created by this envelope.
+    pub fn creates(&self) -> &[NewPosting] {
+        &self.creates
+    }
+
+    /// Account version pins for optimistic concurrency.
+    pub fn account_snapshots(&self) -> &[AccountSnapshotId] {
+        &self.account_snapshots
+    }
+
+    /// Book this envelope belongs to.
+    pub fn book(&self) -> BookId {
+        self.book
+    }
+
+    /// Fixed-width secondary identifiers.
+    pub fn user_data(&self) -> &UserData {
+        &self.user_data
+    }
+
+    /// Free-form key-value metadata.
+    pub fn metadata(&self) -> &Metadata {
+        &self.metadata
+    }
+
+    /// Deduplicated, sorted list of accounts referenced in the created postings.
+    pub fn referenced_accounts(&self) -> Vec<AccountId> {
+        let mut ids: Vec<AccountId> = self.creates.iter().map(|p| p.owner).collect();
+        ids.sort();
+        ids.dedup();
+        ids
+    }
+
+    /// Set account snapshots.
+    pub fn set_account_snapshots(&mut self, snapshots: Vec<AccountSnapshotId>) {
+        self.account_snapshots = snapshots;
+    }
+}
+
+// ---------------------------------------------------------------------------
+// EnvelopeBuilder
+// ---------------------------------------------------------------------------
+
+/// Builder for constructing [`Envelope`] values.
+#[derive(Default)]
+pub struct EnvelopeBuilder {
+    envelope: Envelope,
+}
+
+impl EnvelopeBuilder {
+    /// Create an empty builder.
+    pub fn new() -> Self {
+        Self::default()
+    }
+
+    /// Set the posting ids to consume.
+    pub fn consumes(mut self, ids: Vec<PostingId>) -> Self {
+        self.envelope.consumes = ids;
+        self
+    }
+
+    /// Set the new postings to create.
+    pub fn creates(mut self, postings: Vec<NewPosting>) -> Self {
+        self.envelope.creates = postings;
+        self
+    }
+
+    /// Set the book.
+    pub fn book(mut self, book: BookId) -> Self {
+        self.envelope.book = book;
+        self
+    }
+
+    /// Set the fixed-width secondary identifiers.
+    pub fn user_data(mut self, user_data: UserData) -> Self {
+        self.envelope.user_data = user_data;
+        self
+    }
+
+    /// Set the account version pins.
+    pub fn account_snapshots(mut self, snapshots: Vec<AccountSnapshotId>) -> Self {
+        self.envelope.account_snapshots = snapshots;
+        self
+    }
+
+    /// Set the free-form metadata.
+    pub fn metadata(mut self, metadata: Metadata) -> Self {
+        self.envelope.metadata = metadata;
+        self
+    }
+
+    /// Consume the builder and return the [`Envelope`].
+    pub fn build(self) -> Envelope {
+        self.envelope
+    }
+}
+
+// ---------------------------------------------------------------------------
+// Account
+// ---------------------------------------------------------------------------
+
+/// Controls how much an account can spend beyond its posting-backed balance.
+#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
+pub enum AccountPolicy {
+    /// Balance must stay >= 0.
+    NoOverdraft,
+    /// Balance must stay >= `floor` (floor < 0).
+    CappedOverdraft {
+        /// Minimum allowed balance (must be negative).
+        floor: Cent,
+    },
+    /// No floor — the account can go arbitrarily negative.
+    UncappedOverdraft,
+    /// Fees, settlement, market-making, minting. No balance constraints.
+    SystemAccount,
+    /// Boundary account representing value entering/leaving the ledger; holds
+    /// the offset (negative) side of deposits.
+    ExternalAccount,
+}
+
+bitflags::bitflags! {
+    /// Lifecycle and user-defined flags for an [`Account`].
+    ///
+    /// Bits 0–7 are reserved for system flags. Bits 8–31 are available for
+    /// user-defined flags, which can be used with [`BookPolicy::allowed_flags`]
+    /// to scope which accounts may participate in a book.
+    #[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
+    pub struct AccountFlags: u32 {
+        /// Account may not be the source or destination of any transfer.
+        const FROZEN = 1 << 0;
+        /// Terminal — no further activity.
+        const CLOSED = 1 << 1;
+        // Bits 2–7: reserved for future system flags.
+        // Bits 8–31: user-defined.
+        /// User-defined flag 0.
+        const USER_0 = 1 << 8;
+        /// User-defined flag 1.
+        const USER_1 = 1 << 9;
+        /// User-defined flag 2.
+        const USER_2 = 1 << 10;
+        /// User-defined flag 3.
+        const USER_3 = 1 << 11;
+        /// User-defined flag 4.
+        const USER_4 = 1 << 12;
+        /// User-defined flag 5.
+        const USER_5 = 1 << 13;
+        /// User-defined flag 6.
+        const USER_6 = 1 << 14;
+        /// User-defined flag 7.
+        const USER_7 = 1 << 15;
+    }
+}
+
+/// A registered entity that must exist before it can transact.
+#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
+pub struct Account {
+    /// Stable identity for this account.
+    pub id: AccountId,
+    /// Monotonically increasing version, starts at 1 on creation.
+    pub version: u64,
+    /// Overdraft / balance policy.
+    pub policy: AccountPolicy,
+    /// Lifecycle flags (frozen, closed).
+    pub flags: AccountFlags,
+    /// Book this entity belongs to.
+    pub book: BookId,
+    /// Fixed-width secondary identifiers.
+    pub user_data: UserData,
+    /// Free-form key-value metadata.
+    pub metadata: Metadata,
+}
+
+impl Account {
+    /// Create a version-1 account with the given policy: no flags, the default
+    /// book, and empty user data / metadata. Convenience for the common case —
+    /// set the other fields explicitly when you need them.
+    pub fn new(id: AccountId, policy: AccountPolicy) -> Self {
+        Self {
+            id,
+            version: 1,
+            policy,
+            flags: AccountFlags::empty(),
+            book: DEFAULT_BOOK,
+            user_data: UserData::default(),
+            metadata: Metadata::new(),
+        }
+    }
+
+    /// Returns `true` if the account has the `FROZEN` flag set.
+    pub fn is_frozen(&self) -> bool {
+        self.flags.contains(AccountFlags::FROZEN)
+    }
+
+    /// Returns `true` if the account has the `CLOSED` flag set.
+    pub fn is_closed(&self) -> bool {
+        self.flags.contains(AccountFlags::CLOSED)
+    }
+}
+
+// ---------------------------------------------------------------------------
+// Receipt
+// ---------------------------------------------------------------------------
+
+/// Confirmation of a committed transfer, carrying its content-addressed id.
+#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
+pub struct Receipt {
+    /// Content-addressed id of the committed transfer.
+    pub transfer_id: EnvelopeId,
+}
+
+// ---------------------------------------------------------------------------
+// Transfer — intent-based API
+// ---------------------------------------------------------------------------
+
+/// A single movement within a transfer: move value from one account to another.
+///
+/// Every operation (pay, deposit, withdraw) is expressed as one or more
+/// movements.  The resolve step aggregates net debits per account and selects
+/// postings only for accounts with a positive net debit.
+#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
+pub struct Movement {
+    /// Account being debited.
+    pub from: AccountId,
+    /// Account being credited.
+    pub to: AccountId,
+    /// Asset to transfer.
+    pub asset: AssetId,
+    /// Amount to transfer (may be negative for offset postings).
+    pub amount: Cent,
+}
+
+/// A transfer intent — one or more movements to execute atomically.
+///
+/// The saga pipeline resolves movements into concrete postings ([`Envelope`])
+/// during execution. Callers express *what* should happen, not *which postings*
+/// to consume.
+#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
+pub struct Transfer {
+    /// Movements to execute atomically.
+    pub movements: Vec<Movement>,
+    /// Book this entity belongs to.
+    pub book: BookId,
+    /// Fixed-width secondary identifiers.
+    pub user_data: UserData,
+    /// Free-form key-value metadata.
+    pub metadata: Metadata,
+}
+
+/// Builder for constructing [`Transfer`] values.
+#[derive(Default)]
+pub struct TransferBuilder {
+    transfer: Transfer,
+}
+
+impl TransferBuilder {
+    /// Create an empty builder.
+    pub fn new() -> Self {
+        Self::default()
+    }
+
+    /// Add a raw movement.
+    pub fn movement(
+        mut self,
+        from: AccountId,
+        to: AccountId,
+        asset: AssetId,
+        amount: Cent,
+    ) -> Self {
+        self.transfer.movements.push(Movement {
+            from,
+            to,
+            asset,
+            amount,
+        });
+        self
+    }
+
+    /// Add a pay movement: transfer value between two accounts.
+    pub fn pay(self, from: AccountId, to: AccountId, asset: AssetId, amount: Cent) -> Self {
+        self.movement(from, to, asset, amount)
+    }
+
+    /// Add a deposit: creates an offset posting on the external account and
+    /// credits the target account.  Pushes two movements whose net debit on the
+    /// external account is zero.
+    pub fn deposit(
+        self,
+        to: AccountId,
+        asset: AssetId,
+        amount: Cent,
+        external: AccountId,
+    ) -> Result<Self, OverflowError> {
+        let neg = amount.checked_neg()?;
+        Ok(self
+            .movement(external, external, asset, neg)
+            .movement(external, to, asset, amount))
+    }
+
+    /// Add a withdrawal: move value from an account to an external destination.
+    pub fn withdraw(
+        self,
+        from: AccountId,
+        asset: AssetId,
+        amount: Cent,
+        external: AccountId,
+    ) -> Self {
+        self.movement(from, external, asset, amount)
+    }
+
+    /// Set the book.
+    pub fn book(mut self, book: BookId) -> Self {
+        self.transfer.book = book;
+        self
+    }
+
+    /// Set the fixed-width secondary identifiers.
+    pub fn user_data(mut self, user_data: UserData) -> Self {
+        self.transfer.user_data = user_data;
+        self
+    }
+
+    /// Set the free-form metadata.
+    pub fn metadata(mut self, metadata: Metadata) -> Self {
+        self.transfer.metadata = metadata;
+        self
+    }
+
+    /// Consume the builder and return the [`Transfer`].
+    pub fn build(self) -> Transfer {
+        self.transfer
+    }
+}
+
+// ---------------------------------------------------------------------------
+// ToBytes implementations
+// ---------------------------------------------------------------------------
+
+impl ToBytes for AccountId {
+    fn to_bytes(&self) -> Vec<u8> {
+        self.0.to_be_bytes().to_vec()
+    }
+}
+
+impl ToBytes for AccountSnapshotId {
+    fn to_bytes(&self) -> Vec<u8> {
+        let mut buf = Vec::with_capacity(40);
+        buf.extend_from_slice(&self.account.0.to_be_bytes());
+        buf.extend_from_slice(&self.snapshot_id);
+        buf
+    }
+}
+
+impl ToBytes for AssetId {
+    fn to_bytes(&self) -> Vec<u8> {
+        self.0.to_be_bytes().to_vec()
+    }
+}
+
+impl ToBytes for EnvelopeId {
+    fn to_bytes(&self) -> Vec<u8> {
+        self.0.to_vec()
+    }
+}
+
+impl ToBytes for PostingId {
+    fn to_bytes(&self) -> Vec<u8> {
+        let mut buf = Vec::with_capacity(34);
+        buf.extend_from_slice(&self.transfer.0);
+        write_u16(&mut buf, self.index);
+        buf
+    }
+}
+
+impl ToBytes for UserData {
+    fn to_bytes(&self) -> Vec<u8> {
+        let mut buf = Vec::with_capacity(28);
+        write_u128(&mut buf, self.d128);
+        write_u64(&mut buf, self.d64);
+        write_u32(&mut buf, self.d32);
+        buf
+    }
+}
+
+impl ToBytes for AccountPolicy {
+    fn to_bytes(&self) -> Vec<u8> {
+        let mut buf = Vec::with_capacity(9);
+        match self {
+            Self::NoOverdraft => buf.push(0),
+            Self::CappedOverdraft { floor } => {
+                buf.push(1);
+                buf.extend(floor.to_bytes());
+            }
+            Self::UncappedOverdraft => buf.push(2),
+            Self::SystemAccount => buf.push(3),
+            Self::ExternalAccount => buf.push(4),
+        }
+        buf
+    }
+}
+
+impl ToBytes for AccountFlags {
+    fn to_bytes(&self) -> Vec<u8> {
+        self.bits().to_be_bytes().to_vec()
+    }
+}
+
+impl ToBytes for BookId {
+    fn to_bytes(&self) -> Vec<u8> {
+        self.0.to_be_bytes().to_vec()
+    }
+}
+
+impl ToBytes for NewPosting {
+    fn to_bytes(&self) -> Vec<u8> {
+        let mut buf = Vec::new();
+        buf.extend(self.owner.to_bytes());
+        buf.extend_from_slice(&self.asset.0.to_be_bytes());
+        buf.extend(self.value.to_bytes());
+        match &self.payer {
+            Some(p) => {
+                buf.push(1);
+                buf.extend(p.to_bytes());
+            }
+            None => buf.push(0),
+        }
+        buf
+    }
+}
+
+impl ToBytes for Posting {
+    fn to_bytes(&self) -> Vec<u8> {
+        let mut buf = Vec::new();
+        buf.extend(self.id.to_bytes());
+        buf.extend(self.owner.to_bytes());
+        buf.extend_from_slice(&self.asset.0.to_be_bytes());
+        buf.extend(self.value.to_bytes());
+        buf.push(match self.status {
+            PostingStatus::Active => 0,
+            PostingStatus::PendingInactive => 1,
+            PostingStatus::Inactive => 2,
+        });
+        buf
+    }
+}
+
+impl ToBytes for Envelope {
+    fn to_bytes(&self) -> Vec<u8> {
+        let mut buf = Vec::new();
+        buf.push(CANONICAL_VERSION);
+
+        write_u32(&mut buf, self.consumes.len() as u32);
+        for pid in &self.consumes {
+            buf.extend(pid.to_bytes());
+        }
+
+        write_u32(&mut buf, self.creates.len() as u32);
+        for np in &self.creates {
+            buf.extend(np.to_bytes());
+        }
+
+        write_u32(&mut buf, self.account_snapshots.len() as u32);
+        for snap in &self.account_snapshots {
+            buf.extend(snap.to_bytes());
+        }
+
+        buf.extend(self.book.to_bytes());
+        buf.extend(self.user_data.to_bytes());
+
+        write_u32(&mut buf, self.metadata.len() as u32);
+        for (key, value) in &self.metadata {
+            let key_bytes = key.as_bytes();
+            write_u32(&mut buf, key_bytes.len() as u32);
+            buf.extend_from_slice(key_bytes);
+            write_u32(&mut buf, value.len() as u32);
+            buf.extend_from_slice(value);
+        }
+
+        buf
+    }
+}
+
+impl ToBytes for Account {
+    fn to_bytes(&self) -> Vec<u8> {
+        let mut buf = Vec::new();
+        buf.push(CANONICAL_VERSION);
+        buf.extend(self.id.to_bytes());
+        write_u64(&mut buf, self.version);
+        buf.extend(self.policy.to_bytes());
+        buf.extend(self.flags.to_bytes());
+        buf.extend(self.book.to_bytes());
+        buf.extend(self.user_data.to_bytes());
+
+        write_u32(&mut buf, self.metadata.len() as u32);
+        for (key, value) in &self.metadata {
+            let key_bytes = key.as_bytes();
+            write_u32(&mut buf, key_bytes.len() as u32);
+            buf.extend_from_slice(key_bytes);
+            write_u32(&mut buf, value.len() as u32);
+            buf.extend_from_slice(value);
+        }
+
+        buf
+    }
+}
+
+impl ToBytes for Receipt {
+    fn to_bytes(&self) -> Vec<u8> {
+        self.transfer_id.0.to_vec()
+    }
+}

+ 37 - 0
crates/kuatia/Cargo.toml

@@ -0,0 +1,37 @@
+[package]
+name = "kuatia"
+description = "Append-only, auditable, multi-asset UTXO-style ledger: async resource and saga commit pipeline."
+version.workspace = true
+edition.workspace = true
+rust-version.workspace = true
+license.workspace = true
+repository.workspace = true
+authors.workspace = true
+keywords.workspace = true
+categories.workspace = true
+
+[lints]
+workspace = true
+
+[features]
+default = []
+# Bubble the i128 Cent backing up to the outermost crate. Enabling
+# `kuatia/i128` swaps the integer width across the whole dependency chain.
+i128 = ["kuatia-types/i128", "kuatia-core/i128", "kuatia-storage/i128"]
+
+[dependencies]
+kuatia-types.workspace = true
+kuatia-core.workspace = true
+kuatia-storage.workspace = true
+legend.workspace = true
+tokio = { workspace = true, features = ["sync", "rt", "macros"] }
+serde.workspace = true
+serde_json.workspace = true
+async-trait.workspace = true
+tracing.workspace = true
+
+[dev-dependencies]
+tokio = { workspace = true, features = ["full"] }
+# For the runnable examples — connect to a real SQLite-backed ledger.
+kuatia-storage-sql.workspace = true
+sqlx = { workspace = true, features = ["sqlite"] }

+ 104 - 0
crates/kuatia/README.md

@@ -0,0 +1,104 @@
+# kuatia
+
+Async ledger resource — the main entry point for callers.
+
+Composes `kuatia-core` (validation) and `kuatia-storage` (persistence) into
+a saga-driven commit pipeline with automatic retry and compensation.
+
+## API layers
+
+### Intent layer (highest level)
+
+Build transfers with `TransferBuilder`, then commit them:
+
+```rust
+let transfer = TransferBuilder::new()
+    .deposit(alice, usd, Cent::from(100), bank)
+    .build();
+let receipt = ledger.commit(transfer).await?;
+```
+
+| Builder method | Description |
+|---------------|-------------|
+| `.pay(from, to, asset, amount)` | Transfer with automatic posting selection and change |
+| `.deposit(to, asset, amount, external)` | Fund an account from an external source |
+| `.withdraw(from, asset, amount, external)` | Send value to an external destination |
+| `.movement(from, to, asset, amount)` | Raw movement for custom operations |
+
+### Commit
+
+Every commit is the **envelope saga** — two steps driven by `legend` with
+automatic retry and LIFO compensation:
+
+- `commit(transfer)` — resolves the intent into a concrete envelope (read-only),
+  then runs `commit_envelope`.
+- `commit_envelope(envelope)` — the one commit path. Persists a write-ahead
+  `PendingSaga` record (phase `Reserving`), then:
+  1. **Reserve** — `reserve_postings`: Active → PendingInactive, stamped with this saga's `ReservationId`
+  2. **Finalize** — re-validates against current state (the last-step floor / freeze-close guard), marks the saga `Finalizing`, then runs the dumb primitives `deactivate_postings` → `insert_postings` → `store_transfer` → `append_event`, verifying every end-state
+- `reverse(id)` — builds a reversal envelope and runs the same path.
+
+The store reports an **affected-row count** for each primitive; the saga
+interprets it (full = continue, partial = error → compensate, zero = read state
+and continue only if this same envelope already applied it). There is no
+monolithic `commit_transfer` and no separate "atomic" path.
+
+### Crash recovery
+
+`recover()` — call on startup. It completes any `PendingSaga` left by a crash,
+branching on the persisted phase: a `Reserving` saga is re-run (re-validating,
+aborting cleanly if a posting was taken or an account frozen); a `Finalizing`
+saga is rolled forward through the verified `finalize_envelope`. Roll-forward,
+not rollback.
+
+### Account lifecycle
+
+| Method | Description |
+|--------|-------------|
+| `create_account(account)` | Create account and emit AccountCreated event |
+| `freeze(id)` | Set FROZEN flag |
+| `unfreeze(id)` | Clear FROZEN flag |
+| `close(id)` | Set CLOSED flag (requires zero active postings) |
+
+### Queries
+
+| Method | Description |
+|--------|-------------|
+| `balance(account, asset)` | Current balance (sum of non-Inactive postings) |
+| `query_transfers(query)` | Paginated, filtered transfer history |
+| `history(account)` | All transfers for an account |
+| `postings(account)` | All postings (any status) |
+| `get_events_since(seq, limit)` | Query ledger event log |
+
+### Saga composition
+
+Combine steps into multi-transfer workflows using the `legend!` macro:
+
+```rust
+legend! {
+    FundAndPay<LedgerCtx, SagaError> {
+        deposit: DepositMovementStep,
+        pay: PayMovementStep,
+    }
+}
+```
+
+## Examples
+
+Runnable programs in [`examples/`](examples/) connect to a real SQLite-backed
+ledger (via `sqlx`) and walk through the core operations:
+
+```sh
+cargo run -p kuatia --example create_accounts   # create user/system/external accounts
+cargo run -p kuatia --example fund_and_trade     # fund two accounts in different assets, then swap
+cargo run -p kuatia --example withdraw           # fund an account, then withdraw out of the ledger
+```
+
+Each opens an in-memory SQLite database (`sqlite::memory:`); point the
+connection string at a file or a Postgres URL for a persistent ledger.
+
+## See also
+
+- [doc/accounting-mapping.md](../../doc/accounting-mapping.md) — how classical
+  double-entry concepts (journal, journal entry, ledger) map onto kuatia's
+  transfer log, transfers, and postings.

+ 74 - 0
crates/kuatia/examples/create_accounts.rs

@@ -0,0 +1,74 @@
+//! Connect to a SQLite-backed ledger and create accounts.
+//!
+//! Run with:
+//! ```sh
+//! cargo run -p kuatia --example create_accounts
+//! ```
+
+use std::collections::BTreeMap;
+use std::sync::Arc;
+
+use kuatia::ledger::Ledger;
+use kuatia_core::*;
+use kuatia_storage_sql::SqlStore;
+
+#[tokio::main]
+async fn main() -> Result<(), Box<dyn std::error::Error>> {
+    let ledger = connect().await?;
+
+    // The common case is one line: a version-1 account with the given policy.
+    ledger
+        .create_account(Account::new(AccountId::new(1), AccountPolicy::NoOverdraft))
+        .await?;
+    ledger
+        .create_account(Account::new(AccountId::new(2), AccountPolicy::NoOverdraft))
+        .await?;
+    // A system account (fees, settlement, market-making) — no balance floor.
+    ledger
+        .create_account(Account::new(
+            AccountId::new(50),
+            AccountPolicy::SystemAccount,
+        ))
+        .await?;
+
+    // The same thing spelled out, so you can see every field of an `Account`.
+    // This boundary account is where value enters/leaves the ledger.
+    let external = Account {
+        id: AccountId::new(99),
+        version: 1,                             // accounts always start at version 1
+        policy: AccountPolicy::ExternalAccount, // boundary for deposits/withdrawals
+        flags: AccountFlags::empty(),           // not frozen, not closed
+        book: DEFAULT_BOOK,                     // the implicit default book
+        user_data: UserData::default(),         // fixed-width correlation slots
+        metadata: BTreeMap::new(),              // free-form key/value metadata
+    };
+    ledger.create_account(external).await?;
+
+    // Read them back (latest version of each).
+    println!("accounts:");
+    let mut accounts = ledger.list_accounts().await?;
+    accounts.sort_by_key(|a| a.id.0);
+    for a in &accounts {
+        println!("  {:?}  policy={:?}  v{}", a.id, a.policy, a.version);
+    }
+
+    Ok(())
+}
+
+/// Open a fresh in-memory SQLite database, run migrations, and wrap it in a
+/// `Ledger`. Point the connection string at a file (e.g.
+/// `"sqlite://ledger.db?mode=rwc"`) or a Postgres URL for a persistent ledger.
+async fn connect() -> Result<Arc<Ledger>, Box<dyn std::error::Error>> {
+    sqlx::any::install_default_drivers();
+    let pool = sqlx::any::AnyPoolOptions::new()
+        .max_connections(1)
+        .connect("sqlite::memory:")
+        .await?;
+    let store = SqlStore::new(pool);
+    store.migrate().await?;
+    let ledger = Arc::new(Ledger::new(store));
+    // On startup, finish any commit a crash interrupted (idempotent roll-forward).
+    // A clean store has nothing pending, so this returns 0.
+    ledger.recover().await?;
+    Ok(ledger)
+}

+ 103 - 0
crates/kuatia/examples/fund_and_trade.rs

@@ -0,0 +1,103 @@
+//! Fund two accounts with different assets, then trade between them atomically.
+//!
+//! Run with:
+//! ```sh
+//! cargo run -p kuatia --example fund_and_trade
+//! ```
+
+use std::sync::Arc;
+
+use kuatia::ledger::Ledger;
+use kuatia_core::*;
+use kuatia_storage_sql::SqlStore;
+
+#[tokio::main]
+async fn main() -> Result<(), Box<dyn std::error::Error>> {
+    let ledger = connect().await?;
+
+    let alice = AccountId::new(1);
+    let bob = AccountId::new(2);
+    let external = AccountId::new(99);
+    let usd = AssetId::new(1);
+    let eur = AssetId::new(2);
+
+    // Two-decimal money: `money.parse("100.00")` -> Cent in minor units.
+    let money = Amount::new(2);
+
+    ledger
+        .create_account(Account::new(alice, AccountPolicy::NoOverdraft))
+        .await?;
+    ledger
+        .create_account(Account::new(bob, AccountPolicy::NoOverdraft))
+        .await?;
+    ledger
+        .create_account(Account::new(external, AccountPolicy::ExternalAccount))
+        .await?;
+
+    // Fund: $100.00 to Alice, €90.00 to Bob.
+    ledger
+        .commit(
+            TransferBuilder::new()
+                .deposit(alice, usd, money.parse("100.00")?, external)?
+                .build(),
+        )
+        .await?;
+    ledger
+        .commit(
+            TransferBuilder::new()
+                .deposit(bob, eur, money.parse("90.00")?, external)?
+                .build(),
+        )
+        .await?;
+
+    println!("after funding:");
+    print_balances(&ledger, alice, bob, usd, eur).await?;
+
+    // Trade: Alice gives 100 USD to Bob; Bob gives 90 EUR to Alice. Both legs
+    // settle in one atomic transfer — each asset is conserved independently.
+    let trade = TransferBuilder::new()
+        .movement(alice, bob, usd, money.parse("100.00")?)
+        .movement(bob, alice, eur, money.parse("90.00")?)
+        .build();
+    ledger.commit(trade).await?;
+
+    println!("after trade:");
+    print_balances(&ledger, alice, bob, usd, eur).await?;
+
+    Ok(())
+}
+
+async fn print_balances(
+    ledger: &Arc<Ledger>,
+    alice: AccountId,
+    bob: AccountId,
+    usd: AssetId,
+    eur: AssetId,
+) -> Result<(), Box<dyn std::error::Error>> {
+    let money = Amount::new(2);
+    println!(
+        "  alice: {} USD, {} EUR",
+        money.format(ledger.balance(&alice, &usd).await?),
+        money.format(ledger.balance(&alice, &eur).await?),
+    );
+    println!(
+        "  bob:   {} USD, {} EUR",
+        money.format(ledger.balance(&bob, &usd).await?),
+        money.format(ledger.balance(&bob, &eur).await?),
+    );
+    Ok(())
+}
+
+async fn connect() -> Result<Arc<Ledger>, Box<dyn std::error::Error>> {
+    sqlx::any::install_default_drivers();
+    let pool = sqlx::any::AnyPoolOptions::new()
+        .max_connections(1)
+        .connect("sqlite::memory:")
+        .await?;
+    let store = SqlStore::new(pool);
+    store.migrate().await?;
+    let ledger = Arc::new(Ledger::new(store));
+    // On startup, finish any commit a crash interrupted (idempotent roll-forward).
+    ledger.recover().await?;
+    Ok(ledger)
+}

+ 78 - 0
crates/kuatia/examples/withdraw.rs

@@ -0,0 +1,78 @@
+//! Fund an account, then withdraw value out of the ledger.
+//!
+//! Run with:
+//! ```sh
+//! cargo run -p kuatia --example withdraw
+//! ```
+
+use std::sync::Arc;
+
+use kuatia::ledger::Ledger;
+use kuatia_core::*;
+use kuatia_storage_sql::SqlStore;
+
+#[tokio::main]
+async fn main() -> Result<(), Box<dyn std::error::Error>> {
+    let ledger = connect().await?;
+
+    let alice = AccountId::new(1);
+    let external = AccountId::new(99);
+    let usd = AssetId::new(1);
+    let money = Amount::new(2);
+
+    ledger
+        .create_account(Account::new(alice, AccountPolicy::NoOverdraft))
+        .await?;
+    ledger
+        .create_account(Account::new(external, AccountPolicy::ExternalAccount))
+        .await?;
+
+    // Fund Alice with $100.00.
+    ledger
+        .commit(
+            TransferBuilder::new()
+                .deposit(alice, usd, money.parse("100.00")?, external)?
+                .build(),
+        )
+        .await?;
+    println!(
+        "after deposit:  alice = {} USD",
+        money.format(ledger.balance(&alice, &usd).await?)
+    );
+
+    // Withdraw $30.00 from Alice out to the external boundary account.
+    ledger
+        .commit(
+            TransferBuilder::new()
+                .withdraw(alice, usd, money.parse("30.00")?, external)
+                .build(),
+        )
+        .await?;
+    println!(
+        "after withdraw: alice = {} USD",
+        money.format(ledger.balance(&alice, &usd).await?)
+    );
+
+    // The external account carries the offset (negative) side: the mirror of the
+    // value that currently sits inside the ledger.
+    println!(
+        "external boundary: {} USD",
+        money.format(ledger.balance(&external, &usd).await?)
+    );
+
+    Ok(())
+}
+
+async fn connect() -> Result<Arc<Ledger>, Box<dyn std::error::Error>> {
+    sqlx::any::install_default_drivers();
+    let pool = sqlx::any::AnyPoolOptions::new()
+        .max_connections(1)
+        .connect("sqlite::memory:")
+        .await?;
+    let store = SqlStore::new(pool);
+    store.migrate().await?;
+    let ledger = Arc::new(Ledger::new(store));
+    // On startup, finish any commit a crash interrupted (idempotent roll-forward).
+    ledger.recover().await?;
+    Ok(ledger)
+}

+ 102 - 0
crates/kuatia/src/error.rs

@@ -0,0 +1,102 @@
+//! Error types for the async ledger layer.
+//!
+//! [`LedgerError`] unifies errors from the pure core (validation, selection)
+//! and from storage, so callers get a single error type from every API.
+
+use kuatia_core::{
+    AccountId, BookId, EnvelopeId, OverflowError, PostingId, SelectionError, ValidationError,
+};
+use kuatia_storage::error::StoreError;
+
+/// Unified error type for the async ledger API.
+#[derive(Debug)]
+pub enum LedgerError {
+    /// A transfer invariant was violated.
+    Validation(ValidationError),
+    /// Storage operation failed.
+    Store(StoreError),
+    /// Posting selection failed (e.g. insufficient funds).
+    Selection(SelectionError),
+    /// The referenced transfer does not exist.
+    TransferNotFound(EnvelopeId),
+    /// The posting cannot be reversed (e.g. already consumed).
+    PostingNotReversible(PostingId),
+    /// The referenced account does not exist.
+    AccountNotFound(AccountId),
+    /// Cannot close an account that still has active postings.
+    AccountNotEmpty(AccountId),
+    /// The account is already closed.
+    AccountAlreadyClosed(AccountId),
+    /// A transfer named a book that does not exist.
+    BookNotFound(BookId),
+    /// Monetary arithmetic overflow.
+    Overflow,
+    /// A saga step failed and its compensation also failed.
+    CompensationFailed {
+        /// The error that triggered compensation.
+        original: Box<LedgerError>,
+        /// The error that occurred during compensation.
+        compensation: Box<LedgerError>,
+    },
+}
+
+impl std::fmt::Display for LedgerError {
+    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
+        match self {
+            Self::Validation(e) => write!(f, "validation: {e}"),
+            Self::Store(e) => write!(f, "store: {e}"),
+            Self::Selection(e) => write!(f, "selection: {e}"),
+            Self::TransferNotFound(id) => write!(f, "transfer not found: {id:?}"),
+            Self::PostingNotReversible(id) => write!(f, "posting not reversible: {id:?}"),
+            Self::AccountNotFound(id) => write!(f, "account not found: {id:?}"),
+            Self::AccountNotEmpty(id) => write!(f, "account not empty: {id:?}"),
+            Self::AccountAlreadyClosed(id) => write!(f, "account already closed: {id:?}"),
+            Self::BookNotFound(id) => write!(f, "book not found: {id:?}"),
+            Self::Overflow => write!(f, "monetary amount overflow"),
+            Self::CompensationFailed {
+                original,
+                compensation,
+            } => write!(
+                f,
+                "compensation failed: original={original}, compensation={compensation}"
+            ),
+        }
+    }
+}
+
+impl std::error::Error for LedgerError {
+    fn source(&self) -> Option<&(dyn std::error::Error + 'static)> {
+        match self {
+            Self::Validation(e) => Some(e),
+            Self::Store(e) => Some(e),
+            Self::Selection(e) => Some(e),
+            Self::Overflow => Some(&OverflowError),
+            Self::CompensationFailed { original, .. } => Some(original.as_ref()),
+            _ => None,
+        }
+    }
+}
+
+impl From<ValidationError> for LedgerError {
+    fn from(e: ValidationError) -> Self {
+        LedgerError::Validation(e)
+    }
+}
+
+impl From<StoreError> for LedgerError {
+    fn from(e: StoreError) -> Self {
+        LedgerError::Store(e)
+    }
+}
+
+impl From<SelectionError> for LedgerError {
+    fn from(e: SelectionError) -> Self {
+        LedgerError::Selection(e)
+    }
+}
+
+impl From<OverflowError> for LedgerError {
+    fn from(_: OverflowError) -> Self {
+        LedgerError::Overflow
+    }
+}

+ 1230 - 0
crates/kuatia/src/ledger.rs

@@ -0,0 +1,1230 @@
+//! The async ledger resource -- the primary entry point for callers.
+
+use std::collections::HashMap;
+use std::sync::Arc;
+
+use legend::{ExecutionResult, legend};
+use tracing::instrument;
+
+use kuatia_core::{
+    AccountId, AccountPolicy, AccountSnapshotId, AssetId, Book, Cent, DEFAULT_BOOK, Envelope,
+    EnvelopeBuilder, EnvelopeId, NewPosting, PlanInput, Posting, PostingId, PostingStatus, Receipt,
+    SelectionError, Transfer, account_snapshot_id, envelope_id, select_postings, validate_and_plan,
+};
+
+use crate::error::LedgerError;
+
+/// Return the current time as Unix milliseconds.
+pub(crate) fn now_millis() -> Result<i64, LedgerError> {
+    Ok(std::time::SystemTime::now()
+        .duration_since(std::time::UNIX_EPOCH)
+        .map_err(|_| LedgerError::Overflow)?
+        .as_millis() as i64)
+}
+use crate::saga::{
+    FinalizeInput, FinalizeTransferStep, LedgerCtx, ReserveInput, ReservePostingsStep, SagaError,
+};
+use kuatia_storage::error::StoreError;
+use kuatia_storage::events::{LedgerEvent, LedgerEventKind};
+use kuatia_storage::store::{EnvelopeRecord, Store};
+
+#[allow(missing_docs)]
+mod envelope_saga {
+    use super::*;
+    legend! {
+        EnvelopeSaga<LedgerCtx, SagaError> {
+            reserve: ReservePostingsStep,
+            finalize: FinalizeTransferStep,
+        }
+    }
+}
+use envelope_saga::*;
+
+/// Phase of an in-flight commit, persisted with the write-ahead record so
+/// recovery knows whether validation has completed.
+#[derive(Clone, Copy, PartialEq, Eq, Debug, serde::Serialize, serde::Deserialize)]
+enum SagaPhase {
+    /// Saved before reserve. Validation has not necessarily run, so recovery must
+    /// re-reserve and re-validate before it can commit.
+    Reserving,
+    /// Saved at the start of finalize — after validation passed and just before
+    /// the consumed postings begin turning `Inactive` (the point of no return).
+    /// Recovery rolls forward without re-validating.
+    Finalizing,
+}
+
+/// Write-ahead record for an in-flight commit, persisted via `SagaStore` before
+/// the saga mutates anything and removed once it reaches a terminal state. On
+/// startup [`Ledger::recover`] completes any that survive a crash.
+#[derive(serde::Serialize, serde::Deserialize)]
+struct PendingSaga {
+    envelope: Envelope,
+    reservation: kuatia_core::ReservationId,
+    phase: SagaPhase,
+}
+
+/// Async ledger resource composing the commit pipeline.
+pub struct Ledger {
+    store: Arc<dyn Store>,
+}
+
+impl Ledger {
+    /// Create a new ledger backed by the given store.
+    pub fn new(store: impl Store + 'static) -> Self {
+        Self {
+            store: Arc::new(store),
+        }
+    }
+
+    /// Returns a reference to the underlying store.
+    pub fn store(&self) -> &dyn Store {
+        self.store.as_ref()
+    }
+
+    // -----------------------------------------------------------------------
+    // Three-piece API: load -> plan -> apply
+    // -----------------------------------------------------------------------
+
+    /// Phase 1: load all state needed for validation.
+    #[instrument(skip(self, envelope), name = "ledger.load")]
+    pub async fn load(&self, envelope: &Envelope) -> Result<LoadedState, LedgerError> {
+        let consumed_postings = if envelope.consumes().is_empty() {
+            vec![]
+        } else {
+            self.store.get_postings(envelope.consumes()).await?
+        };
+
+        let mut account_ids: Vec<AccountId> = envelope.creates().iter().map(|p| p.owner).collect();
+        for p in &consumed_postings {
+            account_ids.push(p.owner);
+        }
+        account_ids.sort();
+        account_ids.dedup();
+
+        let account_list = self.store.get_accounts(&account_ids).await?;
+        let accounts: HashMap<AccountId, _> = account_list.into_iter().map(|a| (a.id, a)).collect();
+
+        let mut balance_keys: Vec<(AccountId, AssetId)> = Vec::new();
+        for p in &consumed_postings {
+            balance_keys.push((p.owner, p.asset));
+        }
+        for np in envelope.creates() {
+            balance_keys.push((np.owner, np.asset));
+        }
+        balance_keys.sort();
+        balance_keys.dedup();
+
+        let mut balances = HashMap::new();
+        for (account_id, asset_id) in &balance_keys {
+            let bal = self.compute_balance(account_id, asset_id).await?;
+            balances.insert((*account_id, *asset_id), bal);
+        }
+
+        // Load the gating book. A missing named (non-default) book is an error;
+        // a missing default book means "unrestricted" (no policy to enforce).
+        let book_id = envelope.book();
+        let book = match self.store.get_book(&book_id).await {
+            Ok(b) => Some(b),
+            Err(StoreError::NotFound(_)) if book_id == DEFAULT_BOOK => None,
+            Err(StoreError::NotFound(_)) => return Err(LedgerError::BookNotFound(book_id)),
+            Err(e) => return Err(e.into()),
+        };
+
+        Ok(LoadedState {
+            consumed_postings,
+            accounts,
+            balances,
+            book,
+        })
+    }
+
+    /// Phase 2: run pure validation and produce a plan.
+    pub fn plan(
+        &self,
+        envelope: &Envelope,
+        loaded: &LoadedState,
+    ) -> Result<kuatia_core::Plan, LedgerError> {
+        let input = PlanInput {
+            envelope,
+            consumed_postings: &loaded.consumed_postings,
+            accounts: &loaded.accounts,
+            balances: &loaded.balances,
+            book: loaded.book.as_ref(),
+        };
+        Ok(validate_and_plan(input)?)
+    }
+
+    // -----------------------------------------------------------------------
+    // Resolve: Transfer (intent) -> Envelope (concrete postings)
+    // -----------------------------------------------------------------------
+
+    /// Convert a [`Transfer`] intent into a concrete [`Envelope`] by selecting
+    /// postings for each movement and computing change.
+    ///
+    /// Pass 1: create output postings and aggregate net debits per (account, asset).
+    /// Pass 2: for each pair with a positive net debit, select postings and compute change.
+    #[instrument(skip(self, transfer), name = "ledger.resolve")]
+    pub async fn resolve(&self, transfer: &Transfer) -> Result<Envelope, LedgerError> {
+        let mut consumes: Vec<PostingId> = Vec::new();
+        let mut creates: Vec<NewPosting> = Vec::new();
+        let mut net_debits: HashMap<(AccountId, AssetId), Cent> = HashMap::new();
+
+        // Pass 1: output postings + debit aggregation
+        for m in &transfer.movements {
+            let payer = if m.from != m.to { Some(m.from) } else { None };
+            creates.push(NewPosting {
+                owner: m.to,
+                asset: m.asset,
+                value: m.amount,
+                payer,
+            });
+            let entry = net_debits.entry((m.from, m.asset)).or_insert(Cent::ZERO);
+            *entry = entry.checked_add(m.amount)?;
+        }
+
+        // Pass 2: posting selection for accounts with positive net debit
+        for ((account, asset), net_debit) in &net_debits {
+            if !net_debit.is_positive() {
+                continue;
+            }
+            let available = self
+                .store
+                .get_postings_by_account(account, Some(asset), Some(PostingStatus::Active))
+                .await?;
+            let total_positive = Cent::checked_sum(
+                available
+                    .iter()
+                    .filter(|p| p.value.is_positive())
+                    .map(|p| p.value),
+            )?;
+
+            if total_positive >= *net_debit {
+                // Enough positive postings: select a subset and compute change.
+                let selected = select_postings(&available, *asset, *net_debit)?;
+                let consumed_sum = Cent::checked_sum(
+                    available
+                        .iter()
+                        .filter(|p| selected.contains(&p.id))
+                        .map(|p| p.value),
+                )?;
+                let change = consumed_sum.checked_sub(*net_debit)?;
+
+                consumes.extend_from_slice(&selected);
+                if change.is_positive() {
+                    creates.push(NewPosting {
+                        owner: *account,
+                        asset: *asset,
+                        value: change,
+                        payer: None,
+                    });
+                }
+            } else {
+                // Not enough positive postings. Overdraft accounts cover the
+                // shortfall with a negative posting (an offset position); the
+                // floor is enforced later in validation. Any other policy fails.
+                let policy = self.store.get_account(account).await?.policy;
+                match policy {
+                    AccountPolicy::CappedOverdraft { .. } | AccountPolicy::UncappedOverdraft => {
+                        let positives: Vec<PostingId> = available
+                            .iter()
+                            .filter(|p| p.value.is_positive())
+                            .map(|p| p.id)
+                            .collect();
+                        consumes.extend_from_slice(&positives);
+                        let shortfall = net_debit.checked_sub(total_positive)?;
+                        creates.push(NewPosting {
+                            owner: *account,
+                            asset: *asset,
+                            value: shortfall.checked_neg()?,
+                            payer: None,
+                        });
+                    }
+                    _ => {
+                        return Err(LedgerError::Selection(SelectionError::InsufficientFunds {
+                            available: total_positive,
+                            requested: *net_debit,
+                        }));
+                    }
+                }
+            }
+        }
+
+        let mut envelope = EnvelopeBuilder::new()
+            .consumes(consumes)
+            .creates(creates)
+            .book(transfer.book)
+            .user_data(transfer.user_data.clone())
+            .metadata(transfer.metadata.clone())
+            .build();
+
+        // Resolve account snapshots for optimistic concurrency
+        let ids = envelope.referenced_accounts();
+        envelope.set_account_snapshots(self.resolve_snapshots(&ids).await?);
+
+        Ok(envelope)
+    }
+
+    // -----------------------------------------------------------------------
+    // Commit: every commit is the envelope saga (reserve -> finalize; finalize re-validates)
+    // -----------------------------------------------------------------------
+
+    /// Commit a [`Transfer`] intent. Resolves it into a concrete envelope, then
+    /// drives the envelope saga. Resolution is read-only, so a crash before the
+    /// saga's write-ahead record leaves no partial state.
+    #[instrument(skip(self, transfer), fields(book = transfer.book.0), name = "ledger.commit")]
+    pub async fn commit(self: &Arc<Self>, transfer: Transfer) -> Result<Receipt, LedgerError> {
+        let envelope = self.resolve(&transfer).await?;
+        self.commit_envelope(envelope).await
+    }
+
+    /// Commit a pre-resolved [`Envelope`] through the saga pipeline (reserve ->
+    /// validate -> finalize). This is the single commit path; `commit()` and
+    /// `reverse()` both funnel through it.
+    ///
+    /// Before running, the saga (envelope + reservation) is persisted as a
+    /// pending record so a crash mid-commit is completed by [`recover`](Self::recover). The
+    /// record is deleted once the saga reaches a terminal state. The commit is
+    /// idempotent on the content-addressed transfer id.
+    #[instrument(skip(self, envelope), name = "ledger.commit_envelope")]
+    pub async fn commit_envelope(
+        self: &Arc<Self>,
+        mut envelope: Envelope,
+    ) -> Result<Receipt, LedgerError> {
+        if envelope.account_snapshots().is_empty() {
+            let mut ids: Vec<AccountId> = envelope.creates().iter().map(|p| p.owner).collect();
+            ids.sort();
+            ids.dedup();
+            envelope.set_account_snapshots(self.resolve_snapshots(&ids).await?);
+        }
+
+        // Idempotency: an already-committed transfer returns its receipt.
+        let tid = envelope_id(&envelope);
+        if let Some(record) = self.store.get_transfer(&tid).await? {
+            return Ok(record.receipt);
+        }
+
+        // Write-ahead: persist {envelope, reservation, phase=Reserving} before any
+        // mutation. The finalize step bumps the phase to Finalizing.
+        let reservation = kuatia_core::ReservationId::default();
+        let saga_id = reservation.0;
+        self.save_pending(&envelope, reservation, SagaPhase::Reserving)
+            .await?;
+
+        let result = self.drive_envelope_saga(envelope, reservation).await;
+
+        // Delete the pending record only when it is safe: on success, or on a
+        // failure that never reached finalize (phase still Reserving → the saga's
+        // compensation released our reservation, nothing of ours was applied). If
+        // finalize started (Finalizing) and failed, keep it so `recover()` rolls
+        // the half-applied commit forward.
+        let safe_to_delete = match &result {
+            Ok(_) => true,
+            Err(_) => self.read_pending_phase(saga_id).await? != Some(SagaPhase::Finalizing),
+        };
+        if safe_to_delete {
+            self.store.delete_saga(&saga_id).await?;
+        }
+        result
+    }
+
+    /// Build and run the envelope saga (reserve → finalize) to a terminal
+    /// outcome, returning the resulting receipt.
+    async fn drive_envelope_saga(
+        self: &Arc<Self>,
+        envelope: Envelope,
+        reservation: kuatia_core::ReservationId,
+    ) -> Result<Receipt, LedgerError> {
+        let saga = EnvelopeSaga::new(EnvelopeSagaInputs {
+            reserve: ReserveInput,
+            finalize: FinalizeInput,
+        });
+        let ctx = LedgerCtx::for_envelope(Arc::clone(self), envelope, reservation);
+        let execution = saga.build(ctx);
+
+        match execution.start().await {
+            ExecutionResult::Completed(e) => {
+                let ctx = e.into_context();
+                ctx.receipts.last().cloned().ok_or_else(|| {
+                    LedgerError::Store(StoreError::Internal("saga completed but no receipt".into()))
+                })
+            }
+            ExecutionResult::Failed(_, err) => {
+                Err(LedgerError::Store(StoreError::Internal(err.message)))
+            }
+            ExecutionResult::CompensationFailed {
+                original_error,
+                compensation_error,
+                ..
+            } => Err(LedgerError::CompensationFailed {
+                original: Box::new(LedgerError::Store(StoreError::Internal(
+                    original_error.message,
+                ))),
+                compensation: Box::new(LedgerError::Store(StoreError::Internal(
+                    compensation_error.message,
+                ))),
+            }),
+            ExecutionResult::Paused(_) => Err(LedgerError::Store(StoreError::Internal(
+                "saga paused unexpectedly".into(),
+            ))),
+        }
+    }
+
+    /// Complete every pending saga left by a crash. Call on startup; returns how
+    /// many were processed.
+    ///
+    /// Recovery branches on the persisted phase. A `Reserving` saga had not
+    /// necessarily validated, so it is re-run through the real saga (which
+    /// re-reserves and **re-validates** — aborting cleanly if the postings were
+    /// taken or an account was frozen meanwhile). A `Finalizing` saga had already
+    /// validated and owns its postings, so it is rolled forward through the
+    /// verified `finalize_envelope`. Either way the record is removed only once
+    /// the work is committed or safely abandoned.
+    #[instrument(skip(self), name = "ledger.recover")]
+    pub async fn recover(self: &Arc<Self>) -> Result<usize, LedgerError> {
+        let pending = self.store.list_pending_sagas().await?;
+        let count = pending.len();
+        for (saga_id, blob) in pending {
+            let PendingSaga {
+                envelope,
+                reservation,
+                phase,
+            } = serde_json::from_slice(&blob)
+                .map_err(|e| LedgerError::Store(StoreError::Internal(e.to_string())))?;
+
+            // The transfer record is durable, but a full commit is more than the
+            // transfer row: it also includes the committed event, appended *after*
+            // store_transfer. A crash in that window leaves the record present yet
+            // the event missing, so repair the whole end-state (idempotent) before
+            // clearing the pending record.
+            let tid = envelope_id(&envelope);
+            if self.store.get_transfer(&tid).await?.is_some() {
+                self.append_committed_event(tid).await?;
+                self.store.delete_saga(&saga_id).await?;
+                continue;
+            }
+
+            match phase {
+                SagaPhase::Finalizing => {
+                    // Validation passed and the postings are ours; roll forward.
+                    // Keep the record if completion fails so a later run retries.
+                    if self.finalize_envelope(&envelope, reservation).await.is_ok() {
+                        self.store.delete_saga(&saga_id).await?;
+                    }
+                }
+                SagaPhase::Reserving => {
+                    // Re-run the validating saga. On failure, delete only if it did
+                    // not reach finalize (clean abort); otherwise keep for next run.
+                    let result = self.drive_envelope_saga(envelope, reservation).await;
+                    let safe_to_delete = result.is_ok()
+                        || self.read_pending_phase(saga_id).await? != Some(SagaPhase::Finalizing);
+                    if safe_to_delete {
+                        self.store.delete_saga(&saga_id).await?;
+                    }
+                }
+            }
+        }
+        Ok(count)
+    }
+
+    /// Idempotently finalize `envelope` to its committed state, **verifying every
+    /// step's end-state**. Used by the saga's finalize step and by recovery.
+    ///
+    /// When the consumed postings are still pre-deactivation it re-validates
+    /// against current state (the last-step floor / freeze-close guard) and then
+    /// marks the saga `Finalizing` (the point of no return). Once any consumed
+    /// posting is already `Inactive` — a prior attempt or recovery passed that
+    /// point — it rolls forward without re-validating (validation rejects
+    /// `Inactive`). It never creates or stores anything unless **all** consumed
+    /// postings are confirmed `Inactive`, which is the double-spend guard.
+    pub(crate) async fn finalize_envelope(
+        &self,
+        envelope: &Envelope,
+        reservation: kuatia_core::ReservationId,
+    ) -> Result<Receipt, LedgerError> {
+        let tid = envelope_id(envelope);
+        if let Some(record) = self.store.get_transfer(&tid).await? {
+            // The transfer record is durable, but a crash (or a retried finalize)
+            // can land between store_transfer and the event append below. The
+            // committed end-state includes the event, so ensure it before
+            // returning — `append_committed_event` is idempotent.
+            self.append_committed_event(tid).await?;
+            return Ok(record.receipt); // already committed
+        }
+        let consumes = envelope.consumes();
+
+        // Read consumed postings (also captures their owners for indexing).
+        let consumed = if consumes.is_empty() {
+            Vec::new()
+        } else {
+            self.store.get_postings(consumes).await?
+        };
+        let past_no_return = consumed.iter().any(|p| p.status == PostingStatus::Inactive);
+
+        // Last-step boundary re-check: re-validate floor + freeze/close + snapshots
+        // against current state, but only while it is still safe (validation
+        // rejects already-`Inactive` consumed postings).
+        if !past_no_return {
+            let loaded = self.load(envelope).await?;
+            self.plan(envelope, &loaded)?;
+        }
+
+        // Point of no return: record Finalizing before any posting turns Inactive.
+        self.save_pending(envelope, reservation, SagaPhase::Finalizing)
+            .await?;
+
+        // Deactivate consumed postings (PendingInactive owned by us → Inactive),
+        // then assert ALL consumed postings are Inactive. This is the double-spend
+        // guard: do not create/store unless the inputs were really consumed by us.
+        self.store
+            .deactivate_postings(consumes, Some(reservation))
+            .await?;
+        if !consumes.is_empty() {
+            let after = self.store.get_postings(consumes).await?;
+            if after.len() != consumes.len()
+                || after.iter().any(|p| p.status != PostingStatus::Inactive)
+            {
+                return Err(LedgerError::Store(StoreError::Internal(
+                    "finalize: consumed postings not all inactive (contended or not reserved by this saga)".into(),
+                )));
+            }
+        }
+
+        // Created postings, derived deterministically from the envelope.
+        let created: Vec<Posting> = envelope
+            .creates()
+            .iter()
+            .enumerate()
+            .map(|(i, np)| {
+                Posting::new(
+                    PostingId {
+                        transfer: tid,
+                        index: i as u16,
+                    },
+                    np.owner,
+                    np.asset,
+                    np.value,
+                )
+            })
+            .collect();
+        self.store.insert_postings(&created).await?;
+        if !created.is_empty() {
+            let ids: Vec<PostingId> = created.iter().map(|p| p.id).collect();
+            if self.store.get_postings(&ids).await?.len() != created.len() {
+                return Err(LedgerError::Store(StoreError::Internal(
+                    "finalize: created postings missing after insert".into(),
+                )));
+            }
+        }
+
+        // Index both created and consumed owners.
+        let mut involved: Vec<AccountId> = created.iter().map(|p| p.owner).collect();
+        involved.extend(consumed.iter().map(|p| p.owner));
+        involved.sort();
+        involved.dedup();
+
+        let receipt = Receipt { transfer_id: tid };
+        self.store
+            .store_transfer(
+                EnvelopeRecord {
+                    envelope: envelope.clone(),
+                    receipt: receipt.clone(),
+                    created_at: now_millis()?,
+                },
+                &involved,
+            )
+            .await?;
+        if self.store.get_transfer(&tid).await?.is_none() {
+            return Err(LedgerError::Store(StoreError::Internal(
+                "finalize: transfer record missing after store".into(),
+            )));
+        }
+
+        self.append_committed_event(tid).await?;
+        Ok(receipt)
+    }
+
+    /// Idempotently append the `TransferCommitted` event for `tid`.
+    ///
+    /// The event append is the final finalize step, *after* `store_transfer`, so a
+    /// crash in that window leaves a stored transfer with no event. Recovery and a
+    /// retried finalize both call this to repair the committed end-state.
+    /// `append_event` dedups on the transfer id, so calling it more than once for
+    /// the same transfer is a no-op.
+    async fn append_committed_event(&self, tid: EnvelopeId) -> Result<(), LedgerError> {
+        self.store
+            .append_event(&LedgerEvent {
+                seq: 0,
+                timestamp: now_millis()?,
+                kind: LedgerEventKind::TransferCommitted { transfer_id: tid },
+            })
+            .await?;
+        Ok(())
+    }
+
+    /// Persist the write-ahead pending-saga record (upsert on the reservation id).
+    async fn save_pending(
+        &self,
+        envelope: &Envelope,
+        reservation: kuatia_core::ReservationId,
+        phase: SagaPhase,
+    ) -> Result<(), LedgerError> {
+        let blob = serde_json::to_vec(&PendingSaga {
+            envelope: envelope.clone(),
+            reservation,
+            phase,
+        })
+        .map_err(|e| LedgerError::Store(StoreError::Internal(e.to_string())))?;
+        self.store.save_saga(&reservation.0, blob).await?;
+        Ok(())
+    }
+
+    /// Read the persisted phase of a pending saga, if it still exists.
+    async fn read_pending_phase(&self, saga_id: i64) -> Result<Option<SagaPhase>, LedgerError> {
+        for (id, blob) in self.store.list_pending_sagas().await? {
+            if id == saga_id {
+                let pending: PendingSaga = serde_json::from_slice(&blob)
+                    .map_err(|e| LedgerError::Store(StoreError::Internal(e.to_string())))?;
+                return Ok(Some(pending.phase));
+            }
+        }
+        Ok(None)
+    }
+
+    // -----------------------------------------------------------------------
+    // Reverse
+    // -----------------------------------------------------------------------
+
+    /// Create and commit a reversal envelope for the given envelope id.
+    #[instrument(skip(self), name = "ledger.reverse")]
+    pub async fn reverse(self: &Arc<Self>, id: &EnvelopeId) -> Result<Receipt, LedgerError> {
+        let record = self
+            .store
+            .get_transfer(id)
+            .await?
+            .ok_or(LedgerError::TransferNotFound(*id))?;
+
+        let original = &record.envelope;
+
+        let created_posting_ids: Vec<PostingId> = original
+            .creates()
+            .iter()
+            .enumerate()
+            .map(|(i, _)| PostingId {
+                transfer: record.receipt.transfer_id,
+                index: i as u16,
+            })
+            .collect();
+
+        let original_consumed = if original.consumes().is_empty() {
+            vec![]
+        } else {
+            self.store.get_postings(original.consumes()).await?
+        };
+
+        let new_postings: Vec<NewPosting> = original_consumed
+            .iter()
+            .map(|p| NewPosting {
+                owner: p.owner,
+                asset: p.asset,
+                value: p.value,
+                payer: None,
+            })
+            .collect();
+
+        let reverse_envelope = EnvelopeBuilder::new()
+            .consumes(created_posting_ids)
+            .creates(new_postings)
+            .book(original.book())
+            .metadata(original.metadata().clone())
+            .build();
+
+        self.commit_envelope(reverse_envelope).await
+    }
+
+    // -----------------------------------------------------------------------
+    // Internal: resolve account snapshots
+    // -----------------------------------------------------------------------
+
+    /// Compute balance from non-Inactive postings for an account/asset pair.
+    async fn compute_balance(
+        &self,
+        account: &AccountId,
+        asset: &AssetId,
+    ) -> Result<Cent, LedgerError> {
+        let postings = self
+            .store
+            .get_postings_by_account(account, Some(asset), None)
+            .await?;
+        Ok(Cent::checked_sum(
+            postings
+                .iter()
+                .filter(|p| p.status != PostingStatus::Inactive)
+                .map(|p| p.value),
+        )?)
+    }
+
+    async fn resolve_snapshots(
+        &self,
+        ids: &[AccountId],
+    ) -> Result<Vec<AccountSnapshotId>, LedgerError> {
+        let accounts = self.store.get_accounts(ids).await?;
+        Ok(accounts.iter().map(account_snapshot_id).collect())
+    }
+
+    // -----------------------------------------------------------------------
+    // Account lifecycle
+    // -----------------------------------------------------------------------
+
+    /// Freeze an account, preventing all transfers.
+    #[instrument(skip(self), name = "ledger.freeze")]
+    pub async fn freeze(&self, id: &AccountId) -> Result<(), LedgerError> {
+        let current = self
+            .store
+            .get_account(id)
+            .await
+            .map_err(|_| LedgerError::AccountNotFound(*id))?;
+        if current.is_closed() {
+            return Err(LedgerError::AccountAlreadyClosed(*id));
+        }
+        let mut next = current.clone();
+        next.version = next.version.checked_add(1).ok_or(LedgerError::Overflow)?;
+        next.flags |= kuatia_core::AccountFlags::FROZEN;
+        self.store.append_account_version(next).await?;
+        self.store
+            .append_event(&LedgerEvent {
+                seq: 0,
+                timestamp: now_millis()?,
+                kind: LedgerEventKind::AccountFrozen { account_id: *id },
+            })
+            .await?;
+        Ok(())
+    }
+
+    /// Unfreeze a previously frozen account.
+    #[instrument(skip(self), name = "ledger.unfreeze")]
+    pub async fn unfreeze(&self, id: &AccountId) -> Result<(), LedgerError> {
+        let current = self
+            .store
+            .get_account(id)
+            .await
+            .map_err(|_| LedgerError::AccountNotFound(*id))?;
+        if current.is_closed() {
+            return Err(LedgerError::AccountAlreadyClosed(*id));
+        }
+        let mut next = current.clone();
+        next.version = next.version.checked_add(1).ok_or(LedgerError::Overflow)?;
+        next.flags.remove(kuatia_core::AccountFlags::FROZEN);
+        self.store.append_account_version(next).await?;
+        self.store
+            .append_event(&LedgerEvent {
+                seq: 0,
+                timestamp: now_millis()?,
+                kind: LedgerEventKind::AccountUnfrozen { account_id: *id },
+            })
+            .await?;
+        Ok(())
+    }
+
+    /// Close an account. Must have no active postings.
+    #[instrument(skip(self), name = "ledger.close")]
+    pub async fn close(&self, id: &AccountId) -> Result<(), LedgerError> {
+        let current = self
+            .store
+            .get_account(id)
+            .await
+            .map_err(|_| LedgerError::AccountNotFound(*id))?;
+        if current.is_closed() {
+            return Err(LedgerError::AccountAlreadyClosed(*id));
+        }
+        // Reject if any posting is still live — Active or PendingInactive
+        // (reserved, i.e. a transfer in flight). Only fully Inactive postings
+        // (or none) permit a close.
+        let blocking = self
+            .store
+            .get_postings_by_account(id, None, None)
+            .await?
+            .into_iter()
+            .any(|p| p.status != PostingStatus::Inactive);
+        if blocking {
+            return Err(LedgerError::AccountNotEmpty(*id));
+        }
+        let mut next = current.clone();
+        next.version = next.version.checked_add(1).ok_or(LedgerError::Overflow)?;
+        next.flags |= kuatia_core::AccountFlags::CLOSED;
+        next.flags.remove(kuatia_core::AccountFlags::FROZEN);
+        self.store.append_account_version(next).await?;
+        self.store
+            .append_event(&LedgerEvent {
+                seq: 0,
+                timestamp: now_millis()?,
+                kind: LedgerEventKind::AccountClosed { account_id: *id },
+            })
+            .await?;
+        Ok(())
+    }
+
+    /// Query the current balance of an account for a given asset.
+    #[instrument(skip(self), name = "ledger.balance")]
+    pub async fn balance(&self, account: &AccountId, asset: &AssetId) -> Result<Cent, LedgerError> {
+        self.compute_balance(account, asset).await
+    }
+
+    // -----------------------------------------------------------------------
+    // Query layer
+    // -----------------------------------------------------------------------
+
+    /// List all accounts (latest version of each).
+    pub async fn list_accounts(&self) -> Result<Vec<kuatia_core::Account>, LedgerError> {
+        Ok(self.store.list_accounts().await?)
+    }
+
+    /// Fetch a single account by id.
+    pub async fn get_account(&self, id: &AccountId) -> Result<kuatia_core::Account, LedgerError> {
+        self.store
+            .get_account(id)
+            .await
+            .map_err(|_| LedgerError::AccountNotFound(*id))
+    }
+
+    /// Return all transfers involving the given account.
+    pub async fn history(
+        &self,
+        account: &AccountId,
+    ) -> Result<Vec<crate::store::EnvelopeRecord>, LedgerError> {
+        Ok(self.store.get_transfers_for_account(account).await?)
+    }
+
+    /// Query transfers with filtering and pagination.
+    pub async fn query_transfers(
+        &self,
+        query: &crate::store::TransferQuery,
+    ) -> Result<crate::store::Page<crate::store::EnvelopeRecord>, LedgerError> {
+        Ok(self.store.query_transfers(query).await?)
+    }
+
+    /// Return all postings (any status) for the given account.
+    pub async fn postings(
+        &self,
+        account: &AccountId,
+    ) -> Result<Vec<kuatia_core::Posting>, LedgerError> {
+        Ok(self
+            .store
+            .get_postings_by_account(account, None, None)
+            .await?)
+    }
+
+    /// Query postings with filtering and pagination.
+    pub async fn query_postings(
+        &self,
+        query: &crate::store::PostingQuery,
+    ) -> Result<crate::store::Page<kuatia_core::Posting>, LedgerError> {
+        Ok(self.store.query_postings(query).await?)
+    }
+
+    /// Return the full version history for an account.
+    pub async fn account_history(
+        &self,
+        id: &AccountId,
+    ) -> Result<Vec<kuatia_core::Account>, LedgerError> {
+        Ok(self.store.get_account_history(id).await?)
+    }
+
+    /// Create a new account and emit an AccountCreated event.
+    pub async fn create_account(&self, account: kuatia_core::Account) -> Result<(), LedgerError> {
+        let id = account.id;
+        self.store.create_account(account).await?;
+        self.store
+            .append_event(&LedgerEvent {
+                seq: 0,
+                timestamp: now_millis()?,
+                kind: LedgerEventKind::AccountCreated { account_id: id },
+            })
+            .await?;
+        Ok(())
+    }
+
+    /// Create a new book.
+    pub async fn create_book(&self, book: kuatia_core::Book) -> Result<(), LedgerError> {
+        Ok(self.store.create_book(book).await?)
+    }
+
+    /// Fetch a book by id.
+    pub async fn get_book(
+        &self,
+        id: &kuatia_core::BookId,
+    ) -> Result<kuatia_core::Book, LedgerError> {
+        Ok(self.store.get_book(id).await?)
+    }
+
+    /// List all books.
+    pub async fn list_books(&self) -> Result<Vec<kuatia_core::Book>, LedgerError> {
+        Ok(self.store.list_books().await?)
+    }
+
+    /// Query ledger events after a given sequence number.
+    pub async fn get_events_since(
+        &self,
+        after_seq: u64,
+        limit: u32,
+    ) -> Result<Vec<LedgerEvent>, LedgerError> {
+        Ok(self.store.get_events_since(after_seq, limit).await?)
+    }
+}
+
+/// State loaded in phase 1, passed to the pure validation in phase 2.
+pub struct LoadedState {
+    /// Postings being consumed by the envelope.
+    pub consumed_postings: Vec<Posting>,
+    /// Accounts referenced by the envelope.
+    pub accounts: HashMap<AccountId, kuatia_core::Account>,
+    /// Current balances for all referenced (account, asset) pairs.
+    pub balances: HashMap<(AccountId, AssetId), Cent>,
+    /// The book gating this transfer, if one is loaded (`None` = unrestricted default).
+    pub book: Option<Book>,
+}
+
+#[cfg(test)]
+mod recovery_tests {
+    use super::*;
+    use kuatia_core::{Account, AccountFlags, ReservationId, TransferBuilder, UserData};
+    use kuatia_storage::mem_store::InMemoryStore;
+    use std::collections::BTreeMap;
+
+    fn acct(id: i64, policy: AccountPolicy) -> Account {
+        Account {
+            id: AccountId::new(id),
+            version: 1,
+            policy,
+            flags: AccountFlags::empty(),
+            book: kuatia_core::BookId(0),
+            user_data: UserData::default(),
+            metadata: BTreeMap::new(),
+        }
+    }
+
+    async fn funded_ledger() -> Arc<Ledger> {
+        let ledger = Arc::new(Ledger::new(InMemoryStore::new()));
+        for (id, p) in [
+            (1, AccountPolicy::NoOverdraft),
+            (2, AccountPolicy::NoOverdraft),
+            (3, AccountPolicy::NoOverdraft),
+            (99, AccountPolicy::ExternalAccount),
+        ] {
+            ledger.store().create_account(acct(id, p)).await.unwrap();
+        }
+        let deposit = TransferBuilder::new()
+            .deposit(
+                AccountId::new(1),
+                AssetId::new(1),
+                Cent::from(100),
+                AccountId::new(99),
+            )
+            .unwrap()
+            .build();
+        ledger.commit(deposit).await.unwrap();
+        ledger
+    }
+
+    fn pay_transfer() -> Transfer {
+        TransferBuilder::new()
+            .pay(
+                AccountId::new(1),
+                AccountId::new(2),
+                AssetId::new(1),
+                Cent::from(40),
+            )
+            .build()
+    }
+
+    async fn save_pending(
+        ledger: &Arc<Ledger>,
+        envelope: &Envelope,
+        rid: ReservationId,
+        phase: SagaPhase,
+    ) {
+        let blob = serde_json::to_vec(&PendingSaga {
+            envelope: envelope.clone(),
+            reservation: rid,
+            phase,
+        })
+        .unwrap();
+        ledger.store().save_saga(&rid.0, blob).await.unwrap();
+    }
+
+    /// A commit interrupted right after its write-ahead record (phase Reserving,
+    /// before any step) is re-run and completed by `recover()`.
+    #[tokio::test]
+    async fn recover_redrives_reserving_saga() {
+        let ledger = funded_ledger().await;
+        let envelope = ledger.resolve(&pay_transfer()).await.unwrap();
+        let rid = ReservationId::default();
+        save_pending(&ledger, &envelope, rid, SagaPhase::Reserving).await;
+
+        assert_eq!(ledger.recover().await.unwrap(), 1);
+        assert_eq!(
+            ledger
+                .balance(&AccountId::new(2), &AssetId::new(1))
+                .await
+                .unwrap(),
+            Cent::from(40)
+        );
+        assert_eq!(
+            ledger
+                .balance(&AccountId::new(1), &AssetId::new(1))
+                .await
+                .unwrap(),
+            Cent::from(60)
+        );
+        assert!(
+            ledger
+                .store()
+                .list_pending_sagas()
+                .await
+                .unwrap()
+                .is_empty()
+        );
+    }
+
+    /// A commit that crashed mid-finalize (phase Finalizing; the consumed posting
+    /// is already Inactive) is rolled forward by `recover()`.
+    #[tokio::test]
+    async fn recover_completes_partial_finalize() {
+        let ledger = funded_ledger().await;
+        let envelope = ledger.resolve(&pay_transfer()).await.unwrap();
+        let rid = ReservationId::default();
+        // Run the commit halfway: reserve + deactivate the consumed posting.
+        let consumes = envelope.consumes().to_vec();
+        ledger
+            .store()
+            .reserve_postings(&consumes, rid)
+            .await
+            .unwrap();
+        assert_eq!(
+            ledger
+                .store()
+                .deactivate_postings(&consumes, Some(rid))
+                .await
+                .unwrap(),
+            1
+        );
+        save_pending(&ledger, &envelope, rid, SagaPhase::Finalizing).await;
+
+        assert_eq!(ledger.recover().await.unwrap(), 1);
+        assert_eq!(
+            ledger
+                .balance(&AccountId::new(2), &AssetId::new(1))
+                .await
+                .unwrap(),
+            Cent::from(40)
+        );
+        assert_eq!(
+            ledger
+                .balance(&AccountId::new(1), &AssetId::new(1))
+                .await
+                .unwrap(),
+            Cent::from(60)
+        );
+        assert!(
+            ledger
+                .store()
+                .list_pending_sagas()
+                .await
+                .unwrap()
+                .is_empty()
+        );
+    }
+
+    /// A commit that crashed *after* `store_transfer` but *before* the committed
+    /// event was appended (phase Finalizing, transfer row present, event missing)
+    /// is repaired by `recover()`: the full end-state includes the event, so
+    /// recovery appends it (idempotently) instead of treating the transfer row as
+    /// proof of a complete commit.
+    #[tokio::test]
+    async fn recover_appends_missing_committed_event() {
+        let ledger = funded_ledger().await;
+        let envelope = ledger.resolve(&pay_transfer()).await.unwrap();
+        let tid = envelope_id(&envelope);
+        let rid = ReservationId::default();
+
+        // Replay finalize by hand up to and including store_transfer, stopping
+        // short of the event append — exactly the crash window.
+        let consumes = envelope.consumes().to_vec();
+        ledger
+            .store()
+            .reserve_postings(&consumes, rid)
+            .await
+            .unwrap();
+        ledger
+            .store()
+            .deactivate_postings(&consumes, Some(rid))
+            .await
+            .unwrap();
+        let created: Vec<Posting> = envelope
+            .creates()
+            .iter()
+            .enumerate()
+            .map(|(i, np)| {
+                Posting::new(
+                    PostingId {
+                        transfer: tid,
+                        index: i as u16,
+                    },
+                    np.owner,
+                    np.asset,
+                    np.value,
+                )
+            })
+            .collect();
+        ledger.store().insert_postings(&created).await.unwrap();
+        let consumed = ledger.store().get_postings(&consumes).await.unwrap();
+        let mut involved: Vec<AccountId> = created.iter().map(|p| p.owner).collect();
+        involved.extend(consumed.iter().map(|p| p.owner));
+        involved.sort();
+        involved.dedup();
+        ledger
+            .store()
+            .store_transfer(
+                EnvelopeRecord {
+                    envelope: envelope.clone(),
+                    receipt: Receipt { transfer_id: tid },
+                    created_at: 0,
+                },
+                &involved,
+            )
+            .await
+            .unwrap();
+        save_pending(&ledger, &envelope, rid, SagaPhase::Finalizing).await;
+
+        // Precondition: the transfer is stored, but no committed event exists yet.
+        let committed = |evs: &[LedgerEvent]| {
+            evs.iter().any(|e| {
+                matches!(
+                    e.kind,
+                    LedgerEventKind::TransferCommitted { transfer_id } if transfer_id == tid
+                )
+            })
+        };
+        assert!(ledger.store().get_transfer(&tid).await.unwrap().is_some());
+        assert!(!committed(&ledger.get_events_since(0, 1000).await.unwrap()));
+
+        assert_eq!(ledger.recover().await.unwrap(), 1);
+
+        // The missing event is repaired and the pending record cleared.
+        assert!(committed(&ledger.get_events_since(0, 1000).await.unwrap()));
+        assert!(
+            ledger
+                .store()
+                .list_pending_sagas()
+                .await
+                .unwrap()
+                .is_empty()
+        );
+    }
+
+    /// Recovery of a `Reserving` saga re-validates against current state: if an
+    /// account was frozen after the write-ahead record, the commit is abandoned —
+    /// no postings move, the reservation is released, and the record is cleared.
+    #[tokio::test]
+    async fn recover_revalidates_and_aborts_when_account_frozen() {
+        let ledger = funded_ledger().await;
+        let envelope = ledger.resolve(&pay_transfer()).await.unwrap();
+        let tid = envelope_id(&envelope);
+        let rid = ReservationId::default();
+        save_pending(&ledger, &envelope, rid, SagaPhase::Reserving).await;
+
+        // A freeze lands before recovery runs.
+        ledger.freeze(&AccountId::new(1)).await.unwrap();
+
+        assert_eq!(ledger.recover().await.unwrap(), 1);
+        // Nothing committed; balances unchanged; reservation released.
+        assert!(ledger.store().get_transfer(&tid).await.unwrap().is_none());
+        assert_eq!(
+            ledger
+                .balance(&AccountId::new(1), &AssetId::new(1))
+                .await
+                .unwrap(),
+            Cent::from(100)
+        );
+        assert_eq!(
+            ledger
+                .balance(&AccountId::new(2), &AssetId::new(1))
+                .await
+                .unwrap(),
+            Cent::ZERO
+        );
+        let active = ledger
+            .store()
+            .get_postings_by_account(
+                &AccountId::new(1),
+                Some(&AssetId::new(1)),
+                Some(PostingStatus::Active),
+            )
+            .await
+            .unwrap();
+        assert_eq!(active.len(), 1); // back to Active
+        assert!(
+            ledger
+                .store()
+                .list_pending_sagas()
+                .await
+                .unwrap()
+                .is_empty()
+        );
+    }
+
+    /// Recovery cannot double-spend: if the consumed posting was taken by another
+    /// transfer while the saga was pending, recovery aborts without creating or
+    /// storing anything.
+    #[tokio::test]
+    async fn recover_does_not_double_spend_a_taken_posting() {
+        let ledger = funded_ledger().await;
+        let envelope = ledger.resolve(&pay_transfer()).await.unwrap();
+        let tid = envelope_id(&envelope);
+        let rid = ReservationId::default();
+        save_pending(&ledger, &envelope, rid, SagaPhase::Reserving).await;
+
+        // Another transfer consumes account 1's posting and commits.
+        let steal = TransferBuilder::new()
+            .pay(
+                AccountId::new(1),
+                AccountId::new(3),
+                AssetId::new(1),
+                Cent::from(50),
+            )
+            .build();
+        ledger.commit(steal).await.unwrap();
+
+        assert_eq!(ledger.recover().await.unwrap(), 1);
+        // Our envelope never committed; only the stealing transfer applied.
+        assert!(ledger.store().get_transfer(&tid).await.unwrap().is_none());
+        assert_eq!(
+            ledger
+                .balance(&AccountId::new(1), &AssetId::new(1))
+                .await
+                .unwrap(),
+            Cent::from(50)
+        );
+        assert_eq!(
+            ledger
+                .balance(&AccountId::new(3), &AssetId::new(1))
+                .await
+                .unwrap(),
+            Cent::from(50)
+        );
+        assert_eq!(
+            ledger
+                .balance(&AccountId::new(2), &AssetId::new(1))
+                .await
+                .unwrap(),
+            Cent::ZERO
+        );
+        assert!(
+            ledger
+                .store()
+                .list_pending_sagas()
+                .await
+                .unwrap()
+                .is_empty()
+        );
+    }
+}

+ 28 - 0
crates/kuatia/src/lib.rs

@@ -0,0 +1,28 @@
+//! Kuatia — async ledger resource built on top of [`kuatia_core`].
+//!
+//! This crate adds IO to the pure decision logic: the [`Store`](kuatia_storage::store::Store) trait
+//! abstracts storage, and the [`Ledger`](crate::ledger::Ledger) struct composes the three-phase
+//! commit pipeline (load → plan → apply) behind a convenient async API.
+
+pub mod error;
+pub mod ledger;
+pub mod saga;
+
+// Re-export storage crate for convenience.
+pub use kuatia_storage::{error as store_error, mem_store, store, store_tests};
+
+/// Common imports for building on the ledger.
+///
+/// `use kuatia::prelude::*;` brings the domain types and intent builders from
+/// [`kuatia_core`] into scope, along with the [`Ledger`](crate::ledger::Ledger)
+/// resource, the [`Store`](kuatia_storage::store::Store) trait, and the
+/// [`InMemoryStore`](kuatia_storage::mem_store::InMemoryStore). Reach for the
+/// individual crates when you need types the prelude does not surface.
+pub mod prelude {
+    pub use kuatia_core::*;
+
+    pub use crate::error::LedgerError;
+    pub use crate::ledger::Ledger;
+    pub use kuatia_storage::mem_store::InMemoryStore;
+    pub use kuatia_storage::store::Store;
+}

+ 451 - 0
crates/kuatia/src/saga.rs

@@ -0,0 +1,451 @@
+//! Legend saga step adapters for the ledger.
+//!
+//! Provides [`Step`] implementations so the ledger can participate
+//! in multi-resource saga workflows, with automatic LIFO compensation across
+//! resource boundaries.
+//!
+//! # Envelope pipeline saga
+//!
+//! A commit is two saga steps over a pre-resolved [`Envelope`] (resolution runs
+//! before the saga, in `Ledger::commit`):
+//!
+//! 1. **ReservePostingsStep** -- `reserve_postings`: Active → PendingInactive, stamped with the saga's `ReservationId`; interprets the count via `verify_postings`.
+//! 2. **FinalizeTransferStep** -- delegates to `Ledger::finalize_envelope`, which re-validates against current state (the last-step floor / freeze-close guard), marks the saga `Finalizing`, then runs the dumb primitives (`deactivate_postings` → `insert_postings` → `store_transfer` → `append_event`) verifying every end-state.
+//!
+//! The `EnvelopeSaga` is defined via `legend!` in `ledger.rs` and driven by
+//! `commit_envelope()`. Crash recovery (`Ledger::recover`) re-completes a
+//! persisted saga using its persisted phase: a `Reserving` saga is re-run
+//! (re-validating); a `Finalizing` saga is rolled forward through the same
+//! verified `finalize_envelope`.
+//!
+//! # High-level composition
+//!
+//! High-level steps (`PayMovementStep`, `DepositMovementStep`, etc.) compose over
+//! the intent-layer API and can be combined into multi-transfer sagas via `legend!`.
+
+use std::sync::Arc;
+
+use async_trait::async_trait;
+use legend::step::{CompensationOutcome, RetryPolicy, Step, StepOutcome};
+use serde::{Deserialize, Serialize};
+use tracing::Instrument;
+
+use kuatia_core::{
+    AccountId, AssetId, Cent, Envelope, Posting, PostingId, PostingStatus, Receipt, ReservationId,
+    TransferBuilder,
+};
+
+use crate::error::LedgerError;
+use crate::ledger::Ledger;
+use kuatia_storage::store::Store;
+
+/// Interpret a dumb primitive's affected-row `count` against the `ids` it
+/// targeted. `count == ids.len()` is success. A short count is acceptable only if
+/// the shortfall is already in the desired end-state — a prior attempt (or this
+/// saga, replayed by recovery) already applied it — verified by reading the
+/// postings and checking `ok`. Otherwise it is a genuine failure (contended or
+/// concurrently modified) and the saga compensates.
+async fn verify_postings(
+    store: &dyn Store,
+    ids: &[PostingId],
+    count: u64,
+    ok: impl Fn(&Posting) -> bool,
+    what: &str,
+) -> Result<(), SagaError> {
+    if count == ids.len() as u64 {
+        return Ok(());
+    }
+    let postings = store
+        .get_postings(ids)
+        .await
+        .map_err(|e| SagaError::from(LedgerError::Store(e)))?;
+    if postings.len() == ids.len() && postings.iter().all(&ok) {
+        return Ok(());
+    }
+    Err(SagaError {
+        message: format!(
+            "{what}: storage applied {count}/{} rows and the end-state is not satisfied",
+            ids.len()
+        ),
+    })
+}
+
+// ---------------------------------------------------------------------------
+// Saga error -- serializable + cloneable wrapper
+// ---------------------------------------------------------------------------
+
+/// Serializable error wrapper used across saga steps.
+#[derive(Debug, Clone, Serialize, Deserialize)]
+pub struct SagaError {
+    /// Human-readable error description.
+    pub message: String,
+}
+
+impl std::fmt::Display for SagaError {
+    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
+        write!(f, "{}", self.message)
+    }
+}
+
+impl std::error::Error for SagaError {}
+
+impl From<LedgerError> for SagaError {
+    fn from(e: LedgerError) -> Self {
+        Self {
+            message: e.to_string(),
+        }
+    }
+}
+
+// ---------------------------------------------------------------------------
+// Saga context -- carries the ledger handle + state between steps
+// ---------------------------------------------------------------------------
+
+/// Saga context that wraps a ledger and tracks state across steps.
+///
+/// The ledger handle is `#[serde(skip)]` -- after deserializing a paused
+/// execution you must call [`inject_ledger`](LedgerCtx::inject_ledger)
+/// before resuming.
+#[derive(Clone, Serialize, Deserialize)]
+pub struct LedgerCtx {
+    /// Receipts collected from completed steps.
+    pub receipts: Vec<Receipt>,
+    /// Posting ids reserved so far (for compensation).
+    pub reserved_postings: Vec<PostingId>,
+    /// Resolved envelope produced by the resolve step.
+    pub envelope: Option<Envelope>,
+    /// Reservation owner token for this saga's reserved postings. Serialized so
+    /// it survives pause/recovery, keeping ownership stable across restarts.
+    pub reservation: ReservationId,
+    #[serde(skip)]
+    ledger: Option<Arc<Ledger>>,
+}
+
+impl std::fmt::Debug for LedgerCtx {
+    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
+        f.debug_struct("LedgerCtx")
+            .field("receipts", &self.receipts)
+            .field("reserved_postings", &self.reserved_postings.len())
+            .field("has_envelope", &self.envelope.is_some())
+            .field("ledger_present", &self.ledger.is_some())
+            .finish()
+    }
+}
+
+impl LedgerCtx {
+    /// Create a new context wrapping the given ledger.
+    pub fn new(ledger: Arc<Ledger>) -> Self {
+        Self {
+            receipts: Vec::new(),
+            reserved_postings: Vec::new(),
+            envelope: None,
+            reservation: ReservationId::default(),
+            ledger: Some(ledger),
+        }
+    }
+
+    /// Create a context for the envelope pipeline (reserve → finalize; finalize re-validates)
+    /// with a pre-resolved envelope and an explicit reservation.
+    pub fn for_envelope(
+        ledger: Arc<Ledger>,
+        envelope: Envelope,
+        reservation: ReservationId,
+    ) -> Self {
+        Self {
+            receipts: Vec::new(),
+            reserved_postings: Vec::new(),
+            envelope: Some(envelope),
+            reservation,
+            ledger: Some(ledger),
+        }
+    }
+
+    /// Re-inject the ledger handle after deserializing a paused execution.
+    pub fn inject_ledger(&mut self, ledger: Arc<Ledger>) {
+        self.ledger = Some(ledger);
+    }
+
+    /// Borrow the ledger, returning an error if not injected.
+    pub fn ledger(&self) -> Result<&Ledger, SagaError> {
+        self.ledger.as_ref().map(|l| l.as_ref()).ok_or(SagaError {
+            message: "ledger not injected -- call inject_ledger() after deserializing".into(),
+        })
+    }
+
+    /// Clone the ledger `Arc`, returning an error if not injected.
+    pub fn ledger_arc(&self) -> Result<Arc<Ledger>, SagaError> {
+        self.ledger.clone().ok_or(SagaError {
+            message: "ledger not injected -- call inject_ledger() after deserializing".into(),
+        })
+    }
+}
+
+// ===========================================================================
+// Envelope pipeline steps (reserve -> finalize; resolve runs before the saga, validate inside finalize)
+// ===========================================================================
+
+// ---------------------------------------------------------------------------
+// Step 1: ReservePostingsStep
+// ---------------------------------------------------------------------------
+
+/// Input for the reserve step (posting ids come from ctx.envelope).
+#[derive(Debug, Clone, Serialize, Deserialize)]
+pub struct ReserveInput;
+
+/// Reserves consumed postings by CAS: Active to PendingInactive.
+///
+/// Gets the posting ids from the resolved envelope in the context.
+/// Compensation releases all reserved postings back to Active.
+pub struct ReservePostingsStep;
+
+#[async_trait]
+impl Step<LedgerCtx, SagaError> for ReservePostingsStep {
+    type Input = ReserveInput;
+
+    async fn execute(ctx: &mut LedgerCtx, _input: &ReserveInput) -> Result<StepOutcome, SagaError> {
+        async {
+            let posting_ids: Vec<PostingId> = ctx
+                .envelope
+                .as_ref()
+                .ok_or(SagaError {
+                    message: "no envelope in context -- resolve step must run first".into(),
+                })?
+                .consumes()
+                .to_vec();
+            let rid = ctx.reservation;
+            let ledger = ctx.ledger_arc()?;
+            let store = ledger.store();
+
+            let reserved = store
+                .reserve_postings(&posting_ids, rid)
+                .await
+                .map_err(|e| SagaError::from(LedgerError::Store(e)))?;
+            // Storage reports the count; the saga decides. A short count is fine
+            // only if the shortfall is already reserved by us (idempotent replay).
+            verify_postings(
+                store,
+                &posting_ids,
+                reserved,
+                |p| p.status == PostingStatus::PendingInactive && p.reservation == Some(rid),
+                "reserve",
+            )
+            .await?;
+            ctx.reserved_postings.extend_from_slice(&posting_ids);
+            Ok(StepOutcome::Continue)
+        }
+        .instrument(tracing::info_span!("saga_step", step = "reserve"))
+        .await
+    }
+
+    async fn compensate(
+        ctx: &mut LedgerCtx,
+        _input: &ReserveInput,
+    ) -> Result<CompensationOutcome, SagaError> {
+        ctx.ledger()?
+            .store()
+            .release_postings(&ctx.reserved_postings, ctx.reservation)
+            .await
+            .map_err(|e| SagaError::from(LedgerError::Store(e)))?;
+        ctx.reserved_postings.clear();
+        Ok(CompensationOutcome::Completed)
+    }
+
+    fn retry_policy() -> RetryPolicy {
+        RetryPolicy::retries(3)
+    }
+}
+
+// ---------------------------------------------------------------------------
+// Step 2: FinalizeTransferStep
+// ---------------------------------------------------------------------------
+
+/// Input for the finalize step (envelope comes from ctx).
+#[derive(Debug, Clone, Serialize, Deserialize)]
+pub struct FinalizeInput;
+
+/// Re-validates against current state (the last-step floor / freeze-close guard),
+/// then drives the verified, idempotent commit via `Ledger::finalize_envelope`.
+///
+/// Compensation reverses the finalized envelope (only relevant once committed).
+pub struct FinalizeTransferStep;
+
+#[async_trait]
+impl Step<LedgerCtx, SagaError> for FinalizeTransferStep {
+    type Input = FinalizeInput;
+
+    async fn execute(
+        ctx: &mut LedgerCtx,
+        _input: &FinalizeInput,
+    ) -> Result<StepOutcome, SagaError> {
+        async {
+            let envelope = ctx.envelope.clone().ok_or(SagaError {
+                message: "no envelope in context -- resolve step must run first".into(),
+            })?;
+            let rid = ctx.reservation;
+            let ledger = ctx.ledger_arc()?;
+
+            // All commit work (re-validate, mark Finalizing, deactivate/insert/
+            // store/event with end-state verification) lives in `finalize_envelope`
+            // so recovery uses exactly the same path.
+            let receipt = ledger
+                .finalize_envelope(&envelope, rid)
+                .await
+                .map_err(SagaError::from)?;
+
+            ctx.receipts.push(receipt);
+            ctx.reserved_postings.clear();
+            Ok(StepOutcome::Continue)
+        }
+        .instrument(tracing::info_span!("saga_step", step = "finalize"))
+        .await
+    }
+
+    async fn compensate(
+        ctx: &mut LedgerCtx,
+        _input: &FinalizeInput,
+    ) -> Result<CompensationOutcome, SagaError> {
+        if let Some(receipt) = ctx.receipts.pop() {
+            ctx.ledger_arc()?.reverse(&receipt.transfer_id).await?;
+        }
+        Ok(CompensationOutcome::Completed)
+    }
+
+    fn retry_policy() -> RetryPolicy {
+        RetryPolicy::retries(3)
+    }
+}
+
+// ===========================================================================
+// High-level steps (pay / deposit / withdraw movement steps)
+// ===========================================================================
+
+/// Input for the pay movement saga step.
+#[derive(Debug, Clone, Serialize, Deserialize)]
+pub struct PayInput {
+    /// Source account.
+    pub from: AccountId,
+    /// Destination account.
+    pub to: AccountId,
+    /// Asset to transfer.
+    pub asset: AssetId,
+    /// Amount to transfer.
+    pub amount: Cent,
+}
+
+/// Input for the deposit movement saga step.
+#[derive(Debug, Clone, Serialize, Deserialize)]
+pub struct DepositInput {
+    /// Account receiving the deposit.
+    pub to: AccountId,
+    /// Asset being deposited.
+    pub asset: AssetId,
+    /// Amount to deposit.
+    pub amount: Cent,
+    /// External account funding the deposit.
+    pub external: AccountId,
+}
+
+/// Input for the withdraw movement saga step.
+#[derive(Debug, Clone, Serialize, Deserialize)]
+pub struct WithdrawInput {
+    /// Account to withdraw from.
+    pub from: AccountId,
+    /// Asset being withdrawn.
+    pub asset: AssetId,
+    /// Amount to withdraw.
+    pub amount: Cent,
+    /// External account receiving the withdrawal.
+    pub external: AccountId,
+}
+
+// ---------------------------------------------------------------------------
+// Helpers
+// ---------------------------------------------------------------------------
+
+async fn compensate_last_receipt(ctx: &mut LedgerCtx) -> Result<CompensationOutcome, SagaError> {
+    let receipt = ctx.receipts.pop().ok_or(SagaError {
+        message: "no receipt to compensate".into(),
+    })?;
+    ctx.ledger_arc()?.reverse(&receipt.transfer_id).await?;
+    Ok(CompensationOutcome::Completed)
+}
+
+// ---------------------------------------------------------------------------
+// Steps
+// ---------------------------------------------------------------------------
+
+/// Saga step: pay between two accounts via a single-movement transfer.
+pub struct PayMovementStep;
+
+#[async_trait]
+impl Step<LedgerCtx, SagaError> for PayMovementStep {
+    type Input = PayInput;
+
+    async fn execute(ctx: &mut LedgerCtx, input: &PayInput) -> Result<StepOutcome, SagaError> {
+        let ledger = ctx.ledger_arc()?;
+        let transfer = TransferBuilder::new()
+            .pay(input.from, input.to, input.asset, input.amount)
+            .build();
+        let receipt = ledger.commit(transfer).await?;
+        ctx.receipts.push(receipt);
+        Ok(StepOutcome::Continue)
+    }
+
+    async fn compensate(
+        ctx: &mut LedgerCtx,
+        _input: &PayInput,
+    ) -> Result<CompensationOutcome, SagaError> {
+        compensate_last_receipt(ctx).await
+    }
+}
+
+/// Saga step: deposit value from an external account via a single-movement transfer.
+pub struct DepositMovementStep;
+
+#[async_trait]
+impl Step<LedgerCtx, SagaError> for DepositMovementStep {
+    type Input = DepositInput;
+
+    async fn execute(ctx: &mut LedgerCtx, input: &DepositInput) -> Result<StepOutcome, SagaError> {
+        let ledger = ctx.ledger_arc()?;
+        let transfer = TransferBuilder::new()
+            .deposit(input.to, input.asset, input.amount, input.external)
+            .map_err(|e| SagaError::from(LedgerError::from(e)))?
+            .build();
+        let receipt = ledger.commit(transfer).await?;
+        ctx.receipts.push(receipt);
+        Ok(StepOutcome::Continue)
+    }
+
+    async fn compensate(
+        ctx: &mut LedgerCtx,
+        _input: &DepositInput,
+    ) -> Result<CompensationOutcome, SagaError> {
+        compensate_last_receipt(ctx).await
+    }
+}
+
+/// Saga step: withdraw value to an external account via a single-movement transfer.
+pub struct WithdrawMovementStep;
+
+#[async_trait]
+impl Step<LedgerCtx, SagaError> for WithdrawMovementStep {
+    type Input = WithdrawInput;
+
+    async fn execute(ctx: &mut LedgerCtx, input: &WithdrawInput) -> Result<StepOutcome, SagaError> {
+        let ledger = ctx.ledger_arc()?;
+        let transfer = TransferBuilder::new()
+            .withdraw(input.from, input.asset, input.amount, input.external)
+            .build();
+        let receipt = ledger.commit(transfer).await?;
+        ctx.receipts.push(receipt);
+        Ok(StepOutcome::Continue)
+    }
+
+    async fn compensate(
+        ctx: &mut LedgerCtx,
+        _input: &WithdrawInput,
+    ) -> Result<CompensationOutcome, SagaError> {
+        compensate_last_receipt(ctx).await
+    }
+}

+ 409 - 0
crates/kuatia/tests/concurrency.rs

@@ -0,0 +1,409 @@
+//! Concurrency tests for the saga commit pipeline over `InMemoryStore`.
+//!
+//! `InMemoryStore` guards each field with a `tokio::RwLock`, so every individual
+//! `Store` primitive is atomic. A saga, however, is a *sequence* of primitives
+//! with no overarching lock, so the interesting races live between primitives
+//! across concurrent sagas that share one `Arc<Ledger>`. The generated
+//! conformance suite only drives the store sequentially, so none of this is
+//! covered there.
+//!
+//! These tests run on a multi-thread runtime and use `tokio::spawn` so the
+//! sagas genuinely interleave rather than run to completion one at a time.
+
+#![allow(missing_docs)]
+
+use std::collections::BTreeMap;
+use std::sync::Arc;
+
+use kuatia::ledger::Ledger;
+use kuatia::mem_store::InMemoryStore;
+use kuatia_core::*;
+
+fn usd() -> AssetId {
+    AssetId::new(1)
+}
+
+fn account(id: i64) -> AccountId {
+    AccountId::new(id)
+}
+
+fn external() -> AccountId {
+    AccountId::new(99)
+}
+
+fn make_account(id: i64, policy: AccountPolicy) -> Account {
+    Account {
+        id: AccountId::new(id),
+        version: 1,
+        policy,
+        flags: AccountFlags::empty(),
+        book: BookId(0),
+        user_data: UserData::default(),
+        metadata: BTreeMap::new(),
+    }
+}
+
+/// A ledger with `NoOverdraft` accounts `1..=n` plus an external account.
+async fn ledger_with_accounts(n: i64) -> Arc<Ledger> {
+    let ledger = Arc::new(Ledger::new(InMemoryStore::new()));
+    for id in 1..=n {
+        ledger
+            .store()
+            .create_account(make_account(id, AccountPolicy::NoOverdraft))
+            .await
+            .unwrap();
+    }
+    ledger
+        .store()
+        .create_account(make_account(99, AccountPolicy::ExternalAccount))
+        .await
+        .unwrap();
+    ledger
+}
+
+async fn deposit(ledger: &Arc<Ledger>, to: AccountId, amount: Cent) {
+    let transfer = TransferBuilder::new()
+        .deposit(to, usd(), amount, external())
+        .unwrap()
+        .build();
+    ledger.commit(transfer).await.unwrap();
+}
+
+// ---------------------------------------------------------------------------
+// 1. Double-spend prevention (the headline invariant)
+// ---------------------------------------------------------------------------
+
+/// Many transfers concurrently try to spend the *same* funded posting to
+/// different recipients. Exactly one may win: the winner's `reserve_postings`
+/// flips the single Active posting to `PendingInactive`, and every other saga's
+/// reserve returns zero for a fresh reservation, so it fails and compensates.
+/// The ledger stays conserved: the payer ends at zero and exactly one recipient
+/// receives the full amount.
+#[tokio::test(flavor = "multi_thread", worker_threads = 4)]
+async fn concurrent_double_spend_has_one_winner() {
+    const RECIPIENTS: i64 = 8;
+    let ledger = ledger_with_accounts(1 + RECIPIENTS).await;
+
+    // Account 1 holds a single Active posting of 100.
+    deposit(&ledger, account(1), Cent::from(100)).await;
+
+    // Fire one full-balance payment per recipient, all at once.
+    let mut handles = Vec::new();
+    for recipient in 2..=(1 + RECIPIENTS) {
+        let ledger = Arc::clone(&ledger);
+        handles.push(tokio::spawn(async move {
+            let transfer = TransferBuilder::new()
+                .pay(account(1), account(recipient), usd(), Cent::from(100))
+                .build();
+            ledger.commit(transfer).await
+        }));
+    }
+
+    let mut winners = 0;
+    for h in handles {
+        if h.await.unwrap().is_ok() {
+            winners += 1;
+        }
+    }
+    assert_eq!(winners, 1, "exactly one concurrent spend may succeed");
+
+    // Conservation: payer drained, exactly one recipient credited, total = 100.
+    assert_eq!(
+        ledger.balance(&account(1), &usd()).await.unwrap(),
+        Cent::ZERO
+    );
+    let mut credited = 0;
+    let mut total = Cent::ZERO;
+    for recipient in 2..=(1 + RECIPIENTS) {
+        let bal = ledger.balance(&account(recipient), &usd()).await.unwrap();
+        if bal != Cent::ZERO {
+            credited += 1;
+            assert_eq!(bal, Cent::from(100));
+        }
+        total = total.checked_add(bal).unwrap();
+    }
+    assert_eq!(credited, 1, "exactly one recipient is credited");
+    assert_eq!(total, Cent::from(100), "value is conserved");
+}
+
+// ---------------------------------------------------------------------------
+// 2. Idempotency
+// ---------------------------------------------------------------------------
+
+/// Re-committing an already-committed envelope returns the same receipt and does
+/// not move value a second time. This is the sequential idempotency contract
+/// that `commit_envelope` guarantees via its content-addressed short-circuit.
+#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
+async fn recommit_same_envelope_is_idempotent() {
+    let ledger = ledger_with_accounts(2).await;
+    deposit(&ledger, account(1), Cent::from(100)).await;
+
+    let transfer = TransferBuilder::new()
+        .pay(account(1), account(2), usd(), Cent::from(50))
+        .build();
+    let envelope = ledger.resolve(&transfer).await.unwrap();
+
+    let first = ledger.commit_envelope(envelope.clone()).await.unwrap();
+    let second = ledger.commit_envelope(envelope).await.unwrap();
+
+    assert_eq!(first, second, "replay returns the original receipt");
+    assert_eq!(
+        ledger.balance(&account(1), &usd()).await.unwrap(),
+        Cent::from(50)
+    );
+    assert_eq!(
+        ledger.balance(&account(2), &usd()).await.unwrap(),
+        Cent::from(50)
+    );
+}
+
+/// The same envelope committed concurrently from many tasks. Because the
+/// content-addressed id is the idempotency key, value moves exactly once no
+/// matter how the sagas interleave: some tasks win or observe the stored
+/// transfer and return its receipt; the rest lose the reservation race and
+/// fail. Every successful receipt is identical, and the balances move once.
+#[tokio::test(flavor = "multi_thread", worker_threads = 4)]
+async fn concurrent_identical_commits_move_value_once() {
+    const TASKS: usize = 8;
+    let ledger = ledger_with_accounts(2).await;
+    deposit(&ledger, account(1), Cent::from(100)).await;
+
+    let transfer = TransferBuilder::new()
+        .pay(account(1), account(2), usd(), Cent::from(50))
+        .build();
+    let envelope = ledger.resolve(&transfer).await.unwrap();
+
+    let mut handles = Vec::new();
+    for _ in 0..TASKS {
+        let ledger = Arc::clone(&ledger);
+        let envelope = envelope.clone();
+        handles.push(tokio::spawn(async move {
+            ledger.commit_envelope(envelope).await
+        }));
+    }
+
+    let mut receipts = Vec::new();
+    for h in handles {
+        if let Ok(receipt) = h.await.unwrap() {
+            receipts.push(receipt);
+        }
+    }
+
+    assert!(!receipts.is_empty(), "at least one commit succeeds");
+    let first = &receipts[0];
+    assert!(
+        receipts.iter().all(|r| r == first),
+        "every successful commit returns the same receipt"
+    );
+
+    // Value moved exactly once, and exactly one transfer is stored.
+    assert_eq!(
+        ledger.balance(&account(1), &usd()).await.unwrap(),
+        Cent::from(50)
+    );
+    assert_eq!(
+        ledger.balance(&account(2), &usd()).await.unwrap(),
+        Cent::from(50)
+    );
+    assert!(
+        ledger
+            .store()
+            .get_transfer(&first.transfer_id)
+            .await
+            .unwrap()
+            .is_some(),
+        "the committed transfer is persisted"
+    );
+}
+
+// ---------------------------------------------------------------------------
+// 3. Freeze vs. commit race
+// ---------------------------------------------------------------------------
+
+/// Freezing an account concurrently with a payment out of it must leave a
+/// consistent state. The account is versioned and the commit pins the snapshot
+/// it validated against, so the two serialize one way or the other: either the
+/// payment finalizes first (against the unfrozen snapshot) and the freeze lands
+/// on top, or the freeze bumps the version first and the commit's last-step
+/// re-validation rejects the now-frozen account. There is no middle ground where
+/// value moves out of a frozen account against a stale snapshot. Value is always
+/// conserved and the payment is all-or-nothing.
+#[tokio::test(flavor = "multi_thread", worker_threads = 4)]
+async fn freeze_during_commit_stays_consistent() {
+    // Race is timing-dependent; run several fresh rounds to sample interleavings.
+    for _ in 0..24 {
+        let ledger = ledger_with_accounts(2).await;
+        deposit(&ledger, account(1), Cent::from(100)).await;
+
+        let freezer = {
+            let ledger = Arc::clone(&ledger);
+            tokio::spawn(async move { ledger.freeze(&account(1)).await })
+        };
+        let payer = {
+            let ledger = Arc::clone(&ledger);
+            tokio::spawn(async move {
+                let transfer = TransferBuilder::new()
+                    .pay(account(1), account(2), usd(), Cent::from(50))
+                    .build();
+                ledger.commit(transfer).await
+            })
+        };
+        freezer.await.unwrap().expect("freeze always succeeds");
+        let paid = payer.await.unwrap().is_ok();
+
+        let b1 = ledger.balance(&account(1), &usd()).await.unwrap();
+        let b2 = ledger.balance(&account(2), &usd()).await.unwrap();
+
+        // Conservation and all-or-nothing, keyed on whether the pay committed.
+        assert_eq!(
+            b1.checked_add(b2).unwrap(),
+            Cent::from(100),
+            "value is conserved regardless of who won"
+        );
+        if paid {
+            assert_eq!(b1, Cent::from(50));
+            assert_eq!(b2, Cent::from(50));
+        } else {
+            assert_eq!(b1, Cent::from(100));
+            assert_eq!(b2, Cent::ZERO);
+        }
+
+        // The account is frozen either way; no further payment may leave it.
+        assert!(ledger.get_account(&account(1)).await.unwrap().is_frozen());
+        let after = TransferBuilder::new()
+            .pay(account(1), account(2), usd(), Cent::from(10))
+            .build();
+        assert!(
+            ledger.commit(after).await.is_err(),
+            "a frozen account cannot pay"
+        );
+    }
+}
+
+// ---------------------------------------------------------------------------
+// 4. Disjoint transfers all commit and conserve
+// ---------------------------------------------------------------------------
+
+/// Concurrent transfers over non-overlapping accounts never contend, so all of
+/// them commit and total value is conserved. This is the throughput counterpart
+/// to the double-spend test: parallelism is only constrained where postings are
+/// actually shared.
+#[tokio::test(flavor = "multi_thread", worker_threads = 4)]
+async fn disjoint_transfers_all_commit_and_conserve() {
+    const PAIRS: i64 = 8;
+    // Accounts 1..=2*PAIRS: odd = payer (funded), even = payee.
+    let ledger = ledger_with_accounts(2 * PAIRS).await;
+    for k in 0..PAIRS {
+        deposit(&ledger, account(2 * k + 1), Cent::from(100)).await;
+    }
+
+    let mut handles = Vec::new();
+    for k in 0..PAIRS {
+        let ledger = Arc::clone(&ledger);
+        handles.push(tokio::spawn(async move {
+            let transfer = TransferBuilder::new()
+                .pay(
+                    account(2 * k + 1),
+                    account(2 * k + 2),
+                    usd(),
+                    Cent::from(100),
+                )
+                .build();
+            ledger.commit(transfer).await
+        }));
+    }
+    for h in handles {
+        h.await.unwrap().expect("disjoint transfers never contend");
+    }
+
+    let mut total = Cent::ZERO;
+    for id in 1..=(2 * PAIRS) {
+        let bal = ledger.balance(&account(id), &usd()).await.unwrap();
+        let expected = if id % 2 == 0 {
+            Cent::from(100)
+        } else {
+            Cent::ZERO
+        };
+        assert_eq!(bal, expected, "account {id} settled");
+        total = total.checked_add(bal).unwrap();
+    }
+    assert_eq!(total, Cent::from(100 * PAIRS), "value is conserved");
+}
+
+// ---------------------------------------------------------------------------
+// 5. Overdraft floor is best-effort under concurrency (documented limitation)
+// ---------------------------------------------------------------------------
+
+/// Documents a known, accepted limitation: the `CappedOverdraft` floor is
+/// re-checked at the last step before writing, but that check is not atomic
+/// with the write. Two overdrafts that each pass the floor check against the
+/// same pre-transfer balance can both commit and jointly push the account below
+/// its floor. See `doc/transfers.md`.
+///
+/// This test is `#[ignore]`d because the breach is timing-dependent, so it is
+/// executable documentation rather than a CI assertion. What always holds, and
+/// what it does assert, is per-asset conservation: the overdraft's negative
+/// postings are real value owed, never minted. If a run drives the account below
+/// the floor, that is the documented behavior, not a conservation failure.
+#[tokio::test(flavor = "multi_thread", worker_threads = 4)]
+#[ignore = "documents the best-effort overdraft floor; breach is timing-dependent"]
+async fn overdraft_floor_is_best_effort_under_concurrency() {
+    let floor = Cent::from(-100);
+    let mut observed_breach = false;
+
+    const PAYEES: i64 = 8;
+    for _ in 0..64 {
+        let ledger = Arc::new(Ledger::new(InMemoryStore::new()));
+        ledger
+            .store()
+            .create_account(make_account(1, AccountPolicy::CappedOverdraft { floor }))
+            .await
+            .unwrap();
+        for payee in 2..=(1 + PAYEES) {
+            ledger
+                .store()
+                .create_account(make_account(payee, AccountPolicy::NoOverdraft))
+                .await
+                .unwrap();
+        }
+
+        // One payment of 60 to each distinct payee from an empty overdraft
+        // account (distinct payees keep the envelopes distinct, so they are not
+        // collapsed by content-addressed idempotency). Each alone projects to
+        // -60 (within the -100 floor); any two that slip through the last-step
+        // floor check together already breach it.
+        let mut handles = Vec::new();
+        for payee in 2..=(1 + PAYEES) {
+            let ledger = Arc::clone(&ledger);
+            handles.push(tokio::spawn(async move {
+                let transfer = TransferBuilder::new()
+                    .pay(account(1), account(payee), usd(), Cent::from(60))
+                    .build();
+                ledger.commit(transfer).await
+            }));
+        }
+        for h in handles {
+            let _ = h.await.unwrap();
+        }
+
+        let mut total = ledger.balance(&account(1), &usd()).await.unwrap();
+        for payee in 2..=(1 + PAYEES) {
+            total = total
+                .checked_add(ledger.balance(&account(payee), &usd()).await.unwrap())
+                .unwrap();
+        }
+        assert_eq!(
+            total,
+            Cent::ZERO,
+            "value is conserved even when the floor is breached"
+        );
+        if ledger.balance(&account(1), &usd()).await.unwrap() < floor {
+            observed_breach = true;
+        }
+    }
+
+    eprintln!(
+        "overdraft floor breach observed under concurrency: {observed_breach} \
+         (best-effort by design; see doc/transfers.md)"
+    );
+}

+ 908 - 0
crates/kuatia/tests/integration.rs

@@ -0,0 +1,908 @@
+#![allow(missing_docs)]
+
+use std::sync::Arc;
+
+use kuatia::ledger::Ledger;
+use kuatia::mem_store::InMemoryStore;
+use kuatia_core::*;
+use std::collections::BTreeMap;
+
+fn usd() -> AssetId {
+    AssetId::new(1)
+}
+
+fn eur() -> AssetId {
+    AssetId::new(2)
+}
+
+fn account(id: i64) -> AccountId {
+    AccountId::new(id)
+}
+
+fn external() -> AccountId {
+    AccountId::new(99)
+}
+
+fn make_account(id: i64, policy: AccountPolicy) -> Account {
+    Account {
+        id: AccountId::new(id),
+        version: 1,
+        policy,
+        flags: AccountFlags::empty(),
+        book: BookId(0),
+        user_data: UserData::default(),
+        metadata: BTreeMap::new(),
+    }
+}
+
+async fn setup_ledger() -> Arc<Ledger> {
+    let store = InMemoryStore::new();
+    let ledger = Arc::new(Ledger::new(store));
+
+    ledger
+        .store()
+        .create_account(make_account(1, AccountPolicy::NoOverdraft))
+        .await
+        .unwrap();
+    ledger
+        .store()
+        .create_account(make_account(2, AccountPolicy::NoOverdraft))
+        .await
+        .unwrap();
+    ledger
+        .store()
+        .create_account(make_account(3, AccountPolicy::NoOverdraft))
+        .await
+        .unwrap();
+    ledger
+        .store()
+        .create_account(make_account(99, AccountPolicy::ExternalAccount))
+        .await
+        .unwrap();
+
+    ledger
+}
+
+/// Helper: deposit via commit()
+async fn deposit(
+    ledger: &Arc<Ledger>,
+    to: AccountId,
+    asset: AssetId,
+    amount: Cent,
+    ext: AccountId,
+) -> Receipt {
+    let transfer = TransferBuilder::new()
+        .deposit(to, asset, amount, ext)
+        .unwrap()
+        .build();
+    ledger.commit(transfer).await.unwrap()
+}
+
+/// Helper: pay via commit()
+async fn pay(
+    ledger: &Arc<Ledger>,
+    from: AccountId,
+    to: AccountId,
+    asset: AssetId,
+    amount: Cent,
+) -> Receipt {
+    let transfer = TransferBuilder::new().pay(from, to, asset, amount).build();
+    ledger.commit(transfer).await.unwrap()
+}
+
+/// Helper: withdraw via commit()
+async fn withdraw(
+    ledger: &Arc<Ledger>,
+    from: AccountId,
+    asset: AssetId,
+    amount: Cent,
+    ext: AccountId,
+) -> Receipt {
+    let transfer = TransferBuilder::new()
+        .withdraw(from, asset, amount, ext)
+        .build();
+    ledger.commit(transfer).await.unwrap()
+}
+
+// ---------------------------------------------------------------------------
+// §4.1 Deposit
+// ---------------------------------------------------------------------------
+
+#[tokio::test]
+async fn deposit_creates_balanced_postings() {
+    let ledger = setup_ledger().await;
+
+    deposit(&ledger, account(1), usd(), Cent::from(100), external()).await;
+
+    assert_eq!(
+        ledger.balance(&account(1), &usd()).await.unwrap(),
+        Cent::from(100)
+    );
+    assert_eq!(
+        ledger.balance(&external(), &usd()).await.unwrap(),
+        Cent::from(-100)
+    );
+}
+
+// ---------------------------------------------------------------------------
+// §4.2 Internal transfer with change
+// ---------------------------------------------------------------------------
+
+#[tokio::test]
+async fn pay_with_change() {
+    let ledger = setup_ledger().await;
+
+    deposit(&ledger, account(1), usd(), Cent::from(100), external()).await;
+    pay(&ledger, account(1), account(2), usd(), Cent::from(50)).await;
+
+    assert_eq!(
+        ledger.balance(&account(1), &usd()).await.unwrap(),
+        Cent::from(50)
+    );
+    assert_eq!(
+        ledger.balance(&account(2), &usd()).await.unwrap(),
+        Cent::from(50)
+    );
+    assert_eq!(
+        ledger.balance(&external(), &usd()).await.unwrap(),
+        Cent::from(-100)
+    );
+}
+
+// ---------------------------------------------------------------------------
+// §4.3 Multi-hop
+// ---------------------------------------------------------------------------
+
+#[tokio::test]
+async fn multi_hop_transfer() {
+    let ledger = setup_ledger().await;
+
+    deposit(&ledger, account(1), usd(), Cent::from(100), external()).await;
+    pay(&ledger, account(1), account(2), usd(), Cent::from(50)).await;
+    pay(&ledger, account(2), account(3), usd(), Cent::from(20)).await;
+
+    assert_eq!(
+        ledger.balance(&account(1), &usd()).await.unwrap(),
+        Cent::from(50)
+    );
+    assert_eq!(
+        ledger.balance(&account(2), &usd()).await.unwrap(),
+        Cent::from(30)
+    );
+    assert_eq!(
+        ledger.balance(&account(3), &usd()).await.unwrap(),
+        Cent::from(20)
+    );
+    assert_eq!(
+        ledger.balance(&external(), &usd()).await.unwrap(),
+        Cent::from(-100)
+    );
+}
+
+// ---------------------------------------------------------------------------
+// §4.5 Withdrawal
+// ---------------------------------------------------------------------------
+
+#[tokio::test]
+async fn withdrawal_reduces_external_liability() {
+    let ledger = setup_ledger().await;
+
+    deposit(&ledger, account(1), usd(), Cent::from(100), external()).await;
+    withdraw(&ledger, account(1), usd(), Cent::from(50), external()).await;
+
+    assert_eq!(
+        ledger.balance(&account(1), &usd()).await.unwrap(),
+        Cent::from(50)
+    );
+    assert_eq!(
+        ledger.balance(&external(), &usd()).await.unwrap(),
+        Cent::from(-50)
+    );
+}
+
+// ---------------------------------------------------------------------------
+// Full round-trip: deposit -> pay -> withdraw -> verify total = 0
+// ---------------------------------------------------------------------------
+
+#[tokio::test]
+async fn full_round_trip() {
+    let ledger = setup_ledger().await;
+
+    deposit(&ledger, account(1), usd(), Cent::from(100), external()).await;
+    pay(&ledger, account(1), account(2), usd(), Cent::from(60)).await;
+    withdraw(&ledger, account(2), usd(), Cent::from(60), external()).await;
+    withdraw(&ledger, account(1), usd(), Cent::from(40), external()).await;
+
+    assert_eq!(
+        ledger.balance(&account(1), &usd()).await.unwrap(),
+        Cent::ZERO
+    );
+    assert_eq!(
+        ledger.balance(&account(2), &usd()).await.unwrap(),
+        Cent::ZERO
+    );
+    assert_eq!(
+        ledger.balance(&external(), &usd()).await.unwrap(),
+        Cent::ZERO
+    );
+}
+
+// ---------------------------------------------------------------------------
+// Idempotency -- committing same envelope twice returns same receipt
+// ---------------------------------------------------------------------------
+
+#[tokio::test]
+async fn idempotent_commit() {
+    let ledger = setup_ledger().await;
+
+    let envelope = EnvelopeBuilder::new()
+        .creates(vec![
+            NewPosting {
+                owner: account(1),
+                asset: usd(),
+                value: Cent::from(100),
+                payer: None,
+            },
+            NewPosting {
+                owner: external(),
+                asset: usd(),
+                value: Cent::from(-100),
+                payer: None,
+            },
+        ])
+        .build();
+
+    let r1 = ledger.commit_envelope(envelope.clone()).await.unwrap();
+    let r2 = ledger.commit_envelope(envelope).await.unwrap();
+
+    assert_eq!(r1.transfer_id, r2.transfer_id);
+    // Balance should only be 100, not 200 (second commit was a no-op)
+    assert_eq!(
+        ledger.balance(&account(1), &usd()).await.unwrap(),
+        Cent::from(100)
+    );
+}
+
+// ---------------------------------------------------------------------------
+// Overdraft prevention
+// ---------------------------------------------------------------------------
+
+#[tokio::test]
+async fn overdraft_rejected() {
+    let ledger = setup_ledger().await;
+
+    deposit(&ledger, account(1), usd(), Cent::from(50), external()).await;
+    let transfer = TransferBuilder::new()
+        .pay(account(1), account(2), usd(), Cent::from(100))
+        .build();
+    let result = ledger.commit(transfer).await;
+
+    assert!(result.is_err());
+    // Balance unchanged
+    assert_eq!(
+        ledger.balance(&account(1), &usd()).await.unwrap(),
+        Cent::from(50)
+    );
+}
+
+// ---------------------------------------------------------------------------
+// Reverse: forward compensating transfer
+// ---------------------------------------------------------------------------
+
+#[tokio::test]
+async fn reverse_restores_balances() {
+    let ledger = setup_ledger().await;
+
+    deposit(&ledger, account(1), usd(), Cent::from(100), external()).await;
+    let pay_receipt = pay(&ledger, account(1), account(2), usd(), Cent::from(60)).await;
+
+    assert_eq!(
+        ledger.balance(&account(1), &usd()).await.unwrap(),
+        Cent::from(40)
+    );
+    assert_eq!(
+        ledger.balance(&account(2), &usd()).await.unwrap(),
+        Cent::from(60)
+    );
+
+    // Reverse the payment
+    ledger.reverse(&pay_receipt.transfer_id).await.unwrap();
+
+    assert_eq!(
+        ledger.balance(&account(1), &usd()).await.unwrap(),
+        Cent::from(100)
+    );
+    assert_eq!(
+        ledger.balance(&account(2), &usd()).await.unwrap(),
+        Cent::ZERO
+    );
+}
+
+// ---------------------------------------------------------------------------
+// Frozen account blocks transfers
+// ---------------------------------------------------------------------------
+
+#[tokio::test]
+async fn frozen_account_rejected() {
+    let store = InMemoryStore::new();
+    let ledger = Arc::new(Ledger::new(store));
+
+    let mut frozen = make_account(1, AccountPolicy::NoOverdraft);
+    frozen.flags = AccountFlags::FROZEN;
+    ledger.store().create_account(frozen).await.unwrap();
+    ledger
+        .store()
+        .create_account(make_account(99, AccountPolicy::ExternalAccount))
+        .await
+        .unwrap();
+
+    let transfer = TransferBuilder::new()
+        .deposit(account(1), usd(), Cent::from(100), external())
+        .unwrap()
+        .build();
+    let result = ledger.commit(transfer).await;
+    assert!(result.is_err());
+}
+
+// ---------------------------------------------------------------------------
+// Multi-asset: each asset conserves independently
+// ---------------------------------------------------------------------------
+
+#[tokio::test]
+async fn multi_asset_independent_balances() {
+    let ledger = setup_ledger().await;
+
+    deposit(&ledger, account(1), usd(), Cent::from(100), external()).await;
+    deposit(&ledger, account(1), eur(), Cent::from(200), external()).await;
+
+    assert_eq!(
+        ledger.balance(&account(1), &usd()).await.unwrap(),
+        Cent::from(100)
+    );
+    assert_eq!(
+        ledger.balance(&account(1), &eur()).await.unwrap(),
+        Cent::from(200)
+    );
+
+    pay(&ledger, account(1), account(2), usd(), Cent::from(30)).await;
+
+    assert_eq!(
+        ledger.balance(&account(1), &usd()).await.unwrap(),
+        Cent::from(70)
+    );
+    assert_eq!(
+        ledger.balance(&account(1), &eur()).await.unwrap(),
+        Cent::from(200)
+    );
+    assert_eq!(
+        ledger.balance(&account(2), &usd()).await.unwrap(),
+        Cent::from(30)
+    );
+}
+
+// ---------------------------------------------------------------------------
+// §4.4 FX trade via market account
+// ---------------------------------------------------------------------------
+
+#[tokio::test]
+async fn fx_trade_via_market_account() {
+    let store = InMemoryStore::new();
+    let ledger = Arc::new(Ledger::new(store));
+
+    // Setup accounts
+    for (id, policy) in [
+        (1, AccountPolicy::NoOverdraft),
+        (50, AccountPolicy::SystemAccount), // FX market account
+        (99, AccountPolicy::ExternalAccount),
+    ] {
+        ledger
+            .store()
+            .create_account(make_account(id, policy))
+            .await
+            .unwrap();
+    }
+
+    // Seed: account1 has 100 USD, fx has 92 EUR
+    deposit(&ledger, account(1), usd(), Cent::from(100), external()).await;
+    deposit(&ledger, account(50), eur(), Cent::from(92), external()).await;
+
+    // FX trade: account1 sells 100 USD, buys 92 EUR
+    // Build the atomic envelope manually since it spans two assets
+    let a1_usd_postings = ledger
+        .store()
+        .get_postings_by_account(&account(1), Some(&usd()), Some(PostingStatus::Active))
+        .await
+        .unwrap();
+    let fx_eur_postings = ledger
+        .store()
+        .get_postings_by_account(&account(50), Some(&eur()), Some(PostingStatus::Active))
+        .await
+        .unwrap();
+
+    let envelope = EnvelopeBuilder::new()
+        .consumes(vec![a1_usd_postings[0].id, fx_eur_postings[0].id])
+        .creates(vec![
+            NewPosting {
+                owner: account(50),
+                asset: usd(),
+                value: Cent::from(100),
+                payer: Some(account(1)),
+            },
+            NewPosting {
+                owner: account(1),
+                asset: eur(),
+                value: Cent::from(92),
+                payer: Some(account(50)),
+            },
+        ])
+        .build();
+
+    ledger.commit_envelope(envelope).await.unwrap();
+
+    // Verify
+    assert_eq!(
+        ledger.balance(&account(1), &usd()).await.unwrap(),
+        Cent::ZERO
+    );
+    assert_eq!(
+        ledger.balance(&account(1), &eur()).await.unwrap(),
+        Cent::from(92)
+    );
+    assert_eq!(
+        ledger.balance(&account(50), &usd()).await.unwrap(),
+        Cent::from(100)
+    );
+    assert_eq!(
+        ledger.balance(&account(50), &eur()).await.unwrap(),
+        Cent::ZERO
+    );
+}
+
+// ---------------------------------------------------------------------------
+// Account lifecycle: freeze / unfreeze / close
+// ---------------------------------------------------------------------------
+
+#[tokio::test]
+async fn freeze_blocks_transfers() {
+    let ledger = setup_ledger().await;
+
+    deposit(&ledger, account(1), usd(), Cent::from(100), external()).await;
+    ledger.freeze(&account(1)).await.unwrap();
+
+    // Paying from a frozen account should fail
+    let transfer = TransferBuilder::new()
+        .pay(account(1), account(2), usd(), Cent::from(50))
+        .build();
+    let result = ledger.commit(transfer).await;
+    assert!(result.is_err());
+    // Balance unchanged
+    assert_eq!(
+        ledger.balance(&account(1), &usd()).await.unwrap(),
+        Cent::from(100)
+    );
+}
+
+#[tokio::test]
+async fn unfreeze_re_enables_transfers() {
+    let ledger = setup_ledger().await;
+
+    deposit(&ledger, account(1), usd(), Cent::from(100), external()).await;
+    ledger.freeze(&account(1)).await.unwrap();
+    ledger.unfreeze(&account(1)).await.unwrap();
+
+    // Should work again
+    pay(&ledger, account(1), account(2), usd(), Cent::from(50)).await;
+    assert_eq!(
+        ledger.balance(&account(1), &usd()).await.unwrap(),
+        Cent::from(50)
+    );
+}
+
+#[tokio::test]
+async fn close_account_with_zero_balance() {
+    let ledger = setup_ledger().await;
+
+    // Account 3 has never transacted -- zero balance, no postings
+    ledger.close(&account(3)).await.unwrap();
+
+    // Closed account rejects deposits
+    let transfer = TransferBuilder::new()
+        .deposit(account(3), usd(), Cent::from(100), external())
+        .unwrap()
+        .build();
+    let result = ledger.commit(transfer).await;
+    assert!(result.is_err());
+}
+
+#[tokio::test]
+async fn close_account_with_balance_rejected() {
+    let ledger = setup_ledger().await;
+
+    deposit(&ledger, account(1), usd(), Cent::from(100), external()).await;
+
+    // Should fail -- account still has active postings
+    let result = ledger.close(&account(1)).await;
+    assert!(result.is_err());
+    // Balance unchanged
+    assert_eq!(
+        ledger.balance(&account(1), &usd()).await.unwrap(),
+        Cent::from(100)
+    );
+}
+
+#[tokio::test]
+async fn close_rejects_reserved_postings() {
+    let ledger = setup_ledger().await;
+
+    deposit(&ledger, account(1), usd(), Cent::from(100), external()).await;
+
+    // Reserve the account's only posting (a transfer in flight): Active → PendingInactive.
+    let postings = ledger
+        .store()
+        .get_postings_by_account(&account(1), Some(&usd()), Some(PostingStatus::Active))
+        .await
+        .unwrap();
+    ledger
+        .store()
+        .reserve_postings(&[postings[0].id], ReservationId::new(1))
+        .await
+        .unwrap();
+
+    // Close must reject: the posting is live (PendingInactive), not Inactive.
+    let result = ledger.close(&account(1)).await;
+    assert!(result.is_err());
+}
+
+#[tokio::test]
+async fn freeze_closed_account_rejected() {
+    let ledger = setup_ledger().await;
+
+    ledger.close(&account(3)).await.unwrap();
+
+    let result = ledger.freeze(&account(3)).await;
+    assert!(result.is_err());
+}
+
+// ---------------------------------------------------------------------------
+// Query layer: history, postings, list_accounts, get_account
+// ---------------------------------------------------------------------------
+
+#[tokio::test]
+async fn history_returns_transfers_for_account() {
+    let ledger = setup_ledger().await;
+
+    deposit(&ledger, account(1), usd(), Cent::from(100), external()).await;
+    pay(&ledger, account(1), account(2), usd(), Cent::from(40)).await;
+    deposit(&ledger, account(2), usd(), Cent::from(50), external()).await;
+
+    let h1 = ledger.history(&account(1)).await.unwrap();
+    // account(1) was in the deposit and the pay
+    assert_eq!(h1.len(), 2);
+
+    let h2 = ledger.history(&account(2)).await.unwrap();
+    // account(2) was in the pay and a second deposit
+    assert_eq!(h2.len(), 2);
+}
+
+#[tokio::test]
+async fn postings_returns_all_postings() {
+    let ledger = setup_ledger().await;
+
+    deposit(&ledger, account(1), usd(), Cent::from(100), external()).await;
+    pay(&ledger, account(1), account(2), usd(), Cent::from(60)).await;
+
+    let posts = ledger.postings(&account(1)).await.unwrap();
+    // Original 100 posting (now consumed) + 40 change posting (active)
+    assert_eq!(posts.len(), 2);
+
+    let active: Vec<_> = posts.iter().filter(|p| p.is_active()).collect();
+    assert_eq!(active.len(), 1);
+    assert_eq!(active[0].value, Cent::from(40));
+}
+
+#[tokio::test]
+async fn list_accounts_returns_all() {
+    let ledger = setup_ledger().await;
+
+    let accounts = ledger.list_accounts().await.unwrap();
+    // setup_ledger creates accounts 1, 2, 3, 99
+    assert_eq!(accounts.len(), 4);
+}
+
+#[tokio::test]
+async fn get_account_by_id() {
+    let ledger = setup_ledger().await;
+
+    let acc = ledger.get_account(&account(1)).await.unwrap();
+    assert_eq!(acc.id, account(1));
+    assert_eq!(acc.policy, AccountPolicy::NoOverdraft);
+}
+
+#[tokio::test]
+async fn get_account_not_found() {
+    let ledger = setup_ledger().await;
+
+    let result = ledger.get_account(&account(999)).await;
+    assert!(result.is_err());
+}
+
+// ---------------------------------------------------------------------------
+// Append-only accounts: version history, version conflict, account_versions
+// ---------------------------------------------------------------------------
+
+#[tokio::test]
+async fn account_history_tracks_versions() {
+    let ledger = setup_ledger().await;
+
+    // Version 1: created
+    let history = ledger.account_history(&account(1)).await.unwrap();
+    assert_eq!(history.len(), 1);
+    assert_eq!(history[0].version, 1);
+
+    // Version 2: frozen
+    ledger.freeze(&account(1)).await.unwrap();
+    let history = ledger.account_history(&account(1)).await.unwrap();
+    assert_eq!(history.len(), 2);
+    assert_eq!(history[1].version, 2);
+    assert!(history[1].is_frozen());
+
+    // Version 3: unfrozen
+    ledger.unfreeze(&account(1)).await.unwrap();
+    let history = ledger.account_history(&account(1)).await.unwrap();
+    assert_eq!(history.len(), 3);
+    assert_eq!(history[2].version, 3);
+    assert!(!history[2].is_frozen());
+}
+
+#[tokio::test]
+async fn store_never_compacts() {
+    let ledger = setup_ledger().await;
+
+    // Freeze and unfreeze multiple times
+    for _ in 0..5 {
+        ledger.freeze(&account(1)).await.unwrap();
+        ledger.unfreeze(&account(1)).await.unwrap();
+    }
+
+    // All 11 versions preserved (1 creation + 10 mutations)
+    let history = ledger.account_history(&account(1)).await.unwrap();
+    assert_eq!(history.len(), 11);
+    // Versions are monotonically increasing
+    for (i, acc) in history.iter().enumerate() {
+        assert_eq!(acc.version, (i + 1) as u64);
+    }
+}
+
+#[tokio::test]
+async fn transfer_records_account_snapshots() {
+    let ledger = setup_ledger().await;
+
+    deposit(&ledger, account(1), usd(), Cent::from(100), external()).await;
+
+    // The envelope should have account_snapshots populated by the resolve step
+    let transfers = ledger.history(&account(1)).await.unwrap();
+    assert_eq!(transfers.len(), 1);
+    assert!(!transfers[0].envelope.account_snapshots().is_empty());
+}
+
+#[tokio::test]
+async fn stale_snapshot_rejected() {
+    let ledger = setup_ledger().await;
+
+    // Get current snapshot for account(1)
+    let acc1 = ledger.get_account(&account(1)).await.unwrap();
+    let stale_snapshot = kuatia_core::account_snapshot_id(&acc1);
+
+    // Freeze account(1) -- changes its snapshot hash
+    ledger.freeze(&account(1)).await.unwrap();
+
+    // Build an envelope with the stale snapshot
+    let envelope = EnvelopeBuilder::new()
+        .creates(vec![
+            NewPosting {
+                owner: account(1),
+                asset: usd(),
+                value: Cent::from(100),
+                payer: None,
+            },
+            NewPosting {
+                owner: external(),
+                asset: usd(),
+                value: Cent::from(-100),
+                payer: None,
+            },
+        ])
+        .account_snapshots(vec![stale_snapshot])
+        .build();
+
+    let result = ledger.commit_envelope(envelope).await;
+    assert!(result.is_err());
+}
+
+#[tokio::test]
+async fn account_hash_deterministic() {
+    let acc = make_account(42, AccountPolicy::NoOverdraft);
+    let h1 = kuatia_core::account_hash(&acc);
+    let h2 = kuatia_core::account_hash(&acc);
+    assert_eq!(h1, h2);
+}
+
+#[tokio::test]
+async fn account_hash_changes_with_version() {
+    let mut acc = make_account(42, AccountPolicy::NoOverdraft);
+    let h1 = kuatia_core::account_hash(&acc);
+    acc.version = 2;
+    acc.flags |= AccountFlags::FROZEN;
+    let h2 = kuatia_core::account_hash(&acc);
+    assert_ne!(h1, h2);
+}
+
+// ---------------------------------------------------------------------------
+// Overdraft via negative postings
+// ---------------------------------------------------------------------------
+
+#[tokio::test]
+async fn capped_overdraft_creates_negative_posting() {
+    let store = InMemoryStore::new();
+    let ledger = Arc::new(Ledger::new(store));
+    for (id, policy) in [
+        (
+            10,
+            AccountPolicy::CappedOverdraft {
+                floor: Cent::from(-200),
+            },
+        ),
+        (2, AccountPolicy::NoOverdraft),
+        (99, AccountPolicy::ExternalAccount),
+    ] {
+        ledger
+            .store()
+            .create_account(make_account(id, policy))
+            .await
+            .unwrap();
+    }
+
+    // Fund account 10 with 50, then pay 100 — overdraft covers the 50 shortfall.
+    deposit(&ledger, account(10), usd(), Cent::from(50), external()).await;
+    pay(&ledger, account(10), account(2), usd(), Cent::from(100)).await;
+
+    assert_eq!(
+        ledger.balance(&account(10), &usd()).await.unwrap(),
+        Cent::from(-50)
+    );
+    assert_eq!(
+        ledger.balance(&account(2), &usd()).await.unwrap(),
+        Cent::from(100)
+    );
+
+    // A negative posting now backs the overdraft.
+    let postings = ledger
+        .store()
+        .get_postings_by_account(&account(10), Some(&usd()), Some(PostingStatus::Active))
+        .await
+        .unwrap();
+    assert!(postings.iter().any(|p| p.value == Cent::from(-50)));
+}
+
+#[tokio::test]
+async fn capped_overdraft_respects_floor() {
+    let store = InMemoryStore::new();
+    let ledger = Arc::new(Ledger::new(store));
+    for (id, policy) in [
+        (
+            10,
+            AccountPolicy::CappedOverdraft {
+                floor: Cent::from(-80),
+            },
+        ),
+        (2, AccountPolicy::NoOverdraft),
+        (99, AccountPolicy::ExternalAccount),
+    ] {
+        ledger
+            .store()
+            .create_account(make_account(id, policy))
+            .await
+            .unwrap();
+    }
+
+    // Paying 100 from an empty account would project to -100, below the -80 floor.
+    let transfer = TransferBuilder::new()
+        .pay(account(10), account(2), usd(), Cent::from(100))
+        .build();
+    assert!(ledger.commit(transfer).await.is_err());
+    assert_eq!(
+        ledger.balance(&account(10), &usd()).await.unwrap(),
+        Cent::ZERO
+    );
+}
+
+#[tokio::test]
+async fn uncapped_overdraft_allows_arbitrary_negative() {
+    let store = InMemoryStore::new();
+    let ledger = Arc::new(Ledger::new(store));
+    for (id, policy) in [
+        (10, AccountPolicy::UncappedOverdraft),
+        (2, AccountPolicy::NoOverdraft),
+        (99, AccountPolicy::ExternalAccount),
+    ] {
+        ledger
+            .store()
+            .create_account(make_account(id, policy))
+            .await
+            .unwrap();
+    }
+
+    pay(
+        &ledger,
+        account(10),
+        account(2),
+        usd(),
+        Cent::from(1_000_000),
+    )
+    .await;
+    assert_eq!(
+        ledger.balance(&account(10), &usd()).await.unwrap(),
+        Cent::from(-1_000_000)
+    );
+}
+
+// ---------------------------------------------------------------------------
+// Book policy enforcement
+// ---------------------------------------------------------------------------
+
+#[tokio::test]
+async fn book_policy_rejects_disallowed_asset() {
+    let ledger = setup_ledger().await;
+    // Book 5 permits only EUR.
+    let book = BookBuilder::new("eur-only")
+        .id(BookId::new(5))
+        .allow_asset(eur())
+        .build();
+    ledger.store().create_book(book).await.unwrap();
+
+    deposit(&ledger, account(1), usd(), Cent::from(100), external()).await;
+
+    // Paying USD under a EUR-only book is rejected, balance unchanged.
+    let transfer = TransferBuilder::new()
+        .book(BookId::new(5))
+        .pay(account(1), account(2), usd(), Cent::from(50))
+        .build();
+    assert!(ledger.commit(transfer).await.is_err());
+    assert_eq!(
+        ledger.balance(&account(1), &usd()).await.unwrap(),
+        Cent::from(100)
+    );
+}
+
+#[tokio::test]
+async fn transfer_in_missing_named_book_is_rejected() {
+    let ledger = setup_ledger().await;
+    deposit(&ledger, account(1), usd(), Cent::from(100), external()).await;
+
+    let transfer = TransferBuilder::new()
+        .book(BookId::new(404))
+        .pay(account(1), account(2), usd(), Cent::from(50))
+        .build();
+    assert!(ledger.commit(transfer).await.is_err());
+    assert_eq!(
+        ledger.balance(&account(1), &usd()).await.unwrap(),
+        Cent::from(100)
+    );
+}
+
+// ---------------------------------------------------------------------------
+// Content-addressed determinism
+// ---------------------------------------------------------------------------
+
+#[tokio::test]
+async fn identical_transfers_share_envelope_id() {
+    // Two independently-built default-book transfers must hash identically.
+    let a = TransferBuilder::new()
+        .pay(account(1), account(2), usd(), Cent::from(10))
+        .build();
+    let b = TransferBuilder::new()
+        .pay(account(1), account(2), usd(), Cent::from(10))
+        .build();
+    assert_eq!(a.book, b.book, "default book must be deterministic");
+    assert_eq!(a.book, DEFAULT_BOOK);
+}

+ 226 - 0
crates/kuatia/tests/saga.rs

@@ -0,0 +1,226 @@
+#![allow(missing_docs)]
+
+use std::sync::Arc;
+
+use kuatia::ledger::Ledger;
+use kuatia::mem_store::InMemoryStore;
+use kuatia::saga::*;
+use kuatia_core::*;
+use legend::{ExecutionResult, legend};
+use std::collections::BTreeMap;
+
+fn usd() -> AssetId {
+    AssetId::new(1)
+}
+
+fn account(id: i64) -> AccountId {
+    AccountId::new(id)
+}
+
+fn external() -> AccountId {
+    AccountId::new(99)
+}
+
+fn make_account(id: i64, policy: AccountPolicy) -> Account {
+    Account {
+        id: AccountId::new(id),
+        version: 1,
+        policy,
+        flags: AccountFlags::empty(),
+        book: BookId(0),
+        user_data: UserData::default(),
+        metadata: BTreeMap::new(),
+    }
+}
+
+async fn setup_ledger() -> Arc<Ledger> {
+    let store = InMemoryStore::new();
+    let ledger = Arc::new(Ledger::new(store));
+
+    for (id, policy) in [
+        (1, AccountPolicy::NoOverdraft),
+        (2, AccountPolicy::NoOverdraft),
+        (3, AccountPolicy::NoOverdraft),
+        (99, AccountPolicy::ExternalAccount),
+    ] {
+        ledger
+            .store()
+            .create_account(make_account(id, policy))
+            .await
+            .unwrap();
+    }
+
+    ledger
+}
+
+// Define a two-step saga: deposit then pay
+legend! {
+    FundAndPay<LedgerCtx, SagaError> {
+        deposit: DepositMovementStep,
+        pay: PayMovementStep,
+    }
+}
+
+#[tokio::test]
+async fn saga_happy_path() {
+    let ledger = setup_ledger().await;
+
+    let saga = FundAndPay::new(FundAndPayInputs {
+        deposit: DepositInput {
+            to: account(1),
+            asset: usd(),
+            amount: Cent::from(100),
+            external: external(),
+        },
+        pay: PayInput {
+            from: account(1),
+            to: account(2),
+            asset: usd(),
+            amount: Cent::from(60),
+        },
+    });
+
+    let ctx = LedgerCtx::new(ledger.clone());
+    let execution = saga.build(ctx);
+
+    match execution.start().await {
+        ExecutionResult::Completed(e) => {
+            assert_eq!(e.context().receipts.len(), 2);
+        }
+        other => panic!("expected Completed, got {:?}", result_debug(&other)),
+    }
+
+    assert_eq!(
+        ledger.balance(&account(1), &usd()).await.unwrap(),
+        Cent::from(40)
+    );
+    assert_eq!(
+        ledger.balance(&account(2), &usd()).await.unwrap(),
+        Cent::from(60)
+    );
+    assert_eq!(
+        ledger.balance(&external(), &usd()).await.unwrap(),
+        Cent::from(-100)
+    );
+}
+
+// Define a saga that will fail on the second step and trigger compensation
+legend! {
+    DepositAndOverspend<LedgerCtx, SagaError> {
+        deposit: DepositMovementStep,
+        pay: PayMovementStep,
+    }
+}
+
+#[tokio::test]
+async fn saga_compensation_on_failure() {
+    let ledger = setup_ledger().await;
+
+    // Deposit 50 then try to pay 100 -> pay fails -> deposit should be reversed
+    let saga = DepositAndOverspend::new(DepositAndOverspendInputs {
+        deposit: DepositInput {
+            to: account(1),
+            asset: usd(),
+            amount: Cent::from(50),
+            external: external(),
+        },
+        pay: PayInput {
+            from: account(1),
+            to: account(2),
+            asset: usd(),
+            amount: Cent::from(100), // more than available
+        },
+    });
+
+    let ctx = LedgerCtx::new(ledger.clone());
+    let execution = saga.build(ctx);
+
+    match execution.start().await {
+        ExecutionResult::Failed(_, _err) => {
+            // The deposit should have been compensated (reversed)
+            // Note: balances won't be exactly 0 because the deposit reversal
+            // creates new postings, but the net effect should be zero
+            assert_eq!(
+                ledger.balance(&account(1), &usd()).await.unwrap(),
+                Cent::ZERO
+            );
+            assert_eq!(
+                ledger.balance(&external(), &usd()).await.unwrap(),
+                Cent::ZERO
+            );
+        }
+        other => panic!("expected Failed, got {:?}", result_debug(&other)),
+    }
+}
+
+// Three-step saga
+legend! {
+    ThreeStepFlow<LedgerCtx, SagaError> {
+        deposit: DepositMovementStep,
+        pay_ab: PayMovementStep,
+        pay_bc: PayMovementStep,
+    }
+}
+
+#[tokio::test]
+async fn saga_three_steps_happy() {
+    let ledger = setup_ledger().await;
+
+    let saga = ThreeStepFlow::new(ThreeStepFlowInputs {
+        deposit: DepositInput {
+            to: account(1),
+            asset: usd(),
+            amount: Cent::from(100),
+            external: external(),
+        },
+        pay_ab: PayInput {
+            from: account(1),
+            to: account(2),
+            asset: usd(),
+            amount: Cent::from(60),
+        },
+        pay_bc: PayInput {
+            from: account(2),
+            to: account(3),
+            asset: usd(),
+            amount: Cent::from(30),
+        },
+    });
+
+    let ctx = LedgerCtx::new(ledger.clone());
+    let execution = saga.build(ctx);
+
+    match execution.start().await {
+        ExecutionResult::Completed(e) => {
+            assert_eq!(e.context().receipts.len(), 3);
+        }
+        other => panic!("expected Completed, got {:?}", result_debug(&other)),
+    }
+
+    assert_eq!(
+        ledger.balance(&account(1), &usd()).await.unwrap(),
+        Cent::from(40)
+    );
+    assert_eq!(
+        ledger.balance(&account(2), &usd()).await.unwrap(),
+        Cent::from(30)
+    );
+    assert_eq!(
+        ledger.balance(&account(3), &usd()).await.unwrap(),
+        Cent::from(30)
+    );
+}
+
+fn result_debug<Ctx, Err, Steps>(r: &ExecutionResult<Ctx, Err, Steps>) -> &'static str
+where
+    Ctx: Send + Sync,
+    Err: Send + Sync + Clone,
+    Steps: legend::hlist::InstructionList<Ctx, Err>,
+{
+    match r {
+        ExecutionResult::Completed(_) => "Completed",
+        ExecutionResult::Paused(_) => "Paused",
+        ExecutionResult::Failed(_, _) => "Failed",
+        ExecutionResult::CompensationFailed { .. } => "CompensationFailed",
+    }
+}

+ 173 - 0
doc/accounting-mapping.md

@@ -0,0 +1,173 @@
+# Accounting Mapping: Classical Double-Entry ↔ Kuatia
+
+Kuatia provides double-entry-style safety using a UTXO-style model. Value is
+held as signed postings, and every committed transfer must satisfy per-asset
+conservation. The accounting goal is the same as classical bookkeeping; the
+mechanical model is different:
+
+| Classical double-entry | Kuatia |
+|---|---|
+| `Σ debits = Σ credits` | `sum(consumed) == sum(created)` per asset |
+
+This page maps classical accounting vocabulary onto Kuatia's types and clears
+up the terms that are easy to conflate.
+
+The most important correction: in classical accounting a journal is the
+append-only book of original entry, while a journal entry is one committed
+accounting event. In Kuatia, the closest equivalent to the classical journal
+is the transfer log; the closest equivalent to a journal entry is a committed
+`Transfer`/`Envelope`. Kuatia's `Book` is neither. It is a transfer policy
+scope, not the accounting journal.
+
+## Core mapping
+
+| Classical accounting | Kuatia | Notes |
+|---|---|---|
+| **Journal** (book of original entry) | **Transfer log**, `TransferStore` of `EnvelopeRecord`s | Append-only, ordered source of truth for committed transfers. |
+| **Journal entry** (one balanced event) | Committed **`Transfer`** (intent) → **`Envelope`** (resolved) | One atomic accounting event. |
+| **Compound journal entry** | `Transfer` with multiple `Movement`s | One event touching many accounts/assets. |
+| **Entry line / leg** | **`Posting`** effect (often derived from a `Movement`) | A concrete account-level value fragment. A `Movement` is two-sided intent `{from, to, asset, amount}` that resolves into consumed/created postings, not a 1:1 debit/credit line. |
+| **Σ debits = Σ credits** | **Per-asset conservation** `sum(consumed) == sum(created)` | Enforced in `validate_and_plan`; `ConservationViolation` otherwise. |
+| **Ledger** (accounts + running balances) | **Accounts + active postings** | Balances are projections over `Active` postings, never stored. |
+| **Posting a transaction** (the verb) | **resolve + commit** (`Transfer` → `Envelope` → apply) | Confusing collision: in Kuatia a *posting* is a noun (a value fragment), not the act of recording. |
+| **Accounting book** | *no direct equivalent unless modeled separately* | Kuatia `Book` is **not** this. |
+| **Transfer policy scope** | **`Book`** | Gates which accounts/assets may participate. See [below](#where-book-fits-and-doesnt). |
+
+> A journal entry is one committed accounting event. In many accounting
+> texts this event is also called a transaction, a word that is overloaded
+> in a ledger library (database transaction, business transaction, atomic
+> transfer), so this doc prefers "committed accounting event."
+
+## A journal entry is multi-account
+
+Double-entry entries are inherently multi-account; that is the entire point.
+A minimal entry has two legs; a compound entry has more. So a Kuatia transfer
+touching many accounts is not a mismatch. It is a (compound) journal entry.
+
+Classical compound journal entry:
+
+```
+2026-06-26  Cash sale of goods
+  Dr  Cash ................. 115
+      Cr  Revenue ..........     100
+      Cr  Sales tax payable .      15
+```
+
+The equivalent business effects as a Kuatia transfer, multiple movements
+committed atomically:
+
+```rust
+let transfer = TransferBuilder::new()
+    .book(sales_book)
+    .pay(customer, revenue, usd, Cent::from(100))
+    .pay(customer, tax_payable, usd, Cent::from(15))
+    .build();
+// One Transfer → one Envelope → one EnvelopeRecord in the transfer log.
+```
+
+> Note: this is not a literal debit/credit translation. It shows the business
+> effects as movements. In a production POS model, cash, revenue, and tax
+> might be separate effects routed through system/offset accounts. (A literal
+> multi-hop `customer → cash → revenue` chain inside one transfer would
+> require spending a posting created earlier in the same envelope, which the
+> resolver does not do; it selects from already-committed postings.)
+
+Both are a single balanced event. In the classical entry, `Σ Dr (115) = Σ Cr
+(115)`. In Kuatia, the resolved `Envelope` satisfies per-asset conservation:
+`sum(consumed) == sum(created)` for USD.
+
+## One entry vs the journal: `Transfer`/`Envelope` vs the transfer log
+
+These differ in grain: one record vs. the collection of all records.
+
+- `Transfer` / `Envelope` = one record (one journal entry).
+  - `Transfer` is the intent: `{ movements: Vec<Movement>, book, user_data,
+    metadata }`. Callers express what should happen, not which postings.
+  - `Envelope` is the resolved form produced by `resolve()`: `{ consumes:
+    Vec<PostingId>, creates: Vec<NewPosting>, account_snapshots, book, … }`.
+    It names the concrete postings to spend and create.
+  - Committing one (`commit` / `commit_envelope`) returns a `Receipt {
+    transfer_id }` identifying the committed envelope, the `EnvelopeId`, which
+    is content-addressed (the double-SHA-256 of the canonical envelope bytes).
+- Transfer log = the accounting journal. The append-only, ordered sequence of
+  every committed envelope, persisted by `TransferStore` as `EnvelopeRecord {
+  envelope, receipt, created_at }`. Each transfer is one entry in it.
+
+> Transfer/Envelope : transfer log :: one journal entry : the journal.
+
+"The system is trivially auditable by replaying the transfer log" means:
+re-apply every `EnvelopeRecord` in order and you reconstruct all balances.
+There is no stored balance that can drift.
+
+## Two append-only logs: don't conflate "log"
+
+Kuatia keeps two distinct append-only sequences. Only the first is the
+accounting journal.
+
+| Log | Type | Records | Role |
+|---|---|---|---|
+| **Transfer log** | `TransferStore` → `EnvelopeRecord` | Full posting-level detail of each committed transfer | The accounting **journal**, the source of truth for balances. |
+| **Event log** | `EventStore` → `LedgerEvent` | High-level lifecycle notifications | Projections / subscribers; *not* the journal. |
+
+`LedgerEvent { seq, timestamp, kind }` carries a monotonic `seq` and a `kind`
+of `TransferCommitted | AccountCreated | AccountFrozen | AccountUnfrozen |
+AccountClosed`. It tells you that something happened; the transfer log tells
+you exactly which postings moved.
+
+## The UTXO wrinkle
+
+Classical ledgers post an entry by mutating each account's running balance.
+Kuatia is UTXO-style and posting-based, so the mechanism differs while the
+event grain is identical. Because postings are signed, debit/credit is not the
+native primitive; resolution works in terms of consuming and creating
+postings:
+
+- In a simple movement, the source side is resolved by consuming `Active`
+  postings from the source account, creating a change posting if the selected
+  postings exceed the amount.
+- The destination side is represented by newly created postings on the
+  destination account.
+- An account's balance is the sum of its `Active` postings, computed on
+  demand, never stored.
+
+So an entry line maps to a `Posting` effect, usually derived from a `Movement`
+(two-sided intent) that resolves into one or more postings (a created posting,
+consumed postings, and possibly change). The balancing rule is unchanged: per
+asset, `sum(consumed) == sum(created)`.
+
+## Where `Book` fits (and doesn't)
+
+`Book` is the one Kuatia concept with no classical counterpart. It is a
+transfer policy scope: it gates which accounts and assets may participate in a
+transfer (`BookPolicy { allowed_assets, allowed_flags, allowed_accounts }`).
+
+It is explicitly not:
+- the journal (that is the transfer log),
+- a journal entry (that is a `Transfer`/`Envelope`),
+- a balance partition (balances are global; a Book only gates participation).
+
+> Despite the name, a Kuatia `Book` must not be confused with an accounting
+> book. The accounting journal is the transfer log; `Book` is purely a policy
+> scope.
+
+See the [glossary](glossary.md#book) for the Book model and worked examples.
+
+## Quick reference
+
+| Classical accounting | Kuatia | Notes |
+|---|---|---|
+| Journal | Transfer log / `TransferStore<EnvelopeRecord>` | Append-only source of truth for committed transfers. |
+| Journal entry | Committed `Transfer` / `Envelope` | One atomic accounting event. |
+| Entry line / leg | `Posting` effect | A concrete account-level value fragment; movements are intent that resolve into postings. |
+| Compound journal entry | `Transfer` with multiple movements | One event touching many accounts/assets. |
+| Σ debits = Σ credits | Per-asset conservation | `sum(consumed) == sum(created)` per asset. |
+| Ledger | Accounts + active postings | Balances are projections over active postings. |
+| Posting a transaction (verb) | Resolve + commit | Avoid confusion: Kuatia `Posting` is a noun. |
+| Accounting book | No direct equivalent unless modeled separately | Kuatia `Book` is not this. |
+| Transfer policy scope | `Book` | Gates allowed accounts/assets. |
+| Proof a txn was recorded | `Receipt { transfer_id }` | Content-addressed `EnvelopeId`. |
+| Lifecycle notifications | Event log (`LedgerEvent`) | Separate from the transfer log. |
+
+In one line: Kuatia's transfer log is the accounting journal; each committed
+envelope is a journal entry; Kuatia's `Book` is a policy scope, not a journal
+or a balance partition.

+ 142 - 0
doc/accounts.md

@@ -0,0 +1,142 @@
+# Accounts
+
+## Overview
+
+An account is a versioned entity that owns postings. Balance is never
+stored: it is always computed from postings for a given (account, asset)
+pair. The ledger balance sums non-`Inactive` postings (`Active +
+PendingInactive`); the available balance sums only `Active` postings
+(excluding those reserved for an in-flight transfer). `balance()` returns
+the ledger balance.
+
+## Structure
+
+| Field | Type | Description |
+|-------|------|-------------|
+| `id` | `AccountId(i64)` | Stable identity, assigned at creation |
+| `version` | `u64` | Starts at 1, increments on every mutation |
+| `policy` | `AccountPolicy` | Balance floor rule (see below) |
+| `flags` | `AccountFlags` | Lifecycle flags (`FROZEN`, `CLOSED`) + user-defined (`USER_0` to `USER_7`) |
+| `book` | `BookId` | Book this account belongs to |
+| `user_data` | `UserData` | Fixed 28 bytes: `u128 + u64 + u32` for external refs |
+| `metadata` | `Metadata` | `BTreeMap<String, Vec<u8>>` for free-form data |
+
+## Policies
+
+Each account has a policy that controls what balance constraints apply:
+
+| Policy | Balance floor | Negative postings | CAS guard |
+|--------|--------------|-------------------|-----------|
+| `NoOverdraft` | `>= 0` | No | No |
+| `CappedOverdraft { floor }` | `>= floor` | Yes (down to floor) | Yes |
+| `UncappedOverdraft` | None | Yes (unbounded) | No |
+| `SystemAccount` | None | Yes | No |
+| `ExternalAccount` | None | Yes | No |
+
+An overdraft is represented as a negative posting (an offset position)
+assigned to the account to cover a shortfall. When an account's positive
+postings are insufficient for a debit, the resolve step consumes them all
+and creates a negative posting for the remainder. `NoOverdraft` accounts
+forbid this; validation rejects any transfer that would create a negative
+posting on a `NoOverdraft` account. `CappedOverdraft`'s floor bounds how
+negative the balance may go; `UncappedOverdraft`, `SystemAccount`, and
+`ExternalAccount` are unbounded.
+
+`CappedOverdraft`'s floor is re-validated as the last step before finalize
+writes (the finalize step re-loads balances and account versions and
+re-runs validation just before deactivating). This is the tightest
+best-effort: the check-to-write window is one step, not the whole saga. It
+is not strictly atomic. A concurrent commit in that last gap can still
+breach the floor (write-skew). Double-spend safety is unaffected. The
+reservation protocol (an atomic conditional `reserve_postings`) guarantees
+a posting cannot be consumed twice. See
+[accounting-mapping.md](accounting-mapping.md) and the ADR at
+[adr/0003-dumb-storage-saga-recovery.md](adr/0003-dumb-storage-saga-recovery.md).
+
+## Lifecycle
+
+Accounts follow a three-state lifecycle controlled by flags:
+
+```
+Created (v1) → Frozen (v2) → Unfrozen (v3) → Closed (v4)
+                  ↑               │
+                  └───────────────┘
+```
+
+| Operation | Precondition | Effect |
+|-----------|-------------|--------|
+| `freeze(id)` | Not closed | Sets `FROZEN` flag, increments version |
+| `unfreeze(id)` | Frozen | Clears `FROZEN` flag, increments version |
+| `close(id)` | Zero active postings | Sets `CLOSED` flag, increments version |
+
+- **Frozen** accounts reject all transfers (both debits and credits).
+- **Closed** accounts reject all transfers and cannot be reopened.
+- Closing requires zero active postings for all assets.
+
+## Append-Only Versioning
+
+Accounts are never modified in place. Each mutation appends a new version:
+
+```
+Version 1: { policy: NoOverdraft, flags: ∅ }         ← created
+Version 2: { policy: NoOverdraft, flags: FROZEN }     ← frozen
+Version 3: { policy: NoOverdraft, flags: ∅ }         ← unfrozen
+```
+
+The store enforces `version_new == version_current + 1`, preventing gaps or
+overwrites. The full history is queryable via `account_history(id)`.
+
+## Snapshot Pinning
+
+Transfers can carry `AccountSnapshotId` values: pairs of `(AccountId,
+snapshot_hash)` recording which account version the transfer was validated
+against.
+
+During validation, if snapshots are present, the current account state is
+hashed and compared. A mismatch produces `AccountVersionMismatch`,
+preventing TOCTOU races where an account is mutated between load and apply.
+
+The saga `commit()` path auto-populates snapshots when none are provided.
+
+## Balance Computation
+
+Balance for an (account, asset) pair is computed as:
+
+```
+balance(account, asset) = sum(p.value for p in postings
+                              where p.owner == account
+                              and   p.asset == asset
+                              and   p.status != Inactive)
+```
+
+There is no stored balance field. This eliminates drift between the balance
+and the underlying postings.
+
+## Account Types in Practice
+
+### Regular user accounts (`NoOverdraft`)
+
+Hold positive postings only. Cannot go negative. Used for end-user wallets,
+merchant accounts, etc.
+
+### System accounts (`SystemAccount`)
+
+Operational accounts representing issuance, sink, revenue, COGS, fees, or
+internal balancing. Can hold negative postings (offset positions, e.g. a
+liability when the account is the deposit counterparty). Used as the
+counterparty in deposits: the system account takes on a negative balance to
+offset the value credited elsewhere.
+
+### External accounts (`ExternalAccount`)
+
+Boundary accounts representing the outside world (banks, payment
+processors). They represent value entering and leaving the ledger boundary,
+and like system accounts they can hold negative postings (offset positions).
+
+### Credit accounts (`CappedOverdraft`)
+
+Accounts with a negative floor (e.g. credit lines). The floor is the maximum
+allowed overdraft. When the account's positive postings are insufficient for
+a debit, a negative posting is created to cover the shortfall, down to the
+floor. The floor is re-validated as the last step before finalize and is
+best-effort under concurrency (see above).

+ 145 - 0
doc/adr/0001-modified-utxo-signed-postings.md

@@ -0,0 +1,145 @@
+# Modified UTXO: value as signed postings, not mutable balances
+
+* Status: accepted
+* Authors: Cesar Rodas
+* Date: 2026-06-29
+* Targeted modules: `kuatia-types`, `kuatia-core`, `kuatia-storage`
+* Associated tickets/PRs: N/A
+
+## Context and Problem Statement
+
+A ledger must record value movements so that the result is auditable,
+supports multiple assets, behaves well under concurrency, and can
+represent overdraft or credit lines. How should value be represented at
+rest? The naïve answer, a mutable balance field per `(account, asset)`,
+throws away history and turns every account into a write-contended hot
+row. We want double-entry *safety* (`Σ debits = Σ credits`) without the
+brittleness of mutable running totals.
+
+## Decision Drivers
+
+* **Auditability**: the full financial state should be reconstructible
+  by replaying recorded events, with no "trust me" mutable totals.
+* **Multi-asset**: one model for many assets, not a balance column per
+  asset.
+* **Concurrency**: avoid a single hot balance row that serializes all
+  activity on an account.
+* **Conservation as a structural property**: make "nothing is created
+  or destroyed" a checkable invariant, not a convention.
+* **Overdraft / credit**: represent a negative position naturally,
+  bounded by an account policy.
+* **Embeddability**: pure, deterministic core logic with no database
+  arithmetic.
+
+## Considered Options
+
+#### Option 1: Mutable balance fields per `(account, asset)`
+
+A row holds the current balance; each transfer reads, mutates, and
+writes it.
+
+**Pros:**
+
+* Good, because balance reads are O(1): just read the column.
+* Good, because it is the most familiar model.
+
+**Cons:**
+
+* Bad, because history is lost: you cannot audit how a balance was
+  reached without a separate, independently-trusted journal.
+* Bad, because the balance row is a concurrency hot spot; every transfer
+  on an account contends on it.
+* Bad, because conservation is a convention enforced by application
+  code, not a property of the data.
+
+#### Option 2: Classic (Bitcoin-style) UTXO, non-negative outputs
+
+Value is held as immutable outputs that are consumed and created;
+outputs cannot be negative.
+
+**Pros:**
+
+* Good, because it is auditable (consume/create history) and avoids a
+  hot row.
+* Good, because conservation is naturally expressible
+  (`sum in == sum out`).
+
+**Cons:**
+
+* Bad, because non-negative outputs cannot represent overdraft or credit
+  lines.
+* Bad, because the Bitcoin model carries scripting/locking machinery
+  irrelevant to an accounting ledger.
+
+#### Option 3: Signed postings ("modified UTXO")
+
+Value is held as **signed postings**: an immutable, signed amount of one
+asset owned by one account, with a lifecycle
+(`Active → PendingInactive → Inactive`). A transfer **consumes**
+postings and **creates** postings; a posting may be **negative** (an
+"offset position") to represent an overdraft.
+
+**Pros:**
+
+* Good, because it keeps full history and is auditable by replaying the
+  transfer log; balances are *projections* over `Active` postings and
+  are never stored.
+* Good, because conservation is enforced directly:
+  `sum(consumed) == sum(created)` per asset on every committed transfer
+  (`validate_and_plan`, `ConservationViolation`).
+* Good, because it is multi-asset by construction (a posting names its
+  asset).
+* Good, because there is no hot balance row: different postings of the
+  same account can be touched independently, enabling UTXO-style
+  concurrency and future sharding.
+* Good, because overdraft is just a negative posting, bounded by account
+  policy (`NoOverdraft`, `CappedOverdraft { floor }`,
+  `UncappedOverdraft`, and so on).
+
+**Cons:**
+
+* Bad, because a balance is computed (sum over `Active` postings), not
+  read: a read cost the store must support efficiently.
+* Bad, because the word *posting* (a noun: a value fragment) collides
+  with the accounting verb "to post", a documented source of confusion.
+* Bad, because spending requires **posting selection** and change-making
+  (greedy largest-first), machinery a mutable-balance model would not
+  need.
+
+## Decision Outcome
+
+Chosen option: **Option 3, signed postings ("modified UTXO")**, because
+it is the only option that delivers auditability, a hot-row-free
+concurrency model, and natural overdraft, while making per-asset
+conservation a structural, checkable invariant. The "modification" over
+classic UTXO is precisely that postings may be negative (offset
+positions) and there is no scripting layer.
+
+### Positive Consequences
+
+* The transfer log (`TransferStore` of `EnvelopeRecord`s) is the
+  append-only source of truth; balances are derived, so they cannot
+  silently diverge.
+* Double-entry safety is enforced in the pure core and testable with
+  golden vectors, independent of any storage backend.
+* The posting lifecycle (Active/PendingInactive/Inactive) gives the saga
+  a place to reserve inputs without mutating balances. See ADR-0002 and
+  ADR-0003.
+
+### Negative Consequences
+
+* Balance queries sum over `Active` postings; the store indexes
+  `(owner, asset, status)` to keep this cheap, and all such arithmetic
+  is done in Rust with checked operations (never in SQL).
+* Posting selection / change-making is required on the spend path.
+* Terminology must be taught: see
+  [accounting-mapping.md](../accounting-mapping.md) for the
+  classical-accounting to Kuatia mapping and the noun/verb caveat.
+
+## Links
+
+* Refined by [ADR-0002](0002-saga-commit-pipeline.md) (how postings are
+  committed) and [ADR-0003](0003-dumb-storage-saga-recovery.md) (the
+  storage/commit contract).
+* Background: [accounting-mapping.md](../accounting-mapping.md),
+  [glossary.md](../glossary.md), [accounts.md](../accounts.md).

+ 134 - 0
doc/adr/0002-saga-commit-pipeline.md

@@ -0,0 +1,134 @@
+# Saga commit pipeline instead of a single/distributed transaction
+
+* Status: accepted
+* Authors: Cesar Rodas
+* Date: 2026-06-29
+* Targeted modules: `kuatia` (`ledger`, `saga`), `kuatia-storage`
+* Associated tickets/PRs: N/A
+
+## Context and Problem Statement
+
+Committing a transfer is not one write. It must consume input postings, create
+output postings, persist the transfer record, index it under every involved
+account, and emit events, consistently and recoverably if the process crashes
+mid-way. Kuatia is an embeddable library that should not require an external
+transaction coordinator, and it should compose multi-transfer workflows (e.g.
+an FX trade, a multi-leg settlement) without a global lock. How do we make a
+commit both consistent and crash-safe without a single all-encompassing
+transaction?
+
+## Decision Drivers
+
+* **Crash-safety**: a crash must never leave value created without a recorded
+  transfer (or vice-versa); recovery must converge.
+* **Composability**: several transfers should combine into one logical workflow
+  with rollback across the whole workflow.
+* **No external coordinator**: embeddable, no XA transaction manager or
+  separate service to operate.
+* **Avoid cross-shard / cross-resource transactions**: the model should not
+  depend on a single database transaction spanning everything, so future
+  sharding (UTXO-style) stays open.
+* **Keep storage dumb**: push domain decisions out of the storage layer (see
+  ADR-0003), which a monolithic store-side transaction works against.
+
+## Considered Options
+
+#### Option 1: One database transaction (monolithic commit)
+
+Do everything (deactivate, insert, store record, index, events) inside a single
+`BEGIN … COMMIT`.
+
+**Pros:**
+
+* Good, because it gives strict atomicity on a single database.
+* Good, because recovery is trivial: the DB rolls back a partial commit.
+
+**Cons:**
+
+* Bad, because it does not compose across multiple transfers/resources: a
+  multi-leg workflow cannot be one DB transaction without holding locks across
+  all of it.
+* Bad, because it does not span shards/resources; it pins the design to a
+  single transactional store.
+* Bad, because it pulls domain logic (guards, ownership, indexing decisions)
+  into the storage layer, the opposite of the dumb-storage goal (ADR-0003).
+
+#### Option 2: Two-phase commit / XA across resources
+
+Coordinate a distributed transaction over the resources involved.
+
+**Pros:**
+
+* Good, because it offers cross-resource atomicity.
+
+**Cons:**
+
+* Bad, because it needs a coordinator and XA-capable resources: heavy
+  operationally and at odds with "embeddable, no external services."
+* Bad, because 2PC is blocking: a coordinator failure can leave resources
+  locked.
+* Bad, because it is overkill for a library meant to drop into an application.
+
+#### Option 3: Saga with per-step compensation (the `legend` crate)
+
+Model the commit as a pipeline of small steps (reserve → finalize), each with
+a compensating action, driven by `legend`, with automatic retry and LIFO
+compensation. Crash-safety comes from a write-ahead record plus idempotent
+recovery rather than a global transaction.
+
+**Pros:**
+
+* Good, because steps compose: higher-level multi-transfer sagas combine the
+  same primitives with workflow-wide compensation.
+* Good, because no global or distributed transaction and no coordinator are
+  required; it suits an embeddable library.
+* Good, because reserving inputs (`Active → PendingInactive`, stamped with a
+  `ReservationId`) gives unconditional double-spend safety without locking
+  balances: a single atomic conditional update per posting.
+* Good, because it keeps storage dumb: each step issues simple instructions and
+  the saga owns the decisions (ADR-0003).
+
+**Cons:**
+
+* Bad, because a commit passes through brief intermediate states (postings
+  reserved/partially finalized) rather than flipping atomically.
+* Bad, because idempotency and end-state verification become the saga's
+  responsibility, not the database's.
+* Bad, because some cross-cutting guarantees (e.g. the CappedOverdraft floor)
+  can only be made best-effort without a commit-time atomic guard. See
+  ADR-0003.
+
+## Decision Outcome
+
+Chosen option: **Option 3, a saga commit pipeline** (`reserve → finalize`,
+via `legend`), because it is the only option that composes multi-transfer
+workflows, needs no external coordinator, and keeps storage dumb, while still
+providing crash-safety (through a write-ahead record + idempotent recovery)
+and unconditional double-spend safety (through the reservation protocol).
+
+### Positive Consequences
+
+* The same steps drive both a single commit and multi-transfer workflows, with
+  automatic retry and LIFO compensation on logical failure.
+* Reservation makes consumed-posting double-spend impossible without a global
+  lock, preserving the UTXO concurrency model from ADR-0001.
+* Storage stays a thin record keeper; the commit's correctness lives in one
+  testable place.
+
+### Negative Consequences
+
+* Crash-safety is not "the DB rolls it back" but write-ahead + roll-forward
+  recovery, which the saga must implement idempotently. See ADR-0003.
+* Within a commit there are observable intermediate posting states.
+* The exact recovery model and the best-effort guards are non-trivial and are
+  the subject of ADR-0003.
+
+## Links
+
+* Refined by [ADR-0003](0003-dumb-storage-saga-recovery.md): the storage
+  contract, count interpretation, and the durable phase-tracked recovery the
+  saga relies on.
+* Builds on [ADR-0001](0001-modified-utxo-signed-postings.md): the posting
+  lifecycle the reserve step uses.
+* Background: [architecture.md](../architecture.md),
+  [transfers.md](../transfers.md).

+ 146 - 0
doc/adr/0003-dumb-storage-saga-recovery.md

@@ -0,0 +1,146 @@
+# Dumb storage + durable saga recovery
+
+* Status: accepted
+* Authors: Cesar Rodas
+* Date: 2026-06-29
+* Targeted modules: `kuatia-storage`, `kuatia-storage-sql`,
+  `kuatia` (`ledger`, `saga`), `kuatia-core`
+* Associated tickets/PRs: N/A
+
+## Context and Problem Statement
+
+ADR-0002 chose a saga commit pipeline. Its finalize step still funneled
+everything into one monolithic store transaction,
+`CommitStore::commit_transfer`, which bundled ~8 responsibilities (deactivate,
+insert, store record, index both sides, CAS balance guards, account-version
+guards, reservation authorization, event append) into a single database
+transaction. Two problems surfaced: (1) the storage layer carried a lot of
+domain assumptions (it interpreted state, enforced guards, decided idempotency
+and error semantics), undercutting the "dumb record keeper" goal; and (2)
+crash recovery was designed (`SagaStore`, `legend` pause/resume) but never
+wired, so that single transaction was the *only* thing protecting against a
+half-applied commit. What is the right division of responsibility between the
+store and the saga, and how is a commit made crash-safe without that monolithic
+transaction?
+
+## Decision Drivers
+
+* **Dumb storage**: the store should follow instructions and report results,
+  not make domain decisions; correctness logic should live in one testable
+  place.
+* **Crash-safety without a global transaction**: recovery must converge from a
+  crash at any point and never commit something that did not validate or
+  consume postings it does not own.
+* **No silent divergence / no double-spend**: preserve the unconditional
+  double-spend guarantee from the reservation protocol.
+* **Testability**: per-primitive conformance tests and crash-injection recovery
+  tests over a small, well-defined surface.
+
+## Considered Options
+
+#### Option 1: Store is the atomic invariant boundary (`commit_transfer`)
+
+Keep one store method that atomically applies the whole commit and enforces all
+guards inside a single transaction.
+
+**Pros:**
+
+* Good, because atomicity and crash-safety on a single database are trivial.
+* Good, because all guards (floor, version, reservation) are re-checked
+  atomically with the write.
+
+**Cons:**
+
+* Bad, because the store is no longer dumb: it interprets state, decides
+  idempotency, enforces domain guards, and chooses error semantics.
+* Bad, because it pins crash-safety to one transactional store and does not
+  compose with the saga's multi-step / multi-resource ambitions (ADR-0002).
+* Bad, because the commit/abort logic is split between the saga and a large
+  store method, making correctness hard to locate and test.
+
+#### Option 2: Dumb storage + saga interpretation + durable recovery
+
+Storage write methods apply one update and return the **number of affected
+rows** (or an I/O error); they never interpret the count, decide state, enforce
+idempotency, or compensate. The saga interprets counts (full = continue;
+partial = error → compensate; zero = read state and continue only if this same
+envelope/reservation already applied it) and verifies end-states. Crash-safety
+is a **phase-tracked write-ahead record** (`PendingSaga {envelope, reservation,
+phase}`) plus **idempotent roll-forward** in `Ledger::recover()`.
+
+**Pros:**
+
+* Good, because the store is a thin record keeper with no domain logic; all
+  commit correctness lives in the saga and is unit-testable.
+* Good, because it composes with the saga model and does not require a single
+  all-encompassing transaction.
+* Good, because recovery is correct by construction: a `Reserving` saga is
+  re-run and **re-validated**; a `Finalizing` saga (already validated, owns its
+  postings) is rolled forward through a verified path; nothing commits unless
+  all consumed postings are confirmed `Inactive`.
+
+**Cons:**
+
+* Bad, because correctness now depends on the saga implementing idempotency and
+  end-state verification precisely (no DB safety net).
+* Bad, because guards that were re-checked atomically inside `commit_transfer`
+  (CappedOverdraft floor, freeze/close) become **best-effort**: re-validated as
+  the last step before the writes, but not strictly atomic with them.
+
+## Decision Outcome
+
+Chosen option: **Option 2, dumb storage + saga interpretation + durable
+recovery**, because it is the only option that keeps the storage layer dumb and
+composable with the saga while still providing crash-safety and unconditional
+double-spend safety. Concretely:
+
+* **Storage primitives** return `Result<u64, StoreError>`: `reserve_postings`,
+  `release_postings`, `deactivate_postings`, `insert_postings`,
+  `store_transfer(record, involved)`, and an idempotent `append_event`. The
+  monolithic `commit_transfer` / `CommitStore` / `CommitRequest` and the
+  semantic write-outcome error variants are removed.
+* **The saga owns the commit**: two steps, `reserve → finalize`. Validation
+  runs inside finalize, as its last action before writing, the tightest-window
+  floor and freeze/close re-check. `finalize_envelope` verifies every end-state
+  and never creates/stores unless **all** consumed postings are `Inactive`.
+* **One commit path**: `commit(transfer)` resolves then runs `commit_envelope`;
+  `reverse()` runs the same path; there is no separate raw/atomic entry point.
+* **Durable recovery**: a `PendingSaga` is written before any mutation
+  (`Reserving`), bumped to `Finalizing` at the point of no return. `recover()`
+  branches on the phase; the record is deleted only on commit or a clean
+  pre-finalize abort, and roll-forward (not rollback) means no orphaned
+  `PendingInactive` postings to reconcile.
+
+`legend`'s pause/resume is for external waits, not crash checkpoints, so
+durable recovery is this write-ahead layer around `legend`, not serialization
+of the in-flight execution.
+
+### Positive Consequences
+
+* The storage surface is small, uniform (counts, not verdicts), and covered by
+  a shared conformance suite that both the in-memory and SQL backends pass.
+* All commit/abort/recovery correctness is in the saga, exercised by
+  crash-injection tests (re-drive `Reserving`, roll forward a partial finalize,
+  abort+release when an account is frozen, refuse to double-spend a taken
+  posting).
+* Double-spend safety is unconditional (reservation protocol).
+
+### Negative Consequences
+
+* **CappedOverdraft floor and freeze/close are tightest best-effort, not
+  strictly atomic.** Finalize re-validates immediately before the writes (and
+  on the recovery path), shrinking the check-to-write window to one step, but
+  without folding the check into the write (a CAS) or per-account
+  serialization, a concurrent commit in that last sub-step gap can still breach
+  a floor. If hard floors are ever required, a follow-up ADR should add
+  per-`(account, asset)` serialization or a narrow commit-time CAS.
+* The saga must keep its idempotency and verification invariants exact; the DB
+  no longer provides a rollback safety net.
+
+## Links
+
+* Refines [ADR-0002](0002-saga-commit-pipeline.md) and supersedes its
+  monolithic `commit_transfer` finalize.
+* Builds on [ADR-0001](0001-modified-utxo-signed-postings.md).
+* Background: [architecture.md](../architecture.md) (commit pipeline, recovery,
+  the floor-under-concurrency section), [accounts.md](../accounts.md).

+ 119 - 0
doc/adr/0004-account-policies-overdraft-model.md

@@ -0,0 +1,119 @@
+# Account policies as the negative-posting and floor gate
+
+* Status: accepted
+* Authors: Cesar Rodas
+* Date: 2026-06-29
+* Targeted modules: `kuatia-types` (`AccountPolicy`), `kuatia-core` (`validate.rs`)
+* Associated tickets/PRs: N/A
+
+## Context and Problem Statement
+
+ADR-0001 makes value *signable*: a posting may be negative ("offset
+position"). But "may be negative" is not a per-account truth. A customer
+wallet must never go negative, a credit line may go negative down to a
+limit, and a system/boundary account is unbounded by design. Something
+must decide, per account, whether a negative posting is allowed and how
+far. Where does that rule live, and what shape does it take?
+
+## Decision Drivers
+
+* **Per-account semantics**: overdraft permission and floor differ by
+  account kind, not globally.
+* **Validation, not storage**: the rule belongs in the pure validator,
+  checked on every transfer.
+* **Closed, legible taxonomy**: a small set of named intents is easier to
+  reason about and audit than free-form flags.
+* **Boundary/system accounts**: deposits and withdrawals need an account
+  that may be arbitrarily negative (value entering or leaving the ledger)
+  without being a "bug."
+
+## Considered Options
+
+#### Option 1: A single `allow_negative: bool` + `floor: Option<Cent>`
+
+Two fields on the account control negativity and bound.
+
+**Pros:**
+
+* Good, because it is minimal and flexible.
+
+**Cons:**
+
+* Bad, because illegal combinations are representable (`allow_negative = false`
+  with a non-zero floor) and must be guarded.
+* Bad, because intent is implicit. A reader cannot tell a "customer
+  wallet" from a "boundary account" from the fields alone.
+* Bad, because future kinds (e.g. distinct system vs. external semantics)
+  have no natural home.
+
+#### Option 2: Per-asset policy on each account
+
+Policy varies by `(account, asset)`.
+
+**Pros:**
+
+* Good, because it allows an account to be NoOverdraft in one asset and a
+  credit line in another.
+
+**Cons:**
+
+* Bad, because it multiplies configuration and validation surface for a
+  need the domain rarely has.
+* Bad, because it complicates the account model (policy is no longer an
+  account property but an account×asset matrix).
+
+#### Option 3: A closed `AccountPolicy` enum per account
+
+`NoOverdraft`, `CappedOverdraft { floor }`, `UncappedOverdraft`,
+`SystemAccount`, `ExternalAccount`. Only `NoOverdraft` forbids negative
+postings; the other four permit them; `CappedOverdraft` bounds them at
+`floor`; the rest are unbounded.
+
+**Pros:**
+
+* Good, because each variant names an intent (customer wallet, credit line, fee
+  pool, value boundary), making accounts self-documenting and auditable.
+* Good, because illegal states are unrepresentable (a floor only exists on
+  `CappedOverdraft`).
+* Good, because validation maps cleanly: reject a negative posting on
+  `NoOverdraft`; enforce `floor` on `CappedOverdraft`; allow the rest.
+
+**Cons:**
+
+* Bad, because adding a new policy is an enum change (a deliberate,
+  reviewed event rather than a config tweak).
+* Bad, because per-asset variation is not expressible without modeling it
+  separately.
+
+## Decision Outcome
+
+Chosen option: **Option 3, a closed `AccountPolicy` enum per account**,
+because it makes account intent explicit and auditable, keeps illegal
+states unrepresentable, and maps directly onto the two validation rules
+(negative-posting permission and floor). `SystemAccount` and
+`ExternalAccount` give deposits and withdrawals a principled home (value
+boundaries that may run arbitrarily negative), rather than treating an
+unbounded negative as an exception.
+
+### Positive Consequences
+
+* Validation is a small match on the policy: `validate_and_plan` rejects a
+  negative posting on `NoOverdraft` and enforces the `CappedOverdraft` floor;
+  other policies skip the floor check.
+* Accounts document their own risk posture; an audit can read intent from
+  the type.
+
+### Negative Consequences
+
+* New account kinds require an enum (and validation) change. This is
+  intentional, but not a runtime or config change.
+* Per-asset overdraft, if ever needed, must be modeled on top of this
+  rather than for free.
+* The `CappedOverdraft` floor is only *best-effort* under concurrency, see
+  [ADR-0003](0003-dumb-storage-saga-recovery.md).
+
+## Links
+
+* Refines [ADR-0001](0001-modified-utxo-signed-postings.md) (signed postings).
+* Floor-under-concurrency tradeoff: [ADR-0003](0003-dumb-storage-saga-recovery.md).
+* Background: [accounts.md](../accounts.md).

+ 107 - 0
doc/adr/0005-intent-api-movements-vs-envelopes.md

@@ -0,0 +1,107 @@
+# Intent API: movements and transfers over raw envelopes
+
+* Status: accepted
+* Authors: Cesar Rodas
+* Date: 2026-06-29
+* Targeted modules: `kuatia-types` (`Movement`, `Transfer`, `TransferBuilder`), `kuatia` (`ledger::resolve`)
+* Associated tickets/PRs: N/A
+
+## Context and Problem Statement
+
+In a UTXO-style model (ADR-0001) a commit ultimately operates on concrete
+postings: which exact postings to consume, which to create, and the change.
+But making callers assemble that (pick inputs, compute change, balance per
+asset) is error-prone and couples application code to the ledger's internals.
+What should the *public* unit of intent be, and where does the translation to
+concrete postings happen?
+
+## Decision Drivers
+
+* **Ergonomics and safety**: callers should express *what* they want ("pay B
+  40 USD from A"), not hand-select UTXOs.
+* **Keep the UTXO model an implementation detail**: selection and
+  change-making should not leak into application code.
+* **Determinism and idempotency**: the same intent must resolve
+  deterministically, and re-submitting must be a no-op.
+* **Composability**: multi-account, multi-asset events (FX, compound entries)
+  must be expressible as one atomic intent.
+
+## Considered Options
+
+#### Option 1: Callers build `Envelope`s directly (raw UTXO API)
+
+The public API is the resolved form: callers choose consumed posting ids and
+construct created postings.
+
+**Pros:**
+
+* Good, because it is maximally explicit and gives full control (useful for FX
+  or hand-tuned flows).
+
+**Cons:**
+
+* Bad, because every caller re-implements posting selection, change-making, and
+  per-asset balancing, which is easy to get wrong.
+* Bad, because it couples application code to posting ids and the UTXO model.
+* Bad, because there is no natural high-level vocabulary
+  (pay/deposit/withdraw).
+
+#### Option 2: A two-layer API: intent (`Movement`/`Transfer`) to resolved (`Envelope`)
+
+Callers express intent as `Movement { from, to, asset, amount }` values grouped
+into a `Transfer` (via `TransferBuilder::pay/deposit/withdraw/movement`). The
+ledger's `resolve()` turns intent into a concrete `Envelope` by selecting
+postings and computing change; the envelope is what gets validated and
+committed. The raw envelope path remains available internally (e.g. for
+`reverse()` and hand-built multi-asset envelopes).
+
+**Pros:**
+
+* Good, because the common cases are one call and the UTXO mechanics stay
+  hidden.
+* Good, because intent is small and serializable, and `resolve` is
+  deterministic, so the resolved `Envelope` has a stable content id
+  (idempotency, ADR re: content-addressing).
+* Good, because compound and multi-asset events are just multiple movements
+  committed atomically; deposits and withdrawals are movements against a
+  boundary account.
+* Good, because the escape hatch (build an `Envelope` directly) still exists
+  for flows the intent vocabulary cannot express.
+
+**Cons:**
+
+* Bad, because there are two representations to understand
+  (intent vs. resolved) and the word "posting" is a noun here, not the
+  accounting verb.
+* Bad, because idempotency keys on the *resolved* envelope id, so resolution
+  must be deterministic for re-submits to dedupe, a property the resolver must
+  hold.
+
+## Decision Outcome
+
+Chosen option: **Option 2, the two-layer intent API**, because it keeps the
+UTXO model an internal detail, makes the common operations trivial and safe,
+and yields a deterministic resolved `Envelope` whose content id gives
+idempotency, while still allowing a pre-built envelope for advanced flows.
+
+### Positive Consequences
+
+* `TransferBuilder` offers `pay`/`deposit`/`withdraw` (preferred) over raw
+  `movement` construction; one `Transfer` can carry many movements committed
+  atomically.
+* `commit(transfer)` = `resolve` (read-only) then `commit_envelope`; the saga
+  and recovery operate on the resolved envelope (see ADR-0002/0003).
+* Deposits resolve to two movements that cancel to zero net debit on the system
+  account, so no posting selection is needed.
+
+### Negative Consequences
+
+* Two representations to document; the noun/verb "posting" caveat
+  (see [accounting-mapping.md](../accounting-mapping.md)).
+* Resolution must stay deterministic so re-submitting the same intent dedupes.
+
+## Links
+
+* Builds on [ADR-0001](0001-modified-utxo-signed-postings.md); committed by
+  [ADR-0002](0002-saga-commit-pipeline.md).
+* Background: [transfers.md](../transfers.md), [accounting-mapping.md](../accounting-mapping.md).

+ 126 - 0
doc/adr/0006-reservation-protocol-posting-lifecycle.md

@@ -0,0 +1,126 @@
+# Reservation protocol and the posting lifecycle
+
+* Status: accepted
+* Authors: Cesar Rodas
+* Date: 2026-06-29
+* Targeted modules: `kuatia-types` (`PostingStatus`, `ReservationId`),
+  `kuatia-storage`, `kuatia` (`saga`)
+* Associated tickets/PRs: N/A
+
+## Context and Problem Statement
+
+A commit must consume input postings exactly once, even when many commits run
+concurrently and the commit itself is a multi-step saga (ADR-0002) over a dumb
+store (ADR-0003) with no global transaction. Two sagas must never both spend
+the same posting, and a posting reserved by one saga must not be finalized or
+released by another. How is exclusive, recoverable ownership of inputs achieved
+without locking account balances?
+
+## Decision Drivers
+
+* **Double-spend safety:** a posting can be consumed at most once,
+  unconditionally.
+* **No hot-row locking:** preserve the UTXO concurrency of ADR-0001; do not
+  serialize on an account balance.
+* **Survives the saga's lifetime:** a reservation must hold across reserve →
+  validate → finalize and across a crash + recovery.
+* **Ownership:** only the saga that reserved a posting may finalize or release
+  it.
+* **Fits dumb storage:** expressible as single atomic conditional updates that
+  return affected-row counts (ADR-0003).
+
+## Considered Options
+
+#### Option 1: Database row locks (`SELECT … FOR UPDATE`) per posting
+
+Lock the posting rows for the duration of the commit.
+
+**Pros:**
+
+* Good, because it gives strict mutual exclusion within one database
+  transaction.
+
+**Cons:**
+
+* Bad, because it requires a transaction spanning the whole commit, which the
+  saga model deliberately avoids (ADR-0002/0003).
+* Bad, because held locks block other workers and do not survive a crash
+  (the lock is gone, but no record says the posting was claimed).
+* Bad, because it ties the design to a locking, transactional store.
+
+#### Option 2: Optimistic balance CAS per account
+
+Guard each commit with a compare-and-set on the account balance.
+
+**Pros:**
+
+* Good, because it avoids long-held locks.
+
+**Cons:**
+
+* Bad, because it serializes on a per-account balance (the hot row ADR-0001 set
+  out to avoid) and conflates "did this posting get spent" with "did the
+  balance change."
+* Bad, because it does not, by itself, record exclusive ownership of specific
+  inputs for recovery.
+
+#### Option 3: A three-state posting lifecycle with a reservation token
+
+A posting is `Active → PendingInactive → Inactive`.
+`reserve_postings(ids, rid)` flips `Active → PendingInactive` and stamps each
+with a `ReservationId`, as a single atomic conditional update
+(`… WHERE status = Active`). `release_postings` reverts
+`PendingInactive → Active` for the owning `rid`; finalize flips
+`PendingInactive (owned by rid) → Inactive`. The reservation id is durable
+(persisted with the write-ahead record, ADR-0003), and every later mutation is
+conditioned on ownership.
+
+**Pros:**
+
+* Good, because reservation is a single atomic conditional update. Two sagas
+  cannot both move the same `Active` posting to `PendingInactive`; the loser
+  sees zero rows affected. Double-spend safety is unconditional and lock-free.
+* Good, because the `PendingInactive` state plus `ReservationId` is durable
+  ownership that survives the multi-step saga and a crash, enabling recovery to
+  tell "reserved by us" from "taken by someone else."
+* Good, because it expresses cleanly over dumb storage (counts, not locks) and
+  keeps balances out of the critical section.
+* Good, because compensation is natural: release reverts the reservation.
+
+**Cons:**
+
+* Bad, because a posting carries lifecycle state and an optional reservation
+  column (more than an immutable UTXO).
+* Bad, because a reservation orphaned by a crash must be resolved by recovery
+  (roll-forward) rather than by a lock simply being dropped. ADR-0003 handles
+  this.
+
+## Decision Outcome
+
+Chosen option: **Option 3: the three-state posting lifecycle with a durable
+`ReservationId`**, because it is the only option that gives unconditional,
+lock-free double-spend safety and durable, recoverable ownership across a
+multi-step saga, while expressing as the atomic, count-returning instructions
+the dumb store provides.
+
+### Positive Consequences
+
+* `reserve_postings` is the concurrency gate; the saga reads its affected-row
+  count to know it won the reservation (ADR-0003's count interpretation).
+* Recovery distinguishes "reserved by this saga" / "already finalized by us"
+  from "taken by another transfer," which is what makes phase-tracked
+  roll-forward safe (ADR-0003).
+* Balances never enter the critical section. UTXO concurrency is preserved.
+
+### Negative Consequences
+
+* Postings are not pure immutable UTXOs; they carry `status` + `reservation`.
+* Crash-orphaned reservations are resolved by recovery, not by lock release.
+
+## Links
+
+* The primitive behind [ADR-0002](0002-saga-commit-pipeline.md) and the
+  recovery in [ADR-0003](0003-dumb-storage-saga-recovery.md).
+* Builds on [ADR-0001](0001-modified-utxo-signed-postings.md).
+* Background: [architecture.md](../architecture.md) ("Posting Lifecycle"),
+  [glossary.md](../glossary.md) ("Reservation protocol").

+ 111 - 0
doc/adr/0007-reversal-via-compensating-transfers.md

@@ -0,0 +1,111 @@
+# Reversal via compensating transfers, not deletion
+
+* Status: accepted
+* Authors: Cesar Rodas
+* Date: 2026-06-29
+* Targeted modules: `kuatia` (`ledger::reverse`)
+* Associated tickets/PRs: N/A
+
+## Context and Problem Statement
+
+Sometimes a committed transfer must be undone: a mistaken payment, a saga
+compensating a later failure. The ledger's whole value proposition is being
+auditable by replaying an append-only log (ADR-0001). How do we undo a
+transfer without breaking that property?
+
+## Decision Drivers
+
+* **Audit integrity**: history must never be rewritten; "what happened"
+  includes the mistake and its correction.
+* **Consistency with the model**: an undo should use the same machinery as a
+  normal commit, not a special back door.
+* **Idempotency**: undoing twice (e.g. a retried compensation) must not
+  double-undo.
+* **Composability**: saga compensation needs a reliable, reusable undo.
+
+## Considered Options
+
+#### Option 1: Delete or mutate the original postings/record
+
+Physically remove the created postings (and restore consumed ones), or edit the
+transfer record to "undo" it.
+
+**Pros:**
+
+* Good, because the post-undo state is "clean", as if it never happened.
+
+**Cons:**
+
+* Bad, because it destroys history: the ledger is no longer reconstructible by
+  replay, defeating ADR-0001.
+* Bad, because it is a privileged mutation path outside the normal commit
+  model, with its own concurrency and crash hazards.
+
+#### Option 2: A `void`/`reversed` flag on the transfer
+
+Mark the original as voided rather than deleting it.
+
+**Pros:**
+
+* Good, because the original row is preserved.
+
+**Cons:**
+
+* Bad, because balances now depend on interpreting flags, not just summing
+  postings: the projection is no longer "sum of `Active` postings."
+* Bad, because it adds mutable state to an otherwise append-only record and a
+  second code path for "is this transfer effective?"
+
+#### Option 3: A compensating transfer (an inverse envelope)
+
+`reverse(id)` loads the original transfer and builds an inverse envelope: it
+consumes the original's created postings and recreates the original's consumed
+ones as new postings, then commits it through the normal `commit_envelope`
+path. Nothing is deleted or mutated; the reversal is itself a content-addressed
+transfer.
+
+**Pros:**
+
+* Good, because history is fully preserved: both the original and its reversal
+  appear in the log; balances remain "sum of `Active` postings."
+* Good, because it reuses the exact commit path (reserve → validate →
+  finalize, recovery, idempotency) with no special undo machinery.
+* Good, because it is idempotent: the reversal envelope is deterministic and
+  content-addressed, so committing it twice returns the same receipt.
+* Good, because it gives saga compensation a reliable, uniform primitive.
+
+**Cons:**
+
+* Bad, because the post-undo state is not "as if it never happened": there are
+  now two transfers (intended), which a naive reader might find noisy.
+* Bad, because reversing is a real commit and is subject to the same validation
+  (e.g. it cannot reverse postings already consumed by a later transfer without
+  that surfacing as a normal failure).
+
+## Decision Outcome
+
+Chosen option: **Option 3: reversal as a compensating transfer**, because it
+is the only option that preserves the append-only, replayable audit log
+(ADR-0001) while reusing the normal, already-hardened commit and recovery
+path, and is idempotent by content-addressing.
+
+### Positive Consequences
+
+* `reverse()` is a thin wrapper over `commit_envelope`; saga finalize
+  compensation uses it to undo a committed step.
+* The audit trail shows the original and the correction; balances stay a pure
+  projection over `Active` postings.
+
+### Negative Consequences
+
+* Undo is visible as a second transfer (by design), not an erasure.
+* A reversal is subject to normal validation/availability: it can legitimately
+  fail if the world moved on, surfacing as an ordinary error.
+
+## Links
+
+* Preserves the audit model of
+  [ADR-0001](0001-modified-utxo-signed-postings.md); reuses
+  [ADR-0002](0002-saga-commit-pipeline.md) /
+  [ADR-0003](0003-dumb-storage-saga-recovery.md).
+* Background: [transfers.md](../transfers.md) ("Reversal").

+ 119 - 0
doc/adr/0008-conformance-tested-storage.md

@@ -0,0 +1,119 @@
+# Conformance-tested storage with an in-memory reference
+
+* Status: accepted
+* Authors: Cesar Rodas
+* Date: 2026-06-29
+* Targeted modules: `kuatia-storage` (`store_tests!`, `InMemoryStore`), `kuatia-storage-sql`
+* Associated tickets/PRs: N/A
+
+## Context and Problem Statement
+
+`Store` is a trait with several sub-traits and multiple backends (an
+in-memory store and a SQL store over SQLite/PostgreSQL), and, since
+ADR-0003, the saga's correctness depends on every backend behaving
+*identically*, down to the affected-row counts each primitive returns.
+How do we guarantee that two independent `Store` implementations have
+exactly the same observable semantics, and keep them in lock-step as the
+trait evolves?
+
+## Decision Drivers
+
+* **Semantic equivalence**: InMemory and SQL must return the same counts
+  and state transitions, or the saga's count interpretation (ADR-0003)
+  breaks on one backend.
+* **One source of truth for behavior**: the contract should be
+  executable, not just prose.
+* **Cheap to extend**: adding a backend, or a sub-trait method, should
+  make the obligations obvious.
+* **Fast feedback**: most semantics should be testable without a
+  database.
+
+## Considered Options
+
+#### Option 1: Bespoke tests per backend
+
+Each `Store` impl has its own hand-written test suite.
+
+**Pros:**
+
+* Good, because each suite can exploit backend specifics.
+
+**Cons:**
+
+* Bad, because the two suites drift: a behavior tested for one backend
+  may be untested (and divergent) for the other.
+* Bad, because there is no single, enforceable definition of "correct
+  `Store` behavior."
+
+#### Option 2: Trait documentation only (plus mocks)
+
+Specify semantics in doc comments; let callers mock the store.
+
+**Pros:**
+
+* Good, because it is low-effort up front.
+
+**Cons:**
+
+* Bad, because prose is not executable, so nothing prevents a backend
+  from violating it.
+* Bad, because mocks encode an *assumed* contract, which can itself be
+  wrong.
+
+#### Option 3: A shared conformance suite + an in-memory reference
+
+A `store_tests!` macro generates one suite of `async` tests; every
+backend (`InMemoryStore`, `SqlStore`) runs the same suite via its own
+factory. `InMemoryStore` doubles as the **executable reference** for the
+intended semantics. The convention is that every `Store` sub-trait
+method has a conformance test, so new methods force new tests.
+
+**Pros:**
+
+* Good, because both backends are held to the identical, executable
+  contract, including the affected-row counts ADR-0003 relies on.
+* Good, because `InMemoryStore` is a fast, dependency-free reference for
+  the semantics and a ready test double for higher layers.
+* Good, because adding a backend is "run the macro with your factory,"
+  and adding a sub-trait method is incomplete until its conformance test
+  exists.
+
+**Cons:**
+
+* Bad, because the macro suite must stay backend-agnostic (no
+  backend-specific assertions), so a few backend-specific behaviors
+  still need separate tests.
+* Bad, because the in-memory reference must be maintained to match the
+  SQL backend exactly, a second implementation to keep honest (which is
+  also the point).
+
+## Decision Outcome
+
+Chosen option: **Option 3, a shared `store_tests!` conformance suite
+with `InMemoryStore` as the executable reference**, because it is the
+only option that *enforces* semantic equivalence across backends (a hard
+requirement once the saga interprets counts, ADR-0003), gives a fast
+dependency-free reference/double, and makes the obligations for new
+backends and new methods explicit.
+
+### Positive Consequences
+
+* Both `InMemoryStore` and the SQL backend pass the same suite; a
+  divergence in counts or transitions fails the build.
+* New `Store` sub-trait methods come with conformance tests by
+  convention.
+* Higher layers (ledger, saga) test against the fast in-memory store.
+
+### Negative Consequences
+
+* The conformance suite must remain backend-neutral; genuinely
+  backend-specific behavior needs its own tests.
+* The in-memory reference is a second implementation that must track the
+  SQL one.
+
+## Links
+
+* Underpins [ADR-0003](0003-dumb-storage-saga-recovery.md) (equivalent
+  count semantics across backends).
+* Background: `crates/kuatia-storage/src/store_tests.rs` and the backend
+  test harnesses.

+ 155 - 0
doc/adr/0009-monetary-representation-integer-minor-units.md

@@ -0,0 +1,155 @@
+# Monetary amounts as integer minor units, scale outside the value
+
+* Status: accepted (refined by [ADR-0011](0011-swappable-money-backing.md))
+* Authors: Cesar Rodas
+* Date: 2026-06-29
+* Targeted modules: `kuatia-types` (`Cent`, `Amount`, `AssetId`), `kuatia-core`
+* Associated tickets/PRs: N/A
+
+## Context and Problem Statement
+
+Every posting carries an amount of one asset (ADR-0001), and the core invariant
+is per-asset conservation: `sum(consumed) == sum(created)` checked on every
+commit. That sum must be exact. A ledger that rounds is not a ledger. So the
+monetary type has to be exact under addition, subtraction and negation, deny
+silent overflow, hash deterministically for content-addressing, and still
+represent assets with different decimal precision (USD has 2, a token might
+have 8, JPY has 0). What type represents a stored monetary amount, and where
+does an asset's decimal scale live?
+
+## Decision Drivers
+
+* **Exactness**: addition/subtraction/negation must be exact; conservation and
+  floor checks cannot tolerate rounding error.
+* **No silent overflow**: an amount that overflows must surface as an error,
+  not wrap, since wrapping would forge or destroy value.
+* **Deterministic bytes**: amounts are hashed into the content-addressed
+  `EnvelopeId`, so the representation must serialize identically everywhere
+  (no locale, no float bit-pattern ambiguity).
+* **Multi-asset precision**: different assets have different decimal places,
+  but the stored value should stay one uniform type.
+* **No DB arithmetic**: all sums happen in Rust with checked operations; the
+  store never computes on amounts (CLAUDE.md, ADR-0003).
+
+## Considered Options
+
+#### Option 1: Floating point (`f64`)
+
+Store amounts as binary floating point.
+
+**Pros:**
+
+* Good, because it is built-in and handles fractional values without scaling.
+
+**Cons:**
+
+* Bad, because `f64` cannot represent most decimal fractions exactly
+  (`0.1 + 0.2`), so conservation sums drift and the `Σ consumed == Σ created`
+  check becomes approximate, which disqualifies it for a ledger.
+* Bad, because float bit-patterns and rounding modes make hashing and
+  cross-platform determinism fragile.
+
+#### Option 2: A decimal / big-integer library (`rust_decimal`, `i128`, rationals)
+
+Use a wider or decimal-aware numeric type that carries its own scale.
+
+**Pros:**
+
+* Good, because it offers larger range (`i128`) or scale-aware decimal math,
+  and can embed precision in the value itself.
+
+**Cons:**
+
+* Bad, because it pulls a non-trivial dependency (or wider columns) into the
+  most pervasive type, complicating storage layout and serialization for a
+  need the domain does not yet have.
+* Bad, because a value that carries its own scale invites mixing scales
+  silently and still must be pinned down for deterministic hashing.
+* Bad, because `i64` minor units already cover ~±9.2×10¹⁸ of the smallest
+  unit, ample for realistic balances, so the extra range is mostly unused
+  weight.
+
+#### Option 3: `Cent`, an `i64` newtype of minor units, scale held outside
+
+`Cent(i64)` is a private-field newtype holding an amount in the asset's
+**smallest unit** (cents, satoshis, …). It exposes only checked arithmetic
+(`checked_add`/`checked_sub`/`checked_neg`/`checked_sum` → `OverflowError`),
+serializes as big-endian bytes (`ToBytes`) for hashing, and is `Ord`/`Hash`.
+Decimal **scale is not stored on the value or the asset**: `AssetId(u32)` is an
+opaque identifier, and `Amount { decimals: u8 }` is a presentation-only
+parser/formatter (string ⇄ `Cent`) that is *never persisted*.
+
+**Pros:**
+
+* Good, because integer minor units are exact under +, −, negation, so
+  conservation and the overdraft floor are checked on exact integers.
+* Good, because the private field forbids confusing a monetary amount with a
+  plain `i64`, and the only arithmetic offered is checked, so overflow is a
+  `Result`, never a wrap.
+* Good, because big-endian bytes give one deterministic, locale-free
+  representation for content-addressing across backends and platforms.
+* Good, because keeping scale out of the stored value means the persisted
+  ledger is pure integers, with no per-row precision field to migrate, and
+  presentation concerns never touch the conservation math.
+* Good, because `i64` minor units are compact (fixed 8 bytes) and index/sum
+  cheaply in Rust.
+
+**Cons:**
+
+* Bad, because scale is a convention the *application* must apply
+  consistently. A `Cent` is meaningless without knowing its asset's decimals,
+  and nothing in the type stops formatting a satoshi amount with 2 decimals.
+* Bad, because `i64` caps a single amount/sum at ~±9.2×10¹⁸ minor units; an
+  asset with very high precision and very large supply could in principle
+  exceed it (surfaced as `OverflowError`, not a wrap, but a hard ceiling
+  nonetheless).
+* Bad, because fractional or proportional operations (interest, fees, FX rates)
+  are not closed over `Cent` and must be defined explicitly with an agreed
+  rounding policy when they are introduced.
+
+## Decision Outcome
+
+Chosen option: **Option 3, `Cent`, an `i64` newtype of minor units with scale
+held outside the value**, because it is the only option that makes the
+conservation sum *exact* and *deterministic* while keeping the stored ledger
+pure integers and overflow an explicit error. Scale lives in `Amount`
+(presentation) rather than on `Cent` or `AssetId` (storage), so precision is an
+edge concern at the application boundary and never leaks into the invariant
+math or the database schema. `i64` is chosen over `i128`/decimal because its
+range is more than adequate and its fixed width keeps the most pervasive type
+small and trivially serializable; widening later is a contained newtype change
+if a real asset ever needs it.
+
+### Positive Consequences
+
+* All monetary arithmetic is checked and exact; `validate_and_plan`'s
+  conservation and floor checks operate on integers that cannot silently round
+  or wrap.
+* `Cent`'s big-endian `ToBytes` feeds the content-addressed `EnvelopeId`
+  deterministically; the same transfer hashes identically on every backend.
+* The persisted amount is a plain `BIGINT`/`i64` with no precision metadata,
+  consistent with "no DB arithmetic" and with Rust-owned identity (ADR-0003).
+* `Amount` cleanly separates human input/output (with per-asset decimals)
+  from the stored, scale-free value.
+
+### Negative Consequences
+
+* Asset scale is an application-level convention; the type system does not
+  bind a `Cent` to its asset's decimal places, so callers must format/parse
+  with the right `Amount` for the asset.
+* `i64` is a hard magnitude ceiling per amount and per sum (overflow →
+  `Result`, never a wrap); a future high-precision/high-supply asset may force
+  widening the newtype.
+* Multiplicative/fractional operations (fees, interest, FX) need an explicit
+  rounding policy when added; they are deliberately not part of `Cent` today.
+
+## Links
+
+* Makes the conservation invariant of
+  [ADR-0001](0001-modified-utxo-signed-postings.md) exact, and feeds the
+  content-addressed id used for idempotency
+  (ADR-0005 / future "content-addressed transfer ids").
+* Floor checks that rely on exact integers:
+  [ADR-0004](0004-account-policies-overdraft-model.md).
+* Background: `crates/kuatia-types/src/lib.rs` (`Cent`, `Amount`, `AssetId`),
+  [glossary.md](../glossary.md).

+ 149 - 0
doc/adr/0010-event-stream-vs-transfer-log.md

@@ -0,0 +1,149 @@
+# A derived event stream alongside the transfer log
+
+* Status: accepted
+* Authors: Cesar Rodas
+* Date: 2026-06-29
+* Targeted modules: `kuatia-storage` (`EventStore`, `LedgerEvent`), `kuatia` (`saga`, `ledger`)
+* Associated tickets/PRs: N/A
+
+## Context and Problem Statement
+
+The transfer log is the append-only source of truth for value (ADR-0001):
+balances are projected by summing `Active` postings, and nothing else is
+authoritative. But applications need to *react* to the ledger (update a read
+model, notify a downstream service, drive an outbox), and not everything worth
+reacting to is a value transfer: accounts are also created, frozen, unfrozen
+and closed. Polling the transfer log misses the lifecycle events and forces
+every consumer to re-derive "what changed." Do we need a second log, what is
+authoritative, and what are its delivery/idempotency semantics?
+
+## Decision Drivers
+
+* **Observability without re-deriving**: consumers want a single ordered feed
+  of "what happened," not a diff of the transfer table.
+* **Non-transfer events exist**: account create/freeze/unfreeze/close are
+  meaningful occurrences that are not movements of value.
+* **One source of truth**: value authority must stay with the transfer log
+  (ADR-0001); a notification feed must not become a second, competing
+  authority.
+* **Plays with saga recovery**: a committed transfer can be re-driven by
+  recovery (ADR-0003), so the event emitted for it must not duplicate on
+  replay.
+* **Fits dumb storage, mostly**: append should be a simple primitive; any
+  deviation from "return a count" (ADR-0003) must be deliberate and justified.
+
+## Considered Options
+
+#### Option 1: No event log; consumers tail the transfer log
+
+Derive all reactions from the append-only transfer records.
+
+**Pros:**
+
+* Good, because there is only one log to store and reason about.
+
+**Cons:**
+
+* Bad, because account lifecycle changes (freeze/close) are not transfers and
+  have nowhere to appear, so a whole class of events is invisible.
+* Bad, because every consumer must re-implement "tail and interpret
+  transfers," coupling them to transfer internals and offering no uniform
+  subscription point.
+
+#### Option 2: Event log as the source of truth (full event sourcing)
+
+Make an event stream authoritative and fold balances from events.
+
+**Pros:**
+
+* Good, because there is a single authoritative narrative and reactions are
+  first class.
+
+**Cons:**
+
+* Bad, because it conflicts head-on with ADR-0001: balance would become a fold
+  over events rather than a sum of `Active` postings, and the UTXO/posting
+  model would be demoted to a projection of the event log.
+* Bad, because it creates two ways to be "true" (postings vs. events) that
+  must be kept consistent, exactly the divergence ADR-0001 set out to avoid.
+
+#### Option 3: A secondary, derived append-only event stream (outbox-style)
+
+Keep the transfer log authoritative. Add an `EventStore`: `LedgerEvent { seq,
+timestamp, kind }` where `kind` is `TransferCommitted { transfer_id }` or an
+account-lifecycle event. The store assigns a monotonic `seq` and exposes
+`append_event` + `get_events_since(after_seq, limit)`. `append_event` is
+**store-side idempotent** on `event_dedup_key`: replayable events
+(`TransferCommitted`, re-driven by saga recovery) dedup on the transfer id and
+return the existing `seq`; events with no natural identity (account lifecycle)
+return `None` and may recur. The feed is *derived*: it observes what the
+authoritative writes already decided.
+
+**Pros:**
+
+* Good, because value authority stays with the transfer log (ADR-0001); the
+  event stream is an observation feed, not a second source of truth.
+* Good, because lifecycle events that are not transfers finally have a home,
+  and consumers get one ordered, tailable feed (`get_events_since`) instead of
+  re-deriving from transfers.
+* Good, because dedup on the transfer id makes `TransferCommitted` survive saga
+  recovery's re-drive without emitting a duplicate: at-least-once upstream
+  becomes effectively-once for the events that carry a content identity.
+* Good, because it is an outbox the saga appends to as its last step,
+  decoupling downstream delivery from the commit's critical path.
+
+**Cons:**
+
+* Bad, because it is a second append-only log to persist, index by `seq`, and
+  retain.
+* Bad, because `append_event` deviates from the dumb-storage "return an
+  affected-row count" rule (ADR-0003): the store assigns `seq` and performs the
+  dedup, since both are storage-native and the key is content-based, not a
+  state-machine decision. A deliberate, narrow exception.
+* Bad, because lifecycle events have no dedup key and **may duplicate** on
+  retry/recovery, so consumers must tolerate at-least-once for those.
+* Bad, because `seq` orders events but is not a causal/transactional clock; it
+  is an emission order, not a serialization of value state.
+
+## Decision Outcome
+
+Chosen option: **Option 3, a derived, append-only event stream alongside the
+authoritative transfer log**, because it gives applications a uniform, ordered
+feed (including non-transfer lifecycle events) without challenging ADR-0001's
+"transfer log is the only authority on value." Making `append_event` idempotent
+on a content-based `event_dedup_key` is what lets the saga emit it safely under
+recovery re-drive; accepting that keyless lifecycle events may recur keeps the
+model honest about at-least-once delivery. The store-side `seq` assignment and
+dedup are a consciously scoped exception to dumb storage (ADR-0003), justified
+because both are intrinsic storage concerns rather than domain decisions.
+
+### Positive Consequences
+
+* One subscription point (`get_events_since`) for read models, outboxes and
+  notifications; consumers no longer reverse-engineer the transfer table.
+* `TransferCommitted` is effectively-once thanks to transfer-id dedup, aligning
+  with the saga's idempotent re-drive (ADR-0003) and content-addressed
+  transfers.
+* Balances remain a pure projection of `Active` postings; the event stream adds
+  no competing authority.
+
+### Negative Consequences
+
+* A second append-only log to store and retain (no pruning policy is defined
+  yet, a candidate future ADR alongside posting/log retention).
+* `append_event` is a documented exception to the count-returning storage
+  contract.
+* Account-lifecycle events may be delivered more than once; consumers must be
+  idempotent. `seq` is emission order, not a causal clock.
+
+## Links
+
+* Subordinate to [ADR-0001](0001-modified-utxo-signed-postings.md) (transfer
+  log is the source of truth; events are derived).
+* Idempotent emission under recovery:
+  [ADR-0003](0003-dumb-storage-saga-recovery.md); the `append_event` exception
+  is scoped against that ADR's dumb-storage contract.
+* Dedup key shares the content-identity logic behind reversal idempotency
+  ([ADR-0007](0007-reversal-via-compensating-transfers.md)).
+* Background: `crates/kuatia-storage/src/events.rs` (`LedgerEvent`,
+  `event_dedup_key`, `EventStore`).

+ 179 - 0
doc/adr/0011-swappable-money-backing.md

@@ -0,0 +1,179 @@
+# Swappable integer backing for monetary amounts, default i64
+
+* Status: accepted
+* Authors: Cesar Rodas
+* Date: 2026-06-30
+* Targeted modules: new `kuatia-money` crate (`Cent`, `Amount`,
+  `CentBacking`), `kuatia-types` (re-export, `ToBytes`),
+  `kuatia-storage-sql` (value column)
+* Associated tickets/PRs: N/A
+
+## Context and Problem Statement
+
+ADR-0009 fixed monetary amounts as `i64` minor units and noted that
+widening to a larger integer "is a contained newtype change if a real
+asset ever needs it." That contained change is now wanted: a runtime
+that defaults to `i64` for the common case but can be compiled with
+`i128` for assets whose precision and supply exceed `i64`'s ~±9.2×10¹⁸
+ceiling. At the same time the concrete width should stop leaking:
+`Cent::value() -> i64`, the 8-byte hash encoding, and the `BIGINT`
+column all hard-code the backing. How do we make the backing a
+swappable, hidden detail without threading a type parameter through
+every posting, movement and store method?
+
+## Decision Drivers
+
+* **Swappable, single choice per build**: i64 by default, i128 by a
+  compile-time switch; one money width per binary (a ledger does not
+  need two at once).
+* **Hidden width**: no public signature, serialized form, or DB column
+  should reveal whether the backing is 64- or 128-bit.
+* **Minimal churn**: postings, movements, validation, the store traits
+  and the saga must not gain a generic parameter; "clarity over
+  cleverness."
+* **Stable content addresses**: amounts feed the double-SHA256
+  `EnvelopeId`/`PostingId`; swapping the width must not silently rehash
+  the ledger.
+* **Backend reality**: Postgres and SQLite have no native 128-bit
+  integer; sqlx cannot bind/get `i128` for them, so the persisted form
+  must not be a native wide integer.
+* **Exact, safe math**: keep checked add/sub/neg/sum with overflow as an
+  error (ADR-0009).
+
+## Considered Options
+
+#### Option 1: Generic `Cent<B: CentBacking>`
+
+Parameterize the type over its backing and let callers pick.
+
+**Pros:**
+
+* Good, because multiple widths could coexist and the choice is explicit
+  at the type level.
+
+**Cons:**
+
+* Bad, because the parameter propagates into `Posting`, `NewPosting`,
+  `Movement`, `Envelope`, `Account` (via `CappedOverdraft`), every
+  builder, all of `kuatia-core`, the `Store` trait family and the saga.
+  That is large churn for no domain benefit, since a build uses exactly
+  one width.
+* Bad, because `Hash`/`Ord`/serde/content-addressing all become generic,
+  the opposite of "clarity over cleverness."
+
+#### Option 2: Non-generic `Cent(Backing)` + `CentBacking` trait + cargo-feature selector
+
+`Cent` stays a concrete newtype over a `Backing` type alias. A
+`CentBacking` trait carries the arithmetic, canonical-byte and string
+primitives, with impls for `i64` and `i128`. A cargo feature flips `type
+Backing` between them. The width never appears in a public signature:
+reads go through `to_string()`/parse and through a fixed-width canonical
+encoding; `value() -> i64` is removed.
+
+**Pros:**
+
+* Good, because swapping is "write the other impl, flip a feature":
+  `impl CentBacking for i128` plus `--features kuatia-money/i128`, with
+  no change to any downstream crate.
+* Good, because nothing downstream gains a type parameter; `Posting`, the
+  store traits and the saga are untouched.
+* Good, because the public surface (`to_string`, parse, checked math,
+  `Ord`, fixed-width canonical bytes) names no concrete integer type, so
+  the width is hidden.
+
+**Cons:**
+
+* Bad, because the backing is a workspace-global compile-time choice
+  (cargo feature unification), not a per-value one. That is acceptable,
+  and in fact the intent.
+* Bad, because the `value` column moving to text loses SQL-side numeric
+  ordering on amounts. That is already irrelevant, since all arithmetic
+  is in Rust and no query sorts or ranges on the amount.
+
+#### Option 3: Keep `i64`, just widen the column to `NUMERIC`
+
+Leave the Rust type as `i64` and only make storage wider.
+
+**Pros:**
+
+* Good, because it is the smallest change.
+
+**Cons:**
+
+* Bad, because it does not actually let the runtime compute in `i128`;
+  the in-memory ceiling is still `i64`. It solves none of the request.
+
+## Decision Outcome
+
+Chosen option: **Option 2, a non-generic `Cent(Backing)` newtype with a
+`CentBacking` trait and a cargo-feature selector**, in a new
+`kuatia-money` crate. It delivers the i64↔i128 swap as a second trait
+impl behind a feature flag, keeps the default at `i64`, and hides the
+width from every public and stored form, all without threading a generic
+through the ledger.
+
+Concretely:
+
+* **`kuatia-money` crate** (leaf, `serde` only) holds `Cent`,
+  `OverflowError`, `Amount`, `ParseAmountError`, the `CentBacking` trait,
+  `impl CentBacking for i64`/`i128`, and the `Backing` alias.
+  `kuatia-types` depends on it and re-exports (`pub use
+  kuatia_money::{Cent, Amount, …}`), so every existing
+  `kuatia_types::Cent` import keeps compiling.
+* **Selector:** `#[cfg(not(feature = "i128"))] pub type Backing = i64;` /
+  `#[cfg(feature = "i128")] pub type Backing = i128;`. Default backing is
+  `i64`.
+* **Hidden width:** `value() -> i64` is removed. The public surface is
+  `to_string()` (minor-unit string), `FromStr`, the
+  `From<i32/u32/u8/i8/i64>` literal constructors, checked math,
+  predicates and `Ord`. Serde for `Cent` is hand-written to
+  (de)serialize the **string** form, so no serialized form reveals the
+  width.
+* **Canonical bytes for hashing are fixed at 16 bytes** (sign-extended
+  big-endian), independent of the backing, so an i64 amount and the same
+  i128 amount hash identically and swapping the backing does not change
+  any `EnvelopeId`/`PostingId`. `CANONICAL_VERSION` is bumped 1→2 to mark
+  the new encoding.
+* **Storage serializes to string:** the `value` column becomes `TEXT`;
+  the codec binds `cent.to_string()` and reads it back with `FromStr`.
+  This both hides the width and avoids native 128-bit integers, which
+  Postgres/SQLite lack.
+
+### Positive Consequences
+
+* The default build is behaviorally identical to ADR-0009's `i64`
+  ledger; switching to `i128` is one cargo feature and needs no source
+  edits beyond the (already-written) second trait impl.
+* No public signature, serialized blob, or column type names the backing
+  integer; the width is an internal detail.
+* Content addresses are stable across the swap, because the hash preimage
+  width is fixed.
+* All arithmetic stays exact and checked; overflow remains an
+  `OverflowError`.
+
+### Negative Consequences
+
+* The backing is chosen workspace-wide at compile time, not per value
+  (cargo feature unification).
+* Amounts are stored as text, so the database cannot order or
+  range-filter on the amount. This is a non-issue here, since amount
+  math is Rust-only and no query depends on SQL ordering of the value.
+* This supersedes ADR-0009's "persisted amount is a plain `BIGINT`"
+  consequence; the persisted form is now text. Per project convention
+  there are no migrations pre-release: `001_init.sql` is edited in place
+  and the database recreated.
+
+## Links
+
+* Refines [ADR-0009](0009-monetary-representation-integer-minor-units.md)
+  (keeps integer minor units; makes the width swappable and hidden, and
+  changes the persisted form from `BIGINT` to text).
+* Feeds the content-addressed id of
+  [ADR-0001](0001-modified-utxo-signed-postings.md) /
+  [ADR-0005](0005-intent-api-movements-vs-envelopes.md); fixed-width
+  canonical bytes keep ids stable.
+* Floor checks on exact integers:
+  [ADR-0004](0004-account-policies-overdraft-model.md).
+* Background: `crates/kuatia-money/src/lib.rs` (new),
+  `crates/kuatia-types/src/lib.rs`,
+  `crates/kuatia-storage-sql/src/lib.rs`.

+ 60 - 0
doc/adr/README.md

@@ -0,0 +1,60 @@
+# Architecture Decision Records
+
+Significant, hard-to-reverse design decisions for Kuatia, captured so the
+*why* survives. New ADRs follow [`template.md`](template.md) (MADR-style:
+context → drivers → considered options with pros/cons → decision outcome
+→ consequences → links). Numbering is sequential; an ADR is never edited
+to reverse a decision. Instead, a new ADR supersedes it.
+
+## Index
+
+| ADR | Title | Status | Summary |
+|-----|-------|--------|---------|
+| [0001](0001-modified-utxo-signed-postings.md) | Modified UTXO: value as signed postings | accepted | Value is signed postings (negative = "offset positions"), not mutable balances; conservation is structural; balances are projections. |
+| [0002](0002-saga-commit-pipeline.md) | Saga commit pipeline | accepted | Commit is a compensating saga (`reserve → finalize`), not a single/distributed transaction: composable, coordinator-free, crash-recoverable. |
+| [0003](0003-dumb-storage-saga-recovery.md) | Dumb storage + durable saga recovery | accepted | Storage returns affected-row counts and makes no decisions; the saga owns interpretation/idempotency; crash-safety is phase-tracked write-ahead + roll-forward. Refines 0002. |
+| [0004](0004-account-policies-overdraft-model.md) | Account policies & overdraft model | accepted | A closed `AccountPolicy` enum per account gates negative postings + floor; intent is explicit, illegal states unrepresentable. Refines 0001. |
+| [0005](0005-intent-api-movements-vs-envelopes.md) | Intent API: movements vs. envelopes | accepted | Callers express `Movement`/`Transfer` intent; `resolve()` produces the concrete `Envelope`. UTXO mechanics stay internal; idempotency keys on the resolved id. |
+| [0006](0006-reservation-protocol-posting-lifecycle.md) | Reservation protocol & posting lifecycle | accepted | `Active → PendingInactive → Inactive` + a durable `ReservationId` give lock-free, recoverable, exclusive ownership of inputs. The primitive behind 0002/0003. |
+| [0007](0007-reversal-via-compensating-transfers.md) | Reversal via compensating transfers | accepted | Undo is an inverse envelope committed through the normal path (never deletion/mutation), preserving the append-only audit log. |
+| [0008](0008-conformance-tested-storage.md) | Conformance-tested storage | accepted | One `store_tests!` suite every backend must pass, with `InMemoryStore` as the executable reference; enforces the equal count semantics 0003 relies on. |
+| [0009](0009-monetary-representation-integer-minor-units.md) | Monetary amounts as integer minor units | accepted | `Cent` is an `i64` newtype of minor units with only checked arithmetic; scale lives in the presentation-only `Amount`, not on the stored value or asset. Makes 0001's conservation exact. |
+| [0010](0010-event-stream-vs-transfer-log.md) | Derived event stream vs. transfer log | accepted | A secondary append-only `EventStore` feed (outbox-style) for transfer + account-lifecycle events; transfer log stays authoritative. `append_event` is idempotent on a content key, a scoped exception to 0003. |
+| [0011](0011-swappable-money-backing.md) | Swappable integer backing for money, default i64 | accepted | `Cent` moves to a `kuatia-money` crate over a `CentBacking` trait; the i64↔i128 width is a cargo feature, hidden from the API, stored as text. Refines 0009. |
+
+## Recommended future ADRs
+
+Real decisions whose rationale lives in the code/docs but is not yet
+captured as an ADR, roughly in priority order:
+
+1. **Content-addressed transfer ids, and rejecting a sequential hash
+   chain**: `EnvelopeId = double-SHA-256(canonical bytes)` for idempotency
+   + tamper evidence, and why a per-transfer hash chain was rejected (a
+   concurrency bottleneck). See the "No Sequential Hash Chain" section of
+   `architecture.md`.
+2. **Pure core / async layer split**: a zero-IO, deterministic
+   `kuatia-core` (validation, selection, hashing; golden-vector testable)
+   vs. the async storage + saga layer.
+3. **Rust-generated ids (`AutoId`), no `AUTOINCREMENT`/`SERIAL`**: the
+   application owns identity (snowflake-style `i64`), enabling future
+   sharding without DB coordination.
+4. **Append-only, versioned accounts + snapshot pinning**: accounts are
+   never modified in place; snapshot hashes guard against TOCTOU between
+   load and apply.
+5. **All arithmetic in Rust, never in SQL**: no `SUM`/`MAX`/etc. on
+   monetary values; the storage layer stays a dumb record keeper.
+6. **`Book` is a transfer-policy scope, not the accounting journal**: the
+   naming/modeling decision and why it is easy to conflate
+   (`accounting-mapping.md`).
+7. **Posting proliferation & consolidation**: greedy largest-first
+   selection (ADR-0001/0005) fragments balances into ever-smaller change
+   postings; whether and how to consolidate, and what to do with dust, is
+   undecided.
+8. **Retention / pruning of `Inactive` postings and the append-only
+   logs**: both the transfer log and the derived event stream (ADR-0010)
+   grow without bound; archival/retention is deferred and currently a
+   conscious omission.
+9. **Read/projection consistency model**: a balance is a non-transactional
+   sum over `Active` postings, so a read concurrent with a commit is
+   eventually consistent; the read-side guarantee is implied but never
+   stated.

+ 62 - 0
doc/adr/template.md

@@ -0,0 +1,62 @@
+# [short title of solved problem and solution]
+
+* Status: [proposed | rejected | accepted | deprecated | … | superseded by ADR-1234]
+* Authors: [list everyone who authored the decision]
+* Date: [YYYY-MM-DD when the decision was last updated]
+* Targeted modules: [which crate or module does this change target]
+* Associated tickets/PRs: [PR/issue links]
+
+## Context and Problem Statement
+
+[Describe the context and problem statement, e.g., in free form using two
+to three sentences. You may want to articulate the problem in form of a
+question.]
+
+## Decision Drivers <!-- optional -->
+
+* [driver 1, e.g., a force, facing concern, …]
+* [driver 2, e.g., a force, facing concern, …]
+* … <!-- numbers of drivers can vary -->
+
+## Considered Options <!-- numbers of options can vary -->
+
+#### [Option 1]
+
+[example | description | pointer to more information | …]
+
+**Pros:**
+
+* Good, because [argument …]
+
+**Cons:**
+
+* Bad, because [argument …]
+
+#### [Option 2]
+...
+
+#### [Option 3]
+...
+
+## Decision Outcome
+
+Chosen option: "[option 1]", because [justification. e.g., only option,
+which meets k.o. criterion decision driver | which resolves force force | …
+| comes out best (see below)].
+
+### Positive Consequences <!-- optional -->
+
+* [e.g., improvement of quality attribute satisfaction, follow-up decisions
+  required, …]
+* …
+
+### Negative Consequences <!-- optional -->
+
+* [e.g., compromising quality attribute, follow-up decisions required, …]
+* …
+
+## Links <!-- optional -->
+
+* [Link type] [Link to ADR]
+  <!-- example: Refined by [ADR-0005](0005-example.md) -->
+* … <!-- numbers of links can vary -->

+ 430 - 0
doc/architecture.md

@@ -0,0 +1,430 @@
+# Architecture Decisions
+
+## UTXO (Unspent Transaction Output)-Style Postings
+
+Value is stored as **postings**: signed amounts of a single asset owned by
+exactly one account. A positive posting is value controlled by the account; a
+negative posting is an offset position (issuance, external flow, or system
+balancing).
+
+Account balance = sum of non-`Inactive` postings (`Active + PendingInactive`)
+for that (account, asset) pair. There is no mutable balance field to drift out
+of sync.
+
+Consumed postings are marked inactive but never deleted, preserving a full
+audit trail.
+
+## Pure Core / Async Layer Separation
+
+```mermaid
+graph LR
+    subgraph "kuatia-core (pure, sync, no IO)"
+        V[validate_and_plan]
+        S[select_postings]
+        H[hash / transfer_id]
+        T[Types & ToBytes]
+    end
+    subgraph "kuatia (async, IO)"
+        L[Ledger]
+        ST[Store sub-traits]
+        SG[Saga steps]
+    end
+    L --> V
+    L --> S
+    L --> ST
+    SG --> L
+    SG --> ST
+```
+
+**kuatia-core** contains all validation logic with no IO, no async runtime, and
+near-zero dependencies. It can be tested with golden vectors, replayed
+deterministically, and embedded in `no_std` environments.
+
+**kuatia** adds the async `Store` trait (used as `dyn Store` via trait objects)
+and composes the saga commit pipeline. The `Ledger` struct is non-generic: it
+holds an `Arc<dyn Store>`, which allows the `legend!` macro to define saga
+types with concrete `LedgerCtx`.
+
+This separation keeps the auditable heart of the system deterministic and
+independently testable.
+
+## Store Sub-Trait Architecture
+
+The `Store` trait is a composite of focused sub-traits, each responsible for a
+single domain. Every write method is a **dumb instruction**: it applies one
+update and returns the number of affected rows (or an I/O error). It never
+interprets the count, decides state, enforces idempotency, or compensates. The
+saga does.
+
+```mermaid
+classDiagram
+    class AccountStore {
+        +get_account(id)
+        +get_accounts(ids)
+        +create_account(account)
+        +append_account_version(account)
+        +get_account_history(id)
+        +list_accounts()
+    }
+    class PostingStore {
+        +get_postings(ids)
+        +get_postings_by_account(account, asset?, status?)
+        +reserve_postings(ids, reservation) u64
+        +release_postings(ids, reservation) u64
+        +deactivate_postings(ids, reservation?) u64
+        +insert_postings(postings) u64
+    }
+    class TransferStore {
+        +get_transfer(id)
+        +store_transfer(record, involved) u64
+        +get_transfers_for_account(account)
+        +query_transfers(query)
+    }
+    class SagaStore {
+        +save_saga(id, data)
+        +list_pending_sagas()
+        +delete_saga(id)
+    }
+    class EventStore {
+        +append_event(event)
+        +get_events_since(after_seq, limit)
+    }
+    class BookStore {
+        +create_book(book)
+        +get_book(id)
+        +list_books()
+    }
+    class Store {
+        <<composite>>
+    }
+    Store --|> AccountStore
+    Store --|> PostingStore
+    Store --|> TransferStore
+    Store --|> SagaStore
+    Store --|> EventStore
+    Store --|> BookStore
+```
+
+There is no single atomic commit boundary. A commit is a sequence of the dumb
+primitives above (`reserve_postings`, `deactivate_postings`, `insert_postings`,
+`store_transfer`, `append_event`), each its own atomic update and each
+idempotent. The saga sequences them and interprets their counts; a crash
+mid-sequence is completed by roll-forward recovery (see below).
+
+The store only persists and reads. All domain logic (balance computation,
+validation, policy enforcement, and the interpretation of primitive counts)
+lives in the Ledger/saga and `kuatia-core`.
+
+## Saga Commit Pipeline
+
+Every commit is the envelope saga. `commit(transfer)` resolves the intent into
+a concrete envelope (read-only), then runs `commit_envelope`, which persists a
+write-ahead `PendingSaga` record (phase `Reserving`) and drives **two** steps.
+Validation lives inside the finalize step so it runs as late as possible,
+immediately before the writes.
+
+```mermaid
+sequenceDiagram
+    participant C as Caller
+    participant L as Ledger
+    participant R as ReserveStep
+    participant F as FinalizeStep
+    participant S as Store
+
+    C->>L: commit(transfer) → resolve → commit_envelope(envelope)
+    L->>S: save_saga(PendingSaga{envelope, reservation, Reserving})
+    L->>R: execute
+    R->>S: reserve_postings(ids, rid) → count
+    Note over R: interpret count (full / partial / zero+read)
+
+    L->>F: execute (finalize_envelope)
+    F->>S: load + validate_and_plan() [last-step floor / freeze-close re-check]
+    F->>S: save_saga(... Finalizing)  [point of no return]
+    F->>S: deactivate_postings(consumed, rid) → verify all Inactive
+    F->>S: insert_postings(created) → verify exist
+    F->>S: store_transfer(record, involved) → verify transfer exists
+    F->>S: append_event(committed)
+    F-->>L: Receipt
+    L->>S: delete_saga(...)
+    L-->>C: Receipt
+```
+
+On in-process failure before the point of no return, legend compensates in LIFO
+order (finalize is a no-op if nothing committed; reserve runs
+`release_postings`). Once the finalize step has marked the saga `Finalizing`
+and begun deactivating, the half-applied commit is **rolled forward** by
+recovery
+rather than compensated (see below).
+
+## Durable Crash Recovery
+
+There is no single atomic transaction, so crash-safety comes from a
+phase-tracked write-ahead record plus idempotent roll-forward.
+`commit_envelope` persists a `PendingSaga {envelope, reservation, phase}` via
+`SagaStore` **before** the saga mutates anything (`phase = Reserving`); the
+finalize step bumps it to `Finalizing` after validation passes and just before
+the consumed postings start turning `Inactive`. The record is deleted only when
+the transfer is committed or the commit was cleanly abandoned before finalize.
+
+`Ledger::recover()` (call on startup) re-completes any surviving pending saga,
+**branching on the persisted phase** so it never commits something that did not
+validate or consume postings it does not own:
+
+```mermaid
+graph TD
+    A[get_transfer?] -->|exists| Z[delete record, done]
+    A -->|missing| P{phase}
+    P -->|Reserving| RR[re-run saga: reserve + finalize]
+    RR -->|re-validates; aborts cleanly if taken/frozen| Z
+    P -->|Finalizing| FF[finalize_envelope: roll forward, verified]
+    FF --> Z
+```
+
+- A **`Reserving`** saga had not necessarily validated, so recovery re-runs the
+  real saga, which re-reserves and **re-validates** against current state. If
+  the postings were taken by another transfer, or an account was frozen, it
+  aborts cleanly (nothing commits) and the record is deleted.
+- A **`Finalizing`** saga had already validated and owns its postings (it
+  reached the point of no return), so recovery rolls it forward through the
+  verified `finalize_envelope`, which checks every end-state and only
+  creates/stores once **all** consumed postings are confirmed `Inactive` (the
+  double-spend guard).
+
+Recovery is roll-forward, not rollback, so the reservation protocol never
+leaves orphaned `PendingInactive` postings for a separate reconciliation pass.
+
+`reverse()` builds a reversal envelope and runs the same `commit_envelope`
+path. There is no separate raw/atomic entry point.
+
+## Content-Addressed Transfers
+
+`EnvelopeId` is the double-SHA-256 of a transfer's canonical binary
+serialization. This serves two purposes:
+
+- **Idempotency**: committing the same transfer twice returns the cached
+  receipt instead of applying it again.
+- **Tamper evidence**: any modification to a transfer's data changes its ID.
+
+All domain types implement deterministic binary serialization (`ToBytes` trait)
+using big-endian encoding with a version prefix (`CANONICAL_VERSION = 1`).
+
+## Append-Only Account Versioning
+
+Accounts are never modified in place. Each account mutation (freeze, unfreeze,
+close, or a policy/flags change) appends a new snapshot with an incremented
+`version` field (starts at 1 on creation). Note that transfers do **not** bump
+account versions: balances are derived from postings, not stored on the
+account.
+
+The store enforces that each new version is exactly `current + 1`, preventing
+gaps or overwrites. The full version history is queryable via
+`account_history()`.
+
+## Account Snapshot Pinning
+
+Transfers can carry `AccountSnapshotId` values: pairs of
+`(AccountId, snapshot_hash)` recording which account versions the transfer was
+validated against.
+
+During validation, if snapshots are provided, the current account state is
+hashed and compared. A mismatch produces `AccountVersionMismatch`, preventing
+TOCTOU (Time-Of-Check to Time-Of-Use) races where an account is mutated between
+load and apply.
+
+The `commit()` convenience method auto-populates snapshots when none are
+provided.
+
+## Per-Asset Conservation
+
+The conservation invariant is: for each asset, the sum of consumed posting
+values must equal the sum of created posting values.
+
+Conservation boundaries are **per-asset only**. The `book` field on transfers
+and accounts is a transfer policy scope (which accounts/assets may
+participate). It does not affect conservation enforcement, and it does not
+partition balances.
+
+## Account Policies
+
+Each account has a policy controlling its balance floor and whether it may hold
+negative postings:
+
+| Policy | Balance floor | Negative postings |
+|--------|--------------|-------------------|
+| `NoOverdraft` | `>= 0` | No |
+| `CappedOverdraft { floor }` | `>= floor` | Yes (down to floor) |
+| `UncappedOverdraft` | None | Yes (unbounded) |
+| `SystemAccount` | None | Yes |
+| `ExternalAccount` | None | Yes |
+
+An overdraft is a **negative posting** assigned to the account to cover a
+shortfall. Only `NoOverdraft` forbids negative postings; validation rejects a
+negative posting on a `NoOverdraft` account. `CappedOverdraft`'s floor (checked
+in validation) bounds the negative balance; the other policies are unbounded.
+
+## The CappedOverdraft Floor Under Concurrency
+
+`CappedOverdraft` accounts have a balance floor that is not backed by the UTXO
+model alone: two concurrent transfers could each pass validation but together
+push the balance below the floor (write-skew).
+
+Under the dumb-storage model the floor (and the freeze/close snapshot check) is
+re-validated **as the last thing the finalize step does before it writes**: the
+finalize step re-loads balances and account versions and re-runs
+`validate_and_plan` immediately before `deactivate_postings`. This is the
+tightest best-effort: the check-to-write window is one step, not the whole
+saga's lifetime, and it also runs on the recovery path. It is **not strictly
+atomic**, though. Without folding the check into the write itself (a CAS) or
+serializing per account, a concurrent commit landing in that last sub-step gap
+can still slip through. Double-spend safety is unaffected and holds
+unconditionally: the reservation protocol (`reserve_postings` is a single
+atomic conditional update, so two sagas cannot both claim the same posting)
+prevents
+consuming a posting twice. Only the floor on a `CappedOverdraft` account is
+best-effort. This tradeoff is recorded in
+[doc/adr/0003-dumb-storage-saga-recovery.md](adr/0003-dumb-storage-saga-recovery.md).
+
+`NoOverdraft` is fully UTXO-backed (you can only spend postings you own), and
+the unconstrained policies have no floor to violate.
+
+## No Sequential Hash Chain
+
+An earlier design linked each transfer to its predecessor via a hash chain,
+enforcing total ordering. This was removed because:
+
+- UTXO double-spend prevention already prevents reordering attacks (a posting
+  can only be consumed once).
+- Content-addressed transfer IDs provide tamper evidence without chaining.
+- Append-only account versioning prevents account state manipulation.
+- The chain was a **concurrency bottleneck**: every transfer had to wait for
+  its predecessor's hash.
+
+## Posting Selection
+
+The intent layer hides UTXO complexity from callers. Every operation is
+expressed as one or more `Movement { from, to, asset, amount }` values. The
+resolve step aggregates net debits per (account, asset) across all movements,
+then for each pair with a positive net debit, the `select_postings` function
+uses a **greedy largest-first** algorithm:
+
+1. Filter to active, positive postings of the target asset.
+2. Sort by value descending.
+3. Accumulate until the sum meets or exceeds the target.
+
+If the selected sum exceeds the target, the resolve step creates a **change
+posting** returning the remainder to the sender, exactly like Bitcoin's change
+outputs.
+
+Aggregating before selection means multiple movements debiting the same account
+share one selection pass, avoiding double-selection of the same postings.
+
+## Posting Lifecycle
+
+Postings follow a three-state lifecycle managed by the saga pipeline:
+
+```mermaid
+stateDiagram-v2
+    [*] --> Active: insert_postings
+    Active --> PendingInactive: reserve_postings
+    PendingInactive --> Active: release_postings (compensation)
+    PendingInactive --> Inactive: deactivate_postings(reservation)
+    Active --> Inactive: deactivate_postings(None)
+```
+
+| State | Available | In balance | Description |
+|-------|-----------|------------|-------------|
+| **Active** | Yes | Yes | Available for consumption |
+| **PendingInactive** | No | Yes | Reserved for a transfer. Reverts to Active on compensation |
+| **Inactive** | No | No | Consumed. Kept for audit trail (void) |
+
+### Batch semantics
+
+The batch posting methods are dumb: each id's conditional update is applied
+independently, and the method returns the number of rows it changed. There is
+no all-or-nothing batch rejection. A posting that does not meet the condition is
+simply skipped (it does not count and does not error), so a batch can apply to
+some ids and not others. The saga interprets the returned count (full continue,
+partial compensate, zero idempotent-replay); the Store never decides.
+
+- **`reserve_postings(ids, rid)`**: flips each `Active` posting to
+  `PendingInactive` stamped with `rid`. Each flip is a single atomic conditional
+  update; a posting that is not Active is skipped. Returns the number flipped.
+- **`release_postings(ids, rid)`**: reverts each `PendingInactive` posting owned
+  by `rid` to `Active`. Others are skipped. Returns the number reverted.
+
+Each posting's update is atomic on its own row, so this enables shard-local
+writes with no cross-shard coordination. Atomicity is per posting, not across
+the batch.
+
+## Saga Composition
+
+### Internal pipeline steps
+
+The envelope saga is two `legend::Step` implementations operating on
+`LedgerCtx` (resolution runs before the saga, in `Ledger::commit`):
+
+| Step | Execute | Compensate | Retry |
+|------|---------|------------|-------|
+| `ReservePostingsStep` | Reserve postings `Active → PendingInactive`, interpret the count | Release back to `Active` | 3 retries |
+| `FinalizeTransferStep` | `Ledger::finalize_envelope`: re-validate (last-step floor/freeze guard) → mark `Finalizing` → `deactivate` → `insert` → `store_transfer` → `append_event`, verifying every end-state | `reverse(transfer_id)` | 3 retries |
+
+### High-level composition steps
+
+Higher-level steps compose over the intent-layer API for multi-transfer
+workflows:
+
+| Step | Execute | Compensate |
+|------|---------|------------|
+| `PayMovementStep` | Build pay transfer, `ledger.commit(...)` | `ledger.reverse(receipt.transfer_id)` |
+| `DepositMovementStep` | Build deposit transfer, `ledger.commit(...)` | `ledger.reverse(receipt.transfer_id)` |
+| `WithdrawMovementStep` | Build withdraw transfer, `ledger.commit(...)` | `ledger.reverse(receipt.transfer_id)` |
+
+### Custom orchestration with legend
+
+You can compose any combination of steps into a saga using the `legend!` macro.
+Legend drives the steps in order, retries on transient failures, and
+compensates completed steps in reverse (LIFO) on unrecoverable failure.
+
+```rust
+use std::sync::Arc;
+use legend::legend;
+use kuatia::saga::*;
+
+// Define a multi-transfer saga
+legend! {
+    FundAndPay<LedgerCtx, SagaError> {
+        deposit: DepositMovementStep,
+        pay: PayMovementStep,
+    }
+}
+
+// Build and run: Ledger uses Arc<dyn Store>, so LedgerCtx is concrete
+let ledger: Arc<Ledger> = /* ... */;
+let saga = FundAndPay::new(FundAndPayInputs {
+    deposit: DepositInput { to: alice, asset: usd, amount, external: bank },
+    pay: PayInput { from: alice, to: bob, asset: usd, amount },
+});
+let ctx = LedgerCtx::new(ledger.clone());
+let result = saga.build(ctx).start().await;
+
+match result {
+    ExecutionResult::Completed(e) => { /* all steps succeeded */ }
+    ExecutionResult::Failed(_, err) => { /* deposit was compensated */ }
+    ExecutionResult::Paused(e) => { /* serialize e for crash recovery */ }
+    ExecutionResult::CompensationFailed { .. } => { /* manual intervention */ }
+}
+```
+
+Since `Ledger` uses `Arc<dyn Store>` internally, `LedgerCtx` is a concrete
+type: no generic parameters needed. This is what allows `legend!` to define
+saga types directly.
+
+The `LedgerCtx` is serializable: a paused saga can be persisted and resumed
+later, enabling crash recovery. On boot, load pending sagas and resume them;
+legend will compensate any completed steps that need rollback.
+
+### Reversal
+
+`reverse()` creates a compensating transfer that consumes the original's
+created postings and recreates its consumed postings, undoing the operation
+while preserving the full audit trail.

+ 308 - 0
doc/crates.md

@@ -0,0 +1,308 @@
+# Crate Reference
+
+## kuatia-core
+
+Pure, sans-IO (Input/Output) decision logic. No async runtime, near-zero
+dependencies (`sha2`, `serde`, `bitflags`).
+
+### Modules
+
+| Module | Purpose |
+|--------|---------|
+| `types` | Domain model: all core types, binary serialization, and `AutoId` generator |
+| `validate` | `validate_and_plan()`: single entry point for invariant enforcement |
+| `hash` | Double-SHA-256 (Secure Hash Algorithm), canonical encoding helpers, transfer/account hashing |
+| `posting_selection` | Greedy largest-first posting selection for the intent layer |
+
+### Key Types
+
+| Type | Description |
+|------|-------------|
+| `AccountId(i64)` | Stable account identity (snowflake-style, generated in Rust) |
+| `AssetId(u32)` | Asset identifier (USD, BTC, etc.). Conservation boundary |
+| `EnvelopeId([u8; 32])` | Content-addressed double-SHA-256 of transfer bytes |
+| `PostingId { transfer, index }` | Identifies a posting by its creating transfer + position |
+| `AccountSnapshotId { account, snapshot_id }` | Account state hash for version pinning |
+| `Cent` | Smallest monetary unit (private field, backing integer hidden). Backing is `i64` by default, `i128` under the `i128` feature. Checked arithmetic via `checked_add`, `checked_sub`, `checked_neg`, `checked_sum` returning `Result<Cent, OverflowError>` |
+| `OverflowError` | Returned when a `Cent` operation would overflow or underflow |
+| `PostingStatus` | Posting lifecycle: `Active`, `PendingInactive`, `Inactive` |
+| `Amount` | Parser/formatter for decimal strings. Not stored; use at API boundaries only |
+| `Posting` | Signed amount of one asset owned by one account. Has `status: PostingStatus` and `reservation: Option<ReservationId>` (owner token while `PendingInactive`) |
+| `ReservationId` | Owner token stamped on a reserved posting so only the reserving saga may finalize/release it |
+| `NewPosting` | Posting to be created (no id yet, assigned during validation) |
+| `Transfer` | Atomic unit: consumes postings + creates postings + metadata |
+| `EnvelopeBuilder` | Fluent builder for `Transfer` construction |
+| `Account` | Versioned entity with policy, flags, book, user_data, metadata |
+| `AccountPolicy` | Balance floor rule: `NoOverdraft`, `CappedOverdraft`, `UncappedOverdraft`, `SystemAccount`, `ExternalAccount` |
+| `AccountFlags` | Bitflags: `FROZEN`, `CLOSED` |
+| `UserData` | Fixed 28 bytes (u128 + u64 + u32) for correlation IDs, external refs |
+| `Metadata` | `BTreeMap<String, Vec<u8>>` for free-form key-value data |
+| `Receipt` | Confirmation of a committed transfer (contains `transfer_id`) |
+| `AutoId` | Snowflake-inspired i64 ID generator: `[0][40-bit ms][23-bit CRC32 or counter]`. The ms field counts from `KUATIA_EPOCH_MS` (2026-01-01T00:00:00Z), giving ~34.8 years forward. Lives in `kuatia-types::autoid` |
+
+### Validation Invariants
+
+`validate_and_plan(input: PlanInput) -> Result<Plan, ValidationError>` checks,
+in order:
+
+```mermaid
+graph TD
+    A[1. Non-empty] --> B[2. No duplicate consumes]
+    B --> C[3. Posting existence]
+    C --> D[4. Posting active or reserved]
+    D --> E[5. Account existence & lifecycle]
+    E --> F[6. Snapshot pinning]
+    F --> BP[7. Book policy]
+    BP --> G[8. Per-asset conservation]
+    G --> H[9. Negative posting restriction]
+    H --> J[10. Policy enforcement]
+    J --> I[Plan]
+    style I fill:#e8f5e9
+```
+
+1. **Non-empty**: transfer must consume or create at least one posting
+2. **No duplicate consumes**: each posting consumed at most once
+3. **Posting existence**: every consumed posting exists in state
+4. **Posting active or reserved**: consumed postings must be `Active` or
+   `PendingInactive` (prevents double-spend)
+5. **Account existence & lifecycle**: all referenced accounts exist, not
+   frozen, not closed
+6. **Snapshot pinning**: account snapshots (if provided) must match current
+   state
+7. **Book policy**: when a book is loaded, referenced assets/accounts/flags
+   must be allowed by the book
+8. **Per-asset conservation**: `sum(consumed) == sum(created)` for each asset
+9. **Negative posting restriction**: negative postings forbidden only on
+   `NoOverdraft` (allowed on overdraft/system/external)
+10. **Policy enforcement**: projected balance satisfies account's floor
+
+Output is a `Plan` containing `transfer_id`, `postings_to_deactivate`, and
+`postings_to_create`.
+
+---
+
+## kuatia
+
+Async resource layer. Depends on `kuatia-core`, `tokio`, `async-trait`,
+`serde`, `legend`.
+
+### Modules
+
+| Module | Purpose |
+|--------|---------|
+| `kuatia` | `Ledger`: primary API (non-generic, uses `Arc<dyn Store>`), saga commit pipeline, intent layer |
+| `store` | `Store` composite trait + sub-traits (`AccountStore`, `PostingStore`, `TransferStore`, `SagaStore`, `EventStore`, `BookStore`) |
+| `error` | `StoreError`, `LedgerError`: unified error hierarchy |
+| `mem_store` | `InMemoryStore`: in-memory `Store` implementation for tests |
+| `saga` | Pipeline steps (reserve, validate, finalize) + high-level legend step adapters |
+
+### Ledger API
+
+#### Commit (the envelope saga)
+
+`commit(transfer)` resolves the intent into an envelope (read-only) then runs
+the `EnvelopeSaga` (defined via `legend!`): two steps with automatic retry and
+LIFO compensation. The finalize step re-validates as its last action before the
+writes, then calls the dumb primitives, interpreting/verifying each count:
+
+```mermaid
+graph LR
+    A[resolve] -->|Envelope| W[save PendingSaga: Reserving]
+    W --> B[reserve_postings]
+    B -->|Active→PendingInactive| F[finalize]
+    F --> V[validate_and_plan re-check]
+    V --> M[mark Finalizing]
+    M --> D[deactivate → insert → store_transfer → append_event]
+    D --> E[Receipt + delete PendingSaga]
+    style E fill:#e8f5e9
+```
+
+Note: `commit`/`commit_envelope`/`reverse`/`recover` require `Arc<Ledger>`.
+
+#### Crash recovery
+
+`recover()` re-completes any `PendingSaga` left by a crash, pushing the
+envelope through the idempotent primitives (roll-forward). Call it on startup.
+
+#### Convenience
+
+| Method | Description |
+|--------|-------------|
+| `commit(transfer)` | Resolve intent → `commit_envelope` (requires `Arc<Ledger>`) |
+| `commit_envelope(envelope)` | The one commit path: write-ahead → reserve → finalize (finalize re-validates, then writes); for pre-built/FX envelopes |
+| `reverse(transfer_id)` | Builds a compensating envelope and runs `commit_envelope` |
+| `recover()` | Force-completes pending sagas after a crash (call on startup) |
+
+#### Intent Layer
+
+Transfers are built via `TransferBuilder` and committed with
+`ledger.commit(transfer)`:
+
+| Builder method | Description |
+|---------------|-------------|
+| `.pay(from, to, asset, amount)` | Single movement between accounts |
+| `.deposit(to, asset, amount, external)` | Two movements: offset on external + credit on target |
+| `.withdraw(from, asset, amount, external)` | Single movement from account to external |
+| `.movement(from, to, asset, amount)` | Raw movement for custom operations |
+
+#### Account Lifecycle
+
+| Method | Description |
+|--------|-------------|
+| `create_account(account)` | Create account and emit AccountCreated event |
+| `freeze(id)` | Set FROZEN flag, increment version, emit AccountFrozen event |
+| `unfreeze(id)` | Clear FROZEN flag, increment version, emit AccountUnfrozen event |
+| `close(id)` | Set CLOSED flag (requires zero active postings), emit AccountClosed event |
+
+#### Queries
+
+| Method | Description |
+|--------|-------------|
+| `balance(account, asset)` | Sum of non-Inactive postings (computed by Ledger) |
+| `list_accounts()` | All current account snapshots |
+| `get_account(id)` | Latest account snapshot |
+| `query_transfers(query)` | Paginated, filtered transfer history (by date range, book) |
+| `history(account)` | All transfers involving an account |
+| `postings(account)` | All postings (any status) |
+| `query_postings(query)` | Paginated, filtered postings (by asset, status) |
+| `account_history(id)` | All version snapshots |
+| `get_events_since(seq, limit)` | Query ledger event log after a sequence number |
+
+### Store Trait
+
+The `Store` trait is a composite of focused sub-traits. Every write method is a
+dumb instruction returning the number of affected rows (`u64`); the saga
+interprets the count.
+
+```mermaid
+graph TB
+    Store --> AccountStore
+    Store --> PostingStore
+    Store --> TransferStore
+    Store --> SagaStore
+    Store --> EventStore
+    Store --> BookStore
+```
+
+- **`AccountStore`**: `get_account`, `get_accounts`, `create_account`,
+  `append_account_version`, `get_account_history`, `list_accounts`
+- **`PostingStore`**: `get_postings`,
+  `get_postings_by_account(account, asset?, status?)`, `query_postings(query)`,
+  and the dumb write primitives `reserve_postings(ids, reservation) -> u64`,
+  `release_postings(ids, reservation) -> u64`,
+  `deactivate_postings(ids, reservation?) -> u64`,
+  `insert_postings(postings) -> u64`
+- **`TransferStore`**: `get_transfer`,
+  `store_transfer(record, involved) -> u64`, `get_transfers_for_account`,
+  `query_transfers`
+- **`EventStore`**: `append_event` (idempotent on a per-transfer dedup key),
+  `get_events_since`
+- **`SagaStore`**: `save_saga`, `list_pending_sagas`, `delete_saga`: the
+  write-ahead store the saga and `recover()` use
+- **`BookStore`**: `create_book`, `get_book`, `list_books`
+
+There is no `CommitStore`/`commit_transfer`: a commit is the saga calling these
+primitives in sequence, each idempotent, with crash-safety from write-ahead
+recovery rather than a single transaction.
+
+#### Batch posting operations
+
+`reserve_postings`/`release_postings`/`deactivate_postings` apply each id's
+conditional update and return how many rows changed (the saga decides what a
+short count means):
+
+```mermaid
+stateDiagram-v2
+    [*] --> Active: insert_postings
+    Active --> PendingInactive: reserve_postings
+    PendingInactive --> Active: release_postings
+    PendingInactive --> Inactive: deactivate_postings(reservation)
+    Active --> Inactive: deactivate_postings(None)
+```
+
+Each cell is the count a primitive returns (1 = flipped, 0 = no-op / not
+applicable). The saga interprets a 0:
+
+| Operation | Active | PendingInactive (this rid) | Inactive |
+|-----------|--------|----------------------------|----------|
+| `reserve_postings(rid)` | → PendingInactive (1) | 0 | 0 |
+| `release_postings(rid)` | 0 | → Active (1) | 0 |
+| `deactivate_postings(Some rid)` | 0 | → Inactive (1) | 0 |
+| `deactivate_postings(None)` | → Inactive (1) | 0 | 0 |
+
+There is no all-or-nothing batch rejection: a posting whose condition does not
+hold is skipped (counted as 0, not an error), so a call can apply to some ids
+and not others. Each id's update is atomic on its own row; the batch as a whole
+is not. The saga reads the count and decides what to do.
+
+Balance computation lives in the Ledger (`compute_balance`), not the Store.
+
+### Error Hierarchy
+
+```
+LedgerError
+├── Validation(ValidationError)   // from kuatia-core (includes Overflow)
+├── Store(StoreError)             // storage failures
+├── Selection(SelectionError)     // insufficient funds (includes Overflow)
+├── TransferNotFound
+├── PostingNotReversible
+├── AccountNotFound
+├── AccountNotEmpty              // can't close with active postings
+├── AccountAlreadyClosed
+├── BookNotFound                 // transfer named a book that does not exist
+├── Overflow                     // monetary arithmetic overflow
+└── CompensationFailed           // saga compensation failed (original + compensation errors)
+```
+
+```
+StoreError
+├── NotFound(String)
+├── AlreadyExists(String)
+├── VersionConflict { account, expected, actual }  // append_account_version: stale version
+└── Internal(String)
+```
+
+The store has no semantic write-outcome errors (no "posting not active",
+"reservation mismatch", "cas conflict"). Writes return affected-row counts and
+the saga derives meaning from them.
+
+### Saga Steps
+
+#### Envelope pipeline steps (used internally by `commit_envelope`; resolution runs before the saga)
+
+| Step | Execute | Compensate | Retry |
+|------|---------|------------|-------|
+| `ReservePostingsStep` | `reserve_postings` `Active → PendingInactive`, interpret count | Release back to `Active` | 3 |
+| `FinalizeTransferStep` | `Ledger::finalize_envelope`: re-validate (last-step floor/freeze guard) → mark `Finalizing` → `deactivate` → `insert` → `store_transfer` → `append_event`, verifying every end-state | `reverse(transfer_id)` | 3 |
+
+Validation lives inside the finalize step so it runs immediately before the
+writes. Recovery (`recover()`) re-uses `finalize_envelope` for `Finalizing`
+sagas and re-runs the whole saga for `Reserving` ones.
+
+#### High-level steps (for custom saga composition with `legend!`)
+
+| Step | Execute | Compensate |
+|------|---------|------------|
+| `PayMovementStep` | Build pay transfer, `ledger.commit(...)` | `ledger.reverse(receipt.transfer_id)` |
+| `DepositMovementStep` | Build deposit transfer, `ledger.commit(...)` | `ledger.reverse(receipt.transfer_id)` |
+| `WithdrawMovementStep` | Build withdraw transfer, `ledger.commit(...)` | `ledger.reverse(receipt.transfer_id)` |
+
+#### Custom orchestration
+
+Compose steps into sagas using `legend!`. The saga executor drives steps in
+order with automatic retry and LIFO compensation. `LedgerCtx` is serializable
+for crash recovery:
+
+```rust
+legend! {
+    MyFlow<LedgerCtx, SagaError> {
+        deposit: DepositMovementStep,
+        pay: PayMovementStep,
+    }
+}
+let ctx = LedgerCtx::new(ledger_arc.clone());
+let result = MyFlow::new(inputs).build(ctx).start().await;
+```
+
+`LedgerCtx` is concrete (not generic) because `Ledger` uses `Arc<dyn Store>`
+internally.

+ 355 - 0
doc/glossary.md

@@ -0,0 +1,355 @@
+# Glossary & Usage Guide
+
+> Coming from classical accounting? See
+> [accounting-mapping.md](accounting-mapping.md) for how journals, entries, and
+> ledgers map onto Kuatia's transfers, postings, and books.
+
+## Terms
+
+### Posting
+
+A signed amount of one asset owned by one account. The fundamental unit of
+value in the ledger. Postings are immutable once created. Consumed postings
+are marked `Inactive` but never deleted.
+
+- **Positive posting**: value controlled by the account.
+- **Negative posting**: an offset position, allowed on any policy except
+  `NoOverdraft`. It represents issuance, external flow, system balancing
+  (`SystemAccount`, `ExternalAccount`), or an overdraft
+  (`CappedOverdraft`/`UncappedOverdraft`).
+
+Lifecycle: `Active` → `PendingInactive` (reserved by a saga, stamped with its
+`ReservationId`) → `Inactive` (consumed). **Ledger balance** sums
+`Active + PendingInactive` postings; **available balance** sums only `Active`
+(postings reserved for an in-flight transfer are not available to spend).
+
+### Account
+
+A versioned entity that owns postings. Balance is never stored. It is always
+the sum of non-inactive postings for a given (account, asset) pair.
+
+Accounts have a **policy** (balance floor rule), **flags** (lifecycle +
+user-defined), and a **book** assignment.
+
+### Asset
+
+An identifier (`AssetId(u32)`) representing a unit of value: a currency, a
+product, a token. Each asset is an independent conservation boundary: the sum
+of consumed postings must equal the sum of created postings *per asset* in
+every transfer.
+
+### Movement
+
+The intent layer's building block: `{ from, to, asset, amount }`. Movements
+express *what* should happen. The ledger resolves them into concrete postings.
+
+### Transfer
+
+One or more movements to execute atomically. Built via `TransferBuilder`,
+committed via `ledger.commit(transfer)`.
+
+### Envelope
+
+The resolved, concrete form of a transfer: which postings to consume and which
+to create. Produced by the resolve step (`commit`), or built directly and
+committed via `commit_envelope(envelope)`.
+
+### Dumb storage
+
+The design where every `Store` write method applies one update and returns the
+**number of affected rows** (or an I/O error), never interpreting that count,
+deciding state, enforcing idempotency, or compensating. The saga reads the
+count and decides: full = continue; partial = error → compensate; zero = read
+state and continue only if this same envelope/reservation already applied it.
+
+### Reservation protocol
+
+The concurrency-control mechanism for consumed postings: `reserve_postings`
+atomically flips `Active → PendingInactive` stamped with a `ReservationId`,
+so two sagas cannot both claim the same posting. This (not a global
+transaction) is what prevents double-spend.
+
+### PendingSaga / recovery
+
+A write-ahead record `{envelope, reservation, phase}` persisted via
+`SagaStore` before a commit mutates anything. The `phase`
+(`Reserving` → `Finalizing`) tells `Ledger::recover()` (startup) how to
+complete a crashed saga: a `Reserving` saga is re-run and **re-validated**;
+a `Finalizing` saga (already validated, owns its postings) is rolled forward
+through the verified `finalize_envelope`. Roll-forward, not rollback.
+
+### Book
+
+A **Book is a transfer policy scope**: it gates which accounts and assets may
+participate in a transfer. Note what it is *not*:
+
+- It is **not** the classical accounting journal (the chronological book of
+  entries). That role is played by the append-only transfer log itself.
+- It does **not** partition balances. Accounts and their balances are global;
+  a Book only gates *who can transact with whom in what context*.
+
+A book is `{ id, name, policy }`, where the `policy` (`BookPolicy`) holds:
+- `allowed_assets`: if non-empty, only these assets may appear in movements.
+- `allowed_flags`: if non-empty, accounts with ANY of these flags may
+  participate.
+- `allowed_accounts`: if non-empty, these specific accounts may participate
+  (in addition to flag matches).
+
+An empty policy (no restrictions) allows any account and any asset.
+
+### Conservation
+
+For every transfer, for each asset: `sum(consumed) == sum(created)`. This is
+the double-entry-style safety invariant (the UTXO-model equivalent of
+`Σ debits = Σ credits`), enforced at the type level. No value is created or
+destroyed. It only moves.
+
+### AutoId
+
+Snowflake-inspired `i64` identifier:
+`[0 sign bit][40-bit ms timestamp][23-bit counter or CRC32]`. The timestamp
+counts milliseconds since `KUATIA_EPOCH_MS` (2026-01-01T00:00:00Z), giving
+~34.8 years of range going forward. Generated in Rust. The database never
+assigns IDs.
+
+---
+
+## Usage Examples
+
+### Example 1: Currency Exchange
+
+An exchange lets users deposit fiat, trade between currencies, and withdraw.
+
+**Setup:**
+
+```rust
+use kuatia::prelude::*;
+
+// Assets
+let usd = AssetId::new(1);
+let eur = AssetId::new(2);
+
+// Books: separate deposit/withdrawal flows from trading
+let deposits_book = BookBuilder::new("deposits")
+    .allow_asset(usd)
+    .allow_asset(eur)
+    .allow_flags(AccountFlags::USER_0 | AccountFlags::USER_1) // wallets + bank
+    .build();
+
+let trading_book = BookBuilder::new("trading")
+    .allow_asset(usd)
+    .allow_asset(eur)
+    .allow_flags(AccountFlags::USER_0) // only user wallets
+    .allow_account(exchange_pool)       // + the exchange pool
+    .build();
+
+ledger.create_book(deposits_book).await?;
+ledger.create_book(trading_book).await?;
+
+// Accounts — `Account::new` sets version 1, no flags, and the default book;
+// set the other fields explicitly where the common case is not enough.
+let mut bank = Account::new(AccountId::default(), AccountPolicy::ExternalAccount);
+bank.flags = AccountFlags::USER_1; // bank flag
+bank.book = deposits_book.id;
+
+let mut alice = Account::new(AccountId::default(), AccountPolicy::NoOverdraft);
+alice.flags = AccountFlags::USER_0; // wallet flag
+alice.book = deposits_book.id;
+
+let mut exchange_pool = Account::new(AccountId::default(), AccountPolicy::SystemAccount);
+exchange_pool.book = trading_book.id;
+```
+
+**Deposit USD into Alice's wallet:**
+
+```rust
+let deposit = TransferBuilder::new()
+    .book(deposits_book.id)
+    .deposit(alice.id, usd, Cent::from(10_000), bank.id)?
+    .build();
+ledger.commit(deposit).await?;
+// Alice: +10,000 USD
+// Bank: -10,000 USD (offset: value entered the ledger boundary)
+```
+
+**Alice trades 5,000 USD for EUR at 1:0.92:**
+
+```rust
+let trade = TransferBuilder::new()
+    .book(trading_book.id)
+    .pay(alice.id, exchange_pool, usd, Cent::from(5_000))
+    .pay(exchange_pool, alice.id, eur, Cent::from(4_600))
+    .build();
+ledger.commit(trade).await?;
+// Alice: 5,000 USD + 4,600 EUR
+// Exchange pool: 5,000 USD - 4,600 EUR
+```
+
+**Withdraw EUR to Alice's bank:**
+
+```rust
+let withdrawal = TransferBuilder::new()
+    .book(deposits_book.id)
+    .withdraw(alice.id, eur, Cent::from(4_600), bank.id)
+    .build();
+ledger.commit(withdrawal).await?;
+// Alice: 5,000 USD, 0 EUR
+// Bank: -10,000 USD + 4,600 EUR
+```
+
+Conservation holds at every step. The exchange pool absorbs the spread.
+
+
+### Example 2: Supermarket / Retail POS
+
+A supermarket tracks inventory as product assets, records sales with COGS, and
+manages cash and bank accounts.
+
+**Setup:**
+
+```rust
+// Assets
+let gs = AssetId::new(1);            // Guaranies (currency)
+let product_a = AssetId::new(100);   // Product: rice 1kg
+let product_b = AssetId::new(101);   // Product: cooking oil 1L
+
+// Account flags
+const WAREHOUSE: AccountFlags = AccountFlags::USER_0;
+const CUSTOMER: AccountFlags = AccountFlags::USER_1;
+const REVENUE: AccountFlags = AccountFlags::USER_2;
+const BANK: AccountFlags = AccountFlags::USER_3;
+
+// Books
+let sales_book = BookBuilder::new("sales")
+    .allow_asset(gs)
+    .allow_asset(product_a)
+    .allow_asset(product_b)
+    .allow_flags(WAREHOUSE | CUSTOMER | REVENUE)
+    .build();
+
+let inventory_book = BookBuilder::new("inventory")
+    .allow_asset(product_a)
+    .allow_asset(product_b)
+    .allow_flags(WAREHOUSE)
+    .allow_account(world) // issuance source
+    .build();
+
+let banking_book = BookBuilder::new("banking")
+    .allow_asset(gs)
+    .allow_flags(WAREHOUSE | BANK)
+    .build();
+
+// Accounts — start from `Account::new`, then set flags where needed.
+// issuance source: mints product tokens on receipt
+let world = Account::new(AccountId::default(), AccountPolicy::SystemAccount);
+
+let mut warehouse = Account::new(AccountId::default(), AccountPolicy::NoOverdraft);
+warehouse.flags = WAREHOUSE;
+
+let mut cash_register = Account::new(AccountId::default(), AccountPolicy::NoOverdraft);
+cash_register.flags = WAREHOUSE;
+
+let mut revenue = Account::new(AccountId::default(), AccountPolicy::SystemAccount);
+revenue.flags = REVENUE;
+
+let mut cogs = Account::new(AccountId::default(), AccountPolicy::SystemAccount); // cost of goods sold
+cogs.flags = REVENUE;
+
+let mut bank = Account::new(AccountId::default(), AccountPolicy::NoOverdraft);
+bank.flags = BANK;
+```
+
+**Receive inventory from supplier (50 units of rice):**
+
+```rust
+let receipt = TransferBuilder::new()
+    .book(inventory_book.id)
+    .pay(world, warehouse.id, product_a, Cent::from(50_000)) // 50.000 units (precision 3)
+    .build();
+ledger.commit(receipt).await?;
+// Warehouse: +50.000 rice
+// World: -50.000 rice (offset: issued into the ledger)
+```
+
+**Cash sale, customer buys 2 rice at 15,000 Gs each:**
+
+```rust
+let sale = TransferBuilder::new()
+    .book(sales_book.id)
+    // Move product from warehouse to customer (consumed by sale)
+    .pay(warehouse.id, customer.id, product_a, Cent::from(2_000))
+    // Customer pays cash
+    .pay(customer.id, cash_register.id, gs, Cent::from(30_000))
+    // Record revenue
+    .pay(world, revenue.id, gs, Cent::from(30_000))
+    // Record COGS (cost was 10,000 Gs per unit)
+    .pay(world, cogs.id, gs, Cent::from(20_000))
+    .build();
+ledger.commit(sale).await?;
+```
+
+**Deposit cash to bank:**
+
+```rust
+let deposit = TransferBuilder::new()
+    .book(banking_book.id)
+    .pay(cash_register.id, bank.id, gs, Cent::from(30_000))
+    .build();
+ledger.commit(deposit).await?;
+```
+
+**Query balances:**
+
+```rust
+let warehouse_rice = ledger.balance(&warehouse.id, &product_a).await?;
+// 48.000 units remaining
+
+let bank_balance = ledger.balance(&bank.id, &gs).await?;
+// 30,000 Gs
+
+let total_revenue = ledger.balance(&revenue.id, &gs).await?;
+// 30,000 Gs
+
+let total_cogs = ledger.balance(&cogs.id, &gs).await?;
+// 20,000 Gs gross profit = revenue - cogs = 10,000 Gs
+```
+
+**Why books matter here:** The `sales` book prevents a bug where a bank
+transfer accidentally credits the revenue account. The `banking` book ensures
+only cash and bank accounts participate in deposits. Each flow is isolated by
+scope while sharing the same global balances.
+
+---
+
+## Book Design
+
+### When to use books
+
+- **Always**: even if you only have one flow, defining a book documents what
+  assets and accounts are expected.
+- **Multiple flows**: separate books for sales, payments, inventory, banking.
+  Prevents cross-contamination.
+- **Multi-tenant**: one book per tenant with `allowed_accounts` restricting to
+  that tenant's accounts.
+
+### Book scoping rules
+
+| Field | Empty | Non-empty |
+|-------|-------|-----------|
+| `allowed_assets` | Any asset allowed | Only listed assets |
+| `allowed_flags` | Flag check skipped | Accounts with ANY matching flag pass |
+| `allowed_accounts` | Account check skipped | Listed accounts always pass (even without matching flags) |
+
+An account passes the book check if:
+1. It matches `allowed_flags` (any flag in common), OR
+2. It is explicitly listed in `allowed_accounts`, OR
+3. Both lists are empty (unrestricted book).
+
+### Books do NOT partition balances
+
+An account's balance is the sum of all its non-inactive postings across ALL
+books. If Alice receives 100 USD via the `deposits` book and spends 50 USD via
+the `trading` book, her balance is 50 USD, not 100 in one book and -50 in
+another.
+
+This is intentional: books scope *access*, not *state*.

+ 247 - 0
doc/transfers.md

@@ -0,0 +1,247 @@
+# Transfers
+
+## Overview
+
+A transfer is the atomic unit of value movement in the ledger. It consumes
+existing postings and creates new ones, preserving per-asset conservation.
+
+There are two layers:
+
+- **Intent layer**: callers express movements (from, to, asset, amount). The
+  ledger resolves these into concrete postings.
+- **Envelope layer**: concrete postings to consume and create. Used internally
+  after resolution and available for direct callers.
+
+## Movements
+
+A movement is the fundamental building block:
+
+```rust
+struct Movement {
+    from: AccountId,  // account being debited
+    to: AccountId,    // account being credited
+    asset: AssetId,   // asset to transfer
+    amount: Cent,     // amount (may be negative for offset postings)
+}
+```
+
+Every operation (pay, deposit, withdraw) is expressed as one or more
+movements. The resolve step aggregates net debits per (account, asset) and
+selects postings only for accounts with a positive net debit.
+
+## Operations
+
+### Pay
+
+Transfer value between two accounts.
+
+```rust
+TransferBuilder::new()
+    .pay(from, to, asset, amount)
+    .build()
+```
+
+Produces one movement:
+
+| from | to | asset | amount |
+|------|----|-------|--------|
+| A | B | USD | 50 |
+
+Resolve selects postings from A to cover 50, creates a +50 posting on B, and
+returns change to A if the selected postings exceed 50.
+
+### Deposit
+
+Fund an account from a system/external source. Creates an offset posting on
+the source and a credit on the target.
+
+```rust
+TransferBuilder::new()
+    .deposit(to, asset, amount, external)
+    .build()
+```
+
+Produces two movements:
+
+| from | to | asset | amount |
+|------|----|-------|--------|
+| external | external | USD | -100 |
+| external | to | USD | +100 |
+
+The first movement creates a -100 offset posting on the external account. The
+second creates a +100 posting on the target account.
+
+Net debit on the external account: -100 + 100 = **0**. No posting selection is
+needed: the offset is created directly.
+
+Conservation: created sum = -100 + 100 = 0. Consumed sum = 0. Both sides
+balance.
+
+### Withdraw
+
+Move value from an account to an external destination.
+
+```rust
+TransferBuilder::new()
+    .withdraw(from, asset, amount, external)
+    .build()
+```
+
+Produces one movement:
+
+| from | to | asset | amount |
+|------|----|-------|--------|
+| A | external | USD | 50 |
+
+Resolve selects postings from A to cover 50, creates a +50 posting on the
+external account, and returns change to A.
+
+### Raw movement
+
+For operations that don't fit the convenience methods:
+
+```rust
+TransferBuilder::new()
+    .movement(from, to, asset, amount)
+    .build()
+```
+
+## Resolve Algorithm
+
+The resolve step converts a `Transfer` (intent) into an `Envelope` (concrete
+postings) using a two-pass algorithm:
+
+### Pass 1: Create output postings and aggregate debits
+
+For each movement:
+1. Create a `NewPosting { owner: to, asset, value: amount }` with
+   `payer: Some(from)` when `from != to`
+2. Accumulate the movement's amount into a net debit map keyed by
+   `(from, asset)`
+
+### Pass 2: Select postings for accounts with positive net debit
+
+For each `(account, asset)` pair where net debit > 0:
+1. Query active postings for that account and asset
+2. If positive postings cover the net debit: run greedy largest-first
+   selection, compute change = selected sum − net debit, and (if change > 0)
+   create a change posting returning the remainder to the account.
+3. If positive postings are **insufficient**:
+   - For `CappedOverdraft` / `UncappedOverdraft` accounts: consume all positive
+     postings and create a **negative posting** for the shortfall
+     (`net_debit − total_positive`). The `CappedOverdraft` floor is enforced
+     later in validation.
+   - For any other policy: fail with `InsufficientFunds`.
+
+Pairs with net debit <= 0 (e.g. the external account in a deposit) are skipped.
+No posting selection needed.
+
+### Aggregation benefit
+
+Aggregating debits before selection means that multiple movements debiting the
+same account share one selection pass. For example, if a transfer contains two
+payments from account A (50 + 30), the resolve selects postings once for 80
+rather than twice.
+
+## Envelope
+
+After resolution, the result is an `Envelope`:
+
+```rust
+struct Envelope {
+    consumes: Vec<PostingId>,       // postings to deactivate
+    creates: Vec<NewPosting>,       // new postings to create
+    account_snapshots: Vec<AccountSnapshotId>,
+    book: BookId,
+    user_data: UserData,
+    metadata: Metadata,
+}
+```
+
+The envelope is content-addressed: its `EnvelopeId` is the double-SHA-256 of
+its canonical binary serialization. This provides idempotency (committing the
+same envelope twice returns the cached receipt) and tamper evidence.
+
+## Transfer Builder
+
+The `TransferBuilder` provides a fluent API for constructing transfers:
+
+```rust
+let transfer = TransferBuilder::new()
+    .deposit(alice, usd, Cent::from(1000), bank)
+    .pay(alice, bob, usd, Cent::from(200))
+    .book(sales_book)
+    .metadata(metadata)
+    .build();
+```
+
+A single transfer can contain multiple movements of different types. All
+movements execute atomically.
+
+## Commit Paths
+
+### Saga commit (default)
+
+```
+Transfer → resolve → Envelope → reserve → finalize(validate → write) → Receipt
+```
+
+Resolution is read-only; `commit(transfer)` resolves then runs the two-step
+envelope saga (reserve → finalize) with automatic retry and LIFO compensation.
+Validation runs inside the finalize step, immediately before the writes.
+
+### Committing a pre-built envelope
+
+```
+Envelope → reserve → finalize(validate → write) → Receipt
+```
+
+`ledger.commit_envelope(envelope)` runs the same saga for an envelope you
+already hold (e.g. a hand-built multi-asset/FX envelope, or a reversal).
+`reverse()` uses it. There is no separate single-pass "atomic" path.
+
+## Reversal
+
+`reverse(transfer_id)` creates a compensating envelope that:
+1. Consumes the original transfer's created postings
+2. Recreates the original transfer's consumed postings
+
+This undoes the operation while preserving the full audit trail. No postings
+are deleted.
+
+## Validation
+
+Every envelope passes through `validate_and_plan()` before being applied. The
+validation steps are:
+
+1. Non-empty (must consume or create at least one posting)
+2. No duplicate consumed PostingIds
+3. All consumed postings exist
+4. All consumed postings are Active or PendingInactive
+5. All referenced accounts exist, not frozen, not closed
+6. Account snapshot pinning (if provided)
+7. Book policy (if a book is loaded): referenced assets/accounts/flags allowed
+   by the book
+8. Per-asset conservation: `sum(consumed) == sum(created)`
+9. Negative postings forbidden only on `NoOverdraft` accounts (allowed on
+   overdraft/system/external)
+10. Policy enforcement: projected balance satisfies account floor
+
+Validation runs inside the finalize step, immediately before it writes (the
+last-step floor / freeze-close re-check). The finalize step then applies the
+effects through a sequence of dumb, idempotent store primitives
+(`deactivate_postings` → `insert_postings` → `store_transfer` → `append_event`),
+verifying every end-state. There is no single transaction; crash-safety comes
+from a phase-tracked write-ahead `PendingSaga` record plus `recover()`
+roll-forward. The `CappedOverdraft` floor is re-checked as that last step
+and is best-effort (not strictly atomic) under concurrency: two transfers
+that each pass the floor check against the same pre-transfer balance can
+both commit and jointly push the account below its floor. Per-asset
+conservation still holds in that case (the negative postings are real
+value owed, not minted). The overdraft floor is the only guard with this
+property; double-spend prevention is exact (see
+`crates/kuatia/tests/concurrency.rs`, which asserts the exact guarantees
+and documents the floor race with an ignored test). See
+[architecture.md](architecture.md).
+
+See [architecture.md](architecture.md) for details on each check.

+ 5 - 0
rust-toolchain.toml

@@ -0,0 +1,5 @@
+# Dev/CI toolchain for this repo. Downstream consumers are unaffected; their
+# minimum supported Rust is the `rust-version` (1.85) in the crate manifests.
+[toolchain]
+channel = "stable"
+components = ["rustfmt", "clippy"]