Ratify D39.99: API contract v0 lands in architecture home, row 95 closed with contract blockers reweighed, ping channels and prompt-review checkpoints written into canon

This commit is contained in:
heaven 2026-08-04 13:04:27 +03:00
parent 16e20e5884
commit 480333ded7
7 changed files with 1356 additions and 13 deletions

View file

@ -18,8 +18,8 @@ Go-бэкенд издательского художественного пер
| **Бэкенд** | `backend/` | нет — лендит оркестратор | читать можно; расхождения — пингом оркестратору в `docs/PROGRESS.md` |
| **Полигон (eval)** | `eval/` + `docs/experiments/` | только пре-рег фризы | то же |
| **Оркестратор** | `docs/` (architecture/research/PROGRESS) + корневые онбординг-доки, ревью чужого кода read-only | да | ратифицирует решения, пишет хендофф-промты |
| **Фронт** | `frontend/` | нет — лендит оркестратор | то же; продуктовые требования — `docs/product-requirements.md`, НЕ из памяти |
| **Платформа** | `platform/` (SaaS control plane: пользователи/квоты/очередь/HTTP; движок дёргает процессами, D39.81) | нет — лендит оркестратор | то же |
| **Фронт** | `frontend/` | нет — лендит оркестратор | читать можно; пинги, итоги и вопросы — ЗОННЫЙ журнал `frontend/docs/frontend-PROGRESS.md` (решение владельца 02.08/04.08; в `docs/PROGRESS.md` фронт НЕ пишет), туда же пишет и оркестратор; продуктовые требования — `docs/product-requirements.md`, НЕ из памяти |
| **Платформа** | `platform/` (SaaS control plane: пользователи/квоты/очередь/HTTP; движок дёргает процессами, D39.81) | нет — лендит оркестратор | читать можно; пинги и итоги — зонные доки `platform/`, в `docs/PROGRESS.md` не пишет |
Координация — журнал `docs/PROGRESS.md` (секции «Бэкенд»/«Полигон»/«Память»/«Голос и состояние»/«Ридер-IDE»; сверху CURRENT-STATE). Записывай туда краткие итоги своей сессии. Закрытые хроники вынесены в слайсы `docs/archive/PROGRESS-*.md`НЕ читать при онбординге, только по конкретной ссылке. Параллельные сессии — норма: чужие незакоммиченные файлы в дереве не трогать; сессия считается ЖИВОЙ, пока владелец не сказал обратное.

View file

@ -43,6 +43,8 @@
## Методология (продолжай как предшественники — она себя оплатила)
- **Двухступенчатая верификация:** промты сессий несут мандат самопроверки ИСПОЛНЕНИЕМ (код+запросы+результаты); твоя пост-хок адверсариальная верификация при лендинге — второй рубеж, author≠reviewer, воркфлоу-инструментом, по СЫРЬЮ с пере-выводом чисел. Эра №6: рубеж-2 поймал CRITICAL-артефакт (окно судьи HEAD_CHARS) и спас контракт от трёх ложных заголовков — не ослабляй.
- **Ревью хендофф-промтов — ПОСТРОЧНОЕ, не структурное (урок D39.99 п.4):** каждая инструкция промта с путём или каналом ВНЕ зоны сессии сверяется с зонной таблицей CLAUDE.md (куда пишет · кому пингует · кто коммитит); совпадение с общим каноном не оправдание — зонные решения владельца его перевешивают.
- **Свип зонных решений владельца (урок D39.99 п.4):** при каждом лендинге зоны вычитать из зонного журнала НОВЫЕ «решения владельца» и вынести на confirm списком; решение, живущее только в зонном доке при противоречащем каноне, — дефект синхронизации, чинится ратификацией или опровержением, не молчанием.
- **Анти-паттерны приёмки:** приёмка проверяет НАПРАВЛЕНИЕ и решения пака, не только клеймы отчёта. Шесть ловушек: след отчёта (проверяешь лишь названные автором места) · связность вместо истинности (форма отчёта убеждает сама) · соглашательство с чёткой позицией (распространенная ошибка ллм моделей склонных соглашаться с человеком) · рационализация задним числом · слепой участок общих моделей обученных на одинх и тех же данных (выводы одной модели усиливают уверенность в правильности другой) · экономия усилия на неудобном. Механика-минимум: ≥1 верификатор выносит мнение по промту+диффу ДО чтения отчёта · ≥1 ищет дефекты ВНЕ карты отчёта · экспериментальный клейм без ре-рана (хотя бы редуцированного) в D-ноте помечается «со слов сессии», не «исполнением» · «решение владельца», известное только со слов сессии, НЕ ратифицируется — список confirm/deny владельцу ДО лендинга, если решение несущее · к утверждениям самой приёмки та же дисциплина «заявление=команда» (догадку фактом не подавать) · оценочные эпитеты в ноты не писать.
- **Пре-рег дисциплина полигона:** фриз коммитом ДО платных вызовов (единственный коммит сессии); изменения после = новый experiment-ID; девиации — явно в отчёте; стоп-гейты по бюджету легитимны; «если не влезает — стоп и пинг, не резать молча».
- **Лендинг:** микро-дефекты доков чинишь сам с пометкой «испр. оркестратором»; отчёты получают ревью-шапку; код не правишь — находки в фикс-лист; коммиты скоуп-раздельные, стейджинг пофайловый; `git status` перед каждым коммитом (в дереве бывают ≥2 живые сессии) + **`git diff --cached` перед `commit`** — коммит уносит ВЕСЬ индекс: чужой staged `git mv` уедет в твой коммит (инцидент 77dd9b8); `add` общего файла сметает чужую секцию — diff-контент/`add -p`. **Лендинг, двигающий файлы или состав активных промтов, обновляет `docs/README.md` тем же коммитом** (норма D39.80; урок 02.08: README объявлял заленденный пак «можно запускать» с мёртвой ссылкой — поймал не процесс, а новая сессия). Статус/голову README не несёт вовсе — единственный носитель состояния = PROGRESS CURRENT-STATE. **Перед коммитом ратификации — механический чек головы:** `grep "голова D" docs/PROGRESS.md` обязан показать номер ТОЛЬКО ЧТО ратифицированной ноты (голова отставала дважды: D39.81 и D39.83 — оба раза «Текущее» обновлено, число головы забыто).

