RFC: Order Chain Lifecycle

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.

1. Summary

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:

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).

2. Why racing is blocked today

Both are consequences of the depth-1 representation, not of any EP3 limitation.

3. Current flows

3.1 Place

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

3.2 Replace

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.

3.3 Cancel

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

4. Proposed flows

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.

4.1 Place

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

4.2 Replace (races and stacks)

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.

4.3 Cancel

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

5. Machinery for the proposed flows

5.1 Data structures

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

5.2 Indexes

5.3 Version status transitions

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.

5.4 Outcome correlation (drop copy)

5.5 The one residual rejection

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.

5.6 What this deletes

The change removes more special-case state than it adds; that is the justification for a quasi-rewrite over another patch.

5.7 Affected components

6. Design decisions

7. Risks and testing

Order-lifecycle and risk-control code: the rollout must exercise client disconnect, server-initiated disconnect, restart/crash, and reconnect/recovery before ship. Specific risks:

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.

8. Rollout

  1. Land this RFC.
  2. Implement the chain model behind the unchanged external protocol, keeping the full replace test suite green.
  3. Add depth-N pipelining tests and recovery tests.
  4. Enable replace-before-ack (both forms) once the suite and a soak on ax-demo are clean.