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

196 KiB
Raw Blame History

28 — Контракт-ревью API v0 (фронт ↔ платформа): доклад к батчу 0.3.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.

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:1430frontend/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_bankstop_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:188problem?.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). Экспорт не умеет провалиться — при том что платформа умеет

Факт. Exportrequired: [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. Рекомендация — менять спеку: развести классы стабильности прямо в описании IdChapter.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_cursorallOf сделает инвариант проверяемым; EventChapter — ровно изменяемое подмножество Chapter с переименованным idchapter_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_bankstop_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:114Loaded.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.