textmachine/docs/research/28-contract-review.md

1670 lines
198 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.

# 28 — Контракт-ревью API v0 (фронт ↔ платформа): доклад к батчу 0.3.0
> **⟶ СТАТУС (проставлен 05.09): ОТЧЁТ ОТРАБОТАЛ, ЕГО НАХОДКИ РАЗОБРАНЫ ПОШТУЧНО — но сам он
> ратификацией НЕ ЯВЛЯЕТСЯ.** Куда уехали выводы: К-статусы и Приложения — компаньон канона
> `docs/architecture/14-api-contract/README.md` (там же, что закрыто и чем); Б-находки — строки бэклога.
> ⚠ Читать через компаньон: часть предложений отчёта была отклонена или перекрыта минорами 0.4.00.10.0.
> **Заказ:** D39.136 п.5 (слово владельца 15.08), промт `docs/CONTRACT_REVIEW_SESSION_PROMPT.md`, отдельная
> сессия (эррата 15.08-в: author≠reviewer — оркестратор соавтор ратификаций 0.2.3).
> **Предмет:** `docs/architecture/14-api-contract/openapi.yaml` 0.2.3 + компаньон.
> **Мандат:** «ратифицировано ≠ правильно». **Сессия ничего не правит:** ни спеку, ни код, ни бэклог,
> ни D-лог. Единственный файл сессии — этот. Дерево не коммичено.
> **Метод:** инвентарь кода трёх зон (639 фактов с `file:line`) · greenfield-панель из 4 проектировщиков,
> НЕ видевших спеку · постатейная сверка с RFC 9110/9111/9205/9457, WHATWG SSE, semver, AIP-151/155/158/193,
> Zalando, Microsoft, Stripe, GitHub (тексты скачаны, цитаты — дословные подстроки) · адверсариальное
> судейство каждого буллета (аудитор улик + адвокат нынешней формы, разными моделями).
> **⟶ ЖИВАЯ ГОЛОВА: 16.08 владелец разобрал доклад и принял решения — §8.** Там же то, что решено, что
> отложено и что уходит в общий бэклог. Сессия отчёта ничего не правит и не заводит: строки заводит
> оркестратор.
>
> **Куда смотреть, если вы исполнитель, а не читатель:** §8 — что решено владельцем и что это значит ·
> §8б — что должен сделать оркестратор при лендинге · §5 — состав батча 0.3.0 по порядку исполнения ·
> §5а — что резать · §5б — транспорт и сеть · §9 — находки чужих зон, которые ждут своих сессий.
> Буллеты §2 — обоснования, к ним ходят за «почему».
> **⟶ РЕВЬЮ-ШАПКА ПРИЁМКИ (оркестратор №17, 16.08.2026; ратификация — D39.138). ПРИНЯТ.**
> Верификация: четыре независимых опровергателя (2×fable на денежно-критичном, 2×opus) пере-открыли
> ИСПОЛНЕНИЕМ 42 улики самых несущих блоков (Б-0/Б-13а · Б-1/Б-15 · Б-2/Б-3/Б-23 · Б-7а-п.4/Б-19а) —
> **ни один клейм не опровергнут по существу**; выборочные проверки оркестратора поверх (X-Request-Id ·
> no-store на всех ответах · «epoch» ровно один блок · 16 operationId) — сходятся. Единственная правка
> тела при лендинге — МЕХАНИЧЕСКАЯ: 9 фронтовых якорей получили префикс `frontend/src/` (линтер якорей
> counts.py --lint; смысл не тронут). Поправки приёмки (читать тело через этот список):
> 1. **К-10 в §6 противоречит Б-0/§8 п.1:** «закрывается бесплатно правкой проекции» дало бы пофазность
> у главы, а решение владельца снимает фазы с провода — К-10 закрывается «НЕ строить».
> 2. Б-13а: «величины „глав сделано" на поверхности нет вообще» — смягчить: клампованная производная
> уходит через `run-options.max_chapters = min(affordable, chaptersLeft)` (`pricing.go:83`); клиент
> не отличит её от денежного капа, вывод буллета не двигается.
> 3. Б-5: «путь удаления достижим только из свипа» неверно — `abandon` достижим и из веток отказа самого
> интейка (`books.go:163,180`); суть («нет пользовательской ручки») верна.
> 4. Б-23: жанр — в 6 из 7 top-level ролевых промптов; файлов у пары 11 (repair-хвост без жанра).
> 5. Б-19а/§9: код банка — `backend/internal/membank/memory.go` (НЕ `pipeline/memory.go`), номера строк
> верны; O_EXCL стоит на файле исходника, не на директории; лживых комментариев ДВА —
> `snapshot.go:188-189` и докстринг `membank/memory.go:360-363` (оба до-pack-20; строка 187).
> Усиления от опровергателей: bearer-предъявление — ВТОРОЙ недокументированный CSRF-карваут
> (`csrf.go:55-56`); грант уже 0 при неверифицированном email (`login.go:306-308`); `required:` у
> `BookIntake` тоже ставит `file` первым (`:905`); ja-мина Б-19а — громкий `--resnapshot`, не тихая
> перекупка (mined-строки вне base-версии драфт-волны, `membank/memory.go:349`).
> Ограничение метода (честно заявлено сессией в §7/§10): опровергатели — семейство Claude; кросс-СЕМЕЙНЫЙ
> проход — отдельный заказ. Решения §8 перенесены в D-лог нотой D39.138 (по §8б п.1); состав батча §5 —
> носитель для исполняющих сессий, строка бэклога 183.
> 6. **Пост-ревью автора отчёта (16.08, по заказу владельца — ревью выданных промтов):** ⚠ Б-0, Б-11а и
> Б-23 добавлены ПОСЛЕ раунда адверсариального судейства — единственные три буллета без опровергателя
> исходной сессии; их основания проверять строже. Уже опрокинуто одно: **«TermStatus снять с провода»
> (Б-0 п.2 рекомендации) — НЕВЕРНО**, обоснование «ни один экран не рисует» подменяло «экрана подписи
> ещё нет»: ось продуктовая (`:1070-1072` — главный фильтр S5; шов клиента потребляет,
> `vocabulary.ts:159-163`) — ось ОСТАВИТЬ, слова переименовать (эррата 16.08-г D-лога). Вторая дыра
> носителя: Б-11а не входит в перечень §5 — правило кадра (скоуп/дельта-или-счётчик) и `?after_version=`
> (§5б шаги 34) заказаны батчу поверх §5 (промт батча, правка 16.08).
## 0. Резюме
**Контракт здоров в ядре и дефектен на периферии.** Ядро — то, что построено обеими сторонами и прошло
контакт с кодом (библиотека, карточка книги, старт прогона, шкала потолка, прогресс, деньги вне провода)
— независимая greenfield-панель воспроизвела почти дословно (§3). Дефекты сосредоточены там, где контракт
писался ВПЕРЁД, без потребителя: ошибки, поток, экспорт, замечания, структура глав.
**Кандидатов было около 75; выжило 29 буллетов (24 CONFIRMED, 4 PLAUSIBLE, 1 решён владельцем),
отвергнуто 29.** Отвержения — тоже результат: девять опираются на ратифицированные решения владельца
(три приняты 15.08, в день заказа ревью), четыре суть мои ошибки чтения стандарта или кода. Часть
отвергнутого переформулирована и вернулась в §2 в узком виде, поэтому суммы не сходятся арифметически.
Три буллета (Б-0, Б-11а, Б-23) добавлены 16.08 по вопросам владельца — то есть разбор доклада дал
находок больше, чем некоторые линзы ревью.
**Главное — одно.** Причина отказа не существует в машинном виде: `type` — константа `about:blank` на
каждом ответе, `detail` не отправляется никогда, а единственный различитель — английская фраза, которую
контракт предписывает показывать пользователю «as-is». Это одновременно нарушение RFC 9457, корень Ф-61
и стена для ПТ-36. Всё остальное дешевле.
**Момент.** Восемь операций из шестнадцати не построены НИ ОДНОЙ зоной сервера, а зона фронта заморожена
(D39.136 п.2) и живёт на моках. Правка их формы стоит сегодня спеки и моков; после P7 — воркера,
миграции и экрана. Второй такой дешёвый момент не наступит.
---
## 1. Карта фактов: что стоит на земле под контрактом
Построена чтением кода ДО чтения компаньона и D-нот (порядок против якорения).
### 1.1. Контракт объявляет 16 операций на 15 путях; платформа отвечает на 8 из них
`contractRoutes` регистрирует восемь путей (`platform/internal/httpapi/v0.go:51-78`):
| Операция контракта | Платформа | Клиент (код) | Экран |
|---|---|---|---|
| `GET /books` · `POST /books` · `GET /books/{id}` | есть | есть | есть |
| `GET /books/{id}/run-options` · `POST /books/{id}/runs` | есть | есть | есть |
| `POST /runs/{id}/stop` · `/resume` | есть | есть | **кнопок нет** |
| `GET /usage` | есть | есть | **не читается ничем** |
| `/chapters` · `/units` · `/notes` · `/bank` | **нет** | есть | моки |
| `POST /bank/decisions` | **нет** | есть | **экрана нет** |
| `GET /runs/{id}/events` (SSE) | **нет** | есть | моки |
| `POST /exports` · `GET /exports/{id}` | **нет** | **нет** | нет |
Непостроенное отвечает охраняемым `404 «Object not found»` (`platform/internal/httpapi/server.go:134`).
SSE на платформе нет ни строкой (`grep text/event-stream` по не-тестовому Go — только комментарии в
`serve.go:36`, `middleware.go:105`).
### 1.2. Ошибочная поверхность: один литерал и английская проза
`WriteProblem` (`platform/internal/httpapi/problem.go:22-28`) ставит `type: "about:blank"` **всегда**;
`detail` пуст на всех вызовах контрактной поверхности (единственные непустые — два ответа `/readyz`,
`server.go:158,169`); `instance` спекой объявлен (`openapi.yaml:1444`), а структурой `Problem` в коде
(`problem.go:14-19`) не предусмотрен вовсе. Фраз меньше, чем причин: `«Request could not be read»`
шесть разных условий (`v0.go:238,250,333,339,531,547`), `«The upload is incomplete»` — четыре
(`v0.go:345,356,419,423`). Ни `Accept-Language`, ни локали в платформе нет (грепом ноль). Фронт печатает
серверную строку как есть в трёх местах (`frontend/src/showcase/Loaded.tsx:66`, `RunStart.tsx:188`,
`AddBook.tsx:289`) при русском интерфейсе. Рядом: `X-Request-Id` ставится на каждый ответ
(`platform/internal/reqid/reqid.go:16,27`) и контрактом не упомянут.
### 1.3. Коды, которых в спеке нет
Спека перечисляет 200/201/202/400/401/404/408/409/413/503, `default`-ответа нет. На проводе сверх этого:
**403** «Cross-origin request rejected» (`server.go:99`, `auth/csrf.go:38` — он же отвечает на честный
same-origin запрос без `X-TM-Client`); **500** (`v0.go:518,572`, `middleware.go:68`); **431** от net/http
(`serve.go:79`); **429** на `/auth` (`login/login.go:173,234`). `405` на `/v0` не бывает: catch-all
глотает метод (проверено по stdlib go1.26.6 — 405 синтезируется только когда ни один паттерн не совпал).
### 1.4. Интейк
Цикл прерывается на части `file` (`v0.go:337-378`): обязательное поле после файла → 400, необязательное
**теряется молча, ответ 201**. Порог 413 деплойный и не сообщается; дедлайн тела 10 минут, кап 64 МиБ
(`server.go:85`). Идемпотентности нет — каждый POST создаёт новую книгу (`books.go:125`), `Location` не
ставится (`v0.go:384`), а удалить дубликат нечем. Числа зашиты: `maxIntakeField = 1<<10`,
`maxIntakeParts = 16` (`v0.go:299-302`) с комментарием «Neither is the contract's business» — при том что
спека их объявляет (`openapi.yaml:141-142`).
### 1.5. Поток: словари не совпадают
Движок пишет `hello · progress · unit_done · bank_stop · ceiling · spend · finished`
(`backend/internal/runevents/runevents.go:45-61`), `ceiling` несёт `scope: book|day`
(`runevents.go:151-153`), `finished` — исход из шести значений (`:95-102`); платформа `finished`
сознательно не считает авторитетным (`platform/internal/pgstore/sink.go:178-181` — закрытие прогона идёт
от exit-маркера). Словарь контракта другой и терминального кадра не имеет (`openapi.yaml:1304-1313`).
### 1.6. Масштабы и коллекции
Реальная книга — 2284 раздела, 7,78 млн знаков (`docs/product-requirements.md:13`). Библиотека:
`defaultPage = 100`, `maxPage = 1000`, и `limit` выше максимума **тихо понижается до дефолта**
(`platform/internal/pgstore/books.go:510,586-587`). Курсор`base64(scope|время|id)` без структурной
эпохи (`books.go:821-831`). Клиент никогда не шлёт `limit` и всегда обходит коллекцию до конца
(`frontend/src/api/client.ts:87-115`), поиск делает в браузере (`showcase/Bank.tsx:89`, `Goto.tsx:25`).
### 1.7. Данные движка, которые контракт называет иначе
`heading` манифеста — **не** метка из данных книги, а детерминированный рендер «Глава N», и комментарий
кода прямо запрещает подавать его как метку книги (`backend/internal/pipeline/manifest.go:77-86`).
Id юнита нестабилен при смене чанкера, id главы выживает (`manifest.go:102`). Банк-сайдкар несёт
`aliases` (`bankexport.go:72`), стоп-таблица — 14 полей (`mining.go:317-322`). `BankExport.signed` и
`StatusReport.unsigned_bank_terms` считают разное под похожими именами (`status.go:445`).
### 1.8. Read-модель платформы местами богаче контракта
`notes.id` и `notes.created_at` существуют (`platform/internal/pgstore/migrations/00002_readmodel.sql:134-140`),
контракт их не отдаёт. `exports.failed_reason` и `ready_at` существуют (`:188-199`), контракт состояния
отказа не имеет. `chapters.units_draft_done`/`units_edit_done` существуют (`:102-105`), контракт отдаёт
один счётчик. `units` имеют чеки `translated ⇒ target≠''` и `не translated ⇒ target=''` (`:127-128`),
то есть `target` детерминирован, а контракт объявляет его необязательным.
### 1.9. Сеть: как обновляется текст и сколько это весит (замер 16.08)
Раздел добавлен по вопросу владельца «за счёт чего работает „текст меняется на лету“ и не тяжело ли
гонять тексты». В первой редакции доклада перформанс числами не мерился — это был пробел ревью.
**Механика.** Кадр SSE НИКОГДА не несёт текста; он только провоцирует REST-чтение
(`frontend/src/showcase/useRunStream.ts:84-161`): `progress` — чистый патч карточки, ноль чтений;
`chapter` — патч строки + чтение юнитов той главы; `status` — юниты ВСЕХ открытых вкладок + всё дерево
глав + библиотека; `note`/`bank` — перечитывание СПИСКА ЦЕЛИКОМ (дельту применить нечем);
`resync_required`сброс всего мира книги.
**Объёмы**, сериализованы поверх настоящей книги (`~/books/gu-zhenren`: 23,1 МБ, 7,99 млн символов;
манифест движка — 2283 главы, 4276 юнитов, 5071 чанк; кириллица считалась 2 Б/символ, фактически 1,81):
| Чтение | JSON | тот же ответ под gzip |
|---|---|---|
| юниты одной главы (оригинал + перевод) | **26,1 КБ** | 10,4 КБ |
| дерево 2283 глав | **250 КБ** | 39 КБ |
| банк 1000 строк | **167 КБ** | 17 КБ |
| замечания 500 строк | 95 КБ | — |
| вся книга юнитами | **~57 МБ** | — (такой ручки в контракте нет намеренно, `:224-227`) |
Сходится с независимым фикстурным замером платформы (2284 главы = 289 КБ,
`platform/docs/archive/platform-PROGRESS-P0-P3.md:910-911`). Основание цифры «глава» я пере-проверил
сам: `~/books/gu-zhenren/minirun/export.json` — 14 юнитов на 10 глав, 94 490 символов перевода,
174 217 Б JSON, то есть ~17,4 КБ перевода на главу; плюс исходник (CJK, 3 Б/символ) даёт названные 26 КБ.
**Фан-аут одного кадра `status`:** 1 вкладка — 282 КБ, 12 — 562 КБ, 20 — 767 КБ (замер зоны: «20 вкладок
это 20 чтений на кадр», `frontend/docs/BACKLOG.md:48`; пиннится тестами `useRunStream.test.tsx:271,289,302`).
**Гасителей нет ни одного.** `grep -riE "etag|if-none-match|304|gzip|content-encoding|brotli"` по
контракту и по `frontend/src` — ноль. Сжатие и ETag названы требованиями к платформе только в её
собственном доке (`platform/docs/PLATFORM_DIRECTION.md:202-205`), в контракт не внесены; stdlib
`net/http` не сжимает, edge-конфига в репозитории нет.
---
## 2. Буллеты
Форма: **вердикт · факт с уликами · чья боль · цена · рекомендация · контраргумент** (для «оставить как
есть» контраргумент обязателен). Классы цены по построенному:
**Ц0** операция не построена ни платформой, ни экраном → правка спеки и моков ·
**Ц1** правка проекции платформы без миграции · **Ц2** миграция read-модели или новая ручка ·
**Ц3** переписывается построенный путь с обеих сторон.
---
### Б-0 (CONFIRMED · HIGH). Контракт знает устройство конвейера: число волн и их имена — обязательные поля провода
Буллет заведён по прямому вопросу владельца 16.08: «насколько контракт, фронт и платформа знают о
внутренностях пайплайна? Они не должны знать ничего. Единственная архитектурная точка, которую фронт
вправе знать, — общий банк памяти книги: там пайплайн останавливается и ждёт подписи».
**Что спрятано хорошо (это часть ответа).** Ни одного имени модели, провайдера, температуры, промпта,
langpack — грепом по спеке ноль. Денег нет: только процент и шкала в главах, `EventCeiling` «carries no
figures». Причины флагов движка платформа хранит и не проецирует
(`platform/internal/ingest/events.go:124-127`). Не проецируются `wave` юнита, `engine_run_id`,
`chunker_version`, `spend`, scope потолка. Движок не адресуется ни одним путём. Метка «черновой вариант»
на экране выведена из ПРОДУКТОВОГО статуса (`frontend/src/showcase/format.ts:86-87`,
`isDraft = status !== 'ready'`), а не из волны.
**Что протекает.** `Progress` с обязательной парой `draft`/`edit` (`openapi.yaml:735-738`; `Counter`
там же описан как «Wave counter, in UNITS») — это не абстракция, а сквозной проброс внутренней
структуры движка до React-компонента, пришпиленный в шести местах:
`backend/internal/runevents/runevents.go:195-196` (`WaveDraft`/`WaveEdit`) →
`platform/internal/ingest/events.go:121,130-135` («A closed vocabulary on both sides of the seam») →
SQL-констрейнт `check (wave in ('draft','edit'))`
(`platform/internal/pgstore/migrations/00015_seam_ceiling_and_units.sql:56`) и колонки
`draft_done/draft_total/edit_done/edit_total` (`00002_readmodel.sql:55-58`) →
`wireProgress` (`platform/internal/httpapi/v0.go:92-96`) → спека → генерённые типы →
`frontend/src/showcase/format.ts:109-110` и `About.tsx:96`.
**Компаньон отвечает на собственный ревью-вопрос неверно.** §5 таблицы: «переименована стадия /
добавлена волна → **нет**, фронт не правится». Проверено ИСПОЛНЕНИЕМ (реальный `format.ts` собран через
vite и вызван):
- третья волна приезжает как новое поле — клиент «игнорирует неизвестные поля» — и
`translatedPercent` отдаёт **100 %**, когда треть работы не сделана. Молча;
- переименование фаз или снятие редактуры → **TypeError в рендере** (`Status.tsx:39`, `About.tsx:96`),
а `ErrorBoundary`/`errorElement`/`componentDidCatch` во фронте нет ни одного (грепом пусто);
- незнакомую волну платформа **тихо дропает** (`platform/internal/pgstore/sink.go:206-208`) — прогресс
занижается без единой ошибки;
- «правок ноль» на деле = миграция БД + шов + спека + регенерация типов (её принуждает дрифт-тест
`frontend/src/api/contract.test.ts:30-37`).
Верна только половина ответа: имён СТАДИЙ в спеке действительно нет.
**Прочие утечки того же рода.** `finalizing` — имя фазы в продуктовом словаре, и писателя у него нет
нигде (Б-13). `verify_bank` в описании называет следующую фазу: «Stop for bank signing **before the
final pass**» (`:1201`). `Unit` объяснён как «one edit unit», «roughly 1.9 units per chapter»
(`:994, :220-221`). `TermOrigin: [seed, ruby, mined]` — колонка `source` движка, с японской `ruby` в
общем слое (Б-17). `TermStatus: [auto, draft, approved]` — статус-машина банка движка. `promote`/
`decline` — глаголы оператора майнера. Отдельно: **объяснительная проза спеки компилируется в исходники
фронта** как JSDoc генерённых типов — вместе с «chunk verdict», «flagged», «sanitizer cleanup», «stage
boundaries», операторскими рангами и примером «CJK leak in the ru output: 第一节»
(`openapi.yaml:1430``frontend/src/api/schema.ts:1047`), то есть ровно той строкой, которую контракт
объявляет запретной.
**Гейта нет.** `.spectral.yaml` держит только `spectral:oas`; `generality.test.ts` ловит лишь языковую
специфику. Утечку конвейера не стережёт ничто.
**Чья боль.** Бэкенд (его архитектура подвижна, а контракт делает её изменение ломающим для фронта),
фронт (падение в рендере вместо деградации), продукт (ПТ-33).
**Цена.** Ц1-Ц2: фазовый сплит остаётся в платформе — колонки уже есть; на провод уходит один счётчик.
Переименования (`finalizing`, `verify_bank`, `TermOrigin`, `Unit`) — Ц0/Ц1.
**Рекомендация — менять спеку и проекцию** (владелец 16.08: «переделываем»):
1. `Progress`**один счётчик до ближайшей остановки** плюс, если нужна честность, серверная доля.
Число волн становится внутренним делом бэкенда. Это же закрывает Б-13а: знаменатель считается по
КУПЛЕННОМУ объёму, поэтому полоса всегда доходит до конца, а после подписи банка начинается заново
(форма владельца, В-5).
2. `finalizing` свернуть в `translating`; `verify_bank``stop_for_signing` без упоминания следующей
фазы; `TermOrigin`/`TermStatus` снять с провода (ни один экран их не рисует); `Unit` описать как
фрагмент без «edit unit» и «1.9 на главу».
3. Из описаний вычистить конвейерные слова — иначе они уезжают в исходники клиента.
4. Завести гейт: правило «на проводе нет ни имён стадий/волн, ни движковых словарей» проверяется
тестом, как уже проверяется языковая специфика.
**Контраргумент.** «Пофазный прогресс — не утечка, а честность: сквозной счётчик читал бы ноль всю
черновую волну» (`:729-731`). Довод верен и сохраняется полностью: честность даёт СЕРВЕР, считая долю
или сегмент, а не клиент, складывающий две названные волны. Именно решение «формула — продуктовое
решение клиента» (`:734`) и вытолкнуло имена фаз наружу.
---
### Б-1 (CONFIRMED · HIGH). Причины отказа не существует в машинном виде, а фразу пользователю пишет сервер
**Факт.** `Problem.type` обязателен (`openapi.yaml:1436-1438`), платформа ставит туда `"about:blank"`
всегда (`problem.go:25`). RFC 9457 регистрирует это значение как «the problem has no additional semantics
beyond that of the HTTP status code» (§4.2.1) и требует: «Consumers **MUST** use the "type" URI (after
resolution, if necessary) as the problem type's primary identifier» (§3.1.1), а `title` объявляет
вспомогательным: «The "title" string is advisory and is included only for users who are unaware of and
cannot discover the semantics of the type URI (e.g., during offline log analysis)» (§3.1.3,
https://www.rfc-editor.org/rfc/rfc9457.txt). Наша спека делает наоборот: `title` — «Product phrase naming
the class of failure. **Shown to the user as-is**» (`openapi.yaml:1441`).
Четыре следствия, каждое проверено исполнением:
1. **Различителя нет там, где он нужнее всего.** `fail()` кладёт шесть разных доменных отказов на 409 и
на 400, отличая их только фразой (`v0.go:544-570`). Клиент не отличит «допиши подпись банка» от «уже
идёт прогон» от «потолок уехал».
2. **`detail` мёртв.** Он специфицирован (`openapi.yaml:1434-1435`: «`title` — CLASS, `detail` — the
specific sentence»), клиентом ПРЕДПОЧИТАЕТСЯ (`Loaded.tsx:66`, `AddBook.tsx:289`, `RunStart.tsx:188`
`problem?.detail ?? problem?.title ?? …`), и не отправляется НИКОГДА: все вызовы контрактной
поверхности передают `""`. Весь различающий слой не существует на проводе.
3. **Пустая фраза легальна.** `title` обязателен, `minLength` нет, а спека прямо говорит «either may be
empty» (`openapi.yaml:1435`). Клиенты используют `??`, а `""` не nullish — значит конформный сервер
может отрисовать пустое сообщение об ошибке вместо локального запасного.
4. **Даже пара «about:blank + продуктовая фраза» отклоняется от стандарта:** «When "about:blank" is used,
the title SHOULD be the same as the recommended HTTP status phrase for that code … although it MAY be
localized to suit client preferences (expressed with the **Accept-Language** request header)»
(RFC 9457 §4.2.1). Стандарт называет и механизм локализации, которого у нас нет.
**Внутренняя несогласованность.** Тот же документ для двух причин делает правильно: `RejectReason` и
`PausedReason` — машинные enum'ы, «the phrase the user reads is drawn by the client»
(`openapi.yaml:781-782, 848-849`). Одна спека, две противоположные политики.
**Почему это не видно тестами.** Моки отвечают по-русски НАМЕРЕННО (`frontend/src/mock/handlers.ts:29-35`,
`frontend/src/mock/intake.ts:271-273` — «A fixture that answered in English would be modelling a platform we do not
have»). Вся батарея фронта зелёная против платформы, которой нет; двуязычие видно только на живом стенде.
**Независимая проверка.** Все четыре greenfield-дизайна пришли к «код + параметры у клиента», причём
формулировки почти дословно противоположны нашей: «`detail` in every error is developer English that the
client must never display». Один добавил серверную локализацию по `Accept-Language` только для
неперечислимых причин (модерация, отказ провайдера).
**Чья боль.** Фронт (два языка на экране, Ф-61), продукт (ПТ-36 + слово владельца 15.08: «мультиязычность
фраз ВСЕХ зон … — вход контракт-ревью», D39.136 п.4а), поддержка (нечем коррелировать), индустрия.
**Цена. Ц1 — и она меньше, чем кажется:** клиент УЖЕ держит таблицу «статус → русская фраза + совет»
(`AddBook.tsx:282-288`) и отбрасывает её всякий раз, когда сервер прислал любую фразу (`:289`). Перевод
интейка на машинные коды на стороне клиента — снятие одной строки. На платформе — словарь причин
(их около двенадцати, все уже перечислены в `fail()`) и расширение структуры `Problem`.
**Рекомендация — менять спеку и код, спеку первой.**
1. Расширения RFC 9457 (§3.2 «Problem type definitions MAY extend the problem details object with
additional members»): `code` — закрытый словарь причин версии, и `request_id` (значение уже
существует, `reqid.go:27`); либо заполнять `instance`, который уже объявлен в схеме.
2. Записать: `title`/`detail` — для разработчика и лога, клиент их НЕ показывает; фразу рисует клиент по
`code`; незнакомый код → нейтральная фраза (правило уже написано для `RejectReason`,
`openapi.yaml:797-799`).
3. Для валидации — расширение `errors[]` с указателем на поле (RFC 9457 приводит ровно такой пример).
Сегодня клиент не узнаёт даже, какое поле не прошло (`v0.go:242`), а самый болезненный случай —
`v0.go:356`: «The upload is incomplete» в ответ на форму, где прислано всё, но в другом порядке.
4. Записать правило для случая «`problem+json` не пришёл вовсе»: сегодня оба клиента гейтят разбор по
media-type (`client.ts:73`, `upload.ts:137`), и любой ответ промежуточного узла или stdlib даёт
`problem === null` и своё поведение на каждом экране.
**Контраргумент, который я рассмотрел.** «Клиент не может иметь фразу для причины, которой ещё нет» —
снимается тем же приёмом, что уже записан для `RejectReason`. Второй: «конкретная причина раскрывает
конвейер» — снят словом владельца 15.08: «граница ПТ-33 ПЕРЕ-ЧИТАНА: охранять АЛГОРИТМЫ бэкенда, а не
минорную механику … „не раскрывать внутренности“ не значит „говорить абстракциями“» (D39.136 п.4а).
Третий, самый сильный: «RFC разрешает `about:blank` для самоочевидных ошибок» — верно, и поэтому
рекомендация не «код на каждый ответ», а «код там, где под одним статусом живёт больше одной причины».
---
### Б-2 (CONFIRMED · HIGH). Возможности деплоя нигде не выражены, поэтому клиент их зашивает
**Факт.** Ни одной ручки «что этот деплой умеет». Проверено по каждому измерению:
- **Пары языков.** `LangCode` принимает любой код (`openapi.yaml:710-716`), проверяется только ФОРМА
(`platform/internal/books/books.go:118-121`), равенство `source == target` не проверяет никто. Список
`zh·ja·en → ru` зашит в клиент (`frontend/src/showcase/languages.ts:14-16`), и файл сам называет это
«the honest weak point». Отказ по неподдерживаемой паре не выражен ни одним классом ответа. Худший
случай прослежен до конца (и уточнён 16.08 — первая редакция называла механизм неверно): пар-промпты
в репозитории есть ТОЛЬКО для `zh-ru` (`backend/prompts/`), а отсутствие промпта пары — это ЖЁСТКАЯ
ошибка конфигурации, не деградация: «no prompt for pair %q role %q … never silently substitute another
pair's conventions» (`backend/internal/config/pipeline.go:952-956`). Langpack при этом деградирует
тихо (`backend/internal/pipeline/runner.go:326-329`), но до него дело не доходит. Итог: две из трёх
предлагаемых формой пар сегодня выполниться не могут; книга принимается, пишется на диск оператора,
разбирается, пользователь проходит денежный экран и жмёт «перевести» — и прогон умирает на валидации
конфигурации ДО единого платного вызова. **Деньги не сгорают** (холд закрывается по фактическому
расходу, то есть нулю), но пользователь получает `failed` без причины (Б-8) в конце всего пути.
- **Порог размера файла** не сообщается (`openapi.yaml:148-150`) и не сообщается даже в отказе:
`TooLarge` — голый `Problem` (`:639-643`), а `v0.go:409` передаёт пустой `detail`. Клиент не имеет
предпроверки (`AddBook.tsx:286` только отображает 413 постфактум) и узнаёт порог, потратив минуты
аплоада.
- **Форматы экспорта** — свободная строка (`openapi.yaml:1280-1284`).
- **Размеры страниц** объявлены прозой per-operation, и по-разному: для глав/замечаний/банка число зашито
(`:197, 252, 278`), для библиотеки и юнитов — «the platform's choice» (`:99, 226`).
- **Версия контракта** живёт ровно в одном месте на проводе — `EventHello.contract`
(`openapi.yaml:1342-1351`), внутри прогонного SSE, который платформа не реализует. То есть сегодня
версии на проводе нет нигде, а REST-клиент не узнает её и потом. Правило «клиент пинит точную 0.x»
(`:47-50`) не имеет носителя.
**Независимая проверка.** Все четыре greenfield-дизайна независимо ввели ручку возможностей
(`/config`, `/capabilities`, `/limits`+`/languages`); один связал её ключи с ключами в ошибках, чтобы
отказ ссылался на объявленный предел.
**Чья боль.** Фронт (данные деплоя в коде клиента), платформа (смена набора пар или порога = релиз
клиента), продукт (ПТ-2 «пары во все стороны» — список соврёт первым же деплоем; сегодня форма предлагает
две пары, которых деплой не умеет).
**Цена. Ц2** — одна read-ручка без миграции: пары из конфигурации, порог из `UploadLimits`
(`server.go:70-86`), страницы из констант (`books.go:586-587`), версия из сборки.
**Рекомендация — менять спеку и код.** `GET /capabilities` (или `/config`): `contract_version`,
`language_pairs[]` со статусом, `intake_max_bytes`, `export_formats[]`, `page_size{default,max}`.
Плюс отказ по паре — своим кодом в `Problem` на интейке, а не молчаливым прогоном.
Судья справедливо предупредил о соразмерности: это НЕ повод строить многотенантный документ
возможностей с ETag и пер-аккаунтными оверрайдами — нужен один плоский ответ на один деплой.
**Контраргумент.** «Числа принадлежат деплою, поэтому в контракте им не место» (`openapi.yaml:148-150`)
— верно ровно наполовину и, по сути, аргумент ЗА эту ручку: обоснование спеки («a number in the contract
would be a second copy of it») исключает константу в СПЕКЕ, а не значение в ОТВЕТЕ.
---
### Б-3 (CONFIRMED · HIGH). Приём книги: молчаливая потеря на 201, а схема указывает НЕВЕРНЫЙ порядок частей
**Факт.** Сервер читает форму потоково и прерывает цикл на части `file` (`v0.go:352-361`); часть после
файла не читается никогда. Отсюда ратифицированное 0.2.3 правило (`openapi.yaml:119-128`): обязательное
поле после файла → 400, необязательное → **теряется молча, ответ 201**.
Судейство добавило то, чего в кандидате не было: **схема моделирует обратный порядок.** Свойства
`BookIntake` перечислены как `title` (`:907`), `file` (`:918`), `source_lang` (`:922`), `target_lang`
(`:923`), `genre` (`:924`) — файл ВТОРЫМ, впереди обоих обязательных языков. Генератор, эмитящий части в
порядке свойств (обычный дефолт), произведёт ровно ту форму, которую сервер отвечает 400. То есть
кодоген не просто не ловит правило — он указывает его нарушить. Бьёт это по тому самому второму клиенту,
портируемость к которому декларирована (`openapi.yaml:564-570`).
Сопутствующее в том же запросе:
- **Идемпотентности нет** (`books.go:125` — свежий id на каждый вызов), а удалить дубликат нечем: `DELETE`
на поверхности отсутствует, и `pgstore/books.go:349` прямо это фиксирует. 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» (https://www.rfc-editor.org/rfc/rfc9110.txt) — а контракт при этом сам называет
ретрай средством от 408 (`openapi.yaml:146-147`).
- **Числа порога** спека объявляет («more than 16 parts, a text field longer than a kilobyte»,
`:141-142`), а код называет их не своим делом (`v0.go:296-298`) — и описывают они РАЗНЫЕ правила: код
считает части ДО файла, спека — все; мок фронта считает все (`frontend/src/mock/intake.ts:212`), то есть две
построенные реализации уже расходятся. «Килобайт» без единицы: код считает байты (`v0.go:390-396`).
У `genre` в схеме нет `maxLength` вовсе, хотя фактический предел есть.
**Независимая проверка.** Три из четырёх greenfield-дизайнов выбрали ДВУХШАГОВУЮ загрузку (метаданные
JSON → `PUT` байтов; у одного — tus с возобновлением). При ней проблема порядка не существует, размер
проверяется до передачи, повтор безопасен, обрыв докачивается.
**Чья боль.** Продукт (жанр теряется молча; 10 минут ожидания ради отказа по размеру), платформа
(дубликаты на диске без квоты — `platform/BACKLOG.md:24`), второй клиент (схема указывает неверный
порядок).
**Цена.** Ц3 для двухшаговой формы. **Дешёвый ход внутри нынешней — Ц1:** после того как `Accept`
дочитал файл, `parts.NextPart()` вызывается снова — трейлинг-части можно применить или ответить 400.
Молчаливая потеря исчезает без смены транспорта. Перестановка свойств в схеме — Ц0.
**Рекомендация — менять код и спеку.** (а) Любая часть после файла → 400 с машинным кодом, никакой
тихой потери на 201; (б) переставить свойства `BookIntake` так, чтобы `file` был последним (поле `genre`
к этому моменту уходит совсем — Б-23, решение владельца 16.08, так что его `maxLength` не нужен);
(в) `Idempotency-Key` на `POST /books` и `POST /runs`; (г) `Location` на 201; (д) двухшаговую загрузку
записать направлением на после-беты с ценой.
**Контраргумент.** «Потоковое чтение вынужденное: строка книги нужна до байтов» — верно и сохраняется;
меняется только реакция на нарушение. «Клиент один и он кладёт файл правильно» (`upload.ts:64-69`) —
контракт пишется не для одного клиента, и порядок свойств в схеме уже сегодня противоречит его прозе.
---
### Б-4 (CONFIRMED · HIGH). Экспорт не умеет провалиться — при том что платформа умеет
**Факт.** `Export``required: [id, ready]` плюс необязательный `url` (`openapi.yaml:1286-1298`).
Состояния отказа нет: упавшая сборка навсегда остаётся `ready: false`, а `GET` отвечает 200 с
`Retry-After` (`:530-541`) — клиент опрашивает вечно. Спека сама показывает, что авторы этот тупик
видели («without this read the creating call is a dead end», `:528`) и закрыли только счастливую половину.
В БД платформы поля уже есть: `exports.failed_reason`, `ready_at`
(`platform/internal/pgstore/migrations/00002_readmodel.sql:188-199`).
AIP-151 формулирует это как MUST: «Operations that fail during their execution phase must return an error
response (AIP-193), placed in the `Operation.error` field» (https://google.aip.dev/151).
Сверх того: `format` принимается и нигде не отражается (два экспорта одной книги неразличимы), у ссылки
нет срока, а её защита — только проза «Served to the authenticated owner only» (`:1295-1297`), при том
что носителем ПТ-34 назначен в том числе контракт (`docs/product-requirements.md:61`).
**Цена. Ц0**обе операции не построены. **Рекомендация — менять спеку:**
`state: pending|ready|failed|expired` вместо булева `ready`; `failure_code`; эхо `format`; `expires_at`;
правило доступа к `url` (подписанная ссылка со сроком либо отдача через API). Размер и TTL — по
AIP-151 «may», не настаиваю.
**Контраргумент.** «Форматы — работа S7, сейчас фиксируется только форма вызова» (`:492`). Именно поэтому
и дёшево: форма вызова без состояния отказа — не заготовка, а тупик, и стоит она одной правки схемы.
---
### Б-5 (CONFIRMED · HIGH). У книги нет ни одной записи; у прогона нет чтения
**Факт.** На всей поверхности только `get:` и `post:` — ни `PATCH`, ни `PUT`, ни `DELETE`
(проверено перечислением всех 15 путей). Переименовать книгу нечем (Ф-62,
`frontend/docs/BACKLOG.md:68`: подсказка формы обещала «поправить позже» — обещание сняли, потому что
контракт его не даёт). Удалить книгу нечем: путь удаления в платформе достижим только из своего свипа
интейка (`pgstore/books.go:349`). Название при этом по умолчанию выводится из ИМЕНИ ФАЙЛА с обрезкой до
200 рун (`platform/internal/books/books.go:365`) — то есть заведомо случайно, а исправить нечем; ошибка
лечится второй книгой на диске оператора.
Прогон: `GET /runs/{runId}` не существует — у прогона три под-ресурса (`/events`, `/stop`, `/resume`) и
ни одного чтения себя; в ответе `stop`/`resume` нет `book_id` (`v0.go:123-132`), хотя в read-модели он
есть (`pgstore/books.go:481`). Клиент, потерявший тело 202, может перечитать прогон только через карточку
книги.
**Направление уже задано владельцем:** «Переименование книги (Ф-62) — В КОНТРАКТ направлением; форма —
из контракт-ревью» (D39.136 п.4в).
**Цена. Ц2.** `PATCH` — колонки есть; `DELETE` — мягкое удаление плюс свип; `GET /runs/{runId}`
проекция существующей строки.
**Рекомендация — менять спеку и код.** `PATCH /books/{bookId}` **только с `title`** (merge-patch; 409 при
живом прогоне) — жанр из продукта уходит совсем (Б-23), пара языков в правку НЕ входит (её смена это
пере-перевод, а не правка метаданных); `DELETE /books/{bookId}` (409 при живом прогоне, физическая уборка
свипом); `GET /runs/{runId}`; `book_id` в `Run`.
⚠ Записать в спеке явно: правка названия — **отображение**, до движкового брифа она не доезжает.
Основание: `title` входит в `BriefHash` (`backend/internal/config/book.go:279-283`), но платформа пишет
`book.yaml` ровно один раз и никогда не перезаписывает (`platform/internal/books/render.go:71-77`) —
поэтому сегодня переименование бесплатно, а наивный «пере-рендер конфига» перекупил бы книгу целиком.
Список прогонов книги (история) — снят решением владельца 16.08: в МВП не нужен.
**Контраргумент.** «Метаданные приходят из файла, править их — лишняя поверхность» — не держится, см.
происхождение названия. «История прогонов нужна поддержке» — вероятно, но требования нет; отделяю
чтение прогона (дёшево, нужно клиенту) от истории (продуктовый вопрос).
---
### Б-6 (CONFIRMED · HIGH). Поток: канал привязан к прогону, конца нет, и правила докачки не сходятся
Четыре дефекта одного канала; все — Ц0, потому что канала нет ни строкой.
**(а) Конец разбора не объявляет никто.** Поток — `GET /runs/{runId}/events` (`openapi.yaml:378`), а
книга в `uploading`/`parsing` прогона не имеет по построению: строка прогона создаётся только в
`StartRun` (`platform/internal/pgstore/runs.go:66,80`), весь разбор живёт на строке книги
(`books.go:180,237,283`). Клиент лечится опросом раз в 3 с (`frontend/src/api/queries.ts:64-75`) плюс
вторым хуком, перечитывающим дерево на выходе из интейка (`frontend/src/showcase/useIntakeEnd.ts:31`). Разбор
настоящей книги — минуты. Это Ф-56, и зона правильно отдала его владельцу контракта.
**(б) Конца потока нет.** Терминального кадра в таблице нет (`:1304-1313`), а `204` в ответах операции
не объявлен (`:401-407`) — при том что SSE именно им останавливает переподключение: «Clients will
reconnect if the connection is closed; a client can be told to stop reconnecting using the HTTP 204 No
Content response code» (https://html.spec.whatwg.org/multipage/server-sent-events.html). После
завершённого прогона браузер будет переподключаться вечно.
*Поправка судейства, которую принимаю:* движковый `finished` НЕ является пропавшим сигналом — платформа
не считает его авторитетным намеренно (доставка at-least-once, хвост рвётся при крахе; авторитет — код
выхода и реконсилятор). Дефект — в отсутствии дисциплины конца потока, а не в игнорировании `finished`.
**(в) Разрешение склейки уничтожает единственный кадр, который склеивать нельзя.** «The server MAY
COALESCE frames» (`:1320-1323`) — без ограничения по типу. Шесть кадров из восьми снимки, а `note`
(`:1381-1388`) — добавление: склейка двух `note` теряет замечание навсегда, на живом соединении, без
переподключения и потому без `resync_required`. Это ровно тот исход, которым спека двумя абзацами выше
обосновывает запрет реплея: «a one-shot event such as `note` would otherwise be lost silently»
(`:396-397`, повтор `:1416-1417`). Два правила одного документа исключают друг друга.
**(г) Докачка не определена.** Спека требует `Last-Event-ID` и одновременно запрещает реплей
(`:386-397`), не говоря, ЧТО именно можно переслать; два конформных сервера поведут себя противоположно.
*Здесь мой исходный вывод «докачка невозможна в принципе» судейство опровергло:* связное чтение есть —
короткий живой буфер после предъявленного id это не «реплей истории». Остаётся реальный дефект: правило
не определено. Плюс форма `id`: спека просит «a monotonic `id`» и говорит, что он несёт ревизию книги,
одна транзакция даёт НЕСКОЛЬКО кадров с одним id (`:1315-1318`), лексическая форма не зафиксирована — а
клиент обязан сравнивать его численно (`:681-684`). Сервер, сделавший id уникальным на кадр (`1841-2`),
не нарушит ни слова прозы и при этом навсегда отключит клиентский гард: `Number('1841-2')` = `NaN`
(`frontend/src/api/stream.ts:131`, `revision.ts:16-20`).
**Рекомендация — менять спеку, пока канала нет.** Перевесить поток на книгу (`GET /books/{id}/events`),
прогонный оставить узким видом; добавить кадры конца разбора и конца прогона и объявить `204` при
переподключении к завершённому; развести `id` кадра (позиция потока, форма зафиксирована) и ревизию
книги (поле в `data`); выбрать одно правило докачки; разрешить склейку только снимкам и запретить `note`
(или заменить его счётчиком + перечитыванием — половина этого пути уже построена: `note_count` есть на
`Book`, `Chapter` и `EventChapter`).
**Контраргумент.** «Прогонный поток честнее: события принадлежат прогону» — не выдерживает того, что две
из одиннадцати книжных фаз прогону не принадлежат, а статус их показывает в том же поле. «Никакой
вменяемый реализатор не станет склеивать `note`» — контракт пишется против невменяемого реализатора и
против себя-через-год.
---
### Б-7 (CONFIRMED · HIGH). Структурная эпоха объявлена, но её никто не наблюдает; условных чтений нет
**Факт.** Спека привязывает курсор к «STRUCTURAL epoch of the collection — the generation of the
manifest, the chunker version» и вменяет серверу MUST-отказ по мёртвому курсору (`openapi.yaml:702-708`).
Слово «epoch» встречается в документе ТОЛЬКО там: ни один ответ эпохи не несёт, `ChapterList` — это
`{revision, next_cursor, chapters}` (`:967-975`). Реализованный курсор (библиотечный) эпохи не содержит
(`pgstore/books.go:821-831`), у таблицы `chapters` такой колонки нет.
Структура при этом живая: «число глав может измениться против эвристики — недоразмеченное добавится,
ложное удалится» (`docs/research/27-chapter-detection.md:35`, решение владельца 09.08), а id юнита
нестабилен при смене чанкера (`backend/internal/pipeline/manifest.go:102`).
Вторая половина — цена перечитывания. Условных запросов нет ни одного: `ETag`, `If-None-Match`, `304` не
встречаются ни в спеке, ни в платформе (грепом). Кадр `status` заставляет клиента перечитать юниты ВСЕХ
открытых глав, весь список глав и библиотеку (`frontend/src/showcase/useRunStream.ts:111-122`); зона
замерила фан-аут «20 вкладок = 20 чтений юнитов на кадр» (`frontend/docs/BACKLOG.md:48`). На книге в
2284 главы это полное дерево на каждой границе стадии.
**Независимая проверка.** Все четыре greenfield-дизайна ввели версию дерева на каждом ответе главы плюс
ленту изменений (`added|removed|moved|split|merged`) и `410 Gone` с наследниками; три из четырёх —
условные чтения.
**Цена.** Ц2 для версии дерева (колонка + поле). Ц1 для условных чтений: `ETag: W/"<revision>"` берётся
из уже существующей ревизии.
**Рекомендация — менять спеку и код.** (а) Публиковать структурную версию в каждом ответе с главами или
юнитами и в `hello`; (б) `410 Gone` на исчезнувшую главу; (в) `If-None-Match`/`304` на дерево, банк и
юниты; (г) ленту изменений структуры — направлением к этапу 161, не в этот батч.
**Контраргумент.** «`resync_required` уже закрывает смену эпохи» — только для клиента, ДЕРЖАЩЕГО поток;
вернувшийся на вкладку через час получает 200 и молча рисует старое дерево. «`no-store` делает 304
бессмысленным» — `no-store` запрещает хранение КЭШУ («the no-store response directive indicates that a
cache MUST NOT store any part of either the immediate request or the response», RFC 9111 §5.2.2.5), а
копия живёт в памяти приложения; но раз это неочевидно, в контракте это надо записать, а не
подразумевать.
---
### Б-7а (CONFIRMED · HIGH). Спека утверждает о движке и платформе то, что перестало быть правдой — и одно из утверждений отнимает у клиента работающее лечение
Контракт содержит четыре предупреждения о недостроенном; три устарели, а одно ещё и предписывает
клиенту НЕ показывать средство, которое существует.
1. **`:274-277` («⚠ No backing channel exists for this read today. The engine ships a signing table, not
a bank export»)** — неверно с D39.122: движок пишет сайдкар всего банка
(`backend/internal/pipeline/bankexport.go:16-33,72`), и компаньон это уже исправил четырьмя строками
выше собственного противоречащего абзаца (`README.md:267` против `:271-278`). Зона фронта до сих пор
учится по старой версии (Ф-43).
2. **`:1384-1385` (`EventNote` «the engine does not emit per-unit notes mid-run today»)** — эмиттер
приземлился 14.08: `unit_done` несёт `Flagged` и причину (`backend/internal/runevents/runevents.go:126-135`),
платформа их читает и хранит (`platform/internal/pgstore/sink.go:227-233`).
3. **`:1403` (`EventCeiling` «Depends on the event emitter»)** — то же: событие построено
(`runevents.go:151-154`).
4. **`:440-445` (`resume` после стопа по потолку) — самое дорогое.** Спека пишет: «no handle raises it …
The mechanism is the platform's and does not exist yet … so the client MUST NOT offer resume as the
remedy for `paused`». Первая половина верна (resume действительно не двигает такой прогон), вторая —
нет: стоп по потолку ЗАКРЫВАЕТ прогон, `finished_at` пишется тем же оператором
(`platform/internal/pgstore/runs.go:672-676`), `paused` входит в допустимые для старта состояния
(`platform/internal/runs/runs.go:229`), `HasLiveRun` при этом ложь (`runs.go:187`) — то есть
**новый прогон с бо́льшим потолком запускается и является лечением уже сегодня.** Контракт же
оставляет клиента без единого действия: «resume не предлагать», а про «начать заново с бо́льшим
потолком» не сказано ничего. Пользователь видит тупик там, где его нет.
**Чья боль.** Продукт (тупик на самом частом остановочном состоянии), фронт (учится по устаревшему
тексту), движок (о нём написана неправда в ратифицированном документе), доверие к контракту как
источнику истины.
**Цена. Ц0** — правки текста; для п.4 ещё одна строка в описании `startRun` («потолок поднимается новым
прогоном»).
**Рекомендация — менять спеку.** Снять три устаревших предупреждения, п.4 переписать на действующее
лечение. И системно: предупреждения вида «зависит от того, что ещё не построено» обязаны нести ссылку на
строку бэклога — иначе они переживают своё основание и начинают лгать. В компаньоне такая таблица
предлагается в Б-21.
**Контраргумент.** «Устаревший комментарий — не дефект формы» — для пп.1-3 почти так (цена — обучение
зоны неправде); для п.4 нет: это НОРМАТИВНОЕ «MUST NOT» на основании факта, который неверен.
---
### Б-8 (CONFIRMED · MEDIUM). Единственное состояние, которое ЕСТЬ ошибка, — единственное без причины
**Факт.** У контракта две машинные оси причин: `reject_reason` при `rejected` (`openapi.yaml:820-831`) и
`paused_reason` при `paused` (`:875-879`). У `failed` — «run aborted by an error» (`:754`) — нет ни поля,
ни кода: `Run.required` (`:860`) его не содержит, `EventStatus` несёт только статус и `paused_reason`
(`:1359-1365`), а `Problem` тут не работает — прогон умирает асинхронно, через часы после своего 202.
Клиент не может отличить временную беду от окончательной и решить, показывать ли повтор; цена ошибки —
оплаченный прогон.
**Цена. Ц1** — гадать не нужно: у движка исход уже словарь (`clean|flagged|bank_stop|ceiling|stopped|failed`,
`backend/internal/runevents/runevents.go:95-102`) плюс полоса кодов отказа 1019 (D39.131), у платформы
есть `exit_result` и карантин (`platform/internal/runs/reconcile.go:149,320`).
**Рекомендация — менять спеку.** `Run.failure_reason` тем же паттерном, что две существующие оси; даже
двух значений (временная / окончательная) достаточно, чтобы решить судьбу кнопки повтора.
**Контраргумент.** «Не выдумывать таксономию раньше движка» — именно так ждали `RejectReason` до 0.2.3;
но здесь ждать нечего, словарь уже есть, его надо спроецировать.
---
### Б-9 (CONFIRMED · MEDIUM). Замечание — единственная строка документа без идентификатора
**Факт.** `Note` требует `[severity, message]` (`openapi.yaml:1038`), `chapter_id` и `unit_id`
необязательны — то есть схема допускает замечание, не адресующее ничего, вопреки собственной фразе
операции «A note addresses a unit or a whole chapter» (`:249`). Идентификатора нет ни в списке, ни в
кадре (`:1381-1388`), при том что у `Book`, `Chapter`, `Unit`, `BankTerm` он обязателен. В read-модели
платформы он ЕСТЬ, вместе с `created_at` (`00002_readmodel.sql:134-140`). Клиент ключует строки позицией
в массиве (`frontend/src/showcase/Notes.tsx:41-43`, комментарий это фиксирует), `unit_id` не читает ни
один экран (`Notes.tsx:63-66`), а кадр `note` невозможно сопоставить с прочитанным списком — значит
невозможны ни дедупликация, ни «скрыть замечание», ни устойчивая ссылка.
**Цена. Ц0/Ц1.** **Рекомендация — менять спеку:** `id` и `created_at` обязательными; адресацию сделать
обязательной (`chapter_id` обязателен, `unit_id` опционален) либо явно описать смысл безадресного
замечания. **Контраргумент.** «Замечание — не сущность, а строка» — не держится, раз оно приходит двумя
каналами и должно сойтись.
---
### Б-10 (CONFIRMED · MEDIUM). `limit` без максимума, и превышение понижает до дефолта, а не подрезает
**Факт.** `Limit``{ type: integer, minimum: 1 }`, без `maximum` (`openapi.yaml:597-605`); ключевое
слово `maximum` в этом же файле используется (`:1268`), то есть это не стиль, а пропуск. Сервер:
`if limit <= 0 || limit > maxPage { limit = defaultPage }` (`pgstore/books.go:509-511`) — `?limit=1001`
отдаёт 100 строк, меньше, чем `?limit=1000`. Индустрия говорит обратное: «If the user specifies
`page_size` greater than the maximum permitted by the API, the API **should** coerce down to the maximum
permitted page size» (https://google.aip.dev/158); GitHub — то же («the value is automatically reduced to
the maximum»).
**Цена. Ц1.** **Рекомендация — менять спеку и код:** объявить `maximum`, подрезать до максимума.
**Контраргумент.** «Сервер вправе вернуть меньше, клиент решает только по `next_cursor`» (`:602-604`) —
формально да, поэтому это не поломка; но поведение «попросил больше — получил меньше всех» узнаётся
только экспериментом.
---
### Б-11 (CONFIRMED · MEDIUM). Ни одного параметра запроса, кроме `limit` и `cursor`
**Факт.** На всей поверхности существуют ровно два query-параметра (`openapi.yaml:572-616`): ни поиска,
ни фильтра, ни сортировки. Порядок объявлен только для глав («Chapters in reading order», `:193`) — для
библиотеки, юнитов, замечаний и банка не объявлен нигде, хотя keyset-курсор предполагает стабильный
полный порядок. Цена уже оплачена клиентом: поиск по банку идёт в браузере по всем строкам
(`frontend/src/showcase/Bank.tsx:89`), палитра перехода ищет в памяти и режет до 50
(`showcase/Goto.tsx:25-30`), а зона записала «поиска по книге нет ни в одной из пятнадцати ручек»
(`frontend/docs/BACKLOG.md:39`). Отдельно: `BankDecisionsRequest.decisions` — единственный
неограниченный массив в документе, который всё остальное ограничивает (`:1173-1178`).
*Поправка судейства, которую принимаю:* «нет тоталов» — неверно; `Bank.total`/`signed` есть (`:1134-1141`),
а размеры прочих коллекций живут на родителе (`Book.chapter_count`, `note_count`,
`Chapter.units_total`) — это согласованное решение, а не пропуск.
**Цена.** Ц1 для порядка и `maxItems`; Ц2 для фильтра и поиска (индексы).
**Рекомендация.** Объявить порядок каждой коллекции и `maxItems` у `decisions` — в 0.3.0; фильтр по
статусу для библиотеки и поиск по банку/дереву — направлением к S5/S6, не в батч.
**Контраргумент.** «Клиент виртуализует, ему годится любой размер» (К-7) — верно для отрисовки и неверно
для сети: сегодня клиент выкачивает коллекцию целиком именно потому, что иначе не может ни искать, ни
считать.
---
### Б-11а (CONFIRMED · MEDIUM). Кадр без данных заставляет перечитать список целиком — и это единственный настоящий узкий проход
**Факт.** Кадры `note` и `bank` несут полезную нагрузку, которую клиент применить не может (у замечания
нет id — Б-9; у банка кадр несёт три счётчика, а строки нет), поэтому оба ведут к перечитыванию всего
списка (`frontend/src/showcase/useRunStream.ts:143-149`). Банковский экран смонтирован всегда
(`Showcase.tsx:45-59`). Арифметика на настоящей книге: если эмиттер даст кадр банка на главу, это
**2283 × 167 КБ ≈ 372 МБ за прогон**; на термин (1200 строк) — 195 МБ. Гасителя нет ни одного: ни
условных чтений, ни дельт, ни сжатия. Единственная защита — необязательное «сервер МОЖЕТ склеивать»
(`openapi.yaml:1320-1323`), то есть надежда, а не механизм.
Рядом, по убыванию:
- **дерево 2283 глав целиком на каждом `status`** (250 КБ, под gzip было бы 39) — ради счётчиков,
которые кадр `chapter` уже принёс; это цена открытого К-10;
- **`refetchOnWindowFocus` при `staleTime: 15 c`** (`frontend/src/main.tsx:22-24`): возврат в окно
перечитывает всё активное — 12 вкладок = 768 КБ, и это десятки раз в час, тогда как `status` — 4-6
раз за прогон. **Самый частый трафик порождает не пайплайн, а переключение вкладок браузера.**
**Где узкого места НЕТ** (это тоже вывод): экран чтения дёшев (26 КБ на главу), книга целиком по сети
не ходит, дерево виртуализовано (31 строка в DOM на 2284), прогресс — патч без чтений.
**Чья боль.** Платформа (её нагрузка), пользователь на мобильном канале, деньги на трафик.
**Цена.** Ц0 для правил в контракте; сжатие — конфигурация деплоя; условные чтения — Ц1 (ETag берётся
из уже существующей ревизии).
**Рекомендация.** (а) Обязать сжатие на текстовых ответах и записать это в контракт, а не в зонный док;
(б) условные чтения на дерево, банк, юниты (Б-7); (в) заменить «сервер МОЖЕТ склеивать» на правило
«кадр несёт либо применимую дельту, либо счётчик, но не заставляет перечитывать коллекцию»;
(г) фронту — снять `refetchOnWindowFocus` для тяжёлых коллекций (зона фронта, не контракт).
**Контраргумент.** «Пока эмиттер банка не построен, 372 МБ — арифметика на бумаге». Верно — и потому это
правится сейчас за Ц0, а не после того, как эмиттер даст первый прогон.
---
### Б-12 (CONFIRMED · MEDIUM). Агрегаты всей коллекции лежат внутри страничного конверта
**Факт.** `Bank` обязывает нести `total` и `signed` «в целом по банку, не по странице» на КАЖДОЙ
странице, при этом у каждой страницы своя `revision` (`openapi.yaml:1128-1141`), и правила, какая
страница главная, нет. Референсный клиент неизбежно смешивает два момента времени: счётчики берутся с
последней страницы (`frontend/src/api/queries.ts:144-148`), а ревизия — минимум по страницам, то есть с
первой (`frontend/src/api/client.ts:99-106`). Обе половины написаны осознанно и обе правильны по
отдельности.
**Цена. Ц0** (чтение не построено). **Рекомендация — менять спеку:** либо агрегаты только на первой
странице (и правило «клиент берёт их оттуда»), либо ревизия конверта общая для всего обхода.
**Контраргумент.** «Счётчики на каждой странице удобны» — удобны, но без правила согласования они дают
неопределённость, которую клиент вынужден разрешать сам, и он уже разрешил её двумя разными способами.
---
### Б-13 (CONFIRMED · MEDIUM). Один enum на две машины состояний; мёртвое значение в словаре
**Факт.** `Run.status` типизирован как `BookStatus` (`openapi.yaml:864`) — 11 значений, из которых на
прогоне легальны 7, и спека сама это говорит прозой (`:857-859`). В DDL платформы узкий словарь уже
записан: `check (status in ('translating','awaiting_bank','finalizing','ready','stopped','failed','paused'))`
(`00002_readmodel.sql:47-49`) — то есть контракт отстаёт от собственного сервера, а генерённый union
шире правды. `BookDetail` отдаёт оба статуса без правила старшинства, и фронт уже разошёлся: полоса
состояния решает «paused» по прогону (`showcase/Status.tsx:21`), карточка — по книге.
`finalizing` при этом не пишет никто: у движка такой фазы нет вовсе (`grep -ri finaliz backend/internal`
— пусто), в словаре ingest её нет (`platform/internal/ingest/events.go:53-67`), в платформе значение
живёт только в валидаторах.
**Цена. Ц0/Ц1.** **Рекомендация — менять спеку:** отдельный `RunStatus` (7 значений); правило
старшинства «книга производна от прогона, кроме `uploading`/`parsing`/`not_started`/`rejected`»;
`finalizing`**снять** (решение опирается на слово владельца 16.08: лестница «загрузка → разбор →
перевод → подпись банка → финал → готово» была его устной формулировкой, а не нормой продукта, поэтому
статус ею не связан; под 0.x значение возвращается минором так же дёшево, если фаза когда-нибудь
появится у движка).
**Контраргумент.** «Переиспользование словаря экономит клиенту карту статус→вид» — экономия мнимая:
карта всё равно одна, а лишние значения обязывают писать недостижимые ветки.
---
### Б-13а (CONFIRMED · MEDIUM). Пользователь покупает главы, а видит долю от всей книги
**Факт.** Потолок объявлен в ГЛАВАХ и принадлежит прогону (`openapi.yaml:868,1202`). Прогресс — в
ЮНИТАХ и по всей книге: `Progress.draft/edit` проецируются из книжных счётчиков
(`platform/internal/httpapi/v0.go:473-477`), а движок считает их по книге сознательно — «**The counters
are BOOK state, not process state**: a resumed run re-walks every finished unit, and a counter that
started at zero would walk a reader's progress bar backwards»
(`backend/internal/pipeline/events.go:337-339`), и знаменатель засевается из всей нарезки книги
(`events.go:295-324`). Значит прогон,
купленный на 10 глав из 2284, показывает дробь, которая не может дойти до единицы, а затем книга уходит
в `paused` — и ни одно поле на проводе не переводит потолок в ось прогресса. Величины «глав сделано» и
«глав осталось» на поверхности нет вообще (проверено перечислением всех «главных» полей: `chapter_count`,
`ceiling_chapters`, `min/max/default_chapters`, `since/until_chapter`, `chapter_id` — и всё), при том что
платформа её уже считает в SQL для денежной шкалы (`platform/internal/pgstore/books.go:677-679`) и не
отдаёт.
**Чья боль.** Продукт (полоса прогресса врёт на каждом прогоне с потолком — а с потолком идут ВСЕ
прогоны, поле обязательное), фронт (ему нечем построить честный индикатор), поддержка.
**Цена. Ц1** — величина существует в SQL; нужен знаменатель прогона (или пара «глав куплено / глав
сделано») в `Run`.
**Рекомендация — менять спеку и код:** добавить `Run.chapters_done` (и/или `chapters_left` на книге),
либо явно записать, что `Progress` — по книге, а не по прогону, и клиент обязан рисовать это как
книжную полосу с отдельной отметкой купленного объёма.
**Контраргумент (сильный, судейство его выдвинуло).** Единицы разошлись не по недосмотру: главы у
потолка — потому что доллары на провод не идут (D39.110), юниты у прогресса — потому что сквозной
счётчик по главам читал бы ноль всю черновую волну (`:729-731`). Оба довода в силе, и я не предлагаю
менять единицы. Дефект в другом: у прогона нет СВОЕГО знаменателя, и потому дробь на экране означает
не то, что человек купил.
---
### Б-14 (CONFIRMED · MEDIUM). «Необязательное» в этом документе значит четыре разных вещи
Кандидат был шире; судейство сняло три пары как ЗАЩИЩЁННЫЕ спекой (см. §4 №22-24) и оставило те, где
обоснования нет нигде:
| Место | Что не сходится |
|---|---|
| `Usage.paused_reason` **необязателен** (`:1256` не включает его, поле `:1270-1276`), а ТО ЖЕ имя и тип **обязательны** на `Run` (`:860`) и на `EventStatus` (`:1359`) | Одно поле, две разные обязательности, обоснования нет нигде. Клиент уже платит: `frontend/src/api/contract.ts:97` — одна ветка, `:124-127` — две; платформа при этом всегда шлёт (`v0.go:161,439-444`) |
| Внутри ОДНОЙ схемы: `Run.finished_at` optional+nullable (`:881-883`), `Run.paused_reason` required+nullable (`:860`) | Клиент записал цену в комментарий: «`finished_at` приходит и как `null`, и вовсе отсутствующим … обе формы значат одно и то же» (`frontend/src/showcase/About.tsx:109-111`) |
| `Book.genre` и `character_count` необязательны (`:812-817`), а сервер шлёт их ВСЕГДА (`v0.go:104-110`, без `omitempty`) | Опциональность фиктивна, а смысл СЕНТИНЕЛА не определён: `genre` пуст, когда пользователь его не указал, и экран рисует `book.genre ?? '—'` (`About.tsx:92`) → пустая ячейка вместо прочерка. `character_count: 0` в интейке значит «ещё не знаем», а выглядит как ноль |
| `eta_seconds`: проза предписывает ОДНО кодирование («absent», `:743`), схема разрешает ДВА (`[integer,'null']` + не в required) | Каждый потребитель носит лишнюю ветку: `format.ts:114-115` (`number \| null \| undefined`), `frontend/src/mock/live.ts:8` (`?? null`), генерённый тип — три состояния для одного факта |
| На сегодняшнем проводе «необязательное» значит «всегда есть, бывает `null`» у шести полей и «не приходит никогда» у двух — `Problem.instance` (`:1444`) и `Book.reject_reason` (`:820`) | Клиент не может отличить, какое отсутствие значимо |
| Имя конверта коллекции: `Library` · `ChapterList`/`UnitList`/`NoteList` · `Bank` (`:835,967,1014,1050,1128`) | Один паттерн (revision + next_cursor + строки), три стиля имени; `operationId` при этом единообразны |
| `total` значит «юнитов в волне» в `Counter` (`:724`) и «строк во всём банке» в `Bank`/`EventBank` (`:1134,:1394`) | Одно слово, два референта; у полей `EventBank` описаний нет вовсе |
**Плюс самопротиворечие документа.** `TermStatus` формулирует принцип: «A boolean `signed` would merge
"proposed by the engine, nobody looked" with "a human started and did not finish"» (`:1070-1072`) — и тот
же документ дважды поставляет ровно такую форму: `Export.ready` (сливает «идёт», «упало», «истекло»,
Б-4) и `EventCeiling.halted` (обязательный булев, всегда `true`).
Для `Unit.target` правка бесплатна и не спорна: в БД уже стоят чеки `translated ⇒ target≠''` и
`не translated ⇒ target=''` (`00002_readmodel.sql:127-128`), то есть поле детерминировано; форма —
не «как у `sense`», а условная обязательность по `state`, как уже сделано у `BankDecision.dst`
(`:1153-1160`), с оговоркой К-11: генератор `if/then` игнорирует, значит условие защищает сервер, а
клиента приходится сужать на шве.
**Цена. Ц0/Ц1.** **Рекомендация — менять спеку:** записать ОДНО правило кодирования отсутствия и
применить его к `Usage.paused_reason`, `finished_at`, `character_count`, `eta_seconds` (`genre` из
списка выпадает — поле уходит совсем, Б-23);
`Unit.target` — условной обязательностью; имена конвертов и второй смысл `total` — привести.
**Контраргумент.** «Каждое место обосновано отдельно» — для трёх пар это правда (§4 №22-24), и я их
снял; для перечисленных выше обоснования нет ни в спеке, ни в компаньоне, а цена уже уплачена
ветками в клиенте.
---
### Б-14а (CONFIRMED · MEDIUM). У стопа подписи нет снимка: `complete` живёт только в квитанции POST
**Факт.** `complete` — поле, по которому решается, можно ли предлагать «продолжить»: `resumeRun`
«**Answers 409 while the set of bank decisions is incomplete**» (`openapi.yaml:447`). Существует оно
только в ответе на `POST /bank/decisions` (`:1180-1193`) и, косвенно, в кадре `EventBank` (`:1390-1396`).
Чтение `GET /books/{id}/bank` (`:1128-1144`) не несёт ни `complete`, ни `pending_decisions`. Экран,
перезагруженный посреди стопа `awaiting_bank`, может прочитать ВЕСЬ банк и всё равно не узнать, сколько
решений осталось и можно ли продолжать; единственная реализация выводит признак как `left === 0`
(`frontend/src/mock/handlers.ts:203-204`) — инвариант, которого контракт нигде не объявляет. Кадр как
второй носитель не помогает: он не построен (и по компаньону §3 движок таких событий mid-run не шлёт).
**Чья боль.** Продукт (экран подписи S5 — центральный сценарий), фронт, платформа.
**Цена. Ц0** (чтение не построено).
**Рекомендация — менять спеку:** перенести `pending_decisions` и `complete` в ответ `GET /bank` (или
завести `GET /bank/signing` — маленький снимок стопа), а квитанцию POST оставить его же формой.
**Контраргумент.** «Клиент только что отправил решения и знает остаток» — верно ровно до перезагрузки
вкладки, а стоп по построению длинный (сотни терминов, «a closed tab must not cost an hour of work»,
`:303-305`).
---
### Б-15 (CONFIRMED · MEDIUM). Требование CSRF невидимо для инструментов, а его отказ — не легальный ответ
**Факт.** Правило «на каждом небезопасном запросе — `X-TM-Client`» живёт в описании схемы безопасности
(`openapi.yaml:555-563`): генератор его не создаёт, spectral не проверяет, контрактный тест не ловит.
Клиент носит его двумя копиями руками (`frontend/src/api/client.ts:13,40` и `frontend/src/api/upload.ts:81`).
Отказ — 403 (`server.go:99`), которого в контракте нет, и он же отвечает на честный same-origin запрос
без заголовка, сообщая «Cross-origin request rejected» (`auth/csrf.go:38`). CSRF-слой стоит ПЕРЕД
аутентификацией (`server.go:109-110`), так что для небезопасных запросов правило «401 раньше 404» не
работает.
Сама защита держится не на свойстве заголовка (значение фиксировано и публично), а на факте из другого
раздела спеки — «CORS-слоя нет вовсе» (`:30-35`); ссылки между этими местами нет, и ничто не сломается
громко, если инвариант перестанет держаться.
Ещё три места того же класса — правило, читаемое только человеком:
- **Множество «небезопасных» методов расходится с кодом.** Спека: «anything other than GET and HEAD»
(`:558`). Код освобождает и `OPTIONS` (`platform/internal/auth/csrf.go:51-54`), что совпадает с
RFC 9110 («the GET, HEAD, OPTIONS, and TRACE methods are defined to be safe») и не совпадает со
спекой. Клиент следует СПЕКЕ, не коду.
- **401 идёт без `WWW-Authenticate`:** «The server generating a 401 response **MUST** send a
WWW-Authenticate header field (Section 11.6.1) containing at least one challenge applicable to the
target resource» (RFC 9110 §15.5.2, https://www.rfc-editor.org/rfc/rfc9110.txt). Ни `WriteProblem`
(`platform/internal/httpapi/problem.go:22-28`), ни Deny-обработчик (`platform/cmd/tmplatformd/main.go:85`)
его не ставят, ни компонент `Unauthorized` (`openapi.yaml:624-628`) его не объявляет — при том что
в том же файле `headers:` объявлены дважды (`Location` на 202, `Retry-After` на 200).
- **`servers[0].url` — абсолютный плейсхолдер** `https://app.example.org/v0` (`:63-68`), тогда как
проза рядом требует обратного («a client that hard-codes one is a client that cannot be deployed
anywhere else»). Сгенерированный клиент целится в `app.example.org`; написанный руками вынужден
игнорировать объект `servers` (`frontend/src/api/client.ts:1-9`). Лечится относительным `url: /v0`.
**Отдельно — заявленный, но не выдаваемый bearer.** Схема `bearerToken` объявлена на каждой операции
(`:564-570`) как равноправное удостоверение, а выдать его нечем: путь входа только ставит HttpOnly-куку
(`platform/internal/login/login.go:338`, `auth/cookie.go:72`), эндпойнта выпуска токена/PAT нет ни в
платформе, ни в контракте. То есть «портируемость к десктопу» сегодня не имеет носителя вовсе.
**Цена. Ц1** (объявить заголовок машинно; отдавать `WWW-Authenticate`; развести две причины 403;
поправить множество методов и `servers.url`).
**Рекомендация — менять спеку и код**, плюс записать в спеке связь «защита работает, ПОКА нет CORS» —
чтобы её снятие требовало правки контракта, а не конфига; и назвать, чем выдаётся bearer, либо снять
схему до появления носителя.
**Контраргумент.** «Заголовок-маркер — известная рабочая защита» — да, и она остаётся; речь о том, что
её нет там, где проверяют машины. «403 такого класса не документируют» (Zalando: «403 … do not
document») — верно для авторизационного 403; здесь 403 отвечает на нарушение правила, которое ИЗОБРЁЛ
сам этот документ, поэтому минимум — одна фраза в описании схемы безопасности.
---
### Б-16 (CONFIRMED · LOW). Требование HTTP/2 — ровно то, что стандарт советует не делать
**Факт.** Спека предписывает «Required: HTTP/2 at the edge» (`openapi.yaml:386`). RFC 9205 §4.1:
«Requiring a particular version of HTTP makes it difficult to use in these situations and harms
interoperability. Therefore, it is **NOT RECOMMENDED** that applications using HTTP specify a minimum
version of HTTP to be used. 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**»
(https://www.rfc-editor.org/rfc/rfc9205.txt). Стандарт называет наш же случай и говорит: отметить, а не
потребовать. Рядом — `X-Accel-Buffering: no` (вендорный заголовок конкретного прокси в норме контракта) и
heartbeat без описания формы кадра (браузерный `EventSource` SSE-комментарии не наблюдает вовсе, так что
для ратифицированного клиента heartbeat невидим).
**Цена. Ц0.** **Рекомендация — менять спеку:** требования деплоя — в примечание; форму heartbeat
описать. **Контраргумент.** «Без HTTP/2 шесть соединений на origin убивают вкладки» — верно как ПРИЧИНА
заметки; норма из этого не следует: на HTTP/1.1 сервис обязан работать хуже, а не не работать.
---
### Б-17 (CONFIRMED · LOW). `TermOrigin` протаскивает через шов ровно тот словарь, который шов запрещает
**Факт.** `TermOrigin: [seed, ruby, mined]` (`openapi.yaml:1075-1078`). `ruby` — японская типографская
фуригана, то есть паро-специфика; `mined` — имя стадии конвейера. Шапка того же документа объявляет:
«Pipeline vocabulary does not cross this boundary: no model names, no stage names» (`:20-21`), а канон
проекта — «книжный термин в общем пар-слое = утечка» (`CLAUDE.md`, гардрейлы). Клиент обязан нарисовать
слово для `ruby` в паре, где рубя не существует.
**Цена. Ц1** (в БД `origin` — колонка с чеком, `00002_readmodel.sql:158`).
**Рекомендация — менять спеку:** назвать провенанс продуктово, движковые слова оставить движку.
**Контраргумент.** «Провенанс нужен подписывающему, чтобы понимать доверие к строке» — верно и
сохраняется: меняются слова, а не различение.
---
### Б-18 (CONFIRMED · LOW). Спойлерное окно термина стоит на числе, которое сам контракт называет не-ключом
**Факт.** `since_chapter`/`until_chapter` — целые, `0` значит и «с начала книги», и «без конца»
(`openapi.yaml:1116-1126`), и оба входят в ключ уникальности термина. Но `Chapter.number` — «**Not a
key:** numbering is dense … editing the source shifts every later chapter» и может быть `null` для книги
без нумерации (`:932-939`); плотность подтверждена движком (`backend/internal/chunk/chunker.go:132`).
Окно термина привязано к величине, которая сдвигается при пере-разборе и для легальной книги не
существует.
**Цена. Ц1-Ц2.** **Рекомендация — менять спеку:** либо перевести окно на `chapter_id`, либо записать
явно, что окно живёт в системе координат нарезки и пересчитывается при смене эпохи, и развести два
смысла нуля. **Контраргумент.** «Так устроено у движка» — это происхождение, а не обоснование: движок
живёт внутри одной нарезки, клиент — поверх нескольких.
---
### Б-19 (PLAUSIBLE · HIGH для планов). Глава контракта не выдерживает структуру глав, которую владелец поставил в очередь
**Факт.** `Chapter``{id, number, heading, units_total, units_done, note_count}` (`openapi.yaml:926-965`).
Решение владельца 09.08 (`docs/research/27-chapter-detection.md:33-36,51,64,70`) требует большего:
1. **Две метки.** «ОРИГИНАЛЬНЫЕ названия из данных книги + служебный рендер „Глава N“ ($0, локаль
интерфейса)». Контракт несёт одно поле и ЗАПРЕЩАЕТ клиенту синтезировать метку: «A client MUST NOT
synthesize a label from a template such as "Chapter {n}"» (`:944-948`). А служебный рендер обязан быть
клиентским — он в локали ИНТЕРФЕЙСА, которой сервер не знает (Accept-Language нет, Б-1). Два
ратифицированных решения смотрят в разные стороны.
2. **Тип узла** (глава / технический «фрагмент») — нет.
3. **Вердикт детекции** («структура не распознана») — нет.
4. **Живая структура** в черновой волне — наблюдать нечем (Б-7).
5. **Две фазы названий** (провизорный перевод на старте, ревизия после подписи банка) — метка легально
меняется дважды за прогон; правила «когда метке верить» нет.
6. **Ручная правка структуры** — записи нет (Б-5), а цена ошибки денежная: «Money is keyed by POSITIONS …
shifting ONE chapter boundary renumbers the tail and misses all its checkpoints» (`27:70`).
Сегодняшний единственный производитель метки даёт не метку книги: `heading` манифеста — рендер по
правилу ЛАНГПАКА (для zh→ru это «Глава N»), пустой, когда у пары правила нет или у главы нет
структурного заголовка, и комментарий движка предупреждает «a consumer must not present it as one»
(`backend/internal/pipeline/manifest.go:80-86`). Настоящие названия — строка 160, в очереди D39.136 п.3.
**Почему PLAUSIBLE, а не CONFIRMED.** Спека сама объявляет этот разрыв четырьмя строками ниже правила
(`:954-956`, форвард на К-2), а рабочие строки 160/161 уже перечисляют `title_raw`, тип
`chapter/fragment` и вердикт структуры и прямо резервируют «аддитивное расширение контракта 14»
(`docs/PROGRESS.md:107-108`). То есть находка — не «никто не заметил», а **тайминг и одно конкретное
противоречие**: пункт 1 выше (запрет на клиентскую служебную метку против решения владельца 09.08)
существует уже сегодня и требует правки независимо от этапа.
**Цена. Ц0 сегодня** (чтение глав не построено) и резко дороже после P7.
**Рекомендация — менять спеку:** снять запрет на клиентскую служебную метку СЕЙЧАС (он противоречит
ратифицированному решению 09.08); `title_raw`, `kind`, структурную версию — заложить формой в 0.3.0 либо
явно передать дизайн-паку этапа 161 с записью «контракт 14 расширяется аддитивно этим паком».
**Контраргумент.** «Пока движок не отдаёт настоящих названий, поле будет пустым» — верно и нормально:
nullable-поле с честным `null` дешевле, чем смена смысла существующего `heading` после беты; смена
смысла — это мажор.
---
### Б-19а (CONFIRMED · HIGH для планов). «Добавить главу в существующую книгу»: механизма нет, но экономика уже позволяет — если дописывать В КОНЕЦ
Переписано 16.08 после разбора кода по требованию владельца («в бэкенде должен быть механизм… разберись
сначала в этом; костылей не будет»). Прежняя редакция этого буллета предлагала отдельную книгу-продолжение
**снято владельцем и мной как неверное**.
**Явного механизма нет нигде:** ни команды `tmctl`, ни флага, ни ручки платформы, ни ручки контракта
(грепом по `backend/**/*.go` на «append/add-chapters/source grew» — ноль).
**Но экономика уже почти правильная**, потому что оплаченная работа адресуется ПОЗИЦИЕЙ:
- ключ вызова сворачивает `BookID, Chapter, ChunkIdx, Attempt, Stage, Role, Model, …, SnapshotID`
(`backend/internal/pipeline/render.go:306-309`), строка резолва —
`PRIMARY KEY (book_id, chapter, chunk_idx, stage)` (`backend/internal/store/migrate.go:126`);
контент-хеш — только guard быстрого пути, не ключ поиска;
- исходник не входит НИ В ОДИН снапшот (`backend/internal/pipeline/snapshot.go:426-450`);
- нарезка идёт ПО ГЛАВАМ, `ChunkIdx` рестартует внутри главы (`backend/internal/chunk/chunker.go:124-140`).
**Следствия, и они противоположные:**
- **дописать В КОНЕЦ** — номера и индексы старых глав не двигаются, снапшот не двигается → хвост
резюмится за **$0**, платятся только новые главы; якоря читателя выживают, потому что `chapter.ID`
хеш ТЕКСТА главы, а `unit.ID` производен от него (`backend/internal/pipeline/manifest.go:128-176`);
манифест признаётся протухшим и пересобирается $0-командой;
- **вставить в НАЧАЛО или СЕРЕДИНУ** — перенумеровывается весь хвост → промахиваются и ключ вызова, и
позиционная строка → **обе волны хвоста покупаются заново, и МОЛЧА**: консент-гейт сравнивает только
снапшоты и сам признаёт слепоту к позиционной оси (`backend/internal/pipeline/rebill.go:32-35,144-146,237-239`).
Единственный тормоз — потолок книги.
**Цена, с числами** (exp08 v2 + поправка D30.4, книга 500 глав): дописать главу в конец — **$0,022-0,027**;
вставить главу в середину — **$8,8-11,0**. Разница ~400×.
**Чего не хватает:**
1. **Движок.** Его собственная спека D15.2 требует, чтобы content-addressed reuse (`tm-request-v3` +
`guard_hash`) приземлился ДО режима дописывания; не построено — в коде до сих пор `tm-request-v2`.
Минимум на сейчас: гейт, который ловит позиционный сдвиг и требует явного согласия вместо молчания.
2. **Платформа.** Принять второй файл не может: `Accept` всегда минтит новый `book_id` и создаёт
директорию с `O_EXCL`; пере-разбора нет вовсе («there is no re-parse and no un-reject»,
`platform/internal/books/parse.go:109`).
3. **Контракт.** Ручки нет, но ЯЗЫК для неё уже есть: курсор привязан к «поколению манифеста», полная
замена глав обязана отвечать `resync_required`.
4. **Мина для ja:** новая глава может принести новое ruby-чтение → алиас у сид-термина → `memory_version`
двинется → снапшот двинется → прогон упадёт без `--resnapshot`.
**Рекомендация (в бэклог, исполнение — оркестратору):** форма `POST /books/{bookId}/parts` — дописать
файл к существующей книге; ответ до подтверждения называет, сколько глав добавится, сдвинулись ли
существующие и во что это обойдётся. Стройка — вместе с этапом структуры глав (строки 160-162): там уже
запланированы миграционные карты якорей `same/moved/split/merged/gone/new`.
**Контраргумент.** «Дописывание в конец работает и так, ручка не нужна» — не держится дважды: платформа
второй файл принять не может в принципе, а вставка не в конец сегодня перекупает книгу без вопроса.
---
### Б-20 (PLAUSIBLE · MEDIUM для планов). Одна схема `Id` покрывает пять режимов стабильности
**Факт.** Одно предложение — «Stability across runs is the platform's job when it persists the manifest»
(`openapi.yaml:663-668`) — распространяется на книгу, главу, юнит, прогон и экспорт, у которых
стабильность разная по природе. Для юнита обещание заведомо сильнее возможного: движковый 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`).
**Почему PLAUSIBLE.** Судейство справедливо отделило слои: манифестный id — ПРИВАТНЫЙ движковый, а
проводной `Unit.id` минтит платформа, и её политика ещё не написана — таблица `units` в проде не
заполняется никем (`platform/internal/ingest/manifest.go:12-16` прямо говорит, что читателя пер-юнитных
массивов на этой стороне нет). То есть обещание пока не нарушено — оно необеспечено.
**Цена. Ц0** (правка формулировки) + зависимость строки 100.
**Рекомендация — менять спеку:** развести классы стабильности прямо в описании `Id``Chapter.id`
стабилен через пере-разбор, `Unit.id` стабилен только внутри структурной эпохи, и при её смене якоря на
юниты недействительны (а эпоху клиент видит — Б-7). Заодно: единственная выход-дверь контракта на случай
пере-нарезки — `resync_required` — существует ТОЛЬКО как кадр SSE; у не-стримящего клиента (и у всей
REST-поверхности) эквивалента нет.
**Контраргумент.** «Это работа строки 100» — работа её, но обещание даёт контракт, и клиент строит на
обещании.
---
### Б-21 (PLAUSIBLE · MEDIUM). Карта «чтение → источник» нигде не ведётся, и поэтому устаревает
Три чтения из шестнадцати операций не имеют полного источника, и в каждом случае причина ДРУГАЯ, чем написано в
контракте:
- **`GET /bank`** — движковая половина построена (`bankexport.go`), не хватает проекции платформы
(вход P7, строка 169). Текст спеки об этом устарел — Б-7а п.1.
- **`GET /notes`** — канал ЕСТЬ: `unit_done` несёт `flagged` и причину движка
(`backend/internal/runevents/runevents.go:126-135`), платформа их уже хранит
(`platform/internal/pgstore/sink.go:227-233`), и колонка `notes.reason` заведена именно под это
(«The ENGINE's flag reason, stored and never projected. The product phrase … applied at read time from
the contract's map», `00002_readmodel.sql:139-142`). Не хватает ДВУХ вещей: карты «причина → фраза»
(приложение А компаньона — до сих пор ЗАГОТОВКА, `README.md:389-412`) и проекции. Это НЕ строка 49
(annot-v1 — поверхность спанов для читалки, другая работа); буллет, поданный с неверной причиной,
заказал бы не ту работу.
- **`POST /exports`** — у движка только stdout-JSON и `--plaintext` (`backend/cmd/tmctl/invocation.go:107`),
носитель — строка 49/D29.1 «tmctl export-контракт».
**Рекомендация — оставить форму как есть и завести дисциплину:** таблица «чтение → источник → строка
бэклога» в компаньоне, и правило «предупреждение о недостроенном обязано нести номер строки». Без неё
такие абзацы переживают своё основание — что уже произошло четырежды (Б-7а).
**Контраргумент к «оставить».** Можно было бы снять эти операции из контракта до появления источника —
не рекомендую: они уже сгенерированы в типы клиента и служат картой работ; вред от устаревшего
ОБОСНОВАНИЯ лечится дисциплиной, а не удалением операции.
---
### Б-22 (PLAUSIBLE · LOW). Имя файла — несущая конструкция, о которой контракт молчит
`file` описан как «Book file. The LAST part of the form» (`openapi.yaml:918-921`). Между тем
клиентское имя файла делает две вещи: становится названием книги (`platform/internal/books/books.go:141`)
и выбирает читателя движка по расширению (`:371-391`). Контракт не говорит ни того, ни другого, и клиент
вынужден ПРЕДСКАЗЫВАТЬ серверное правило названия, чтобы показать его в форме
(`frontend/src/showcase/AddBook.tsx:300`). **Рекомендация — менять спеку:** записать роль имени файла;
после введения `PATCH` (Б-5) первая половина перестаёт быть необратимой.
---
### Б-23 (РЕШЕНО ВЛАДЕЛЬЦЕМ 16.08 · MEDIUM). Жанр книги — выкинуть
**Что жанр делает сегодня.** Ни на чём не ветвится: во всём Go два функциональных места —
подстановка в шаблон (`backend/internal/pipeline/render.go:185`) и поле конфига
(`backend/internal/config/book.go:28`). Ни маршрутизации, ни чекеров, ни экранов, ни langpack, ни
майнера. В промпты попадает одной фразой шапки — «Жанр книги: {{genre}}. Аудитория: {{audience}}.
Книга: «{{title}}».» — в шести ролях из семи (`backend/prompts/zh-ru/*.md`); судья жанр не получает.
Валидации нет: поле необязательное, и при пустом значении в промпт уезжает «Жанр книги: . Аудитория: …».
Замера влияния нет ни одного. Жанровый СЛОВАРЬ (другая сущность) уже отменён владельцем как класс
26.07 — D39.47, код размотан.
**Решение владельца 16.08: выкинуть.** Записано как его слово; планирование — за оркестратором.
**⚠ Цена, которую надо знать до планирования (не возражение, а факт).** `genre` входит в `BriefHash`
(`backend/internal/config/book.go:279-309`), а тот сворачивается в оба снапшота волн и в каждый
`RequestHash`; изменение канона брифа = «the whole book is re-billed on the next resume» (`:284-290`).
Значит удаление поля из брифа — **перекупка всех существующих платных книг**, и дешёвое окно то же
самое, что у структуры глав: пока платная книга одна. При этом правка жанра НА ПЛАТФОРМЕ денег не стоит
вовсе: `book.yaml` пишется ровно один раз и никогда не перезаписывается
(`platform/internal/books/render.go:71-77`).
**Форма удаления (предложение к планированию):** снять `{{genre}}` из шести промптов и поле из канона
брифа одним касанием вместе с этапом структуры глав (общее resnapshot-окно) · убрать `genre` из
`book.yaml`-рендера платформы · убрать поле из `BookIntake` и `Book` в контракте (Ц0/Ц1: поле никем не
читается, кроме карточки) · снять поле из формы интейка (зона фронта, разморозка).
**Контраргумент, который я рассмотрел:** «одна строка контекста может помогать модели держать регистр».
Возможно — но это ровно то, что не измерено ни разу за всё время, а цена наличия поля уже уплачена
(оно в `BriefHash`, то есть в стоимости пере-покупок). Решение владельца снимает вопрос; если позже
захочется вернуть — это новый вход с замером, а не восстановление статус-кво.
---
## 3. Что ревью ПОДТВЕРДИЛО
Четыре greenfield-дизайна, спеки не видевшие, независимо пришли к тем же решениям. Батчу их не трогать:
1. **Прогресс — абсолютными снимками, пофазно, ETA nullable** (все четверо; дельты небезопасны при
at-least-once, счётчик легально идёт вниз). Наши `Counter`/`Progress`/`eta_seconds` совпали до поля.
2. **Денег на проводе нет ни в каком виде** — все четверо дошли до более жёсткой формулировки, чем наша
(«поле не должно существовать в схеме»), и у нас именно так.
3. **Курсорная пагинация с непрозрачным курсором и `next_cursor` на каждом ответе.**
4. **Непрозрачные id; порядковый номер — только для показа.**
5. **Продуктовый словарь статусов вместо стадий конвейера.**
6. **Подпись банка как накапливаемый НАБОР РЕШЕНИЙ, а не правка строк** — по той же причине (банк
пересобирается). Уточнение в нашу пользу: id термина у движка уже контентный (SHA-256 от
`src+sense+since+until`, `bankexport.go:52`), то есть решение переживает пересборку; контракт этого
не требует — стоит записать.
7. **`verify_bank` и `ceiling_chapters` как параметры ПРОГОНА, не книги.**
8. **Отдельная ручка границ шкалы перед стартом** (у одного дизайна ещё quote-token; у нас это лечится
409 — приемлемо).
9. **Свежесть текста на границах стадий, а не непрерывно** — не оспорено никем: живой SQLite движка
читать нельзя (D39.85/D39.106, `docs/research/23-engine-platform-seam.md:46-47`).
10. **Опрос для артефакта экспорта** — AIP-151 прямо запрещает стриминговый ответ для LRO; наш выбор
«SSE для прогона, опрос для экспорта» оказался textbook-верным, а не непоследовательным.
---
## 4. REJECTED — и почему
Отвергнутое — тоже результат. Девять из этих кандидатов опрокидывали ратифицированные решения
владельца, четыре — мои ошибки чтения стандарта или кода.
| # | Кандидат | Почему отвергнут |
|---|---|---|
| 1 | 500 и `default` не документированы | Zalando прямо: «500 Internal Server Error · use · **do not document** · \<all\>». RFC обязанности не создаёт. Контракт последователен. |
| 2 | 429 не выражен | На `/v0` лимитера нет вовсе — нечего недо-объявлять. Форвард-вопрос платформы, не дефект контракта. |
| 3 | 404 несёт три смысла | Ратифицировано (PD-174, `:143-145`), и RFC 9110 §15.5.5 это прямо разрешает: 404 покрывает и «is not willing to disclose that one exists». Остаток — узкий: ратифицирован смысл только для `createBook`, а отвечают так семь операций; сворачивается в Б-2. |
| 4 | 405 не выдаётся / две формы 405 | Следствие метод-независимого catch-all (проверено по stdlib go1.26.6), а `/healthz` вне контракта по решению его владельца. |
| 5 | 413 только на интейке, иначе 400 | На тех маршрутах контракт объявляет 400 и не объявляет 413 — код отвечает единственным легальным. Объявлять 413 везде значило бы тащить порог в документ. |
| 6 | 409 «нет денег» одет фразой про потолок | **Моя ошибка.** `ErrInsufficientCredit` возникает ровно в гонке между чтением границ и взятием холда (`pgstore/credits.go:174-176`) — то есть в том самом случае, который фраза и описывает; ретрай с меньшим потолком работает. При исчерпанном балансе шкала отдаёт `max_chapters: 0` ещё до старта. |
| 7 | 408 без `Retry-After` и без `Connection: close` | **Моя ошибка дважды.** RFC 9110 §15.5.9 про `Retry-After` не говорит ничего; `Connection: close` net/http ставит сам на этом пути. |
| 8 | 503 без `Retry-After` | RFC 9110 §15.6.4 — «The server **MAY** send a Retry-After», а спека прямо пишет «no Retry-After is promised» (`:656-657`). Осознанно, ратифицировано D39.123. |
| 9 | `paused_reason` беднее реальности (три причины у платформы, одна в enum) | **Ратифицировано 15.08:** «PD-199 закрыт этим же решением: `null` на проводе подтверждён, слово в контракт не заводится» (D39.132 п.2а) — тем же решением, что убрало `day_usd` из шаблона. Пере-судил и не опрокидываю: с уходом дневной оси из платформы различие перестало быть продуктовым состоянием. **Выживший остаток вношу в батч:** `:879` по-прежнему пишет «`null` in every other state», а ратифицированное поведение даёт `paused` + `null` — правка описания, Ц0. |
| 10 | `EventCeiling` без `scope` | Тем же решением. Остаток: `halted: boolean`, всегда `true` — обязательное поле, не несущее информации. |
| 11 | Bearer-клиент не может читать SSE | **Моя ошибка:** `text/event-stream` — обычный HTTP-ответ; ограничение у браузерного `EventSource`, а не у контракта. Остаётся продуктовый вопрос (webview-десктоп), не дефект. |
| 12 | Два механизма для двух долгих операций | AIP-151: «The response must not be a streaming response» — опрос для артефакта и есть стандартная форма. |
| 13 | `min_chapters: 1` при `max_chapters: 0` | Документированный сентинел (`:1231-1233`), ратифицирован D39.115 §4, клиент его уже проверяет первым (`RunStart.tsx:122`). Остаток — общий: OpenAPI не выражает межполевой инвариант. |
| 14 | `default_chapters` = верх шкалы | Ратифицировано с явно названным компромиссом (D39.123 п.2е, D39.110 §2в). Не мой вызов; называю как конфликт интересов «продукт ↔ деньги». |
| 15 | Клиент обходит все страницы | Это КОНФОРМНОЕ поведение, предписанное спекой (`:99-100`). Остаток — у контракта нет НИЖНЕЙ границы страницы, и конформный сервер с 10 строками на страницу упрёт клиента в его потолок 50 страниц. |
| 16 | Страница глав 5000 при 2284 главах | Не дефект: страница больше коллекции — это чтение без разрывов. Остаток — два разных владельца одного числа (проза vs «platform's choice»), учтён в Б-2. |
| 17 | Курсор без эпохи (по коду библиотеки) | **Мой промах в адресации:** реализован только библиотечный курсор, которому эпоха не нужна. Дефект переформулирован как контрактный — Б-7. |
| 18 | `/usage` назван не тем, что отдаёт | Операция самоописательна («State of the credit balance»), переименование пути — ломающая правка ни за что. Остаток: `Usage.paused_reason` типизирован прогонным enum, тогда как PD-203 (D39.136 п.6а) развёл уровни — нужен свой словарь причин аккаунта либо снятие поля (в батч, Ц0). |
| 19 | Байт-копия спеки никем не сверяется | Неверно: сверка — обязанность лендинга и она исполняется (D39.135 п.2а); я сверил `cmp` — файлы идентичны. Остаток процессный: сверка ручная, не в CI. Пингом оркестратору, не в батч. |
| 20 | Обоснование `Retry-After` на 200 неверно читает RFC | Спорно в обе стороны: §10.2.3 начинается с общего правила, но специфицирует только 503 и 3xx. Решение (объявить) безвредно; довод не тяну. |
| 21 | Добавочные поля `BankTerm` (evidence/variants/conf, aliases) для экрана подписи S5 | **Слово владельца 15.08:** «Добавочные поля `BankTerm` НЕ заводить … смысла хватает» (D39.136 п.4б). Снято. Поправка к кандидату: у `BankTerm` девять полей, не восемь, и блокер S5 — отсутствующий канал ЧТЕНИЯ, а не набор полей. |
| 22 | `reject_reason` optional против `paused_reason` required — асимметрия | Спека аргументирует её в собственном тексте (`:826-831`), и довод состоятелен: требовать поле значило бы сломать деплой, который старше минора. Пере-судил; честная версия найдена в другом месте — `Usage.paused_reason` (см. Б-14). |
| 23 | `Unit.target` optional против `sense` required — «одна задача, два решения» | Не одна задача: `sense` входит в КЛЮЧ уникальности, `target` дизамбигуируется обязательным `state`. Форма исправления взята из К-11 (условная обязательность), а не из симметрии имён — она и вошла в Б-14. |
| 24 | Счётные имена не выровнены (`chapter_count` / `units_total` / `total`) | Правило есть и без исключений: короткое имя там, где у поля есть парная метрика в том же объекте. Остаток — два референта у слова `total` — перенесён в Б-14. |
| 25 | `src`/`dst` у банка против `source`/`target` у юнита | Выбор объяснён в схеме (`:1085-1087`) и в компаньоне историей ошибки, которую он исправляет: у движка `source` — это ПРОВЕНАНС. Переименование — самый дорогой класс правки ради стиля. |
| 26 | `TrustedOrigins` против «CORS-слоя нет вовсе» | Ратифицировано: D39.111 п.1 «В-кватер» (`:285`) — «кросс-origin не проектировать»; и это открытая строка СВОЕГО регистра платформы (PD-96), явно помеченная «НЕ трогать» тем же решением. Вне зоны этого ревью. Остаётся замечание о коде: комментарий `csrf.go:26-27` обещает отдельно развёрнутый фронт — мёртвая ручка с вводящим в заблуждение комментарием. |
| 27 | `/auth/*` вне контракта | Ратифицировано (`:37-39` + компаньон §2.14, D39.99) как механика сессии, а не контрактная поверхность. Выживает половина, и она НЕ контрактная: 401 — тупик (нет вызова, нет ссылки на вход, клиент рисует его как отказ) — строка бэклога фронта, которой сегодня нет. |
| 28 | Ставка $0.03/глава искажает шкалу | Не контрактная поверхность вовсе (пересчёт «главы → деньги» на провод не идёт, D39.84), свойство задокументировано в самом коде («It is an ESTIMATE and it is wrong for any particular book … Nothing depends on it being right»), носитель остатка — строка 166. |
| 29 | `ExportRequest.format` — свободная строка | Прозрачно отложено самой схемой («the set of formats is stage S7 work»), обе операции не построены. Остаток — «обязательное поле с неопределённым пространством значений» — учтён в Б-2 как часть документа возможностей. |
---
## 5. Предложение состава батча 0.3.0
> **Состав уточнён 16.08 по решениям владельца (§8).** Добавлены Б-0 (утечка пайплайна) и жанр; снята
> история прогонов; форма ответа на В-1 выбрана — вариант B.
**Ядро (без него остальное не имеет смысла):**
0. **Б-0. Убрать конвейер с провода**`Progress` в один счётчик до остановки (закрывает и Б-13а),
`finalizing` свернуть, `verify_bank` переименовать, `TermOrigin`/`TermStatus` снять, описания
вычистить, завести гейт. Решение владельца 16.08: «переделываем».
1. **Б-1. Модель ошибок** — вариант B (решение владельца 16.08): машинный `code` (двухуровневый) +
`request_id`; `title`/`detail` объявить developer-facing и неотображаемыми; серверная локализованная
фраза — отдельным полем и только для неперечислимых причин; `errors[]` с указателем на поле; два
класса конкретности из §8а.
2. **Б-15. Полнота и машинность безопасности**`X-TM-Client` как header-параметр, 403 в ответах (хотя
бы фразой в описании схемы), `WWW-Authenticate` на 401, множество небезопасных методов, относительный
`servers.url`, судьба заявленного bearer.
3. **Б-2. `GET /capabilities`** — версия · пары · порог интейка · форматы · размеры страниц. Снимает
Ф-57, половину Б-3 и «зашитые числа» разом.
4. **Б-7а. Вычистить устаревшие утверждения о движке** — три предупреждения снять, `resume`-абзац
переписать на действующее лечение (новый прогон с бо́льшим потолком). Дешевле всего в батче и
единственное, что сегодня отнимает у пользователя работающее действие.
**Пока дёшево (операции не построены — Ц0):**
5. **Б-6. Поток** — перевесить на книгу; кадры конца разбора и конца прогона; `204` при переподключении
к завершённому; развести `id` кадра и ревизию; правило докачки; запрет склейки `note`.
6. **Б-4. Экспорт** — состояние вместо булева `ready`, `failure_code`, `expires_at`, эхо формата,
правило доступа к ссылке.
7. **Б-9 + Б-14а. Замечание и стоп подписи**`id`, `created_at`, обязательная адресация;
`pending_decisions`/`complete` в чтение банка.
8. **Б-19. Глава** — снять запрет на клиентскую служебную метку СЕЙЧАС; `title_raw`, `kind`, структурная
версия — формой здесь либо явной передачей дизайн-паку этапа 161.
9. **Б-7. Структурная версия и условные чтения** — версия в ответах и в `hello`, `410` на исчезнувшую
главу, `If-None-Match`/`304`.
10. **Б-12. Агрегаты банка** — правило согласования со страницами.
**Дёшево и на построенном (Ц1):**
11. **Б-3.** Запрет молчаливой потери; порядок свойств `BookIntake`; `Idempotency-Key`; `Location` на 201.
12. **Б-8 + Б-13а.** `failure_reason` у прогона; знаменатель прогона в главах (или явная запись, что
`Progress` — книжный).
13. **Б-13.** Отдельный `RunStatus`; правило старшинства; `book_id` в `Run`; судьба `finalizing`.
14. **Б-5.** `PATCH /books/{id}` (только `title` — жанр уходит из продукта, §8 п.6); `DELETE /books/{id}`;
`GET /runs/{runId}`. Записать явно: правка метаданных — ОТОБРАЖЕНИЕ, до движкового брифа она не
доезжает (иначе первый же пере-рендер `book.yaml` перекупит книгу).
15. **Б-10 / Б-11.** Максимум `limit` и подрезание вместо понижения; объявленный порядок коллекций;
`maxItems` у `decisions`.
16. **Б-14.** Одно правило «отсутствия значения»; условная обязательность `Unit.target`; имена конвертов.
**Правки одной строкой:** Б-17 (`TermOrigin` → продуктовый словарь) · Б-18 (окно термина) ·
Б-16 (требования деплоя — в примечание) · Б-20 (классы стабильности `Id`) · Б-22 (роль имени файла) ·
описание `paused_reason` при `null` (остаток §4 №9) · `Usage.paused_reason` (остаток §4 №18) ·
`Export.revision` · кэш-директивы в схему (сегодня платформа ставит `no-store` на ВСЕ ответы,
`middleware.go:38`, а контракт требует его только для ответов с переводом — контракт беднее кода).
**Отвечено владельцем 16.08, вопросов не осталось:** «добавить главу» — дописывать в ТЕКУЩУЮ книгу, без
костылей (Б-19а, форма `POST /books/{id}/parts`) · дефолт шкалы остаётся, но клиент получает причину
`blocked: {code, book_id}` (В-6).
**Развилка снята владельцем 16.08: «экспорт будет».** Значит операции остаются, и пункт 6 становится
ОБЯЗАТЕЛЬНЫМ: без состояния отказа опрос «готово?» не завершается никогда. Предложение §5а снять обе
операции — отозвано.
**Идёт отдельными строками бэклога (не правки спеки):** снятие жанра из брифа и промптов вместе с
этапом структуры глав (§8 п.6) · форма `POST /books/{id}/parts` и движковый гейт против молчаливой
перекупки (§8 п.7) · сжатие и условные чтения на платформе (§5б шаги 1-2) · сворачивание замечаний в
тексте под кнопку (§8 п.11) · обнуление гранта на бете (§8 п.14).
**Не в батч, записать направлением:** двухшаговая загрузка · лента изменений структуры · поиск и фильтр
по банку и дереву · «грубая группа статуса» для эволюции словаря · дисциплина «предупреждение о
недостроенном несёт номер строки бэклога» (Б-21). **Снято решением владельца:** история прогонов
(в МВП не нужна).
### 5а. Что РЕЗАТЬ (по вопросу владельца «не переусложнён ли контракт»)
Мерка: 1445 строк — **625 прозы против 760 формы (43 % файла — проза)**, и внутри прозы нормативного
всего ~37 предписаний (24 MUST, 3 MAY, 4 forbidden). Остальное — генезис: 12 ссылок на конкретный минор,
13 на компаньон, 15 «rather than», 21 «would». Спека сама пишет о себе «This file is normative for the
FORM; the companion explains where the form comes from» (`:12-13`) — и сама это нарушает: история
ратификации semver (`:47-53`), две апологии RFC 9110 (`:534-541`, `:644-651`), объяснение, почему имя
`source` не взято (`:1084-1087`). Всё это — в компаньон.
**Мёртвое, режется без потерь (~186 строк формы плюс проза):**
| Что | Почему мёртвое |
|---|---|
| ~~экспорт, 2 операции, 84 строки~~ | **ОТОЗВАНО решением владельца 16.08 «экспорт будет».** Кандидат был: снять до S7, потому что фиксируется «only the call shape», содержимое которого неизвестно. Раз экспорт в продукте — операции остаются, и вместо снятия делается Б-4 (состояние вместо булева `ready`, `failure_code`, `expires_at`, эхо формата, правило доступа к ссылке). Заодно решается К-12 |
| **`Problem.instance`** | структуры на платформе нет вовсе; реальная форма ответа — `{title, status}` |
| **`Note.unit_id`** | только в фикстуре мока; экраны берут `unit.note` вложенно и `note.chapter_id` |
| **параметр `limit`** | клиент не отправил его ни разу (все пять вызовов идут без `query`) |
| **`EventCeiling`** | единственное поле `halted`, всегда `true`, клиент его выбрасывает; дубль кадра `status`, который уже несёт `paused_reason` |
| **нагрузка 4 кадров из 8** | `EventBank`, `EventNote.note`, `EventHello.run_id/revision`, `EventResyncRequired.reason` передаются и игнорируются. Честная форма: имя кадра + `id`, нагрузка только там, где клиент патчит без чтения (`status`, `progress`, `chapter`) — три схемы вместо восьми |
| **`Bank.signed`** | доезжает до клиента и не рисуется |
**Что оставить, оно несёт вес:** ревизия и её гард (лечит замеренную регрессию — прогресс откатывался на
refetch по фокусу), курсор (держит один ответ ограниченным), `verify_bank`, `ceiling_chapters`/
`RunOptions`, `eta_seconds` (проходит сквозь всю цепь до экрана).
**Слить формально:** пять конвертов списков (`Library`/`ChapterList`/`UnitList`/`NoteList`/`Bank`)
вручную повторяют `revision`+`next_cursor``allOf` сделает инвариант проверяемым; `EventChapter`
ровно изменяемое подмножество `Chapter` с переименованным `id``chapter_id`, и клиент разбирает это
переименование руками.
**Из восьми непостроенных операций** экспорт (обе) остаётся по решению владельца. Мои сомнения
сохраняются по двум другим: `listBankTerms` (форму задаст содержимое уже построенного сайдкара банка, а
не догадка до него; за этим чтением висит крупнейший блок файла — `BankTerm` плюс три словаря) и
`listNotes` (единственное содержательное поле — ненаписанная фраза, карта «причина → фраза» пуста, число
ступеней открыто). Это не предложение снять, а предложение НЕ доводить их форму до финала, пока источник
не построен. `listChapters`, `listUnits`, `streamRunEvents`, `submitBankDecisions` — оставить как есть:
это ридер, у него есть рабочий клиент и мок.
**Зеркальный дефект:** `/usage` ПОСТРОЕН и не читается ни одним экраном (`usageQuery` встречается только
в тесте) — то есть в контракте одновременно есть и невостребованное построенное, и построенное
невыраженное.
### 5б. Транспорт: менять ли REST (вопрос владельца 16.08 — «есть ведь ещё gRPC»)
Отдельная проверка независимым агентом другого тира (`fable`) с веб-ресёрчем; я перепроверил ключевые
цитаты по первоисточникам — дословны. Вывод совпал с моим.
**Транспорт не менять. Лечить HTTP.** «REST умеет в сжатие» — верно, но сжатие это не свойство REST, а
слой HTTP (`Content-Encoding`), который надо включить явно: Go stdlib его не делает, и это уже записано
дырой в собственном доке платформы (`platform/docs/PLATFORM_DIRECTION.md:202-205`).
**Почему не gRPC.** Наша нагрузка — художественный текст: глава 26,1 КБ это почти целиком сам текст.
Смена сериализации экономит на ключах и структуре, а не на тексте; после gzip разница между JSON и
protobuf на таком payload — единицы процентов. Цена: «gRPC-web clients connect to gRPC services via a
special proxy; by default, gRPC-web uses Envoy» и «Client-side and Bi-directional streaming is not
currently supported» (https://github.com/grpc/grpc-web) — новая инфраструктурная деталь на same-origin
продукте, вторая кодогенерация параллельно ратифицированному OpenAPI, потеря `If-None-Match`/304 и
читаемости в DevTools. **Connect** честно лучший из RPC-вариантов ровно потому, что возвращает обычный
кэшируемый HTTP GET + JSON («For RPCs that have no side effects, it is possible to use GET requests
instead… easy to cache in browsers, proxies, and CDNs», https://connectrpc.com/docs/protocol/) — то есть
платить миграцией за то, что у нас уже есть. **WebSocket** окупается только вместе с sync-движком (см.
ниже), **GraphQL** лечит over-fetching формы, а у нас болит частота перечитываний.
**Наш паттерн «кадр без данных → клиент перечитывает» — легитимный, у него есть имя: poke/pull.**
Replicache: «A Replicache poke caries no data it's only a hint telling the client to pull soon. This
enables developers to build their realtime apps in the standard stateless request/response style»
(https://doc.replicache.dev/byob/poke). Мейнтейнер React Query о том же для нашего стека: «I like to
send events from the backend instead of complete data objects… if we receive an event for an entity
that we are not interested in at the moment, nothing will happen»
(https://tkdodo.eu/blog/using-web-sockets-with-react-query). GitHub строит на этом же:
«If the data has not changed, you will receive a 304 Not Modified response, which does not count
against your primary rate limit» (https://docs.github.com/en/rest/using-the-rest-api/best-practices-for-using-the-rest-api).
Дельта-пакеты вместо poke шлют те, у кого много одновременных писателей и local-first-редактирование
(Figma, Linear) — у нас писатель один (движок), клиент читает прогресс.
**Но наш poke недоукомплектован по сравнению с нормой:** кадр не несёт СКОУПА (что именно изменилось),
поэтому клиент перечитывает всё; и перечитывание ничем не удешевлено.
**План по эффекту на килобайт усилия:**
| # | Шаг | Эффект на наших числах |
|---|---|---|
| 1 | сжатие ответов (middleware/edge; на SSE НЕ вешать) | глава 60 %, дерево 84 %, банк 90 %; кадр на 12 вкладок 562 → ~164 КБ |
| 2 | `ETag`/`If-None-Match` → 304 на всех списочных GET | фокус-рефетч 562 КБ → ~4 КБ заголовков, 99 % |
| 3 | скоуп в кадре (id сущности + версия, по-прежнему БЕЗ текста) | кадр стадии 767 КБ → ~50 КБ; неоткрытые вкладки не читаются вовсе |
| 4 | дельта-чтение банка/замечаний `?after_version=` + коалесинг на эмиттере | сценарий «кадр банка на главу» 372 МБ → 2-3 МБ |
| 5 | клиенту — поднять `staleTime` (после шага 2 фокус-рефетч почти бесплатен) | зона фронта |
Шаги 1-2 — слой HTTP, контракта по семантике не касаются (но должны быть В НЁМ записаны, сегодня они
живут только в зонном доке). Шаги 3-4 — минорная правка контракта, не смена транспорта.
**Чего НЕ делать:** менять транспорт · строить sync-движок уровня Linear/Figma (месяцы за то, чего
продукт не просит) · класть текст в кадры (второй источник истины и гонки с чтениями) · заводить ручку
«вся книга юнитами» (57 МБ) «раз уж будет сжатие» · включать сжатие на `text/event-stream`.
**Версия.** Правки ломающие по смыслу (снимается опора на серверную фразу, меняется словарь `Problem`,
переезжает канал потока). По нынешнему правилу 0.x минор — лана для ломающих правок (`:47-50`), значит
формально **0.3.0**. Вопрос «не сделать ли его последним 0.x перед 1.0» задан и **закрыт владельцем
16.08: НЕТ — последний 0.x будет после беты.** То есть 0.3.0 — обычный ломающий минор, и право ломать в
0.x сохраняется на весь бета-период; окно «после беты» и есть место для 1.0.
---
## 6. Диспозиции по обязательным входам промта
| Вход | Диспозиция |
|---|---|
| **Ф-56** конец разбора | **Б-6(а).** Чинить не кадром, а перевеской канала на книгу: `uploading`/`parsing` прогону не принадлежат. Сегодня Ц0. |
| **Ф-57** пары языков | **Б-2.** Отдельной ручки пар не делать — один документ возможностей; плюс код отказа по паре на интейке. Худший случай прослежен: пар-промпты есть только для `zh-ru`, отсутствие промпта — жёсткая ошибка конфигурации, поэтому две из трёх предлагаемых пар умирают на старте прогона; денег это не стоит, но пользователь узнаёт об этом только `failed`-ом без причины в конце пути. |
| **Ф-61** хардкод-английский | **Б-1.** Машинный `code` + клиентская фраза; `Accept-Language` — только для неперечислимых причин. Закрывает половину слова владельца 15.08 о мультиязычности всех зон; вторая половина (фразы в логах и артефактах движка) вне контракта — носителя назвать отдельно. |
| **Ф-62** переименование | **Б-5.** `PATCH` только с `title` (жанр уходит из продукта — Б-23, решение владельца 16.08); `DELETE` обязателен рядом. Пара языков и структура — не метаданные. Правка названия — отображение: до движкового брифа она не доезжает, и это надо записать. |
| **Форма PD-172** | **Б-3. Протёкшая реализация в части МОЛЧАНИЯ, осмысленный контракт в части порядка.** Правило «файл последний» оставить, реакцию сменить на 400; порядок свойств в схеме исправить (сегодня он указывает нарушить правило). Двухшаговая загрузка — направлением. |
| **Пагинация и масштабы** | **Б-10, Б-11, Б-7.** Главное не размер страницы, а: не объявлен порядок, нет максимума `limit`, нет фильтра и поиска, нет наблюдаемой структурной эпохи. Масштабы сверены с движком (2284/7,78 млн; ~1,9 юнита на главу). |
| **Зашитые числа** | **Б-2.** В контракт как константы — нет (тут комментарий кода прав); в ответ документа возможностей — да. Исключение: максимум `limit` принадлежит контракту как предел формы. |
| **Паттерн-консистентность** | **Б-14.** Названная в промте пара (`paused_reason` / `reject_reason`) как раз ЗАЩИЩЕНА текстом спеки и снята (§4 №22); вместо неё найдено семь мест без обоснования нигде — включая то же имя `paused_reason` с двумя разными обязательностями на трёх схемах. Победитель — правило `Chapter.number`/`sense`. |
| **Идемпотентность, 408/413, 404-как-нет-интейка** | **Б-3 + §4 №3,5,7.** Идемпотентность — да, `Idempotency-Key` (RFC 9110 §9.2.2 прямо про наш случай: контракт советует ретрай и не даёт средства). 408 и 413 — коды верные, претензии сняты. 404 на `createBook` — ратифицирован и разрешён RFC; остаток про непостроенные маршруты сворачивается в Б-2. |
| **Версионная политика** | **Б-2 (часть).** Недостаточна: минорную версию не видит ни один не-потоковый клиент, а сегодня — вообще никакой (носитель не реализован). Минимум — версия в документе возможностей или в заголовке ответа. |
| **К-вопросы** | Таблица ниже. |
| **Спойлер (В-10)** | **ЗАКРЫТ владельцем 16.08: «вежливость».** Контракта не касается вовсе: `sense` остаётся обязательным, размытие — дело фронта, находимость текста через Ctrl+F — приемлемое следствие, а не дефект. Ревью его не трогало и по условию промта (слова «защита» не было), и по существу. |
### К-вопросы компаньона: что закрывает это ревью
| К | Статус | Вердикт ревью |
|---|---|---|
| К-2 (титул главы) | открыт | **Не закрывать формой «одно поле»** — Б-19: нужны два носителя, а нынешний запрет на клиентскую метку «Глава N» противоречит решению владельца 09.08 и должен быть снят. |
| К-4 (ревизия) | отвечен P0 | Подтверждаю, с поправкой Б-6(г): ревизия и позиция потока — разные величины, совмещать их в `id` нельзя. |
| К-6 (ступени замечания) | **ответ владельца 16.08** | «Показывать все; в тексте — сворачивать и раскрывать по кнопке; как именно — решим потом». Значит словарь ступеней проектировать заранее НЕ надо. Зависимость остаётся: без `Note.id` (Б-9) конкретное замечание нельзя свернуть и запомнить. В бэклог. |
| К-7 (пагинация) | отвечен P0 | Курсор верен; недостаёт порядка, максимума и фильтра (Б-10/Б-11). |
| К-9 (отказ прескрина) | **принято владельцем 16.08** | Прескрин — ещё одна ПРИЧИНА, а не двенадцатый статус: `rejected` + код. Владелец добавил рамку: отвечать максимально абстрактно, чтобы подбирающий не понял, где именно он валится — то есть ОДИН грубый код на весь модельный класс, без деталей и без вариации между попытками (§8а). |
| К-10 (пофазность у главы) | открыт | **Закрывается бесплатно:** обе фазы уже в read-модели (`00002_readmodel.sql:102-105`), это правка проекции. |
| К-11 (условная обязательность) | открыт | Частично закрывается Б-14: `Unit.target` — на правило `sense` (инвариант уже гарантирован чеками БД), `Note` — обязательная адресация. Остаток (генератор игнорирует `if/then`) — инструментальный, лечится сужением на шве клиента. |
| К-12 (завершение выгрузки) | отвечен P0 (опрос) | Ответ верен и подтверждён AIP-151. Но Б-4 важнее: без состояния отказа опрос не завершается никогда. |
| К-13 (`paused_reason` не различает) | открыт (владелец) | **Закрыт не мной:** D39.132 п.2а ратифицировал `null` на проводе. Остаток — противоречие в описании `:879` (в батч). |
---
## 7. Не покрыто · препятствия · самопроверка (редакция 15.08; дополнения после разбора — §10)
**Не покрыто по решению:** В-10/спойлер (нет слова владельца) · полигон (`eval/`, `docs/experiments/`) —
чужая живая зона, не читал · `/auth/*`, `/healthz`, `/readyz`, `/metrics` — вне контракта; смотрел только
там, где они касаются контрактной поверхности · административная поверхность (её в контракте нет, и это
правильно; три из четырёх greenfield-дизайнов её спроектировали — когда она появится, встанет вопрос
«одна спека или две»).
**Попутно замечено, передаю пингом (не находки контракта):** `/metrics` слушает без аутентификации на
отдельном адресе, защищает только привязка к `127.0.0.1` (`platform/cmd/tmplatformd/main.go:200-202`);
лимитер входа один на процесс, не пер-адресный (`platform/internal/login/login.go:128-129,165`) — выбор
объяснён комментарием (`:170`, за прокси `RemoteAddr` один на всех), но следствие «один клиент упирает
вход всем» стоит назвать; сверка байт-копии спеки — ручная, не в CI.
**Препятствия:**
- **Кросс-модельность опровергателей.** Требование промта §2.5 при границе «$0 по внешним провайдерам»
(§5) невыполнимо: все доступные харнессу агенты — семейства Claude. Компенсировал ролью-опровергателем,
разными тирами модели у аудитора улик и адвоката формы, проверкой каждой улики ИСПОЛНЕНИЕМ и
greenfield-панелью, спеки не видевшей. Это ослабляет ОЦЕНОЧНЫЕ вердикты (не фактические): засчитывать
их как независимое подтверждение нельзя. Настоящий кросс-семейный проход по этому докладу — отдельный
заказ полигонного контура.
- **Рантайм не поднимался** (по §5 промта). Всё, что от него зависит, помечено PLAUSIBLE: наблюдаемость
`problem+json` при 413/408 посреди тела, склейка кадров (кода
склейки не существует).
- **Масштаб замечаний не замерен никем** — ни один документ и ни один прогон не говорят, сколько
замечаний даёт настоящая книга. Страница 500 и книжный (не поглавный) доступ выбраны без числа.
- ~~Единицы юнитов~~ — **закрыто 16.08 замером**: манифест движка на настоящей книге даёт 2283 главы,
4276 юнитов, 5071 чанк (то есть 1,87 юнита на главу — «~1,9» спеки верно, и фикстура фронта не выдумана).
**Пере-проверка 16.08 (по прямому вопросу владельца «что поленился дочекать»):**
- прогнал ВСЕ 126 ссылок на строки спеки, встречающиеся в этом файле, против самого файла: вне диапазона
нет ни одной; глазная сверка содержимого каждой — совпадает, найдена и исправлена одна ошибка на
единицу (`:192``:193`);
- проверил три утверждения, взятые у агентов на веру: `ErrorBoundary` во фронте действительно нет
(грепом пусто) · сжатие и ETag действительно названы требованиями в `PLATFORM_DIRECTION.md:200-206`,
причём с той же цифрой «289 КБ против 46 КБ gzip» · основание цифры «26 КБ на главу» сходится по
`~/books/gu-zhenren/minirun/export.json`;
- прогнал линтер проекта по канону — `spectral lint docs/architecture/14-api-contract/openapi.yaml
--ruleset frontend/.spectral.yaml` → «No results with a severity of 'error' found!». То есть документ
валиден как OpenAPI 3.1 и чист по правилам проекта: **все находки этого доклада — смысловые, ни одной
синтаксической**. Раньше я его как машинный артефакт не прогонял — это была дыра в методе;
- **нашёл и исправил СВОЮ ошибку в HIGH-буллете Б-2**: механизм отказа по неподдерживаемой паре — не
«тихая деградация langpack до платных вызовов», а жёсткая ошибка валидации конфигурации из-за
отсутствующих пар-промптов, ДО единого платного вызова. Деньги не сгорают; неверная была только
атрибуция механизма и слова «доходит до платных вызовов».
**Самопроверка (оси D39.120):**
1. *claim-fidelity* — перед сдачей пере-открыл исполнением 24 улики (не 10): `problem.go:22-28`,
`v0.go:104-132/242/250/297-302/352-378/384/505-510/518/544-572`, `server.go:99/120/134/143`,
`csrf.go:26-33/38/62`, `reqid.go:16-27`, `middleware.go:22/38/68`, `pricing.go:87/97`,
`pgstore/books.go:349/510/586-587/821-831`, `00002_readmodel.sql:91-199`, `sink.go:178-181/435`,
`runevents.go:45-102/151-153`, `manifest.go:77-102`, `bankexport.go:52/72`, `mining.go:317-322`,
`client.ts:87-115`, `queries.ts:64-75/141-151`, `stream.ts:17/97/131-139`, `Loaded.tsx:58-69`,
`Notes.tsx:41-43/63-66`, `AddBook.tsx:278-292`, `Status.tsx:21`, `languages.ts:14-16`,
`27-chapter-detection.md:33-73`. Все цитаты стандартов сверены по скачанным текстам как ДОСЛОВНЫЕ
подстроки. Пойманное этой проверкой на СВОЁМ тексте: контракт несёт **16 операций на 15 путях**, а не
15 операций (`grep -c operationId:` — 16), и построено 8, не 7; неверно назывался эпизод с курсором
(реализован только библиотечный, эпоха ему не нужна — §4 №17); четыре ссылки на строки в кандидатах
указывали не туда (`runevents.go:64`, `books.go:96`, `middleware.go:111`, `Usage`-диапазон) и
исправлены по месту.
2. *анти-инерция* — у каждого «оставить как есть» записан контраргумент; в §4 девять отвержений
опираются на ратификации владельца, и в каждом сказано, что именно я пере-судил и почему не
опрокидываю.
Обратная сторона той же дисциплины: собственные буллеты, опровергнутые кодом или стандартом (№6-8,
11, 15-17), сняты полностью, а не «понижены до low».
3. *полнота интересов* — у каждого буллета названы «чья боль» и класс миграционной цены; конфликты
интересов названы явно, не решены молча (дефолт шкалы «весь баланс под одну книгу»; порог интейка —
деплой против клиента; фразы отказа — сервер против клиента).
4. *leave-one-out по линзам* — мульти-линзовые буллеты держатся без каждой линзы по отдельности: Б-1 стоит
на коде и без RFC (три места печатают серверную строку в русском интерфейсе) и на RFC без greenfield;
Б-2 стоит на коде (`languages.ts` + отсутствие langpack) и без панели; Б-7 стоит на греп-факте
(«epoch» встречается ровно один раз) и без панели. Буллетов, живущих ТОЛЬКО согласием линз, в докладе
нет.
---
## 8. Решения владельца 16.08 по этому докладу (ратифицирует оркестратор; сессия отчёта строк не заводит)
Разбор шёл вопрос-за-вопросом; ниже — что решено, дословный смысл решения и что это значит для работ.
**Это раздел для будущих сессий: он говорит, ЧТО именно правится и ГДЕ, чтобы не пришлось перечитывать
всю переписку.**
| # | Вопрос | Решение владельца | Что это значит конкретно |
|---|---|---|---|
| 1 | Утечка пайплайна в контракт (Б-0) | **«Согласен, переделываем»** | `Progress{draft,edit}` → один счётчик до ближайшей остановки; `finalizing` свернуть; `verify_bank` → `stop_for_signing` без «before the final pass»; `TermOrigin`/`TermStatus` снять с провода; `Unit` описать без «edit unit»; вычистить конвейерные слова из описаний (они компилируются в исходники фронта); завести гейт на утечку. Затрагивает: спеку, `wireProgress` (`v0.go:92-96`), проекцию (`v0.go:473-477`), фронт (`format.ts:109-110`, `About.tsx:96`). Колонки платформы и словарь шва при этом МОЖНО оставить как есть — фазы остаются внутренним делом |
| 2 | Транспорт | **«Что сам думаешь»** → ответ §5б, проверен независимым агентом | Транспорт НЕ меняем. Порядок работ: сжатие → ETag/304 → скоуп в кадре → дельта-чтение банка/замечаний → `staleTime` у клиента. Записать сжатие и условные чтения В КОНТРАКТ, а не только в зонный док |
| 3 | Переусложнённость | **«Дампануть буллетами будущим сессиям»** | §5а: что резать (экспорт, `Problem.instance`, `Note.unit_id`, `limit`, `EventCeiling`, нагрузка 4 кадров, `Bank.signed`), что слить (`allOf` на конвертах), что оставить. Проза 43 % файла — генезис переносится в компаньон |
| 4 | В-1, кто пишет фразу отказа | **«Идём в Б путём гугла»** | Вариант **B**: машинный `code` (закрытый словарь, двухуровневый — корневой стабильный + расширяемый вложенный, как у Microsoft) + `request_id`; `title`/`detail` объявляются developer-facing и НЕ показываются; серверная локализованная фраза остаётся ОТДЕЛЬНЫМ полем и только для неперечислимых причин (как `google.rpc.LocalizedMessage`). Клиент уже почти готов: таблица фраз по статусам есть (`AddBook.tsx:282-288`) и просто перебивается серверной строкой |
| 5 | В-2, конкретность причин против брутфорса | **«Расширяемая/сужаемая категория, главное — архитектурно чистый пайплайн»** | Два класса, форма ниже (§8а). Не откладываю в бэклог: форма определена |
| 6 | Жанр | **«Выкидываем. Просто выкидываем»** | Б-23. ⚠ Планировать вместе с этапом структуры глав: `genre` в `BriefHash`, удаление двигает канон брифа и перекупает существующие платные книги; дешёвое окно — пока платная книга одна. Правка жанра на платформе денег не стоит (движковый `book.yaml` пишется один раз и не перезаписывается) |
| 7 | Добавление главы | **«Твоё предложение рассмотрит оркестратор»** | Б-19а: форма `POST /books/{bookId}/parts` + ответ «что сдвинется и почём» до подтверждения; стройка вместе со строками 160-162. Отдельно нужен движковый гейт против МОЛЧАЛИВОЙ перекупки хвоста при вставке не в конец |
| 8 | В-5, полоса прогресса | **«Ок, делаем так»** | Полоса до ближайшей остановки, после подписи банка начинается заново; знаменатель — по КУПЛЕННОМУ объёму, иначе прогон с потолком не доходит до конца. Совпадает с п.1 |
| 9 | В-6, «почему вторая книга не стартует» | **«ОК»** | `run-options` (и 409) получают `blocked: {code, book_id}`; платформа знает открытые холды с `book_id` (таблица `reservations`) |
| 10 | В-7, история прогонов | **«В МВП не нужно»** | Из батча снято. Хранить дополнительно ничего не надо: прогоны и попытки уже в Postgres, не удаляются, индекс `runs (book_id, started_at desc)` под этот запрос уже стоит — если поддержка попросит, это один read-путь |
| 11 | К-6, ступени замечаний | **«Показывать все; в тексте — сворачивать и раскрывать по кнопке. Как — решим потом, в бэклог»** | Словарь ступеней проектировать заранее НЕ нужно. Зависимость: чтобы конкретное замечание можно было свернуть и запомнить, нужен `Note.id` (Б-9) |
| 12 | К-9, отказ прескрина | **«Отвечать максимально абстрактно, чтобы юзер не догадался, в чём причина и где он валится»** | `rejected` + один грубый код, без деталей, без вариации между попытками. Двенадцатый статус НЕ заводить |
| 13 | В-10, спойлер | **«Вежливость»** | Контракта не касается вовсе. `sense` остаётся обязательным, размытие — дело фронта, находимость через Ctrl+F — приемлемое следствие |
| 15 | `finalizing` против лестницы владельца | **«Ну да, это чисто моя фраза была»** | Лестница «загрузка → разбор → перевод → подпись банка → финал → готово» — устная формулировка, а не норма продукта. Значит статус `finalizing` ею не связан и **сворачивается** (Б-0, Б-13). Если фаза когда-нибудь появится у движка — возвращается минором |
| 16 | Экспорт: снять операции или чинить | **«Экспорт будет»** | Операции ОСТАЮТСЯ. Предложение §5а снять их отозвано; вместо этого обязателен Б-4: `state` вместо булева `ready`, `failure_code`, `expires_at`, эхо формата, правило доступа к ссылке (ПТ-34). Заодно закрывается К-12 |
| 17 | 0.3.0 как последний 0.x перед 1.0 | **«НЕ последний. Последний будет после беты»** | 0.3.0 — обычный ломающий минор; право ломать в 0.x держится весь бета-период. Окно для 1.0 — после беты, и туда же уходит правило «с 1.0.0 минор аддитивен безусловно» |
| 14 | Грант новому аккаунту | **«Обнулить — это решение, которое я окнул»** | Дефолт `SignupGrantMicroUSD` → 0 на бете, начисление руками; возврат к $5 — вместе с суточным агрегатным потолком, когда появятся платежи. ⚠ В репозитории этого решения раньше НЕ БЫЛО: PD-104 везде помечен «ждёт слова владельца», а «дефолт в НОЛЬ» было предложением оркестратора. Теперь это слово владельца 16.08 — оркестратору закрыть PD-104 и строку в CURRENT-STATE. Подтверждено кодом: движок кредитами не занимается вообще, начисляет только платформа (`login.go:305`, `config.go:191`) |
### 8а. Форма ответа на В-2 (конкретность причин против подбора)
Опасение владельца точное: чем конкретнее отказ, тем лучше он работает как ОРАКУЛ для подбора входа,
который заставит систему делать не перевод. Поэтому правило не «насколько подробно», а **какого класса
причина**:
- **Класс 1 — детерминированные, про вход пользователя и его счёт:** порядок частей формы, размер файла,
нечитаемый файл, неподдерживаемая пара, «не нашлось ни одного раздела», нет кредитов, идёт другой
прогон. Конкретика МАКСИМАЛЬНАЯ и безопасная: правила детерминированы, их можно хоть публиковать,
оракула не возникает. Сегодня этот класс недосказан — «The upload is incomplete» покрывает шесть
разных причин.
- **Класс 2 — модельные:** прескрин misuse, отказ провайдера, контент-фильтр. Здесь каждый различимый
код — бит обратной связи атакующему. Правило: **один грубый код на весь класс**, без причины, без
вариации между попытками, с лимитом попыток на аккаунт. Сегодня этот класс не отделён вовсе.
Механизм расширяемости — двухуровневый код (стабильный корневой + расширяемый вложенный): новый
частный случай добавляется, не ломая клиентов, — ровно то, чем Microsoft закрывает «новый код = ломающее
изменение».
---
### 8б. Оркестратору при лендинге (адресно, по слову владельца «скажи ему в своём ресёрче»)
Четыре вещи, которые сессия отчёта сделать не может, а без них батч приземлится криво.
1. **Перенести решения §8 в D-ноту.** Сегодня они лежат в `docs/research/` — а по порядку источников
истины побеждает `05-decisions-log.md`. Пока переноса нет, исполняющие сессии будут действовать по
ресёрч-файлу: ровно тот класс, ради которого в проекте заведены ⚠-баннеры. Это первое действие
лендинга, до любой правки спеки.
2. **Компаньон правится ВМЕСТЕ со спекой, а не после.** Его §5 отвечает на собственный ревью-вопрос
«добавлена волна — править ли фронт?» словом «нет», и это опровергнуто исполнением (Б-0); его §3
(«у чтения `/bank` сегодня нет канала вообще») противоречит собственной строке 267, исправленной
15.08 (Б-7а). Если править только `openapi.yaml`, зона фронта продолжит учиться по неправде — она уже
учится: Ф-43 в её бэклоге ссылается на устаревшую версию.
3. **Лендинг — два коммита в порядке, канон первым.** Канон и байт-зеркало `frontend/docs/api-contract/`
обязаны совпадать (сверял `cmp` — сегодня совпадают), но хук фронта запрещает смешивать зоны в одном
коммите, а сверка ручная и не в CI. Порядок и факт сверки стоит записать в саму процедуру лендинга,
иначе расхождение обнаружится на следующей генерации типов.
4. **Пинги по чужим зонам из §9 нужно раздать по журналам** — платформенные (незакрывающиеся расчёты,
флаг аккаунта от одной книги, `/metrics` без аутентификации, лимитер входа на процесс), движковый
(комментарий `snapshot.go:186-188` противоречит коду с пака-20) и фронтовый фикс-лист к разморозке.
Ни один из них не является дефектом контракта, и ни один не имеет сегодня носителя.
Плюс два замечания о самом батче, которые видны только с моей стороны:
- **Порядок исполнения в §5 не косметический.** Б-1 (модель ошибок) стоит первым не по важности, а
потому что от неё зависят формулировки в Б-2, Б-3, Б-8, Б-14а и §8а: если словарь кодов заведут
позже, эти правки придётся переписывать дважды.
- **Три буллета из двадцати девяти появились ПОСЛЕ доклада, из разбора с владельцем** (Б-0, Б-11а,
Б-23). Это стоит прочесть как сигнал о методе: вопросы владельца покрыли то, чего не покрыли ни
инвентарь кода, ни линза стандартов, ни greenfield-панель. Если у следующего ревью будет фаза
«разбор с заказчиком», её лучше ставить не в конец.
---
## 9. Замечено попутно и НЕ вошло в контракт-ревью (владелец 16.08: «не замалчивай ничего»)
Это находки чужих зон, всплывшие в инвентаре 639 фактов. Контракта они не касаются, я их не судил
адверсариально — отдаю как пинги, с честной пометкой уверенности.
**Платформа — деньги и надёжность:**
- **Расчёт может остаться незакрытым навсегда** в двух случаях: попытка, спавненная без базовой отметки
расхода — «settlement withheld: this attempt ran without a spend baseline», холд остаётся открытым
(`platform/internal/runs/reconcile.go:660-670`); и дев-путь без движка, где `settle()` возвращает nil
и прогон вечно остаётся в `UnsettledRuns`. Оба случая ЛОГИРУЮТСЯ громко, оба легаси-по-построению —
но бюджета «сколько холд может висеть» не существует.
- **Флаг «аккаунт остановлен» выводится сканированием последних прогонов всех книг пользователя**
(`platform/internal/pgstore/books.go:720-724`): одна книга с `credit_exhausted` зажигает состояние
всего аккаунта. После PD-203 (уровни разведены) это стоит перечитать.
- **`ReadRunForSpawn` читает ВСЕ живые прогоны и линейно ищет нужный** (`pgstore/runs.go:712`) — O(живых
прогонов) на каждую задачу спавна.
- **Проверено и НЕ является дефектом,** хотя выглядело им: авто-рестарт при exit 5 без записанного
намерения стопа. Код знает о компромиссе и объясняет его, и деньги не страдают — «the restart settles
the old attempt at what it actually spent and gives the new one what is left of the run's budget»
(`reconcile.go:540-549`). Снимаю.
**Платформа — прочее:** `/metrics` слушает без аутентификации на отдельном адресе (защита — только
привязка к `127.0.0.1`, `platform/cmd/tmplatformd/main.go:200-202`) · лимитер входа один на процесс, не
пер-адресный (`login.go:128-129,165`; выбор объяснён комментарием про прокси, но следствие «один клиент
упирает вход всем» стоит назвать) · сырой Go-текст ошибки сохраняется как причина карантина в
пользовательской строке (`reconcile.go:320`) — на провод не идёт, но лежит в БД.
**Движок:** комментарий `snapshot.go:186-188` утверждает, что auto/draft-строки банка исключены из
`memory_version`, а код с пака-20 сворачивает КАЖДУЮ строку любого статуса вместе с алиасами
(`memory.go:410-421,441-446`). Практическое следствие названо в Б-19а (ja-мина при дописывании главы).
**Фронт (зона заморожена, чинить при разморозке):** мок отказов интейка триггерится МАРКЕРОМ В ИМЕНИ
ФАЙЛА (`frontend/src/mock/intake.ts:238`) — фикстурный переключатель без аналога на сервере · обработчик стопа в моке
игнорирует `runId` и всегда отвечает прогоном дефолтной книги (`frontend/src/mock/handlers.ts:190`) · в мире `scale`
чтения отвечают замороженной ревизией 4102, а кадры идут от счётчика с 1841 — чтения и поток расходятся
по построению (`frontend/src/mock/worlds.ts:136`) · в сценарии `loading` любой POST уходит мимо мока в реальную сеть
(`frontend/src/mock/handlers.ts:59`) · переполнение обхода страниц отдаётся в UI статусом 0 — тем же, что «сервер
недоступен» (`client.ts:114` → `Loaded.tsx:65`).
**Процесс:** байт-сверка канона спеки и зеркала фронта — ручная обязанность лендинга, не гейт CI; при
этом хук фронта запрещает смешивать зоны в одном коммите, то есть атомарно обновить обе копии нельзя.
---
## 10. Что осталось непокрытым после разбора 16.08
- **Кросс-семейность.** Долг частично закрыт: транспортный вопрос (§5б) проверен агентом другого тира
(`fable`), его цитаты я сверил по первоисточникам. Настоящая кросс-СЕМЕЙНАЯ проверка (не-Claude) при
границе «$0 по внешним провайдерам» по-прежнему невозможна и остаётся отдельным заказом.
- **Один агент ресёрча деградировал**: тема «индустриальные стандарты ошибок» вернулась пустой
(`answer: "test"`). Тему закрыл руками по четырём первоисточникам (RFC 9457, Microsoft, Google
`error_details.proto` + AIP-193, Stripe) — цитаты в Б-1 и в §8 п.4.
- **Масштаб замечаний по-прежнему не замерен** ни одним прогоном: сколько замечаний даёт настоящая
книга, не знает никто, а страница 500 и книжный (не поглавный) доступ выбраны без числа.
- **Влияние жанровой строки на качество не измерено** — и уже не будет: владелец решил поле убрать.
- **Рантайм не поднимался**: всё, что от него зависит, помечено PLAUSIBLE.