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

350 lines
36 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)
СТАТУС: РАТИФИЦИРОВАН как контракт 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 —
платформа — ОТВЕЧЕНЫ P0 и ратифицированы приёмкой 04.08 с поправками (носитель — зонный журнал
`platform/docs/platform-PROGRESS.md`, раздел «Ратификация приёмкой»); К-2/К-9/К-10/К-11 — автор контракта/бэкенд.
Зависимости §3 покрыты строками единого бэклога: 99/100/101/103 (перевешены в
БЛОКЕРЫ контракта) + новые 125 (банк-экспорт) · 126 (поднятие потолка) · 127
(DDL-коммент). Приложение А — заготовка: фразы и ступени заполняет владелец (В-3).
Тело ниже — авторский текст фронт-сессии S3 (два раунда дофикса), не переписано.
ПОСТ-РАТИФИКАЦИЯ (решения владельца 04.08 вечер, D39.100): К-1 ЗАКРЫТ (десять
статусов приняты) · К-8 ЗАКРЫТ (механизм «как Claude Code»: BookStatus получает
11-е значение `paused`, оповещение «лимиты исчерпаны», страница лимитов в
настройках — ПТ-35/П-5; суммы денег на провод по-прежнему не идут) · К-5 ЗАКРЫТ
(ETA показывать: `eta_seconds` в спеку) · К-3 ЗАКРЫТ (метка главы — из ДАННЫХ
книги; зашитой формы «Глава N» не существует — легальна книга без номеров глав) ·
К-6 — принцип принят (две ступени по читательскому эффекту; карта — с В-3 позже).
Правки спеки исполняет S3 (блок в её промте) с минорным бампом версии; эта копия
пере-ратифицируется диффом при лендинге.
====================================================================== -->
# Контракт 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-модели и продуктовые словари. Транспорт платформы (пути, аутентификация, коды) и
> работы движка (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`),
как и любая будущая причина. Последняя строка — не формальность: контракт обязан иметь фразу
для причины, которой ещё не существует.