988 lines
36 KiB
YAML
988 lines
36 KiB
YAML
openapi: 3.1.0
|
|
|
|
info:
|
|
title: TextMachine API
|
|
version: 0.1.0-draft
|
|
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.
|
|
⚠ Text corrected by orchestrator #15 on 07.08: this file called itself an unratified draft of
|
|
session S3 for three days after it was ratified, and pointed at a companion path that does not
|
|
exist beside the canonical copy. The version string still reads `-draft` — session S3 bumps it
|
|
as the first item of its spec work.
|
|
|
|
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`.
|
|
|
|
## 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.
|
|
|
|
Every `enum` below is the vocabulary of version 0.1, 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: Platform. The base path is a frontend proposal; the platform confirms it.
|
|
|
|
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.
|
|
|
|
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), not of any run. Revision scope is open — companion §4 (K-4).
|
|
responses:
|
|
'200':
|
|
description: Library.
|
|
content:
|
|
application/json:
|
|
schema: { $ref: '#/components/schemas/Library' }
|
|
'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.
|
|
responses:
|
|
'200':
|
|
description: Chapters of the book.
|
|
content:
|
|
application/json:
|
|
schema: { $ref: '#/components/schemas/ChapterList' }
|
|
'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.
|
|
responses:
|
|
'200':
|
|
description: Pairs of the chapter.
|
|
content:
|
|
application/json:
|
|
schema: { $ref: '#/components/schemas/UnitList' }
|
|
'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.
|
|
responses:
|
|
'200':
|
|
description: Notes of the book.
|
|
content:
|
|
application/json:
|
|
schema: { $ref: '#/components/schemas/NoteList' }
|
|
'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.
|
|
responses:
|
|
'200':
|
|
description: Bank of the book.
|
|
content:
|
|
application/json:
|
|
schema: { $ref: '#/components/schemas/Bank' }
|
|
'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}/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".
|
|
requestBody:
|
|
required: true
|
|
content:
|
|
application/json:
|
|
schema: { $ref: '#/components/schemas/RunRequest' }
|
|
responses:
|
|
'202':
|
|
description: Run 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}/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 channel for raising it exists here — money
|
|
is absent from the MVP interface by decision. The mechanism belongs to the platform
|
|
(companion §3), and what the user sees meanwhile is companion §4 (K-8).
|
|
|
|
**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' }
|
|
|
|
/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.
|
|
requestBody:
|
|
required: true
|
|
content:
|
|
application/json:
|
|
schema: { $ref: '#/components/schemas/ExportRequest' }
|
|
responses:
|
|
'202':
|
|
description: Export is being prepared.
|
|
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. Whether completion is ALSO pushed as a stream event, making polling
|
|
unnecessary, is the platform's call — companion §4 (K-12).
|
|
responses:
|
|
'200':
|
|
description: State of the export.
|
|
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
|
|
protection is mandatory on the cookie path. The form belongs to the platform.
|
|
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' }
|
|
|
|
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' }
|
|
|
|
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 of a resource. A read whose revision is LOWER than an event the client
|
|
already applied MUST be dropped rather than rendered, otherwise the interface rolls
|
|
progress backwards on every refetch.
|
|
examples: [1841]
|
|
|
|
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' }
|
|
|
|
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 · `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 NOT `failed`.** It is a resumable book-wide stop; mapping it to
|
|
`failed` is forbidden, as that would lie about resumability. Which status and which word
|
|
the user sees is an open product question — companion §4 (K-8).
|
|
|
|
A prescreen refusal maps to none of these values either — companion §4 (K-9).
|
|
enum:
|
|
- uploading
|
|
- parsing
|
|
- not_started
|
|
- translating
|
|
- awaiting_bank
|
|
- finalizing
|
|
- ready
|
|
- 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, books]
|
|
properties:
|
|
revision: { $ref: '#/components/schemas/Revision' }
|
|
books:
|
|
type: array
|
|
items: { $ref: '#/components/schemas/Book' }
|
|
|
|
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, 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.
|
|
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, units_total, units_done, note_count]
|
|
properties:
|
|
id: { $ref: '#/components/schemas/Id' }
|
|
number:
|
|
type: integer
|
|
minimum: 1
|
|
description: |
|
|
Displayed ordinal. **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
|
|
description: |
|
|
Chapter title as a field is a frontend PROPOSAL: today the engine glues the rendered
|
|
title into the text of the first unit and leaves the source column without it.
|
|
Unresolved — 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, chapters]
|
|
properties:
|
|
revision: { $ref: '#/components/schemas/Revision' }
|
|
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, units]
|
|
properties:
|
|
revision: { $ref: '#/components/schemas/Revision' }
|
|
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, notes]
|
|
properties:
|
|
revision: { $ref: '#/components/schemas/Revision' }
|
|
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, 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.
|
|
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, total, signed, terms]
|
|
properties:
|
|
revision: { $ref: '#/components/schemas/Revision' }
|
|
total: { type: integer, minimum: 0 }
|
|
signed:
|
|
type: integer
|
|
minimum: 0
|
|
description: How many rows are in status `approved`.
|
|
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]
|
|
properties:
|
|
verify_bank:
|
|
type: boolean
|
|
description: Stop for bank signing before the final pass.
|
|
|
|
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 a monotonic revision. Whether it is the same counter that reads
|
|
carry is open — companion §4 (K-4).
|
|
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
|
|
required: [status]
|
|
properties:
|
|
status: { $ref: '#/components/schemas/BookStatus' }
|
|
|
|
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 stop is resumable, so it does
|
|
not yield `failed`. Depends on the event emitter (companion §3).
|
|
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. ⚠ `detail` NEVER carries engine text: the engine's own detail strings
|
|
read like "CJK leak in the ru output: 第一节", which exposes how the pipeline works. Here
|
|
`detail` is either empty or already a product phrase.
|
|
required: [type, title, status]
|
|
properties:
|
|
type: { type: string, format: uri }
|
|
title: { type: string }
|
|
status: { type: integer }
|
|
detail: { type: string }
|
|
instance: { type: string, format: uri-reference }
|