textmachine/docs/architecture/14-api-contract
2026-08-21 00:15:24 +03:00
..
openapi.yaml Ratify contract 0.4.0 and record the P7 acceptance with the second-rubezh corrections, seven new backlog rows and the fix list carried to the zone 2026-08-20 23:56:21 +03:00
README.md Finish the documentation sweep the P7 landing left half-done: closed working docs go to the zone archive, the P7 era is sliced out, and eight stale claims are corrected 2026-08-21 00:15:24 +03:00

Контракт API v0 — спутник спеки: провенанс, обоснования, вопросы

Нормативная поверхность контракта — openapi.yaml (файл рядом, в этой же папке). Этот файл её НЕ дублирует: он несёт то, чего YAML не выражает — откуда взято каждое решение, чем оно обосновано, что осталось открытым, и ГЕНЕЗИС форм (историю ратификаций, сверку со стандартами, разобранные альтернативы). При расхождении по ФОРМЕ побеждает YAML; при вопросе «почему так» — этот файл.

Статус: РАТИФИЦИРОВАН. 0.2.0 (D39.115, 08.08) · 0.2.1 (D39.123, 09.08) · 0.2.2 (D39.129, 10.08) · 0.2.3 (D39.135, 15.08) · 0.3.0 (D39.138, 16.08) — ломающий минор по целостному ревью research/28 · 0.4.0 (D39.152, 20.08) — синк с платформой, §6в. Дом канона — этот каталог; frontend/docs/api-contract/openapi.yaml — байт-зеркало.

0.4.0 ломающий ровно по одному месту, и это ЗАМЕРЕНО, а не объявлено. Дифф генерённых типов 0.3.0 → 0.4.0 (openapi-typescript@7, комментарии отброшены) — одна строка: Run.stop_requested: boolean. Всё остальное в этой редакции — проза канона, одна запись в таблицу resumeRun и одно значение ВТОРОГО уровня (cause.code: credit_unavailable, а он по замыслу не закрыт и типов не двигает): корневой enum не сужен и не расширен, обязательных полей больше не появилось. Линтер зоны (spectral --ruleset frontend/.spectral.yaml) — 0 ошибок.

Две границы этого замера, названные ревью диффа и обе честные. (1) База — 0.3.0, а зонное зеркало фронта стоит на 0.2.3: при разморозке фронт платит переход 0.2.3 → 0.4.0 целиком, и это совсем другой объём (шапка ниже про отставание зеркала). Замер отвечает «что добавил ЭТОТ синк», не «что стоит фронту разморозка». (2) Гейт §5 на утечку конвейерного словаря пере-ран и чист по сути, но «ноль вхождений» сказать нельзя: слово edit встречается дважды как обычный английский глагол («not an edit», «not a row edit»), stage — один раз в самой формулировке запрета. Ни одного нового вхождения этот синк не внёс.

0.x ломает миноры и будет ломать весь бета-период (решение владельца 16.08, §8 п.17 research/28): 0.3.0 — обычный ломающий минор, а НЕ последний перед 1.0. Окно 1.0 — после беты.

Язык. Спека английская: из неё генерятся типы, а исходники фронта по конвенции английские (слово владельца 04.08). Ссылки на К-вопросы внутри YAML набраны латинской K-N — это те же вопросы §4. Спутник и остальные доки зоны — русские.

Зона строки 95 — «оркестратор/бэкенд/фронт». Фронт авторитетен в одной трети: форма read-модели и продуктовые словари. Транспорт платформы (пути, аутентификация, коды) и работы движка здесь ПРЕДЛОЖЕНЫ и без подтверждения своих зон не действуют.

0. Пометки провенанса

Пометка Что значит
✓ выведено следует из кода движка/платформы или ратифицированного решения; грунт file:line рядом
◆ предложено решение автора контракта, разумное по его сведениям; подтверждает названная зона
○ открыто развилка, на которую ответа нет; перечень — §4

⚠ Пометка ставится на утверждение, а не на раздел: у одного пункта половина бывает выведенной, а половина предложенной.

Пере-разметка 0.3.0. Батч сдвинул четыре класса:

  • Progress был ✓ (выведен из пофазной механики движка) — стал : одна полоса до ближайшей остановки со знаменателем «купленный объём» это ПРОДУКТОВОЕ решение владельца (§8 п.1/п.8 research/28), а не проекция устройства движка. Это и было дефектом: форма ✓ означала, что устройство движка обязано пролезать на провод;
  • словари банка (TermStatus, TermOrigin) были ✓ по значениям — стали ✓ по различению, ◆ по словам: различение выведено из кода, слова назначены продуктом (движковые auto/draft/ ruby/mined на провод больше не идут);
  • модель ошибок была ◆ (форма RFC 9457 как её понимал автор) — стала ✓ по словарю причин (класс 1 выведен перечислением реальных ветвей platform/internal/httpapi/v0.go:333-427,542-574) и ✓ по политике (вариант B ратифицирован владельцем, §8 п.4);
  • GET /capabilities, условные чтения, Idempotency-Key, blocked◆ целиком: формы назначены этим батчем, подтверждает платформа при P7.

1. Почему YAML, а не проза

Контракт — машинный артефакт: из него генерируются типы, по нему линтуется форма, им типизируются моки. Прозаический контракт расходится с кодом ровно тем способом, ради предотвращения которого заведена строка 95.

Но машинного мало, и 0.3.0 это подтвердил дважды. Ревью прогнало линтер по канону 0.2.3 и получило «No results» — то есть все 29 находок ревью были смысловыми, ни одной синтаксической. А К-11 замерил, что if/then OpenAPI 3.1 генератор типов ИГНОРИРУЕТ. Отсюда правило, действующее с 0.3.0: любое межполевое или условное правило обязано быть записано И схемой, И словами в описании поля — схема защищает сервер, слова доезжают до клиента. Мест таких три: BankDecision.dst, Unit.target, агрегаты BankPage.

Инструменты и пины — STACK_DECISIONS.md §3 и бэклог зоны.


2. Решения и их происхождение

2.1. Язык — код, никогда не имя — ✓ выведено

Движок держит коды (backend/internal/config/book.go:26-27), ключ пары — zh-ru (configs/langpacks/zh-ru/). Имя языка в данных — пар-специфика в общем слое, запрещённая §2 канона. Вторая цена, дороже: lang элемента берётся из данных книги, и пара ja→ru с именем вместо кода отрисует кандзи китайскими начертаниями молча.

