> 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.

# Market Data WebSocket changelog

Consumer-facing history of the client wire
([asyncapi.yaml](/api-reference/market-data/overview) is the
contract; 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.

## The composite channels — Binance spot rejoins: its connector states each value's minimal scale

**What changed**

* **Nothing in the gateway, its schema or its frames.** What moved is upstream, in
  binance-md-connector, which no longer states the eight places Binance renders on every
  surface as the instrument's precision. Every price and size is now read at its *minimal*
  scale — the trailing zeros stripped, `64887.68000000` read as `64887.68` at 2 — and
  stated, as every moved connector states a value, at its channel's working scale, which
  therefore settles at the instrument's grid instead of at eight: a BTCUSDT book is stated
  at one pair, 2/5 (its 0.01 tick and 0.00001 step), instead of 8/8, inside the record's
  4/7, and an ETHUSDT book at 2/4 inside its 4/6. The rule is the venue's, chosen because
  Binance's width carries no information about the instrument: a venue that pads *to* its
  grid (Kraken, Bybit) is unchanged.
* **Every Binance spot book enters the merge.** A composite holding a Binance spot source
  beside others stops naming it `precision` and merges its levels, and one built on Binance
  spot alone serves. The rejoin is the one the re-cut's section below (*a `precision`
  exclusion reason*) defines:
  once the source's next snapshot is stated within the topic's output scales it is named in
  a `recovered` frame — whether it had left the merge or, as every Binance spot source
  since its move, had never entered it — and appears in `src` from the next full image. A
  topic on Binance spot alone that acked `syncing` and never served says `recovered`, then
  serves.
* **`precision` no longer stands for padding.** A source is told `precision` because its
  book is stated finer than the topic's output scales, and the two ways that happens are
  the ones already in this file: the venue outgrew its reference record (the *`precision`
  is reachable* section), or — after a record *narrows* — a running connector keeps stating
  the finer scale it had learned until it restarts (the transient cause the section directly
  below names). Never any more because a venue renders more digits than it trades.
* **The per-instrument channels are unchanged.** They render shortest form and always
  did, so `64887.68000000` read as `64887.68` before this release and reads the same after;
  no digit a client saw changes. What a client can notice: `agg_book` levels attributed to
  a Binance spot source appear where before the source was absent; and on `book`, raw or
  conflated, a Binance spot book's restatements now come from real precision moves rather
  than never — over a connector's first frames after a restart a *book* channel can widen
  as it climbs to the grid, and each book-channel widen is one more `snapshot: true` frame,
  the form the contract has always used. Expect none in the common case: the venue's
  capture widened twice, both on a trade channel, which emits no `book` frame.
* **`rescaled` is unchanged by this**, and no reference record moves: the alternative —
  widening the record to the venue's rendering once a record's pair can move — was not
  taken, so no composite holding a Binance spot source is rescaled to eight places.

**Deploy window**: nothing in the gateway deploys for this section; binance-md-connector
does, and its restart is the window. A gateway beside the connector **before** its deploy
is the *`precision` is reachable* section's world for this venue: every Binance spot book out
of every composite as `precision`, a composite on Binance spot alone `syncing` and never
serving. **After**:
the connector's next snapshot of each book is stated at the minimal pair, and every
composite holding that source says `recovered` — or serves for the first time — and merges
it at its next tick. Nothing a client must do: one that already treats `precision` as a
standing word and parses decimal strings without assuming a digit count sees a source
appear, and that is all. The gateway has no "before" of its own here, for the reason the
*`precision` is reachable* section gives.

## The composite channels — `rescaled` becomes ordinary: a source's scales can now change

**What changed**

* **Nothing in the gateway, its schema or its frames** — the re-cut's section below taught
  the two words and they are unchanged. What changes is upstream, and it changes *when* a
  client hears one of them. Until now the reference-data owner froze each instrument's
  scale pair at first listing and refused any catalog row that needed another, so a
  composite's output scales — derived from its sources' records — could only move in an
  incident. From this release the owner derives the pair from the catalog row at every
  poll and publishes a changed pair as the record's next version. **A topic's output
  `priceScale` / `qtyScale` can therefore move under a live subscription in normal
  operation, and `status: "rescaled"` is how the client is told** — one frame per affected
  topic per change, before the first frame rendered at the new pair, exactly as the
  re-cut's section below specifies. Never per tick.
