0005-subaccount-dimension.md 7.1 KB

A subaccount dimension on account identity

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

Context and Problem Statement

ADR-0004 modeled an inflight hold as a fresh standalone holding account per destination and allowed only one open inflight per destination at a time (open_inflight_hold_for rejected a second). Two limits followed: a destination could not host several concurrent inflights, and a hold account was a random id unrelated to the destination, so "which holds belong to account X" was a metadata scan rather than a property of identity.

We want many concurrent inflights per account, with holds that are attributable to their destination. The natural model is a subaccount: an account is identified by a base id plus an i64-range subaccount, default 0 (the main account), and a hold is a subaccount of its destination.

Decision Drivers

  • Concurrency: a destination must host several open inflights at once.
  • Attribution: a hold should be discoverable as a subaccount of its destination, not a floating account.
  • Preserve invariants: over-confirmation stays structurally impossible, so a hold must keep its own NoOverdraft policy regardless of the destination's.
  • Least API churn: the change touches every layer that keys on an account, so the representation should minimize signature churn.
  • Segregated balances: balances must never be silently summed across subaccounts.

Considered Options

Option 1: Fold the subaccount into AccountId (a composite {id, sub})

Make the base id itself carry the subaccount.

Pros:

  • Good, because every API that already takes an AccountId carries the subaccount with no signature change.

Cons:

  • Bad, because it breaks the "query all subaccounts of an account" ergonomics: a composite id always pins one subaccount, so an aggregate read needs a second, parallel handle anyway.
  • Bad, because it churns every .0 access across the codebase and both storage backends.

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

Keep AccountId as the i64 base. Introduce AccountRef as the owner/endpoint/entity identity, and let aggregate reads take a base AccountId plus an optional subaccount filter.

Pros:

  • Good, because posting owners, movement endpoints, account records, and balance keys all become AccountRef, so per-subaccount balances fall out of the existing keys with almost no logic change.
  • Good, because reads take (&AccountId, sub: Option<..>): None spans every subaccount, Some(s) restricts to one. This is exactly the segregated, optionally-filtered query the balances API needs.
  • Good, because each (base, sub) is a full account record with its own policy, so inflight holds stay NoOverdraft no matter the destination.

Cons:

  • Bad, because it is a large, cross-crate rename (AccountId -> AccountRef in every owner/identity position) plus a schema migration.
  • Bad, because the account entity now needs the pair (id, sub) in its key, so the accounts primary key and the transfer_accounts index widen.

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 hold under a SystemAccount/overdraft destination would inherit that policy and could be over-confirmed. The structural over-confirm guard is lost for those destinations.

Decision Outcome

Chosen option: Option 2, a separate AccountRef identity, because it is the only option that keeps balances naturally segregated and optionally filtered, keeps inflight holds NoOverdraft by giving every subaccount its own record, and still threads through the code with a mechanical rename rather than new parameters everywhere. Concretely:

  • AccountRef { account: AccountId, sub: u64 } is the owner of a posting, the endpoint of a movement, the id of an Account, and the subject of a snapshot. sub = 0 is the main account. sub is unsigned because it is an opaque, unordered id (an inflight hold's subaccount is the low 64 bits of a hash of the submitted trade), so the full range is usable.
  • Each (account, sub) is its own account record with its own policy, flags, book, and version. The accounts primary key becomes (id, subaccount, version) and transfer_accounts carries the subaccount.
  • Reads are segregated and optionally filtered. get_postings_by_account and get_transfers_for_account take sub: Option<u64>. Ledger::balances(base, asset, sub) returns one SubAccountBalance per non-closed subaccount and never a summed total; balance(&AccountRef, asset) reads exactly one subaccount. Closed subaccounts are excluded from the aggregate reads.
  • Inflight holds become subaccounts. For an inflight, one subaccount sub is derived from a hash of the submitted trade (deterministic and known before the holds are created, unlike the authorize transfer's own id). Each destination's hold is (destination, sub). Re-authorizing the identical trade collides on the existing hold (rejected); a different trade derives a different sub, so a destination hosts many concurrent inflights. open_inflight_hold_for and the "one open inflight per account" rule are removed.
  • Storage evolves by migration. A 002_subaccounts migration adds the subaccount column (existing rows default to 0, the main account) and rebuilds accounts / transfer_accounts for the widened keys. 001_init.sql is left intact.

Positive Consequences

  • A destination can hold arbitrarily many concurrent inflights, each isolated in its own subaccount and attributable via list_subaccounts.
  • Balances are always presented per subaccount; nothing sums a main account and its holds into one figure by accident.
  • The over-confirm and double-spend guarantees are unchanged: holds are NoOverdraft records, and the reservation protocol still serializes captures.

Negative Consequences

  • Every content hash changes (AccountRef is folded into canonical bytes), and the schema migrates. Existing data upgrades in place to subaccount = 0.
  • The accounts table grows one row per hold, as before, now keyed under the destination rather than a random id.
  • Two co-funders of the same (hold, asset) still cannot split a partially-confirmed remainder exactly on void; unchanged from ADR-0004.

Links