Date: 2026-10-02
Status: Proposed. Built in draft PRs; the SoW lists them.
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:
rr
is UNKNOWN_ORDER
and
there
is
no
order.
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.
| 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.
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.
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.
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.
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.
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.
OrderRejectedOn
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.
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.
ws::Response data
is
a
plain
string,
so
the
order
would
need
a
new
response
shape,
and REST
callers
would
not
get
it.
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.
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.
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.
USER_FROZEN.
System unavailable
and
venue
connectivity
degraded
report
SERVICE_UNAVAILABLE. Read-only
API
key,
onboarding,
and
account
errors
send
no
event.
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.
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:
OrderGatewayEvent.
cancel_rejected_*
tests,
not
at
the end
of
the
module,
where
#4103
adds
its
tests.
just update-docs.
It
is
not
merged by
hand.
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.