# Передача оркестратору: контракт 0.2.0 — что применить в каноне > **Это не носитель состояния, а патч.** Файл существует ровно до лендинга S3: оркестратор > переносит правку спеки и вписывает блоки §2–§4 в компаньон канона, после чего файл удаляется. > Сессия фронта канон (`docs/architecture/14-api-contract/`) не правит — это чужая зона. > > Написано фронт-сессией S3, 08.08.2026. ## 1. Спека: зонная копия впереди канона | Что | Где | |---|---| | Правленая копия | `frontend/docs/api-contract/openapi.yaml` — **version `0.2.0`** | | Канон | `docs/architecture/14-api-contract/openapi.yaml` — всё ещё `0.1.0-draft` | Байт-сверка (пункт Д промта) **даёт расхождение, и это ожидаемо**: правки заказаны промтом S3, а канон правит оркестратор. Диффом: ``` diff -u docs/architecture/14-api-contract/openapi.yaml frontend/docs/api-contract/openapi.yaml ``` После пере-ратификации копии обязаны снова стать байт-равными; шапка `info` в зонной копии это прямо говорит, чтобы следующая сессия не приняла временное расхождение за дефект. **Что сделано по списку промта** (каждый пункт — в спеке, а не в намерении): | Пункт | Исполнено | |---|---| | А-нуль | `version: 0.2.0`. ⚠ **Отступление, называю вслух:** промт говорил «твоё там одно — бампнуть version», но в шапке стояла фраза «version всё ещё `-draft` — S3 бампнет первым действием», которая после бампа становилась ложной. Заменил её на правду о том, что копия сейчас впереди канона. Баннер «DRAFT … not a ratified contract» НЕ возвращён | | А.1 К-1 | статусы не тронуты | | А.2 К-8 | `BookStatus` += `paused`; в описании прямо: стоп по потолку это `paused`, не `failed` | | А.3 К-5 | `Progress.eta_seconds`, опциональное и nullable | | А.4 К-3 | `Chapter.number` и `Chapter.heading` — оба обязательны и оба допускают `null`; запрет синтеза «Глава {n}» записан нормативом. Фикстуры проверены и правлены (см. §5) | | Б.5 К-4 | ревизия пер-книжная; правила «монотонна в скоупе», «отбрасывание — обязанность клиента», «докачка `>= R`, не `>`», «полная замена ⇒ `resync_required`» — прозой в `Revision` | | Б.6 К-7 | `?limit=&cursor=` и `next_cursor` на ВСЕХ пяти списочных ответах; дефолты 5000/1000/500; курсор привязан к структурной эпохе, отклонение протухшего — MUST сервера, ответ `400` | | Б.7 К-12 | `202` + обязательный `Location`; `Retry-After` объявлен ЯВНО заголовком 200-ответа со ссылкой на то, почему RFC 9110 сам этого не даёт | | Б.8 CSRF | `X-TM-Client` в описании `sessionCookie`: несёт ПРИСУТСТВИЕ, значение любое, обязателен и на same-origin | | Б.9 | `PausedReason` = `credit_exhausted`, `Run.paused_reason` обязателен и nullable | | В.10–11 | `GET /usage` + схема `Usage`. ⚠ Форма из промта записана в 3.0-синтаксисе (`nullable: true`), в 3.1 он невалиден — выражено через `oneOf` с `type: 'null'`, как везде в этой спеке | | В-бис | в `openapi.yaml` НЕ потащено, как и велено; текст для компаньона — §2 ниже | | Г.12–15 | пер-главное чтение юнитов с запретом пер-книжной ручки · экспорт-артефакт с явным запретом сборки текста на клиенте · право сервера склеивать кадры + обязанность клиента терпеть скачки · `maxLength` у `heading` | | В-трис.16–18 | `GET /books/{bookId}/run-options` → `CeilingBounds`; `RunRequest.ceiling_chapters`; `Run.ceiling_chapters`. Обоснование формы — §3 | | В-кватер | раздел «Transport» в шапке: тот же origin как факт, CORS-слоя нет, кросс-origin не проектируется. В коде — прокси dev-сервера (`vite.config.ts`, включается `TM_PLATFORM`) | ## 2. Для компаньона: поверхность входа (пункт 15-бис) Вставить в компаньон канона (`docs/architecture/14-api-contract/README.md`) новым разделом. > ### 2.14. Поверхность входа — вне версионного префикса > > Платформа построила вход через OIDC (P1). Четыре ручки живут ВНЕ `/v0`, как `/healthz`, потому > что это механика сессии, а не контрактная поверхность, и в `openapi.yaml` они намеренно не > тащатся (решение оркестратора как владельца контракта). > > | Ручка | Метод | Что делает | > |---|---|---| > | `/auth/login` | GET | начинает вход, редиректит к провайдеру; принимает `?return_to=<путь на этом сайте>` | > | `/auth/callback` | GET | завершает вход, ставит сессионную куку, редиректит на `return_to` либо на дефолт | > | `/auth/logout` | POST | завершает ЭТУ сессию | > | `/auth/logout-all` | POST | завершает ВСЕ сессии пользователя | > > Клиенту нужно знать три вещи, и только три. Куда вести на вход. Что `return_to` принимает > **только путь этого сайта** — чужой сервер молча заменит на дефолт, открытый редирект закрыт. > И что обе `POST`-ручки лежат на cookie-пути, то есть требуют `X-TM-Client` (§2.8 спеки). > Отказ входа — `problem+json`, как везде; различать причины отказа клиент не может и не должен. ## 3. Для компаньона: управляемый потолок прогона — форма и обоснование Единственное место списка правок, где решение было за сессией. Форма и почему именно она: > ### 2.15. Потолок прогона — ◆ предложено фронтом > > Ратифицировано владельцем 07.08: шкала в интерфейсе от минимума до максимума, ноль выбрать > нельзя, единица — ГЛАВЫ, максимум — доступный остаток (баланс минус холды), потолок принадлежит > ПРОГОНУ. Ручки, отдающей границы шкалы, в контракте не было; она объявлена этой правкой. > > **Отдельный ресурс `GET /books/{bookId}/run-options`, а не поле карточки книги.** Два довода, и > оба про правду на экране. Максимум зависит от АККАУНТА — баланс минус открытые холды — и > двигается, когда книга не меняется: холд, взятый под другую книгу, опускает остаток. Карточка > книги кэшируется и переиспользуется библиотекой, то есть назвала бы максимум, которого уже нет, > ровно в тот момент, когда пользователь двигает ползунок. Второй довод дешевле, но тоже > настоящий: граница нужна один раз, перед стартом, а поле на карточке заставило бы КАЖДОЕ чтение > библиотеки нести состояние счёта. > > **Три числа, а не два.** `min_chapters` объясняет себя единицей — одна глава. `max_chapters` > отдаётся уже подрезанным и по остатку, и по тому, сколько в книге осталось непереведённого: > клиенту запрещено подрезать второй раз, иначе правило живёт в двух местах и расходится. > `default_chapters` отдаёт платформа, потому что выбор предустановленного значения — это > продуктовая политика («потратить всё» ↔ «одна глава»), а не презентация; клиент, выбравший его > сам, принял бы это решение молча. > > **`max_chapters: 0` — легальный ответ** и значит «прогон начать нельзя вовсе». Клиент показывает > исчерпанное состояние вместо шкалы. Это единственный случай, когда `default_chapters` тоже `0`. > > **Пересчёта «главы → деньги» на проводе нет ни в каком виде** (D39.84). Он живёт на платформе по > оценке движка. > > **`ceiling_chapters` — обязательное поле старта и поле прогона.** Обязательное, потому что прогон > без объявленного потолка тратит мимо той границы, которую человек вправе поставить ДО, а не > узнавать после. Поле на `Run` — чтобы перезагруженный экран мог назвать выбранный колпак, а не > забыть его. > > **409 на старте** отвечает и на «потолок больше не помещается»: границы читаются отдельным > вызовом и могут сдвинуться между чтением и стартом. ## 4. К-таблица компаньона: статусы после этой правки Заменить таблицу §4 компаньона на эту (изменения — колонка «Статус»): | # | Кому | Статус после 0.2.0 | |---|---|---| | К-1 | владелец | **ЗАКРЫТ** (D39.100) — десять статусов приняты; одиннадцатым добавлен `paused` по К-8 | | К-2 | автор контракта + бэкенд | **ОТКРЫТ** — кто ПОРОЖДАЕТ титул, по-прежнему не решено; К-3 закрыл продуктовую половину (метка из данных), механическая осталась | | К-3 | владелец | **ЗАКРЫТ** (D39.100) — метка из данных книги; спека запрещает синтез «Глава {n}», `number` и `heading` оба nullable | | К-4 | платформа | **ЗАКРЫТ** — счётчик пер-книжный, одно число на все книго-скоупные чтения и кадр потока; выражено прозой в `Revision` | | К-5 | владелец | **ЗАКРЫТ** (D39.100) — `Progress.eta_seconds`, опциональное | | К-6 | владелец | **ОТКРЫТ** — принцип принят (две ступени), карта «причина → фраза» ждёт В-3 | | К-7 | платформа | **ЗАКРЫТ** — keyset-курсор на всех списках, дефолты 5000/1000/500, привязка к структурной эпохе | | К-8 | владелец | **ЗАКРЫТ** (D39.100) — `paused` + `Run.paused_reason: credit_exhausted` | | К-9 | владелец + автор контракта | **ОТКРЫТ** — отказ прескрина по-прежнему не выразим ни одним статусом | | К-10 | автор контракта + бэкенд | **ОТКРЫТ** — `Chapter.units_done` так и не разведён по фазам; дерево глав показывает ноль всю черновую волну | | К-11 | автор контракта | **ОТКРЫТ**, и половина стала фактом: `Note` по-прежнему не требует ни `chapter_id`, ни `unit_id`; сужение `BankDecision` для экрана — работа S5 | | К-12 | платформа | **ЗАКРЫТ** — опрос, `202` + `Location`, `Retry-After` объявлен явно | | **К-13** | **владелец + платформа** | **НОВЫЙ.** Достижение СОБСТВЕННОГО потолка прогона (`ceiling_chapters`) и исчерпание кредита аккаунта — это одно `paused_reason` или два? Сегодня значение ровно одно, `credit_exhausted`, и оно про кредит. Платформа берёт холд на сумму потолка до спавна, поэтому механически они могут совпасть — но это надо СКАЗАТЬ, а не вывести: пользователь, упершийся в собственный колпак, и пользователь, у которого кончились деньги, находятся в разных положениях, и фраза у них разная. Записано пометкой в `EventCeiling` | ## 5. Что фронт исправил в фикстурах и почему это надо знать автору контракта - **Снят неснятый маркер `第二节:` из колонки оригинала.** Компаньон §2.3 фиксирует замером: чанкер ВЫРЕЗАЕТ маркер из текста, который видит модель. Формы, которую рисовала прежняя фикстура, движок для zh→ru не порождает — это был четвёртый экземпляр класса «фикстура выдумала форму». - **Титул больше не вклеен в текст первого юнита фикстуры.** Метка живёт в `Chapter.heading`. Это НЕ решение К-2: фикстура просто перестала утверждать что-либо про открытый вопрос. - **Ключ строки банка — непрозрачный `id` контракта.** Прежняя заплата из (`src`,`dst`,`kind`) роняла вторую строку полисемичного термина; в фикстуре теперь есть настоящая пара таких строк (青茅 в двух смыслах с разными окнами), и без `id` она видна как дефект сразу. ## 5-бис. Три правки спеки СВЕРХ списка промта — с основанием на каждую Все три сняты адверсариальным ревью диффа как утверждения, переставшие быть правдой; ни одна не меняет форму, все три меняют то, что файл о себе говорит. 1. **`Problem.title` получил то же ограничение, что и `detail`.** Было: запрет движкового текста стоял только на `detail`. Но клиенту при отказе нечего показать, кроме этих двух полей, и `title`, написанный для разработчика, становится фразой, которую читает человек. Теперь оба поля объявлены продуктовым языком: `title` — класс отказа, `detail` — конкретная фраза. 2. **«vocabulary of version 0.1» → «of THIS version».** Предложение осталось от 0.1.0 и после бампа стало враньём о собственном файле. 3. **`servers.description` перестал называть базовый путь непроверенным предложением фронта.** Платформа заехала, и её собственная ручка в промте записана как `GET /v0/usage`, то есть префикс подтверждён. Заодно сказано главное: фиксирован только префикс версии, хост — тот origin, что отдал приложение, и клиент, зашивший конкретный хост, никуда не разворачивается. ⚠ Пункт 3 — единственное место, где я опираюсь на промт, а не на код платформы: в её зону мне ходить не велено. Если префикс на самом деле другой, это правится одной строкой. ## 6. Зависимости, которые эта правка НЕ закрыла Прежний §3 компаньона в силе целиком, плюс одно уточнение: - **Механизма поднятия потолка по-прежнему нет,** и теперь это записано в спеке жёстче: `ceiling_chapters` едет со СТАРТОМ прогона, изменить его контракт не умеет вовсе. Значит `resume` после стопа по потолку возвращает прогон в то же состояние, и спека прямо запрещает клиенту предлагать `resume` как лекарство от `paused`. Строка единого бэклога (126) остаётся открытой и становится блокером экрана S4.