RFC: Order details on rejected cancels and modifies

Date: 2026-10-02

Status: Proposed. Built in draft PRs; the SoW lists them.

Tracking: A-5050. Related to A-4955, which answers the other half of the same customer request.

Summary

When a gateway rejects a cancel or a modify, it sends a CancelRejected event (t="e") to every WebSocket session on the account. The event carries the order in its current state and a typed reason, rr. The order is absent only when the order is unknown. The error response to the request does not change.

This applies to both gateways, AX (EP3) and Bitnomial. It applies to a rejection the gateway reports in its reply to the request (synchronous) and to a rejection the venue reports later on the drop copy (asynchronous).

The design follows four rules, in priority order:

  1. Ownership stays hidden. A request that names an order owned by another account gets the same event as a request that names no order: rr is UNKNOWN_ORDER and there is no order.
  2. An ambiguous failure sends no event. If the cancel or modify may have reached the venue, the gateway does not report it as rejected.
  3. No event unless a site names a reason. A rejection path sends the event only when its code names a reason. A path that names none sends nothing.
  4. Existing responses do not change. The status code and body of every error response stay as they are, with one exception on Bitnomial (decision 5).

The customer request

A customer asked:

Always include the order in question (not currently happening for 100% of rejections), and echo the current state of the order if not an "unknown order" rejection.

On main, a rejected cancel reaches the client by one of two routes. Only the asynchronous route carries the order.

On the asynchronous route, the venue accepts the cancel and then rejects it on the drop copy. The gateway sends a CancelRejected event with the order:

{"t":"e","ts":1704067200,"tn":0,"oid":"ORD-1","r":"too late to cancel",
 "txt":"too late to cancel",
 "o":{"oid":"ORD-1","s":"BTC-PERP","o":"FILLED","q":10,"xq":10, ...}}

On the synchronous route, the gateway rejects the request before it reaches the venue. Examples are an order that is filled, an order the venue has not yet acknowledged, and an order with a modify in progress. The client gets only an error response:

{"rid":7,"err":{"code":404,"msg":"order not found"}}

This response has no order id, no order, and no order state. Every rejection the gateway makes itself takes this route. On AX these are in resolve_cancelable_order, cancel_order_common, resolve_replaceable_order and replace_order_common in rs/order-gateway/src/rest_service.rs. On Bitnomial they are in rs/btnl-order-gateway/src/order_ops.rs and in the admission checks in check_margin.rs.

flowchart TD
    C[client: cancel or modify] --> G{gateway validates}
    G -- "rejects" --> E["error response<br/>{code, msg}<br/><b>no order</b>"]
    G -- "accepts, sends to venue" --> V[venue]
    V -- "ack" --> OK[OrderCanceled / OrderReplacedOrAmended]
    V -- "rejects on drop copy" --> CR["CancelRejected<br/><b>carries the order</b>"]

The event on main also has no typed reason. CancelRejected.reject_reason (r) is a free-text String. On AX, cancel_rejected_event in rs/order-gateway/src/lib.rs copies the venue text into both r and txt. A client cannot identify an unknown-order rejection without reading the text. The order-reject event t="j" has a typed OrderRejectReason; t="e" has none.

The event

Field Type Rule
oid Option<OrderId> The order id. Absent only when the request named an unknown client order id.
cid Option<ClientOrderId> The order's client order id, or the client order id the request named when that order is unknown.
r String Free text. Unchanged.
txt String Free text. Unchanged.
rr Option<CancelRejectReason> The typed reason. Absent when the venue gives a reason that does not map to a value.
o Option<OrderDetails> The order in its current state. Absent only when the order is unknown.

rr is new. oid was required and is now optional. All other fields exist on main. A producer or consumer that does not know rr continues to work, and oid is present in every case that exists on main.

The event reports only the identifier that the request named. When a request names an unknown client order id, there is no order id to report, so oid is absent and cid carries the value the client sent.

The current state in o is often terminal. A cancel usually fails because the order filled first, so o shows the order as FILLED.

