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