RFC: AX Liquidity Program

Date: 2026-08-03 (updated 2026-08-12)

Status: Part 1 partially built — the pure core, schema, settler, and admin CLI have landed; the snapshotter (book producer), public params API, participant read-out, and all of Part 2 are still unbuilt. See Implementation status.

Related: A-4316 / PR #3291 (alee/mmlp-executable-policy, Andrew Lee, draft) — the program's rules as executable code (rs/policy/mmlp); this engine builds on it (see "Builds on A-4316" below); generic program-payouts module (Part 2 credit path); execution plan liquidity-program.plan.yaml (rendered: docs/plans/liquidity-program.html) — phases, tracks, live PR status; customer notice AX Liquidity Program — Effective September 1, 2026 (the spec); dynamic fee engine (A-4094, shipped; plan retired), which this program sits beside; incremental VolumeRing (PR #3389, branch incremental-volumering-fee-tiering) — the trailing-window accumulator this borrows its cadence shape from; sibling docs price-band-automation (mark / best-bid-ask infra), referral-program + partner-rebate-payouts (USD-credit plumbing). No Linear epic or tickets exist yet — the ticket breakdown at the end is a proposal.

Author: (with Claude)

The program is fully specified to customers and has a hard Sept 1, 2026 effective date (30-day notice already sent), but is completely unbuilt, unowned, and unticketed. This RFC is the engineering plan to close that gap. It is a budgeted, per-product cash reward for resting maker quotes scored on quote quality, not fills — separate from and additive to the volume-tiered fee engine. $200k/month across ~35 products (Appendix B of the spec). The mechanism: snapshot every book once a minute at a random instant, score each trader's two-sided resting size by a quadratic proximity weight around the mark, normalize to per-snapshot shares, and pay each day's per-product pool out pro-rata subject to a 40% anti-concentration cap, a minimum-payout floor, and floor-to-cent settlement.

This was a design document; Part 1 is now substantially built (2026-08-12). The pure scoring/settlement core, the three tables, the epoch settler, and an admin CLI have shipped; the book-reconstruction producer, the public/admin params API, the participant read-out, and the entire Part 2 credit path have not. The Implementation status section maps every RFC proposal to its as-built code (or its absence). The design sections below are preserved as the original plan of record; where a path or crate name diverges from what shipped, the status section is authoritative.


Implementation status (2026-08-12)

Headline: the RFC's "80% needs no math and can be built before A-4316 lands" bet paid off, but not by building on A-4316 — that PR (#3291 / ax-policy-mmlp) never merged, and a fresh core crate ax-mmlp2 (rs/sdk-internal/mmlp2/) was written instead (see Builds on A-4316, now superseded). The pipeline is wired from the pure core through settlement into the accrual ledger, but the snapshotter that feeds it does not exist, so mmlp.liquidity_snapshots has no producer and the settler currently settles empty epochs. Nothing credits cash.

As-built map (RFC proposal → shipped code)

RFC element Status As-built location PR / ticket
Pure scorer + waterfall + gates (RFC put in sdk-internal/src/liquidity_program/) shipped — as crate ax-mmlp2, not the proposed path rs/sdk-internal/mmlp2/src/{score,settle,params}.rs: mark_price, score_snapshot, group_snapshot_rows, daily_weights, cap_waterfall, cap_waterfall_traced, settle_day; types ProgramParams, BonusWindow, SnapshotRow, DaySettlement, SettlementConfig, LiquidityAccrualStatus #3533 / A-4496
mmlp.liquidity_programs (Postgres, versioned, immutable, bonus_windows JSONB) shipped — matches D5 exactly db/postgres/1.sql:1835; freeze trigger on UPDATE/DELETE/TRUNCATE #3470 / A-4493
accrual_engine.liquidity_accruals (the seam) shipped — matches §4 exactly db/postgres/1.sql:1874; append-only + status-only trigger enforcing ACCRUED→APPROVED→PAID / ACCRUED→FORFEITED #3470 / A-4493
mmlp.liquidity_snapshots (ClickHouse, ReplacingMergeTree(computed_at)) shipped — matches data-model row exactly db/clickhouse/init.sql:537 #3470 / A-4493
DB entity layer shipped rs/sdk-internal/db/src/mmlp.rs (DbLiquidityProgram, DbLiquidityAccrual, NewLiquidityAccrual); rs/sdk-internal/clickhouse/src/mmlp.rs (ChLiquiditySnapshot, query_for_epoch) #3531 / A-4493
Epoch settler (accrual-engine host, §3/§7) shipped — reads CH snapshots → settle_day → writes accruals, batched, US/Central month math, moves no money rs/accrual-engine/src/mmlp/{mod,settle.rs} (settle_epoch, settle_once, settle_task); wired at rs/accrual-engine/src/lib.rs; batched inserts #3539 / A-4497, batch #3587 / A-4610
Params authoring shipped as CLI, not the proposed admin API rs/admin-cli/src/mmlp.rs (list, materialize from examples/mmlp.yaml → liquidity_programs) — the only real writer today #3560 / A-4498
Admin params GUI (mirror FeePrograms.tsx) shipped as UI shell, backed by a mock gui/apps/admin/src/pages/mm-liquidity-programs/ renders against mockApi.ts; no api-gateway route touches liquidity_programs/liquidity_accruals #3572 / A-4593
Snapshotter / book reconstruction from order_log (D4, §1) NOT built — the blocking gap none; mmlp/mod.rs notes "the snapshotter (producer) is a follow-up"; mmlp.liquidity_snapshots has no writer, so the settler settles empty epochs —
Public params API on /instruments (§6, "v1-critical") NOT built Instrument.additional_product_specs still None; no liquidity_program field, no read endpoint —
Admin HTTP CRUD for rewards params NOT built GUI is mock-only; authoring is CLI-only —
Participant accrued read-out (D6, "v1-valuable") NOT built — —
Conservation invariant check (§Testing, §7) not confirmed present no liquidity_conservation InvariantCheck found —
Part 2 — entire credit path (§5, D7) NOT built no liquidity_payouts table, no credit adapter; blocked on the program-payouts module, which is also unbuilt —

