Date: 2026-07-30
Status: Draft
Author: Joey McCarey (with Claude)
Related: commercial-program-accruals — the producer that turns this policy into an accrual row. api-broker-program — Program 2, which reuses this RFC's registry, ladder and accrual ledger. omnibus-broker-program — Program 3, which shares none of them. program-payouts — one candidate payer. partner-rebate-payouts — the requirements any payer must meet. continuous-recon-checks — the host for the cross-cutting invariant checks in §H. Sequencing is in SoW: Partner Programs, PRs 1–7.
A referrer earns a share of the trading fees AX collects from the clients it brought in. The share is a percentage set by a volume ladder. The program computes that share into an approved, auditable accrual row, one per earner per calendar month, and produces a CSV statement Finance reconciles. It does not move money.
signup with ?ref=CODE ClickHouse trades partners registry
attribution row, user -> user fees per account per day override/grandchild rates
| | |
+----------------------------------+-------------------------+
|
v
rs/sdk-internal/src/referral_program.rs
ownership -> eligibility -> daily ladder
|
v
rs/accrual-engine/src/rebates.rs
upsert partner_programs.rebate_accruals
|
+------------------+------------------+
v v
provisional row approved row
recomputed daily frozen, CSV statement
Three facts set the shape of everything below:
| Fact | Consequence |
|---|---|
| Attribution binds a user, not an account | Account
provisioning
is
lazy,
so
no
trading
account
exists
at
signup.
The
ledger
is
keyed
on
users.id,
and
the
accrual
resolves
user
→
accounts
through
trading_accounts.ubo_user_id. |
| The rebate is a share of fees collected, not of notional volume | Volume
only
sets
the
ladder
rung.
The
base
is
maker_fee
or
taker_fee
on
the
attributed
side
of
each
fill. |
| A fill-side can have two owners | Referral and API Broker/OMS both earn on flow that carries a referral attribution and a Broker ID. The pair splits the lower of their two rates, half each (D7′). A referral-only accrual is still correct on its own, because no fill carries a Broker ID until the broker arm lands. |
The program terminates at an approved accrual plus a CSV statement. That pair is the settlement artifact until a payer ships.
Every question that gated the accrual is answered. Two remain.
| # | Question | Owner | Gates |
|---|---|---|---|
| O6 | Is an indefinitely held balance acceptable for a partner who never completes KYB, or must the claim window be finite? Held-not-forfeited is settled (payouts RFC P8), so the answer sets a constant. | Compliance + Finance | Payout only. Now a shared payout-module gate (A-4508); holds SoW PR 12′ |
| O12 | Confirm the progressive-daily ladder rate (D3): a promotion applies forward day by day rather than repricing the whole month, and a day's own volume counts toward that day's rung. The fold is one function either way. | Commercial | The first partner statement |
One further gate is operational. Every non-standard-priced account must carry the right pricing classification before an accrual is trustworthy (D4a). There is no backfill pass: classification is entered through the admin path, and the accrual refuses to run rather than under-collect silently. This is a standing property, not a milestone to clear.
AX Commercial runs three partner-economics programs on staggered timelines:
Programs 1 and 2 share one rebate ladder (Bronze/Silver/Gold → 30/40/50% of eligible fees) and one attribution rule: a fill-side may be owned by both, and when it is, the two earners split the lower of their two rates (D7′). Program 3 uses neither.
| Component | State |
|---|---|
| Fees on the tape | Both
trade
engines
resolve
the
account's
rate
from
in-memory
account_fee_rates
and
write
maker_fee
/
taker_fee
onto
the
ClickHouse
trades
row
at
fill
time
(trade-engine2/src/state_machine.rs:369-392,
btnl-trade-engine/src/state_machine.rs:610).
trades
is
a
sufficient
source
for
the
rebate
base. |
| Missing fee rate | Both
engines
book
dec!(0)
when
an
account
is
absent
from
account_fee_rates
and
log
an
error
(state_machine.rs:376-387).
A
structurally-zero
fee
is
indistinguishable
on
the
tape
from
a
genuinely
free
fill.
D1d
covers
it. |
| Fee schedule | fee_schedules
(db/postgres/1.sql:640-681)
is
one
global
immutable
JSONB
tier
ladder,
window_shape = 'trailing_30d',
with
a
structural
validator
and
a
freeze
trigger.
Typed
as
FeeProgram
/
FeeTier
in
rs/sdk-internal/src/fee_program.rs.
Maker
fees
may
be
negative. |
| Fee engine | refresh_fee_rates_task()
(rs/accrual-engine/src/fee.rs)
recomputes
each
account's
tier
from
a
hysteretic
trailing-30d
volume
and
writes
(maker_fee, taker_fee)
onto
trading_accounts.
It
writes
rates
only:
there
is
no
persisted
tier
index
and
no
fee-rate
audit
table. |
| Volume aggregation | query_account_volumes_windowed()
(rs/sdk-internal/clickhouse/src/schema.rs:1191-1258)
sums
notional
per
(account,
symbol)
over
a
set
of
windows
with
per-symbol
multiplier
scaling,
returning
missing_multipliers
so
a
caller
can
refuse
rather
than
mis-bill. |
| Accounts | trading_accounts
(db/postgres/1.sql:698-735)
is
flat:
id,
maker_fee
/
taker_fee,
ubo_user_id
→
users.id.
No
partner
FK,
no
referral
code,
no
persisted
tier. |
| Registration | Self-serve
signup
is
Clerk-owned.
POST /login/clerk
(rs/api-gateway/src/public_routes.rs:117)
is
the
only
entry
point
and
fires
on
every
login.
It
materializes
the
user
through
resolve_or_materialize_clerk_user
→
provision_user
(rs/api-gateway/src/auth.rs:155,
:210),
and
the
trading
account
separately
through
materialize_default_account
(auth.rs:269). |
| Daily rollup | account_volume_1d
(A-4334)
is
a
per-(account,
symbol,
day)
rollup
of
notional,
fees
and
trade
counts.
It
is
DDL
only;
its
write
path
is
deferred.
D4c
is
why
the
accrual
does
not
wait
for
it. |
The fee system is volume-ladder-based and has no notion of a partner, referrer, broker, or rebate ledger. This RFC adds a partner-and-attribution model, an eligibility model, a rebate accrual ledger, and a periodic accrual job.
| Framework language | Requirement | Mechanism |
|---|---|---|
| "Referral ownership is established at account registration using the referral link or code" | Capture at registration, keyed to the client | Signup insert in the registration transaction (§A) |
| "A client may be assigned to only one Referral Partner" | Exactly one referral owner per client | Partial unique index, one signup row per user (§B) |
| "Referral ownership remains in place unless AX approves an exception" | Permanence by default; change only by approved exception | Append-only ledger; corrections are new rows; approval enforced by the admin surface (D2a) |
| "Referral attribution takes priority over a later API Broker / OMS connection" / "no double rebate" | Superseded by Commercial: the two programs share a fill-side rather than one excluding the other | The split rule (D7′). What survives of "no double rebate" is the bound: a shared fill-side pays out no more in total than either program would have paid alone |
| Monthly rebate calculation on 30-day eligible volume | Owner-at-period reads that do not shift after a month is paid | The strictly-after ordering rule on corrections (D2a) |
The framework requires no audit trail and no immutability mechanism. Those are engineering choices, sized in D2a to serve the last two rows. Two-level earning and retail refer-a-friend are additions from the team, not framework requirements (D2d).
One framework rule has been withdrawn. The framework wrote referral and broker as mutually exclusive claims on the same flow, settled by a strict waterfall. Commercial changed course: both earn. D7′ carries the replacement and the reasoning; the row above records what it displaced, because the waterfall framing is still quoted in older notes.
D1. The rebate is computed on fees AX collected, not on notional volume. Volume sets the ladder level only.
D1a.
The
base
is
the
fee
on
the
attributed
side
of
each
fill: maker_fee
when
the
attributed
account
was
the
maker,
taker_fee
when
it
was the
taker.
Never
both
legs
of
one
fill.
This
makes
the
referral
aggregation structurally
identical
to
the
broker
one (API
Broker
§D),
which
splits
maker
and
taker
legs and
UNION ALLs
them.
D1b.
Maker
fees
may
be
negative,
so
an
attributed
fill-side
can
carry negative
fee
revenue.
Only
positive
fees
earn,
floored
per
fill-side:
each attributed
fill-side
contributes
GREATEST(fee, 0).
A
maker-rebate
fill neither
earns
nor
subtracts.
D1c. On the current ladder and exclusion set, D1b never fires. Two facts give this:
FeeProgram::validate()
requires taker_fee >= 0
(rs/sdk-internal/src/fee_program.rs:170).
rs/admin-cli/examples/fee_program.yaml
rungs
0–2
are
maker-positive (0.0005 / 0.0002 / 0.00005)
and
only
the
>$100M
rung
is
negative (-0.00005,
with
taker_fee: 0).
Because the floor never fires, per-fill, per-account and per-partner flooring are numerically identical today, and the statement ties out against net fee intake.
The
margin
is
one
rung.
Under
tier_index < 3
the
lowest
eligible
maker rate
is
rung
2's
0.00005,
directly
above
the
negative
rung.
A
ladder
edit that
pushes
rung
2
negative
puts
a
maker-negative
rung
inside
the
eligible
set in
one
step.
§H
check
5
refuses
that
edit,
and
its
bound
must
move
with
the
§C predicate.
D1b
is
the
arithmetic
that
keeps
the
rebate
correct
if
the
edit lands
anyway;
§H
check
1
is
what
stops
a
partner
being
overpaid.
D1d.
A
zero
fee
is
a
fact
to
verify,
not
a
value
to
trust.
Both
trade engines
book
a
zero
fee
when
an
account
is
missing
from
account_fee_rates, and
the
tape
cannot
distinguish
that
from
a
genuinely
free
fill.
Left
alone
it under-attributes,
and
it
under-attributes
for
accounts
whose
configuration
is broken
—
a
population
correlated
with
new
onboarding,
where
referred
clients are.
The
accrual
counts
zero-fee
fills
with
non-zero
notional
on
eligible attributed
accounts
and
treats
them
as
a
coverage
measure
alongside unjoinable_*
(§E).
Above
a
threshold
it
refuses
the
period
rather
than accruing
a
number
known
to
be
low,
matching
the
refuse-on-missing-multiplier posture.
Where
a
trade
engine
exports
fee_miss_count,
recon
cross-checks
it against
the
tape-derived
count.
D1e. Goodwill credits and fee waivers do not reduce a rebate in v1 (Finance + Eng, 2026-08-03). AX has no refund mechanism. Where AX hands a client money back outside the tape, the partner keeps the rebate on the returned fee. The statement says so (§F).
Trade
corrections
and
busts
are
a
different
case
and
self-heal:
trades
is
a ReplacingMergeTree,
and
the
accrual's
FINAL
read
plus
daily
recompute
picks up
the
superseding
row.
That
stops
at
approval,
after
which
a
correction
is
a forward
adjustment
(Payouts
P7).
The
exposure
cannot
be
measured.
A
goodwill
credit
is
a
manual
POST /deposit with
nothing
distinguishing
it
from
any
other
deposit.
Revisit
when
the amounts
become
material
or
a
deposit
carries
a
reason
code.
D1f. Block trades and liquidation fills both earn (Finance, 2026-08-03). Every fill on a fee-eligible attributed account contributes, which makes the accrual's trade filter identical to the fee engine's own.
Final-settlement
fills
are
excluded.
is_final_settlement
marks
the closeout
of
a
delisted
or
expired
contract,
the
fee
engine
already
excludes
it, and
it
is
not
flow
a
partner
introduced.
Because
is_final_settlement
implies is_block_trade,
block
trades
earn
except
the
settlement
ones.
D2.
Attribution
binds
a
user,
through referral_attributions.user_id.
Account
provisioning
is
lazy,
so
at
the
moment the
referral
link
is
used
no
trading
account
exists.
The
accrual
resolves
user →
accounts
through
trading_accounts.ubo_user_id.
Every
account
that
user
ever opens
is
attributed
to
the
referrer,
including
later
ones.
D2a.
Attribution
is
an
append-only
ownership
timeline: (user_id, attributed_at, referred_by, code, origin, approved_by, reason), where
referred_by
names
a
user.
The
current
owner
is
the
greatest attributed_at <= now.
A
partial
unique
index
on
(user_id) WHERE origin = 'signup'
makes
first
touch
permanent
and
every
repeat
login
a
no-op.
An exception
is
a
second
row
with
origin = 'admin_exception'.
Immutability
is
enforced
by
privilege,
not
by
trigger.
The
application
role holds
SELECT, INSERT
only;
UPDATE,
DELETE
and
TRUNCATE
are
not
granted. Grants
survive
session_replication_role = replica
and ALTER TABLE ... DISABLE TRIGGER,
which
bypass
trigger-based
guards.
One trigger
remains:
the
strictly-after
ordering
check
on
admin-origin
inserts, which
keeps
already-paid
months
stable.
There
is
no
back-dating
path
—
the ledger
carries
no
backfill
origin,
and
the
ordering
trigger
refuses
an exception
attributed
at
or
before
the
row
it
supersedes
(O7).
approved_by
and
reason
are
operator
notes
recorded
by
the
admin
surface. They
are
not
an
approval
system
and
not
an
audit
trail;
enforcement
of
"AX approves"
lives
in
the
surface
that
writes
the
row.
D2b.
A
referrer
owns
many
campaign
codes,
many
live
at
once,
in referral_codes.
A
code
has
no
status
and
no
validity
window.
One
client
still gets
one
owner;
the
attribution
records
which
code
minted
it,
for
campaign analytics.
There
is
no
max_uses
cap
and
no
cap
on
referrals
received
(Commercial, 2026-07-30).
A
count-then-insert
cap
is
a
TOCTOU
race
under
concurrent
signups. A
capped
campaign,
if
Commercial
needs
one,
gets
a
race-free
counter
design.
D2c.
Retiring
a
code
is
forward-only,
through
a
retired_at soft-retire.
It
stops
the
code
minting
new
attributions
and
revokes
none. Referred
clients
stay
owned
and
keep
accruing.
referral_attributions.code
is therefore
denormalized
TEXT
rather
than
a
foreign
key:
a
reference
would block
the
retire
and
make
the
campaign's
history
deletable
with
it.
D2e. Capture gates on nothing but the code's existence. Neither the referrer's status nor its enrollment is read at registration. Attribution is first-touch and permanent, so a code that refuses to resolve destroys the fact that the user arrived through it, and no later correction recovers it.
Accrual
computes
regardless.
partners.status
is
read
at
payout,
the
only place
it
can
stop
anything,
and
an
accrual
that
cannot
be
paid
is
held
rather than
forfeited
(payouts
RFC
P8).
D2d. Two-level earning and retail refer-a-friend are in scope. A referrer's referrer earns on the referred client's fees, and a retail user may refer like any partner.
Both
cost
the
schema
nothing,
because
the
ledger
is
the
tree.
D2a's
timeline records
user
→
user
edges.
A
retail
referrer
is
an
ordinary
user;
an institutional
referrer
is
a
user
too,
because
its
org
holds
a
backing
users row.
The
grandparent
is
not
stored:
for
a
period
ending
at
t, override(U, t) = owner(owner(U, t).referred_by, t),
one
more
step
of
the
read the
accrual
already
does.
Corrections follow, they do not freeze. Correcting a referrer's own ownership moves the override on all of their recruits for future periods in one row. Settled periods are untouched, because the strictly-after rule pins past reads.
Depth is capped at two by the accrual reader taking exactly two steps. The cap belongs to the money-split policy, pinned by tests, not to the schema. Cycles need no guard: a user is attributed before they can refer, so signup edges are temporally ordered and a two-step reader terminates.
The
second
level
is
an
internal-BD
instrument,
not
a
public
tier. Commercial
runs
it
for
named
sales
and
BD
staff
who
recruit
referrers
—
one person
at
launch.
It
is
gated
on
the
grandparent
carrying
a
grandchild_rate in
the
partners
registry:
no
rate,
no
grandchild
arm,
which
is
the
state
of every
ordinary
user
in
the
tree.
Both
arms
land
on
one
accrual
row,
in
their
own
columns:
the
child
arm
in rate_source
/
rebate_rate
/
eligible_fees_usd,
the
grandchild
arm
in grandchild_rate
/
grandchild_fees_usd
/
grandchild_amount_usd,
and rebate_amount_usd
as
the
payable
total.
The
per-account
child/grandchild split
is
in
detail_json.
There
is
no
level
column:
the
arms
are discriminated
by
columns,
not
by
rows.
The two registry rates are independent. An earner may pair a flat override on its direct book with the grandchild arm, run the ladder on its direct book alongside the grandchild arm, or carry either alone.
D3. The ladder level is derived from the earner's aggregate attributed 30-day pricing-eligible volume, resolved per ET day from the trailing 30 ET days ending with that day. The rate is progressive: each day's eligible fees earn the rung in force that day, so a promotion applies forward with one-day granularity. The ladder floor is $0, so every day resolves to a rung.
The daily resolution comes from the tape — per-account-per-day volume from ClickHouse, prefix-summed in the policy core — so it needs no Postgres state and the run is a stateless recompute. There is no hysteresis: hysteresis exists to stop billed fee rates flapping day to day, and a daily-resolved ladder inside a monthly accrual has nothing to flap.
The
partner
ladder
is
not
the
client
fee
tier.
The
ladder
reads
the earner's
aggregate
attributed
volume
and
sets
the
earner's
rate
(30/40/50%). tier_index
reads
a
client
account's
own
volume
against
the
fee
schedule
and sets
eligibility
—
whether
that
account's
fees
count
at
all.
Merging
them reintroduces
demotion-by-success
(D4b):
a
top-tier
client
would
stop
lifting its
referrer's
rung
at
the
same
moment
it
stops
earning.
D4.
tier_index
is
read
live
at
each
accrual
run
from
the
account's currently
billed
rates,
through
FeeProgram::tier_index_for_rates
against
the schedule
in
force,
and
snapshotted
per
account
onto
the
accrual
row
(D4d′). There
is
no
account_fee_rate_changes
log
and
eligibility
is
not event-sourced.
An account that re-tiers mid-period is scored whole-period at the state the daily run sees. The exposure is one provisional period, because the open period recomputes daily and approval freezes the row (D6a).
D4a. Eligibility comes only from matching the account's live billed rates to the current fee ladder. An account whose rates match no rung is excluded from both ladder volume and the rebate base.
D4b.
Tier
4
—
the
>$100M
rung
—
is
the
only
rung
carved
out
of
rev share,
and
the
carve-out
is
on
earnings,
not
on
ladder
volume
(Business 2026-07-30;
Finance
and
Business
2026-08-03).
An
attributed
account
on
that rung
on
a
given
day
earns
its
referrer
nothing
for
that
day,
and
its
volume still
counts
toward
the
referrer's
ladder
level.
Three consequences:
tier_index < 3,
so
Tier
3
earns.
The
tier
numbering
is 1-indexed:
Tier
1
(0–1M)
through
Tier
4
(>$100M).
Only
the
top
rung
is carved
out.
Treat
any
document
saying
Tier
3
is
excluded
as
stale.
pricing_eligible
for
the
ladder
and
fee_eligible
for the
rebate
base.
Collapsing
them
reintroduces
the
behaviour
O11
rejected.
D4c.
The
accrual
reads
fees
and
volume
straight
from
ClickHouse trades,
behind
a
single
function,
and
depends
on
no
derived
rollup. account_volume_1d
(A-4334)
exists
to
keep
interactive
endpoints
off
full partition
scans;
the
accrual
is
a
batch
job
where
a
partition
scan
is acceptable.
Routing
it
through
one
seam
makes
adopting
the
rollup
later
an implementation
swap.
D4d′.
The
resolved
tier_index
is
snapshotted
per
account
into
the accrual's
detail_json,
so
a
figure
stays
defensible
months
later
without
a second
table.
A
fee-rate
change
during
a
period
rescores
the
whole
open
period on
the
next
run;
approval
freezes
the
row.
D5.
Accruals
are
written
to
partner_programs.rebate_accruals,
one
row
per (earner,
program,
period),
recording
the
eligible-fee
base,
the
anchor
ladder level,
the
computed
amount,
coverage
metrics,
and
a
detail_json
breakdown
— the
per-day
ladder
walk
and
the
per-account
eligibility
snapshot.
A
row
is freely
recomputed
while
approved_at IS NULL
and
frozen
after.
Approval converts
a
provisional
number
into
a
promise.
D5a.
Approval
is
automatic
below
$1,000
and
manual
at
or
above
it.
The threshold
is
on
rebate_amount_usd
and
is
a
configured
parameter
recorded
on each
approved
row
(approval_threshold_usd),
so
a
later
change
does
not
make old
rows
unexplainable.
The
column
lands
with
the
auto-approval
task
in
PR
7; the
accrual
engine
writes
rows
unapproved
and
never
reads
it.
Provisional: whether a human must authorize a sweep at all is with Legal and Compliance. If they require review regardless of size, the threshold drops to zero and the auto path is never taken. No structural change, so the build does not wait.
Four rules bound the automatic path:
approved_by
records
which
path
ran
—
the
sentinel 'system:auto-approve'
rather
than
a
NULL,
so
the
approval
trail
never implies
a
human
reviewed
a
row
no
human
saw.
A sub-threshold accrual for an established earner therefore goes from computed to credited with no human at any point, because the monthly sweep (payouts RFC P9) also gates on approval. The escalation rules above and the §H invariants are the only controls on that path.
D5b. The producer is a pure core plus its own host task, not a module of a shared accrual engine. The core is a pure, I/O-free policy module holding ownership and the split rule (D7′), the two predicates (D4b) and the ladder (§D); the host task owns the ledger. This is the shape the liquidity program shipped. The full reasoning, the cost of adopting a shared engine later, and the trigger for doing so are in commercial-program-accruals D1, D1a and O2.
Policy lives in one crate that nothing else can fork. What a dollar of referred flow is worth is not forkable.
The
payee
is
optional.
payout_account()
is
Option<AccountId>
because attribution
binds
a
user
and
provisioning
is
lazy,
so
an
earner
can
hold
an accrual
while
owning
no
trading
account.
The
host
task
declines
to
enqueue
such a
row,
counts
it,
and
leaves
it
approved.
The
payout
module's
account_id
is NOT NULL
precisely
so
this
case
never
reaches
its
queue.
D6.
The
accrual
job
is
a
periodic
task
in
rs/accrual-engine (rebates.rs),
structurally
mirroring
fee.rs:
a compute_referral_rebates_once()
wrapped
in
run_periodic().
It
runs daily,
upserting
the
open
monthly
period
and
any
prior
period
not
yet approved.
The
accrual
period
is
the
calendar
month;
daily
recompute
gives
BD
a live
running
number
and
needs
no
monthly
Cadence
variant.
D6a. An intra-period accrual is provisional and may go down. Under D3 a booked day's rung does not move when the earner is later promoted, so the running number is monotone in the ladder. Eligibility is resolved live (D4), so a fee-rate update or a wash-trade exclusion still revalues the whole open period. Daily recompute makes that self-healing rather than a manual restatement.
The running number is labelled provisional wherever it is shown, and approval is the moment it becomes a promise. Nothing intra-period is sent to a partner as an owed amount.
D7′.
(Revised:
the
strict
waterfall
is
withdrawn.)
A
fill-side
may
be owned
by
two
programs
at
once,
and
both
earn.
Ownership
is
resolved
per fill-side,
per
ET
day,
by
reading
the
two
sources
independently
rather
than
by falling
through
a
CASE:
referral_attribution,
which
gives
a
referral
owner;
Four outcomes, of which only the third is new:
referral only -> referral earns r(d) * fee
broker only -> broker earns b(d) * fee
both -> referral earns 0.5 * min(r(d), b(d)) * fee
broker earns 0.5 * min(r(d), b(d)) * fee
neither -> AX direct, no accrual
r(d)
and
b(d)
are
each
owner's
own
effective
rate
for
that
ET
day
—
its progressive-daily
ladder
rung
(D3),
or
its
partners.override_rate
where
it carries
one.
The
two
ladders
are
resolved
independently
first;
the
min
and
the halving
are
applied
afterwards,
to
the
shared
fill-side
only.
Why
min
and
not
max
or
the
sum.
The
replaced
rule
paid
one
owner
its full
rate.
The
split
must
not
cost
AX
more
than
that
rule
did
on
any
fill-side,
or the
change
is
a
repricing
rather
than
a
reallocation.
Taking
the
lower
of
the two
rates
and
halving
it
bounds
a
shared
fill-side
at
min(r, b)
in
total
—
no more
than
either
owner
would
have
earned
alone,
and
strictly
less
than
the waterfall
paid
whenever
the
referral
owner
was
the
higher-rated
of
the
two.
The cost
of
the
change
is
therefore
borne
by
the
partners,
which
is
what
makes
it
a policy
question
Commercial
could
settle
and
not
a
margin
question.
Ladder volume is not split. A shared account's volume counts in full toward both owners' 30-day ladder totals (§D). Halving it would demote both owners for the act of cooperating, and the ladder measures flow sourced, not fees paid. This is the same asymmetry as D4b — volume and earnings answer different questions.
The
grandchild
arm
halves
with
its
child
(D2d).
On
a
shared
fill-side
the whole
referral
side
is
halved,
grandchild_rate
included,
so
it
never
pays
out more
than
the
sole-owner
case
it
replaced.
Worst
case
falls
from 50% + 20% = 70%
to
0.5 * (50% + 20%) + 25% = 60%,
so
§H
check
1
keeps
the margin
it
had.
One
partner
may
hold
both
sides.
A
partner
that
is
the
referral
owner
and the
broker
owner
of
the
same
fill-side
accrues
both
halves,
on
its
two
rows (program_kind = 'referral'
and
'api_broker'),
for
min(r, b)
in
total
—
the same
amount
any
two
partners
would
have
shared.
Nothing
special-cases
it.
Nothing reprices retroactively. A fill is shared only if it carried a Broker ID when it was placed. No fill carries one today, so every fill accrued before the broker arm ships is sole-owner forever and the referral-only accrual stays exactly as computed. The split begins at the first Broker ID, forward.
What survives of "no double rebate". Not "one owner per fill-side" — that is what changed — but the bound it existed to enforce: total payout on a fill-side never exceeds what a single owner would have drawn. That is now an invariant the accrual asserts (§H check 2) rather than a structure that makes it unreachable, which is a real loss of safety and the reason the check is hard rather than monitored.
D8.
Partner
source-of-truth
tables
live
in
a
dedicated
partner_programs schema,
following
the
mm_liquidity_performance
precedent (1.sql:962-1050):
partners,
referral_codes,
referral_attributions,
and the
accrual
ledger.
Namespacing
keeps
1.sql
readable
and
lets
the
tables carry
partner-neutral
names
the
broker
RFC
extends
without
a
rename.
Closed enums
are
TEXT
plus
a
named
CHECK
at
the
database
layer
with
typed
Rust enums
at
the
application
layer.
D8a.
rebate_accruals
lives
in
partner_programs,
beside
its
sources, per
commercial-program-accruals
D7.
One schema
owns
this
domain
end
to
end:
the
facts,
the
policy,
and
the
ledger
they produce.
D8b.
The
accrual
row
is
value-typed:
it
carries
everything
that
produced its
figure.
The
snapshot
is
the
anchor
ladder_level
and attributed_volume_usd
on
the
row,
plus
the
per-day
ladder
walk
in detail_json
—
each
day's
trailing
volume,
rung,
rate,
base
and
amount
— alongside
the
per-account
eligibility
snapshot
(D4d′).
An
override
row
records its
flat
rebate_rate;
a
grandchild
arm
records
its
own
rate,
base
and
amount in
the
grandchild_*
columns.
The row defends its own figure. "Why is this $8,412?" is answered by this volume, these days at these rungs, this fee base, this breakdown — with no join, and no dependency on another table staying immutable.
The
snapshot
is
the
whole
audit
story.
There
is
no
rebate_schedules
table and
no
rebate_schedule_id
provenance
column:
the
ladder
is
a
constant
in
the pure
policy
core,
pinned
by
tests,
and
which
ladder
was
in
force
at
compute time
is
answered
by
the
deploy
history.
The
table
returns,
additively,
the first
time
Commercial
wants
a
rate
change
faster
than
a
deploy
or
a
per-partner ladder;
rebate_schedule_id
returns
with
it
as
a
nullable
provenance
column
on rows
written
afterward.
D8c.
Superseded
by
D8b.
This
decision
governed
when
a rebate_schedule_id
provenance
reference
had
to
be
recorded.
With rebate_schedules
deferred
there
is
no
reference
to
police.
The
principle
it encoded
—
anything
the
frozen
row
will
ever
need
is
written
at
compute
time
— is
D8b's
snapshot.
If
the
table
returns,
this
decision
returns
with
it.
A referral code enters the system on any inbound app URL and reaches the attribution row inside the registration transaction.
any app URL ?ref=CODE root, deep link, or the signup page
-> AuthProvider captures once per load gui/packages/app/provider/AuthContext.tsx
-> localStorage gui/packages/app/util/referralCode.ts
-> useReferralCapture gui/packages/app/hooks/useReferralCapture.ts
-> ClerkSignupPage referral field pre-filled, editable, never blocks submit
-> exchangeClerkSession(token, code) gui/packages/app/util/clerkSession.ts:24-41
-> loginWithClerkToken(token, code) gui/packages/app/provider/AuthContext.tsx:38,125-141
-> POST /login/clerk { clerk_token, expiration_seconds, referral_code }
rs/sdk/src/protocol/api_gateway.rs:114-118
-> clerk_login rs/api-gateway/src/public_routes.rs:127-148
-> resolve_or_materialize_clerk_user rs/api-gateway/src/auth.rs:155-204
-> provision_user rs/api-gateway/src/auth.rs:210-262
attribution row written in the SAME tx as the users row (auth.rs:249-252)
The
attribution
row
is
written
in
provision_user's
existing
transaction.
The users
row
is
the
registration
event,
user_id
is
the
correct
key
(D2),
and
one transaction
means
a
split-write
cannot
lose
the
attribution
while
consuming
the code.
The
captured
code
is
shown
in
the
signup
form,
pre-filled
and
editable.
URL capture
is
last-wins:
a
visitor
who
follows
two
referrers'
links
stores
the second,
and
attribution
is
first-touch
and
permanent.
Showing
the
code
makes
the choice
visible
at
the
only
moment
it
can
be
corrected,
and
doubles
as
the
way
to enter
a
code
handed
over
out
of
band.
The
field
is
optional
and
never
blocks submit;
a
value
that
does
not
normalize
is
dropped
at
exchange
time,
as
a malformed
?ref=
is.
Three behaviors this path must get right:
/login/clerk
fires
on
every
login,
not
just
signup.
Pass
the
code
only down
the
resolve_or_materialize_clerk_user
miss
branch
(auth.rs:180),
with the
WHERE origin = 'signup'
partial
unique
index
as
the
backstop,
so
a concurrent
or
replayed
exchange
is
a
no-op
rather
than
a
second
owner.
warn
and
commit
the
user with
no
attribution.
A
referral
code
is
marketing
metadata
at
capture
time
and must
not
be
able
to
500
a
registration.
Edition::auto_materializes_default_account()
is
false).
The
edition
gate is
on
accrual.
Validate
referral_code
on
the
way
in
with
the
same
shape
as
the
table
CHECK (trim,
uppercase-normalize,
^[A-Z0-9]{4,32}$)
so
hbot
and
HBOT
cannot
split attribution.
rs/onboarding-gateway/src/validation.rs:83-91
is
the
precedent.
Do
not
reuse
lead_source.
It
is
keyed
by
email
on
waitlist_entries,
is
never joined
to
users,
and
was
never
meant
to
carry
money.
Cross-repo
dependency.
The
app
captures
?ref=
on
any
inbound
app
URL,
so
a referral
link
pointing
straight
at
the
app
needs
no
other
repo.
A
link
that points
at
the
marketing
site
first
needs
a
coordinated
change
in architect-xyz/landing-page:
persist
the
code,
carry
it
to
the
app
on
the
shared apex
domain.
The
two
ship
in
either
order.
One
append-only
edge
store
over
users,
plus
one
sparse
registry
for
money. Referral
edges
are
user
→
user;
partners
is
a
payout
registry,
not
a
graph node.
CREATE SCHEMA partner_programs;
-- Campaign codes. Many per referrer, many live at once. Retiring one sets
-- retired_at; the row stays so an already-minted attribution's denormalized
-- code keeps its meaning, and it stops resolving new signups.
CREATE TABLE partner_programs.referral_codes (
code TEXT PRIMARY KEY,
user_id CHAR(16) NOT NULL REFERENCES users(id), -- the referrer
created_at TIMESTAMPTZ NOT NULL DEFAULT CURRENT_TIMESTAMP,
retired_at TIMESTAMPTZ,
CONSTRAINT referral_codes_format_valid CHECK (code ~ '^[A-Z0-9]{4,32}$')
);
CREATE INDEX referral_codes_user_id_idx
ON partner_programs.referral_codes (user_id);
-- Append-only ownership timeline. Current owner = greatest attributed_at <= now.
-- The tree lives here and only here: override(U, t) is two steps of the same
-- read (D2d), never a stored column.
CREATE TABLE partner_programs.referral_attributions (
user_id CHAR(16) NOT NULL REFERENCES users(id), -- the referred user
-- NULL only for a revocation, which ends ownership naming no successor.
referred_by CHAR(16) REFERENCES users(id),
-- Denormalized, not a reference: retiring a campaign must neither be
-- blocked by this row nor erase how the user arrived.
code TEXT,
attributed_at TIMESTAMPTZ NOT NULL DEFAULT CURRENT_TIMESTAMP,
origin TEXT NOT NULL,
-- Operator notes recorded by the admin surface. The CHECK enforces
-- presence; the surface enforces approval.
approved_by TEXT,
reason TEXT,
CONSTRAINT referral_attributions_origin_valid
CHECK (origin IN ('signup', 'admin_exception')),
CONSTRAINT referral_attributions_exception_is_approved
CHECK (origin = 'signup'
OR (approved_by IS NOT NULL AND reason IS NOT NULL)),
CONSTRAINT referral_attributions_code_for_signup
CHECK (origin <> 'signup' OR code IS NOT NULL),
-- A signup always names a referrer; only an exception may end ownership
-- by naming nobody, which is how a revocation is spelled.
CONSTRAINT referral_attributions_signup_names_referrer
CHECK (origin <> 'signup' OR referred_by IS NOT NULL),
-- A user cannot refer itself: a self-edge would make an earner with a
-- grandchild rate its own grandparent and pay both arms on one book.
CONSTRAINT referral_attributions_no_self_attribution
CHECK (referred_by IS NULL OR referred_by <> user_id),
PRIMARY KEY (user_id, attributed_at)
);
-- First touch wins, permanently; a repeat or concurrent login is a no-op.
CREATE UNIQUE INDEX referral_attributions_one_signup_per_user_idx
ON partner_programs.referral_attributions (user_id) WHERE origin = 'signup';
CREATE INDEX referral_attributions_referred_by_idx
ON partner_programs.referral_attributions (referred_by);
-- Commercial counterparty facts for a user that gets paid: whether payout is
-- gated, where the money goes. Not a graph node, and not required to refer:
-- rows are minted at payout onboarding, and a referrer with no row accrues
-- normally with the accrual held (payouts RFC P8). Shared with the API Broker
-- RFC, which adds its own attribution source against the same registry.
CREATE TABLE partner_programs.partners (
user_id CHAR(16) PRIMARY KEY REFERENCES users(id),
status TEXT NOT NULL DEFAULT 'pending', -- gates payout only
-- Nullable by design: lazy provisioning means a referrer may have no
-- trading account at all.
payout_account_id CHAR(16) REFERENCES trading_accounts(id),
-- A negotiated flat rate that REPLACES the ladder on this referrer's
-- DIRECT (child) book (D2d). NULL is the ordinary case. A rate and not a
-- level: 20% is not a rung.
override_rate NUMERIC,
-- The flat rate this earner is paid on grandchild flow; its presence gates
-- the second level (D2d). Set only for named internal sales/BD staff.
-- Independent of override_rate.
grandchild_rate NUMERIC,
CONSTRAINT partners_status_valid
CHECK (status IN ('pending', 'active', 'suspended', 'terminated')),
CONSTRAINT partners_override_rate_valid
CHECK (override_rate IS NULL
OR (override_rate > 0 AND override_rate <= 1)),
CONSTRAINT partners_grandchild_rate_valid
CHECK (grandchild_rate IS NULL
OR (grandchild_rate > 0 AND grandchild_rate <= 1))
);There
is
no
excluded_accounts
table.
Literal
self-dealing
needs
no
row:
the accrual
skips
any
account
whose
ubo_user_id
is
the
earner's
own
user
(§C,
§G). Judgment-call
exclusions
—
a
related
party's
account,
wash-flagged
flow,
anything Compliance
carves
out
other
than
by
key
equality
—
wait
for
the
first
real
case, with
withheld
approval
as
the
interim
control,
since
an
unapproved
row
recomputes freely
and
pays
nothing.
The
re-add
is
one CREATE TABLE (account_id, reason)
—
global
per
account,
not
per
(referrer, account),
since
an
account
Compliance
distrusts
is
bad
flow
for
every
earner
— plus
one
NOT IN
in
the
accrual
fold.
Grants.
The
application
role
holds
SELECT, INSERT
only
on referral_attributions;
UPDATE,
DELETE
and
TRUNCATE
are
not
granted.
One trigger
enforces
the
strictly-after
ordering
rule
on
admin-origin
inserts. 1.sql
carries
no
roles
or
grants,
so
the
app
role
and
its
grants
land
as
a deploy
step
or
an
a-*.sql
file
alongside
the
schema,
verified
per
environment.
Code
redemption
rule.
A
code
mints
an
attribution
when,
at
registration
time, the
code
exists,
is
not
retired,
and
the
user
has
no
origin = 'signup' attribution.
Nothing
about
the
referrer
is
consulted
(D2e).
The
code
resolves
to its
owning
user_id,
which
is
denormalized
onto
the
attribution
row
alongside the
code,
so
retiring
the
code
orphans
neither.
At
each
run
the
accrual
reads
each
UBO-owned
account's
live
billed
rates, resolves
tier_index = FeeProgram::tier_index_for_rates(rates)
against
the schedule
in
force
(NULL
means
off-ladder),
and
snapshots
the
result
into
the accrual
row's
detail_json.
The
whole
open
period
is
scored
against
that
state.
There are two predicates, and they are not interchangeable. An attributed account is:
pricing_eligible
when
tier_index IS NOT NULL.
A
NULL
tier
means off-ladder
rates
and
excludes
the
account.
This
drives
ladder
volume
(§D).
fee_eligible
when
pricing_eligible
and
tier_index < 3
—
every rung
below
Tier
4
(>$100M),
the
only
carve-out
(D4b).
This
drives
the rebate
base
(§E).
The gap between them is one population: Tier-4 accounts, which raise an earner's level and earn it nothing. Writing this as a single predicate is the likely implementation error and silently restores demotion-by-success.
Neither
predicate
is
the
whole
answer.
Nothing
is
paid
on
fee_eligible alone;
it
is
the
account-level
half.
The
accrual
also
applies
the
per-earner conditions:
ubo_user_id
is
not
the
earner's
own
user
(structural self-dealing
exclusion);
users.is_onboarded AND NOT is_frozen.
Trade-level
exclusions
inside
the
fee
aggregation
are
two
(D1f): is_final_settlement = false,
and
a
wash
filter
over
same-ubo_user_id-on-both- sides
fills,
plus
the
quarterly
manual
review
(O5).
There is no periodic task here and no table. Eligibility is a read the accrual makes for itself. The only scheduled work in this RFC is the accrual (§E).
partner_30d_volume(d) = Σ over pricing_eligible attributed accounts of
trailing_30d_volume(account, over the 30 ET days
ending with ET day d)
-- pricing_eligible, NOT fee_eligible (§C). A Tier-4 account contributes its
-- full volume here while contributing zero fees to §E. An off-ladder account
-- contributes to neither.
level(d) = Bronze if v < $10M → 30%
Silver if $10M ≤ v < $100M → 40%
Gold if v ≥ $100M → 50%
-- The floor is $0 and every threshold is inclusive: any volume, including
-- zero, clears Bronze, and there is no no-rebate state. An earner with a
-- single $50 day earns 30% of that day's eligible fees.
-- Progressive (D3): day d's fee_eligible fees earn rate(level(d)); the
-- period's rebate is the sum over its ET days.
**A Gold earner with a $0 accrual is a valid state.** An earner whose whole attributed book sits on Tier 4 reaches the top rung on volume and earns 50% of nothing. The statement and the admin accrual list must render it as a real zero, because "$0" and "the job did not compute this earner" have to stay distinguishable.
The
trailing
sums
are
plain
per-ET-day
sums,
prefix-summed
in
the
policy
core from
per-account-per-day
ClickHouse
aggregates.
They
are
not
a
generalization
of hysteretic_volumes().
Thresholds and rates are constants in the pure policy core, pinned by tests. Changing the ladder is a PR, which is the right cadence for a program whose accrual row snapshots every number that justifies its payable (D8b). One ladder serves both programs.
CREATE TABLE partner_programs.rebate_accruals (
-- The earner: a user FK, not a registry FK, so a held accrual for a
-- referrer with no partners row -- or no trading account -- is
-- representable.
user_id CHAR(16) NOT NULL REFERENCES users(id),
program_kind TEXT NOT NULL,
period TEXT NOT NULL, -- ET calendar month, 'YYYY-MM'
-- Trailing-30d pricing_eligible attributed volume at the anchor
-- (min(now, period_end)); the statement's headline. The per-day ladder
-- walk that priced each day is in detail_json (D3).
attributed_volume_usd NUMERIC NOT NULL,
eligible_fees_usd NUMERIC NOT NULL, -- the child arm's base (D1)
-- The ladder level at the anchor. Informational under the progressive
-- rate: earlier days may have earned at other rungs. NULL when no ladder
-- days were walked. An earner with no eligible accounts still gets a row,
-- so a provisional $0 stays distinguishable from a job that never ran.
ladder_level TEXT,
-- How the CHILD arm was priced (the grandchild arm is always flat, in its
-- own columns below).
-- 'ladder': the child amount = Σ over ET days of rate(day) x base(day),
-- and rebate_rate is NULL because no single period rate exists.
-- 'override': the flat registry rate replaces the ladder (D2d).
rate_source TEXT NOT NULL,
rebate_rate NUMERIC,
-- The payable total: child amount plus grandchild_amount_usd when set.
rebate_amount_usd NUMERIC NOT NULL,
-- The second-tier arm (D2d), set exactly when the earner carries a
-- registry grandchild_rate: its flat rate, its own fee base (the
-- fee-eligible grandchild book), and its share of the total. The
-- per-account child/grandchild split is in detail_json.
grandchild_rate NUMERIC,
grandchild_fees_usd NUMERIC,
grandchild_amount_usd NUMERIC,
-- Coverage. Fills that resolve to no known owner: silent
-- under-attribution is the failure mode, so it is measured, not assumed 0.
unjoinable_fill_count BIGINT NOT NULL DEFAULT 0,
unjoinable_fees_usd NUMERIC NOT NULL DEFAULT 0,
-- Per-account eligibility snapshot (tier_index, D4d') and breakdown, plus
-- the per-day ladder walk, for disputes and the statement.
detail_json JSONB NOT NULL,
computed_at TIMESTAMPTZ NOT NULL DEFAULT CURRENT_TIMESTAMP,
approved_at TIMESTAMPTZ,
-- An admin's identity, or the 'system:auto-approve' sentinel (D5a). Never
-- NULL alongside a set approved_at, so the trail always says which path
-- approved the row.
approved_by TEXT,
-- approval_threshold_usd and its threshold-iff-approved CHECK land with
-- the auto-approval task in PR 7 as an additive ALTER (D5a).
CONSTRAINT rebate_accruals_program_kind_valid
CHECK (program_kind IN ('referral', 'api_broker')),
CONSTRAINT rebate_accruals_period_valid CHECK (period ~ '^\d{4}-\d{2}$'),
CONSTRAINT rebate_accruals_ladder_level_valid
CHECK (ladder_level IS NULL
OR ladder_level IN ('bronze', 'silver', 'gold')),
CONSTRAINT rebate_accruals_rate_source_valid
CHECK (rate_source IN ('ladder', 'override')),
-- A single flat child rate exactly when the override replaced the ladder.
CONSTRAINT rebate_accruals_rate_iff_override
CHECK ((rate_source = 'override') = (rebate_rate IS NOT NULL)),
CONSTRAINT rebate_accruals_rate_valid
CHECK (rebate_rate IS NULL OR (rebate_rate > 0 AND rebate_rate <= 1)),
CONSTRAINT rebate_accruals_volume_nonneg
CHECK (attributed_volume_usd >= 0),
CONSTRAINT rebate_accruals_fees_nonneg CHECK (eligible_fees_usd >= 0),
CONSTRAINT rebate_accruals_amount_nonneg CHECK (rebate_amount_usd >= 0),
-- The grandchild arm is all-or-nothing, bounded by its own rate and base.
CONSTRAINT rebate_accruals_grandchild_together
CHECK ((grandchild_rate IS NULL) = (grandchild_fees_usd IS NULL)
AND (grandchild_rate IS NULL) = (grandchild_amount_usd IS NULL)),
CONSTRAINT rebate_accruals_grandchild_rate_valid
CHECK (grandchild_rate IS NULL
OR (grandchild_rate > 0 AND grandchild_rate <= 1)),
CONSTRAINT rebate_accruals_grandchild_fees_nonneg
CHECK (grandchild_fees_usd IS NULL OR grandchild_fees_usd >= 0),
CONSTRAINT rebate_accruals_grandchild_amount_within_base
CHECK (grandchild_amount_usd IS NULL
OR (grandchild_amount_usd >= 0
AND grandchild_amount_usd
<= grandchild_rate * grandchild_fees_usd)),
-- Each arm within its own base. No day's ladder rate exceeds Gold's 50%,
-- so a ladder child arm never pays more than half its base; an override
-- child arm is bounded by its own rate. A shared fill-side (D7') only ever
-- lowers the amount, so the split needs no constraint of its own. The
-- per-day walk in detail_json is what recomputes the amount exactly.
CONSTRAINT rebate_accruals_amount_within_base
CHECK (CASE WHEN rate_source = 'ladder'
THEN rebate_amount_usd - COALESCE(grandchild_amount_usd, 0)
<= 0.5 * eligible_fees_usd
ELSE rebate_amount_usd - COALESCE(grandchild_amount_usd, 0)
<= rebate_rate * eligible_fees_usd
END),
CONSTRAINT rebate_accruals_approved_together
CHECK ((approved_at IS NULL) = (approved_by IS NULL)),
CONSTRAINT rebate_accruals_qa_user_cannot_accrue
CHECK (user_id <> '000000-0025-06J0'),
PRIMARY KEY (user_id, program_kind, period)
);A
trigger
guards
the
row:
an
unapproved
row
is
recomputed
in
place;
an
approved row
is
frozen,
and
TRUNCATE
is
refused
outright
because
a
statement
trigger cannot
see
which
rows
are
approved.
Corrections
to
an
approved
period
are
a forward
adjustment
in
a
later
period,
not
an
edit.
The
paid
and
voided lifecycle
and
clawback
live
in
the
payouts
RFC;
this
RFC's
terminal
state
is approved.
The
job
is
rs/accrual-engine/src/rebates.rs,
mirroring
fee.rs.
Each
run recomputes
the
open
period
and
the
previous
period,
oldest
first:
Anchor
the
pass
at
min(now, period_end).
The
anchor
pins
ownership
and the
ET-day
list
(owner_at(user, anchor)).
A
closed
period's
anchor
is
its end,
and
the
strictly-after
ordering
rule
(D2a)
means owner_at(user, period_end)
cannot
change
retroactively.
Eligibility
and registry
rates
are
read
live
each
run,
which
is
why
detail_json
snapshots them,
so
an
unapproved
closed
period
may
still
rescore
after
a
fee-rate update.
Load
current
ownership
from
the
attribution
ledger;
resolve
each
attributed user
to
its
accounts
through
trading_accounts.ubo_user_id;
read
each account's
live
billed
rates
and
resolve
the
two
predicates
(§C).
Accounts whose
rates
match
no
current
rung
are
excluded.
Scan
ClickHouse
trades
once
for
all
earners'
accounts
over [period_start − 30 ET days, anchor),
grouped
per
account
per
ET
day: per-side
attributed
fees
floored
per
fill-side
inline (SUM(GREATEST(fee, 0)),
D1b),
with
the
floored-away
negative
tail accumulated
separately
into
detail_json;
notional
volume
scaled
per
symbol, refusing
the
run
on
a
missing
multiplier;
zero-fee
fill
counts
(D1d); final-settlement
and
wash
fills
excluded
in-query.
One
query
serves
both predicates:
fees
fold
over
fee_eligible
accounts,
ladder
volume
over pricing_eligible
ones.
Resolve ownership per fill-side (D7′): the referral owner from the attribution ledger, and the broker owner from the order's Broker ID — stubbed here, so every fill-side resolves referral-only until the broker arm lands. Mark each fill-side sole or shared, and drop each earner's own-UBO accounts.
Fold
per
earner
in
the
pure
policy
core,
arm
by
arm.
The
child
arm
is
the progressive
ladder
walk
(D3)
—
prefix-sum
the
per-day
pricing-eligible
volume, resolve
each
period
day's
rung,
multiply
by
that
day's
fee
base
—
or,
for
an earner
carrying
an
override_rate,
that
flat
rate
times
the
period
base
of its
child
accounts.
The
grandchild
arm,
for
an
earner
carrying
a grandchild_rate,
is
that
flat
rate
times
the
period
base
of
its
grandchild accounts.
Each
day's
fee
base
splits
in
two:
the
sole
base
earns
the
day's
own
rate, and
the
shared
base
earns
0.5 * min(own rate, broker rate)
(D7′),
with the
same
halving
applied
to
the
grandchild
arm.
Ladder
volume
does
not
split
— it
folds
over
the
whole
base.
The
two
bases,
the
broker
rate
that
set
each day's
min,
and
the
resulting
per-day
amounts
are
written
into
detail_json, so
a
shared
period
recomputes
exactly
from
the
stored
row.
Upsert
one
row
per
(earner
user_id,
'referral',
period),
idempotent
on
the primary
key,
never
touching
an
approved
row.
Both
arms
land
on
that
one
row.
Emit metrics and refuse the run on the §H in-run invariants.
Posture,
copied
from
rs/accrual-engine/src/fee.rs:
a
pure
function
of warehouse
history,
so
ticks
are
idempotent;
ensure!
on
missing_multipliers and
refuse
the
whole
run
rather
than
mis-bill.
compute_referral_rebates_once() is
pub
so
integration
tests
drive
it
directly,
as
refresh_fee_rates_once already
is.
The
task
is
AX-only:
watch()
refuses
it
on
the
aiex
edition
the
same way
it
refuses
the
fee-rates
task.
Cost.
trades
is
ORDER BY (symbol, timestamp_ns, trade_id)
with
no
account in
the
sort
key,
so
an
account-set
scan
reads
whole
month
partitions.
The
daily scan
spans
the
period
plus
its
30-ET-day
ladder
lookback,
about
two
partitions per
pass.
If
that
grows,
stop
recomputing
closed
periods
once
fully
approved. Confirm
the
a-4097
account
skip-indexes
exist
in
production;
they
were
a consumed
migration
file.
The
ClickHouse
read
sits
behind
one
function
(D4c),
so
the
accrual
takes
no dependency
on
the
store's
shape.
account_volume_1d
(A-4334)
would
serve
steps
3 and
5
more
cheaply
once
its
write
path
exists,
and
adopting
it
should
change
that function
alone.
Two
things
to
check
at
that
point:
the
rollup
recomputes
from trades FINAL
while
the
current
live
aggregates
read
without
it,
so
the
two disagree
on
a
re-inserted
trade_id;
and
the
rollup
double-counts
per-account volume
(maker
+
taker)
to
match
query_account_volumes,
which
must
be
reconciled with
what
§D's
per-day
trailing
volume
does.
rs/api-gateway/src/admin_partner_routes.rs,
nested
at
/admin/partners, following
admin_mmlp_routes.rs
and
admin_account_limits_routes.rs,
gated
by ax_web_auth::authorize_admin
(rs/sdk-internal/web-auth/src/lib.rs:196-220). Exceptions,
revocations
and
registry
edits
are
admin-only.
| Route | Purpose |
|---|---|
GET/POST /partners,
GET/PUT /partners/{user_id} |
Registry
CRUD:
status,
payout_account_id,
override_rate,
grandchild_rate.
Rows
are
keyed
on
user_id;
there
is
no
surrogate
partner
id |
GET/POST/PATCH /referral-codes?user_id= |
Issue
and
soft-retire
(retired_at,
D2c) |
GET /referral-attributions?user_id=,
POST /referral-attributions |
The
exception
path;
approved_by
and
reason
enforced
by
CHECK |
GET /accruals?user_id=,
POST /accruals/approve |
The
manual
path,
keyed
user_id
+
program_kind
+
period,
for
rows
at
or
above
the
threshold
and
anything
escalated
(D5a) |
GET /accruals/statement |
CSV
streamed
from
detail_json |
The accrual list must separate the pending-manual queue from auto-approved rows. Auto-approved rows are not review work.
A
self-service
surface
also
exists
at
/referrals (rs/api-gateway/src/referral_routes.rs,
A-4725):
any
user
can
create
their
own code
—
server-generated,
never
chosen
—
which
auto-provisions
a
pending partners
row,
and
can
list
the
users
attributed
to
them,
masked.
Changing
or retiring
a
code
is
a
support
action
on
the
admin
surface.
Require
Clerk
step-up
on
approving
an
accrual
and
on
creating
a
referral exception,
through
the
existing
POST /admin/verify-identity
pattern (admin_routes.rs:2733-2787,
MultiFactor
within
1
minute).
Statements are CSV, not PDF. No runtime image carries a PDF renderer: the typst CLI left with MMLPv1 under A-4812. CSV needs no renderer and no S3, and is what Finance reconciles against. Until a payer credits its first referral accrual, the approved accrual plus its CSV statement is the settlement artifact, and the statement stays the reconciliation input afterward.
The statement reconciles to net fee intake for the attributed accounts, because every eligible fee on the current ladder is positive (D1c). If that stops being true, §H check 1 fires before a statement goes out.
Two things the statement says out loud, because Finance settles by hand against it:
$0
accrual
is
a
computed
result,
not
a
missing
one.
An
earner
whose book
is
entirely
Tier
4
shows
a
real
ladder
level
against
a
zero
base
(D4b).
GUI
in
gui/apps/admin/src/pages/partners/,
api
functions
in gui/packages/admin/src/api/admin.ts,
react-query
hooks
following useMmRequirements.ts.
Do
not
add
/partners
to
AIEX_SECTIONS
in gui/apps/admin/src/edition.ts:
the
programs
are
AX-only,
and
omission
is
how
a section
stays
hidden.
partners.status
gates
payout,
and
approval
gates
everything
—
an
accrual under
investigation
is
simply
not
approved.
ubo_user_id
is
the earner's
own
user
never
contribute.
approved_by
and
reason,
enforced
by
CHECK, with
Clerk
step-up.
min(r, b)
in
total
and
no
more.
§H
check
2
is
what
enforces it,
which
is
why
that
check
is
hard.
Metrics, per run: earners processed, total accrued, unjoinable share, and rebate as a fraction of fee intake. A rebate approaching the fee account's period intake means a misconfiguration; alert through the existing recon incident.io path.
Recon checks:
Monitored,
per
period:
Σ rebates accrued ≤ Σ net fees collected.
This holds
today
(D1c)
and
fires
if
the
assumption
behind
D1c
breaks
—
a
new
ladder rung
going
maker-negative
below
Tier
4.
It
is
an
alert
rather
than
an assertion
in
the
accrual
engine,
because
an
override
promotion
may
legitimately pay
out
more
than
a
period's
fee
intake.
It
lives
in
the referral-rebates-within-fees
recon
check
and
is
snoozed
for
a
promotion's duration.
Outside
a
promotion
a
breach
means
a
bug.
Hard,
per
accrual,
per
arm:
eligible_fees_usd ≥ 0;
the
child
amount (rebate_amount_usd − grandchild_amount_usd)
is
≤ 0.5 × eligible_fees_usd on
a
ladder
row
or
≤ rebate_rate × eligible_fees_usd
on
an
override
row;
and the
grandchild
amount
is
≤ grandchild_rate × grandchild_fees_usd.
The
exact recompute
is
the
per-day
walk
in
detail_json,
which
check
4
exercises.
Hard,
per
shared
fill-side
(D7′):
the
two
owners'
amounts
on
a
shared fill-side
sum
to
exactly
min(r, b) × fee,
and
neither
exceeds
half
of
it. This
is
the
bound
the
withdrawn
waterfall
used
to
make
structurally unreachable,
so
it
is
an
assertion
that
refuses
the
run,
not
an
alert.
It
is inert
until
the
broker
arm
lands,
because
nothing
is
shared
before
then.
Zero-fee
coverage
(D1d):
count
fills
on
eligible
attributed
accounts
with non-zero
notional
and
a
zero
fee,
and
refuse
the
period
above
a
threshold. Where
a
trade
engine
exports
fee_miss_count,
assert
the
two
measures
agree.
Recompute and diff: rederive a closed period from the tape and compare it to the stored accrual. The accrual is a pure function of its inputs, so a divergence means an input moved underneath it — a fee-rate update, a retroactive trade amendment, or a schedule change.
Fee-schedule
tripwire:
fail
the
accrual
run
if
any
rung
in
the fee-eligible
range
(tier_index < 3,
§C)
has
a
negative
maker_fee.
That
is the
single
upstream
change
that
invalidates
check
1,
and
it
is
cheaper
to catch
when
the
schedule
is
published
than
after
a
partner
is
paid.
It
also catches
a
ladder
that
grows
a
fifth
rung,
which
would
shift
what
"Tier
4" refers
to.
The
bound
moves
with
the
§C
predicate:
at
< 2
it
stops guarding
rung
2,
which
is
both
eligible
and
adjacent
to
the
negative
rung.
Tests.
Ladder
resolution
at
every
boundary,
including
0andtheinclusiveBronzefloor.ATier − 4attributedaccountcontributingladdervolumebutzerofees, andtheGold − with−0-accrual
case
it
produces.
An
off-ladder
account contributing
to
neither.
Forward-only
retirement,
where
a
retired
code
keeps accruing
existing
owners.
A
repeated
/login/clerk
not
creating
a
second attribution.
An
unknown
code
committing
the
user
with
no
attribution
and
not failing
the
login.
A
missing
contract
multiplier
refusing
the
whole
run.
A zero-fee
share
above
the
D1d
bound
refusing
the
period,
the
bound
itself accruing,
and
accounts
outside
the
fee
base
not
counting
toward
the
measure.
A mid-period
ladder
promotion
earning
the
lower
rate
on
earlier
days
and
the
higher on
later
ones,
with
the
total
equal
to
the
per-day
sum.
A
demotion
to
Bronze
at the
anchor
leaving
earlier
days'
earnings
intact,
including
the
legal
row
shape ladder_level NULL
with
rebate_amount_usd > 0.
A
mid-period
rate
change rescoring
the
whole
open
period
on
the
next
run.
A
shared
fill-side
paying min(r, b)
in
total
across
its
two
owners
at
each
ordering
of
the
two
rates (referral
higher,
broker
higher,
equal),
the
same
account's
volume
lifting
both owners'
ladder
rungs
in
full,
the
grandchild
arm
halving
with
its
child,
and
a fill
placed
before
the
broker
arm
existed
staying
sole-owner
when
the
run
is replayed
afterwards.
An
account
on
the
Tier-4
rung contributing
neither
eligible
fees
nor
ladder
volume,
including
one
that
crosses into
Tier
4
mid-period.
Idempotent
recompute
of
an
unapproved
period.
A two-program
earner
at
$999
+
$999
requiring
manual
approval
on
both
rows,
an auto-approval
refusing
to
fire
on
an
open
period,
and
each
escalation
trigger forcing
manual
review
on
a
sub-threshold
row.
The
freeze
trigger
rejecting
an approved-row
edit.
Inline
insta
snapshots
per
CLAUDE.md;
integration
tests
on
testcontainers through
rs/test-utils/src/seed_data.rs
and
the
TestClickhouseDatabase
harness (rs/test-utils/src/clickhouse.rs:64).
PR-level sequencing across the four partner-program RFCs is in SoW: Partner Programs. This RFC's share is PRs 1–7.
Questions that gate delivery are in Open questions that gate delivery. The rest gate no PR.
O5 — Wash and artificial volume. Is the same-owner-both-sides filter plus quarterly manual review enough for v1, or is there an existing recon wash-trade signal to reuse?
Proposed (Eng, unconfirmed): keep the same-owner-both-sides filter, build no further surveillance, and make artificial volume a terms-of-service breach penalised by reward forfeiture and tier demotion. The reasoning is that a reward is a fraction of fees actually paid, and negative fees are excluded (D1b), so artificial volume can buy a higher ladder rung but can never pay for itself — it is a tier-boosting attack, not a profitable one. Surveillance beyond a single UBO on both sides gets expensive quickly and would be buying protection against an attack that loses money. Two existing controls carry the residual risk: manual approval at or above the materiality threshold (D5a), and the quarterly partner review. This needs Legal to confirm the TOS wording and Commercial to confirm the penalty before it can be recorded as answered.
O7 — Backdating. Do existing clients who arrived through a partner relationship earn anything for pre-launch periods? No existing mechanism reaches this: the ledger carries no backfill origin, and D2a's ordering rule refuses a back-dated exception. A "yes" costs a deliberate schema change, so this is a commercial question with an engineering price attached. Either way it needs BD to evidence the relationship.
O10
—
"Net
of
refunds"
has
no
mechanism
behind
it.
Answered:
no
mechanism for
v1
(D1e).
The
commercial
framework
says
rebates
are
paid
on
fees
"actually collected,
net
of
refunds".
AX
has
no
refund
concept:
TransactionKind
is Deposit
/
Withdrawal
/
Funding
/
Fee
/
PnL
/
LendingCredit
/
LendingDebit (rs/transaction-engine/src/lib.rs:112-127),
there
is
no
fee-reversal
path, and
no
table
records
a
fee
being
given
back.
The
clause
covers
three
situations:
trades
is
a ReplacingMergeTree,
so
a
corrected
row
supersedes
the
original
by
key,
and the
accrual's
FINAL
read
plus
daily
recompute
picks
up
the
new
fee.
This stops
at
approval,
after
which
a
correction
is
the
forward
adjustment
of Payouts
P7.
POST /deposit
calls.
They
never
touch
trades,
so
the
accrual
cannot
see them,
and
a
partner
keeps
a
rebate
on
a
fee
AX
effectively
returned.
Closing this
needs
either
a
fee-reversal
transaction
kind
or
an
admin
per-period accrual
adjustment.
D1 does not claim to be net of refunds. Case 2 is the accepted v1 position and cannot be measured until a deposit carries a reason code.
O8 — Multi-level referrals (A→B→C). In scope (D2d). Several benchmarks offer two levels: Polymarket 10% direct plus 5% second-tier, Bybit 10% sub-affiliate override. The three decisions that gated it:
override_rate
for
the
child
level,
grandchild_rate
for
the
second).
On the
child
level
the
override
replaces
the
ladder
rather
than
capping
it. The
money
comes
from
AX's
retained
cut:
the
child
referrer
keeps
its
full ladder
rate,
and
the
override
is
a
separate
claim
funded
from
AX's
margin.
Exposure:
a
Bronze
child
pays
out
30% + 20% = 50%
of
collected
fees;
a Gold
child,
50% + 20% = 70%.
Check
1
holds
at
both.
The
program
targets
retail referrers
recruited
by
BD,
so
the
Bronze
case
is
expected.
A
given
account yields
the
grandparent
20%
once
—
it
is
either
a
child
or
a
grandchild
of
that user,
never
both
—
so
the
two
rates
bound
the
grandparent's
aggregate
exposure, not
any
single
account's.
Scope: named internal sales and BD staff only, one person at launch.
O1 — Bronze boundary. The ladder starts at $0. Every volume clears Bronze, there is no no-rebate state, and an earner's eligible fees always earn at least 30%. §D carries the ladder.
O2
—
Tier
mapping.
Eligibility
is
tier_index < 3:
only
Tier
4 (>$100M)
is
carved
out,
and
Tier
3
(10–100M)
earns.
Commercial
reads
the
carve-out
against
a
three-tier
framing
that
matches the
business
spec's
bands
—
combining
the
fee
schedule's
first
two
rungs
gives 0-10M,
10-100M,
>100M.
Under
that
framing,
"Tier
3
pricing
or
better
do not
generate
rebates"
excludes
exactly
the
>$100M
accounts,
the
same population
tier_index < 3
excludes
against
today's
four
rungs.
The renumbering
is
presentational
and
the
eligible
set
is
identical.
The
fee
schedule
itself
is
not
restructured.
Merging
rungs
0-1M
and 1-10M
would
re-price
every
client
between
$1M
and
$10M
from
0.0002 / 0.0005 to
0.0005 / 0.0010
maker/taker.
That
is
a
billing
change
owned
outside
this program.
O4 — Block trades and liquidations. Both earn; no trade-kind carve-outs. Final settlement remains excluded (D1f).
O10 — "Net of refunds". No mechanism for v1 (D1e), detailed above.
O11 — Tier-4 volume. It counts toward the earner's ladder level even though it earns nothing, so an earner is not demoted by its best client's growth. Off-ladder accounts contribute to neither side. This is what splits §C into two predicates (D4b).
Benchmark and commercial framing (Binance CaaS, Bybit, OKX) live in the Commercial team's program document; this RFC is the systems design only. Final program terms require Finance, Product, Legal, Compliance and Risk approval.