RFC: API Broker / OMS Program

Date: 2026-07-30

Status: Draft

Related: Referral Program — builds the partner registry, rebate ladder, eligibility snapshots and accrual ledger this RFC reuses; must land first (see Background). Partner Rebate Payouts — settlement, shared and unchanged; superseded for the cash path by Generic Program Payout Module. This program is a second adapter over the same module, registered as program_kind = 'api_broker'. Note that a partner can earn under both programs in one period, so the two adapters must write distinct idempotency keys — see SoW PR 12′. Omnibus Broker Program — the adjacent program this one is often confused with; that one shares none of this infrastructure.

Author: Joey McCarey (with Claude)

Answers needed before this ships

# Question Owner Blocks
O4 Who opens the upstream Hummingbot PR — an Architect engineer, petioptrv, or a paid bounty? Eng lead + BD Q4 go-live. Their release cadence, not ours — SoW PR 8
O1 May one AX account connect to several OMS providers for technology while naming a single rebate-bearing primary? Commercial + Legal Policy-only until api_keys.partner_id (§E); enforceable in data after
O2 Does Hummingbot's existing flow earn anything for pre-broker_id periods? Untagged history is attributable only per-account, via admin override. Commercial Launch terms, not the build

Also inherited from the Referral Program: the ladder boundary, block-trade/liquidation treatment, negative-fee handling, the missing refund mechanism, and the split rule for jointly-owned flow (D7′) are the same questions with the same answers, and are tracked there. Eligible fee components are settled — the base is the fee on the attributed side of each fill (referral RFC D1a), which is the split this RFC's §D already implements.

The one question that is not a question: B1 (a new broker_id field rather than tag) must be frozen before PR 8 is opened, because the upstream connector hard-codes the field name and re-opening it costs a Hummingbot release cycle.

Background

Program 2 of the commercial partner framework: a fee share on routed API order flow, attributed at the transaction level via an approved Broker ID that the partner stamps on each order it sends. The user opens a direct AX account, authorizes a partner, and the partner's software tags the orders it routes.

Hummingbot is the launch partner, gated on a Q4 marketing activation.

Everything commercial about this program — the Bronze/Silver/Gold ladder, the eligible-fee base, the exclusions — is identical to the Referral Program. What differs is the attribution source, and that difference is where all the work is. The one-owner rule is gone: referral and broker both earn on flow they both own, splitting the lower of their two rates (referral RFC D7′).

Why this lands second

Referral owns the machinery this program reuses — the partner registry, the ladder, the eligibility predicates, the accrual ledger and the approval path — so broker-first would mean building all of it here and re-homing it later.

The split rule (referral RFC D7′) is what makes that ordering safe rather than merely convenient. A fill-side owned by both programs pays each owner half of the lower of the two rates, and a fill is shared only if it carried a Broker ID when it was placed. No fill carries one until this RFC ships, so every referral accrual computed before then is sole-owner forever: landing broker afterwards reprices nothing already shown, and nothing needs excluding from a referral accrual that predates the first Broker ID. What does change on the day this lands is the rate on newly shared flow, forward only — a referral partner whose client then routes through Hummingbot sees its rate on that flow drop by at least half. That is a partner-communications item for BD, not a clawback.

What exists today

Decisions

Design

A. Schema

One table on top of the referral RFC's registry. (Re-keyed 2026-08-21 with the partner_programs restructure, referral RFC §B: the registry's key is the partner's backing user_id; the surrogate UUID is gone.)

CREATE TABLE partner_programs.broker_ids (
    broker_id      TEXT PRIMARY KEY,   -- the literal on-wire value
    user_id        CHAR(16) NOT NULL REFERENCES partner_programs.partners(user_id),
    status         TEXT NOT NULL DEFAULT 'active',
    effective_from TIMESTAMPTZ NOT NULL DEFAULT CURRENT_TIMESTAMP,
    effective_to   TIMESTAMPTZ,
    CONSTRAINT broker_ids_format_valid CHECK (broker_id ~ '^[A-Z0-9]{3,10}$'),
    CONSTRAINT broker_ids_status_valid
        CHECK (status IN ('active', 'suspended', 'revoked'))
);
CREATE INDEX broker_ids_user_id_idx
    ON partner_programs.broker_ids (user_id);

Uppercase-only by construction, matching B2, so hbot and HBOT cannot split attribution. partners.enrollments already carries api_broker in its CHECK, so nothing in the referral schema changes.

B. The ClickHouse migration — and its landmine

Add broker_id LowCardinality(Nullable(String)) to order_log and historical_orders, in db/clickhouse/init.sql (so fresh environments and the test harness match) and in a one-off migration file db/clickhouse/a-<LINEAR-ID>-broker-id.sql for existing environments, following the house style of a-2946-account-id-backfill.sql: heavy comment header, IF NOT EXISTS, SETTINGS mutations_sync = 2, SELECT throwIf(...) pre-flight and verification guards, deleted from the repo once consumed (precedent: e7b071e0e "Delete consumed migrations").

historical_orders carries two SELECT * projections — proj_ts_user and proj_ts_account (db/clickhouse/init.sql:49-61) — and a projection's column list is frozen at creation. A plain ADD COLUMN, which is all Atlas emits, leaves broker_id reading as empty on every projection-served query. Neither MATERIALIZE COLUMN nor MATERIALIZE PROJECTION fixes it. Both projections must be dropped, re-added so SELECT * re-expands, then re-materialized. The unmerged branch loc/a-3965-is-liquidation-column hit this exact trap and documents the fix as verified.

These are heavy background mutations: run in a controlled window, watch system.mutations.is_done, and expect time-range reads to fall back to a correct-but-slower base-table scan while a projection is dropped.

Two further preconditions:

