# Промт: контрактная сессия — синк с платформой по 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`).** «Продолжить» — это открыть прогону следующую попытку и взять новый денежный холд на ОСТАТОК бюджета этого прогона: ```go 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 как «продолжается» — таблица ему это и обещала. **⚠ МНЕНИЕ ПЛАТФОРМЫ.** Две аддитивные вещи, и первая важнее: 1. **Убрать мёртвый клик, а не объяснять его.** `Run` получает необязательный признак «этот прогон ещё можно продолжить». Платформа знает его точно; клиент вывести не может — `progress.done == total` не равно исчерпанию, глава может стоить дороже оценки. Экран тогда не рисует кнопку, которая не может сработать, а рисует «докупить главы» со шкалой из `run-options` (где `max_chapters: 0` уже значит «сейчас нельзя») и, если счёт пуст, «пополнить» — это уже говорит `Usage`. Порядок чтения этих пяти мест канон уже ратифицировал (§RunOptions), новых словарей не нужно. 2. **Строку в таблицу §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`): все предложения аддитивны по замыслу, но проверка — не зоны.