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.
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.
account_permissions has five bits per user and account:
can_list, can_read,
can_set_limits, can_reduce_or_close,
can_trade (db/postgres/1.sql:1005).
Trade implies ReduceOrClose implies
Read implies List; SetLimits
implies List
(rs/sdk-internal/db/src/entities.rs,
DbAccountPermission::allows).PUT /admin/accounts/{id}/permissions/{user_id} and the
admin CLI. There is no customer-facing grant route.trading_accounts.ubo_user_id names the user who filed the
onboarding application. Company facts live in that user's questionnaire.
The KYC beneficial owners are a separate list per application in
onboarding_gateway.beneficial_owners, and most of them have
no AX login.users.is_onboarded and users.is_frozen are set
by the approval endpoint, the admin toggle, and the Bitnomial IB sync.
Order entry and replace read both flags on the account's
ubo_user_id, not on the acting user
(rs/order-gateway/src/state.rs). The customer app gates its
onboarding funnel on the acting user's own flag.rs/sdk-internal/trm/src/ids.rs), the fiat deposit code
encodes it, and the default account id shares its bit pattern
(rs/sdk-internal/src/user_id.rs).onboarding_gateway table and
treasury_engine.trm_account_ids are keyed by the
applicant's user id.BtnlAccountIndex
(rs/sdk-internal/db/src/btnl_account_index.rs). The IB sync
refuses an owner with more than one account.Action::Trade or on "not read-only". Production
deposits and withdrawals are admin-only routes with no per-user
permission check.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.customer_members has user_id as its primary
key. Admin and staff users belong to no customer.can_deposit and can_withdraw join
account_permissions and api_keys. Each implies
Read. Trade implies neither. Neither implies
Trade.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.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.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.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.The decision review is decision D1 in the master SoW decision record. It sets four items:
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:
ubo_user_id, whether that
user has an onboarding application, and the customer name and type that
the backfill will write;ubo_user_id that is an admin, staff, or QA user,
because the backfill must not make a customer from it;ubo_user_id that owns more
than one account, because decision 12 forbids it; andaccount_permissions row and each
api_keys row with can_trade = TRUE.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:
customer_id.customer_id.customer_members row never names an admin, staff,
system, or QA user.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.
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.
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:
customer_id must equal the caller's
customer. Else 404.can_deposit and can_withdraw
values.POST /customer/members resolves the username to an
existing user. The user must have no current customer, or the same
customer (no-op). The user must not be admin, staff, system, QA, or
frozen. Else 409.DELETE /customer/members/{user_id} refuses the caller's
own id.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.
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.
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.
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.trading_accounts.customer_id = ubo_user_id on every
non-system account.customer_members row per applicant with
can_manage_access = FALSE.trading_accounts.ubo_user_id, the clearing columns
on trading_accounts, and
users.is_onboarded.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.
Customer app (gui/packages/app):
gui/packages/admin/src/accountAccess/tiers.ts).can_deposit and
Withdraw on can_withdraw, not on "not read-only".GET /whoami account entries carry the two new
bits.is_onboarded from GET /whoami. No change; the
field's meaning changes.Admin app (gui/apps/admin):
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:
users.is_frozen on
that member still refuses their login.customers.is_onboarded and one
audit_log row keyed by the customer.Acceptance for D, as tests in
rs/api-gateway/tests/sections/:
Acceptance for B:
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.
can_withdraw is defined so that the debit leg can use
it.customer_members can take
can_view_kyc and can_update_kyc later without
a new table.onboarding_applications.customer_id stays unique until that
work relaxes it.is_onboarded, which is the counterparty's approval.