> 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

Consumer-facing history of the trading gateway's HTTP contract
([openapi.json](/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](/guides/rest-api-guide#versioning). Every section from
there on is headed by the version it lands (`## <version> — …`), 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`). The sections below `0.1.0` are headed by the register schema's
version (`114.v6`, …) — which `info.version` carried until then — or by what changed where
the schema did not move.

## 0.5.1 — 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](/guides/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 — 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 — 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 — 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 — 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](/guides/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 — `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](/guides/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 — 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](/guides/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 — 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](/guides/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 — 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](/guides/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 — 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](/guides/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 — 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 — 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](/guides/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](/guides/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 — 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 — 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**](/guides/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 — 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 derive 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 — 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 — 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 — 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 — 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 — 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 — 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 — 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 — 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 — 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 — `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 — 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 — 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 — the initial contract

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