315 lines
30 KiB
Markdown
315 lines
30 KiB
Markdown
# Промт: контрактная сессия — синк с платформой по 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`): все предложения аддитивны по замыслу, но проверка — не зоны.
|