* **Both directions happen.** The scales **widen** when a source venue starts quoting finer
  than the catalog had registered and the catalog catches up. They can **narrow** when a
  venue coarsens its price or size grid, or when a first registration that was too fine is
  corrected. A client must not assume a `rescaled` pair is at or above the one it replaces.
* **A narrowing can leave a `vol_book` ladder behind — and a re-subscribe can then answer
  `bad_ladder`.** `bucketSizes` are held at the topic's quantity scale, so a rescale restates
  them exactly or not at all. A widening always can. A narrowing cannot when a bucket size
  uses a decimal the narrower scale drops (`"2.55"` at a quantity scale going from 2 to 1):
  that topic keeps the pair it advertised — still exact, and no `rescaled` is sent for it —
  but the same config sent again later (a reconnect re-subscribes with the full config) is
  judged against the *current* scale and refused `bad_ladder`, naming the scale. Send bucket
  sizes no finer than the instrument's size increment and neither can happen.
* **Expect a burst once, then rarity.** Be ready for `rescaled` on many topics at the first
  catalog poll after this release deploys, and for it to recur, rarely, over a
  subscription's life — it is not a once-in-a-listing event. The burst: every instrument
  whose pair had been held at a stale first sighting corrects at once, and each affected
  topic — one whose output pair moves with it — says `rescaled` then; a source's pair can
  change under an output pair another source still sets, and then nothing is said. The
  rarity: after that a topic's scales move only when a source's catalog row stops fitting
  the scales its record holds — a venue making its grid finer than the record's headroom
  absorbs, or a registration being corrected — while a one-decimal tick move is absorbed
  and says nothing (a week of published catalog history, 2026-09-20, held no other kind of
  move).
* **`reason: "precision"` is live already, and gains one transient cause.** The re-cut's
  section below said `precision` could not be sent until a later release let a connector
  state a venue's own digits; the section directly below is that release, and the
  connectors moved to it venue by venue — every market-data connector states the venue's
  own digits now, and that section names them.
  For a moved connector `precision` means what the re-cut's section says: the
  source's book is stated finer than the topic's output scales, which derive from the
  reference records. Two things clear it. The record widens — which, from this release,
  the owner does at the next catalog poll that shows the finer precision, nobody acting —
  or the venue's rendering changes. (A venue that padded its rendering past its own grid —
  Binance spot, eight places on every value above its record — stayed `precision` on
  either side of this until its connector stripped the padding: the section above, which
  lands in the same release.) **A narrowing can put a
  source out until its connector restarts.** A stated scale never narrows within a
  connector's process — the rule the section below states — so after a record **narrows**,
  a connector whose channel had learned digits above the new pair keeps stating them,
  every value still exact. If that leaves the source above a topic's output scales, it is
  announced out of the merge by an ordinary `degraded` frame with `reason: "precision"`,
  and rejoins through an ordinary `recovered` frame when the connector restarts and its
  channels learn the venue's digits afresh. Nothing is mis-rendered in between: a level is
  never rounded into a merge. It takes a venue coarsening by more than the record's
  headroom, so expect it rarely.

**Deploy window**: the gateway and the reference-data owner deploy independently, and only
the owner changes here. A gateway carrying the re-cut's section below, beside an owner
**before** this release: `rescaled` only in an incident, as that section says; `precision`
already, from the connectors that state a venue's own digits — and *standing* wherever a
venue has outgrown a record the owner still refuses to widen. The
same gateway beside an owner **at** this release: `rescaled` on each affected topic
whenever a source's pair change moves its output pair; `precision` clearing within a poll
of the catalog showing the finer precision; and the transient cause above. There is no
third combination — a gateway older than the
re-cut's section below cannot read the re-cut market-data schema this owner's estate runs
on. What a client must do is what that section already asked: parse decimal strings rather
than assuming a digit count; if it formats to the ack's `priceScale` / `qtyScale`, take
the new pair from the `rescaled` frame — in either direction; treat an unknown `status` as
ignorable and an unknown `reason` as `stale`.

## The composite channels — `precision` is reachable: the connectors state the venue's own digits

**What changed**

* **Nothing in the gateway, its schema or its frames.** No field, enum member or frame
  shape moves, and this section adds no word the section below did not teach. What moved
  is upstream, in the connectors, and it makes one sentence of that section true sooner
  than it said: `reason: "precision"` "cannot be sent until a later release lets a
  connector state a venue's own digits". This is that release — it carries the section
  below's re-cut and the connectors' move together, so a client meets both at once.
