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