File diff suppressed because one or more lines are too long

View file

@ -10,7 +10,7 @@
- [product-requirements.md](product-requirements.md) — реестр «что продукт обязан уметь»: START_PROMT V0V5 + находки H6H18, статусы сверены кодом.
- `architecture/` — синтез и контракты:
- [05-decisions-log.md](architecture/05-decisions-log.md) — **источник истины по решениям**. Читать: карта актуальности (шапка) + живая голова (с хвоста); корпус D1D38 — справочник по ссылкам/grep, целиком не читать.
- [09-target-architecture.md](architecture/09-target-architecture.md) — целевая 7-слойная архитектура (статус стройки — шапка-таблица; §3 — карта находок H/L) · [10-prompt-architecture.md](architecture/10-prompt-architecture.md) — промпт-слой · [12-go-style-notes.md](architecture/12-go-style-notes.md) — норматив общности §0 · [13-tech-debt-anchors.md](architecture/13-tech-debt-anchors.md) — якоря техдолга (справочник к бэклогу, НЕ трекер).
- [09-target-architecture.md](architecture/09-target-architecture.md) — целевая 7-слойная архитектура (статус стройки — шапка-таблица; §3 — карта находок H/L) · [10-prompt-architecture.md](architecture/10-prompt-architecture.md) — промпт-слой · [12-go-style-notes.md](architecture/12-go-style-notes.md) — норматив общности §0 · [13-tech-debt-anchors.md](architecture/13-tech-debt-anchors.md) — якоря техдолга (справочник к бэклогу, НЕ трекер) · [14-api-contract/](architecture/14-api-contract/) — **контракт API v0 фронт↔платформа (D39.99)**: нормативная спека OpenAPI 3.1 (типы фронта генерятся из неё) + компаньон-README (провенанс ✓/◆/○, К-вопросы; зонная копия `frontend/docs/api-contract/` — байт-зеркало, сверять при лендинге).
- Исторические, читать через ⚠-баннеры: [01-decisions.md](architecture/01-decisions.md) (Р1Р10) · [02-mvp-plan.md](architecture/02-mvp-plan.md) · [03-implementation-notes.md](architecture/03-implementation-notes.md) · [04-unhappy-paths.md](architecture/04-unhappy-paths.md) · [06-memory-risk-registry.md](architecture/06-memory-risk-registry.md).
- `experiments/` — эмпирика полигона: [00-provider-quirks.md](experiments/00-provider-quirks.md) — **читать перед любым вызовом провайдера**; [08-cost-model-v2.md](experiments/08-cost-model-v2.md) — денежная модель; [09-pilot-protocol.md](experiments/09-pilot-protocol.md) — пилот Ф2.5; остальные 0116 — отчёты закрытых экспериментов (судьба — в баннерах/D-логе).
- `research/` — фактура ресёрчей 0122; у принятых — ревью-шапки, часть тел под ⚠ superseded: **читай баннер прежде содержимого**. Ключевые для навигации: 15 голос · 16 ридер-IDE · 17 внешняя критика · 18 рычаги качества · 19 нарезка · 20 банк-майнинг · 21 обзор транспорта · 22 доменные харнессы · 23 шов движок↔платформа (читать перед любым кодом стыка).

View file