Reasons

CancelRejectReason is in rs/sdk/src/protocol/order_gateway.rs. It serializes as SCREAMING_SNAKE_CASE. An unrecognized value deserializes as UNKNOWN, so a newer producer does not break an older consumer.

Value Meaning Sent for
UNKNOWN_ORDER No order on this account matches the order id or client order id. No o. cancel, modify
NOT_CANCELABLE The order is in a state that cannot be canceled. cancel, drop copy
NOT_REPLACEABLE The order is in a state that cannot be modified. modify
REPLACE_IN_FLIGHT The order is a replacement the venue has not acknowledged. Cancel the original order id. cancel
PLACE_IN_FLIGHT The venue has not acknowledged the order. cancel
REPLACE_PENDING A modify is already in progress for this order. cancel, modify
INVALID_QUANTITY The quantity is not valid for this order or instrument. modify, drop copy
INVALID_PRICE The price is not valid for this instrument. modify, drop copy
INSUFFICIENT_MARGIN The modified order needs more initial margin than is available. modify, drop copy
CLOSE_ONLY The account is close-only and the modified order does not close. modify
USER_FROZEN Trading is suspended for this user. cancel, modify
EXCHANGE_REJECTED The venue rejected the request. cancel, modify, drop copy
SERVICE_UNAVAILABLE The gateway could not send the request. Retry shortly. cancel, modify, drop copy
INSTRUMENT_NOT_TRADABLE The instrument is closed, expired, or not available for trading. modify, drop copy
INVALID_TIME_IN_FORCE The time-in-force is not valid or cannot be changed. modify
UNKNOWN The value is not recognized. none; receive only

On the drop copy, the gateway maps the venue's reason to rr. On AX the source is EP3's CxlRejReason. EXCHANGE_OPTION maps to no value, so rr is absent. EP3's UNKNOWN_ORDER, for an order the gateway still tracks, maps to EXCHANGE_REJECTED, because the event carries that order. On Bitnomial the source is the BTP reject code.

Unknown orders

o is absent if and only if the order is unknown. Every other rejection carries the order.

The gateway cannot deliver an asynchronous unknown-order rejection. The gateway finds the account from the venue order id. When it does not know the order, it has no account to send the event to, so it sends nothing. On AX this is the drop-copy handler in rs/order-gateway/src/lib.rs. On Bitnomial, cancel_reject_for_unknown_order_emits_nothing in rs/btnl-order-gateway/src/inbound.rs tests it.

A synchronous unknown-order rejection is delivered. The account comes from the session, so the event has rr set to UNKNOWN_ORDER and no o.

Ownership

A cancel or modify that names an order owned by another account gets "order not found" (404), not "forbidden". This prevents a client from probing for orders on other accounts. On AX, resolve_cancelable_order and resolve_replaceable_order map NotOwned to the same response as NotFound. On Bitnomial, resolve_for_action does the same.

The lookup has the other account's order in this case. The event must not carry it, because the order shows the symbol, size, price, side, and owning account. A not-owned rejection therefore reports UNKNOWN_ORDER with no order. On the wire it is identical to a rejection for an order that does not exist. Each gateway has a test for this case.

Ambiguous failures

On main, cancel_order_common maps every EP3 RPC error to a 500. Some of these errors are ambiguous: Unavailable, DeadlineExceeded, Internal, Unknown, Cancelled, and Aborted. After such an error the cancel may have reached EP3. If the gateway reported it as rejected, the client could act on a live order as if it were not canceled.

The gateway sends the event for an RPC error only when ExchangeReject::is_definitive_rejection() in rs/order-gateway/src/exchange_reject.rs returns true. The modify path on main already uses this check before it sends OrderRejected. The cancel path now uses the same check. An ambiguous error, and an error that is not an RPC status, sends no event.

Delivery