0.3.0 дополнил: форма кода — не то же, что умение пары. Платформа проверяет только ФОРМУ (platform/internal/books/books.go:118-121) и равенство source == target не проверяет никто, а пар-промпты в репозитории есть ТОЛЬКО для zh-ru; отсутствие промпта пары — жёсткая ошибка конфигурации, не деградация («no prompt for pair %q role %q … never silently substitute another pair's conventions», backend/internal/config/pipeline.go:952-956). До 0.3.0 книга в неподдерживаемой паре принималась, ложилась на диск, разбиралась, проводила пользователя через денежный экран — и умирала на старте прогона без причины. Отсюда Capabilities.language_pairs[] со статусом и код отказа на интейке (errors[].code: unsupported_pair).

2.2. Идентификаторы непрозрачны — ✓ выведено, классы стабильности разведены в 0.3.0

glossary.id — свежий автоинкремент на каждой пересборке банка и намеренно не хешируется (store/migrate.go:173-174); номер главы плотный, «Chapters that yield no text … do NOT consume a chapter number» (chunk/chunker.go:99-105), поэтому правка исходника сдвигает номера последующих глав.

0.3.0: одно предложение «Stability across runs is the platform's job» покрывало пять сущностей с разной по природе стабильностью и для юнита обещало заведомо больше возможного: движковый id юнита несёт cut-tag, и «any chunker/budget/pipeline-shape change mints a new id for EVERY unit in the book, while chapter ids survive» (backend/internal/pipeline/manifest.go:102). Теперь в схеме Id три класса явно: книга/прогон/экспорт — навсегда; глава — переживает пере-разбор; пара — только внутри одного structure_version, и клиент это НАБЛЮДАЕТ (Б-7), а не узнаёт по разъехавшимся вкладкам. Стабильность по-прежнему обеспечивает платформа при персисте манифеста (строка 100).

2.3. Заголовок главы отдельным полем — ◆ предложено

Движок сегодня делает ОБРАТНОЕ, и это надо назвать прямо: титул рендерится детерминистически из шаблона пары (configs/langpacks/zh-ru/heading.txt), исходный маркер вырезается из текста для модели (chunk/chunker.go:110-114), а на экспорте титул вклеивается внутрь текста первого юнита (pipeline/export.go:215). Комментарий движка прямо запрещает подавать этот рендер как метку книги (pipeline/manifest.go:80-86). Развилка — К-2.

0.3.0 снял противоречие, которое существовало независимо от К-2. Спека 0.2.3 запрещала клиенту синтезировать метку «Глава N» — а решение владельца 09.08 (research/27 §33-36) требует ДВУХ меток: оригинальной из данных книги плюс служебного рендера «Глава N» за $0 в локали ИНТЕРФЕЙСА. Сервер локали интерфейса не знает (Accept-Language в платформе нет грепом), значит служебный рендер обязан быть клиентским. Запрет снят; Chapter.heading = только метка ИЗ ДАННЫХ книги, null — обычный ответ, и деплою прямо запрещено класть сюда рендеренный порядковый.

title_raw и kind (глава/фрагмент) в 0.3.0 НЕ заводятся — передано дизайн-паку этапа 161 (строка 161, очередь D39.136 п.3), и вот почему это не откладывание: (а) производителя настоящих названий не существует — это Этап 0, строка 160; (б) форма узла «глава ↔ технический фрагмент» и вердикт детекции — ровно тот предмет, который дизайн-пак и решает, а заложенная до него форма заморозила бы догадку; (в) добавление обоих полей — АДДИТИВНОЕ расширение (минор), а смены СМЫСЛА существующего heading не будет: он и сегодня, и после 160 означает одно — метку из данных книги. Запись для пака 161: контракт 14 расширяется этим паком аддитивно; Chapter получает title_raw (если решится, что рендер и оригинал — разные поля), kind, вердикт структуры; ломать 0.3.0 для этого не требуется.

2.4. Состояние — у прогона; у главы выполнение — ✓ выведено

Подпись банка это один стоп на всю книгу (pipeline/mining.go:81,243), поэтому «глава ждёт подписи, пока соседняя финализируется» — невозможная картина. У главы движок держит ChapterPassport (pipeline/status.go:37-55).

2.5. Прогресс — ОДНА полоса до ближайшей остановки, в главах (0.3.0) — ◆ решение владельца

Прежняя редакция (0.2.x): пофазно и в юнитах, ✓ выведено. Основание было верное по факту: «A unit is DONE when every member draft AND the unit's edit resolved ok» (pipeline/status.go:328-331), редактура не стартует до стопа банка ⇒ сквозной счётчик стоял бы на нуле всю первую волну.

Почему это всё равно был дефект (research/28 Б-0, вопрос владельца 16.08). Пара draft/edit была не абстракцией, а сквозным пробросом внутренней структуры движка до React-компонента — runevents.go:195-196ingest/events.go:121,130-135 → SQL-констрейнт check (wave in ('draft','edit')) (00015_seam_ceiling_and_units.sql:56) → wireProgress (v0.go:92-96) → спека → генерённые типы → format.ts:109-110. То есть архитектура движка была пришпилена к контракту в шести местах, а её изменение — ломающим для фронта.

Решение владельца 16.08 («Согласен, переделываем» + В-5 «Ок, делаем так»): одна полоса до ближайшей остановки, знаменатель — КУПЛЕННЫЙ объём, после подписи банка полоса начинается заново.

Форма, выбранная батчем: счёт в ГЛАВАХ, а не в юнитах. Три довода, и второй — денежный:

  1. Одна величина, а не две. «Один счётчик» и «знаменатель — купленный объём» вместе значат, что числитель и знаменатель обязаны быть в одной единице. Купленный объём объявлен в ГЛАВАХ (ceiling_chapters), и другой единицы у него нет: пересчёт «главы → деньги» на провод не идёт (D39.84), пересчёт «главы → юниты» до разбора неизвестен.
  2. Дробь наконец означает то, что человек купил (Б-13а). До 0.3.0 потолок был в главах, а прогресс — в юнитах ПО ВСЕЙ КНИГЕ (v0.go:473-477), поэтому прогон, купленный на 10 глав из 2284, показывал дробь, которая не могла дойти до единицы, — и книга уходила в paused на 0,4 %. А потолок стоит на КАЖДОМ прогоне: поле обязательное.
  3. Ноль всю первую волну не возвращается — его снимает СЕГМЕНТНАЯ логика, а не единица счёта. Сегмент = работа между двумя остановками; в первом сегменте глава засчитывается, когда её работа ЭТОГО сегмента закончена, а не когда она пройдена от начала до конца. Обе величины у платформы уже есть: chapters.units_draft_done / units_edit_done заведены именно под это (00002_readmodel.sql:102-105, комментарий «K-10 is open… answering it is a projection change rather than a migration»). Новых колонок батч не требует.

Тем же ходом закрыт К-10 — вердикт «НЕ строить» (D39.138, поправка приёмки research/28 №1): пофазные счётчики на главу строить НЕ надо, потому что фаз на проводе больше нет вовсе. Chapter.units_done считается той же сегментной логикой, что и книжная полоса, — иначе дерево глав читало бы ноль всю первую волну, а это и была исходная жалоба К-10, и снятие фаз само по себе её не лечит.

Книжная полоса — отдельная величина. Book.chapters_done против chapter_count — прогресс КНИГИ (строка библиотеки), он не откатывается при старте нового прогона. Величина уже считается в SQL и до 0.3.0 не отдавалась: `b.chapter_count - (select count(*) from chapters c where … units_done

= units_total) (pgstore/books.go:677-681). ProgressнаBook` больше нет.

2.6. Словарь статусов — ✓ по механике, ◆ по составу; finalizing снят в 0.3.0

Лестница «загрузка → разбор → перевод → подпись банка → финал → готово» пришла из строки 95. stopped, rejected, not_started контракт ВЫВОДИТ из поведения процесса, а не получает полем: механика стопа у движка есть (cmd/tmctl/main.go:63), но «кто нажал» знает платформа.

Стоп по потолку — не failed ✓ выведено: «Ceiling is a hard, book-wide stop … the job stays 'pending' and resume continues once the ceiling is raised» (pipeline/stagerun.go:488-489).

0.3.0 — три правки:

  • finalizing снят. У движка такой фазы нет вовсе (grep -ri finaliz backend/internal — пусто), в словаре ingest её нет (ingest/events.go:53-67), писателя у значения нет нигде. Держалась она на лестнице владельца — а владелец 16.08 сказал про лестницу: «Ну да, это чисто моя фраза была» (§8 п.15). Устная формулировка нормой продукта не является, статус ею не связан. Появится фаза у движка — значение вернётся минором так же дёшево.
  • Заведён отдельный RunStatus (6 значений). Run.status был типизирован книжным словарём из 11 значений, из которых на прогоне легальны не все, и спека говорила это ПРОЗОЙ — то есть генерённый union был шире правды, а клиент обязан был писать недостижимые ветки. Узкий словарь у платформы в DDL уже записан (00002_readmodel.sql:47-49).
  • Записано правило старшинства «книга производна от прогона, кроме uploading/parsing/ not_started/rejected». До 0.3.0 правила не было, и фронт уже разошёлся сам с собой: полоса состояния решала «paused» по прогону (showcase/Status.tsx:21), карточка — по книге.

2.7. Состояние пары выводится из ПАРЫ — ✓ выведено

Флагнутый юнит легально приходит С ТЕКСТОМ в двух случаях: косметическая зачистка санитайзера («the chunk is NOT lost — the cleaned text is committed as the export», pipeline/disposition.go:96-104) и c-lite member-drop (pipeline/export.go:203-207). Поэтому состояние выводится из ПАРЫ (вердикт + наличие финального текста): флаг+текст → translated с замечанием; флаг+пусто → withheld.

Причина флага при этом ПРОИЗВОЛЬНА («единственный легальный случай» снято ревью round-2): при c-lite drop юнит несёт причину ПЕРВОГО выпавшего члена, какой бы она ни была. Значит карта причин обязана иметь фразу для каждой, а не для двух. Но пара «текст + промах словаря» невозможна ни одним каналом: memberDrops берёт причину из ЧЕРНОВОЙ строки члена (status.go:242-257), а glossary_miss ставится пост-чеком только там, где отгружается финал (waverun.go:373-381, :494).

0.3.0: инвариант «translated ⇒ текст непуст, иначе пуст» перестал быть только чеком БД (00002_readmodel.sql:127-128) и стал условной обязательностью в схеме плюс словами в описании (К-11: генератор if/then игнорирует). Слова «flagged», «chunk verdict», «sanitizer» с провода сняты — они компилировались в JSDoc генерённых типов клиента.

Свежесть ✓ выведено: target обновляется на границах работы и на стопах, а не непрерывно — посреди прогона канала чтения не существует (эксклюзивный лок движка; санкционированное чтение — завершённый либо остановленный прогон, research/23 §0, §4).

2.8. Банк: различение ✓, слова ◆ (пере-назначены в 0.3.0)

Поле Словарь 0.3.0 Что было у движка Грунт
status proposed · in_progress · approved auto · draft · approved store/migrate.go:191; только approved — канон
kind name · place · title · term · nickname плюс null то же terminology/classify.go:15 + banknote.go:74; пустое — membank/memseed.go:323-326
origin given · annotated · found seed · ruby · mined пути записи, см. ниже
sense свободный текст, пустая строка = «нет различителя» то же store/migrate.go:182
окно since_chapter/until_chapter, null = без границы целые, 0 = без границы store/migrate.go:189-190

Почему слова пере-назначены (0.3.0, Б-0/Б-17). ruby — японская фуригана, то есть паро-специфика в общем слое; mined — имя стадии конвейера; draft — имя волны; auto читается как «движок сам». Канон проекта: книжный/паровой термин в общем слое = утечка (CLAUDE.md, гардрейлы), и шапка самой спеки объявляет «no stage names». Клиент был обязан нарисовать слово для ruby в паре, где рубя не существует.

TermStatus — ось СОХРАНЕНА, переименованы только значения (эррата 16.08-г D-лога). Исходная рекомендация Б-0 «снять с провода» опиралась на «ни один экран их не рисует» — а это подмена: экрана подписи ещё нет (S5). Ось продуктовая и несущая — «на экране в сотни строк это главный фильтр работы», и шов клиента её уже потребляет (frontend/src/api/vocabulary.ts:159-163, termStatus с безопасным дефолтом canon: false). TermOrigin тем же разбором ОСТАВЛЕН как различение (провенанс нужен подписывающему, чтобы понимать доверие к строке) и переименован по значениям: seedgiven (пришло с книгой), rubyannotated (сам текст книги сказал, как читать), minedfound (сервис нашёл в тексте). Проекция трёх пар — работа платформы.

Правило пустого kind — и его проекция (было в 0.2.3, при резке прозы уцелело в каноне и пропало здесь; возвращено синком 20.08). kind присутствует всегда и допускает null; null значит «сервис не решил», строка при этом остаётся подписываемой, и клиенту запрещено и выбрасывать её, и додумывать тип за движок (канон, схема BankTerm.kind). У движка та же строка несёт Type: "" (membank/memseed.go:323-326), поэтому проекция ""null — работа платформы; это единственное место, где пустая строка и null означают одно, и потому оно записано.

Фантом auto в провенансе убран ещё в 0.2.0: комментарий схемы движка (migrate.go:193: seed|ruby|auto) устарел — "auto" пишет СТАТУС, не провенанс (membank/memseed.go:328: Status:"auto", Source:"ruby"), майнинг ставит mined (pipeline/mining.go:424).

Ложный друг source. У движка колонка source — это ПРОВЕНАНС. Поэтому в контракте провенанс зовётся origin, формы термина — src/dst, а имя source в схеме банка не используется вовсе. Переименование ради стиля здесь — самый дорогой класс правки, и ревью 0.3.0 его отвергло (§4 №25).

Окно термина (0.3.0, Б-18). since_chapter/until_chapter стоят на НОМЕРЕ главы, а номер сам контракт называет не-ключом: нумерация плотная, правка исходника сдвигает хвост, и для книги без нумерации номера легально нет. Перевести окно на chapter_id нельзя — оно входит в ключ уникальности термина у движка (store/migrate.go:202), и смена ключа это работа движка, не контракта. Поэтому записано ЯВНО: окно живёт в координатах текущего structure_version и пересчитывается при его смене; два смысла нуля разведены на null («без границы»), потому что Chapter.number начинается с единицы и 0 был сентинелом с двумя значениями в двух полях.

2.9. Подпись — набор решений — ✓ выведено

Конвейер заменяет банк целиком (store/migrate.go:169-170), поэтому PATCH /term/{id} молча не работает. Механика дословно: «for EACH term either promote it into the mined-delta file OR decline it in the mined-rejects file, then resume» (pipeline/mining.go:81,243; ⚠ фраза «the stop clears once every proposed term is promoted or rejected» ОПРОКИНУТА D39.144 — движок авто-продолжает с неподписанным, D39.42 п.3). Отсюда живое: решение на термин · информационный счётчик «решено N из M» · частичное сохранение ◆. Запрет «продолжить» при неполном наборе — СНЯТ (подпись = один resume).

0.3.0 — две правки:

  • promoteapprove. promote/decline — дословно глаголы оператора майнера из строки выше, и promote порождал status: approved — два слова на один акт. Теперь approveapproved.
  • Снимок стопа переехал в ЧТЕНИЕ банка (Б-14а). ⚠ Пере-писано D39.144 (модель владельца 16.08: подпись — ОДИН акт над всем банком): complete/pending_decisions — ИНФОРМАЦИОННЫЕ, resumeRun снимает стоп при ЛЮБОМ состоянии решений, 409-полноты НЕТ. Прежняя формулировка — «поле, по которому решается, можно ли предлагать „продолжить“» — существовало только в квитанции POST. Экран, перезагруженный посреди стопа, мог прочитать ВЕСЬ банк и не узнать, сколько решений осталось; единственная реализация выводила признак как left === 0 (frontend/src/mock/handlers.ts:203-204) — инвариант, которого контракт не объявлял. Теперь pending_decisions и complete отвечает и GET /bank, и квитанция, и кадр bank.

POST /runs/{id}/resume — нормативная операция, а не резерв.

Открытый остаток, НЕ закрытый батчем и вынесенный вопросом (см. отчёт батча §8). Чтение банка отвечает СКОЛЬКО решений осталось (pending_decisions, complete), но не КАКИЕ строки уже решены: TermStatus — состояние строки банка, а решение живёт отдельной таблицей (bank_decisions, 00002_readmodel.sql), и на провод оно не проецируется ни одним полем. Экран подписи, перезагруженный посреди стопа, поэтому знает «осталось 17 из 300» и не знает, какие семнадцать. Лечение — одно поле BankTerm.decision (approve/decline/null), и оно НЕ добавлено: слово владельца 15.08 «добавочные поля BankTerm НЕ заводить» (D39.136 п.4б) прямо это запрещает, а промт батча повторяет запрет. Найдено холодным потребителем; решение — за владельцем.

2.10. Ревизия — ✓ у ре-синка, ◆ у чтений

✓ выведено: правило ре-синка — идемпотентный апсерт по (run_id, seq), канал согласования — status --json (research/23 §2, §8; D39.85).

◆ предложено: что ревизию несут и ЧТЕНИЯ, и что счётчик у потока и у чтений ОДИН. Обоснование — гонка, которую иначе нечем разрешить: фронт живёт на снимке и потоке разом, а рефетч по возврату фокуса окна у ратифицированного @tanstack/react-query включён по умолчанию.

0.3.0 — два уточнения, оба выведены из построенного клиента:

  • Ревизия страничного обхода — МИНИМУМ по страницам, не максимум. Правило жило только комментарием в frontend/src/api/client.ts:99-106 («The revision of a torn list is its OLDEST page»), то есть второй клиент его бы не узнал. Теперь оно в схеме Revision.
  • Агрегаты банка — на ПЕРВОЙ странице (Б-12). Bank обязывал нести total/signed «в целом по банку» на КАЖДОЙ странице, при этом у каждой страницы своя revision, а правила согласования не было — и референсный клиент неизбежно смешивал два момента времени: счётчики брал с последней страницы (queries.ts:144-148), ревизию — с первой. Обе половины написаны осознанно и обе верны по отдельности. Первая страница — единственный выбор, согласованный с правилом выше, и он не требует от сервера держать снимок между запросами.

Кадры соединения не тратят номеров истории. id кадра объявлен позицией в истории событий КНИГИ, строго возрастающей, — но hello приходит первым на КАЖДОМ подключении, а end и resync_required тоже принадлежат соединению, не книге. Первая редакция батча этого не развела, и у реализатора оставалось два пути, оба против текста: минтить служебным кадрам книжные номера (тогда два одновременных зрителя тратят номера друг друга, и Last-Event-ID одного указывает на кадры, которых второй не видел) либо повторять последний номер (тогда «one per frame» ложь). Поймано кросс-модельной линзой; исправлено: служебные кадры несут id последнего кадра ИСТОРИИ и своего номера не тратят, повтор id на них легален, а дыра в нумерации легальна из-за склейки — и клиенту прямо запрещено читать пропуск как потерянный кадр. Последнее правило было в 0.2.3, потерялось при резке прозы и возвращено.

Дельта-чтения ВКЛЮЧИТЕЛЬНЫ (>=), а не строго больше. Первая редакция батча сделала ?after_version= строгим — и тем сломала собственное правило Revision («catch-up reads >= R, not > R»): одна транзакция это одна ревизия, но НЕСКОЛЬКО строк, и строгое сравнение теряет соседей последней применённой. Хуже: кадр bank предписывал читать строки «ревизией этого кадра», что при строгом сравнении всегда возвращало пустоту. Поймано холодным потребителем, исправлено: чтение включительно, повторно пришедшая строка безвредна (строка заменяется по id), а водяной знак следующего чтения берётся из КОНВЕРТА, а не выводится из строк.

Дофикс 16.08 (ФБ-4): у многостраничного обхода конверт не один. Формулировка «водяной знак — revision конверта» была однозначна ровно до тех пор, пока чтение умещалось в одну страницу; на рваном чтении она сталкивалась со вторым правилом того же раздела — «ревизия рваного списка это ревизия СТАРЕЙШЕЙ страницы». Два правила давали два разных числа, и клиент, взявший новейшее, молча терял строки, изменившиеся между первой страницей и последней. Сведено в одно: и гард свежести, и водяной знак — это НАИМЕНЬШАЯ ревизия, увиденная за обход; на одностраничном ответе это его собственная. Направление выбора — безопасное: лишнее перечитывание бесплатно (строка заменяется по id), пропуск строки — нет. Там же заявлено равенство BookDetail.revision и BookDetail.book.revision: карточка собирается одной транзакцией с книгой, и клиент вправе брать любое. Устаревший водяной знак (коллекцию заменили целиком) отвечает 400 c cause.code: version_too_old — тем же ответом и с тем же смыслом, что мёртвый курсор.

Структурная версия — новая ось (0.3.0, Б-7). Курсор был привязан к «STRUCTURAL epoch of the collection», серверу вменялся MUST-отказ по мёртвому курсору, — а слово «epoch» встречалось в документе РОВНО ОДИН раз: ни один ответ эпохи не нёс, наблюдать её было нечем. При этом структура живая: «число глав может измениться против эвристики» (research/27:35, решение владельца 09.08). Теперь structure_version едет на КАЖДОМ кадре (EventBase) и в Book, ChapterPage, UnitPage, NotePage, BankPage; на исчезнувшую главу отвечает 410; к смене версии привязаны курсор, якоря на пары, окно термина и идентичность строки банка. ⚠ Первая редакция батча положила версию только в hello и два конверта — и тем оставила ДВЕ свои же обязанности («перечитать банк, когда версия сдвинулась», «уронить якоря на пары») без единого триггера, а BankPage/NotePage — без указания, к какой структуре относится их содержимое. Поймано обеими линзами селф-ревью, исправлено.

2.11. Разрыв потока — ◆ предложено; канал перевешен на КНИГУ в 0.3.0

При переподключении клиент шлёт Last-Event-ID. Если сервер докачать не может — обязан ответить resync_required, а не молча начать с текущего момента: реплей истории запрещён, иначе разовое событие вроде note теряется молча.

0.3.0 — четыре правки одного канала (Б-6), все Ц0: канала нет ни строкой (grep text/event-stream по не-тестовому Go платформы — только комментарии).

  • Поток перевешен с прогона на книгу. Книга в uploading/parsing прогона не имеет по построению: строка прогона создаётся только в StartRun (pgstore/runs.go:66,80), весь разбор живёт на строке книги (books.go:180,237,283). Прогонный поток не мог сообщить конец разбора в принципе, и клиент лечился опросом раз в 3 с (queries.ts:64-75) плюс вторым хуком (useIntakeEnd.ts:31) — а разбор настоящей книги это минуты. Это Ф-56, и чинится он не новым кадром, а перевеской канала: конец разбора становится обычной сменой статуса. ⚠ Прогонный поток узким видом НЕ оставлен (Б-6 предлагал оставить). Довод: второй канал с теми же кадрами — вторая реализация и второй источник расхождения, а адресация «кадры этого прогона» выводится из книжного потока клиентом, у которого id прогона уже есть. §5а того же ревью требует резать, а не добавлять поверхность.
  • Конец потока объявлен. Терминального кадра не было, 204 в ответах не объявлен — при том что SSE именно им останавливает переподключение («a client can be told to stop reconnecting using the HTTP 204 No Content response code», WHATWG). После завершённого прогона браузер переподключался бы вечно. Теперь: кадр end + 204 на переподключение с Last-Event-ID от завершённого потока; запрос БЕЗ Last-Event-ID всегда открывает новый поток — иначе клиент не смог бы начать смотреть снова после старта прогона.
  • id кадра и ревизия книги разведены. Спека просила «a monotonic id», говорила, что он несёт ревизию, и допускала несколько кадров с одним id; форма зафиксирована не была. Сервер, сделавший id уникальным на кадр (1841-2), не нарушил бы ни слова прозы и навсегда отключил бы клиентский гард: Number('1841-2') = NaN (frontend/src/api/stream.ts:131). Теперь id — позиция потока (десятичное целое, форма зафиксирована), ревизия — поле в data на КАЖДОМ кадре.
  • Склейка ограничена по типу. «The server MAY COALESCE frames» стояло без ограничений — а склейка двух note теряет замечание навсегда, на живом соединении, без переподключения и потому без resync_required. Это ровно тот исход, которым та же спека двумя абзацами выше обосновывала запрет реплея. Теперь: склеивать можно кадры СОСТОЯНИЯ, note — нельзя.
  • Правило докачки выбрано одно (было два взаимоисключающих): короткий живой буфер после предъявленного id разрешён, реплей истории за его пределами запрещён, размер буфера сервер не объявляет и клиент на него не опирается.

2.12. Разрешающий список — ✓ инвариант, ◆ форма

Проекция «read-модель → фронт» строится как allowlist. Что лежит в операторских структурах (pipeline/status.go:58-130, :37-55) и не может доехать: пять денежных полей плюс cost_usd главы · routing вида «stage=model», content_labels, content_routing_problems (ПТ-33) · снапшот, дрифт, ре-билл · escalations, postcheck_misses, style_flags, glossary_miss_flagged, stages_skipped, repair_applied, worst_flag_reason · flag_reason и detail.

0.3.0 расширил инвариант на ПРОЗУ. Аллоулист полей держался, а описания — нет: объяснительный текст спеки компилируется в исходники фронта как JSDoc генерённых типов, и туда уехали «chunk verdict», «flagged», «sanitizer cleanup», «stage boundaries», операторские ранги и пример «CJK leak in the ru output: 第一节» (openapi.yaml:1430frontend/src/api/schema.ts:1047 в редакции 0.2.3) — то есть ровно та строка, которую контракт объявлял запретной. Теперь правило звучит так: на проводе нет ни имён стадий/волн, ни движковых словарей — ни в полях, ни в описаниях. Ревью-вопрос каждой правки — §5.

2.13. ПТ-34 — перевод не индексируется — ✓ инвариант

Приложение живёт под X-Robots-Tag: noindex, ответы несут Cache-Control: no-store, ссылка на выгрузку выдаётся только владельцу.

0.3.0 — две правки. (а) no-store объявлен на ВСЕХ ответах, как его и ставит платформа (middleware.go:38): контракт требовал его только для ответов с переводом, то есть был беднее кода. (б) Ссылка экспорта получила НОРМУ доступа вместо прозы: минтится под этот ответ и под аутентифицированного владельца, не индексируется, истекает в expires_at. Грунт — сама платформа: «The download URL is minted per request for the owner and is never indexable (PT-34), so it is not a column» (00002_readmodel.sql:188-199).

2.14. Поверхность входа /auth/* — ✓ построено платформой

Четыре ручки живут ВНЕ версионного префикса, как /healthz: это механика сессии, а не контрактная поверхность.

Ручка Метод Что делает
/auth/login GET начинает вход, редиректит к провайдеру; принимает ?return_to=<путь этого сайта>
/auth/callback GET завершает вход, ставит сессионную куку, редиректит на return_to либо на дефолт
/auth/logout POST завершает ЭТУ сессию
/auth/logout-all POST завершает ВСЕ сессии пользователя

return_to принимает ТОЛЬКО путь этого сайта, и чужой путь сервер молча заменяет дефолтом (login.go:safeReturnTo). Обе POST-ручки лежат на cookie-пути, то есть требуют X-TM-Client.

Отказ входа — problem+json, как везде; различать причины отказа клиент не может по замыслу. ⚠ Эта фраза стояла здесь в 0.2.3, была молча срезана коммитом лендинга батча 8d82096 (ловится git log -S) и восстановлена синком 20.08 — не как переоткрытый спор, а как правило, которое никто не отменял. Разбор потери — §6в п.0.

Что это значит для клиента, дословно (синк 20.08, п. C записки платформы). /auth/* отвечает тем же конвертом, что и /v0, но БЕЗ машинного code — и это решение, а не пробел: клиент диспетчеризует по статусу и показывает одну нейтральную фразу. Особый случай ровно один — 429 с Retry-After, который вход действительно отдаёт (login.go:173,233, dev.go:138); ветвление по нему это механизм HTTP по назначению, а не запашок.

Арифметика остатка, из-за которой словарь здесь не нужен: из шести статусов входа четыре уже имеют точные соответствия в ErrorCode (401 → unauthenticated, 400 → invalid_request, 404 → not_found, 503 → service_unavailable), а из двух оставшихся 405баг клиента, не пользовательский случай. Обсуждался по существу ОДИН код.

Проводного словаря в этом файле не объявляется, и это принципиально. Компаньон объявлен не нормативным для формы; словарь, записанный здесь, сделал бы его нормативным с чёрного хода — форму стало бы нечем ни сгенерировать, ни отвалидировать, ни удержать линтером. Механическая половина разбора уже исполнена платформой без разрешения и правильно: codeForStatus снят (grep -rn "codeForStatus" --include=*.go platform/ → 0).

Триггер пересмотра, записанный явно: в день, когда на входе появится ВТОРОЙ пользовательски осмысленный случай — «аккаунт отключён» против «провайдер лёг», — эта поверхность получает собственный нормативный документ (маленький OpenAPI на /auth/*), а не таблицу в компаньоне.

2.15. Потолок прогона — ◆ форма, РАТИФИЦИРОВАНА оркестратором №15 (08.08)

Решение владельца 07.08: шкала от минимума до максимума, ноль выбрать нельзя, единица — ГЛАВЫ, потолок принадлежит ПРОГОНУ.

Отдельный ресурс, а не поле карточки книги. Максимум зависит от АККАУНТА и двигается, когда книга не меняется. Три числа, а не два: max_chapters приходит УЖЕ подрезанным и по остатку, и по непереведённому хвосту; клиенту подрезать второй раз ЗАПРЕЩЕНО. default_chapters отдаёт платформа, потому что предустановленное значение — продуктовая политика.

max_chapters — величина, а не арифметика. Ратификация D39.110 в первой редакции требовала «баланс МИНУС открытые холды»: это была ОШИБКА — вычитание дважды. Холд есть дебет в момент взятия (pgstore/credits.go:179), поэтому баланс уже не содержит открытых холдов. Замерено на живом PostgreSQL: грант $10 и холд $1 дают Balance 9 и Reserved 1.

0.3.0 — blocked (В-6, «ОК» владельца 16.08). Шкала второй книги молча ужимается — или исчезает — когда кредит держит холд ПЕРВОЙ книги, и узнать это из контракта было нечем. Теперь run-options и 409 на старте несут blocked: {code, book_id}. Грунт: платформа знает открытые холды с book_id (таблица reservations). Один код credit_held — других причин ужать шкалу у платформы сегодня нет: ErrRunInFlight привязан к СВОЕЙ книге (book.HasLiveRun, runs/runs.go:187-188) и вторую книгу не блокирует.

2.16. Транспорт: тот же origin — ФАКТ, а не выбор

CORS-слоя в платформе нет вовсе: preflight OPTIONS с чужим Origin получает 401 от гарда сессии, заголовков Access-Control-* нет ни на одном ответе (PD-96). Браузерный клиент с другого origin неработоспособен как класс. X-TM-Client обязателен и на same-origin — он не про CORS.

0.3.0 — безопасность стала машинной (Б-15). Правило X-TM-Client жило в ПРОЗЕ описания схемы безопасности: генератор его не создавал, spectral не проверял, контрактный тест не ловил, и клиент носил его двумя копиями руками (client.ts:13,40, upload.ts:81). Правки: заголовок объявлен параметром на каждой небезопасной операции · 403 объявлен ответом (раньше его в контракте не было вовсе, а платформа им отвечает — server.go:99) · 401 объявил обязательный WWW-Authenticate (RFC 9110 §15.5.2 требует его MUST, ни WriteProblem, ни Deny-обработчик его не ставили) · множество небезопасных методов приведено к коду и к RFC (код освобождает и OPTIONS, auth/csrf.go:51-54; спека говорила «anything other than GET and HEAD», и клиент следовал СПЕКЕ) · записан второй карваут (well-formed Bearer, csrf.go:55-62) · servers[0].url стал относительным /v0 (был абсолютный плейсхолдер https://app.example.org/v0, в который целился бы сгенерированный клиент) · записана связь «защита работает, ПОКА нет CORS», чтобы её снятие требовало правки контракта, а не конфига.

2.17. Модель ошибок — вариант B (0.3.0) — ✓ словарь, ◆ форма

Что было. type — константа about:blank на каждом ответе (problem.go:25); detail пуст на всех вызовах контрактной поверхности; instance объявлен спекой и структурой Problem в коде не предусмотрен вовсе. Различитель — английская фраза, которую контракт предписывал показывать пользователю «as-is» при русском интерфейсе (Loaded.tsx:66, RunStart.tsx:188, AddBook.tsx:289). Фраз при этом меньше, чем причин: «Request could not be read» покрывала шесть разных условий, «The upload is incomplete» — четыре. Accept-Language и локалей в платформе нет грепом.

Решение владельца 16.08 — «Идём в Б путём гугла» (§8 п.4): машинный code (двухуровневый: стабильный корневой + расширяемый вложенный) + request_id; title/detail — developer-facing, клиент их НЕ показывает; серверная локализованная фраза — отдельным полем и только для неперечислимых причин; errors[] с указателем поля.

Форма, выбранная батчем.

  • code — корневой, закрытый, 16 значений; cause.code — второй уровень, НЕ закрытый. Это и есть механизм расширяемости: новый частный случай добавляется в cause, не ломая клиентов, — то, чем Microsoft закрывает «новый код = ломающее изменение». Второй уровень сделан ВЛОЖЕННЫМ объектом, а не соседним полем: так граница «стабильное / расширяемое» видна структурно, и всё, что внутри cause, по определению вне словаря версии. Глубина ровно одна — рекурсии innererror у нас нет.
  • Словарь класса 1 выведен, а не придуман: перечислением всех ветвей, где платформа сегодня отвечает ошибкой, — v0.go:238,242,250,333,339,345,356,409,417,419,423,531 (прямые ответы) и v0.go:542-574 (fail()), плюс server.go:99,134. Каждый корневой код имеет там источник; ни одного кода «на будущее» не заведено, кроме двух названных ниже.
  • Два кода заведены под ратифицированные решения, а не под построенный код: idempotency_conflict (форма Idempotency-Key — заказ батча, реализация P7) и content_refused (класс 2, К-9).
  • Класс 2 — ОДИН грубый код на весь класс (§8а): без причины, без cause, без errors, без вариации между попытками. Опасение владельца точное — чем конкретнее отказ, тем лучше он работает как оракул для подбора входа. Тот же код и тем же правилом заведён значением RejectReason (content_refused), потому что К-9 ратифицировал «rejected + грубый код, двенадцатый статус НЕ заводить».
  • type остался about:blank. RFC 9457 §3.1.1 требует использовать type как первичный идентификатор — но URI, который никуда не резолвится, или URN, дублирующий code, это либо обещание, которого мы не держим, либо вторая копия факта. Расширение членами объекта — то, что §3.2 разрешает прямо. Зато отклонение 0.2.3 от §3.1.3 («The "title" string is advisory and is included only for users who are unaware of … the semantics of the type URI») ИСПРАВЛЕНО: title больше не показывается пользователю.
  • Problem.instance СНЯТ (§5а): структуры на платформе нет вовсе, реальная форма ответа — {type, title, status, detail?}, а функцию корреляции несёт request_id, значение которого уже существует и уже едет на каждом ответе (reqid.go:16,27).
  • localized объявлен и сегодня не используется ни одним кодом. Это ратифицированный слот (§8 п.4, форма google.rpc.LocalizedMessage) под класс «причина не перечислима заранее»; носитель — research/28 §8 п.4. Помечено в спеке словами, чтобы предупреждение не пережило своё основание (правило §3 ниже).

Цена на клиенте — меньше, чем кажется: таблица «состояние → русская фраза + совет» у клиента УЖЕ есть (AddBook.tsx:282-288) и отбрасывается всякий раз, когда сервер прислал любую фразу (:289). Перевод на машинные коды — снятие одной строки.

2.18. Idempotency-Key — семантика (0.3.0) — ◆ форма

Идемпотентности не было, а контракт сам советовал ретрай как лечение 408 — при том что RFC 9110 §9.2.2 говорит: «A client SHOULD NOT automatically retry a request with a non-idempotent method unless it has some means to know that the request semantics are actually idempotent … or some means to detect that the original request was never applied». Каждый POST /books создаёт новую книгу (books.go:125), а удалить дубликат до 0.3.0 было нечем вовсе.

Семантику определяет контракт, реализует P7 (решение промта батча). Выбрано: область ключа — (принципал, метод, путь) · повтор того же запроса → ИСХОДНЫЙ ответ, без новой работы · повтор с другими параметрами → 409 key_reused · повтор во время исполнения первого → 409 key_in_flight · окно хранения ≥ 24 часа · длина ≤ 255 · отсутствие заголовка легально и означает «без защиты от повтора». Область по ПУТИ, а не по телу: ключ, поданный на другую операцию, — другой ключ, иначе клиент, генерирующий ключ на попытку пользователя, получил бы взаимное влияние двух разных действий. Окно 24 часа — не замер, а достаточная граница для клиентского ретрая; сокращать его дешевле, чем удлинять, поэтому взята нижняя обещаемая граница («не менее»).

0.4.0 сменил ОДНУ половину — что такое «тот же запрос» на multipart (§6в E): не объявленные части плюс длина, а метаданные + имя файла + СОДЕРЖИМОЕ, и сервер, который тождества установить не может, не реплеит, а отвечает 409. Остальное выше в силе. ⚠ Формулировка §6б ниже («тождество считается по объявленным частям», дофикс ФБ-8 16.08) этой правкой ПЕРЕКРЫТА и оставлена только как история — читать по канону.


3. Зависимости: чтение → источник → строка бэклога

Правило, введённое 0.3.0 (Б-21): предупреждение о недостроенном ОБЯЗАНО нести номер строки бэклога (а где строки нет — явного носителя-документ). Без него абзац переживает своё основание и начинает лгать — что уже произошло четырежды, и одно из четырёх отняло у пользователя работающее действие. Таблица ниже — единственное место, где это ведётся.

Чтение / механизм Источник данных Состояние Строка / носитель
GET /books, GET /books/{id} read-модель платформы ПОСТРОЕНО
GET /books/{id}/run-options, POST /runs шкала + холды ПОСТРОЕНО
POST /runs/{id}/stop реконсилятор ПОСТРОЕНО (кнопки на экране нет) зона фронта
POST /runs/{id}/resume реконсилятор ПОСТРОЕНО, но 0.3.0 сменил поведение на паузе по потолку, а 0.4.0 — на ИСЧЕРПАННОМ прогоне. Пауза: платформа уже отвечает 409 ceiling_reached (runs/reconcile.go, ветка case "paused" в Resume) — совпало. Исчерпанность: платформа отвечает 202 и возвращает прогон в то же состояние (reconcile.go, оба выхода exhausted из reopen → ветка exhausted в Resumehttpapi/v0.go, хендлер resumeRun), а контракт теперь требует 409 run_not_resumable · cause.code: ceiling_reached. Молчаливый 202 на действие, которое ничего не сделало, — ровно то, от чего предупреждает собственный комментарий платформы; клиенту нечем отличить успех от no-op, а на awaiting_bank это ещё и не вызывает ReleaseBankStop. Полная таблица по статусам — в описании resumeRun вход P7 (правка построенного пути, не только читающей поверхности)
GET /usage кредиты ПОСТРОЕНО, не читается ни одним экраном зона фронта
GET /capabilities конфигурация деплоя НЕ ПОСТРОЕНО (заведено 0.3.0) вход P7
PATCH/DELETE /books/{id}, GET /runs/{id} колонки есть НЕ ПОСТРОЕНО (заведено 0.3.0) вход P7
GET /books/{id}/chapters, /units материализация манифеста НЕ ПОСТРОЕНО вход P7
GET /books/{id}/notes unit_done несёт флаг и причину (runevents.go:126-135), платформа хранит (sink.go:227-233), колонка notes.reason заведена под это канал ЕСТЬ; не хватает карты «причина → код → фраза» (приложение А) и проекции приложение А + вход P7
GET /books/{id}/bank движок пишет сайдкар всего банка (pipeline/bankexport.go:16-33,72, D39.122) движковая половина ПОСТРОЕНА; не хватает проекции платформы строка 169 · вход P7
POST /bank/decisions стоп-механика майнера НЕ ПОСТРОЕНО вход P7
GET /books/{id}/events (SSE) эмиттер шва построен (D39.131) на платформе SSE нет ни строкой вход P7
POST/GET /exports у движка только stdout-JSON и --plaintext (cmd/tmctl/invocation.go:107) НЕ ПОСТРОЕНО с обеих сторон строка 49 / D29.1 «tmctl export-контракт»
Условные чтения (ETag/304), сжатие НЕ ПОСТРОЕНО строка 186
Idempotency-Key НЕ ПОСТРОЕНО (семантика задана 0.3.0) вход P7
bearerToken — чем ВЫДАЁТСЯ токен вход только ставит HttpOnly-куку (login.go:338) сервер токен ПРИНИМАЕТ, выдать его нечем носитель: research/28 §2 (Б-15); строки нет
Problem.localized объявлено, ни одним кодом не используется носитель: research/28 §8 п.4
Note.code как enum спеки карта приложения А не enum, пока не написаны фразы строка 148 (фразы владельца)
Настоящие названия глав (Chapter.heading ≠ null) парсер структуры НЕ ПОСТРОЕНО строка 160 (Этап 0)
title_raw / kind (глава ↔ фрагмент) дизайн-пак структуры глав передано паку, аддитивно строка 161
ErrorCode.content_refused (400) и RejectReason.content_refused прескрин злоупотреблений НЕ ПОСТРОЕН ни на одной стороне: в платформе только объявление константы (httpapi/problem.go:42,94), ContractRejectReason (ingest/vocabulary.go:59-69) его не отображает; в движке отказ провайдера живёт как ПРИЧИНА ЗАМЕЧАНИЯ (disposition.go:60-63Note.code: content_withheld) и в exit-контракт не выходит — мостá между двумя словарями нет строка 94 (ПТ-16); там же ограничение числа попыток аккаунта — обязанность падает ВМЕСТЕ с производителем, не раньше
decline в подписи банка доезжает до работы движок читает файл mined_rejects (pipeline/mining.go), платформа его не пишет решение ЗАПИСЫВАЕТСЯ (таблица bank_decisions) и на следующем прогоне НЕ применяется — отклонённый термин уезжает авто-строкой строка 192 (отложена владельцем); канон предупреждает на BankDecision.action
Снятие замечания (переход «флаг снят») движок НЕДОСТИЖИМО сегодня, проверено чтением движка — п. H §6в PD-298 регистра платформы (platform/docs/DEFECT_REGISTER.md) — там строка и живёт; механизма не строим
Счёт «сделанного» на деплое без второго прохода платформа канон 0.4.0 определил «сделано» = последний проход ЭТОГО деплоя; проекция платформы считает жёстко второй проход вход P7 (PD-202)

Три прежних предупреждения СНЯТЫ как устаревшие (Б-7а), и это причина, по которой заведена таблица выше:

  1. «GET /bank — канала нет вообще» — неверно с D39.122: сайдкар пишется. Файл при этом противоречил сам себе (строка таблицы против абзаца ниже неё), а зона фронта до сих пор учится по старой версии (Ф-43).
  2. «EventNote — движок не эмитит пер-юнитных замечаний» — эмиттер приземлился 14.08.
  3. «EventCeiling — зависит от эмиттера» — то же (кадр при этом снят по §5а, см. §6).

Четвёртое было ХУЖЕ устаревшего — оно было НОРМАТИВНЫМ и отнимало работающее лечение. Спека 0.2.3 писала: механизма поднятия потолка нет, «so the client MUST NOT offer resume as the remedy for paused», — и не называла НИКАКОГО другого действия. Первая половина верна: resume действительно не двигает такой прогон. Вторая — нет: стоп по потолку ЗАКРЫВАЕТ прогон, finished_at пишется тем же оператором (pgstore/runs.go:672-676), paused входит в допустимые для старта состояния (runs/runs.go:227-231, allowlist readyToTranslate), HasLiveRun при этом ложь — то есть новый прогон с бОльшим потолком запускается и является лечением уже сегодня. Пользователь видел тупик там, где его нет, на самом частом остановочном состоянии. В 0.3.0 лечение записано в startRun и в resumeRun, а resume после потолка отвечает 409 с cause.code: ceiling_reached.


4. Открытые вопросы

# Вопрос Статус
К-1 Словарь статусов ЗАКРЫТ D39.100; 0.3.0 снял finalizing и развёл RunStatus — §2.6
К-2 Титул главы: поле heading или вклейка в текст ○ ОТКРЫТ, автор контракта + бэкенд. 0.3.0 закрыл ОТДЕЛЬНОЕ противоречие (запрет клиентской служебной метки снят), но кто производит настоящую метку — строка 160
К-3 Метка «Глава N» как единственная форма ЗАКРЫТ D39.100 (метка — из ДАННЫХ книги). 0.3.0: служебный рендер «Глава N» разрешён КЛИЕНТУ, в локали интерфейса
К-4 Ревизия: сквозная или пер-ресурсная; несут ли её чтения ОТВЕЧЕН P0, подтверждён ревью с поправкой: ревизия и позиция потока — РАЗНЫЕ величины, совмещать в id нельзя (0.3.0, §2.11)
К-5 Показывать ли ETA ЗАКРЫТ D39.100 (eta_seconds в спеке; 0.3.0 сделал поле required+nullable)
К-6 Ступени замечания: сколько и где граница ○ ОТКРЫТ, владелец. Ответ 16.08: «показывать ВСЕ; в тексте сворачивать и раскрывать по кнопке; как именно — решим потом». Словарь ступеней проектировать заранее НЕ надо — батч его и не проектировал. Зависимость закрыта: Note.id заведён (0.3.0), без него конкретное замечание нельзя свернуть и запомнить
К-7 Пагинация: курсор или один ответ ОТВЕЧЕН P0. 0.3.0 добавил недостающее: максимум limit, подрезание вместо понижения, объявленный порядок каждой коллекции, maxItems у decisions
К-8 Стоп по потолку — каким статусом ЗАКРЫТ D39.100 (paused + оповещение)
К-9 Отказ прескрина не выразим статусами ПРИНЯТО ВЛАДЕЛЬЦЕМ 16.08: прескрин — ещё одна ПРИЧИНА, а не двенадцатый статус: rejected + ОДИН грубый код (content_refused), максимально абстрактно, без вариации между попытками (§8а). Заведено 0.3.0
К-10 Пофазность у главы ЗАКРЫТ — вердикт «НЕ строить» (D39.138, поправка приёмки research/28 №1). Пофазных счётчиков на главу не будет: фаз на проводе нет. Исходная жалоба («дерево читает ноль всю первую волну») лечится СЕГМЕНТНОЙ логикой Chapter.units_done — §2.5
К-11 Условная обязательность полей ОСТАТОК ИНСТРУМЕНТАЛЬНЫЙ. 0.3.0 применил правило «схема + слова» к трём местам (BankDecision.dst, Unit.target, агрегаты BankPage) и сделал адресацию Note обязательной. Остаток — генератор игнорирует if/then; лечится сужением на шве клиента (S5)
К-12 Завершение выгрузки: опрос или событие ОТВЕЧЕН P0 (опрос), подтверждён AIP-151 («The response must not be a streaming response»). 0.3.0 добавил то, без чего опрос не завершался: state вместо булева ready, failure_code, expires_at
К-13 paused_reason не различает две беды ЗАКРЫТ D39.132 п.2а (null на проводе ратифицирован). 0.3.0 добил остаток: описание требовало «null in every other state», что противоречило ратифицированному «paused + null»

5. Ревью-вопрос: «Сменится устройство пайплайна — придётся ли править фронт?»

Прежний ответ этой таблицы был ОПРОВЕРГНУТ ИСПОЛНЕНИЕМ (research/28 Б-0) и здесь не сохраняется даже как история — он учил зону неправде. Что на самом деле давала редакция 0.2.3, если в движок добавляли волну (реальный format.ts собран через vite и вызван):

  • третья волна приезжала как новое поле, клиент «игнорирует неизвестные поля» — и translatedPercent отдавал 100 %, когда треть работы не сделана. Молча;
  • переименование фаз или снятие редактуры → TypeError в рендере (Status.tsx:39, About.tsx:96), а ErrorBoundary/errorElement/componentDidCatch во фронте нет ни одного (грепом пусто);
  • незнакомую волну платформа тихо дропала (pgstore/sink.go:206-208) — прогресс занижался без ошибки;
  • «правок ноль» на деле означало миграцию БД + шов + спеку + регенерацию типов (её принуждает дрифт-тест frontend/src/api/contract.test.ts:30-37).

Ответ редакции 0.3.0 — и теперь он верен, потому что чинили ПРИЧИНУ, а не формулировку:

Изменение в движке Правит ли фронт
переименована стадия / добавлена/снята волна нет — числа волн на проводе больше нет: одна полоса до ближайшей остановки, знаменатель — купленный объём (§2.5). Пофазный сплит остаётся ВНУТРИ платформы, колонки не трогаются
сменилась модель, маршрутизация, температура, промпт нет — в allowlist не входят
добавлена новая причина флага нет — на провод идёт КОД, карта живёт в контракте (приложение А); незнакомый код → нейтральная фраза, правило записано в схеме
добавлена новая причина отказа запроса нет — второй уровень cause.code не закрыт по замыслу; клиент матчит корневой code
добавлен новый тип термина / новый провенанс нет — словарь расширяется минором, ветка неизвестного стоит на шве
сменился чанкер, главы пере-разобраны нет по коду, ДА по данным — и теперь это ВИДНО: structure_version двигается, курсоры и якоря на пары объявлены недействительными, на исчезнувшую главу отвечает 410, полная замена — resync_required. До 0.3.0 клиент молча рисовал старое дерево
добавлено новое ПРОДУКТОВОЕ состояние да, один файл — карта «статус → вид» на шве src/api/; это и есть контрольный вопрос владельца

Правило, которое отсюда следует и действует на КАЖДУЮ правку контракта (0.3.0):

На проводе нет ни имён стадий/волн, ни движковых словарей — ни в полях, ни в ЗНАЧЕНИЯХ, ни в описаниях. Описания компилируются в исходники клиента как JSDoc генерённых типов, поэтому объяснительная проза подпадает под тот же запрет, что и поля. Проверять грепом финальной спеки по списку: draft · edit · wave · stage · mined · miner · ruby · finalizing · chunk · langpack · sanitiz · flagged · verdict · prompt · engine · pipeline · glossar · escalat · snapshot (список открытый — дополнять по мере находок). Единственное легальное вхождение — сама формулировка этого запрета в шапке спеки.

Гейт под это правило — тестом, по образцу языкового generality.test.ts — половина ФРОНТА: .spectral.yaml держит только spectral:oas, а generality.test.ts ловит лишь языковую специфику, то есть утечку конвейера не стережёт ничто. Носитель: пинг фронту 16.08 в frontend/docs/frontend-PROGRESS.md, исполнение при разморозке зоны (D39.136 п.2).


6. Направления: что решено НЕ делать в 0.3.0

Записано направлениями, чтобы не превратиться в молчаливые дыры. Номеров бэклога здесь не выдумывается: где строки нет, носителем назван документ.

Что Почему не в батч Носитель
POST /books/{id}/parts — дописать главы в существующую книгу форма и движковый гейт против МОЛЧАЛИВОЙ перекупки хвоста при вставке не в конец — отдельная работа вместе с этапом структуры глав строка 185
Снятие жанра из брифа и промптов двигает BriefHash ⇒ только в общее resnapshot-окно строка 184
Сжатие и условные чтения НА ПЛАТФОРМЕ контракт их объявил; включение — работа платформы строка 186
Сворачивание замечаний в тексте под кнопку зона фронта, при разморозке пинг фронту 16.08
История прогонов книги снята решением владельца 16.08: «в МВП не нужно». Данные уже в Postgres и не удаляются, индекс runs (book_id, started_at desc) стоит — если поддержка попросит, это один read-путь research/28 §8 п.10
Двухшаговая загрузка (метаданные JSON → PUT байтов) три из четырёх greenfield-дизайнов выбрали её, и при ней проблема порядка частей не существует. Ц3: переписывается построенный путь с обеих сторон. Направление на после-беты research/28 §5 «не в батч»
Лента изменений структуры (added|removed|moved|split|merged) с наследниками дизайн-пак структуры глав строка 161
Поиск и фильтр по банку и дереву нужны индексы (Ц2); сегодня клиент ищет в браузере, и это осознанно research/28 §5 «не в батч»
«Грубая группа статуса» для эволюции словаря эволюционный механизм без сегодняшней боли research/28 §5 «не в батч»
Словарь ступеней замечаний К-6: владелец — «решим потом» research/28 §8 п.11
Добавочные поля BankTerm (evidence/variants/conf, aliases) слово владельца 15.08: «НЕ заводить … смысла хватает» D39.136 п.4б
Поток на БИБЛИОТЕКУ (одна лента на все книги аккаунта) поток книжный (§5 п.5 заказа — «перевесить на книгу»); экран библиотеки обновляется чтением, которое под условным чтением стоит заголовков. Ленты на библиотеку нет, и открывать поток на строку списка клиент НЕ должен — это записано нормой в спеке. Найдено холодным потребителем как реальный перф-вопрос research/28 §5б · строка 186
Чтение ОДНОЙ главы GET /books/{id}/chapters/{chapterId} сегодня кадр chapter про главу, которой клиент не держит, игнорируется (норма записана), а 410 лечится перечитыванием дерева. Операции нет — она не в заказе; вопрос дизайн-пака структуры глав строка 161
Список экспортов GET /books/{id}/exports Б-4 просил СОСТОЯНИЕ экспорта, не список. Восстановление после перезагрузки закрыто иначе — Idempotency-Key на создании возвращает исходный 202 с тем же Location research/28 §2 (Б-4)
Новое число страницы замечаний замера нет ни одного: сколько замечаний даёт настоящая книга, не знает никто. 500 выбрано без числа, и выдумывать второе число вместо первого — та же ошибка. Число ушло из спеки в Capabilities.page_size_default; калибровка — после первого настоящего прогона research/28 §7, §10

6а. Что РЕЗАЛОСЬ по §5а и что резать отказались

Снято: Problem.instance (структуры на платформе нет; функцию несёт request_id) · кадр EventCeiling целиком (единственное поле halted всегда true, дубль кадра status, который уже несёт paused_reason) · EventHello.run_id (поток теперь книжный) · EventResyncRequired.reason · схема Counter (одна полоса вместо двух) · Book.genre и BookIntake.genre (Б-23) · значение finalizing · прогонный поток GET /runs/{id}/events (перевешен на книгу) · зашитые числа размеров страниц из прозы операций · генезис-проза (история ратификации semver, две апологии RFC 9110, объяснение имени source) — ужата НА МЕСТЕ до ссылки на источник, а не перенесена сюда: в спеке остались semver §4 и одна строка про Retry-After на 200, целиком выброшенного текста нет. Формулировка «перенесена в этот файл» держалась ровно один раунд и исправлена дофиксом (ФБ-10): проверяется грепом — этих абзацев в компаньоне нет.

Резать отказались, с контраргументом на каждое:

  • параметр limit («клиент не отправил его ни разу»). Тот же §5 требует объявить у него maximum и подрезание вместо понижения (Б-10) — у удалённого параметра максимума не объявишь. Плюс: «референсный клиент не шлёт» — свойство ОДНОГО клиента, а контракт пишется для второго.
  • Note.unit_id («только в фикстуре мока; экраны берут unit.note вложенно»). Основание — «ни один экран не читает», ровно тот довод, который эррата 16.08-г уже опрокинула на TermStatus: экранов замечаний (S6) ещё нет. Адресация на пару — единственный способ перейти к МЕСТУ замечания из плоского списка, а собственная фраза операции говорит «A note addresses a unit or a whole chapter». Поле оставлено НЕОБЯЗАТЕЛЬНЫМ (Б-9 просил ровно это), а обязательным сделан chapter_id — то есть дефект «замечание, не адресующее ничего» закрыт.
  • Bank.signed («доезжает до клиента и не рисуется»). Тот же довод «нет экрана» — экран подписи это S5. total, signed и pending_decisions — три НЕЗАВИСИМЫХ факта: строку можно решить и не подписать (отклонить), поэтому signed не выводится из двух других.
  • нагрузка кадров note и bank («передаётся и игнорируется»). Игнорировалась она по причине, которую батч устранил: у замечания не было id, поэтому кадр нельзя было сопоставить со списком. С Note.id кадр note несёт ПРИМЕНИМУЮ ДЕЛЬТУ — это ровно первая ветка правила Б-11а, и снятие нагрузки вернуло бы перечитывание всего списка. Счётчики bank — вторая ветка того же правила («счётчик + скоуп»), а строки читаются дельтой ?after_version=.

6б. Дофикс-раунд 16.08 (заказ приёмки D39.142): что изменилось в каноне и почему

Десять находок ХОЛОДНОГО ПОТРЕБИТЕЛЯ — линзы, которой у селф-ревью батча не было: только финальная спека и проба генератором, без ревью, без компаньона, без диффа. Ниже — не пересказ правок, а их основание; сами правки в каноне.

Форма кадра была двусмысленна (ФБ-1). EventEnvelope{event,id,data} читался и как объект на проводе, и как описание SSE-фрейминга — оба чтения соответствовали тексту, и клиент по второму прочтению искал бы JSON с полем data внутри. Теперь на схеме стоит дословный пример кадра и сказано прямо: event и id — ПОЛЯ SSE, телом кадра является data и только оно. Это тот класс дефекта, который не ловится ни линтером, ни генератором: документ валиден в обоих прочтениях.

Книга в покое могла крутить переподключение вечно (ФБ-2). Служебные кадры несут id последнего кадра истории — а у книги, которая ещё ничего не производила, такого кадра нет. Клиент оставался без Last-Event-ID, каждый его запрос был «новым потоком», сервер отвечал hello+end, браузер переподключался — и так по кругу. Закрыто самой дешёвой из возможных мер: история нумеруется с 1, служебные кадры пустой книги несут 0, а Last-Event-ID: 0 на книге в покое попадает под уже существующее правило 204. Последовательность стала конечной: helloend → закрытие → одно переподключение → 204 → браузер останавливается.

Обещание «note не теряется» не переживало разрыв (ФБ-3). Внутри одного соединения кадр-добавление защищён запретом склейки; между двумя соединениями — ничем: буфер не обещан, resync_required за обычный реконнект не полагается. Обещание не расширено (буфер обещать нечем), а названа обязанность клиента: после КАЖДОГО переподключения — дельта-чтение /notes?after_version=…. Механизм для этого уже был; не хватало записи, что он обязателен, а не удобен.

Пустые члены allOf давали необитаемые типы (ФБ-5). EventEnd и EventResyncRequired описывались как EventBase плюс пустой объект — генератор выводил Record<string, never>, и пересечение становилось типом, значение которого построить нельзя. Заменено на чистый allOf из одного члена с описанием на самой схеме; проверено генератором — оба теперь EventBase. Попутно записано то, что раньше подразумевалось: эти два кадра НЕРАЗЛИЧИМЫ по форме, диспетчеризация только по имени события.

«Новый прогон» предписывался, а условия — нет (ФБ-6). Спека велела предлагать новый прогон как лечение стопа по потолку, но нигде не перечисляла, что делает resume в каждом останавливающем статусе, а paused_reason: null описывался как «нейтрально, продолжаемо» — из чего клиент мог заключить, что при null надо звать resume. Добавлена таблица по всем статусам и сказано прямо: новый прогон легален при ЛЮБОМ paused, включая null; причина паузы — подсказка о прошлом, а не разрешение на будущее. Значение enum при этом не заводилось — ратификация D39.132 п.2а не двигается.

Пол под моделью ошибок (ФБ-7). Весь §Errors описывал ответы, которые СООТВЕТСТВУЮТ контракту; что делать с ответом прокси, HTML-страницей или оборванным телом — не говорил никто, а именно там у клиента нет ни code, ни request_id. Записано правило того же вида, что и для известных кодов: не показывать из такого ответа ни байта, рисовать свою нейтральную фразу.

Пачка мелких (ФБ-8). title: null в merge-patch — объяснено, почему это 400, а не удаление члена по RFC 7386 (книги без названия у этой поверхности не бывает) · X-TM-Client — записано, что required: true стоит при живом исключении для bearer, потому что условной обязательности по схеме безопасности OpenAPI не выражает, и валидатор не должен читать законное отсутствие как нарушение · список отказов интейка помечен неисчерпывающим (авторитет — code ответа) · правило резолюции Location · ⚠ (ПЕРЕКРЫТО 0.4.0, см. §2.18) тождество Idempotency-Key на multipart считается по объявленным частям, а 408 не считается состоявшейся попыткой · min_chapters при max_chapters: 0 — не диапазон, клиент проверяет максимум первым · порядок пяти носителей «нет кредита» · «carried forward marked as unverified» — снято как обещание без носителя, заменено на наблюдаемое (BankPage.signed против total) · 409 у экспорта объяснён как ключевой, а не книжный · неизвестный term_id в подписи отклоняет весь батч, а не молча пропускает строку.

Слова, которые называли не то (ФБ-9). parser_unavailable называл наш компонент — переименован в processing_failed, по эффекту: имя значения это то, на что клиент вешает фразу. ⚠ Проводное имя теперь отличается от внутреннего словаря платформы — там значение зовётся parser_unavailable (platform/internal/books/parse.go:66), и проекция обязана отобразить одно на другое; рядом там же живут schema_mismatch и storage_unavailable, которые на провод не идут вовсе. Вход P7. Из описаний вычищены внутренние ссылки (research/28 §…, «companion К-6»): описания компилируются в исходники клиента, и ссылка на ревью в чужом репозитории там — мусор; носители остались здесь.

6в. Синк с платформой 20.08 → канон 0.4.0: что решено и почему

Вход — platform/docs/archive/CONTRACT_SYNC_FROM_PLATFORM_2026-08-20.md, четырнадцать мест, где провод сообщает состояние и не даёт клиенту действия. Записка честно разделяет ФАКТ / ⚠ МНЕНИЕ ПЛАТФОРМЫ / ГРАНИЦА, и разбор шёл по этой границе: ФАКТы перепроверены исполнением, МНЕНИЯ проверялись на ПОСЫЛКУ, а не только на предложенную форму. Два мнения посылку не выдержали (B, часть A), одно оказалось слабее собственного факта (L о метках глав).

База ссылок этого раздела — РАБОЧЕЕ ДЕРЕВО 20.08, а не HEAD. В platform/ на этот момент 81 незакоммиченный файл (P7 в работе), поэтому одна и та же строка в HEAD и в дереве разная, и ссылка без базы проверяема только случайно. backend/ чист, его ссылки годятся в обоих. Там, где конструкция переживёт любую перенумерацию, адрес дан ИМЕНЕМ функции или ветки — это дешевле и надёжнее номера. Поймано ревью диффа: первая редакция §6в смешивала обе базы в одной строке таблицы.

п.0. Сверка на потери прозы 0.2.3 → 0.3.0 — исполнена; найдена ОДНА потеря, она и была заявлена

Метод: полный дифф лендинга 8d82096 по обоим файлам (300 удалённых строк компаньона, 597 канона), чтение ВСЕХ удалённых строк, выделение из них утверждений-правил и проверка каждого на наличие смыслового двойника в живых файлах.

Отдельно про метод, потому что он чуть не дал ложный результат. Первый проход греп-проверки шёл построчно и дал шесть «пропаж», которых нет: правило переносится через перенос строки, и grep "fewer rows than asked" не находит текст, где fewer заканчивает строку. Сверка пере-ранена на файлах, склеенных в одну строку (tr '\n' ' '), — и все шесть нашлись на месте. Тот же класс ошибки в этой сессии уже ловился однажды; ставлю его сюда как метод, а не как случай.

Итог: единственная потерянная НОРМА — фраза §2.14 («различать причины отказа клиент не может по замыслу»), восстановлена. Остальное удалённое — три класса, и ни один не является потерей правила:

  • правило уцелело в КАНОНЕ, а канон нормативенkind/null («MUST NOT drop it or invent a kind»), «MUST NOT clamp it again», «stale read is the CLIENT's duty», «catch-up reads >=», «assembling a book's text on the client is forbidden», «pairs are read PER CHAPTER», уникальность термина по пятёрке, «a phrase MUST exist for a reason we do not know yet», no-store, CSRF, запрет реплея истории, «max_chapters: 0 = экран исчерпанности». Один из них — правило пустого kind — уцелел в каноне, но потерял здесь ПРОЕКЦИЮ (""null — работа платформы); клауза возвращена в §2.8;
  • утверждение снято ОСОЗНАННО и с записанным основанием — «carried forward marked as unverified» (ФБ-8, обещание без носителя), «замена в failed запрещена» (ужато до «не failed»), «Required: HTTP/2 at the edge» (Б-16, RFC 9205), пофазные счётчики, EventCeiling, genre, finalizing, пример «CJK leak … 第一节» (он и был утечкой, §2.12);
  • пропала МОТИВИРОВКА, а не норма — «1.9 юнита на главу», «иначе строки выглядят дубликатами и удаляются», «portable to the desktop client», Intl.DisplayNames. Это цена резки прозы, принятая осознанно; ни одно не меняет того, что обязана делать сторона.

Вывод, который стоит записать отдельно. Приёмка батча сверяла мультимножество модальных глаголов и этот класс структурно не ловила — но пере-сверка показала, что улов класса равен одному. Отсюда дешёвый гейт вместо дорогого: при следующей резке прозы сверять не модальность, а список собственных имён формы (полей, значений, кодов, заголовков) — норма почти всегда стоит рядом с именем, а фраза §2.14 пропала именно там, где имени не было (/auth/* — не поле). Носитель: эта запись.

A. Прогон с исчерпанным бюджетом (PD-282) — ПРАВЛЮ КАНОН; половину мнения отклоняю

Факт подтверждён и оказался шире записки. reopen даёт exhausted в ДВУХ местах, а не одном (runs/reconcile.go): remaining <= 0 и ErrInsufficientCredit от RestartRun — то есть «кончился потолок прогона» и «нечем взять холд на счёте» уже сегодня схлопнуты в один вердикт, и Resume отвечает на оба одинаково: 202 + неизменённый Run (httpapi/v0.go, хендлер resumeRun). paused до этой ветки не доходит — он отказан выше, поэтому молчаливый 202 живёт ровно на stopped и awaiting_bank. Худший из двух — awaiting_bank: человек подписал банк, нажал «продолжить», получил «принято», а ReleaseBankStop не вызывался (зона это записала сама, archive/P7_ACCEPTANCE_HANDOFF_2026-08-17.md §7(и)).

Номера строк в чужой зоне в этом разделе сняты намеренно (находка ревью): дерево платформы на 20.08 держит незакоммиченные правки, поэтому одна и та же строка в HEAD и в рабочем дереве разная, и половина ссылок первой редакции указывала в одно дерево, половина — в другое. То же лечение, что у приложения А: адресуемся именем функции и ветки.

Правка канона (§resumeRun). Записана ВТОРАЯ ОСЬ: исчерпанность лимита прогона перебивает обе строки 202; ответ — 409 run_not_resumable, cause.code: ceiling_reached, лечение — новый прогон. Новых значений enum не заводится: ceiling_reached уже есть и его собственный текст этот случай описывает дословно. Тем же ходом канон получил обратную гарантию, которой не имел: 202 теперь означает, что работа действительно переоткрыта, и клиенту не нужно второе чтение, чтобы отличить успех от no-op. И записано разделение, о котором предупреждает сам канон у AccountHaltReason: пустота СЧЁТА не сворачивается в ceiling_reachedу неё своя причина credit_unavailable (аддендум ниже), а состояние аккаунта ЦЕЛИКОМ по-прежнему говорит Usage, не этот вызов. (Первая редакция раздела писала здесь «пустой счёт — не ответ этого вызова», что противоречит и аддендуму, и канону; испр. оркестратором при ратификации.)

Признак «этот прогон ещё можно продолжить» на Run — ОТКЛОНЁН, с оговоркой. Посылка «платформа знает его точно» верна, посылка «дёшево» — нет: ReadRun — один запрос по books join runs (pgstore, функция ReadRun), а флагу нужны ТРИ чтения, которых в нём нет — RunSpent, AttemptReservationOpen и баланс счёта. Run сериализуется в карточке книги, а карточку канон велит перечитывать НА КАЖДЫЙ кадр потока («it is still read on a frame, on navigation and on focus»), то есть цена платится на горячем пути ради факта, который (а) устаревает к моменту клика и (б) уже отвечен ратифицированным порядком чтения пяти мест (§RunOptions): max_chapters == 0 + blocked — это ровно «стоит ли предлагать старт сейчас и что мешает», и рисует его тот же экран. Авторитет по-прежнему у мутации, как канон и объявляет. ⚠ Поправка ревью к предыдущему абзацу: сам Run в КАДРЕ не едет — ни один payload его не несёт; горячий путь создаёт не кадр, а предписанная им перечитка карточки. Вывод не меняется, носитель цены — другой; первая редакция называла его неверно. Триггер пересмотра: замер, показывающий, что мёртвый клик частый, ЛИБО сворачивание трёх чтений в коррелированные подзапросы того же запроса (агент проверил — возможно). Тогда форма — Run, поле, минор.

B. Суточный потолок движка — ОТКЛОНЯЮ ОБЕ ФОРМЫ; посылка не выдержала проверки

Записка просит слово или признак для паузы, которую деньги не лечат. Проверка посылки:

  1. Владелец уже решил этот вопрос — 15.08, D39.132 п.2а. day_usd УБРАН из платформенного шаблона книги; трата прогона ограничена купленным объёмом (холд + --ceiling-usd), дневная ось на платформе объявлена дублирующей, в движке остаётся ОПЕРАТОРСКОЙ опцией, а обработка daily_ceiling/409 — предохранитель. Там же дословно: «PD-199 закрыт этим же решением: null на проводе подтверждён, слово в контракт не заводится». Заводить его сейчас значило бы опрокинуть ратификацию пятидневной давности ради случая, который зона сама пометила ГРАНИЦЕЙ («живьём не воспроизводилось»). Проверено: платформа это поле не заводит — её рендер book.yaml проставляет ровно пять строковых ключей, «the whole of what this platform claims about the engine's schema» (books/render.go, функция render), и потолки в их число не входят. ⚠ Уточнение ревью, и оно важнее исходной формулировки: «не заводит» ≠ «не пишет». Шаблон деплоя пере-маршалится целиком, поэтому оператор, положивший ceilings.day_usd в СВОЙ шаблон, получит это поле в каждой книге, которую платформа создаст (запинено render_test.go — «the operator's ceilings did not survive»). Именно поэтому триггер ниже сформулирован через шаблон, а не через код платформы: настройка живёт в артефакте деплоя, и вернуться она может без единой правки Go.
  2. Предложенная форма неверна по СМЫСЛУ, даже если бы случай был. «Лечится покупкой / не лечится» — булево, а суточный потолок это ОКНО: он снимается сам в полночь UTC (store/ledger.go:60-72, todayUTC()). Клиент, прочитавший «не лечится», спрячет кнопку навсегда там, где честный ответ — «не сейчас». Булево, у которого один из двух исходов врёт, хуже, чем отсутствие поля.
  3. Второе значение enum — та же проблема плюс своя: оно называет настройку чужого файла на проводе, что запрещает правило §5 («на проводе нет движковых словарей»), и зона сама этого не хочет.

Что записано вместо этого — одна фраза в каноне (§resumeRun). «Новый прогон легален при любом paused» осталось нормой, но перестало читаться как обещание прогресса: деплой может держать СВОИ лимиты, которых этот контракт словом не называет, и прогон под таким лимитом останавливается так же; клиент всё равно предлагает новый прогон — лучшего действия у него нет, — но не подаёт его как гарантию, а повторный paused это законный исход, а не сбой. Цена ошибки при этом наблюдаемая и самокорректирующаяся: холд возвращается, провайдерских денег не теряется (ФАКТ записки).

Триггер пересмотра, записанный явно: день, когда day_usd вернётся в платформенный шаблон книги. Тогда это настоящая дыра и контракт открывается заново.

Попутно найдено и стоит знать зоне: суточный потолок — НЕ единственный стоп, который деньги не лечат. Их четыре класса (cmd/tmctl/main.go:52-79): подпись банка (exit 3), graceful stop (exit 5), полоса отказов 1019 и суточный потолок (exit 4, scope day). Просто три из четырёх уже имеют свою проекцию (awaiting_bank, stopped, failed + RunFailureReason), и «денег не хватает» из них не следует ни для одного. То есть класс закрыт, а дыра — только в четвёртом.

C. Вход /auth/* — ИСПОЛНЕНО (§2.14)

Фраза восстановлена, вариант A уточнённый записан целиком, триггер пересмотра зафиксирован, словарь в компаньоне НЕ объявлен. Разбор потери — п.0 выше. Возражение зоны про «нормативность с чёрного хода» принято дословно и стало основанием.

D. Note.code против пустой клетки — ПРАВЛЮ ОБА ФАЙЛА

Дыра реальна и была острее, чем в записке: канон не просто требует поле, он называет Note.code «a closed vocabulary of this version», а карта в приложении А оставляла клетку пустой — то есть сервер обязан прислать значение, которого словарь не содержит. unspecified ратифицирован как ОБЯЗАННОСТЬ сервера (не строка словаря причин), записан и в канон, и в приложение А, п.5. Довод записки про независимый выпуск принят. Прецедент D39.144 опорой не служит — согласен, его довод («спека ещё никем не потреблена») больше не верен.

Родственный вопрос про ступени — НЕ решаю: К-6 открыт, слово владельца 16.08 — «решим потом». Но провизорность построенного зафиксирована в приложении А, п.6, чтобы ingest/notes.go не читался как ратифицированная карта.

E. Отпечаток интейка (PD-262) — ПРАВЛЮ КАНОН, форму НЕ трогаю

ФАКТ подтверждён дословно: intakeFingerprint берёт r.ContentLength (httpapi/v0.go:511-517), и комментарий рядом (:503-510) сам называет оба остатка. Из двух исходов один безопасный (ложный key_reused на другой границе multipart — честный ретрай отвергнут), второй нет: chunked-клиент даёт "unknown" обоим запросам, и две разные книги под одним ключом реплеят первую — то есть ключ возвращает ответ, принадлежащий чужому запросу. Это нарушение самой гарантии, ради которой ключ существует.

Выбрано: привести фразу канона к тому, что сервер может сравнить, БЕЗ поля размера в форме. Записано: «тот же запрос» = те же метаданные + то же ИМЯ файла + то же СОДЕРЖИМОЕ; чем сервер устанавливает последнее — его дело (дайджест на лету стоит ноль, книгу в памяти держать не надо); обрамление multipart и длина тела запросом НЕ являются; сервер, который не может установить тождество, не реплеит, а отвечает 409 idempotency_conflict. Почему не поле размера: оно дало бы клиенту ещё одно обязательное поле, не закрыв случай «две разные книги одной длины», и всё равно потребовало бы сверки с фактически прочитанным. Механизм отката для этого уже построен и назван зоной верно (trailingParts v0.go:536-563books.go:190-204); регистр зоны сам называет полное лечение «сверка принятого дайджеста на завершении с откатом» — контракт теперь это разрешает вместо того, чтобы описывать деталь, которой в форме нет.

F. ETag/304 на любом безопасном чтении — ПРИНЯТО, канон правится одной фразой

Зона права дважды: RFC 9110 валидатор на любом GET разрешает, и список исключений в слое записи протух бы за пак. Записано в шапке канона: валидатор на любом безопасном чтении легален и объявления не требует; операции, которые его объявляют, — те, где клиенту есть смысл им пользоваться, а не исчерпывающий список. Плюс две границы, чтобы фраза не стала лицензией: клиент НИКОГДА не обязан слать If-None-Match, а 304 бывает ответом только на присланный. getUsage/getRunOptions в объявленные НЕ добавлены сознательно — оба читаются ровно перед действием, и условное чтение там не даёт ничего, кроме риска показать устаревшую шкалу.

G. Draft-only конвейер (PD-202) — ОТВЕЧАЮ: ДА, начерновленная глава «сделана»; правлю канон

Проверено чтением обеих зон, и цена выше заявленной. У движка деплой без второго прохода — не экзотика: разбиение выводится из РОЛЕЙ стадий (pipeline/waverun.go:43-53), конфиг требует лишь «хотя бы одна стадия» (config/pipeline.go:841), ветка живая и покрыта тестом (contractblockers_test.go:115-136); в репозитории такого конфига пока нет, но выбирает его оператор, и платформа шаблон не читает. У платформы units_done — жёстко второй проход (pgstore/sink.go:252-263, readmodel.go:200-208).

Следствие, которого в записке нет: ноль навсегда — это не только короткая полоса. Book.chapters_done и ChaptersLeft (pgstore, функции chaptersDone и BookRunContext) читают ту же колонку, а ChaptersLeft подрезает ШКАЛУ покупки. То есть на таком деплое сервис бесконечно предлагает купить главы, которые уже переведены. Это выводит вопрос за рамки «косметика полосы» и делает ответ обязательным.

Первая редакция этого абзаца дописывала «и берёт за них деньги» — снято ревью как неверенное, и снято ИЗ КАНОНА тоже (там оно уже стало нормативным текстом, компилирующимся в JSDoc клиента). Что проверено: платформа держит ПОТОЛОК, а не цену, и рассчитывается по ЗАМЕРЕННОЙ трате, а неизменённая уже начерновленная книга переигрывается с чекпойнтов за $0 — то есть в обычном случае холд возвращается целиком и не берётся ничего. Плата возникает только если между прогонами сдвинулся снапшот (слаг модели, промпт, langpack, флип гейта), и тогда пере-покупка действительно оплачивает переведённое заново. Довод «предлагают уже сделанное» стоит сам по себе и в поправке не нуждается; денежный довод был сильнее фактов, и это ровно тот класс ошибки, который у меня уже ловился.

Ответ контракта. «Сделано» = последний проход, который эта книга на ЭТОМ деплое реально получает. Записано в Progress и в Book.chapters_done — вместе с доводом: контракт не говорит, сколько проходов бывает, значит и назвать один из них для счёта не может, а обещание «дробь доходит до единицы» — это обещание, а не проходы. Имён волн в тексте нет (гейт §5 пере-ран, чисто). Форма не меняется, генерённые типы не двигаются; работа — проекция платформы, вход P7.

H. Снятие замечания (PD-298) — СВЕРЕНО С ДВИЖКОМ ПЕРВЫМ, как просила зона; механизма НЕ строим

Порядок соблюдён: сначала чтение движка. Результат — переход достижим у драйвера и НЕ достижим на проводе. flagged вычисляется на каждой эмиссии, а не хранится (pipeline/events.go:351,377-379, waverun.go:139-140,205-206), и redrive/--resnapshot/правка исходника пере-атакуют юнит, так что второе разрешение законно может выйти flagged=false. Но эмиссия идёт через announce-once-леджер: ключ unit:<book>:<wave>:<chapter>:<unit> не несёт ни снапшота, ни прогона (events.go:399-401), EnqueueOnce вторую строку не пишет (store/outbox.go:74-106), леджер переживает прогоны (outbox.go:205-212) и запинен тестом (runevents_test.go:242-246). Единственное окно — падение между записью строки в журнал и пометкой (events.go:154-164,200-202); плюс redrive платформа не вызывает вовсе (runner/engine.go:83-124).

Поэтому: строка «недостижимо» в регистр, а не механизм — ровно тот исход, который зона назвала правильным. Но в каноне закрыт РАЗРЫВ ПРАВИЛА, который эта проверка обнажила: §AfterVersion говорил «удаление так не выразить» только про ОПТОВУЮ замену, а про исчезновение ОДНОЙ строки не говорил ничего. Записано: строка, once delivered, поодиночке не отзывается; коллекция, которая обязана потерять строку, теряет её единственным выразимым способом — заменой целиком с resync_required. Это обязанность сервера, а не пожелание, и она делает поведение платформы выводимым: сегодня при flagged=false строка обновляется и ревизия двигается (pgstore/sink.go:237-244), дельта её прячет (readmodel.go:725-735), а note_count в кадре главы уже УМЕНЬШАЕТСЯ (sink.go:310-328) — то есть клиент увидел бы счётчик, противоречащий списку. Теперь ясно, что должно произойти вместо этого.

I. content_refused — forward-looking, НО с носителем; правлю канон и §3

Ответ на вопрос зоны: forward-looking, долг зона не заводит. Значение ратифицировано К-9 (владелец 16.08) до того, как появился производитель, и это сознательно: форму отказа лучше решить не под давлением. Проверено — производителя нет ни на одной стороне (в платформе только константа и строка таблицы; в движке отказ провайдера живёт как ПРИЧИНА ЗАМЕЧАНИЯ и уезжает как Note.code: content_withheld, ingest/notes.go:45-48, — другой словарь, моста нет).

Правки: (а) оба значения помечены в каноне как объявленные ДО производителя, с причиной; (б) обязанность ограничивать число попыток аккаунта пере-привязана: она падает ВМЕСТЕ с производителем, а не висит на сервере, который отказа не выдаёт, — иначе это обязательство без адресата, ровно тот класс, из-за которого заведено правило Б-21; (в) §3 получил строку с носителем (строка 94, ПТ-16). Клиент обрабатывает код с сегодня — старый клиент, встретивший его впервые, и есть та беда, ради которой значение объявлено заранее.

J. «Остановлено по вашей просьбе» — ПОЛЕ ЗАВЕДЕНО; форма отличается от предложенной зоной

Решение владельца 17.08 принято, форму делегировали контрактной сессии (archive/P7_ACCEPTANCE_HANDOFF_2026-08-17.md:452). Заведено Run.stop_requestedобязательное булево, а не необязательное. Довод: функция тотальна — прогон, который никто не просил остановить, это false, а не «поля нет»; необязательность дала бы два написания одного факта, и это ровно тот дефект, который контракт уже чинил у sense (0.2.2), у reject_reason и у kind. Ценой стал ломающий минор — Run получил обязательное поле, — и он объявлен честно в шапке компаньона, вместо того чтобы называть правку «аддитивной» и оставить генератор доказывать обратное. В описании записано и то, чего в заказе не было: флаг — запись ПРОСЬБЫ, а не исхода, и остаётся true на прогоне, который успел доработать, потому что это и есть случай, который экран обязан объяснить.

K. --verify-bankНЕ КОНТРАКТ, релей в зоны; но контрактно видимая половина закрыта

Расхождение «D39.144 (подпись = один акт) против пер-термного гейта движка» — ратификация и поведение движка, обе вне этого файла: контрактная половина уже исполнена (D39.144/145 сняли гейт полноты из канона и компаньона, 409 bank_decisions_incomplete удалён). Обход зоны (не передавать флаг на resume) — её решение и её же декларация.

Контрактно видимая половина — вторая, и она молчала. Канон обещал decline — «leave it out», а решение до движка не доезжает (mined_rejects платформа не пишет), то есть отклонённый термин возвращается предложенным. Это обещание пользователю, которого система не держит. Записано предупреждением на BankDecision.action + строкой §3 с носителем — строка 192, отложенная владельцем. Форму не меняю: обещание верное, не выполнена реализация.

L. Мелкое

  • Ссылка приложения А протухла — ИСПРАВЛЕНО, и не новым номером, а именем функции (flagReasonSeverity): номер строки в чужой зоне протухает за пак, что здесь и произошло.
  • heading всегда null — посылка «у читателя НЕТ меток глав вообще» неверна, и это тот случай, где сказалась объявленная зоной граница (фронт не смотрели). 0.3.0 снял запрет клиентской служебной метки: «Глава N» рисует КЛИЕНТ, в локали интерфейса, за $0 (§2.3, решение владельца 09.08). Без меток остаётся только книга, у которой их нет в данных, — а это легальная книга. Продуктовый пробел «настоящих названий нет» держится видимым строкой §3 (строка 160) и никуда не делся; менять нечего.
  • 410 на опечатку в id главы (PD-253)канон изменён в сторону зоны, а не наоборот. Требование различать «была и исчезла» от «такой не было» невыполнимо: id непрозрачны, после пере-разбора не хранятся, и доказать, что id никогда не минтился, можно только кладбищем всех выданных. Записано: 410 — ответ на id, которого нет в ТЕКУЩЕЙ структуре, был он когда-то или нет; 404 остаётся за книгой. Лечение у клиента в обоих случаях одно — перечитать дерево. Расхождение перестаёт быть расхождением.

M. Что зона сделала сама — сверено, возражений нет

Все восемь пунктов сверены с каноном: область Idempotency-Key (принципал, метод, путь), подрезание limit, resync_required вместо тихого старта, blocked только когда холд реально укорачивает шкалу, heading: null, finalizing снят, полоса и её база на одном проходе, снятие codeForStatus. Совпадает.

N. Границы этой сессии

Закрыто из того, что зона объявила своей границей: пункт H сверен с движком (чтением), пункт G сверен с обеими зонами, совместимость с генерённым фронтом проверена исполнением (openapi-typescript@7 — чисто; единственная ломающая правка названа). Пункт B живьём тоже не воспроизводился — и не требовался: он закрыт ратификацией, а не замером. Не проверялось: как экраны фронта рисуют новые ответы (зона заморожена), и поведение построенного пути resume после правки таблицы — это работа P7.

Что забирает зона (P7): 409 ceiling_reached на исчерпанном прогоне вместо 202 (A) · unspecified как обязанность, а не самодеятельность (D) · тождество интейка по содержимому с откатом вместо Content-Length (E) · «сделано» = последний проход этого деплоя (G) · Run.stop_requested в projectRun (J) · при снятии флага — resync_required, а не тихое исчезновение (H). Что закрыто одной фразой в тексте и работы зоны не требует: C, F, I, L.

Ревью диффа этой зоны (исполнено этой же сессией, 20.08) — что оно изменило

Четыре независимые линзы по диффу: ХОЛОДНЫЙ ПОТРЕБИТЕЛЬ (модель другого семейства — fable; читал ТОЛЬКО финальный канон, без диффа, без компаньона, без записки платформы, и пытался реализовать шесть изменённых мест) · опровергатель ПОСЫЛОК диспозиций · регрессионная линза (умерло ли правило вместе с удалённой строкой + независимая пере-сверка 0.2.3 → 0.3.0 ДРУГИМ срезом: не по удалённым строкам, а по перечислению всех схем/полей/операций 0.2.3) · линза стандартов (RFC по первоисточникам). Каждая находка потом адверсариально верифицировалась отдельным агентом с установкой «по умолчанию опровергнуто». 31 находка, 5 подтверждено, 26 опровергнуто; 35 агентов, 0 упавших (важно: упавший агент — это НЕ «опровергнуто»).

Ни одна из пяти не опрокинула диспозицию — все пять о точности МОЕГО текста, и это правильный результат для ревью, которое ищет не «согласен ли я», а «проверяемо ли написанное»:

  1. Денежный довод в пункте G был сильнее фактов — и уже стоял НОРМОЙ в каноне. «Предлагает уже сделанное и берёт за них деньги»: вторая половина не проверена и для обычного случая неверна (потолок, а не цена; расчёт по замеренной трате; неизменённая книга переигрывается за $0). Снято и из канона, и отсюда; довод «предлагает уже сделанное» стоит сам.
  2. «Платформа физически не пишет day_usd» — неверный глагол: не ЗАВОДИТ, но шаблон деплоя пере-маршалится целиком, и оператор может внести поле без единой правки Go. Поправка усиливает триггер пересмотра, а не отменяет отказ.
  3. «Run едет в кадре потока» — ложный факт: ни один payload Run не несёт. Цена реальна, но её носитель другой — предписанная кадром перечитка карточки.
  4. Носитель строки H («регистр движка») — документ, которого нет. Живой носитель — PD-298 регистра платформы. Ровно то нарушение правила Б-21, которое этот же файл и вводит.
  5. Ссылки в чужую зону мешали ДВЕ базы (HEAD и рабочее дерево с 81 незакоммиченным файлом платформы) в одной строке таблицы. Лечение — база названа явно, адресация именами функций.

Три опровержения я отклонил и правку внёс. Три линзы независимо споткнулись об один шов — что отвечает resume, когда у прогона лимит ещё есть, а СЧЁТ не тянет холд. Каждую формулировку верификатор опроверг по отдельности («тексты можно прочесть согласованно»), но холодный потребитель прямо написал, что решить не может, а платформа этот случай уже схлопывает в тот же вердикт. Сходимость трёх независимых линз на одном месте — сигнал сильнее трёх поштучных опровержений. Заведено cause.code: credit_unavailable (второй уровень не закрыт по замыслу — типы не двигаются), ceiling_reached пере-сформулирован как «ЭТОТ прогон закончен», и явно сказано, что подменять друг друга они не могут. Тем же ходом закрыт вопрос холодного потребителя про stop_requested после resume (снимается) и возвращена клауза «предел попыток не на проводе», умершая с моей же правкой.

Второе ревью: велосипеды и объём прозы (заказ владельца 20.08)

Первое ревью проверяло текст на соответствие RFC. Владелец задал ДРУГОЙ вопрос — «а так вообще делают, или ты изобрёл своё», плюс «не раздул ли ты прозу». Отдельная проверка: линза охоты за велосипедами (модель другого семейства, с обязательным чтением первоисточников через WebFetch) + линза размещения прозы, которой прямо сказали, что автор диффа склонен переобъяснять. 20 находок, подтверждена 1.

Велосипедов не подтверждено ни одного. Проверялись против настоящих спек: тождество интейка против draft-ietf-httpapi-idempotency-key-header и RFC 9530 (Digest Fields) — формулировка «чем сервер устанавливает тождество, его дело» черновику СООТВЕТСТВУЕТ, а Repr-Digest предписать клиенту мы не можем и не должны · credit_unavailable под 409 — 402 зарезервирован, двухуровневый код конвенционален (Microsoft innererror, google.rpc.ErrorInfo) · stop_requested булевым против конвенции таймстемпа (k8s deletionTimestamp, AIP-165) — отклонено · 410 на никогда не существовавший id против RFC 9110 §15.5.11 — отклонено · «валидатор легален на любом безопасном чтении» против OAS 3.1 — отклонено · пер-строчные tombstone по образцу JMAP (RFC 8620) — не наш случай.

По объёму — замер стоит, классификация не устояла. Верификатор пере-ран замер пином зоны (openapi-typescript@7.13.0) и воспроизвёл его до байта: канон дал +10 546 байт и +144 строк комментариев в исходники фронта. Но гипотеза «62 % этого — обоснование, которому место здесь» пере-проверку не прошла: из тринадцати заявленных блоков выжил ОДИН — довод в Progress («контракт не говорит, сколько проходов бывает…»), который (а) дословно уже записан здесь, в §6в G, и (б) локально пере-выводит глобальное правило шапки канона. Срезано; правило осталось одной фразой.

Своим решением, а не по находке, срезан ещё денежный хвост у Book.chapters_done — он ничего не меняет в поведении клиента. Итог трима: 152 758 → 152 002 байта. Урок для следующей правки канона: норма — в канон, довод — сюда; проверять не глазом, а диффом генерённого клиента.

Ратификация этой редакции — D39.152 (20.08, оркестратор №18)

Текст ноты, сданный контрактной сессией, исполнен и заменён живым: ратификация записана в docs/architecture/05-decisions-log.md, нота D39.152, строка реестра — 05-decisions-index.md. Черновик ноты, живший здесь, снят при лендинге (испр. оркестратором №18): он расходился с итоговым текстом в двух местах, и две копии одной ноты — ровно тот распад «один носитель на факт», против которого написана норма D39.112. Что в живой ноте отличается от черновика: (1) внесены ЧЕТЫРЕ текстовые правки при ратификации (сеттер stop_requested в stopRun · стухшая фраза §6в A против собственного аддендума · «cleared by a resume that SUCCEEDS» · приписка третьего случая resumeRun к таблице); (2) клейм «ломающая правка ровно одна» уточнён: он верен для диффа генерённых ТИПОВ и неточен поведенчески — против 0.3.0 расходятся ЧЕТЫРЕ места (см. D39.152 п.5).


7. Эксплуатационные примечания — НЕ норма контракта

Вынесено из спеки в 0.3.0 (Б-16). RFC 9205 §4.1 прямо про наш случай: «Requiring a particular version of HTTP … harms interoperability. Therefore, it is NOT RECOMMENDED that applications using HTTP specify a minimum version … However, if an application's deployment benefits from the use of a particular version of HTTP (for example, HTTP/2's multiplexing), this ought be noted». Отметить, а не потребовать. Спека 0.2.3 требовала («Required: HTTP/2 at the edge») и держала в норме вендорный заголовок конкретного прокси.

Норма в спеке — то, что клиент НАБЛЮДАЕТ и на что вправе рассчитывать: ETag/If-None-Match/304 · обязанность честить Accept-Encoding на JSON-ответах и НЕ сжимать text/event-stream · Vary на согласованном представлении · Cache-Control: no-store · запрет буферизации потока · форма heartbeat · форма id кадра. Сжатие как ТРЕБОВАНИЕ живёт в контракте — это прямое указание D39.138 п.2(д) («сжатие и условные чтения записываются В КОНТРАКТ, не в зонный док»), и первая редакция батча его нарушила, вынеся сжатие целиком в примечание; поймано опровергателем полноты и исправлено. В примечании остаётся ТОЛЬКО слой исполнения — где именно сжимать, — потому что вот этого клиент действительно не наблюдает, и вот это Б-16 из контракта и выносит.

Ниже — то, что клиент не наблюдает и что деплой обязан себе устроить сам:

  • Чем и на каком слое сжимать (middleware, обратный прокси, CDN). Go stdlib не сжимает, edge-конфига в репозитории нет, grep -rn "gzip|Content-Encoding" platform --include=*.go → ноль.
  • HTTP/2 на edge. Клиент держит одно SSE-соединение на книгу; на HTTP/1.1 шесть соединений на origin — потолок вкладок. Сервис на HTTP/1.1 обязан работать ХУЖЕ, а не не работать.
  • X-Accel-Buffering: no (или эквивалент прокси) — механизм для нормы «поток не буферизуется».
  • Сколько это даёт. Замер ревью на настоящей книге (~/books/gu-zhenren, 2283 главы): глава 26,1 КБ → 10,4 КБ; дерево глав 250 КБ → 39 КБ; банк 1000 строк 167 КБ → 17 КБ.
  • Порядок работ по эффекту на килобайт усилия (research/28 §5б, строка 186): сжатие → ETag/304 → скоуп в кадре → дельта-чтения ?after_version=staleTime у клиента. Шаги 34 — уже в контракте (0.3.0), шаги 12 — конфигурация, шаг 5 — зона фронта.
  • Транспорт НЕ меняется (проверено независимым агентом другого тира + сверка первоисточников): наша нагрузка — художественный текст, и после gzip разница между JSON и protobuf единицы процентов; gRPC-web требует прокси и теряет If-None-Match/304; Connect возвращает то, что у нас уже есть. Наш паттерн «кадр без данных → клиент перечитывает» легитимен и называется poke/pull.

Приложение А. Карта «причина → КОД контракта → продуктовая фраза» — ЗАГОТОВКА

Что изменилось в 0.3.0. Прежняя карта вела «причина движка → фраза», то есть предполагала, что ФРАЗУ рисует сервер. Это ровно та политика, которую вариант B отменил для ошибок (§2.17), и держать её для замечаний значило бы оставить на проводе серверную локализованную строку — второй русский текст, приходящий из зоны, у которой нет ни Accept-Language, ни локалей. Поэтому Note несёт code, а фразу рисует клиент; Note.message с провода снят.

Правила заполнения.

  1. Фраза пишется по ДОККОММЕНТУ disposition.go, а не по имени константы, и рядом кладётся цитата — иначе повторяется инверсия, стоившая двух фраз (glossary_miss подан как «термин не подписан», хотя термин ПОДПИСАН и его проигнорировали, disposition.go:78-79; sanitizer_stripped подан как потеря текста, хотя «the chunk is NOT lost», disposition.go:99).
  2. Класс 2 схлопывается в ОДИН код (§8а, нормативно): четыре причины ранга 0 — модельный отказ — на проводе неразличимы, потому что каждый различимый код здесь бит обратной связи подбирающему.
  3. Слова — владельца (ПТ-33, В-3, строка 148). Коды ниже — ◆ ПРЕДЛОЖЕНИЕ, ратифицируются вместе с фразами; до тех пор Note.code в спеке НЕ enum, чтобы схема не стала второй копией незаписанной карты.
  4. Последняя строка — не формальность: контракт обязан иметь фразу для причины, которой ещё не существует, и она обязана читаться нейтрально, а не как «ошибка».
  5. Клетка кода последней строки БОЛЬШЕ НЕ ПУСТА — unspecified (синк 20.08, п. D записки). Пустая клетка была невыполнима: Note.code в каноне обязателен и minLength: 1, движок и платформа выпускаются независимо, поэтому окно «пришла причина, которой этот билд не знает» — штатное. Платформа уже отдавала стабильный плейсхолдер unspecified (ingest/notes.go), не имея на него ратификации; синк её ратифицировал и записал в канон обязанностью сервера, а не строкой словаря. Бампа схемы не требует — Note.code объявлен type: string.
  6. Столбец «Ступень» пуст, а платформа уже провела границу. ingest/notes.go раскладывает девять движковых рангов на два проводных значения по правилу «потерял ли читатель текст». Это ПРОВИЗОРНО и ратификацией не является: К-6 открыт, ответ владельца 16.08 — «решим потом». Строка записана здесь, чтобы построенное не читалось как решённое.
Причина движка Ранг Код контракта ◆ Продуктовая фраза Ступень
hard_refusal · soft_refusal · content_filter · hard_block 0 content_withheld (ОДИН на все четыре — класс 2)
cjk_artifact 1 source_residue
excision_suspect 1 text_possibly_dropped
coverage_fail 1 incomplete_coverage
sanitizer_defect 2 markup_defect
loop_degenerate 3 repetition
decode_error 4 unreadable_answer
glossary_miss 5 term_not_applied плейсхолдер: «Подписанный термин не применён в переводе»
length 6 length_mismatch
empty 6 empty_answer
sanitizer_stripped 7 markup_cleaned плейсхолдер: «Служебная разметка вычищена автоматически»
upstream_not_ok 8 (по умолчанию) unavailable
незнакомая причина 8 (по умолчанию) unspecified (обязанность сервера, п.5; клиент рисует ту же нейтральную фразу, что и на незнакомый код) нейтральная, НЕ «ошибка»

Причин пятнадцать; upstream_not_ok не имеет своей ветки в flagReasonSeverity (backend/internal/pipeline/status.go, функция flagReasonSeverity) и падает в ранг по умолчанию, как и любая будущая причина. ⚠ Номер строки здесь намеренно не ставится: прежняя ссылка :174 протухла за один пак (там теперь GlossaryMissFlagged) — поймано синком 20.08, п. L записки.

Приложение А-2. Карта кодов ОШИБОК — заполнена (0.3.0)

В отличие от карты выше, эта заполнена целиком: словарь выведен из реальных ветвей платформы, а фраз она не содержит по замыслу — их рисует клиент.

Корневой code HTTP Откуда взят (платформа)
invalid_request 400 v0.go:238,250,333,339,531 · pgstore.ErrBadCursor · books.ErrBadIntake (books.go:118-121, readField v0.go:394-396) · v0.go:242 (неполное тело) · v0.go:345,356,419,423 (интейк)
unauthenticated 401 гард сессии (server.go:109-110)
forbidden 403 server.go:99 + auth/csrf.go (отсутствие X-TM-Client / чужой origin)
not_found 404 pgstore.ErrNoBook/ErrNoAccount/ErrNoRun · охраняемый catch-all server.go:134
gone 410 pgstore.ErrNoChapterv0.go:761 — глава, которой нет в ТЕКУЩЕЙ структуре книги (была она когда-то или нет — не различается по замыслу, openapi.yaml §gone; лечение клиента одно: перечитать дерево). ⚠ Строка дописана оркестратором №18 при ратификации 0.4.0: карта объявляла себя «заполненной», а несла 15 значений из 16 корневых — производитель у gone живой с P7
request_timeout 408 os.ErrDeadlineExceededv0.go:417
payload_too_large 413 *http.MaxBytesErrorv0.go:409
run_in_flight 409 pgstore.ErrRunInFlight (v0.go:548)
book_not_ready 409 runs.ErrBookNotReady (v0.go:550)
run_not_stoppable 409 runs.ErrNotStoppable (v0.go:554)
run_not_resumable 409 runs.ErrNotResumable; cause: ceiling_reachedс 0.4.0 это же ответ на ИСЧЕРПАННЫЙ прогон в stopped/awaiting_bank, где платформа сегодня молча отвечает 202 (A, PD-282: runs/reconcile.go, ветка exhausted функции reopen и её чтение в Resume). ⚠ bank_decisions_incomplete удалён D39.144 — гейта полноты нет; построенный гейт полноты (reconcile.go, ветка case "awaiting_bank") ДЕМОНТИРУЕТСЯ в P7
ceiling_unavailable 409 runs.ErrCeilingOutOfBounds + pgstore.ErrInsufficientCredit (v0.go:558); cause: bounds_moved · credit_held; несёт blocked
idempotency_conflict 409 форма заведена батчем; реализация — P7
content_refused 400 К-9; прескрин не построен (ПТ-16, строка 94). Отказ целой КНИГИ приходит не сюда, а состоянием rejected + reject_reason
service_unavailable 503 runner.ErrCeilingNotWired + runs.ErrRunnerIncomplete (v0.go:562)
internal_error 500 v0.go:518,572 · middleware.go:68

Коды, которых платформа сегодня достигает, а контракт до 0.3.0 не объявлял: 403 (server.go:99), 500 (v0.go:518,572), 431 от net/http (serve.go:79), 429 на /auth (login.go:173,234). Из них контракт объявляет 403 (это нарушение правила, которое ИЗОБРЁЛ сам документ) и не объявляет 500 (Zalando: «500 Internal Server Error · use · do not document»), 431 (уровень stdlib) и 429 (на /v0 лимитера нет вовсе — форвард-вопрос платформы). 405 на /v0 не бывает: catch-all глотает метод.