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