RFC: Accrual Engine — a shared library for incentive-program compute

Date: 2026-08-05

Status: Draft — deferred. No producer is being built against this design. The commercial programs specify their own producer in commercial-program-accruals, and the liquidity program shipped without this crate. Both keep the decisions settled here: the optional payee, the accrual-row shape, ledger placement, and the seam to program-payouts. Whether and when a shared engine is built is tracked on A-4647.

Author: (with Claude)

Related: the producer-side mirror of program-payouts, which owns the payer side. Hosts the accrual compute for referral-program, liquidity-program (Part 1), and the existing fee-tier, MMLP and leaderboard tasks. Follows the library-not-service pattern of ax-transaction-engine.

Concept

ax-accrual-engine is a library crate with no binary. It holds the plumbing every incentive program re-implements — windowed ClickHouse reads, ladder evaluation, eligibility resolution, and a provisional-then-frozen period upsert — and draws a line at the accrual row, where program-payouts takes over.

 RAW ACTIVITY            ACCRUAL (this RFC)              PAYOUT (program-payouts)
 ClickHouse trades  ┐
 fees collected     ├─► ax-accrual-engine ──► rebate_accruals     ┐
 resting quotes     ┘    (library; policy      liquidity_accruals ├─► requests ─► credit
                          per program)         (per-program       ┘   (uniform queue)
                                                tables, D4)

Each program keeps its own policy and its own accrual table. The engine owns the drive loop. The immutable accrual row is the seam between producer and payer.

Conditions for revisiting

Offered from the producer side as input, not as a gate. This design becomes worth building when:

  1. Two liability producers run in production. Generalizing over two independently built implementations is an extraction; over one it is a guess. Liquidity is the first. The commercial producer (commercial-program-accruals) is the second.
  2. A third producer is starting. Two justify the shape; the third pays for the refactor. With no new consumer it is rework of code that already works.
  3. The first two converged without coordinating. If they are structurally alike, the shared shape has been discovered and is safe to lift. If they diverged, the divergence is the finding, and a common trait would hide it.
  4. What is shared exceeds a trait and a drive loop. Each helper named below needs re-checking against the real producers: windowed ClickHouse reads are already shared (ChTradeRow::query_account_tier_metrics), the idempotent period upsert turned out program-specific, and eligibility resolution has one consumer. If the residue is accrue() plus upsert(), the crate boundary costs more than it returns.
  5. Library or service is settled before adoption is scheduled. As a library, adoption is a wrapper in place. As a service, it re-homes every producer — a deployment, config and on-call change rather than a code one.

Background

recon-engine runs two kinds of work

recon-engine runs both in one watch() loop:

  1. Invariant checks, around 22 of them — read-only assertions over Postgres and ClickHouse that fire incident.io alerts when the ledger disagrees with itself. This is the crate's stated purpose.
  2. Mutating cadence tasks, spawned alongside the checks in lib.rs: fees::refresh_fee_rates_task (daily) re-tiers every account against the fee schedule and writes trading_accounts.maker_fee / taker_fee; leaderboard::refresh_leaderboard_task (roughly every 15 minutes) recomputes leaderboard_entries. mm_reports::run generated MMLPv1 PDFs to S3 and was removed under A-4812.

An auditor that also mutates the state it audits mixes two responsibilities. The incoming programs add referral's rebates.rs and the liquidity scorer as a fourth and fifth writer.

The target shape exists next door

ax-transaction-engine is "an embedded transaction engine that can be used as a library": no binary, I/O behind a Store trait, hosted by four service binaries (api-gateway, settlement-engine, trade-engine2, btnl-trade-engine), with balance mutations serialized under a pg_advisory_xact_lock. The programs' accrual compute wants the same treatment: a deterministic core and a thin host.

Decisions

D1 — A library, not a service, hosted on the existing scheduler

ax-accrual-engine is a lib crate with no binary. Its I/O sits behind a trait, as ax-transaction-engine hides Postgres and ClickHouse behind Store, so the accrual math is deterministic and unit-testable without a live database. A host — recon-engine today, a dedicated incentives host or ax-scheduler later — drives it on cadence.

