openapi: 3.1.0 info: title: TextMachine API version: 0.2.2 summary: Ratified contract between the frontend and the TextMachine platform (D39.99). description: | **RATIFIED contract (D39.99, 04.08.2026).** The canonical copy lives in `docs/architecture/14-api-contract/` (orchestrator's zone); `frontend/docs/api-contract/` is a byte-mirror of it, and a divergence is a defect of one of the two, never a local edit. Companion document: the `README.md` beside the canonical copy — provenance of every decision (derived from engine code / proposed by the frontend / open), rationale, dependencies and open questions. This file is normative for the FORM; the companion explains where the form comes from. ## Boundaries The frontend reads the platform read-model only. The engine is never addressed by any path below (D39.85). Pipeline vocabulary does not cross this boundary: no model names, no stage names, no money. The read-model → frontend projection is an allowlist — a field not named here never reaches the browser. No response is served from an indexable URL. The app lives on `app.` under `X-Robots-Tag: noindex`; responses carrying translated text MUST be sent with `Cache-Control: no-store`. ## Transport The browser client is served from the **same origin** as this API. That is a fact of the platform, not a setting: it has no CORS layer at all — a preflight `OPTIONS` carrying a foreign `Origin` is answered `401` by the session guard, and no `Access-Control-*` header is sent on any response. A cross-origin browser client is therefore inoperable as a class rather than unconfigured, and nothing here is designed around cross-origin requests. A development server reaches this API through a proxy onto its own origin. Session mechanics — starting a login, finishing it, ending one session, ending all of them — live OUTSIDE the version prefix, like `/healthz`, and are described in the companion rather than here: they are the mechanics of holding a session, not a contract surface. ## Versioning Semver. **Minor** — backwards-compatible additions: a client MUST ignore unknown fields and MUST tolerate unknown enum values without failing. **Major** — a client MUST refuse an unsupported version and tell the user. ⚠ **While the version is 0.x, a MINOR bump is the lane for breaking changes** (semver §4: "Major version zero … anything MAY change at any time"), and 0.2.0 carries several — a required `next_cursor` on every list, a required `ceiling_chapters` on a run request. A client pins the exact 0.x version it was generated against and does not assume compatibility across minors. From 1.0.0 the rule above applies unqualified. Ratified by the orchestrator at the S3 landing: session S3 named the contradiction between this section and its own bump instead of resolving it by its own hand, which was correct — the versioning rule is the contract owner's. Every `enum` below is the vocabulary of THIS version, not a closed world. Generated types are closed unions and do NOT protect against an unknown value, so the unknown-value branch belongs on the client seam (`src/api/`), where values enter, not in every component. license: name: UNLICENSED identifier: LicenseRef-proprietary servers: - url: https://app.example.org/v0 description: | The platform, on the SAME ORIGIN as the browser client (see Transport). Only the version prefix is fixed here: the host is whatever origin served the application, and a client that hard-codes one is a client that cannot be deployed anywhere else. security: - sessionCookie: [] - bearerToken: [] tags: - 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 progress, control. - name: export description: Export of a finished book. - name: account description: Credit balance of the account. paths: /books: get: tags: [library] operationId: listBooks summary: Book library. description: | Flat list of the user's books. `revision` is the revision of the LIBRARY itself (membership and statuses) and belongs to the library's own scope: it is never compared with the revision of a book. Page size default is the platform's choice here — the client MUST follow `next_cursor` until it is `null` rather than assume the library fits in one page. parameters: - $ref: '#/components/parameters/Limit' - $ref: '#/components/parameters/Cursor' responses: '200': description: Library. content: application/json: schema: { $ref: '#/components/schemas/Library' } '400': { $ref: '#/components/responses/BadRequest' } '401': { $ref: '#/components/responses/Unauthorized' } post: tags: [library] operationId: createBook summary: Add a book. description: | Accepts the file and the properties declared by the user. Responds immediately; the book enters `uploading` and parsing is a separate visible step. requestBody: required: true content: multipart/form-data: schema: { $ref: '#/components/schemas/BookIntake' } responses: '201': description: Book accepted. content: application/json: schema: { $ref: '#/components/schemas/Book' } '400': { $ref: '#/components/responses/BadRequest' } '401': { $ref: '#/components/responses/Unauthorized' } '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. responses: '200': description: Book card. content: application/json: schema: { $ref: '#/components/schemas/BookDetail' } '401': { $ref: '#/components/responses/Unauthorized' } '404': { $ref: '#/components/responses/NotFound' } /books/{bookId}/chapters: parameters: - $ref: '#/components/parameters/BookId' get: tags: [reading] operationId: listChapters summary: Chapter tree. description: | Chapters in reading order. A chapter has NO status, only unit progress: bank signing is a single book-wide stop, so "one chapter awaits signing while its neighbour finalizes" cannot happen. Default page size **5000**. parameters: - $ref: '#/components/parameters/Limit' - $ref: '#/components/parameters/Cursor' responses: '200': description: Chapters of the book. content: application/json: schema: { $ref: '#/components/schemas/ChapterList' } '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 unit of shipping is the EDIT UNIT, not a paragraph and not a chunk: roughly 1.9 units per chapter, and a whole chapter is sometimes a single block. Alignment is coarse and accepted as such. **Units are read PER CHAPTER and only per chapter.** A book-wide units endpoint is never introduced: the whole memory model of the client stands on this — the working set stays tens of kilobytes instead of tens of megabytes. Page size default is the platform's choice; the client follows `next_cursor`. parameters: - $ref: '#/components/parameters/Limit' - $ref: '#/components/parameters/Cursor' responses: '200': description: Pairs of the chapter. content: application/json: schema: { $ref: '#/components/schemas/UnitList' } '400': { $ref: '#/components/responses/BadRequest' } '401': { $ref: '#/components/responses/Unauthorized' } '404': { $ref: '#/components/responses/NotFound' } /books/{bookId}/notes: parameters: - $ref: '#/components/parameters/BookId' get: tags: [reading] operationId: listNotes summary: Notes of a book. description: | A note addresses a unit or a whole chapter. Byte offsets do not exist in the engine's checks and are not planned. Default page size **500**. parameters: - $ref: '#/components/parameters/Limit' - $ref: '#/components/parameters/Cursor' responses: '200': description: Notes of the book. content: application/json: schema: { $ref: '#/components/schemas/NoteList' } '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: | ⚠ **No backing channel exists for this read today.** The engine ships a signing table, not a bank export, and its private store must not be read by the platform. The export artifact is a dependency the frontend cannot create — companion §3. Default page size **1000**. parameters: - $ref: '#/components/parameters/Limit' - $ref: '#/components/parameters/Cursor' responses: '200': description: Bank of the book. content: application/json: schema: { $ref: '#/components/schemas/Bank' } '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 pipeline replaces a book's whole glossary from its deterministic inputs, so a direct write would be erased by the next run. A decision is `promote` (with a translation) or `decline`, following the stop mechanics exactly. Submission is PARTIAL and accumulates on the server: there are hundreds of terms, and a closed tab must not cost an hour of work. 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' } '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-ceiling scale, read right before a run is started. A resource of its own rather than 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 — at the moment the user is dragging 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: | `verify_bank` is a parameter of the RUN, not a global setting. With it the run stops at the bank boundary; without it the unsigned bank is carried forward marked as unverified. It is the user's choice between "I will sign" and "translate as is". `ceiling_chapters` is a parameter of the RUN as well, and it is not a property of the book: it travels with the start and does not outlive the run. **409** also answers a ceiling that no longer fits: the bounds are read by `GET /books/{bookId}/run-options` and may move between that read and this call, because a hold taken for another book lowers the remainder. 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' } '404': { $ref: '#/components/responses/NotFound' } '409': { $ref: '#/components/responses/Conflict' } '503': { $ref: '#/components/responses/ServiceUnavailable' } /runs/{runId}/events: parameters: - $ref: '#/components/parameters/RunId' get: tags: [runs] operationId: streamRunEvents summary: Live run events (SSE). description: | `text/event-stream`. Required: HTTP/2 at the edge, `Cache-Control: no-store`, `X-Accel-Buffering: no`, a heartbeat every ~20 s, a monotonic `id` and `Last-Event-ID` support. Events are PUSHED by the platform worker; the frontend never polls the read-model. **The first event is always `hello`** — the version handshake. A client that does not support the major version closes the stream and tells the user. **Reconnect.** The client sends `Last-Event-ID`. If the server cannot resume from it, it MUST answer with `resync_required` instead of silently starting from the present moment; the client then re-reads snapshots. Replaying history is forbidden — a one-shot event such as `note` would otherwise be lost silently. OpenAPI does not type SSE frames, so the event name → payload schema mapping is a table in the `EventEnvelope` description. responses: '200': description: Event stream. content: text/event-stream: schema: { $ref: '#/components/schemas/EventEnvelope' } '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. The engine stops gracefully on a signal; deciding who pressed it belongs to the platform. responses: '202': description: Stop accepted. content: application/json: schema: { $ref: '#/components/schemas/Run' } '401': { $ref: '#/components/responses/Unauthorized' } '404': { $ref: '#/components/responses/NotFound' } '409': { $ref: '#/components/responses/Conflict' } /runs/{runId}/resume: parameters: - $ref: '#/components/parameters/RunId' post: tags: [runs] operationId: resumeRun summary: Resume a stopped run. description: | Clears the bank-signing stop and continues after a user stop. ⚠ **After a ceiling stop this call alone does not move the run.** The engine continues only once the ceiling has been raised, and no handle raises it: `ceiling_chapters` travels with the START of a run and this contract has no way to change it afterwards. The mechanism is the platform's and does not exist yet (companion §3); until it does, `resume` on a run paused by a ceiling returns it to the same state, so the client MUST NOT offer resume as the remedy for `paused`. **Answers 409 while the set of bank decisions is incomplete** — the stop clears only on a complete set. responses: '202': description: Resume accepted. content: application/json: schema: { $ref: '#/components/schemas/Run' } '401': { $ref: '#/components/responses/Unauthorized' } '404': { $ref: '#/components/responses/NotFound' } '409': { $ref: '#/components/responses/Conflict' } /usage: get: tags: [account] operationId: getUsage summary: State of the credit balance. description: | Credits are a BALANCE, not a subscription with windows. There is no period, no `resets_at` and no "resets in": the screen shows what is LEFT. The percentage is computed against the sum of the account's grants, not against a limit of a period — periods do not exist. 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' post: tags: [export] operationId: createExport summary: Build a book export. description: | Formats and their contents are stage S7 work; only the call shape is fixed here. The export is an ARTIFACT BEHIND A LINK. Assembling the text of a book on the client — reading every chapter and stitching it together — is forbidden explicitly: it would defeat the per-chapter working set that the read paths are built around. Completion is POLLED, not pushed: the `202` names the status resource in `Location`, and the status read carries `Retry-After`. 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' } '401': { $ref: '#/components/responses/Unauthorized' } '404': { $ref: '#/components/responses/NotFound' } /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: it answers `ready: false` and nothing ever says otherwise. **Completion is polled, not pushed** — no stream frame announces it. responses: '200': description: State of the export. headers: Retry-After: description: | Seconds to wait before polling again; sent while `ready` is `false`. Declared here on purpose. RFC 9110 defines this header for `503` and for `3xx`, and its general semantics do not reach a `200`, so a contract that wants it on a `200` has to say so itself. 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 browser client MUST send the header `X-TM-Client` on every UNSAFE request — anything other than GET and HEAD. What carries the protection is the PRESENCE of the header; the value is arbitrary and has no token semantics, so do not invent any. It is required on same-origin requests as well: it is not a CORS mechanism. The same requirement holds for the session-mechanics endpoints that live outside the version prefix (companion). bearerToken: type: http scheme: bearer description: | Desktop and CLI present the same server-side session as an opaque token. The principal is established in middleware only; no endpoint may assume a cookie — that is what keeps the API portable to the desktop client. 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' } Limit: name: limit in: query required: false description: | Page size. The default is stated per collection on the operation; a server MAY return fewer rows than asked for, and the client decides nothing from that — only from `next_cursor`. schema: { type: integer, minimum: 1 } Cursor: name: cursor in: query required: false description: | Keyset cursor taken 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 rejected with `400`; see `NextCursor` for why that rejection is the server's duty and not the client's. schema: { type: string, minLength: 1 } responses: BadRequest: description: Request rejected. content: application/problem+json: schema: { $ref: '#/components/schemas/Problem' } Unauthorized: description: Session missing or invalid. content: application/problem+json: schema: { $ref: '#/components/schemas/Problem' } NotFound: description: Object not found. content: application/problem+json: schema: { $ref: '#/components/schemas/Problem' } Conflict: description: Action impossible in the current state. content: application/problem+json: schema: { $ref: '#/components/schemas/Problem' } TooLarge: description: File exceeds the intake size limit. content: application/problem+json: schema: { $ref: '#/components/schemas/Problem' } ServiceUnavailable: description: | The deployment cannot perform this action right now (added in 0.2.1, D39.123): starting a run requires the engine seam to be fully configured, and answering with any other code would misname the state. Temporary by nature — retry later; no Retry-After is promised. 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 across runs is the platform's job when it persists the manifest. examples: ['bk_7c1'] Revision: type: integer minimum: 0 description: | Monotonic revision. **The counter is PER BOOK:** every book-scoped read and the `id` of every stream frame of that book's run carry the same number. The library has a scope of its own. A revision is monotonic WITHIN its scope and is NEVER compared across scopes. Discarding a stale read is the CLIENT's duty: a read whose revision is lower than what the client has already applied MUST be dropped rather than rendered, otherwise the interface rolls progress backwards on every refetch — and a refetch on window focus is the default behaviour of the client's query layer, so the race happens on every tab switch. **Catch-up after a reconnect reads `revision >= R`, not `> R`.** One transaction is one revision but SEVERAL frames; strict "greater than" drops the sibling frames of the last one the client applied. After a transaction of FULL REPLACEMENT — the bank rebuilt from scratch, re-chunking replacing the chapters — the server MUST answer `resync_required` rather than a delta: a delta read cannot express a deletion. examples: [1841] NextCursor: type: [string, 'null'] description: | Cursor of the NEXT page, or `null` on the last one. Present on EVERY list response, always — introducing it later would silently cut the tail off a client that does not read the field. The cursor is bound to the STRUCTURAL epoch of the collection — the generation of the manifest, the chunker version — and **not to the revision of the book**: the revision bumps on every materialization, so binding to it would restart pagination forever while a 5000-chapter book is running. Rejecting a cursor from a dead epoch is the SERVER's duty (MUST), answered `400`. The client cannot perform it: the cursor is opaque to it by construction. LangCode: type: string pattern: '^[a-z]{2,3}(-[A-Za-z0-9]{2,8})*$' description: | Language code, never a name. The display name is computed by the screen via `Intl.DisplayNames`. examples: ['zh'] Counter: type: object description: Wave counter, in UNITS. required: [done, total] properties: done: { type: integer, minimum: 0 } total: { type: integer, minimum: 0 } Progress: type: object description: | Progress PER PHASE, in units: a unit is done only once its edit resolved, and editing does not start before the bank stop, so a single end-to-end counter reads zero for the whole draft wave. The phases exist for the DATA, not for the screen — the user sees one fraction with no phase names. No ready-made percentage is shipped: the formula is a product decision. required: [draft, edit] properties: draft: { $ref: '#/components/schemas/Counter' } edit: { $ref: '#/components/schemas/Counter' } eta_seconds: type: [integer, 'null'] minimum: 0 description: | Estimated seconds to the end of the run. **Optional:** it is absent whenever there is nothing to estimate from — before the first calls of a wave there is no throughput yet — and the screen MUST render without it rather than show a zero. BookStatus: type: string description: | Product status of a book: `uploading` file is being accepted · `parsing` split into chapters · `not_started` parsed, never run · `translating` translation in progress · `awaiting_bank` waiting for the glossary to be signed · `finalizing` final pass · `ready` done · `paused` halted and resumable · `stopped` stopped by the user · `rejected` file could not be parsed · `failed` run aborted by an error. The engine has no run-state vocabulary at all, so `not_started`, `stopped` and `rejected` are DERIVED by the contract rather than received as a field. **A ceiling stop is `paused`, never `failed`.** It is a resumable book-wide stop, and mapping it to `failed` is forbidden because that would lie about resumability. The machine reason travels as `Run.paused_reason`; the phrase the user reads is drawn by the client. A prescreen refusal maps to none of these values either — companion §4 (K-9). enum: - uploading - parsing - not_started - translating - awaiting_bank - finalizing - ready - paused - stopped - rejected - failed Book: type: object description: A book in the library. required: [id, title, source_lang, target_lang, status, chapter_count, added_at, progress, note_count] properties: id: { $ref: '#/components/schemas/Id' } title: { type: string } source_lang: { $ref: '#/components/schemas/LangCode' } target_lang: { $ref: '#/components/schemas/LangCode' } genre: { type: string, description: Genre as declared by the user. } chapter_count: { type: integer, minimum: 0 } character_count: type: integer minimum: 0 description: Size in characters. Not an engine field; the platform knows it from intake. added_at: { type: string, format: date-time } status: { $ref: '#/components/schemas/BookStatus' } progress: { $ref: '#/components/schemas/Progress' } note_count: { type: integer, minimum: 0 } Library: type: object required: [revision, next_cursor, books] properties: revision: { $ref: '#/components/schemas/Revision' } next_cursor: { $ref: '#/components/schemas/NextCursor' } books: type: array items: { $ref: '#/components/schemas/Book' } PausedReason: type: string description: | Machine reason a run is paused. The API carries STATE; the phrase the user reads is drawn by the client, so no wording appears here. One value exists today. A client MUST tolerate an unknown one arriving under a minor bump and show the neutral "halted, resumable" state rather than failing or guessing. enum: [credit_exhausted] Run: type: object description: | A run over a book. `status` reuses the book vocabulary, but the book-level values (`uploading`, `parsing`, `not_started`, `rejected`) never appear on a run. required: [id, revision, status, verify_bank, ceiling_chapters, paused_reason, started_at] properties: id: { $ref: '#/components/schemas/Id' } revision: { $ref: '#/components/schemas/Revision' } status: { $ref: '#/components/schemas/BookStatus' } verify_bank: type: boolean description: The run was requested with a stop for bank signing. ceiling_chapters: type: integer minimum: 1 description: | The ceiling this run was started with, in CHAPTERS. A property of the RUN, not of the book: it travels with the start and does not outlive the run. Present so that a reloaded screen can still name the cap the user chose. paused_reason: oneOf: - $ref: '#/components/schemas/PausedReason' - type: 'null' description: Reason when `status` is `paused`; `null` in every other state. started_at: { type: string, format: date-time } finished_at: type: [string, 'null'] format: date-time BookDetail: type: object required: [revision, book] 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. BookIntake: type: object description: Add-a-book form. required: [file, source_lang, target_lang] properties: file: type: string format: binary description: Book file. source_lang: { $ref: '#/components/schemas/LangCode' } target_lang: { $ref: '#/components/schemas/LangCode' } genre: { type: string } Chapter: type: object required: [id, number, heading, units_total, units_done, note_count] properties: id: { $ref: '#/components/schemas/Id' } number: type: [integer, 'null'] minimum: 1 description: | Displayed ordinal, or `null` when the book has no numbering — a legal book. Always present, possibly null, so the client handles one shape rather than two. **Not a key:** numbering is dense — chapters that yield no text do not consume a number — 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 client MUST NOT synthesize a label from a template such as "Chapter {n}": no such form exists, a book legally has no numbers, and a book legally has no chapters at all. An unlabelled chapter is shown without a name rather than given an invented one. The server bounds the length — in a list of 5000 rows this is the only string that would otherwise be unbounded. Who produces the label is still a backend question: today the engine glues a rendered title into the text of the first unit and leaves the source column without it — companion §4 (K-2). units_total: { type: integer, minimum: 0 } units_done: type: integer minimum: 0 description: | Units finished in this chapter. Whether "finished" needs the same draft/edit split as `Progress` — without it a chapter reads zero for the whole draft wave — is open, companion §4 (K-10). note_count: { type: integer, minimum: 0 } ChapterList: type: object required: [revision, next_cursor, chapters] properties: revision: { $ref: '#/components/schemas/Revision' } next_cursor: { $ref: '#/components/schemas/NextCursor' } chapters: type: array items: { $ref: '#/components/schemas/Chapter' } UnitState: type: string description: | State of a pair, derived from the PAIR (chunk verdict plus presence of final text), not from the verdict alone: a flagged unit legally arrives WITH text. - `translated` — text shipped. This includes a flagged unit whose text shipped anyway, such as a cosmetic sanitizer cleanup; such a unit carries a `note`; - `withheld` — verdict flagged AND no text; - `pending` — not translated yet. The word "flagged" never goes on the wire: it is pipeline vocabulary. enum: [translated, withheld, pending] Unit: type: object description: | A source/translation pair, one edit unit wide. **Freshness.** `target` is updated at stage boundaries and at stops, not continuously — mid-run there is no read channel at all. The live "something changed" signal arrives as an event; the text arrives with a read after the boundary. required: [id, source, state] properties: id: { $ref: '#/components/schemas/Id' } source: type: string description: Source text, aligned to the edit unit. target: type: string description: Translated text. Empty for `withheld` and `pending`. state: { $ref: '#/components/schemas/UnitState' } note: oneOf: - $ref: '#/components/schemas/Note' - type: 'null' UnitList: type: object required: [revision, next_cursor, units] properties: revision: { $ref: '#/components/schemas/Revision' } next_cursor: { $ref: '#/components/schemas/NextCursor' } units: type: array items: { $ref: '#/components/schemas/Unit' } NoteSeverity: type: string description: | Severity step. Two steps are a frontend PROPOSAL projected from the engine's operator severity ranks, and that axis need not match the product one. How many steps there are and where the boundary lies is an open product question — companion §4 (K-6). enum: [attention, glance] Note: type: object description: | A note in product terms. Neither the engine's flag reason nor its detail text crosses the boundary; the reason → phrase map belongs to the contract and is not filled in this draft (companion, appendix A). required: [severity, message] properties: severity: { $ref: '#/components/schemas/NoteSeverity' } message: type: string description: | Ready human phrase; the wording is the owner's call. A phrase MUST exist for every reason, including one this contract does not know yet, and it MUST read neutrally rather than as an error. chapter_id: { $ref: '#/components/schemas/Id' } unit_id: { $ref: '#/components/schemas/Id' } NoteList: type: object required: [revision, next_cursor, notes] properties: revision: { $ref: '#/components/schemas/Revision' } next_cursor: { $ref: '#/components/schemas/NextCursor' } notes: type: array items: { $ref: '#/components/schemas/Note' } TermKind: type: string description: | Kind of term. Not cosmetic: `name` and `place` ROUTE a term into transliteration, so signing a term without seeing its kind means signing blind. enum: [name, place, title, term, nickname] TermStatus: type: string description: | Signing status, THREE-VALUED; only `approved` is injected as canon. A boolean `signed` would merge "proposed by the engine, nobody looked" with "a human started and did not finish" — on a screen of hundreds of rows that is the main filter of work. enum: [auto, draft, approved] TermOrigin: type: string description: Provenance of a bank row — who created it. An axis independent of `status`. enum: [seed, ruby, mined] BankTerm: type: object description: | A memory bank row. ⚠ **The name `source` is deliberately unused here.** In the engine that column means PROVENANCE; this contract calls provenance `origin` and the term's surfaces `src`/`dst`. Naming the term's text `source` would create a false friend between the two schemas. required: [id, src, dst, kind, status, origin, sense, since_chapter, until_chapter] properties: id: { $ref: '#/components/schemas/Id' } 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 engine could not decide the kind: ruby candidates that are neither a name nor a place legally carry none. A client MUST show such a row as "kind not decided" and MUST NOT drop it or invent a kind — the row still needs signing. 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" (0.2.2).** It was optional while being named as part of the key: a client then could not tell "this term has no disambiguator" from "the field was not sent", although that is exactly the field by which two legal rows of the same surface differ. since_chapter: type: integer minimum: 0 description: | Start of the spoiler window; `0` means from the beginning of the book. A term is unique by `(book, src, sense, since, until)`, so the same `src` legally arrives as several rows — without the window they look like duplicates and get deleted. until_chapter: type: integer minimum: 0 description: End of the spoiler window; `0` means open-ended. Bank: type: object required: [revision, next_cursor, total, signed, terms] properties: revision: { $ref: '#/components/schemas/Revision' } next_cursor: { $ref: '#/components/schemas/NextCursor' } 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 on this page. 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 `promote`: the engine refuses a signed term with an empty translation on the next run, because such a term matches nothing yet reads as an intended rendering. required: [term_id, action] if: properties: action: { const: promote } required: [action] then: required: [dst] properties: dst: { minLength: 1 } properties: term_id: { $ref: '#/components/schemas/Id' } action: type: string enum: [promote, decline] description: '`promote` — accept the term (with a translation in `dst`); `decline` — reject it.' dst: type: string description: Translation. Required and non-empty when `action` is `promote`. BankDecisionsRequest: type: object required: [decisions] properties: decisions: type: array minItems: 1 items: { $ref: '#/components/schemas/BankDecision' } BankDecisionsResult: type: object 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. The stop clears ONLY on a complete set, so the screen must show "N of M decided" and must not offer to resume while this is `false`. RunRequest: type: object required: [verify_bank, ceiling_chapters] properties: verify_bank: type: boolean description: Stop for bank signing before the final pass. ceiling_chapters: type: integer minimum: 1 description: | Ceiling of THIS run, in chapters, within the bounds returned by `GET /books/{bookId}/run-options`. Required: a run started without a declared ceiling would spend past the limit the user is entitled to set before it begins rather than learn about afterwards. `0` is not a legal value — a run with a zero ceiling does not start, so it is not offered. RunOptions: type: object required: [ceiling] properties: ceiling: { $ref: '#/components/schemas/CeilingBounds' } CeilingBounds: type: object description: | Bounds of the run-ceiling scale, in CHAPTERS. The chapters → money conversion 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 it is taken, so a running balance already excludes the holds open against it, and subtracting them a second time would halve the scale. `max_chapters` of `0` means no run can start at all — the client shows the exhausted state instead of a scale. Zero is never selectable. required: [min_chapters, max_chapters, default_chapters] properties: min_chapters: type: integer minimum: 1 description: Smallest ceiling that can be started. max_chapters: type: integer minimum: 0 description: Largest ceiling that can be started; `0` when none can. default_chapters: type: integer minimum: 0 description: | Pre-selected value. The platform owns it because the choice is product policy — a client picking it would decide "spend everything" or "one chapter" on its own. `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] properties: state: type: string description: | `ok` · `low` the threshold at which the interface warns · `exhausted` nothing left. The threshold itself belongs to the platform and is not on the wire: a client that computed it from the percentage would carry 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. paused_reason: oneOf: - $ref: '#/components/schemas/PausedReason' - type: 'null' description: | Set when the account itself is in a halted state; `null` otherwise. The same value travels per-run as `Run.paused_reason`. ExportRequest: type: object required: [format] properties: format: type: string description: Export format; the set of formats is stage S7 work. Export: type: object required: [id, ready] properties: id: { $ref: '#/components/schemas/Id' } ready: { type: boolean } url: type: string format: uri description: | Link to the finished export. Served to the authenticated owner only and never indexed. 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` | counters moved | | `chapter` | `EventChapter` | a chapter's progress changed | | `note` | `EventNote` | a note appeared | | `bank` | `EventBank` | the bank changed or a signing stop occurred | | `ceiling` | `EventCeiling` | the run was halted by a ceiling | | `resync_required` | `EventResyncRequired` | resuming the stream is impossible | The frame `id` carries the book's revision — the same counter every book-scoped read carries, so a frame and a read can be ordered against each other. One transaction produces one revision but possibly SEVERAL frames, which is why catch-up reads `>=` and not `>` (see `Revision`). **The server MAY COALESCE frames**, and a client MUST tolerate counters that jump: a run over 9500 units would otherwise be an unbounded source of renders. A client therefore must not animate from its previous value as though every step had arrived, and must not treat a skipped number as a lost frame. required: [event, data] properties: event: { type: string } data: description: | Frame payload. Schemas are listed as a union rather than tied by a discriminator: `event` lives in the SSE frame, not inside `data`, so an OpenAPI discriminator does not apply. Dispatch by event name, per the table above. oneOf: - $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/EventCeiling' - $ref: '#/components/schemas/EventResyncRequired' EventHello: type: object description: Version handshake. A client that does not support the major version closes the stream. required: [contract, run_id, revision] properties: contract: type: string description: Contract version, e.g. `0.1.0`. run_id: { $ref: '#/components/schemas/Id' } revision: { $ref: '#/components/schemas/Revision' } EventStatus: type: object description: | Product status changed. `paused_reason` travels with it so that a pause is actionable without a second read — the frame that announces the stop is exactly the moment the screen has to say why. required: [status, paused_reason] properties: status: { $ref: '#/components/schemas/BookStatus' } paused_reason: oneOf: - $ref: '#/components/schemas/PausedReason' - type: 'null' EventProgress: type: object required: [progress] properties: progress: { $ref: '#/components/schemas/Progress' } EventChapter: type: object required: [chapter_id, units_done, note_count] properties: chapter_id: { $ref: '#/components/schemas/Id' } units_done: { type: integer, minimum: 0 } note_count: { type: integer, minimum: 0 } EventNote: type: object description: | A note appeared. Depends on the event emitter — the engine does not emit per-unit notes mid-run today (companion §3). required: [note] properties: note: { $ref: '#/components/schemas/Note' } EventBank: type: object required: [total, signed, pending_decisions] properties: total: { type: integer, minimum: 0 } signed: { type: integer, minimum: 0 } pending_decisions: { type: integer, minimum: 0 } EventCeiling: type: object description: | The run hit a ceiling and halted. **Carries no figures** — the fact of the stop, not a sum: money does not appear in the MVP interface at all. The resulting status is `paused`, never `failed`: the stop is resumable. Depends on the event emitter (companion §3). Which ceiling — the account's credit or the run's own `ceiling_chapters` — is not distinguished by this frame; whether the two need separate `paused_reason` values is open, companion §4 (K-13). required: [halted] properties: halted: { type: boolean } EventResyncRequired: type: object description: | The server cannot resume the stream from the presented `Last-Event-ID`. The client MUST re-read snapshots. Replaying history is forbidden — a one-shot event such as `note` would be lost silently. required: [reason] properties: reason: type: string description: Product-level reason; carries no internals. Problem: type: object description: | Error per RFC 9457. ⚠ **Neither `title` nor `detail` ever carries engine text.** The engine's own detail strings read like "CJK leak in the ru output: 第一节", which exposes how the pipeline works. The constraint applies to BOTH fields because both are shown: a client has nothing else to put on the screen when a call fails, so a `title` written for a developer becomes the sentence the reader gets. `title` is the CLASS of the failure, `detail` the specific sentence; both are product language, and either may be empty. required: [type, title, status] properties: type: { type: string, format: uri } title: type: string description: Product phrase naming the class of failure. Shown to the user as-is. status: { type: integer } detail: { type: string } instance: { type: string, format: uri-reference }