Browse Source

Add a subaccount dimension to account identity

Some workloads need several distinct balances under one owner (sub-ledgers,
earmarks, reservations) that are individually addressable and closed
independently, without minting unrelated top-level accounts. This implements
ADR-0012: fold a subaccount into account identity so each partition is a full
account record with its own policy and lifecycle, while existing behaviour is
unchanged when subaccounts are not used.

AccountId becomes { id: i64, sub: i64 } where sub = 0 is the main account.
Conservation, double-spend, and floor checks are preserved because they already
key on the whole AccountId, which now distinguishes subaccounts, so they become
per-subaccount with no logic change. Balances are always reported per subaccount
and never summed across them; the ledger gains balances() and list_subaccounts().
Aggregate reads take a base id plus an optional subaccount filter, while exact
entity operations take the full AccountId. Book membership is scoped by base
account.

The SQL backend adds a migration that widens the accounts and transfer_accounts
primary keys and adds a subaccount column to postings, with existing rows
defaulting to the main account. The dashboard surfaces subaccounts in the account
list and detail views.

For presentation and routing, AccountId gains an IBAN-style string form: two
mod-97 check digits followed by a base-36 body, with no country code. Parsing
validates the checksum, so a mistyped id is rejected before it reaches the store.
To avoid leaking raw ids, the (id, sub) pair is run through a keyed 128-bit
Feistel permutation before encoding and inverted on parse, so codes look random
and a base account and its subaccount are not visibly related. The permutation
key is a global seed with a default, configurable via set_id_seed (the dashboard
exposes --id-seed / KUATIA_ID_SEED). This is obfuscation, not security. The
string is an edge form only; storage, the Store trait, content hashing, and serde
keep the two i64 legs.

Finally, make the SQL entity inserts race-free. create_account,
append_account_version, and create_book each ran a separate existence check then
an insert, a check-then-insert TOCTOU where two concurrent callers can both pass
the check and then both insert. They now insert atomically with ON CONFLICT DO
NOTHING and read the affected-row count, letting the primary key decide; a bare
transaction would not close this under Postgres READ COMMITTED. For
append_account_version the conflict on the version key acts as the
compare-and-swap.
Cesar Rodas 20 hours ago
parent
commit
155ac9ef5b

+ 113 - 1
crates/kuatia-core/src/validate.rs

