Date: 2026-09-16
Related: ep3-booking-core, tracking A-3622, the RFQ epic A-3209.
Objective. Two customers agree a block trade off the
lit book. One submits a ticket. The other affirms it. The order-gateway
books it through the EP3 booking core with a B- cross id.
The customer-facing ticket lifecycle, the margin reservations, and the
directed events are this SoW. The booking is not; it is the core.
submitter WS counterparty WS
| submit (allege) |
v |
order-gateway: ticket PENDING_COUNTERPARTY ---------> bt Alleged
| reserve submitter margin |
| accept / reject / (expire)
|<-----------------------------------------------+
v
ticket ACCEPTED, reserve counterparty margin
|
v
book(kind = BlockTrade, cross_id = ticket id, reference = ticket id,
hash = version_hash, fees = NULL) -- the booking core
|
v
ep3_bookings row: PENDING -> ACCEPTED -> BOOKED (or FAILED)
|
v
sweeper reads the row, releases reservations, emits bt Printed / Out
A ticket has five states: PENDING_COUNTERPARTY,
ACCEPTED, REJECTED, CANCELED,
EXPIRED. ACCEPTED is terminal for the ticket.
The booking outcome is the ep3_bookings row with the same
id.
| PR | Merged | What it added |
|---|---|---|
| #2368 | 2026-06-14 | The bt wire family in
rs/sdk-internal/src/protocol/block_trade.rs:
SubmitBlockTradeRequest,
AcceptBlockTradeRequest,
RejectBlockTradeRequest,
CancelBlockTradeRequest,
GetBlockTradesRequest, the BlockTradeAlleged,
BlockTradeUpdated, BlockTradeOut, and
BlockTradePrinted events, BlockTradeStatus,
and compute_version_hash. The block_trades
table. The order-gateway drop-copy skip for block-trade legs. |
| #3049 | 2026-07-09 | trading_accounts.block_trades_enabled and
DbBlockTrade, a compare-and-set DAO over
block_trades. |
#2391
closed unmerged on 2026-07-09. Its
rs/order-gateway/src/block_trades.rs holds the state
machine, two-phase margin, the sweeper, Redis directed events, and the
blotter. It also holds an EP3 booking path that this SoW does not carry
over. It is the reference for the ticket layer.
All of it lives in the order-gateway. Postgres
ep3_block_trades is the system of record. Redis carries
directed events across replicas. No new crate, no new datastore.
Submit. The submitter names the symbol, price,
quantity, their side, the counterparty account, and a client order id.
The gateway checks block_trades_enabled on both accounts,
reserves the submitter's margin for the leg through
can_send_order, mints a B- id, computes
version_hash over the economic terms, inserts the ticket
PENDING_COUNTERPARTY with an expiration, and sends
BlockTradeAlleged to the counterparty.
Accept. The counterparty presents the ticket id and
the version_hash they saw. A hash mismatch is rejected. The
gateway rechecks the entitlement on both accounts, reserves the
counterparty's margin, moves the ticket to ACCEPTED by
compare-and-set, and calls book with kind
BlockTrade, cross_id and
reference_id equal to the ticket id,
request_hash equal to version_hash, and no
fees. A repeat accept replays the stored booking outcome through the
core's idempotency rule.
Reject. The counterparty moves the ticket to
REJECTED. The submitter's reservation is released. Both
parties receive BlockTradeUpdated.
Cancel. The submitter moves a
PENDING_COUNTERPARTY ticket to CANCELED. The
reservation is released.
Expire. A one-second sweeper moves tickets past
expiration to EXPIRED and releases the
reservation. The default expiration is end of trading day. The submitter
may set a shorter one.
Booking outcome. The same sweeper reads the
ep3_bookings row for each ACCEPTED ticket.
BOOKED: release both reservations, since the positions are
now real, and send BlockTradePrinted to both parties.
FAILED: release both reservations and send
BlockTradeOut with the reason.
NEEDS_MANUAL_RECONCILIATION: hold the reservations and send
BlockTradeUpdated with a pending-settlement status.
PENDING older than the reconciler's grace period: no
action; the reconciler owns it.
Blotter. GetBlockTradesRequest returns
the caller's tickets, newest first, 500 rows, joined to
ep3_bookings for the booking status and venue order
ids.
Restart. The gateway rebuilds margin reservations
for PENDING_COUNTERPARTY and ACCEPTED tickets
from Postgres at startup. Reservations for ACCEPTED tickets
whose booking row is BOOKED or FAILED are not
rebuilt.
Disconnect. A ticket is durable. A WebSocket disconnect on either side does not cancel it.
ep3_block_trades is block_trades renamed,
minus the booking columns.
| Column | Change |
|---|---|
status |
CLEARED removed from the check constraint. |
trade_id, reason |
Dropped. Both live on the ep3_bookings row. |
order_id |
Unchanged. It is the ep3_bookings.cross_id for the
booking. |
The rename and the column drops ship with a hand-applied script, as ep3-booking-core describes.
Three surfaces in the web app.
Check @architect-xyz/ui-components for existing form,
list, and timeline primitives before writing new ones.
| PR | Scope |
|---|---|
| A | Schema: ep3_block_trades rename and column drops, with
the migration script. DbBlockTrade updated. |
| B | The ticket layer in order-gateway: submit, accept, reject, cancel,
expiry, booking outcome, blotter, restart rebuild. bt
dispatch in ws_service.rs. Redis directed events. |
| C | GUI: submit form, inbox, blotter. |
| D | Lifecycle test matrix. |
B depends on phase 1 of ep3-booking-core. A and C depend on nothing. D depends on B.
Acceptance for B, as integration tests against ep3-mock:
version_hash is rejected and
changes nothing.FAILED booking releases both reservations and emits
BlockTradeOut.BOOKED booking releases both reservations and emits
BlockTradePrinted.block_trades_enabled cannot submit
or accept.Acceptance for D: client disconnect, server-initiated disconnect,
gateway restart, and reconnect at every ticket state, including the
submit-and-reserve and accept-and-book races and a booking row left
PENDING across a restart. Recovery rebuilds reservations
from Postgres. Fill adjacency is confirmed against a live drop-copy
stream with concurrent activity, not only ep3-mock.
Cut from the first version: minimum block size (EP3
BlockTradeThreshold), reporting window, FCM and
clearing-firm fields, executing-broker submitters, multi-leg tickets,
settlement date, a block-specific fee schedule, price-reasonability
bands, the public-tape block condition marker, and a counterparty
directory or consent model beyond the entitlement flag.
An RFQ workflow is a later front end on this layer. A requester posts
a request, makers quote, the requester accepts one. The accepted quote
is a consummated agreement, so it produces a ticket that enters at
ACCEPTED, past the allege step, and books through the core
like any other ticket. The decisions that carry over from the earlier
RFQ design:
sdk-internal until the feature
ships.The full negotiation design is in git history at
docs/rfc/rome.md before this PR.