# Kronos X WebSocket Integration Manual for Claude Code

Drop this file into the root of your integration repository and point Claude
Code at it. It is self-contained: everything needed to generate a correct
WebSocket client for the Kronos X MTF, in any language, including the parts most
integrations get wrong (delivery guarantees, gap handling, recovery).

Suggested first prompt:

> Read KronosX-Claude-Code-WebSocket.md and generate a Kronos X WebSocket client
> in <language>: public market-data socket with checksum-verified book,
> authenticated private socket, reconnect with cursor-based backfill, and an
> order state machine fed by the event stream. Then write a smoke test against
> the sandbox using credentials from environment variables.

Companion manual: `KronosX-Claude-Code-REST.md` (requests, history, backfill
endpoints). You need REST alongside this: recovery after a disconnect is a REST
call.

---

## 1. Endpoints

| Environment | WebSocket base |
|---|---|
| Sandbox | `wss://sandbox.mtf.perpetuals.net` |
| Production | `wss://production.mtf.perpetuals.com` |

- `/ws/public` : market data, no authentication.
- `/ws/private`: account data, order entry, synchronous reads. Authenticate
  first.
- The sandbox host is `sandbox.mtf.perpetuals.net` since member API revision
  3.83: three nodes behind that one entry host, replacing the single-node
  `sandbox.mtf.perpetuals.com` from the date venue operations announce. Each
  environment publishes ONE entry host, which sends new connections to the
  node that accepts writes (the leader); never pin a node. A leader change
  therefore happens on the sandbox as it can on production (sections 8 and
  10).

TLS only. Credentials carry a scope: a `READ_ONLY` (drop copy) key may
subscribe and read but is refused on order entry with code 1005.

## 2. Message shape

Everything is JSON. Client requests:

```json
{ "op": "<operation>", "reqId": "<your correlation id>", "args": [ ... ] }
```

Every request gets exactly one ack:

```json
{ "op": "subscribe", "reqId": "1", "success": true, "code": 0, "msg": "ok", "data": null }
```

Streamed frames carry a `channel` and, on private channels, `asOfSeq` and
`eventIndex`:

```json
{ "channel": "order", "type": "OrderAccepted", "data": { }, "asOfSeq": 904118, "eventIndex": 0 }
```

**Rules that matter:**
- Prices, quantities and money are exact decimal STRINGS. Parse to decimal.
- Ignore unknown fields, unknown channels and unknown `type` values: the venue
  adds them without notice.
- `asOfSeq` is the engine's total order across the whole venue and is comparable
  with REST `asOfSeq` and the recovery cursor.
- The cursor of an event has three zero-padded fields (member API section
  11.2): `seq` (19 digits, the frame's `asOfSeq`, a signed 64-bit integer, 0
  to 9223372036854775807), `eventIndex` (10 digits, a signed 32-bit integer, 0
  to 2147483647) and `leg` (1 digit). `eventIndex` is not a running counter:
  it is the 0-based position of the event among the events of ONE engine
  command and restarts at 0 for every `seq`; you receive only your own events,
  so within one `seq` the values you see increase but are not contiguous. Ten
  digits is the padded width of a 32-bit value, not its range. A cursor field
  above its range answers `2019`, since member API revision 3.83 with a `msg`
  naming the field and the range; store cursors as the 30-character strings
  the venue returns, compare them as strings, and branch on the code.
- On every `order` frame `acceptedTs` is the sequencing time of the event the
  frame reports (the acceptance, the amendment, the cancel, the trigger, the
  completing fill, the expiry or the rejection), not the time the order was
  first accepted; the open-order reads carry the placement time instead
  (member API section 6, "`acceptedTs` per surface").

## 3. Authentication (private socket)

Since Member API revision 3.85 the login signs a SERVER CHALLENGE (mandatory). The
first frame of every `/ws/private` connection, before anything else, is:

```json
{ "channel": "challenge", "type": "challenge", "nonce": "<32 lowercase hex>", "ts": <venue ms> }
```

Read it, keep its `nonce`, then send the `auth` op with the nonce as the FOURTH arg:

```json
{ "op": "auth", "reqId": "1", "args": ["<apiKey>", <expiresMs>, "<signature>", "<nonce>"] }
```

where

```
signature = hex_lowercase(HMAC_SHA256(secret, "GET/ws/private" + expiresMs + nonce))
```

`expiresMs` is unix milliseconds a few seconds in the future (at most 60 s ahead of
the venue clock), written as a plain decimal integer, and `nonce` is exactly the
challenge's 32 characters. Worked example: `secret=my-api-secret`,
`expiresMs=1700000060000`, `nonce=5f2b8c0e9a4d4e7c8b1f3a6d2c9e0b7a` gives
`d9e79457cc986b400928993ae3e89cd4e3a0f5fdeeb035d4a3768430fcbf0980`.

The signature is bound to the connection: a frame verifies only on the connection
whose challenge it signs, so a captured frame opens no other connection. Failure
gives code 1001: `auth failed: sign the nonce of this connection's challenge frame
and send it as the fourth arg (Member API section 10.1)` for the old three-arg
frame, `auth failed: the nonce is not this connection's challenge` for another
connection's nonce, `auth failed` otherwise. A second login on the same connection
(another key after a rotation) signs the same nonce; a reconnect reads the new
connection's challenge, never reuse a nonce. Nothing but `auth` is accepted before
authentication succeeds. A generated client must:

- read frames until the one whose `channel` is `challenge` BEFORE it sends `auth`,
  and keep that nonce in the connection's own state;
- never log the `auth` frame (it carries a live signature).

## 4. Operations

