RFC: Lazy Account Provisioning

Date: 2026-06-23

Status: Draft

Related: Multi-Accounts (A-3402, step 11) migration complete; the plan and service-audit docs have been retired (see A-3402 / git history).

Background

Today, POST /signup provisions a complete trading account eagerly and atomically, before the user has been KYC'd and before they have shown any intent to trade. The provision_user function (rs/api-gateway/src/auth.rs:267-383) performs, in one Postgres transaction plus a side ClickHouse/EP3 effect, the following ordered sequence:

  1. (EP3 enabled) Mint a default account_id from the generator, then call provision_ep3_trading_account (rs/ep3/src/ep3_user_manager.rs:176-184), which fires two real EP3 gRPC RPCs CreateAccount and CreateParticipant (ep3_user_manager.rs:115-167). The user_id is then read back out of the minted EP3 username via Ep3Username::from_full_path(...).user_id_v2() (auth.rs:301-304).
  2. INSERT INTO users (auth.rs:345).
  3. Seed a zero current_balances row in Postgres via initialize_balances → pg_insert_balance (auth.rs:353-359, rs/transaction-engine/src/lib.rs:56).
  4. INSERT INTO user_risk_profiles (auth.rs:360).
  5. INSERT INTO trading_accounts with ubo_user_id = user_id (auth.rs:363-375).
  6. INSERT INTO account_permissions the UBO's full_access grant (auth.rs:376-378).

The cost of this eagerness is borne by EP3. Every signup mints an EP3 participant, whether or not the user ever funds, trades, or even passes KYC. If we open signups to non-traders who just want to view market data or window-shop the product, we will flood EP3 with permanently-inactive participants a finite, externally-managed namespace and carry the operational and reconciliation weight of accounts that will never see a fill.

The Multi-Accounts work (A-3402) has done the heavy lifting that makes this tractable. It cleanly separated identity (user_id) from ownership (account_id) and membership (account_permissions) (Multi-Accounts A-3402 §2a), inverted the gateway guard to a real authorize(user, account, action) ACL (step 6), and made the EP3 seam account-keyed rather than user-keyed (step 10e). The one piece the plan explicitly deferred is account lifecycle "who creates accounts beyond onboarding, and is there a public-SDK creation message?" is an open question, slated for step 11 (CreateAccount/ListAccounts/...). This RFC is the design for that lifecycle: stop creating the account at signup, and materialize it lazily at a well-defined trigger.

Why this is now safe to do

The eager flow exists for one reason: under the old 1:1 user == account world, "a user" and "their account" were the same object, so there was no seam at which to split them. After A-3402 that seam exists everywhere every money path already keys on account_id and authorizes against account_permissions. A user with no trading_accounts row and no account_permissions rows is now a perfectly coherent state: it is simply a user whose readable-account set is empty. Most read surfaces already degrade correctly to that state (see § Graceful degradation). What is left is to (a) make signup create only identity, (b) introduce an idempotent materialization step, and (c) fix the handful of read paths that still assume the default account row exists.

Goals

Non-Goals

Design Overview

Split the seven-step eager sequence above into two phases along the identity/ownership line the plan already drew:

Phase Creates When
Identity (signup) users, user_risk_profiles, onboarding scaffolding POST /signup
Ownership (materialization) EP3 participant, trading_accounts, account_permissions (UBO full_access), zero current_balances First funding action, gated behind KYC

1. Mint user_id locally decouple identity from EP3

The single structural blocker to deferring EP3 is that the user_id is currently produced by EP3 provisioning (auth.rs:289-304): we mint an account_id, hand it to EP3, and parse the user_id back out of the returned participant string. If EP3 provisioning moves to a later trigger, signup has no user_id.

The fix is clean and is in fact a simplification: mint the user_id from the generator directly at signup (exactly what the EP3-disabled branch already does, auth.rs:306). The EP3 username was never genuinely supplied by EP3 it is a pure deterministic function of the account id (generate_ep3_username(account_id), ep3_user_manager.rs:62-65), and account_id = user_id.as_default_account_id() is itself derived. So at signup we can compute the eventual ep3_username/ep3_account strings without talking to EP3 at all; the RPCs that actually register those strings on the venue are what we defer. (Whether we persist the computed strings on the users row at signup or compute them at materialization is a minor implementation choice; computing at materialization keeps the users row honest about what exists on EP3.)

