diff --git a/docs/CONTRACT_BATCH_SESSION_PROMPT.md b/docs/CONTRACT_BATCH_SESSION_PROMPT.md index 5cfa62dc..a17bcc9a 100644 --- a/docs/CONTRACT_BATCH_SESSION_PROMPT.md +++ b/docs/CONTRACT_BATCH_SESSION_PROMPT.md @@ -184,3 +184,20 @@ D39.138): ядро контракта здорово, дефекты сосре дифф-сводка «было → стало» по операциям. Без саги. Дерево оставить незакоммиченным. Вопросы и конфликты промта с кодом/доками — пинг оркестратору через владельца, НЕ интерпретация. + +--- + +## ДОФИКС-РАУНД (заказ приёмки D39.142, 16.08; аддендум — доезжает релеем владельца, подтверди эхом) + +Батч ПРИНЯТ и заленден (панель: слепая сверка — потерь 0/искажений 0; аудит отчёта — 25+ клеймов сошлись). Дофикс — находки ХОЛОДНОГО ПОТРЕБИТЕЛЯ (линза, которой у твоего селф-ревью не было: только финальная спека + tsc-проба) и хвосты аудита. Зона и гейты прежние. Ратификации приёмки, которые НЕ двигать: прогонный поток снят · `Note.code` вместо `message` · `content_refused` остаётся · единый `page_size_default` принят. По каждому ФБ — диспозиция в дополнении к отчёту. + +**ФБ-1 (HIGH).** Wire-форма SSE-кадра неоднозначна: `EventEnvelope{event,id,data}` как объект против `event:`/`id:` как SSE-фрейминг — два верных чтения (улика: A3 панели). Записать однозначно. +**ФБ-2 (HIGH).** Книга в покое без единого кадра истории: `hello` не имеет id последнего кадра → клиент без `Last-Event-ID` → «всегда новый поток» → hello→end→close→reconnect навечно (A4). Определить поведение. +**ФБ-3 (HIGH).** «`note` не теряется» не переживает reconnect: буфер не гарантирован, resync не шлётся (A5). Записать обязанность клиента: дельта-чтение `/notes?after_version=` после КАЖДОГО переподключения (механизм уже есть — назвать долг). +**ФБ-4 (HIGH).** `Revision` противоречит себе: «каждое книжное чтение несёт одно число» vs «рваное чтение = ревизия СТАРЕЙШЕЙ страницы» vs водяной знак «ревизия только что полученного конверта» (C1/A6); `BookDetail.revision` vs `book.revision` — равенство не заявлено (C2). Свести в одну модель; правило водяного знака при много-страничной дельте — явно. +**ФБ-5 (MED).** `EventEnd`/`EventResyncRequired` — пустые `allOf`-члены дают необитаемые типы (`Record`, B1); дать им явные `properties` (хотя бы `{}` честной формой) и подсказку маппинга event→payload машинно-дружелюбнее (B2 — можно прозой-таблицей + непересекающимися формами). +**ФБ-6 (MED).** Спека приказывает предлагать «новый прогон» как лечение стопа по потолку, но resumable-статусы не перечислены, а `paused_reason:null` описан «нейтрально, continuable» (A1/A2). БЕЗ нового значения enum (ратификацию D39.132 п.2а не двигать): записать — «новый прогон с бОльшим потолком» легален для ЛЮБОГО paused; перечислить, что делает `resume` по каждому останавливающему статусу. +**ФБ-7 (MED).** Правило «ответ НЕ `problem+json`» (прокси 502, HTML, обрыв) — потерянный пункт заказа (Б-1 рек. п.4): клиенту нельзя показывать серверный текст и нечем взять код — записать нейтральный fallback (E1). +**ФБ-8 (MED, пачка одной строкой каждая).** merge-patch `title:null`→400 против RFC 7386 — объяснить в описании (C6) · `X-TM-Client` required против освобождения bearer — записать разводку в самом параметре (C8) · список «Refusals:» пометить неисчерпывающим (C5) · правило резолюции `Location` (A7) · тождество `Idempotency-Key` на multipart + путь ретрая 408-под-тем-же-ключом (A9) · `min_chapters` при `max=0` (A11) · приоритет пяти носителей «нет кредита» (A12) · «carried forward marked as unverified» — носителя на проводе нет: снять или дать (A13) · 409 у `createExport` объяснить (A14) · код для неизвестного `term_id` в decisions (A15). +**ФБ-9 (LOW).** `parser_unavailable` — имя компонента в enum, переименовать по эффекту (L1) · внутренние ссылки `research/28 §…`/«companion К-6» уезжают в исходники клиента — вычистить из описаний, оставить в компаньоне (L3). +**ФБ-10 (отчёт).** Пере-ран чисел ПОСЛЕ последней правки: строки файла (2315, не 2290) · `ErrorCode` 16, не 15 · генератор 2608 · «26 находок» → фактические 57/67 · «все девять» §5.3 → 11 строк · «8 из 20 не отвечает» → 12 из 20 · якорь `mining.go:201` → :81/:243 · «строка 21» → 18; скрипт мерки прозы/формы приложить в отчёт или снять вывод о доле; дописать строки записки-плана Б-23/К-10/В-6; ярлык §5.5 «исполнено иначе» → «отступление, аргумент»; README §6а — фраза «апологии перенесены» → «ужаты на месте». diff --git a/docs/architecture/14-api-contract/README.md b/docs/architecture/14-api-contract/README.md index f4692c2c..9617747e 100644 --- a/docs/architecture/14-api-contract/README.md +++ b/docs/architecture/14-api-contract/README.md @@ -1,91 +1,102 @@ # Контракт API v0 — спутник спеки: провенанс, обоснования, вопросы -> **Нормативная поверхность контракта — [`openapi.yaml`](openapi.yaml)** (файл рядом, в этой же папке) -> (OpenAPI 3.1). Этот файл её НЕ дублирует: он несёт то, чего YAML не выражает — откуда взято -> каждое решение, чем оно обосновано, что осталось открытым. При расхождении по ФОРМЕ -> побеждает YAML; при вопросе «почему так» — этот файл. +> **Нормативная поверхность контракта — [`openapi.yaml`](openapi.yaml)** (файл рядом, в этой же +> папке). Этот файл её НЕ дублирует: он несёт то, чего YAML не выражает — откуда взято каждое +> решение, чем оно обосновано, что осталось открытым, и ГЕНЕЗИС форм (историю ратификаций, +> сверку со стандартами, разобранные альтернативы). При расхождении по ФОРМЕ побеждает YAML; +> при вопросе «почему так» — этот файл. > -> **Статус: РАТИФИЦИРОВАН — D39.99 (04.08), контракт 0.2.0 (D39.115, 08.08); 0.2.1 (D39.123, 09.08): += `503` на `POST /books/{bookId}/runs` — старт прогона на неукомплектованном деплое (шов движка не сконфигурирован) не выражается ни одним прежним кодом; закрывает PD-112 платформы; 0.2.2 (D39.129, 10.08): `BankTerm.sense` обязателен (Ф-47); **0.2.3 (D39.135, 15.08)**: PD-172 потоковое правило формы `createBook` (уточнено ПО КОДУ платформы против текста заказа: чтение останавливается НА файле — обязательное поле после файла = 400 «как не слали», необязательное молча теряется) · PD-173 `RejectReason` (enum версии: source_unreadable · not_configured · parser_unavailable; `Book.reject_reason` необязательное) · PD-174 404 интейка / 503 `resume` · PD-180 честный 201=`parsing` + отказы 400/408/413 · `BookIntake.title` необязательное. ⚠ Форвард-половина 0.2.3: платформа сегодня `title` не читает и `reject_reason` не проецирует — реализация обоих = явный вход P7 (D39.135 п.2в).** Дом канона — этот каталог; `frontend/docs/api-contract/openapi.yaml` — байт-зеркало. ⚠ Испр. оркестратором №15 08.08: файл называл себя черновиком на ратификацию четверо суток ПОСЛЕ ратификации — тот же класс, что шапка `info` спеки. -> `docs/architecture/14-api-contract/`, зона оркестратора; перенос делает он, генерация типов -> после ратификации идёт из перенесённой копии — контракт первичен, код вторичен. +> **Статус: РАТИФИЦИРОВАН.** 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` — +> байт-зеркало. > -> **Язык.** Спека английская: из неё генерятся типы, а исходники фронта по конвенции -> английские (слово владельца 04.08). Ссылки на К-вопросы внутри YAML набраны латинской -> `K-N` — это те же вопросы §4. Спутник и остальные доки зоны — русские. +> **0.x ломает миноры и будет ломать весь бета-период** (решение владельца 16.08, §8 п.17 +> research/28): 0.3.0 — обычный ломающий минор, а НЕ последний перед 1.0. Окно 1.0 — после беты. +> +> **Язык.** Спека английская: из неё генерятся типы, а исходники фронта по конвенции английские +> (слово владельца 04.08). Ссылки на К-вопросы внутри YAML набраны латинской `K-N` — это те же +> вопросы §4. Спутник и остальные доки зоны — русские. > > **Зона строки 95 — «оркестратор/бэкенд/фронт».** Фронт авторитетен в одной трети: форма -> read-модели и продуктовые словари. Транспорт платформы (пути, аутентификация, коды) и -> работы движка (99–103) здесь ПРЕДЛОЖЕНЫ и без подтверждения своих зон не действуют. +> read-модели и продуктовые словари. Транспорт платформы (пути, аутентификация, коды) и работы +> движка здесь ПРЕДЛОЖЕНЫ и без подтверждения своих зон не действуют. ## 0. Пометки провенанса | Пометка | Что значит | |---|---| -| **✓ выведено** | следует из кода движка или ратифицированного решения; грунт `file:line` рядом | -| **◆ предложено** | решение фронта, разумное по его сведениям; подтверждает названная зона | -| **○ открыто** | развилка, на которую у фронта ответа нет; перечень — §4 | +| **✓ выведено** | следует из кода движка/платформы или ратифицированного решения; грунт `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. +Контракт — машинный артефакт: из него генерируются типы, по нему линтуется форма, им типизируются +моки. Прозаический контракт расходится с кодом ровно тем способом, ради предотвращения которого +заведена строка 95. -Инструменты, пины и отклонение по пиру TS не дублирую: они в `STACK_DECISIONS.md` §3 и в -бэклоге зоны — Ф-23 (`overrides` вместо `--legacy-peer-deps`), Ф-24 (AsyncAPI отложен с -причиной). Четыре формы нарушения гейта, каждая проверена живьём, — `FRONTEND_PLAN.md` §5.4.2. +⚠ **Но машинного мало, и 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 и бэклог зоны. --- @@ -94,333 +105,744 @@ resume-абзац спеки :440-445 запрещает работающее л ### 2.1. Язык — код, никогда не имя — ✓ выведено Движок держит коды (`backend/internal/config/book.go:26-27`), ключ пары — `zh-ru` -(`configs/langpacks/zh-ru/`). Имя языка в данных — пар-специфика в общем слое, запрещённая -§2 канона. Вторая цена, дороже: `lang` элемента берётся из данных книги, и пара ja→ru с именем -вместо кода отрисует кандзи китайскими начертаниями молча. +(`configs/langpacks/zh-ru/`). Имя языка в данных — пар-специфика в общем слое, запрещённая §2 +канона. Вторая цена, дороже: `lang` элемента берётся из данных книги, и пара ja→ru с именем вместо +кода отрисует кандзи китайскими начертаниями молча. -### 2.2. Идентификаторы непрозрачны — ✓ выведено +**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`), поэтому правка исходника сдвигает -номера последующих глав. Стабильность обеспечивает платформа при персисте манифеста (100). +(`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`: `template Глава {n}`), -исходный маркер вырезается из текста для модели (`chunk/chunker.go:110-114`), а на экспорте -титул **вклеивается внутрь текста первого юнита** (`pipeline/export.go:215`), причём колонка -исходника остаётся без него. +**Движок сегодня делает ОБРАТНОЕ**, и это надо назвать прямо: титул рендерится детерминистически из +шаблона пары (`configs/langpacks/zh-ru/heading.txt`), исходный маркер вырезается из текста для модели +(`chunk/chunker.go:110-114`), а на экспорте титул вклеивается внутрь текста первого юнита +(`pipeline/export.go:215`). Комментарий движка прямо запрещает подавать этот рендер как метку книги +(`pipeline/manifest.go:80-86`). Развилка — К-2. -Выведена здесь только МЕХАНИКА. Само поле `heading` — предложение фронта, и у него есть цена -на другой стороне: движку придётся отдавать титул отдельно. Альтернатива (оставить вклейку, -фронт отрезает строку) хуже: отрезание титула из текста — это парсинг прозы, и он сломается -на первой главе без заголовка. Развилка — К-2. +**0.3.0 снял противоречие, которое существовало независимо от К-2.** Спека 0.2.3 запрещала клиенту +синтезировать метку «Глава N» — а решение владельца 09.08 (`research/27` §33-36) требует ДВУХ меток: +оригинальной из данных книги плюс служебного рендера «Глава N» за $0 в локали ИНТЕРФЕЙСА. Сервер +локали интерфейса не знает (`Accept-Language` в платформе нет грепом), значит служебный рендер обязан +быть клиентским. Запрет снят; `Chapter.heading` = только метка ИЗ ДАННЫХ книги, `null` — обычный +ответ, и деплою прямо запрещено класть сюда рендеренный порядковый. -**Расхождение фикстуры, найденное разбором:** дерево витрины показывает «Раздел 2. …», колонка -оригинала — неснятый «第二节:». Для zh→ru движок не порождает ни одной из форм. Не чинится -до ответа на К-2. +**`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`). +Подпись банка это один стоп на всю книгу (`pipeline/mining.go:201`), поэтому «глава ждёт подписи, +пока соседняя финализируется» — невозможная картина. У главы движок держит `ChapterPassport` +(`pipeline/status.go:37-55`). -### 2.5. Прогресс пофазно и в юнитах — ✓ выведено +### 2.5. Прогресс — ОДНА полоса до ближайшей остановки, в главах (0.3.0) — ◆ решение владельца -«A unit is DONE when every member draft AND the unit's edit resolved ok» -(`pipeline/status.go:328-331`), редактура не стартует до стопа банка ⇒ сквозной счётчик стоит -на нуле всю черновую волну. Зависимость — строка 99. +**Прежняя редакция (0.2.x): пофазно и в юнитах, ✓ выведено.** Основание было верное по факту: +«A unit is DONE when every member draft AND the unit's edit resolved ok» (`pipeline/status.go:328-331`), +редактура не стартует до стопа банка ⇒ сквозной счётчик стоял бы на нуле всю первую волну. -### 2.6. Словарь статусов — ✓ лестница, ◆ ненормальные исходы +**Почему это всё равно был дефект (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`. То есть архитектура движка была +пришпилена к контракту в шести местах, а её изменение — ломающим для фронта. -Лестница дословно из строки 95: «загрузка → разбор → перевод → подпись банка → финал → -готово». **`not_started` — дыра, найденная самопроверкой черновика:** лестница описывает -идущий прогон, а библиотека обязана показывать разобранную книгу, которую не запускали. -`stopped`, `rejected`, `not_started` контракт ВЫВОДИТ из поведения процесса, а не получает -полем: механика стопа у движка есть (`cmd/tmctl/main.go:63`), но «кто нажал» знает платформа. +**Решение владельца 16.08 («Согласен, переделываем» + В-5 «Ок, делаем так»):** одна полоса до +ближайшей остановки, знаменатель — КУПЛЕННЫЙ объём, после подписи банка полоса начинается заново. -**Стоп по потолку — не `failed`** ✓ выведено: «Ceiling is a hard, book-wide stop (not a -per-chunk flag): the job stays 'pending' and resume continues once the ceiling is raised» -(`pipeline/stagerun.go:488-489`). Мапить его в `failed` запрещено — это соврало бы про -резюмируемость. Каким статусом и словом он показывается — К-8, вопрос владельцу. +**Форма, выбранная батчем: счёт в ГЛАВАХ, а не в юнитах.** Три довода, и второй — денежный: -### 2.7. Состояние пары выводится из ПАРЫ — ✓ выведено (исправление первой редакции) +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»). Новых колонок батч не требует. -Первая редакция утверждала «`withheld` = текст не выдан» как факт о движке. **Это было -ложно:** флагнутый юнит легально приходит С ТЕКСТОМ в двух случаях — +**Тем же ходом закрыт К-10 — вердикт «НЕ строить»** (D39.138, поправка приёмки research/28 №1): +пофазные счётчики на главу строить НЕ надо, потому что фаз на проводе больше нет вовсе. +`Chapter.units_done` считается той же сегментной логикой, что и книжная полоса, — иначе дерево глав +читало бы ноль всю первую волну, а это и была исходная жалоба К-10, и снятие фаз само по себе её не +лечит. -- косметическая зачистка санитайзера: «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`). +**Книжная полоса — отдельная величина.** `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` больше нет. -Поэтому состояние выводится из ПАРЫ (вердикт, наличие финального текста): флаг+текст → -`translated` с замечанием; флаг+пусто → `withheld`. +### 2.6. Словарь статусов — ✓ по механике, ◆ по составу; `finalizing` снят в 0.3.0 -**Причина флага при этом ПРОИЗВОЛЬНА, и «единственный легальный случай» — снято** (ревью -оркестратора, round-2, пункт 4; утверждение противоречило выводу строкой выше). При c-lite drop -юнит несёт `FlagReason` ПЕРВОГО выпавшего члена, каким бы он ни был (`export.go:203-207`: -`ce.FlagReason = drops[0].Reason`, и тут же `ce.FinalText … still ships`). Значит «текст + -замечание» — это класс, а не один случай, и карта вердиктов обязана иметь фразу для каждой -причины, а не для двух. +Лестница «загрузка → разбор → перевод → подпись банка → финал → готово» пришла из строки 95. +`stopped`, `rejected`, `not_started` контракт ВЫВОДИТ из поведения процесса, а не получает полем: +механика стопа у движка есть (`cmd/tmctl/main.go:63`), но «кто нажал» знает платформа. -**Но `glossary_miss` в этот класс НЕ входит, и это проверено отдельно** (иначе правка выше -воскресила бы невоспроизводимый пример). `memberDrops` берёт причину из ЧЕРНОВОЙ строки члена -(`status.go:242-257`: `draftStages[cs.Stage] && flagged`), а `glossary_miss` ставится -пост-чеком только там, где отгружается финал: в черновой волне — лишь когда она сама финальная -(`waverun.go:373-381`, draft-only), в c-lite — на строке РЕДАКТУРЫ (`waverun.go:494`). В -пайплайне, где текст отгружает редактура (то есть где c-lite drop вообще возможен), черновая -строка `glossary_miss` нести не может. При включённом гейте текст удерживается целиком -(`export.go:295-300`), при выключенном мисса нет вовсе. **Итог: пара «текст + промах словаря» -невозможна ни одним каналом — фикстура витрины, показывавшая её, переведена на c-lite drop.** +**Стоп по потолку — не `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`). -**Свежесть** ✓ выведено: `target` обновляется на границах стадий и на стопах, а не -непрерывно — посреди прогона канала чтения не существует (эксклюзивный лок движка; -санкционированное чтение — завершённый либо остановленный прогон, `research/23` §0, §4). +**0.3.0 — три правки:** -### 2.8. Банк: словари ✓, имена ◆, `kind` ◆ с дырой +- **`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`), карточка — по книге. -Значения выведены из схемы и гейтов Go; **имена полей контракта — предложение фронта.** +### 2.7. Состояние пары выводится из ПАРЫ — ✓ выведено -| Поле | Словарь | Грунт | -|---|---|---| -| `status` | `auto · draft · approved` | `store/migrate.go:191`; только `approved` — канон | -| `kind` ◆ | `name · place · title · term · nickname` **плюс отсутствие значения** | `terminology/classify.go:15` + `pipeline/banknote.go:74`; пустое — `membank/memseed.go:323-326` | -| `origin` | `seed · ruby · mined` | пути записи, см. ниже | -| `sense` | свободный текст | `store/migrate.go:182` | -| `since_chapter`/`until_chapter` | целые, `0` = без границы | `store/migrate.go:189-190` | +Флагнутый юнит легально приходит С ТЕКСТОМ в двух случаях: косметическая зачистка санитайзера («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`. -**Фантом `auto` в провенансе убран.** Первая редакция взяла словарь из комментария схемы -(`migrate.go:193`: `seed|ruby|auto`) — комментарий устарел. По путям записи `"auto"` пишет -**статус**, не провенанс (`membank/memseed.go:328`: `Status:"auto", Source:"ruby"`), а майнинг -ставит `mined` (`pipeline/mining.go:424`). Правка комментария в движке — за оркестратором. +Причина флага при этом ПРОИЗВОЛЬНА («единственный легальный случай» снято ревью round-2): при c-lite +drop юнит несёт причину ПЕРВОГО выпавшего члена, какой бы она ни была. Значит карта причин обязана +иметь фразу для каждой, а не для двух. Но пара «текст + промах словаря» невозможна ни одним каналом: +`memberDrops` берёт причину из ЧЕРНОВОЙ строки члена (`status.go:242-257`), а `glossary_miss` +ставится пост-чеком только там, где отгружается финал (`waverun.go:373-381`, `:494`). -**`kind` пере-размечен ✓→◆, и вот почему это не косметика** (ревью round-2, пункт 3). Словарь -из пяти значений выведен верно, но ЗАКРЫТЫМ и обязательным он делает нелегальной легальную -строку: ruby-кандидат получает `Type: ""`, если его класс не `name` — то есть gloss и -ambiguous живут без типа по построению (`membank/memseed.go:323-326`: `typ := ""`, и только -`class == rubyClassName` даёт `"name"`). Материализатору read-модели такую строку было -физически нечем заполнить. **Правило пустого:** `kind` присутствует всегда и допускает `null`; -`null` значит «движок не решил», строка при этом остаётся подписываемой, и клиенту запрещено -и выбрасывать её, и додумывать тип за движок. Проекция `""` → `null` — работа платформы. +**0.3.0:** инвариант «`translated` ⇒ текст непуст, иначе пуст» перестал быть только чеком БД +(`00002_readmodel.sql:127-128`) и стал условной обязательностью в схеме плюс словами в описании +(К-11: генератор `if`/`then` игнорирует). Слова «flagged», «chunk verdict», «sanitizer» с провода +сняты — они компилировались в JSDoc генерённых типов клиента. -**Ложный друг устранён.** У движка колонка `source` — это ПРОВЕНАНС. Первая редакция назвала -провенанс `origin`, а имя `source` отдала ДРУГОЙ колонке (тексту термина) — то есть завела -между схемами ложного друга. Теперь: провенанс `origin`, формы термина `src`/`dst`, как их -зовёт сам движок; имя `source` в схеме банка не используется вовсе. +**Свежесть** ✓ выведено: `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`). Отсюда: решение `promote|decline` · -счётчик «решено N из M» · запрет «продолжить» при неполном наборе · частичное сохранение ◆. +Конвейер заменяет банк целиком (`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» · запрет +«продолжить» при неполном наборе · частичное сохранение ◆. -`POST /runs/{id}/resume` — **нормативная операция, а не резерв** (первая редакция помечала её -«○ резерв строки 94», хотя тут же делала её носителем снятия стопа банка). Резерв строки 94 — -это `stop`, продуктовая кнопка. +**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). +**✓ выведено:** правило ре-синка — идемпотентный апсерт по `(run_id, seq)`, канал согласования — +`status --json` (`research/23` §2, §8; D39.85). -**◆ предложено фронтом:** что ревизию несут и ЧТЕНИЯ, и что счётчик у потока и у чтений ОДИН. -Обоснование — гонка, которую иначе нечем разрешить: фронт живёт на снимке и потоке разом, -а рефетч по возврату фокуса окна у ратифицированного `@tanstack/react-query` включён по -умолчанию, то есть гонка на каждое переключение вкладки. Но это просьба, не вывод; выбор — -К-4. +**◆ предложено:** что ревизию несут и ЧТЕНИЯ, и что счётчик у потока и у чтений ОДИН. Обоснование — +гонка, которую иначе нечем разрешить: фронт живёт на снимке и потоке разом, а рефетч по возврату +фокуса окна у ратифицированного `@tanstack/react-query` включён по умолчанию. -**Скоуп ревизии** (дыра первой редакции: она отдавала `revision` на межкнижной библиотеке при -пер-прогонном определении): в спеке ревизия объявлена НА РЕСУРС — у библиотеки своя, у -прогона своя. Единая сквозная или пер-ресурсная — часть К-4. +**0.3.0 — два уточнения, оба выведены из построенного клиента:** -### 2.11. Разрыв потока — ◆ предложено +- **Ревизия страничного обхода — МИНИМУМ по страницам, не максимум.** Правило жило только + комментарием в `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`), ревизию — с первой. Обе половины написаны осознанно и обе верны по + отдельности. Первая страница — единственный выбор, согласованный с правилом выше, и он не требует + от сервера держать снимок между запросами. -При переподключении клиент шлёт `Last-Event-ID`. Если сервер докачать не может, он обязан -ответить событием `resync_required`, а не молча начать с текущего момента: **реплей истории -запрещён**, иначе разовое событие вроде `note` теряется молча и замечание не появится -до перезагрузки. Клиент по этому событию перечитывает снимки. +**Кадры соединения не тратят номеров истории.** `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`), а водяной знак +следующего чтения — `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` главы (§4.8 — денег в MVP-интерфейсе нет вовсе) · `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` — последний несёт сырой текст -движка вида «CJK leak in the ru output: 第一节»; строку собирает `checks/sanitizer.go:658`, -а `pipeline/export.go:34` — лишь объявление поля, куда она доезжает. +(`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 — перевод не индексируется — ✓ инвариант -Реестр требований назначает носителем ПТ-34 в том числе контракт, а первая редакция пункта -не имела вовсе. В спеке: приложение живёт под `X-Robots-Tag: noindex`, ответы с текстом -перевода несут `Cache-Control: no-store`, ссылка на выгрузку выдаётся только владельцу. +Приложение живёт под `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`). -## 3. Зависимости: без чего контракт не заработает - -| Что | Строка | Без чего именно | -|---|---|---| -| Пофазный прогресс `draft ∥ edit` | 99 | прогресс (§2.5) | -| Персист манифеста + `chunker_version` | 100 | стабильный `id` главы (§2.2) | -| Машиночитаемая таблица ПОДПИСИ | 101 | экран подписи (§2.9) | -| Событийный эмиттер + событие потолка | 103 | весь поток (§2.11), событие `note`, событие `ceiling` | -| **Артефакт экспорта БАНКА** | **169 — движковая половина ПОСТРОЕНА** (`internal/pipeline/bankexport.go`, сайдкар `.bank.json`, D39.122/127; остаток — платформенная проекция, вход P7); ~~строки нет~~ испр. №17 15.08 | чтение `GET /books/{id}/bank` | -| **Механизм поднятия потолка** | **126 — ИСПОЛНЕНА** (`--ceiling-usd` D39.122 + `run-options`/шкала на экране S4, D39.135); ~~строки нет~~ испр. №17 15.08 | `POST /runs/{id}/resume` после стопа по потолку (см. ниже) | -| HTTP/SSE, аутентификация, воркер | П-1 | всё; в `platform/` ноль строк кода | - -⚠ **Отдельно про банк — дыра, найденная ревью оркестратора.** Строка 101 даёт таблицу -ПОДПИСИ (стоп-таблица, кап 20 на stdout — `cmd/tmctl/render.go:98`), а не экспорт всего банка; -сам банк живёт в приватном SQLite движка, читать который платформе запрещено (D39.85). То есть -у чтения `/bank` сегодня **нет канала вообще**. Фронт этого не решает — нужна строка единого -бэклога, и заводит её оркестратор. - -Та же природа у события `note`: пер-юнитных замечаний посреди прогона движок не эмитит — -зависимость на словарь строки 103. - -⚠ **Потолок: `resume` сам по себе не сдвинет прогон** (ревью round-2, пункт 5). Движок -продолжает «once the ceiling is raised» (`pipeline/stagerun.go:488-489`), а канала поднятия -в контракте нет — и в MVP-интерфейсе быть не может: денег на экране нет вовсе (D39.84). Значит -между стопом по потолку и продолжением обязан стоять механизм ПЛАТФОРМЫ (поднятие по политике, -или явное действие вне интерфейса книги), и до него `resume` после потолка возвращает прогон -в то же состояние. Что при этом видит пользователь — К-8, вопрос владельцу; чем поднимают — -строка единого бэклога, которой нет. - ---- - -## 4. Открытые вопросы - -Статусы «✅ ЗАКРЫТ» аннотированы оркестратором №16 (строка 167; авторские формулировки вопросов сохранены под аннотацией). - -| # | Вопрос | Кому | -|---|---|---| -| К-1 | **✅ ЗАКРЫТ D39.100** (десять статусов приняты). Было: десять статусов (§2.6) — принять или поправить? Три контракт выводит, а не получает | владелец / автор контракта | -| К-2 | Титул главы: отдать полем `heading` (предложено) — или оставить вклейку в текст, и фронт отрезает строкой? ⚠ Контекст 09.08: `heading` манифеста движка = ВРЕМЕННЫЙ рендер (D39.122 п.2д), настоящие заголовки — строка 160 (titleRaw) | автор контракта + бэкенд | -| К-3 | **✅ ЗАКРЫТ D39.100** (метка главы — из ДАННЫХ книги; зашитой формы «Глава N» не существует, легальна книга без номеров; глава без заголовка на экране — вопрос В-4/Ф-30). Было: титул это ровно «Глава N», узлов 2284 — дерево одинаковых по форме строк | владелец (продуктовое) | -| К-4 | Ревизия: одна сквозная на прогон или своя на ресурс? И несут ли её чтения вообще (§2.10) | платформа | -| К-5 | **✅ ЗАКРЫТ D39.100** (ETA показывать: `eta_seconds` в спеку). Было: показывать ли оценку времени. **ПТ-19 существует** (`docs/product-requirements.md`: «видимый прогресс/ETA»), посылка «не запрошено» неверна | владелец (продуктовое) | -| К-6 | Ступени замечания: сколько их и где граница. Сегодняшние две — проекция ОПЕРАТОРСКОЙ лестницы рангов, а она не обязана совпадать с продуктовой осью. Статус D39.100: принцип принят (две ступени по читательскому эффекту), карта — с В-3 | владелец (продуктовое) | -| К-7 | Пагинация: 2284 главы и 1200 терминов одним ответом или курсором? Фронт виртуализует, ему годится любой | платформа | -| К-8 | **✅ ЗАКРЫТ D39.100** (BookStatus получает 11-е значение `paused` + оповещение «лимиты исчерпаны» — ПТ-35; точное продуктовое слово — В-3 на владельце). Было: стоп по потолку — каким статусом и словом? В `failed` мапить нельзя — стоп резюмируемый | владелец (продуктовое) | -| К-9 | **Отказ прескрина не выразим ни одним из десяти статусов.** Абьюз/misuse-прескрин до трат токенов и UI-контракт отказа — строка 94 (ПТ-16); книга, отклонённая прескрином, это не `rejected` (тот про неразобранный файл) и не `failed` | владелец + автор контракта | -| К-10 | **Выполнение главы — тот самый несплитованный счётчик, который §2.5 объявляет негодным.** `Chapter.units_done` не разведён по фазам, значит дерево глав показывает ноль всю черновую волну — ровно то, из-за чего прогресс книги сделан пофазным. Развести и тут (цена — пофазные счётчики НА ГЛАВУ в строке 99) или показывать в дереве другое | автор контракта + бэкенд | -| К-11 | **Условная обязательность полей — выражена у банка, не выражена у чтений.** У `BankDecision` констрейнт поставлен (`if action=promote → dst` непустой), и вот что это стоило, измерено: spectral его валидирует, а **openapi-typescript его игнорирует** — в генерённых типах `dst?: string` как был. То есть 3.1-условие защищает сервер, но не экран; клиентское сужение (юнион `promote`-с-`dst` ↔ `decline`) — работа подписного экрана S5, писать его до экрана не на чем проверить. Остаётся решить то же для чтений: `Note` не требует ни `chapter_id`, ни `unit_id`, `Unit.target` не обязателен при `translated`; обе схемы служат и вложенно, и отдельно, поэтому простое `required` соврало бы | автор контракта | -| К-12 | **Завершение выгрузки: опрос или событие?** Чтение `GET /books/{id}/exports/{id}` заведено — без него создающий вызов был тупиком (`ready:false` и ни слова дальше). Но пушить ли завершение ещё и кадром потока, чтобы не опрашивать, решает платформа: у неё воркер и её цена | платформа | -| К-13 | **`paused_reason` не различает две разные беды** (заведён D39.115 п.6б = Ф-31): единственное значение `credit_exhausted`, а упор в СВОЙ потолок прогона и исчерпание кредита — разные состояния с разным следующим действием; первому из двух фраза врёт. После PD-158 цена выросла: консервативный потолок останавливает ровно на исчерпании холда (D39.123 п.2ж) | владелец (продуктовое) | - ---- - -## 5. Проверка ревью-вопросом строки 95 - -**«Сменится стадия конвейера — придётся ли править фронт?»** - -| Изменение в движке | Правит ли фронт | -|---|---| -| переименована стадия / добавлена волна | **нет** — имена стадий не пересекают шов, прогресс пофазный, а не постадийный | -| сменилась модель или маршрутизация | **нет** — `routing`/`content_labels` в allowlist не входят | -| добавлена новая причина флага | **нет** — на провод идёт продуктовая фраза, карта живёт в контракте | -| добавлен новый тип термина | **нет** — словарь расширяется минором, ветка неизвестного стоит на шве | -| добавлено новое продуктовое состояние | **да, один файл** — карта «статус → вид» на шве `src/api/`; это и есть контрольный вопрос владельца | -| сменился чанкер, главы пере-разобраны | **частично** — код фронта не правится (ключ непрозрачный, номер отображаемый), но **сохранность соответствия старых `id` новым главам контрактом не гарантируется**: это работа персиста манифеста (строка 100). Если соответствие потеряно, у пользователя разъезжаются открытые вкладки и закладки — не правка кода, но видимый ущерб, и решать его строке 100 | - -Единственное безусловное «да» — то, которое и должно быть «да». - -## 2.14. Поверхность входа `/auth/*` — ✓ построено платформой (внесено оркестратором №15 при лендинге S3) +### 2.14. Поверхность входа `/auth/*` — ✓ построено платформой Четыре ручки живут ВНЕ версионного префикса, как `/healthz`: это механика сессии, а не контрактная -поверхность, поэтому в `openapi.yaml` они не тащатся (решение оркестратора как владельца контракта, -подтверждено платформой). +поверхность. | Ручка | Метод | Что делает | |---|---|---| | `/auth/login` | GET | начинает вход, редиректит к провайдеру; принимает `?return_to=<путь этого сайта>` | | `/auth/callback` | GET | завершает вход, ставит сессионную куку, редиректит на `return_to` либо на дефолт | | `/auth/logout` | POST | завершает ЭТУ сессию | -| `/auth/logout-all` | POST | завершает ВСЕ сессии пользователя («выйти везде») | +| `/auth/logout-all` | POST | завершает ВСЕ сессии пользователя | -Клиенту нужно знать три вещи. `return_to` принимает ТОЛЬКО путь этого сайта, и чужой путь сервер -молча заменяет дефолтом — открытого редиректа нет, но и ошибки клиент не получит (сверено с -`login.go:safeReturnTo`). Обе `POST`-ручки лежат на cookie-пути, то есть требуют `X-TM-Client`. -Отказ входа — `problem+json`, как везде; различать причины отказа клиент не может по замыслу. +`return_to` принимает ТОЛЬКО путь этого сайта, и чужой путь сервер молча заменяет дефолтом +(`login.go:safeReturnTo`). Обе `POST`-ручки лежат на cookie-пути, то есть требуют `X-TM-Client`. +Отказ входа — `problem+json`. -## 2.15. Потолок прогона — ◆ форма предложена фронтом, РАТИФИЦИРОВАНА оркестратором №15 (08.08) +### 2.15. Потолок прогона — ◆ форма, РАТИФИЦИРОВАНА оркестратором №15 (08.08) -Решение владельца 07.08: шкала в интерфейсе от минимума до максимума, ноль выбрать нельзя, единица — -ГЛАВЫ, потолок принадлежит ПРОГОНУ. Ручки, отдающей границы шкалы, в контракте не было — объявлена -правкой 0.2.0 как `GET /books/{bookId}/run-options` → `CeilingBounds`. +Решение владельца 07.08: шкала от минимума до максимума, ноль выбрать нельзя, единица — ГЛАВЫ, +потолок принадлежит ПРОГОНУ. **Отдельный ресурс, а не поле карточки книги.** Максимум зависит от АККАУНТА и двигается, когда книга -не меняется: холд под другую книгу опускает остаток. Карточка книги кэшируется библиотекой, то есть -назвала бы максимум, которого уже нет, ровно когда человек двигает ползунок. Второй довод дешевле, но -настоящий: граница нужна один раз перед стартом, а поле на карточке заставило бы КАЖДОЕ чтение -библиотеки нести состояние счёта. - -**Три числа, а не два.** `min_chapters` объясняет себя единицей — одна глава. `max_chapters` приходит -УЖЕ подрезанным и по остатку, и по непереведённому хвосту книги; клиенту подрезать второй раз -ЗАПРЕЩЕНО, иначе правило живёт в двух местах и расходится. `default_chapters` отдаёт платформа, потому -что предустановленное значение — продуктовая политика («потратить всё» ↔ «одна глава»), а не -презентация. `max_chapters: 0` — легальный ответ, значит «прогон начать нельзя вовсе»; тогда и -`default_chapters` равен нулю, а клиент показывает исчерпанное состояние вместо шкалы. +не меняется. **Три числа, а не два:** `max_chapters` приходит УЖЕ подрезанным и по остатку, и по +непереведённому хвосту; клиенту подрезать второй раз ЗАПРЕЩЕНО. `default_chapters` отдаёт платформа, +потому что предустановленное значение — продуктовая политика. ⚠ **`max_chapters` — величина, а не арифметика.** Ратификация D39.110 в первой редакции требовала -«баланс МИНУС открытые холды»: это была ОШИБКА оркестратора — вычитание дважды. Холд есть дебет в -момент взятия (`pgstore/credits.go:179` пишет отрицательную строку и тем же знаком двигает кэш -баланса), поэтому баланс уже не содержит открытых холдов. Замерено при приёмке на живом PostgreSQL: -грант $10 и холд $1 дают `Balance` 9 и `Reserved` 1, а «баланс минус Reserved» дало бы 8, то есть -вдвое урезанную шкалу. Ошибку нашла фронт-сессия S3 чтением Go-кода платформы. +«баланс МИНУС открытые холды»: это была ОШИБКА — вычитание дважды. Холд есть дебет в момент взятия +(`pgstore/credits.go:179`), поэтому баланс уже не содержит открытых холдов. Замерено на живом +PostgreSQL: грант $10 и холд $1 дают `Balance` 9 и `Reserved` 1. -**Пересчёта «главы → деньги» на проводе нет ни в каком виде** (D39.84) — он живёт на платформе по -оценке движка. **`ceiling_chapters` обязателен** и в запросе старта, и на `Run`: прогон без -объявленного потолка тратит мимо границы, которую человек вправе поставить ДО, а не узнавать после, а -поле на `Run` позволяет перезагруженному экрану назвать выбранный колпак. **`409` на старте** отвечает -и на «потолок больше не помещается»: границы читаются отдельным вызовом и могут сдвинуться. +**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 — ФАКТ, а не выбор (внесено оркестратором №15) +### 2.16. Транспорт: тот же origin — ФАКТ, а не выбор CORS-слоя в платформе нет вовсе: preflight `OPTIONS` с чужим `Origin` получает 401 от гарда сессии, -заголовков `Access-Control-*` нет ни на одном ответе (замер приёмки на живом бинаре, PD-96 регистра -платформы). Браузерный клиент с другого origin неработоспособен как класс. В деве фронт ходит через -прокси dev-сервера; кросс-origin не проектируется. `X-TM-Client` обязателен и на same-origin — он не -про CORS. +заголовков `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): предупреждение о недостроенном ОБЯЗАНО нести номер строки +бэклога** (а где строки нет — явного носителя-документ). Без него абзац переживает своё основание и +начинает лгать — что уже произошло четырежды, и одно из четырёх отняло у пользователя работающее +действие. Таблица ниже — единственное место, где это ведётся. -Заполняет автор контракта вместе с бэкендом и владельцем. **Правило: фраза пишется по -доккомменту `disposition.go`, а не по имени константы, и рядом кладётся цитата** — иначе -повторяется инверсия, стоившая двух фраз (`glossary_miss` подан как «термин не подписан», -хотя термин ПОДПИСАН и его проигнорировали, `disposition.go:78-79`; `sanitizer_stripped` подан -как потеря текста, хотя «the chunk is NOT lost», `disposition.go:99`). - -⚠ **Русские фразы ниже — плейсхолдеры, а не предложение фронта.** Словарь продуктовый, его -слова выбирает владелец (ПТ-33, В-3). Ступень — тоже: сегодняшние `attention`/`glance` -унаследовали ОПЕРАТОРСКУЮ ось рангов, а она не обязана совпадать с продуктовой (К-6). - -| Причина движка | Ранг | Продуктовая фраза | Ступень | +| Чтение / механизм | Источник данных | Состояние | Строка / носитель | |---|---|---|---| -| `hard_refusal` · `soft_refusal` · `content_filter` · `hard_block` | 0 | ⬜ | ⬜ | -| `cjk_artifact` · `excision_suspect` · `coverage_fail` | 1 | ⬜ | ⬜ | -| `sanitizer_defect` | 2 | ⬜ | ⬜ | -| `loop_degenerate` | 3 | ⬜ | ⬜ | -| `decode_error` | 4 | ⬜ | ⬜ | -| `glossary_miss` | 5 | плейсхолдер: «Подписанный термин не применён в переводе» | ⬜ | -| `length` · `empty` | 6 | ⬜ | ⬜ | -| `sanitizer_stripped` | 7 | плейсхолдер: «Служебная разметка вычищена автоматически» | ⬜ | -| `upstream_not_ok` | 8 (по умолчанию) | ⬜ | ⬜ | -| незнакомая причина | 8 (по умолчанию) | ⬜ нейтральная, НЕ «ошибка» | ⬜ | +| `GET /books`, `GET /books/{id}` | read-модель платформы | ПОСТРОЕНО | — | +| `GET /books/{id}/run-options`, `POST /runs` | шкала + холды | ПОСТРОЕНО | — | +| `POST /runs/{id}/stop`, `/resume` | реконсилятор | ПОСТРОЕНО (кнопок на экране нет) | зона фронта | +| `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 | -Причин пятнадцать; `upstream_not_ok` в первой редакции отсутствовал — у него нет своей ветки -в `flagReasonSeverity`, поэтому он падает в ранг по умолчанию (`pipeline/status.go:174`), -как и любая будущая причина. Последняя строка — не формальность: контракт обязан иметь фразу -для причины, которой ещё не существует. +⚠ **Три прежних предупреждения СНЯТЫ как устаревшие** (Б-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`) — перенесена в этот файл, не удалена. + +**Резать отказались, с контраргументом на каждое:** + +- **параметр `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=`. + +--- + +## 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 глотает +метод. diff --git a/docs/architecture/14-api-contract/openapi.yaml b/docs/architecture/14-api-contract/openapi.yaml index 8900e68f..227a4d9f 100644 --- a/docs/architecture/14-api-contract/openapi.yaml +++ b/docs/architecture/14-api-contract/openapi.yaml @@ -2,76 +2,104 @@ openapi: 3.1.0 info: title: TextMachine API - version: 0.2.3 - summary: Ratified contract between the frontend and the TextMachine platform (D39.99). + version: 0.3.0 + summary: Ratified contract between the frontend and the TextMachine platform (D39.99, D39.138). description: | - **RATIFIED contract (D39.99, 04.08.2026).** The canonical copy lives in - `docs/architecture/14-api-contract/` (orchestrator's zone); `frontend/docs/api-contract/` is a - byte-mirror of it, and a divergence is a defect of one of the two, never a local edit. - Companion document: the `README.md` beside the canonical copy — provenance of every decision - (derived from engine code / proposed by the frontend / open), rationale, dependencies and open - questions. This file is normative for the FORM; the companion explains where the form comes from. + **RATIFIED contract.** Canonical copy: `docs/architecture/14-api-contract/`; + `frontend/docs/api-contract/` is a byte-mirror and a divergence is a defect of one of the two. + This file is normative for the FORM; the `README.md` beside it — the companion — carries + provenance, rationale, open questions and the history of every form here. ## Boundaries - The frontend reads the platform read-model only. The engine is never addressed by any path - below (D39.85). + The client reads the platform read-model only; no path below addresses the translation service. - Pipeline vocabulary does not cross this boundary: no model names, no stage names, no money. - The read-model → frontend projection is an allowlist — a field not named here never reaches - the browser. + **Nothing about HOW a book is translated crosses this boundary** — no model names, no phase or + stage names, no internal vocabularies, no money sums. The one exception is the book's memory + bank: the work stops there for a signature, and a stop the user must clear cannot be hidden. The + projection is an allowlist: a field not named here never reaches the client. The rule binds the + PROSE too — every description here is compiled into the generated client's source. - No response is served from an indexable URL. The app lives on `app.` under - `X-Robots-Tag: noindex`; responses carrying translated text MUST be sent with - `Cache-Control: no-store`. + ## On every response + + `X-Request-Id` · `Cache-Control: no-store` · `X-Robots-Tag: noindex`. `no-store` binds caches + (RFC 9111 §5.2.2.5), not the copy an application holds in memory — which is why the conditional + reads below still work. ## Transport - The browser client is served from the **same origin** as this API. That is a fact of the - platform, not a setting: it has no CORS layer at all — a preflight `OPTIONS` carrying a foreign - `Origin` is answered `401` by the session guard, and no `Access-Control-*` header is sent on any - response. A cross-origin browser client is therefore inoperable as a class rather than - unconfigured, and nothing here is designed around cross-origin requests. A development server - reaches this API through a proxy onto its own origin. + The client is served from the **same origin**. No `Access-Control-*` header is sent on any + response and a preflight `OPTIONS` with a foreign `Origin` is refused, so a cross-origin browser + client is inoperable as a class. Unsafe requests are also checked against their origin; a + rejected one is `403`. A dev server proxies onto its own origin. - Session mechanics — starting a login, finishing it, ending one session, ending all of them — - live OUTSIDE the version prefix, like `/healthz`, and are described in the companion rather - than here: they are the mechanics of holding a session, not a contract surface. + **Signing in is not part of this surface**: session mechanics live outside the version prefix and + the flow starts at `GET /auth/login` (companion). A client that meets `401` sends the user there. + + ## Conditional reads + + Every collection read and the book card answer an `ETag` and honour `If-None-Match` with `304`. A + client is expected to use them: a frame says only THAT something changed. + + An `ETag` is bound to the principal and to the representation; a response negotiated on + `Accept-Encoding` MUST carry `Vary: Accept-Encoding`. + + ## Compression + + A server MUST honour `Accept-Encoding` on `application/json` and MUST NOT compress + `text/event-stream` — compressing a stream buffers it. Where compression is done is the + deployment's business (companion). + + ## Absence of a value + + **A field whose value can be missing is REQUIRED and NULLABLE**; `null` is "not known". A field is + OPTIONAL only when its absence is itself the fact. An empty collection is an empty array. + + Two exceptions, in both of which absence means "does not apply": the extension members of + `Problem` (defined per `code`, RFC 9457 §3.2) and the aggregates of `BankPage` (first page only). + Both are stated on their schemas. + + ## Errors + + `application/problem+json` (RFC 9457), identified by the machine `code` and never by their words. + `title` and `detail` are for a developer and a log: a client MUST NOT show either, and draws the + phrase from `code` — a neutral one for a code it does not know. See `Problem`. ## Versioning - Semver. **Minor** — backwards-compatible additions: a client MUST ignore unknown fields and - MUST tolerate unknown enum values without failing. **Major** — a client MUST refuse an - unsupported version and tell the user. + Semver. A client MUST ignore unknown fields and MUST tolerate unknown enum values without + failing; on an unsupported version it MUST refuse and tell the user. - ⚠ **While the version is 0.x, a MINOR bump is the lane for breaking changes** (semver §4: - "Major version zero … anything MAY change at any time"), and 0.2.0 carries several — a required - `next_cursor` on every list, a required `ceiling_chapters` on a run request. A client pins the - exact 0.x version it was generated against and does not assume compatibility across minors. - From 1.0.0 the rule above applies unqualified. Ratified by the orchestrator at the S3 landing: - session S3 named the contradiction between this section and its own bump instead of resolving - it by its own hand, which was correct — the versioning rule is the contract owner's. + ⚠ **While the version is 0.x, a MINOR bump is the lane for breaking changes** (semver §4). A + client pins the exact 0.x version it was generated against and assumes nothing across minors. The + version a deployment serves is read from `GET /capabilities`. - Every `enum` below is the vocabulary of THIS version, not a closed world. Generated types are - closed unions and do NOT protect against an unknown value, so the unknown-value branch - belongs on the client seam (`src/api/`), where values enter, not in every component. + Every `enum` here is the vocabulary of THIS version. Generated types are closed unions and do NOT + protect against an unknown value, so that branch belongs on the client seam (`src/api/`). + + ⚠ The "tolerate an unknown value under a minor bump" rules on the schemas below describe the lane + where minors are ADDITIVE — from 1.0, and within one version where a deployment is older than the + contract. While the major is `0` a client refuses a differing minor outright, so those branches + are a floor and not a licence to run against another 0.x. license: name: UNLICENSED identifier: LicenseRef-proprietary servers: - - url: https://app.example.org/v0 + - url: /v0 description: | - The platform, on the SAME ORIGIN as the browser client (see Transport). Only the version - prefix is fixed here: the host is whatever origin served the application, and a client that - hard-codes one is a client that cannot be deployed anywhere else. + Same origin as the client (see Transport). Only the version prefix is fixed; the host is + whatever origin served the application. Relative on purpose: an absolute placeholder is what a + generated client compiles in. security: - sessionCookie: [] - bearerToken: [] tags: + - name: deployment + description: What this deployment can do. - name: library description: Book library and book card. - name: reading @@ -79,34 +107,59 @@ tags: - name: bank description: Memory bank and term signing. - name: runs - description: Translation runs, live progress, control. + description: Translation runs, live events, control. - name: export description: Export of a finished book. - name: account description: Credit balance of the account. paths: + /capabilities: + get: + tags: [deployment] + operationId: getCapabilities + summary: What this deployment can do. + description: | + What this deployment can do: contract version, the pairs it can actually translate, the size + it accepts, the formats it builds, the page size it hands out. Read once at start-up. One + deployment, one answer — not per-account, not a negotiation. + responses: + '200': + description: Capabilities of this deployment. + headers: + ETag: { $ref: '#/components/headers/ETag' } + content: + application/json: + schema: { $ref: '#/components/schemas/Capabilities' } + '304': { $ref: '#/components/responses/NotModified' } + '401': { $ref: '#/components/responses/Unauthorized' } + parameters: + - $ref: '#/components/parameters/IfNoneMatch' + /books: get: tags: [library] operationId: listBooks summary: Book library. description: | - Flat list of the user's books. `revision` is the revision of the LIBRARY itself - (membership and statuses) and belongs to the library's own scope: it is never compared - with the revision of a book. + The user's books, newest addition first. `revision` here is the LIBRARY's own — never + compared with a book's. - Page size default is the platform's choice here — the client MUST follow `next_cursor` - until it is `null` rather than assume the library fits in one page. + Page size default: `GET /capabilities`. A client MUST follow `next_cursor` until it is + `null`. parameters: - $ref: '#/components/parameters/Limit' - $ref: '#/components/parameters/Cursor' + - $ref: '#/components/parameters/IfNoneMatch' responses: '200': description: Library. + headers: + ETag: { $ref: '#/components/headers/ETag' } content: application/json: - schema: { $ref: '#/components/schemas/Library' } + schema: { $ref: '#/components/schemas/BookPage' } + '304': { $ref: '#/components/responses/NotModified' } '400': { $ref: '#/components/responses/BadRequest' } '401': { $ref: '#/components/responses/Unauthorized' } post: @@ -114,40 +167,24 @@ paths: operationId: createBook summary: Add a book. description: | - Accepts the file and the properties declared by the user. + **The `file` part MUST come LAST in the form** — it is read as a stream and reading stops at + the file. `BookIntake` lists its properties in the order they must be sent. - **The `file` part MUST come LAST in the form** (0.2.3). The platform reads the form as a - STREAM, part by part, and stops at the file: the book's row — the record that makes an - upload visible while it arrives and findable when it dies halfway — cannot be written - before the languages that row requires. + **A part sent after the file is refused, never ignored**: `400`, `code: invalid_request`, with + an `errors[]` entry naming it. - A part sent after the file is therefore NOT READ AT ALL. What that costs depends on the - part: a required one (`source_lang`, `target_lang`) is answered `400`, because to a reader - that streams "the languages came late" and "the languages never came" are the same thing; - an optional one is silently lost, and the book is created without it. A client that puts - anything after the file is a client whose form is partly ignored. + **The `201` carries `parsing`, not `uploading`** — it is written after the last byte lands. + `uploading` is observable only by a second read of the library while the upload is on the + wire. `Location` names the book card. Parsing has no numeric progress; its END arrives on the + book's event stream as an ordinary status change. - **The `201` carries `parsing`, not `uploading`** (0.2.3). The answer is written after the - last byte has landed, so by the time a client can read it the file is in and the book has - moved on. `uploading` is a real state and is observable — by a SECOND read of the library - while the upload is still on the wire — but never as the answer to this call. - - Parsing is a separate visible step after that, and it has no numeric progress: no counter of - it exists on this contract, and a percentage would have to be invented. - - Refusals of the intake, all of them product states of the form rather than failures of the - service: - - - `400` — the form could not be read: more than 16 parts, a text field longer than a - kilobyte, a REQUIRED field that arrived after the file (or never), or no file at all; - - `404` — this deployment does not accept books at all. An instance with no place to put a - file and no engine to cut it with serves the library and refuses this path, which is a - property of the DEPLOYMENT and not of the request; - - `408` — the body did not finish inside the route's deadline: a slow client on a large - book. Retrying is the remedy, which is what separates it from `413`; - - `413` — the body is over the intake cap. The threshold belongs to the deployment and is - not stated here: a number in the contract would be a second copy of it, and the two would - disagree the day it changes. + Refusals: `400` unreadable form, missing or late part, over-long part, too many parts, + malformed language code, or a pair this deployment cannot translate (`code` and `errors[]` + say which) · `404` this deployment takes no books at all (`intake_enabled`) · `408` the body + did not finish in time, retry · `413` over `intake_max_bytes`. + parameters: + - $ref: '#/components/parameters/ClientHeader' + - $ref: '#/components/parameters/IdempotencyKey' requestBody: required: true content: @@ -156,13 +193,20 @@ paths: responses: '201': description: Book accepted; it is being parsed. + headers: + Location: + required: true + description: Address of the book card just created. + schema: { type: string, format: uri-reference } content: application/json: schema: { $ref: '#/components/schemas/Book' } '400': { $ref: '#/components/responses/BadRequest' } '401': { $ref: '#/components/responses/Unauthorized' } + '403': { $ref: '#/components/responses/Forbidden' } '404': { $ref: '#/components/responses/NotFound' } '408': { $ref: '#/components/responses/RequestTimeout' } + '409': { $ref: '#/components/responses/Conflict' } '413': { $ref: '#/components/responses/TooLarge' } /books/{bookId}: @@ -173,14 +217,66 @@ paths: operationId: getBook summary: Book card. description: Book metadata plus the current or last run. + parameters: + - $ref: '#/components/parameters/IfNoneMatch' responses: '200': description: Book card. + headers: + ETag: { $ref: '#/components/headers/ETag' } content: application/json: schema: { $ref: '#/components/schemas/BookDetail' } + '304': { $ref: '#/components/responses/NotModified' } '401': { $ref: '#/components/responses/Unauthorized' } '404': { $ref: '#/components/responses/NotFound' } + patch: + tags: [library] + operationId: updateBook + summary: Rename a book. + description: | + Merge patch (RFC 7386). **Only `title` may be changed.** + + ⚠ **A title is DISPLAY and reaches nothing else** — not the translation, whose configuration + is written once at intake and never rewritten. The language pair is NOT patchable: changing + it is a re-translation, not an edit. + + Accepted while a run is live: a rename touches nothing a run reads. `title: null` is refused + with `400`. + parameters: + - $ref: '#/components/parameters/ClientHeader' + requestBody: + required: true + content: + application/merge-patch+json: + schema: { $ref: '#/components/schemas/BookPatch' } + responses: + '200': + description: Book card after the patch. + content: + application/json: + schema: { $ref: '#/components/schemas/Book' } + '400': { $ref: '#/components/responses/BadRequest' } + '401': { $ref: '#/components/responses/Unauthorized' } + '403': { $ref: '#/components/responses/Forbidden' } + '404': { $ref: '#/components/responses/NotFound' } + delete: + tags: [library] + operationId: deleteBook + summary: Delete a book. + description: | + Removes the book and everything derived from it; the physical clean-up is asynchronous and + not observable here. `409` while a run is live — stop it first. Irreversible: re-adding the + file makes a new book. + parameters: + - $ref: '#/components/parameters/ClientHeader' + responses: + '204': + description: Book deleted. + '401': { $ref: '#/components/responses/Unauthorized' } + '403': { $ref: '#/components/responses/Forbidden' } + '404': { $ref: '#/components/responses/NotFound' } + '409': { $ref: '#/components/responses/Conflict' } /books/{bookId}/chapters: parameters: @@ -190,20 +286,23 @@ paths: operationId: listChapters summary: Chapter tree. description: | - Chapters in reading order. A chapter has NO status, only unit progress: bank signing is a - single book-wide stop, so "one chapter awaits signing while its neighbour finalizes" - cannot happen. - - Default page size **5000**. + Chapters in reading order; a book legally has no chapters at all, and then this list is + empty. A chapter has NO status of its own, only progress: signing is a single book-wide stop, + so "one chapter awaits signing while its neighbour is translated" cannot happen. Page size + default: `GET /capabilities`. parameters: - $ref: '#/components/parameters/Limit' - $ref: '#/components/parameters/Cursor' + - $ref: '#/components/parameters/IfNoneMatch' responses: '200': description: Chapters of the book. + headers: + ETag: { $ref: '#/components/headers/ETag' } content: application/json: - schema: { $ref: '#/components/schemas/ChapterList' } + schema: { $ref: '#/components/schemas/ChapterPage' } + '304': { $ref: '#/components/responses/NotModified' } '400': { $ref: '#/components/responses/BadRequest' } '401': { $ref: '#/components/responses/Unauthorized' } '404': { $ref: '#/components/responses/NotFound' } @@ -217,26 +316,31 @@ paths: operationId: listUnits summary: Source/translation pairs of a chapter. description: | - The unit of shipping is the EDIT UNIT, not a paragraph and not a chunk: roughly 1.9 units - per chapter, and a whole chapter is sometimes a single block. Alignment is coarse and - accepted as such. + The pairs of one chapter, in reading order. - **Units are read PER CHAPTER and only per chapter.** A book-wide units endpoint is never - introduced: the whole memory model of the client stands on this — the working set stays - tens of kilobytes instead of tens of megabytes. Page size default is the platform's choice; - the client follows `next_cursor`. + **Pairs are read PER CHAPTER and only per chapter** — a book-wide pairs endpoint is never + introduced, and the client's whole memory model stands on that. Page size default: + `GET /capabilities`. + + **`410 Gone`** answers a chapter that existed and no longer does: a book cut again leaves the + old ids gone rather than absent, and the client re-reads the tree. `404` would mean a typo. parameters: - $ref: '#/components/parameters/Limit' - $ref: '#/components/parameters/Cursor' + - $ref: '#/components/parameters/IfNoneMatch' responses: '200': description: Pairs of the chapter. + headers: + ETag: { $ref: '#/components/headers/ETag' } content: application/json: - schema: { $ref: '#/components/schemas/UnitList' } + schema: { $ref: '#/components/schemas/UnitPage' } + '304': { $ref: '#/components/responses/NotModified' } '400': { $ref: '#/components/responses/BadRequest' } '401': { $ref: '#/components/responses/Unauthorized' } '404': { $ref: '#/components/responses/NotFound' } + '410': { $ref: '#/components/responses/Gone' } /books/{bookId}/notes: parameters: @@ -246,19 +350,33 @@ paths: operationId: listNotes summary: Notes of a book. description: | - A note addresses a unit or a whole chapter. Byte offsets do not exist in the engine's - checks and are not planned. + Notes oldest first, by `created_at`. Ordered by time and not by position in the book so that + a note arriving on the stream can be placed into a list the client already holds; a screen + that wants reading order sorts against the chapter tree it already has. - Default page size **500**. + Ties on `created_at` are broken by the server in a way the client does NOT reproduce — the + prohibition on sorting by `Id` stands — and a client placing a streamed note puts it after + every note it holds with the same `created_at`. + + A note addresses a chapter, and usually a pair inside it. Byte offsets do not exist. + + **Delta read** with `after_version`; without it the whole collection. Page size default: + `GET /capabilities` — ⚠ how many notes a real book produces has never been measured, so that + default is a guess. parameters: - $ref: '#/components/parameters/Limit' - $ref: '#/components/parameters/Cursor' + - $ref: '#/components/parameters/AfterVersion' + - $ref: '#/components/parameters/IfNoneMatch' responses: '200': description: Notes of the book. + headers: + ETag: { $ref: '#/components/headers/ETag' } content: application/json: - schema: { $ref: '#/components/schemas/NoteList' } + schema: { $ref: '#/components/schemas/NotePage' } + '304': { $ref: '#/components/responses/NotModified' } '400': { $ref: '#/components/responses/BadRequest' } '401': { $ref: '#/components/responses/Unauthorized' } '404': { $ref: '#/components/responses/NotFound' } @@ -271,20 +389,29 @@ paths: operationId: listBankTerms summary: Memory bank of a book. description: | - ⚠ **No backing channel exists for this read today.** The engine ships a signing table, - not a bank export, and its private store must not be read by the platform. The export - artifact is a dependency the frontend cannot create — companion §3. + The bank ordered by source surface then by the term's window, so the several rows of one + surface stand together. - Default page size **1000**. + **This read is also the STATE of a signing stop**: `pending_decisions` and `complete` are + answered here and not only in a submission receipt, so a screen reloaded mid-stop knows + whether the work is finished. + + **Delta read** with `after_version` — a full book's bank is too large to re-read on every + change. Page size default: `GET /capabilities`. parameters: - $ref: '#/components/parameters/Limit' - $ref: '#/components/parameters/Cursor' + - $ref: '#/components/parameters/AfterVersion' + - $ref: '#/components/parameters/IfNoneMatch' responses: '200': description: Bank of the book. + headers: + ETag: { $ref: '#/components/headers/ETag' } content: application/json: - schema: { $ref: '#/components/schemas/Bank' } + schema: { $ref: '#/components/schemas/BankPage' } + '304': { $ref: '#/components/responses/NotModified' } '400': { $ref: '#/components/responses/BadRequest' } '401': { $ref: '#/components/responses/Unauthorized' } '404': { $ref: '#/components/responses/NotFound' } @@ -297,12 +424,14 @@ paths: operationId: submitBankDecisions summary: Submit term decisions. description: | - **Signing is not a row edit.** The pipeline replaces a book's whole glossary from its - deterministic inputs, so a direct write would be erased by the next run. A decision is - `promote` (with a translation) or `decline`, following the stop mechanics exactly. + **Signing is not a row edit**: the bank is rebuilt from its inputs on every run, so a direct + write would be erased. A decision is `approve` (with a translation) or `decline`, keyed by + term, so re-sending one is harmless. - Submission is PARTIAL and accumulates on the server: there are hundreds of terms, and a - closed tab must not cost an hour of work. + Submission is PARTIAL and accumulates on the server — a closed tab must not cost an hour of + work. + parameters: + - $ref: '#/components/parameters/ClientHeader' requestBody: required: true content: @@ -316,6 +445,7 @@ paths: schema: { $ref: '#/components/schemas/BankDecisionsResult' } '400': { $ref: '#/components/responses/BadRequest' } '401': { $ref: '#/components/responses/Unauthorized' } + '403': { $ref: '#/components/responses/Forbidden' } '404': { $ref: '#/components/responses/NotFound' } /books/{bookId}/run-options: @@ -326,11 +456,12 @@ paths: operationId: getRunOptions summary: Bounds for starting a run. description: | - Bounds of the run-ceiling scale, read right before a run is started. + Bounds of the run-limit scale, read right before a run starts, plus why the scale is smaller + than expected when it is. - A resource of its own rather than a field of the book card: the maximum depends on the - ACCOUNT and moves while the book does not, so a cached card would state a maximum that is no - longer true — at the moment the user is dragging the scale. + Its own resource and not a field of the book card: the maximum depends on the ACCOUNT and + moves while the book does not, so a cached card would state a maximum that is no longer true + exactly while the user drags the scale. responses: '200': description: Bounds of the scale. @@ -348,16 +479,20 @@ paths: operationId: startRun summary: Start a translation run. description: | - `verify_bank` is a parameter of the RUN, not a global setting. With it the run stops at - the bank boundary; without it the unsigned bank is carried forward marked as unverified. - It is the user's choice between "I will sign" and "translate as is". + `stop_for_signing` and `ceiling_chapters` are parameters of the RUN, not of the book: they + travel with the start and do not outlive it. With `stop_for_signing` the run waits for the + bank to be signed; without it the unsigned bank is carried forward marked as unverified. - `ceiling_chapters` is a parameter of the RUN as well, and it is not a property of the - book: it travels with the start and does not outlive the run. + **409** answers a limit that no longer fits — the bounds are read by + `GET /books/{bookId}/run-options` and may move in between. When another book's hold is the + cause the error carries `blocked`, naming that book. - **409** also answers a ceiling that no longer fits: the bounds are read by - `GET /books/{bookId}/run-options` and may move between that read and this call, because a - hold taken for another book lowers the remainder. + **Raising the limit of a stopped run is done by starting a NEW run** with a larger + `ceiling_chapters`: a paused book is startable, finished work is not bought twice, and the + new run continues where the old stopped. `resume` does not do this. + parameters: + - $ref: '#/components/parameters/ClientHeader' + - $ref: '#/components/parameters/IdempotencyKey' requestBody: required: true content: @@ -371,39 +506,87 @@ paths: schema: { $ref: '#/components/schemas/Run' } '400': { $ref: '#/components/responses/BadRequest' } '401': { $ref: '#/components/responses/Unauthorized' } + '403': { $ref: '#/components/responses/Forbidden' } '404': { $ref: '#/components/responses/NotFound' } '409': { $ref: '#/components/responses/Conflict' } '503': { $ref: '#/components/responses/ServiceUnavailable' } - /runs/{runId}/events: + /books/{bookId}/events: parameters: - - $ref: '#/components/parameters/RunId' + - $ref: '#/components/parameters/BookId' + - $ref: '#/components/parameters/LastEventId' get: tags: [runs] - operationId: streamRunEvents - summary: Live run events (SSE). + operationId: streamBookEvents + summary: Live events of a book (SSE). description: | - `text/event-stream`. Required: HTTP/2 at the edge, `Cache-Control: no-store`, - `X-Accel-Buffering: no`, a heartbeat every ~20 s, a monotonic `id` and `Last-Event-ID` - support. Events are PUSHED by the platform worker; the frontend never polls the - read-model. + `text/event-stream`, on the BOOK and not on a run: a book is received and cut into chapters + before any run exists, and those minutes are what a user watches. - **The first event is always `hello`** — the version handshake. A client that does not - support the major version closes the stream and tells the user. + Events are PUSHED — the read-model is not polled to DISCOVER a change; it is still read on a + frame, on navigation and on focus, and conditional reads make that cheap. The server MUST NOT + buffer the stream. A heartbeat goes out about every 20 s as an SSE comment line (`:` and a + newline), invisible to a browser `EventSource`. - **Reconnect.** The client sends `Last-Event-ID`. If the server cannot resume from it, it - MUST answer with `resync_required` instead of silently starting from the present moment; - the client then re-reads snapshots. Replaying history is forbidden — a one-shot event - such as `note` would otherwise be lost silently. + **The first frame is always `hello`.** A client closes the stream and tells the user when the + version it was generated against is not the one served — while the major is `0` that means + ANY difference, minor included. - OpenAPI does not type SSE frames, so the event name → payload schema mapping is a table - in the `EventEnvelope` description. + **What a frame carries.** Either a delta the client can APPLY, or a counter plus the SCOPE of + what changed (entity id and the revision to read from). A frame MUST NOT be a bare "something + changed" that leaves re-reading a whole collection as the only way to find out what. Frames + never carry translated text. + + **Coalescing.** A state frame (`status`, `progress`, `chapter`, `bank`) MAY be replaced by a + later one of its kind, and a client MUST tolerate counters that jump. `note` is an ADDITION: + it MUST NOT be coalesced or dropped — a lost one is lost silently and forever. + + **Reconnect.** The client sends `Last-Event-ID`. The server MAY resend frames it still holds + in a short live buffer after that id and MUST NOT replay history beyond it; the buffer's size + is not declared and a client MUST NOT depend on any frame being resent. If the server cannot + resume from the id it sends `resync_required` rather than silently starting from now. + + **End of stream.** With no run live and no intake in flight the server sends `end` and + closes. A request presenting a `Last-Event-ID` at or past the book's last frame, while the + book is at rest, is answered `204` — which is how SSE is told to stop reconnecting. A request + WITHOUT `Last-Event-ID` always opens a new stream. + + One stream per book being watched; there is no library-wide stream, and a list screen does + NOT open one per row. A `chapter` frame for a chapter the client does not hold is IGNORED — a + frame is never a reason to page a collection. A deleted book ends its stream, and a reconnect + is answered `404`. + + OpenAPI does not type SSE frames; the event → payload mapping is the table on + `EventEnvelope`. responses: '200': description: Event stream. content: text/event-stream: schema: { $ref: '#/components/schemas/EventEnvelope' } + '204': + description: | + The presented `Last-Event-ID` is at or past the last frame this book has produced, and + the book is at rest. The client MUST NOT reconnect automatically; it opens a new stream, + without `Last-Event-ID`, when it has a reason to watch again. + '401': { $ref: '#/components/responses/Unauthorized' } + '404': { $ref: '#/components/responses/NotFound' } + + /runs/{runId}: + parameters: + - $ref: '#/components/parameters/RunId' + get: + tags: [runs] + operationId: getRun + summary: State of a run. + description: | + The run as it stands, for a client that lost the body of a `202`. + responses: + '200': + description: The run. + content: + application/json: + schema: { $ref: '#/components/schemas/Run' } '401': { $ref: '#/components/responses/Unauthorized' } '404': { $ref: '#/components/responses/NotFound' } @@ -415,8 +598,14 @@ paths: operationId: stopRun summary: Stop a run. description: | - The product "stop" action. The engine stops gracefully on a signal; deciding who pressed - it belongs to the platform. + The product "stop" action; finished work is kept and not paid for again. + + **The `202` does not mean the run has stopped** — the `Run` it returns still carries a live + status. Stopping is asynchronous and there is no `stopping` value in `RunStatus`: the run + reaches `stopped` when the work winds down, and the `status` frame says so. Between the two + the client shows its own pending state. `409` answers a run that is not running at all. + parameters: + - $ref: '#/components/parameters/ClientHeader' responses: '202': description: Stop accepted. @@ -424,6 +613,7 @@ paths: application/json: schema: { $ref: '#/components/schemas/Run' } '401': { $ref: '#/components/responses/Unauthorized' } + '403': { $ref: '#/components/responses/Forbidden' } '404': { $ref: '#/components/responses/NotFound' } '409': { $ref: '#/components/responses/Conflict' } @@ -433,24 +623,21 @@ paths: post: tags: [runs] operationId: resumeRun - summary: Resume a stopped run. + summary: Continue a stopped run. description: | - Clears the bank-signing stop and continues after a user stop. + Clears the bank-signing stop and continues after a stop the user asked for. - ⚠ **After a ceiling stop this call alone does not move the run.** The engine continues only - once the ceiling has been raised, and no handle raises it: `ceiling_chapters` travels with - the START of a run and this contract has no way to change it afterwards. The mechanism is - the platform's and does not exist yet (companion §3); until it does, `resume` on a run - paused by a ceiling returns it to the same state, so the client MUST NOT offer resume as - the remedy for `paused`. + **409 while the set of bank decisions is incomplete** — the stop clears only on a complete + set; `cause.code: bank_decisions_incomplete`. - **Answers 409 while the set of bank decisions is incomplete** — the stop clears only on a - complete set. + ⚠ **A run stopped at its limit is NOT continued by this call** — `409`, + `cause.code: ceiling_reached`. The limit travels with the START of a run and nothing changes + it afterwards. The remedy is a NEW run with a larger `ceiling_chapters`: the paused book is + startable and finished work is not bought again. A client offers that, not this call. - **503 answers a deployment that cannot run at all** (0.2.3). Continuing a run is starting a - process, so it needs exactly what a start needs — the seam that tells the engine its ceiling - and the one that records how a run ended. 0.2.1 named that state for `startRun` only, and - the omission was in the SPEC and not in the wire: the two calls share one refusal. + **503 answers a deployment that cannot run at all** — continuing a run is starting a process. + parameters: + - $ref: '#/components/parameters/ClientHeader' responses: '202': description: Resume accepted. @@ -458,6 +645,7 @@ paths: application/json: schema: { $ref: '#/components/schemas/Run' } '401': { $ref: '#/components/responses/Unauthorized' } + '403': { $ref: '#/components/responses/Forbidden' } '404': { $ref: '#/components/responses/NotFound' } '409': { $ref: '#/components/responses/Conflict' } '503': { $ref: '#/components/responses/ServiceUnavailable' } @@ -468,9 +656,8 @@ paths: operationId: getUsage summary: State of the credit balance. description: | - Credits are a BALANCE, not a subscription with windows. There is no period, no `resets_at` - and no "resets in": the screen shows what is LEFT. The percentage is computed against the - sum of the account's grants, not against a limit of a period — periods do not exist. + Credits are a BALANCE, not a subscription with windows: no period, no `resets_at`, no + "resets in" — the screen shows what is LEFT, as a share of the account's grants. Money SUMS never cross this boundary in any form: a percentage, never an amount. responses: @@ -484,19 +671,24 @@ paths: /books/{bookId}/exports: parameters: - $ref: '#/components/parameters/BookId' + - $ref: '#/components/parameters/IdempotencyKey' post: tags: [export] operationId: createExport summary: Build a book export. description: | - Formats and their contents are stage S7 work; only the call shape is fixed here. + Formats: `GET /capabilities`; one outside that set is `400`. Any book already cut into + chapters may be exported, finished or not — what an export of an unfinished book CONTAINS is + not fixed here. - The export is an ARTIFACT BEHIND A LINK. Assembling the text of a book on the client — - reading every chapter and stitching it together — is forbidden explicitly: it would defeat - the per-chapter working set that the read paths are built around. + The export is an ARTIFACT BEHIND A LINK; assembling a book's text on the client is forbidden + explicitly, as it would defeat the per-chapter working set the read paths are built around. - Completion is POLLED, not pushed: the `202` names the status resource in `Location`, and - the status read carries `Retry-After`. + Completion is POLLED: the `202` names the status resource in `Location` and the status read + carries `Retry-After` while the build runs. Repeating with the same `Idempotency-Key` returns + the original `202` and `Location` rather than building a second copy. + parameters: + - $ref: '#/components/parameters/ClientHeader' requestBody: required: true content: @@ -513,8 +705,11 @@ paths: content: application/json: schema: { $ref: '#/components/schemas/Export' } + '400': { $ref: '#/components/responses/BadRequest' } '401': { $ref: '#/components/responses/Unauthorized' } + '403': { $ref: '#/components/responses/Forbidden' } '404': { $ref: '#/components/responses/NotFound' } + '409': { $ref: '#/components/responses/Conflict' } /books/{bookId}/exports/{exportId}: parameters: @@ -525,19 +720,19 @@ paths: operationId: getExport summary: State of an export. description: | - Without this read the creating call is a dead end: it answers `ready: false` and nothing - ever says otherwise. **Completion is polled, not pushed** — no stream frame announces it. + Without this read the creating call is a dead end. **Completion is polled, not pushed.** + + **Every poll ends.** `pending` → `ready` → `expired`, or `pending` → `failed`. A poll stops at + `ready`, `failed` or `expired`; only `ready` can still change afterwards, and only into + `expired`, which no client is obliged to watch for. responses: '200': description: State of the export. headers: Retry-After: description: | - Seconds to wait before polling again; sent while `ready` is `false`. - - Declared here on purpose. RFC 9110 defines this header for `503` and for `3xx`, - and its general semantics do not reach a `200`, so a contract that wants it on a - `200` has to say so itself. + Seconds to wait before polling again; sent while `state` is `pending`, and only + then. Declared here because RFC 9110 does not define this header for a `200`. schema: { type: integer, minimum: 0 } content: application/json: @@ -554,20 +749,39 @@ components: description: | Browser presentation of one server-side session: HttpOnly, Secure, SameSite=Lax. - **CSRF.** On the cookie path a browser client MUST send the header `X-TM-Client` on every - UNSAFE request — anything other than GET and HEAD. What carries the protection is the - PRESENCE of the header; the value is arbitrary and has no token semantics, so do not invent - any. It is required on same-origin requests as well: it is not a CORS mechanism. + **CSRF.** On the cookie path a client MUST send `X-TM-Client` on every UNSAFE request — + anything other than `GET`, `HEAD` and `OPTIONS`. What protects is the PRESENCE of the header; + the value is arbitrary and has no token semantics. Required on same-origin requests too: it + is not a CORS mechanism. It is declared as a parameter on every operation that needs it, so a + generated client sends it. - The same requirement holds for the session-mechanics endpoints that live outside the - version prefix (companion). + A request presenting a well-formed `Authorization: Bearer` instead of the cookie is exempt. + + ⚠ **This protection stands on there being no cross-origin access** (see Transport). The day + that changes, this section is rewritten rather than re-configured. The same requirement holds + for the session-mechanics endpoints outside the version prefix (companion). bearerToken: type: http scheme: bearer description: | - Desktop and CLI present the same server-side session as an opaque token. The principal is - established in middleware only; no endpoint may assume a cookie — that is what keeps the - API portable to the desktop client. + A server-side session as an opaque token, for a client that is not a browser. The principal + is established in middleware only; no endpoint may assume a cookie. + + ⚠ **Nothing issues such a token today** — the server accepts one, but no call here or in the + session mechanics hands one out. Carrier: research/28 §2 (Б-15); companion §3. + + headers: + RequestId: + description: | + Identifier of this request, present on EVERY response. The same value is `request_id` inside + an error body. + schema: { type: string, minLength: 1 } + ETag: + description: | + Validator of this representation; a client sends it back in `If-None-Match`. Weak validators + are allowed. Bound to the WHOLE request, query string included: page two of a collection and + a delta read of it carry different validators. + schema: { type: string, minLength: 1 } parameters: BookId: @@ -594,67 +808,185 @@ components: required: true description: Opaque export identifier. schema: { $ref: '#/components/schemas/Id' } + ClientHeader: + name: X-TM-Client + in: header + required: true + description: | + Present on every unsafe request presented by session cookie; the value is arbitrary (see + the `sessionCookie` scheme). Absent, such a request is `403` with `code: forbidden`. + + Declared required because the browser client always has to send it; a client presenting a + bearer token is exempt by the security scheme. + schema: { type: string, minLength: 1 } + IdempotencyKey: + name: Idempotency-Key + in: header + required: false + description: | + Makes this call safe to retry. Semantics, all the server's duty: + + - scoped to (principal, method, path); the same key on another operation is another key; + - a repeat with the same key and the same request returns the ORIGINAL response and does no + new work; + - a repeat with the same key and a DIFFERENT request is `409`, `cause.code: key_reused`; + - a repeat while the first is still in flight is `409`, `cause.code: key_in_flight`; retry + after `Retry-After`; + - the record is kept at least 24 hours, then the key is forgotten; + - a key over 255 characters is `400`. + + Omitting the header is legal and means no retry protection. + schema: { type: string, minLength: 1, maxLength: 255 } + LastEventId: + name: Last-Event-ID + in: header + required: false + description: | + The `id` of the last frame the client applied, sent on a RECONNECT. See + `streamBookEvents`. + schema: { type: string, pattern: '^[0-9]+$' } + IfNoneMatch: + name: If-None-Match + in: header + required: false + description: | + Validator the client already holds, from an earlier `ETag`. Unchanged, the answer is `304` + with no body. + schema: { type: string, minLength: 1 } + AfterVersion: + name: after_version + in: query + required: false + description: | + Delta read: the rows changed at or after this revision, in the same order and envelope as a + full read. The value is a `revision` the client has already applied. + + **INCLUSIVE**, for the same reason catch-up reads `>=` (see `Revision`): one transaction is + one revision but several ROWS. Re-reading a row already held costs nothing — a row is + replaced by its `id`. + + **The watermark for the next delta read is the `revision` of the envelope just received**; + rows carry no version of their own. + + A DELETION cannot be expressed this way. Two answers close that: `resync_required` on the + stream when a collection is replaced wholesale, and `400` with + `cause.code: version_too_old` for a watermark that predates such a replacement. + schema: { $ref: '#/components/schemas/Revision' } Limit: name: limit in: query required: false description: | - Page size. The default is stated per collection on the operation; a server MAY return - fewer rows than asked for, and the client decides nothing from that — only from - `next_cursor`. - schema: { type: integer, minimum: 1 } + Page size; the default is the deployment's (`GET /capabilities`). A server MAY return fewer + rows than asked for, and the client decides nothing from that — only from `next_cursor`. + + The `maximum` below bounds what a CLIENT may ask for. A server that receives more MUST clamp + down to it and answer, and MUST NOT refuse the request — answering the default instead is + what made "ask for more, get fewer rows than a smaller request" discoverable only by + experiment. A deployment that validates this parameter against the schema has to exempt it + from rejection. + schema: { type: integer, minimum: 1, maximum: 1000 } Cursor: name: cursor in: query required: false description: | - Keyset cursor taken from `next_cursor` of the previous page. Opaque: the client MUST NOT - parse, compare or construct it. Omitted for the first page. + Keyset cursor from `next_cursor` of the previous page. Opaque: the client MUST NOT parse, + compare or construct it. Omitted for the first page. - A cursor that no longer applies is rejected with `400`; see `NextCursor` for why that - rejection is the server's duty and not the client's. + A cursor that no longer applies is `400` with `cause.code: cursor_invalid`; see + `NextCursor`. schema: { type: string, minLength: 1 } responses: + NotModified: + description: | + The client's copy is still current. No body; the `ETag` it presented stays valid. BadRequest: description: Request rejected. + headers: + X-Request-Id: { $ref: '#/components/headers/RequestId' } content: application/problem+json: schema: { $ref: '#/components/schemas/Problem' } Unauthorized: description: Session missing or invalid. + headers: + X-Request-Id: { $ref: '#/components/headers/RequestId' } + WWW-Authenticate: + required: true + description: | + The challenge for this resource, as RFC 9110 §15.5.2 requires of every `401`. A client + holding no session sends the user to the sign-in flow rather than parsing this. + schema: { type: string, minLength: 1 } + content: + application/problem+json: + schema: { $ref: '#/components/schemas/Problem' } + Forbidden: + description: | + Refused before authorization: the marker header of the `sessionCookie` scheme was missing on + a cookie-presented request, or the request came from an origin this deployment does not + accept. Not a decision about the object — those are `404`. + headers: + X-Request-Id: { $ref: '#/components/headers/RequestId' } content: application/problem+json: schema: { $ref: '#/components/schemas/Problem' } NotFound: - description: Object not found. + description: | + No such object, or one this account may not see, or a path this deployment does not serve — + deliberately one answer: neither another account's library nor the shape of this instance is + public information. + headers: + X-Request-Id: { $ref: '#/components/headers/RequestId' } content: application/problem+json: schema: { $ref: '#/components/schemas/Problem' } Conflict: description: Action impossible in the current state. + headers: + X-Request-Id: { $ref: '#/components/headers/RequestId' } + Retry-After: + description: | + Seconds to wait before repeating, sent when waiting is the remedy — today + `idempotency_conflict` with `cause.code: key_in_flight`. Absent otherwise, and a client + that sees no header does not invent a delay. + schema: { type: integer, minimum: 0 } + content: + application/problem+json: + schema: { $ref: '#/components/schemas/Problem' } + Gone: + description: | + The object existed and does not any more; its identifier will not be reissued. A client + holding a reference re-reads the collection it came from. + headers: + X-Request-Id: { $ref: '#/components/headers/RequestId' } content: application/problem+json: schema: { $ref: '#/components/schemas/Problem' } TooLarge: - description: File exceeds the intake size limit. + description: | + File over the intake cap (`intake_max_bytes` of `GET /capabilities`). + headers: + X-Request-Id: { $ref: '#/components/headers/RequestId' } content: application/problem+json: schema: { $ref: '#/components/schemas/Problem' } RequestTimeout: description: | - The body did not arrive whole inside the route's deadline (added in 0.2.3): a slow client on - a large book. RFC 9110 §15.5.9 describes this case exactly, and it names RETRY as the - remedy — which is what a `413` and a `500` in its place would both hide. + The body did not arrive whole inside the route's deadline: a slow client on a large book. + RETRY is the remedy, which is what separates it from `413`. + headers: + X-Request-Id: { $ref: '#/components/headers/RequestId' } content: application/problem+json: schema: { $ref: '#/components/schemas/Problem' } ServiceUnavailable: description: | - The deployment cannot perform this action right now (added in 0.2.1, D39.123): starting or - continuing a run requires the engine seam to be fully configured, and answering with any - other code would misname the state. Temporary by nature — retry later; no Retry-After is - promised. + The deployment cannot do this right now: starting or continuing a run needs its machinery + fully configured. Temporary — retry later; no `Retry-After` is promised. + headers: + X-Request-Id: { $ref: '#/components/headers/RequestId' } content: application/problem+json: schema: { $ref: '#/components/schemas/Problem' } @@ -664,227 +996,366 @@ components: type: string minLength: 1 description: | - Opaque identifier. The client MUST NOT parse, sort by or construct it. Stability across - runs is the platform's job when it persists the manifest. + Opaque identifier. The client MUST NOT parse, sort by or construct it. + + **Stability differs by what the identifier names:** + + - book, run, export — kept for as long as the object exists; + - chapter — survives a re-parse of the same source, because a chapter is identified by its + own text. It does not survive that text changing; + - PAIR (`Unit.id`) — stable only within one `structure_version`. Cutting the book + differently mints a new id for every pair while the chapters survive, so a stored anchor on + a pair is invalid the moment the version moves, and the client re-reads the chapter rather + than reporting the pair as deleted. examples: ['bk_7c1'] Revision: type: integer minimum: 0 description: | - Monotonic revision. **The counter is PER BOOK:** every book-scoped read and the `id` of - every stream frame of that book's run carry the same number. The library has a scope of its - own. + Monotonic revision. **The counter is PER BOOK:** every book-scoped read and every frame of + that book carry the same number. The library has its own scope, and a revision is never + compared across scopes. - A revision is monotonic WITHIN its scope and is NEVER compared across scopes. + **Discarding a stale read is the CLIENT's duty**: a read whose revision is lower than what it + has already applied MUST be dropped rather than rendered, or the interface rolls progress + backwards on every refetch. The comparison is per SCOPE and per COLLECTION — never against + the library's, never across two collections of one book, and an export poll is never dropped + for carrying an older number than the chapter tree. - Discarding a stale read is the CLIENT's duty: a read whose revision is lower than what the - client has already applied MUST be dropped rather than rendered, otherwise the interface - rolls progress backwards on every refetch — and a refetch on window focus is the default - behaviour of the client's query layer, so the race happens on every tab switch. + **A list read across several pages is torn**, and its revision is that of the OLDEST page — + stamped with the newest, a list whose head predates an applied frame would pass the guard + above and overwrite it. - **Catch-up after a reconnect reads `revision >= R`, not `> R`.** One transaction is one - revision but SEVERAL frames; strict "greater than" drops the sibling frames of the last - one the client applied. + **Catch-up after a reconnect reads `revision >= R`, not `> R`**: one transaction is one + revision but SEVERAL frames. - After a transaction of FULL REPLACEMENT — the bank rebuilt from scratch, re-chunking - replacing the chapters — the server MUST answer `resync_required` rather than a delta: a - delta read cannot express a deletion. + After a FULL REPLACEMENT — the bank rebuilt, re-cutting replacing the chapters — the server + MUST answer `resync_required` rather than a delta: a delta cannot express a deletion. examples: [1841] + StructureVersion: + type: integer + minimum: 0 + description: | + Generation of the book's structure: which chapters exist and where their boundaries fall. It + moves when the book is cut again. A cursor is bound to it, pair identifiers are bound to it + (`Id`), and a term's chapter window is expressed in its coordinates (`BankTerm`). + + Distinct from `Revision`, which moves on every materialization: binding pagination or an + anchor to that would restart them constantly. + examples: [3] + NextCursor: type: [string, 'null'] description: | - Cursor of the NEXT page, or `null` on the last one. Present on EVERY list response, - always — introducing it later would silently cut the tail off a client that does not read - the field. + Cursor of the NEXT page, or `null` on the last one. Present on EVERY list response — + introducing it later would silently cut the tail off a client that does not read the field. - The cursor is bound to the STRUCTURAL epoch of the collection — the generation of the - manifest, the chunker version — and **not to the revision of the book**: the revision bumps - on every materialization, so binding to it would restart pagination forever while a - 5000-chapter book is running. + Bound to the `structure_version` of the collection and NOT to the book's revision: the + revision bumps on every materialization, which would restart pagination forever. - Rejecting a cursor from a dead epoch is the SERVER's duty (MUST), answered `400`. The - client cannot perform it: the cursor is opaque to it by construction. + Rejecting a cursor from a structure that no longer exists is the SERVER's duty (MUST), + answered `400` with `cause.code: cursor_invalid`. The client cannot: the cursor is opaque + to it. LangCode: type: string pattern: '^[a-z]{2,3}(-[A-Za-z0-9]{2,8})*$' description: | - Language code, never a name. The display name is computed by the screen via - `Intl.DisplayNames`. + Language code, never a name; the display name is the client's to render. Well-formed is not + the same as supported — `GET /capabilities` names the pairs this deployment can run, and one + outside that set is refused at intake. examples: ['zh'] - Counter: + Capabilities: type: object - description: Wave counter, in UNITS. - required: [done, total] + description: | + What this deployment can do. One flat document, the same for every account. + required: + - contract_version + - language_pairs + - intake_enabled + - intake_max_bytes + - export_formats + - page_size_default properties: - done: { type: integer, minimum: 0 } - total: { type: integer, minimum: 0 } + contract_version: + type: string + description: | + The version this deployment serves — the only place a non-streaming client learns it. A + client generated against a different one REFUSES to work and says so: while the major is + `0` a differing minor carries breaking changes by design. + examples: ['0.3.0'] + language_pairs: + type: array + description: | + Every pair the deployment knows about, unavailable ones included: "absent" and "listed + as unavailable" are different facts to a user waiting for one. + items: { $ref: '#/components/schemas/LanguagePair' } + intake_enabled: + type: boolean + description: | + Whether this deployment takes books at all. `false` is a read-only instance that serves + a library and answers `404` to `POST /books`. Without it a client discovers this only by + spending a user's upload. + intake_max_bytes: + type: integer + minimum: 1 + description: | + Largest file this deployment accepts, when it accepts any. A client checks it before + starting an upload; the server enforces it regardless and answers `413`. + export_formats: + type: array + description: | + Formats `POST /books/{bookId}/exports` accepts. Empty means none are built here. + items: { type: string, minLength: 1 } + page_size_default: + type: integer + minimum: 1 + maximum: 1000 + description: | + Rows a collection returns when `limit` is omitted. Never larger than the maximum a + client may ASK for, which is why it carries the same bound; that maximum is defined once, + on the `limit` parameter, and this is its consequence rather than a second copy. + + LanguagePair: + type: object + description: A translation direction this deployment knows about. + required: [source, target, state] + properties: + source: { $ref: '#/components/schemas/LangCode' } + target: { $ref: '#/components/schemas/LangCode' } + state: + type: string + description: | + `available` — books in this pair can be translated here · `unavailable` — the deployment + knows the pair and cannot run it. + enum: [available, unavailable] + + Page: + type: object + description: | + The envelope every collection answers in, composed into each list so that "every page carries + a revision and a next cursor" is checkable rather than merely observed. + required: [revision, next_cursor] + properties: + revision: { $ref: '#/components/schemas/Revision' } + next_cursor: { $ref: '#/components/schemas/NextCursor' } Progress: type: object description: | - Progress PER PHASE, in units: a unit is done only once its edit resolved, and editing does - not start before the bank stop, so a single end-to-end counter reads zero for the whole - draft wave. + How far the current SEGMENT of work has got, in chapters. A segment is the work between two + stops: to the point where the run stops for the bank to be signed, and from there to the end + of what the run bought. **When a stop is cleared the counter starts again from zero** — the + two segments cover the same chapters and are never added together or compared. - The phases exist for the DATA, not for the screen — the user sees one fraction with no - phase names. No ready-made percentage is shipped: the formula is a product decision. - required: [draft, edit] + **`total` is what this run BOUGHT**, not the length of the book, so the fraction always + reaches one. The book-wide figure is `Book.chapters_done` against `Book.chapter_count` and + answers a different question. + + No ready-made percentage is shipped: how a fraction is drawn is a product decision. + required: [done, total, eta_seconds] properties: - draft: { $ref: '#/components/schemas/Counter' } - edit: { $ref: '#/components/schemas/Counter' } + done: + type: integer + minimum: 0 + description: Chapters finished in this segment. + total: + type: integer + minimum: 0 + description: Chapters this segment covers — what the run bought. eta_seconds: type: [integer, 'null'] minimum: 0 description: | - Estimated seconds to the end of the run. **Optional:** it is absent whenever there is - nothing to estimate from — before the first calls of a wave there is no throughput yet - — and the screen MUST render without it rather than show a zero. + Estimated seconds to the end of the segment, or `null` when there is nothing to estimate + from. The screen renders without it rather than showing a zero. BookStatus: type: string description: | - Product status of a book: `uploading` file is being accepted · `parsing` split into - chapters · `not_started` parsed, never run · `translating` translation in progress · - `awaiting_bank` waiting for the glossary to be signed · `finalizing` final pass · - `ready` done · `paused` halted and resumable · `stopped` stopped by the user · - `rejected` file could not be parsed · `failed` run aborted by an error. + Product status of a book: `uploading` file is being accepted · `parsing` being cut into + chapters · `not_started` cut, never run · `translating` translation in progress · + `awaiting_bank` waiting for the book's terms to be signed · `ready` done · `paused` halted and + continuable · `stopped` stopped by the user · `rejected` the file was refused · + `failed` the run ended in an error. - The engine has no run-state vocabulary at all, so `not_started`, `stopped` and `rejected` - are DERIVED by the contract rather than received as a field. + `not_started`, `stopped` and `rejected` are derived by the PLATFORM from behaviour, but they + arrive in this field like any other: a client reads `status` and never computes it. - **A ceiling stop is `paused`, never `failed`.** It is a resumable book-wide stop, and - mapping it to `failed` is forbidden because that would lie about resumability. The machine - reason travels as `Run.paused_reason`; the phrase the user reads is drawn by the client. + **The book's status is that of its current or last run**, except the four a run cannot be in + — `uploading`, `parsing`, `not_started`, `rejected` — which belong to the book alone. So a + client holding both never has to decide which wins. - A prescreen refusal maps to none of these values either — companion §4 (K-9). + **A stop at the limit is `paused`, never `failed`**: it is continuable, and mapping it to + `failed` would lie about that. The machine reason is `Run.paused_reason`; the phrase is the + client's. enum: - uploading - parsing - not_started - translating - awaiting_bank - - finalizing - ready - paused - stopped - rejected - failed + RunStatus: + type: string + description: | + Status of a RUN — the states a run can be in, which are fewer than a book's. A run is never + `uploading`, `parsing`, `not_started` or `rejected`: those belong to the book before any run + exists or instead of one. + enum: [translating, awaiting_bank, ready, paused, stopped, failed] + RejectReason: type: string description: | - Machine reason a book was rejected (0.2.3). The platform's own closed vocabulary; as with - `PausedReason` the API carries STATE and the phrase the user reads is drawn by the client, - so no wording appears here. + Machine reason a book was rejected. A closed vocabulary of this version; the API carries + STATE and the client draws the phrase, so no wording appears here. - - `source_unreadable` — the file was read and is not a book this service can cut: it yields - no sections at all, or the reader refused it. TERMINAL, and the source does not survive - it — there is no path in this contract that re-reads a rejected book, so the remedy is to - add the book again; - - `not_configured` — this deployment has nothing to read the book AGAINST. It is a state of - the service, never of the file, and retrying by itself does not clear it; - - `parser_unavailable` — the service could not process the file, repeatedly, until it gave - up. A state of the service as well, and a temporary one. + - `source_unreadable` — read, and not a book this service can cut. TERMINAL, and the source + does not survive it: no path here re-reads a rejected book, so the remedy is to add it + again; + - `not_configured` — this deployment has nothing to read the book AGAINST. A state of the + service, and retrying alone does not clear it; + - `parser_unavailable` — the service failed on the file repeatedly and gave up. Also a state + of the service, and temporary; + - `content_refused` — the service will not translate this book. **One coarse reason for a + whole class**: it never says which check refused, never varies between attempts, and gives + nothing to search against. A client shows one neutral phrase and does not invite a retry. - The three are two different NEXT ACTIONS for the user, and a client that told them apart by - colour alone would be telling them apart by nothing: the first means "the file is not one we - can read", the other two mean "not us, not now". + A client MUST tolerate an unknown value under a minor bump and MUST render a rejected book + that carries no reason at all: `null` is legal. + enum: [source_unreadable, not_configured, parser_unavailable, content_refused] - A client MUST tolerate an unknown value arriving under a minor bump, and MUST render a - rejected book that carries no reason at all: a deployment older than this minor answers - exactly that. - enum: [source_unreadable, not_configured, parser_unavailable] + PausedReason: + type: string + description: | + Machine reason a run is paused; the client draws the phrase. One value today. A client MUST + tolerate an unknown one under a minor bump and MUST render a paused run whose reason is + `null` — the ordinary answer when the service has no word for what stopped it — showing the + neutral "halted, continuable" state. + enum: [credit_exhausted] + + AccountHaltReason: + type: string + description: | + Machine reason the ACCOUNT is halted — a state of the account, not of a run. A separate + vocabulary from `PausedReason` on purpose: a run stops for reasons that say nothing about the + account, and lighting an account-wide state from one would tell a user with money that they + have none. + enum: [credit_exhausted] + + RunFailureReason: + type: string + description: | + Why a run ended in `failed` — the one state that IS an error, and the one a client decides a + retry from. + + - `source_unreadable` — the book could not be read when the work reached it. Adding it again + in another form is the remedy; retrying this run is not; + - `service_error` — this deployment could not do the work: its configuration, storage or its + own state. Not the user's file and not their account; retrying alone does not clear it; + - `interrupted` — the run ended without saying how. Retrying IS the remedy, and finished work + is not bought again. + + A client MUST tolerate an unknown value under a minor bump and take the cautious branch: show + the failure, do not promise a retry will help. + enum: [source_unreadable, service_error, interrupted] Book: type: object description: A book in the library. required: - [id, title, source_lang, target_lang, status, chapter_count, added_at, progress, note_count] + - id + - revision + - title + - source_lang + - target_lang + - status + - reject_reason + - structure_version + - chapter_count + - chapters_done + - character_count + - added_at + - note_count properties: id: { $ref: '#/components/schemas/Id' } - title: { type: string } + revision: + $ref: '#/components/schemas/Revision' + description: | + Revision of THIS BOOK, not of the library carrying it — so the answer to a write can be + ordered against a frame the way a read can. + title: + type: string + description: | + Name shown in the library. Given by the user at intake or derived from the name of the + uploaded file; changed afterwards with `PATCH /books/{bookId}`. source_lang: { $ref: '#/components/schemas/LangCode' } target_lang: { $ref: '#/components/schemas/LangCode' } - genre: { type: string, description: Genre as declared by the user. } - chapter_count: { type: integer, minimum: 0 } - character_count: - type: integer - minimum: 0 - description: Size in characters. Not an engine field; the platform knows it from intake. - added_at: { type: string, format: date-time } status: { $ref: '#/components/schemas/BookStatus' } reject_reason: oneOf: - $ref: '#/components/schemas/RejectReason' - type: 'null' description: | - Why the book was rejected; meaningful only while `status` is `rejected` (0.2.3). - - OPTIONAL, unlike `Run.paused_reason`, and the asymmetry is deliberate: a rejection - reason is meaningful in exactly one of eleven states, and a deployment that predates - this minor sends nothing here at all. "Absent" and "null" therefore mean one and the - same thing — the reason is not known — and a client renders both the same way. That is - the opposite of the `sense` case (0.2.2), where the two shapes carried DIFFERENT facts. - progress: { $ref: '#/components/schemas/Progress' } - note_count: { type: integer, minimum: 0 } - - Library: - type: object - required: [revision, next_cursor, books] - properties: - revision: { $ref: '#/components/schemas/Revision' } - next_cursor: { $ref: '#/components/schemas/NextCursor' } - books: - type: array - items: { $ref: '#/components/schemas/Book' } - - PausedReason: - type: string - description: | - Machine reason a run is paused. The API carries STATE; the phrase the user reads is drawn - by the client, so no wording appears here. - - One value exists today. A client MUST tolerate an unknown one arriving under a minor bump - and show the neutral "halted, resumable" state rather than failing or guessing. - enum: [credit_exhausted] - - Run: - type: object - description: | - A run over a book. `status` reuses the book vocabulary, but the book-level values - (`uploading`, `parsing`, `not_started`, `rejected`) never appear on a run. - required: [id, revision, status, verify_bank, ceiling_chapters, paused_reason, started_at] - properties: - id: { $ref: '#/components/schemas/Id' } - revision: { $ref: '#/components/schemas/Revision' } - status: { $ref: '#/components/schemas/BookStatus' } - verify_bank: - type: boolean - description: The run was requested with a stop for bank signing. - ceiling_chapters: + Why the book was rejected; meaningful only while `status` is `rejected`, and `null` + everywhere else — including on a rejected book whose reason the service cannot name. + structure_version: { $ref: '#/components/schemas/StructureVersion' } + chapter_count: type: integer - minimum: 1 + minimum: 0 + description: Chapters the book was cut into. + chapters_done: + type: integer + minimum: 0 description: | - The ceiling this run was started with, in CHAPTERS. A property of the RUN, not of the - book: it travels with the start and does not outlive the run. Present so that a reloaded - screen can still name the cap the user chose. - paused_reason: - oneOf: - - $ref: '#/components/schemas/PausedReason' - - type: 'null' - description: Reason when `status` is `paused`; `null` in every other state. - started_at: { type: string, format: date-time } - finished_at: - type: [string, 'null'] - format: date-time + Chapters fully translated. Against `chapter_count` this is the book's own progress — + what a library row shows — and it never moves backwards WITHIN one `structure_version`; + cutting the book again recomputes both numbers. The bar of a RUNNING run is + `Run.progress`, which measures what that run bought. + character_count: + type: [integer, 'null'] + minimum: 0 + description: | + Size of the source in characters, or `null` while the book is still arriving. + added_at: { type: string, format: date-time } + note_count: + type: integer + minimum: 0 + description: Notes on the whole book. + + BookPatch: + type: object + description: | + Merge patch over a book. One member, and a member absent from the patch is left alone. + properties: + title: + type: string + minLength: 1 + maxLength: 200 + description: | + New display name, bounded like the one the platform derives. + + BookPage: + allOf: + - $ref: '#/components/schemas/Page' + - type: object + required: [books] + properties: + books: + type: array + items: { $ref: '#/components/schemas/Book' } BookDetail: type: object - required: [revision, book] + required: [revision, book, run] properties: revision: { $ref: '#/components/schemas/Revision' } book: { $ref: '#/components/schemas/Book' } @@ -894,200 +1365,319 @@ components: - type: 'null' description: Current or last run; `null` if the book was never run. + Run: + type: object + description: A run over a book. + required: + - id + - book_id + - revision + - status + - stop_for_signing + - ceiling_chapters + - progress + - paused_reason + - failure_reason + - started_at + - finished_at + properties: + id: { $ref: '#/components/schemas/Id' } + book_id: + $ref: '#/components/schemas/Id' + description: | + The book this run belongs to — so an answer to `stop`, `resume` or `GET /runs/{runId}` + is enough to find it without a second read. + revision: { $ref: '#/components/schemas/Revision' } + status: { $ref: '#/components/schemas/RunStatus' } + stop_for_signing: + type: boolean + description: | + The run was started with a stop for the book's terms to be signed. + ceiling_chapters: + type: integer + minimum: 1 + description: | + The limit this run was started with, in CHAPTERS — a property of the RUN. Present so a + reloaded screen can name the limit the user chose and read `progress.total` against it. + progress: { $ref: '#/components/schemas/Progress' } + paused_reason: + oneOf: + - $ref: '#/components/schemas/PausedReason' + - type: 'null' + description: | + Machine reason when `status` is `paused`, `null` otherwise — including for a paused run + whose reason this contract has no word for, where the client shows the neutral halted + state and does not guess. + failure_reason: + oneOf: + - $ref: '#/components/schemas/RunFailureReason' + - type: 'null' + description: Machine reason when `status` is `failed`; `null` otherwise. + started_at: { type: string, format: date-time } + finished_at: + type: [string, 'null'] + format: date-time + description: | + When the run ended, or `null` while it is still live. + BookIntake: type: object description: | Add-a-book form. - ⚠ **Order matters here and nowhere else on this surface:** `file` is the LAST part, and - every other field precedes it — see `createBook`. An OpenAPI object has no ordering, so the - rule is stated in prose because it cannot be stated in the schema. - required: [file, source_lang, target_lang] + ⚠ **Order matters here and nowhere else on this surface:** `file` is the LAST part and every + other field precedes it — see `createBook`. An OpenAPI object has no ordering, so the rule + lives in prose; the properties are nevertheless listed in the required order, and the schema + has NO optional member, so no emission order a generator picks can break the rule. + required: [title, source_lang, target_lang, file] properties: title: type: string maxLength: 200 description: | - Title given by hand (0.2.3). OPTIONAL, and the two cases are told apart by the user - rather than guessed at: absent or empty means "the parse will name it" — today the - platform takes the name of the uploaded file — while a value present means the person - named the book themselves, and no later parse overwrites it. + Title given by hand; **the EMPTY STRING means "name it from the file"** — today the + platform takes the name of the uploaded file. A value present means the person named the + book themselves, and no later parse overwrites it. - Bounded like the title the platform derives: the library lists it, and an unbounded - string on that screen is the client's problem to draw, not the server's to store. + Required, and empty rather than absent, so that this schema has no optional member: a + generator emitting required members first would otherwise place `title` after `file`, and + a part after the file is refused. + source_lang: { $ref: '#/components/schemas/LangCode' } + target_lang: { $ref: '#/components/schemas/LangCode' } file: type: string format: binary - description: Book file. The LAST part of the form. - source_lang: { $ref: '#/components/schemas/LangCode' } - target_lang: { $ref: '#/components/schemas/LangCode' } - genre: { type: string } + description: | + Book file, and the LAST part of the form. + + **Its NAME carries two facts**, so a client sends a real one: it becomes the book's title + when `title` was empty, and its extension selects the reader — `.epub` as a book, + anything else as plain text. Chapter: + allOf: + - $ref: '#/components/schemas/ChapterProgress' + - type: object + required: [number, heading, units_total] + properties: + number: + type: [integer, 'null'] + minimum: 1 + description: | + Displayed ordinal, or `null` when the book has no numbering — a legal book. + + **Not a key:** numbering is dense, so editing the source shifts every later chapter. + heading: + type: [string, 'null'] + maxLength: 200 + description: | + The chapter's label **as it comes from the data of the book**, or `null` when the + book carries none. A deployment whose parser does not extract labels answers `null` + and MUST NOT put a rendered ordinal here — this field is the book's own words. + + **A client with no label renders its own ordinal from `number`, in the language of + the interface**; the server does not know that language. When `heading` and `number` + are both `null` the client labels the row from its position in reading order. + + The server bounds the length: in a list of thousands of rows this is the only string + that would otherwise be unbounded. + units_total: + type: integer + minimum: 0 + description: Pairs in this chapter. + + ChapterProgress: type: object - required: [id, number, heading, units_total, units_done, note_count] + description: | + The part of a chapter that MOVES while a book is translated — composed into both `Chapter` + and the `chapter` frame, so a client applies a frame to a row by the same field names. + required: [id, units_done, note_count] properties: id: { $ref: '#/components/schemas/Id' } - number: - type: [integer, 'null'] - minimum: 1 - description: | - Displayed ordinal, or `null` when the book has no numbering — a legal book. Always - present, possibly null, so the client handles one shape rather than two. - - **Not a key:** numbering is dense — chapters that yield no text do not consume a - number — so editing the source shifts every later chapter. - heading: - type: [string, 'null'] - maxLength: 200 - description: | - The chapter's label **as it comes from the data of the book**, or `null` when the book - carries none. - - A client MUST NOT synthesize a label from a template such as "Chapter {n}": no such - form exists, a book legally has no numbers, and a book legally has no chapters at all. - An unlabelled chapter is shown without a name rather than given an invented one. - - The server bounds the length — in a list of 5000 rows this is the only string that - would otherwise be unbounded. - - Who produces the label is still a backend question: today the engine glues a rendered - title into the text of the first unit and leaves the source column without it — - companion §4 (K-2). - units_total: { type: integer, minimum: 0 } units_done: type: integer minimum: 0 description: | - Units finished in this chapter. Whether "finished" needs the same draft/edit split as - `Progress` — without it a chapter reads zero for the whole draft wave — is open, - companion §4 (K-10). - note_count: { type: integer, minimum: 0 } + Pairs of this chapter finished IN THE CURRENT PASS over the book — the same accounting + as `Progress`, one level down. `0` for a chapter the pass has not reached, `units_total` + for one it has finished; it restarts from zero for the chapters a NEW pass re-walks, and + a chapter outside the current pass keeps what the last pass left. Counted end to end + instead, the tree would read zero through the whole first pass. - ChapterList: - type: object - required: [revision, next_cursor, chapters] - properties: - revision: { $ref: '#/components/schemas/Revision' } - next_cursor: { $ref: '#/components/schemas/NextCursor' } - chapters: - type: array - items: { $ref: '#/components/schemas/Chapter' } + ⚠ The state of a PASS, not the lifetime of the chapter, so it can legally return to zero. + The lifetime figure is `Book.chapters_done`. + note_count: + type: integer + minimum: 0 + description: Notes on this chapter. + + ChapterPage: + allOf: + - $ref: '#/components/schemas/Page' + - type: object + required: [structure_version, chapters] + properties: + structure_version: { $ref: '#/components/schemas/StructureVersion' } + chapters: + type: array + items: { $ref: '#/components/schemas/Chapter' } UnitState: type: string description: | - State of a pair, derived from the PAIR (chunk verdict plus presence of final text), not - from the verdict alone: a flagged unit legally arrives WITH text. - - - `translated` — text shipped. This includes a flagged unit whose text shipped anyway, - such as a cosmetic sanitizer cleanup; such a unit carries a `note`; - - `withheld` — verdict flagged AND no text; - - `pending` — not translated yet. - - The word "flagged" never goes on the wire: it is pipeline vocabulary. + `translated` — a translation shipped, including a pair that shipped WITH a note attached · + `withheld` — no translation was produced · `pending` — not translated yet. enum: [translated, withheld, pending] Unit: type: object description: | - A source/translation pair, one edit unit wide. + A source/translation pair — one fragment of a chapter beside its translation. A fragment is + as long as the text needs: sometimes a paragraph, sometimes a whole chapter. Alignment is + coarse and accepted as such. - **Freshness.** `target` is updated at stage boundaries and at stops, not continuously — - mid-run there is no read channel at all. The live "something changed" signal arrives as an - event; the text arrives with a read after the boundary. - required: [id, source, state] + **Freshness.** `target` is updated at the boundaries of the work and at stops, not + continuously: a frame says something changed, the text arrives with the next read. + required: [id, source, target, state, notes] + if: + properties: + state: { const: translated } + required: [state] + then: + properties: + target: { minLength: 1 } properties: id: { $ref: '#/components/schemas/Id' } source: type: string - description: Source text, aligned to the edit unit. + description: Source text of the fragment. target: type: string - description: Translated text. Empty for `withheld` and `pending`. + description: | + Translation. **Non-empty exactly when `state` is `translated`, the empty string + otherwise** — never absent, never `null`. Stated both as a constraint above and in words + here because a generator ignores `if`/`then` and leaves it a plain optional string; the + client narrows the pair on its own seam. state: { $ref: '#/components/schemas/UnitState' } - note: - oneOf: - - $ref: '#/components/schemas/Note' - - type: 'null' - - UnitList: - type: object - required: [revision, next_cursor, units] - properties: - revision: { $ref: '#/components/schemas/Revision' } - next_cursor: { $ref: '#/components/schemas/NextCursor' } - units: + notes: type: array - items: { $ref: '#/components/schemas/Unit' } + description: | + The notes on this pair, in the note list's order; empty when there are none. Delivered + here as well as in the book's note list so a reader screen need not join two collections, + and as an ARRAY because a pair legally carries more than one. + items: { $ref: '#/components/schemas/Note' } + + UnitPage: + allOf: + - $ref: '#/components/schemas/Page' + - type: object + required: [structure_version, units] + properties: + structure_version: { $ref: '#/components/schemas/StructureVersion' } + units: + type: array + items: { $ref: '#/components/schemas/Unit' } NoteSeverity: type: string description: | - Severity step. Two steps are a frontend PROPOSAL projected from the engine's operator - severity ranks, and that axis need not match the product one. How many steps there are and - where the boundary lies is an open product question — companion §4 (K-6). + How much attention the note asks for. Two steps today; how many there ought to be is an open + product question (companion K-6), and a client MUST tolerate a new value under a minor bump. enum: [attention, glance] Note: type: object description: | - A note in product terms. Neither the engine's flag reason nor its detail text crosses the - boundary; the reason → phrase map belongs to the contract and is not filled in this draft - (companion, appendix A). - required: [severity, message] + A remark about a piece of the translation. **The words are the client's**, drawn from `code` + as they are from every other machine reason here; nothing about the machinery that produced + it crosses this boundary. + required: [id, created_at, severity, code, chapter_id] properties: - severity: { $ref: '#/components/schemas/NoteSeverity' } - message: - type: string + id: + $ref: '#/components/schemas/Id' description: | - Ready human phrase; the wording is the owner's call. A phrase MUST exist for every - reason, including one this contract does not know yet, and it MUST read neutrally - rather than as an error. - chapter_id: { $ref: '#/components/schemas/Id' } - unit_id: { $ref: '#/components/schemas/Id' } + Identity of the note — without it a note arriving on the stream cannot be matched + against the list already read. + created_at: { type: string, format: date-time } + severity: { $ref: '#/components/schemas/NoteSeverity' } + code: + type: string + minLength: 1 + description: | + Machine reason for the note: a closed vocabulary of this version, listed with its phrase + in the companion's appendix A. A client MUST show a neutral phrase — never the word + "error" — for a code it does not know. - NoteList: - type: object - required: [revision, next_cursor, notes] - properties: - revision: { $ref: '#/components/schemas/Revision' } - next_cursor: { $ref: '#/components/schemas/NextCursor' } - notes: - type: array - items: { $ref: '#/components/schemas/Note' } + Not enumerated here: the map is a table the contract's owner fills, and freezing a list + in the schema before the words exist would make it a second copy. It becomes an enum when + appendix A is written. + chapter_id: + $ref: '#/components/schemas/Id' + description: | + The chapter the note is about. Required: a note addressing nothing could not be placed + on any screen. + unit_id: + $ref: '#/components/schemas/Id' + description: | + The pair the note is about, when it is about one rather than the whole chapter. Optional + for that reason and no other. + + NotePage: + allOf: + - $ref: '#/components/schemas/Page' + - type: object + required: [structure_version, notes] + properties: + structure_version: { $ref: '#/components/schemas/StructureVersion' } + notes: + type: array + items: { $ref: '#/components/schemas/Note' } TermKind: type: string description: | - Kind of term. Not cosmetic: `name` and `place` ROUTE a term into transliteration, so - signing a term without seeing its kind means signing blind. + Kind of term. Not cosmetic: `name` and `place` decide whether a term is transliterated, so + signing one without seeing its kind means signing blind. enum: [name, place, title, term, nickname] TermStatus: type: string description: | - Signing status, THREE-VALUED; only `approved` is injected as canon. A boolean `signed` - would merge "proposed by the engine, nobody looked" with "a human started and did not - finish" — on a screen of hundreds of rows that is the main filter of work. - enum: [auto, draft, approved] + Signing status, THREE-VALUED; only `approved` is carried into the translation as canon. A + boolean would merge "proposed, nobody has looked" with "a person started and did not finish" + — on a screen of hundreds of rows that is the main filter of work. + + This is the state of the ROW. `POST /books/{bookId}/bank/decisions` does not set it: a + decision is recorded against the row and its status follows on the next rebuild. + enum: [proposed, in_progress, approved] TermOrigin: type: string - description: Provenance of a bank row — who created it. An axis independent of `status`. - enum: [seed, ruby, mined] + description: | + Where the row came from — an axis independent of `status`, and one the person signing needs. + + - `given` — it came with the book: someone stated it up front; + - `annotated` — the book's own text says how to read it, and the row was taken from there; + - `found` — the service found it in the text. + enum: [given, annotated, found] BankTerm: type: object description: | - A memory bank row. - - ⚠ **The name `source` is deliberately unused here.** In the engine that column means - PROVENANCE; this contract calls provenance `origin` and the term's surfaces `src`/`dst`. - Naming the term's text `source` would create a false friend between the two schemas. + A memory bank row. Provenance is `origin` and the term's two surfaces are `src`/`dst`; the + name `source` is deliberately unused — companion §2.8. required: [id, src, dst, kind, status, origin, sense, since_chapter, until_chapter] properties: - id: { $ref: '#/components/schemas/Id' } + id: + $ref: '#/components/schemas/Id' + description: | + Identity of the row, derived from the term itself — its surfaces, sense and window — so + a decision against it survives the bank being rebuilt. + + ⚠ It does NOT survive the book being cut differently: the window is in chapter numbers, + those move with a re-cut, and an identity derived from them moves too. A client that sees + `structure_version` change re-reads the bank and does not assume its decisions carried + over. src: type: string description: Source surface of the term. @@ -1099,60 +1689,80 @@ components: - $ref: '#/components/schemas/TermKind' - type: 'null' description: | - `null` when the engine could not decide the kind: ruby candidates that are neither a - name nor a place legally carry none. A client MUST show such a row as "kind not - decided" and MUST NOT drop it or invent a kind — the row still needs signing. + `null` when the kind could not be decided — legal, and the row still needs signing. A + client MUST show it as "kind not decided" and MUST NOT drop it or invent a kind. status: { $ref: '#/components/schemas/TermStatus' } origin: { $ref: '#/components/schemas/TermOrigin' } sense: type: string description: | - Polysemy disambiguator; part of the uniqueness key. - - **Required, and the EMPTY STRING means "no disambiguator" (0.2.2).** It was optional - while being named as part of the key: a client then could not tell "this term has no - disambiguator" from "the field was not sent", although that is exactly the field by - which two legal rows of the same surface differ. + Polysemy disambiguator; part of the uniqueness key. **Required, and the EMPTY STRING + means "no disambiguator"** — otherwise a client could not tell that from "the field was + not sent", which is exactly the field two legal rows of one surface differ by. since_chapter: - type: integer - minimum: 0 + type: [integer, 'null'] + minimum: 1 description: | - Start of the spoiler window; `0` means from the beginning of the book. A term is - unique by `(book, src, sense, since, until)`, so the same `src` legally arrives as - several rows — without the window they look like duplicates and get deleted. - until_chapter: - type: integer - minimum: 0 - description: End of the spoiler window; `0` means open-ended. + First chapter the term applies from, or `null` for "from the beginning". - Bank: - type: object - required: [revision, next_cursor, total, signed, terms] - properties: - revision: { $ref: '#/components/schemas/Revision' } - next_cursor: { $ref: '#/components/schemas/NextCursor' } - total: - type: integer - minimum: 0 - description: Rows in the whole bank, not on this page. - signed: - type: integer - minimum: 0 - description: Rows in status `approved` in the whole bank, not on this page. - terms: - type: array - items: { $ref: '#/components/schemas/BankTerm' } + ⚠ **The window is in chapter NUMBERS, which are not keys** (see `Chapter.number`), so it + lives in the coordinates of the current `structure_version`: cut the book differently and + the same window covers different text. A client re-reads the bank when the version moves. + + A term is unique by `(book, src, sense, since_chapter, until_chapter)`, so the same `src` + legally arrives as several rows. + until_chapter: + type: [integer, 'null'] + minimum: 1 + description: | + Last chapter the term applies to, or `null` for "to the end". Same coordinates as + `since_chapter`. + + BankPage: + allOf: + - $ref: '#/components/schemas/Page' + - type: object + description: | + **The aggregates below describe the WHOLE bank and ride on the FIRST page only** — any + response to a request with no `cursor`, a delta read included; absent on later pages. A + client takes them from the first page it read, which puts them at the same moment as the + oldest rows — the moment the whole walk is stamped with (see `Revision`). + required: [structure_version, terms] + properties: + structure_version: { $ref: '#/components/schemas/StructureVersion' } + total: + type: integer + minimum: 0 + description: Rows in the whole bank, not on this page. + signed: + type: integer + minimum: 0 + description: | + Rows in status `approved` in the whole bank. Distinct from the counters below: a row + can be decided and NOT signed, because declining is also a decision. + pending_decisions: + type: integer + minimum: 0 + description: | + How many proposed terms still have no decision. + complete: + type: boolean + description: | + The set is complete. The stop clears ONLY on a complete set, so a client shows + "N of M decided" and does not offer to continue while this is `false`. + terms: + type: array + items: { $ref: '#/components/schemas/BankTerm' } BankDecision: type: object description: | - A decision on one proposed term. `dst` is mandatory and non-empty for `promote`: the - engine refuses a signed term with an empty translation on the next run, because such a - term matches nothing yet reads as an intended rendering. + A decision on one proposed term. `dst` is mandatory and non-empty for `approve`: a signed + term with an empty translation matches nothing yet reads as an intended rendering. required: [term_id, action] if: properties: - action: { const: promote } + action: { const: approve } required: [action] then: required: [dst] @@ -1162,11 +1772,13 @@ components: term_id: { $ref: '#/components/schemas/Id' } action: type: string - enum: [promote, decline] - description: '`promote` — accept the term (with a translation in `dst`); `decline` — reject it.' + enum: [approve, decline] + description: '`approve` — take the term into the book (with a translation in `dst`); `decline` — leave it out.' dst: type: string - description: Translation. Required and non-empty when `action` is `promote`. + description: | + Translation. **Required and non-empty when `action` is `approve`** — in words as well as + in the constraint above, because a generator ignores `if`/`then`. BankDecisionsRequest: type: object @@ -1175,10 +1787,16 @@ components: decisions: type: array minItems: 1 + maxItems: 1000 + description: | + Decisions to record, bounded like every other collection here. items: { $ref: '#/components/schemas/BankDecision' } BankDecisionsResult: type: object + description: | + Receipt of a submission: the same facts the bank read answers, so a client updates its + screen without a second call. required: [revision, pending_decisions, complete] properties: revision: { $ref: '#/components/schemas/Revision' } @@ -1188,92 +1806,114 @@ components: description: How many proposed terms still have no decision. complete: type: boolean - description: | - The set is complete. The stop clears ONLY on a complete set, so the screen must show - "N of M decided" and must not offer to resume while this is `false`. + description: The set is complete and the stop can be cleared. RunRequest: type: object - required: [verify_bank, ceiling_chapters] + required: [stop_for_signing, ceiling_chapters] properties: - verify_bank: + stop_for_signing: type: boolean - description: Stop for bank signing before the final pass. + description: | + Stop when the book's terms are ready and wait for them to be signed; without it the + unsigned bank is carried forward marked as unverified. ceiling_chapters: type: integer minimum: 1 description: | - Ceiling of THIS run, in chapters, within the bounds returned by + Limit of THIS run, in chapters, within the bounds from `GET /books/{bookId}/run-options`. - Required: a run started without a declared ceiling would spend past the limit the user - is entitled to set before it begins rather than learn about afterwards. `0` is not a - legal value — a run with a zero ceiling does not start, so it is not offered. + Required: a run started without a declared limit would spend past the boundary the user + is entitled to set BEFORE it begins rather than learn about after. `0` is not legal. RunOptions: type: object - required: [ceiling] + required: [ceiling, blocked] properties: ceiling: { $ref: '#/components/schemas/CeilingBounds' } + blocked: + oneOf: + - $ref: '#/components/schemas/Blocked' + - type: 'null' + description: | + Why the scale is smaller than the account could otherwise afford, or `null`. Without it + an account's second book shows a shrunken scale with no way to learn that its own first + book is the reason. + + Blocked: + type: object + description: | + What is holding the scale down, and which book is doing it. + required: [code, book_id] + properties: + code: + type: string + description: | + `credit_held` — another book of this account has a run holding the credit, released when + that run settles. A client MUST tolerate an unknown value under a minor bump and show a + neutral "something else is using the balance" state. + enum: [credit_held] + book_id: + $ref: '#/components/schemas/Id' + description: | + The book that holds it — an id, so a client that wants to name it reads that book's + card, which is also where the user can act. CeilingBounds: type: object description: | - Bounds of the run-ceiling scale, in CHAPTERS. The chapters → money conversion lives on the - platform and is not exposed here in any form. + Bounds of the run-limit scale, in CHAPTERS. The conversion to money lives on the platform and + is not exposed here in any form. - `max_chapters` is what the account can still spend, already clamped to what is left of the - book. A client MUST NOT clamp it again. + `max_chapters` is what the account can still spend, ALREADY clamped to what is left of the + book; a client MUST NOT clamp it again. ⚠ A quantity, not arithmetic: a hold is a debit when + taken, so a running balance already excludes the holds open against it. - ⚠ A quantity, not arithmetic: a hold is a debit when it is taken, so a running balance - already excludes the holds open against it, and subtracting them a second time would halve - the scale. - - `max_chapters` of `0` means no run can start at all — the client shows the exhausted state - instead of a scale. Zero is never selectable. + `max_chapters: 0` means no run can start — the client shows the exhausted state instead of a + scale, and `RunOptions.blocked` may say what is holding it. required: [min_chapters, max_chapters, default_chapters] properties: min_chapters: type: integer minimum: 1 - description: Smallest ceiling that can be started. + description: Smallest limit that can be started. max_chapters: type: integer minimum: 0 - description: Largest ceiling that can be started; `0` when none can. + description: Largest limit that can be started; `0` when none can. default_chapters: type: integer minimum: 0 description: | - Pre-selected value. The platform owns it because the choice is product policy — a client - picking it would decide "spend everything" or "one chapter" on its own. `0` only when - `max_chapters` is `0`. + Pre-selected value, owned by the platform because the choice is product policy. `0` only + when `max_chapters` is `0`. Usage: type: object description: | State of the credit balance. No window, no `resets_at`, no sums — see `GET /usage`. - required: [state, remaining_percent] + required: [state, remaining_percent, halt_reason] properties: state: type: string description: | `ok` · `low` the threshold at which the interface warns · `exhausted` nothing left. The - threshold itself belongs to the platform and is not on the wire: a client that computed - it from the percentage would carry a second copy of the policy. + threshold is the platform's and is not on the wire: computing it from the percentage + would be a second copy of the policy. enum: [ok, low, exhausted] remaining_percent: type: integer minimum: 0 maximum: 100 description: Share of the account's grants still available. A percentage, never an amount. - paused_reason: + halt_reason: oneOf: - - $ref: '#/components/schemas/PausedReason' + - $ref: '#/components/schemas/AccountHaltReason' - type: 'null' description: | - Set when the account itself is in a halted state; `null` otherwise. The same value - travels per-run as `Run.paused_reason`. + Set when the ACCOUNT is halted, `null` otherwise. Named and typed apart from + `Run.paused_reason` on purpose: only reasons of the account's own level appear here. ExportRequest: type: object @@ -1281,20 +1921,53 @@ components: properties: format: type: string - description: Export format; the set of formats is stage S7 work. + minLength: 1 + description: | + One of `export_formats` from `GET /capabilities`; a format outside that set is `400`. Export: type: object - required: [id, ready] + description: | + A built copy of the book, behind a link. + required: [id, revision, state, format, expires_at, failure_code, url] properties: id: { $ref: '#/components/schemas/Id' } - ready: { type: boolean } - url: + revision: { $ref: '#/components/schemas/Revision' } + state: type: string + description: | + `pending` being built · `ready` downloadable · `failed` the build ended in an error · + `expired` it was built and the link has lapsed. A state and not a boolean: a boolean + merges three situations into "not ready" and a poll on it never ends. + enum: [pending, ready, failed, expired] + format: + type: string + minLength: 1 + description: | + The format asked for, echoed back — without it a client holding two export addresses + cannot tell which is which. + expires_at: + type: [string, 'null'] + format: date-time + description: | + When the link stops working, or stopped: set once the artifact exists — in `ready` and + in `expired` — and `null` in `pending` and `failed`. + failure_code: + type: [string, 'null'] + minLength: 1 + description: | + Machine reason when `state` is `failed`, `null` otherwise; the phrase is the client's. + Not enumerated — how an export can fail depends on formats that do not exist yet. It + becomes an enum with the first built format. Carrier: research/28 §2 (Б-4). + url: + type: [string, 'null'] format: uri description: | - Link to the finished export. Served to the authenticated owner only and never - indexed. + Where to download it from; `null` unless `state` is `ready`. + + **Minted for THIS response and for the authenticated owner**, never indexed, on the same + origin as this API, and expiring at `expires_at`. A browser NAVIGATES to it: it is a + download, not a call. EventEnvelope: type: object @@ -1305,140 +1978,338 @@ components: |---|---|---| | `hello` | `EventHello` | always the first frame | | `status` | `EventStatus` | product status changed | - | `progress` | `EventProgress` | counters moved | - | `chapter` | `EventChapter` | a chapter's progress changed | + | `progress` | `EventProgress` | the segment counter moved | + | `chapter` | `EventChapter` | a chapter's own progress changed | | `note` | `EventNote` | a note appeared | - | `bank` | `EventBank` | the bank changed or a signing stop occurred | - | `ceiling` | `EventCeiling` | the run was halted by a ceiling | + | `bank` | `EventBank` | the bank changed, or a signing stop happened | | `resync_required` | `EventResyncRequired` | resuming the stream is impossible | + | `end` | `EventEnd` | nothing further will arrive on this stream | - The frame `id` carries the book's revision — the same counter every book-scoped read - carries, so a frame and a read can be ordered against each other. One transaction produces - one revision but possibly SEVERAL frames, which is why catch-up reads `>=` and not `>` - (see `Revision`). - - **The server MAY COALESCE frames**, and a client MUST tolerate counters that jump: a run - over 9500 units would otherwise be an unbounded source of renders. A client therefore must - not animate from its previous value as though every step had arrived, and must not treat a - skipped number as a lost frame. - required: [event, data] + **`id` and `revision` are different numbers.** The frame's `id` is a position in the BOOK's + event history and the only thing a client does with it is send it back as `Last-Event-ID`; + the book's `revision` travels inside `data` and is what a frame and a read are ordered + against. + required: [event, id, data] properties: - event: { type: string } + event: + type: string + description: Frame name; dispatch on it, per the table above. + enum: [hello, status, progress, chapter, note, bank, resync_required, end] + id: + type: string + pattern: '^[0-9]+$' + description: | + Position in the BOOK's event history: a decimal integer, increasing. Per BOOK and not + per connection — `Last-Event-ID` must mean the same thing however the frame was carried. + + **Only history frames consume a number.** `hello`, `resync_required` and `end` belong to + the CONNECTION, not to the book: each carries the id of the last history frame and + consumes none of its own, so the same id legally appears more than once in one stream. A + client stores the id it last saw and sends it back; it never counts with it. + + **A gap is legal** — coalescing removes frames, and a client MUST NOT read a skipped + number as a lost frame. data: description: | - Frame payload. Schemas are listed as a union rather than tied by a discriminator: - `event` lives in the SSE frame, not inside `data`, so an OpenAPI discriminator does - not apply. Dispatch by event name, per the table above. - oneOf: + Frame payload. `anyOf` and not `oneOf`: dispatch is by the event NAME, and two frames + legally carry the same shape. + anyOf: - $ref: '#/components/schemas/EventHello' - $ref: '#/components/schemas/EventStatus' - $ref: '#/components/schemas/EventProgress' - $ref: '#/components/schemas/EventChapter' - $ref: '#/components/schemas/EventNote' - $ref: '#/components/schemas/EventBank' - - $ref: '#/components/schemas/EventCeiling' - $ref: '#/components/schemas/EventResyncRequired' + - $ref: '#/components/schemas/EventEnd' + + EventBase: + type: object + description: | + The two book-scope numbers every frame carries. `revision` orders the frame against a read. + `structure_version` tells a client the book was cut again — the moment its pair anchors stop + being valid and its tree, bank windows and cursors must be read afresh; without it on every + frame that moment is unobservable. + required: [revision, structure_version] + properties: + revision: { $ref: '#/components/schemas/Revision' } + structure_version: { $ref: '#/components/schemas/StructureVersion' } EventHello: - type: object - description: Version handshake. A client that does not support the major version closes the stream. - required: [contract, run_id, revision] - properties: - contract: - type: string - description: Contract version, e.g. `0.1.0`. - run_id: { $ref: '#/components/schemas/Id' } - revision: { $ref: '#/components/schemas/Revision' } + allOf: + - $ref: '#/components/schemas/EventBase' + - type: object + description: | + The handshake, always first. A client generated against another version closes the + stream and tells the user — while the major is `0`, a differing MINOR counts. + required: [contract] + properties: + contract: + type: string + description: Contract version this deployment serves, e.g. `0.3.0`. + examples: ['0.3.0'] EventStatus: - type: object - description: | - Product status changed. `paused_reason` travels with it so that a pause is actionable - without a second read — the frame that announces the stop is exactly the moment the screen - has to say why. - required: [status, paused_reason] - properties: - status: { $ref: '#/components/schemas/BookStatus' } - paused_reason: - oneOf: - - $ref: '#/components/schemas/PausedReason' - - type: 'null' + allOf: + - $ref: '#/components/schemas/EventBase' + - type: object + description: | + Product status changed. All three machine reasons travel with it, so a stop, a refusal + and a failure are actionable without a second read; each is `null` unless its own status + is the one being announced. + required: [status, paused_reason, reject_reason, failure_reason] + properties: + status: { $ref: '#/components/schemas/BookStatus' } + paused_reason: + oneOf: + - $ref: '#/components/schemas/PausedReason' + - type: 'null' + reject_reason: + oneOf: + - $ref: '#/components/schemas/RejectReason' + - type: 'null' + failure_reason: + oneOf: + - $ref: '#/components/schemas/RunFailureReason' + - type: 'null' EventProgress: - type: object - required: [progress] - properties: - progress: { $ref: '#/components/schemas/Progress' } + allOf: + - $ref: '#/components/schemas/EventBase' + - type: object + description: The segment counter moved. Applied as it is; no read follows. + required: [progress] + properties: + progress: { $ref: '#/components/schemas/Progress' } EventChapter: - type: object - required: [chapter_id, units_done, note_count] - properties: - chapter_id: { $ref: '#/components/schemas/Id' } - units_done: { type: integer, minimum: 0 } - note_count: { type: integer, minimum: 0 } + allOf: + - $ref: '#/components/schemas/EventBase' + - $ref: '#/components/schemas/ChapterProgress' EventNote: - type: object - description: | - A note appeared. Depends on the event emitter — the engine does not emit per-unit notes - mid-run today (companion §3). - required: [note] - properties: - note: { $ref: '#/components/schemas/Note' } + allOf: + - $ref: '#/components/schemas/EventBase' + - type: object + description: | + A note appeared, and the frame CARRIES it — with an identity of its own it is a delta a + client can apply, which is why it must never be coalesced or dropped. + required: [note] + properties: + note: { $ref: '#/components/schemas/Note' } EventBank: - type: object - required: [total, signed, pending_decisions] - properties: - total: { type: integer, minimum: 0 } - signed: { type: integer, minimum: 0 } - pending_decisions: { type: integer, minimum: 0 } - - EventCeiling: - type: object - description: | - The run hit a ceiling and halted. **Carries no figures** — the fact of the stop, not a - sum: money does not appear in the MVP interface at all. The resulting status is `paused`, - never `failed`: the stop is resumable. Depends on the event emitter (companion §3). - - Which ceiling — the account's credit or the run's own `ceiling_chapters` — is not - distinguished by this frame; whether the two need separate `paused_reason` values is open, - companion §4 (K-13). - required: [halted] - properties: - halted: { type: boolean } + allOf: + - $ref: '#/components/schemas/EventBase' + - type: object + description: | + The bank changed, or a signing stop happened. The counters are the delta a screen header + needs; the ROWS are read with `after_version` set to the revision the client last + applied. + required: [total, signed, pending_decisions, complete] + properties: + total: { type: integer, minimum: 0 } + signed: { type: integer, minimum: 0 } + pending_decisions: { type: integer, minimum: 0 } + complete: { type: boolean } EventResyncRequired: - type: object - description: | - The server cannot resume the stream from the presented `Last-Event-ID`. The client MUST - re-read snapshots. Replaying history is forbidden — a one-shot event such as `note` would - be lost silently. - required: [reason] - properties: - reason: - type: string - description: Product-level reason; carries no internals. + allOf: + - $ref: '#/components/schemas/EventBase' + - type: object + description: | + The server cannot resume from the presented `Last-Event-ID`, or the book's collections + were replaced wholesale. The client MUST re-read what it holds in full: a delta cannot + express a deletion. + + EventEnd: + allOf: + - $ref: '#/components/schemas/EventBase' + - type: object + description: | + Nothing further will arrive: no run is live and no intake is in flight. The client closes + and does NOT reconnect automatically; it opens a new stream when it has a reason to watch + again. Problem: type: object description: | - Error per RFC 9457. + Error, per RFC 9457 with the extension members below. - ⚠ **Neither `title` nor `detail` ever carries engine text.** The engine's own detail strings - read like "CJK leak in the ru output: 第一节", which exposes how the pipeline works. + **The machine identifier is `code`.** `type` is `about:blank` on every response and carries + no information: this deployment serves no problem-type documents. - The constraint applies to BOTH fields because both are shown: a client has nothing else to - put on the screen when a call fails, so a `title` written for a developer becomes the - sentence the reader gets. `title` is the CLASS of the failure, `detail` the specific - sentence; both are product language, and either may be empty. - required: [type, title, status] + ⚠ **`title` and `detail` are written for a DEVELOPER and a log, and a client MUST NOT show + either to a user.** They are English and will not be translated. The sentence the user reads + is drawn by the CLIENT from `code`, in the language of the interface — a neutral phrase for a + code it does not know. Neither field ever carries text from inside the translation machinery. + + **Extension members are defined per `code`** (RFC 9457 §3.2) and are absent where the code + does not define them — one of the two places here where absence means "does not apply": + + | member | carried by | + |---|---| + | `errors` | `invalid_request` | + | `cause` | any code with a narrower cause to give | + | `blocked` | `ceiling_unavailable` | + | `localized` | codes whose cause cannot be enumerated | + required: [type, title, status, code, request_id] properties: - type: { type: string, format: uri } + type: + type: string + format: uri + description: Always `about:blank`. See above. title: type: string - description: Product phrase naming the class of failure. Shown to the user as-is. + minLength: 1 + description: | + Short developer-facing name of the failure, for a log. Never empty, never shown. status: { type: integer } - detail: { type: string } - instance: { type: string, format: uri-reference } + detail: + type: string + description: Developer-facing sentence about THIS occurrence, for a log. Never shown to a user. + code: { $ref: '#/components/schemas/ErrorCode' } + request_id: + type: string + minLength: 1 + description: | + Identifier of the request that failed, echoed on every response as `X-Request-Id`. **A + client MAY show it**: it identifies a request, never a person, and an error screen + without it makes a user's report unsearchable. + cause: + $ref: '#/components/schemas/ErrorCause' + description: | + Narrower cause within `code`, when there is one to give. + errors: + type: array + description: | + Which parts of the request were wrong. Carried by `invalid_request` and possibly empty — + a request can be unreadable as a whole. + items: { $ref: '#/components/schemas/ErrorItem' } + blocked: + $ref: '#/components/schemas/Blocked' + description: | + Carried by `ceiling_unavailable` when another book of the account holds the credit — the + same shape `RunOptions` answers. + localized: + $ref: '#/components/schemas/LocalizedMessage' + description: | + A phrase written by the SERVER, to be shown as it is — the single exception, for causes + that cannot be enumerated in advance. No code in this version carries it. Carrier: + research/28 §8 п.4. + + ErrorCode: + type: string + description: | + Root reason a request failed: stable, closed for this version, and the only thing a client + dispatches on. A narrower cause travels in `cause`, which is NOT closed — that split is what + lets a new case appear without breaking a client, so a client MUST match on `code` first. + + Each code names its status. **`500` is deliberately not enumerated on any operation** — it can + answer any of them and is not something a client branches on — but it carries `internal_error` + in the same shape as every other error. + + - `invalid_request` (400) — the request could not be read, or violates the declared form. `errors` + says which part. This is the code the intake answers when a part arrives after the file, + when a field is longer than this deployment reads, when the language codes are malformed + or name a pair this deployment cannot translate; + - `unauthenticated` (401) — no live session; + - `forbidden` (403) — the marker header of the cookie scheme was missing on a request + presented by cookie, or the request came from an origin this deployment does not accept; + - `not_found` (404) — no such object, or one this account may not see, or a path this + deployment does not serve; + - `gone` (410) — the object existed and does not any more, and its identifier will not be + reissued. Told apart from `not_found` because the remedy differs: re-read the collection, + rather than check the address; + - `request_timeout` (408) — the body did not arrive whole in time. Retrying is the remedy; + - `payload_too_large` (413) — over `intake_max_bytes`; + - `run_in_flight` (409) — this book is already being translated; + - `book_not_ready` (409) — the book cannot be translated yet: it is still arriving, still + being cut, or was rejected; + - `run_not_stoppable` (409) — this run is not running; + - `run_not_resumable` (409) — this run cannot be continued. `cause.code` says why: + `bank_decisions_incomplete` — terms are still undecided; `ceiling_reached` — the run stopped + at its limit, and the remedy is a NEW run with a larger one, not this call; + - `ceiling_unavailable` (409) — the limit asked for cannot be started. `cause.code`: `bounds_moved` + — the bounds changed between the read and this call; `credit_held` — the account's credit + is held elsewhere, and `blocked` names the book holding it; + - `idempotency_conflict` (409) — an `Idempotency-Key` was re-used. `cause.code`: + `key_reused` for a different request under the same key, `key_in_flight` for one that is + still running, and then `Retry-After` says how long to wait; + - `content_refused` (400) — the service will not do this work. **One coarse code for a whole + class** and deliberately so: it does not say which check refused, does not vary between + attempts, and carries neither `cause` nor `errors`. A client shows one neutral phrase and + does not invite a retry, and the server bounds how many times one account may try — that + bound is the server's and is not on the wire. A refusal of a whole BOOK is not reported + here at all: it is a state of the book, `rejected` with `reject_reason: content_refused`; + - `service_unavailable` (503) — the deployment cannot do this right now; + - `internal_error` (500) — a defect on our side. Nothing about it is actionable by a client + beyond quoting `request_id`. + enum: + - invalid_request + - unauthenticated + - forbidden + - not_found + - gone + - request_timeout + - payload_too_large + - run_in_flight + - book_not_ready + - run_not_stoppable + - run_not_resumable + - ceiling_unavailable + - idempotency_conflict + - content_refused + - service_unavailable + - internal_error + + ErrorCause: + type: object + description: | + The second level of the code. Its vocabulary is NOT closed and grows without a minor bump, so + a client that does not recognise one falls back to the root `code` and loses only precision. + required: [code] + properties: + code: + type: string + minLength: 1 + description: Narrower cause within the root code. + examples: ['bank_decisions_incomplete'] + + ErrorItem: + type: object + description: | + One thing wrong with the request — used to mark a field on the form; the sentence is drawn + from the code as everywhere else. + required: [pointer, code] + properties: + pointer: + type: string + minLength: 1 + description: | + JSON Pointer to the offending member, or `/` naming a form part. A cause with no + field — a form with too many parts — is reported by the root code alone. + examples: ['/source_lang'] + code: + type: string + minLength: 1 + description: | + `missing` · `missing_or_late` (absent, or sent after the file) · `malformed` · + `too_long` · `unsupported_pair` · `out_of_range`. Not closed, like `cause.code`. + examples: ['missing_or_late'] + + LocalizedMessage: + type: object + description: | + A phrase produced by the server, to be shown as it is — only where the client cannot hold it. + required: [locale, message] + properties: + locale: + type: string + minLength: 2 + description: BCP 47 tag of the language the message is written in. + examples: ['ru'] + message: + type: string + minLength: 1 + description: The phrase, ready to show. diff --git a/docs/archive/reports/CONTRACT_BATCH_0.3.0_REPORT.md b/docs/archive/reports/CONTRACT_BATCH_0.3.0_REPORT.md new file mode 100644 index 00000000..a01b17fb --- /dev/null +++ b/docs/archive/reports/CONTRACT_BATCH_0.3.0_REPORT.md @@ -0,0 +1,594 @@ +# Отчёт: батч 0.3.0 — ломающая правка контракта API v0 + +> Сессия по промту `docs/CONTRACT_BATCH_SESSION_PROMPT.md` (выдан оркестратором №17 16.08.2026 по +> ратификации D39.138 п.2/п.3, строка 183). Носитель заказа — `docs/research/28-contract-review.md` +> §5 · §5а · §5б · §8 · §8а + ревью-шапка приёмки; при расхождении с ней побеждала нота D39.138. +> +> **Зона записи — три файла, других не тронуто:** `docs/architecture/14-api-contract/openapi.yaml` · +> `docs/architecture/14-api-contract/README.md` · этот отчёт. Зеркало +> `frontend/docs/api-contract/openapi.yaml` НЕ тронуто (зона фронта заморожена, D39.136 п.2) — +> расхождение канона и зеркала на момент сдачи известно и ратифицируется лендингом. Git не трогался: +> ни `add`, ни `commit`, ни `reset`, ни `checkout`. Дерево оставлено незакоммиченным. + +## 0. Что сделано, одним экраном + +| | было (0.2.3) | стало (0.3.0) | +|---|---|---| +| операций / путей | 16 / 15 | **20 / 17** | +| строк файла | 1445 | **2290** | +| форма (структурные строки) | 828 | **1336** (+61 %) | +| проза (строки внутри описаний) | 472 | **757** (+60 %) | +| доля прозы | 36 % | **36 %** | +| причина отказа на проводе | английская фраза, показываемая «as-is» | машинный `code` (15 корневых) + `cause` + `request_id` + `errors[]` | +| прогресс | `{draft{done,total}, edit{done,total}}` по всей книге, в юнитах | ОДНА полоса до ближайшей остановки, в главах, знаменатель — купленный объём | +| поток | `GET /runs/{runId}/events` | `GET /books/{bookId}/events` + кадр `end` + `204` | +| условные чтения | нет ни одного | `ETag`/`If-None-Match`/`304` на 7 чтениях + дельта `?after_version=` | +| «что умеет деплой» | нигде | `GET /capabilities` | + +Команды, которыми получены числа: +``` +grep -c 'operationId:' openapi.yaml # 16 → 20 +grep -cE '^ /' openapi.yaml # 15 → 17 +wc -l openapi.yaml # 1445 → 2290 +git show HEAD:...openapi.yaml > /tmp/old.yaml # снимок «было» для всех сверок +``` +Проза/форма меряны одним скриптом по обоим файлам (лист-скан: непустая строка внутри блочного +скаляра `description:|`/`summary:|` — проза, остальные — форма; пустые не считаются). Число «43 %» из +research/28 §5а получено другой меркой и с этими двумя не сравнивается — сравнимы только они между +собой. + +**Форма выросла на 61 %, проза — на 60 %, доля прозы не сдвинулась.** Это и есть ответ на вопрос §8 +п.3 «не переусложнён ли контракт»: батч добавил четыре операции и шестнадцать схем, и текст вырос +ровно пропорционально поверхности, а не быстрее неё. Как этого добились — §4.4; первая редакция +батча давала 43 % и 2587 строк, то есть проза росла быстрее формы, и это было исправлено отдельным +проходом. + +## 1. Записка-план: каждый ID заказа с диспозицией + +Комплектность сверялась механически против носителя, не по памяти. + +### 1.1. Ядро — research/28 §5, пункты 0–4 + +| ID | Заказ | Диспозиция | Где | +|---|---|---|---| +| **§5.0 / Б-0** | `Progress` в один счётчик до остановки | **ИСПОЛНЕНО.** `Progress{done,total,eta_seconds}` в ГЛАВАХ, сегментная логика, знаменатель — купленный объём; `Counter` снят; `Progress` переехал с `Book` на `Run` | `§Progress`, `§Run.progress` | +| | `finalizing` свернуть | **ИСПОЛНЕНО.** Значение снято из `BookStatus`; в новый `RunStatus` не попало | `§BookStatus`, `§RunStatus` | +| | `verify_bank` → `stop_for_signing` без «before the final pass» | **ИСПОЛНЕНО** на `RunRequest` и на `Run` | `§RunRequest`, `§Run.stop_for_signing` | +| | `TermOrigin` снять/переименовать | **ПЕРЕИМЕНОВАНО, ось сохранена:** `seed·ruby·mined` → `given·annotated·found` | `§TermOrigin` | +| | `TermStatus` ОСТАВИТЬ осью, переименовать движковые значения (эррата 16.08-г) | **ИСПОЛНЕНО.** Ось на месте; `auto·draft·approved` → `proposed·in_progress·approved` (переименован и `draft` — он читается как имя волны и ловится анти-утечка-грепом) | `§TermStatus` | +| | `Unit` описать без «edit unit» и «1.9 на главу» | **ИСПОЛНЕНО** | `§Unit` | +| | из ВСЕХ описаний вычистить конвейерные слова | **ИСПОЛНЕНО**, проверено грепом (§4.2) | весь файл | +| | счётчик ГЛАВЫ той же сегментной логикой | **ИСПОЛНЕНО.** `ChapterProgress.units_done` — «в текущем сегменте», с явным «иначе дерево читало бы ноль всю первую волну» | `§ChapterProgress.units_done` | +| | завести гейт на утечку | **ЧАСТИЧНО — по границе зон, как заказано промтом §3.** Правило записано в компаньон как ревью-вопрос каждой правки со списком грепа; ТЕСТ — половина фронта, носитель — пинг фронту 16.08, исполнение при разморозке | компаньон §5 | +| **§5.1 / Б-1** | модель ошибок, вариант B | **ИСПОЛНЕНО ПЕРВОЙ** (порядок §3 промта соблюдён: словарь кодов определён до формулировок Б-2/Б-3/Б-8/Б-14а). `code` (15 корневых, закрытый) + `cause{code}` (второй уровень, НЕ закрытый) + `request_id` + `errors[]{pointer,code}` + `localized` + `blocked`; `title`/`detail` объявлены developer-facing и неотображаемыми, `title` получил `minLength:1` | `§Problem`, `§ErrorCode`, `§ErrorCause`, `§ErrorItem` | +| | два класса конкретности §8а | **ИСПОЛНЕНО.** Класс 1 — максимальная конкретика (`errors[]` + `cause`); класс 2 — ОДИН код `content_refused` без `cause`, без `errors`, без вариации между попытками; тем же правилом заведено значение `RejectReason.content_refused` (К-9) | `§ErrorCode.content_refused`, `§RejectReason` | +| **§5.2 / Б-15** | `X-TM-Client` машинно · 403 · `WWW-Authenticate` · множество методов · `servers.url` · судьба bearer | **ИСПОЛНЕНО ПОЛНОСТЬЮ.** Заголовок — параметр на 9 небезопасных операциях; `403` — компонент-ответ; `401` несёт обязательный `WWW-Authenticate`; методы приведены к коду и RFC (`OPTIONS` освобождён); `servers[0].url: /v0`; bearer ОСТАВЛЕН с явной записью «сервер принимает, выдать нечем» + носитель | `§ClientHeader`, `§responses.Forbidden`, `§responses.Unauthorized`, `§sessionCookie`, `servers`, `§bearerToken` | +| **§5.3 / Б-2** | `GET /capabilities` | **ИСПОЛНЕНО.** `contract_version` · `language_pairs[]` со статусом · `intake_max_bytes` · `export_formats[]` · `page_size_default`. Один плоский ответ на деплой (предупреждение судьи о соразмерности учтено: ни ETag-мультитенантности, ни пер-аккаунтных оверрайдов) | `/capabilities`, `§Capabilities` | +| **§5.4 / Б-7а** | три устаревших предупреждения снять; resume-абзац переписать | **ИСПОЛНЕНО.** «канала банка нет», «движок не эмитит note», «зависит от эмиттера» — сняты; `resume` после потолка → 409 `cause.code: ceiling_reached` + названо работающее лечение (новый прогон с бОльшим потолком) в `startRun` И в `resumeRun` | `/runs/{runId}/resume`, `/books/{bookId}/runs`, компаньон §3 | + +### 1.2. Пока дёшево (Ц0) — §5, пункты 5–10 + +| ID | Заказ | Диспозиция | Где | +|---|---|---|---| +| **§5.5 / Б-6** | перевесить поток на книгу | **ИСПОЛНЕНО** — `GET /books/{bookId}/events`, `streamBookEvents` | `/books/{bookId}/events` | +| | кадры конца разбора и конца прогона | **ИСПОЛНЕНО ИНАЧЕ, чем буквально:** конец разбора — обычная смена статуса на книжном потоке (кадр не нужен, и Б-6(а) сам это говорит: «чинить не кадром, а перевеской»); конец ПОТОКА — новый кадр `end` | `§EventEnd` | +| | `204` при переподключении к завершённому | **ИСПОЛНЕНО** с точной границей: `204` только на запрос С `Last-Event-ID`; без него всегда открывается новый поток | `/books/{bookId}/events` → `204` | +| | развести `id` кадра и ревизию | **ИСПОЛНЕНО.** `id` — позиция потока, форма зафиксирована `^[0-9]+$`; `revision` — в `data` каждого кадра через `EventBase` | `§EventEnvelope.id`, `§EventBase` | +| | правило докачки — одно | **ИСПОЛНЕНО:** короткий живой буфер разрешён, реплей истории запрещён, размер не объявляется | `/books/{bookId}/events` → Reconnect | +| | запрет склейки `note` | **ИСПОЛНЕНО** | `/books/{bookId}/events` → Coalescing | +| | *(отступление)* прогонный поток «оставить узким видом» | **ОТКАЗ, аргументирован** — §3.1 ниже | — | +| **§5.6 / Б-4** | экспорт: `state`, `failure_code`, `expires_at`, эхо формата, правило доступа | **ИСПОЛНЕНО ПОЛНОСТЬЮ** + `Export.revision` (одиночная правка §5) | `§Export` | +| **§5.7 / Б-9+Б-14а** | `id`, `created_at`, обязательная адресация; `pending_decisions`/`complete` в чтение банка | **ИСПОЛНЕНО.** `Note.id`/`created_at`/`chapter_id` обязательны; `unit_id` оставлен необязательным (ровно то, что просил Б-9); банк отвечает `pending_decisions`+`complete`+`total`+`signed` | `§Note`, `§BankPage` | +| **§5.8 / Б-19** | снять запрет клиентской служебной метки СЕЙЧАС | **ИСПОЛНЕНО** | `§Chapter.heading` | +| | `title_raw`, `kind` — формой ЛИБО передачей паку 161 | **ПЕРЕДАНО ПАКУ 161**, аргумент — §3.2 ниже; запись в компаньоне §2.3 | компаньон §2.3, §3 | +| | структурная версия | **ИСПОЛНЕНО (обязательна батчу)** — `structure_version` в `hello`, `ChapterPage`, `UnitPage`, `Book` | `§StructureVersion` и др. | +| **§5.9 / Б-7** | версия в ответах и `hello`; `410`; `If-None-Match`/`304` | **ИСПОЛНЕНО.** `410 Gone` на `listUnits`; условные чтения на 7 операциях | `§responses.Gone`, info §Conditional reads | +| **§5.10 / Б-12** | правило согласования агрегатов со страницами | **ИСПОЛНЕНО:** агрегаты — на ПЕРВОЙ странице; правило согласовано с ревизией обхода (минимум по страницам), которая тоже записана впервые | `§BankPage`, `§Revision` | + +### 1.3. Дёшево и на построенном (Ц1) — §5, пункты 11–16 + +| ID | Заказ | Диспозиция | Где | +|---|---|---|---| +| **§5.11 / Б-3** | запрет молчаливой потери | **ИСПОЛНЕНО:** любая часть после файла → 400 + `errors[]`, «Nothing is lost silently» | `/books` POST | +| | порядок свойств `BookIntake` | **ИСПОЛНЕНО и ПРОВЕРЕНО ГЕНЕРАТОРОМ** (§4.1): `title, source_lang, target_lang, file` | `§BookIntake` | +| | `Idempotency-Key` (семантика — моя) | **ИСПОЛНЕНО**, шесть правил: область (принципал+метод+путь) · повтор → исходный ответ · другие параметры → 409 `key_reused` · параллельный → 409 `key_in_flight` · окно ≥24 ч · длина ≤255 | `§IdempotencyKey` | +| | `Location` на 201 | **ИСПОЛНЕНО** | `/books` POST → 201 | +| **§5.12 / Б-8+Б-13а** | `failure_reason` у прогона | **ИСПОЛНЕНО.** `RunFailureReason: source_unreadable · service_error · interrupted` — спроецировано с полосы отказов движка 10–19 (`config_invalid`/`source_unreadable`/`project_locked`/`schema_mismatch`, `backend/internal/pipeline/refusal.go:29-46`) и ветки `outcome()` платформы; таксономия НЕ выдумана | `§RunFailureReason` | +| | знаменатель прогона в главах | **ИСПОЛНЕНО** — `Progress.total` = купленный объём; плюс `Book.chapters_done` (величина «глав сделано», которой не было вовсе) | `§Progress`, `§Book.chapters_done` | +| **§5.13 / Б-13** | отдельный `RunStatus` | **ИСПОЛНЕНО** (6 значений) | `§RunStatus` | +| | правило старшинства | **ИСПОЛНЕНО** — записано в `BookStatus` | `§BookStatus` | +| | `book_id` в `Run` | **ИСПОЛНЕНО** | `§Run.book_id` | +| | судьба `finalizing` | **СНЯТ** | — | +| **§5.14 / Б-5** | `PATCH /books/{id}` только `title` | **ИСПОЛНЕНО** (merge-patch). ⚠ БЕЗ 409 при живом прогоне, вопреки скобке Б-5 — §3.9 | `/books/{bookId}` PATCH | +| | `DELETE /books/{id}` | **ИСПОЛНЕНО** (204, 409 при живом прогоне) | `/books/{bookId}` DELETE | +| | `GET /runs/{runId}` | **ИСПОЛНЕНО** | `/runs/{runId}` | +| | записать явно: правка метаданных — ОТОБРАЖЕНИЕ | **ИСПОЛНЕНО** дословно, с причиной («конфигурация пишется один раз и не перезаписывается») | `/books/{bookId}` PATCH | +| **§5.15 / Б-10+Б-11** | максимум `limit` и подрезание | **ИСПОЛНЕНО:** `maximum: 1000` (= `maxPage`, `pgstore/books.go:587`) + «CLAMPED DOWN, never answered with the default instead» | `§Limit` | +| | объявленный порядок коллекций | **ИСПОЛНЕНО для всех пяти** (библиотека · главы · юниты · замечания · банк) | описания пяти list-операций | +| | `maxItems` у `decisions` | **ИСПОЛНЕНО** (1000) | `§BankDecisionsRequest` | +| **§5.16 / Б-14** | ОДНО правило кодирования отсутствия | **ИСПОЛНЕНО: правило ЗАПИСАНО** отдельным разделом шапки и применено к `Usage.halt_reason`, `Run.finished_at`/`paused_reason`/`failure_reason`, `Book.character_count`/`reject_reason`, `Progress.eta_seconds`, `Unit.note`, `RunOptions.blocked`, `Export.*` | info §Absence of a value | +| | условная обязательность `Unit.target` | **ИСПОЛНЕНО** — `if/then` + слова (К-11: генератор `if/then` игнорирует) | `§Unit.target` | +| | имена конвертов | **ИСПОЛНЕНО:** `Library/ChapterList/UnitList/NoteList/Bank` → `BookPage/ChapterPage/UnitPage/NotePage/BankPage` на общем `Page` через `allOf` | `§Page` и др. | + +### 1.4. Правки одной строкой — §5, хвост + +| ID | Диспозиция | Где | +|---|---|---| +| Б-17 `TermOrigin` → продуктовый словарь | ИСПОЛНЕНО | `§TermOrigin` | +| Б-18 окно термина | ИСПОЛНЕНО: координаты `structure_version` записаны явно; два смысла нуля разведены на `null` (`minimum: 1`) | `§BankTerm.since_chapter` | +| Б-16 требования деплоя — в примечание | ИСПОЛНЕНО: `HTTP/2 at the edge` и `X-Accel-Buffering` вынесены в компаньон §7; в спеке осталась НАБЛЮДАЕМАЯ норма «поток не буферизуется» + форма heartbeat | `/books/{bookId}/events`, компаньон §7 | +| Б-20 классы стабильности `Id` | ИСПОЛНЕНО (три класса) | `§Id` | +| Б-22 роль имени файла | ИСПОЛНЕНО | `§BookIntake.file` | +| описание `paused_reason` при `null` (К-13-остаток, D39.138 п.4) | ИСПОЛНЕНО: «`null` in every other state» заменено на форму, допускающую `paused` + `null` | `§Run.paused_reason` | +| `Usage.paused_reason` (§4 №18) | ИСПОЛНЕНО: → `Usage.halt_reason` со СВОИМ словарём `AccountHaltReason` (PD-203 развёл уровни), required+nullable | `§AccountHaltReason`, `§Usage.halt_reason` | +| `Export.revision` | ИСПОЛНЕНО | `§Export.revision` | +| кэш-директивы в схему | ИСПОЛНЕНО: `no-store` объявлен на ВСЕХ ответах, как его и ставит `middleware.go:38` | info §Responses common to the whole surface | + +### 1.5. §5а — резка + +| Кандидат §5а | Диспозиция | +|---|---| +| экспорт (2 операции) | **ОТОЗВАНО владельцем** («экспорт будет») — сделан Б-4 | +| `Problem.instance` | **СНЯТО** | +| `Note.unit_id` | **ОТКАЗ, аргументирован** (§3.3) | +| параметр `limit` | **ОТКАЗ, аргументирован** (§3.4) | +| `EventCeiling` | **СНЯТО целиком** | +| нагрузка 4 кадров из 8 | **ЧАСТИЧНО:** сняты `EventHello.run_id` и `EventResyncRequired.reason`; нагрузка `EventNote.note` и счётчики `EventBank` СОХРАНЕНЫ — аргумент §3.5 | +| `Bank.signed` | **ОТКАЗ, аргументирован** (§3.6) | +| слить формально пять конвертов на `allOf` | **ИСПОЛНЕНО** | +| `EventChapter` = подмножество `Chapter` | **ИСПОЛНЕНО:** выделен `ChapterProgress`, `Chapter` и `EventChapter` собраны из него через `allOf` — переименования `id`→`chapter_id` больше нет, клиент не разбирает его руками | +| не доводить до финала `listBankTerms`/`listNotes` | **УЧТЕНО:** словарь ступеней не проектировался (К-6), поля `BankTerm` не добавлялись (D39.136 п.4б), `Note.code` НЕ сделан enum до написания фраз, число страницы замечаний не выдумывалось | +| генезис-проза (semver-история, две апологии RFC 9110, объяснение имени `source`) | **ПЕРЕНЕСЕНО в компаньон**, не удалено (промт §5д) | +| зеркальный дефект `/usage` построен и не читается | **ЗАПИСАНО** в таблицу компаньона §3 | + +### 1.6. §5б шаги 3–4 и Б-11а (заказ ПОВЕРХ §5) + +| Что | Диспозиция | Где | +|---|---|---| +| правило кадра: применимая дельта ЛИБО счётчик + скоуп; не заставлять перечитывать коллекцию | **ИСПОЛНЕНО**, заменило необязательное «сервер МОЖЕТ склеивать» | `/books/{bookId}/events` → What a frame carries | +| `?after_version=` контрактной формой | **ИСПОЛНЕНО** на `/notes` и `/bank`; грунт — колонки `revision` и индексы `notes_book_revision_idx`/`bank_terms_book_revision_idx`, заведённые с комментарием «Delta reads on reconnect» | `§AfterVersion` | +| разводка «норма vs эксплуатационное примечание» (не упереться в Б-16) | **ИСПОЛНЕНО ПОСЛЕ ИСПРАВЛЕНИЯ.** Первая редакция вынесла сжатие ЦЕЛИКОМ в примечание — нарушение D39.138 п.2(д); поймано опровергателем (A1), исправлено: обязанность честить `Accept-Encoding` и запрет сжимать SSE — норма спеки, слой исполнения — примечание. §3.7 | info §Compression, компаньон §7 | + +### 1.7. §5(а)–(д) промта — компаньон + +| Подпункт | Диспозиция | +|---|---| +| **(а)** снять три опровергнутых утверждения | **ИСПОЛНЕНО.** Все три сняты и названы в ревью-шапке компаньона поимённо, с указанием, чем именно каждое было ложно. Ответ §5 не «поправлен», а ПЕРЕПИСАН: старый ответ приведён как то, что реально давал 0.2.3 (проверено исполнением), новый — как следствие снятия фаз с провода | +| **(б)** таблица «чтение → источник → строка бэклога» + правило «предупреждение несёт номер строки» | **ИСПОЛНЕНО** — компаньон §3, 19 строк; правило поставлено над таблицей | +| **(в)** провенанс-классы ✓/◆/○ под 0.3.0 | **ИСПОЛНЕНО** — компаньон §0, «Пере-разметка 0.3.0»: четыре класса сдвинуты, `Progress` ✓→◆ с объяснением, почему прежняя ✓ и была дефектом | +| **(г)** приложение А на словарь кодов Б-1, слова не заполнять | **ИСПОЛНЕНО.** Карта переведена на «причина движка → КОД контракта → фраза ⬜»; класс 2 схлопнут в один код (нормативно, §8а); коды помечены ◆ и в спеку как enum НЕ занесены. Добавлено приложение А-2 — карта кодов ОШИБОК, заполненная целиком с грунтом `file:line` на каждый | +| **(д)** генезис-проза из спеки — в компаньон | **ИСПОЛНЕНО** (27 блоков перенесено/сжато) | + +## 2. Решения, которые я принял сам (промт §4) + +### 2.1. Двухуровневый код: `cause` вложенным объектом + +Ратифицировано «стабильный корневой + расширяемый ВЛОЖЕННЫЙ». Рассматривал плоский `subcode` +(проще на один объект) — выбрал вложенный: (а) буквально соответствует ратификации, не требуя пинга; +(б) граница «внутри версии / вне версии» видна структурно — всё, что в `cause`, по определению вне +закрытого словаря. Глубина ровно одна: рекурсии Microsoft-овского `innererror` нет и не нужна. + +### 2.2. Счёт полосы — в ГЛАВАХ + +Три довода в компаньоне §2.5. Коротко: «один счётчик» + «знаменатель — купленный объём» вместе +требуют одной единицы, а купленный объём объявлен в главах и другой единицы у него нет (пересчёт в +деньги на провод не идёт — D39.84; в юниты до разбора неизвестен). Возражение «ноль всю первую волну» +снимает сегментная логика, а не единица счёта, и обе величины у платформы уже есть +(`00002_readmodel.sql:102-105`). Новых колонок батч не требует. + +### 2.3. `Note.message` снят, введён `Note.code` + +Вариант B ратифицирован для `Problem`. Замечание — то же самое явление: сервер рисовал бы русскую +фразу, не имея ни `Accept-Language`, ни локалей (грепом ноль), а карта «причина → фраза» объявлена +принадлежащей контракту. Промт §5(г) требует карту «КОД → фраза» — у карты кодов должен быть носитель +кода на проводе. Поэтому `code` обязателен, `message` снят, а enum кодов в спеку НЕ занесён, пока +фразы не написаны (строка 148): иначе схема стала бы второй копией незаписанной карты. +**Спорно; вариант «оставить `message`» отвергнут как сохранение ровно того дефекта, который Ф-61 и +называет.** + +### 2.4. `promote` → `approve` + +Б-0 называет `promote`/`decline` глаголами оператора майнера (они дословно из +`pipeline/mining.go:201`). Плюс `promote` порождал `status: approved` — два слова на один акт. +`decline` оставлен: обычное слово, к именам файлов майнера не привязано. + +### 2.5. Порядок работ + +Б-1 исполнен ПЕРВЫМ (требование §3 промта и §8б): словарь кодов класса 1 выведен перечислением +реальных ветвей платформы ДО того, как писались Б-2 (`unsupported_pair`), Б-3 (`missing_or_late`), +Б-8 (`RunFailureReason`), Б-14а (`bank_decisions_incomplete`) и §8а (`content_refused`). + +## 3. Спорные формы: варианты и выбор + +### 3.1. Прогонный поток НЕ оставлен узким видом + +Б-6 рекомендовал «перевесить на книгу, прогонный оставить узким видом». §5 п.5 (ратифицированный +состав) говорит только «перевесить на книгу». **Выбрал: одна операция.** Второй канал с теми же +кадрами — вторая реализация, второй источник расхождения и вторая поверхность там, где §5а требует +резать. Адресация «кадры этого прогона» выводится клиентом, у которого id прогона уже есть из карточки. +⚠ Это единственное место, где я разошёлся с текстом буллета (не с ратификацией). Если оркестратор +считает иначе — возврат стоит одной операции. + +### 3.2. `title_raw`/`kind` — паку 161, а не формой сейчас + +Промт даёт выбор с требованием аргумента. **Выбрал передачу**, три довода: производителя настоящих +названий нет (строка 160, Этап 0 не построен) · форма узла «глава ↔ фрагмент» и вердикт детекции — +ровно предмет дизайн-пака, заложенная до него форма заморозила бы догадку · добавление аддитивно +(минор), и смены СМЫСЛА `heading` не будет — он и сегодня, и после 160 означает метку из данных книги. +Контр-риск («смена смысла = мажор», контраргумент Б-19) закрыт нормой в спеке: деплою, чей парсер +названий не извлекает, ПРЕДПИСАНО отвечать `null` и ЗАПРЕЩЕНО класть сюда рендеренный порядковый. + +### 3.3. `Note.unit_id` оставлен (отказ от резки §5а) + +Основание резки — «только в фикстуре мока; экраны берут `unit.note` вложенно». Это дословно тот довод, +который **эррата 16.08-г уже опрокинула** на `TermStatus`: «ни один экран не рисует» подменяет «экрана +ещё нет» (замечания — S6). Собственная фраза операции: «A note addresses a unit or a whole chapter»; +из плоского списка перейти к МЕСТУ замечания больше нечем. Б-9 просил ровно «`chapter_id` обязателен, +`unit_id` опционален» — исполнено буквально, дефект «замечание, не адресующее ничего» закрыт. + +### 3.4. Параметр `limit` оставлен (отказ от резки §5а) + +§5 п.15 того же заказа требует объявить у `limit` `maximum` и подрезание вместо понижения — у +удалённого параметра максимума не объявишь; два пункта одного носителя противоречат друг другу, и +ратифицированный СОСТАВ (§5) весит больше кандидата на резку (§5а). Плюс «референсный клиент не шлёт +ни разу» — свойство одного клиента, а контракт пишется для второго (`openapi.yaml:564-570` прежней +редакции декларировала именно портируемость). + +### 3.5. Нагрузка кадров `note` и `bank` сохранена (частичный отказ) + +§5а называет её мёртвой, потому что она «передаётся и игнорируется». Причину игнорирования батч +устранил: у замечания появился `id`, и кадр `note` стал ПРИМЕНИМОЙ ДЕЛЬТОЙ — первая ветка правила +Б-11а. Снятие нагрузки вернуло бы перечитывание всего списка, то есть ровно ту болезнь, которую +Б-11а лечит. Счётчики `bank` — вторая ветка того же правила («счётчик + скоуп»), строки читаются +дельтой. Сняты те две нагрузки, что мертвы и после батча: `EventHello.run_id` (поток стал книжным) и +`EventResyncRequired.reason`. + +### 3.6. `Bank.signed` оставлен (отказ от резки §5а) + +Тот же класс довода «нет экрана» (подпись — S5). `total`, `signed`, `pending_decisions` — три +НЕЗАВИСИМЫХ факта: строку можно решить и не подписать (отклонить), поэтому `signed` не выводится из +двух других. + +### 3.9. `PATCH` без 409 при живом прогоне + +Б-5 в скобке рекомендует «409 при живом прогоне». Холодный потребитель показал, что это противоречит +собственному обоснованию операции: если переименование не доезжает до движка и не трогает ничего, что +читает прогон, то конфликтовать не с чем, а клиенту нечего написать в подсказке к задизейбленной на +несколько часов кнопке. **409 снят с `PATCH`.** У `DELETE` он остаётся — там конфликт настоящий: +удалить книгу, против которой идут траты, значит потерять их привязку. Отступление от скобки буллета, +не от ратификации; при несогласии возврат — одна строка. + +### 3.7. Разводка «норма / примечание» по НАБЛЮДАЕМОСТИ + +Норма в спеке — только то, что клиент видит и обязан уметь: `ETag`/`If-None-Match`/`304`, `Vary` на +согласованном представлении, `no-store` на всех ответах, форма heartbeat, форма `id` кадра, запрет +буферизации потока (наблюдаемое требование вместо вендорного `X-Accel-Buffering`). В примечание +компаньона ушло то, что клиент не наблюдает: HTTP/2 на edge, конкретный заголовок прокси, включение +gzip. Так Б-16 (требования деплоя вон из контракта) и §5б шаги 1–2 («записать В КОНТРАКТ») не +сталкиваются: в контракт записана СЕМАНТИКА условных чтений, в примечание — способ развернуть. + +### 3.8. `type` остался `about:blank` + +RFC 9457 §3.1.1 требует использовать `type` как первичный идентификатор. Рассматривал +`urn:textmachine:error:`. **Выбрал `about:blank` + `code`**: URN был бы второй копией `code`, а +URL — обещанием документа, которого мы не серверим. Отклонение названо в спеке вслух. Зато отклонение +0.2.3 от §3.1.3 (`title` показывался пользователю вопреки «advisory … for offline log analysis») +ИСПРАВЛЕНО. + +## 4. Самопроверка исполнением (промт §6) + +### 4.1. Линт и «второй клиент» + +``` +cd frontend && npx spectral lint ../docs/architecture/14-api-contract/openapi.yaml \ + --ruleset .spectral.yaml --fail-severity=warn +→ "No results with a severity of 'warn' or higher found!" EXIT=0 +``` +Прогонялся 6 раз: базовый (на 0.2.3, тоже EXIT=0 — форма команды рабочая и на «до»), после первой +редакции, после де-утечки, после добавления правила отсутствия, после правок по селф-ревью и после +починки дубля ключа, который эти правки внесли (`BankPage.allOf[1].properties` — единственная +поломка, которую линт поймал за всю сессию, и он её поймал). + +``` +cd frontend && npx openapi-typescript ../docs/architecture/14-api-contract/openapi.yaml \ + -o /schema-probe.ts +→ EXIT=0, 2916 строк +``` +Прогонялся трижды: после первой редакции, после де-утечки, после правок по селф-ревью. + +**Класс PD-172 проверен генератором, а не мысленно, и проверен ДВАЖДЫ.** Первый прогон: в генерённом +`BookIntake` порядок стал `title? → source_lang → target_lang → file`, файл ПОСЛЕДНИМ (в редакции +0.2.3 генератор ставил `file` вторым, впереди обоих обязательных языков, и произведённая им форма +получала 400). Второй прогон, после находки F1: `title` сделан обязательным, необязательных членов в +схеме не осталось — `title · source_lang · target_lang · file`, и теперь порядок не зависит от того, +эмитит генератор по `properties` или «сначала required». Проверил также, что `$ref`+`description` не +теряют описание (`Run.book_id`, `Problem.cause`, `Blocked.book_id` — на месте); что `Unit.target` +вышел `target: string` (не опциональным), то есть условная обязательность действительно НЕ доезжает +через `if/then` — К-11 подтверждён на новой схеме и потому продублирован словами; и что три +восстановленных `enum` (`UnitState`, `RejectReason`, `EventEnvelope.event`) вышли закрытыми юнионами. + +### 4.4. Резка прозы — отдельный проход, проверенный типами + +Первая редакция батча дала 2587 строк при доле прозы 43 % (у 0.2.3 — 36 %). То есть я исполнил букву +§5а (вынести названный генезис в компаньон) и провалил её предмет: объяснял ПОЧЕМУ прямо в описании +поля, хотя рядом лежит компаньон ровно под это. + +Проход по всему файлу: в описании остаётся правило и обязанность потребителя, «потому что» уходит в +компаньон либо снимается. Итог — 2290 строк, проза 1011 → 757 (−25 %), доля 43 % → 36 %. + +**Что резалась именно вода, а не норма, проверено двумя способами исполнением:** + +``` +# 1. Генерённые типы до и после резки — с вычеркнутыми JSDoc-комментариями +npx openapi-typescript … -o schema-probe.ts # до +npx openapi-typescript … -o schema-after-cut.ts # после +python: re.sub(r'/\*\*.*?\*/','',t,flags=re.S) на обоих → сравнить +→ TYPE SHAPE IDENTICAL: True +``` +Ни одно поле, тип, `enum`, `required` или `oneOf` не сдвинулись — резка коснулась ТОЛЬКО текста. + +``` +# 2. Множество нормативных утверждений до и после +python: для каждого MUST / MUST NOT / MAY / SHOULD взять модал + 4 следующих слова, сравнить мультимножества +→ 42 → 41 +``` +Единственная потеря — цитата semver §4 («Major version zero … anything MAY change at any time») из +раздела Versioning. Это ВНЕШНЯЯ цитата, а не наше правило; само правило («пока версия 0.x, минор — +полоса ломающих правок») на месте. Правил не потеряно ни одного. + +Что осталось прозой и почему это не вода: самые крупные блоки после резки — словарь `ErrorCode` +(42 строки), протокол потока (30), шапка `info` с четырьмя правилами документа (49), `Problem`, +`Revision`, схема безопасности, значения `RejectReason`/`RunFailureReason`/`BookStatus`. Это и есть +контракт: смысл значения словаря выразить схемой нечем. + +### 4.2. Анти-утечка + +``` +grep -niE "draft|wave|stage names|mined|miner|ruby|finaliz|chunk|langpack|sanitiz|flagged|verdict|prompt|engine|pipeline|glossar|promote|snapshot|CJK" openapi.yaml +→ ровно 1 вхождение: строка 21 — сама формулировка запрета в шапке. +``` +Список составлен из слов, названных Б-0, плюс найденные мной самостоятельно: `glossar` (два слова на +одну сущность — «bank» и «glossary»), `snapshot` (у движка это конкретный артефакт ре-биллинга), +`engine`, `pipeline`, `promote`. Пример `«CJK leak in the ru output: 第一节»`, который в 0.2.3 уезжал в +`frontend/src/api/schema.ts:1047`, снят. + +### 4.3. Код первичен: что перепроверял руками + +Не доверял якорям research/28 — открывал места. Подтвердилось: `fail()` и прямые `WriteProblem` +(`v0.go:238-250,333-345,356,409-423,531,542-574`) · `problem.go:14-28` (`instance` в структуре нет) · +`reqid.go:16,27` (`X-Request-Id` на каждом ответе) · `middleware.go:38` (`no-store` на всех) · +`server.go:99,134` (403 и охраняемый 404) · `csrf.go:51-62` (`OPTIONS` освобождён + карваут bearer) · +`pgstore/books.go:510-511,586-587` (`defaultPage=100`, `maxPage=1000`, понижение до дефолта) · +`pgstore/books.go:677-681` («глав сделано» уже считается в SQL) · `00002_readmodel.sql:102-105,127-142, +158-199` (обе фазовые колонки главы · чеки `target` · `notes.id`/`created_at`/`unit_id` · `exports. +failed_reason`/`ready_at`) · `runs/runs.go:187-231` (`paused` в allowlist старта, `HasLiveRun` по +своей книге) · `reconcile.go:466-527` (`outcome()`: полоса отказов → `failed`) · +`pipeline/refusal.go:29-46` + `cmd/tmctl/main.go:50-59` (полоса 10–19) · `books/books.go:118-121,141, +355-391` (форма кода, имя файла → титул, расширение → читатель) · +`frontend/src/api/vocabulary.ts:159-163` (шов ПОТРЕБЛЯЕТ `TermStatus` — эррата 16.08-г верна) · +`frontend/src/api/client.ts:99-106` (ревизия обхода = минимум). + +**Одно расхождение с якорями отчёта:** research/28 Б-7а п.4 указывает `runs.go:229` для «`paused` +входит в допустимые для старта» — сегодня это `readyToTranslate` на `runs/runs.go:227-231`. Смысл +подтверждён, номер уплыл; в компаньоне записан диапазон. + +## 5. Адверсариальное селф-ревью (промт §6.5) + +Два независимых субагента, author≠reviewer, видели только артефакты, не мой ход мысли: + +- **(а) опровергатель полноты** — получил research/28 (§5 · §5а · §5б · §8 · §8а · §6 + ревью-шапку), + D39.138 п.2, канон 0.2.3 и канон 0.3.0. Мандат: «пункты заказа, исполненные неверно или неполно, и + правки СВЕРХ заказа». Этого отчёта и прозы компаньона не получал (сам назвал, что грепал только + заголовки компаньона — его существование несущее для двух вердиктов). Вернул таблицу покрытия по + всем ID заказа + 4 находки класса (A) «не исполнено», 6 класса (B) «сверх заказа», 10 класса (C) + «внутренние противоречия». +- **(б) холодный потребитель** — получил ТОЛЬКО новую спеку, с запретом открывать любой другой файл + репозитория (запрет соблюдён: «хочется контекста, которого в файле нет» он записывал находкой, а + не шёл искать). Мандат: «ты пишешь клиента с нуля». Вернул 47 находок, ранжированных по цене + реализатору. + +**Ноль находок не случился ни у одного, и это правильный исход.** Принято и исправлено 26 находок; +четыре из них — дефекты, которые прошли бы в код P7 и стоили бы там на порядок дороже (F1, F8/F9, +F32, C2). Обе линзы независимо нашли одно и то же в трёх местах (структурная версия · `X-TM-Client` +против bearer · `Book` без ревизии в ответе на запись) — это и есть подтверждение, что находки не +шум. + +### 5.1. Исправлено (26 позиций) + +| # | Линза | Находка | Что сделано | +|---|---|---|---| +| **F1** | б | **`BookIntake.required` ставит `file` перед необязательным `title`.** Генератор «сначала required, потом optional» — обычный дефолт там, где язык не даёт положить параметр с дефолтом первым, — кладёт `title` ПОСЛЕ файла, и форма получает 400. Тот же класс PD-172 в новой маскировке: моя перестановка `properties` защищала одно семейство генераторов и была опрокинута массивом `required` тремя строками ниже | **Класс убит структурно, а не прозой:** `title` сделан ОБЯЗАТЕЛЬНЫМ, пустая строка = «назови по файлу». В схеме не осталось НИ ОДНОГО необязательного члена — значит нет порядка, который генератор мог бы выбрать и нарушить правило. Перепроверено генератором: `title · source_lang · target_lang · file` | +| **F2** | б | **У `UnitState` пропал `enum`** — регрессия, внесённая моей правкой описания | Восстановлен | +| **F3** | б | **У `RejectReason` пропал `enum`**, при том что проза называет словарь закрытым | Восстановлен, 4 значения | +| F4 | б | `EventEnvelope.event` — восемь имён кадров живут только в markdown-таблице | `enum` из 8 имён | +| F5 | б | `limit`: схема «выше 1000 невалидно», проза «подрезается», словарь ошибок несёт `out_of_range` | Записано: подрезается, НЕ отвергается; «максимум, объявленный здесь» | +| F6 / C9 | б, а | `X-TM-Client` `required: true` против прозы «предъявитель bearer освобождён» | Записано: required — потому что браузерный клиент обязан всегда; bearer освобождён схемой безопасности | +| **F8 / F9** | б | **Дельта-чтение строго `>`, а `Revision` требует catch-up `>=`; кадр `bank` предписывает читать строки «ревизией этого кадра» — что при строгом сравнении ВСЕГДА возвращает пустоту.** Основной поток всей банковской фичи | `?after_version=` сделан ВКЛЮЧИТЕЛЬНЫМ (та же причина, что у catch-up: одна транзакция — одна ревизия, но НЕСКОЛЬКО строк); кадр `bank` переписан на «ревизию, которую клиент применил последней»; записано правило водяного знака | +| F10 | б | Рукопожатие проверяет МАЖОР, а 0.x ломает миноры — два правила документа исключают друг друга | Проверка версии сделана минор-чувствительной, пока мажор `0`; то же на `Capabilities.contract_version` | +| F11 | б | Переименование объявлено бесплатным («не доезжает до движка») и тут же отвечает 409 при живом прогоне | **409 снят с `PATCH`** — конфликтовать не с чем (§3.9). У `DELETE` остаётся: там конфликт настоящий | +| F12 | б | «Клиент никогда не опрашивает read-модель» — неправда: ни один кадр не несёт книжных величин библиотеки, а поток книжный | Формулировка приведена к правде: read-модель не опрашивается, чтобы ОБНАРУЖИТЬ изменение; читается по кадру/навигации/фокусу, и условные чтения делают это дёшево. Отсутствие ленты на библиотеку записано направлением в компаньон | +| F13 | б | `BankTerm.id` объявлен переживающим пересборку и выводится из окна, которое ПЕРЕ-РАЗБОР двигает | Записано прямо: пере-разбор он НЕ переживает; при смене `structure_version` клиент перечитывает банк и не считает решения перенесёнными | +| F14 / C8 | б, а | «CORS-слоя нет вовсе» против 403 по origin; плюс 403 по origin срабатывает БЕЗ куки, а описание требует сессии | Обе формулировки уточнены по коду (`csrf.go:52,62,65-66`) | +| F15 | б | `stopped` одновременно «выводится контрактом» и значение `RunStatus` | Записано: выводит ПЛАТФОРМА, клиент читает поле и никогда не вычисляет | +| F16 | б | Правило «дропать устаревшее чтение», прочитанное буквально, ломает опрос экспорта | Правило сужено: сравнение ПО СКОУПУ и ПО КОЛЛЕКЦИИ; опрос экспорта не дропается | +| F17 / C10 | б, а | Ответы `POST`/`PATCH` возвращают `Book`, у которого нет `revision` — упорядочить их против кадра нечем | Добавлен `Book.revision` (ревизия КНИГИ, не библиотеки) | +| F18 | б | Меняется ли `ETag` от `limit`/`cursor`/`after_version` | Записано: валидатор привязан ко всему запросу, строку запроса включая | +| F19 | б | Нет карты «код → статус» | Каждое значение `ErrorCode` получило свой HTTP-статус | +| F20 | б | Нельзя узнать ДО загрузки, принимает ли деплой книги (404 неотличим от прочих 404) | `Capabilities.intake_enabled` | +| F21 | б | Можно ли экспортировать недопереведённую книгу | Записано: можно; что окажется внутри — работа S7 | +| F22 | б | `{"title": null}` в merge-patch — удаление или 400 | Записано: 400 | +| F23 | б | Едут ли агрегаты банка на ДЕЛЬТА-чтении | Записано: да — «любой ответ на запрос без `cursor`, дельта включая»; и всегда описывают весь банк | +| F24 | б | `Export.url` — свой origin? под кукой? `fetch` или навигация? | Записано: свой origin, браузер НАВИГИРУЕТ; это загрузка, а не вызов | +| F25 / F26 | б | Что с потоком при удалении книги; сколько потоков держать | Оба записаны нормой: удаление закрывает поток и реконнект → `404`; один поток на книгу, ленты на библиотеку нет и открывать поток на строку списка клиент не должен | +| F27 / F39 | б | `Retry-After` обещан на `key_in_flight` и объявлен только на опросе экспорта | Объявлен на ответе `Conflict` | +| F28 | б | Показывать ли `request_id` пользователю | Записано: MAY; идентифицирует запрос, не человека | +| F29 | б | `number` и `heading` оба `null` | Записано: метка — из позиции в порядке чтения | +| **F30** | б | `Unit.note` единственное, а у пары легально несколько замечаний | `Unit.note` → `Unit.notes` массивом; это же согласуется с моим собственным правилом «пустая коллекция — пустой массив» | +| **F31** | б | Кадр `status` несёт только `paused_reason`: о `rejected` и `failed` объявляет, а причину не даёт — при том что обоснование самого кадра «это ровно тот момент, когда экран обязан сказать почему» | `EventStatus` получил `reject_reason` и `failure_reason` | +| **F32 / F33 / C7** | б, а | **Ничто не сообщает, что `structure_version` сдвинулась** — версия была только в `hello` и двух конвертах; `BankPage`/`NotePage` не говорят, к какой структуре относится их содержимое. Две обязанности документа («перечитать банк», «уронить якоря на пары») остались без триггера | `structure_version` — на КАЖДОМ кадре (`EventBase`) и в `BankPage`/`NotePage` | +| F34 / F35 | б | Дельта фильтрует по «версии строки», которой у строк нет; и нечем ответить на устаревший водяной знак | Водяной знак = `revision` конверта (записано); устаревший → `400` `cause.code: version_too_old` — тот же ответ и тот же смысл, что у мёртвого курсора | +| F36 / C3 | б, а | `500` не объявлен нигде, а `internal_error` — в закрытом словаре кодов | Записано: `500` сознательно не перечисляется по операциям (§4 №1, Zalando «do not document»), но тело у него то же и код тот же | +| F37 | б | Нет восстановления экспорта после перезагрузки; нет `Idempotency-Key` на создании | `Idempotency-Key` добавлен на `POST /exports`; обоснование `format` переписано (списка экспортов нет) | +| F38 | б | `Last-Event-ID` и `X-Request-Id` живут только в прозе — генерённый клиент их не увидит | `Last-Event-ID` объявлен параметром `streamBookEvents`; `X-Request-Id` — компонент-заголовок на всех девяти ответах-ошибках | +| F41 | б | `TermStatus.in_progress` недостижим | Записано: это состояние СТРОКИ; `POST /bank/decisions` его не ставит | +| **F42** | б | **Замечания объявлены «в порядке чтения», а ключа порядка на проводе нет:** кадр `note` — единственный, который нельзя потерять, — вставить некуда, и дельта-чтение слить не с чем | Порядок переобъявлен: `created_at`, затем `id` — единственный тотальный порядок, который считают обе стороны; порядок чтения строит клиент по дереву, которое у него уже есть | +| F43 | б | Нет чтения одной главы; кадр `chapter` про неподгруженную главу некуда деть | Записано: такой кадр ИГНОРИРУЕТСЯ — кадр не повод пейджить коллекцию. Операция — направлением к паку 161 | +| F45 | б | Нет состояния «останавливается»: `202` возвращает живой статус, кнопка включается обратно | Записано: `202` не значит «остановлен», значения `stopping` нет, клиент держит своё pending до кадра `status` | +| F46 | б | `Blocked` называет книгу id, а не именем | Записано: клиент читает карточку той книги | +| F0 | б | Из спеки нельзя сделать НИ ОДНОГО успешного вызова: оба способа аутентификации недостижимы из неё | Названо место входа (`GET /auth/login`, вне версионного префикса) прямо в разделе Transport | +| **A1** | а | **Сжатие ратифицировано «В КОНТРАКТ» (D39.138 п.2д), а спека вынесла его ЦЕЛИКОМ в примечание** — я перестарался с разводкой по Б-16 | Обязанность честить `Accept-Encoding` на JSON и НЕ сжимать `text/event-stream` — норма спеки; в примечании остался только слой исполнения, которого клиент не наблюдает | +| A4 | а | §8а требует класс 2 «с лимитом попыток на аккаунт» — носителя не было нигде | Записано на `content_refused`: сервер ограничивает число попыток одного аккаунта, граница его и на провод не идёт | +| C1 | а | Правило отсутствия объявляет ОДНО исключение, а их два (`Problem` и агрегаты `BankPage`) | Названы оба | +| **C2** | а | **`410 Gone` возвращает `Problem`, у которого `code` — закрытый словарь без значения `gone`:** конформный сервер не может ответить конформным 410 | `gone` добавлен в `ErrorCode` | +| C5 | а | `page_size_default` без максимума может превысить максимум, который клиент вправе ЗАПРОСИТЬ | `maximum: 1000` | +| **C6** | а | Правила докачки опираются на тождество, которого `id` кадра не несёт: «позиция в потоке» не уникальна между потоками одной книги, а обе нормы (буфер докачки и `204`) решают именно «тот ли это поток» | `id` переопределён как позиция в истории событий КНИГИ; `204` переформулирован без «поток, названный `Last-Event-ID`» | +| C4 / B1 | а | `Chapter.units_done` как сегментный счётчик противоречит «`Book.chapters_done` никогда не идёт назад» и не определён для главы ВНЕ текущего сегмента | Формулировка уточнена: счётчик ПРОХОДА (`0` — не дошли, `units_total` — прошли, глава вне прохода держит прежнее) + явная разводка с `Book.chapters_done`. Сама сегментная логика — прямой заказ промта батча §3, а не самодеятельность: опровергатель промта не видел | + +### 5.2. Отклонено — с причиной (12 позиций) + +| # | Линза | Находка | Почему отклонено | +|---|---|---|---| +| A2 | а | Три строки таблицы §5а не вырезаны: `Note.unit_id`, `Bank.signed`, нагрузка кадров | **Диспозиция была записана ДО ревью** — §3.3, §3.5, §3.6 этого отчёта и §6а компаньона, с контраргументом на каждое. Опровергатель отчёта и прозы компаньона не видел по условию мандата; его вердикт «без прикрытия» основан на неполном входе. Аргументы стоят | +| **A3** | а | Файл вырос на 63 %, доля прозы выросла | **ПРИНЯТО и ИСПРАВЛЕНО отдельным проходом** (§4.4), после того как владелец назвал ту же вещь прямо. Итог: 2587 → 2290 строк, проза −25 %, доля 43 % → 36 % — ровно уровень 0.2.3, при форме, выросшей на 61 %. Проверено, что резалась вода: генерённые типы до и после резки идентичны с точностью до JSDoc, нормативных правил потеряно ноль. Заказанное содержание НЕ сокращалось | +| B2 | а | `Book.reject_reason` сделан обязательным вопреки §4 №22 | Сам опровергатель называет правку justified: единое правило отсутствия (§5 п.16 того же заказа) не держится с неперепроверенным исключением, а прежний довод («деплой старше этого минора») внутри ЛОМАЮЩЕГО минора пуст | +| B3 | а | `Note.message` снят, введён `Note.code` | Признано justified самим опровергателем; аргумент — §2.3. Вынесено вопросом в §8 | +| B4 / B6 | а | Контентность `BankTerm.id` записана; дисциплина Б-21 применена в спеке | Обе — прямые «стоит записать» из research/28 §3 п.6 и Б-21 | +| B5 | а | Новый MUST NOT на `heading` при открытом К-2 | Кодифицирует собственное предупреждение движка (`manifest.go:80-86`). Без него P7 положил бы в поле рендеренный порядковый, и смысл поля сменился бы после строки 160 — то есть мажор | +| F7 | б | Четыре `code`-поля — свободные строки без словаря | По замыслу: `cause.code`/`ErrorItem.code` открыты специально (это и есть механизм расширяемости варианта B), `Note.code`/`Export.failure_code` не enum, пока нет источника — §6 obstacle | +| **F40** | б | **`TermStatus` не имеет значения для ОТКЛОНЁННОГО термина** — экран подписи не покажет, что уже отклонено | **НЕ ИСПРАВЛЕНО СОЗНАТЕЛЬНО, вынесено вопросом владельцу** (§8 п.4): лечение — одно поле `BankTerm.decision`, а «добавочные поля `BankTerm` НЕ заводить» — слово владельца 15.08 (D39.136 п.4б), повторённое промтом батча. Право сказать «этого делать не надо» есть, права молча сделать иначе — нет | +| F44 | б | `localized` — механизм, который ни один код не использует | Ратифицированный слот (§8 п.4, форма `google.rpc.LocalizedMessage`); помечен в спеке словами и носителем, чтобы предупреждение не пережило своё основание | +| — | б | «`Intl.DisplayNames` — браузерный API в контракте, а bearer заявлен для не-браузера» | Верно как замечание. `LangCode` отдаёт КОД; чем клиент рисует имя — его дело, и упоминание одного API в описании ни к чему не обязывает. Оставлено как есть | +| — | б | Три дизайн-возражения: двухшаговая загрузка · `X-TM-Client` параметром, а не обязанностью схемы · `Book` против `BookDetail` в ответах на запись | Первое — направление вне батча (§5 «не в батч»); второе — прямой заказ Б-15 («объявить машинно», иначе правило видно только человеку); третье — форма ответа на запись, менять её не заказано | + +### 5.3. Третья линза — КРОСС-МОДЕЛЬНАЯ (fable), по слову владельца + +Обе линзы §5.1–5.2 — одного семейства со мной, и их согласие независимым подтверждением не считается +(тот же долг записан у research/28 §10 и строкой 190). По слову владельца добавлен проход агентом +ДРУГОЙ модели по диффу `0.2.3 → 0.3.0` и по заказу; входы те же артефакты, отчёт и промт не давались. +Мандат отличался прицелом: (1) что будет отлито в код P7 неверно · (2) смысловые дефекты, проходящие +линтер · (3) сверка с заказом · (4) **не потеряна ли норма при резке прозы** — отдельный риск, которого +у первых двух линз не было, потому что резка случилась после них. + +Результат: **1 HIGH и 4 MEDIUM, все — дефекты моей новой формы; ни одного неисполненного пункта +заказа.** Все девять находок приняты и исправлены. + +| # | Находка | Что сделано | +|---|---|---| +| **HIGH-1** | **`id` кадра объявлен «позицией в истории КНИГИ, строго возрастающей, по одному на кадр» — а `hello` приходит первым на КАЖДОМ подключении, `end` и `resync_required` тоже принадлежат соединению.** У реализатора два пути, и оба против текста: минтить служебным кадрам книжные номера (два одновременных зрителя тратят номера друг друга, `Last-Event-ID` одного указывает на кадры, которых второй не видел) либо повторять последний номер (тогда «one per frame» — ложь). На этом же `id` стоят правило `204` и клиентский гард, то есть ошибка отливается в миграцию, воркер и шов клиента разом | Разведено: служебные кадры несут id последнего кадра ИСТОРИИ и своего номера НЕ тратят, повтор id на них легален; клиент хранит последний увиденный id и отправляет обратно, но им не считает | +| **MED-2** | **`limit`: схема (`maximum: 1000`) приказывает отвергнуть то, что проза приказывает подрезать.** Сервер, собранный со схемной валидацией запросов (oapi-codegen — кандидат P7), ответит 400 на `limit=5000` и будет конформен схеме и неконформен собственной прозе | Записано явно: `maximum` бьёт по тому, что вправе ЗАПРОСИТЬ КЛИЕНТ; сервер, получивший больше, MUST подрезать и MUST NOT отвергнуть, а деплою со схемной валидацией предписано вывести параметр из-под отказа | +| **MED-3** | `createExport` несёт `Idempotency-Key`, чья семантика без `409` неисполнима, а `409` в ответах нет (у `createBook` и `startRun` есть) | `409` добавлен | +| **MED-4** | Машина состояний экспорта недоопределена: `ready` объявлен терминальным ⇒ `expired` недостижим и значение мертво; `expires_at` описан как `null` вне `ready` ⇒ в состоянии `expired` поле «когда истекло» обязано быть пустым | Переписано: `pending → ready → expired` либо `pending → failed`; опрос останавливается на трёх, но терминальность опроса и терминальность состояния разведены; `expires_at` заполнен там, где артефакт существует — в `ready` И в `expired` | +| **MED-5** | Порядок замечаний, который я ввёл по находке F42, опирается на сравнение `id` как тай-брейка — а `Id` тем же документом ЗАПРЕЩАЕТ сортировать по id; вдобавок лексикографический порядок строк и серверный совпадать не обязаны, так что «единственный порядок, который считают обе стороны» фактически неверно | Тай-брейк снят с клиента: сервер разрешает ничьи по-своему, клиент этого НЕ воспроизводит и ставит пришедшее кадром замечание после всех, что держит с тем же `created_at`. Запрет сортировки по `Id` остаётся в силе | +| LOW | `page_size_default` несёт `maximum: 1000` внутри абзаца, отрицающего вторую копию числа | Формулировка переписана: бound — следствие единственного определения на `limit`, а не вторая копия | +| LOW | «MUST tolerate an unknown value under a minor bump» недостижимо весь 0.x: клиенту велено отказываться при любом расхождении минора, значит ветка толерантности физически не получит значения | Названа полоса, для которой правило написано: аддитивные миноры (с 1.0) и деградация внутри версии; в 0.x это пол, а не разрешение работать против чужого минора | +| LOW | `Book.chapters_done` «никогда не идёт назад» — необеспечимый абсолют: пере-разбор пересчитывает и его, и `chapter_count` | Сужено до «внутри одного `structure_version`» | +| **MED (резка)** | **Потеряно правило «клиент не читает пропуск номера как потерянный кадр»** (было в 0.2.3) — и потеряно ровно тогда, когда стало НУЖНЕЕ: в 0.2.3 `id` был ревизией и дыры были легальны по построению, в 0.3.0 `id` — «по одному на кадр», а склейка создаёт настоящие дыры. Клиент, дорожащий несклеиваемыми `note`, прочитал бы дыру как потерю и ушёл в лишний resync | Возвращено на `EventEnvelope.id` вместе с явным «дыра легальна» | +| LOW (резка) | Потеряно «книга легально не имеет глав вовсе» — единственное место, где пустое дерево объявлялось законным | Возвращено в `listChapters` | +| LOW (резка) | Потеряны числа интейка «16 частей / поле ≤ 1 КБ» без переезда в `Capabilities` | **Отклонено осознанно:** Б-3 показал, что эти два числа в спеке и в коде описывали РАЗНЫЕ правила (код считает части ДО файла, спека — все), а комментарий кода называет их «не делом контракта». Клиент узнаёт факт кодами `too_long`/`too_many_parts` в `errors[]`; превалидировать ему нечего — он шлёт четыре части | +| LOW | Компаньон (приложение А-2) давал `content_refused` как «400/409», спека — только 400 | Компаньон исправлен | + +**Что третья линза подтвердила независимо** (ценно именно потому, что модель другая): исполнение Б-0 +по ЭРРАТЕ, а не по опрокинутой рекомендации (`TermStatus` — ось, `TermOrigin` — переименование) · +чистоту греп-листа анти-утечки, включая проверку СГЕНЕРЁННЫХ типов (`wave|draft` = 0) · что основание +переписанного resume-абзаца верно по коду (`pgstore/runs.go:672-676`, `runs/runs.go:227-231`, +`reconcile.go` ветка `stopped`) · что `Book.chapters_done` действительно Ц1 (величина уже в SQL) · +что baseline диффа аутентичен (`old.yaml` = байт-зеркало фронта, ещё 0.2.3). + +**Что она подняла на ратификацию, а не на исправление:** частичный отказ от резки §5а (четыре строки, +каждая с контраргументом) — «должен окнуть оркестратор, а не констатировать батч»; и `Note.message` → +`Note.code` как решение класса «политика фраз замечаний», принятое батчем без слова владельца. Оба +уже стояли в §8 ниже; после этой линзы они там усилены, а не добавлены. + +## 6. Obstacle reporting — что НЕ удалось и не сделано + +1. **Гейт на утечку конвейера не построен** — и не должен был: тест по образцу `generality.test.ts` + живёт в зоне фронта, которая заморожена. Я записал ПРАВИЛО и список грепа в компаньон и прогнал + греп руками; автоматической защиты до разморозки нет. Носитель — пинг фронту 16.08. +2. **Число страницы замечаний не изменено и не могло быть.** Замера масштаба нет ни одного (research/28 + §7, §10: «сколько замечаний даёт настоящая книга, не знает никто»). Я снял ЧИСЛО из спеки в + `Capabilities.page_size_default` (это правильное место для значения деплоя) и оставил оговорку в + спеке и в компаньоне. Новое число не выдумано. +3. **Дельта-чтение и структурная версия были дефектны в первой редакции батча** и починены только + селф-ревью (F8/F9, F32). Это не «удалось», а «поймано вторым рубежом»: механизм, который Б-11а + заводит ради экономии 372 МБ, в первой моей редакции возвращал пустое множество в своём главном + сценарии, а триггера «структура сдвинулась» не существовало. Обе ошибки — мои, обе были бы отлиты + в код P7. +4. **`Note.code` не стал enum.** Карта «код → фраза» пуста, фразы пишет владелец (строка 148). + Enum-словарь без фраз был бы формой без источника — тот самый класс, от которого предостерегает + §5а-оговорка. Коды предложены в компаньоне (◆), enum появится вместе с фразами. +5. **`Export.failure_code` не enum** — по той же причине: способы упасть зависят от форматов, которых + не существует (S7). Носитель записан в самой схеме. +6. **Рантайм не поднимался.** Ни платформа, ни фронт не запускались: платформа 8 из 20 операций не + отвечает вовсе, а фронт заморожен и живёт на моках. Всё, что я утверждаю о поведении сервера, + выведено чтением кода (§4.3), а не наблюдением. Соответственно НЕ проверено исполнением: + реальная форма `problem+json` при 413/408 посреди тела; поведение `Idempotency-Key` (не построен); + условные чтения (не построены). +7. **Зеркало фронта расходится с каноном на момент сдачи** — намеренно (зона заморожена, D39.136 п.2). + `cmp` при лендинге ОБЯЗАТЕЛЕН, порядок — канон первым, зеркало отдельным зонным коммитом + (D39.138 п.3). +8. **Кросс-СЕМЕЙНОЙ проверки по-прежнему нет, кросс-МОДЕЛЬНАЯ есть.** Третья линза (§5.3) — другая + модель того же семейства, по слову владельца; она нашла HIGH, который две предыдущие пропустили, + то есть разнос по модели окупился. Настоящий не-Claude проход при границе «$0 по внешним + провайдерам» остаётся невозможным — долг записан строкой 190. +9. **Платформе и фронту прибавилось работы, и это не оценено мной в часах.** Батч ломает + `wireProgress`, `projectBook`, `projectRun`, словари банка, форму `Problem` и путь потока; фронту — + `format.ts`, `About.tsx`, `Status.tsx`, `vocabulary.ts`, `contract.ts`, все моки. Оценка — за зонами. + +## 7. Дифф-сводка «было → стало» по операциям + +| Операция | Было | Стало | +|---|---|---| +| `getCapabilities` | — | **НОВАЯ**: версия · пары · порог · форматы · размер страницы | +| `listBooks` | `Library` | `BookPage` (на `Page`), `ETag`/`304`, объявлен порядок | +| `createBook` | `file` вторым в схеме; часть после файла молча терялась; идемпотентности нет; `Location` нет | `file` последним; часть после файла → 400 + `errors[]`; `Idempotency-Key`; `Location`; `409`; отказ по паре кодом | +| `getBook` | `BookDetail` | + `ETag`/`304`; `run` стал required+nullable | +| `updateBook` | — | **НОВАЯ**: merge-patch, только `title`; принимается и при живом прогоне | +| `deleteBook` | — | **НОВАЯ**: 204, 409 при живом прогоне | +| `listChapters` | «Default page size 5000» в прозе | `ChapterPage` + `structure_version` + `ETag`/`304`; число ушло в `capabilities` | +| `listUnits` | — | + `structure_version`, `ETag`/`304`, **`410 Gone`** | +| `listNotes` | `NoteList`; «в порядке чтения» без ключа порядка | `NotePage` + `structure_version` + `?after_version=` + `ETag`/`304`; порядок `created_at`,`id`; `Note` получил `id`/`created_at`/`code`, `chapter_id` стал обязательным, `message` снят | +| `listBankTerms` | `Bank`; предупреждение «канала нет» | `BankPage` + `?after_version=` + `ETag`/`304` + `pending_decisions`/`complete`; агрегаты на первой странице; предупреждение снято | +| `submitBankDecisions` | `promote`/`decline`, `decisions` без `maxItems` | `approve`/`decline`, `maxItems: 1000` | +| `getRunOptions` | `{ceiling}` | + `blocked: {code, book_id}` | +| `startRun` | `verify_bank`; 409 без причины | `stop_for_signing`; `Idempotency-Key`; 409 несёт `blocked`; названо лечение «новый прогон с бОльшим потолком» | +| `streamRunEvents` → **`streamBookEvents`** | поток на ПРОГОНЕ; конца нет; склейка без ограничений; `id` = ревизия; 8 кадров | поток на КНИГЕ; кадр `end` + `204`; склейка только состояний; `id` = позиция, ревизия в `data`; `EventCeiling` снят, `EventEnd` добавлен | +| `getRun` | — | **НОВАЯ** | +| `stopRun` / `resumeRun` | `Run` без `book_id`; «MUST NOT offer resume» и никакого лечения | `Run` с `book_id`/`failure_reason`/`progress`; 409 c `cause.code`; лечение названо | +| `getUsage` | `paused_reason` необязателен, типизирован прогонным enum | `halt_reason` required+nullable, свой `AccountHaltReason` | +| `createExport` / `getExport` | `{id, ready, url?}`; опрос не завершался | `{id, revision, state, format, expires_at, failure_code, url}`; формат из `capabilities`; правило доступа к ссылке | + +**Проход резки прозы (§4.4):** 2587 → 2290 строк, доля прозы 43 % → 36 %; типы не изменились ни на +поле. + +**Прочее, изменённое селф-ревью:** `BookIntake.title` стал ОБЯЗАТЕЛЬНЫМ (пустая строка = «назови по +файлу») — в схеме не осталось необязательных членов, и порядок частей больше не зависит от повадки +генератора · `Unit.note` → `Unit.notes` массивом · `Book` получил `revision` · `EventBase` получил +`structure_version` · `EventStatus` получил `reject_reason` и `failure_reason` · `ErrorCode` получил +`gone` · `Capabilities` получил `intake_enabled` · `?after_version=` стал включительным. + +**Снято с провода:** `Book.genre` · `BookIntake.genre` · `Book.progress` · схема `Counter` · +`BookStatus.finalizing` · `Problem.instance` · `EventCeiling` · `EventHello.run_id` · +`EventResyncRequired.reason` · `Note.message` · движковые значения `TermStatus`/`TermOrigin` · +операция `streamRunEvents`. + +## 8. Вопросы оркестратору (через владельца) — не интерпретировал + +1. **§3.1** — прогонный поток снят вместо «оставить узким видом». Если это чтение §5 неверно, + возврат стоит одной операции. +2. **§2.3 / 3.3** — `Note.message` снят в пользу `Note.code`, `Note.unit_id` оставлен вопреки §5а. + Оба — мои чтения ратифицированного принципа, а не буквы носителя. +3. **`content_refused` в `RejectReason`** заведён под непостроенный прескрин (ПТ-16, строка 94). Это + ратифицировано К-9, но остаётся значением словаря без сегодняшнего писателя — если правило + «форма без источника» весит больше, значение снимается одной строкой. +4. **Экран подписи не может показать, ЧТО уже отклонено** (находка F40 холодного потребителя; не + исправлена сознательно). Чтение банка отвечает `pending_decisions`/`complete` — сколько осталось, + но не КАКИЕ строки решены: `TermStatus` это состояние строки банка, а решение живёт отдельной + таблицей `bank_decisions` (`00002_readmodel.sql`) и на провод не проецируется ни одним полем. + Экран, перезагруженный посреди стопа, знает «осталось 17 из 300» и не знает, какие семнадцать; на + сотнях строк это разница между «доделать» и «пройти заново». Лечение — ОДНО поле + `BankTerm.decision` (`approve`/`decline`/`null`), Ц0: чтение банка не построено. + **Не сделано, потому что «добавочные поля `BankTerm` НЕ заводить» — слово владельца 15.08 + (D39.136 п.4б), повторённое промтом батча §3.** Моё чтение: тот запрет закрывал вопрос ПТ-33 о + полях СМЫСЛА («смысла хватает, но не слишком подробно»), а это поле — состояние подписи, то есть + предмет Б-14а, который в батч входит. Но чтение — не право сделать иначе. Вопрос владельцу; если + ответ «заводить», правка стоит четырёх строк схемы и одной строки P7.