The gateway routes events by account, not by session (RoutedEvent in rs/order-gateway/src/state.rs). Every WebSocket session on the account receives the event. This includes other users on the account and the session that sent the rejected request. The admin event stream also receives it under the rejects or orders subscription (handle_admin_event in rs/order-gateway/src/ws_service.rs). A client that sends many rejected cancels therefore adds events to the admin stream.

The event and the error response travel on different channels. The client can receive them in either order. The public documentation added in #4392 says this.

Modify rejections that also send OrderRejected

On AX, some modify rejections happen after the gateway has created the replacement order: insufficient margin, and a definitive EP3 RPC rejection. On main these send OrderRejected for the replacement order. That event stays. The replacement order was rejected, it has a pending ClickHouse row that the gateway must close, and the client tracks it by the id the modify response returned.

These paths now also send CancelRejected for the original order. The two events report different orders: OrderRejected reports the replacement, and CancelRejected reports the original, which is still live.

Decisions

  1. rr is a new field. r stays free text. The GUI reads r through humanizeRejectReason (gui/packages/app/data/orderRejectReasons.ts), and other clients read it as text. A typed r would break them. rr follows the rule in ExchangeReject::reported_reject_reason: report no value rather than a wrong one. A client that wants text reads r. A client that wants to act on the reason reads rr.
  2. The order goes in an event, not in the error response. ws::Response data is a plain string, so the order would need a new response shape, and REST callers would not get it.
  3. Each rejection site names its reason. A single wrapper around replace_order_common cannot do this. The function's error type is (StatusCode, Json<ErrorResponse>), and ErrorResponse holds only error: String (rs/sdk/src/protocol/mod.rs). A wrapper sees a status code and a sentence. Thirteen of the function's exits return BAD_REQUEST for at least nine different reasons. The wrapper could find the reason only by reading the message text, which CLAUDE.md forbids. A wrapper would also send an event by default, which breaks rule 3.
  4. oid is optional. A request that names an unknown client order id has no order id. A placeholder value would fail OrderId::validate (rs/sdk/src/types/order_id.rs). oid is absent only in this case, so no payload that exists on main changes shape. This changes a field that clients have always received, so it needs explicit reviewer approval.
  5. Bitnomial returns 400 for a terminal order. On main, OrderBook::resolve skips terminal orders, so a cancel or modify of a known, owned, terminal order returns 404 "order not found". It now returns 400 with the AX text, "order cannot be canceled" or "order cannot be replaced", and the event reports NOT_CANCELABLE or NOT_REPLACEABLE with the order. Unknown and foreign orders still return 404. This is A-5057, and it is the one change to an existing response.
  6. Bitnomial admission failures report with the order when the cause is the user or the service. A frozen user reports USER_FROZEN. System unavailable and venue connectivity degraded report SERVICE_UNAVAILABLE. Read-only API key, onboarding, and account errors send no event.
  7. The GUI shows one toast per rejection. The toast comes from the t="e" event. The error-response toast uses the same toast id, so the event's toast replaces it, and an error response that arrives after the event shows no toast.

Relationship to A-4955

A-4955 answers the other half of the same customer request. It is three open PRs: #4103 (SDK), #4106 (order gateway), and #4107 (GUI).

The two pieces of work conflict in one hunk of rs/sdk/src/protocol/order_gateway.rs. git merge-tree of #4103 and #4246 on 2026-10-02 shows it:

<<<<<<< #4246
    OrderGatewayEvent::Heartbeat(..) => None,
    OrderGatewayEvent::CancelRejected(rej) => rej.order_id.as_ref(),
=======
    OrderGatewayEvent::Heartbeat(..) | OrderGatewayEvent::RiskSnapshot(..) => None,
    OrderGatewayEvent::CancelRejected(rej) => Some(&rej.order_id),
>>>>>>> #4103

The PR that merges second keeps #4103's Heartbeat | RiskSnapshot line and #4246's as_ref() line. The rest of the file merges cleanly.

Three rules keep the conflict to this one hunk:

Out of scope

Cancel-all. It sends one FindAndCancelOrders call to the venue for many orders. The call has no per-order result, so there is no single order to report.