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.
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.
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:
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.
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.
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.
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.
can_subtransfer
on
a
master
see
a family
view
—
master
and
subaccount
balances
—
and
a
transfer
form,
plus the
family's
transfer
history.
ubo_user_id
remains
the
owner key;
this
RFC
leans
on
it
but
does
not
replace
it.
(It
arguably
delays the
need
for
one:
"one
entity,
many
accounts"
is
the
main
thing
an
entity table
would
buy.)
usd_balance (current_balances,
db/postgres/1.sql:148-156);
transfers
move
USD.
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):
master_account_id
may
only
reference
a
row
with
is_master = TRUE;
sub.ubo_user_id = master.ubo_user_id,
and
both
non-NULL
(a
system
account can
never
be
a
master
or
a
sub
—
its
UBO
is
the
system
user,
which
never gets
is_master);
can_master/can_subtransfer
may
only
be
granted
on
accounts
with is_master = TRUE;
is_master
off
requires
the
account
to
have
no
subaccounts
and
no can_master/can_subtransfer
grants.
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.
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.
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:
ubo_user_id := master.ubo_user_id,
master_account_id := master.id, is_master := FALSE;
maker_fee/taker_fee
copied
from
the
master
(the
family
is
one
customer; admins
can
diverge
a
subaccount's
fees
afterwards);
current_balances
row
seeded
(ON CONFLICT DO NOTHING,
per
the lazy-provisioning
idempotency
work);
account_permissions.full_access
row
for
the
creator
(they
created it
to
use
it
or
hand
it
out;
they
can
trim
their
own
row
afterwards).
No implicit
row
for
the
UBO
—
the
UBO's
operational
access
is
a
deliberate grant,
as
everywhere
else
post-multi-account.
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.
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:
master_account_id
pointing
at
a
master
on
which
the
caller
holds can_master.
The
master
account's
own
permission
rows
are
not
editable from
this
surface
—
grants
on
the
master
(including
can_master
and can_subtransfer
themselves)
remain
admin-only.
This
caps
the
blast radius:
a
rogue
or
compromised
manager
can
reshuffle
sub
access
but
cannot mint
more
managers,
cannot
grant
treasury
power,
and
cannot
lock
anyone
out of
the
master.
can_master/can_subtransfer
—
unrepresentable,
nothing
to validate
(they'd
also
be
meaningless
on
a
sub,
and
the
write-path
invariant in
§1
rejects
them
there
from
any
path).
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:
TransactionKind::Transfer
("transfer",
allows
USD).
Two
rows
land in
ClickHouse
transactions
(one
per
leg,
signed
amounts,
shared event_id);
reference_id
carries
the
counterparty
account
id
so
each account's
history
is
self-describing
without
a
join.
OverWithdrawal
on balance,
the
loan
covenant
(ensure_withdrawal_within_loan_covenant, rs/api-gateway/src/utils.rs:1281),
and
—
critically
—
the
account
must remain
adequately
margined
after
the
debit
if
it
has
open
positions. Moving
equity
out
of
a
sub
with
open
risk
is
economically
a
withdrawal; whatever
free-collateral
predicate
withdrawals
settle
on
(see
open questions
—
today's
check
is
balance-based,
not
margin-based)
applies identically
here.
The
credit
leg
needs
no
check.
RecordTransaction
events (rs/risk-engine2/src/events.rs:17-33)
fire
for
both
legs.
In
TigerBeetle the
pair
books
as
one
direct
customer↔︎customer
transfer
—
TB's primitive
is
a
credit/debit
pair,
and
unlike
deposits/withdrawals
there is
no
system-account
counterparty:
the
family's
money
never
leaves
the exchange,
and
exchange-wide
net
transfer
delta
is
zero.
In-memory (rs/risk-engine2/src/state.rs:155-175),
each
leg
adjusts
its
account's net_transfers_usd
with
no
system-account
mirror
entry.
current_balances
updates
+ both
CH-bound
rows)
commits
atomically
under
the
existing
advisory
lock; the
CH
append
and
TB
booking
follow
the
same
eventual
path
every deposit/withdrawal
takes
today.
No
new
consistency
machinery.
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:
Action::Trade
check on
whichever
account
it
exits
from.)
POST /admin/subtransfer,
god-mode,
any
family)
for
support operations,
mirroring
the
admin
deposit/withdraw
pair (rs/api-gateway/src/admin_routes.rs:1543,1613).
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.
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:
Read.
can_subtransfer):
from/to
selectors
constrained
to valid
master↔︎sub
pairs
(picking
a
sub
in
"from"
locks
"to"
to
the
master, and
vice
versa),
amount,
and
the
family
transfer
history
below.
Lives alongside
the
existing
Deposit/Withdraw
"Transfer
Funds"
component
rather than
inside
it
—
external
funding
and
internal
rebalancing
are
different operations
with
different
permission
gates.
can_master):
per-subaccount
list
of
(user, flags) rows
with
an
editor
for
the
five
base
flags,
grant-by-username,
revoke;
and a
"New
subaccount"
action.
Effectively
the
admin
GUI's
permission
tab scoped
to
one
family
—
reuse
its
patterns (gui/apps/admin/src/pages/users/).
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.
rs/order-gateway/src/state.rs:239-274);
subaccounts
inherit
standing
and freeze
with
zero
code
change.
This
is
the
load-bearing
reason
"same
UBO"
is the
right
ownership
shape.
blockchain_deposit_addresses.
| 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 |
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).
can_master/can_subtransfer
to
a pilot
customer
by
hand.
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.
OverWithdrawal)
plus
the
loan
covenant
—
is
that actually
margin-safe
for
an
account
with
open
positions,
and
if
not,
do
we fix
withdrawals
and
transfers
together
on
a
free-collateral
predicate
from the
risk
engine?
(Transfers
should
not
launch
with
a
weaker
check
than withdrawals,
nor
vice
versa.)
master_account_id
on
a pre-existing
standalone
account
(same
UBO)
seems
clearly
wanted
—
any reason
to
disallow?
What
about
detaching
a
sub
(clearing
the
pointer): require
flat
balance
first?
can_master
derive
SetLimits
on
subs?
The
manager
sets
up
the desk
—
do
they
also
set
its
loss
limits,
or
is
that
a
separate
explicit grant
per
sub?
Leaning
explicit
(limits
are
risk
controls;
keep
the
derived set
minimal).
Read.
can_read
conventionally
includes balances,
positions,
orders,
and
history.
Does
the
treasurer (can_subtransfer)
need
positions/orders,
or
only
balances?
Deriving
full Read
is
simpler
and
probably
fine
—
the
master-side
grant
is
already
a high-trust
role
—
but
confirm
with
the
pilot
customer.
initiated_by_user_id
display.
Transfer
legs
stamp
the
acting treasurer.
The
sub's
delegate-trader
sees
an
outflow
initiated
by
a
user who
holds
no
grant
on
their
account
—
make
sure
history
rendering ("internal
transfer
to/from
master")
reads
sensibly
rather
than
alarmingly.
accounts_monitor
consumers, and
decide
whether
admin-created
btnl
subaccounts
are
in
v1
scope
there.
| 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 |