0012-subaccounts.md 9.8 KB

Extend account identity with a subaccount dimension

  • Status: accepted
  • Authors: Cesar Rodas
  • Date: 2026-07-05
  • Targeted modules: kuatia-types, kuatia-core, kuatia-storage, kuatia-storage-sql, kuatia (ledger), kuatia-dashboard
  • Associated tickets/PRs: N/A

Context and Problem Statement

An 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.

Decision Drivers

  • Partitioning and attribution: several balances under one owner, each addressable and discoverable as a subaccount of the base account.
  • Per-partition policy: a subaccount must be able to carry its own policy, flags, book, and version, independent of the base account.
  • Segregated balances: a base account's subaccounts must never be silently summed into a single figure.
  • Query by account or by subaccount: reads must span all subaccounts or restrict to one.
  • Least churn and preserved invariants: the change touches every layer that keys on an account; conservation, double-spend, and floor checks must be unchanged.

Considered Options

Option 1: Fold the subaccount into 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:

  • Good, because there is one identity type: posting owners, movement endpoints, account records, and balance keys are all AccountId, so per-subaccount balances fall out of the existing keys with no new wrapper type.
  • Good, because "query by account or by subaccount" is explicit: base reads take (id: i64, sub: Option<u64>)None spans every subaccount, Some(s) restricts to one — while entity ops take the full &AccountId.
  • Good, because each (id, sub) is a full account record with its own policy.

Cons:

  • Bad, because callers that want a base handle read 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.
  • Bad, because it is a large, cross-crate change (the identity gains a field, .0 accesses become .id) plus a schema migration.

Option 2: A separate AccountRef { account, sub } owner/identity type

Keep AccountId as the i64 base and add a separate AccountRef wrapper as the owner/endpoint/entity identity.

Pros:

  • Good, because the base AccountId stays a bare i64, so aggregate "all subaccounts" reads keep a natural base handle.

Cons:

  • Bad, because it adds a second account-identity type (AccountId vs AccountRef) that every layer has to convert between.
  • Bad, because it is the same cross-crate churn as Option 1 without collapsing to a single identity.

Option 3: Subaccounts as balance buckets that inherit the parent policy

Track a subaccount only on postings, with the account entity keyed by base id and its policy shared by all subaccounts.

Pros:

  • Good, because the accounts table does not change.

Cons:

  • Bad, because a subaccount cannot carry its own policy. A partition that must stay NoOverdraft under a SystemAccount/overdraft base account could not, so any structural guarantee that depends on the partition's own policy is lost.

Decision Outcome

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.

The identity type

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).

The entity model

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.

Reads: by account or by subaccount

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 always segregated

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.

Validation and books

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.

Storage schema and migration

  • 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.
  • The 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).
  • A 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.

Positive Consequences

  • One account can carry several independent balances, each a full account record with its own policy, discoverable via list_subaccounts and attributable by shared base id.
  • Balances are always presented per subaccount, so a main account and its subaccounts are never accidentally summed into one figure.
  • Conservation, double-spend, and floor guarantees are unchanged; they simply key on the full (id, sub) owner.

Negative Consequences

  • Every content hash changes (the subaccount is folded into AccountId's canonical bytes) and the schema migrates. Existing data upgrades in place to subaccount = 0.
  • Because accounts are append-only and never deleted, each subaccount that is created and later closed leaves a permanent record (its versions plus its inactive postings); the accounts and postings tables grow with the number of subaccounts ever created, not the number currently open.
  • 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.
  • The base-id-vs-full-AccountId split (aggregate reads take i64, entity ops take &AccountId) has to be kept clear at call sites.

Links