Kuatia is an append-only, auditable, multi-asset UTXO-style ledger library in Rust. Value is tracked as signed postings — no mutable balance fields. Transfers atomically consume and create postings, enforcing per-asset conservation — the double-entry-style safety invariant (sum(consumed) == sum(created) per asset).
crates/
kuatia-money/ Cent monetary type + CentBacking trait; integer width (i64 default, i128 via feature) is hidden and swappable
kuatia-types/ Domain types: AccountId, Posting, Movement, AutoId, etc.; re-exports Cent/Amount from kuatia-money
kuatia-core/ Pure, sync, no-IO logic: validation, hashing, posting selection
kuatia-storage/ Store trait (7 sub-traits), InMemoryStore, conformance tests
kuatia-storage-sql/ SQL backend: SQLite/PostgreSQL via sqlx
kuatia/ Async layer: Ledger resource, saga pipeline, intent API
doc/
architecture.md Architecture decisions and rationale
crates.md Crate reference: modules, types, APIs
accounts.md Account model, balance constraint, lifecycle
transfers.md Transfer/Movement API, resolve algorithm
journaling.md Journaling: transfers as (compound) journal entries
glossary.md Terms, book design, exchange & supermarket examples
accounting-mapping.md Classical double-entry ↔ Kuatia term mapping
{ from, to, asset, amount } — the fundamental unit of intent. All operations (pay, deposit, withdraw) are one or more movements.sum(consumed) == sum(created).AccountFlags::DEBIT_MUST_NOT_EXCEED_CREDIT. Default (no flag): overdraft allowed, unbounded, a shortfall becomes a negative offset posting and the transfer records if it conserves value. Flag set: balance may not go negative and the account may not hold a negative posting. Construct with Account::debit_must_not_exceed_credit(id); query with Account::forbids_overdraft(). There is no bounded floor and no system/external policy label; a boundary/deposit account is just a default overdraft-permitting account.Store is a thin instruction follower. Write methods apply one update and return the number of affected rows (or an I/O error) — they never interpret counts, decide state, enforce idempotency, or compensate. The saga owns all of that. There is no monolithic commit_transfer; commit is a sequence of dumb primitives (reserve_postings, deactivate_postings, insert_postings, store_transfer, append_event), each idempotent. See doc/adr/0003-dumb-storage-saga-recovery.md.reserve → finalize (validation runs inside the finalize step, as the last thing before the writes), with automatic retry and LIFO compensation via the legend crate. commit(transfer) = resolve (read-only) then commit_envelope; reverse() builds a reversal envelope and runs the same path. There is one commit path, not a separate "atomic" one.finalize_envelope additionally verifies every end-state (all consumed postings Inactive, created exist, transfer stored).PendingSaga {envelope, reservation, phase} is persisted via SagaStore before the saga mutates anything (Reserving), bumped to Finalizing once validation passed and the consumed postings are about to turn Inactive. Ledger::recover() (call on startup) branches on phase: a Reserving saga is re-run and re-validated (aborting cleanly if a posting was taken or an account frozen); a Finalizing saga is rolled forward through the verified finalize_envelope. Roll-forward, not rollback, so there are no orphaned PendingInactive postings to reconcile.Arc<dyn Store>: Ledger is non-generic, enabling concrete saga types.Two-pass:
to and accumulate net debit on from.DEBIT_MUST_NOT_EXCEED_CREDIT flag) consume all positives and create a negative posting for the shortfall; accounts that forbid overdraft fail with InsufficientFunds.Deposit: two movements cancel to zero net debit on the system account — no posting selection needed.
DEBIT_MUST_NOT_EXCEED_CREDIT (allowed on overdraft-permitting accounts)cargo test # runs all tests across all crates
cargo test -p kuatia-core # pure core tests only
cargo test -p kuatia # integration + saga tests
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 overflowunwrap()/expect() in production code — all errors bubble up via Result.pay(), .deposit(), .withdraw()) over raw .movement() constructionstore_tests! macro — new trait methods require new tests.deposit() returns Result<Self, OverflowError> — callers must handle the errorNo 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)
KUATIA_EPOCH_MS (2026-01-01T00:00:00Z), not the Unix epoch — 40 bits ≈ 34.8 years going forward (until ~2060)AutoId in kuatia-types/src/autoid.rs, includes inline CRC32 (IEEE)BIGINT — the DB never assigns IDs