2. The materialization primitive

Introduce one idempotent function call it materialize_account(state, user_id) -> Result<AccountId> that performs the ownership-phase writes and only those writes:

account_id = user_id.as_default_account_id()        // derived, stable

// idempotent guard: if trading_accounts[account_id] exists, return early
if account_exists(account_id) { return account_id }

begin tx (with a pg advisory lock keyed on account_id, or ON CONFLICT DO NOTHING):
  (EP3 enabled) provision_ep3_trading_account(account_id)   // already idempotent:
                                                            // find_ep3_user_by_account_id guards
  initialize_balances(account_id, 0)                        // needs ON CONFLICT DO NOTHING
  trading_accounts.insert(ubo_user_id = user_id)            // ON CONFLICT DO NOTHING
  account_permissions.full_access(user_id, account_id)      // ON CONFLICT DO NOTHING
commit

Idempotency is the crux, because the trigger is not exactly-once:

materialize_account is the same primitive step 11's public CreateAccount message will call; this RFC just wires it to the default account's lifecycle first.

3. The trigger when does materialization fire?

The user framed two options: at first deposit (latest) or at the earliest, once onboarding/KYC is complete. These are not really competing KYC is a precondition for funding, so they compose into a single pipeline:

signup ──▶ identity only (window-shop: marketdata, browse, WhoAmI=[])
   │
   ▼
KYC complete ──▶ still no account, no EP3 — user is now *eligible* to fund
   │
   ▼
first funding action ──▶ materialize_account(): EP3 participant + trading_accounts
                         + account_permissions + zero current_balances

Recommendation: trigger on the first funding action, with KYC as a hard precondition. Rationale:

The deposit edge is special and must materialize-then-authorize, not the usual authorize-then-act. A bare deposit into one's own default account currently 403s under the post-A-3402 ACL because there is no account_permissions row yet (authorize returns Forbidden on a missing row, rs/sdk-internal/db/src/entities.rs:2779-2789). For the UBO depositing into their own default account the authorization is implicit (they are the beneficial owner by derivation: account_id.as_default → user_id). So the deposit-bootstrap path resolves the default account, calls materialize_account(user_id) if absent, then proceeds through the normal deposit machinery (process_deposit_or_withdrawal, rs/api-gateway/src/utils.rs:908-976), which already auto-creates the balance row on first transaction (rs/transaction-engine/src/lib.rs:325-340). Deposits into a non-default account a user does not own remain governed by the normal ACL.

The KYC gate (is_onboarded) is the precondition for the funding trigger, and making it fire automatically on KYC approval is in scope for this RFC see the next section.

4. KYC-completion callback (onboarding-gateway api-gateway)

Today there is no automatic transition from "onboarding application complete" to is_onboarded = true. An admin flips the flag by hand via PUT /admin/user/status on api-gateway (rs/api-gateway/src/admin_routes.rs:337-411 DbUser::update_status, rs/sdk-internal/db/src/entities.rs:161-198), and the onboarding-gateway's application_completed write (admin_update_application, rs/onboarding-gateway/src/admin_onboarding_routes.rs:1035-1087 DbOnboardingApplication::update, entities.rs:1365-1399) notifies nothing downstream. The two services share one Postgres but onboarding-gateway never writes the users table api-gateway owns those writes and maintains the users_replica (rs/api-gateway/src/state.rs:32). We must respect that ownership boundary: onboarding-gateway should not reach across and UPDATE users directly, or the api-gateway replica and any write-side invariants are bypassed.

