RFC: Commercial Program Accruals — the referral and API-broker producer

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.

Concept

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.

Decisions

D1 — A pure core plus a host task

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.

D1a — What adopting a shared engine would cost

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.

D2 — The ledger is keyed on the earner, not on an account

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

D3 — The payee is optional, and the hold is the producer's

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.

D4 — One ledger, two programs, discriminated by program_kind

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

D5 — Rows are value-typed

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.

D6 — Coverage is measured, not assumed

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.

D7 — The ledger lives with its sources, in partner_programs

partner_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:

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.

D8 — Approval is ours; the payer only pays approved rows

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.

D9 — Depend on payer properties, not on a payer

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:

  1. Exactly-once per obligation, enforced in the database — a unique constraint or index, not application logic. Retries and replay must be incapable of double crediting.
  2. A producer-supplied idempotency key that carries its program. A cross-program collision must be a write failure, not a silently missing payment. The <program_kind>://<period>/<subject> URN with a CHECK that the key matches the program achieves this.
  3. A non-nullable payee. This producer holds payee-less accruals (D3) so the payer never receives one. A payer that accepts a null account would move that decision, and D3 would have to be re-argued.
  4. Pays only what the producer approved, taking no view on how approval happened (D8).
  5. A hold state for a payee who exists but cannot yet receive — no KYB, or a restricted account — distinct from the producer-side hold in D3.
  6. Terminal and transient failure distinguished, with transient retries that cannot double-credit and terminal failures that consume the key.
  7. Credits through ax-transaction-engine, so the ledger stays the single source of truth and no payer keeps a shadow balance.
  8. Per-program config — funding account, currency, cadence, hold policy, pacing — resolved by program, so policy is data rather than a branch in the payer.
  9. Readable settlement state: a supported join from an obligation to its credited attempt, carrying the transaction event id, cheap and 0-or-1. The admin surface and the statement read this.

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.

Non-goals

Data model

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.

Design

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.

Testing

Open questions

Appendix: source index

Everything below is on main unless marked otherwise.

Candidate payers. None is depended on (D9):

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.