SoW: Block trade tickets

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.

Concept

   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.

What exists on main

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.

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.

Schema

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.

GUI

Three surfaces in the web app.

Check @architect-xyz/ui-components for existing form, list, and timeline primitives before writing new ones.

Delivery

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:

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.

Deferred

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.

RFQ negotiation

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:

The full negotiation design is in git history at docs/rfc/rome.md before this PR.

Open questions

  1. Whether counterparty discovery needs a directory or consent model before the first customer use, on top of the entitlement flag.
  2. Whether the pending-ticket expiration default is end of trading day or shorter.