> This page is for Trading.

> For clean Markdown of any page, append .md to the page URL.
> For a complete documentation index, see https://docs.immix.xyz/llms.txt.
> For AI client integration (Claude Code, Cursor, etc.), connect to the MCP server at https://docs.immix.xyz/_mcp/server.

# REST API changelog

Every client-visible change to the REST API, newest first, by area: each area lists its changes under the version of its part of the API that they shipped in.

[Organisation, risk and trading](#organisation-risk-and-trading) · [Data](#data)

## Organisation, risk and trading

Consumer-facing history of the trading gateway's HTTP contract
([openapi.json](/trading/api-reference/rest-api) is the
contract the gateway serves at `/openapi.json`; this file says what changed, for the people
who parse it). Every client-visible change has its section here, newest first, each stating
what each side of the deploy window sees.

From `0.1.0` the document carries its own version — `info.version`, semver, bumped by hand
with the section that describes the change; what bumps which part is the
[API guide's *Versioning* section](/trading/docs/rest-api-guide#versioning). Every section from
there on is headed by the version it lands and its date (`## <version> — <YYYY-MM-DD> — …`),
and the contract test holds `info.version` equal to the newest heading here, so neither moves
alone. The wire schemas the message shapes derive from are named beside it
(`info.x-immix-register-schema`, `info.x-immix-orders-schema`, `info.x-immix-custody-schema`,
`info.x-immix-algos-schema`). The sections below `0.1.0` are headed by the register schema's
version (`114.v6`, …) — which `info.version` carried until then — or by a word for what
changed where the schema did not move, and are dated the same way.

### 0.14.0 — 2026-10-05 — WebSocket tickets

One operation under a new **`WebSocket tickets`** tag: **`POST /v1/ws-tickets`** exchanges
your bearer token for a short-lived, single-use **ticket** that opens the trading WebSocket
from a browser, which cannot send a bearer token on the WebSocket handshake. Nothing existing
moves. The [API guide's *WebSocket tickets*](/trading/docs/rest-api-guide#websocket-tickets)
section has the whole flow.

* **The request** is `{"stream":"trading"}` — `stream` is an enum with one member today
  (`WsTicketRequest`); anything else is 400 `MALFORMED_REQUEST`.
* **The response** is `{"ticket", "expiresAtNs", "stream"}` (`WsTicket`), served
  `Cache-Control: no-store`. `ticket` is opaque and URL-safe: put it in the WebSocket URL as
  `?ticket=` unchanged. `expiresAtNs` (an epoch-nanosecond string) is when it stops opening
  sessions: 10 seconds after it is created, or your token's expiry if that comes first.
* **Refusals.** The route is admitted like every other (401, 403, `NOT_PRIMED`); a token with
  no expiry cannot create a ticket (401 `UNAUTHENTICATED`); at most 60 tickets a minute per
  user (429 `RATE_LIMITED` with `Retry-After`; a request refused for its body or its token
  costs none of it); and a member that holds no signing key answers **503
  `TICKETS_UNAVAILABLE`**, a new `Error.code` — a retry there changes nothing, and its
  `retryable` is `false`.
* **No `Idempotency-Key`**, unlike every other write: a replayed answer would hand a retry the
  same single-use ticket, so every call mints a new one.
* **From a browser on another origin** the call needs CORS, which the gateway does not serve
  yet: call it from the gateway's own origin or through your backend until it does.
* **The first `/v1` route.** Every other route keeps its path.

**During the deploy window**: a member below `0.14.0` does not know the route and answers 404;
one on `0.14.0` mints, or answers 503 `TICKETS_UNAVAILABLE` where no signing key is configured.
A ticket opens a session once the trading WebSocket authenticates on the handshake — its own
changelog's `1.4.0` says so; a client that is not a browser needs no ticket, and sends its
bearer token on that handshake.

### 0.13.0 — 2026-10-05 — Every collection is a page

Every collection now answers a page, and can be walked a page at a time:

```json
{"globalSequence": "1042", "asOfSeqTsNs": "1759570000123456789",
 "items": [], "nextCursor": "AQAAb3…", "prevCursor": null, "total": null}
```

* **Three parameters on every collection.** `limit` is the page size, 1 to 100; a larger
  value is clamped. `cursor` is the `nextCursor` or `prevCursor` of a page you were served,
  sent with the same filters. `include=total` adds the exact number of rows matching the
  filters. **Without `limit` the whole collection is served as one page, as before.**
* **Four members beside `globalSequence` and `items`**: `nextCursor`, `prevCursor`, `total`
  and `asOfSeqTsNs`. Each is on every page, `null` where it does not apply.
* **A second position token.** `asOfSeqTsNs`, and an `X-Immix-As-Of-Seq-Ts` header on every
  read served from the platform's state — the collections, single rows and their 404s,
  `GET /me` and `GET /operations/{key}` — are the platform's timestamp of the change
  `X-Immix-Global-Sequence` names. A response holds every change at or before the position
  it states and may already hold later ones. Before a member has applied its first change
  the header is absent and `asOfSeqTsNs` is `null`. A write's answer carries the position of
  the fact that answered it and no timestamp, as before; the one write response that gains
  the header is the 404 for a path entity the member does not hold, which is a read's miss.
* **One order per collection, stated on its operation.** `orders`, `executions` and an algo
  instance's orders are newest first, by descending id. **This moves three orders**: `orders`
  and `executions` were unordered by contract and ascending in practice, and an instance's
  orders were published ascending. The register's collections (`orgs`, `users`,
  `credentials`, `accounts`, `account-addresses`, `portfolios` and the three policies) were
  unordered and now ascend by id. Balances, algo instances, an instance's documents and the
  algo catalogue keep the order they had.
* **A query parameter a read does not take is refused** 400 `MALFORMED_REQUEST`. It was
  ignored. A misspelt filter no longer serves the unfiltered collection, and a single row
  takes `view` alone: a client that re-sends its list's filters on a row read, such as
  `GET /users/{id}?status=DISCOVERED`, is refused. `GET /me` and `GET /operations/{key}` take
  no parameter. A query string that does not decode is refused the same way.
* **`INVALID_CURSOR`** (400) joins `Error.code`: a cursor that does not decode, or that came
  from another query — another collection, filter, view or organisation. Drop it and fetch
  the first page.

Pages are read from live state, so merge them by the row's key and never append them, and
take a finished walk as complete as of its first page's position.
[The guide's *Reads* section](/trading/docs/rest-api-guide#reads) has the walk.

**In the document**, each collection's 200 is the new named schema **`PageInfo`** together
with the collection's own `items`: a regenerated client gains one type and four optional,
nullable members on every list response. Every collection gains the parameters `Limit`,
`Cursor` and `Include`, and every read response the header `AsOfSeqTs`. The four members
become required in a later version, once no member below this one is left.

Across the deploy window, a `0.13.0` member serves every collection in its stated order with
all six members, honours the three parameters and refuses a parameter it does not take, on a
collection and on a single row alike. A `0.12.0` member ignores `limit`, `cursor` and
`include`, ignores any other parameter it does not know, and answers the whole collection in its own order with `globalSequence` and `items`
alone and no `X-Immix-As-Of-Seq-Ts`. **A walk can cross the two versions**: its first page
from a `0.13.0` member, then the cursor request answered by a `0.12.0` member with the whole
collection and no `nextCursor`. A reader that merges pages by key and takes a page without a
`nextCursor` as the end then holds no row twice. Merging does not drop a row that left the
collection after its page was read: as on any walk, take the result as complete as of the
first page's position ([the guide's *Reads*](/trading/docs/rest-api-guide#reads)). A reader
that needs strict pages sends `limit` only once the environment runs `0.13.0` throughout.

### 0.12.0 — 2026-10-05 — Every refusal names its request and says whether to retry

Every refusal now says three more things, in the same envelope:

```json
{"error": {"code": "NOT_PRIMED", "message": "…", "requestId": "01K6S7Z0Q4…", "retryable": true}}
```

* **`error.requestId`** is the request's id. Every response carries the same value in an
  **`X-Request-Id`** header, a success included, so quote it when you ask about a request.
  Send your own `X-Request-Id` — 8 to 128 of `[A-Za-z0-9._-]` — and it is the id used;
  anything else is replaced with a fresh one.
* **`error.retryable`** says whether sending the identical request again, its
  `Idempotency-Key` included, after `Retry-After` or a short pause, can succeed with nothing
  else changed. It is `true` for a 429 and a 503 and for `INTERRUPTED`, and `false`
  otherwise, with two kinds of exception. Four 503s are `false` because they state how the
  service is deployed: `BALANCES_UNAVAILABLE` and `ALGOS_UNAVAILABLE` always,
  `INTAKE_UNAVAILABLE` on a member that stores no credentials, and `NOT_SERVING` on a read
  replica. And a refusal a record owner judged is always `false`: it is the recorded answer
  to its `Idempotency-Key`, and that key sent again replays it. So the orders owner's 429
  `RATE_CEILING` is `false`: send the order again under a fresh key once `Retry-After` has
  passed.
  [The guide's *Retrying* section](/trading/docs/rest-api-guide#retrying) lists every code
  that is `true`.
* **`error.message`** is now on every refusal. Where neither the gateway nor the platform has
  something specific to add, it is a standing sentence for the code. It is still display
  text: never parse it.

**A request no route answers is refused in the envelope too.** An unknown path answers
**404 `ROUTE_NOT_FOUND`**, before authentication. The code is its own, so `NOT_FOUND` keeps
one meaning, no such row in your organisation, and a misspelt path, or a member older than
the operation you call, never reads as a missing row. A path asked with a method it does not
take answers **405 `METHOD_NOT_ALLOWED`** with an `Allow` header; it answered 404 before. A
request whose `Content-Length` declares a body past 1 MB answers 413
`PAYLOAD_TOO_LARGE`, one whose `Content-Type` names a charset the gateway cannot read
answers 400 `MALFORMED_REQUEST`, and an unexpected fault answers 500 `INTERNAL_ERROR`. Each
of these left with the web server's own body until now. `Error.code` grows by
`ROUTE_NOT_FOUND` and `METHOD_NOT_ALLOWED`. Two answers still carry no envelope: a request
the web server turns
away before the gateway reads it, such as a request line it cannot parse, and a failure too
deep for the gateway to answer.

**In the document**, the envelope's inner object is now the named schema **`ErrorBody`**, and
`Error.error` references it: a regenerated client gains one type name where it had an
anonymous one, and no field moves. `message`, `requestId` and `retryable` are published
optional in this version, because a member below it does not send them all; they become
required in a later version, once no such member is left. `X-Request-Id` is declared on
every operation, as an optional request header and as a header of every response: a
regenerated client gains one optional parameter on each operation.

Across the deploy window, a `0.12.0` member sends all four members of `error` on every
refusal and `X-Request-Id` on every response. A `0.11.x` member sends `code`, sometimes
`message`, and neither `requestId` nor `retryable`; it sends no `X-Request-Id` and ignores
yours, answers a wrong method 404, and answers an unknown path or an oversized body with a
body that is not the envelope. Where `retryable` is missing, decide by the status and the
code, as before: treat a record owner's refusal as final for its key, and bound any retry of
`INTAKE_UNAVAILABLE` and `NOT_SERVING`, whose deployment case that member cannot tell you
apart from an outage.

### 0.11.4 — 2026-10-04 — A write a stalled member could not look up answers 503, never a 202 that leads nowhere

A member that cannot look a request's key up within the write deadline now answers 503
`NOT_SERVING`: nothing was stored or sent, so retry. That covers `POST /credentials` and
`POST /credentials/{id}/rotate`, and `POST /orders` and the algos document writes when a name
the body states resolves to nothing here. Until now it answered 202 with a `Location` for an
operation the request never took, which polling could answer 404 `UNKNOWN_OPERATION`. The
`Unavailable` response's description names the case.

**Across the deploy window:** a gateway below `0.11.4` answered such a request 202 with a
`Location`; from `0.11.4` it answers 503 `NOT_SERVING`. On either side, retry such a request
under the same key — below `0.11.4` once its `Location` answers 404, from `0.11.4` at once.

### 0.11.3 — 2026-10-04 — A retried credential key stores nothing the member already answered

`POST /credentials` and `POST /credentials/{id}/rotate` now ask the member's journal before
they store any key material. A retry of a key the member answering it already holds stores
nothing and answers as any retry does: the first attempt's answer, replayed with
`Idempotent-Replay: true` and its position; the attempt still being carried, attached to; or
409 `IDEMPOTENCY_KEY_REUSED` for a key used on another record or resource. The intake's
store-time refusals — 409 `INTAKE_KEY_REUSED`, 429 `RATE_LIMITED`, and 503
`INTAKE_UNAVAILABLE` when the store refuses the write — meet only a key the member holds no
answer for.
[The guide's *Writes* section](/trading/docs/rest-api-guide#writes) has the rule.

**Across the deploy window:** a gateway below `0.11.3` answered most such retries the same
way, with two exceptions. Near the end of the time a member keeps a key's answer, it stored
the legs again before replaying, and could answer `INTAKE_KEY_REUSED`, `RATE_LIMITED` or
`INTAKE_UNAVAILABLE` where the replay belonged. And a retry sent while the first attempt was
still being carried, if the platform then lost that attempt in transit, could be accepted
while the key material it named was disabled in the store — a credential no connector can
use. From `0.11.3` the late retry replays its key's answer, and the retry sent mid-flight
answers the lost attempt's 503 — the next retry then stores the legs and sends them. On
either side, retry under the same key with the same legs; new legs are a new key.

### 0.11.2 — 2026-10-02 — A retried order key replays, whatever its names resolve to now

On `POST /orders`, the gateway's own 422 for a name — `INSTRUMENT_NOT_LIVE` for a symbol the
member holds no instrument under, `ACCOUNT_NOT_ACTIVE` for a display name no live account of
your organisation holds — now stands only for a key the member answering it holds no answer
for. A retry of a key it already holds answers as any retry does, whatever the name resolves
to now: the order the first attempt placed, or the platform's refusal of it, replayed with
`Idempotent-Replay: true` and its position; a submit still being carried, attached; a key used
for another operation, 409 `IDEMPOTENCY_KEY_REUSED`. A key the member holds nothing for meets
the 422 as before — the same message, nothing sent, no position header, and the key free to
retry. The 422 now comes from the member's journal, so a member that cannot carry writes
answers 503 `NOT_SERVING` or `OWNER_ABSENT`, or 429 `INBOX_FULL`, ahead of it, as it does any
write. [The guide's *Orders* section](/trading/docs/rest-api-guide#orders) has the rule.

**Across the deploy window:** a gateway below `0.11.2` answered such a retry with the 422 —
after the account was renamed or deactivated, or once the member stopped holding the symbol —
while the order its first attempt placed stood, and its message said nothing was sent: a
client that minted a fresh key on it placed a second order. From `0.11.2` the retry replays
the first attempt's answer. On either side, after the gateway's 422, fix the name and retry
under the same key: it is the order's `clientOrderId`, so a retry under it can never place a
second order, and a fresh key can.

### 0.11.1 — 2026-10-02 — Name an algo's account and instrument

A config document — the `document` of `POST /algos/instances` and of
`PUT /algos/instances/{id}/config` — may now name an account or an instrument wherever the
algo's params schema types a reference, instead of stating its id: an account by its display
name in your organisation (`"account": "okx-main"`), an instrument by its platform symbol
(`"instrument": "OKX@BTC/USDT"`) — the names `POST /orders` already accepts. A JSON number is
still an id, and a JSON string is a name: `"700"` names an account called 700.

The gateway resolves each name when it receives the request and sends the document with the
name replaced by the id it names, keeping the name beside it as a comment, so a document you
read back (`GET /algos/instances/{id}/configs/{seq}`, or the config write's 200) shows
`"account": 118310760 /* okx-main */`. Everything else in the document — your comments, the
layout, every other value — is kept exactly as you wrote it. A name is resolved once: a later
rename never moves a running instance, and submitting the document again resolves it again.
When the comments would take the document past its 6,144-byte bound — or for a name a comment
cannot carry — the ids are written without them.

* A name nothing here holds is **422** `REFERENCE_UNKNOWN_ACCOUNT` or
  `REFERENCE_UNKNOWN_INSTRUMENT` — the codes the platform gives an id it does not know —
  answered by the gateway with nothing sent: no position header, nothing journaled, and the
  key retries fresh. The message names the value's path and the name.
* The 6,144-byte bound holds for the document as written and as sent: one that fits as
  written but not once its names are replaced by their ids is **413** `DOCUMENT_TOO_LARGE`.
* A retried key the member answering it already holds replays its answer, as before, whatever
  its names resolve to now: a refusal answers only a key's first attempt.
* A version that member has not caught up with yet — one loaded a moment ago — sends the
  document as written, and the platform refuses its names 422 `FIELD_TYPE_MISMATCH` — a fact,
  so the key is spent: once `GET /algos/versions` lists the version, retry under a new key.

**Across the deploy window:** a gateway below `0.11.1` sends a named document as written, and
the platform refuses it 422 `FIELD_TYPE_MISMATCH` at the reference's path — a fact, so the key
is spent. From `0.11.1` the name is resolved. A document that states ids crosses unchanged on
both sides.

### 0.11.0 — 2026-10-01 — The algos surface

Twelve operations under a new **`Algos`** tag serve your organisation's **algo instances** —
strategies the platform runs for you, each created from a loaded **algo version** with a
**config document** — and the reads around them. Nothing existing moves.

**The reads.** `GET /algos/instances` lists every instance your organisation ever created,
`DELETED` ones included, in id order; `GET /algos/instances/{id}` serves one, with its
attribution (who created it, who or what last changed it — `lastUpdatedByUserId` is `null`
when the engine or the platform's own rule moved it, and `lastCause` says which) and
**`ETag: "<configSeq>"`**, the sequence number of its latest document.
`GET /algos/instances/{id}/configs` and `…/configs/{seq}` serve the document history: each
document verbatim (the JSONC text you sent), its `outcome` (`STAGED`, `APPLIED`, `REFUSED`),
`outcomeAtNs` (`null` while the engine's verdict is out), the engine's `detail` on a refusal,
and the account and instrument references the platform projected from it.
`GET /algos/instances/{id}/orders` is exactly that instance's orders, in the `OrderUpdated`
row shape (`algoInstanceId` set, `userId` `0`). `GET /algos/versions` is the catalog — the
platform's, not an organisation's: every loaded version per engine pool, with its manifest
and the JSON Schema a document for it must conform to. The algos reads have one surface
(`?view=` other than `latest` is 400) and no status filter (`?status=` is 400). Another
organisation's instance is 404 on every route, indistinguishable from an absent id.

**The writes** all need `algos:manage`, judged at the platform (a refusal is a fact with the
position header; the door checks nothing but the request's shape). On every algos write the
**`Idempotency-Key` is the command's `commandKey`** — one grammar, 1 to 64 of
`[A-Za-z0-9._~-]`; a body `commandKey` may restate it and never contradict it — and a body
`userId` is refused 400: the principal is stamped. `POST /algos/instances` creates an instance
**with its document in one request** (`algoVersionId`, `pool`, `displayName`, `remark`,
`document`); the document is one JSON **string** holding the JSONC text, and a document past
**6,144 bytes of UTF-8** is **413 `DOCUMENT_TOO_LARGE` at the door** — nothing is sent, and
the key retries fresh. The create answers **201** with the row once the platform has recorded
both the instance and its first document (`configSeq` 1), or 202 with `Location:
/operations/{key}` past the sync deadline, as every write; the operation record's `outcome`
then names the `instanceId` and the `configSeq` the pair minted. `PUT
/algos/instances/{id}/config` stages a new document (`remark`, `document`), while the
instance runs too, under **`If-Match: "<configSeq>"`** — the instance's `ETag`; absent or `*`
is unconditional, a weak validator is 400 `INVALID_IF_MATCH`, and a stale one is **412
`CONFIG_SEQ_CONFLICT`** with `currentVersion` and a fresh `ETag` (re-read, reapply, retry).
It answers 200 with the **staged** document — no `ETag`: documents carry no version — and the
engine's verdict is a later state, read on `…/configs/{seq}`; one document awaits a verdict at
a time (409 `CONFIG_PENDING`). `POST /algos/instances/{id}/start`, `/stop` and `/delete`
take `{ remark }` and **no `If-Match`** (one is 400 `INVALID_IF_MATCH`: stopping is the safe
direction, and no version race may lose it); each answers 200 with the row. A `delete` is
terminal: the row and its documents stay readable, the id is never reused. `POST /algos/halt`
stops every started instance of your organisation and answers 200 with `count` — `0` when
nothing was started, so the call always has an answer.

**`Error.code` grows** by the door's 413 `DOCUMENT_TOO_LARGE` and 503 `ALGOS_UNAVAILABLE`,
and by the algos owner's reasons — the `AlgoRejected.reason` enum, published in the document;
[the guide's *Algos* section](/trading/docs/rest-api-guide#algos) places each under its
status. `OperationRecord.outcome.family` gains `algos`, and `outcome` gains `instanceId` and
`configSeq`. `info.x-immix-algos-schema` names the wire schema the algos message shapes
derive from (`121.v0`).

Across the deploy window, a `0.10.0` member answers every `/algos` path 404 — a route it does
not serve — and a `0.11.0` member deployed **without the surface** answers them `503
ALGOS_UNAVAILABLE` after admission, with no position header. The 503 is the environment's
posture, not a transient: it clears when the environment gains its algos owner, never by
retrying, so a client renders "not available here" on it rather than an empty table, as for
`BALANCES_UNAVAILABLE`. Every other route is unaffected on both sides.

### 0.10.0 — 2026-10-01 — Binance spot accepts balances-only credentials

`POST /credentials` now accepts a **balances-only** Binance spot credential — `exchangeId` 100
with `canReadBalances: true` and `canTrade: false` — where it answered `422
VENUE_UNSUPPORTED` before. In `CredentialMaterial`'s `x-immix-intake-venues`, `binance_spot`
now names `accountGroup: "binance-account"` instead of `null`. Nothing else moves: the legs
are the same two (`key`, `privateKey`), and a `canTrade` row is served by `binance-oe` as
before. Such a credential's `scopeUnobserved` is `true` on Binance's demo environment, which
serves no read of a key's own scope (0.9.0).

Across the deploy window, a `0.9.0` gateway still refuses the row `VENUE_UNSUPPORTED` and a
`0.10.0` gateway accepts it. A client that retries a refused submission after the upgrade
stages it normally.

### 0.9.0 — 2026-10-01 — `scopeUnobserved` on the credential

A credential gains **`scopeUnobserved`** (boolean, optional — absent reads `false`). It is
`true` when the pre-approval probe **answered** but the venue offers no way to read the key's
own permissions — today Binance's Demo Mode. On such a row `observedPermissions` is empty
because nothing stated a scope, not because the key has none, and the approval's permission
checks (`SCOPE_INSUFFICIENT`, `SCOPE_MISMATCH`) are not applied to it: render it as "scope not
observed", never as "no permissions".

`false` means what `observedPermissions` always meant: the venue stated the key's scope, and
approval judges it.

Across the deploy window: a gateway still on `0.8.0` omits the field — read it as `false`, the
value every credential had before. After it, every credential carries it; only a credential
probed by a venue without a permissions read is ever `true`. No existing field changes.

### 0.8.0 — 2026-10-01 — Binance spot credentials

`POST /credentials` and `POST /credentials/{id}/rotate` accept a **Binance spot** API key
(`exchangeId` 100) beside OKX. `CredentialMaterial` is now **one schema per venue**, a
`oneOf` of `OkxCredentialMaterial` (`key`, `secret`, `passphrase` — unchanged) and
`BinanceSpotCredentialMaterial` (`key`, `privateKey`). The body's `exchangeId` names the
venue, and the material must carry exactly that venue's legs.

**Both sides of the deploy window.** An OKX body is unaffected: the same three legs validate
against the same closed schema, now under its own name. A Binance body sent to a member
below `0.8.0` is refused 422 `VENUE_UNSUPPORTED`, because that member serves no Binance
connector. Roll the fleet before onboarding Binance keys.

**What to do.**

* **Regenerate, and expect generated source to change.** The wire is unchanged for OKX; the
  generated types are not, and how they change depends on the generator:
  * **Structural generators** (TypeScript — openapi-typescript and the like) emit
    `CredentialMaterial` as a union, so an OKX object literal still type-checks where
    `CredentialMaterial` is expected. Code that *reads* a leg (`.secret`) off the union no
    longer type-checks until it narrows to `OkxCredentialMaterial`.
  * **Nominal generators** (openapi-generator's Java, Kotlin and C# targets) emit
    `CredentialMaterial` as a oneOf *wrapper* holding an instance, and the three legs move to
    the new class `OkxCredentialMaterial`. Code that built the material directly —
    `new CredentialMaterial().key(k).secret(s).passphrase(p)` — **stops compiling**: build an
    `OkxCredentialMaterial` and wrap it in `CredentialMaterial`.
* **For Binance, send an Ed25519 key.** Binance's order-entry session accepts no other key
  type, so the intake reads `privateKey` as an Ed25519 PKCS 8 key and refuses anything else —
  an HMAC secret, an RSA key — with 400 `MALFORMED_REQUEST` naming `material.privateKey`,
  before anything is staged. Send the API key Binance issued as `key` and the Ed25519 private
  key you registered as `privateKey` (PKCS 8 form, PEM or its base64 body).
* **Trading keys only.** A Binance credential must set `canTrade`. A balances-only Binance
  credential is refused 422 `VENUE_UNSUPPORTED`, because no Binance balance connector
  exists yet; `x-immix-intake-venues` shows this as a null `accountGroup`.

### 0.7.0 — 2026-10-01 — `algos:manage`: the capability enum gains the algos surface's one scope

The register's `UserCapabilities` set gains its eighteenth choice, `algosManage`, and this
document follows: **one value added to the `capabilities` enum** — on the user row, `GET
/me` and the two user bodies — which is why this is a **minor** (regenerate: a typed
client's capability enum gains a member). **No route, code or request of yours changes.**

**What changed**

* **`capabilities` may carry `algos:manage`**, served in declared order after
  `orgs:approve`. It is the one bit that admits every command of the algos surface (create,
  configure, start, stop, delete, halt — the routes arrive with their own section) and the
  organisation's authority for its robots to trade: an algo instance's orders carry no user
  (`0.6.0`), so no `orders:trade` sits anywhere in their chain, and this bit is granted as
  a trading permission. **It is in no preset** — `TRADER` stays `orders:trade` — so grant it
  by scope string on the user create and restate routes, as a Trader's bit is granted
  outside the maker-checker presets; the door refuses a body naming it beside a preset
  whose expansion lacks it, as for any other scope.
* `info.version` moves from `0.6.0` to `0.7.0`; `info.x-immix-register-schema` stays
  `114.v7` — a set choice is a bit in a field that already exists, so the wire layout and
  the schema version are unchanged.

**Each side of the deploy window**

A `0.6.0` member never serves the scope — the serializer walks the scopes its build names,
as the field's description states, so a bit it cannot name is dropped from the array — and
refuses a body naming it (400, an unknown scope); a `0.7.0` member serves it on every user
granted it and accepts it in a body. A client generated from `0.6.0` whose capability enum
is closed regenerates before it reads a user holding the new scope (the closed-enum
trade-off `114.v6` recorded); a client that does not validate the enum keeps working against
either. Deploy order is register → gateway → **grant**: a gateway that admits the scope
forwards it to an owner that recognises it, never the other way round, and the scope is
granted to nobody until every gateway member serves `0.7.0` and the platform's other readers
of the user record (the orders owner, the persistence writer) run this release — an older
reader drops a bit it cannot name, and keeps it dropped across its restarts. The org-register pair restarts
lockstep (its owner's snapshot version moves: it now grants the bit its previous version
refused, so a mixed pair would answer one grant two ways). The roll itself is the reason:
a user read through a `0.6.0` member and restated through a `0.7.0` one loses the scope —
`PUT` replaces the whole set, the array the old member served never carried it, and no
member can tell that restate from a revocation. The scope is in no preset, so a grant is
always an explicit act through a `0.7.0` member; holding grants until the roll completes
leaves nothing an old member could drop, and a grant made mid-roll anyway is exposed until
the roll completes and is restated after it. The loss is of authority, never a grant.

### 0.6.0 — 2026-10-01 — `algoInstanceId` on the order reads: an order placed by an algo instance carries the instance and `userId` 0

The orders wire schema moves to `111.v2`, appending `algoInstanceId` to the four order
messages, and this document follows: **one property added on two reads**, which is why this
is a **minor** (regenerate: a generated row type gains an optional property). **No route, code or
request of yours changes**; what you send on `POST /orders` and `POST /orders/{id}/cancel` is
exactly what `0.5.1` took, and the platform's autonomous algos — the reason for the field —
place their orders on a lane of their own, never through this edge.

**What changed**

* **`OrderUpdated` and `OrderRejected` gain `algoInstanceId`** (`x-since-schema-version: 2`,
  nullable, never required — an appended field). On an order a human placed — every order
  this edge has ever served, and every order placed through it — it serves `null`, and
  `userId` is the user it always was. On an order an **algo instance** placed it names the
  instance, and `userId` serves **`0`**: an algo's order carries no user. Exactly one of the
  two is ever set. `GET /orders` is the organisation's list and serves them like any other
  order; a client that filters it by its own `userId` will not see them, by design — which
  robot placed an order is the instance, and which human is behind the robot is that
  instance's own record, served by the algos surface when it lands. Read
  `algoInstanceId != null` (never `userId == 0`) as "machine flow".
* **The two write bodies stay as they were, and now say so.** `SubmitOrderBody` and
  `CancelOrderBody` publish neither `userId` nor `algoInstanceId`: both are the gateway's to
  stamp — the session's user, and an instance of `0` — and a body that states either is
  refused 400 `MALFORMED_REQUEST`, as a body `userId` always was. No session can dress a
  click order as an algo's.
* **A human may still cancel an algo's order.** `POST /orders/{id}/cancel` on an order
  whose `algoInstanceId` is set is accepted for a user of the same organisation, exactly as
  for any order the organisation holds; the row keeps its `algoInstanceId` and its `userId`
  of `0` — the cancel's actor rides the cancel command, never the order.
* **The trading edge's WebSocket `order` channel does not carry `algoInstanceId` yet.** Its row keeps
  serving `userId` as before and no instance field, by choice: no machine-flow order can
  exist before the platform release that lets the algos place orders, and that release's
  version of the trading-ws contract adds the field (`null` where this document serves
  `null`), amends its `userId` prose and ships a section of its own changelog. Until then a
  subscriber there has no machine-flow rows to read, and the rule above — the instance field,
  never `userId == 0` — is the one to build on for both lanes.
* `info.version` moves from `0.5.1` to `0.6.0`; `info.x-immix-orders-schema` moves from
  `111.v1` to `111.v2`. The [API guide](/trading/docs/rest-api-guide)'s *Writes* and *Orders*
  notes carry the rule.

**Each side of the deploy window**

A `0.5.1` member serves rows without `algoInstanceId` — the property is absent, not `null` —
and a `0.6.0` member serves it on every order row, `null` on every order placed to date. A
client generated from `0.5.1` keeps working against either (an unknown property is ignored;
a `null` optional is one it never had to read); one generated from `0.6.0` reads `null` from
a `0.6.0` member and *absent* from a `0.5.1` member for the same order — treat the two alike.
Between an older gateway member and the newer orders owner the wire frames carry the field
and the older member reads past it, exactly as an appended field is meant to be read; no
stream reset rides this release. Nothing you send differs between the two.

### 0.5.1 — 2026-09-25 — the guide and this changelog move with the gateway's new name

**No wire change**: no path, parameter, body, schema, status, header, operation id or tag
moves, and `info.title` is still `Immix API`. The gateway that serves this document is now
called the **trading gateway** — it was the org gateway — and the two documents it links
were renamed with it.

**What changed**

* **The links to the [API guide](/trading/docs/rest-api-guide) and to this changelog** — in
  `info.description`, in `externalDocs.url` and in every operation description that points
  into the guide — follow the two documents to their new addresses. Before the deploy the
  document links the old addresses; after it, the new ones, and the old addresses no longer
  resolve.
* **`POST /credentials`' description** opens its list with "Refusals" where it read "Its
  refusals" — the same six codes and the same link, in fewer characters.

**What to do.** Nothing, unless you store the guide's address: take the new one.

### 0.5.0 — 2026-09-25 — name the account, not just its id

`POST /orders` gains an **`account` alias for `accountId`**: name the account by its display
name within your organisation (`"okx-main"`) instead of its numeric id. The same rule the
`instrument` alias already states, spelled the same way — at least one of the two, both
stated must agree — and `accountId` leaves the body's `required` list so a body naming only
the name validates against the published document.

**Both sides of the deploy window.** Nothing moved: a client sending `accountId` is
unaffected, and `accountId` is still the stable address. A client that starts sending
`account` gets 400 `MALFORMED_REQUEST` ("the order names no account") from a member below
`0.5.0`, because the alias is stripped there and no id is left — so roll the fleet before
switching, or send both spellings through the window. Regenerate: the body's schema gained a
property and its `anyOf` became an `allOf` of two `anyOf`s, one rule per alias.

**Only the id crosses.** The edge resolves the name and puts the id on the wire. A name is
your organisation's to change; a command carrying one would name a different account after a
rename, and served rows would have to answer in a name the register no longer holds. The
same reasoning already keeps the instrument symbol off the wire.

**A new 422: `ACCOUNT_NOT_ACTIVE`** — a display name no live account of your organisation
holds. Answered at the door, where nothing has been sent: no position header, and your
`Idempotency-Key` retries fresh. The orders owner answers the same code, as a fact with the
position header, to an id it will not trade — one meaning for the consumer, whichever
spelling named the account. `Error.code` has always been open, so a client that treats an
unknown code by its status already handles it.

**Resolution is org-scoped by construction.** The org is the admitted principal's, never the
body's. Another organisation holding an account of the same name is invisible here: naming
it answers exactly as an unheld name does, so the alias is not a way to discover other
tenants. An account still being classified carries whatever the venue called it, binds no
name, and is addressable by id alone.

**The WebSocket door takes the same alias**, resolved through the same predicate. One submit
body serves both lanes, which is the reason this landed with the WS door rather than a rung
later.

### 0.4.3 — 2026-09-25 — money is served at the scale it states, and reference data leaves the read path

Sections `0.3.1` through `0.4.2` said every balance, order, execution and policy amount —
each a `Decimal` on the platform's wire since `0.3.1`, a scaled integer carrying its own
scale — was served *at no fewer places than the referenced record's*: an amount widened to
the asset's `unitScale`, a quantity to the instrument's `qtyScale`, a price or notional to
its `priceScale`. **That widening is removed.** Every money value is now served at exactly
the scale its own value states, and the read path consults no asset and no instrument at
all.

**What you see change.** Only the number of trailing zeros, and only where a value was
recorded at fewer places than its reference record. Examples, all of them the same numbers as
before:

| Field                                                                       | Was          | Now       |
| --------------------------------------------------------------------------- | ------------ | --------- |
| an asset-policy `maxTicketAmount` recorded at 2 places, asset `unitScale` 6 | `"2.500000"` | `"2.50"`  |
| an order `price` recorded at 1 place, instrument `priceScale` 2             | `"100.50"`   | `"100.5"` |
| an execution `fee` of a stated zero                                         | `"0.000000"` | `"0"`     |

A value recorded at **more** places than its record was already served in full and is
unchanged. A value recorded at exactly its record's places — the overwhelming majority, since
the order path states the record's pair at acceptance — is unchanged.

**What to do.** If you parse money as a decimal and compare numerically, nothing. If you
compare money as **strings**, or read the digit count as meaning, that breaks: `"2.5"` and
`"2.500000"` are the same number, and which one you get is now the precision the platform
recorded rather than a width derived from reference data. The guide has said to parse and
never assume the scale since `0.1.0`; this makes it load-bearing.

**The other half.** `0.3.4` made the way *in* refdata-free — the door encodes each decimal at its own minimal scale and reads no record to do it. This is the way *out*. Together they take reference data off this gateway's money path entirely, in both directions.

**Why.** The widening was never the design this API meant to have, and removing it restores
the one it did: a read serves each value at its own scale. The widening was bought to keep
the served width independent of the precision a publisher happened to state. It never actually delivered that: it degraded to the stated scale
whenever this member did not hold the asset or instrument — during catch-up, or for a venue
listing an asset before refdata carries it — so the same row could serve two different widths
depending on when you asked. Serving the stated scale is independent of reference data
unconditionally, which is the property the widening was reaching for. It also lets the
websocket edge render money identically without putting a refdata lookup and a `BigDecimal`
allocation on its per-row push path: one number, one string, whichever edge serves it.

**Either side of the deploy window.** A reader on the old build and a reader on the new one
see the same numbers, and differ only in trailing zeros on rows recorded coarser than their
record. No field is added, removed or retyped; `unscaled` stays absent and deprecated as it
has been since `0.3.3`.

### 0.4.2 — 2026-09-22 — fees and fills at the venue's precision: a fee where `null` was, executions that used to be refused, more places on some orders

Nothing in this document moves — no route, field, enum member, code or schema keyword — and
the rule you read money by is the one `0.3.2` stated: every value is served at the scale the
platform holds it at, never fewer places than the referenced record's, more only where a
venue reported finer. What changes is what the platform holds: the order-entry connector now
states every amount at the digits the venue rendered — a working scale per instrument and
family, and per fee asset, learned from the wire and widened, never narrowed — where it used
to read fills against the instrument record's scales and fees at a fixed eight places. A
patch, by the guide's *Versioning* rule: behaviour inside the published shapes, what a value
is served as. Three things you can see:

* **A fill's `fee` where you used to read `null`.** A fee the connector could not state —
  finer than eight places, as a BTC-denominated fee on a buy is (the venue charged
  `-0.00000124914`: eleven places) — was served as `null`, "not stated", and the execution
  stood without it. It is now stated exactly, at the fee asset's working scale, and served at
  no fewer places than that asset's `unitScale` (`"0.000001249140"` for a `unitScale` of 12),
  positive for a charge as before. `null` still means what it meant — the venue named no fee
  currency this platform holds, or an amount no 64-bit integer carries at 18 places — and
  both are counted on the connector, never on you. If your reconciliation read `null` as "no
  fee charged", it was undercounting these fees; expect those totals to move.
* **Executions that used to be missing arrive.** A fill whose quantity or price carried more
  places than the instrument record's `qtyScale` or `priceScale` was refused whole by the
  connector — the execution, its trade id and the order's progress lost until a reconcile.
  It now reaches the order, whose holders widen to take it; the one refusal precision has
  left is a value no 64-bit integer holds at its scale, the `FILL_PRECISION_OVERFLOW`
  rejection `0.4.0` published.
* **More places on some orders.** An order touched by a value the venue rendered finer than
  the instrument record — a fill, or the venue's derived average price, which can carry one
  place more than the tick — reads with those places from then on, on every money property
  of the row (`price`, `qty`, `cumQty`, `leavesQty`, `avgPx`, `lastFillPx`, `lastFillQty`):
  `"50000.12"` may become `"50000.120"`. Exact — trailing zeros only — and `0.3.2`'s promise;
  parse money as a decimal, never by counting places.

**Across the deploy window** the change lands with the order-entry connector's roll, not the
gateway's: until it rolls, such fees read `null` and such fills are refused; after it, they
are stated and admitted, and an order's places widen the first time the new connector reports
it. Nothing you send changes, nothing already served is rewritten, and no client needs
regenerating.

### 0.4.1 — 2026-09-22 — the door holds money to the published `pattern`, and its 64-character bound is published as `maxLength`

Every money property in this document has always published one grammar — `pattern`
`^-?[0-9]+(\.[0-9]+)?$`: an optional minus, digits, and at most one point with digits on both
sides of it — but the gateway never checked it. It handed
your string to a general-purpose decimal parser, which reads more than that grammar does.
And in the other direction the gateway refused a money string longer than 64 characters,
which the schema did not say. Both gaps close: **the gateway now accepts exactly what the
schema admits.** A patch, by the guide's *Versioning* rule — behaviour inside the published
shapes and code set, *which requests the door refuses*; the one change to the schemas is a
constraint keyword stating a rule the gateway already enforced, a case the rule now names.
No route, field, enum member or code moves.

**If every money string you send already matches the published `pattern`, nothing changes
for you.** Check the place your client turns a number into a string: a general-purpose
formatter switches to exponent form for small or large values — JavaScript's
`String(0.0000001)` is `"1e-7"`, Java's `BigDecimal.toString()` and Python's `str(Decimal)`
give `"1E-7"` — and that spelling is now refused. Format money from an exact decimal type in
plain notation (`BigDecimal.toPlainString()`, Python's `format(d, 'f')`), or carry the string
you were given. In JavaScript keep money in a string or a decimal library, never a `Number`:
`toFixed` is no fix — it rounds a binary float, `(0.0000001).toFixed()` is `"0"` (which the
gateway reads as "unset"), and from 1e21 it gives exponent form again.

**What changed**

* **A money string off the published `pattern` is refused 400 `MALFORMED_REQUEST`**, on
  every route that takes money — `POST /orders` (`price`, `qty`) and `POST` / `PUT` of
  `/asset-policies`, `/chain-policies` and `/trading-policies` — where it used to be read as
  the number it spells. The spellings that stop working: an exponent (`"1e3"`, `"1E-3"`), a
  leading plus (`"+5"`), a point with no digit on one side (`".5"`, `"5."`), **surrounding
  whitespace** (`" 1.5 "` — the gateway used to trim it, and no longer does), and digits
  outside ASCII (fullwidth `"１２.５"` used to read as 12.5). `error.message` names the
  field, the accepted form and the string it was sent. It is the door's refusal, like every
  other `MALFORMED_REQUEST`: nothing reached the platform, the response carries no position
  header, and **the key is not spent** — correct the spelling and retry under the same
  `Idempotency-Key`.
* **What is still accepted is everything on the grammar**: leading zeros (`"007.50"`),
  trailing zeros (`"1.50"` and `"1.5"` are one value, `0.3.4`'s rule), a minus sign, and
  `"-0"`, which is `"0"` — "unset", as `null` and an absent field are. What a value *is*
  judged for has not moved: more than 18 fractional digits, or past a 64-bit integer at its
  own scale, is still the door's 400, and everything else is still the platform's to judge.
* **Every money property now publishes `maxLength: 64`** beside its `pattern` — request
  bodies and served rows alike. On a request it is the bound the gateway has enforced since
  `0.3.4`, now written where a generated client and a form can read it, so a schema-valid
  request is no longer one the door can refuse for its length. On a response it is simply
  true: the widest decimal this API serves is 39 characters. The length is counted over the
  string as sent; a string past it is refused for its length before anything else reads it.
* **`BadRequest`'s description** (the 400 every write route shares) names the two money
  rules by their schema keywords instead of the bare 64. `info.version` moves from `0.4.0`
  to `0.4.1`; no `info.x-immix-*-schema` moves — nothing on the platform's wire changed. The
  [API guide](/trading/docs/rest-api-guide)'s *Money* section states the grammar.

**Each side of the deploy window**

For the length of a deploy a request can meet a member on either side. A member **below
`0.4.1`** still accepts `"1e3"`, `"+5"`, `".5"`, `"5."`, a string padded with whitespace and
digits outside ASCII, reads each as the number it spells, and sends that value on — so an
off-pattern order or policy write can succeed on one member and be refused on the next. A
member **at `0.4.1`** answers each of them 400 `MALFORMED_REQUEST`. Both refuse a money
string longer than 64 characters, and both serve the same rows: nothing you read changes on
either side, only the document's schemas gain `maxLength`. A client that sends only
on-pattern strings cannot tell the two apart. No wire schema version moves and no stream
reset rides this release.

### 0.4.0 — 2026-09-22 — `FILL_PRECISION_OVERFLOW` joins `OrderRejected.reason`; nothing you send or read changes

The orders owner gained one refusal reason, named one it already had, and made its ticket-cap
comparison exact in every case. The one change to the document is an enum member, which is
why this is a **minor**: a client generated with a closed enum type gains a member when it
regenerates. **No route, field or status moves, and no request of yours can earn the new
reason.**

**What changed**

* **`OrderRejected.reason` gains `FILL_PRECISION_OVERFLOW`** (appended; the enum is
  append-only — branch on what you know). The orders owner answers it to a *connector's*
  progress report, or to an attribution of a discovered fill — never to `POST /orders` or a
  cancel — when a venue states a fill at a precision the order cannot hold in a 64-bit
  integer. The fill is refused whole, never rounded to fit and never applied, the platform's
  operators are paged, and none is expected. You meet it only if you read refusals off the
  platform's stream; it sits under 422 in the guide's table because that table is total, not
  because a request can answer it. Until now the same refusal was the overflow half of
  `INVALID_FIELD`.
* **`NO_REFERENCE_PRICE` has a second cause, now written down.** An order needs a fresh
  reference price, and the owner places the quote on the instrument record's price grid: a
  quote that grid cannot hold exactly — a venue quoting finer than reference data has
  registered — is no reference, and every order on that instrument is refused until
  reference data catches up. That is a decision (fail-closed: the record's tick, bounds and
  minimum notional are as stale as its grid), not a fault, and nothing about the refusal
  changed: the same code, the same 409, no detail. Your handling does not change either —
  the refusal is the owner's, so retry under a fresh key (`0.3.4`'s rule). What is new is on
  the platform's side: the owner counts this cause apart from a dead feed.
* **A ticket cap compares exactly whatever scale it was written at.** `0.3.4` let a trading
  policy's `maxOrderQty` and `maxOrderNotional` ride at the scale you stated, so a cap and
  the order it judges differ in scale as a rule. The owner now compares them as products,
  restating neither — no verdict moved, and the one case it used to refuse on caution (a
  notional and a cap both past a 64-bit integer at the instrument's `priceScale`) is decided
  exactly. A breach that large states no `observed` leg in `error.message` — the figure does
  not fit the platform's decimal — and reads `limit … (notional)` alone.
* `info.version` moves from `0.3.4` to `0.4.0`; `info.x-immix-orders-schema` stays `111.v1` —
  an enum member appends without a schema version, as `ORG_NOT_ACTIVE` did. The
  [API guide](/trading/docs/rest-api-guide)'s *Orders* table lists the reason with its note.

**Each side of the deploy window**

Nothing a client sends or reads differs between a `0.3.4` member and a `0.4.0` one: the new
reason cannot answer an HTTP request, and the other two items are the owner's. Between an
older gateway member and a newer orders owner, a `FILL_PRECISION_OVERFLOW` refusal on the
stream decodes as a reason that member does not know — which it has always treated as the
validation class, and which concludes no operation of yours. No wire schema version moves
and no stream reset rides this release.

### 0.3.4 — 2026-09-22 — money in crosses at its own scale: the door's two scale refusals retire

Until now the gateway scaled every decimal you sent to the
*referenced record's* scale — an amount to the asset's `unitScale`, a quantity to the
instrument's `qtyScale`, a price or a notional to its `priceScale` — which is why it had to
hold that record, and why it refused what that scale could not express. Every money value on
the platform's wire has stated its own scale since `0.3.2` and `0.3.3`, so the gateway now
encodes each decimal at **its own minimal scale** — the fractional digits you sent, trailing
zeros dropped, so `"1.50"` and `"1.5"` are one value — and reads no reference data to do it.
The two refusals `0.3.2` and `0.3.3` said would retire do, and the platform judges the value
instead. No route, field or schema shape moves, and no code leaves the document; request and
response bodies are the same decimal strings. What does move is behaviour inside those
shapes — which requests the door refuses, and one code it answers. A patch: the guide's
*Versioning* section now says what `0.3.1` to `0.3.3` already did, that behaviour inside the
published shapes and code set is a patch.

**The one thing to do before you deploy against it: requests this door used to stop now reach
the platform, and the platform's refusal spends the key.** A door refusal was never
journaled, so the same `Idempotency-Key` retried fresh; an owner's refusal is a journaled
fact, and a retry of its key answers the stored refusal again with `Idempotent-Replay: true`
for as long as that member's journal holds the key (a day by default). For
`INSTRUMENT_NOT_LIVE`, which both the door and the orders owner now answer, the position
header tells them apart — the window section at the end has the rule in full.

**What changed**

* **Digits beyond the record's scale no longer refuse 400 at the door.** On `POST /orders` a
  `price` finer than the instrument's `priceScale`, or a `qty` finer than its `qtyScale`,
  reaches the orders owner, whose grid answers: 422 `TICK_SIZE_VIOLATION` or
  `QTY_STEP_VIOLATION`, with the observed value against the tick or step in `error.message`
  (`observed 50000.001 against limit 1.00 (price)`) — or 422 `INVALID_FIELD` where the
  instrument states no tick or step to be off, or the value is past a 64-bit integer at the
  instrument's scale. That refusal is a fact on the platform's stream: it carries the
  position header, the operation is journaled, and **the key is spent** — a retried key
  replays the refusal, so a corrected order needs a fresh `Idempotency-Key` (a fresh
  `clientOrderId`), where the door's 400 left the key free.
* **A policy amount finer than its record is accepted**, on `POST` and `PUT` of
  `/asset-policies`, `/chain-policies` and `/trading-policies`, and compared exactly: a
  `maxOrderQty` of `"1.123456789"` on an instrument whose `qtyScale` is 8 is that cap, not a
  refusal. **The amounts of one asset or chain policy share one scale** — the platform holds a
  row's amount family at the finest scale any of its amounts states — which has two
  consequences you can see. A row reads back at that shared scale or the asset's `unitScale`,
  whichever is finer: on an asset whose `unitScale` is 6, send `maxTicketAmount` `"2.5"`
  beside a `reconciliationToleranceAmount` of `"0.0000001"` and the ticket reads
  `"2.5000000"`. And amounts that do not fit a 64-bit integer together at that scale — about
  nineteen digits between the largest amount's integer part and the finest amount's places —
  are refused by the register, 422 `INVALID_FIELD`, where the door used to refuse the
  oversized one 400 by name. A trading policy's two caps are two families and do not share.
* **`SCALE_UNKNOWN` (422) is retired.** No member at `0.3.4` or above answers it. A policy
  naming an asset or instrument this member does not hold is accepted — its amounts need no
  record to be encoded — and reads back at the scale the row holds, with no places added for
  a record the member does not hold (`0.3.3`'s rule). An order naming an `instrumentId` this
  member does not hold **now reaches the orders owner, whose own reference data decides**: it
  is accepted if the owner holds the instrument open — an order a lagging member used to stop
  at the door can now trade — and otherwise answered 422 **`INSTRUMENT_NOT_LIVE`**, the code
  the owner has always answered for an instrument it does not hold or that is not open. The
  retired code stays listed in `Error.code`'s description and the guide's table until the
  next major: a member below `0.3.4` still answers it.
* **`POST /orders` by symbol: an `instrument` this member holds no instrument under answers
  422 `INSTRUMENT_NOT_LIVE` at the door**, where it answered 422 `SCALE_UNKNOWN`. The symbol
  resolves to no id, so nothing is sent and nothing is journaled — the same key retries
  fresh — and the code is the one the owner answers to an unheld id, so you meet one meaning
  by either spelling. **Tell the two apart by the position header**, as for every code both
  the door and an owner answer: with `X-Immix-Global-Sequence` it is the owner's fact and the
  key is spent; without it, the door's, and the key is free. `INSTRUMENT_NOT_LIVE` joins the
  door's codes in `Error.code`'s description and the guide's table; it was already in the
  document as an `OrderRejected.reason`.
* **What the door still refuses for money, all 400 `MALFORMED_REQUEST`**: a value that is not
  a decimal string; one no value on the platform can state — more than 18 fractional digits,
  or past a 64-bit integer at its own scale; a string longer than 64 characters (new: the
  widest decimal this API serves is 39, and the bound keeps a body-sized string of digits
  from costing the gateway seconds to parse); a scale of your own (`priceScale`,
  `maxTicketAmountScale`, …); and an amount stated beside an `assetId` or `instrumentId`
  that is absent or 0, which is still how the organisation-wide default trading policy is
  told it takes no money caps. `"0"`, `null` and an absent field still mean "unset".
* **A cap breach's `limit` leg reads at the scale the cap was stated at.** The
  `observed … against limit …` text in `error.message` renders each leg at the scale the
  platform's fact states for it (`0.3.2`), and a trading policy's cap now rides at the scale
  its write stated: a `maxOrderQty` sent as `"1"` reads `observed 2.00000000 against limit 1
  (quantity); trading policy 1 v2` where it read `… against limit 1.00000000 …`. The
  policy's row still serves the cap at the instrument's places (`"1.00000000"`);
  `error.message` is free text by the contract either way, never parsed.
* **No string you read changes on a row whose every amount sits at or coarser than its
  record.** A read serves each family from the scale its row holds at no fewer places than
  the referenced record's (`0.3.2`, `0.3.3`): `"2.5"` sent as a BTC amount crosses as 25 at
  one place and reads back `"2.500000000000"`, exactly as before, and an order's values,
  which the owner aligns to the instrument's pair on acceptance, read as they always did.
  What is new is a policy amount stated *finer* than its record: it reads back with every
  digit you sent, and its row's other amounts with it (the shared scale above).
* The descriptions of the nine input money properties (`SubmitOrderBody`'s `price` and `qty`;
  the three asset-policy amounts, the two chain-policy bounds and the two trading-policy
  caps on their `Submit*` bodies) state the input rule above in place of "scaled at the edge
  to the referenced record's scale … digits beyond it refuse"; the three policy `POST`
  descriptions, `POST /orders`, the `instrument` alias, `Error.code`, and the `BadRequest`
  and `Rejected` responses say the same. The [API guide](/trading/docs/rest-api-guide)'s *Money*,
  *Errors*, *Orders* and *Versioning* sections follow; `info.version` moves from `0.3.3` to
  `0.3.4`.

**Each side of the deploy window**

A member below `0.3.4` refuses at the door what a `0.3.4` member sends on: digits beyond the
record's scale (400 `MALFORMED_REQUEST`) and an asset or instrument it does not hold (422
`SCALE_UNKNOWN`) — neither journaled, so the same `Idempotency-Key` retries fresh. A `0.3.4`
member accepts the same requests or hands them to the platform, which may accept them or
refuse with `TICK_SIZE_VIOLATION`, `QTY_STEP_VIOLATION`, `INVALID_FIELD` or
`INSTRUMENT_NOT_LIVE` — facts on the stream, so **the key is spent and a retry needs a fresh
one**: the member that carried the request replays the stored refusal, with
`Idempotent-Replay: true`, for as long as its journal holds the key (a day by default; a
restarted member, or another member, judges the key afresh). That is the difference a client
must carry across the window, and it is not new — it is how every owner's refusal has always
behaved. **Do not give `INSTRUMENT_NOT_LIVE` the handler you gave `SCALE_UNKNOWN`** ("wait
for reference data, send the same request again") **unchanged**: on `POST /orders` the key
is the `clientOrderId`, and against a `0.3.4` member that loop is answered the owner's
stored 422 each time, even after the instrument lands. Keep the `SCALE_UNKNOWN` handler for
as long as a member you reach may report an `info.version` below `0.3.4`. For
`INSTRUMENT_NOT_LIVE` — one of the codes both the door and an owner answer, like
`ORG_NOT_ACTIVE` and `CAPABILITY_DENIED` — the position header says whose it was: without
`X-Immix-Global-Sequence` it is the door's (an unresolvable symbol; nothing journaled, the
same key retries once reference data has landed), with it the orders owner's (retry under a
fresh key). On the platform side there is nothing to coordinate: the owners have aligned a
command's values to their own holder since `111.v1` and `114.v7` (`0.3.2`, `0.3.3`), so a
gateway at either version may stand beside them, no wire schema moves, and no stream reset
rides this release.

### 0.3.3 — 2026-09-22 — a policy amount states its own scale: never served raw

On the platform's wire the seven policy money fields became `Decimal`s — an asset policy's
`maxTicketAmount`, `approvalThresholdAmount` and `reconciliationToleranceAmount`, a chain
policy's `minTransferAmount` and `maxTransferAmount`, a trading policy's `maxOrderQty` and
`maxOrderNotional` — each carrying its own scale beside its integer, where until now the
gateway looked the scale up in reference data: the asset's `unitScale`, the instrument's
`qtyScale` and `priceScale`. With it the last raw fallback on this API stops happening.
**No route, field, code or schema shape moves**; the seven descriptions say why. A patch, per
the guide's *Versioning* section, on the same reasoning as `0.3.1` and `0.3.2`.

**What changed**

* **`GET /asset-policies`, `GET /chain-policies`, `GET /trading-policies`, their `/{id}`
  routes — and the row every policy write answers with (`POST`, `PUT`, `approve`, `retire`,
  `withdraw`, `halt`, `resume`) — never serve a row raw.** Every amount and cap is read at the
  scale the row holds for its family and served as an exact decimal at no fewer places than
  the record's — the asset's `unitScale` for an amount, the instrument's `qtyScale` for
  `maxOrderQty`, its `priceScale` for `maxOrderNotional` — so a row whose asset or instrument
  you already saw reads **the same string as before** (`"1.500000"`, `"2.5000"`,
  `"12345.00"`). What changes is the row whose asset or instrument this member does not
  hold, or whose asset leaves `unitScale` unstated: `0.3.2` served its money as raw scaled
  integers (`"250"`) under `"unscaled": true`; `0.3.3` serves the decimal (`"2.50"`), because
  a value that states its scale needs no reference data to be read exactly. `null` still
  means unset — no cap, no bound, every transfer requires approval, an exact reconciliation —
  exactly as before.
* **`unscaled` is never present anywhere on this API now** — not on any register row, not as
  any collection's coarse marker; the balance surface lost it at `0.3.1`, the order surface at
  `0.3.2`, and the three policy families were the last rows that could carry it. Every
  `unscaled` property stays in the document, marked `deprecated`, so a client generated from
  `0.3.2` still compiles; all of them go at the next major.
* **The input side is unchanged.** `POST` and `PUT` bodies take the same decimal strings,
  scaled at the edge to the record's pair: digits beyond the asset's `unitScale` (or the
  instrument's `qtyScale` / `priceScale`) refuse 400, an asset or instrument this member does
  not hold refuses 422 `SCALE_UNKNOWN`, and a cap on the organisation-wide default trading
  policy refuses 400, exactly as before — the two scale rules retire with a later slice, which
  will say so here. What moved is on the wire: the edge now states the scale it encoded at
  beside each value, so no reader guesses. A body that sends a scale of its own
  (`maxTicketAmountScale`, `maxOrderQtyScale`, …) is refused 400, as `priceScale` is on
  `POST /orders`: the document never advertised one.
* **`info.x-immix-register-schema`** moves `114.v6` → `114.v7`. No published shape moves; the
  descriptions of the seven money properties on `AssetPolicyUpdated`, `ChainPolicyUpdated`,
  `TradingPolicyUpdated` and their `Submit*` bodies now say `Decimal` instead of naming a
  typedef (`amount_t`, `qty_t`, `notional_t`) whose scale to look up — and three of them
  (`minTransferAmount`, `maxOrderQty`, `maxOrderNotional`) were reworded for consumers with
  the re-cut, saying what their `0` means and nothing about the wire's history.
* The [API guide](/trading/docs/rest-api-guide)'s *Money* section says the same — its typedef
  table is now one rule for every money value on the API; `info.version` moves from `0.3.2`
  to `0.3.3`.

**Each side of the deploy window**

A `0.3.2` member serves a policy whose asset or instrument it does not hold as raw integers
under the marker; a `0.3.3` member serves the decimal and no marker. **Only a client that reads
the marker is correct against both**: under `"unscaled": true` a money string is the raw scaled
integer (`"250"` is `2.50` at two places), which no amount of careful decimal parsing recovers
— so keep honouring `unscaled` for as long as a member you reach may report an `info.version`
below `0.3.3`, and treat every money field as the exact decimal it says from `0.3.3` on. On the
platform side there is no mixed window: `114.v7` is a clean break under a schema floor, so the environment that takes this release takes it with a
stream reset (the gateway and the register it serves are dev-only), and a `0.3.2` member is never live beside a `0.3.3` one
on the same stream.

### 0.3.2 — 2026-09-22 — an order states its own scales: never served raw, and a refusal's legs are decimals

On the platform's
wire every money value on the order surface became a `Decimal`: each carries its own scale
beside its integer, where until now the gateway looked the scale up in reference data — the
instrument's `priceScale` and `qtyScale`, the fee asset's `unitScale`, and, for a refusal's
detail, a scale chosen by reason code. **No route, field, code or schema shape moves**; one
fallback stops happening, one free-text detail reads from the fact instead of a table, and
the descriptions say why. A patch, per the guide's *Versioning* section, on the same
reasoning as `0.3.1`.

**What changed**

* **`GET /orders`, `GET /orders/{id}`, `GET /executions`, `GET /executions/{id}` — and the
  order row `POST /orders` and `POST /orders/{id}/cancel` answer with — never serve a row
  raw.** Every price and quantity is read at the scale the row holds for its family and
  served as an exact decimal at no fewer places than the instrument's `priceScale` or
  `qtyScale` (a fill's `fee`: its asset's `unitScale`), so a row whose instrument you already
  saw reads **the same string as before** (`"50000.00"`, `"0.01000000"`). What changes is the
  row whose instrument — or whose fee asset — this member does not hold: `0.3.1` served its
  money as raw scaled integers (`"5000000"`) under `"unscaled": true`; `0.3.2` serves the
  decimal, because a value that states its scale needs no reference data to be read exactly.
* **A fill's `fee` on an asset whose `unitScale` is not 8 reads correctly for the first
  time.** okx-oe parses every fee at scale 8, and until `111.v1` each reader scaled that
  integer by the fee asset's `unitScale` instead: a `0.3.1` member served such a fee `10^(unitScale − 8)` out. The
  connector now states 8 beside the value, and `0.3.2`
  serves the fee from the value at no fewer places than the asset's `unitScale`. A fee on an
  asset at `unitScale` 8 reads the same string as before.
* **A stated zero fee serves as `"0.000000"`, not `null`.** A fill's `fee` is unstated only
  when it names no asset (`feeAssetId` null): a zero fee whose asset is named — a zero-fee
  promotion — is a measurement, and `0.3.2` serves it as the decimal the persistence lane
  already lands. `0.3.1` served every zero fee as `null`, asset or no asset.
* **`unscaled` is never present on the order surface** — not on an order or execution row,
  not as the two collections' coarse marker. The collections' property stays in the document
  marked `deprecated`; the row's rides `RowExtras`, shared with the register rows where it is
  still live (a trading policy's caps are scaled from reference data until the register's own
  re-cut), so its description says which rows never carry it. All of it goes at the next
  major. A client generated from `0.3.1` still compiles.
* **A refusal's detail legs are the decimals the owner stamped.** The `observed … against
  limit …` text in `error.message` — on a refused `POST /orders` (403/409/422) and on the
  polled operation's outcome — renders each leg at the scale the fact states for it, whatever
  the reason. Where this member held the instrument the string is unchanged
  (`observed 2.00000000 against limit 1.00000000 (quantity); trading policy 1 v2`); where it
  did not, `0.3.1` wrote the raw integer and said so (`quantity, raw at the instrument's
  qtyScale`), `0.3.2` writes the decimal. `error.message` is free text by the contract either
  way, never parsed.
* **`POST /orders` takes the same body.** `price` and `qty` are decimal strings, scaled at the
  edge to the instrument's pair: digits beyond it refuse 400, and an instrument this member
  does not hold refuses 422 `SCALE_UNKNOWN`, exactly as before — both retire with a later
  slice, which will say so here. What moved is on the wire: the edge now states the scale it
  encoded at beside each value, so no reader guesses. The two properties' descriptions say
  they are `Decimal`s on the wire instead of naming a typedef whose scale to look up.
* **`info.x-immix-orders-schema`** moves `111.v0` → `111.v1`. No published shape moves; the
  descriptions of every money property on `OrderUpdated` and `ExecutionRecorded` now say
  `Decimal`, and `OrderRejected`'s `observedValue` and `limitValue` — published for the
  reason value set, never served as JSON — are described and patterned as money, since each
  leg states its own scale.
* The [API guide](/trading/docs/rest-api-guide)'s *Money* and *Orders* sections say the same;
  `info.version` moves from `0.3.1` to `0.3.2`.

**Each side of the deploy window**

A `0.3.1` member serves an order or execution whose instrument (or fee asset) it does not
hold as raw integers under the marker, words such a refusal's detail as raw, and serves a
fee on an asset whose `unitScale` is not 8 at the wrong magnitude; a `0.3.2` member serves
the decimal, no marker, the decimal detail, and the fee as stated. Only a client that reads the
marker is correct against both: under `"unscaled": true` a money string is the raw scaled
integer, which careful decimal parsing does not recover (corrected with `0.3.3`, whose section
says the same of the policy rows). On the platform side there is no
mixed window: `111.v1` is a clean break under a schema floor,
so the environment that takes this release takes it with a stream reset (the order path is
dev-only), and a `0.3.1` member is never
live beside a `0.3.2` one on the same stream.

### 0.3.1 — 2026-09-22 — a balance states its own scale: never served raw

On the platform's wire a balance's three amounts became
`Decimal`s: each carries its own scale beside its integer, where until now the gateway looked
the scale up in reference data. **No route, field, code or schema shape moves**; one fallback
stops happening and three descriptions say why. A patch, per the guide's *Versioning* section.

**What changed**

* **`GET /account-balances` never serves a row raw.** `total`, `available` and `held` are read
  at the scale the reading itself stated and served as exact decimals — at no fewer places
  than the asset's `unitScale`, so a row whose asset you already saw reads **the same string
  as before** (`"2.500000"`). What changes is the row whose asset this member does not hold,
  or whose `unitScale` reference data leaves unstated: `0.3.0` served its amounts as raw
  scaled integers (`"2500000"`) under `"unscaled": true`; `0.3.1` serves the decimal, because
  a value that states its scale needs no reference data to be read exactly.
* **`unscaled` is never present on this surface** — neither on a balance row nor as the
  collection's coarse marker. Both properties stay in the document, marked `deprecated`, so a
  client generated from `0.3.0` still compiles; they go at the next major. The marker is
  unchanged everywhere else (register rows, orders, executions), where the scale is still
  read from reference data.
* **`info.x-immix-custody-schema`** moves `115.v0` → `115.v1`. `AccountBalance`'s published
  shape does not move — three decimal strings, as before — but the descriptions of `total`,
  `available` and `held` now say they are `Decimal`s on the wire, served at the value's own
  scale and never raw, instead of naming a typedef whose scale to look up.
* The [API guide](/trading/docs/rest-api-guide)'s *Money* and *Balances* sections say the same;
  `info.version` moves from `0.3.0` to `0.3.1`.

**Each side of the deploy window**

A `0.3.0` member serves a balance whose asset it does not hold as raw integers under the
marker; a `0.3.1` member serves the decimal and no marker. Only a client that reads the marker
is correct against both, mid-roll included: under `"unscaled": true` a money string is the raw
scaled integer, which careful decimal parsing does not recover (corrected with `0.3.3`). A
client that *required* the marker to be present on a balance row was never promised one.

### 0.3.0 — 2026-09-17 — account balances: `GET /account-balances`

**Additive**: one route, one tag, one query parameter, two schemas, one response and one error
code added; nothing existing moves. A minor, per the guide's *Versioning* section.

**What changed**

* **`GET /account-balances`** (tag `Account balances`, operation `listAccountBalances`) serves
  your organisation's **latest observed balance per (account, asset)**: what each of your
  accounts holds at its venue, as the venue states it, republished by the platform's account
  connector as a reading — keyed latest-wins, a reading superseded by the next and never
  corrected. Every row is the custody domain's `AccountBalance` record (schema 115, named by
  the new **`info.x-immix-custody-schema`**, `115.v0`) plus two fields of the gateway's own:
  * `accountId`, `assetId`, `credentialId` — the references (`GET /accounts` and
    `GET /credentials` serve the rows they name; a balance carries no venue or symbol of its
    own — join on them).
  * `total`, `available`, `held` — the venue's partition of the holding, **decimal strings at
    the asset's `unitScale`** like every other asset amount on this API (the `amount_t` row of
    the guide's money table); `available` is what the venue would let *move* now (a withdrawal
    or transfer amount, never what is usable as margin), `held` what the venue itself has
    locked (order margin, pending withdrawals, venue-side freezes), `total` the whole. **Zero
    is a reading**: the three are required and never `null` — `"0.00000000"` is a value.
  * `venueTsNs`, `venueRevision` — the venue's own clock and sequence for the reading where
    it states one; `null` otherwise (the 0 sentinel, as everywhere).
  * **`observedAtNs`** (new, the gateway's) — the platform time of the reading, epoch
    nanoseconds as a decimal string, never `null`: a changed reading and a restatement on the
    connector's heartbeat both move it, so a row whose `observedAtNs` stops advancing is an
    account the venue has stopped reporting. Judge freshness from it, never from `venueTsNs`.
  * **`unscaled`** (new, per row) — present and `true` only while that row's amounts are
    served raw: the asset is not yet held by the member, or its `unitScale` is unstated
    (typically an asset the venue lists before reference data carries it). The collection
    also carries the coarse top-level marker every collection carries, while any row carries
    its own.
* **The rows are your organisation's by construction**: a reading names its account, the
  account row names the organisation, and a reading whose account the member does not hold
  — or another organisation's — is indistinguishable from absent. A `DISCOVERED` account's
  balances are served while the account is being classified.
* **`?accountId=`** narrows the collection to one account's balances (a positive int32; an
  account outside your organisation, or one with no reading, yields an empty collection;
  `0`, a negative or a non-integer is 400 `MALFORMED_REQUEST`). Rows are ordered by
  `accountId` then `assetId`, ascending.
* **One surface, no entity route, no `ETag`**: `?view=latest` is accepted and
  `?view=approved` refuses 400, as on `/orders`; `?status=` refuses 400, as on every
  collection but `/users`; the key is the (account, asset) pair and a reading carries no
  version, so there is nothing to address by id or to precondition on.
* **The tag** joins the resource list after `Accounts`, in onboarding order: a generator that
  names a class per tag emits `AccountBalancesApi` beside the twelve it emitted before, and
  nothing it emitted moves.
* **`X-Immix-Global-Sequence` moves on balance readings too**: the position advances on
  every change the gateway sees — records, orders and, from now on, readings. A reader
  polling the header for "anything new?" will see it move on every heartbeat restatement
  (every 30 s per account and asset, by the connector's default) even when nothing else
  changed; read the rows it wants rather than treating the token as a change flag.
* **A member deployed without the balance surface answers `503 BALANCES_UNAVAILABLE`** on
  this route — after authentication and admission, before the query is read, with no
  position header — and every other route exactly as before. Composing the surface is a
  per-environment choice of the deployment (the gateway's `balances` knob, on by default):
  an environment into which no account connector publishes readings yet runs the edge with
  it off — dev, until the connector is deployed there — and turns it on with no change to
  this document. The code joins `Error.code`'s 503 set, and the route's 503 response names
  both it and `NOT_PRIMED`.
* `info.description` and `externalDocs.description` mention balances; the guide gains a
  *Balances* section; `info.version` moves from `0.2.1` to `0.3.0` — a minor: a route and
  its schemas added.

**What each side of the deploy window sees**: a member on the previous build answers
`GET /account-balances` with a plain `404 Not Found` (no error envelope, no position
header), and its `/openapi.json` reads `0.2.1` with no `x-immix-custody-schema`; a member on
this build answers the rows and reads `0.3.0` — or, deployed with the surface off, answers
the route `503 BALANCES_UNAVAILABLE` and serves the same `0.3.0` document: the document
describes the contract, the code says what this member composes. Every other route, body,
header and code answers exactly as before. A member on this build also moves the position header on
readings, so two members mid-roll can report different positions for the same register state
— the header is a consistency token, not a change counter, on both.

### 0.2.1 — 2026-09-17 — the generated property descriptions written for the consumer

**No wire change**: no path, parameter, body, schema, status or header moves; the text of the
generated schemas' property descriptions changes, and the one refusal detail below.

**What changed**

* **Every property description** under `components.schemas` — the message shapes generated
  from the register and orders wire schemas — is one line of prose written for the reader:
  what the field is, its sentinel where it has one, then the encoding note. The engineering
  citations the text carried (internal convention, decision and design-section references)
  are gone from the document; a client that scraped a description for one (none is known) reads the
  [API guide](/trading/docs/rest-api-guide) instead.
* **The encoding notes** every generated property ends with are reworded the same way: money
  ("a scaled integer on the wire, served as an exact decimal string — the money rules this
  document links list the typedef's scale source and the unscaled fallback"; on input, "a
  decimal string on input, scaled at the edge by the typedef's scale, whose source the money
  rules this document links list by typedef"), `int64` ("int64 as a decimal JSON string"),
  boolean ("a boolean; absent reads false"), and the enum and set notes minus their IDs. The
  [API guide's *Money* section](/trading/docs/rest-api-guide#money) gains the typedef → scale
  table those notes point at.
* **The `400 MALFORMED_REQUEST` detail** for an `int64` field sent as a JSON number (or, where
  the field is required, absent) reads `<field> must be an int64 as a JSON string` — the
  parenthesised internal reference it used to end with is gone.
* **Two trading-policy ceilings say how they are counted today**: `maxOpenOrders` and
  `maxOrdersPerMinute` are counted over the organisation's orders as a whole, the tighter of
  the governing row's and the organisation-wide default row's stated ceilings binding — the
  text had described a per-instrument row's ceiling as scoped to its instrument, which the
  orders owner does not do yet. The behaviour is unchanged; the description now matches it.
* `info.version` moves from `0.2.0` to `0.2.1` — a patch: prose only (the guide's
  *Versioning* section).

**What each side of the deploy window sees**: nothing behavioural on either side — every
route, body, header and code answers exactly as before, and a client generated from the
`0.2.0` document carries the same identifiers. A member on the previous build serves the old
descriptions and the old refusal detail.

### 0.2.0 — 2026-09-17 — tags are resources; `update*` operationIds; summaries and descriptions rewritten

**No wire change**: no path, parameter, body, schema, status or header moves; the document's
grouping and prose change, and with them the names a generated client carries.

**What changed**

* **One tag per resource, in onboarding order**: `Users`, `Credentials`, `Accounts`,
  `Account addresses`, `Portfolios`, `Asset policies`, `Chain policies`, `Trading policies`,
  `Orders`, `Executions`, `Operations`, `Organisations` — every operation carries exactly one,
  and `Organisations` (the platform's lifecycle arms) goes last with its audience stated.
  `Organisation`, `Venue access`, `Policies` and `Trading` retire. A generator that names a
  class per tag emits `UsersApi`, `CredentialsApi`, `AccountsApi`, `AccountAddressesApi`,
  `AssetPoliciesApi`, `ChainPoliciesApi`, `TradingPoliciesApi`, `OrdersApi`, `ExecutionsApi`
  and `OrganisationsApi` where it emitted `OrganisationApi`, `VenueAccessApi`, `PoliciesApi`
  and `TradingApi`; `PortfoliosApi` and `OperationsApi` are unchanged.
* **Six operationIds**: `upsertUser`, `upsertCredential`, `upsertPortfolio`,
  `upsertAssetPolicy`, `upsertChainPolicy` and `upsertTradingPolicy` are `updateUser`,
  `updateCredential`, `updatePortfolio`, `updateAssetPolicy`, `updateChainPolicy` and
  `updateTradingPolicy` — a generated client's method names move with them; every other
  operationId stays.
* **Every summary** is an imperative of at most six words (`List credentials`,
  `Get a credential`, `Withdraw a credential proposal`, `Halt trading under a policy`) and
  **every description** at most three sentences — what the operation does, who may call it
  (the capability), what it answers — with a link into the guide where the long form lives.
  The mechanics every write shares (the key, the precondition, the `202`, the position
  header) are stated once, on the shared parameters and responses, never per route. The
  fourteen broken summaries ("Read one of orgs by entity id", "Disable an user") go with the
  rest.
* **The shared components' prose** (`View`, `Id`, `Idempotency-Key`, `If-Match`, the headers,
  the responses, the nine `*Row` schemas, `RowExtras`, `Me`, `Error`, `PendingOperation`, `OperationRecord`,
  `CredentialMaterial`, the security scheme) is rewritten under the same rule; `Error.code`
  lists the gateway's own codes by status and points at the two owners' published reason sets.
* **`/docs`** gains a search box over the operations, keeps the bearer across a reload and
  shows request durations.
* `info.version` moves from `0.1.0` to `0.2.0` — a minor, since before `1.0.0` a minor may
  rename generated identifiers (the guide's *Versioning* section).

**What each side of the deploy window sees**: nothing behavioural on either side — every
route, body, header and code answers exactly as before. A client generated from the `0.1.0`
document keeps working against a member on this build (paths, parameters and schemas are
unchanged); regenerated from this document it carries the class and method names above, and
nothing else moves. A member on the previous build serves the old grouping and names.

### 0.1.0 — 2026-09-17 — the document's own version, title and intro; the API guide

**No wire change**: no path, parameter, body, schema, status or header moves; this section is
about the document's first screen and where its rules are written.

**What changed**

* **`info.title` is `Immix API`** — it read `immix org-gateway — the register edge`, the
  module's name and its design, neither of which a consumer has a word for.
* **`info.version` is the document's own version** — `0.1.0`, semver, bumped by hand with
  the changelog section that describes the change (the guide's *Versioning* section is the
  rule). It read the register schema's version (`114.v6`) before, which the sections below
  share while the document changed under it — a schema version identifies the wire, not the
  document. **A client that read `info.version` to learn which schema the messages
  derive from reads `info.x-immix-register-schema` (`114.v6`) from now on**;
  `info.x-immix-orders-schema` (`111.v0`) is as it was.
* **`info.description` is an eight-line introduction** (Markdown — Swagger UI renders it):
  what the API covers, then authentication, reads, writes and money in one line each, and the
  links to the guide and to this changelog. Every rule the previous description stated —
  one paragraph of 10,529 characters, rendered above the fold at `/docs` — moves, unchanged
  in meaning and rewritten for its reader, to the [**API guide**](/trading/docs/rest-api-guide):
  authentication and admission, principals, reads, writes, maker-checker, money, ids and
  sentinels, errors, orders, the organisation lifecycle, versioning. Nothing is deleted, and
  the half that is schema
  (`nullable`, `pattern`, `enum`, `uniqueItems`, `x-since-schema-version`) stays where it was.
* **`externalDocs`** (new, top level) points at the guide.

**What each side of the deploy window sees**: nothing behavioural on either side — every
route, body, header and code answers exactly as before. A client comparing `info.version`
across members during the roll reads `114.v6` from a member on the previous build and `0.1.0`
from one on this build; a client reading the version for the schema reads
`x-immix-register-schema`, absent on the previous build (fall back to `info.version` there).

### Vocabulary — 2026-09-17 — connections become credentials: `/credentials`, `credentialId`, `credentials:propose|approve`, `CREDENTIAL_*`, `material`, `/rotate`

The register's venue-key entity is a **credential** — what a user holds when they set a venue
up: one venue API key, what the platform may do with it, which connector serves it, and
what the venue says about it. "Connection" now names only the runtime axis: the health a
connector reports for the connection it holds with a credential. Nothing on the SBE wire
moves — every template id, field id, ordinal and byte offset is unchanged (114 stays v6,
111 and 115 at their versions) — but every client-visible name does:

* **Routes.** `/connections` → `/credentials`, and the arms under it (`approve`, `suspend`,
  `reactivate`, `revoke`, `withdraw`); `POST /connections/{id}/rotate-credential` →
  `POST /credentials/{id}/rotate`. `PUT /credentials/{id}` is the amend.
* **Bodies.** `SubmitConnectionBody` → `SubmitCredentialBody`, `AmendConnectionBody` →
  `AmendCredentialBody`; the legs object is `material` (`CredentialMaterial`, formerly
  `ConnectionCredential`) on the create and on the rotation.
* **Rows.** `ConnectionUpdated` → `CredentialUpdated` with `credentialId`; `connectionId` is
  `credentialId` on every row that carries it — `AccountUpdated` (and `tradingConnectionId`
  → `tradingCredentialId`), `OrderUpdated`, `ExecutionRecorded` — and on the stream facts
  `ConnectionHealth` (114) and `AccountBalance` (custody, schema 115); `ConnectionStatus` →
  `CredentialStatus` (values unchanged); `pendingTransition` reads `ROTATE` where it read
  `ROTATE_CREDENTIAL`.
* **Capabilities.** `connections:propose` → `credentials:propose`, `connections:approve` →
  `credentials:approve` (bits 1 and 2 unchanged; the presets follow).
* **Refusal codes.** `CONNECTION_IN_USE` → `CREDENTIAL_IN_USE`, `WRONG_CONNECTION` →
  `WRONG_CREDENTIAL`, `CONNECTION_NOT_ACTIVE` → `CREDENTIAL_NOT_ACTIVE`,
  `CONNECTION_UNASSIGNED` → `CREDENTIAL_UNASSIGNED`; on orders, `CONNECTION_UNAVAILABLE` →
  `CREDENTIAL_UNAVAILABLE`. `CONNECTION_DOWN` keeps its name — it is the pipe's health.
* **The operation journal** reports `"family":"credentials"`; the command kinds read
  `SUBMIT_CREDENTIAL` … `ROTATE_CREDENTIAL` and `REPORT_CREDENTIAL_PROBE`
  (`REPORT_CONNECTION_HEALTH` and `ConnectionHealth` keep their names).
* **The tag** `Connectivity` is `Venue access`: a generator naming a class by tag emits
  `VenueAccessApi` where it emitted `ConnectivityApi`, and one row type per family, now
  `Credential`.
* **Metrics.** The lane gauges (`oe_*`, `custody_lane_*`) label lanes by `credential` where
  they said `connection`; the alert rules and their scenarios follow.
* **The service principal's kind** is described as an M2M client (`UserKind.SERVICE`'s
  description); the wire value is unchanged.

**Deploy window: none is offered.** No environment runs the org tier with rows — dev's org
is not bootstrapped and prod has no org tier — so dev takes a flag-day and no member serves both shapes. A client on the old
shape answers 404 on `/connections`, and a create body still naming `credential` answers
400 `MALFORMED_REQUEST` (material is required) — nothing is staged either way; a client on
the new shape against an old member sees the mirror image. Pin clients to upgraded members.

### Entity ids — 2026-09-17 — minted from the sequencer timestamp

The owners now mint every id — the register's int32 keys (`orgId`, `userId`,
`connectionId`, `accountId`, `accountAddressId`, `portfolioId`, `assetPolicyId`,
`chainPolicyId`, `tradingPolicyId`) and the orders' int64 keys (`orderId`, `executionId`) —
from the
sequenced time of the creating command rather than from a dense counter, so an id no
longer tells its holder how many rows other orgs created before it.

**What changed**

* **Values, not types.** Every id keeps its JSON shape — int32 ids as integers, `orderId`
  and `executionId` as decimal strings — and its place in every path, body and operation
  outcome. What changes is the magnitude: a register id minted after this deploy is the
  whole seconds since 2023-01-01T00:00:00Z (about 117,000,000 in September 2026, reaching
  2,147,483,647 in 2091); an order or execution id is 16.384 µs ticks since the same epoch
  (thirteen digits today). Ids are strictly increasing in mint order and are never `0`.
* **Treat ids as opaque.** They were never promised small or contiguous; now they are
  neither. Do not size columns, pre-allocate by id, or infer counts from the gaps.
* **Both sides of the deploy window.** Ids minted before the deploy keep their small
  values and stay addressable forever — the bootstrap org and its first admin remain `1`;
  ids minted after are large. An id a client stored does not change. Nothing re-numbers.
* **Unique within a family, never across them.** A user and an account minted in the same
  second carry the same number: keys are per-family, so storage and every route are
  unaffected, but a bare id in a log line, a ticket or a URL is ambiguous without its family,
  and a mistyped cross-family lookup can now find a real row instead of a 404.
* **Why.** Under the dense counter an org's own ids revealed platform-wide populations and
  rates: how many users, accounts or orders every other org created between two of its
  own. The sequenced-time tick carries elapsed time instead.

### 114.v6 — 2026-09-15 — the credential intake: `POST /connections` takes the venue key's legs, the edge mints `secretRef` and fingerprints the material; `AmendConnectionBody`; `rotate-credential` carries material

**No wire change**: schema 114 stays at v6 — `SubmitConnection` and
`RotateConnectionCredential` carry the same fields; `ConnectionUpdated.keyFingerprint`'s
description now states the one
fingerprint (`fp-` + 16 hex of SHA-256 over the canonical payload). This section is about
**three request bodies that changed shape** — a breaking change for any client that built
them — six new door codes, and one door gate.

**What changed**

* **`POST /connections` takes the material, once.** `SubmitConnectionBody` gains a required
  `credential` object (`ConnectionCredential`: the venue's legs — for OKX `key`, `secret`,
  `passphrase`, each a non-blank write-only string, no other leg) and **loses** `secretRef`,
  `appGroup`, `venueKeyId` and `keyFingerprint`: the edge stages the legs in its write-only
  store, mints `secretRef` (`org<orgId>-<ulid>`), resolves `appGroup` from the venue and the
  flags (`canTrade` → the order-entry group `okx-oe`; `canReadBalances` alone → `okx-account`),
  fingerprints the material and fills `venueKeyId` from the key leg — and only then proposes
  the command with references and metadata. A body carrying any of the four derived fields
  answers **400 `MALFORMED_REQUEST`**; the row (200) carries all four as derived.
* **`PUT /connections/{id}` has its own body, `AmendConnectionBody`**: `SubmitConnection`
  minus the same four fields and with no `credential` — an amend restates the row's
  `secretRef`, `venueKeyId` and `keyFingerprint` from the row and re-derives `appGroup` from
  the flags (a row at a venue the intake does not serve keeps its group); a `credential` in
  an amend answers 400. An amend that would move a pinned row — one whose `keyFingerprint`
  the intake stamped — to another connector group (`canTrade` flipped on an OKX row) answers
  **400 `MALFORMED_REQUEST`** too: the credential store addresses the material by group, so
  the row's new connector could not read it; the road is a new connection through
  `POST /connections`.
* **`POST /connections/{id}/rotate-credential` requires a body**: `credential` (the new
  legs) replaces `venueKeyId` and `keyFingerprint`, which the edge now derives; the legs are
  staged under the row's existing `secretRef` before `RotateConnectionCredential` crosses.
  An empty body — legal before — answers 400.
* **One door gate.** The intake checks `connections:propose` on the caller's row before any
  material is staged: a principal without the bit answers **403 `CAPABILITY_DENIED` at the
  door** — the owner's code, the same meaning, but no `X-Immix-Global-Sequence` (nothing
  reached the stream). Every other route is gated by the owner alone, as before.
* **Six door codes**, all in `Error.code`'s description: `INTAKE_UNAVAILABLE` (503 — the
  member runs no sink, `secrets.sink=none`, or its store refused the write),
  `VENUE_UNSUPPORTED` (422 — an `exchangeId` the intake's venue registry does not name, a
  `providerKind` other than `EXCHANGE`, or a row that neither trades nor reads balances),
  `INTAKE_KEY_REUSED` (409 — the `Idempotency-Key` already staged different material),
  `PAYLOAD_TOO_LARGE` (413 — the 16 KiB intake body cap), `RATE_LIMITED` (429, with
  `Retry-After` — 20 stagings a minute per principal), and `CAPABILITY_DENIED` as the door's.
  Staging is idempotent by key: a retried key restates the same `secretRef` and adds no
  second version of the material — a version a refusal withdrew is staged again before the
  command re-crosses.
* **`ConnectionUpdated.keyFingerprint`** is described as the intake's fingerprint (`fp-` and
  the first 16 hex characters of SHA-256 over the canonical payload), the pin a connector
  lane adopts material at; empty on a row seeded before the intake. Prose only; the value
  set for rows the intake wrote is the `fp-…` form.
* The off-contract `/status` gains an `intake` section (the sink, stagings, replays, one
  counter per refusal).

**The deploy window** (member by member): a client on the previous body shape against a
member on this build answers `400 MALFORMED_REQUEST` (it sent `secretRef`, or no
`credential`); a client on the new shape against a member on the previous build answers 400
too (the old body's required `secretRef`, `venueKeyId` and `keyFingerprint` are absent,
and `credential` is an unknown field it ignores) — nothing is staged either way. Pin
connection creates, amends and rotations to upgraded members until the roll completes (a
member on the previous build also accepts an amend that moves a pinned row to another
connector group); every other route is unchanged.
**Row state on the connectors' side:** a connector lane now adopts material only at the
row's `keyFingerprint` in the `fp-…` form; an empty pin keeps the unpinned bind. A row pinned
under a retired scheme — the oe connector's key tail, the account connector's 64-hex digest
— would refuse its lane (`CONFIG_ERROR`, never a session under a mismatched pin) until it is
rotated through `POST /connections/{id}/rotate-credential` or its pin cleared by an amend
the owner accepts. No environment is known to hold such a row: every seed and every
runbook walk wrote an empty `keyFingerprint` (the old schemes were only ever checked, never
stamped). The observed axis (`observedKeyFingerprint`, the probe's readout) is not a pin —
a probe lane simply re-reports a pending row once in the new form.

### 114.v6 — 2026-09-15 — org activation at the edge: `POST /orgs` and the five `/orgs/{id}` arms, the platform's read exception, `ReportDiscoveredOrg` at the door

**No wire change**: schema 114 stays at v6; the six org command
messages and `ReportDiscoveredOrg` were on the wire since the v6 break and are now served and
proposed. This section is about six new operations, one widened predicate and one new door
behaviour.

**What changed**

* **Six org operations**, under the `Organisation` tag: `POST /orgs` (`SubmitOrg` — a new org
  and its first admin, two facts: the org `PENDING_APPROVAL` under dual control and the
  admin's `DISCOVERED` row), `POST /orgs/{id}/approve` (`ApproveOrg` — on an activation the
  body names `firstAdminUserId` among the org's `DISCOVERED` rows and fixes
  `requiredApprovals`, and two facts land: the org `ACTIVE`, the first admin `ACTIVE` with the
  Admin bits; on a reactivation or retirement the transition concludes), `/suspend`
  (`SuspendOrg`, immediate), `/reactivate` and `/retire` (`ReactivateOrg`, `RetireOrg` —
  proposals an approver other than the maker concludes), `/withdraw`
  (`WithdrawOrgProposal`). They take `Idempotency-Key`, and the entity arms `If-Match`,
  exactly as every command route; `200` is the org row (`OrgAnswer` — `OrgRow` with the
  fact's position and `ETag`). The bodies are the commands' derived bodies (`SubmitOrgBody` …
  `WithdrawOrgProposalBody`): `userId` removed, `targetOrgId` and `expectedVersion`
  route-sourced. **Two defaults at the door**: `SubmitOrgBody.idpOrgRef` and
  `firstAdminIdpUserRef` absent read as `""` (unbound — the roster-mode shape), and
  `ApproveOrgBody.requiredApprovals` absent reads as `1` (dual control — the stricter side;
  `0` is stated, never defaulted). The owner's reasons on these arms ride as ever:
  `CAPABILITY_DENIED` (403 — also the platform clause: every org arm needs an internal
  platform org actor holding the arm's `orgs` bit), `SELF_APPROVAL`, `NOT_PROPOSER` (403),
  `NAME_TAKEN`, `ALREADY_DISCOVERED`, `ILLEGAL_TRANSITION`, `PROPOSAL_PENDING`,
  `NOTHING_PENDING` (409), `UNKNOWN_ENTITY`, `INVALID_FIELD` (422), `VERSION_CONFLICT` (412).
* **The read predicate's one exception**. An internal platform org
  member holding an `orgs` bit (`orgs:propose` or `orgs:approve`) now reads **every** org's
  row on `GET /orgs` and `GET /orgs/{id}` — `PENDING_APPROVAL` ones included — and, on
  `GET /users` and `GET /users/{id}`, the members of every `PENDING_APPROVAL` org (the
  `DISCOVERED` rows an activation names its first admin among; `?status=DISCOVERED` narrows
  to them plus the caller's own queue). Nothing else widens: an `ACTIVE` org's members and
  every other family stay the caller's own org's. **What moved**: `GET /orgs` was "at
  most one row" for every caller; for these principals it is the list. The org entity arms
  ride the same predicate — to any other principal another org's `/orgs/{id}/…` is
  `404 NOT_FOUND`, indistinguishable from an absent id, exactly as its row is; `POST /orgs`
  has no path entity and reaches the owner, whose platform clause refuses a non-platform
  maker `CAPABILITY_DENIED`. The users write arms ride the same predicate too: a platform
  orgs reader's `PUT /users/{id}` or `POST /users/{id}/disable` on a pending org's
  `DISCOVERED` row passes the door (the row is theirs to read) and is refused by the owner —
  `CAPABILITY_DENIED` without `users:manage`, `ORG_MISMATCH` with it: the platform activates
  an org, it admits nobody inside it; every other principal keeps the `404`. Both
  operations' descriptions say so.
* **The door reports an unknown organization.** In `jwt` mode a token whose `org_id` binds
  no org used to be refused `403 ORG_NOT_ACTIVE` with *awaiting activation* and nothing
  else. Now the door first proposes `ReportDiscoveredOrg` — the `org_id` with the `orgName`
  and `productProfile` claims the tenant's post-login Action stamps from the Auth0
  organization's metadata — and `ReportDiscoveredUser` under it, then refuses the same code;
  the message says *the door has reported the organization "…"* (or *could not report* —
  a replica). A token whose `org_id` binds a **parked** org — an activation the platform
  withdrew, its name and binding held — is reported the same way, and the owner re-proposes
  on that row: the member's next login re-queues it, once per withdrawal (the report is keyed
  on the parked row's version, so a burst of logins proposes once); the refusal is a bound
  org's — `orgNotActive` on `/status`, the message naming the withdrawal and its remedies —
  and the member is reported under the parked org even when the token carries no metadata
  claims (then the message says the door cannot re-propose it, and what does). The org lands `PENDING_APPROVAL`, its first member `DISCOVERED`, and a platform
  approver activates on `POST /orgs/{id}/approve`. A token carrying no `orgName` or no known
  `productProfile` claim is refused `ORG_NOT_ACTIVE` with *cannot report the organization*
  naming the missing claim — nothing is proposed. A non-ASCII `org_id` is refused
  `403 FORBIDDEN_PRINCIPAL` as a non-ASCII `sub` already was (the wire's rule). The
  `Forbidden` response's and `Error.code`'s descriptions say so; `/status`'s `admission`
  section gains `discoveredOrgs`.
* `info.description`'s *Authentication* paragraph and the `Organisation` tag describe the
  org lifecycle surface.
* **The order surface's reason set grows by one** (schema 111, appended):
  `ORG_NOT_ACTIVE`, answered `403` on `POST /orders` when the acting user's org is not
  `ACTIVE` (the orders owner's admission gained an org-liveness check: a suspended org's members trade nothing, and
  `POST /orders/{id}/cancel` stays ungated). The
  same string the door and the register answer, one meaning at all three; it rides
  `error.code` with no detail pair, like `ORG_MISMATCH`. A client switching on the reason
  set adds the case; one that maps unknown reasons to "refused" needs nothing.
* **`SubmitOrgBody` and `ApproveOrgBody` describe their optional fields as optional.** The
  document had called `idpOrgRef`, `firstAdminIdpUserRef` and `requiredApprovals`
  "route-sourced — the route injects them", the wording of the path-sourced ids; they are
  body fields a client may set, read as the door's default when absent (unbound; dual
  control), and each property and the body's description now say so (a fourth derivation
  arm, `optional`). Prose only: the wire, the routes and the defaults are as they were.

**The deploy window** (member by member, no wire change): a member on the previous build
answers `404` on the six org routes for everyone, serves `GET /orgs` as the caller's own org
alone, and refuses an unbound `org_id` without reporting it; a member on this build serves
the routes and the widened reads to the platform's orgs readers and reports the
organization. A client of the new surface should be pinned to upgraded members until the
roll completes; nothing changes for any other caller.

### 114.v6 — 2026-09-15 — admission at the edge: `jwt` mode, `AWAITING_ADMISSION` and `ORG_NOT_ACTIVE`, `GET /users?status=`, the preset expansion

**No wire change**:
schema 114 stays at v6 and every message shape is as the section below states; this section
is about how a caller is admitted and two additions to the document.

**What changed**

* **Authentication has two lanes, and the deployed one is the IdP's.** In `jwt` mode (dev
  and prod) the bearer is a JWT verified against the tenant's JWKS with `aud` and `iss`
  pinned; its `org_id` claim names the organization (the org row's `idpOrgRef`), its `sub`
  the member (the user row's `idpUserRef` — an M2M client's is `<client_id>@clients` on a
  `SERVICE` row). In `roster` mode (local and test) a static per-user token binds to a
  register user by name, as before. Either way the register decides: nothing on a token is
  a role, a permission or an org id. `info.description`'s *Authentication* paragraph now
  says exactly this — a client reading the old paragraph ("static per-user bearer tokens")
  is not wrong about the local lane, only incomplete.
* **Two new door codes, both 403.** `AWAITING_ADMISSION`: a verified subject the register
  holds no row for, or holds a `DISCOVERED` row (a `DISABLED` row is `FORBIDDEN_PRINCIPAL`,
  below) — on its first request the door reports the discovery
  (`ReportDiscoveredUser`, a machine proposal; a `DISCOVERED` row lands holding no
  capabilities) and until a `users:manage` holder admits the row every route but `GET /me`
  answers this code; `GET /me` serves the row so a UI can say "awaiting admission".
  `ORG_NOT_ACTIVE`: the bearer's org is not `ACTIVE` — bound but suspended, pending or
  retired, or not registered at all (the message says *awaiting activation*; the section
  above makes the door report such an organization, the code stays) — its members are refused
  whatever their rows say. **What moved**: a `DISCOVERED` row was refused
  `FORBIDDEN_PRINCIPAL` before this change and is refused `AWAITING_ADMISSION` now, in both
  lanes — a client that branched on `FORBIDDEN_PRINCIPAL` to mean "not admitted" branches on
  `AWAITING_ADMISSION` for that case and keeps `FORBIDDEN_PRINCIPAL` for the outright
  refusals (a roster binding with no row, a token with no `org_id`, a `DISABLED` row). Both
  codes are named in `Error.code`'s description; `ORG_NOT_ACTIVE` is the same string the
  owner already answers to a command whose actor's org is not active — one meaning at both
  ends. **Also moved, in both lanes**: a principal whose org is bound but not `ACTIVE`
  (`SUSPENDED`, `PENDING_APPROVAL`) was admitted to the reads before this change and is
  refused `ORG_NOT_ACTIVE` now — the org's status gates its members at the door as it
  already gated their commands at the owner.
* **`GET /users?status=`** — a new optional query parameter on the user collection alone
  (`DISCOVERED` | `ACTIVE` | `DISABLED`, the wire enum less its sentinel):
  `status=DISCOVERED` is the admission queue. Absent, every row as before; an unknown value,
  or the parameter on any other collection, refuses `400 MALFORMED_REQUEST`.
* **`UpsertUserBody.preset` carries `x-immix-preset-capabilities`**: the expansion of each
  preset name to its scope strings, in declared order — what a UI's preset picker pre-fills
  before the human edits the bits; pinned to the view library's bundles by the contract
  test. An extension keyword: generators ignore it, and the `preset` enum is unchanged.
* **The alias bodies state their "at least one" rule in schema.** `UpsertUserBody` and
  `SubmitOrderBody` carry an `anyOf` with one branch per spelling (`preset` | `capabilities`;
  `instrument` | `instrumentId`), so a body naming neither fails schema validation at the
  client where before it validated and met the door's `400 MALFORMED_REQUEST`. The "both
  stated must agree" half stays prose (JSON Schema has no cross-field equality). Nothing
  changes for a conforming client: every body the gateway accepted still validates.

**The deploy window** (member by member, no wire change): a member on the previous build
answers a `DISCOVERED` row `FORBIDDEN_PRINCIPAL`, admits a member of a non-`ACTIVE` org to
the reads, and ignores `?status=` (it serves the whole collection); a member on this build
answers `AWAITING_ADMISSION`, refuses that member `ORG_NOT_ACTIVE`, and honors the filter.
Nothing else differs in `roster` mode. `jwt` mode is new — a gateway is switched to it by
configuration (`auth.mode`, `auth.jwk.domain`, `auth.audience`, `auth.issuer`), and a member so configured refuses every static token
`401` from its first request.

### 114.v6 — 2026-09-15 — capabilities replace roles; the user's kind, subject and display name; the org's approval count; `GET /me`

**This is a clean break, not an append**: schema 114 was
re-baselined at v6, the stream it fed was reset, and the `role` string is gone
from every user row and body. It is the last such break — from v6 the wire is append-only.

**What changed**

* **`UserUpdated` loses `role` and gains `kind`, `capabilities` and `displayName`.**
  `capabilities` is an array of scope strings — `users:manage`, `connections:propose`,
  `connections:approve`, `accounts:propose`, `accounts:approve`, `account-addresses:propose`,
  `account-addresses:approve`, `portfolios:manage`, `asset-policies:propose`,
  `asset-policies:approve`, `chain-policies:propose`, `chain-policies:approve`,
  `trading-policies:propose`, `trading-policies:approve`, `orders:trade`, `orgs:propose`,
  `orgs:approve` — served in that (declared) order, unique, never null: an empty array is a
  read-only member. Every bit stands alone (`approve` does not imply `propose`). The set is
  append-only on the wire, and the published schema is an `array` of an `enum` listing the
  seventeen, so a generated client gets a typed set — which also means a scope appended
  later arrives with a contract version bump and its own changelog section, and a client
  that validates responses against an older document regenerates before it meets the new
  scope (the closed-enum trade-off `Error.code` once made and `114.v3` reversed). `kind`
  is `HUMAN` (logs in through the IdP) or `SERVICE` (an M2M credential). `displayName` is
  what the IdP stated at discovery — informational, `""` when none was.
* **`UserStatus` renumbers around a new value**: `DISCOVERED` (a subject the door met, no
  capabilities yet) → `ACTIVE` ⇄ `DISABLED`. Names are what the contract carries; a client
  comparing names is unaffected, one that hard-coded ordinals is not.
* **`UpsertUserBody` (`POST /users`, `PUT /users/{id}`)** takes `kind` (required),
  `capabilities` (the scope strings above, a strict allow-list — an unknown or repeated
  scope refuses 400) **or `preset`**, an edge alias the door expands and never forwards:
  `INITIATOR`, `APPROVER`, `ADMIN`, `TRADER`, `TREASURY`, `PLATFORM`. At least one of the
  two is required; both stated must agree (the preset's expansion equals the listed set) or
  the body refuses 400. The row records bits, never the preset — `GET /users` renders the
  expansion. `role` in a body is ignored like any unknown property, so a `114.v5` body
  without `kind` and `capabilities` refuses 400 at the generated parse.
* **`OrgUpdated` gains `requiredApprovals`** (`integer`, 0..255): how many approvals a
  maker-checker mutation in the org needs — `1` is the dual-control pair; `0` means the
  maker's fact is the concluded row (local and throwaway stacks; a production guard refuses
  it elsewhere). It is a plain count: `0` is a value, never `null`.
* **`GET /me`** (new): the caller's own row joined to its org — `userId`, `orgId`,
  `orgName`, `name`, `displayName`, `kind`, `status`, `capabilities`, `requiredApprovals`,
  `isInternalPlatform`, `globalSequence`. The one read a `DISCOVERED` principal may make;
  every UI configures itself from it and from nothing in a token.
* **Refusal codes**: `ROLE_DENIED` is renamed **`CAPABILITY_DENIED`** (403 — the owner's
  register reason now spells the same as the orders owner's); new `USER_NOT_ADMITTED` (403,
  the actor is `DISCOVERED`) and `ORG_NOT_ACTIVE` (403, the actor's org is not `ACTIVE`).
  `IDP_REF_TAKEN` (409) stays. The `OrgRegisterRejected.reason` enum publishes them all.
* `info.version` moves from `114.v5` to `114.v6`.

**What each side of the deploy window sees**

* There is no mixed window on the register: the break resets the stream, and the
  org-register pair, the orders owner, the gateway and the persistence pair roll together
  onto v6. Nothing fail-stops a `114.v5` member that meets a v6 frame — it misreads it
  silently (`UserUpdated.status` renumbered: a v6 `DISCOVERED` row reads as v5 `ACTIVE`, the
  `kind` byte reads as a `role`), which is why the group rolls as one. The other direction is
  refused: a v6 owner answers a `114.v5` `UpsertUser` or bootstrap frame `INVALID_FIELD`
  (detail `schema version 5 is below the 114 v6 floor`) rather than reading a grant out of
  its bytes, and a v6 users or orgs fold fail-stops on a pre-v6 `UserUpdated`/`OrgUpdated` —
  a stream the break did not reset. Roll the clients with them.
* A client on `114.v5` parsing `role` finds no such property on a `114.v6` gateway: read
  `capabilities` instead, and branch on `CAPABILITY_DENIED` where `ROLE_DENIED` was. A client
  posting a `114.v5` user body (`role`, no `kind`) is refused 400 (`MALFORMED_REQUEST`) —
  loudly, never with a silently defaulted grant.

### 114.v5 — 2026-09-15 — the IdP subject binding

The register records which IdP subject a principal is; the read edge serves it.

**What changed**

* **`UserUpdated` gains `idpUserRef`** (`string`, `x-since-schema-version: 5`): the IdP
  subject bound to the user — the token's `sub` (an Auth0 user id such as
  `google-oauth2|1111…` or `auth0|6aa2…`), opaque: compare it, never parse it. Empty means
  unbound — a principal admitted before the IdP, or a local lane without one — served as
  `""`, never `null`. A subject resolves to at most one live user per org.
* **`UpsertUserBody` gains `idpUserRef`** (`string`, `x-since-schema-version: 5`, not
  required) on `POST /users` and `PUT /users/{id}` — the subject to bind, read off the
  IdP's dashboard (the token's `sub`). An appended field is lenient: absent, `null` and
  `""` all mean *unstated*. On a create that leaves the new user unbound; on a `PUT`
  restate it **keeps** the row's current binding (unstated, never unbind — a rename never
  strands a login), and a value re-binds. A row read from `GET /users/{id}` round-trips
  into its `PUT` body unchanged.
* **A new register refusal, `IDP_REF_TAKEN`**, in `NAME_TAKEN`'s class (409): an
  `UpsertUser` stating a subject another live user of the org already carries. A
  `DISABLED` user's subject is free; a row restating its own subject is no collision.
* `info.version` moves from `114.v4` to `114.v5`.

**What each side of the deploy window sees**

* A client on `114.v4` ignores the new property; nothing it already parses changes.
* A gateway still serving `114.v4` renders a user the register has already bound
  **without** the property — so "absent = unbound" only holds once the serving gateway is
  on `114.v5`; check `info.version` before concluding a user carries no subject.
* A gateway on `114.v5` serves `idpUserRef: ""` for every row the register wrote before v5
  and every row no `ADMIN` has bound; the bootstrap of a deployment an IdP fronts binds its
  first `ADMIN` at birth.
* A client posting a `114.v4` user body (no `idpUserRef`) to a `114.v5` gateway is
  accepted unchanged — the appended field reads as unstated. The other direction is the
  hazard: a `114.v5` body against a `114.v4` gateway is accepted too, and the binding is
  **silently dropped** (the edge ignores properties it does not know; only `userId` is
  refused). Check `info.version` before binding a subject through the edge.
* The org-register pair restarts lockstep (its owner's snapshot version moves); roll the
  gateway after the register, so the property appears as soon as the register can set it —
  and **before any subject is bound through the edge**. A `114.v4` gateway's generated
  codec does not degrade on a reason it does not know: its lookup throws on the ordinal
  (`IDP_REF_TAKEN` is 26) rather than answering `UNKNOWN`, so an `IDP_REF_TAKEN` refusal
  reaching a `114.v4` gateway fail-stops that member — it rejoins and replays, and the
  request that earned the refusal concludes on the 202 sweep, never as a 409. From
  `114.v5` the command lane reads the reason through a guard that maps an unknown
  ordinal to `UNKNOWN`, so the *next* appended reason is served 422 (the outcome table's
  documented arm) until the gateway rolls, instead of stopping it.

### 114.v4 — 2026-09-15 — the HTTP order surface: `POST /orders`, the cancel, the orders and executions reads

The document's `info.version` stays `114.v4` — no register message moves — and gains
`info.x-immix-orders-schema: "111.v0"`, the orders schema the new messages derive from.

**What changed**

* **Six routes under a new `Trading` tag.** `GET /orders` and `GET /orders/{id}` serve the
  orders domain's rows; `GET /executions` (with an optional `?orderId=` join filter) and
  `GET /executions/{id}` serve the executions; `POST /orders` proposes `SubmitOrder`;
  `POST /orders/{id}/cancel` proposes `CancelOrder`. Org-predicated exactly like the
  register families (a foreign id is 404, indistinguishable from absent). The two reads
  have **one surface**: no `view` parameter — `view=approved` refuses 400
  `MALFORMED_REQUEST`. Order ids are int64 and cross as decimal strings, in the path too.
* **The submit's `Idempotency-Key` IS the order's `clientOrderId`** — the orders owner's
  business idempotency key: 1 to 36 characters of the key grammar (its own
  parameter, `OrderIdempotencyKey`;
  a longer key refuses 400 `INVALID_IDEMPOTENCY_KEY`). A body `clientOrderId` may restate
  it, never contradict it (400). A retried key replays or attaches at the edge as every
  key does; a retry that reaches the owner afresh (a restarted instance, an evicted key)
  restates the same order under it — the same key can never mint a second order — and
  another user's key answers 409 `CLIENT_ORDER_ID_CONFLICT`.
* **A submit must name its instrument, by id or by name.** `instrumentId` (the int64, as
  a decimal string) and **`instrument`** (refdata's platform symbol — `OKX@BTC/USDT`,
  with the venue's product suffix where it has one) are two spellings of one field:
  at least one is required, both stated must agree (400 `MALFORMED_REQUEST`), and the
  symbol is resolved **at the edge** against the instruments that member holds — only the
  id ever rides the command, and every served row carries the id. `SubmitOrderBody`
  publishes both properties and requires neither on its own; a client that sends
  `instrumentId` today is unaffected. Symbols are refdata's to change, so an id remains
  the stable address and a name the convenience; a symbol two instruments share resolves
  to the higher id, the same rule refdata's own symbol index keeps.
* **The named instrument is the scale source**: `price` (LIMIT only; absent, `null` or
  `"0"` for MARKET) scales by the instrument's `priceScale`, `qty` by its `qtyScale`,
  exactly — more fractional digits than the scale refuses 400; an instrument the
  projection does not hold refuses 422 `SCALE_UNKNOWN` unsequenced, by either spelling
  (the message names what was asked for).
* **Money on orders and executions serves like the register's**: price-shaped fields
  (`price`, `avgPx`, `lastFillPx`, `fillPx`) as decimals at `priceScale`,
  quantity-shaped fields (`qty`, `cumQty`, `leavesQty`, `lastFillQty`, `venueCumQty`,
  `fillQty`) at `qtyScale`, an execution's `fee` at the fee asset's `unitScale`; a
  required money field always serves a decimal (`"0.00000000"` is a quantity), the
  optional ones serve `null` at their sentinel — `venueCumQty`'s is `-1`, the one
  non-zero sentinel on the served surface; a value whose scale source is not held serves
  raw under `unscaled`, as the register's does.
* **The cancel takes no `If-Match`** (a cancel must never lose a version race — no version
  precondition applies; a stated one refuses 400 `INVALID_IF_MATCH`); its
  body is optional, a body `orderId` may restate the path, and `CancelOrderBody` carries no
  `clientOrderId` — the path names the order, and a stated key refuses 400 (an empty string
  is that field's unset sentinel: tolerated, never addressing). The
  accept answers the row with `cancelRequestedAtNs` set and the **status unchanged** —
  the venue-async window; `CANCELED` arrives later on the venue's word as an ordinary
  order update the reads serve. An order's `ETag` is its per-order `version`, a read
  token only; executions carry no version and no `ETag`. A replayed 200 whose order has
  since been released (the owner's retention outran the journal window) answers 404
  `NOT_FOUND` under `Idempotent-Replay` — the read's own answer, never a fabricated row.
* **The orders owner's reasons ride verbatim as `error.code`** (the `OrderRejectReason`
  value set, published on `OrderRejected.reason`), mapped by class: 403
  `CAPABILITY_DENIED` (the edge pre-checks no role — the owner refuses a principal
  who is not a live `TRADER`); 404 `UNKNOWN_ORDER`; 409 `CLIENT_ORDER_ID_CONFLICT`,
  `ACCOUNT_NOT_ACTIVE`, `CONNECTION_DOWN`, `TRADING_HALTED`, `NO_REFERENCE_PRICE`,
  `OPEN_ORDER_CEILING`, `ORDER_TERMINAL`, `ORDER_STATUS_UNKNOWN`; 429 `RATE_CEILING` (with
  `Retry-After: 60`, the window); 422 the field, reference, sizing and cap refusals. A
  refusal's `error.message` restates the owner's detail — the observed value against the
  limit in the reason's own unit, and the trading-policy pin — and a replay keeps it.
* **The projection position moves on every fact this edge folds**, the orders domain's
  included: a reader observing position S observes every register row AND every order and
  execution at or before S (before, the token moved on register facts only). The
  `GlobalSequence` header's and `GlobalSequenceValue`'s descriptions say so.
* **`OperationRecord.outcome`** gains `orderId` (an int64 string) and `family` admits
  `orders`: an orders operation names its order there, never in the int32 `entityId`. It
  also gains `message` — the free text the same outcome answered synchronously as
  `error.message` — so a client that polled a `202` reads the refusal's detail too; absent
  when the outcome stated none (every accept, the register owner's refusals, the door's).
* New components: parameters `OrderIdempotencyKey`, `OrderId`, `ExecutionId`,
  `OrderIdFilter`; schemas `OrderUpdated`, `ExecutionRecorded`, `OrderRejected`,
  `SubmitOrder`/`SubmitOrderBody`, `CancelOrder`/`CancelOrderBody`; responses
  `OrderAnswer`, `OrderRow`, `ExecutionRow`. `Error.code`'s description names the order
  reasons; `Forbidden` and `Backpressure` describe their order-side arms.

**What each side of the deploy window sees**

* A client of the register contract keeps working unchanged: no path, property, type or
  `required` set of any register route moves. The only shared change is the token's
  meaning — it now also advances on order facts — which a client comparing tokens for
  monotonicity never notices.
* A gateway still on the previous build answers every order route 404 (the route does
  not exist); a gateway on this build answers the contract above. The gateway now
  **borrows the orders owner's two folds** (`orders`, `executions`): it must be deployed
  where the orders owner runs, and its boot order follows the owner (the genesis-floor
  rule — a borrower refuses to cold-start until the owner it borrows has an acknowledged
  snapshot where a store is wired). Locally, start the orders owner before the gateway.
* The owner-liveness gate is the register owner's: an absent **orders** owner does
  not refuse at the door — a submit commits and answers 202 at the sync deadline, its
  record standing `COMMITTED` until the owner returns.
* The instrument alias is additive on both sides of the window: a body naming
  `instrumentId` is the shape a gateway on either build accepts, and one naming
  `instrument` is refused 400 by a gateway still on the previous build (the body then
  names no instrument at all). A client that wants the name must therefore know it is
  talking to this build — read `info.x-immix-orders-schema` and the presence of
  `SubmitOrderBody.properties.instrument`, or simply keep sending the id.

### 114.v4 — 2026-09-15 — the two freshness arms' 0 sentinels are described correctly

A prose correction only: no message shape moves, no served value changes and
`info.version` stays `114.v4` — the published descriptions of two `TradingPolicyUpdated`
fields stated the **opposite** of what the orders owner and the oe connector do with them.

**What changed**

* **`TradingPolicyUpdated.refFreshnessNs`'s description.** `0` now reads as *disables the
  age comparison alone — the reference itself stays mandatory for every order type,
  so an absent reference refuses either way*, where it read "0 = nothing admits (the strict
  arm — state a window to trade)". The owner's admission arm guards the comparison with
  `refFreshnessNs > 0`, so `0` is the **permissive** setting — no staleness bound at all —
  not a lockout. `NO_REFERENCE_PRICE` still refuses when no reference exists, at any
  `refFreshnessNs`.
* **`TradingPolicyUpdated.instructFreshnessNs`'s description.** `0` now reads as *the
  connector's configured default window (`instruct.freshness.default.ms`), never a
  lockout*, where it read "0 = nothing instructs (strict arm)". The oe connector
  substitutes its configured default whenever the governing policy states `0`; the
  first-instruction gate always applies, only its window is operator-set.
* **`degradedGraceNs` is untouched** — its "0 = any DEGRADED refuses" was, and stays,
  accurate. The three duration sentinels on this row genuinely differ from one another;
  read each field's own description rather than generalising from a neighbour.

**What each side of the deploy window sees**

* **Nothing behavioural, on either side.** No arm changes, so a client sends the same
  values and gets the same answers from a gateway on either build; the document is
  catching up to arms that have always worked this way.
* What moves is what a reader believes `0` means, and the old text pointed the wrong way on
  a risk control. An operator who read it and left `refFreshnessNs` at `0` believing it
  refused everything was in fact running with **no reference-staleness bound**; one who
  left `instructFreshnessNs` at `0` for the same reason was running the connector's default
  window rather than a lockout. Both are worth re-checking against the intended policy —
  state a non-zero `refFreshnessNs` to bound reference age (5 s is the recommended
  bound) and a non-zero `instructFreshnessNs` to
  override the connector's default.

### 114.v4 — 2026-09-15 — the command surface: 40 routes over the register's commands

The document's `info.version` stays `114.v4`: no message shape moves; the document grows
routes, request bodies and components.

**What changed**

* **Three route shapes per family, 40 routes over 34 commands.** `POST /{family}` creates
  (`users`, `connections`, `account-addresses`, `portfolios`, `asset-policies`,
  `chain-policies`, `trading-policies`); `PUT /{family}/{id}` amends or restates (the same
  families minus `account-addresses`); `POST /{family}/{id}/{verb}` acts — `users/disable`;
  `connections/approve|suspend|reactivate|revoke|rotate-credential|withdraw`;
  `accounts/classify|approve|freeze|unfreeze|retire|withdraw`;
  `account-addresses/verify|retire`; `portfolios/retire`;
  `asset-policies` and `chain-policies` `approve|retire|withdraw`;
  `trading-policies/approve|retire|withdraw|halt|resume`. `orgs` and `accounts` have no
  create; the connector-proposed commands and the bootstrap never ride this edge.
* **Request bodies are the command schemas minus what the edge supplies**, published as
  derived `<Command>Body` schemas beside the generated `<Command>` schemas: `userId` is
  **absent from every body schema and refused if sent** (400 `MALFORMED_REQUEST`) — it is
  stamped from the admitted principal; the entity ref (`connectionId`, `targetUserId`, …)
  and `expectedVersion` are route-sourced (the path, `If-Match`) and not required — a body
  may restate them, never contradict them (400). A create's ref must be absent, `null` or
  `0`. String fields are required as the schema states them (send `""` for none — the
  empty-not-null rule the rows already follow); optional wire fields are lenient (absent or
  `null` = the 0 sentinel); enums by name, strictly (the sentinel refuses 400); booleans as
  booleans, absent = `false`. An action's body is optional (`{}` or empty is legal).
* **`Idempotency-Key` is required on every unsafe route**: a URL-safe string of 1–64
  characters from `[A-Za-z0-9._~-]` (a UUID fits), scoped by the admitted principal. A
  retry of a concluded key replays the stored outcome with `Idempotent-Replay: true` and
  publishes nothing; a retry of a live key attaches (one publish total); a key reused on
  another family or entity refuses 409 `IDEMPOTENCY_KEY_REUSED`.
* **`If-Match` on entity routes carries the command's `expectedVersion`**: the
  `ETag` an entity read minted, quoted or bare; absent or `*` = unconditional; weak
  validators refuse 400 `INVALID_IF_MATCH`. A stale precondition answers
  **412 `VERSION_CONFLICT`** with the entity's `currentVersion` in the envelope and a fresh
  `ETag`: re-read, reapply, retry.
* **Every write is a full-record restatement — send `If-Match`.** A register command
  carries the whole record and the answering fact replaces `latest` wholesale, so a write
  with no precondition restates every field from the client's copy, including the ones it
  never meant to touch: two admins editing one row from stale reads silently revert each
  other, both answered 200 (the owner's pending book keeps two commands from clobbering
  each other *inside* the owner; it cannot know the second was assembled from a stale
  read — only the token can). Round-trip the `ETag` of the read the edit was based on, and
  on 412 re-read before retrying. The same shape one step less visible: an amend against
  an entity whose proposal is still pending replaces that proposal wholesale — a
  `classify` on an account with a pending `AMEND` is a revision of that proposal, not a
  collision with it, so without `If-Match` the second maker's body simply becomes the
  proposal. The `If-Match` parameter's description states both, on every entity route.
* **Responses are the owner's answer from the stream.** 200 is the family's row (the same
  shape the reads serve, plus `globalSequence`) with the answering fact's position in
  `X-Immix-Global-Sequence` and the row's `ETag`; the same instance's reads already reflect
  it. A refusal the owner answered carries the refusal's own position, and the same
  instance's reads are at or past it too (every register fact moves the position, a
  refusal folding to nothing); a replayed 200 restates its original answer's position, no
  newer than the projection's. 202 is committed-or-pending: `{"status":"PENDING","key":k}`
  with `Location: /operations/{key}`. The owner's rejection reasons ride verbatim as
  `error.code`, mapped to statuses by class: 412 `VERSION_CONFLICT`; 403 `SELF_APPROVAL`,
  `ROLE_DENIED`, `NOT_PROPOSER`, `USER_DISABLED`; 409 for state and uniqueness refusals
  (`ILLEGAL_TRANSITION`, `PROPOSAL_PENDING`, `NOTHING_PENDING`, `NAME_TAKEN`,
  `DUPLICATE_POLICY`, `DUPLICATE_ADDRESS`, `CONNECTION_IN_USE`, `PORTFOLIO_IN_USE`,
  `LAST_ADMIN`, `CONNECTION_NOT_ACTIVE`, `ALREADY_DISCOVERED`, `ALREADY_BOOTSTRAPPED` —
  and, from `114.v5`, `IDP_REF_TAKEN`);
  422 for field and reference refusals (`UNKNOWN_ENTITY`, `INVALID_FIELD`, `MISSING_VENUE`,
  `WRONG_CONNECTION`, `ORG_MISMATCH`). Door refusals: 400 (`MISSING_IDEMPOTENCY_KEY`,
  `INVALID_IDEMPOTENCY_KEY`, `INVALID_IF_MATCH`, `MALFORMED_REQUEST`), 422
  `SCALE_UNKNOWN`, 404 `NOT_FOUND` (an entity route's id absent from, or outside, the
  principal's org — exactly as the read answers), 429 `INBOX_FULL` / `IN_FLIGHT_FULL`
  (`Retry-After`), 503 `NOT_PRIMED` / `NOT_SERVING` / `OWNER_ABSENT`. A replayed refusal
  (403, 409, 412, 422 — the owner's rejections, sequenced and journaled) carries
  `Idempotent-Replay: true` and the position of the rejection fact exactly as a replayed
  200 does; the edge's and the platform's own refusals (the 400s, 409
  `IDEMPOTENCY_KEY_REUSED`, 413 `TOO_LARGE`, 422 `SCALE_UNKNOWN`, 429, 503) are never
  journaled and carry neither header — a retry takes fresh. In the document each family's
  row is one named schema (`OrgRow`, `UserRow`, …: the fact plus `RowExtras`) that both its
  read's 200 and its commands' 200 reference, and every operation carries an `operationId`
  (`listOrgs`, `createUser`, `approveConnection`, …): generated clients get one row type
  per family, by construction, and stable method names.
* **Money in mirrors money out.** `amount_t`, `qty_t` and `notional_t` fields are decimal
  strings scaled at the edge by the same source the reads render by — the referenced
  asset's `unitScale`, the referenced instrument's qty and price scales — exactly: more
  fractional digits than the scale refuses 400; a scale the projection does not hold for
  the asset or instrument the body names refuses 422 `SCALE_UNKNOWN` naming the ref (a
  client may retry once refdata lands); a cap stated with no ref at all (`assetId` or
  `instrumentId` absent), or a ref off its grammar, can never scale and refuses 400;
  absent, `null` or `"0"` is the 0 sentinel, so a row read from `GET` round-trips into a
  `PUT` body. The org-default trading policy (`instrumentId` absent) takes no caps (400).
* **`GET /operations/{key}`** serves the principal's own operation records (`key`, `phase`,
  `commandGlobalSequence`, `origin{memberId, instanceId}`, timestamps, `outcome{httpStatus,
  code, family, entityId, globalSequence}`); another principal's key is 404
  `UNKNOWN_OPERATION`, indistinguishable from an unknown one. The journal is member-local:
  poll the instance that carried the request (session affinity behind N instances); a
  restarted instance answers 404, and a retried key then republishes — safe, because every
  create is natural-key-unique and every action state-gated at the owner.
* **`Error.code` opens.** It was a closed enum of five read-side codes; it is now a string
  whose description lists the door codes and points at `OrgRegisterRejected.reason` (now
  published) for the owner's set — append-only. `Error` gains `currentVersion` (412 only).
  The `Rejected` responses declare the position header (the refusing fact's).
* New components: parameters `IdempotencyKey`, `IfMatch`, `OperationKey`; header
  `IdempotentReplay`; schemas `PendingOperation`, `OperationRecord`, `OrgRegisterRejected`
  and the 34 command/body pairs; responses `Pending`, `BadRequest`, `Rejected`,
  `VersionConflict`, `Backpressure`, `Unavailable`, `InternalError`, `UnknownOperation`
  (`ForbiddenPrincipal` is renamed `Forbidden`; the response is unchanged). The document's
  title and description say the edge is the register's reads *and* commands.

**What each side of the deploy window sees**

* A client of the read contract keeps working unchanged: no path, property, type or
  `required` set of any `GET` moves. A client whose generated `Error.code` type was a closed
  enum must regenerate before it meets the new codes (a stale enum would have refused them
  as unparseable — that is why the field opened).
* A gateway still on the previous build answers every unsafe route 404 (the route does not
  exist); a gateway on this build answers the contract above. Roll the gateway before any
  client that writes; the register owner needs no change for this one (the commands are
  the ones it has always accepted).
* The 202 path is honest about a quiet or slow owner: a command committed but not yet
  answered within the sync deadline answers 202 and concludes on the record later; a
  client that needs the row polls `/operations/{key}` on the same instance, or re-reads.

### 114.v4 — 2026-09-15 — the connection's observed axis

The register records what the venue said about a staged key, on the connection row itself;
the read edge serves it.

**What changed**

* **`ConnectionUpdated` gains the connection's observed axis** — eleven properties, all
  `x-since-schema-version: 4`, filled by the register from the connector's pre-approval
  key readout (`ReportConnectionProbe`, a machine-proposed command no gateway route
  prepares):
  * `observedPermissions` (`array` of `string`; items enumerated `read` / `trade` /
    `withdraw`; `uniqueItems`): the key scopes the venue stated, served in that order.
    Empty means no scope observed — served as `[]`, never `null`. The choice set is
    append-only: skip an unknown name, never error on it.
  * `observedAllowlist` (`string`, enum `UNKNOWN` / `NONE` / `COVERS` / `MISSES`): the
    key's IP allowlist judged against the cluster's egress set.
  * `probeOutcome` (`string`, the `HealthReason` enum): `UNKNOWN` = never probed; `NONE` =
    the venue's read answered, and the observed scopes are its word; any other value is
    the probe's own failure (`AUTH_FAILED`, `INVALID_KEY`, `IP_NOT_WHITELISTED`, …), with
    `probeDetail` saying what the connector could.
  * `probedAtNs` (int64 epoch-ns as a decimal string, nullable): the consensus time of the
    probe report the row records — the register stamped it, so it is the same on every
    member; `null` = never probed.
  * `observedKeyCreatedAtNs` (int64 epoch-ns as a decimal string, nullable): the key
    creation time the venue stated; `null` = the venue states none, or never probed.
  * `observedKeyId`, `observedKeyFingerprint`, `venueAccountRef`, `permissionDetail`,
    `allowlistDetail`, `probeDetail` (`string`, empty until a probe answers): the
    venue-facing key identifier (the public half — never material), the fingerprint of
    the material the probe adopted (compare with `keyFingerprint`, the intake's pin), the
    venue's account identifier behind the key, the venue's own permission and allowlist
    strings verbatim, and redacted diagnostic text for a non-`NONE` outcome.
* **"Never probed" is one shape**: `probeOutcome: "UNKNOWN"`, `probedAtNs: null`,
  `observedPermissions: []`, `observedAllowlist: "UNKNOWN"`, `observedKeyCreatedAtNs: null`
  and six empty strings — what every row the register wrote before v4 serves, and every
  v4 row until a probe answers.
* **Where an answer shows**: a probe on a `PENDING_APPROVAL` proposal shows on
  `view=latest` only, and `view=approved` gains it with the conclusion — the observed axis
  is content, like the proposal it measured. A probe on an `ACTIVE` or `SUSPENDED` row with
  nothing pending shows on both views at once. A later probe supersedes an earlier one
  whole (`probedAtNs` moves).
* **The register now refuses an activation, reactivation or credential rotation** whose
  answered probe (`probeOutcome: "NONE"`) shows the key scoped short of the row's declared
  capabilities (`SCOPE_INSUFFICIENT` — `canReadBalances` needs `read`, `canTrade` needs
  `trade`, `canExecuteTransfers` needs `withdraw`) or beyond them (`SCOPE_MISMATCH` — a
  Read-only pipe must not carry `trade` or `withdraw`). A reader sees such a row stay
  `PENDING_APPROVAL` with its `observedPermissions` explaining why, until a re-probe
  answers with the right scopes. An unprobed row and a failed probe approve as before.
* The document description states the set rule beside the enum rule; no existing property
  changes name, type, nullability or `required` membership.
* `info.version` moves from `114.v3` to `114.v4`.

**What each side of the deploy window sees**

* A client on `114.v3` ignores the eleven new properties; nothing it already parses
  changes.
* A gateway still serving `114.v3` renders a connection the register has already probed
  **without** the properties — so "absent = never probed" only holds once the serving
  gateway is on `114.v4`; from then on the row itself says so (`probedAtNs: null`,
  `probeOutcome: "UNKNOWN"`). Check `info.version` before concluding a key was never read.
* A gateway on `114.v4` serves the never-probed shape for every row the register wrote
  before v4 and every row not yet probed: the properties are present from the first v4
  response, with their defaults, and fill in as probes answer.
* The org-register pair restarts lockstep (its owner's snapshot version moves); roll the
  gateway after the register, so the properties appear as soon as the register can set
  them. Treat an unknown `probeOutcome` or `observedAllowlist` name as `UNKNOWN` and skip
  an unknown `observedPermissions` choice — every one of these sets is append-only.

### 114.v2 — 2026-09-07 — `amount_t` values serve as exact decimals off the asset's `unitScale`

A rendering change under the same schema: no message shape moves and `info.version` stays
`114.v2` — the `unscaled` marker is what tells the two sides apart.

**What changed**

* **`AssetPolicyUpdated.maxTicketAmount` / `approvalThresholdAmount` /
  `reconciliationToleranceAmount` and `ChainPolicyUpdated.minTransferAmount` /
  `maxTransferAmount`** are served as exact decimal strings scaled by the referenced
  asset's refdata `unitScale` — `"2.500000000000"` for a 2.5 BTC ticket cap at
  BTC's scale of 12 — where they were raw scaled-integer strings (`"2500000000000"`) under
  `"unscaled": true` before.
* **The `unscaled` marker narrows to the missing-scale fallback**, uniform across both
  money sources: a row carries it only while the projection does not hold a money field's
  scale source — the referenced asset not yet folded or its `unitScale` unstated, the
  referenced instrument not yet folded (the catch-up window, or an absent row). Absent,
  every money value on the row is an exact decimal. The collection-level marker keeps its
  coarse meaning (present while any item's row is flagged).
* The document description and `RowExtras.unscaled`'s description say so; no path,
  property, type or `required` set changes.

**What each side of the deploy window sees**

* A client that branches on `unscaled` (the contract said to) keeps working unchanged,
  because the marker's two meanings are what they were. On an **entity** response it says
  some money field on that row is a raw scaled integer. On a **collection** response it is
  coarse: at least one item's row would carry it, and the items themselves carry no
  per-row marker, so a client that must know which item is raw re-reads those entities.
  What changes is only where it appears: no longer on `amount_t` rows whose asset the
  gateway holds with a stated scale.
* A client that parsed `amount_t` values as raw integers **without** reading the marker
  was already off-contract; on the new gateway it reads `"2.500000000000"` where it read
  `"2500000000000"`. Read the marker, or parse every money field as a decimal string.
* A gateway still on the previous build serves `amount_t` raw under the marker whatever
  the refdata says; a gateway on this build serves decimals for every asset whose
  `AssetUpdated` fact states a `unitScale`. An asset stated before the refdata `unitScale`
  field existed decodes `unitScale` 0 (not stated) and its amounts stay raw under the
  marker until the refdata owner restates it — honest at every point of the window, and
  no lockstep restart: refdata and the gateway roll independently.

### 114.v2 — 2026-09-07 — a connection names the connector app group that serves it

**What changed**

* **`ConnectionUpdated` gains `appGroup`** (`string`, `x-since-schema-version: 2`): the
  connector app group serving this connection — one group per connection by construction.
  Empty means unassigned, and nothing serves it; that is the fail-safe, so orders on an
  unassigned pipe are refused rather than silently routed.
* `info.version` moves from `114.v1` to `114.v2`.

**Why its own version, and not part of `114.v1`**

`appGroup` first landed alongside `114.v1`'s `tradingConnectionId`, both marked
`x-since-schema-version: 1`. That is not a legal append: a `114.v1` encoder writes
`ConnectionUpdated` with **no** `appGroup` bytes, but a reader gating on
`actingVersion < sinceVersion` (`1 < 1`) reads a length prefix past the end of the message —
a buffer overrun on replay, or a garbage value. Two wire shapes cannot share one version
number. Every append takes the version that introduces it, which is what makes the
append-only guarantee hold at all.

**What each side of the deploy window sees**

* A client on `114.v1` ignores the new property; nothing it already parses changes.
* A gateway still serving `114.v1` renders a connection the register has already assigned
  **without** the property — so "absent = unassigned" only holds once the serving gateway is
  on `114.v2`. Check `info.version` before concluding a connection has no connector.
* The org-register pair restarts lockstep (its owner's snapshot version moves); roll the
  gateway after the register, so the property appears as soon as the register can set it.

### 114.v1 — 2026-09-02 — accounts designate their trading pipe

**What changed**

* **`AccountUpdated` gains `tradingConnectionId`** (`integer`/`int32`, nullable,
  `x-since-schema-version: 1`): the trading pipe this account's orders instruct through when
  set. Absent or `null` means orders route through the account's own `connectionId` — the
  default, and the only shape the contract had before.
* **`ConnectionUpdated.canTrade`'s description is reworded** to say that orders route through
  the account's trading pipe and that any number of trading connections may share one
  (org × venue). No shape change.
* `info.version` moves from `114.v0` to `114.v1`.

**What each side of the deploy window sees**

* A client on the old contract ignores the new property; nothing it already parses changes.
* A gateway still serving `114.v0` renders an account the register has already designated
  **without** the property — so "absent = own pipe" only holds once the serving gateway is on
  `114.v1`. Check `info.version` before inferring a route from absence. The org-register pair
  restarts lockstep for this change (its owner's snapshot version moves); roll the gateway
  after the register, so the property appears as soon as the register can set it.

### 114.v0 — 2026-09-01 — the initial contract

The nine register facts as read models;
no client-visible change was recorded before this file existed.

## Data

Consumer-facing changes to this API document, newest first. Each heading is the document's
version, and the document's `info.version` always equals the newest heading. Within a major
version, changes are additive only.

### 0.6.0 — 2026-10-12 — Instruments rank by USD turnover; the enum filters list their values

Additive: one new ticker field and sort, and value lists on parameters that took them in prose.

* **`ticker.turnoverUsd24h`** is `turnover24h` valued in USD at the quote asset's current USD
  rate: the last 24 hours of quote turnover at today's rate, an approximation. It is the one
  statistic comparable across quote assets. `volume24h` is in base units and `turnover24h` in
  quote units, so neither ranks `OKX@SOL/BTC` against `OKX@BTC/USDT`. It is `null` whenever
  `turnover24h` is, and when no USD rate is current for the quote asset. The name and the rule
  are the market-data stream's `tickerEvent`'s.
* **`sort=turnoverUsd24h`** (and `-turnoverUsd24h`) orders the instrument list by it, absent
  values last in both directions, as every statistic sorts. `-turnoverUsd24h` is the
  "biggest markets first" order for a list that mixes quote assets.
* **The enum filters list their values**, so a reference or a generated client offers them
  rather than a free string. `type` and `status` on `GET /instruments` and `type` on
  `GET /assets` are open: each item is `anyOf` the documented values and any string, because
  each filter also takes a value its rows carry beyond the list, so a value you were served
  stays valid. `include` on every list is a closed `enum`: anything else is `400`. Nothing about
  what is accepted changes. A generator that reads `anyOf` an enum and a string as an open enum
  offers the values and still sends another. The rows' own `type` and `status` stay open
  strings: those value sets only grow, so tolerate a value you don't know.
* **The facets say which filter each dimension ignores.** Each facet is counted under every
  filter but its own dimension's, as since 0.4.0, so with `type=SPOT` the `types` facet still
  counts every type the rest of the query matches. The descriptions now say so field by field.

**During the deploy window** a server on `0.5.0` answers `sort=turnoverUsd24h` with
`400 MALFORMED_REQUEST` and sends no `turnoverUsd24h` on the ticker, and one on `0.6.0` serves
both: retry such a `400` once the deploy is through. Everything else is the same on both.

### 0.5.0 — 2026-10-09 — The reference reads as the wire: property order and examples

No shape changes: every schema, parameter and response is as published in 0.4.0. Two things
change how the document reads.

* **Properties are listed in the order the service sends them**, where they were listed
  alphabetically. A page reads `items`, `nextCursor`, `prevCursor`, `total`, then `facets` on
  instruments, and `asOfSeqTsNs` last; a row opens with its id, then its symbol or code; a
  ticker with `lastPx`. JSON member order carries no meaning, so a client that reads members by
  name sees no difference. `required` lists keep their order.
* **Every operation's `200` carries an example**: the service's own answer to a small request
  over a fixed sample of the catalog, members in the order they are sent. The venue and asset
  lists are a first page of two with `include=total`; the instrument list is OKX's BTC markets
  with `include=total,facets,ticker`; the lookups by id answer for OKX, BTC and OKX's BTC/USDT
  perpetual. The values are real in shape, not live: the cursors in them are valid on no
  deployment.
* **Each property's own example agrees with them**: where a property shows a value in an
  operation's example, its schema's example is one of those values, and a decimal's is spelt
  plain, as the wire spells it (`"0.0002"`, where `makerFeeRate` and `takerFeeRate` read
  `"2.0E-4"` and `"5.0E-4"`).

### 0.4.0 — 2026-10-08 — The instrument list is complete: search, tickers and facets

`GET /instruments` now serves everything it published: `search`, `include=ticker` and
`include=facets` no longer answer `501 NOT_IMPLEMENTED`, and the operation no longer declares
that response. No shape changes: the parameters and the `Ticker`, `Facets` and `FacetCount`
schemas are as published since 0.2.0.

* **`search`** matches word prefixes: every word sent must start one of the instrument's own
  words, those of its symbol, of the venue's own symbol, of its type, its id and, for a dated
  future, its expiry (`jun`, `2027`, `25`, `jun27`). Anything but ASCII letters and digits
  separates words and is otherwise ignored, so `btc/usdt` is `btc usdt`, and a search with no
  ASCII letter or digit filters nothing. One character is enough, and every match is listed. It
  combines with every filter, sort and lookup by `symbol`, and a cursor is bound to it as its
  words.
* **`include=ticker`** renders each row's `ticker` from the same statistics the sorts read:
  exact decimal strings at display precision; `null` for a value the statistics cannot state
  and for a window still warming (`pctChange1h` until the one-hour window is warm, the five
  24-hour fields until the 24-hour window is), never `0`; `windowComplete` is the 24-hour
  window's flag; and `ticker` is `null` for an instrument that has never traded or whose last
  price cannot be stated.
* **`include=facets`** counts the matching instruments per venue, type, base asset and quote
  asset, each dimension under the request's filters minus that dimension's own (`venueId`,
  `venue` and `venueId.notIn` are the venue dimension's; `search`, `status` and `symbol` apply to
  every facet), so a filter panel keeps showing its alternatives. Each `value` is what the
  matching filter takes, with the display alias as `label` (`null` for types); values with no
  matches are omitted; within a dimension the values sort by count, most first, then by value,
  ids as numbers and type names as text. A lookup by `symbol` counts its own rows.

**During the deploy window** a server on `0.3.0` still answers `search`, `include=ticker` and
`include=facets` with `501 NOT_IMPLEMENTED`, and one on `0.4.0` serves them: retry a `501` once
the deploy is through. Everything else is the same on both.

### 0.3.0 — 2026-10-06 — The error envelope and headers match the rest of the REST API

The error envelope and the headers every exchange carries are now declared as the rest of the
Immix REST API declares them, so a client generated from either document reads them the same
way. One answer changes: a path no route serves has a code of its own.

* **`ROUTE_NOT_FOUND`** (404, not retryable) answers a path no route serves: a misspelt path,
  or an operation this version does not serve yet. It was `NOT_FOUND`, which now means one
  thing only: the id names nothing served here.
* **`Error.currentVersion`** is declared: an optional decimal string beside `error`, which a
  conditional write's `412 VERSION_CONFLICT` carries (the row's current version, as in its
  `ETag`). No operation of this API is a conditional write yet, so no response carries it.
* **`X-Request-Id`** is declared on every operation, as an optional request header and as a
  header of every response. The service already took and echoed it: send 8 to 128 of
  `[A-Za-z0-9._-]` to name a request yourself; a value off that grammar is replaced with a
  fresh id, never refused.
* **`X-Immix-As-Of-Seq-Ts`** is declared on the `200` of every read, which already sent it.
* **`retryable`** is described by the rule both REST APIs share: sending the identical request
  again, after `Retry-After` or a short pause, can succeed with nothing else changed. No code's
  value changes.

In the document, every operation gains the parameter `RequestId`, every response the header
`RequestId`, and every read's `200` the header `AsOfSeqTs`. A regenerated client gains one
optional argument per operation and two response headers it can read, and `Error` gains an
optional `currentVersion`.

**During the deploy window** a server on `0.2.0` answers a path no route serves `NOT_FOUND`,
and one on `0.3.0` answers it `ROUTE_NOT_FOUND`, both with status 404: treat a code you do not
know by its status. Everything else is the same on both.

### 0.2.0 — 2026-09-25

Contract stubs, published ahead of their implementations. Every operation added here is
published with its full shape and answers `501 NOT_IMPLEMENTED` until its implementation ships;
nothing already published changes.

* `GET /instruments` (stubbed, pending): the instrument catalogue with `search` (word prefixes
  of the symbol, the venue's own symbol, the type and the id), lookup by `symbol` (at most 100
  per call), the filters `venueId`, `venueId.notIn`, `venue`, `type`, `status`, `baseAssetId`,
  `baseAsset`, `quoteAssetId` and `quoteAsset`, sorts by `symbol`, `instrumentId`, `type`,
  `listedAtNs`, `expiresAtNs` and the 24h statistics, and `include=total,facets,ticker`.
* `GET /instruments/{instrumentId}` (stubbed, pending).
* `GET /assets` and `GET /assets/{assetId}` (stubbed, pending): `search` as a substring of the
  symbol, the filters `symbol` and `type`, sorts by `symbol`, `assetId` and `type`,
  `include=total`.
* New schemas: `Instrument`, `InstrumentPage`, `Ticker`, `Facets`, `FacetCount`, `Asset`,
  `AssetPage`.
* The error code `NOT_IMPLEMENTED` (501, not retryable): a published operation whose
  implementation has not shipped.
* Conventions, in the guide: the per-resource `search` rule (word prefixes for instruments, a
  substring for venues and assets), the `.notIn` filter suffix, `include=facets` and
  `include=ticker`, and the symbol grammar.
* Amended 27 Sep 2026, before any `v0.2.0` bundle existed: the `type` sort on both lists,
  `search` over the venue's own symbol and the id, and clearer field descriptions.
* Amended 28 Sep 2026: `GET /instruments/{instrumentId}` is live, and no longer answers `501`. A
  removed instrument is served with its last `status`; another environment's instrument, and any
  id that is not exactly an instrument's (`0300`, `+300`), is `404`. (The `v0.1.0` release bundle
  carries this document as it stood on 28 Sep, with every phase-1 operation stubbed.)
* Amended 29 Sep 2026: `GET /assets` and `GET /assets/{assetId}` are live, and no longer answer
  `501`. A removed asset is absent from the list and `404` by id, as is any id that is not exactly
  an asset's (`0201`, `+201`). `symbol` sorts ignore case. `type` takes the eight documented
  classes and any other class an asset carries, a removed asset's included; a value that is
  neither is `400 MALFORMED_REQUEST`, which the guide now states for every enum filter. (The
  `v0.2.0-rc.1` release bundle carries this document as it stood on 28 Sep, with both asset
  operations stubbed.)
* Amended 1 Oct 2026: `GET /instruments` serves lookups by `symbol`, which no longer answer
  `501`: at most 100 symbols a request, matched case-insensitively against each instrument's
  canonical symbol, sorted by `symbol` (the default, ignoring case), `instrumentId`, `type`,
  `listedAtNs` or `expiresAtNs`, with `include=total`. A symbol that names nothing served here is
  absent from the page, a legacy `YYYYMMDD` maturity (`…-20270625`) included; more than 100
  symbols, a repeat counted, is `400 MALFORMED_REQUEST`, naming the limit. A cursor is bound to
  the symbols as a set, whatever their order or case. The list without `symbol`, every other
  filter, the statistics sorts and `include=facets,ticker` still answer `501`, as the `501`
  response now describes. On every list, an "any of" parameter may be repeated and
  comma-separated at once.
* Amended 1 Oct 2026, the list: `GET /instruments` serves it, and only `search`,
  `include=facets` and `include=ticker` still answer `501`. Every filter narrows it: `venueId`,
  `venueId.notIn`, `venue` (a venue code), `type`, `status` (`SUSPENDED` among them),
  `baseAssetId`, `baseAsset`, `quoteAssetId` and `quoteAsset` (asset symbols), and `symbol`, which
  combines with every other filter and sort. Codes and symbols match whatever their case, and one
  that names nothing matches nothing, as does an id that is not exactly one (`0300`); a `type` or
  `status` neither documented nor carried by an instrument is `400 MALFORMED_REQUEST`. Every sort is
  served: the statistics sorts (`lastPx`, `pctChange24h`, `volume24h`, `turnover24h`) read the
  statistics `include=ticker` will show, with absent values last in both directions: a 24-hour
  statistic is absent while its window warms, as is any the projection cannot state.
  `include=total` is exact. A cursor is bound to the
  filters and the sort as normalized, so the lookups' cursors stay valid. `asOfSeqTsNs` and
  `X-Immix-As-Of-Seq-Ts` report the older of the catalogue's and the statistics' positions. The
  `type` parameter lists every documented type, `QUANTO_FUTURES` and `QUANTO_PERPETUAL_SWAP`
  included.

### 0.1.0 — 2026-09-24

* `GET /venues` and `GET /venues/{venueId}`: the trading venues IMMIX connects to.
* The list envelope shared by every collection: `items`, `nextCursor`, `prevCursor`, `total`
  (with `include=total`) and `asOfSeqTsNs`.
* The error envelope shared by every response:
  `{"error": {"code", "message", "retryable", "requestId"}}`.