RFC: System User and System Trading Accounts

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.

Background

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:

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:

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.

What is still broken

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:

Goals

  1. An admin can open any system ledger in the admin GUI balance, transaction history, and a clear "system" badge with the fee account (and its negative-balance alert) as the driving case.
  2. Every account id that holds a balance resolves through trading_accounts. No phantom accounts; joins and FKs work the same for system and customer ledgers.
  3. Invariants are schema-enforced, not conventional. A system account can never become tradable, fee-charging, venue-connected, or owned by a customer and a customer account can never be marked system.
  4. No behavior change in money flows. The frozen key is the account id: TigerBeetle, recon, lending, and existing ClickHouse rows all address the five 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.

Design: one system user, five accounts

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.

Why one owner and not five

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:

The system user

One users row:

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.

The five accounts

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:

Schema (declarative, 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.

Seeding

Extend the boot seed (ensure_system_users_in_tx ensure_system_entities_in_tx), same transaction, in dependency order:

  1. the one users row for AX_SYSTEM_USER_ID;
  2. the five 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;
  3. a zero 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.

Write-path guards

Downstream audit

Materializing five new rows on the trading_accounts logical-replication stream touches every subscriber; per the ticket's audit clause:

Admin GUI (the original ask, closed)

With the rows materialized, the fee-account debugging loop becomes:

  1. Negative-Fee alert (recon fee_account_above_threshold) fires with account_id now deep-linkable to /admin/accounts/{id} instead of a dangling id.
  2. The account page renders like any other: current balance (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).
  3. Accounts and users lists show system rows to admins by default, badge them, and exclude them from customer counts.

Rollout

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.

Open questions

  1. 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.
  2. aiex-main. The Bitnomial edition shares the ledger constants but diverges on provisioning (fixed pre-provisioned account set, no auto-materialization). Does the boot seed run identically there, and does the btnl accounts_monitor tolerate the NULL-venue rows? Needs a check when this merges across.
  3. Read-only grants for non-admin staff. The guard above rejects all permission grants on system accounts. If ops staff without is_admin ever need read access to the fee ledger, we'd relax to can_read-only grants defer until someone asks.

Side-finding (separate ticket, not gated on this RFC)

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).