Эх сурвалжийг харах

Kuatia: append-only, auditable, multi-asset UTXO-style ledger

Building a ledger library that can be embedded in any Rust application
without requiring external services. Value is tracked as signed postings
(UTXO-style) rather than mutable balance fields, making the system
trivially auditable by replaying the transfer log.

The core insight is separating pure decision logic from IO: validation,
posting selection, and hashing live in a sync crate with zero
dependencies, while the async layer adds storage and a saga pipeline.
This makes the auditable heart testable with golden vectors and
embeddable in no_std environments.

Key design choices and their motivations:

- All arithmetic in Rust, never in SQL — the storage layer is a dumb
  record keeper. This prevents subtle divergence between app logic and
  database-computed values (e.g. SUM rounding differently).

- No unwrap()/expect() in production code — panics in a ledger are
  unacceptable. Every error path returns Result.

- No AUTOINCREMENT — IDs are generated in Rust via AutoId (snowflake-
  inspired i64) so the application controls identity, not the database.
  This enables future sharding without ID coordination.

- Content-addressed transfers (double-SHA-256) provide idempotency and
  tamper evidence without additional infrastructure.

- Append-only accounts with version pinning prevent TOCTOU races during
  concurrent transfers.

- Per-asset conservation is enforced on every transfer, guaranteeing
  double-entry bookkeeping at the type level.

Apache-2.0 licensed.
Cesar Rodas 2 долоо хоног өмнө
commit
c6ef03c386
45 өөрчлөгдсөн 10373 нэмэгдсэн , 0 устгасан
  1. 41 0
      .github/workflows/ci.yml
  2. 1 0
      .gitignore
  3. 87 0
      CLAUDE.md
  4. 1845 0
      Cargo.lock
  5. 13 0
      Cargo.toml
  6. 190 0
      LICENSE
  7. 76 0
      README.md
  8. 13 0
      crates/kuatia-core/Cargo.toml
  9. 30 0
      crates/kuatia-core/README.md
  10. 103 0
      crates/kuatia-core/src/hash.rs
  11. 18 0
      crates/kuatia-core/src/lib.rs
  12. 174 0
      crates/kuatia-core/src/posting_selection.rs
  13. 1048 0
      crates/kuatia-core/src/validate.rs
  14. 25 0
      crates/kuatia-storage-sql/Cargo.toml
  15. 34 0
      crates/kuatia-storage-sql/README.md
  16. 806 0
      crates/kuatia-storage-sql/src/lib.rs
  17. 42 0
      crates/kuatia-storage-sql/src/migrations/001_init.sql
  18. 5 0
      crates/kuatia-storage-sql/src/migrations/002_timestamps_and_columns.sql
  19. 6 0
      crates/kuatia-storage-sql/src/migrations/003_events.sql
  20. 18 0
      crates/kuatia-storage-sql/tests/sqlite.rs
  21. 18 0
      crates/kuatia-storage/Cargo.toml
  22. 29 0
      crates/kuatia-storage/README.md
  23. 51 0
      crates/kuatia-storage/src/error.rs
  24. 65 0
      crates/kuatia-storage/src/events.rs
  25. 10 0
      crates/kuatia-storage/src/lib.rs
  26. 316 0
      crates/kuatia-storage/src/mem_store.rs
  27. 218 0
      crates/kuatia-storage/src/store.rs
  28. 740 0
      crates/kuatia-storage/src/store_tests.rs
  29. 9 0
      crates/kuatia-storage/tests/store_conformance.rs
  30. 12 0
      crates/kuatia-types/Cargo.toml
  31. 24 0
      crates/kuatia-types/README.md
  32. 183 0
      crates/kuatia-types/src/autoid.rs
  33. 1003 0
      crates/kuatia-types/src/lib.rs
  34. 21 0
      crates/kuatia/Cargo.toml
  35. 70 0
      crates/kuatia/README.md
  36. 99 0
      crates/kuatia/src/error.rs
  37. 584 0
      crates/kuatia/src/ledger.rs
  38. 12 0
      crates/kuatia/src/lib.rs
  39. 508 0
      crates/kuatia/src/saga.rs
  40. 716 0
      crates/kuatia/tests/integration.rs
  41. 227 0
      crates/kuatia/tests/saga.rs
  42. 105 0
      doc/accounts.md
  43. 319 0
      doc/architecture.md
  44. 263 0
      doc/crates.md
  45. 196 0
      doc/transfers.md

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

@@ -0,0 +1,41 @@
+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
+
+  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

+ 1 - 0
.gitignore

@@ -0,0 +1 @@
+/target

+ 87 - 0
CLAUDE.md

