Date: 2026-06-23 (decisions locked 2026-07-09) Status: Draft — core decisions settled in review (A-4094); moving to plan draft Author: (you)
Review decisions (Slack A-4094, 2026-07-08/09). v1 is a single uniform volume ladder, tiered per
trading_accounts.idover a trailing-30-day window recomputed daily (with hysteresis), authored as a flat schedule + tiers (no composition), withmanualretained as an engine bypass. See Decisions below for the full record; the design text has been updated to match.
Maker/taker
fees
today
are
static
per
trading
account.
The
source
of
truth
is two
columns
on
the
trading_accounts
row:
trading_accounts.maker_fee,
trading_accounts.taker_fee
(db/postgres/1.sql:301-302), decimals
like
0.0002
/
0.0025,
read
into
DbTradingAccount (rs/sdk-internal/db/src/entities.rs:2489).
DbTradingAccount::update_fees(...) (rs/sdk-internal/db/src/entities.rs:2537),
driven
by
an
admin
endpoint
in rs/api-gateway/src/admin_routes.rs:368-398.
A
human
sets
the
number.
From there the rate flows entirely through existing infrastructure:
trade-engine2
subscribes
to
trading_accounts
via
logical
replication (BTreeMapReplica<DbTradingAccount, 1>),
and
accounts_monitor.rs
translates each
row
change
into
AccountFeesSnapshot
/
AccountFeesUpdate
events (rs/trade-engine2/src/tasks/accounts_monitor.rs:9-40).
account_fee_rates: BTreeMap<AccountId, FeeRates> and
applies
fee = rate × price × quantity
at
fill
time (rs/trade-engine2/src/state_machine.rs:345-384,
:482-484).
recon-engine
enforces
the
4-leg,
net-zero
fee
ledger
invariant
per
trade (rs/recon-engine/src/checks/trade_fees_square.rs).
So
changing
a
rate
is
already
a
single
Postgres
UPDATE
that
propagates everywhere
automatically.
What
is
missing
is
anything
that
decides
the
rate from
trading
activity.
The
business
intent
already
exists,
but
only
as
an
offline
model.
py/nate ships
the
published
ladder
in
py/nate/config/fee_program.yaml:
four
tiers
keyed by
30-day
total
(maker
+
taker)
notional,
maker/taker
in
bps,
top
tier
a maker
rebate
(-0.5 bps).
Its
own
comment
is
the
crux
of
this
RFC:
This is the published ladder, not what every desk is actually charged: AX bills flat per-user rates today, so some desks sit on off-ladder negotiated rates.
We want to close that gap: an engine that computes each account's effective maker/taker fees from its trailing volume — by calendar month or trailing 30 days — and writes them back, while preserving negotiated overrides.
Two reusable primitives already exist and should not be re-implemented:
ChTradeRow::query_account_volumes(conn, accounts, start_ns, end_ns, limit) sums
maker + taker
notional
from
ClickHouse
trades (rs/sdk-internal/clickhouse/src/schema.rs:873-926).
recon-engine's
refresh_leaderboard_task
(rs/recon-engine/src/leaderboard.rs) ticks
on
a
cadence,
computes
per-account
volume
for
a
period
via query_account_volumes,
and
upserts
results
to
Postgres.
The
fee
engine
is
the same
loop
with
a
different
sink.
It
also
gives
us
the
windowing
helper LeaderboardCadence::{period_start,next_period_start,crossed_boundary} (rs/sdk/src/protocol/api_gateway.rs:223-265)
and
the
calendar
helper start_of_current_month_ns()
(rs/sdk-internal/src/calendar_math.rs).
Build an engine that, on a schedule:
trading_accounts
so
it
propagates
through
the existing
replication
path
with
zero
changes
to
trade-engine2.
Non-goals
(v1):
RFQ-specific
rebate
schedules
(tracked
separately
in
rome.md
/ A-3297),
MM
stipends
and
bonus-pool
payouts
(those
are
deposits
with
free-text reference_ids,
not
fees
—
see
the
MM-payouts
model),
per-product
fee differentiation,
and
referral/promo
programs.
The
data
model
is
shaped
so
these can
land
later
without
a
rewrite
(see
Future-proofing).
This is the core question. I evaluated folding it into each existing service against standing up a new one.
| Candidate | Fit | Verdict |
|---|---|---|
trade-engine2 |
Already holds the rate map and applies fees. One reviewer suggested adding a tier task here. | No. This is the hot, deterministic fill path. It must stay a pure consumer of rates. Mixing "decide the rate" (periodic ClickHouse scans, schedule logic) into "apply the rate" couples a slow analytical job to the matching/settlement path and conflates two responsibilities. |
recon-engine |
Already
runs
the
exact
CH→PG
periodic
volume
loop
and
writes
derived
state
to
Postgres
—
refresh_leaderboard_task
upserts
per-account
volume
rankings
(DbLeaderboardEntry::upsert_batch,
rs/recon-engine/src/leaderboard.rs).
This
is
a
direct
precedent:
the
fee
engine
is
the
same
loop
with
a
different
sink. |
Recommended. See below. |
settlement-engine |
Scheduled financial daemon that already writes funding/settlement transactions. | No. Its domain is contract lifecycle — funding, rolls, settlement prices. Commercial pricing programs are a different bounded context; bundling them dilutes the service and entangles fee-policy deploys with settlement-critical code. |
api-gateway |
Owns
the
admin
write
path
(update_fees)
and
an
admin
UI
surface. |
Partial. It should own the human-facing CRUD for schedules and manual overrides (request/response). It should not host a long-running periodic evaluator — that is not a gateway's job. |
| New standalone service | A
dedicated
ax-fee-engine
daemon
would
be
the
textbook
"own
bounded
context"
answer. |
No, for v1. It buys a clean domain boundary at the cost of a whole new deploy unit — binary, Dockerfile, health wiring, on-call surface — to host one periodic loop that recon-engine already runs verbatim. Not worth it until the fee domain grows past volume tiers (see Future-proofing). |
recon-engineLand
the
engine
as
a
new
periodic
task
inside
recon-engine,
alongside refresh_leaderboard_task.
The
two
are
mechanically
the
same
—
tick
on
a
cadence, call
ChTradeRow::query_account_volumes
for
the
window,
upsert
to
Postgres
—
so this
reuses
the
service's
existing
daemon
scaffolding,
ClickHouse
+
Postgres pools,
cadence/boundary
helpers,
and
failure-isolation
pattern
rather
than standing
up
a
second
deploy
unit
for
one
loop.
recon-engine
already
writes derived
state
to
Postgres
on
a
schedule,
so
this
is
in-character,
not
a
layering violation.
Concretely,
add
rs/recon-engine/src/fees.rs
with
a
refresh_fee_rates_task that:
query_account_volumes),
DbTradingAccount::update_fees
only
for
accounts in
automatic
mode,
and
The
human-facing
CRUD
(define
schedules,
assign
an
account
to
a
schedule,
pin
a manual
override)
lives
in
api-gateway
admin
routes,
writing
the
same
Postgres tables
the
task
reads.
The
recon-engine
task
is
the
periodic
evaluator;
the gateway
is
the
control
surface.
This
keeps
request/response
and
scheduled-compute concerns
in
their
natural
homes
while
sharing
one
data
model.
The
one
real
cost
is
blast
radius:
fee
rates
are
authoritative
billing
state that
flows
straight
into
trade-engine2's
fill
path,
whereas
recon-engine's other
writes
(invariants_log,
the
leaderboard
table)
are
diagnostic.
A
bug
in the
fee
task
charges
customers
wrong.
We
accept
that
and
mitigate
it
with
the fee_mode='auto'
gate,
the
dry-run
rollout,
and
a
self-recon
invariant
(all below)
—
the
same
service
that
hosts
the
engine
also
hosts
the
check
that
polices it.
Keep
the
data
model
generic
even
though
the
home
is
recon-engine.
The schedule/tier/assignment
tables
(below)
model
program
→
window
→
tier
→
rate plus
per-account
assignment
and
override,
so
RFQ
rebate
schedules,
per-product fees,
and
promo/referral
programs
become
new
rows
and
new
program_kinds,
not
a schema
rewrite.
If
and
when
the
fee
domain
outgrows
a
single
periodic
task
—
its own
admin
surface,
multiple
program
kinds,
independent
deploy
cadence
—
extract the
task
and
its
tables
into
a
standalone
ax-fee-engine
crate.
Because
all
fee logic
lives
behind
update_fees
and
the
schedule
tables,
that
extraction
is
a move,
not
a
redesign.
(If
the
name
is
ever
needed:
fee-engine,
not pricing-engine
—
"price"
is
overloaded
here
by
index/mark/settlement
prices
and index-publisher.)
The
engine
deliberately
does
not
absorb
risk
limits (owned
by
risk)
or
stipend/bonus
payouts
(which
are
deposits,
not
fees).
Today
the
dependency
points
the
wrong
way:
there
is
a
published
rulebook (somewhere
in
a
doc/PDF),
and
py/nate/config/fee_program.yaml
is
a
hand-kept copy
of
it
that
code
reads.
The
code
chases
the
doc,
and
the
two
drift.
Invert it. Make a version-controlled policy artifact in the repo the single source of truth, and generate everything else from it:
Git history then is the audit log of every fee change — who, when, and (via PR review) why — which is exactly the provenance a regulated exchange wants. This is the "code is law" framing: the rulebook is rendered from the policy, never maintained alongside it.
Authored
artifact:
rs/admin-cli/examples/fee_program.yaml
—
moved
under admin-cli/examples
so
the
materializer
owns
the
example
it
publishes.
Schema
(the
types):
rs/sdk-internal/src/fee_program.rs
—
serde
structs
that define
the
structure
of
a
program:
pub struct FeeProgram {
pub name: String,
pub window_kind: FeeWindowKind, // CalendarMonth | Trailing30d
pub tiers: Vec<FeeTier>,
}
pub struct FeeTier {
pub min_notional_usd: Decimal,
pub maker_fee: Decimal,
pub taker_fee: Decimal,
}sdk-internal
is
the
right
home:
it
is
the
base
crate
the
engine (recon-engine),
api-gateway,
and
a
doc-gen
tool
all
already
depend
on.
These are
internal
policy
types
—
keep
them
out
of
the
public
rs/sdk
per
the SDK-leakage
rule
in
CLAUDE.md.
The
YAML
supplies
the
values;
the
Rust
structs
are
the
single
source
of
truth for
shape.
Deserialization
is
validation
gate
#1.
Gate
#2
is
a FeeProgram::validate() -> Result<()>
that
ensure!s
the
invariants
a
type
can't express
—
tiers
strictly
monotonic
in
min_notional_usd,
no
gaps/overlaps,
rebate floor
respected
(per
CLAUDE.md:
enforce
contracts
with
ensure!,
not
comments). Export
a
JSON
Schema
(via
schemars)
into
the
existing
schemas/
dir
so
editors lint
the
YAML
live.
Two axes get conflated here: the authoring format and the type system. The recommendation is YAML authored, typed by Rust serde structs, validated in CI — for a flat tier ladder, the program is data, not a program, and YAML is diffable, commentable, PR-able by non-engineers, and already the de-facto choice.
Survey of the alternatives and when each would actually win:
| Option | When it wins | Verdict here |
|---|---|---|
| YAML + serde schema | Declarative data; humans edit; structure enforced in Rust | Recommended.
Matches
existing
fee_program.yaml;
lowest
friction. |
| TOML | Flat config | Nested tier arrays read worse than YAML. |
| JSON / JSON5 | Machine target | Plain JSON has no comments — a generated artifact, not a source. |
| Rust consts | Maximum type safety | Defeats the point — rates become a recompile+redeploy, not DB-driven. |
| CUE / Dhall / Jsonnet / KCL | Typed config that generates artifacts and validates invariants natively | Genuinely
strong
for
"rulebook
from
code";
CUE
could
even
subsume
validate().
Defer
—
niche,
new
toolchain
—
until
many
interrelated
programs
justify
it. |
| CEL / Rhai / Starlark / Lua | Rules need logic: conditional eligibility, computed thresholds, time-boxed promos, per-product overrides | Keep in the back pocket. Don't start here. |
The
guiding
principle:
stay
declarative
as
long
as
the
policy
is
data;
reach
for a
language
only
when
it
becomes
computation.
You'll
know
you've
crossed
the
line when
you
catch
yourself
encoding
if/then
into
YAML
keys
—
that's
the
signal
to introduce
a
sandboxed,
deterministic
evaluator
(CEL
for
safe
boolean
eval,
Rhai for
Rust-native
embedding),
not
to
keep
contorting
the
data
format.
Because
every consumer
goes
through
the
fee_program.rs
types,
that
later
migration
is
contained.
One artifact, two consumers, zero drift:
recon-engine
fee
task
loads
the
active
program.
Docs:
a small
doc-gen
step
(just policy/rulebook,
or
a
tiny
bin)
renders
the
same program
to
the
customer
rulebook
page
via
the
existing
docs
pipeline.
fee_schedules
/
fee_schedule_tiers
are
the deployed
materialization.
A
sync
step
(migration-like
loader,
alongside just migrate-db)
writes
the
active
policy
version
into
Postgres;
the
engine reads
the
DB
at
runtime
(low-latency,
joinable
to
trading_accounts). Git
is
law;
the
DB
is
a
cache.
This
replaces
ad-hoc
"seed
from fee_program.yaml"
with
a
deliberate
compile-policy
→
materialize
→
run
flow, and
lets
py/nate
retire
its
private
copy
in
favour
of
the
canonical
artifact.
db/postgres/1.sql)Schedules and tiers are versioned and immutable-by-version so historical rates are reconstructable for billing disputes.
CREATE TABLE fee_schedules (
id CHAR(16) PRIMARY KEY,
name TEXT NOT NULL,
program_kind TEXT NOT NULL DEFAULT 'volume_tier', -- future: 'rfq_rebate', 'promo', ...
window_kind TEXT NOT NULL, -- 'calendar_month' | 'trailing_30d'
is_active BOOLEAN NOT NULL DEFAULT TRUE,
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
);
CREATE TABLE fee_schedule_tiers (
schedule_id CHAR(16) NOT NULL REFERENCES fee_schedules(id),
min_notional_usd NUMERIC NOT NULL, -- lower bound, inclusive
maker_fee NUMERIC NOT NULL, -- decimal, e.g. -0.00005
taker_fee NUMERIC NOT NULL,
PRIMARY KEY (schedule_id, min_notional_usd)
);trading_accounts
gains
an
assignment
+
override
mode.
The
existing maker_fee
/
taker_fee
columns
stay
as
the
materialized
effective
rate
— i.e.
the
engine
writes
the
resolved
rate
there
and
the
entire
downstream
path (replication
→
trade-engine2)
is
unchanged.
ALTER TABLE trading_accounts
ADD COLUMN fee_schedule_id CHAR(16) REFERENCES fee_schedules(id), -- NULL = no auto program
ADD COLUMN fee_mode TEXT NOT NULL DEFAULT 'manual'; -- 'manual' | 'auto'fee_mode = 'manual':
the
engine
never
touches
maker_fee/taker_fee. This
is
how
negotiated
off-ladder
desks
are
protected
—
the
default,
so
the migration
is
a
no-op
for
every
existing
account.
fee_mode = 'auto'
with
a
fee_schedule_id:
the
engine
owns
the
two
rate columns
and
keeps
them
in
sync
with
the
account's
tier.
v1
runs
a
single
uniform
ladder
(decision
Q8):
exactly
one
fee_schedules
row is
is_active,
and
every
auto
account
points
its
fee_schedule_id
at
it.
The per-account
fee_schedule_id
column
is
kept
—
cheap,
and
it
future-proofs multiple
concurrent
schedules
—
but
v1
never
assigns
divergent
schedules
to different
accounts.
The
only
per-account
degree
of
freedom
in
v1
is manual
vs
auto.
Audit trail (also the source for "why did my fee change?"):
CREATE TABLE fee_rate_changes (
id BIGSERIAL PRIMARY KEY,
account_id CHAR(16) NOT NULL,
changed_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
schedule_id CHAR(16),
window_notional NUMERIC NOT NULL,
old_maker_fee NUMERIC, old_taker_fee NUMERIC,
new_maker_fee NUMERIC, new_taker_fee NUMERIC,
reason TEXT NOT NULL -- 'tier_promotion', 'tier_demotion', 'schedule_edit', 'manual'
);A
new
task
in
recon-engine,
modeled
directly
on
refresh_leaderboard_task:
on each tick (and on boundary crossing):
for each active schedule S:
window = resolve_window(S.window_kind, now) # [start_ns, end_ns]
accounts = trading_accounts where fee_mode='auto' and fee_schedule_id = S.id
volumes = ChTradeRow::query_account_volumes(conn, Some(accounts), start_ns, end_ns, None)
for each account A:
tier = highest tier in S whose min_notional_usd <= volumes[A]
if (tier.maker_fee, tier.taker_fee) != (A.maker_fee, A.taker_fee):
DbTradingAccount::update_fees(A, tier.maker_fee, tier.taker_fee) # propagates automatically
insert fee_rate_changes(...)
resolve_window:
calendar_month
→
[start_of_current_month_ns(), now]
(reuse calendar_math.rs),
i.e.
month-to-date.
trailing_30d
→
[now - 30d, now].
Both
windows
already
work
with
query_account_volumes's
(start_ns, end_ns) signature
—
no
new
ClickHouse
query
is
needed
for
v1.
The
cadence/boundary
logic generalizes
LeaderboardCadence;
since
both
tasks
now
live
in
recon-engine, the
fee
task
can
share
that
windowing
helper
directly
(extend
it
with trailing_30d
rather
than
fork
it).
Hoisting
it
into
sdk-internal
is
optional and
only
worth
it
once
a
second
crate
needs
it.
Calendar-month vs trailing-30-day differ in behavior, not just window math. Decided (A-4094): trailing-30 days.
Trailing
30
days
(chosen).
Recompute
on
a
daily
cadence
over
[now-30d, now]. Responsive
and
lets
an
account
"catch
up"
as
it
trades,
but
one
near
a
tier boundary
can
oscillate
day-to-day
as
old
volume
rolls
off
—
so
hysteresis
is mandatory,
in
v1:
promote
immediately,
demote
only
after
N
consecutive
days below
the
lower
band
(and/or
a
"best
tier
in
the
last
K
days"
rule).
Demotions are
rate-limited
and
always
audited.
Calendar
month
(not
chosen).
Compute
on
the
prior
completed
month
and apply
for
the
whole
current
month
—
fixed
and
knowable
in
advance,
no intra-month
flapping,
trivially
contestable,
re-evaluated
once
at
the
boundary. Simpler
(no
hysteresis),
but
rejected
in
review
as
less
standard
and
slower
to let
desks
catch
up.
Retained
as
a
second
window_kind
we
can
offer
later.
The engine ships trailing-30d with hysteresis. To preserve the idempotent-tick property (Lifecycle & correctness), express hysteresis as a pure function of warehouse history — e.g. "best tier over the last K daily 30-day windows," each recomputed from ClickHouse — rather than a persisted consecutive-days counter, so a crashed or duplicated tick still converges with no resume token.
A
cross-cutting
rule
for
both:
never
silently
raise
a
rate
mid-window
without
a demotion
record,
and
consider
a
"no-worse-than-assigned-floor"
guard
so
a manual
sweetener
can
coexist
with
auto
(a
per-account
maker_fee_floor
/ taker_fee_floor,
deferred
to
v2).
trading_accounts
(not
a
new
effective
table)Keeping
the
engine's
output
in
the
existing
maker_fee/taker_fee
columns
means trade-engine2,
the
replication
stream,
the
admin
GUI,
and
the
recon
invariant all
keep
working
untouched.
Introducing
a
separate
"effective
rates"
table would
force
a
second
consumer
path
in
the
hot
fill
engine
for
no
benefit
in
v1. The
schedule/tier
tables
capture
the
derivation;
the
account
columns
hold
the materialized
result.
This
is
the
minimal,
lowest-risk
integration.
Per
CLAUDE.md's
connection/lifecycle
guidance,
the
engine
must
be
safe
across restarts
and
partial
failures:
trade-engine2
at
fill
time;
the
audit
row
records the
cutover.
The
trade_fees_square
invariant (rs/recon-engine/src/checks/trade_fees_square.rs)
continues
to
hold
because
it checks
the
rate
recorded
on
each
trade,
not
a
global
schedule.
fee_mode='manual'
filter
is
the
single guardrail
protecting
negotiated
desks;
it
must
be
enforced
in
the
SQL
WHERE, not
in
application
code
that
could
regress.
Add
a
recon/invariant
check: "no
fee_rate_changes
row
exists
for
an
account
that
was
manual
at
change time."
trades
is
the
warehouse;
brief
ingestion
lag
only
delays
a promotion
by
one
tick
—
acceptable.
Calendar-month-on-prior-period
is
immune since
the
window
is
closed.
{e:?}
and
skips,
exactly
like
the
leaderboard
task
—
it
never
blocks
other schedules
or
wedges
the
rate
path.
trading_accounts
columns,
all
defaulting
to manual
→
zero
behavior
change
on
deploy.
Reuses
the
declarative db/postgres/1.sql
flow.
refresh_fee_rates_task
in
recon-engine
(alongside
the
leaderboard task),
extend
the
cadence
helper
with
trailing_30d,
and
add
admin
CRUD
in api-gateway.
rs/admin-cli/examples/fee_program.yaml
(typed
by sdk-internal/fee_program.rs),
and
add
the
loader
that
materializes
it
into
the fee_schedules
row
+
tiers
—
replacing
the
py/nate
private
copy.
fee_rate_changes
rows
but
does not
call
update_fees.
Diff
intended
vs.
actual
across
all
accounts;
this
is exactly
the
realized-vs-modeled
gap
NATE
already
visualizes,
so
we
can reconcile
against
it
before
going
live.
fee_mode='auto',
watch
the
audit
table
and recon,
then
widen.
trade-engine2
(rejected):
pollutes
the
deterministic
fill
path
with analytical
scans;
conflates
deciding
and
applying
rates.
ax-fee-engine
service
(rejected
for
v1,
adopted):
the clean-bounded-context
answer,
but
a
whole
new
deploy
unit
(binary,
Dockerfile, health
wiring,
on-call)
for
one
periodic
loop
recon-engine
already
runs.
Deferred until
the
fee
domain
outgrows
a
single
task;
the
generic
schema
makes
that extraction
a
move,
not
a
redesign.
fee_components
(rejected
for
v1):
a program
as
a
set
of
typed
components
(ladder | polynomial | multiplier,
each
a JSON
attributes
blob)
applied
additively.
Normalizes
the
ladder
into
one component
and
turns
future
shapes
into
"blanks
to
fill
in,"
but
only
pays
off once
a
second
component
type
exists,
and
demands
per-component
parsing
+ compose
logic
up
front.
The
flat
model
also
gives
a
cleaner,
revertible
cutover (initialize
everyone
to
manual,
flip
cohorts
to
auto)
vs.
authoring/parsing
a collection
of
component
files
in
one
go.
Generalize
later
via
program_kinds.
Settled in review (Slack thread, A-4094, 2026-07-08/09; decider Andrew Lee unless noted). Listed decision-first; the design text above reflects these.
Window
—
trailing-30d
rolling,
recomputed
daily.
calendar-month-prior
vs trailing-30d
Chosen
because
it's
the
prevailing
exchange
standard
(Hyperliquid 14d;
Coinbase/Binance/Kraken
30d)
and
"lets
people
catch
up
if
they
fall behind."
Consequence:
hysteresis
moves
into
v1
scope
—
a
rolling
window flaps
at
tier
boundaries
without
it
(see
Rate
application
policy).
Promote immediately;
demote
only
after
N
consecutive
days
below
the
lower
band, rate-limited
and
audited.
One
uniform
ladder
for
everyone
—
yes.
v1
puts
every
account
on
a
single uniform
fee
ladder.
Rationale:
no
exchange
precedent
for
publishing
"different fee
programs
for
different
people";
it
hurts
perceived
fairness
and
"invites capriciousness
in
administering
it"
(+1'd
in
thread).
Consequence:
one
active schedule;
per-account
assignment
collapses
to
manual
vs
auto
(see
Data model).
This
narrows
—
but
does
not
delete
—
goal
#4:
negotiated
desks
are preserved
via
manual
mode,
not
via
a
divergent
published
ladder.
Program
modeling
—
flat
schedule
+
tiers,
not
composition.
v1
implements only
the
ladder
shape;
program_kind
is
reserved
for
future
kinds.
The composition
model
(typed
additive
fee_components)
was
floated
and
then withdrawn
in
review
("shouldn't
use
a
composite
model
for
v1"):
it
only
pays
off once
a
second
component
type
exists,
and
the
flat
model
gives
an
"infinitely cleaner,"
revertible
cutover
and
partial
rollout.
Generalize
by
adding program_kinds
later
(see
Alternatives
considered).
manual
stays
an
engine
bypass,
not
a
program.
With
composition
rejected (Q6),
representing
a
negotiated
desk
as
a
"fixed
constant
program"
is
moot.
Keep fee_mode='manual'.
Rollout
initializes
every
account
to
manual,
then flips
cohorts
to
auto
—
this
is
the
clean,
revertible
cutover
that
motivated the
flat
model.
The
override-safety
invariant
keeps
checking
against
manual.
Aggregation
—
per
trading_accounts.id.
Fees
are
set
per
trading
account; that's
the
accepted
v1
heuristic
("a
decent
heuristic
for
now…
we
can
update
it later").
No
pooling
across
an
owner's
accounts
(ubo_user_id).
Manual
siblings'
volume
in
the
pool
—
moot.
Per-account
tiering
(Q3)
means each
account
is
tiered
by
its
own
trailing
volume
only;
there
is
no
cross-account pool
for
a
manual
account's
volume
to
contribute
to.
Multi-ladder per account — no. One ladder in v1; fairness issues with per-account/per-region ladders. A single account is only ever on one ladder.
fee_program.yaml
keys
tiers
on
combined
maker+taker notional
(what
query_account_volumes
returns).
Confirm
that's
the
intended basis,
vs.
separate
maker/taker
volume
thresholds.
auto
mode
(maker_fee_floor/taker_fee_floor),
or
is manual-vs-auto
binary
enough
for
v1?
(Leaning
binary;
floor
deferred
to
v2.)
calendar_math.rs)
and
the
demotion
band
width
/
N-day
count
for hysteresis
still
need
concrete
numbers.