@ -1,4 +1,4 @@
# Журнал решений оркестратора — контракт D1D39.98 (развязки 04.07 · пакеты 0910.07 · приёмка/качество-первым/пивот/эмпирика 1112.07 · арх-ресет+стройка пере-прогонного стека 1319.07)
# Журнал решений оркестратора — контракт D1D39.99 (развязки 04.07 · пакеты 0910.07 · приёмка/качество-первым/пивот/эмпирика 1112.07 · арх-ресет+стройка пере-прогонного стека 1319.07)
> **КАРТА АКТУАЛЬНОСТИ (ревизия D31, продлена до D38.2 [12.07]; исторические записи ниже НЕ переписываются — дисциплина D23.3).** Читая контракт целиком, держи под рукой, что чем перекрыто:
> ⚠ **Навигация (актуализация 01.08):** карта ниже детально покрывает D1D39.28; решения D39.29+ живут хронологически в теле файла, **свежая голова — С ХВОСТА** (новые ноты аппендятся вниз). Сводка текущей головы и очередь — CURRENT-STATE в `../PROGRESS.md`.
@ -1432,3 +1432,15 @@ API-529-долг закрыт: 8-осевой refute-by-default воркфлоу
**3. Git-канон v2 заленден по носителям (решение владельца 04.08, правка `CLAUDE.md` его рукой): коммитит ТОЛЬКО оркестратор; право самокоммита фронта/платформы (D39.88) ОТОЗВАНО — supersede-запись = этот блок.** Носители: `CLAUDE.md` (таблица зон + гардрейлы) · промты фронта (переписаны фронт-сессией в своей зоне) · `platform/README.md` · подчистки D-лога/PROGRESS. ⚠ **Процедурная нота (D23.3):** синхронизация переписала тела D39.84 п.8 и D39.88 НА МЕСТЕ — снято в т.ч. дословное решение владельца 02.08; это против правила «исторические записи не переписываются». История цела в git (`2b166b7`, pre-rewrite HEAD); авторство подчистки — на подтверждении владельца; впредь supersede — только пометкой в карте + новым блоком.
**4. Открыто на владельце (+2 к списку D39.97):** confirm/deny двух «решений владельца 04.08» из журнала фронта («+» в левой панели закрывает половину Ф-7 · референсы возвращены на диск) · авторство подчистки D-лога (п.3). **В дереве появились нетрекнутые артефакты банк-арбитража** (`docs/research/24-bank-arbitration.md` · `eval/bank_arbitration/`) — заход строки 5, судя по всему, пере-исполнен; НЕ залендены, ждут отдельной приёмки. (04.08.2026, оркестратор №12). ✅
## D39.99 — КОНТРАКТ API v0 РАТИФИЦИРОВАН: строка 95 закрыта; форма = OpenAPI 3.1; три пасса приёмки, 19/19 находок вправлены; пост-мортем промт-ревью в канон (04.08). ✅
**1. Артефакт и форма.** S3 упёрлась в замок (контракта не существовало, строка 95 без промта) — по слову владельца написала вход Ф-14 (`frontend/docs/API_CONTRACT_INPUT.md`) и черновик контракта. **Решение владельца о форме: нормативная поверхность = OpenAPI 3.1** (`openapi.yaml`: JSON Schema моделей, spectral-линт шестым шагом `npm run check`, типы фронта ГЕНЕРЯТСЯ `openapi-typescript` с дрифт-тестом по голден-паттерну, MSW типизируется от них) — класс «фикстура выдумала форму», случившийся ЧЕТЫРЕЖДЫ (org/fullname · титульная политика · «Раздел N» · девять статусов), закрыт механически; прозаический md остался компаньоном (провенанс ✓/◆/○ · обоснования · К-вопросы). AsyncAPI отложен с записью (Ф-24). **Дом ратифицированной копии — `docs/architecture/14-api-contract/`** (спека байт-копией + компаньон-README с ревью-шапкой; байт-сверка копий — обязанность каждого лендинга; генерация типов далее из ратифицированной копии — контракт первичен).
**2. Приёмка — три адверсариальных воркфлоу-пасса, 0 опровергнутых из 19 подтверждённых** (черновик: 4 линзы×скептики, 2 high — «withheld=текст не выдан» ложно о движке (sanitizer_stripped и c-lite drop отгружают текст под флагом, `disposition.go:96-104`/`export.go:203-215`) и чтения без канала шва (текст юнитов посреди прогона · полный банк — строка 101 это таблица подписи, не экспорт) + 8 medium — сквозной паттерн «инфляция провенанса ✓ на ◆-предложениях»; round-1 дофикс: 6/6 применён, перепроверено кодом, генерённая цепочка подтверждена пере-генерацией байт-в-байт; round-2 свежий взгляд на новые схемы: 5 medium — экспорт ненаблюдаем · promote без dst (движок hard-reject, `memseed.go:84-86`) · закрытый required kind против легального пустого (`memseed.go:323-326`) · самопротиворечие §2.7 · resume после потолка без канала поднятия; все вправлены, включая if/then-констрейнт с честным замером «openapi-typescript игнорирует 3.1-условия» → К-11). Сессия сама завела К-10 (несплитованный счётчик главы) и К-12 (poll vs push экспорта). State выведен из ПАРЫ (вердикт, наличие текста); фразы приложения А — плейсхолдеры (В-3 за владельцем); ПТ-34-инвариант вписан (noindex · no-store · выгрузка только владельцу).
**3. Очередь.** Строка 95 ЗАКРЫТА. **99/100/101/103 перевешены «к подключению фронта» → «скоро, БЛОКЕРЫ контракта»** (аргументы сессии подтверждены кодом: без 100 у главы нет стабильного id — нумерация плотная, `chunker.go:99-105`; без 99 индикатор 0% всю черновую волну — `status.go:328-331`; без 101 подписной экран читает сайдкар с капом 20 — `cmd/tmctl/render.go:98`). НОВЫЕ строки: **125** банк-экспорт артефакт (канал `/bank`) · **126** механизм поднятия потолка (платформа; продуктовое слово — К-8) · **127** DDL-коммент `migrate.go:193` устарел (`auto` не пишет никто — фантом утёк во фронт-доки дважды). Счёт скриптом: 103 строки · скоро 21 · бэкенд 58/66.
**4. Пост-мортем процесса (вопрос владельца «где мы ошиблись на берегу») — три слоя, фиксы в канон.** (а) Ошибка последовательности оркестратора: ратифицирован S3-промт с мёртвым основным сценарием (замок закрыт, промта строки 95 не существовало) — фронт три сессии упирался в один вход. (б) Ревью промта было структурным, не построчным: инструкция «пинг в docs/PROGRESS.md» дословно следовала таблице ролей CLAUDE.md, но противоречила решению владельца 02.08 из шапки зонного журнала. (в) Системная дыра: решения владельца, принятые в зонных сессиях, не доезжают до канона. **Фиксы:** CLAUDE.md — пинг-канал фронта/платформы = ЗОННЫЙ журнал (`frontend/docs/frontend-PROGRESS.md` — переименован по слову владельца, писать туда всем включая оркестратора), не docs/PROGRESS.md; промт оркестратора — два чек-пункта (хендофф-промты сверяются ПОСТРОЧНО: каждая инструкция с путём/каналом вне зоны — против зонной таблицы; при каждом лендинге зоны — свип новых «решений владельца» из зонного журнала на confirm списком); шапка `frontend/docs/BACKLOG.md` разведена «предлагать обязан / ратифицирует оркестратор» (просьба №3 сессии — прежняя формулировка спровоцировала остановку первого захода).
**5. Открыто на владельце:** продуктовые К контракта — К-1 (десять статусов) · К-3 (метка главы в дереве) · К-5 (ETA в MVP; ПТ-19 существует) · К-6 (ось ступеней замечаний — операторская унаследована) · К-8 (слово и статус стопа по потолку) + заполнение приложения А (В-3) + прежний список D39.98 п.4 (confirm двух решений · авторство подчистки · приёмка банк-арбитража). **Следующий разговор на берегу — бутстрап платформы П-1: у второй стороны контракта ноль строк кода, К-4/К-7/К-12 адресованы зоне, которой нет** (урок строки 95 — не ждать, пока три сессии упрутся). (04.08.2026, оркестратор №12). ✅