* **Which sources can now be announced `precision`, and why.** A moved connector states
  every price and size at the digits the venue renders on that channel — a working scale
  learned from the wire — where before it stated the instrument's reference-record pair
  on every value. A composite's output scales are still derived from its sources' records
  (the `priceScale` / `qtyScale` the ack echoed), and each source is judged on the pair its
  book was last stated at. A record's pair is derived with headroom — at least two places
  past the instrument's tick and past its step — so a venue that renders inside that
  headroom enters the merge as it always did, and one that renders **past** it is out as
  `precision`. One kind of venue does: one that outgrew its record — it re-gridded an
  instrument finer than the record's headroom absorbs. (A venue that padded its rendering
  past its own grid, Binance spot, did too between its move and the section at the top of
  this file, which strips the padding at the connector.) The sources this can be said of are
  the moved venues' books — every market-data venue's, since this release moves them all:
  **Kraken, Coinbase, Gate.io, Bybit, Binance futures, Binance spot, OKX, KuCoin futures and
  MEXC**. KuCoin spot moved too but publishes no book yet, so none of its instruments is a
  composite source.
* **How it clears.** As the section below says, the source rejoins through an ordinary
  `recovered` frame once its book is within the topic's scales again, and two things get
  it there. **The record widens**: the catalog restates the instrument's pair, the gateway
  rebinds every composite holding that source, says `rescaled` with the new pair wherever
  the output scales moved, and the source rejoins. Today that widening is an operator's
  act — the reference-data owner refuses a changed pair in normal operation, so a record
  moves only when the instrument is re-listed, the incident shape the section below names
  — and a `precision` source waits on it. The release that makes the pair a versioned
  attribute turns the wait into a poll: the owner derives the pair from the catalog row at
  every poll and publishes the wider pair as the record's next version, so the source
  rejoins within a poll of the catalog showing the finer precision, nobody acting — that
  release's own section says what else it changes.
  **The venue's rendering changes**: a stated scale never narrows within a connector's
  process — a venue that coarsens its digits, or stops padding, keeps being stated at the
  finer pair, exactly, with trailing zeros — so a coarser rendering reaches the gateway at
  that connector's next restart, when its channels learn the venue's digits afresh and the
  source's next snapshot states the coarser pair.
* **Binance spot stood excluded from its move until the section at the top of this
  file.** Binance spot renders eight places on every surface whatever the instrument's
  grid — BTCUSDT trades on a 0.01 tick and a 0.00001 step, and every price and size still
  reads `64887.68000000` and `0.00200000` — and its moved connector stated 8/8 on
  every channel while the record derives 4/7, so every Binance spot book was out of every
  composite as `precision` from its first tick, and a composite on Binance spot alone
  never served. The fix is at the connector, in the same release as this section — the
  section at the top of this file: the connector states each value's minimal scale, its
  books settle at the grid (BTCUSDT 2/5) and enter the merge with the topic's scales where
  they are, so no client meets the exclusion.
* **The per-instrument channels are unchanged in shape and, nearly everywhere, in content.**
  `trade`, `tob`, `book`, `mark`, `oi`, `candlestick` and `ticker` advertise no scale and
  render shortest form, so a value the venue pads reads as it did — Binance spot's
  `64887.68000000` was `64887.68` before the move and is after — and a level's `src`
  attribution on `agg_book`, rendered from the venue's stated pair, is the same string at
  any stated scale. Two things a client can notice. A value the record's grid could not hold
  was quarantined by the unmoved connector — never published — and a moved connector
  publishes it exactly, so on a venue that outgrew its record a client sees prices or sizes
  at more decimals than the record's pair where before it saw nothing. And when a moved
  connector's book channel learns a finer digit than it had seen, it restates the held book
  at the finer pair before deltas resume: on `book`, raw or conflated, that is one more
  `snapshot: true` frame — the form the contract has always used for a restatement —
  carrying the same digits. On a shortest-form venue (Coinbase, Gate.io) expect those over a
  connector's first frames after a restart, then rarely.
* **`rescaled` is unchanged by this**: still only the reference-data incident the section
  below names, until the release that lets a record's pair move.

