textmachine/docs/architecture/14-api-contract/README.md

932 lines
109 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

<!-- ======================================================================
РЕВЬЮ-ШАПКА (ратификация D39.99, 04.08.2026, оркестратор №12;
батч 0.3.0 — D39.138, 16.08.2026, оркестратор №17)
СТАТУС: РАТИФИЦИРОВАН как контракт API v0. Нормативная поверхность —
openapi.yaml РЯДОМ. ⚠ Зонная копия frontend/docs/api-contract/ ВРЕМЕННО ОТСТАЁТ (0.2.3 при каноне
0.3.0) — ратифицировано D39.142 п.5 на время фриза фронта; синк байт-в-байт + перегенерация типов =
первое касание зоны при разморозке; после него правило прежнее: расхождение = дефект лендинга.
Генерация типов фронта после этой ратификации идёт из ЭТОЙ копии.
Приёмка 0.2.0: три адверсариальных воркфлоу-пасса (черновик · дофикс round-1 · round-2),
19 подтверждённых находок, 0 опровергнутых, все вправлены; батарея фронта с
контракт-шагом (spectral + дрифт-тест генерённых типов) зелёная исполнением.
16.08.2026 (№17): ЦЕЛОСТНОЕ РЕВЬЮ КОНТРАКТА ПРИНЯТО (docs/research/28-contract-review.md,
D39.138) — исполнен ЛОМАЮЩИЙ батч 0.3.0 (состав — research/28 §5; строка 183; отчёт
исполнения — docs/archive/reports/CONTRACT_BATCH_0.3.0_REPORT.md). Тело этого файла
ПЕРЕПИСАНО под 0.3.0 вместе со спекой: держать его на 0.2.3 значило бы учить зоны неправде
(ровно то, что ревью и поймало — §5 ниже). Авторские формулировки фронт-сессии S3 сохранены
там, где их предмет не двигался; всё, что двигал батч, помечено «0.3.0».
Правило лендинга батча: канон первым, зеркало фронта отдельным зонным коммитом,
cmp-сверка обязательна (D39.138 п.3).
ТРИ ОПРОВЕРГНУТЫХ УТВЕРЖДЕНИЯ ПРЕЖНЕЙ РЕДАКЦИИ СНЯТЫ ЭТОЙ (не «поправлены» — сняты,
потому что каждое учило зону неправде):
1. §5 «переименована стадия / добавлена волна → фронт НЕ правится» — было ЛОЖНО для 0.2.3
(опровергнуто исполнением, research/28 Б-0). Ответ переписан, см. §5.
2. §3 «у чтения /bank сегодня нет канала вообще» — неверно с D39.122 (движок пишет сайдкар
всего банка); файл противоречил собственной строке. Снято, см. §3.
3. §3 «resume после стопа по потолку возвращает прогон в то же состояние, и лечения нет» —
вторая половина неверна: лечение существует и работает (новый прогон с бОльшим потолком).
Снято, см. §3 и описание `resumeRun` в спеке.
====================================================================== -->
# Контракт API v0 — спутник спеки: провенанс, обоснования, вопросы
> **Нормативная поверхность контракта — [`openapi.yaml`](openapi.yaml)** (файл рядом, в этой же
> папке). Этот файл её НЕ дублирует: он несёт то, чего YAML не выражает — откуда взято каждое
> решение, чем оно обосновано, что осталось открытым, и ГЕНЕЗИС форм (историю ратификаций,
> сверку со стандартами, разобранные альтернативы). При расхождении по ФОРМЕ побеждает YAML;
> при вопросе «почему так» — этот файл.
>
> **Статус: РАТИФИЦИРОВАН.** 0.2.0 (D39.115, 08.08) · 0.2.1 (D39.123, 09.08) · 0.2.2 (D39.129,
> 10.08) · 0.2.3 (D39.135, 15.08) · **0.3.0 (D39.138, 16.08) — ломающий минор по целостному
> ревью research/28**. Дом канона — этот каталог; `frontend/docs/api-contract/openapi.yaml` —
> байт-зеркало.
>
> **0.x ломает миноры и будет ломать весь бета-период** (решение владельца 16.08, §8 п.17
> research/28): 0.3.0 — обычный ломающий минор, а НЕ последний перед 1.0. Окно 1.0 — после беты.
>
> **Язык.** Спека английская: из неё генерятся типы, а исходники фронта по конвенции английские
> (слово владельца 04.08). Ссылки на К-вопросы внутри YAML набраны латинской `K-N` — это те же
> вопросы §4. Спутник и остальные доки зоны — русские.
>
> **Зона строки 95 — «оркестратор/бэкенд/фронт».** Фронт авторитетен в одной трети: форма
> read-модели и продуктовые словари. Транспорт платформы (пути, аутентификация, коды) и работы
> движка здесь ПРЕДЛОЖЕНЫ и без подтверждения своих зон не действуют.
## 0. Пометки провенанса
| Пометка | Что значит |
|---|---|
| **✓ выведено** | следует из кода движка/платформы или ратифицированного решения; грунт `file:line` рядом |
| **◆ предложено** | решение автора контракта, разумное по его сведениям; подтверждает названная зона |
| **○ открыто** | развилка, на которую ответа нет; перечень — §4 |
⚠ Пометка ставится **на утверждение, а не на раздел**: у одного пункта половина бывает
выведенной, а половина предложенной.
**Пере-разметка 0.3.0.** Батч сдвинул четыре класса:
- `Progress` был ✓ (выведен из пофазной механики движка) — стал **◆**: одна полоса до ближайшей
остановки со знаменателем «купленный объём» это ПРОДУКТОВОЕ решение владельца (§8 п.1/п.8
research/28), а не проекция устройства движка. Это и было дефектом: форма ✓ означала, что
устройство движка обязано пролезать на провод;
- словари банка (`TermStatus`, `TermOrigin`) были ✓ по значениям — стали **✓ по различению, ◆ по
словам**: различение выведено из кода, слова назначены продуктом (движковые `auto`/`draft`/
`ruby`/`mined` на провод больше не идут);
- модель ошибок была ◆ (форма RFC 9457 как её понимал автор) — стала **✓ по словарю причин**
(класс 1 выведен перечислением реальных ветвей `platform/internal/httpapi/v0.go:333-427,542-574`)
**и ✓ по политике** (вариант B ратифицирован владельцем, §8 п.4);
- `GET /capabilities`, условные чтения, `Idempotency-Key`, `blocked`**◆ целиком**: формы
назначены этим батчем, подтверждает платформа при P7.
---
## 1. Почему YAML, а не проза
Контракт — машинный артефакт: из него генерируются типы, по нему линтуется форма, им типизируются
моки. Прозаический контракт расходится с кодом ровно тем способом, ради предотвращения которого
заведена строка 95.
**Но машинного мало, и 0.3.0 это подтвердил дважды.** Ревью прогнало линтер по канону 0.2.3 и
получило «No results» — то есть **все 29 находок ревью были смысловыми, ни одной синтаксической**.
А К-11 замерил, что `if`/`then` OpenAPI 3.1 генератор типов ИГНОРИРУЕТ. Отсюда правило,
действующее с 0.3.0: **любое межполевое или условное правило обязано быть записано И схемой, И
словами в описании поля** — схема защищает сервер, слова доезжают до клиента. Мест таких три:
`BankDecision.dst`, `Unit.target`, агрегаты `BankPage`.
Инструменты и пины — `STACK_DECISIONS.md` §3 и бэклог зоны.
---
## 2. Решения и их происхождение
### 2.1. Язык — код, никогда не имя — ✓ выведено
Движок держит коды (`backend/internal/config/book.go:26-27`), ключ пары — `zh-ru`
(`configs/langpacks/zh-ru/`). Имя языка в данных — пар-специфика в общем слое, запрещённая §2
канона. Вторая цена, дороже: `lang` элемента берётся из данных книги, и пара ja→ru с именем вместо
кода отрисует кандзи китайскими начертаниями молча.
**0.3.0 дополнил:** форма кода — не то же, что умение пары. Платформа проверяет только ФОРМУ
(`platform/internal/books/books.go:118-121`) и равенство `source == target` не проверяет никто, а
пар-промпты в репозитории есть ТОЛЬКО для `zh-ru`; отсутствие промпта пары — жёсткая ошибка
конфигурации, не деградация («no prompt for pair %q role %q … never silently substitute another
pair's conventions», `backend/internal/config/pipeline.go:952-956`). До 0.3.0 книга в
неподдерживаемой паре принималась, ложилась на диск, разбиралась, проводила пользователя через
денежный экран — и умирала на старте прогона без причины. Отсюда `Capabilities.language_pairs[]`
со статусом и код отказа на интейке (`errors[].code: unsupported_pair`).
### 2.2. Идентификаторы непрозрачны — ✓ выведено, классы стабильности разведены в 0.3.0
`glossary.id` — свежий автоинкремент на каждой пересборке банка и намеренно не хешируется
(`store/migrate.go:173-174`); номер главы плотный, «Chapters that yield no text … do NOT consume a
chapter number» (`chunk/chunker.go:99-105`), поэтому правка исходника сдвигает номера последующих
глав.
**0.3.0:** одно предложение «Stability across runs is the platform's job» покрывало пять сущностей
с разной по природе стабильностью и для юнита обещало заведомо больше возможного: движковый id
юнита несёт cut-tag, и «any chunker/budget/pipeline-shape change mints a new id for EVERY unit in
the book, while chapter ids survive» (`backend/internal/pipeline/manifest.go:102`). Теперь в схеме
`Id` три класса явно: книга/прогон/экспорт — навсегда; глава — переживает пере-разбор; **пара —
только внутри одного `structure_version`**, и клиент это НАБЛЮДАЕТ (Б-7), а не узнаёт по разъехавшимся
вкладкам. Стабильность по-прежнему обеспечивает платформа при персисте манифеста (строка 100).
### 2.3. Заголовок главы отдельным полем — ◆ предложено
**Движок сегодня делает ОБРАТНОЕ**, и это надо назвать прямо: титул рендерится детерминистически из
шаблона пары (`configs/langpacks/zh-ru/heading.txt`), исходный маркер вырезается из текста для модели
(`chunk/chunker.go:110-114`), а на экспорте титул вклеивается внутрь текста первого юнита
(`pipeline/export.go:215`). Комментарий движка прямо запрещает подавать этот рендер как метку книги
(`pipeline/manifest.go:80-86`). Развилка — К-2.
**0.3.0 снял противоречие, которое существовало независимо от К-2.** Спека 0.2.3 запрещала клиенту
синтезировать метку «Глава N» — а решение владельца 09.08 (`research/27` §33-36) требует ДВУХ меток:
оригинальной из данных книги плюс служебного рендера «Глава N» за $0 в локали ИНТЕРФЕЙСА. Сервер
локали интерфейса не знает (`Accept-Language` в платформе нет грепом), значит служебный рендер обязан
быть клиентским. Запрет снят; `Chapter.heading` = только метка ИЗ ДАННЫХ книги, `null` — обычный
ответ, и деплою прямо запрещено класть сюда рендеренный порядковый.
**`title_raw` и `kind` (глава/фрагмент) в 0.3.0 НЕ заводятся — передано дизайн-паку этапа 161**
(строка 161, очередь D39.136 п.3), и вот почему это не откладывание: (а) производителя настоящих
названий не существует — это Этап 0, строка 160; (б) форма узла «глава ↔ технический фрагмент» и
вердикт детекции — ровно тот предмет, который дизайн-пак и решает, а заложенная до него форма
заморозила бы догадку; (в) добавление обоих полей — АДДИТИВНОЕ расширение (минор), а смены СМЫСЛА
существующего `heading` не будет: он и сегодня, и после 160 означает одно — метку из данных книги.
**Запись для пака 161: контракт 14 расширяется этим паком аддитивно; `Chapter` получает `title_raw`
(если решится, что рендер и оригинал — разные поля), `kind`, вердикт структуры; ломать 0.3.0 для
этого не требуется.**
### 2.4. Состояние — у прогона; у главы выполнение — ✓ выведено
Подпись банка это один стоп на всю книгу (`pipeline/mining.go:81,243`), поэтому «глава ждёт подписи,
пока соседняя финализируется» — невозможная картина. У главы движок держит `ChapterPassport`
(`pipeline/status.go:37-55`).
### 2.5. Прогресс — ОДНА полоса до ближайшей остановки, в главах (0.3.0) — ◆ решение владельца
**Прежняя редакция (0.2.x): пофазно и в юнитах, ✓ выведено.** Основание было верное по факту:
«A unit is DONE when every member draft AND the unit's edit resolved ok» (`pipeline/status.go:328-331`),
редактура не стартует до стопа банка ⇒ сквозной счётчик стоял бы на нуле всю первую волну.
**Почему это всё равно был дефект (research/28 Б-0, вопрос владельца 16.08).** Пара `draft`/`edit`
была не абстракцией, а сквозным пробросом внутренней структуры движка до React-компонента —
`runevents.go:195-196``ingest/events.go:121,130-135` → SQL-констрейнт
`check (wave in ('draft','edit'))` (`00015_seam_ceiling_and_units.sql:56`) → `wireProgress`
(`v0.go:92-96`) → спека → генерённые типы → `format.ts:109-110`. То есть архитектура движка была
пришпилена к контракту в шести местах, а её изменение — ломающим для фронта.
**Решение владельца 16.08 («Согласен, переделываем» + В-5 «Ок, делаем так»):** одна полоса до
ближайшей остановки, знаменатель — КУПЛЕННЫЙ объём, после подписи банка полоса начинается заново.
**Форма, выбранная батчем: счёт в ГЛАВАХ, а не в юнитах.** Три довода, и второй — денежный:
1. **Одна величина, а не две.** «Один счётчик» и «знаменатель — купленный объём» вместе значат, что
числитель и знаменатель обязаны быть в одной единице. Купленный объём объявлен в ГЛАВАХ
(`ceiling_chapters`), и другой единицы у него нет: пересчёт «главы → деньги» на провод не идёт
(D39.84), пересчёт «главы → юниты» до разбора неизвестен.
2. **Дробь наконец означает то, что человек купил** (Б-13а). До 0.3.0 потолок был в главах, а
прогресс — в юнитах ПО ВСЕЙ КНИГЕ (`v0.go:473-477`), поэтому прогон, купленный на 10 глав из
2284, показывал дробь, которая не могла дойти до единицы, — и книга уходила в `paused` на 0,4 %.
А потолок стоит на КАЖДОМ прогоне: поле обязательное.
3. **Ноль всю первую волну не возвращается — его снимает СЕГМЕНТНАЯ логика, а не единица счёта.**
Сегмент = работа между двумя остановками; в первом сегменте глава засчитывается, когда её работа
ЭТОГО сегмента закончена, а не когда она пройдена от начала до конца. Обе величины у платформы
уже есть: `chapters.units_draft_done` / `units_edit_done` заведены именно под это
(`00002_readmodel.sql:102-105`, комментарий «K-10 is open… answering it is a projection change
rather than a migration»). Новых колонок батч не требует.
**Тем же ходом закрыт К-10 — вердикт «НЕ строить»** (D39.138, поправка приёмки research/28 №1):
пофазные счётчики на главу строить НЕ надо, потому что фаз на проводе больше нет вовсе.
`Chapter.units_done` считается той же сегментной логикой, что и книжная полоса, — иначе дерево глав
читало бы ноль всю первую волну, а это и была исходная жалоба К-10, и снятие фаз само по себе её не
лечит.
**Книжная полоса — отдельная величина.** `Book.chapters_done` против `chapter_count` — прогресс
КНИГИ (строка библиотеки), он не откатывается при старте нового прогона. Величина уже считается в
SQL и до 0.3.0 не отдавалась: `b.chapter_count - (select count(*) from chapters c where … units_done
>= units_total)` (`pgstore/books.go:677-681`). `Progress` на `Book` больше нет.
### 2.6. Словарь статусов — ✓ по механике, ◆ по составу; `finalizing` снят в 0.3.0
Лестница «загрузка → разбор → перевод → подпись банка → финал → готово» пришла из строки 95.
`stopped`, `rejected`, `not_started` контракт ВЫВОДИТ из поведения процесса, а не получает полем:
механика стопа у движка есть (`cmd/tmctl/main.go:63`), но «кто нажал» знает платформа.
**Стоп по потолку — не `failed`** ✓ выведено: «Ceiling is a hard, book-wide stop … the job stays
'pending' and resume continues once the ceiling is raised» (`pipeline/stagerun.go:488-489`).
**0.3.0 — три правки:**
- **`finalizing` снят.** У движка такой фазы нет вовсе (`grep -ri finaliz backend/internal` — пусто),
в словаре ingest её нет (`ingest/events.go:53-67`), писателя у значения нет нигде. Держалась она
на лестнице владельца — а владелец 16.08 сказал про лестницу: «Ну да, это чисто моя фраза была»
(§8 п.15). Устная формулировка нормой продукта не является, статус ею не связан. Появится фаза у
движка — значение вернётся минором так же дёшево.
- **Заведён отдельный `RunStatus` (6 значений).** `Run.status` был типизирован книжным словарём из
11 значений, из которых на прогоне легальны не все, и спека говорила это ПРОЗОЙ — то есть
генерённый union был шире правды, а клиент обязан был писать недостижимые ветки. Узкий словарь у
платформы в DDL уже записан (`00002_readmodel.sql:47-49`).
- **Записано правило старшинства** «книга производна от прогона, кроме `uploading`/`parsing`/
`not_started`/`rejected`». До 0.3.0 правила не было, и фронт уже разошёлся сам с собой: полоса
состояния решала «paused» по прогону (`showcase/Status.tsx:21`), карточка — по книге.
### 2.7. Состояние пары выводится из ПАРЫ — ✓ выведено
Флагнутый юнит легально приходит С ТЕКСТОМ в двух случаях: косметическая зачистка санитайзера («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): при c-lite
drop юнит несёт причину ПЕРВОГО выпавшего члена, какой бы она ни была. Значит карта причин обязана
иметь фразу для каждой, а не для двух. Но пара «текст + промах словаря» невозможна ни одним каналом:
`memberDrops` берёт причину из ЧЕРНОВОЙ строки члена (`status.go:242-257`), а `glossary_miss`
ставится пост-чеком только там, где отгружается финал (`waverun.go:373-381`, `:494`).
**0.3.0:** инвариант «`translated` ⇒ текст непуст, иначе пуст» перестал быть только чеком БД
(`00002_readmodel.sql:127-128`) и стал условной обязательностью в схеме плюс словами в описании
(К-11: генератор `if`/`then` игнорирует). Слова «flagged», «chunk verdict», «sanitizer» с провода
сняты — они компилировались в JSDoc генерённых типов клиента.
**Свежесть** ✓ выведено: `target` обновляется на границах работы и на стопах, а не непрерывно —
посреди прогона канала чтения не существует (эксклюзивный лок движка; санкционированное чтение —
завершённый либо остановленный прогон, `research/23` §0, §4).
### 2.8. Банк: различение ✓, слова ◆ (пере-назначены в 0.3.0)
| Поле | Словарь 0.3.0 | Что было у движка | Грунт |
|---|---|---|---|
| `status` | `proposed · in_progress · approved` | `auto · draft · approved` | `store/migrate.go:191`; только `approved` — канон |
| `kind` ◆ | `name · place · title · term · nickname` **плюс `null`** | то же | `terminology/classify.go:15` + `banknote.go:74`; пустое — `membank/memseed.go:323-326` |
| `origin` | `given · annotated · found` | `seed · ruby · mined` | пути записи, см. ниже |
| `sense` | свободный текст, пустая строка = «нет различителя» | то же | `store/migrate.go:182` |
| окно | `since_chapter`/`until_chapter`, **`null` = без границы** | целые, `0` = без границы | `store/migrate.go:189-190` |
**Почему слова пере-назначены (0.3.0, Б-0/Б-17).** `ruby` — японская фуригана, то есть
паро-специфика в общем слое; `mined` — имя стадии конвейера; `draft` — имя волны; `auto` читается
как «движок сам». Канон проекта: книжный/паровой термин в общем слое = утечка (CLAUDE.md,
гардрейлы), и шапка самой спеки объявляет «no stage names». Клиент был обязан нарисовать слово для
`ruby` в паре, где рубя не существует.
**`TermStatus` — ось СОХРАНЕНА, переименованы только значения** (эррата 16.08-г D-лога). Исходная
рекомендация Б-0 «снять с провода» опиралась на «ни один экран их не рисует» — а это подмена: экрана
подписи ещё нет (S5). Ось продуктовая и несущая — «на экране в сотни строк это главный фильтр
работы», и шов клиента её уже потребляет (`frontend/src/api/vocabulary.ts:159-163`, `termStatus` с
безопасным дефолтом `canon: false`). **`TermOrigin` тем же разбором ОСТАВЛЕН как различение**
(провенанс нужен подписывающему, чтобы понимать доверие к строке) и переименован по значениям: `seed`
`given` (пришло с книгой), `ruby``annotated` (сам текст книги сказал, как читать), `mined`
`found` (сервис нашёл в тексте). Проекция трёх пар — работа платформы.
**Фантом `auto` в провенансе убран ещё в 0.2.0:** комментарий схемы движка (`migrate.go:193`:
`seed|ruby|auto`) устарел — `"auto"` пишет СТАТУС, не провенанс (`membank/memseed.go:328`:
`Status:"auto", Source:"ruby"`), майнинг ставит `mined` (`pipeline/mining.go:424`).
**Ложный друг `source`.** У движка колонка `source` — это ПРОВЕНАНС. Поэтому в контракте провенанс
зовётся `origin`, формы термина — `src`/`dst`, а имя `source` в схеме банка не используется вовсе.
Переименование ради стиля здесь — самый дорогой класс правки, и ревью 0.3.0 его отвергло (§4 №25).
**Окно термина (0.3.0, Б-18).** `since_chapter`/`until_chapter` стоят на НОМЕРЕ главы, а номер сам
контракт называет не-ключом: нумерация плотная, правка исходника сдвигает хвост, и для книги без
нумерации номера легально нет. Перевести окно на `chapter_id` нельзя — оно входит в ключ уникальности
термина у движка (`store/migrate.go:202`), и смена ключа это работа движка, не контракта. Поэтому
записано ЯВНО: окно живёт в координатах текущего `structure_version` и пересчитывается при его
смене; два смысла нуля разведены на `null` («без границы»), потому что `Chapter.number` начинается с
единицы и `0` был сентинелом с двумя значениями в двух полях.
### 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» (`pipeline/mining.go:81,243`; ⚠ фраза «the stop clears once
every proposed term is promoted or rejected» ОПРОКИНУТА D39.144 — движок авто-продолжает с
неподписанным, D39.42 п.3). Отсюда живое: решение на термин · информационный счётчик «решено N из M»
· частичное сохранение ◆. Запрет «продолжить» при неполном наборе — СНЯТ (подпись = один resume).
**0.3.0 — две правки:**
- **`promote``approve`.** `promote`/`decline` — дословно глаголы оператора майнера из строки выше,
и `promote` порождал `status: approved` — два слова на один акт. Теперь `approve``approved`.
- **Снимок стопа переехал в ЧТЕНИЕ банка** (Б-14а). ⚠ Пере-писано D39.144 (модель владельца 16.08:
подпись — ОДИН акт над всем банком): `complete`/`pending_decisions` — ИНФОРМАЦИОННЫЕ, `resumeRun`
снимает стоп при ЛЮБОМ состоянии решений, 409-полноты НЕТ. Прежняя формулировка — «поле, по
которому решается, можно ли предлагать „продолжить“» — существовало только в
квитанции POST. Экран, перезагруженный посреди стопа, мог прочитать ВЕСЬ банк и не узнать, сколько
решений осталось; единственная реализация выводила признак как `left === 0`
(`frontend/src/mock/handlers.ts:203-204`) — инвариант, которого контракт не объявлял. Теперь
`pending_decisions` и `complete` отвечает и `GET /bank`, и квитанция, и кадр `bank`.
`POST /runs/{id}/resume` — нормативная операция, а не резерв.
**Открытый остаток, НЕ закрытый батчем и вынесенный вопросом (см. отчёт батча §8).** Чтение банка
отвечает СКОЛЬКО решений осталось (`pending_decisions`, `complete`), но не КАКИЕ строки уже решены:
`TermStatus` — состояние строки банка, а решение живёт отдельной таблицей (`bank_decisions`,
`00002_readmodel.sql`), и на провод оно не проецируется ни одним полем. Экран подписи, перезагруженный
посреди стопа, поэтому знает «осталось 17 из 300» и не знает, какие семнадцать. Лечение — одно
поле `BankTerm.decision` (`approve`/`decline`/`null`), и оно НЕ добавлено: слово владельца 15.08
«добавочные поля `BankTerm` НЕ заводить» (D39.136 п.4б) прямо это запрещает, а промт батча повторяет
запрет. Найдено холодным потребителем; решение — за владельцем.
### 2.10. Ревизия — ✓ у ре-синка, ◆ у чтений
**✓ выведено:** правило ре-синка — идемпотентный апсерт по `(run_id, seq)`, канал согласования —
`status --json` (`research/23` §2, §8; D39.85).
**◆ предложено:** что ревизию несут и ЧТЕНИЯ, и что счётчик у потока и у чтений ОДИН. Обоснование —
гонка, которую иначе нечем разрешить: фронт живёт на снимке и потоке разом, а рефетч по возврату
фокуса окна у ратифицированного `@tanstack/react-query` включён по умолчанию.
**0.3.0 — два уточнения, оба выведены из построенного клиента:**
- **Ревизия страничного обхода — МИНИМУМ по страницам, не максимум.** Правило жило только
комментарием в `frontend/src/api/client.ts:99-106` («The revision of a torn list is its OLDEST
page»), то есть второй клиент его бы не узнал. Теперь оно в схеме `Revision`.
- **Агрегаты банка — на ПЕРВОЙ странице** (Б-12). `Bank` обязывал нести `total`/`signed` «в целом по
банку» на КАЖДОЙ странице, при этом у каждой страницы своя `revision`, а правила согласования не
было — и референсный клиент неизбежно смешивал два момента времени: счётчики брал с последней
страницы (`queries.ts:144-148`), ревизию — с первой. Обе половины написаны осознанно и обе верны по
отдельности. Первая страница — единственный выбор, согласованный с правилом выше, и он не требует
от сервера держать снимок между запросами.
**Кадры соединения не тратят номеров истории.** `id` кадра объявлен позицией в истории событий
КНИГИ, строго возрастающей, — но `hello` приходит первым на КАЖДОМ подключении, а `end` и
`resync_required` тоже принадлежат соединению, не книге. Первая редакция батча этого не развела, и у
реализатора оставалось два пути, оба против текста: минтить служебным кадрам книжные номера (тогда два
одновременных зрителя тратят номера друг друга, и `Last-Event-ID` одного указывает на кадры, которых
второй не видел) либо повторять последний номер (тогда «one per frame» ложь). Поймано кросс-модельной
линзой; исправлено: служебные кадры несут id последнего кадра ИСТОРИИ и своего номера не тратят,
повтор id на них легален, а дыра в нумерации легальна из-за склейки — и клиенту прямо запрещено читать
пропуск как потерянный кадр. Последнее правило было в 0.2.3, потерялось при резке прозы и возвращено.
**Дельта-чтения ВКЛЮЧИТЕЛЬНЫ (`>=`), а не строго больше.** Первая редакция батча сделала
`?after_version=` строгим — и тем сломала собственное правило `Revision` («catch-up reads `>= R`, not
`> R`»): одна транзакция это одна ревизия, но НЕСКОЛЬКО строк, и строгое сравнение теряет соседей
последней применённой. Хуже: кадр `bank` предписывал читать строки «ревизией этого кадра», что при
строгом сравнении всегда возвращало пустоту. Поймано холодным потребителем, исправлено: чтение
включительно, повторно пришедшая строка безвредна (строка заменяется по `id`), а водяной знак
следующего чтения берётся из КОНВЕРТА, а не выводится из строк.
**Дофикс 16.08 (ФБ-4): у многостраничного обхода конверт не один.** Формулировка «водяной знак —
`revision` конверта» была однозначна ровно до тех пор, пока чтение умещалось в одну страницу; на
рваном чтении она сталкивалась со вторым правилом того же раздела — «ревизия рваного списка это
ревизия СТАРЕЙШЕЙ страницы». Два правила давали два разных числа, и клиент, взявший новейшее, молча
терял строки, изменившиеся между первой страницей и последней. Сведено в одно: **и гард свежести, и
водяной знак — это НАИМЕНЬШАЯ ревизия, увиденная за обход**; на одностраничном ответе это его
собственная. Направление выбора — безопасное: лишнее перечитывание бесплатно (строка заменяется по
`id`), пропуск строки — нет. Там же заявлено равенство `BookDetail.revision` и
`BookDetail.book.revision`: карточка собирается одной транзакцией с книгой, и клиент вправе брать
любое. Устаревший водяной знак
(коллекцию заменили целиком) отвечает `400` c `cause.code: version_too_old` — тем же ответом и с тем
же смыслом, что мёртвый курсор.
**Структурная версия — новая ось (0.3.0, Б-7).** Курсор был привязан к «STRUCTURAL epoch of the
collection», серверу вменялся MUST-отказ по мёртвому курсору, — а слово «epoch» встречалось в
документе РОВНО ОДИН раз: ни один ответ эпохи не нёс, наблюдать её было нечем. При этом структура
живая: «число глав может измениться против эвристики» (`research/27:35`, решение владельца 09.08).
Теперь `structure_version` едет **на КАЖДОМ кадре** (`EventBase`) и в `Book`, `ChapterPage`,
`UnitPage`, `NotePage`, `BankPage`; на исчезнувшую главу отвечает `410`; к смене версии привязаны
курсор, якоря на пары, окно термина и идентичность строки банка. ⚠ Первая редакция батча положила
версию только в `hello` и два конверта — и тем оставила ДВЕ свои же обязанности («перечитать банк,
когда версия сдвинулась», «уронить якоря на пары») без единого триггера, а `BankPage`/`NotePage`
без указания, к какой структуре относится их содержимое. Поймано обеими линзами селф-ревью,
исправлено.
### 2.11. Разрыв потока — ◆ предложено; канал перевешен на КНИГУ в 0.3.0
При переподключении клиент шлёт `Last-Event-ID`. Если сервер докачать не может — обязан ответить
`resync_required`, а не молча начать с текущего момента: реплей истории запрещён, иначе разовое
событие вроде `note` теряется молча.
**0.3.0 — четыре правки одного канала (Б-6), все Ц0: канала нет ни строкой** (`grep
text/event-stream` по не-тестовому Go платформы — только комментарии).
- **Поток перевешен с прогона на книгу.** Книга в `uploading`/`parsing` прогона не имеет по
построению: строка прогона создаётся только в `StartRun` (`pgstore/runs.go:66,80`), весь разбор
живёт на строке книги (`books.go:180,237,283`). Прогонный поток не мог сообщить конец разбора в
принципе, и клиент лечился опросом раз в 3 с (`queries.ts:64-75`) плюс вторым хуком
(`useIntakeEnd.ts:31`) — а разбор настоящей книги это минуты. Это Ф-56, и чинится он не новым
кадром, а перевеской канала: конец разбора становится обычной сменой статуса.
**Прогонный поток узким видом НЕ оставлен** (Б-6 предлагал оставить). Довод: второй канал с теми
же кадрами — вторая реализация и второй источник расхождения, а адресация «кадры этого прогона»
выводится из книжного потока клиентом, у которого id прогона уже есть. §5а того же ревью требует
резать, а не добавлять поверхность.
- **Конец потока объявлен.** Терминального кадра не было, `204` в ответах не объявлен — при том что
SSE именно им останавливает переподключение («a client can be told to stop reconnecting using the
HTTP 204 No Content response code», WHATWG). После завершённого прогона браузер переподключался бы
вечно. Теперь: кадр `end` + `204` на переподключение с `Last-Event-ID` от завершённого потока;
запрос БЕЗ `Last-Event-ID` всегда открывает новый поток — иначе клиент не смог бы начать смотреть
снова после старта прогона.
- **`id` кадра и ревизия книги разведены.** Спека просила «a monotonic `id`», говорила, что он несёт
ревизию, и допускала несколько кадров с одним id; форма зафиксирована не была. Сервер, сделавший id
уникальным на кадр (`1841-2`), не нарушил бы ни слова прозы и навсегда отключил бы клиентский гард:
`Number('1841-2')` = `NaN` (`frontend/src/api/stream.ts:131`). Теперь `id` — позиция потока
(десятичное целое, форма зафиксирована), ревизия — поле в `data` на КАЖДОМ кадре.
- **Склейка ограничена по типу.** «The server MAY COALESCE frames» стояло без ограничений — а склейка
двух `note` теряет замечание навсегда, на живом соединении, без переподключения и потому без
`resync_required`. Это ровно тот исход, которым та же спека двумя абзацами выше обосновывала запрет
реплея. Теперь: склеивать можно кадры СОСТОЯНИЯ, `note` — нельзя.
- **Правило докачки выбрано одно** (было два взаимоисключающих): короткий живой буфер после
предъявленного id разрешён, реплей истории за его пределами запрещён, размер буфера сервер не
объявляет и клиент на него не опирается.
### 2.12. Разрешающий список — ✓ инвариант, ◆ форма
Проекция «read-модель → фронт» строится как allowlist. Что лежит в операторских структурах
(`pipeline/status.go:58-130`, `:37-55`) и не может доехать: пять денежных полей плюс `cost_usd` главы
· `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`.
**0.3.0 расширил инвариант на ПРОЗУ.** Аллоулист полей держался, а описания — нет: объяснительный
текст спеки компилируется в исходники фронта как JSDoc генерённых типов, и туда уехали «chunk
verdict», «flagged», «sanitizer cleanup», «stage boundaries», операторские ранги и пример
«CJK leak in the ru output: 第一节» (`openapi.yaml:1430``frontend/src/api/schema.ts:1047` в
редакции 0.2.3) — то есть ровно та строка, которую контракт объявлял запретной. Теперь правило
звучит так: **на проводе нет ни имён стадий/волн, ни движковых словарей — ни в полях, ни в
описаниях.** Ревью-вопрос каждой правки — §5.
### 2.13. ПТ-34 — перевод не индексируется — ✓ инвариант
Приложение живёт под `X-Robots-Tag: noindex`, ответы несут `Cache-Control: no-store`, ссылка на
выгрузку выдаётся только владельцу.
**0.3.0 — две правки.** (а) `no-store` объявлен на ВСЕХ ответах, как его и ставит платформа
(`middleware.go:38`): контракт требовал его только для ответов с переводом, то есть был беднее кода.
(б) Ссылка экспорта получила НОРМУ доступа вместо прозы: минтится под этот ответ и под
аутентифицированного владельца, не индексируется, истекает в `expires_at`. Грунт — сама платформа:
«The download URL is minted per request for the owner and is never indexable (PT-34), so it is not a
column» (`00002_readmodel.sql:188-199`).
### 2.14. Поверхность входа `/auth/*` — ✓ построено платформой
Четыре ручки живут ВНЕ версионного префикса, как `/healthz`: это механика сессии, а не контрактная
поверхность.
| Ручка | Метод | Что делает |
|---|---|---|
| `/auth/login` | GET | начинает вход, редиректит к провайдеру; принимает `?return_to=<путь этого сайта>` |
| `/auth/callback` | GET | завершает вход, ставит сессионную куку, редиректит на `return_to` либо на дефолт |
| `/auth/logout` | POST | завершает ЭТУ сессию |
| `/auth/logout-all` | POST | завершает ВСЕ сессии пользователя |
`return_to` принимает ТОЛЬКО путь этого сайта, и чужой путь сервер молча заменяет дефолтом
(`login.go:safeReturnTo`). Обе `POST`-ручки лежат на cookie-пути, то есть требуют `X-TM-Client`.
Отказ входа — `problem+json`.
### 2.15. Потолок прогона — ◆ форма, РАТИФИЦИРОВАНА оркестратором №15 (08.08)
Решение владельца 07.08: шкала от минимума до максимума, ноль выбрать нельзя, единица — ГЛАВЫ,
потолок принадлежит ПРОГОНУ.
**Отдельный ресурс, а не поле карточки книги.** Максимум зависит от АККАУНТА и двигается, когда книга
не меняется. **Три числа, а не два:** `max_chapters` приходит УЖЕ подрезанным и по остатку, и по
непереведённому хвосту; клиенту подрезать второй раз ЗАПРЕЩЕНО. `default_chapters` отдаёт платформа,
потому что предустановленное значение — продуктовая политика.
**`max_chapters` — величина, а не арифметика.** Ратификация D39.110 в первой редакции требовала
«баланс МИНУС открытые холды»: это была ОШИБКА — вычитание дважды. Холд есть дебет в момент взятия
(`pgstore/credits.go:179`), поэтому баланс уже не содержит открытых холдов. Замерено на живом
PostgreSQL: грант $10 и холд $1 дают `Balance` 9 и `Reserved` 1.
**0.3.0 — `blocked` (В-6, «ОК» владельца 16.08).** Шкала второй книги молча ужимается — или исчезает
— когда кредит держит холд ПЕРВОЙ книги, и узнать это из контракта было нечем. Теперь `run-options` и
409 на старте несут `blocked: {code, book_id}`. Грунт: платформа знает открытые холды с `book_id`
(таблица `reservations`). Один код `credit_held` — других причин ужать шкалу у платформы сегодня нет:
`ErrRunInFlight` привязан к СВОЕЙ книге (`book.HasLiveRun`, `runs/runs.go:187-188`) и вторую книгу не
блокирует.
### 2.16. Транспорт: тот же origin — ФАКТ, а не выбор
CORS-слоя в платформе нет вовсе: preflight `OPTIONS` с чужим `Origin` получает 401 от гарда сессии,
заголовков `Access-Control-*` нет ни на одном ответе (PD-96). Браузерный клиент с другого origin
неработоспособен как класс. `X-TM-Client` обязателен и на same-origin — он не про CORS.
**0.3.0 — безопасность стала машинной (Б-15).** Правило `X-TM-Client` жило в ПРОЗЕ описания схемы
безопасности: генератор его не создавал, spectral не проверял, контрактный тест не ловил, и клиент
носил его двумя копиями руками (`client.ts:13,40`, `upload.ts:81`). Правки: заголовок объявлен
параметром на каждой небезопасной операции · `403` объявлен ответом (раньше его в контракте не было
вовсе, а платформа им отвечает — `server.go:99`) · `401` объявил обязательный `WWW-Authenticate`
(RFC 9110 §15.5.2 требует его MUST, ни `WriteProblem`, ни Deny-обработчик его не ставили) ·
множество небезопасных методов приведено к коду и к RFC (код освобождает и `OPTIONS`,
`auth/csrf.go:51-54`; спека говорила «anything other than GET and HEAD», и клиент следовал СПЕКЕ) ·
записан второй карваут (well-formed Bearer, `csrf.go:55-62`) · `servers[0].url` стал относительным
`/v0` (был абсолютный плейсхолдер `https://app.example.org/v0`, в который целился бы сгенерированный
клиент) · записана связь «защита работает, ПОКА нет CORS», чтобы её снятие требовало правки контракта,
а не конфига.
### 2.17. Модель ошибок — вариант B (0.3.0) — ✓ словарь, ◆ форма
**Что было.** `type` — константа `about:blank` на каждом ответе (`problem.go:25`); `detail` пуст на
всех вызовах контрактной поверхности; `instance` объявлен спекой и структурой `Problem` в коде не
предусмотрен вовсе. Различитель — английская фраза, которую контракт предписывал показывать
пользователю «as-is» при русском интерфейсе (`Loaded.tsx:66`, `RunStart.tsx:188`, `AddBook.tsx:289`).
Фраз при этом меньше, чем причин: «Request could not be read» покрывала шесть разных условий,
«The upload is incomplete» — четыре. `Accept-Language` и локалей в платформе нет грепом.
**Решение владельца 16.08 — «Идём в Б путём гугла» (§8 п.4):** машинный `code` (двухуровневый:
стабильный корневой + расширяемый вложенный) + `request_id`; `title`/`detail` — developer-facing,
клиент их НЕ показывает; серверная локализованная фраза — отдельным полем и только для
неперечислимых причин; `errors[]` с указателем поля.
**Форма, выбранная батчем.**
- **`code` — корневой, закрытый, 16 значений; `cause.code` — второй уровень, НЕ закрытый.** Это и
есть механизм расширяемости: новый частный случай добавляется в `cause`, не ломая клиентов, — то,
чем Microsoft закрывает «новый код = ломающее изменение». Второй уровень сделан ВЛОЖЕННЫМ объектом,
а не соседним полем: так граница «стабильное / расширяемое» видна структурно, и всё, что внутри
`cause`, по определению вне словаря версии. Глубина ровно одна — рекурсии `innererror` у нас нет.
- **Словарь класса 1 выведен, а не придуман:** перечислением всех ветвей, где платформа сегодня
отвечает ошибкой, — `v0.go:238,242,250,333,339,345,356,409,417,419,423,531` (прямые ответы) и
`v0.go:542-574` (`fail()`), плюс `server.go:99,134`. Каждый корневой код имеет там источник; ни
одного кода «на будущее» не заведено, кроме двух названных ниже.
- **Два кода заведены под ратифицированные решения, а не под построенный код:**
`idempotency_conflict` (форма `Idempotency-Key` — заказ батча, реализация P7) и `content_refused`
(класс 2, К-9).
- **Класс 2 — ОДИН грубый код на весь класс** (§8а): без причины, без `cause`, без `errors`, без
вариации между попытками. Опасение владельца точное — чем конкретнее отказ, тем лучше он работает
как оракул для подбора входа. Тот же код и тем же правилом заведён значением `RejectReason`
(`content_refused`), потому что К-9 ратифицировал «`rejected` + грубый код, двенадцатый статус НЕ
заводить».
- **`type` остался `about:blank`.** RFC 9457 §3.1.1 требует использовать `type` как первичный
идентификатор — но URI, который никуда не резолвится, или URN, дублирующий `code`, это либо
обещание, которого мы не держим, либо вторая копия факта. Расширение членами объекта — то, что §3.2
разрешает прямо. Зато отклонение 0.2.3 от §3.1.3 («The "title" string is advisory and is included
only for users who are unaware of … the semantics of the type URI») ИСПРАВЛЕНО: `title` больше не
показывается пользователю.
- **`Problem.instance` СНЯТ** (§5а): структуры на платформе нет вовсе, реальная форма ответа —
`{type, title, status, detail?}`, а функцию корреляции несёт `request_id`, значение которого уже
существует и уже едет на каждом ответе (`reqid.go:16,27`).
- **`localized` объявлен и сегодня не используется ни одним кодом.** Это ратифицированный слот
(§8 п.4, форма `google.rpc.LocalizedMessage`) под класс «причина не перечислима заранее»; носитель
— research/28 §8 п.4. Помечено в спеке словами, чтобы предупреждение не пережило своё основание
(правило §3 ниже).
**Цена на клиенте — меньше, чем кажется:** таблица «состояние → русская фраза + совет» у клиента УЖЕ
есть (`AddBook.tsx:282-288`) и отбрасывается всякий раз, когда сервер прислал любую фразу (`:289`).
Перевод на машинные коды — снятие одной строки.
### 2.18. `Idempotency-Key` — семантика (0.3.0) — ◆ форма
Идемпотентности не было, а контракт сам советовал ретрай как лечение 408 — при том что RFC 9110 §9.2.2
говорит: «A client SHOULD NOT automatically retry a request with a non-idempotent method unless it has
some means to know that the request semantics are actually idempotent … or some means to detect that
the original request was never applied». Каждый `POST /books` создаёт новую книгу (`books.go:125`), а
удалить дубликат до 0.3.0 было нечем вовсе.
**Семантику определяет контракт, реализует P7** (решение промта батча). Выбрано: область ключа —
(принципал, метод, путь) · повтор того же запроса → ИСХОДНЫЙ ответ, без новой работы · повтор с
другими параметрами → 409 `key_reused` · повтор во время исполнения первого → 409 `key_in_flight` ·
окно хранения ≥ 24 часа · длина ≤ 255 · отсутствие заголовка легально и означает «без защиты от
повтора». Область по ПУТИ, а не по телу: ключ, поданный на другую операцию, — другой ключ, иначе
клиент, генерирующий ключ на попытку пользователя, получил бы взаимное влияние двух разных действий.
Окно 24 часа — не замер, а достаточная граница для клиентского ретрая; сокращать его дешевле, чем
удлинять, поэтому взята нижняя обещаемая граница («не менее»).
---
## 3. Зависимости: чтение → источник → строка бэклога
**Правило, введённое 0.3.0 (Б-21): предупреждение о недостроенном ОБЯЗАНО нести номер строки
бэклога** (а где строки нет — явного носителя-документ). Без него абзац переживает своё основание и
начинает лгать — что уже произошло четырежды, и одно из четырёх отняло у пользователя работающее
действие. Таблица ниже — единственное место, где это ведётся.
| Чтение / механизм | Источник данных | Состояние | Строка / носитель |
|---|---|---|---|
| `GET /books`, `GET /books/{id}` | read-модель платформы | ПОСТРОЕНО | — |
| `GET /books/{id}/run-options`, `POST /runs` | шкала + холды | ПОСТРОЕНО | — |
| `POST /runs/{id}/stop` | реконсилятор | ПОСТРОЕНО (кнопки на экране нет) | зона фронта |
| `POST /runs/{id}/resume` | реконсилятор | ПОСТРОЕНО, но **поведение на паузе по потолку 0.3.0 МЕНЯЕТ**: сегодня платформа отвечает `202` и возвращает прогон в то же состояние (`runs/reconcile.go:906` — сама `Resume`; ветка `case "paused"` `:919` доходит до `reopen`, 409 только на дневном потолке движка `:931`), а контракт теперь требует `409` `run_not_resumable` · `cause.code: ceiling_reached`. Молчаливый `202` на действие, которое ничего не сделало, — ровно то, от чего предупреждает собственный комментарий платформы; клиенту нечем отличить успех от no-op. Полная таблица по статусам — в описании `resumeRun` | **вход P7** (правка построенного пути, не только читающей поверхности) |
| `GET /usage` | кредиты | ПОСТРОЕНО, **не читается ни одним экраном** | зона фронта |
| `GET /capabilities` | конфигурация деплоя | НЕ ПОСТРОЕНО (заведено 0.3.0) | вход P7 |
| `PATCH`/`DELETE /books/{id}`, `GET /runs/{id}` | колонки есть | НЕ ПОСТРОЕНО (заведено 0.3.0) | вход P7 |
| `GET /books/{id}/chapters`, `/units` | материализация манифеста | НЕ ПОСТРОЕНО | вход P7 |
| `GET /books/{id}/notes` | `unit_done` несёт флаг и причину (`runevents.go:126-135`), платформа хранит (`sink.go:227-233`), колонка `notes.reason` заведена под это | канал ЕСТЬ; не хватает карты «причина → код → фраза» (приложение А) и проекции | приложение А + вход P7 |
| `GET /books/{id}/bank` | движок пишет сайдкар всего банка (`pipeline/bankexport.go:16-33,72`, D39.122) | движковая половина ПОСТРОЕНА; не хватает проекции платформы | строка 169 · вход P7 |
| `POST /bank/decisions` | стоп-механика майнера | НЕ ПОСТРОЕНО | вход P7 |
| `GET /books/{id}/events` (SSE) | эмиттер шва построен (D39.131) | на платформе SSE нет ни строкой | вход P7 |
| `POST`/`GET /exports` | у движка только stdout-JSON и `--plaintext` (`cmd/tmctl/invocation.go:107`) | НЕ ПОСТРОЕНО с обеих сторон | строка 49 / D29.1 «tmctl export-контракт» |
| Условные чтения (`ETag`/304), сжатие | — | НЕ ПОСТРОЕНО | строка 186 |
| `Idempotency-Key` | — | НЕ ПОСТРОЕНО (семантика задана 0.3.0) | вход P7 |
| `bearerToken` — чем ВЫДАЁТСЯ токен | вход только ставит HttpOnly-куку (`login.go:338`) | сервер токен ПРИНИМАЕТ, выдать его нечем | носитель: research/28 §2 (Б-15); строки нет |
| `Problem.localized` | — | объявлено, ни одним кодом не используется | носитель: research/28 §8 п.4 |
| `Note.code` как enum спеки | карта приложения А | не enum, пока не написаны фразы | строка 148 (фразы владельца) |
| Настоящие названия глав (`Chapter.heading` ≠ null) | парсер структуры | НЕ ПОСТРОЕНО | строка 160 (Этап 0) |
| `title_raw` / `kind` (глава ↔ фрагмент) | дизайн-пак структуры глав | передано паку, аддитивно | строка 161 |
**Три прежних предупреждения СНЯТЫ как устаревшие** (Б-7а), и это причина, по которой заведена
таблица выше:
1. «`GET /bank` — канала нет вообще» — неверно с D39.122: сайдкар пишется. Файл при этом противоречил
сам себе (строка таблицы против абзаца ниже неё), а зона фронта до сих пор учится по старой
версии (Ф-43).
2. «`EventNote` — движок не эмитит пер-юнитных замечаний» — эмиттер приземлился 14.08.
3. «`EventCeiling` — зависит от эмиттера» — то же (кадр при этом снят по §5а, см. §6).
**Четвёртое было ХУЖЕ устаревшего — оно было НОРМАТИВНЫМ и отнимало работающее лечение.** Спека
0.2.3 писала: механизма поднятия потолка нет, «so the client MUST NOT offer resume as the remedy for
`paused`», — и не называла НИКАКОГО другого действия. Первая половина верна: `resume` действительно
не двигает такой прогон. Вторая — нет: стоп по потолку ЗАКРЫВАЕТ прогон, `finished_at` пишется тем же
оператором (`pgstore/runs.go:672-676`), `paused` входит в допустимые для старта состояния
(`runs/runs.go:227-231`, allowlist `readyToTranslate`), `HasLiveRun` при этом ложь — то есть **новый
прогон с бОльшим потолком запускается и является лечением уже сегодня**. Пользователь видел тупик там,
где его нет, на самом частом остановочном состоянии. В 0.3.0 лечение записано в `startRun` и в
`resumeRun`, а `resume` после потолка отвечает 409 с `cause.code: ceiling_reached`.
---
## 4. Открытые вопросы
| # | Вопрос | Статус |
|---|---|---|
| К-1 | Словарь статусов | **✅ ЗАКРЫТ D39.100**; 0.3.0 снял `finalizing` и развёл `RunStatus` — §2.6 |
| К-2 | Титул главы: поле `heading` или вклейка в текст | **○ ОТКРЫТ**, автор контракта + бэкенд. 0.3.0 закрыл ОТДЕЛЬНОЕ противоречие (запрет клиентской служебной метки снят), но кто производит настоящую метку — строка 160 |
| К-3 | Метка «Глава N» как единственная форма | **✅ ЗАКРЫТ D39.100** (метка — из ДАННЫХ книги). 0.3.0: служебный рендер «Глава N» разрешён КЛИЕНТУ, в локали интерфейса |
| К-4 | Ревизия: сквозная или пер-ресурсная; несут ли её чтения | **✅ ОТВЕЧЕН P0**, подтверждён ревью с поправкой: ревизия и позиция потока — РАЗНЫЕ величины, совмещать в `id` нельзя (0.3.0, §2.11) |
| К-5 | Показывать ли ETA | **✅ ЗАКРЫТ D39.100** (`eta_seconds` в спеке; 0.3.0 сделал поле required+nullable) |
| К-6 | Ступени замечания: сколько и где граница | **○ ОТКРЫТ, владелец.** Ответ 16.08: «показывать ВСЕ; в тексте сворачивать и раскрывать по кнопке; как именно — решим потом». **Словарь ступеней проектировать заранее НЕ надо** — батч его и не проектировал. Зависимость закрыта: `Note.id` заведён (0.3.0), без него конкретное замечание нельзя свернуть и запомнить |
| К-7 | Пагинация: курсор или один ответ | **✅ ОТВЕЧЕН P0.** 0.3.0 добавил недостающее: максимум `limit`, подрезание вместо понижения, объявленный порядок каждой коллекции, `maxItems` у `decisions` |
| К-8 | Стоп по потолку — каким статусом | **✅ ЗАКРЫТ D39.100** (`paused` + оповещение) |
| К-9 | Отказ прескрина не выразим статусами | **✅ ПРИНЯТО ВЛАДЕЛЬЦЕМ 16.08:** прескрин — ещё одна ПРИЧИНА, а не двенадцатый статус: `rejected` + ОДИН грубый код (`content_refused`), максимально абстрактно, без вариации между попытками (§8а). Заведено 0.3.0 |
| К-10 | Пофазность у главы | **✅ ЗАКРЫТ — вердикт «НЕ строить»** (D39.138, поправка приёмки research/28 №1). Пофазных счётчиков на главу не будет: фаз на проводе нет. Исходная жалоба («дерево читает ноль всю первую волну») лечится СЕГМЕНТНОЙ логикой `Chapter.units_done` — §2.5 |
| К-11 | Условная обязательность полей | **ОСТАТОК ИНСТРУМЕНТАЛЬНЫЙ.** 0.3.0 применил правило «схема + слова» к трём местам (`BankDecision.dst`, `Unit.target`, агрегаты `BankPage`) и сделал адресацию `Note` обязательной. Остаток — генератор игнорирует `if`/`then`; лечится сужением на шве клиента (S5) |
| К-12 | Завершение выгрузки: опрос или событие | **✅ ОТВЕЧЕН P0 (опрос)**, подтверждён AIP-151 («The response must not be a streaming response»). 0.3.0 добавил то, без чего опрос не завершался: `state` вместо булева `ready`, `failure_code`, `expires_at` |
| К-13 | `paused_reason` не различает две беды | **✅ ЗАКРЫТ D39.132 п.2а** (`null` на проводе ратифицирован). 0.3.0 добил остаток: описание требовало «`null` in every other state», что противоречило ратифицированному «`paused` + `null`» |
---
## 5. Ревью-вопрос: **«Сменится устройство пайплайна — придётся ли править фронт?»**
**Прежний ответ этой таблицы был ОПРОВЕРГНУТ ИСПОЛНЕНИЕМ** (research/28 Б-0) и здесь не
сохраняется даже как история — он учил зону неправде. Что на самом деле давала редакция 0.2.3, если
в движок добавляли волну (реальный `format.ts` собран через vite и вызван):
- третья волна приезжала как новое поле, клиент «игнорирует неизвестные поля» — и
`translatedPercent` отдавал **100 %**, когда треть работы не сделана. Молча;
- переименование фаз или снятие редактуры → **TypeError в рендере** (`Status.tsx:39`, `About.tsx:96`),
а `ErrorBoundary`/`errorElement`/`componentDidCatch` во фронте нет ни одного (грепом пусто);
- незнакомую волну платформа тихо дропала (`pgstore/sink.go:206-208`) — прогресс занижался без ошибки;
- «правок ноль» на деле означало миграцию БД + шов + спеку + регенерацию типов (её принуждает
дрифт-тест `frontend/src/api/contract.test.ts:30-37`).
**Ответ редакции 0.3.0 — и теперь он верен, потому что чинили ПРИЧИНУ, а не формулировку:**
| Изменение в движке | Правит ли фронт |
|---|---|
| переименована стадия / добавлена/снята волна | **нет** — числа волн на проводе больше нет: одна полоса до ближайшей остановки, знаменатель — купленный объём (§2.5). Пофазный сплит остаётся ВНУТРИ платформы, колонки не трогаются |
| сменилась модель, маршрутизация, температура, промпт | **нет** — в allowlist не входят |
| добавлена новая причина флага | **нет** — на провод идёт КОД, карта живёт в контракте (приложение А); незнакомый код → нейтральная фраза, правило записано в схеме |
| добавлена новая причина отказа запроса | **нет** — второй уровень `cause.code` не закрыт по замыслу; клиент матчит корневой `code` |
| добавлен новый тип термина / новый провенанс | **нет** — словарь расширяется минором, ветка неизвестного стоит на шве |
| сменился чанкер, главы пере-разобраны | **нет по коду, ДА по данным — и теперь это ВИДНО:** `structure_version` двигается, курсоры и якоря на пары объявлены недействительными, на исчезнувшую главу отвечает `410`, полная замена — `resync_required`. До 0.3.0 клиент молча рисовал старое дерево |
| добавлено новое ПРОДУКТОВОЕ состояние | **да, один файл** — карта «статус → вид» на шве `src/api/`; это и есть контрольный вопрос владельца |
**Правило, которое отсюда следует и действует на КАЖДУЮ правку контракта (0.3.0):**
> **На проводе нет ни имён стадий/волн, ни движковых словарей — ни в полях, ни в ЗНАЧЕНИЯХ, ни в
> описаниях.** Описания компилируются в исходники клиента как JSDoc генерённых типов, поэтому
> объяснительная проза подпадает под тот же запрет, что и поля. Проверять грепом финальной спеки по
> списку: `draft · edit · wave · stage · mined · miner · ruby · finalizing · chunk · langpack ·
> sanitiz · flagged · verdict · prompt · engine · pipeline · glossar · escalat · snapshot` (список
> открытый — дополнять по мере находок). Единственное легальное вхождение — сама формулировка этого
> запрета в шапке спеки.
>
> **Гейт под это правило — тестом, по образцу языкового `generality.test.ts`** — половина ФРОНТА:
> `.spectral.yaml` держит только `spectral:oas`, а `generality.test.ts` ловит лишь языковую
> специфику, то есть утечку конвейера не стережёт ничто. Носитель: пинг фронту 16.08 в
> `frontend/docs/frontend-PROGRESS.md`, исполнение при разморозке зоны (D39.136 п.2).
---
## 6. Направления: что решено НЕ делать в 0.3.0
Записано направлениями, чтобы не превратиться в молчаливые дыры. **Номеров бэклога здесь не
выдумывается:** где строки нет, носителем назван документ.
| Что | Почему не в батч | Носитель |
|---|---|---|
| `POST /books/{id}/parts` — дописать главы в существующую книгу | форма и движковый гейт против МОЛЧАЛИВОЙ перекупки хвоста при вставке не в конец — отдельная работа вместе с этапом структуры глав | **строка 185** |
| Снятие жанра из брифа и промптов | двигает `BriefHash` ⇒ только в общее resnapshot-окно | **строка 184** |
| Сжатие и условные чтения НА ПЛАТФОРМЕ | контракт их объявил; включение — работа платформы | **строка 186** |
| Сворачивание замечаний в тексте под кнопку | зона фронта, при разморозке | пинг фронту 16.08 |
| История прогонов книги | **снята решением владельца 16.08: «в МВП не нужно».** Данные уже в Postgres и не удаляются, индекс `runs (book_id, started_at desc)` стоит — если поддержка попросит, это один read-путь | research/28 §8 п.10 |
| Двухшаговая загрузка (метаданные JSON → `PUT` байтов) | три из четырёх greenfield-дизайнов выбрали её, и при ней проблема порядка частей не существует. Ц3: переписывается построенный путь с обеих сторон. Направление на после-беты | research/28 §5 «не в батч» |
| Лента изменений структуры (`added\|removed\|moved\|split\|merged`) с наследниками | дизайн-пак структуры глав | **строка 161** |
| Поиск и фильтр по банку и дереву | нужны индексы (Ц2); сегодня клиент ищет в браузере, и это осознанно | research/28 §5 «не в батч» |
| «Грубая группа статуса» для эволюции словаря | эволюционный механизм без сегодняшней боли | research/28 §5 «не в батч» |
| Словарь ступеней замечаний | К-6: владелец — «решим потом» | research/28 §8 п.11 |
| Добавочные поля `BankTerm` (evidence/variants/conf, aliases) | слово владельца 15.08: «НЕ заводить … смысла хватает» | D39.136 п.4б |
| **Поток на БИБЛИОТЕКУ** (одна лента на все книги аккаунта) | поток книжный (§5 п.5 заказа — «перевесить на книгу»); экран библиотеки обновляется чтением, которое под условным чтением стоит заголовков. Ленты на библиотеку нет, и открывать поток на строку списка клиент НЕ должен — это записано нормой в спеке. Найдено холодным потребителем как реальный перф-вопрос | research/28 §5б · строка 186 |
| **Чтение ОДНОЙ главы** `GET /books/{id}/chapters/{chapterId}` | сегодня кадр `chapter` про главу, которой клиент не держит, игнорируется (норма записана), а `410` лечится перечитыванием дерева. Операции нет — она не в заказе; вопрос дизайн-пака структуры глав | **строка 161** |
| **Список экспортов** `GET /books/{id}/exports` | Б-4 просил СОСТОЯНИЕ экспорта, не список. Восстановление после перезагрузки закрыто иначе — `Idempotency-Key` на создании возвращает исходный `202` с тем же `Location` | research/28 §2 (Б-4) |
| Новое число страницы замечаний | **замера нет ни одного**: сколько замечаний даёт настоящая книга, не знает никто. 500 выбрано без числа, и выдумывать второе число вместо первого — та же ошибка. Число ушло из спеки в `Capabilities.page_size_default`; калибровка — после первого настоящего прогона | research/28 §7, §10 |
### 6а. Что РЕЗАЛОСЬ по §5а и что резать отказались
Снято: `Problem.instance` (структуры на платформе нет; функцию несёт `request_id`) · кадр
`EventCeiling` целиком (единственное поле `halted` всегда `true`, дубль кадра `status`, который уже
несёт `paused_reason`) · `EventHello.run_id` (поток теперь книжный) · `EventResyncRequired.reason` ·
схема `Counter` (одна полоса вместо двух) · `Book.genre` и `BookIntake.genre` (Б-23) · значение
`finalizing` · прогонный поток `GET /runs/{id}/events` (перевешен на книгу) · зашитые числа размеров
страниц из прозы операций · генезис-проза (история ратификации semver, две апологии RFC 9110,
объяснение имени `source`) — **ужата НА МЕСТЕ до ссылки на источник, а не перенесена сюда**: в спеке
остались `semver §4` и одна строка про `Retry-After` на `200`, целиком выброшенного текста нет.
Формулировка «перенесена в этот файл» держалась ровно один раунд и исправлена дофиксом (ФБ-10):
проверяется грепом — этих абзацев в компаньоне нет.
**Резать отказались, с контраргументом на каждое:**
- **параметр `limit`** («клиент не отправил его ни разу»). Тот же §5 требует объявить у него
`maximum` и подрезание вместо понижения (Б-10) — у удалённого параметра максимума не объявишь.
Плюс: «референсный клиент не шлёт» — свойство ОДНОГО клиента, а контракт пишется для второго.
- **`Note.unit_id`** («только в фикстуре мока; экраны берут `unit.note` вложенно»). Основание — «ни
один экран не читает», ровно тот довод, который эррата 16.08-г уже опрокинула на `TermStatus`:
экранов замечаний (S6) ещё нет. Адресация на пару — единственный способ перейти к МЕСТУ замечания
из плоского списка, а собственная фраза операции говорит «A note addresses a unit or a whole
chapter». Поле оставлено НЕОБЯЗАТЕЛЬНЫМ (Б-9 просил ровно это), а обязательным сделан `chapter_id`
— то есть дефект «замечание, не адресующее ничего» закрыт.
- **`Bank.signed`** («доезжает до клиента и не рисуется»). Тот же довод «нет экрана» — экран подписи
это S5. `total`, `signed` и `pending_decisions` — три НЕЗАВИСИМЫХ факта: строку можно решить и не
подписать (отклонить), поэтому `signed` не выводится из двух других.
- **нагрузка кадров `note` и `bank`** («передаётся и игнорируется»). Игнорировалась она по причине,
которую батч устранил: у замечания не было id, поэтому кадр нельзя было сопоставить со списком.
С `Note.id` кадр `note` несёт ПРИМЕНИМУЮ ДЕЛЬТУ — это ровно первая ветка правила Б-11а, и снятие
нагрузки вернуло бы перечитывание всего списка. Счётчики `bank` — вторая ветка того же правила
(«счётчик + скоуп»), а строки читаются дельтой `?after_version=`.
---
## 6б. Дофикс-раунд 16.08 (заказ приёмки D39.142): что изменилось в каноне и почему
Десять находок ХОЛОДНОГО ПОТРЕБИТЕЛЯ — линзы, которой у селф-ревью батча не было: только финальная
спека и проба генератором, без ревью, без компаньона, без диффа. Ниже — не пересказ правок, а их
основание; сами правки в каноне.
**Форма кадра была двусмысленна (ФБ-1).** `EventEnvelope{event,id,data}` читался и как объект на
проводе, и как описание SSE-фрейминга — оба чтения соответствовали тексту, и клиент по второму
прочтению искал бы JSON с полем `data` внутри. Теперь на схеме стоит дословный пример кадра и
сказано прямо: `event` и `id` — ПОЛЯ SSE, телом кадра является `data` и только оно. Это тот класс
дефекта, который не ловится ни линтером, ни генератором: документ валиден в обоих прочтениях.
**Книга в покое могла крутить переподключение вечно (ФБ-2).** Служебные кадры несут id последнего
кадра истории — а у книги, которая ещё ничего не производила, такого кадра нет. Клиент оставался без
`Last-Event-ID`, каждый его запрос был «новым потоком», сервер отвечал `hello`+`end`, браузер
переподключался — и так по кругу. Закрыто самой дешёвой из возможных мер: история нумеруется с `1`,
служебные кадры пустой книги несут `0`, а `Last-Event-ID: 0` на книге в покое попадает под уже
существующее правило `204`. Последовательность стала конечной: `hello``end` → закрытие → одно
переподключение → `204` → браузер останавливается.
**Обещание «`note` не теряется» не переживало разрыв (ФБ-3).** Внутри одного соединения кадр-добавление
защищён запретом склейки; между двумя соединениями — ничем: буфер не обещан, `resync_required` за
обычный реконнект не полагается. Обещание не расширено (буфер обещать нечем), а названа обязанность
клиента: после КАЖДОГО переподключения — дельта-чтение `/notes?after_version=…`. Механизм для этого
уже был; не хватало записи, что он обязателен, а не удобен.
**Пустые члены `allOf` давали необитаемые типы (ФБ-5).** `EventEnd` и `EventResyncRequired`
описывались как `EventBase` плюс пустой объект — генератор выводил `Record<string, never>`, и
пересечение становилось типом, значение которого построить нельзя. Заменено на чистый `allOf` из
одного члена с описанием на самой схеме; проверено генератором — оба теперь `EventBase`. Попутно
записано то, что раньше подразумевалось: эти два кадра НЕРАЗЛИЧИМЫ по форме, диспетчеризация только
по имени события.
**«Новый прогон» предписывался, а условия — нет (ФБ-6).** Спека велела предлагать новый прогон как
лечение стопа по потолку, но нигде не перечисляла, что делает `resume` в каждом останавливающем
статусе, а `paused_reason: null` описывался как «нейтрально, продолжаемо» — из чего клиент мог
заключить, что при `null` надо звать `resume`. Добавлена таблица по всем статусам и сказано прямо:
новый прогон легален при ЛЮБОМ `paused`, включая `null`; причина паузы — подсказка о прошлом, а не
разрешение на будущее. Значение enum при этом не заводилось — ратификация D39.132 п.2а не двигается.
**Пол под моделью ошибок (ФБ-7).** Весь §Errors описывал ответы, которые СООТВЕТСТВУЮТ контракту;
что делать с ответом прокси, HTML-страницей или оборванным телом — не говорил никто, а именно там у
клиента нет ни `code`, ни `request_id`. Записано правило того же вида, что и для известных кодов:
не показывать из такого ответа ни байта, рисовать свою нейтральную фразу.
**Пачка мелких (ФБ-8).** `title: null` в merge-patch — объяснено, почему это `400`, а не удаление
члена по RFC 7386 (книги без названия у этой поверхности не бывает) · `X-TM-Client` — записано, что
`required: true` стоит при живом исключении для bearer, потому что условной обязательности по схеме
безопасности OpenAPI не выражает, и валидатор не должен читать законное отсутствие как нарушение ·
список отказов интейка помечен неисчерпывающим (авторитет — `code` ответа) · правило резолюции
`Location` · тождество `Idempotency-Key` на multipart считается по объявленным частям, а `408` не
считается состоявшейся попыткой · `min_chapters` при `max_chapters: 0` — не диапазон, клиент проверяет
максимум первым · порядок пяти носителей «нет кредита» · «carried forward marked as unverified» — снято
как обещание без носителя, заменено на наблюдаемое (`BankPage.signed` против `total`) · `409` у
экспорта объяснён как ключевой, а не книжный · неизвестный `term_id` в подписи отклоняет весь батч, а
не молча пропускает строку.
**Слова, которые называли не то (ФБ-9).** `parser_unavailable` называл наш компонент — переименован в
`processing_failed`, по эффекту: имя значения это то, на что клиент вешает фразу. ⚠ Проводное имя
теперь отличается от внутреннего словаря платформы — там значение зовётся `parser_unavailable`
(`platform/internal/books/parse.go:66`), и проекция обязана отобразить одно на другое; рядом там же
живут `schema_mismatch` и `storage_unavailable`, которые на провод не идут вовсе. Вход P7. Из описаний вычищены внутренние ссылки (`research/28 §…`,
«companion К-6»): описания компилируются в исходники клиента, и ссылка на ревью в чужом репозитории
там — мусор; носители остались здесь.
## 7. Эксплуатационные примечания — НЕ норма контракта
Вынесено из спеки в 0.3.0 (Б-16). RFC 9205 §4.1 прямо про наш случай: «Requiring a particular version
of HTTP … harms interoperability. Therefore, it is **NOT RECOMMENDED** that applications using HTTP
specify a minimum version … However, if an application's deployment benefits from the use of a
particular version of HTTP (for example, HTTP/2's multiplexing), **this ought be noted**». Отметить, а
не потребовать. Спека 0.2.3 требовала («Required: HTTP/2 at the edge») и держала в норме вендорный
заголовок конкретного прокси.
Норма в спеке — то, что клиент НАБЛЮДАЕТ и на что вправе рассчитывать: `ETag`/`If-None-Match`/`304`
· **обязанность честить `Accept-Encoding` на JSON-ответах и НЕ сжимать `text/event-stream`** · `Vary`
на согласованном представлении · `Cache-Control: no-store` · запрет буферизации потока · форма
heartbeat · форма `id` кадра. Сжатие как ТРЕБОВАНИЕ живёт в контракте — это прямое указание D39.138
п.2(д) («сжатие и условные чтения записываются В КОНТРАКТ, не в зонный док»), и первая редакция
батча его нарушила, вынеся сжатие целиком в примечание; поймано опровергателем полноты и исправлено.
В примечании остаётся ТОЛЬКО слой исполнения — где именно сжимать, — потому что вот этого клиент
действительно не наблюдает, и вот это Б-16 из контракта и выносит.
Ниже — то, что клиент не наблюдает и что деплой обязан себе устроить сам:
- **Чем и на каком слое сжимать** (middleware, обратный прокси, CDN). Go stdlib не сжимает,
edge-конфига в репозитории нет, `grep -rn "gzip|Content-Encoding" platform --include=*.go` → ноль.
- **HTTP/2 на edge.** Клиент держит одно SSE-соединение на книгу; на HTTP/1.1 шесть соединений на
origin — потолок вкладок. Сервис на HTTP/1.1 обязан работать ХУЖЕ, а не не работать.
- **`X-Accel-Buffering: no`** (или эквивалент прокси) — механизм для нормы «поток не буферизуется».
- **Сколько это даёт.** Замер ревью на настоящей книге (`~/books/gu-zhenren`, 2283 главы): глава
26,1 КБ → 10,4 КБ; дерево глав 250 КБ → 39 КБ; банк 1000 строк 167 КБ → 17 КБ.
- **Порядок работ по эффекту на килобайт усилия** (research/28 §5б, строка 186): сжатие → `ETag`/304
→ скоуп в кадре → дельта-чтения `?after_version=``staleTime` у клиента. Шаги 34 — уже в
контракте (0.3.0), шаги 12 — конфигурация, шаг 5 — зона фронта.
- **Транспорт НЕ меняется** (проверено независимым агентом другого тира + сверка первоисточников):
наша нагрузка — художественный текст, и после gzip разница между JSON и protobuf единицы процентов;
gRPC-web требует прокси и теряет `If-None-Match`/304; Connect возвращает то, что у нас уже есть.
Наш паттерн «кадр без данных → клиент перечитывает» легитимен и называется poke/pull.
---
## Приложение А. Карта «причина → КОД контракта → продуктовая фраза» — ЗАГОТОВКА
**Что изменилось в 0.3.0.** Прежняя карта вела «причина движка → фраза», то есть предполагала, что
ФРАЗУ рисует сервер. Это ровно та политика, которую вариант B отменил для ошибок (§2.17), и держать
её для замечаний значило бы оставить на проводе серверную локализованную строку — второй русский
текст, приходящий из зоны, у которой нет ни `Accept-Language`, ни локалей. Поэтому `Note` несёт
**`code`**, а фразу рисует клиент; `Note.message` с провода снят.
**Правила заполнения.**
1. Фраза пишется по ДОККОММЕНТУ `disposition.go`, а не по имени константы, и рядом кладётся цитата —
иначе повторяется инверсия, стоившая двух фраз (`glossary_miss` подан как «термин не подписан»,
хотя термин ПОДПИСАН и его проигнорировали, `disposition.go:78-79`; `sanitizer_stripped` подан как
потеря текста, хотя «the chunk is NOT lost», `disposition.go:99`).
2. **Класс 2 схлопывается в ОДИН код** (§8а, нормативно): четыре причины ранга 0 — модельный отказ —
на проводе неразличимы, потому что каждый различимый код здесь бит обратной связи подбирающему.
3. Слова — владельца (ПТ-33, В-3, строка 148). **Коды ниже — ◆ ПРЕДЛОЖЕНИЕ**, ратифицируются вместе с
фразами; до тех пор `Note.code` в спеке НЕ enum, чтобы схема не стала второй копией незаписанной
карты.
4. Последняя строка — не формальность: контракт обязан иметь фразу для причины, которой ещё не
существует, и она обязана читаться нейтрально, а не как «ошибка».
| Причина движка | Ранг | Код контракта ◆ | Продуктовая фраза | Ступень |
|---|---|---|---|---|
| `hard_refusal` · `soft_refusal` · `content_filter` · `hard_block` | 0 | `content_withheld` (ОДИН на все четыре — класс 2) | ⬜ | ⬜ |
| `cjk_artifact` | 1 | `source_residue` | ⬜ | ⬜ |
| `excision_suspect` | 1 | `text_possibly_dropped` | ⬜ | ⬜ |
| `coverage_fail` | 1 | `incomplete_coverage` | ⬜ | ⬜ |
| `sanitizer_defect` | 2 | `markup_defect` | ⬜ | ⬜ |
| `loop_degenerate` | 3 | `repetition` | ⬜ | ⬜ |
| `decode_error` | 4 | `unreadable_answer` | ⬜ | ⬜ |
| `glossary_miss` | 5 | `term_not_applied` | плейсхолдер: «Подписанный термин не применён в переводе» | ⬜ |
| `length` | 6 | `length_mismatch` | ⬜ | ⬜ |
| `empty` | 6 | `empty_answer` | ⬜ | ⬜ |
| `sanitizer_stripped` | 7 | `markup_cleaned` | плейсхолдер: «Служебная разметка вычищена автоматически» | ⬜ |
| `upstream_not_ok` | 8 (по умолчанию) | `unavailable` | ⬜ | ⬜ |
| незнакомая причина | 8 (по умолчанию) | — (клиент рисует нейтральную фразу по правилу схемы) | ⬜ нейтральная, НЕ «ошибка» | ⬜ |
Причин пятнадцать; `upstream_not_ok` не имеет своей ветки в `flagReasonSeverity` и падает в ранг по
умолчанию (`pipeline/status.go:174`), как и любая будущая причина.
### Приложение А-2. Карта кодов ОШИБОК — заполнена (0.3.0)
В отличие от карты выше, эта заполнена целиком: словарь выведен из реальных ветвей платформы, а фраз
она не содержит по замыслу — их рисует клиент.
| Корневой `code` | HTTP | Откуда взят (платформа) |
|---|---|---|
| `invalid_request` | 400 | `v0.go:238,250,333,339,531` · `pgstore.ErrBadCursor` · `books.ErrBadIntake` (`books.go:118-121`, `readField` `v0.go:394-396`) · `v0.go:242` (неполное тело) · `v0.go:345,356,419,423` (интейк) |
| `unauthenticated` | 401 | гард сессии (`server.go:109-110`) |
| `forbidden` | 403 | `server.go:99` + `auth/csrf.go` (отсутствие `X-TM-Client` / чужой origin) |
| `not_found` | 404 | `pgstore.ErrNoBook`/`ErrNoAccount`/`ErrNoRun` · охраняемый catch-all `server.go:134` |
| `request_timeout` | 408 | `os.ErrDeadlineExceeded``v0.go:417` |
| `payload_too_large` | 413 | `*http.MaxBytesError``v0.go:409` |
| `run_in_flight` | 409 | `pgstore.ErrRunInFlight` (`v0.go:548`) |
| `book_not_ready` | 409 | `runs.ErrBookNotReady` (`v0.go:550`) |
| `run_not_stoppable` | 409 | `runs.ErrNotStoppable` (`v0.go:554`) |
| `run_not_resumable` | 409 | `runs.ErrNotResumable` (`v0.go:556`); `cause`: `ceiling_reached` (⚠ `bank_decisions_incomplete` удалён D39.144 — гейта полноты нет; построенный гейт `reconcile.go:934-938` ДЕМОНТИРУЕТСЯ в P7) |
| `ceiling_unavailable` | 409 | `runs.ErrCeilingOutOfBounds` + `pgstore.ErrInsufficientCredit` (`v0.go:558`); `cause`: `bounds_moved` · `credit_held`; несёт `blocked` |
| `idempotency_conflict` | 409 | форма заведена батчем; реализация — P7 |
| `content_refused` | 400 | К-9; прескрин не построен (ПТ-16, строка 94). Отказ целой КНИГИ приходит не сюда, а состоянием `rejected` + `reject_reason` |
| `service_unavailable` | 503 | `runner.ErrCeilingNotWired` + `runs.ErrRunnerIncomplete` (`v0.go:562`) |
| `internal_error` | 500 | `v0.go:518,572` · `middleware.go:68` |
**Коды, которых платформа сегодня достигает, а контракт до 0.3.0 не объявлял:** 403 (`server.go:99`),
500 (`v0.go:518,572`), 431 от `net/http` (`serve.go:79`), 429 на `/auth` (`login.go:173,234`). Из них
контракт объявляет 403 (это нарушение правила, которое ИЗОБРЁЛ сам документ) и не объявляет 500
(Zalando: «500 Internal Server Error · use · **do not document**»), 431 (уровень stdlib) и 429 (на
`/v0` лимитера нет вовсе — форвард-вопрос платформы). `405` на `/v0` не бывает: catch-all глотает
метод.