SoW: Partner Programs

Author: Joey McCarey (with Claude)

RFCs: referral-program (Program 1), api-broker-program (Program 2), commercial-program-accruals (the producer that turns Program 1 and 2 policy into accrual rows), program-payouts (one candidate payer, owned outside this workstream), partner-rebate-payouts (the requirements the producer holds a payer to), omnibus-broker-program (Program 3, scoping only).

Tracking: A-4419 (master) → A-4420 Referral · A-4421 API Broker · A-4422 Payouts · A-4423 Omnibus. Each PR below has a ticket under its track: PRs 1–7 → A-4424…A-4430; PRs 8–11 → A-4431…A-4434 and PR 14 → A-4435; PR 13′ → A-4437. A-4436 closes as superseded by A-4492's module work. PR 12′ needs a ticket under A-4422 — A-4505 covers the same adapter but sits under A-4492, so it moves to us or closes as a duplicate. Do not leave the adapter owned twice.

Concept

Three commercial partner-economics programs on staggered timelines. This SoW sequences them into independently landable PRs and names what gates what.

Program 1 (Referral) leads because it builds the shared machinery. Program 2 (API Broker) is needed live for a Q4 marketing activation, with Hummingbot as launch partner. Program 3 (Omnibus) is blocked on Master Accounts and Subaccounts.

The workstream is bracketed by one shared platform, and half of it is owned elsewhere. The producer side — compute what is owed — is ours (commercial-program-accruals). The payer side is not: it is whichever exactly-once payer ships, integrated against that RFC's D9 acceptance criteria. What stays exclusively ours is what a dollar of referred flow is worth, and who it belongs to.

Programs 1 and 2 terminate at an approved accrual plus a CSV statement. That pair is the settlement artifact until Track C lands.

Baseline

AX has none of this: no partner entity, no referral field, no Broker ID, no rebate ledger, no per-partner aggregation. The one mechanism that ever stamped a code on a user (users.invite_code) was deleted at the Clerk cutover, and its write path lost attribution on failure.

Reused rather than rebuilt: the dynamic fee engine (rs/accrual-engine/src/fee.rs) and its trailing-30d ClickHouse volume query (query_account_volumes_windowed, schema.rs:1191-1258); the fee_schedules versioned-immutable-config pattern (db/postgres/1.sql:640-697); the MMLP subsystem as the structural analogue for a partner registry plus daily evaluation plus statements plus admin GUI; AX_FEE_ACCOUNT_ID and the POST /deposit ledger path.

Verified absent: trading_accounts has no fee_tier, fee_schedule_id or fee_mode; api_keys has no owner or label column; no ClickHouse table carries a broker identifier.

Answers needed, and what they hold up

Every question that gated PR 6 is answered. What remains gates Track B and Track C. Owners are named because most are not engineering calls.

Question Owner Holds up
Who opens the upstream Hummingbot PR — an Architect engineer, petioptrv, or a bounty? Eng lead + BD PR 8, and Q4 go-live
Must a human authorize any sweep at all, regardless of size? If yes, the $1k auto-approval threshold drops to zero. No structural change, so this gates policy, not build Legal + Compliance PR 7 threshold value
Confirm the progressive-daily ladder rate (referral O12): a promotion applies forward day by day rather than repricing the whole month, and a day's own volume counts toward its rung. The fold is one function either way Commercial First partner statement

Three questions are now shared payout-module gates (A-4508): currency (USDfiat vs USDC), the hold-window constant, and tax and withholding treatment. They are answered once for every producer and gate PR 12′ rather than anything in Track A or B. Same owners — Finance, Legal, Compliance — so chase them once.

The sweep-authorization question above is not one of them. It sets referral's auto-approval threshold, which is producer-side approval policy gating PR 7, an admin surface in Track A. Folding it into the payout module's gate list would leave it dormant on another track's clock while silently blocking ours.

Two more items are operational rather than open:

Non-blocking questions live in each RFC's Open questions section.

Governing principle

Over-engineering is the main risk to this workstream, not under-building it (Commercial, 2026-07-30). AX is under pressure from existing market makers to grow the participant base, and the value of these programs is being live. Where this SoW names a deferral — second-level machinery, omnibus, capped campaigns, partner-facing surfaces — the deferral is the decision, not a placeholder to revisit before launch.

Locked decisions

Settled across the four RFCs; full records in their Decisions sections.

Sequencing and attribution

The rebate base

Two-level earning

Approval and payout

Scope and schema

Deliverables

Track A — Referral Program