This makes the work a move of code behind a crate boundary rather than a new service, and it lets the auditor stop being a mutator without a big-bang split.

D2 — Scope is the accrual step only

The engine reads the raw activity (trades, quotes, fees collected), applies a program's policy, and writes a per-period accrual: account X is owed or owes $Y for period P. It does not move money.

That is the seam program-payouts draws: producers own what is owed, the payout module owns paying it. The liquidity RFC calls the same boundary its Part 1 / Part 2 seam. accrual-engine is the generalization of Part 1 across producers, as program-payouts is the generalization of Part 2.

D3 — Share the engine, not the policy

Each program keeps its own policy: ax-policy-mmlp for liquidity scoring, the fee-share waterfall for referral, the fee ladder for fee-tiering. The engine does not unify what a dollar is worth, which is program-specific and must not be forkable. It unifies the mechanics every program re-implements:

D4 — Share the code, not the table

program-payouts unifies the table because payout is uniform: every producer wants "credit $Y to account X, once." Accrual is the opposite — the columns and math differ per program. rebate_accruals carries partner and ladder-level fields; liquidity_accruals carries epoch, symbol and score fields.

So accrual-engine unifies the code path while each program keeps its own accrual table. Accrual math is program-specific, so share the library; payout is uniform, so share the table.

D5 — A normalized accrual row shape with a direction

Programs accrue in two directions:

Both share a lifecycle (ACCRUED → APPROVED → PAID / FORFEITED), an idempotency discipline, and a recompute-while-open rule. The engine models that lifecycle as a trait over a per-program row with a direction discriminator, rather than forcing one physical schema (D4). A liability accrual, once APPROVED, is what a producer hands to program-payouts.

D6 — Extract with a strangler, do not rewrite

The engine ships by extracting the plumbing already in recon-engine — the hysteresis in fees.rs, the windowed-read helpers — into the crate, then re-pointing the existing tasks at it. Behavior-preserving, snapshot-guarded. Referral and liquidity are then written against the crate. No task changes host in the first cut; only the code moves behind the boundary. Re-homing the host (D1) is a later, independent step.

D7 — Home the accrual ledgers in an accrual_engine Postgres schema

D4 keeps per-program accrual tables; D7 says where they live. One dedicated accrual_engine schema, following the domain-schema precedent (partner_programs, mm_liquidity_performance, treasury_engine). The engine's cross-program tables — a programs registry and a period_runs bookkeeping log — and each program's accrual ledger co-locate there, so the engine's grants and logical-replication scope are one schema rather than a table hunt.

The commercial producer does not follow this. rebate_accruals lives in partner_programs beside its sources (commercial-program-accruals D7, referral D8a). The liquidity program created accrual_engine for its own ledger. If a shared engine is built and wants the ledgers co-located, moving one table between schemas is an ALTER TABLE ... SET SCHEMA.

D8 — An accrual row's payee is optional, and a row with none is held by the producer

AccrualRow exposes payout_account() -> Option<AccountId>, not account_id() -> AccountId. None is a real and expected state: attribution binds a user and account provisioning is lazy (referral D2), so a partner can hold a referral code, earn a rebate, and own no trading account. That accrual is correct and the money is owed; there is nowhere to send it yet.

enqueue_approved therefore declines such a row rather than dropping it or inventing a payee, and returns EnqueueOutcome { enqueued, held } so the unpayable count is a reported figure and not a silent skip. The row stays APPROVED in the producer's ledger, and a later run enqueues it once a payout account exists.

The hold lives here, not in the payout module. The module's requests.account_id is NOT NULL REFERENCES trading_accounts(id), so a payee-less accrual cannot be represented in its queue even as HELD. The module's HELD covers a different case: a payee who exists but cannot yet receive, for want of KYB or because the account is restricted.

A Subject associated type on AccrualProgram was considered and dropped. The engine never computes with a subject; it only identifies a row for the log and the payout URN, which idempotency_key() already does.

Goals

Non-goals

Design

1. The crate

A lib crate. The public surface:

/// One program's accrual computation. Deterministic given its inputs;
/// I/O is injected via `Store` so policy is unit-testable without a DB.
pub trait AccrualProgram {
    type Row: AccrualRow;              // program-specific columns (D4)
    fn program_kind(&self) -> ProgramKind;
    async fn accrue(&self, ctx: &AccrualCtx<impl Store>, period: PeriodKey)
        -> Result<Vec<Self::Row>>;
}

pub trait AccrualRow {
    fn direction(&self) -> Direction;               // Expense | Liability (D5)
    fn payout_account(&self) -> Option<AccountId>;  // None = owed, no payee yet (D8)
    fn amount(&self) -> Decimal;
    fn currency(&self) -> Currency;
    fn status(&self) -> AccrualStatus;              // Accrued | Approved | Paid | Forfeited
    fn period_key(&self) -> String;
    fn idempotency_key(&self) -> String;            // `<program>://<period>/<subject>`
}

The engine owns the drive loop — upsert the open period and unapproved prior periods, provisional while open, frozen on approval — and the shared helpers below. Each program supplies accrue().

The row carries what the seam needs and nothing more. The last three accessors are what a payout request row is built from (§4), which is why there is no Subject associated type (D8).

2. Shared helpers

Extracted from what recon-engine and the two program RFCs need:

3. Store trait

Mirrors ax-transaction-engine's Store: a production PgChStore over Postgres and ClickHouse, plus in-memory fakes for property tests. Accrual math never touches a live connection in a unit test.

4. The seam to program-payouts

When a liability accrual reaches APPROVED, the engine enqueues the payout request row, with the funding account and currency read from the program's payout config. Enqueue is uniform across producers and belongs with the shared lifecycle. What stays with each producer is approval: which accruals become payable, and at what threshold.

Two kinds of approved row never leave the engine, and neither is an error:

The idempotency_key() a producer returns is the module's primary key, so it must carry its own program: <program_kind>://<period>/<subject>. The module enforces this with CHECK (idempotency_key LIKE program_kind || '://%'), which turns a cross-program collision — silent, and therefore a missing payment rather than an error — into a write failure.

5. Placement and host

First cut: recon-engine stays the host, because it already has the Postgres and ClickHouse pools and the ax-scheduler cadence loop, but the accrual tasks call into ax-accrual-engine instead of living inline (D6). This does not resolve recon's split responsibilities; it makes them fixable, because the mutating work becomes a crate the auditor calls, and can be lifted to another host by moving three tokio::spawn lines. Coordinate with program-payouts O6 on placement: the two modules likely want the same eventual host.

Data model

One dedicated accrual_engine Postgres schema (D7), holding the engine's own tables plus each program's accrual ledger:

Not held here: partner_programs.rebate_accruals, which lives with its sources (D7). Unchanged and not moved: fee-tiering writes trading_accounts.maker_fee / taker_fee, a rate with no ledger row; MMLP stays in mm_liquidity_performance; the leaderboard stays in leaderboard_entries.

Schema is declared in db/postgres/1.sql (Atlas, declarative), not as ad-hoc migration files.

Config

Cadence and window knobs move from recon-engine's WatchConfig into per-program config the host passes to the engine: fee hysteresis days, leaderboard refresh interval, accrual period. No new secrets. Funding-account and currency knobs stay in the payout program config, read at the seam.

Rollout

A strangler, behavior-preserving (D6). There is no data migration.

  1. Add the accrual_engine schema plus the programs and period_runs tables to db/postgres/1.sql, applied through just migrate-db <env>.
  2. Extract the fees.rs hysteresis and the windowed-read helpers into ax-accrual-engine; re-point refresh_fee_rates_task at the crate. Snapshot-guard the fee output: no rate should change.
  3. Write the liquidity scorer against the crate, landing its ledger in accrual_engine.
  4. Decide fee-tiering, leaderboard and MMLP membership (O1, O2); move or leave per that decision.
  5. Later, independent: lift the host off recon-engine (O3) if the split responsibilities still bite.

Testing

Open questions

Answered

Appendix: source index