RFC: Master Accounts and Subaccounts

Date: 2026-07-14

Status: Draft

Related: Lazy Account Provisioning this RFC is a large part of the "step 11" account-lifecycle surface that RFC explicitly deferred (self-serve creation of a second, non-default account); System User and System Trading Accounts the permission-grant guard on system accounts interacts with the new grant paths; Margin Groups margin stays per-account here, but a subaccount family is an obvious future margin-group candidate.

Background

The account model today is flat. trading_accounts (db/postgres/1.sql:551-585) has no parent pointer, no grouping table, and no hierarchy of any kind. Each account is optionally owned by one UBO ubo_user_id, "the user that 'owns' this account for KYC/tax/compliance purposes" (db/postgres/1.sql:577-582) and operational access is a separate M:N grant table:

CREATE TABLE account_permissions (
    user_id CHAR(16) NOT NULL REFERENCES users(id) ON DELETE CASCADE,
    account_id CHAR(16) NOT NULL REFERENCES trading_accounts(id) ON DELETE CASCADE,
    can_list BOOLEAN NOT NULL DEFAULT FALSE,
    can_read BOOLEAN NOT NULL DEFAULT FALSE,
    can_set_limits BOOLEAN NOT NULL DEFAULT FALSE,
    can_reduce_or_close BOOLEAN NOT NULL DEFAULT FALSE,
    can_trade BOOLEAN NOT NULL DEFAULT FALSE,
    ...
);

with a hierarchy encoded in AccountPermissions::allows (rs/sdk-internal/db/src/entities.rs:3410-3437): Trade ⇒ ReduceOrClose ⇒ Read ⇒ List, SetLimits ⇒ List. All permission grants are admin-only today PUT /admin/accounts/{id}/permissions/{user_id} (rs/api-gateway/src/admin_trading_account_routes.rs:352) and the admin-cli (rs/admin-cli/src/accounts.rs). There is no customer-facing surface for creating an account or granting anyone access to one.

Money movement is equally flat: there is no account-to-account transfer anywhere in the system. Every money event is a single-account NewTransaction (rs/transaction-engine/src/lib.rs:106-118) whose double-entry counterparty is always an AX system ledger deposits and withdrawals book against AX_DEPOSITOR_ACCOUNT_ID, funding against AX_FUNDING_ACCOUNT_ID (rs/risk-engine2/src/tigerbeetle_driver.rs:286-331). The customer GUI's "Transfer Funds" panel (gui/packages/app/screens/portfolio/components/Transfer/index.tsx) offers exactly two actions: Deposit and Withdraw.

The ask

Institutional customers want the standard exchange subaccount workflow: one legal entity (one UBO, one KYC) running several segregated books per desk, per strategy, per client with a designated operator who can:

  1. spin up new subaccounts without an admin ticket,
  2. delegate trading access on those subaccounts to their own people, and
  3. rebalance capital between the master account and its subaccounts,

all without any subaccount going through onboarding, because the beneficial owner and therefore the compliance standing is the same. The compliance half of this already works by construction: the order-gateway checks is_onboarded/is_frozen on the account's UBO, not the acting user (rs/order-gateway/src/state.rs:239-274), so an account minted with the same ubo_user_id inherits the owner's KYC status and freeze state automatically. What's missing is the hierarchy, the delegated authority, and the money movement.

Goals

  1. A master account can sprout subaccounts. A subaccount is an ordinary trading_accounts row same UBO as its master, its own venue identity, its own balances, margin, limits, and permissions plus a parent pointer. No new KYC; freezing the UBO freezes the whole family.
  2. can_master delegated family administration. A per-(user, master) grant that lets its holder create subaccounts under that master and manage (grant/revoke/edit) the base permissions on those subaccounts, from a customer-facing UI no admin involvement per subaccount or per trader.
  3. can_subtransfer delegated treasury, one-sided. A per-(user, master) grant that lets its holder move balance freely between the master and any of its subaccounts in both directions without holding any permission row on the subaccounts themselves. Master↔︎sub only; never sub↔︎sub directly.
  4. A transfers GUI. Users holding can_subtransfer on a master see a family view master and subaccount balances and a transfer form, plus the family's transfer history.
  5. Transfers are first-class ledger events. A transfer is one atomic two-leg transaction (debit one account, credit the other), visible in ClickHouse history for both accounts, booked in TigerBeetle, and subject to the same withdrawability constraints as an external withdrawal on the debited leg.

