SoW: EP3 booking core

Date: 2026-09-16

Related: block-trade-tickets, the manual fill stack #3714 to #3718 (A-4655), the delist command in rs/admin-cli, the runbook docs/runbooks/delisting-a-product.md.

Supersedes: the block-trades SoW, the EP3 booking journal SoW (#4147), the ROME RFC, and the ROME plan. This PR deletes them. The table-names SoW (#4149) is closed; its content is in the "Table names" section below.

Concept

One library books every two-sided trade at EP3. Three front ends call it. Each front end decides who trades, at what terms, and with whose consent. The core decides how the trade is journaled, sent, classified, reconciled, and confirmed.

   admin manual fill        block trade ticket         delist closeout
   api-gateway, M-          order-gateway, B-          admin-cli, F-
   operator enters terms    two parties affirm         one holder vs house
   explicit fees            fee schedule               zero fees
          |                        |                         |
          +------------------------+-------------------------+
                                   |
                                   v
                          EP3 booking core
              claim journal row -> InsertTwoSidedBlockTrade
                    -> classify reply -> ACCEPTED or FAILED
                                   |
                +------------------+------------------+
                v                  v                  v
          ep3_bookings         reconciler        trade-engine2
          Postgres journal     PENDING rows      pairs both legs
                               searched at EP3   from drop copy
                                                 -> BOOKED

The RFQ negotiation layer (ROME) is a fourth front end. An accepted quote produces a block trade ticket that enters the core through the ticket layer. It is described in block-trade-tickets.

The cross id prefix is the kind. M-, B-, and F- are distinct because downstream systems read the kind: the public tape drops F- prints, the ClickHouse trade row carries is_final_settlement, and the ticket blotter joins on B- ids.

The core has one lifecycle for every kind:

Status Meaning
PENDING Journal row claimed. The EP3 call is in flight, or a crash left it unresolved.
ACCEPTED EP3 accepted the request, by the synchronous reply or by the reconciler finding two non-rejected legs. Drop copy has not yet paired both legs.
BOOKED trade-engine2 paired both legs from drop copy in the same transaction as the resume token. Positions, fees, PnL, and ledger effects are durable.
FAILED EP3 rejected the request. A corrected request needs a new reference.
NEEDS_MANUAL_RECONCILIATION The outcome is unknown. An operator checks EP3 by cross id.

Idempotency is one rule for every kind. A reference_id maps to one cross id and one request_hash over the economic terms. The same reference with the same hash replays the stored outcome. The same reference with a different hash is rejected. A new reference books a new trade.

What exists on main

PR Merged What it added
#2368 2026-06-14 The bt wire types in rs/sdk-internal/src/protocol/block_trade.rs, the block_trades ticket table, the order-gateway drop-copy skip for block-trade legs, and the EP3 cross probe that fixed the booking conventions: the client cross_id is echoed on both orders, the sell leg is the aggressor, trade_id is exchange-minted.
#3012 2026-07-08 InsertTwoSidedBlockTrade in ep3-mock, pinned to the probe's observations.
#3049 2026-07-09 trading_accounts.block_trades_enabled and DbBlockTrade, a compare-and-set DAO over block_trades. The DAO has no caller.
#3111 2026-07-14 is_final_settlement on the ClickHouse trade row. trade-engine2 sets it and applies zero fees when the maker order's cross id starts with F-.
#3218 2026-07-22 marketdata-publisher drops F- prints from the public tape.
#3251 2026-07-25 The delist orchestrator and the final_settlements journal. One row per holder closeout, written before the EP3 call. A rerun resolves PENDING rows by searching EP3 orders on the cross id.
#3310 2026-07-27 delist-verify, the invariant suite that checks flat positions in EP3 and ClickHouse after a delist.
#3453 2026-08-05 A delist retry re-journals position and price under the same cross id.
#3710, #3932 2026-08-28, 2026-09-02 delist --force. Books closeouts directly when EP3 has purged the instrument. EP3 is not called.
#3856 2026-08-28 final_settlements columns renamed to venue_account, venue_order_ids, and human-unit price.

Three code paths call InsertTwoSidedBlockTrade on main today: delist.rs, the two admin-cli probes, and the block-trade helpers in rs/sdk-internal/src/protocol/block_trade.rs. None of them is the core.

Open PRs this SoW changes

The manual fill stack is the core. It is open and lands first, with the changes in phase 0 below.

PR Scope Change under this SoW
#3714 The journal table Table renamed to ep3_bookings. Fees nullable. Prefix check widened to M-, B-, F-.
#3715 ManualFillStatus, ValidatedManualFillTerms, the DAO DAO renamed to DbEp3Booking. ManualFillMetadata fees become Option<Decimal>.
#3716 The api-gateway route, the reconciler, trade-engine2 pairing trade-engine2 applies explicit fees when present and the rate lookup otherwise. No other change.
#3717 admin-cli manual-fill None.
#3718 The admin manual fill modal None.

#4149, the table-names SoW, is closed. #2391, the order-gateway ticket lifecycle, closed unmerged on 2026-07-09 and is the reference for the ticket layer in block-trade-tickets.

The journal

ep3_bookings replaces manual_fills and, after phase 3, final_settlements.

CREATE TABLE ep3_bookings (
    cross_id TEXT PRIMARY KEY,           -- M-, B-, or F- plus a 26-char ULID
    reference_id TEXT NOT NULL UNIQUE,   -- caller idempotency key
    request_hash TEXT NOT NULL,          -- SHA-256 hex over the economic terms
    symbol TEXT NOT NULL,
    taker_side TEXT NOT NULL,            -- 'B' or 'S'
    buyer_fee NUMERIC,                   -- NULL: the fee schedule applies
    seller_fee NUMERIC,
    status TEXT NOT NULL DEFAULT 'PENDING',
    venue_order_ids TEXT[] NOT NULL DEFAULT '{}',
    created_at TIMESTAMPTZ NOT NULL DEFAULT CURRENT_TIMESTAMP,
    updated_at TIMESTAMPTZ NOT NULL DEFAULT CURRENT_TIMESTAMP,
    CONSTRAINT ep3_booking_kind CHECK (cross_id ~ '^(M|B|F)-'),
    CONSTRAINT ep3_booking_cross_id_len CHECK (length(cross_id) = 28),
    CONSTRAINT ep3_booking_fees_paired
        CHECK ((buyer_fee IS NULL) = (seller_fee IS NULL)),
    CONSTRAINT ep3_booking_status_valid CHECK (
        status IN ('PENDING', 'ACCEPTED', 'BOOKED', 'FAILED', 'NEEDS_MANUAL_RECONCILIATION')
    )
);
CREATE INDEX ep3_bookings_reconcile_idx ON ep3_bookings (created_at)
    WHERE status = 'PENDING';

The reference and hash per kind:

Kind reference_id request_hash over Fees
M- Caller-supplied Accounts, symbol, taker side, price, quantity, both fees Explicit
B- The ticket's B- order id The ticket's version_hash terms NULL
F- delist:{symbol}:{account}:{attempt} Symbol, account, position, price Explicit zero

The economic terms are not stored in the journal. They live in EP3 and ClickHouse, and for tickets in ep3_block_trades.

The library

rs/sdk-internal/src/ep3_booking.rs:

pub enum CrossIdKind { ManualFill, BlockTrade, FinalSettlement }
pub struct CrossId(String);
impl CrossId {
    pub fn mint(kind: CrossIdKind) -> Self;
    pub fn parse(s: &str) -> Result<Self>;
    pub fn kind(&self) -> CrossIdKind;
}
pub enum BookingStatus { Pending, Accepted, Booked, Failed, NeedsManualReconciliation }
pub struct BookingTerms {
    pub cross_id: CrossId,
    pub reference_id: String,
    pub request_hash: String,
    pub buyer_account: AccountId,
    pub seller_account: AccountId,
    pub symbol: String,
    pub taker_side: Side,
    pub price: Decimal,
    pub quantity: i64,
    pub fees: Option<(Decimal, Decimal)>,
}

rs/ep3/src/booking.rs:

pub async fn book(pool, ep3: &Ep3Client, terms: BookingTerms) -> Result<BookingOutcome>;
pub fn is_definitive_rpc_rejection(e: &anyhow::Error) -> bool;
pub fn classify_booking_search(orders: &[Order]) -> BookingSearchResult;

book claims the journal row, scales the price, resolves both parties, calls InsertTwoSidedBlockTrade, and latches ACCEPTED, FAILED, or leaves PENDING for the reconciler. It is what api-gateway/src/manual_fills.rs does today, moved into ax-ep3 with the kind and the fees as parameters.

DbEp3Booking in ax-db is DbManualFill from #3715 renamed. Its transitions do not change.

The reconciler in api-gateway scans PENDING rows of every kind. Its rule does not change: two non-rejected legs is ACCEPTED, two rejected legs is FAILED, anything else is NEEDS_MANUAL_RECONCILIATION.

trade-engine2 loads journal metadata for every journaled cross id in a drop-copy batch, pairs the two legs, applies explicit fees when the row has them and the account rate lookup otherwise, sets is_final_settlement from the kind, and writes BOOKED in the resume-token transaction.

Phases

Phase 0, inside #3714 to #3716 before they merge. Rename the table and the DAO. Make fees nullable. Widen the prefix check. trade-engine2 applies explicit fees when present. The manual fill route, CLI, and modal keep their names; they are the M- front end.

Phase 1, after the stack merges. CrossId, CrossIdKind, and BookingStatus in ax-sdk-internal. book and the two classifiers in ax-ep3. api-gateway/src/manual_fills.rs calls book. trade-engine2 dispatches on CrossIdKind in place of prefix string comparisons. No behavior change. Acceptance: no file outside ep3_booking.rs and rs/sdk/src/types/order_id.rs compares a cross id against a prefix literal; the reconciliation matrix test from #3716 passes unchanged.

Phase 2, the ticket layer. block-trade-tickets. The accept transition calls book with kind BlockTrade.

Phase 3, delist. delist calls book with kind FinalSettlement and explicit zero fees, one call per holder. Each attempt gets a new F- id and a new reference. delist-verify waits for BOOKED on every row, then checks flat positions. The rerun path that searched EP3 by cross id is removed; the reconciler covers F- rows. Existing final_settlements rows migrate into ep3_bookings as F- rows with zero fees, and final_settlements is dropped. delist --force does not call EP3 and does not enter the journal. The runbook docs/runbooks/delisting-a-product.md is rewritten in this phase. This phase runs last: it touches settled positions in production.

Table names

Tables whose rows exist for one venue carry that venue's name. This matches the existing anchorage_*, trm_*, and btnl_* tables and the ep3_* and btnl_* columns on trading_accounts and instruments.

Current New When
manual_fills (#3714) ep3_bookings Phase 0, no migration
block_trades ep3_block_trades Phase 2
final_settlements dropped Phase 3

Atlas diffs db/postgres/1.sql declaratively and plans a rename as DROP plus CREATE. The block_trades rename and the final_settlements data migration each ship with a hand-applied script under db/postgres/, guarded on information_schema so it is a no-op where already applied, run per environment before just migrate-db, as #3856 did.

Tables that stay as they are: trade_engine.resume_tokens and risk_engine.resume_tokens are keyed by service and shared with the Bitnomial engines. liquidation_engine.order_request has a venue column. treasury_engine.deposit_credit_attempts has a provider column.

Decisions

  1. The core is a library, not a process. Two processes call EP3: api-gateway for M- and order-gateway for B-, plus admin-cli for F-. They share one code path and one journal. The book-exactly-once discipline lives in book and in the journal's compare-and-set transitions.
  2. BOOKED comes from drop copy for every kind. The synchronous reply means accepted for processing. Delist verification waits for BOOKED.
  3. The reconciler replaces operator reruns. It resolves PENDING rows of every kind by searching EP3.
  4. The ticket table does not mirror booking status. ep3_block_trades ends at ACCEPTED. The blotter joins ep3_bookings by cross id.
  5. M-, B-, and F- stay distinct. The kind is read downstream.
  6. The reconciler stays in api-gateway. One instance is enough.

Out of scope

Open questions

  1. Where force-delist provenance lives after final_settlements is dropped. Today it is final_settlements.reason. Options: a slim delist_runs table, or the ClickHouse trade row.
  2. Whether delist keeps a per-run record of the position and price it booked. The values are re-derived per run and ClickHouse has the trades, so the default is no.