textmachine/frontend/docs/S3_CONTRACT_HANDOFF.md

165 lines
19 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

# Передача оркестратору: контракт 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 |
| В.1011 | `GET /usage` + схема `Usage`. ⚠ Форма из промта записана в 3.0-синтаксисе (`nullable: true`), в 3.1 он невалиден — выражено через `oneOf` с `type: 'null'`, как везде в этой спеке |
| В-бис | в `openapi.yaml` НЕ потащено, как и велено; текст для компаньона — §2 ниже |
| Г.1215 | пер-главное чтение юнитов с запретом пер-книжной ручки · экспорт-артефакт с явным запретом сборки текста на клиенте · право сервера склеивать кадры + обязанность клиента терпеть скачки · `maxLength` у `heading` |
| В-трис.1618 | `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.