RFC: Generic Program Payout Module

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).


Implementation status (2026-08-12)

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.


Decision-first summary

D1 — The producer↔︎module contract: a normalized payout-request queue, not a per-program trait

Two ways to make the module generic:

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.

D2 — Exactly-once: idempotency-key PK + status compare-and-swap

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.

D3 — Adapters are config rows, resolved by program_kind at runtime — not compile-time

Each 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.

D4 — The approval gate lives in the producer, not 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.

D5 — Currency (USDfiat vs USDC) is per-program config — the shared O1 gate

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.

D6 — One paced sweep, per-program cadence — no per-program scheduler

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.

D7 — Hold, don't forfeit, when a payee can't receive — per-program hold_policy

An 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.

D8 — This module supersedes partner-rebate-payouts

partner-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.


Background

The three producers, all stopped at the same wall

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 mechanism already exists; the caller doesn't

The credit itself is boring and proven:

What is missing everywhere: the scheduled, idempotent, paced caller and the config that lets one caller serve many producers. That is this module.


Design

 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)

1. The contract — program_payout_requests

A 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.

2. The adapter — 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.

3. The sweep — one paced, idempotent loop

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.

4. Exactly-once + audit

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.

5. Placement

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.


Data model

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.


Interface — how a producer plugs in

  1. Register once: insert a payout_program_config row (funding account, currency, cadence, hold, pacing).
  2. Per period, after computing + approving amounts: insert program_payout_requests rows with status = APPROVED and a deterministic idempotency_key.
  3. That's it. The sweep credits them on the configured cadence, idempotently, and flips them 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.


Migration / rollout

Testing

Open questions (the shared gates — decide once, unblock all producers)


Appendix: source index

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.