30 KiB
Промт: контрактная сессия — синк с платформой по API v0
✅ ОТВЕЧЕНА ЦЕЛИКОМ 20.08 контрактной сессией; канон стал 0.4.0 (в дереве контрактной зоны, НЕ ратифицирован). Все 14 пунктов имеют диспозицию; работа, которая пришла зоне (A · E · G · J), исполнена актом 5. Документ ИСТОРИЧЕСКИЙ — это вход другой сессии, а не задача. Что из ответа стало кодом —
platform-PROGRESS.md, шапка; зависимость от ратификации — PD-327.
Составила зона «Платформа» 20.08.2026 по слову владельца. Это ЗАПРОС, не проект правки: зона называет места, где провод не даёт клиенту сделать то, что нужно пользователю, и говорит, что она сама уже сделала, чтобы не ждать.
0. Как читать эту записку
Три уровня, и они помечены по-разному:
- ФАКТ — проверено исполнением сегодня (грепом, живой пробой, прогоном теста). Команда названа.
- ⚠ МНЕНИЕ ПЛАТФОРМЫ — предложение зоны. Соглашаться с ним преждевременно архитектурно опасно: зона видит один срез (свою реализацию и своего единственного потребителя), не видит фронта, не видит движка изнутри и не держит в голове историю ратификаций. Мнение приложено потому, что его просили, а не потому, что оно взвешено со всех сторон.
- ГРАНИЦА — то, чего зона НЕ проверяла и утверждать не берётся.
Нормативно: docs/architecture/14-api-contract/openapi.yaml (info.version: 0.3.0) — форма;
README.md рядом — компаньон (провенанс, обоснования, открытые вопросы), для формы НЕ нормативен.
При конфликте с чем угодно побеждает канон.
Сквозной класс, вокруг которого собрана половина записки: провод сообщает состояние, но не даёт клиенту действия, которое пользователю нужно. Не «неверное поле», а «верное поле, из которого не следует, что делать».
A. Прогон, у которого кончился купленный бюджет (PD-282)
Что происходит. Пользователь купил прогон на N глав. Движок потратил эти деньги до конца. Прогон закрылся — сам, или пользователь нажал «стоп», или он встал на подпись банка. Пользователь жмёт «продолжить». Платформа отвечает 202 «принято» — и не делает ничего.
Как это устроено (ФАКТ, internal/runs/reconcile.go reopen). «Продолжить» — это открыть
прогону следующую попытку и взять новый денежный холд на ОСТАТОК бюджета этого прогона:
budget := Pricing.Ceiling(l.CeilingChapters)
spent := RunSpent(runID)
remaining := budget - spent
if remaining <= 0 { return exhausted } // ничего не открывается
Холд на ноль запрещён явно (holdTx: hold must be positive), поэтому продолжать нечем. Отказ
корректен; неверен только ОТВЕТ.
Почему это дыра контракта, а не бэкенда. Таблица §resumeRun расписана по СТАТУСУ прогона
(awaiting_bank → 202, paused → 409, stopped → 202). Исчерпанность бюджета — не статус, это
вторая ось, и строки для неё нет вовсе. Зона попала в неназванный случай и выбрала самый мягкий из
доступных ответов.
ФАКТ, важный для формы: после того как потолок прогона выбран, resume не заработает уже
никогда, сколько денег ни доложи — потолок едет со стартом прогона и потом не меняется
(ратифицировано, §startRun). То есть «продолжить» под капотом ВСЕГДА означает новый прогон.
ФАКТ, который легко смешать и нельзя: «деньги кончились» — это два разных факта с разным
лечением. Кончился потолок ЭТОГО прогона (на счёте может лежать сколько угодно — надо докупить
главы) и кончился баланс АККАУНТА (надо пополнить). Канон сам предупреждает об этой ошибке у
AccountHaltReason: зажечь общий по аккаунту статус из причины, которая про аккаунт ничего не
говорит, значит сказать пользователю с деньгами, что денег нет.
Что видит клиент сейчас (ФАКТ). 202 плюс объект Run, у которого status остался
stopped/awaiting_bank, а finished_at заполнен. Формально догадаться можно. Практически клиент,
сгенерированный по контракту, читает 202 как «продолжается» — таблица ему это и обещала.
⚠ МНЕНИЕ ПЛАТФОРМЫ. Две аддитивные вещи, и первая важнее:
- Убрать мёртвый клик, а не объяснять его.
Runполучает необязательный признак «этот прогон ещё можно продолжить». Платформа знает его точно; клиент вывести не может —progress.done == totalне равно исчерпанию, глава может стоить дороже оценки. Экран тогда не рисует кнопку, которая не может сработать, а рисует «докупить главы» со шкалой изrun-options(гдеmax_chapters: 0уже значит «сейчас нельзя») и, если счёт пуст, «пополнить» — это уже говоритUsage. Порядок чтения этих пяти мест канон уже ратифицировал (§RunOptions), новых словарей не нужно. - Строку в таблицу §resumeRun — на гонку и устаревший экран: 409
run_not_resumable,cause: ceiling_reached. Текст самой причины уже описывает этот случай дословно: «остановился на пределе — на главах, которые он купил, или на кредите за ними».
Чего платформа НЕ предлагает и почему. Чтобы resume сам стартовал новый прогон: это трата
денег без выбора суммы, а канон объявляет потолок выбором пользователя.
Пока не ратифицировано зона оставила 202 и написала в коде прямым текстом, что это чтение
неназванного случая (reconcile.go, ветка exhausted; пин TestResumeOfARunWithNothingLeftReturns ItUnchanged тоже помечен). Это ближайшее к «обходу», что есть в дереве, и зона его декларирует.
B. Суточный потолок движка — лечение, которое не лечит
ФАКТ. Оператор ставит суточный потолок в book.yaml; он принадлежит движку, и платформа снять
его не может физически. У канона в PausedReason одно значение (credit_exhausted), слова для
суточного нет, и зона его намеренно не изобретает (PD-199, ратифицировано D39.132 п.2а). Поэтому
ingest.ContractPausedReason отдаёт пустое → на проводе paused_reason: null.
Дальше канон велит клиенту: «A NEW run is legal from ANY paused book, whatever paused_reason
says — null included», и предупреждает, что клиент, который ждёт конкретной причины, «strands the
user on the commonest stop there is». Для денежной паузы это верно. Для суточной — пользователь
покупает новый прогон, и движок упирается в тот же потолок сразу же.
Цена (ФАКТ по коду): провайдерских денег не теряется — холд возвращается, — но каждый клик стоит
холда, transient-юнита и вызова tmctl status, а экран не движется. В reconcile.go это названо
своими словами: «The pause the platform CANNOT lift — the engine's daily ceiling».
ГРАНИЦА. Живьём не воспроизводилось: стендовая книга до суточного потолка не доходит.
⚠ МНЕНИЕ ПЛАТФОРМЫ. Нужен способ сказать «эту паузу деньги не лечат». Форм минимум две, и выбор
между ними — не зоны: либо второе значение в PausedReason (просто, но заводит вокабуляр про чужой
конфиг), либо признак у паузы вида «лечится покупкой / не лечится» (не называет причину, но говорит
клиенту ровно то, что ему нужно для кнопки). Зона склоняется ко второму, потому что первое требует от
платформы называть настройку, которую она не контролирует и не читает.
C. Вход /auth/* — и правило, которое молча срезали
Это разбор, который уже проводился; здесь он с перепроверенными числами.
ФАКТ 1. codeForStatus снят: grep -rn "codeForStatus" --include=*.go platform/ → 0.
Механическая половина сделана, разрешения не требовала.
ФАКТ 2. Фраза компаньона 0.2.3 §2.14 «различать причины отказа клиент не может по замыслу»
отсутствует до сих пор: grep -c "различать причины отказа" docs/architecture/14-api-contract/README.md
→ 0. Срезана коммитом лендинга батча (8d82096, ловится git log -S).
Почему это стоит дороже самого вопроса. Это не спор, который надо переоткрыть, — это правило, которое кто-то выкинул. И это пойманный экземпляр класса потерь, который приёмка батча структурно поймать не могла: она сверяла мультимножество модальных глаголов (MUST/SHOULD/MAY), а в этой фразе модального глагола нет. Стоит проверить, не потерялось ли тем же способом что-то ещё.
ФАКТ 3, арифметика остатка. Из шести статусов входа четыре имеют точные соответствия в
ErrorCode: 401 → unauthenticated, 400 → invalid_request, 404 → not_found,
503 → service_unavailable. Без кода остаются 405 и 429, из которых 405 — не пользовательский
случай, а баг клиента. То есть по существу обсуждается ОДИН код, а не словарь.
⚠ МНЕНИЕ ПЛАТФОРМЫ (вариант A уточнённый). Не «оставить как есть», а дописать две фразы:
/auth/* отвечает тем же конвертом БЕЗ машинного code, и это решение, а не пробел; клиент
диспетчеризует по статусу, показывает одну нейтральную фразу и особый случай 429 с Retry-After
(он реально отдаётся: login.go:173,233, dev.go:138). 429+Retry-After — не «ветвление по
статусу как запашок», а механизм HTTP по назначению.
Объявлять словарь в КОМПАНЬОНЕ зона считает опасным: компаньон объявлен не нормативным для формы, и проводной словарь в нём делает его нормативным с чёрного хода — форму нечем ни сгенерировать, ни отвалидировать, ни удержать линтером.
Триггер пересмотра, который стоит записать явно: в день, когда на входе появится ВТОРОЙ
пользовательски осмысленный случай («аккаунт отключён» против «провайдер лёг»), эта поверхность
получает свой нормативный документ — маленький OpenAPI на /auth/*, — а не таблицу в компаньоне.
D. Note.code: обязательное поле против пустой клетки
ФАКТ. Приложение А компаньона (README.md:898) в последней строке — «незнакомая причина» —
оставляет клетку кода пустой, а поле Note.code в каноне обязательно. Платформа обязана что-то
отдать и отдаёт стабильный плейсхолдер unspecified (internal/ingest/notes.go). Слова
unspecified в компаньоне нет: grep даёт 0.
Почему это не мелочь. Движок и платформа выпускаются независимо, поэтому окно «движок эмитит причину, которой этот билд не знает» — штатное, а не теоретическое. Сегодня зона закрывает его значением, которого никто не ратифицировал.
⚠ МНЕНИЕ ПЛАТФОРМЫ. Минимум — заполнить клетку компаньона значением unspecified с пометкой,
что это не запись словаря, а ОБЯЗАННОСТЬ сервера. Желательное — одна фраза у Note.code в каноне о
том же. Бампа версии не требует: Note.code объявлен type: string, не enum, генерённые типы не
сужаются. ⚠ Прецедент D39.144 опорой НЕ служит — его довод «спека ещё никем не потреблена» больше
не верен.
Родственный вопрос, тоже не ратифицированный: ступени замечаний. У движка девять рангов, на
проводе два значения (attention/glance), и границу между ними платформа провела САМА, построчно:
«потерял ли читатель текст». Столбец «Ступень» приложения А стоит с ⬜.
E. Отпечаток интейка: канон называет размер, которого форма не объявляет (PD-262)
ФАКТ. §createBook: «"The same request" is compared over the DECLARED parts — the metadata and the
file's name and size». При этом схема BookIntake объявляет title, source_lang,
target_lang, file — и никакого размера.
Сервер до чтения тела знает только длину ЗАПРОСА, которая считает multipart-оболочку. Платформа её и
использует, и говорит это в комментарии вслух. Следствия, оба реальные: повтор того же файла с другой
границей multipart даёт ложный key_reused; клиент без объявленной длины (chunked) даёт «unknown»
обоим запросам, и две разные книги под одним ключом реплеят первую.
⚠ МНЕНИЕ ПЛАТФОРМЫ. Либо в форму добавляется объявленный размер (тогда сервер сверяет его с фактически прочитанным и откатывает расхождение — механизм уже есть, так устроен отказ на «часть после файла»), либо фраза канона приводится к тому, что сервер физически может сравнить. Сегодня она описывает деталь, которой в форме нет.
F. ETag/304 на getUsage и getRunOptions — зона выбрала удобное чтение
ФАКТ. Валидатор ставится на КАЖДЫЙ GET, потому что живёт в writeJSON, через который идут все
чтения. Канон объявляет условное чтение на коллекциях, карточке книги и /capabilities; про
getUsage и getRunOptions молчит — ни ETag в заголовках ответа, ни 304 в списке.
Зона отклонила эту находку на том основании, что канон не запрещает, а RFC 9110 разрешает валидатор на любом GET. Это правда, но это же и удобное для зоны чтение: альтернатива — список исключений в слое записи, который протухает за один пак.
⚠ МНЕНИЕ ПЛАТФОРМЫ. Дешевле и честнее объявить в каноне, что валидатор и 304 допустимы на любом
безопасном чтении этой поверхности, чем перечислять, где именно. Но если контракт хочет ровно
перечисленное — скажите, зона сузит; тогда это будет решение, а не умолчание.
G. Draft-only конвейер: дробь, которая не может достичь единицы (PD-202)
ФАКТ. chapters.units_done считает ТОЛЬКО волну edit (sink.go, и тот же предикат в
пересчёте после ре-ката). Движок штатно поддерживает конвейер без волны редактора
(backend/internal/pipeline/waverun.go, ветка if !editWave: «the draft IS the shipping output»).
На таком деплое Book.chapters_done, Chapter.units_done и Run.progress.done навсегда нули, а
канон §Progress требует, чтобы дробь достигала единицы.
Почему зона это не починила сама. Правильная форма — «считать по последней волне, которую книга реально видела» — меняет смысл контрактно видимого счётчика. Это продуктовое решение.
Вопрос контракту: считается ли начерновленная глава «сделанной» на деплое без волны редактора?
H. Снятие замечания дельтой невыразимо (PD-298)
ФАКТ. Дельта-чтение замечаний фильтруется предикатом ur.flagged. Резолюция, пере-разрешённая
как НЕ флагнутая (редрайв), обновляет строку и двигает ревизию, но в дельту не попадает — клиент
никогда не узнает, что замечание снято, и оно остаётся на экране навсегда. Канон §AfterVersion прямо
говорит: «A DELETION cannot be expressed this way», и требует одного из двух ответов
(resync_required либо 400 version_too_old); не даётся ни один.
ГРАНИЦА, и она важна: зона НЕ подтвердила чтением движка, что он вообще пере-издаёт unit_done
для той же тройки (глава, юнит, волна) с flagged=false. Комментарий платформы это утверждает, но
сверки нет. Порядок правильный: сначала сверка у движка, потом решение. Если переход недостижим,
правильный результат — строка в регистре «недостижимо», а не механизм.
I. content_refused объявлен дважды и не производится ничем
ФАКТ. Значение живёт в двух местах канона: корневой ErrorCode.content_refused (400, «один
грубый код на целый класс», и там же обязанность сервера ограничивать число попыток аккаунта) и
RejectReason.content_refused («сервис не будет переводить эту книгу»).
Платформа не производит ни того, ни другого: grep по зоне даёт только объявление константы и
строку таблицы кодов; ContractRejectReason отображает три внутренние причины и content_refused
среди них нет. Счётчика попыток, который канон вменяет серверу, тоже нет.
Вопрос контракту: это forward-looking значения (сознательно объявлены до того, как появится производитель) или невыполненное обязательство 0.3.0? От ответа зависит, заводить ли зоне долг.
J. Run не умеет сказать «остановлено по вашей просьбе» (§7(и))
Решение владельца 17.08 уже принято, строка для единого бэклога готова в
P7_ACCEPTANCE_HANDOFF.md §7(и) — здесь только чтобы список был полным. Суть: стоп, запрошенный во
время перевода, может встретиться с самостоятельным выходом движка на границе подписи банка;
прогон закрывается как awaiting_bank, и экран отвечает кнопкой «продолжить» на клик, который
означал «стоп». Статус менять НЕЛЬЗЯ (он несёт проводку и информативнее), лечение — признак «стоп был
запрошен».
Это тот же класс, что A и B: провод говорит правду о состоянии и не даёт действия.
K. --verify-bank: ратификация против поведения движка (§7(з))
ФАКТ. D39.144 ратифицировал «подпись = ОДИН акт над всем банком». Движок держит пер-термный гейт
полноты: его собственный лог — «the stop clears once every proposed term is promoted or rejected».
Платформа сегодня обходит расхождение тем, что на resume не передаёт флаг вовсе (тогда движок идёт
авто-путём: неподписанное едет с пометкой ⟨проверить⟩). Одно из двух должно уступить — это
ратификация, а не правка кода.
Остаток той же темы: decline пользователя до движка не доезжает — движок читает файл
mined_rejects, платформа его не пишет, поэтому отклонённый термин уедет в банк авто-строкой.
Территория строки единого бэклога 192, отложенной владельцем.
L. Мелкое, но пусть будет закрыто
- Ссылка приложения А протухла (ФАКТ).
README.md:902указывает на место дефолтного ранга в движке; живое —backend/internal/pipeline/status.go:237-262(flagReasonSeverity), а:174теперь проGlossaryMissFlagged. Лучше именем функции без номера строки. headingвсегдаnull(ФАКТ, и это канон соблюдается). Канон запрещает класть в это поле рендеренный порядковый, а у движкаheadingманифеста ровно им и является. Следствие: у читателя НЕТ меток глав вообще, пока у движка не появится производитель настоящих (его бэклог, строка 160). Продуктовый пробел стоит держать видимым, а не считать закрытым.410 Goneна опечатку в id главы (PD-253). Канон различает «была и исчезла» и «такой не было»; зона отвечает410на оба, и это задекларированное расхождение, принятое как есть.
M. Что зона сделала САМА и разрешения не просит
Чтобы контрактная сессия не тратила время: механика уже приведена в соответствие там, где решение
было зоны. codeForStatus снят. Область Idempotency-Key — каноническая (principal, method, path). resync_required вместо тихого старта «с текущего момента», включая дыру, подрезанную ДО
чтения кадров. blocked называет чужую книгу только когда её холд действительно укорачивает шкалу.
limit подрезается, а не отвергается. heading проецируется null. finalizing выведен из
словарей. Полоса прогона и её база считают один и тот же проход.
N. Чего платформа НЕ проверяла (границы этой записки)
- Фронт не смотрели вовсе: как эти ответы рисуются сегодня — вне зоны.
- Движок читали только по конкретным вопросам (майнинг, экспорт, ранги причин), целиком — нет.
- Пункт B живьём не воспроизводился.
- Пункт H не сверялся с движком — и это единственный пункт, где зона просит НЕ принимать решение до сверки.
- Ни один пункт этой записки не проверялся на совместимость с уже сгенерированными типами фронта
(
frontend/src/api/schema.ts): все предложения аддитивны по замыслу, но проверка — не зоны.