@@ -0,0 +1,87 @@
+# 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 (double-entry bookkeeping).
+
+## Crate layout
+
+```
+crates/
+  kuatia-types/     Domain types: AccountId, Posting, Movement, Cent, AutoId, etc.
+  kuatia-core/      Pure, sync, no-IO logic: validation, hashing, posting selection
+  kuatia-storage/   Store trait (5 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
+```
+
+## 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 SystemAccount and ExternalAccount may hold negative postings.
+
+## 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**: reserve → validate → finalize, with automatic retry and LIFO compensation via the `legend` crate.
+- **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 prevents TOCTOU races.
+- **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.
+
+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. Per-asset conservation
+8. Negative postings only on SystemAccount/ExternalAccount
+9. Policy enforcement (balance floor)
+
+## Testing
+
+```bash
+cargo test          # runs all 119 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: Unix milliseconds (40 bits ≈ 34.8 years from epoch)
+  - 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

+ 1845 - 0
Cargo.lock

@@ -0,0 +1,1845 @@
+# 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-types",
+ "legend",
+ "serde",
+ "tokio",
+ "tracing",
+]
+
+[[package]]
+name = "kuatia-core"
+version = "0.1.0"
+dependencies = [
+ "kuatia-types",
+ "serde",
+ "sha2",
+]
+
+[[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",
+ "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"

+ 13 - 0
Cargo.toml

@@ -0,0 +1,13 @@
+[workspace]
+resolver = "2"
+members = ["crates/kuatia-types", "crates/kuatia-core", "crates/kuatia-storage", "crates/kuatia-storage-sql", "crates/kuatia"]
+
+[workspace.package]
+license = "Apache-2.0"
+
+[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.

+ 76 - 0
README.md

@@ -0,0 +1,76 @@
+# 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 (double-entry bookkeeping). 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 (5 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
+
+## License
+
+See [LICENSE](LICENSE) for details.

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

@@ -0,0 +1,13 @@
+[package]
+name = "kuatia-core"
+version = "0.1.0"
+edition = "2024"
+license.workspace = true
+
+[lints]
+workspace = true
+
+[dependencies]
+kuatia-types = { path = "../kuatia-types" }
+sha2 = { version = "0.10", default-features = false }
+serde = { version = "1", features = ["derive"] }

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

@@ -0,0 +1,30 @@
+# 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. Per-asset conservation: `sum(consumed) == sum(created)`
+8. Negative postings only on SystemAccount or ExternalAccount
+9. 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};

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

@@ -0,0 +1,174 @@
+//! 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 {
+            id: PostingId {
+                transfer: EnvelopeId([1; 32]),
+                index,
+            },
+            owner: AccountId::new(1),
+            asset: AssetId::new(1),
+            value: Cent::from(value),
+            status: PostingStatus::Active,
+        }
+    }
+
+    #[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 {
+            id: PostingId {
+                transfer: EnvelopeId([1; 32]),
+                index: 0,
+            },
+            owner: AccountId::new(1),
+            asset: AssetId::new(1),
+            value: Cent::from(-100),
+            status: PostingStatus::Active,
+        };
+        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);
+    }
+}

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

@@ -0,0 +1,1048 @@
+//! 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 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>,
+    /// CAS guards for CappedOverdraft accounts: (account, asset, expected_balance).
+    pub cas_guards: Vec<(AccountId, AssetId, Cent)>,
+}
+
+// ---------------------------------------------------------------------------
+// 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 that is not a system or external account.
+    NegativePostingOnNonSystemAccount {
+        /// The account that would receive the negative posting.
+        account: AccountId,
+        /// The asset involved.
+        asset: AssetId,
+        /// The negative value.
+        value: Cent,
+    },
+    /// 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 non-system account {account:?}/{asset:?}"
+                )
+            }
+            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,
+            });
+        }
+    }
+
+    // 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 may only target system or external accounts.
+    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 => {}
+                _ => {
+                    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)?;
+    }
+
+    let mut cas_guards = Vec::new();
+
+    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,
+                    });
+                }
+                // Emit CAS guard for write-skew prevention
+                cas_guards.push((*account_id, *asset_id, current_balance));
+            }
+            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 {
+            id: PostingId {
+                transfer: tid,
+                index: i as u16,
+            },
+            owner: np.owner,
+            asset: np.asset,
+            value: np.value,
+            status: PostingStatus::Active,
+        })
+        .collect();
+
+    Ok(Plan {
+        transfer_id: tid,
+        postings_to_deactivate,
+        postings_to_create,
+        cas_guards,
+    })
+}
+
+#[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: 0,
+            code: 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: 0,
+            code: 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,
+        };
+
+        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: 0,
+            code: 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,
+        };
+
+        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: 0,
+            code: 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,
+        };
+
+        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: 0,
+            code: 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,
+        };
+
+        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
+        };
+        let envelope = Envelope {
+            consumes: vec![pid],
+            creates: vec![NewPosting {
+                owner: AccountId::new(2),
+                asset: AssetId::new(1),
+                value: Cent::from(100),
+                payer: None,
+            }],
+            book: 0,
+            code: 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,
+        };
+
+        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,
+        };
+
+        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,
+        };
+
+        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,
+        };
+        // 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: 0,
+            code: 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,
+        };
+
+        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,
+        };
+        let envelope = Envelope {
+            consumes: vec![pid],
+            creates: vec![NewPosting {
+                owner: AccountId::new(2),
+                asset: AssetId::new(1),
+                value: Cent::from(100),
+                payer: None,
+            }],
+            book: 0,
+            code: 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,
+        };
+
+        let plan = validate_and_plan(input).unwrap();
+        // Should have a CAS guard for the capped account
+        assert_eq!(plan.cas_guards.len(), 1);
+        assert_eq!(plan.cas_guards[0].0, AccountId::new(1));
+    }
+
+    #[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,
+        };
+        let envelope = Envelope {
+            consumes: vec![pid],
+            creates: vec![NewPosting {
+                owner: AccountId::new(2),
+                asset: AssetId::new(1),
+                value: Cent::from(100),
+                payer: None,
+            }],
+            book: 0,
+            code: 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,
+        };
+
+        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,
+        };
+        let envelope = Envelope {
+            consumes: vec![pid],
+            creates: vec![NewPosting {
+                owner: AccountId::new(2),
+                asset: AssetId::new(1),
+                value: Cent::from(100),
+                payer: None,
+            }],
+            book: 0,
+            code: 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,
+        };
+
+        let plan = validate_and_plan(input).unwrap();
+        assert!(plan.cas_guards.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: 0,
+            code: 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,
+        };
+
+        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,
+        };
+        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: 0,
+            code: 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,
+        };
+
+        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: 0,
+            code: 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,
+        };
+
+        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: 0,
+            code: 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,
+        };
+
+        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,
+        };
+
+        let plan = validate_and_plan(input).unwrap();
+        assert_eq!(plan.postings_to_create.len(), 2);
+    }
+}

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

@@ -0,0 +1,25 @@
+[package]
+name = "kuatia-storage-sql"
+version = "0.1.0"
+edition = "2024"
+license.workspace = true
+
+[lints]
+workspace = true
+
+[dependencies]
+kuatia-types = { path = "../kuatia-types" }
+kuatia-storage = { path = "../kuatia-storage" }
+sqlx = { version = "0.8", default-features = false, features = ["runtime-tokio", "macros", "any"] }
+async-trait = "0.1"
+serde = { version = "1", features = ["derive"] }
+serde_json = "1"
+
+[dev-dependencies]
+tokio = { version = "1", features = ["full"] }
+paste = "1"
+
+[features]
+default = ["sqlite"]
+sqlite = ["sqlx/sqlite"]
+postgres = ["sqlx/postgres"]

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

@@ -0,0 +1,34 @@
+# 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 | Feature-flagged, needs running instance |
+
+## 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
+
+Five tables: `accounts`, `postings`, `transfers`, `transfer_accounts`, `sagas`.
+Migrations run via `store.migrate()`.

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

@@ -0,0 +1,806 @@
+//! 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::collections::HashSet;
+
+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.
+    pub async fn migrate(&self) -> Result<(), StoreError> {
+        for sql in [
+            include_str!("migrations/001_init.sql"),
+            include_str!("migrations/002_timestamps_and_columns.sql"),
+            include_str!("migrations/003_events.sql"),
+        ] {
+            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()))?;
+                }
+            }
+        }
+        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: i32 = row
+        .try_get("book")
+        .map_err(|e| StoreError::Internal(e.to_string()))?;
+    let code: i32 = row
+        .try_get("code")
+        .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: book as u32,
+        code: code as u32,
+        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: i64 = row
+        .try_get("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 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: Cent::from(value),
+        status: status_from_i16(status)?,
+    })
+}
+
+// ---------------------------------------------------------------------------
+// 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, code, user_data, metadata) VALUES ($1, $2, $3, $4, $5, $6, $7, $8)"
+        )
+            .bind(account.id.0)
+            .bind(account.version as i64)
+            .bind(serialize_policy(&account.policy)?)
+            .bind(account.flags.bits() as i32)
+            .bind(account.book as i32)
+            .bind(account.code as i32)
+            .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, code, user_data, metadata) VALUES ($1, $2, $3, $4, $5, $6, $7, $8)"
+        )
+            .bind(account.id.0)
+            .bind(account.version as i64)
+            .bind(serialize_policy(&account.policy)?)
+            .bind(account.flags.bits() as i32)
+            .bind(account.book as i32)
+            .bind(account.code as i32)
+            .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]) -> Result<(), StoreError> {
+        // Validate all Active first, then update in a transaction.
+        let mut tx = self
+            .pool
+            .begin()
+            .await
+            .map_err(|e| StoreError::Internal(e.to_string()))?;
+
+        for id in ids {
+            let row =
+                sqlx::query("SELECT status FROM postings WHERE transfer_id = $1 AND idx = $2")
+                    .bind(id.transfer.0.as_slice())
+                    .bind(id.index as i16)
+                    .fetch_optional(&mut *tx)
+                    .await
+                    .map_err(|e| StoreError::Internal(e.to_string()))?
+                    .ok_or_else(|| StoreError::NotFound(format!("posting {id:?}")))?;
+            let status: i16 = row
+                .try_get("status")
+                .map_err(|e| StoreError::Internal(e.to_string()))?;
+            if status != 0 {
+                return Err(StoreError::PostingNotActive(*id));
+            }
+        }
+
+        for id in ids {
+            sqlx::query("UPDATE postings SET status = $1 WHERE transfer_id = $2 AND idx = $3")
+                .bind(status_to_i16(PostingStatus::PendingInactive))
+                .bind(id.transfer.0.as_slice())
+                .bind(id.index as i16)
+                .execute(&mut *tx)
+                .await
+                .map_err(|e| StoreError::Internal(e.to_string()))?;
+        }
+
+        tx.commit()
+            .await
+            .map_err(|e| StoreError::Internal(e.to_string()))?;
+        Ok(())
+    }
+
+    async fn release_postings(&self, ids: &[PostingId]) -> Result<(), StoreError> {
+        let mut tx = self
+            .pool
+            .begin()
+            .await
+            .map_err(|e| StoreError::Internal(e.to_string()))?;
+
+        for id in ids {
+            let row =
+                sqlx::query("SELECT status FROM postings WHERE transfer_id = $1 AND idx = $2")
+                    .bind(id.transfer.0.as_slice())
+                    .bind(id.index as i16)
+                    .fetch_optional(&mut *tx)
+                    .await
+                    .map_err(|e| StoreError::Internal(e.to_string()))?
+                    .ok_or_else(|| StoreError::NotFound(format!("posting {id:?}")))?;
+            let status: i16 = row
+                .try_get("status")
+                .map_err(|e| StoreError::Internal(e.to_string()))?;
+            if status == 2 {
+                return Err(StoreError::PostingInactive(*id));
+            }
+        }
+
+        for id in ids {
+            sqlx::query("UPDATE postings SET status = $1 WHERE transfer_id = $2 AND idx = $3 AND status = $4")
+                .bind(status_to_i16(PostingStatus::Active))
+                .bind(id.transfer.0.as_slice())
+                .bind(id.index as i16)
+                .bind(status_to_i16(PostingStatus::PendingInactive))
+                .execute(&mut *tx)
+                .await
+                .map_err(|e| StoreError::Internal(e.to_string()))?;
+        }
+
+        tx.commit()
+            .await
+            .map_err(|e| StoreError::Internal(e.to_string()))?;
+        Ok(())
+    }
+
+    async fn finalize_postings(
+        &self,
+        deactivate: &[PostingId],
+        create: &[Posting],
+    ) -> Result<(), StoreError> {
+        let mut tx = self
+            .pool
+            .begin()
+            .await
+            .map_err(|e| StoreError::Internal(e.to_string()))?;
+
+        for id in deactivate {
+            sqlx::query("UPDATE postings SET status = $1 WHERE transfer_id = $2 AND idx = $3")
+                .bind(status_to_i16(PostingStatus::Inactive))
+                .bind(id.transfer.0.as_slice())
+                .bind(id.index as i16)
+                .execute(&mut *tx)
+                .await
+                .map_err(|e| StoreError::Internal(e.to_string()))?;
+        }
+
+        for posting in create {
+            sqlx::query(
+                "INSERT INTO postings (transfer_id, idx, owner, asset, value, status) VALUES ($1, $2, $3, $4, $5, $6)"
+            )
+                .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.value())
+                .bind(status_to_i16(posting.status))
+                .execute(&mut *tx)
+                .await
+                .map_err(|e| StoreError::Internal(e.to_string()))?;
+        }
+
+        tx.commit()
+            .await
+            .map_err(|e| StoreError::Internal(e.to_string()))?;
+        Ok(())
+    }
+}
+
+// ---------------------------------------------------------------------------
+// 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) -> Result<(), 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()))?;
+
+        sqlx::query("INSERT INTO transfers (id, transfer, receipt, created_at, book, code) VALUES ($1, $2, $3, $4, $5, $6)")
+            .bind(tid.0.as_slice())
+            .bind(&transfer_bytes)
+            .bind(&receipt_bytes)
+            .bind(record.created_at)
+            .bind(record.envelope.book() as i32)
+            .bind(record.envelope.code() as i32)
+            .execute(&mut *tx)
+            .await
+            .map_err(|e| StoreError::Internal(e.to_string()))?;
+
+        // Populate transfer_accounts join table
+        let mut account_ids: HashSet<i64> = HashSet::new();
+        for np in record.envelope.creates() {
+            account_ids.insert(np.owner.0);
+        }
+
+        for aid in &account_ids {
+            sqlx::query("INSERT INTO transfer_accounts (transfer_id, account_id) VALUES ($1, $2)")
+                .bind(tid.0.as_slice())
+                .bind(*aid)
+                .execute(&mut *tx)
+                .await
+                .map_err(|e| StoreError::Internal(e.to_string()))?;
+        }
+
+        tx.commit()
+            .await
+            .map_err(|e| StoreError::Internal(e.to_string()))?;
+        Ok(())
+    }
+
+    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;
+                }
+                if let Some(code) = query.code
+                    && r.envelope.code() != code
+                {
+                    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 OR REPLACE INTO sagas (id, data) VALUES ($1, $2)")
+            .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;
+
+        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)
+    }
+}

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

@@ -0,0 +1,42 @@
+CREATE TABLE IF NOT EXISTS accounts (
+    id          BIGINT NOT NULL,
+    version     BIGINT NOT NULL,
+    policy      TEXT NOT NULL,
+    flags       INTEGER NOT NULL,
+    book        INTEGER NOT NULL,
+    code        INTEGER 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       BIGINT NOT NULL,
+    status      SMALLINT NOT NULL,
+    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
+);
+
+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
+);

+ 5 - 0
crates/kuatia-storage-sql/src/migrations/002_timestamps_and_columns.sql

@@ -0,0 +1,5 @@
+ALTER TABLE transfers ADD COLUMN created_at BIGINT NOT NULL DEFAULT 0;
+ALTER TABLE transfers ADD COLUMN book INTEGER NOT NULL DEFAULT 0;
+ALTER TABLE transfers ADD COLUMN code INTEGER NOT NULL DEFAULT 0;
+CREATE INDEX idx_transfers_created_at ON transfers(created_at);
+CREATE INDEX idx_transfers_book ON transfers(book);

+ 6 - 0
crates/kuatia-storage-sql/src/migrations/003_events.sql

@@ -0,0 +1,6 @@
+CREATE TABLE IF NOT EXISTS events (
+    seq       BIGINT PRIMARY KEY,
+    timestamp BIGINT NOT NULL,
+    kind      TEXT NOT NULL,
+    data      BLOB NOT NULL
+);

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

@@ -0,0 +1,18 @@
+#![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);

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

@@ -0,0 +1,18 @@
+[package]
+name = "kuatia-storage"
+version = "0.1.0"
+edition = "2024"
+license.workspace = true
+
+[lints]
+workspace = true
+
+[dependencies]
+kuatia-types = { path = "../kuatia-types" }
+async-trait = "0.1"
+tokio = { version = "1", features = ["sync", "rt", "macros"] }
+serde = { version = "1", features = ["derive"] }
+paste = "1"
+
+[dev-dependencies]
+tokio = { version = "1", features = ["full"] }

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

@@ -0,0 +1,29 @@
+# kuatia-storage
+
+Storage abstraction for the kuatia ledger.
+
+Defines the `Store` trait (composed of four 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, reserve/release/finalize lifecycle |
+| `TransferStore` | Transfer persistence and queries |
+| `SagaStore` | Saga state for crash recovery |
+
+`Store` is a blanket trait — any type implementing all four 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 22 tests covering every Store method.

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

@@ -0,0 +1,51 @@
+//! Error types for storage implementations.
+
+use kuatia_types::{AccountId, PostingId};
+
+/// Errors produced by [`Store`](crate::store::Store) implementations.
+#[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),
+    /// Attempted to reserve a posting that is not Active.
+    PostingNotActive(PostingId),
+    /// Attempted to release a void (Inactive) posting.
+    PostingInactive(PostingId),
+}
+
+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}"),
+            Self::PostingNotActive(id) => write!(f, "posting not active: {id:?}"),
+            Self::PostingInactive(id) => write!(f, "posting is void (inactive): {id:?}"),
+        }
+    }
+}
+
+impl std::error::Error for StoreError {}

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

@@ -0,0 +1,65 @@
+//! 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,
+}
+
+/// Persistent event log for ledger events.
+#[async_trait]
+pub trait EventStore: Send + Sync {
+    /// Append an event and return its assigned sequence number.
+    ///
+    /// The `seq` field on the input event 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 four 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;

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

@@ -0,0 +1,316 @@
+//! 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, EnvelopeId, Posting, PostingId, PostingStatus};
+
+use crate::error::StoreError;
+use crate::events::{EventStore, LedgerEvent};
+use crate::store::{AccountStore, 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>>,
+    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()),
+            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]) -> Result<(), StoreError> {
+        let mut postings = self.postings.write().await;
+        for id in ids {
+            let posting = postings
+                .get(id)
+                .ok_or_else(|| StoreError::NotFound(format!("posting {id:?}")))?;
+            if posting.status != PostingStatus::Active {
+                return Err(StoreError::PostingNotActive(*id));
+            }
+        }
+        for id in ids {
+            postings
+                .get_mut(id)
+                .ok_or_else(|| StoreError::NotFound(format!("posting {id:?}")))?
+                .status = PostingStatus::PendingInactive;
+        }
+        Ok(())
+    }
+
+    async fn release_postings(&self, ids: &[PostingId]) -> Result<(), StoreError> {
+        let mut postings = self.postings.write().await;
+        for id in ids {
+            let posting = postings
+                .get(id)
+                .ok_or_else(|| StoreError::NotFound(format!("posting {id:?}")))?;
+            if posting.status == PostingStatus::Inactive {
+                return Err(StoreError::PostingInactive(*id));
+            }
+        }
+        for id in ids {
+            let posting = postings
+                .get_mut(id)
+                .ok_or_else(|| StoreError::NotFound(format!("posting {id:?}")))?;
+            if posting.status == PostingStatus::PendingInactive {
+                posting.status = PostingStatus::Active;
+            }
+        }
+        Ok(())
+    }
+
+    async fn finalize_postings(
+        &self,
+        deactivate: &[PostingId],
+        create: &[Posting],
+    ) -> Result<(), StoreError> {
+        let mut postings = self.postings.write().await;
+        for pid in deactivate {
+            if let Some(p) = postings.get_mut(pid) {
+                p.status = PostingStatus::Inactive;
+            }
+        }
+        for posting in create {
+            postings.insert(posting.id, posting.clone());
+        }
+        Ok(())
+    }
+}
+
+// ---------------------------------------------------------------------------
+// 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) -> Result<(), StoreError> {
+        let mut transfers = self.transfers.write().await;
+        transfers.insert(record.receipt.transfer_id, record);
+        Ok(())
+    }
+
+    async fn get_transfers_for_account(
+        &self,
+        account: &AccountId,
+    ) -> Result<Vec<EnvelopeRecord>, StoreError> {
+        let transfers = self.transfers.read().await;
+        let postings = self.postings.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 seq = self.autoid.next() as u64;
+        let mut events = self.events.write().await;
+        let stored = LedgerEvent {
+            seq,
+            timestamp: event.timestamp,
+            kind: event.kind.clone(),
+        };
+        events.push(stored);
+        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())
+    }
+}

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

@@ -0,0 +1,218 @@
+//! Storage abstraction separating the pure decision logic from IO.
+//!
+//! The [`Store`] trait composes four focused sub-traits:
+//! - [`AccountStore`] — account CRUD and versioning
+//! - [`PostingStore`] — posting reads and lifecycle transitions
+//! - [`TransferStore`] — transfer persistence and queries
+//! - [`SagaStore`] — saga state for crash recovery
+
+use async_trait::async_trait;
+use kuatia_types::{
+    Account, AccountId, AssetId, Envelope, EnvelopeId, Posting, PostingId, PostingStatus, Receipt,
+};
+
+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 label.
+    pub book: Option<u32>,
+    /// Filter by code.
+    pub code: Option<u32>,
+    /// 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.
+    /// Atomic: if any posting is not Active, the entire batch fails.
+    async fn reserve_postings(&self, ids: &[PostingId]) -> Result<(), StoreError>;
+    /// Release postings back from reservation.
+    /// - PendingInactive → Active
+    /// - Active → no-op (already released)
+    /// - Inactive → fail (void posting cannot be released)
+    /// Atomic: if any posting is Inactive, the entire batch fails.
+    async fn release_postings(&self, ids: &[PostingId]) -> Result<(), StoreError>;
+    /// Deactivate postings and insert newly created postings.
+    async fn finalize_postings(
+        &self,
+        deactivate: &[PostingId],
+        create: &[Posting],
+    ) -> Result<(), 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 committed transfer and its receipt.
+    async fn store_transfer(&self, record: EnvelopeRecord) -> Result<(), 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;
+                }
+                if let Some(code) = query.code
+                    && r.envelope.code() != code
+                {
+                    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>;
+}
+
+// ---------------------------------------------------------------------------
+// Composite trait
+// ---------------------------------------------------------------------------
+
+/// Async storage abstraction composing all sub-traits.
+pub trait Store: AccountStore + PostingStore + TransferStore + SagaStore + EventStore {}
+
+impl<T: AccountStore + PostingStore + TransferStore + SagaStore + EventStore> Store for T {}

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

@@ -0,0 +1,740 @@
+//! Generic conformance test suite for [`Store`](crate::store::Store) implementations.
+//!
+//! Use the [`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: 0,
+        code: 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 {
+        id: PostingId {
+            transfer: EnvelopeId(transfer_hash),
+            index,
+        },
+        owner: AccountId::new(owner),
+        asset: AssetId::new(asset),
+        value: Cent::from(value),
+        status: PostingStatus::Active,
+    }
+}
+
+fn make_envelope_with_book(book: u32) -> (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 value to create distinct EnvelopeIds.
+    let mut tid_bytes = [0u8; 32];
+    tid_bytes[0] = book 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)
+}
+
+// ---------------------------------------------------------------------------
+// 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
+// ---------------------------------------------------------------------------
+
+/// Finalize with empty deactivate creates new postings.
+pub async fn finalize_creates_postings(store: &(impl Store + 'static)) {
+    let p = make_posting([1; 32], 0, 1, 1, 100);
+    store
+        .finalize_postings(&[], std::slice::from_ref(&p))
+        .await
+        .unwrap();
+
+    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);
+    store.finalize_postings(&[], &[p1, p2, p3]).await.unwrap();
+
+    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();
+    store.finalize_postings(&[], &postings).await.unwrap();
+
+    // 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);
+    store
+        .finalize_postings(&[], &[p1.clone(), p2.clone()])
+        .await
+        .unwrap();
+
+    store.reserve_postings(&[p1.id, p2.id]).await.unwrap();
+
+    let got = store.get_postings(&[p1.id, p2.id]).await.unwrap();
+    assert!(
+        got.iter()
+            .all(|p| p.status == PostingStatus::PendingInactive)
+    );
+}
+
+/// Reserve fails if any posting is not Active — no partial mutation.
+pub async fn reserve_non_active_fails(store: &(impl Store + 'static)) {
+    let p1 = make_posting([1; 32], 0, 1, 1, 100);
+    let p2 = make_posting([1; 32], 1, 1, 1, 200);
+    store
+        .finalize_postings(&[], &[p1.clone(), p2.clone()])
+        .await
+        .unwrap();
+
+    store.reserve_postings(&[p1.id]).await.unwrap();
+
+    let err = store.reserve_postings(&[p1.id, p2.id]).await.unwrap_err();
+    assert!(matches!(err, StoreError::PostingNotActive(_)));
+
+    let got = store.get_postings(&[p2.id]).await.unwrap();
+    assert_eq!(got[0].status, PostingStatus::Active);
+}
+
+/// 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);
+    store
+        .finalize_postings(&[], std::slice::from_ref(&p1))
+        .await
+        .unwrap();
+    store.reserve_postings(&[p1.id]).await.unwrap();
+
+    store.release_postings(&[p1.id]).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);
+    store
+        .finalize_postings(&[], std::slice::from_ref(&p1))
+        .await
+        .unwrap();
+
+    store.release_postings(&[p1.id]).await.unwrap();
+
+    let got = store.get_postings(&[p1.id]).await.unwrap();
+    assert_eq!(got[0].status, PostingStatus::Active);
+}
+
+/// Releasing an Inactive (void) posting fails.
+pub async fn release_inactive_fails(store: &(impl Store + 'static)) {
+    let p1 = make_posting([1; 32], 0, 1, 1, 100);
+    store
+        .finalize_postings(&[], std::slice::from_ref(&p1))
+        .await
+        .unwrap();
+
+    store.finalize_postings(&[p1.id], &[]).await.unwrap();
+
+    let err = store.release_postings(&[p1.id]).await.unwrap_err();
+    assert!(matches!(err, StoreError::PostingInactive(_)));
+}
+
+/// Finalize transitions PendingInactive → Inactive.
+pub async fn finalize_deactivates_postings(store: &(impl Store + 'static)) {
+    let p1 = make_posting([1; 32], 0, 1, 1, 100);
+    store
+        .finalize_postings(&[], std::slice::from_ref(&p1))
+        .await
+        .unwrap();
+    store.reserve_postings(&[p1.id]).await.unwrap();
+
+    let p2 = make_posting([2; 32], 0, 1, 1, 100);
+    store
+        .finalize_postings(&[p1.id], 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);
+}
+
+// ---------------------------------------------------------------------------
+// TransferStore tests
+// ---------------------------------------------------------------------------
+
+/// Store a transfer record and retrieve by id.
+pub async fn store_and_get_transfer(store: &(impl Store + 'static)) {
+    let (envelope, tid) = make_envelope();
+    let record = EnvelopeRecord {
+        envelope,
+        receipt: Receipt { transfer_id: tid },
+        created_at: 1000,
+    };
+    store.store_transfer(record.clone()).await.unwrap();
+
+    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();
+    let record = EnvelopeRecord {
+        envelope,
+        receipt: Receipt { transfer_id: tid },
+        created_at: 1000,
+    };
+    store.store_transfer(record).await.unwrap();
+
+    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 store/retrieve.
+pub async fn store_transfer_preserves_created_at(store: &(impl Store + 'static)) {
+    let (envelope, tid) = make_envelope();
+    let record = EnvelopeRecord {
+        envelope,
+        receipt: Receipt { transfer_id: tid },
+        created_at: 1718000000000,
+    };
+    store.store_transfer(record.clone()).await.unwrap();
+
+    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();
+    store
+        .store_transfer(EnvelopeRecord {
+            envelope: e1,
+            receipt: Receipt { transfer_id: t1 },
+            created_at: 1000,
+        })
+        .await
+        .unwrap();
+
+    let (e2, t2) = make_envelope_with_book(1);
+    store
+        .store_transfer(EnvelopeRecord {
+            envelope: e2,
+            receipt: Receipt { transfer_id: t2 },
+            created_at: 2000,
+        })
+        .await
+        .unwrap();
+
+    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);
+        store
+            .store_transfer(EnvelopeRecord {
+                envelope,
+                receipt: Receipt { transfer_id: tid },
+                created_at: (i as i64 + 1) * 1000,
+            })
+            .await
+            .unwrap();
+    }
+
+    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
+    store
+        .store_transfer(EnvelopeRecord {
+            envelope: e1,
+            receipt: Receipt { transfer_id: t1 },
+            created_at: 1000,
+        })
+        .await
+        .unwrap();
+
+    let (e2, t2) = make_envelope_with_book(5);
+    store
+        .store_transfer(EnvelopeRecord {
+            envelope: e2,
+            receipt: Receipt { transfer_id: t2 },
+            created_at: 2000,
+        })
+        .await
+        .unwrap();
+
+    let page = store
+        .query_transfers(&TransferQuery {
+            account: Some(AccountId::new(1)),
+            book: Some(5),
+            ..Default::default()
+        })
+        .await
+        .unwrap();
+    assert_eq!(page.total, 1);
+    assert_eq!(page.items[0].envelope.book(), 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);
+}
+
+// ---------------------------------------------------------------------------
+// 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
+            finalize_creates_postings,
+            get_postings_missing_fails,
+            get_postings_by_account_filters,
+            query_postings_pagination,
+            reserve_postings_batch,
+            reserve_non_active_fails,
+            release_postings_batch,
+            release_active_is_noop,
+            release_inactive_fails,
+            finalize_deactivates_postings,
+            // TransferStore
+            store_and_get_transfer,
+            get_missing_transfer,
+            get_transfers_for_account,
+            store_transfer_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,
+        );
+    };
+
+    (@tests $factory:path, $($test:ident),+ $(,)?) => {
+        ::paste::paste! {
+            $(
+                #[tokio::test]
+                async fn [< $test >]() {
+                    $crate::store_tests::$test(&$factory().await).await;
+                }
+            )+
+        }
+    };
+}

