Date: 2026-08-19
Status: Draft
Author: Joey McCarey (with Claude)
Related: referral-program and api-broker-program own the policy — what a dollar of partner-attributed flow is worth. This RFC owns the producer: how that policy becomes a durable, defensible accrual row. program-payouts describes one candidate payer and takes over at the approved row; this RFC is neutral on which payer ships (D9). partner-rebate-payouts holds the requirements this producer holds any payer to. accrual-engine is a shared-engine design that is deferred, has no implementation, and is not a dependency (O2). Sequencing is in SoW: Partner Programs, Track A PR 6.
Two commercial programs — referral and API broker/OMS — compute the same thing: a share of the fees AX collected on flow a partner brought in, per earner, per calendar month. They share a rebate ladder, an eligibility model, an ownership and rate-split rule, and a ledger.
They share no machinery with anything else. There is no shared accrual engine. This producer is a pure, I/O-free policy core plus a host task that owns the connections and writes the ledger.
sources pure core partner_programs (ledger)
───────────────────────── ───────── ─────────────────────────
attributions ─┐
billed rates (live, ├──► waterfall + ladder ──► rebate_accruals
tier snapshotted) │ + eligibility (provisional → approved)
ClickHouse trades ──────────┘ (rungs in-core, │
per-account-per-day metrics progressive daily) ▼
whichever payer ships
(D9 acceptance criteria)
The producer terminates at an approved accrual row plus a CSV statement. That pair is the settlement artifact until a payer ships.
The
accrual
is
computed
by
a
pure,
I/O-free
core
in
rs/sdk-internal/
— ownership
and
the
split
rule,
the
two
eligibility
predicates,
the
partner
ladder, and
the
rebate
computation
—
driven
by
a
host
task
that
owns
the
Postgres and
ClickHouse
connections
and
writes
the
ledger.
This
is
the
shape
the
MM liquidity
program
shipped
(rs/sdk-internal/mmlp2/
plus rs/accrual-engine/src/mmlp/settle.rs).
Two properties follow:
This is not an argument against a shared engine. There is not one to adopt, and this track cannot wait for one. If one is built later, moving onto it costs a thin adapter and no data migration (D1a). See O2.
Recorded so a future extraction is evaluated on its merits rather than re-argued.
Implementing
a
shared
producer
trait
later
means
a
row
adapter
exposing
amount, currency,
direction,
status,
period
key,
payout
account
and
idempotency
key
—
all derivable
from
columns
the
ledger
already
has
—
plus
a
program
adapter
wrapping the
pure
core
and
the
existing
writer.
No
ledger
migration:
the
payout
URN
is a
deterministic
function
of
(program_kind, period, user_id),
so
it
needs
no stored
column.
What
is
deleted
rather
than
rewritten
is
the
host
task's
own scheduling
loop.
The cost is not constant. A shared engine delivered as a library makes adoption a wrapper, in place. Delivered as a service, adoption also re-homes this producer out of its host — a deployment, config surface, failure domain and on-call change rather than a code change. Establish which is proposed before treating O2's cost as small.
Nothing in the ledger or the pure core encodes the absence of an engine, which is what keeps adoption a wrapper rather than a rework.
PRIMARY KEY (user_id, program_kind, period),
with
user_id
a
real
FK
into users.
This
is
forced
by
referral
D2:
attribution
binds
a
user,
and
account provisioning
is
lazy.
A
referrer
can
hold
a
referral
code,
earn
a
rebate,
and own
no
trading
account
at
all.
A
table
keyed
on
account_id
cannot
represent that
row,
so
it
cannot
represent
an
obligation
AX
genuinely
has.
The
earner
is
a
referrer
user
—
retail,
or
an
institutional
partner's
backing user.
A
partner_programs.partners
registry
row
is
minted
at
payout
onboarding, so
an
FK
into
the
registry
would
refuse
exactly
the
held-accrual
case
this section
exists
to
allow.
A partner is a customer who does not have an account to receive payouts yet. Every partner is expected to have one eventually; the ledger has to be correct in the interval before that, and the interval is normal.
The subject of the accrual and the destination of the money are therefore different questions. The subject is the earner; the payee is resolved separately (D3).
The
two-level
arm
does
not
change
the
key.
An
internal-BD
earner's
direct
rebate and
grandparent
override
land
on
one
row
in
separate
columns,
with
the per-account
split
in
detail_json
(referral
D2d).
payout_account()
is
Option<AccountId>.
None
is
expected,
not
an
error:
the accrual
is
correct,
the
money
is
owed,
and
there
is
nowhere
to
send
it
yet.
Such a
row
is
held
—
not
paid,
not
forfeited,
and
counted
rather
than
silently skipped.
The
division
is
exact.
The
payout
module's
requests.account_id
is NOT NULL REFERENCES trading_accounts(id) (#3609),
so
a
payee-less
accrual cannot
reach
its
queue
even
as
HELD.
The
module's
HELD
covers
a
different case:
a
payee
who
exists
but
cannot
yet
receive,
for
want
of
KYB
or
because
the account
is
restricted.
That
non-nullable
column
is
safe
because
this
producer holds
these
rows,
which
makes
D3
part
of
the
payout
contract
rather
than
a
local convention.
This is a property of the commercial programs, not of any engine. With no engine in the path, the host task makes the call directly.
program_kindReferral
and
API
broker
produce
rows
of
identical
shape
and
differ
only
in
how the
subject
is
attributed.
They
share
partner_programs.rebate_accruals
with program_kind IN ('referral', 'api_broker')
in
the
natural
key.
Rebate ownership is not exclusive (referral RFC D7′, revised). A unit of flow owned by both programs produces one row in each, and the two together pay half each of the lower of the owners' two rates, so the total on that flow never exceeds what either owner would have drawn alone. Sharing the table is what makes that bound checkable with a query — it is now the only thing enforcing it, since the withdrawn waterfall made it unreachable by construction.
The
row
carries
the
anchor
ladder_level
and
volume,
the
flat
rate
on
an override
row,
and
the
per-day
ladder
walk
in
detail_json
(referral
D8b),
so
it never
reaches
across
a
schema
boundary
for
the
authority
behind
its
own
number.
The
snapshot
is
the
whole
story.
The
ladder
is
a
constant
in
the
pure
core, pinned
by
tests;
there
is
no
rebate_schedules
table
and
no
rebate_schedule_id column.
Which
ladder
was
in
force
is
answered
by
the
deploy
history,
and
the table
returns
additively
the
first
time
Commercial
needs
a
rate
change
faster than
a
deploy.
unjoinable_fill_count
and
unjoinable_fees_usd
are
NOT NULL DEFAULT 0 columns,
not
a
log
line.
Silent
under-attribution
—
fills
that
resolve
to
no owner
—
produces
a
plausible
wrong
number,
which
is
worse
than
an
obviously
wrong one.
A
partner
disputing
a
statement
is
entitled
to
know
what
the
computation could
not
see.
For
the
same
reason
an
earner
with
no
eligible
accounts
still
gets
a
row,
with ladder_level IS NULL:
a
labelled
provisional
$0
is
distinguishable
from
a
job that
failed
to
run,
and
nothing
at
all
is
not.
partner_programspartner_programs.rebate_accruals
sits
beside
partners,
referral_codes
and referral_attributions.
One
schema
owns
the
commercial-programs
domain
end
to end:
the
facts,
the
policy,
and
the
ledger
those
produce.
Three things support the placement:
partner_programs, mm_liquidity_performance,
treasury_engine,
liquidation_engine.
The liquidity
program
follows
it
for
the
half
it
owns
outright,
putting
published params
in
mmlp.
accrual_engine,
is
owned
by
another
track.
Its only
inhabitant
is
the
liquidity
program's
ledger,
and
its
name
advertises
a crate
this
producer
does
not
use.
If
a
shared
engine
is
built
later
and
wants
its
ledgers
co-located,
moving
one table
between
schemas
is
an
ALTER TABLE ... SET SCHEMA
on
a
table
whose consumer
is
a
query
this
track
owns.
A row is freely recomputed while provisional and frozen once approved. Approval converts a number into a promise. Below a materiality threshold it happens automatically; above it a human does it (referral D5a). The payout module takes no view on how a row became approved, and this producer takes no view on how it is paid.
approval_threshold_usd
is
snapshotted
on
the
row
alongside
approved_at
and approved_by,
so
a
later
change
to
the
threshold
does
not
retroactively re-describe
who
was
allowed
to
approve
what.
The
column
lands
with
the auto-approval
task
(SoW
PR
7)
as
an
additive
ALTER;
this
producer
writes
rows unapproved
and
never
reads
it.
This
track
is
neutral
on
which
payer
wins
and
does
not
arbitrate
between them.
Two
exactly-once
cash
paths
are
being
built
concurrently
by
other
tracks: a
program-payout
module
for
incentive
programs,
and
a
replay-safe deposit-settlement
core
with
a
transaction-projection
outbox
for
treasury.
Both split
request
from
attempt,
both
enforce
one
credit
per
obligation
with append-only
rows,
and
both
end
at
ax-transaction-engine.
Which
becomes
the payer
is
being
worked
out
between
those
two
tracks.
The neutrality is affordable because this producer terminates before the cash path: its deliverable is an approved accrual plus a CSV statement. A payer arriving late costs this track nothing, and a payer never arriving costs it nothing either.
This producer integrates with whichever payer lands, provided it offers these properties. They are the ones partner-rebate-payouts already holds any payer to, restated as acceptance criteria:
<program_kind>://<period>/<subject>
URN
with
a
CHECK
that
the key
matches
the
program
achieves
this.
ax-transaction-engine,
so
the
ledger
stays
the
single source
of
truth
and
no
payer
keeps
a
shadow
balance.
A payer meeting all nine needs no renegotiation here. A payer missing (2), (3) or (9) requires changes on this side.
Who makes the enqueue call is not part of the contract. This producer's host task calls it directly. A shared accrual engine, if one is built, takes over the call without changing anything above.
One
table,
partner_programs.rebate_accruals,
declared
in
db/postgres/1.sql (Atlas,
declarative).
The
full
definition
is
in
referral
RFC
§E.
The
load-bearing columns:
| Column | Why it exists |
|---|---|
user_id
FK
→
users |
The subject — the earner (D2) |
program_kind,
period |
With
user_id,
the
natural
key
and
the
primary
key.
No
surrogate
id,
because
nothing
references
an
accrual
row.
period
is
an
ET
calendar
month,
CHECK (period ~ '^\d{4}-\d{2}$') |
attributed_volume_usd |
The
anchor
trailing-30d
volume
over
the
wider
pricing_eligible
set;
the
per-day
ladder
walk
is
in
detail_json
(referral
D3) |
eligible_fees_usd |
The
rebate
base
—
the
narrower
fee_eligible
set |
ladder_level |
The level at the anchor (D5). Informational under the progressive rate: earlier days may have earned at other rungs |
rate_source,
rebate_rate |
ladder
or
override.
The
rate
is
set
exactly
on
override
rows
and
NULL
on
ladder
rows,
where
no
single
period
rate
exists |
grandchild_rate,
grandchild_fees_usd,
grandchild_amount_usd |
The
second-tier
arm,
set
exactly
when
the
earner
carries
a
registry
grandchild_rate
(referral
D2d) |
unjoinable_fill_count,
unjoinable_fees_usd |
Coverage (D6) |
detail_json |
The
per-day
ladder
walk
(trailing
volume,
rung,
rate,
base,
amount),
the
per-account
breakdown
for
disputes
and
the
statement,
the
live-resolved
tier_index
each
account
was
scored
under,
and
each
account's
depth
—
which
is
what
makes
a
stored
level
column
unnecessary |
approved_at,
approved_by |
The
approval
gate
(D8).
approval_threshold_usd
joins
them
through
PR
7's
additive
ALTER |
Two
volume
figures,
not
one.
attributed_volume_usd
and
eligible_fees_usd answer
different
predicates.
The
ladder
sums
volume
over
pricing_eligible, which
includes
standard-priced
Tier-4
accounts
that
raise
an
earner's
level
and earn
it
nothing;
the
base
sums
fees
over
fee_eligible,
which
excludes
them. Collapsing
these
into
one
predicate
is
the
likely
implementation
error
and silently
restores
demotion-by-success
(referral
D4b).
Constraints carry the invariants rather than comments: a rate is present exactly when the override arm set it; a ladder row's amount is bounded by Gold's 50% of its base and an override row's by its own rate; approval fields move together.
The pure core takes resolved inputs and returns rows. No pools, no queries. The progressive ladder walk (referral D3) lives here: prefix-sum the per-day attributed volume, resolve each ET day's rung, price that day's fee base.
The
host
task
resolves
attribution
sets,
reads
billed
rates
live,
resolves tier_index
through
FeeProgram::tier_index_for_rates
and
snapshots
it
onto
the row
(referral
D4,
D4d′),
aggregates
per-day
fees
and
volume,
calls
the
core,
and upserts
the
ledger.
Fee
aggregation
is
one
per-day
query
on
a
shared
scan.
The
maker/taker ARRAY JOIN
fan-out
and
final-settlement
filter
of ChTradeRow::query_account_tier_metrics
are
the
shape
to
build
on,
factored
so both
queries
share
one
trades FINAL
scan.
The
rebate
query
groups
per
account per
ET
day,
floors
each
fill-side
at
zero,
counts
zero-fee
fills,
and
excludes wash
pairs.
ensure!
on
missing
multipliers
and
refuse
the
whole
run
rather
than under-attribute
silently.
Eligibility resolution belongs to this producer. The referral program resolves account tiers; the liquidity program has no eligibility notion at all. The predicates live in the core.
rs/accrual-engine/tests/mmlp_settler.rs:
idempotent
re-run,
recompute
while provisional,
refusal
to
mutate
after
approval.
Σ paid ≤ Σ collected
as
a
hard
invariant
(referral
§H).
A
breach
means
an account
is
mis-classified
—
a
negotiated
account
left
marked
standard
whose rates
coincide
with
a
ladder
rung,
which
is
the
half
the
preflight
gate
cannot see.
standard.
Tested
as
a
refusal,
not
a
warning.
O1
—
The
ledger
is
earner-keyed.
Answered
(D2).
Kept
because
the
next person
to
notice
that
every
other
accrual
ledger
is
account-keyed
will rediscover
the
question.
A
table
keyed
on
the
account
cannot
hold
an
accrual for
a
partner
who
has
none,
and
the
payout
module's
non-nullable
account_id is
only
safe
because
this
producer
holds
those
rows
(D3).
Account-keying
this ledger
would
break
the
payer's
contract,
not
just
this
one's.
O2 — Under what conditions a shared engine is worth building. Not a date, and not this track's to schedule. The five conditions are recorded in accrual-engine Conditions for revisiting. Two bear on this producer: liquidity and this producer are the two liability implementations those conditions count, and they must have converged without coordinating for a common shape to be worth lifting. Whether they did is answerable only once both run. If they did, D1a is the cost of moving. If they did not, the divergence is the more useful finding.
O3
—
Where
the
pure
core
lives,
and
what
it
is
called.
rs/sdk-internal/ beside
ax-mmlp2
is
settled;
the
name
is
not.
It
serves
both
commercial programs,
so
ax-referral
under-describes
it.
Choose
something program-neutral
before
the
second
program
lands.
O4
—
Statement
generation
shares
a
shape
with
the
liquidity
reward-accruals page.
The
admin
CSV
export
and
accrual
ledger
table
shipped
for
MMLP (gui/packages/admin/src/pages/mm-liquidity-programs/,
#3588)
are
page-scoped and
thin:
AccrualsTable.tsx
and
accrualColumns.tsx
are
under
100
lines together.
Forking
is
probably
cheaper
than
generalizing,
but AccrualTrendChart.tsx
and
format.ts
are
worth
lifting.
Decide
when
A-4430 starts.
O5
—
Whether
the
per-day
fee
aggregate
should
be
a
mode
on query_account_tier_metrics
rather
than
a
sibling.
The
producer
needs
a per-account-per-ET-day
aggregate
for
the
progressive
ladder,
which
is
a
plain GROUP BY
on
the
shared
scan.
What
the
producer
built:
a
sibling, ChTradeRow::query_account_rebate_metrics,
returning
per-account
metrics
with a
GREATEST/LEAST
split
so
a
fee
floors
per
fill-side
rather
than
netting across
the
period,
zero-fee-fill
counts
on
non-zero
notional,
per-symbol output,
and
a
wash-pair
exclusion
the
admin
caller
does
not
inherit.
Alongside it,
the
shared
trades FINAL
and
maker/taker
ARRAY JOIN
fan-out
was
factored out
of
query_account_tier_metrics
into
fanned_trade_sides_sql,
which
both queries
use.
The
scan
is
shared,
and
query_account_tier_metrics
kept
its signature
and
semantics.
The cost of deciding this wrong is query cost, not correctness — both forms read the same rows through the same scan. Revisit when the API-broker arm lands and there is a second caller to generalize over.
Everything
below
is
on
main
unless
marked
otherwise.
rs/sdk-internal/mmlp2/
—
the
pure-core
precedent
(D1):
scorer,
waterfall, gates,
no
I/O.
rs/accrual-engine/src/mmlp/settle.rs
—
the
host-task
precedent
(D1):
drives the
pure
core,
owns
the
pools,
writes
accrual_engine.liquidity_accruals, moves
no
money.
rs/sdk-internal/clickhouse/src/schema.rs
—
query_account_tier_metrics,
the windowed
per-account
volume
and
fee
aggregate,
and
the
fanned_trade_sides_sql scan
it
shares
with
the
producer's
per-day
sibling
(O5).
rs/accrual-engine/src/fee.rs
—
hysteretic_volumes,
the
fee
engine's ladder-with-hysteresis.
The
partner
ladder
does
not
mirror
it:
it
uses
plain per-day
trailing
sums
(referral
D3).
db/postgres/1.sql
—
accrual_engine.liquidity_accruals
(the
sibling
ledger), mmlp.liquidity_programs.
partner_programs.{referral_codes, referral_attributions, partners}
—
merged, #3703.
rebate_accruals
rides the
accrual
PR
(A-4429,
referral
RFC
§E).
Candidate payers. None is depended on (D9):
payout_engine.{programs,requests,attempts}
—
the
program-payout
module's schema.
See
program-payouts.
treasury_engine.deposit_credits
—
the
replay-safe
deposit-settlement
path.
It inserts
the
ClickHouse
transaction
before
the
PostgreSQL
commit,
so
a
failed commit
can
leave
a
ClickHouse
orphan
for
reconciliation.
treasury_engine.deposit_credit_attempts
—
the
idempotency-ledger
shape
both designs
descend
from.
On
main,
with
no
production
reader.
accrual_engine
is
a
Postgres
schema
on
main,
created
by
the
liquidity
program for
its
own
ledger.
This
producer
does
not
use
it
(D7),
and
it
does
not
imply
a crate.