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, inexternalDocs.urland 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:
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
feewhere you used to readnull. 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 asnull, “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’sunitScale("0.000001249140"for aunitScaleof 12), positive for a charge as before.nullstill 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 readnullas “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
qtyScaleorpriceScalewas 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, theFILL_PRECISION_OVERFLOWrejection0.4.0published. - 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 — and0.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
patternis refused 400MALFORMED_REQUEST, on every route that takes money —POST /orders(price,qty) andPOST/PUTof/asset-policies,/chain-policiesand/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.messagenames the field, the accepted form and the string it was sent. It is the door’s refusal, like every otherMALFORMED_REQUEST: nothing reached the platform, the response carries no position header, and the key is not spent — correct the spelling and retry under the sameIdempotency-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”, asnulland 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: 64beside itspattern— request bodies and served rows alike. On a request it is the bound the gateway has enforced since0.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.versionmoves from0.4.0to0.4.1; noinfo.x-immix-*-schemamoves — 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.reasongainsFILL_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 toPOST /ordersor 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 ofINVALID_FIELD.NO_REFERENCE_PRICEhas 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.4let a trading policy’smaxOrderQtyandmaxOrderNotionalride 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’spriceScale) is decided exactly. A breach that large states noobservedleg inerror.message— the figure does not fit the platform’s decimal — and readslimit … (notional)alone. info.versionmoves from0.3.4to0.4.0;info.x-immix-orders-schemastays111.v1— an enum member appends without a schema version, asORG_NOT_ACTIVEdid. 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 /ordersapricefiner than the instrument’spriceScale, or aqtyfiner than itsqtyScale, reaches the orders owner, whose grid answers: 422TICK_SIZE_VIOLATIONorQTY_STEP_VIOLATION, with the observed value against the tick or step inerror.message(observed 50000.001 against limit 1.00 (price)) — or 422INVALID_FIELDwhere 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 freshIdempotency-Key(a freshclientOrderId), where the door’s 400 left the key free. - A policy amount finer than its record is accepted, on
POSTandPUTof/asset-policies,/chain-policiesand/trading-policies, and compared exactly: amaxOrderQtyof"1.123456789"on an instrument whoseqtyScaleis 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’sunitScale, whichever is finer: on an asset whoseunitScaleis 6, sendmaxTicketAmount"2.5"beside areconciliationToleranceAmountof"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, 422INVALID_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 at0.3.4or 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 aninstrumentIdthis 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 422INSTRUMENT_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 inError.code’s description and the guide’s table until the next major: a member below0.3.4still answers it.POST /ordersby symbol: aninstrumentthis member holds no instrument under answers 422INSTRUMENT_NOT_LIVEat the door, where it answered 422SCALE_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: withX-Immix-Global-Sequenceit is the owner’s fact and the key is spent; without it, the door’s, and the key is free.INSTRUMENT_NOT_LIVEjoins the door’s codes inError.code’s description and the guide’s table; it was already in the document as anOrderRejected.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 anassetIdorinstrumentIdthat is absent or 0, which is still how the organisation-wide default trading policy is told it takes no money caps."0",nulland an absent field still mean “unset”. - A cap breach’s
limitleg reads at the scale the cap was stated at. Theobserved … against limit …text inerror.messagerenders 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: amaxOrderQtysent as"1"readsobserved 2.00000000 against limit 1 (quantity); trading policy 1 v2where it read… against limit 1.00000000 …. The policy’s row still serves the cap at the instrument’s places ("1.00000000");error.messageis 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’spriceandqty; the three asset-policy amounts, the two chain-policy bounds and the two trading-policy caps on theirSubmit*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 policyPOSTdescriptions,POST /orders, theinstrumentalias,Error.code, and theBadRequestandRejectedresponses say the same. The API guide’s Money, Errors, Orders and Versioning sections follow;info.versionmoves from0.3.3to0.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’sunitScalefor an amount, the instrument’sqtyScaleformaxOrderQty, itspriceScaleformaxOrderNotional— 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 leavesunitScaleunstated:0.3.2served its money as raw scaled integers ("250") under"unscaled": true;0.3.3serves the decimal ("2.50"), because a value that states its scale needs no reference data to be read exactly.nullstill means unset — no cap, no bound, every transfer requires approval, an exact reconciliation — exactly as before.unscaledis never present anywhere on this API now — not on any register row, not as any collection’s coarse marker; the balance surface lost it at0.3.1, the order surface at0.3.2, and the three policy families were the last rows that could carry it. Everyunscaledproperty stays in the document, markeddeprecated, so a client generated from0.3.2still compiles; all of them go at the next major.- The input side is unchanged.
POSTandPUTbodies take the same decimal strings, scaled at the edge to the record’s pair: digits beyond the asset’sunitScale(or the instrument’sqtyScale/priceScale) refuse 400, an asset or instrument this member does not hold refuses 422SCALE_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, aspriceScaleis onPOST /orders: the document never advertised one. info.x-immix-register-schemamoves114.v6→114.v7. No published shape moves; the descriptions of the seven money properties onAssetPolicyUpdated,ChainPolicyUpdated,TradingPolicyUpdatedand theirSubmit*bodies now sayDecimalinstead 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 their0means 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.versionmoves from0.3.2to0.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 rowPOST /ordersandPOST /orders/{id}/cancelanswer 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’spriceScaleorqtyScale(a fill’sfee: its asset’sunitScale), 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.1served its money as raw scaled integers ("5000000") under"unscaled": true;0.3.2serves the decimal, because a value that states its scale needs no reference data to be read exactly.- A fill’s
feeon an asset whoseunitScaleis not 8 reads correctly for the first time. okx-oe parses every fee at scale 8, and until111.v1each reader scaled that integer by the fee asset’sunitScaleinstead: a0.3.1member served such a fee10^(unitScale − 8)out. The connector now states 8 beside the value, and0.3.2serves the fee from the value at no fewer places than the asset’sunitScale. A fee on an asset atunitScale8 reads the same string as before. - A stated zero fee serves as
"0.000000", notnull. A fill’sfeeis unstated only when it names no asset (feeAssetIdnull): a zero fee whose asset is named — a zero-fee promotion — is a measurement, and0.3.2serves it as the decimal the persistence lane already lands.0.3.1served every zero fee asnull, asset or no asset. unscaledis 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 markeddeprecated; the row’s ridesRowExtras, 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 from0.3.1still compiles.- A refusal’s detail legs are the decimals the owner stamped. The
observed … against limit …text inerror.message— on a refusedPOST /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.1wrote the raw integer and said so (quantity, raw at the instrument's qtyScale),0.3.2writes the decimal.error.messageis free text by the contract either way, never parsed. POST /orderstakes the same body.priceandqtyare 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 422SCALE_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 areDecimals on the wire instead of naming a typedef whose scale to look up.info.x-immix-orders-schemamoves111.v0→111.v1. No published shape moves; the descriptions of every money property onOrderUpdatedandExecutionRecordednow sayDecimal, andOrderRejected’sobservedValueandlimitValue— 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.versionmoves from0.3.1to0.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-balancesnever serves a row raw.total,availableandheldare read at the scale the reading itself stated and served as exact decimals — at no fewer places than the asset’sunitScale, 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 whoseunitScalereference data leaves unstated:0.3.0served its amounts as raw scaled integers ("2500000") under"unscaled": true;0.3.1serves the decimal, because a value that states its scale needs no reference data to be read exactly.unscaledis never present on this surface — neither on a balance row nor as the collection’s coarse marker. Both properties stay in the document, markeddeprecated, so a client generated from0.3.0still 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-schemamoves115.v0→115.v1.AccountBalance’s published shape does not move — three decimal strings, as before — but the descriptions oftotal,availableandheldnow say they areDecimals 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.versionmoves from0.3.0to0.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(tagAccount balances, operationlistAccountBalances) 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’sAccountBalancerecord (schema 115, named by the newinfo.x-immix-custody-schema,115.v0) plus two fields of the gateway’s own:accountId,assetId,credentialId— the references (GET /accountsandGET /credentialsserve 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’sunitScalelike every other asset amount on this API (theamount_trow of the guide’s money table);availableis what the venue would let move now (a withdrawal or transfer amount, never what is usable as margin),heldwhat the venue itself has locked (order margin, pending withdrawals, venue-side freezes),totalthe whole. Zero is a reading: the three are required and nevernull—"0.00000000"is a value.venueTsNs,venueRevision— the venue’s own clock and sequence for the reading where it states one;nullotherwise (the 0 sentinel, as everywhere).observedAtNs(new, the gateway’s) — the platform time of the reading, epoch nanoseconds as a decimal string, nevernull: a changed reading and a restatement on the connector’s heartbeat both move it, so a row whoseobservedAtNsstops advancing is an account the venue has stopped reporting. Judge freshness from it, never fromvenueTsNs.unscaled(new, per row) — present andtrueonly while that row’s amounts are served raw: the asset is not yet held by the member, or itsunitScaleis 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
DISCOVEREDaccount’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 400MALFORMED_REQUEST). Rows are ordered byaccountIdthenassetId, ascending.- One surface, no entity route, no
ETag:?view=latestis accepted and?view=approvedrefuses 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 emitsAccountBalancesApibeside the twelve it emitted before, and nothing it emitted moves. X-Immix-Global-Sequencemoves 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_UNAVAILABLEon 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’sbalancesknob, 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 joinsError.code’s 503 set, and the route’s 503 response names both it andNOT_PRIMED. info.descriptionandexternalDocs.descriptionmention balances; the guide gains a Balances section;info.versionmoves from0.2.1to0.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_REQUESTdetail for anint64field 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:
maxOpenOrdersandmaxOrdersPerMinuteare 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.versionmoves from0.2.0to0.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, andOrganisations(the platform’s lifecycle arms) goes last with its audience stated.Organisation,Venue access,PoliciesandTradingretire. A generator that names a class per tag emitsUsersApi,CredentialsApi,AccountsApi,AccountAddressesApi,AssetPoliciesApi,ChainPoliciesApi,TradingPoliciesApi,OrdersApi,ExecutionsApiandOrganisationsApiwhere it emittedOrganisationApi,VenueAccessApi,PoliciesApiandTradingApi;PortfoliosApiandOperationsApiare unchanged. - Six operationIds:
upsertUser,upsertCredential,upsertPortfolio,upsertAssetPolicy,upsertChainPolicyandupsertTradingPolicyareupdateUser,updateCredential,updatePortfolio,updateAssetPolicy,updateChainPolicyandupdateTradingPolicy— 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, the202, 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*Rowschemas,RowExtras,Me,Error,PendingOperation,OperationRecord,CredentialMaterial, the security scheme) is rewritten under the same rule;Error.codelists the gateway’s own codes by status and points at the two owners’ published reason sets. /docsgains a search box over the operations, keeps the bearer across a reload and shows request durations.info.versionmoves from0.1.0to0.2.0— a minor, since before1.0.0a 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.titleisImmix API— it readimmix org-gateway — the register edge, the module’s name and its design, neither of which a consumer has a word for.info.versionis 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 readinfo.versionto learn which schema the messages derive from readsinfo.x-immix-register-schema(114.v6) from now on;info.x-immix-orders-schema(111.v0) is as it was.info.descriptionis 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 ismaterial(CredentialMaterial, formerlyConnectionCredential) on the create and on the rotation. - Rows.
ConnectionUpdated→CredentialUpdatedwithcredentialId;connectionIdiscredentialIdon every row that carries it —AccountUpdated(andtradingConnectionId→tradingCredentialId),OrderUpdated,ExecutionRecorded— and on the stream factsConnectionHealth(114) andAccountBalance(custody, schema 115);ConnectionStatus→CredentialStatus(values unchanged);pendingTransitionreadsROTATEwhere it readROTATE_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_DOWNkeeps its name — it is the pipe’s health. - The operation journal reports
"family":"credentials"; the command kinds readSUBMIT_CREDENTIAL…ROTATE_CREDENTIALandREPORT_CREDENTIAL_PROBE(REPORT_CONNECTION_HEALTHandConnectionHealthkeep their names). - The tag
ConnectivityisVenue access: a generator naming a class by tag emitsVenueAccessApiwhere it emittedConnectivityApi, and one row type per family, nowCredential. - Metrics. The lane gauges (
oe_*,custody_lane_*) label lanes bycredentialwhere they saidconnection; 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,
orderIdandexecutionIdas 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 never0. - 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 /connectionstakes the material, once.SubmitConnectionBodygains a requiredcredentialobject (ConnectionCredential: the venue’s legs — for OKXkey,secret,passphrase, each a non-blank write-only string, no other leg) and losessecretRef,appGroup,venueKeyIdandkeyFingerprint: the edge stages the legs in its write-only store, mintssecretRef(org<orgId>-<ulid>), resolvesappGroupfrom the venue and the flags (canTrade→ the order-entry groupokx-oe;canReadBalancesalone →okx-account), fingerprints the material and fillsvenueKeyIdfrom the key leg — and only then proposes the command with references and metadata. A body carrying any of the four derived fields answers 400MALFORMED_REQUEST; the row (200) carries all four as derived.PUT /connections/{id}has its own body,AmendConnectionBody:SubmitConnectionminus the same four fields and with nocredential— an amend restates the row’ssecretRef,venueKeyIdandkeyFingerprintfrom the row and re-derivesappGroupfrom the flags (a row at a venue the intake does not serve keeps its group); acredentialin an amend answers 400. An amend that would move a pinned row — one whosekeyFingerprintthe intake stamped — to another connector group (canTradeflipped on an OKX row) answers 400MALFORMED_REQUESTtoo: the credential store addresses the material by group, so the row’s new connector could not read it; the road is a new connection throughPOST /connections.POST /connections/{id}/rotate-credentialrequires a body:credential(the new legs) replacesvenueKeyIdandkeyFingerprint, which the edge now derives; the legs are staged under the row’s existingsecretRefbeforeRotateConnectionCredentialcrosses. An empty body — legal before — answers 400.- One door gate. The intake checks
connections:proposeon the caller’s row before any material is staged: a principal without the bit answers 403CAPABILITY_DENIEDat the door — the owner’s code, the same meaning, but noX-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 — anexchangeIdthe intake’s venue registry does not name, aproviderKindother thanEXCHANGE, or a row that neither trades nor reads balances),INTAKE_KEY_REUSED(409 — theIdempotency-Keyalready staged different material),PAYLOAD_TOO_LARGE(413 — the 16 KiB intake body cap),RATE_LIMITED(429, withRetry-After— 20 stagings a minute per principal), andCAPABILITY_DENIEDas the door’s. Staging is idempotent by key: a retried key restates the samesecretRefand adds no second version of the material — a version a refusal withdrew is staged again before the command re-crosses. ConnectionUpdated.keyFingerprintis 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 thefp-…form.- The off-contract
/statusgains anintakesection (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
Organisationtag:POST /orgs(SubmitOrg— a new org and its first admin, two facts: the orgPENDING_APPROVALunder dual control and the admin’sDISCOVEREDrow),POST /orgs/{id}/approve(ApproveOrg— on an activation the body namesfirstAdminUserIdamong the org’sDISCOVEREDrows and fixesrequiredApprovals, and two facts land: the orgACTIVE, the first adminACTIVEwith the Admin bits; on a reactivation or retirement the transition concludes),/suspend(SuspendOrg, immediate),/reactivateand/retire(ReactivateOrg,RetireOrg— proposals an approver other than the maker concludes),/withdraw(WithdrawOrgProposal). They takeIdempotency-Key, and the entity armsIf-Match, exactly as every command route;200is the org row (OrgAnswer—OrgRowwith the fact’s position andETag). The bodies are the commands’ derived bodies (SubmitOrgBody…WithdrawOrgProposalBody):userIdremoved,targetOrgIdandexpectedVersionroute-sourced. Two defaults at the door:SubmitOrgBody.idpOrgRefandfirstAdminIdpUserRefabsent read as""(unbound — the roster-mode shape), andApproveOrgBody.requiredApprovalsabsent reads as1(dual control — the stricter side;0is 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’sorgsbit),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
orgsbit (orgs:proposeororgs:approve) now reads every org’s row onGET /orgsandGET /orgs/{id}—PENDING_APPROVALones included — and, onGET /usersandGET /users/{id}, the members of everyPENDING_APPROVALorg (theDISCOVEREDrows an activation names its first admin among;?status=DISCOVEREDnarrows to them plus the caller’s own queue). Nothing else widens: anACTIVEorg’s members and every other family stay the caller’s own org’s. What moved:GET /orgswas “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}/…is404 NOT_FOUND, indistinguishable from an absent id, exactly as its row is;POST /orgshas no path entity and reaches the owner, whose platform clause refuses a non-platform makerCAPABILITY_DENIED. The users write arms ride the same predicate too: a platform orgs reader’sPUT /users/{id}orPOST /users/{id}/disableon a pending org’sDISCOVEREDrow passes the door (the row is theirs to read) and is refused by the owner —CAPABILITY_DENIEDwithoutusers:manage,ORG_MISMATCHwith it: the platform activates an org, it admits nobody inside it; every other principal keeps the404. Both operations’ descriptions say so. - The door reports an unknown organization. In
jwtmode a token whoseorg_idbinds no org used to be refused403 ORG_NOT_ACTIVEwith awaiting activation and nothing else. Now the door first proposesReportDiscoveredOrg— theorg_idwith theorgNameandproductProfileclaims the tenant’s post-login Action stamps from the Auth0 organization’s metadata — andReportDiscoveredUserunder it, then refuses the same code; the message says the door has reported the organization ”…” (or could not report — a replica). A token whoseorg_idbinds 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 —orgNotActiveon/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 landsPENDING_APPROVAL, its first memberDISCOVERED, and a platform approver activates onPOST /orgs/{id}/approve. A token carrying noorgNameor no knownproductProfileclaim is refusedORG_NOT_ACTIVEwith cannot report the organization naming the missing claim — nothing is proposed. A non-ASCIIorg_idis refused403 FORBIDDEN_PRINCIPALas a non-ASCIIsubalready was (the wire’s rule). TheForbiddenresponse’s andError.code’s descriptions say so;/status’sadmissionsection gainsdiscoveredOrgs. info.description’s Authentication paragraph and theOrganisationtag describe the org lifecycle surface.- The order surface’s reason set grows by one (schema 111, appended):
ORG_NOT_ACTIVE, answered403onPOST /orderswhen the acting user’s org is notACTIVE(the orders owner’s admission gained an org-liveness check: a suspended org’s members trade nothing, andPOST /orders/{id}/cancelstays ungated). The same string the door and the register answer, one meaning at all three; it rideserror.codewith no detail pair, likeORG_MISMATCH. A client switching on the reason set adds the case; one that maps unknown reasons to “refused” needs nothing. SubmitOrgBodyandApproveOrgBodydescribe their optional fields as optional. The document had calledidpOrgRef,firstAdminIdpUserRefandrequiredApprovals“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
jwtmode (dev and prod) the bearer is a JWT verified against the tenant’s JWKS withaudandisspinned; itsorg_idclaim names the organization (the org row’sidpOrgRef), itssubthe member (the user row’sidpUserRef— an M2M client’s is<client_id>@clientson aSERVICErow). Inrostermode (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 aDISCOVEREDrow (aDISABLEDrow isFORBIDDEN_PRINCIPAL, below) — on its first request the door reports the discovery (ReportDiscoveredUser, a machine proposal; aDISCOVEREDrow lands holding no capabilities) and until ausers:manageholder admits the row every route butGET /meanswers this code;GET /meserves the row so a UI can say “awaiting admission”.ORG_NOT_ACTIVE: the bearer’s org is notACTIVE— 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: aDISCOVEREDrow was refusedFORBIDDEN_PRINCIPALbefore this change and is refusedAWAITING_ADMISSIONnow, in both lanes — a client that branched onFORBIDDEN_PRINCIPALto mean “not admitted” branches onAWAITING_ADMISSIONfor that case and keepsFORBIDDEN_PRINCIPALfor the outright refusals (a roster binding with no row, a token with noorg_id, aDISABLEDrow). Both codes are named inError.code’s description;ORG_NOT_ACTIVEis 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 notACTIVE(SUSPENDED,PENDING_APPROVAL) was admitted to the reads before this change and is refusedORG_NOT_ACTIVEnow — 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=DISCOVEREDis the admission queue. Absent, every row as before; an unknown value, or the parameter on any other collection, refuses400 MALFORMED_REQUEST.UpsertUserBody.presetcarriesx-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 thepresetenum is unchanged.- The alias bodies state their “at least one” rule in schema.
UpsertUserBodyandSubmitOrderBodycarry ananyOfwith 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’s400 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
UserUpdatedlosesroleand gainskind,capabilitiesanddisplayName.capabilitiesis 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 (approvedoes not implypropose). The set is append-only on the wire, and the published schema is anarrayof anenumlisting 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-offError.codeonce made and114.v3reversed).kindisHUMAN(logs in through the IdP) orSERVICE(an M2M credential).displayNameis what the IdP stated at discovery — informational,""when none was.UserStatusrenumbers 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}) takeskind(required),capabilities(the scope strings above, a strict allow-list — an unknown or repeated scope refuses 400) orpreset, 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 /usersrenders the expansion.rolein a body is ignored like any unknown property, so a114.v5body withoutkindandcapabilitiesrefuses 400 at the generated parse.OrgUpdatedgainsrequiredApprovals(integer, 0..255): how many approvals a maker-checker mutation in the org needs —1is the dual-control pair;0means the maker’s fact is the concluded row (local and throwaway stacks; a production guard refuses it elsewhere). It is a plain count:0is a value, nevernull.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 aDISCOVEREDprincipal may make; every UI configures itself from it and from nothing in a token.- Refusal codes:
ROLE_DENIEDis renamedCAPABILITY_DENIED(403 — the owner’s register reason now spells the same as the orders owner’s); newUSER_NOT_ADMITTED(403, the actor isDISCOVERED) andORG_NOT_ACTIVE(403, the actor’s org is notACTIVE).IDP_REF_TAKEN(409) stays. TheOrgRegisterRejected.reasonenum publishes them all. info.versionmoves from114.v5to114.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.v5member that meets a v6 frame — it misreads it silently (UserUpdated.statusrenumbered: a v6DISCOVEREDrow reads as v5ACTIVE, thekindbyte reads as arole), which is why the group rolls as one. The other direction is refused: a v6 owner answers a114.v5UpsertUseror bootstrap frameINVALID_FIELD(detailschema 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-v6UserUpdated/OrgUpdated— a stream the break did not reset. Roll the clients with them. - A client on
114.v5parsingrolefinds no such property on a114.v6gateway: readcapabilitiesinstead, and branch onCAPABILITY_DENIEDwhereROLE_DENIEDwas. A client posting a114.v5user body (role, nokind) 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
UserUpdatedgainsidpUserRef(string,x-since-schema-version: 5): the IdP subject bound to the user — the token’ssub(an Auth0 user id such asgoogle-oauth2|1111…orauth0|6aa2…), opaque: compare it, never parse it. Empty means unbound — a principal admitted before the IdP, or a local lane without one — served as"", nevernull. A subject resolves to at most one live user per org.UpsertUserBodygainsidpUserRef(string,x-since-schema-version: 5, not required) onPOST /usersandPUT /users/{id}— the subject to bind, read off the IdP’s dashboard (the token’ssub). An appended field is lenient: absent,nulland""all mean unstated. On a create that leaves the new user unbound; on aPUTrestate it keeps the row’s current binding (unstated, never unbind — a rename never strands a login), and a value re-binds. A row read fromGET /users/{id}round-trips into itsPUTbody unchanged.- A new register refusal,
IDP_REF_TAKEN, inNAME_TAKEN’s class (409): anUpsertUserstating a subject another live user of the org already carries. ADISABLEDuser’s subject is free; a row restating its own subject is no collision. info.versionmoves from114.v4to114.v5.
What each side of the deploy window sees
- A client on
114.v4ignores the new property; nothing it already parses changes. - A gateway still serving
114.v4renders a user the register has already bound without the property — so “absent = unbound” only holds once the serving gateway is on114.v5; checkinfo.versionbefore concluding a user carries no subject. - A gateway on
114.v5servesidpUserRef: ""for every row the register wrote before v5 and every row noADMINhas bound; the bootstrap of a deployment an IdP fronts binds its firstADMINat birth. - A client posting a
114.v4user body (noidpUserRef) to a114.v5gateway is accepted unchanged — the appended field reads as unstated. The other direction is the hazard: a114.v5body against a114.v4gateway is accepted too, and the binding is silently dropped (the edge ignores properties it does not know; onlyuserIdis refused). Checkinfo.versionbefore 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.v4gateway’s generated codec does not degrade on a reason it does not know: its lookup throws on the ordinal (IDP_REF_TAKENis 26) rather than answeringUNKNOWN, so anIDP_REF_TAKENrefusal reaching a114.v4gateway fail-stops that member — it rejoins and replays, and the request that earned the refusal concludes on the 202 sweep, never as a 409. From114.v5the command lane reads the reason through a guard that maps an unknown ordinal toUNKNOWN, 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
Tradingtag.GET /ordersandGET /orders/{id}serve the orders domain’s rows;GET /executions(with an optional?orderId=join filter) andGET /executions/{id}serve the executions;POST /ordersproposesSubmitOrder;POST /orders/{id}/cancelproposesCancelOrder. Org-predicated exactly like the register families (a foreign id is 404, indistinguishable from absent). The two reads have one surface: noviewparameter —view=approvedrefuses 400MALFORMED_REQUEST. Order ids are int64 and cross as decimal strings, in the path too. - The submit’s
Idempotency-KeyIS the order’sclientOrderId— the orders owner’s business idempotency key: 1 to 36 characters of the key grammar (its own parameter,OrderIdempotencyKey; a longer key refuses 400INVALID_IDEMPOTENCY_KEY). A bodyclientOrderIdmay 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 409CLIENT_ORDER_ID_CONFLICT. - A submit must name its instrument, by id or by name.
instrumentId(the int64, as a decimal string) andinstrument(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 (400MALFORMED_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.SubmitOrderBodypublishes both properties and requires neither on its own; a client that sendsinstrumentIdtoday 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,nullor"0"for MARKET) scales by the instrument’spriceScale,qtyby itsqtyScale, exactly — more fractional digits than the scale refuses 400; an instrument the projection does not hold refuses 422SCALE_UNKNOWNunsequenced, 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 atpriceScale, quantity-shaped fields (qty,cumQty,leavesQty,lastFillQty,venueCumQty,fillQty) atqtyScale, an execution’sfeeat the fee asset’sunitScale; a required money field always serves a decimal ("0.00000000"is a quantity), the optional ones servenullat 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 underunscaled, 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 400INVALID_IF_MATCH); its body is optional, a bodyorderIdmay restate the path, andCancelOrderBodycarries noclientOrderId— 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 withcancelRequestedAtNsset and the status unchanged — the venue-async window;CANCELEDarrives later on the venue’s word as an ordinary order update the reads serve. An order’sETagis its per-orderversion, a read token only; executions carry no version and noETag. A replayed 200 whose order has since been released (the owner’s retention outran the journal window) answers 404NOT_FOUNDunderIdempotent-Replay— the read’s own answer, never a fabricated row. - The orders owner’s reasons ride verbatim as
error.code(theOrderRejectReasonvalue set, published onOrderRejected.reason), mapped by class: 403CAPABILITY_DENIED(the edge pre-checks no role — the owner refuses a principal who is not a liveTRADER); 404UNKNOWN_ORDER; 409CLIENT_ORDER_ID_CONFLICT,ACCOUNT_NOT_ACTIVE,CONNECTION_DOWN,TRADING_HALTED,NO_REFERENCE_PRICE,OPEN_ORDER_CEILING,ORDER_TERMINAL,ORDER_STATUS_UNKNOWN; 429RATE_CEILING(withRetry-After: 60, the window); 422 the field, reference, sizing and cap refusals. A refusal’serror.messagerestates 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
GlobalSequenceheader’s andGlobalSequenceValue’s descriptions say so. OperationRecord.outcomegainsorderId(an int64 string) andfamilyadmitsorders: an orders operation names its order there, never in the int32entityId. It also gainsmessage— the free text the same outcome answered synchronously aserror.message— so a client that polled a202reads 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; schemasOrderUpdated,ExecutionRecorded,OrderRejected,SubmitOrder/SubmitOrderBody,CancelOrder/CancelOrderBody; responsesOrderAnswer,OrderRow,ExecutionRow.Error.code’s description names the order reasons;ForbiddenandBackpressuredescribe 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
requiredset 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
COMMITTEDuntil the owner returns. - The instrument alias is additive on both sides of the window: a body naming
instrumentIdis the shape a gateway on either build accepts, and one naminginstrumentis 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 — readinfo.x-immix-orders-schemaand the presence ofSubmitOrderBody.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.0now 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 withrefFreshnessNs > 0, so0is the permissive setting — no staleness bound at all — not a lockout.NO_REFERENCE_PRICEstill refuses when no reference exists, at anyrefFreshnessNs.TradingPolicyUpdated.instructFreshnessNs’s description.0now 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 states0; the first-instruction gate always applies, only its window is operator-set.degradedGraceNsis 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
0means, and the old text pointed the wrong way on a risk control. An operator who read it and leftrefFreshnessNsat0believing it refused everything was in fact running with no reference-staleness bound; one who leftinstructFreshnessNsat0for 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-zerorefFreshnessNsto bound reference age (5 s is the recommended bound) and a non-zeroinstructFreshnessNsto 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 minusaccount-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-policiesandchain-policiesapprove|retire|withdraw;trading-policies/approve|retire|withdraw|halt|resume.orgsandaccountshave 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>Bodyschemas beside the generated<Command>schemas:userIdis absent from every body schema and refused if sent (400MALFORMED_REQUEST) — it is stamped from the admitted principal; the entity ref (connectionId,targetUserId, …) andexpectedVersionare 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,nullor0. 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 ornull= 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-Keyis 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 withIdempotent-Replay: trueand publishes nothing; a retry of a live key attaches (one publish total); a key reused on another family or entity refuses 409IDEMPOTENCY_KEY_REUSED.If-Matchon entity routes carries the command’sexpectedVersion: theETagan entity read minted, quoted or bare; absent or*= unconditional; weak validators refuse 400INVALID_IF_MATCH. A stale precondition answers 412VERSION_CONFLICTwith the entity’scurrentVersionin the envelope and a freshETag: 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 replaceslatestwholesale, 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 theETagof 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 — aclassifyon an account with a pendingAMENDis a revision of that proposal, not a collision with it, so withoutIf-Matchthe second maker’s body simply becomes the proposal. TheIf-Matchparameter’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 inX-Immix-Global-Sequenceand the row’sETag; 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}withLocation: /operations/{key}. The owner’s rejection reasons ride verbatim aserror.code, mapped to statuses by class: 412VERSION_CONFLICT; 403SELF_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, from114.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), 422SCALE_UNKNOWN, 404NOT_FOUND(an entity route’s id absent from, or outside, the principal’s org — exactly as the read answers), 429INBOX_FULL/IN_FLIGHT_FULL(Retry-After), 503NOT_PRIMED/NOT_SERVING/OWNER_ABSENT. A replayed refusal (403, 409, 412, 422 — the owner’s rejections, sequenced and journaled) carriesIdempotent-Replay: trueand the position of the rejection fact exactly as a replayed 200 does; the edge’s and the platform’s own refusals (the 400s, 409IDEMPOTENCY_KEY_REUSED, 413TOO_LARGE, 422SCALE_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 plusRowExtras) that both its read’s 200 and its commands’ 200 reference, and every operation carries anoperationId(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_tandnotional_tfields are decimal strings scaled at the edge by the same source the reads render by — the referenced asset’sunitScale, 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 422SCALE_UNKNOWNnaming the ref (a client may retry once refdata lands); a cap stated with no ref at all (assetIdorinstrumentIdabsent), or a ref off its grammar, can never scale and refuses 400; absent,nullor"0"is the 0 sentinel, so a row read fromGETround-trips into aPUTbody. The org-default trading policy (instrumentIdabsent) 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 404UNKNOWN_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.codeopens. It was a closed enum of five read-side codes; it is now a string whose description lists the door codes and points atOrgRegisterRejected.reason(now published) for the owner’s set — append-only.ErrorgainscurrentVersion(412 only). TheRejectedresponses declare the position header (the refusing fact’s).- New components: parameters
IdempotencyKey,IfMatch,OperationKey; headerIdempotentReplay; schemasPendingOperation,OperationRecord,OrgRegisterRejectedand the 34 command/body pairs; responsesPending,BadRequest,Rejected,VersionConflict,Backpressure,Unavailable,InternalError,UnknownOperation(ForbiddenPrincipalis renamedForbidden; 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
requiredset of anyGETmoves. A client whose generatedError.codetype 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
ConnectionUpdatedgains the connection’s observed axis — eleven properties, allx-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(arrayofstring; items enumeratedread/trade/withdraw;uniqueItems): the key scopes the venue stated, served in that order. Empty means no scope observed — served as[], nevernull. The choice set is append-only: skip an unknown name, never error on it.observedAllowlist(string, enumUNKNOWN/NONE/COVERS/MISSES): the key’s IP allowlist judged against the cluster’s egress set.probeOutcome(string, theHealthReasonenum):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, …), withprobeDetailsaying 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 withkeyFingerprint, 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-NONEoutcome.
- “Never probed” is one shape:
probeOutcome: "UNKNOWN",probedAtNs: null,observedPermissions: [],observedAllowlist: "UNKNOWN",observedKeyCreatedAtNs: nulland 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_APPROVALproposal shows onview=latestonly, andview=approvedgains it with the conclusion — the observed axis is content, like the proposal it measured. A probe on anACTIVEorSUSPENDEDrow with nothing pending shows on both views at once. A later probe supersedes an earlier one whole (probedAtNsmoves). - 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—canReadBalancesneedsread,canTradeneedstrade,canExecuteTransfersneedswithdraw) or beyond them (SCOPE_MISMATCH— a Read-only pipe must not carrytradeorwithdraw). A reader sees such a row stayPENDING_APPROVALwith itsobservedPermissionsexplaining 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
requiredmembership. info.versionmoves from114.v3to114.v4.
What each side of the deploy window sees
- A client on
114.v3ignores the eleven new properties; nothing it already parses changes. - A gateway still serving
114.v3renders a connection the register has already probed without the properties — so “absent = never probed” only holds once the serving gateway is on114.v4; from then on the row itself says so (probedAtNs: null,probeOutcome: "UNKNOWN"). Checkinfo.versionbefore concluding a key was never read. - A gateway on
114.v4serves 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
probeOutcomeorobservedAllowlistname asUNKNOWNand skip an unknownobservedPermissionschoice — 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/reconciliationToleranceAmountandChainPolicyUpdated.minTransferAmount/maxTransferAmountare served as exact decimal strings scaled by the referenced asset’s refdataunitScale—"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": truebefore.- The
unscaledmarker 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 itsunitScaleunstated, 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 orrequiredset 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 onamount_trows whose asset the gateway holds with a stated scale. - A client that parsed
amount_tvalues 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_traw under the marker whatever the refdata says; a gateway on this build serves decimals for every asset whoseAssetUpdatedfact states aunitScale. An asset stated before the refdataunitScalefield existed decodesunitScale0 (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
ConnectionUpdatedgainsappGroup(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.versionmoves from114.v1to114.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.v1ignores the new property; nothing it already parses changes. - A gateway still serving
114.v1renders a connection the register has already assigned without the property — so “absent = unassigned” only holds once the serving gateway is on114.v2. Checkinfo.versionbefore 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
AccountUpdatedgainstradingConnectionId(integer/int32, nullable,x-since-schema-version: 1): the trading pipe this account’s orders instruct through when set. Absent ornullmeans orders route through the account’s ownconnectionId— 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.versionmoves from114.v0to114.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.v0renders an account the register has already designated without the property — so “absent = own pipe” only holds once the serving gateway is on114.v1. Checkinfo.versionbefore 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.