+ 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);

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

@@ -0,0 +1,12 @@
+[package]
+name = "kuatia-types"
+version = "0.1.0"
+edition = "2024"
+license.workspace = true
+
+[lints]
+workspace = true
+
+[dependencies]
+serde = { version = "1", features = ["derive"] }
+bitflags = { version = "2", features = ["serde"] }

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

@@ -0,0 +1,24 @@
+# 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 |
+| `Transfer` | Atomic unit: consumes + creates postings |
+| `Account` | Versioned entity with policy and flags |
+| `PostingStatus` | `Active` → `PendingInactive` → `Inactive` |
+
+## Traits
+
+- **`ToBytes`** — deterministic binary serialization for content-addressing

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

@@ -0,0 +1,183 @@
+//! 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: Unix milliseconds (40 bits ≈ 34.8 years from epoch)
+//! - Bits 22–0: lower 23 bits of CRC32 of context data, or an internal
+//!   counter that wraps on overflow when no data is provided.
+
+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;
+
+/// 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.
+    pub 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 {
+        SystemTime::now()
+            .duration_since(UNIX_EPOCH)
+            .unwrap_or_default()
+            .as_millis() as u64
+    }
+
+    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);
+    }
+}

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

@@ -0,0 +1,1003 @@
+//! 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.
+pub const CANONICAL_VERSION: u8 = 1;
+
+/// 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 — low-level stored monetary amount
+// ---------------------------------------------------------------------------
+
+/// A monetary amount in the smallest unit (e.g. cents for USD).
+///
+/// Wraps `i64` with a private field so that monetary values are never confused
+/// with plain integers. Used everywhere a monetary amount is stored or compared.
+#[derive(Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash, Default, Serialize, Deserialize)]
+pub struct Cent(i64);
+
+/// Returned when a [`Cent`] arithmetic operation would overflow or underflow.
+#[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 {}
+
+impl Cent {
+    /// The zero amount.
+    pub const ZERO: Cent = Cent(0);
+
+    /// Returns the underlying `i64` value.
+    pub fn value(self) -> i64 {
+        self.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`] if `self == i64::MIN`.
+    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)
+    }
+}
+
+impl From<i64> for Cent {
+    fn from(v: i64) -> Self {
+        Self(v)
+    }
+}
+
+impl From<i32> for Cent {
+    fn from(v: i32) -> Self {
+        Self(v as i64)
+    }
+}
+
+impl From<u32> for Cent {
+    fn from(v: u32) -> Self {
+        Self(v as i64)
+    }
+}
+
+impl From<u8> for Cent {
+    fn from(v: u8) -> Self {
+        Self(v as i64)
+    }
+}
+
+impl From<i8> for Cent {
+    fn from(v: i8) -> Self {
+        Self(v as i64)
+    }
+}
+
+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 ToBytes for Cent {
+    fn to_bytes(&self) -> Vec<u8> {
+        self.0.to_be_bytes().to_vec()
+    }
+}
+
+// ---------------------------------------------------------------------------
+// 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: i64 = if whole_str.is_empty() {
+            0
+        } else {
+            whole_str
+                .parse()
+                .map_err(|_| 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: i64 = if frac_str.is_empty() {
+            0
+        } else {
+            let padded = format!("{:0<width$}", frac_str, width = self.decimals as usize);
+            padded
+                .parse()
+                .map_err(|_| ParseAmountError::InvalidFormat(s.to_string()))?
+        };
+
+        let multiplier = 10i64.pow(self.decimals as u32);
+        let value = whole
+            .checked_mul(multiplier)
+            .and_then(|v| v.checked_add(frac))
+            .ok_or_else(|| ParseAmountError::InvalidFormat(s.to_string()))?;
+
+        Ok(Cent::from(if negative {
+            value
+                .checked_neg()
+                .ok_or_else(|| ParseAmountError::InvalidFormat(s.to_string()))?
+        } else {
+            value
+        }))
+    }
+
+    /// Formats a [`Cent`] value as a decimal string.
+    pub fn format(&self, cent: Cent) -> String {
+        if self.decimals == 0 {
+            return cent.value().to_string();
+        }
+
+        let value = cent.value();
+        let negative = value < 0;
+        let abs = value.unsigned_abs();
+        let multiplier = 10u64.pow(self.decimals as u32);
+        let whole = abs / multiplier;
+        let frac = abs % multiplier;
+
+        let sign = if negative { "-" } else { "" };
+        format!(
+            "{sign}{whole}.{frac:0>width$}",
+            width = self.decimals as usize
+        )
+    }
+}
+
+// ---------------------------------------------------------------------------
+// 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 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)
+    }
+}
+
+// ---------------------------------------------------------------------------
+// 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.
+#[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 = holding, negative = liability.
+    pub value: Cent,
+    /// Lifecycle state — only `Active` postings count toward balance.
+    pub status: PostingStatus,
+}
+
+impl Posting {
+    /// 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 = holding, negative = liability.
+    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>,
+    /// Grouping label (e.g. tenant or product).
+    pub book: u32,
+    /// Category code for this envelope.
+    pub code: u32,
+    /// 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
+    }
+
+    /// Grouping label (e.g. tenant or product).
+    pub fn book(&self) -> u32 {
+        self.book
+    }
+
+    /// Category code for this envelope.
+    pub fn code(&self) -> u32 {
+        self.code
+    }
+
+    /// 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 label.
+    pub fn book(mut self, book: u32) -> Self {
+        self.envelope.book = book;
+        self
+    }
+
+    /// Set the category code.
+    pub fn code(mut self, code: u32) -> Self {
+        self.envelope.code = code;
+        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 holding the negative side of deposits.
+    ExternalAccount,
+}
+
+bitflags::bitflags! {
+    /// Lifecycle flags for an [`Account`].
+    #[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;
+    }
+}
+
+/// 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,
+    /// Grouping label (e.g. tenant or product).
+    pub book: u32,
+    /// Category code.
+    pub code: u32,
+    /// Fixed-width secondary identifiers.
+    pub user_data: UserData,
+    /// Free-form key-value metadata.
+    pub metadata: Metadata,
+}
+
+impl Account {
+    /// 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 liability 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>,
+    /// Grouping label (e.g. tenant or product).
+    pub book: u32,
+    /// Category code.
+    pub code: u32,
+    /// 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 a liability 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 label.
+    pub fn book(mut self, book: u32) -> Self {
+        self.transfer.book = book;
+        self
+    }
+
+    /// Set the category code.
+    pub fn code(mut self, code: u32) -> Self {
+        self.transfer.code = code;
+        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 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());
+        }
+
+        write_u32(&mut buf, self.book);
+        write_u32(&mut buf, self.code);
+        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());
+        write_u32(&mut buf, self.book);
+        write_u32(&mut buf, self.code);
+        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()
+    }
+}

+ 21 - 0
crates/kuatia/Cargo.toml

@@ -0,0 +1,21 @@
+[package]
+name = "kuatia"
+version = "0.1.0"
+edition = "2024"
+license.workspace = true
+
+[lints]
+workspace = true
+
+[dependencies]
+kuatia-types = { path = "../kuatia-types" }
+kuatia-core = { path = "../kuatia-core" }
+kuatia-storage = { path = "../kuatia-storage" }
+legend = "0.1"
+tokio = { version = "1", features = ["sync", "rt", "macros"] }
+serde = { version = "1", features = ["derive"] }
+async-trait = "0.1"
+tracing = "0.1"
+
+[dev-dependencies]
+tokio = { version = "1", features = ["full"] }

+ 70 - 0
crates/kuatia/README.md

@@ -0,0 +1,70 @@
+# 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 |
+
+### Saga commit
+
+`commit(transfer)` runs the four-step pipeline with automatic compensation:
+1. **Resolve** — convert Transfer intent into concrete Envelope
+2. **Reserve** — batch CAS: Active → PendingInactive
+3. **Validate** — pure `validate_and_plan()`
+4. **Finalize** — PendingInactive → Inactive, create new postings, emit event
+
+### Atomic commit
+
+`commit_atomic(envelope)` — single-pass load → plan → apply. Used by `reverse()`.
+
+### 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,
+    }
+}
+```

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

@@ -0,0 +1,99 @@
+//! 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, 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),
+    /// 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::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
+    }
+}

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