Design: a service-authed internal callback. The plumbing already exists ServiceAuthToken (bearer, constant-time compare, rs/sdk-internal/web/src/service_auth.rs:8-43) is loaded by both services but not yet used between them. Wire it up:

  1. Add an internal, service-authed endpoint on api-gateway POST /internal/onboarding/complete { user_id } (or fold the same guard onto the existing update_user_status path with a service-token auth layer). It sets is_onboarded = true through the normal DbUser::update_status write, so the replica and all existing invariants stay intact. It is idempotent (setting an already-true flag is a no-op) and does not materialize the account materialization stays bound to the funding trigger (§3).
  2. onboarding-gateway calls it when an application crosses into the "approved" terminal state. "Approved" is not a single boolean today: it is the conjunction of questionnaire_completed, documents_completed, application_completed/status='complete' (db/postgres/1.sql:166-168) and the separate risk_assessments.status = 'complete' (db/postgres/1.sql:231-236, entities.rs:1777-1808), which are updated independently and not synchronized today. Part of this work is to define the single predicate is_kyc_approved(application, risk_assessment) and fire the callback exactly when it first becomes true (in admin_update_application and in the risk-assessment update path, since either can be the last to flip).

Delivery reliability. A bare in-handler HTTP call drops the signal if api-gateway is briefly down at the moment of approval, leaving a KYC-approved user stuck un-onboarded. Two acceptable shapes, in increasing robustness:

Because the flag is idempotent and the funding trigger is what actually mints the EP3 participant, at-least-once delivery is entirely safe here a double-fired callback is a no-op.

Interaction with the Clerk migration. If user authentication moves to Clerk (see the companion Clerk authN migration RFC), is_onboarded remains an AX-owned authZ/state attribute on the AX users row, not a Clerk concern Clerk owns credentials and MFA, AX owns "is this user cleared to trade." This callback is unaffected by that migration.

5. Graceful degradation (window-shoppers)

A user with no account must traverse every non-trading surface without error. The audit of read paths found that most already degrade correctly because A-3402 made them key on the (possibly empty) readable-account set rather than assuming the default account:

Surface Behavior with no account today Action
WhoAmI.accounts Returns [] filters over account_permissions, empty is fine (auth_routes.rs:365-407) none
Market-data stream Identity-gated only; no account check (marketdata-publisher/src/auth.rs:68-128) none
GET /balances Returns [] on missing current_balances (auth_routes.rs:900-928) none
authorize_account on any money route Clean 403 Forbidden, no panic (utils.rs:137-154 entities.rs:2779-2789) none
Order placement 403 (no can_trade perm) and/or NotOnboarded (order-gateway/src/state.rs:241-243) none
GET /risk-snapshot 500 Redis risk:{account_id} key is absent because risk-engine only tracks accounts with a current_balances row (utils.rs:210-233, esp. 225-228; RE tracks via the current_balances replica, risk-engine/src/lib.rs:567) fix required

The only hard breakage is the risk-snapshot endpoint. The fix is to make get_risk_snapshot_from_redis return a synthetic zero/empty snapshot (or a clean 404 Not Provisioned) when the account has no Redis key, rather than mapping the absent key to INTERNAL_SERVER_ERROR. A zero snapshot is the correct answer for an account with no balances and no positions, and it keeps the GUI's risk panel rendering "$0 / no positions" for an unfunded account instead of erroring. We should sweep for any sibling reads that .ok_or(... 500) on a default-account Redis/CH key with the same pattern.

6. What stays eager

Affected components