The D4 verification (Q1) — still the critical open

The RFC's blocking pre-work (does order_log decrement remaining_quantity on partial fills?) gates the snapshotter, and the snapshotter is exactly what's missing. Everything downstream shipped against a stubbed/empty snapshot stream; the CH table's shape is fixed but no code fills it. Q1 remains the top blocker for a live Part 1, unchanged from the original RFC.

Naming collision - resolved (was D "MMLP overloaded")

The collision is resolved: MMLPv1 was removed in full (A-4812), so only one MMLP remains.

mmlp_routes.rs and admin_mmlp_routes.rs now serve only this program, at /mmlp/v2 (client) and /admin/mmlp/v2 (admin). The MMLP_* env prefix is likewise unambiguous, and every var under it belongs to this program.


The two-part split (read this first)

The build ships as two independent parts, and Part 2 is optional.

Why the seam is here and not elsewhere: the accrual ledger is the clean contract between the two. Part 1's output is a row saying "account X earned $Y for epoch Z, computed and immutable"; Part 2's only job is to credit that row exactly once and mark it paid. Part 1 can run in production for weeks — accruing and displaying — with Part 2 entirely dark, and nothing about Part 1 assumes cash ever moves. This mirrors the partner-rebate-payouts accrual→sweep seam, and is deliberately the same shared plumbing (see D7).

The rest of this RFC labels every table, task, and ticket Part 1 or Part 2.


Builds on A-4316 — the executable MMLP policy (notes, 2026-08-04) — SUPERSEDED

Superseded (2026-08-12). A-4316 / PR #3291 (ax-policy-mmlp) never merged. Rather than depend on it, the team wrote a fresh core crate ax-mmlp2 (rs/sdk-internal/mmlp2/, #3533 / A-4496) that owns the scorer, waterfall, gates, and params directly — the "interim stub → swap in Andrew's crate" seam described below never materialized because there was no crate to swap in. The reasoning below (don't fork the rules, keep one implementation) still holds in spirit: ax-mmlp2 is now that one implementation, and the customer doc should be generated from it. The A-4316 coordination questions are moot; the live question is whether ax-mmlp2 becomes the doc-generation oracle. The rest of this section is retained for historical context only.

The program's real name is MMLP (Market Maker Liquidity Program), and its rules already exist as code: A-4316 / PR #3291 ships rs/policy/mmlp — the scorer, the settlement math, and the params, default-features = false as the "normative core… the surface a production scorer/payout engine should depend on." The customer doc (the "PDF" this RFC's D2 distrusted) is generated from that code; every worked example is computed by the same functions. This engine builds on that crate — it does not re-implement the rules.

Decision: build on A-4316, don't fork the math. Re-writing scoring/settlement would create two implementations of the rules — the doc says one thing, the engine another — which is the exact drift "codeslaw" exists to prevent.

What is "the rules" (import from ax-policy-mmlp, never fork):

What is ours (everything around the rules):

Start now, leave scoring/settlement a seam. ~80% of the engine (book acquisition, tables, snapshotter/settler scaffolding, params, API, payout) needs no math and can be built before A-4316 lands. The one open seam is a score_snapshot(params, book) -> shares trait with a stub; fill it with ax-policy-mmlp when the PR merges. Never write "our version" of the scorer even temporarily — the interim stub returns fake scores for tests, not a second rulebook.

Params source — now → later (the codeslaw end-state):

Also settled by reading the crate:

Naming collision (resolved by A-4812): "MMLP" was overloaded — mm_liquidity_performance (quoting-obligation monitoring/reporting, no cash) vs rs/policy/mmlp (this: quote-quality rewards). The performance program has since been removed; see the status section above.

Coordination questions for Andrew (gate, phase 0): (1) A-4316 merge timeline, or do we stack on alee/mmlp-executable-policy? (2) does the normative core stay in rs/policy/mmlp or graduate to sdk-internal for a runtime dependency? (3) is ProductParams / score_snapshot / DailySettlement the frozen API we build against?

Consequence for the decisions below: D2 is largely resolved — the crate is the oracle (pin its output), not "re-derive the AI examples." Phase T1 flips from "write the scorer" to "depend on ax-policy-mmlp + build the book adapter."


Decision-first summary

Seven decisions gate the build: the three the rollout thread flagged, the architectural fork the spec leaves open, two v1 scope cuts, and the payout-automation split (D7) that structures the whole PR into the two parts above.

D1 — q_min gate is per individual order, matching the published PDF

The published PDF (§3.1) states sizes are not aggregated across a trader's orders "even at the same price level" — each order is judged individually against q_min. An earlier draft of this RFC recommended the opposite (sum size across a level before gating) for order-splitting invariance; that was reversed to keep the engine faithful to the customer-facing spec, which asserts its own worked numbers are computed by the implementation.

