# Контракт API v0 — спутник спеки: провенанс, обоснования, вопросы > **Нормативная поверхность контракта — [`openapi.yaml`](openapi.yaml)** (файл рядом, в этой же папке) > (OpenAPI 3.1). Этот файл её НЕ дублирует: он несёт то, чего YAML не выражает — откуда взято > каждое решение, чем оно обосновано, что осталось открытым. При расхождении по ФОРМЕ > побеждает YAML; при вопросе «почему так» — этот файл. > > **Статус: черновик фронт-сессии S3 на ратификацию.** Дом ратифицированной копии — > `docs/architecture/14-api-contract/`, зона оркестратора; перенос делает он, генерация типов > после ратификации идёт из перенесённой копии — контракт первичен, код вторичен. > > **Язык.** Спека английская: из неё генерятся типы, а исходники фронта по конвенции > английские (слово владельца 04.08). Ссылки на К-вопросы внутри YAML набраны латинской > `K-N` — это те же вопросы §4. Спутник и остальные доки зоны — русские. > > **Зона строки 95 — «оркестратор/бэкенд/фронт».** Фронт авторитетен в одной трети: форма > read-модели и продуктовые словари. Транспорт платформы (пути, аутентификация, коды) и > работы движка (99–103) здесь ПРЕДЛОЖЕНЫ и без подтверждения своих зон не действуют. ## 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`), как и любая будущая причина. Последняя строка — не формальность: контракт обязан иметь фразу для причины, которой ещё не существует.