RFC: Referral Program

Date: 2026-07-30

Status: Draft

Author: Joey McCarey (with Claude)

Related: commercial-program-accruals — the producer that turns this policy into an accrual row. api-broker-program — Program 2, which reuses this RFC's registry, ladder and accrual ledger. omnibus-broker-program — Program 3, which shares none of them. program-payouts — one candidate payer. partner-rebate-payouts — the requirements any payer must meet. continuous-recon-checks — the host for the cross-cutting invariant checks in §H. Sequencing is in SoW: Partner Programs, PRs 1–7.

Concept

A referrer earns a share of the trading fees AX collects from the clients it brought in. The share is a percentage set by a volume ladder. The program computes that share into an approved, auditable accrual row, one per earner per calendar month, and produces a CSV statement Finance reconciles. It does not move money.

   signup with ?ref=CODE            ClickHouse trades           partners registry
   attribution row, user -> user    fees per account per day    override/grandchild rates
          |                                  |                         |
          +----------------------------------+-------------------------+
                                             |
                                             v
                              rs/sdk-internal/src/referral_program.rs
                              ownership -> eligibility -> daily ladder
                                             |
                                             v
                              rs/accrual-engine/src/rebates.rs
                              upsert partner_programs.rebate_accruals
                                             |
                          +------------------+------------------+
                          v                                     v
                   provisional row                        approved row
                   recomputed daily                       frozen, CSV statement

Three facts set the shape of everything below:

Fact Consequence
Attribution binds a user, not an account Account provisioning is lazy, so no trading account exists at signup. The ledger is keyed on users.id, and the accrual resolves user → accounts through trading_accounts.ubo_user_id.
The rebate is a share of fees collected, not of notional volume Volume only sets the ladder rung. The base is maker_fee or taker_fee on the attributed side of each fill.
A fill-side can have two owners Referral and API Broker/OMS both earn on flow that carries a referral attribution and a Broker ID. The pair splits the lower of their two rates, half each (D7′). A referral-only accrual is still correct on its own, because no fill carries a Broker ID until the broker arm lands.

The program terminates at an approved accrual plus a CSV statement. That pair is the settlement artifact until a payer ships.

Open questions that gate delivery

Every question that gated the accrual is answered. Two remain.

# Question Owner Gates
O6 Is an indefinitely held balance acceptable for a partner who never completes KYB, or must the claim window be finite? Held-not-forfeited is settled (payouts RFC P8), so the answer sets a constant. Compliance + Finance Payout only. Now a shared payout-module gate (A-4508); holds SoW PR 12′
O12 Confirm the progressive-daily ladder rate (D3): a promotion applies forward day by day rather than repricing the whole month, and a day's own volume counts toward that day's rung. The fold is one function either way. Commercial The first partner statement

One further gate is operational. Every non-standard-priced account must carry the right pricing classification before an accrual is trustworthy (D4a). There is no backfill pass: classification is entered through the admin path, and the accrual refuses to run rather than under-collect silently. This is a standing property, not a milestone to clear.

Background

AX Commercial runs three partner-economics programs on staggered timelines:

  1. Referral Program — fee share on a referred client's volume, ownership set at registration. First to launch. This RFC.
  2. API Broker / OMS Program — fee share on routed order flow, attributed per transaction by a Broker ID. Second, with Hummingbot as launch partner.
  3. Omnibus Broker Program — a master-account pricing model where the broker sets its own client-facing fees and retains the spread. Third.

Programs 1 and 2 share one rebate ladder (Bronze/Silver/Gold → 30/40/50% of eligible fees) and one attribution rule: a fill-side may be owned by both, and when it is, the two earners split the lower of their two rates (D7′). Program 3 uses neither.

What exists today

