textmachine/frontend/docs/S3_CONTRACT_HANDOFF.md

19 KiB
Raw Blame History

Передача оркестратору: контракт 0.2.0 — что применить в каноне

Это не носитель состояния, а патч. Файл существует ровно до лендинга S3: оркестратор переносит правку спеки и вписывает блоки §2§4 в компаньон канона, после чего файл удаляется. Сессия фронта канон (docs/architecture/14-api-contract/) не правит — это чужая зона.

Написано фронт-сессией S3, 08.08.2026.

1. Спека: зонная копия впереди канона

Что Где
Правленая копия frontend/docs/api-contract/openapi.yamlversion 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-optionsCeilingBounds; 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.