Date: 2026-07-30
Status: Superseded for everything that moves money by Generic Program Payout Module. Retained as the record of what a payer must do. Its P-numbered decisions are the requirements the referral and API-broker producers hold any payer to, and they are cross-referenced by name from Referral Program. Read What survives first.
Author: Joey McCarey (with Claude)
Related:
Referral
Program
—
produces
the
approved partner_programs.rebate_accruals
row
this
RFC
settles. API
Broker
/
OMS
Program
—
produces
accruals
of
the same
shape;
settlement
is
identical
and
shared. commercial-program-accruals
—
the
producer, whose
D9
restates
this
RFC's
requirements
as
payer
acceptance
criteria. System
trading
accounts
(shipped;
their
RFC
is
retired)
—
the
rebate
is
a debit
against
AX_FEE_ACCOUNT_ID. Verify
USDC
Deposits
Onchain
—
the treasury_engine.deposit_credit_attempts
design
this
RFC
was
the
first
to specify
an
implementation
of.
An
approved
rebate_accruals
row
becomes
a
credit
to
the
partner's
AX
trading account,
booked
double-entry
against
the
fee
account,
with
an
immutable authorization
ledger
in
front
of
it.
The
mechanics
below
are
built
once,
generically,
by program-payouts
(A-4492),
and
referral
is
its
first adapter.
Read
every
table
name
here
as
a
role,
not
an
identifier:
the
leading payer
names
them
payout_engine.programs
/
requests
/
attempts
rather
than the
payout_program_config
/
program_payout_requests
/
payout_attempts
used throughout
this
document.
Its
contract
matches
what
is
required
here
—
a non-nullable
account_id,
the idempotency_key LIKE program_kind || '://%'
check,
a
HELD
state,
and
a
0-or-1 credited-attempt
index.
This
document
states
requirements
a
payer
must
meet,
so it
survives
the
payer
changing.
Absorbed
by
the
payout
module
—
do
not
build:
the
rebate_payouts
ledger (§A)
becomes
the
module's
attempts
table;
the
settlement
step
(§B),
the
monthly sweep
(P9),
one-partner-per-transaction
pacing
(P5),
and
the claim_window_periods
mechanism
(P8)
all
become
module
code
and
config.
The enqueue is not ours to design, but it is ours to call. The producer's own host task makes the call, against the contract here and against the acceptance criteria in commercial-program-accruals D9. The producer owns everything upstream of that row — which accruals get approved, and at what threshold — plus the payee-less hold, which never reaches the queue.
Settlement
state
is
read
back
through
a
supported
join.
The
admin
surface reads
the
requests
table
joined
to
the
attempts
table
on
request_key WHERE outcome = 'CREDITED',
with
transaction_event_id
on
that
row
as
the
link
to
the transaction
event.
The
module's
owner
confirmed
(2026-08-05)
this
is
a
stable producer-facing
contract,
kept
cheap
and
0-or-1
by
a
one-credit-per-request index.
Retained, and still the producer's to build:
The approval gate. The module pays only approved rows and takes no view on how a row got there. P1's gate, and referral D5a's threshold, escalations and auto-approval task, land with the admin surfaces, not with settlement.
payout_account_id
resolution
(P2).
Which
of
a
partner's
own
accounts earnings
land
in
is
producer
knowledge,
resolved
at
enqueue.
Σ paid ≤ Σ collected
(§D,
referral
§H).
The
module
cannot
compute
this; it
has
no
view
of
what
fees
were
collected
on
attributed
flow.
A
breach
means the
accrual
or
fee-ladder
assumptions
failed,
which
is
why
it
stays
a
hard invariant.
The
held-balance
gauge
and
the
"accrued
but
unpayable"
admin
filter
(P8, §D).
A
partner
with
no
trading_accounts
row
cannot
be
represented
in
the module's
queue
at
all,
because
its
account_id
is NOT NULL REFERENCES trading_accounts(id),
so
these
accruals
are
held producer-side
and
never
enqueued.
The
gauge
is
computed
over
rebate_accruals, not
over
the
module's
tables.
Confirmed
on
the
module's
schema
PR
(2026-08-05):
account_id
stays non-nullable,
the
module
only
ever
pays
an
account
that
exists,
and
its
HELD state
covers
the
different
case
of
a
payee
who
exists
but
cannot
yet
receive
— no
KYB,
or
a
restricted
account.
An
affiliate
with
no
trading_accounts
row
is ours
to
hold
and
ours
to
report.
payout_account()
returns Option<AccountId>,
and
the
producer's
host
task
declines
a
None
row,
counts it,
and
leaves
it
approved (commercial-program-accruals
D3).
P7 forward-only corrections. A negative adjustment in the next period's accrual is accrual math, upstream of any payment.
Sequencing. This RFC's four gates are now the module's shared gates, decided once for every producer. The one question that does not move is whether a human must authorize every sweep regardless of size: that sets referral's auto-approval threshold, which is producer-side policy gating the admin surfaces, not the cash path.
These four are now program-payouts gates (its O1–O4), tracked on A-4508 and answered once for all producers. They are kept here because the reasoning is referral-specific and the owners are the same people. The "Blocks" column is stale wherever it names a PR from this RFC; the SoW's Track C says what gates what now.
| # | Question | Owner | Blocks |
|---|---|---|---|
| O1 | Payout
currency:
USDfiat
vs
USDC.
TransactionKind::Deposit
rejects
Currency::USD
(lib.rs:131-142);
confirm
against
what
the
fee
account
holds. |
Eng + Finance | §B step 3 |
| O2 | Answered by P9: a monthly sweep at period close, over approved accruals only. What remains is operational — the pacing gap between transactions and the off-peak window, both forced by the global advisory lock (P5). | Ops | Pacing only |
| O3 | Answered
by
P8:
held,
not
forfeited.
What
remains
is
narrower:
is
an
indefinitely
held
balance
acceptable
to
Compliance
and
Finance,
or
must
claim_window_periods
be
finite?
P8
takes
either
answer
as
a
constant. |
Compliance + Finance | The window value only |
| O4 | Tax and withholding treatment of a rebate credited to a trading account. | Finance + Legal | Go-live. Not a systems question, but nothing pays out until it is settled |
Until a payer ships, an approved accrual plus its CSV statement is the settlement artifact and Finance settles by hand. See Referral Program §F.
The Referral Program stops at an approved accrual and a CSV statement. Finance can reconcile and settle by hand for the first periods, but hand settlement does not scale and has no idempotency story.
This RFC is split from that one because it is the part that mutates balances, its blast radius differs in kind, and it can land weeks after partners are already earning.
| Component | State |
|---|---|
AX_FEE_ACCOUNT_ID |
The
double-entry
sink
for
collected
fees
(rs/sdk-internal/src/account_id.rs:10).
Its
balance
is
live
in
current_balances;
it
is
not
exposed
through
GET /admin/accounts/{id}. |
| The deposit path | POST /deposit
(rs/api-gateway/src/utils.rs:1232-1307)
is
how
MM
stipends
are
already
paid
by
hand,
consistent
with
the
dynamic-fee
SoW
classifying
stipends
as
deposits
rather
than
fees. |
| Double-entry precedent | lending_transactions
(rs/transaction-engine/src/lending.rs:23-60)
books
a
matched
credit/debit
pair
against
a
system
counterparty. |
| Transaction-engine idempotency | None.
insert_transactions
(rs/transaction-engine/src/lib.rs:61)
does
usd_balance = usd_balance + amount
with
no
dedup,
and
the
caller-supplied
event_id
is
written
to
ClickHouse
and
used
for
nothing.
A
replayed
payout
double-credits. |
| The authorization-ledger shape | treasury_engine.deposit_credit_attempts
(db/postgres/1.sql:1464-1486)
is
an
immutable
authorization
ledger
with
an
idempotency
key,
actor
and
reason.
Schema
only,
with
zero
Rust
references. |
P1.
Payout
is
a
separate
settlement
step,
never
implied
by
accrual.
It runs
only
against
a
rebate_accruals
row
with
approved_at
set.
Compute automatically,
approve,
then
sweep
on
a
schedule:
the
gate
sits
at
approval, not
at
each
transfer,
so
money
movement
is
batched
without
any
accrual
paying itself.
The gate is human only above the materiality threshold. Referral D5a auto-approves accruals below it, so for a small established partner the whole chain — compute, approve, sweep, credit — runs unattended. On that path D5a's escalation rules and this RFC's recon invariants are the only controls.
P2.
Rebates
live
on
a
side
ledger
until
the
sweep,
never
on
the
main ledger
as
they
accrue.
rebate_accruals
is
the
running
record,
and
the transaction
engine
is
touched
exactly
once
per
partner
per
period,
at settlement.
Then
double-entry:
debit
AX_FEE_ACCOUNT_ID,
credit partners.payout_account_id,
through
the
same
transaction-engine
path funding
uses.
payout_account_id
is
the
partner's
choice
among
its
own
accounts, defaulting
to
its
only
account.
Partners
with
several
accounts
should
not
have AX
guessing
which
one
earnings
land
in,
and
making
it
explicit
costs
a
nullable FK
that
already
exists.
Debiting
the
fee
account
is
where
the
money
is,
and
it
makes
the
recon invariant
(Σ paid ≤ Σ collected,
referral
§H)
direct.
No
new
system
account is
introduced.
P3.
Reuse
TransactionKind::Deposit;
do
not
add
a
variant.
A
new variant
touches
the
public
SDK
TransactionType (rs/sdk/src/protocol/api_gateway.rs:317-332),
TransactionKind::allows(), and
every
consumer
that
matches
on
the
string.
Distinguish
through reference_id = 'partner-rebate://<period>/<partner_slug>/<program_kind>', following
the
URN
convention
at
db/clickhouse/init.sql:300-304,
already covered
by
INDEX idx_transaction_type.
P4.
Idempotency
is
a
primary
key,
not
a
check.
A
row
keyed
on
a deterministic
idempotency_key
—
the
same
URN
as
P3
—
is
inserted
in
the
same transaction
as
the
balance
mutation,
through
handle_transactions_with_txn (rs/transaction-engine/src/lib.rs:236).
A
replayed
payout
is
a
PK
violation, not
a
second
credit.
This
is
the
deposit_credit_attempts
design,
field
for field.
P5.
One
partner
per
transaction.
All
balance
mutations
serialize
on
a single
global
advisory
lock,
pg_advisory_xact_lock(8675309) (rs/transaction-engine/src/database.rs:105).
A
batched
multi-partner
payout would
stall
every
deposit
exchange-wide
for
its
duration.
P6.
Rebates
are
paid
in
the
fee-collection
currency;
there
is
no
FX conversion.
TransactionKind::Deposit
rejects
Currency::USD
and
requires USDfiat
(rs/transaction-engine/src/lib.rs:131-142); process_deposit_or_withdrawal
does
that
translation
at
utils.rs:1243. Confirm
the
intended
currency
against
what
trades
books
(O1).
P7. Corrections are forward-only. A paid period is closed. Wash trading, refunds, or fees reversed after payout are handled by a negative adjustment in the next period's accrual for that partner, or a direct debit if the relationship is ending. Never by editing a settled row.
P8.
A
partner
may
hold
a
referral
code
without
a
trading
account,
and unpayable
accruals
are
held,
not
forfeited.
Lazy
provisioning
means
a
referral partner
—
typically
an
affiliate
or
IB,
not
a
trader
—
may
have
no trading_accounts
row.
Attribution
and
accrual
are
unaffected,
because
both
key on
the
user,
so
such
a
partner
accrues
and
approves
normally;
the
accrual
rests in
approved
until
a
payout_account_id
exists.
This decision is tentative, and it is specified so that reversing it is a change of constant rather than a rewrite:
claim_window_periods.
Unset
means
hold indefinitely,
which
is
the
chosen
setting;
0
means
forfeit
at
approval. Ship
the
parameter
unset,
so
the
fallback
is
a
config
change
to
a
value already
on
the
code
path.
claim_expires_at
is
stamped
at
approval,
NULL
when
the
window
is
unset,
and a
daily
job
transitions
elapsed
rows
to
expired.
The
freeze
trigger
must permit
that
one
transition
on
an
approved
row,
as
it
must
for approved → paid.
Flip to forfeit if any of these turns up:
AX_FEE_ACCOUNT_ID
is
debited
and
pulls
in
accounting
integration,
which
is outside
Eng's
control.
Held accruals carry no ladder or rate risk: the rate, base and amount are frozen at approval (referral D5), so a schedule change years later cannot revalue them.
P9. Settlement is a monthly sweep at period close, not a per-accrual action and never realtime. Earnings accrue continuously against the open period (referral D6); once the period closes and its accruals are approved, a scheduled run sweeps each approved, payable accrual into its partner's account.
Realtime payout is not on the table: an intra-period accrual is provisional and can revalue downward (referral D6a), so paying one before the period closes would create a clawback the ledger has no mechanism for, because corrections are forward-only (P7).
CREATE TABLE partner_programs.rebate_payouts (
-- 'partner-rebate://<period>/<partner_slug>/<program_kind>'. Deterministic,
-- so a replay collides here instead of double-crediting.
idempotency_key TEXT PRIMARY KEY,
account_id CHAR(16) NOT NULL REFERENCES trading_accounts(id),
amount NUMERIC NOT NULL,
outcome TEXT NOT NULL,
-- The transaction-engine event this credit became. NULL iff FAILED.
transaction_event_id TEXT UNIQUE,
actor TEXT NOT NULL,
reason TEXT NOT NULL,
attempt_ts TIMESTAMPTZ NOT NULL DEFAULT CURRENT_TIMESTAMP,
CONSTRAINT rebate_payouts_outcome_valid
CHECK (outcome IN ('CREDITED', 'FAILED')),
CONSTRAINT rebate_payouts_event_id_iff_credited
CHECK ((outcome = 'CREDITED') = (transaction_event_id IS NOT NULL))
);The
table
references
its
accrual
by
that
accrual's
natural
key (user_id, program_kind, period),
which
is
also
what
the
idempotency
URN
carries.
Immutable:
a
freeze
trigger
rejects
UPDATE
and
DELETE.
Corrections
are
new rows
(P7).
The
accrual's
lifecycle
extends
by
one
terminal
state,
approved → paid,
flipped in
the
same
transaction
that
books
the
transfer,
so
the
ledger
and
the
accrual cannot
disagree.
Two
entry
points,
one
code
path.
The
monthly
sweep
(P9)
is
a
scheduled
task that
selects
approved,
payable
accruals
for
the
closed
period
and
drives
them
one at
a
time,
paced,
off-peak.
The
admin
pay
endpoint
drives
the
same
routine
for
a single
accrual,
admin-only,
with
Clerk
step-up
required
(the POST /admin/verify-identity
MultiFactor-within-1-minute
pattern, admin_routes.rs:2733-2787).
Both
share
the
P4
idempotency
key,
so
a
manual payment
followed
by
a
sweep
collides
and
no-ops.
The
sweep's
selection
predicate
is
the
whole
of
its
policy: approved_at IS NOT NULL,
the
partner
is
active
with
a
payout_account_id, not
already
paid,
and
not
expired.
Everything
it
skips
stays
where
it
was.
Per accrual:
approved_at IS NOT NULL
and
the
partner
has
a payout_account_id
and
is
active.
Refusal
here
is
a
hold,
not
an
error (P8):
the
accrual
stays
approved
and
payable
later.
Distinguish
it
from
a genuine
failure
in
the
admin
surface,
or
the
unpayable
queue
reads
as
a backlog
of
broken
payouts.
handle_transactions_with_txn,
with reference_id
set
to
the
same
URN.
approved
to
paid.
outcome
and
transaction_event_id,
and
commit. Steps
2
through
5
are
one
transaction.
These belong in the runbook:
current_balances
is
logically
replicated
to
the
risk
engine (db/postgres/1.sql:1548).
A
partner
can
trade
against
a
rebate
the
moment
it lands.
lib.rs:222-232),
so
a
rollback
leaves
an
orphan
ClickHouse row.
The
transaction_event_id
join
is
what
lets
reconciliation
spot
it.
Recon
invariants.
Σ paid ≤ Σ approved
for
every
closed
period:
every settled
dollar
traces
to
an
approved
accrual,
and
no
accrual
is
paid
twice.
The accrual-side
Σ paid ≤ Σ collected
check
lives
in
referral
§H.
Alert
if
a
payout
would
take
AX_FEE_ACCOUNT_ID's
period
intake
negative. Under
the
current
ladder
this
is
unreachable,
which
is
what
makes
it
worth alerting
on.
Held-balance
gauge
(P8):
Σ approved − Σ paid
for
partners
with
no payout_account_id,
broken
out
by
partner
and
oldest
period.
Its
partner
count should
agree
with
the
enqueue
pass's
held
count
for
the
same
period:
two independent
derivations
of
the
same
fact,
one
from
the
ledger
and
one
from
the enqueue,
so
a
disagreement
means
a
row
was
dropped
rather
than
held.
This is the number that decides whether holding was the right call. If it grows without partners converting to payable, tripwire 1 or 4 is firing and the forfeit fallback is the answer. Surface it as an admin "accrued but unpayable" filter on the existing accruals list. Nothing partner-facing.
Tests.
A
replayed
payout
hits
the
idempotency
PK
and
does
not double-credit.
A
rollback
mid-payout
leaves
no
accrual
in
paid.
An
unapproved accrual
is
refused.
A
partner
with
no
payout_account_id
is
refused.
The freeze
trigger
rejects
an
edit
to
a
settled
payout
row.
current_balances moves
by
exactly
rebate_amount_usd.
End-to-end
check:
after
paying,
assert
the
authorization
row's
outcome
is CREDITED
with
a
transaction_event_id,
the
ClickHouse
transactions
row
carries reference_id = 'partner-rebate://…',
current_balances.usd_balance
moved
by exactly
the
rebate
amount,
and
re-running
the
payout
is
a
no-op.
See SoW: Partner Programs, Track C.
All four are listed with their owners in Answers needed before this ships.