**Deploy window**: nothing in the gateway deploys for this section; the connectors do, one
venue at a time, and each is its own window. A gateway carrying the section below beside a
connector **before** its move is that section's world for the venue: the connector states
the record's pair, so the venue's sources sit at or below the output scales by construction,
`precision` is unreachable in normal operation, and a value off the record's grid does not
reach the wire. The same gateway beside a connector **after** its move: that venue's books,
where it publishes one (KuCoin spot does not yet), can be told `precision` under the rule
above, and the per-instrument channels
carry every value the venue renders, exactly. The gateway has no "before" of its own here: a
gateway older than the section below cannot read the re-cut market-data schema the moved
connectors publish on (a clean break under a schema floor, taken with an environment reset),
so it never runs beside one. What a client must do is what the section below already asked —
treat an unknown `reason` as `stale`, and parse decimal strings rather than assuming a digit
count — plus one thing new with this section: expect `precision` to be a standing word, not
a transient one. A source told `precision` waits on the catalog or on the venue, not on a
restate interval.

## The composite channels — a `precision` exclusion reason, a `rescaled` status, and one rule for `ts`

**What changed**

* **Two new enum members on `agg_book` and `vol_book` status frames.** The exclusion
  `reason` vocabulary gains **`precision`**, and the `status` vocabulary gains
  **`rescaled`**. Both enums were documented closed ("complete so consumers bind once"), so
  a client that generated an exhaustive enum from the spec must regenerate — or, better,
  treat an unknown `reason` as it treats `stale` and ignore an unknown `status`, which was
  always the safe reading.
* **`reason: "precision"`** rides an ordinary `degraded` frame: the named source's book is
  stated at more decimal places than the topic's output scales (the `priceScale` /
  `qtyScale` the subscribe ack echoed) hold, so its levels cannot enter the merge without
  dropping digits, and a level is never rounded into a merge. It is judged on the scale, not
  on the digits the book happens to hold, so a source does not flap in and out with its
  content. The source rejoins through an ordinary `recovered` frame once its book is within
  the topic's scales again: the topic was rescaled wide enough, or the venue's next snapshot
  states a coarser scale. A rescale that widens only part of the way leaves it out.
* **`precision` is the one exclusion announced for a source that never entered the merge.**
  A source that is merely not synced yet is covered by the ack's `syncing` promise and joins
  silently, as it always has. A `precision` source is different — its book is servable, and
  it stays out until the catalog or the venue moves — so its `degraded` frame is sent once
  whether the source left the merge or was never in it. A composite whose only source is
  born that way answers `syncing` and then, at its first tick:

  ```json
  {"event":"agg_book","topic":"agg_book.BTC-X.100ms","status":"degraded",
   "seq":"4242","seqTs":"1700000000000000000",
   "excluded":[{"s":"OKX@BTC/USDT","reason":"precision"}]}
  ```

  No `ts` — nothing is included to timestamp — and no `stale` marker after it, because the
  topic has not served yet and `syncing` still stands. A topic that *was* serving and loses
  its last source to `precision` gets the same frame first (naming every source that left
  with it) and then the `stale` marker, so the silence that follows has a stated cause.
* **`precision` is also the one reason said over another.** A source already announced
  `stale` or `fx_expired` whose exclusion *turns into* `precision` — its book comes back
  from a resync, or its rate returns, and the book is finer than the topic — is named again
  in a `degraded` frame carrying the new reason. The first word promised a heal within a
  restate interval or a rate tick; this one waits on the catalog, and a client should not be
  left waiting on the wrong one. One `recovered` answers both. No other change of reason is
  re-announced: a `precision` source whose chain breaks and heals stays told `precision`.
* **`status: "rescaled"`** is a new status frame with two new integer fields, `priceScale`
  and `qtyScale` — the ack's own field names, carrying the topic's **new** output scales:

  ```json
  {"event":"agg_book","topic":"agg_book.BTC-X.100ms","status":"rescaled",
   "seq":"4242","seqTs":"1700000000000000000","priceScale":3,"qtyScale":2}
  ```

  The output scales are derived from the sources' reference records. When a source's record
  restates its scale pair — a venue starts quoting finer than the catalog had registered,
  and the catalog catches up — the gateway rebinds every composite holding that source, and
  because the client sent no request for an ack to answer, the frame says so. It is emitted
  once, at the tier's tick, **before** the first frame rendered at the new pair (so before
  any `recovered` frame the rebind causes, and before the image). It carries no `ts`: it is
  a fact about the subscription, not about any source's book. A rebind that leaves the
  output scales where they were says nothing, and so do two that land back on the pair the
  client was last told.
* **`ts` is written by one rule on every composite frame.** It is the newest included
  source's exchange timestamp: present when an included source carries one, **absent**
  otherwise — never `"0"`. Images and the `stale` marker already behaved this way;
  `degraded` and `recovered` wrote `"ts":"0"` when no included source carried a timestamp,
  and now omit the field like the other forms. The schema never required `ts`.