@@ -321,7 +321,9 @@ 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: a book that lists a
+                // base account admits all of that account's subaccounts.
+                let listed = policy.allowed_accounts.contains(&aid.base());
                 let flag_match = !policy.allowed_flags.is_empty()
                     && account.flags.intersects(policy.allowed_flags);
                 if !(listed || flag_match) {
@@ -1016,6 +1018,116 @@ mod tests {
         // account2 projected: 0 + 60 = 60 >= 0 ✓
     }
 
+    fn make_subaccount(id: i64, sub: i64, policy: AccountPolicy) -> Account {
+        Account {
+            id: AccountId::with_sub(id, sub),
+            version: 1,
+            policy,
+            flags: AccountFlags::empty(),
+            book: BookId(0),
+            user_data: UserData::default(),
+            metadata: BTreeMap::new(),
+        }
+    }
+
+    #[test]
+    fn subaccount_carries_own_policy() {
+        // Base (1,0) is NoOverdraft but subaccount (1,7) is UncappedOverdraft.
+        // A negative posting on the subaccount is allowed because the check keys
+        // on the full owner and uses the subaccount's own policy.
+        let sub = AccountId::with_sub(1, 7);
+        let envelope = Envelope {
+            consumes: vec![],
+            creates: vec![
+                NewPosting {
+                    owner: AccountId::new(2),
+                    asset: AssetId::new(1),
+                    value: Cent::from(100),
+                    payer: Some(sub),
+                },
+                NewPosting {
+                    owner: sub,
+                    asset: AssetId::new(1),
+                    value: Cent::from(-100),
+                    payer: None,
+                },
+            ],
+            book: BookId(0),
+            user_data: UserData::default(),
+            account_snapshots: vec![],
+            metadata: BTreeMap::new(),
+        };
+        let accounts = accounts_map(vec![
+            make_account(1, AccountPolicy::NoOverdraft),
+            make_subaccount(1, 7, AccountPolicy::UncappedOverdraft),
+            make_account(2, AccountPolicy::NoOverdraft),
+        ]);
+        let balances = HashMap::new();
+        let input = PlanInput {
+            envelope: &envelope,
+            consumed_postings: &[],
+            accounts: &accounts,
+            balances: &balances,
+            book: None,
+        };
+
+        let plan = validate_and_plan(input).unwrap();
+        assert_eq!(plan.postings_to_create.len(), 2);
+    }
+
+    #[test]
+    fn subaccount_floor_is_segregated_from_base() {
+        // Base (1,0) holds 100, but NoOverdraft subaccount (1,7) holds nothing.
+        // The base's balance must not rescue the subaccount: a negative posting
+        // on the subaccount is rejected on its own policy.
+        let sub = AccountId::with_sub(1, 7);
+        let envelope = Envelope {
+            consumes: vec![],
+            creates: vec![
+                NewPosting {
+                    owner: AccountId::new(2),
+                    asset: AssetId::new(1),
+                    value: Cent::from(100),
+                    payer: Some(sub),
+                },
+                NewPosting {
+                    owner: sub,
+                    asset: AssetId::new(1),
+                    value: Cent::from(-100),
+                    payer: None,
+                },
+            ],
+            book: BookId(0),
+            user_data: UserData::default(),
+            account_snapshots: vec![],
+            metadata: BTreeMap::new(),
+        };
+        let accounts = accounts_map(vec![
+            make_account(1, AccountPolicy::NoOverdraft),
+            make_subaccount(1, 7, AccountPolicy::NoOverdraft),
+            make_account(2, AccountPolicy::NoOverdraft),
+        ]);
+        let mut balances = HashMap::new();
+        balances.insert((AccountId::new(1), AssetId::new(1)), Cent::from(100));
+
+        let input = PlanInput {
+            envelope: &envelope,
+            consumed_postings: &[],
+            accounts: &accounts,
+            balances: &balances,
+            book: None,
+        };
+
+        assert_eq!(
+            validate_and_plan(input).unwrap_err(),
+            ValidationError::NegativePostingOnNonSystemAccount {
+                account: sub,
+                asset: AssetId::new(1),
+                value: Cent::from(-100),
+            },
+        );
+    }
+
     #[test]
     fn account_not_found() {
         let envelope = Envelope {

+ 4 - 5
crates/kuatia-dashboard/src/api.rs

@@ -25,7 +25,7 @@ pub fn router(state: AppState) -> Router {
         .route("/assets", get(assets))
         .route("/overview", get(overview))
         .route("/accounts", get(accounts))
-        .route("/accounts/{id}", get(account_detail))
+        .route("/accounts/{uuid}", get(account_detail))
         .route("/transfers", get(transfers))
         .route("/events", get(events))
         .with_state(state)
@@ -45,11 +45,10 @@ async fn accounts(State(state): State<AppState>) -> Result<Json<Vec<AccountDto>>
 
 async fn account_detail(
     State(state): State<AppState>,
-    Path(id): Path<i64>,
+    Path(uuid): Path<String>,
 ) -> Result<Json<AccountDetailDto>, ApiError> {
-    Ok(Json(
-        crate::data::account_detail(&state, AccountId::new(id)).await?,
-    ))
+    let id: AccountId = uuid.parse().map_err(ApiError::bad_request)?;
+    Ok(Json(crate::data::account_detail(&state, id).await?))
 }
 
 #[derive(Deserialize)]

+ 29 - 1
crates/kuatia-dashboard/src/data.rs

@@ -41,6 +41,10 @@ pub struct BalanceDto {
 #[derive(Serialize)]
 pub struct AccountDto {
     pub id: AccountId,
+    /// IBAN-style account code (machine format, checksum-valid) for the full id.
+    pub code: String,
+    /// Subaccount id (`0` is the main account). Mirrors `id.sub` for templates.
+    pub sub: i64,
     pub label: Option<&'static str>,
     pub version: u64,
     pub policy: PolicyDto,
@@ -108,6 +112,10 @@ pub struct OverviewDto {
 #[derive(Serialize)]
 pub struct AccountDetailDto {
     pub account: AccountDto,
+    /// The non-closed subaccounts sharing this account's base id (the viewed
+    /// account included), so a base account and its subaccounts are navigable
+    /// together while never summed.
+    pub subaccounts: Vec<AccountDto>,
     pub postings: Vec<PostingDto>,
     pub transfers: Vec<TransferDto>,
 }
@@ -163,6 +171,8 @@ async fn account_dto(state: &AppState, account: &Account) -> Result<AccountDto,
     }
     Ok(AccountDto {
         id: account.id,
+        code: account.id.to_string(),
+        sub: account.id.sub,
         label: account_label(account.id),
         version: account.version,
         policy: policy_dto(&account.policy),
@@ -260,7 +270,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.id, a.id.sub));
     let mut out = Vec::with_capacity(accounts.len());
     for account in &accounts {
         out.push(account_dto(state, account).await?);
@@ -296,8 +306,17 @@ pub async fn account_detail(state: &AppState, id: AccountId) -> Result<AccountDe
         .map(transfer_dto)
         .collect();
 
+    // The base account and its non-closed subaccounts, so the detail page can
+    // list every partition with its own (segregated) balances.
+    let mut subaccounts = Vec::new();
+    for sub_id in state.ledger.list_subaccounts(&id).await? {
+        let sub_account = state.ledger.get_account(&sub_id).await?;
+        subaccounts.push(account_dto(state, &sub_account).await?);
+    }
+
     Ok(AccountDetailDto {
         account: account_dto(state, &account).await?,
+        subaccounts,
         postings,
         transfers,
     })
@@ -344,6 +363,15 @@ impl ApiError {
     pub fn from_display(err: impl std::fmt::Display) -> Self {
         Self::internal(err.to_string())
     }
+
+    /// Build a 400 from a displayable error (used for a malformed account id in
+    /// the URL).
+    pub fn bad_request(err: impl std::fmt::Display) -> Self {
+        Self {
+            status: StatusCode::BAD_REQUEST,
+            message: err.to_string(),
+        }
+    }
 }
 
 impl From<kuatia::error::LedgerError> for ApiError {

+ 11 - 0
crates/kuatia-dashboard/src/main.rs

@@ -50,6 +50,12 @@ struct Cli {
     /// an already-populated database.
     #[arg(long, env = "KUATIA_DASHBOARD_SEED", default_value_t = false)]
     seed: bool,
+
+    /// Seed keying the account-code obfuscation. Must be stable across a
+    /// deployment: changing it changes every rendered account code. Defaults to
+    /// the built-in `DEFAULT_ID_SEED`.
+    #[arg(long, env = "KUATIA_ID_SEED")]
+    id_seed: Option<u64>,
 }
 
 #[tokio::main]
@@ -63,6 +69,11 @@ async fn main() -> Result<(), Box<dyn std::error::Error>> {
 
     let cli = Cli::parse();
 
+    // Set the account-code obfuscation seed before any code is rendered.
+    if let Some(seed) = cli.id_seed {
+        kuatia_core::set_id_seed(seed);
+    }
+
     let ledger = seed::connect(&cli.db).await?;
     tracing::info!("connected to ledger at {}", cli.db);
     if cli.seed {

+ 9 - 0
crates/kuatia-dashboard/src/seed.rs

@@ -14,6 +14,9 @@ use crate::assets::{BTC, EUR, USD};
 pub const TREASURY: AccountId = AccountId::new(1);
 pub const EXTERNAL: AccountId = AccountId::new(99);
 pub const ALICE: AccountId = AccountId::new(100);
+/// A subaccount of Alice: an earmarked savings bucket under the same base id,
+/// with its own balance that is never summed into Alice's main account.
+pub const ALICE_SAVINGS: AccountId = AccountId::with_sub(100, 1);
 pub const BOB: AccountId = AccountId::new(101);
 pub const CAROL: AccountId = AccountId::new(102);
 pub const MERCHANT: AccountId = AccountId::new(103);
@@ -25,6 +28,7 @@ pub fn account_label(id: AccountId) -> Option<&'static str> {
         TREASURY => "Treasury",
         EXTERNAL => "External",
         ALICE => "Alice",
+        ALICE_SAVINGS => "Alice / Savings",
         BOB => "Bob",
         CAROL => "Carol",
         MERCHANT => "Merchant",
@@ -85,6 +89,7 @@ pub async fn populate(ledger: &Arc<Ledger>) -> Result<(), Box<dyn std::error::Er
     create(ledger, TREASURY, AccountPolicy::SystemAccount).await?;
     create(ledger, EXTERNAL, AccountPolicy::ExternalAccount).await?;
     create(ledger, ALICE, AccountPolicy::NoOverdraft).await?;
+    create(ledger, ALICE_SAVINGS, AccountPolicy::NoOverdraft).await?;
     create(ledger, BOB, AccountPolicy::NoOverdraft).await?;
     // Carol may overdraw down to -$500.00.
     create(
@@ -111,6 +116,10 @@ pub async fn populate(ledger: &Arc<Ledger>) -> Result<(), Box<dyn std::error::Er
     // Carol spends past her balance, into the capped overdraft.
     pay(ledger, CAROL, MERCHANT, USD, fiat.parse("250.00")?).await?;
 
+    // Alice earmarks part of her balance into her savings subaccount. The two
+    // balances stay segregated under the same base id.
+    pay(ledger, ALICE, ALICE_SAVINGS, USD, fiat.parse("300.00")?).await?;
+
     // Atomic multi-asset trade: Alice buys EUR from Bob with USD.
     let trade = TransferBuilder::new()
         .pay(ALICE, BOB, USD, fiat.parse("100.00")?)

+ 49 - 14
crates/kuatia-dashboard/src/ui.rs

@@ -80,12 +80,12 @@ pub fn router(state: AppState) -> Router {
     Router::new()
         .route("/", get(overview_page))
         .route("/accounts", get(accounts_page))
-        .route("/accounts/{id}", get(account_page))
+        .route("/accounts/{uuid}", get(account_page))
         .route("/transfers", get(transfers_page))
         .route("/events", get(events_page))
         .route("/ui/overview", get(overview_partial))
         .route("/ui/accounts", get(accounts_partial))
-        .route("/ui/accounts/{id}", get(account_partial))
+        .route("/ui/accounts/{uuid}", get(account_partial))
         .route("/ui/transfers", get(transfers_partial))
         .route("/ui/events", get(events_partial))
         .route("/static/dashboard.css", get(css))
@@ -111,7 +111,15 @@ struct BalanceView {
 
 #[derive(Serialize)]
 struct AccountView {
+    /// Base account id (used only for the route; the display uses `display_id`).
     id: i64,
+    sub: i64,
+    /// `"5"` for a main account, `"5.7"` for subaccount 7 of base 5.
+    display_id: String,
+    /// Full-page detail link (`/accounts/5` or `/accounts/5/7`).
+    link: String,
+    /// htmx partial link (`/ui/accounts/5` or `/ui/accounts/5/7`).
+    ui_link: String,
     name: String,
     version: u64,
     policy_kind: &'static str,
@@ -149,7 +157,8 @@ struct TransferView {
 struct EventView {
     seq: u64,
     kind: &'static str,
-    account: Option<i64>,
+    /// Account display id (`"5"` or `"5.7"`), if the event names an account.
+    account: Option<String>,
     transfer_short: Option<String>,
     time: String,
 }
@@ -262,14 +271,31 @@ fn floor_style(assets: &[AssetMeta]) -> (u8, &str) {
 // View builders — DTO -> view model.
 // ---------------------------------------------------------------------------
 
+/// Display an account id as its IBAN-style code in grouped (spaced) format.
+fn acct_display(id: kuatia_core::AccountId) -> String {
+    id.to_grouped()
+}
+
+/// The detail route path for an account, keyed by the machine-format code:
+/// `/accounts/<code>`.
+fn acct_link(id: kuatia_core::AccountId) -> String {
+    format!("/accounts/{id}")
+}
+
 fn account_view(dto: &AccountDto, assets: &[AssetMeta]) -> AccountView {
     let (floor_dec, floor_sym) = floor_style(assets);
+    let display_id = acct_display(dto.id);
+    let link = acct_link(dto.id);
     AccountView {
-        id: dto.id.0,
+        id: dto.id.id,
+        sub: dto.sub,
+        ui_link: format!("/ui{link}"),
+        link,
         name: dto
             .label
             .map(String::from)
-            .unwrap_or_else(|| format!("#{}", dto.id.0)),
+            .unwrap_or_else(|| display_id.clone()),
+        display_id,
         version: dto.version,
         policy_kind: dto.policy.kind,
         floor: dto.policy.floor.map(|f| fmt(f, floor_dec, floor_sym)),
@@ -301,11 +327,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(|| acct_display(leg.owner)),
                 from_name: leg.payer.map(|p| {
                     leg.payer_label
                         .map(String::from)
-                        .unwrap_or_else(|| format!("#{}", p.0))
+                        .unwrap_or_else(|| acct_display(p))
                 }),
                 is_change: leg.payer.is_none(),
                 money: fmt_asset(leg.value, asset_of(assets, leg.asset)),
@@ -326,7 +352,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(acct_display),
         transfer_short: dto.transfer.as_deref().map(short_hex),
         time: fmt_millis(dto.timestamp),
     }
@@ -371,12 +397,19 @@ async fn accounts_ctx(state: &AppState) -> Result<Context, ApiError> {
     Ok(ctx)
 }
 
-async fn account_ctx(state: &AppState, id: i64) -> Result<Context, ApiError> {
-    let dto = data::account_detail(state, kuatia_core::AccountId::new(id)).await?;
+async fn account_ctx(state: &AppState, id: kuatia_core::AccountId) -> Result<Context, ApiError> {
+    let dto = data::account_detail(state, id).await?;
     let mut ctx = Context::new();
     ctx.insert("nav", "accounts");
     ctx.insert("account", &account_view(&dto.account, &state.assets));
     ctx.insert(
+        "subaccounts",
+        &dto.subaccounts
+            .iter()
+            .map(|a| account_view(a, &state.assets))
+            .collect::<Vec<_>>(),
+    );
+    ctx.insert(
         "postings",
         &dto.postings
             .iter()
@@ -451,22 +484,24 @@ async fn accounts_partial(State(state): State<AppState>) -> Result<Html<String>,
 
 async fn account_page(
     State(state): State<AppState>,
-    Path(id): Path<i64>,
+    Path(uuid): Path<String>,
 ) -> Result<Html<String>, ApiError> {
+    let account = uuid.parse().map_err(ApiError::bad_request)?;
     render(
         &state,
         "pages/account.html",
-        &account_ctx(&state, id).await?,
+        &account_ctx(&state, account).await?,
     )
 }
 async fn account_partial(
     State(state): State<AppState>,
-    Path(id): Path<i64>,
+    Path(uuid): Path<String>,
 ) -> Result<Html<String>, ApiError> {
+    let account = uuid.parse().map_err(ApiError::bad_request)?;
     render(
         &state,
         "partials/account.html",
-        &account_ctx(&state, id).await?,
+        &account_ctx(&state, account).await?,
     )
 }
 

+ 1 - 0
crates/kuatia-dashboard/static/dashboard.css

@@ -230,6 +230,7 @@ table.grid tbody tr:hover {
 
 .flag.frozen { background: #2b3f57; color: #7fb3ff; }
 .flag.closed { background: #4a2a2a; color: #e8736b; }
+.flag.sub { background: #2f3a2b; color: #a6cf7f; }
 
 .status.Active { color: var(--pos); }
 .status.PendingInactive { color: #e0b25a; }

+ 2 - 2
crates/kuatia-dashboard/templates/pages/account.html

@@ -2,8 +2,8 @@
 {% block content %}
 <p><a class="back" href="/accounts">&larr; Accounts</a></p>
 <div class="panel">
-  <h2>{{ account.name }} <span class="acct-id">#{{ account.id }}</span></h2>
-  <div id="live" hx-get="/ui/accounts/{{ account.id }}" hx-trigger="every 3s" hx-swap="innerHTML">
+  <h2>{{ account.name }} <span class="acct-id">{{ account.display_id }}</span></h2>
+  <div id="live" hx-get="{{ account.ui_link }}" hx-trigger="every 3s" hx-swap="innerHTML">
     {% include "partials/account.html" %}
   </div>
 </div>

+ 32 - 0
crates/kuatia-dashboard/templates/partials/account.html

@@ -22,6 +22,38 @@
 <p class="muted">No balances.</p>
 {% endif %}
 
+{% if subaccounts | length > 1 %}
+<h3>Subaccounts ({{ subaccounts | length }})</h3>
+<table class="grid small">
+  <thead>
+    <tr>
+      <th>Subaccount</th>
+      <th>Policy</th>
+      <th class="right">Balances</th>
+    </tr>
+  </thead>
+  <tbody>
+    {% for s in subaccounts %}
+    <tr onclick="window.location='{{ s.link }}'">
+      <td>
+        <div class="acct-id">{{ s.display_id }}{% if s.sub == 0 %} <span class="muted">(main)</span>{% endif %}</div>
+      </td>
+      <td><span class="policy {{ s.policy_kind }}">{{ s.policy_kind }}</span></td>
+      <td class="right">
+        {% if s.balances %}
+          {% for b in s.balances %}
+          <div><span class="money {% if b.money.negative %}neg{% else %}pos{% endif %}">{{ b.money.text }}</span></div>
+          {% endfor %}
+        {% else %}
+          <span class="muted">&mdash;</span>
+        {% endif %}
+      </td>
+    </tr>
+    {% endfor %}
+  </tbody>
+</table>
+{% endif %}
+
 <h3>Postings ({{ postings | length }})</h3>
 <table class="grid small">
   <thead>

+ 3 - 2
crates/kuatia-dashboard/templates/partials/accounts.html

@@ -8,11 +8,12 @@
   </thead>
   <tbody>
     {% for a in accounts %}
-    <tr onclick="window.location='/accounts/{{ a.id }}'">
+    <tr onclick="window.location='{{ a.link }}'">
       <td>
         <div class="acct-name">{{ a.name }}</div>
         <div class="acct-id">
-          #{{ a.id }}
+          {{ a.display_id }}
+          {% if a.sub %}<span class="flag sub">subaccount</span>{% endif %}
           {% if a.frozen %}<span class="flag frozen">frozen</span>{% endif %}
           {% if a.closed %}<span class="flag closed">closed</span>{% endif %}
         </div>

+ 1 - 1
crates/kuatia-dashboard/templates/partials/events.html

@@ -3,7 +3,7 @@
   <div class="event-row">
     <span class="event-kind {{ e.kind }}">{{ e.kind }}</span>
     <span class="event-target">
-      {% if e.account %}account #{{ e.account }}{% endif %}
+      {% if e.account %}account {{ e.account }}{% endif %}
       {% if e.transfer_short %}<code class="hash">{{ e.transfer_short }}</code>{% endif %}
     </span>
     <span class="muted event-time">{{ e.time }}</span>

+ 158 - 101
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: AccountId::with_sub(id, subaccount),
         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: AccountId::with_sub(owner, subaccount),
         asset: AssetId::new(asset as u32),
         value,
         status: status_from_i16(status)?,
@@ -234,12 +246,15 @@ 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:?}")))?;
+        let row = sqlx::query(
+            "SELECT * FROM accounts WHERE id = $1 AND subaccount = $2 ORDER BY version DESC LIMIT 1",
+        )
+        .bind(id.id)
+        .bind(id.sub)
+        .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)
     }
 
@@ -252,22 +267,18 @@ 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()))?;
-        if exists.is_some() {
-            return Err(StoreError::AlreadyExists(format!(
-                "account {:?}",
-                account.id
-            )));
-        }
-
-        sqlx::query(
-            "INSERT INTO accounts (id, version, policy, flags, book, user_data, metadata) VALUES ($1, $2, $3, $4, $5, $6, $7)"
+        // Insert atomically and let the (id, subaccount, version) primary key
+        // decide existence: `ON CONFLICT DO NOTHING` makes a duplicate a no-op
+        // rather than an error, and the affected-row count tells us which
+        // happened. A separate `SELECT` then `INSERT` would be a TOCTOU — two
+        // concurrent creates could both find no row and both insert. (A plain
+        // transaction would not close this under Postgres READ COMMITTED; the
+        // unique constraint does.)
+        let res = sqlx::query(
+            "INSERT INTO accounts (id, subaccount, version, policy, flags, book, user_data, metadata) VALUES ($1, $2, $3, $4, $5, $6, $7, $8) ON CONFLICT (id, subaccount, version) DO NOTHING"
         )
-            .bind(account.id.0)
+            .bind(account.id.id)
+            .bind(account.id.sub)
             .bind(account.version as i64)
             .bind(serialize_policy(&account.policy)?)
             .bind(account.flags.bits() as i32)
@@ -277,17 +288,25 @@ impl AccountStore for SqlStore {
             .execute(&self.pool)
             .await
             .map_err(|e| StoreError::Internal(e.to_string()))?;
+        if res.rows_affected() == 0 {
+            return Err(StoreError::AlreadyExists(format!(
+                "account {:?}",
+                account.id
+            )));
+        }
         Ok(())
     }
 
     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.id)
+        .bind(account.id.sub)
+        .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")
@@ -304,10 +323,17 @@ 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)"
+        // The read above is only a friendly fast path: the read and the write
+        // are not one atomic step, so two concurrent appends could both compute
+        // the same next version. The `ON CONFLICT (id, subaccount, version) DO
+        // NOTHING` makes the insert the real compare-and-swap on the version
+        // key — only one insert of a given version wins; the loser sees zero
+        // affected rows and reports the conflict.
+        let res = sqlx::query(
+            "INSERT INTO accounts (id, subaccount, version, policy, flags, book, user_data, metadata) VALUES ($1, $2, $3, $4, $5, $6, $7, $8) ON CONFLICT (id, subaccount, version) DO NOTHING"
         )
-            .bind(account.id.0)
+            .bind(account.id.id)
+            .bind(account.id.sub)
             .bind(account.version as i64)
             .bind(serialize_policy(&account.policy)?)
             .bind(account.flags.bits() as i32)
@@ -317,15 +343,25 @@ impl AccountStore for SqlStore {
             .execute(&self.pool)
             .await
             .map_err(|e| StoreError::Internal(e.to_string()))?;
+        if res.rows_affected() == 0 {
+            return Err(StoreError::VersionConflict {
+                account: account.id,
+                expected: expected as u64,
+                actual: account.version,
+            });
+        }
         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()))?;
+        let rows = sqlx::query(
+            "SELECT * FROM accounts WHERE id = $1 AND subaccount = $2 ORDER BY version ASC",
+        )
+        .bind(id.id)
+        .bind(id.sub)
+        .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,7 +369,7 @@ 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()))?;
@@ -367,44 +403,43 @@ impl PostingStore for SqlStore {
 
     async fn get_postings_by_account(
         &self,
-        account: &AccountId,
+        id: i64,
+        sub: Option<i64>,
         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: `sub == None` spans every subaccount
+        // of `id`, `Some(s)` restricts to one. The subaccount is compared only
+        // for equality, never as a magnitude.
+        let mut sql = String::from("SELECT * FROM postings WHERE owner = $1");
+        let mut placeholder = 2u32;
+        if sub.is_some() {
+            sql.push_str(&format!(" AND subaccount = ${placeholder}"));
+            placeholder += 1;
+        }
+        if asset.is_some() {
+            sql.push_str(&format!(" AND asset = ${placeholder}"));
+            placeholder += 1;
+        }
+        if status.is_some() {
+            sql.push_str(&format!(" AND status = ${placeholder}"));
+        }
+
+        let mut q = sqlx::query(&sql).bind(id);
+        if let Some(s) = sub {
+            q = q.bind(s);
+        }
+        if let Some(a) = asset {
+            q = q.bind(a.0 as i32);
+        }
+        if let Some(s) = status {
+            q = q.bind(status_to_i16(s));
         }
-        .map_err(|e| StoreError::Internal(e.to_string()))?;
 
+        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 +447,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;
@@ -427,7 +466,10 @@ impl PostingStore for SqlStore {
         };
 
         // Build count query
-        let mut count_q = sqlx::query(&count_clause).bind(query.account.0);
+        let mut count_q = sqlx::query(&count_clause).bind(query.account);
+        if let Some(s) = query.sub {
+            count_q = count_q.bind(s);
+        }
         if let Some(ref a) = query.asset {
             count_q = count_q.bind(a.0 as i32);
         }
@@ -443,7 +485,10 @@ impl PostingStore for SqlStore {
             .map_err(|e| StoreError::Internal(e.to_string()))?;
 
         // Build data query
-        let mut data_q = sqlx::query(&where_clause).bind(query.account.0);
+        let mut data_q = sqlx::query(&where_clause).bind(query.account);
+        if let Some(s) = query.sub {
+            data_q = data_q.bind(s);
+        }
         if let Some(ref a) = query.asset {
             data_q = data_q.bind(a.0 as i32);
         }
@@ -583,11 +628,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.id)
+                .bind(posting.owner.sub)
                 .bind(posting.asset.0 as i32)
                 .bind(posting.value.to_string())
                 .bind(status_to_i16(posting.status))
@@ -667,9 +713,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.id)
+                .bind(account.sub)
                 .execute(&mut *tx)
                 .await
                 .map_err(|e| StoreError::Internal(e.to_string()))?;
@@ -683,12 +730,24 @@ impl TransferStore for SqlStore {
 
     async fn get_transfers_for_account(
         &self,
-        account: &AccountId,
+        id: i64,
+        sub: Option<i64>,
     ) -> 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)
+        // `sub == None` spans every subaccount of `id`; `Some(s)` restricts to
+        // one. The subaccount is matched only for equality.
+        let mut sql = String::from(
+            "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",
+        );
+        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(id);
+        if let Some(s) = sub {
+            q = q.bind(s);
+        }
+        let rows = q
             .fetch_all(&self.pool)
             .await
             .map_err(|e| StoreError::Internal(e.to_string()))?;
@@ -718,8 +777,8 @@ impl TransferStore for SqlStore {
         query: &TransferQuery,
     ) -> 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?
+        let base_records = if let Some(account) = query.account {
+            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",
@@ -917,23 +976,21 @@ impl EventStore for SqlStore {
 #[async_trait]
 impl BookStore for SqlStore {
     async fn create_book(&self, book: Book) -> Result<(), StoreError> {
-        let exists = sqlx::query("SELECT 1 FROM books WHERE id = $1 LIMIT 1")
-            .bind(book.id.0)
-            .fetch_optional(&self.pool)
-            .await
-            .map_err(|e| StoreError::Internal(e.to_string()))?;
-        if exists.is_some() {
+        // Same TOCTOU as create_account: insert atomically on the `id` primary
+        // key instead of checking then inserting.
+        let data = serialize_json(&book)?;
+        let res = sqlx::query(
+            "INSERT INTO books (id, name, data) VALUES ($1, $2, $3) ON CONFLICT (id) DO NOTHING",
+        )
+        .bind(book.id.0)
+        .bind(&book.name)
+        .bind(&data)
+        .execute(&self.pool)
+        .await
+        .map_err(|e| StoreError::Internal(e.to_string()))?;
+        if res.rows_affected() == 0 {
             return Err(StoreError::AlreadyExists(format!("book {:?}", book.id)));
         }
-
-        let data = serialize_json(&book)?;
-        sqlx::query("INSERT INTO books (id, name, data) VALUES ($1, $2, $3)")
-            .bind(book.id.0)
-            .bind(&book.name)
-            .bind(&data)
-            .execute(&self.pool)
-            .await
-            .map_err(|e| StoreError::Internal(e.to_string()))?;
         Ok(())
     }
 

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

@@ -0,0 +1,38 @@
+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);
+
+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;
+
+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);

+ 109 - 0
crates/kuatia-storage-sql/tests/sqlite.rs

@@ -75,6 +75,115 @@ async fn columns_store_hex_ids_and_json_text() {
     );
 }
 
+/// The 002 migration adds the subaccount column and a subaccount account/posting
+/// round-trips through the schema, kept distinct from the main account.
+#[tokio::test]
+async fn subaccount_columns_round_trip() {
+    let store = new_store().await;
+
+    let sub = AccountId::with_sub(1, 7);
+    store
+        .create_account(Account::new(sub, AccountPolicy::NoOverdraft))
+        .await
+        .unwrap();
+    // The main account (1, 0) is a separate record.
+    store
+        .create_account(Account::new(AccountId::new(1), AccountPolicy::NoOverdraft))
+        .await
+        .unwrap();
+
+    let got = store.get_account(&sub).await.unwrap();
+    assert_eq!(got.id, sub);
+
+    let posting = Posting::new(
+        PostingId {
+            transfer: EnvelopeId([0xcd; 32]),
+            index: 0,
+        },
+        sub,
+        AssetId::new(1),
+        Cent::from(500),
+    );
+    store.insert_postings(&[posting]).await.unwrap();
+
+    // Filtering by the subaccount returns it; the main account holds nothing.
+    let by_sub = store
+        .get_postings_by_account(1, Some(7), None, None)
+        .await
+        .unwrap();
+    assert_eq!(by_sub.len(), 1);
+    assert_eq!(by_sub[0].owner, sub);
+    let by_main = store
+        .get_postings_by_account(1, Some(0), None, None)
+        .await
+        .unwrap();
+    assert!(by_main.is_empty());
+}
+
+/// A database created under 001 (no subaccount column) upgrades in place: the
+/// 002 migration rebuilds the tables and existing rows default to subaccount 0,
+/// the main account.
+#[tokio::test]
+async fn migration_upgrades_existing_rows_to_main_account() {
+    let pool = new_pool().await;
+
+    // Pre-002 schema: the tables 002 rebuilds, in their old (no-subaccount) shape.
+    sqlx::query(
+        "CREATE TABLE accounts (id BIGINT NOT NULL, 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, version))",
+    )
+    .execute(&pool)
+    .await
+    .unwrap();
+    sqlx::query(
+        "CREATE TABLE postings (transfer_id TEXT NOT NULL, idx SMALLINT NOT NULL, owner BIGINT NOT NULL, asset INTEGER NOT NULL, value TEXT NOT NULL, status SMALLINT NOT NULL, reservation BIGINT, PRIMARY KEY (transfer_id, idx))",
+    )
+    .execute(&pool)
+    .await
+    .unwrap();
+    sqlx::query("CREATE INDEX idx_postings_owner ON postings(owner, asset, status)")
+        .execute(&pool)
+        .await
+        .unwrap();
+    sqlx::query(
+        "CREATE TABLE transfer_accounts (transfer_id TEXT NOT NULL, account_id BIGINT NOT NULL, PRIMARY KEY (transfer_id, account_id))",
+    )
+    .execute(&pool)
+    .await
+    .unwrap();
+    sqlx::query("CREATE INDEX idx_xfer_acct ON transfer_accounts(account_id)")
+        .execute(&pool)
+        .await
+        .unwrap();
+    let user_data = serde_json::to_string(&UserData::default()).unwrap();
+    let metadata =
+        serde_json::to_string(&std::collections::BTreeMap::<String, String>::new()).unwrap();
+    sqlx::query(
+        "INSERT INTO accounts (id, version, policy, flags, book, user_data, metadata) VALUES (5, 1, '\"NoOverdraft\"', 0, 0, $1, $2)",
+    )
+    .bind(user_data)
+    .bind(metadata)
+    .execute(&pool)
+    .await
+    .unwrap();
+    // Record 001 as applied so migrate() only runs 002 against this schema.
+    sqlx::query("CREATE TABLE _migrations (name TEXT PRIMARY KEY)")
+        .execute(&pool)
+        .await
+        .unwrap();
+    sqlx::query("INSERT INTO _migrations (name) VALUES ('001_init')")
+        .execute(&pool)
+        .await
+        .unwrap();
+
+    let store = SqlStore::new(pool);
+    store.migrate().await.unwrap();
+
+    // The pre-existing account is now the main account (subaccount 0).
+    let got = store.get_account(&AccountId::new(5)).await.unwrap();
+    assert_eq!(got.id, AccountId::new(5));
+    assert!(got.id.is_main());
+}
+
 /// migrate() is idempotent: running it repeatedly on the same DB is a no-op.
 #[tokio::test]
 async fn migrate_is_idempotent() {

+ 9 - 5
crates/kuatia-storage/src/mem_store.rs

@@ -147,7 +147,8 @@ impl PostingStore for InMemoryStore {
 
     async fn get_postings_by_account(
         &self,
-        account: &AccountId,
+        id: i64,
+        sub: Option<i64>,
         asset: Option<&AssetId>,
         status: Option<PostingStatus>,
     ) -> Result<Vec<Posting>, StoreError> {
@@ -155,7 +156,8 @@ impl PostingStore for InMemoryStore {
         Ok(postings
             .values()
             .filter(|p| {
-                p.owner == *account
+                p.owner.id == id
+                    && sub.is_none_or(|s| p.owner.sub == s)
                     && asset.is_none_or(|a| p.asset == *a)
                     && status.is_none_or(|s| p.status == s)
             })
@@ -275,12 +277,14 @@ impl TransferStore for InMemoryStore {
 
     async fn get_transfers_for_account(
         &self,
-        account: &AccountId,
+        id: i64,
+        sub: Option<i64>,
     ) -> Result<Vec<EnvelopeRecord>, StoreError> {
         // Acquire postings → transfers in a consistent order to avoid an AB–BA
         // deadlock with any reader that takes both.
         let postings = self.postings.read().await;
         let transfers = self.transfers.read().await;
+        let matches = |owner: &AccountId| owner.id == id && sub.is_none_or(|s| owner.sub == s);
         let mut result: Vec<EnvelopeRecord> = transfers
             .values()
             .filter(|record| {
@@ -288,12 +292,12 @@ impl TransferStore for InMemoryStore {
                     .envelope
                     .creates()
                     .iter()
-                    .any(|np| np.owner == *account)
+                    .any(|np| matches(&np.owner))
                     || record
                         .envelope
                         .consumes()
                         .iter()
-                        .any(|pid| postings.get(pid).is_some_and(|p| p.owner == *account))
+                        .any(|pid| postings.get(pid).is_some_and(|p| matches(&p.owner)))
             })
             .cloned()
             .collect();

+ 20 - 11
crates/kuatia-storage/src/store.rs

@@ -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.
-    pub account: AccountId,
+    /// Filter to postings owned by this base account.
+    pub account: i64,
+    /// Restrict to one subaccount; `None` spans every subaccount of `account`.
+    pub sub: Option<i64>,
     /// 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.
-    pub account: Option<AccountId>,
+    /// Filter to transfers involving this base account.
+    pub account: Option<i64>,
+    /// Restrict to one subaccount; `None` spans every subaccount of `account`.
+    pub sub: Option<i64>,
     /// Inclusive lower bound (unix millis).
     pub from_ts: Option<i64>,
     /// Exclusive upper bound (unix millis).
@@ -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
+    /// of `id`; `sub == Some(s)` restricts to that one subaccount.
     async fn get_postings_by_account(
         &self,
-        account: &AccountId,
+        id: i64,
+        sub: Option<i64>,
         asset: Option<&AssetId>,
         status: Option<PostingStatus>,
     ) -> Result<Vec<Posting>, StoreError>;
@@ -147,7 +154,7 @@ 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;
@@ -172,10 +179,12 @@ pub trait TransferStore: Send + Sync {
         record: EnvelopeRecord,
         involved: &[AccountId],
     ) -> Result<u64, StoreError>;
-    /// Return all transfers involving the given account.
+    /// Return all transfers involving the given base account. `sub == None`
+    /// spans every subaccount of `id`; `sub == Some(s)` restricts to one.
     async fn get_transfers_for_account(
         &self,
-        account: &AccountId,
+        id: i64,
+        sub: Option<i64>,
     ) -> Result<Vec<EnvelopeRecord>, StoreError>;
 
     /// Query transfers with filtering and pagination.
@@ -184,8 +193,8 @@ pub trait TransferStore: Send + Sync {
         query: &TransferQuery,
     ) -> Result<Page<EnvelopeRecord>, StoreError> {
         // Default in-memory implementation
-        let all = if let Some(ref account) = query.account {
-            self.get_transfers_for_account(account).await?
+        let all = if let Some(account) = query.account {
+            self.get_transfers_for_account(account, query.sub).await?
         } else {
             return Err(StoreError::Internal(
                 "query_transfers requires account filter in default implementation".into(),

+ 89 - 20
crates/kuatia-storage/src/store_tests.rs

@@ -49,6 +49,25 @@ fn make_posting(
     )
 }
 
+fn make_posting_sub(
+    transfer_hash: [u8; 32],
+    index: u16,
+    owner: i64,
+    sub: i64,
+    asset: u32,
+    value: i64,
+) -> Posting {
+    Posting::new(
+        PostingId {
+            transfer: EnvelopeId(transfer_hash),
+            index,
+        },
+        AccountId::with_sub(owner, sub),
+        AssetId::new(asset),
+        Cent::from(value),
+    )
+}
+
 fn make_envelope_with_book(book: BookId) -> (Envelope, EnvelopeId) {
     let t = EnvelopeBuilder::new()
         .creates(vec![
@@ -275,25 +294,72 @@ 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(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(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(1, None, None, Some(PostingStatus::Active))
         .await
         .unwrap();
     assert_eq!(active.len(), 2);
 }
 
+/// Postings are segregated by subaccount: reading a base id spans every
+/// subaccount, a subaccount filter restricts to one, and no read ever sums
+/// across subaccounts.
+pub async fn get_postings_by_subaccount(store: &(impl Store + 'static)) {
+    // Base account 1 holds three postings across two subaccounts of asset 1.
+    let main = make_posting_sub([7; 32], 0, 1, 0, 1, 100);
+    let sub7a = make_posting_sub([7; 32], 1, 1, 7, 1, 200);
+    let sub7b = make_posting_sub([7; 32], 2, 1, 7, 1, 50);
+    seed_active(store, 0, &[main, sub7a, sub7b]).await;
+
+    // sub = None spans every subaccount of base id 1.
+    let all = store
+        .get_postings_by_account(1, None, Some(&AssetId::new(1)), None)
+        .await
+        .unwrap();
+    assert_eq!(all.len(), 3);
+
+    // sub = Some(0) is the main account only.
+    let main_only = store
+        .get_postings_by_account(1, Some(0), Some(&AssetId::new(1)), None)
+        .await
+        .unwrap();
+    assert_eq!(main_only.len(), 1);
+    assert_eq!(main_only[0].value, Cent::from(100));
+    assert_eq!(main_only[0].owner, AccountId::new(1));
+
+    // sub = Some(7) is that subaccount only; its two postings are never folded
+    // into the main account's figure.
+    let sub_only = store
+        .get_postings_by_account(1, Some(7), Some(&AssetId::new(1)), None)
+        .await
+        .unwrap();
+    assert_eq!(sub_only.len(), 2);
+    assert!(
+        sub_only
+            .iter()
+            .all(|p| p.owner == AccountId::with_sub(1, 7))
+    );
+
+    // A subaccount that was never used returns nothing.
+    let empty = store
+        .get_postings_by_account(1, Some(9), None, None)
+        .await
+        .unwrap();
+    assert!(empty.is_empty());
+}
+
 /// Query postings with pagination.
 pub async fn query_postings_pagination(store: &(impl Store + 'static)) {
     // Create 5 postings for account 1, asset 1
@@ -305,7 +371,8 @@ pub async fn query_postings_pagination(store: &(impl Store + 'static)) {
     // Page 1: first 2
     let page1 = store
         .query_postings(&PostingQuery {
-            account: AccountId::new(1),
+            account: 1,
+            sub: None,
             asset: None,
             status: None,
             limit: Some(2),
@@ -319,7 +386,8 @@ pub async fn query_postings_pagination(store: &(impl Store + 'static)) {
     // Page 2: next 2
     let page2 = store
         .query_postings(&PostingQuery {
-            account: AccountId::new(1),
+            account: 1,
+            sub: None,
             asset: None,
             status: None,
             limit: Some(2),
@@ -333,7 +401,8 @@ pub async fn query_postings_pagination(store: &(impl Store + 'static)) {
     // Page 3: last 1
     let page3 = store
         .query_postings(&PostingQuery {
-            account: AccountId::new(1),
+            account: 1,
+            sub: None,
             asset: None,
             status: None,
             limit: Some(2),
@@ -347,7 +416,8 @@ pub async fn query_postings_pagination(store: &(impl Store + 'static)) {
     // With asset filter
     let filtered = store
         .query_postings(&PostingQuery {
-            account: AccountId::new(1),
+            account: 1,
+            sub: None,
             asset: Some(AssetId::new(1)),
             status: None,
             limit: Some(10),
@@ -604,7 +674,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(1, None)
             .await
             .unwrap()
             .len(),
@@ -702,16 +772,10 @@ pub async fn get_transfers_for_account(store: &(impl Store + 'static)) {
     let (envelope, tid) = make_envelope();
     commit_envelope(store, envelope, tid, 1000).await;
 
-    let records = store
-        .get_transfers_for_account(&AccountId::new(1))
-        .await
-        .unwrap();
+    let records = store.get_transfers_for_account(1, None).await.unwrap();
     assert_eq!(records.len(), 1);
 
-    let empty = store
-        .get_transfers_for_account(&AccountId::new(999))
-        .await
-        .unwrap();
+    let empty = store.get_transfers_for_account(999, None).await.unwrap();
     assert!(empty.is_empty());
 }
 
@@ -738,7 +802,8 @@ pub async fn query_transfers_by_date_range(store: &(impl Store + 'static)) {
 
     let page = store
         .query_transfers(&TransferQuery {
-            account: Some(AccountId::new(1)),
+            account: Some(1),
+            sub: None,
             from_ts: Some(1500),
             ..Default::default()
         })
@@ -761,7 +826,8 @@ pub async fn query_transfers_pagination(store: &(impl Store + 'static)) {
 
     let page = store
         .query_transfers(&TransferQuery {
-            account: Some(AccountId::new(1)),
+            account: Some(1),
+            sub: None,
             limit: Some(2),
             offset: Some(0),
             ..Default::default()
@@ -773,7 +839,8 @@ pub async fn query_transfers_pagination(store: &(impl Store + 'static)) {
 
     let page2 = store
         .query_transfers(&TransferQuery {
-            account: Some(AccountId::new(1)),
+            account: Some(1),
+            sub: None,
             limit: Some(2),
             offset: Some(2),
             ..Default::default()
@@ -794,7 +861,8 @@ pub async fn query_transfers_by_book(store: &(impl Store + 'static)) {
 
     let page = store
         .query_transfers(&TransferQuery {
-            account: Some(AccountId::new(1)),
+            account: Some(1),
+            sub: None,
             book: Some(BookId(5)),
             ..Default::default()
         })
@@ -956,6 +1024,7 @@ macro_rules! store_tests {
             commit_creates_postings,
             get_postings_missing_fails,
             get_postings_by_account_filters,
+            get_postings_by_subaccount,
             query_postings_pagination,
             reserve_postings_batch,
             reserve_skips_non_active,

+ 399 - 9
crates/kuatia-types/src/lib.rs

@@ -10,6 +10,7 @@ pub mod autoid;
 use serde::{Deserialize, Serialize};
 use std::collections::BTreeMap;
 use std::fmt;
+use std::sync::atomic::{AtomicU64, Ordering};
 
 // ---------------------------------------------------------------------------
 // ToBytes trait
@@ -28,7 +29,9 @@ pub trait ToBytes {
 
 /// Version byte prepended to canonical serializations for forward compatibility.
 /// Bumped to 2 when `Cent` moved to a fixed 16-byte canonical encoding (ADR-0011).
-pub const CANONICAL_VERSION: u8 = 2;
+/// Bumped to 3 when `AccountId` gained a `subaccount` leg folded into its
+/// canonical bytes (ADR-0012).
+pub const CANONICAL_VERSION: u8 = 3;
 
 /// Append a `u16` in big-endian to `buf`.
 pub fn write_u16(buf: &mut Vec<u8>, v: u16) {
@@ -60,8 +63,19 @@ pub fn write_u128(buf: &mut Vec<u8>, v: u128) {
 // ---------------------------------------------------------------------------
 
 /// Stable account identity. Used in all public APIs.
+///
+/// An account is a base `id` plus a `subaccount`. `sub = 0` is the main account
+/// (the default when subaccounts are not used); a non-zero `sub` is a
+/// subaccount of the same base id. `sub` is an opaque id (an `i64`, like the
+/// base id), so the whole range is usable. Each `(id, sub)` is a full account
+/// record with its own policy and lifecycle. See ADR-0012.
 #[derive(Clone, Copy, PartialEq, Eq, Hash, PartialOrd, Ord, Serialize, Deserialize)]
-pub struct AccountId(pub i64);
+pub struct AccountId {
+    /// Base account id.
+    pub id: i64,
+    /// Subaccount id; `0` is the main account.
+    pub sub: i64,
+}
 
 /// Pairs an [`AccountId`] with a snapshot hash — the double-SHA256 of the
 /// account's state at a point in time. Stored on [`Transfer`] to record which
@@ -115,7 +129,25 @@ impl ToBytes for Cent {
 
 impl fmt::Debug for AccountId {
     fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
-        write!(f, "AccountId({})", self.0)
+        if self.sub == 0 {
+            write!(f, "AccountId({})", self.id)
+        } else {
+            write!(f, "AccountId({}.{})", self.id, self.sub)
+        }
+    }
+}
+
+impl fmt::Display for AccountId {
+    /// IBAN-style machine format: two ISO 7064 mod-97 check digits, then a
+    /// 26-character base-36 body. There is no country code. The `(id, sub)` pair
+    /// is run through a keyed 128-bit Feistel permutation (see [`set_id_seed`])
+    /// before encoding, so the body does not reveal the raw ids. Round-trips via
+    /// [`FromStr`](std::str::FromStr); [`to_grouped`](AccountId::to_grouped) adds
+    /// the presentation spacing.
+    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
+        let (l, r) = feistel(self.id as u64, self.sub as u64, id_seed());
+        let body = format!("{}{}", base36_u64(l), base36_u64(r));
+        write!(f, "{:02}{body}", check_digits(&body))
     }
 }
 
@@ -145,6 +177,125 @@ fn hex(bytes: &[u8]) -> String {
 }
 
 // ---------------------------------------------------------------------------
+// IBAN-style string view for AccountId (ADR-0012)
+// ---------------------------------------------------------------------------
+
+/// Encode a `u64` as exactly 13 base-36 digits (`0-9A-Z`), zero-padded on the
+/// left. 13 digits is the widest a `u64` needs (`36^13 > u64::MAX`), so this
+/// never truncates.
+fn base36_u64(mut v: u64) -> String {
+    const D: &[u8; 36] = b"0123456789ABCDEFGHIJKLMNOPQRSTUVWXYZ";
+    let mut out = [b'0'; 13];
+    let mut i = out.len();
+    while v > 0 && i > 0 {
+        i -= 1;
+        out[i] = D[(v % 36) as usize];
+        v /= 36;
+    }
+    out.iter().map(|&b| b as char).collect()
+}
+
+/// Expand an IBAN string to its numeric form for the checksum: digits stay,
+/// letters `A-Z` become `10..35`.
+fn iban_expand(s: &str) -> String {
+    let mut out = String::with_capacity(s.len() * 2);
+    for c in s.bytes() {
+        if c.is_ascii_digit() {
+            out.push(c as char);
+        } else {
+            let v = (c - b'A') as u32 + 10;
+            out.push_str(&v.to_string());
+        }
+    }
+    out
+}
+
+/// ISO 7064 mod-97-10 over a decimal string, computed iteratively so the input
+/// length is unbounded.
+fn mod97(digits: &str) -> u32 {
+    let mut rem = 0u32;
+    for b in digits.bytes() {
+        rem = (rem * 10 + (b - b'0') as u32) % 97;
+    }
+    rem
+}
+
+/// The two mod-97 check digits for a base-36 body, IBAN-style but with no
+/// country code: `98 - (expand(body ++ "00") mod 97)`.
+fn check_digits(body: &str) -> u32 {
+    98 - mod97(&iban_expand(&format!("{body}00")))
+}
+
+// ---------------------------------------------------------------------------
+// Account-code obfuscation (ADR-0012)
+//
+// The account code's body is a base-36 rendering of the two i64 legs. Without
+// mixing, small ids render as long runs of zeros that reveal their value and
+// sequence. To hide that from outsiders, the (id, sub) pair is run through a
+// keyed 128-bit Feistel permutation before encoding, and inverted on parse.
+// This is obfuscation, not security: anyone with the seed can decode it, so it
+// is not a substitute for authorization. The seed has a default and can be set
+// once at startup via `set_id_seed`; changing it changes every code, so it must
+// be stable across a deployment.
+// ---------------------------------------------------------------------------
+
+/// Default seed for the account-code obfuscation permutation. Override at
+/// startup with [`set_id_seed`], before any code is issued or parsed.
+pub const DEFAULT_ID_SEED: u64 = 0x9E37_79B9_7F4A_7C15;
+
+/// Process-global seed keying the account-code permutation.
+static ID_SEED: AtomicU64 = AtomicU64::new(DEFAULT_ID_SEED);
+
+/// Set the process-global seed that keys the account-code obfuscation. Call once
+/// at startup: every [`AccountId`] string form depends on it, so changing it
+/// after codes are issued invalidates the previously issued ones.
+pub fn set_id_seed(seed: u64) {
+    ID_SEED.store(seed, Ordering::Relaxed);
+}
+
+/// The current process-global account-code seed.
+pub fn id_seed() -> u64 {
+    ID_SEED.load(Ordering::Relaxed)
+}
+
+/// Number of Feistel rounds. Four rounds of a strong round function give a
+/// strong pseudo-random permutation (Luby-Rackoff), which is ample for
+/// obfuscation.
+const FEISTEL_ROUNDS: usize = 4;
+
+/// SplitMix64 finalizer: a strong 64-bit avalanche mixer.
+fn mix64(mut z: u64) -> u64 {
+    z = (z ^ (z >> 30)).wrapping_mul(0xBF58_476D_1CE4_E5B9);
+    z = (z ^ (z >> 27)).wrapping_mul(0x94D0_49BB_1331_11EB);
+    z ^ (z >> 31)
+}
+
+/// Per-round subkey derived from the seed and round index.
+fn round_key(seed: u64, round: usize) -> u64 {
+    mix64(seed ^ (round as u64).wrapping_mul(0x9E37_79B9_7F4A_7C15))
+}
+
+/// Keyed 128-bit Feistel permutation over the two halves `(l, r)`.
+fn feistel(mut l: u64, mut r: u64, seed: u64) -> (u64, u64) {
+    for round in 0..FEISTEL_ROUNDS {
+        let next = l ^ mix64(r ^ round_key(seed, round));
+        l = r;
+        r = next;
+    }
+    (l, r)
+}
+
+/// Inverse of [`feistel`] under the same seed.
+fn feistel_inv(mut l: u64, mut r: u64, seed: u64) -> (u64, u64) {
+    for round in (0..FEISTEL_ROUNDS).rev() {
+        let prev = r ^ mix64(l ^ round_key(seed, round));
+        r = l;
+        l = prev;
+    }
+    (l, r)
+}
+
+// ---------------------------------------------------------------------------
 // Identifier constructors
 // ---------------------------------------------------------------------------
 
@@ -153,14 +304,50 @@ impl Default for AccountId {
         // Process-global generator: a per-thread one could mint the same id on
         // two threads within a millisecond, yielding duplicate account ids.
         static GEN: crate::autoid::AutoId = crate::autoid::AutoId::new();
-        Self(GEN.next())
+        Self {
+            id: GEN.next(),
+            sub: 0,
+        }
     }
 }
 
 impl AccountId {
-    /// Create an `AccountId` from an `i64`.
+    /// Create the main account (`sub = 0`) for a base `id`.
     pub const fn new(id: i64) -> Self {
-        Self(id)
+        Self { id, sub: 0 }
+    }
+
+    /// Create a specific subaccount of a base `id`.
+    pub const fn with_sub(id: i64, sub: i64) -> Self {
+        Self { id, sub }
+    }
+
+    /// Return the main account of this id (`sub` set to `0`).
+    pub const fn base(&self) -> Self {
+        Self {
+            id: self.id,
+            sub: 0,
+        }
+    }
+
+    /// Whether this is the main account (`sub == 0`).
+    pub const fn is_main(&self) -> bool {
+        self.sub == 0
+    }
+
+    /// IBAN-style presentation format: the machine [`Display`](fmt::Display)
+    /// form grouped into blocks of four with a single space
+    /// (e.g. `9200 0000 0000 0050 0000 0000 07`).
+    pub fn to_grouped(&self) -> String {
+        let machine = self.to_string();
+        let mut out = String::with_capacity(machine.len() + machine.len() / 4);
+        for (i, c) in machine.chars().enumerate() {
+            if i > 0 && i % 4 == 0 {
+                out.push(' ');
+            }
+            out.push(c);
+        }
+        out
     }
 }
 
@@ -170,6 +357,62 @@ impl From<AccountSnapshotId> for AccountId {
     }
 }
 
+/// Returned when a string is not a valid [`AccountId`] code: wrong structure,
+/// non-base-36 body, or a failed mod-97 checksum.
+#[derive(Debug, Clone, PartialEq, Eq)]
+pub struct ParseAccountIdError;
+
+impl fmt::Display for ParseAccountIdError {
+    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
+        write!(f, "invalid AccountId: not a checksum-valid account code")
+    }
+}
+
+impl std::error::Error for ParseAccountIdError {}
+
+impl std::str::FromStr for AccountId {
+    type Err = ParseAccountIdError;
+
+    /// Parse an IBAN-style account code back into the two i64 legs. Spaces
+    /// (grouped format) and dashes are ignored and input is upper-cased first.
+    /// The value must be two check digits followed by a 26-character base-36
+    /// body, and the ISO 7064 mod-97 checksum must pass — so a mistyped or
+    /// otherwise invalid id is rejected here rather than reaching the store.
+    /// Each 13-char half is read as a `u64` bit pattern and reinterpreted as
+    /// `i64`.
+    fn from_str(s: &str) -> Result<Self, Self::Err> {
+        let cleaned: String = s
+            .chars()
+            .filter(|c| !c.is_whitespace() && *c != '-')
+            .map(|c| c.to_ascii_uppercase())
+            .collect();
+        // 2 check digits + 26-char base-36 body.
+        if cleaned.len() != 28 {
+            return Err(ParseAccountIdError);
+        }
+        let check = &cleaned[0..2];
+        let body = &cleaned[2..28];
+        let is_base36 = |b: u8| b.is_ascii_digit() || b.is_ascii_uppercase();
+        if !check.bytes().all(|b| b.is_ascii_digit()) || !body.bytes().all(is_base36) {
+            return Err(ParseAccountIdError);
+        }
+        // Checksum-valid iff the expanded (body ++ check) reduces to 1 under
+        // mod-97.
+        if mod97(&iban_expand(&format!("{body}{check}"))) != 1 {
+            return Err(ParseAccountIdError);
+        }
+        // Decode the two halves, then invert the Feistel permutation to recover
+        // the raw legs.
+        let l = u64::from_str_radix(&body[0..13], 36).map_err(|_| ParseAccountIdError)?;
+        let r = u64::from_str_radix(&body[13..26], 36).map_err(|_| ParseAccountIdError)?;
+        let (id, sub) = feistel_inv(l, r, id_seed());
+        Ok(Self {
+            id: id as i64,
+            sub: sub as i64,
+        })
+    }
+}
+
 impl AssetId {
     /// Create an `AssetId` from a `u32`.
     pub const fn new(id: u32) -> Self {
@@ -737,6 +980,25 @@ impl TransferBuilder {
         self.movement(from, to, asset, amount)
     }
 
+    /// Add a movement between two specific subaccounts. Identical to
+    /// [`movement`](Self::movement) — `from`/`to` already carry a subaccount —
+    /// but names the subaccount intent at the call site (ADR-0012).
+    pub fn movement_ref(
+        self,
+        from: AccountId,
+        to: AccountId,
+        asset: AssetId,
+        amount: Cent,
+    ) -> Self {
+        self.movement(from, to, asset, amount)
+    }
+
+    /// Add a pay movement between two specific subaccounts. See
+    /// [`movement_ref`](Self::movement_ref).
+    pub fn pay_ref(self, from: AccountId, to: AccountId, asset: AssetId, amount: Cent) -> Self {
+        self.movement(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.
@@ -794,14 +1056,19 @@ impl TransferBuilder {
 
 impl ToBytes for AccountId {
     fn to_bytes(&self) -> Vec<u8> {
-        self.0.to_be_bytes().to_vec()
+        // Base id then subaccount, both big-endian, so the subaccount is folded
+        // into every content hash (envelope ids, posting ids, snapshots).
+        let mut buf = Vec::with_capacity(16);
+        buf.extend_from_slice(&self.id.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(40);
-        buf.extend_from_slice(&self.account.0.to_be_bytes());
+        let mut buf = Vec::with_capacity(48);
+        buf.extend_from_slice(&self.account.to_bytes());
         buf.extend_from_slice(&self.snapshot_id);
         buf
     }
@@ -965,3 +1232,126 @@ impl ToBytes for Receipt {
         self.transfer_id.0.to_vec()
     }
 }
+
+#[cfg(test)]
+mod account_id_tests {
+    use super::*;
+    use std::str::FromStr;
+
+    #[test]
+    fn code_structure() {
+        let s = AccountId::with_sub(5, 7).to_string();
+        // 2 check digits + 26-char base-36 body. No country code.
+        assert_eq!(s.len(), 28);
+        assert!(s[0..2].bytes().all(|b| b.is_ascii_digit()));
+        // The body is Feistel-permuted, so it does NOT expose the raw legs the
+        // way an unmixed base-36 rendering (all zeros then "5"/"7") would.
+        assert_ne!(&s[2..], "00000000000050000000000007");
+    }
+
+    #[test]
+    fn code_round_trips() {
+        for acc in [
+            AccountId::new(0),
+            AccountId::new(100),
+            AccountId::with_sub(5, 7),
+            // High-bit subaccount: exercises the u64-bit-pattern reinterpretation.
+            AccountId::with_sub(1, -1),
+            AccountId::with_sub(i64::MAX, i64::MIN),
+        ] {
+            let s = acc.to_string();
+            assert_eq!(AccountId::from_str(&s).unwrap(), acc, "round-trip {s}");
+        }
+    }
+
+    #[test]
+    fn parses_a_fixed_vector() {
+        // A hardcoded, checksum-valid code (under DEFAULT_ID_SEED) pins the
+        // exact encoding, permutation, and checksum, so an accidental change to
+        // any of them is caught by a failing parse.
+        let code = "123PER2Q81K52QL1HA26CYE1IZH5";
+        let expected = AccountId::with_sub(987654321, 12345);
+        assert_eq!(AccountId::from_str(code).unwrap(), expected);
+        // The grouped (spaced, lower-cased) form parses to the same value.
+        assert_eq!(
+            AccountId::from_str("123p er2q 81k5 2ql1 ha26 cye1 izh5").unwrap(),
+            expected
+        );
+        // Display reproduces the exact machine form.
+        assert_eq!(expected.to_string(), code);
+    }
+
+    #[test]
+    fn feistel_is_invertible_across_seeds() {
+        for &seed in &[0u64, 1, DEFAULT_ID_SEED, u64::MAX] {
+            for &(l, r) in &[(0u64, 0u64), (5, 7), (u64::MAX, 1), (42, u64::MAX)] {
+                let (el, er) = feistel(l, r, seed);
+                assert_eq!(feistel_inv(el, er, seed), (l, r), "seed={seed} l={l} r={r}");
+            }
+        }
+    }
+
+    #[test]
+    fn obfuscation_hides_structure() {
+        // The default seed is in force.
+        assert_eq!(id_seed(), DEFAULT_ID_SEED);
+        // Sequential base ids do not produce visibly related codes (avalanche).
+        let a = AccountId::new(100).to_string();
+        let b = AccountId::new(101).to_string();
+        let shared = a.bytes().zip(b.bytes()).take_while(|(x, y)| x == y).count();
+        assert!(shared < 4, "codes share too long a prefix: {a} vs {b}");
+        // A base account and its subaccount are likewise not obviously related.
+        let main = AccountId::new(100).to_string();
+        let sub = AccountId::with_sub(100, 1).to_string();
+        assert_ne!(main, sub);
+    }
+
+    #[test]
+    fn grouped_format_groups_by_four_and_re_parses() {
+        let acc = AccountId::with_sub(5, 7);
+        let grouped = acc.to_grouped();
+        assert!(grouped.contains(' '));
+        assert!(grouped.split(' ').all(|g| g.len() <= 4));
+        // Grouped format (with spaces) and lower case both parse back.
+        assert_eq!(AccountId::from_str(&grouped).unwrap(), acc);
+        assert_eq!(AccountId::from_str(&grouped.to_lowercase()).unwrap(), acc);
+    }
+
+    #[test]
+    fn from_str_rejects_bad_checksum_and_junk() {
+        let good = AccountId::with_sub(5, 7).to_string();
+        assert!(AccountId::from_str(&good).is_ok());
+
+        // A helper to overwrite one character while keeping the length.
+        let with_char_at = |i: usize, c: char| {
+            let mut v: Vec<char> = good.chars().collect();
+            v[i] = c;
+            v.into_iter().collect::<String>()
+        };
+
+        // Flip the last body digit: still base-36 and right length, but the
+        // checksum no longer matches, so it is rejected.
+        let last = good.len() - 1;
+        let flipped = with_char_at(last, if good.ends_with('8') { '9' } else { '8' });
+        assert!(AccountId::from_str(&flipped).is_err(), "bad checksum");
+
+        // Structurally malformed inputs are all rejected.
+        assert!(AccountId::from_str("").is_err(), "empty");
+        assert!(AccountId::from_str("not-a-code").is_err(), "junk");
+        assert!(AccountId::from_str(&good[..27]).is_err(), "too short");
+        assert!(
+            AccountId::from_str(&format!("{good}0")).is_err(),
+            "too long"
+        );
+        // A check digit that is not a digit.
+        assert!(
+            AccountId::from_str(&with_char_at(0, 'A')).is_err(),
+            "alpha check"
+        );
+        // A non-base-36 character in the body (survives space/dash stripping).
+        assert!(
+            AccountId::from_str(&with_char_at(5, '*')).is_err(),
+            "non-base36 body"
+        );
+    }
+}

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

@@ -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.id, a.id.sub));
     for a in &accounts {
         println!("  {:?}  policy={:?}  v{}", a.id, a.policy, a.version);
     }

+ 78 - 12
crates/kuatia/src/ledger.rs

@@ -63,6 +63,16 @@ struct PendingSaga {
     phase: SagaPhase,
 }
 
+/// A single subaccount's balance for one asset. Balances are always reported
+/// per subaccount and never summed across them (ADR-0012).
+#[derive(Clone, Copy, PartialEq, Eq, Debug)]
+pub struct SubAccountBalance {
+    /// The subaccount this balance belongs to.
+    pub account: AccountId,
+    /// The balance of `account` for the queried asset.
+    pub value: Cent,
+}
+
 /// Async ledger resource composing the commit pipeline.
 pub struct Ledger {
     store: Arc<dyn Store>,
@@ -189,7 +199,12 @@ impl Ledger {
             }
             let available = self
                 .store
-                .get_postings_by_account(account, Some(asset), Some(PostingStatus::Active))
+                .get_postings_by_account(
+                    account.id,
+                    Some(account.sub),
+                    Some(asset),
+                    Some(PostingStatus::Active),
+                )
                 .await?;
             let total_positive = Cent::checked_sum(
                 available
@@ -653,7 +668,7 @@ impl Ledger {
     ) -> Result<Cent, LedgerError> {
         let postings = self
             .store
-            .get_postings_by_account(account, Some(asset), None)
+            .get_postings_by_account(account.id, Some(account.sub), Some(asset), None)
             .await?;
         Ok(Cent::checked_sum(
             postings
@@ -741,7 +756,7 @@ impl Ledger {
         // (or none) permit a close.
         let blocking = self
             .store
-            .get_postings_by_account(id, None, None)
+            .get_postings_by_account(id.id, Some(id.sub), None, None)
             .await?
             .into_iter()
             .any(|p| p.status != PostingStatus::Inactive);
@@ -763,12 +778,64 @@ impl Ledger {
         Ok(())
     }
 
-    /// Query the current balance of an account for a given asset.
+    /// Query the current balance of one subaccount for a given asset. This reads
+    /// exactly the `account` passed (base id and subaccount) and never rolls up
+    /// other subaccounts.
     #[instrument(skip(self), name = "ledger.balance")]
     pub async fn balance(&self, account: &AccountId, asset: &AssetId) -> Result<Cent, LedgerError> {
         self.compute_balance(account, asset).await
     }
 
+    /// Report the per-subaccount balances of a base account for one asset.
+    ///
+    /// One entry per non-closed subaccount. `sub == None` spans every
+    /// subaccount of `account`'s base id; `Some(s)` restricts to that one.
+    /// Balances are never summed across subaccounts (ADR-0012).
+    #[instrument(skip(self), name = "ledger.balances")]
+    pub async fn balances(
+        &self,
+        account: &AccountId,
+        asset: &AssetId,
+        sub: Option<i64>,
+    ) -> Result<Vec<SubAccountBalance>, LedgerError> {
+        let mut result = Vec::new();
+        for subaccount in self.list_subaccounts(account).await? {
+            if let Some(s) = sub
+                && subaccount.sub != s
+            {
+                continue;
+            }
+            let value = self.compute_balance(&subaccount, asset).await?;
+            result.push(SubAccountBalance {
+                account: subaccount,
+                value,
+            });
+        }
+        Ok(result)
+    }
+
+    /// List the non-closed subaccounts of a base account.
+    ///
+    /// This scans every account row and filters in memory, so it pays for
+    /// subaccounts that were created and later closed (ADR-0012).
+    #[instrument(skip(self), name = "ledger.list_subaccounts")]
+    pub async fn list_subaccounts(
+        &self,
+        account: &AccountId,
+    ) -> Result<Vec<AccountId>, LedgerError> {
+        let base = account.id;
+        let mut subs: Vec<AccountId> = self
+            .store
+            .list_accounts()
+            .await?
+            .into_iter()
+            .filter(|a| a.id.id == base && !a.is_closed())
+            .map(|a| a.id)
+            .collect();
+        subs.sort();
+        Ok(subs)
+    }
+
     // -----------------------------------------------------------------------
     // Query layer
     // -----------------------------------------------------------------------
@@ -786,12 +853,15 @@ impl Ledger {
             .map_err(|_| LedgerError::AccountNotFound(*id))
     }
 
-    /// Return all transfers involving the given account.
+    /// Return all transfers involving the given account (exact subaccount).
     pub async fn history(
         &self,
         account: &AccountId,
     ) -> Result<Vec<crate::store::EnvelopeRecord>, LedgerError> {
-        Ok(self.store.get_transfers_for_account(account).await?)
+        Ok(self
+            .store
+            .get_transfers_for_account(account.id, Some(account.sub))
+            .await?)
     }
 
     /// Query transfers with filtering and pagination.
@@ -809,7 +879,7 @@ impl Ledger {
     ) -> Result<Vec<kuatia_core::Posting>, LedgerError> {
         Ok(self
             .store
-            .get_postings_by_account(account, None, None)
+            .get_postings_by_account(account.id, Some(account.sub), None, None)
             .await?)
     }
 
@@ -1154,11 +1224,7 @@ mod recovery_tests {
         );
         let active = ledger
             .store()
-            .get_postings_by_account(
-                &AccountId::new(1),
-                Some(&AssetId::new(1)),
-                Some(PostingStatus::Active),
-            )
+            .get_postings_by_account(1, None, Some(&AssetId::new(1)), Some(PostingStatus::Active))
             .await
             .unwrap();
         assert_eq!(active.len(), 1); // back to Active

+ 94 - 4
crates/kuatia/tests/integration.rs

@@ -410,12 +410,12 @@ 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(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(50, None, Some(&eur()), Some(PostingStatus::Active))
         .await
         .unwrap();
 
@@ -539,7 +539,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(1, None, Some(&usd()), Some(PostingStatus::Active))
         .await
         .unwrap();
     ledger
@@ -778,7 +778,7 @@ async fn capped_overdraft_creates_negative_posting() {
     // 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(10, None, Some(&usd()), Some(PostingStatus::Active))
         .await
         .unwrap();
     assert!(postings.iter().any(|p| p.value == Cent::from(-50)));
@@ -906,3 +906,93 @@ async fn identical_transfers_share_envelope_id() {
     assert_eq!(a.book, b.book, "default book must be deterministic");
     assert_eq!(a.book, DEFAULT_BOOK);
 }
+
+// ---------------------------------------------------------------------------
+// Subaccounts (ADR-0012)
+// ---------------------------------------------------------------------------
+
+#[tokio::test]
+async fn subaccount_balances_are_segregated() {
+    let ledger = setup_ledger().await;
+    let sub = AccountId::with_sub(1, 7);
+    // A subaccount is a full account record with its own policy.
+    ledger
+        .store()
+        .create_account(Account::new(sub, AccountPolicy::NoOverdraft))
+        .await
+        .unwrap();
+
+    // Fund the main account (1, 0) and the subaccount (1, 7) independently.
+    deposit(&ledger, account(1), usd(), Cent::from(100), external()).await;
+    deposit(&ledger, sub, usd(), Cent::from(40), external()).await;
+
+    // balance() reads exactly one subaccount and never rolls up the other.
+    assert_eq!(
+        ledger.balance(&account(1), &usd()).await.unwrap(),
+        Cent::from(100)
+    );
+    assert_eq!(ledger.balance(&sub, &usd()).await.unwrap(), Cent::from(40));
+
+    // list_subaccounts spans the base id's subaccounts, sorted.
+    let subs = ledger.list_subaccounts(&account(1)).await.unwrap();
+    assert_eq!(subs, vec![account(1), sub]);
+
+    // balances() reports one entry per subaccount, never summed.
+    let all = ledger.balances(&account(1), &usd(), None).await.unwrap();
+    assert_eq!(all.len(), 2);
+    assert_eq!(
+        all.iter().find(|b| b.account == account(1)).unwrap().value,
+        Cent::from(100)
+    );
+    assert_eq!(
+        all.iter().find(|b| b.account == sub).unwrap().value,
+        Cent::from(40)
+    );
+
+    // A subaccount filter restricts to that one.
+    let just_sub = ledger.balances(&account(1), &usd(), Some(7)).await.unwrap();
+    assert_eq!(just_sub.len(), 1);
+    assert_eq!(just_sub[0].account, sub);
+    assert_eq!(just_sub[0].value, Cent::from(40));
+}
+
+#[tokio::test]
+async fn pay_moves_value_between_subaccounts() {
+    let ledger = setup_ledger().await;
+    let sub = AccountId::with_sub(1, 7);
+    ledger
+        .store()
+        .create_account(Account::new(sub, AccountPolicy::NoOverdraft))
+        .await
+        .unwrap();
+
+    deposit(&ledger, sub, usd(), Cent::from(40), external()).await;
+    // Move 30 from subaccount (1, 7) to the main account (1, 0).
+    pay(&ledger, sub, account(1), usd(), Cent::from(30)).await;
+
+    assert_eq!(ledger.balance(&sub, &usd()).await.unwrap(), Cent::from(10));
+    assert_eq!(
+        ledger.balance(&account(1), &usd()).await.unwrap(),
+        Cent::from(30)
+    );
+}
+
+#[tokio::test]
+async fn closed_subaccounts_drop_out_of_aggregate_reads() {
+    let ledger = setup_ledger().await;
+    let sub = AccountId::with_sub(1, 7);
+    ledger
+        .store()
+        .create_account(Account::new(sub, AccountPolicy::NoOverdraft))
+        .await
+        .unwrap();
+
+    // The subaccount is created then closed while empty.
+    ledger.close(&sub).await.unwrap();
+
+    // list_subaccounts and balances exclude the closed subaccount.
+    let subs = ledger.list_subaccounts(&account(1)).await.unwrap();
+    assert_eq!(subs, vec![account(1)]);
+    let all = ledger.balances(&account(1), &usd(), None).await.unwrap();
+    assert!(all.iter().all(|b| b.account != sub));
+}

+ 40 - 1
doc/accounts.md

@@ -13,7 +13,7 @@ the ledger balance.
 
 | Field | Type | Description |
 |-------|------|-------------|
-| `id` | `AccountId(i64)` | Stable identity, assigned at creation |
+| `id` | `AccountId { id: i64, sub: i64 }` | Stable identity: a base id plus a subaccount (`sub = 0` is the main account) |
 | `version` | `u64` | Starts at 1, increments on every mutation |
 | `policy` | `AccountPolicy` | Balance floor rule (see below) |
 | `flags` | `AccountFlags` | Lifecycle flags (`FROZEN`, `CLOSED`) + user-defined (`USER_0` to `USER_7`) |
@@ -21,6 +21,45 @@ the ledger balance.
 | `user_data` | `UserData` | Fixed 28 bytes: `u128 + u64 + u32` for external refs |
 | `metadata` | `Metadata` | `BTreeMap<String, Vec<u8>>` for free-form data |
 
+## Subaccounts
+
+An `AccountId` is a base `id` plus a `sub`. `sub = 0` is the account's main
+account; a non-zero `sub` is a subaccount of the same base id. Each `(id, sub)`
+is a full account record with its own policy, flags, book, version, and
+lifecycle, created, versioned, frozen, and closed exactly like any other
+account. A subaccount can be `NoOverdraft` while its base account is not, or the
+reverse, because every check keys on the full `AccountId`.
+
+Subaccounts partition one owner's holdings into several individually addressable
+balances (sub-ledgers, earmarks, reservations) without minting unrelated
+top-level accounts. Helpers on `AccountId`: `new(id)` (main account),
+`with_sub(id, sub)`, `base()` (the main account of an id), and `is_main()`.
+
+`AccountId` also has an IBAN-style string form (`Display` / `FromStr`): two ISO
+7064 mod-97 check digits, then a 26-character base-36 body carrying the base id
+and the subaccount (13 characters each; no country code).
+The `(id, sub)` pair is run through a keyed 128-bit Feistel permutation before
+encoding (and inverted on parse), so a code does not reveal the raw ids; the key
+is a global seed with a default, configurable via `set_id_seed`. Parsing
+validates the checksum, so a mistyped identifier is rejected. This is
+obfuscation, not security (the seed decodes it), and a presentation/routing form
+only; storage keeps the two `i64` legs.
+
+Balances are always reported per subaccount and are never summed across them:
+
+- `balance(&AccountId, &AssetId)` reads exactly one subaccount.
+- `balances(&AccountId, &AssetId, sub)` returns one entry per non-closed
+  subaccount (`sub = None` spans all, `Some(s)` filters to one).
+- `list_subaccounts(&AccountId)` lists the non-closed subaccounts of a base id.
+
+A base account does **not** roll up its subaccounts: there is deliberately no
+API that sums across them. Aggregate reads take a base `id: i64` plus an
+optional subaccount filter (`get_postings_by_account`,
+`get_transfers_for_account`); exact entity operations take the full
+`&AccountId`. Book membership is scoped by base account: a book that lists a base
+account admits all of that account's subaccounts. See
+[adr/0012-subaccounts.md](adr/0012-subaccounts.md).
+
 ## Policies
 
 Each account has a policy that controls what balance constraints apply:

+ 248 - 0
doc/adr/0012-subaccounts.md

@@ -0,0 +1,248 @@
+# Extend account identity with a subaccount dimension
+
+* Status: accepted
+* Authors: Cesar Rodas
+* Date: 2026-07-05
+* Targeted modules: `kuatia-types`, `kuatia-core`, `kuatia-storage`,
+  `kuatia-storage-sql`, `kuatia` (`ledger`), `kuatia-dashboard`
+* Associated tickets/PRs: N/A
+
+## Context and Problem Statement
+
+An account was identified by a single `i64` (`AccountId`). Some workloads need to
+partition one account's holdings into several distinct balances under the same
+owner: sub-ledgers, per-purpose buckets, earmarks, or reservations that are
+individually addressable and drained or closed independently, without minting
+unrelated top-level accounts. Classical accounting calls the general shape a
+control account with a subsidiary ledger; payment and banking systems call it a
+sub-ledger or a set of virtual accounts under a master account.
+
+We want that structure as a first-class part of account identity: an account is a
+base id plus a **subaccount**, and each partition is a full account record with
+its own policy, so no special-case code is needed to give a partition its own
+overdraft rule or lifecycle. The default subaccount (`0`) is the account's main
+account, so existing behaviour is unchanged when subaccounts are not used.
+
+## Decision Drivers
+
+* **Partitioning and attribution**: several balances under one owner, each
+  addressable and discoverable as a subaccount of the base account.
+* **Per-partition policy**: a subaccount must be able to carry its own policy,
+  flags, book, and version, independent of the base account.
+* **Segregated balances**: a base account's subaccounts must never be silently
+  summed into a single figure.
+* **Query by account or by subaccount**: reads must span all subaccounts or
+  restrict to one.
+* **Least churn and preserved invariants**: the change touches every layer that
+  keys on an account; conservation, double-spend, and floor checks must be
+  unchanged.
+
+## Considered Options
+
+#### Option 1: Fold the subaccount into `AccountId` (a composite `{id, sub}`, chosen)
+
+Make the account identity itself two legs: `AccountId { id: i64, sub: i64 }`, with
+`sub = 0` the main account. Aggregate reads take a base `id: i64` plus an optional
+subaccount filter.
+
+**Pros:**
+
+* Good, because there is one identity type: posting owners, movement endpoints,
+  account records, and balance keys are all `AccountId`, so per-subaccount
+  balances fall out of the existing keys with no new wrapper type.
+* Good, because "query by account or by subaccount" is explicit: base reads take
+  `(id: i64, sub: Option<i64>)` — `None` spans every subaccount, `Some(s)`
+  restricts to one — while entity ops take the full `&AccountId`.
+* Good, because each `(id, sub)` is a full account record with its own policy.
+
+**Cons:**
+
+* Bad, because callers that want a base handle read `account.id` rather than
+  passing a distinct base type; the split between base reads (`i64`) and exact
+  entity ops (`&AccountId`) has to be kept clear.
+* Bad, because it is a large, cross-crate change (the identity gains a field, `.0`
+  accesses become `.id`) plus a schema migration.
+
+#### Option 2: A separate `AccountRef { account, sub }` owner/identity type
+
+Keep `AccountId` as the i64 base and add a separate `AccountRef` wrapper as the
+owner/endpoint/entity identity.
+
+**Pros:**
+
+* Good, because the base `AccountId` stays a bare i64, so aggregate "all
+  subaccounts" reads keep a natural base handle.
+
+**Cons:**
+
+* Bad, because it adds a second account-identity type (`AccountId` vs
+  `AccountRef`) that every layer has to convert between.
+* Bad, because it is the same cross-crate churn as Option 1 without collapsing to
+  a single identity.
+
+#### Option 3: Subaccounts as balance buckets that inherit the parent policy
+
+Track a subaccount only on postings, with the account entity keyed by base id and
+its policy shared by all subaccounts.
+
+**Pros:**
+
+* Good, because the `accounts` table does not change.
+
+**Cons:**
+
+* Bad, because a subaccount cannot carry its own policy. A partition that must
+  stay `NoOverdraft` under a `SystemAccount`/overdraft base account could not,
+  so any structural guarantee that depends on the partition's own policy is lost.
+
+## Decision Outcome
+
+Chosen option: **Option 1, fold the subaccount into `AccountId`**, because a
+single two-leg identity keeps balances naturally segregated, lets every
+subaccount carry its own policy, and avoids carrying two account-identity types.
+
+### The identity type
+
+`AccountId { id: i64, sub: i64 }` (in `kuatia-types`). `sub = 0` is the main
+account; a non-zero `sub` is a subaccount. `sub` is an `i64`, the same type as
+the base id, so it stores directly in a `BIGINT` column with no cast.
+Constructors and helpers:
+
+* `AccountId::new(id)` — the main account `{ id, sub: 0 }`.
+* `AccountId::with_sub(id, sub)` — a specific subaccount.
+* `base()` — the main account of an id (`sub` set to `0`).
+* `is_main()` — whether `sub == 0`.
+
+`AccountId` derives `Copy`/`Eq`/`Hash`/`Ord` and its canonical `ToBytes` is the
+base id followed by the subaccount (both big-endian), so the subaccount is folded
+into every content hash (envelope ids, posting ids, account snapshots).
+
+### IBAN-style account code
+
+`AccountId` has an IBAN-style string form, so an identifier carries a checksum
+and a mistyped one is rejected before it reaches the store. The machine format
+is two ISO 7064 mod-97 check digits followed by a 26-character base-36 body,
+with no country code. `to_grouped()` adds a space every four characters for
+display.
+
+The body does not encode the raw legs directly. The `(id, sub)` pair is first
+run through a keyed 128-bit Feistel permutation, then each 64-bit half is
+base-36 encoded (13 characters each). Without this, small sequential ids would
+render as near-zero codes that leak their value and order, and a base account
+and its subaccount would share a visible prefix. After the permutation the codes
+look random and unrelated. Under the default seed, `AccountId { id: 5, sub: 7 }`
+renders `221RDWNSN4VCQNK2NN42KJFSAOLI` (grouped `221R DWNS N4VC QNK2 NN42 KJFS
+AOLI`).
+
+`FromStr` ignores spaces and dashes, upper-cases the input, checks the
+structure, and **validates the mod-97 checksum** (returning `ParseAccountIdError`
+on failure), then inverts the permutation to recover the two legs. Each half is
+read as a `u64` bit pattern and reinterpreted as `i64`, so any value
+round-trips.
+
+The permutation key is a process-global seed with a built-in default, settable
+once at startup with `set_id_seed` (the dashboard exposes it as `--id-seed` /
+`KUATIA_ID_SEED`). Changing the seed changes every code, so it must be stable
+across a deployment. This is obfuscation, not security: anyone with the seed can
+decode a code, so it is not a substitute for authorization.
+
+This is a presentation and edge form only. Storage and low-level usages keep the
+two `i64` legs: the SQL schema, the `Store` trait signatures and query types,
+in-memory keys, `ToBytes`, and serde (`{id, sub}`) are unchanged, so there is no
+migration and no content-hash impact. The dashboard exposes the string as a
+`code` field and routes account pages by the machine form (`/accounts/<code>`),
+parsing and checksum-validating it at the route boundary. `Debug` keeps the short
+`id` / `id.sub` form for logs.
+
+### The entity model
+
+Each `(id, sub)` is its own **full account record** with its own `policy`,
+`flags`, `book`, `version`, `user_data`, and `metadata`. The main account is
+`(id, 0)`. A subaccount is created, versioned, frozen, and closed exactly like any
+other account (closing still requires zero live postings), and its policy is
+enforced independently — a subaccount can be `NoOverdraft` while its base account
+is not, or vice versa.
+
+`AccountId` is the owner of a posting (`Posting.owner`, `NewPosting.owner`,
+`NewPosting.payer`), the endpoint of a movement (`Movement.from`/`to`), the id of
+an `Account`, and the subject of an `AccountSnapshotId`. `TransferBuilder::pay` and
+`movement` move between main accounts; `pay_ref` and `movement_ref` move between
+specific subaccounts.
+
+### Reads: by account or by subaccount
+
+Entity operations take the full `&AccountId` (exact): `get_account`,
+`get_accounts`, `append_account_version`, `get_account_history`. Aggregate reads
+take a base `id: i64` plus an optional subaccount:
+
+* `get_postings_by_account(id: i64, sub: Option<i64>, asset, status)` and
+  `get_transfers_for_account(id: i64, sub: Option<i64>)` span every subaccount
+  when `sub` is `None` and one when `Some(s)`.
+* `PostingQuery`/`TransferQuery` carry a base `account: i64` and `sub:
+  Option<i64>`.
+
+### Balances are always segregated
+
+Balances are reported per subaccount and never summed across them:
+
+* `Ledger::balance(&AccountId, &AssetId) -> Cent` reads exactly one subaccount.
+* `Ledger::balances(&AccountId, &AssetId, sub: Option<i64>) -> Vec<SubAccountBalance>`
+  returns one entry per non-closed subaccount (`sub = None` spans all, `Some(s)`
+  filters to one). There is deliberately no API that sums across subaccounts.
+* `Ledger::list_subaccounts(&AccountId) -> Vec<AccountId>` lists the non-closed
+  subaccounts of a base account.
+
+Closed subaccounts are excluded from the aggregate reads. This inverts the
+classical control-account expectation (where a parent's balance is the sum of its
+subsidiaries) on purpose: a base account does **not** roll up its subaccounts.
+
+### Validation and books
+
+Per-asset conservation and the balance-floor / negative-posting checks operate on
+the full `AccountId` owner, so they are per subaccount and use each subaccount's
+own policy. Book membership is scoped by **base account**: a book that lists a
+base account (or matches its flags) admits all of that account's subaccounts.
+
+### Storage schema and migration
+
+* `accounts` primary key becomes `(id, subaccount, version)`. `postings` gain a
+  `subaccount` column and `idx_postings_owner` widens to `(owner, subaccount,
+  asset, status)`. `transfer_accounts` gains `subaccount` in its key and index.
+* The subaccount is an `i64`, so it stores directly in a `BIGINT` column with no
+  cast (an opaque id, compared only for equality in SQL, never as a magnitude).
+* A `002_subaccounts` migration adds the column (existing rows default to
+  `subaccount = 0`, the main account) and rebuilds `accounts` /
+  `transfer_accounts` for the widened primary keys, since SQLite cannot alter a
+  primary key. `001_init.sql` is left intact. The in-memory store keys accounts by
+  the composite `AccountId` directly.
+
+### Positive Consequences
+
+* One account can carry several independent balances, each a full account record
+  with its own policy, discoverable via `list_subaccounts` and attributable by
+  shared base id.
+* Balances are always presented per subaccount, so a main account and its
+  subaccounts are never accidentally summed into one figure.
+* Conservation, double-spend, and floor guarantees are unchanged; they simply key
+  on the full `(id, sub)` owner.
+
+### Negative Consequences
+
+* Every content hash changes (the subaccount is folded into `AccountId`'s
+  canonical bytes) and the schema migrates. Existing data upgrades in place to
+  `subaccount = 0`.
+* Because accounts are append-only and never deleted, each subaccount that is
+  created and later closed leaves a permanent record (its versions plus its
+  inactive postings); the accounts and postings tables grow with the number of
+  subaccounts ever created, not the number currently open.
+* `list_subaccounts` and any "open subaccounts" scan currently read all account
+  rows and filter in memory, so they pay for closed subaccounts; a store with many
+  historical subaccounts would want an index on the not-closed set.
+* The base-id-vs-full-`AccountId` split (aggregate reads take `i64`, entity ops
+  take `&AccountId`) has to be kept clear at call sites.
+
+## Links
+
+* 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/glossary.md](../glossary.md).

+ 23 - 0
doc/glossary.md

@@ -31,6 +31,29 @@ 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.
 
+An account is identified by `AccountId { id: i64, sub: i64 }`: a base `id` plus a
+**subaccount**. `sub = 0` is the main account; a non-zero `sub` is a subaccount
+of the same base id, and each `(id, sub)` is a full account record with its own
+policy and lifecycle. Balances are reported per subaccount and never summed
+across them. See the Subaccount entry and
+[adr/0012-subaccounts.md](adr/0012-subaccounts.md).
+
+### Subaccount
+
+A partition of one owner's holdings under a shared base id. `AccountId::with_sub(id,
+sub)` names one; `AccountId::base()` returns the main account of an id. A base
+account does not roll up its subaccounts, so several independent balances live
+under one owner, each individually addressable, drained, and closed. Aggregate
+reads take a base `id` plus an optional subaccount filter; exact entity
+operations take the full `AccountId`.
+
+`AccountId` also has an IBAN-style string view via `Display` / `FromStr`: two
+mod-97 check digits + a base-36 body of the two legs (no country code), e.g.
+`9200000000000050000000000007` for `{ id: 5, sub: 7 }` (grouped:
+`9200 0000 0000 0050 0000 0000 07`). Parsing validates the checksum, rejecting
+mistyped ids. It is a presentation/routing form (the dashboard URLs); storage
+keeps the two `i64` legs.
+
 ### Asset
 
 An identifier (`AssetId(u32)`) representing a unit of value: a currency, a