928 lines
108 KiB
Markdown
928 lines
108 KiB
Markdown
<!-- ======================================================================
|
||
РЕВЬЮ-ШАПКА (ратификация D39.99, 04.08.2026, оркестратор №12;
|
||
батч 0.3.0 — D39.138, 16.08.2026, оркестратор №17)
|
||
|
||
СТАТУС: РАТИФИЦИРОВАН как контракт API v0. Нормативная поверхность —
|
||
openapi.yaml РЯДОМ (байт-копия зонной frontend/docs/api-contract/openapi.yaml;
|
||
байт-сверка копий — обязанность каждого лендинга, расхождение = дефект лендинга).
|
||
Генерация типов фронта после этой ратификации идёт из ЭТОЙ копии.
|
||
|
||
Приёмка 0.2.0: три адверсариальных воркфлоу-пасса (черновик · дофикс round-1 · round-2),
|
||
19 подтверждённых находок, 0 опровергнутых, все вправлены; батарея фронта с
|
||
контракт-шагом (spectral + дрифт-тест генерённых типов) зелёная исполнением.
|
||
|
||
16.08.2026 (№17): ЦЕЛОСТНОЕ РЕВЬЮ КОНТРАКТА ПРИНЯТО (docs/research/28-contract-review.md,
|
||
D39.138) — исполнен ЛОМАЮЩИЙ батч 0.3.0 (состав — research/28 §5; строка 183; отчёт
|
||
исполнения — docs/archive/reports/CONTRACT_BATCH_0.3.0_REPORT.md). Тело этого файла
|
||
ПЕРЕПИСАНО под 0.3.0 вместе со спекой: держать его на 0.2.3 значило бы учить зоны неправде
|
||
(ровно то, что ревью и поймало — §5 ниже). Авторские формулировки фронт-сессии S3 сохранены
|
||
там, где их предмет не двигался; всё, что двигал батч, помечено «0.3.0».
|
||
Правило лендинга батча: канон первым, зеркало фронта отдельным зонным коммитом,
|
||
cmp-сверка обязательна (D39.138 п.3).
|
||
|
||
ТРИ ОПРОВЕРГНУТЫХ УТВЕРЖДЕНИЯ ПРЕЖНЕЙ РЕДАКЦИИ СНЯТЫ ЭТОЙ (не «поправлены» — сняты,
|
||
потому что каждое учило зону неправде):
|
||
1. §5 «переименована стадия / добавлена волна → фронт НЕ правится» — было ЛОЖНО для 0.2.3
|
||
(опровергнуто исполнением, research/28 Б-0). Ответ переписан, см. §5.
|
||
2. §3 «у чтения /bank сегодня нет канала вообще» — неверно с D39.122 (движок пишет сайдкар
|
||
всего банка); файл противоречил собственной строке. Снято, см. §3.
|
||
3. §3 «resume после стопа по потолку возвращает прогон в то же состояние, и лечения нет» —
|
||
вторая половина неверна: лечение существует и работает (новый прогон с бОльшим потолком).
|
||
Снято, см. §3 и описание `resumeRun` в спеке.
|
||
====================================================================== -->
|
||
|
||
# Контракт API v0 — спутник спеки: провенанс, обоснования, вопросы
|
||
|
||
> **Нормативная поверхность контракта — [`openapi.yaml`](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:201`), поэтому «глава ждёт подписи,
|
||
пока соседняя финализируется» — невозможная картина. У главы движок держит `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 «Ок, делаем так»):** одна полоса до
|
||
ближайшей остановки, знаменатель — КУПЛЕННЫЙ объём, после подписи банка полоса начинается заново.
|
||
|
||
**Форма, выбранная батчем: счёт в ГЛАВАХ, а не в юнитах.** Три довода, и второй — денежный:
|
||
|
||
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` тем же разбором ОСТАВЛЕН как различение**
|
||
(провенанс нужен подписывающему, чтобы понимать доверие к строке) и переименован по значениям: `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 — the stop clears once every proposed term is promoted or
|
||
rejected» (`pipeline/mining.go:201`). Отсюда: решение на термин · счётчик «решено N из M» · запрет
|
||
«продолжить» при неполном наборе · частичное сохранение ◆.
|
||
|
||
**0.3.0 — две правки:**
|
||
|
||
- **`promote` → `approve`.** `promote`/`decline` — дословно глаголы оператора майнера из строки выше,
|
||
и `promote` порождал `status: approved` — два слова на один акт. Теперь `approve` → `approved`.
|
||
- **Снимок стопа переехал в ЧТЕНИЕ банка** (Б-14а). `complete` — поле, по которому решается, можно
|
||
ли предлагать «продолжить» (`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: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` — корневой, закрытый, 15 значений; `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а), и это причина, по которой заведена
|
||
таблица выше:
|
||
|
||
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`. Последовательность стала конечной: `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` с провода снят.
|
||
|
||
**Правила заполнения.**
|
||
|
||
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. Последняя строка — не формальность: контракт обязан иметь фразу для причины, которой ещё не
|
||
существует, и она обязана читаться нейтрально, а не как «ошибка».
|
||
|
||
| Причина движка | Ранг | Код контракта ◆ | Продуктовая фраза | Ступень |
|
||
|---|---|---|---|---|
|
||
| `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`: `bank_decisions_incomplete` · `ceiling_reached` |
|
||
| `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 глотает
|
||
метод.
|