Changelog
Changes to the Exchange API contract, newest first. Every change within v1 is additive
(versioning).
2026-10-16 #
- New: org-stock sale, moves, note and stolen.
POST /exchange/v1/org-stock/changesgains the opssell(withterminalandproceeds, mission shares booked as in the Basetool),move-location,move-holder(to a member of a unit the member is a Logistician of, withtoUnit),move-pool(into another pool of the entry’s holder),noteandstolen(the whole entry or a part) (org stock); new per-op reasonSTOCK_OFFERED. - New: org-stock writes. Under the new scope
exchange.orgstock.write, a Logistician of a unit books stock into the unit’s pool for a member of the unit (book-in) and books an entry out (book-out) throughPOST /exchange/v1/org-stock/changes;GET /exchange/v1/org-stock/memberslists the unit’s roster to name the holder from. Each op counts against the daily write quota, a dry run once (org stock). New:change-set.schema.json#/$defs/orgStockChangeSetandorg-stock-members.schema.json; new per-op reasonsNOT_PERMITTED,HOLDER_UNKNOWN,INSUFFICIENT_STOCK,OVER_ALLOCATIONandEARMARK_TARGET_INVALID. - New: org-stock entries. Under the new scope
exchange.orgstock.details, in addition toexchange.orgstock.read, every lot of a pool in which the member is a Logistician of the unit carriesentries: holder name and a per-clientref, amount, note and the job-order and mission earmarks of each single entry. A holder who withholds their name is{withheld: true}without name,refor note. Name, withholding and mission-name changes reach the feed (org stock).org-stock-lot.schema.jsongains the optionalentries. - New: the org-stock feed. The feed cursor on the last page of
GET /exchange/v1/org-stocknow resumes a change feed instead of answering410 CURSOR_EXPIRED: each changed lot once, its current state or a tombstone, with the units the member reads on every page. A member who joins a unit receives its pool through the feed (org stock). No schema changes.
2026-10-15 #
- New: the org stock.
GET /exchange/v1/org-stockunder the new scopeexchange.orgstock.readserves the shared warehouse stock of the units the member directly belongs to, one pool per unit: lots summed over every holder, with the amounts earmarked to job orders and missions, and on every page the units the member reads (org stock). A snapshot only for now: reading with the feed cursor of its last page is410 CURSOR_EXPIRED. New schemaorg-stock-lot.schema.jsonand theorgStockPagedefinition inpage.schema.json. - New: an upsert sets a ship’s org unit. An
upsertinPOST /exchange/v1/me/ships/changesmay carryorgUnit, one of the member’s units ornullfor none, and withshipIdthe ship’sorgUnitVersion. WithoutorgUnitnothing changes: a create still gets the member’s only unit, an update keeps the ship’s. New reasonsORG_UNIT_NOT_ALLOWEDandORG_UNIT_VERSION_CONFLICTreject only their own op (ships).change-set.schema.jsongains both fields, optional. - New: a ship’s org unit. A ship in
GET /exchange/v1/me/shipscarriesorgUnit, its unit’s{id, name, shorthand, kind}(absent when it has none), andorgUnitVersion, the unit’s own counter. A change of the unit, or a rename of it, brings the ship back into the feed (ships).ship.schema.jsongains both fields, optional. - New: the member’s org units.
GET /exchange/v1/me/org-unitsunder the new scopeexchange.orgunits.readlists the units the member directly belongs to, with id, name, shorthand and kind, so a client can offer them as a choice (org units). New schemasorg-unit.schema.jsonandorg-unit-list.schema.json; the service document lists the scope once the registry grants it. Nothing else changes.
2026-10-03 #
- Corrected: a refinery draft carries at least one order.
refinery-draft.schema.jsonnow requires one to fiveorders, as the drafts page always said; the fixture with an empty list moves torefinery-draft/invalid/no-orders.json. An empty list never succeeded: it was answered400 SCHEMA_INVALIDbefore and still is, so this is a correction, not a breaking change (versioning). The gateway now refuses it itself, witherrors[]naming/ordersand the detail of its other schema refusals, instead of relaying it to the Basetool. - Corrected: every refinery-draft order carries at least one good and one source image.
refinery-draft.schema.jsonnow requires one to 100goodsand one to 50sourceImagesper order; the fixturesrefinery-draft/invalid/no-goods.jsonandinvalid/no-source-images.jsonshow the refused shapes. An order with either list empty never succeeded, so this too is a correction, not a breaking change. The gateway refuses it itself, witherrors[]naming the list.
2026-09-28 #
LEGACY_ENDPOINT_GONEis retired. It answered the SC Extractor’s old/v1/*routes, which were never part of the exchange and are now removed; the code stays in the registry so it is never reused (errors). Nothing under/exchange/v1changes.EXCHANGE_BUDGET_EXHAUSTEDtells when to retry and costs no write. ItsRetry-Afteris now the seconds until enough of the full budget expires, at most 3600, instead of a fixed 60 that a full budget never kept; and the refused write no longer counts against the daily write quota, so a client that honoursRetry-Afterkeeps its writes (errors, sync guide).- New code
RELAY_BUSY, and large change sets are relayed at most four at a time. A change set of more than 100 ops that arrives while the gateway already relays four such sets, over all clients, is answered503 RELAY_BUSYwithRetry-After: 10and not relayed; retry it under the same key; the refused set costs no write of the daily quota. Sets of at most 100 ops are unaffected. The gateway also waits up to 30 s for the Basetool instead of 15 s, so a slow large set is answered rather than failed with502 BACKEND_RELAY_FAILEDafter it was written (errors, sync guide). - A connection without
offline_accessends after 90 days too. Its online session now ends after 30 days without use and after 90 days at the latest, as an offline session always did, instead of after the browser session’s 180 days. A client that requestsoffline_access, as it must, is unaffected; one that omits it signs in again at the latest after 90 days (authentication). - Clarified: one code per situation, whichever side refuses. The Basetool behind the gateway
checks the switch, the registry, the revocations and the capabilities of every request again.
Right after a change, while the gateway’s registry cache still admits a request, that second
check used to refuse it as
403 NOT_PERMITTED, or502 BACKEND_RELAY_FAILEDwhen it could not read the revocations. It now answers with the code, status,Retry-Afteranddetailthe gateway gives the same situation:CLIENT_SUSPENDED,CLIENT_NOT_ALLOWED,EXCHANGE_DISABLED,INSTALLATION_REVOKED,CLIENT_REVOKED,SCOPE_MISSING,REGISTRY_UNAVAILABLE; the service document answers such a refusal as well (errors). No code is new, and the contract always documented these codes for these situations. - The org demand is withheld with
reason: NOT_PERMITTED. A member who may not see their organisation’s job orders in the Basetool — no unit of theirs takes part in the profit sharing — now gets200with two empty lists and"reason": "NOT_PERMITTED"instead of their units’ demand; a member whose unit leaves the profit sharing stops seeing it at once (org demand).org-demand.schema.jsongains the optionalreason, absent whenever the demand is shown. A client that does not read it shows an empty demand, as it already did for a member of no unit; the sandbox shows both answers. - Every refusal carries a registered code. A body that is not a JSON document is
400 SCHEMA_INVALIDwith one error at the pointer""instead of400 BAD_REQUEST, and it is no longer cached for itsIdempotency-Key, so the corrected request under the same key runs. A body sent withoutContent-Type: application/jsongets the new code415 UNSUPPORTED_MEDIA_TYPEinstead of a415withoutcode. A method other thanGET,POST,HEADorOPTIONSon an exchange path is404 NOT_FOUNDlike any unknown route instead of a bare405, and a query parameter without a name is400 SCHEMA_INVALIDat/instead of a bare400. The registry now also lists500 INTERNAL_ERROR, the generic fallback (errors). New codes are additive; a client handles them by their status. - Documented: the per-IP limit. 120 requests a minute per source IP address, over all members
and clients, are checked before the token is read; every client behind one address shares them.
Its
429 RATE_LIMITEDcarries noRateLimitheaders and noDPoP-Nonce(sync guide). The limit existed before. - Corrected: which answers carry
DPoP-Nonce, and when the nonce challenge comes. The answers given before the token is read — the per-IP429,413 PAYLOAD_TOO_LARGE, the identity provider’s503, a refused method, path or query — carry no nonce; keep the last one. The challenge comes before the proof’s claim checks, but a proof that cannot be parsed or verified is refusedinvalid_dpop_proofwithout a challenge (authentication). The behaviour is unchanged. - Corrected: smaller statements.
NOT_FOUNDalso means an unknown route or method, checked right after the token’s audience;IDEMPOTENCY_KEY_REUSEDmeans another route or another body under the key, andIDEMPOTENCY_KEY_MISSINGalso a malformed key;RATE_LIMITEDalso covers the hourly account check and the per-IP limit;LOC_KEY_UNRESOLVEDis added whenever the key fields, thelocKeyincluded, resolve to no single entry; the schema check runs before the relay, after the gates; the installation label must not start with a space; only the gates’ refusals are counted as metric reasons. - A full proof store answers
503 SERVICE_UNAVAILABLE. When all members together hold the gateway’s 100 000 live DPoP proofs, a proof is no longer refused like a replayed one (401 DPOP_INVALID) but with503 SERVICE_UNAVAILABLEandRetry-After, the seconds until the earliest live proof no longer counts (errors, authentication). A client already handles the code by its status: waitRetry-After, then retry. - New approval criteria: back-off and sync cadence numbers. “A few seconds” and “every few
minutes” are replaced by binding numbers: retries back off from 5 seconds, doubling up to at most
5 minutes, with random jitter and never less than
Retry-After; a client syncs on start and after a local change, and a timed sync runs at most every 5 minutes (sync guide, client security). - A
formatVersionmajor other than 1 is refused. A blueprint draft whoseformatVersionis not a1.xgets400 SCHEMA_INVALIDwitherrors[]at/formatVersion(“unsupported major version”), and the web import refuses such a file; every1.xis still read (formats, drafts). The schema is unchanged; its description states the rule. Until now the field was not read, and2.0was read as1.0.
2026-09-27 #
- New code
429 DPOP_PROOF_LIMIT. A proof whose member already holds 600 live proofs is no longer refused like a replayed one (401 DPOP_INVALID) but with429 DPOP_PROOF_LIMITandRetry-After, the seconds until the member’s earliest live proof no longer counts (errors, authentication). A new code is additive inv1; a client that does not know it handles it by its status,429. - More approval criteria. An application is now also checked for: a Linux fallback key
file in a
0700directory; baseline, ship links and cursors kept per installation and random idempotency keys; showingdetachedFromMissions,offersReducedandoffersRemovedto the member; and every shipupsertsending the currentnameandlocation(client security). A sandbox run before applying is recommended, not required. - Corrected:
Retry-Afterper code. The pages said every Redis failure answersRetry-After: 60. The gateway sends 30 withEXCHANGE_DISABLED,REGISTRY_UNAVAILABLEand aSERVICE_UNAVAILABLEwhose daily write quota cannot be counted; 60 withEXCHANGE_BUDGET_EXHAUSTEDand aSERVICE_UNAVAILABLEfor a store it cannot reach; 5 when the identity provider cannot be reached (errors). The behaviour is unchanged. - Clarified: the change result’s
cursoris reserved.change-result.schema.jsondeclares an optionalcursor, which the server has never sent. It stays in the schema, sincev1never removes a field, and is marked reserved; read the feed after a push for the new position. - Corrected:
UNAUTHENTICATEDis answered by a refresh. The error registry said to start a device login again; as the authentication page says, refresh once, and start a device login only after the refresh answersinvalid_grantand the member asks. - Corrected: the
docsUrlexample. The service document’s example and its fixture showedhttps://krt-profit.github.io/basetool/exchange/, which does not exist. The gateway sends the site root,https://krt-profit.github.io/basetool/. - Documented: the cap on live DPoP proofs. A member holds at most 600 live proofs over all clients (authentication). The cap existed before; only the page is new.
- Documented: the problem fields. The error registry lists
which fields a problem carries and when, and the
X-Correlation-Idheader.retryAfterSecondsis reserved and not sent.problem.schema.jsonallows acorrelationIdof up to 128 characters instead of 64, because the gateway echoes a client’s own id of that length. - Clarified:
confirmationUrlis a secret. Like a draft’sfrontendUrl, it carries a one-time handoff id; never log or share it (sync guide). The fixture now has the real shape,/connected-apps/confirm?handoff=…. - Corrected: draft slots. The drafts page said a member’s ten live drafts are shared with the SC
Extractor’s uploads. Each client holds ten per member in slots of its own, and staging an eleventh
drops only that client’s oldest. A draft item’s
provenanceis not read; the review recordsimport. - Documented: offline files. The web import takes a file of at most 8 MiB, checks only
formatand the items, and does not readformatVersion(formats). - Clarified: SCU decimals.
quantity.schema.jsonread like a limit of three decimals, which nothing checks. An SCU amount may carry any number; the server rounds it half-up to three before storing or comparing it (formats). - Clarified: sandbox
htu. The sandbox gateway compareshtuwith the called URL, port included, solocalhostand127.0.0.1both work when the proof names the address the request went to (sandbox). - Clarified: which answers are replayed.
409 IDEMPOTENCY_IN_PROGRESS,422 IDEMPOTENCY_KEY_REUSED,400 IDEMPOTENCY_KEY_MISSINGand413are never cached, and an answer the server could not store is not replayed (sync guide). - Clarified: the label’s hyphen and the tombstone channels. The installation label allows only
the ASCII hyphen-minus;
VerseKit – Windowswith an en dash is refused (connect). A tombstone’sremovedBy.channelisweb,app,clientorsystem(sync guide). - Clarified: after
CLIENT_SUSPENDED. A client cannot learn when a suspension ends, so it tries again at its next start or when the member asks, never on a timer. - One held mass change per client. A newer held batch replaces only your client’s older one for that member; another client’s held batch no longer displaces yours.
- Show the bare
verification_uri. A client shows theuser_codeandverification_uriand lets the member type the code; it no longer opensverification_uri_complete, which skips the page that warns about device-code phishing. The device response is unchanged. CLIENT_REVOKEDcounts from the sign-in. After the member disconnects a client, a token withoutoffline_accessis refused while itsauth_timelies before the disconnect, also when it was refreshed afterwards; a client that requestsoffline_access, as it must, is unaffected.- Store outages. A store the exchange cannot reach answers
503 SERVICE_UNAVAILABLEwithRetry-After: 60on every exchange route. A lost Redis connection while staging a draft answered with the extractor upload’sRetry-After: 5, and a store failure outside the staging could answer500. - Oversize drafts. A draft or a held-back change set whose staged form exceeds the cap answered
400 BAD_REQUEST, a code outside the registry, and the answer was replayed for its key. It now answers413 PAYLOAD_TOO_LARGE(draft) or413 BATCH_TOO_LARGE(change set), which is not cached. - Overlong unknown field names. A request whose undeclared field has a JSON Pointer longer than
200 characters is refused with
400 SCHEMA_INVALID(errors[]names its parent) instead of being written and answered502 BACKEND_RELAY_FAILED. - Fixed
detailtexts. A refusal the Basetool raises behind the gateway arrives with its code and a fixed Englishdetailper code, no longer with the Basetool’s own, possibly localised text (errors). - OpenAPI document matches the gateway. The
Idempotency-Keytakes 8 to 128 characters of[A-Za-z0-9._~-];POST /exchange/v1/me/installationneeds none;catalog/resolveandcatalog/locationsaccept any exchange scope,exchange.connectincluded. VERSION_CONFLICTat request level. A write that meets a concurrent change the row locks did not already turn into a per-opVERSION_CONFLICTnow answers409 VERSION_CONFLICTinstead of502 BACKEND_RELAY_FAILED.- Drafts.
POST /exchange/v1/me/drafts/blueprintsand…/drafts/refinery-ordersstage abasetool.blueprintsenvelope or a refinery extract for the member’s review and answer thedraft-result. A draft too large to hand off is413 PAYLOAD_TOO_LARGE; a draft the Basetool refuses as malformed is400 SCHEMA_INVALIDwithouterrors[]. - Offline blueprint files. The web’s blueprint import reads the
basetool.blueprintsenvelope. - Provenance. A blueprint
addrecords itsprovenance.source, and the blueprint feed answers it for every blueprint whose source was recorded. - Stock. A change set that lowers a lot and raises its stolen or not-stolen twin marks the stock instead of booking it out and in; a lowered lot counts as moved for the mass-change guard only when the batch’s rises of the same material cover all of it.
2026-09-26 #
- v1 published. The schemas, the OpenAPI document, the error registry and the conformance fixtures.
installationIdin the installation response and the service document — the value a tombstone’sremovedBy.installationIdcarries.