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).
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:
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).
INSERT INTO users
(auth.rs:345).
current_balances
row
in
Postgres
via initialize_balances → pg_insert_balance (auth.rs:353-359,
rs/transaction-engine/src/lib.rs:56).
INSERT INTO user_risk_profiles
(auth.rs:360).
INSERT INTO trading_accounts
with
ubo_user_id = user_id (auth.rs:363-375).
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.
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.
users
row (and
onboarding/KYC
scaffolding)
but
no
trading_accounts
row,
no account_permissions
rows,
no
current_balances
row,
and
—
critically
— no
EP3
participant.
WhoAmI,
browse
instruments,
and
stream
market
data.
None
of
these surfaces
500
or
403
on
a
missing
default
account.
CreateAccount/CreateParticipant
RPCs
move
to
the
materialization trigger,
gated
behind
KYC.
account_id
is
stable,
so
re-running
converges
to
one
account.
CreateAccount
message).
This
RFC
only
changes
when
the
default
account is
materialized;
it
reuses
the
same
materialization
primitive
step
11
will expose,
but
does
not
design
the
multi-account
UX.
account_id = user_id.as_default_account_id()
(id
shares
the
user's
ULID
bit
pattern)
stays exactly
as
it
is
(Multi-Accounts
A-3402
§2c.1).
Lazy provisioning
defers
creating
the
row;
it
does
not
change
what
id
the
row gets.
This
is
what
makes
materialization
a
safe
upsert
on
a
stable
key.
account_id
is
out
of
repo
and
unchanged;
this
RFC only
addresses
what
AX-side
state
must
exist
when
a
deposit
is
credited.
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 |
user_id
locally
—
decouple
identity
from
EP3The
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.)
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:
provision_ep3_trading_account
early-returns via
find_ep3_user_by_account_id
(ep3_user_manager.rs:89-93,180-182).
The first
race-loser
that
finds
the
participant
already
present
is
a
no-op.
ON CONFLICT DO NOTHING
on
their
primary
keys (trading_accounts.id,
account_permissions (user_id, account_id), current_balances.account_id).
Because
the
default
account_id
is
derived, not
generated,
two
concurrent
materializations
target
the
same
key
and
collapse cleanly.
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.
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:
trading_accounts
(db/postgres/1.sql:314-324),
so
we
cannot
hand
out
a deposit
address
without
an
account
row
anyway.
Materializing
on deposit-address
request
is
therefore
forced
by
the
schema
and
is
clean.
is_onboarded
flips)
is
the weaker
choice
on
its
own:
it
re-introduces
the
very
EP3
clutter
we
are
trying
to avoid
for
the
cohort
that
KYCs
out
of
curiosity
but
never
funds.
We
keep
KYC
as the
gate
(you
cannot
fund
un-KYC'd)
but
not
the
trigger.
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.
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:
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).
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:
is_kyc_approved
but
whose
users.is_onboarded
is
still
false
and
re-fires. This
is
cheap,
and
the
backstop
also
retro-onboards
the
existing
manually-managed population
on
first
run.
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_onboardedremains an AX-owned authZ/state attribute on the AXusersrow, not a Clerk concern — Clerk owns credentials and MFA, AX owns "is this user cleared to trade." This callback is unaffected by that migration.
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.
users
and
user_risk_profiles
—
identity/compliance,
created
at
signup.
onboarding_applications
and
its questionnaire/document/risk-assessment
children)
—
these
are
per
user
and exist
precisely
to
drive
KYC,
which
happens
before
materialization.
They
are identity-grained
and
correct
to
create
eagerly (Multi-Accounts
service
audit:
onboarding-gateway,
A-3402).
| 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. |
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.
lazy_account_provisioning,
per environment).
Off
=
today's
eager
flow;
on
=
identity-only
signup
+
deposit/KYC trigger.
Roll
out
sandbox
→
demo
→
prod.
trading_accounts). Is
requesting
an
address
the
intended
"first
funding
action,"
or
do
we
want
an even
earlier
explicit
"activate
account"
button?
Leaning:
address
request
is
the natural,
schema-forced
point.
SignupResponse.account_id.
Keep
returning
the
derived
(not-yet-real) account
id,
or
omit
it
until
materialization?
Returning
it
preserves
the
current response
shape
but
advertises
an
id
with
no
row
behind
it.
is_kyc_approved
predicate.
KYC
approval
is
today
an
unsynchronized conjunction
of
three
onboarding-application
flags
plus
a
separate risk_assessments.status.
The
exact
predicate
(and
which
writes
can
be
the
last to
flip
it,
hence
must
fire
the
callback)
needs
to
be
pinned
down
with compliance
—
§4
assumes
"all
four
complete"
but
that
should
be
confirmed.
| 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 |