Non-Goals

Design

1. Schema

ALTER TABLE trading_accounts
    -- An account that may own subaccounts. Flipped by admins only.
    ADD COLUMN is_master BOOLEAN NOT NULL DEFAULT FALSE,
    -- Parent pointer: set iff this row is a subaccount. One level deep.
    ADD COLUMN master_account_id CHAR(16) REFERENCES trading_accounts(id),
    ADD CONSTRAINT master_has_no_parent
        CHECK (NOT (is_master AND master_account_id IS NOT NULL));
CREATE INDEX trading_accounts_master_account_id_idx
    ON trading_accounts (master_account_id);

ALTER TABLE account_permissions
    -- Family administration: create subaccounts under this (master) account
    -- and manage base permissions on them. Meaningful only on master rows.
    ADD COLUMN can_master BOOLEAN NOT NULL DEFAULT FALSE,
    -- Family treasury: transfer between this (master) account and its
    -- subaccounts, both directions. One-sided: no row needed on the subs.
    ADD COLUMN can_subtransfer BOOLEAN NOT NULL DEFAULT FALSE;
-- extend permission_coherence: the new powers presuppose seeing the account
    ... CHECK (
        NOT (can_set_limits OR can_reduce_or_close OR can_trade
             OR can_master OR can_subtransfer)
        OR (can_list AND can_read)
    );

