kuatia-types, kuatia-core, kuatia-storage,
kuatia-storage-sql, kuatia (ledger), kuatia-dashboardAn account was identified by a single i64 (AccountId). Some workloads need to
partition one account's holdings into several distinct balances under the same
owner: sub-ledgers, per-purpose buckets, earmarks, or reservations that are
individually addressable and drained or closed independently, without minting
unrelated top-level accounts. Classical accounting calls the general shape a
control account with a subsidiary ledger; payment and banking systems call it a
sub-ledger or a set of virtual accounts under a master account.
We want that structure as a first-class part of account identity: an account is a
base id plus a subaccount, and each partition is a full account record with
its own policy, so no special-case code is needed to give a partition its own
overdraft rule or lifecycle. The default subaccount (0) is the account's main
account, so existing behaviour is unchanged when subaccounts are not used.
AccountId (a composite {id, sub}, chosen)Make the account identity itself two legs: AccountId { id: i64, sub: u64 }, with
sub = 0 the main account. Aggregate reads take a base id: i64 plus an optional
subaccount filter.
Pros:
AccountId, so per-subaccount
balances fall out of the existing keys with no new wrapper type.(id: i64, sub: Option<u64>) — None spans every subaccount, Some(s)
restricts to one — while entity ops take the full &AccountId.(id, sub) is a full account record with its own policy.Cons:
account.id rather than
passing a distinct base type; the split between base reads (i64) and exact
entity ops (&AccountId) has to be kept clear..0
accesses become .id) plus a schema migration.AccountRef { account, sub } owner/identity typeKeep AccountId as the i64 base and add a separate AccountRef wrapper as the
owner/endpoint/entity identity.
Pros:
AccountId stays a bare i64, so aggregate "all
subaccounts" reads keep a natural base handle.Cons:
AccountId vs
AccountRef) that every layer has to convert between.Track a subaccount only on postings, with the account entity keyed by base id and its policy shared by all subaccounts.
Pros:
accounts table does not change.Cons:
NoOverdraft under a SystemAccount/overdraft base account could not,
so any structural guarantee that depends on the partition's own policy is lost.Chosen option: Option 1, fold the subaccount into AccountId, because a
single two-leg identity keeps balances naturally segregated, lets every
subaccount carry its own policy, and avoids carrying two account-identity types.
AccountId { id: i64, sub: u64 } (in kuatia-types). sub = 0 is the main
account; a non-zero sub is a subaccount. sub is unsigned because it is an
opaque, unordered id, so the whole range is usable. Constructors and helpers:
AccountId::new(id) — the main account { id, sub: 0 }.AccountId::with_sub(id, sub) — a specific subaccount.base() — the main account of an id (sub set to 0).is_main() — whether sub == 0.AccountId derives Copy/Eq/Hash/Ord and its canonical ToBytes is the
base id followed by the subaccount (both big-endian), so the subaccount is folded
into every content hash (envelope ids, posting ids, account snapshots).
Each (id, sub) is its own full account record with its own policy,
flags, book, version, user_data, and metadata. The main account is
(id, 0). A subaccount is created, versioned, frozen, and closed exactly like any
other account (closing still requires zero live postings), and its policy is
enforced independently — a subaccount can be NoOverdraft while its base account
is not, or vice versa.
AccountId is the owner of a posting (Posting.owner, NewPosting.owner,
NewPosting.payer), the endpoint of a movement (Movement.from/to), the id of
an Account, and the subject of an AccountSnapshotId. TransferBuilder::pay and
movement move between main accounts; pay_ref and movement_ref move between
specific subaccounts.
Entity operations take the full &AccountId (exact): get_account,
get_accounts, append_account_version, get_account_history. Aggregate reads
take a base id: i64 plus an optional subaccount:
get_postings_by_account(id: i64, sub: Option<u64>, asset, status) and
get_transfers_for_account(id: i64, sub: Option<u64>) span every subaccount
when sub is None and one when Some(s).PostingQuery/TransferQuery carry a base account: i64 and sub:
Option<u64>.Balances are reported per subaccount and never summed across them:
Ledger::balance(&AccountId, &AssetId) -> Cent reads exactly one subaccount.Ledger::balances(&AccountId, &AssetId, sub: Option<u64>) -> Vec<SubAccountBalance>
returns one entry per non-closed subaccount (sub = None spans all, Some(s)
filters to one). There is deliberately no API that sums across subaccounts.Ledger::list_subaccounts(&AccountId) -> Vec<AccountId> lists the non-closed
subaccounts of a base account.Closed subaccounts are excluded from the aggregate reads. This inverts the classical control-account expectation (where a parent's balance is the sum of its subsidiaries) on purpose: a base account does not roll up its subaccounts.
Per-asset conservation and the balance-floor / negative-posting checks operate on
the full AccountId owner, so they are per subaccount and use each subaccount's
own policy. Book membership is scoped by base account: a book that lists a
base account (or matches its flags) admits all of that account's subaccounts.
accounts primary key becomes (id, subaccount, version). postings gain a
subaccount column and idx_postings_owner widens to (owner, subaccount,
asset, status). transfer_accounts gains subaccount in its key and index.u64 subaccount is stored in a signed BIGINT column via an as i64
bit-cast on write and as u64 on read (an opaque id, so the reinterpretation is
lossless and never compared as a number in SQL).002_subaccounts migration adds the column (existing rows default to
subaccount = 0, the main account) and rebuilds accounts /
transfer_accounts for the widened primary keys, since SQLite cannot alter a
primary key. 001_init.sql is left intact. The in-memory store keys accounts by
the composite AccountId directly.list_subaccounts and attributable by
shared base id.(id, sub) owner.AccountId's
canonical bytes) and the schema migrates. Existing data upgrades in place to
subaccount = 0.list_subaccounts and any "open subaccounts" scan currently read all account
rows and filter in memory, so they pay for closed subaccounts; a store with many
historical subaccounts would want an index on the not-closed set.AccountId split (aggregate reads take i64, entity ops
take &AccountId) has to be kept clear at call sites.