Skip to navigation

REST API changelog

Consumer-facing history of the trading gateway’s HTTP contract (openapi.json 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. 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 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 anyOfs, 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:

FieldWasNow
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 "12.5" 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’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’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’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 Decimals — 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’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 Decimals 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’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 Decimals: 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 Decimals 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’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 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 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: 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.