| Status | Draft — re-baselined on the block-trade protocol (A-3622) |
| Owner | @tin |
| Created | 2026-05-06 |
| Last updated | 2026-07-08 |
| Scope | The AX RFQ (request-for-quote) workflow: a negotiation layer that settles accepted quotes as block trades through the order-gateway booking core. |
ROME lets participants negotiate block-sized trades off the lit book — a requester asks the market for a price, makers quote, the requester accepts one — and books the result atomically in EP3. The design question this RFC answers is not "how do we book a two-sided trade" (that already shipped) but "how do we run the negotiation on top of it without adding a second settlement path."
The
block-trade
protocol
landed
first
and
independently
(A-3622): submit
→
counterparty
affirm
→
book
once
to
EP3
via
InsertTwoSidedBlockTrade, built
into
the
order-gateway,
with
Postgres
as
system
of
record.
That
work deliberately
carved
out
the
two-sided
booking
spine
and
dropped
the
separate
rome service.
This
RFC
is
re-baselined
on
that
reality:
ROME
is
the
negotiation
layer, the
booking
core
is
the
settlement
layer,
and
there
is
exactly
one
EP3
caller.
Three conclusions everything else follows from:
ROME
never
calls
EP3.
An
accepted
quote
is
already
a
consummated
agreement
— the
maker
quoted
firm,
the
requester
accepted.
ROME
turns
it
into
a
born-affirmed two-sided
ticket
and
hands
it
to
the
block-trade
booking
core,
which
owns
the
sole InsertTwoSidedBlockTrade
call
and
the
book-exactly-once
discipline.
No
second
EP3 caller,
no
second
dedup/idempotency
story,
no
new
settlement
surface.
ROME
is
not
a
service.
Following
the
block-trade
precedent,
RFQ
negotiation builds
into
the
order-gateway
—
it
needs
only
what
the
gateway
already
owns (per-user
WS,
margin
gating,
EP3
connectivity
via
the
booking
core,
Redis
cross- replica
pub/sub).
There
is
no
rs/rome
crate;
the
prototype
rome
service
and
its compose
entry
were
closed
the
day
they
were
built.
ROME
owns
negotiation
state;
the
booking
core
owns
settlement
state.
ROME's state
machine
ends
at
"quote
accepted
→
ticket
produced."
It
has
no
Settling/ Settled/needs_manual_reconciliation
—
those
belong
to
the
ticket.
Durability, cross_id
minting,
never-retry
latch,
reconciliation,
position/fee/PnL
flow-through are
all
inherited
unchanged.
Load-bearing commitments. §2–§8 are elaboration.
D1
—
Settle
as
a
block
trade,
not
via
EP3's
native
RFQ.
EP3
ships
a
native Quotes/RFQ
service
whose
acceptance
books
through
InsertOrder-with-quote
(a
lit-book order/trade).
ROME
instead
settles
accepted
quotes
through
the
block-trade
booking core
(InsertTwoSidedBlockTrade,
a
block
print).
One
EP3
caller,
one
audited
settlement discipline,
correct
block-print
tape
semantics.
EP3's
native
service
stays
available
as a
future
option
and
as
the
source
of
fair-market
controls
(§6).
See
§11
alt
A1.
D2
—
Born-affirmed
ticket,
entering
the
booking
core
past
allege/affirm.
The block-trade
ticket
has
an
allege→affirm
front
half.
An
accepted
RFQ
has
already completed
the
equivalent
handshake,
so
ROME
produces
a
ticket
that
is
born
affirmed: both
parties
bound,
terms
fixed
by
the
accepted
quote,
version_hash
computed
over those
terms.
It
enters
the
booking
core
directly
at
the
accepted → booking
hop, skipping
pending_counterparty.
This
is
the
one
new
seam
ROME
adds
to
the
block-trade layer;
everything
downstream
is
unchanged.
See
§11
alt
A2.
D3
—
RFQ
margin-checks
at
accept
only
(single
phase).
Block-trade
tickets reserve
in
two
phases
(submitter
at
submit,
counterparty
at
accept)
because
a
directly- alleged
ticket
pends
for
hours
and
a
submit-side
gap
is
an
open-limit
bypass.
An
RFQ quote
is
short-lived
and
the
requester
is
uncommitted
until
accept,
so
ROME
checks
and reserves
both
legs
in
the
accept
transition,
reusing
the
booking
core's can_send_order
/
ProspectiveOrder
gating
with
both
legs
supplied
at
once.
D4
—
Build
into
the
order-gateway;
no
new
crate,
no
new
datastore.
Negotiation state
is
in-memory
in
the
gateway
process.
The
block_trades
Postgres
table
is
the settlement
SoR.
Redis
carries
only
cross-replica
directed-event
pub/sub
—
the
same
role it
plays
for
bt
events.
No
rs/rome
crate.
D5
—
Anonymity
and
targeting
are
protocol-level,
decided
at
emit
time. disclose_identity = false
strips
the
requester's
identity
to
a
per-request
pseudonym on
outgoing
WS
frames
only
—
it
is
a
presentation
flag,
not
a
privacy
flag.
Targeted RFQs
(target_makers: Vec<UserId>)
route
through
the
per-user
directed
channel
and never
touch
the
public
stream.
The
audit
log
and
the
produced
ticket
always
carry
real identities.
D6
—
Cancel-on-disconnect
mirrors
order
behavior.
A
dropped
requester
WS
cancels their
open
requests
and
quotes;
a
dropped
responder
WS
cancels
their
open
quotes. Subscribers
see
QuoteRequestRemoved/QuoteRemoved.
This
is
the
load-bearing correctness
surface
(house
rule
on
connection/session
state);
hardening
is
A-3295.
D7
—
RFQ
types
live
in
sdk-internal
until
the
feature
ships.
Per
the
#2068 revert
policy
—
the
public
SDK
must
not
advertise
RFQs
before
integration
—
RFQ
wire types
live
in
sdk-internal/src/protocol/rfq.rs
and
WS
dispatch
handles
them
as
an untagged
fallback.
Same
policy
that
governs
the
block-trade
"bt"
tag.
D8
—
Single-leg
v1.
The
booking
core
is
single-leg
(InsertTwoSidedBlockTrade carries
one
symbol/price/quantity),
and
the
EP3
atomic-multi-leg
question
is
unanswered in
#ext-architect-connamara
(block-trade
plan
§2f).
Multi-leg
RFQs
are
v2,
blocked
on both
the
vendor
answer
and
the
booking
core
gaining
a
multi-leg
form.
The
wire
carries
a forward-compat
legs
shim
(§4.1)
so
v2
doesn't
break
v1
clients.
This is the center of the RFC. ROME is layered, not standalone.
Per Brett's framing (2026-06-09, block-trade plan §2d): block trades "use a subset of ROME which is the two-sided trade booking thing." That subset landed first; ROME builds the negotiation on top.
| Concern | Owner |
|---|---|
| Public/targeted RFQ stream, quote book, anonymity, targeting, maker UX | ROME (this RFC) |
| RFQ/quote expiration, cancel-on-disconnect of live negotiation | ROME (§3.4, §7) |
| Quote-accept handshake → produce a born-affirmed ticket | ROME → booking core (§2.3) |
Two-sided
ticket
lifecycle,
version_hash,
directed
bt*
events |
block-trade protocol |
The
single
InsertTwoSidedBlockTrade
call;
book-exactly-once |
block-trade protocol |
cross_id
minting,
no-dedup
classifier,
never-retry
latch |
block-trade protocol |
| Two-phase margin, Postgres CAS SoR, sweeper, reconciliation | block-trade protocol |
| Position / fee / PnL flow-through via drop-copy | existing machinery |
ROME's deliverable is the first three rows. Everything below the line ROME reuses — wired, tested, live-bound under A-3622.
The
load-bearing
constraint
inherited
from
the
block-trade
work
(plan
§2h):
there
is exactly
one
path
to
InsertTwoSidedBlockTrade
—
the
order-gateway
booking
core.
The RPC
does
not
dedup
(vendor-confirmed
2026-05-26)
and
exposes
no
idempotency_key,
so
the book-exactly-once
discipline
(snapshot-before-call,
definitive-vs-ambiguous
classifier, never-retry
latch,
Postgres
CAS,
reconciliation)
is
non-negotiable
and
lives
in
exactly one
place.
A
second
caller
would
mean
a
second,
differently-behaved
dedup
story.
ROME feeds
the
core
consummated
tickets;
it
never
books
itself.
Two ways to map an accepted RFQ onto the booking core:
bt
path
verbatim
but
adds
a redundant
round-trip
and
a
window
where
the
synthesized
affirm
could
be
lost.
accepted → booking
directly.
The
RFQ
accept
is
the affirmation.
No
redundant
round-trip,
no
extra
loss
window.
The
booking
core
exposes
an
internal,
non-public
born-affirmed
entry
point
that
ROME alone
calls
with:
buyer
account,
seller
account,
symbol,
price,
quantity,
both
legs' client
order
ids,
and
a
version_hash
over
the
economic
terms.
The
core's
economic-terms binding
(compute_version_hash)
still
applies,
computed
over
the
accepted
quote.
From
the entry
point
down,
settlement
is
the
block-trade
protocol
verbatim.
InsertTwoSidedBlockTrade,
two-sided
and
atomic,
called
exactly
once after
consummation.
classify_booking_error
splits
outcomes
— definitive-no-commit
=
INVALID_ARGUMENT,
FAILED_PRECONDITION,
OUT_OF_RANGE, NOT_FOUND,
ALREADY_EXISTS,
UNAUTHENTICATED,
PERMISSION_DENIED, RESOURCE_EXHAUSTED;
ambiguous
(latch,
never
retry)
=
UNAVAILABLE, DEADLINE_EXCEEDED,
INTERNAL,
CANCELLED,
ABORTED,
UNKNOWN,
DATA_LOSS, UNIMPLEMENTED,
plus
any
non-gRPC
error.
Ambiguous
outcomes
latch
to needs_manual_reconciliation.
cross_id.
Fresh
ULID
minted
by
the
core,
snapshotted
to
Postgres
before
the
EP3 call,
propagated
to
both
order
legs
and
every
drop-copy
Execution
(vendor-confirmed 2026-05-27)
—
enabling
self-healing
reconcile
and
SearchOrders(cross_id=…)
lookups. ROME
does
not
mint
or
own
cross_id.
block_trades
is
the
SoR;
transitions
are
CAS;
a
1s sweeper
drives
the
reconciliation
latch;
restart
rebuilds
in-flight
ticket
state
from Postgres.
Executions
with block_trade_indicator = true
update
positions,
fees,
and
PnL
through
existing machinery
—
no
ROME
plumbing.
Earlier
drafts
of
this
RFC
owned
a
Settling/Settled/needs_manual_reconciliation state
machine,
cross_id
minting,
a
Redis
durable_state
module,
a
layered
ambiguous- outcome
retry-safety
analysis,
and
the
EP3
idempotency
contract.
All
of
that
moved
to the
block-trade
protocol.
ROME
keeps
only:
produce
the
ticket,
observe
its
terminal state,
emit
the
negotiation-side
Filled/reject
event.
If
the
seam
or
its
semantics change,
that
is
a
block-trade-plan
change,
not
a
ROME
change.
In-process in the order-gateway. Owns the request/quote book and the negotiation lifecycle. Does not own settlement.
pub struct RomeState {
pub requests: DashMap<RequestId, Arc<RequestEntry>>,
pub quotes_index: DashMap<QuoteId, RequestId>,
pub expirations: Arc<Mutex<BinaryHeap<Reverse<(i64, ExpiryKey)>>>>,
pub log_tx: mpsc::Sender<RfqLogRow>,
pub id_gen: IdGen,
// directed/public RFQ event fanout reuses the gateway's per-user WS
// channels + Redis cross-replica pub/sub (shared with bt events)
}
pub struct RequestEntry {
pub immutable: RequestImmutable, // requester, symbol, sides, anonymity, targets
pub state: parking_lot::Mutex<RequestState>,
}
pub enum RequestState {
Active { quotes: SmallVec<[Quote; 4]>, expires_at_ns: i64 },
Accepted { quote_id: QuoteId, ticket_id: OrderId }, // terminal for ROME
Cancelled,
Expired,
}The
per-entry
lock
is
parking_lot::Mutex,
not
tokio::sync::Mutex:
ROME
never
holds
a lock
across
an
await
to
EP3.
The
only
cross-process
call
happens
inside
the
booking
core, after
ROME
has
produced
the
ticket
and
released
the
entry.
Accepted
records
the ticket_id
purely
so
a
status
query
can
correlate
the
negotiation
with
its
settlement.
DashMap
shards
lookup
across
requests;
a
single
request's
lifecycle
is
serialized
by its
per-entry
mutex.
Active
and
the
quote
is present/unexpired,
flip
to
Accepted,
build
the
ticket,
drop
the
mutex,
then
call the
booking
core.
Concurrent
AcceptQuote/Cancel
on
the
same
request
see
Accepted and
reject
cleanly
—
exactly-one-winner
from
mutex-then-flip
ordering,
same
as
the
order path.
DONE_AWAY
notifications
are
cloned
under
the
mutex
and
sent
after release
(clone-and-release),
so
a
slow
directed
consumer
can't
stall
the
entry.
SubmitQuoteRequest
—
WS frame → mint request_id → DashMap insert → publish on public/targeted stream → push expiration → log → SubmitQuoteRequestResponse.
No
margin reserve,
no
EP3
hop;
the
requester
is
committed
only
at
accept.
SubmitQuote
—
WS frame → gateway risk-*check* (no reserve) → take entry mutex → verify Active → mint quote_id → append → release → emit QuoteReceived → push expiration → log → SubmitQuoteResponse.
No
EP3
hop;
the
maker
is
committed
only
when
the
requester accepts.
AcceptQuote (the handoff) —
RfqReject,
nothing
produced.
Active,
quote
present
and
unexpired.
Active → Accepted;
mark
competing
quotes
DONE_AWAY;
build
the
born-affirmed ticket.
Drop
the
mutex.
Filled
(cleared)
or
a
pending/reject event
to
both
parties;
log.
ROME never retries the EP3 call and never inspects EP3 status codes — that is the core's job.
One
in-process
task.
State
is
BinaryHeap<Reverse<(deadline_ns, key)>>
behind
a parking_lot::Mutex.
Loop:
peek,
sleep
to
the
head
deadline,
pop,
look
up
the
entry,
take its
mutex,
and
if
still
Active
transition
to
Expired
(or
drop
the
quote),
then broadcast
QuoteRequestRemoved/QuoteRemoved
and
log.
If
the
entry
already
moved (Accepted/Cancelled),
drop
the
heap
entry
silently.
Once
Accepted,
expiration
is
a no-op
—
the
produced
ticket
has
its
own
TTL
in
the
booking
core
(end-of-day
default, settable
shorter).
Same
pattern
trade-engine
uses
for
GTD
orders.
Wire
types
in
sdk-internal/src/protocol/rfq.rs
(graduating
to
the
public
SDK
when
RFQ ships,
D7).
They
follow
the
conventions
frozen
in
the
#2087
review
(block-trade
plan
§2g), shared
with
the
block-trade
bt
family.
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(tag = "t")]
pub enum RfqRequest {
#[serde(rename = "qr")] SubmitQuoteRequest(SubmitQuoteRequest),
#[serde(rename = "xqr")] CancelQuoteRequest(CancelQuoteRequest),
#[serde(rename = "sqr")] SubscribeQuoteRequests(SubscribeQuoteRequests),
#[serde(rename = "uqr")] UnsubscribeQuoteRequests,
#[serde(rename = "q")] SubmitQuote(SubmitQuote),
#[serde(rename = "xq")] CancelQuote(CancelQuote),
#[serde(rename = "aq")] AcceptQuote(AcceptQuote),
}
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(tag = "t")]
pub enum RfqEvent {
#[serde(rename = "QR")] QuoteRequestPosted(QuoteRequestPosted), // public/targeted
#[serde(rename = "XQR")] QuoteRequestRemoved(QuoteRequestRemoved), // public/targeted
#[serde(rename = "Q")] QuoteReceived(QuoteReceived), // directed to requester
#[serde(rename = "XQ")] QuoteRemoved(QuoteRemoved), // directed (incl. DONE_AWAY)
#[serde(rename = "F")] Filled(Filled), // directed to both sides
}
pub struct SubmitQuoteRequest {
pub symbol: Symbol,
pub quantity: Decimal,
pub req_bids: bool,
pub req_asks: bool,
pub expiration: DateTime<Utc>,
pub client_request_id: Option<u64>, // echoed back, for sender-side dedupe
pub disclose_identity: bool, // false → responders see a pseudonym
pub target_makers: Vec<UserId>, // empty = public; non-empty = directed only
}
pub struct SubmitQuote {
pub request_id: RequestId,
pub quote: RfqQuote, // Option<bid> / Option<ask>, validated
pub expiration: DateTime<Utc>,
pub client_quote_id: Option<u64>,
}
pub struct AcceptQuote {
pub quote_id: QuoteId,
pub side: Side, // disambiguates two-sided quotes
}Conventions (§2g):
y
=
state
on
the
wire
(st/s
rejected
—
collisions).
QuoteDetails
mirrors
public
OrderDetails; RfqFilled/RfqCancelAck/RfqReject
carry
the
prefix,
matching
ticket
analogs.
R-…
requests,
Q-…
quotes
(modeled
on
O-/L-);
tickets
mint their
own
B-
prefix.
Internally
carried
as
u128
for
compact
indexing.
RfqQuote
is
a
struct
of
Option<Decimal>
bid/ask
(key presence
encodes
sides),
validated
in
validate()
(≥1
side,
positive,
not crossed/locked).
Ingest
must
call
validate
and
the
gateway
must
echo
the
parsed quote
—
unknown
keys
are
ignored
on
decode,
so
a
mistyped
side
key
silently
degrades
to one-sided
(pinned
in
submit_quote_ignores_unknown_keys).
RfqQuoteSides
tokens
are b/a/2.
Settlement
identifiers
(cross_id,
trade_id,
ticket
B-…)
are
not
ROME's
to
mint (§2.4).
The
constraint
that
they
must
not
be
derivable
from
request_id
—
which
the
public stream
exposes
—
is
satisfied
automatically:
the
core
mints
fresh
ULIDs
independent
of
any RFQ
field.
A-3295
introduces
a
legs: Vec<Leg> representation
behind
the
single-instrument
shape
so
v2
multi-leg
work
doesn't
break
the wire.
Single-leg
v1
clients
deserialize
unchanged;
multi-leg
requests
are
accepted
by
the wire
and
rejected
by
the
engine
with
not_implemented
until
A-3297
F1
lands
—
and
only
once
the
EP3
multi-leg
block
question
is answered
(D8).
Every
client
message
goes
through
order_gateway;
ROME
and
the
booking
core
both
live in-process
there.
requester WS ─► gateway: ROME negotiation ──► public/targeted RFQ stream
│ │
│ accept ▼ broadcast
▼ responder WS
born-affirmed ticket
│
▼
block-trade booking core ──tonic gRPC──► EP3 InsertTwoSidedBlockTrade
│
├─► Postgres block_trades (settlement SoR)
└─► drop-copy Execution ──► position / fee / PnL (existing)
Two
state
machines,
one
handoff.
ROME
negotiation
(in-memory):
Active → {Cancelled, Expired, Accepted};
Accepted
is
terminal
for
ROME.
Block-trade
ticket
(Postgres
SoR, owned
by
the
core):
enters
at
accepted → booking,
then
cleared
(or needs_manual_reconciliation).
ROME
observes
the
outcome
to
emit
Filled
but
does
not drive
it.
sequenceDiagram
autonumber
actor R as Requester
participant Gr as order_gateway (R) — ROME
participant BC as block-trade booking core (in gateway)
participant PG as Postgres (block_trades SoR)
participant Gp as order_gateway (P) — ROME
actor P as Provider
participant E as EP3 BlockTradesAPI
R->>Gr: WS SubmitQuoteRequest
Gr->>Gr: ROME: insert Active request
Gr--)Gp: publish QuoteRequestPosted (public/targeted)
Gr-->>R: WS SubmitQuoteRequestResponse { request_id }
Gp-->>P: WS RfqEvent::QuoteRequestPosted
P->>Gp: WS SubmitQuote
Gp->>Gp: risk-check provider (offered side/price)
Gp--)Gr: directed QuoteReceived
Gp-->>P: WS SubmitQuoteResponse { quote_id }
Gr-->>R: WS RfqEvent::QuoteReceived
R->>Gr: WS AcceptQuote { quote_id, side }
Gr->>Gr: ROME: risk-check + reserve BOTH legs (single phase)
Gr->>Gr: ROME: Active → Accepted; competing quotes DONE_AWAY
Gr->>BC: born-affirmed two-sided ticket (internal seam)
BC->>PG: persist ticket + cross_id (before EP3 call)
BC->>E: InsertTwoSidedBlockTrade { cross_id }
alt cleared (EP3 booked)
E-->>BC: trade_id + order_ids
BC->>PG: ticket → cleared
E--)BC: drop-copy Executions → position/fee/PnL (existing)
BC--)Gr: ticket cleared
BC--)Gp: ticket cleared
Gr-->>R: WS RfqEvent::Filled
Gp-->>P: WS RfqEvent::Filled
else definitive reject (4xx, no commit)
E-->>BC: tonic::Status (INVALID_ARGUMENT, FAILED_PRECONDITION, …)
BC->>PG: ticket → rejected; release reservations
BC--)Gr: ticket rejected
Gr-->>R: WS RfqReject (quote consumed; requester may re-request)
else ambiguous (5xx / DEADLINE_EXCEEDED / crash)
Note over BC,E: EP3 may or may not have committed. NO auto-retry.
BC->>PG: ticket → needs_manual_reconciliation (reservations held)
BC--)Gr: ticket uncertain
Gr-->>R: WS RfqEvent (pending settlement)
Note over BC: sweeper / drop-copy self-healing / operator resolves
end
Everything
in
the
alt
block
is
the
block-trade
protocol's
behavior,
reproduced
only so
the
end-to-end
path
is
legible.
The
authoritative
settlement
spec
is A-3622;
§2
is
the
seam.
EP3
ships
a
native
Quotes/RFQ
service
(connamara/ep3/quotes/v1beta1/),
distinct
from
the block-trade
API.
ROME
mirrors
its
semantics
and
sources
its
fair-market
controls
but does
not
use
it
to
settle
(D1).
What
it
does.
CreateRequestForQuote
(requester)
→
CreateQuote
(dealer,
references the
RFQ)
→
requester
accepts.
There
is
no
AcceptQuote
RPC:
acceptance
is
InsertOrder with
the
quote
field
set
—
the
accepted
quote
becomes
ACCEPTED,
competitors
become DONE_AWAY.
Dealers
may
PassQuote;
either
side
may
DeleteQuote.
FIX
wraps
the lifecycle
(QuoteRequest,
Quote,
QuoteStatusReport,
QuoteResponse <AJ>; HIT_LIFT
synthesizes
a
LIMIT+IOC
order
with
order.quote
set).
The
key
fact: native
acceptance
settles
through
InsertOrder
—
a
lit-book
order/trade,
a
different primitive
from
InsertTwoSidedBlockTrade.
Fair-market controls ROME folds in (§3.2 knobs):
disclose_identity.
quotesServer.behavior.restrictFirmAcceptOwnRfqs)
—
a
policy flag
ROME
carries
so
an
AIEX
rulebook
can
forbid
accepting
RFQs
from
same-firm
accounts.
The
native
service
remains
available
if
AIEX
later
needs
EP3-side
RFQ-to-trade
with DONE_AWAY
audit
on
EP3
(open
question
Q8);
D1
does
not
foreclose
it.
Negotiation
audit
→
ClickHouse
(settlement
is
recorded
by
the
block-trade
protocol
in Postgres;
the
two
correlate
on
ticket_id/cross_id):
CREATE TABLE rfq_log (
timestamp_ns UInt64,
event_type LowCardinality(String),
user_id String,
request_id UInt128,
quote_id Nullable(UInt128),
ticket_id Nullable(String), -- produced block-trade ticket (B-…)
symbol LowCardinality(String),
quantity Decimal128(18),
bid Nullable(Decimal128(18)),
ask Nullable(Decimal128(18)),
accepted_side Nullable(String),
expiration_ns Nullable(UInt64),
reject_reason Nullable(String),
target_makers Array(String),
disclose_identity Bool
) ENGINE = MergeTree
ORDER BY (timestamp_ns, request_id);event_type:
request_submitted,
quote_submitted,
quote_accepted,
quote_done_away, request_cancelled,
quote_cancelled,
request_expired,
quote_expired, ticket_produced,
trade_booked,
trade_book_failed.
The
settlement-outcome
values (trade_booked/trade_book_failed)
are
written
by
ROME
on
observing
the
ticket's
terminal state,
for
correlation
only
—
they
are
not
the
authoritative
settlement
record.
Writer:
bounded
mpsc::Sender<RfqLogRow>,
batched
async
inserts,
drop-oldest-on-full
with a
rome.log_drops
counter
—
never
block
a
hot
path
on
logging.
Anonymity
+
the
log
(D5).
ClickHouse
rows
always
carry
the
real
user_id
for compliance;
stripping
happens
only
at
RfqEvent
emission.
The
produced
ticket
records
real buyer/seller
accounts
regardless.
Trade-tape
marker.
RFQ
prints
are
block
trades
—
drop-copy
Executions
carry block_trade_indicator = true,
and
the
public-tape
block
condition
marker
rides
with
the block-trade
tape
work
(A-3294).
Prometheus
(ROME
negotiation
path;
settlement
metrics
are
the
core's): rome_active_requests,
rome_active_quotes,
rome_event_total{type}, rome_accept_to_ticket_latency_seconds
(accept
→
ticket
produced),
rome_tickets_produced_total, rome_log_drops_total,
rome_public_rfq_subscribers,
rome_public_rfq_lagged_total. Tracked
under
A-3219.
Negotiation-layer failures are ROME's; settlement-layer failures are inherited and listed for completeness only.
| Scenario | Layer | Behavior |
|---|---|---|
| Requester WS disconnect | ROME | Cancel
all
their
requests
+
quotes
(mirrors
cancel_session_orders);
subscribers
see
QuoteRequestRemoved.
Hardening
A-3295. |
| Responder WS disconnect | ROME | Cancel their open quotes. Disconnect between SubmitQuote and AcceptQuote is Q1 — default: cancel the quote. |
| Slow public RFQ subscriber | ROME | Lagged consumer re-snapshots and resumes; client never sees the lag. |
| Crash before cancel-on-disconnect | ROME | RFQs/quotes leak until their natural deadline; server-side TTL/heartbeat needed (A-3295 item 4). |
| Accept margin check fails | ROME | Typed
RfqReject;
no
ticket
produced;
quote
may
still
be
acceptable
if
margin
recovers
before
expiry. |
| Born-affirmed ticket: definitive EP3 reject (4xx) | booking core | Ticket
→
rejected,
reservations
released;
ROME
emits
RfqReject.
Quote
was
consumed;
requester
re-requests. |
Born-affirmed
ticket:
ambiguous
EP3
(5xx
/
DEADLINE_EXCEEDED
/
crash) |
booking core | Ticket
latches
needs_manual_reconciliation,
reservations
held,
no
auto-retry.
Sweeper
/
drop-copy
self-healing
/
operator
resolves.
ROME
emits
pending-settlement,
not
Filled. |
| Gateway restart with in-flight ticket | booking core | Recovery rebuilds ticket state from Postgres; ROME's in-memory negotiation state for non-accepted requests is lost by design. |
The
detailed
retry-safety-by-layer
analysis
(double-click,
reconnect-resubmit,
gateway retry,
core→EP3
retry,
restart)
is
the
block-trade
protocol's.
ROME's
only
contribution
is accept-idempotency
(Q5):
a
reconnecting
client
that
re-issues
AcceptQuote
after
the
quote was
consumed
gets
a
clean
reject,
but
cannot
yet
distinguish
"trade
booking"
from
"quote expired"
—
a
recovery-UX
gap,
tracked,
not
a
correctness
gap.
Integration
tests
under
rs/order-gateway/tests/,
using
ax_test_utils
containers (Postgres,
ClickHouse,
Redis)
and
Ep3Mock
(whose
InsertTwoSidedBlockTrade
validates parties,
preserves
cross_id,
emits
drop-copy
fills
with
block_trade_indicator = true). Each
drives
real
WS
clients
against
the
gateway.
Tracked
under A-3260.
Required scenarios:
Filled,
rfq_log
and
the
block_trades
row
correlate
on ticket_id.
AcceptQuote
same
request
→
exactly
one
wins;
exactly
one
ticket produced
→
no
double-book.
rejected,
reservations
released,
both
notified, trade_book_failed
logged. 6a.
Ambiguous
EP3
(5xx
/
DEADLINE_EXCEEDED
/
crash
mid-call)
→
latch needs_manual_reconciliation,
no
auto-retry,
operator/sweeper
path
(primarily
a block-trade
test,
asserted
through
the
RFQ
entry
point). 6b.
Client-retry-after-accept
→
clean
reject,
no
second
ticket,
no
duplicate
core
call.
QuoteRequestRemoved.
RfqRequest/RfqEvent,
incl.
optional-sides RfqQuote
and
the
submit_quote_ignores_unknown_keys
degradation
guard.
QuoteRequestPosted.
QuoteRequestPosted
must
not
contain
the
requester's user_id;
the
rfq_log
row
and
the
ticket
carry
real
accounts.
DONE_AWAY:
on
accept,
other
quotes
emit
QuoteRemoved
with
a done-away
reason.
position_cache
flow-through:
after
clear,
a
tight
opposite-side
follow-on
order reflects
the
new
position
(inherited
block-trade
behavior;
regression
guard).
Per the project rule, scenarios 7–10 (client disconnect, responder disconnect, restart/recovery) must exist and pass before rollout.
Tracked
under
A-3211
(full
surface
spec
+ milestone
list).
Reuse
policy:
check
@architect-xyz/ui-components
and
@architect/ui before
writing
any
new
util/hook/component
(CLAUDE.md);
the
block-trade
GUI
prototype (book/affirm/blotter)
is
the
nearest
surface
—
the
RFQ
responder
fills
view
and
the
ticket blotter
should
share
components.
InsertOrder-with-quote
adds
a
second
settlement
surface
with
its
own
dedup/idempotency questions,
alongside
the
hardened
block-trade
path.
It
also
books
a
lit-book
order/trade, not
a
block
print
(wrong
tape
condition,
wrong
threshold
semantics).
Reusing
the
block- trade
core
keeps
one
audited
path
and
correct
block-print
semantics.
Revisit
only
if
AIEX requires
EP3-native
SEF
semantics
(Q8).
bt
path
(rejected,
D2).
Synthesizing
the maker's
affirm
reuses
the
public
path
verbatim
but
adds
a
redundant
round-trip
and
a window
where
the
affirm
could
be
lost.
The
RFQ
accept
already
is
the
affirmation; born-affirmed
entry
removes
both.
rome
service
(rejected,
D4).
A
ticket/negotiation
needs
only
what
the order-gateway
already
owns;
the
prototype
service
and
its
compose
entry
were
closed
the day
they
were
built.
In-process
removes
gateway⇄service
IPC
entirely.
InsertTwoSidedBlockTrade
—
the
booking
core (§2.2).
ROME
feeds
it
tickets;
it
never
books
itself.
AmendQuote
is
v2
(F4).
accept-other-quote)
(v2).
Each carries a default so we can ship without sign-off; a decision replaces the placeholder.
| # | Question | Default | Owner |
|---|---|---|---|
| Q1 | Counterparty
disconnect
between
SubmitQuote
and
AcceptQuote
—
cancel
or
leave
live? |
Cancel the quote (mirror order behavior) | tin (A-3295 item 5) |
| Q2 | Two-sided requests — partial acceptance? | No — accepting closes the request | tin |
| Q3 | Quote replacement — amend or cancel+resubmit? | Cancel+resubmit
v1;
AmendQuote
v2
(F4) |
tin |
| Q4 | Born-affirmed
seam
—
enter
at
accepted → booking,
or
real
allege+affirm
round-trip? |
Born-affirmed internal entry (D2); ROME alone calls it | tin (A-3622) |
| Q5 | Accept
idempotency
—
does
a
reconnecting
client
need
client_accept_id
+
a
status
query? |
Reuse
the
ticket
blotter
(bt
query)
keyed
by
produced
ticket_id;
add
ROME→ticket
correlation |
tin |
| Q6 | Fair-market gating — enforce in v1 or stub? | Stub as config knobs (§6); enforce at AIEX/SEF tier | product |
| Q7 | Fee schedule for RFQ (block) fills | Standard block-trade fees v1; rebates v2 (F7) | commercial |
| Q8 | Ever
adopt
EP3-native
Quotes
(SEF
DONE_AWAY
audit
on
EP3)? |
No for v1 — settle as block trades (D1); revisit for AIEX | tin / product |
| Q9 | EP3 multi-leg block trades — native, N-call, or spread-as-instrument? | Single-leg v1; vendor question unasked (block-trade plan §2f) | tin (F1) |
| # | Feature | AX (v1) | EP3 native RFQ | Bybit RFQ | Deribit Block RFQ |
|---|---|---|---|---|---|
| 1 | Instruments | Single, perp-style | Single | Spot+perp+future+option | Option+perp+future |
| 2 | Multi-leg | ❌ (v2: F1) | ❌ single | ✅ | ✅ ≤20 legs |
| 3 | Settlement primitive | InsertTwoSidedBlockTrade
(block
print) |
InsertOrder
w/
quote
(order/trade) |
exchange book | block print |
| 4 | Quote sides | Bid / Ask / Both | one-way | one/two-way | one/two-way |
| 5 | Targeted vs public | ✅
target_makers |
✅
firms
whitelist |
✅ | ✅ |
| 6 | Anonymity | ✅
disclose_identity |
❌ (associated firms) | ✅ | ✅ blind |
| 7 | Competing-quote DONE_AWAY | ✅ | ✅ | partial | ✅ |
| 8 | Quote pass / decline | ✅ (cancel/no-accept) | ✅
PassQuote |
✅ | ✅ |
| 9 | Quote aggregation | ❌ (v2: F3) | ❌ | partial | ✅ |
| 10 | Quote replacement | ❌ cancel+resubmit (v2: F4) | ✅ | ✅ | ✅ |
| 11 | Min block size | stub config (§6) | BlockTradeThreshold |
✅ | ✅ |
| 12 | Min unaffiliated firms | stub config (§6) | ✅ admin knob | n/a | n/a |
| 13 | Self-RFQ restriction | policy flag (§6) | ✅
restrictFirmAcceptOwnRfqs |
n/a | n/a |
| 14 | Expiration | ✅ caller sets, server enforces | ✅ | ✅ | ✅ |
| 15 | Maker discovery | ✅ favorites + targeting | ✅ firm associations | ✅ | ✅ |
| 16 | Public tape print | ✅ block condition (rides tape work) | product-dependent | ✅ | ✅ |
| 17 | Fee rebate | ❌ standard block fees (v2: F7) | n/a | ✅ | ✅ |
| 18 | Per-user rate limit | ✅ (A-3294) | ✅ | ✅ | ✅ |
| 19 | History / analytics | ✅
rfq_log
+
block_trades |
✅
ListRfqHistory |
✅ | ✅ |
| 20 | Book-exactly-once on no-dedup RPC | ✅ (inherited) | n/a | n/a | n/a |
| 21 | Cancel-on-disconnect | ✅ (A-3295) | — | ✅ | ✅ |
| 22 | FIX entrypoint | ❌ (v2) | ✅
QuoteResponse <AJ> |
— | — |
| 23 | WS streaming | ✅ | ✅ subscription | ✅ | ✅ |
| Term | Meaning |
|---|---|
| RFQ | Request for Quote — a participant asks the market for a price, makers respond, requester accepts one |
| ROME | The AX RFQ negotiation layer (in the order-gateway, not a separate service) |
| Block-trade protocol | The
AX
two-sided
ticket
layer
(bt
WS
family,
block_trades
Postgres
SoR)
that
books
once
to
EP3
(A-3622);
ROME's
settlement
layer |
| Born-affirmed ticket | A consummated two-sided ticket ROME produces on quote-accept, entering the booking core past the allege/affirm hop (both parties already agreed via the RFQ handshake) |
| EP3 | The Connamara matching engine; the block-trade core books into it |
InsertTwoSidedBlockTrade |
The
two-sided,
atomic,
single-leg
EP3
block-trade
RPC;
no
dedup,
no
idempotency_key |
| EP3 native RFQ | EP3's
separate
Quotes
service
(accept-via-InsertOrder);
the
reference
workflow
and
fair-market-config
source,
not
AX's
settlement
path
(§6) |
| DONE_AWAY | A losing quote when another quote on the same RFQ is accepted |
| Min unaffiliated firms | The minimum distinct unaffiliated firms an RFQ must reach for a fair market (SEF/CFTC); an EP3 admin knob ROME mirrors as config |
cross_id |
Fresh-ULID correlation key minted by the booking core (not ROME); propagates to both order legs and drop-copy executions |