View file

@ -0,0 +1,339 @@
<!-- ======================================================================
РЕВЬЮ-ШАПКА ОРКЕСТРАТОРА (ратификация D39.99, 04.08.2026, оркестратор №12)
СТАТУС: РАТИФИЦИРОВАН как контракт API v0. Нормативная поверхность —
openapi.yaml РЯДОМ (байт-копия зонной frontend/docs/api-contract/openapi.yaml;
байт-сверка копий — обязанность каждого лендинга, расхождение = дефект лендинга).
Генерация типов фронта после этой ратификации идёт из ЭТОЙ копии.
Приёмка: три адверсариальных воркфлоу-пасса (черновик · дофикс round-1 · round-2),
19 подтверждённых находок, 0 опровергнутых, все вправлены; батарея фронта с
контракт-шагом (spectral + дрифт-тест генерённых типов) зелёная исполнением.
Провенанс-классы ✓/◆/○ ниже выборочно перепроверены кодом движка — легенда честная.
ОТКРЫТЫ (см. §4): продуктовые К-1/К-3/К-5/К-6/К-8 — владелец; К-4/К-7/К-12 —
платформа (П-1, кода ещё нет); К-2/К-9/К-10/К-11 — автор контракта/бэкенд.
Зависимости §3 покрыты строками единого бэклога: 99/100/101/103 (перевешены в
БЛОКЕРЫ контракта) + новые 125 (банк-экспорт) · 126 (поднятие потолка) · 127
(DDL-коммент). Приложение А — заготовка: фразы и ступени заполняет владелец (В-3).
Тело ниже — авторский текст фронт-сессии S3 (два раунда дофикса), не переписано.
====================================================================== -->
# Контракт API v0 — спутник спеки: провенанс, обоснования, вопросы
> **Нормативная поверхность контракта — [`api-contract/openapi.yaml`](api-contract/openapi.yaml)**
> (OpenAPI 3.1). Этот файл её НЕ дублирует: он несёт то, чего YAML не выражает — откуда взято
> каждое решение, чем оно обосновано, что осталось открытым. При расхождении по ФОРМЕ
> побеждает YAML; при вопросе «почему так» — этот файл.
>
> **Статус: черновик фронт-сессии S3 на ратификацию.** Дом ратифицированной копии —
> `docs/architecture/14-api-contract/`, зона оркестратора; перенос делает он, генерация типов
> после ратификации идёт из перенесённой копии — контракт первичен, код вторичен.
>
> **Язык.** Спека английская: из неё генерятся типы, а исходники фронта по конвенции
> английские (слово владельца 04.08). Ссылки на К-вопросы внутри YAML набраны латинской
> `K-N` — это те же вопросы §4. Спутник и остальные доки зоны — русские.
>
> **Зона строки 95 — «оркестратор/бэкенд/фронт».** Фронт авторитетен в одной трети: форма
> read-модели и продуктовые словари. Транспорт платформы (пути, аутентификация, коды) и
> работы движка (99103) здесь ПРЕДЛОЖЕНЫ и без подтверждения своих зон не действуют.
## 0. Пометки провенанса
| Пометка | Что значит |
|---|---|
| **✓ выведено** | следует из кода движка или ратифицированного решения; грунт `file:line` рядом |
| **◆ предложено** | решение фронта, разумное по его сведениям; подтверждает названная зона |
| **○ открыто** | развилка, на которую у фронта ответа нет; перечень — §4 |
⚠ Пометка ставится **на утверждение, а не на раздел**: у одного пункта половина бывает
выведенной, а половина предложенной. Первая редакция черновика этим и грешила — восемь мест
несли ✓ там, где верно было ◆; ниже разведено.
---
## 1. Почему YAML, а не проза
Контракт — машинный артефакт: из него генерируются типы, по нему линтуется форма, им
типизируются моки. Прозаический контракт расходится с кодом ровно тем способом, ради
предотвращения которого заведена строка 95.
Инструменты, пины и отклонение по пиру TS не дублирую: они в `STACK_DECISIONS.md` §3 и в
бэклоге зоны — Ф-23 (`overrides` вместо `--legacy-peer-deps`), Ф-24 (AsyncAPI отложен с
причиной). Четыре формы нарушения гейта, каждая проверена живьём, — `FRONTEND_PLAN.md` §5.4.2.
---
## 2. Решения и их происхождение
### 2.1. Язык — код, никогда не имя — ✓ выведено
Движок держит коды (`backend/internal/config/book.go:26-27`), ключ пары — `zh-ru`
(`configs/langpacks/zh-ru/`). Имя языка в данных — пар-специфика в общем слое, запрещённая
§2 канона. Вторая цена, дороже: `lang` элемента берётся из данных книги, и пара ja→ru с именем
вместо кода отрисует кандзи китайскими начертаниями молча.
### 2.2. Идентификаторы непрозрачны — ✓ выведено
`glossary.id` — свежий автоинкремент на каждой пересборке банка и намеренно не хешируется
(`store/migrate.go:173-174`); номер главы плотный, «Chapters that yield no text … do NOT
consume a chapter number» (`chunk/chunker.go:99-105`), поэтому правка исходника сдвигает
номера последующих глав. Стабильность обеспечивает платформа при персисте манифеста (100).
### 2.3. Заголовок главы отдельным полем — ◆ предложено
**Движок сегодня делает ОБРАТНОЕ**, и это надо назвать прямо: титул рендерится
детерминистически из шаблона пары (`configs/langpacks/zh-ru/heading.txt`: `template Глава {n}`),
исходный маркер вырезается из текста для модели (`chunk/chunker.go:110-114`), а на экспорте
титул **вклеивается внутрь текста первого юнита** (`pipeline/export.go:215`), причём колонка
исходника остаётся без него.
Выведена здесь только МЕХАНИКА. Само поле `heading` — предложение фронта, и у него есть цена
на другой стороне: движку придётся отдавать титул отдельно. Альтернатива (оставить вклейку,
фронт отрезает строку) хуже: отрезание титула из текста — это парсинг прозы, и он сломается
на первой главе без заголовка. Развилка — К-2.
**Расхождение фикстуры, найденное разбором:** дерево витрины показывает «Раздел 2. …», колонка
оригинала — неснятый «第二节:». Для zh→ru движок не порождает ни одной из форм. Не чинится
до ответа на К-2.
### 2.4. Состояние — у прогона; у главы выполнение — ✓ выведено
Подпись банка это один стоп на всю книгу (`pipeline/mining.go:201`), поэтому «глава ждёт
подписи, пока соседняя финализируется» — невозможная картина. У главы движок держит
`ChapterPassport` (`pipeline/status.go:37-55`).
### 2.5. Прогресс пофазно и в юнитах — ✓ выведено
«A unit is DONE when every member draft AND the unit's edit resolved ok»
(`pipeline/status.go:328-331`), редактура не стартует до стопа банка ⇒ сквозной счётчик стоит
на нуле всю черновую волну. Зависимость — строка 99.
### 2.6. Словарь статусов — ✓ лестница, ◆ ненормальные исходы
Лестница дословно из строки 95: «загрузка → разбор → перевод → подпись банка → финал →
готово». **`not_started` — дыра, найденная самопроверкой черновика:** лестница описывает
идущий прогон, а библиотека обязана показывать разобранную книгу, которую не запускали.
`stopped`, `rejected`, `not_started` контракт ВЫВОДИТ из поведения процесса, а не получает
полем: механика стопа у движка есть (`cmd/tmctl/main.go:63`), но «кто нажал» знает платформа.
**Стоп по потолку — не `failed`** ✓ выведено: «Ceiling is a hard, book-wide stop (not a
per-chunk flag): the job stays 'pending' and resume continues once the ceiling is raised»
(`pipeline/stagerun.go:488-489`). Мапить его в `failed` запрещено — это соврало бы про
резюмируемость. Каким статусом и словом он показывается — К-8, вопрос владельцу.
### 2.7. Состояние пары выводится из ПАРЫ — ✓ выведено (исправление первой редакции)
Первая редакция утверждала «`withheld` = текст не выдан» как факт о движке. **Это было
ложно:** флагнутый юнит легально приходит С ТЕКСТОМ в двух случаях —
- косметическая зачистка санитайзера: «the chunk is NOT lost — the cleaned text is committed
as the export» (`pipeline/disposition.go:96-104`);
- c-lite member-drop: редактор отгружает отредактированный чистый остаток, а юнит флагнут
из-за выпавшего члена (`pipeline/export.go:203-207`).
Поэтому состояние выводится из ПАРЫ (вердикт, наличие финального текста): флаг+текст →
`translated` с замечанием; флаг+пусто → `withheld`.
**Причина флага при этом ПРОИЗВОЛЬНА, и «единственный легальный случай» — снято** (ревью
оркестратора, round-2, пункт 4; утверждение противоречило выводу строкой выше). При c-lite drop
юнит несёт `FlagReason` ПЕРВОГО выпавшего члена, каким бы он ни был (`export.go:203-207`:
`ce.FlagReason = drops[0].Reason`, и тут же `ce.FinalText … still ships`). Значит «текст +
замечание» — это класс, а не один случай, и карта вердиктов обязана иметь фразу для каждой
причины, а не для двух.
**Но `glossary_miss` в этот класс НЕ входит, и это проверено отдельно** (иначе правка выше
воскресила бы невоспроизводимый пример). `memberDrops` берёт причину из ЧЕРНОВОЙ строки члена
(`status.go:242-257`: `draftStages[cs.Stage] && flagged`), а `glossary_miss` ставится
пост-чеком только там, где отгружается финал: в черновой волне — лишь когда она сама финальная
(`waverun.go:373-381`, draft-only), в c-lite — на строке РЕДАКТУРЫ (`waverun.go:494`). В
пайплайне, где текст отгружает редактура (то есть где c-lite drop вообще возможен), черновая
строка `glossary_miss` нести не может. При включённом гейте текст удерживается целиком
(`export.go:295-300`), при выключенном мисса нет вовсе. **Итог: пара «текст + промах словаря»
невозможна ни одним каналом — фикстура витрины, показывавшая её, переведена на c-lite drop.**
**Свежесть** ✓ выведено: `target` обновляется на границах стадий и на стопах, а не
непрерывно — посреди прогона канала чтения не существует (эксклюзивный лок движка;
санкционированное чтение — завершённый либо остановленный прогон, `research/23` §0, §4).
### 2.8. Банк: словари ✓, имена ◆, `kind`с дырой
Значения выведены из схемы и гейтов Go; **имена полей контракта — предложение фронта.**
| Поле | Словарь | Грунт |
|---|---|---|
| `status` | `auto · draft · approved` | `store/migrate.go:191`; только `approved` — канон |
| `kind` ◆ | `name · place · title · term · nickname` **плюс отсутствие значения** | `terminology/classify.go:15` + `pipeline/banknote.go:74`; пустое — `membank/memseed.go:323-326` |
| `origin` | `seed · ruby · mined` | пути записи, см. ниже |
| `sense` | свободный текст | `store/migrate.go:182` |
| `since_chapter`/`until_chapter` | целые, `0` = без границы | `store/migrate.go:189-190` |
**Фантом `auto` в провенансе убран.** Первая редакция взяла словарь из комментария схемы
(`migrate.go:193`: `seed|ruby|auto`) — комментарий устарел. По путям записи `"auto"` пишет
**статус**, не провенанс (`membank/memseed.go:328`: `Status:"auto", Source:"ruby"`), а майнинг
ставит `mined` (`pipeline/mining.go:424`). Правка комментария в движке — за оркестратором.
**`kind` пере-размечен ✓→◆, и вот почему это не косметика** (ревью round-2, пункт 3). Словарь
из пяти значений выведен верно, но ЗАКРЫТЫМ и обязательным он делает нелегальной легальную
строку: ruby-кандидат получает `Type: ""`, если его класс не `name` — то есть gloss и
ambiguous живут без типа по построению (`membank/memseed.go:323-326`: `typ := ""`, и только
`class == rubyClassName` даёт `"name"`). Материализатору read-модели такую строку было
физически нечем заполнить. **Правило пустого:** `kind` присутствует всегда и допускает `null`;
`null` значит «движок не решил», строка при этом остаётся подписываемой, и клиенту запрещено
и выбрасывать её, и додумывать тип за движок. Проекция `""``null` — работа платформы.
**Ложный друг устранён.** У движка колонка `source` — это ПРОВЕНАНС. Первая редакция назвала
провенанс `origin`, а имя `source` отдала ДРУГОЙ колонке (тексту термина) — то есть завела
между схемами ложного друга. Теперь: провенанс `origin`, формы термина `src`/`dst`, как их
зовёт сам движок; имя `source` в схеме банка не используется вовсе.
### 2.9. Подпись — набор решений — ✓ выведено
Конвейер заменяет банк целиком (`store/migrate.go:169-170`), поэтому `PATCH /term/{id}` молча
не работает. Механика дословно: «for EACH term either promote it into the mined-delta file OR
decline it in the mined-rejects file, then resume — the stop clears once every proposed term
is promoted or rejected» (`pipeline/mining.go:201`). Отсюда: решение `promote|decline` ·
счётчик «решено N из M» · запрет «продолжить» при неполном наборе · частичное сохранение ◆.
`POST /runs/{id}/resume`**нормативная операция, а не резерв** (первая редакция помечала её
«○ резерв строки 94», хотя тут же делала её носителем снятия стопа банка). Резерв строки 94 —
это `stop`, продуктовая кнопка.
### 2.10. Ревизия — ✓ у ре-синка, ◆ у чтений
**✓ выведено:** правило ре-синка — идемпотентный апсерт по `(run_id, seq)`, канал согласования
`status --json` (`research/23` §2, §8; D39.85).
**◆ предложено фронтом:** что ревизию несут и ЧТЕНИЯ, и что счётчик у потока и у чтений ОДИН.
Обоснование — гонка, которую иначе нечем разрешить: фронт живёт на снимке и потоке разом,
а рефетч по возврату фокуса окна у ратифицированного `@tanstack/react-query` включён по
умолчанию, то есть гонка на каждое переключение вкладки. Но это просьба, не вывод; выбор —
К-4.
**Скоуп ревизии** (дыра первой редакции: она отдавала `revision` на межкнижной библиотеке при
пер-прогонном определении): в спеке ревизия объявлена НА РЕСУРСу библиотеки своя, у
прогона своя. Единая сквозная или пер-ресурсная — часть К-4.
### 2.11. Разрыв потока — ◆ предложено
При переподключении клиент шлёт `Last-Event-ID`. Если сервер докачать не может, он обязан
ответить событием `resync_required`, а не молча начать с текущего момента: **реплей истории
запрещён**, иначе разовое событие вроде `note` теряется молча и замечание не появится
до перезагрузки. Клиент по этому событию перечитывает снимки.
### 2.12. Разрешающий список — ✓ инвариант, ◆ форма
Проекция «read-модель → фронт» строится как allowlist. Что лежит в операторских структурах
(`pipeline/status.go:58-130`, `:37-55`) и не может доехать: пять денежных полей плюс
`cost_usd` главы (§4.8 — денег в MVP-интерфейсе нет вовсе) · `routing` вида «stage=model»,
`content_labels`, `content_routing_problems` (ПТ-33) · снапшот, дрифт, ре-билл ·
`escalations`, `postcheck_misses`, `style_flags`, `glossary_miss_flagged`, `stages_skipped`,
`repair_applied`, `worst_flag_reason` · `flag_reason` и `detail` — последний несёт сырой текст
движка вида «CJK leak in the ru output: 第一节»; строку собирает `checks/sanitizer.go:658`,
а `pipeline/export.go:34` — лишь объявление поля, куда она доезжает.
### 2.13. ПТ-34 — перевод не индексируется — ✓ инвариант
Реестр требований назначает носителем ПТ-34 в том числе контракт, а первая редакция пункта
не имела вовсе. В спеке: приложение живёт под `X-Robots-Tag: noindex`, ответы с текстом
перевода несут `Cache-Control: no-store`, ссылка на выгрузку выдаётся только владельцу.
---
## 3. Зависимости: без чего контракт не заработает
| Что | Строка | Без чего именно |
|---|---|---|
| Пофазный прогресс `draft ∥ edit` | 99 | прогресс (§2.5) |
| Персист манифеста + `chunker_version` | 100 | стабильный `id` главы (§2.2) |
| Машиночитаемая таблица ПОДПИСИ | 101 | экран подписи (§2.9) |
| Событийный эмиттер + событие потолка | 103 | весь поток (§2.11), событие `note`, событие `ceiling` |
| **Артефакт экспорта БАНКА** | **строки нет — заводит оркестратор** | чтение `GET /books/{id}/bank` |
| **Механизм поднятия потолка** | **строки нет — заводит оркестратор** | `POST /runs/{id}/resume` после стопа по потолку (см. ниже) |
| HTTP/SSE, аутентификация, воркер | П-1 | всё; в `platform/` ноль строк кода |
**Отдельно про банк — дыра, найденная ревью оркестратора.** Строка 101 даёт таблицу
ПОДПИСИ (стоп-таблица, кап 20 на stdout — `cmd/tmctl/render.go:98`), а не экспорт всего банка;
сам банк живёт в приватном SQLite движка, читать который платформе запрещено (D39.85). То есть
у чтения `/bank` сегодня **нет канала вообще**. Фронт этого не решает — нужна строка единого
бэклога, и заводит её оркестратор.
Та же природа у события `note`: пер-юнитных замечаний посреди прогона движок не эмитит —
зависимость на словарь строки 103.
**Потолок: `resume` сам по себе не сдвинет прогон** (ревью round-2, пункт 5). Движок
продолжает «once the ceiling is raised» (`pipeline/stagerun.go:488-489`), а канала поднятия
в контракте нет — и в MVP-интерфейсе быть не может: денег на экране нет вовсе (D39.84). Значит
между стопом по потолку и продолжением обязан стоять механизм ПЛАТФОРМЫ (поднятие по политике,
или явное действие вне интерфейса книги), и до него `resume` после потолка возвращает прогон
в то же состояние. Что при этом видит пользователь — К-8, вопрос владельцу; чем поднимают —
строка единого бэклога, которой нет.
---
## 4. Открытые вопросы
| # | Вопрос | Кому |
|---|---|---|
| К-1 | Десять статусов (§2.6) — принять или поправить? Три контракт выводит, а не получает | владелец / автор контракта |
| К-2 | Титул главы: отдать полем `heading` (предложено) — или оставить вклейку в текст, и фронт отрезает строкой? | автор контракта + бэкенд |
| К-3 | Метка главы в дереве: титул это ровно «Глава N», узлов 2284 — дерево одинаковых по форме строк | владелец (продуктовое) |
| К-4 | Ревизия: одна сквозная на прогон или своя на ресурс? И несут ли её чтения вообще (§2.10) | платформа |
| К-5 | Показывать ли оценку времени. **ПТ-19 существует** (`docs/product-requirements.md:49`: «видимый прогресс/ETA — из Ф3-видения ридер-IDE»), то есть посылка «не запрошено» неверна; вопрос в том, показываем ли в MVP | владелец (продуктовое) |
| К-6 | Ступени замечания: сколько их и где граница. Сегодняшние две — проекция ОПЕРАТОРСКОЙ лестницы рангов, а она не обязана совпадать с продуктовой осью | владелец (продуктовое) |
| К-7 | Пагинация: 2284 главы и 1200 терминов одним ответом или курсором? Фронт виртуализует, ему годится любой | платформа |
| К-8 | Стоп по потолку: каким статусом и каким словом? В `failed` мапить нельзя — стоп резюмируемый | владелец (продуктовое) |
| К-9 | **Отказ прескрина не выразим ни одним из десяти статусов.** Абьюз/misuse-прескрин до трат токенов и UI-контракт отказа — строка 94 (ПТ-16); книга, отклонённая прескрином, это не `rejected` (тот про неразобранный файл) и не `failed` | владелец + автор контракта |
| К-10 | **Выполнение главы — тот самый несплитованный счётчик, который §2.5 объявляет негодным.** `Chapter.units_done` не разведён по фазам, значит дерево глав показывает ноль всю черновую волну — ровно то, из-за чего прогресс книги сделан пофазным. Развести и тут (цена — пофазные счётчики НА ГЛАВУ в строке 99) или показывать в дереве другое | автор контракта + бэкенд |
| К-11 | **Условная обязательность полей — выражена у банка, не выражена у чтений.** У `BankDecision` констрейнт поставлен (`if action=promote → dst` непустой), и вот что это стоило, измерено: spectral его валидирует, а **openapi-typescript его игнорирует** — в генерённых типах `dst?: string` как был. То есть 3.1-условие защищает сервер, но не экран; клиентское сужение (юнион `promote`-с-`dst``decline`) — работа подписного экрана S5, писать его до экрана не на чем проверить. Остаётся решить то же для чтений: `Note` не требует ни `chapter_id`, ни `unit_id`, `Unit.target` не обязателен при `translated`; обе схемы служат и вложенно, и отдельно, поэтому простое `required` соврало бы | автор контракта |
| К-12 | **Завершение выгрузки: опрос или событие?** Чтение `GET /books/{id}/exports/{id}` заведено — без него создающий вызов был тупиком (`ready:false` и ни слова дальше). Но пушить ли завершение ещё и кадром потока, чтобы не опрашивать, решает платформа: у неё воркер и её цена | платформа |
---
## 5. Проверка ревью-вопросом строки 95
**«Сменится стадия конвейера — придётся ли править фронт?»**
| Изменение в движке | Правит ли фронт |
|---|---|
| переименована стадия / добавлена волна | **нет** — имена стадий не пересекают шов, прогресс пофазный, а не постадийный |
| сменилась модель или маршрутизация | **нет**`routing`/`content_labels` в allowlist не входят |
| добавлена новая причина флага | **нет** — на провод идёт продуктовая фраза, карта живёт в контракте |
| добавлен новый тип термина | **нет** — словарь расширяется минором, ветка неизвестного стоит на шве |
| добавлено новое продуктовое состояние | **да, один файл** — карта «статус → вид» на шве `src/api/`; это и есть контрольный вопрос владельца |
| сменился чанкер, главы пере-разобраны | **частично** — код фронта не правится (ключ непрозрачный, номер отображаемый), но **сохранность соответствия старых `id` новым главам контрактом не гарантируется**: это работа персиста манифеста (строка 100). Если соответствие потеряно, у пользователя разъезжаются открытые вкладки и закладки — не правка кода, но видимый ущерб, и решать его строке 100 |
Единственное безусловное «да» — то, которое и должно быть «да».
---
## Приложение А. Карта «вердикт → продуктовая фраза» — ЗАГОТОВКА
Заполняет автор контракта вместе с бэкендом и владельцем. **Правило: фраза пишется по
доккомменту `disposition.go`, а не по имени константы, и рядом кладётся цитата** — иначе
повторяется инверсия, стоившая двух фраз (`glossary_miss` подан как «термин не подписан»,
хотя термин ПОДПИСАН и его проигнорировали, `disposition.go:78-79`; `sanitizer_stripped` подан
как потеря текста, хотя «the chunk is NOT lost», `disposition.go:99`).
**Русские фразы ниже — плейсхолдеры, а не предложение фронта.** Словарь продуктовый, его
слова выбирает владелец (ПТ-33, В-3). Ступень — тоже: сегодняшние `attention`/`glance`
унаследовали ОПЕРАТОРСКУЮ ось рангов, а она не обязана совпадать с продуктовой (К-6).
| Причина движка | Ранг | Продуктовая фраза | Ступень |
|---|---|---|---|
| `hard_refusal` · `soft_refusal` · `content_filter` · `hard_block` | 0 | ⬜ | ⬜ |
| `cjk_artifact` · `excision_suspect` · `coverage_fail` | 1 | ⬜ | ⬜ |
| `sanitizer_defect` | 2 | ⬜ | ⬜ |
| `loop_degenerate` | 3 | ⬜ | ⬜ |
| `decode_error` | 4 | ⬜ | ⬜ |
| `glossary_miss` | 5 | плейсхолдер: «Подписанный термин не применён в переводе» | ⬜ |
| `length` · `empty` | 6 | ⬜ | ⬜ |
| `sanitizer_stripped` | 7 | плейсхолдер: «Служебная разметка вычищена автоматически» | ⬜ |
| `upstream_not_ok` | 8 (по умолчанию) | ⬜ | ⬜ |
| незнакомая причина | 8 (по умолчанию) | ⬜ нейтральная, НЕ «ошибка» | ⬜ |
Причин пятнадцать; `upstream_not_ok` в первой редакции отсутствовал — у него нет своей ветки
в `flagReasonSeverity`, поэтому он падает в ранг по умолчанию (`pipeline/status.go:174`),
как и любая будущая причина. Последняя строка — не формальность: контракт обязан иметь фразу
для причины, которой ещё не существует.

View file

@ -0,0 +1,983 @@
openapi: 3.1.0
info:
title: TextMachine API
version: 0.1.0-draft
summary: Contract between the frontend and the TextMachine platform (draft, awaiting ratification).
description: |
**DRAFT of frontend session S3 — not a ratified contract.** The ratified copy belongs in
`docs/architecture/14-api-contract/` (orchestrator's zone).
Companion document: `../API_CONTRACT_DRAFT.md` — 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 }