| Op | Purpose |
|---|---|
| `ping` | Liveness. Answered with a pong on the reply queue. |
| `auth` | Authenticate the private socket. |
| `subscribe` / `unsubscribe` | `args` is an array of channel names. |
| `resync` | Re-send order-book snapshots for your subscribed book channels. |
| `order.place` | Place an order. `args[0]` is the same body as REST `POST /v1/orders`. |
| `order.cancel` | `args[0] = { "orderId": 123 }`. |
| `order.cancelAll` | `args[0] = { "symbol": "...", "side": "BUY" }` (both optional: without them, `[{}]`, EVERY open order is cancelled, untriggered conditionals included; `side`, `BUY` or `SELL` in any case, cancels that side only, exactly as `DELETE /v1/orders?side=`, since Member API 3.84). Since Member API 3.84 nothing else is accepted and a refusal cancels nothing: any other member is 2017 (names case-sensitive), a `side` other than `BUY` or `SELL` (empty included) 5003 as on REST, a `null` or non-string value, a member given twice, `args` given twice or args that are not exactly one object 2013. |
| `order.amend` | `args[0] = { "orderId": 123, "price": "...", "qty": "..." }`. |
| `orders.batch` | `args[0] = { "orders": [ ... ] }`: 1 to 50 placements, the body of REST `POST /v1/orders/batch` (section 10). |
| `orders.batchAmend` | `args[0] = { "amends": [ ... ] }`: 1 to 50 amendments, the body of REST `POST /v1/orders/batch/amend` (Member API 3.74, section 10). |
| `orders.batchCancel` | `args[0] = { "cancels": [ ... ] }`: 1 to 50 cancellations, the body of REST `POST /v1/orders/batch/cancel` (Member API 3.74, section 10). |
| `marketSegments.set` | Counterparty acceptance (configuration write). |
| `session.cancelOnDisconnect` | `args[0] = { "enabled": true, "graceMs": 5000 }`: arm (or disarm) cancel on disconnect for this session; in memory only, re-arm on every connect (member API section 10.5a). |

**The single amend and cancel acks carry no order.** A successful
`order.amend` or `order.cancel` acks
`{"op","reqId","success":true,"code":0,"msg":"ok"}` with no `data`; only the
`order.place` ack and the three batch acks carry orders. Read the state the
venue applied from the `order` channel (`OrderAmended`, `OrderCanceled`:
`price`, `qty`, `remainingQty`, `status`, `clOrdId`, `clientUid`; guaranteed
and recoverable by cursor), from `get.order`, or from the row of a one-entry
`orders.batchAmend` or `orders.batchCancel` (adopting the batch ops needs
re-certification, Conformance Test Plan X-07 to X-11). The venue never applies
part of an amendment: `qty` is the new total, fills included, and the engine
applies the price and quantity you sent or refuses the whole amendment; only
`remainingQty` can differ from what you computed. A success ack means the
amendment was taken, not that the order still stands: a reprice that crosses
re-matches the order. Acks are not ordered against channel frames, so
correlate them by `orderId`.

**Cancel on disconnect reports `USER_REQUESTED`.** When an armed session closes
and the grace window passes with no trading-capable session of yours left on
the WebSocket, the venue submits ONE member-scope cancel-all, whose cancels
report `cancelReason` `USER_REQUESTED` by design (the armed protection is your
own standing instruction). By its fields it cannot be told apart from a
member-wide cancel-all of your own or from the member portal's kill switch,
which report `USER_REQUESTED` too (`ADMIN_CANCEL` is reserved for cancels by
venue operations). What tells it apart: it follows an armed session's close and
the grace window, it is ONE command (all its `OrderCanceled` frames share one
`asOfSeq`), it covers every open order of yours on every market whichever
transport placed it, and it answers no request of yours. A restart or a leader
change voids every arming and fires nothing.

**Synchronous reads** mirror the REST reads with identical payload shapes, so
you never need a second transport for a point-in-time question:

`get.time`, `get.status`, `get.instruments`, `get.orderbook`, `get.trades`,
`get.ticker`, `get.markPrice`, `get.indexPrice`, `get.funding`,
`get.insuranceFund`, `get.account`, `get.positions`, `get.clientPositions`,
`get.openOrders`, `get.order`, `get.executions`, `get.marginPreview`,
`get.marketSegments`, `get.matchableMembers`, `get.matchableBook`,
`get.adlIndicator`, `get.unwinds`, `get.streamCursor`.

Read acks carry `asOfSeq` so a snapshot can be ordered against the live stream.

Since Member API revision 3.73 `get.streamCursor` also acks `5007` or `1003`
with `data.retryAfterMs`: `5007` when the venue's history store did not answer
in time, is held after a slow read, or is catching up; `1003` when more reads of
yours of the event stream group are in flight than the node serves at once, or
when the node's budget of these reads that every member shares is full. Sleep
the wait and send the op again.

**Reference data gained two routing fields.** `get.instruments` (and REST
`GET /v1/instruments`) now carries `mode` per instrument: `ORDER_BOOK` (central
limit order book) or `RFQ` (request-for-quote only: no order book exists).
`kind` gained `BARRIER` alongside `PERP` and `SPOT`; barrier markets are always
mode `RFQ`. Read `mode` from reference data and route: `order.place` on an RFQ
market rejects 2022, and RFQ operations on an order-book market reject 2023.
RFQ create/quote/accept and the cancels are REST-only requests (companion REST
manual); there are no `rfq.*` ops on this socket, and RFQ is not offered over
FIX at all. The socket's RFQ surface is the two private channels in section 7.

## 5. Channels

### Public (`/ws/public`)

| Channel | Content |
|---|---|
| `orderbook.{depth}.{symbol}` | Snapshot then deltas, with CRC32C `checksum` and a per-channel `seq`. Depth 1, 5, 25 or 100. The `checksum` on EVERY frame, snapshot and delta alike, covers at most the top 25 levels a side. On a snapshot at depths 1, 5 and 25 that is exactly the rows it carries; at depth 100 it covers only the top 25 of the carried rows, never all 100, so verify a depth-100 snapshot against its first 25 rows a side. Delta checksums always cover the venue top 25 levels, so subscribe at depth 25 or 100 when you verify checksums continuously. Deltas that delete levels also carry repair rows for the levels sliding into the standard window boundaries (1, 5, 25, 100), so a depth-limited window never goes short after a delete: apply repair rows like any other row and trim to your depth. |
| `trades.{symbol}` | Public prints. |
| `ticker.{symbol}` | Best bid/ask, mark, index, funding, open interest. |
| `markIndex.{symbol}` | Mark and index updates. |
| `funding.{symbol}` | `type: "rate"` updates, `type: "settlement"` results and, on LOCAL_DAILY markets, `type: "settlementDetail"` figures and, since Member API 3.70, `type: "skipped"` (a day that closed without a settlement: `skipReason`, `settleTs`, `snapshotTs`, `settleCcy`, `ts`; best-effort, re-read `GET /v1/funding/history?includeSkips=true`). |
| `status.{symbol}` / `status.venue` | Legacy trading-state feed. `status.{symbol}` also repeats the market's unchanged status whenever the venue edits the market's configuration: treat a frame as a prompt to re-read `get.status` or `get.instruments`, never as the source of a value. |
| `venueEvents` | Typed venue events (see section 6). Also on the private socket. |

### Private (`/ws/private`, after auth)

