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.
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.
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. |
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.
rs/sdk/src/protocol/order_gateway.rs.
CancelRejectReason sits beside
CancelRejected. It follows OrderRejectReason
in rs/sdk/src/types/trading.rs: strum Display
and EnumString, schemars, SCREAMING_SNAKE_CASE
on the wire, and #[serde(other)] Unknown. The RFC lists the
values. The names do not refer to a venue; check-sdk-leaks
enforces this.CancelRejected gets
rr: Option<CancelRejectReason>, omitted when
None.CancelRejected.order_id (oid) becomes
Option<OrderId>. It is absent only when the request
named an unknown client order id.order states that it is absent only
for an unknown order. order stays
Option<OrderDetails>.CancelRejected sets
rr to None. Phases C and E set it.rs/sdk-internal/test-batteries/src/cancel_replace_by_cid/helpers.rs)
no longer stops a targeted wait when a CancelRejected
arrives. When two requests race, the event answers the request that lost
while the event for the request that won is still on its way.Tests, beside the existing cancel_rejected_* tests:
cancel_rejected_omits_order_only_for_an_unknown_order
replaces
cancel_rejected_without_order_details_omits_field.cancel_rejected_for_an_unknown_client_order_id_omits_order_id.cancel_rejected_omits_reason_when_it_maps_to_no_variant.cancel_reject_reason_round_trips_and_tolerates_unknown_values.cancel_rejected_legacy_wire_format_deserializes is
unchanged.The PR has a Public-Changelog: trailer, because the SDK
change is visible to customers.
rs/order-gateway/src/open_orders.rs. No change in
behavior.
CancelableEp3Order holds the Order in
place of the symbol. This matches ReplaceableEp3Order,
which already holds the Order.
get_cancelable_ep3_order returns
CancelLookupFailure:
pub(crate) struct CancelLookupFailure {
pub error: CancelOrderError,
pub order: Option<Box<Order>>,
}order is None for NotFound
and NotOwned. It is Some for
NotCancelable, ReplaceInFlight, and
PlaceInFlight.
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.
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.
resolve_cancelable_order: each lookup error builds its
status and message, then calls CancelReject::for_order with
reason_for_cancel_error, or
CancelReject::unknown_order when the failure has no
order.cancel_order_common: the EP3 RPC error path calls
CancelReject::for_rpc_error. An ambiguous error sends
nothing. The response stays 500 "internal server error".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:
cancel_lookup_errors_map_to_the_intended_reasonknown_order_reject_echoes_current_stateorder_is_absent_exactly_when_the_order_is_unknownunknown_order_echoes_only_the_named_identifierep3_cancel_reject_reasons_map_to_the_intended_reasoncancel_rpc_error_reports_only_definitive_rejections:
FailedPrecondition, NotFound, and
InvalidArgument send the event. Unavailable,
DeadlineExceeded, Internal,
Unknown, Cancelled, Aborted, and
a non-RPC error send nothing.Integration tests in
rs/order-gateway/tests/sections/test_cancel_rejected.rs:
test_sync_cancel_unknown_order_emits_reject_without_ordertest_sync_cancel_unknown_cid_matches_unknown_oidtest_sync_cancel_not_owned_is_indistinguishable_from_unknowntest_sync_cancel_terminal_order_emits_reject_with_current_stateassert_no_more_cancel_rejects checks that one rejection
sends one event.
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:
test_sync_replace_invalid_params_report_reason: invalid
quantity, invalid price, and invalid time-in-force. Also checks that the
error response and the event text match.test_sync_replace_unknown_ordertest_sync_replace_not_owned_is_indistinguishable_from_unknowntest_sync_replace_terminal_order_reports_current_statetest_sync_replace_frozen_user_reports_reasonChanges to existing integration tests:
test_replace_post_only_crossing_rejected_by_ep3 checks
for both events: OrderRejected for the replacement, then
CancelRejected with EXCHANGE_REJECTED for the
original.test_admin.rs checks
INSTRUMENT_NOT_TRADABLE with the order.test_cancel_rejected.rs checks that a drop-copy
"insufficient credit" reject reports
INSUFFICIENT_MARGIN.No AX integration test covers the close-only exit or the insufficient-margin path of a synchronous modify.
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.
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:
cancel_of_an_unknown_order_reports_no_ordercancel_of_a_foreign_order_reports_it_as_unknowncancel_of_a_terminal_order_reports_its_statecancel_with_the_venue_down_reports_service_unavailablecancel_of_a_liquidation_id_is_silentfrozen_user_cancel_reports_the_orderrs/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.
schemas/asyncapi-order-gateway.yml:
CancelRejectReason schema with a description per value;
rr on CancelRejectedEvent; oid no
longer required; new descriptions for oid,
cid, and o; an unknown-order example and a
terminal-order example.docs/public/openapi/asyncapi-order-gateway.bundled.json:
regenerated with just update-docs.docs/public/api-reference/order-management/orders-ws.mdx:
t="e" in the event list, and a "Rejected cancels and
modifies" section.rs/sdk/.claude/skills/ax-api-integration/SKILL.md: the
t="e" example and the list of reasons.docs/public/changelog.mdx is generated from
Public-Changelog: trailers. The PR has two trailers: one
for the new event content and one for the Bitnomial 400.PR G also edits this SoW. That edit conflicts with this version of the file and must be removed from PR G.
gui/packages/app/data/types/order.ts:
CancelRejectReason type.
OrderGatewayCancelReject gets optional oid,
and cid, rr, and o.gui/packages/app/data/orderRejectReasons.ts: text for
each new reason. UNKNOWN_ORDER already has text.gui/packages/app/provider/OrdersPub.tsx: the toast
comes from the t="e" event and uses rr before
r. Its title is "Modify Rejected" or "Cancel Rejected",
from the request that was rejected. The error-response toast for a
tracked cancel or modify uses the same toast id,
cancelReject:<key>. The event's toast replaces an
earlier error-response toast. An error response that arrives within 2
seconds after the event shows no toast.gui/apps/admin/src/context/OrdersPub.tsx: uses
humanizeRejectReason in place of r ?? txt, and
handles an absent oid.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.
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.
production.cloudfront.docker.com) and GHCR
(pkg-containers.githubusercontent.com). It allows
mirror.gcr.io, which mirrors Docker Hub. Add it as a
registry-mirrors entry in
/etc/docker/daemon.json. The image names in the code do not
change.cargo test hides the println! lines that
each section prints. A section that did not run looks the same as one
that passed. Run with -- --nocapture and read the
PASSED lines.CancelRejected to every WebSocket session on the account,
with the order in its current state, on both gateways.rr is UNKNOWN_ORDER and there is no
order. The two cannot be told apart on the wire.oid that fails
OrderId::validate.main, except
the Bitnomial 400 for a terminal order.CancelRejected.match from an enum to a reason lists each
variant. None has a wildcard arm.CLAUDE.md requires for order-lifecycle work. No test in
these PRs covers them.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.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.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.cancel_inner or
replace_inner before resolve_for_action, so no
order is resolved when they reject.Cancel-all. It is one FindAndCancelOrders call with no
per-order result, so there is no single order to report.