Component State
Fees on the tape Both trade engines resolve the account's rate from in-memory account_fee_rates and write maker_fee / taker_fee onto the ClickHouse trades row at fill time (trade-engine2/src/state_machine.rs:369-392, btnl-trade-engine/src/state_machine.rs:610). trades is a sufficient source for the rebate base.
Missing fee rate Both engines book dec!(0) when an account is absent from account_fee_rates and log an error (state_machine.rs:376-387). A structurally-zero fee is indistinguishable on the tape from a genuinely free fill. D1d covers it.
Fee schedule fee_schedules (db/postgres/1.sql:640-681) is one global immutable JSONB tier ladder, window_shape = 'trailing_30d', with a structural validator and a freeze trigger. Typed as FeeProgram / FeeTier in rs/sdk-internal/src/fee_program.rs. Maker fees may be negative.
Fee engine refresh_fee_rates_task() (rs/accrual-engine/src/fee.rs) recomputes each account's tier from a hysteretic trailing-30d volume and writes (maker_fee, taker_fee) onto trading_accounts. It writes rates only: there is no persisted tier index and no fee-rate audit table.
Volume aggregation query_account_volumes_windowed() (rs/sdk-internal/clickhouse/src/schema.rs:1191-1258) sums notional per (account, symbol) over a set of windows with per-symbol multiplier scaling, returning missing_multipliers so a caller can refuse rather than mis-bill.
Accounts trading_accounts (db/postgres/1.sql:698-735) is flat: id, maker_fee / taker_fee, ubo_user_id → users.id. No partner FK, no referral code, no persisted tier.
Registration Self-serve signup is Clerk-owned. POST /login/clerk (rs/api-gateway/src/public_routes.rs:117) is the only entry point and fires on every login. It materializes the user through resolve_or_materialize_clerk_user → provision_user (rs/api-gateway/src/auth.rs:155, :210), and the trading account separately through materialize_default_account (auth.rs:269).
Daily rollup account_volume_1d (A-4334) is a per-(account, symbol, day) rollup of notional, fees and trade counts. It is DDL only; its write path is deferred. D4c is why the accrual does not wait for it.

The fee system is volume-ladder-based and has no notion of a partner, referrer, broker, or rebate ledger. This RFC adds a partner-and-attribution model, an eligibility model, a rebate accrual ledger, and a periodic accrual job.

Requirements from the commercial framework

Framework language Requirement Mechanism
"Referral ownership is established at account registration using the referral link or code" Capture at registration, keyed to the client Signup insert in the registration transaction (§A)
"A client may be assigned to only one Referral Partner" Exactly one referral owner per client Partial unique index, one signup row per user (§B)
"Referral ownership remains in place unless AX approves an exception" Permanence by default; change only by approved exception Append-only ledger; corrections are new rows; approval enforced by the admin surface (D2a)
"Referral attribution takes priority over a later API Broker / OMS connection" / "no double rebate" Superseded by Commercial: the two programs share a fill-side rather than one excluding the other The split rule (D7′). What survives of "no double rebate" is the bound: a shared fill-side pays out no more in total than either program would have paid alone
Monthly rebate calculation on 30-day eligible volume Owner-at-period reads that do not shift after a month is paid The strictly-after ordering rule on corrections (D2a)

The framework requires no audit trail and no immutability mechanism. Those are engineering choices, sized in D2a to serve the last two rows. Two-level earning and retail refer-a-friend are additions from the team, not framework requirements (D2d).

One framework rule has been withdrawn. The framework wrote referral and broker as mutually exclusive claims on the same flow, settled by a strict waterfall. Commercial changed course: both earn. D7′ carries the replacement and the reasoning; the row above records what it displaced, because the waterfall framing is still quoted in older notes.

Decisions

The rebate base

Attribution

Ladder and eligibility

Ledger, job and approval

Schema placement

Design

A. Capture

A referral code enters the system on any inbound app URL and reaches the attribution row inside the registration transaction.

any app URL ?ref=CODE                     root, deep link, or the signup page
  -> AuthProvider captures once per load  gui/packages/app/provider/AuthContext.tsx
  -> localStorage                         gui/packages/app/util/referralCode.ts
  -> useReferralCapture                   gui/packages/app/hooks/useReferralCapture.ts
  -> ClerkSignupPage referral field       pre-filled, editable, never blocks submit
  -> exchangeClerkSession(token, code)    gui/packages/app/util/clerkSession.ts:24-41
  -> loginWithClerkToken(token, code)     gui/packages/app/provider/AuthContext.tsx:38,125-141
  -> POST /login/clerk { clerk_token, expiration_seconds, referral_code }
                                          rs/sdk/src/protocol/api_gateway.rs:114-118
  -> clerk_login                          rs/api-gateway/src/public_routes.rs:127-148
  -> resolve_or_materialize_clerk_user    rs/api-gateway/src/auth.rs:155-204
  -> provision_user                       rs/api-gateway/src/auth.rs:210-262
       attribution row written in the SAME tx as the users row (auth.rs:249-252)

The attribution row is written in provision_user's existing transaction. The users row is the registration event, user_id is the correct key (D2), and one transaction means a split-write cannot lose the attribution while consuming the code.

The captured code is shown in the signup form, pre-filled and editable. URL capture is last-wins: a visitor who follows two referrers' links stores the second, and attribution is first-touch and permanent. Showing the code makes the choice visible at the only moment it can be corrected, and doubles as the way to enter a code handed over out of band. The field is optional and never blocks submit; a value that does not normalize is dropped at exchange time, as a malformed ?ref= is.

