textmachine/docs/architecture/14-api-contract/openapi.yaml

2592 lines
120 KiB
YAML

openapi: 3.1.0
info:
title: TextMachine API
version: 0.4.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, no money sums. The one exception is the book's memory
bank: the work stops there for a signature, and a stop the user must clear cannot be hidden. The
projection is an allowlist: a field not named here never reaches the client. The rule binds the
PROSE too — every description here is compiled into the generated client's source.
## On every response
`X-Request-Id` · `Cache-Control: no-store` · `X-Robots-Tag: noindex`. `no-store` binds caches
(RFC 9111 §5.2.2.5), not the copy an application holds in memory — which is why the conditional
reads below still work.
## Transport
The client is served from the **same origin**. No `Access-Control-*` header is sent on any
response and a preflight `OPTIONS` with a foreign `Origin` is refused, so a cross-origin browser
client is inoperable as a class. Unsafe requests are also checked against their origin; a
rejected one is `403`. A dev server proxies onto its own origin.
**Signing in is not part of this surface**: session mechanics live outside the version prefix and
the flow starts at `GET /auth/login` (companion). A client that meets `401` sends the user there.
## `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 and term signing.
- name: runs
description: Translation runs, live events, control.
- name: export
description: Export of a finished book.
- name: account
description: Credit balance of the account.
paths:
/capabilities:
get:
tags: [deployment]
operationId: getCapabilities
summary: What this deployment can do.
description: |
What this deployment can do: contract version, the pairs it can actually translate, the size
it accepts, the formats it builds, the page size it hands out. Read once at start-up. One
deployment, one answer — not per-account, not a negotiation.
responses:
'200':
description: Capabilities of this deployment.
headers:
ETag: { $ref: '#/components/headers/ETag' }
content:
application/json:
schema: { $ref: '#/components/schemas/Capabilities' }
'304': { $ref: '#/components/responses/NotModified' }
'401': { $ref: '#/components/responses/Unauthorized' }
parameters:
- $ref: '#/components/parameters/IfNoneMatch'
/books:
get:
tags: [library]
operationId: listBooks
summary: Book library.
description: |
The user's books, newest addition first. `revision` here is the LIBRARY's own — never
compared with a book's.
Page size default: `GET /capabilities`. A client MUST follow `next_cursor` until it is
`null`.
parameters:
- $ref: '#/components/parameters/Limit'
- $ref: '#/components/parameters/Cursor'
- $ref: '#/components/parameters/IfNoneMatch'
responses:
'200':
description: Library.
headers:
ETag: { $ref: '#/components/headers/ETag' }
content:
application/json:
schema: { $ref: '#/components/schemas/BookPage' }
'304': { $ref: '#/components/responses/NotModified' }
'400': { $ref: '#/components/responses/BadRequest' }
'401': { $ref: '#/components/responses/Unauthorized' }
post:
tags: [library]
operationId: createBook
summary: Add a book.
description: |
**The `file` part MUST come LAST in the form** — it is read as a stream and reading stops at
the file. `BookIntake` lists its properties in the order they must be sent.
**A part sent after the file is refused, never ignored**: `400`, `code: invalid_request`, with
an `errors[]` entry naming it.
**The `201` carries `parsing`, not `uploading`** — it is written after the last byte lands.
`uploading` is observable only by a second read of the library while the upload is on the
wire. `Location` names the book card. Parsing has no numeric progress; its END arrives on the
book's event stream as an ordinary status change.
Refusals, 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.
**This read is also the STATE of a signing stop** — informationally, never as a gate:
`pending_decisions` and `complete` say how much of the bank a person has touched, and a
screen reloaded mid-stop can show it. **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 decisions as they
stand, and a term nobody touched rides on as the service proposed it, marked unverified
inside the service. Per-term decisions are the OPTIONAL correction path, not the unit of
signing.
**Delta read** with `after_version` — a full book's bank is too large to re-read on every
change. Page size default: `GET /capabilities`.
parameters:
- $ref: '#/components/parameters/Limit'
- $ref: '#/components/parameters/Cursor'
- $ref: '#/components/parameters/AfterVersion'
- $ref: '#/components/parameters/IfNoneMatch'
responses:
'200':
description: Bank of the book.
headers:
ETag: { $ref: '#/components/headers/ETag' }
content:
application/json:
schema: { $ref: '#/components/schemas/BankPage' }
'304': { $ref: '#/components/responses/NotModified' }
'400': { $ref: '#/components/responses/BadRequest' }
'401': { $ref: '#/components/responses/Unauthorized' }
'404': { $ref: '#/components/responses/NotFound' }
/books/{bookId}/bank/decisions:
parameters:
- $ref: '#/components/parameters/BookId'
post:
tags: [bank]
operationId: submitBankDecisions
summary: Submit term decisions.
description: |
**Signing is not a row edit**: the bank is rebuilt from its inputs on every run, so a direct
write would be erased. A decision is `approve` (with a translation) or `decline`, keyed by
term, so re-sending one is harmless.
Submission is PARTIAL and accumulates on the server — a closed tab must not cost an hour of
work.
**A `term_id` this book's bank does not hold** — a stale row from before a rebuild, or one
belonging to another book — refuses the WHOLE call: `400`, `code: invalid_request`, with an
`errors[]` entry pointing at the item (`/decisions/2/term_id`, item code `unknown`). Refusing
the batch rather than skipping the row is deliberate: a silently dropped decision reads on
the screen as a decision that was saved.
parameters:
- $ref: '#/components/parameters/ClientHeader'
requestBody:
required: true
content:
application/json:
schema: { $ref: '#/components/schemas/BankDecisionsRequest' }
responses:
'200':
description: Decisions accepted; the response carries what is left.
content:
application/json:
schema: { $ref: '#/components/schemas/BankDecisionsResult' }
'400': { $ref: '#/components/responses/BadRequest' }
'401': { $ref: '#/components/responses/Unauthorized' }
'403': { $ref: '#/components/responses/Forbidden' }
'404': { $ref: '#/components/responses/NotFound' }
/books/{bookId}/run-options:
parameters:
- $ref: '#/components/parameters/BookId'
get:
tags: [runs]
operationId: getRunOptions
summary: Bounds for starting a run.
description: |
Bounds of the run-limit scale, read right before a run starts, plus why the scale is smaller
than expected when it is.
Its own resource and not a field of the book card: the maximum depends on the ACCOUNT and
moves while the book does not, so a cached card would state a maximum that is no longer true
exactly while the user drags the scale.
responses:
'200':
description: Bounds of the scale.
content:
application/json:
schema: { $ref: '#/components/schemas/RunOptions' }
'401': { $ref: '#/components/responses/Unauthorized' }
'404': { $ref: '#/components/responses/NotFound' }
/books/{bookId}/runs:
parameters:
- $ref: '#/components/parameters/BookId'
post:
tags: [runs]
operationId: startRun
summary: Start a translation run.
description: |
`stop_for_signing` and `ceiling_chapters` are parameters of the RUN, not of the book: they
travel with the start and do not outlive it. With `stop_for_signing` the run 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.
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, as a share of the account's grants.
Money SUMS never cross this boundary in any form: a percentage, never an amount.
responses:
'200':
description: Balance state.
content:
application/json:
schema: { $ref: '#/components/schemas/Usage' }
'401': { $ref: '#/components/responses/Unauthorized' }
/books/{bookId}/exports:
parameters:
- $ref: '#/components/parameters/BookId'
- $ref: '#/components/parameters/IdempotencyKey'
post:
tags: [export]
operationId: createExport
summary: Build a book export.
description: |
Formats: `GET /capabilities`; one outside that set is `400`. Any book already cut into
chapters may be exported, finished or not — what an export of an unfinished book CONTAINS is
not fixed here.
The export is an ARTIFACT BEHIND A LINK; assembling a book's text on the client is forbidden
explicitly, as it would defeat the per-chapter working set the read paths are built around.
Completion is POLLED: the `202` names the status resource in `Location` and the status read
carries `Retry-After` while the build runs. Repeating with the same `Idempotency-Key` returns
the original `202` and `Location` rather than building a second copy.
**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' }
components:
securitySchemes:
sessionCookie:
type: apiKey
in: cookie
name: __Host-tm_session
description: |
Browser presentation of one server-side session: HttpOnly, Secure, SameSite=Lax.
**CSRF.** On the cookie path a client MUST send `X-TM-Client` on every UNSAFE request —
anything other than `GET`, `HEAD` and `OPTIONS`. What protects is the PRESENCE of the header;
the value is arbitrary and has no token semantics. Required on same-origin requests too: it
is not a CORS mechanism. It is declared as a parameter on every operation that needs it, so a
generated client sends it.
A request presenting a well-formed `Authorization: Bearer` instead of the cookie is exempt.
⚠ **This protection stands on there being no cross-origin access** (see Transport). The day
that changes, this section is rewritten rather than re-configured. The same requirement holds
for the session-mechanics endpoints outside the version prefix (companion).
bearerToken:
type: http
scheme: bearer
description: |
A server-side session as an opaque token, for a client that is not a browser. The principal
is established in middleware only; no endpoint may assume a cookie.
⚠ **Nothing issues such a token today** — the server accepts one, but no call here or in the
session mechanics hands one out. The companion carries who owes that and when.
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: |
File over the intake cap (`intake_max_bytes` of `GET /capabilities`).
headers:
X-Request-Id: { $ref: '#/components/headers/RequestId' }
content:
application/problem+json:
schema: { $ref: '#/components/schemas/Problem' }
RequestTimeout:
description: |
The body did not arrive whole inside the route's deadline: a slow client on a large book.
RETRY is the remedy, which is what separates it from `413`.
headers:
X-Request-Id: { $ref: '#/components/headers/RequestId' }
content:
application/problem+json:
schema: { $ref: '#/components/schemas/Problem' }
ServiceUnavailable:
description: |
The deployment cannot do this right now: starting or continuing a run needs its machinery
fully configured. Temporary — retry later; no `Retry-After` is promised.
headers:
X-Request-Id: { $ref: '#/components/headers/RequestId' }
content:
application/problem+json:
schema: { $ref: '#/components/schemas/Problem' }
schemas:
Id:
type: string
minLength: 1
description: |
Opaque identifier. The client MUST NOT parse, sort by or construct it.
**Stability differs by what the identifier names:**
- book, run, export — kept for as long as the object exists;
- chapter — survives a re-parse of the same source, because a chapter is identified by its
own text. It does not survive that text changing;
- PAIR (`Unit.id`) — stable only within one `structure_version`. Cutting the book
differently mints a new id for every pair while the chapters survive, so a stored anchor on
a pair is invalid the moment the version moves, and the client re-reads the chapter rather
than reporting the pair as deleted.
examples: ['bk_7c1']
Revision:
type: integer
minimum: 0
description: |
Monotonic revision. **The counter is PER BOOK:** every book-scoped read and every frame of
that book carry the same number. The library has its own scope, and a revision is never
compared across scopes.
**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]
NextCursor:
type: [string, 'null']
description: |
Cursor of the NEXT page, or `null` on the last one. Present on EVERY list response —
introducing it later would silently cut the tail off a client that does not read the field.
Bound to the `structure_version` of the collection and NOT to the book's revision: the
revision bumps on every materialization, which would restart pagination forever.
Rejecting a cursor from a structure that no longer exists is the SERVER's duty (MUST),
answered `400` with `cause.code: cursor_invalid`. The client cannot: the cursor is opaque
to it.
LangCode:
type: string
pattern: '^[a-z]{2,3}(-[A-Za-z0-9]{2,8})*$'
description: |
Language code, never a name; the display name is the client's to render. Well-formed is not
the same as supported — `GET /capabilities` names the pairs this deployment can run, and one
outside that set is refused at intake.
examples: ['zh']
Capabilities:
type: object
description: |
What this deployment can do. One flat document, the same for every account.
required:
- contract_version
- language_pairs
- intake_enabled
- intake_max_bytes
- export_formats
- page_size_default
properties:
contract_version:
type: string
description: |
The version this deployment serves — the only place a non-streaming client learns it. A
client generated against a different one REFUSES to work and says so: while the major is
`0` a differing minor carries breaking changes by design.
examples: ['0.4.0']
language_pairs:
type: array
description: |
Every pair the deployment knows about, unavailable ones included: "absent" and "listed
as unavailable" are different facts to a user waiting for one.
items: { $ref: '#/components/schemas/LanguagePair' }
intake_enabled:
type: boolean
description: |
Whether this deployment takes books at all. `false` is a read-only instance that serves
a library and answers `404` to `POST /books`. Without it a client discovers this only by
spending a user's upload.
intake_max_bytes:
type: integer
minimum: 1
description: |
Largest file this deployment accepts, when it accepts any. A client checks it before
starting an upload; the server enforces it regardless and answers `413`.
export_formats:
type: array
description: |
Formats `POST /books/{bookId}/exports` accepts. Empty means none are built here.
items: { type: string, minLength: 1 }
page_size_default:
type: integer
minimum: 1
maximum: 1000
description: |
Rows a collection returns when `limit` is omitted. Never larger than the maximum a
client may ASK for, which is why it carries the same bound; that maximum is defined once,
on the `limit` parameter, and this is its consequence rather than a second copy.
LanguagePair:
type: object
description: A translation direction this deployment knows about.
required: [source, target, state]
properties:
source: { $ref: '#/components/schemas/LangCode' }
target: { $ref: '#/components/schemas/LangCode' }
state:
type: string
description: |
`available` — books in this pair can be translated here · `unavailable` — the deployment
knows the pair and cannot run it.
enum: [available, unavailable]
Page:
type: object
description: |
The envelope every collection answers in, composed into each list so that "every page carries
a revision and a next cursor" is checkable rather than merely observed.
required: [revision, next_cursor]
properties:
revision: { $ref: '#/components/schemas/Revision' }
next_cursor: { $ref: '#/components/schemas/NextCursor' }
Progress:
type: object
description: |
How far the current SEGMENT of work has got, in chapters. A segment is the work between two
stops: to the point where the run stops for the bank to be signed, and from there to the end
of what the run bought. **When a stop is cleared the counter starts again from zero** — the
two segments cover the same chapters and are never added together or compared.
**`total` is what this run BOUGHT**, not the length of the book, so the fraction always
reaches one. The book-wide figure is `Book.chapters_done` against `Book.chapter_count` and
answers a different question.
⚠ **"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, eta_seconds]
properties:
done:
type: integer
minimum: 0
description: Chapters finished in this segment.
total:
type: integer
minimum: 0
description: Chapters this segment covers — what the run bought.
eta_seconds:
type: [integer, 'null']
minimum: 0
description: |
Estimated seconds to the end of the segment, or `null` when there is nothing to estimate
from. The screen renders without it rather than showing a zero.
BookStatus:
type: string
description: |
Product status of a book: `uploading` file is being accepted · `parsing` being cut into
chapters · `not_started` cut, never run · `translating` translation in progress ·
`awaiting_bank` waiting for the book's terms to be signed · `ready` done · `paused` halted and
continuable · `stopped` stopped by the user · `rejected` the file was refused ·
`failed` the run ended in an error.
`not_started`, `stopped` and `rejected` are derived by the PLATFORM from behaviour, but they
arrive in this field like any other: a client reads `status` and never computes it.
**The book's status is that of its current or last run**, except the four a run cannot be in
— `uploading`, `parsing`, `not_started`, `rejected` — which belong to the book alone. So a
client holding both never has to decide which wins.
**A stop at the limit is `paused`, never `failed`**: it is continuable, and mapping it to
`failed` would lie about that. The machine reason is `Run.paused_reason`; the phrase is the
client's.
enum:
- uploading
- parsing
- not_started
- translating
- awaiting_bank
- ready
- paused
- stopped
- rejected
- failed
RunStatus:
type: string
description: |
Status of a RUN — the states a run can be in, which are fewer than a book's. A run is never
`uploading`, `parsing`, `not_started` or `rejected`: those belong to the book before any run
exists or instead of one.
enum: [translating, awaiting_bank, ready, paused, stopped, failed]
RejectReason:
type: string
description: |
Machine reason a book was rejected. A closed vocabulary of this version; the API carries
STATE and the client draws the phrase, so no wording appears here.
- `source_unreadable` — read, and not a book this service can cut. TERMINAL, and the source
does not survive it: no path here re-reads a rejected book, so the remedy is to add it
again;
- `not_configured` — this deployment has nothing to read the book AGAINST. A state of the
service, and retrying alone does not clear it;
- `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
properties:
id: { $ref: '#/components/schemas/Id' }
revision:
$ref: '#/components/schemas/Revision'
description: |
Revision of THIS BOOK, not of the library carrying it — so the answer to a write can be
ordered against a frame the way a read can.
title:
type: string
description: |
Name shown in the library. Given by the user at intake or derived from the name of the
uploaded file; changed afterwards with `PATCH /books/{bookId}`.
source_lang: { $ref: '#/components/schemas/LangCode' }
target_lang: { $ref: '#/components/schemas/LangCode' }
status: { $ref: '#/components/schemas/BookStatus' }
reject_reason:
oneOf:
- $ref: '#/components/schemas/RejectReason'
- type: 'null'
description: |
Why the book was rejected; meaningful only while `status` is `rejected`, and `null`
everywhere else — including on a rejected book whose reason the service cannot name.
structure_version: { $ref: '#/components/schemas/StructureVersion' }
chapter_count:
type: integer
minimum: 0
description: Chapters the book was cut into.
chapters_done:
type: integer
minimum: 0
description: |
Chapters fully translated — "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 `structure_version`;
cutting the book again recomputes both numbers. 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.
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.
heading:
type: [string, 'null']
maxLength: 200
description: |
The chapter's label **as it comes from the data of the book**, or `null` when the
book carries none. A deployment whose parser does not extract labels answers `null`
and MUST NOT put a rendered ordinal here — this field is the book's own words.
**A client with no label renders its own ordinal from `number`, in the language of
the interface**; the server does not know that language. When `heading` and `number`
are both `null` the client labels the row from its position in reading order.
The server bounds the length: in a list of thousands of rows this is the only string
that would otherwise be unbounded.
units_total:
type: integer
minimum: 0
description: Pairs in this chapter.
ChapterProgress:
type: object
description: |
The part of a chapter that MOVES while a book is translated — composed into both `Chapter`
and the `chapter` frame, so a client applies a frame to a row by the same field names.
required: [id, units_done, note_count]
properties:
id: { $ref: '#/components/schemas/Id' }
units_done:
type: integer
minimum: 0
description: |
Pairs of this chapter finished IN THE CURRENT PASS over the book — the same accounting
as `Progress`, one level down. `0` for a chapter the pass has not reached, `units_total`
for one it has finished; it restarts from zero for the chapters a NEW pass re-walks, and
a chapter outside the current pass keeps what the last pass left. Counted end to end
instead, the tree would read zero through the whole first pass.
⚠ The state of a PASS, not the lifetime of the chapter, so it can legally return to zero.
The lifetime figure is `Book.chapters_done`.
note_count:
type: integer
minimum: 0
description: Notes on this chapter.
ChapterPage:
allOf:
- $ref: '#/components/schemas/Page'
- type: object
required: [structure_version, chapters]
properties:
structure_version: { $ref: '#/components/schemas/StructureVersion' }
chapters:
type: array
items: { $ref: '#/components/schemas/Chapter' }
UnitState:
type: string
description: |
`translated` — a translation shipped, including a pair that shipped WITH a note attached ·
`withheld` — no translation was produced · `pending` — not translated yet.
enum: [translated, withheld, pending]
Unit:
type: object
description: |
A source/translation pair — one fragment of a chapter beside its translation. A fragment is
as long as the text needs: sometimes a paragraph, sometimes a whole chapter. Alignment is
coarse and accepted as such.
**Freshness.** `target` is updated at the boundaries of the work and at stops, not
continuously: a frame says something changed, the text arrives with the next read.
required: [id, source, target, state, notes]
if:
properties:
state: { const: translated }
required: [state]
then:
properties:
target: { minLength: 1 }
properties:
id: { $ref: '#/components/schemas/Id' }
source:
type: string
description: Source text of the fragment.
target:
type: string
description: |
Translation. **Non-empty exactly when `state` is `translated`, the empty string
otherwise** — never absent, never `null`. Stated both as a constraint above and in words
here because a generator ignores `if`/`then` and leaves it a plain optional string; the
client narrows the pair on its own seam.
state: { $ref: '#/components/schemas/UnitState' }
notes:
type: array
description: |
The notes on this pair, in the note list's order; empty when there are none. Delivered
here as well as in the book's note list so a reader screen need not join two collections,
and as an ARRAY because a pair legally carries more than one.
items: { $ref: '#/components/schemas/Note' }
UnitPage:
allOf:
- $ref: '#/components/schemas/Page'
- type: object
required: [structure_version, units]
properties:
structure_version: { $ref: '#/components/schemas/StructureVersion' }
units:
type: array
items: { $ref: '#/components/schemas/Unit' }
NoteSeverity:
type: string
description: |
How much attention the note asks for. Two steps today; how many there ought to be is an open
product question 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/decisions` does not set it: a
decision is recorded against the row and its status follows on the next rebuild.
enum: [proposed, in_progress, approved]
TermOrigin:
type: string
description: |
Where the row came from — an axis independent of `status`, and one the person signing needs.
- `given` — it came with the book: someone stated it up front;
- `annotated` — the book's own text says how to read it, and the row was taken from there;
- `found` — the service found it in the text.
enum: [given, annotated, found]
BankTerm:
type: object
description: |
A memory bank row. Provenance is `origin` and the term's two surfaces are `src`/`dst`; the
name `source` is deliberately unused 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 surfaces, sense and window — so
a decision against it survives the bank being rebuilt.
⚠ It does NOT survive the book being cut differently: the window is in chapter numbers,
those move with a re-cut, and an identity derived from them moves too. A client that sees
`structure_version` change re-reads the bank and does not assume its decisions carried
over.
src:
type: string
description: Source surface of the term.
dst:
type: string
description: Translation; empty for a candidate with no proposed form.
kind:
oneOf:
- $ref: '#/components/schemas/TermKind'
- type: 'null'
description: |
`null` when the kind could not be decided — legal, and the row still needs signing. A
client MUST show it as "kind not decided" and MUST NOT drop it or invent a kind.
status: { $ref: '#/components/schemas/TermStatus' }
origin: { $ref: '#/components/schemas/TermOrigin' }
sense:
type: string
description: |
Polysemy disambiguator; part of the uniqueness key. **Required, and the EMPTY STRING
means "no disambiguator"** — otherwise a client could not tell that from "the field was
not sent", which is exactly the field two legal rows of one surface differ by.
since_chapter:
type: [integer, 'null']
minimum: 1
description: |
First chapter the term applies from, or `null` for "from the beginning".
⚠ **The window is in chapter NUMBERS, which are not keys** (see `Chapter.number`), so it
lives in the coordinates of the current `structure_version`: cut the book differently and
the same window covers different text. A client re-reads the bank when the version moves.
A term is unique by `(book, src, sense, since_chapter, until_chapter)`, so the same `src`
legally arrives as several rows.
until_chapter:
type: [integer, 'null']
minimum: 1
description: |
Last chapter the term applies to, or `null` for "to the end". Same coordinates as
`since_chapter`.
BankPage:
allOf:
- $ref: '#/components/schemas/Page'
- type: object
description: |
**The aggregates below describe the WHOLE bank and ride on the FIRST page only** — any
response to a request with no `cursor`, a delta read included; absent on later pages. A
client takes them from the first page it read, which puts them at the same moment as the
oldest rows — the moment the whole walk is stamped with (see `Revision`).
required: [structure_version, terms]
properties:
structure_version: { $ref: '#/components/schemas/StructureVersion' }
total:
type: integer
minimum: 0
description: Rows in the whole bank, not on this page.
signed:
type: integer
minimum: 0
description: |
Rows in status `approved` in the whole bank. Distinct from the counters below: a row
can be decided and NOT signed, because declining is also a decision.
pending_decisions:
type: integer
minimum: 0
description: |
How many proposed terms still have no decision.
complete:
type: boolean
description: |
The set is complete. INFORMATIONAL, never a gate: signing is one act over the whole
bank, and `resume` lifts the stop with the decisions as they stand. A client may
show "N of M decided"; it never disables the continue action on this.
terms:
type: array
items: { $ref: '#/components/schemas/BankTerm' }
BankDecision:
type: object
description: |
A decision on one proposed term. `dst` is mandatory and non-empty for `approve`: a signed
term with an empty translation matches nothing yet reads as an intended rendering.
required: [term_id, action]
if:
properties:
action: { const: approve }
required: [action]
then:
required: [dst]
properties:
dst: { minLength: 1 }
properties:
term_id: { $ref: '#/components/schemas/Id' }
action:
type: string
enum: [approve, decline]
description: |
`approve` — take the term into the book (with a translation in `dst`); `decline` — leave
it out.
⚠ **NOTHING recorded here reaches the work itself yet** — not `decline`, not `approve`,
not the translation typed into `dst`. A deployment stores the decisions and answers the
receipt; the producer of the bank reads its own files and this call does not write them.
So a term left out here can come back proposed on the next run, and a corrected
translation does not change the text. Both halves are read from `BankPage` and neither
is guessed; the companion carries who owes the missing half.
This is a TEMPORARY property of the deployment and not a promise of the contract: the
call exists so the correction has somewhere to go the day the delivery is built, and a
client may show what it recorded. What DOES take effect today is the signature itself —
one `resume` over the whole bank, whatever state the decisions are in.
dst:
type: string
description: |
Translation. **Required and non-empty when `action` is `approve`** — in words as well as
in the constraint above, because a generator ignores `if`/`then`. ⚠ See the caveat on
`action`: today this text is recorded and not applied.
BankDecisionsRequest:
type: object
required: [decisions]
properties:
decisions:
type: array
minItems: 1
maxItems: 1000
description: |
Decisions to record, bounded like every other collection here.
items: { $ref: '#/components/schemas/BankDecision' }
BankDecisionsResult:
type: object
description: |
Receipt of a submission: the same facts the bank read answers, so a client updates its
screen without a second call.
required: [revision, pending_decisions, complete]
properties:
revision: { $ref: '#/components/schemas/Revision' }
pending_decisions:
type: integer
minimum: 0
description: How many proposed terms still have no decision.
complete:
type: boolean
description: |
The set is complete. Informational, never a gate — `resume` lifts the stop with the
decisions as they stand.
RunRequest:
type: object
required: [stop_for_signing, ceiling_chapters]
properties:
stop_for_signing:
type: boolean
description: |
Stop when the book's terms are ready and wait for them to be signed; without it the
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: a run started without a declared limit would spend past the boundary the user
is entitled to set BEFORE it begins rather than learn about after. `0` is not legal.
RunOptions:
type: object
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.
`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`.
required: [state, remaining_percent, halt_reason]
properties:
state:
type: string
description: |
`ok` · `low` the threshold at which the interface warns · `exhausted` nothing left. The
threshold is the platform's and is not on the wire: computing it from the percentage
would be a second copy of the policy.
enum: [ok, low, exhausted]
remaining_percent:
type: integer
minimum: 0
maximum: 100
description: Share of the account's grants still available. A percentage, never an amount.
halt_reason:
oneOf:
- $ref: '#/components/schemas/AccountHaltReason'
- type: 'null'
description: |
Set when the ACCOUNT is halted, `null` otherwise. Named and typed apart from
`Run.paused_reason` on purpose: only reasons of the account's own level appear here.
ExportRequest:
type: object
required: [format]
properties:
format:
type: string
minLength: 1
description: |
One of `export_formats` from `GET /capabilities`; a format outside that set is `400`.
Export:
type: object
description: |
A built copy of the book, behind a link.
required: [id, revision, state, format, expires_at, failure_code, url]
properties:
id: { $ref: '#/components/schemas/Id' }
revision: { $ref: '#/components/schemas/Revision' }
state:
type: string
description: |
`pending` being built · `ready` downloadable · `failed` the build ended in an error ·
`expired` it was built and the link has lapsed. A state and not a boolean: a boolean
merges three situations into "not ready" and a poll on it never ends.
enum: [pending, ready, failed, expired]
format:
type: string
minLength: 1
description: |
The format asked for, echoed back — without it a client holding two export addresses
cannot tell which is which.
expires_at:
type: [string, 'null']
format: date-time
description: |
When the link stops working, or stopped: set once the artifact exists — in `ready` and
in `expired` — and `null` in `pending` and `failed`.
failure_code:
type: [string, 'null']
minLength: 1
description: |
Machine reason when `state` is `failed`, `null` otherwise; the phrase is the client's.
Not enumerated — how an export can fail depends on formats that do not exist yet. It
becomes an enum with the first built format; the companion carries why it is open.
url:
type: [string, 'null']
format: uri
description: |
Where to download it from; `null` unless `state` is `ready`.
**Minted for THIS response and for the authenticated owner**, never indexed, on the same
origin as this API, and expiring at `expires_at`. A browser NAVIGATES to it: it is a
download, not a call.
EventEnvelope:
type: object
description: |
An SSE frame.
⚠ **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 segment counter moved |
| `chapter` | `EventChapter` | a chapter's own progress changed |
| `note` | `EventNote` | a note appeared |
| `bank` | `EventBank` | the bank changed, or a signing stop happened |
| `resync_required` | `EventResyncRequired` | resuming the stream is impossible |
| `end` | `EventEnd` | nothing further will arrive on this stream |
**`id` and `revision` are different numbers.** The frame's `id` is a position in the BOOK's
event history and the only thing a client does with it is send it back as `Last-Event-ID`;
the book's `revision` travels inside `data` and is what a frame and a read are ordered
against.
required: [event, id, data]
properties:
event:
type: string
description: Frame name; dispatch on it, per the table above.
enum: [hello, status, progress, chapter, note, bank, resync_required, end]
id:
type: string
pattern: '^[0-9]+$'
description: |
Position in the BOOK's event history: a decimal integer, increasing. Per BOOK and not
per connection — `Last-Event-ID` must mean the same thing however the frame was carried.
**Only history frames consume a number.** `hello`, `resync_required` and `end` belong to
the CONNECTION, not to the book: each carries the id of the last history frame and
consumes none of its own, so the same id legally appears more than once in one stream. A
client stores the id it last saw and sends it back; it never counts with it.
**A 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/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.4.0`.
examples: ['0.4.0']
EventStatus:
allOf:
- $ref: '#/components/schemas/EventBase'
- type: object
description: |
Product status changed. All three machine reasons travel with it, so a stop, a refusal
and a failure are actionable without a second read; each is `null` unless its own status
is the one being announced.
required: [status, paused_reason, reject_reason, failure_reason]
properties:
status: { $ref: '#/components/schemas/BookStatus' }
paused_reason:
oneOf:
- $ref: '#/components/schemas/PausedReason'
- type: 'null'
reject_reason:
oneOf:
- $ref: '#/components/schemas/RejectReason'
- type: 'null'
failure_reason:
oneOf:
- $ref: '#/components/schemas/RunFailureReason'
- type: 'null'
EventProgress:
allOf:
- $ref: '#/components/schemas/EventBase'
- type: object
description: The segment counter moved. Applied as it is; no read follows.
required: [progress]
properties:
progress: { $ref: '#/components/schemas/Progress' }
EventChapter:
allOf:
- $ref: '#/components/schemas/EventBase'
- $ref: '#/components/schemas/ChapterProgress'
EventNote:
allOf:
- $ref: '#/components/schemas/EventBase'
- type: object
description: |
A note appeared, and the frame CARRIES it — with an identity of its own it is a delta a
client can apply, which is why it must never be coalesced or dropped.
required: [note]
properties:
note: { $ref: '#/components/schemas/Note' }
EventBank:
allOf:
- $ref: '#/components/schemas/EventBase'
- type: object
description: |
The bank changed, or a signing stop happened. The counters are the delta a screen header
needs; the ROWS are read with `after_version` set to the revision the client last
applied.
required: [total, signed, pending_decisions, complete]
properties:
total: { type: integer, minimum: 0 }
signed: { type: integer, minimum: 0 }
pending_decisions: { type: integer, minimum: 0 }
complete: { type: boolean }
EventResyncRequired:
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.
⚠ **`title` and `detail` are written for a DEVELOPER and a log, and a client MUST NOT show
either to a user.** They are English and will not be translated. The sentence the user reads
is drawn by the CLIENT from `code`, in the language of the interface — a neutral phrase for a
code it does not know. Neither field ever carries text from inside the translation machinery.
**Extension members are defined per `code`** (RFC 9457 §3.2) and are absent where the code
does not define them — one of the two places here where absence means "does not apply":
| member | carried by |
|---|---|
| `errors` | `invalid_request` |
| `cause` | any code with a narrower cause to give |
| `blocked` | `ceiling_unavailable` |
| `localized` | codes whose cause cannot be enumerated |
required: [type, title, status, code, request_id]
properties:
type:
type: string
format: uri
description: Always `about:blank`. See above.
title:
type: string
minLength: 1
description: |
Short developer-facing name of the failure, for a log. Never empty, never shown.
status: { type: integer }
detail:
type: string
description: Developer-facing sentence about THIS occurrence, for a log. Never shown to a user.
code: { $ref: '#/components/schemas/ErrorCode' }
request_id:
type: string
minLength: 1
description: |
Identifier of the request that failed, echoed on every response as `X-Request-Id`. **A
client MAY show it**: it identifies a request, never a person, and an error screen
without it makes a user's report unsearchable.
cause:
$ref: '#/components/schemas/ErrorCause'
description: |
Narrower cause within `code`, when there is one to give.
errors:
type: array
description: |
Which parts of the request were wrong. Carried by `invalid_request` and possibly empty —
a request can be unreadable as a whole.
items: { $ref: '#/components/schemas/ErrorItem' }
blocked:
$ref: '#/components/schemas/Blocked'
description: |
Carried by `ceiling_unavailable` when another book of the account holds the credit — the
same shape `RunOptions` answers.
localized:
$ref: '#/components/schemas/LocalizedMessage'
description: |
A phrase written by the SERVER, to be shown as it is — the single exception, for causes
that cannot be enumerated in advance. No code in this version carries it; 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) — over `intake_max_bytes`;
- `run_in_flight` (409) — this book is already being translated;
- `book_not_ready` (409) — the book cannot be translated yet: it is still arriving, still
being cut, or was rejected;
- `run_not_stoppable` (409) — this run is not running;
- `run_not_resumable` (409) — this run cannot be continued. `cause.code` says why:
`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;
- `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
- 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.