SoW: Desk management / Customer account access

Master: SoW: Desk management, steps 1 to 7.

Tracking: A-5006. Customer need: A-4855 (TTG), Axtior.

Owns: customers, membership, account ownership, the customer's compliance standing, the customer's clearing identity, the manager right, and the deposit and withdraw permissions. The other Desk management SoWs use these records and do not redefine them.

Objective. A person at a customer adds traders to the customer's accounts, sets what each trader may do, and removes traders, without an AX ticket. Money movement needs its own permission. The trade permission no longer permits deposits or withdrawals.

The model

Four records carry access.

Record Meaning Table
Customer The legal counterparty AX onboarded. Individual or business. Holds the approval and freeze state. In the aiex edition, holds the clearing account. customers (new)
Member A user who belongs to one customer. A member may hold the manager right. customer_members (new)
Account One trading account. Owned by one customer. trading_accounts.customer_id (new column)
Account permission What one user may do on one account. account_permissions (two new bits)

The rule for managers:

A manager of customer Z may set account permissions on any account owned by Z, for any member of Z, up to a fixed ceiling.

The ceiling is the five existing bits: list, read, set limits, reduce or close, trade. AX grants the deposit and withdraw bits and the manager right.

Current behavior

Decisions

  1. Every customer account has a customer. trading_accounts.customer_id points at the legal owner. trading_accounts.ubo_user_id is removed in the same migration. System accounts have no customer.
  2. A user belongs to at most one customer. customer_members has user_id as its primary key. Admin and staff users belong to no customer.
  3. The manager right is customer-level, not account-level. A manager manages every account the customer owns, including accounts created later. AX appoints and removes managers.
  4. Membership comes before permissions. A manager adds a user to the customer first, by exact username. A grant to a user who is not a member is refused. Membership by itself grants nothing.
  5. Removal from the customer removes all grants. One transaction deletes the membership row and every permission row on the customer's accounts for that user. Grants that AX made on other customers' accounts stay.
  6. Deposit and withdraw are explicit bits. can_deposit and can_withdraw join account_permissions and api_keys. Each implies Read. Trade implies neither. Neither implies Trade.
  7. A manager may set their own permissions within the ceiling. A manager may not remove themselves from the customer. AX can.
  8. Grants by AX are not limited to members. AX may still grant any user access to any account. A manager sees those rows on the customer's accounts and may revoke them.
  9. Compliance standing is customer-level. customers.is_onboarded and customers.is_frozen replace the two flags on users. Order entry and replace read the account's customer. users.is_onboarded is removed. users.is_frozen stays and locks the login only. Membership does not require any onboarding state. If Compliance requires a person check for a member, that is a new per-user field, never the counterparty flag.
  10. Customer ids share the user id space. CustomerId has the same 64-bit layout and display as UserId and is minted by the same generator. A customer that the backfill creates takes its applicant's user id. So every TRM account id already sent to TRM, every fiat deposit code already given to a customer, and every default account id keeps its meaning with no data rewrite, and every table keyed by the applicant re-points with a foreign-key swap. CustomerId and UserId have no conversion in either direction. The applicant is found through customer_members, never through the id.
  11. A customer is born with its application. Creating an onboarding application creates the customer, not approved. Approval stamps customers.is_onboarded. A decided application is immutable. The customer holds the accepted snapshot that the running system reads: business_name, doing_business_as, and customer_type. Questionnaire PII stays on the application under the existing encryption and scrub rules. This SoW keeps one application per customer.
  12. In the aiex edition a customer has at most one trading account. The Bitnomial clearing pair lives on customers, because Bitnomial clears one account per counterparty. The customer's account is its default account, whose id equals the customer id. The rule is enforced in the account creation write paths by edition and reported by a recon check, not by the schema, which both editions share. The BtnlAccountIndex keeps its shape: clearing pair to the customer's one account.

Decision review

The decision review is decision D1 in the master SoW decision record. It sets four items:

  1. the authoritative owner and onboarding-evidence mapping for each account;
  2. one-customer-per-user membership, required person checks, and staff or system exclusions;
  3. the exact user and API-key funding migration, defaults for new keys, and the review of retained funding rights; and
  4. the manager scope, five-bit grant ceiling, self-management rule, and treatment of AX grants to non-members.