Three behaviors this path must get right:

Validate referral_code on the way in with the same shape as the table CHECK (trim, uppercase-normalize, ^[A-Z0-9]{4,32}$) so hbot and HBOT cannot split attribution. rs/onboarding-gateway/src/validation.rs:83-91 is the precedent.

Do not reuse lead_source. It is keyed by email on waitlist_entries, is never joined to users, and was never meant to carry money.

Cross-repo dependency. The app captures ?ref= on any inbound app URL, so a referral link pointing straight at the app needs no other repo. A link that points at the marketing site first needs a coordinated change in architect-xyz/landing-page: persist the code, carry it to the app on the shared apex domain. The two ship in either order.

B. Referrer, code and attribution model

One append-only edge store over users, plus one sparse registry for money. Referral edges are user → user; partners is a payout registry, not a graph node.

CREATE SCHEMA partner_programs;

-- Campaign codes. Many per referrer, many live at once. Retiring one sets
-- retired_at; the row stays so an already-minted attribution's denormalized
-- code keeps its meaning, and it stops resolving new signups.
CREATE TABLE partner_programs.referral_codes (
    code          TEXT PRIMARY KEY,
    user_id       CHAR(16) NOT NULL REFERENCES users(id),  -- the referrer
    created_at    TIMESTAMPTZ NOT NULL DEFAULT CURRENT_TIMESTAMP,
    retired_at    TIMESTAMPTZ,
    CONSTRAINT referral_codes_format_valid CHECK (code ~ '^[A-Z0-9]{4,32}$')
);
CREATE INDEX referral_codes_user_id_idx
    ON partner_programs.referral_codes (user_id);

-- Append-only ownership timeline. Current owner = greatest attributed_at <= now.
-- The tree lives here and only here: override(U, t) is two steps of the same
-- read (D2d), never a stored column.
CREATE TABLE partner_programs.referral_attributions (
    user_id       CHAR(16) NOT NULL REFERENCES users(id),  -- the referred user
    -- NULL only for a revocation, which ends ownership naming no successor.
    referred_by   CHAR(16) REFERENCES users(id),
    -- Denormalized, not a reference: retiring a campaign must neither be
    -- blocked by this row nor erase how the user arrived.
    code          TEXT,
    attributed_at TIMESTAMPTZ NOT NULL DEFAULT CURRENT_TIMESTAMP,
    origin        TEXT NOT NULL,
    -- Operator notes recorded by the admin surface. The CHECK enforces
    -- presence; the surface enforces approval.
    approved_by   TEXT,
    reason        TEXT,
    CONSTRAINT referral_attributions_origin_valid
        CHECK (origin IN ('signup', 'admin_exception')),
    CONSTRAINT referral_attributions_exception_is_approved
        CHECK (origin = 'signup'
               OR (approved_by IS NOT NULL AND reason IS NOT NULL)),
    CONSTRAINT referral_attributions_code_for_signup
        CHECK (origin <> 'signup' OR code IS NOT NULL),
    -- A signup always names a referrer; only an exception may end ownership
    -- by naming nobody, which is how a revocation is spelled.
    CONSTRAINT referral_attributions_signup_names_referrer
        CHECK (origin <> 'signup' OR referred_by IS NOT NULL),
    -- A user cannot refer itself: a self-edge would make an earner with a
    -- grandchild rate its own grandparent and pay both arms on one book.
    CONSTRAINT referral_attributions_no_self_attribution
        CHECK (referred_by IS NULL OR referred_by <> user_id),
    PRIMARY KEY (user_id, attributed_at)
);
-- First touch wins, permanently; a repeat or concurrent login is a no-op.
CREATE UNIQUE INDEX referral_attributions_one_signup_per_user_idx
    ON partner_programs.referral_attributions (user_id) WHERE origin = 'signup';
CREATE INDEX referral_attributions_referred_by_idx
    ON partner_programs.referral_attributions (referred_by);