@@ -0,0 +1,584 @@
+//! 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, AccountSnapshotId, AssetId, Cent, Envelope, EnvelopeBuilder, EnvelopeId, NewPosting,
+    PlanInput, Posting, PostingId, PostingStatus, Receipt, 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,
+    ResolveInput, ResolveStep, SagaError, ValidateInput, ValidateTransferStep,
+};
+use kuatia_storage::events::{LedgerEvent, LedgerEventKind};
+use kuatia_storage::store::{EnvelopeRecord, Store};
+
+#[allow(missing_docs)]
+mod transfer_saga {
+    use super::*;
+    legend! {
+        TransferSaga<LedgerCtx, SagaError> {
+            resolve: ResolveStep,
+            reserve: ReservePostingsStep,
+            validate: ValidateTransferStep,
+            finalize: FinalizeTransferStep,
+        }
+    }
+}
+use transfer_saga::*;
+
+/// Async ledger resource composing the four-phase 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);
+        }
+
+        Ok(LoadedState {
+            consumed_postings,
+            accounts,
+            balances,
+        })
+    }
+
+    /// 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,
+        };
+        Ok(validate_and_plan(input)?)
+    }
+
+    /// Phase 3: apply the plan to storage and return a receipt.
+    #[instrument(skip(self, envelope, plan), name = "ledger.apply")]
+    pub async fn apply(
+        &self,
+        envelope: &Envelope,
+        plan: &kuatia_core::Plan,
+    ) -> Result<Receipt, LedgerError> {
+        self.store
+            .finalize_postings(&plan.postings_to_deactivate, &plan.postings_to_create)
+            .await?;
+
+        let receipt = Receipt {
+            transfer_id: plan.transfer_id,
+        };
+
+        self.store
+            .store_transfer(EnvelopeRecord {
+                envelope: envelope.clone(),
+                receipt: receipt.clone(),
+                created_at: now_millis()?,
+            })
+            .await?;
+
+        let _ = self
+            .store
+            .append_event(&LedgerEvent {
+                seq: 0,
+                timestamp: now_millis()?,
+                kind: LedgerEventKind::TransferCommitted {
+                    transfer_id: receipt.transfer_id,
+                },
+            })
+            .await;
+
+        Ok(receipt)
+    }
+
+    // -----------------------------------------------------------------------
+    // 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 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,
+                });
+            }
+        }
+
+        let mut envelope = EnvelopeBuilder::new()
+            .consumes(consumes)
+            .creates(creates)
+            .book(transfer.book)
+            .code(transfer.code)
+            .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)
+    }
+
+    // -----------------------------------------------------------------------
+    // Atomic commit (raw path -- used by reverse() and internal callers)
+    // -----------------------------------------------------------------------
+
+    /// Load, validate, and apply an envelope in one shot (no saga).
+    #[instrument(skip(self, envelope), name = "ledger.commit_atomic")]
+    pub async fn commit_atomic(&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?);
+        }
+
+        let tid = envelope_id(&envelope);
+        if let Some(record) = self.store.get_transfer(&tid).await? {
+            return Ok(record.receipt);
+        }
+
+        let loaded = self.load(&envelope).await?;
+        let plan = self.plan(&envelope, &loaded)?;
+        self.apply(&envelope, &plan).await
+    }
+
+    // -----------------------------------------------------------------------
+    // Saga commit: resolve -> reserve -> validate -> finalize (via legend)
+    // -----------------------------------------------------------------------
+
+    /// Commit a transfer intent using the saga pipeline driven by legend.
+    ///
+    /// Steps: resolve movements into envelope -> reserve consumed postings ->
+    /// validate -> finalize.
+    /// On failure, legend compensates completed steps in reverse order.
+    #[instrument(skip(self, transfer), fields(book = transfer.book, code = transfer.code), name = "ledger.commit")]
+    pub async fn commit(self: &Arc<Self>, transfer: Transfer) -> Result<Receipt, LedgerError> {
+        let saga = TransferSaga::new(TransferSagaInputs {
+            resolve: ResolveInput {
+                transfer: transfer.clone(),
+            },
+            reserve: ReserveInput,
+            validate: ValidateInput,
+            finalize: FinalizeInput,
+        });
+
+        let ctx = LedgerCtx::new(Arc::clone(self));
+        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(kuatia_storage::error::StoreError::Internal(
+                        "saga completed but no receipt".into(),
+                    ))
+                })
+            }
+            ExecutionResult::Failed(_, err) => Err(LedgerError::Store(
+                kuatia_storage::error::StoreError::Internal(err.message),
+            )),
+            ExecutionResult::CompensationFailed {
+                original_error,
+                compensation_error,
+                ..
+            } => Err(LedgerError::CompensationFailed {
+                original: Box::new(LedgerError::Store(
+                    kuatia_storage::error::StoreError::Internal(original_error.message),
+                )),
+                compensation: Box::new(LedgerError::Store(
+                    kuatia_storage::error::StoreError::Internal(compensation_error.message),
+                )),
+            }),
+            ExecutionResult::Paused(_) => Err(LedgerError::Store(
+                kuatia_storage::error::StoreError::Internal("saga paused unexpectedly".into()),
+            )),
+        }
+    }
+
+    // -----------------------------------------------------------------------
+    // Reverse
+    // -----------------------------------------------------------------------
+
+    /// Create and commit a reversal envelope for the given envelope id.
+    #[instrument(skip(self), name = "ledger.reverse")]
+    pub async fn reverse(&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())
+            .code(original.code())
+            .metadata(original.metadata().clone())
+            .build();
+
+        self.commit_atomic(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?;
+        let _ = 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?;
+        let _ = 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));
+        }
+        let has_active = !self
+            .store
+            .get_postings_by_account(id, None, Some(PostingStatus::Active))
+            .await?
+            .is_empty();
+        if has_active {
+            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?;
+        let _ = 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?;
+        let _ = self
+            .store
+            .append_event(&LedgerEvent {
+                seq: 0,
+                timestamp: now_millis()?,
+                kind: LedgerEventKind::AccountCreated { account_id: id },
+            })
+            .await;
+        Ok(())
+    }
+
+    /// 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>,
+}

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

