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

2469 lines
110 KiB
YAML

openapi: 3.1.0
info:
title: TextMachine API
version: 0.3.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.
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" is compared over the DECLARED
parts — the metadata and the file's name and size — and never over the bytes themselves: a
server does not hold a book in memory to compare it, and a retry of an interrupted upload
re-sends the same file. 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 that existed and no longer does: a book cut again leaves the
old ids gone rather than absent, and the client re-reads the tree. `404` would mean a typo.
parameters:
- $ref: '#/components/parameters/Limit'
- $ref: '#/components/parameters/Cursor'
- $ref: '#/components/parameters/IfNoneMatch'
responses:
'200':
description: Pairs of the chapter.
headers:
ETag: { $ref: '#/components/headers/ETag' }
content:
application/json:
schema: { $ref: '#/components/schemas/UnitPage' }
'304': { $ref: '#/components/responses/NotModified' }
'400': { $ref: '#/components/responses/BadRequest' }
'401': { $ref: '#/components/responses/Unauthorized' }
'404': { $ref: '#/components/responses/NotFound' }
'410': { $ref: '#/components/responses/Gone' }
/books/{bookId}/notes:
parameters:
- $ref: '#/components/parameters/BookId'
get:
tags: [reading]
operationId: listNotes
summary: Notes of a book.
description: |
Notes oldest first, by `created_at`. Ordered by time and not by position in the book so that
a note arriving on the stream can be placed into a list the client already holds; a screen
that wants reading order sorts against the chapter tree it already has.
Ties on `created_at` are broken by the server in a way the client does NOT reproduce — the
prohibition on sorting by `Id` stands — and a client placing a streamed note puts it after
every note it holds with the same `created_at`.
A note addresses a chapter, and usually a pair inside it. Byte offsets do not exist.
**Delta read** with `after_version`; without it the whole collection. Page size default:
`GET /capabilities` — ⚠ how many notes a real book produces has never been measured, so that
default is a guess.
parameters:
- $ref: '#/components/parameters/Limit'
- $ref: '#/components/parameters/Cursor'
- $ref: '#/components/parameters/AfterVersion'
- $ref: '#/components/parameters/IfNoneMatch'
responses:
'200':
description: Notes of the book.
headers:
ETag: { $ref: '#/components/headers/ETag' }
content:
application/json:
schema: { $ref: '#/components/schemas/NotePage' }
'304': { $ref: '#/components/responses/NotModified' }
'400': { $ref: '#/components/responses/BadRequest' }
'401': { $ref: '#/components/responses/Unauthorized' }
'404': { $ref: '#/components/responses/NotFound' }
/books/{bookId}/bank:
parameters:
- $ref: '#/components/parameters/BookId'
get:
tags: [bank]
operationId: listBankTerms
summary: Memory bank of a book.
description: |
The bank ordered by source surface then by the term's window, so the several rows of one
surface stand together.
**This read is also the STATE of a signing stop** — 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. Between the two
the client shows its own pending state. `409` answers a run that is not running at all.
parameters:
- $ref: '#/components/parameters/ClientHeader'
responses:
'202':
description: Stop accepted.
content:
application/json:
schema: { $ref: '#/components/schemas/Run' }
'401': { $ref: '#/components/responses/Unauthorized' }
'403': { $ref: '#/components/responses/Forbidden' }
'404': { $ref: '#/components/responses/NotFound' }
'409': { $ref: '#/components/responses/Conflict' }
/runs/{runId}/resume:
parameters:
- $ref: '#/components/parameters/RunId'
post:
tags: [runs]
operationId: resumeRun
summary: Continue a stopped run.
description: |
Clears the bank-signing stop and continues after a stop the user asked for. **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 |
⚠ **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.
**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.
schema: { $ref: '#/components/schemas/Revision' }
Limit:
name: limit
in: query
required: false
description: |
Page size; the default is the deployment's (`GET /capabilities`). A server MAY return fewer
rows than asked for, and the client decides nothing from that — only from `next_cursor`.
The `maximum` below bounds what a CLIENT may ask for. A server that receives more MUST clamp
down to it and answer, and MUST NOT refuse the request — answering the default instead is
what made "ask for more, get fewer rows than a smaller request" discoverable only by
experiment. A deployment that validates this parameter against the schema has to exempt it
from rejection.
schema: { type: integer, minimum: 1, maximum: 1000 }
Cursor:
name: cursor
in: query
required: false
description: |
Keyset cursor from `next_cursor` of the previous page. Opaque: the client MUST NOT parse,
compare or construct it. Omitted for the first page.
A cursor that no longer applies is `400` with `cause.code: cursor_invalid`; see
`NextCursor`.
schema: { type: string, minLength: 1 }
responses:
NotModified:
description: |
The client's copy is still current. No body; the `ETag` it presented stays valid.
BadRequest:
description: Request rejected.
headers:
X-Request-Id: { $ref: '#/components/headers/RequestId' }
content:
application/problem+json:
schema: { $ref: '#/components/schemas/Problem' }
Unauthorized:
description: Session missing or invalid.
headers:
X-Request-Id: { $ref: '#/components/headers/RequestId' }
WWW-Authenticate:
required: true
description: |
The challenge for this resource, as RFC 9110 §15.5.2 requires of every `401`. A client
holding no session sends the user to the sign-in flow rather than parsing this.
schema: { type: string, minLength: 1 }
content:
application/problem+json:
schema: { $ref: '#/components/schemas/Problem' }
Forbidden:
description: |
Refused before authorization: the marker header of the `sessionCookie` scheme was missing on
a cookie-presented request, or the request came from an origin this deployment does not
accept. Not a decision about the object — those are `404`.
headers:
X-Request-Id: { $ref: '#/components/headers/RequestId' }
content:
application/problem+json:
schema: { $ref: '#/components/schemas/Problem' }
NotFound:
description: |
No such object, or one this account may not see, or a path this deployment does not serve —
deliberately one answer: neither another account's library nor the shape of this instance is
public information.
headers:
X-Request-Id: { $ref: '#/components/headers/RequestId' }
content:
application/problem+json:
schema: { $ref: '#/components/schemas/Problem' }
Conflict:
description: Action impossible in the current state.
headers:
X-Request-Id: { $ref: '#/components/headers/RequestId' }
Retry-After:
description: |
Seconds to wait before repeating, sent when waiting is the remedy — today
`idempotency_conflict` with `cause.code: key_in_flight`. Absent otherwise, and a client
that sees no header does not invent a delay.
schema: { type: integer, minimum: 0 }
content:
application/problem+json:
schema: { $ref: '#/components/schemas/Problem' }
Gone:
description: |
The object existed and does not any more; its identifier will not be reissued. A client
holding a reference re-reads the collection it came from.
headers:
X-Request-Id: { $ref: '#/components/headers/RequestId' }
content:
application/problem+json:
schema: { $ref: '#/components/schemas/Problem' }
TooLarge:
description: |
File over the intake cap (`intake_max_bytes` of `GET /capabilities`).
headers:
X-Request-Id: { $ref: '#/components/headers/RequestId' }
content:
application/problem+json:
schema: { $ref: '#/components/schemas/Problem' }
RequestTimeout:
description: |
The body did not arrive whole inside the route's deadline: a slow client on a large book.
RETRY is the remedy, which is what separates it from `413`.
headers:
X-Request-Id: { $ref: '#/components/headers/RequestId' }
content:
application/problem+json:
schema: { $ref: '#/components/schemas/Problem' }
ServiceUnavailable:
description: |
The deployment cannot do this right now: starting or continuing a run needs its machinery
fully configured. Temporary — retry later; no `Retry-After` is promised.
headers:
X-Request-Id: { $ref: '#/components/headers/RequestId' }
content:
application/problem+json:
schema: { $ref: '#/components/schemas/Problem' }
schemas:
Id:
type: string
minLength: 1
description: |
Opaque identifier. The client MUST NOT parse, sort by or construct it.
**Stability differs by what the identifier names:**
- book, run, export — kept for as long as the object exists;
- chapter — survives a re-parse of the same source, because a chapter is identified by its
own text. It does not survive that text changing;
- PAIR (`Unit.id`) — stable only within one `structure_version`. Cutting the book
differently mints a new id for every pair while the chapters survive, so a stored anchor on
a pair is invalid the moment the version moves, and the client re-reads the chapter rather
than reporting the pair as deleted.
examples: ['bk_7c1']
Revision:
type: integer
minimum: 0
description: |
Monotonic revision. **The counter is PER BOOK:** every book-scoped read and every frame of
that book carry the same number. The library has its own scope, and a revision is never
compared across scopes.
**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.3.0']
language_pairs:
type: array
description: |
Every pair the deployment knows about, unavailable ones included: "absent" and "listed
as unavailable" are different facts to a user waiting for one.
items: { $ref: '#/components/schemas/LanguagePair' }
intake_enabled:
type: boolean
description: |
Whether this deployment takes books at all. `false` is a read-only instance that serves
a library and answers `404` to `POST /books`. Without it a client discovers this only by
spending a user's upload.
intake_max_bytes:
type: integer
minimum: 1
description: |
Largest file this deployment accepts, when it accepts any. A client checks it before
starting an upload; the server enforces it regardless and answers `413`.
export_formats:
type: array
description: |
Formats `POST /books/{bookId}/exports` accepts. Empty means none are built here.
items: { type: string, minLength: 1 }
page_size_default:
type: integer
minimum: 1
maximum: 1000
description: |
Rows a collection returns when `limit` is omitted. Never larger than the maximum a
client may ASK for, which is why it carries the same bound; that maximum is defined once,
on the `limit` parameter, and this is its consequence rather than a second copy.
LanguagePair:
type: object
description: A translation direction this deployment knows about.
required: [source, target, state]
properties:
source: { $ref: '#/components/schemas/LangCode' }
target: { $ref: '#/components/schemas/LangCode' }
state:
type: string
description: |
`available` — books in this pair can be translated here · `unavailable` — the deployment
knows the pair and cannot run it.
enum: [available, unavailable]
Page:
type: object
description: |
The envelope every collection answers in, composed into each list so that "every page carries
a revision and a next cursor" is checkable rather than merely observed.
required: [revision, next_cursor]
properties:
revision: { $ref: '#/components/schemas/Revision' }
next_cursor: { $ref: '#/components/schemas/NextCursor' }
Progress:
type: object
description: |
How far the current SEGMENT of work has got, in chapters. A segment is the work between two
stops: to the point where the run stops for the bank to be signed, and from there to the end
of what the run bought. **When a stop is cleared the counter starts again from zero** — the
two segments cover the same chapters and are never added together or compared.
**`total` is what this run BOUGHT**, not the length of the book, so the fraction always
reaches one. The book-wide figure is `Book.chapters_done` against `Book.chapter_count` and
answers a different question.
No ready-made percentage is shipped: how a fraction is drawn is a product decision.
required: [done, total, eta_seconds]
properties:
done:
type: integer
minimum: 0
description: Chapters finished in this segment.
total:
type: integer
minimum: 0
description: Chapters this segment covers — what the run bought.
eta_seconds:
type: [integer, 'null']
minimum: 0
description: |
Estimated seconds to the end of the segment, or `null` when there is nothing to estimate
from. The screen renders without it rather than showing a zero.
BookStatus:
type: string
description: |
Product status of a book: `uploading` file is being accepted · `parsing` being cut into
chapters · `not_started` cut, never run · `translating` translation in progress ·
`awaiting_bank` waiting for the book's terms to be signed · `ready` done · `paused` halted and
continuable · `stopped` stopped by the user · `rejected` the file was refused ·
`failed` the run ended in an error.
`not_started`, `stopped` and `rejected` are derived by the PLATFORM from behaviour, but they
arrive in this field like any other: a client reads `status` and never computes it.
**The book's status is that of its current or last run**, except the four a run cannot be in
— `uploading`, `parsing`, `not_started`, `rejected` — which belong to the book alone. So a
client holding both never has to decide which wins.
**A stop at the limit is `paused`, never `failed`**: it is continuable, and mapping it to
`failed` would lie about that. The machine reason is `Run.paused_reason`; the phrase is the
client's.
enum:
- uploading
- parsing
- not_started
- translating
- awaiting_bank
- ready
- paused
- stopped
- rejected
- failed
RunStatus:
type: string
description: |
Status of a RUN — the states a run can be in, which are fewer than a book's. A run is never
`uploading`, `parsing`, `not_started` or `rejected`: those belong to the book before any run
exists or instead of one.
enum: [translating, awaiting_bank, ready, paused, stopped, failed]
RejectReason:
type: string
description: |
Machine reason a book was rejected. A closed vocabulary of this version; the API carries
STATE and the client draws the phrase, so no wording appears here.
- `source_unreadable` — read, and not a book this service can cut. TERMINAL, and the source
does not survive it: no path here re-reads a rejected book, so the remedy is to add it
again;
- `not_configured` — this deployment has nothing to read the book AGAINST. A state of the
service, and retrying alone does not clear it;
- `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.
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. Against `chapter_count` this is the book's own progress —
what a library row shows — and it never moves backwards WITHIN one `structure_version`;
cutting the book again recomputes both numbers. The bar of a RUNNING run is
`Run.progress`, which measures what that run bought.
character_count:
type: [integer, 'null']
minimum: 0
description: |
Size of the source in characters, or `null` while the book is still arriving.
added_at: { type: string, format: date-time }
note_count:
type: integer
minimum: 0
description: Notes on the whole book.
BookPatch:
type: object
description: |
Merge patch over a book. One member, and a member absent from the patch is left alone.
properties:
title:
type: string
minLength: 1
maxLength: 200
description: |
New display name, bounded like the one the platform derives.
BookPage:
allOf:
- $ref: '#/components/schemas/Page'
- type: object
required: [books]
properties:
books:
type: array
items: { $ref: '#/components/schemas/Book' }
BookDetail:
type: object
required: [revision, book, run]
properties:
revision: { $ref: '#/components/schemas/Revision' }
book: { $ref: '#/components/schemas/Book' }
run:
oneOf:
- $ref: '#/components/schemas/Run'
- type: 'null'
description: Current or last run; `null` if the book was never run.
Run:
type: object
description: A run over a book.
required:
- id
- book_id
- revision
- status
- stop_for_signing
- ceiling_chapters
- progress
- paused_reason
- failure_reason
- started_at
- finished_at
properties:
id: { $ref: '#/components/schemas/Id' }
book_id:
$ref: '#/components/schemas/Id'
description: |
The book this run belongs to — so an answer to `stop`, `resume` or `GET /runs/{runId}`
is enough to find it without a second read.
revision: { $ref: '#/components/schemas/Revision' }
status: { $ref: '#/components/schemas/RunStatus' }
stop_for_signing:
type: boolean
description: |
The run was started with a stop for the book's terms to be signed.
ceiling_chapters:
type: integer
minimum: 1
description: |
The limit this run was started with, in CHAPTERS — a property of the RUN. Present so a
reloaded screen can name the limit the user chose and read `progress.total` against it.
progress: { $ref: '#/components/schemas/Progress' }
paused_reason:
oneOf:
- $ref: '#/components/schemas/PausedReason'
- type: 'null'
description: |
Machine reason when `status` is `paused`, `null` otherwise — including for a paused run
whose reason this contract has no word for, where the client shows the neutral halted
state and does not guess.
failure_reason:
oneOf:
- $ref: '#/components/schemas/RunFailureReason'
- type: 'null'
description: Machine reason when `status` is `failed`; `null` otherwise.
started_at: { type: string, format: date-time }
finished_at:
type: [string, 'null']
format: date-time
description: |
When the run ended, or `null` while it is still live.
BookIntake:
type: object
description: |
Add-a-book form.
⚠ **Order matters here and nowhere else on this surface:** `file` is the LAST part and every
other field precedes it — see `createBook`. An OpenAPI object has no ordering, so the rule
lives in prose; the properties are nevertheless listed in the required order, and the schema
has NO optional member, so no emission order a generator picks can break the rule.
required: [title, source_lang, target_lang, file]
properties:
title:
type: string
maxLength: 200
description: |
Title given by hand; **the EMPTY STRING means "name it from the file"** — today the
platform takes the name of the uploaded file. A value present means the person named the
book themselves, and no later parse overwrites it.
Required, and empty rather than absent, so that this schema has no optional member: a
generator emitting required members first would otherwise place `title` after `file`, and
a part after the file is refused.
source_lang: { $ref: '#/components/schemas/LangCode' }
target_lang: { $ref: '#/components/schemas/LangCode' }
file:
type: string
format: binary
description: |
Book file, and the LAST part of the form.
**Its NAME carries two facts**, so a client sends a real one: it becomes the book's title
when `title` was empty, and its extension selects the reader — `.epub` as a book,
anything else as plain text.
Chapter:
allOf:
- $ref: '#/components/schemas/ChapterProgress'
- type: object
required: [number, heading, units_total]
properties:
number:
type: [integer, 'null']
minimum: 1
description: |
Displayed ordinal, or `null` when the book has no numbering — a legal book.
**Not a key:** numbering is dense, so editing the source shifts every later chapter.
heading:
type: [string, 'null']
maxLength: 200
description: |
The chapter's label **as it comes from the data of the book**, or `null` when the
book carries none. A deployment whose parser does not extract labels answers `null`
and MUST NOT put a rendered ordinal here — this field is the book's own words.
**A client with no label renders its own ordinal from `number`, in the language of
the interface**; the server does not know that language. When `heading` and `number`
are both `null` the client labels the row from its position in reading order.
The server bounds the length: in a list of thousands of rows this is the only string
that would otherwise be unbounded.
units_total:
type: integer
minimum: 0
description: Pairs in this chapter.
ChapterProgress:
type: object
description: |
The part of a chapter that MOVES while a book is translated — composed into both `Chapter`
and the `chapter` frame, so a client applies a frame to a row by the same field names.
required: [id, units_done, note_count]
properties:
id: { $ref: '#/components/schemas/Id' }
units_done:
type: integer
minimum: 0
description: |
Pairs of this chapter finished IN THE CURRENT PASS over the book — the same accounting
as `Progress`, one level down. `0` for a chapter the pass has not reached, `units_total`
for one it has finished; it restarts from zero for the chapters a NEW pass re-walks, and
a chapter outside the current pass keeps what the last pass left. Counted end to end
instead, the tree would read zero through the whole first pass.
⚠ The state of a PASS, not the lifetime of the chapter, so it can legally return to zero.
The lifetime figure is `Book.chapters_done`.
note_count:
type: integer
minimum: 0
description: Notes on this chapter.
ChapterPage:
allOf:
- $ref: '#/components/schemas/Page'
- type: object
required: [structure_version, chapters]
properties:
structure_version: { $ref: '#/components/schemas/StructureVersion' }
chapters:
type: array
items: { $ref: '#/components/schemas/Chapter' }
UnitState:
type: string
description: |
`translated` — a translation shipped, including a pair that shipped WITH a note attached ·
`withheld` — no translation was produced · `pending` — not translated yet.
enum: [translated, withheld, pending]
Unit:
type: object
description: |
A source/translation pair — one fragment of a chapter beside its translation. A fragment is
as long as the text needs: sometimes a paragraph, sometimes a whole chapter. Alignment is
coarse and accepted as such.
**Freshness.** `target` is updated at the boundaries of the work and at stops, not
continuously: a frame says something changed, the text arrives with the next read.
required: [id, source, target, state, notes]
if:
properties:
state: { const: translated }
required: [state]
then:
properties:
target: { minLength: 1 }
properties:
id: { $ref: '#/components/schemas/Id' }
source:
type: string
description: Source text of the fragment.
target:
type: string
description: |
Translation. **Non-empty exactly when `state` is `translated`, the empty string
otherwise** — never absent, never `null`. Stated both as a constraint above and in words
here because a generator ignores `if`/`then` and leaves it a plain optional string; the
client narrows the pair on its own seam.
state: { $ref: '#/components/schemas/UnitState' }
notes:
type: array
description: |
The notes on this pair, in the note list's order; empty when there are none. Delivered
here as well as in the book's note list so a reader screen need not join two collections,
and as an ARRAY because a pair legally carries more than one.
items: { $ref: '#/components/schemas/Note' }
UnitPage:
allOf:
- $ref: '#/components/schemas/Page'
- type: object
required: [structure_version, units]
properties:
structure_version: { $ref: '#/components/schemas/StructureVersion' }
units:
type: array
items: { $ref: '#/components/schemas/Unit' }
NoteSeverity:
type: string
description: |
How much attention the note asks for. Two steps today; how many there ought to be is an open
product question 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.
Not enumerated here: the map is a table the contract's owner fills, and freezing a list
in the schema before the words exist would make it a second copy. It becomes an enum when
appendix A is written.
chapter_id:
$ref: '#/components/schemas/Id'
description: |
The chapter the note is about. Required: a note addressing nothing could not be placed
on any screen.
unit_id:
$ref: '#/components/schemas/Id'
description: |
The pair the note is about, when it is about one rather than the whole chapter. Optional
for that reason and no other.
NotePage:
allOf:
- $ref: '#/components/schemas/Page'
- type: object
required: [structure_version, notes]
properties:
structure_version: { $ref: '#/components/schemas/StructureVersion' }
notes:
type: array
items: { $ref: '#/components/schemas/Note' }
TermKind:
type: string
description: |
Kind of term. Not cosmetic: `name` and `place` decide whether a term is transliterated, so
signing one without seeing its kind means signing blind.
enum: [name, place, title, term, nickname]
TermStatus:
type: string
description: |
Signing status, THREE-VALUED; only `approved` is carried into the translation as canon. A
boolean would merge "proposed, nobody has looked" with "a person started and did not finish"
— on a screen of hundreds of rows that is the main filter of work.
This is the state of the ROW. `POST /books/{bookId}/bank/decisions` does not set it: a
decision is recorded against the row and its status follows on the next rebuild.
enum: [proposed, in_progress, approved]
TermOrigin:
type: string
description: |
Where the row came from — an axis independent of `status`, and one the person signing needs.
- `given` — it came with the book: someone stated it up front;
- `annotated` — the book's own text says how to read it, and the row was taken from there;
- `found` — the service found it in the text.
enum: [given, annotated, found]
BankTerm:
type: object
description: |
A memory bank row. Provenance is `origin` and the term's two surfaces are `src`/`dst`; the
name `source` is deliberately unused 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. The stop clears ONLY on a complete set, so a client shows
"N of M decided" and does not offer to continue while this is `false`.
terms:
type: array
items: { $ref: '#/components/schemas/BankTerm' }
BankDecision:
type: object
description: |
A decision on one proposed term. `dst` is mandatory and non-empty for `approve`: a signed
term with an empty translation matches nothing yet reads as an intended rendering.
required: [term_id, action]
if:
properties:
action: { const: approve }
required: [action]
then:
required: [dst]
properties:
dst: { minLength: 1 }
properties:
term_id: { $ref: '#/components/schemas/Id' }
action:
type: string
enum: [approve, decline]
description: '`approve` — take the term into the book (with a translation in `dst`); `decline` — leave it out.'
dst:
type: string
description: |
Translation. **Required and non-empty when `action` is `approve`** — in words as well as
in the constraint above, because a generator ignores `if`/`then`.
BankDecisionsRequest:
type: object
required: [decisions]
properties:
decisions:
type: array
minItems: 1
maxItems: 1000
description: |
Decisions to record, bounded like every other collection here.
items: { $ref: '#/components/schemas/BankDecision' }
BankDecisionsResult:
type: object
description: |
Receipt of a submission: the same facts the bank read answers, so a client updates its
screen without a second call.
required: [revision, pending_decisions, complete]
properties:
revision: { $ref: '#/components/schemas/Revision' }
pending_decisions:
type: integer
minimum: 0
description: How many proposed terms still have no decision.
complete:
type: boolean
description: The set is complete and the stop can be cleared.
RunRequest:
type: object
required: [stop_for_signing, ceiling_chapters]
properties:
stop_for_signing:
type: boolean
description: |
Stop when the book's terms are ready and wait for them to be signed; without it the
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.3.0`.
examples: ['0.3.0']
EventStatus:
allOf:
- $ref: '#/components/schemas/EventBase'
- type: object
description: |
Product status changed. All three machine reasons travel with it, so a stop, a refusal
and a failure are actionable without a second read; each is `null` unless its own status
is the one being announced.
required: [status, paused_reason, reject_reason, failure_reason]
properties:
status: { $ref: '#/components/schemas/BookStatus' }
paused_reason:
oneOf:
- $ref: '#/components/schemas/PausedReason'
- type: 'null'
reject_reason:
oneOf:
- $ref: '#/components/schemas/RejectReason'
- type: 'null'
failure_reason:
oneOf:
- $ref: '#/components/schemas/RunFailureReason'
- type: 'null'
EventProgress:
allOf:
- $ref: '#/components/schemas/EventBase'
- type: object
description: The segment counter moved. Applied as it is; no read follows.
required: [progress]
properties:
progress: { $ref: '#/components/schemas/Progress' }
EventChapter:
allOf:
- $ref: '#/components/schemas/EventBase'
- $ref: '#/components/schemas/ChapterProgress'
EventNote:
allOf:
- $ref: '#/components/schemas/EventBase'
- type: object
description: |
A note appeared, and the frame CARRIES it — with an identity of its own it is a delta a
client can apply, which is why it must never be coalesced or dropped.
required: [note]
properties:
note: { $ref: '#/components/schemas/Note' }
EventBank:
allOf:
- $ref: '#/components/schemas/EventBase'
- type: object
description: |
The bank changed, or a signing stop happened. The counters are the delta a screen header
needs; the ROWS are read with `after_version` set to the revision the client last
applied.
required: [total, signed, pending_decisions, complete]
properties:
total: { type: integer, minimum: 0 }
signed: { type: integer, minimum: 0 }
pending_decisions: { type: integer, minimum: 0 }
complete: { type: boolean }
EventResyncRequired:
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 object existed and does not any more, and its identifier will not be
reissued. Told apart from `not_found` because the remedy differs: re-read the collection,
rather than check the address;
- `request_timeout` (408) — the body did not arrive whole in time. Retrying is the remedy;
- `payload_too_large` (413) — over `intake_max_bytes`;
- `run_in_flight` (409) — this book is already being translated;
- `book_not_ready` (409) — the book cannot be translated yet: it is still arriving, still
being cut, or was rejected;
- `run_not_stoppable` (409) — this run is not running;
- `run_not_resumable` (409) — this run cannot be continued. `cause.code` says why:
`ceiling_reached` — the run stopped at a limit, the chapters it bought or the credit
behind them, and the remedy is a NEW run rather than this call. 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, and the server bounds how many times one account may try — that
bound is the server's and is not on the wire. A refusal of a whole BOOK is not reported
here at all: it is a state of the book, `rejected` with `reject_reason: content_refused`;
- `service_unavailable` (503) — the deployment cannot do this right now;
- `internal_error` (500) — a defect on our side. Nothing about it is actionable by a client
beyond quoting `request_id`.
enum:
- invalid_request
- unauthenticated
- forbidden
- not_found
- gone
- request_timeout
- payload_too_large
- run_in_flight
- book_not_ready
- run_not_stoppable
- run_not_resumable
- ceiling_unavailable
- idempotency_conflict
- content_refused
- service_unavailable
- internal_error
ErrorCause:
type: object
description: |
The second level of the code. Its vocabulary is NOT closed and grows without a minor bump, so
a client that does not recognise one falls back to the root `code` and loses only precision.
required: [code]
properties:
code:
type: string
minLength: 1
description: Narrower cause within the root code.
examples: ['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.