-- Commercial counterparty facts for a user that gets paid: whether payout is
-- gated, where the money goes. Not a graph node, and not required to refer:
-- rows are minted at payout onboarding, and a referrer with no row accrues
-- normally with the accrual held (payouts RFC P8). Shared with the API Broker
-- RFC, which adds its own attribution source against the same registry.
CREATE TABLE partner_programs.partners (
    user_id           CHAR(16) PRIMARY KEY REFERENCES users(id),
    status            TEXT NOT NULL DEFAULT 'pending',  -- gates payout only
    -- Nullable by design: lazy provisioning means a referrer may have no
    -- trading account at all.
    payout_account_id CHAR(16) REFERENCES trading_accounts(id),
    -- A negotiated flat rate that REPLACES the ladder on this referrer's
    -- DIRECT (child) book (D2d). NULL is the ordinary case. A rate and not a
    -- level: 20% is not a rung.
    override_rate     NUMERIC,
    -- The flat rate this earner is paid on grandchild flow; its presence gates
    -- the second level (D2d). Set only for named internal sales/BD staff.
    -- Independent of override_rate.
    grandchild_rate   NUMERIC,
    CONSTRAINT partners_status_valid
        CHECK (status IN ('pending', 'active', 'suspended', 'terminated')),
    CONSTRAINT partners_override_rate_valid
        CHECK (override_rate IS NULL
               OR (override_rate > 0 AND override_rate <= 1)),
    CONSTRAINT partners_grandchild_rate_valid
        CHECK (grandchild_rate IS NULL
               OR (grandchild_rate > 0 AND grandchild_rate <= 1))
);

There is no excluded_accounts table. Literal self-dealing needs no row: the accrual skips any account whose ubo_user_id is the earner's own user (§C, §G). Judgment-call exclusions — a related party's account, wash-flagged flow, anything Compliance carves out other than by key equality — wait for the first real case, with withheld approval as the interim control, since an unapproved row recomputes freely and pays nothing. The re-add is one CREATE TABLE (account_id, reason) — global per account, not per (referrer, account), since an account Compliance distrusts is bad flow for every earner — plus one NOT IN in the accrual fold.

Grants. The application role holds SELECT, INSERT only on referral_attributions; UPDATE, DELETE and TRUNCATE are not granted. One trigger enforces the strictly-after ordering rule on admin-origin inserts. 1.sql carries no roles or grants, so the app role and its grants land as a deploy step or an a-*.sql file alongside the schema, verified per environment.

Code redemption rule. A code mints an attribution when, at registration time, the code exists, is not retired, and the user has no origin = 'signup' attribution. Nothing about the referrer is consulted (D2e). The code resolves to its owning user_id, which is denormalized onto the attribution row alongside the code, so retiring the code orphans neither.

C. Eligibility

At each run the accrual reads each UBO-owned account's live billed rates, resolves tier_index = FeeProgram::tier_index_for_rates(rates) against the schedule in force (NULL means off-ladder), and snapshots the result into the accrual row's detail_json. The whole open period is scored against that state.

There are two predicates, and they are not interchangeable. An attributed account is:

The gap between them is one population: Tier-4 accounts, which raise an earner's level and earn it nothing. Writing this as a single predicate is the likely implementation error and silently restores demotion-by-success.

Neither predicate is the whole answer. Nothing is paid on fee_eligible alone; it is the account-level half. The accrual also applies the per-earner conditions:

Trade-level exclusions inside the fee aggregation are two (D1f): is_final_settlement = false, and a wash filter over same-ubo_user_id-on-both- sides fills, plus the quarterly manual review (O5).

There is no periodic task here and no table. Eligibility is a read the accrual makes for itself. The only scheduled work in this RFC is the accrual (§E).

D. Ladder

partner_30d_volume(d) = Σ over pricing_eligible attributed accounts of
                        trailing_30d_volume(account, over the 30 ET days
                                            ending with ET day d)

-- pricing_eligible, NOT fee_eligible (§C). A Tier-4 account contributes its
-- full volume here while contributing zero fees to §E. An off-ladder account
-- contributes to neither.

level(d) = Bronze  if         v <  $10M    → 30%
           Silver  if  $10M ≤ v <  $100M   → 40%
           Gold    if  v ≥ $100M           → 50%

-- The floor is $0 and every threshold is inclusive: any volume, including
-- zero, clears Bronze, and there is no no-rebate state. An earner with a
-- single $50 day earns 30% of that day's eligible fees.
-- Progressive (D3): day d's fee_eligible fees earn rate(level(d)); the
-- period's rebate is the sum over its ET days.

**A Gold earner with a $0 accrual is a valid state.** An earner whose whole attributed book sits on Tier 4 reaches the top rung on volume and earns 50% of nothing. The statement and the admin accrual list must render it as a real zero, because "$0" and "the job did not compute this earner" have to stay distinguishable.

The trailing sums are plain per-ET-day sums, prefix-summed in the policy core from per-account-per-day ClickHouse aggregates. They are not a generalization of hysteretic_volumes().

Thresholds and rates are constants in the pure policy core, pinned by tests. Changing the ladder is a PR, which is the right cadence for a program whose accrual row snapshots every number that justifies its payable (D8b). One ladder serves both programs.