@@ -0,0 +1,12 @@
+//! 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};

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

@@ -0,0 +1,508 @@
+//! Legend saga step adapters for the ledger.
+//!
+//! Provides [`Step`](legend::Step) implementations so the ledger can participate
+//! in multi-resource saga workflows. Each step commits a transfer in `execute`
+//! and reverses it in `compensate`, giving you automatic rollback across
+//! resource boundaries.
+//!
+//! # Transfer pipeline saga
+//!
+//! The core transfer pipeline is broken into four saga steps:
+//!
+//! 1. **ResolveStep** -- resolve a `Transfer` intent into an `Envelope`
+//! 2. **ReservePostingsStep** -- CAS each consumed posting from Active to PendingInactive
+//! 3. **ValidateTransferStep** -- load accounts/balances, run `validate_and_plan()`
+//! 4. **FinalizeTransferStep** -- PendingInactive to Inactive, create new postings, store envelope
+//!
+//! The `TransferSaga` is defined via `legend!` in `ledger.rs` and driven by
+//! `commit()`.
+//!
+//! # 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, Plan, PlanInput, PostingId, Receipt, Transfer,
+    TransferBuilder, validate_and_plan,
+};
+
+use crate::error::LedgerError;
+use crate::ledger::{Ledger, now_millis};
+use kuatia_storage::events::{LedgerEvent, LedgerEventKind};
+use kuatia_storage::store::EnvelopeRecord;
+
+// ---------------------------------------------------------------------------
+// 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>,
+    /// Validated plan produced by the validate step.
+    pub plan: Option<Plan>,
+    /// Resolved envelope produced by the resolve step.
+    pub envelope: Option<Envelope>,
+    #[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_plan", &self.plan.is_some())
+            .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(),
+            plan: None,
+            envelope: None,
+            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(),
+        })
+    }
+}
+
+// ===========================================================================
+// Transfer pipeline steps (resolve -> reserve -> validate -> finalize)
+// ===========================================================================
+
+// ---------------------------------------------------------------------------
+// Step 1: ResolveStep
+// ---------------------------------------------------------------------------
+
+/// Input for the resolve step: the transfer intent to resolve.
+#[derive(Debug, Clone, Serialize, Deserialize)]
+pub struct ResolveInput {
+    /// The transfer intent to resolve into a concrete envelope.
+    pub transfer: Transfer,
+}
+
+/// Resolves a [`Transfer`] intent into a concrete [`Envelope`] by selecting
+/// postings for each movement.
+///
+/// Compensation is a no-op (no side effects).
+pub struct ResolveStep;
+
+#[async_trait]
+impl Step<LedgerCtx, SagaError> for ResolveStep {
+    type Input = ResolveInput;
+
+    async fn execute(ctx: &mut LedgerCtx, input: &ResolveInput) -> Result<StepOutcome, SagaError> {
+        async {
+            let ledger = ctx.ledger()?;
+            let envelope = ledger
+                .resolve(&input.transfer)
+                .await
+                .map_err(SagaError::from)?;
+            ctx.envelope = Some(envelope);
+            Ok(StepOutcome::Continue)
+        }
+        .instrument(tracing::info_span!("saga_step", step = "resolve"))
+        .await
+    }
+
+    async fn compensate(
+        _ctx: &mut LedgerCtx,
+        _input: &ResolveInput,
+    ) -> Result<CompensationOutcome, SagaError> {
+        Ok(CompensationOutcome::Completed)
+    }
+}
+
+// ---------------------------------------------------------------------------
+// Step 2: 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 envelope = ctx.envelope.as_ref().ok_or(SagaError {
+                message: "no envelope in context -- resolve step must run first".into(),
+            })?;
+            let posting_ids: Vec<PostingId> = envelope.consumes().to_vec();
+
+            ctx.ledger()?
+                .store()
+                .reserve_postings(&posting_ids)
+                .await
+                .map_err(|e| SagaError::from(LedgerError::Store(e)))?;
+            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)
+            .await
+            .map_err(|e| SagaError::from(LedgerError::Store(e)))?;
+        ctx.reserved_postings.clear();
+        Ok(CompensationOutcome::Completed)
+    }
+
+    fn retry_policy() -> RetryPolicy {
+        RetryPolicy::retries(3)
+    }
+}
+
+// ---------------------------------------------------------------------------
+// Step 3: ValidateTransferStep
+// ---------------------------------------------------------------------------
+
+/// Input for the validate step (envelope comes from ctx).
+#[derive(Debug, Clone, Serialize, Deserialize)]
+pub struct ValidateInput;
+
+/// Loads accounts and balances, then runs `validate_and_plan()`.
+///
+/// Stores the resulting [`Plan`] in the context for the finalize step.
+/// Compensation is a no-op (reads only).
+pub struct ValidateTransferStep;
+
+#[async_trait]
+impl Step<LedgerCtx, SagaError> for ValidateTransferStep {
+    type Input = ValidateInput;
+
+    async fn execute(
+        ctx: &mut LedgerCtx,
+        _input: &ValidateInput,
+    ) -> Result<StepOutcome, SagaError> {
+        async {
+            let envelope = ctx.envelope.as_ref().ok_or(SagaError {
+                message: "no envelope in context -- resolve step must run first".into(),
+            })?;
+
+            let ledger = ctx.ledger()?;
+            let loaded = ledger.load(envelope).await.map_err(SagaError::from)?;
+
+            let plan_input = PlanInput {
+                envelope,
+                consumed_postings: &loaded.consumed_postings,
+                accounts: &loaded.accounts,
+                balances: &loaded.balances,
+            };
+
+            let plan =
+                validate_and_plan(plan_input).map_err(|e| SagaError::from(LedgerError::from(e)))?;
+            ctx.plan = Some(plan);
+            Ok(StepOutcome::Continue)
+        }
+        .instrument(tracing::info_span!("saga_step", step = "validate"))
+        .await
+    }
+
+    async fn compensate(
+        _ctx: &mut LedgerCtx,
+        _input: &ValidateInput,
+    ) -> Result<CompensationOutcome, SagaError> {
+        Ok(CompensationOutcome::Completed)
+    }
+}
+
+// ---------------------------------------------------------------------------
+// Step 4: FinalizeTransferStep
+// ---------------------------------------------------------------------------
+
+/// Input for the finalize step (envelope and plan come from ctx).
+#[derive(Debug, Clone, Serialize, Deserialize)]
+pub struct FinalizeInput;
+
+/// Finalizes the envelope: PendingInactive to Inactive, creates new postings,
+/// stores the envelope record.
+///
+/// Compensation reverses the finalized envelope.
+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 plan = ctx.plan.take().ok_or(SagaError {
+                message: "no plan in context -- validate step must run first".into(),
+            })?;
+
+            let envelope = ctx.envelope.as_ref().ok_or(SagaError {
+                message: "no envelope in context -- resolve step must run first".into(),
+            })?;
+
+            let store = ctx.ledger()?.store();
+
+            store
+                .finalize_postings(&plan.postings_to_deactivate, &plan.postings_to_create)
+                .await
+                .map_err(|e| SagaError::from(LedgerError::Store(e)))?;
+
+            let receipt = Receipt {
+                transfer_id: plan.transfer_id,
+            };
+            store
+                .store_transfer(EnvelopeRecord {
+                    envelope: envelope.clone(),
+                    receipt: receipt.clone(),
+                    created_at: now_millis().map_err(SagaError::from)?,
+                })
+                .await
+                .map_err(|e| SagaError::from(LedgerError::Store(e)))?;
+
+            let _ = store
+                .append_event(&LedgerEvent {
+                    seq: 0,
+                    timestamp: now_millis().map_err(SagaError::from)?,
+                    kind: LedgerEventKind::TransferCommitted {
+                        transfer_id: receipt.transfer_id,
+                    },
+                })
+                .await;
+
+            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()?.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()?.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
+    }
+}

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