Why an explicit parent pointer and not "same UBO". The ask phrases a subaccount as "just an additional account with the same UBO," and that is the compliance semantics but it cannot be the structural definition. A UBO may legitimately own accounts that are not part of the family (a pre-existing standalone account, or two independent masters), and can_master/can_subtransfer need a precise scope: "the subaccounts of this master," not "everything this UBO owns." So the parent pointer defines the family, and same-UBO becomes an invariant of the pointer: sub.ubo_user_id = master.ubo_user_id, enforced at every write path and by a recon check (cross-row, so a CHECK can't reach it same treatment as the system-account grant guard in system-accounts).

Write-path invariants (endpoint-enforced + recon-checked):

trading_accounts and account_permissions are both already on every relevant logical-replication publication (db/postgres/1.sql:1112-1115), so the new columns flow to api-gateway, order-gateway, and trade-engine replicas with no publication changes; existing consumers ignore columns they don't read.

2. Family-derived authority

Two new Action variants join the enum at rs/sdk-internal/db/src/entities.rs:3431-3437:

pub enum Action { List, Read, SetLimits, ReduceOrClose, Trade, Master, Subtransfer }

with allows: Master => can_master, Subtransfer => can_subtransfer (neither implies nor is implied by Trade treasury and administration are deliberately orthogonal to trading authority).

The genuinely new mechanism is derived authority on subaccounts. Both new grants live on the master row only ("one-sided"), but their holders need visibility into the subaccounts the manager to render the permissions UI, the treasurer to see the balances they're rebalancing. Rather than fanning out shadow account_permissions rows onto every subaccount (which would need lifecycle management on every subaccount create/revoke, and would blur which rows are direct grants), authorization becomes family-aware: the effective permission of (user, account) is the direct row, unioned when the account has a master_account_id with a derivation from the user's row on the master:

master-row grant derived on each subaccount
can_master List, Read
can_subtransfer List, Read

Nothing stronger is ever derived: neither grant confers Trade, ReduceOrClose, or SetLimits on a subaccount. A can_master holder who wants to trade a subaccount grants themself can_trade on it an explicit, audited row exactly as they would for anyone else.

Concretely: authorize (entities.rs:3454-3464) gains a family-aware variant that takes the accounts replica alongside the permissions replica (api-gateway already holds both, rs/api-gateway/src/utils.rs:185-210); given (user, S, action) where S.master_account_id = M, it checks the direct row and falls back to the derivation from (user, M). The authorize_with_api_key intersection (entities.rs:3488-3503) composes unchanged on top.

3. Subaccount lifecycle (customer-facing, gated Action::Master)

POST   /accounts/{master_id}/subaccounts     { name }        -> TradingAccountResponse
GET    /accounts/{master_id}/subaccounts                     -> [TradingAccountResponse]
PUT    /accounts/{sub_id}                    { name }        -- rename
PUT    /accounts/{sub_id}/close-only         { is_close_only }

Creation reuses the machinery admin create_account (rs/api-gateway/src/admin_trading_account_routes.rs:57-168) already has mint an id from state.account_id_generator, provision the EP3 participant/account (provision_ep3_trading_account is idempotent and account-keyed), insert the row with the family shape forced rather than requested:

This is materialize_account (lazy-provisioning §2) generalized from "the derived default id" to "a minted id with a parent" one primitive, two callers.

Edition note. Self-serve creation requires the edition to be able to mint venue identity on demand. The EP3 edition can (two idempotent gRPC RPCs); the Bitnomial/aiex edition cannot accounts must be pre-provisioned and paired with btnl_* identity by an admin (create_account's edition rules already encode this). On aiex, POST .../subaccounts is disabled and subaccount creation stays an admin action; the rest of this design (flags, transfers, GUI) works identically there once the rows exist.

Deletion stays admin-only (admin-cli accounts delete); customers get close-only at most. A closed-and-flat subaccount is cheap to keep.

4. Permission management (customer-facing, gated Action::Master on the sub's master)

GET    /accounts/{sub_id}/permissions                        -> [AccountPermissionResponse]
PUT    /accounts/{sub_id}/permissions/{user_id}   { can_list, can_read, can_set_limits,
                                                    can_reduce_or_close, can_trade }
DELETE /accounts/{sub_id}/permissions/{user_id}

Same request/response shapes as the admin endpoints (SetAccountPermissionsRequest, rs/sdk-internal/src/protocol.rs:1589-1594) so the sdk-internal types and the admin GUI's permission editor carry over. Guard rails, all endpoint-enforced:

5. The subtransfer primitive

A transfer is one event, two legs: -amount on the source account, +amount on the destination, sharing an event_id. The plumbing wants this shape already insert_transactions takes a Vec<NewTransaction> and applies it in one advisory-lock-serialized Postgres transaction (rs/transaction-engine/src/lib.rs:199-220), and NewTransaction targets an arbitrary account_id. What's new:

6. The transfer endpoint

POST /accounts/{master_id}/subtransfer   { from_account, to_account, amount }
GET  /accounts/{master_id}/subtransfers  -> family transfer history (both legs, CH-backed)

Authorization is entirely master-side: the caller must hold Action::Subtransfer on {master_id}, and {from_account, to_account} must be {master_id, S} in either order where S.master_account_id = master_id. No permission row on S is consulted that is the "one-sided" semantics. Consequences worth stating:

API keys. api_keys mirrors the permission flags and intersects with live grants (db/postgres/1.sql:25-48, api_key_permissions_allow, entities.rs:3473-3481). Add can_subtransfer to the key flags automated rebalancing between strategy subaccounts is a first-order programmatic use case. can_master stays session-only in v1; nobody should be minting subaccounts from a bot before the workflow has soaked.

7. GUI

Customer app (gui/packages/app): a Subaccounts section, visible when WhoAmI/MyAccountEntry (rs/sdk-internal/src/protocol.rs:1638-1651, extended with is_master, master_account_id, and the two new flags) shows the user holding either new grant on some master:

Admin GUI: is_master badge and a family tree on the account page; subaccounts grouped under their master in listings; the two new flags in the admin permission editor, with the master-only guard surfaced as a disabled state rather than a 4xx surprise.

8. What deliberately does not change

Affected components

Component Change
db/postgres/1.sql trading_accounts.is_master + master_account_id (+ CHECK, index); account_permissions.can_master + can_subtransfer (+ coherence CHECK); api_keys.can_subtransfer
rs/sdk-internal/db entities.rs New fields on DbTradingAccount / DbAccountPermission; Action::{Master, Subtransfer}; family-aware authorize
rs/sdk-internal protocol.rs Family fields on account/permission wire types; subaccount + subtransfer request/response types
rs/transaction-engine TransactionKind::Transfer; two-leg insert with debit-leg checks
rs/risk-engine2 RiskTransactionType::Transfer; TB direct customer↔︎customer booking; state handling without system-account mirror
rs/api-gateway Customer routes (§3, §4, §6); admin subtransfer; family-aware authorize_account; admin account routes learn is_master/parent + master-only grant guard
rs/order-gateway, rs/trade-engine2 Replica structs pick up new columns; no behavior change (verify with tests)
rs/recon-engine Family invariant checks (§1); transfer legs sum to zero per event_id
rs/admin-cli accounts subcommands for master flag, parent, new permission flags
gui/packages/app Subaccounts section: family view, transfer panel, manage panel
gui/apps/admin Master badge, family tree, new flags in permission editor
rs/sdk (public) Whatever subset of this surfaces publicly mind the leak rule; "subaccount transfer" is fine, internal store/service names are not

Rollout

  1. Schema + types columns, CHECKs, replica plumbing. Inert: nothing reads the new columns yet. Atlas-applied.
  2. Transfer primitive TransactionKind::Transfer end to end (engine CH RE/TB), admin-only endpoint first. Soak on demo with recon's zero-sum check watching. Lifecycle tests per CLAUDE.md: transfer racing a withdrawal on the debited account, api-gateway crash between commit and downstream consumption, restart/replay, and the concurrent double-submit (advisory lock + event_id idempotency).
  3. Family + delegation customer endpoints (§3, §4, §6), family-aware authorize, admin GUI surfaces. Grant can_master/can_subtransfer to a pilot customer by hand.
  4. Customer GUI family view, transfer panel, manage panel.
  5. No backfill. Existing accounts stay flat (is_master = FALSE, no parent). Admins flip is_master and/or adopt existing accounts under a master (set master_account_id, subject to the §1 invariants) per customer request.

Open questions

Appendix: source index

Concern Location
trading_accounts / ubo_user_id db/postgres/1.sql:551-585
account_permissions + coherence CHECK db/postgres/1.sql:652-666
api_keys flag mirror db/postgres/1.sql:25-48
Permission structs + allows hierarchy rs/sdk-internal/db/src/entities.rs:3261-3437
authorize / authorize_with_api_key rs/sdk-internal/db/src/entities.rs:3454-3503
authorize_account (gateway edge) rs/api-gateway/src/utils.rs:185-210
UBO-keyed compliance gate rs/order-gateway/src/state.rs:239-274
Admin account/permission routes rs/api-gateway/src/admin_trading_account_routes.rs:32-451
Admin deposit/withdraw rs/api-gateway/src/admin_routes.rs:1543,1613
NewTransaction / TransactionKind rs/transaction-engine/src/lib.rs:106-138
insert_transactions (advisory-lock tx) rs/transaction-engine/src/lib.rs:199-220
Deposit/withdrawal processing rs/api-gateway/src/utils.rs:1243-1317
Loan covenant on withdrawals rs/api-gateway/src/utils.rs:1281
TB double-entry booking rs/risk-engine2/src/tigerbeetle_driver.rs:286-331
RE transaction state rs/risk-engine2/src/state.rs:155-175, events.rs:17-33
current_balances db/postgres/1.sql:148-156
CH transactions schema rs/sdk-internal/clickhouse/src/schema.rs:2114-2127
Replication publications db/postgres/1.sql:1112-1115
Customer Transfer (deposit/withdraw) UI gui/packages/app/screens/portfolio/components/Transfer/index.tsx
Admin permission/funding UI gui/apps/admin/src/pages/users/
materialize_account primitive rs/api-gateway/src/auth.rs:265-365; lazy-account-provisioning §2