PR 1 — partner_programs schema

The ledger third merged as #3703: referral_codes, the append-only referral_attributions user → user ledger, and the partners payout registry keyed on user_id.

The remainder rides the accrual PR (A-4429) rather than a standalone schema PR, because nothing in it is read by anything but the accrual. It carries:

Cut or deferred (each recorded in the referral RFC): enrollments — one program, and being a partner is a registry row; rebate_schedules — the ladder is a constant in the pure core, pinned by tests, and the table returns additively the first time Commercial wants a rate change faster than a deploy; excluded_accounts — self-dealing is structural through ubo_user_id, and the judgment-call valve waits for its first real case; rebate_accruals.approval_threshold_usd — lands with PR 7's auto-approval task as an additive ALTER; account_fee_rate_changes — eligibility is read live and snapshotted (referral D4).

Immutability on referral_attributions is enforced by grants (the app role holds SELECT, INSERT only) plus one strictly-after ordering trigger, not by freeze triggers. The app role and its grants land as a deploy step or an a-*.sql file; 1.sql carries no roles today.

PR 1 does not declare or touch the accrual_engine schema. That schema belongs to the liquidity program (#3470), and a duplicate declaration is something git merges cleanly and Atlas rejects.

PR 2 — Cut

No separate account-pricing deliverable is part of the referral track.

PR 3 — Cut

Do not build an account_fee_rate_changes log. tier_index is resolved live at each accrual run and snapshotted onto the accrual row (referral D4, D4d′), so there is no log, no fee-engine writer, no self-seeding, and no grants deploy step. The cost — a mid-period re-tier rescores the whole open period at the state the daily run sees — is one provisional period, bounded by daily recompute and by approval freezing the row.

The two predicates the log fed live in PR 6 (referral §C): pricing_eligible requires a non-NULL tier_index; fee_eligible adds tier_index < 3, which is where the Tier 4 carve-out is enforced. This also removes the rollout wait for a period of log history before the first accrual.

PR 4 — Referral capture

referral_code on the /login/clerk request (rs/sdk/src/protocol/api_gateway.rs:114-118), threaded through clerk_login → resolve_or_materialize_clerk_user → provision_user, writing the attribution row in the users transaction.

Pass it only down the miss branch (auth.rs:180); the WHERE origin = 'signup' partial unique index is the backstop. An unknown or malformed code is a warn and a commit without attribution — never a failed registration — and the resolve runs against a savepoint so a database error rolls back to it rather than poisoning the users transaction. Resolution asks only whether the code exists.

Needs PR 1.

PR 5 — GUI signup threading

?ref= from URL and storage through ClerkSignupPage → exchangeClerkSession → loginWithClerkToken. Capture happens once per app load in AuthProvider, not on the signup screen, so a referral link pointing at any app URL is persisted: the parameter is gone from the URL by the time the visitor reaches signup.

The captured code is shown in the signup form, pre-filled and editable, on all four signup surfaces (AX web, aiex, native, california). Edits write through to storage so a correction survives the navigations before the exchange.

Pairs with PR 4.

Companion, separate repo (architect-xyz/landing-page): capture ?ref= on the marketing site, persist it, and carry it to the app on the shared apex domain. Needed only for links that point at the marketing site first, so the two ship in either order.

PR 6 — Accrual engine

All five commercial questions that gated this are answered. What remains is engineering.

Build it as a pure, I/O-free core plus a host task, the shape MMLP shipped:

The grandchild arm is in scope and is deliberately small. It runs only for an earner carrying a grandchild_rate and pays that flat rate on the grandchild book, funded from AX's retained cut. The direct book is priced separately, by the ladder or by a flat override_rate where negotiated. It writes no extra row: both arms land on that earner's single rebate_accruals row, the grandchild arm in its own columns, with the per-account split in detail_json. Keep it that way: no per-user rate tiers, no depth parameter, no stored upline, no level column.

Two predicates, not one (referral §C, D4b). The fee base sums over fee_eligible accounts; the ladder sums volume over the wider pricing_eligible set, which includes Tier-4 accounts that earn nothing. The schema already has both columns — eligible_fees_usd and attributed_volume_usd — so this needs no DDL. Collapsing them into one predicate is the likely bug and restores demotion-by-success.

Build a per-account-per-ET-day sibling on ChTradeRow::query_account_tier_metrics's scan. Factor its maker/taker ARRAY JOIN fan-out and final-settlement filter so both queries share one trades FINAL scan; group the new one by account and ET day, floor each fill-side at zero, count zero-fee fills, and exclude wash pairs. Keep the (values, …, missing_multipliers) contract: ensure! on missing multipliers and refuse the whole run.

Recover assert_ladder_supports_carve_out — the maker-negative and rung-count tripwire — from #3382's parent commit rather than rewriting it, and move its bound to tier_index < 3. At < 2 it no longer guards rung 2, which is both fee-eligible and adjacent to the negative rung.

Also count zero-fee fills with non-zero notional on eligible accounts and refuse above a threshold (referral D1d): both trade engines book a zero fee when an account is missing from account_fee_rates, which is indistinguishable on the tape from a genuinely free fill.

Daily cadence over a monthly period. Make compute_referral_rebates_once() pub so integration tests drive it directly. Carries its own schema (the PR 1 remainder); needs nothing else in-track beyond the merged ledger.

The payee stays optional and the hold stays ours (commercial-program-accruals D3). A partner with no trading account accrues, is owed, and is held producer-side: not enqueued, not forfeited, counted. PR 6 owns that decision directly.

PR 7 — Admin surfaces, statements, GUI

rs/api-gateway/src/admin_partner_routes.rs at /admin/partners, following admin_mmlp_routes.rs: partner registry and code CRUD, the attribution exception path, the accrual list and approve endpoint, and the CSV statement from detail_json. Clerk step-up on approve and on exceptions.

Carries the auto-approval task (referral D5a): runs after period close plus a settling delay, approves earners whose cross-program period total is under the configured threshold, stamps 'system:auto-approve' and the threshold in force, and escalates anything hitting an anomaly trigger to the manual queue. This PR adds rebate_accruals.approval_threshold_usd and its iff-approved CHECK as an additive ALTER, since it is the first thing that reads them.

The accrual list must separate pending-manual from auto-approved. The latter is not review work.

Admin GUI under gui/apps/admin/src/pages/partners/.

Two surfaces are out of scope: rebate-schedule publish (the ladder is a code constant) and exclusion CRUD (excluded_accounts is deferred).

Needs PRs 1 and 6.

Track B — API Broker / OMS Program

PR 8 — Upstream Hummingbot connector (separate repo, open FIRST)

BROKER_ID = "HBOT" in constants, "bid": CONSTANTS.BROKER_ID in _place_order. Five lines.

Runs on Hummingbot's release cadence, not ours, and is the Q4 critical path. Open it as soon as the field name is frozen; it depends on no AX-side PR landing. Leave client_order_id_prefix raising NotImplementedError, which is correct for AX's numeric cid.

PR 9 — ClickHouse broker_id column (operator step)

broker_id LowCardinality(Nullable(String)) on order_log and historical_orders, in init.sql and in a one-off db/clickhouse/a-<ID>-broker-id.sql.

Drop, re-add and re-materialize proj_ts_user and proj_ts_account. Their SELECT * column lists are frozen at creation, so a plain ADD COLUMN leaves broker_id reading empty on every projection-served query. These are heavy background mutations: use a controlled window and watch system.mutations.is_done.

Apply before PR 10's binary. A struct field with no column errors the insert, while a column with no field is safe.

Also adds a skip index on order_log.order_id.

PR 10 — BrokerId type and order-path plumbing

rs/sdk/src/types/broker_id.rs newtype with #[serde(try_from = "String")] (trim, uppercase, ^[A-Z0-9]{3,10}$); PlaceOrderRequest.broker_id as "bid"; mirror tag through into_pending_order, RedisPendingOrderMeta, restart recovery, both ClickHouse row constructors, cancel-replace, and OrderDetails.

Verify the WebSocket path rejects a malformed bid as an order reject, not a disconnect. Adds the missing ClickHouse schema-drift tests for ChOrderLog and ChHistoricalOrder.

Needs PR 9 applied.

PR 11 — Broker attribution, accrual arm, admin surfaces

partner_programs.broker_ids table; ChTradeRow::query_broker_volumes_windowed (FINAL on trades, argMax on the order side, COALESCE across both order tables, unjoinable-fill accounting); the broker ownership arm and the shared fill-side split (referral D7′), including its hard per-shared-fill-side invariant; broker-id CRUD on /admin/partners.

Needs PRs 1, 6 and 10.

PR 14 — api_keys.partner_id (independent)

Turns a self-asserted Broker ID into a claim anchored to the key that placed the order, and makes "one rebate-bearing primary OMS" enforceable in data. Needs a key-labeling UX, which is why it is not blocking. Independently valuable.

Track C — Payouts

The ledger, the settlement step, the sweep, the pacing work and the claim-window state machine are all built once, generically, by the payout module (A-4492). See program-payouts. What remains here is the producer side: telling the payer what is owed, and checking afterward that it paid the right amount.

Track C is payer-neutral. Two exactly-once cash paths are being built concurrently by other tracks. This SoW does not pick between them; it integrates with whichever ships, against the nine properties in commercial-program-accruals D9. That is affordable because Tracks A and B terminate at an approved accrual plus a CSV statement.

Checked against the program-payout schema as it stands, everything PR 12′ depends on is present: program_kind accepts 'referral', funding_account_id and currency are columns, enqueue_ceiling is nullable, and hold_policy accepts 'hold_indefinitely'. requests.account_id stays NOT NULL, so the held payee-less accrual stays producer-side as designed. The module names its tables payout_engine.programs / requests / attempts rather than the payout_program_config / program_payout_requests / payout_attempts this Track still calls them; the contract is the same. Two knobs this SoW once treated as open questions became config columns: currency ('USDfiat' | 'USDC') and the claim window (hold_policy plus hold_claim_window_periods), so Finance and Compliance set a value instead of blocking a schema.

Not yet built: the sweep itself. PR 6's host task makes the enqueue call directly.

PR 12′ — Referral payout adapter

One payout-program config row for program_kind = 'referral': funding_account_id = AX_FEE_ACCOUNT_ID, monthly at period close, hold_policy = 'hold_indefinitely', enqueue_ceiling null. Confirmed with the module's owner (2026-08-05): enqueue_ceiling is a module-side backstop, not the approval mechanism, and leaving it null makes our gate authoritative.

The enqueue is ours to call, not ours to design. PR 6's host task makes the call against the payer's contract. This PR is the config row plus the producer knowledge the enqueue needs:

Needs PRs 1 and 7, and the module's schema and sweep landed. Independently deployable behind the module's enabled flag, which defaults false.

PR 13′ — Referral-side reconciliation and runbook

The checks the module structurally cannot perform, because it has no view of what was collected:

Reads rebate_accruals and fee intake, so it does not depend on the module and can land any time after PR 6. Needs PR 6.

Track D — Omnibus

No PRs. Blocked on Account families and on owner-level fee pooling, which the dynamic-fee SoW deferred. See omnibus-broker-program.

Dependency graph

#3703 ledger (merged) ──┬── PR 4 ── PR 5 (+ landing)   capture; landed as #3704
                        ├── PR 6 ──┬── PR 7 ── PR 12′ ◄── A-4492 module (external)
                        │          └── PR 13′  (recon; no module dependency)
                        └── PR 11 ◄── PR 10 ◄── PR 9

PR 6 carries the PR 1 remainder and the PR 2 endpoint. PR 3 is cut.

PR 8 (Hummingbot, external)             no AX dependency; open first
PR 14                                   independent

Independently deployable: 6, 8, 14. Everything else has the prerequisite named in its section.

One PR is coupled to another team's clock. PR 12′ is the tail: it cannot land before the payout module (#3609) does. PR 6 is standalone and waits on nothing outside this track.

Rollout gates

  1. Schema merged — the ledger third landed with #3703; the remainder lands with the engine that reads it (PR 6).
  2. Capture live end-to-end (PRs 4–5 plus the landing page). Verify a real signup through a real ?ref= link produces exactly one origin = 'signup' row, and that a repeat login produces none.
  3. Dry-run accrual (PR 6). Compute a closed period, reconcile eligible_fees_usd against net fee intake for the attributed accounts over the same window, and check the unjoinable share is near zero before any number reaches a partner. The two should tie, because every eligible fee on the current ladder is positive (referral D1c). Investigate any gap.
  4. First manual settlement against the CSV statement (PR 7), before Track C automates it. Finance signs off on the statement format while the numbers are small.
  5. Broker migration rehearsed on a copy of demo (PR 9). After the projection drop and re-add, confirm a query the optimizer serves from proj_ts_account returns real broker_id values, not empties. This is the failure mode most likely to silently zero the money column.
  6. Enqueue replay-tested (PR 12′) before the first real credit. The module owns exactly-once crediting and tests it; what is ours to prove is that the enqueue is correct and idempotent. Re-running approval for a closed period produces no second request row; a partner earning under both referral and api_broker in one period gets two distinct rows, not one; and an account-less partner produces no row at all while staying approved and payable later. The first two are silent failure modes.

Deliberately deferred