Component Change
api-gateway provision_user (auth.rs:267-383) Split: mint user_id locally; write only users + user_risk_profiles. Drop EP3 RPCs, trading_accounts, account_permissions, current_balances from the signup path.
api-gateway (new) materialize_account New idempotent primitive holding the ownership-phase writes (EP3 + 3 PG rows). Reused by the deposit edge and, later, step-11 CreateAccount.
api-gateway deposit edge (utils.rs:908-976, sandbox/admin deposit routes) Materialize-then-authorize for the UBO's own default account; gate on is_onboarded.
api-gateway (new) POST /internal/onboarding/complete Service-authed (ServiceAuthToken) endpoint that flips is_onboarded via DbUser::update_status; idempotent; does not materialize.
onboarding-gateway admin_update_application + risk-assessment update Define is_kyc_approved predicate; call the api-gateway callback when it first becomes true.
Reconciler (recon-engine or task) Backstop: re-fire the callback for is_kyc_approved users still is_onboarded = false; retro-onboards existing population.
api-gateway get_risk_snapshot_from_redis (utils.rs:210-233) Degrade missing key synthetic zero snapshot / 404, not 500. Sweep siblings.
ep3 ep3_user_manager No code change required provision_ep3_trading_account is already idempotent and account-keyed; it simply gets called later.
SignupResponse Still derivable: account_id = user_id.as_default_account_id() is computable without the row existing. Decide whether to keep returning it (it now denotes a future account id) or omit it until materialization.
admin-cli create_user already skips trading_accounts/account_permissions (main.rs:327-425); the separate accounts provisioning command (accounts.rs) should call the same materialize_account primitive for parity.
transaction-engine initialize_balances (lib.rs:56) Make pg_insert_balance ON CONFLICT DO NOTHING so materialization is re-runnable.
API keys (step 9, #2227) A key is minted against an explicit account; minting for the default account must trigger (or require) materialization. Note as a dependency, not in scope here.

Rollout

  1. Land materialize_account as a no-op-equivalent refactor: have eager provision_user call it, so the new primitive is exercised on the existing path with zero behavior change. Verify idempotency with a test that calls it twice.
  2. Fix the risk-snapshot degradation (and any sibling 500s) independently it is correct on its own merits and unblocks window-shopping.
  3. Feature-flag the signup split (lazy_account_provisioning, per environment). Off = today's eager flow; on = identity-only signup + deposit/KYC trigger. Roll out sandbox demo prod.
  4. No backfill. Existing users are fully provisioned and untouched. Only signups after the flag flips take the lazy path.
  5. Lifecycle tests per CLAUDE.md's connection/lifecycle directive: signup window-shop (marketdata + WhoAmI + balances + risk-snapshot all 200/empty) KYC first deposit materialize trade; plus the race (concurrent deposit and a second deposit, or deposit + admin provisioning) converging to a single account; plus restart/replay (materialize, crash mid-trigger, re-fire).

Open questions

Appendix: source index

Concern Location
Eager signup provisioning rs/api-gateway/src/auth.rs:267-383
Signup route rs/api-gateway/src/public_routes.rs:182-280
EP3 participant RPCs rs/ep3/src/ep3_user_manager.rs:99-184 (generate_ep3_username :62-65)
user_id round-trip out of EP3 rs/api-gateway/src/auth.rs:289-304
initialize_balances PG balance rs/transaction-engine/src/lib.rs:56, handle_init_balances
authorize (missing perm Forbidden) rs/sdk-internal/db/src/entities.rs:2779-2789
authorize_account rs/api-gateway/src/utils.rs:137-154
Risk-snapshot 500 on missing key rs/api-gateway/src/utils.rs:210-233
RE tracks accounts via current_balances replica rs/risk-engine/src/lib.rs:567, monitor_balances_task:438
is_onboarded set / enforced auth.rs:321 / order-gateway/src/state.rs:241-243, entities.rs:63-65
Onboarding admin update (no callback) rs/onboarding-gateway/src/admin_onboarding_routes.rs:1035-1087, DbOnboardingApplication::update entities.rs:1365-1399
ServiceAuthToken (loaded both services, unused between them) rs/sdk-internal/web/src/service_auth.rs:8-43
users write ownership + replica DbUser::update_status entities.rs:161-198, users_replica rs/api-gateway/src/state.rs:32
risk_assessments status db/postgres/1.sql:231-236, entities.rs:1777-1808
Deposit address FK to trading_accounts db/postgres/1.sql:314-324
Deposit processing rs/api-gateway/src/utils.rs:908-976, rs/transaction-engine/src/lib.rs:257-340
WhoAmI / balances graceful empties rs/api-gateway/src/auth_routes.rs:365-407, :900-928
Marketdata identity-only auth rs/marketdata-publisher/src/auth.rs:68-128
admin-cli create_user (no account) rs/admin-cli/src/main.rs:327-425, rs/admin-cli/src/accounts.rs