| Channel | Content |
|---|---|
| `order` | Order lifecycle: `OrderAccepted`, `OrderFilled`, `OrderCanceled`, `OrderExpired`, `OrderAmended`, `OrderTriggered`, `OrderRejected`. `OrderFilled` reports the fill that completes the order; a partial fill arrives on `execution` only, and every frame before the terminal one reads `status` `NEW` (`PENDING_TRIGGER` before a conditional triggers), an `OrderAmended` of a part-filled order included: the filled quantity is `qty` minus `remainingQty`. A venue reduce-only trim (of a resting or an untriggered conditional reduce-only order, after your member position shrank) arrives unsolicited as `OrderAmended`. |
| `execution` | Your fills, with `clOrdId`, `clientUid`, `role`, `flags`, `refSource` and, on PERP and BARRIER fills once the venue LEI is configured (Member API 3.70), the venue-leg `uti`. |
| `position` | Member-level position changes. |
| `clientPosition` | Per-`clientUid` sub-position changes. |
| `wallet` | Ledger movements (fees, funding, settlements). Each entry carries `balanceAfter` and the equal-valued legacy alias `balance` (the REST ledger's field name is `balanceAfter`). |
| `margin` | `type: "marginCall"` and `type: "update"`. Field spellings are frozen for deployed clients and differ from REST: this channel's lowercase `mm` is the same number `GET /v1/account` reports as uppercase `MM`, and its `im` equals the REST `positionIM` plus `orderMargin`. Normalise once at your boundary. |
| `adl` | `type: "execution"` and `type: "indicator"`. |
| `unwind` | Liquidation episode lifecycle. |
| `eligibility` | Market Segments version changes. |
| `rfq` | Your RFQ lifecycle on RFQ-mode markets: `created`, `quoted`, `myQuote`, `quoteCanceled`, `accepted`, `canceled`, `expired`. Section 7. |
| `barriers` | Barrier contract lifecycle: `opened`, `knockOut`, `takeProfit`, `expiry`, `earlyClose`, `liquidation`, `finalSettlement`. Every frame carries the contract's terms and, since revision 3.45, `strictTakeProfit`, `premium` (the holder's maximum loss) and `premiumBps`. Section 7. |
| `venueEvents` | Member-scoped risk events plus the market-wide ones. |

Subscribing to a book channel returns a snapshot immediately; apply deltas after
it and **verify the checksum on every update**. On mismatch, `resync` (or
re-read `GET /v1/orderbook`) rather than continuing on a corrupt book.

## 6. venueEvents

One channel, typed frames, on both sockets. Public sessions get the market-wide
types; an authenticated private session additionally gets your own risk events.

`type`: `SNAPSHOT` (on subscribe), `STOP_TRADING`, `RESUME_TRADING`,
`LIQUIDATION`, `ADL`, `MARGIN_CALL`, `LIQUIDATION_COMPLETE`, plus the advisory
types `STALE_MARK_WARNING` and `STALE_MARK_RECOVERED` and, on the private socket
only, `MEMBER_PARAMS` (revision 3.61) and the advisory `MARGIN_WATCH` (revision
3.70). Treat as an open set.

- `STOP_TRADING` carries a typed `reason`: `CIRCUIT_BREAKER`,
  `STALE_MARK_PRICE`, `DELISTING`, `ADMINISTRATIVE`. `DELISTING` is permanent
  for that listing: a `tickSize` or `qtyStep` change is made only by a
  re-listing announced in advance (member API section 2a), and its
  `STOP_TRADING` with `DELISTING` (every resting order cancelled
  `INSTRUMENT_HALT`) is the only `venueEvents` frame a re-listing produces; the same symbol
  can return as a new market afterwards. `venueEvents` carries trading-state
  changes only, never a listing or a parameter change, so re-read
  `get.instruments` after an announced date.
- `RESUME_TRADING` reasons: `ADMINISTRATIVE`, `CIRCUIT_BREAKER_EXPIRY`,
  `MARK_PRICE_RECOVERED`.
- `MARGIN_CALL` carries `state`: `ACTIVE` or `CLEARED`.
- `tradingState` values: `TRADING`, `HALTED`, `REDUCE_ONLY`, `POST_ONLY`,
  `DELISTED`.
- `STALE_MARK_WARNING` (market-wide, both sockets) is the early step of the
  mark-price staleness ladder: broadcast once per staleness episode when the
  age of a market's last mark price tick crosses HALF that market's staleness
  threshold. `data`: `symbol`, `tickAgeSec`, `reduceOnlyInSec` (seconds left
  before the market goes reduce-only via `STOP_TRADING` with reason
  `STALE_MARK_PRICE`; `0` when already past the threshold). The market is
  still fully tradable when the warning arrives. Market makers: treat it as
  the signal to widen quotes or pause quoting on that market.
- `STALE_MARK_RECOVERED` (`data`: `symbol`, `tickAgeSec`) closes the episode
  when the feed is fresh again; if the market flipped to reduce-only it
  follows the `RESUME_TRADING` with reason `MARK_PRICE_RECOVERED`.
- `MARGIN_WATCH` (private socket only, advisory, revision 3.70) is the venue's
  early warning that your margin utilisation (initial margin plus order margin
  over equity in the settlement currency) reached your watch level
  (`marginWatchPct` on `GET /v1/account/parameters`): `data` `{state, ccy,
  utilisationPct, thresholdPct, reArmPct}`, `state` `ABOVE` on the crossing and
  `CLEARED` once utilisation fell back below the level minus 5 points. Top up
  collateral or reduce before the engine's `MARGIN_CALL` at 100 percent
  utilisation; never drive your risk state off it: it is not a margin call,
  and `MARGIN_CALL`, `LIQUIDATION` and the `margin` channel remain the only
  risk-state signals.
- The advisory types are best-effort and informational: `eventId`
  namespace `advisory-{asOfSeq}-{n}`, never in the `SNAPSHOT`, never
  backfilled or replayed, and a venue restart mid-episode may repeat a
  warning once. Never drive your trading-state machine off them; state
  transitions arrive only as `STOP_TRADING`, `RESUME_TRADING` and
  `SNAPSHOT`.

This channel writes explicit `null`s (unlike the rest of the API, which omits
them), so an absent client reference is unambiguous.

## 7. RFQ and barrier channels (private)

RFQ-mode markets have no order book. You request quotes, makers answer, you
accept, all over REST; the private socket then tells you everything that
happens to YOUR requests, quotes and contracts on two channels. Subscribe
after `auth` exactly like the other private channels:

```json
{ "op": "subscribe", "reqId": "2", "args": ["rfq", "barriers"] }
```