Decision: gate each order individually — an order scores only if its own size ≥ q_min (and d ≤ v); sub-q_min orders are dropped before the side sum. The load-bearing observation still holds that the gate is the only place aggregation matters: all orders at one price share the same distance d, so Σ w·qᵢ = w·Σqᵢ and the score of the surviving orders is unchanged whether summed per-order or per-level. The engine therefore still aggregates qualifying size per (account, side, price) for the score — it just applies the q_min gate to each qᵢ first, not to Σqᵢ. The cost is the loss of split-invariance (a fat-finger split below q_min silently forfeits, and fragmentation is penalized); the benefit is that engine and doc cannot diverge.

At q_min = 1 (shipping today) the two rules coincide — every integer-contract order qualifies individually — so this reversal is behavior-neutral for current params and only bites once a product publishes q_min ≥ 2. Pinned in the scorer test suite so the rule cannot silently regress.

D2 — The ax-policy-mmlp crate is the oracle (superseded by A-4316)

Original framing: the PDF's worked examples were AI-generated and not hand-verified, so re-derive the math and don't trust the tables.

Resolved by A-4316: the doc is now generated from rs/policy/mmlp, and every worked example is computed by the same scoring.rs/payout.rs functions the engine will call — so the crate, not the PDF and not a hand re-derivation, is the source of truth. Recommendation: depend on the crate and pin its output as the insta fixtures; the engine and the doc can never disagree because they run the same code. (As a sanity check this RFC hand-re-derived Examples 1 and 3 and they hold — Ex1 Alice 0.8 / Bob 0.2; Ex3's two-round waterfall conserving to $166.67 with `$66.66 / $66.66 / $33.33` paid — consistent with the crate being correct.) See "Builds on A-4316" above.

D3 — Combined-take interaction with the fee engine: no combined cap in v1; ship an observability metric instead

The program is additive to fee tiers, so a barely-trading MM on a thin product can collect program cash and deep fee discounts at once; nothing caps the sum. Andrew floated reintroducing a −0.5bps top maker tier; Brett vetoed any negative-fee tier ("introducing −0.5bp makes us lose money").

Recommendation: v1 keeps the program purely additive, adds a per-(MM, product) combined_take metric (program cash earned + notional value of the fee discount vs. the top public maker rate), and defers any cap to a data-driven fast-follow. Negative-fee tiers are out (Brett's veto stands). Rationale: a combined cap couples two independently-budgeted systems and forces a contestable definition of "discount relative to what baseline"; meanwhile the program's own 40% anti-concentration cap and the fixed per-product monthly pool already bound total cash outflow with certainty. The right first move is to measure whether double-dipping is real before building machinery to cap it — the metric is cheap and answers the question. If the data shows abuse, a combined cap or a claw at settlement time slots in without touching the scorer.

D4 — Book acquisition: reconstruct the book from order_log post-hoc, don't stand up a live account-aware snapshotter (pending one verification)

This is the central architectural fork and the spec is silent on it. Scoring is per trader, which needs per-order → account attribution at the sample instant. The public L3 book in marketdata-publisher (rs/marketdata-publisher/src/lib.rs) carries no account identity; EP3 is the source of truth for resting orders; order-gateway holds per-account OpenOrders in memory (rs/order-gateway/src/open_orders.rs) but only as live state, not a queryable as-of snapshot.

Two options:

Recommendation: Option B, contingent on verifying order_log captures intra-life resting-size changes (partial fills). Post-hoc random sampling is strictly more gaming-resistant than a live snapshotter — a trader cannot observe or be tipped off to the sample instant if it is chosen after the minute closes — and it reuses recon-engine + ClickHouse with zero new always-on writers, and it is deterministically replayable for disputes (recompute a whole day from the log). The one risk is precision: the comment at db/clickhouse/init.sql:67-81 says only order_state = Pending carries full details and rows are emitted for state transitions, so partial fills may not decrement resting size in order_log — which would make reconstructed resting size stale between placement and terminal. Verification task (blocking): confirm whether partial fills emit order_log rows with updated remaining_quantity. If yes → Option B as-is. If no → the cheaper fix is to enrich order_log (or a sibling resting_order_events stream) with resting-size deltas — far less than a new live service — and only fall back to Option A if enrichment proves infeasible. The market_snapshots best-bid/offer sampler (db/clickhouse/init.sql:319-336) is the precedent for per-symbol snapshot cadence and an independent cross-check on the reconstructed mark.

D5 — B(t) bonus multiplier: engine in v1, window admin UI fast-follow

The payout formula weights snapshots by B(t). Ship the weighted-average formula in v1 with all windows defaulting to B(t) = 1 (the spec's default), so payouts are correct from day one with no window config. The admin surface to define non-unit windows is a fast-follow — it changes incentives, not correctness, and no product needs a non-unit window on Sept 1.

D6 — Participant tools (rewards dashboard + on-book qualifying-band highlight): fast-follow, not v1-critical

The spec gates these tools to participants who earned ≥1 reward in the trailing 30 days — which by definition nobody has on Sept 1. Scoring → accrual → published-params API are v1-critical; the dashboard and the on-book band highlight can land in the weeks after launch. But note: a thin "accrued today" read-out is v1-valuable — with Part 2 dark (D7), the accrual number is the only proof to a participant that the program is working, so a minimal accrued-balance view ships in Part 1 even though the full gated dashboard is fast-follow.

D7 — Payout automation: split the PR — Part 1 accrues, Part 2 credits (optional, human-gated)

The spec tells customers rewards are "credited to the customer's AX account at the end of each daily epoch." But a scheduled liquidity-reward job that moves cash still does not exist in AX. The transaction-engine Deposit mechanism exists, and treasury-engine now has shared settlement consumers for manual and automated Anchorage deposit decisions: treasury_engine.deposit_credit_attempts records immutable request identity, while treasury_engine.deposit_credits owns the canonical one-effect-per-deposit result and its transaction_event_id. Settlement writes ClickHouse before committing Postgres; a later Postgres commit failure can leave an orphan ClickHouse row for reconciliation. That core is deposit-specific and is not runtime wiring for a liquidity sweep; the existing ax_scheduler::run_periodic jobs still only compute and update rows (fee refresh, leaderboard). The partner-rebate monthly sweep is RFC-stage, blocked on tax/withholding, currency (USDfiat vs USDC), and approval policy. Reward cash is plausibly 1099-reportable — a Finance/Legal gate, not an eng one.

Recommendation: split the build. Part 1 (v1) computes and writes an immutable accrual ledger row per (epoch, symbol, account) and moves no money. Part 2 (separate, later, optional) credits approved accruals — starting human-gated (an admin approves/pays a closed period via an endpoint), graduating to an automatic daily sweep only after tax + currency + approval policy clear. Do not build a liquidity-specific credit path at all — Part 2 is an adapter over the generic program-payouts module (a payout_program_config row for program_kind = Liquidity + enqueuing approved accruals into program_payout_requests); the same module serves referral and treasury deposits.

Tradeoff / why: the spec promised daily auto-credit, so there is a promise-vs-readiness gap to surface to whoever owns the program. But splitting de-risks Sept 1 decisively — Part 1 is un-gated pure-compute + read APIs and fully honors "scoring is live, params are published, you can see what you earned," while the genuinely novel, Finance-gated cash-mover is decoupled and can mature on its own clock without a hard date on it. Building the sweep once, deliberately, as shared infra also stops us paying for "robot pays cash" plumbing three times (liquidity, partner-rebate, treasury deposits). The cost is that an accrual is not a payment: if Part 2 slips, participants have earned visible balances they can't yet withdraw — manageable with a human-approved manual pay per period in the interim, which Part 2's first increment delivers.

Net Part 1 (v1) scope: book reconstruction + scorer + two-sided min + normalization + B(t)-weighted daily settlement + waterfall + minimum-payout + floor-to-cent → immutable accrual ledger + published-params API + a minimal accrued read-out + conservation invariant + alerting. No cash movement.

Net Part 2 (later, optional) scope: the shared credit path — idempotent payout ledger, double-entry Deposit, an admin approve/pay endpoint (human-gated), then a scheduled sweep — plus the tax/currency/approval decisions that gate it.

Fast-follow (either part): full rewards dashboard, on-book band highlight, B(t) window admin, combined-take cap (if the metric justifies it).


Background

What the program pays for

Per the spec (re-stated so this RFC is self-contained; §-refs are to the customer notice):

What exists to build on

The good news is that almost every primitive already exists; the genuinely new code is the scorer, the waterfall, the payout ledger, and the params surface.


Goals

Non-Goals


Design

The pipeline is five stages. The first four are Part 1 (three pure functions + one book-reconstruction read); the accrual ledger is the seam; the fifth stage is Part 2 and is optional/gated (D7).

 order_log (ClickHouse)                    published params (Postgres, versioned)
        │                                          │
        ▼   [random instant t, per minute]         │
  reconstruct_book(symbol, t)  ──────────────┐     │            ┌─ PART 1 (v1) ─ no cash moves
        │  resting orders w/ account attrib  │     │            │
        ▼                                    ▼     ▼            │
  score_snapshot(book, params) ── pure ──▶ per-trader S_bids/S_asks/S_total, N_i, is_scoring
        │                                                        │
        ▼   append per (symbol, epoch_start_ts, snapshot_minute, account)│
  mmlp.liquidity_snapshots (ClickHouse, recomputable)            │
        │                                                        │
        ▼   [at configured epoch close]                          │
  settle_day(snapshot rows, P_daily, B(t)) ── pure ──▶ weights   │
        │                                                        │
        ▼                                                        │
  cap_waterfall + min-payout + floor-to-cent ── pure ──▶ computed $
        │                                                        │
        ▼   write immutable computed amount                      │
  ══ accrual_engine.liquidity_accruals (Postgres) ═ THE SEAM ═══┘
        │
        ▼   ┌─ PART 2 (later, optional, human-gated — D7) ─────────
        │   │  admin approves a closed period  ─or─  scheduled sweep
        ▼   │        (blocked on tax / currency / approval)
  credit USD (transaction-engine, double-entry) + liquidity_payouts ledger (idempotent)

1. Book reconstruction (D4, Option B)

A recon-engine periodic task (liquidity_snapshotter, modeled on refresh_fee_rates_task) runs on a 1-minute tick. For each minute it picks a random instant within the just-closed minute (so the sample cannot be anticipated), and for each program-enrolled symbol reconstructs the resting book as-of that instant from order_log:

resting orders at t = { o in order_log[symbol]
                        : o entered working state at ≤ t
                        ∧ o has no terminal event at ≤ t }
each carries (account_id, side, price, resting_qty, distance-computable from mark)

The mark p_mark = (best_bid + best_offer)/2 is computed from the reconstructed book's best levels (raw best per spec §3.1, not filtered by q_min — see Risk R1), cross-checked against market_snapshots at the nearest timestamp. No valid mark → the snapshot is marked non-scoring and produces no score rows.

resting_qty is the crux precision question (D4): if order_log does not decrement size on partial fills, reconstructed size is stale for partially-filled resting orders. Resolve via the D4 verification before this task ships.

Randomness note: Math.random/wall-clock entropy is fine in a live service (unlike the deterministic workflow context); seed from the OS RNG, and record the chosen instant on the snapshot row so the day is replayable.

2. The scorer — a pure core

All scoring is one pure function with no I/O, in rs/sdk-internal (shared, and reachable by the public rulebook doc-gen without leaking datastores per the SDK rule):

// rs/sdk-internal/src/liquidity_program/score.rs
pub struct ProgramParams { pub v_bps: Decimal, pub q_min: Decimal /* + b_window lookup */ }
pub struct RestingOrder { pub account: AccountId, pub side: Side, pub price: Decimal, pub qty: Decimal }

pub struct SnapshotScore { pub per_trader: HashMap<AccountId, TraderScore>, pub total: Decimal }
pub struct TraderScore { pub s_bids: Decimal, pub s_asks: Decimal, pub s_total: Decimal, pub n_share: Decimal }

/// None => non-scoring snapshot (no valid mark, or Σ S_total == 0).
pub fn score_snapshot(book: &[RestingOrder], mark: Option<Decimal>, p: &ProgramParams) -> Option<SnapshotScore>;

Internals, exactly per spec:

  1. mark? — bail to None if absent.
  2. Gate each order individually on q_min (D1): drop any order with qty < q_min, then sum the surviving qualifying size per (account, side, price).
  3. Per surviving level: d = |price − mark| / mark × 10_000 bps; skip if d > v; else w = ((v − d)/v)² (note d = v ⇒ w = 0, inclusive but zero — Frank in Example 2), s = w × Σ(qualifying qty).
  4. Per trader: s_bids, s_asks, s_total = min(s_bids, s_asks).
  5. total = Σ s_total; if total == 0 → None (non-scoring). Else n_share = s_total / total.

Decimal throughout (rust_decimal), never float — bps distances and the quadratic must be exact to match the pinned snapshots. Guard the /v and /total divisions (v > 0 from params validation; total > 0 checked).

3. Daily settlement + the waterfall — pure

At configured epoch close, a second recon-engine task aggregates the epoch's mmlp.liquidity_snapshots per (symbol, account) into a B(t)-weighted average, then runs the cap waterfall, min-payout gate, and floor-to-cent — all pure:

// rs/sdk-internal/src/liquidity_program/settle.rs
pub fn daily_weights(snapshots: &[SnapshotRow], b: &BonusSchedule) -> HashMap<AccountId, Decimal>; // Σ N·B / Σ B

/// Iterative pro-rata waterfall (spec Appendix A). Cap C = 0.40 × pool, fixed for the run.
/// Bounded (≤ n rounds), order-independent, conserving (payouts + retained == pool).
pub fn cap_waterfall(weights: &HashMap<AccountId, Decimal>, pool: Decimal) -> WaterfallResult;

pub struct WaterfallResult { pub allocations: HashMap<AccountId, Decimal>, pub retained: Decimal }

Coverage does not prorate the pool (2026-09-03). The pool fed to the waterfall is the full P_daily, whatever the epoch's coverage. Weights normalize over the minutes that actually scored, which is exactly what §3.3 describes — "non-scoring snapshots count toward neither the numerator nor the denominator" — so each day's pool is distributed entirely across the minutes where someone provided two-sided liquidity, and a day with zero scoring snapshots pays nothing because it is never settled. Conservation reads accrued + forfeited + retained == P_daily, and the cap C = 40% × P_daily and the min($10, 1% × P_daily) threshold are computed against that same full pool (Appendix A, Example 3).

Coverage remains a first-class operational figure, not a payout input. expected is the epoch's DST-adjusted minute count less the daily 15:00–16:00 US/Central maintenance hour (1380, or 1320/1440 across DST; MAINTENANCE_WINDOW in epoch.rs, since no book exists to score in it) and covered is the number of scoring minutes present outside that hour; rows scored inside it are ignored and logged (scoring_rows in the pure core, ahead of epoch_pool), so the numerator and denominator leave out the same hour by construction. Over-coverage therefore means duplicate rows only, and is still refused rather than clamped. Both figures are displayed on the admin page and alerted on, and MMLP_SETTLE_MIN_COVERAGE_PCT can hold a thin epoch back from settling entirely — but neither ever pays a reduced amount. The accepted trade-off is that a genuine sampling outage concentrates a whole day's pool on whoever quoted through it.

An earlier revision of this RFC specified P_daily × covered / expected as an engine rule on top of the spec; it was removed before the settler was enabled on prod, because it paid off-terms on every symbol that is not quoted around the clock.

The waterfall, verbatim from Appendix A: start with the full pool R and all positive-weight traders active; provisionally allocate R pro-rata by weight among the active set; any allocation > C is fixed at exactly C, removed from active, C deducted from R; repeat until no active allocation exceeds C, or the active set empties (remainder retained). Because every violator in a round is compared against the same fixed constant C, the result is order-independent — implement with a stable pass over a sorted-by-account set so the same input always produces byte-identical output.

Then, per surviving trader: min-payout gate accrued ≥ min($10, 0.01 × P_daily) evaluated on the exact pre-round amount (sub-threshold → forfeit, retained); then floor to cent (sub-cent residue retained). daily_weights, cap_waterfall, and the gates compose into one settle_day that returns, per trader: pre_cap_usd, post_cap_usd, accrued_usd (floored), and per-epoch totals of forfeited + retained for the conservation invariant.

4. The accrual ledger — the Part 1 / Part 2 seam (Part 1)

settle_day's output is written to accrual_engine.liquidity_accruals (Postgres) — one immutable row per (epoch_start_ts, symbol, account): epoch_start_ts, epoch_end_ts, pre_cap_usd, accrued_usd, and a status (ACCRUED for payees, FORFEITED for sub-min-payout traders; later APPROVED/PAID). This is the extent of Part 1's write side: it records what was earned and moves no money. The row is the contract handed to Part 2. There is no separate roll-up table — the conservation invariant is derived: retained = P_daily − Σ accrued_usd (above-cap + floor residue), and paid/forfeited totals are sums by status. accruals + mmlp.liquidity_programs is the single source, so accrued + forfeited + retained == P_daily holds without any cash path or a second table.

Immutability, and why settlement never revises. A row, once written, never moves — that is what makes the seam safe for a Part 2 that may already have credited it. An epoch is only ever settled once, so nothing needs to revise it: settle once, ON CONFLICT DO NOTHING. A re-run is a no-op and a racing writer collides on the PK. What makes settling once safe is a grace period: an epoch is not a candidate until epoch_end_ts <= now − grace, with grace ≥ the snapshotter's lookback (enforced at config load). The snapshotter only backfills inside its lookback, and a pass nets at least one minute, so a full lookback of backlog drains within one lookback. Past the grace, coverage is final. Without it an epoch could settle mid-backfill and permanently retain the pool the late minutes would have paid. An out-of-band re-score (an ad-hoc re-sample over an older window, a manual correction) is deliberately not repaired automatically — for an immutable money ledger that is an operator decision, not a background task.

An earlier draft kept a revision column in the PK as the place such a correction would land; nothing ever wrote a non-zero one and it was dropped (#3998). The PK is (epoch_start_ts, symbol, account_id), and a correction is a manual ledger operation, not a second row.

5. USD credit — idempotent, floor-to-cent (Part 2, optional, D7)

Everything below moves cash and is Part 2 — gated per D7, shippable weeks after Part 1, and built as shared plumbing (the same path partner-rebate needs), not liquidity-specific. It reuses the partner-rebate-payouts design.

6. Published params + admin surface (Part 1)

7. Placement & singleton discipline

The Part 1 tasks (snapshotter, settler) live in recon-engine for the same reasons the fee engine does: it already carries the Postgres + ClickHouse pools, the cron scheduler, and the incident.io sink, and "engine that writes exchange state" wants one clear owner. They are periodic tasks alongside refresh_fee_rates_task, not InvariantChecks (they produce state, not verify it) — though a self-recon invariant should be added (§Testing) so recon-engine polices its own conservation. If D4 forces Option A (a live subscriber), that subscriber must be an obligate singleton and should still write into recon-engine's tables rather than settle directly.

The Part 2 credit path is separable by construction (D7): it reads accrual_engine.liquidity_accruals and writes cash. It can start as an admin endpoint in api-gateway (human-gated pay) and later graduate to a run_periodic sweep — hosted wherever the shared partner-rebate sweep lands, since it is the same plumbing. Keeping the writer (Part 1) and the payer (Part 2) in separate services with the ledger between them is the whole point of the seam.


Data model

Following the "high-volume append → ClickHouse; params/ledger → Postgres" split, and the declarative Atlas convention (db/postgres/1.sql; ClickHouse in db/clickhouse/init.sql). Four tables — deliberately minimal (see the schema-simplification note below for the three merges that got us here from a naive six).

Table Part Store Why Shape (key columns)
mmlp.liquidity_snapshots 1 ClickHouse ~1,440 snapshots × ~35 products × N traders/day, recomputable, queried by (symbol, epoch_start_ts) at settlement symbol, epoch_start_ts, epoch_end_ts, snapshot_minute (0–1439 offset from epoch_start_ts), snapshot_ts_ns (the recorded random instant), account_id, account_score, snapshot_score_total (the denominator), bonus_multiplier, computed_at. ReplacingMergeTree(computed_at) on (symbol, epoch_start_ts, snapshot_minute, account_id) ⇒ latest recompute wins; settlement picks each minute's newest (computed_at, snapshot_ts_ns) with a window function rather than FINAL, which would keep the participants an earlier pass had and a later one didn't. n_share = account_score / snapshot_score_total is derived at read when the total is positive, not stored; s_bids/s_asks are debug-only and not persisted. Rows are written for participating accounts; account_score = 0 means participation without score, while absence means no participation.
mmlp.liquidity_programs 1 Postgres low-volume, versioned, joined at settlement + served to API; disputes need point-in-time symbol, effective_at TIMESTAMPTZ, pool_usd NUMERIC, v_bps NUMERIC, q_min NUMERIC, bonus_windows JSONB (PgJson<Vec<BonusWindow>>, absent/empty ⇒ B(t)=1), created_at. Immutable by (symbol, effective_at).
accrual_engine.liquidity_accruals 1 Postgres the seam — immutable computed amount per (epoch, symbol, account); Part 1 writes it, Part 2 reads it. No cash. Also the sole conservation source. epoch_start_ts, epoch_end_ts, symbol, account_id, pre_cap_usd NUMERIC (uncapped reward amount), accrued_usd NUMERIC (post-cap allocation, floored), status TEXT (ACCRUED/APPROVED/PAID/FORFEITED — sub-min-payout traders stored as FORFEITED), computed_at, paid_at. Unique (epoch_start_ts, symbol, account). Immutable except allowed status transitions.
liquidity_payouts 2 Postgres the money ledger — PK idempotency, freeze trigger, transactional credit. Only table Part 2 adds. Kept separate to match the shared partner-rebate ledger shape (D7). idempotency_key TEXT PK (ax-liquidity://…), epoch_start_ts, symbol, account_id, accrued_usd NUMERIC, paid_usd NUMERIC, outcome TEXT (CREDITED/FAILED), transaction_event_id TEXT UNIQUE, actor, reason, attempt_ts. Append-only.

Money is rust_decimal::Decimal in Rust / NUMERIC in Postgres / Decimal128(12) in ClickHouse, consistent with the transaction engine (BALANCE_DECIMAL_PLACES = 12). Part 1 ships three tables and writes zero money; Part 2 adds only liquidity_payouts.

Schema-simplification note

A naive schema wants six tables; three collapse without losing anything:

Not folded: liquidity_payouts into liquidity_accruals. They are 1:1 and a status compare-and-swap (UPDATE … WHERE status='APPROVED') would give exactly-once credit with the immutable audit relocated to the transaction-engine transactions ledger — dropping Part 2 to zero new tables (total three). Rejected for v1 because D7 wants the credit path built as shared partner-rebate plumbing, and matching that RFC's separate append-only idempotency ledger keeps the two programs on one code path. Revisit if the credit path turns out not to be shared.


Config / env

Most tunables are per-product in the DB (P, v, q_min, B(t)) — a params edit, not a deploy. Global engine knobs in recon-engine config (rs/recon-engine/src/config.rs, alongside FeeRatesConfig):

Knob Default Notes
snapshot cadence 60 s one random instant per minute per symbol
epoch boundary 15:00 US/Central diverges from the fee engine's UTC-daily cadence — needs a Central-time epoch helper; DST-aware (Central shifts vs. UTC across the year). Store epoch_start_ts/epoch_end_ts on written rows so historical epochs stay self-describing if the configured boundary changes. Reuse/extend calendar_math.rs.
anti-concentration cap 0.40 spec-fixed; config only for tests
min-payout formula min($10, 0.01 × P_daily) spec-fixed
settlement precision floor-to-cent (2 dp) ledger stores full precision; credit floors
staleness / reconstruction lookback (from D4 verification) how far back order_log is scanned to establish resting state at t
dry-run mode on (rollout) compute + write scores/ledger rows with outcome set, but do not credit — reconcile before flipping live

Per-product enrollment: a symbol is in the program iff it has a mmlp.liquidity_programs row with the greatest effective_at <= now — absent ⇒ invisible to the snapshotter (mirrors the fee engine's WHERE fee_mode='auto' gating and the auto-band config absent ⇒ skip pattern).


Integration points

Component Part Change
rs/sdk-internal/src/liquidity_program/{score,settle}.rs 1 new — the pure scorer + waterfall + gates (shared, SDK-clean)
rs/sdk-internal/src/liquidity_program/params.rs 1 new — ProgramParams, BonusSchedule
rs/recon-engine/src/liquidity/snapshotter.rs 1 new — 1-min tick, book reconstruction from order_log, mark, calls scorer, appends mmlp.liquidity_snapshots
rs/recon-engine/src/liquidity/settle.rs 1 new — epoch-close aggregation, waterfall, gates → writes accrual_engine.liquidity_accruals (no cash)
rs/recon-engine/src/checks/liquidity_conservation.rs 1 new InvariantCheck — accrued + forfeited + retained == P_daily per settled epoch
rs/sdk-internal/db/src/entities.rs 1 new DbLiquidityProgram (w/ BonusWindow serde type), DbLiquidityAccrual (Part 1); DbLiquidityPayout (Part 2)
db/postgres/1.sql 1 2 new Postgres tables (mmlp.liquidity_programs w/ bonus_windows JSONB, accrual_engine.liquidity_accruals)
db/clickhouse/init.sql 1 mmlp.liquidity_snapshots (ReplacingMergeTree(computed_at))
rs/api-gateway/src/public_routes.rs + rs/sdk/src/types/trading.rs 1 publish P, v, q_min, B(t) on /instruments / Instrument (SDK-clean)
rs/api-gateway/src/admin_routes.rs 1 admin CRUD for params + bonus windows (mirror fee-schedules)
gui/apps/admin/src/pages/liquidity-program/ 1 new admin params page (mirror FeePrograms.tsx) + minimal accrued read-out
db/clickhouse/init.sql order_log 1 possibly enriched with partial-fill resting-size deltas (D4 verification outcome)
db/postgres/1.sql liquidity_payouts 2 idempotent payout ledger + freeze trigger
rs/transaction-engine 2 new system account AX_LIQUIDITY_PROGRAM_ACCOUNT_ID (or reuse AX_FEE_ACCOUNT_ID, Q4); no logic change (reuse Deposit)
rs/api-gateway/src/admin_routes.rs 2 POST /admin/liquidity/approve + pay (human-gated credit)
generic program-payouts module 2 Part 2 = an adapter (config row + enqueue program_payout_requests); the shared sweep credits, after tax/currency/approval clear. No liquidity-specific credit code.
gui participant surfaces ff fast-follow — full rewards dashboard + on-book qualifying-band highlight (D6)

Migration


Testing

Per the house insta-inline convention and the lifecycle-path rule.

Pure-core snapshots (re-derived, not copied from the PDF — D2):

Integration / lifecycle (CLAUDE.md rule — the snapshotter reads live-ish book state):


Rollout

Sequenced against Sept 1. Part 1 alone satisfies the Sept 1 promise (scoring live, params published, accruals visible). Part 2 runs on its own clock behind the Finance/Legal gates and has no hard date.

Part 1 — scoring & accrual (targets Sept 1):

  1. Pre-work (blocking): resolve D4's order_log verification. (D1 is settled — the engine gates per-order, matching the PDF; no doc correction owed.) Gate everything; do it first.
  2. Land the pure core (sdk-internal scorer + waterfall + gates) with the full re-derived snapshot suite. No I/O, no deploy risk — correctness is won here.
  3. Data model + params backfill (mmlp.liquidity_programs w/ bonus_windows JSONB, accrual_engine.liquidity_accruals, ClickHouse mmlp.liquidity_snapshots + Appendix B 2026-09 rows). Deploy dark.
  4. Snapshotter in dry-run, every Appendix-B symbol: reconstruct + score + append, no settlement. Verify reconstructed marks track market_snapshots. Validates D4 in prod shape.
  5. Settler live to the accrual ledger: write accrual_engine.liquidity_accruals + conservation invariant. This "goes live" without moving a cent — it is safe to enable broadly early. Reconcile a full week; eyeball combined-take (D3 metric).
  6. Publish params on the public API + admin params page + a minimal accrued read-out — the customer "all params in API/GUI/docs" + "see what you earned" promise is live by Sept 1.

Part 2 — payout & credit (optional, no hard date, D7):

  1. Resolve the gates: tax/withholding, currency (USDfiat vs USDC), approval policy (Q7). Until then, Part 2 does not ship.
  2. Human-gated pay first: POST /admin/liquidity/approve + a manual pay over one closed period on one low-pool Bootstrap symbol (3, 500/mo⇒ 45/day cap). Soak, watch liquidity_payouts + credited balances + the ledger-squares-with-transactions check.
  3. Automatic sweep, built as shared partner-rebate plumbing, once the manual path is proven; widen product-by-product.

Fast-follow (either part): full rewards dashboard + on-book highlight (D6), B(t) window admin (D5), combined-take cap (D3) only if the metric shows abuse.


Open questions / risks

Decisions still needing a human sign-off

Risks / adversarial surface


Proposed epic + ticket breakdown

Per CLAUDE.md, run the linear-workflow skill and confirm before creating anything. Proposed shape: one epic, two sub-epics (Part 1 / Part 2) so Part 2 can be scheduled independently.

Part 1 — Scoring & accrual (v1): (status as of 2026-08-12 — see Implementation status)

Part 2 — Payout & credit (optional, gated): none built — blocked on the program-payouts module (also unbuilt) and the Finance/Legal gates.

Fast-follow: none built.

Remaining critical path to a live Part 1: (1) resolve Q1 / D4 (order_log partial-fill precision); (2) build the snapshotter/book-reconstruction producer that fills mmlp.liquidity_snapshots; (3) add the conservation InvariantCheck; (4) wire real admin HTTP CRUD behind the GUI shell and publish params on /instruments. Only then does the shipped settler produce non-empty accruals.


Appendix: source index

Concern Location
Order lifecycle event stream (book reconstruction, D4) db/clickhouse/init.sql:83-115 (order_log; caveat :67-81)
Best bid/offer sampler (mark cross-check) db/clickhouse/init.sql:319-336 (market_snapshots)
recon-engine check framework / context / scheduler rs/recon-engine/src/check.rs:14-20,51-71; rs/recon-engine/src/lib.rs:519-618; incident_io.rs
Fee-engine periodic task (loop template) rs/recon-engine/src/fees.rs; config rs/recon-engine/src/config.rs
Trailing-window accumulator sibling PR #3389, branch incremental-volumering-fee-tiering (rs/recon-engine/src/volume_ring.rs)
USD credit path / money type rs/transaction-engine/src/lib.rs:83,239,264,333; double-entry rs/transaction-engine/src/lending.rs:23-70
Balance / transaction ledgers db/postgres/1.sql:166-174 (current_balances); db/clickhouse/init.sql:288-317 (transactions)
Idempotent-payout precedent db/postgres/1.sql:1475-1510 (treasury_engine.deposit_credit_attempts); partner-rebate-payouts
System fee account rs/sdk-internal/src/account_id.rs:10 (AX_FEE_ACCOUNT_ID)
Public instruments endpoint / SDK type rs/api-gateway/src/public_routes.rs:204-264; rs/sdk/src/types/trading.rs:31-84
Typed discriminator enum pattern rs/sdk/src/types/trading.rs:138-162 (InstrumentCategory); rs/sdk-internal/src/fee_program.rs:16-35 (FeeWindowShape)
Per-instrument PgJson config pattern rs/sdk-internal/db/src/entities.rs:709-759 (DbInstrument)
Fee-program admin surface (template) gui/apps/admin/src/pages/fee-programs/FeePrograms.tsx; rs/api-gateway/src/admin_routes.rs:2464-2527; DbFeeSchedule rs/sdk-internal/db/src/entities.rs:6838-6915
Mark / band infra (parity check) price-band-automation
Fee-engine plan (additive sibling) A-4094 (shipped; plan retired)

Spec: customer notice "AX Liquidity Program — Effective September 1, 2026" (Appendix A cap waterfall, Appendix B pools). Examples 1 and 3 independently re-derived in this RFC and confirmed consistent with the formulas; all other tables to be re-derived, not trusted, per D2. Code references as of branch calgary.