Date: 2026-07-13
Status: Draft. AX's five internal bookkeeping ledgers become first-class Postgres rows: one Architect system user owning five system trading accounts. Phase 1 (the system user row) is in flight as PR #1935 (A-3338), approved on backend; this document records the design and the follow-on phases raised in review — the materialized accounts — now that multi-account has landed.
AX
runs
five
internal
bookkeeping
ledgers
that
exist
to
satisfy
double-entry invariants:
fee,
lending,
funding,
depositor,
and
pnl. Their
account
identifiers
are
frozen
constants
in
rs/sdk-internal:
account_id.rs
—
the
five
AX_*_ACCOUNT_ID
values
plus
is_system_account. These
are
the
canonical
ledger
identities:
minted
under
the
old
1:1
era,
they are
what
TigerBeetle
and
EP3
reconcile
against,
so
they
never
change.
user_id.rs
—
is_system_user
and,
historically,
one
AX_*_USER_ID
per ledger
(each
an
account's
bit-pattern
twin).
This
design
replaces
the
five user
ids
with
a
single
AX_SYSTEM_USER_ID;
see
below.
The account side of these ledgers is already load-bearing everywhere money moves — and it is the account id, not any user id, that money flows key on:
rs/risk-engine2/src/tigerbeetle_driver.rs)
books
every fee
against
AX_FEE_ACCOUNT_ID,
deposits/withdrawals
against AX_DEPOSITOR_ACCOUNT_ID,
funding
against
AX_FUNDING_ACCOUNT_ID,
and finalized
P&L
against
AX_PNL_ACCOUNT_ID.
fee_account_above_threshold
and
trade_fees_square query
current_balances
for
AX_FEE_ACCOUNT_ID
—
the
"fee
account
going negative"
wire
that
motivated
the
original
ask.
AX_LENDING_USER_ID.as_default_account_id() (lending.rs:60),
but
AX_LENDING_ACCOUNT_ID
already
exists
as
a
standalone constant,
so
the
leg
addresses
account_id
either
way.
transactions
rows
carry
account_id
per
leg.
Since
PR
#2610 (A-3402),
the
row's
initiated_by_user_id
records
the
actor
—
the
human
who caused
a
leg,
or
empty
for
system-generated
legs
—
not
the
owner;
a
lending pool
leg
written
today
carries
the
initiating
admin's
id.
Reads
that
identify a
ledger
key
on
account_id.
What
existed
in
Postgres,
until
A-3338,
was
nothing:
A-3151
/
PR
#1768 virtualized
the
users
at
the
admin-API
layer,
so
replication
never
shipped them,
FK
joins
didn't
work,
and
/admin/user/:id
404'd.
Phase
1
(PR
#1935) makes
the
system
user
a
real
row:
a
boot-time
idempotent
seed
in
api-gateway (INSERT … ON CONFLICT (id) DO NOTHING
in
one
transaction),
a users.is_system BOOLEAN
with
a
system_user_locked
CHECK
(frozen,
not
admin, not
onboarded,
no
Clerk
login),
reserved-@system.architect.co
signup/login guards,
and
is_system
surfaced
on
the
admin
protocol
so
the
GUI
filters without
hardcoding
ids.
Multi-account
moved
the
ledger
key
from
users
to
accounts: current_balances,
monthly_loss_limits,
account_permissions,
and
the
admin account
pages
are
all
keyed
on
trading_accounts.id.
The
five
system
account ids
appear
in
current_balances
(the
fee
account
has
a
live,
real
balance) but
have
no
trading_accounts
row:
GET /admin/accounts/{id}
returns
404
for
the
fee
account (rs/api-gateway/src/admin_trading_account_routes.rs
—
query_by_id
finds nothing),
so
the
admin
GUI
cannot
open
the
one
account
the
Negative-Fee
wire points
at.
Debugging
"why
is
the
fee
account
negative"
is
a
psql
session today.
This
is
the
original
ask.
current_balances
rows
exist
for
account
ids
that
no
account-listing
join can
see
—
phantom
ledgers.
trading_accounts.ubo_user_id
carries
a
comment
promising
it
is
"NULL
for system
bookkeeping
accounts",
written
when
no
system
user
row
existed;
with
a real
owner
the
account
→
user
resolution
the
ticket
wanted
should
work
through the
normal
join.
trading_accounts. No
phantom
accounts;
joins
and
FKs
work
the
same
for
system
and
customer ledgers.
AX_*_ACCOUNT_ID
values,
which
are
unchanged.
The
only
code
touches
are the
constant
swap
(five
AX_*_USER_ID
→
one
AX_SYSTEM_USER_ID,
with lending.rs
pointing
at
AX_LENDING_ACCOUNT_ID
directly)
and
re-keying
the one
exchange-stats
query
from
user_id
to
account_id
—
no
history
backfill.
Non-goals:
an
EP3-side
hard
block
on
SYSTEM.*
order
entry
(tracked
separately, and
largely
mooted
below),
and
any
new
"operating
account"
concept
beyond
the existing
five
ledgers.
The
exchange's
five
ledgers
are
accounts,
not
identities.
Under
multi-account semantics
—
accounts
are
ledgers,
users
are
the
entities
that
own
them
—
the five
ledgers
share
a
single
identity:
Architect
itself.
So
the
model
is
one Architect
system
user
(is_system)
owning
five
system
trading
accounts:
Architect (AX_SYSTEM_USER_ID)
├── System Fees (000001-8888-0000)
├── System Lending (000000-0000-4CM0)
├── System Funding (000000-0000-3X00)
├── System Depositor (000000-0000-0FM0)
└── System PnL (000000-0000-4AW0)
This
is
exactly
the
customer
shape
the
schema
already
models:
one
ubo_user_id —
the
"company
or
person"
that
owns
an
account
"for
KYC/tax/compliance purposes"
(db/postgres/1.sql:546)
—
owning
many
accounts.
Architect
is
one company;
the
five
ledgers
are
its
accounts
for
five
accounting
purposes. "ax-fee"
is
a
purpose,
not
a
person.
The five account ids are the canonical ledger identities and stay frozen; the five user ids were only ever scaffolding from the 1:1 era, and nothing load-bearing needs them:
AX_*_ACCOUNT_ID,
which
are
identical
under
either
shape.
Only
one
production query
filters
on
a
system
user
id
—
exchange-stats
(admin_routes.rs:656)
— and
it
just
re-keys
to
account_id,
after
which
history
is
uniform
across
the pre-
and
post-#2610
eras
with
no
backfill.
lending.rs:60 (AX_LENDING_USER_ID.as_default_account_id())
still
derives
an
account
from
a user
id,
and
AX_LENDING_ACCOUNT_ID
already
exists
—
a
one-line
swap. as_default_account_id's
own
doc
comment
(user_id.rs:81-83)
states
the reverse
direction
is
"deliberately
absent
—
an
account's
owner
is trading_accounts.ubo_user_id,
never
the
bit
pattern."
/admin/user/architect page
then
shows
the
exchange's
entire
book
—
all
five
ledgers
as
its
accounts —
through
the
same
UBO
→
accounts
GUI
customers
already
get,
rather
than
five one-account
pages
linked
only
by
a
badge.
One
users
row:
AX_SYSTEM_USER_ID,
username
architect@system.architect.co;
is_system = TRUE,
frozen,
not
admin,
not
onboarded,
no
Clerk
identity;
materialize_default_account
already
skips
system
users; the
id
is
deliberately
not
a
twin
of
any
account
id).
Constants
become
honest:
keep
the
five
AX_*_ACCOUNT_ID;
delete
the
five AX_*_USER_ID
in
favor
of
one
AX_SYSTEM_USER_ID;
lending.rs
addresses AX_LENDING_ACCOUNT_ID
directly
and
its
"cannot
lend
to
the
pool"
guard
keys
on the
account
id.
One
trading_accounts
row
per
ledger,
id
equal
to
the
existing
frozen
constant, all
owned
by
the
single
Architect
system
user:
| account id | ubo_user_id | name |
|---|---|---|
000001-8888-0000 |
AX_SYSTEM_USER_ID | System Fees |
000000-0000-4CM0 |
AX_SYSTEM_USER_ID | System Lending |
000000-0000-3X00 |
AX_SYSTEM_USER_ID | System Funding |
000000-0000-0FM0 |
AX_SYSTEM_USER_ID | System Depositor |
000000-0000-4AW0 |
AX_SYSTEM_USER_ID | System PnL |
Display
names
are
edition/venue-agnostic
(System *,
not
AX *),
matching
the brand-neutral
system
usernames
PR
#1935
settled
on
(fee@,
lending@,
…),
so the
same
rows
read
correctly
under
the
AX
and
Bitnomial/AIEx
editions.
Row shape and invariants:
ubo_user_id
=
the
Architect
system
user.
The
Negative-Fee
wire
gets account → ubo_user_id → /admin/user/architect
through
the
normal
join,
and that
one
page
lists
all
five
ledgers
as
its
accounts.
ep3_username,
ep3_account, btnl_clearing_firm_code,
btnl_account_id
all
NULL.
This
is
the
strongest possible
no-order-entry
guarantee:
an
account
with
no
venue
identity
is unroutable
by
construction
—
the
order
gateway
has
nothing
to
map
it
to.
The deferred
"EP3-side
hard
block
on
SYSTEM.*
orders"
is
redundant
for
these accounts
(EP3's
own
admin/agent
identities
remain
a
separate
concern).
maker_fee = 0,
taker_fee = 0
(columns
are
NOT
NULL)
and is_close_only = TRUE
as
belt-and-braces.
db/postgres/1.sql)ALTER TABLE trading_accounts
ADD COLUMN is_system BOOLEAN NOT NULL DEFAULT FALSE,
-- A system account is owned by the single Architect system user and is
-- locked into the non-tradable shape: close-only, zero fees, no venue
-- identity. With exactly one legitimate owner, the coherence check is a
-- literal rather than a cross-table composite FK.
ADD CONSTRAINT system_account_locked CHECK (
NOT is_system OR (
ubo_user_id = '<AX_SYSTEM_USER_ID>'
AND is_close_only
AND maker_fee = 0 AND taker_fee = 0
AND ep3_username IS NULL AND ep3_account IS NULL
AND btnl_clearing_firm_code IS NULL
)
);Marking
a
customer-owned
account
is_system = TRUE
fails
the
CHECK
because
its UBO
isn't
the
system
user;
a
system
account
can't
be
a
permissionless
orphan because
NULL ≠
the
literal.
Existing
rows
are
unaffected
—
they
default
to is_system = FALSE.
is_system
is
a
plain
flag
worth
carrying
on
both
tables (for
replication
consumers
and
the
GUI,
and
to
keep
id-literals
out
of
app code);
the
invariant
lives
in
the
CHECK,
so
there
is
no
composite
FK
or redundant
unique
index
to
maintain.
Extend
the
boot
seed
(ensure_system_users_in_tx
→
ensure_system_entities_in_tx), same
transaction,
in
dependency
order:
users
row
for
AX_SYSTEM_USER_ID;
trading_accounts
rows,
all
ubo_user_id = AX_SYSTEM_USER_ID, ON CONFLICT (id) DO NOTHING
—
ops
edits
(e.g.
renaming
name)
survive restarts,
same
philosophy
as
the
user;
current_balances
row
per
account
via
the
transaction-engine's existing
ON CONFLICT DO NOTHING
seed
—
environments
where
the
fee
account already
holds
a
live
balance
are
untouched.
No
username-style
collision
pre-check
is
needed
for
the
accounts:
id
is
the only
constrained
column
we
set
(name
is
non-unique,
venue
identities
NULL). Atlas
applies
the
column/constraint
changes;
rows
arrive
on
first
boot,
the
same rollout
shape
Phase
1
establishes.
POST /admin/accounts)
has
no
is_system
field on
its
request
type
—
unrepresentable,
nothing
to
add.
PUT /admin/accounts/{id}/permissions/{user_id}) reject
system
accounts.
Admins
are
god-mode
and
need
no
permission
rows; customers
must
never
gain
can_list/can_read
(let
alone
can_trade)
on
a system
ledger.
This
is
the
one
cross-cutting
rule
a
CHECK
can't
reach
(it
lives on
account_permissions),
so
it's
an
endpoint
guard
plus
an
integration
test.
materialize_default_account
gates
on
is_system_user);
with
the
row
seeded at
boot
the
skip
is
a
no-op,
but
stays
as
the
backstop.
Materializing
five
new
rows
on
the
trading_accounts
logical-replication
stream touches
every
subscriber;
per
the
ticket's
audit
clause:
accounts_monitor
—
verify
no
code
path assumes
every
replicated
account
is
venue-backed.
The
Bitnomial
account
index keys
on
(btnl_clearing_firm_code, btnl_account_id)
and
skips
NULLs,
so
system rows
fall
out
naturally;
confirm
with
a
test.
is_system_account;
no
change.
users_count
over-count):
every
account
listing
and
count
path must
treat
is_system
consistently
—
shown
to
admins
with
a
badge
by
default, excluded
under
the
same
hide_architect-style
toggle
and
from
any customer-facing
aggregate.
With the rows materialized, the fee-account debugging loop becomes:
fee_account_above_threshold)
fires
with account_id
—
now
deep-linkable
to
/admin/accounts/{id}
instead
of
a dangling
id.
current_balances), transaction
history
(ClickHouse
transactions
filtered
by
account),
UBO
link to
/admin/user/architect,
and
a
System
badge
sourced
from
is_system (no
hardcoded
ids
in
the
GUI).
Phase
1
—
the
system
user
as
a
real
row.
PR
#1935
(A-3338),
approved
on backend:
seed
the
single
AX_SYSTEM_USER_ID,
delete
the
five
AX_*_USER_ID, point
lending.rs
at
AX_LENDING_ACCOUNT_ID,
and
re-key
exchange-stats
to account_id.
Land
with
Andrew's
review
nits
(column
placement
next
to is_admin,
English
comment
on
the
CHECK,
one-line
doc
comment).
Phase
2
—
system
trading
accounts.
New
ticket:
schema (trading_accounts.is_system,
system_account_locked
CHECK),
boot
seed extension
(one
user
+
five
accounts),
permission-grant
guard,
downstream
audit
+ tests.
Backend
only;
no
GUI
dependency.
Phase
3
—
GUI
+
wire
polish.
Account/user
badges
and
hide
toggles
in
the admin
GUI,
and
the
recon
alert
deep
link.
current_balances
FK.
With
system
accounts
materialized,
every
balance row
should
reference
a
trading_accounts
row
—
do
we
add
the
FK?
Requires
a prod/demo
orphan
check
first;
propose
as
a
Phase
2
stretch
goal,
not
a blocker.
is_admin
ever need
read
access
to
the
fee
ledger,
we'd
relax
to
can_read-only
grants
— defer
until
someone
asks.
The
exchange-stats
lending
exclusion
looks
broken
since
PR
#2610, independent
of
this
RFC.
admin_routes.rs:656
excludes
lending
via user_id NOT IN ('000000-0000-4CM0')
(schema.rs:2048).
Before
#2610
the
pool legs
carried
user_id = AX_LENDING_USER_ID,
so
the
filter
caught
them;
after #2610
they
carry
the
initiating
admin's
id
(the
pool
legs
are
plain Withdrawal/Deposit
typed
and
stamped
with
initiated_by_user_id),
so
every admin
lending
credit/debit
since
June
22
leaks
its
pool
leg
into
the deposit/withdrawal
count
and
notional.
The
fix
is
the
same
account_id
re-key this
design
wants
anyway
(account_id NOT IN (AX_LENDING_ACCOUNT_ID)),
but
it
is a
standalone
bug
worth
its
own
ticket.
Confirm
against
prod
ClickHouse
first (transactions
where
account_id = AX_LENDING_ACCOUNT_ID
and timestamp > 2026-06-22,
inspect
user_id).