2463 lines
110 KiB
YAML
2463 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**: `pending_decisions` and `complete` are
|
|
answered here and not only in a submission receipt, so a screen reloaded mid-stop knows
|
|
whether the work is finished.
|
|
|
|
**Delta read** with `after_version` — a full book's bank is too large to re-read on every
|
|
change. Page size default: `GET /capabilities`.
|
|
parameters:
|
|
- $ref: '#/components/parameters/Limit'
|
|
- $ref: '#/components/parameters/Cursor'
|
|
- $ref: '#/components/parameters/AfterVersion'
|
|
- $ref: '#/components/parameters/IfNoneMatch'
|
|
responses:
|
|
'200':
|
|
description: Bank of the book.
|
|
headers:
|
|
ETag: { $ref: '#/components/headers/ETag' }
|
|
content:
|
|
application/json:
|
|
schema: { $ref: '#/components/schemas/BankPage' }
|
|
'304': { $ref: '#/components/responses/NotModified' }
|
|
'400': { $ref: '#/components/responses/BadRequest' }
|
|
'401': { $ref: '#/components/responses/Unauthorized' }
|
|
'404': { $ref: '#/components/responses/NotFound' }
|
|
|
|
/books/{bookId}/bank/decisions:
|
|
parameters:
|
|
- $ref: '#/components/parameters/BookId'
|
|
post:
|
|
tags: [bank]
|
|
operationId: submitBankDecisions
|
|
summary: Submit term decisions.
|
|
description: |
|
|
**Signing is not a row edit**: the bank is rebuilt from its inputs on every run, so a direct
|
|
write would be erased. A decision is `approve` (with a translation) or `decline`, keyed by
|
|
term, so re-sending one is harmless.
|
|
|
|
Submission is PARTIAL and accumulates on the server — a closed tab must not cost an hour of
|
|
work.
|
|
|
|
**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 waits for the
|
|
bank to be signed; 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`, decisions complete | continues it — `202` | this call |
|
|
| `awaiting_bank`, decisions incomplete | `409` `run_not_resumable`, `cause.code: bank_decisions_incomplete` | finish signing, then 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:
|
|
`bank_decisions_incomplete` — terms are still undecided; `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`;
|
|
- `ceiling_unavailable` (409) — the limit asked for cannot be started. `cause.code`: `bounds_moved`
|
|
— the bounds changed between the read and this call; `credit_held` — the account's credit
|
|
is held elsewhere, and `blocked` names the book holding it;
|
|
- `idempotency_conflict` (409) — an `Idempotency-Key` was re-used. `cause.code`:
|
|
`key_reused` for a different request under the same key, `key_in_flight` for one that is
|
|
still running, and then `Retry-After` says how long to wait;
|
|
- `content_refused` (400) — the service will not do this work. **One coarse code for a whole
|
|
class** and deliberately so: it does not say which check refused, does not vary between
|
|
attempts, and carries neither `cause` nor `errors`. A client shows one neutral phrase and
|
|
does not invite a retry, and the server bounds how many times one account may try — that
|
|
bound is the server's and is not on the wire. A refusal of a whole BOOK is not reported
|
|
here at all: it is a state of the book, `rejected` with `reject_reason: content_refused`;
|
|
- `service_unavailable` (503) — the deployment cannot do this right now;
|
|
- `internal_error` (500) — a defect on our side. Nothing about it is actionable by a client
|
|
beyond quoting `request_id`.
|
|
enum:
|
|
- invalid_request
|
|
- unauthenticated
|
|
- forbidden
|
|
- not_found
|
|
- gone
|
|
- request_timeout
|
|
- payload_too_large
|
|
- run_in_flight
|
|
- book_not_ready
|
|
- run_not_stoppable
|
|
- run_not_resumable
|
|
- ceiling_unavailable
|
|
- idempotency_conflict
|
|
- content_refused
|
|
- service_unavailable
|
|
- internal_error
|
|
|
|
ErrorCause:
|
|
type: object
|
|
description: |
|
|
The second level of the code. Its vocabulary is NOT closed and grows without a minor bump, so
|
|
a client that does not recognise one falls back to the root `code` and loses only precision.
|
|
required: [code]
|
|
properties:
|
|
code:
|
|
type: string
|
|
minLength: 1
|
|
description: Narrower cause within the root code.
|
|
examples: ['bank_decisions_incomplete']
|
|
|
|
ErrorItem:
|
|
type: object
|
|
description: |
|
|
One thing wrong with the request — used to mark a field on the form; the sentence is drawn
|
|
from the code as everywhere else.
|
|
required: [pointer, code]
|
|
properties:
|
|
pointer:
|
|
type: string
|
|
minLength: 1
|
|
description: |
|
|
JSON Pointer to the offending member, or `/<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.
|