1444 lines
60 KiB
YAML
1444 lines
60 KiB
YAML
openapi: 3.1.0
|
|
|
|
info:
|
|
title: TextMachine API
|
|
version: 0.2.3
|
|
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.
|
|
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.
|
|
|
|
**The `file` part MUST come LAST in the form** (0.2.3). The platform reads the form as a
|
|
STREAM, part by part, and stops at the file: the book's row — the record that makes an
|
|
upload visible while it arrives and findable when it dies halfway — cannot be written
|
|
before the languages that row requires.
|
|
|
|
A part sent after the file is therefore NOT READ AT ALL. What that costs depends on the
|
|
part: a required one (`source_lang`, `target_lang`) is answered `400`, because to a reader
|
|
that streams "the languages came late" and "the languages never came" are the same thing;
|
|
an optional one is silently lost, and the book is created without it. A client that puts
|
|
anything after the file is a client whose form is partly ignored.
|
|
|
|
**The `201` carries `parsing`, not `uploading`** (0.2.3). The answer is written after the
|
|
last byte has landed, so by the time a client can read it the file is in and the book has
|
|
moved on. `uploading` is a real state and is observable — by a SECOND read of the library
|
|
while the upload is still on the wire — but never as the answer to this call.
|
|
|
|
Parsing is a separate visible step after that, and it has no numeric progress: no counter of
|
|
it exists on this contract, and a percentage would have to be invented.
|
|
|
|
Refusals of the intake, all of them product states of the form rather than failures of the
|
|
service:
|
|
|
|
- `400` — the form could not be read: more than 16 parts, a text field longer than a
|
|
kilobyte, a REQUIRED field that arrived after the file (or never), or no file at all;
|
|
- `404` — this deployment does not accept books at all. An instance with no place to put a
|
|
file and no engine to cut it with serves the library and refuses this path, which is a
|
|
property of the DEPLOYMENT and not of the request;
|
|
- `408` — the body did not finish inside the route's deadline: a slow client on a large
|
|
book. Retrying is the remedy, which is what separates it from `413`;
|
|
- `413` — the body is over the intake cap. The threshold belongs to the deployment and is
|
|
not stated here: a number in the contract would be a second copy of it, and the two would
|
|
disagree the day it changes.
|
|
requestBody:
|
|
required: true
|
|
content:
|
|
multipart/form-data:
|
|
schema: { $ref: '#/components/schemas/BookIntake' }
|
|
responses:
|
|
'201':
|
|
description: Book accepted; it is being parsed.
|
|
content:
|
|
application/json:
|
|
schema: { $ref: '#/components/schemas/Book' }
|
|
'400': { $ref: '#/components/responses/BadRequest' }
|
|
'401': { $ref: '#/components/responses/Unauthorized' }
|
|
'404': { $ref: '#/components/responses/NotFound' }
|
|
'408': { $ref: '#/components/responses/RequestTimeout' }
|
|
'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.
|
|
|
|
**503 answers a deployment that cannot run at all** (0.2.3). Continuing a run is starting a
|
|
process, so it needs exactly what a start needs — the seam that tells the engine its ceiling
|
|
and the one that records how a run ended. 0.2.1 named that state for `startRun` only, and
|
|
the omission was in the SPEC and not in the wire: the two calls share one refusal.
|
|
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' }
|
|
'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. 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' }
|
|
RequestTimeout:
|
|
description: |
|
|
The body did not arrive whole inside the route's deadline (added in 0.2.3): a slow client on
|
|
a large book. RFC 9110 §15.5.9 describes this case exactly, and it names RETRY as the
|
|
remedy — which is what a `413` and a `500` in its place would both hide.
|
|
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 or
|
|
continuing 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
|
|
|
|
RejectReason:
|
|
type: string
|
|
description: |
|
|
Machine reason a book was rejected (0.2.3). The platform's own closed vocabulary; as with
|
|
`PausedReason` the API carries STATE and the phrase the user reads is drawn by the client,
|
|
so no wording appears here.
|
|
|
|
- `source_unreadable` — the file was read and is not a book this service can cut: it yields
|
|
no sections at all, or the reader refused it. TERMINAL, and the source does not survive
|
|
it — there is no path in this contract that re-reads a rejected book, so the remedy is to
|
|
add the book again;
|
|
- `not_configured` — this deployment has nothing to read the book AGAINST. It is a state of
|
|
the service, never of the file, and retrying by itself does not clear it;
|
|
- `parser_unavailable` — the service could not process the file, repeatedly, until it gave
|
|
up. A state of the service as well, and a temporary one.
|
|
|
|
The three are two different NEXT ACTIONS for the user, and a client that told them apart by
|
|
colour alone would be telling them apart by nothing: the first means "the file is not one we
|
|
can read", the other two mean "not us, not now".
|
|
|
|
A client MUST tolerate an unknown value arriving under a minor bump, and MUST render a
|
|
rejected book that carries no reason at all: a deployment older than this minor answers
|
|
exactly that.
|
|
enum: [source_unreadable, not_configured, parser_unavailable]
|
|
|
|
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' }
|
|
reject_reason:
|
|
oneOf:
|
|
- $ref: '#/components/schemas/RejectReason'
|
|
- type: 'null'
|
|
description: |
|
|
Why the book was rejected; meaningful only while `status` is `rejected` (0.2.3).
|
|
|
|
OPTIONAL, unlike `Run.paused_reason`, and the asymmetry is deliberate: a rejection
|
|
reason is meaningful in exactly one of eleven states, and a deployment that predates
|
|
this minor sends nothing here at all. "Absent" and "null" therefore mean one and the
|
|
same thing — the reason is not known — and a client renders both the same way. That is
|
|
the opposite of the `sense` case (0.2.2), where the two shapes carried DIFFERENT facts.
|
|
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.
|
|
|
|
⚠ **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 is stated in prose because it cannot be stated in the schema.
|
|
required: [file, source_lang, target_lang]
|
|
properties:
|
|
title:
|
|
type: string
|
|
maxLength: 200
|
|
description: |
|
|
Title given by hand (0.2.3). OPTIONAL, and the two cases are told apart by the user
|
|
rather than guessed at: absent or empty means "the parse will name it" — today the
|
|
platform takes the name of the uploaded file — while a value present means the person
|
|
named the book themselves, and no later parse overwrites it.
|
|
|
|
Bounded like the title the platform derives: the library lists it, and an unbounded
|
|
string on that screen is the client's problem to draw, not the server's to store.
|
|
file:
|
|
type: string
|
|
format: binary
|
|
description: Book file. The LAST part of the form.
|
|
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 }
|