Items 1 to 3 block stage A. Item 4 blocks stage D. The review does not authorize customer account creation, internal transfers, or shared margin.

Read-only production queries, run in both editions, supply the evidence for items 1 and 3:

Schema

CREATE TABLE customers (
    -- Same layout and generator as users.id. A backfilled customer has its
    -- applicant's user id. See decision 10.
    id CHAR(16) PRIMARY KEY,
    display_name TEXT NOT NULL,
    -- 'individual' | 'business', from onboarding_applications.organization_type
    customer_type TEXT NOT NULL,
    -- Accepted snapshot of the application. Plaintext, as on the questionnaires.
    business_name TEXT,
    doing_business_as TEXT,
    -- Compliance standing, moved from users. Order entry gates on both.
    is_onboarded BOOLEAN NOT NULL DEFAULT FALSE,
    is_frozen BOOLEAN NOT NULL DEFAULT FALSE,
    -- aiex edition: the Bitnomial clearing account. NULL in the AX edition.
    btnl_clearing_firm_code TEXT,
    btnl_account_id TEXT,
    created_at TIMESTAMPTZ NOT NULL DEFAULT CURRENT_TIMESTAMP,
    updated_at TIMESTAMPTZ NOT NULL DEFAULT CURRENT_TIMESTAMP,
    CONSTRAINT customer_type_valid CHECK (customer_type IN ('individual', 'business')),
    CONSTRAINT btnl_pair_complete CHECK (
        (btnl_clearing_firm_code IS NULL) = (btnl_account_id IS NULL)
    )
);
CREATE UNIQUE INDEX customers_btnl_account_idx
    ON customers (btnl_clearing_firm_code, btnl_account_id)
    WHERE btnl_account_id IS NOT NULL;

CREATE TABLE customer_members (
    -- Primary key on user_id: a user belongs to at most one customer.
    user_id CHAR(16) PRIMARY KEY REFERENCES users(id) ON DELETE CASCADE,
    customer_id CHAR(16) NOT NULL REFERENCES customers(id),
    can_manage_access BOOLEAN NOT NULL DEFAULT FALSE,
    added_by_user_id CHAR(16) REFERENCES users(id),
    created_at TIMESTAMPTZ NOT NULL DEFAULT CURRENT_TIMESTAMP,
    updated_at TIMESTAMPTZ NOT NULL DEFAULT CURRENT_TIMESTAMP
);
CREATE INDEX customer_members_customer_id_idx ON customer_members (customer_id);

ALTER TABLE trading_accounts
    ADD COLUMN customer_id CHAR(16) REFERENCES customers(id),
    DROP COLUMN ubo_user_id,
    DROP COLUMN btnl_clearing_firm_code,
    DROP COLUMN btnl_account_id;
-- system_account_locked: `customer_id IS NULL` replaces the ubo_user_id term.
CREATE INDEX trading_accounts_customer_id_idx ON trading_accounts (customer_id);

ALTER TABLE users DROP COLUMN is_onboarded;
-- users.is_frozen stays. It locks the login and no longer gates orders.

-- Every onboarding_gateway table keyed by user_id re-points to customers(id)
-- by a foreign-key swap: the four questionnaire tables, onboarding_applications
-- (still UNIQUE), documents_meta, risk_assessments, audit_log,
-- application_decisions, assessment_attempts, btnl_ib_account_sync_log, and
-- btnl_ib_account_sync_errors. The column is renamed to customer_id.
ALTER TABLE treasury_engine.trm_account_ids
    RENAME COLUMN ubo_user_id TO customer_id;
-- The foreign key moves to customers(id). The append-only triggers stay.

ALTER TABLE account_permissions
    ADD COLUMN can_deposit BOOLEAN NOT NULL DEFAULT FALSE,
    ADD COLUMN can_withdraw BOOLEAN NOT NULL DEFAULT FALSE;
-- permission_coherence becomes:
--   NOT (can_set_limits OR can_reduce_or_close OR can_trade
--        OR can_deposit OR can_withdraw)
--   OR (can_list AND can_read)

