SoW: Order details on rejected cancels and modifies

Date: 2026-10-02

RFC: cancel-reject-order-details

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

Concept

Every rejected cancel and every rejected modify sends a CancelRejected event to the account. The event carries the order in its current state and a typed reason, rr. An unknown order, including an order owned by another account, carries rr and no order. This holds on both gateways and for synchronous and asynchronous rejections. The error responses do not change, except that Bitnomial returns 400 in place of 404 for a terminal order.

   client: cancel / modify
        |
        v
   gateway lookup and checks --- rejects ---> CancelRejected(rr, order) ---> WS sessions on the account
        |                                     + error response (unchanged)     + admin event stream
        |  accepts
        v
   venue ------ rejects on drop copy ---> CancelRejected(rr, order)
        |
        v
      ack

rr is absent when the venue's reason maps to no value. order is absent if and only if the order is unknown.

What exists on main

At 6af587c.

Area State
AX, asynchronous cancel reject Sends CancelRejected with the order. cancel_rejected_event in rs/order-gateway/src/lib.rs. No rr.
Bitnomial, asynchronous cancel reject Sends CancelRejected with the order, including a terminal state. on_cancel_reject in rs/btnl-order-gateway/src/order_tracker.rs. No rr.
Synchronous cancel and modify reject, both gateways Sends no event. Error response only.
Typed reason on t="e" None. reject_reason is a String.
oid on t="e" Required.
Bitnomial, cancel or modify of an owned terminal order 404 "order not found". OrderBook::resolve skips terminal orders.

Open PRs

All are open drafts. The phase letters are used in the rest of this document.

Phase PR Branch Base Scope
A #4246 jmc/cancel-reject-reason main SDK: CancelRejectReason, rr, optional oid
B #4247 jmc/cancel-lookup-order A AX: the cancel lookup keeps the order
C #4395 jmc/cancel-reject-cancel-emit B AX: cancel rejects send the event; drop copy sets rr
D #4368 jmc/cancel-reject-replace C AX: modify rejects send the event
A-5057 #4369 jmc/btnl-terminal-order-400 A Bitnomial: 400 for a terminal order
E #4389 jmc/btnl-cancel-reject A-5057 Bitnomial: cancel rejects send the event; drop copy sets rr
F #4390 jmc/btnl-replace-reject E Bitnomial: modify rejects send the event
G #4392 jmc/cancel-reject-docs main AsyncAPI, public API docs, SDK skill
H #4394 jmc/cancel-reject-gui main GUI toasts and types

A-4955 has three open PRs that touch the same SDK file: #4103, #4106, and #4107. Phase A conflicts with #4103 in one hunk of rs/sdk/src/protocol/order_gateway.rs. The RFC section "Relationship to A-4955" shows the hunk and how to resolve it.

Phase A: SDK

rs/sdk/src/protocol/order_gateway.rs.

Tests, beside the existing cancel_rejected_* tests:

The PR has a Public-Changelog: trailer, because the SDK change is visible to customers.

Phase B: AX cancel lookup

rs/order-gateway/src/open_orders.rs. No change in behavior.

The order is beside the error, not in the enum variants, because Order does not implement PartialEq. Variants with an Order would break derive(PartialEq) on CancelOrderError and every assert_eq! against it. A match on error is still exhaustive. The Box is there because clippy::result_large_err rejects the carrier by value at 264 bytes.

Existing tests pass unchanged, with assertions moved to err.error and err.order.

Phase C: AX cancel

rs/order-gateway/src/exchange_reject.rs, rest_service.rs, lib.rs.

CancelReject in exchange_reject.rs holds a reason, a message, the identifiers, and the order. It sends CancelRejected through state.event_broadcaster and writes no ClickHouse rows, because a rejected cancel leaves the order live. Bitnomial follows the same rule (cancel_reject_keeps_order_and_margin_live in rs/btnl-order-gateway/src/inbound.rs). The broadcaster is synchronous, so the call sites do not await.

Constructor Use
CancelReject::for_order(reason, message, &order) A known order. Attaches the order.
CancelReject::unknown_order(message, &order_ref) An unknown or not-owned order. Reports UNKNOWN_ORDER, no order, and only the identifier the request named.
CancelReject::for_rpc_error(&e, &order) An EP3 RPC error. Returns None unless is_definitive_rejection() is true. Phase D renames it from for_cancel_rpc_error.

A site sends the event by calling .notify(state, account_id). A site that does not call it sends nothing.

Call sites.

Drop copy. cancel_rejected_event in lib.rs sets rr from reason_for_ep3_cancel_reject:

EP3 CxlRejReason rr
ExchangeOption none
UnknownOrder, ExchangeClosed EXCHANGE_REJECTED
IncorrectQuantity INVALID_QUANTITY
InvalidPriceIncrement, PriceOutOfBounds INVALID_PRICE
InsufficientCreditLimit INSUFFICIENT_MARGIN

An order the gateway does not know is not delivered, as on main. test_cancel_rejected_unknown_order_is_dropped passes unchanged.

Tests. Unit tests in exchange_reject.rs:

Integration tests in rs/order-gateway/tests/sections/test_cancel_rejected.rs:

assert_no_more_cancel_rejects checks that one rejection sends one event.

Phase D: AX modify

rs/order-gateway/src/open_orders.rs, exchange_reject.rs, rest_service.rs.

Lookup. get_replaceable_order returns ReplaceLookupFailure, with the same shape and reasons as CancelLookupFailure. order is None for NotFound and NotOwned, and Some for NotReplaceable and ReplacePending. reason_for_lookup_replaceable_error maps the error to a reason. resolve_replaceable_order sends the event for each error.

Validation exits. Each exit in replace_order_common before begin_replace calls reject_replace_and_notify:

return Err(reject_replace_and_notify(
    state,
    &original_order,
    account_id,
    CancelRejectReason::InvalidPrice,
    StatusCode::BAD_REQUEST,
    "invalid order price for symbol ...",
));

The function sends the event and returns the error response. The event text and the response text come from the same value, so they always match. The function sends the event when it builds the value, so a caller must return the value where it builds it. A second call sends a second event.

Exit rr
reject_new_orders set SERVICE_UNAVAILABLE
user frozen USER_FROZEN
system unavailable SERVICE_UNAVAILABLE
instrument not found, or instrument access denied INSTRUMENT_NOT_TRADABLE
invalid size, or quantity below filled quantity INVALID_QUANTITY
price not a multiple of the tick, or scaled price overflow INVALID_PRICE
close-only account, replacement not closing CLOSE_ONLY
invalid time-in-force INVALID_TIME_IN_FORCE

At and after begin_replace. reason_for_replace_error maps ReplaceOrderError. InvalidOrderId and MarginError are internal and map to no reason, so they send nothing. Insufficient margin sends INSUFFICIENT_MARGIN with the original order. A definitive EP3 RPC rejection sends EXCHANGE_REJECTED through CancelReject::for_rpc_error. These two paths also send the existing OrderRejected for the replacement order. The three opaque_internal_response() exits and ambiguous EP3 errors send nothing.

A rejected modify does not change the original order. The rollback of the replacement order runs as on main. No ClickHouse row is written for the original order.

Tests. Unit tests: replace_lookup_errors_map_to_the_intended_reason and replace_errors_map_to_the_intended_reason.

Integration tests in test_replace_rejected.rs. Each checks that exactly one CancelRejected arrives, that it carries the original order, and that the original order is still open and unchanged:

Changes to existing integration tests:

No AX integration test covers the close-only exit or the insufficient-margin path of a synchronous modify.

A-5057: Bitnomial terminal order

rs/btnl-order-gateway/src/order_ops.rs, order_book.rs, order_tracker.rs.

OrderBook::resolve_terminal finds an owned order that is terminal. resolve_for_action calls it after resolve. A cancel of an owned terminal order returns 400 "order cannot be canceled". A modify returns 400 "order cannot be replaced". These are the AX texts. Unknown and foreign orders still return 404. A live order that reuses a client order id takes priority over a terminal one.

Tests: cancel_or_replace_of_a_terminal_order_is_a_400, cancel_of_a_foreign_terminal_order_is_a_404, and resolve_terminal_finds_only_owned_terminal_orders.

Phase E: Bitnomial cancel

rs/btnl-order-gateway/src/cancel_reject.rs (new), order_ops.rs, check_margin.rs, inbound.rs, order_tracker.rs, rest_service.rs, ws_service.rs.

order_ops.rs handles cancel and modify for both REST and WebSocket. OrderOpError is its error type.

OpReject in cancel_reject.rs holds an OrderOpError, an optional reason, and an optional BTP order id. Conversion from a bare OrderOpError sets no reason. OrderOpError::reported(reason, btp_order_id) sets one. OpReject::notify sends CancelRejected only when a reason is set, then returns the OrderOpError for the response. It reads the order from the order book at that time. If the order is no longer in the order book, it reports UNKNOWN_ORDER with no order.

cancel calls cancel_inner, which returns OpReject, and calls notify on the error. Both REST and WebSocket use this one path.

Cancel failure rr
unknown or foreign order UNKNOWN_ORDER, no order
owned terminal order NOT_CANCELABLE
replace pending on the order REPLACE_PENDING
venue channel closed SERVICE_UNAVAILABLE
liquidation order id (L-) none