@@ -0,0 +1,716 @@
+#![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: 0,
+        code: 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_atomic(envelope.clone()).await.unwrap();
+    let r2 = ledger.commit_atomic(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_atomic(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 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_atomic(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);
+}

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

@@ -0,0 +1,227 @@
+#![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: 0,
+        code: 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",
+    }
+}

+ 105 - 0
doc/accounts.md

@@ -0,0 +1,105 @@
+# Accounts
+
+## Overview
+
+An account is a versioned entity that owns postings. Balance is never stored — it is always computed as the sum of active postings for a given (account, asset) pair.
+
+## 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` |
+| `book` | `u32` | Grouping label (e.g. tenant or product line) |
+| `code` | `u32` | Category code (e.g. chart of accounts) |
+| `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` | No | Yes |
+| `UncappedOverdraft` | None | No | No |
+| `SystemAccount` | None | Yes | No |
+| `ExternalAccount` | None | Yes | No |
+
+Only `SystemAccount` and `ExternalAccount` may hold negative postings (liabilities). Validation rejects any transfer that would create a negative posting on another account type.
+
+`CappedOverdraft` accounts emit CAS (Compare-And-Swap) guards during validation to prevent write-skew — two concurrent transfers could each pass validation independently but together push the balance below the floor.
+
+## 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 for fees, settlement, market-making. Can hold negative postings (liabilities). Used as the counterparty in deposits — the system account takes on a negative balance to represent the liability.
+
+### External accounts (`ExternalAccount`)
+
+Boundary accounts representing entities outside the ledger (banks, payment processors). Like system accounts, they can hold negative postings. Used to track money entering and leaving the system.
+
+### Credit accounts (`CappedOverdraft`)
+
+Accounts with a negative floor (e.g. credit lines). The floor is the maximum allowed overdraft. Write-skew prevention via CAS guards ensures concurrent transfers respect the floor.