ALTER TABLE api_keys
    ADD COLUMN can_deposit BOOLEAN NOT NULL DEFAULT FALSE,
    ADD COLUMN can_withdraw BOOLEAN NOT NULL DEFAULT FALSE;
-- api_keys_permission_coherence gets the same two terms.

Tables that are about a login stay on users: api_keys, account_permissions, user_risk_profiles, block_trades, liquidation_engine.order_request, and partner_programs.

Invariants that a CHECK cannot reach. The write paths enforce them and a recon check reports violations:

customers joins the api-gateway, order-gateway, trade-engine, risk-engine, and recon-engine publications and gets a BTreeMapReplica<DbCustomer, 1> in each. trading_accounts and account_permissions are already on every publication. The BtnlAccountIndex is built from customers instead of trading_accounts. customer_members is read from Postgres directly; access management is not on the order hot path.

Authorization

Action gains two variants:

pub enum Action { List, Read, SetLimits, ReduceOrClose, Trade, Deposit, Withdraw }

allows adds Deposit => self.can_deposit and Withdraw => self.can_withdraw. The Read arm also returns true when Deposit or Withdraw is allowed. The API-key intersection composes unchanged.

The compliance gate in can_send_order and in replace reads the account from the accounts replica, then the customer from the customers replica, and refuses when the customer is missing, not onboarded, or frozen. The WebSocket place path runs this on every order, as it does today. The extra step is one replica lookup, and og_can_send_order_duration_us shows its cost. The QA customer has the QA user's id, so the reserved-id mask in the gate compares the customer id.

Routes that change their gate:

Route Today After
Sandbox deposit Trade Deposit
Deposit address, funding instructions Trade Deposit
Sandbox withdraw Trade Withdraw
Customer withdrawal request (A-4810, future) none Withdraw
Internal transfer debit leg (future) none Withdraw on the source

Admin deposit and withdraw routes stay admin-only and unchanged.

The manager gate, authorize_manager(caller, customer_id), passes when the caller has a customer_members row for customer_id with can_manage_access = TRUE. API-key sessions never pass it in this release.

Customer-facing routes

All routes take the caller's customer from customer_members. A caller with no membership gets 403.

GET    /customer                                  -> customer, my membership
GET    /customer/members                          -> [member]          (manager)
POST   /customer/members         { username }     -> member            (manager)
DELETE /customer/members/{user_id}                -> 204               (manager)
GET    /customer/accounts                         -> [account + grants] (manager)
PUT    /customer/accounts/{id}/permissions/{user_id}
       { can_list, can_read, can_set_limits, can_reduce_or_close, can_trade }
                                                  -> permission        (manager)
DELETE /customer/accounts/{id}/permissions/{user_id} -> 204            (manager)

Rules on the permission routes:

GET /whoami keeps its is_onboarded field. Its value is the caller's customer's flag, or false when the caller has no customer. The admin user responses derive the field the same way. The customer app's onboarding funnel therefore opens for an applicant without an approved customer and never for a member that a manager added.

Admin routes and CLI

POST   /admin/customers                { display_name, customer_type }
PUT    /admin/accounts/{id}/customer   { customer_id }
POST   /admin/customers/{id}/members   { user_id, can_manage_access }
PUT    /admin/customers/{id}/members/{user_id}  { can_manage_access }
DELETE /admin/customers/{id}/members/{user_id}

PUT /admin/accounts/{id}/permissions/{user_id} and the admin CLI set-permissions gain can_deposit and can_withdraw. The admin CLI gains customers create, customers set-account, customers add-member, customers set-manager, and customers remove-member.

The onboarding approval endpoint, the admin onboarding toggle, and the Bitnomial IB sync write customers.is_onboarded and log to onboarding_gateway.audit_log by customer. The IB sync stamps the clearing pair on the customer and materializes the default account when the customer has none. Its one-account-per-owner ambiguity check is deleted.

In the aiex edition, account creation refuses a second non-system account for a customer, and PUT /admin/accounts/{id}/customer refuses a customer that already has one.