Also add a skip index on order_log.order_id — it has none today, and the accrual join would otherwise be a full scan. Precedent: a-3522-order-id-skip-indexes.sql.

Close a schema-drift gap while here. There is exactly one ClickHouse schema-drift test in the repo (rs/sdk-internal/clickhouse/tests/test_clickhouse_liquidation_log.rs:26-66, diffing a struct's serialized field names against system.columns). There is no equivalent for ChOrderLog, ChHistoricalOrder or ChTradeRow, and inserts use FORMAT NATIVE with no explicit column list, so drift fails at engine runtime. Copy that test for the two order tables in the same PR — this is precisely the class of bug that silently zeroes a money-bearing column.

C. Plumbing

Add to PlaceOrderRequest (rs/sdk/src/protocol/order_gateway.rs:165-217):

/// Optional approved Broker ID for partner-routed order flow.
#[serde(rename = "bid", skip_serializing_if = "Option::is_none")]
pub broker_id: Option<BrokerId>,

Then mirror tag exactly, everywhere it already flows: into_pending_order (order_gateway.rs:219-246) → Order.broker_id; RedisPendingOrderMeta (rs/sdk-internal/src/redis/redis_values.rs:22-27) and restart recovery (rs/order-gateway/src/lib.rs:774-853); ChOrderLog::from_pending_order and ChHistoricalOrder::from_order (schema.rs:665, :163), leaving the other ChOrderLog constructors at None as they treat tag; cancel-replace carry-forward (rest_service.rs:1290); the OrderDetails read path (order_gateway.rs:692). The Bitnomial gateway hardcodes broker_id: None, as it does for tag.

Verify before merging: confirm the WS path (rs/order-gateway/src/ws_service.rs:719 → handle_place_order:954+) surfaces a deserialization failure as a proper order reject rather than dropping the connection. If it does not, fix that here — otherwise a malformed bid becomes a disconnect.

D. Accrual

A new ChTradeRow::query_broker_volumes_windowed, mirroring query_account_volumes_windowed (schema.rs:1191-1258) in shape: one scan over the window union, per-window sumIf, per-symbol contract-multiplier scaling, and the same (values, missing_multipliers) return so callers can refuse rather than mis-bill.

WITH orders AS (
    SELECT order_id, argMax(broker_id, event_timestamp_ns) AS broker_id
    FROM order_log WHERE broker_id IS NOT NULL GROUP BY order_id
)
-- maker leg: trades.maker_order_id -> orders, attribute trades.maker_fee
-- taker leg: trades.taker_order_id -> orders, attribute trades.taker_fee
FROM trades FINAL ... SETTINGS do_not_merge_across_partitions_select_final = 1

Non-negotiables:

The broker arm the referral RFC left stubbed. Ownership is resolved from both sources independently, then combined (referral RFC D7′):

for each eligible fill-side, on ET day d:
    ref    = referral partner, if the account's ubo_user_id
             has a current referral_attribution
    broker = API broker partner, if broker_id is a registered,
             active Broker ID                                   <-- this RFC

    ref only     -> ref    earns  r(d) * fee
    broker only  -> broker earns  b(d) * fee
    both         -> ref    earns  0.5 * min(r(d), b(d)) * fee
                    broker earns  0.5 * min(r(d), b(d)) * fee
    neither      -> AX direct, no accrual

b(d) is the broker's own progressive-daily ladder rung, resolved from the broker's own 30-day attributed volume before the min is taken. A shared account's volume counts in full toward both owners' ladder totals — it is not halved (referral RFC D7′).

Everything downstream — eligibility snapshots, ladder, rebate_accruals (with program_kind = 'api_broker'), approval, statements, payout — is unchanged. The one addition this RFC owes the shared path is the hard per-shared-fill-side invariant (referral RFC §H check 2), which is inert until this program ships and is the only thing standing where the withdrawn waterfall used to stand.

Anti-abuse additions beyond the referral RFC's set: registry gating (B5); automatic exclusion of accounts whose ubo_user_id is the partner's own user; and volume-anomaly detection (sudden Broker ID volume spikes, concentration in few accounts) — py/nate/'s unmapped_accounts-above-a-flow-floor pattern is the model.

E. api_keys.partner_id — anchoring the claim

api_keys (db/postgres/1.sql:25-54) has no label, name or owner column — nothing records which software a key belongs to. Adding partner_id makes a broker_id claim cross-checkable against the key that actually placed the order, turning attribution from self-asserted (B6) into anchored, and makes the framework's "one rebate-bearing primary OMS" rule enforceable in data rather than policy.

It needs a key-labeling and provisioning UX, which is why it is not blocking. It is independently valuable and worth doing regardless.

F. Upstream Hummingbot

The change to the merged connector is small: add BROKER_ID = "HBOT" to the constants module and "bid": CONSTANTS.BROKER_ID to _place_order's payload.

Leave client_order_id_prefix and client_order_id_max_length raising NotImplementedError — that is correct for AX's numeric cid, and it is why the Broker ID needs its own field. The rejected PR #8128 is a useful reference for test shape (test_generate_order_tag_max_10_chars) but not for mechanism: it drove attribution through a 10-char order tag against the brokerage SDK, not the AX gateway.

This is the Q4 critical path, because it runs on Hummingbot's release cadence, not ours. Open it as soon as the field name is frozen — it does not depend on any AX-side PR landing.

Partner-facing integration docs should include the two AX quirks the Hummingbot integration surfaced: one-way positions only, and fixed per-token leverage as an integer (so 12.5 must be entered as 12).

Deliverables

See SoW: Partner Programs, PRs 8–11 and 14.

Open Questions

O1, O2 and O4 are listed with their owners in Answers needed before this ships above. The remainder does not gate any PR.