+ 319 - 0
doc/architecture.md

@@ -0,0 +1,319 @@
+# 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 a holding; a negative posting is a liability (used by external/boundary accounts).
+
+Account balance = sum of active postings 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 ensures the auditable heart of the system is fully deterministic and independently testable.
+
+## Store Sub-Trait Architecture
+
+The `Store` trait is a composite of five focused sub-traits, each responsible for a single domain:
+
+```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)
+        +release_postings(ids)
+        +finalize_postings(deactivate, create)
+    }
+    class TransferStore {
+        +get_transfer(id)
+        +store_transfer(record)
+        +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 Store {
+        <<composite>>
+    }
+    Store --|> AccountStore
+    Store --|> PostingStore
+    Store --|> TransferStore
+    Store --|> SagaStore
+    Store --|> EventStore
+```
+
+The store only persists and reads — all domain logic (balance computation, validation, policy enforcement) lives in the Ledger and `kuatia-core`.
+
+## Saga Commit Pipeline
+
+The intent layer uses a saga-based pipeline that breaks the commit into four independently-persisted steps:
+
+```mermaid
+sequenceDiagram
+    participant C as Caller
+    participant L as Ledger
+    participant R as ReserveStep
+    participant V as ValidateStep
+    participant F as FinalizeStep
+    participant S as Store
+
+    C->>L: commit(transfer)
+    L->>R: execute
+    R->>S: reserve_postings(ids)
+    Note over S: Active → PendingInactive (atomic batch)
+    R-->>L: reserved_postings tracked in LedgerCtx
+
+    L->>V: execute
+    V->>S: get_postings, get_accounts, get_postings_by_account
+    V->>V: validate_and_plan() [pure]
+    V-->>L: Plan stored in LedgerCtx
+
+    L->>F: execute
+    F->>S: finalize_postings(deactivate, create)
+    Note over S: PendingInactive → Inactive + insert new
+    F->>S: store_transfer(record)
+    F-->>L: Receipt
+
+    L-->>C: Receipt
+```
+
+On failure, legend compensates completed steps in LIFO order:
+
+```mermaid
+sequenceDiagram
+    participant L as Legend
+    participant F as FinalizeStep
+    participant V as ValidateStep
+    participant R as ReserveStep
+    participant S as Store
+
+    Note over L: Step 3 fails...
+    L->>V: compensate
+    Note over V: No-op (reads only)
+    L->>R: compensate
+    R->>S: release_postings(reserved)
+    Note over S: PendingInactive → Active
+```
+
+Each step is a small, shard-local operation with automatic compensation on failure. This design avoids cross-shard transactions: no single step touches multiple shards atomically.
+
+## Raw Three-Phase Commit
+
+A lower-level `commit_atomic()` method runs the traditional atomic pipeline in a single pass without reservation. Used internally by `reverse()` and available for callers who need direct control.
+
+```mermaid
+graph LR
+    A[load] -->|LoadedState| B[plan]
+    B -->|Plan| C[apply]
+    C -->|Receipt| D[done]
+    style A fill:#e1f5fe
+    style B fill:#fff3e0
+    style C fill:#e8f5e9
+```
+
+The three phases can also be called independently: `load()`, `plan()`, `apply()`.
+
+## 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 mutation (freeze, unfreeze, close, balance change) appends a new snapshot with an incremented `version` field (starts at 1 on creation).
+
+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` and `code` fields on transfers and accounts are grouping labels for reporting — they do not affect conservation enforcement.
+
+## Account Policies
+
+Each account has a policy controlling its balance floor and whether it may hold negative postings:
+
+| Policy | Balance floor | Negative postings | CAS guard |
+|--------|--------------|-------------------|-----------|
+| `NoOverdraft` | `>= 0` | No | No |
+| `CappedOverdraft { floor }` | `>= floor` | No | Yes |
+| `UncappedOverdraft` | None | No | No |
+| `SystemAccount` | None | Yes | No |
+| `ExternalAccount` | None | Yes | No |
+
+Only `SystemAccount` and `ExternalAccount` may receive negative postings (liabilities). Validation rejects any transfer that would create a negative posting on another account type.
+
+## CAS (Compare-And-Swap) Guards for CappedOverdraft
+
+`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).
+
+The validation phase emits `cas_guards: Vec<(AccountId, AssetId, Cent)>` for these accounts. The saga pipeline handles isolation via the reserve step (Active → PendingInactive), which prevents concurrent transfers from consuming the same postings.
+
+Other policies do not need CAS guards: `NoOverdraft` is fully UTXO-backed (you can only spend postings you own), and 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: created by finalize
+    Active --> PendingInactive: reserve_postings
+    PendingInactive --> Active: release_postings (compensation)
+    PendingInactive --> Inactive: finalize_postings
+    Active --> Active: release_postings (no-op)
+```
+
+| 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
+
+`reserve_postings` and `release_postings` operate on batches with atomic semantics — if any posting fails validation, the entire batch is rejected and no state changes.
+
+- **`reserve_postings(ids)`** — all postings must be Active; fails if any is not.
+- **`release_postings(ids)`** — fails if any posting is Inactive (void); Active postings are a no-op, PendingInactive postings revert to Active.
+
+This enables shard-local writes: each posting reservation is an independent operation on the posting's shard, with no cross-shard coordination needed.
+
+## Saga Composition
+
+### Internal pipeline steps
+
+The saga pipeline is built from four `legend::Step` implementations that operate on `LedgerCtx`:
+
+| Step | Execute | Compensate | Retry |
+|------|---------|------------|-------|
+| `ResolveStep` | Convert Transfer intent into concrete Envelope | No-op | No retry |
+| `ReservePostingsStep` | Batch reserve postings `Active → PendingInactive` | Release all back to `Active` | 3 retries |
+| `ValidateTransferStep` | Load accounts/balances, run `validate_and_plan()` | No-op (reads only) | No retry |
+| `FinalizeTransferStep` | `PendingInactive → Inactive`, create postings, store transfer, emit event | `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, effectively undoing the operation while preserving the full audit trail.