Backfill

Stage A ships as two data migrations. Each runs once per environment after its schema applies. Each takes the migration lock, verifies that its durable completion marker is absent, does its work, and writes the marker in the same transaction. A failed attempt rolls back. A retry after the marker exists is a no-op.

A1, identity. No user's authority on any route changes.

  1. Insert one customers row per applicant: each non-system, non-admin, non-staff user that has an onboarding application or is the ubo_user_id of an account. The customer id is the user id. is_onboarded and is_frozen copy from the user. business_name and doing_business_as copy from the user's questionnaire. customer_type is individual for individual and individual_intl, business for the other organization types, and individual when there is no application. display_name is the business name if present, else the username. In the aiex edition the clearing pair copies from the user's account. The QA user gets a QA customer with the same id, not onboarded.
  2. Set trading_accounts.customer_id = ubo_user_id on every non-system account.
  3. Swap the foreign keys on the tables listed in the schema section and rename the columns. The rows do not change.
  4. Insert one customer_members row per applicant with can_manage_access = FALSE.
  5. Drop trading_accounts.ubo_user_id, the clearing columns on trading_accounts, and users.is_onboarded.
  6. Make sure that no row violates the invariants above.

Applicants with an approved application and no account get a customer row and a membership. They are onboarded customers with no account yet.

A2, funding bits. Authority-preserving. It copies each account-permission row's old can_trade value to that row's two funding bits. It separately copies each API key's old can_trade value to that key's two funding bits. It never uses an unconditional TRUE. Thus, the live user grant intersected with the API key has the same authority before and after the migration. A retry after the marker exists cannot copy can_trade again after AX revokes a funding bit. Explicit funding gates and funding-bit edits activate only after the marker commits. Before activation, AX reviews all retained funding rights.

GUI

Customer app (gui/packages/app):

Admin app (gui/apps/admin):

Delivery

Five implementation issues are in the Desk management project. A lands first. B and C are independent after A. D needs C. E needs D. The master SoW builds them in the order A, B, C, live-session revocation, D, E.

Stage Linear issue Scope Reviewers
A A-4890 A1: customers, compliance gate, id space, foreign-key swaps, ubo_user_id removal. A2: funding bits and their backfill. Two PRs. high-risk
B A-4889 Funding actions, route gates, API-key flags, admin controls, whoami, and funding UI gates. backend, frontend
C A-4897 Admin customer and membership operations, CLI commands, and recon checks. backend
D A-4892 Customer routes, authorize_manager, and atomic customer removal. backend
E A-4896 One Team screen under Account Settings. frontend
Decision review (items 1 to 3)
    ↓
A1 → A2
    ├── B
    └── C
            ↓
        D
            ↓
        E

Release requires B, E, and A-4887. The E chain includes A, C, and D. A-4887 needs no other stage.

Acceptance for A1:

Acceptance for D, as tests in rs/api-gateway/tests/sections/:

Acceptance for B:

Live-session revocation

Order-gateway and api-gateway check the account_permissions replica on every REST request. Order-gateway also checks it on WebSocket place, single cancel, and replace. A revoked user's next such request is refused.

Two WebSocket paths in rs/order-gateway/src/ws_service.rs use the permission that was read when the connection opened:

Path Current gate Required gate
Cancel-all SessionScope.can_reduce_or_close, set at connect authorize_with_api_key with Action::ReduceOrClose on each request, as the REST cancel-all route does
Account event delivery handle_event compares the account ID only The session also holds Action::Read on the account when the event is sent

A-4887 changes both gates. A session that loses Read receives no further account events. The session may stay open.

Tests cover order placement, cancel-all, event delivery, and account-scoped API keys after a revoke and after a member removal. They also cover client disconnect, server disconnect, reconnect, and order-gateway restart. Other authorized traders must still be able to manage the account's existing orders.

The management response reports that the change is committed. It does not report that every gateway has observed the change.

Out of scope

Open questions

  1. Does adding a member require that user to complete a person check first? Compliance decides in decision review item 2. If yes, the route gates on a new per-user field that the person check sets. It never gates on is_onboarded, which is the counterparty's approval.