openapi: 3.1.0 info: title: TextMachine API version: 0.3.0 summary: Ratified contract between the frontend and the TextMachine platform (D39.99, D39.138). 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, no money sums. The one exception is the book's memory bank: the work stops there for a signature, and a stop the user must clear cannot be hidden. 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. ## 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. ## 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. 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`. ## 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 and term signing. - 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` carries `parsing`, not `uploading`** — it is written after the last byte lands. `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: `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`. 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`. 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 that existed and no longer does: a book cut again leaves the old ids gone rather than absent, and the client re-reads the tree. `404` would mean a typo. 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. **This read is also the STATE of a signing stop**: `pending_decisions` and `complete` are answered here and not only in a submission receipt, so a screen reloaded mid-stop knows whether the work is finished. **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/decisions: parameters: - $ref: '#/components/parameters/BookId' post: tags: [bank] operationId: submitBankDecisions summary: Submit term decisions. description: | **Signing is not a row edit**: the bank is rebuilt from its inputs on every run, so a direct write would be erased. A decision is `approve` (with a translation) or `decline`, keyed by term, so re-sending one is harmless. Submission is PARTIAL and accumulates on the server — a closed tab must not cost an hour of work. parameters: - $ref: '#/components/parameters/ClientHeader' requestBody: required: true content: application/json: schema: { $ref: '#/components/schemas/BankDecisionsRequest' } responses: '200': description: Decisions accepted; the response carries what is left. content: application/json: schema: { $ref: '#/components/schemas/BankDecisionsResult' } '400': { $ref: '#/components/responses/BadRequest' } '401': { $ref: '#/components/responses/Unauthorized' } '403': { $ref: '#/components/responses/Forbidden' } '404': { $ref: '#/components/responses/NotFound' } /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' } /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 waits for the bank to be signed; without it the unsigned bank is carried forward marked as unverified. **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. 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. **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. 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. Between the two the client shows its own pending state. `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. **409 while the set of bank decisions is incomplete** — the stop clears only on a complete set; `cause.code: bank_decisions_incomplete`. ⚠ **A run stopped at its limit is NOT continued by this call** — `409`, `cause.code: ceiling_reached`. The limit travels with the START of a run and nothing changes it afterwards. The remedy is a NEW run with a larger `ceiling_chapters`: the paused book is startable and finished work is not bought again. A client offers that, not this call. **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, as a share of the account's grants. Money SUMS never cross this boundary in any form: a percentage, never an amount. 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. 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' } 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. ⚠ **Nothing issues such a token today** — the server accepts one, but no call here or in the session mechanics hands one out. Carrier: research/28 §2 (Б-15); companion §3. 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 because the browser client always has to send it; a client presenting a bearer token is exempt by the security scheme. 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 envelope just received**; 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. 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 object existed and does not any more; its identifier will not be reissued. A client holding a reference re-reads the collection it came from. headers: X-Request-Id: { $ref: '#/components/headers/RequestId' } content: application/problem+json: schema: { $ref: '#/components/schemas/Problem' } TooLarge: description: | File over the intake cap (`intake_max_bytes` of `GET /capabilities`). 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. 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. **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 its revision is that of the OLDEST page — stamped with the newest, a list whose head predates an applied frame would pass the guard above and overwrite it. **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] 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 - 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.3.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 } 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 the current SEGMENT of work has got, in chapters. A segment is the work between two stops: to the point where the run stops for the bank to be signed, and from there to the end of what the run bought. **When a stop is cleared the counter starts again from zero** — the two segments cover the same chapters and are never added together or compared. **`total` is what this run BOUGHT**, not the length of the book, so the fraction always reaches one. The book-wide figure is `Book.chapters_done` against `Book.chapter_count` and answers a different question. No ready-made percentage is shipped: how a fraction is drawn is a product decision. required: [done, total, eta_seconds] properties: done: type: integer minimum: 0 description: Chapters finished in this segment. total: type: integer minimum: 0 description: Chapters this segment covers — what the run bought. eta_seconds: type: [integer, 'null'] minimum: 0 description: | Estimated seconds to the end of the segment, 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; - `parser_unavailable` — the service failed on the file repeatedly and gave up. Also a state of the service, and temporary; - `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. 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, parser_unavailable, content_refused] PausedReason: type: string description: | Machine reason a run is paused; the client draws the phrase. One value today. 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] 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 - added_at - note_count 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' } chapter_count: type: integer minimum: 0 description: Chapters the book was cut into. chapters_done: type: integer minimum: 0 description: | Chapters fully translated. Against `chapter_count` this is the book's own progress — what a library row shows — and it never moves backwards WITHIN one `structure_version`; cutting the book again recomputes both numbers. The bar of a RUNNING run is `Run.progress`, which measures what that run bought. character_count: type: [integer, 'null'] minimum: 0 description: | Size of the source in characters, or `null` while the book is still arriving. 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 - 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. ceiling_chapters: type: integer minimum: 1 description: | The limit this run was started with, in CHAPTERS — a property of the RUN. Present so a reloaded screen can name the limit the user chose and read `progress.total` against it. 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. 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 — the same accounting as `Progress`, one level down. `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`. 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 (companion K-6), 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. Not 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/decisions` does not set it: a decision is recorded against the row and its 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 — companion §2.8. 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 surfaces, sense and window — so 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, and the row still needs signing. 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. Distinct from the counters below: a row can be decided and NOT signed, because declining is also a decision. pending_decisions: type: integer minimum: 0 description: | How many proposed terms still have no decision. complete: type: boolean description: | The set is complete. The stop clears ONLY on a complete set, so a client shows "N of M decided" and does not offer to continue while this is `false`. terms: type: array items: { $ref: '#/components/schemas/BankTerm' } BankDecision: type: object description: | A decision on one proposed term. `dst` is mandatory and non-empty for `approve`: a signed term with an empty translation matches nothing yet reads as an intended rendering. required: [term_id, action] if: properties: action: { const: approve } required: [action] then: required: [dst] properties: dst: { minLength: 1 } properties: term_id: { $ref: '#/components/schemas/Id' } action: type: string enum: [approve, decline] description: '`approve` — take the term into the book (with a translation in `dst`); `decline` — leave it out.' dst: type: string description: | Translation. **Required and non-empty when `action` is `approve`** — in words as well as in the constraint above, because a generator ignores `if`/`then`. BankDecisionsRequest: type: object required: [decisions] properties: decisions: type: array minItems: 1 maxItems: 1000 description: | Decisions to record, bounded like every other collection here. items: { $ref: '#/components/schemas/BankDecision' } BankDecisionsResult: type: object description: | Receipt of a submission: the same facts the bank read answers, so a client updates its screen without a second call. required: [revision, pending_decisions, complete] properties: revision: { $ref: '#/components/schemas/Revision' } pending_decisions: type: integer minimum: 0 description: How many proposed terms still have no decision. complete: type: boolean description: The set is complete and the stop can be cleared. RunRequest: type: object required: [stop_for_signing, ceiling_chapters] properties: stop_for_signing: type: boolean description: | Stop when the book's terms are ready and wait for them to be signed; without it the unsigned bank is carried forward marked as unverified. ceiling_chapters: type: integer minimum: 1 description: | Limit of THIS run, in chapters, within the bounds from `GET /books/{bookId}/run-options`. Required: a run started without a declared limit would spend past the boundary the user is entitled to set BEFORE it begins rather than learn about after. `0` is not legal. RunOptions: type: object required: [ceiling, blocked] properties: ceiling: { $ref: '#/components/schemas/CeilingBounds' } 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. 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. `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. 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`. 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. A percentage, never an amount. 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 description: | Machine reason when `state` is `failed`, `null` otherwise; the phrase is the client's. Not enumerated — how an export can fail depends on formats that do not exist yet. It becomes an enum with the first built format. Carrier: research/28 §2 (Б-4). url: type: [string, 'null'] format: uri description: | Where to download it from; `null` unless `state` is `ready`. **Minted for THIS response and for 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. EventEnvelope: type: object description: | An SSE frame. 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 segment 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 | | `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, 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` 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 gap is legal** — coalescing removes frames, and a client MUST NOT read a skipped number as a lost frame. data: description: | Frame payload. `anyOf` and not `oneOf`: dispatch is by the event NAME, and two frames legally carry the same shape. 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/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.3.0`. examples: ['0.3.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 segment counter moved. Applied as it is; no read follows. required: [progress] properties: progress: { $ref: '#/components/schemas/Progress' } 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. required: [total, signed, pending_decisions, complete] properties: total: { type: integer, minimum: 0 } signed: { type: integer, minimum: 0 } pending_decisions: { type: integer, minimum: 0 } complete: { type: boolean } EventResyncRequired: allOf: - $ref: '#/components/schemas/EventBase' - type: object 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. EventEnd: allOf: - $ref: '#/components/schemas/EventBase' - type: object 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. 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. ⚠ **`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` | | `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. 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. Carrier: research/28 §8 п.4. 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 object existed and does not any more, and its identifier will not be reissued. Told apart from `not_found` because the remedy differs: re-read the collection, rather than check the address; - `request_timeout` (408) — the body did not arrive whole in time. Retrying is the remedy; - `payload_too_large` (413) — over `intake_max_bytes`; - `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: `bank_decisions_incomplete` — terms are still undecided; `ceiling_reached` — the run stopped at its limit, and the remedy is a NEW run with a larger one, not this call; - `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; - `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, and the server bounds how many times one account may try — that bound is the server's and is not on the wire. 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`; - `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 - 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: ['bank_decisions_incomplete'] 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`. 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.