1347 lines
54 KiB
YAML
1347 lines
54 KiB
YAML
openapi: 3.1.0
|
||
|
||
info:
|
||
title: TextMachine API
|
||
version: 0.2.2
|
||
summary: Ratified contract between the frontend and the TextMachine platform (D39.99).
|
||
description: |
|
||
**RATIFIED contract (D39.99, 04.08.2026).** The canonical copy lives in
|
||
`docs/architecture/14-api-contract/` (orchestrator's zone); `frontend/docs/api-contract/` is a
|
||
byte-mirror of it, and a divergence is a defect of one of the two, never a local edit.
|
||
⚠ **This copy is deliberately AHEAD of the canonical one right now.** Version 0.2.0 was written
|
||
by session S3 from the owner's decisions of 04–07.08 and the platform's ratified answers; the
|
||
orchestrator re-ratifies the canonical copy by diff, and the two are byte-equal again after
|
||
that landing.
|
||
|
||
Companion document: the `README.md` beside the canonical copy — provenance of every decision
|
||
(derived from engine code / proposed by the frontend / open), rationale, dependencies and open
|
||
questions. This file is normative for the FORM; the companion explains where the form comes from.
|
||
|
||
## Boundaries
|
||
|
||
The frontend reads the platform read-model only. The engine is never addressed by any path
|
||
below (D39.85).
|
||
|
||
Pipeline vocabulary does not cross this boundary: no model names, no stage names, no money.
|
||
The read-model → frontend projection is an allowlist — a field not named here never reaches
|
||
the browser.
|
||
|
||
No response is served from an indexable URL. The app lives on `app.<domain>` under
|
||
`X-Robots-Tag: noindex`; responses carrying translated text MUST be sent with
|
||
`Cache-Control: no-store`.
|
||
|
||
## Transport
|
||
|
||
The browser client is served from the **same origin** as this API. That is a fact of the
|
||
platform, not a setting: it has no CORS layer at all — a preflight `OPTIONS` carrying a foreign
|
||
`Origin` is answered `401` by the session guard, and no `Access-Control-*` header is sent on any
|
||
response. A cross-origin browser client is therefore inoperable as a class rather than
|
||
unconfigured, and nothing here is designed around cross-origin requests. A development server
|
||
reaches this API through a proxy onto its own origin.
|
||
|
||
Session mechanics — starting a login, finishing it, ending one session, ending all of them —
|
||
live OUTSIDE the version prefix, like `/healthz`, and are described in the companion rather
|
||
than here: they are the mechanics of holding a session, not a contract surface.
|
||
|
||
## Versioning
|
||
|
||
Semver. **Minor** — backwards-compatible additions: a client MUST ignore unknown fields and
|
||
MUST tolerate unknown enum values without failing. **Major** — a client MUST refuse an
|
||
unsupported version and tell the user.
|
||
|
||
⚠ **While the version is 0.x, a MINOR bump is the lane for breaking changes** (semver §4:
|
||
"Major version zero … anything MAY change at any time"), and 0.2.0 carries several — a required
|
||
`next_cursor` on every list, a required `ceiling_chapters` on a run request. A client pins the
|
||
exact 0.x version it was generated against and does not assume compatibility across minors.
|
||
From 1.0.0 the rule above applies unqualified. Ratified by the orchestrator at the S3 landing:
|
||
session S3 named the contradiction between this section and its own bump instead of resolving
|
||
it by its own hand, which was correct — the versioning rule is the contract owner's.
|
||
|
||
Every `enum` below is the vocabulary of THIS version, not a closed world. Generated types are
|
||
closed unions and do NOT protect against an unknown value, so the unknown-value branch
|
||
belongs on the client seam (`src/api/`), where values enter, not in every component.
|
||
|
||
license:
|
||
name: UNLICENSED
|
||
identifier: LicenseRef-proprietary
|
||
|
||
servers:
|
||
- url: https://app.example.org/v0
|
||
description: |
|
||
The platform, on the SAME ORIGIN as the browser client (see Transport). Only the version
|
||
prefix is fixed here: the host is whatever origin served the application, and a client that
|
||
hard-codes one is a client that cannot be deployed anywhere else.
|
||
|
||
security:
|
||
- sessionCookie: []
|
||
- bearerToken: []
|
||
|
||
tags:
|
||
- name: library
|
||
description: Book library and book card.
|
||
- name: reading
|
||
description: Chapters, source/translation pairs, notes.
|
||
- name: bank
|
||
description: Memory bank and term signing.
|
||
- name: runs
|
||
description: Translation runs, live progress, control.
|
||
- name: export
|
||
description: Export of a finished book.
|
||
- name: account
|
||
description: Credit balance of the account.
|
||
|
||
paths:
|
||
/books:
|
||
get:
|
||
tags: [library]
|
||
operationId: listBooks
|
||
summary: Book library.
|
||
description: |
|
||
Flat list of the user's books. `revision` is the revision of the LIBRARY itself
|
||
(membership and statuses) and belongs to the library's own scope: it is never compared
|
||
with the revision of a book.
|
||
|
||
Page size default is the platform's choice here — the client MUST follow `next_cursor`
|
||
until it is `null` rather than assume the library fits in one page.
|
||
parameters:
|
||
- $ref: '#/components/parameters/Limit'
|
||
- $ref: '#/components/parameters/Cursor'
|
||
responses:
|
||
'200':
|
||
description: Library.
|
||
content:
|
||
application/json:
|
||
schema: { $ref: '#/components/schemas/Library' }
|
||
'400': { $ref: '#/components/responses/BadRequest' }
|
||
'401': { $ref: '#/components/responses/Unauthorized' }
|
||
post:
|
||
tags: [library]
|
||
operationId: createBook
|
||
summary: Add a book.
|
||
description: |
|
||
Accepts the file and the properties declared by the user. Responds immediately; the book
|
||
enters `uploading` and parsing is a separate visible step.
|
||
requestBody:
|
||
required: true
|
||
content:
|
||
multipart/form-data:
|
||
schema: { $ref: '#/components/schemas/BookIntake' }
|
||
responses:
|
||
'201':
|
||
description: Book accepted.
|
||
content:
|
||
application/json:
|
||
schema: { $ref: '#/components/schemas/Book' }
|
||
'400': { $ref: '#/components/responses/BadRequest' }
|
||
'401': { $ref: '#/components/responses/Unauthorized' }
|
||
'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.
|
||
responses:
|
||
'200':
|
||
description: Book card.
|
||
content:
|
||
application/json:
|
||
schema: { $ref: '#/components/schemas/BookDetail' }
|
||
'401': { $ref: '#/components/responses/Unauthorized' }
|
||
'404': { $ref: '#/components/responses/NotFound' }
|
||
|
||
/books/{bookId}/chapters:
|
||
parameters:
|
||
- $ref: '#/components/parameters/BookId'
|
||
get:
|
||
tags: [reading]
|
||
operationId: listChapters
|
||
summary: Chapter tree.
|
||
description: |
|
||
Chapters in reading order. A chapter has NO status, only unit progress: bank signing is a
|
||
single book-wide stop, so "one chapter awaits signing while its neighbour finalizes"
|
||
cannot happen.
|
||
|
||
Default page size **5000**.
|
||
parameters:
|
||
- $ref: '#/components/parameters/Limit'
|
||
- $ref: '#/components/parameters/Cursor'
|
||
responses:
|
||
'200':
|
||
description: Chapters of the book.
|
||
content:
|
||
application/json:
|
||
schema: { $ref: '#/components/schemas/ChapterList' }
|
||
'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 unit of shipping is the EDIT UNIT, not a paragraph and not a chunk: roughly 1.9 units
|
||
per chapter, and a whole chapter is sometimes a single block. Alignment is coarse and
|
||
accepted as such.
|
||
|
||
**Units are read PER CHAPTER and only per chapter.** A book-wide units endpoint is never
|
||
introduced: the whole memory model of the client stands on this — the working set stays
|
||
tens of kilobytes instead of tens of megabytes. Page size default is the platform's choice;
|
||
the client follows `next_cursor`.
|
||
parameters:
|
||
- $ref: '#/components/parameters/Limit'
|
||
- $ref: '#/components/parameters/Cursor'
|
||
responses:
|
||
'200':
|
||
description: Pairs of the chapter.
|
||
content:
|
||
application/json:
|
||
schema: { $ref: '#/components/schemas/UnitList' }
|
||
'400': { $ref: '#/components/responses/BadRequest' }
|
||
'401': { $ref: '#/components/responses/Unauthorized' }
|
||
'404': { $ref: '#/components/responses/NotFound' }
|
||
|
||
/books/{bookId}/notes:
|
||
parameters:
|
||
- $ref: '#/components/parameters/BookId'
|
||
get:
|
||
tags: [reading]
|
||
operationId: listNotes
|
||
summary: Notes of a book.
|
||
description: |
|
||
A note addresses a unit or a whole chapter. Byte offsets do not exist in the engine's
|
||
checks and are not planned.
|
||
|
||
Default page size **500**.
|
||
parameters:
|
||
- $ref: '#/components/parameters/Limit'
|
||
- $ref: '#/components/parameters/Cursor'
|
||
responses:
|
||
'200':
|
||
description: Notes of the book.
|
||
content:
|
||
application/json:
|
||
schema: { $ref: '#/components/schemas/NoteList' }
|
||
'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: |
|
||
⚠ **No backing channel exists for this read today.** The engine ships a signing table,
|
||
not a bank export, and its private store must not be read by the platform. The export
|
||
artifact is a dependency the frontend cannot create — companion §3.
|
||
|
||
Default page size **1000**.
|
||
parameters:
|
||
- $ref: '#/components/parameters/Limit'
|
||
- $ref: '#/components/parameters/Cursor'
|
||
responses:
|
||
'200':
|
||
description: Bank of the book.
|
||
content:
|
||
application/json:
|
||
schema: { $ref: '#/components/schemas/Bank' }
|
||
'400': { $ref: '#/components/responses/BadRequest' }
|
||
'401': { $ref: '#/components/responses/Unauthorized' }
|
||
'404': { $ref: '#/components/responses/NotFound' }
|
||
|
||
/books/{bookId}/bank/decisions:
|
||
parameters:
|
||
- $ref: '#/components/parameters/BookId'
|
||
post:
|
||
tags: [bank]
|
||
operationId: submitBankDecisions
|
||
summary: Submit term decisions.
|
||
description: |
|
||
**Signing is not a row edit.** The pipeline replaces a book's whole glossary from its
|
||
deterministic inputs, so a direct write would be erased by the next run. A decision is
|
||
`promote` (with a translation) or `decline`, following the stop mechanics exactly.
|
||
|
||
Submission is PARTIAL and accumulates on the server: there are hundreds of terms, and a
|
||
closed tab must not cost an hour of work.
|
||
requestBody:
|
||
required: true
|
||
content:
|
||
application/json:
|
||
schema: { $ref: '#/components/schemas/BankDecisionsRequest' }
|
||
responses:
|
||
'200':
|
||
description: Decisions accepted; the response carries what is left.
|
||
content:
|
||
application/json:
|
||
schema: { $ref: '#/components/schemas/BankDecisionsResult' }
|
||
'400': { $ref: '#/components/responses/BadRequest' }
|
||
'401': { $ref: '#/components/responses/Unauthorized' }
|
||
'404': { $ref: '#/components/responses/NotFound' }
|
||
|
||
/books/{bookId}/run-options:
|
||
parameters:
|
||
- $ref: '#/components/parameters/BookId'
|
||
get:
|
||
tags: [runs]
|
||
operationId: getRunOptions
|
||
summary: Bounds for starting a run.
|
||
description: |
|
||
Bounds of the run-ceiling scale, read right before a run is started.
|
||
|
||
A resource of its own rather than 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 — at the moment the user is dragging 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: |
|
||
`verify_bank` is a parameter of the RUN, not a global setting. With it the run stops at
|
||
the bank boundary; without it the unsigned bank is carried forward marked as unverified.
|
||
It is the user's choice between "I will sign" and "translate as is".
|
||
|
||
`ceiling_chapters` is a parameter of the RUN as well, and it is not a property of the
|
||
book: it travels with the start and does not outlive the run.
|
||
|
||
**409** also answers a ceiling that no longer fits: the bounds are read by
|
||
`GET /books/{bookId}/run-options` and may move between that read and this call, because a
|
||
hold taken for another book lowers the remainder.
|
||
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' }
|
||
'404': { $ref: '#/components/responses/NotFound' }
|
||
'409': { $ref: '#/components/responses/Conflict' }
|
||
'503': { $ref: '#/components/responses/ServiceUnavailable' }
|
||
|
||
/runs/{runId}/events:
|
||
parameters:
|
||
- $ref: '#/components/parameters/RunId'
|
||
get:
|
||
tags: [runs]
|
||
operationId: streamRunEvents
|
||
summary: Live run events (SSE).
|
||
description: |
|
||
`text/event-stream`. Required: HTTP/2 at the edge, `Cache-Control: no-store`,
|
||
`X-Accel-Buffering: no`, a heartbeat every ~20 s, a monotonic `id` and `Last-Event-ID`
|
||
support. Events are PUSHED by the platform worker; the frontend never polls the
|
||
read-model.
|
||
|
||
**The first event is always `hello`** — the version handshake. A client that does not
|
||
support the major version closes the stream and tells the user.
|
||
|
||
**Reconnect.** The client sends `Last-Event-ID`. If the server cannot resume from it, it
|
||
MUST answer with `resync_required` instead of silently starting from the present moment;
|
||
the client then re-reads snapshots. Replaying history is forbidden — a one-shot event
|
||
such as `note` would otherwise be lost silently.
|
||
|
||
OpenAPI does not type SSE frames, so the event name → payload schema mapping is a table
|
||
in the `EventEnvelope` description.
|
||
responses:
|
||
'200':
|
||
description: Event stream.
|
||
content:
|
||
text/event-stream:
|
||
schema: { $ref: '#/components/schemas/EventEnvelope' }
|
||
'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. The engine stops gracefully on a signal; deciding who pressed
|
||
it belongs to the platform.
|
||
responses:
|
||
'202':
|
||
description: Stop accepted.
|
||
content:
|
||
application/json:
|
||
schema: { $ref: '#/components/schemas/Run' }
|
||
'401': { $ref: '#/components/responses/Unauthorized' }
|
||
'404': { $ref: '#/components/responses/NotFound' }
|
||
'409': { $ref: '#/components/responses/Conflict' }
|
||
|
||
/runs/{runId}/resume:
|
||
parameters:
|
||
- $ref: '#/components/parameters/RunId'
|
||
post:
|
||
tags: [runs]
|
||
operationId: resumeRun
|
||
summary: Resume a stopped run.
|
||
description: |
|
||
Clears the bank-signing stop and continues after a user stop.
|
||
|
||
⚠ **After a ceiling stop this call alone does not move the run.** The engine continues only
|
||
once the ceiling has been raised, and no handle raises it: `ceiling_chapters` travels with
|
||
the START of a run and this contract has no way to change it afterwards. The mechanism is
|
||
the platform's and does not exist yet (companion §3); until it does, `resume` on a run
|
||
paused by a ceiling returns it to the same state, so the client MUST NOT offer resume as
|
||
the remedy for `paused`.
|
||
|
||
**Answers 409 while the set of bank decisions is incomplete** — the stop clears only on a
|
||
complete set.
|
||
responses:
|
||
'202':
|
||
description: Resume accepted.
|
||
content:
|
||
application/json:
|
||
schema: { $ref: '#/components/schemas/Run' }
|
||
'401': { $ref: '#/components/responses/Unauthorized' }
|
||
'404': { $ref: '#/components/responses/NotFound' }
|
||
'409': { $ref: '#/components/responses/Conflict' }
|
||
|
||
/usage:
|
||
get:
|
||
tags: [account]
|
||
operationId: getUsage
|
||
summary: State of the credit balance.
|
||
description: |
|
||
Credits are a BALANCE, not a subscription with windows. There is no period, no `resets_at`
|
||
and no "resets in": the screen shows what is LEFT. The percentage is computed against the
|
||
sum of the account's grants, not against a limit of a period — periods do not exist.
|
||
|
||
Money SUMS never cross this boundary in any form: a percentage, never an amount.
|
||
responses:
|
||
'200':
|
||
description: Balance state.
|
||
content:
|
||
application/json:
|
||
schema: { $ref: '#/components/schemas/Usage' }
|
||
'401': { $ref: '#/components/responses/Unauthorized' }
|
||
|
||
/books/{bookId}/exports:
|
||
parameters:
|
||
- $ref: '#/components/parameters/BookId'
|
||
post:
|
||
tags: [export]
|
||
operationId: createExport
|
||
summary: Build a book export.
|
||
description: |
|
||
Formats and their contents are stage S7 work; only the call shape is fixed here.
|
||
|
||
The export is an ARTIFACT BEHIND A LINK. Assembling the text of a book on the client —
|
||
reading every chapter and stitching it together — is forbidden explicitly: it would defeat
|
||
the per-chapter working set that the read paths are built around.
|
||
|
||
Completion is POLLED, not pushed: the `202` names the status resource in `Location`, and
|
||
the status read carries `Retry-After`.
|
||
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' }
|
||
'401': { $ref: '#/components/responses/Unauthorized' }
|
||
'404': { $ref: '#/components/responses/NotFound' }
|
||
|
||
/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: it answers `ready: false` and nothing
|
||
ever says otherwise. **Completion is polled, not pushed** — no stream frame announces it.
|
||
responses:
|
||
'200':
|
||
description: State of the export.
|
||
headers:
|
||
Retry-After:
|
||
description: |
|
||
Seconds to wait before polling again; sent while `ready` is `false`.
|
||
|
||
Declared here on purpose. RFC 9110 defines this header for `503` and for `3xx`,
|
||
and its general semantics do not reach a `200`, so a contract that wants it on a
|
||
`200` has to say so itself.
|
||
schema: { type: integer, minimum: 0 }
|
||
content:
|
||
application/json:
|
||
schema: { $ref: '#/components/schemas/Export' }
|
||
'401': { $ref: '#/components/responses/Unauthorized' }
|
||
'404': { $ref: '#/components/responses/NotFound' }
|
||
|
||
components:
|
||
securitySchemes:
|
||
sessionCookie:
|
||
type: apiKey
|
||
in: cookie
|
||
name: __Host-tm_session
|
||
description: |
|
||
Browser presentation of one server-side session: HttpOnly, Secure, SameSite=Lax.
|
||
|
||
**CSRF.** On the cookie path a browser client MUST send the header `X-TM-Client` on every
|
||
UNSAFE request — anything other than GET and HEAD. What carries the protection is the
|
||
PRESENCE of the header; the value is arbitrary and has no token semantics, so do not invent
|
||
any. It is required on same-origin requests as well: it is not a CORS mechanism.
|
||
|
||
The same requirement holds for the session-mechanics endpoints that live outside the
|
||
version prefix (companion).
|
||
bearerToken:
|
||
type: http
|
||
scheme: bearer
|
||
description: |
|
||
Desktop and CLI present the same server-side session as an opaque token. The principal is
|
||
established in middleware only; no endpoint may assume a cookie — that is what keeps the
|
||
API portable to the desktop client.
|
||
|
||
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' }
|
||
Limit:
|
||
name: limit
|
||
in: query
|
||
required: false
|
||
description: |
|
||
Page size. The default is stated per collection on the operation; a server MAY return
|
||
fewer rows than asked for, and the client decides nothing from that — only from
|
||
`next_cursor`.
|
||
schema: { type: integer, minimum: 1 }
|
||
Cursor:
|
||
name: cursor
|
||
in: query
|
||
required: false
|
||
description: |
|
||
Keyset cursor taken 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 rejected with `400`; see `NextCursor` for why that
|
||
rejection is the server's duty and not the client's.
|
||
schema: { type: string, minLength: 1 }
|
||
|
||
responses:
|
||
BadRequest:
|
||
description: Request rejected.
|
||
content:
|
||
application/problem+json:
|
||
schema: { $ref: '#/components/schemas/Problem' }
|
||
Unauthorized:
|
||
description: Session missing or invalid.
|
||
content:
|
||
application/problem+json:
|
||
schema: { $ref: '#/components/schemas/Problem' }
|
||
NotFound:
|
||
description: Object not found.
|
||
content:
|
||
application/problem+json:
|
||
schema: { $ref: '#/components/schemas/Problem' }
|
||
Conflict:
|
||
description: Action impossible in the current state.
|
||
content:
|
||
application/problem+json:
|
||
schema: { $ref: '#/components/schemas/Problem' }
|
||
TooLarge:
|
||
description: File exceeds the intake size limit.
|
||
content:
|
||
application/problem+json:
|
||
schema: { $ref: '#/components/schemas/Problem' }
|
||
ServiceUnavailable:
|
||
description: |
|
||
The deployment cannot perform this action right now (added in 0.2.1, D39.123): starting a
|
||
run requires the engine seam to be fully configured, and answering with any other code
|
||
would misname the state. Temporary by nature — retry later; no Retry-After is promised.
|
||
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 across
|
||
runs is the platform's job when it persists the manifest.
|
||
examples: ['bk_7c1']
|
||
|
||
Revision:
|
||
type: integer
|
||
minimum: 0
|
||
description: |
|
||
Monotonic revision. **The counter is PER BOOK:** every book-scoped read and the `id` of
|
||
every stream frame of that book's run carry the same number. The library has a scope of its
|
||
own.
|
||
|
||
A revision is monotonic WITHIN its scope and is NEVER compared across scopes.
|
||
|
||
Discarding a stale read is the CLIENT's duty: a read whose revision is lower than what the
|
||
client has already applied MUST be dropped rather than rendered, otherwise the interface
|
||
rolls progress backwards on every refetch — and a refetch on window focus is the default
|
||
behaviour of the client's query layer, so the race happens on every tab switch.
|
||
|
||
**Catch-up after a reconnect reads `revision >= R`, not `> R`.** One transaction is one
|
||
revision but SEVERAL frames; strict "greater than" drops the sibling frames of the last
|
||
one the client applied.
|
||
|
||
After a transaction of FULL REPLACEMENT — the bank rebuilt from scratch, re-chunking
|
||
replacing the chapters — the server MUST answer `resync_required` rather than a delta: a
|
||
delta read cannot express a deletion.
|
||
examples: [1841]
|
||
|
||
NextCursor:
|
||
type: [string, 'null']
|
||
description: |
|
||
Cursor of the NEXT page, or `null` on the last one. Present on EVERY list response,
|
||
always — introducing it later would silently cut the tail off a client that does not read
|
||
the field.
|
||
|
||
The cursor is bound to the STRUCTURAL epoch of the collection — the generation of the
|
||
manifest, the chunker version — and **not to the revision of the book**: the revision bumps
|
||
on every materialization, so binding to it would restart pagination forever while a
|
||
5000-chapter book is running.
|
||
|
||
Rejecting a cursor from a dead epoch is the SERVER's duty (MUST), answered `400`. The
|
||
client cannot perform it: the cursor is opaque to it by construction.
|
||
|
||
LangCode:
|
||
type: string
|
||
pattern: '^[a-z]{2,3}(-[A-Za-z0-9]{2,8})*$'
|
||
description: |
|
||
Language code, never a name. The display name is computed by the screen via
|
||
`Intl.DisplayNames`.
|
||
examples: ['zh']
|
||
|
||
Counter:
|
||
type: object
|
||
description: Wave counter, in UNITS.
|
||
required: [done, total]
|
||
properties:
|
||
done: { type: integer, minimum: 0 }
|
||
total: { type: integer, minimum: 0 }
|
||
|
||
Progress:
|
||
type: object
|
||
description: |
|
||
Progress PER PHASE, in units: a unit is done only once its edit resolved, and editing does
|
||
not start before the bank stop, so a single end-to-end counter reads zero for the whole
|
||
draft wave.
|
||
|
||
The phases exist for the DATA, not for the screen — the user sees one fraction with no
|
||
phase names. No ready-made percentage is shipped: the formula is a product decision.
|
||
required: [draft, edit]
|
||
properties:
|
||
draft: { $ref: '#/components/schemas/Counter' }
|
||
edit: { $ref: '#/components/schemas/Counter' }
|
||
eta_seconds:
|
||
type: [integer, 'null']
|
||
minimum: 0
|
||
description: |
|
||
Estimated seconds to the end of the run. **Optional:** it is absent whenever there is
|
||
nothing to estimate from — before the first calls of a wave there is no throughput yet
|
||
— and the screen MUST render without it rather than show a zero.
|
||
|
||
BookStatus:
|
||
type: string
|
||
description: |
|
||
Product status of a book: `uploading` file is being accepted · `parsing` split into
|
||
chapters · `not_started` parsed, never run · `translating` translation in progress ·
|
||
`awaiting_bank` waiting for the glossary to be signed · `finalizing` final pass ·
|
||
`ready` done · `paused` halted and resumable · `stopped` stopped by the user ·
|
||
`rejected` file could not be parsed · `failed` run aborted by an error.
|
||
|
||
The engine has no run-state vocabulary at all, so `not_started`, `stopped` and `rejected`
|
||
are DERIVED by the contract rather than received as a field.
|
||
|
||
**A ceiling stop is `paused`, never `failed`.** It is a resumable book-wide stop, and
|
||
mapping it to `failed` is forbidden because that would lie about resumability. The machine
|
||
reason travels as `Run.paused_reason`; the phrase the user reads is drawn by the client.
|
||
|
||
A prescreen refusal maps to none of these values either — companion §4 (K-9).
|
||
enum:
|
||
- uploading
|
||
- parsing
|
||
- not_started
|
||
- translating
|
||
- awaiting_bank
|
||
- finalizing
|
||
- ready
|
||
- paused
|
||
- stopped
|
||
- rejected
|
||
- failed
|
||
|
||
Book:
|
||
type: object
|
||
description: A book in the library.
|
||
required:
|
||
[id, title, source_lang, target_lang, status, chapter_count, added_at, progress, note_count]
|
||
properties:
|
||
id: { $ref: '#/components/schemas/Id' }
|
||
title: { type: string }
|
||
source_lang: { $ref: '#/components/schemas/LangCode' }
|
||
target_lang: { $ref: '#/components/schemas/LangCode' }
|
||
genre: { type: string, description: Genre as declared by the user. }
|
||
chapter_count: { type: integer, minimum: 0 }
|
||
character_count:
|
||
type: integer
|
||
minimum: 0
|
||
description: Size in characters. Not an engine field; the platform knows it from intake.
|
||
added_at: { type: string, format: date-time }
|
||
status: { $ref: '#/components/schemas/BookStatus' }
|
||
progress: { $ref: '#/components/schemas/Progress' }
|
||
note_count: { type: integer, minimum: 0 }
|
||
|
||
Library:
|
||
type: object
|
||
required: [revision, next_cursor, books]
|
||
properties:
|
||
revision: { $ref: '#/components/schemas/Revision' }
|
||
next_cursor: { $ref: '#/components/schemas/NextCursor' }
|
||
books:
|
||
type: array
|
||
items: { $ref: '#/components/schemas/Book' }
|
||
|
||
PausedReason:
|
||
type: string
|
||
description: |
|
||
Machine reason a run is paused. The API carries STATE; the phrase the user reads is drawn
|
||
by the client, so no wording appears here.
|
||
|
||
One value exists today. A client MUST tolerate an unknown one arriving under a minor bump
|
||
and show the neutral "halted, resumable" state rather than failing or guessing.
|
||
enum: [credit_exhausted]
|
||
|
||
Run:
|
||
type: object
|
||
description: |
|
||
A run over a book. `status` reuses the book vocabulary, but the book-level values
|
||
(`uploading`, `parsing`, `not_started`, `rejected`) never appear on a run.
|
||
required: [id, revision, status, verify_bank, ceiling_chapters, paused_reason, started_at]
|
||
properties:
|
||
id: { $ref: '#/components/schemas/Id' }
|
||
revision: { $ref: '#/components/schemas/Revision' }
|
||
status: { $ref: '#/components/schemas/BookStatus' }
|
||
verify_bank:
|
||
type: boolean
|
||
description: The run was requested with a stop for bank signing.
|
||
ceiling_chapters:
|
||
type: integer
|
||
minimum: 1
|
||
description: |
|
||
The ceiling this run was started with, in CHAPTERS. A property of the RUN, not of the
|
||
book: it travels with the start and does not outlive the run. Present so that a reloaded
|
||
screen can still name the cap the user chose.
|
||
paused_reason:
|
||
oneOf:
|
||
- $ref: '#/components/schemas/PausedReason'
|
||
- type: 'null'
|
||
description: Reason when `status` is `paused`; `null` in every other state.
|
||
started_at: { type: string, format: date-time }
|
||
finished_at:
|
||
type: [string, 'null']
|
||
format: date-time
|
||
|
||
BookDetail:
|
||
type: object
|
||
required: [revision, book]
|
||
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.
|
||
|
||
BookIntake:
|
||
type: object
|
||
description: Add-a-book form.
|
||
required: [file, source_lang, target_lang]
|
||
properties:
|
||
file:
|
||
type: string
|
||
format: binary
|
||
description: Book file.
|
||
source_lang: { $ref: '#/components/schemas/LangCode' }
|
||
target_lang: { $ref: '#/components/schemas/LangCode' }
|
||
genre: { type: string }
|
||
|
||
Chapter:
|
||
type: object
|
||
required: [id, number, heading, units_total, units_done, note_count]
|
||
properties:
|
||
id: { $ref: '#/components/schemas/Id' }
|
||
number:
|
||
type: [integer, 'null']
|
||
minimum: 1
|
||
description: |
|
||
Displayed ordinal, or `null` when the book has no numbering — a legal book. Always
|
||
present, possibly null, so the client handles one shape rather than two.
|
||
|
||
**Not a key:** numbering is dense — chapters that yield no text do not consume a
|
||
number — 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 client MUST NOT synthesize a label from a template such as "Chapter {n}": no such
|
||
form exists, a book legally has no numbers, and a book legally has no chapters at all.
|
||
An unlabelled chapter is shown without a name rather than given an invented one.
|
||
|
||
The server bounds the length — in a list of 5000 rows this is the only string that
|
||
would otherwise be unbounded.
|
||
|
||
Who produces the label is still a backend question: today the engine glues a rendered
|
||
title into the text of the first unit and leaves the source column without it —
|
||
companion §4 (K-2).
|
||
units_total: { type: integer, minimum: 0 }
|
||
units_done:
|
||
type: integer
|
||
minimum: 0
|
||
description: |
|
||
Units finished in this chapter. Whether "finished" needs the same draft/edit split as
|
||
`Progress` — without it a chapter reads zero for the whole draft wave — is open,
|
||
companion §4 (K-10).
|
||
note_count: { type: integer, minimum: 0 }
|
||
|
||
ChapterList:
|
||
type: object
|
||
required: [revision, next_cursor, chapters]
|
||
properties:
|
||
revision: { $ref: '#/components/schemas/Revision' }
|
||
next_cursor: { $ref: '#/components/schemas/NextCursor' }
|
||
chapters:
|
||
type: array
|
||
items: { $ref: '#/components/schemas/Chapter' }
|
||
|
||
UnitState:
|
||
type: string
|
||
description: |
|
||
State of a pair, derived from the PAIR (chunk verdict plus presence of final text), not
|
||
from the verdict alone: a flagged unit legally arrives WITH text.
|
||
|
||
- `translated` — text shipped. This includes a flagged unit whose text shipped anyway,
|
||
such as a cosmetic sanitizer cleanup; such a unit carries a `note`;
|
||
- `withheld` — verdict flagged AND no text;
|
||
- `pending` — not translated yet.
|
||
|
||
The word "flagged" never goes on the wire: it is pipeline vocabulary.
|
||
enum: [translated, withheld, pending]
|
||
|
||
Unit:
|
||
type: object
|
||
description: |
|
||
A source/translation pair, one edit unit wide.
|
||
|
||
**Freshness.** `target` is updated at stage boundaries and at stops, not continuously —
|
||
mid-run there is no read channel at all. The live "something changed" signal arrives as an
|
||
event; the text arrives with a read after the boundary.
|
||
required: [id, source, state]
|
||
properties:
|
||
id: { $ref: '#/components/schemas/Id' }
|
||
source:
|
||
type: string
|
||
description: Source text, aligned to the edit unit.
|
||
target:
|
||
type: string
|
||
description: Translated text. Empty for `withheld` and `pending`.
|
||
state: { $ref: '#/components/schemas/UnitState' }
|
||
note:
|
||
oneOf:
|
||
- $ref: '#/components/schemas/Note'
|
||
- type: 'null'
|
||
|
||
UnitList:
|
||
type: object
|
||
required: [revision, next_cursor, units]
|
||
properties:
|
||
revision: { $ref: '#/components/schemas/Revision' }
|
||
next_cursor: { $ref: '#/components/schemas/NextCursor' }
|
||
units:
|
||
type: array
|
||
items: { $ref: '#/components/schemas/Unit' }
|
||
|
||
NoteSeverity:
|
||
type: string
|
||
description: |
|
||
Severity step. Two steps are a frontend PROPOSAL projected from the engine's operator
|
||
severity ranks, and that axis need not match the product one. How many steps there are and
|
||
where the boundary lies is an open product question — companion §4 (K-6).
|
||
enum: [attention, glance]
|
||
|
||
Note:
|
||
type: object
|
||
description: |
|
||
A note in product terms. Neither the engine's flag reason nor its detail text crosses the
|
||
boundary; the reason → phrase map belongs to the contract and is not filled in this draft
|
||
(companion, appendix A).
|
||
required: [severity, message]
|
||
properties:
|
||
severity: { $ref: '#/components/schemas/NoteSeverity' }
|
||
message:
|
||
type: string
|
||
description: |
|
||
Ready human phrase; the wording is the owner's call. A phrase MUST exist for every
|
||
reason, including one this contract does not know yet, and it MUST read neutrally
|
||
rather than as an error.
|
||
chapter_id: { $ref: '#/components/schemas/Id' }
|
||
unit_id: { $ref: '#/components/schemas/Id' }
|
||
|
||
NoteList:
|
||
type: object
|
||
required: [revision, next_cursor, notes]
|
||
properties:
|
||
revision: { $ref: '#/components/schemas/Revision' }
|
||
next_cursor: { $ref: '#/components/schemas/NextCursor' }
|
||
notes:
|
||
type: array
|
||
items: { $ref: '#/components/schemas/Note' }
|
||
|
||
TermKind:
|
||
type: string
|
||
description: |
|
||
Kind of term. Not cosmetic: `name` and `place` ROUTE a term into transliteration, so
|
||
signing a term without seeing its kind means signing blind.
|
||
enum: [name, place, title, term, nickname]
|
||
|
||
TermStatus:
|
||
type: string
|
||
description: |
|
||
Signing status, THREE-VALUED; only `approved` is injected as canon. A boolean `signed`
|
||
would merge "proposed by the engine, nobody looked" with "a human started and did not
|
||
finish" — on a screen of hundreds of rows that is the main filter of work.
|
||
enum: [auto, draft, approved]
|
||
|
||
TermOrigin:
|
||
type: string
|
||
description: Provenance of a bank row — who created it. An axis independent of `status`.
|
||
enum: [seed, ruby, mined]
|
||
|
||
BankTerm:
|
||
type: object
|
||
description: |
|
||
A memory bank row.
|
||
|
||
⚠ **The name `source` is deliberately unused here.** In the engine that column means
|
||
PROVENANCE; this contract calls provenance `origin` and the term's surfaces `src`/`dst`.
|
||
Naming the term's text `source` would create a false friend between the two schemas.
|
||
required: [id, src, dst, kind, status, origin, sense, since_chapter, until_chapter]
|
||
properties:
|
||
id: { $ref: '#/components/schemas/Id' }
|
||
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 engine could not decide the kind: ruby candidates that are neither a
|
||
name nor a place legally carry none. A client MUST show such a row as "kind not
|
||
decided" and MUST NOT drop it or invent a kind — the row still needs signing.
|
||
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" (0.2.2).** It was optional
|
||
while being named as part of the key: a client then could not tell "this term has no
|
||
disambiguator" from "the field was not sent", although that is exactly the field by
|
||
which two legal rows of the same surface differ.
|
||
since_chapter:
|
||
type: integer
|
||
minimum: 0
|
||
description: |
|
||
Start of the spoiler window; `0` means from the beginning of the book. A term is
|
||
unique by `(book, src, sense, since, until)`, so the same `src` legally arrives as
|
||
several rows — without the window they look like duplicates and get deleted.
|
||
until_chapter:
|
||
type: integer
|
||
minimum: 0
|
||
description: End of the spoiler window; `0` means open-ended.
|
||
|
||
Bank:
|
||
type: object
|
||
required: [revision, next_cursor, total, signed, terms]
|
||
properties:
|
||
revision: { $ref: '#/components/schemas/Revision' }
|
||
next_cursor: { $ref: '#/components/schemas/NextCursor' }
|
||
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 on this page.
|
||
terms:
|
||
type: array
|
||
items: { $ref: '#/components/schemas/BankTerm' }
|
||
|
||
BankDecision:
|
||
type: object
|
||
description: |
|
||
A decision on one proposed term. `dst` is mandatory and non-empty for `promote`: the
|
||
engine refuses a signed term with an empty translation on the next run, because such a
|
||
term matches nothing yet reads as an intended rendering.
|
||
required: [term_id, action]
|
||
if:
|
||
properties:
|
||
action: { const: promote }
|
||
required: [action]
|
||
then:
|
||
required: [dst]
|
||
properties:
|
||
dst: { minLength: 1 }
|
||
properties:
|
||
term_id: { $ref: '#/components/schemas/Id' }
|
||
action:
|
||
type: string
|
||
enum: [promote, decline]
|
||
description: '`promote` — accept the term (with a translation in `dst`); `decline` — reject it.'
|
||
dst:
|
||
type: string
|
||
description: Translation. Required and non-empty when `action` is `promote`.
|
||
|
||
BankDecisionsRequest:
|
||
type: object
|
||
required: [decisions]
|
||
properties:
|
||
decisions:
|
||
type: array
|
||
minItems: 1
|
||
items: { $ref: '#/components/schemas/BankDecision' }
|
||
|
||
BankDecisionsResult:
|
||
type: object
|
||
required: [revision, pending_decisions, complete]
|
||
properties:
|
||
revision: { $ref: '#/components/schemas/Revision' }
|
||
pending_decisions:
|
||
type: integer
|
||
minimum: 0
|
||
description: How many proposed terms still have no decision.
|
||
complete:
|
||
type: boolean
|
||
description: |
|
||
The set is complete. The stop clears ONLY on a complete set, so the screen must show
|
||
"N of M decided" and must not offer to resume while this is `false`.
|
||
|
||
RunRequest:
|
||
type: object
|
||
required: [verify_bank, ceiling_chapters]
|
||
properties:
|
||
verify_bank:
|
||
type: boolean
|
||
description: Stop for bank signing before the final pass.
|
||
ceiling_chapters:
|
||
type: integer
|
||
minimum: 1
|
||
description: |
|
||
Ceiling of THIS run, in chapters, within the bounds returned by
|
||
`GET /books/{bookId}/run-options`.
|
||
|
||
Required: a run started without a declared ceiling would spend past the limit the user
|
||
is entitled to set before it begins rather than learn about afterwards. `0` is not a
|
||
legal value — a run with a zero ceiling does not start, so it is not offered.
|
||
|
||
RunOptions:
|
||
type: object
|
||
required: [ceiling]
|
||
properties:
|
||
ceiling: { $ref: '#/components/schemas/CeilingBounds' }
|
||
|
||
CeilingBounds:
|
||
type: object
|
||
description: |
|
||
Bounds of the run-ceiling scale, in CHAPTERS. The chapters → money conversion lives on the
|
||
platform and is not exposed here in any form.
|
||
|
||
`max_chapters` is what the account can still spend, already clamped to what is left of the
|
||
book. A client MUST NOT clamp it again.
|
||
|
||
⚠ A quantity, not arithmetic: a hold is a debit when it is taken, so a running balance
|
||
already excludes the holds open against it, and subtracting them a second time would halve
|
||
the scale.
|
||
|
||
`max_chapters` of `0` means no run can start at all — the client shows the exhausted state
|
||
instead of a scale. Zero is never selectable.
|
||
required: [min_chapters, max_chapters, default_chapters]
|
||
properties:
|
||
min_chapters:
|
||
type: integer
|
||
minimum: 1
|
||
description: Smallest ceiling that can be started.
|
||
max_chapters:
|
||
type: integer
|
||
minimum: 0
|
||
description: Largest ceiling that can be started; `0` when none can.
|
||
default_chapters:
|
||
type: integer
|
||
minimum: 0
|
||
description: |
|
||
Pre-selected value. The platform owns it because the choice is product policy — a client
|
||
picking it would decide "spend everything" or "one chapter" on its own. `0` only when
|
||
`max_chapters` is `0`.
|
||
|
||
Usage:
|
||
type: object
|
||
description: |
|
||
State of the credit balance. No window, no `resets_at`, no sums — see `GET /usage`.
|
||
required: [state, remaining_percent]
|
||
properties:
|
||
state:
|
||
type: string
|
||
description: |
|
||
`ok` · `low` the threshold at which the interface warns · `exhausted` nothing left. The
|
||
threshold itself belongs to the platform and is not on the wire: a client that computed
|
||
it from the percentage would carry a second copy of the policy.
|
||
enum: [ok, low, exhausted]
|
||
remaining_percent:
|
||
type: integer
|
||
minimum: 0
|
||
maximum: 100
|
||
description: Share of the account's grants still available. A percentage, never an amount.
|
||
paused_reason:
|
||
oneOf:
|
||
- $ref: '#/components/schemas/PausedReason'
|
||
- type: 'null'
|
||
description: |
|
||
Set when the account itself is in a halted state; `null` otherwise. The same value
|
||
travels per-run as `Run.paused_reason`.
|
||
|
||
ExportRequest:
|
||
type: object
|
||
required: [format]
|
||
properties:
|
||
format:
|
||
type: string
|
||
description: Export format; the set of formats is stage S7 work.
|
||
|
||
Export:
|
||
type: object
|
||
required: [id, ready]
|
||
properties:
|
||
id: { $ref: '#/components/schemas/Id' }
|
||
ready: { type: boolean }
|
||
url:
|
||
type: string
|
||
format: uri
|
||
description: |
|
||
Link to the finished export. Served to the authenticated owner only and never
|
||
indexed.
|
||
|
||
EventEnvelope:
|
||
type: object
|
||
description: |
|
||
An SSE frame. 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` | counters moved |
|
||
| `chapter` | `EventChapter` | a chapter's progress changed |
|
||
| `note` | `EventNote` | a note appeared |
|
||
| `bank` | `EventBank` | the bank changed or a signing stop occurred |
|
||
| `ceiling` | `EventCeiling` | the run was halted by a ceiling |
|
||
| `resync_required` | `EventResyncRequired` | resuming the stream is impossible |
|
||
|
||
The frame `id` carries the book's revision — the same counter every book-scoped read
|
||
carries, so a frame and a read can be ordered against each other. One transaction produces
|
||
one revision but possibly SEVERAL frames, which is why catch-up reads `>=` and not `>`
|
||
(see `Revision`).
|
||
|
||
**The server MAY COALESCE frames**, and a client MUST tolerate counters that jump: a run
|
||
over 9500 units would otherwise be an unbounded source of renders. A client therefore must
|
||
not animate from its previous value as though every step had arrived, and must not treat a
|
||
skipped number as a lost frame.
|
||
required: [event, data]
|
||
properties:
|
||
event: { type: string }
|
||
data:
|
||
description: |
|
||
Frame payload. Schemas are listed as a union rather than tied by a discriminator:
|
||
`event` lives in the SSE frame, not inside `data`, so an OpenAPI discriminator does
|
||
not apply. Dispatch by event name, per the table above.
|
||
oneOf:
|
||
- $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/EventCeiling'
|
||
- $ref: '#/components/schemas/EventResyncRequired'
|
||
|
||
EventHello:
|
||
type: object
|
||
description: Version handshake. A client that does not support the major version closes the stream.
|
||
required: [contract, run_id, revision]
|
||
properties:
|
||
contract:
|
||
type: string
|
||
description: Contract version, e.g. `0.1.0`.
|
||
run_id: { $ref: '#/components/schemas/Id' }
|
||
revision: { $ref: '#/components/schemas/Revision' }
|
||
|
||
EventStatus:
|
||
type: object
|
||
description: |
|
||
Product status changed. `paused_reason` travels with it so that a pause is actionable
|
||
without a second read — the frame that announces the stop is exactly the moment the screen
|
||
has to say why.
|
||
required: [status, paused_reason]
|
||
properties:
|
||
status: { $ref: '#/components/schemas/BookStatus' }
|
||
paused_reason:
|
||
oneOf:
|
||
- $ref: '#/components/schemas/PausedReason'
|
||
- type: 'null'
|
||
|
||
EventProgress:
|
||
type: object
|
||
required: [progress]
|
||
properties:
|
||
progress: { $ref: '#/components/schemas/Progress' }
|
||
|
||
EventChapter:
|
||
type: object
|
||
required: [chapter_id, units_done, note_count]
|
||
properties:
|
||
chapter_id: { $ref: '#/components/schemas/Id' }
|
||
units_done: { type: integer, minimum: 0 }
|
||
note_count: { type: integer, minimum: 0 }
|
||
|
||
EventNote:
|
||
type: object
|
||
description: |
|
||
A note appeared. Depends on the event emitter — the engine does not emit per-unit notes
|
||
mid-run today (companion §3).
|
||
required: [note]
|
||
properties:
|
||
note: { $ref: '#/components/schemas/Note' }
|
||
|
||
EventBank:
|
||
type: object
|
||
required: [total, signed, pending_decisions]
|
||
properties:
|
||
total: { type: integer, minimum: 0 }
|
||
signed: { type: integer, minimum: 0 }
|
||
pending_decisions: { type: integer, minimum: 0 }
|
||
|
||
EventCeiling:
|
||
type: object
|
||
description: |
|
||
The run hit a ceiling and halted. **Carries no figures** — the fact of the stop, not a
|
||
sum: money does not appear in the MVP interface at all. The resulting status is `paused`,
|
||
never `failed`: the stop is resumable. Depends on the event emitter (companion §3).
|
||
|
||
Which ceiling — the account's credit or the run's own `ceiling_chapters` — is not
|
||
distinguished by this frame; whether the two need separate `paused_reason` values is open,
|
||
companion §4 (K-13).
|
||
required: [halted]
|
||
properties:
|
||
halted: { type: boolean }
|
||
|
||
EventResyncRequired:
|
||
type: object
|
||
description: |
|
||
The server cannot resume the stream from the presented `Last-Event-ID`. The client MUST
|
||
re-read snapshots. Replaying history is forbidden — a one-shot event such as `note` would
|
||
be lost silently.
|
||
required: [reason]
|
||
properties:
|
||
reason:
|
||
type: string
|
||
description: Product-level reason; carries no internals.
|
||
|
||
Problem:
|
||
type: object
|
||
description: |
|
||
Error per RFC 9457.
|
||
|
||
⚠ **Neither `title` nor `detail` ever carries engine text.** The engine's own detail strings
|
||
read like "CJK leak in the ru output: 第一节", which exposes how the pipeline works.
|
||
|
||
The constraint applies to BOTH fields because both are shown: a client has nothing else to
|
||
put on the screen when a call fails, so a `title` written for a developer becomes the
|
||
sentence the reader gets. `title` is the CLASS of the failure, `detail` the specific
|
||
sentence; both are product language, and either may be empty.
|
||
required: [type, title, status]
|
||
properties:
|
||
type: { type: string, format: uri }
|
||
title:
|
||
type: string
|
||
description: Product phrase naming the class of failure. Shown to the user as-is.
|
||
status: { type: integer }
|
||
detail: { type: string }
|
||
instance: { type: string, format: uri-reference }
|