No snapshot frame is sent on subscribe. Current state lives in the REST reads:
`GET /v1/rfq/mine` (your requests, quotes anonymised), `GET /v1/rfq/open` (the
open board, eligibility-filtered per caller), `GET /v1/rfq/quotes/mine` (your
live quotes) and `GET /v1/barriers` (your open contracts). The board is polled,
never pushed: pushing it to everyone would leak past Market Segments.

Frames carry `asOfSeq` and `eventIndex` like every other private channel, and
omit absent fields (standard omit-null rule: unlike venueEvents and gap
frames, these channels never write explicit nulls).

### `rfq` frames

| `type` | Sent to | `data` fields |
|---|---|---|
| `created` | Requester (and the contract's writer on a close request) | `rfqId`, `symbol`, `side`, `qty`, `expiryTs` (ms, end of the quoting window), `barrier` (barrier RFQs only), `takeProfit` (when set), `strictTakeProfit`, `premiumBps` and `premium` (barrier RFQs only, revision 3.45: the holder's maximum loss, INDICATIVE at the current mark on an open request since the entry is the quoted price, the closed contract's frozen figure on a close request), `closeContractId` and `role` (`REQUESTER` or `WRITER`, close requests only), `clOrdId` (the requester's copy only). |
| `quoted` | Requester | `rfqId`, `quoteId`, `price`, `expiryTs`. One per incoming quote; the maker's identity is never included. |
| `myQuote` | Maker | `rfqId`, `quoteId`, `symbol`, `price`, `expiryTs`, `clOrdId`. Confirmation of your own quote. |
| `quoteCanceled` | Maker | `rfqId`, `quoteId`, `closeContractId` (close requests only). Explicit cancel or venue tear-up (liquidation episode). |
| `accepted` | Both parties | `rfqId`, `quoteId`, `tradeId`, `symbol`, `role` (`REQUESTER` or `MAKER`), `side` (the requester's side), `price`, `qty`, `ts`. |
| `canceled` / `expired` | Requester | `rfqId`, `symbol`, `side`, `qty`, `closeContractId` and `role` `REQUESTER` (close requests only), `clOrdId`. On these frames `expiryTs` arrives as `0` and `barrier` is absent even on barrier RFQs: key close handling on `rfqId`, not on the echoed terms. |

```json
{ "channel": "rfq", "type": "quoted",
  "data": { "rfqId": 41, "quoteId": 907, "price": "64810.5",
            "expiryTs": 1767225660000 },
  "asOfSeq": 904220, "eventIndex": 1 }
```

```json
{ "channel": "rfq", "type": "accepted",
  "data": { "rfqId": 41, "quoteId": 907, "tradeId": 55012,
            "symbol": "BTC-USD-KO", "role": "MAKER", "side": "BUY",
            "price": "64810.5", "qty": "2.5", "ts": 1767225632000 },
  "asOfSeq": 904230, "eventIndex": 0 }
```

**Lifecycle rules that matter:**
- A maker re-quoting the same RFQ REPLACES its earlier quote: the requester
  gets a fresh `quoted` with a new `quoteId`, the maker a fresh `myQuote`, and
  no `quoteCanceled` is sent for the superseded entry.
- A quote that merely expires lapses silently at its `expiryTs`; no frame.
  Makers are also NOT notified when an RFQ closes around their live quote
  (accepted with another maker's quote, canceled or expired): a maker's
  authoritative view is `GET /v1/rfq/quotes/mine` plus each quote's `expiryTs`.
- On accept, the fill also arrives on the guaranteed `execution` channel for
  both parties with `flags` containing `RFQ`, and prints on public
  `trades.{symbol}` flagged `RFQ`. RFQ prints never move the last-trade print,
  never fire conditional orders and never feed the circuit breaker.
- On a barrier market, the accept additionally opens a contract: the
  `contractId` arrives on the `barriers` `opened` frame (and in the REST accept
  response), not on the `accepted` frame.
- A CLOSE REQUEST (revision 3.44: `POST /v1/rfq` with `closeContractId`, the
  holder of an open barrier contract asking its writer for a closing price)
  rides the same channel with one addition: every frame of it carries
  `closeContractId`, and its `created` frame is ALSO delivered to the
  contract's writer (role `WRITER`, no requester `clOrdId`; the requester's
  copy says role `REQUESTER`), because the writer is the only member that may
  quote it. Only the writer ever sees it, on its board or on this channel. Its
  `accepted` frame closes the contract instead of opening one: the fill arrives
  on `execution` and `trades.{symbol}` flagged `BARRIER_CLOSE`, and an
  `earlyClose` frame follows on `barriers`. If the contract terminates any
  other way while the request is open, the venue cancels the request in the
  same command and the requester receives an ordinary `canceled` frame.

### `barriers` frames

Barrier (knock-out) contracts live on markets with `kind: "BARRIER"`. Both
parties receive every frame; `role` says which side you are (`HOLDER`, always
the original requester, or `WRITER`, the maker who quoted).

| `type` | Meaning |
|---|---|
| `opened` | Contract opened by an RFQ accept. |
| `knockOut` | Mark touched the barrier: settled AT the barrier price (guaranteed stop, no gap-through). |
| `takeProfit` | Mark reached the contract's optional take-profit level (revision 3.43): settled AT the take-profit price, gap or no gap, fee-free like the knock-out. |
| `expiry` | First tick at or past the contract's `expiryTs`: settled at the terms-clamped mark. |
| `earlyClose` | The holder's close request was accepted (revision 3.44): settled AT the writer's quoted price; the one close that printed a trade (`BARRIER_CLOSE`) and paid maker/taker fees like the entry. |
| `liquidation` | A party entered liquidation: contract torn up at the terms-clamped mark. |
| `finalSettlement` | Venue final settlement (market delisting): the venue determines a fair settlement price, terms-clamped. |

`data` fields on every frame: `contractId`, `symbol`, `role`, `direction`
(`LONG` or `SHORT`, always the HOLDER's direction), `qty`, `barrier`,
`takeProfit` (omitted on a knock-out-only contract), `expiryTs` (ms, omitted
on open-ended contracts, which exist only on a market without
`maxContractLifeHours`), `strictTakeProfit` (bool), `premium` (money string:
the holder's maximum loss, `|entry - barrier| x qty x contract value x
premiumBps / 10000`, frozen at the open) and `premiumBps` (int: `10000 -
knockOutRebateBps` of the instrument on a normal contract, its
`strictTakeProfitPremiumBps` on a strict one, 10000 on contracts opened before
revision 3.45), `ts`. `opened` adds `entry`; the six close types
add `settlePrice`, `pnl` (role-signed: YOUR realized P&L, so the holder's
number arrives negated on the writer's frame) and `terminationReason`
(`KNOCK_OUT` | `TAKE_PROFIT` | `EXPIRY` | `TEAR_UP` | `FINAL_SETTLEMENT` |
`EARLY_CLOSE`, the same vocabulary as the closed rows of
`GET /v1/barriers?status=CLOSED`).

```json
{ "channel": "barriers", "type": "opened",
  "data": { "contractId": 12, "symbol": "BTC-USD-KO", "role": "HOLDER",
            "direction": "LONG", "qty": "2.5", "entry": "64810.5",
            "barrier": "60000", "takeProfit": "72000", "expiryTs": 1767312000000,
            "strictTakeProfit": false, "premium": "10823.625", "premiumBps": 9000,
            "ts": 1767225632000 },
  "asOfSeq": 904230, "eventIndex": 2 }
```

```json
{ "channel": "barriers", "type": "knockOut",
  "data": { "contractId": 12, "symbol": "BTC-USD-KO", "role": "HOLDER",
            "direction": "LONG", "qty": "2.5", "settlePrice": "60000",
            "barrier": "60000", "takeProfit": "72000", "terminationReason": "KNOCK_OUT",
            "pnl": "-10823.625",
            "strictTakeProfit": false, "premium": "10823.625", "premiumBps": 9000,
            "expiryTs": 1767312000000, "ts": 1767290100000 },
  "asOfSeq": 911437, "eventIndex": 0 }
```

```json
{ "channel": "barriers", "type": "takeProfit",
  "data": { "contractId": 13, "symbol": "BTC-USD-KO", "role": "WRITER",
            "direction": "LONG", "qty": "2.5", "settlePrice": "72000",
            "barrier": "60000", "takeProfit": "72000", "terminationReason": "TAKE_PROFIT",
            "pnl": "-17973.75",
            "strictTakeProfit": false, "premium": "10823.625", "premiumBps": 9000,
            "ts": 1767293400000 },
  "asOfSeq": 912004, "eventIndex": 1 }
```

Settlement cash lands on the guaranteed `wallet` channel with
`kind: "BARRIERSETTLE"`; venue-initiated settles are fee-free (entry paid
normal maker/taker fees on entry notional). Contracts NEVER appear on
`position`, `clientPosition` or `GET /v1/positions`: the only inventory view
is `GET /v1/barriers`.

### Delivery and recovery (read this twice)

`rfq` and `barriers` are GUARANTEED (section 8) but they are NOT
cursor-backfillable: they have no row source under `/v1/stream/events` (naming
them in a `channels=` filter there rejects 2019), so a terminal gap frame
lists them in its `unrecoverable` array and never in its `backfill` query.
Recovery after ANY gap or disconnect is state-only: reconnect, resubscribe,
then re-read `GET /v1/rfq/mine`, `GET /v1/rfq/open` and `GET /v1/barriers` and
reconcile your local tables against them. Frames you already applied are
deduplicated by `(asOfSeq, eventIndex)` as usual; frames you missed are gone
and only the snapshots tell you the outcome.

Sandbox: the barrier market is `BTC-USD-KO` (kind `BARRIER`, mode `RFQ`, its
own mark price feed), provisioned for exactly the acceptance tests below.

## 8. Delivery guarantees, gaps and close codes

This is the section that separates a correct client from one that silently loses
fills.

**Two delivery classes.**

- **GUARANTEED** (never silently dropped): `order`, `execution`, `wallet`,
  `eligibility`, `adl` executions, `unwind`, `margin` margin-calls, `rfq`,
  `barriers`, `venueEvents`, and every request ack.
- **BEST_EFFORT** (may conflate under pressure): `orderbook`, `matchableBook`,
  `trades`, `ticker`, `markIndex`, `funding`, `status`, `position`,
  `clientPosition`, `margin` updates, `adl` indicators.

**Gap frames.** If anything is dropped you are told:

```json
{ "channel": "gap", "reason": "CONFLATED", "delivery": "BEST_EFFORT",
  "terminal": false, "fromSeq": 900100, "toSeq": 900140, "dropped": 12 }
```

- `reason: "CONFLATED"`, `terminal: false`: market data was conflated. The
  socket stays open. Recover by re-reading state (`resync` or a REST snapshot),
  not by cursor.
- `reason: "SLOW_CONSUMER"`, `terminal: true`: a guaranteed queue overflowed
  because you stopped reading. The frame names which families are backfillable
  and from which cursor, then the socket closes with **4008**:

```json
{ "channel": "gap", "reason": "SLOW_CONSUMER", "delivery": "GUARANTEED",
  "terminal": true, "channels": ["execution", "order", "rfq"],
  "fromSeq": 904118, "fromEventIndex": 0, "inclusive": true, "toSeq": null,
  "dropped": 3,
  "backfill": { "path": "/v1/stream/events",
                "query": "fromSeq=904118&channels=execution,order&limit=500" },
  "unrecoverable": ["rfq"] }
```

  Guaranteed families with no cursor store (`rfq`, `barriers`, `venueEvents`)
  are listed under `unrecoverable` and never appear in the `backfill` query.
  Do not try to backfill them: recover `rfq`/`barriers` with the state re-read
  in section 7, and `venueEvents` from its subscribe-time `SNAPSHOT`.

**Close codes.**

| Code | Meaning | What to do |
|---|---|---|
| 1000 | Normal | Reconnect normally. |
| 1001 | leader change, reconnect | Clustered deployments only (member API revision 3.76; among them the clustered sandbox of revision 3.83, from the date venue operations announce): the node you were connected to no longer leads, and the venue stays up; an `advisory` frame (`"advisory":"leaderChange"`) precedes the close. Reconnect to your configured entry host (it leads to the new leader), `auth`, resubscribe, re-arm cancel-on-disconnect (a leader change voids it and fires nothing) and recover per section 9. A reconnect just after the change can land on the former leader and read `5030`: retry after a short delay (section 10). New upgrades are not refused. |
| 1001 | venue restart | Normally the venue is stopping for a rollout; an `advisory` frame (`"advisory":"restart"`) precedes the close. The venue also sends the same advisory and close, while it keeps running, to every session that may have missed a frame when a venue-side fault lost frames of one command or batch (the command itself executed); the venue keeps running, so new upgrades are not refused, and no other close code or reason marks this case. Either way: reconnect with backoff, `auth`, resubscribe (subscriptions are not preserved), re-arm cancel-on-disconnect, then recover per section 9: the cursor backfill delivers every missed frame of the guaranteed families, and `rfq`, `barriers`, `venueEvents` and the best-effort families are re-read as state. When the venue is stopping, new upgrades answer HTTP 503 until it is back. |
| 4008 | SLOW_CONSUMER | Wait at least 1000 ms. Reconnect, resubscribe, then backfill from the gap frame's cursor. Fix your consumer speed. |
| 4009 | IDLE_TIMEOUT | You sent nothing for 60 s. Reconnect and add a keepalive. Nothing was dropped. |

**Liveness.** Send `{"op":"ping"}` at least every 30 seconds. The venue also
sends heartbeats on an otherwise silent socket; do not treat a heartbeat as data.

## 9. Reconnect and recovery (mandatory)

After ANY disconnect:

1. Reconnect with backoff to your configured entry host (never tighter than
   1000 ms after a 4008; after a 1001 `venue restart` keep backing off until
   the upgrade stops answering 503; after a 1001 `leader change, reconnect`
   reconnect at once, the venue stays up).
2. `auth`, then `subscribe` to your channels (nothing is preserved across a
   venue restart: not the auth, not the subscriptions, not the
   cancel-on-disconnect arming).
3. **Buffer live frames; do not process them yet.**
4. Take your resume point per backfill filter: the ordinal maximum of the
   `nextCursor` of your last page under that filter and the cursor of the last
   event you processed (a live frame's cursor can be ahead of the store). On a
   first start with nothing persisted, take it from `get.streamCursor` (or REST
   `GET /v1/stream/cursor`).
5. Page REST `GET /v1/stream/events?afterCursor=<resume point>` with the same
   `channels` and `symbol`, feeding `nextCursor` back as `afterCursor`, until
   `hasMore` is false and `store.status` is `COMPLETE`. A page with `hasMore`
   false or `count` 0 ends the loop, never `nextCursor` equal to the
   `afterCursor` you sent: since member API revision 3.73 a last page answers a
   position at the store watermark (a bound, `eventIndex` 2147483647 and `leg`
   9), valid only with the same filter, on an environment from the date venue
   operations announce for it there (before that date a last page answers the
   cursor of its last event, else the cursor you sent). Persist that
   `nextCursor` per filter.
6. Deduplicate the backfill against your buffered live frames by
   `(seq, eventIndex, leg)`, equivalently the fixed-width cursor of each event
   (event cursors deduplicate; `nextCursor` is a position, not an event).
7. Drain the buffer, discarding anything at or below your cursor, but only for
   the families that backfill read (a filtered cursor passes every seq up to
   the watermark, so never discard a buffered frame of a family you did not
   backfill by it), and resume live processing.
8. Re-snapshot state-class channels (book, positions, account) rather than
   trying to backfill them.
9. If you trade RFQ markets: re-read `GET /v1/rfq/mine`, `GET /v1/rfq/open`
   and `GET /v1/barriers`. The `rfq` and `barriers` channels are guaranteed
   but NOT in the cursor-backfillable set, so the event pages of step 5 will
   never contain them; the three snapshots are the only recovery (section 7).

`store.status` values: `COMPLETE` (level with the engine), `CATCHING_UP` (poll
again), `REPAIRING` (the store has a recorded hole: reads deliberately stall
below it rather than skip it; keep polling from your last `nextCursor` and
raise an operational alert).

**`REPAIRING` in detail** (member API section 11.3, revision 3.83 states it).
The store block describes the store of the NODE that answered. `REPAIRING`
starts at every leader change of a clustered venue, the sandbox included, so
expect the first backfill after a `1001` `leader change, reconnect` to read it;
it also starts when the store falls further behind than its write buffer
absorbs and when a store write fails beyond a short retry. No timeout ends it
and the venue sets no maximum duration: it ends when the hole is re-folded from
the venue's journal, by the new leader after a leader change and by venue
operations otherwise, and the older the hole, the longer the repair.
Meanwhile `lagSeq` counts from the start of the hole to the engine head (not
the size of the hole), so it grows while the hole stands; `GET /v1/executions`
and `GET /v1/account/ledger` serve only rows at or below the store watermark.
Until it clears you cannot backfill past the hole: keep consuming live frames,
recover the state you need from the state reads (`get.openOrders`,
`get.positions`, `get.account`), and never skip the hole. Trading, the live
feeds and those reads do not depend on the store.

Persist your high-water cursor. On startup, backfill from it before trusting
your own state.

### 9.1 Position seeding and reconciliation

Build your position store on this division of labour:

- **`execution` is the guaranteed feed.** It is durable, cursor-backfillable
  and never silently dropped: it is the only stream you should derive position
  arithmetic from. Every fill, including venue-initiated liquidation and ADL
  legs (`refSource: "VENUE"`), arrives here exactly once per (cursor, role).
- **`position` and `clientPosition` are best-effort CHANGE streams.** They are
  conveniences for display and for divergence detection, and they may be
  conflated under pressure (announced by a non-terminal gap frame). Never treat
  them as a source of truth, and never try to backfill them: they are
  state-class, recovery is a snapshot read.

**Seeding an absolute baseline.** Deltas need an anchor. On startup, and after
any gap on `position`/`clientPosition`:

1. Finish the section 9 catch-up first: page the event backfill until
   `store.status` is `COMPLETE` and your live buffer is drained, so no
   execution you have not applied is still in flight toward you.
2. Pick a QUIESCENT moment: you have no working orders whose fills could land
   mid-read, or you accept that rows read now are only valid as of the
   response's `asOfSeq`.
3. Read `GET /v1/positions` (member level) and
   `GET /v1/positions/end-clients` (per `clientUid`) and adopt those rows as
   your absolute baseline, recording the response `asOfSeq` with it.
4. From there, advance the baseline by applying `execution` events only, and
   use incoming `position`/`clientPosition` frames as cheap hints that your
   ledger and the venue's may have diverged.

**Reconcile only at quiescence.** Streams and reads are ordered by `asOfSeq`,
not by wall clock, so comparing your ledger against a live read while your own
executions are still arriving manufactures false breaks: only compare once
your applied cursor has passed the read's `asOfSeq` and nothing of yours is
working. For the authoritative deep check, align with the venue's daily cycle
and reconcile your per-client table against the end-of-day snapshot
(`GET /v1/reconciliation/end-client-positions?date=YYYY-MM-DD`, CSV mode
available), which the venue takes within about five minutes after 00:00 UTC
for the day that just ended, while it keeps trading (a fill of the first
minutes of the new day can be in it), and which is the same data the venue
itself reconciles against. If the venue restarts, or another of its nodes
takes over, before the next anchor, the anchor runs again for the previous
day and adds the rows of the sub-positions that day does not hold yet, from
the state at that moment (all of the day's rows when the anchor had not run
at midnight); a snapshot venue operations take by hand does the same, and a
row already written never changes.

## 10. Order entry over WebSocket

`order.place` takes the same body as REST `POST /v1/orders`, with the same
strict-field rule (unknown field gives 2017) and the same idempotency semantics
on `clOrdId`. The ack is synchronous; the resulting lifecycle also arrives on
the `order` and `execution` channels. Since Member API 3.72 (in force from the
date venue operations announce) a `clOrdId`, a `clientUid` or a Trader ID of the
`reg` block that begins or ends with whitespace acks `success: false` with code
2013 and a `msg` naming the rule, exactly as on REST; `orders.batch` answers it
per entry.

`orders.batch` takes the same body as REST `POST /v1/orders/batch`
(`args: [{"orders":[...]}]`): 1 to 50 complete, independent place-order
entries, each with its own `clOrdId` and `clientUid`, submitted to the engine
in array order and checked exactly like singles (order-size and position
caps included: 2013/2011 ride per-entry results as `rejectCode`). The ack's `data.results` returns per-entry outcomes in array
order with the same shapes as REST: a success entry is the single-order
response data shape (engine rejects ride `rejectCode`), a gateway failure
entry is `{code, msg, clOrdId}`. The batch is non-atomic and is charged one
order-budget unit per entry up front, all or nothing (a denied batch acks 1003
with `retryAfterMs` and consumes nothing); every entry counts toward the
order-to-trade regime. Since Member API 3.74 an entry whose outcome the venue
could not confirm is a `5000` row (reconcile it by its `clOrdId` and resend it
only with the SAME `clOrdId`), where such a fault acked the whole op 2013.

`orders.batchAmend` and `orders.batchCancel` (Member API 3.74) take the bodies
of REST `POST /v1/orders/batch/amend` and `POST /v1/orders/batch/cancel`:
`args: [{"amends":[...]}]` with 1 to 50 entries `{orderId, price?, qty?}`,
each processed by the engine exactly as `order.amend`, and
`args: [{"cancels":[...]}]` with 1 to 50 entries `{orderId}` or `{clOrdId}`,
each looked up first (an entry naming no open order of yours is a 2014 row and
nothing is submitted for it) and processed exactly as `order.cancel`. The ack
is `{"op", "reqId", "success": true, "code": 0, "msg": "ok", "data":
{"results": [...]}, "asOfSeq", "ts"}`: the rows in array order, a row carrying
`code` a failure row, any other row the order (an amendment row can read
`REJECTED` 2005 or `CANCELED` `STP_CANCEL`: that level is gone, place it
again), and `asOfSeq` at the top level, the evidence the Conformance Test Plan
asks you to keep. An amendment row is the order as of `asOfSeq`; when the
venue could not read your orders back after the batch it is the order's last
state change among the batch's entries and `asOfSeq` the lowest seqNo at which
a still-working row was taken, or the seqNo before it when the command that
made that row's change also traded the order (the row is then the order before
those fills), so every row is the order as of `asOfSeq` or later and the
`order` and `execution` events after it bring it up to date. After an entry the venue's intake refused as it was submitted, every
later entry answers 5001 "not submitted"; on a clustered deployment the
replicated intake can instead refuse an entry after the later ones were
submitted, and those may take effect: read every row. Non-atomic: a refused entry never stops a later one, so send
an entry that depends on another's outcome in a second message. The amendment
batch is charged one order-budget unit per entry after the engine-busy check;
the cancellation batch is free, and one cancellation batch of yours runs at a
time on a venue node (5001 when eight already wait). On a node that is not
the leader both ops ack 5030 with `msg` `NOT_LEADER` and `data`
`{leaderId, leader}` before anything is charged, like every other write op
(below). A session processes one message at a time, so keep a SECOND private
socket for urgent cancels.
`order.cancelAll` reads `symbol` and `side` only and cancels EVERY open order
without them; since Member API 3.84 a batch body sent to it by mistake is
refused 2017 with nothing cancelled (before, it cancelled everything), but
never rely on that: check the op name. Neither op is offered over FIX. Adopting
either batch op: re-certify with the Conformance Test Plan's rows X-07 to X-11
before production use.

**Leader change: `5030` and the other answers** (clustered deployments, among
them the clustered sandbox of member API revision 3.83 from the date venue
operations announce; member API section 10.7).
Every write op that reaches a node which is not the leader (`order.place`,
`order.cancel`, `order.cancelAll`, `order.amend`, `orders.batch`,
`orders.batchAmend`, `orders.batchCancel`, `marketSegments.set`,
`session.cancelOnDisconnect`) acks `success` false, `code` `5030`, `msg`
`NOT_LEADER`, with `leaderId` and `leader` under `data`, first: before
authentication, validation, or anything charged or counted. Such a node never
forwards a write, so a `5030` op did not take effect and cannot reach the book
later. It is a redirect, never a rejection:

- `leader` is an `https` base URL, on every venue deployment the
  environment's published entry host, never a WebSocket URL. Accept it only
  when it equals the entry host you configured; if it names any other host,
  send no credentials there, reconnect to your configured entry host and tell
  venue operations. While no leader is known `data` carries `leaderId` 0 and no
  `leader`: back off briefly and retry your entry host.
- The node does NOT close a WebSocket opened on it: at the first `5030` close
  the socket yourself, reconnect to the entry host, `auth`, resubscribe,
  re-arm `session.cancelOnDisconnect` and recover per section 9, then send
  the op again with the SAME `clOrdId`.
- A cancel, cancel-all, amendment or cancellation batch answered `5030` did
  not take effect: the order still rests at its old terms, so send it again on
  the leader at once. A `session.cancelOnDisconnect` answered `5030` is not
  armed. A leader change pulls no resting order: re-quote or cancel what you
  no longer want.
- Never count a `5030` as a reject or let it stop your trading.

None of these other answers proves that a write was not executed: `5001` on a
SINGLE write that was already in the former leader's log when it lost the role
(it may still execute on the new leader at its original terms; a batch row's
`5001` is never such a write), `5000` with `msg` `internal error` on a write
whose commit the venue did not confirm within 10 seconds (since member API
revision 3.83; before, that ack read `2013` "malformed request"), a batch row
`5000` "outcome unknown", and no ack at all for an op you sent after the `1001`
close. Read such an order back on the leader by `clOrdId` (`get.openOrders`,
REST `GET /v1/orders/history` or the backfill of section 9) before you decide,
or send it again with the SAME `clOrdId`; never under a new reference.

**Trader IDs (Authorised Users).** The order body's `reg` object takes an
optional `reg.traderId` string: the venue-issued Trader ID of the Authorised
User entering the order. Its default form is `TR-{memberId}-{8 hex}` (e.g.
`TR-12-9F3A21BC`), but treat it as an opaque string of up to 64 characters.
Enforcement is STAGED per member: while your member has no registered
Authorised Users, nothing is gated and the field is recorded verbatim if
present. From the FIRST registered user onward, EVERY order must carry a
registered, ACTIVE Trader ID or the ack comes back `success: false` with code
2021 (`reg.traderId missing or not a registered Authorised User`), raised
before the order reaches the engine. The check is one shared code path across
REST, WebSocket and FIX, so behaviour is identical on every transport.
`get.marginPreview` is exempt: previews are reads and are never gated on
Trader IDs.

**Pipelining is supported and recommended.** You may send many order ops without
waiting for each ack. The venue processes them in arrival order, so causal
ordering per client is preserved while the round-trip stall disappears. A
strictly serialized REST client is limited to roughly `1 / RTT` orders per
second (about 12/s at 80 ms); pipelined WebSocket removes that ceiling. This is
the single biggest throughput lever for a DMA integration.

Rate limits: order placements, placement and amendment batch entries (one unit
each), and amendments all consume the 40000/min order budget and, where your class publishes one, a
per-second ceiling on order entry (STANDARD 1,000 orders/s on production, 0 on
the sandbox) that binds beside the per-minute budget; `get.*` reads use the
6000/min read bucket; the two are independent. Cancels, cancellation batches
included, are never throttled but count toward the order-to-trade regime (1004
blocks new orders, batches and amendments only; an amendment batch answers it
per entry and runs its other entries).

## 11. What to build

1. **Connection manager** per socket: backoff, keepalive ping, heartbeat
   tolerance, close-code handling (4008 vs 4009 differ).
2. **Book maintainer**: snapshot plus deltas with checksum verification on every
   update, and automatic `resync` on mismatch.
3. **Private stream consumer** feeding an order state machine and a
   per-`clientUid` sub-position ledger, applying events idempotently by
   `(seq, eventIndex)` so replaying a backfill twice is harmless.
4. **Recovery engine** implementing section 9, with a persisted cursor.
5. **Unsolicited-flow handler**: executions and cancels for orders you never
   sent, keyed on `refSource: "VENUE"`, never on the shape of `clOrdId`.
6. **Backpressure discipline**: read the socket on a dedicated loop and hand off
   to a queue. If your handler blocks the read loop you will earn a 4008.
7. **RFQ/barrier state tracker** (only if you trade RFQ markets): local tables
   keyed by `rfqId`, `quoteId` and `contractId`, updated from the `rfq` and
   `barriers` frames, with the three REST snapshots of section 7 as the
   recovery reset path instead of the cursor backfill.

## 12. Acceptance tests

1. Public socket: maintain `orderbook.25.BTC-USD-PERP` for 5 minutes with the
   checksum verified on every update and zero mismatches.
2. Private socket: authenticate, place an order over `order.place`, and assert
   the ack, the `order` channel `OrderAccepted`, and the matching `execution`
   frame all agree on `clOrdId` and `clientUid`.
3. Subscribe to `venueEvents`; assert you receive the `SNAPSHOT` and can parse
   a `STOP_TRADING` reason.
4. Kill the socket mid-flow, recover per section 9, and prove the merged event
   log is gap-free and duplicate-free by cursor ordering.
5. Deliberately stall your reader to earn a 4008; assert you parse the terminal
   gap frame, back off, and backfill from its cursor.
6. Idle the socket without pinging; assert you handle 4009 and reconnect.
7. Send an order with an invented field; assert the ack carries code 2017.
8. On the sandbox barrier market `BTC-USD-KO`: subscribe to `rfq` and
   `barriers`, create an RFQ over REST, quote it from a second member, accept,
   and assert the requester sees `created`, `quoted` and `accepted` (role
   `REQUESTER`), the maker sees `myQuote` and `accepted` (role `MAKER`), and
   both receive the `barriers` `opened` frame with the same `contractId`.
9. With an open RFQ and a live barrier contract, kill the socket, reconnect,
   and recover by re-reading `/v1/rfq/mine`, `/v1/rfq/open` and `/v1/barriers`;
   assert your backfill queries never name `rfq` or `barriers` (the venue
   rejects them with 2019).
10. At the leader hand-over venue operations stage on the clustered sandbox
    at an agreed time (Conformance Test Plan I-05): assert you handle the
    `leaderChange` advisory and the `1001` close, reconnect to your configured
    entry host, re-arm cancel-on-disconnect, recover from your cursor through a
    `REPAIRING` store, send an op acked `5030` again with the SAME `clOrdId`
    (a cancel at once) without counting it as a reject, and read back by
    `clOrdId` a write acked `5001` or `5000` meanwhile.

## 13. Common mistakes

- Processing live frames before the backfill completes, producing duplicates or
  out-of-order state. Buffer first (section 9).
- Ignoring the checksum, then trading on a book that silently diverged.
- Treating a `CONFLATED` gap as fatal, or a `SLOW_CONSUMER` gap as recoverable
  without backfill. They are different: one is non-terminal, one precedes a
  close.
- Doing work on the socket read loop, causing the venue to disconnect you as a
  slow consumer.
- Assuming `position`/`clientPosition` are guaranteed. They are best-effort
  state channels: re-read them after any gap.
- Serializing order entry request-by-request and blaming venue latency.
- Reconnecting instantly in a tight loop after 4008.
- Trying to cursor-backfill `rfq` or `barriers` after a gap. They are
  guaranteed but state-only: the gap frame lists them under `unrecoverable`,
  never in `backfill`, and recovery is re-reading `/v1/rfq/mine`,
  `/v1/rfq/open` and `/v1/barriers` (section 7).
- Placing orders on an RFQ-mode market. Read `mode` from `get.instruments` and
  route: order entry there rejects 2022, on every transport.
- Hardcoding the absence of `reg.traderId`. The day your member registers its
  first Authorised User, every order without a registered active Trader ID
  starts rejecting with 2021: thread `reg.traderId` through your order path
  from day one.
- Waiting for the venue to close a socket that acks `5030`: it does not;
  close it yourself and reconnect to your entry host. Never follow a `leader`
  that is not your configured entry host.
- Reading `acceptedTs` on an order frame as the time the order was first
  accepted: it is the time of the event the frame reports.
- Waiting for `data` on a successful `order.amend` or `order.cancel` ack: there
  is none; the applied state arrives on the `order` channel.
