Date: 2026-08-04 (updated 2026-08-12)
Status:
Draft
—
unbuilt,
but
its
first
upstream
producer
has
now
landed:
the
liquidity
program's
Part
1
accrual
ledger
(accrual_engine.liquidity_accruals)
and
the
accrual_engine
schema
ship
as
of
2026-08-12,
so
this
module
is
the
concrete
next
step
to
turn
those
computed
accruals
into
credits.
See
Implementation
status.
Related: generalizes partner-rebate-payouts (which is this module specialized to one producer — this RFC proposes absorbing it); consumers referral-program, liquidity-program (Part 2), and the treasury deposit pipeline (verify-usdc-deposits-onchain); execution plan liquidity-program.plan.yaml.
Author: (with Claude)
Three
separate
features
want
the
same
thing:
take
a
computed
"account
X
is
owed
$Y"
record
and
credit
it
to
a
customer's
AX
balance,
exactly
once,
on
a
schedule,
unattended.
The
referral
program,
the
liquidity
program's
Part
2,
and
the
treasury
deposit
pipeline
all
stop
at
that
boundary
—
and
none
of
them
has
built
past
it,
because
a
scheduled
cash-mover
has
never
existed
in
AX.
Rather
than
build
it
three
times,
this
RFC
defines
one
generic
payout
module
that
any
producer
feeds
through
a
normalized
contract,
with
each
producer
supplying
an
adapter
config
—
resolved
dynamically
by
program_kind
—
for
the
parts
that
differ
(funding
account,
currency,
cadence,
hold
policy,
idempotency
key).
The
producers
own
what
is
owed;
the
module
owns
paying
it.
This
is
a
design
document.
None
of
this
module
is
implemented
—
no
program_payout_requests,
payout_program_config,
or
payout_attempts
tables;
no
ProgramKind
enum;
no
sweep
task;
no
ax-program-payout/ax-accrual-engine
crate.
What
has
changed
since
drafting
is
upstream:
a
real
producer
now
writes
accrual
rows
waiting
for
this
module
(see
below).
The module itself: not started. But the first of its three producers has partially landed, which sharpens the case for building it:
| Producer | What landed | What still needs this module |
|---|---|---|
| Liquidity Part 1 (liquidity-program) | accrual_engine.liquidity_accruals
(Postgres,
append-only,
status
machine
ACCRUED→APPROVED→PAID/FORFEITED),
the
accrual_engine
schema,
and
the
recon-engine
settler
that
writes
it
(#3470/#3539).
The
accrual_engine
schema
exists
precisely
as
this
RFC's
"producer
accrual
ledgers
before
payout
plumbing." |
Everything
after
APPROVED:
this
module's
request
queue
+
sweep
is
the
only
path
from
an
approved
accrual
to
a
credited
balance.
The
accrual
status
enum
already
reserves
APPROVED/PAID
for
exactly
this
hand-off. |
| Referral (referral-program) | unbuilt | first adapter (D8) |
| Treasury deposits (verify-usdc-deposits-onchain) | pipeline built; credit step still unbuilt | third adapter |
Still
true,
verified
2026-08-12:
treasury_engine.deposit_credit_attempts
(the
shape
D2
generalizes)
has
zero
Rust
references
—
this
module
would
be
its
first
consumer.
The
accrual_engine.liquidity_accruals
status
trigger
already
models
the
APPROVED→PAID
compare-and-swap
this
module's
D2
relies
on,
so
the
producer/module
seam
is
real
in
the
schema,
not
just
the
design.
Concrete
next
step:
build
the
three
tables
(D-data-model)
+
the
paced
sweep
(§3),
then
wire
the
liquidity
settler
to
enqueue
program_payout_requests
on
admin
approval
(liquidity
Part
2
/
T6).
The
Finance/Legal
gates
(O1
currency,
O4
tax)
remain
the
hard
go-live
blocker
for
any
producer.
Two ways to make the module generic:
program_payout_requests
table
(program_kind,
account_id,
amount,
currency,
idempotency_key,
status).
The
module
is
one
sweep
that
drains
it.
Adding
a
program
=
a
new
program_kind
value
+
writing
rows;
zero
module
code
changes.
PayoutSource.
Each
program
implements
list_payable()
/
mark_paid();
the
module
calls
the
trait.
Compile-time,
more
code
per
program,
and
the
module
must
know
every
producer.
Recommendation: A — the shared request queue. It is what "adapters that fit dynamically" means: a producer is data, not a code change to the module. The module never imports MMLP or referral; it drains a queue and applies a config. This also cleanly separates the two never-to-be-coupled halves — the producer's accrual math and the module's credit mechanics — at a table, the same seam the liquidity RFC (D7) and the referral/rebate RFCs already draw. B's only advantage (type-checked per-program logic) is unneeded: the payable row is trivially typed, and per-program behavior lives in config (D3), not code.
Every
payout
request
carries
a
producer-supplied
idempotency
key
(a
URN,
e.g.
referral://2026-08/hbot
or
liquidity://2026-09-01/XAU-PERP/<acct>)
as
the
primary
key
of
the
attempt
ledger.
The
sweep
credits
inside
one
Postgres
transaction:
insert
the
attempt
row
(PK
collision
⇒
compare
with
the
immutable
original
request),
book
the
double-entry
Deposit,
flip
the
request
APPROVED → PAID.
A
crash/replay/duplicate
tick
can
never
double-credit.
Treasury
deposit
settlement
now
uses
treasury_engine.deposit_credit_attempts
for
immutable
request
identity
and
treasury_engine.deposit_credits
for
the
canonical
one-effect-per-deposit
result
and
its
transaction_event_id.
It
writes
ClickHouse
before
committing
Postgres,
so
a
Postgres
commit
failure
can
leave
an
orphan
ClickHouse
row
for
reconciliation.
The
manual
and
automated
treasury
settlement
paths
are
current
Rust
consumers
of
these
tables.
They
are
the
precedent
for
this
RFC's
payout
ledger,
not
a
single
attempt-table
shape
to
copy
verbatim.
program_kind
at
runtime
—
not
compile-timeEach
producer
registers
a
payout_program_config
row
keyed
by
a
typed
program_kind
discriminator
(ProgramKind { Referral, ApiBroker, Liquidity, TreasuryDeposit, … },
mapped
to
a
text
column
per
the
house
typed-discriminator
rule).
The
config
carries
every
knob
the
sweep
needs:
funding_account_id,
currency,
cadence,
min_payout,
hold_policy,
approval_mode,
pacing.
The
sweep
reads
config-then-requests;
a
new
program
is
one
config
row
+
writing
requests.
Program-specific
extras
that
don't
generalize
go
in
a
params JSONB
on
the
config
row
(the
DbInstrument
PgJson<T>
pattern).
No
if program == mmlp
anywhere
in
the
module.
The
module
only
ever
pays
rows
already
at
status = APPROVED.
How
a
row
becomes
approved
is
the
producer's
business
—
auto-approve
below
a
materiality
threshold
(referral
D5a),
a
human
POST /admin/<program>/approve
per
period
(liquidity
Part
2),
or
always-approved
(treasury
deposits,
which
already
gated
upstream).
The
module's
approval_mode
config
just
records
whether
it
should
refuse
to
pay
un-approved
rows
(always
yes)
and
whether
a
dollar
ceiling
forces
human
review
before
enqueue.
Keeping
approval
upstream
means
the
module
has
no
program
policy
in
it
—
it
is
a
dumb,
safe,
exactly-once
payer.
TransactionKind::Deposit
rejects
Currency::USD
and
requires
USDfiat
(rs/transaction-engine/src/lib.rs,
~:131-142).
Whether
each
program
credits
USDfiat
or
USDC
is
a
per-program_kind
config
field,
and
it
is
the
same
open
question
partner-rebate
calls
O1
and
liquidity
calls
Q7.
Deciding
it
once,
in
this
module's
config
schema,
settles
it
for
every
producer.
The
module
runs
a
single
ax_scheduler::run_periodic
loop
(rs/sdk-internal/scheduler/src/cadence.rs:144).
Each
tick,
for
each
program
whose
cadence
is
due
(referral
monthly
at
period
close;
liquidity
daily
or
manual;
treasury
near-real-time),
it
drains
that
program's
approved
requests,
paced
(one
credit
at
a
time,
off-peak,
under
a
global
advisory
lock
so
concurrent
producers
don't
contend
—
partner-rebate
P5/P9).
Cadence
is
config,
not
code;
the
module
does
not
know
why
a
program
pays
monthly
vs
daily.
hold_policyAn
approved
request
for
an
account
that
exists
but
cannot
yet
receive
—
no
completed
KYB,
restricted
—
is
held,
not
dropped
(partner-rebate
P8).
hold_policy
config
=
hold_indefinitely
or
claim_window: N periods;
the
module
is
built
to
take
either
as
a
constant,
so
Compliance/Finance
can
set
the
window
later
without
a
code
change
(partner-rebate
O3).
Note:
this
is
distinct
from
the
liquidity
program's
min-payout
forfeit
and
above-cap
retained
—
those
are
decided
in
the
producer's
accrual
math
(spec-mandated,
before
enqueue),
never
in
the
module.
The
absent-payee
case
is
not
this
one,
and
is
not
ours
(2026-08-05,
on
this
module's
schema
PR).
program_payout_requests.account_id
is
NOT NULL REFERENCES trading_accounts(id)
and
stays
that
way:
the
module
only
ever
pays
an
account
that
exists.
A
producer
whose
payee
has
no
account
at
all
—
a
referral
partner
who
holds
a
code
and
never
opened
one
—
therefore
cannot
enqueue
the
row
even
as
HELD,
and
holds
it
on
its
own
ledger
instead.
The
accrual
engine
encodes
exactly
this
in
AccrualRow::payout_account() -> Option<AccountId>,
declining
such
rows
and
counting
them
(accrual-engine
D8).
The
two
holds
are
deliberately
not
merged:
nullable
account_id
would
put
rows
in
the
payable
queue
that
no
sweep
could
ever
pay,
and
the
query
that
skips
them
is
the
query
that
should
never
have
selected
them.
partner-rebate-payoutspartner-rebate-payouts.md
is
this
module
with
a
single
hardcoded
producer.
Rather
than
build
a
rebate-specific
payer
and
then
generalize,
build
the
generic
module
and
make
rebate
its
first
adapter.
That
RFC's
decisions
(P2
side-ledger,
P4
idempotency,
P5
advisory
lock,
P8
hold,
P9
monthly
sweep)
become
this
module's
mechanics,
verbatim;
its
open
gates
(O1
currency,
O2
cadence,
O3
hold
window,
O4
tax)
become
this
module's
gates,
shared
across
all
producers.
| Producer | Computes | State today |
|---|---|---|
| Referral (referral-program) | fee-share
on
attributed
trades
→
rebate_accruals |
RFC draft, unbuilt |
| Liquidity Part 2 (liquidity-program) | quote-quality
reward
→
accrual_engine.liquidity_accruals |
Part 1 accrual ledger + settler LANDED (#3470/#3539); Part 2 credit unbuilt — waiting on this module |
| Treasury deposits (verify-usdc-deposits-onchain) | verified
on-chain
deposit
→
Credited
event |
pipeline built; credit step unbuilt |
Each
produces
a
per-(account,
period)
amount
and
needs
it
credited
exactly
once.
Each
independently
rediscovered
the
same
schema
(an
idempotency-keyed
attempt
ledger)
and
the
same
missing
piece
(a
scheduled,
unattended,
paced
credit
sweep).
The
treasury
pipeline
even
reaches
a
Credited
event
with
no
consumer
that
calls
the
transaction
engine.
The credit itself is boring and proven:
handle_transactions_with_txn
(rs/transaction-engine/src/lib.rs:239)
writes
double-entry
balance
moves,
rust_decimal::Decimal
rounded
to
BALANCE_DECIMAL_PLACES = 12
(:83),
into
current_balances.usd_balance
(Postgres
NUMERIC,
db/postgres/1.sql:166-174)
mirrored
to
ClickHouse
transactions
(Decimal128(12),
:288-317).
lending_transactions
(rs/transaction-engine/src/lending.rs:23-70)
is
the
double-entry
template
(credit
payee,
debit
a
funding
account,
shared
reference_id
+
event_id).
What is missing everywhere: the scheduled, idempotent, paced caller and the config that lets one caller serve many producers. That is this module.
PRODUCERS (own the accrual math — unchanged by this module)
referral liquidity Part 2 treasury deposits
│ │ │
│ enqueue normalized payable request (status=APPROVED)
▼ ▼ ▼
══════════════ program_payout_requests (Postgres) ══════════════ ← the contract
│ ▲
│ reads config │ status: APPROVED → PAID | HELD | FAILED
▼ │
payout_program_config (per program_kind: funding acct, currency, cadence, hold, pacing)
│
▼ ax_scheduler::run_periodic — one paced sweep, advisory-locked
sweep(program_kind):
for each due, APPROVED, payable request:
┌ one Postgres txn ────────────────────────────────────────┐
│ INSERT payout_attempts(idempotency_key) -- PK ⇒ skip dup │
│ transaction-engine Deposit (credit payee, debit funding) │
│ UPDATE request SET status=PAID, transaction_event_id=… │
└───────────────────────────────────────────────────────────┘
│
▼
current_balances credit + transactions (immutable audit)
program_payout_requestsA
producer,
having
computed
and
approved
an
amount,
writes
one
row:
program_kind,
account_id,
amount,
currency,
period_key,
idempotency_key
(the
URN),
reference
(free
producer
context),
status
(APPROVED
on
enqueue;
the
producer
may
also
write
HELD/PENDING_APPROVAL
if
it
wants
the
module
to
skip).
The
producer's
own
accrual
table
remains
its
source
of
truth;
the
request
is
the
hand-off.
The
module
treats
the
row
as
opaque
—
it
does
not
know
or
care
that
MMLP
computed
it
via
a
waterfall
or
referral
via
a
fee-share.
Who
physically
writes
the
row
is
settled
on
the
producer
side
and
does
not
change
this
contract:
for
producers
hosted
on
ax-accrual-engine,
the
engine
writes
it
generically
when
a
liability
accrual
is
approved
(accrual-engine
O5,
engine-owned),
reading
funding
account
and
currency
from
payout_program_config.
The
treasury-deposit
adapter,
which
is
not
an
accrual
producer,
enqueues
directly.
Either
way
the
module
sees
one
shape.
payout_program_config
(dynamic,
per
program_kind)One row per producer, resolved at sweep time:
payout_program_config {
program_kind: ProgramKind, // typed text discriminator (PK)
funding_account_id: AccountId, // debit source (AX_FEE_ACCOUNT_ID or a dedicated program account)
currency: Currency, // USDfiat | USDC (D5, the O1 gate)
cadence: Cadence, // Monthly{at_period_close} | Daily{hour} | Manual | Continuous
min_payout: Option<Decimal>, // module-level floor (usually None — producer already gated)
hold_policy: HoldPolicy, // HoldIndefinitely | ClaimWindow{periods} (D7)
approval_mode: ApprovalMode, // PayApprovedOnly (always) + optional enqueue ceiling
pacing: Pacing, // per-credit gap + off-peak window (D6)
params: PgJson<Value>, // program-specific extras that don't generalize
}
Adding a producer = insert one row + start writing requests. No module recompile.
ax_scheduler::run_periodic.
Each
tick:
for
each
program_kind
whose
cadence
is
due,
take
the
global
advisory
lock,
select
status = APPROVED
payable
requests,
and
credit
them
one
at
a
time
(pacing
gap
between
credits
to
avoid
a
thundering
herd
on
the
transaction
engine).
Each
credit
is
the
D2
single-transaction
insert-attempt
→
Deposit
→
mark-PAID.
A
payee
that
can't
receive
→
HELD
per
hold_policy.
A
transaction-engine
failure
→
the
attempt
row
records
outcome = FAILED
with
a
reason;
the
request
stays
APPROVED
and
retries
next
tick
(no
retry
storm
—
natural
re-selection).
The
sweep
is
stateless
across
ticks;
all
state
is
in
the
two
tables
+
the
attempt
ledger.
payout_attempts
(the
idempotency
ledger,
the
deposit_credit_attempts
shape):
idempotency_key TEXT PK,
program_kind,
account_id,
amount,
outcome (CREDITED|FAILED),
transaction_event_id TEXT UNIQUE
(null
on
FAILED),
actor,
reason,
attempt_ts.
Append-only,
freeze-triggered
—
corrections
are
new
rows.
The
immutable
financial
audit
is
the
transaction-engine
transactions
row
itself
(event_id);
the
attempt
ledger
is
the
idempotency
+
attempt-history
record.
The
sweep
is
a
single
obligate-singleton
periodic
task.
It
reads/writes
only
its
own
three
tables
+
calls
the
transaction
engine,
so
it
can
live
wherever
the
first
producer's
sweep
would
have
—
most
naturally
alongside
the
other
run_periodic
money-adjacent
jobs
(recon-engine
hosts
the
fee
engine
and
would
host
liquidity
Part
1;
the
sweep
is
a
sibling),
or
as
a
small
dedicated
payout-engine
if
operators
want
the
cash-mover
isolated
from
everything
else.
Either
way
it
is
one
writer,
config-driven,
serving
all
producers.
Three
new
Postgres
tables;
no
ClickHouse
(the
firehose
stays
in
each
producer).
Declarative
Atlas
(db/postgres/1.sql).
| Table | Store | Job | Key columns |
|---|---|---|---|
payout_program_config |
Postgres | the adapter — one row per producer | program_kind
PK
(typed
enum),
funding_account_id,
currency,
cadence,
min_payout,
hold_policy,
approval_mode,
pacing,
params JSONB |
program_payout_requests |
Postgres | the contract — a payable, per (program, account, period) | idempotency_key
PK,
program_kind,
account_id,
amount NUMERIC,
currency,
period_key,
reference,
status
(PENDING_APPROVAL/APPROVED/PAID/HELD/FAILED),
created_at,
updated_at |
payout_attempts |
Postgres | the
idempotency
+
attempt
ledger
(the
deposit_credit_attempts
shape) |
idempotency_key
PK,
program_kind,
account_id,
amount,
outcome,
transaction_event_id UNIQUE,
actor,
reason,
attempt_ts;
append-only,
freeze
trigger |
program_payout_requests.idempotency_key
and
payout_attempts.idempotency_key
are
the
same
URN
—
the
request
and
its
terminal
attempt
share
identity,
so
"did
this
already
pay"
is
one
PK
lookup.
Money
is
rust_decimal::Decimal
/
NUMERIC
throughout
(12
dp),
consistent
with
the
transaction
engine.
Producers
keep
their
own
accrual
tables
(rebate_accruals,
accrual_engine.liquidity_accruals,
the
deposit
pipeline
record)
—
those
are
unchanged
and
out
of
this
module's
scope.
Each
producer's
accrual
table
can
be
dropped
in
favor
of
writing
straight
to
program_payout_requests
if
it
needs
no
richer
per-program
state;
most
(referral
tiers,
liquidity
pre-cap/forfeit
detail)
will
keep
their
table
and
enqueue
a
derived
request.
payout_program_config
row
(funding
account,
currency,
cadence,
hold,
pacing).
program_payout_requests
rows
with
status = APPROVED
and
a
deterministic
idempotency_key.
PAID.
The
producer
can
read
back
status/transaction_event_id
for
its
own
dashboards.
A
producer
that
needs
a
human
approval
step
writes
PENDING_APPROVAL
and
flips
to
APPROVED
behind
its
own
admin
endpoint;
the
module
simply
never
pays
non-APPROVED
rows.
transaction-engine,
producers,
or
existing
flows.
payout_program_config
row
+
enqueue
from
the
referral
accrual
job).
partner-rebate-payouts.md
is
marked
superseded
by
this
RFC.
Credited
event
to
enqueue
(closes
the
long-standing
"deposit
pipeline
reaches
Credited
but
nothing
credits"
gap).
approval_mode;
the
sweep
is
safe
to
run
with
zero
configured
programs
(no-op).
idempotency_key
credits
once
(PK
+
status
CAS).
program_kind
with
only
a
config
row
+
requests
pays
correctly
with
no
module
change.
HELD,
not
paid,
not
lost;
later
becomes
payable.
The
neighbouring
case
—
a
payee
with
no
account
at
all
—
is
held
by
the
producer
and
never
reaches
this
queue
(D7),
so
the
test
that
matters
here
is
that
account_id
is
unconditionally
resolvable
for
every
row
the
sweep
selects.
FAILED/retryable
without
blocking
the
rest
or
storming.
Currency::USD
is
rejected
at
load
(must
be
USDfiat/USDC).
APPROVED→PAID
race
across
a
restart.
USDfiat
vs
USDC
per
program
(D5).
Confirm
against
what
each
funding
account
actually
holds.
hold_indefinitely
vs
a
finite
claim_window
(D7)
—
Compliance
+
Finance.
AX_FEE_ACCOUNT_ID
debit
for
all,
or
a
dedicated
funding
account
per
program
(P&L
attribution)?
Per-program
config
either
way.
payout-engine
singleton.
Isolation
vs
one-more-service.
| Concern | Location |
|---|---|
| Credit mechanism / money type | rs/transaction-engine/src/lib.rs:83,239
(BALANCE_DECIMAL_PLACES,
handle_transactions_with_txn);
TransactionKind/Currency::USDfiat
~:112-142 |
| Double-entry template | rs/transaction-engine/src/lending.rs:23-70 |
| Idempotent-ledger shape (to generalize) | db/postgres/1.sql:1464-1510
(treasury_engine.deposit_credit_attempts) |
| Balance / audit ledgers | db/postgres/1.sql:166-174
(current_balances);
db/clickhouse/init.sql:288-317
(transactions) |
| Periodic scheduler | rs/sdk-internal/scheduler/src/cadence.rs:144
(run_periodic) |
| System funding account | rs/sdk-internal/src/account_id.rs:10
(AX_FEE_ACCOUNT_ID) |
| Specialized predecessor | partner-rebate-payouts (superseded) |
| Producers | referral-program; liquidity-program Part 2; verify-usdc-deposits-onchain |
Design
document.
The
credit
mechanism
(transaction-engine)
exists
and
is
proven;
this
RFC
adds
the
scheduled,
config-driven,
exactly-once
caller
that
lets
one
payout
path
serve
every
producer.
No
bank/payment-processor
integration
—
internal
ledger
credit
only;
external
value
exits
via
the
existing
custody
withdrawal.
Code
references
as
of
branch
liquidity-program-rfc.