openapi: 3.1.0 info: title: TextMachine API version: 0.13.0 summary: Ratified contract between the frontend and the TextMachine platform. description: | **RATIFIED contract.** Canonical copy: `docs/architecture/14-api-contract/`; `frontend/docs/api-contract/` is a byte-mirror and a divergence is a defect of one of the two. This file is normative for the FORM; the `README.md` beside it — the companion — carries provenance, rationale, open questions and the history of every form here. ## Boundaries The client reads the platform read-model only; no path below addresses the translation service. **Nothing about HOW a book is translated crosses this boundary** — no model names, no phase or stage names, no internal vocabularies, and no cost of any model, stage or call. ⚠ **Money the ACCOUNT owns is not in that set** (owner, 05.09): the balance, an order's ceiling and its hold are the user's own figures and WILL be shown as amounts — ⚠ **ИСПР. 06.09: условие ИЗРАСХОДОВАНО.** Минор формы заказа ЗАЛАНДЕН (`0.11.0`), суммы на проводе: `balance_micro_usd`, `limit.{min,max}_micro_usd`, `estimate.{expected,hold}_micro_usd`. `Usage` ретированное правило по-прежнему НЕСЁТ и это верно (её провод не изменился); `CeilingBounds` — РЕТИРОВАНА целиком и больше не отдаётся ни одной операцией. The earlier blanket «no money sums» was the owner's own rule of 02.08 and he RETIRED it on 05.09; what stays banned is the structure of OUR spend. The projection is an allowlist: a field not named here never reaches the client. The rule binds the PROSE too — every description here is compiled into the generated client's source. **Two exceptions, both bounded, both because hiding the fact would make the wire lie.** The book's memory bank: the work stops there for a signature, and a stop the user must clear cannot be hidden. And `Progress.stage`: a bar that moves without saying what is moving answers worse than a caption does. The second carries its own bound — the value is DERIVED by the platform from the counters it already sends, never an identifier forwarded from the translation service, and its vocabulary is OPEN, so a client that meets a value it does not know renders it neutrally and a deployment whose work has a different shape needs no new client. ## On every response `X-Request-Id` · `Cache-Control: no-store` · `X-Robots-Tag: noindex`. `no-store` binds caches (RFC 9111 §5.2.2.5), not the copy an application holds in memory — which is why the conditional reads below still work. ## Transport The client is served from the **same origin**. No `Access-Control-*` header is sent on any response and a preflight `OPTIONS` with a foreign `Origin` is refused, so a cross-origin browser client is inoperable as a class. Unsafe requests are also checked against their origin; a rejected one is `403`. A dev server proxies onto its own origin. **Signing in is not part of this surface**: session mechanics live outside the version prefix and the flow starts at `GET /auth/login` (companion). A client that meets `401` sends the user there. ⚠ **Outside the version prefix the error envelope is thinner.** A refusal from `/auth/*` (or any path not under `/v0`) is `problem+json` of the same family but MAY arrive without the mandatory `code` — there a client dispatches on the HTTP status and shows one neutral phrase, and a client generated from this document MUST tolerate the absence rather than fail parsing. The surface and the reasoning live in the companion, §2.14. ## `Location` Every `Location` here is a URI reference resolved against the request's URL (RFC 9110 §10.2.2); it may be relative and usually is. A client follows it as given and does not rebuild the address from an identifier of its own. ## Conditional reads Every collection read and the book card answer an `ETag` and honour `If-None-Match` with `304`. A client is expected to use them: a frame says only THAT something changed. A validator on any OTHER safe read of this surface is legal too and needs no declaration here — the operations that declare one are those where a client gains by using it, not an exhaustive list of where a server may send one. Two rules bound that: a client is never REQUIRED to send `If-None-Match`, and `304` is only ever the answer to one it did send. Stated so that a server can put a validator in one place instead of keeping a list of exceptions that rots. An `ETag` is bound to the principal and to the representation; a response negotiated on `Accept-Encoding` MUST carry `Vary: Accept-Encoding`. ## Compression A server MUST honour `Accept-Encoding` on `application/json` and MUST NOT compress `text/event-stream` — compressing a stream buffers it. Where compression is done is the deployment's business (companion). ## Absence of a value **A field whose value can be missing is REQUIRED and NULLABLE**; `null` is "not known". A field is OPTIONAL only when its absence is itself the fact. An empty collection is an empty array. Two exceptions, in both of which absence means "does not apply": the extension members of `Problem` (defined per `code`, RFC 9457 §3.2) and the aggregates of `BankPage` (first page only). Both are stated on their schemas. ## Errors `application/problem+json` (RFC 9457), identified by the machine `code` and never by their words. `title` and `detail` are for a developer and a log: a client MUST NOT show either, and draws the phrase from `code` — a neutral one for a code it does not know. See `Problem`. **A failure that is NOT this shape is still a failure a user sees.** A gateway answering `502` with an HTML page, a connection that dies mid-body, a body that does not parse: there is no `code` to dispatch on and there may be no `X-Request-Id`. The rule is the same as everywhere — **the client shows NOTHING from that response, not one byte of its body**, and draws its own neutral phrase from what it does know: the status class if there is one, "the service could not be reached" if there is not. This is not an error class of this API; it is the floor under it. ## Versioning Semver. A client MUST ignore unknown fields and MUST tolerate unknown enum values without failing; on an unsupported version it MUST refuse and tell the user. ⚠ **While the version is 0.x, a MINOR bump is the lane for breaking changes** (semver §4). A client pins the exact 0.x version it was generated against and assumes nothing across minors. The version a deployment serves is read from `GET /capabilities`. Every `enum` here is the vocabulary of THIS version. Generated types are closed unions and do NOT protect against an unknown value, so that branch belongs on the client seam (`src/api/`). ⚠ The "tolerate an unknown value under a minor bump" rules on the schemas below describe the lane where minors are ADDITIVE — from 1.0, and within one version where a deployment is older than the contract. While the major is `0` a client refuses a differing minor outright, so those branches are a floor and not a licence to run against another 0.x. license: name: UNLICENSED identifier: LicenseRef-proprietary servers: - url: /v0 description: | Same origin as the client (see Transport). Only the version prefix is fixed; the host is whatever origin served the application. Relative on purpose: an absolute placeholder is what a generated client compiles in. security: - sessionCookie: [] - bearerToken: [] tags: - name: deployment description: What this deployment can do. - name: library description: Book library and book card. - name: reading description: Chapters, source/translation pairs, notes. - name: bank description: Memory bank — reading it, correcting terms, signing it as one act. - name: runs description: Translation runs, live events, control. - name: export description: Export of a finished book. - name: account description: Credit balance of the account. paths: /capabilities: get: tags: [deployment] operationId: getCapabilities summary: What this deployment can do. description: | What this deployment can do: contract version, the pairs it can actually translate, the size it accepts, the formats it builds, the page size it hands out. Read once at start-up. One deployment, one answer — not per-account, not a negotiation. responses: '200': description: Capabilities of this deployment. headers: ETag: { $ref: '#/components/headers/ETag' } content: application/json: schema: { $ref: '#/components/schemas/Capabilities' } '304': { $ref: '#/components/responses/NotModified' } '401': { $ref: '#/components/responses/Unauthorized' } parameters: - $ref: '#/components/parameters/IfNoneMatch' /books: get: tags: [library] operationId: listBooks summary: Book library. description: | The user's books, newest addition first. `revision` here is the LIBRARY's own — never compared with a book's. Page size default: `GET /capabilities`. A client MUST follow `next_cursor` until it is `null`. parameters: - $ref: '#/components/parameters/Limit' - $ref: '#/components/parameters/Cursor' - $ref: '#/components/parameters/IfNoneMatch' responses: '200': description: Library. headers: ETag: { $ref: '#/components/headers/ETag' } content: application/json: schema: { $ref: '#/components/schemas/BookPage' } '304': { $ref: '#/components/responses/NotModified' } '400': { $ref: '#/components/responses/BadRequest' } '401': { $ref: '#/components/responses/Unauthorized' } post: tags: [library] operationId: createBook summary: Add a book. description: | **The `file` part MUST come LAST in the form** — it is read as a stream and reading stops at the file. `BookIntake` lists its properties in the order they must be sent. **A part sent after the file is refused, never ignored**: `400`, `code: invalid_request`, with an `errors[]` entry naming it. **The `201` is written after the last byte lands, and what it carries depends on whether the engine could cut the book inside the request** (0.13.0). Four outcomes: - the engine cut **2 or more chapters** — `201` with `status: not_started`, `chapter_count` from the engine, `character_count` from the intake with `character_count_exact: false`. `structure` and an exact count arrive with a later revision of the library row: the intake does NOT materialize the reader surface, which costs two engine passes and would hold the uploader's request open; - the engine read the file and cut **0 chapters** — `400`, `code: invalid_request`, one `errors[]` entry with `pointer: /file` and `code: no_book`. Nothing is admitted: no library row, no directory; - the engine cut **exactly 1 chapter** — `400` the same way with `code: no_chapter_structure`. The delivery would hand the reader one undivided canvas, so the intake refuses what the export cannot serve. ⚠ Temporary: it lifts when chapter structure for delivery is built; - **the deployment cannot answer** (engine not configured, storage unmounted, project schema mismatched, parser unstartable) **or the in-request attempt runs out of budget** — `201` with `status: parsing`, the route's previous behaviour: the queue and the sweep finish it. A deployment fault never refuses the user's file. `uploading` is observable only by a second read of the library while the upload is on the wire. `Location` names the book card. Parsing has no numeric progress; its END arrives on the book's event stream as an ordinary status change. Refusals, the common ones and NOT an exhaustive list — the authority is the response's own `code`, and every code of `ErrorCode` may answer here: `400` unreadable form, missing or late part, over-long part, too many parts, malformed language code, or a pair this deployment cannot translate (`code` and `errors[]` say which) · `404` this deployment takes no books at all (`intake_enabled`) · `408` the body did not finish in time, retry · `413` over `intake_max_bytes`. **`Idempotency-Key` on a multipart body.** "The same request" means the same metadata parts, the same file NAME, and the same file CONTENT. How a server establishes the last of those is its own affair — it need not hold a book in memory, and a digest taken while the bytes stream past costs it nothing — but the guarantee is the one that matters: **a key never answers with a response that belongs to a different request.** A server that cannot establish sameness MUST NOT replay: it answers `409` `idempotency_conflict` rather than the first response. The framing is NOT the request. Two uploads of one file differ in their multipart boundaries and in the length of the body carrying them, and a server that compared either would refuse the honest retry this key exists to serve. Consequence, and it is the case that matters: **a `408` is not a completed attempt**, so the same key may be presented again; the retry is a repeat of the first call, not a second book. parameters: - $ref: '#/components/parameters/ClientHeader' - $ref: '#/components/parameters/IdempotencyKey' requestBody: required: true content: multipart/form-data: schema: { $ref: '#/components/schemas/BookIntake' } responses: '201': description: Book accepted; it is being parsed. headers: Location: required: true description: Address of the book card just created. schema: { type: string, format: uri-reference } content: application/json: schema: { $ref: '#/components/schemas/Book' } '400': { $ref: '#/components/responses/BadRequest' } '401': { $ref: '#/components/responses/Unauthorized' } '403': { $ref: '#/components/responses/Forbidden' } '404': { $ref: '#/components/responses/NotFound' } '408': { $ref: '#/components/responses/RequestTimeout' } '409': { $ref: '#/components/responses/Conflict' } '413': { $ref: '#/components/responses/TooLarge' } /books/{bookId}: parameters: - $ref: '#/components/parameters/BookId' get: tags: [library] operationId: getBook summary: Book card. description: Book metadata plus the current or last run. parameters: - $ref: '#/components/parameters/IfNoneMatch' responses: '200': description: Book card. headers: ETag: { $ref: '#/components/headers/ETag' } content: application/json: schema: { $ref: '#/components/schemas/BookDetail' } '304': { $ref: '#/components/responses/NotModified' } '401': { $ref: '#/components/responses/Unauthorized' } '404': { $ref: '#/components/responses/NotFound' } patch: tags: [library] operationId: updateBook summary: Rename a book. description: | Merge patch (RFC 7386). **Only `title` may be changed.** ⚠ **A title is DISPLAY and reaches nothing else** — not the translation, whose configuration is written once at intake and never rewritten. The language pair is NOT patchable: changing it is a re-translation, not an edit. Accepted while a run is live: a rename touches nothing a run reads. ⚠ **`title: null` is refused with `400`** (`invalid_request`, `errors[]` pointing at `/title`) — and that is a deliberate narrowing of RFC 7386, where `null` means "remove this member". A book without a title is not a state this surface has: the field is required on `Book`, and the intake fills it from the file name when the user gives none. "Remove the title" therefore has no meaning to grant, and answering `400` is honest where silently restoring the derived name would be a rename the user did not ask for. parameters: - $ref: '#/components/parameters/ClientHeader' requestBody: required: true content: application/merge-patch+json: schema: { $ref: '#/components/schemas/BookPatch' } responses: '200': description: Book card after the patch. content: application/json: schema: { $ref: '#/components/schemas/Book' } '400': { $ref: '#/components/responses/BadRequest' } '401': { $ref: '#/components/responses/Unauthorized' } '403': { $ref: '#/components/responses/Forbidden' } '404': { $ref: '#/components/responses/NotFound' } delete: tags: [library] operationId: deleteBook summary: Delete a book. description: | Removes the book and everything derived from it; the physical clean-up is asynchronous and not observable here. `409` while a run is live — stop it first. Irreversible: re-adding the file makes a new book. parameters: - $ref: '#/components/parameters/ClientHeader' responses: '204': description: Book deleted. '401': { $ref: '#/components/responses/Unauthorized' } '403': { $ref: '#/components/responses/Forbidden' } '404': { $ref: '#/components/responses/NotFound' } '409': { $ref: '#/components/responses/Conflict' } /books/{bookId}/chapters: parameters: - $ref: '#/components/parameters/BookId' get: tags: [reading] operationId: listChapters summary: Chapter tree. description: | Chapters in reading order; a book legally has no chapters at all, and then this list is empty. A chapter has NO status of its own, only progress: signing is a single book-wide stop, so "one chapter awaits signing while its neighbour is translated" cannot happen. Page size default: `GET /capabilities`. parameters: - $ref: '#/components/parameters/Limit' - $ref: '#/components/parameters/Cursor' - $ref: '#/components/parameters/IfNoneMatch' responses: '200': description: Chapters of the book. headers: ETag: { $ref: '#/components/headers/ETag' } content: application/json: schema: { $ref: '#/components/schemas/ChapterPage' } '304': { $ref: '#/components/responses/NotModified' } '400': { $ref: '#/components/responses/BadRequest' } '401': { $ref: '#/components/responses/Unauthorized' } '404': { $ref: '#/components/responses/NotFound' } /books/{bookId}/chapters/{chapterId}/units: parameters: - $ref: '#/components/parameters/BookId' - $ref: '#/components/parameters/ChapterId' get: tags: [reading] operationId: listUnits summary: Source/translation pairs of a chapter. description: | The pairs of one chapter, in reading order. **Pairs are read PER CHAPTER and only per chapter** — a book-wide pairs endpoint is never introduced, and the client's whole memory model stands on that. Page size default: `GET /capabilities`. **`410 Gone`** answers a chapter id this book's current structure does not hold: a book cut again leaves the old ids gone rather than absent, and the client re-reads the tree. It is the answer whether or not the id was ever real. Ids here are opaque and are not kept after a re-cut, so "gone" and "never existed" are not two states a server can tell apart without keeping a graveyard of every id it ever minted — and a client's remedy is the same in both cases anyway. `404` stays for the BOOK, which a server does know about. parameters: - $ref: '#/components/parameters/Limit' - $ref: '#/components/parameters/Cursor' - $ref: '#/components/parameters/IfNoneMatch' responses: '200': description: Pairs of the chapter. headers: ETag: { $ref: '#/components/headers/ETag' } content: application/json: schema: { $ref: '#/components/schemas/UnitPage' } '304': { $ref: '#/components/responses/NotModified' } '400': { $ref: '#/components/responses/BadRequest' } '401': { $ref: '#/components/responses/Unauthorized' } '404': { $ref: '#/components/responses/NotFound' } '410': { $ref: '#/components/responses/Gone' } /books/{bookId}/notes: parameters: - $ref: '#/components/parameters/BookId' get: tags: [reading] operationId: listNotes summary: Notes of a book. description: | Notes oldest first, by `created_at`. Ordered by time and not by position in the book so that a note arriving on the stream can be placed into a list the client already holds; a screen that wants reading order sorts against the chapter tree it already has. Ties on `created_at` are broken by the server in a way the client does NOT reproduce — the prohibition on sorting by `Id` stands — and a client placing a streamed note puts it after every note it holds with the same `created_at`. A note addresses a chapter, and usually a pair inside it. Byte offsets do not exist. **Delta read** with `after_version`; without it the whole collection. Page size default: `GET /capabilities` — ⚠ how many notes a real book produces has never been measured, so that default is a guess. parameters: - $ref: '#/components/parameters/Limit' - $ref: '#/components/parameters/Cursor' - $ref: '#/components/parameters/AfterVersion' - $ref: '#/components/parameters/IfNoneMatch' responses: '200': description: Notes of the book. headers: ETag: { $ref: '#/components/headers/ETag' } content: application/json: schema: { $ref: '#/components/schemas/NotePage' } '304': { $ref: '#/components/responses/NotModified' } '400': { $ref: '#/components/responses/BadRequest' } '401': { $ref: '#/components/responses/Unauthorized' } '404': { $ref: '#/components/responses/NotFound' } /books/{bookId}/bank: parameters: - $ref: '#/components/parameters/BookId' get: tags: [bank] operationId: listBankTerms summary: Memory bank of a book. description: | The bank ordered by source surface then by the term's window, so the several rows of one surface stand together. **Signing the bank is ONE act over the whole of it**, not a march through every row: the stop is lifted by `resumeRun` with the corrections as they stand, and a term nobody touched rides on as the service proposed it. Per-term corrections (`POST /books/{bookId}/bank/corrections`) are the OPTIONAL correction path, not the unit of signing. **How many surfaces still await a word is NOT this read's answer.** This deployment cannot count it honestly from the rows it serves; the honest count rides the correction receipt (`signature` on `BankCorrectionsReceipt`), with the warning attached there — and only there: a client with nothing to correct or preview cannot ask for it yet. **Delta read** with `after_version` — a full book's bank is too large to re-read on every change. Page size default: `GET /capabilities`. parameters: - $ref: '#/components/parameters/Limit' - $ref: '#/components/parameters/Cursor' - $ref: '#/components/parameters/AfterVersion' - $ref: '#/components/parameters/IfNoneMatch' responses: '200': description: Bank of the book. headers: ETag: { $ref: '#/components/headers/ETag' } content: application/json: schema: { $ref: '#/components/schemas/BankPage' } '304': { $ref: '#/components/responses/NotModified' } '400': { $ref: '#/components/responses/BadRequest' } '401': { $ref: '#/components/responses/Unauthorized' } '404': { $ref: '#/components/responses/NotFound' } /books/{bookId}/bank/corrections: parameters: - $ref: '#/components/parameters/BookId' post: tags: [bank] operationId: applyBankCorrections summary: Apply term corrections to the memory bank. description: | The per-term half of the bank model: signing is ONE act over the whole bank (`resumeRun`), and what exists per term is a CORRECTION — `approve` a rendering, or `decline` a surface so it stops being proposed. The whole document is applied as one act, **all or nothing**: one refused correction refuses the set (`409`, `code: bank_corrections_refused`), because the refused one is the one the user has to see, and a partial save would hide it behind work that appears saved. **Preview first.** Every request says whether it is a preview: `"preview": true` answers the same receipt without changing anything. A client offers the preview before the save — this surface says what it will do before doing it. **The receipt is not a read.** A correction takes effect on the NEXT run; the bank rows this surface serves (`GET /books/{bookId}/bank`) do not change until a run next rebuilds the bank, no `bank` frame fires, and a client re-reading the bank right after a correction sees it unchanged — correct behaviour, not staleness. What was recorded is in the receipt. **While the book is being translated the bank cannot be corrected**: `409`, `code: run_in_flight`. The service reads its own inputs mid-run, and a correction landing under a live run would enter it unpredictably. Wait for the stop or the end. ⚠ **THERE ARE THREE CEILINGS NOW, NOT TWO — corrected 05.09, and the third is NOT in this schema yet.** The engine refuses a correction whose `src` OR `dst` exceeds **200 runes** or carries a control character, U+2028/2029 or a bidi override (`backend/internal/membank/wirefence.go`, landed `81a89e9`): the row is rejected at the door and at the seed loader, because a user's text used to reach the model's SYSTEM message verbatim. The bound stands on EVERY paid call and this document does not declare it — the `maxLength` on `src`/`dst` arrives with the next contract minor, so the schema is not moved ahead of an agreed mirror. Until then a client learns the bound by having a save refused. **Two ceilings declared here, both hard:** at most **5000** corrections in one act (bound in the schema; a larger set is refused whole — split the document), and at most **1 MiB** of request document (`413`, `code: payload_too_large`). **A failed write answers `503`, `code: bank_corrections_incomplete`: the document was ACCEPTED and did not land whole. The remedy is to re-send the SAME document** — the retry converges: what already landed is recognised, not duplicated, and a fully-landed document answers `200` with `changed: false`. ⚠ **Declared ahead of its serving half.** A deployment that has not mounted this door says so — `Capabilities.bank_corrections_enabled: false` — and answers `404` here. A client checks the flag before offering the correction UI rather than discovering the absence by failing a user's save. parameters: - $ref: '#/components/parameters/ClientHeader' requestBody: required: true content: application/json: schema: { $ref: '#/components/schemas/BankCorrectionsRequest' } responses: '200': description: | The receipt — for a preview, what WOULD happen; otherwise what happened. `200` means the whole document was accepted and, unless a preview, landed durably. content: application/json: schema: { $ref: '#/components/schemas/BankCorrectionsReceipt' } '400': { $ref: '#/components/responses/BadRequest' } '401': { $ref: '#/components/responses/Unauthorized' } '403': { $ref: '#/components/responses/Forbidden' } '404': { $ref: '#/components/responses/NotFound' } '409': { $ref: '#/components/responses/Conflict' } '413': { $ref: '#/components/responses/TooLarge' } '503': { $ref: '#/components/responses/ServiceUnavailable' } /books/{bookId}/run-options: parameters: - $ref: '#/components/parameters/BookId' get: tags: [runs] operationId: getRunOptions summary: Bounds for starting a run. description: | Bounds of the run-limit scale, read right before a run starts, plus why the scale is smaller than expected when it is. Its own resource and not a field of the book card: the maximum depends on the ACCOUNT and moves while the book does not, so a cached card would state a maximum that is no longer true exactly while the user drags the scale. responses: '200': description: Bounds of the scale. content: application/json: schema: { $ref: '#/components/schemas/RunOptions' } '401': { $ref: '#/components/responses/Unauthorized' } '404': { $ref: '#/components/responses/NotFound' } '409': $ref: '#/components/responses/Conflict' description: | The book carries no price projection yet, so no options can be quoted — `cause: not_priced`. ⚠ **Added in 0.11.0, and it makes this operation able to REFUSE for the first time.** A GET that could not fail before now can: a client generated against 0.10.0 meets a code its stubs do not carry. The refusal is not an error of the request — it says the engine has not published this book's projection, and the reading surface owes it. /books/{bookId}/runs: parameters: - $ref: '#/components/parameters/BookId' post: tags: [runs] operationId: startRun summary: Start a translation run. description: | `stop_for_signing` and `ceiling_chapters` are parameters of the RUN, not of the book: they travel with the start and do not outlive it. With `stop_for_signing` the run pauses once, book-wide, for the user to LOOK at the bank — correct what they care to, then release it with ONE `resume`; without it the run does not stop and uses the bank as it stands. **409** answers a limit that no longer fits — the bounds are read by `GET /books/{bookId}/run-options` and may move in between. When another book's hold is the cause the error carries `blocked`, naming that book. **Raising the limit of a stopped run is done by starting a NEW run** with a larger `ceiling_chapters`: a paused book is startable, finished work is not bought twice, and the new run continues where the old stopped. `resume` does not do this. ⚠ **"Not bought twice" bounds THIS path, not the whole surface.** A re-pass (`RunRequest.re_pass`) deliberately returns to finished chapters — but it buys the DELIVERY of a correction into them, not the work again: what the correction does not touch is re-used at no cost, and only what it does touch is paid for. A book that is finished has no other way forward, which is what the member exists for. parameters: - $ref: '#/components/parameters/ClientHeader' - $ref: '#/components/parameters/IdempotencyKey' requestBody: required: true content: application/json: schema: { $ref: '#/components/schemas/RunRequest' } responses: '202': description: Run accepted. content: application/json: schema: { $ref: '#/components/schemas/Run' } '400': { $ref: '#/components/responses/BadRequest' } '401': { $ref: '#/components/responses/Unauthorized' } '403': { $ref: '#/components/responses/Forbidden' } '404': { $ref: '#/components/responses/NotFound' } '409': { $ref: '#/components/responses/Conflict' } '503': { $ref: '#/components/responses/ServiceUnavailable' } /books/{bookId}/events: parameters: - $ref: '#/components/parameters/BookId' - $ref: '#/components/parameters/LastEventId' get: tags: [runs] operationId: streamBookEvents summary: Live events of a book (SSE). description: | `text/event-stream`, on the BOOK and not on a run: a book is received and cut into chapters before any run exists, and those minutes are what a user watches. Events are PUSHED — the read-model is not polled to DISCOVER a change; it is still read on a frame, on navigation and on focus, and conditional reads make that cheap. The server MUST NOT buffer the stream. A heartbeat goes out about every 20 s as an SSE comment line (`:` and a newline), invisible to a browser `EventSource`. **The first frame is always `hello`.** A client closes the stream and tells the user when the version it was generated against is not the one served — while the major is `0` that means ANY difference, minor included. **What a frame carries.** Either a delta the client can APPLY, or a counter plus the SCOPE of what changed (entity id and the revision to read from). A frame MUST NOT be a bare "something changed" that leaves re-reading a whole collection as the only way to find out what. Frames never carry translated text. **Coalescing.** A state frame (`status`, `progress`, `chapter`, `bank`) MAY be replaced by a later one of its kind, and a client MUST tolerate counters that jump. `note` is an ADDITION: it MUST NOT be coalesced or dropped — a lost one is lost silently and forever. ⚠ **That guarantee covers one connection, not a gap between two.** Nothing is buffered for a client that is not attached, the live buffer above is not promised, and a `resync_required` is not owed for an ordinary reconnect — so a note raised while the client was away can be missing from the stream. **After EVERY reconnect the client MUST close that gap with a delta read** — `GET /books/{bookId}/notes?after_version=` — before trusting its list. The same read repairs any state frame lost with it, and costs one request against a validator when nothing changed. **Reconnect.** The client sends `Last-Event-ID`. The server MAY resend frames it still holds in a short live buffer after that id and MUST NOT replay history beyond it; the buffer's size is not declared and a client MUST NOT depend on any frame being resent. If the server cannot resume from the id it sends `resync_required` rather than silently starting from now. **End of stream.** With no run live and no intake in flight the server sends `end` and closes. A request presenting a `Last-Event-ID` at or past the book's last frame, while the book is at rest, is answered `204` — which is how SSE is told to stop reconnecting. A request WITHOUT `Last-Event-ID` always opens a new stream. ⚠ **A book at rest must not become an endless open-and-close.** Every frame carries an `id`, `hello` included — `0` on a book that has produced no history yet (`EventEnvelope.id`) — so a client always has something to present on the reconnect a browser makes by itself after `end`, and that reconnect is the one answered `204`. The sequence is therefore bounded: `hello` → `end` → close → one reconnect → `204` → the browser stops. A server that omitted the id on `hello` would leave the client with nothing to send and reopen the stream forever. One stream per book being watched; there is no library-wide stream, and a list screen does NOT open one per row. A `chapter` frame for a chapter the client does not hold is IGNORED — a frame is never a reason to page a collection. A deleted book ends its stream, and a reconnect is answered `404`. OpenAPI does not type SSE frames; the event → payload mapping is the table on `EventEnvelope`. responses: '200': description: Event stream. content: text/event-stream: schema: { $ref: '#/components/schemas/EventEnvelope' } '204': description: | The presented `Last-Event-ID` is at or past the last frame this book has produced, and the book is at rest. The client MUST NOT reconnect automatically; it opens a new stream, without `Last-Event-ID`, when it has a reason to watch again. '401': { $ref: '#/components/responses/Unauthorized' } '404': { $ref: '#/components/responses/NotFound' } /runs/{runId}: parameters: - $ref: '#/components/parameters/RunId' get: tags: [runs] operationId: getRun summary: State of a run. description: | The run as it stands, for a client that lost the body of a `202`. responses: '200': description: The run. content: application/json: schema: { $ref: '#/components/schemas/Run' } '401': { $ref: '#/components/responses/Unauthorized' } '404': { $ref: '#/components/responses/NotFound' } /runs/{runId}/stop: parameters: - $ref: '#/components/parameters/RunId' post: tags: [runs] operationId: stopRun summary: Stop a run. description: | The product "stop" action; finished work is kept and not paid for again. **The `202` does not mean the run has stopped** — the `Run` it returns still carries a live status. Stopping is asynchronous and there is no `stopping` value in `RunStatus`: the run reaches `stopped` when the work winds down, and the `status` frame says so. This call is what SETS `stop_requested`, and the `Run` in this `202` already carries it `true` — so the state between the two survives a reload instead of living only in the client. `409` answers a run that is not running at all. parameters: - $ref: '#/components/parameters/ClientHeader' responses: '202': description: Stop accepted. content: application/json: schema: { $ref: '#/components/schemas/Run' } '401': { $ref: '#/components/responses/Unauthorized' } '403': { $ref: '#/components/responses/Forbidden' } '404': { $ref: '#/components/responses/NotFound' } '409': { $ref: '#/components/responses/Conflict' } /runs/{runId}/resume: parameters: - $ref: '#/components/parameters/RunId' post: tags: [runs] operationId: resumeRun summary: Continue a stopped run. description: | Clears the bank-signing stop and continues after a stop the user asked for. **What it does in every other state, so a client never has to guess which stop it is looking at:** | the run stands in | this call answers | the remedy a client offers | |---|---|---| | `awaiting_bank` — with ANY state of decisions | continues it — `202` | this call | | `paused` — ANY reason, `null` included | `409` `run_not_resumable`, `cause.code: ceiling_reached` | a NEW run with a larger `ceiling_chapters` | | `stopped` — the user's own stop | continues it — `202` | this call | | `failed`, `ready` | `409` `run_not_resumable`, no `cause` — it is not a stopped run | a NEW run | | `translating` | `409` `run_not_resumable`, no `cause` | nothing; it is already running | ⚠ **The limit of the run is a SECOND axis, and it overrides the two `202` rows above.** A run that has spent everything its `ceiling_chapters` bought has nothing left to continue WITH — there is no work this call could pay for — and that is true whether it stands in `awaiting_bank` or in `stopped`, and however much credit the account holds. Such a call is `409` `run_not_resumable`, `cause.code: ceiling_reached`, exactly as `paused` is, and the remedy is the same: a NEW run. **A `202` therefore means work was actually reopened**, and a client may render it as "continuing" without a second read. **The account is a THIRD case, and it is answered separately.** Continuing costs money, so a run with room left in its limit still cannot be reopened while the account cannot cover the rest of it: that is `409` `run_not_resumable`, `cause.code: credit_unavailable`, and the remedy is to top up rather than to start anything. It is deliberately not folded into `ceiling_reached` — that one says "this run is finished with", and saying it to a user whose run has chapters left would send them to buy a new run they do not need. The reverse mistake is the one the canon already warns about at `AccountHaltReason`: an account-wide state lit from a fact about one run tells a user with money that they have none. Like the second axis, this one is judged AFTER the table and only where the table said `202`: a row that already answers `409` keeps the `cause` the table gives it, so a `paused` run on an empty account is still `ceiling_reached` — the remedy there is a new run either way, and the account's own emptiness is what `Usage` is for. ⚠ **A NEW run is legal from ANY paused book, whatever `paused_reason` says — `null` included.** The limit travels with the START of a run and nothing changes it afterwards, so a run stopped at one is never continued by this call; the reason is a hint about WHY the work stopped, never a gate on what may be started next. The book is startable, `POST /books/{bookId}/runs` takes a larger `ceiling_chapters`, and finished work is not bought again. A client that waits for a particular reason value before offering that strands the user on the commonest stop there is. That legality is not a promise of progress: a deployment may hold limits of its OWN that no purchase lifts and this contract has no word for, and a new run under one stops the same way it did before. The remedy a client offers is still the new run — it has no better one — but it does not present it as a guarantee, and a `paused` run that returns immediately to `paused` is a legal outcome rather than a fault to report. **503 answers a deployment that cannot run at all** — continuing a run is starting a process. parameters: - $ref: '#/components/parameters/ClientHeader' responses: '202': description: Resume accepted. content: application/json: schema: { $ref: '#/components/schemas/Run' } '401': { $ref: '#/components/responses/Unauthorized' } '403': { $ref: '#/components/responses/Forbidden' } '404': { $ref: '#/components/responses/NotFound' } '409': { $ref: '#/components/responses/Conflict' } '503': { $ref: '#/components/responses/ServiceUnavailable' } /usage: get: tags: [account] operationId: getUsage summary: State of the credit balance. description: | Credits are a BALANCE, not a subscription with windows: no period, no `resets_at`, no "resets in" — the screen shows what is LEFT. ⚠ **«A percentage, never an amount» is RETIRED (owner, 05.09).** The account's balance is money and is shown as money; a percentage answers «is it running out», never «will it cover this order», and the order screen asks the second question. The AMOUNT field lands with the pack that builds the order form — this schema is not changed ahead of it, so today the percentage is still all this endpoint returns. What remains banned is the cost of models, stages and calls. responses: '200': description: Balance state. content: application/json: schema: { $ref: '#/components/schemas/Usage' } '401': { $ref: '#/components/responses/Unauthorized' } /books/{bookId}/exports: parameters: - $ref: '#/components/parameters/BookId' - $ref: '#/components/parameters/IdempotencyKey' post: tags: [export] operationId: createExport summary: Build a book export. description: | Formats: `GET /capabilities`; one outside that set is `400`. Any book already cut into chapters may be exported, finished or not — what an export of an unfinished book CONTAINS is not fixed here. The export is an ARTIFACT BEHIND A LINK; assembling a book's text on the client is forbidden explicitly, as it would defeat the per-chapter working set the read paths are built around. Completion is POLLED: the `202` names the status resource in `Location` and the status read carries `Retry-After` while the build runs. Repeating with the same `Idempotency-Key` returns the original `202` and `Location` rather than building a second copy. **The `409` here is about the key, not about the book** — `idempotency_conflict`, when a key is re-used for a different request or while the first is still running. Nothing about a book's state conflicts with exporting it: a book still being translated may be exported, and a second export under a different key is a second artifact, not a conflict. parameters: - $ref: '#/components/parameters/ClientHeader' requestBody: required: true content: application/json: schema: { $ref: '#/components/schemas/ExportRequest' } responses: '202': description: Export is being prepared. headers: Location: required: true description: Address of the status resource for this export. schema: { type: string, format: uri-reference } content: application/json: schema: { $ref: '#/components/schemas/Export' } '400': { $ref: '#/components/responses/BadRequest' } '401': { $ref: '#/components/responses/Unauthorized' } '403': { $ref: '#/components/responses/Forbidden' } '404': { $ref: '#/components/responses/NotFound' } '409': { $ref: '#/components/responses/Conflict' } /books/{bookId}/exports/{exportId}: parameters: - $ref: '#/components/parameters/BookId' - $ref: '#/components/parameters/ExportId' get: tags: [export] operationId: getExport summary: State of an export. description: | Without this read the creating call is a dead end. **Completion is polled, not pushed.** **Every poll ends.** `pending` → `ready` → `expired`, or `pending` → `failed`. A poll stops at `ready`, `failed` or `expired`; only `ready` can still change afterwards, and only into `expired`, which no client is obliged to watch for. responses: '200': description: State of the export. headers: Retry-After: description: | Seconds to wait before polling again; sent while `state` is `pending`, and only then. Declared here because RFC 9110 does not define this header for a `200`. schema: { type: integer, minimum: 0 } content: application/json: schema: { $ref: '#/components/schemas/Export' } '401': { $ref: '#/components/responses/Unauthorized' } '404': { $ref: '#/components/responses/NotFound' } /books/{bookId}/exports/{exportId}/content: parameters: - $ref: '#/components/parameters/BookId' - $ref: '#/components/parameters/ExportId' get: tags: [export] operationId: downloadExport summary: Download the built file. description: | The address `Export.url` names. Given an `operationId` in 0.10.0 because until then this was the one surface of the door described in prose only: a generated client did not know the address a browser actually NAVIGATES to. **Range requests are answered**, so a download of tens of megabytes survives an interruption instead of starting over. The body is `application/octet-stream` and deliberately not a per-format media type: the formats are a DEPLOYMENT's declaration, so a table here would be a second list to keep in step with the engine — and it would be wrong for exactly the format nobody remembered to add. The file NAME carries the extension, which is what a browser and an operating system dispatch on. responses: '200': description: The file. headers: Content-Disposition: description: | Carries the file name a reader sees — the book's TITLE, because that is what a library is sorted by, and the export id only when the title is unusable. Declared here because a client that ignores it saves the file under an opaque identifier. schema: { type: string, minLength: 1 } content: application/octet-stream: schema: { type: string, format: binary } '206': description: | The requested part of the file, in answer to a `Range` request. content: application/octet-stream: schema: { type: string, format: binary } '401': { $ref: '#/components/responses/Unauthorized' } '404': $ref: '#/components/responses/NotFound' '410': { $ref: '#/components/responses/Gone' } components: securitySchemes: sessionCookie: type: apiKey in: cookie name: __Host-tm_session description: | Browser presentation of one server-side session: HttpOnly, Secure, SameSite=Lax. **CSRF.** On the cookie path a client MUST send `X-TM-Client` on every UNSAFE request — anything other than `GET`, `HEAD` and `OPTIONS`. What protects is the PRESENCE of the header; the value is arbitrary and has no token semantics. Required on same-origin requests too: it is not a CORS mechanism. It is declared as a parameter on every operation that needs it, so a generated client sends it. A request presenting a well-formed `Authorization: Bearer` instead of the cookie is exempt. ⚠ **This protection stands on there being no cross-origin access** (see Transport). The day that changes, this section is rewritten rather than re-configured. The same requirement holds for the session-mechanics endpoints outside the version prefix (companion). bearerToken: type: http scheme: bearer description: | A server-side session as an opaque token, for a client that is not a browser. The principal is established in middleware only; no endpoint may assume a cookie. ⚠ **A token IS issued now, and NOT by an endpoint** (corrected 05.09; the earlier text said "nothing issues such a token today" and stopped being true when the operator command landed). It is minted OUT OF BAND by the operator — `tmplatformctl token issue --user … --client …` — and the session it creates is an ordinary one: it appears in the account's login journal under the operator provider, and `POST /auth/logout` revokes it like any other. No call in this document hands one out, and that is deliberate: a door that issues its own credentials over HTTP is a different threat model and would be ratified separately, not added here. headers: RequestId: description: | Identifier of this request, present on EVERY response. The same value is `request_id` inside an error body. schema: { type: string, minLength: 1 } ETag: description: | Validator of this representation; a client sends it back in `If-None-Match`. Weak validators are allowed. Bound to the WHOLE request, query string included: page two of a collection and a delta read of it carry different validators. schema: { type: string, minLength: 1 } parameters: BookId: name: bookId in: path required: true description: Opaque book identifier. schema: { $ref: '#/components/schemas/Id' } ChapterId: name: chapterId in: path required: true description: Opaque chapter identifier. schema: { $ref: '#/components/schemas/Id' } RunId: name: runId in: path required: true description: Opaque run identifier. schema: { $ref: '#/components/schemas/Id' } ExportId: name: exportId in: path required: true description: Opaque export identifier. schema: { $ref: '#/components/schemas/Id' } ClientHeader: name: X-TM-Client in: header required: true description: | Present on every unsafe request presented by session cookie; the value is arbitrary (see the `sessionCookie` scheme). Absent, such a request is `403` with `code: forbidden`. ⚠ **Declared `required: true` although one class of client is exempt.** The rule is "required on the COOKIE path", and OpenAPI has no way to make a parameter conditional on which security scheme was used. Declaring it required is the choice that makes a generated browser client send it; a client presenting `Authorization: Bearer` legally omits it, and a validator checking requests against this document must not read that omission as a violation. The server enforces the real rule: the header is demanded only of a request carrying the session cookie. schema: { type: string, minLength: 1 } IdempotencyKey: name: Idempotency-Key in: header required: false description: | Makes this call safe to retry. Semantics, all the server's duty: - scoped to (principal, method, path); the same key on another operation is another key; - a repeat with the same key and the same request returns the ORIGINAL response and does no new work; - a repeat with the same key and a DIFFERENT request is `409`, `cause.code: key_reused`; - a repeat while the first is still in flight is `409`, `cause.code: key_in_flight`; retry after `Retry-After`; - the record is kept at least 24 hours, then the key is forgotten; - a key over 255 characters is `400`. Omitting the header is legal and means no retry protection. schema: { type: string, minLength: 1, maxLength: 255 } LastEventId: name: Last-Event-ID in: header required: false description: | The `id` of the last frame the client applied, sent on a RECONNECT. See `streamBookEvents`. schema: { type: string, pattern: '^[0-9]+$' } IfNoneMatch: name: If-None-Match in: header required: false description: | Validator the client already holds, from an earlier `ETag`. Unchanged, the answer is `304` with no body. schema: { type: string, minLength: 1 } AfterVersion: name: after_version in: query required: false description: | Delta read: the rows changed at or after this revision, in the same order and envelope as a full read. The value is a `revision` the client has already applied. **INCLUSIVE**, for the same reason catch-up reads `>=` (see `Revision`): one transaction is one revision but several ROWS. Re-reading a row already held costs nothing — a row is replaced by its `id`. **The watermark for the next delta read is the revision of the WALK just completed** — the lowest envelope revision seen across its pages, which for a single-page answer is that page's own (`Revision`). Rows carry no version of their own. A DELETION cannot be expressed this way. Two answers close that: `resync_required` on the stream when a collection is replaced wholesale, and `400` with `cause.code: version_too_old` for a watermark that predates such a replacement. ⚠ **A row already delivered is therefore never withdrawn ONE AT A TIME.** A delta read has no shape for "this one is gone", so a row that stops belonging to its collection would sit on the client for as long as the tab is open — and a count beside it would already disagree, which is the visible half of the same fault. A collection that must lose a row loses it the only way this contract can express: replaced wholesale, announced with `resync_required`. A server that finds itself retracting a single row owes that frame; it does not simply stop sending it. schema: { $ref: '#/components/schemas/Revision' } Limit: name: limit in: query required: false description: | Page size; the default is the deployment's (`GET /capabilities`). A server MAY return fewer rows than asked for, and the client decides nothing from that — only from `next_cursor`. The `maximum` below bounds what a CLIENT may ask for. A server that receives more MUST clamp down to it and answer, and MUST NOT refuse the request — answering the default instead is what made "ask for more, get fewer rows than a smaller request" discoverable only by experiment. A deployment that validates this parameter against the schema has to exempt it from rejection. schema: { type: integer, minimum: 1, maximum: 1000 } Cursor: name: cursor in: query required: false description: | Keyset cursor from `next_cursor` of the previous page. Opaque: the client MUST NOT parse, compare or construct it. Omitted for the first page. A cursor that no longer applies is `400` with `cause.code: cursor_invalid`; see `NextCursor`. schema: { type: string, minLength: 1 } responses: NotModified: description: | The client's copy is still current. No body; the `ETag` it presented stays valid. BadRequest: description: Request rejected. headers: X-Request-Id: { $ref: '#/components/headers/RequestId' } content: application/problem+json: schema: { $ref: '#/components/schemas/Problem' } Unauthorized: description: Session missing or invalid. headers: X-Request-Id: { $ref: '#/components/headers/RequestId' } WWW-Authenticate: required: true description: | The challenge for this resource, as RFC 9110 §15.5.2 requires of every `401`. A client holding no session sends the user to the sign-in flow rather than parsing this. schema: { type: string, minLength: 1 } content: application/problem+json: schema: { $ref: '#/components/schemas/Problem' } Forbidden: description: | Refused before authorization: the marker header of the `sessionCookie` scheme was missing on a cookie-presented request, or the request came from an origin this deployment does not accept. Not a decision about the object — those are `404`. headers: X-Request-Id: { $ref: '#/components/headers/RequestId' } content: application/problem+json: schema: { $ref: '#/components/schemas/Problem' } NotFound: description: | No such object, or one this account may not see, or a path this deployment does not serve — deliberately one answer: neither another account's library nor the shape of this instance is public information. headers: X-Request-Id: { $ref: '#/components/headers/RequestId' } content: application/problem+json: schema: { $ref: '#/components/schemas/Problem' } Conflict: description: Action impossible in the current state. headers: X-Request-Id: { $ref: '#/components/headers/RequestId' } Retry-After: description: | Seconds to wait before repeating, sent when waiting is the remedy — today `idempotency_conflict` with `cause.code: key_in_flight`. Absent otherwise, and a client that sees no header does not invent a delay. schema: { type: integer, minimum: 0 } content: application/problem+json: schema: { $ref: '#/components/schemas/Problem' } Gone: description: | The identifier does not belong to the current structure of this book and will not be reissued. A client holding a reference re-reads the collection it came from. It is also the answer to an identifier that never existed, on a collection whose identifiers a server cannot prove it never minted — the operation says which. headers: X-Request-Id: { $ref: '#/components/headers/RequestId' } content: application/problem+json: schema: { $ref: '#/components/schemas/Problem' } TooLarge: description: | Body over the bound the operation declares: the intake cap (`intake_max_bytes` of `GET /capabilities`) on the intake, 1 MiB on bank corrections. headers: X-Request-Id: { $ref: '#/components/headers/RequestId' } content: application/problem+json: schema: { $ref: '#/components/schemas/Problem' } RequestTimeout: description: | The body did not arrive whole inside the route's deadline: a slow client on a large book. RETRY is the remedy, which is what separates it from `413`. headers: X-Request-Id: { $ref: '#/components/headers/RequestId' } content: application/problem+json: schema: { $ref: '#/components/schemas/Problem' } ServiceUnavailable: description: | The deployment cannot do this right now: starting or continuing a run needs its machinery fully configured. Temporary — retry later; no `Retry-After` is promised. On bank corrections it can instead carry `bank_corrections_incomplete`, whose remedy is specific: re-send the SAME document. Dispatch on `code`, as everywhere. headers: X-Request-Id: { $ref: '#/components/headers/RequestId' } content: application/problem+json: schema: { $ref: '#/components/schemas/Problem' } schemas: Id: type: string minLength: 1 description: | Opaque identifier. The client MUST NOT parse, sort by or construct it. **Stability differs by what the identifier names:** - book, run, export — kept for as long as the object exists; - chapter — survives a re-parse of the same source, because a chapter is identified by its own text. It does not survive that text changing; - PAIR (`Unit.id`) — stable only within one `structure_version`. Cutting the book differently mints a new id for every pair while the chapters survive, so a stored anchor on a pair is invalid the moment the version moves, and the client re-reads the chapter rather than reporting the pair as deleted. examples: ['bk_7c1'] Revision: type: integer minimum: 0 description: | Monotonic revision. **The counter is PER BOOK:** every book-scoped read and every frame of that book carry the same number. The library has its own scope, and a revision is never compared across scopes. **One number per ENVELOPE.** A response carries the revision at which the server built THAT response; a frame carries it inside `data`. `BookDetail.revision` is the revision of the envelope, and `BookDetail.book.revision` is the same number — the card is built in one transaction with the book it carries, and a client may use either. **Discarding a stale read is the CLIENT's duty**: a read whose revision is lower than what it has already applied MUST be dropped rather than rendered, or the interface rolls progress backwards on every refetch. The comparison is per SCOPE and per COLLECTION — never against the library's, never across two collections of one book, and an export poll is never dropped for carrying an older number than the chapter tree. **A list read across several pages is torn**, and ONE number stands for the whole walk: the LOWEST revision seen across its pages. Stamped with the newest, a list whose head predates an applied frame would pass the guard above and overwrite it. That single number is also **the watermark for the next delta read** of that collection. Taking the newest envelope of the walk instead would skip every row changed between the first page and the last; taking the lowest re-reads a few rows, which costs nothing because a row is replaced by its `id` (`after_version`). A one-page read is the same rule with one page in the walk. **Catch-up after a reconnect reads `revision >= R`, not `> R`**: one transaction is one revision but SEVERAL frames. After a FULL REPLACEMENT — the bank rebuilt, re-cutting replacing the chapters — the server MUST answer `resync_required` rather than a delta: a delta cannot express a deletion. examples: [1841] StructureVersion: type: integer minimum: 0 description: | Generation of the book's structure: which chapters exist and where their boundaries fall. It moves when the book is cut again. A cursor is bound to it, pair identifiers are bound to it (`Id`), and a term's chapter window is expressed in its coordinates (`BankTerm`). Distinct from `Revision`, which moves on every materialization: binding pagination or an anchor to that would restart them constantly. examples: [3] ShapeEpoch: type: integer minimum: 0 description: | Generation of the book's COUNT. It moves when the deployment changes what "finished" means for a chapter — the one event besides re-cutting that legitimately recomputes `chapters_done` and `chapter_count`'s remainder. OPAQUE, and a client must treat it as such: an integer to compare with the last one it saw, never a fact to interpret. It says only THAT the generation changed, never how the book is translated — what a deployment does to a chapter is not part of this contract, and a member that leaked it would be a third exception to a boundary that has two (see **Boundaries**: there are two, and there is no third). A larger value is not a breaking change and needs no version of its own. What a client MUST do when it sees a new value: re-read the book's counters. A jump in `chapters_done` across a shape epoch is the count being recomputed on a legitimate boundary, not a server walking a counter backwards — the same relationship `structure_version` has with a re-cut. Distinct from `StructureVersion`, which is about which chapters EXIST: a shape epoch moves no chapter boundary, so it invalidates no cursor, no pair identifier and no term window. examples: [1] NextCursor: type: [string, 'null'] description: | Cursor of the NEXT page, or `null` on the last one. Present on EVERY list response — introducing it later would silently cut the tail off a client that does not read the field. Bound to the `structure_version` of the collection and NOT to the book's revision: the revision bumps on every materialization, which would restart pagination forever. Rejecting a cursor from a structure that no longer exists is the SERVER's duty (MUST), answered `400` with `cause.code: cursor_invalid`. The client cannot: the cursor is opaque to it. LangCode: type: string pattern: '^[a-z]{2,3}(-[A-Za-z0-9]{2,8})*$' description: | Language code, never a name; the display name is the client's to render. Well-formed is not the same as supported — `GET /capabilities` names the pairs this deployment can run, and one outside that set is refused at intake. examples: ['zh'] Capabilities: type: object description: | What this deployment can do. One flat document, the same for every account. required: - contract_version - language_pairs - intake_enabled - intake_max_bytes - export_formats - bank_corrections_enabled - page_size_default properties: contract_version: type: string description: | The version this deployment serves — the only place a non-streaming client learns it. A client generated against a different one REFUSES to work and says so: while the major is `0` a differing minor carries breaking changes by design. examples: ['0.9.0'] language_pairs: type: array description: | Every pair the deployment knows about, unavailable ones included: "absent" and "listed as unavailable" are different facts to a user waiting for one. items: { $ref: '#/components/schemas/LanguagePair' } intake_enabled: type: boolean description: | Whether this deployment takes books at all. `false` is a read-only instance that serves a library and answers `404` to `POST /books`. Without it a client discovers this only by spending a user's upload. intake_max_bytes: type: integer minimum: 1 description: | Largest file this deployment accepts, when it accepts any. A client checks it before starting an upload; the server enforces it regardless and answers `413`. export_formats: type: array description: | Formats `POST /books/{bookId}/exports` accepts. Empty means none are built here. items: { type: string, minLength: 1 } bank_corrections_enabled: type: boolean description: | Whether this deployment serves `POST /books/{bookId}/bank/corrections`. `false` means the door is declared by this contract and not mounted here: the path answers `404`, and a client does not offer the correction UI. Machine-readable so a client learns it here — not by failing a user's save against a promised door. page_size_default: type: integer minimum: 1 maximum: 1000 description: | Rows a collection returns when `limit` is omitted. Never larger than the maximum a client may ASK for, which is why it carries the same bound; that maximum is defined once, on the `limit` parameter, and this is its consequence rather than a second copy. LanguagePair: type: object description: A translation direction this deployment knows about. required: [source, target, state] properties: source: { $ref: '#/components/schemas/LangCode' } target: { $ref: '#/components/schemas/LangCode' } state: type: string description: | `available` — books in this pair can be translated here · `unavailable` — the deployment knows the pair and cannot run it. enum: [available, unavailable] Page: type: object description: | The envelope every collection answers in, composed into each list so that "every page carries a revision and a next cursor" is checkable rather than merely observed. required: [revision, next_cursor] properties: revision: { $ref: '#/components/schemas/Revision' } next_cursor: { $ref: '#/components/schemas/NextCursor' } Progress: type: object description: | How far THIS RUN has got through the whole of its own work, as ONE monotonic fraction. ⚠ **THE UNIT FOLLOWS HOW THE ORDER WAS PHRASED, and it is read off `Run.ordered_chapters` and `Run.ordered_units` — the pair that describes the ORDER — in this order, first match wins:** 1. **`ordered_chapters == 0` ⇒ a RE-PASS.** `total` is `1` and `done` is `0` until it finishes clean, then `1`: a declared shape, not a count of anything. Do not render it as "0 of 1 chapters". Held by `pgstore.TestARePassRunsBarIsOneUnitOfWork`, and its EXCLUSIVITY — that nothing else can put a zero here — by `runs.TestABookWithNothingLeftIsRefusedRatherThanSoldAsAZeroChapterRun`: a whole-book order over a fully delivered book quotes zero chapters, and the service refuses it rather than admitting a run that would wear the re-pass's shape without being one. 2. **`ordered_chapters == null` ⇒ the order was placed in CHARACTERS**, and the counters are the engine's own shipping units — the same `Unit` this contract already carries. The size bought stands in `ordered_units`. Held by `runs.TestACharacterOrdersBarIsCountedInWhatItActuallyBought` and `runs.TestACharacterOrdersVolumeIsPublishedInTheUnitItWasSoldIn`. 3. **Otherwise (`ordered_chapters >= 1`) ⇒ the counters are CHAPTERS.** Held by `runs.TestAChapterOrderKeepsTheBarItAlwaysHad`. ⛔ **THE ORDER OF THOSE CHECKS IS LOAD-BEARING, and `Run.delivered_chapters` is NOT the discriminator** — an earlier edition of this paragraph said it was, and it was wrong in the one case that matters. A re-pass answers `delivered_chapters: 0`, which is a NUMBER, so the rule "a number means chapters" sends a client to case 3 and it draws the very "0 of 1 chapters" this paragraph forbids. `delivered_chapters: null` does mark case 2 — it is derived from the same fact — but it cannot separate case 1 from case 3, so a client that starts there gets a re-pass wrong. Measured, not reasoned: the re-pass row of `pgstore.TestARePassRunsBarIsOneUnitOfWork` asserts `delivered_chapters: 0` beside `ordered_chapters: 0`. ⚠ **Do not key on `stage` either.** `stage: re_pass` is true of case 1 today, but `stage` is an OPEN vocabulary whose new values move no version — a client owes it degradation, not decisions. It corroborates; it does not discriminate. **Do not render a unit noun without running the three checks** — "3 of 10 chapters" drawn over units is a lie the client tells on our behalf. A client that draws a PERCENTAGE needs none of this: the fraction is meaningful in every case, which is why no ready-made percentage is shipped. A run may make more than one pass over the chapters it bought, and it may stop part-way for the book's terms to be signed; the counter spans all of that. **It never restarts from zero** — not when a signing stop is cleared, not when one pass gives way to the next. The counters only grow and nothing under them moves. **`total` is what this run BOUGHT**, counted across the passes this run actually owes — not the length of the book. The book-wide figure is `Book.chapters_done` against `Book.chapter_count` and answers a different question. ⚠ **A run that ends early stops short of `total`** — at its ceiling, stopped by the user, or failed. Reaching one is the shape of a COMPLETED purchase, not a promise about every run, and a fraction below one is not an error to render. ⚠ **A RE-PASS run counts ONE INDIVISIBLE unit of work: `total` is `1`, and `done` is `0` until it finishes clean, then `1`.** This is a declared shape, not chapters — do not render it as "0 of 1 chapters". The reason is a property of the service, named rather than hidden: it announces a piece of work ONCE over a book's life, so a re-pass, which re-does work already announced, produces no new announcements to count. A finer-looking bar would be a bar that never moves, and a bar that never moves is worse than an honest one that moves once. ⚠ **"Finished" means the work THIS deployment does on a chapter is finished** — the last pass the book actually gets, whatever that is. No ready-made percentage is shipped: how a fraction is drawn is a product decision. required: [done, total, stage, eta_seconds] properties: done: type: integer minimum: 0 description: Chapters of this run's own work that are finished. total: type: integer minimum: 0 description: Chapters of work this run bought, counted across the passes it owes. stage: type: string description: | What the run is doing NOW — the caption beside the bar, phrased by the client. **The vocabulary is OPEN, and a client MUST render a value it does not know neutrally** — the bar alone, or a generic phrase — rather than failing, hiding the run, or guessing. Today's deployments answer `drafting`, `editing` or `re_pass`; other values are not a breaking change and do not raise this version. ⚠ Derived by the platform from the same counters as `done` and `total`, so the caption and the bar cannot disagree with each other. It is never an identifier forwarded from inside the translation service — see the bound on this exception under **Boundaries**. ⚠ **It therefore describes the platform's model of the run's work, not a report from the service**, which is neither asked nor answers. Where the two diverge, the caption is the approximation and the finished text is the fact. eta_seconds: type: [integer, 'null'] minimum: 0 description: | Estimated seconds to the end of this run's work, or `null` when there is nothing to estimate from. The screen renders without it rather than showing a zero. BookStatus: type: string description: | Product status of a book: `uploading` file is being accepted · `parsing` being cut into chapters · `not_started` cut, never run · `translating` translation in progress · `awaiting_bank` waiting for the book's terms to be signed · `ready` done · `paused` halted and continuable · `stopped` stopped by the user · `rejected` the file was refused · `failed` the run ended in an error. `not_started`, `stopped` and `rejected` are derived by the PLATFORM from behaviour, but they arrive in this field like any other: a client reads `status` and never computes it. **The book's status is that of its current or last run**, except the four a run cannot be in — `uploading`, `parsing`, `not_started`, `rejected` — which belong to the book alone. So a client holding both never has to decide which wins. **A stop at the limit is `paused`, never `failed`**: it is continuable, and mapping it to `failed` would lie about that. The machine reason is `Run.paused_reason`; the phrase is the client's. enum: - uploading - parsing - not_started - translating - awaiting_bank - ready - paused - stopped - rejected - failed RunStatus: type: string description: | Status of a RUN — the states a run can be in, which are fewer than a book's. A run is never `uploading`, `parsing`, `not_started` or `rejected`: those belong to the book before any run exists or instead of one. enum: [translating, awaiting_bank, ready, paused, stopped, failed] RejectReason: type: string description: | Machine reason a book was rejected. A closed vocabulary of this version; the API carries STATE and the client draws the phrase, so no wording appears here. - `source_unreadable` — read, and not a book this service can cut. TERMINAL, and the source does not survive it: no path here re-reads a rejected book, so the remedy is to add it again; - `not_configured` — this deployment has nothing to read the book AGAINST. A state of the service, and retrying alone does not clear it; - `processing_failed` — the service tried this file repeatedly and gave up. Also a state of the service and a temporary one: named by what happened, not by which component of ours was unwell, because the name is what a client keys a phrase on; - `content_refused` — the service will not translate this book. **One coarse reason for a whole class**: it never says which check refused, never varies between attempts, and gives nothing to search against. A client shows one neutral phrase and does not invite a retry. ⚠ **Declared ahead of any producer**, on the same footing and for the same reason as `ErrorCode.content_refused`; the companion carries who owes the check. A client MUST tolerate an unknown value under a minor bump and MUST render a rejected book that carries no reason at all: `null` is legal. enum: [source_unreadable, not_configured, processing_failed, content_refused] PausedReason: type: string description: | Machine reason a run is paused; the client draws the phrase. **TWO values since the order-form minor (0.11.0), and the distinction is load-bearing because the remedies are OPPOSITE:** `credit_exhausted` — the ACCOUNT has no money, the remedy is to top up; `run_limit_reached` — the account is fine and the RUN spent the order it was sold, the remedy is to order more. ⚠ **The mechanism is the run's own ceiling, set flush against its hold — NOT a proof that everything ordered was delivered:** an order whose projection ran low reaches this state before the last chapter, and the honest reading is "this run has spent what it was given", not "your order is complete". Reading the second as the first sends a paying user to a payment page they do not need. A client MUST tolerate an unknown one under a minor bump and MUST render a paused run whose reason is `null` — the ordinary answer when the service has no word for what stopped it — showing the neutral "halted, continuable" state. enum: [credit_exhausted, run_limit_reached] AccountHaltReason: type: string description: | Machine reason the ACCOUNT is halted — a state of the account, not of a run. A separate vocabulary from `PausedReason` on purpose: a run stops for reasons that say nothing about the account, and lighting an account-wide state from one would tell a user with money that they have none. enum: [credit_exhausted] RunFailureReason: type: string description: | Why a run ended in `failed` — the one state that IS an error, and the one a client decides a retry from. - `source_unreadable` — the book could not be read when the work reached it. Adding it again in another form is the remedy; retrying this run is not; - `service_error` — this deployment could not do the work: its configuration, storage or its own state. Not the user's file and not their account; retrying alone does not clear it; - `interrupted` — the run ended without saying how. Retrying IS the remedy, and finished work is not bought again. A client MUST tolerate an unknown value under a minor bump and take the cautious branch: show the failure, do not promise a retry will help. enum: [source_unreadable, service_error, interrupted] Book: type: object description: A book in the library. required: - id - revision - title - source_lang - target_lang - status - reject_reason - structure_version - chapter_count - chapters_done - character_count - character_count_exact - structure - added_at - note_count - shape_epoch properties: id: { $ref: '#/components/schemas/Id' } revision: $ref: '#/components/schemas/Revision' description: | Revision of THIS BOOK, not of the library carrying it — so the answer to a write can be ordered against a frame the way a read can. title: type: string description: | Name shown in the library. Given by the user at intake or derived from the name of the uploaded file; changed afterwards with `PATCH /books/{bookId}`. source_lang: { $ref: '#/components/schemas/LangCode' } target_lang: { $ref: '#/components/schemas/LangCode' } status: { $ref: '#/components/schemas/BookStatus' } reject_reason: oneOf: - $ref: '#/components/schemas/RejectReason' - type: 'null' description: | Why the book was rejected; meaningful only while `status` is `rejected`, and `null` everywhere else — including on a rejected book whose reason the service cannot name. structure_version: { $ref: '#/components/schemas/StructureVersion' } shape_epoch: { $ref: '#/components/schemas/ShapeEpoch' } chapter_count: type: integer minimum: 0 description: Chapters the book was cut into. chapters_done: type: integer minimum: 0 description: | Chapters fully translated — "finished" as `Progress` defines it, the last pass this deployment gives a chapter. Against `chapter_count` this is the book's own progress — what a library row shows — and it never moves backwards WITHIN one pair of (`structure_version`, `shape_epoch`). TWO events recompute both numbers, and each has its own coordinate to announce itself by: cutting the book again, and the deployment changing what "finished" means for a chapter. Across either boundary the count is recomputed and may jump in either direction; a client re-reads rather than treating the jump as an error. Within one pair it only grows. The bar of a RUNNING run is `Run.progress`, which measures what that run bought. ⚠ It is also the number a service reads to decide **how much of the book is left to buy**, so a count that cannot reach `chapter_count` keeps offering work already finished. character_count: type: [integer, 'null'] minimum: 0 description: | Size of the source in characters, or `null` while the book is still arriving. ⚠ **THE NUMBER IS EXACT ONLY FOR UTF-8 TEXT, and today nothing on the wire says which case you are in** (measured 04.09; ratified as a defect 05.09). It counts runes of the WRITE STREAM: for a UTF-8 `.txt` upload those are the source's own characters, but for an EPUB they are runes of the ZIP CONTAINER, and for GB18030 or UTF-16 the engine decodes what this side could not. A reader shown "9 972 characters" for an EPUB is shown the size of an archive. **The chosen cure is a PRECISION SIGNAL BESIDE the number, not a change to what `null` means** — `null` already says "still arriving", a live client renders it as `0`, and overloading it would break a reader that is deployed. The field lands with the order-form pack, which is the one that makes this number a PRICE. ⚠ **ИСПР. 06.09: ПРИЕХАЛО ЛЕНДИНГОМ 0.11.0** — сигнал точности стоит рядом полем `character_count_exact`, и это описание больше не единственное место, где сказано, что число может быть неверным. character_count_exact: type: boolean description: | Whether `character_count` is the source's own characters (`true`) or a proxy this side could not decode — runes of an EPUB's ZIP container, or of a stream in an encoding the engine re-reads (`false`). ⚠ **Added by the order-form minor (0.11.0)**, because the same minor turns this number into a PRICE: selling by characters against an archive's byte count would charge for the container. Beside the number, never inside it: `null` on `character_count` keeps its one meaning, "still arriving", and a deployed reader that renders `null` as `0` is not broken by this. structure: oneOf: - type: string - type: 'null' description: | Where this book's chapter boundaries came from, in the engine's own word, passed through untouched — or `null` when NO MANIFEST HAS BEEN READ FOR THIS BOOK. ⚠ **Added by the order-form minor (0.11.0)** so a client can tell an order in chapters from an order it must phrase in characters. ⛔ **`null` IS NOT A VALUE OF THE VOCABULARY AND IT IS NOT `none`.** `none` is a book the engine LOOKED at and found one chapter in — a statement about the book. `null` is the absence of any statement: nobody has asked. The difference is the whole reason a client can tell «there is no provenance» from «there is one, and it is poor», and only the second is a fact about the text. ⚠ **And `null` does NOT mean «still arriving».** An earlier edition of this line said so, and it is narrower than the truth. A book reaches `not_started` the moment its parse finishes — and that same transaction leaves it OWING a reading surface, which is what later brings the provenance. So the ordinary state of a freshly parsed book is `cut, and no provenance yet`, and a client that waits for arrival waits for a thing that already happened. Held by `pgstore.TestAParsedBookIsNotStartedBeforeItHasAnyProvenance`. ⚠ Two further roads reach the same `null`, and neither is a book in flight: a manifest older than this field leaves the column empty for good, and a book registered from a file already on disk has no manifest at all yet. ⇒ `not_started` says the SOURCE was cut; it does not say a manifest has been read. The rendering half — that an absent provenance reaches the client as an explicit `null` rather than a missing member or an invented word, and that a word this build does not know travels VERBATIM — is held separately by `httpapi.TestABooksCutProvenanceIsTheEnginesOwnWordOrAnExplicitNull`. ⚠ **An OPEN vocabulary that grows additively** — today `declared`, `delimited`, `detected`, `none` (⚠ `delimited` added by the engine 06.09: the file drew a separator — an EPUB spine, a form feed — but calling it a CHAPTER is the engine's assumption, not the format's word). A value this build does not know is read as "not recognised" and is never promoted into trust. A client MUST do the same: switch on the values it knows and treat the rest as "no usable structure", rather than compile an exhaustive list that a minor will outgrow. added_at: { type: string, format: date-time } note_count: type: integer minimum: 0 description: Notes on the whole book. BookPatch: type: object description: | Merge patch over a book. One member, and a member absent from the patch is left alone. properties: title: type: string minLength: 1 maxLength: 200 description: | New display name, bounded like the one the platform derives. BookPage: allOf: - $ref: '#/components/schemas/Page' - type: object required: [books] properties: books: type: array items: { $ref: '#/components/schemas/Book' } BookDetail: type: object required: [revision, book, run] properties: revision: { $ref: '#/components/schemas/Revision' } book: { $ref: '#/components/schemas/Book' } run: oneOf: - $ref: '#/components/schemas/Run' - type: 'null' description: Current or last run; `null` if the book was never run. Run: type: object description: A run over a book. required: - id - book_id - revision - status - stop_for_signing - stop_requested - ceiling_chapters - progress - paused_reason - failure_reason - started_at - finished_at properties: id: { $ref: '#/components/schemas/Id' } book_id: $ref: '#/components/schemas/Id' description: | The book this run belongs to — so an answer to `stop`, `resume` or `GET /runs/{runId}` is enough to find it without a second read. revision: { $ref: '#/components/schemas/Revision' } status: { $ref: '#/components/schemas/RunStatus' } stop_for_signing: type: boolean description: | The run was started with a stop for the book's terms to be signed. stop_requested: type: boolean description: | The user asked this run to stop. **It answers "was this what I asked for", which no `status` can answer**: stopping is not instant, so a stop asked for during translation can meet the run reaching a stop of its own — the bank signature — and the run then ends in `awaiting_bank`, a status that offers to continue on a click that meant "stop". A client that reads this flag says "stopped — the work is parked at the signature, continuing is cheap" instead of offering the wrong action, and it survives a reload, which a client's own pending state does not. Required and never absent, `false` for a run nobody asked to stop: absence would be a second way of writing `false` and the reader would have to handle two. It is a record of the REQUEST, not of the outcome — it stays `true` on a run that went on to finish anyway, because that is exactly the case the screen has to explain. It is cleared by a `resume` that SUCCEEDS: continuing IS the withdrawal of the request, and a run that was asked to continue is no longer a run someone asked to stop. A refused `resume` clears nothing — nothing was withdrawn. ordered_chapters: type: [integer, 'null'] minimum: 0 description: | ⚠ **`null` when the order was NOT placed in chapters** — an order phrased in characters carries its size in `ordered_units` instead. **Exactly one of the two is filled**, and which one says how the buyer expressed themselves. ⚠ **`0` is legal and means a re-pass:** it buys no chapters while being a perfectly ordinary run. A client that treats this field as at-least-one will reject valid answers. ⚠ **The bound was `minimum: 1` until this was measured, and it contradicted the line above it** — a generated validating client would have rejected every re-pass. `null` was never at risk (a bound constrains numbers, not the null branch); the value it excluded was the zero. Held by `pgstore.TestARePassRunsBarIsOneUnitOfWork`, which asserts `0` on BOTH surfaces a run is handed out on — the start receipt and the read path — because they are two different expressions and a promise made once must hold on both. The bound is kept rather than dropped: `0` is the floor and not merely today's smallest value. A negative is refused at admission by the service itself, so the schema says out loud what the store already guarantees. ⚠ **Changed in 0.12.0.** Until then this field carried, for a character order, the SPAN of chapters the order reached into — a number that could exceed what was bought (an order of one unit out of four reported `1`). That was a defect: the field is named for what was ORDERED. It is now `null` there, and the honest size stands beside it. What this run was ORDERED to deliver, in chapters — a property of the RUN. Present so a reloaded screen can name what the user bought and read `progress` against it. ⚠ **Renamed from `ceiling_chapters` by the order-form minor (0.11.0).** The old name said "limit" when the figure is a PURCHASE: a ceiling is what a run may not exceed, an order is what it owes. The retired name is rejected on the way IN with a pointer to `/chapters`, never silently reinterpreted. ordered_units: oneOf: - type: integer minimum: 1 - type: 'null' description: | What this run was ordered to deliver when the order was placed in CHARACTERS, counted in the engine's own shipping units — the same `Unit` this contract already carries. `null` for an order placed in chapters. ⚠ **Added in 0.12.0.** ⚠ **This is the unit of our ANSWER, not of the buyer's QUESTION.** They asked in characters; the number they typed is not stored anywhere today, so a reloaded screen can say "5 units" but not "4500 characters". Whether that echo is owed is a product question, not a contract one. delivered_chapters: oneOf: - type: integer minimum: 0 - type: 'null' description: | Whole chapters this run has delivered, or `null` when the question does not apply. `0` would read as "nothing happened" when work was in fact done and paid for. ⚠ **The `null` is a POLICY, not an accident of arithmetic.** An order expressed in characters is measured in characters throughout, and landing flush with a chapter boundary does not change the unit it was bought in — on a book whose chapters are one unit each, every such order closes whole chapters and still reports `null`. Two purchases a person cannot tell apart must not be drawn differently. ⚠ A previous edition justified this by claiming such an order "closes no whole chapter"; that was false and is withdrawn. term_consistency_funded: type: boolean description: | Whether THIS run's reservation carries the book-wide terminology pass. It is a flat bond on the book, not a per-chapter cost, so a small order can be short of it while a large one is not — and the screen must say so BEFORE the click, not after. progress: { $ref: '#/components/schemas/Progress' } paused_reason: oneOf: - $ref: '#/components/schemas/PausedReason' - type: 'null' description: | Machine reason when `status` is `paused`, `null` otherwise — including for a paused run whose reason this contract has no word for, where the client shows the neutral halted state and does not guess. failure_reason: oneOf: - $ref: '#/components/schemas/RunFailureReason' - type: 'null' description: Machine reason when `status` is `failed`; `null` otherwise. started_at: { type: string, format: date-time } finished_at: type: [string, 'null'] format: date-time description: | When the run ended, or `null` while it is still live. BookIntake: type: object description: | Add-a-book form. ⚠ **Order matters here and nowhere else on this surface:** `file` is the LAST part and every other field precedes it — see `createBook`. An OpenAPI object has no ordering, so the rule lives in prose; the properties are nevertheless listed in the required order, and the schema has NO optional member, so no emission order a generator picks can break the rule. required: [title, source_lang, target_lang, file] properties: title: type: string maxLength: 200 description: | Title given by hand; **the EMPTY STRING means "name it from the file"** — today the platform takes the name of the uploaded file. A value present means the person named the book themselves, and no later parse overwrites it. Required, and empty rather than absent, so that this schema has no optional member: a generator emitting required members first would otherwise place `title` after `file`, and a part after the file is refused. source_lang: { $ref: '#/components/schemas/LangCode' } target_lang: { $ref: '#/components/schemas/LangCode' } file: type: string format: binary description: | Book file, and the LAST part of the form. **Its NAME carries two facts**, so a client sends a real one: it becomes the book's title when `title` was empty, and its extension selects the reader — `.epub` as a book, anything else as plain text. Chapter: allOf: - $ref: '#/components/schemas/ChapterProgress' - type: object required: [number, heading, units_total] properties: number: type: [integer, 'null'] minimum: 1 description: | Displayed ordinal, or `null` when the book has no numbering — a legal book. **Not a key:** numbering is dense, so editing the source shifts every later chapter. ⚠ **ЭРРАТА 05.09 — КОНТРАКТ ПРОТИВОРЕЧИТ СЕБЕ, И ШОВ РЕАЛИЗУЕТ ДРУГУЮ ПОЛОВИНУ.** `null` объявлен здесь ЗАКОННЫМ, но платформа его ОТВЕРГАЕТ: `Whole()` в `platform/internal/ingest/manifest.go` проверяет `c.Number < 1` и возвращает ошибку «chapter numbering is 1-based», а JSON-овый `null` декодируется в ноль. То есть книга, которую этот абзац называет законной, через шов не проходит вовсе. Противоречие — ВНУТРИ поля: «нумерация плотная, номер не ключ» и «`null` законен» несовместимы, и реализована первая половина. ⇒ До структурного пака **считать законной только плотную 1-based нумерацию**; ненумерованная книга сегодня НЕ поддерживается ни одним концом, и обещание обратного здесь — единственный её носитель. Пак, который научит движок резать книги без нумерации, обязан снять это противоречие ЯВНО: либо шов принимает `null`, либо контракт перестаёт его обещать. Версия контракта здесь НЕ двинута намеренно — поведение провода не изменилось, изменилась только честность его описания. heading: type: [string, 'null'] maxLength: 200 description: | The chapter's label **as it comes from the data of the book**, or `null` when the book carries none. A deployment whose parser does not extract labels answers `null` and MUST NOT put a rendered ordinal here — this field is the book's own words. **A client with no label renders its own ordinal from `number`, in the language of the interface**; the server does not know that language. When `heading` and `number` are both `null` the client labels the row from its position in reading order. The server bounds the length: in a list of thousands of rows this is the only string that would otherwise be unbounded. units_total: type: integer minimum: 0 description: Pairs in this chapter. ChapterProgress: type: object description: | The part of a chapter that MOVES while a book is translated — composed into both `Chapter` and the `chapter` frame, so a client applies a frame to a row by the same field names. required: [id, units_done, note_count] properties: id: { $ref: '#/components/schemas/Id' } units_done: type: integer minimum: 0 description: | Pairs of this chapter finished IN THE CURRENT PASS over the book. `0` for a chapter the pass has not reached, `units_total` for one it has finished; it restarts from zero for the chapters a NEW pass re-walks, and a chapter outside the current pass keeps what the last pass left. Counted end to end instead, the tree would read zero through the whole first pass. ⚠ The state of a PASS, not the lifetime of the chapter, so it can legally return to zero. The lifetime figure is `Book.chapters_done`. **And it is NOT the accounting `Progress` uses**: the run's bar spans every pass and never goes back, this figure returns to zero when the next pass begins. A client that mirrors one into the other draws a bar that jumps. note_count: type: integer minimum: 0 description: Notes on this chapter. ChapterPage: allOf: - $ref: '#/components/schemas/Page' - type: object required: [structure_version, chapters] properties: structure_version: { $ref: '#/components/schemas/StructureVersion' } chapters: type: array items: { $ref: '#/components/schemas/Chapter' } UnitState: type: string description: | `translated` — a translation shipped, including a pair that shipped WITH a note attached · `withheld` — no translation was produced · `pending` — not translated yet. enum: [translated, withheld, pending] Unit: type: object description: | A source/translation pair — one fragment of a chapter beside its translation. A fragment is as long as the text needs: sometimes a paragraph, sometimes a whole chapter. Alignment is coarse and accepted as such. **Freshness.** `target` is updated at the boundaries of the work and at stops, not continuously: a frame says something changed, the text arrives with the next read. required: [id, source, target, state, notes] if: properties: state: { const: translated } required: [state] then: properties: target: { minLength: 1 } properties: id: { $ref: '#/components/schemas/Id' } source: type: string description: Source text of the fragment. target: type: string description: | Translation. **Non-empty exactly when `state` is `translated`, the empty string otherwise** — never absent, never `null`. Stated both as a constraint above and in words here because a generator ignores `if`/`then` and leaves it a plain optional string; the client narrows the pair on its own seam. state: { $ref: '#/components/schemas/UnitState' } notes: type: array description: | The notes on this pair, in the note list's order; empty when there are none. Delivered here as well as in the book's note list so a reader screen need not join two collections, and as an ARRAY because a pair legally carries more than one. items: { $ref: '#/components/schemas/Note' } UnitPage: allOf: - $ref: '#/components/schemas/Page' - type: object required: [structure_version, units] properties: structure_version: { $ref: '#/components/schemas/StructureVersion' } units: type: array items: { $ref: '#/components/schemas/Unit' } NoteSeverity: type: string description: | How much attention the note asks for. Two steps today; how many there ought to be is an open product question the companion tracks, and a client MUST tolerate a new value under a minor bump. enum: [attention, glance] Note: type: object description: | A remark about a piece of the translation. **The words are the client's**, drawn from `code` as they are from every other machine reason here; nothing about the machinery that produced it crosses this boundary. required: [id, created_at, severity, code, chapter_id] properties: id: $ref: '#/components/schemas/Id' description: | Identity of the note — without it a note arriving on the stream cannot be matched against the list already read. created_at: { type: string, format: date-time } severity: { $ref: '#/components/schemas/NoteSeverity' } code: type: string minLength: 1 description: | Machine reason for the note: a closed vocabulary of this version, listed with its phrase in the companion's appendix A. A client MUST show a neutral phrase — never the word "error" — for a code it does not know. ⚠ **`unspecified` is reserved and is the server's answer when it has no code for what it was told.** The service that produces notes and the one that serves them ship apart, so a reason newer than this build is an ordinary event, not a fault — and this field is required, so a server has to write something. Writing `unspecified` is the obligation; inventing a word for the reason is forbidden, because an invented word is a vocabulary nobody agreed to. A client treats it exactly as it treats a code it does not know. Not otherwise enumerated here: the map is a table the contract's owner fills, and freezing a list in the schema before the words exist would make it a second copy. It becomes an enum when appendix A is written. chapter_id: $ref: '#/components/schemas/Id' description: | The chapter the note is about. Required: a note addressing nothing could not be placed on any screen. unit_id: $ref: '#/components/schemas/Id' description: | The pair the note is about, when it is about one rather than the whole chapter. Optional for that reason and no other. NotePage: allOf: - $ref: '#/components/schemas/Page' - type: object required: [structure_version, notes] properties: structure_version: { $ref: '#/components/schemas/StructureVersion' } notes: type: array items: { $ref: '#/components/schemas/Note' } TermKind: type: string description: | Kind of term. Not cosmetic: `name` and `place` decide whether a term is transliterated, so signing one without seeing its kind means signing blind. enum: [name, place, title, term, nickname] TermStatus: type: string description: | Signing status, THREE-VALUED; only `approved` is carried into the translation as canon. A boolean would merge "proposed, nobody has looked" with "a person started and did not finish" — on a screen of hundreds of rows that is the main filter of work. This is the state of the ROW. `POST /books/{bookId}/bank/corrections` does not set it directly: a correction is recorded and the status follows on the next rebuild. enum: [proposed, in_progress, approved] TermOrigin: type: string description: | Where the row came from — an axis independent of `status`, and one the person signing needs. - `given` — it came with the book: someone stated it up front; - `annotated` — the book's own text says how to read it, and the row was taken from there; - `found` — the service found it in the text. enum: [given, annotated, found] BankTerm: type: object description: | A memory bank row. Provenance is `origin` and the term's two surfaces are `src`/`dst`; the name `source` is deliberately unused here, because it means something else on the other side of this boundary and a field name must not mean two things. required: [id, src, dst, kind, status, origin, sense, since_chapter, until_chapter] properties: id: $ref: '#/components/schemas/Id' description: | Identity of the row, derived from the term itself — its SOURCE surface, sense and window; not from `dst`, so a corrected rendering keeps the id — and a decision against it survives the bank being rebuilt. ⚠ It does NOT survive the book being cut differently: the window is in chapter numbers, those move with a re-cut, and an identity derived from them moves too. A client that sees `structure_version` change re-reads the bank and does not assume its decisions carried over. src: type: string description: Source surface of the term. dst: type: string description: Translation; empty for a candidate with no proposed form. kind: oneOf: - $ref: '#/components/schemas/TermKind' - type: 'null' description: | `null` when the kind could not be decided — legal; the row stays and stays correctable. A client MUST show it as "kind not decided" and MUST NOT drop it or invent a kind. status: { $ref: '#/components/schemas/TermStatus' } origin: { $ref: '#/components/schemas/TermOrigin' } sense: type: string description: | Polysemy disambiguator; part of the uniqueness key. **Required, and the EMPTY STRING means "no disambiguator"** — otherwise a client could not tell that from "the field was not sent", which is exactly the field two legal rows of one surface differ by. since_chapter: type: [integer, 'null'] minimum: 1 description: | First chapter the term applies from, or `null` for "from the beginning". ⚠ **The window is in chapter NUMBERS, which are not keys** (see `Chapter.number`), so it lives in the coordinates of the current `structure_version`: cut the book differently and the same window covers different text. A client re-reads the bank when the version moves. A term is unique by `(book, src, sense, since_chapter, until_chapter)`, so the same `src` legally arrives as several rows. until_chapter: type: [integer, 'null'] minimum: 1 description: | Last chapter the term applies to, or `null` for "to the end". Same coordinates as `since_chapter`. BankPage: allOf: - $ref: '#/components/schemas/Page' - type: object description: | **The aggregates below describe the WHOLE bank and ride on the FIRST page only** — any response to a request with no `cursor`, a delta read included; absent on later pages. A client takes them from the first page it read, which puts them at the same moment as the oldest rows — the moment the whole walk is stamped with (see `Revision`). required: [structure_version, terms] properties: structure_version: { $ref: '#/components/schemas/StructureVersion' } total: type: integer minimum: 0 description: Rows in the whole bank, not on this page. signed: type: integer minimum: 0 description: | Rows in status `approved` in the whole bank. Not a count of decisions: a row can be corrected and NOT signed, because declining is also a correction. How many surfaces still await a word is not derivable from these rows at all — see the note on the operation. terms: type: array items: { $ref: '#/components/schemas/BankTerm' } BankCorrection: type: object additionalProperties: false description: | One correction of one term: `approve` a rendering, or `decline` a surface. It names its term EITHER by `id` — a row of the bank read as it stands — OR by the full (`src`, `sense`, `since_chapter`, `until_chapter`) tuple that id is derived from; never by both, because both could name two different terms and the caller would never learn which one was taken. Stated in the schema AND in words, because a generator ignores the constraint forms. **A tuple the bank does not hold is NOT an error — it is how a term is ADDED.** Approving an unknown tuple writes a new term. Only an unknown `id` refuses — the whole set, `409` `code: bank_corrections_refused`, the entry named in `refusals[]`: an id comes from a read, so an unknown one means the read was of another book or predates a re-cut of this one — the id is derived from the chapter window, and windows move with a re-cut. **A member this schema does not declare refuses the request** (`400`, `code: invalid_request`): an unknown member is a caller believing it set something, and the quiet version of that is a correction half-applied. **What is deliberately NOT correctable here:** - a term that came WITH the book (`origin: given`): APPROVING its key is refused — changing what the book was given re-buys the translation from the start, a different operation this contract does not offer. DECLINING such a surface is refused only when it would change nothing; when a recorded approval of this book holds that same surface, the decline is accepted and removes it — that is the repair for exactly that clash; - a term's chapter WINDOW in one act: approving a new tuple ADDS a second row and leaves the old one standing, and declining a surface removes EVERY window of that surface. Moving or widening a window is a two-call operation — decline, then approve; - a surface that, once this document is applied, would remain only an ALIAS an approved correction still fires for: declining it alone is refused as changing nothing — decline the owning term instead. (The alias set itself is not published on this surface; the refusal names the owner in its developer-facing `detail`.); - a `note` cannot be emptied, and a `kind` cannot be cleared back to `null` (see the two fields). One call decides a term once. A `decline` names a whole SURFACE: the `sense` and window of its tuple say which row the receipt reports and do NOT narrow the effect — every window of the surface goes (for a surface the bank does not list, send `sense: ""` and `null` windows). So a decline and any second correction of the same surface in one document contradict each other and are refused; several `approve`s of one surface in different senses or windows are legitimately several terms. required: [action] oneOf: - title: an existing row, by id required: [id] not: anyOf: - required: [src] - required: [sense] - required: [since_chapter] - required: [until_chapter] - title: a term by its full key — the form that can also add one required: [src, sense, since_chapter, until_chapter] not: { required: [id] } if: properties: action: { const: approve } required: [action] then: required: [dst] properties: dst: { minLength: 1 } else: not: anyOf: - required: [dst] - required: [kind] properties: action: type: string enum: [approve, decline] description: | `approve` — take the term into the book with the rendering in `dst`; it also lifts a previous decline of the surface. `decline` — leave the surface out and stop it being proposed again; it also removes every previously approved row of that surface. There is no "un-decide": a correction is REPLACED by the opposite one, never withdrawn, and the receipt says when one displaced an earlier word. id: $ref: '#/components/schemas/Id' description: | The bank row's `id`, exactly as the bank read published it. Mutually exclusive with the tuple. src: type: string minLength: 1 description: Source surface of the term, as `BankTerm.src`. sense: type: string description: | Polysemy disambiguator, as `BankTerm.sense`: required in the tuple form, and the EMPTY STRING means "no disambiguator". Required precisely so a caller cannot omit it and silently name a DIFFERENT term than it meant: the tuple is the term's whole key, and a partial key is another key. since_chapter: type: [integer, 'null'] minimum: 1 description: | First chapter of the window, `null` for "from the beginning" — the same coordinates and the same `null` as `BankTerm.since_chapter`; required in the tuple form for the same reason as `sense`. A window that ends before it begins is refused: such a term would be recorded and then apply nowhere. until_chapter: type: [integer, 'null'] minimum: 1 description: Last chapter of the window, `null` for "to the end". As `since_chapter`. dst: type: string description: | The rendering. **Required and non-empty when `action` is `approve`; forbidden on `decline`** — in words as well as in the constraints above, because a generator ignores them. An approved term with no rendering is not a weak approval — it would fail the next run; and a rendering on a decline says the caller meant to approve, and half of that is not something to guess at. kind: $ref: '#/components/schemas/TermKind' description: | Reclassify the term. ABSENT means "not decided" and keeps whatever the term already carries — including a `kind` of `null`: there is no way to clear a kind back to `null` through this surface. Forbidden on `decline`, like `dst`. note: type: string description: | The user's reason, recorded with the correction and NOT published: it never appears in `BankTerm` or anywhere else on this surface. ABSENT means "not decided" and keeps the note recorded earlier — so a note cannot be EMPTIED here, only replaced with other words. Named rather than hidden, because dropping the earlier reasoning by omission is the mistake a caller makes by accident. Legal on both actions. BankCorrectionsRequest: type: object additionalProperties: false description: | The correction document, applied as ONE act, all or nothing. An undeclared member refuses the request — see `BankCorrection`. required: [book_id, preview, corrections] properties: book_id: $ref: '#/components/schemas/Id' description: | The book these corrections were computed FOR, checked against the book in the path; a mismatch is `400` `invalid_request` with an `errors[]` entry at `/book_id`. Deliberately a second carrier of the same fact: corrections carry a user's own words into a book's canon, and a set computed for one book landing in another is not a mistake anything downstream could notice. preview: type: boolean description: | `true` — answer the receipt and change NOTHING; `false` — apply. Required, not defaulted: which of the two acts this call is must be said, not implied. corrections: type: array minItems: 1 maxItems: 5000 description: | At most 5000 in one act — a hard ceiling, not advice: a larger set is refused whole. Split a larger document and send the parts in turn. items: { $ref: '#/components/schemas/BankCorrection' } BankCorrectionsReceipt: type: object description: | The answer of a correction call — the whole of it, for a preview and for an apply alike; `preview` says which of the two this receipt is. It is NOT a read of the bank: the rows `GET /books/{bookId}/bank` serves change only when a run next rebuilds the bank. required: [preview, changed, depth, accepted, preexisting_faults, signature] properties: preview: type: boolean description: Echo of the request. A preview's receipt promises; an apply's reports. changed: type: boolean description: | Whether this call changed the recorded corrections (for a preview: whether applying would). `false` on an apply means the document was ALREADY fully applied — the normal answer to a retry, and nothing was written again. depth: type: string description: | How far an accepted correction reaches. `refinement` — it is applied when a run next refines the text: the translation already produced is corrected in later passes rather than re-translated from scratch, and nothing changes until a run happens. A client renders its own words from this value, MUST NOT promise a fresh re-translation, and treats an unknown value neutrally — this vocabulary can grow. examples: ['refinement'] accepted: type: array description: One entry per correction of the document, in the document's order. items: { $ref: '#/components/schemas/AcceptedCorrection' } preexisting_faults: type: integer minimum: 0 description: | Faults the book's bank inputs ALREADY carried — not caused by this call, and not refusing it: this door must stay usable exactly when the book needs repair. Non-zero warns that the next run would FAIL at the bank — a fault, not the signing stop — regardless of this call. The detail is a server-side matter, correlated by `X-Request-Id`; it is not on this surface. signature: oneOf: - $ref: '#/components/schemas/BankSignatureCount' - type: 'null' description: | The count against the surfaces the LAST signing stop offered — `null` when no run has reached a signing stop yet, so there is nothing to count against. Carries its own warning: see the schema. ⚠ A named narrowness of this version: the count lives ONLY on this receipt, so a client with no correction to send — or preview — has no way to ask for it yet; the companion carries when a read would take it over. AcceptedCorrection: type: object description: What one correction did — for a preview, would do. required: [index, action, id, src, dst, state, displaced] properties: index: type: integer minimum: 0 description: Position of the correction in the request document. action: type: string enum: [approve, decline] id: $ref: '#/components/schemas/Id' description: | Id of the term the correction named — for a term this call ADDS, the id its tuple derives to: the same id the bank read will publish for it. src: type: string description: Source surface of the term. dst: type: [string, 'null'] description: The rendering; `null` on `decline`. state: type: string enum: [applied, already_applied] description: | `already_applied` — the recorded corrections already say this; nothing was (or would be) written for it. That is the whole of idempotency as a caller sees it: re-sending a document is safe. displaced: type: boolean description: | Whether this correction displaced an earlier word — a previous rendering, a previous decline of the surface, previously approved rows removed by a decline. `true` is not an error: a correction REPLACES. It is surfaced because overwriting without seeing that you overwrote is the mistake this door must not enable. The itemization of WHAT was displaced is not on this surface: its vocabulary is the service's own free text, which does not cross this boundary. BankSignatureCount: type: object description: | Of the surfaces the LAST signing stop offered, how many the user has not yet spoken about — counted by the same rule a run uses to fold corrections into the bank, so the count agrees with what the next run treats as decided. ⚠ **INFORMATIONAL, never a gate — it counts, it does not decide.** It does NOT answer whether the stop will lift, in either direction: `undecided: 0` promises NOTHING about the next run's behaviour, and an undecided remainder forbids nothing — deciding surfaces is the user's RIGHT, not the stop's demand. A client may show "N of M still open"; it MUST NOT disable or gate the continue action on these numbers or imply they must reach zero: `resumeRun` lifts the stop with the corrections as they stand. required: [surfaces, undecided, unreadable] properties: surfaces: type: integer minimum: 0 description: How many surfaces the last stop asked about. undecided: type: integer minimum: 0 description: How many of them are still undecided AFTER this call. unreadable: type: boolean description: | `true` — the two numbers mean NOTHING for this call: the state they are counted from could not be read. Without this flag, "could not count" would be byte-identical to "nothing left undecided" — the one thing this schema must never say by accident. CorrectionRefusal: type: object description: One refused correction, or one refusal about the would-be result as a whole. required: [pointer, detail] properties: pointer: type: string description: | JSON Pointer to the refused correction (`/corrections/3`), or the EMPTY STRING for a refusal about the result as a whole — a contradiction the SET introduces rather than any one entry. detail: type: string minLength: 1 description: | Developer-facing sentence naming the reason, like `Problem.detail`: a client MUST NOT show it to a user — it marks the row and draws its own neutral phrase. The reasons carry no machine vocabulary on this surface yet: that vocabulary is the service's own and still growing, and freezing a copy here would be a second source of truth. RunRequest: type: object required: [stop_for_signing] properties: re_pass: type: boolean description: | Buy a RE-PASS instead of new chapters: carry the corrections already recorded on the book's memory bank into text that is already translated. `ceiling_chapters` is then neither required nor legal — a re-pass buys no chapters, it walks the book the service has already produced and re-does only what the corrections actually touch. **Legal only while the book carries a correction the service has not yet walked in.** Otherwise the start answers `409` with `cause.code: re_pass_unavailable` — either no correction was recorded since the last run, or the one recorded touched nothing that was paid for. ⚠ **What it costs is bounded by the hold like any other run**, and most of the work is free: the parts of the book a correction does not reach are re-used, not re-bought. The service does not quote the affected amount BEFORE the purchase — it cannot yet, and saying otherwise would be a promise this deployment does not keep. stop_for_signing: type: boolean description: | Stop when the book's terms are ready and wait for them to be signed; without it the run does not stop for it and uses the bank as it stands. Nothing on this surface marks a book as having been translated against an unsigned bank: what is observable is the bank itself — `BankPage.signed` against `BankPage.total`. chapters: type: integer minimum: 1 description: | Order this many chapters, counted from the first not yet delivered. Bounds come from `GET /books/{bookId}/run-options` (`order.chapters_left`, `order.affordable_chapters`). ⚠ **OPTIONAL, and its absence is not "no limit" — it is the order of the WHOLE BOOK**, which is the ratified default. `0` is not legal: an order that buys nothing is not an order. Mutually exclusive with `characters`; a request carrying both is `400`. characters: type: integer minimum: 1 description: | Order by size instead of by chapters, for a book whose chapter boundaries this build does not trust — `order.chapter_orders: false` says when that is the case. ⚠ **The run it starts reports `delivered_chapters: null` rather than `0`, and the reason is a POLICY, not an accident of arithmetic:** an order expressed in characters is measured in characters throughout, and landing flush with a chapter boundary does not change the unit it was bought in. ⚠ A previous edition justified the `null` by claiming such an order "closes no whole chapter" — **false**: whether it closes one depends on where the unit boundary fell, and on a book of one-unit chapters every such order closes them. The `null` holds regardless, which is the point: two purchases a person cannot tell apart must not be drawn differently. Mutually exclusive with `chapters`; a request carrying both is `400`. ceiling_chapters: deprecated: true type: integer description: | ⛔ **RETIRED BY THE ORDER-FORM MINOR (0.11.0) AND REFUSED ON THE WAY IN — `400` with a pointer to `/chapters`.** It is NOT silently reinterpreted: the old name said "limit" when the figure is a PURCHASE, and a client that kept sending it would be buying something it did not mean. ⚠ Named here only so a reader of a 0.10.0 client can find out what became of it. It is not accepted, and it is not the same field as `chapters` — one bounded a run, the other orders work. ⚠ **A re-pass (`re_pass: true`) carries NEITHER volume member:** it does not buy chapters, so a volume would describe nothing. RunOptions: type: object description: | What a run may be started with, read immediately before starting one. **Five places on this surface can say "there is no credit", and they answer different questions. In this order:** 1. the `409` of `POST /books/{bookId}/runs` (`ceiling_unavailable`) — the only authority on whether THIS start may happen; every read below can be stale by the time it is used; 2. `RunOptions.order.verdict: covers_none` and `RunOptions.blocked` — whether a start is worth offering AT ALL right now, and what is holding it. This is what the start screen draws; 3. `Run.paused_reason: credit_exhausted` — why a run that already ran stopped. History, not a gate: a new run may still be startable (see `resumeRun`); 4. `Usage.halt_reason` — the ACCOUNT is halted, which is a state of the account and not of any book; 5. `Usage.state: exhausted` — the account screen's own summary, the coarsest of the five. A client that read them in the opposite order would refuse to offer a run the server would have accepted. required: [order, balance_micro_usd, limit, blocked] properties: order: { $ref: '#/components/schemas/OrderOptions' } balance_micro_usd: type: integer minimum: 0 description: | What the account can still spend, in micro-dollars. ⚠ A quantity, not arithmetic: a hold is a debit when taken, so this figure ALREADY excludes the holds open against it. limit: { $ref: '#/components/schemas/MoneyLimit' } blocked: oneOf: - $ref: '#/components/schemas/Blocked' - type: 'null' description: | Why the scale is smaller than the account could otherwise afford, or `null`. Without it an account's second book shows a shrunken scale with no way to learn that its own first book is the reason. OrderOptions: type: object description: | What can be ordered on this book right now, and whether the balance covers it. ⚠ **Added by the order-form minor (0.11.0).** required: [chapters_left, affordable_chapters, verdict, chapter_orders, structure, source_chars, term_consistency_funded, estimate] properties: chapters_left: type: integer minimum: 0 description: Chapters of this book not yet delivered. affordable_chapters: type: integer minimum: 0 description: | How many of those the balance covers, already clamped to `chapters_left`. A client MUST NOT clamp it again. verdict: type: string enum: [covers_all, covers_part, covers_none] description: | The one-word answer the start screen draws BEFORE the click. Computed by the same arithmetic that admits the order, so the verdict and the click cannot disagree. chapter_orders: type: boolean description: | Whether an order may be phrased in CHAPTERS at all. `false` when this build does not trust the book's chapter boundaries — then the only partial order is in characters, and the screen must say so in words rather than offering a slider against a cut nobody made. structure: oneOf: - type: string - type: 'null' description: | Where the chapter boundaries came from, passed through from the engine's manifest, or `null` when NO MANIFEST HAS ANSWERED YET. ⚠ **`null` is not a KIND of cut at all, and it is not `none`.** (⚠ Said "a fourth kind" until 06.09, when the vocabulary already held four values and `null` would have been a fifth: the sentence was written in the three-value era and the count went stale under it. The number is gone rather than corrected, so the rule outlives the next value too.) `none` is a book the engine looked at and found one chapter in; `null` is a book it has not been asked about. Having no provenance and having a poor one are different facts, and only the second says anything about the book. Held by `httpapi.TestABookWhoseCutCannotBeSoldAgainstOffersCharactersAndSaysSo`, which asserts an explicit `null` rather than an invented word. ⚠ The sibling `Book.structure` carries the same fact and now says the same in its own words — until 06.09 it did NOT: it called `null` «still arriving» and drew no line between `null` and `none` at all, so the rule lived in the ORDER schema while the field a client reads on the book card was silent about it. «The two cannot disagree» was a statement of intent then; it is a statement about the text now. ⚠ **An open vocabulary that GROWS additively** — today `declared`, `delimited`, `detected`, `none` (⚠ `delimited` added by the engine 06.09: the file drew a separator — an EPUB spine, a form feed — but calling it a CHAPTER is the engine's assumption, not the format's word). A value this build does not know is read as "not recognised" and never promoted into trust; a client MUST do the same rather than switch on an exhaustive list. source_chars: type: integer minimum: 0 description: Characters of the source this order is priced against. term_consistency_funded: type: boolean description: | Whether the balance covers the book-wide terminology bond ON TOP of this order. A flat bond on the book, so a small order can fall short of it while a large one does not — and the degradation must be visible BEFORE the click. estimate: { $ref: '#/components/schemas/Estimate' } Estimate: type: object description: What an order is expected to cost, and what will be reserved for it. required: [expected_micro_usd, hold_micro_usd] properties: expected_micro_usd: type: integer minimum: 0 description: | The engine's a-priori projection for this order, in micro-dollars — derived from the text before a single call is made, not a running total. hold_micro_usd: type: integer minimum: 0 description: | What is actually reserved: the expectation with headroom, plus the engine's largest INDIVISIBLE step so the last call of the order cannot be refused for want of room. MoneyLimit: type: object description: | Bounds of the money slider. ⚠ **These two are NOT a range.** The minimum is the engine's indivisible step — the smallest order any run could move under — and it can exceed the maximum when the balance does not carry even one reservation. That is the honest shape of "nothing can be bought", and `order.verdict: covers_none` says it in a word. required: [min_micro_usd, max_micro_usd] properties: min_micro_usd: { type: integer, minimum: 0 } max_micro_usd: { type: integer, minimum: 0 } Blocked: type: object description: | What is holding the scale down, and which book is doing it. required: [code, book_id] properties: code: type: string description: | `credit_held` — another book of this account has a run holding the credit, released when that run settles. A client MUST tolerate an unknown value under a minor bump and show a neutral "something else is using the balance" state. enum: [credit_held] book_id: $ref: '#/components/schemas/Id' description: | The book that holds it — an id, so a client that wants to name it reads that book's card, which is also where the user can act. CeilingBounds: type: object description: | Bounds of the run-limit scale, in CHAPTERS. The conversion to money lives on the platform and is not exposed here in any form. ⛔⛔ **RETIRED BY THE ORDER-FORM MINOR (0.11.0), 06.09 — THIS SCHEMA IS SERVED BY NOTHING.** `GET /books/{bookId}/run-options` no longer carries a `ceiling` member at all; its answer is `order` + `balance_micro_usd` + `limit` + `blocked` (see `RunOptions`). Everything above is kept as the history of the chapter-scale era and describes NO live wire. ⚠ The paragraph that stood here until 06.09 said the opposite — «the schema is NOT moved ahead of the code, so what is written above still describes what this version serves». It was written expecting the canon never to lag the code; the landing did the reverse, and the sentence outlived its condition. What is NOT retired and never was: the price of a model, the cost of a stage, the price of one call. `max_chapters` is what the account can still spend, ALREADY clamped to what is left of the book; a client MUST NOT clamp it again. ⚠ A quantity, not arithmetic: a hold is a debit when taken, so a running balance already excludes the holds open against it. `max_chapters: 0` means no run can start — the client shows the exhausted state instead of a scale, and `RunOptions.blocked` may say what is holding it. ⚠ **When `max_chapters` is `0` the three numbers are not a range.** `min_chapters` stays `1` — it is the smallest limit this service will ever accept, not a bound derived from what is affordable — so the pair reads `min 1, max 0`, which is empty by construction. **A client tests `max_chapters == 0` FIRST** and never treats `min_chapters` as the start of a scale before it has done so. The alternative, moving `min` to `0`, would make "one chapter" and "nothing at all" the same value. required: [min_chapters, max_chapters, default_chapters] properties: min_chapters: type: integer minimum: 1 description: Smallest limit that can be started. max_chapters: type: integer minimum: 0 description: Largest limit that can be started; `0` when none can. default_chapters: type: integer minimum: 0 description: | Pre-selected value, owned by the platform because the choice is product policy. `0` only when `max_chapters` is `0`. Usage: type: object description: | State of the credit balance. No window, no `resets_at`, no sums — see `GET /usage`. ⚠ **«no sums» IS THE RETIRED RULE, KEPT UNTIL ITS FIELD LANDS (05.09).** The owner retired the ban on 05.09: an account HAS a balance and it is shown as an amount. The field lands with the order-form pack; until then this description is honest about what is served. Still banned, and never part of the retraction: the structure of OUR spend. required: [state, remaining_percent, halt_reason] properties: state: type: string description: | `ok` · `low` the threshold at which the interface warns · `exhausted` nothing left. The threshold is the platform's and is not on the wire: computing it from the percentage would be a second copy of the policy. enum: [ok, low, exhausted] remaining_percent: type: integer minimum: 0 maximum: 100 description: | Share of the account's grants still available. ⚠ Honest as an ALARM («low», «exhausted») and useless as an answer to «will my balance cover this order» — the amount field that answers it lands with the order-form pack (owner, 05.09). halt_reason: oneOf: - $ref: '#/components/schemas/AccountHaltReason' - type: 'null' description: | Set when the ACCOUNT is halted, `null` otherwise. Named and typed apart from `Run.paused_reason` on purpose: only reasons of the account's own level appear here. ExportRequest: type: object required: [format] properties: format: type: string minLength: 1 description: | One of `export_formats` from `GET /capabilities`; a format outside that set is `400`. Export: type: object description: | A built copy of the book, behind a link. required: [id, revision, state, format, expires_at, failure_code, url] properties: id: { $ref: '#/components/schemas/Id' } revision: { $ref: '#/components/schemas/Revision' } state: type: string description: | `pending` being built · `ready` downloadable · `failed` the build ended in an error · `expired` it was built and the link has lapsed. A state and not a boolean: a boolean merges three situations into "not ready" and a poll on it never ends. enum: [pending, ready, failed, expired] format: type: string minLength: 1 description: | The format asked for, echoed back — without it a client holding two export addresses cannot tell which is which. expires_at: type: [string, 'null'] format: date-time description: | When the link stops working, or stopped: set once the artifact exists — in `ready` and in `expired` — and `null` in `pending` and `failed`. failure_code: type: [string, 'null'] minLength: 1 enum: [book_empty, deployment_error, build_interrupted, build_failed, null] description: | Machine reason when `state` is `failed`, `null` otherwise; the phrase is the client's. **Enumerated since 0.10.0** — the condition this field set for itself («becomes an enum with the first built format») is met. The set is what a deployment PRODUCES, not what one can imagine: · `book_empty` — there is no text in the book to make a file out of. One code for two situations, because the reader's fact is one and so is the remedy: a book that was cut and is empty, and a book nobody has cut yet. The door may not refuse by a book's state, so «nothing to write» travels as this export's outcome rather than as a `409`. · `deployment_error` — the engine refused THIS deployment's configuration: a format it does not build, a book without a language tag, a reader-words file it cannot read, a directory it may not write. The operator's files need fixing; the user can do nothing, and is told a machine reason rather than a lie. ⚠ Deliberately NOT named after the format: the same class covers all of the above, so «that format is unavailable» would be false for most of them. · `build_interrupted` — the build did not finish and nobody is coming back for it. Asking again converges. · `build_failed` — the file could not be produced and this build has no better word. It is an answer that ENDS the poll, which is the one thing this resource must never fail to give. url: type: [string, 'null'] format: uri-reference description: | Where to download it from; `null` unless `state` is `ready`. The operation is `downloadExport`. **Bound to the authenticated owner**, never indexed, on the same origin as this API, and expiring at `expires_at`. A browser NAVIGATES to it: it is a download, not a call. ⚠ **A RELATIVE reference** — hence `uri-reference` and not `uri` since 0.10.0. An absolute URL would require the service to know its own public origin, which behind an edge proxy it does not. ⚠ The wording before 0.10.0 («minted for THIS response») read as a signed capability and described a mechanism nobody built: the link carries no secret and no expiring token, it is protected by the session like every other read here. That is strictly stronger — a leaked link is useless to a stranger — and it needs no deployment secret that nobody rotates. EventEnvelope: type: object description: | An SSE frame. ⚠ **This schema is a DESCRIPTION, not the shape of a JSON object on the wire.** OpenAPI cannot type `text/event-stream`, so the three members below are written as one object; on the wire they are not one. A frame is exactly: ``` event: progress id: 57 data: {"revision":1841,"structure_version":3,"progress":{...}} ``` `event` and `id` are SSE FIELDS of the frame (`EventSource` exposes them as `event.type` and `event.lastEventId`); the JSON body of the frame is `data` ALONE, and it is the `data` schema of the table below — never an object containing `event`/`id`/`data`. A heartbeat is a comment line (`:` and a newline) and is not a frame. The two numbers in that example are deliberately unlike: `id` is this frame's position in the book's event history, `revision` is the book's state. They are not the same counter and a client never substitutes one for the other. OpenAPI does not type stream frames, so the mapping is fixed here: | `event` | `data` schema | When | |---|---|---| | `hello` | `EventHello` | always the first frame | | `status` | `EventStatus` | product status changed | | `progress` | `EventProgress` | the run's counter moved | | `chapter` | `EventChapter` | a chapter's own progress changed | | `note` | `EventNote` | a note appeared | | `bank` | `EventBank` | the bank changed, or a signing stop happened | | `session_ended` | `EventBase` | the session behind this connection was revoked or reached its absolute lifetime | | `resync_required` | `EventResyncRequired` | resuming the stream is impossible | | `end` | `EventEnd` | nothing further will arrive on this stream | **`id` and `revision` are different numbers.** The frame's `id` is a position in the BOOK's event history and the only thing a client does with it is send it back as `Last-Event-ID`; the book's `revision` travels inside `data` and is what a frame and a read are ordered against. required: [event, id, data] properties: event: type: string description: Frame name; dispatch on it, per the table above. enum: [hello, status, progress, chapter, note, bank, resync_required, session_ended, end] id: type: string pattern: '^[0-9]+$' description: | Position in the BOOK's event history: a decimal integer, increasing. Per BOOK and not per connection — `Last-Event-ID` must mean the same thing however the frame was carried. **Only history frames consume a number.** `hello`, `resync_required`, `session_ended` and `end` belong to the CONNECTION, not to the book: each carries the id of the last history frame and consumes none of its own, so the same id legally appears more than once in one stream. A client stores the id it last saw and sends it back; it never counts with it. **A book that has produced no history at all** — never run, still arriving — has no last frame, and its connection frames carry `0`. History numbering starts at `1`, so `0` is "nothing yet" and can never collide with a real frame. This is what stops the loop on a book at rest: the client has an id to send, presents `Last-Event-ID: 0`, and is answered `204` instead of another empty stream. **A gap is legal** — coalescing removes frames, and a client MUST NOT read a skipped number as a lost frame. data: description: | Frame payload — the JSON body of the SSE frame, per the table above. `anyOf` and not `oneOf`: dispatch is by the event NAME, and two frames legally carry the same shape. **`resync_required` and `end` are exactly that case** — both are `EventBase` and nothing more, so a client that tried to tell frames apart by their members would confuse them. Dispatch on `event` first, always; the shape only validates what arrived. anyOf: - $ref: '#/components/schemas/EventHello' - $ref: '#/components/schemas/EventStatus' - $ref: '#/components/schemas/EventProgress' - $ref: '#/components/schemas/EventChapter' - $ref: '#/components/schemas/EventNote' - $ref: '#/components/schemas/EventBank' - $ref: '#/components/schemas/EventResyncRequired' - $ref: '#/components/schemas/EventSessionEnded' - $ref: '#/components/schemas/EventEnd' EventBase: type: object description: | The two book-scope numbers every frame carries. `revision` orders the frame against a read. `structure_version` tells a client the book was cut again — the moment its pair anchors stop being valid and its tree, bank windows and cursors must be read afresh; without it on every frame that moment is unobservable. required: [revision, structure_version] properties: revision: { $ref: '#/components/schemas/Revision' } structure_version: { $ref: '#/components/schemas/StructureVersion' } EventHello: allOf: - $ref: '#/components/schemas/EventBase' - type: object description: | The handshake, always first. A client generated against another version closes the stream and tells the user — while the major is `0`, a differing MINOR counts. required: [contract] properties: contract: type: string description: Contract version this deployment serves, e.g. `0.9.0`. examples: ['0.9.0'] EventStatus: allOf: - $ref: '#/components/schemas/EventBase' - type: object description: | Product status changed. All three machine reasons travel with it, so a stop, a refusal and a failure are actionable without a second read; each is `null` unless its own status is the one being announced. required: [status, paused_reason, reject_reason, failure_reason] properties: status: { $ref: '#/components/schemas/BookStatus' } paused_reason: oneOf: - $ref: '#/components/schemas/PausedReason' - type: 'null' reject_reason: oneOf: - $ref: '#/components/schemas/RejectReason' - type: 'null' failure_reason: oneOf: - $ref: '#/components/schemas/RunFailureReason' - type: 'null' EventProgress: allOf: - $ref: '#/components/schemas/EventBase' - type: object description: The run's counter moved. Applied as it is; no read follows. required: [progress] properties: progress: { $ref: '#/components/schemas/Progress' } EventSessionEnded: allOf: - $ref: '#/components/schemas/EventBase' - type: object description: | Nothing further will arrive because the SESSION is over — it was revoked, or it reached the absolute lifetime the deployment sets. **The client must sign in again; a reconnect without doing so is answered `401`.** Told apart from `end` and `resync_required` by the frame's `event` name and by nothing else: `end` means the BOOK is finished and there is nothing to come back for, `resync_required` means ask again from scratch — and neither is true here. A client that treats this as either one loops into `401` with nothing to explain it. A frame of the CONNECTION, like `hello`: it carries the id of the last historical frame and consumes no number of its own. EventChapter: allOf: - $ref: '#/components/schemas/EventBase' - $ref: '#/components/schemas/ChapterProgress' EventNote: allOf: - $ref: '#/components/schemas/EventBase' - type: object description: | A note appeared, and the frame CARRIES it — with an identity of its own it is a delta a client can apply, which is why it must never be coalesced or dropped. required: [note] properties: note: { $ref: '#/components/schemas/Note' } EventBank: allOf: - $ref: '#/components/schemas/EventBase' - type: object description: | The bank changed, or a signing stop happened. The counters are the delta a screen header needs; the ROWS are read with `after_version` set to the revision the client last applied. A correction (`POST …/bank/corrections`) does not produce this frame: it changes no row until the next run. required: [total, signed] properties: total: { type: integer, minimum: 0 } signed: { type: integer, minimum: 0 } EventResyncRequired: description: | The server cannot resume from the presented `Last-Event-ID`, or the book's collections were replaced wholesale. The client MUST re-read what it holds in full: a delta cannot express a deletion. Payload is exactly `EventBase` — no member of its own. Told apart from `end` by the frame's `event` name and by nothing else; see the dispatch rule on `EventEnvelope.data`. allOf: - $ref: '#/components/schemas/EventBase' EventEnd: description: | Nothing further will arrive: no run is live and no intake is in flight. The client closes and does NOT reconnect automatically; it opens a new stream when it has a reason to watch again. Payload is exactly `EventBase` — no member of its own. Told apart from `resync_required` by the frame's `event` name and by nothing else. allOf: - $ref: '#/components/schemas/EventBase' Problem: type: object description: | Error, per RFC 9457 with the extension members below. **The machine identifier is `code`.** `type` is `about:blank` on every response and carries no information: this deployment serves no problem-type documents. On THIS surface `code` is always present; outside the version prefix it may be absent (header, `/auth/*`) — a parser applied there must tolerate that rather than fail. ⚠ **`title` and `detail` are written for a DEVELOPER and a log, and a client MUST NOT show either to a user.** They are English and will not be translated. The sentence the user reads is drawn by the CLIENT from `code`, in the language of the interface — a neutral phrase for a code it does not know. Neither field ever carries text from inside the translation machinery. **Extension members are defined per `code`** (RFC 9457 §3.2) and are absent where the code does not define them — one of the two places here where absence means "does not apply": | member | carried by | |---|---| | `errors` | `invalid_request` | | `cause` | any code with a narrower cause to give | | `blocked` | `ceiling_unavailable` | | `refusals` | `bank_corrections_refused` | | `localized` | codes whose cause cannot be enumerated | required: [type, title, status, code, request_id] properties: type: type: string format: uri description: Always `about:blank`. See above. title: type: string minLength: 1 description: | Short developer-facing name of the failure, for a log. Never empty, never shown. status: { type: integer } detail: type: string description: Developer-facing sentence about THIS occurrence, for a log. Never shown to a user. code: { $ref: '#/components/schemas/ErrorCode' } request_id: type: string minLength: 1 description: | Identifier of the request that failed, echoed on every response as `X-Request-Id`. **A client MAY show it**: it identifies a request, never a person, and an error screen without it makes a user's report unsearchable. cause: $ref: '#/components/schemas/ErrorCause' description: | Narrower cause within `code`, when there is one to give. errors: type: array description: | Which parts of the request were wrong. Carried by `invalid_request` and possibly empty — a request can be unreadable as a whole. items: { $ref: '#/components/schemas/ErrorItem' } blocked: $ref: '#/components/schemas/Blocked' description: | Carried by `ceiling_unavailable` when another book of the account holds the credit — the same shape `RunOptions` answers. refusals: type: array minItems: 1 description: | Which corrections were refused and why. Carried by `bank_corrections_refused`; the whole set was refused and NOTHING was applied. items: { $ref: '#/components/schemas/CorrectionRefusal' } localized: $ref: '#/components/schemas/LocalizedMessage' description: | A phrase written by the SERVER, to be shown as it is — the single exception, for causes that cannot be enumerated in advance. No code in this version carries it; the companion carries the rule for when one may. ErrorCode: type: string description: | Root reason a request failed: stable, closed for this version, and the only thing a client dispatches on. A narrower cause travels in `cause`, which is NOT closed — that split is what lets a new case appear without breaking a client, so a client MUST match on `code` first. Each code names its status. **`500` is deliberately not enumerated on any operation** — it can answer any of them and is not something a client branches on — but it carries `internal_error` in the same shape as every other error. - `invalid_request` (400) — the request could not be read, or violates the declared form. `errors` says which part. This is the code the intake answers when a part arrives after the file, when a field is longer than this deployment reads, when the language codes are malformed or name a pair this deployment cannot translate; - `unauthenticated` (401) — no live session; - `forbidden` (403) — the marker header of the cookie scheme was missing on a request presented by cookie, or the request came from an origin this deployment does not accept; - `not_found` (404) — no such object, or one this account may not see, or a path this deployment does not serve; - `gone` (410) — the identifier does not belong to the current structure and will not be reissued. Told apart from `not_found` because the remedy differs: re-read the collection, rather than check the address. The operation says where it is also the answer to an identifier that never existed; - `request_timeout` (408) — the body did not arrive whole in time. Retrying is the remedy; - `payload_too_large` (413) — the body is over the bound the operation declares: `intake_max_bytes` on the intake, 1 MiB on bank corrections; - `run_in_flight` (409) — this book is already being translated; - `book_not_ready` (409) — the book cannot be translated yet: it is still arriving, still being cut, or was rejected; - `run_not_stoppable` (409) — this run is not running; - `run_not_resumable` (409) — this run cannot be continued. `cause.code` says why: `ceiling_reached` — THIS RUN is finished with: it stopped at the limit it was given, or has spent all of it. The remedy is a NEW run rather than this call, and it is the answer in every status, not only `paused` (see `resumeRun`). `credit_unavailable` — the run has room left but the ACCOUNT cannot cover the rest of it; the remedy is to top up, and no new run would help either. The two never stand in for one another. A run that is not a stopped run at all carries no `cause`. An incomplete set of bank decisions is NOT a reason: signing is one act over the whole bank, and `resume` lifts that stop with the decisions as they stand; - `ceiling_unavailable` (409) — the limit asked for cannot be started. `cause.code`: `bounds_moved` — the bounds changed between the read and this call; `credit_held` — the account's credit is held elsewhere, and `blocked` names the book holding it; - `idempotency_conflict` (409) — an `Idempotency-Key` was re-used. `cause.code`: `key_reused` for a different request under the same key, `key_in_flight` for one that is still running, and then `Retry-After` says how long to wait; - `bank_corrections_refused` (409) — the correction document was read, understood and declined WHOLE; nothing was applied. A legal refusal, not a fault: what the document asks contradicts the book as it stands — a term that came with the book, a contradiction within the set, an `id` no current row carries, a `decline` nothing recorded answers to, or a set larger than the service applies in one act. Re-sending an already-recorded correction is NOT this — that answers `200` with `already_applied`. ⚠ **A NEW REASON JOINED THIS LIST ON 05.09 — the SHAPE of the refusal is unchanged.** The engine now rejects a row for its own SHAPE: `src`/`dst` longer than 200 runes, or carrying a control character, U+2028/2029 or a bidi override (`backend/internal/membank/wirefence.go`, greps `WireFieldMaxRunes` and `forbiddenWireRune`; landed `81a89e9`). ⚠ That refusal is PER-ROW and names the offending index in `refusals[]` — the set is still declined WHOLE and nothing is applied (`backend/internal/membank/decisions.go`, greps `All-or-nothing` and `res.Accepted = nil`), exactly as the paragraph above says. The bound stands on every paid call because a user's text used to reach the model's SYSTEM message verbatim. The vocabulary entry and the `maxLength` on `src`/`dst` arrive with the next contract minor — the schema is not moved ahead of an agreed mirror; until then a client learns the rule by having a save refused. The USER re-decides and sends a new document; `refusals` names every refused entry. Distinct from `invalid_request`, which is about the request's FORM; - `bank_corrections_incomplete` (503) — the correction document was ACCEPTED and the write did not land whole. The remedy is to re-send the SAME document: the retry converges — what landed is recognised, not duplicated. Distinct from `service_unavailable` because the remedy is this specific; - `content_refused` (400) — the service will not do this work. **One coarse code for a whole class** and deliberately so: it does not say which check refused, does not vary between attempts, and carries neither `cause` nor `errors`. A client shows one neutral phrase and does not invite a retry. A refusal of a whole BOOK is not reported here at all: it is a state of the book, `rejected` with `reject_reason: content_refused`. ⚠ **Declared ahead of any producer** — the check that would raise it is not built, and nothing on either side of this boundary emits it today. It is here because the shape of the answer was settled before the check was, and settling it later would have meant settling it under pressure. The obligation that comes with it — that whatever raises it also bounds how often one account may try, since an unbounded refusal is an oracle for guessing past it — falls due WITH the producer, not before; the companion carries who owes both. That bound is the server's and is never on the wire in any form: a number a client can read is a number a client can plan around, which is the whole of what the bound exists to stop. A client handles the code from today: an old client meeting it for the first time is the failure this note exists to prevent; - `service_unavailable` (503) — the deployment cannot do this right now; - `internal_error` (500) — a defect on our side. Nothing about it is actionable by a client beyond quoting `request_id`. enum: - invalid_request - unauthenticated - forbidden - not_found - gone - request_timeout - payload_too_large - run_in_flight - book_not_ready - run_not_stoppable - run_not_resumable - ceiling_unavailable - idempotency_conflict - bank_corrections_refused - bank_corrections_incomplete - content_refused - service_unavailable - internal_error ErrorCause: type: object description: | The second level of the code. Its vocabulary is NOT closed and grows without a minor bump, so a client that does not recognise one falls back to the root `code` and loses only precision. required: [code] properties: code: type: string minLength: 1 description: Narrower cause within the root code. examples: ['ceiling_reached'] ErrorItem: type: object description: | One thing wrong with the request — used to mark a field on the form; the sentence is drawn from the code as everywhere else. required: [pointer, code] properties: pointer: type: string minLength: 1 description: | JSON Pointer to the offending member, or `/` naming a form part. A cause with no field — a form with too many parts — is reported by the root code alone. examples: ['/source_lang'] code: type: string minLength: 1 description: | `missing` · `missing_or_late` (absent, or sent after the file) · `malformed` · `too_long` · `unsupported_pair` · `out_of_range` · `unknown` (an identifier this book does not hold). Not closed, like `cause.code`. examples: ['missing_or_late'] LocalizedMessage: type: object description: | A phrase produced by the server, to be shown as it is — only where the client cannot hold it. required: [locale, message] properties: locale: type: string minLength: 2 description: BCP 47 tag of the language the message is written in. examples: ['ru'] message: type: string minLength: 1 description: The phrase, ready to show.