+ 263 - 0
doc/crates.md

@@ -0,0 +1,263 @@
+# 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(u128)` | Stable account identity |
+| `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(i64)` | Smallest monetary unit (private field). 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` |
+| `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/code, 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]`. 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 --> G[7. Per-asset conservation]
+    G --> H[8. Negative posting restriction]
+    H --> J[9. 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. **Per-asset conservation** — `sum(consumed) == sum(created)` for each asset
+8. **Negative posting restriction** — negative postings only allowed on `SystemAccount` or `ExternalAccount`
+9. **Policy enforcement** — projected balance satisfies account's floor
+
+Output is a `Plan` containing `transfer_id`, `postings_to_deactivate`, `postings_to_create`, and `cas_guards` (Compare-And-Swap guards for concurrency safety).
+
+---
+
+## 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`) |
+| `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
+
+#### Saga Commit (default for intent layer)
+
+Driven by a `TransferSaga` defined via `legend!` — four steps with automatic retry and LIFO compensation:
+
+```mermaid
+graph LR
+    A[resolve] -->|Envelope| B[reserve_postings]
+    B -->|batch Active→PendingInactive| C[validate_and_plan]
+    C -->|Plan| D[finalize + store + emit event]
+    D --> E[Receipt]
+    style E fill:#e8f5e9
+```
+
+Note: `commit` requires `Arc<Ledger>` (takes `self: &Arc<Self>`).
+
+#### Raw Three-Phase Commit
+
+```mermaid
+graph LR
+    A["load()"] -->|LoadedState| B["plan()"]
+    B -->|Plan| C["apply()"]
+    C --> D[Receipt]
+    style A fill:#e1f5fe
+    style B fill:#fff3e0
+    style C fill:#e8f5e9
+```
+
+`commit_atomic(transfer)` runs all three in one shot. Used by `reverse()` and available for direct callers.
+
+#### Convenience
+
+| Method | Description |
+|--------|-------------|
+| `commit(transfer)` | Saga pipeline: resolve → reserve → validate → finalize with retry and compensation (requires `Arc<Ledger>`) |
+| `commit_atomic(transfer)` | Raw atomic pipeline: load → plan → apply (used by `reverse()`) |
+| `reverse(transfer_id)` | Creates compensating transfer that undoes the original |
+
+#### 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: liability 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, code) |
+| `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 five focused sub-traits:
+
+```mermaid
+graph TB
+    Store --> AccountStore
+    Store --> PostingStore
+    Store --> TransferStore
+    Store --> SagaStore
+    Store --> EventStore
+```
+
+- **`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)`, `reserve_postings`, `release_postings`, `finalize_postings`
+- **`TransferStore`**: `get_transfer`, `store_transfer`, `get_transfers_for_account`, `query_transfers`
+- **`EventStore`**: `append_event`, `get_events_since`
+- **`SagaStore`**: `save_saga`, `list_pending_sagas`, `delete_saga`
+
+#### Batch posting operations
+
+`reserve_postings` and `release_postings` operate on batches with atomic semantics:
+
+```mermaid
+stateDiagram-v2
+    [*] --> Active: created by finalize
+    Active --> PendingInactive: reserve_postings
+    PendingInactive --> Active: release_postings
+    PendingInactive --> Inactive: finalize_postings
+    Active --> Active: release_postings (no-op)
+    note right of Inactive: void — release_postings fails
+```
+
+| Operation | Active | PendingInactive | Inactive |
+|-----------|--------|-----------------|----------|
+| `reserve_postings` | → PendingInactive | **fail** | **fail** |
+| `release_postings` | no-op | → Active | **fail** (void) |
+| `finalize_postings` | → Inactive | → Inactive | — |
+
+If any posting in the batch fails validation, the entire batch is rejected and no state changes.
+
+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
+├── Overflow                     // monetary arithmetic overflow
+└── CompensationFailed           // saga compensation failed (original + compensation errors)
+```
+
+```
+StoreError
+├── NotFound(String)
+├── AlreadyExists(String)
+├── VersionConflict { account, expected, actual }
+├── Internal(String)
+├── PostingNotActive(PostingId)   // reserve_postings: posting not Active
+└── PostingInactive(PostingId)    // release_postings: posting is void
+```
+
+### Saga Steps
+
+#### Pipeline steps (used internally by `commit`)
+
+| Step | Execute | Compensate | Retry |
+|------|---------|------------|-------|
+| `ResolveStep` | Convert Transfer intent into Envelope | No-op | None |
+| `ReservePostingsStep` | Batch reserve `Active → PendingInactive` | Batch release back to `Active` | 3 |
+| `ValidateTransferStep` | Load state, `validate_and_plan()` | No-op | None |
+| `FinalizeTransferStep` | Finalize postings, store transfer, emit event | `reverse(transfer_id)` | 3 |
+
+#### 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.

+ 196 - 0
doc/transfers.md

@@ -0,0 +1,196 @@
+# 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 liabilities)
+}
+```
+
+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 a liability 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 liability 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 liability 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. Run greedy largest-first selection to cover the net debit
+3. Compute change = selected sum - net debit
+4. If change > 0, create a change posting returning the remainder to the account
+
+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: u32,
+    code: u32,
+    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(1)
+    .code(100)
+    .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 → validate → finalize → Receipt
+```
+
+Four-phase pipeline with automatic retry and LIFO compensation on failure. Used by `ledger.commit(transfer)`.
+
+### Atomic commit
+
+```
+Envelope → load → plan → apply → Receipt
+```
+
+Single-pass pipeline without reservation. Used by `ledger.commit_atomic(envelope)` and internally by `reverse()`.
+
+## 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. Per-asset conservation: `sum(consumed) == sum(created)`
+8. Negative postings only on SystemAccount or ExternalAccount
+9. Policy enforcement: projected balance satisfies account floor
+
+See [architecture.md](architecture.md) for details on each check.