E. Accrual ledger and job

CREATE TABLE partner_programs.rebate_accruals (
    -- The earner: a user FK, not a registry FK, so a held accrual for a
    -- referrer with no partners row -- or no trading account -- is
    -- representable.
    user_id             CHAR(16) NOT NULL REFERENCES users(id),
    program_kind        TEXT NOT NULL,
    period              TEXT NOT NULL,          -- ET calendar month, 'YYYY-MM'
    -- Trailing-30d pricing_eligible attributed volume at the anchor
    -- (min(now, period_end)); the statement's headline. The per-day ladder
    -- walk that priced each day is in detail_json (D3).
    attributed_volume_usd NUMERIC NOT NULL,
    eligible_fees_usd     NUMERIC NOT NULL,     -- the child arm's base (D1)
    -- The ladder level at the anchor. Informational under the progressive
    -- rate: earlier days may have earned at other rungs. NULL when no ladder
    -- days were walked. An earner with no eligible accounts still gets a row,
    -- so a provisional $0 stays distinguishable from a job that never ran.
    ladder_level          TEXT,
    -- How the CHILD arm was priced (the grandchild arm is always flat, in its
    -- own columns below).
    -- 'ladder': the child amount = Σ over ET days of rate(day) x base(day),
    -- and rebate_rate is NULL because no single period rate exists.
    -- 'override': the flat registry rate replaces the ladder (D2d).
    rate_source           TEXT NOT NULL,
    rebate_rate           NUMERIC,
    -- The payable total: child amount plus grandchild_amount_usd when set.
    rebate_amount_usd     NUMERIC NOT NULL,
    -- The second-tier arm (D2d), set exactly when the earner carries a
    -- registry grandchild_rate: its flat rate, its own fee base (the
    -- fee-eligible grandchild book), and its share of the total. The
    -- per-account child/grandchild split is in detail_json.
    grandchild_rate       NUMERIC,
    grandchild_fees_usd   NUMERIC,
    grandchild_amount_usd NUMERIC,
    -- Coverage. Fills that resolve to no known owner: silent
    -- under-attribution is the failure mode, so it is measured, not assumed 0.
    unjoinable_fill_count BIGINT NOT NULL DEFAULT 0,
    unjoinable_fees_usd   NUMERIC NOT NULL DEFAULT 0,
    -- Per-account eligibility snapshot (tier_index, D4d') and breakdown, plus
    -- the per-day ladder walk, for disputes and the statement.
    detail_json         JSONB NOT NULL,
    computed_at         TIMESTAMPTZ NOT NULL DEFAULT CURRENT_TIMESTAMP,
    approved_at         TIMESTAMPTZ,
    -- An admin's identity, or the 'system:auto-approve' sentinel (D5a). Never
    -- NULL alongside a set approved_at, so the trail always says which path
    -- approved the row.
    approved_by         TEXT,
    -- approval_threshold_usd and its threshold-iff-approved CHECK land with
    -- the auto-approval task in PR 7 as an additive ALTER (D5a).
    CONSTRAINT rebate_accruals_program_kind_valid
        CHECK (program_kind IN ('referral', 'api_broker')),
    CONSTRAINT rebate_accruals_period_valid CHECK (period ~ '^\d{4}-\d{2}$'),
    CONSTRAINT rebate_accruals_ladder_level_valid
        CHECK (ladder_level IS NULL
               OR ladder_level IN ('bronze', 'silver', 'gold')),
    CONSTRAINT rebate_accruals_rate_source_valid
        CHECK (rate_source IN ('ladder', 'override')),
    -- A single flat child rate exactly when the override replaced the ladder.
    CONSTRAINT rebate_accruals_rate_iff_override
        CHECK ((rate_source = 'override') = (rebate_rate IS NOT NULL)),
    CONSTRAINT rebate_accruals_rate_valid
        CHECK (rebate_rate IS NULL OR (rebate_rate > 0 AND rebate_rate <= 1)),
    CONSTRAINT rebate_accruals_volume_nonneg
        CHECK (attributed_volume_usd >= 0),
    CONSTRAINT rebate_accruals_fees_nonneg CHECK (eligible_fees_usd >= 0),
    CONSTRAINT rebate_accruals_amount_nonneg CHECK (rebate_amount_usd >= 0),
    -- The grandchild arm is all-or-nothing, bounded by its own rate and base.
    CONSTRAINT rebate_accruals_grandchild_together
        CHECK ((grandchild_rate IS NULL) = (grandchild_fees_usd IS NULL)
               AND (grandchild_rate IS NULL) = (grandchild_amount_usd IS NULL)),
    CONSTRAINT rebate_accruals_grandchild_rate_valid
        CHECK (grandchild_rate IS NULL
               OR (grandchild_rate > 0 AND grandchild_rate <= 1)),
    CONSTRAINT rebate_accruals_grandchild_fees_nonneg
        CHECK (grandchild_fees_usd IS NULL OR grandchild_fees_usd >= 0),
    CONSTRAINT rebate_accruals_grandchild_amount_within_base
        CHECK (grandchild_amount_usd IS NULL
               OR (grandchild_amount_usd >= 0
                   AND grandchild_amount_usd
                       <= grandchild_rate * grandchild_fees_usd)),
    -- Each arm within its own base. No day's ladder rate exceeds Gold's 50%,
    -- so a ladder child arm never pays more than half its base; an override
    -- child arm is bounded by its own rate. A shared fill-side (D7') only ever
    -- lowers the amount, so the split needs no constraint of its own. The
    -- per-day walk in detail_json is what recomputes the amount exactly.
    CONSTRAINT rebate_accruals_amount_within_base
        CHECK (CASE WHEN rate_source = 'ladder'
                    THEN rebate_amount_usd - COALESCE(grandchild_amount_usd, 0)
                         <= 0.5 * eligible_fees_usd
                    ELSE rebate_amount_usd - COALESCE(grandchild_amount_usd, 0)
                         <= rebate_rate * eligible_fees_usd
               END),
    CONSTRAINT rebate_accruals_approved_together
        CHECK ((approved_at IS NULL) = (approved_by IS NULL)),
    CONSTRAINT rebate_accruals_qa_user_cannot_accrue
        CHECK (user_id <> '000000-0025-06J0'),
    PRIMARY KEY (user_id, program_kind, period)
);

A trigger guards the row: an unapproved row is recomputed in place; an approved row is frozen, and TRUNCATE is refused outright because a statement trigger cannot see which rows are approved. Corrections to an approved period are a forward adjustment in a later period, not an edit. The paid and voided lifecycle and clawback live in the payouts RFC; this RFC's terminal state is approved.

The job is rs/accrual-engine/src/rebates.rs, mirroring fee.rs. Each run recomputes the open period and the previous period, oldest first:

  1. Anchor the pass at min(now, period_end). The anchor pins ownership and the ET-day list (owner_at(user, anchor)). A closed period's anchor is its end, and the strictly-after ordering rule (D2a) means owner_at(user, period_end) cannot change retroactively. Eligibility and registry rates are read live each run, which is why detail_json snapshots them, so an unapproved closed period may still rescore after a fee-rate update.

  2. Load current ownership from the attribution ledger; resolve each attributed user to its accounts through trading_accounts.ubo_user_id; read each account's live billed rates and resolve the two predicates (§C). Accounts whose rates match no current rung are excluded.

  3. Scan ClickHouse trades once for all earners' accounts over [period_start − 30 ET days, anchor), grouped per account per ET day: per-side attributed fees floored per fill-side inline (SUM(GREATEST(fee, 0)), D1b), with the floored-away negative tail accumulated separately into detail_json; notional volume scaled per symbol, refusing the run on a missing multiplier; zero-fee fill counts (D1d); final-settlement and wash fills excluded in-query. One query serves both predicates: fees fold over fee_eligible accounts, ladder volume over pricing_eligible ones.

  4. Resolve ownership per fill-side (D7′): the referral owner from the attribution ledger, and the broker owner from the order's Broker ID — stubbed here, so every fill-side resolves referral-only until the broker arm lands. Mark each fill-side sole or shared, and drop each earner's own-UBO accounts.

  5. Fold per earner in the pure policy core, arm by arm. The child arm is the progressive ladder walk (D3) — prefix-sum the per-day pricing-eligible volume, resolve each period day's rung, multiply by that day's fee base — or, for an earner carrying an override_rate, that flat rate times the period base of its child accounts. The grandchild arm, for an earner carrying a grandchild_rate, is that flat rate times the period base of its grandchild accounts.

    Each day's fee base splits in two: the sole base earns the day's own rate, and the shared base earns 0.5 * min(own rate, broker rate) (D7′), with the same halving applied to the grandchild arm. Ladder volume does not split — it folds over the whole base. The two bases, the broker rate that set each day's min, and the resulting per-day amounts are written into detail_json, so a shared period recomputes exactly from the stored row.

  6. Upsert one row per (earner user_id, 'referral', period), idempotent on the primary key, never touching an approved row. Both arms land on that one row.

  7. Emit metrics and refuse the run on the §H in-run invariants.

Posture, copied from rs/accrual-engine/src/fee.rs: a pure function of warehouse history, so ticks are idempotent; ensure! on missing_multipliers and refuse the whole run rather than mis-bill. compute_referral_rebates_once() is pub so integration tests drive it directly, as refresh_fee_rates_once already is. The task is AX-only: watch() refuses it on the aiex edition the same way it refuses the fee-rates task.

Cost. trades is ORDER BY (symbol, timestamp_ns, trade_id) with no account in the sort key, so an account-set scan reads whole month partitions. The daily scan spans the period plus its 30-ET-day ladder lookback, about two partitions per pass. If that grows, stop recomputing closed periods once fully approved. Confirm the a-4097 account skip-indexes exist in production; they were a consumed migration file.

The ClickHouse read sits behind one function (D4c), so the accrual takes no dependency on the store's shape. account_volume_1d (A-4334) would serve steps 3 and 5 more cheaply once its write path exists, and adopting it should change that function alone. Two things to check at that point: the rollup recomputes from trades FINAL while the current live aggregates read without it, so the two disagree on a re-inserted trade_id; and the rollup double-counts per-account volume (maker + taker) to match query_account_volumes, which must be reconciled with what §D's per-day trailing volume does.

F. Admin surfaces and statements

rs/api-gateway/src/admin_partner_routes.rs, nested at /admin/partners, following admin_mmlp_routes.rs and admin_account_limits_routes.rs, gated by ax_web_auth::authorize_admin (rs/sdk-internal/web-auth/src/lib.rs:196-220). Exceptions, revocations and registry edits are admin-only.

Route Purpose
GET/POST /partners, GET/PUT /partners/{user_id} Registry CRUD: status, payout_account_id, override_rate, grandchild_rate. Rows are keyed on user_id; there is no surrogate partner id
GET/POST/PATCH /referral-codes?user_id= Issue and soft-retire (retired_at, D2c)
GET /referral-attributions?user_id=, POST /referral-attributions The exception path; approved_by and reason enforced by CHECK
GET /accruals?user_id=, POST /accruals/approve The manual path, keyed user_id + program_kind + period, for rows at or above the threshold and anything escalated (D5a)
GET /accruals/statement CSV streamed from detail_json

The accrual list must separate the pending-manual queue from auto-approved rows. Auto-approved rows are not review work.

A self-service surface also exists at /referrals (rs/api-gateway/src/referral_routes.rs, A-4725): any user can create their own code — server-generated, never chosen — which auto-provisions a pending partners row, and can list the users attributed to them, masked. Changing or retiring a code is a support action on the admin surface.

Require Clerk step-up on approving an accrual and on creating a referral exception, through the existing POST /admin/verify-identity pattern (admin_routes.rs:2733-2787, MultiFactor within 1 minute).

Statements are CSV, not PDF. No runtime image carries a PDF renderer: the typst CLI left with MMLPv1 under A-4812. CSV needs no renderer and no S3, and is what Finance reconciles against. Until a payer credits its first referral accrual, the approved accrual plus its CSV statement is the settlement artifact, and the statement stays the reconciliation input afterward.

The statement reconciles to net fee intake for the attributed accounts, because every eligible fee on the current ladder is positive (D1c). If that stops being true, §H check 1 fires before a statement goes out.

Two things the statement says out loud, because Finance settles by hand against it:

GUI in gui/apps/admin/src/pages/partners/, api functions in gui/packages/admin/src/api/admin.ts, react-query hooks following useMmRequirements.ts. Do not add /partners to AIEX_SECTIONS in gui/apps/admin/src/edition.ts: the programs are AX-only, and omission is how a section stays hidden.

G. Anti-abuse

  1. Accrual computes for every attributed referrer regardless of status (D2e). partners.status gates payout, and approval gates everything — an accrual under investigation is simply not approved.
  2. Structural self-dealing exclusion: accounts whose ubo_user_id is the earner's own user never contribute.
  3. First-touch-wins partial unique index: one signup owner per user, forever.
  4. The exception path requires approved_by and reason, enforced by CHECK, with Clerk step-up.
  5. The shared-fill-side bound (D7′), asserted per arm rather than made unreachable by structure: once the broker arm exists, a fill-side owned by both programs pays out min(r, b) in total and no more. §H check 2 is what enforces it, which is why that check is hard.
  6. Same-owner-both-sides wash filter.
  7. Coverage metrics, with an alert when the unjoinable share crosses a threshold.
  8. The approval gate, manual at or above the materiality threshold and automatic below it, with escalation overriding the amount (D5a). This is the one control that is not a hard stop on the sub-threshold path, which is why item 7 and the §H invariants carry the weight there.

H. Observability and testing

Metrics, per run: earners processed, total accrued, unjoinable share, and rebate as a fraction of fee intake. A rebate approaching the fee account's period intake means a misconfiguration; alert through the existing recon incident.io path.

Recon checks:

  1. Monitored, per period: Σ rebates accrued ≤ Σ net fees collected. This holds today (D1c) and fires if the assumption behind D1c breaks — a new ladder rung going maker-negative below Tier 4. It is an alert rather than an assertion in the accrual engine, because an override promotion may legitimately pay out more than a period's fee intake. It lives in the referral-rebates-within-fees recon check and is snoozed for a promotion's duration. Outside a promotion a breach means a bug.

  2. Hard, per accrual, per arm: eligible_fees_usd ≥ 0; the child amount (rebate_amount_usd − grandchild_amount_usd) is ≤ 0.5 × eligible_fees_usd on a ladder row or ≤ rebate_rate × eligible_fees_usd on an override row; and the grandchild amount is ≤ grandchild_rate × grandchild_fees_usd. The exact recompute is the per-day walk in detail_json, which check 4 exercises.

    Hard, per shared fill-side (D7′): the two owners' amounts on a shared fill-side sum to exactly min(r, b) × fee, and neither exceeds half of it. This is the bound the withdrawn waterfall used to make structurally unreachable, so it is an assertion that refuses the run, not an alert. It is inert until the broker arm lands, because nothing is shared before then.

  3. Zero-fee coverage (D1d): count fills on eligible attributed accounts with non-zero notional and a zero fee, and refuse the period above a threshold. Where a trade engine exports fee_miss_count, assert the two measures agree.

  4. Recompute and diff: rederive a closed period from the tape and compare it to the stored accrual. The accrual is a pure function of its inputs, so a divergence means an input moved underneath it — a fee-rate update, a retroactive trade amendment, or a schedule change.

  5. Fee-schedule tripwire: fail the accrual run if any rung in the fee-eligible range (tier_index < 3, §C) has a negative maker_fee. That is the single upstream change that invalidates check 1, and it is cheaper to catch when the schedule is published than after a partner is paid. It also catches a ladder that grows a fifth rung, which would shift what "Tier 4" refers to. The bound moves with the §C predicate: at < 2 it stops guarding rung 2, which is both eligible and adjacent to the negative rung.

Tests. Ladder resolution at every boundary, including 0andtheinclusiveBronzefloor.ATier − 4attributedaccountcontributingladdervolumebutzerofees, andtheGold − with−0-accrual case it produces. An off-ladder account contributing to neither. Forward-only retirement, where a retired code keeps accruing existing owners. A repeated /login/clerk not creating a second attribution. An unknown code committing the user with no attribution and not failing the login. A missing contract multiplier refusing the whole run. A zero-fee share above the D1d bound refusing the period, the bound itself accruing, and accounts outside the fee base not counting toward the measure. A mid-period ladder promotion earning the lower rate on earlier days and the higher on later ones, with the total equal to the per-day sum. A demotion to Bronze at the anchor leaving earlier days' earnings intact, including the legal row shape ladder_level NULL with rebate_amount_usd > 0. A mid-period rate change rescoring the whole open period on the next run. A shared fill-side paying min(r, b) in total across its two owners at each ordering of the two rates (referral higher, broker higher, equal), the same account's volume lifting both owners' ladder rungs in full, the grandchild arm halving with its child, and a fill placed before the broker arm existed staying sole-owner when the run is replayed afterwards. An account on the Tier-4 rung contributing neither eligible fees nor ladder volume, including one that crosses into Tier 4 mid-period. Idempotent recompute of an unapproved period. A two-program earner at $999 + $999 requiring manual approval on both rows, an auto-approval refusing to fire on an open period, and each escalation trigger forcing manual review on a sub-threshold row. The freeze trigger rejecting an approved-row edit.

Inline insta snapshots per CLAUDE.md; integration tests on testcontainers through rs/test-utils/src/seed_data.rs and the TestClickhouseDatabase harness (rs/test-utils/src/clickhouse.rs:64).

Deliverables

PR-level sequencing across the four partner-program RFCs is in SoW: Partner Programs. This RFC's share is PRs 1–7.

Open questions

Questions that gate delivery are in Open questions that gate delivery. The rest gate no PR.

Answered


Benchmark and commercial framing (Binance CaaS, Bybit, OKX) live in the Commercial team's program document; this RFC is the systems design only. Final program terms require Finance, Product, Legal, Compliance and Risk approval.