Admission. can_mutate_existing_order in check_margin.rs returns MutationAdmissionError. report_admission_reject in order_ops.rs sends the event with the order, including a terminal order.

Admission failure rr
UserFrozen USER_FROZEN
SystemUnavailable, VenueConnectivityDegraded SERVICE_UNAVAILABLE
OrderEntryDisabled, ReadOnlyApiKey, user, onboarding, and account errors, MaxOpenOrdersExceeded none

Drop copy. on_cancel_reject takes rr from reason_for_btp_cancel_reject:

BTP reject code rr
order already closed NOT_CANCELABLE
quantity above maximum, quantity below minimum INVALID_QUANTITY
price outside bands, price outside limits, price not tick-aligned INVALID_PRICE
product not found, market halted, market closed INSTRUMENT_NOT_TRADABLE
messaging rate exceeded, connection disabled SERVICE_UNAVAILABLE
account not found, order not found, order already exists, order not changed, give-up account not found, give-up unauthorized, position limit exceeded, feature not supported EXCHANGE_REJECTED
any other code none

The event on this path always carries the order, so the venue's "order not found" reports EXCHANGE_REJECTED. The BTP code is a u8, so this mapping has a default arm.

Tests. btp_cancel_reject_codes_map_to_the_intended_reason covers every code from 0x01 to 0x13 and two unknown codes. Tests in order_ops.rs:

Phase F: Bitnomial modify

rs/btnl-order-gateway/src/order_ops.rs, check_margin.rs.

replace calls replace_inner, which returns OpReject, and calls notify on the error.

Modify failure rr
unknown or foreign order UNKNOWN_ORDER, no order
owned terminal order NOT_REPLACEABLE
replace pending REPLACE_PENDING
invalid time-in-force INVALID_TIME_IN_FORCE
invalid quantity INVALID_QUANTITY
instrument not tradable INSTRUMENT_NOT_TRADABLE
invalid price INVALID_PRICE
close-only account (check_margin) CLOSE_ONLY
insufficient margin (check_margin) INSUFFICIENT_MARGIN
venue channel closed SERVICE_UNAVAILABLE
post-only requested, liquidation order id, margin computation unavailable none

CLOSE_ONLY and INSUFFICIENT_MARGIN are the values AX sends for the same conditions.

Tests in order_ops.rs: modify_rejects_report_reason_with_the_order, modify_by_a_close_only_account_reports_close_only, modify_of_unknown_and_terminal_orders, and modify_with_post_only_is_silent.

Phase G: Docs

PR G also edits this SoW. That edit conflicts with this version of the file and must be removed from PR G.

Phase H: GUI

Merge order

A ──> B ──> C ──> D             AX: lookup, cancel, modify
└──> A-5057 ──> E ──> F         Bitnomial: terminal order, cancel, modify
G, H: merge after D and F

A goes first. B, C, and D merge in order. A-5057, E, and F merge in order. The AX and Bitnomial lines do not depend on each other.

G merges after D and F. One AsyncAPI spec covers both gateways. If G merges before a gateway sends the event, the docs are wrong for that gateway: a client would read a missing o as an unknown order.

H merges after D and F. PR H states this order.

Gates for each PR:

just rs/format
cargo clippy -p <crate> --manifest-path rs/Cargo.toml
cargo test -p <crate> --manifest-path rs/Cargo.toml
cargo insta test --accept -p ax-exchange-sdk --manifest-path rs/Cargo.toml
just rs/lint                  # machete, check-sdk-leaks, check-comment-references
just update-docs              # phase G only

For SDK unit tests, use --features schemars,utoipa --lib. --all-features includes the production smoke tests, which need production credentials.

Running the integration tests

The order-gateway integration suite needs Postgres, ClickHouse, and Redis containers, and starts one stack per test shard. Each integration test is a pub async fn that must also have a call line in its shard in rs/order-gateway/tests/integration_tests.rs. They are not #[test] functions.

Acceptance

Decisions

  1. The lookup error and the order are separate fields. Order does not implement PartialEq, so it cannot go in the variants of an enum that derives it. The order is boxed for clippy::result_large_err.
  2. reject_replace_and_notify both sends and returns. A version that sends in one statement and returns in another would repeat the message string, and the event text and the response text could then differ.
  3. Bitnomial reads the order at notify time. OpReject holds the BTP order id, not the order, so the event shows the order as it is when the gateway sends the event.
  4. Bitnomial post-only and liquidation rejections send no event. Both checks run in cancel_inner or replace_inner before resolve_for_action, so no order is resolved when they reject.

Out of scope

Cancel-all. It is one FindAndCancelOrders call with no per-order result, so there is no single order to report.