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)
| # | 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.
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′).
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.
tag,
and nothing
in
the
order
path
identifies
routing
software.
tag
is
not
a
viable
channel.
It
has
three
mutually
inconsistent
specs (the
SDK
doc
comment
says
≤10
alphanumeric;
the
dead rs/sdk/src/types/tag.rs
newtype
says
≤50
with
underscores;
the
wire
accepts arbitrary
bytes)
and
zero
enforcement.
Users
already
stamp
values
through it,
including
HBOT,
so
overloading
it
would
collide
with
real
traffic
and silently
mis-attribute.
cid
is
a
u64,
unlike Binance
Link
/
Bybit
and
unlike
Hummingbot's
own
client_order_id_prefix convention,
which
assume
a
string
order
id.
architect_perpetual (PR
hummingbot/hummingbot#7951,
milestone
v2.14)
sends {"d","p","po","q","s","tif","cid"}
with
cid = int(order_id)
and
no
tag.
broker_id
field
on
PlaceOrderRequest (serde
"bid"),
not
the
existing
tag.
tag
stays
free-form
user
metadata, with
no
semantic
change
to
a
documented
public
field
and
no
collision
with users
already
stamping
HBOT.
BrokerId
is
a
newtype
with
#[serde(try_from = "String")]
— trim,
uppercase-normalize,
then
^[A-Z0-9]{3,10}$.
Deserializing
through try_from
makes
the
invariant
real
at
every
entry
point
(REST,
WS,
CLI,
SDK, tests)
rather
than
a
doc
comment.
This
is
CLAUDE.md's
rule
against
asserting invariants
the
code
does
not
enforce,
and
it
is
exactly
the
failure
mode
tag has
today.
order_log
and
historical_orders, COALESCEd
at
query
time.
order_log's
Pending
row
is
written
at
order
entry (ChOrderLog::from_pending_order, rs/sdk-internal/clickhouse/src/schema.rs:665)
so
it
always
precedes
any
fill —
that
is
what
keeps
a
GTC
order
which
fills
in
month
M
but
terminates
in
M+2 attributable
in
M.
But
it
is
written
fire-and-forget (async_insert=1, wait_for_async_insert=0)
on
the
hot
path. historical_orders'
terminal
row
goes
through
the
batched
writer
with
retry (rs/order-gateway/src/order_log_writer.rs:255-276),
so
it
is
more
durable
but only
exists
at
completion.
Neither
alone
is
sufficient.
broker_id.
It
is
not
written
as
a
per-account
binding. The
framework
sets
this
program's
attribution
"at
the
transaction
level",
and
a user
may
route
some
accounts
or
some
flow
through
a
partner
and
not
others. (Supersedes
an
earlier
draft
that
wrote
a
broker_id-source
row
into
the account-level
attribution
table.)
partner_programs.broker_ids;
an
unregistered
value
is
inert.
This
is structural,
not
a
filter.
HBOT.
The
incentive
shape
matters:
a false
stamp
pays
the
partner,
not
the
user,
so
the
realistic
threats
are
a partner
instructing
its
users
or
a
partner
running
accounts
itself
—
both addressed
by
registry
gating,
self-dealing
exclusion,
and
anomaly
detection (§D).
Anchoring
the
claim
properly
needs
api_keys.partner_id
(§E).
broker_id
is
an
AX-side
annotation
only.
It
never
reaches
EP3
— prepare_ep3_insert_order_request
(rs/order-gateway/src/rest_service.rs:362-397) has
no
field
to
carry
it
and
ax_ep3::protocol::v1beta1::InsertOrderRequest has
none.
Anything
needing
venue-level
attestation
of
a
Broker
ID
needs
a different
design.
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.
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:
DEFAULT
fill
it
on
write).
Give
broker_id
a permanent
DEFAULT
as
a
rollout/rollback
net.
db/clickhouse/README.md's
"never
modify
existing
tables
directly"
is stale
and
must
not
be
followed
here.
It
references
a
deleted order_gateway_schema.sql,
contains
a
self-labelled
"Deprecated"
section,
and predates
Atlas.
ClickHouse
is
Atlas-managed
declaratively
from
init.sql
via just migrate-db <env>
(scripts/migrate-db.sh:134-137),
and
columns
have been
added
in
place
repeatedly
—
to
historical_orders
and
order_log specifically
(a-2946),
and
to
trades
with
no
migration
file
at
all (6611a6d32).
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.
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.
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:
trades
is
ReplacingMergeTree;
order_log
and historical_orders
are
MergeTree
with
per-event
rows.
Use
FINAL
on trades
and
argMax
on
the
order
side,
copying
the
existing
query's SETTINGS.
COALESCE(hist.broker_id, plog.broker_id).
trades.maker_order_id
/
taker_order_id
are Nullable
"for
historical
migration" (db/clickhouse/init.sql:126-127).
Join
only
non-NULL
rows,
and
separately count
and
sum
the
fees
on
unjoinable
fills
into
the
accrual's unjoinable_fill_count
/
unjoinable_fees_usd.
Alert
when
the
unjoinable share
crosses
a
threshold
—
silent
under-attribution
is
the
failure
mode.
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.
api_keys.partner_id
—
anchoring
the
claimapi_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.
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.
petioptrv
(the
connector's
author,
a Hummingbot
core
dev)
a
small
bounty
to
review
and
shepherd
it.
01KH3W-B6DH-0000).
Do
not
fork
or
publish
an
Architect
Docker
image
— that
fragments
against
the
upstream
install
users
actually
run.
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).
See SoW: Partner Programs, PRs 8–11 and 14.
O1, O2 and O4 are listed with their owners in Answers needed before this ships above. The remainder does not gate any PR.
tag
usage
in
prod
—
worth
measuring (SELECT tag, count() FROM historical_orders WHERE tag IS NOT NULL GROUP BY tag ORDER BY 2 DESC LIMIT 50)
before
deciding
whether
a
separate
follow-on
tag hardening
is
safe.
Not
decision-blocking
here,
since
B1
leaves
tag
alone.