* **Nothing else moves.** Image frames, the ack, `degraded` / `recovered` / `stale`, every
  field name and type are unchanged, and every number on the topic is still a canonical
  shortest-form decimal string: a client that parses decimals — rather than assuming a digit
  count — sees the same values before and after a rescale. For `vol_book`, the ladder's
  `bucketSizes` are restated at the new quantity scale, so the buckets a client subscribed
  to are the buckets it keeps receiving. An identical re-subscribe after a rescale is
  answered with the new pair. **The per-instrument channels are untouched**: the release
  that carries this section re-cuts the sequenced market-data schema underneath the gateway
  (every price and quantity now states its own scale), but `trade`, `tob`, `book`, `mark`,
  `oi`, `candlestick` and `ticker` advertise no scale and render shortest form, so every
  string on them is byte-identical before and after.

**Deploy window**: a gateway before the upgrade never sends either word, so a client built
to this section meets nothing new against it and must simply not *wait* for the words. A
gateway after the upgrade can send them to a client that has not learned them, but only
when the data gives it cause, and through this release the data does not: every connector
still states its reference record's scale pair, which sits at or below every composite's
output scales by construction, so **`precision` cannot be sent until a later release lets a
connector state a venue's own digits** finer than its record; and **`rescaled` needs a
reference record whose pair changed**, which the catalog refuses in normal operation until
the release that makes a pair an attribute — the *`rescaled` becomes ordinary* section of
this file, which also gives `precision` its one transient cause; the connectors that now
state a venue's own digits are named in the section directly above. The one way either
happens earlier is an incident — a reference-data rebuild that re-lists an instrument at
other scales
— and there the frames are the better outcome: before this release the composite stayed
bound to the pair the catalog had moved past and said nothing. That is the window to teach a
client the two words. A client that has not learned them by then sees, at worst, a
`degraded` frame whose
`reason` it does not recognise (treat as `stale`: the source is out of the merge, and the
image's `src` says which sources are in) and a status frame whose `status` it does not
recognise (safe to ignore if it parses decimal strings; a client that fixed its formatting
to the ack's `priceScale` / `qtyScale` must take the new pair from this frame). The `ts`
correction is visible on both sides at once: an old gateway may send `"ts":"0"` on a
`degraded` or `recovered` frame, a new one omits the field — treat `ts` as optional on every
composite frame, which is what the schema has always said.

## The ticker channel — `turnover*` is the contract-aware quote notional

**What changed**

* **`turnover1h` / `turnover24h` now carry the quote notional, not the bare
  `price × quantity` sum.** For spot instruments nothing moves: the sum IS the quote
  notional. For contract-sized instruments the numbers change visibly: a linear contract
  (`FUTURES`, `PERPETUAL_SWAP`, `QUANTO_*`) is the sum times the instrument's
  `contractMultiplier` (rounded half-even at `priceScale + qtyScale`, so the string keeps
  its scale — a 0.01-multiplier perpetual's turnover is a hundredth of what it read
  before, which is what it was worth), and an inverse contract is `volume ×
  contractValue`.
* **Null now also means "not valued"**, beside the warming window it already meant: an
  instrument whose shape the rule cannot value — a linear contract with no stated
  multiplier, an inverse contract with no stated contract value (every inverse row
  today: reference data does not publish the field yet), an option, an index — renders
  `null`, never `0` and never the raw sum. The AsyncAPI description states the rule.
* No field, type or frame-shape change: both fields stay `[string, "null"]` at the same
  scale; every other `ticker` field is untouched.

**Deploy window**: before the upgrade a contract-sized instrument's `turnover*` read the
raw contract-count sum (a mismatched unit — the correction this section lands); after, it
reads the quote notional or `null`. A client that already treated `null` as "no value"
needs no change; one that summed turnover across instruments of mixed shapes was summing
mismatched units before and gets comparable quote amounts after. The `candlestick`
channel carries no turnover field and is unaffected.

## The ticker channel — trailing stats that decay on the stream frontier

**What's new**

* **The `ticker` channel is served**: `ticker.SYMBOL[.tier]` — last price plus derived
  trailing 1h/24h statistics off the rolling fold, re-rendered on trades (conflated per
  tier) **and** once per elapsed stream minute — the decay edge for quiet instruments;
  an actively trading topic simply receives one extra image at the minute edge. `asOf`
  (the consumed event-time frontier at render) advances on decay re-renders while
  `seq`/`seqTs` (fact provenance, the last trade folded) stay unchanged: `asOf` moving
  alone IS the decay tell.
* **Per-window cold-start completeness rides nullability**: a window's derived fields
  are null until that window's history exists post-cold-start (between +1h and +24h the
  1h numbers stand while the 24h ones are null); `windowComplete` is the 24h window's
  flag; null never renders as 0, and a warm window with no trades is flat (zero sums,
  ratio `"0"`, extremes carried), not null. `pctChange*` are ratios (not percent) at 4
  decimals; `turnover*` are exact decimal strings at `priceScale + qtyScale` (sums never
  pass through floating point).

**Deploy window**: before this deploy, `ticker.*` topics answer per-topic errors —
probe-and-fall-back works as for the candlestick channel's section below.

## The candlestick channel — closes on the stream frontier, and the object topic form

**What's new**

* **The `candlestick` channel is served**: `candlestick.SYMBOL.INTERVAL[.tier]`, the
  interval segment mandatory from `components.schemas.candleInterval` (the 8-interval
  set shared with the candle-history surface). Subscribing answers `live` and pushes one
  `snapshot: true` frame — the last two closed bars plus the forming bar, oldest to
  newest; a never-traded instrument answers `bars: []` with `seq`/`seqTs` omitted, so
  "no data exists" is distinguishable from "snapshot lost". The raw lane streams a
  forming-bar partial per folded trade; tiers serve the latest forming bar per tick.
  **Closes never conflate**: every elapsed interval closes — zero-trade intervals as a
  carried close (OHLC = previous close, volume `"0"`, count 0, provenance inherited) —
  released once the stream's consumed event-time frontier passes the bucket boundary,
  never wall clock: under catch-up closes arrive late, never wrong. After `closed: true`
  for a bucket no further update frame for that bucket arrives, and the close precedes
  any frame of the next bucket. Around the subscribe itself, snapshot and topic
  broadcasts may overlap — re-applying a final keyed on the bucket timestamp is a
  no-op, never desync. `seq`/`seqTs` are fact provenance (repeats legal, gaps
  meaningless): apply keyed on the bucket timestamp.
* **Subscribe requests accept the object topic form**
  (`components.schemas.topicSelector`): `{channel, instrument, interval, throttle}`,
  exactly those keys, string-valued, normalized and echoed as the dotted string — one
  ack shape with the string form. An unknown key or a non-string value (e.g. a numeric
  throttle) answers op `error`/`malformed` rather than silently arming different
  semantics. The required top-level `instrument` key is what distinguishes a selector
  from a composite entry (`aggBookEntry`/`volBookEntry` carry `name`/`tier`/`sources`
  and never a top-level `instrument` — an object with one always parses as a selector).

**Deploy window**: the old gateway answers every `candlestick.*` topic with a per-topic
error, and mis-collects an object entry's inner strings as individual topic strings,
answering the subscribe ack with per-topic errors echoing those fragments (never
malformed). A probing client should treat an ack whose per-topic errors echo its
object's field names/values as the old deploy and fall back to dotted strings. Bare
literals (numbers, `true`, `null`) in a topics array are skipped without an answer on
both sides of the window, unchanged.

## Composite sources — a contract multiplier of exactly 1 is no longer a unit mismatch

**What changed**

* **`agg_book`/`vol_book` sources whose `contractMultiplier` is exactly `1` are now
  accepted.** The rule was always about *units*: a source is refused when its quantities
  do not speak the base asset. A multiplier of one says one contract IS one unit of the
  base asset, so those quantities already are base units and nothing is being mixed. The
  check previously refused every stated multiplier, one included.
* **Everything else still answers `unit_mismatch`**, unchanged: any multiplier other than
  one — `0.01`, `100`, whatever the venue states — is a genuine unit change, and
  conversion through the multiplier remains deferred.
* No code, field, or frame shape changes: `unit_mismatch` stays in the error enum with the
  same meaning and the same `message` text, and an admitted source serves exactly like any
  other (scales echo, margins and MID normalisation apply, images render identically).

**Who this unblocks**: reference data states `1` on rows other venues leave unstated —
every OKX spot instrument carries it, as do linear perpetuals on several venues. Those
instruments could not be used as a composite source at all; now they can.

**Deploy window**: before the upgrade, a source with a stated multiplier of `1` answers
`unit_mismatch` per topic and nothing arms; after, it validates and serves. Sources with a
multiplier of one are the only behaviour that moves — `0` (unstated) sources and genuinely
contract-sized sources behave identically on both sides, so a client whose subscriptions
were succeeding before sees no change. A client that retried on `unit_mismatch` should
expect those topics to start serving; one that treats `unit_mismatch` as permanent needs no
change beyond re-subscribing to pick the instrument up.

## Composite books, phase D — `vol_book` volume ladders

**What's new**

* **The `vol_book` channel is served.** Subscribe with an object entry
  (`channel: "vol_book"`) carrying the same source vocabulary as `agg_book`
  (margins and MID normalisation included) plus the ladder: `pricingModel`
  (`VWAP` | `BEST` | `WORST`), `bucketSizes` (decimal strings at the resolved output
  `qtyScale` — positive, distinct by value, **any order**: the server sorts, the
  response ascends, and the optional positional `bidAdjBps`/`askAdjBps` pair with the
  sizes **as submitted**). Identity, `replace`, budgets, status frames, and reconnect
  behave exactly as `agg_book`; the session composite budget counts both channels
  together.
* **Frames are full ladder images** (`event: "vol_book"`): ascending buckets, each
  present side an object of `cum` (the achieved cumulative quantity — the threshold
  when filled, the side's whole depth when it exhausts, with `filled: false` and every
  deeper bucket sharing that tail), `px` (the model price), `adjPx` (with the bucket's
  directional margin — bid down, ask up), and **`notional` — the exact swept cost at
  `priceScale + qtyScale`. It can exceed a 64-bit integer: parse it as the
  arbitrary-precision decimal string it is.** A side with no levels is absent
  (one-sided markets publish); `spreadBps` rides every bucket both sides price —
  partial fills included and the adjusted mid nonzero (a maximal bid margin can floor
  a sub-tick-scale price to an adjusted zero; the field is omitted rather than divided
  by zero) — over the **adjusted mid** at a fixed two-decimal scale, negative when
  independent venues cross. VWAP rounds to nearest with ties away from zero (an
  estimate, not a quote); `BEST`/`WORST` quote actual levels.
* **`bad_ladder` is answered.** Present-but-wrong ladder values (an unknown model, a
  non-positive/duplicate/finer-than-scale size, more than `composites.max.buckets`
  buckets, an adj array that does not pair) answer `bad_ladder` per topic; an absent
  required field (`pricingModel`, `bucketSizes`) answers `malformed`, as does
  `maxDepth` on a `vol_book` entry (it is `agg_book` vocabulary — ladders walk as
  deep as their buckets need) or a ladder field on an `agg_book` entry.

**Deploy window**: before the upgrade, any `vol_book` entry answers
`unknown_channel` per topic (nothing arms); after, it validates and serves.
`bad_ladder` was already in the documented code enum (bind-once). One `agg_book`
edge tightens: a ladder field attached to an `agg_book` entry was silently ignored
before (the fields did not exist) and now answers `malformed` — a schema-valid frame
is unaffected, and everything else behaves identically on both sides.

## Composite books, phase C — MID normalisation

**What's new**

* **`normalisationMode: "MID"` is served.** A MID source (with the now-meaningful
  `normalisedAsset`) has its prices normalised through the internal index: the current
  (source-quote → target) rate multiplies each price **before margins**, rounding
  directionally (bids down, asks up — an adjustment never flatters a price). Rates are
  read fresh at every render, and a rate refresh itself marks the composite dirty
  exactly like a source-book tick — a standing image re-renders on the next tier tick
  when its rate moves, even while every source book is quiet.
* **`fx_expired` is emitted.** A MID source whose pair has no current rate (absent, or
  older than the index's 30 s consumer window) leaves the merge and the topic says so —
  `status: "degraded"` with `reason: "fx_expired"`; a returning rate announces
  `recovered`. Expiry is watched on the gateway's health cadence (1 s by default), so
  the announcement lands within about a second even while every source book is quiet.
* **`live` now also requires FX**: a composite acks `live` only when every enabled
  source book is synced *and* every required pair is current; otherwise `syncing`, kept
  by the first computable tick.
* Validation: `normalisedAsset` is required with MID and must resolve to a known asset
  that differs from the composite's base asset and from the source's own quote asset —
  each violation answers `bad_normalisation`. The touch modes stay reserved and answer
  the same code.
* Normalisation never changes a topic's scale identity: output `priceScale`/`qtyScale`
  stay the max over the configured sources, exactly as phase B advertised them.

**Deploy window**: before the upgrade, any `normalisationMode: "MID"` entry answers
`bad_normalisation` per topic (nothing arms); after, it validates and serves. Nothing
else changes shape: `fx_expired` was already in the documented reason enum (bind-once),
and NONE-mode composites behave identically on both sides.

## Composite books, phase B — `agg_book`

**What's new**

* **Object-form subscribe entries.** `params.topics` now accepts objects beside strings.
  An object entry subscribes a client-configured composite: `channel` (a required const —
  `agg_book`), `name` (your handle, session-scoped, 1–64 of `[A-Za-z0-9._-]`), `tier`
  (required — composites are always conflated), optional `maxDepth`, and 1–16 `sources`
  (canonical symbols, optional per-side `bidMarginBps`/`askMarginBps`, optional
  `disabled`). All sources must share one base asset; contract-sized derivatives are
  rejected (`unit_mismatch`) in v1.
* **The composite ack row.** `live`/`syncing` rows echo the resolved output
  `priceScale`/`qtyScale` — they bound every number the topic can carry (bind your
  parser's precision once; a `replace` that only toggles `disabled` never changes them).
  Error rows carry a **closed `code`** (see the AsyncAPI enum) plus a human `message`
  you should not parse. `live` means every enabled source book is synced and the full
  image pushes immediately; `syncing` promises the first image at the first tick the
  composite becomes computable.
* **`agg_book` data frames** are full images at each tier tick: idempotent, no chain
  semantics, both sides always present (`[]` when empty — one-sided markets publish).
  Levels are **objects** — `px`/`qty` at the output scales plus `src` per-venue
  attribution (venue-native raw prices at each venue's own scales). `ts` is the newest
  included source's `exchangeTsNs`; `seq`/`seqTs` are stream-position provenance, not a
  per-topic chain.
* **Degradation is explicit.** A source whose book cannot be served leaves the merge and
  a status frame says so (`status: "degraded"`, `excluded: [{s, reason}]`); recovery
  announces itself (`status: "recovered"`). All enabled sources out → one
  `status: "stale"` marker, then silence, then the healing image. The `reason` enum is
  complete in the contract (`stale`, `fx_expired`, `delisted`, `disabled`) so you bind
  once; this release serves `stale` — `fx_expired` arrives with FX normalisation.
* **Reconfigure with `"replace": true`** on a live `(name, tier)`: the swap is atomic
  (no unsubscribe, no gap, no `syncing` flash while sources stay synced), a failing
  replace answers its error code and leaves the old config serving, and an identical
  re-subscribe (your reconnect replay) is an idempotent re-ack. A different config
  *without* the flag answers `name_in_use` — reconfiguration is never an accident.
  The tier is topic identity: changing it is unsubscribe + resubscribe.
* **Unsubscribe by the composed topic string alone** (`agg_book.<name>.<tier>`,
  byte-identical to the echo in acks and frames) — the config is never re-sent.
* Budgets: at most 16 composites per session, 16 sources per composite, depth ≤ 50
  (deployment-configurable). Over-budget subscribes answer `budget_exceeded` per topic.
* Reserved, not yet served: `normalisationMode: "MID"` (+ `normalisedAsset`) answers
  `bad_normalisation` until MID normalisation is served (phase C, above); `vol_book`
  entries answer `unknown_channel` until volume ladders are (phase D).

**Deploy window** (a not-yet-upgraded gateway vs an upgraded one):

* **Do not mix object entries into frames an old gateway might serve.** The old parser
  reads the topics array as loose quoted strings with no nesting awareness: an object
  entry comes back as a flood of spurious per-string `error` rows (its keys and values
  each read as a "topic", so the ack can carry MORE rows than entries you sent,
  breaking order-correlation for that frame), and collection **stops at the first `]`
  the parser meets — the entry's own `sources` array's** — so every entry after the
  object in the same frame, plain string topics included, is silently dropped: never
  subscribed, never answered. A client-chosen `name` that happens to look like a
  servable topic string could even be *subscribed* by the old parser. During the
  window: send composites in their own subscribe frame, after your string
  subscriptions, and treat "no ack row matching my composed `agg_book.<name>.<tier>`
  topic" as "old gateway — retry later", never as an error.
* On the upgraded gateway, string-topic behaviour is unchanged, including acks, all
  existing channels, and unsubscribe. The ack row's new fields
  (`priceScale`/`qtyScale`/`code`/`message`) appear only on composite rows, so existing
  parsers are unaffected.
* A bare-string subscribe of a composite topic (`"agg_book.X.250ms"` as a string entry)
  answers `error` on both sides of the window: the config is the subscription.