Date: 2026-06-22
Status: Draft
Scope: the order gateway's internal representation of an order and its cancel-replace history. This RFC proposes a unifying "order chain" model that lets clients race a cancel or replace against an unacknowledged place and against an in-flight replace. It does not change the external protocol — clients continue to see the 2-order replace model (a new order id per replace) and the same WebSocket events.
A market-making client needs to cancel-replace an order before it has received the acknowledgement for the operation that created it — both replace-after-place and replace-after-replace, at sub-round-trip latency (A-3824).
The order gateway (OG) cannot do this today because its replace machinery is a set of depth-1 devices, each handling exactly one in-flight replace. The root cause is that three identifiers are reconciled ad hoc on every replace instead of being modeled once:
| Identifier | Stability | Audience |
|---|---|---|
client
cid |
stable across the lineage | client-facing |
EP3
order_id
(E0) |
stable — EP3 replaces in-place | exchange-facing |
AX
order_id
(O0 → R1 → R2 …) |
new
per
replace;
doubles
as
the
EP3
clord_id |
internal |
This
RFC
introduces
a
first-class
order
chain:
one
lineage
holding
an ordered
list
of
versions
(the
AX
order
ids),
with
a
single
stable
EP3
handle E0
and
per-version
correlation
by
the
minted
clord_id.
The
remainder
of
the RFC
is
organized
as:
the
current
place/replace/cancel
flows
(§3),
the proposed
flows
(§4),
and
the
machinery
the
proposal
needs
(§5).
Two facts from EP3 anchor the design:
InsertOrder
returns
E0
synchronously
(the
id
is
globally
unique
on enqueue,
independent
of
matching-engine
processing).
So
the
OG
holds
E0
the instant
the
place
RPC
returns.
E0,
and
EP3
echoes our
minted
clord_id
on
both
the
success
execution
and
the
asynchronous reject.
So
E0
addresses
the
lineage
and
clord_id
attributes
each
outcome.
Notation
below:
O0
is
the
first
AX
order
id;
R1,
R2
are
replacement
ids; E0
is
the
EP3
order
id;
cid
is
the
client
order
id
(constant
across
the lineage).
Accepted
/ PartiallyFilled
and
rejects
Pending,
even
though
E0
is
already
known
once the
place
RPC
returns.
replace_pending
flag
rejects
a
second replace
while
the
first
is
in
flight.
The
guard
is
load-bearing:
a
second replace-by-cid
resolves
(via
the
deferred
index)
to
the
original,
so
it
would produce
a
second
replacement
also
pointing
at
the
original
—
two
replacements claiming
one
parent,
a
broken
FIX
chain,
and
a
double
margin
swap.
Both are consequences of the depth-1 representation, not of any EP3 limitation.
client → OG : place(cid, params)
OG : mint O0; insert OrderLog{ state=Pending, ep3_order_id="",
clord_index[(user,cid)] = O0 }
OG → EP3 : InsertOrder(clord=O0, params) ⇒ returns E0 (synchronous)
OG : set ep3_order_id(O0) = E0
OG → client: ack { order_id: O0 } ← the ack the client sees
… later …
EP3 → OG : dropcopy New(clord=O0) ⇒ O0: Pending → Accepted
client → OG : replace(ref = cid | O0, new_params)
OG : resolve ref → O0
GATE: state ∈ {Accepted, PartiallyFilled} (Pending ⇒ reject 400)
AND not replace_pending (else reject 409)
AND ep3_order_id non-empty
OG : mint R1; begin_replace:
O0.replace_pending = true
insert R1{ state=Pending, ep3_order_id="", replaces_order_id=O0 }
margin: swap O0 → R1
clord_index stays on O0 (deferred transfer)
OG → EP3 : CancelReplace(order_id=E0, clord=R1, new_params) ⇒ empty response
OG → client: ack { order_id: R1 }
… later, one of …
EP3 → OG : dropcopy Replace(clord=R1) ⇒ promote R1; R1.ep3_order_id = E0;
clord_index O0 → R1; O0 → Replaced;
clear replace_pending
EP3 → OG : dropcopy CancelReject(clord=R1, is_replace)
⇒ rollback R1; restore O0
A
second
replace
arriving
before
the
Replace
dropcopy
hits replace_pending = true
→
409
rejected.
client → OG : cancel(ref)
OG : resolve ref
GATE: can_be_canceled (Pending/Accepted/PartiallyFilled)
reject if ep3_order_id empty (PlaceInFlight 409)
reject if replacement in flight (ReplaceInFlight 409)
OG → EP3 : CancelOrder(order_id=E0)
EP3 → OG : dropcopy Canceled ⇒ mark terminal
The
chain
is
created
on
place
and
is
the
unit
every
operation
resolves
to.
A replace
appends
a
version;
the
head
is
the
latest
non-rejected
version
(the client's
current
intended
order);
at
most
one
version
is
Live.
client → OG : place(cid, params)
OG : create Chain{ cid, ep3_order_id="", versions=[V0=O0: InFlight] }
index O0 → chain; (user,cid) → chain
OG → EP3 : InsertOrder(clord=O0, params) ⇒ returns E0 (synchronous)
OG : chain.ep3_order_id = E0; index E0 → chain
OG → client: ack { order_id: O0 }
… later …
EP3 → OG : dropcopy New(clord=O0) ⇒ V0: InFlight → Live
client → OG : replace(ref = cid | any version id, new_params)
OG : resolve ref → chain
GATE: chain.ep3_order_id known (else 409 retry — see §5.5)
— no Accepted requirement, no replace_pending check
OG : append version Vn=R: InFlight; reserve margin for head=R
OG → EP3 : CancelReplace(order_id=E0, clord=R, new_params)
OG → client: ack { order_id: R }
… per version, attributed by its clord …
EP3 → OG : dropcopy Replace(clord=R) ⇒ R: Live; prior head: Superseded
EP3 → OG : dropcopy CancelReject(clord=R) ⇒ R: Rejected; head reverts to prior
Worked racing example — place, then two replaces before any ack:
client : place(cid) → O0 chain.versions = [O0]
client : replace(cid, p1) → R1 chain.versions = [O0, R1] head=R1
client : replace(cid, p2) → R2 chain.versions = [O0, R1, R2] head=R2
EP3 FIFO emits, OG stamps by clord:
New(O0) ⇒ O0 Live → Superseded
Replace(R1) ⇒ R1 Live → Superseded
Replace(R2) ⇒ R2 Live
end state: E0 carries p2; R2 is the live order; O0/R1 Superseded.
If EP3 rejects R1 (e.g. bad price) but accepts R2:
CancelReject(R1) ⇒ R1 Rejected; Replace(R2) ⇒ R2 Live.
The client learns each outcome by its own order id.
client → OG : cancel(ref)
OG : resolve ref → chain
GATE: chain.ep3_order_id known (else 409 retry)
OG → EP3 : CancelOrder(order_id=E0)
EP3 → OG : dropcopy Canceled ⇒ chain + all versions terminal
Chain // one live order lineage
ep3_order_id: String // E0 — single stable exchange handle; empty ONLY
// in the pre-place-ack sliver
cid: Option<ClientOrderId>// stable; never transferred
user_id, instrument_id
versions: Vec<Version> // ordered: O0, R1, R2, … (head = last non-rejected)
cum_qty, leaves_qty // chain-level, authoritative from EP3 (continuous
// across in-place replaces)
margin // chain-level, reserved for the head version
Version
ax_order_id: OrderId // == the EP3 clord_id for this request —
// the per-request correlation key
params // price, qty, tif, post_only
status // InFlight | Live | Superseded | Rejected | Terminal
event_number
ax_order_id → chain
—
always,
for
every
version.
(user_id, cid) → chain
—
when
a
cid
is
present;
stable,
no
deferred
transfer.
ep3_order_id → chain
—
once
E0
is
known.
| Event (key) | Effect |
|---|---|
place
creates
O0 |
O0 = InFlight |
replace
appends
R |
R = InFlight,
head
←
R |
New
(clord) |
version
→
Live |
Replace
(clord) |
version
→
Live;
prior
head
→
Superseded |
CancelReject
is_replace
(clord) |
version
→
Rejected;
head
←
prior
version |
Canceled
/
terminal
fill
(E0) |
live
version
+
chain
→
Terminal |
EP3
session
ordering
is
FIFO,
so
the
execution
order
is
authoritative
for which
version
is
Live;
the
OG
only
stamps
status
as
messages
arrive.
clord:
Replace
and
CancelReject (is_replace).
This
is
the
attribution
that
distinguishes
which
replace succeeded
vs.
was
rejected,
and
it
already
exists
today.
E0:
New,
fills,
plain
cancel.
Unambiguous
because exactly
one
version
is
Live
—
which
removes
today's
find-by-ep3_order_id ambiguity
(multiple
versions
share
E0).
A
replace
or
cancel
that
beats
the
place
RPC's
return
—
E0
not
yet
known
— returns
a
retryable
409
(chain.ep3_order_id.is_empty()).
This
single
"not ready"
condition
replaces
today's
scattered
replace_pending, empty-replacement-id,
and
NotReplaceable-on-Pending
cases.
No
queueing;
the client
retries.
replace_pending
flag
and
the
depth-1
"one
in-flight
replace"
guard
→
version statuses.
replaces_order_id
as
load-bearing
state
→
implicit
in
version
order
(kept,
if useful,
only
as
a
display
back-pointer).
(user, cid)
keys
the
chain
directly.
ep3_order_id
+
async
back-fill
→
one
chain-level
E0.
begin_replace
→
chain-level
head reservation.
The change removes more special-case state than it adds; that is the justification for a quasi-rewrite over another patch.
order-gateway/src/open_orders.rs
—
the
core
structure,
plus
insert, begin_replace,
cancel,
and
the
replaceable/cancelable
lookups.
order-gateway/src/lib.rs
—
drop-copy
handlers
for
New,
Replace,
Fill, CancelReject,
reframed
as
chain/version
transitions.
persist_pending_recovery_artifacts
and
sync_initial_open_orders reconcile
chains
against
EP3's
authoritative
E0
state
on
startup.
rest_service.rs
/
ws_service.rs
—
request/response
shapes
unchanged; internal
calls
move
to
chain
operations.
(user, cid)
index
is
additive
when
present.
OrderReplacedOrAmended,
etc.).
This
is
an internal
representation
refactor,
which
bounds
the
blast
radius
and
makes
the existing
replace
test
suite
the
regression
oracle.
Order-lifecycle and risk-control code: the rollout must exercise client disconnect, server-initiated disconnect, restart/crash, and reconnect/recovery before ship. Specific risks:
Replace
exec
can
arrive
for
a
version
the
OG
no longer
tracks.
The
chain
must
reconcile
these
idempotently
rather
than
error.
The
existing
order-gateway/tests/sections/test_replace_orders.rs
race
and drop-copy
suite
is
the
regression
baseline;
new
cases
should
cover
depth-N in-flight
chains
and
per-version
success/reject
attribution.