Просмотр исходного кода

Add a subaccount dimension so an account can hold many inflights

The inflight model allowed only one open hold per destination, and a hold
was a random account unrelated to its destination. Callers want several
concurrent inflights per account, with holds attributable to the account
they belong to.

Introduce a subaccount dimension on account identity: AccountRef
{ account, sub } (sub 0 = main), where each (account, sub) is a full account
record with its own policy. AccountRef becomes the owner of a posting, the
endpoint of a movement, and the id of an account. Reads take an optional
subaccount filter, and balances are always reported segregated per
subaccount, never summed: balances(base, asset, sub) lists one entry per
non-closed subaccount, balance(&AccountRef, asset) reads one, and
list_subaccounts enumerates them.

An inflight hold is now a subaccount of its destination, keyed by a value
derived from a hash of the submitted trade (deterministic and known before
the holds are created, unlike the authorize transfer's own id). Different
trades derive different subaccounts, so a destination hosts many concurrent
inflights; the identical trade collides on its existing hold. Holds keep
their own NoOverdraft policy, so over-confirmation stays impossible for any
destination. The one-open-per-account rule is removed.

The schema evolves by a 002_subaccounts migration: existing accounts and
postings default to subaccount 0, and the accounts and transfer_accounts
primary keys widen (rebuilt, since SQLite cannot alter a primary key).

ADR 0005 records the decision and revises ADR 0004.

Claude-Session: https://claude.ai/code/session_01SJFJen8Ethv9Q6Ysb1xmz4
Cesar Rodas 2 часов назад
Родитель
Сommit
8908a515ca

+ 1 - 1
crates/kuatia-core/src/hash.rs

@@ -71,7 +71,7 @@ mod tests {
     fn sample_envelope() -> Envelope {
         EnvelopeBuilder::new()
             .creates(vec![NewPosting {
-                owner: AccountId::new(1),
+                owner: AccountRef::main(AccountId::new(1)),
                 asset: AssetId::new(1),
                 value: Cent::from(100),
                 payer: None,

+ 2 - 2
crates/kuatia-core/src/posting_selection.rs

@@ -97,7 +97,7 @@ mod tests {
                 transfer: EnvelopeId([1; 32]),
                 index,
             },
-            AccountId::new(1),
+            AccountRef::main(AccountId::new(1)),
             AssetId::new(1),
             Cent::from(value),
         )
@@ -159,7 +159,7 @@ mod tests {
                 transfer: EnvelopeId([1; 32]),
                 index: 0,
             },
-            AccountId::new(1),
+            AccountRef::main(AccountId::new(1)),
             AssetId::new(1),
             Cent::from(-100),
         );

+ 65 - 49
crates/kuatia-core/src/validate.rs

@@ -22,10 +22,10 @@ pub struct PlanInput<'a> {
     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>,
+    /// All accounts (subaccounts) referenced by the transfer.
+    pub accounts: &'a HashMap<AccountRef, Account>,
+    /// Current balances keyed by (account reference, asset).
+    pub balances: &'a HashMap<(AccountRef, AssetId), Cent>,
     /// The book gating this transfer, if one is loaded. `Some` enforces the
     /// book's [`BookPolicy`] (allowed assets/accounts/flags); `None` means the
     /// implicit unrestricted default book. The async layer is responsible for
@@ -65,16 +65,16 @@ pub enum ValidationError {
         /// The posting that failed the ownership check.
         posting_id: PostingId,
         /// The account that should own the posting.
-        expected: AccountId,
+        expected: AccountRef,
         /// The account that actually owns the posting.
-        actual: AccountId,
+        actual: AccountRef,
     },
     /// A referenced account does not exist.
-    AccountNotFound(AccountId),
+    AccountNotFound(AccountRef),
     /// A referenced account is frozen.
-    AccountFrozen(AccountId),
+    AccountFrozen(AccountRef),
     /// A referenced account is closed.
-    AccountClosed(AccountId),
+    AccountClosed(AccountRef),
     /// Per-asset conservation law violated: consumed sum != created sum.
     ConservationViolation {
         /// The asset whose sums differ.
@@ -87,7 +87,7 @@ pub enum ValidationError {
     /// Projected balance would fall below the account's floor.
     OverdraftExceeded {
         /// The account that would be overdrawn.
-        account: AccountId,
+        account: AccountRef,
         /// The asset involved.
         asset: AssetId,
         /// The minimum allowed balance.
@@ -98,7 +98,7 @@ pub enum ValidationError {
     /// Account snapshot hash does not match current state (stale read).
     AccountVersionMismatch {
         /// The account whose version was stale.
-        account: AccountId,
+        account: AccountRef,
         /// The snapshot hash the transfer expected.
         expected: [u8; 32],
         /// The actual current snapshot hash.
@@ -107,7 +107,7 @@ pub enum ValidationError {
     /// A negative posting targets an account whose policy forbids offset positions.
     NegativePostingOnNonSystemAccount {
         /// The account that would receive the negative posting.
-        account: AccountId,
+        account: AccountRef,
         /// The asset involved.
         asset: AssetId,
         /// The negative value.
@@ -256,7 +256,7 @@ pub fn validate_and_plan(input: PlanInput<'_>) -> Result<Plan, ValidationError>
     }
 
     // 5. Every referenced account exists, not FROZEN, not CLOSED
-    let mut all_account_ids: Vec<AccountId> = envelope.creates().iter().map(|p| p.owner).collect();
+    let mut all_account_ids: Vec<AccountRef> = 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);
@@ -321,13 +321,14 @@ pub fn validate_and_plan(input: PlanInput<'_>) -> Result<Plan, ValidationError>
         if !no_account_restriction {
             for aid in &all_account_ids {
                 let account = &input.accounts[aid];
-                let listed = policy.allowed_accounts.contains(aid);
+                // Book membership is scoped by base account, not subaccount.
+                let listed = policy.allowed_accounts.contains(&aid.account);
                 let flag_match = !policy.allowed_flags.is_empty()
                     && account.flags.intersects(policy.allowed_flags);
                 if !(listed || flag_match) {
                     return Err(ValidationError::BookAccountNotAllowed {
                         book: book.id,
-                        account: *aid,
+                        account: aid.account,
                     });
                 }
             }
@@ -391,7 +392,7 @@ pub fn validate_and_plan(input: PlanInput<'_>) -> Result<Plan, ValidationError>
     }
 
     // 8. Policy: projected balance satisfies account's floor
-    let mut deltas: HashMap<(AccountId, AssetId), Cent> = HashMap::new();
+    let mut deltas: HashMap<(AccountRef, AssetId), Cent> = HashMap::new();
     for pid in envelope.consumes() {
         let posting = consumed_by_id[pid];
         let entry = deltas
@@ -478,7 +479,7 @@ mod tests {
 
     fn make_account(id: i64, policy: AccountPolicy) -> Account {
         Account {
-            id: AccountId::new(id),
+            id: AccountRef::main(AccountId::new(id)),
             version: 1,
             policy,
             flags: AccountFlags::empty(),
@@ -488,7 +489,7 @@ mod tests {
         }
     }
 
-    fn accounts_map(accs: Vec<Account>) -> HashMap<AccountId, Account> {
+    fn accounts_map(accs: Vec<Account>) -> HashMap<AccountRef, Account> {
         accs.into_iter().map(|a| (a.id, a)).collect()
     }
 
@@ -499,13 +500,13 @@ mod tests {
             consumes: vec![],
             creates: vec![
                 NewPosting {
-                    owner: AccountId::new(1),
+                    owner: AccountRef::main(AccountId::new(1)),
                     asset: AssetId::new(1),
                     value: Cent::from(100),
                     payer: None,
                 },
                 NewPosting {
-                    owner: AccountId::new(99),
+                    owner: AccountRef::main(AccountId::new(99)),
                     asset: AssetId::new(1),
                     value: Cent::from(-100),
                     payer: None,
@@ -570,7 +571,7 @@ mod tests {
         let envelope = Envelope {
             consumes: vec![],
             creates: vec![NewPosting {
-                owner: AccountId::new(1),
+                owner: AccountRef::main(AccountId::new(1)),
                 asset: AssetId::new(1),
                 value: Cent::from(100),
                 payer: None,
@@ -634,7 +635,7 @@ mod tests {
         };
         let posting = Posting {
             id: pid,
-            owner: AccountId::new(1),
+            owner: AccountRef::main(AccountId::new(1)),
             asset: AssetId::new(1),
             value: Cent::from(100),
             status: PostingStatus::Inactive, // already consumed
@@ -643,7 +644,7 @@ mod tests {
         let envelope = Envelope {
             consumes: vec![pid],
             creates: vec![NewPosting {
-                owner: AccountId::new(2),
+                owner: AccountRef::main(AccountId::new(2)),
                 asset: AssetId::new(1),
                 value: Cent::from(100),
                 payer: None,
@@ -689,7 +690,7 @@ mod tests {
 
         assert_eq!(
             validate_and_plan(input).unwrap_err(),
-            ValidationError::AccountFrozen(AccountId::new(1))
+            ValidationError::AccountFrozen(AccountRef::main(AccountId::new(1)))
         );
     }
 
@@ -710,7 +711,7 @@ mod tests {
 
         assert_eq!(
             validate_and_plan(input).unwrap_err(),
-            ValidationError::AccountClosed(AccountId::new(1))
+            ValidationError::AccountClosed(AccountRef::main(AccountId::new(1)))
         );
     }
 
@@ -722,7 +723,7 @@ mod tests {
         };
         let posting = Posting {
             id: pid,
-            owner: AccountId::new(1),
+            owner: AccountRef::main(AccountId::new(1)),
             asset: AssetId::new(1),
             value: Cent::from(50),
             status: PostingStatus::Active,
@@ -733,7 +734,7 @@ mod tests {
         let envelope = Envelope {
             consumes: vec![pid],
             creates: vec![NewPosting {
-                owner: AccountId::new(2),
+                owner: AccountRef::main(AccountId::new(2)),
                 asset: AssetId::new(1),
                 value: Cent::from(50),
                 payer: None,
@@ -750,7 +751,10 @@ mod tests {
         // 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));
+        balances.insert(
+            (AccountRef::main(AccountId::new(1)), AssetId::new(1)),
+            Cent::from(30),
+        );
         // projected = 30 - 50 = -20 < 0 → overdraft
         let input = PlanInput {
             envelope: &envelope,
@@ -762,7 +766,7 @@ mod tests {
 
         match validate_and_plan(input) {
             Err(ValidationError::OverdraftExceeded { account, .. }) => {
-                assert_eq!(account, AccountId::new(1));
+                assert_eq!(account, AccountRef::main(AccountId::new(1)));
             }
             other => panic!("expected OverdraftExceeded, got {other:?}"),
         }
@@ -776,7 +780,7 @@ mod tests {
         };
         let posting = Posting {
             id: pid,
-            owner: AccountId::new(1),
+            owner: AccountRef::main(AccountId::new(1)),
             asset: AssetId::new(1),
             value: Cent::from(100),
             status: PostingStatus::Active,
@@ -785,7 +789,7 @@ mod tests {
         let envelope = Envelope {
             consumes: vec![pid],
             creates: vec![NewPosting {
-                owner: AccountId::new(2),
+                owner: AccountRef::main(AccountId::new(2)),
                 asset: AssetId::new(1),
                 value: Cent::from(100),
                 payer: None,
@@ -806,7 +810,10 @@ mod tests {
         ]);
         // 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));
+        balances.insert(
+            (AccountRef::main(AccountId::new(1)), AssetId::new(1)),
+            Cent::from(80),
+        );
 
         let input = PlanInput {
             envelope: &envelope,
@@ -829,7 +836,7 @@ mod tests {
         };
         let posting = Posting {
             id: pid,
-            owner: AccountId::new(1),
+            owner: AccountRef::main(AccountId::new(1)),
             asset: AssetId::new(1),
             value: Cent::from(100),
             status: PostingStatus::Active,
@@ -838,7 +845,7 @@ mod tests {
         let envelope = Envelope {
             consumes: vec![pid],
             creates: vec![NewPosting {
-                owner: AccountId::new(2),
+                owner: AccountRef::main(AccountId::new(2)),
                 asset: AssetId::new(1),
                 value: Cent::from(100),
                 payer: None,
@@ -859,7 +866,10 @@ mod tests {
         ]);
         // 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));
+        balances.insert(
+            (AccountRef::main(AccountId::new(1)), AssetId::new(1)),
+            Cent::from(30),
+        );
 
         let input = PlanInput {
             envelope: &envelope,
@@ -888,7 +898,7 @@ mod tests {
         };
         let posting = Posting {
             id: pid,
-            owner: AccountId::new(1),
+            owner: AccountRef::main(AccountId::new(1)),
             asset: AssetId::new(1),
             value: Cent::from(100),
             status: PostingStatus::Active,
@@ -897,7 +907,7 @@ mod tests {
         let envelope = Envelope {
             consumes: vec![pid],
             creates: vec![NewPosting {
-                owner: AccountId::new(2),
+                owner: AccountRef::main(AccountId::new(2)),
                 asset: AssetId::new(1),
                 value: Cent::from(100),
                 payer: None,
@@ -913,7 +923,10 @@ mod tests {
         ]);
         // 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));
+        balances.insert(
+            (AccountRef::main(AccountId::new(1)), AssetId::new(1)),
+            Cent::from(10),
+        );
 
         let input = PlanInput {
             envelope: &envelope,
@@ -967,7 +980,7 @@ mod tests {
         };
         let posting = Posting {
             id: pid,
-            owner: AccountId::new(1),
+            owner: AccountRef::main(AccountId::new(1)),
             asset: AssetId::new(1),
             value: Cent::from(100),
             status: PostingStatus::Active,
@@ -977,13 +990,13 @@ mod tests {
             consumes: vec![pid],
             creates: vec![
                 NewPosting {
-                    owner: AccountId::new(2),
+                    owner: AccountRef::main(AccountId::new(2)),
                     asset: AssetId::new(1),
                     value: Cent::from(60),
-                    payer: Some(AccountId::new(1)),
+                    payer: Some(AccountRef::main(AccountId::new(1))),
                 },
                 NewPosting {
-                    owner: AccountId::new(1),
+                    owner: AccountRef::main(AccountId::new(1)),
                     asset: AssetId::new(1),
                     value: Cent::from(40),
                     payer: None,
@@ -999,7 +1012,10 @@ mod tests {
             make_account(2, AccountPolicy::NoOverdraft),
         ]);
         let mut balances = HashMap::new();
-        balances.insert((AccountId::new(1), AssetId::new(1)), Cent::from(100));
+        balances.insert(
+            (AccountRef::main(AccountId::new(1)), AssetId::new(1)),
+            Cent::from(100),
+        );
 
         let input = PlanInput {
             envelope: &envelope,
@@ -1022,13 +1038,13 @@ mod tests {
             consumes: vec![],
             creates: vec![
                 NewPosting {
-                    owner: AccountId::new(999),
+                    owner: AccountRef::main(AccountId::new(999)),
                     asset: AssetId::new(1),
                     value: Cent::from(100),
                     payer: None,
                 },
                 NewPosting {
-                    owner: AccountId::new(99),
+                    owner: AccountRef::main(AccountId::new(99)),
                     asset: AssetId::new(1),
                     value: Cent::from(-100),
                     payer: None,
@@ -1052,7 +1068,7 @@ mod tests {
 
         assert_eq!(
             validate_and_plan(input).unwrap_err(),
-            ValidationError::AccountNotFound(AccountId::new(999))
+            ValidationError::AccountNotFound(AccountRef::main(AccountId::new(999)))
         );
     }
 
@@ -1062,13 +1078,13 @@ mod tests {
             consumes: vec![],
             creates: vec![
                 NewPosting {
-                    owner: AccountId::new(1),
+                    owner: AccountRef::main(AccountId::new(1)),
                     asset: AssetId::new(1),
                     value: Cent::from(-100),
                     payer: None,
                 },
                 NewPosting {
-                    owner: AccountId::new(1),
+                    owner: AccountRef::main(AccountId::new(1)),
                     asset: AssetId::new(1),
                     value: Cent::from(100),
                     payer: None,
@@ -1092,7 +1108,7 @@ mod tests {
         assert_eq!(
             validate_and_plan(input).unwrap_err(),
             ValidationError::NegativePostingOnNonSystemAccount {
-                account: AccountId::new(1),
+                account: AccountRef::main(AccountId::new(1)),
                 asset: AssetId::new(1),
                 value: Cent::from(-100),
             }

+ 13 - 11
crates/kuatia-dashboard/src/data.rs

@@ -11,7 +11,7 @@ use axum::{
     response::{IntoResponse, Response},
 };
 use kuatia::ledger::Ledger;
-use kuatia_core::{Account, AccountId, AccountPolicy, AssetId, Cent, PostingId};
+use kuatia_core::{Account, AccountId, AccountPolicy, AccountRef, AssetId, Cent, PostingId};
 use kuatia_storage::events::{LedgerEvent, LedgerEventKind};
 use kuatia_storage::store::{EnvelopeRecord, TransferQuery};
 use serde::Serialize;
@@ -40,7 +40,7 @@ pub struct BalanceDto {
 
 #[derive(Serialize)]
 pub struct AccountDto {
-    pub id: AccountId,
+    pub id: AccountRef,
     pub label: Option<&'static str>,
     pub version: u64,
     pub policy: PolicyDto,
@@ -58,7 +58,7 @@ pub struct PolicyDto {
 #[derive(Serialize)]
 pub struct PostingDto {
     pub id: String,
-    pub owner: AccountId,
+    pub owner: AccountRef,
     pub asset: AssetId,
     pub value: Cent,
     pub status: String,
@@ -66,11 +66,11 @@ pub struct PostingDto {
 
 #[derive(Serialize)]
 pub struct TransferLegDto {
-    pub owner: AccountId,
+    pub owner: AccountRef,
     pub label: Option<&'static str>,
     pub asset: AssetId,
     pub value: Cent,
-    pub payer: Option<AccountId>,
+    pub payer: Option<AccountRef>,
     pub payer_label: Option<&'static str>,
 }
 
@@ -87,7 +87,7 @@ pub struct EventDto {
     pub seq: u64,
     pub timestamp: i64,
     pub kind: &'static str,
-    pub account: Option<AccountId>,
+    pub account: Option<AccountRef>,
     pub transfer: Option<String>,
 }
 
@@ -236,7 +236,7 @@ pub async fn overview(state: &AppState) -> Result<OverviewDto, ApiError> {
     for asset in state.assets.iter() {
         let external = state
             .ledger
-            .balance(&crate::seed::EXTERNAL, &asset.id)
+            .balance(&AccountRef::main(crate::seed::EXTERNAL), &asset.id)
             .await?;
         let issued_value = external
             .checked_neg()
@@ -260,7 +260,7 @@ pub async fn overview(state: &AppState) -> Result<OverviewDto, ApiError> {
 /// Every account (sorted by id) with its balances.
 pub async fn accounts(state: &AppState) -> Result<Vec<AccountDto>, ApiError> {
     let mut accounts = state.ledger.list_accounts().await?;
-    accounts.sort_by_key(|a| a.id.0);
+    accounts.sort_by_key(|a| (a.id.account.0, a.id.sub));
     let mut out = Vec::with_capacity(accounts.len());
     for account in &accounts {
         out.push(account_dto(state, account).await?);
@@ -271,11 +271,13 @@ pub async fn accounts(state: &AppState) -> Result<Vec<AccountDto>, ApiError> {
 /// One account with its postings (largest first) and the transfers it took part
 /// in.
 pub async fn account_detail(state: &AppState, id: AccountId) -> Result<AccountDetailDto, ApiError> {
-    let account = state.ledger.get_account(&id).await?;
+    // The route addresses the main subaccount of the base account.
+    let aref = AccountRef::main(id);
+    let account = state.ledger.get_account(&aref).await?;
 
     let mut postings: Vec<PostingDto> = state
         .ledger
-        .postings(&id)
+        .postings(&aref)
         .await?
         .iter()
         .map(|p| PostingDto {
@@ -290,7 +292,7 @@ pub async fn account_detail(state: &AppState, id: AccountId) -> Result<AccountDe
 
     let transfers = state
         .ledger
-        .history(&id)
+        .history(&aref)
         .await?
         .iter()
         .map(transfer_dto)

+ 7 - 4
crates/kuatia-dashboard/src/seed.rs

@@ -5,7 +5,9 @@
 use std::sync::Arc;
 
 use kuatia::ledger::Ledger;
-use kuatia_core::{Account, AccountId, AccountPolicy, Amount, AssetId, Cent, TransferBuilder};
+use kuatia_core::{
+    Account, AccountId, AccountPolicy, AccountRef, Amount, AssetId, Cent, TransferBuilder,
+};
 use kuatia_storage_sql::SqlStore;
 
 use crate::assets::{BTC, EUR, USD};
@@ -19,9 +21,10 @@ pub const CAROL: AccountId = AccountId::new(102);
 pub const MERCHANT: AccountId = AccountId::new(103);
 
 /// Human-readable labels for the seeded accounts, surfaced by the API so the
-/// frontend can show names instead of raw ids.
-pub fn account_label(id: AccountId) -> Option<&'static str> {
-    Some(match id {
+/// frontend can show names instead of raw ids. Labels are per base account;
+/// a subaccount (an inflight hold) shares its base account's label.
+pub fn account_label(id: AccountRef) -> Option<&'static str> {
+    Some(match id.account {
         TREASURY => "Treasury",
         EXTERNAL => "External",
         ALICE => "Alice",

+ 5 - 5
crates/kuatia-dashboard/src/ui.rs

@@ -265,11 +265,11 @@ fn floor_style(assets: &[AssetMeta]) -> (u8, &str) {
 fn account_view(dto: &AccountDto, assets: &[AssetMeta]) -> AccountView {
     let (floor_dec, floor_sym) = floor_style(assets);
     AccountView {
-        id: dto.id.0,
+        id: dto.id.account.0,
         name: dto
             .label
             .map(String::from)
-            .unwrap_or_else(|| format!("#{}", dto.id.0)),
+            .unwrap_or_else(|| format!("#{}", dto.id.account.0)),
         version: dto.version,
         policy_kind: dto.policy.kind,
         floor: dto.policy.floor.map(|f| fmt(f, floor_dec, floor_sym)),
@@ -301,11 +301,11 @@ fn transfer_view(dto: &TransferDto, assets: &[AssetMeta]) -> TransferView {
                 to_name: leg
                     .label
                     .map(String::from)
-                    .unwrap_or_else(|| format!("#{}", leg.owner.0)),
+                    .unwrap_or_else(|| format!("#{}", leg.owner.account.0)),
                 from_name: leg.payer.map(|p| {
                     leg.payer_label
                         .map(String::from)
-                        .unwrap_or_else(|| format!("#{}", p.0))
+                        .unwrap_or_else(|| format!("#{}", p.account.0))
                 }),
                 is_change: leg.payer.is_none(),
                 money: fmt_asset(leg.value, asset_of(assets, leg.asset)),
@@ -326,7 +326,7 @@ fn event_view(dto: &EventDto) -> EventView {
     EventView {
         seq: dto.seq,
         kind: dto.kind,
-        account: dto.account.map(|a| a.0),
+        account: dto.account.map(|a| a.account.0),
         transfer_short: dto.transfer.as_deref().map(short_hex),
         time: fmt_millis(dto.timestamp),
     }

+ 123 - 76
crates/kuatia-storage-sql/src/lib.rs

@@ -47,7 +47,13 @@ impl SqlStore {
             .await
             .map_err(|e| StoreError::Internal(e.to_string()))?;
 
-        let migrations: &[(&str, &str)] = &[("001_init", include_str!("migrations/001_init.sql"))];
+        let migrations: &[(&str, &str)] = &[
+            ("001_init", include_str!("migrations/001_init.sql")),
+            (
+                "002_subaccounts",
+                include_str!("migrations/002_subaccounts.sql"),
+            ),
+        ];
 
         for (name, sql) in migrations {
             let applied = sqlx::query("SELECT 1 FROM _migrations WHERE name = $1")
@@ -160,6 +166,9 @@ 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 subaccount: i64 = row
+        .try_get("subaccount")
+        .map_err(|e| StoreError::Internal(e.to_string()))?;
     let version: i64 = row
         .try_get("version")
         .map_err(|e| StoreError::Internal(e.to_string()))?;
@@ -180,7 +189,7 @@ fn row_to_account(row: &sqlx::any::AnyRow) -> Result<Account, StoreError> {
         .map_err(|e| StoreError::Internal(e.to_string()))?;
 
     Ok(Account {
-        id: AccountId::new(id),
+        id: AccountRef::with_sub(AccountId::new(id), subaccount as u64),
         version: version as u64,
         policy: deserialize_policy(&policy_str)?,
         flags: AccountFlags::from_bits_truncate(flags_bits as u32),
@@ -200,6 +209,9 @@ fn row_to_posting(row: &sqlx::any::AnyRow) -> Result<Posting, StoreError> {
     let owner: i64 = row
         .try_get("owner")
         .map_err(|e| StoreError::Internal(e.to_string()))?;
+    let subaccount: i64 = row
+        .try_get("subaccount")
+        .map_err(|e| StoreError::Internal(e.to_string()))?;
     let asset: i32 = row
         .try_get("asset")
         .map_err(|e| StoreError::Internal(e.to_string()))?;
@@ -219,7 +231,7 @@ fn row_to_posting(row: &sqlx::any::AnyRow) -> Result<Posting, StoreError> {
             transfer: envelope_id_from_hex(&transfer_id)?,
             index: idx as u16,
         },
-        owner: AccountId::new(owner),
+        owner: AccountRef::with_sub(AccountId::new(owner), subaccount as u64),
         asset: AssetId::new(asset as u32),
         value,
         status: status_from_i16(status)?,
@@ -233,17 +245,20 @@ fn row_to_posting(row: &sqlx::any::AnyRow) -> Result<Posting, StoreError> {
 
 #[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:?}")))?;
+    async fn get_account(&self, id: &AccountRef) -> Result<Account, StoreError> {
+        let row = sqlx::query(
+            "SELECT * FROM accounts WHERE id = $1 AND subaccount = $2 ORDER BY version DESC LIMIT 1",
+        )
+        .bind(id.account.0)
+        .bind(id.sub as i64)
+        .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> {
+    async fn get_accounts(&self, ids: &[AccountRef]) -> Result<Vec<Account>, StoreError> {
         let mut result = Vec::with_capacity(ids.len());
         for id in ids {
             result.push(self.get_account(id).await?);
@@ -252,11 +267,13 @@ impl AccountStore for SqlStore {
     }
 
     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()))?;
+        let exists =
+            sqlx::query("SELECT 1 FROM accounts WHERE id = $1 AND subaccount = $2 LIMIT 1")
+                .bind(account.id.account.0)
+                .bind(account.id.sub as i64)
+                .fetch_optional(&self.pool)
+                .await
+                .map_err(|e| StoreError::Internal(e.to_string()))?;
         if exists.is_some() {
             return Err(StoreError::AlreadyExists(format!(
                 "account {:?}",
@@ -265,9 +282,10 @@ impl AccountStore for SqlStore {
         }
 
         sqlx::query(
-            "INSERT INTO accounts (id, version, policy, flags, book, user_data, metadata) VALUES ($1, $2, $3, $4, $5, $6, $7)"
+            "INSERT INTO accounts (id, subaccount, version, policy, flags, book, user_data, metadata) VALUES ($1, $2, $3, $4, $5, $6, $7, $8)"
         )
-            .bind(account.id.0)
+            .bind(account.id.account.0)
+            .bind(account.id.sub as i64)
             .bind(account.version as i64)
             .bind(serialize_policy(&account.policy)?)
             .bind(account.flags.bits() as i32)
@@ -281,13 +299,15 @@ impl AccountStore for SqlStore {
     }
 
     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 = sqlx::query(
+            "SELECT version FROM accounts WHERE id = $1 AND subaccount = $2 ORDER BY version DESC LIMIT 1",
+        )
+        .bind(account.id.account.0)
+        .bind(account.id.sub as i64)
+        .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")
@@ -305,9 +325,10 @@ impl AccountStore for SqlStore {
         }
 
         sqlx::query(
-            "INSERT INTO accounts (id, version, policy, flags, book, user_data, metadata) VALUES ($1, $2, $3, $4, $5, $6, $7)"
+            "INSERT INTO accounts (id, subaccount, version, policy, flags, book, user_data, metadata) VALUES ($1, $2, $3, $4, $5, $6, $7, $8)"
         )
-            .bind(account.id.0)
+            .bind(account.id.account.0)
+            .bind(account.id.sub as i64)
             .bind(account.version as i64)
             .bind(serialize_policy(&account.policy)?)
             .bind(account.flags.bits() as i32)
@@ -320,12 +341,15 @@ impl AccountStore for SqlStore {
         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()))?;
+    async fn get_account_history(&self, id: &AccountRef) -> Result<Vec<Account>, StoreError> {
+        let rows = sqlx::query(
+            "SELECT * FROM accounts WHERE id = $1 AND subaccount = $2 ORDER BY version ASC",
+        )
+        .bind(id.account.0)
+        .bind(id.sub as i64)
+        .fetch_all(&self.pool)
+        .await
+        .map_err(|e| StoreError::Internal(e.to_string()))?;
         if rows.is_empty() {
             return Err(StoreError::NotFound(format!("account {id:?}")));
         }
@@ -333,12 +357,14 @@ impl AccountStore for SqlStore {
     }
 
     async fn list_accounts(&self) -> Result<Vec<Account>, StoreError> {
-        let rows = sqlx::query("SELECT * FROM accounts ORDER BY id, version DESC")
+        let rows = sqlx::query("SELECT * FROM accounts ORDER BY id, subaccount, 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<_, _>>()?;
+        // Ordered by (id, subaccount, version DESC), so the first row of each
+        // (id, subaccount) group is the latest version; dedup keeps it.
         accounts.dedup_by_key(|a| a.id);
         Ok(accounts)
     }
@@ -368,42 +394,39 @@ impl PostingStore for SqlStore {
     async fn get_postings_by_account(
         &self,
         account: &AccountId,
+        sub: Option<u64>,
         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
-            }
+        // Build the predicate dynamically: owner is always bound; subaccount,
+        // asset, and status are each optional filters.
+        let mut sql = String::from("SELECT * FROM postings WHERE owner = $1");
+        let mut idx = 2u32;
+        if sub.is_some() {
+            sql.push_str(&format!(" AND subaccount = ${idx}"));
+            idx += 1;
         }
-        .map_err(|e| StoreError::Internal(e.to_string()))?;
+        if asset.is_some() {
+            sql.push_str(&format!(" AND asset = ${idx}"));
+            idx += 1;
+        }
+        if status.is_some() {
+            sql.push_str(&format!(" AND status = ${idx}"));
+        }
+        let mut q = sqlx::query(&sql).bind(account.0);
+        if let Some(s) = sub {
+            q = q.bind(s as i64);
+        }
+        if let Some(a) = asset {
+            q = q.bind(a.0 as i32);
+        }
+        if let Some(s) = status {
+            q = q.bind(status_to_i16(s));
+        }
+        let rows = q
+            .fetch_all(&self.pool)
+            .await
+            .map_err(|e| StoreError::Internal(e.to_string()))?;
 
         rows.iter().map(row_to_posting).collect()
     }
@@ -412,6 +435,10 @@ impl PostingStore for SqlStore {
         let (where_clause, count_clause) = {
             let mut w = String::from("WHERE owner = $1");
             let mut idx = 2u32;
+            if query.sub.is_some() {
+                w.push_str(&format!(" AND subaccount = ${idx}"));
+                idx += 1;
+            }
             if query.asset.is_some() {
                 w.push_str(&format!(" AND asset = ${idx}"));
                 idx += 1;
@@ -428,6 +455,9 @@ impl PostingStore for SqlStore {
 
         // Build count query
         let mut count_q = sqlx::query(&count_clause).bind(query.account.0);
+        if let Some(s) = query.sub {
+            count_q = count_q.bind(s as i64);
+        }
         if let Some(ref a) = query.asset {
             count_q = count_q.bind(a.0 as i32);
         }
@@ -444,6 +474,9 @@ impl PostingStore for SqlStore {
 
         // Build data query
         let mut data_q = sqlx::query(&where_clause).bind(query.account.0);
+        if let Some(s) = query.sub {
+            data_q = data_q.bind(s as i64);
+        }
         if let Some(ref a) = query.asset {
             data_q = data_q.bind(a.0 as i32);
         }
@@ -583,11 +616,12 @@ impl PostingStore for SqlStore {
         let mut inserted: u64 = 0;
         for posting in postings {
             let res = sqlx::query(
-                "INSERT INTO postings (transfer_id, idx, owner, asset, value, status) VALUES ($1, $2, $3, $4, $5, $6) ON CONFLICT (transfer_id, idx) DO NOTHING"
+                "INSERT INTO postings (transfer_id, idx, owner, subaccount, asset, value, status) VALUES ($1, $2, $3, $4, $5, $6, $7) ON CONFLICT (transfer_id, idx) DO NOTHING"
             )
                 .bind(envelope_id_to_hex(&posting.id.transfer))
                 .bind(posting.id.index as i16)
-                .bind(posting.owner.0)
+                .bind(posting.owner.account.0)
+                .bind(posting.owner.sub as i64)
                 .bind(posting.asset.0 as i32)
                 .bind(posting.value.to_string())
                 .bind(status_to_i16(posting.status))
@@ -640,7 +674,7 @@ impl TransferStore for SqlStore {
     async fn store_transfer(
         &self,
         record: EnvelopeRecord,
-        involved: &[AccountId],
+        involved: &[AccountRef],
     ) -> Result<u64, StoreError> {
         let tid = record.receipt.transfer_id;
         let tid_hex = envelope_id_to_hex(&tid);
@@ -667,9 +701,10 @@ impl TransferStore for SqlStore {
         // Index every involved account (caller supplies the set; storage does no
         // computation). Idempotent so a replay is harmless.
         for account in involved {
-            sqlx::query("INSERT INTO transfer_accounts (transfer_id, account_id) VALUES ($1, $2) ON CONFLICT (transfer_id, account_id) DO NOTHING")
+            sqlx::query("INSERT INTO transfer_accounts (transfer_id, account_id, subaccount) VALUES ($1, $2, $3) ON CONFLICT (transfer_id, account_id, subaccount) DO NOTHING")
                 .bind(&tid_hex)
-                .bind(account.0)
+                .bind(account.account.0)
+                .bind(account.sub as i64)
                 .execute(&mut *tx)
                 .await
                 .map_err(|e| StoreError::Internal(e.to_string()))?;
@@ -684,11 +719,23 @@ impl TransferStore for SqlStore {
     async fn get_transfers_for_account(
         &self,
         account: &AccountId,
+        sub: Option<u64>,
     ) -> 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)
+        // DISTINCT because a transfer can index the same base account under
+        // several subaccounts; an unfiltered (sub = None) query would otherwise
+        // return the transfer once per matching subaccount row.
+        let mut sql = String::from(
+            "SELECT DISTINCT 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",
+        );
+        if sub.is_some() {
+            sql.push_str(" AND ta.subaccount = $2");
+        }
+        sql.push_str(" ORDER BY t.created_at");
+        let mut q = sqlx::query(&sql).bind(account.0);
+        if let Some(s) = sub {
+            q = q.bind(s as i64);
+        }
+        let rows = q
             .fetch_all(&self.pool)
             .await
             .map_err(|e| StoreError::Internal(e.to_string()))?;
@@ -719,7 +766,7 @@ impl TransferStore for SqlStore {
     ) -> 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?
+            self.get_transfers_for_account(account, query.sub).await?
         } else {
             let rows = sqlx::query(
                 "SELECT transfer, receipt, created_at FROM transfers ORDER BY created_at",

+ 43 - 0
crates/kuatia-storage-sql/src/migrations/002_subaccounts.sql

@@ -0,0 +1,43 @@
+-- Add a subaccount dimension to account identity and posting ownership.
+-- Existing rows become subaccount 0 (the main subaccount). Portable across
+-- SQLite and PostgreSQL. The migration runner executes each statement on its
+-- own, so there are no transaction-control or backend-specific constructs here.
+-- Comments avoid semicolons because the runner splits statements on them.
+
+-- postings: a new column plus a widened owner index. The primary key
+-- (transfer_id, idx) is unaffected, so a plain ADD COLUMN suffices.
+ALTER TABLE postings ADD COLUMN subaccount BIGINT NOT NULL DEFAULT 0;
+DROP INDEX IF EXISTS idx_postings_owner;
+CREATE INDEX IF NOT EXISTS idx_postings_owner ON postings(owner, subaccount, asset, status);
+
+-- accounts: the primary key must widen to (id, subaccount, version). SQLite
+-- cannot alter a primary key, so rebuild the table and copy rows as subaccount 0.
+CREATE TABLE accounts_new (
+    id          BIGINT NOT NULL,
+    subaccount  BIGINT NOT NULL DEFAULT 0,
+    version     BIGINT NOT NULL,
+    policy      TEXT NOT NULL,
+    flags       INTEGER NOT NULL,
+    book        BIGINT NOT NULL,
+    user_data   TEXT NOT NULL,
+    metadata    TEXT NOT NULL,
+    PRIMARY KEY (id, subaccount, version)
+);
+INSERT INTO accounts_new (id, subaccount, version, policy, flags, book, user_data, metadata)
+    SELECT id, 0, version, policy, flags, book, user_data, metadata FROM accounts;
+DROP TABLE accounts;
+ALTER TABLE accounts_new RENAME TO accounts;
+
+-- transfer_accounts: the primary key must widen to include the subaccount, and
+-- the involved-account index widens with it. Rebuild and copy as subaccount 0.
+CREATE TABLE transfer_accounts_new (
+    transfer_id TEXT NOT NULL,
+    account_id  BIGINT NOT NULL,
+    subaccount  BIGINT NOT NULL DEFAULT 0,
+    PRIMARY KEY (transfer_id, account_id, subaccount)
+);
+INSERT INTO transfer_accounts_new (transfer_id, account_id, subaccount)
+    SELECT transfer_id, account_id, 0 FROM transfer_accounts;
+DROP TABLE transfer_accounts;
+ALTER TABLE transfer_accounts_new RENAME TO transfer_accounts;
+CREATE INDEX IF NOT EXISTS idx_xfer_acct ON transfer_accounts(account_id, subaccount);

+ 2 - 2
crates/kuatia-storage-sql/tests/sqlite.rs

@@ -33,7 +33,7 @@ async fn columns_store_hex_ids_and_json_text() {
     store.migrate().await.unwrap();
 
     let account = Account {
-        id: AccountId::new(1),
+        id: AccountRef::main(AccountId::new(1)),
         version: 1,
         policy: AccountPolicy::NoOverdraft,
         flags: AccountFlags::empty(),
@@ -49,7 +49,7 @@ async fn columns_store_hex_ids_and_json_text() {
             transfer: tid,
             index: 0,
         },
-        AccountId::new(1),
+        AccountRef::main(AccountId::new(1)),
         AssetId::new(1),
         Cent::from(100),
     );

+ 3 - 3
crates/kuatia-storage/src/error.rs

@@ -1,6 +1,6 @@
 //! Error types for storage implementations.
 
-use kuatia_types::AccountId;
+use kuatia_types::AccountRef;
 
 /// Errors produced by [`Store`](crate::store::Store) implementations.
 ///
@@ -15,8 +15,8 @@ pub enum StoreError {
     AlreadyExists(String),
     /// Optimistic version check failed on an account update.
     VersionConflict {
-        /// Account that had a version mismatch.
-        account: AccountId,
+        /// Account (subaccount) that had a version mismatch.
+        account: AccountRef,
         /// Version the caller expected.
         expected: u64,
         /// Version the store actually had.

+ 5 - 5
crates/kuatia-storage/src/events.rs

@@ -3,7 +3,7 @@
 use async_trait::async_trait;
 use serde::{Deserialize, Serialize};
 
-use kuatia_types::{AccountId, EnvelopeId};
+use kuatia_types::{AccountRef, EnvelopeId};
 
 use crate::error::StoreError;
 
@@ -18,22 +18,22 @@ pub enum LedgerEventKind {
     /// An account was created.
     AccountCreated {
         /// The id of the created account.
-        account_id: AccountId,
+        account_id: AccountRef,
     },
     /// An account was frozen.
     AccountFrozen {
         /// The id of the frozen account.
-        account_id: AccountId,
+        account_id: AccountRef,
     },
     /// An account was unfrozen.
     AccountUnfrozen {
         /// The id of the unfrozen account.
-        account_id: AccountId,
+        account_id: AccountRef,
     },
     /// An account was closed.
     AccountClosed {
         /// The id of the closed account.
-        account_id: AccountId,
+        account_id: AccountRef,
     },
 }
 

+ 21 - 14
crates/kuatia-storage/src/mem_store.rs

@@ -8,10 +8,15 @@ use tokio::sync::RwLock;
 
 use kuatia_types::autoid::AutoId;
 use kuatia_types::{
-    Account, AccountId, AssetId, Book, BookId, EnvelopeId, Posting, PostingId, PostingStatus,
-    ReservationId,
+    Account, AccountId, AccountRef, AssetId, Book, BookId, EnvelopeId, Posting, PostingId,
+    PostingStatus, ReservationId,
 };
 
+/// Whether an owner reference matches a base account and optional subaccount.
+fn owner_matches(owner: &AccountRef, account: &AccountId, sub: Option<u64>) -> bool {
+    owner.account == *account && sub.is_none_or(|s| owner.sub == s)
+}
+
 use crate::error::StoreError;
 use crate::events::{EventStore, LedgerEvent};
 use crate::store::{
@@ -21,7 +26,7 @@ use crate::store::{
 /// 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>>>,
+    accounts: RwLock<HashMap<AccountRef, Vec<Account>>>,
     transfers: RwLock<HashMap<EnvelopeId, EnvelopeRecord>>,
     sagas: RwLock<HashMap<i64, Vec<u8>>>,
     events: RwLock<Vec<LedgerEvent>>,
@@ -56,7 +61,7 @@ impl InMemoryStore {
 
 #[async_trait]
 impl AccountStore for InMemoryStore {
-    async fn get_account(&self, id: &AccountId) -> Result<Account, StoreError> {
+    async fn get_account(&self, id: &AccountRef) -> Result<Account, StoreError> {
         let accounts = self.accounts.read().await;
         accounts
             .get(id)
@@ -65,7 +70,7 @@ impl AccountStore for InMemoryStore {
             .ok_or_else(|| StoreError::NotFound(format!("account {id:?}")))
     }
 
-    async fn get_accounts(&self, ids: &[AccountId]) -> Result<Vec<Account>, StoreError> {
+    async fn get_accounts(&self, ids: &[AccountRef]) -> Result<Vec<Account>, StoreError> {
         let accounts = self.accounts.read().await;
         let mut result = Vec::with_capacity(ids.len());
         for id in ids {
@@ -110,7 +115,7 @@ impl AccountStore for InMemoryStore {
         Ok(())
     }
 
-    async fn get_account_history(&self, id: &AccountId) -> Result<Vec<Account>, StoreError> {
+    async fn get_account_history(&self, id: &AccountRef) -> Result<Vec<Account>, StoreError> {
         let accounts = self.accounts.read().await;
         accounts
             .get(id)
@@ -148,6 +153,7 @@ impl PostingStore for InMemoryStore {
     async fn get_postings_by_account(
         &self,
         account: &AccountId,
+        sub: Option<u64>,
         asset: Option<&AssetId>,
         status: Option<PostingStatus>,
     ) -> Result<Vec<Posting>, StoreError> {
@@ -155,7 +161,7 @@ impl PostingStore for InMemoryStore {
         Ok(postings
             .values()
             .filter(|p| {
-                p.owner == *account
+                owner_matches(&p.owner, account, sub)
                     && asset.is_none_or(|a| p.asset == *a)
                     && status.is_none_or(|s| p.status == s)
             })
@@ -259,7 +265,7 @@ impl TransferStore for InMemoryStore {
     async fn store_transfer(
         &self,
         record: EnvelopeRecord,
-        _involved: &[AccountId],
+        _involved: &[AccountRef],
     ) -> Result<u64, StoreError> {
         // `_involved` is ignored here: `get_transfers_for_account` derives the
         // involved accounts from the stored envelope (creates owners + consumed
@@ -276,6 +282,7 @@ impl TransferStore for InMemoryStore {
     async fn get_transfers_for_account(
         &self,
         account: &AccountId,
+        sub: Option<u64>,
     ) -> Result<Vec<EnvelopeRecord>, StoreError> {
         // Acquire postings → transfers in a consistent order to avoid an AB–BA
         // deadlock with any reader that takes both.
@@ -288,12 +295,12 @@ impl TransferStore for InMemoryStore {
                     .envelope
                     .creates()
                     .iter()
-                    .any(|np| np.owner == *account)
-                    || record
-                        .envelope
-                        .consumes()
-                        .iter()
-                        .any(|pid| postings.get(pid).is_some_and(|p| p.owner == *account))
+                    .any(|np| owner_matches(&np.owner, account, sub))
+                    || record.envelope.consumes().iter().any(|pid| {
+                        postings
+                            .get(pid)
+                            .is_some_and(|p| owner_matches(&p.owner, account, sub))
+                    })
             })
             .cloned()
             .collect();

+ 29 - 15
crates/kuatia-storage/src/store.rs

@@ -13,8 +13,8 @@
 
 use async_trait::async_trait;
 use kuatia_types::{
-    Account, AccountId, AssetId, Book, BookId, Envelope, EnvelopeId, Posting, PostingId,
-    PostingStatus, Receipt, ReservationId,
+    Account, AccountId, AccountRef, AssetId, Book, BookId, Envelope, EnvelopeId, Posting,
+    PostingId, PostingStatus, Receipt, ReservationId,
 };
 
 use crate::error::StoreError;
@@ -34,8 +34,10 @@ pub struct EnvelopeRecord {
 /// Pagination and filtering parameters for posting queries.
 #[derive(Debug, Clone)]
 pub struct PostingQuery {
-    /// Filter to postings owned by this account.
+    /// Filter to postings owned by this base account.
     pub account: AccountId,
+    /// Filter to a specific subaccount; `None` spans all subaccounts.
+    pub sub: Option<u64>,
     /// Filter by asset.
     pub asset: Option<AssetId>,
     /// Filter by posting status.
@@ -49,8 +51,10 @@ pub struct PostingQuery {
 /// Pagination and filtering parameters for transfer queries.
 #[derive(Debug, Clone, Default)]
 pub struct TransferQuery {
-    /// Filter to transfers involving this account.
+    /// Filter to transfers involving this base account.
     pub account: Option<AccountId>,
+    /// Restrict the account filter to a specific subaccount; `None` spans all.
+    pub sub: Option<u64>,
     /// Inclusive lower bound (unix millis).
     pub from_ts: Option<i64>,
     /// Exclusive upper bound (unix millis).
@@ -79,16 +83,16 @@ pub struct Page<T> {
 /// 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>;
+    /// Fetch a single account (subaccount) by reference.
+    async fn get_account(&self, id: &AccountRef) -> Result<Account, StoreError>;
+    /// Fetch multiple accounts (subaccounts) by reference.
+    async fn get_accounts(&self, ids: &[AccountRef]) -> 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>;
+    /// Return the full version history for an account (subaccount).
+    async fn get_account_history(&self, id: &AccountRef) -> Result<Vec<Account>, StoreError>;
     /// List all accounts (latest version of each).
     async fn list_accounts(&self) -> Result<Vec<Account>, StoreError>;
 }
@@ -98,10 +102,13 @@ pub trait AccountStore: Send + Sync {
 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.
+    /// Return postings owned by a base account, optionally filtered by subaccount,
+    /// asset, and/or status. `sub == None` spans every subaccount; `Some(s)`
+    /// restricts to that subaccount.
     async fn get_postings_by_account(
         &self,
         account: &AccountId,
+        sub: Option<u64>,
         asset: Option<&AssetId>,
         status: Option<PostingStatus>,
     ) -> Result<Vec<Posting>, StoreError>;
@@ -147,7 +154,12 @@ pub trait PostingStore: Send + Sync {
     /// 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)
+            .get_postings_by_account(
+                &query.account,
+                query.sub,
+                query.asset.as_ref(),
+                query.status,
+            )
             .await?;
         let total = all.len() as u64;
         let offset = query.offset.unwrap_or(0) as usize;
@@ -170,12 +182,14 @@ pub trait TransferStore: Send + Sync {
     async fn store_transfer(
         &self,
         record: EnvelopeRecord,
-        involved: &[AccountId],
+        involved: &[AccountRef],
     ) -> Result<u64, StoreError>;
-    /// Return all transfers involving the given account.
+    /// Return all transfers involving the given base account, optionally
+    /// restricted to a single subaccount (`sub == None` spans all subaccounts).
     async fn get_transfers_for_account(
         &self,
         account: &AccountId,
+        sub: Option<u64>,
     ) -> Result<Vec<EnvelopeRecord>, StoreError>;
 
     /// Query transfers with filtering and pagination.
@@ -185,7 +199,7 @@ pub trait TransferStore: Send + Sync {
     ) -> Result<Page<EnvelopeRecord>, StoreError> {
         // Default in-memory implementation
         let all = if let Some(ref account) = query.account {
-            self.get_transfers_for_account(account).await?
+            self.get_transfers_for_account(account, query.sub).await?
         } else {
             return Err(StoreError::Internal(
                 "query_transfers requires account filter in default implementation".into(),

+ 43 - 21
crates/kuatia-storage/src/store_tests.rs

@@ -21,7 +21,7 @@ use crate::store::*;
 
 fn make_account(id: i64, policy: AccountPolicy) -> Account {
     Account {
-        id: AccountId::new(id),
+        id: AccountRef::main(AccountId::new(id)),
         version: 1,
         policy,
         flags: AccountFlags::empty(),
@@ -43,7 +43,7 @@ fn make_posting(
             transfer: EnvelopeId(transfer_hash),
             index,
         },
-        AccountId::new(owner),
+        AccountRef::main(AccountId::new(owner)),
         AssetId::new(asset),
         Cent::from(value),
     )
@@ -53,13 +53,13 @@ fn make_envelope_with_book(book: BookId) -> (Envelope, EnvelopeId) {
     let t = EnvelopeBuilder::new()
         .creates(vec![
             NewPosting {
-                owner: AccountId::new(1),
+                owner: AccountRef::main(AccountId::new(1)),
                 asset: AssetId::new(1),
                 value: Cent::from(100),
                 payer: None,
             },
             NewPosting {
-                owner: AccountId::new(99),
+                owner: AccountRef::main(AccountId::new(99)),
                 asset: AssetId::new(1),
                 value: Cent::from(-100),
                 payer: None,
@@ -78,13 +78,13 @@ fn make_envelope() -> (Envelope, EnvelopeId) {
     let t = EnvelopeBuilder::new()
         .creates(vec![
             NewPosting {
-                owner: AccountId::new(1),
+                owner: AccountRef::main(AccountId::new(1)),
                 asset: AssetId::new(1),
                 value: Cent::from(100),
                 payer: None,
             },
             NewPosting {
-                owner: AccountId::new(99),
+                owner: AccountRef::main(AccountId::new(99)),
                 asset: AssetId::new(1),
                 value: Cent::from(-100),
                 payer: None,
@@ -127,7 +127,7 @@ async fn commit_envelope(
             )
         })
         .collect();
-    let mut involved: Vec<AccountId> = create.iter().map(|p| p.owner).collect();
+    let mut involved: Vec<AccountRef> = create.iter().map(|p| p.owner).collect();
     involved.sort();
     involved.dedup();
     store.insert_postings(&create).await.unwrap();
@@ -152,7 +152,10 @@ async fn commit_envelope(
 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();
+    let got = store
+        .get_account(&AccountRef::main(AccountId::new(1)))
+        .await
+        .unwrap();
     assert_eq!(got.id, acc.id);
     assert_eq!(got.version, 1);
 }
@@ -167,7 +170,10 @@ pub async fn create_duplicate_account_fails(store: &(impl Store + 'static)) {
 
 /// 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();
+    let err = store
+        .get_account(&AccountRef::main(AccountId::new(999)))
+        .await
+        .unwrap_err();
     assert!(matches!(err, StoreError::NotFound(_)));
 }
 
@@ -182,7 +188,10 @@ pub async fn get_accounts_batch(store: &(impl Store + 'static)) {
         .await
         .unwrap();
     let accs = store
-        .get_accounts(&[AccountId::new(1), AccountId::new(2)])
+        .get_accounts(&[
+            AccountRef::main(AccountId::new(1)),
+            AccountRef::main(AccountId::new(2)),
+        ])
         .await
         .unwrap();
     assert_eq!(accs.len(), 2);
@@ -198,7 +207,10 @@ pub async fn append_account_version(store: &(impl Store + 'static)) {
     v2.flags = AccountFlags::FROZEN;
     store.append_account_version(v2).await.unwrap();
 
-    let got = store.get_account(&AccountId::new(1)).await.unwrap();
+    let got = store
+        .get_account(&AccountRef::main(AccountId::new(1)))
+        .await
+        .unwrap();
     assert_eq!(got.version, 2);
     assert!(got.is_frozen());
 }
@@ -223,7 +235,10 @@ pub async fn get_account_history(store: &(impl Store + 'static)) {
     v2.version = 2;
     store.append_account_version(v2).await.unwrap();
 
-    let history = store.get_account_history(&AccountId::new(1)).await.unwrap();
+    let history = store
+        .get_account_history(&AccountRef::main(AccountId::new(1)))
+        .await
+        .unwrap();
     assert_eq!(history.len(), 2);
     assert_eq!(history[0].version, 1);
     assert_eq!(history[1].version, 2);
@@ -275,20 +290,20 @@ pub async fn get_postings_by_account_filters(store: &(impl Store + 'static)) {
     seed_active(store, 200, &[p1, p2, p3]).await;
 
     let all = store
-        .get_postings_by_account(&AccountId::new(1), None, None)
+        .get_postings_by_account(&AccountId::new(1), None, None, None)
         .await
         .unwrap();
     assert_eq!(all.len(), 2);
 
     let filtered = store
-        .get_postings_by_account(&AccountId::new(1), Some(&AssetId::new(1)), None)
+        .get_postings_by_account(&AccountId::new(1), None, 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))
+        .get_postings_by_account(&AccountId::new(1), None, None, Some(PostingStatus::Active))
         .await
         .unwrap();
     assert_eq!(active.len(), 2);
@@ -306,6 +321,7 @@ pub async fn query_postings_pagination(store: &(impl Store + 'static)) {
     let page1 = store
         .query_postings(&PostingQuery {
             account: AccountId::new(1),
+            sub: None,
             asset: None,
             status: None,
             limit: Some(2),
@@ -320,6 +336,7 @@ pub async fn query_postings_pagination(store: &(impl Store + 'static)) {
     let page2 = store
         .query_postings(&PostingQuery {
             account: AccountId::new(1),
+            sub: None,
             asset: None,
             status: None,
             limit: Some(2),
@@ -334,6 +351,7 @@ pub async fn query_postings_pagination(store: &(impl Store + 'static)) {
     let page3 = store
         .query_postings(&PostingQuery {
             account: AccountId::new(1),
+            sub: None,
             asset: None,
             status: None,
             limit: Some(2),
@@ -348,6 +366,7 @@ pub async fn query_postings_pagination(store: &(impl Store + 'static)) {
     let filtered = store
         .query_postings(&PostingQuery {
             account: AccountId::new(1),
+            sub: None,
             asset: Some(AssetId::new(1)),
             status: None,
             limit: Some(10),
@@ -590,7 +609,10 @@ pub async fn store_transfer_counts(store: &(impl Store + 'static)) {
         receipt: Receipt { transfer_id: tid },
         created_at: 1000,
     };
-    let involved = [AccountId::new(1), AccountId::new(99)];
+    let involved = [
+        AccountRef::main(AccountId::new(1)),
+        AccountRef::main(AccountId::new(99)),
+    ];
 
     assert_eq!(
         store
@@ -604,7 +626,7 @@ pub async fn store_transfer_counts(store: &(impl Store + 'static)) {
     assert!(store.get_transfer(&tid).await.unwrap().is_some());
     assert_eq!(
         store
-            .get_transfers_for_account(&AccountId::new(1))
+            .get_transfers_for_account(&AccountId::new(1), None)
             .await
             .unwrap()
             .len(),
@@ -703,13 +725,13 @@ pub async fn get_transfers_for_account(store: &(impl Store + 'static)) {
     commit_envelope(store, envelope, tid, 1000).await;
 
     let records = store
-        .get_transfers_for_account(&AccountId::new(1))
+        .get_transfers_for_account(&AccountId::new(1), None)
         .await
         .unwrap();
     assert_eq!(records.len(), 1);
 
     let empty = store
-        .get_transfers_for_account(&AccountId::new(999))
+        .get_transfers_for_account(&AccountId::new(999), None)
         .await
         .unwrap();
     assert!(empty.is_empty());
@@ -840,7 +862,7 @@ pub async fn append_and_query_events(store: &(impl Store + 'static)) {
         seq: 0,
         timestamp: 1000,
         kind: LedgerEventKind::AccountCreated {
-            account_id: AccountId::new(1),
+            account_id: AccountRef::main(AccountId::new(1)),
         },
     };
     let e2 = LedgerEvent {
@@ -869,7 +891,7 @@ pub async fn events_sequence_ordering(store: &(impl Store + 'static)) {
                 seq: 0,
                 timestamp: (i as i64 + 1) * 1000,
                 kind: LedgerEventKind::AccountCreated {
-                    account_id: AccountId::new(i as i64 + 1),
+                    account_id: AccountRef::main(AccountId::new(i as i64 + 1)),
                 },
             })
             .await

+ 1 - 1
crates/kuatia-storage/tests/concurrency.rs

@@ -20,7 +20,7 @@ fn posting(index: u16) -> Posting {
             transfer: EnvelopeId([1; 32]),
             index,
         },
-        AccountId::new(1),
+        AccountRef::main(AccountId::new(1)),
         AssetId::new(1),
         Cent::from(100),
     )

+ 100 - 28
crates/kuatia-types/src/lib.rs

@@ -69,8 +69,8 @@ pub struct AccountId(pub i64);
 /// public API uses [`AccountId`].
 #[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
 pub struct AccountSnapshotId {
-    /// The account this snapshot belongs to.
-    pub account: AccountId,
+    /// The account (subaccount) this snapshot belongs to.
+    pub account: AccountRef,
     /// Double-SHA256 of the account's state at the time of the snapshot.
     pub snapshot_id: [u8; 32],
 }
@@ -164,7 +164,55 @@ impl AccountId {
     }
 }
 
-impl From<AccountSnapshotId> for AccountId {
+/// A subaccount identity: a base [`AccountId`] plus an `i64` subaccount, where
+/// `0` is the account's main subaccount. Every posting is owned by an
+/// `AccountRef`, and each `(account, sub)` is its own account record with its own
+/// policy. Inflight holds live in a non-zero subaccount of their destination, so
+/// one account can host many concurrent inflights.
+#[derive(Clone, Copy, PartialEq, Eq, Hash, PartialOrd, Ord, Serialize, Deserialize)]
+pub struct AccountRef {
+    /// Base account.
+    pub account: AccountId,
+    /// Subaccount within the base account; `0` is the main subaccount. Opaque and
+    /// unordered (an inflight hold's subaccount is derived from a hash), so it is
+    /// unsigned to use the full range.
+    pub sub: u64,
+}
+
+impl AccountRef {
+    /// The main subaccount (`sub = 0`) of `account`.
+    pub const fn main(account: AccountId) -> Self {
+        Self { account, sub: 0 }
+    }
+
+    /// A specific subaccount of `account`.
+    pub const fn with_sub(account: AccountId, sub: u64) -> Self {
+        Self { account, sub }
+    }
+
+    /// Whether this reference is the main subaccount (`sub == 0`).
+    pub const fn is_main(&self) -> bool {
+        self.sub == 0
+    }
+}
+
+impl From<AccountId> for AccountRef {
+    fn from(account: AccountId) -> Self {
+        Self::main(account)
+    }
+}
+
+impl fmt::Debug for AccountRef {
+    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
+        if self.sub == 0 {
+            write!(f, "AccountRef({})", self.account.0)
+        } else {
+            write!(f, "AccountRef({}.{})", self.account.0, self.sub)
+        }
+    }
+}
+
+impl From<AccountSnapshotId> for AccountRef {
     fn from(snap: AccountSnapshotId) -> Self {
         snap.account
     }
@@ -365,8 +413,8 @@ pub enum PostingStatus {
 pub struct Posting {
     /// Unique identifier derived from the creating transfer.
     pub id: PostingId,
-    /// The account that owns this posting.
-    pub owner: AccountId,
+    /// The account (subaccount) that owns this posting.
+    pub owner: AccountRef,
     /// The asset this posting denominates.
     pub asset: AssetId,
     /// Signed: positive = value controlled by the account, negative = offset position.
@@ -381,7 +429,7 @@ pub struct Posting {
 
 impl Posting {
     /// Construct an `Active`, unreserved posting.
-    pub fn new(id: PostingId, owner: AccountId, asset: AssetId, value: Cent) -> Self {
+    pub fn new(id: PostingId, owner: AccountRef, asset: AssetId, value: Cent) -> Self {
         Self {
             id,
             owner,
@@ -402,14 +450,14 @@ impl Posting {
 /// 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 account (subaccount) that will own the created posting.
+    pub owner: AccountRef,
     /// The asset this posting denominates.
     pub asset: AssetId,
     /// Signed amount: positive = value controlled by the account, negative = offset position.
     pub value: Cent,
     /// Informational provenance — who funded this posting.
-    pub payer: Option<AccountId>,
+    pub payer: Option<AccountRef>,
 }
 
 // ---------------------------------------------------------------------------
@@ -480,9 +528,9 @@ impl Envelope {
         &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();
+    /// Deduplicated, sorted list of account references in the created postings.
+    pub fn referenced_accounts(&self) -> Vec<AccountRef> {
+        let mut ids: Vec<AccountRef> = self.creates.iter().map(|p| p.owner).collect();
         ids.sort();
         ids.dedup();
         ids
@@ -614,8 +662,8 @@ bitflags::bitflags! {
 /// 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,
+    /// Stable identity for this account (base account plus subaccount).
+    pub id: AccountRef,
     /// Monotonically increasing version, starts at 1 on creation.
     pub version: u64,
     /// Overdraft / balance policy.
@@ -631,10 +679,15 @@ pub struct Account {
 }
 
 impl Account {
-    /// Create a version-1 account with the given policy: no flags, the default
-    /// book, and empty user data / metadata. Convenience for the common case —
-    /// set the other fields explicitly when you need them.
+    /// Create a version-1 main-subaccount account with the given policy: no flags,
+    /// the default book, and empty user data / metadata. Convenience for the common
+    /// case — set the other fields explicitly when you need them.
     pub fn new(id: AccountId, policy: AccountPolicy) -> Self {
+        Self::new_ref(AccountRef::main(id), policy)
+    }
+
+    /// Like [`Account::new`] but for a specific subaccount reference.
+    pub fn new_ref(id: AccountRef, policy: AccountPolicy) -> Self {
         Self {
             id,
             version: 1,
@@ -679,10 +732,10 @@ pub struct Receipt {
 /// 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,
+    /// Account (subaccount) being debited.
+    pub from: AccountRef,
+    /// Account (subaccount) being credited.
+    pub to: AccountRef,
     /// Asset to transfer.
     pub asset: AssetId,
     /// Amount to transfer (may be negative for offset postings).
@@ -718,11 +771,16 @@ impl TransferBuilder {
         Self::default()
     }
 
-    /// Add a raw movement.
-    pub fn movement(
+    /// Add a raw movement between main subaccounts.
+    pub fn movement(self, from: AccountId, to: AccountId, asset: AssetId, amount: Cent) -> Self {
+        self.movement_ref(AccountRef::main(from), AccountRef::main(to), asset, amount)
+    }
+
+    /// Add a raw movement between specific subaccounts.
+    pub fn movement_ref(
         mut self,
-        from: AccountId,
-        to: AccountId,
+        from: AccountRef,
+        to: AccountRef,
         asset: AssetId,
         amount: Cent,
     ) -> Self {
@@ -735,11 +793,16 @@ impl TransferBuilder {
         self
     }
 
-    /// Add a pay movement: transfer value between two accounts.
+    /// Add a pay movement between main subaccounts.
     pub fn pay(self, from: AccountId, to: AccountId, asset: AssetId, amount: Cent) -> Self {
         self.movement(from, to, asset, amount)
     }
 
+    /// Add a pay movement between specific subaccounts.
+    pub fn pay_ref(self, from: AccountRef, to: AccountRef, asset: AssetId, amount: Cent) -> Self {
+        self.movement_ref(from, to, asset, amount)
+    }
+
     /// Add a deposit: creates an offset posting on the external account and
     /// credits the target account.  Pushes two movements whose net debit on the
     /// external account is zero.
@@ -801,10 +864,19 @@ impl ToBytes for AccountId {
     }
 }
 
-impl ToBytes for AccountSnapshotId {
+impl ToBytes for AccountRef {
     fn to_bytes(&self) -> Vec<u8> {
-        let mut buf = Vec::with_capacity(40);
+        let mut buf = Vec::with_capacity(16);
         buf.extend_from_slice(&self.account.0.to_be_bytes());
+        buf.extend_from_slice(&self.sub.to_be_bytes());
+        buf
+    }
+}
+
+impl ToBytes for AccountSnapshotId {
+    fn to_bytes(&self) -> Vec<u8> {
+        let mut buf = Vec::with_capacity(48);
+        buf.extend(self.account.to_bytes());
         buf.extend_from_slice(&self.snapshot_id);
         buf
     }

+ 2 - 2
crates/kuatia/examples/create_accounts.rs

@@ -34,7 +34,7 @@ async fn main() -> Result<(), Box<dyn std::error::Error>> {
     // The same thing spelled out, so you can see every field of an `Account`.
     // This boundary account is where value enters/leaves the ledger.
     let external = Account {
-        id: AccountId::new(99),
+        id: AccountRef::main(AccountId::new(99)),
         version: 1,                             // accounts always start at version 1
         policy: AccountPolicy::ExternalAccount, // boundary for deposits/withdrawals
         flags: AccountFlags::empty(),           // not frozen, not closed
@@ -47,7 +47,7 @@ async fn main() -> Result<(), Box<dyn std::error::Error>> {
     // Read them back (latest version of each).
     println!("accounts:");
     let mut accounts = ledger.list_accounts().await?;
-    accounts.sort_by_key(|a| a.id.0);
+    accounts.sort_by_key(|a| a.id.account.0);
     for a in &accounts {
         println!("  {:?}  policy={:?}  v{}", a.id, a.policy, a.version);
     }

+ 4 - 4
crates/kuatia/examples/fund_and_trade.rs

@@ -77,13 +77,13 @@ async fn print_balances(
     let money = Amount::new(2);
     println!(
         "  alice: {} USD, {} EUR",
-        money.format(ledger.balance(&alice, &usd).await?),
-        money.format(ledger.balance(&alice, &eur).await?),
+        money.format(ledger.balance(&AccountRef::main(alice), &usd).await?),
+        money.format(ledger.balance(&AccountRef::main(alice), &eur).await?),
     );
     println!(
         "  bob:   {} USD, {} EUR",
-        money.format(ledger.balance(&bob, &usd).await?),
-        money.format(ledger.balance(&bob, &eur).await?),
+        money.format(ledger.balance(&AccountRef::main(bob), &usd).await?),
+        money.format(ledger.balance(&AccountRef::main(bob), &eur).await?),
     );
     Ok(())
 }

+ 3 - 3
crates/kuatia/examples/withdraw.rs

@@ -37,7 +37,7 @@ async fn main() -> Result<(), Box<dyn std::error::Error>> {
         .await?;
     println!(
         "after deposit:  alice = {} USD",
-        money.format(ledger.balance(&alice, &usd).await?)
+        money.format(ledger.balance(&AccountRef::main(alice), &usd).await?)
     );
 
     // Withdraw $30.00 from Alice out to the external boundary account.
@@ -50,14 +50,14 @@ async fn main() -> Result<(), Box<dyn std::error::Error>> {
         .await?;
     println!(
         "after withdraw: alice = {} USD",
-        money.format(ledger.balance(&alice, &usd).await?)
+        money.format(ledger.balance(&AccountRef::main(alice), &usd).await?)
     );
 
     // The external account carries the offset (negative) side: the mirror of the
     // value that currently sits inside the ledger.
     println!(
         "external boundary: {} USD",
-        money.format(ledger.balance(&external, &usd).await?)
+        money.format(ledger.balance(&AccountRef::main(external), &usd).await?)
     );
 
     Ok(())

+ 7 - 7
crates/kuatia/src/error.rs

@@ -4,7 +4,7 @@
 //! and from storage, so callers get a single error type from every API.
 
 use kuatia_core::{
-    AccountId, AssetId, BookId, EnvelopeId, OverflowError, PostingId, SelectionError,
+    AccountRef, AssetId, BookId, EnvelopeId, OverflowError, PostingId, SelectionError,
     ValidationError,
 };
 use kuatia_storage::error::StoreError;
@@ -23,11 +23,11 @@ pub enum LedgerError {
     /// The posting cannot be reversed (e.g. already consumed).
     PostingNotReversible(PostingId),
     /// The referenced account does not exist.
-    AccountNotFound(AccountId),
+    AccountNotFound(AccountRef),
     /// Cannot close an account that still has active postings.
-    AccountNotEmpty(AccountId),
+    AccountNotEmpty(AccountRef),
     /// The account is already closed.
-    AccountAlreadyClosed(AccountId),
+    AccountAlreadyClosed(AccountRef),
     /// A transfer named a book that does not exist.
     BookNotFound(BookId),
     /// The referenced inflight transaction does not exist (no authorize record).
@@ -37,16 +37,16 @@ pub enum LedgerError {
     NotInflightTransaction(EnvelopeId),
     /// The destination already has an open inflight hold; only one is allowed at
     /// a time per account.
-    InflightAlreadyOpen(AccountId),
+    InflightAlreadyOpen(AccountRef),
     /// The inflight transaction has no leg matching this destination and asset.
     InflightLegNotFound {
         /// The destination account with no matching leg.
-        destination: AccountId,
+        destination: AccountRef,
         /// The asset with no matching leg.
         asset: AssetId,
     },
     /// An inflight movement must move between two distinct accounts.
-    InflightSelfMovement(AccountId),
+    InflightSelfMovement(AccountRef),
     /// Monetary arithmetic overflow.
     Overflow,
     /// A saga step failed and its compensation also failed.

+ 104 - 58
crates/kuatia/src/inflight.rs

@@ -2,8 +2,9 @@
 //! later.
 //!
 //! An inflight transaction is an ordinary trade whose every destination is
-//! rewritten to a fresh per-destination holding account (`NoOverdraft`, flagged
-//! [`AccountFlags::INFLIGHT`]). Committing that rewritten transfer parks the
+//! rewritten to a per-destination holding subaccount (`NoOverdraft`, flagged
+//! [`AccountFlags::INFLIGHT`], keyed by a subaccount derived from the trade).
+//! Committing that rewritten transfer parks the
 //! funds. Confirm and void are ordinary commits that move a hold's balance to
 //! its destination or back to its funder. Nothing new is stored: the authorize
 //! transfer's metadata carries the leg table, and every artifact is tagged with
@@ -15,8 +16,8 @@ use std::collections::{BTreeMap, BTreeSet};
 use std::sync::Arc;
 
 use kuatia_core::{
-    Account, AccountFlags, AccountId, AccountPolicy, AssetId, BookId, Cent, EnvelopeId, Metadata,
-    Receipt, SelectionError, Transfer, TransferBuilder,
+    Account, AccountFlags, AccountPolicy, AccountRef, AssetId, BookId, Cent, EnvelopeId, Metadata,
+    Receipt, SelectionError, Transfer, TransferBuilder, hash::double_sha256,
 };
 use kuatia_storage::error::StoreError;
 use kuatia_storage::store::EnvelopeRecord;
@@ -34,11 +35,11 @@ const K_INFLIGHT: &str = "inflight";
 #[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
 pub struct InflightLeg {
     /// Account the funds settle to on confirm.
-    pub destination: AccountId,
+    pub destination: AccountRef,
     /// Per-destination holding account parking the funds.
-    pub hold: AccountId,
+    pub hold: AccountRef,
     /// Account that funded this leg (the funds return here on void).
-    pub funder: AccountId,
+    pub funder: AccountRef,
     /// Asset being held.
     pub asset: AssetId,
     /// Amount authorized for this leg.
@@ -76,9 +77,9 @@ pub enum InflightState {
 #[derive(Debug, Clone, Copy, PartialEq, Eq)]
 pub struct InflightLegStatus {
     /// Destination account.
-    pub destination: AccountId,
+    pub destination: AccountRef,
     /// Holding account.
-    pub hold: AccountId,
+    pub hold: AccountRef,
     /// Asset.
     pub asset: AssetId,
     /// Amount originally authorized.
@@ -113,17 +114,17 @@ pub struct InflightStatus {
 enum InflightMeta {
     /// Tags the authorize transfer and carries its leg table.
     Authorize { legs: Vec<InflightLeg> },
-    /// Tags a per-destination holding account.
-    Hold { destination: AccountId },
+    /// Tags a per-destination holding subaccount.
+    Hold { destination: AccountRef },
     /// Tags a settling transfer that delivers to a destination.
     Confirm {
         tx: EnvelopeId,
-        destination: AccountId,
+        destination: AccountRef,
     },
     /// Tags a settling transfer that returns to a funder.
     Void {
         tx: EnvelopeId,
-        destination: AccountId,
+        destination: AccountRef,
     },
 }
 
@@ -170,34 +171,49 @@ impl Ledger {
     /// [`confirm_all`](Self::confirm_all), [`confirm`](Self::confirm), and
     /// [`void`](Self::void).
     ///
-    /// Every movement must move between two distinct accounts. A destination
-    /// that already has an open inflight hold is rejected.
+    /// Every movement must move between two distinct accounts. All holds share a
+    /// subaccount derived from the trade, so re-authorizing the identical trade is
+    /// rejected (its holds already exist), while different trades to the same
+    /// destination run concurrently under distinct subaccounts.
     pub async fn authorize(
         self: &Arc<Self>,
         transfer: Transfer,
     ) -> Result<Authorization, LedgerError> {
-        // One holding account per distinct destination.
-        let mut dest_to_hold: BTreeMap<AccountId, AccountId> = BTreeMap::new();
+        // All holds of this inflight share one subaccount, derived from the
+        // submitted trade so it is stable and known before the holds are created
+        // (the authorize transfer's own id cannot be used: it is a hash of the
+        // envelope that pays into the holds).
+        let sub = inflight_subaccount(&transfer);
+
+        // One holding subaccount per distinct destination: (destination, sub).
+        let mut dest_to_hold: BTreeMap<AccountRef, AccountRef> = BTreeMap::new();
         for m in &transfer.movements {
             if m.from == m.to {
                 return Err(LedgerError::InflightSelfMovement(m.from));
             }
-            dest_to_hold.entry(m.to).or_default();
+            dest_to_hold
+                .entry(m.to)
+                .or_insert_with(|| AccountRef::with_sub(m.to.account, sub));
         }
 
-        // Enforce one open inflight per destination, then create the holds.
+        // Create the holds. An existing (destination, sub) entity means this exact
+        // trade is already inflight, so different trades (different subs) can hold
+        // against the same destination at once.
         for (dest, hold) in &dest_to_hold {
-            if self.open_inflight_hold_for(dest).await?.is_some() {
-                return Err(LedgerError::InflightAlreadyOpen(*dest));
-            }
-            let mut acct = Account::new(*hold, AccountPolicy::NoOverdraft);
+            let mut acct = Account::new_ref(*hold, AccountPolicy::NoOverdraft);
             acct.flags = AccountFlags::INFLIGHT;
             acct.book = transfer.book;
             acct.metadata = meta_map(&InflightMeta::Hold { destination: *dest })?;
-            self.create_account(acct).await?;
+            match self.create_account(acct).await {
+                Ok(()) => {}
+                Err(LedgerError::Store(StoreError::AlreadyExists(_))) => {
+                    return Err(LedgerError::InflightAlreadyOpen(*hold));
+                }
+                Err(e) => return Err(e),
+            }
         }
 
-        // Rewrite each movement to its hold and record the leg table.
+        // Rewrite each movement funder -> hold and record the leg table.
         let mut legs = Vec::with_capacity(transfer.movements.len());
         let mut builder = TransferBuilder::new()
             .book(transfer.book)
@@ -211,7 +227,7 @@ impl Ledger {
                 asset: m.asset,
                 amount: m.amount,
             });
-            builder = builder.movement(m.from, hold, m.asset, m.amount);
+            builder = builder.movement_ref(m.from, hold, m.asset, m.amount);
         }
         let mut md = transfer.metadata.clone();
         md.insert(
@@ -287,7 +303,7 @@ impl Ledger {
         let (record, legs) = self.load_inflight(inflight).await?;
         let book = record.envelope.book();
         let mut receipts = Vec::new();
-        let mut touched: BTreeSet<AccountId> = BTreeSet::new();
+        let mut touched: BTreeSet<AccountRef> = BTreeSet::new();
         for m in &confirms.movements {
             let leg = legs
                 .iter()
@@ -344,7 +360,7 @@ impl Ledger {
                 // Return to funders in leg order, each up to what it funded. For
                 // the common single-funder-per-(hold, asset) case this returns the
                 // whole remaining balance to that funder.
-                let mut funders: Vec<(AccountId, Cent)> = legs
+                let mut funders: Vec<(AccountRef, Cent)> = legs
                     .iter()
                     .filter(|l| l.hold == hold && l.asset == asset)
                     .map(|l| (l.funder, l.amount))
@@ -395,15 +411,15 @@ impl Ledger {
         let (_record, legs) = self.load_inflight(inflight).await?;
 
         // Authorized per (hold, asset).
-        let mut authorized: BTreeMap<(AccountId, AssetId), Cent> = BTreeMap::new();
+        let mut authorized: BTreeMap<(AccountRef, AssetId), Cent> = BTreeMap::new();
         for l in &legs {
             let e = authorized.entry((l.hold, l.asset)).or_insert(Cent::ZERO);
             *e = e.checked_add(l.amount)?;
         }
 
         // Confirmed / voided per (hold, asset), summed from settle transfers.
-        let mut confirmed: BTreeMap<(AccountId, AssetId), Cent> = BTreeMap::new();
-        let mut voided: BTreeMap<(AccountId, AssetId), Cent> = BTreeMap::new();
+        let mut confirmed: BTreeMap<(AccountRef, AssetId), Cent> = BTreeMap::new();
+        let mut voided: BTreeMap<(AccountRef, AssetId), Cent> = BTreeMap::new();
         for hold in holds_of(&legs) {
             for record in self.history(&hold).await? {
                 let bucket = match read_meta(record.envelope.metadata()) {
@@ -449,7 +465,7 @@ impl Ledger {
 
     /// List the holding accounts of every currently open inflight (an
     /// `INFLIGHT`-flagged account that is not closed).
-    pub async fn list_open_inflights(&self) -> Result<Vec<AccountId>, LedgerError> {
+    pub async fn list_open_inflights(&self) -> Result<Vec<AccountRef>, LedgerError> {
         Ok(self
             .list_accounts()
             .await?
@@ -480,31 +496,15 @@ impl Ledger {
         Ok((record, legs))
     }
 
-    /// Find an open (not-closed) inflight holding account for `destination`.
-    async fn open_inflight_hold_for(
-        &self,
-        destination: &AccountId,
-    ) -> Result<Option<AccountId>, LedgerError> {
-        for a in self.list_accounts().await? {
-            if a.flags.contains(AccountFlags::INFLIGHT)
-                && !a.is_closed()
-                && matches!(read_meta(&a.metadata), Some(InflightMeta::Hold { destination: d }) if d == *destination)
-            {
-                return Ok(Some(a.id));
-            }
-        }
-        Ok(None)
-    }
-
     /// Commit a `hold -> target` settling transfer tagged with the inflight role.
     #[allow(clippy::too_many_arguments)]
     async fn settle(
         self: &Arc<Self>,
         book: BookId,
         inflight: EnvelopeId,
-        hold: AccountId,
-        target: AccountId,
-        destination: AccountId,
+        hold: AccountRef,
+        target: AccountRef,
+        destination: AccountRef,
         asset: AssetId,
         amount: Cent,
         role: SettleRole,
@@ -521,7 +521,7 @@ impl Ledger {
         };
         let tx = TransferBuilder::new()
             .book(book)
-            .pay(hold, target, asset, amount)
+            .pay_ref(hold, target, asset, amount)
             .metadata(meta_map(&meta)?)
             .build();
         self.commit(tx).await
@@ -529,10 +529,10 @@ impl Ledger {
 
     /// Close a holding account once it has no live (Active/PendingInactive)
     /// postings left. No-op if already closed or still holding funds.
-    async fn close_if_drained(&self, hold: &AccountId) -> Result<(), LedgerError> {
+    async fn close_if_drained(&self, hold: &AccountRef) -> Result<(), LedgerError> {
         let live = self
             .store()
-            .get_postings_by_account(hold, None, None)
+            .get_postings_by_account(&hold.account, Some(hold.sub), None, None)
             .await?
             .into_iter()
             .any(|p| p.status != PostingStatus::Inactive);
@@ -546,11 +546,26 @@ impl Ledger {
     }
 }
 
-fn holds_of(legs: &[InflightLeg]) -> BTreeSet<AccountId> {
+/// Derive the shared subaccount id for an inflight from the submitted trade.
+/// Deterministic and known before the holds are created (unlike the authorize
+/// transfer's id). The sign bit is masked so the id is always positive.
+fn inflight_subaccount(transfer: &Transfer) -> u64 {
+    let mut buf = Vec::new();
+    // Serialization of a fixed `Transfer` is deterministic; the encode cannot
+    // fail for these types, but on any error fall back to the empty buffer so
+    // the result stays deterministic rather than panicking.
+    let _ = ciborium::into_writer(transfer, &mut buf);
+    let hash = double_sha256(&buf);
+    let mut first = [0u8; 8];
+    first.copy_from_slice(&hash[..8]);
+    u64::from_be_bytes(first)
+}
+
+fn holds_of(legs: &[InflightLeg]) -> BTreeSet<AccountRef> {
     legs.iter().map(|l| l.hold).collect()
 }
 
-fn assets_of(legs: &[InflightLeg], hold: AccountId) -> BTreeSet<AssetId> {
+fn assets_of(legs: &[InflightLeg], hold: AccountRef) -> BTreeSet<AssetId> {
     legs.iter()
         .filter(|l| l.hold == hold)
         .map(|l| l.asset)
@@ -559,9 +574,9 @@ fn assets_of(legs: &[InflightLeg], hold: AccountId) -> BTreeSet<AssetId> {
 
 fn destination_of(
     legs: &[InflightLeg],
-    hold: AccountId,
+    hold: AccountRef,
     inflight: EnvelopeId,
-) -> Result<AccountId, LedgerError> {
+) -> Result<AccountRef, LedgerError> {
     legs.iter()
         .find(|l| l.hold == hold)
         .map(|l| l.destination)
@@ -592,3 +607,34 @@ fn overall_state(lines: &[InflightLegStatus]) -> InflightState {
         (false, _, false) => InflightState::Confirmed,
     }
 }
+
+#[cfg(test)]
+mod tests {
+    use super::*;
+    use kuatia_core::AccountId;
+
+    fn pay_trade(amount: i64) -> Transfer {
+        TransferBuilder::new()
+            .pay(
+                AccountId::new(1),
+                AccountId::new(2),
+                AssetId::new(1),
+                Cent::from(amount),
+            )
+            .build()
+    }
+
+    #[test]
+    fn inflight_subaccount_is_deterministic_and_trade_specific() {
+        let t1 = pay_trade(10);
+        // Same trade -> same subaccount, so re-authorizing collides with itself.
+        assert_eq!(inflight_subaccount(&t1), inflight_subaccount(&t1));
+        // A different trade -> a different subaccount, so it can run concurrently.
+        assert_ne!(
+            inflight_subaccount(&t1),
+            inflight_subaccount(&pay_trade(20))
+        );
+        // Never the main subaccount.
+        assert_ne!(inflight_subaccount(&t1), 0);
+    }
+}

+ 132 - 41
crates/kuatia/src/ledger.rs

@@ -7,9 +7,10 @@ use legend::{ExecutionResult, legend};
 use tracing::instrument;
 
 use kuatia_core::{
-    AccountId, AccountPolicy, AccountSnapshotId, AssetId, Book, Cent, DEFAULT_BOOK, Envelope,
-    EnvelopeBuilder, EnvelopeId, NewPosting, PlanInput, Posting, PostingId, PostingStatus, Receipt,
-    SelectionError, Transfer, account_snapshot_id, envelope_id, select_postings, validate_and_plan,
+    AccountId, AccountPolicy, AccountRef, AccountSnapshotId, AssetId, Book, Cent, DEFAULT_BOOK,
+    Envelope, EnvelopeBuilder, EnvelopeId, NewPosting, PlanInput, Posting, PostingId,
+    PostingStatus, Receipt, SelectionError, Transfer, account_snapshot_id, envelope_id,
+    select_postings, validate_and_plan,
 };
 
 use crate::error::LedgerError;
@@ -94,7 +95,7 @@ impl Ledger {
             self.store.get_postings(envelope.consumes()).await?
         };
 
-        let mut account_ids: Vec<AccountId> = envelope.creates().iter().map(|p| p.owner).collect();
+        let mut account_ids: Vec<AccountRef> = envelope.creates().iter().map(|p| p.owner).collect();
         for p in &consumed_postings {
             account_ids.push(p.owner);
         }
@@ -102,9 +103,10 @@ impl Ledger {
         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 accounts: HashMap<AccountRef, _> =
+            account_list.into_iter().map(|a| (a.id, a)).collect();
 
-        let mut balance_keys: Vec<(AccountId, AssetId)> = Vec::new();
+        let mut balance_keys: Vec<(AccountRef, AssetId)> = Vec::new();
         for p in &consumed_postings {
             balance_keys.push((p.owner, p.asset));
         }
@@ -167,7 +169,7 @@ impl Ledger {
     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();
+        let mut net_debits: HashMap<(AccountRef, AssetId), Cent> = HashMap::new();
 
         // Pass 1: output postings + debit aggregation
         for m in &transfer.movements {
@@ -189,7 +191,12 @@ impl Ledger {
             }
             let available = self
                 .store
-                .get_postings_by_account(account, Some(asset), Some(PostingStatus::Active))
+                .get_postings_by_account(
+                    &account.account,
+                    Some(account.sub),
+                    Some(asset),
+                    Some(PostingStatus::Active),
+                )
                 .await?;
             let total_positive = Cent::checked_sum(
                 available
@@ -291,7 +298,7 @@ impl Ledger {
         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();
+            let mut ids: Vec<AccountRef> = envelope.creates().iter().map(|p| p.owner).collect();
             ids.sort();
             ids.dedup();
             envelope.set_account_snapshots(self.resolve_snapshots(&ids).await?);
@@ -517,7 +524,7 @@ impl Ledger {
         }
 
         // Index both created and consumed owners.
-        let mut involved: Vec<AccountId> = created.iter().map(|p| p.owner).collect();
+        let mut involved: Vec<AccountRef> = created.iter().map(|p| p.owner).collect();
         involved.extend(consumed.iter().map(|p| p.owner));
         involved.sort();
         involved.dedup();
@@ -648,12 +655,12 @@ impl Ledger {
     /// Compute balance from non-Inactive postings for an account/asset pair.
     async fn compute_balance(
         &self,
-        account: &AccountId,
+        account: &AccountRef,
         asset: &AssetId,
     ) -> Result<Cent, LedgerError> {
         let postings = self
             .store
-            .get_postings_by_account(account, Some(asset), None)
+            .get_postings_by_account(&account.account, Some(account.sub), Some(asset), None)
             .await?;
         Ok(Cent::checked_sum(
             postings
@@ -665,7 +672,7 @@ impl Ledger {
 
     async fn resolve_snapshots(
         &self,
-        ids: &[AccountId],
+        ids: &[AccountRef],
     ) -> Result<Vec<AccountSnapshotId>, LedgerError> {
         let accounts = self.store.get_accounts(ids).await?;
         Ok(accounts.iter().map(account_snapshot_id).collect())
@@ -677,7 +684,7 @@ impl Ledger {
 
     /// Freeze an account, preventing all transfers.
     #[instrument(skip(self), name = "ledger.freeze")]
-    pub async fn freeze(&self, id: &AccountId) -> Result<(), LedgerError> {
+    pub async fn freeze(&self, id: &AccountRef) -> Result<(), LedgerError> {
         let current = self
             .store
             .get_account(id)
@@ -702,7 +709,7 @@ impl Ledger {
 
     /// Unfreeze a previously frozen account.
     #[instrument(skip(self), name = "ledger.unfreeze")]
-    pub async fn unfreeze(&self, id: &AccountId) -> Result<(), LedgerError> {
+    pub async fn unfreeze(&self, id: &AccountRef) -> Result<(), LedgerError> {
         let current = self
             .store
             .get_account(id)
@@ -727,7 +734,7 @@ impl Ledger {
 
     /// Close an account. Must have no active postings.
     #[instrument(skip(self), name = "ledger.close")]
-    pub async fn close(&self, id: &AccountId) -> Result<(), LedgerError> {
+    pub async fn close(&self, id: &AccountRef) -> Result<(), LedgerError> {
         let current = self
             .store
             .get_account(id)
@@ -741,7 +748,7 @@ impl Ledger {
         // (or none) permit a close.
         let blocking = self
             .store
-            .get_postings_by_account(id, None, None)
+            .get_postings_by_account(&id.account, Some(id.sub), None, None)
             .await?
             .into_iter()
             .any(|p| p.status != PostingStatus::Inactive);
@@ -763,12 +770,79 @@ impl Ledger {
         Ok(())
     }
 
-    /// Query the current balance of an account for a given asset.
+    /// Balance of a single subaccount for a given asset. Names one subaccount, so
+    /// it is already segregated; for the account-wide, per-subaccount view use
+    /// [`balances`](Self::balances).
     #[instrument(skip(self), name = "ledger.balance")]
-    pub async fn balance(&self, account: &AccountId, asset: &AssetId) -> Result<Cent, LedgerError> {
+    pub async fn balance(
+        &self,
+        account: &AccountRef,
+        asset: &AssetId,
+    ) -> Result<Cent, LedgerError> {
         self.compute_balance(account, asset).await
     }
 
+    /// Balances of a base account for a given asset, **always segregated by
+    /// subaccount** — one entry per non-closed subaccount, never a summed total.
+    /// `sub == None` spans every subaccount (the main plus every open hold);
+    /// `Some(s)` restricts to that one subaccount. Closed subaccounts are excluded.
+    #[instrument(skip(self), name = "ledger.balances")]
+    pub async fn balances(
+        &self,
+        account: &AccountId,
+        asset: &AssetId,
+        sub: Option<u64>,
+    ) -> Result<Vec<SubAccountBalance>, LedgerError> {
+        // Non-closed subaccounts of this base account (seeded at zero so a
+        // subaccount with no posting in this asset still reports segregated).
+        let mut by_sub: std::collections::BTreeMap<u64, Cent> = self
+            .store
+            .list_accounts()
+            .await?
+            .into_iter()
+            .filter(|a| {
+                a.id.account == *account
+                    && !a.is_closed()
+                    && sub.is_none_or(|want| a.id.sub == want)
+            })
+            .map(|a| (a.id.sub, Cent::ZERO))
+            .collect();
+
+        let postings = self
+            .store
+            .get_postings_by_account(account, sub, Some(asset), None)
+            .await?;
+        for p in postings {
+            if p.status == PostingStatus::Inactive {
+                continue;
+            }
+            if let Some(bal) = by_sub.get_mut(&p.owner.sub) {
+                *bal = bal.checked_add(p.value)?;
+            }
+        }
+
+        Ok(by_sub
+            .into_iter()
+            .map(|(sub, balance)| SubAccountBalance { sub, balance })
+            .collect())
+    }
+
+    /// List the non-closed subaccounts of a base account.
+    #[instrument(skip(self), name = "ledger.list_subaccounts")]
+    pub async fn list_subaccounts(
+        &self,
+        account: &AccountId,
+    ) -> Result<Vec<AccountRef>, LedgerError> {
+        Ok(self
+            .store
+            .list_accounts()
+            .await?
+            .into_iter()
+            .filter(|a| a.id.account == *account && !a.is_closed())
+            .map(|a| a.id)
+            .collect())
+    }
+
     // -----------------------------------------------------------------------
     // Query layer
     // -----------------------------------------------------------------------
@@ -779,7 +853,7 @@ impl Ledger {
     }
 
     /// Fetch a single account by id.
-    pub async fn get_account(&self, id: &AccountId) -> Result<kuatia_core::Account, LedgerError> {
+    pub async fn get_account(&self, id: &AccountRef) -> Result<kuatia_core::Account, LedgerError> {
         self.store
             .get_account(id)
             .await
@@ -789,9 +863,12 @@ impl Ledger {
     /// Return all transfers involving the given account.
     pub async fn history(
         &self,
-        account: &AccountId,
+        account: &AccountRef,
     ) -> Result<Vec<crate::store::EnvelopeRecord>, LedgerError> {
-        Ok(self.store.get_transfers_for_account(account).await?)
+        Ok(self
+            .store
+            .get_transfers_for_account(&account.account, Some(account.sub))
+            .await?)
     }
 
     /// Query transfers with filtering and pagination.
@@ -805,11 +882,11 @@ impl Ledger {
     /// Return all postings (any status) for the given account.
     pub async fn postings(
         &self,
-        account: &AccountId,
+        account: &AccountRef,
     ) -> Result<Vec<kuatia_core::Posting>, LedgerError> {
         Ok(self
             .store
-            .get_postings_by_account(account, None, None)
+            .get_postings_by_account(&account.account, Some(account.sub), None, None)
             .await?)
     }
 
@@ -824,7 +901,7 @@ impl Ledger {
     /// Return the full version history for an account.
     pub async fn account_history(
         &self,
-        id: &AccountId,
+        id: &AccountRef,
     ) -> Result<Vec<kuatia_core::Account>, LedgerError> {
         Ok(self.store.get_account_history(id).await?)
     }
@@ -871,14 +948,24 @@ impl Ledger {
     }
 }
 
+/// One subaccount's balance for an asset. Balances are always returned
+/// segregated per subaccount, never summed across them.
+#[derive(Debug, Clone, Copy, PartialEq, Eq)]
+pub struct SubAccountBalance {
+    /// The subaccount id (`0` = the main subaccount).
+    pub sub: u64,
+    /// The balance held in that subaccount for the queried asset.
+    pub balance: Cent,
+}
+
 /// 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>,
+    /// Accounts (subaccounts) referenced by the envelope.
+    pub accounts: HashMap<AccountRef, kuatia_core::Account>,
+    /// Current balances for all referenced (account reference, asset) pairs.
+    pub balances: HashMap<(AccountRef, AssetId), Cent>,
     /// The book gating this transfer, if one is loaded (`None` = unrestricted default).
     pub book: Option<Book>,
 }
@@ -892,7 +979,7 @@ mod recovery_tests {
 
     fn acct(id: i64, policy: AccountPolicy) -> Account {
         Account {
-            id: AccountId::new(id),
+            id: AccountRef::main(AccountId::new(id)),
             version: 1,
             policy,
             flags: AccountFlags::empty(),
@@ -963,14 +1050,14 @@ mod recovery_tests {
         assert_eq!(ledger.recover().await.unwrap(), 1);
         assert_eq!(
             ledger
-                .balance(&AccountId::new(2), &AssetId::new(1))
+                .balance(&AccountRef::main(AccountId::new(2)), &AssetId::new(1))
                 .await
                 .unwrap(),
             Cent::from(40)
         );
         assert_eq!(
             ledger
-                .balance(&AccountId::new(1), &AssetId::new(1))
+                .balance(&AccountRef::main(AccountId::new(1)), &AssetId::new(1))
                 .await
                 .unwrap(),
             Cent::from(60)
@@ -1012,14 +1099,14 @@ mod recovery_tests {
         assert_eq!(ledger.recover().await.unwrap(), 1);
         assert_eq!(
             ledger
-                .balance(&AccountId::new(2), &AssetId::new(1))
+                .balance(&AccountRef::main(AccountId::new(2)), &AssetId::new(1))
                 .await
                 .unwrap(),
             Cent::from(40)
         );
         assert_eq!(
             ledger
-                .balance(&AccountId::new(1), &AssetId::new(1))
+                .balance(&AccountRef::main(AccountId::new(1)), &AssetId::new(1))
                 .await
                 .unwrap(),
             Cent::from(60)
@@ -1077,7 +1164,7 @@ mod recovery_tests {
             .collect();
         ledger.store().insert_postings(&created).await.unwrap();
         let consumed = ledger.store().get_postings(&consumes).await.unwrap();
-        let mut involved: Vec<AccountId> = created.iter().map(|p| p.owner).collect();
+        let mut involved: Vec<AccountRef> = created.iter().map(|p| p.owner).collect();
         involved.extend(consumed.iter().map(|p| p.owner));
         involved.sort();
         involved.dedup();
@@ -1133,21 +1220,24 @@ mod recovery_tests {
         save_pending(&ledger, &envelope, rid, SagaPhase::Reserving).await;
 
         // A freeze lands before recovery runs.
-        ledger.freeze(&AccountId::new(1)).await.unwrap();
+        ledger
+            .freeze(&AccountRef::main(AccountId::new(1)))
+            .await
+            .unwrap();
 
         assert_eq!(ledger.recover().await.unwrap(), 1);
         // Nothing committed; balances unchanged; reservation released.
         assert!(ledger.store().get_transfer(&tid).await.unwrap().is_none());
         assert_eq!(
             ledger
-                .balance(&AccountId::new(1), &AssetId::new(1))
+                .balance(&AccountRef::main(AccountId::new(1)), &AssetId::new(1))
                 .await
                 .unwrap(),
             Cent::from(100)
         );
         assert_eq!(
             ledger
-                .balance(&AccountId::new(2), &AssetId::new(1))
+                .balance(&AccountRef::main(AccountId::new(2)), &AssetId::new(1))
                 .await
                 .unwrap(),
             Cent::ZERO
@@ -1156,6 +1246,7 @@ mod recovery_tests {
             .store()
             .get_postings_by_account(
                 &AccountId::new(1),
+                None,
                 Some(&AssetId::new(1)),
                 Some(PostingStatus::Active),
             )
@@ -1199,21 +1290,21 @@ mod recovery_tests {
         assert!(ledger.store().get_transfer(&tid).await.unwrap().is_none());
         assert_eq!(
             ledger
-                .balance(&AccountId::new(1), &AssetId::new(1))
+                .balance(&AccountRef::main(AccountId::new(1)), &AssetId::new(1))
                 .await
                 .unwrap(),
             Cent::from(50)
         );
         assert_eq!(
             ledger
-                .balance(&AccountId::new(3), &AssetId::new(1))
+                .balance(&AccountRef::main(AccountId::new(3)), &AssetId::new(1))
                 .await
                 .unwrap(),
             Cent::from(50)
         );
         assert_eq!(
             ledger
-                .balance(&AccountId::new(2), &AssetId::new(1))
+                .balance(&AccountRef::main(AccountId::new(2)), &AssetId::new(1))
                 .await
                 .unwrap(),
             Cent::ZERO

+ 61 - 15
crates/kuatia/tests/concurrency.rs

@@ -33,7 +33,7 @@ fn external() -> AccountId {
 
 fn make_account(id: i64, policy: AccountPolicy) -> Account {
     Account {
-        id: AccountId::new(id),
+        id: AccountRef::main(AccountId::new(id)),
         version: 1,
         policy,
         flags: AccountFlags::empty(),
@@ -109,13 +109,19 @@ async fn concurrent_double_spend_has_one_winner() {
 
     // Conservation: payer drained, exactly one recipient credited, total = 100.
     assert_eq!(
-        ledger.balance(&account(1), &usd()).await.unwrap(),
+        ledger
+            .balance(&AccountRef::main(account(1)), &usd())
+            .await
+            .unwrap(),
         Cent::ZERO
     );
     let mut credited = 0;
     let mut total = Cent::ZERO;
     for recipient in 2..=(1 + RECIPIENTS) {
-        let bal = ledger.balance(&account(recipient), &usd()).await.unwrap();
+        let bal = ledger
+            .balance(&AccountRef::main(account(recipient)), &usd())
+            .await
+            .unwrap();
         if bal != Cent::ZERO {
             credited += 1;
             assert_eq!(bal, Cent::from(100));
@@ -148,11 +154,17 @@ async fn recommit_same_envelope_is_idempotent() {
 
     assert_eq!(first, second, "replay returns the original receipt");
     assert_eq!(
-        ledger.balance(&account(1), &usd()).await.unwrap(),
+        ledger
+            .balance(&AccountRef::main(account(1)), &usd())
+            .await
+            .unwrap(),
         Cent::from(50)
     );
     assert_eq!(
-        ledger.balance(&account(2), &usd()).await.unwrap(),
+        ledger
+            .balance(&AccountRef::main(account(2)), &usd())
+            .await
+            .unwrap(),
         Cent::from(50)
     );
 }
@@ -198,11 +210,17 @@ async fn concurrent_identical_commits_move_value_once() {
 
     // Value moved exactly once, and exactly one transfer is stored.
     assert_eq!(
-        ledger.balance(&account(1), &usd()).await.unwrap(),
+        ledger
+            .balance(&AccountRef::main(account(1)), &usd())
+            .await
+            .unwrap(),
         Cent::from(50)
     );
     assert_eq!(
-        ledger.balance(&account(2), &usd()).await.unwrap(),
+        ledger
+            .balance(&AccountRef::main(account(2)), &usd())
+            .await
+            .unwrap(),
         Cent::from(50)
     );
     assert!(
@@ -237,7 +255,7 @@ async fn freeze_during_commit_stays_consistent() {
 
         let freezer = {
             let ledger = Arc::clone(&ledger);
-            tokio::spawn(async move { ledger.freeze(&account(1)).await })
+            tokio::spawn(async move { ledger.freeze(&AccountRef::main(account(1))).await })
         };
         let payer = {
             let ledger = Arc::clone(&ledger);
@@ -251,8 +269,14 @@ async fn freeze_during_commit_stays_consistent() {
         freezer.await.unwrap().expect("freeze always succeeds");
         let paid = payer.await.unwrap().is_ok();
 
-        let b1 = ledger.balance(&account(1), &usd()).await.unwrap();
-        let b2 = ledger.balance(&account(2), &usd()).await.unwrap();
+        let b1 = ledger
+            .balance(&AccountRef::main(account(1)), &usd())
+            .await
+            .unwrap();
+        let b2 = ledger
+            .balance(&AccountRef::main(account(2)), &usd())
+            .await
+            .unwrap();
 
         // Conservation and all-or-nothing, keyed on whether the pay committed.
         assert_eq!(
@@ -269,7 +293,13 @@ async fn freeze_during_commit_stays_consistent() {
         }
 
         // The account is frozen either way; no further payment may leave it.
-        assert!(ledger.get_account(&account(1)).await.unwrap().is_frozen());
+        assert!(
+            ledger
+                .get_account(&AccountRef::main(account(1)))
+                .await
+                .unwrap()
+                .is_frozen()
+        );
         let after = TransferBuilder::new()
             .pay(account(1), account(2), usd(), Cent::from(10))
             .build();
@@ -318,7 +348,10 @@ async fn disjoint_transfers_all_commit_and_conserve() {
 
     let mut total = Cent::ZERO;
     for id in 1..=(2 * PAIRS) {
-        let bal = ledger.balance(&account(id), &usd()).await.unwrap();
+        let bal = ledger
+            .balance(&AccountRef::main(account(id)), &usd())
+            .await
+            .unwrap();
         let expected = if id % 2 == 0 {
             Cent::from(100)
         } else {
@@ -386,10 +419,18 @@ async fn overdraft_floor_is_best_effort_under_concurrency() {
             let _ = h.await.unwrap();
         }
 
-        let mut total = ledger.balance(&account(1), &usd()).await.unwrap();
+        let mut total = ledger
+            .balance(&AccountRef::main(account(1)), &usd())
+            .await
+            .unwrap();
         for payee in 2..=(1 + PAYEES) {
             total = total
-                .checked_add(ledger.balance(&account(payee), &usd()).await.unwrap())
+                .checked_add(
+                    ledger
+                        .balance(&AccountRef::main(account(payee)), &usd())
+                        .await
+                        .unwrap(),
+                )
                 .unwrap();
         }
         assert_eq!(
@@ -397,7 +438,12 @@ async fn overdraft_floor_is_best_effort_under_concurrency() {
             Cent::ZERO,
             "value is conserved even when the floor is breached"
         );
-        if ledger.balance(&account(1), &usd()).await.unwrap() < floor {
+        if ledger
+            .balance(&AccountRef::main(account(1)), &usd())
+            .await
+            .unwrap()
+            < floor
+        {
             observed_breach = true;
         }
     }

+ 52 - 11
crates/kuatia/tests/inflight.rs

@@ -39,7 +39,7 @@ fn ext() -> AccountId {
 
 fn make_account(id: i64, policy: AccountPolicy) -> Account {
     Account {
-        id: AccountId::new(id),
+        id: AccountRef::main(AccountId::new(id)),
         version: 1,
         policy,
         flags: AccountFlags::empty(),
@@ -90,7 +90,10 @@ fn trade() -> Transfer {
 }
 
 async fn bal(ledger: &Arc<Ledger>, account: AccountId, asset: AssetId) -> Cent {
-    ledger.balance(&account, &asset).await.unwrap()
+    ledger
+        .balance(&AccountRef::main(account), &asset)
+        .await
+        .unwrap()
 }
 
 /// A one-movement confirm set, built with the same `.pay()` interface as a
@@ -187,7 +190,7 @@ async fn partial_confirm_then_confirm_remainder() {
     let leg = status
         .legs
         .iter()
-        .find(|l| l.destination == b() && l.asset == eur())
+        .find(|l| l.destination.account == b() && l.asset == eur())
         .unwrap();
     assert_eq!(leg.authorized, Cent::from(100));
     assert_eq!(leg.confirmed, Cent::from(40));
@@ -233,7 +236,7 @@ async fn partial_confirm_then_void_remainder() {
     let leg = status
         .legs
         .iter()
-        .find(|l| l.destination == b() && l.asset == eur())
+        .find(|l| l.destination.account == b() && l.asset == eur())
         .unwrap();
     assert_eq!(leg.confirmed, Cent::from(40));
     assert_eq!(leg.voided, Cent::from(60));
@@ -297,19 +300,36 @@ async fn confirm_unknown_leg_is_rejected() {
     assert!(matches!(err, LedgerError::InflightLegNotFound { .. }));
 }
 
-/// Only one open inflight is allowed per destination account at a time.
+/// A destination can hold several concurrent inflights (one per distinct trade,
+/// each under its own subaccount), but the *same* trade cannot be authorized
+/// twice while open (its holds already exist).
 #[tokio::test]
-async fn one_open_inflight_per_account() {
+async fn concurrent_inflights_per_account() {
     let ledger = setup().await;
-    let _auth = ledger.authorize(trade()).await.unwrap();
+    let auth = ledger.authorize(trade()).await.unwrap();
+
+    // Re-authorizing the identical trade collides on the derived hold subaccount.
+    let err = ledger.authorize(trade()).await.unwrap_err();
+    assert!(matches!(err, LedgerError::InflightAlreadyOpen(_)));
 
-    // A second authorize touching B (an open destination) is rejected.
+    // A different trade to the same destination B opens a second, independent
+    // inflight under a different subaccount.
     deposit(&ledger, a(), eur(), 10).await;
-    let again = TransferBuilder::new()
+    let other = TransferBuilder::new()
         .pay(a(), b(), eur(), Cent::from(10))
         .build();
-    let err = ledger.authorize(again).await.unwrap_err();
-    assert!(matches!(err, LedgerError::InflightAlreadyOpen(id) if id == b()));
+    let auth2 = ledger.authorize(other).await.unwrap();
+    assert_ne!(auth.inflight, auth2.inflight);
+
+    // Both are open at once: B has two inflight holds under distinct subaccounts.
+    let b_holds = ledger
+        .list_subaccounts(&b())
+        .await
+        .unwrap()
+        .into_iter()
+        .filter(|r| r.sub != 0)
+        .count();
+    assert_eq!(b_holds, 2);
 }
 
 /// After a full confirm closes the holds, a fresh inflight to the same
@@ -340,3 +360,24 @@ async fn unknown_inflight_is_an_error() {
         LedgerError::InflightNotFound(_)
     ));
 }
+
+/// Balances are always segregated by subaccount: the account query lists the
+/// main subaccount and each open hold separately, never summed.
+#[tokio::test]
+async fn balances_are_segregated_by_subaccount() {
+    let ledger = setup().await;
+    let _auth = ledger.authorize(trade()).await.unwrap();
+
+    // B's EUR across subaccounts: the main (0, now empty) and its inflight hold.
+    let all = ledger.balances(&b(), &eur(), None).await.unwrap();
+    let main = all.iter().find(|e| e.sub == 0).unwrap();
+    assert_eq!(main.balance, Cent::ZERO); // B's own 1 EUR went into the fee hold
+    let hold = all.iter().find(|e| e.sub != 0).unwrap();
+    assert_eq!(hold.balance, Cent::from(100)); // A's 100 EUR parked for B
+    assert_eq!(all.len(), 2);
+
+    // Filtering to the main subaccount returns only it (still segregated form).
+    let only_main = ledger.balances(&b(), &eur(), Some(0)).await.unwrap();
+    assert_eq!(only_main.len(), 1);
+    assert_eq!(only_main[0].sub, 0);
+}

+ 230 - 76
crates/kuatia/tests/integration.rs

@@ -25,7 +25,7 @@ fn external() -> AccountId {
 
 fn make_account(id: i64, policy: AccountPolicy) -> Account {
     Account {
-        id: AccountId::new(id),
+        id: AccountRef::main(AccountId::new(id)),
         version: 1,
         policy,
         flags: AccountFlags::empty(),
@@ -115,11 +115,17 @@ async fn deposit_creates_balanced_postings() {
     deposit(&ledger, account(1), usd(), Cent::from(100), external()).await;
 
     assert_eq!(
-        ledger.balance(&account(1), &usd()).await.unwrap(),
+        ledger
+            .balance(&AccountRef::main(account(1)), &usd())
+            .await
+            .unwrap(),
         Cent::from(100)
     );
     assert_eq!(
-        ledger.balance(&external(), &usd()).await.unwrap(),
+        ledger
+            .balance(&AccountRef::main(external()), &usd())
+            .await
+            .unwrap(),
         Cent::from(-100)
     );
 }
@@ -136,15 +142,24 @@ async fn pay_with_change() {
     pay(&ledger, account(1), account(2), usd(), Cent::from(50)).await;
 
     assert_eq!(
-        ledger.balance(&account(1), &usd()).await.unwrap(),
+        ledger
+            .balance(&AccountRef::main(account(1)), &usd())
+            .await
+            .unwrap(),
         Cent::from(50)
     );
     assert_eq!(
-        ledger.balance(&account(2), &usd()).await.unwrap(),
+        ledger
+            .balance(&AccountRef::main(account(2)), &usd())
+            .await
+            .unwrap(),
         Cent::from(50)
     );
     assert_eq!(
-        ledger.balance(&external(), &usd()).await.unwrap(),
+        ledger
+            .balance(&AccountRef::main(external()), &usd())
+            .await
+            .unwrap(),
         Cent::from(-100)
     );
 }
@@ -162,19 +177,31 @@ async fn multi_hop_transfer() {
     pay(&ledger, account(2), account(3), usd(), Cent::from(20)).await;
 
     assert_eq!(
-        ledger.balance(&account(1), &usd()).await.unwrap(),
+        ledger
+            .balance(&AccountRef::main(account(1)), &usd())
+            .await
+            .unwrap(),
         Cent::from(50)
     );
     assert_eq!(
-        ledger.balance(&account(2), &usd()).await.unwrap(),
+        ledger
+            .balance(&AccountRef::main(account(2)), &usd())
+            .await
+            .unwrap(),
         Cent::from(30)
     );
     assert_eq!(
-        ledger.balance(&account(3), &usd()).await.unwrap(),
+        ledger
+            .balance(&AccountRef::main(account(3)), &usd())
+            .await
+            .unwrap(),
         Cent::from(20)
     );
     assert_eq!(
-        ledger.balance(&external(), &usd()).await.unwrap(),
+        ledger
+            .balance(&AccountRef::main(external()), &usd())
+            .await
+            .unwrap(),
         Cent::from(-100)
     );
 }
@@ -191,11 +218,17 @@ async fn withdrawal_reduces_external_liability() {
     withdraw(&ledger, account(1), usd(), Cent::from(50), external()).await;
 
     assert_eq!(
-        ledger.balance(&account(1), &usd()).await.unwrap(),
+        ledger
+            .balance(&AccountRef::main(account(1)), &usd())
+            .await
+            .unwrap(),
         Cent::from(50)
     );
     assert_eq!(
-        ledger.balance(&external(), &usd()).await.unwrap(),
+        ledger
+            .balance(&AccountRef::main(external()), &usd())
+            .await
+            .unwrap(),
         Cent::from(-50)
     );
 }
@@ -214,15 +247,24 @@ async fn full_round_trip() {
     withdraw(&ledger, account(1), usd(), Cent::from(40), external()).await;
 
     assert_eq!(
-        ledger.balance(&account(1), &usd()).await.unwrap(),
+        ledger
+            .balance(&AccountRef::main(account(1)), &usd())
+            .await
+            .unwrap(),
         Cent::ZERO
     );
     assert_eq!(
-        ledger.balance(&account(2), &usd()).await.unwrap(),
+        ledger
+            .balance(&AccountRef::main(account(2)), &usd())
+            .await
+            .unwrap(),
         Cent::ZERO
     );
     assert_eq!(
-        ledger.balance(&external(), &usd()).await.unwrap(),
+        ledger
+            .balance(&AccountRef::main(external()), &usd())
+            .await
+            .unwrap(),
         Cent::ZERO
     );
 }
@@ -238,13 +280,13 @@ async fn idempotent_commit() {
     let envelope = EnvelopeBuilder::new()
         .creates(vec![
             NewPosting {
-                owner: account(1),
+                owner: AccountRef::main(account(1)),
                 asset: usd(),
                 value: Cent::from(100),
                 payer: None,
             },
             NewPosting {
-                owner: external(),
+                owner: AccountRef::main(external()),
                 asset: usd(),
                 value: Cent::from(-100),
                 payer: None,
@@ -258,7 +300,10 @@ async fn idempotent_commit() {
     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(),
+        ledger
+            .balance(&AccountRef::main(account(1)), &usd())
+            .await
+            .unwrap(),
         Cent::from(100)
     );
 }
@@ -280,7 +325,10 @@ async fn overdraft_rejected() {
     assert!(result.is_err());
     // Balance unchanged
     assert_eq!(
-        ledger.balance(&account(1), &usd()).await.unwrap(),
+        ledger
+            .balance(&AccountRef::main(account(1)), &usd())
+            .await
+            .unwrap(),
         Cent::from(50)
     );
 }
@@ -297,11 +345,17 @@ async fn reverse_restores_balances() {
     let pay_receipt = pay(&ledger, account(1), account(2), usd(), Cent::from(60)).await;
 
     assert_eq!(
-        ledger.balance(&account(1), &usd()).await.unwrap(),
+        ledger
+            .balance(&AccountRef::main(account(1)), &usd())
+            .await
+            .unwrap(),
         Cent::from(40)
     );
     assert_eq!(
-        ledger.balance(&account(2), &usd()).await.unwrap(),
+        ledger
+            .balance(&AccountRef::main(account(2)), &usd())
+            .await
+            .unwrap(),
         Cent::from(60)
     );
 
@@ -309,11 +363,17 @@ async fn reverse_restores_balances() {
     ledger.reverse(&pay_receipt.transfer_id).await.unwrap();
 
     assert_eq!(
-        ledger.balance(&account(1), &usd()).await.unwrap(),
+        ledger
+            .balance(&AccountRef::main(account(1)), &usd())
+            .await
+            .unwrap(),
         Cent::from(100)
     );
     assert_eq!(
-        ledger.balance(&account(2), &usd()).await.unwrap(),
+        ledger
+            .balance(&AccountRef::main(account(2)), &usd())
+            .await
+            .unwrap(),
         Cent::ZERO
     );
 }
@@ -356,26 +416,41 @@ async fn multi_asset_independent_balances() {
     deposit(&ledger, account(1), eur(), Cent::from(200), external()).await;
 
     assert_eq!(
-        ledger.balance(&account(1), &usd()).await.unwrap(),
+        ledger
+            .balance(&AccountRef::main(account(1)), &usd())
+            .await
+            .unwrap(),
         Cent::from(100)
     );
     assert_eq!(
-        ledger.balance(&account(1), &eur()).await.unwrap(),
+        ledger
+            .balance(&AccountRef::main(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(),
+        ledger
+            .balance(&AccountRef::main(account(1)), &usd())
+            .await
+            .unwrap(),
         Cent::from(70)
     );
     assert_eq!(
-        ledger.balance(&account(1), &eur()).await.unwrap(),
+        ledger
+            .balance(&AccountRef::main(account(1)), &eur())
+            .await
+            .unwrap(),
         Cent::from(200)
     );
     assert_eq!(
-        ledger.balance(&account(2), &usd()).await.unwrap(),
+        ledger
+            .balance(&AccountRef::main(account(2)), &usd())
+            .await
+            .unwrap(),
         Cent::from(30)
     );
 }
@@ -410,12 +485,17 @@ async fn fx_trade_via_market_account() {
     // 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))
+        .get_postings_by_account(&account(1), None, Some(&usd()), Some(PostingStatus::Active))
         .await
         .unwrap();
     let fx_eur_postings = ledger
         .store()
-        .get_postings_by_account(&account(50), Some(&eur()), Some(PostingStatus::Active))
+        .get_postings_by_account(
+            &account(50),
+            None,
+            Some(&eur()),
+            Some(PostingStatus::Active),
+        )
         .await
         .unwrap();
 
@@ -423,16 +503,16 @@ async fn fx_trade_via_market_account() {
         .consumes(vec![a1_usd_postings[0].id, fx_eur_postings[0].id])
         .creates(vec![
             NewPosting {
-                owner: account(50),
+                owner: AccountRef::main(account(50)),
                 asset: usd(),
                 value: Cent::from(100),
-                payer: Some(account(1)),
+                payer: Some(AccountRef::main(account(1))),
             },
             NewPosting {
-                owner: account(1),
+                owner: AccountRef::main(account(1)),
                 asset: eur(),
                 value: Cent::from(92),
-                payer: Some(account(50)),
+                payer: Some(AccountRef::main(account(50))),
             },
         ])
         .build();
@@ -441,19 +521,31 @@ async fn fx_trade_via_market_account() {
 
     // Verify
     assert_eq!(
-        ledger.balance(&account(1), &usd()).await.unwrap(),
+        ledger
+            .balance(&AccountRef::main(account(1)), &usd())
+            .await
+            .unwrap(),
         Cent::ZERO
     );
     assert_eq!(
-        ledger.balance(&account(1), &eur()).await.unwrap(),
+        ledger
+            .balance(&AccountRef::main(account(1)), &eur())
+            .await
+            .unwrap(),
         Cent::from(92)
     );
     assert_eq!(
-        ledger.balance(&account(50), &usd()).await.unwrap(),
+        ledger
+            .balance(&AccountRef::main(account(50)), &usd())
+            .await
+            .unwrap(),
         Cent::from(100)
     );
     assert_eq!(
-        ledger.balance(&account(50), &eur()).await.unwrap(),
+        ledger
+            .balance(&AccountRef::main(account(50)), &eur())
+            .await
+            .unwrap(),
         Cent::ZERO
     );
 }
@@ -467,7 +559,7 @@ 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();
+    ledger.freeze(&AccountRef::main(account(1))).await.unwrap();
 
     // Paying from a frozen account should fail
     let transfer = TransferBuilder::new()
@@ -477,7 +569,10 @@ async fn freeze_blocks_transfers() {
     assert!(result.is_err());
     // Balance unchanged
     assert_eq!(
-        ledger.balance(&account(1), &usd()).await.unwrap(),
+        ledger
+            .balance(&AccountRef::main(account(1)), &usd())
+            .await
+            .unwrap(),
         Cent::from(100)
     );
 }
@@ -487,13 +582,19 @@ 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();
+    ledger.freeze(&AccountRef::main(account(1))).await.unwrap();
+    ledger
+        .unfreeze(&AccountRef::main(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(),
+        ledger
+            .balance(&AccountRef::main(account(1)), &usd())
+            .await
+            .unwrap(),
         Cent::from(50)
     );
 }
@@ -503,7 +604,7 @@ 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();
+    ledger.close(&AccountRef::main(account(3))).await.unwrap();
 
     // Closed account rejects deposits
     let transfer = TransferBuilder::new()
@@ -521,11 +622,14 @@ async fn close_account_with_balance_rejected() {
     deposit(&ledger, account(1), usd(), Cent::from(100), external()).await;
 
     // Should fail -- account still has active postings
-    let result = ledger.close(&account(1)).await;
+    let result = ledger.close(&AccountRef::main(account(1))).await;
     assert!(result.is_err());
     // Balance unchanged
     assert_eq!(
-        ledger.balance(&account(1), &usd()).await.unwrap(),
+        ledger
+            .balance(&AccountRef::main(account(1)), &usd())
+            .await
+            .unwrap(),
         Cent::from(100)
     );
 }
@@ -539,7 +643,7 @@ async fn close_rejects_reserved_postings() {
     // Reserve the account's only posting (a transfer in flight): Active → PendingInactive.
     let postings = ledger
         .store()
-        .get_postings_by_account(&account(1), Some(&usd()), Some(PostingStatus::Active))
+        .get_postings_by_account(&account(1), None, Some(&usd()), Some(PostingStatus::Active))
         .await
         .unwrap();
     ledger
@@ -549,7 +653,7 @@ async fn close_rejects_reserved_postings() {
         .unwrap();
 
     // Close must reject: the posting is live (PendingInactive), not Inactive.
-    let result = ledger.close(&account(1)).await;
+    let result = ledger.close(&AccountRef::main(account(1))).await;
     assert!(result.is_err());
 }
 
@@ -557,9 +661,9 @@ async fn close_rejects_reserved_postings() {
 async fn freeze_closed_account_rejected() {
     let ledger = setup_ledger().await;
 
-    ledger.close(&account(3)).await.unwrap();
+    ledger.close(&AccountRef::main(account(3))).await.unwrap();
 
-    let result = ledger.freeze(&account(3)).await;
+    let result = ledger.freeze(&AccountRef::main(account(3))).await;
     assert!(result.is_err());
 }
 
@@ -575,11 +679,11 @@ async fn history_returns_transfers_for_account() {
     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();
+    let h1 = ledger.history(&AccountRef::main(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();
+    let h2 = ledger.history(&AccountRef::main(account(2))).await.unwrap();
     // account(2) was in the pay and a second deposit
     assert_eq!(h2.len(), 2);
 }
@@ -591,7 +695,10 @@ async fn postings_returns_all_postings() {
     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();
+    let posts = ledger
+        .postings(&AccountRef::main(account(1)))
+        .await
+        .unwrap();
     // Original 100 posting (now consumed) + 40 change posting (active)
     assert_eq!(posts.len(), 2);
 
@@ -613,8 +720,11 @@ async fn list_accounts_returns_all() {
 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));
+    let acc = ledger
+        .get_account(&AccountRef::main(account(1)))
+        .await
+        .unwrap();
+    assert_eq!(acc.id, AccountRef::main(account(1)));
     assert_eq!(acc.policy, AccountPolicy::NoOverdraft);
 }
 
@@ -622,7 +732,7 @@ async fn get_account_by_id() {
 async fn get_account_not_found() {
     let ledger = setup_ledger().await;
 
-    let result = ledger.get_account(&account(999)).await;
+    let result = ledger.get_account(&AccountRef::main(account(999))).await;
     assert!(result.is_err());
 }
 
@@ -635,20 +745,32 @@ async fn account_history_tracks_versions() {
     let ledger = setup_ledger().await;
 
     // Version 1: created
-    let history = ledger.account_history(&account(1)).await.unwrap();
+    let history = ledger
+        .account_history(&AccountRef::main(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();
+    ledger.freeze(&AccountRef::main(account(1))).await.unwrap();
+    let history = ledger
+        .account_history(&AccountRef::main(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();
+    ledger
+        .unfreeze(&AccountRef::main(account(1)))
+        .await
+        .unwrap();
+    let history = ledger
+        .account_history(&AccountRef::main(account(1)))
+        .await
+        .unwrap();
     assert_eq!(history.len(), 3);
     assert_eq!(history[2].version, 3);
     assert!(!history[2].is_frozen());
@@ -660,12 +782,18 @@ async fn store_never_compacts() {
 
     // Freeze and unfreeze multiple times
     for _ in 0..5 {
-        ledger.freeze(&account(1)).await.unwrap();
-        ledger.unfreeze(&account(1)).await.unwrap();
+        ledger.freeze(&AccountRef::main(account(1))).await.unwrap();
+        ledger
+            .unfreeze(&AccountRef::main(account(1)))
+            .await
+            .unwrap();
     }
 
     // All 11 versions preserved (1 creation + 10 mutations)
-    let history = ledger.account_history(&account(1)).await.unwrap();
+    let history = ledger
+        .account_history(&AccountRef::main(account(1)))
+        .await
+        .unwrap();
     assert_eq!(history.len(), 11);
     // Versions are monotonically increasing
     for (i, acc) in history.iter().enumerate() {
@@ -680,7 +808,7 @@ async fn transfer_records_account_snapshots() {
     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();
+    let transfers = ledger.history(&AccountRef::main(account(1))).await.unwrap();
     assert_eq!(transfers.len(), 1);
     assert!(!transfers[0].envelope.account_snapshots().is_empty());
 }
@@ -690,23 +818,26 @@ 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 acc1 = ledger
+        .get_account(&AccountRef::main(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();
+    ledger.freeze(&AccountRef::main(account(1))).await.unwrap();
 
     // Build an envelope with the stale snapshot
     let envelope = EnvelopeBuilder::new()
         .creates(vec![
             NewPosting {
-                owner: account(1),
+                owner: AccountRef::main(account(1)),
                 asset: usd(),
                 value: Cent::from(100),
                 payer: None,
             },
             NewPosting {
-                owner: external(),
+                owner: AccountRef::main(external()),
                 asset: usd(),
                 value: Cent::from(-100),
                 payer: None,
@@ -767,18 +898,29 @@ async fn capped_overdraft_creates_negative_posting() {
     pay(&ledger, account(10), account(2), usd(), Cent::from(100)).await;
 
     assert_eq!(
-        ledger.balance(&account(10), &usd()).await.unwrap(),
+        ledger
+            .balance(&AccountRef::main(account(10)), &usd())
+            .await
+            .unwrap(),
         Cent::from(-50)
     );
     assert_eq!(
-        ledger.balance(&account(2), &usd()).await.unwrap(),
+        ledger
+            .balance(&AccountRef::main(account(2)), &usd())
+            .await
+            .unwrap(),
         Cent::from(100)
     );
 
     // A negative posting now backs the overdraft.
     let postings = ledger
         .store()
-        .get_postings_by_account(&account(10), Some(&usd()), Some(PostingStatus::Active))
+        .get_postings_by_account(
+            &account(10),
+            None,
+            Some(&usd()),
+            Some(PostingStatus::Active),
+        )
         .await
         .unwrap();
     assert!(postings.iter().any(|p| p.value == Cent::from(-50)));
@@ -811,7 +953,10 @@ async fn capped_overdraft_respects_floor() {
         .build();
     assert!(ledger.commit(transfer).await.is_err());
     assert_eq!(
-        ledger.balance(&account(10), &usd()).await.unwrap(),
+        ledger
+            .balance(&AccountRef::main(account(10)), &usd())
+            .await
+            .unwrap(),
         Cent::ZERO
     );
 }
@@ -841,7 +986,10 @@ async fn uncapped_overdraft_allows_arbitrary_negative() {
     )
     .await;
     assert_eq!(
-        ledger.balance(&account(10), &usd()).await.unwrap(),
+        ledger
+            .balance(&AccountRef::main(account(10)), &usd())
+            .await
+            .unwrap(),
         Cent::from(-1_000_000)
     );
 }
@@ -869,7 +1017,10 @@ async fn book_policy_rejects_disallowed_asset() {
         .build();
     assert!(ledger.commit(transfer).await.is_err());
     assert_eq!(
-        ledger.balance(&account(1), &usd()).await.unwrap(),
+        ledger
+            .balance(&AccountRef::main(account(1)), &usd())
+            .await
+            .unwrap(),
         Cent::from(100)
     );
 }
@@ -885,7 +1036,10 @@ async fn transfer_in_missing_named_book_is_rejected() {
         .build();
     assert!(ledger.commit(transfer).await.is_err());
     assert_eq!(
-        ledger.balance(&account(1), &usd()).await.unwrap(),
+        ledger
+            .balance(&AccountRef::main(account(1)), &usd())
+            .await
+            .unwrap(),
         Cent::from(100)
     );
 }

+ 33 - 9
crates/kuatia/tests/saga.rs

@@ -23,7 +23,7 @@ fn external() -> AccountId {
 
 fn make_account(id: i64, policy: AccountPolicy) -> Account {
     Account {
-        id: AccountId::new(id),
+        id: AccountRef::main(AccountId::new(id)),
         version: 1,
         policy,
         flags: AccountFlags::empty(),
@@ -91,15 +91,24 @@ async fn saga_happy_path() {
     }
 
     assert_eq!(
-        ledger.balance(&account(1), &usd()).await.unwrap(),
+        ledger
+            .balance(&AccountRef::main(account(1)), &usd())
+            .await
+            .unwrap(),
         Cent::from(40)
     );
     assert_eq!(
-        ledger.balance(&account(2), &usd()).await.unwrap(),
+        ledger
+            .balance(&AccountRef::main(account(2)), &usd())
+            .await
+            .unwrap(),
         Cent::from(60)
     );
     assert_eq!(
-        ledger.balance(&external(), &usd()).await.unwrap(),
+        ledger
+            .balance(&AccountRef::main(external()), &usd())
+            .await
+            .unwrap(),
         Cent::from(-100)
     );
 }
@@ -141,11 +150,17 @@ async fn saga_compensation_on_failure() {
             // 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(),
+                ledger
+                    .balance(&AccountRef::main(account(1)), &usd())
+                    .await
+                    .unwrap(),
                 Cent::ZERO
             );
             assert_eq!(
-                ledger.balance(&external(), &usd()).await.unwrap(),
+                ledger
+                    .balance(&AccountRef::main(external()), &usd())
+                    .await
+                    .unwrap(),
                 Cent::ZERO
             );
         }
@@ -198,15 +213,24 @@ async fn saga_three_steps_happy() {
     }
 
     assert_eq!(
-        ledger.balance(&account(1), &usd()).await.unwrap(),
+        ledger
+            .balance(&AccountRef::main(account(1)), &usd())
+            .await
+            .unwrap(),
         Cent::from(40)
     );
     assert_eq!(
-        ledger.balance(&account(2), &usd()).await.unwrap(),
+        ledger
+            .balance(&AccountRef::main(account(2)), &usd())
+            .await
+            .unwrap(),
         Cent::from(30)
     );
     assert_eq!(
-        ledger.balance(&account(3), &usd()).await.unwrap(),
+        ledger
+            .balance(&AccountRef::main(account(3)), &usd())
+            .await
+            .unwrap(),
         Cent::from(30)
     );
 }

+ 20 - 1
doc/accounts.md

@@ -98,9 +98,28 @@ preventing TOCTOU races where an account is mutated between load and apply.
 
 The saga `commit()` path auto-populates snapshots when none are provided.
 
+## Subaccounts
+
+An account is identified by a base id plus a `u64` **subaccount**, written
+`AccountRef { account, sub }`; `sub = 0` is the main account. Each `(account,
+sub)` is its own record with its own policy, flags, book, and version, so a
+subaccount is a full account that happens to share a base id. Subaccounts are how
+one account holds many concurrent inflights: an inflight hold is a subaccount of
+its destination, keyed by a value derived from the trade (see
+[adr/0005-subaccount-dimension.md](adr/0005-subaccount-dimension.md)).
+
+Balances are reported **segregated per subaccount**, never summed across them:
+
+- `balance(&AccountRef, asset)` reads one subaccount.
+- `balances(&AccountId, asset, sub)` returns one entry per non-closed subaccount
+  (`sub = None` spans all; `Some(s)` filters to one). Closed subaccounts are
+  excluded.
+- `list_subaccounts(&AccountId)` lists the non-closed subaccounts of a base
+  account.
+
 ## Balance Computation
 
-Balance for an (account, asset) pair is computed as:
+Balance for an (account reference, asset) pair is computed as:
 
 ```
 balance(account, asset) = sum(p.value for p in postings

+ 6 - 0
doc/adr/0004-inflight-holds-via-holding-accounts.md

@@ -1,5 +1,11 @@
 # Inflight holds via per-destination holding accounts
 
+> Revised by [ADR-0005](0005-subaccount-dimension.md): a hold is now a
+> **subaccount** of its destination rather than a standalone account, and the
+> "one open inflight per account" rule is dropped (a destination hosts many
+> concurrent inflights, one per distinct trade). The rest of this ADR still
+> holds.
+
 * Status: accepted
 * Authors: Cesar Rodas
 * Date: 2026-07-03

+ 152 - 0
doc/adr/0005-subaccount-dimension.md

@@ -0,0 +1,152 @@
+# 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
+
+* Builds on and revises [ADR-0004](0004-inflight-holds-via-holding-accounts.md)
+  (inflight holds), which now uses subaccounts instead of standalone hold
+  accounts and drops the one-open-per-account rule.
+* Builds on [ADR-0001](0001-modified-utxo-signed-postings.md) (signed postings)
+  and [ADR-0003](0003-dumb-storage-saga-recovery.md) (dumb storage).
+* Usage: [doc/accounts.md](../accounts.md), [doc/inflight.md](../inflight.md).

+ 10 - 0
doc/glossary.md

@@ -31,6 +31,16 @@ the sum of non-inactive postings for a given (account, asset) pair.
 Accounts have a **policy** (balance floor rule), **flags** (lifecycle +
 user-defined), and a **book** assignment.
 
+### Subaccount / AccountRef
+
+An account is identified by a base `AccountId` plus a `u64` subaccount
+(`AccountRef { account, sub }`); `sub = 0` is the main account. Each `(account,
+sub)` is a full, independent account record. Subaccounts let one account hold
+many concurrent inflights (a hold is a subaccount of its destination). Balances
+are always reported segregated per subaccount, never summed. See
+[accounts.md](accounts.md) and
+[adr/0005-subaccount-dimension.md](adr/0005-subaccount-dimension.md).
+
 ### Asset
 
 An identifier (`AssetId(u32)`) representing a unit of value: a currency, a

+ 15 - 3
doc/inflight.md

@@ -99,10 +99,21 @@ let open = ledger.list_open_inflights().await?;
   Confirmed and voided amounts are summed from the metadata-tagged settling
   transfers. Nothing mutable is stored.
 
+## Subaccounts and concurrency
+
+A hold is a **subaccount** of its destination: `(destination, sub)`, where `sub`
+is derived from a hash of the submitted trade (see
+[adr/0005-subaccount-dimension.md](adr/0005-subaccount-dimension.md)). All holds
+of one inflight share that `sub`. Because a different trade derives a different
+`sub`, a destination can host **many concurrent inflights** at once, each isolated
+in its own subaccount and listed by `ledger.list_subaccounts(&destination)`.
+Re-authorizing the *identical* trade collides on the existing hold and is
+rejected. Balances are always segregated per subaccount: `ledger.balances(&base,
+&asset, None)` lists the main account and every open hold separately, never
+summed.
+
 ## Constraints and limitations
 
-- **One open inflight per account.** A destination with an open hold cannot be
-  the destination of a second authorize until the first is confirmed or voided.
 - **Distinct movements.** Every movement in an authorize must move between two
   different accounts.
 - **Void needs an open payer.** Voiding returns funds to the original funder, so
@@ -118,5 +129,6 @@ let open = ledger.list_open_inflights().await?;
 
 - `crates/kuatia/src/inflight.rs` — the API and metadata schema.
 - `crates/kuatia/tests/inflight.rs` — authorize, confirm, partial confirm, void,
-  over-confirm rejection, one-open-per-account, and status tests.
+  over-confirm rejection, concurrent inflights per account, segregated balances,
+  and status tests.
 - `AccountFlags::INFLIGHT` — `crates/kuatia-types/src/lib.rs`.