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.
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.
| 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.
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.
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.
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.
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.
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.
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.BOOKED comes from drop copy for every
kind. The synchronous reply means accepted for processing.
Delist verification waits for BOOKED.PENDING rows of every kind by searching EP3.ep3_block_trades ends at ACCEPTED. The blotter
joins ep3_bookings by cross id.M-, B-, and F- stay
distinct. The kind is read downstream.delist --force.InsertTwoSidedBlockTrade carries
one symbol.final_settlements is dropped. Today it is
final_settlements.reason. Options: a slim
delist_runs table, or the ClickHouse trade row.