diff --git a/docs/PROGRESS.md b/docs/PROGRESS.md index eade0188..cc9c577b 100644 --- a/docs/PROGRESS.md +++ b/docs/PROGRESS.md @@ -1,6 +1,6 @@ # Журнал прогресса -> **⟶ ТЕКУЩЕЕ СОСТОЯНИЕ** (на 2026-08-27, голова D39.161 — КУРС: движок и платформа до «работает и отдаёт результат», фронт ЗАМОРОЖЕН и P7 его НЕ размораживает (D39.147). **ОЧЕРЕДЬ №19** (единственный носитель — здесь; роль передана 22.08, №18 закрыт D39.155): (а) **P7 платформы ПРИНЯТ С ФИКС-ЛИСТОМ и ЗАЛЕНДЕН, сессия закрыта владельцем 20.08** (D39.153; зонный фикс-лист из тринадцати пунктов плюс врезанный первым блокер свипа — пинг №18 в `platform/docs/platform-PROGRESS.md`; ⚠ прежний врезанный пример «первым `ContractVersion` 0.3.0 при формах 0.4.0» СНЯТ — константа поднята и запинена гейтом против канона лендингом `31f1f82`) · (а1) **контракт API v0 0.4.0 РАТИФИЦИРОВАН** (D39.152; PD-327 закрыт; зеркало фронта отстаёт ратифицированно, при разморозке `cmp`) · (б) **лендинг петель полигона (фаза Д) — ОТКРЫТ**: приёмка ФАЗЫ у параллельного оркестратора, лендинг за этим; ⚠ **предупреждение в силе: работа полигона в дереве по-прежнему НЕЗАКОММИЧЕНА** (20 позиций: `START_PROMT.MD` + 14 правленых и 5 новых файлов `eval/`). Владелец перевозил её на новую машину транспортным коммитом `temp`, но 24.08 **снял его словом владельца** (`reset --mixed`, дерево не тронуто — сверено побайтно, бэкап `~/tm-backup-2026-08-24/textmachine-HEAD.bundle` + reflog): транспортный коммит вдобавок уносил `START_PROMT.MD`, который канон коммитить запрещает. Состав читать командой `git status --short -- eval/`, снимок здесь протухает. Не трогать и не «прибирать»; приёмка ФАЗЫ — за параллельным оркестратором, пинг №20 — в секции «Полигон». Форма коммита строго pathspec · (в) **дофикс-пак P8-FIX ПРИНЯТ И ЗАЛЕНДЕН 22.08** (D39.154, лендинг `31f1f82`): блокер свипа закрыт четырьмя механизмами и пере-проверен живой пробой оркестратора, деньги сошлись двумя путями из сырого леджера; мои четыре строки регистра — `PD-371`…`PD-374`. ⚠ **Порядок вышел ОБРАТНЫМ этой очереди:** промт дофикса требовал запуска ПОСЛЕ читающего пака, запущен был раньше. Цена перестановки, названная мной вслух 21.08 («блокер живёт всё время читающего пака»), тем самым НЕ заплачена — это лучший исход, чем планировалось; в минус — §4.7 (аддендум релеем) пуст и читающий пак пойдёт по только что переписанному коду. **ЧТО ДАЛЬШЕ С ПЛАТФОРМОЙ — решено 22.08 при актуализации доков, порядок такой.** **(1) Читающий пак P8-REVIEW — ОТРАБОТАН, ПРИНЯТ И ЗАЛЕНДЁН 27.08** (D39.159): четыре оси, 24 новые строки регистра (`PD-375`…`PD-398`), 17 дописок, каталог воспроизведения `platform/docs/p8-review/`. Единственный vuln — `PD-379` (major): открытый поток событий переживает отзыв сессии. Числа сдачи пере-мерены приёмкой на дереве С лендингом бэкенда. Промт архивируется. **Следующая работа зоны — кодовый пак по этим строкам, запуск по слову владельца.** Промт ЖИВ и переписан 22.08 в трёх местах, которые дофикс сделал ложными: «два денежных дефекта не заведены» (заведены и закрыты — `PD-333`/`PD-334`, вместо них дан их КЛАСС и живой образец `PD-372`) · приор оси 4 «наблюдаемость без ручки» (ручка построена ⇒ вопрос стал «достаточна ли она») · абсолют «оси ни в один дифф не входили» (сужен: системного разбора не было, кусками входили). **Довод ЗА пак — базовая ставка, а не вера:** первый же серьёзный взгляд на ОДНУ из четырёх осей (очередь, 21.08) дал пред-продовый блокер, проживший два пака под статусом `fixed`. Осей осталось три, и на двух из них лежат деньги и вход. **(2) ПОРЯДОК РАБОТ ПО ШВУ — ратифицирован D39.156 (23.08), исполняется строго так.** **(2а) Движковый пак — АКТИВНЫЙ ПРОМТ ЗОНЫ** (`BACKEND_SEAM_PACK_SESSION_PROMPT.md`, выдан 23.08, запуск по слову владельца): конвенционные дефолты `mined_delta`/`mined_rejects` · $0-глагол приёма правок банка · путь к файлу ключей аргументом (снимает блокер **211**) · строгость сид-загрузчика · путь артефакта и версия документа в `status --json` (снимает движковую половину **213**). **(2б) Контрактный минор — ПОСЛЕ него и ОДНИМ куском:** снос отменённой пер-термной модели + новая дверь, тело которой выводится из словаря глагола, а не сочиняется до него; сюда же строки **200**, **203** и хвосты компаньона. ⚠ Гигиена держится сознательно: снести дверь сейчас и завести в следующем миноре — две перегенерации фронта вместо одной; фолбэк объявлен — если пак застрянет надолго, гигиену шипить отдельно. **(2в) Платформенный пак — последним:** воркер решений → глагол перед возобновлением · путь ключей из конфига · снятие дубля вывода пути · дев-супервизор на тот же механизм ключей. **(3) `sqlc` отдельной сессией** (решение владельца 22.08, `BACKLOG.md` П-19, граница 41 запрос в пяти файлах) — механическая работа, не мешать её с содержательной. **(4) Живой прогон книги насквозь** — строка 202, гейт прежний: ПОСЛЕ холодного прогона движка. ⚠ **На владельце и блокирует пункт своей темы:** развилка владения `book.yaml` (строка 199а, зона сдала четырьмя вариантами с ценой, рекомендация зоны C или D+B) — без неё труба доставки правок банка не строится, а верхнего края у неё теперь нет вовсе · (г) **контракт:** сквозная полоса прогресса (строка 200, слово владельца 20.08) и хвосты релеев (203) едут тем же минором, что и дверь приёма правок банка — состав собирается ПОСЛЕ ратификации нормы шва, см. пункт (2) · (д) свободные бэкенд: 160 Этап 0, фикс-лист ФЧ строкой 197 (носителя-сессии нет по слову владельца); ⚠ строки 176/181/187 СНЯТЫ с таблицы при лендинге 20.08 — они закрыты ещё D39.149, а очередь всё это время звала 181 свободной работой (поймано ревью доков; остатки живут строками 194/196/197) · (е) фронтовый фрагмент pre-commit хука получает норму «жалуйся владельцу» — **решение владельца 21.08: ДА**, исполняется первым касанием зоны (пинг в её журнале). · (ж) **ПЕРЕЕЗД НА НОВУЮ МАШИНУ — стенд ПЕРЕСОБРАН 24.08 (№19), как поднимать — карта `README.md`, строки «Тулчейн» и `counts.py`.** Ubuntu 26.04 WSL2, пользователь `ubuntu-26`; тулчейн стоял НУЛЕВОЙ. Поставлено: Go 1.26.7 · golangci-lint 2.12.2 · Node 22.23.2 — тарболами вендоров со сверкой sha256 в `~/.local/opt` (симлинки в `~/.local/bin`); PostgreSQL 18.4 — рецептом самой зоны (`platform/docs/STACK_DECISIONS.md` §«Postgres на стенде без root»); `build-essential` + `python3-venv` — руками владельца, они единственные требовали root. **Обе батареи зелены под `-race`:** бэкенд `make battery` EXIT=0, линтер 0 issues, скипы только корпус-гейченные и хелперы; платформа `make check` EXIT=0, 18 пакетов, линтер 0 issues, **скипов 0**. Фронт: `npm ci` + `npm run check` проходит целиком (335 тестов), `vite build` собирается. Сквозняк проверен ЖИВЬЁМ: демон платформы → сид через настоящий интейк → движок распарсил книгу (3 главы) → дев-прокси фронта достучался до платформы. ⚠ **Что НЕ проверено:** ни одного платного вызова · браузеров Playwright нет ⇒ `check:full` не гонялся · локальной модели и GPU на машине нет вовсе (слово владельца 24.08: мерить нечем, ветка не закрыта) · OIDC не поднимался, вход только дев-логином. ⚠ Правка `.gitignore` владельца несла дефект — правило `books/` без якоря глушило ЛЮБОЙ каталог `books`, включая живой пакет `platform/internal/books/` (новый файл там уходил бы в игнор молча); заякорено в `/books/`, проверено пробой в обе стороны. Новые строки бэклога — **217** (пути полигона), **218** (дефолты корпуса в движке — ЗАКРЫТА и снята 27.08, D39.159 эррата-д), **219** (line-якоря D-лога съехали — ⚠ ОБЪЯВЛЕНА 24.08, а В ТАБЛИЦЕ ЗАВЕДЕНА только 27.08: три дня очередь ссылалась на несуществующий носитель) **Открытые обязательства, которых нет больше нигде:** веса `pro` — строка 172-г НЕ закрыта, прогон мерил классификатор на flash; дешёвый вход — спросить полигон, говорят ли что-то данные фазы Д (там pro гоняется редактором), отдельный замер не покупать без этого · `docs/experiments/00-provider-quirks.md` несёт протухшие цены DeepSeek («не изменились, flash $0.14/$0.28») — зона полигона, чинить пингом · санкция на платный прогон в архивном промте пака честности была РАЗОВОЙ · **норма плотности комментариев — ОТВЕЧЕНА владельцем 21.08:** режем ВОДУ, а не длину — счёт строк «пиши одну-две» негодный гейт и снят; уходит пересказ решений, провенанс и изложение исследования вместо ссылки, остаётся всё, что из одной функции не выводится, сколько бы строк ни заняло (норма переписана — `12-go-style-notes` §1; PD-255 платформы этим закрывается). ⚠ Метод-урок: её хендофф объявил «вопросов на владельце нет», а строка регистра просила именно решения — вопрос был закрыт ОБЪЯВЛЕНИЕМ, поймано ревьюером полноты выгрузки. Оркестраторов ДВА (решение владельца 07.08): этот — движок/платформа/фронт/доки; параллельный (РОЛЬЮ, без номера — счётчик один, D39.112 п.6) — приёмка полигона. Одновременно не запускаются; CURRENT-STATE ведут оба, чужие строки не трогают. Норма изоляции панелей — D39.113, гардрейлы в CLAUDE.md.) +> **⟶ ТЕКУЩЕЕ СОСТОЯНИЕ** (на 2026-08-28, голова D39.163 — КУРС: движок и платформа до «работает и отдаёт результат», фронт ЗАМОРОЖЕН и P7 его НЕ размораживает (D39.147). **ОЧЕРЕДЬ №19** (единственный носитель — здесь; роль передана 22.08, №18 закрыт D39.155): (а) **P7 платформы ПРИНЯТ С ФИКС-ЛИСТОМ и ЗАЛЕНДЕН, сессия закрыта владельцем 20.08** (D39.153; зонный фикс-лист из тринадцати пунктов плюс врезанный первым блокер свипа — пинг №18 в `platform/docs/platform-PROGRESS.md`; ⚠ прежний врезанный пример «первым `ContractVersion` 0.3.0 при формах 0.4.0» СНЯТ — константа поднята и запинена гейтом против канона лендингом `31f1f82`) · (а1) **контракт API v0 0.4.0 РАТИФИЦИРОВАН** (D39.152; PD-327 закрыт; зеркало фронта отстаёт ратифицированно, при разморозке `cmp`) · (б) **лендинг петель полигона (фаза Д) — ОТКРЫТ**: приёмка ФАЗЫ у параллельного оркестратора, лендинг за этим; ⚠ **предупреждение в силе: работа полигона в дереве по-прежнему НЕЗАКОММИЧЕНА** (20 позиций: `START_PROMT.MD` + 14 правленых и 5 новых файлов `eval/`). Владелец перевозил её на новую машину транспортным коммитом `temp`, но 24.08 **снял его словом владельца** (`reset --mixed`, дерево не тронуто — сверено побайтно, бэкап `~/tm-backup-2026-08-24/textmachine-HEAD.bundle` + reflog): транспортный коммит вдобавок уносил `START_PROMT.MD`, который канон коммитить запрещает. Состав читать командой `git status --short -- eval/`, снимок здесь протухает. Не трогать и не «прибирать»; приёмка ФАЗЫ — за параллельным оркестратором, пинг №20 — в секции «Полигон». Форма коммита строго pathspec · (в) **дофикс-пак P8-FIX ПРИНЯТ И ЗАЛЕНДЕН 22.08** (D39.154, лендинг `31f1f82`): блокер свипа закрыт четырьмя механизмами и пере-проверен живой пробой оркестратора, деньги сошлись двумя путями из сырого леджера; мои четыре строки регистра — `PD-371`…`PD-374`. ⚠ **Порядок вышел ОБРАТНЫМ этой очереди:** промт дофикса требовал запуска ПОСЛЕ читающего пака, запущен был раньше. Цена перестановки, названная мной вслух 21.08 («блокер живёт всё время читающего пака»), тем самым НЕ заплачена — это лучший исход, чем планировалось; в минус — §4.7 (аддендум релеем) пуст и читающий пак пойдёт по только что переписанному коду. **ЧТО ДАЛЬШЕ С ПЛАТФОРМОЙ — решено 22.08 при актуализации доков, порядок такой.** **(1) Читающий пак P8-REVIEW — ОТРАБОТАН, ПРИНЯТ И ЗАЛЕНДЁН 27.08** (D39.159): четыре оси, 24 новые строки регистра (`PD-375`…`PD-398`), 17 дописок, каталог воспроизведения `platform/docs/p8-review/`. Единственный vuln — `PD-379` (major): открытый поток событий переживает отзыв сессии. Числа сдачи пере-мерены приёмкой на дереве С лендингом бэкенда. Промт архивируется. **Следующая работа зоны — кодовый пак по этим строкам, запуск по слову владельца.** Промт ЖИВ и переписан 22.08 в трёх местах, которые дофикс сделал ложными: «два денежных дефекта не заведены» (заведены и закрыты — `PD-333`/`PD-334`, вместо них дан их КЛАСС и живой образец `PD-372`) · приор оси 4 «наблюдаемость без ручки» (ручка построена ⇒ вопрос стал «достаточна ли она») · абсолют «оси ни в один дифф не входили» (сужен: системного разбора не было, кусками входили). **Довод ЗА пак — базовая ставка, а не вера:** первый же серьёзный взгляд на ОДНУ из четырёх осей (очередь, 21.08) дал пред-продовый блокер, проживший два пака под статусом `fixed`. Осей осталось три, и на двух из них лежат деньги и вход. **(2) ПОРЯДОК РАБОТ ПО ШВУ — ратифицирован D39.156 (23.08), исполняется строго так.** **(2а) Движковый пак — АКТИВНЫЙ ПРОМТ ЗОНЫ** (`BACKEND_SEAM_PACK_SESSION_PROMPT.md`, выдан 23.08, запуск по слову владельца): конвенционные дефолты `mined_delta`/`mined_rejects` · $0-глагол приёма правок банка · путь к файлу ключей аргументом (**211** — движковый конец; платформенный доехал паком P9, строка ЗАКРЫТА D39.162) · строгость сид-загрузчика · путь артефакта и версия документа в `status --json` (**213** — движковая половина; платформенная снята P9, у строки остался один дев-путь). **(2б) Контрактный минор — ПОСЛЕ него и ОДНИМ куском:** снос отменённой пер-термной модели + новая дверь, тело которой выводится из словаря глагола, а не сочиняется до него; сюда же строка **203** и хвосты компаньона. ⚠ Строка **200** ушла НЕ сюда: D39.160 изъял её из этого минора и отправил с паком (2в), где она и закрылась минором **0.6.0**. ⚠ Гигиена держится сознательно: снести дверь сейчас и завести в следующем миноре — две перегенерации фронта вместо одной; фолбэк объявлен — если пак застрянет надолго, гигиену шипить отдельно. **(2в) Платформенный пак P9 — ОТРАБОТАН, ПРИНЯТ И ЗАЛЕНДЁН 28.08 (D39.162):** дверь правок банка смонтирована синхронно и под пер-книжным мьютексом · ключи доехали до движка аргументом `--keys-file` (строка **211** ЗАКРЫТА) · дубль конвенции пути снят (строка **213** — осталась одна строка в дев-пути) · снята сквозная полоса прогресса вместе с её канонной половиной, минор **0.6.0** (строка **200** ЗАКРЫТА) · с провода сняты два поля упразднённой модели (`PD-399`). ⚠ Пункт «воркер решений» СНЯТ эрратой 27.08-з: синхронность держится, замер сессии её подтвердил. Фикс-раунд приёмки — 13 позиций, две мои диспозиции сессия ОПРОВЕРГЛА исполнением и я это принял (см. D39.162). **(3) `sqlc` отдельной сессией** (решение владельца 22.08, `BACKLOG.md` П-19, граница 41 запрос в пяти файлах) — механическая работа, не мешать её с содержательной. **(4) Живой прогон книги насквозь** — строка 202, гейт прежний: ПОСЛЕ холодного прогона движка. ⚠ **На владельце и блокирует пункт своей темы:** развилка владения `book.yaml` (строка 199а, зона сдала четырьмя вариантами с ценой, рекомендация зоны C или D+B) — без неё труба доставки правок банка не строится, а верхнего края у неё теперь нет вовсе · (г) **контракт: сквозная полоса прогресса ЗАКРЫТА** — минор **0.6.0** заленджен вместе с паком P9 (D39.162): доля одна на всю работу прогона через обе волны, подпись стадии объявлена ВТОРЫМ ограниченным исключением из границы «ничего о том, КАК переводится книга». ⚠ Остаток назван и НЕ забыт: платформа продаёт главы, а движку передаёт только `--ceiling-usd` — единицы разные, и это `PD-410` (архитектурный стоп зоны). Хвосты релеев (203) ждут следующего минора · (д) свободные бэкенд: 160 Этап 0, фикс-лист ФЧ строкой 197 (носителя-сессии нет по слову владельца); ⚠ строки 176/181/187 СНЯТЫ с таблицы при лендинге 20.08 — они закрыты ещё D39.149, а очередь всё это время звала 181 свободной работой (поймано ревью доков; остатки живут строками 194/196/197) · (е) фронтовый фрагмент pre-commit хука получает норму «жалуйся владельцу» — **решение владельца 21.08: ДА**, исполняется первым касанием зоны (пинг в её журнале). · (ж) **ПЕРЕЕЗД НА НОВУЮ МАШИНУ — стенд ПЕРЕСОБРАН 24.08 (№19), как поднимать — карта `README.md`, строки «Тулчейн» и `counts.py`.** Ubuntu 26.04 WSL2, пользователь `ubuntu-26`; тулчейн стоял НУЛЕВОЙ. Поставлено: Go 1.26.7 · golangci-lint 2.12.2 · Node 22.23.2 — тарболами вендоров со сверкой sha256 в `~/.local/opt` (симлинки в `~/.local/bin`); PostgreSQL 18.4 — рецептом самой зоны (`platform/docs/STACK_DECISIONS.md` §«Postgres на стенде без root»); `build-essential` + `python3-venv` — руками владельца, они единственные требовали root. **Обе батареи зелены под `-race`:** бэкенд `make battery` EXIT=0, линтер 0 issues, скипы только корпус-гейченные и хелперы; платформа `make check` EXIT=0, 18 пакетов, линтер 0 issues, **скипов 0**. Фронт: `npm ci` + `npm run check` проходит целиком (335 тестов), `vite build` собирается. Сквозняк проверен ЖИВЬЁМ: демон платформы → сид через настоящий интейк → движок распарсил книгу (3 главы) → дев-прокси фронта достучался до платформы. ⚠ **Что НЕ проверено:** ни одного платного вызова · браузеров Playwright нет ⇒ `check:full` не гонялся · локальной модели и GPU на машине нет вовсе (слово владельца 24.08: мерить нечем, ветка не закрыта) · OIDC не поднимался, вход только дев-логином. ⚠ Правка `.gitignore` владельца несла дефект — правило `books/` без якоря глушило ЛЮБОЙ каталог `books`, включая живой пакет `platform/internal/books/` (новый файл там уходил бы в игнор молча); заякорено в `/books/`, проверено пробой в обе стороны. Новые строки бэклога — **217** (пути полигона), **218** (дефолты корпуса в движке — ЗАКРЫТА и снята 27.08, D39.159 эррата-д), **219** (line-якоря D-лога съехали — ⚠ ОБЪЯВЛЕНА 24.08, а В ТАБЛИЦЕ ЗАВЕДЕНА только 27.08: три дня очередь ссылалась на несуществующий носитель) **Открытые обязательства, которых нет больше нигде:** веса `pro` — строка 172-г НЕ закрыта, прогон мерил классификатор на flash; дешёвый вход — спросить полигон, говорят ли что-то данные фазы Д (там pro гоняется редактором), отдельный замер не покупать без этого · `docs/experiments/00-provider-quirks.md` несёт протухшие цены DeepSeek («не изменились, flash $0.14/$0.28») — зона полигона, чинить пингом · санкция на платный прогон в архивном промте пака честности была РАЗОВОЙ · **норма плотности комментариев — ОТВЕЧЕНА владельцем 21.08:** режем ВОДУ, а не длину — счёт строк «пиши одну-две» негодный гейт и снят; уходит пересказ решений, провенанс и изложение исследования вместо ссылки, остаётся всё, что из одной функции не выводится, сколько бы строк ни заняло (норма переписана — `12-go-style-notes` §1; PD-255 платформы этим закрывается). ⚠ Метод-урок: её хендофф объявил «вопросов на владельце нет», а строка регистра просила именно решения — вопрос был закрыт ОБЪЯВЛЕНИЕМ, поймано ревьюером полноты выгрузки. Оркестраторов ДВА (решение владельца 07.08): этот — движок/платформа/фронт/доки; параллельный (РОЛЬЮ, без номера — счётчик один, D39.112 п.6) — приёмка полигона. Одновременно не запускаются; CURRENT-STATE ведут оба, чужие строки не трогают. Норма изоляции панелей — D39.113, гардрейлы в CLAUDE.md.) > - ⚠ **СОСТОЯНИЕ ПРИЁМКИ P9 на 28.08 — дамп перед компакцией (D39.121).** Дерево передано, **лендинг НЕ сделан**. Пройдено: адверсариальное ревью независимой сессией (26 находок) + воркфлоу-ревью самой сессии (16 линз, 2 прогона, Fable 5 на тяжёлых). **Блокер полосы закрыт и пере-проверен** пробой ревьюера на новом дереве (`2/2` против `0/4`). **Блокер `preview` закрыт** тремя пинами, включая живой против настоящего движка. Починены: обрыв валидации на потолке · ключи в композиционном корне · ложный клейм пина · пин кадра · два лгущих комментария. **ЖДЁТ починки (заказано, не сделано):** двойной замер 1 МиБ (`json.Marshal` экранирует HTML, 1 байт → 6) · мьютекс без контекста · Р1 окно `bank_stop`→`finished_at` · Р2 миграция для прогонов в полёте · Р3 `draftWork=0` у continuation · Р5 presence-vs-null · Р4 `limitedBuffer`. **Состав лендинга:** дерево P9 + канонная половина прогресса (обязательна: канон `openapi.yaml:1339` описывает посегментный прогресс, который пак снял) + закрытие `PD-166` + `PD-400`.2 в `accepted-risk` + закрытие `PD-401`. > - ⚠ **ИЗ ПОЛНОГО ЧТЕНИЯ БЭКЛОГА 28.08 — в ПЛАТФОРМЕННЫЙ пак, не в бэкендовый:** строка **215** — движковый $0-точечный репин работает только под `--resnapshot`, а платформа флага НЕ передаёт; строка прямо говорит, что этот механизм и делает подпись термина дешёвой ⇒ правка термина может стоить полной пере-покупки волны вместо точечной пере-редактуры. **Самое денежное из найденного за день.** Строка **216** — подпись банка, halt на потолке и резюм после пополнения не проверялись против живого движка ни разу; живая проба P9 закрывает ЧАСТЬ, диспозировать при лендинге. > - ⚠ **Бэкенд-промт `BACKEND_SILENT_HARM_SESSION_PROMPT.md`: второй рубеж ПРОТУХ.** Опровержение (12 находок) делалось ДО четырёх добавлений — движковой идемпотентности (§3.3), четвёртой дыры (141), предупреждения о столкновении (201) и соседа (131). Механическая сверка против 12 блоков и фантом-гард пере-прогнаны и чисты; независимого опровержения текущей редакции НЕТ. @@ -17,7 +17,7 @@ > - **Курс:** ОБЩНОСТЬ ✅ → КАЧЕСТВО БАНКА ✅ → ПАКЕТ-ЧЕКЕРОВ ✅ (D39.59–78) → **ФРОНТ-ЭРА** (D39.81–100: зоны живые, контракт API ратифицирован) → **шов/платформа/движковые блокеры построены** (D39.106–123). Хвосты курса живут строками: 16 (полная цена холодного старта не измерена: у `coldrun-a`/`coldrun-b` нет НИ ОДНОГО редакторского вызова, хотя сама edit-волна отработала и оплачена — 58 вызовов волновым драйвером 23–25.07 на малых прогонах, плюс 648 до драйвера; ⚠ прежняя редакция писала «не гонялась НИ РАЗУ» — абсолют ложен, испр. оркестратором №19 по леджерам `~/books/gu-zhenren/**/*.db`; держит и оси голоса 13б/24) · 46 (дизайн заморожен D39.92/93, промт ждёт выдачи) · coldrun-b фаза C заморожена чекпойнтом легитимно (D39.86; эталон денег/поведения — coldrun-a, read-only); развилка 0731 решена и исполнена (D39.87/91, код `553f1a3`). > - **Горизонт (D39.62/67, освежён D39.95):** **ДОБОР ИДЕАЛА** (первым прогоном: оси голоса 24 · авто-режим · цена 16; жильцы ролей решаются ДО прогона эксп-22 — строка 149 · веса K1–K12 13а · вне-претрейн чекпоинт 55; остаток арбитража банка = рецензент спорных кластеров при ре-пробе 74 — D39.102) → ВТОРАЯ ПАРА живьём (ja→ru; преп 81) → МАСШТАБ → пилот Ф2.5 (гейт резюме-строки 80; строки 62–68, 85) → Ф3 ридер-IDE (69–71). **Стоячие:** ToS-триггер 25.10 · Ш-2 до go1.27 (⚠ + x/text Unicode 17 тем же тулчейном — строка 119, реестр §Б-108 справочника якорей) · платные прогоны разблокированы (проба провода — D39.97, конфиги 112 залендены). > - **Стек (полная карта роль→модель→конфиг→квирки — [STACK.md](STACK.md), D39.126):** draft deepseek-v4-flash thinking-ON `low` **⚠0731** → терминолог (та же модель) → editor deepseek-v4-pro БИЛИНГВ ИНТЕРИМ (топология ПОДТВЕРЖДЕНА при неразличимости жильцов — D39.117; закон-блок ОБЯЗАТЕЛЕН — строка 134; glm-5 резерв; вахта маппинга эффорта pro — §Б-108) → судья gemini (Ф2, в движке НЕ построен — строка 33); канал B Mistral+grok; ~$0.85/ранобэ (D30.4, пере-калибровка при следующем платном прогоне). ⚠ **Вендор-факты 13–15.08 (пере-пин ИСПОЛНЕН и ПРИНЯТ, D39.137):** таблица цен запинена ПИКОМ (flash 0.44/1.32 · pro 1.32/3.96/кэш-хит 0.044 за 1M; счёт шиппинг-c1 = 100% DeepSeek ⇒ ×4.2–4.4 в пике / ×2.1–2.2 в долине — замер по трём прогонам); ⚠ про эффорт pro НОСИТЕЛИ ПРОТИВОРЕЧАТ и это НЕ РЕШЕНО (испр. 23.08 №19: прежняя редакция утверждала здесь одну сторону — «эффорт стал настраиваемым low/high/max, квирк 3а устарел»): квирк-канон и `STACK.md` говорят «ручки у pro НЕТ», замера поведением после 13.08 нет ни у одной стороны, и по гардрейлу владельца такое закрывается ВЕНДОР-СВЕРКОЙ, а не выбором стороны; до неё в силе канон, разбор — `STACK.md` строка редактора; ⚠ ВЕСА pro сменились под тем же слагом (V4-Pro-0813, класс D39.61) — вахта-риг готов (остаток 172); ⚠ посылка интерим-редактора «dspro дешевле glm» в пике ПЕРЕВЁРНУТА (×1.26 дороже — вход ратификации фазы Д, D39.137 п.4); покупки фазы Д на deepseek до 16.08 16:00 UTC — по старым ценам. ⚠ **ВЕСЬ банковый контур (банкнота+терминолог+классификатор) в shipping-c1 НЕ включён** — жив ран-локальным конфигом книги (строка 140; сверка STACK.md 09.08 — факт шире прежней декларации «одна банкнота»); эскалация в shipping за `budget_usd: 0` (STACK.md §примечания). -> - **ЕДИНЫЙ БЭКЛОГ — секция «Бэклог» ниже** (одна таблица, единственный трекер; каждая петля обязана иметь диспозицию: решено / отложено-с-записью / отклонено; ведёт оркестратор). **СЧЁТ ОЧЕРЕДИ на 27.08 (скриптом по таблице — `python3 docs/scripts/counts.py`; обновлять при каждом лендинге):** всего **176** строк · зона бэкенд **81** строго / **122** широко (175 пере-скоуплена D39.134; 176/177 заведены 15.08; 178 — D39.136; 179 ЗАКРЫТА D39.138; 180/181 — D39.137; 182 — wire-батч, аудит 15.08; 183–187 — контракт-ревью 28, D39.138; 188–190 — аудит бэклога, D39.140; 191/192 — модель подписи банка и пост-ридинговый цикл, D39.144; 193 — молчащие дыры выдачи, D39.147; 194–196 — приёмка пака честности, D39.149; 197 — фикс-лист ФЧ, D39.150; **198–204 — приёмка P7 и слова владельца 20.08, D39.153; 205 — слепота гейта якорей, аудит 21.08; 206–207 — приёмка P8-FIX, D39.154; 208–209 — аудит доков 22.08 (потеря глоссария на Gemini, риг живых проб); 210 — трассировка цепи банка 22.08 (род не производится авто-путём); **211–213 — консилиум шва 22–23.08: ключи провайдеров не доезжают до движка на SaaS (блокер 202), нестрогий загрузчик сида, дубль конвенции пути у платформы; 214–216 — разбор журнала трассировки банка: подпись не оставляет следа, $0-репин недостижим с платформы, три несущих сценария не проверены живым движком; 217–218, 220 — аудит переезда машины 24.08: абсолютные пути полигона, дефолты корпуса в движке, бутстрап гейта доков чужой замороженной зоной; 219 (съехавшие line-якоря D-лога) ЗАКРЫТА тем же заходом — D39.157 п.6**) (⚠ колонки счётчик читает С КОНЦА — испр. D39.116) · **блокеров очереди 0**, платные прогоны разблокированы · «скоро» **50** (перечень — грепом по таблице, рукописный список снят D39.126) · гейт-строки эксп-22: 55·153 (плюс 150 — руки владельца); строка 5 — остаток гейчен ре-пробой 74, носитель события теперь строка 188 (аудит D39.140); остальное «когда-нибудь». ⚠ Счёт — НИЖНЯЯ граница долга, не потолок (разбор — легенда таблицы ниже). +> - **ЕДИНЫЙ БЭКЛОГ — секция «Бэклог» ниже** (одна таблица, единственный трекер; каждая петля обязана иметь диспозицию: решено / отложено-с-записью / отклонено; ведёт оркестратор). **СЧЁТ ОЧЕРЕДИ на 27.08 (скриптом по таблице — `python3 docs/scripts/counts.py`; обновлять при каждом лендинге):** всего **175** строк · зона бэкенд **82** строго / **120** широко (175 пере-скоуплена D39.134; 176/177 заведены 15.08; 178 — D39.136; 179 ЗАКРЫТА D39.138; 180/181 — D39.137; 182 — wire-батч, аудит 15.08; 183–187 — контракт-ревью 28, D39.138; 188–190 — аудит бэклога, D39.140; 191/192 — модель подписи банка и пост-ридинговый цикл, D39.144; 193 — молчащие дыры выдачи, D39.147; 194–196 — приёмка пака честности, D39.149; 197 — фикс-лист ФЧ, D39.150; **198–204 — приёмка P7 и слова владельца 20.08, D39.153; 205 — слепота гейта якорей, аудит 21.08; 206–207 — приёмка P8-FIX, D39.154; 208–209 — аудит доков 22.08 (потеря глоссария на Gemini, риг живых проб); 210 — трассировка цепи банка 22.08 (род не производится авто-путём); **211–213 — консилиум шва 22–23.08: ключи провайдеров не доезжают до движка на SaaS (блокер 202), нестрогий загрузчик сида, дубль конвенции пути у платформы — 211 ЗАКРЫТА и 213 сужена лендингом P9 (D39.162); 214–216 — разбор журнала трассировки банка: подпись не оставляет следа, $0-репин недостижим с платформы, три несущих сценария не проверены живым движком; 217–218, 220 — аудит переезда машины 24.08: абсолютные пути полигона, дефолты корпуса в движке, бутстрап гейта доков чужой замороженной зоной; 219 (съехавшие line-якоря D-лога) ЗАКРЫТА тем же заходом — D39.157 п.6**) (⚠ колонки счётчик читает С КОНЦА — испр. D39.116) · **блокеров очереди 0**, платные прогоны разблокированы · «скоро» **48** (перечень — грепом по таблице, рукописный список снят D39.126) · гейт-строки эксп-22: 55·153 (плюс 150 — руки владельца); строка 5 — остаток гейчен ре-пробой 74, носитель события теперь строка 188 (аудит D39.140); остальное «когда-нибудь». ⚠ Счёт — НИЖНЯЯ граница долга, не потолок (разбор — легенда таблицы ниже). > - Архивы хроники: `archive/PROGRESS-2026-07-04-10.md` (D31) · `-10-13` (D39.6-гигиена) · `-13-25` (стройка паков 11–16, rerun2) · **`-25-31` (паки 17–20 · мини-прогон · полигон-пакеты 5–8 · ToS · холодный прогон; D39.26–58)** · **`-08-01-02` (сессии №9/№10: общность · качество банка · coldrun-b · открытие фронта/платформы; D39.59–90, срез D39.105)** · **`-08-02-04` (сессии №11–№13: ручки эффорта · стандарты · контракт API · платформа P0 · банк-арбитраж; D39.91–105 + снимок шапки эры №15)** · `-08-04-09` (пинги закрытых паков эры №15) · **`-08-14-15` (закрытые бэкенд-записи №16–17: эмиттер шва · migrate · пере-пин DeepSeek; вынесено D39.139)**. Записи ниже — живой хвост (№16+, эра D39.124+; подрезка D39.139). ## Бэклог (ЕДИНЫЙ, собран 26.07, актуализация 04.08 D39.99/D39.101; правки — только через оркестратора) @@ -133,7 +133,7 @@ | 183 | **Батч 0.3.0 — ОТРАБОТАН ЦЕЛИКОМ (батч D39.142 + дофикс D39.143, `86720cf`); ⚠ «канон 0.3.0 финален» ПРОТУХЛО — канон стоит на 0.4.0, ратифицирован D39.152 (испр. 20.08). Промт в архиве.** ОСТАТОК-указатель: ~~(а) P7 разблокирован~~ — **прожито: P7 исполнен, принят и заленден 20.08 (D39.153), промт в архиве зоны**; (б) зеркало `frontend/docs/api-contract/` + типы + моки + гейт утечки — первое касание фронта при разморозке (расхождение канона и зеркала ВРЕМЕННО ратифицировано D39.142 п.5); ~~(в) вопрос владельцу — `BankTerm.decision`~~ — СНЯТ ОРКЕСТРАТОРОМ ещё 16.08 как стоящий на ложной посылке (D39.144 п.4), в остатке строки его быть не должно; прежний текст (D39.142 п.4, рекомендация заводить; при «да» — 4 строки схемы + 1 строка P7) | оркестратор | скоро (остаток = только (б) зеркало фронта при разморозке) | P7 → фронт при разморозке | D39.138, D39.142, **D39.143** | | 184 | **Жанр книги — ВЫКИНУТЬ (слово владельца 16.08). ⚠ ДВЕ ПОЛОВИНЫ ИЗ ЧЕТЫРЁХ УЖЕ ИСПОЛНЕНЫ** (сверено кодом при аудите доков 22.08): поле ушло из контракта и из формы интейка батчем 0.3.0, и платформа жанра больше НЕ ЗНАЕТ — `platform/internal/books/render.go` несёт `genre` шаблона нечитаемым насквозь и говорит об этом прямо. **ЖИВЫМИ остаются две:** (а) движковая — снять `{{genre}}` из промптов пары (сегодня он ещё в `terminologist.md`, `editor-mono.md`, `translator-banknote.md`) + поле из канона брифа: двигает `BriefHash` ⇒ ТОЛЬКО в общее resnapshot-окно, координировать со 161/182, дешёвое окно пока платная книга одна; (б) фронтовая — форма при разморозке; влияние жанр-строки на качество не измерено и уже не будет | бэкенд (+платформа/контракт/фронт) | скоро (движковая половина — resnapshot-окном) | касание в окне 161/182 + батч 183 | research/28 Б-23/§8 п.6, D39.138 п.2в | | 185 | **Дописывание глав в ТЕКУЩУЮ книгу («костылей не будет»):** форма `POST /books/{id}/parts` + ответ «сколько глав добавится, что сдвинется и почём» ДО подтверждения; экономика уже позволяет В КОНЕЦ за ~$0.02 (ключ вызова позиционный, исходник вне снапшота), вставка в середину = МОЛЧАЛИВАЯ перекупка хвоста ~$9–11/500 глав — движковый гейт против молчания ОБЯЗАТЕЛЕН (родня дыры Р6 строки 160); платформа второй файл принять не может (re-parse нет); ja-мина: новый ruby-алиас двигает `memory_version` → снапшот (громкий `--resnapshot`, уточнение приёмки); полный $0-резюм хвоста — предусловие D15.2 (строка 49) | бэкенд+платформа+контракт | когда-нибудь (вместе со строками 160–162 — дизайн в паке 161) | дизайн-пак 161 → стройка | research/28 Б-19а/§8 п.7, D39.138 п.2г | -| 186 | **Сеть: перечитывания ничем не удешевлены** (замер 16.08: дерево 2283 глав = 250 КБ на каждый кадр `status`, фокус-рефетч 12 вкладок = 562 КБ — самый частый трафик порождает переключение вкладок, не пайплайн; гипотетический кадр банка на главу = 372 МБ/прогон; сжатия/ETag/304 нет НИГДЕ): план по эффекту — (1) gzip на текстовых ответах, НЕ на SSE (2) `ETag`/`If-None-Match`→304 на списочных GET (3) скоуп в кадре (id+версия, БЕЗ текста) (4) дельта-чтение банка/замечаний `?after_version=` (5) `staleTime` у клиента; шаги 1–2 — платформа (её же `PLATFORM_DIRECTION.md:202-205`), семантика — в спеку батчем 183; транспорт НЕ менять (решение владельца по §5б) ⚠ **ШАГИ 1–2 ПОСТРОЕНЫ И ЗАЛЕНДЕНЫ P7 (D39.153):** gzip на JSON и НЕ на SSE + `ETag`/`If-None-Match`→`304` на коллекциях, карточке книги и `/capabilities`; валидатор считается от БАЙТ ответа, поэтому страница 2 и дельта той же ревизии различаются по построению. Остаток — шаги 3–5 | платформа (+контракт/фронт) | скоро (остаток: шаги 3–5) | мидлварь/edge платформы + батч 183 + разморозка фронта | research/28 §5б/Б-11а, D39.138 п.2д | +| 186 | **Сеть: перечитывания ничем не удешевлены** (замер 16.08: дерево 2283 глав = 250 КБ на каждый кадр `status`, фокус-рефетч 12 вкладок = 562 КБ — самый частый трафик порождает переключение вкладок, не пайплайн; гипотетический кадр банка на главу = 372 МБ/прогон; сжатия/ETag/304 нет НИГДЕ): план по эффекту — (1) gzip на текстовых ответах, НЕ на SSE (2) `ETag`/`If-None-Match`→304 на списочных GET (3) скоуп в кадре (id+версия, БЕЗ текста) (4) дельта-чтение банка/замечаний `?after_version=` (5) `staleTime` у клиента; шаги 1–2 — платформа (её же `PLATFORM_DIRECTION.md:202-205`), семантика — в спеку батчем 183; транспорт НЕ менять (решение владельца по §5б) ⚠ **ШАГИ 1–2 ПОСТРОЕНЫ И ЗАЛЕНДЕНЫ P7 (D39.153):** gzip на JSON и НЕ на SSE + `ETag`/`If-None-Match`→`304` на коллекциях, карточке книги и `/capabilities`; валидатор считается от БАЙТ ответа, поэтому страница 2 и дельта той же ревизии различаются по построению. ⚠ **ШАГИ 1–2 ПЕРЕ-ПРОВЕРЕНЫ ЗАМЕРОМ 28.08** (воркфлоу-ревью P9 на стенде, приёмка D39.162): банк 437 Б холодным → повтор с `If-None-Match` даёт **304 / 0 Б**; юниты главы 3008 Б → **740 Б gzip (×4.1)**; ниже порога 1024 Б gzip честно НЕ применяется; SSE корректно не сжимается. То есть половина строки не просто заленджена, а измеренно РАБОТАЕТ. Остаток — шаги 3–5 | платформа (+контракт/фронт) | скоро (остаток: шаги 3–5) | мидлварь/edge платформы + батч 183 + разморозка фронта | research/28 §5б/Б-11а, D39.138 п.2д | | 188 | **Носитель события «ре-проба 74»** (аудит D39.140: событие ждали строки 5 · 36б · 44 и шапка CURRENT-STATE, а владельца у него не было — строка 74 закрыта D39.91): дешёвая ре-проба flash при ПЕРВОМ платном прогоне добора идеала (D39.63 п.4) + слоты, подвешенные на неё: рецензент спорных кластеров банка (остаток 5) · замер второго мнения (36б) · выбор числа флора (44, после одобренной мини-пробы) · итерация №2 редакторов (D39.22/63) · выборочный порог 116 в риге (дописка 172-г) · решение о включении ре-гена перед эскалацией (экс-77, D39.64: «включение — решением при ре-пробе 74»; ручка построена, мертва во всех конфигах, ре-ген ≈7.6× дешевле эскалации) | полигон/бэкенд | скоро (гейт добора идеала — первый платный прогон) | проб-пакет при первом платном прогоне → решения по слотам | D39.63 п.4, D39.91, аудит D39.140 | | 189 | **Мультиязычность фраз ЛОГОВ и АРТЕФАКТОВ движка — вторая половина слова владельца 15.08** (D39.136 п.4а «фразы ВСЕХ зон»; первая половина — юзерский провод — решена ревью 28 Б-1/батчем 183): фразы в `bank-stop`/статусных артефактах и логи движка вне контракта, носителя не было (research/28 §6, строка Ф-61: «носителя назвать отдельно» — аудит D39.140 подтвердил: не назван); разобрать, что из этого читает ПОЛЬЗОВАТЕЛЬ (артефакты банка — да, через проекцию платформы) и что оператор (логи — легитимно английские) | бэкенд | когда-нибудь (до второй локали интерфейса) | разбор → точечная правка | research/28 §6, D39.136 п.4а, аудит D39.140 | | 190 | **Кросс-СЕМЕЙНЫЙ проход контракт-ревью** (не-Claude опровергатели; буква заказа владельца D39.136 п.5 «кросс-модели», исполненная в ревью только кросс-тирами Claude — ограничение честно заявлено research/28 §7/§10): проверка вердиктов доклада моделью другого семейства полигонным контуром; жила только колонкой «ЖИВОЕ» реестра нот — аудит D39.140 дал строку | полигон | когда-нибудь (после фазы Д; перед лендингом батча 183 — если владелец захочет второй рубеж) | полигон-слот с внешним провайдером | research/28 §10, аудит D39.140 | @@ -146,21 +146,20 @@ | 197 | **Фикс-лист приёмки пака честности ФЧ-1…ФЧ-8** (D39.149; тело — [archive/PROGRESS-2026-08-17.md](archive/PROGRESS-2026-08-17.md), запись приёмки №18): три НЕПРИБИТЫЕ гарантии (снятие фильтра по книге в `CheckpointUsageForBook` не валит ни один тест — воспроизведено оркестратором · ключ ячейки без `chunk_idx` съедает деньги соседнего чанка · ветка «строка не пере-оценена» не ловится на продовом пути) · два докстринга `reprice.go` описывают НЕ то, что делает код · 187 исполнен наполовину (тот же снятый закон в докстринге теста и `backend/docs/D15.2-*.md:317`) · `HistoricalRows` не доезжает до `status`/`--json`, второй операторский текст (`--accept-rebill`) без basis · **ФЧ-5 РЕШЁН владельцем 17.08 (D39.150): прайсить по модели, которую стадия резолвит СЕЙЧАС, + округление вверх и оговорка в тексте согласия** · `Status()` жёстко падает при ошибке репрайсера против соседней политики деградации · паника поверх ceiling-стопа даёт `failed` вместо `paused` (PD-113) · три стейл-докстринга. Носителя-сессии НЕТ — по слову владельца 17.08 пока только записано | бэкенд | скоро (следующее касание денежного пути; ФЧ-5 обязателен до первого пере-прогона со сменой модели) | дофикс-промт ЛИБО попутно ближайшим паком | **D39.149**, **D39.150** | | 198 | **Апгрейд движка стирает замечания и счётчики книги БЕЗВОЗВРАТНО — композиция двух половин, каждая известна по отдельности** (приёмка P7, линза «вне карты», обе половины пере-прочитаны оркестратором). Платформа при смене `manifest_key` сносит ВСЕ `unit_resolutions` книги (`platform/internal/pgstore/readmodel.go:164-165`), рассчитывая, что поток их пере-наполнит; движок анонсирует юнит РОВНО ОДИН РАЗ за жизнь книги — ключ `unit::::` БЕЗ метки нарезки (`backend/internal/pipeline/events.go:399-400`), а леджер анонсов переживает прогоны (`backend/internal/store/outbox.go:96` «already announced… by one that ran before it») ⇒ совпавшие координаты не переанонсируются НИКОГДА. Обостряется порядком: долг на материализацию ставится только на ГРАНИЦАХ работы, поэтому первая зачистка после апгрейда случается в КОНЦЕ первого пост-апгрейдного прогона и сносит замечания ТОГО ЖЕ прогона, за который заплачено. Следствия на проводе: «0 из N» на переведённой книге навсегда · шкала снова предлагает купить переведённое · замечания читателя исчезают. **Лечится с обеих сторон:** движковая половина — метка нарезки в ключе анонса (решение зоны движка), платформенная — не сносить вслепую либо восстанавливать из экспорта. ⚠ Гейт холодного прогона: смысл упражнения — гонять книгу против МЕНЯЮЩЕГОСЯ движка, то есть первый же апгрейд обнулит библиотеку | бэкенд + платформа | **скоро (до первого реального пользователя И до холодного прогона с апгрейдами)** | связка: решение по ключу анонса в движке → правка платформы | приёмка P7 (D39.153) | | 199 | **Канал доставки правок банка в движок НЕ построен — цепь «поправить/добавить термин → перегенерация» рвётся на шве платформа→движок** (расследование 20.08 по слову владельца; грепы пере-проверены оркестратором). ⚠ **ВЕРХНЕГО КРАЯ У ЦЕПИ БОЛЬШЕ НЕТ (испр. 22.08 при лендинге P8-FIX, D39.154).** Прежняя редакция строки опиралась на ручку `submitBankDecisions` с полем `dst`, куда пользователь мог впечатать исправленный перевод, — **она СНЯТА вместе со всей пер-термной моделью подписи** (`PD-370`, ратификация D39.144). То есть сегодня разорван не один конец, а оба: снизу движок умеет точечную ре-редактуру по вхождениям за $0 на незатронутых юнитах со сметой и порогом согласия (`stagerun.go:88-107`, `repin.go`/`rebill.go`), а сверху ручки «поправить/добавить термин» нет НИ В ЗОНЕ, НИ В КАНОНЕ — на 17 путей канона запись в банк была ровно одна, и она ушла. Это ужесточает строку, а не смягчает: раньше не хватало трубы посередине, теперь не хватает ещё и входа. ⚠ **И третья половина, вскрытая сквозной трассировкой 22.08 — «снизу движок готов» верно ЛИШЬ НАПОЛОВИНУ, сверено кодом:** (а) **правка существующего термина** доедет до РЕДАКТОРА, но не до ЧЕРНОВИКА — `loadMinedDelta` принудительно ставит `Source:"mined"` (`backend/internal/pipeline/mining.go`), а базовый банк черновой волны mined-строки ИСКЛЮЧАЕТ (`seeding.go:143-153`, `if row.Source == "mined" { continue }`). Чтобы правка формировала сам черновик, она должна ехать через `glossary_seed`, а это сдвиг БАЗОВОГО снапшота ⇒ пере-оплата всей черновой волны. То есть «дёшево» работает только для редакторского слоя; (б) **вход в пере-генерацию после прочтения закрыт двумя флагами, которых платформа не передаёт**: `--resnapshot` и `--accept-rebill` сверх порога. `platform/internal/runner/engine.go` `TranslateArgs` собирает РОВНО `translate --config <...> [--verify-bank] [ceiling]` и больше ничего; (в) команды, превращающей карту подписи в подписанный `mined_delta`, у движка НЕТ — в `tmctl` девять подкоманд, среди них ни `sign`, ни `promote`, а банк-экспорт объявлен «проекция, никогда не источник». Писателя файла надо НАЗВАТЬ, и это ровно развилка (а) выше. ⚠⚠ **И ТРИ МИНЫ В САМОМ ФОРМАТЕ ФАЙЛА, которые обязана знать сессия, которая его напишет (сверено кодом 22.08):** **(1) копи-паст карты подписи НЕ РАБОТАЕТ.** Карта пишется вся со `Status: "auto"` (`internal/miner/miner_emit.go`, док прямо: «Every term stays status:auto — that is the load-bearing line», и это запинено). А `unsignedEngineSurfaces` выбрасывает `Source=="mined" && Status!="approved"` (`mining.go:710-719`) ⇒ механически скопированные строки отфильтруются как неподписанные и стоп НЕ снимется — бесконечная петля. Писать надо ЯВНЫЙ `status: approved`. ⚠ Тонкость в другую сторону: ОПУЩЕННЫЙ статус в сид-загрузчике дефолтится в `approved` (`membank/memseed.go:96`=`status = "approved"`), то есть файл БЕЗ поля статуса работает, а файл со скопированным `auto` — нет. **(2) терм БЕЗ `dst` промотить нельзя вообще:** approved/draft с пустым `dst` — ПАДЕНИЕ загрузки сида, то есть срыв всего прогона (`memseed.go:83-86`). А `dst` есть не у всех — часть термов WHICH-only (`mining.go` считает `withDst` именно поэтому). Для них единственный очищающий глагол — ОТКАЗ (`mined_rejects`, берёт только `src`). **(3) хвост реверс-секции ломает «один ОК» на больших книгах:** `reverseSectionTerms` режет кап 200 ПОСЛЕ фильтров, и движок сам предупреждает — «the tail is NOT in this signature map and re-proposes on the next run». При >200 banknote-only поверхностей подпись всей карты стоп НЕ снимет: приедет свежая двухсотка, и актов подписи понадобится ceil(N/200). Носителя между ними нет: колонки `action`/`dst` не читает НИ ОДИН SELECT (единственная выборка `readmodel.go:336` проверяет лишь факт наличия строки) · `mined_delta ⚠ **СУЖЕНА 27.08 приёмкой (D39.159, свип по норме §3 п.8): движковая половина ЦЕПИ ПОСТРОЕНА.** `tmctl bank-apply` заленджен `d1eb8a9` и ратифицирован D39.158 — писатель файлов решений существует, правку термина есть куда положить. Открытым остаётся ровно ПЛАТФОРМЕННЫЙ конец: воркер, который дёргает глагол перед возобновлением (пункт (2в) очереди). Формулировка «канал НЕ построен» с этого дня неверна и отправляла бы следующий пак строить построенное |mined_rejects` по Go платформы = **0** вхождений · воркер, который должен переносить решения в эти файлы перед resume, описан в комментарии её же миграции (`00002_readmodel.sql:172-174`) и НЕ построен. **Делится надвое:** (а) **труба** — платформа пишет решения в два файла, которые движок читает. ⚠ **«Контракт файлов существует, проектировать нечего» — ОПРОВЕРГНУТО КОДОМ 21.08** (опровергатель промта, пере-проверено оркестратором построчно): движок читает `mined_delta`/`mined_rejects`, ⚠ **прежняя формулировка «ТОЛЬКО если их путь объявлен в `book.yaml`» УСТАРЕЛА и снята 28.08:** гард `if r.Book.MinedDelta == ""` снесён лендингом `d1eb8a9` — пути стали КОНВЕНЦИОННЫМИ (D39.158), и объявлять их в `book.yaml` больше не требуется (`backend/internal/pipeline/mining.go:822`), а объявленный ключ с НЕсуществующим файлом валит загрузку конфига целиком (`backend/internal/config/book.go:254`=`is not readable`). Ключей нет ни в шаблоне оператора, ни в дев-стенде (`backend/example/book.yaml` — ноль вхождений), и добавить их платформа не может молча: она пишет `book.yaml` ОДИН раз под `O_EXCL` и «никогда не перезаписывает» (`platform/internal/books/render.go:106-109`=`writes the file once and never again`), а схему движка знать не должна — это ратифицированная форма Б (D39.110 §2б). **Значит развилка есть и она упирается в ратифицированную границу владения `book.yaml`:** объявляет ли ключи платформа при рождении книги (и создаёт ли два пустых файла сразу, иначе движок не стартует) · несёт ли их шаблон оператора · двигается ли граница владения. Решать её — ратификацией, не паком. Закрывает и честность `decline` (PD-277 — его текст сегодня относит работу к отложенной 192, пере-диспозиция нужна тем же решением); (б) **отложенное владельцем** — правки ПОСЛЕ прочтения уже переведённой книги: там правка банка двигает снапшот, движок падает громко без явного согласия на пере-оплату, а `resnapshot|accept-rebill` по Go платформы = 0 ⇒ прогон не перегенерирует, а УПАДЁТ (сегодня не кусает только потому, что до движка не доезжает ничего). Половина (б) — территория строки **192**. ⚠ **ПОПРАВКИ 23.08 (№19, консилиум шва):** (1) формулировка «ни на одном конце» СНЯТА — принимающий конец ДВИЖКА построен (там же, `pipeline/mining.go:784`; гард снят тем же лендингом), не построен ПЛАТФОРМЕННЫЙ путь целиком; (2) тело развилки с четырьмя вариантами и ценой лежит ТОЛЬКО в `platform/docs/archive/platform-PROGRESS-P8.md:176-205`=`Двигается ратифицированная граница владения`; (3) есть СЕДЬМОЙ вариант, которым развилка не решается, а РАСТВОРЯЕТСЯ: движок дефолтит `mined_delta`/`mined_rejects` по конвенции каталога (как уже делает с `project_db` — `config/book.go:189`=`b.ProjectDB = filepath.Join(dir, b.BookID+".db")` — и как уже читает `.auto-bank.yaml`, `mining.go:644`), писателем становится движковый $0-глагол ⇒ ключей никто не объявляет, файлов заранее никто не создаёт; (4) зона дополнена ВЛАДЕЛЬЦЕМ: канон зовёт половину (а) блокером на нём; (5) ⚠ **ГРАНИЦА, которую труба НЕ снимает и которая до 23.08 жила только внутри чужого списка ошибок:** правка, доставленная через `mined_delta`, доезжает до РЕДАКТОРСКОЙ волны, но НЕ формирует черновик — черновая волна выбирает по банку БЕЗ mined-строк (`backend/internal/pipeline/seeding.go:140`=`The DRAFT wave selects over a BASE-scoped bank`). Для читателя это не дефект: отгружается выход редактора, а юниты с термином пере-редактируются, незатронутые пере-пиниваются за $0 (`backend/internal/pipeline/stagerun.go:88-107`=`POINTWISE RE-EDIT`). Настоящая цена тоньше и НЕ ИЗМЕРЕНА: редактор латает черновик, построенный вокруг старого термина (согласование, род, игра слов). Правка, формирующая сам черновик, — это `glossary_seed`, то есть сдвиг базового снапшота с пере-оплатой; территория строки 192, глубину решает владелец ПОСЛЕ фазы Д | владелец + платформа + бэкенд | (а) РАСТВОРЕНА D39.156 · (б) гейчена 192 | (а) движковый пак (дефолты путей + глагол приёма правок) и платформенный (воркер решений); ⚠ прежняя запись звала «дофикс-пак платформы» — такого носителя в репозитории нет ни одного, имя мёртвое, испр. 23.08 · (б) с циклом чтения | расследование 20.08, D39.153 | -| 200 | **Сквозная полоса прогресса вместо пофазной — требование владельца 20.08. ⚠ ДИСПОЗИЦИЯ 27.08 (D39.160, слово владельца): едет с ПЛАТФОРМЕННЫМ паком (2в), а НЕ с контрактным минором.** Причина механическая: гейт версии требует совпадения канона и деплоя в момент лендинга, а сервер прогресс по-новому не считает и не будет до (2в) — канон, объявляющий новую семантику при старом сервере, лжёт ровно так, как гейт запрещает. Требование не ослаблено, перенесён только носитель. Сегодня прогресс считается ПО ОТРЕЗКАМ и при снятии стопа начинается с нуля (канон §Progress: «When a stop is cleared the counter starts again from zero») ⇒ пользователь видит 100% после черновика и обнуление — владелец назвал это дефектом подписанного, не вкусовщиной. Форма решения: ОДНА доля на всю работу прогона, считает СЕРВЕР (иначе клиент снова начнёт знать про фазы), плюс подпись «что делается сейчас» и неопределённый индикатор там, где доли в главах нет. ⚠ Веса «условно 10/40/40» НЕ нужны для переводческой части: движок объявляет знаменатель каждого прохода и ноль для прохода, которого на деплое нет (`backend/internal/pipeline/events.go:297-323`) ⇒ доля = сделано по всем проходам ÷ запланировано, без подобранных констант, и на деплое без редактора честно доходит до 100%. Догадка остаётся только у банка: поток прогресса несёт лишь два переводческих прохода, майнинг знаменателя не объявляет — **вопрос движку: может ли майнинг объявлять знаменатель** (он идёт по главам). Цена: правит канон §Progress + `Book.chapters_done`, то есть новый минор; пока фронт заморожен, минор бесплатен ⚠ **СВЯЗКА, поднимающая приоритет (найдена контрактной сессией 27.08, пере-проверена приёмкой):** после D39.161 главный пользовательский цикл — «поправил термины → запустил прогон, чтобы движок их применил» — это РОВНО сценарий открытой строки `PD-281`: прогон-применение над полной книгой стоит `0/N` и не достигает единицы. **Дверь правок сделала невидимый прогон флагманским, а не редким.** И канон 0.5.0 в §Progress ПО-ПРЕЖНЕМУ обещает «the fraction always reaches one» (`openapi.yaml:1343`) — то есть после сегодняшнего минора канон обещает то, что деплой на этом цикле нарушает. Прогресс-минор обязан либо дать числителю серверную семантику пере-разрешения, либо оговорить обещание; оговорку в 0.5.0 не вносили, потому что D39.160 прямо запретил трогать прогресс в том миноре. ⚠ `PD-281` — ПЛАТФОРМЕННАЯ строка, в бэкенд-пак не входит по зоне | контракт → платформа + бэкенд | скоро (следующий минор контракта) | контрактная сессия + платформенная половина; вопрос движку о знаменателе майнинга | слово владельца 20.08, D39.153 | | 201 | **Движковое «Глава N» доезжает читателю ВНУТРИ текста, обходя дисциплину `heading: null`** (линза шва P7, пере-прочитано оркестратором): `backend/internal/pipeline/export.go:230` приклеивает детерминированный порядковый к экспортному тексту первой юнит-главы (`ce.FinalText = chunk.ApplyHeading(...)`), колонка `Source` при этом остаётся heading-stripped. Платформа переносит обе как есть и честно отдаёт `heading: null` — то есть клиент нарисует СВОЙ порядковый на языке своего интерфейса над абзацем, который уже начинается с русского «Глава N», а исходная колонка соответствующего маркера не несёт: пара визуально рассинхронизирована на каждой первой главе. Конкретное следствие открытого К-2 контракта; родня движковой строки **160** (глава без заголовка) | бэкенд | когда-нибудь (с 160) | отдельное решение | приёмка P7 (D39.153) | | 202 | **Живой перевод книги НАСКВОЗЬ через API платформы не гонялся НИ РАЗУ** (obstacle сессии P7, подтверждён приёмкой): всё после «прогон стартовал и остановился» — подпись банка, halt на потолке, резюм после пополнения — покрыто батареей против живого Postgres, но с движком-заглушкой; настоящий манифест, сайдкар банка и причины флагов будут ПЕРВЫМИ данными, форму которых платформа не сочинила сама. Форма проверки (решена): только через API, как ходил бы клиент — интейк → старт → живой движок с ключами провайдера → поток событий → подпись и продолжение → чтение глав/юнитов/замечаний/банка и сверка с тем, что движок реально произвёл; качество перевода НЕ предмет (полигон). Цена ≈ $0.05 на десятиглавую книгу. **Решение владельца 20.08: ПОСЛЕ холодного прогона движка** — гонять одну неготовую систему против другой смысла нет | платформа | скоро (гейт: холодный прогон движка) | короткая сессия по промту оркестратора | слово владельца 20.08, D39.153 | -| 203 | **Хвосты контракта после синка 0.4.0 — ОТКРЫТА ЧЕТВЕРТЬ, остальное исполнено** (тела релеев — `platform/docs/archive/P7_ACCEPTANCE_HANDOFF_2026-08-17.md` §7; сюда переписаны НЕ тела, а статус — один носитель на факт). ⚠ Сверено грепом при лендинге, потому что первая редакция этой строки уже разошлась с деревом: **(б) ИСПОЛНЕН** — срезанная фраза компаньона §2.14 стоит на месте (`14-api-contract/README.md:536`=`различать причины отказа клиент не может по замыслу`) · **(д) ИСПОЛНЕН** — клетка приложения А заполнена `unspecified` (`README.md:1376` — ⚠ первая редакция писала `:1391`, это строка ДРУГОГО приложения; испр. 20.08) · **(г) наполовину** — `unspecified` ратифицирован в каноне (`openapi.yaml:1784-1787`), открыта только ГРАНИЦА ступеней замечаний (две ступени при девяти рангах движка) → это строка **148** · **(и)** закрыт полем `stop_requested` (D39.152) · (л)(м)(н)(о) закрыты контрактной сессией. **ОТКРЫТО РЕАЛЬНО:** ⚠ **(а) ИСПОЛНЕН 27.08 минором 0.5.0 (D39.161)** — канон получил предупреждение о конверте вне `/v0`; открытым по строке остаётся (к). Прежний текст пункта: (а) канон-половина про `/auth` — компаньон поверхность описывает (§2.14), а канон говорит только «Signing in is not part of this surface» (`openapi.yaml:35-36`) и НЕ предупреждает, что конверт вне `/v0` может прийти БЕЗ обязательного `code`; сгенерированный по канону клиент об это спотыкается · (з) ⚠ **СНЯТО КАК РАТИФИКАЦИЯ 22.08 — конфликта моделей НЕТ, я нёс причину и следствие наоборот.** Прежняя редакция писала: «D39.144 ратифицировал подпись = ОДИН акт, движок держит пер-термный гейт полноты, одно из двух должно уступить». Сквозная трассировка это опровергла КОДОМ: гейт проверяет полноту не ПОДПИСЕЙ, а ФАЙЛА решений, и ОДИН акт его снимает — `loadMinedDelta` штампует только `Source`, статус не трогает (`mining.go:855-867`) → опущенный статус дефолтится в `approved` (`membank/memseed.go:96`=`status = "approved"`) → `unsignedEngineSurfaces` выбрасывает лишь `Source=="mined" && Status!="approved"` (`mining.go:710-719`) ⇒ промоутнутые строки остаются сид-поверхностями, дельта пустеет, стоп снимается. Шапка файла говорит это прямым текстом. **Один файл со всем банком = один ОК.** Обход в платформе (`--verify-bank` не передаётся на resume) — костыль под ОТСУТСТВУЮЩУЮ ДОСТАВКУ, а не под расхождение моделей: как только доставка появится, флаг можно просто оставить включённым. Настоящий и единственный блокер темы — владение `book.yaml`, строка **199(а)**. Ратификации здесь НЕ нужно; остаётся снять обход одной боевой строкой (`platform/internal/runs/spawn.go`) и семью строками ставшего ложным обоснования — работа платформенного пака, не решение владельца · (к) ключ `project_db` в `book.yaml`: договорить, кто им владеет — шаблон оператора его не содержит, движок делает необязательным. ⚠ **Следствие «на штатном деплое банк не читается» ОПРОВЕРГНУТО живым кодом** (проверено при лендинге): акт 5 внёс фолбэк `.db` и назвал прежний отказ багом (`platform/internal/runner/artifacts.go:82-90`=`The key is OPTIONAL to the engine`); первая редакция этой строки повторила клейм хендоффа зоны, не сверив с деревом — остаток чисто договорной · **(л) остаток закрытой строки 148 — Приложение А компаньона.** ⚠ Сама 148 СНЯТА 21.08 по слову владельца: она держала мир, которого больше нет — «карта потеряна инцидентом 08.08, реконструкция у оркестратора №15, решить, брать ли черновиком». Реконструкция брать нечего: прежняя карта вела «причина движка → ФРАЗА», то есть предполагала, что фразу рисует СЕРВЕР, а батч 0.3.0 эту политику ОТМЕНИЛ — `Note` несёт машинный `code`, фразу рисует клиент, `Note.message` с провода снят (`14-api-contract/README.md:1511`=`Карта «причина → КОД контракта`). Реально пуста другая клетка: соответствие причин движка кодам замечаний, и правила её заполнения в компаньоне уже написаны (фраза по ДОККОММЕНТУ `disposition.go`, а не по имени константы — инверсия уже стоила двух фраз; класс 2 схлопывается в ОДИН код). Исполнитель — контрактная сессия, не оркестратор ⚠ **ПЕРЕ-ДИСПОЗИЦИЯ 27.08 (D39.158 п.7):** хвост (з) закрыт — «ратификации не нужно» отвечало на вопрос про один файл решений, не про флажок; движковый гейт полноты снят, не обойдён.| контракт/доки | скоро (следующее касание контракта, вместе с 200) | контрактная сессия | приёмка P7 (D39.153) | +| 203 | **Хвосты контракта после синка 0.4.0 — ОТКРЫТА ЧЕТВЕРТЬ, остальное исполнено** (тела релеев — `platform/docs/archive/P7_ACCEPTANCE_HANDOFF_2026-08-17.md` §7; сюда переписаны НЕ тела, а статус — один носитель на факт). ⚠ Сверено грепом при лендинге, потому что первая редакция этой строки уже разошлась с деревом: **(б) ИСПОЛНЕН** — срезанная фраза компаньона §2.14 стоит на месте (`14-api-contract/README.md:548`=`различать причины отказа клиент не может по замыслу`) · **(д) ИСПОЛНЕН** — клетка приложения А заполнена `unspecified` (`README.md:1376` — ⚠ первая редакция писала `:1391`, это строка ДРУГОГО приложения; испр. 20.08) · **(г) наполовину** — `unspecified` ратифицирован в каноне (`openapi.yaml:1784-1787`), открыта только ГРАНИЦА ступеней замечаний (две ступени при девяти рангах движка) → это строка **148** · **(и)** закрыт полем `stop_requested` (D39.152) · (л)(м)(н)(о) закрыты контрактной сессией. **ОТКРЫТО РЕАЛЬНО:** ⚠ **(а) ИСПОЛНЕН 27.08 минором 0.5.0 (D39.161)** — канон получил предупреждение о конверте вне `/v0`; открытым по строке остаётся (к). Прежний текст пункта: (а) канон-половина про `/auth` — компаньон поверхность описывает (§2.14), а канон говорит только «Signing in is not part of this surface» (`openapi.yaml:35-36`) и НЕ предупреждает, что конверт вне `/v0` может прийти БЕЗ обязательного `code`; сгенерированный по канону клиент об это спотыкается · (з) ⚠ **СНЯТО КАК РАТИФИКАЦИЯ 22.08 — конфликта моделей НЕТ, я нёс причину и следствие наоборот.** Прежняя редакция писала: «D39.144 ратифицировал подпись = ОДИН акт, движок держит пер-термный гейт полноты, одно из двух должно уступить». Сквозная трассировка это опровергла КОДОМ: гейт проверяет полноту не ПОДПИСЕЙ, а ФАЙЛА решений, и ОДИН акт его снимает — `loadMinedDelta` штампует только `Source`, статус не трогает (`mining.go:855-867`) → опущенный статус дефолтится в `approved` (`membank/memseed.go:96`=`status = "approved"`) → `unsignedEngineSurfaces` выбрасывает лишь `Source=="mined" && Status!="approved"` (`mining.go:710-719`) ⇒ промоутнутые строки остаются сид-поверхностями, дельта пустеет, стоп снимается. Шапка файла говорит это прямым текстом. **Один файл со всем банком = один ОК.** Обход в платформе (`--verify-bank` не передаётся на resume) — костыль под ОТСУТСТВУЮЩУЮ ДОСТАВКУ, а не под расхождение моделей: как только доставка появится, флаг можно просто оставить включённым. Настоящий и единственный блокер темы — владение `book.yaml`, строка **199(а)**. Ратификации здесь НЕ нужно; остаётся снять обход одной боевой строкой (`platform/internal/runs/spawn.go`) и семью строками ставшего ложным обоснования — работа платформенного пака, не решение владельца · (к) ключ `project_db` в `book.yaml`: договорить, кто им владеет — шаблон оператора его не содержит, движок делает необязательным. ⚠ **Следствие «на штатном деплое банк не читается» ОПРОВЕРГНУТО живым кодом** (проверено при лендинге): ⚠ носитель довода СМЕНИЛСЯ лендингом P9 (D39.162): опровержение опиралось на фолбэк `.db`, который платформа вычисляла сама, а он СНЕСЁН вместе с дублем конвенции (строка 213). Сегодня путь публикует ДВИЖОК конвертом артефактов, и пустой путь — ГРОМКИЙ отказ (`platform/internal/runner/artifacts.go:39`=`the engine published no bank read-out path`), то есть банк либо читается, либо деплой говорит об этом вслух; первая редакция этой строки повторила клейм хендоффа зоны, не сверив с деревом — остаток чисто договорной · **(л) остаток закрытой строки 148 — Приложение А компаньона.** ⚠ Сама 148 СНЯТА 21.08 по слову владельца: она держала мир, которого больше нет — «карта потеряна инцидентом 08.08, реконструкция у оркестратора №15, решить, брать ли черновиком». Реконструкция брать нечего: прежняя карта вела «причина движка → ФРАЗА», то есть предполагала, что фразу рисует СЕРВЕР, а батч 0.3.0 эту политику ОТМЕНИЛ — `Note` несёт машинный `code`, фразу рисует клиент, `Note.message` с провода снят (`14-api-contract/README.md:1564`=`Карта «причина → КОД контракта`). Реально пуста другая клетка: соответствие причин движка кодам замечаний, и правила её заполнения в компаньоне уже написаны (фраза по ДОККОММЕНТУ `disposition.go`, а не по имени константы — инверсия уже стоила двух фраз; класс 2 схлопывается в ОДИН код). Исполнитель — контрактная сессия, не оркестратор ⚠ **ПЕРЕ-ДИСПОЗИЦИЯ 27.08 (D39.158 п.7):** хвост (з) закрыт — «ратификации не нужно» отвечало на вопрос про один файл решений, не про флажок; движковый гейт полноты снят, не обойдён.| контракт/доки | скоро (следующее касание контракта, вместе с 200) | контрактная сессия | приёмка P7 (D39.153) | | 204 | **Движок не публикует причины флагов ДАННЫМИ — карта причин у платформы рукописная и расходится молча** (релей §7(в) хендоффа P7; зона платформы в чужой бэклог не пишет и просила строку сюда — `platform/docs/archive/platform-PROGRESS-P7.md:206`). Сегодня `platform/internal/ingest/notes.go` держит рукописную копию закрытого словаря ЧУЖОЙ зоны: 15 причин движка → коды замечаний контракта, сверено побайтно на 20.08 и верно СЕЙЧАС. Импортировать движок платформе нельзя (D39.85 — разные модули), поэтому расхождение появится молча в тот день, когда движок добавит причину: платформа отдаст `unspecified` и напишет ERROR в лог, то есть деградация честная, но карта протухнет незаметно. Лечение: движок публикует свой словарь причин артефактом-данными (тем же классом, что манифест и сайдкары банка), платформа читает его вместо копии. ⚠ Носителем был ТОЛЬКО регистр платформы (PD-246) — то есть обязательство к зоне движка жило внутри зоны автора; поймано ревьюером полноты выгрузки при лендинге | бэкенд | когда-нибудь (со следующим касанием эмиссии флагов) | отдельное решение | приёмка P7 (D39.153), релей §7(в) | | 205 | **Гейт якорей: ИСПОЛНЕН 21.08 — сверка по содержимому, двусторонняя дельта, объявленная слепота.** Был: проверял только существование файла и переполнение номера, поэтому дал ЗЕЛЁНЫЙ на четыре якоря, сломанных архивацией — а оркестратор на эту зелень опёрся вместо ручной сверки. Стало (`docs/scripts/counts.py`): якорь может нести ОПТ-ИН токен ожидания `` `путь:12-14`=`подстрока` ``, и такой якорь сверяется по содержимому; область расширена с `docs/` на `platform/docs` и `frontend/docs` (98 доков); из хука убран быстрый выход по «коммит не трогает docs/» — он ампутировал ЦЕЛЕВУЮ сторону, а цели у нас в основном КОД, то есть главный двигатель протухания приезжал ровно тем коммитом, на котором хук молчал (проверено ТЕСТ-КОММИТОМ, не ручным прогоном — норма D39.148). ⚠ **Разбор с внешним ревьюером снял три моих ошибки дизайна:** (1) у дельты ДВЕ роли, и путать их нельзя — ТРЕБОВАТЬ токен можно только на затронутых строках (иначе долг блокирует всех), а ПРОВЕРЯТЬ уже написанные токены дельта ограничивать не должна вовсе: токен был истинным при записи, значит упавший — гниль по построению; corpus-wide проверка схлопывает «пропущенное окно» (коммит без хука, `--no-verify`, свежий клон, проигнорированный warn) из «навсегда» в «до следующего прогона»; (2) окно диапазона `80-82,243` считалось как `min..max` — 164 строки, и токен, случайно живущий внутри, красил зелёным; теперь ОБЪЕДИНЕНИЕ отрезков; (3) литеральная сверка токена была машиной ложных тревог — фраза, разорванная переносом строки в цели, непроверяема в принципе, а будущая пере-вёрстка абзаца кричала бы «цель уехала» на невиновного; теперь ПРОБЕЛЬНАЯ нормализация (детерминированная и симметричная), разметка остаётся литеральной. ⚠ Мой довод «неудобство и есть механизм» был неверен: мой собственный случай был FALSE POSITIVE самого гейта — токен был истинным, цель не двигалась. **ОБЪЯВЛЕННАЯ СЛЕПОТА (печатается гейтом каждый прогон):** якорь БЕЗ токена — только существование и длина · подмена цели файлом с тем же путём ловится только токеном · цели читаются из ДЕРЕВА, поэтому чужой незакоммиченный WIP может транзиентно и уронить токен, и спасти · токен доказывает ссылочную свежесть, НЕ истинность фразы дока о цели · в ручном прогоне «дельта» = всё незакоммиченное кем угодно, поэтому ТРЕБОВАНИЕ токена живёт только под `--from-index`. **ОСТАТОК:** миграция корпуса на токен-форму органическая — токен требуется на ЗАТРОНУТЫХ строках, поэтому счёт растёт сам с каждым касанием. Числа здесь НЕ держим (первая редакция держала «4 из ~580» и протухла за сутки): текущий счёт токенов, якорей и доков гейт печатает КАЖДЫЙ прогон — `python3 docs/scripts/counts.py --lint` | оркестратор/доки | сделано 21.08, миграция органическая | исполнено; остаток гасится касаниями | аудит работы №18 + разбор с внешним ревьюером, 21.08 | | 207 | **Пол самосогласованности манифеста стоит только у материализатора, интейк тот же документ ПРИНИМАЕТ — и по такой книге можно ОПЛАТИТЬ прогон** (`PD-367`, вторая волна ревью P8-FIX). Манифест `{ChaptersTotal: 120, UnitsTotal: 400}` с пустым списком глав `Whole()` отвергает, а `books.Parse` заводит книгу `not_started` с `chapter_count=120` и пустым деревом; потолок считается ОТ `chapter_count`. **Очевидное лекарство опровергнуто исполнением самой зоной:** применить `Whole()` на интейке нельзя — вся батарея интейка ездит на манифестах без списка глав, контракт интейка это только счётчики. Значит решение не зонное: либо контракт интейка меняется, либо пол ставится в другом месте цепи | оркестратор → платформа | скоро | нужно решение (лекарство ломает запиненный контракт интейка) | D39.154 п.10, `PD-367` | | 208 | **Эскалационный хоп на Gemini ТИХО ТЕРЯЕТ инъекцию банка памяти — книга едет без канона имён** (живая проба полигона 22.08, подтверждена вендор-докой и пере-проверена оркестратором по коду). OpenAI-совместимый слой Gemini принимает РОВНО ОДНО системное сообщение, лишние выбрасывает МОЛЧА — без ошибки, при `finish=stop` и внешне правильном переводе. Замер полигона на одном и том же системном тексте 7970 знаков: одним сообщением → `prompt_tokens` 2994 и маркерная инструкция ИСПОЛНЕНА, двумя → 535 токенов и инструкция НЕ исполнена. Механизм вендорский, поэтому уход на нативный API НЕ лечит: `systemInstruction` там — один объект `Content`. Наша сторона: `backend/internal/pipeline/render.go` штатно строит ВТОРОЕ системное сообщение (инъекция глоссария/STM), `backend/internal/llm/httpllm.go` `toOpenAIMessages` копирует сообщения дословно, склейки под Gemini нет нигде — а `gemini-3.1-pro-preview` стоит в ратифицированной цепочке эскалации ВО ВСЕХ четырёх арм-конфигах. ⇒ любой хоп на Gemini при непустой инъекции теряет глоссарий, и **это невидимо гейтам годности**. ⚠ Класс в проекте УЖЕ решён рядом и не обобщён: `provider_anthropic.go` сворачивает ведущий system-префикс в одно поле и падает громко на system посреди диалога. Оговорка D22.3 «экспозиция Ф1 нулевая» держится на МАРШРУТЕ (до Gemini в дефолте не доходят), а не на коде — то есть на одном включённом `budget_usd` от дефекта в проде. Полигон у себя починил склейкой по вендору (`eval/dovodka/rol.py`), в движок не лез; квирк записан в `00-provider-quirks.md` | бэкенд | **скоро (до первого платного прогона с ненулевым `budget_usd` эскалации)** | склейка системных сообщений для Gemini в транспорте + пин | пинг полигона №19 (22.08), вендор-дока `ai.google.dev/api/generate-content` | | 209 | **Риг живых проб не может залогировать тела запросов ни при каких env** (находка бэкенд-пака честности 17.08, оставшаяся без носителя до аудита доков 22.08): `LOG_LLM_BODIES=1`+`LOG_LEVEL=debug` объявлены носителем тел, но ключ читается из `obs.ReqInfo` контекста, а `live_reprobe_test.go` строит ctx БЕЗ `WithReqInfo` — гейт `obs/logging.go` не пропускает. Обход, которым пользовались: ручной дамп сообщений в файл. Цена — каждая живая проба, которой нужно прочитать провод глазами, платит за обход заново | бэкенд | когда-нибудь (следующее касание рига живых проб) | малое касание (`WithReqInfo` в риге) + пин | пак честности 17.08, вынесено аудитом доков 22.08 | | 210 | **Род НЕ ПРОИЗВОДИТСЯ авто-путём вовсе — механизм доставки построен, а данных для него нет** (найдено трассировкой цепи банка 22.08, сверено кодом оркестратором; полигон вскрыл то же независимо: «в банке НЕТ ПОЛЕЙ ПОЛА»). Доставка рода на провод ПОСТРОЕНА и работает: `membank/memory.go` кладёт `genderConstraintNote` в черновую инъекцию — но ТОЛЬКО для строк со статусом `Confirmed`, по прямой директиве владельца D39.21 «род должен доезжать». Данных ей никто не даёт: промпт терминолога возвращает РОВНО ТРИ поля (`backend/prompts/zh-ru/terminologist.md`: термин · перевод · уверенность 0–100) — поля рода в ответе НЕТ; майнер берёт `gender` исключительно из СИДА (`internal/miner/miner_emit.go` `seedMeta`, `meta.gender = e.Gender`), эмитируемые кандидаты его не несут. ⇒ **на любой книге без ручного сида ось рода мертва по построению**, и «плывущий род» — это не дефект рендера, а отсутствие входных данных. Прямо бьёт по цели №1: реестр памяти C3 называет русский глагол прош. вр. при скрытом роде «механическим спойлер-каналом» и «жалобой №1 читателей MTL» (строка 82). **Развилка, которую надо назвать до стройки:** добавлять ли поле рода в ответ терминолога (двигает провод ⇒ ТОЛЬКО в вайр-батч 182) ЛИБО выводить род отдельным дешёвым проходом, ЛИБО признать род сид-только свойством и гейтить им качество явно. Родня 82 (морфо-гейт рода) и 178 (калибровка `sense`). ⚠ **ДОПОЛНЕНО 23.08 (№19):** замка ДВА и они независимы — род рендерится только при `Disp == Confirmed` (`membank/memory.go:705-713,823-827`=`Mirrors the editor block's confirmed-only gender`), а `mining.go:676` запрещает авто-банку статус `approved`. Род НЕ единственное осиротевшее поле: `Sense`/`Speech`/`Decl` тоже приходят только из сида, авто-продюсер есть лишь у `Dst` (терминолог) и `Type` (классификатор). ⚠ Поле `Gender` в схеме сида УЖЕ ЕСТЬ (`internal/seed/seed.go:71`=`Gender string`) — ось мертва в ПИСАТЕЛЯХ, не в формате. ⚠ **И тесты слепы ПО ПОСТРОЕНИЮ** (пере-проверено 23.08): все gender-кейсы конструируют род РУКАМИ, то есть зелёная батарея не отличает «производитель работает» от «производителя нет»; гейта на дрейф рода в движке нет | бэкенд (+полигон на замер) | **скоро (цель №1; правка провода — вайр-батчем 182)** | развилка → решение → правка промпта/прохода + замер на прогоне с редактурой | трассировка цепи банка 22.08, D39.21, C3 реестра памяти | -| 211 | **⛔ Движок на SaaS НЕ ПОЛУЧАЕТ провайдерских ключей — блокер живого прогона (202).** Прод-путь `systemd-run --user` (`platform/internal/runner/runner.go:137`=`"systemd-run", args...`), окружение юнита = окружение systemd-менеджера + `--setenv` из `s.Env`, а платформа кладёт туда РОВНО `TM_TRACE_ID` (`runs/spawn.go:168`=`Env: engineEnv(engineStreamID(`, `engineEnv` `:268`). Замысел записан комментарием `:265-267` («движок читает ключи из .env рядом со своим book.yaml»), движок так и делает (`backend/cmd/tmctl/main.go:202`=`loadDotEnv(filepath.Join(filepath.Dir(inv.cfgPath), ".env")`; `cmd.Dir = workdir` — `runner/engine.go:186`=`cmd.Dir = workdir`, оба пути в ОДИН файл), **а кладёт туда `.env` НИКТО**: в каталоге книги платформа создаёт только `book.yaml` (`books/render.go:109`=`os.O_CREATE`) ⚠ **ДВИЖКОВАЯ ПОЛОВИНА ПОСТРОЕНА 27.08 (лендинг `d1eb8a9`, D39.158), строка об этом не знала — поймано свипом по норме §3 п.8 при ответе владельцу 27.08.** Движок принимает путь к файлу ключей аргументом (`backend/cmd/tmctl/dotenv.go:46,63`, флаг `--keys-file`, пин `cmd/tmctl/keysfile_test.go`) — то есть класть ключи в окружение юнита больше не обязательно. **Открыт ПЛАТФОРМЕННЫЙ конец:** греп по `platform/` даёт НОЛЬ упоминаний `--keys-file` — раннер его не передаёт. Значит блокер живого прогона (строка 202) снят наполовину и закрывается пунктом **(2в)** очереди D39.156, а не отдельной работой |os.O_EXCL`, O_EXCL) и исходник (`books/books.go:259-264`=`os.O_CREATE|os.O_EXCL`); писателя `.env` в `platform/internal/` ноль. ⚠ **Почему пережило все батареи — ловушка паритета dev/prod:** дев-супервизор запускает движок обычным процессом с `Env=nil` и наследует окружение ПЛАТФОРМЫ, что обещает его же докстринг (`ingest/supervisor.go:35-36,65-67`=`Provider keys reach the engine through it`) — стенд работает, прод голодает, гейты гоняют дев-путь. **Форма фикса (консилиум 22.08):** путь к файлу ключей аргументом у `translate` (читающим $0-глаголам ключи запрещены — `dotenv.go:53-55`), деплой-файл ПЕРВЫМ в цепочке без перекрытия, дев-супервизор на тот же механизм. Довод формы: ключи обязаны покинуть каталог пользовательского контента — сегодня бэкап/экспорт каталога книги несёт секреты. ⚠ Ключи ОБЩИЕ на всех пользователей (слово владельца 22.08); BYOK не исключён позже и ложится в ту же форму пер-книжным путём | бэкенд + платформа | **блокер строки 202** | движковый + платформенный пак | консилиум шва 22.08, пере-проверено оркестратором №19 | -| 213 | **Платформа дублирует движковую конвенцию пути и знает суффикс чужого артефакта.** `platform/internal/runner/artifacts.go:65-95`=`neither project_db nor book_id` парсит `book.yaml` нестрогим декодером ради двух ключей и при пустом `project_db` САМА вычисляет `filepath.Join(workdir, book_id+".db")`, повторяя `backend/internal/config/book.go:189`=`b.ProjectDB = filepath.Join(dir, b.BookID+".db")`; суффикс `.bank.json` зашит на `:37`. Смена дефолта в движке тихо уводит чтение банка платформой на несуществующий путь. ⚠ Свип ВСЕЙ границы движок↔платформа (консилиум 22.08) дал ровно ОДНО такое место плюс тривиальность: дев-супервизор хардкодит `"book.yaml"` мимо константы `ConfigFile` (`ingest/supervisor.go:140`=`"status", "--config"`). Прочее знание платформы о движке — ратифицированные каналы, не гниль. **Фикс:** движок отдаёт путь артефакта аддитивно (`status --json` либо ответ нового глагола), платформа выкидывает свой `projectDB()`. ⚠ Пере-именование банк-экспорта в фикс-имя рядом с `events.jsonl` — ЛОМАЮЩЕЕ, его место в окне строки **161**, НЕ здесь | бэкенд + платформа | скоро (в те же паки) | движковый + платформенный пак | консилиум шва 22.08 | -| 214 | **Подпись банка не оставляет на банке НИ ОДНОГО следа.** Единственная долговечная запись акта подписи — булев `bank_released` на прогоне (`platform/internal/pgstore/runs.go:851`=`bank_released = bank_released or $2`): кто и когда подписал, что именно вошло в подписанный набор — не хранится нигде. Следствие: пост-фактум ответить «почему в книге этот перевод термина» нельзя, а при пере-нарезке или пере-прогоне восстановить намерение человека не из чего. Всплыло трассировкой цепи банка 22.08, подтверждено пере-проверкой | платформа + бэкенд | когда-нибудь (с трубой доставки правок, D39.156) | отдельное решение о провенансе подписи | трассировка цепи банка 22.08, пере-проверено №19 | +| 213 | **ОСТАТОК — одна строка в ДЕВ-пути: супервизор зашивает `"book.yaml"` мимо константы `ConfigFile`** (`platform/internal/ingest/supervisor.go:142`=`"status", "--config"`). ⚠ **ГЛАВНАЯ половина ЗАКРЫТА лендингом P9 (D39.162):** платформа больше не парсит `book.yaml` нестрогим декодером и не вычисляет `.bank.json` по чужой конвенции — путь берётся из конверта артефактов, который публикует движок (`platform/internal/runner/artifacts.go:28-35`; пустой путь — ГРОМКИЙ отказ, а не тихий недо-чит), и собственный `projectDB()` снесён вместе с суффиксом. Осталась ровно та тривиальность, которую строка называла рядом с дефектом. ⚠ Пере-именование банк-экспорта в фикс-имя рядом с `events.jsonl` — ЛОМАЮЩЕЕ, его место в окне строки **161**, НЕ здесь | платформа | когда-нибудь (одна строка, следующим касанием зоны) | правка дев-супервизора | консилиум шва 22.08; главная половина — D39.162 | +| 214 | **Подпись банка не оставляет на банке НИ ОДНОГО следа.** Единственная долговечная запись акта подписи — булев `bank_released` на прогоне (`platform/internal/pgstore/runs.go:870`=`bank_released = bank_released or $2`): кто и когда подписал, что именно вошло в подписанный набор — не хранится нигде. Следствие: пост-фактум ответить «почему в книге этот перевод термина» нельзя, а при пере-нарезке или пере-прогоне восстановить намерение человека не из чего. ⚠ **ЧАСТИЧНЫЙ ответ появился с дверью правок (D39.162):** её документы решений — долговечная запись того, ЧТО владелец решил, и они переживают прогон. Не отвечено по-прежнему: КТО и КОГДА подписал, и что именно вошло в подписанный набор. Всплыло трассировкой цепи банка 22.08, подтверждено пере-проверкой | платформа + бэкенд | когда-нибудь (с трубой доставки правок, D39.156) | отдельное решение о провенансе подписи | трассировка цепи банка 22.08, пере-проверено №19 | | 215 | **$0-точечный репин движка работает ТОЛЬКО под `--resnapshot`, а платформа этого флага не передаёт.** Механизм пере-привязки незатронутых юнитов при сдвиге банка построен (`backend/internal/pipeline/stagerun.go:88-107`=`POINTWISE RE-EDIT`) и он и делает подпись термина доступной по цене; но с платформы он недостижим — она зовёт `translate` без флагов сверх потолка и `--verify-bank`. ⇒ на SaaS правка термина сегодня стоила бы полной пере-редактуры, а не дельты. Родня строки 199(б) (нулевой набор consent-флагов) | платформа + бэкенд | когда-нибудь (с платформенным паком шва) | отдельное решение | трассировка цепи банка 22.08, пере-проверено №19 | | 216 | **Подпись банка, halt на потолке и резюм после пополнения не проверялись против ЖИВОГО движка ни разу.** Проверки этих путей в батарее платформы стоят на фейках; единственный живой прогон движка в батарее — не по этим маршрутам. То есть три несущих сценария продукта держатся на договорённости о поведении чужой стороны, а не на замере. ⚠ Гейтится тем же, чем и живой прогон книги (строка 202): холодный прогон движка | платформа | когда-нибудь (с 202) | живой прогон по трём маршрутам | трассировка цепи банка 22.08, пере-проверено №19 | +| 227 | **`signature` в квитанции двери правок считается от карты, которую переписывает ЛЮБАЯ граница майнинга** (находка воркфлоу-ревью P9, 28.08): запись карты стоит ВЫШЕ решения о стопе (`backend/internal/pipeline/mining.go:191`=`writeFileAtomic(r.signatureMapPath()`), поэтому `signature != null` НЕ означает состоявшегося стопа подписи; `surfaces`/`undecided` дрейфуют между двумя вызовами владельца, а `undecided: 0` достижим при НЕпредъявленных решениях (кап top-200 вытесняет). ⚠ Починка аддитивна и носитель для неё уже есть: карта несёт СВОЙ идентификатор (`backend/internal/membank/decisions.go:903-912`=`id, err := seed.SignatureMapID(content)`), а шов его не читает — отдать идентификатор в квитанцию и судить по нему. ⚠ Якорь находки был неверен (`pipeline/signaturemap.go` не существует) — исправлен приёмкой чтением. Цена сегодня мала (поле информационное, гейтом не служит) и растёт вместе с экраном подписи: именно он будет решать «что я подписываю» по этому числу | бэкенд | когда-нибудь (гейт: заказ экрана подписи) | аддитивная правка квитанции | воркфлоу-ревью P9, D39.162 | | 217 | **Скрипты полигона захардкодили АБСОЛЮТНЫЕ пути двух корней и не работают на этой машине вовсе.** ДВА РАЗНЫХ слома. **Корень репо** — смена пользователя: литерал в 121 файле, из них 104 дословно `REPO = Path("/home/ubuntu/projects/textmachine")`, портабельных форм всего 5. **Корень книг** — оба события порознь: литерал `/home/ubuntu/books` 86 файлов (пользователь), портабельный `Path.home()/"books"` 89 файлов (переезд каталога; на прежних машинах работал). Объединение 173 файла. Пользователь теперь `ubuntu-26`, книги переехали в `<репозиторий>/books` ⇒ ломается и то и другое, включая ЖИВОЙ гейт фазы Д `eval/conformance.py:48`=`REPO = Path("/home/ubuntu/projects/textmachine")`. Живых носителей 26 по корню книг (среди них портабельная форма преобладает 20:6) плюс корневые гейты и сборщики корпусов; остальное — скрипты закрытых эксп-12–16, их владелец велел ВЫБРОСИТЬ, а не чинить (D39.157 п.4а). Форма правки — за зоной; приор оркестратора: один модуль путей, корень резолвится МАРКЕРОМ вверх по дереву (движок так и делает — `backend/internal/miner/miner_parity_test.go:29`=`derived from the repository MARKER`), книги от него, обе ручки перекрываются env | полигон | скоро (блокирует любой прогон зоны) | пак путей полигона | переезд машины 24.08, №19 | | 219 | **Line-якоря в тела D-лога обречены съезжать, и механизм именно в дисциплине эррат.** Эрраты вписываются в КАРТУ ШАПКИ (append-only, D23.3), то есть в начало файла, — значит каждая эррата сдвигает номера строк ВСЕХ тел ниже, и любой якорь вида `05-decisions-log.md:NNN` умирает молча. ⚠ Замерено на себе 27.08: три эрраты за сессию убили якоря в `17-seam-inbound-law.md:120` и `research/25-seam-cold-review.md:5` (оба целили в `:232`, тело уехало на `:239`). Лечение — не пере-нацеливание (оно повторится через эррату), а СМЕНА ФОРМЫ якоря: в тела D-лога целиться номером ноты (`^## D39.106`), который стабилен навсегда, а не строкой. ⚠ Строка ОБЪЯВЛЕНА в CURRENT-STATE 24.08 и до 27.08 в таблице НЕ СУЩЕСТВОВАЛА — очередь три дня ссылалась на носитель, которого нет; поймано свипом якорей при выдаче контрактного минора | оркестратор | скоро (растёт с каждой эрратой) | `--lint` учит форму `файл:^## D<номер>`, живые якоря в D-лог переводятся на неё | ревью доков 24.08, механизм и отсутствие носителя — 27.08, D39.160 | | 220 | **Репо-широкий гейт доков бутстрапится пакетным менеджером ОДНОЙ зоны, и та заморожена.** Диспетчер `.git/hooks/pre-commit` зоно-нейтрален по построению (`for hook in */scripts/githooks/pre-commit`, `#!/bin/sh`, node не нужен) и судит `CLAUDE.md` + `docs/**` + `platform/docs/**` + `frontend/docs/**` (`docs/scripts/counts.py:500`=`ROOT / "CLAUDE.md"`), но единственный его УСТАНОВЩИК — `frontend/scripts/githooks/install.mjs`, вызываемый ключом `prepare` из `frontend/package.json`. Охват и бутстрап не совпадают ⇒ инструмент решает свою задачу не в полной мере. Проверено исполнением 24.08: на этой машине `.git/hooks/` содержал только `*.sample`, гейт МОЛЧАЛ, ничего не проверив, и поймал первые четыре дефекта только после ручной установки; сессия бэкенда или доков `npm install` не делает никогда. Приор: зоно-нейтральный установщик в `docs/scripts/githooks/` как ЕДИНСТВЕННЫЙ писатель диспетчера, `prepare` фронта зовёт его — фронтовая половина выписана пингом №19 в зонный журнал фронта. ⚠ Ручная установка — временная мера, пока строка открыта; записывать её как порядок работы ЗАПРЕЩЕНО (это ровно тот обход, цену которого называет шапка `CLAUDE.md`) | доки + фронт | скоро | зоно-нейтральный бутстрап + правка `prepare` при разморозке | аудит переезда 24.08, №19 | @@ -227,6 +226,340 @@ **⟶ Состояние бэкенда — CURRENT-STATE (один носитель, D39.80); здесь только пинги живых сессий.** +**✅ ИТОГ БЭКЕНД-СЕССИИ 28.08 — ПАК «ТИХАЯ ПОРЧА» (`docs/BACKEND_SILENT_HARM_SESSION_PROMPT.md`), все три пункта. Дерево готово, НЕ закоммичено.** + +**§6 КОМПЛЕКТНОСТЬ — пункт → что сделано → каким ИСПОЛНЕНИЕМ подтверждено.** + +| Заказ | Что сделано | Чем подтверждено ИСПОЛНЕНИЕМ | +|---|---|---| +| **§3.1** инъекция банка доезжает на каждом провайдере либо громкий отказ | Новая ось `llm.Capability.SystemMessages` (`multi`\|`single`, `capability.go:87-127,158-165`); под `single` `toOpenAIMessages` СКЛЕИВАЕТ ведущий системный ряд в одно сообщение (`httpllm.go:513-559`) и падает громко на системном посреди диалога — зеркально Anthropic; YAML-ось `capabilities.system_messages` с валидацией enum (`config/models.go:101-106,566-578,646-650`); квирк объявлен у провайдера `gemini` с вендор-ссылкой и датой (`configs/models.yaml:120-137`) | 6 тестов `internal/llm/systemmessages_test.go` + сквозной на ОБОИХ волнах `internal/pipeline/systemmessages_wave_test.go`. Мутация «ось игнорируется» → падает `TestSingleSystemEndpointCarriesTheBankOnBothWaves` обеими под-ветками и `…CarriesTheInjection` с текстом `got 2 (roles [system system user])`. Мутация «ось теряется по дороге из YAML» → тот же тест. Каталог: `SH1`, `SH2` | +| **§3.2** дыра выдачи видна читателю файла | ⚠ **Заказ пере-формулирован по находке** (разрешено оркестратором 28.08): маркер уже существовал и ВРАЛ. `ChunkOutcome`/`ChunkExport` несут `DroppedMembers` (`bookrun.go:69-75`, `export.go:45-51`, `waverun.go:488`, `export.go:227-230`); `cmd/tmctl/render.go` печатает маркер В ПОТОКЕ ТЕКСТА (`gapMarker`, :26-63) в обоих рендерах и честные счётчики `withheld`/`incomplete` | Сквозной прогон БОЕВОГО бинаря `cmd/tmctl/exportgap_cli_test.go`: `tmctl translate` → `tmctl export --plaintext`, текст ДО и ПОСЛЕ на ОДНОМ состоянии (ниже). Матрица семи состояний. Мутации `SH3` (маркер пуст) и `SH4` (счёт выпавших не ведётся) | +| **§3.3** повтор принятого документа сходится | `refuseSeedConflicts` больше не срабатывает, когда отказ УЖЕ на записи (`membank/decisions.go:365-393`, новый `rejectsHoldSurface` :690-700); порядок записи в `writeDecisionFiles` изменён на REJECTS-FIRST с обоснованием (`bankdecisions.go:427-441,458-489`) | 5 тестов `membank/decisions_converge_test.go` + 3 дверных `pipeline/bankdecisions_converge_test.go` (реальные файлы, реальные классы отказа). Мутации `SH5` (сужение снято) и `SH6` (порядок возвращён) | + +**РЕШЕНИЯ ПО ПРОПУСКАМ И РОДНЕ — строкой каждое, как просил оркестратор.** +- **Четвёртая дыра (строка 141) — ВЗЯТА БЕСПЛАТНО, работы не потребовала.** Проверено исполнением + (проба по `internal/checks`, файл удалён после замера): сноска `[1] 蛊真人 — название романа.` + мимо детектора примечаний идёт, но поднимает класс `CJKLeak`, а он `CosmeticOnly` + (`checks/sanitizer.go:161-163`) ⇒ `StripCosmetic` вырезает ханьцзы, юнит уходит + `sanitizer_stripped`, и баннер «leak cleaned, verify» для НЕГО правдив. Механизм этого же заказа + накрывает класс без правки детектора — граница промта соблюдена буквально. + ⚠ Смежное, НЕ взятое и названное строкой: сноска БЕЗ ханьцзы (`[1] Название романа.`) не поднимает + ни одного класса (замер: `total=0`), едет как есть — но текста при этом НЕ теряет, это лишняя + добавка, а не дыра. Маркер на ней дезинформировал бы (⛔ анти-скоуп промта). Ловля таких сносок — + работа В ДЕТЕКТОРЕ, промт её прямо запретил. +- **Д1 — НЕ ВЗЯТ, отдельным предметом.** Право §9. Канон определяет decline ПОВЕРХНОСТНО + («declining a surface removes EVERY window of that surface», `14-api-contract/openapi.yaml:2038`), + и расширять его до энтити значило бы менять ратифицированный контракт из бэкенд-сессии. Вторая + половина Д1 («память глушит стоп») — уже НАЗВАННЫЙ и принятый двусторонний остаток в комментарии + `mining.go:308-313`, а не необнаруженный дефект. ⚠ Но одна траектория Д1 реальна и требует + ЧУЖОГО решения: эмиссия майнера энтити-широка (`miner_emit.go` `clusterTouches`), а фильтр + авто-банка ключуется только по собственному src строки (`mining.go:662`), поэтому ОТКЛОНЁННАЯ + поверхность может вернуться алиасом уцелевшей строки. Это противоречит канону уже сегодня. + **Не чиню в этом паке** (правильная форма — снять алиас со строки, а не снести строку; это + предмет банковой онтологии, а не тихой порчи) — **но это заказ, который стоит выдать.** +- **Д3 — НЕ ВЗЯТ, вне моей зоны.** `LiftBankStop` в `backend/` не существует ни в каком написании; + символ живёт в `platform/internal/runs/spawn.go` и `reconcile.go`, то есть в зоне, запертой паком + P9. Движковая половина при этом здорова: порядок «карта → память» ратифицирован и исполнен + (`mining.go:191` пишет карту, `:274` метит память), отказ памяти оценён в комментарии `:275-276` + как «one extra stop, nothing lost». ⚠ Поправка к моему же раннему пингу: я сказал «гарда не + существует» — точнее «не существует В ДВИЖКЕ». +- **Сосед 131 — НАЗВАН, не чиню.** Кап инъекции считает только `src → dst` + (`membank/memory.go:517-521` `glossaryLineTokens`), а рендер дописывает к КАЖДОЙ строке + `tx.UnverifiedMarker` (`:706`) либо `genderConstraintNote(...)` (`:711`), а редакторский блок ещё и + префикс `- ` с кавычками-ёлочками (`:821`). То есть отгружаемая строка строго длиннее той, за + которую платит бюджет; на коротких именных строках перерасход велик. Тема денег и качества + инъекции — не тихий брак в тексте. + +**НАХОДКИ, МЕНЯЮЩИЕ ПРОМТ (все три подписаны, ни одна не абсорбирована молча).** +1. **§3.2: маркер существовал и ВРАЛ.** Ветка `case ce.FinalText != ""` печатала «(leak cleaned, + verify)» ЛЮБОМУ флагнутому юниту с текстом, а c-lite member-drop (`export.go` ставит `DispFlagged` + при непустом тексте) попадал ровно в неё. Существующий тест не ловил: покрывал ветку только + причиной `sanitizer_stripped` (`export_cli_test.go:66`). Предмет заказа изменён с «завести маркер» + на «перестать врать и покрыть непокрытое» — разрешено оркестратором 28.08 при закрытом гейте. +2. **§3.2: подсказка промта про место правки была опасна.** Промт указывал `export.go:237` рядом с + `ApplyHeading`. Туда нельзя: `ChunkExport.FinalText` — поле артефактного контракта, а провод судит + `translated`/`withheld` предикатом «текст непуст» (`waverun.go:203-204` и `events.go:538-554`, + `runevents.go:130` `Shipped`). Маркер в пустом юните перевернул бы `withheld` → `translated` — + ровно ⛔(а) промта. `ApplyHeading` этого не делает лишь потому, что на пустом тексте он no-op. + Маркер живёт в РЕНДЕР-слое. Проверено: `FlagReason`-константы до и после ИДЕНТИЧНЫ, `exportVersion` + не двигался, `dropped_members` аддитивно и `omitempty`. +3. **§3.1: посылка «Gemini принимает ровно одно» отозвана автором** (оркестратор, 28.08) — вендор- + вердикт со ссылками и датами отправлен ему отдельно, для `00-provider-quirks.md`. Кратко: + структурная посылка ПОДТВЕРЖДЕНА (`systemInstruction` — один `object (Content)`, `Content.role` ∈ + {user, model}), поведение шима вендором НЕ документировано нигде, наш замер даёт «не склеиваются» + и «инструкция первого не исполнена»; «выживает одно или ноль» остаётся ОТКРЫТЫМ и для решения + безразлично. + +**ДЕФЕКТ, КОТОРЫЙ Я ВНЁС САМ И ЗАКРЫЛ (называю, потому что он показателен).** Первая редакция ветки +маркера срабатывала по `DroppedMembers > 0` БЕЗ проверки непустого текста. Юнит, у которого выпали ВСЕ +члены, ship-ит пустоту — и получил бы баннер «(INCOMPLETE)» с фразой «фрагмент отсутствует в тексте +НИЖЕ», указывающей на несуществующий текст. То есть я едва не заменил одну ложь читателю на другую. +Найдено собственным разбором ветвлений до опровергателей, закрыто гардом на обоих сайтах вызова, +покрыто состоянием «выпали все» в матрице и запинено мутацией (снятие гарда → тест падает). + +**§5 ОСИ РЕВЮ.** +1. **Читатель книги.** Все четыре класса проверены на ОТГРУЖЕННОМ ТЕКСТЕ, не на структурах: + (а) c-lite — был помечен ложно, теперь помечен верно и в потоке текста; (б) юнит целиком withheld и + (в) `glossary_miss` — помечены верно и раньше, работы не требовали, теперь ещё и не попадают под + маркер по ошибке; (г) сноска с ханьцзы — накрыта существующим верным маркером. +2. **Пара, которой в репо нет.** Правка не ветвится по паре ни в одном месте: ось `system_messages` — + свойство ЭНДПОЙНТА, маркер — английский операторский словарь той же поверхности, что и соседние + баннеры, поэтому у книги с любым `target_lang` и БЕЗ langpack вывод байт-идентичен. Специально НЕ + стал класть маркер в langpack: `InjectionTextsFor` для незнакомой цели возвращает нулевое значение + молча (`lang/embedded.go:411-423`) — маркер бы исчез, и дыра снова стала бы тихой ровно для той + пары, ради которой мандат общности и написан. +3. **Детерминизм.** Голден зелёный (`go test ./internal/pipeline/ -run TestGolden -count=1`), и это + сильная улика: голден пинит ПОЛНОЕ тело запроса с обоими системными сообщениями + (`testdata/golden/capture.golden:110`), то есть глобальная свёртка сделала бы его красным, а + квирк-свёртка не трогает никого, кто квирк не объявил. `omitempty` на новом поле запинен тестом + (`TestCapabilityWithoutTheAxisMarshalsUnchanged` сверяет БАЙТЫ). Снапшот фолдит Capability только + праймари и `escalate_to` (`snapshot.go:316-340`), а в `pipeline-c1.yaml` это `deepseek-v4-pro` + (`:60`) — gemini там лишь третий в цепочке при `budget_usd: 0` и SINGLE-HOP раннере ⇒ снапшот + боевого конфига не двигается, перекупки нет. + +**§8 OBSTACLE — что НЕ удалось и что НЕ проверено.** +- **Живого вызова к Gemini НЕ было** (пак платных вызовов не требует, и я их не делал). Что склейка + ПОМОГАЕТ, доказано структурно вендор-докой и логикой «при любом чтении, кроме склеивания, два + системных теряют текст» — но НЕ замерено на проводе. Решающая проба стоит денег и принадлежит + полигону: маркер на ПОСЛЕДНЕЕ системное + user-only контроль закрыли бы открытый вопрос + «выживает одно или ноль». Также: проба полигона гонялась на `gemini-3.1-flash-lite`, а в цепочке + стоит `gemini-3.1-pro-preview` — слой тот же, слаг другой, перенос не замерен. +- **Достижимость §3.1 сегодня НУЛЕВАЯ, и это не смягчение дефекта, а его честная рамка.** Раннер + исполняет SINGLE-HOP, `escalate_to` боевого конфига — `deepseek-v4-pro`, `budget_usd: 0`. Gemini не + достигается ни одним шиппинг-конфигом; дефект ждал первого конфига, который назовёт его в + `escalate_to` или в `chain[0]` лейбла. Кривая пост-чек-промахов на эскалационном хопе, которую §4.2 + просил как второй замер, СНЯТА БЫТЬ НЕ МОЖЕТ по той же причине — мерить нечего. +- **Ловушка, которую я НЕ чинил и обязан назвать: `capabilities` у провайдера с `kind: anthropic` + принимается валидацией и молча игнорируется** — `clients.go` не передаёт `Cap` в Anthropic-клиент + вовсе. Это ПРЕД-СУЩЕСТВУЮЩИЙ класс (так ведут себя ВСЕ оси: `budget_field`, `temperature`, + `reasoning`, `min_max_tokens`), моя ось лишь наследует его. Чинить правильно — тащить `kind` в + `validateCapabilities`, что трогает каждую ось; в этом паке это чужой предмет. Практический риск + сегодня нулевой: провайдера `anthropic` в `models.yaml` нет. +- **Изменён СУЩЕСТВУЮЩИЙ тест — заявляю прямо, не прячу в дифе.** + `internal/pipeline/bankreport_test.go` `TestHalfLandedIsNamedPerFile` кодировал ПОРЯДОК переименований + в своей фикстуре (занимал путь rejects, чтобы упал второй rename). Порядок изменён заказом §3.3, и + фикстура пере-нацелена на путь delta. Утверждение НЕ ослаблено: по-прежнему требуется, что ровно + одна половина легла, что это ПРОВЕРЕННЫЕ байты и что отчёт называет какая. Сверх того добавлен + `TestTheRenameOrderIsWhatMakesADeclineRecover`, который пинит САМ порядок вместе с его причиной, + чтобы его нельзя было вернуть как «уборку». **Если оркестратор считает это подгонкой — я готов + откатить и вынести §3.3-половину с полусостоянием отдельным заказом.** +- **Каталог мутаций: запись `AJ-seed-alias-decline` пере-заякорена.** Моя правка сдвинула строку, на + которую она указывала, и инструмент падал жёсткой ошибкой «the source moved under the catalogue» — + это был бы хард-блокер для следующей сессии. Атакуемое свойство сохранено дословно (снять + дельта-оговорку, оставить остальное); плюс добавлены шесть СВОИХ посадок `SH1`…`SH6`, по две на + пункт, чтобы фикс был постоянным гейтом, а не разовым замером. +- **Не проверено:** поведение под реальным параллелизмом двух `bank-apply` (лок это исключает, но я + не гонял); поведение склейки на провайдере, который принимает одно системное И имеет префиксный кеш + (у gemini кеш иной) — довод «склейка кеш не портит» опирается на комментарий самого кода и на то, + что префикс сохраняет байты и позицию, живьём не мерен. + +**§4 ОПРОВЕРГАТЕЛЬ — ЗАКАЗ ИСПОЛНЕН, И ОН ОКУПИЛСЯ. Шесть линз, мандат «опровергни».** +Веер шесть, а не «2–4» из §4: численный потолок снят словом владельца 28.08, соразмеряю предмету. +Линзы: Anthropic-путь и кеш · читатель книги · провод `withheld`/`translated` · безопасность банковых +решений · пара, которой в репо нет + детерминизм · вырожденность собственных тестов. **Пять из шести +вернули вердикт REFUTED или PARTIAL, и это правильный исход: они нашли ШЕСТЬ дефектов, из них ЧЕТЫРЕ +я внёс сам этим паком.** Все воспроизведены ИСПОЛНЕНИЕМ на копиях дерева, не чтением. + +| Находка | Кто/чем доказано | Что сделано | +|---|---|---| +| **MAJOR: маркер называл ЧУЖУЮ причину.** Юнит, где редактура флагнулась САМА (косм. чистка) И выпал член, печатал причину ЧИСТКИ как причину дыры: «фрагмент не переведён: sanitizer_stripped». Та же ложь, что убирал пак, перенесённая в поток текста | 2 линзы независимо, боевой бинарь | **ИСПРАВЛЕНО:** заведены `DroppedReason` на `ChunkOutcome`/`ChunkExport`, маркер несёт причину ЧЛЕНА, баннер — вердикт ЮНИТА. Пин `SH7`, состояние в матрице обоих рендеров | +| **MAJOR: анти-скоуп.** В черновично-однофазном конвейере юнит — одиночный чанк, и его СОБСТВЕННЫЙ флаг читался как «выпавший член» ⇒ косметически почищенный чанк, у которого ВЕСЬ текст на месте, объявлялся неполным | боевой бинарь, `tmctl translate` и `tmctl export` расходились на одном юните | **ИСПРАВЛЕНО:** учёт выпадений только когда финальная волна — редакторская (`export.go`). Тест `TestADraftOnlyStrippedChunkIsNotCalledIncomplete`, пин `SH8` | +| **MAJOR: перестановка порядка записи купила сходимость ЦЕНОЙ второго тихого вреда.** Rejects-first оставляет строку дельты на диске, а `seedGlossary` дельту отказами НЕ фильтровал ⇒ в окне между падением и ре-сендом ОПЛАЧЕННЫЙ прогон вливал в глоссарий редактора терм, который владелец отклонил | 2 линзы, исполнение на реальных файлах | **ИСПРАВЛЕНО:** `loadMinedDelta` отбрасывает поверхность, стоящую в списке отказов, и говорит об этом WARN'ом. Тест на реальном полусостоянии, пин `SH12`. Заодно исправлено утверждение в шапке `writeDecisionFiles`, которое после перестановки стало ложным | +| **MAJOR: сходимость останавливалась на ОДНОЙ из двух дверей.** `refuseInertDeclines` я решил не трогать — и ошибся: траектория из ОДНИХ ПРИНЯТЫХ вызовов (decline терма-со-своей-строкой → approve терма, который несёт его алиасом) делала повтор стоячего реестра решений отказным навсегда, а всё-или-ничего уносило с ним законный approve | исполнение, полная трасса | **ИСПРАВЛЕНО:** та же оговорка на второй двери (по `in.Rejects`, не `res.Rejects` — фолд уже отработал). Тест `TestTheStandingLedgerConvergesThroughBothDoors` + направление-гард на живом алиасе, пин `SH13` | +| **MAJOR: у боевого объявления в `configs/models.yaml` не было ГЕЙТА вовсе.** Две строки — единственное, что доводит фикс до реального эндпойнта, а удаление их оставляло ВЕСЬ модуль зелёным | опровергатель посадил свою мутацию и получил SURVIVED | **ИСПРАВЛЕНО:** `TestShippedGeminiDeclaresOneSystemMessage` над боевым конфигом, пин `SH9` (проверено: удаление строк → тест краснеет) | +| **MINOR: гарды в `renderTranslate` не были запинены** (близнецы в `renderExport` были), `case "multi"` не покрыт, счётчики читались как разбиение целого | исполнение | **ИСПРАВЛЕНО:** матрица шести состояний для `renderTranslate`, тест `TestExplicitMultiCostsNothing`, счётчик переписан как `exported=N (of them incomplete=M)`. Пины `SH10`, `SH11` | + +**ЧТО ОПРОВЕРГАТЕЛИ АТАКОВАЛИ И НЕ СЛОМАЛИ** (чтобы следующий читатель знал, что уже покрыто): +Anthropic-путь (склейка до него не доходит структурно — `Cap` в него не передаётся; два блока и живой +`cache_control` на месте) · префиксный кеш (стабильный префикс сохраняет байты и позицию) · провод +`translated`/`withheld` (`UnitDone.Shipped` считается по пустоте текста; ни `DroppedMembers`, ни +маркер её не трогают) · отсутствие новых причин флага (константы `FlagReason` до и после ИДЕНТИЧНЫ) · +`exportVersion` не двигался · снапшоты боевых конфигов не двигались · арм-конфиги грузятся. + +**ЧТО НАЙДЕНО, НО СОЗНАТЕЛЬНО НЕ ЧИНИТСЯ — с аргументом.** +1. **Ловушка `capabilities` на `kind: anthropic`** — оказалась чинибельной дёшево, и я её ВЗЯЛ: ось + теперь отказывается на этом виде по образцу `cache_ttl` (`models.go`), с тестом. Пред-существующий + класс для ОСТАЛЬНЫХ осей (`budget_field`, `temperature`, `reasoning`, `min_max_tokens`) остаётся — + его правильная форма тащит `kind` в `validateCapabilities` и трогает каждую ось; это чужой предмет. +2. **Снапшот не покрывает модель ГЕЙТА ТЕРМИНОЛОГИИ** (`config/pipeline.go` — не фолдится сознательно, + ратифицированно), а терминолог шлёт ДВА системных. ⇒ флип оси у провайдера, которым пользуется + только этот гейт, меняет байты под НЕИЗМЕННЫМ `RequestHash`, и чекпойнты ложно попадают. Дыра НЕ + моей оси — по ней ездят все оси `capabilities` с D3.1 — но моя первая, чей смысл именно в смене + этих байтов. **Утверждение в комментарии я ослабил до правды и дыру НАЗВАЛ прямо в коде** + (`capability.go`); закрытие — фолд модели гейта, как это делает `repairSnapshot`, и это правка + снапшот-контракта, принадлежащая владельцу решения «не фолдить», а не провайдерскому квирку. +3. **Сузив отказ по поверхности, я потерял поучение в одном углу:** decline, который И записан, И + по-прежнему инертен против подписанного сид-терма, теперь отвечает `already_applied` вместо + единственной работающей инструкции «убери из glossary_seed». Сходимость сочтена более тяжёлой + обязанностью (у вызывающего, который следует опубликованному ретраю, другого хода нет), но размен + реальный: **закрывается полем отчёта, несущим стоячий факт**, и это форма шва. + +**ХАРД-БЛОКЕР, НАЙДЕННЫЙ И СНЯТЫЙ (требование владельца «после тебя не осталось хард-блокеров»).** +`go test ./cmd/tmctl/` строил боевой бинарь в `os.MkdirTemp` и НИКОГДА не убирал каталог — по 20 МБ за +прогон. За этот пак накопилось **249 брошенных каталогов `tmctl-bin*`, ~5 ГБ**, и `/tmp` заполнился до +99%: батарея под `-race` начала падать с `link: mapping output file failed: no space left on device`, +что читается как сломанная сборка, а не как кончившееся место. Я на это и купился на один прогон — +сообщать «батарея красная» было бы ложью. **Причина найдена, место освобождено, харнесс починен:** +добавлен `TestMain`, снимающий каталог (`cmd/tmctl/exitcontract_test.go`); проверено исполнением — +два прогона подряд, прирост каталогов 0. Дефект пред-существующий, пак его лишь довёл до отказа. + +**ЧИСЛА, С КОМАНДАМИ (§4.1).** +- `go build ./...` · `gofmt -l .` (пусто) · `go vet ./...` · `go vet -tags live ./...` — **все 0**. +- `go test ./... -count=1 -timeout=20m` → **EXIT 0**. +- `go test ./... -race -count=1 -timeout=20m` (= строка `make test`) → **EXIT 0, real 2m09s**. +- `golangci-lint run --timeout=15m ./...` (= `make lint`) → **0 issues**. +- `go test ./internal/pipeline/ -run TestGolden -count=1` → **ok** (голден пинит ПОЛНОЕ тело запроса). +- `go run ./cmd/tmmutate -root <КОПИЯ>` → **73 посадки, 0 неожиданных исходов, EXIT 0** (72 RED + один каталогизированный выживший `G-byte-gate`; все 13 моих красны); каталог вырос 60 → 73 (13 новых посадок: + `SH1`…`SH13`), у каждой из трёх заказанных тем ≥2 своих. +⚠ **Каталог гонять ТОЛЬКО на копии** — сам инструмент это требует, а я один раз запустил его на рабочем +дереве и словил таймаут; дерево спас обработчик сигнала самого инструмента (проверено: мутационного +текста не осталось, батарея зелена). Записываю как урок, а не как мелочь. + +**ФАЙЛЫ (19 правленых + 5 новых; всё в `backend/`, плюс эта запись в журнале).** +`cmd/tmctl/render.go` · `cmd/tmctl/exitcontract_test.go` · `cmd/tmmutate/mutations.json` · +`configs/models.yaml` · `internal/config/models.go` · `internal/config/models_catalog_test.go` · +`internal/llm/capability.go` · `internal/llm/httpllm.go` · `internal/llm/provider_openai.go` · +`internal/llm/provider_local.go` · `internal/membank/decisions.go` · +`internal/membank/decisions_fuzz_test.go` · `internal/pipeline/bankdecisions.go` · +`internal/pipeline/bankreport_test.go` · `internal/pipeline/bookrun.go` · +`internal/pipeline/export.go` · `internal/pipeline/mining.go` · `internal/pipeline/runner_test.go` · +`internal/pipeline/waverun.go`. +Новые: `cmd/tmctl/exportgap_cli_test.go` · `internal/llm/systemmessages_test.go` · +`internal/membank/decisions_converge_test.go` · `internal/pipeline/bankdecisions_converge_test.go` · +`internal/pipeline/systemmessages_wave_test.go`. + +**⛔ ВОПРОСЫ И ПИНГИ ОРКЕСТРАТОРУ — четыре, каждый требует ЧУЖОГО решения.** +1. **Изменён СУЩЕСТВУЮЩИЙ тест, и я это заявляю, а не прячу.** `bankreport_test.go` + `TestHalfLandedIsNamedPerFile` кодировал в фикстуре ПОРЯДОК переименований, который заказ §3.3 + изменил; фикстура пере-нацелена на второй rename, утверждение сохранено дословно и усилено новым + `TestTheRenameOrderIsWhatMakesADeclineRecover`, пинящим сам порядок с его причиной. **Считаешь + подгонкой — откачу, и тогда половину §3.3 с полусостоянием надо выносить отдельным заказом.** +2. **Вендор-вердикт по Gemini** — прислан отдельным сообщением, с URL и датами; три поправки к + `00-provider-quirks.md:157` (структурная посылка ≠ поведенческий вывод · `Content.parts[]` + повторяемо · «форум Google» — это два не-сотрудника из 2025). Пишешь ты, зона твоя. +3. **Дыра снапшота у модели гейта терминологии** (см. выше, п.2 «не чинится»). Закрытие — правка + снапшот-контракта; принадлежит владельцу решения «гейт не фолдится», не мне. +4. **Д1 достоин собственного заказа.** Не «расширить decline до энтити» (это против канона), а узко: + эмиссия майнера энтити-широка (`miner_emit.go` `clusterTouches`), а фильтр авто-банка ключуется + только по собственному src строки (`mining.go:662`), поэтому ОТКЛОНЁННАЯ поверхность возвращается + алиасом уцелевшей строки. Канон уже сегодня обещает «declining a surface removes EVERY window of + that surface» — то есть движок не держит обещание. Правильная форма — снять алиас со строки, а не + снести строку; это банковая онтология, и в «тихую порчу» её тянуть не следует. + +**ПРАВО §9 ПРИМЕНЕНО ТРИЖДЫ, каждый раз с грунтом:** Д3 отклонён (символ живёт в `platform/`, зона +заперта; движковая половина здорова и порядок «карта → память» исполнен) · Д1 отклонён как отдельный +предмет (выше) · подсказка промта про место маркера отклонена (она перевернула бы `withheld` → +`translated`). ⚠ И один раз право §9 я применил ОШИБОЧНО: решил не трогать вторую дверь +`refuseInertDeclines`, рассудив, что её случай — «устаревший документ, а не ретрай». Опровергатель +показал траекторию из ОДНИХ ПРИНЯТЫХ вызовов. Рассуждение было неверным, исполнение это доказало, +дверь починена. + + +**⚠ ЗАПИСКА НИЖЕ — ПЛАН ДО ПРАВОК (§6), она СОХРАНЕНА как след дисциплины, но её выводы местами ОПРОВЕРГНУТЫ итогом выше. Не наследовать из неё: «сломан только класс (а)» (сломан ещё и стык «редактура флагнулась сама + выпал член»), «фиксация порядка записи безопасна» (она купила второй тихий вред, закрытый фильтром дельты) и «Д3 не существует» (существует, в `platform/`).** + +**📝 ЗАПИСКА-ПЛАН БЭКЕНД-СЕССИИ 28.08 — ПАК «ТИХАЯ ПОРЧА» (`docs/BACKEND_SILENT_HARM_SESSION_PROMPT.md`), §6. Правок кода на момент записи НЕТ.** +Три заказа: §3.1 инъекция банка не теряется · §3.2 дыра выдачи видна читателю файла · §3.3 повтор +принятого документа сходится. Ниже — что установлено ЧТЕНИЕМ до правок и как из этого следует план. +⚠ Всё, что помечено «проверить исполнением», в план входит как обязательство, а не как факт. + +**Находка 1 (меняет §3.2). Маркер уже есть — и он ВРЁТ.** `cmd/tmctl/render.go:499` печатает для +флагнутого юнита с НЕПУСТЫМ текстом баннер «(leak cleaned, verify)», а `:38` — «[FLAG … — leak +cleaned, exported cleaned, verify]». Ветка выбирается по `ce.FinalText != ""`, и c-lite member-drop +(`export.go:226-230` ставит `DispFlagged` при непустом тексте) попадает ровно в неё. То есть юнит, у +которого ВЫПАЛ ЦЕЛЫЙ ЧЛЕН, объявляется читателю косметической чисткой санитайзера, которой не было. +Существующий тест это не ловит: `cmd/tmctl/export_cli_test.go:66` покрывает ветку только причиной +`sanitizer_stripped`. ⇒ Предмет §3.2 не «завести маркер», а «перестать врать и покрыть непокрытое» +(пере-формулировка разрешена оркестратором 28.08 при закрытом гейте «что читает человек»). + +**Гейт §3.2 закрыт чтением: движок НЕ ПИШЕТ ФАЙЛОВ ВООБЩЕ.** Все команды рендерят в stdout +(`cmd/tmctl/main.go:227-228`, `:338-349`). Читаемых человеком поверхностей у движка две, обе +текстовые и обе уже несут пер-юнитный баннер: `tmctl translate` (`render.go:27-72`) и +`tmctl export --plaintext` (`render.go:478-511`). Дефолт `export` — JSON +(`export_cli_test.go:24-28`), и он же артефакт шва к платформе (`research/23` строка 76 — +«артефактный контракт (export-JSON · 101 · 100)»). Читательский файл (epub и пр.) собирает +ПЛАТФОРМА по `POST /books/{bookId}/exports` (`14-api-contract/openapi.yaml:824-870`) — чужая зона. +⇒ Адресат моего маркера — человек, читающий plaintext-выгрузку движка; платформенная половина +остаётся за платформой, и провод различение несёт (`14-api-contract/README.md:300`). + +**Следствие для места правки, и оно расходится с подсказкой промта.** Промт указывал на +`export.go:237` (прецедент `ApplyHeading`). Туда маркер класть НЕЛЬЗЯ: `ChunkExport.FinalText` — это +поле `final_text` артефактного контракта, а `waverun.go:203-204` кормит провод предикатом +`oc.FinalText != ""` (`shipped`). Любой маркер, вклеенный в текст ПУСТОГО юнита, перевернул бы +`withheld` → `translated` — ровно ⛔(а) промта. `ApplyHeading` этого не делает только потому, что на +пустом тексте он no-op (`chunk/chunker.go:186-191`). Различение: титул — часть КНИГИ и обязан ехать +на провод; маркер дыры — метаданные О тексте, и в поле, по которому провод судит, ему нельзя. ⇒ +маркер живёт в РЕНДЕР-слое (`cmd/tmctl/render.go`), на слой дальше проекции. Это ответ на требование +промта «либо тем же механизмом, либо объясни, почему другое». + +**Классы дыр — ревизия по коду.** (а) c-lite member-drop — помечен, но ЛОЖНО (находка 1); (б) юнит +целиком withheld (`waverun.go:496-509`) и (в) gate-override `glossary_miss` (`export.go:319-323`) +дают пустой текст и попадают в ветку `render.go:502-504` «(not translated, flagged for a human)» — +помечены ВЕРНО, работы не требуют; (г) четвёртая, сноска «[1] …» мимо детектора примечаний: детектор +требует заголовочного слова (`lang/data/target-ru.txt`, `sanitizer_trailing_note`), но ханьцзы в +сноске поднимают класс `CJKLeak`, а он `CosmeticOnly` (`checks/sanitizer.go:161-163`) ⇒ юнит уходит +как `sanitizer_stripped`, и баннер «leak cleaned» для НЕГО правдив. То есть класс (г) накрыт +существующим механизмом БЕСПЛАТНО и работы в детекторе не требует — граница промта соблюдена. +**Проверить исполнением обязательно:** что сноска с ханьцзы действительно даёт `CosmeticOnly`, а не +подтягивает субстантивный класс. + +**§3.1 — план и его цена.** Свёртки в `toOpenAIMessages` нет (`llm/httpllm.go:513-521`, копия +один-в-один); её комментарий `:511-513` говорит, что `CacheBoundary` на этом пути бессмысленна ⇒ +свёртка кеш не портит. У Anthropic путь другой и трогать его нельзя +(`llm/provider_anthropic.go:169-179`: ведущий system-префикс → блоки, `CacheBoundary` → реальный +`cache_control`). Ось лечения — `llm.Capability` + блок `capabilities:` (провайдерный ИЛИ модельный, +`config/models.go:85`,`:146`,`:442-450`), прецедент безопасного расширения — `MinMaxTokens` с +`json:",omitempty"`. **Детерминизм:** снапшот фолдит РАЗРЕШЁННУЮ Capability только для праймари и +единственного `escalate_to` (`pipeline/snapshot.go:316-340`,`:479`,`:495`); в `pipeline-c1.yaml` +`escalate_to: deepseek-v4-pro` (`:60`), а gemini стоит лишь третьим в цепочке `default` (`:129`) при +`budget_usd: 0` (`:136`) и раннере SINGLE-HOP ⇒ объявление квирка у gemini снапшот боевого конфига +НЕ двигает. Проверить исполнением (голдены + снапшот-тесты). +⚠ **Честная граница достижимости:** при SINGLE-HOP и текущих конфигах gemini не достигается ВООБЩЕ; +дефект латентный и ждёт конфига, который назовёт gemini в `escalate_to` или в `chain[0]` лейбла. +⚠ **Развилка, которую выношу на опровергателя (§4 заказ):** дефолт оси. «Квирк объявляет тот, у кого +он есть» (нулевое значение = сегодняшнее поведение) не двигает ничего, но НОВЫЙ провайдер с той же +ловушкой снова теряет инъекцию молча — тот же класс тихого вреда. «Свёртка по умолчанию, исключение +объявляет проверенный» безопасна для незнакомого провайдера, но меняет провод боевому переводчику без +замера (запрет «эмпирика решает») и двигает снапшот. Решение — после опровергателя, с обоснованием. +⚠ **Посылка промта «Gemini принимает ровно одно системное» ОТОЗВАНА автором** (оркестратор, 28.08): +это было рассуждение; собственный замер полигона (`00-provider-quirks.md:157`) даёт 535 токенов ≈ не +выжило НИ ОДНО. Заказу это безразлично, тесту — нет: утверждается пост-фиксный инвариант «системное +РОВНО ОДНО и инъекция ВНУТРИ него», он верен при любом чтении. Вендор-вердикт со ссылкой уходит +оркестратору для `00-provider-quirks.md` (его зона). + +**§3.3 — трасса контрактной сессии ПОДТВЕРЖДЕНА моим чтением, и подтверждено то, что она сама не +читала.** Отказ живёт в `membank/decisions.go:374-377`, бежит ДО фолда (`:296-300`), и +всё-или-ничего вытирает документ целиком (`:302-307`). **Порядок записи я прочитал сам: +`bankdecisions.go:458-473` коммитит ДЕЛЬТУ ПЕРВОЙ**, а собственный комментарий функции `:436-438` +говорит дословно: «delta-first is safe for an approve and NOT for a decline … convergence is what +actually carries both». ⇒ окно застревания реально, и код сам называет допущение, которое ложно. +Идемпотентная ветка уже есть (`already_applied`, `:221-224`,`:269-272`) — вводить понятие не надо. +План: сузить условие отказа так, чтобы оно не срабатывало, когда отказ УЖЕ записан в rejects, плюс +направление-гард (настояще-инертный decline обязан отказывать по-прежнему тем же текстом). +Идентичность документа НЕ вводить — сходимость по СОСТОЯНИЮ. + +**Д1 и Д3 — беру право §9 и НЕ чиню, оба с грунтом.** +· **Д3 ОТКЛОНЁН как несуществующий:** гард `LiftBankStop` не найден в коде НИ В КАКОМ написании +(греп по всему `backend/`); реальный носитель — `mining.go:266-277`, и он уже реализует +ратифицированный порядок «карта → память»: `writeFileAtomic` карты на `:191`, `MarkStopPresented` +на `:274`, с комментарием `:270-273` «strictly after the map … the reverse order can mark surfaces +presented whose map never reached disk» и с явной оценкой отказа памяти как «one extra stop, nothing +lost» (`:275-276`). То есть механизм написан ВМЕСТЕ с памятью v16, а не до неё. +· **Д1 ОТКЛОНЁН как ратифицированное поведение + названный остаток:** канон определяет decline +ПОВЕРХНОСТНО («declining a surface removes EVERY window of that surface» — +`14-api-contract/openapi.yaml:2038`), и расширение до энтити противоречило бы контракту; а «память +глушит стоп» — уже НАЗВАННЫЙ и принятый двусторонний остаток в комментарии +`mining.go:308-313`. Оба — строка с причиной, не правка. + +**Сосед 131 (называю, не чиню, как велит §3.4):** кап инъекции считает только «src → dst» и не +считает маркер `⟨проверить⟩` и родовые пометки (`lang/data/injection.txt` несёт их как отдельные +значения) ⇒ реальная инъекция шире бюджета. Проверю точный носитель при работе в этом коде и назову +`file:line` в итоге; чинить не буду — тема денег и качества инъекции, а не тихого брака в тексте. + +**Порядок работы:** §3.1 → §3.2 → §3.3, каждый с тестом на ПОСТ-фиксный инвариант; батарея под +`-race` + линтер; посадки мутаций ≥1 на пункт с вердиктом по ДЕЛЬТЕ и ТОПИЧНОСТИ; опровергатель на +выбор места §3.1 (заказ §4). Веер агентов соразмеряю предмету: численный потолок «2–4» из §4 промта +снят словом владельца 28.08. + **✅ ИТОГ БЭКЕНД-СЕССИИ 27.08 (2) — ФИКС3 ПО ФИКС-ЛИСТУ ПРИЁМКИ (`docs/BACKEND_SEAM_FIX3_SESSION_PROMPT.md`), все 6 пунктов. Дерево готово, НЕ закоммичено; продолжает дерево трёх паков.** Четыре пина, один снос, ноль новых механизмов: пин порядка «карта → память» (мутация приёмки воспроизведена в трёх пакетах ДО пина; занятый путь карты → ошибка прогона + пустая память) · пин diff --git a/docs/README.md b/docs/README.md index fe51cf7f..01cddd45 100644 --- a/docs/README.md +++ b/docs/README.md @@ -22,11 +22,11 @@ | Роль | Активный промт | Статус | |---|---|---| | Оркестратор | [ORCHESTRATOR_SESSION_PROMPT.md](ORCHESTRATOR_SESSION_PROMPT.md) | роль и нормы; счётчик роли — CURRENT-STATE | - | Бэкенд | [BACKEND_SILENT_HARM_SESSION_PROMPT.md](BACKEND_SILENT_HARM_SESSION_PROMPT.md) | ⚠ **НАПИСАН, но ещё НЕ ВЫДАН** — исполнителя нет. Пак «тихая порча»: эскалация теряет инъекцию банка (208) · дыры выдачи молчат в выгруженном файле (193). Второй рубеж ПРОЙДЕН — 12 находок опровергателя применены, включая вырожденный тест самопроверки. Состав — §3 промта. Четыре промта входной двери шва — в `archive/prompts/` (D39.158) | - | Платформа | [PLATFORM_P9_SESSION_PROMPT.md](PLATFORM_P9_SESSION_PROMPT.md) | ВЫДАН 27.08, пак **P9** — пункт (2в) очереди D39.156, последний в связке шва. Состав — §3 промта, здесь не пересказан. ⚠ **Второй рубеж (независимое опровержение промта) НЕ пройден** — сессия окружения, которой он был заказан, кончилась вместе с рестартом; исполнителю это сказано, его право отказа по §9 несёт этот рубеж | + | Бэкенд | [BACKEND_SILENT_HARM_SESSION_PROMPT.md](BACKEND_SILENT_HARM_SESSION_PROMPT.md) | **ВЫДАН 28.08**, пак «тихая порча»: эскалация теряет инъекцию банка (208) · дыры выдачи молчат в выгруженном файле (193) · движковая идемпотентность повтора документа (§3.3). Состав — §3 промта. ⚠ **Второй рубеж ПРОТУХ:** опровергатель отработал (12 находок, включая вырожденный тест самопроверки) и лёг коммитом `3a65989`, но ПОСЛЕ него в промт добавлены три вещи, которых он не видел — §3.3 целиком (`9fd1d3e`), четвёртая дыра со своей границей и предупреждение о столкновении с `201` (`2c1fa9a`). Исполнителю это сказано прямо; его право отказа по §9 несёт этот рубеж. Четыре промта входной двери шва — в `archive/prompts/` (D39.158) | + | Платформа | активного НЕТ | пак **P9** ОТРАБОТАН, ПРИНЯТ и ЗАЛЕНДЖЕН 28.08 (**D39.162**): дверь правок банка смонтирована, ключи доехали, полоса стала сквозной вместе с каноном **0.6.0**. Промт — в `archive/prompts/`. ⚠ Открытым остался архитектурный стоп `PD-410` (платформа продаёт главы, движку идёт только `--ceiling-usd`) и продуктовый вопрос владельцу по `Н2`/`Н3`. Следующая работа зоны — по строкам **215**/**216** и регистру, запуск по слову владельца | | Полигон | [POLYGON_EXP2223_REDO_SESSION_PROMPT.md](POLYGON_EXP2223_REDO_SESSION_PROMPT.md) (отложенный — [POLYGON_PACKAGE4_SESSION_PROMPT.md](POLYGON_PACKAGE4_SESSION_PROMPT.md), строка 85) | фаза Д ИДЁТ; ⚠ живой носитель курса — в `eval/dovodka/`, какой именно называет зона (⚠ [POLYGON_PHASE_D_HANDOFF.md](POLYGON_PHASE_D_HANDOFF.md) — перекрытый снимок, читать не как курс) | | Фронт | активного НЕТ | **ЗОНА ЗАМОРОЖЕНА** (D39.136 п.2 + D39.147: разморозка отдельным словом владельца, не привязана к P7); перечень первого касания — в зонном журнале | - | Контракт | активного НЕТ | минор **0.5.0** ПРИНЯТ и заленджен 27.08 (D39.161): отменённая пер-термная модель снесена, дверь `POST …/bank/corrections` объявлена и выведена из словаря глагола, признак «не построено» машиночитаем. Следующая работа по контракту — ПОСЛЕ монтажа двери паком (2в) | + | Контракт | активного НЕТ | минор **0.6.0** ПРИНЯТ и заленджен 28.08 (**D39.162**/**D39.163**): полоса прогресса объявлена сквозной, `Progress.stage` заведён ВТОРЫМ ограниченным исключением из границы «ничего о том, КАК переводится книга». До него — **0.5.0** (D39.161, дверь правок банка). Хвост компаньона — строка **203**, следующий минор по её пункту (к) | Отработанные промты — `archive/prompts/`, отчёты с ревью-шапками — `archive/reports/`. Зонные журналы фронта и платформы — `frontend-PROGRESS.md` / `platform-PROGRESS.md` в их зонах (прогресс зон только там, D39.100). - Чужие зоны — фронт и платформа (читать при касании стыка; каждая ведёт СВОЙ зонный бэклог — единый бэклог их строк не принимает, D39.84): [../frontend/](../frontend/) — веб-интерфейс: промт фронт-сессий + [STACK_DECISIONS.md](../frontend/docs/STACK_DECISIONS.md) (пины версий точными числами и ловушки) + [BACKLOG.md](../frontend/docs/BACKLOG.md) · [../platform/](../platform/) — SaaS control plane: README + [BACKLOG.md](../platform/BACKLOG.md) + `docs/` (зонный журнал `platform-PROGRESS.md` · регистр дефектов · `STACK_DECISIONS.md` с рецептом стенда и инвентарём каналов шва · **[ENGINEERING_STANDARDS.md](../platform/docs/ENGINEERING_STANDARDS.md) — ратифицирован; КАЖДЫЙ промт платформенной сессии обязан на него ссылаться, отступление = пинг** · **[PLATFORM_DIRECTION.md](../platform/docs/PLATFORM_DIRECTION.md) — ратифицированное направление зоны: аутентификация, деньги, стандарты, скорость** · архив промтов). diff --git a/docs/architecture/05-decisions-index.md b/docs/architecture/05-decisions-index.md index 8aac0dba..f5c0c76a 100644 --- a/docs/architecture/05-decisions-index.md +++ b/docs/architecture/05-decisions-index.md @@ -1,4 +1,4 @@ -# Реестр D-нот — карта актуальности v2 (D1–D39.161; +# Реестр D-нот — карта актуальности v2 (D1–D39.163; > ⚠ **СЛАБОЕ МЕСТО, КОТОРОЕ БЫЛО ЗДЕСЬ (вписано 22.08, ЗАКРЫТО 24.08 — D39.157 п.6).** Колонка ТЕЛА > у нот D39.107…D39.123 говорила «жив», хотя тела уехали в слайс подрезкой D39.139; семнадцать строк @@ -222,4 +222,6 @@ | D39.159 | 27.08 | Пак P8-REVIEW принят: числа воспроизведены приёмкой на дереве С лендингом бэкенда (18 пакетов, EXIT=0, скипов 0; регистр 398/96). Ратифицированы норма копии-с-каноном (`ENGINEERING_STANDARDS` §3 п.3), эррата `STACK_DECISIONS` §13 и токен `ОСПОРЕНО(PD-N)` при двусторонней ссылке; статусы трёх спорных строк НЕ пере-открыты. `PD-379` подтверждён чтением, `PD-376` — своей посадкой. Правка гейта формы регистра отклонена замером (краснит 7 законных строк). | ✅ | | D39.160 | 27.08 | Контрактный минор 0.4.0 → 0.5.0 выдан промтом; **сквозная полоса прогресса (строка 200) ИЗЪЯТА из него словом владельца** и едет с платформенным паком (2в) — гейт версии требует совпадения канона и деплоя в момент лендинга, а сервер прогресс по-новому не считает. Цена «одним куском» обнулена заморозкой фронта. Промт прошёл оба рубежа: 9 находок опровергателя, все применены. | ✅ | | D39.161 | 27.08 | Контрактный минор **0.5.0** принят и заленджен: отменённая пер-термная модель снесена из канона и компаньона, дверь `POST …/bank/corrections` выведена из словаря `bank-apply` поле в поле, признак «не построено» машиночитаем, счётчики упразднённой модели сняты. Константа платформы поднята тем же коммитом — батарея зоны 18/EXIT=0. Ошибка промта про «поля навсегда нули» найдена исполнителем и вынесена `PD-399`. | ✅ | +| D39.162 | 28.08 | **Платформенный пак P9 принят и заленджен + контрактный минор 0.6.0**: дверь правок банка смонтирована синхронно, ключи доехали `--keys-file` (строка 211 закрыта), дубль конвенции пути снят (213 сужена), полоса стала СКВОЗНОЙ вместе с каноном, `PD-399` снят. Батарея пере-прогнана приёмкой с живым Postgres (793/18/0/0). Две мои диспозиции сессия опровергла исполнением, я принял; одну её рекомендацию (закрыть 186) отклонил — жив остаток. Р3 — архитектурный стоп `PD-410`, канон НЕ смягчён. | ✅ | +| D39.163 | 28.08 | **Граница контракта получает ВТОРОЕ исключение — `Progress.stage`**, ограниченное двумя условиями: значение ВЫВОДИТСЯ платформой из тех же счётчиков (не проброс из движка) и словарь ОТКРЫТ (клиент рисует незнакомое нейтрально, версия не поднимается). Ради канона мультиязычности: пара с иной формой работы не требует нового клиента. Оговорка по `PD-410`: подпись — модель платформы, не отчёт движка. Третьего исключения нота НЕ разрешает. | ✅ | diff --git a/docs/architecture/05-decisions-log.md b/docs/architecture/05-decisions-log.md index 19fd3b21..8444f363 100644 --- a/docs/architecture/05-decisions-log.md +++ b/docs/architecture/05-decisions-log.md @@ -1,4 +1,4 @@ -# Журнал решений оркестратора — контракт D1–D39.161 (живой файл: карта · эрраты · живые тела · голова D39.124+ (подрезка D39.139); тела закрытых эр — в слайсах `docs/archive/architecture/`, указатель ниже; реестр всех нот — `05-decisions-index.md`) +# Журнал решений оркестратора — контракт D1–D39.163 (живой файл: карта · эрраты · живые тела · голова D39.124+ (подрезка D39.139); тела закрытых эр — в слайсах `docs/archive/architecture/`, указатель ниже; реестр всех нот — `05-decisions-index.md`) > **⟶ КАРТА АКТУАЛЬНОСТИ (ревизия D31, продлена до D38.2 [12.07]; исторические записи ниже НЕ переписываются — дисциплина D23.3).** Работая с контрактом (греп номера: живой файл → слайсы, целиком НЕ читать — D39.125), держи под рукой, что чем перекрыто: > ⚠ **Эррата 09.08 (D39.125):** D39.111 п.1 предписывал промту S3 «максимум = баланс МИНУС открытые холды» — формула ОШИБОЧНА (вычитание дважды), исправлена D39.115 п.2(а): максимум = Balance КАК ЕСТЬ; тело D39.111 живёт ниже в этом файле (голова D39.106+). @@ -807,3 +807,102 @@ 8. **Щели v1 названы, не спрятаны** (в каноне и компаньоне): машинного словаря причин отказа нет · алиасы клиенту невидимы · счёт `signature` недостижим без правки или превью · на 409 не едут `preexisting`/`signature`, хотя движок их печатает — названная цена, не забывчивость. + +## D39.162 — ПЛАТФОРМЕННЫЙ ПАК P9 ПРИНЯТ И ЗАЛЕНДЖЕН + КОНТРАКТНЫЙ МИНОР 0.6.0: дверь правок банка смонтирована, ключи доехали, полоса стала сквозной (28.08, оркестратор №19). ✅ + +**Что заленджено.** Пункт (2в) очереди D39.156 — последний в связке шва — отработан целиком: +дверь `POST /books/{bookId}/bank/corrections` смонтирована синхронно и под пер-книжным мьютексом · +ключи деплоя доезжают до движка аргументом `--keys-file` (строка **211** ЗАКРЫТА, 11 пинов) · +дубль движковой конвенции пути снят, путь банка берётся из конверта артефактов движка (**213** +сужена до одной строки дев-пути) · полоса прогресса стала ОДНОЙ монотонной долей на всю работу +прогона через обе волны, с канонной половиной — минором **0.6.0** (строка **200** ЗАКРЫТА) · с +провода сняты `pending_decisions`/`complete` (`PD-399`). Бэклог 176 → 175. + +**Приёмка — исполнением, не чтением отчёта.** Батарея пере-прогнана мной с живым Postgres: +`EXIT=0`, 18 пакетов, `=== RUN` **793**, FAIL 0, DATA RACE 0, SKIP 2 (оба гейчены живым бинарём +движка). ⚠ Первый мой прогон БЕЗ `TM_PLATFORM_TEST_DSN` дал 279 пропусков из 774 — то есть «зелёная +батарея» без DSN проверяет две трети зоны, и это стоит помнить всякой будущей приёмке. + +**Две мои диспозиции сессия ОПРОВЕРГЛА, и я принял опровержение.** +1. **Р4 — «`limitedBuffer` не убивает чайлда» НЕВЕРНА, мой заказ отозван.** Проверено МОИМ + исполнением, а не её доводом: отдельная программа с чайлдом, печатающим бесконечно, и writer'ом, + отказывающим на первой записи, — чайлд умер САМ за 1 мс с `signal: broken pipe`. Механизм стоит в + исходнике Go (`os/exec`, `writerDescriptor`: `pr.Close() // in case io.Copy stopped due to write + error`) и работает для `Stdout = io.Writer`; `drain()` носит свой `Kill` потому, что читает через + `StdoutPipe`, где никто ничего не закрывает. Ревьюер экстраполировал одну семантику на другую и + НЕ исполнял. Урок общий: находка «по аналогии с соседним кодом» — гипотеза, пока не посажена. +2. **Р2-dispute (READ COMMITTED между двумя UPDATE) растворён фиксом** — миграция сведена к одному + стейтменту, окна не существует; проверено чтением миграции. + +**Одну её рекомендацию я ОТКЛОНИЛ.** Строку **186** она предлагала закрыть по своему замеру. Замер +верен и внесён в строку (`If-None-Match` → `304`/0 Б; gzip ×4.1; ниже 1024 Б честно не применяется), +но он подтверждает шаги 1–2, заленденные ещё P7, — а у строки живы шаги 3–5. Закрытие унесло бы долг +целиком. Строка остаётся открытой с ПОДТВЕРЖДЁННОЙ половиной. + +**Р3 — архитектурный СТОП, и он назван, а не заметён (`PD-410`).** Проверено мной по коду и жёстче её +формулировки: у движка НЕТ флага числа глав вовсе — `TranslateArgs` несёт только `--ceiling-usd` +(`platform/internal/runner/engine.go:90-98`), а сам флаг каппит КУМУЛЯТИВНУЮ трату по книге +(`backend/cmd/tmctl/invocation.go:129`), тогда как волна идёт по всем чанкам. Платформа продаёт +ГЛАВЫ, движок останавливается по ДЕНЬГАМ: единицы разные, и никакая формула полосы их не мирит, пока +платёжная модель и план работ не согласованы. **Канон я НЕ смягчил** — по прецеденту §3.3 бэкенд-пака: +обещание верное, дефект в реализации; смягчение канона под текущий деплой — это то, как обещание +тихо становится ложью. Вместо смягчения канон получил честную оговорку у `stage` (D39.163) и явное +«прогон, кончившийся рано, до `total` не доходит». + +**Строка 227 заведена с ИСПРАВЛЕННЫМ якорем.** Находка Д4 (квитанция считает `signature` от карты, +которую переписывает любая граница майнинга) ссылалась на `pipeline/signaturemap.go` — такого файла +НЕТ. Существо находки верно, носитель другой: идентификатор карты живёт в +`backend/internal/membank/decisions.go:903-912` (`seed.SignatureMapID`), и шов его не читает, то есть +починка аддитивна. + +**Тесты под зелень не подгонялись — проверено диффом, а не заявлением.** 108 удалённых строк в тестах +разобраны поимённо: словарь кодов 16 → 18 (минор 0.5.0 добавил два банковских), «шесть фактов» → +«семь» с новым полем в проверяемом списке, снесённые утверждения о `pending_decisions` — вслед за +снесёнными полями. Единственная снесённая ФУНКЦИЯ +(`TestLiftingTheStopOnADraftOnlyDeploymentRetakesTheRightBaseline`) проверяла пере-взятие базовой +линии — механику, которую сквозная полоса упразднила; на её месте стоит тест противоположного +инварианта («снятие стопа не двигает бар») плюс два новых, каждый с названной пойманной мутацией. + +**Фикс-раунд приёмки — 13 позиций** (`platform/docs/platform-PROGRESS.md`, таблица F1-F13). Из +проверенных мной по коду: потолок 1 МиБ меряется на ОТРЕНДЕРЕННОМ документе до спавна, поэтому +законное тело получает канонный `413`, а не движковое «пере-решите» (экранирование HTML выключено — +документ читает движок, не браузер); гард resume разделил слова честно — живой сосед `run_in_flight`, +финишировавший `not_resumable`; грейс стопа поднят с 10 с выше измеренных 11.3 с непрерываемого +участка. + +**Что осталось открытым и НЕ спрятано:** `PD-410` (стоп Р3) · `PD-281` (прогон-применение над полной +книгой) · строка **213** одной строкой дев-пути · строка **186** шагами 3–5 · строка **214** вопросом +«кто и когда подписал» · строка **227**. Наследие `Н2`/`Н3` (смена формы конвейера на живой книге +перепродаёт купленные главы) упирается в ПРОДУКТОВОЕ решение владельца и ждёт его словом, а не +кодом. + +## D39.163 — ГРАНИЦА КОНТРАКТА получает ВТОРОЕ исключение: `Progress.stage`, и оно ограничено двумя условиями (28.08, оркестратор №19). ✅ + +**Что было.** Шапка канона (`14-api-contract/openapi.yaml`, §Boundaries) запрещает проводу всё о том, +КАК переводится книга: имена моделей, фаз и стадий, внутренние словари, суммы денег. Исключение было +ОДНО — банк памяти: стоп, который снимает пользователь, спрятать нельзя. + +**Почему понадобилось второе.** Владелец заказал (строка 200) полосу с подписью «что делается +сейчас». Бар, который движется молча, отвечает хуже бара с подписью, а любая подпись «что происходит» +по построению говорит нечто о том, КАК идёт работа. Спрятать её значило бы отказать в заказанном; +пропустить молча — завести дыру в границе без имени. + +**Решение: исключение вписано ЯВНО и ОГРАНИЧЕНО двумя условиями,** оба выполняются кодом уже сейчас: +1. **Значение ВЫВОДИТСЯ платформой** из тех же счётчиков, что и бар (`runStage` — SQL над теми же + колонками), и НИКОГДА не пробрасывается идентификатором из движка. Отсюда же следствие: подпись и + бар не могут разойтись между собой. +2. **Словарь ОТКРЫТ**, и клиент ОБЯЗАН рисовать незнакомое значение нейтрально. Новые значения + ломающими не считаются и версию не поднимают. + +**Ради чего условия и поставлены** — ради канона мультиязычности: пара или конвейер с ИНОЙ формой +работы не требуют нового клиента. Захардкоженный закрытый словарь `drafting`/`editing` привязал бы +клиента к сегодняшней двухволновой форме, то есть ответ на ревью-вопрос «заработает ли пара, которой +в репо ещё НЕТ» стал бы «нет» — в поле, не в Go. + +⚠ **Честная оговорка, добавленная по следствию `PD-410`:** подпись описывает МОДЕЛЬ ПЛАТФОРМЫ о +работе прогона, а не отчёт движка — движок об этом не спрашивают и он не отвечает. Где двое +расходятся, подпись — приближение, а факт — готовый текст. Без этой оговорки поле читалось бы как +свидетельство движка, каким оно не является. + +**Что этой нотой НЕ разрешено:** третьего исключения нет, и «раз уж есть два» аргументом не будет. +Каждое следующее — отдельная нота с собственным ограничением, иначе граница перестанет быть границей. diff --git a/docs/architecture/14-api-contract/README.md b/docs/architecture/14-api-contract/README.md index 6cc1cbe5..29bd4501 100644 --- a/docs/architecture/14-api-contract/README.md +++ b/docs/architecture/14-api-contract/README.md @@ -51,30 +51,34 @@ cmp-сверка обязательна (D39.138 п.3). > сверку со стандартами, разобранные альтернативы). При расхождении по ФОРМЕ побеждает YAML; > при вопросе «почему так» — этот файл. > -> **Статус: РАТИФИЦИРОВАН по 0.4.0 включительно.** 0.2.0 (D39.115, 08.08) · 0.2.1 (D39.123, 09.08) · +> **Статус: РАТИФИЦИРОВАН по 0.6.0 включительно.** 0.2.0 (D39.115, 08.08) · 0.2.1 (D39.123, 09.08) · > 0.2.2 (D39.129, 10.08) · 0.2.3 (D39.135, 15.08) · **0.3.0 (D39.138, 16.08) — ломающий минор по > целостному ревью research/28** · **0.4.0 (D39.152, 20.08) — синк с платформой, §6в** · -> **0.5.0 (сдано контрактной сессией 27.08, ратификация — акт оркестратора при лендинге) — снос -> отменённой пер-термной модели подписи (PD-370) + дверь правок банка, выведенная из построенного -> глагола движка (D39.158), + предупреждение о конверте вне `/v0`; вывод и провенанс — §2.19, -> отчёт сессии — `docs/CONTRACT_MINOR_REPORT.md`**. Дом канона — этот каталог; +> **0.5.0 (D39.161, 27.08) — снос отменённой пер-термной модели подписи (PD-370) + дверь правок +> банка, выведенная из построенного глагола движка (D39.158), + предупреждение о конверте вне +> `/v0`; вывод и провенанс — §2.19, отчёт сессии — `docs/CONTRACT_MINOR_REPORT.md`** · +> **0.6.0 (D39.162, 28.08) — сквозная полоса прогресса и подпись стадии, приехавшие ОДНИМ лендингом +> с платформенным паком P9; вывод и провенанс — §2.20**. Дом канона — этот каталог; > `frontend/docs/api-contract/openapi.yaml` — байт-зеркало. > > ⚠ **0.5.0 ломающий по построению (мажор `0`): снесены путь `POST …/bank/decisions` и три его > схемы, из `BankPage` и `EventBank` сняты `pending_decisions`/`complete` (§2.19-бис), в -> `Capabilities.required` добавлен `bank_corrections_enabled`.** Дверь -> `POST …/bank/corrections` объявлена И НЕ ОБСЛУЖИВАЕТСЯ до платформенного пака (2в очереди -> D39.156): деплой честно говорит это флагом `bank_corrections_enabled: false` и отвечает `404`. +> `Capabilities.required` добавлен `bank_corrections_enabled`.** > -> ⚠ **Совпадение канона и деплоя при лендинге — точное по ПУТЯМ, не по полям** (находка -> опровергателя этой сессии, сверена с кодом): свою половину ПУТИ платформа снесла 22.08, а -> проекция `GET /bank` ещё кладёт на провод снятые 0.5.0 счётчики (`wireBankPage`, -> `platform/internal/httpapi/reading.go` — `PendingDecisions`/`Complete`; комментарий «the wire -> fields are the canon's» с этого минора ложен). Клиент 0.5.0 лишние поля игнорирует по общему -> правилу, но аллоулист-норма нарушена, пока пак (2в) не снимет их одной строкой проекции — -> носитель: пункт (2в) очереди D39.156, §2.19-бис. Гейт версии -> (`TestTheAnnouncedContractVersionIsTheOneTheCanonRatified`) этого класса не ловит — он сверяет -> только номер. +> ⚠ **Оговорка «дверь объявлена И НЕ ОБСЛУЖИВАЕТСЯ» СНЯТА лендингом P9 (D39.162).** Платформа +> смонтировала `POST …/bank/corrections`; флаг `bank_corrections_enabled` теперь следует +> включённости прогонов (`platform/cmd/tmplatformd/runner.go:195` — `cfg.RunsEnabled()`), потому что +> дверь спавнит тот же глагол движка и без прогонов ей нечем работать. Деплой без прогонов +> по-прежнему честно отвечает `404`, и это тот же флаг, а не новый. +> +> ⚠ **Расхождение «точное по ПУТЯМ, не по полям» — ЗАКРЫТО лендингом P9 (D39.162).** Было: путь +> платформа снесла 22.08, а проекция `GET /bank` ещё клала на провод снятые 0.5.0 счётчики +> (`wireBankPage` в `platform/internal/httpapi/reading.go` — `PendingDecisions`/`Complete`, носитель +> `PD-399`). Полей в проекции больше нет, аллоулист-норма восстановлена. +> +> ⚠ **Урок оставлен НАМЕРЕННО, он переживает свой дефект: гейт версии этого класса не ловит.** +> `TestTheAnnouncedContractVersionIsTheOneTheCanonRatified` сверяет НОМЕР, а не поля — совпадение +> версий не значит совпадения форм, и следующее расхождение поля найдёт снова не он. > > ⚠ **0.4.0 ломающий ровно по одному месту, и это ЗАМЕРЕНО, а не объявлено.** Дифф генерённых > типов 0.3.0 → 0.4.0 (`openapi-typescript@7`, комментарии отброшены) — **одна строка:** @@ -251,6 +255,14 @@ the book, while chapter ids survive» (`backend/internal/pipeline/manifest.go:10 читало бы ноль всю первую волну, а это и была исходная жалоба К-10, и снятие фаз само по себе её не лечит. +⚠ **(0.6.0: клауза «той же логикой, что и полоса» ПЕРЕКРЫТА — прежде чем читать дальше.)** Полоса +ПРОГОНА сегментной быть перестала: она одна монотонная доля на всю работу прогона и через стоп +подписи не обнуляется (§2.20). Глава — по-прежнему состояние ОДНОГО прохода и по-прежнему возвращается +к нулю, когда начинается следующий. То есть две величины теперь законно РАСХОДЯТСЯ, и это записано +предупреждением в самом каноне (`ChapterProgress.units_done`): клиент, зеркалящий одну в другую, +нарисует прыгающий бар. Абзац сохранён как провенанс батча 0.3.0 — вердикт К-10 «пофазные счётчики на +главу НЕ строить» им не затронут и в силе. + **Книжная полоса — отдельная величина.** `Book.chapters_done` против `chapter_count` — прогресс КНИГИ (строка библиотеки), он не откатывается при старте нового прогона. Величина уже считается в SQL и до 0.3.0 не отдавалась: `b.chapter_count - (select count(*) from chapters c where … units_done @@ -741,13 +753,18 @@ the original request was never applied». Каждый `POST /books` созда это рестарт деплоя, `5xx`, ретрай сходится; потолок 1 МиБ → `413`, потолок 5000 и вся валидация формы → `400` `invalid_request`. 14 и 15 получили СВОИ корневые коды: разные адресаты ремеди (пере-решить человек / повторить машина), совет промта принят. - ⚠ **Три обязательства монтажа (2в), которые раскладка создаёт — названы, чтобы не потерялись - (находки опровергателя этой сессии, сверены с кодом):** (а) оба потолка движок классифицирует + ⚠ **Три обязательства монтажа — ИСПОЛНЕНЫ пакетом P9 (D39.162); список сохранён, потому что + первое из трёх монтаж сперва исполнил НЕВЕРНО и это стоило отдельной находки (см. ниже):** (а) оба потолка движок классифицирует КЛАССОМ 14 («the document is well-formed and the deployment is fine», `bankdecisions.go:656-673`), а канон раскладывает их в `400`/`413` — платформа обязана мерить оба на ПРОВОДНОЙ форме до спавна; остаточный случай (HTTP-тело < 1 МиБ, отрендеренный документ шва — больше) падает в `409` `bank_corrections_refused`, чьё описание несёт «a set larger than the service applies in - one act»; (б) канонный `400` с `errors[/book_id]` на несовпадение тела с путём производит + one act». ⚠ **Здесь монтаж и ошибся:** он мерил ПРОВОДНОЕ тело, а движку отдавал документ, + отрендеренный `json.Marshal` с HTML-экранированием (`&`/`<`/`>` — один байт в шесть), так что + законное тело под проводным потолком перепрыгивало движковый и возвращалось `409` «пере-решите» + вместо канонного `413`. Найдено воркфлоу-ревью P9 замером (не рассуждением: два агента ошибочно + положили ось в «не опровергнуто», рассуждая от `omitempty`), вылечено выключением экранирования — + документ читает движок, не браузер; (б) канонный `400` с `errors[/book_id]` на несовпадение тела с путём производит ПЛАТФОРМА до спавна — движковая сверка того же факта (`bankdecisions.go:267-269`) есть класс 10, «конфиг вызывающего», и в `400` сама не раскладывается; (в) свои вызовы двери по одной книге монтаж СЕРИАЛИЗУЕТ сам: флок движка держит любой глагол, включая второй `bank-apply` и превью @@ -812,17 +829,53 @@ the original request was never applied». Каждый `POST /books` созда описании `listBankTerms`; фраза «marked unverified inside the service» из того же абзаца снята — это обещание без носителя, снятое ещё ФБ-8 (§6б) и уцелевшее в одном месте. -Цена, названная честно — ТРЕМЯ половинами: (1) чтение банка больше не отвечает «сколько -осталось» — экран узнаёт счёт из квитанции двери правок (`signature`), то есть только имея что -послать или что превьюировать; (2) **до монтажа пака (2в) счёт недоступен НИГДЕ** — дверь не -обслуживается, чтение не отвечает: это названная цена окна между минорами, а не пробел; (3) до -того же монтажа проекция `GET /bank` деплоя ещё шлёт снятые поля (см. ⚠ шапки — лишние поля, -клиент их игнорирует; снимает монтаж). Возврат счёта в чтение — день, когда экран подписи закажут +Цена, названная честно — ТРЕМЯ половинами, и две из трёх ПОГАШЕНЫ лендингом P9 (D39.162): +(1) чтение банка больше не отвечает «сколько осталось» — экран узнаёт счёт из квитанции двери правок +(`signature`), то есть только имея что послать или что превьюировать: **эта половина В СИЛЕ**; +(2) ~~до монтажа пака (2в) счёт недоступен НИГДЕ~~ — окно между минорами закрыто, дверь смонтирована +и отвечает; (3) ~~проекция `GET /bank` деплоя ещё шлёт снятые поля~~ — сняты, носитель `PD-399` +закрыт. Возврат счёта в чтение — день, когда экран подписи закажут и движок опубликует нерешённость проекцией (`18-bank-ontology.md`, «Чего эта форма НЕ несёт»); сегодняшние носители лгать не будут. --- +### 2.20. Сквозная полоса прогресса и подпись стадии (0.6.0) — ✓ выведено из построенного сервера + +**Что изменилось.** `Progress` перестал быть счётчиком СЕГМЕНТА между двумя стопами и стал ОДНОЙ +монотонной долей на всю работу прогона через все его проходы. Оговорка «при снятии стопа счётчик +начинается заново с нуля» снесена; добавлен обязательный член `stage`. + +**Почему минор приехал ПОСЛЕ сервера, а не до него.** Требование владельца (строка 200) — одна доля +на всю работу прогона, считает СЕРВЕР (иначе клиент снова начнёт знать про фазы). D39.160 изъял его +из минора 0.5.0 ровно потому, что гейт версии требует совпадения канона и деплоя В МОМЕНТ лендинга: +объявить сквозную полосу раньше, чем сервер её считает, значило бы завести класс `PD-370` в поле +вместо пути. Канон догнал сервер тем же коммитом, которым сервер приехал. + +**Провенанс формы — код, а не рассуждение** (`platform/internal/pgstore/readmodel.go`): числитель +складывает вклад каждого прохода (`runDone`), знаменатель — работу, которую прогон РЕАЛЬНО должен +(`runTotal`), и каждая половина мерится от СВОЕЙ базовой линии прогона (`chapters_before` для +последнего прохода, `draft_before` для чернового), после чего каппится покупкой. Отсюда обе новые +фразы канона — «счётчики только растут и ничто под ними не движется» и «прогон, кончившийся рано, до +`total` не доходит»: первая описывает базовые линии, вторая — то, что доля до единицы обязана +доходить лишь у ЗАВЕРШЁННОЙ покупки. + +⚠ **`stage` — ВТОРОЕ исключение из границы «ничего о том, КАК переводится книга».** Первое — банк: +стоп, который снимает пользователь, спрятать нельзя. Второе завёл этот минор, потому что бар, +который движется молча, отвечает хуже, чем бар с подписью. Исключение ОГРАНИЧЕНО двумя условиями, и +оба выполняются кодом уже сейчас: значение ВЫВОДИТСЯ платформой из тех же счётчиков, что и бар +(`runStage` — SQL над теми же колонками, а не проброс движковой строки), и словарь ОТКРЫТ — клиент +обязан рисовать незнакомое значение нейтрально. Следствие, ради которого условия и поставлены: +**пара или конвейер с ИНОЙ формой работы не требуют нового клиента и не поднимают версию.** +Сегодняшние значения — `drafting` и `editing`. + +⚠ **Ложная перекрёстная ссылка, снятая этим же минором.** `ChapterProgress.units_done` обещал «то же +счетоводство, что у `Progress`, уровнем ниже». После сквозной полосы это неправда: глава по-прежнему +обнуляется каждым новым проходом, прогон — больше никогда. Оставленная, фраза научила бы клиента +зеркалить одно в другое и рисовать прыгающий бар. + +--- + ## 3. Зависимости: чтение → источник → строка бэклога **Правило, введённое 0.3.0 (Б-21): предупреждение о недостроенном ОБЯЗАНО нести номер строки @@ -843,9 +896,9 @@ the original request was never applied». Каждый `POST /books` созда | `PATCH`/`DELETE /books/{id}`, `GET /runs/{id}` | колонки есть | НЕ ПОСТРОЕНО (заведено 0.3.0) | вход P7 | | `GET /books/{id}/chapters`, `/units` | материализация манифеста | **ПОСТРОЕНО P7** — `httpapi/reading.go`, материализатор `internal/readmodel` | закрыто D39.153 | | `GET /books/{id}/notes` | `unit_done` несёт флаг и причину (`runevents.go:126-135`), платформа хранит (`sink.go:227-233`), колонка `notes.reason` заведена под это | канал ЕСТЬ; не хватает карты «причина → код → фраза» (приложение А) и проекции | приложение А + вход P7 | -| `GET /books/{id}/bank` | движок пишет сайдкар всего банка (`pipeline/bankexport.go:16-33,72`, D39.122) | ⚠ пере-снято 0.5.0: проекция платформы ПОСТРОЕНА (P7 — маршрут в `contractSurface`, `wireBankPage` в `httpapi/reading.go`, `SaveBank` в `pgstore`; прежняя запись «не хватает проекции» устарела при израсходованном носителе «вход P7», D39.153); живой дефект другой — проекция несёт форму 0.4.0 со снесёнными счётчиками, см. ⚠ шапки | пункт (2в) очереди D39.156 — снять счётчики при монтаже | +| `GET /books/{id}/bank` | движок пишет сайдкар всего банка (`pipeline/bankexport.go:16-33,72`, D39.122) | ⚠ пере-снято 0.5.0: проекция платформы ПОСТРОЕНА (P7 — маршрут в `contractSurface`, `wireBankPage` в `httpapi/reading.go`, `SaveBank` в `pgstore`; прежняя запись «не хватает проекции» устарела при израсходованном носителе «вход P7», D39.153); форма синхронна канону с лендингом P9: счётчики `pending_decisions`/`complete` сняты с проекции (`PD-399`) | закрыто D39.162 | | ~~`POST /bank/decisions`~~ | стоп-механика майнера | ⚠ **НЕ «не построено», а ОТМЕНЕНО**: было построено P7 и СНЯТО 22.08 вместе с пер-термной моделью подписи (D39.144, слово владельца). **0.5.0 снёс и канон-половину — `PD-370` закрыт этим минором**; преемник — строка `POST …/bank/corrections` ниже | отменено D39.144; снесено 0.5.0 | -| `POST /books/{bookId}/bank/corrections` | `tmctl bank-apply` — движковая половина ПОСТРОЕНА (D39.158, лендинг `d1eb8a9`) | **объявлено 0.5.0, платформой НЕ обслуживается**: деплой говорит это `Capabilities.bank_corrections_enabled: false` и отвечает `404`; монтаж (перевод словаря, спавн глагола, раскладка отказов) — платформенный пак | **пункт (2в) очереди D39.156** | +| `POST /books/{bookId}/bank/corrections` | `tmctl bank-apply` — движковая половина ПОСТРОЕНА (D39.158, лендинг `d1eb8a9`) | **ПОСТРОЕНО пакетом P9**: перевод словаря (`platform/internal/ingest/bankdecisions.go`), спавн глагола (`runner/bankapply.go`), раскладка отказов и пер-книжная сериализация (`runs/bank.go`), дверь синхронная. Флаг `bank_corrections_enabled` следует включённости прогонов (`cmd/tmplatformd/runner.go:195`) — дверь спавнит тот же глагол | закрыто D39.162 | | `GET /books/{id}/events` (SSE) | эмиттер шва построен (D39.131) | **ПОСТРОЕНО P7** — `httpapi/stream.go`, поток регистрируется ВНЕ слоя сжатия (сжатие буферизует поток — единственное, что канон запрещает этому маршруту) | закрыто D39.153 | | `POST`/`GET /exports` | у движка только stdout-JSON и `--plaintext` (`cmd/tmctl/invocation.go:107`) | НЕ ПОСТРОЕНО с обеих сторон | строка 49 / D29.1 «tmctl export-контракт» | | Условные чтения (`ETag`/304), сжатие | — | **ПОСТРОЕНО P7** — `httpapi/conditional.go`; валидатор считается от БАЙТ ответа. Остаток строки 186 — шаги 3–5 (скоуп кадра, дельта-чтение, `staleTime`) | шаги 1–2 закрыты D39.153 | @@ -856,7 +909,7 @@ the original request was never applied». Каждый `POST /books` созда | Настоящие названия глав (`Chapter.heading` ≠ null) | парсер структуры | НЕ ПОСТРОЕНО | строка 160 (Этап 0) | | `title_raw` / `kind` (глава ↔ фрагмент) | дизайн-пак структуры глав | передано паку, аддитивно | строка 161 | | `ErrorCode.content_refused` (400) **и** `RejectReason.content_refused` | прескрин злоупотреблений | НЕ ПОСТРОЕН ни на одной стороне: в платформе только объявление константы (`httpapi/problem.go:42,94`), `ContractRejectReason` (`ingest/vocabulary.go:59-69`) его не отображает; в движке отказ провайдера живёт как ПРИЧИНА ЗАМЕЧАНИЯ (`disposition.go:60-63` → `Note.code: content_withheld`) и в exit-контракт не выходит — мостá между двумя словарями нет | **строка 94 (ПТ-16)**; там же ограничение числа попыток аккаунта — обязанность падает ВМЕСТЕ с производителем, не раньше | -| `decline` в подписи банка доезжает до работы | ⚠ пере-снято 0.5.0: старый носитель («таблица `bank_decisions`, движок её не читает») умер вместе с моделью — таблица снесена 22.08, предупреждение на `BankDecision.action` снесено вместе со схемой | новая дверь пишет ФАЙЛЫ ДВИЖКА (`tmctl bank-apply`), decline доезжает до следующего прогона ПО ПОСТРОЕНИЮ — как только платформа смонтирует дверь; до монтажа двери нет вовсе (флаг `bank_corrections_enabled`) | **пункт (2в) очереди D39.156** | +| `decline` в подписи банка доезжает до работы | ⚠ пере-снято 0.5.0: старый носитель («таблица `bank_decisions`, движок её не читает») умер вместе с моделью — таблица снесена 22.08, предупреждение на `BankDecision.action` снесено вместе со схемой | дверь смонтирована (D39.162), и decline доезжает до следующего прогона ПО ПОСТРОЕНИЮ. ⚠ **Остаток ДВИЖКОВЫЙ, две штуки, обе найдены воркфлоу-ревью P9:** decline не энтити-широк на обратном пути (отклонённая сущность возвращается через свой АЛИАС, `mining.go:548`), и повтор ТОГО ЖЕ документа на одном пути не сходится (`membank/decisions.go:374`) — второе ломает обещание сходимости, на котором стоит синхронная дверь | заказано бэкенд-паку «тихая порча», `docs/BACKEND_SILENT_HARM_SESSION_PROMPT.md` §3.3 | | Снятие замечания (переход «флаг снят») | движок | **НЕДОСТИЖИМО сегодня, проверено чтением движка** — п. H §6в | **PD-298** регистра платформы (`platform/docs/DEFECT_REGISTER.md`) — там строка и живёт; механизма не строим | | Счёт «сделанного» на деплое без второго прохода | платформа | канон 0.4.0 определил «сделано» = последний проход ЭТОГО деплоя; проекция платформы считает жёстко второй проход | **вход P7** (PD-202) | diff --git a/docs/architecture/14-api-contract/openapi.yaml b/docs/architecture/14-api-contract/openapi.yaml index 3f653a45..14441941 100644 --- a/docs/architecture/14-api-contract/openapi.yaml +++ b/docs/architecture/14-api-contract/openapi.yaml @@ -2,7 +2,7 @@ openapi: 3.1.0 info: title: TextMachine API - version: 0.5.0 + version: 0.6.0 summary: Ratified contract between the frontend and the TextMachine platform. description: | **RATIFIED contract.** Canonical copy: `docs/architecture/14-api-contract/`; @@ -15,10 +15,17 @@ info: The client reads the platform read-model only; no path below addresses the translation service. **Nothing about HOW a book is translated crosses this boundary** — no model names, no phase or - stage names, no internal vocabularies, no money sums. The one exception is the book's memory - bank: the work stops there for a signature, and a stop the user must clear cannot be hidden. The - projection is an allowlist: a field not named here never reaches the client. The rule binds the - PROSE too — every description here is compiled into the generated client's source. + stage names, no internal vocabularies, no money sums. The projection is an allowlist: a field not + named here never reaches the client. The rule binds the PROSE too — every description here is + compiled into the generated client's source. + + **Two exceptions, both bounded, both because hiding the fact would make the wire lie.** The book's + memory bank: the work stops there for a signature, and a stop the user must clear cannot be + hidden. And `Progress.stage`: a bar that moves without saying what is moving answers worse than a + caption does. The second carries its own bound — the value is DERIVED by the platform from the + counters it already sends, never an identifier forwarded from the translation service, and its + vocabulary is OPEN, so a client that meets a value it does not know renders it neutrally and a + deployment whose work has a different shape needs no new client. ## On every response @@ -1267,7 +1274,7 @@ components: The version this deployment serves — the only place a non-streaming client learns it. A client generated against a different one REFUSES to work and says so: while the major is `0` a differing minor carries breaking changes by design. - examples: ['0.5.0'] + examples: ['0.6.0'] language_pairs: type: array description: | @@ -1334,35 +1341,57 @@ components: Progress: type: object description: | - How far the current SEGMENT of work has got, in chapters. A segment is the work between two - stops: to the point where the run stops for the bank to be signed, and from there to the end - of what the run bought. **When a stop is cleared the counter starts again from zero** — the - two segments cover the same chapters and are never added together or compared. + How far THIS RUN has got through the whole of its own work, in chapters, as ONE monotonic + fraction. A run may make more than one pass over the chapters it bought, and it may stop + part-way for the book's terms to be signed; the counter spans all of that. **It never + restarts from zero** — not when a signing stop is cleared, not when one pass gives way to the + next. The counters only grow and nothing under them moves. - **`total` is what this run BOUGHT**, not the length of the book, so the fraction always - reaches one. The book-wide figure is `Book.chapters_done` against `Book.chapter_count` and - answers a different question. + **`total` is what this run BOUGHT**, counted across the passes this run actually owes — not + the length of the book. The book-wide figure is `Book.chapters_done` against + `Book.chapter_count` and answers a different question. + + ⚠ **A run that ends early stops short of `total`** — at its ceiling, stopped by the user, or + failed. Reaching one is the shape of a COMPLETED purchase, not a promise about every run, and + a fraction below one is not an error to render. ⚠ **"Finished" means the work THIS deployment does on a chapter is finished** — the last pass the book actually gets, whatever that is. No ready-made percentage is shipped: how a fraction is drawn is a product decision. - required: [done, total, eta_seconds] + required: [done, total, stage, eta_seconds] properties: done: type: integer minimum: 0 - description: Chapters finished in this segment. + description: Chapters of this run's own work that are finished. total: type: integer minimum: 0 - description: Chapters this segment covers — what the run bought. + description: Chapters of work this run bought, counted across the passes it owes. + stage: + type: string + description: | + What the run is doing NOW — the caption beside the bar, phrased by the client. + + **The vocabulary is OPEN, and a client MUST render a value it does not know neutrally** + — the bar alone, or a generic phrase — rather than failing, hiding the run, or guessing. + Today's deployments answer `drafting` or `editing`; other values are not a breaking + change and do not raise this version. + + ⚠ Derived by the platform from the same counters as `done` and `total`, so the caption + and the bar cannot disagree with each other. It is never an identifier forwarded from + inside the translation service — see the bound on this exception under **Boundaries**. + + ⚠ **It therefore describes the platform's model of the run's work, not a report from the + service**, which is neither asked nor answers. Where the two diverge, the caption is the + approximation and the finished text is the fact. eta_seconds: type: [integer, 'null'] minimum: 0 description: | - Estimated seconds to the end of the segment, or `null` when there is nothing to estimate - from. The screen renders without it rather than showing a zero. + Estimated seconds to the end of this run's work, or `null` when there is nothing to + estimate from. The screen renders without it rather than showing a zero. BookStatus: type: string @@ -1716,14 +1745,17 @@ components: type: integer minimum: 0 description: | - Pairs of this chapter finished IN THE CURRENT PASS over the book — the same accounting - as `Progress`, one level down. `0` for a chapter the pass has not reached, `units_total` + Pairs of this chapter finished IN THE CURRENT PASS over the book. `0` for a chapter the + pass has not reached, `units_total` for one it has finished; it restarts from zero for the chapters a NEW pass re-walks, and a chapter outside the current pass keeps what the last pass left. Counted end to end instead, the tree would read zero through the whole first pass. ⚠ The state of a PASS, not the lifetime of the chapter, so it can legally return to zero. - The lifetime figure is `Book.chapters_done`. + The lifetime figure is `Book.chapters_done`. **And it is NOT the accounting `Progress` + uses**: the run's bar spans every pass and never goes back, this figure returns to zero + when the next pass begins. A client that mirrors one into the other draws a bar that + jumps. note_count: type: integer minimum: 0 @@ -2494,7 +2526,7 @@ components: |---|---|---| | `hello` | `EventHello` | always the first frame | | `status` | `EventStatus` | product status changed | - | `progress` | `EventProgress` | the segment counter moved | + | `progress` | `EventProgress` | the run's counter moved | | `chapter` | `EventChapter` | a chapter's own progress changed | | `note` | `EventNote` | a note appeared | | `bank` | `EventBank` | the bank changed, or a signing stop happened | @@ -2572,8 +2604,8 @@ components: properties: contract: type: string - description: Contract version this deployment serves, e.g. `0.5.0`. - examples: ['0.5.0'] + description: Contract version this deployment serves, e.g. `0.6.0`. + examples: ['0.6.0'] EventStatus: allOf: @@ -2603,7 +2635,7 @@ components: allOf: - $ref: '#/components/schemas/EventBase' - type: object - description: The segment counter moved. Applied as it is; no read follows. + description: The run's counter moved. Applied as it is; no read follows. required: [progress] properties: progress: { $ref: '#/components/schemas/Progress' } diff --git a/docs/architecture/17-seam-inbound-law.md b/docs/architecture/17-seam-inbound-law.md index 1ba96077..d856ed79 100644 --- a/docs/architecture/17-seam-inbound-law.md +++ b/docs/architecture/17-seam-inbound-law.md @@ -56,7 +56,7 @@ *Почему жёстко:* движок пере-читает сид ПОСРЕДИ прогона (`mining.go:221`), запись в живой прогон въехала бы в снапшот недетерминированно. Потолок посреди прогона менять НЕЛЬЗЯ — он едет со стартом и после не меняется, лекарство — новый прогон - (`platform/internal/runs/reconcile.go:1228`=`answering 202 with the run in the state`). Дверь «менять потолок файлом» закрыта; + (`platform/internal/runs/reconcile.go:1251`=`answering 202 with the run in the state`). Дверь «менять потолок файлом» закрыта; не выдумывать её заново из `research/25`. 3. **Каждый JSON-выход глагола несёт версию документа.** Сегодня асимметрия: `tm-bank-v1` @@ -117,7 +117,7 @@ границы процессов; единственный мид-ран кандидат закрыт каноном (п.2); инбокс-файл возвращает гонку, ради исключения которой существует flock. ⚠ **Эррата, которую ратифицирующая нота обязана поставить на `research/25`:** там «поднятие потолка файлом через -atomic rename» отнесено к носителям D39.106 — в теле D39.106 этого механизма НЕТ вовсе (проверено по ЖИВОМУ телу `architecture/05-decisions-log.md:239`=`ШОВ: прогон переживает деплой платформы`; ⚠ испр. 23.08 — прежняя редакция ссылалась на слайс +atomic rename» отнесено к носителям D39.106 — в теле D39.106 этого механизма НЕТ вовсе (проверено по ЖИВОМУ телу ноты **D39.106** («ШОВ: прогон переживает деплой платформы») в `architecture/05-decisions-log.md` — адресуем НОМЕРОМ, а не строкой: прежний line-якорь уже съехал внутрь чужого тела (гейт якорей, 28.08); ⚠ испр. 23.08 — прежняя редакция ссылалась на слайс `archive/architecture/05-decisions-D39-106-123.md`, где тела D39.106 НЕТ, и греп по нему подтвердил бы эррату вакуумно; первый хит темы — только в D39.110). Строка была КАНДИДАТОМ и никогда не ратифицировалась, а живой канон решил ось иначе (п.2). diff --git a/docs/PLATFORM_P9_SESSION_PROMPT.md b/docs/archive/prompts/PLATFORM_P9_SESSION_PROMPT_2026-08-27.md similarity index 100% rename from docs/PLATFORM_P9_SESSION_PROMPT.md rename to docs/archive/prompts/PLATFORM_P9_SESSION_PROMPT_2026-08-27.md diff --git a/docs/research/25-seam-cold-review.md b/docs/research/25-seam-cold-review.md index aed60fcb..8fc35c54 100644 --- a/docs/research/25-seam-cold-review.md +++ b/docs/research/25-seam-cold-review.md @@ -4,7 +4,7 @@ > Отчёт относит этот механизм к носителям решения D39.106 — в теле D39.106 его НЕТ вовсе (проверено грепом по слайсу > живого тела D39.106 — `../architecture/05-decisions-log.md:241`=`ШОВ: прогон переживает деплой платформы`; первый хит темы — только в D39.110. ⚠ Испр. 23.08: прежняя редакция ссылалась на слайс `archive/architecture/05-decisions-D39-106-123.md`, где тела D39.106 НЕТ — греп по нему подтвердил бы эррату вакуумно). Строка была КАНДИДАТОМ панели, > а живой канон решил ось иначе: **потолок едет со стартом прогона и после не меняется**, лекарство при упоре — НОВЫЙ прогон -> (`platform/internal/runs/reconcile.go:1228`=`answering 202 with the run in the state`). ⚠ Испр. 23.08: прежняя редакция звала «строку 69», но вставка самого баннера сдвинула нумерацию — 69-я строка теперь несёт ЖИВОЙ денежный механизм («холд не освобождается по выходу юнита»), и исполнение прежней инструкции пометило бы отвергнутым НЕ ТО. Номера строк здесь больше не называем, только подстроку. Читать НАЗВАННЫЙ ВЫШЕ пункт как отвергнутого +> (`platform/internal/runs/reconcile.go:1251`=`answering 202 with the run in the state`). ⚠ Испр. 23.08: прежняя редакция звала «строку 69», но вставка самого баннера сдвинула нумерацию — 69-я строка теперь несёт ЖИВОЙ денежный механизм («холд не освобождается по выходу юнита»), и исполнение прежней инструкции пометило бы отвергнутым НЕ ТО. Номера строк здесь больше не называем, только подстроку. Читать НАЗВАННЫЙ ВЫШЕ пункт как отвергнутого > кандидата, а не как обязательный механизм; закон входной двери шва (`../architecture/17-seam-inbound-law.md` п.2) это фиксирует: > посреди прогона внутрь идёт ТОЛЬКО сигнал. diff --git a/platform/cmd/tmplatformd/runner.go b/platform/cmd/tmplatformd/runner.go index 6dff54bf..be109a23 100644 --- a/platform/cmd/tmplatformd/runner.go +++ b/platform/cmd/tmplatformd/runner.go @@ -33,6 +33,7 @@ func runsConfig(cfg config.Config, marker []string, ceiling []string) runs.Confi EngineBinary: cfg.Runner.EngineBinary, MarkerArgv: marker, Ceiling: ceiling, + KeysFile: cfg.Runner.KeysFile, MemoryMax: cfg.Runner.MemoryMax, TasksMax: cfg.Runner.TasksMax, AllowEngineVersionChange: cfg.Runner.AllowEngineVersionChange, @@ -71,6 +72,12 @@ func startRunner(ctx context.Context, cfg config.Config, db *pgstore.Store, log // account's money under the BOOK's ceiling instead of the one the user chose (row 145). log.Warn("TM_PLATFORM_ENGINE_CEILING_ARG is empty: starting a run will be refused rather than run under the book's own ceiling (row 145)") } + if cfg.Runner.KeysFile == "" { + // Runs still start: a stand may keep a `.env` beside each book. What cannot be allowed is the + // silent version — a SaaS deployment whose engine gets no keys from anywhere fails at the first + // provider call, hours of queueing later (row 211). + log.Warn("no TM_PLATFORM_ENGINE_KEYS_PATH: the engine is passed no provider keys; a run finds them only in a .env beside its own book.yaml") + } marker, err := markerArgv(cfg.Runner.MarkerBinary) if err != nil { // Reads keep working. Losing the exit-marker command means a finished run could not be @@ -89,10 +96,14 @@ func startRunner(ctx context.Context, cfg config.Config, db *pgstore.Store, log Store: db, Runner: rn, Engine: rn, + Bank: rn, Pricing: model, Cfg: runsConfig(cfg, marker, ceiling), Log: log, } + // Before the door serves: a decision document still on disk here outlived its process, and + // nothing else ever deletes by that mask (PD-409's cure). + svc.SweepCorrectionScratch() intake, err := startIntake(cfg, db, rn, reader, log, deps) if err != nil { return nil, err @@ -110,6 +121,9 @@ func startRunner(ctx context.Context, cfg config.Config, db *pgstore.Store, log } svc.Queue = queue deps.Runs = svc + // The correction door mounts with the run machinery: it needs the same engine binary, the same + // state directory and the same per-book serialization. The capability flag says the same fact. + deps.Bank = svc if intake != nil { intake.Queue = queue } @@ -174,8 +188,12 @@ func capabilities(cfg config.Config) httpapi.Capabilities { IntakeMaxBytes: cfg.Intake.MaxUploadBytes, // None are built here yet: the export path is deferred, and answering a format this // deployment cannot produce would be a promise the first click discovers is empty. - ExportFormats: []string{}, - PageSizeDefault: pgstore.DefaultPage, + ExportFormats: []string{}, + // The same condition that mounts the door (startRunner sets deps.Bank exactly when runs are + // enabled): the flag and the 404 must be one fact, or a client learns the truth by failing + // a user's save. + BankCorrectionsEnabled: cfg.RunsEnabled(), + PageSizeDefault: pgstore.DefaultPage, } for _, p := range cfg.LanguagePairs { caps.Pairs = append(caps.Pairs, httpapi.LanguagePair{ diff --git a/platform/cmd/tmplatformd/runner_test.go b/platform/cmd/tmplatformd/runner_test.go index 6c79e881..1ef48d23 100644 --- a/platform/cmd/tmplatformd/runner_test.go +++ b/platform/cmd/tmplatformd/runner_test.go @@ -56,12 +56,18 @@ func TestTheOperatorsRunnerKnobsReachTheReconciler(t *testing.T) { cfg.Runner.AllowEngineVersionChange = true cfg.Runner.ResyncEvery = 5 * time.Minute cfg.Runner.RunBudget = 90 * time.Second + cfg.Runner.KeysFile = "/etc/tm/keys.env" got := runsConfig(cfg, []string{"/usr/bin/tmplatformctl", "exit-marker"}, []string{"--max-cost"}) switch { case got.RunBudget != cfg.Runner.RunBudget: t.Errorf("RunBudget is %v, want the operator's %v: TM_PLATFORM_RUN_BUDGET does nothing", got.RunBudget, cfg.Runner.RunBudget) + case got.KeysFile != cfg.Runner.KeysFile: + // The exact regression class this test exists for: dropped here, the engine silently starves + // for keys again (row 211) at the price of a whole paid run. + t.Errorf("KeysFile is %q, want the operator's %q: TM_PLATFORM_ENGINE_KEYS_PATH does nothing", + got.KeysFile, cfg.Runner.KeysFile) case got.ResyncEvery != cfg.Runner.ResyncEvery: t.Errorf("ResyncEvery is %v, want %v", got.ResyncEvery, cfg.Runner.ResyncEvery) case got.StateDir != cfg.Runner.StateDir || got.EngineBinary != cfg.Runner.EngineBinary: diff --git a/platform/docs/DEFECT_REGISTER.md b/platform/docs/DEFECT_REGISTER.md index 2140b811..5d17c407 100644 --- a/platform/docs/DEFECT_REGISTER.md +++ b/platform/docs/DEFECT_REGISTER.md @@ -26,22 +26,28 @@ | ID | Класс | Серьёзность | Где | Суть | Статус | Источник | |---|---|---|---|---|---|---| | PD-370 | bug | **major** | `internal/httpapi/reading.go`, `internal/httpapi/v0.go`, `internal/pgstore/readmodel.go`, канон `14-api-contract` §BankDecision | **Была построена и стоит в каноне модель, которую владелец отменил 16.08.** `D39.144` ратифицировал: подписывается ВЕСЬ банк ОДНИМ ОК, «пер-термная подпись = сотни кликов — НЕ модель продукта»; пер-термно существует не подпись, а ПРАВКА термина (до подписи) и правка с пере-генерацией задетых глав (после прочтения, гейчено 192). Гейт полноты нота сняла, а САМ ГЛАГОЛ остался — чистка дошла до resume и счётчиков и не дошла до словаря. Колонки были write-only: их не читал ни один SELECT, то есть реализации не существовало **— ЗОННАЯ ПОЛОВИНА ЗАКРЫТА 22.08 по слову владельца: снят write-путь целиком** (маршрут `POST /books/{bookId}/bank/decisions`, хендлер, wire-типы, метод интерфейса `Library`, `SubmitBankDecisions`, типы `BankDecision`/`BankReceipt`, `UnknownTermError`), на его месте — пометка для будущей сессии с ратифицированной моделью и с тем, что уже есть в схеме. Пол размера поверхности 15 → 14 сдвинут ЯВНО и с причиной в комментарии: гейт сработал, а не был подогнан. ⚠ **ОСТАЁТСЯ ОТКРЫТОЙ КОНТРАКТНАЯ ПОЛОВИНА** — канон 0.4.0 по-прежнему несёт путь, три схемы и глагол, и это НЕ зона платформы: правка канона (ломающий минор 0.4.0 → 0.5.0, ~17 мест в `openapi.yaml` + ~8 в компаньоне) за контрактной сессией. До неё деплой обслуживает на одну ручку меньше, чем объявляет | open (зонная половина закрыта) | поправка владельца 22.08; ратификация D39.144; аудит 31 агента 22.08 | -| PD-379 | vuln | **major** | `internal/httpapi/stream.go:118`=`h.pump(r.Context(), s, user, bookID, state, last, resuming)`, `internal/httpapi/server.go` `guard`, `internal/auth/session.go` `SessionStore` | **Открытый поток событий переживает и отзыв сессии, и оба её потолка: «выйти везде» не выключает уже установленный канал.** Аутентификация происходит РОВНО ОДИН РАЗ, в `auth.Authenticator.Require` внутри `guard`; дальше `streamEvents` уходит в `pump`, и цикл до конца соединения читает только `ReadFrames`/`ReadStream`, строку сессии не смотрит ни разу. Значит `POST /auth/logout`, `POST /auth/logout-all`, `tmplatformctl revoke` и оба потолка (idle и абсолютный) уже открытый `GET /v0/books/{bookId}/events` не прекращают. Пере-проверено ТРИЖДЫ независимо: финдером, рефутером в отдельной копии и координатором пака — у координатора `revoke --user` отчитался «revoked 4 sessions», новый запрос той же кукой дал 401, а тот же поток через 9 секунд ПОСЛЕ отзыва отдал настоящий кадр данных `event: status`. На демоне с `SESSION_IDLE=5s`/`SESSION_MAX_AGE=10s` поток жив на +40 с. Бьёт по объявленной норме: ASVS 5.0 7.4.1 — требование УРОВНЯ 1 при объявленном зоной L2, и `STACK_DECISIONS` §13 отказывается от лимита одновременных сессий ИМЕННО в обмен на мгновенный отзыв. Довод СУЖЕНИЯ, который был снят поднятием веса ниже, гласил: кадры несут счётчики и идентификаторы и не несут текста, а соединение само закрывается, когда книга приходит в покой. ⚠ Побочно, тем же прогоном: `/auth/logout-all` на ДЕВ-профиле не смонтирован вовсе (404) — из пары ручек, которой §13 обосновывает свою политику, на стенде доступна одна. Воспроизведение: `docs/p8-review/axis2-auth/sse-outlives-revocation.sh` и `sse-outlives-absolute-ceiling.sh`, снимок координатора `docs/p8-review/sse-outlives-revocation.txt` ⚠ **ВЕС ПОДНЯТ minor → major ПОСЛЕ РЕВЬЮ СТАРШЕЙ МОДЕЛЬЮ (fable-5), и поднят по трём доводам, которых сужение не учло.** **(1)** Граница «соединение само закрывается, когда книга приходит в покой» — не гарантия кода: у книги, чей долг материализации списан как неоплатный, поток НЕ КОНЧАЕТСЯ НИКОГДА, и это собственный комментарий зоны — `internal/pgstore/books.go:609`=`a book whose event stream can NEVER end`. То есть окно утечки не ограничено прогоном. **(2)** Вес отказавшего КОМПЕНСИРУЮЩЕГО контроля наследуется от рисков, которые он компенсирует, а не от схемы кадра: `STACK_DECISIONS` §13 отказывается и от лимита одновременных сессий, и от собственной границы федеративной сессии ИМЕННО в обмен на мгновенный отзыв и два срока — а открытый поток ускользает от всех трёх разом, и у §13 не остаётся содержания. **(3)** Провалено требование УРОВНЯ 1 при объявленном зоной L2 — это дыра ниже собственного пола, а не отклонение от лучших практик. ⚠ Следствие, которое надо решить вместе с этой строкой: `STACK_DECISIONS` §13 становится доком, который ЛЖЁТ о коде («мгновенный отзыв» читается как факт), и эрратой это не помечено. Правку §13 пак не делал — это диспозиция приёмки. Цена лечения названа и она мала: `pump` и так ходит в базу раз в секунду (`ReadStream` с проверкой владельца на каждом тике), проверка живости сессии — одна выборка на биение. ⚠ **Эта строка ОПРОВЕРГАЕТ прежнюю галочку зоны, и сказать это обязан именно пак:** `docs/archive/platform-PROGRESS-P0-P3.md:572` держит таблицу соответствия, где `ASVS 5.0 V7 · 7.2.3, 7.2.4, 7.4.1, 7.4.2 (L1)` отмечено выполненным со словами «отзыв прекращает использование». Против дерева это неверно для единственного длинноживущего канала платформы. Найдено интервальной самоверификацией пака | open | ревью-пак P8-REVIEW, ось 2 (живая проба, подтверждено рефутером и координатором) | -| PD-385 | bug | **major** | `internal/pgstore/runs.go:381`=`where r.finished_at is null and a.reconcile_failures >= $1`, `internal/pgstore/observe.go` (гейдж), `internal/pgstore/runs.go` `AbandonRun` | **Прогон, чей РАСЧЁТ доведён до `StalledAfter`, не виден операторским поверхностям порога, а лог-строка на пересечении порога шлёт оператора именно туда.** Обе фазы делят один счётчик через общий `deferItem`, но операторская половина построена только для ЖИВЫХ прогонов: `StalledRuns` джойнит `a.ended_at is null` и фильтрует `r.finished_at is null`, гейдж `tm_platform_runs_stalled` считает по тому же предикату, а `AbandonRun` читает `where id = $1 and finished_at is null` и отвечает `ErrNoRun`. Живая проба на состоянии, выращенном штатными путями (интейк, HTTP-старт, отказ спавна, `run abandon`): `runs --stalled` отвечает «no run is failing to reconcile», `runs` — «no run is live», `run abandon` — «is not a live run», гейдж 0, при этом в базе `settled_at` NULL, `reconcile_failures` 5 и открытая резервация на 90000 микро. Тот же слепой угол закрывает прогон, который ЖИВ, но чья ПРЕДЫДУЩАЯ попытка не рассчиталась после рестарта. ⚠ Рефутер опроверг заголовочный абсолют «невидим ВСЕМ поверхностям»: `tmplatformctl balance --user` печатает этот холд строкой, `tm_platform_oldest_open_hold_seconds` растёт без потолка, а `tmplatformctl books` показывает «WHY NOT: unsettled hold»; ноль в улике финдера был артефактом его фикстуры. Остаётся то, ради чего строка заведена: три поверхности ПОРОГА слепы, терминальной ручки для такого прогона нет, а ERROR на пороге называет команду, которая на нём молчит. Воспроизведение: `docs/p8-review/axis3-queue/live-stalled-settlement.sh` ⚠ **ВЕС ПОДНЯТ minor → major ЗАКРЫВАЮЩИМ РЕВЬЮ СТАРШЕЙ МОДЕЛИ, и довод не про эту строку в одиночку, а про КРУГОВОЕ сужение четырёх строк пака.** `PD-384` сужен до minor тем, что холд «виден» гейджу `tm_platform_oldest_open_hold_seconds` и команде `balance --user`. Но `PD-392` доказывает ЖИВОЙ ПРОБОЙ, что у этого гейджа ручки НЕТ: идентификатора он не даёт, `balance --user` требует аккаунт, которого гейдж не называет, глобального списка открытых холдов в CLI нет, а документированный случай самого гейджа это ровно данная популяция — при `oldest_open_hold_seconds 10813` все три команды отвечают «no run is live», «no run is failing to reconcile», «no book has been given up on». `PD-390` доказывает, что тот же гейдж умеет ЗАМИРАТЬ и отдавать нули как здоровье. `PD-389` — что его сеттеры не запинены ничем. То есть каждое из четырёх сужений держится поверхностью, несостоятельность которой доказывает соседняя строка ТОГО ЖЕ пака, и по кругу. А терминальной ручки для этой популяции нет ПО ПОСТРОЕНИЮ: `internal/pgstore/runs.go:441`=`select book_id from runs where id = $1 and finished_at is null` отвечает `ErrNoRun` законченному прогону. Следствие, названное прямо: для всей популяции «закончен, но не рассчитан» деньги пользователя заморожены бессрочно, поверхности ПОРОГА слепы, ERROR на пороге называет команду, которая откажет, и единственный выход — сырой SQL в проде. Составной инвариант, на котором принят пак P8-FIX (`D39.154`: гейдж плюс `runs --stalled` плюс `run abandon` как ответ на `PD-169`), для этой популяции ЛОЖЕН ЦЕЛИКОМ — а «решается до следующего пака» есть определение major-секции самого регистра. Носителем major сделана ЭТА строка как самая полная по улике (живая проба на состоянии из штатных путей плюс отказ ручки); `PD-384` и `PD-392` несут ссылку сюда, чтобы не плодить второй major на тот же корень | open | ревью-пак P8-REVIEW, ось 3 (живая проба, сужено рефутером) | +| PD-379 | vuln | **major** | `internal/httpapi/stream.go:127`=`h.pump(r.Context(), s, user, bookID, state, last, resuming)`, `internal/httpapi/server.go` `guard`, `internal/auth/session.go` `SessionStore` | **Открытый поток событий переживает и отзыв сессии, и оба её потолка: «выйти везде» не выключает уже установленный канал.** Аутентификация происходит РОВНО ОДИН РАЗ, в `auth.Authenticator.Require` внутри `guard`; дальше `streamEvents` уходит в `pump`, и цикл до конца соединения читает только `ReadFrames`/`ReadStream`, строку сессии не смотрит ни разу. Значит `POST /auth/logout`, `POST /auth/logout-all`, `tmplatformctl revoke` и оба потолка (idle и абсолютный) уже открытый `GET /v0/books/{bookId}/events` не прекращают. Пере-проверено ТРИЖДЫ независимо: финдером, рефутером в отдельной копии и координатором пака — у координатора `revoke --user` отчитался «revoked 4 sessions», новый запрос той же кукой дал 401, а тот же поток через 9 секунд ПОСЛЕ отзыва отдал настоящий кадр данных `event: status`. На демоне с `SESSION_IDLE=5s`/`SESSION_MAX_AGE=10s` поток жив на +40 с. Бьёт по объявленной норме: ASVS 5.0 7.4.1 — требование УРОВНЯ 1 при объявленном зоной L2, и `STACK_DECISIONS` §13 отказывается от лимита одновременных сессий ИМЕННО в обмен на мгновенный отзыв. Довод СУЖЕНИЯ, который был снят поднятием веса ниже, гласил: кадры несут счётчики и идентификаторы и не несут текста, а соединение само закрывается, когда книга приходит в покой. ⚠ Побочно, тем же прогоном: `/auth/logout-all` на ДЕВ-профиле не смонтирован вовсе (404) — из пары ручек, которой §13 обосновывает свою политику, на стенде доступна одна. Воспроизведение: `docs/p8-review/axis2-auth/sse-outlives-revocation.sh` и `sse-outlives-absolute-ceiling.sh`, снимок координатора `docs/p8-review/sse-outlives-revocation.txt` ⚠ **ВЕС ПОДНЯТ minor → major ПОСЛЕ РЕВЬЮ СТАРШЕЙ МОДЕЛЬЮ (fable-5), и поднят по трём доводам, которых сужение не учло.** **(1)** Граница «соединение само закрывается, когда книга приходит в покой» — не гарантия кода: у книги, чей долг материализации списан как неоплатный, поток НЕ КОНЧАЕТСЯ НИКОГДА, и это собственный комментарий зоны — `internal/pgstore/books.go:609`=`a book whose event stream can NEVER end`. То есть окно утечки не ограничено прогоном. **(2)** Вес отказавшего КОМПЕНСИРУЮЩЕГО контроля наследуется от рисков, которые он компенсирует, а не от схемы кадра: `STACK_DECISIONS` §13 отказывается и от лимита одновременных сессий, и от собственной границы федеративной сессии ИМЕННО в обмен на мгновенный отзыв и два срока — а открытый поток ускользает от всех трёх разом, и у §13 не остаётся содержания. **(3)** Провалено требование УРОВНЯ 1 при объявленном зоной L2 — это дыра ниже собственного пола, а не отклонение от лучших практик. ⚠ Следствие, которое надо решить вместе с этой строкой: `STACK_DECISIONS` §13 становится доком, который ЛЖЁТ о коде («мгновенный отзыв» читается как факт), и эрратой это не помечено. Правку §13 пак не делал — это диспозиция приёмки. Цена лечения названа и она мала: `pump` и так ходит в базу раз в секунду (`ReadStream` с проверкой владельца на каждом тике), проверка живости сессии — одна выборка на биение. ⚠ **Эта строка ОПРОВЕРГАЕТ прежнюю галочку зоны, и сказать это обязан именно пак:** `docs/archive/platform-PROGRESS-P0-P3.md:572` держит таблицу соответствия, где `ASVS 5.0 V7 · 7.2.3, 7.2.4, 7.4.1, 7.4.2 (L1)` отмечено выполненным со словами «отзыв прекращает использование». Против дерева это неверно для единственного длинноживущего канала платформы. Найдено интервальной самоверификацией пака | open | ревью-пак P8-REVIEW, ось 2 (живая проба, подтверждено рефутером и координатором) | +| PD-385 | bug | **major** | `internal/pgstore/runs.go:388`=`where r.finished_at is null and a.reconcile_failures >= $1`, `internal/pgstore/observe.go` (гейдж), `internal/pgstore/runs.go` `AbandonRun` | **Прогон, чей РАСЧЁТ доведён до `StalledAfter`, не виден операторским поверхностям порога, а лог-строка на пересечении порога шлёт оператора именно туда.** Обе фазы делят один счётчик через общий `deferItem`, но операторская половина построена только для ЖИВЫХ прогонов: `StalledRuns` джойнит `a.ended_at is null` и фильтрует `r.finished_at is null`, гейдж `tm_platform_runs_stalled` считает по тому же предикату, а `AbandonRun` читает `where id = $1 and finished_at is null` и отвечает `ErrNoRun`. Живая проба на состоянии, выращенном штатными путями (интейк, HTTP-старт, отказ спавна, `run abandon`): `runs --stalled` отвечает «no run is failing to reconcile», `runs` — «no run is live», `run abandon` — «is not a live run», гейдж 0, при этом в базе `settled_at` NULL, `reconcile_failures` 5 и открытая резервация на 90000 микро. Тот же слепой угол закрывает прогон, который ЖИВ, но чья ПРЕДЫДУЩАЯ попытка не рассчиталась после рестарта. ⚠ Рефутер опроверг заголовочный абсолют «невидим ВСЕМ поверхностям»: `tmplatformctl balance --user` печатает этот холд строкой, `tm_platform_oldest_open_hold_seconds` растёт без потолка, а `tmplatformctl books` показывает «WHY NOT: unsettled hold»; ноль в улике финдера был артефактом его фикстуры. Остаётся то, ради чего строка заведена: три поверхности ПОРОГА слепы, терминальной ручки для такого прогона нет, а ERROR на пороге называет команду, которая на нём молчит. Воспроизведение: `docs/p8-review/axis3-queue/live-stalled-settlement.sh` ⚠ **ВЕС ПОДНЯТ minor → major ЗАКРЫВАЮЩИМ РЕВЬЮ СТАРШЕЙ МОДЕЛИ, и довод не про эту строку в одиночку, а про КРУГОВОЕ сужение четырёх строк пака.** `PD-384` сужен до minor тем, что холд «виден» гейджу `tm_platform_oldest_open_hold_seconds` и команде `balance --user`. Но `PD-392` доказывает ЖИВОЙ ПРОБОЙ, что у этого гейджа ручки НЕТ: идентификатора он не даёт, `balance --user` требует аккаунт, которого гейдж не называет, глобального списка открытых холдов в CLI нет, а документированный случай самого гейджа это ровно данная популяция — при `oldest_open_hold_seconds 10813` все три команды отвечают «no run is live», «no run is failing to reconcile», «no book has been given up on». `PD-390` доказывает, что тот же гейдж умеет ЗАМИРАТЬ и отдавать нули как здоровье. `PD-389` — что его сеттеры не запинены ничем. То есть каждое из четырёх сужений держится поверхностью, несостоятельность которой доказывает соседняя строка ТОГО ЖЕ пака, и по кругу. А терминальной ручки для этой популяции нет ПО ПОСТРОЕНИЮ: `internal/pgstore/runs.go:448`=`select book_id from runs where id = $1 and finished_at is null` отвечает `ErrNoRun` законченному прогону. Следствие, названное прямо: для всей популяции «закончен, но не рассчитан» деньги пользователя заморожены бессрочно, поверхности ПОРОГА слепы, ERROR на пороге называет команду, которая откажет, и единственный выход — сырой SQL в проде. Составной инвариант, на котором принят пак P8-FIX (`D39.154`: гейдж плюс `runs --stalled` плюс `run abandon` как ответ на `PD-169`), для этой популяции ЛОЖЕН ЦЕЛИКОМ — а «решается до следующего пака» есть определение major-секции самого регистра. Носителем major сделана ЭТА строка как самая полная по улике (живая проба на состоянии из штатных путей плюс отказ ручки); `PD-384` и `PD-392` несут ссылку сюда, чтобы не плодить второй major на тот же корень | open | ревью-пак P8-REVIEW, ось 3 (живая проба, сужено рефутером) | +| PD-402 | bug | **major** | `internal/runs/reconcile.go:1221` `Resume` (switch по статусу, «последний ли» не спрашивается), `internal/pgstore/readmodel.go:484` `lastRun` (`order by started_at desc`), `internal/pgstore/runs.go` `RestartRun` (`started_at` не пере-штампуется) | **Возобновление НЕ-последнего прогона книги ломает полосу и карточку сразу двумя путями.** (1) Полоса возобновлённого открывается на ЧУЖОЙ работе: базы не пере-снимаются (это правильно для последнего прогона), но между стопом и resume по книге легально прошёл ЦЕЛЫЙ другой прогон, и оба числителя уже накрутили его главы — на нажатие «продолжить» пользователь видит 100% при нуле собственной работы, клэмп режет только превышение единицы, не двойной счёт до неё. (2) `lastRun` выбирает строку по `started_at desc`, а RestartRun `started_at` не трогает — карточка книги, `derivedStatus` и КАЖДЫЙ progress-кадр живого прогона считаются по ЧУЖОМУ финишировавшему: экран показывает `ready` и замороженный бар, пока живой прогон тратит деньги невидимо. Найдено воркфлоу-ревью и подтверждено его overlay-прогонами (4 независимые линзы сошлись на одном корне); ответ на сам resume (`ReadRun` по id) при этом честный — клиент получает от одной ревизии книги два разных бара. Лечение — вопрос формы: запрет resume не-последнего ИЛИ live-приоритет в `lastRun`; решение следующего пака | open | воркфлоу-ревью P9 28.08 (линзы bar:monotonicity · bar:double-count · bar:baselines-x-migration · bar:stage-caption), диспозиция оркестратора 28.08: строкой | +| PD-403 | bug | **major** | `internal/pgstore/sink.go:415` `recordWaveShape` (`edit_wave` только растёт), `internal/pgstore/readmodel.go:388` `finishedUnits`, `internal/pgstore/books.go` `ReadBookForRun.ChaptersLeft` | **Книга с уже сложившимся `edit_wave=true` на деплое, где оператор УБРАЛ редактора, продаёт уже переведённые главы повторно — и ни один прогон больше не доводит полосу до единицы.** Безредакторный движок шлёт `editTotal=0`, но флаг не возвращается (`or $2` пишет только false→true); прогон делает 100% купленного черновика и закрывается `ready` на 50% со stage `editing`; `chaptersDone` (edit-колонка) мёрзнет, `ChaptersLeft` не падает — шкала покупки предлагает те же главы снова (симптом `PD-202`, записанной fixed этим самым флагом), а follow-up прогон над этой покупкой читает 0/C с первой секунды. Комментарий `sink.go:406-409` о собственном коде неверен обеими фразами (запрещённое направление называет разрешённым, «shows up as more chapters finished» не случается). Подтверждено overlay-прогонами воркфлоу. Корень продуктовый — смена формы конвейера на живой книге; лечение за решением владельца/оркестратора | open | воркфлоу-ревью P9 28.08 (линзы bar:stage-caption · bar:double-count · bar:monotonicity), диспозиция оркестратора 28.08: строкой | +| PD-404 | bug | **major** | `internal/pgstore/sink.go:415` `recordWaveShape`, `internal/pgstore/readmodel.go:388` `finishedUnits` (живой флаг), потребители `bookColumns.chaptersDone` и `ReadBookForRun.ChaptersLeft` | **Единственное разрешённое движение флага (false→true, оператор ДОБАВИЛ редактора) уводит `Book.chapters_done` НАЗАД в пределах одного `structure_version` — то, что канон запрещает прямо.** Книга с начерченным заделом: карточка 7/10; первое progress-событие прогона с редактором переворачивает флаг, `finishedUnits` переезжает на edit-колонку — 7 → 0 одной транзакцией при РАСТУЩЕЙ ревизии (клиент обязан отрисовать спад), `ChaptersLeft` раздувается обратно и шкала снова предлагает купить купленное. Класс `PD-316` (записана fixed) на непокрытом триггере: её пин ловит только admission, а не движение самого флага. Крайний случай той же арифметики: книга, начерченная ПОЛНОСТЬЮ под `false`, редактором НЕдочитываема ни одним действием API — `ChaptersLeft=0` не допускает прогон, а флип случается только в progress-событии допущенного прогона. Подтверждено воркфлоу на in-tree фикстуре (`sink_test.go:701`) | open | воркфлоу-ревью P9 28.08 (линзы race:flip-read-consistency · bar:stage-caption · bar:baselines-x-migration), диспозиция оркестратора 28.08: строкой | +| PD-405 | bug | **major** | `internal/pgstore/sink.go:219` (ложный комментарий-обоснование), `internal/books/parse.go` (инлайн-материализация после коммита `not_started`), `internal/pgstore/books.go` `BooksOwedReadModel` (`nothingIsRunning`) | **Прогон над книгой без материализованного дерева читает 0/total ВСЮ ЖИЗНЬ при исправно доезжающих progress-событиях — а комментарий, которым этот случай объявлен закрытым, описывает архитектуру, которой больше нет.** Окно штатное: интейк коммитит `not_started` ДО материализации; если она упала/отложилась и пользователь успел стартовать прогон, долг дерева заморожен до конца прогона (`nothingIsRunning`), `unitDone` находит 0 строк глав и выходит, вся полоса выведена из `chapters` → 0/total до `ready`. Комментарий `sink.go:219` «the counters the screen reads today come from the progress event» ложен — эти колонки не читает никто (носитель — `PD-411`). Достижимость ЗАМЕРЕНА логом живого стенда: на здоровом пути окно «`not_started` без строк `chapters`» живёт **0.018 с** (обе загрузки пробоя P9: `book parsed` → `the reading surface was refreshed`, 0.0179 и 0.0189 с) — опасная форма только УПАВШАЯ/отложенная материализация, и тогда длина окна = вся жизнь прогона. Лечение — честная привязка полосы к бездеревному окну (отказ старта до дерева ИЛИ полоса из progress-событий как раньше); НЕ легло фикс-раундом — открыта целиком, замер достижимости ратифицирован актом D39.162 | open | воркфлоу-ревью P9 28.08 (линзы bar:stage-caption · bar:double-count), диспозиция оркестратора 28.08: строкой, достижимость замером (акт D39.162) | +| PD-406 | bug | **major** | `internal/httpapi/stream.go:104`=`helloID := state.Position` | **Служебный кадр `hello` на ПЕРЕПОДКЛЮЧЕНИИ несёт `id = state.Position` (голова истории книги) вместо предъявленного клиентом `last` — и переводит `Last-Event-ID` браузера ВПЕРЁД через кадры, которые это соединение ещё не отдало.** По WHATWG поле `id` фиксируется в last event ID buffer на диспатче; обрыв сразу после hello (прокси, вывод инстанса из ротации, ошибка первого `ReadFrames`) теряет кадры `last+1..Position` НАВСЕГДА — включая `note`, которые контракт запрещает терять («MUST NOT be coalesced or dropped»), без `resync_required`; усилитель — `revision` в data того же hello выше потерянных строк, так что предписанный контрактом дельта-догон `?after_version=` возвращает пусто, обезоружены обе починки сразу. Это ровно вариант, который `14-api-contract/README.md` (§Last-Event-ID) называет отвергнутым. **Лечение — одна строка:** при `resuming` слать hello с `last` (свежее подключение оставить как есть); код четырьмя строками ниже уже делает ровно этот выбор для `from` (`stream.go:122-126`). Свежий коннект не задет — там клиент читает коллекции целиком ⚠ **ЗАКРЫТА фикс-раундом P9 (28.08, акт D39.162):** при `resuming` hello несёт `last` клиента (свежий коннект — голову, как и `from` ниже); пин `TestAResumingHelloCarriesTheClientsOwnWatermark` (+ посадка «hello обратно на голову» поймана) | fixed(фикс-раунд P9, D39.162) | воркфлоу-ревью P9 28.08 (линза race:flip-read-consistency), лечение по слову оркестратора 28.08 | +| PD-410 | bug | **major** | `internal/pgstore/readmodel.go:460` `draftWork` (и вся модель «купленный диапазон в обеих волнах»), контраст: движку передаётся ТОЛЬКО `--ceiling-usd` (`internal/runs/spawn.go` `bookCap`), draft-волна фанится по ВСЕМ чанкам книги (`backend/internal/pipeline/waverun.go`) | **Полоса и платёжная модель считают, что прогон работает над диапазоном «C глав от edit-фронта» в обеих волнах, а движок гонит волны последовательно от СВОИХ фронтов до денег — и на continuation над недочерновленной книгой (`draft_before ≥ chapters_before + C`) формула даёт `draftWork=0`: движок тратит весь потолок на черновики, полоса стоит 0/C весь прогон со stage `editing` на прогоне, который только черновит.** Зеркало дефекта, который `draftWork` чинил (тот закрывал «ready на 50%», этот открывает «0% при сделанной работе»). Это НЕ локальная ошибка формулы: любая полоса из (баз, C, D, E) где-то соврёт, пока платёжная модель («довести до конца C глав от `e0`») и движковый план работ («деньги в порядке волн от фронтов») не согласованы — лечение требует либо передавать движку план (диапазон/волны), либо выводить total полосы из движкового плана; `ceiling_chapters` сегодня до движка не доезжает вовсе. Архитектурное, ОТДЕЛЬНЫМ паком по слову оркестратора 28.08 («остановись и скажи»); подтверждено воркфлоу арифметикой на существующем зелёном пине (`TestARunOverADraftedBacklogOwesOnlyTheLastPass` держит `draftWork=0` в родственной точке) | open | воркфлоу-ревью P9 28.08 (линза bar:double-count), стоп-решение сессии P9 28.08: тянет архитектуру, не чинится в паке | ## Открытые — minor | ID | Класс | Серьёзность | Где | Суть | Статус | Источник | |---|---|---|---|---|---|---| -| PD-375 | bug | minor | `internal/runs/runs.go:243`=`if in.CeilingChapters < bounds.Min`, `internal/httpapi/v0_test.go:359`, `internal/runs/control_test.go` | **Верхнюю половину проверки `ceiling_chapters` не исполняет НИ ОДИН тест: снятие второго операнда `in.CeilingChapters > bounds.Max` проходит ВСЮ батарею.** Собственный доккомментарий называет проверку несущей — «число, решающее СКОЛЬКО ДЕНЕГ резервируется, не может быть выбрано вызывающим односторонне», — и канон требует того же от сервера (`max_chapters` уже прижат к остатку, «a client MUST NOT clamp it again»). Единственный тест, называющий `ErrCeilingOutOfBounds`, это таблица соответствия ошибки коду 409 в `httpapi/v0_test.go`, которая `Start` не зовёт, а самая тесная фикстура просит 10 глав у книги, где их 100. Код сегодня ВЕРЕН; дефект в том, что править эту строку можно безнаказанно. Замерено на живом стенде: чистая сборка отвечает `409 ceiling_unavailable/bounds_moved`, сборка с мутацией отдаёт 202 и открывает резервацию `24000000` микро на книге из ТРЁХ глав, а движку уходит `--ceiling-usd 24.200000`. Вес: рефутер сузил major → minor, потому что код верен и ни один сегодняшний клиент до вреда не доходит. Воспроизведение: `docs/p8-review/axis1-money/a1-mut5-ceiling-bound.sh`, готовый пин `docs/p8-review/axis1-money/r1_ceiling_bound_test.go.txt` | open | ревью-пак P8-REVIEW, ось 1 (посадка мутации + живая проба, подтверждено рефутером) | -| PD-376 | bug | minor, деньги | `internal/pgstore/runs.go:588`=`select min(a.spend_baseline_micro_usd)`, пин `internal/runs/sweep_test.go` `TestADeferredSettlementIsNotChargedForTheNextRunOfTheSameBook` | **`PD-159` стоит `fixed`, а её пин доказывает свойство СЛАБЕЕ, чем читается: слово `min` не исполняет никто, и мутация `min` → `max` проходит батарею.** Строка PD-159 закрывает двойную оплату формулой «SpendBound — НАИМЕНЬШАЯ базовая линия среди попыток этой книги, стартовавших ПОЗЖЕ», а названный ею пин кладёт в книгу РОВНО ОДНУ более позднюю попытку — на множестве из одного элемента `min` и `max` совпадают. Прогон `./internal/pgstore/` и `./internal/runs/` под мутацией зелёный; независимый пин на той же мутации падает (`the bound is 0.500000, want 0.200000`), то есть мутация поведенческая, а не эквивалентная. Достижимость: при монотонном росте книжного счётчика `min` и `max` расходятся уже при ДВУХ более поздних попытках, а две даёт один преемник, переживший рестарт или резюм; тогда границей становится базовая линия, УЖЕ содержащая трату предыдущего прогона — это ровно PD-159 на одну попытку дальше. Переплата ограничена холдом. Класс — «реестр умеет врать», тот же разбор, каким был найден PD-169. Готовый пин: `docs/p8-review/axis1-money/a1_spendbound_test.go.txt` и независимый `r1_spendbound_test.go.txt`. ⚠ Статус PD-159 этим паком НЕ менялся: пере-открывать её или оставить закрытой с этой строкой как носителем живого пробела — диспозиция приёмки ⚠ **Пере-проверено на ПОЛНОЙ батарее координатором пака (мутация M11):** первая улика была снята на пакетном сабсете `./internal/pgstore/ ./internal/runs/`, и упрёк «сабсет слабее полной батареи» справедлив. Прогон `go test ./... -count=1` со всеми тремя гейтами под той же мутацией даёт ПУСТУЮ дельту против чистой базовой линии той же копии — то есть мутацию не ловит ни один из 18 пакетов. Лог — `docs/p8-review/mutations-round2.log` | open | ревью-пак P8-REVIEW, ось 1 (посадка мутации, подтверждено рефутером собственным пином) | +| PD-375 | bug | minor | `internal/runs/runs.go:269`=`if in.CeilingChapters < bounds.Min`, `internal/httpapi/v0_test.go:359`, `internal/runs/control_test.go` | **Верхнюю половину проверки `ceiling_chapters` не исполняет НИ ОДИН тест: снятие второго операнда `in.CeilingChapters > bounds.Max` проходит ВСЮ батарею.** Собственный доккомментарий называет проверку несущей — «число, решающее СКОЛЬКО ДЕНЕГ резервируется, не может быть выбрано вызывающим односторонне», — и канон требует того же от сервера (`max_chapters` уже прижат к остатку, «a client MUST NOT clamp it again»). Единственный тест, называющий `ErrCeilingOutOfBounds`, это таблица соответствия ошибки коду 409 в `httpapi/v0_test.go`, которая `Start` не зовёт, а самая тесная фикстура просит 10 глав у книги, где их 100. Код сегодня ВЕРЕН; дефект в том, что править эту строку можно безнаказанно. Замерено на живом стенде: чистая сборка отвечает `409 ceiling_unavailable/bounds_moved`, сборка с мутацией отдаёт 202 и открывает резервацию `24000000` микро на книге из ТРЁХ глав, а движку уходит `--ceiling-usd 24.200000`. Вес: рефутер сузил major → minor, потому что код верен и ни один сегодняшний клиент до вреда не доходит. Воспроизведение: `docs/p8-review/axis1-money/a1-mut5-ceiling-bound.sh`, готовый пин `docs/p8-review/axis1-money/r1_ceiling_bound_test.go.txt` | open | ревью-пак P8-REVIEW, ось 1 (посадка мутации + живая проба, подтверждено рефутером) | +| PD-376 | bug | minor, деньги | `internal/pgstore/runs.go:595`=`select min(a.spend_baseline_micro_usd)`, пин `internal/runs/sweep_test.go` `TestADeferredSettlementIsNotChargedForTheNextRunOfTheSameBook` | **`PD-159` стоит `fixed`, а её пин доказывает свойство СЛАБЕЕ, чем читается: слово `min` не исполняет никто, и мутация `min` → `max` проходит батарею.** Строка PD-159 закрывает двойную оплату формулой «SpendBound — НАИМЕНЬШАЯ базовая линия среди попыток этой книги, стартовавших ПОЗЖЕ», а названный ею пин кладёт в книгу РОВНО ОДНУ более позднюю попытку — на множестве из одного элемента `min` и `max` совпадают. Прогон `./internal/pgstore/` и `./internal/runs/` под мутацией зелёный; независимый пин на той же мутации падает (`the bound is 0.500000, want 0.200000`), то есть мутация поведенческая, а не эквивалентная. Достижимость: при монотонном росте книжного счётчика `min` и `max` расходятся уже при ДВУХ более поздних попытках, а две даёт один преемник, переживший рестарт или резюм; тогда границей становится базовая линия, УЖЕ содержащая трату предыдущего прогона — это ровно PD-159 на одну попытку дальше. Переплата ограничена холдом. Класс — «реестр умеет врать», тот же разбор, каким был найден PD-169. Готовый пин: `docs/p8-review/axis1-money/a1_spendbound_test.go.txt` и независимый `r1_spendbound_test.go.txt`. ⚠ Статус PD-159 этим паком НЕ менялся: пере-открывать её или оставить закрытой с этой строкой как носителем живого пробела — диспозиция приёмки ⚠ **Пере-проверено на ПОЛНОЙ батарее координатором пака (мутация M11):** первая улика была снята на пакетном сабсете `./internal/pgstore/ ./internal/runs/`, и упрёк «сабсет слабее полной батареи» справедлив. Прогон `go test ./... -count=1` со всеми тремя гейтами под той же мутацией даёт ПУСТУЮ дельту против чистой базовой линии той же копии — то есть мутацию не ловит ни один из 18 пакетов. Лог — `docs/p8-review/mutations-round2.log` | open | ревью-пак P8-REVIEW, ось 1 (посадка мутации, подтверждено рефутером собственным пином) | | PD-377 | doc | minor | `internal/pgstore/credits.go:153`=`The ceiling to hand the engine is the amount held`, `internal/runs/spawn.go` `bookCap`, `cmd/tmplatformctl/main.go` `balance` | **Доккомментарий `Hold` на входе в денежный путь неверен ОБЕИМИ половинами с миграции 00011.** Он обещает «the ceiling to hand the engine is the amount held; read it back with OpenReservations». Движку передаётся не сумма холда, а `run_attempts.ceiling_arg_micro_usd` = committed книги плюс прирост (`spawn.go` `bookCap`), и сама 00011 говорит это прямым текстом («It is NOT ceiling_micro_usd»); начиная со ВТОРОГО прогона книги числа расходятся тем сильнее, чем дороже книга — на стенде до 17 раз (`60000` холда против `1060000` в аргументе). Вторая половина не работает даже механически: `Reservation.Ceiling` — поле, которое только сканируется и не читается никем, единственный потребитель `OpenReservations` печатает `Amount`/`BookID`/`OpenedAt`/`EngineRunID`. ⚠ Рефутер снял два из трёх исходных якорей: доккомментарии в применённых миграциях `00007`/`00009` зона не правит после лендинга (у всех 25 файлов миграций ровно по одному коммиту), а исправление уже лежит в следующем файле того же каталога. Остаётся Go-доккомментарий. Воспроизведение: `docs/p8-review/axis1-money/a1-ceiling-column-doc.sql` | open | ревью-пак P8-REVIEW, ось 1 (живой стенд, сужено рефутером до одного якоря) | | PD-384 | bug | minor | `internal/runs/reconcile.go:200`=`if !overran {` (`settleOne`), `internal/runs/reconcile.go` `settle` (три тихих `return nil`) | **Расчёт денег, упавший ДЁШЕВО, не считается никогда: порог `StalledAfter` для него недостижим.** Вторая фаза считает неудачу ТОЛЬКО по исчерпанию бюджета (`overran := errors.Is(item.Err(), context.DeadlineExceeded)`, дальше `if !overran { return }`), а `settle` возвращает nil БЫСТРО в трёх случаях: движок не ответил, в отчёте нет committed, попытка без базовой линии. Каждый может быть ПОСТОЯННЫМ — запиненный бинарь движка снесён при выкате, проект заменён под платформой, попытка старой схемы. Тогда цикл вечен: `reconcile_failures` остаётся 0, `reconcile_after` NULL, гейдж и `tmplatformctl runs --stalled` пусты, холд заморожен. Замерено пробой: пять проходов одного нерассчитываемого прогона дали 5 вызовов движка, `reconcile_failures=0`, `StalledRuns(5)=0`. Плюс цена: `settle` зовёт `tmctl status` НА КАЖДОМ проходе без рейт-лимита, тогда как соседний `maybeResync` имеет `dueForResync` ровно из-за этой цены. ⚠ Рефутер сузил вес major → minor: холд ВИДЕН двум поверхностям, которых финдер не спросил — гейдж `tm_platform_oldest_open_hold_seconds` и `tmplatformctl balance --user`, печатающий каждый открытый холд суммой, книгой и id прогона; плюс каждый проход пишет WARN с id прогона. Воспроизведение: `docs/p8-review/axis3-queue/probe_settlement_surface_test.go.txt` ⚠ **Общий корень с `PD-385`, и там же он взвешен:** сужение ЭТОЙ строки опирается на операторскую поверхность, несостоятельность которой доказывает соседняя строка того же пака — круговое сужение разобрано в `PD-385`, поднятой до major | open | ревью-пак P8-REVIEW, ось 3 (проба на реальном сторе, сужено рефутером) | -| PD-386 | bug | minor | `cmd/tmplatformd/runner.go:251`=`pass("runs", sweepBudget, s.runs.Sweep)` и четыре следующих прохода того же `one()`, `cmd/tmplatformd/runner_test.go` | **Такт свипа последователен, и бюджеты пяти проходов СКЛАДЫВАЮТСЯ: сумма объявленных — 37 минут при `TM_PLATFORM_SWEEP_EVERY` 15 секунд.** Пак P8-FIX дал каждому проходу свой бюджет и закрыл «один проход съедает дедлайн другого», но проходы по-прежнему идут подряд в одной горутине: runs 2м, readmodel 10м, intake 21м, idempotency 2м, observe 2м. Ничто эту сумму не ограничивает и ничто её не пинит — у функции `sweep` нет ни одного теста (в пакете два теста, оба про другое), и посадка «фаза runs уходит из головы такта в хвост» пережила ПОЛНУЮ батарею. Замерено пробой на реальной функции: за 4 секунды при такте 100 мс фаза прогонов отработала 40 раз сама по себе и 9 раз позади прохода материализации. Следствия по оси: калибровки самого лечения заданы в проходах и минутах и молча растягиваются (`StalledAfter` 5 «неудач подряд» и бэкофф, про который комментарий обещает «about a quarter of an hour», превращаются в часы); обещание `Stop` «the reconciler re-issues the stop on its next pass» задерживается на ту же величину; телеметрия стоит ПОСЛЕДНЕЙ. ⚠ Рефутер сузил: 37 минут — сумма ОБЪЯВЛЕННЫХ бюджетов, а не достижимая длительность (idempotency это один индексированный DELETE, а проход интейка сам себя режет). Воспроизведение: `docs/p8-review/axis3-queue/probe_sweep_serialisation_test.go.txt` | open | ревью-пак P8-REVIEW, ось 3 (проба на реальной функции + посадка мутации, сужено рефутером) | +| PD-386 | bug | minor | `cmd/tmplatformd/runner.go:269`=`pass("runs", sweepBudget, s.runs.Sweep)` и четыре следующих прохода того же `one()`, `cmd/tmplatformd/runner_test.go` | **Такт свипа последователен, и бюджеты пяти проходов СКЛАДЫВАЮТСЯ: сумма объявленных — 37 минут при `TM_PLATFORM_SWEEP_EVERY` 15 секунд.** Пак P8-FIX дал каждому проходу свой бюджет и закрыл «один проход съедает дедлайн другого», но проходы по-прежнему идут подряд в одной горутине: runs 2м, readmodel 10м, intake 21м, idempotency 2м, observe 2м. Ничто эту сумму не ограничивает и ничто её не пинит — у функции `sweep` нет ни одного теста (в пакете два теста, оба про другое), и посадка «фаза runs уходит из головы такта в хвост» пережила ПОЛНУЮ батарею. Замерено пробой на реальной функции: за 4 секунды при такте 100 мс фаза прогонов отработала 40 раз сама по себе и 9 раз позади прохода материализации. Следствия по оси: калибровки самого лечения заданы в проходах и минутах и молча растягиваются (`StalledAfter` 5 «неудач подряд» и бэкофф, про который комментарий обещает «about a quarter of an hour», превращаются в часы); обещание `Stop` «the reconciler re-issues the stop on its next pass» задерживается на ту же величину; телеметрия стоит ПОСЛЕДНЕЙ. ⚠ Рефутер сузил: 37 минут — сумма ОБЪЯВЛЕННЫХ бюджетов, а не достижимая длительность (idempotency это один индексированный DELETE, а проход интейка сам себя режет). Воспроизведение: `docs/p8-review/axis3-queue/probe_sweep_serialisation_test.go.txt` | open | ревью-пак P8-REVIEW, ось 3 (проба на реальной функции + посадка мутации, сужено рефутером) | | PD-387 | doc | minor | `deploy/README.md:268`=`сколько всего может занять один проход свипа`, `cmd/tmplatformd/runner.go` `one`, `internal/config/config.go` `SweepBudget` | **Рантбук называет `TM_PLATFORM_SWEEP_BUDGET` ручкой «одного прохода свипа» и не говорит, КАКОГО из пяти, — а два бюджета из пяти оператору недоступны вовсе.** Один такт прогоняет ПЯТЬ проходов подряд, и только три берут `sweepBudget`; проход материализации берёт `refreshSweepBudget` 10 минут, проход интейка — `intakeSweepBudget` = `jobs.JobTimeout` + `readmodel.MaterializeBudget` + минута = 21 минута, и обе константы оператору недоступны вовсе. Собственный доккомментарий кода при этом ТОЧЕН («SweepBudget is what ONE pass of the RUN sweep may take») — расходится именно операторская проза, и расходится в разделе «Застрявшая работа: что оператор делает, когда свип не справляется», то есть там, где по ней и будут действовать. Родня `PD-368`: та про то, что поднимать надо пару, эта про то, что ручка не покрывает такт ⚠ **Заголовок ИСПРАВЛЕН после интервальной самоверификации, и первая редакция была завышена:** она писала, что рантбук обещает ограничение ТАКТА целиком, а `deploy/README.md:268` буквально говорит «сколько всего может занять один проход свипа» — то есть ровно то, что ручка и делает. Дефект уже и точнее: строка не называет, какой из ПЯТИ проходов такта она ограничивает, `refreshSweepBudget` и `intakeSweepBudget` не являются ручками вовсе, и раздел, в котором эта таблица стоит, — «Застрявшая работа: что оператор делает, когда свип не справляется». Арифметика 37 минут пере-проверена и держится: 3×2 + 10 + 21. Воспроизведение — `docs/p8-review/axis3-queue/probe_sweep_serialisation_test.go.txt` (последовательность тактов) плюс `sed -n '264,272p' deploy/README.md` | open | ревью-пак P8-REVIEW, ось 3 (находка рефутера) | -| PD-389 | hardening | minor | `cmd/tmplatformd/runner.go:302`=`s.metrics.ObserveRunner(metrics.Runner{`, `cmd/tmplatformd/runner.go` `pass`, `internal/metrics/metrics_test.go` `TestTheRunnersStateIsExposedWithItsUnits` | **Шов телеметрии не покрыт НИЧЕМ, и это доказуемо без прогона батареи: `sweep` и `observe` — неэкспортируемые функции пакета `main`, то есть из другого пакета их не может вызвать ни один тест в принципе,** а единственный тест-файл каталога несёт два теста, оба про другое. Проверено тремя посадками, пережившими полную батарею: `StalledRuns: o.StalledRuns` в ноль (наблюдаемая половина закрытого BLOCKER `PD-346`), `errors.Is(err, context.DeadlineExceeded)` в false (`sweep_unfinished_total` больше не может вырасти — `PD-351` со стороны ВЫЗЫВАЮЩЕГО, куда пин `TestAPassThatRanOutOfTimeSaysSo` по построению не достаёт), и перестановка `queue_depth` с `live_runs`. Дыра шире шва: пин формы, на который ссылается `STACK_DECISIONS` §24, задаёт литерал `metrics.Runner` из ШЕСТИ полей из восьми — `StalledRuns` и `AbandonedSurfaces` в него не входят, поэтому мутация внутри самого `ObserveRunner` тоже выживает. Пере-проверено координатором пака независимо: снятие `m.stalledRuns.Set(...)` и снятие `m.abandonedSurfaces.Set(...)` по отдельности проходят ПОЛНУЮ батарею (18 пакетов), при том что снятие соседнего инкремента `sweepUnfinished` тем же пином ловится. То есть операторская ручка, построенная паком P8-FIX в ответ на `PD-169`, не пиньётся ничем. Воспроизведение: `docs/p8-review/mutations-full.log` и `docs/p8-review/axis4-metrics/60-mutations.sh` | open | ревью-пак P8-REVIEW, ось 4 (посадки финдера, рефутера и координатора) | -| PD-390 | hardening | minor | `cmd/tmplatformd/runner.go:299`=`log.Warn("the control plane's own state could not be read", "err", err)`, `internal/pgstore/observe.go` `Observe`, `internal/metrics/metrics.go` `ObserveRunner` | **Гейджи замирают при отказе телеметрического чтения, и признака устаревания в экспозиции нет.** `STACK_DECISIONS` §24 объявляет «значения снимает СВИП, а не скрейп», но у снятого значения нет ни отметки свежести, ни счётчика неудач: `observe()` при ошибке пишет один WARN и возвращается, НЕ тронув ни одного гейджа, а Prometheus такой ряд устаревшим не помечает — цель жива, ряд на месте, значение старое. Живой замер: при сломанном чтении и одновременно вылеченном мире экспозиция продолжала утверждать «1 застрявший прогон, 1 карантин, 1 живой прогон, холд возрастом 1201 с», тогда как в базе застрявших было 0; при этом `sweep_duration_seconds_count` рос по всем четырём проходам, то есть все «жив ли свип» сигналы оставались зелёными. `Observe` — ОДИН стейтмент на все восемь чисел, поэтому любая его поломка гасит все гейджи разом, а сам `observe()` вызывается ВНЕ `pass()`, поэтому своего ряда в `sweep_duration_seconds` у него нет и его отказ там не виден. Обратное направление хуже: процесс, у которого чтение не удалось НИ РАЗУ, отдаёт нули как здоровье, и `/readyz` с `/healthz` при этом зелёные. ⚠ Вторая половина того же корня, найденная рефутером: инстанс-ЧИТАТЕЛЬ (пустой `TM_PLATFORM_ENGINE_BIN` — объявленная форма деплоя) не запускает свип вовсе, `observe()` не зовётся ни разу, а метрики созданы раньше и регистрируют все восемь гейджей безусловно, поэтому реплика уверенно отвечает `runs_stalled 0`, `queue_depth 0`, `oldest_open_hold_seconds 0` про контрол-плейн, который она не измеряет — и тут нет даже WARN-строки. Лечится дёшево: `*_last_success_timestamp_seconds` либо счётчик неудач наблюдения плюс проведение `observe` через тот же `pass`. Воспроизведение: `docs/p8-review/axis4-metrics/30-stale-gauges.sh`, `r1-boot-with-blind-telemetry.sh`, `r6-read-replica-zeroes.sh` | open | ревью-пак P8-REVIEW, ось 4 (живой замер, расширено рефутером) | -| PD-391 | bug | minor | `internal/pgstore/sink.go:695`=`where a.reconcile_after is null or a.reconcile_after <= $1`, `internal/pgstore/runs.go` `AbandonRun`, `cmd/tmplatformctl/runs.go` (сообщение), `deploy/README.md` | **`run abandon` не возвращает холд «на ближайшем свипе»: отсрочка застрявшей попытки остаётся, и деньги ждут до 30 минут.** `AbandonRun` завершает прогон и попытку, но `run_attempts.reconcile_after` не трогает, а `UnsettledRuns` по нему фильтрует. Застрявший прогон по построению всегда отсрочен: `deferItem` ставит `now + backoff(failures+1)`, а `backoff` при пяти неудачах упирается в потолок 30 минут. Значит для ВСЕЙ популяции, ради которой команда построена, обещание CLI «its hold comes back whole on the next sweep» и та же фраза рантбука ложны: кредит остаётся вычтенным, `tm_platform_oldest_open_hold_seconds` продолжает расти ПОСЛЕ действия оператора, и оператор читает это как «я сделал, не помогло». Пин `PD-361` доказывает свойство слабее: он берёт прогон, созданный `Start` и брошенный СРАЗУ, у которого `reconcile_failures` 0 и `reconcile_after` NULL. Живая проба на состоянии, выращенном штатным механизмом: через 45 секунд и три свипа холд открыт, `balance` печатает «reserved 3.000000», гейдж 2439 с; ручной `update run_attempts set reconcile_after = now()` закрывает холд в тот же свип. Лечится одной строкой в той же транзакции — снять отсрочку вместе с терминальным вердиктом. Воспроизведение: `docs/p8-review/axis4-metrics/20-abandon-keeps-the-hold.sh` и `r3-abandon-hold.sh` | open | ревью-пак P8-REVIEW, ось 4 (живая проба, подтверждено рефутером на состоянии из штатного пути) | +| PD-389 | hardening | minor | `cmd/tmplatformd/runner.go:320`=`s.metrics.ObserveRunner(metrics.Runner{`, `cmd/tmplatformd/runner.go` `pass`, `internal/metrics/metrics_test.go` `TestTheRunnersStateIsExposedWithItsUnits` | **Шов телеметрии не покрыт НИЧЕМ, и это доказуемо без прогона батареи: `sweep` и `observe` — неэкспортируемые функции пакета `main`, то есть из другого пакета их не может вызвать ни один тест в принципе,** а единственный тест-файл каталога несёт два теста, оба про другое. Проверено тремя посадками, пережившими полную батарею: `StalledRuns: o.StalledRuns` в ноль (наблюдаемая половина закрытого BLOCKER `PD-346`), `errors.Is(err, context.DeadlineExceeded)` в false (`sweep_unfinished_total` больше не может вырасти — `PD-351` со стороны ВЫЗЫВАЮЩЕГО, куда пин `TestAPassThatRanOutOfTimeSaysSo` по построению не достаёт), и перестановка `queue_depth` с `live_runs`. Дыра шире шва: пин формы, на который ссылается `STACK_DECISIONS` §24, задаёт литерал `metrics.Runner` из ШЕСТИ полей из восьми — `StalledRuns` и `AbandonedSurfaces` в него не входят, поэтому мутация внутри самого `ObserveRunner` тоже выживает. Пере-проверено координатором пака независимо: снятие `m.stalledRuns.Set(...)` и снятие `m.abandonedSurfaces.Set(...)` по отдельности проходят ПОЛНУЮ батарею (18 пакетов), при том что снятие соседнего инкремента `sweepUnfinished` тем же пином ловится. То есть операторская ручка, построенная паком P8-FIX в ответ на `PD-169`, не пиньётся ничем. Воспроизведение: `docs/p8-review/mutations-full.log` и `docs/p8-review/axis4-metrics/60-mutations.sh` | open | ревью-пак P8-REVIEW, ось 4 (посадки финдера, рефутера и координатора) | +| PD-390 | hardening | minor | `cmd/tmplatformd/runner.go:317`=`log.Warn("the control plane's own state could not be read", "err", err)`, `internal/pgstore/observe.go` `Observe`, `internal/metrics/metrics.go` `ObserveRunner` | **Гейджи замирают при отказе телеметрического чтения, и признака устаревания в экспозиции нет.** `STACK_DECISIONS` §24 объявляет «значения снимает СВИП, а не скрейп», но у снятого значения нет ни отметки свежести, ни счётчика неудач: `observe()` при ошибке пишет один WARN и возвращается, НЕ тронув ни одного гейджа, а Prometheus такой ряд устаревшим не помечает — цель жива, ряд на месте, значение старое. Живой замер: при сломанном чтении и одновременно вылеченном мире экспозиция продолжала утверждать «1 застрявший прогон, 1 карантин, 1 живой прогон, холд возрастом 1201 с», тогда как в базе застрявших было 0; при этом `sweep_duration_seconds_count` рос по всем четырём проходам, то есть все «жив ли свип» сигналы оставались зелёными. `Observe` — ОДИН стейтмент на все восемь чисел, поэтому любая его поломка гасит все гейджи разом, а сам `observe()` вызывается ВНЕ `pass()`, поэтому своего ряда в `sweep_duration_seconds` у него нет и его отказ там не виден. Обратное направление хуже: процесс, у которого чтение не удалось НИ РАЗУ, отдаёт нули как здоровье, и `/readyz` с `/healthz` при этом зелёные. ⚠ Вторая половина того же корня, найденная рефутером: инстанс-ЧИТАТЕЛЬ (пустой `TM_PLATFORM_ENGINE_BIN` — объявленная форма деплоя) не запускает свип вовсе, `observe()` не зовётся ни разу, а метрики созданы раньше и регистрируют все восемь гейджей безусловно, поэтому реплика уверенно отвечает `runs_stalled 0`, `queue_depth 0`, `oldest_open_hold_seconds 0` про контрол-плейн, который она не измеряет — и тут нет даже WARN-строки. Лечится дёшево: `*_last_success_timestamp_seconds` либо счётчик неудач наблюдения плюс проведение `observe` через тот же `pass`. Воспроизведение: `docs/p8-review/axis4-metrics/30-stale-gauges.sh`, `r1-boot-with-blind-telemetry.sh`, `r6-read-replica-zeroes.sh` | open | ревью-пак P8-REVIEW, ось 4 (живой замер, расширено рефутером) | +| PD-391 | bug | minor | `internal/pgstore/sink.go:696`=`where a.reconcile_after is null or a.reconcile_after <= $1`, `internal/pgstore/runs.go` `AbandonRun`, `cmd/tmplatformctl/runs.go` (сообщение), `deploy/README.md` | **`run abandon` не возвращает холд «на ближайшем свипе»: отсрочка застрявшей попытки остаётся, и деньги ждут до 30 минут.** `AbandonRun` завершает прогон и попытку, но `run_attempts.reconcile_after` не трогает, а `UnsettledRuns` по нему фильтрует. Застрявший прогон по построению всегда отсрочен: `deferItem` ставит `now + backoff(failures+1)`, а `backoff` при пяти неудачах упирается в потолок 30 минут. Значит для ВСЕЙ популяции, ради которой команда построена, обещание CLI «its hold comes back whole on the next sweep» и та же фраза рантбука ложны: кредит остаётся вычтенным, `tm_platform_oldest_open_hold_seconds` продолжает расти ПОСЛЕ действия оператора, и оператор читает это как «я сделал, не помогло». Пин `PD-361` доказывает свойство слабее: он берёт прогон, созданный `Start` и брошенный СРАЗУ, у которого `reconcile_failures` 0 и `reconcile_after` NULL. Живая проба на состоянии, выращенном штатным механизмом: через 45 секунд и три свипа холд открыт, `balance` печатает «reserved 3.000000», гейдж 2439 с; ручной `update run_attempts set reconcile_after = now()` закрывает холд в тот же свип. Лечится одной строкой в той же транзакции — снять отсрочку вместе с терминальным вердиктом. Воспроизведение: `docs/p8-review/axis4-metrics/20-abandon-keeps-the-hold.sh` и `r3-abandon-hold.sh` | open | ревью-пак P8-REVIEW, ось 4 (живая проба, подтверждено рефутером на состоянии из штатного пути) | | PD-392 | hardening | minor | `internal/metrics/metrics.go:80`=`Namespace: namespace, Name: "quarantined_attempts"`, `internal/metrics/metrics.go` `oldest_open_hold_seconds`, `cmd/tmplatformctl/runs.go` `listRuns` | **Две метрики без ручки — тот самый класс, за который `PD-169` стоял BLOCKER'ом, в двух других местах.** Help гейджа карантина сам называет цену («Such a run keeps going and keeps spending»), но команды, называющей строку за этим числом, нет: `grep -rn quarantine cmd/` не даёт ни одного хита, и в таблице `runs` колонки карантина тоже нет. У возраста холда ручка формально есть — `balance --user`, — но она требует идентификатор аккаунта, которого гейдж не даёт, а документированный случай самого гейджа («A hold outlives its run only when a settlement could not be made») — это холд ЗАКОНЧЕННОГО прогона, которого список не показывает по построению. Живая проба на состоянии, произведённом ШТАТНЫМ операторским сценарием (abandon застрявшего прогона): при `tm_platform_oldest_open_hold_seconds 10813` команды отвечают «no run is live», «no run is failing to reconcile» и «no book has been given up on», а единственный путь к строке — psql, то есть ровно то, что эти числа заводились заменить. Глобального списка открытых холдов в CLI нет. Воспроизведение: `docs/p8-review/axis4-metrics/50-gauges-without-a-handle.sh` и `r5-hold-without-a-handle.sh` ⚠ **Общий корень с `PD-385`, и там же он взвешен:** сужение ЭТОЙ строки опирается на операторскую поверхность, несостоятельность которой доказывает соседняя строка того же пака — круговое сужение разобрано в `PD-385`, поднятой до major | open | ревью-пак P8-REVIEW, ось 4 (живая проба, подтверждено рефутером) | | PD-89 | hardening | minor | `cmd/tmplatformctl/main.go:143-150` | **Сминченный ключ идемпотентности не печатается при ошибке записи:** PD-75 закрыл путь ПОСЛЕ коммита, но неоднозначный обрыв НА коммите остался — оператор видит ошибку, повторяет без `--key`, `newKey()` чеканит новый ключ, второе начисление проходит. Фикс: печатать ключ вместе с ошибкой, чтобы повтор был с тем же `--key` | open | приёмка P2 (панель) | | PD-101 | bug | minor | `internal/login/login.go:507` | `login_events.ip_prefix` берётся из `r.RemoteAddr`, а в задуманном деплое перед сервисом стоит edge-прокси ⇒ префикс всегда сеть прокси. Журнал входов заведён как ответ на «откуда примерно я входил» — в шипуемой форме он систематически отвечает неверно. `X-Forwarded-For`/`Forwarded` нигде не читаются и доверенного прокси в конфиге нет (это правильный дефолт: доверять заголовку без edge нельзя) — значит решение про edge и про этот столбец принимается вместе ⚠ **ПАК P8-REVIEW 24.08: якорь дрейфанул и носителей ДВА.** `internal/login/login.go:507` сегодня это `h.mu.Lock()` внутри `checkIssuer`; чтение адреса живёт на `:527`=`ev.IPPrefix = ipPrefix(r.RemoteAddr)`, и второй, строкой не названный, — `internal/login/dev.go:195` с тем же выражением. Суть верна | open | приёмка P2 (панель) | @@ -49,7 +55,7 @@ | PD-103 | hardening | minor | `internal/auth/middleware.go:43,66` | У обращений к БД на аутентифицированном пути (`Lookup`/`Touch`) нет собственного дедлайна — только голый `r.Context()`, а `WriteTimeout` у сервера отсутствует по проекту (SSE) и `TimeoutHandler` в цепочке нет. Зависший Postgres паркует хендлеры и ждущих в пуле, пока клиент сам не уйдёт. `readyz` свой таймаут получил (PD-14) — горячий путь нет | open | приёмка P2 (панель) | | PD-115 | standards | minor | `docs/ENGINEERING_STANDARDS.md` §2 | **Внешняя версионированная базовая линия объявлена ровно для ОДНОЙ оси — безопасности** (ASVS 5.0 L2 + OWASP API Top-10 2023, с указанием глав). Отказоустойчивость, наблюдаемость и контракт-первичность описаны собственной прозой зоны без внешнего эталона, а конфигурация, релиз/откат, ёмкость и восстановление не описаны вовсе. Разница не теоретическая: PD-57 и PD-58 нашлись ИМЕННО сверкой кода с RFC 9700/9207 и NIST SP 800-63B — механизм работает там, где эталон есть, и не может сработать там, где его нет. Грепнуто на 08.08: метрик и трейсинга ноль (ни prometheus, ни otel, ни expvar, ни pprof), процедуры бэкапа/восстановления в `deploy/README.md` нет, SLO не заданы. Предложение зоны: §2 получает по эталону на ось (наблюдаемость, ops, конфигурация) — **направление РАТИФИЦИРОВАНО 09.08 (оркестратор №15 по делегации владельца); носитель работы — эта строка, исполнение — своими паками** ⚠ Уточнено паком P5: ось НАБЛЮДАЕМОСТИ эталон получила — практики именования Prometheus (базовые единицы, `_total` у счётчиков, единица не в лейбле) плюс «четыре золотых сигнала» на вопрос «что мерить», записано в `STACK_DECISIONS` §24 и пинится `metrics.TestTheRunnersStateIsExposedWithItsUnits`. Оси ops/конфигурация/восстановление эталона по-прежнему не имеют — строка открыта ими | open (наблюдаемость закрыта P5; ops и конфигурация — нет) | абстрактный вопрос владельца 08.08 + сессия P4 | | PD-157 | bug | minor | `internal/runs/spawn.go`, `cmd/tmplatformctl/runs.go` `book add` | **ДНЕВНОЙ потолок книги `--ceiling-usd` не перекрывает, а платформа его не видит и не задаёт.** Движок требует хотя бы один из `book_usd`/`day_usd` (`backend/internal/config/book.go:321`, Р7 — ⚠ якорь пере-нацелен оркестратором №19 при лендинге 27.08 с `:250`: требование уехало на 321 из-за лендинга бэкенда `d1eb8a9`, не из-за правки платформы), флаг переопределяет только книжный (D39.122 прямо: «День-потолок не перекрывается»), а `book.yaml` пишет ОПЕРАТОР — платформа его не правит (D39.110 §2b) и в `book add` только проверяет наличие файла. Значит книга с низким `day_usd` останавливает прогон на лимите, которого платформа не выбирала: движок выходит кодом 1 (тот же путь, что у PD-113), прогон приезжает `failed`, а деньги пользователя целы и он не понимает, почему. В `status --json` дневной фигуры нет вовсе (есть `book_ceiling_usd`/`ceiling_pct`), поэтому даже диагностировать это платформа сегодня не может. Заведено, не построено: закрывать — либо проверкой `day_usd` при заведении книги, либо словом контракта о том, кто владеет потолками `book.yaml` у книг под платформой ⚠ **ПОЛОВИНА ЗАКРЫТА (P6): дневной потолок стал РАЗЛИЧИМ.** Поток несёт `ceiling.scope` со значениями book и day (D39.131), платформа хранит внутреннюю причину `daily_ceiling` (миграция 00015) и НЕ проецирует её как `credit_exhausted` — иначе экран сказал бы «кончились деньги» об аккаунте, на котором деньги есть, и зажёгся бы аккаунт-флаг `ReadUsage`. Резюм такого прогона отвечает 409 с диагностикой, а не гоняет попытки в цикл: `day_usd` живёт в `book.yaml` оператора, платформа его не ставит и поднять не может, а граница дня принадлежит ледджеру ДВИЖКА — таймер здесь был бы догадкой, которая тратит спавны. ОСТАЁТСЯ открытым то, ради чего строка заведена: платформа по-прежнему не выбирает и не видит `day_usd` (в `status --json` его нет), поэтому книга с низким дневным потолком остановится на лимите, которого никто на этой стороне не назначал. Пины: `pgstore.TestTheCeilingScopeDecidesWhichPauseTheRunGets` · `runs.TestAResumeOfARunTheEnginesDailyCeilingStoppedIsRefused` · `httpapi.TestOnlyTheContractsOwnPausedReasonReachesTheWire` ⚠ **ПАК P8-REVIEW 24.08: имя пина в этой строке МЁРТВОЕ.** `go test ./internal/runs/ -run TestAResumeOfARunTheEnginesDailyCeilingStoppedIsRefused` даёт `no tests to run`; свойство запинено под другим именем — `internal/runs/seam_test.go:146`=`func TestAResumeOfARunPausedAtALimitIsRefusedWhoseverLimitItWas`, переименование приехало коммитом `9b23e8c`. Суть строки верна и не оспаривается: `day_usd` платформе по-прежнему невидим ⚠ Дополнено рефутером: контракт 0.3.0 РАСШИРИЛ свойство — резюм отбивается `ErrCeilingReached` на ЛЮБУЮ паузу, а различение day против credit пинится не этим тестом, а `pgstore.TestTheCeilingScopeDecidesWhichPauseTheRunGets` и `httpapi.TestOnlyTheContractsOwnPausedReasonReachesTheWire`. Посадка мутации (снят `ErrCeilingReached` в ветке `paused`) роняет пин под НОВЫМ именем — свойство держится | open | собственная сверка шва при F1 (чтение движка + D39.122) | -| PD-162 | bug | minor | `internal/runs/spawn.go` `journalSize`, `internal/runs/reconcile.go` | **Книга, чей каталог удалён или перемещён, принимает прогон и заклинивает его навсегда с открытым холдом.** `journalSize` мапит ENOENT в «ноль, ошибки нет» (законно для первого прогона), поэтому `Start` отдаёт 202 и берёт холд; дальше `bookMeter` вечно падает (спавн отказывает), либо расчёт вечно откладывается — ни один путь не приходит к терминальному состоянию: прогон вечно `translating`, деньги вечно в холде, пользователю видно только «идёт». Дизайн «холд лучше догадки» осознан (строка 136), но отсутствие И валидации каталога на старте, И эскалации после N неудач — дыра. Закрывать вместе с эскроу/`uncertain` либо проверкой каталога при допуске. ⚠ Ре-чек V2 расширил КЛАСС строки: обязательность `reserved_usd` даёт тот же клин без всякого удаления каталога — прогон, запиненный к СТАРОЙ сборке движка (строка 139), у которой поля ещё нет, вечно отказывает спавну с открытым холдом. Лечится тем же терминальным состоянием после N неудач; отказ сам по себе верен (без цифры потолок считать нечем) ⚠ Сужено паком P5 с одной стороны и НЕ закрыто с другой: у ИНТЕЙКА терминальное состояние после N неудач теперь есть (`books.parseAttempts` = 5 → `rejected` с причиной `parser_unavailable`), и прогон на книге, не прошедшей интейк, отвергается до денег (`runs.ErrBookNotReady`). Клин ЖИВОГО прогона на удалённом каталоге не тронут: там нужен тот же счётчик неудач на стороне реконсилятора либо эскроу-половина строки 136 ⚠ Дополнено кросс-семейным ревью: терминальность интейка теперь НЕ применяется к `not_configured` — книга без конфигурации ждёт в `parsing` вместо уничтожения, потому что это состояние ДЕПЛОЯ, а не книги. Цена названа: пока развилка `book.yaml` не ратифицирована, такие книги копятся, и видит их метрика `tm_platform_books_in_intake{status="parsing"}` ⚠ **ДИСПОЗИЦИЯ P7: не бралась.** Клин живого прогона на удалённом каталоге лечится терминальным состоянием после N неудач у реконсилятора, а это половина эскроу (строка 136) — строить её рядом с проектируемым целым значит ставить второй, более слабый ответ на тот же вопрос. P7 добавил к строке одно наблюдение: материализатор читающей поверхности зовёт движок в том же каталоге и на ту же ошибку отвечает логом, не трогая деньги ⚠ **ПАК P8-REVIEW 24.08: терминальный путь для ПОЛОВИНЫ строки ПОСТРОЕН.** `internal/pgstore/runs.go` `AbandonRun` плюс `tmplatformctl run abandon --run --reason [--release-hold]`, диагноз — `runs --stalled` и гейдж `tm_platform_runs_stalled` (коммит `31f1f82`); зонный бэклог это знает строкой П-20, регистр нет. Гейд abandon пропускает ровно ту половину, где спавн ОТКАЗЫВАЕТ (попытка до движка не дошла) — она теперь и считается, и закрывается рукой вместе с холдом. Живым остаётся клин прогона, у которого ЕСТЬ юнит или базовая линия. Предложение пака: сузить до него. ⚠ И оговорка, которая сужение ограничивает: обещание «холд вернётся на ближайшем свипе» для этой популяции неверно — `PD-391` ⚠⚠ **МОЯ ДОПИСКА ВЫШЕ ЗАВЫШЕНА, сужаю по рефутеру.** «Терминальный путь ПОСТРОЕН» неверно: построены ДИАГНОЗ (`runs --stalled`, гейдж) и РУЧНОЙ вердикт оператора для половины «спавн отказал». **Автоматического терминального состояния после N неудач НЕТ и оно не планируется вне эскроу** — это записанное решение (`internal/runs/reconcile.go:249-257` «what it must not buy is this platform deciding on its own»). Проба end-to-end на стенде, восемь проходов при отказывающем движке: `status=translating unit="" failures=8`, гейдж 1, `reserved=0.300000` — прогон висит и деньги заморожены, пока не придёт человек. Плюс возврат холда только с флагом: без `--release-hold` холд ждёт до 30 минут (`PD-391`). Значит сужать строку до «клина с юнитом или базовой линией» НЕЛЬЗЯ: беспризорный деплой на удалённом каталоге по-прежнему висит вечно ⚠ Границы улики: числа пробы (`failures=8`, гейдж 1, `reserved=0.300000`) сняты рефутером на его стенде, и ЛОГ прогона в артефакты не попал — пере-ранить их приёмка не сможет, воспроизводить придётся по описанию. Механизм при этом проверяем чтением: `internal/pgstore/runs.go:441`=`select book_id from runs where id = $1 and finished_at is null` | open | самопроверка дофикса (ревью вне карты) | +| PD-162 | bug | minor | `internal/runs/spawn.go` `journalSize`, `internal/runs/reconcile.go` | **Книга, чей каталог удалён или перемещён, принимает прогон и заклинивает его навсегда с открытым холдом.** `journalSize` мапит ENOENT в «ноль, ошибки нет» (законно для первого прогона), поэтому `Start` отдаёт 202 и берёт холд; дальше `bookMeter` вечно падает (спавн отказывает), либо расчёт вечно откладывается — ни один путь не приходит к терминальному состоянию: прогон вечно `translating`, деньги вечно в холде, пользователю видно только «идёт». Дизайн «холд лучше догадки» осознан (строка 136), но отсутствие И валидации каталога на старте, И эскалации после N неудач — дыра. Закрывать вместе с эскроу/`uncertain` либо проверкой каталога при допуске. ⚠ Ре-чек V2 расширил КЛАСС строки: обязательность `reserved_usd` даёт тот же клин без всякого удаления каталога — прогон, запиненный к СТАРОЙ сборке движка (строка 139), у которой поля ещё нет, вечно отказывает спавну с открытым холдом. Лечится тем же терминальным состоянием после N неудач; отказ сам по себе верен (без цифры потолок считать нечем) ⚠ Сужено паком P5 с одной стороны и НЕ закрыто с другой: у ИНТЕЙКА терминальное состояние после N неудач теперь есть (`books.parseAttempts` = 5 → `rejected` с причиной `parser_unavailable`), и прогон на книге, не прошедшей интейк, отвергается до денег (`runs.ErrBookNotReady`). Клин ЖИВОГО прогона на удалённом каталоге не тронут: там нужен тот же счётчик неудач на стороне реконсилятора либо эскроу-половина строки 136 ⚠ Дополнено кросс-семейным ревью: терминальность интейка теперь НЕ применяется к `not_configured` — книга без конфигурации ждёт в `parsing` вместо уничтожения, потому что это состояние ДЕПЛОЯ, а не книги. Цена названа: пока развилка `book.yaml` не ратифицирована, такие книги копятся, и видит их метрика `tm_platform_books_in_intake{status="parsing"}` ⚠ **ДИСПОЗИЦИЯ P7: не бралась.** Клин живого прогона на удалённом каталоге лечится терминальным состоянием после N неудач у реконсилятора, а это половина эскроу (строка 136) — строить её рядом с проектируемым целым значит ставить второй, более слабый ответ на тот же вопрос. P7 добавил к строке одно наблюдение: материализатор читающей поверхности зовёт движок в том же каталоге и на ту же ошибку отвечает логом, не трогая деньги ⚠ **ПАК P8-REVIEW 24.08: терминальный путь для ПОЛОВИНЫ строки ПОСТРОЕН.** `internal/pgstore/runs.go` `AbandonRun` плюс `tmplatformctl run abandon --run --reason [--release-hold]`, диагноз — `runs --stalled` и гейдж `tm_platform_runs_stalled` (коммит `31f1f82`); зонный бэклог это знает строкой П-20, регистр нет. Гейд abandon пропускает ровно ту половину, где спавн ОТКАЗЫВАЕТ (попытка до движка не дошла) — она теперь и считается, и закрывается рукой вместе с холдом. Живым остаётся клин прогона, у которого ЕСТЬ юнит или базовая линия. Предложение пака: сузить до него. ⚠ И оговорка, которая сужение ограничивает: обещание «холд вернётся на ближайшем свипе» для этой популяции неверно — `PD-391` ⚠⚠ **МОЯ ДОПИСКА ВЫШЕ ЗАВЫШЕНА, сужаю по рефутеру.** «Терминальный путь ПОСТРОЕН» неверно: построены ДИАГНОЗ (`runs --stalled`, гейдж) и РУЧНОЙ вердикт оператора для половины «спавн отказал». **Автоматического терминального состояния после N неудач НЕТ и оно не планируется вне эскроу** — это записанное решение (`internal/runs/reconcile.go:249-257` «what it must not buy is this platform deciding on its own»). Проба end-to-end на стенде, восемь проходов при отказывающем движке: `status=translating unit="" failures=8`, гейдж 1, `reserved=0.300000` — прогон висит и деньги заморожены, пока не придёт человек. Плюс возврат холда только с флагом: без `--release-hold` холд ждёт до 30 минут (`PD-391`). Значит сужать строку до «клина с юнитом или базовой линией» НЕЛЬЗЯ: беспризорный деплой на удалённом каталоге по-прежнему висит вечно ⚠ Границы улики: числа пробы (`failures=8`, гейдж 1, `reserved=0.300000`) сняты рефутером на его стенде, и ЛОГ прогона в артефакты не попал — пере-ранить их приёмка не сможет, воспроизводить придётся по описанию. Механизм при этом проверяем чтением: `internal/pgstore/runs.go:448`=`select book_id from runs where id = $1 and finished_at is null` | open | самопроверка дофикса (ревью вне карты) | | PD-168 | bug | minor, деньги | `internal/runs/reconcile.go` `restart` | **Бюджет перезапуска пересчитывается по ТЕКУЩЕЙ ставке, а не по той, под которую брался холд:** `s.Pricing.Ceiling(l.CeilingChapters)` читает конфигурацию нынешнего деплоя. Смена `TM_PLATFORM_USD_PER_CHAPTER` между допуском и перезапуском ломает обе стороны — вверх: резервируется больше, чем пользователь видел на шкале (нарушение «явного согласия на оплату»); вниз: остаток уходит в минус и прогон ошибочно встаёт `paused/credit_exhausted`. Замерено верификатором: при удвоении ставки перезапуск зарезервировал $5.50 вместо $2.50. Исходная сумма восстановима без пересчёта — она лежит в холде первой попытки (`reservations.ceiling_micro_usd` / `run_attempts.ceiling_micro_usd`) ⚠ **ПАК P8-REVIEW 24.08: якорь дрейфанул, и дефект ШИРЕ записанного.** Пересчёт живёт не в `restart`, а в `internal/runs/reconcile.go:1094`=`budget := s.Pricing.Ceiling(l.CeilingChapters)` внутри `reopen`, и у `reopen` ДВА вызывающих — реконсиляторный `restart` и контрактный `Resume`. То есть смена `TM_PLATFORM_USD_PER_CHAPTER` между допуском и продолжением бьёт и по пользовательскому резюму, а строка описывает только перезапуск ⚠ Охват уточнён рефутером и ЗАМЕРЕН на стенде: `Resume` доходит до `reopen` только из `stopped` и `awaiting_bank` (`paused` отбивается раньше, `reconcile.go:1225`). Удвоение ставки между допуском и ПОЛЬЗОВАТЕЛЬСКИМ резюмом дало холд `5.500000` вместо ожидаемых `2.500000` — то есть денежный путь дёргает КЛИЕНТ, а не только реконсилятор | open | самопроверка дофикса (два верификатора, один исполнением) | | PD-175 | hardening | minor | `internal/books/`, `internal/httpapi/v0.go` `createBook` | **Квоты на интейк нет: аутентифицированный аккаунт может писать на диск оператора неограниченно.** Пер-маршрутный потолок (PD-72) ограничивает ОДИН аплоад (64 МиБ по умолчанию), число аплоадов — ничто: ни лимита книг на аккаунт, ни ретеншена. Отклонённая по вине ИСТОЧНИКА книга свой файл теряет (каталог удаляется), а отклонённая по вине ДЕПЛОЯ — сохраняет намеренно (удалять чужую загрузку из-за своей поломки нельзя), и такие каталоги не чистит никто. Лечится квотой на аккаунт плюс свипом ретеншена по `rejected`; и то и другое — продуктовая политика (сколько книг входит в фри-тир), поэтому заведено, а не выбрано зоной ⚠ Третья половина того же вопроса — УДАЛЕНИЕ книги: `pgstore.DeleteBook` убирает строку и закрытые резервации и НЕ трогает каталог книги на диске, а ручки удаления в контракте нет вовсе (гейт PD-122). То есть сегодня утечки нет, потому что удалять нечем; день, когда ручка появится, — это и день, когда каталог обязан уходить вместе со строкой, и гард «только под `BooksDir`» для этого уже есть (`books.owns`). Диспозиция: закрывать ВМЕСТЕ с ручкой удаления, не раньше и не позже ⚠ Дополнено адверсариальным ревью P5 двумя фактами, которые делают строку острее, чем она написана: (1) пока развилка `book.yaml` не ратифицирована, ЛЮБАЯ загрузка приходит к `rejected/not_configured` — то есть путь «интейк пишет на диск и никто не убирает» сегодня ординарный, а не краевой; (2) у `rejected` нет ВЫХОДА вовсе: ни перепарса, ни удаления в контракте нет, строка остаётся в библиотеке навсегда. Обе половины закрываются тем же — квотой на аккаунт плюс свипом ретеншена, и обе продуктовые ⚠ Дополнено кросс-семейным ревью (Fable, 11.08): крэш-окно «строка закоммичена — каталог ещё не снесён» оставляет каталог-сироту, которого не найдёт никто (`StuckIntake` берёт только `uploading` и `parsing`). У отказа порядок перевёрнут в самоизлечивающийся — сначала каталог, потом строка, — а у брошенной загрузки перевернуть нельзя (каталог можно сносить только убедившись, что строки нет), так что окно там остаётся и закрывается тем же свипом ретеншена, что и вся строка | open | сессия P5 (самопроверка, ось «что этот маршрут создаёт») | | PD-367 | bug | minor | `internal/books/parse.go:129`, `internal/ingest/manifest.go` `Whole` | **Пол на самосогласованность манифеста стоит только у материализатора, а интейк тот же документ ПРИНИМАЕТ.** Манифест `{ChaptersTotal: 120, UnitsTotal: 400}` с пустым списком глав `Whole()` отвергает, а `books.Parse` заводит книгу `not_started` с `chapter_count=120` и пустым деревом — по такой книге можно СТАРТОВАТЬ и ОПЛАТИТЬ прогон (потолок считается от `chapter_count`). Воспроизведено ревью на живом Postgres. ⚠ **Лекарство не построено и это решение, а не недоделка:** контракт интейка с манифестом — только счётчики (`FinishParse` берёт три поля, дерево объявлено ОТДЕЛЬНОЙ границей со своим долгом), и вся батарея интейка ездит на документах без списка глав (`books_test.go:145`), то есть `Whole()` в `parse.go` разворачивает запиненный контракт. Нужно решение владельца/оркестратора: расширять контракт интейка или оставить асимметрию осознанной | open | воркфлоу-ревью волны 2 (P8-FIX); рефутер подтвердил механику и опроверг предложенное лекарство | @@ -59,23 +65,30 @@ | PD-217 | bug | minor | `internal/pgstore/books.go` `BooksForMigration`, `deploy/README.md` | **Книга, застрявшая на `daily_ceiling` или на вечно незакрытом холде, блокирует апгрейд движка бессрочно, и выхода у оператора нет.** Оба состояния снимаются только тем, чего платформа сделать не может: дневной потолок живёт в `book.yaml` оператора, и резюм по нему отказан 409; холд закрывается расчётом, который читает движок ЗАПИНЕННЫМ бинарём. Форсирующего флага у `books --migratable` нет намеренно — он и был бы способом пере-оплатить уже купленные вызовы. Лечится либо ручным разрешением в БД, либо каналом «признать попытку невосстановимой», которого в контракте нет ⚠ **ПОЛОВИНА ЗАКРЫТА P7:** `paused` вышел из предиката `Resumable` — с 0.3.0 прогон, остановленный ЛЮБЫМ потолком, `resume` продолжить не может вовсе (409 `ceiling_reached`), значит к старой сборке ничего не пришпилено и мигрировать такую книгу безопасно; лечение пользователя — НОВЫЙ прогон, который спавнится ТЕКУЩИМ бинарём. Книга на дневном потолке апгрейд больше не запирает. Связь названа в коде: вернётся резюм паузы — вернётся и предикат. Пин `TestEachOfTheThreeBlockersAloneKeepsABookOutOfTheMigrationList` (перечень «безопасных» проверяется целиком, а не по одной книге). ⚠ ОСТАЁТСЯ вторая половина: книга с вечно незакрытым холдом блокирует по-прежнему, и это правильно — расчёт читает движок запиненным бинарём | open | приёмка P6 (дофикс, ФП-7) | | PD-219 | bug | minor | `internal/runs/reconcile.go` `drainJournal`, `reopen` | **Перезапуск после упавшего дрейна теряет непримененный хвост журнала навсегда.** Новая попытка получает курсор с РАЗМЕРА журнала на момент допуска, поэтому строки, которые прошлый дрейн не успел применить, не прочитает уже никто. Счётчики восстанавливает ре-синк, а `unit_resolutions` — нет: их единственный источник — поток. Сегодня невидимо (читающей поверхности единиц ещё нет), к P7 станет расхождением read-модели ⚠ **ДИСПОЗИЦИЯ P7 (взято на учёт, НЕ закрыто):** предсказание строки сбылось ровно наполовину, и половины теперь названы. Что САМОЛЕЧИТСЯ: текст и состояние пары приходят не из потока, а из `tmctl export` на границе работы (`readmodel.Refresh`), поэтому недодрейненный хвост их не искажает. Что НЕ лечится: `unit_resolutions` — единственный источник ЗАМЕЧАНИЙ и счётчиков главы, и потерянная строка это замечание, которого пользователь не увидит никогда, плюс `units_done`, занижённый навсегда. Лечение — перечитывание хвоста журнала при переоткрытии прогона (курсор новой попытки начинается с размера журнала на допуске); это работа того же класса, что эскроу (строка 136), и в P7 не бралась осознанно | open | приёмка P6 (дофикс, ФП-7) | | PD-380 | hardening | minor | `internal/pgstore/sessions.go:64`=`if _, err := s.pool.Exec(ctx, q, digest, userID, now, now.Add(idleTTL), now.Add(maxAge)); err != nil {`, `internal/config/config_test.go` | **Абсолютный потолок сессии не запинен в единственном месте, где он становится фактом в базе.** `CreateSession` — единственный писатель `sessions.absolute_expires_at`, и мутация этого выражения проходит ПОЛНЫЙ пакет `pgstore`: ни один сессионный тест не краснеет. Пин, который `STACK_DECISIONS` §13 называет носителем потолка, смотрит только на результат `config.Load()` (что значение конфигурации не выше ASVS-предела), то есть проверяет НАСТРОЙКУ, а не то, что она доезжает до строки. Родня `PD-86`, но на шаг раньше: там не запинены клаузы ЧТЕНИЯ и потолок держится транзитивно через `Touch`, здесь не запинена сама ЗАПИСЬ, а транзитивной страховки у неё нет. Воспроизведение: `docs/p8-review/axis2-auth/mutations-axis2.sh` ⚠ **Пере-проверено на ПОЛНОЙ батарее координатором пака (мутация M12):** `now.Add(maxAge)` → `now.Add(100*maxAge)` в `CreateSession`, дельта против чистой копии ПУСТА на всех 18 пакетах. ⚠ Первая редакция этой посадки НЕ СОБРАЛАСЬ — комментарий вставлялся в середину строки и съедал хвост `; err != nil {`; это ошибка харнесса, а не находка, и она названа в логе `docs/p8-review/mutations-round2.log`, чтобы приёмка не пере-открыла её как расхождение ⚠ **ВЕС ПОДНЯТ info → minor закрывающим ревью, довод — симметрия с `PD-375`:** форма идентична (единственная точка принуждения объявленной границы не исполняется ни одним тестом, мутация переживает ПОЛНУЮ батарею, транзитивной страховки нет — `Touch` зажимает по значению ИЗ ТОЙ ЖЕ испорченной строки), а вес расходился только по валюте: там деньги и minor, здесь механизм ASVS 7.3.2 уровня 2 при объявленной зоной базовой линии L2 и info. Асимметрия была отпечатком того самого храповика «вреда сегодня нет», который это ревью и нашло | open | ревью-пак P8-REVIEW, ось 2 (посадка мутации, пере-посажена рефутером на полном пакете) | +| PD-401 | bug | minor | `internal/pgstore/runs.go` `StartRun` (снятие баз), `internal/pgstore/sink.go` `recordWaveShape`, `internal/pgstore/readmodel.go` `runDone`/`runTotal` | **Прогон, стартовавший при `edit_wave = false` и получивший переворот флага ПОСЛЕ старта, делает всю купленную работу с полосой на `0/N` — и это навсегда, а не «окном».** Механика: движок объявляет форму волн первым progress-событием ПОСЛЕ старта (`recordWaveShape`; флаг только растёт, `STACK_DECISIONS` §35), а базы полосы снимались в `StartRun` через флаг-зависимый предикат МОМЕНТА СТАРТА и не пере-базируются никогда — сидят на черновой колонке, числитель после переворота уезжает на редакторскую, разрыв не закрывается. Достижимо штатно: книга прочерчена на пайплайне без редактора, оператор ставит редактора, куплено продолжение; воспроизведено ревьюером приёмки заменой одной строки в пине пака (`edit_wave = false` до старта, переворот после → `0/4` при всей сделанной работе). Тот же симптом «полоса не достигает единицы», что у `PD-281`, — на том «промежуточном классе», которым диспозиция `PD-281` и объясняется. ⚠ Первая редакция строки (заведена опровергателем пака) занижала вес (info) и формулировку: «скачок доли при done>0» и «окно секунды» — секунды живёт ПЕРЕВОРОТ, ущерб его переживает; пере-формулирована и пере-взвешена по слову оркестратора 27.08. ⚠ **Лечение ПОСТРОЕНО в этом же дереве и зелено:** базы снимаются на ФИКСИРОВАННЫХ колонках (`chapters_before` — редакторская, `draft_before` — черновая), пара «числитель+база» выбирается ЖИВЫМ флагом в момент чтения (`runDone`), миграция `00026` пере-снимает базы live-прогонов, порядок переворота запинен ровно как в проде — `TestAFlagThatFlipsAfterStartDoesNotStrandTheBar`; лендить ли лечение этим паком — решение владельца, состав собирает оркестратор, до лендинга строка open. Остаток и ПОСЛЕ лечения: на смешанном заделе (купленный диапазон шире начернённого) переворот двигает знаменатель вверх в окне первого progress-события — транзиентный провал доли, прежняя суть первой редакции | open | пак P9: опровергатель формы полосы (первая редакция) · блокер ревьюера приёмки 27.08 (пере-формулировка) | +| PD-407 | bug | minor | `internal/runs/bank.go` (ветка `ctx.Err()` после `BankApply`), `internal/runner/bankapply.go:40` `bankStopGrace` | **Единственный путь двери правок, способный оставить полу-приземлённую пару файлов БЕЗ отчёта, отвечает общим `503 service_unavailable` вместо своего слова `bank_corrections_incomplete` — а комментарий ветки обосновывает её контрактом SIGTERM, который сюда не попадает.** Ветка `ctx.Err() != nil` достижима только при `res.Exited=false`, т.е. когда глагол НЕ вышел сам — SIGKILL по `WaitDelay` (грейс 10 с) или несостоявшийся старт; глагол, поймавший SIGTERM, выходит 5 и идёт через `bankVerdict`. SIGKILL между двумя rename — ровно класс 15, и адресат его слова другой («машина шлёт ТО ЖЕ, никто не пере-решает»). Смягчение: ре-сенд сходится байтовым no-op движка в обе стороны, цена — неточное слово. Рядом: `bankStopGrace` 10 с КОРОЧЕ самого длинного непрерываемого участка глагола (движковый замер: 5000 declines ≈ 11.3 с до пред-записной проверки ctx) — на документе-максимуме SIGKILL опережает штатный останов для большинства моментов отмены ⚠ **ГРЕЙС-ПОЛОВИНА ЗАКРЫТА фикс-раундом P9 (28.08, D39.162):** `bankStopGrace` поднят до 30 с, число обосновано движковым замером в комментарии константы (×2.5 запас на медленный хост); комментарий SIGKILL-ветки переписан честно (описывал соседний путь). ОТКРЫТЫМ остаётся слово: полу-приземлённая пара без отчёта по-прежнему отвечается общим `503` вместо `bank_corrections_incomplete` — смягчение (ре-сенд сходится) в силе | open (грейс-половина закрыта D39.162) | воркфлоу-ревью P9 28.08 (линзы door:interleave · door:crash-windows), диспозиция оркестратора 28.08: строкой; грейс — фикс-раундом | +| PD-409 | hardening | minor | `internal/runs/bank.go` `decisionsFile` (`bank-corrections-*.json` в StateDir, удаление только `defer cleanup()`) | **Отрендеренный документ решений переживает любую нечистую смерть демона и остаётся в StateDir навсегда: ничто в дереве каталог не подметает.** SIGKILL/OOM/паника/истёкший грейс деплоя посреди вызова двери — и файл до 1 МиБ с полными src/dst правленых терминов пользователя лежит под 0600 бессрочно в каталоге, где оператор считает маркеры прогонов; единственная существующая уборка (`os.Remove` exit-маркера в `spawn.go`) эти файлы не видит. Накапливается через деплои: N нечистых смертей = N сирот. Лечение — бут-подметание по маске или переезд под каталог с автоочисткой ⚠ **ЗАКРЫТА фикс-раундом P9 (28.08, акт D39.162):** `Service.SweepCorrectionScratch()` подметает по маске на буте (вызов в композиционном корне `tmplatformd` до подъёма HTTP, файлы вне маски не трогаются); пин `TestBootSweepsOrphanedCorrectionDocuments` (посадка «маска мимо» поймана). Честная оговорка: сам ВЫЗОВ из main юнитом не запинен — ловится чтением | fixed(фикс-раунд P9, D39.162) | воркфлоу-ревью P9 28.08 (линзы door:crash-windows · door:lock-lifecycle), лечение по слову оркестратора 28.08 | +| PD-411 | standards | minor | `internal/pgstore/sink.go:137` и `:447` (два писателя), потребителей НЕТ (греп: ни одного select) | **`runs.draft_done/draft_total/edit_done/edit_total` — носитель с ДВУМЯ писателями и НУЛЁМ читателей: класс A второго яруса онтологии банка (`18-bank-ontology.md`), сосед `PD-314` по форме.** Полоса целиком выведена из `chapters` (runDone/runTotal), эти колонки не читает ни один SELECT — а пишутся они на КАЖДОМ progress-событии внутри транзакции, держащей блокировку строки книги, двумя РАЗНЫМИ дисциплинами (поток присваивает, resync берёт `greatest`), и обоснование `greatest` на `sink.go:444-446` защищает полосу, которой не существует, — следующая сессия будет искать монотонность бара не там, где он живёт. Лечение: снос колонок миграцией + снос обоих писателей и лгущих комментариев; в паке P9 НЕ делается (слово оркестратора 28.08: миграцию на снос в этом паке не заводить) | open | воркфлоу-ревью P9 28.08 (линзы race:flip-read-consistency · bar:double-count · bar:stage-caption), диспозиция оркестратора 28.08: отдельной строкой класса A | +| PD-412 | bug | minor | `internal/pgstore/readmodel.go:434-481` (draftChapters ×3 текстовых вхождения, editChapters, chaptersDone, noteCount), `internal/pgstore/books.go` `bookColumns`/`lastRunTx`/`listBooksTx` | **Карточка книги стоит 5 коррелированных сканов `chapters` на один GET, и 2 из них повторяются на КАЖДУЮ строку страницы библиотеки (×100).** PG повторные текстовые вхождения одного подзапроса НЕ дедуплицирует (доказано воркфлоу side-effect-последовательностью на живом PG 18.4), CASE-ветки честно short-circuit; замер на фикстуре в форме миграции 00002 и книге 2283 глав: выражение как написано — 1.359 мс, те же три числа одним `LEFT JOIN LATERAL (count(*) filter (...))` — 0.307 мс (×4.4); контрольный EXPLAIN сессии на реальной схеме tmp9stand: 6 SubPlan в плане, 5 исполняются. Лечение названо (один LATERAL-проход рядом с существующим lastRun); проект уже применял этот класс лекарства уровнем ниже (миграция 00022, `note_count`) | open | воркфлоу-ревью P9 28.08 (линза cost:per-request, замер) + контрольный EXPLAIN сессии P9, диспозиция оркестратора 28.08: строкой | +| PD-413 | bug | minor | `internal/pgstore/sink.go:293-309` `emitProgress` (тот же 3-скан агрегат) под `lockBook` из `RunSink.Apply` (`sink.go:80-104`), источник событий: движок шлёт progress на КАЖДЫЙ разрешённый юнит каждой волны | **Каждое progress-событие пересобирает полосный агрегат по ВСЕЙ книге — под удержанной блокировкой строки книги: стоимость прогона растёт как O(глав × юнитов) вместо O(юнитов).** Изменение счётчиков ОДНОЙ главы (unitDone трогает одну строку) на следующей же строке потока триггерит 3 полных скана `chapters` (≈1 мс на книге 2283 глав, замер воркфлоу), сериализуя относительно себя любого другого писателя книги (RequestStop, дверь правок, следующее событие) — за прогон это десятки тысяч повторов, секунды суммарного удержания блокировки. Лечение — то же, что у `PD-412` (один LATERAL), плюс возможная инкрементальность; оркестратор 28.08: «вторая пахнет хуже первой» | open | воркфлоу-ревью P9 28.08 (линза cost:per-request, замер), диспозиция оркестратора 28.08: строкой | + ## Открытые — info | ID | Класс | Серьёзность | Где | Суть | Статус | Источник | |---|---|---|---|---|---|---| | PD-398 | standards | info | `docs/scripts/counts.py` `malformed`, строки `PD-99` и `PD-197` этого файла | **Гейт формы регистра ловит только НЕДОСТАЧУ ячеек, а не избыток — и в файле уже есть две строки, которые он пропускает.** `malformed()` сравнивает `if n < shape`, поэтому строка с лишним символом вертикальной черты внутри инлайн-кода проходит молча: счёт ячеек через `awk` с разделителем-чертой даёт `PD-99` (11 ячеек) и `PD-197` (10), а `counts.py --check` печатает `битая форма: []`. Обе строки допаковые, обе сломаны греп-альтернацией и регекспом в тексте. ⚠ Что СМЯГЧАЕТ и почему это `info`, а не выше: `col()` считает колонки С КОНЦА, поэтому статус и вес таких строк читаются ВЕРНО, и сегодняшние числа зоны не врут — пере-проверено, `PD-99` и `PD-197` попадают в свои корзины правильно. Дыра латентная: лишняя черта в одной из ТРЁХ последних колонок сдвинет уже их, и гейт снова промолчит. Пак наткнулся на это ДВАЖДЫ собственной рукой, и второй раз — этой же строкой: первая её редакция цитировала команду счёта ВМЕСТЕ с разделителем-чертой и сломала себя ровно тем, что описывает. моя же правка `PD-396` внесла конвейер в ячейку, счётчик весов выдал мусорный ключ вместо `info`, а `битая форма` осталась пустой — то есть симптом виден в СЧЁТЕ, а не в проверке формы, которая для этого и написана. Лечение однострочное и не моё: `if n != shape` вместо `if n < shape` (зона `docs/`, решение оркестратора). Родня по классу — весь этот пак: гейт, доказывающий меньше, чем читается ⚠ **ПЕРЕ-ФОРМУЛИРОВАНА приёмкой (оркестратор №19, 27.08, D39.159 п.8): предложенная правка ОТКЛОНЕНА замером.** `n != shape` краснит СЕМЬ законных строк (пять бэклога, две регистра), потому что колонки читаются С КОНЦА НАМЕРЕННО — `counts.py:132` называет это прямо и приводит ровно этот случай (строка бэклога 127 несёт три слова через вертикальную черту внутри инлайн-кода). Избыток в ПЕРВОЙ содержательной ячейке легален, и числа из-за него не врут: проверено, статус и вес читаются из хвоста, `PD-99` и `PD-197` разбираются верно. **Настоящий остаточный риск — избыток в ХВОСТОВОЙ ячейке:** там лишняя вертикальная черта сдвинула бы именно статус или вес, и тихо. Ловится не счётом ячеек, а сверкой хвостового словаря (статус ∈ {open, fixed, accepted-risk}); сегодня у такой сверки два известных доброкачественных исключения — `PD-59` и `PD-273` несут статус прозой. Строка остаётся ОТКРЫТОЙ под эту формулировку ⚠⚠ **ВТОРАЯ ПОЛОВИНА ПОСТРОЕНА 27.08 (оркестратор №19), смягчение строки ОПРОВЕРГНУТО.** Довод «читаем с конца, значит лишняя черта безвредна» неверен: он защищает СТАТУС (третья с конца), но не ВЕС — тот читался `col(l,6)`, то есть тоже с конца, а от конца он далеко, и любая лишняя черта в «сути» сдвигала его молча. Живой пример — сама `PD-197`: вес читался из ячейки «где». Эмпирика, принесённая зоной: три случая за двое суток, все у тех, кто в этот момент про этот класс ПИСАЛ (правка `PD-396` · первая редакция этой строки · пере-формулировка её приёмкой), и во всех трёх «битая форма» оставалась пустой. **Построено три вещи, каждая проверена исполнением:** (1) парсер уважает markdown-экранирование — `PD-99` несла корректно экранированные черты, и ломался парсер, а не строка; (2) ВЕС регистра читается с НАЧАЛА (`cells(l)[3]`), потому что ведущие ячейки коротки и черт не несут, хвостовые тоже, а свободный текст живёт в СЕРЕДИНЕ — оба конца безопасны, середина нет; (3) новый гейт `tail_vocab` судит СЛОВАРЬ хвоста (статус и вес), а не счёт ячеек, с одним грандфазерным исключением `PD-59`. Проверено подсадкой: черта в статусную ячейку краснит гейт немедленно; на чистом дереве EXIT=0, числа не сдвинулись (398/96, 3/27/66). ⚠ Правка `n != shape` по-прежнему ОТКЛОНЕНА и теперь на точном основании: после уважения экранирования в бэклоге остаются пять строк с законной сырой чертой внутри инлайн-кода, и их хвост чист. **Строка остаётся ОТКРЫТОЙ ровно на одно: у построенного гейта нет автоматического пина — он проверен подсадкой руками. Закрывать её без пина значило бы нарушить правило `PD-1` в строке, которая про это правило и есть** ⚠ **ЗАКРЫТА 27.08: пин появился.** `selftest_tail_vocab()` в `docs/scripts/counts.py` гоняется на КАЖДОМ `--check` и несёт четыре утверждения — чистая строка молчит · сырая черта в СТАТУСНОЙ ячейке краснит · экранирование законно и лишней ячейкой не считается · черта в СЕРЕДИНЕ не сдвигает вес. Проверен ПОСАДКОЙ, а не заявлением: три мутации гейта (снять уважение к экранированию · вернуть вес на чтение с конца · обезвредить словарь статуса) — пин ловит все три, базовая линия молчит. ⚠ Первая редакция пина МОЛЧАЛА на второй мутации: утверждение про вес проверяло `cells()`, а мутация меняет то, чем пользуется `register()`, — пин смотрел не туда. Переписано на настоящий путь; называю, потому что пин, проверенный одним прогоном вместо посадки, — это ровно тот дефект, который эта строка и описывает | fixed(приёмка 27.08, дерево сессии) | ревью-пак P8-REVIEW (наткнулся при правке собственной строки, подтверждено редакторским аудитом) | -| PD-399 | standards | minor | `internal/pgstore/readmodel.go:330` `bankCountsTx`, `internal/httpapi/reading_test.go:113` | **`GET /books/{bookId}/bank` шлёт два поля, которых канон 0.5.0 больше НЕ объявляет.** Минор снёс `pending_decisions` и `complete` из `BankPage` и `EventBank` вместе с пер-термной моделью, которую они обслуживали, а проекция платформы их по-прежнему кладёт на провод, и это НЕ нули: `bankCountsTx` считает `proposed`-строки, и её собственный комментарий это говорит. Клиент 0.5.0 лишние поля игнорирует по общему правилу канона, поэтому вес minor, но аллоулист-норма нарушена, а комментарий «the wire fields are the canon's» с этого минора ЛОЖЕН. ⚠ Присутствие полей ЗАПИНЕНО (`reading_test.go:113`), значит снятие — правка с пином, а не вычёркивание. Лечение — пункт **(2в)** очереди D39.156, тем же паком, что монтирует дверь `bank/corrections`. ⚠ Найдено САМОЙ контрактной сессией и принесено пингом: промт (§3.2-бис) утверждал «поля навсегда нули», а нота D39.160 п.2 — «после сноса канон и деплой совпадут точно»; верно по ПУТЯМ, неверно по ПОЛЯМ. Ошибка оркестратора, исправлена эрратой | open | пинг контрактной сессии при сдаче минора 27.08, пере-проверен приёмкой | +| PD-399 | standards | minor | `internal/pgstore/readmodel.go:330` `bankCountsTx`, `internal/httpapi/reading_test.go:113` | **`GET /books/{bookId}/bank` шлёт два поля, которых канон 0.5.0 больше НЕ объявляет.** Минор снёс `pending_decisions` и `complete` из `BankPage` и `EventBank` вместе с пер-термной моделью, которую они обслуживали, а проекция платформы их по-прежнему кладёт на провод, и это НЕ нули: `bankCountsTx` считает `proposed`-строки, и её собственный комментарий это говорит. Клиент 0.5.0 лишние поля игнорирует по общему правилу канона, поэтому вес minor, но аллоулист-норма нарушена, а комментарий «the wire fields are the canon's» с этого минора ЛОЖЕН. ⚠ Присутствие полей ЗАПИНЕНО (`reading_test.go:113`), значит снятие — правка с пином, а не вычёркивание. Лечение — пункт **(2в)** очереди D39.156, тем же паком, что монтирует дверь `bank/corrections`. ⚠ Найдено САМОЙ контрактной сессией и принесено пингом: промт (§3.2-бис) утверждал «поля навсегда нули», а нота D39.160 п.2 — «после сноса канон и деплой совпадут точно»; верно по ПУТЯМ, неверно по ПОЛЯМ. Ошибка оркестратора, исправлена эрратой ⚠ **ЗАКРЫТА паком P9 (27.08): поля сняты со всех трёх носителей** — `BankCounts`/`payload()` (кадр `EventBank`), `wireBankPage`/`listBank` (провод), подзапрос к мёртвой `bank_decisions` ушёл из `bankCountsTx`; пин `reading_test.go` ПЕРЕПИСАН и теперь пинит ОТСУТСТВИЕ снятых полей на первой странице (`TestTheBankAggregatesRideOnTheFirstPageOnly`) | fixed(пак P9, дерево сессии) | пинг контрактной сессии при сдаче минора 27.08, пере-проверен приёмкой | | PD-397 | hardening | info | `internal/pgstore/credits.go:52-53`=`A ledger row is never edited: the correction is another row`, `internal/pgstore/credits.go:354-356`=`The two are never written apart`, миграция `internal/pgstore/migrations/00007_credits.sql` | **Два самых сильных денежных инварианта объявлены ПРОЗОЙ и держатся ТОЛЬКО кодом — схема их не навязывает.** `Adjust` обещает «леджер не правится, коррекция это ещё одна строка, и именно это делает сумму воспроизводимой»; `appendLedger` обещает «кэш и леджер никогда не пишутся врозь, потому что отстающий кэш — это второй ответ про деньги». Проба прямым SQL по стенду показывает, что DDL допускает нарушение обоих: `UPDATE` и `DELETE` строки леджера ПРИНЯТЫ, кэш баланса выставляется ЛОЖЬЮ и ОТРИЦАТЕЛЬНЫМ тоже. Пере-проверено координатором пака независимо от агента — все четыре приняты, и откат пробы сам же оставил расхождение кэша с леджером в 1 микро-доллар, которое поймало только сведение двумя путями, а не база. ⚠ Что схема при этом ДЕРЖИТ и что находкой НЕ является (иначе строка читается как «денежных констрейнтов нет»): знак по каждому виду строки, обязательная нота у коррекции, закрытый словарь видов, непустые `source`/`source_id`, уникальность ключа идемпотентности в пределах аккаунта, положительность сумм резервации, согласованность состояния и времени закрытия, владение книгой через композитный внешний ключ, и переполнение bigint в кэше. То есть DDL закрывает ФОРМУ строки и не закрывает ИСТОРИЮ. Цена названа и она не про сегодняшний код: пути правки леджера в Go нет, поэтому эксплуатации нет — опасны миграция данных, операторский `psql` и будущий инструмент, каждый из которых по построению идёт мимо кода, а прозу в доккомментарии не читает. Лечится либо триггером на `update`/`delete` по `credit_ledger`, либо явной записью «append-only — дисциплина кода, не схемы» рядом с обещанием. ⚠ Заведено ЗАПОЗДАЛО и это отдельный факт: работа была сделана агентом оси 1 по прямому требованию промта («попробуй нарушить каждый прямым SQL; констрейнт, которого нет, это находка»), артефакт `docs/p8-review/axis1-money/constraint-probe.out` лежал в сдаче, а строки не имел — нашёл редакторский аудит полноты. Воспроизведение: `docs/p8-review/axis1-money/constraint-probe.sh` | open | ревью-пак P8-REVIEW, ось 1 (проба агента, пере-проверена координатором; строка заведена по аудиту полноты) | | PD-396 | standards | info | `internal/pgstore/books.go:306`=`chunker_version = $4, parse_started_at = null,`, `internal/pgstore/sink.go:127`=`update books set chunker_version = $2 where id = $1`, `internal/ingest/resync.go:30`=`UnsignedBankTerms int `json:"unsigned_bank_terms"`` | **Мёртвые поля шва: у `books.chunker_version` ДВА писателя и НОЛЬ читателей, и это второй экземпляр класса, первый назвал оркестратор.** Колонку пишет интейк из манифеста движка (`FinishParse`) и пишет тейлер из хендшейка потока (`effect`); ни одного `select` по ней в зоне нет — грепом ноль. Сегодня это безвредно, но следствие названо ЗАРАНЕЕ, потому что оно семантическое, а не техническое: в день, когда читатель появится, РАСХОЖДЕНИЕ двух писателей (чанкер прогона против чанкера разбора) станет значением, и решать, какой из них правда, придётся задним числом — по колонке, у которой уже накоплена история из обоих источников. Родня — п.4 пинга оркестратора №19: `ingest.StatusReport.UnsignedBankTerms` разбирается из ответа движка и не используется НИ ОДНОЙ строкой продакшн-кода (грепом — только объявление и его доккомментарий, где поле описано как опора экрана подписи). Формулировка оркестратора применима дословно к обоим: мёртвое поле в структуре шва читается как контракт. ⚠ Заведено ОТДЕЛЬНОЙ строкой по прямому указанию закрывающего ревью старшей модели и с его же доводом: этой фразе НЕ место в `PD-166` — та строка про потерю значения между двумя автокоммитами, и её свойство построено; держать дефект-строку открытой как плейсхолдер несуществующей фичи есть ровно та патология «open, а лекарство построено», против которой пак завёл шестнадцать дописок. Диспозиция — вопрос владельца, а не зоны: либо назначить владельца колонки (один писатель), либо записать расхождение как ожидаемое до появления читателя Воспроизведение — две команды без конвейера (символ вертикальной черты в ячейку регистра не влезает): `grep -rn chunker_version platform/internal --include=*.go` даёт два `update` и ни одного `select`, `grep -rn UnsignedBankTerms platform/internal platform/cmd --include=*.go` даёт только объявление и его доккомментарий | open | ревью-пак P8-REVIEW (побочная находка сверки реестра, оформлена по указанию закрывающего ревью) | -| PD-378 | bug | info | `internal/pgstore/books.go:1053`=`u.RemainingPercent = int(balance * 100 / granted)`, `internal/httpapi/v0.go` `usageState`, канон `14-api-contract` `remaining_percent` | **`/v0/usage` отдаёт `remaining_percent` вне контрактных 0..100 и зажигает предупреждение «low» на полном счёте: `balance * 100` переполняет int64.** Порог измерен точно: баланс 92 233 720 368 547 758 микро ещё даёт 99%, следующий микро-доллар даёт минус 99. Ответ нарушает схему (`minimum: 0`, `maximum: 100`), и хуже того `usageState` видит отрицательное значение ниже порога `lowCredit` и отдаёт `state: "low"` — «денег почти нет» счёту на сто миллиардов. Замерено на проводе: до гранта `{"state":"ok","remaining_percent":96}`, после `grant --usd 100000000000` → `{"state":"low","remaining_percent":-84}`; соседняя арифметика (`pricing.Scale`, `balance`) при том же балансе отвечает верно, то есть переполнение локально именно в этой строке. ⚠ Рефутер сузил minor → info: чтобы туда попасть, оператор должен добавить на счёт не меньше 92.23 млрд долларов, ни одна пользовательская ручка кредит не пишет; прецедент веса — `PD-39`. ⚠ Оговорка рефутера в другую сторону: более правдоподобный носитель — не разовая команда, а конфиг `TM_PLATFORM_SIGNUP_GRANT_USD`, у которого верхней границы нет и значение НАМЕРЕННО не печатается в стартовый лог, так что промах в нём сломал бы `/usage` каждому новому аккаунту невидимо. Воспроизведение: `docs/p8-review/axis1-money/a1-usage-overflow.sh` (сам откатывает грант) | open | ревью-пак P8-REVIEW, ось 1 (живой провод, сужено рефутером с измеренным порогом) | +| PD-378 | bug | info | `internal/pgstore/books.go:1065`=`u.RemainingPercent = int(balance * 100 / granted)`, `internal/httpapi/v0.go` `usageState`, канон `14-api-contract` `remaining_percent` | **`/v0/usage` отдаёт `remaining_percent` вне контрактных 0..100 и зажигает предупреждение «low» на полном счёте: `balance * 100` переполняет int64.** Порог измерен точно: баланс 92 233 720 368 547 758 микро ещё даёт 99%, следующий микро-доллар даёт минус 99. Ответ нарушает схему (`minimum: 0`, `maximum: 100`), и хуже того `usageState` видит отрицательное значение ниже порога `lowCredit` и отдаёт `state: "low"` — «денег почти нет» счёту на сто миллиардов. Замерено на проводе: до гранта `{"state":"ok","remaining_percent":96}`, после `grant --usd 100000000000` → `{"state":"low","remaining_percent":-84}`; соседняя арифметика (`pricing.Scale`, `balance`) при том же балансе отвечает верно, то есть переполнение локально именно в этой строке. ⚠ Рефутер сузил minor → info: чтобы туда попасть, оператор должен добавить на счёт не меньше 92.23 млрд долларов, ни одна пользовательская ручка кредит не пишет; прецедент веса — `PD-39`. ⚠ Оговорка рефутера в другую сторону: более правдоподобный носитель — не разовая команда, а конфиг `TM_PLATFORM_SIGNUP_GRANT_USD`, у которого верхней границы нет и значение НАМЕРЕННО не печатается в стартовый лог, так что промах в нём сломал бы `/usage` каждому новому аккаунту невидимо. Воспроизведение: `docs/p8-review/axis1-money/a1-usage-overflow.sh` (сам откатывает грант) | open | ревью-пак P8-REVIEW, ось 1 (живой провод, сужено рефутером с измеренным порогом) | | PD-381 | hardening | info | `internal/auth/middleware.go:38`=`a.Deny.ServeHTTP(w, r)` и та же строка на `:52`, `internal/auth/cookie.go` `ClearSession` | **401 по мёртвой сессии не стирает куку: браузер продолжает слать отозванный токен до конца её `Max-Age` (по умолчанию 14 суток).** Обе ветки отказа зовут `a.Deny.ServeHTTP` и к `a.Cookies` не обращаются, перекрытия выше по стеку нет — живой 401 не несёт ни одной строки `Set-Cookie`. Норму формулирует сам код: комментарий `ClearLogin` говорит, что кука, пережившая свой круг, это «a replay waiting for an accident», а `PD-88` заведена ровно на тот исход, при котором кука переживает сессию. Дешёвое лечение — чистить куку на пути отказа, где она была предъявлена. Отдельно от `PD-88` (та про `Max-Age` меньше секунды) и от `PD-5`/`PD-70`/`PD-74`/`PD-103` Воспроизведение: `docs/p8-review/axis2-auth/csrf-matrix.sh` и `session-clocks-probe.sh` (обе пробы поднимают демон и печатают ПОЛНЫЕ заголовки ответа, включая отсутствие `Set-Cookie` на 401); проверка чтением — `grep -n 'Cookies' internal/auth/middleware.go`, ни одного вхождения на путях отказа | open | ревью-пак P8-REVIEW, ось 2 (чтение + живая проба, подтверждено рефутером) | | PD-382 | hardening | info | `internal/auth/cookie.go:52`=`func (c Cookies) ClearSession(w http.ResponseWriter) { c.set(w, c.SessionName(), "", -time.Second) }`, `internal/auth/cookie.go` `ClearLogin` | **Путь ИСТЕЧЕНИЯ куки не запинен: две мутации, стирающие выход из браузера, прошли батарею целиком.** `ClearSession` и `ClearLogin` — единственные места, где кука получает отрицательный `Max-Age`, и порча этого выражения ничего не роняет. ⚠ Рефутер поправил ЦЕНУ, названную первой редакцией находки: `set` пишет значение вызывающего, а обе `Clear`-ручки передают ПУСТУЮ строку, поэтому мутант не перевыпускает куку с живым токеном — он оставляет пустую куку, и следующий запрос всё равно приходит без сессии. То есть вреда сегодня нет, а не запинено СВОЙСТВО «выход удаляет куку из браузера», и это класс `PD-1`, родня `PD-86`/`PD-87`. Воспроизведение: `docs/p8-review/axis2-auth/mutations-axis2.sh` ⚠ **Пере-проверено на ПОЛНОЙ батарее координатором пака (мутации M13 и M14), и по дороге поймана СВОЯ ошибка метода:** первый прогон M13 дал красный, но упавшим оказался `TestAClaimThatLostARaceToAReleaseIsRetriedAndNotAnError` — известный флейк `PD-369`, к `ClearSession` отношения не имеющий. То есть вердикт, вынесенный по ЦВЕТУ батареи, а не по ТОПИЧНОСТИ упавшего теста, даёт ложное «пойман» и тихо теряет находку. Пере-прогон обеих мутаций даёт пустую дельту против чистой копии. Правило записано здесь, потому что цена его забывания — потерянная находка о недостающем пине: `docs/p8-review/mutations-round2.log` | open | ревью-пак P8-REVIEW, ось 2 (посадка мутации, цена поправлена рефутером) | | PD-383 | hardening | info | `cmd/tmplatformd/main.go:257`=`const loginJournalRetention = 180 * 24 * time.Hour`, `cmd/tmplatformd/main.go` `sweepLogins`, `internal/pgstore/identity.go` `DeleteOldLoginEvents` | **Ретенция журнала входов работает и не запинена ничем: и срок хранения, и сам свип переживают батарею.** Механизм построен (константа 180 суток, тикер 15 минут, `delete from login_events where at < $1`, монтируется в обеих ветках входа) и проверен ЖИВЬЁМ: строка возрастом 200 суток исчезла на ближайшем тике, демон напечатал `"login sweep" events=1`. Но ни срок, ни вызов не пинятся: `DeleteOldLoginEvents` не зовёт ни один тест, а `sweepLogins` — неэкспортируемая функция пакета `main` без теста. Класс `PD-1` на механизме, который ЛЕЧИТ уже закрытую строку. Воспроизведение: `docs/p8-review/pd23-retention-probe.sh` и `pd23-result.txt` ⚠ **Пере-проверено на ПОЛНОЙ батарее координатором пака (мутации M15 и M16):** срок хранения поднят со 180 суток до 180 ЛЕТ и, отдельно, предикат свипа обезврежен (`delete from login_events where at < $1 and 1=0`) — обе дельты против чистой копии ПУСТЫ на всех 18 пакетах. Лог — `docs/p8-review/mutations-round2.log` | open | ревью-пак P8-REVIEW, ось 2 (находка рефутера, живая проба координатора) | -| PD-388 | hardening | info | `internal/readmodel/readmodel.go:183`=`if deadline, ok := ctx.Deadline(); ok && time.Until(deadline) < MaterializeBudget {`, `cmd/tmplatformd/runner.go` `refreshSweepBudget`, `internal/books/parse.go` (запиненный близнец) | **Пара чисел в разных пакетах, не выводимая и не запиненная, и на неверной её стороне материализатор молча не делает ничего.** `Drain` начинает книгу, только если у прохода осталось не меньше ЦЕЛОГО `MaterializeBudget` (5 минут), а число прохода живёт в `cmd/tmplatformd` голым литералом 10 минут, без ссылки на константу, которую обязано превышать. Это единственный член семьи без страховки: `intakeSweepBudget` ВЫВЕДЕН формулой и разъехаться не может, а `claimGrace` и выведен, и запинен отдельным тестом. Цена неверной стороны — не деградация, а полное молчаливое отключение: замерено пробой, проход 4м59с даёт claims=0, вызовов движка 0 и nil, ошибки нет, лога нет, `sweep_unfinished_total` не растёт. Мутация «`refreshSweepBudget` 10м → 1м» пережила ПОЛНУЮ батарею. ⚠ Вторая половина, найденная рефутером: сам гейт бюджета между книгами — живой носитель ЗАКРЫТОЙ `PD-293`, чья эррата прямо на него ссылается, — не покрыт ни одним тестом (во всём дереве нет теста, который даёт `Drain` дедлайн), поэтому его можно выключить целиком, и батарея останется зелёной; точный близнец у интейка при этом запинен своим `TestAPassTooShortForAParseStartsNoneAtAll`. ⚠ Рефутер опроверг приписку финдера «проход по построению начинает максимум 2 книги из 4»: гейт сравнивает остаток перед КАЖДОЙ книгой, и при проходе 10 минут стартуют все четыре. Воспроизведение: `docs/p8-review/axis3-queue/probe_drain_budget_pair_test.go.txt` | open | ревью-пак P8-REVIEW, ось 3 (посадка мутации, расширено рефутером на носитель PD-293) | +| PD-388 | hardening | info | `internal/readmodel/readmodel.go:198`=`if deadline, ok := ctx.Deadline(); ok && time.Until(deadline) < MaterializeBudget {`, `cmd/tmplatformd/runner.go` `refreshSweepBudget`, `internal/books/parse.go` (запиненный близнец) | **Пара чисел в разных пакетах, не выводимая и не запиненная, и на неверной её стороне материализатор молча не делает ничего.** `Drain` начинает книгу, только если у прохода осталось не меньше ЦЕЛОГО `MaterializeBudget` (5 минут), а число прохода живёт в `cmd/tmplatformd` голым литералом 10 минут, без ссылки на константу, которую обязано превышать. Это единственный член семьи без страховки: `intakeSweepBudget` ВЫВЕДЕН формулой и разъехаться не может, а `claimGrace` и выведен, и запинен отдельным тестом. Цена неверной стороны — не деградация, а полное молчаливое отключение: замерено пробой, проход 4м59с даёт claims=0, вызовов движка 0 и nil, ошибки нет, лога нет, `sweep_unfinished_total` не растёт. Мутация «`refreshSweepBudget` 10м → 1м» пережила ПОЛНУЮ батарею. ⚠ Вторая половина, найденная рефутером: сам гейт бюджета между книгами — живой носитель ЗАКРЫТОЙ `PD-293`, чья эррата прямо на него ссылается, — не покрыт ни одним тестом (во всём дереве нет теста, который даёт `Drain` дедлайн), поэтому его можно выключить целиком, и батарея останется зелёной; точный близнец у интейка при этом запинен своим `TestAPassTooShortForAParseStartsNoneAtAll`. ⚠ Рефутер опроверг приписку финдера «проход по построению начинает максимум 2 книги из 4»: гейт сравнивает остаток перед КАЖДОЙ книгой, и при проходе 10 минут стартуют все четыре. Воспроизведение: `docs/p8-review/axis3-queue/probe_drain_budget_pair_test.go.txt` | open | ревью-пак P8-REVIEW, ось 3 (посадка мутации, расширено рефутером на носитель PD-293) | | PD-393 | hardening | info | `internal/metrics/metrics.go:187`=`if unfinished {`, `internal/metrics/metrics.go` (два счётчика из двенадцати коллекторов), `deploy/README.md` (рецепт алерта) | **Счётчиков событий на весь демон два, и оба отвечают не на тот вопрос: «ошибки» из четырёх золотых сигналов закрыты только для HTTP.** `sweep_unfinished_total` поднимается ИСКЛЮЧИТЕЛЬНО на `context.DeadlineExceeded`, поэтому проход, упавший обычной ошибкой, регистрируется как быстрый здоровый проход — замерено: 4 строки ERROR «runs sweep failed» в журнале и НИ ОДНОГО изменения в экспозиции, кроме счётчика длительности. Ни у чего остального счётчика нет вовсе: отказ спавна, неудача расчёта, карантин, ненулевой выход движка, упавший прогон. Всё состояние снимается гейджами раз в такт, поэтому событие, уместившееся между двумя проходами, в экспозиции не существует, и на вопрос «сколько прогонов сегодня упало» ответить нечем. Отдельная мелочь того же корня: `sweep_unfinished_total` — `CounterVec`, и пока он ни разу не вырос, семейства в экспозиции НЕТ вовсе, поэтому готовый рецепт алерта рантбука («растёт `tm_platform_sweep_unfinished_total`») даёт «no data», а не ноль; практика Prometheus велит инициализировать известные наборы лейблов нулём. ⚠ Речь о СЧЁТЧИКАХ СОБЫТИЙ, не о суммах денег: запрет `D39.84` на денежные числа в метриках соблюдён, проверено. Воспроизведение: `docs/p8-review/axis4-metrics/40-exposition-check.sh` | open | ревью-пак P8-REVIEW, ось 4 (живой замер, заголовок сужен рефутером) | | PD-394 | hardening | info | `internal/pgstore/credits.go:215`=`if spent < 0 {`, констрейнт `credit_ledger_sign` в `internal/pgstore/migrations/00007_credits.sql` | **Гард отрицательного расхода в `Settle` не запинен: снятие проходит ПОЛНУЮ батарею (18 пакетов).** Класс тот же, что у `PD-333`/`PD-334` — оговорка денежного пути, которую ни один тест не исполняет. Цена НАЗВАНА и она ограничена схемой, а не кодом: отрицательный `spent` дал бы `settlement` с положительной суммой, а это ловит констрейнт `credit_ledger_sign` — проверено прямым INSERT на стенде, Postgres отвечает `violates check constraint "credit_ledger_sign"`. То есть сегодня вреда нет, и защита ТРАНЗИТИВНА: держит её схема, а не гард, который для этого написан. Родня `PD-86` (там потолок сессии держится через соседнюю функцию). Достижимость самого отрицательного значения сегодня нулевая — единственный источник `attemptSpend` клампит в ноль, и этот кламп запинен. Воспроизведение: `docs/p8-review/plant.py` (мутация M4) и `mutations-full.log` ⚠ Улика воспроизводима из артефактов: `plant.py` знает эту мутацию под именем M4, лог — `docs/p8-review/mutations-full.log` | open | ревью-пак P8-REVIEW, ось 1 (посадка мутации координатора) | | PD-395 | doc | info | `internal/gates/contract_test.go:14`=`const canonPath = zoneRoot + "/../docs/architecture/14-api-contract/openapi.yaml"`, `docs/ENGINEERING_STANDARDS.md` §3, промты ревью-паков зоны | **Копия `platform/`, вынутая из репозитория, даёт красную батарею по причине, не имеющей отношения к коду.** Гейт контрактной версии читает канон по пути ВЫШЕ модуля (`internal/gates/contract_test.go:14`=`const canonPath = zoneRoot + "/../docs/architecture/14-api-contract/openapi.yaml"`) и при его отсутствии `t.Fatalf`, а не сообщает, что запущен вне репозитория. Стоило времени КАЖДОМУ, кто работал в копии — все девять субагентов пака и координатор, — и один агент едва не завёл ложный красный находкой. ⚠ **САМОЕ ОСТРОЕ следствие, найденное ревью старшей моделью и координатором пропущенное:** в голой копии становятся НЕСУДИМЫ мутации самой `ContractVersion` — базовый красный того же теста маскирует дельту, вердикт выходит «ВЫЖИЛА», и протокол произвёл бы ЛОЖНУЮ находку «константа не запинена». То есть цена не только во времени. ⚠ **ДИСПОЗИЦИЯ ПЕРЕСМОТРЕНА 24.08 после ревью старшей моделью, и первая редакция этой строки целилась НЕ ТУДА** (адресовала гейт вместо рецепта). Три ошибки первой редакции сняты проверкой: **(1)** носители названы шире, чем есть — `ENGINEERING_STANDARDS` §3 копий на момент находки НЕ требовал (`grep -ci 'копи'` давал 0; ПОСЛЕ правки этого пака даёт 6 — рецепт туда и записан, см. ниже), норма копий живёт только в промте ревью-пака и в `D39.113`; значит сталкиваются не гейт и стандарт зоны, а гейт и РЕЦЕПТ ОДНОГО ПРОМТА. **(2)** «точечное решение, а не стиль» — НЕВЕРНО: в `backend/internal/standdata/standdata.go` живёт целый репо-паттерн чтения выше корня модуля с поиском корня по МАРКЕРУ и env-переопределением, и его потребители читают даже чужую зону (`internal/miner/miner_parity_test.go` читает `eval/`). Платформа реализовала тот же паттерн грубее — голым счётом `..`, — и комментарий `standdata` объясняет, чем именно это хуже. **(3)** довод «модуль обязан быть самодостаточным» к этой зоне не применяется: её собственный DoD определяет полную приёмку через ТРИ ВНЕШНИХ условия. Ценность зоны — не герметичность, а громкий учёт непроверенного. **ЛЕЧЕНИЕ — РЕЦЕПТ, А НЕ ГЕЙТ, и оно проверено исполнением:** `cp -a --parents platform docs/architecture/14-api-contract <куда>/` даёт в копии `ok textmachine/platform/internal/gates`, тогда как голая копия даёт `FAIL … the ratified canon could not be read`. Одна строка. Гейт сохраняет зубы во ВСЕХ средах, мутации `ContractVersion` становятся судимыми, а забытый рецепт ломается ГРОМКО, а не тихо — решающее свойство для гейта, рождённого из «a human noticing failed twice». Второй, необязательный шаг: резолвить канон маркер-обходом по образцу `standdata.Root()` и дописать в сообщение `Fatalf` вторую гипотезу «ты вне репозитория» — это убивает стоимость повторной диагностики и остаётся падением, а не скипом. **ОТВЕРГНУТО с доводами:** гейт-переменная батареи с названным скипом (вне `make` гейт выключался бы сам — зона уже осудила эту форму словами `internal/gates/toolchain_test.go:55-57` «the Makefile is a convenience… a build that skips the battery gets a toolchain the battery would have refused»; плюс это легализует поломку рецепта как «условие среды») · переезд проверки на репо-уровень в `counts.py` как ЗАМЕНА (единственная репо-точка принуждения НИКОГДА не блокирует, только предупреждает — гейт остался бы без зубов; как ВТОРАЯ сеть законен) · кодоген из спеки (посылка протухла: `oapi-codegen` пере-подписан `D39.132` в КАНДИДАТА и P7 решил НЕ БРАТЬ с доводом; и класс он не убивает, а переносит — генерат коммитится внутрь модуля, то есть второй источник истины) · вендорить снапшот канона в модуль (то же самое) ⚠ **Оговорка к слову «ГРОМКО», без которой оно обманывает:** голая копия ломается громко только В ПАРЕ с правилом «вердикт судится по ТОПИЧНОСТИ упавшего теста, а не по цвету батареи». Без этого правила голая копия плюс суд по цвету по-прежнему дают ложную выжившую `ContractVersion`. Правило записано здесь, а не только в отчёте, потому что пак переживёт именно строка ⚠⚠ **ВТОРАЯ ошибка первой редакции лечения, найденная ЗАКРЫВАЮЩИМ ревью старшей модели и уже исправленная: лечение было припарковано в САМОМ ЭФЕМЕРНОМ носителе.** Рецепт правился только в промте этого пака — а промты паков после лендинга АРХИВИРУЮТСЯ (в `platform/docs/archive/` их уже шесть). Долговечные носители при этом были пусты: `ENGINEERING_STANDARDS` §3 копий не упоминал вовсе, `D39.113` знает изоляцию, но не путь канона. Следующий ревью-промт пишется из НОРМ, а не из архивного промта, значит ловушка взводилась бы заново и цена «пять агентов и координатор» платилась бы второй раз. Это тот же класс, что девять ⚠-дописок этого пака: лекарство есть, а знание о нём живёт не там, где его будут искать. **Исправлено: рецепт вместе с доводом про маскировку дельты и правилом топичности вердикта записан пунктом 3 в `ENGINEERING_STANDARDS` §3** — свой зонный док, долговечный носитель. Промт пака поправлен тоже, но теперь он дублёр, а не единственный носитель | open | ревью-пак P8-REVIEW (координатор пака, цена замерена этой же сессией) | -| PD-281 | bug | info | `internal/pgstore/readmodel.go` `runProgress` | **Полоса прогона над книгой, уже полной в считаемом проходе, стоит на `0/N` и не достигает единицы** — против канона §Progress («the fraction always reaches one»). Штатный цикл: книга доведена до конца → пользователь подписал решения → запустил прогон, чтобы движок их применил → прогресс платной работы невидим до `ready`. Остаток подхода «числитель считает ГЛАВЫ, законченные проходом», а не регрессия: до правки PD-263 бар был неверен в другую сторону. Лечение требует продуктового решения (считать главы, ПЕРЕразрешённые после `started_at`), а не тихой правки | open | приёмка правок P7 (fable-5) | +| PD-281 | bug | info | `internal/pgstore/readmodel.go` `runProgress` | **Полоса прогона над книгой, уже полной в считаемом проходе, стоит на `0/N` и не достигает единицы** — против канона §Progress («the fraction always reaches one»). Штатный цикл: книга доведена до конца → пользователь подписал решения → запустил прогон, чтобы движок их применил → прогресс платной работы невидим до `ready`. Остаток подхода «числитель считает ГЛАВЫ, законченные проходом», а не регрессия: до правки PD-263 бар был неверен в другую сторону. Лечение требует продуктового решения (считать главы, ПЕРЕразрешённые после `started_at`), а не тихой правки ⚠ **Дописка пака P9 (27.08), диспозиция: НЕ ТРОНУТА.** Сквозная полоса (строка 200) переписала `runProgress` в `runDone`/`runTotal`, но ровно для сценария этой строки — книга полна в считаемом (последнем) проходе, значит и в черновом — ничего не меняет: обе базы равны счёту глав, `draftWork = C`, полоса стоит `0/2C` и до единицы не доходит; числитель по-прежнему считает завершения глав от баз прогона. Знаменатель сменился только для промежуточного класса «черновик впереди редактуры», который эта строка не описывает. Живой факт того же пробоя: старт нового прогона над ПОЛНОЙ книгой сегодня вообще отвечает 409 (шкале нечего продать, `ChaptersLeft = 0`) — то есть у правки готовой книги нет и входа, которым её сложили бы в прогон; это смежный продуктовый вопрос той же строки. Лечение прежнее — продуктовое решение «считать пере-разрешения после `started_at`». Канонному минору к полосе НЕ наследовать обещание «the fraction always reaches one» без этой оговорки | open | приёмка правок P7 (fable-5) | | PD-297 | bug | info | `internal/pgstore/readmodel.go` `writeChapters`/`writeUnits` | **Материализация дерева делает один round-trip на СТРОКУ под эксклюзивной блокировкой книги** — на корпусной книге (2283 главы, ~7 тыс. пар) это ≈11 тыс. последовательных обращений, и всё это время за блокировкой стоят `emitFrame` потока, `StartRun` и фолд юнитов. Штатный инструмент — `tx.SendBatch` (pgx v5, уже драйвер модуля) или `CopyFrom` во временную таблицу. НЕ сделано осознанно: рефутеры первой приёмки понизили до DOUBT/LOW, цена не замерена на форме этого деплоя (unix-сокет против управляемого PG по TCP — разница на два порядка), а путь — самый опасный на запись. Мерить прежде правки: время удержания блокировки на 2283-главной книге до и после ⚠ **P8-FIX: НЕ ВЗЯТ, причина названа и она не «не успели».** Сама эта строка объявляет замер на здешнем стенде НЕпредставительным (unix-сокет против управляемого PG по TCP — разница на два порядка), а корпусной книги нет: она появляется на холодном прогоне движка, которым гейчена строка 202 единого бэклога (решение владельца 20.08). Мерить нечем и не на чем, а правка самого опасного на запись пути без замера — ровно то, что эта строка запрещает. Берётся вместе с холодным прогоном | open | доработка 20.08 (сверка находок против дерева) | | PD-298 | bug | info | `internal/pgstore/readmodel.go` `ListNotes`, `internal/pgstore/sink.go` `unitDone` | **Снятие флага с замечания дельта-чтение выразить не может.** Резолюция, пере-разрешённая как не-`flagged` (редрайв), обновляет строку и двигает `revision`, но дельта фильтруется предикатом `ur.flagged` — строка не возвращается, и клиент никогда не узнаёт, что замечание снято: оно остаётся на экране навсегда. Канон §AfterVersion: «A DELETION cannot be expressed this way», и требует одного из двух ответов — `resync_required` либо `400 version_too_old`; здесь не даётся ни один. ⚠ НЕ подтверждено, что движок вообще пере-издаёт `unit_done` для той же тройки (глава, юнит, волна) с `flagged=false` — комментарий `sink.go` это УТВЕРЖДАЕТ («a redrive re-attacks a flagged one»), но чтением движка не сверено. Порядок: сначала сверка у движка, потом либо счётчик замены для замечаний, либо строка «переход недостижим» ⚠ **ДИСПОЗИЦИЯ АКТА 5 (сверка с движком контрактной сессией 20.08): переход НЕДОСТИЖИМ и механизма не строим.** Движок объявляет вердикт юнита один раз на волну, его announce-once-леджер не пере-announce-ит, поэтому единственная пере-доставка — ТОТ ЖЕ вердикт. Инвариант записан в коде (`sink.go` `unitDone`): если что-то научится снимать флаг, фолд обязан выдать кадр — иначе счётчик разъедется со списком молча. Строка держится открытой этим долгом, а не живым дефектом | open | приёмка P7 → доработка 20.08 (сверка) | | PD-299 | standards | info | `internal/httpapi/conditional.go` `acceptsGzip` | **`Accept-Encoding: identity;q=0` не отвечает `406`.** Клиент, потребовавший ЛЮБОГО кодирования кроме identity, получает identity. Половина RFC 9110 §12.5.3, которую правка PD-268 не закрыла: gzip-сторона (именованное кодирование выигрывает у `*`, нулевой вес — отказ) закрыта и пиньётся, эта — нет. Достижимо только специально сконструированным клиентом; ни один генерённый по контракту клиент так не делает | open | доработка 20.08 (сверка находок против дерева) | @@ -133,6 +146,8 @@ | PD-220 | hardening | info | `internal/config/config.go` `Load` | **Резерв имени `dev` сравнивается байт-в-байт:** `TM_PLATFORM_OIDC_PROVIDER=Dev` проходит гейт. Сегодня инертно, и ровно по той же причине: Postgres сравнивает `identities.provider` тоже байт-в-байт, поэтому в пространство имён дев-входа такой издатель не попадает. Станет опасным в день, когда сравнение личности станет регистронезависимым | open | приёмка P6 (дофикс, ФП-7) | | PD-221 | bug | info | `internal/runs/spawn.go` `engineStreamID`, `internal/pgstore/runs.go` `EngineStreamID` | **Имя потока переиспользуется при повторном спавне ТОЙ ЖЕ попытки:** оно детерминировано по паре (прогон, номер попытки), а движок отвергает id, который уже писал события этой книги, и чеканит свой (`store.EventsUsed`, `pipeline/events.go`). Тогда платформа не узнаёт собственный поток и живёт на медленном ре-синке — свежесть, не деньги. Ре-спавн одной попытки бывает после отданной назад заявки на спавн | open | приёмка P6 (дофикс, ФП-7) | | PD-222 | standards | info | `cmd/tmplatformctl/seed.go` | **HTTP-променад сида не покрыт тестом:** вход, грант, загрузка и ожидание интейка проверены только живым прогоном на стенде (P6), автоматически — лишь разбор аргументов (`TestASubjectThatIsOnlyPaddingIsNoSubject`). Дев-инструмент, но именно он — единственный потребитель контракта в репозитории, и его поломка видна только тому, кто поднимет стенд | open | приёмка P6 (дофикс, ФП-7) | +| PD-400 | doc | info | `internal/runs/bank.go` `bankVerdict` (класс 12) и шапка файла, `internal/runner/engine.go` `Manifest`/`Export` | **Слово `run_in_flight` у двери правок покрывало больше, чем прогон; пер-книжный мьютекс внутрипроцессный.** Разобрана на две половины по слову владельца 27.08 («техдолг в паке не держим»), диспозиция каждой явная. **(1) ЗАКРЫТА этим же деревом:** раскладка слова сделана точной по построению — `run_in_flight` отвечается ТОЛЬКО из проверки собственной строки прогона под мьютексом, а класс 12 от самого глагола под тем же мьютексом прогоном быть НЕ МОЖЕТ (Start/Resume ждут этот мьютекс, реконсилер рестартует только живые строки, которые проверка видит) — это транзиентный держатель флока (границная материализация, ручной tmctl), и он едет `503 service_unavailable` «занято, повтори позже», а не словом про прогон, которого нет; пин — кейс «a held project = a transient holder» в `TestBankVerdictKeepsTheRemediesApart`. **(2) ГРАНИЦА v1, названная с условием и ценой** (на перевод в «Принятый риск» словом лендинга; ⚠ цена ПЕРЕ-ОПИСАНА по воркфлоу-ревью 28.08 и слову оркестратора — прежняя редакция говорила «ложного слова нет», и это неверно): мьютекс `runs.Service.books` — внутрипроцессный; вторая реплика платформы над одним хранилищем сужает сериализацию до пер-репличной. Цена ДВУСТОРОННЯЯ: (а) translate, заспавненный в флок глагола, умирает холостой попыткой — деньги и данные целы; (б) обратная сторона ТОЙ ЖЕ гонки: глагол проигрывает флок НАСТОЯЩЕМУ прогону, который соседняя реплика допустила своим мьютексом по чистой на тот миг таблице, — и класс 12 тогда отвечается `503` «транзиентный держатель, повтори позже» про живой многочасовой прогон, то есть ЛОЖНЫМ словом (канонно верное там — `409 run_in_flight`). Принятие риска включает эту ложь, а не только холостую попытку; оговорка внесена и в комментарий ветки класса 12 (`bank.go`). Условие: граница ПЕРЕСТАЁТ держать в день второй реплики; лечение тогда — арбитр в хранилище, не больший мьютекс. Сегодняшний деплой однорепличный по всей зоне (in-memory `resynced` в том же сервисе — тот же допуск) | open (половина 1 закрыта; половина 2 — на перевод в принятый риск) | пак P9: опровергатель раскладки кодов (находка 2) · разбор по слову владельца 27.08 · воркфлоу-ревью 28.08 (линза code-layout): цена включает ложное слово | +| PD-408 | doc | info | `internal/runs/bank.go` (бюджет двери = `s.runBudget()`), `internal/runner/bankapply.go` (`errOut` без лимита; `Stderr: firstLine`) | **Две операционные оговорки двери правок, названные воркфлоу-ревью; обе — цена конфигурации, не дефект пути.** (1) Бюджет двери — та же ручка `TM_PLATFORM_RUN_BUDGET`, что у прохода свипа; движок выбирал потолок 5000 решений против ЖЁСТКИХ 60 с («пять раз внутри бюджета»), и оператор, понизивший ручку (к чему соседние комментарии подталкивают), делает легальный документ-максимум навсегда неприменимым — вечный `503` вместо «разбей документ»; связка ручки и капа нигде не названа. (2) stderr глагола читается в НЕограниченный `bytes.Buffer`, хотя потребляется только первая строка, — не-тот бинарь по сконфигурированному пути (полудеплой, обёртка) может раздуть демона до OOM за 60-секундный бюджет; stdout той же команды капнут 64 МиБ | open | воркфлоу-ревью P9 28.08 (линзы door:lock-lifecycle · door:crash-windows), диспозиция оркестратора 28.08: строкой | ## Принятый риск @@ -296,7 +311,7 @@ | PD-155 | doc | info | `deploy/README.md` | **Смена `TM_PLATFORM_STATE_DIR` осиротляет exit-маркеры идущих прогонов:** маркер пишется по пути, вычисленному при спавне, а читается по пути из текущей конфигурации, поэтому после смены каталога конец прогона невидим и прогон перезапускается — **закрыто:** строка в `deploy/README.md` — менять каталог только при отсутствии живых прогонов | fixed(дофикс P4, дерево сессии) | приёмка P4 (N4) | | PD-156 | bug | info | `internal/runs/spawn.go` `spawnAttempt` | **Отказ ДЕПЛОЯ проверялся после вызова движка.** Дофикс перенёс чтение денежного отсчёта в начало спавна и тем поставил его ПЕРЕД дешёвыми отказами «нечем записать конец юнита» / «нечем передать потолок»: инстанс с неполной конфигурацией платил бы секундами CPU движка (`status --json` пере-нарезает исходник, строка 100) за каждый прогон на каждом свипе, чтобы прийти к ответу, зависящему только от конфигурации — **закрыто:** `runs.runnable()` вызывается первым в `spawnAttempt`, `spec` и `Start`; пин `TestAMisconfiguredDeploymentIsRefusedWithoutAskingTheEngine` (посадка «убрать ранний отказ» падает) | fixed(дофикс P4, дерево сессии) | собственная сверка диффа дофикса | | PD-158 | bug | minor, деньги | `internal/runs/spawn.go` `meter.bookCap` | **Формула потолка из пинга ратификации (`committed + reserved (из status --json) + прирост`; сам D39.122 §2в ратифицирует ОБЯЗАННОСТЬ платформы пересчитывать «прирост → абсолют», буквальной формулы в решении нет — овер-атрибуцию поправило ревью доков) даёт прогону запас БОЛЬШЕ его холда, когда предыдущий процесс умер с незакрытой резервацией.** Найдено сверкой формулы с кодом движка: `store.Open` — путь ЗАПИСИ, которым идёт каждый `translate` — выполняет `recoverReservations` и обнуляет `reserved_usd` книги ДО первой судимой резервации (`backend/internal/store/store.go:88` и `:214`); `tmctl status` читает read-only и этот проход намеренно не делает (`store.go:108` говорит об этом прямо). Значит цифра, которую видит платформа, — ОСТАТОК мёртвого процесса, и к моменту сравнения её уже нет: движок остановится позже на её величину, расчёт упрётся в потолок холда, а леджер запишет «capped at the hold», как будто перерасходовал движок. Величина ограничена размером остатка (обычно одна оценка вызова), но путь достижим на каждом резюме после падения. **Сделано: `bookCap` считает `committed + прирост`** — отклонение в консервативную сторону (более узкий потолок может только остановить прогон раньше, перерасхода не даёт), названо в коде и запинено `TestAResumeIsGivenACapComputedFromTheMeterAsItStandsNow` (второе утверждение: остаток НЕ раздул потолок). Сама цифра по-прежнему читается и обязательна (отсутствие ≠ ноль) — она и есть доказательство, что отклонение безопасно: на спавне другого писателя нет (эксклюзивный лок + один живой прогон на книгу), значит любой `reserved` — остаток. ⚠ **Строка остаётся ОТКРЫТОЙ как вопрос:** отклонение от ратифицированной формулы — не право зоны, нужен ответ оркестратора (принять формулу в виде `committed + прирост` либо назвать другой разбор) **ЗАКРЫТО РАТИФИКАЦИЕЙ (оркестратор №15, 09.08, ре-чек V2):** принята формула `committed + прирост`; аргументация подтверждена оркестратором исполнением обеих формул против гейта движка — ратифицированная переплачивала запасом ровно на leftover-reserved | fixed(ратификация 09.08) | собственная сверка формулы с кодом движка при F1 | -| PD-159 | bug | **major, деньги** | `internal/runs/reconcile.go` `settle`, `internal/pgstore/runs.go` `SpendBound` | **Отложенный расчёт прогона оплачивал работу СЛЕДУЮЩЕГО прогона той же книги, и тот платил за неё ещё раз.** Расчёт читает пожизненный счётчик КНИГИ в момент ПОВТОРА, а откладываться он вправе (движка не спросить). Завершённый-но-нерассчитанный прогон при этом не мешает новому: `HasLiveRun` смотрит только на `finished_at`. Замерено: прогон, стоивший $0.10, списан на $2.10 — своя трата плюс всё, что успел потратить преемник, — после чего преемник заплатил ту же сумму снова; переплата ограничена холдом. Найдено ДВУМЯ независимыми верификаторами самопроверки, каждый воспроизвёл исполнением, и третий раз воспроизведено мной перед починкой — **закрыто:** `SpendBound` — наименьшая базовая линия среди попыток этой книги, стартовавших ПОЗЖЕ; она снята до того, как та попытка что-либо добавила, и после того, как эта остановилась, поэтому является точной верхней границей. Расчёт берёт минимум из неё и текущего счётчика. Пин: `TestADeferredSettlementIsNotChargedForTheNextRunOfTheSameBook` (первый платит $0.10, второй — свои $2.00, баланс и леджер сходятся) ⚠ **ПАК P8-REVIEW 24.08: пин этой строки доказывает свойство СЛАБЕЕ, чем она гласит.** Формула «НАИМЕНЬШАЯ базовая линия среди попыток, стартовавших позже» не исполняется: названный пин кладёт РОВНО ОДНУ более позднюю попытку, а на множестве из одного элемента `min` и `max` совпадают, и мутация `min` → `max` в `internal/pgstore/runs.go:588` проходит батарею. Живой пробел вынесен строкой `PD-376` с готовым пином; статус этой строки паком НЕ менялся — если приёмка считает, что правило `PD-1` требует пере-открытия, это одна правка, улика уже на месте ⚠ **ОСПОРЕНО(PD-376)** — греппируемый токен по предложению ревью старшей моделью: спор о том, закрыта ли эта строка, иначе живёт только прозой и тихо истекает, если приёмка пропустит вопрос. | fixed(дофикс P4, дерево сессии) | самопроверка дофикса (два верификатора, независимо, исполнением) | +| PD-159 | bug | **major, деньги** | `internal/runs/reconcile.go` `settle`, `internal/pgstore/runs.go` `SpendBound` | **Отложенный расчёт прогона оплачивал работу СЛЕДУЮЩЕГО прогона той же книги, и тот платил за неё ещё раз.** Расчёт читает пожизненный счётчик КНИГИ в момент ПОВТОРА, а откладываться он вправе (движка не спросить). Завершённый-но-нерассчитанный прогон при этом не мешает новому: `HasLiveRun` смотрит только на `finished_at`. Замерено: прогон, стоивший $0.10, списан на $2.10 — своя трата плюс всё, что успел потратить преемник, — после чего преемник заплатил ту же сумму снова; переплата ограничена холдом. Найдено ДВУМЯ независимыми верификаторами самопроверки, каждый воспроизвёл исполнением, и третий раз воспроизведено мной перед починкой — **закрыто:** `SpendBound` — наименьшая базовая линия среди попыток этой книги, стартовавших ПОЗЖЕ; она снята до того, как та попытка что-либо добавила, и после того, как эта остановилась, поэтому является точной верхней границей. Расчёт берёт минимум из неё и текущего счётчика. Пин: `TestADeferredSettlementIsNotChargedForTheNextRunOfTheSameBook` (первый платит $0.10, второй — свои $2.00, баланс и леджер сходятся) ⚠ **ПАК P8-REVIEW 24.08: пин этой строки доказывает свойство СЛАБЕЕ, чем она гласит.** Формула «НАИМЕНЬШАЯ базовая линия среди попыток, стартовавших позже» не исполняется: названный пин кладёт РОВНО ОДНУ более позднюю попытку, а на множестве из одного элемента `min` и `max` совпадают, и мутация `min` → `max` в `internal/pgstore/runs.go:595` проходит батарею. Живой пробел вынесен строкой `PD-376` с готовым пином; статус этой строки паком НЕ менялся — если приёмка считает, что правило `PD-1` требует пере-открытия, это одна правка, улика уже на месте ⚠ **ОСПОРЕНО(PD-376)** — греппируемый токен по предложению ревью старшей моделью: спор о том, закрыта ли эта строка, иначе живёт только прозой и тихо истекает, если приёмка пропустит вопрос. | fixed(дофикс P4, дерево сессии) | самопроверка дофикса (два верификатора, независимо, исполнением) | | PD-160 | standards | minor (пин) | `internal/runs/reconcile.go` `drainJournal` | **Проводка «транзиентный сбой НЕ карантинит» не пинилась: пин стоял на чистой функции `quarantines`, а удаление ветки, которая её ВЫЗЫВАЕТ, переживало батарею.** Регрессия этой формы тихо карантинит проекцию живого платящего прогона — то есть ровно тот дефект, который F2 объявил закрытым — **закрыто:** `TestADeadlockDoesNotStopTheProjection` гонит НАСТОЯЩИЙ дедлок через весь путь (транзакция берёт строки в обратном порядке, Postgres рвёт цикл; раунд, где жертвой стала наша сторона, и есть предмет теста) и утверждает на КАЖДОМ раунде, что попытка не в карантине. Посадка «убрать ветку» падает за 1.9 с | fixed(дофикс P4, дерево сессии) | самопроверка дофикса (посадка) | | PD-161 | bug | minor, деньги | `internal/pgstore/runs.go` `RecordSpawn` | **Повторная заявка права на спавн ПЕРЕЗАПИСЫВАЛА базовую линию.** «Не удалось создать юнит» — не то же, что «юнит не создан»: `systemd-run`, убитый по таймауту ПОСЛЕ подачи запроса, рапортует ошибку и оставляет движок работать. Право отдаётся обратно (`ReleaseSpawnClaim`), следующий свип заявляет его снова и кладёт в базовую линию счётчик, который этот же движок двигает, — попытка потом оплачивает разницу от цифры, уже включающей её собственную работу (недоплата, которую никто не ищет) — **закрыто:** повторная заявка сохраняет и базовую линию, и записанный аргумент потолка (`coalesce` / `case when`), там где юнит действительно не создан значения совпадают. ⚠ **Ре-чек V2 показал, что этим строка закрыта НАПОЛОВИНУ:** в БД значения сохранялись, а движку на ретрае уходил потолок, пересчитанный по СВЕЖЕМУ счётчику — то есть включающий трату собственного «призрака», — и форензик-колонка 00011 на этом пути лгала (замерено приёмкой: handed 3.400000 против stored 3.000000). Закрыто по-настоящему: на ретрае (`SpendBaseline != nil && CeilingArg > 0`) спавн ПЕРЕДАЁТ сохранённый аргумент, а не пересчитанный. Пин: `TestAReclaimedAttemptKeepsTheBaselineItFirstRecorded` — теперь утверждает и базовую линию, и равенство handed == stored | fixed(дофикс P4, дерево сессии) | самопроверка дофикса (ревью вне карты, чтением) | | PD-167 | doc | info | `internal/pgstore/migrations/00009_runner.sql:37-39` | Комментарий DDL обещает «хинт, не ведущий к `seq = last_seq + 1`, отбрасывается, и файл перечитывается с начала» — код так не делает: пропасть ведёт к карантину проекции и переходу на ре-синк. Расхождение док↔код, поведение верное — **закрыто ре-чеком V2:** комментарий приведён к тому, что делает тейлер. ⚠ Ре-чек назвал эту строку «застывшим комментарием миграции 00011»; предмет строки — комментарий 00009, а 00011 нёс отменённую формулу потолка и исправлен вместе с ней (обе миграции этим паком и написаны, нигде не применялись, поэтому их отпечатки в `migrations.sha256` обновлены с явной причиной в шапке файла) | fixed(ратификация + дофикс V2, дерево сессии) | самопроверка дофикса (ревью вне карты) | diff --git a/platform/docs/STACK_DECISIONS.md b/platform/docs/STACK_DECISIONS.md index 201b447f..1290a5f3 100644 --- a/platform/docs/STACK_DECISIONS.md +++ b/platform/docs/STACK_DECISIONS.md @@ -434,6 +434,11 @@ callback не `http://`, — отказ на старте: §30) · Дев-стенд: `TM_PLATFORM_DEV_LOGIN` (личность дев-входа; вместе с OIDC — отказ на старте, §30). Наблюдаемость: `TM_PLATFORM_METRICS_ADDR` (дефолт `127.0.0.1:9464`; пусто = метрики не отдаются). Раннер: `TM_PLATFORM_ENGINE_BIN` (версионированный путь tmctl; пусто = инстанс только читает) · +`TM_PLATFORM_ENGINE_KEYS_PATH` (только АБСОЛЮТНЫЙ путь к файлу провайдерских ключей деплоя, +KEY=VALUE; едет движку АРГУМЕНТОМ `--keys-file` на `translate` — строка 211, ключи не проходят ни +через процесс платформы, ни через окружение юнита; пусто = флаг не передаётся и движок зависит от +`.env` рядом с book.yaml, которого SaaS-путь не пишет — WARN на буте; ⚠ суффикс `_PATH`, потому что +`*_FILE` в этой зоне значит «файл со ЗНАЧЕНИЕМ секрета», а тут значение — сам путь) · `TM_PLATFORM_STATE_DIR` (только АБСОЛЮТНЫЙ путь: маркер пишет юнит из каталога книги, читает демон из своего — относительный назвал бы два разных файла; отказ на буте) · `TM_PLATFORM_CTL_BIN` · `TM_PLATFORM_ENGINE_CEILING_ARG` (шаблон с `{{usd}}`; ДЕФОЛТ — `--ceiling-usd {{usd}}`, залендённая diff --git a/platform/docs/p9/door-live-probe.md b/platform/docs/p9/door-live-probe.md new file mode 100644 index 00000000..f8f734f7 --- /dev/null +++ b/platform/docs/p9/door-live-probe.md @@ -0,0 +1,169 @@ +# P9 — транскрипт живого пробоя двери правок банка (27.08.2026) + +Требование §4.2 промта пака: дверь пробита ЖИВЫМ стендом против НАСТОЯЩЕГО движка. Ниже — команды +и СЫРЫЕ фрагменты их вывода из сессии, дословно (норма «заявление = команда»; вторая редакция — +первая была пересказом, что приёмка справедливо назвала). + +## Стенд + +Демон и движок собраны из дерева сессии; свои порты/БД/каталоги, чужой стенд не тронут: + +```sh +W=~/tm-p9-work +cd backend && go build -o $W/tmctl ./cmd/tmctl +cd platform && go build -o $W/tmplatformd ./cmd/tmplatformd && go build -o $W/tmplatformctl ./cmd/tmplatformctl +~/.local/pgsql/bin/psql -h 127.0.0.1 -p 5432 -U postgres -d postgres -c "create database tmp9stand" +# env: TM_PLATFORM_DSN=…/tmp9stand · _MIGRATE=1 · _ADDR=127.0.0.1:8123 · _DEV_LOGIN=p9dev · +# _INSECURE_COOKIES=1 · _BOOKS_DIR=$W/stand/books · _STATE_DIR=$W/stand/state · +# _ENGINE_BIN=$W/tmctl · _BOOK_TEMPLATE=$W/book-template.yaml · _CTL_BIN=$W/tmplatformctl · +# _LANGUAGE_PAIRS='zh>ru' · _METRICS_ADDR=off · _ENGINE_KEYS_PATH=/backend/.env +nohup $W/tmplatformd > $W/stand/daemon.log 2>&1 & +curl -s --noproxy '*' http://127.0.0.1:8123/readyz # → ready +$W/tmplatformctl seed --url http://127.0.0.1:8123 +``` + +Вывод seed (дословно): + +``` +account u_QNMBL43CZVBE42IJ (identity dev/p9dev) +credit granted 25.000000 to u_QNMBL43CZVBE42IJ (key seed-u_QNMBL43CZVBE42IJ); balance is 25.000000 +book bk_QLS63SDD3NWF2YUV (parsing) +intake not_started, 3 chapters +``` + +## Почему провайдер — локальная $0-заглушка + +Первый прогон демо-книги упал классом 10; журнал юнита (дословно): + +``` +tmctl[823168]: tmctl: missing API keys (fill in backend/.env): +tmctl[823168]: - provider deepseek: env DEEPSEEK_API_KEY is not set (needed for model deepseek-v4-flash) +``` + +Механика `--keys-file` при этом доказана двумя $0-пробами на КОПИИ книги (значения ключей в сессию +не читались — гардрейл `.env` цел): + +``` +$ tmctl translate --config book.yaml --keys-file /nonexistent/keys.env --ceiling-usd 0.05 +tmctl: --keys-file /nonexistent/keys.env cannot be read: open /nonexistent/keys.env: no such file or directory +$ echo "DEEPSEEK_API_KEY=sk-dummy-p9-probe" > keys-dummy.env && tmctl translate … --keys-file keys-dummy.env … +… msg="calling model" model=deepseek-v4-flash attempt=0 … # пре-флайт пройден, файл загружен +``` + +Пробы остальных провайдеров тем же способом (`--ceiling-usd 0.000001`: отказ резервации ДО вызова, +`committed=$0.000000 reserved=$0.000000`, exit 10 = ключа нет): + +``` +gemini-3.1-pro-preview EXIT=10 :: … env GEMINI_API_KEY is not set … +mistral-large-2512 EXIT=10 :: … env MISTRAL_API_KEY is not set … +gpt-5-mini EXIT=10 :: … env OPENAI_API_KEY is not set … +``` + +Живого облачного ключа в файле деплоя нет ⇒ пробой цепи — на `provider: local` (models.yaml), +которого на стенде обслуживает OpenAI-совместимая заглушка `$W/fake_provider.py` (переводчику — +проза, терминологу/классификатору — TSV по строкам `key:`). Майнингу даны `langpack_root` (реальные +langpacks движка) и фабрикованный jieba-контраст (76 частотных слов); книга — крафтовый исходник +(方源 ×54, 苏长老, 青云山; 3 главы, 1222 байта). + +## Цепь, команда за командой + +Загрузка и старт (сессия `curl -c jar` через `/auth/dev-login`, всюду `-H "X-TM-Client: p9"`): + +``` +$ curl … -F title="Проба P9" -F source_lang=zh -F target_lang=ru -F file=@probe-book.txt /v0/books +"id": "bk_MNPEAQTOVKK5SMCI", "status": "parsing" … → not_started 3 +$ curl … -d '{"stop_for_signing":true,"ceiling_chapters":3}' /v0/books/bk_MNPEAQTOVKK5SMCI/runs +run_BMLFFO5TCESMXRW4 translating {'done': 0, 'total': 6, 'stage': 'drafting', 'eta_seconds': None} +``` + +Стоп достигнут (поллер карточки, дословно): `23:12:10 awaiting_bank 3 6 editing` — полоса на стопе +50%, НЕ обнулилась. Чтение банка на стопе: + +``` +$ curl … /v0/books/bk_MNPEAQTOVKK5SMCI/bank +{"revision": 23, "next_cursor": null, "structure_version": 1, "total": 0, "signed": 0, "terms": []} +``` + +— ни `pending_decisions`, ни `complete` (PD-399 живьём); пустота банка на стопе — строка 224 +(читающая сторона — движковый пак). Поверхности стопа — из карты подписи на сервере +(`sample-zh.db.mined-signature.yaml`: `方源`, `苏长老`, …; `.bank-stop.txt`: «BANK VERIFICATION +TABLE — 3 term(s)»). + +ПРЕВЬЮ (approve 方源 → «Фан Юань-П9» kind name + decline 苏长老), ответ дословно: + +``` +{"preview": true, "changed": true, "depth": "refinement", + "accepted": [ + {"index": 0, "action": "approve", "id": "e1e2b5625d031d33", "src": "方源", + "dst": "Фан Юань-П9", "state": "applied", "displaced": false}, + {"index": 1, "action": "decline", "id": "bf4a082f8bf17aed", "src": "苏长老", + "dst": null, "state": "applied", "displaced": false}], + "preexisting_faults": 0, + "signature": {"surfaces": 3, "undecided": 1, "unreadable": false}} +``` + +ПРАВКА и ПОВТОР того же документа (вывод скрипта сверки, дословно): + +``` +== APPLY == +preview False changed True accepted [('方源', 'applied', False), ('苏长老', 'applied', False)] signature {'surfaces': 3, 'undecided': 1, 'unreadable': False} +== RETRY SAME == +changed False states ['already_applied', 'already_applied'] +``` + +ОТКАЗ (противоречие в одном документе: decline 方源 + approve 方源): + +``` +code bank_corrections_refused refusals [('/corrections/1', 'decision 0 already decides this term (sense "", window [0,0]')] +HTTP=409 +``` + +RESUME и правка при живом прогоне (второй замер — на живом прогоне демо-книги, тело дословно): + +``` +$ curl … /v0/runs/run_BMLFFO5TCESMXRW4/resume +translating {'done': 3, 'total': 6, 'stage': 'editing', 'eta_seconds': 1} # полоса ПРОДОЛЖИЛА с 3/6 +$ curl … -d @corr-apply.json /v0/books/…/bank/corrections # прогон живой +{"type":"about:blank","title":"Run in flight","status":409,"code":"run_in_flight","request_id":"5M6SCTCU4XYQP5P2"} +``` + +Финал (поллер + чтение банка, дословно): + +``` +23:14:04 ready 6 6 editing +$ curl … /v0/books/bk_MNPEAQTOVKK5SMCI/bank +{"revision": 34, …, "total": 2, "signed": 1, "terms": [ + {"id": "tm_73f73a5b396d7d40b68bac09", "src": "方源", "dst": "Фан Юань-П9", + "kind": "name", "status": "approved", …}, + {"id": "tm_9e446a871eb17f39eade265c", "src": "青云山", "dst": "", "kind": "place", + "status": "proposed", "since_chapter": 1, …}]} +``` + +Следующий прогон правку УВИДЕЛ: `方源` — approved с моим dst; задеклайненный `苏长老` из +предложений исчез; нерешённый `青云山` стоит proposed. + +## Деньги (ось §5.1 — леджер двумя путями) + +``` +$ $W/tmplatformctl balance --user u_QNMBL43CZVBE42IJ +balance 25.000000 +$ psql … -d tmp9stand -c "select kind, count(*), sum(amount_micro_usd) from credit_ledger group by kind order by kind" + grant | 1 | 25000000 + hold | 3 | -270000 + hold_release | 3 | 270000 + settlement | 3 | 0 +``` + +Сошлось до цента. Три холда = три попытки (демо-книга exit 10; пробная книга, попытки 1 и 2 через +resume); расчёты $0 — заглушка. Оговорка силы замера: расчёты нулевые, открытого холда в момент +сверки не было; сверку на открытом холде и после ненулевого расчёта исполнил ревьюер приёмки на +этом же дереве — расхождений нет. + +## Что пробой доказал + +Цепь строк 199/202 срослась: правка термина через контрактную дверь доезжает до движковых файлов +решений, возобновление пере-собирает банк с ней, читающая поверхность следующей границы её +показывает. Плюс живьём: сквозная полоса через обе волны без обнуления на стопе; PD-399 не на +проводе; превью/идемпотентность/отказ/взаимоисключение — по канону 0.5.0. Ограничение честно: +качество перевода и поведение под латентностью настоящего провайдера пробой НЕ доказывает — +заглушка, строка 202 «по-настоящему» ждёт живого ключа. diff --git a/platform/docs/platform-PROGRESS.md b/platform/docs/platform-PROGRESS.md index 635945cb..d7a8590b 100644 --- a/platform/docs/platform-PROGRESS.md +++ b/platform/docs/platform-PROGRESS.md @@ -4,6 +4,434 @@ > вопросы, предложения на ратификацию. В `docs/PROGRESS.md` платформа не пишет; оркестратор > читает этот журнал при каждом лендинге зоны (свип «решений владельца» — норма D39.99 п.4). +## ФИКС-РАУНД ПО ДИСПОЗИЦИЯМ ОРКЕСТРАТОРА ИСПОЛНЕН — 13 фиксов, 9/9 посадок пойманы, одна находка воркфлоу ОПРОВЕРГНУТА исполнением, канон 0.6.0 принят (сессия платформы, 28.08, после записи ниже) + +Диспозиции пришли двумя сообщениями оркестратора (28.08) + третьим — канонная половина полосы +(0.6.0). Всё исполнено, кроме ДВУХ пунктов с несогласием (аргументы ниже — обе позиции по норме +«несогласие говори»). + +### Фиксы в дереве (сверх сданного пака; каждый с пином, посадки — в копии `~/tm-p9-mut2`) + +| # | Что | Пин | Посадка | +|---|---|---|---| +| F1 | **MAJOR(а) 1 МиБ**: `ingest.EncodeDecisions` рендерит с `SetEscapeHTML(false)` (документ читает движок, не браузер; выбор обоснован в комментарии) **+ жёсткий гейт**: рендер сверяется с зеркалом движкового капа `ingest.MaxDecisionsDocument` ДО спавна → `ErrBankDocumentTooLarge` → **413** (остаточная конвертная полоса ~40 байт закрыта гейтом, слово всегда канонное) | `TestTheRenderedDocumentDoesNotInflateEscapableBytes` (ingest) · `TestARenderedDocumentOverTheEngineCapIsRefusedBeforeTheSpawn` (runs, движок НЕ спавнится) · `TestARenderedDocumentOverTheEngineCapAnswers413` (httpapi) | M6 (возврат `json.Marshal`) и M7 (снятие гейта) пойманы топично | +| F2 | **MAJOR(б) мьютекс**: `lockBook(ctx)` — одноместный канал вместо `sync.Mutex`, ожидание наблюдает контекст; **бюджет двери ставится ДО очереди** (накрывает ожидание+вызов); Start/Resume передают свои ctx; отмена в очереди корректно декрементит refcount | `TestAWaiterWhoseContextEndsLeavesTheBookQueue` (+ leak-тест расширен) | M11 (ожидание игнорирует ctx) поймана | +| F3 | **Р1 окно стопа**: `ReadBookForRun` вырос полем `LiveRunAwaitingBank` (живая строка в `awaiting_bank`); предикат двери — `HasLiveRun && !LiveRunAwaitingBank`; Start по-прежнему держится на `HasLiveRun` (вторая строка сломала бы `runs_one_live_per_book`); худший случай окна — движок ещё дожёвывает → честный класс 12 → 503 «повтори». Довод в комментарии переписан, противоречие с `reconcile.go:1122` разрешено в пользу кода | `TestTheDoorOpensOnTheSigningStopWindow` (фикстура — как `control_test`: журнальный `awaiting_bank` без `finished_at`); обратная сторона держится старым `TestCorrectionsRefuseWhileTheBookIsBeingTranslated` | M8 (старый предикат) поймана — refuse-тест при этом остался зелёным | +| F4 | **Р2 миграция 00026**: два UPDATE сведены к ОДНОМУ — `draft_before := chapters_before` для ВСЕХ строк (та аппроксимация, которую ревью само проверило как самосогласованную для finished): пере-снятие БАЗ ПОСЛЕ работы прогона — единственный источник вечного недоезда −2·p_e — удалено; заодно умер и dispute про READ COMMITTED между двумя стейтментами (стейтмент один). Остаток по эпохам старого `chapters_before` честно назван в комментарии миграции. `migrations.sha256` пере-подписан (миграция не релизнута — слово оркестратора) | пин исполнением невозможен (тестовые БД наливаются миграциями ДО данных — старая семантика юнитом задним числом непроверяема); держится формулой + комментарием | — (названо, не скрыто) | +| F5 | **Р5 presence-vs-null**: все строковые члены `wireCorrection` — `nullableString` (парный `nullableInt`); явный `null` на любом = malformed ТИПА (рефьюз до правил присутствия); правила присутствия (`id`×tuple, `dst`/`kind` на decline) считают КЛЮЧИ | +3 кейса в `TestACorrectionRequestIsValidatedWholeWithPointers` (null-src при id · null-dst на decline · null-action) | M10 (снятие null-гейта) поймана | +| F6 | **Р6(i)**: комментарий SIGKILL-ветки переписан честно (ветка достижима только когда SIGTERM НЕ отработал; полу-приземлённая пара без отчёта — остаток в PD-407) | — (комментарий) | — | +| F7 | **Р6(ii)**: `bankStopGrace` 10 с → **30 с**, число обосновано замером движка в комментарии (11.3 с непрерываемого фолда на документе-максимуме, ×2.5 запас на медленный хост) | — (константа с доводом) | — | +| F8 | **Р6(iii)**: связка «ручка `TM_PLATFORM_RUN_BUDGET` × движковый кап 5000×11.3с» и цена понижения названы комментарием у бюджета двери (механизм не строился — слово оркестратора) | — | — | +| F9 | **Р7**: `Service.SweepCorrectionScratch()` — подметание `bank-corrections-*.json` на буте (вызов в композиционном корне `tmplatformd` ДО подъёма HTTP), файлы вне маски не трогаются | `TestBootSweepsOrphanedCorrectionDocuments`. ⚠ Честно: вызов ИЗ main юнитом не запинен — мутация «не звать на буте» ловится только чтением | M14 (маска мимо) поймана | +| F10 | **Д5**: комментарий `spawn.go` о сбросе `--verify-bank` переписан с упразднённого D39.144-контракта на настоящее основание (память v16 покрывает карту; старое основание питало дырявый гард — названо в комментарии) | — | — | +| F11 | **Н5 hello**: при `resuming` hello несёт `last` клиента, не голову истории (свежий коннект — голову, как и `from` четырьмя строками ниже) | `TestAResumingHelloCarriesTheClientsOwnWatermark` (+ свежий коннект отдаёт голову) | M13 поймана | +| F12 | **Н1-гард**: `Resume` под мьютексом сверяется с новым `pgstore.LatestRunID` (тот же порядок, что `lastRun`) — не-последний прогон получает `ErrNotResumable` с человеческим доводом; реконсилерский RestartRun в гарде не нуждается (живой прогон всегда последний по `started_at`: пока он жив, новый не стартует) | `TestAnOlderRunCannotBeResumedOverANewerOne` (Now сдвигается между стартами — фикстурный Now дал бы одинаковый `started_at` и флейк по случайному id) | M12 поймана | +| F13 | **Канон 0.6.0** (третье сообщение): `ContractVersion` → 0.6.0; комментарий `wireProgress` переписан (сквозная доля и `stage` теперь КАНОННЫ, открытый словарь; оба условия исключения шапки — вывод платформой из тех же счётчиков и открытость — коду отвечают, проверила); протухший абзац про `stop_requested` заменён на «ни одного члена впереди канона»; комментарий у теста строки библиотеки сужен до правды (сам тест не тронут) | гейт версии (`reading_test`) снова зелёный | — | + +### Два НЕСОГЛАСИЯ с диспозициями (норма «говори») + +1. **Р4 (`limitedBuffer` не убивает чайлда) — находка воркфлоу ОПРОВЕРГНУТА исполнением, фикс + ОТКАЧЕН.** Посадка M9 (kill вырезан) прошла пин за **0.09 с** — чайлд умер сам; исходник Go + называет механизм прямо: копирующая горутина exec закрывает читающий конец при ошибке Write + (`os/exec/exec.go` writerDescriptor: «pr.Close() // in case io.Copy stopped due to write + error») → EPIPE на следующей записи. Агент экстраполировал семантику `StdoutPipe` (там `drain()` + Kill действительно нужен — exec ничего не закрывает за ручного читателя) на `Stdout=io.Writer` + и НЕ исполнял. Мой добавленный было kill снесён (он был бы кодом под ложный довод); исходный + комментарий `limitedBuffer` был ВЕРЕН и расширен ссылкой на опровержение; поведение запинено + живьём: `TestAnEndlessBankApplyIsRefusedRatherThanRead` (0.1 с; посадка M9b «кап перестал + отказывать» валит его таймаутом). Это второй случай за ревью, где замер бьёт рассуждение — в + обе стороны. +2. **Р2-dispute (READ COMMITTED между двумя UPDATE миграции) — строкой НЕ заведён:** фикс F4 свёл + миграцию к одному стейтменту, окна больше не существует. + +### Регистр и якоря + +- `PD-400.2` пере-описана честно (снято «ни ложного слова», мульти-репличный суб-кейс с ложным + 503 назван прямо; оговорка внесена и в комментарий ветки класса 12) — редакция для акта лендинга. +- Новые строки: **PD-402..PD-406, PD-410** (major: resume не-последнего [read-половина, write-гард + закрыт F12] · sticky `edit_wave`/re-sell · `chapters_done` назад · бездеревная книга С ЗАМЕРОМ + достижимости 0.018 с из лога стенда · SSE hello [закрыта F11 — оркестратору решить статус] · + Р3-полоса/план движка — АРХИТЕКТУРНОЕ, отдельным паком), **PD-407, PD-409, PD-411..413** (minor: + SIGKILL-слово+грейс [грейс закрыт F7] · утечка temp-файлов [закрыта F9] · мёртвые колонки классом + A второго яруса онтологии-18 · карточка 5 сканов · emitProgress под блокировкой), **PD-408** + (info: ручки двери). Статусы строк, чьё лечение легло этим раундом (405-замер/406/407-часть/409), + НЕ переводила — закрытие актом лендинга, как у PD-401. +- Гейты регистра: `counts.py --check` → **413 строк, 109 открытых (9/33/67), битая форма [], хвост + []**. Якоря, сдвинутые МОИМИ правками, пере-нацелены (PD-375 `runs.go:269`, PD-*` books.go:1065`). + ⚠ Для оркестратора: `docs/PROGRESS.md:159-160` и `17-seam-inbound-law.md:59` / + `25-seam-cold-review.md:7` держат якоря, уехавшие НЕ моими правками (supervisor/book.go — чужие + сдвиги) и моей (reconcile.go:1228 → теперь `:1246`) — файлы твоей зоны, чинить тебе. + +### Числа финальной батареи фикс-раунда (каждое — командой) + +`go test ./... -race -count=1 -v` с тремя гейтами → **EXIT=0, 18 пакетов ok, `grep -c -- '--- SKIP'` +→ 0, `grep -c '^=== RUN'` → 793** (было 781 на сдаче ревью — +12 новых пинов), `FAIL|DATA RACE` — 0 +вхождений; `golangci-lint run` → **0 issues**; `gofmt -l` пусто; `go vet` чисто (в составе линта). +⚠ Промежуточная батарея №2 имела РОВНО ОДИН честный FAIL — старый пин +`TestResumeIsRefusedWhenTheBookHasAnotherLiveRun` поймал, что первый вариант гарда F12 перекрывал +канонное слово `run_in_flight` при живом чужом прогоне; чинился КОД (словоразделение: живой сосед → +`run_in_flight`, финишировавший → `ErrNotResumable`), тест не тронут. Опись дерева: +`git status --short -- platform/` → **50 путей (39 M + 11 ??)**. + +## ВОРКФЛОУ-РЕВЬЮ ДЕРЕВА P9 ОТРАБОТАНО — 16 линз, оба отложенных MAJOR подтверждены замером, сводка находок для оркестратора (сессия платформы, 28.08) + +Заказ владельца (релей 28.08): адверсариальная вычитка дерева воркфлоу-оркестрацией — полоса ×4, +дверь ×3, миграция, раскладка кодов (`wf_cac14b2f-e84`) + гонки данных по 4 траекториям владельца, +баг-хант нового кода, стоимость per-request (`wf_155de7c3-bb4`). Раскладка моделей — по слову +владельца: 1×Fable на воркфлоу (самая тяжёлая линза), остальным явный opus/sonnet. 16/16 агентов +дошли (0 ошибок), ~2.83M токенов. Мандат: «найди, где рассуждение неверно», каждая находка — +severity + file:line + траектория с числами; PD-281, PD-401-остаток и пере-нарезка исключены +заданием как названные границы. **По находкам НИЧЕГО не чинил — жду слова оркестратора.** + +Дисциплина честности: пометка «подтверждено прогоном» в траекториях — исполнение АГЕНТОВ (их +overlay-тесты и /tmp-замеры в копиях дерева), не моё; я пере-исполнил чтением два клейма своего кода +(Р4, Р5 ниже — оба подтвердились) и одним своим EXPLAIN-замером ось стоимости (сошлось, ниже). +Полные результаты с траекториями сохранены вне репо: журналы воркфлоу в каталоге сессии +(`subagents/workflows/wf_*/journal.jsonl`). + +### Два отложенных MAJOR ревьюера приёмки — оба ПОДТВЕРЖДЕНЫ, чинить + +- **MAJOR(а) «1 МиБ на двух документах» — ПОДТВЕРЖДЁН тремя агентами с независимыми замерами.** + Механизм: `json.Marshal` в `ingest.EncodeDecisions` HTML-экранирует `&`/`<`/`>` (1 байт → 6) и + меняет конверт (+~25 Б), так что тело под проводным капом 1 МиБ рендерится в документ НАД + движковым капом 1 МиБ. Замер агента: 5000 declines с `note` из `&`×160 → тело 1 030 050 Б → + рендер 5 030 075 Б → движковый exit 14 → **409 `bank_corrections_refused` «split it» вместо + канонного 413**. Полоса законных тел широкая (~550 КБ плотного спецсимволами тела уже + перескакивает), а существующий пин `TestAnOversizedCorrectionDocumentAnswers413` дыру не ловит — + набит кириллицей, которая НЕ экранируется. ⚠ Конфликт линз: два агента первого воркфлоу положили + эту ось в «не опровергнуто», рассуждая от omitempty («рендер меньше тела») и НЕ меряя; замер двух + других бьёт рассуждение (один из «опровергателей» сам оговорил условие плотности `&<>`). + Бонус-cosmetic туда же: скраб пути (`runs/bank.go:146`) на этом отказе даёт задвоенную фразу «the + decision document the decision document» и пропускает на провод движковый префикс `pipeline:`. +- **MAJOR(б) «мьютекс без контекста, бюджет после захвата» — ПОДТВЕРЖДЁН** (`runs/bank.go:94` vs + `:126`, `lockBook` без ctx `:270-291`): K одинаковых ретраев дают очередь K×60 с; отменённый + клиентом запрос ЖДЁТ в очереди и, дождавшись, делает работу (чтение книги, temp-файл) за мёртвый + запрос; в ту же очередь встают Start и Resume — заявление «ждать один вызов движка» + (`runs.go:242-246`) держится только для одного ожидающего. Дёшево лечится ожиданием с + наблюдением ctx (канал вместо `sync.Mutex`). + +### Находки в МОЁМ коде P9 (сверх двух MAJOR; лечение — после слова оркестратора) + +| # | Вес | Что | Где | +|---|---|---|---| +| Р1 | breaks | **Окно стопа подписи**: `bank_stop` ставит `awaiting_bank` БЕЗ `finished_at`, а `HasLiveRun` читает `finished_at is null` → до ближайшего свипа (15 с штатно; до 30 мин на бэкоффе отсрочек) дверь отвечает **409 `run_in_flight` на экран подписи, который платформа сама только что объявила**; соседняя кнопка Resume в том же окне отказывает ДРУГИМ словом («being settled»). Мой довод в `runs/bank.go:110-112` («its run is closed») прямо противоречит `reconcile.go:1122`; состояние строится собственным тестом дерева (`control_test.go:598-603`). Нашли НЕЗАВИСИМО 3 агента | `runs/bank.go:109` | +| Р2 | breaks | **Миграция 00026 для прогонов в полёте**: пере-снятие баз посреди прогона, уже сделавшего p_e правок, оставляет его финал на `runDone−runTotal = −2·p_e` — полностью доделанный прогон закрывается, например, 5/7 навсегда. Двух-базовая схема корректна ровно потому, что базы берутся ДО работы прогона; миграция — единственное место, берущее их ПОСЛЕ. + dispute: READ COMMITTED между двумя UPDATE допускает двойное касание строки при конкурентном финише (при штатном деплое одной реплики окно закрыто остановкой старого демона — вес мал) | `migrations/00026:20-27` | +| Р3 | breaks | **`draftWork` не моделирует порядок волн движка**: continuation над недочерновленной книгой (`draft_before ≥ chapters_before+C`, напр. прогон-предшественник упёрся в потолок на черновике) даёт `draftWork=0`, движок продолжает ЧЕРНОВУЮ волну — полоса 0/C весь прогон, stage «editing» на прогоне, который только черновит. Зеркало дефекта, который draftWork чинил | `readmodel.go:460` | +| Р4 | breaks | **`limitedBuffer` НЕ убивает чайлда** при переполнении stdout — вопреки своему комментарию (контраст: `drain()` в `engine.go:262-272` при том же переполнении явно зовёт `Process.Kill`); чайлд виснет на записи в полный пайп до чужого дедлайна (~70 с под пер-книжным мьютексом), без дедлайна — навсегда. Подтвердил чтением. + dispute: stderr — НЕограниченный `bytes.Buffer` (не-тот бинарь по сконфигурированному пути может раздуть демона до OOM; читается всё равно только первая строка) | `runner/bankapply.go:98-111` | +| Р5 | breaks | **Presence-vs-null дыра валидации**: только окна (`nullableInt`) различают явный `null` от отсутствия; `src/sense/dst/kind/id` — голые `*string`, так что `{"id":"X","src":null}` и decline с `"dst":null,"kind":null` проходят гейты, которые канонный `oneOf`/`not-required` велит отбивать 400. Подтвердил чтением (сам вводил `nullableInt` только для окон). Порчи данных нет — лишнее значение не читается; дыра контрактная | `httpapi/bank.go:48-51, 221-224, 251-259` | +| Р6 | dispute | **Раскладка на ветке SIGKILL**: `ctx.Err()`-ветка (`runs/bank.go:132`) достижима ТОЛЬКО когда SIGTERM не отработал (процесс убит по `WaitDelay`) — а это единственный путь, оставляющий полу-приземлённую пару БЕЗ отчёта, т.е. адресат `bank_corrections_incomplete`, отвечаемый общим 503; мой комментарий описывает соседнюю ветку. + `bankStopGrace` 10 с КОРОЧЕ самого длинного непрерываемого участка движка (11.3 с на документе-максимуме) — грейс истекает до фазы записи. + `runBudget` — операторская ручка свипа: понижение (к чему подталкивают комментарии) делает легальный документ-максимум навсегда неприменимым (вечный 503 вместо «split it») | `runs/bank.go:132`, `runner/bankapply.go:40` | +| Р7 | cosmetic | Утечка `bank-corrections-*.json` в StateDir при нечистой смерти демона (SIGKILL/OOM/обрыв грейса деплоя): cleanup только на defer, никто каталог не подметает; до 1 МиБ пользовательских решений на файл, бессрочно | `runs/bank.go:250` | +| Р8 | — | **Формулировка PD-400.2 (accepted-risk) требует правки**: на мульти-реплике класс 12 может быть НАСТОЯЩИМ многочасовым прогоном соседней реплики (её Start видит свой мьютекс и чистый `runs`) — слово 503 «transient holder» и мой лог тогда ЛОЖНЫ, канонно верное слово — 409 `run_in_flight`. «Ни денег, ни порчи» держится; «ни ложного слова» — НЕТ. Риск остаётся принятым (v1 = одна реплика), но акт лендинга должен описать его честно | `runs/bank.go:27-39, 182-189` | + +### Находки в НАСЛЕДИИ платформы, вскрытые ревью (не код P9; полоса и дверь на них стоят) + +| # | Вес | Что | Где | +|---|---|---|---| +| Н1 | breaks | **Resume НЕ-последнего прогона** (Resume пропускает любой `stopped` без проверки последнести; `lastRun` = `started_at desc`; RestartRun не трогает `started_at`): (i) полоса возобновлённого открывается на ЧУЖОЙ работе — 2/2 при нуле своих пассов (клэмп режет только >1, не двойной счёт до 1); (ii) карточка, кадр статуса и КАЖДЫЙ progress-кадр живого прогона считаются по ЧУЖОМУ финишировавшему — «ready» и замороженный бар, пока живой прогон тратит деньги. 4 агента, overlay-прогоны | `reconcile.go:1216`, `readmodel.go:487`, `runs.go:843` | +| Н2 | breaks | **`edit_wave` sticky-true × оператор убрал редактора**: прогон делает 100% купленного и закрывается ready на 50% со stage «editing»; `chaptersDone` (edit-колонка) мёрзнет → `ChaptersLeft` не падает → **шкала повторно продаёт уже переведённые главы** (симптом PD-202, записанной fixed); follow-up прогон над этой покупкой читается 0/C с первой секунды. Обе фразы комментария `sink.go:406-409` о собственном коде ложны | `sink.go:415`, `readmodel.go:388` | +| Н3 | breaks | **Флип false→true уводит `Book.chapters_done` НАЗАД** (finishedUnits через ЖИВОЙ флаг): 40/100 → 0/100 одной транзакцией при росте ревизии — клиент обязан отрисовать спад; `ChaptersLeft` раздувается обратно → предлагает купить купленное. PD-316-класс на непокрытом триггере (пин ловит только admission). Крайний случай: книга, полностью начерченная под false, НЕдочитываема редактором никаким действием API (ChaptersLeft=0 → прогон не допускается → флип недостижим) | `sink.go:415`, `readmodel.go:388`, `books.go:981-983` | +| Н4 | breaks | **Книга без материализованного дерева**: полоса (вся из `chapters`) читает 0/total весь прогон при исправно доезжающих progress-событиях; комментарий `sink.go:219` («счётчики придут из progress-события») ложен уже в HEAD. + dispute: `runs.draft_done/draft_total/edit_done/edit_total` — 2 писателя, 0 читателей (PD-314-класс, лишняя запись на каждом событии под блокировкой книги; обоснование `greatest()` на `sink.go:444-446` защищает несуществующую полосу) | `sink.go:219, 137, 447` | +| Н5 | breaks | **SSE `hello` при переподключении несёт `id = state.Position` вместо предъявленного `last`** — WHATWG-клиент фиксирует новый Last-Event-ID ДО получения догона; обрыв сразу после hello теряет кадры навсегда (включая `note`, которые контракт запрещает терять) и обезоруживает дельта-ремонт (`revision` из hello выше потерянных строк). Лечение — одна строка: при resuming слать `last`. Код рядом (`stream.go:122-126`) сам знает правильное значение | `stream.go:99` | + +### Находки зоны ДВИЖКА (чужая зона — пинг оркестратору, мне не лечить) + +| # | Вес | Что | Где | +|---|---|---|---| +| Д1 | breaks | **Decline не энтити-широк на обратном пути**: отклонённая сущность возвращается в карту/auto-bank/редактору через свой АЛИАС (`reverseSectionTerms` фильтрует по одному ключу, а `mined_rejects` получает только `src`), и память предъявления глушит стоп об этом НАВСЕГДА — нарушен собственный контракт «a declined term never re-enters» (`miner_emit.go:59-63`); approve симметричной утечки не имеет (кластер через строку банка) | `mining.go:548`, `membank/decisions.go:598` | +| Д2 | breaks | **Неидемпотентный decline** поверхности подписанного сида, имеющей строку в дельте: первый вызов ПРИНЯТ, повтор ТОГО ЖЕ документа — 409 (гейт `decisions.go:374` судит ДО-состояние, которое свёртка сама стирает); при классе 15 предписанный ре-сенд отбивается ЦЕЛИКОМ — **обещание сходимости 503-ретрая, на котором стоит синхронная дверь, ломается**. Доказано исполнением через СОБСТВЕННЫЙ оракул репозитория (оракул 4 фаззера); фаззер структурно не достаёт (фикс-книга не пересекает сид с дельтой). Лечение: судить по ПОСТ-состоянию, как соседний `refuseInertDeclines` | `membank/decisions.go:374` | +| Д3 | breaks | **Потеря/порча маркера выхода после стопа** (совместная с платформой): рестарт/ретрай проходит границу банка НАСКВОЗЬ — память предъявления покрывает карту, движок «continuing» одним WARN себе в журнал — оплаченный `verify_bank` стоп исчезает молча и навсегда (память append-only). Гард `LiftBankStop` (`reconcile.go:1122-1126`) писан против движка ДО памяти v16 и этот путь не держит | `reconcile.go:424`, `mining.go:216-227` | +| Д4 | dispute | **`signature` в квитанции двери** считается от карты, которую переписывает ЛЮБАЯ граница майнинга (запись карты на `mining.go:191` — ВЫШЕ решения о стопе): `signature != null` не означает состоявшегося стопа; `surfaces`/`undecided` дрейфуют между двумя вызовами владельца; `undecided: 0` достижим при непредъявленных решениях (кап top-200 вытесняет). У карты ЕСТЬ идентификатор (`signaturemap.go:25-29`) — шов его не читает | `pipeline/bankdecisions.go:549-598` | +| Д5 | cosmetic | `spawn.go:159-165` (платформа, но про движок) цитирует упразднённый D39.144-контракт стопа («halts whenever undecided terms remain») — при памяти v16 фактически ложно и питает дырявый гард Д3 | `spawn.go:159-165` | + +### Ось «стоимость» — мои живые замеры + вердикт агента (сошлись) + +- **Строка 186 единого бэклога ПРОТУХЛА — рекомендация оркестратору: закрыть.** Механизм построен + и применён: агент прогрепал ВСЕ call sites `writeJSON` (коллекции, карточка, capabilities, POST + двери; SSE корректно НЕ сжимается). Мои замеры на стенде tmp9stand (команды и заголовки — сырьём): + банк 437 Б / 7.2 мс холодным, повтор с `If-None-Match` → **304 / 0 Б / 2.6 мс**; юниты главы + 3008 Б → **740 Б gzip (×4.1)**; gzip ниже порога 1024 Б честно не применяется; resume SSE за + головой → 204 / 1.3 мс. Взамен предлагаю ДВЕ новые стоимостные строки (ниже). +- **Карточка книги — 5 коррелированных сканов `chapters` на один GET** (3 в runRow: draftChapters + ×2 + editChapters, +2 в bookColumns: chaptersDone + noteCount), и те же 2 — на КАЖДУЮ строку + страницы библиотеки (×100). Эмпирика агента на живом PG 18.4, фикстура в форме миграции 00002, + книга 2283 главы: как написано — 1.359 мс; те же числа одним LATERAL `count(*) filter(...)` — + 0.307 мс (×4.4). Мой контрольный EXPLAIN на реальной схеме tmp9stand: **6 SubPlan-ов в плане, + 5 исполняются** (ELSE-ветка never executed) — форма подтверждена. PG повторные текстовые + вхождения подзапроса НЕ дедуплицирует (доказано side-effect-последовательностью), CASE-ветки + честно short-circuit. +- **`emitProgress` повторяет 3-скан агрегат по ВСЕЙ книге на каждое progress-событие** (движок шлёт + его на каждый разрешённый юнит каждой волны) — ПОД блокировкой строки книги: O(глав × юнитов) + вместо O(юнитов), на большой книге — секунды суммарного удержания блокировки за прогон. +- cosmetic: `MkdirAll(StateDir)` на каждый вызов двери — место одному разу в конструкторе Service. + +### Чистые оси (проверено — не опровергнуто) + +Линза `bugs:service-and-seams` — **0 находок** (проверены: cleanup temp-файла по всем выходам; +редакция refusals — единственная ветка с путём; вердикт-таблица против всей полосы exit.go; ключи +только на translate; refcount lockBook; SpendBaseline из колонок ПОПЫТКИ; согласованность +bankCountsTx; проекции без утечек словаря/путей; build/vet/тесты). Сквозные not_refuted (по многу +агентов): взаимоисключение «дверь × спавн» в заявленную сторону ДЕРЖИТСЯ на одной реплике (все 5 +путей спавна упираются в `finished_at is null` = предикат HasLiveRun); правило одного писателя двух +файлов решений; сходимость повтора того же/другого документа при ЦЕЛОМ гейте Д2; SIGTERM после +записи не теряет квитанцию (`Exited=true` глотает ctx.Err — совпадает с диском); идентичность +термов через пересборку банка (id из ключа уникальности); сериализация чеканки кадров и штамповка +ревизий чисты; чтения на одном снимке; идемпотентный ключ Start не клинит за очередью мьютекса. + +### Финальная батарея по дереву (после всех приёмочных правок; дерево ревью НЕ меняло) + +`go test ./... -race -count=1 -v` с тремя гейтами → **EXIT=0, 18 пакетов ok, SKIP=0, +`^=== RUN` = 781**; DATA RACE — нет. (На сдаче было 775 — рост на приёмочных доборах, число +командой.) + +### Вопросы/предложения оркестратору + +1. Диспозиция Р1–Р8: что чиню в этом дереве до лендинга, что строками регистра. Готов завести + пакет строк PD-40x по всем группам после твоего вердикта (не завожу до слова — рядом лендинг). +2. PD-400.2: скорректировать формулировку accepted-risk по Р8 (снять «ни ложного слова», описать + мульти-репличный суб-кейс) ДО акта лендинга. +3. Строку 186 закрыть (замер выше), взамен — две стоимостные строки (5-скан карточка ×100 на + библиотеку; emitProgress O(глав×юнитов) под блокировкой) куда решишь. +4. Д1–Д5 — пинг бэкенду твоим каналом; Д2 ломает обещание, которое МОЯ дверь даёт на проводе + (сходимость ретрая 503) — до его лечения в движке слова канона §applyBankCorrections о ретрае + верны не для всех документов. +5. Н1–Н5 — наследие: Н1 (resume не-последнего) и Н5 (hello id, лечение в одну строку) выглядят + дешёвыми и болезненными; Н2/Н3 упираются в продуктовое решение о смене формы конвейера на + живой книге. + +## ПАК P9 ОТРАБОТАН — дверь правок банка смонтирована, ключи едут, полоса сквозная; цепь живого прогона ПРОБИТА живьём (сессия платформы, 27.08) + +Дерево передаётся на лендинг. Опись: `git status --short -- platform/` → 31 изменённый + 9 новых +файлов, все в зоне; вне `platform/` не тронуто ничего. + +### Таблица комплектности против §3 (пункт → сделано → каким ИСПОЛНЕНИЕМ подтверждено) + +| §3 | Что сделано | Исполнение | +|---|---|---| +| §3.1 дверь | `POST /v0/books/{bookId}/bank/corrections` в `contractSurface` (монтаж по `Deps.Bank`, кап тела = канонный 1 МиБ = `DefaultMaxBody`); строгий декод (`DisallowUnknownFields` + запрет хвостовых байт), вся канонная валидация формы с JSON Pointer'ами; `Capabilities.bank_corrections_enabled` = факт монтажа; словарь шва `ingest/bankdecisions.go` (запрос v1 / отчёт v2, аллоулист); канал `runner.BankApply` (прямой чайлд, SIGTERM-грейс, потолок чтения); вердикт `runs.bankVerdict`; квитанция-проекция с переводом `edit_wave→refinement` и отказом на неизвестное слово; `refusals[]` в конверте `Problem` + коды `bank_corrections_refused`/`bank_corrections_incomplete` | живой пробой — `docs/p9/door-live-probe.md` (превью → правка → ретрай `already_applied` → отказ 409 с указателем → resume → 409 `run_in_flight` при живом прогоне, тела дословно); юнит-пины `internal/httpapi/bank_test.go` (7 на сдаче; 9 после приёмочных доборов — превью и обрыв на потолке), `internal/runs/bank_test.go` (6), `internal/ingest/bankdecisions_test.go` (2); посадки M1, M2 | +| §3.1 раскладка кодов | заказанное: 14→409 `bank_corrections_refused`+`refusals[]` · 15→503 `bank_corrections_incomplete` · 12→409 `run_in_flight` · тело>1МиБ→413 · >5000 и форма→400. Моя половина с доводом: **13→503 `service_unavailable`** (не мигрирован — оператор, транзиентно; различим от 15 по коду) · **19 и незнакомые члены полосы→503 `service_unavailable`** (рассинхрон сборок; какое из двух других ремеди — неизвестно по построению) · **10/11→500** (оба входа глагола рендерит платформа) · **exit 5 и таймаут бюджета→503 `service_unavailable`** (рестарт деплоя; SIGTERM-контракт глагола graceful). Три ремеди («пере-реши»/«повтори то же»/«позови оператора») не сливаются | пин-таблица `TestBankVerdictKeepsTheRemediesApart` + `TestTheDoorsFailuresKeepTheirRemediesApart`; посадка M1 (слияние 15 в 503-generic) поймана; опровергатель кодов: «(а) слияние ремедий — не опровергнуто по всем девяти строкам» | +| §3.2 синхронность | вызов синхронный, бюджет = `runBudget()` (60 с — класс вызовов движка); пер-книжный мьютекс `lockBook` в `runs.Service`, его берут corrections И `Start` И `Resume` (Start — та же гонка спавна, что resume); проверка живого прогона — ПОД мьютексом; гейт готовности книги (`readyToTranslate`) — как у Start (находка опровергателя) | `TestAResumeWaitsOutALiveCorrectionCall` (resume ЖДЁТ живой вызов двери, канал-гейтед фейк); `TestCorrectionsRefuseWhileTheBookIsBeingTranslated`; живьём — шаг 11 пробоя (409 `run_in_flight` на живом прогоне); синхронность ДЕРЖИТСЯ: живой вызов двери на стенде — доли секунды, потолок глагола 5000 подобран движком под таймаут вызывающего | +| §3.3 ключи | `TM_PLATFORM_ENGINE_KEYS_PATH` (абсолютный или отказ на буте; ⚠ суффикс `_PATH`, не `_FILE` — `*_FILE` в зоне значит «файл со значением секрета», гейт `TestEverySettingThisServiceReadsIsPrinted` это и поймал) → `runner.TranslateArgs` кладёт `--keys-file` ТОЛЬКО на `translate`; в окружение юнита ключи не кладутся; пусто = WARN на буте | `TestTheDeploymentKeyFileReachesTranslate`, `TestTheSpawnedUnitCarriesTheDeploymentKeyFile` (argv юнита + отсутствие ключей в Env); живьём: движок с несуществующим файлом падает громким «--keys-file … cannot be read», с файлом — пре-флайт пройден (лог пробоя); посадка M3 поймана | +| §3.4 полоса | ОДНА монотонная доля через обе волны: `done = draftBar + lastBar`, `total = draftWork + ceiling` (редактор) / `ceiling` (без), где `draftWork = clamp(chapters_before + ceiling − draft_before)` — знаменатель считает работу ЭТОГО прогона (правка по находке опровергателя: continuation поверх начернённого задела кончал ready на 50%); две базы в `StartRun`, пере-базирование при снятии стопа УДАЛЕНО; подпись `progress.stage` (`drafting`/`editing`, открытый словарь); кадр `progress` и старт-квитанция несут то же; миграция `00026` (live-прогоны — обе базы пере-сняты верными предикатами, законченные — аппроксимация, названо в самой миграции) | живьём: 0/6 drafting → стоп 3/6 editing → resume 3/6 (БЕЗ обнуления) → ready 6/6 (лог пробоя); пины `TestTheBarIsOneMonotonicFractionThroughTheSigningStop`, `TestARunOverADraftedBacklogOwesOnlyTheLastPass`, `TestADraftOnlyDeploymentCountsItsOneWaveOnce`, `TestASecondRunsBarStartsAtZeroOverAHalfFinishedBook`, `TestTheRunsBarNeverExceedsWhatItBought`; посадки M4, M5 пойманы | +| §3.5 PD-399 | `pending_decisions`/`complete` сняты со всех трёх носителей (`BankCounts`+кадр `EventBank`, `wireBankPage`, подзапрос к мёртвой `bank_decisions` ушёл); пин ПЕРЕПИСАН на отсутствие | `TestTheBankAggregatesRideOnTheFirstPageOnly` пинит ОТСУТСТВИЕ; живьём: `GET /bank` на стопе и после прогона — полей нет (лог пробоя, шаги 5 и 13); строка PD-399 → fixed | +| §3.6 конвенция пути | `runner.projectDB()` и парс `book.yaml` УДАЛЕНЫ; путь банк-экспорта берётся из конверта `artifacts.bank_export`, который движок публикует в `manifest --json` (движковая половина — d1eb8a9); `refreshBank` кормится манифестом той же refresh-пачки; движок без конверта = громкий отказ, долг ретраится | `TestTheBankIsReadAtThePathTheEnginePublished`, `TestAnEngineWithoutTheEnvelopeIsAFailureRatherThanAnEmptyBank`; живьём: банк пробной книги материализовался по опубликованному пути (шаг 13) | + +### Числа сдачи (каждое — командой) + +- Батарея с ТРЕМЯ гейтами (`TM_PLATFORM_TEST_DSN` · `_ENGINE_BIN`+`_BOOK_TEMPLATE` · живой + пользовательский systemd): `go test ./... -race -count=1 -v` → **EXIT=0, 18 пакетов ok, + `grep -c -- '--- SKIP'` → 0, `grep -c '^=== RUN'` → 775**; линтер `golangci-lint run` → **0 issues**; + `gofmt -l` пусто, `go vet ./...` чисто. Лог — `~/tm-p9-work/final-battery.log` (вне репо). +- Тест-функции зоны: `grep -rh '^func Test' platform --include='*_test.go' | wc -l` → **561 (HEAD) → 580**. +- Регистр: `python3 docs/scripts/counts.py --check` → **401 строка, открытых 97** (на сдаче 3/27/67; + после пере-взвеса PD-401 приёмкой — 3/28/66); было 398/96 — PD-399 закрыта, PD-400/PD-401 заведены; + «битая форма: []», хвост чист. ⚠ Первая редакция этой строки называла «400 строк» — снято + пере-счётом ревьюера, число выше — командой. +- Миграции: `00026` добавлена, отпечаток в `migrations.sha256`; `pgstore.Migrate` гонялся каждой + тестовой базой батареи (сотни накатов за прогон). +- **Посадки мутаций — 5, пойманы 5/5, в копии с каноном** (`cp -a --parents platform + docs/architecture/14-api-contract`, скрипт `~/tm-p9-mut/run-mutations.sh`, логи `~/tm-p9-mut/*.log`), + вердикт по ДЕЛЬТЕ против чистой базы ТОЙ ЖЕ копии (база EXIT=0, FAILS пусто) и по ТОПИЧНОСТИ: + +| # | механизм | что посажено | что упало (ровно топичный пин) | +|---|---|---|---| +| M1 | дверь | класс 15 слит в `ErrBankUnavailable` | `TestBankVerdictKeepsTheRemediesApart` | +| M2 | провод | кортеж перестал требовать `sense` | `TestACorrectionRequestIsValidatedWholeWithPointers` | +| M3 | ключи | `Cfg.KeysFile` не доезжает до argv | `TestTheSpawnedUnitCarriesTheDeploymentKeyFile` | +| M4 | полоса | знаменатель снова `2×ceiling` | `TestARunOverADraftedBacklogOwesOnlyTheLastPass` | +| M5 | полоса | возвращено пере-базирование на снятии стопа | `TestADraftOnlyDeploymentCountsItsOneWaveOnce` | + +### Живой пробой (§4.2) — цепь срослась, и за $0 + +Полный лог — **`docs/p9/door-live-probe.md`**. Суть: книга через живой интейк → прогон до банкового +стопа → превью → правка → ретрай (`already_applied`) → отказ 409 с указателем → resume → 409 +`run_in_flight` при живом прогоне → ready → **банк следующей границы несёт правку** (方源 → +approved «Фан Юань-П9», задеклайненная поверхность исчезла из предложений). ⚠ Провайдер — локальная +$0-заглушка (`local`-модель из `models.yaml`), потому что **в файле ключей деплоя нет ни одного +живого провайдерского ключа** — движковый пре-флайт называл недостающие ключи по имени для всех +шести облачных провайдеров (значения ключей в сессию не читались, гардрейл `.env` цел; замер — отказ +резервации при потолке $0.000001, ДО вызова провайдера). Ось «деньги»: леджер стенда сверен ДВУМЯ +путями на нетривиальном состоянии (3 холда/3 возврата/3 расчёта) — CLI `balance` и сырой SQL сошлись +до цента ($25.000000). + +### Опровергатели (§4, заказ) — 2 агента, обе панели принесли «ломает», всё абсорбировано + +1. **Раскладка кодов.** «Ломает»: у двери не было гейта готовности книги (не-готовая книга доезжала + до движка и возвращалась 500-ложью «наш дефект») — **починено** (`readyToTranslate` под мьютексом + → 409 `book_not_ready`). «Спорно»: транзиентные держатели флока вне сериализации (границная + материализация, ручной tmctl) читаются словом `run_in_flight`; мьютекс внутрипроцессный + (мульти-реплика теряет сериализацию) — **заведено PD-400**, лечение вне заказа. Утечка пути + темп-файла в `refusals[].detail` на движковых капах — **починено** (редакция пути в + `ApplyBankCorrections`). Косметика: SIGINT вместо SIGTERM — починено (`syscall.SIGTERM`); пустой + `rejected` при exit 14 — гард добавлен; хвостовые байты за JSON — отвергаются (`dec.More()`). + ПРИНЯТО БЕЗ ПРАВКИ с причиной: `since_chapter: 3.0` (валидный integer по JSON Schema) реализация + 400-ит — генерённые клиенты целых через точку не шлют, названная узость; item-код `missing` на + присутствующем-но-пустом члене — словарь item-кодов открыт. +2. **Форма полосы.** «Ломает» №1: continuation поверх начернённого задела — ready на 50% с вечным + `drafting` — **починено** (`draftWork` в знаменателе и в stage; пин + посадка M4). «Ломает» №2: + бэкфил замораживал полосу легаси verify-прогонов — **починено** (миграция пере-снимает обе базы + live-прогонов верными предикатами; для ЗАКОНЧЕННЫХ легаси — названная аппроксимация в тексте + миграции). «Спорно»: флип `edit_wave` при живом прогоне двигает знаменатель — **заведено PD-401** + (окно секунды, лечение трогает словарь шва). ПРИНЯТО С ПРИЧИНОЙ: пере-нарезка внутри прогона + обнуляет полосу (снос resolutions — правда о пере-резанной книге, осознанное исключение); + `stage='editing'` на банковом стопе (статус `awaiting_bank` на экране первичен); старт-квитанция + читает «последний прогон книги» (вставка видна в своей транзакции, гонка требует регресса часов). + +### Диспозиции по норме §3 п.8 (греп открытых строк по ПОЛНЫМ путям моих файлов — 46 совпадений) + +- **PD-399 — ЗАКРЫТА** этим паком (см. §3.5, пин назван в строке). +- **PD-370 — предлагаю ЗАКРЫТЬ приёмке**: зонная половина закрыта 22.08, контрактная — минором + 0.5.0 (D39.161: ноль вхождений отменённой модели в каноне), ратифицированная замена (дверь правок) + построена этим паком. Закрытие — акт лендинга, не зоны: лекарство контрактной половины не в моём дереве. +- **PD-281 — остаётся open, дописка внесена**: сквозная форма сменила знаменатель, но прогон над + книгой, полной в обоих проходах, по-прежнему невидим до ready; канонному минору полосы НЕ + наследовать «the fraction always reaches one» без оговорки. +- **PD-396 — остаётся** (вопрос владельца по строке); замечено: счёт нерешённости теперь едет + квитанцией двери (`signature`), `UnsignedBankTerms` так и мёртв — снятие поля не брал (не в §3). +- Остальные 42 совпадения (PD-6…PD-397 по списку грепа) — **оставлены: совпадение по файлу, не по + механизму** — пак их механизмов не трогал; полный список воспроизводится: + `python3` -скриптом по `DEFECT_REGISTER.md` (колонка «Где» × список файлов описи). + +### Чего в паке НЕТ (по §3.7 — пропуски подписаны) + +Читающая сторона банка (221/224/226 — на стопе провод банка ПУСТ, видно живьём в пробое, шаг 5) · +sqlc · строка 198 · PD-375…PD-398 кроме PD-399 · воркер решений (синхронность ДЕРЖИТСЯ — замер, не +рассуждение: живой вызов двери — доли секунды при потолке, подобранном движком под таймаут) · +снятие обхода `--verify-bank` из пинга №21 (не в §3; `bank_released` остался в спавне и чтениях глав). + +### Obstacle — что НЕ удалось и что НЕ проверено + +- **Живой пробой на ОБЛАЧНОМ провайдере не удался: в файле ключей деплоя нет ни одного живого + ключа** (deepseek/zai/kimi/gemini/mistral/openai — все названы движком отсутствующими; grok — упёрся + в конфиг аддитивного биллинга раньше ключей). Цепь пробита на `local`-заглушке — она доказывает ШОВ + (дверь → глагол → файлы → resume → пере-сбор банка), но НЕ качество и НЕ поведение под латентностью + настоящего провайдера. Строка 202 «книга насквозь по-настоящему» упирается теперь ровно в ключи. +- **Таймаут-ветка двери (SIGTERM по бюджету) и класс 15 живьём не воспроизводились** — юнит-пины + есть, живого файлового отказа не строил. +- **`bank_corrections_enabled: false ⇒ 404` живьём не гонял** (нужен второй демон без движка) — + юнит-пин `TestAnUnmountedCorrectionDoorAnswers404AndSaysSoInCapabilities`. +- **Стенд-эффект на батарею**: живой юнит стенда оставил `tm-runs.slice` без контроллеров памяти — + `TestARunIsBoundedByItsOwnCgroup` падал, пока слайс не сброшен (`systemctl --user stop + tm-runs.slice`); это среда, не регрессия — на чистом слайсе зелёный. Приёмке знать при пере-прогоне. +- **Пробные артефакты вне репо**: `~/tm-p9-work/` (стенд, логи батарей, fake-провайдер), + `~/tm-p9-mut/` (копия с каноном + логи посадок). БД стенда `tmp9stand` на локальном PG 5432 — можно + сносить. В КАТАЛОГЕ КНИГ стенда остались мои крафтовые книги — репо не касаются. +- Сырые логи двух опровергателей не сохранены в зону — их отчёты абсорбированы сюда и в PD-400/401. + +### Вопросы оркестратору + +1. **Канонный минор полосы** (D39.160: полоса едет паком, механика версии — за тобой): деплой этой + сдачи шлёт `progress.done/total` в сквозной семантике (chapter-passes работы ПРОГОНА) + новый член + `progress.stage` (открытый словарь `drafting`/`editing`) — впереди объявленного 0.5.0 по прецеденту + `Run.stop_requested` (`v0.go`, ⚠-коммент у `wireProgress`). Канону нужны: пере-описание `Progress` + (снять «restarts from zero», НЕ наследовать «always reaches one» — PD-281), член `stage`. Кадр + `progress` меняется тем же минором (`EventProgress` ссылается на ту же схему). +2. **PD-370** — закрытие строкой за лендингом (см. диспозиции). +3. **Провайдерские ключи деплоя** — нужен хотя бы один живой ключ, чтобы строка 202 прошла на + настоящем провайдере; какие имена ключей движок ждёт — в логе пробоя. + +### Аддендум приёмки (27.08, вечер) — блокер ревьюера по полосе: причина починена, лендить ли — слово владельца + +Ревьюер приёмки (`textmachine-29`) принёс блокер: прогон, стартовавший при `edit_wave = false` с +переворотом флага ПОСЛЕ старта (движок объявляет форму волн первым progress-событием), делал всю +купленную работу с полосой `0/N` навсегда — база снята флаг-зависимым предикатом момента старта и +не пере-базируется. Мой пак знал механизм (текст миграции 00026 его называл) и вылечил только +legacy-прогоны на буте, оставив генератор живым; строка `PD-401` его называла, но с заниженным +весом и формулировкой «окно секунды». + +**Сделано по первому заказу оркестратора (до его поправки «жди слова» — работа уже была зелёной, +откат по слову, не молча):** причина, не следствие — обе базы снимаются на ФИКСИРОВАННЫХ колонках +(`chapters_before` — редакторская, `draft_before` — черновая, `StartRun` без CASE по флагу), пара +«числитель+база» выбирается ЖИВЫМ флагом в момент чтения (`runDone`: `draftBar+editBar` против +`draftOnlyBar`); миграция 00026 переписана (live-прогоны — обе базы на фиксированных колонках); +пин ровно на прод-порядок переворота — `TestAFlagThatFlipsAfterStartDoesNotStrandTheBar` (флаг +false ДО `StartRun`, переворот ПОСЛЕ, вся работа → 2/2). Сценарий блокера сходится: до переворота +0/C, после — `draftWork = 0` ⇒ total = C, редактура двигает 0→C, ready C/C. + +**Побочно вскрыто и починено (класс PD-1):** два старых пина `recut_test.go` пережили снос +сегментной модели с ложными словами — `TestLiftingTheSigningStopRestartsTheBarFromZero` объявлял +«Mutation caught: dropping the re-capture from the re-open», а ре-кэпчер снесён и тест зелёный; +переименован в `TestLiftingTheSigningStopLeavesTheBarWhereItStood` с честным свойством и живым +mutation-catch (спутать пары «числитель×база»); комментарий +`TestTheBarAndItsBaselineCountTheSamePass` переписан под пары-по-колонкам. + +**PD-401 пере-формулирована и пере-взвешена** (info → minor, переезд в minor-секцию) по слову +оркестратора: постоянная слепота платной работы, не транзиентный скачок; лечение в дереве названо +в строке, статус open до решения владельца о составе лендинга. Регистр: 401 строка, 97 открытых +(3/28/66), оба гейта чисты; полный `pgstore` зелёный (`go test ./internal/pgstore/ -count=1` → ok). +Ось денег на нетривиальном состоянии (открытый холд + после расчёта) ревьюер исполнил на этом +дереве сам — расхождений нет. + +### Аддендум 2 (28.08, по слову владельца «техдолг в паке не держим») — PD-400 разобрана по половинам + +**Половина 1 (слово `run_in_flight` шире правды) — ЗАКРЫТА в дереве**, и лечение оказалось точнее, +чем строка думала: под мьютексом и ПОСЛЕ проверки строки прогона класс 12 от глагола прогоном быть +не может по построению (Start/Resume ждут тот же мьютекс, реконсилер рестартует только живые строки, +которые проверка видит) — значит `run_in_flight` отвечается ТОЛЬКО из проверки собственной строки, а +класс 12 глагола едет `503 service_unavailable` «занято, повтори позже» с ERROR-строкой оператору. +Пин — обновлённый кейс вердикт-таблицы; `-race` по четырём задетым пакетам зелёный, линтер 0 issues. +**Половина 2 (внутрипроцессный мьютекс) — граница v1, названная с условием и ценой** в строке и в +шапке `bank.go`: сериализация сужается до пер-репличной при второй реплике; цена — холостая попытка +и «повтори позже», не ложь и не деньги; лечение при второй реплике — арбитр в хранилище. Предложен +перевод половины в «Принятый риск» словом лендинга. **`supervisor.go:35` проверен по существу — +ЧЕСТЕН для своего дев-пути** (ключи там законно едут окружением/наследованием); дописана одна +страховочная фраза «прод передаёт ключи аргументом `--keys-file`; копировать этот канал в прод-спавн +— ловушка паритета, которую флаг и закрыл». Сдвинутые этой правкой якоря runs.go пере-нацелены, +оба гейта регистра чисты. + +## ПАК P9 — ЗАПИСКА-ПЛАН (сессия платформы, 27.08, промт `docs/PLATFORM_P9_SESSION_PROMPT.md`) + +План до правок, по §6 промта. Итоги и таблица комплектности — записью сдачи ниже по завершении. + +1. **§3.3 Ключи движку.** Новый конфиг `TM_PLATFORM_ENGINE_KEYS_FILE` (абсолютный путь или отказ на + буте, как `StateDir`) → `RunnerConfig.KeysFile` → `runs.Config` → `runner.TranslateArgs` получает + `--keys-file` (флаг принимает ТОЛЬКО `translate` — `cmd/tmctl/invocation.go:151-157`). В окружение + юнита ключи не кладутся. Пусто = флаг не передаётся (сегодняшнее поведение), с WARN на буте. +2. **§3.4 Полоса.** Форма: `done/total` пере-определяются как «проходы-главы через ОБЕ волны»: + `total = 2×ceiling` при редакторе (`edit_wave`), иначе `ceiling`; `done = clamp(главы-с-черновиком + − draft_before) + clamp(главы-с-последним-проходом − chapters_before)`, каждый clamp в + `[0, ceiling]`. Монотонно по построению (счётчики юнитов только растут, базы фиксированы на + старте), база и кап прогона сохранены (комментарий `readmodel.go:437-445` чтится). Миграция 00026: + `runs.draft_before` (бэкфил = `chapters_before`); `StartRun` снимает ОБЕ базы, ветвление по + `$3=verify_bank` уходит. Подпись «что делается сейчас» — новое поле `progress.stage` + (`drafting`/`editing`, открытый словарь, клиент рисует фразу сам) в JSON и в кадре `progress`. + ⚠ Канон 0.5.0 описывает `Progress` посегментно — канонный минор к смене едет с лендингом + (D39.160: полоса ЗДЕСЬ, механика гейта версий — у оркестратора); прецедент поля впереди + объявленной версии — `Run.stop_requested` (`v0.go:138`). +3. **§3.5 PD-399.** Снять `pending_decisions`/`complete` с `BankPage` и `EventBank`: `BankCounts`, + `payload()`, `wireBankPage`, `listBank`; пин `reading_test.go:113` ПЕРЕПИСЫВАЕТСЯ на новый + контракт (присутствие total/signed + ОТСУТСТВИЕ снятых полей) — правка с пином по заказу. +4. **§3.6 Дубль конвенции.** Движок публикует конверт `artifacts` (`bank_export` и др.) в + `status --json` И `manifest --json` (`backend/internal/pipeline/status.go:110-134`, лендинг + d1eb8a9). `ingest.Manifest` получает конверт; `refreshBank` берёт путь из ТОЛЬКО ЧТО прочитанного + манифеста той же refresh-пачки; `runner.projectDB()` и парс `book.yaml` удаляются. Без фолбэка: + старый движок без конверта = ошибка с именем причины, долг ретраится (порядок деплоя «движок + первым» — норма зоны). +5. **§3.1+§3.2 Дверь.** Маршрут `POST /books/{bookId}/bank/corrections` в таблице `contractSurface`, + монтирование по `Deps.Bank != nil`; `Capabilities.bank_corrections_enabled` = тот же факт; false ⇒ + 404. Вызов СИНХРОННЫЙ: обработчик валидирует проводную форму (строгий декод, оба потолка ДО + спавна: >1 МиБ → 413 через кап тела маршрута, >5000 и вся форма → 400), рендерит документ движка + (`tm-bank-decisions-v1`, null-окна → 0), кладёт во временный файл под `StateDir`, зовёт + `tmctl bank-apply` прямым чайлдом с бюджетом `RunBudget` (60 с — класс бюджета вызовов движка), + декодирует отчёт v2 аллоулистом, отвечает квитанцией. Сериализация: пер-книжный мьютекс в + `runs.Service`, его берут corrections И `Resume`/`Start` (та же гонка на спавне свежего прогона); + проверка «жив прогон» — ПОД мьютексом (`ReadBookForRun.HasLiveRun` → 409 `run_in_flight`). + Раскладка кодов: заказанное — 14→409 `bank_corrections_refused` (+`refusals[]` в `Problem`), + 15→503 `bank_corrections_incomplete`, 12→409 `run_in_flight`, тело >1 МиБ→413. Моя половина, с + доводом в отчёте: 13 (схема не мигрирована — оператор, транзиентно) → 503 `service_unavailable`; + 19 (класс без номера — рассинхрон сборок) → 503 `service_unavailable`; 10/11 (сломан вызывающий/ + деплой) → 500 `internal_error`; exit 5 и таймаут бюджета → 503 `service_unavailable`. Ни один не + сливается в неразличимый 500: три ремеди («пере-реши»/«повтори то же»/«позови оператора») + различимы по коду. `depth: edit_wave→refinement`; неизвестный depth движка НЕ форвардится + (утечка имени волны) — 500 с ERROR-строкой. +6. **Самопроверка §4:** батарея с тремя гейтами на копии-с-каноном (скипы отдельным `-v`-грепом); + живой пробой двери на стенде против настоящего движка (книга → банковый стоп → preview → правка → + resume → следующий прогон видит правку; лог в отчёт); посадки мутаций — минимум по одной на + дверь/ключи/прогресс, вердикт по дельте и топичности; опровергатели (2 агента): раскладка кодов и + форма полосы. +7. **Не делаю:** читающая сторона банка (221/224/226) · sqlc · строка 198 · PD-375…PD-398 кроме + PD-399 · воркер решений · снятие обхода `--verify-bank` из пинга №21 (не в составе §3; трогаю + `bank_released` только в чтении полосы). + ## ПАК P8-REVIEW ПРИНЯТ И ЗАЛЕНДЁН (оркестратор №19, 27.08) — ратификация D39.159 Пак принят целиком: четыре оси, 24 новые строки, 17 дописок, каталог воспроизведения. Тело приёмки — @@ -81,14 +509,14 @@ 2. **⛔ БЛОКЕР ЖИВОГО ПРОГОНА: движок на SaaS не получает провайдерских ключей** — строка **211** единого бэклога, тело там. Ваша половина: путь к файлу ключей едет из конфига платформы в аргументы движка, а дев-супервизор переводится на тот же механизм. Сейчас пути РАЗНЫЕ — - `internal/ingest/supervisor.go:35-36,65-67`=`Provider keys reach the engine through it` наследует + `internal/ingest/supervisor.go` (до-паковые строки 35-36 и 65-67 — «Provider keys reach the engine through it»; комментарий переписан лендингом P9, якорь исторический) наследует окружение платформы, а прод-спавн ставит юниту ровно один элемент - (`internal/runs/spawn.go:168`=`Env: engineEnv(engineStreamID(`). Поэтому стенд зелёный, а прод + (`internal/runs/spawn.go`, до-паковая строка 168 `Env: engineEnv(…)`; с лендингом P9 ключи едут аргументом `--keys-file`, якорь исторический). Поэтому стенд зелёный, а прод голодает, и ни один тест упасть не мог. Пока два пути кормят движок по-разному, следующий такой блокер снова пройдёт всю батарею. -3. **Дубль движковой конвенции у вас.** `internal/runner/artifacts.go:65-95`=`neither project_db nor book_id` - сам вычисляет путь БД книги, повторяя `backend/internal/config/book.go:163-167`=`b.ProjectDB = filepath.Join(dir, b.BookID+".db")`; +3. **Дубль движковой конвенции у вас.** `internal/runner/artifacts.go` (до-паковые строки 65-95 — «neither project_db nor book_id»; `projectDB` снесён лендингом P9, файл ужался, якорь исторический) + сам вычисляет путь БД книги, повторяя `backend/internal/config/book.go` (до-паковые строки 163-167 — `b.ProjectDB = filepath.Join(…)`; конвенция ушла в конверт манифеста движковой половиной, якорь исторический); смена дефолта в движке тихо уведёт ваше чтение банка на несуществующий путь. Строка **213**. Фикс аддитивный и без ломки: движок отдаёт путь артефакта, вы выкидываете свой `projectDB()`. ⚠ Пере-именование банк-экспорта в фикс-имя — ЛОМАЮЩЕЕ, ему место в окне строки 161, не здесь. diff --git a/platform/internal/config/config.go b/platform/internal/config/config.go index e196ab99..8215caa0 100644 --- a/platform/internal/config/config.go +++ b/platform/internal/config/config.go @@ -115,6 +115,15 @@ type RunnerConfig struct { // Defaulted to the landed form (row 145, D39.122) and overridable for a build that spells the flag // differently; an override that does not carry {{usd}} is refused rather than sent as a literal. CeilingArg string + // KeysFile is the DEPLOYMENT's provider-key file, handed to `translate` as `--keys-file` + // (row 211). The path travels as an engine argument and the keys never pass through this + // process or the unit's environment. Empty means the flag is not passed — the engine then + // depends on a `.env` beside each book, which nothing on the SaaS path writes. + // + // ⚠ The variable is `_PATH`, not `_FILE`, and the suffix is load-bearing: in this service's own + // convention `KEY_FILE` names a file whose CONTENT replaces KEY's value (see loader.secret) — + // this value IS the path, read by the engine and never by this process. + KeysFile string // MemoryMax and TasksMax bound ONE run's cgroup (PD-13). MemoryMax string TasksMax int @@ -424,6 +433,7 @@ func (c *Config) loadRunner(l *loader) error { StateDir: l.env("TM_PLATFORM_STATE_DIR", "/var/lib/tmplatform"), MarkerBinary: l.env("TM_PLATFORM_CTL_BIN", ""), CeilingArg: l.env("TM_PLATFORM_ENGINE_CEILING_ARG", "--ceiling-usd {{usd}}"), + KeysFile: l.env("TM_PLATFORM_ENGINE_KEYS_PATH", ""), // A run gets a generous but finite share of the machine. The figure is a BACKSTOP, not a // sizing: a translation is bounded by its ceiling in dollars, and this bounds the one way a // single run can hurt the others regardless of money (PD-13). @@ -444,6 +454,12 @@ func (c *Config) loadRunner(l *loader) error { if !filepath.IsAbs(r.StateDir) { return fmt.Errorf("config: TM_PLATFORM_STATE_DIR must be an absolute path, got %q", r.StateDir) } + // Absolute for the same reason StateDir is: the flag is read by a systemd unit whose working + // directory is the BOOK's, so a relative path would name a different file per book — and the + // symptom, a paid run failing at its first provider call, points nowhere near the cause. + if r.KeysFile != "" && !filepath.IsAbs(r.KeysFile) { + return fmt.Errorf("config: TM_PLATFORM_ENGINE_KEYS_PATH must be an absolute path, got %q", r.KeysFile) + } var err error if r.TasksMax, err = l.number("TM_PLATFORM_RUN_TASKS_MAX", r.TasksMax); err != nil { return err diff --git a/platform/internal/httpapi/bank.go b/platform/internal/httpapi/bank.go new file mode 100644 index 00000000..135c6a1e --- /dev/null +++ b/platform/internal/httpapi/bank.go @@ -0,0 +1,437 @@ +package httpapi + +import ( + "bytes" + "context" + "encoding/json" + "errors" + "fmt" + "io" + "net/http" + "strconv" + + "textmachine/platform/internal/ingest" + "textmachine/platform/internal/runs" +) + +// Bank is the correction door, as the HTTP layer needs to see it. +type Bank interface { + ApplyBankCorrections(ctx context.Context, in runs.BankCorrectionsInput) (runs.BankReceipt, error) +} + +// bank.go: the correction door, `POST /books/{bookId}/bank/corrections` (canon 0.5.0, +// §applyBankCorrections) — the HTTP half over runs.ApplyBankCorrections. +// +// The handler owns the WIRE form and everything the canon orders measured before the engine is +// spawned: the strict decode (an undeclared member refuses the request — this schema declares +// `additionalProperties: false`, unlike the tolerant JSON routes next door), the whole-form +// validation into `400 invalid_request`, the 1 MiB body → `413`, and the 5000-correction ceiling. +// The count ceiling is measured HERE on the wire form; the BYTE ceiling is measured twice — the +// body cap here, and the RENDERED document against the engine's own cap in runs (the envelope adds +// a few bytes over the wire's) — so the engine's copies of both caps, which it classifies as its +// refusal class 14, are never reached and the canon's 400/413 words are kept. + +// maxCorrections is the canon's hard ceiling on one act (§BankCorrectionsRequest, mirrored from the +// engine's own maxDecisions — chosen there so one call fits a caller's timeout). +const maxCorrections = 5000 + +// termKinds is the wire's closed `TermKind` vocabulary (canon §TermKind). The engine accepts any +// non-empty string through this door; the wire is deliberately narrower — a kind the reading +// surface does not know cannot be set through it. +var termKinds = map[string]bool{"name": true, "place": true, "title": true, "term": true, "nickname": true} + +type wireCorrectionsRequest struct { + BookID *string `json:"book_id"` + // Preview is REQUIRED, not defaulted: which of the two acts this call is must be said. + Preview *bool `json:"preview"` + Corrections []wireCorrection `json:"corrections"` +} + +// wireCorrection is one correction as the wire declares it. Every member is presence-sensitive — +// the tuple form requires ALL FOUR of its members precisely so a caller cannot omit `sense` and +// silently name a DIFFERENT term (an unknown key legally ADDS one here, so the price of the +// omission would be a quiet parallel row, not a refusal). Presence-sensitive means every member +// must tell «absent» from «sent as null»: a plain pointer collapses the two, and the schema's +// presence rules (`oneOf` on the identity, `not: required` on a decline's dst/kind) are about KEYS, +// value irrelevant — a client that serializes every optional field as null walked straight through +// them (workflow finding, P9). Hence nullable* for every member, not just the windows. +type wireCorrection struct { + Action nullableString `json:"action"` + ID nullableString `json:"id"` + Src nullableString `json:"src"` + Sense nullableString `json:"sense"` + SinceChapter nullableInt `json:"since_chapter"` + UntilChapter nullableInt `json:"until_chapter"` + Dst nullableString `json:"dst"` + Kind nullableString `json:"kind"` + Note nullableString `json:"note"` +} + +// nullableInt tells an ABSENT member from an explicit `null` from a number — the window members are +// `integer|null` and required in the tuple form, so all three states carry meaning. +type nullableInt struct { + Given bool + Value *int +} + +func (n *nullableInt) UnmarshalJSON(b []byte) error { + n.Given = true + if bytes.Equal(bytes.TrimSpace(b), []byte("null")) { + return nil + } + var v int + if err := json.Unmarshal(b, &v); err != nil { + return err + } + n.Value = &v + return nil +} + +// nullableString is nullableInt's shape for the string members. None of them is `string|null` in +// the schema, so `Given && Value == nil` is always a type violation — but it must be SEEN to be +// refused, which a *string cannot do. +type nullableString struct { + Given bool + Value *string +} + +func (n *nullableString) UnmarshalJSON(b []byte) error { + n.Given = true + if bytes.Equal(bytes.TrimSpace(b), []byte("null")) { + return nil + } + var v string + if err := json.Unmarshal(b, &v); err != nil { + return err + } + n.Value = &v + return nil +} + +// str reads the member's value where validation has already established it is a present, non-null +// string. +func (n nullableString) str() string { return *n.Value } + +func (h *v0) bankCorrections(w http.ResponseWriter, r *http.Request) { + user, ok := principal(w, r) + if !ok { + return + } + body, err := io.ReadAll(r.Body) + if err != nil { + var tooLarge *http.MaxBytesError + if errors.As(err, &tooLarge) { + // The canon's 1 MiB document ceiling, measured on the wire BEFORE anything is rendered + // or spawned (§applyBankCorrections). The route's body cap is exactly the document cap. + Fail(w, r, CodePayloadTooLarge) + return + } + h.log.InfoContext(r.Context(), "the body of a correction request did not arrive", "err", err) + Invalid(w, r) + return + } + // STRICT, unlike the run request next door, and the difference is the canon's own: this schema + // declares `additionalProperties: false` on both levels — an unknown member is a caller + // believing it set something, and the quiet version of that is a correction half-applied. + dec := json.NewDecoder(bytes.NewReader(body)) + dec.DisallowUnknownFields() + var req wireCorrectionsRequest + if err := dec.Decode(&req); err != nil || dec.More() { + // dec.More(): the body is ONE document; trailing bytes after it are a second one nobody + // will read, which is the same quiet half-belief the strict decode refuses. + Invalid(w, r) + return + } + if items := validateCorrections(req, r.PathValue("bookId")); len(items) > 0 { + Invalid(w, r, items...) + return + } + receipt, err := h.bank.ApplyBankCorrections(r.Context(), runs.BankCorrectionsInput{ + UserID: user, + BookID: r.PathValue("bookId"), + Preview: *req.Preview, + Decisions: renderDecisions(req.Corrections), + }) + if err != nil { + h.bankFail(w, r, err) + return + } + out, err := projectBankReceipt(receipt) + if err != nil { + // A word of the engine's this build cannot map — the seam's vocabulary moved. Refused rather + // than forwarded: an unmapped value here would put a wave name on the wire. + h.log.ErrorContext(r.Context(), "the correction receipt could not be projected", "err", err) + Fail(w, r, CodeInternalError) + return + } + h.writeJSON(w, r, http.StatusOK, out) +} + +// bankFail maps the door's own refusals; everything else falls through to the shared table. +func (h *v0) bankFail(w http.ResponseWriter, r *http.Request, err error) { + var refused *runs.ErrBankRefused + switch { + case errors.As(err, &refused): + // All-or-nothing: one refused correction refuses the set, and the refused one is what the + // user has to see (canon §applyBankCorrections). The receipt does NOT ride the refusal — + // the envelope grows the refusal members instead. + p := Problem{Code: CodeBankCorrectionsRefused, + Refusals: make([]CorrectionRefusal, 0, len(refused.Refusals))} + for _, f := range refused.Refusals { + pointer := "" + if f.Index >= 0 { + pointer = "/corrections/" + strconv.Itoa(f.Index) + } + p.Refusals = append(p.Refusals, CorrectionRefusal{Pointer: pointer, Detail: f.Reason}) + } + WriteProblem(w, r, p) + case errors.Is(err, runs.ErrBankDocumentTooLarge): + // The rendered document is over the engine's byte ceiling — the same fact the body cap + // answers, measured on the form the engine actually reads (the envelope's few bytes above + // the wire's). One word for one fact: 413, split the document. + Fail(w, r, CodePayloadTooLarge) + case errors.Is(err, runs.ErrBankIncomplete): + // The document was ACCEPTED and did not land whole; the remedy is to re-send the SAME one. + // Its own code, because the addressee differs from every other 503: the machine retries + // verbatim, no one re-decides. + Fail(w, r, CodeBankCorrectionsIncomplete) + case errors.Is(err, runs.ErrBankUnavailable): + Fail(w, r, CodeServiceUnavailable) + default: + h.fail(w, r, err) + } +} + +// validateCorrections is the canon's whole-form validation, every finding with its JSON Pointer. +// The rules are the schema's own (§BankCorrectionsRequest, §BankCorrection); what the schema leaves +// to the service — an unknown id, a window that ends before it begins, contradictions the SET +// introduces — stays the engine's and comes back as `409 bank_corrections_refused`. +func validateCorrections(req wireCorrectionsRequest, pathBook string) []Item { + var items []Item + switch { + case req.BookID == nil || *req.BookID == "": + items = append(items, Item{Pointer: "/book_id", Code: ItemMissing}) + case *req.BookID != pathBook: + // The deliberate second carrier of one fact: a set computed for one book landing in another + // is not a mistake anything downstream could notice (canon §BankCorrectionsRequest.book_id). + items = append(items, Item{Pointer: "/book_id", Code: ItemMalformed}) + } + if req.Preview == nil { + items = append(items, Item{Pointer: "/preview", Code: ItemMissing}) + } + switch n := len(req.Corrections); { + case n == 0: + items = append(items, Item{Pointer: "/corrections", Code: ItemMissing}) + case n > maxCorrections: + // The count ceiling, measured on the WIRE form before the engine is spawned — the engine + // classifies its own copy of this cap as a refusal of the set, the canon says the schema + // bound is a 400: split the document. + // + // And the ceiling is a POINT OF STOPPING, not only an item: walking the elements past it + // itemized every flaw of an oversized document, and a megabyte of minimal corrections came + // back as tens of megabytes of pointers — a ~100× amplification a caller does not even need + // a session for more than once (reviewer finding, P9). Whoever hit the cap gets the cap. + items = append(items, Item{Pointer: "/corrections", Code: ItemOutOfRange}) + return items + } + for i, c := range req.Corrections { + items = append(items, validateCorrection(c, "/corrections/"+strconv.Itoa(i))...) + } + return items +} + +func validateCorrection(c wireCorrection, at string) []Item { + var items []Item + // None of the string members is nullable in the schema: an explicit `null` on any of them is a + // type violation, refused as malformed — and refused HERE, before the presence rules below read + // `Given`, so a null never doubles as a value. + for _, m := range []struct { + n nullableString + name string + }{{c.Action, "/action"}, {c.ID, "/id"}, {c.Src, "/src"}, {c.Sense, "/sense"}, + {c.Dst, "/dst"}, {c.Kind, "/kind"}, {c.Note, "/note"}} { + if m.n.Given && m.n.Value == nil { + items = append(items, Item{Pointer: at + m.name, Code: ItemMalformed}) + } + } + if len(items) > 0 { + return items + } + approve := false + switch { + case !c.Action.Given: + items = append(items, Item{Pointer: at + "/action", Code: ItemMissing}) + case c.Action.str() == "approve": + approve = true + case c.Action.str() == "decline": + default: + items = append(items, Item{Pointer: at + "/action", Code: ItemMalformed}) + } + // The identity: an existing row by `id` XOR a term by its FULL tuple — both at once could name + // two different terms, and the caller would never learn which one was taken. Presence is the + // KEY's presence: an explicit null was already refused above, so it cannot smuggle a member in + // or out of either form. + tupleGiven := c.Src.Given || c.Sense.Given || c.SinceChapter.Given || c.UntilChapter.Given + switch { + case c.ID.Given && tupleGiven: + items = append(items, Item{Pointer: at, Code: ItemMalformed}) + case c.ID.Given: + if c.ID.str() == "" { + items = append(items, Item{Pointer: at + "/id", Code: ItemMalformed}) + } + default: + // The tuple form, all four members or nothing: a partial key is ANOTHER key, and an unknown + // key legally ADDS a term here — so the omission's price would be a quiet parallel row. + if !c.Src.Given || c.Src.str() == "" { + items = append(items, Item{Pointer: at + "/src", Code: ItemMissing}) + } + if !c.Sense.Given { + items = append(items, Item{Pointer: at + "/sense", Code: ItemMissing}) + } + items = append(items, requireWindow(c.SinceChapter, at+"/since_chapter")...) + items = append(items, requireWindow(c.UntilChapter, at+"/until_chapter")...) + } + if approve { + if !c.Dst.Given || c.Dst.str() == "" { + // An approved term with no rendering is not a weak approval — it would fail the next run. + items = append(items, Item{Pointer: at + "/dst", Code: ItemMissing}) + } + if c.Kind.Given && !termKinds[c.Kind.str()] { + // The wire's `TermKind` is the READING surface's closed vocabulary; a kind it does not + // know cannot be set through this door even though the engine itself would take it. + items = append(items, Item{Pointer: at + "/kind", Code: ItemMalformed}) + } + } else if c.Action.Given { + // A rendering on a decline says the caller meant to approve, and half of that is not + // something to guess at; `kind` is forbidden with it. The rule is about the KEY: a decline + // carrying `dst: null` believed it said something about the rendering just as loudly. + if c.Dst.Given { + items = append(items, Item{Pointer: at + "/dst", Code: ItemMalformed}) + } + if c.Kind.Given { + items = append(items, Item{Pointer: at + "/kind", Code: ItemMalformed}) + } + } + return items +} + +func requireWindow(n nullableInt, at string) []Item { + switch { + case !n.Given: + return []Item{{Pointer: at, Code: ItemMissing}} + case n.Value != nil && *n.Value < 1: + return []Item{{Pointer: at, Code: ItemOutOfRange}} + } + return nil +} + +// renderDecisions projects the validated wire form into the seam's vocabulary. The `null` window +// becomes the seam's 0 and an empty `sense` stays empty — by this point the two forms mean the same +// thing to the engine (canon: «null-семантика окна — как у BankTerm; проекция null→0 — платформа»). +func renderDecisions(cs []wireCorrection) []ingest.BankDecision { + out := make([]ingest.BankDecision, 0, len(cs)) + for _, c := range cs { + d := ingest.BankDecision{Action: c.Action.str()} + if c.ID.Given { + d.ID = c.ID.str() + } else { + d.Src, d.Sense = c.Src.str(), c.Sense.str() + if v := c.SinceChapter.Value; v != nil { + d.SinceChapter = *v + } + if v := c.UntilChapter.Value; v != nil { + d.UntilChapter = *v + } + } + if c.Dst.Given { + d.Dst = c.Dst.str() + } + if c.Kind.Given { + d.Kind = c.Kind.str() + } + if c.Note.Given { + d.Note = c.Note.str() + } + out = append(out, d) + } + return out +} + +// The receipt's wire shapes (canon §BankCorrectionsReceipt), an allowlist like every projection. + +type wireBankReceipt struct { + Preview bool `json:"preview"` + Changed bool `json:"changed"` + Depth string `json:"depth"` + Accepted []wireAcceptedCorrection `json:"accepted"` + PreexistingFaults int `json:"preexisting_faults"` + // Signature is `null` when no run has reached a signing stop yet — nothing to count against. + Signature *wireSignatureCount `json:"signature"` +} + +type wireAcceptedCorrection struct { + Index int `json:"index"` + Action string `json:"action"` + ID string `json:"id"` + Src string `json:"src"` + Dst *string `json:"dst"` + State string `json:"state"` + // Displaced says the correction overwrote an earlier word — a fact, not an error. The + // itemization of WHAT stays server-side: its vocabulary is the engine's free text. + Displaced bool `json:"displaced"` +} + +type wireSignatureCount struct { + Surfaces int `json:"surfaces"` + Undecided int `json:"undecided"` + Unreadable bool `json:"unreadable"` +} + +// projectBankReceipt translates the service's receipt into contract words, refusing any value it +// cannot map: the engine's vocabulary is wave names, and forwarding an unmapped one would leak the +// pipeline's architecture through the one field a screen quotes. +func projectBankReceipt(rec runs.BankReceipt) (wireBankReceipt, error) { + depth, err := contractDepth(rec.Depth) + if err != nil { + return wireBankReceipt{}, err + } + out := wireBankReceipt{ + Preview: rec.Preview, Changed: rec.Changed, Depth: depth, + Accepted: make([]wireAcceptedCorrection, 0, len(rec.Accepted)), + PreexistingFaults: rec.PreexistingFaults, + } + for _, a := range rec.Accepted { + if a.Action != "approve" && a.Action != "decline" { + return wireBankReceipt{}, fmt.Errorf("httpapi: an accepted correction carries the action %q", a.Action) + } + if a.State != "applied" && a.State != "already_applied" { + return wireBankReceipt{}, fmt.Errorf("httpapi: an accepted correction carries the state %q", a.State) + } + w := wireAcceptedCorrection{ + Index: a.Index, Action: a.Action, ID: a.ID, Src: a.Src, + State: a.State, Displaced: len(a.Replaced) > 0, + } + if a.Action == "approve" { + dst := a.Dst + w.Dst = &dst + } + out.Accepted = append(out.Accepted, w) + } + if rec.Signature != nil { + out.Signature = &wireSignatureCount{ + Surfaces: rec.Signature.Surfaces, Undecided: rec.Signature.Undecided, + Unreadable: rec.Signature.Unreadable, + } + } + return out, nil +} + +// contractDepth translates the engine's depth into the contract's open vocabulary. `edit_wave` is +// a WAVE name and forbidden on the wire; `refinement` is the product word with the same two halves +// of meaning — applied when a run next refines the text, and the draft is not re-formed. +func contractDepth(engine string) (string, error) { + if engine == ingest.DepthEditWave { + return "refinement", nil + } + return "", fmt.Errorf("httpapi: the engine's correction depth %q has no contract word", engine) +} diff --git a/platform/internal/httpapi/bank_test.go b/platform/internal/httpapi/bank_test.go new file mode 100644 index 00000000..b7d0bdff --- /dev/null +++ b/platform/internal/httpapi/bank_test.go @@ -0,0 +1,338 @@ +package httpapi + +import ( + "context" + "net/http" + "strings" + "testing" + + "textmachine/platform/internal/ingest" + "textmachine/platform/internal/pgstore" + "textmachine/platform/internal/runs" +) + +// The correction door (canon 0.5.0 §applyBankCorrections): the wire form, its whole-form +// validation, the receipt's projection and the refusal envelope. + +type fakeBank struct { + in runs.BankCorrectionsInput + called bool + receipt runs.BankReceipt + err error +} + +func (f *fakeBank) ApplyBankCorrections(_ context.Context, in runs.BankCorrectionsInput) (runs.BankReceipt, error) { + f.in, f.called = in, true + return f.receipt, f.err +} + +func bankServer(t *testing.T, f *fakeBank) http.Handler { + t.Helper() + return v0ServerWith(t, Deps{Bank: f, Capabilities: Capabilities{BankCorrectionsEnabled: true}}) +} + +// A deployment that has not mounted the door says so twice with ONE fact: the capability is false +// and the path answers 404 (canon: «declared ahead of its serving half»). +func TestAnUnmountedCorrectionDoorAnswers404AndSaysSoInCapabilities(t *testing.T) { + h := readingServer(t, &fakeLibrary{}) // no Bank dependency + w := call(t, h, "POST", "/v0/books/bk_1/bank/corrections", + `{"book_id":"bk_1","preview":true,"corrections":[{"action":"decline","src":"蛊","sense":"","since_chapter":null,"until_chapter":null}]}`) + if w.Code != http.StatusNotFound { + t.Errorf("an unmounted door answered %d, want 404", w.Code) + } + caps := decode(t, call(t, h, "GET", "/v0/capabilities", "")) + if got, ok := caps["bank_corrections_enabled"]; !ok || got != false { + t.Errorf("bank_corrections_enabled = %v, want false on a deployment without the door", got) + } +} + +// The whole-form validation: every refusal is a 400 with the offending member's JSON Pointer, and +// nothing reaches the service. The rules are the schema's own — the tuple takes ALL FOUR members, +// identity is id XOR tuple, `dst` belongs to `approve` alone, the wire's TermKind is closed. +func TestACorrectionRequestIsValidatedWholeWithPointers(t *testing.T) { + cases := []struct { + name, body string + pointer string + }{ + {"no preview", `{"book_id":"bk_1","corrections":[{"action":"decline","id":"tm_1"}]}`, "/preview"}, + {"book_id mismatch", `{"book_id":"bk_OTHER","preview":true,"corrections":[{"action":"decline","id":"tm_1"}]}`, "/book_id"}, + {"no corrections", `{"book_id":"bk_1","preview":true,"corrections":[]}`, "/corrections"}, + {"no action", `{"book_id":"bk_1","preview":true,"corrections":[{"id":"tm_1"}]}`, "/corrections/0/action"}, + {"both identities", `{"book_id":"bk_1","preview":true,"corrections":[{"action":"decline","id":"tm_1","src":"蛊"}]}`, "/corrections/0"}, + {"a partial tuple", `{"book_id":"bk_1","preview":true,"corrections":[{"action":"decline","src":"蛊","since_chapter":null,"until_chapter":null}]}`, "/corrections/0/sense"}, + {"approve without dst", `{"book_id":"bk_1","preview":false,"corrections":[{"action":"approve","id":"tm_1"}]}`, "/corrections/0/dst"}, + {"dst on a decline", `{"book_id":"bk_1","preview":true,"corrections":[{"action":"decline","id":"tm_1","dst":"Гу"}]}`, "/corrections/0/dst"}, + {"kind on a decline", `{"book_id":"bk_1","preview":true,"corrections":[{"action":"decline","id":"tm_1","kind":"name"}]}`, "/corrections/0/kind"}, + {"a kind outside the wire vocabulary", `{"book_id":"bk_1","preview":true,"corrections":[{"action":"approve","id":"tm_1","dst":"Гу","kind":"weapon"}]}`, "/corrections/0/kind"}, + {"a zero chapter", `{"book_id":"bk_1","preview":true,"corrections":[{"action":"decline","src":"蛊","sense":"","since_chapter":0,"until_chapter":null}]}`, "/corrections/0/since_chapter"}, + // The presence rules are about KEYS, and a client that serializes every optional member as + // `null` must not walk through them: an explicit null is a type violation on every string + // member, seen and refused rather than collapsed into «absent» (workflow finding, P9). + {"a null src smuggled into the id form", `{"book_id":"bk_1","preview":true,"corrections":[{"action":"approve","id":"tm_1","src":null,"sense":null,"dst":"Гу"}]}`, "/corrections/0/src"}, + {"a null dst on a decline", `{"book_id":"bk_1","preview":true,"corrections":[{"action":"decline","id":"tm_1","dst":null,"kind":null}]}`, "/corrections/0/dst"}, + {"a null action", `{"book_id":"bk_1","preview":true,"corrections":[{"action":null,"id":"tm_1"}]}`, "/corrections/0/action"}, + } + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + f := &fakeBank{} + w := call(t, bankServer(t, f), "POST", "/v0/books/bk_1/bank/corrections", tc.body) + if w.Code != http.StatusBadRequest { + t.Fatalf("status %d, want 400: %s", w.Code, w.Body) + } + if f.called { + t.Error("an invalid document reached the service") + } + if !strings.Contains(w.Body.String(), `"`+tc.pointer+`"`) { + t.Errorf("the 400 does not point at %s: %s", tc.pointer, w.Body) + } + }) + } + // An UNDECLARED member refuses the request — this schema is strict, unlike the run request. + f := &fakeBank{} + w := call(t, bankServer(t, f), "POST", "/v0/books/bk_1/bank/corrections", + `{"book_id":"bk_1","preview":true,"corrections":[{"action":"decline","id":"tm_1","aliases":["蛊虫"]}]}`) + if w.Code != http.StatusBadRequest || f.called { + t.Errorf("an undeclared member answered %d (service called: %v), want a 400 refusal", w.Code, f.called) + } +} + +// A valid document reaches the service in seam vocabulary — `null` windows as the seam's 0 — and +// the receipt comes back in contract words: `refinement` and never the engine's wave name, `dst` +// null on a decline, `displaced` a boolean, `signature` present when a stop exists. +func TestAValidCorrectionRoundTripsIntoTheReceipt(t *testing.T) { + f := &fakeBank{receipt: runs.BankReceipt{ + Preview: false, Changed: true, Depth: "edit_wave", + Accepted: []ingest.AcceptedDecision{ + {Index: 0, Action: "approve", ID: "tm_9", Src: "蛊", Dst: "гу", State: "applied", Replaced: []string{"a previous rendering"}}, + {Index: 1, Action: "decline", ID: "tm_2", Src: "方源", State: "already_applied"}, + }, + PreexistingFaults: 2, + Signature: &ingest.SignatureState{Map: "/srv/books/bk_1/x.mined-signature.yaml", Surfaces: 200, Undecided: 40}, + }} + body := `{"book_id":"bk_1","preview":false,"corrections":[ + {"action":"approve","src":"蛊","sense":"","since_chapter":null,"until_chapter":null,"dst":"гу","kind":"term","note":"почему"}, + {"action":"decline","id":"tm_2"}]}` + w := call(t, bankServer(t, f), "POST", "/v0/books/bk_1/bank/corrections", body) + if w.Code != http.StatusOK { + t.Fatalf("status %d: %s", w.Code, w.Body) + } + if !f.called || f.in.BookID != "bk_1" || f.in.Preview { + t.Fatalf("the service saw %+v", f.in) + } + if len(f.in.Decisions) != 2 { + t.Fatalf("decisions: %+v", f.in.Decisions) + } + if d := f.in.Decisions[0]; d.Src != "蛊" || d.Sense != "" || d.SinceChapter != 0 || d.UntilChapter != 0 || + d.Dst != "гу" || d.Kind != "term" || d.Note != "почему" || d.ID != "" { + t.Errorf("the tuple form reached the seam as %+v", d) + } + if d := f.in.Decisions[1]; d.ID != "tm_2" || d.Src != "" { + t.Errorf("the id form reached the seam as %+v", d) + } + got := decode(t, w) + for _, k := range []string{"preview", "changed", "depth", "accepted", "preexisting_faults", "signature"} { + if _, ok := got[k]; !ok { + t.Errorf("the receipt is missing the required member %q", k) + } + } + if got["depth"] != "refinement" { + t.Errorf("depth = %v, want the contract's word", got["depth"]) + } + if strings.Contains(w.Body.String(), "edit_wave") { + t.Errorf("the engine's wave name reached the wire: %s", w.Body) + } + rows, _ := got["accepted"].([]any) + if len(rows) != 2 { + t.Fatalf("accepted: %v", got["accepted"]) + } + first, _ := rows[0].(map[string]any) + if first["displaced"] != true || first["state"] != "applied" || first["dst"] != "гу" { + t.Errorf("accepted[0]: %v", first) + } + if strings.Contains(w.Body.String(), "a previous rendering") { + t.Errorf("the engine's free-text itemization reached the wire: %s", w.Body) + } + second, _ := rows[1].(map[string]any) + if second["dst"] != nil || second["displaced"] != false { + t.Errorf("accepted[1]: %v", second) + } + sig, _ := got["signature"].(map[string]any) + if sig["surfaces"] != float64(200) || sig["undecided"] != float64(40) || sig["unreadable"] != false { + t.Errorf("signature: %v", got["signature"]) + } + if strings.Contains(w.Body.String(), "mined-signature") { + t.Errorf("a server-side path reached the wire: %s", w.Body) + } + // No stop yet: `signature` is null, not a zeroed count that reads as "everything decided". + f.receipt.Signature = nil + later := decode(t, call(t, bankServer(t, f), "POST", "/v0/books/bk_1/bank/corrections", body)) + if v, ok := later["signature"]; !ok || v != nil { + t.Errorf("with no stop, signature = %v, want null", v) + } +} + +// `"preview": true` REACHES the service as a preview — the one bit that separates a safe look from +// an irreversible write, pinned at the seam it crosses. The reviewer regressed exactly this with +// one constant (`Preview: false`) and the whole battery stayed green; the engine-side halves are +// pinned in runner (the `--dry-run` argv, and the live no-write proof). +// +// Mutation caught: hardcoding Preview on the way into the service, either way. +func TestAPreviewStaysAPreviewAcrossTheWire(t *testing.T) { + f := &fakeBank{receipt: runs.BankReceipt{Preview: true, Depth: "edit_wave"}} + w := call(t, bankServer(t, f), "POST", "/v0/books/bk_1/bank/corrections", + `{"book_id":"bk_1","preview":true,"corrections":[{"action":"decline","id":"tm_1"}]}`) + if w.Code != http.StatusOK { + t.Fatalf("status %d: %s", w.Code, w.Body) + } + if !f.called || !f.in.Preview { + t.Errorf("preview:true reached the service as preview=%v — a safe look became a write", f.in.Preview) + } + if got := decode(t, w)["preview"]; got != true { + t.Errorf("the receipt echoes preview=%v, want true", got) + } + f = &fakeBank{receipt: runs.BankReceipt{Preview: false, Depth: "edit_wave"}} + call(t, bankServer(t, f), "POST", "/v0/books/bk_1/bank/corrections", + `{"book_id":"bk_1","preview":false,"corrections":[{"action":"decline","id":"tm_1"}]}`) + if !f.called || f.in.Preview { + t.Errorf("preview:false reached the service as preview=%v — every write would be a no-op", f.in.Preview) + } +} + +// One refused correction refuses the set: the 409 carries `refusals[]` in the Problem envelope, +// each with the pointer of its correction — or the empty string for a refusal about the whole. +func TestARefusedSetAnswers409WithItsRefusals(t *testing.T) { + f := &fakeBank{err: &runs.ErrBankRefused{Refusals: []ingest.RejectedDecision{ + {Index: 1, Reason: "declining it would change nothing"}, + {Index: -1, Reason: "the set contradicts itself"}, + }}} + w := call(t, bankServer(t, f), "POST", "/v0/books/bk_1/bank/corrections", + `{"book_id":"bk_1","preview":false,"corrections":[{"action":"decline","id":"tm_1"},{"action":"decline","id":"tm_2"}]}`) + if w.Code != http.StatusConflict { + t.Fatalf("status %d: %s", w.Code, w.Body) + } + got := decode(t, w) + if got["code"] != "bank_corrections_refused" { + t.Errorf("code = %v", got["code"]) + } + refusals, _ := got["refusals"].([]any) + if len(refusals) != 2 { + t.Fatalf("refusals: %v", got["refusals"]) + } + first, _ := refusals[0].(map[string]any) + whole, _ := refusals[1].(map[string]any) + if first["pointer"] != "/corrections/1" || whole["pointer"] != "" { + t.Errorf("pointers: %v / %v", first["pointer"], whole["pointer"]) + } + if first["detail"] == "" { + t.Error("a refusal without its developer-facing reason") + } +} + +// The three remedies stay apart on the wire: «re-decide» (409 refused, above), «re-send the same» +// (503 with its OWN code) and «call the operator / retry later» (503 service_unavailable) — plus +// the shared table's run_in_flight and not_found. +func TestTheDoorsFailuresKeepTheirRemediesApart(t *testing.T) { + body := `{"book_id":"bk_1","preview":false,"corrections":[{"action":"decline","id":"tm_1"}]}` + cases := []struct { + err error + status int + code string + }{ + {runs.ErrBankIncomplete, http.StatusServiceUnavailable, "bank_corrections_incomplete"}, + {runs.ErrBankUnavailable, http.StatusServiceUnavailable, "service_unavailable"}, + {pgstore.ErrRunInFlight, http.StatusConflict, "run_in_flight"}, + {pgstore.ErrNoBook, http.StatusNotFound, "not_found"}, + } + for _, tc := range cases { + w := call(t, bankServer(t, &fakeBank{err: tc.err}), "POST", "/v0/books/bk_1/bank/corrections", body) + if w.Code != tc.status { + t.Errorf("%v answered %d, want %d", tc.err, w.Code, tc.status) + } + if got := decode(t, w)["code"]; got != tc.code { + t.Errorf("%v carried code %v, want %q", tc.err, got, tc.code) + } + } +} + +// A receipt whose vocabulary this build cannot map is refused, not forwarded: the depth field is +// where a wave name would leak through to the one string a screen quotes. +func TestAnUnmappableReceiptIsRefusedNotForwarded(t *testing.T) { + f := &fakeBank{receipt: runs.BankReceipt{Depth: "draft_wave_v2"}} + w := call(t, bankServer(t, f), "POST", "/v0/books/bk_1/bank/corrections", + `{"book_id":"bk_1","preview":true,"corrections":[{"action":"decline","id":"tm_1"}]}`) + if w.Code != http.StatusInternalServerError { + t.Errorf("status %d, want 500", w.Code) + } + if strings.Contains(w.Body.String(), "draft_wave_v2") { + t.Errorf("the unmapped engine word reached the wire: %s", w.Body) + } +} + +// The 5000-correction ceiling is a POINT OF STOPPING: the 400 carries the cap's own item and +// nothing per element, so an oversized document cannot buy an itemization of its every flaw — a +// megabyte of minimal corrections used to come back as tens of megabytes of pointers (~100×). +// +// Mutation caught: falling through the cap into the per-item walk. +func TestTheCorrectionCountCeilingStopsTheValidation(t *testing.T) { + f := &fakeBank{} + var b strings.Builder + b.WriteString(`{"book_id":"bk_1","preview":true,"corrections":[`) + for i := 0; i < maxCorrections+1; i++ { + if i > 0 { + b.WriteString(",") + } + // Deliberately FLAWED elements: without the stop each would add its items to the answer. + b.WriteString(`{"action":"decline"}`) + } + b.WriteString(`]}`) + w := call(t, bankServer(t, f), "POST", "/v0/books/bk_1/bank/corrections", b.String()) + if w.Code != http.StatusBadRequest || f.called { + t.Fatalf("status %d (service called: %v), want a 400 refusal", w.Code, f.called) + } + if w.Body.Len() > 1<<10 { + t.Errorf("the refusal of an oversized document is %d bytes — the cap did not stop the walk", w.Body.Len()) + } + if !strings.Contains(w.Body.String(), `"/corrections"`) || strings.Contains(w.Body.String(), `"/corrections/0`) { + t.Errorf("the 400 itemizes past the cap: %s", firstOf(w.Body.String())) + } +} + +// The canon's 1 MiB document ceiling, measured on the wire: over it is 413 `payload_too_large`, and +// the service is never called. +func TestAnOversizedCorrectionDocumentAnswers413(t *testing.T) { + f := &fakeBank{} + body := `{"book_id":"bk_1","preview":true,"corrections":[{"action":"decline","id":"tm_1","note":"` + + strings.Repeat("х", 1<<20) + `"}]}` + w := call(t, bankServer(t, f), "POST", "/v0/books/bk_1/bank/corrections", body) + if w.Code != http.StatusRequestEntityTooLarge { + t.Fatalf("status %d, want 413: %s", w.Code, firstOf(w.Body.String())) + } + if got := decode(t, w)["code"]; got != "payload_too_large" { + t.Errorf("code = %v", got) + } + if f.called { + t.Error("an oversized document reached the service") + } +} + +// The engine's byte cap is measured on the RENDERED document in runs; when it refuses, the wire +// word is the same 413 the body cap answers — one fact, one word — never the engine's «re-decide» +// (workflow finding, P9). +func TestARenderedDocumentOverTheEngineCapAnswers413(t *testing.T) { + f := &fakeBank{err: runs.ErrBankDocumentTooLarge} + body := `{"book_id":"bk_1","preview":true,"corrections":[{"action":"decline","id":"tm_1"}]}` + w := call(t, bankServer(t, f), "POST", "/v0/books/bk_1/bank/corrections", body) + if w.Code != http.StatusRequestEntityTooLarge { + t.Fatalf("status %d, want 413: %s", w.Code, firstOf(w.Body.String())) + } + if got := decode(t, w)["code"]; got != "payload_too_large" { + t.Errorf("code = %v", got) + } +} + +func firstOf(s string) string { + if len(s) > 200 { + return s[:200] + } + return s +} diff --git a/platform/internal/httpapi/capabilities.go b/platform/internal/httpapi/capabilities.go index b47abc17..160ea52e 100644 --- a/platform/internal/httpapi/capabilities.go +++ b/platform/internal/httpapi/capabilities.go @@ -10,7 +10,7 @@ import "net/http" // client generated against another one refuses to work and says so — which is why this must be // raised in the same commit as the code that implements a new minor, and never as a courtesy // afterwards. -const ContractVersion = "0.5.0" +const ContractVersion = "0.6.0" // Capabilities is what this deployment can do: one flat document, the same for every account. type Capabilities struct { @@ -25,6 +25,11 @@ type Capabilities struct { // ExportFormats is what `POST /books/{bookId}/exports` accepts. Empty means none are built here, // which is the honest answer while the export path is not built (deferred to P8). ExportFormats []string + // BankCorrectionsEnabled is whether this deployment serves the correction door, + // `POST /books/{bookId}/bank/corrections`. False means the path answers 404 and a client does + // not offer the correction UI — machine-readable so nobody learns it by failing a user's save + // (canon §Capabilities; the same fact that decides the route's mount). + BankCorrectionsEnabled bool // PageSizeDefault is how many rows a collection returns when `limit` is omitted. It is the // DEPLOYMENT's number rather than a constant of the contract, and every read path takes its // default from the same place this answers from. @@ -39,12 +44,13 @@ type LanguagePair struct { } type wireCapabilities struct { - ContractVersion string `json:"contract_version"` - LanguagePairs []wireLanguagePair `json:"language_pairs"` - IntakeEnabled bool `json:"intake_enabled"` - IntakeMaxBytes int64 `json:"intake_max_bytes"` - ExportFormats []string `json:"export_formats"` - PageSizeDefault int `json:"page_size_default"` + ContractVersion string `json:"contract_version"` + LanguagePairs []wireLanguagePair `json:"language_pairs"` + IntakeEnabled bool `json:"intake_enabled"` + IntakeMaxBytes int64 `json:"intake_max_bytes"` + ExportFormats []string `json:"export_formats"` + BankCorrectionsEnabled bool `json:"bank_corrections_enabled"` + PageSizeDefault int `json:"page_size_default"` } type wireLanguagePair struct { @@ -58,12 +64,13 @@ func (h *v0) capabilities(w http.ResponseWriter, r *http.Request) { return } out := wireCapabilities{ - ContractVersion: ContractVersion, - LanguagePairs: make([]wireLanguagePair, 0, len(h.caps.Pairs)), - IntakeEnabled: h.caps.IntakeEnabled, - IntakeMaxBytes: h.caps.IntakeMaxBytes, - ExportFormats: h.caps.ExportFormats, - PageSizeDefault: h.caps.PageSizeDefault, + ContractVersion: ContractVersion, + LanguagePairs: make([]wireLanguagePair, 0, len(h.caps.Pairs)), + IntakeEnabled: h.caps.IntakeEnabled, + IntakeMaxBytes: h.caps.IntakeMaxBytes, + ExportFormats: h.caps.ExportFormats, + BankCorrectionsEnabled: h.caps.BankCorrectionsEnabled, + PageSizeDefault: h.caps.PageSizeDefault, } if out.ExportFormats == nil { out.ExportFormats = []string{} // an empty collection is an empty array, never null diff --git a/platform/internal/httpapi/problem.go b/platform/internal/httpapi/problem.go index aed4e1e0..9c2238c2 100644 --- a/platform/internal/httpapi/problem.go +++ b/platform/internal/httpapi/problem.go @@ -42,6 +42,11 @@ const ( CodeContentRefused Code = "content_refused" CodeServiceUnavailable Code = "service_unavailable" CodeInternalError Code = "internal_error" + // The two bank codes of 0.5.0. They exist because their remedies have different ADDRESSEES: a + // refused set is re-decided by the person, an incomplete write is re-sent verbatim by the + // machine (canon §applyBankCorrections). + CodeBankCorrectionsRefused Code = "bank_corrections_refused" + CodeBankCorrectionsIncomplete Code = "bank_corrections_incomplete" ) // Second-level causes this deployment gives. NOT a closed vocabulary — a client that does not know @@ -113,6 +118,9 @@ var codes = map[Code]struct { CodeIdempotencyConflict: {http.StatusConflict, "Idempotency conflict"}, CodeServiceUnavailable: {http.StatusServiceUnavailable, "Service unavailable"}, CodeInternalError: {http.StatusInternalServerError, "Internal error"}, + + CodeBankCorrectionsRefused: {http.StatusConflict, "Bank corrections refused"}, + CodeBankCorrectionsIncomplete: {http.StatusServiceUnavailable, "Bank corrections incomplete"}, } func statusOf(c Code) int { @@ -152,6 +160,9 @@ type Problem struct { Errors []Item `json:"errors,omitempty"` // Blocked is carried by `ceiling_unavailable` when another book of the account holds the credit. Blocked *Blocked `json:"blocked,omitempty"` + // Refusals is carried by `bank_corrections_refused`: one entry per refused correction, or one + // about the would-be result as a whole (canon §CorrectionRefusal). + Refusals []CorrectionRefusal `json:"refusals,omitempty"` // RetryAfter is not a member: it is the header of the same name, and it is here so one writer // sets it (canon §Conflict). RetryAfter time.Duration `json:"-"` @@ -176,6 +187,14 @@ type Blocked struct { BookID string `json:"book_id"` } +// CorrectionRefusal is one refused correction: a JSON Pointer at it (`/corrections/3`), or the +// EMPTY string for a refusal about the result as a whole. `detail` is developer-facing free text, +// like Problem's own — a client marks the row and draws a neutral phrase (canon §CorrectionRefusal). +type CorrectionRefusal struct { + Pointer string `json:"pointer"` + Detail string `json:"detail"` +} + // Fail writes an error identified by its root code. func Fail(w http.ResponseWriter, r *http.Request, c Code) { WriteProblem(w, r, Problem{Code: c}) diff --git a/platform/internal/httpapi/problem_test.go b/platform/internal/httpapi/problem_test.go index 639c0876..7a876439 100644 --- a/platform/internal/httpapi/problem_test.go +++ b/platform/internal/httpapi/problem_test.go @@ -108,10 +108,12 @@ func TestTheTwoRefusalsBeforeAuthorizationShareARootCodeAndDifferInTheirCause(t // divergence a reviewer cannot see, because both halves look right on their own. // // Driven from the production table, so a code added there is covered the moment it exists; the count -// is what pins the table itself to the canon's closed vocabulary of SIXTEEN root codes at 0.3.0. +// is what pins the table itself to the canon's closed vocabulary — SIXTEEN root codes at 0.3.0 plus +// the TWO bank codes 0.5.0 added (§2.19: refused is re-decided by the person, incomplete is re-sent +// verbatim by the machine). func TestEveryCodeNamesExactlyOneStatus(t *testing.T) { - if len(codes) != 16 { - t.Fatalf("the root vocabulary has %d codes; 0.3.0 closes it at 16", len(codes)) + if len(codes) != 18 { + t.Fatalf("the root vocabulary has %d codes; 0.5.0 closes it at 18", len(codes)) } // ⚠ Transcribed from the CANON's own list (§ErrorCode), not read back from the table above. // Comparing `statusOf(c)` with `codes[c].status` compares the table with itself: it is the same @@ -133,6 +135,9 @@ func TestEveryCodeNamesExactlyOneStatus(t *testing.T) { CodeIdempotencyConflict: 409, CodeServiceUnavailable: 503, CodeInternalError: 500, + + CodeBankCorrectionsRefused: 409, + CodeBankCorrectionsIncomplete: 503, } unknown := Code("a code that does not exist") for code := range codes { diff --git a/platform/internal/httpapi/project.go b/platform/internal/httpapi/project.go index 0d34c313..0b152451 100644 --- a/platform/internal/httpapi/project.go +++ b/platform/internal/httpapi/project.go @@ -49,7 +49,8 @@ func projectRun(r pgstore.Run) wireRun { StopRequested: r.StopRequested, CeilingChapters: r.CeilingChapters, Progress: wireProgress{ - Done: r.Progress.Done, Total: r.Progress.Total, ETASeconds: r.Progress.ETASeconds, + Done: r.Progress.Done, Total: r.Progress.Total, Stage: r.Progress.Stage, + ETASeconds: r.Progress.ETASeconds, }, StartedAt: r.StartedAt, FinishedAt: r.FinishedAt, } diff --git a/platform/internal/httpapi/reading.go b/platform/internal/httpapi/reading.go index c8ec963b..857f488c 100644 --- a/platform/internal/httpapi/reading.go +++ b/platform/internal/httpapi/reading.go @@ -84,14 +84,17 @@ type wireTerm struct { // with no cursor, a delta read included. They are `omitempty` for that reason and for no other: // absence here means "does not apply to this page", one of the two places on this surface where it // does (canon §BankPage). +// +// ⚠ `pending_decisions` and `complete` are gone WITH the canon (0.5.0, PD-399): they counted for +// the abolished per-term model and had degraded into a live count of `proposed` rows. "How many are +// still undecided" is the engine's answer and rides the correction receipt (`signature`), not a +// read of this table. type wireBankPage struct { Revision int64 `json:"revision"` NextCursor *string `json:"next_cursor"` StructureVersion int `json:"structure_version"` Total *int `json:"total,omitempty"` Signed *int `json:"signed,omitempty"` - PendingDecisions *int `json:"pending_decisions,omitempty"` - Complete *bool `json:"complete,omitempty"` Terms []wireTerm `json:"terms"` } @@ -215,7 +218,6 @@ func (h *v0) listBank(w http.ResponseWriter, r *http.Request) { if page.First { c := page.Counts out.Total, out.Signed = &c.Total, &c.Signed - out.PendingDecisions, out.Complete = &c.PendingDecisions, &c.Complete } for _, t := range page.Terms { out.Terms = append(out.Terms, projectTerm(t)) @@ -223,17 +225,8 @@ func (h *v0) listBank(w http.ResponseWriter, r *http.Request) { h.writeJSON(w, r, http.StatusOK, out) } -// ⚠ THE WRITE HALF OF THE BANK IS GONE, AND WHAT REPLACES IT IS NOT BUILT — for a future session. -// -// What stood here: `POST /books/{bookId}/bank/decisions`, taking a per-term verb `approve|decline`. -// D39.144 (16.08) abolished that model — the bank is signed as ONE act over the whole of it, and a -// per-term verb was never the unit of signing. What the same note DID keep per-term is an EDIT: -// correct a term's rendering, or add one, before the signing resume. That handle does not exist on -// any surface, here or in the canon, and this removal does not create it. -// -// For whoever builds it: `bank_decisions` already holds (term_id, dst), but not for free — `action` -// is NOT NULL with a check constraint on the abolished verb (migrations 00002/00016, both released), -// so an edit needs either a migration or a filler value. The engine's side is a file the platform -// does not write yet: `mined_delta` (a seed YAML, backend/internal/seed) carries corrected and added -// terms, and the engine reads it only if `book.yaml` declares the path — which is the ownership -// fork of unified backlog row 199(a), unratified. Signing itself needs nothing: it is `resume`. +// The write half of the bank lives at `POST /books/{bookId}/bank/corrections` (bank.go): the +// per-term act is a CORRECTION applied by the engine's own verb, never a signature — signing stayed +// ONE act over the whole bank, `resume` (D39.144, D39.156). What stood here before it — the +// abolished per-term decisions route — went on the owner's word of 22.08; the `bank_decisions` +// table it fed is dead and nothing here reads it. diff --git a/platform/internal/httpapi/reading_test.go b/platform/internal/httpapi/reading_test.go index dbe2a05e..a63fe4f6 100644 --- a/platform/internal/httpapi/reading_test.go +++ b/platform/internal/httpapi/reading_test.go @@ -98,11 +98,15 @@ func TestAPairCarriesItsNotesAsCodesAndNotAsEngineReasons(t *testing.T) { // The aggregates describe the WHOLE bank and ride on the FIRST page only — any response to a // request with no cursor. On a later page they are ABSENT, which is the one place on this surface // where absence means "does not apply". +// +// The aggregate pair is `total`/`signed` and NOTHING ELSE: canon 0.5.0 removed the per-term-model +// counters `pending_decisions`/`complete` (PD-399), which had degraded into a live count of +// `proposed` rows — so their ABSENCE is pinned on the first page too, where they used to ride. func TestTheBankAggregatesRideOnTheFirstPageOnly(t *testing.T) { full := pgstore.BankPage{ Page: pgstore.Page{Revision: 1841, StructureVersion: 3}, First: true, - Counts: pgstore.BankCounts{Total: 300, Signed: 40, PendingDecisions: 17}, + Counts: pgstore.BankCounts{Total: 300, Signed: 40}, Terms: []pgstore.BankTerm{{ ID: "tm_1", Src: "方源", Dst: "Фан Юань", Kind: "name", Status: "proposed", Origin: "found", Sense: "", @@ -110,11 +114,16 @@ func TestTheBankAggregatesRideOnTheFirstPageOnly(t *testing.T) { } lib := &fakeLibrary{bank: full} got := decode(t, call(t, readingServer(t, lib), "GET", "/v0/books/bk_1/bank", "")) - for _, k := range []string{"total", "signed", "pending_decisions", "complete", "structure_version"} { + for _, k := range []string{"total", "signed", "structure_version"} { if _, ok := got[k]; !ok { t.Errorf("the first page is missing %q", k) } } + for _, k := range []string{"pending_decisions", "complete"} { + if _, ok := got[k]; ok { + t.Errorf("the removed counter %q is still on the wire (canon 0.5.0, PD-399)", k) + } + } term, _ := got["terms"].([]any)[0].(map[string]any) for _, k := range []string{"id", "src", "dst", "kind", "status", "origin", "sense", "since_chapter", "until_chapter"} { @@ -127,7 +136,7 @@ func TestTheBankAggregatesRideOnTheFirstPageOnly(t *testing.T) { } lib.bank.First = false later := decode(t, call(t, readingServer(t, lib), "GET", "/v0/books/bk_1/bank?cursor=x", "")) - for _, k := range []string{"total", "signed", "pending_decisions", "complete"} { + for _, k := range []string{"total", "signed"} { if _, ok := later[k]; ok { t.Errorf("a later page carries the whole-bank aggregate %q", k) } @@ -141,11 +150,13 @@ func TestTheBankAggregatesRideOnTheFirstPageOnly(t *testing.T) { // the one source of mounted routes and every route in it is walked below. // `GET /capabilities` is the only place a non-streaming client learns which contract it is talking -// to, and the six fields are what a client is entitled to decide from before it spends anything. -func TestCapabilitiesCarriesTheSixFacts(t *testing.T) { +// to, and the seven fields are what a client is entitled to decide from before it spends anything +// (0.5.0 added `bank_corrections_enabled`: the correction door is declared ahead of its serving +// half, and a client must learn a deployment has not mounted it here, not by failing a save). +func TestCapabilitiesCarriesTheSevenFacts(t *testing.T) { got := decode(t, call(t, readingServer(t, &fakeLibrary{}), "GET", "/v0/capabilities", "")) for _, k := range []string{"contract_version", "language_pairs", "intake_enabled", - "intake_max_bytes", "export_formats", "page_size_default"} { + "intake_max_bytes", "export_formats", "bank_corrections_enabled", "page_size_default"} { if _, ok := got[k]; !ok { t.Errorf("Capabilities is missing the required field %q", k) } diff --git a/platform/internal/httpapi/server.go b/platform/internal/httpapi/server.go index 177966b8..671fe261 100644 --- a/platform/internal/httpapi/server.go +++ b/platform/internal/httpapi/server.go @@ -53,6 +53,10 @@ type Deps struct { // cut it with, and then POST /books is a guarded 404 like every other unbuilt route: an instance // that accepted uploads it could only reject would be worse than one that says it takes none. Intake Intake + // Bank is the correction door. Nil where this deployment has no engine to apply corrections + // with, and then the path answers the guarded 404 the canon promises for + // `bank_corrections_enabled: false` — the capability and the mount are one fact. + Bank Bank // Upload bounds the one route that carries a file. Upload UploadLimits // Capabilities is what `GET /capabilities` answers: what this deployment can do. diff --git a/platform/internal/httpapi/stream.go b/platform/internal/httpapi/stream.go index e452cf7a..25915555 100644 --- a/platform/internal/httpapi/stream.go +++ b/platform/internal/httpapi/stream.go @@ -93,10 +93,19 @@ func (h *v0) streamEvents(w http.ResponseWriter, r *http.Request) { } s := &stream{w: w, rc: rc} - // The first frame is ALWAYS hello, and it carries the id of the last history frame — `0` on a - // book that has produced none. A hello without an id would leave the client with nothing to - // present on the reconnect a browser makes by itself, and the stream would reopen forever. - if !s.send("hello", state.Position, map[string]any{ + // The first frame is ALWAYS hello, and its id is what the browser will present on the reconnect + // it makes by itself — the WHATWG last event ID buffer takes it the moment the frame is + // dispatched. So on a RESUME the id must be the one the client just presented, not the head of + // the book's history: a drop right after a head-stamped hello moved the client's watermark + // FORWARD across every frame this connection was about to replay — notes included, silently, + // with no resync_required, and the delta remedy disarmed with it (workflow finding, P9; + // PD-406). A fresh connection has no replay to protect and presents the head, exactly as + // `from` is chosen below. + helloID := state.Position + if resuming { + helloID = last + } + if !s.send("hello", helloID, map[string]any{ "contract": ContractVersion, "revision": state.Revision, "structure_version": state.StructureVersion, }) { diff --git a/platform/internal/httpapi/stream_test.go b/platform/internal/httpapi/stream_test.go index fea1fadb..83b48bd7 100644 --- a/platform/internal/httpapi/stream_test.go +++ b/platform/internal/httpapi/stream_test.go @@ -346,3 +346,31 @@ func TestEveryConnectionFrameCarriesTheBooksRevisionAndStructureVersion(t *testi }) } } + +// On a RESUME the hello's id is the id the client PRESENTED, never the head of the book's history: +// the browser stores hello's id the moment the frame is dispatched, so a head-stamped hello +// followed by a drop moved the client's watermark forward across the whole pending replay — notes +// lost silently, no resync_required, and the delta remedy disarmed with them (workflow finding, +// P9; PD-406). A fresh connection has no replay to protect and presents the head. +// +// Mutation caught: sending `hello` with state.Position on a resuming connection. +func TestAResumingHelloCarriesTheClientsOwnWatermark(t *testing.T) { + lib := &fakeLibrary{ + stream: pgstore.StreamState{Position: 5, Revision: 9, StructureVersion: 1, AtRest: true}, + frames: []pgstore.Frame{ + {Position: 2, Event: pgstore.FrameNote, Data: json.RawMessage(`{"note":{"id":"nt_1"}}`)}, + {Position: 5, Event: pgstore.FrameProgress, Data: json.RawMessage(`{"progress":{"done":3}}`)}, + }, + } + got := frames(t, streamOf(t, lib, "1").Body.String()) + if len(got) == 0 || got[0].Event != "hello" { + t.Fatalf("no hello: %+v", got) + } + if got[0].ID != "1" { + t.Fatalf("a resuming hello carries id %q, want the client's own 1", got[0].ID) + } + fresh := frames(t, streamOf(t, lib, "").Body.String()) + if len(fresh) == 0 || fresh[0].ID != "5" { + t.Fatalf("a fresh hello must present the head: %+v", fresh) + } +} diff --git a/platform/internal/httpapi/v0.go b/platform/internal/httpapi/v0.go index 86940088..5fb42b54 100644 --- a/platform/internal/httpapi/v0.go +++ b/platform/internal/httpapi/v0.go @@ -78,6 +78,10 @@ var contractSurface = []struct { {method: "GET", path: "/books/{bookId}/chapters/{chapterId}/units", mounts: hasLibrary, handler: func(h *v0) http.HandlerFunc { return h.listUnits }}, {method: "GET", path: "/books/{bookId}/notes", mounts: hasLibrary, handler: func(h *v0) http.HandlerFunc { return h.listNotes }}, {method: "GET", path: "/books/{bookId}/bank", mounts: hasLibrary, handler: func(h *v0) http.HandlerFunc { return h.listBank }}, + // The correction door (canon 0.5.0). Unmounted it answers the guarded 404 the canon promises for + // `Capabilities.bank_corrections_enabled: false` — the same fact decides both. Its body cap IS + // the canon's 1 MiB document ceiling, so it rides the default deliberately. + {method: "POST", path: "/books/{bookId}/bank/corrections", mounts: hasBank, handler: func(h *v0) http.HandlerFunc { return h.bankCorrections }}, // The stream is registered outside the compression layer — compressing a stream buffers it, which // is the one thing the contract forbids of this route — and that holds structurally: it does not // go through writeJSON, which is where compression lives. @@ -96,6 +100,7 @@ var contractSurface = []struct { func hasLibrary(d Deps) bool { return d.Library != nil } func hasRuns(d Deps) bool { return d.Runs != nil } func hasIntake(d Deps) bool { return d.Intake != nil } +func hasBank(d Deps) bool { return d.Bank != nil } // contractRoutes registers the /v0 surface. // @@ -103,7 +108,7 @@ func hasIntake(d Deps) bool { return d.Intake != nil } // ops endpoints: a nested mux behind StripPrefix hands the inner handler a copy of the request and // the pattern never comes back, which would put raw paths carrying book ids into the access log. func contractRoutes(mux *http.ServeMux, d Deps, guard func(int64, http.Handler) http.Handler) { - h := &v0{lib: d.Library, runs: d.Runs, intake: d.Intake, upload: d.Upload, + h := &v0{lib: d.Library, runs: d.Runs, intake: d.Intake, bank: d.Bank, upload: d.Upload, caps: d.Capabilities, keys: d.Keys, log: d.Log} for _, r := range contractSurface { if r.mounts != nil && !r.mounts(d) { @@ -121,6 +126,7 @@ type v0 struct { lib Library runs Runs intake Intake + bank Bank upload UploadLimits caps Capabilities keys IdempotencyKeys @@ -135,15 +141,19 @@ type v0 struct { // a word from inside the translation machinery — which is why the projections below translate // vocabularies instead of forwarding them. // -// ⚠ `Run.stop_requested` is the ONE member ahead of the version this deployment announces: 0.3.0's -// Run does not declare it and 0.4.0 requires it. It is sent because the fact has no other carrier — -// no `status` answers "is this what I asked for" — and an unknown member is what a 0.x minor is for; -// the dependency is named here rather than left for a reader to find by diffing. +// As of 0.6.0 no member here is ahead of the announced canon: `Run.stop_requested` landed in +// 0.4.0's Run (0.5.0 keeps it required), and the through-run bar with its `stage` member is what +// 0.6.0's Progress declares. +// `Progress` is the canon's through-run bar (0.6.0, D39.160): one monotonic fraction across both +// waves, and the `stage` caption derived by the PLATFORM from the same counters as the bar — +// never a forwarded engine string. `stage` is an OPEN vocabulary by the canon's own second +// exception: a client renders an unknown value neutrally, and new values do not move the version. type wireProgress struct { - Done int `json:"done"` - Total int `json:"total"` - ETASeconds *int `json:"eta_seconds"` + Done int `json:"done"` + Total int `json:"total"` + Stage string `json:"stage"` + ETASeconds *int `json:"eta_seconds"` } type wireBook struct { diff --git a/platform/internal/httpapi/v0_test.go b/platform/internal/httpapi/v0_test.go index 779e615d..f3bb1930 100644 --- a/platform/internal/httpapi/v0_test.go +++ b/platform/internal/httpapi/v0_test.go @@ -212,7 +212,9 @@ func TestTheLibraryResponseCarriesEveryRequiredField(t *testing.T) { if book["chapters_done"] != float64(7) || book["structure_version"] != float64(3) { t.Errorf("book row: %v", book) } - // Not a stage name, not a wave name, not an engine word anywhere on the wire. + // Not a wave name, not an engine word anywhere on THIS wire — the library row, which carries no + // bar by construction. (`Progress.stage` on the RUN legally says drafting/editing since 0.6.0; + // this list guards the surface that must stay free of all of it.) for _, leak := range []string{"draft", "edit", "wave", "stage", "mined", "ruby", "genre"} { if bytes.Contains(w.Body.Bytes(), []byte(leak)) { t.Errorf("the pipeline's vocabulary reached the wire: %q in %s", leak, w.Body) diff --git a/platform/internal/ingest/bankdecisions.go b/platform/internal/ingest/bankdecisions.go new file mode 100644 index 00000000..b8d9c4dc --- /dev/null +++ b/platform/internal/ingest/bankdecisions.go @@ -0,0 +1,136 @@ +package ingest + +import ( + "bytes" + "encoding/json" + "fmt" +) + +// bankdecisions.go: the two documents of the engine's bank-correction door, `tmctl bank-apply` +// (D39.158) — the request the platform RENDERS and the report it READS BACK. Both live here because +// this package is where the seam's vocabulary lives; the HTTP shapes of the same facts are the +// contract's and stay in httpapi. + +// DecisionsVersion and DecisionsReportVersion are the SHAPES of the two documents, as the engine +// declares them (backend/internal/membank/decisions.go). Two constants because they are two +// documents, free to move at different times. +const ( + DecisionsVersion = "tm-bank-decisions-v1" + DecisionsReportVersion = "tm-bank-decisions-report-v2" +) + +// DepthEditWave is the engine's word for how far an accepted correction reaches: the edit wave — +// the next run refines the produced text rather than re-forming the draft (membank.DecisionDepth). +// A WAVE name, so it must never be forwarded to the wire as it stands. +const DepthEditWave = "edit_wave" + +// BankDecision is one correction, in the engine's own request vocabulary (membank.Decision). The +// wire's strictness — a tuple must carry all four members, `null` windows and an empty `sense` +// meaning "none" — is the HANDLER's validation; by the time a value gets here the two forms mean +// the same thing to the engine, which defaults an omitted member (`omitempty` is therefore safe). +// Wire `null` windows are projected to 0 here, the seam's own null (canon §BankCorrection). +type BankDecision struct { + Action string `json:"action"` + ID string `json:"id,omitempty"` + Src string `json:"src,omitempty"` + Sense string `json:"sense,omitempty"` + SinceChapter int `json:"since_chapter,omitempty"` + UntilChapter int `json:"until_chapter,omitempty"` + Dst string `json:"dst,omitempty"` + Kind string `json:"kind,omitempty"` + Note string `json:"note,omitempty"` +} + +// MaxDecisionsDocument mirrors the engine's own byte ceiling on the rendered document +// (pipeline.maxDecisionsBytes) — the figure the platform measures BEFORE spawning the verb, so the +// engine's copy of the cap answers as the canon's 413 instead of its refusal class. +const MaxDecisionsDocument = 1 << 20 + +// EncodeDecisions renders the request document the verb reads (`--decisions`). +// +// HTML escaping is OFF: the document is read by the engine, never by a browser, and Go's default +// escape turns one `&`/`<`/`>` byte into six — enough for a body inside the wire's 1 MiB cap to +// render past the engine's identical cap and come back as «re-decide» instead of «too large» +// (workflow finding, P9). Unescaped, the rendered form differs from the wire's by the envelope +// alone, and the residual band is those few bytes, gated by the caller against MaxDecisionsDocument. +func EncodeDecisions(bookID string, decisions []BankDecision) ([]byte, error) { + doc := struct { + Version string `json:"decisions_version"` + BookID string `json:"book_id"` + Decisions []BankDecision `json:"decisions"` + }{Version: DecisionsVersion, BookID: bookID, Decisions: decisions} + var buf bytes.Buffer + enc := json.NewEncoder(&buf) + enc.SetEscapeHTML(false) + if err := enc.Encode(doc); err != nil { + return nil, fmt.Errorf("ingest: encode decisions: %w", err) + } + return bytes.TrimSpace(buf.Bytes()), nil +} + +// BankReport is the ALLOWLISTED subset of the verb's report (pipeline.BankDecisionsReport). Absent +// on purpose, like everywhere on this seam: the two file paths and the per-file write truth are +// server topology, the canonical-rewrite warnings address an operator of files, and the TEXTS of +// pre-existing faults are the engine's free vocabulary — the wire carries their COUNT. +type BankReport struct { + Version string `json:"report_version"` + BookID string `json:"book_id"` + // Mode names the outcome: `apply` · `projection` · `refused` · `stopped` · `write_incomplete`. + Mode string `json:"mode"` + Depth string `json:"depth"` + Changed bool `json:"changed"` + // PreexistingProblems is decoded for its LENGTH; the texts never cross the next boundary. + PreexistingProblems []string `json:"preexisting_problems"` + Accepted []AcceptedDecision `json:"accepted"` + Rejected []RejectedDecision `json:"rejected"` + Signature SignatureState `json:"signature"` +} + +// AcceptedDecision is what one accepted correction did. +type AcceptedDecision struct { + Index int `json:"index"` + Action string `json:"action"` + ID string `json:"id"` + Src string `json:"src"` + Dst string `json:"dst"` + // State is `applied` or `already_applied` — the whole of idempotency as a caller sees it. + State string `json:"state"` + // Replaced is decoded for its PRESENCE: the wire carries the boolean `displaced`, never the + // engine's free-text itemization. + Replaced []string `json:"replaced"` +} + +// RejectedDecision is one refusal. Index is the decision's position in the request, or -1 for a +// refusal about the result as a whole. +type RejectedDecision struct { + Index int `json:"index"` + Reason string `json:"reason"` +} + +// SignatureState counts the last signing stop's surfaces against the recorded decisions. +// INFORMATIONAL, never a gate (D39.144). `Map` is a server-side path and is read here only for +// "does a stop exist at all": empty means no run has reached one, which the wire says as `null`. +type SignatureState struct { + Map string `json:"map"` + Surfaces int `json:"surfaces"` + Undecided int `json:"undecided"` + Unreadable bool `json:"unreadable"` +} + +// DecodeBankReport parses the verb's report and refuses a shape this build does not speak. +// +// The version is matched EXACTLY, unlike the manifest's presence-only rule, and the asymmetry is +// deliberate: the manifest feeds a read model that a stale field set degrades, while this report is +// the receipt of a WRITE — a half-read receipt would tell a user their correction did something +// other than what it did. An engine that moved the shape is a deployment skew to refuse loudly. +func DecodeBankReport(b []byte) (BankReport, error) { + var r BankReport + if err := json.Unmarshal(b, &r); err != nil { + return BankReport{}, fmt.Errorf("ingest: decode bank report: %w", err) + } + if r.Version != DecisionsReportVersion { + return BankReport{}, fmt.Errorf("ingest: decode bank report: report_version is %q, this build speaks %q", + r.Version, DecisionsReportVersion) + } + return r, nil +} diff --git a/platform/internal/ingest/bankdecisions_test.go b/platform/internal/ingest/bankdecisions_test.go new file mode 100644 index 00000000..14a35c97 --- /dev/null +++ b/platform/internal/ingest/bankdecisions_test.go @@ -0,0 +1,82 @@ +package ingest + +import ( + "strings" + "testing" +) + +// The two documents of the correction seam: what the platform renders is the engine's own request +// shape, and what it reads back refuses a shape this build does not speak. + +// The rendered document IS the engine's vocabulary: versioned, book-bound, with the two forms of +// identity and nothing the engine does not declare (DisallowUnknownFields on its side would refuse +// an invented member loudly, and half-applied quietly is what that strictness exists against). +func TestEncodeDecisionsRendersTheEngineRequest(t *testing.T) { + doc, err := EncodeDecisions("bk_1", []BankDecision{ + {Action: "approve", Src: "蛊", Sense: "", SinceChapter: 0, UntilChapter: 0, Dst: "гу", Note: "why"}, + {Action: "decline", ID: "tm_2"}, + }) + if err != nil { + t.Fatal(err) + } + s := string(doc) + for _, want := range []string{ + `"decisions_version":"tm-bank-decisions-v1"`, + `"book_id":"bk_1"`, + `"action":"approve"`, `"src":"蛊"`, `"dst":"гу"`, `"note":"why"`, + `"action":"decline"`, `"id":"tm_2"`, + } { + if !strings.Contains(s, want) { + t.Errorf("the document does not carry %s: %s", want, s) + } + } + // An omitted member is OMITTED, not zero-valued: the engine defaults absences, and a literal + // `"since_chapter":0` beside `"id"` would put tuple members on an id-form decision. + if strings.Contains(s, `"since_chapter"`) || strings.Contains(s, `"sense"`) { + t.Errorf("zero members were rendered instead of omitted: %s", s) + } +} + +// The report decode is version-EXACT, unlike the manifest's presence-only rule: this is the receipt +// of a write, and a half-read receipt would tell a user their correction did something it did not. +func TestDecodeBankReportRefusesAShapeThisBuildDoesNotSpeak(t *testing.T) { + good := `{"report_version":"tm-bank-decisions-report-v2","book_id":"bk_1","mode":"apply", + "depth":"edit_wave","changed":true, + "accepted":[{"index":0,"action":"approve","id":"tm_9","src":"蛊","dst":"гу","state":"applied", + "replaced":["previous"]}], + "rejected":[],"preexisting_problems":["a delta that would not load"], + "signature":{"map":"/x/y.yaml","surfaces":3,"undecided":1,"unreadable":false}}` + rep, err := DecodeBankReport([]byte(good)) + if err != nil { + t.Fatal(err) + } + if rep.Mode != "apply" || !rep.Changed || rep.Depth != DepthEditWave || + len(rep.Accepted) != 1 || len(rep.Accepted[0].Replaced) != 1 || + len(rep.PreexistingProblems) != 1 || rep.Signature.Surfaces != 3 { + t.Errorf("decoded: %+v", rep) + } + if _, err := DecodeBankReport([]byte(`{"report_version":"tm-bank-decisions-report-v3","mode":"apply"}`)); err == nil { + t.Error("a report shape from another engine build was believed") + } + if _, err := DecodeBankReport([]byte(`not a report`)); err == nil { + t.Error("bytes that are not a report were believed") + } +} + +// The rendered document is read by the engine, never a browser: Go's default HTML escape turned +// one `&`/`<`/`>` byte into six, enough for a body inside the wire's 1 MiB cap to render past the +// engine's identical cap (workflow finding, P9). The render must carry those bytes verbatim. +func TestTheRenderedDocumentDoesNotInflateEscapableBytes(t *testing.T) { + doc, err := EncodeDecisions("bk", []BankDecision{{Action: "decline", ID: "tm_1", Note: "A&BD"}}) + if err != nil { + t.Fatal(err) + } + for _, esc := range []string{`\u0026`, `\u003c`, `\u003e`} { + if strings.Contains(string(doc), esc) { + t.Fatalf("the render HTML-escapes (%s): %s", esc, doc) + } + } + if !strings.Contains(string(doc), `"A&BD"`) { + t.Fatalf("the escapable bytes did not survive verbatim: %s", doc) + } +} diff --git a/platform/internal/ingest/exit.go b/platform/internal/ingest/exit.go index 7aab501a..5a5bba2b 100644 --- a/platform/internal/ingest/exit.go +++ b/platform/internal/ingest/exit.go @@ -67,6 +67,15 @@ const ( // safe in both directions either way, because an unrecognised code in the band already falls to // the non-destructive answer (books.intakeReason). ExitSchemaMismatch = 13 + // ExitDecisionsRejected — the correction document was read and declined WHOLE (bank-apply, + // D39.158): all-or-nothing, nothing written, and the USER is who re-decides. The class always + // prints its report beside the code, the caps included — the per-decision reasons ARE its + // product (pipeline.ApplyBankDecisions). + ExitDecisionsRejected = 14 + // ExitWriteIncomplete — the correction document was ACCEPTED and the write did not complete + // (bank-apply). The remedy is to re-send the SAME document: the retry converges on the engine's + // byte no-op, whether nothing landed or half did. + ExitWriteIncomplete = 15 // ExitRefusedOther — a refusal class this build of the engine had no number for. ExitRefusedOther = 19 ) diff --git a/platform/internal/ingest/manifest.go b/platform/internal/ingest/manifest.go index d265e4fe..fa6e83d4 100644 --- a/platform/internal/ingest/manifest.go +++ b/platform/internal/ingest/manifest.go @@ -38,6 +38,22 @@ type Manifest struct { // chapters, so a stored tree is only comparable within one of these (register row PD-166). ChunkerVersion string `json:"chunker_version"` Chapters []ManifestChapter `json:"chapters"` + // Artifacts is the engine's own answer to where its file channels live (row 213). Taking the + // paths from here is what lets the platform stop re-deriving them from the book's configuration + // — knowing the `.bank.json` spelling was a copy of the engine's convention that a + // changed default would have silently walked away from. + Artifacts StatusArtifacts `json:"artifacts"` +} + +// StatusArtifacts is the allowlisted half of the engine's artifact envelope, published by both +// `manifest --json` and `status --json` (backend/internal/pipeline/status.go, StatusArtifacts). +// Only the channel the platform reads is taken: the project database is the engine's own and must +// not be opened here (D39.85), and the two decision files have exactly one writer, `tmctl +// bank-apply`. +type StatusArtifacts struct { + // BankExport is the whole-bank read-out — the one channel through which the bank leaves the + // engine. The path names a PLACE, not a presence: a book that has never run has no file there. + BankExport string `json:"bank_export"` } // ManifestChapter is one chapter of the tree. diff --git a/platform/internal/ingest/supervisor.go b/platform/internal/ingest/supervisor.go index baf373ae..c28bd83b 100644 --- a/platform/internal/ingest/supervisor.go +++ b/platform/internal/ingest/supervisor.go @@ -32,8 +32,10 @@ type Supervisor struct { // Workdir is the book's project directory: book.yaml, the source and the engine's private // SQLite live there. That SQLite is never opened by us (D39.85 §4). Workdir string - // Env is the child's environment. Provider keys reach the engine through it and must never be - // logged; nil means the parent's environment. + // Env is the child's environment. On THIS dev path provider keys may reach the engine through + // it (or through the inherited parent environment — nil means exactly that) and must never be + // logged. Production hands keys as the `--keys-file` argument instead (row 211): copying this + // channel into a prod spawn is the dev/prod parity trap that flag exists to close. Env []string // EngineLog receives the child's stderr verbatim. It is a FILE, not our structured logger: the // engine logs per-call cost estimates at INFO, and money must not enter the platform's INFO diff --git a/platform/internal/pgstore/books.go b/platform/internal/pgstore/books.go index 57c2997f..c2bd5666 100644 --- a/platform/internal/pgstore/books.go +++ b/platform/internal/pgstore/books.go @@ -755,20 +755,22 @@ func (s *Store) StuckIntake(ctx context.Context, uploadingBefore, claimedBefore return out, rows.Err() } -// Progress is how far the current SEGMENT of a run has got, in CHAPTERS (canon §Progress). +// Progress is how far a run's WHOLE work has got, as one monotonic fraction (owner's word of 20.08, +// row 200): `done` counts chapter-passes through both waves and `total` is what the run bought, +// doubled where the pipeline has an editor. The counting itself is pgstore.runDone/runTotal. // -// ONE counter and not two, and in chapters and not units, and both halves are the same decision: -// the numerator and the denominator have to be in one unit, the denominator is what the run BOUGHT, -// and what a run buys is declared in chapters (`ceiling_chapters`). The per-phase split stays INSIDE -// the platform — the wire carried it until 0.3.0, which pinned the engine's architecture to a React -// component through six files. -// -// A segment is the work between two stops. In the first segment of a run that stops for signing, a -// chapter counts when THAT segment's work on it is done; otherwise when it is finished end to end. -// Counted the second way throughout, the bar would read zero for the whole first pass. +// ONE counter and not two per wave, and in chapter-passes and not units, and both halves are the +// same decision: the numerator and the denominator have to be in one unit, the denominator derives +// from what the run BOUGHT, and what a run buys is declared in chapters (`ceiling_chapters`). The +// per-phase split stays INSIDE the platform — the wire carried it until 0.3.0, which pinned the +// engine's architecture to a React component through six files. What crosses the wire beside the +// numbers is `stage`, the caption's machine value — the client draws its own phrase from it. type Progress struct { Done int Total int + // Stage is what the run is doing now: `drafting` or `editing` today, an open vocabulary by + // design (pgstore.runStage). + Stage string // ETASeconds is absent when there is nothing to estimate from. ETASeconds *int } @@ -803,15 +805,15 @@ type Run struct { // run and `b` its book. Written once because the three that used it wrote the same fourteen columns // three times, in three orders that had to stay in step by hand. const runRow = `r.id, b.id, b.revision, r.status, r.verify_bank, r.stop_requested_at is not null, - r.ceiling_chapters, ` + runProgress + `, r.ceiling_chapters, r.eta_seconds, + r.ceiling_chapters, ` + runDone + `, ` + runTotal + `, ` + runStage + `, r.eta_seconds, coalesce(r.paused_reason, ''), coalesce(r.failure_reason, ''), r.started_at, r.finished_at` // scanRun reads runRow into a Run, plus whatever the caller selected after it. func scanRun(row pgx.Row, out *Run, extra ...any) error { return row.Scan(append([]any{&out.ID, &out.BookID, &out.Revision, &out.Status, &out.VerifyBank, &out.StopRequested, &out.CeilingChapters, &out.Progress.Done, &out.Progress.Total, - &out.Progress.ETASeconds, &out.PausedReason, &out.FailureReason, &out.StartedAt, - &out.FinishedAt}, extra...)...) + &out.Progress.Stage, &out.Progress.ETASeconds, &out.PausedReason, &out.FailureReason, + &out.StartedAt, &out.FinishedAt}, extra...)...) } // Library is one page of a user's books plus the revision of the library scope itself. @@ -970,6 +972,13 @@ type BookRunContext struct { // whole length — which is right for a book that has never run. ChaptersLeft int HasLiveRun bool + // LiveRunAwaitingBank narrows HasLiveRun for the correction door: a bank stop moves the run's + // status to `awaiting_bank` from the journal BEFORE the reconciler reads the exit marker and + // closes the row (sink TypeBankStop; reconcile.finish) — so for up to a sweep the row is live + // while the book already shows its signing screen. In that window the door must open, not + // answer run_in_flight; Start keeps refusing on HasLiveRun alone (the row IS still live, and a + // second one would break runs_one_live_per_book). + LiveRunAwaitingBank bool } // ReadBookForRun gathers the facts a run start is judged on. @@ -979,10 +988,13 @@ func (s *Store) ReadBookForRun(ctx context.Context, userID, bookID string) (Book b.chapter_count - (select count(*) from chapters c where c.book_id = b.id and c.units_total > 0 and ` + finishedUnits + ` >= c.units_total), - exists (select 1 from runs lr where lr.book_id = b.id and lr.finished_at is null) + exists (select 1 from runs lr where lr.book_id = b.id and lr.finished_at is null), + exists (select 1 from runs lr where lr.book_id = b.id and lr.finished_at is null + and lr.status = 'awaiting_bank') from books b ` + lastRun + ` where b.id = $1 and b.owner_id = $2` var out BookRunContext - err := s.pool.QueryRow(ctx, q, bookID, userID).Scan(&out.Workdir, &out.Status, &out.ChaptersLeft, &out.HasLiveRun) + err := s.pool.QueryRow(ctx, q, bookID, userID).Scan(&out.Workdir, &out.Status, &out.ChaptersLeft, + &out.HasLiveRun, &out.LiveRunAwaitingBank) if errors.Is(err, pgx.ErrNoRows) { return BookRunContext{}, ErrNoBook } diff --git a/platform/internal/pgstore/migrations.sha256 b/platform/internal/pgstore/migrations.sha256 index 91e6582c..294e2f68 100644 --- a/platform/internal/pgstore/migrations.sha256 +++ b/platform/internal/pgstore/migrations.sha256 @@ -41,3 +41,4 @@ c21877113b5966bc8a200ba69ce752d4ac295bdfd34e681afbd887523edb6ceb 00019_read_mod 735a18878fba70bb156df81a1c097239463d0eb976d4fede4cb4a106d99e2b2b 00023_idempotency_content_digest.sql 0213ea7636b536d1aad46b617ee399cccbf8efd87b3eb282340cdc9430a96b0e 00024_book_edit_wave.sql 1ad95e77c78d556a83e35c2fceac09475240c7a919fd3554e34cb5bf62ef52d1 00025_stalled_work.sql +14de53c75746c2b9ab872dcf9c9e0c4bd1ea2eb9e9778fe6a6e2cbe390d8961d 00026_run_draft_baseline.sql diff --git a/platform/internal/pgstore/migrations/00026_run_draft_baseline.sql b/platform/internal/pgstore/migrations/00026_run_draft_baseline.sql new file mode 100644 index 00000000..9cdb5623 --- /dev/null +++ b/platform/internal/pgstore/migrations/00026_run_draft_baseline.sql @@ -0,0 +1,28 @@ +-- +goose Up + +-- The second baseline of a run's bar. The bar now measures the run's WHOLE work through both waves +-- (owner's word of 20.08, backlog row 200): the draft pass and the edit pass each contribute their +-- finished chapters against what the run bought, so each needs its own "what the book already had" +-- figure — `chapters_before` is re-purposed as the EDIT-column baseline, this column is the DRAFT +-- one. Both are captured in the statement that creates the run (pgstore.StartRun), on FIXED +-- predicates: `edit_wave` legitimately flips false→true after a run starts (the engine announces +-- its wave shape with its first progress event), and a baseline captured through the flag sat on +-- the draft column while the numerator moved to the edit one — the bar then read 0/N forever. Each +-- baseline pairs only with the numerator of its own column (pgstore.runDone), chosen by the live +-- flag at read time. +alter table runs add column draft_before integer not null default 0; + +-- Every pre-split run — finished or still in flight — gets the same approximation: the one figure +-- its start DID record. A baseline is only honest when captured BEFORE the run produced any of its +-- own output; re-deriving one mid-run from the counters as they stand NOW would bake the run's own +-- finished passes into its zero point, and such a run could then finish all of its bought work with +-- a bar that never reaches one (workflow finding, P9: the shortfall is exactly twice the edits it +-- had already made). Copying `chapters_before` cannot do that — it predates the run's work by +-- construction. The residual imprecision is inherited from the epochs of the old single baseline +-- (verify_bank runs recorded it off the DRAFT column): such a run's edit half may read low against +-- the re-purposed pairing. Named, not hidden — and gone with the last pre-split run. +update runs set draft_before = chapters_before; + +-- +goose Down + +alter table runs drop column draft_before; diff --git a/platform/internal/pgstore/readmodel.go b/platform/internal/pgstore/readmodel.go index a2cb1219..8cbc0a55 100644 --- a/platform/internal/pgstore/readmodel.go +++ b/platform/internal/pgstore/readmodel.go @@ -317,37 +317,30 @@ func (s *Store) SaveBank(ctx context.Context, bookID string, terms []ingest.Bank // BankCounts are the whole-bank aggregates: the header of a signing screen, and INFORMATIONAL — // never a gate. Signing is one act over the whole bank and `resume` lifts the stop with the // decisions as they stand (D39.144), so nothing here decides whether continuing may be offered. +// +// ⚠ `pending_decisions` and `complete` are GONE (canon 0.5.0, PD-399), not trimmed for taste: they +// were fed by the `bank_decisions` table whose write path went with the per-term model, which left +// them a live count of `proposed` rows — a frozen-plausible number teaching the abolished model. +// The honest count of undecidedness is the ENGINE's (`SignatureState`, served on the correction +// receipt); this read model cannot compute it without re-implementing the engine's law, which the +// seam forbids (17-seam-inbound-law п.6). type BankCounts struct { - Total int - Signed int - PendingDecisions int - Complete bool + Total int + Signed int } func (c BankCounts) payload() map[string]any { - return map[string]any{ - "total": c.Total, "signed": c.Signed, - "pending_decisions": c.PendingDecisions, "complete": c.Complete, - } + return map[string]any{"total": c.Total, "signed": c.Signed} } func bankCountsTx(ctx context.Context, tx pgx.Tx, bookID string) (BankCounts, error) { - // ⚠ `pending_decisions` reads "proposed rows nobody has touched", and since the write path was - // removed (D39.144) nothing ever touches one — so it is the count of `proposed` rows, and - // `complete` says the engine's read-out carries none. The subquery is kept rather than folded - // away: it is the shape the EDIT handle will need, and the wire fields are the canon's. const q = ` - select count(*), - count(*) filter (where t.status = 'approved'), - count(*) filter (where t.status = 'proposed' - and not exists (select 1 from bank_decisions d - where d.book_id = t.book_id and d.term_id = t.id)) + select count(*), count(*) filter (where t.status = 'approved') from bank_terms t where t.book_id = $1` var c BankCounts - if err := tx.QueryRow(ctx, q, bookID).Scan(&c.Total, &c.Signed, &c.PendingDecisions); err != nil { + if err := tx.QueryRow(ctx, q, bookID).Scan(&c.Total, &c.Signed); err != nil { return BankCounts{}, fmt.Errorf("pgstore: count the bank: %w", err) } - c.Complete = c.PendingDecisions == 0 return c, nil } @@ -434,23 +427,63 @@ func readBookTx(ctx context.Context, tx pgx.Tx, bookID string) (Book, error) { `select `+bookColumns+` from books b `+lastRun+` where b.id = $1`, bookID)) } -// runProgress is the run's bar: chapters finished in the CURRENT segment, against what the run -// bought. The segment is chosen by the same rule the chapter counters are read by (scope.wave). +// draftChapters / editChapters are how many of the book's chapters each pass has fully resolved — +// each on its OWN column, never through the flag. The bar's baselines are captured on these same +// fixed predicates (StartRun), so a capture can never disagree with the numerator it will later be +// subtracted from, however `edit_wave` moves in between. +const draftChapters = `(select count(*) from chapters c + where c.book_id = b.id and c.units_total > 0 and c.units_draft_done >= c.units_total)` + +const editChapters = `(select count(*) from chapters c + where c.book_id = b.id and c.units_total > 0 and c.units_edit_done >= c.units_total)` + +// The run's bar measures the run's WHOLE work through both waves as ONE monotonic fraction (owner's +// word of 20.08, row 200): the draft pass and the last pass each contribute the chapters they +// finished, against what the run bought — so the bar no longer restarts from zero when the signing +// stop is lifted and the counting switches waves, which is exactly what it used to do. // -// ⚠ Measured from the run's own BASELINE and clamped into its bought range. Resolutions persist -// across runs — a resumed run re-walks finished chapters at $0 and must not move them — so counting -// the book's finished chapters would open a continuation run's bar at everything the previous run -// did, against a denominator of what THIS one bought: a fraction starting above zero and able to -// exceed one. `least(…, ceiling_chapters)` closes the other end, where a run that bought 5 chapters -// watches a neighbouring run finish more of the same book. -const runProgress = `least(greatest((select count(*) from chapters c - where c.book_id = b.id and c.units_total > 0 - and ` + segmentUnits + ` >= c.units_total) - r.chapters_before, 0), r.ceiling_chapters)` +// ⚠ Each half is measured from the run's own BASELINE and clamped into its bought range, and that +// part is unchanged deliberately. Resolutions persist across runs — a resumed run re-walks finished +// chapters at $0 and must not move them — so counting the book's totals would open a continuation +// run's bar at everything the previous runs did, against a denominator of what THIS one bought: a +// fraction starting above zero and able to exceed one. `least(…, ceiling_chapters)` closes the +// other end, where a run that bought 5 chapters watches a neighbouring run finish more of the same +// book. Two baselines because there are two halves: `chapters_before` for the last pass, +// `draft_before` for the draft one, both captured when the run row is created (StartRun) and never +// re-based after — monotonicity is exactly "the counters only grow and nothing under them moves". +const ( + // draftWork is how many DRAFT passes this run actually has to do: the bought range runs from the + // edit baseline to `chapters_before + ceiling`, and whatever of it was already drafted when the + // run started (`draft_before`) is not this run's work. Without this term the denominator + // tariffed a draft wave the run never performs, and a continuation run over a drafted backlog + // finished a clean `ready` at 50% with the caption stuck on drafting (refuter finding, P9). + draftWork = `least(greatest(r.chapters_before + r.ceiling_chapters - r.draft_before, 0), r.ceiling_chapters)` + draftBar = `least(greatest(` + draftChapters + ` - r.draft_before, 0), ` + draftWork + `)` + // ⚠ EACH numerator is paired with the baseline captured on ITS OWN column, and the pair is chosen + // by the LIVE flag at read time — never by the flag as it stood at capture. The alternative was + // the reviewer's blocker (P9): `edit_wave` legitimately flips false→true AFTER a run starts (the + // engine announces its wave shape with its first progress event — sink.recordWaveShape, STACK §35), + // and a baseline captured through the flag sat on the draft column while the numerator moved to + // the edit one, so the bar read 0/N after all the bought work was done and never caught up. + editBar = `least(greatest(` + editChapters + ` - r.chapters_before, 0), r.ceiling_chapters)` + draftOnlyBar = `least(greatest(` + draftChapters + ` - r.draft_before, 0), r.ceiling_chapters)` + // A pipeline with no editor has ONE wave, and its draft column IS the last pass: counting both + // halves there would count every chapter twice against a doubled total that one wave can never + // reach. + runDone = `(case when ` + editWave + ` then ` + draftBar + ` + ` + editBar + ` else ` + draftOnlyBar + ` end)` + runTotal = `(case when ` + editWave + ` then ` + draftWork + ` + r.ceiling_chapters else r.ceiling_chapters end)` + // runStage is the caption's machine value — what the run is doing NOW, for the client to phrase + // (open vocabulary, like the correction receipt's `depth`). Derived from the same counters as the + // bar so the two cannot disagree: the run is `editing` once the draft passes IT owed are done — + // at once, when it owed none — and always `drafting` where drafting is the only pass there is. + runStage = `(case when ` + editWave + ` and ` + draftBar + ` >= ` + draftWork + ` + then 'editing' else 'drafting' end)` +) // lastRun is the join every book-scoped read uses: the current or last run of the book. const lastRun = `left join lateral ( select id, status, paused_reason, failure_reason, verify_bank, bank_released, ceiling_chapters, - chapters_before, eta_seconds, started_at, finished_at, stop_requested_at + chapters_before, draft_before, eta_seconds, started_at, finished_at, stop_requested_at from runs where book_id = b.id order by started_at desc, id desc limit 1) r on true` func readBookStatusTx(ctx context.Context, tx pgx.Tx, bookID string) (bookStatus, error) { diff --git a/platform/internal/pgstore/readmodel_test.go b/platform/internal/pgstore/readmodel_test.go index 05a5565f..486adf38 100644 --- a/platform/internal/pgstore/readmodel_test.go +++ b/platform/internal/pgstore/readmodel_test.go @@ -271,7 +271,7 @@ func TestTheBankIsReplacedFromTheEnginesReadOut(t *testing.T) { if err != nil { t.Fatal(err) } - if page.Counts.Total != 2 || page.Counts.PendingDecisions != 2 || page.Counts.Complete { + if page.Counts.Total != 2 || page.Counts.Signed != 0 { t.Fatalf("counts: %+v", page.Counts) } // A rebuild of the same read-out answers the same bank: the identities are derived from the term @@ -665,17 +665,22 @@ func TestASecondRunsBarStartsAtZeroOverAHalfFinishedBook(t *testing.T) { if err != nil { t.Fatal(err) } - if run.Progress.Total != 1 { - t.Errorf("the start receipt carries total %d, want what the run bought", run.Progress.Total) + // The denominator is in chapter-passes through BOTH waves: one bought chapter is two passes on a + // pipeline whose editor is not known to be absent (row 200 — the through-bar). + if run.Progress.Total != 2 { + t.Errorf("the start receipt carries total %d, want both passes of what the run bought", run.Progress.Total) } _, card, err := s.GetBook(ctx, "u1", book) if err != nil { t.Fatal(err) } - if card.Progress.Done != 0 || card.Progress.Total != 1 { - t.Errorf("a second run over a half-finished book opens at %d/%d, want 0/1", + if card.Progress.Done != 0 || card.Progress.Total != 2 { + t.Errorf("a second run over a half-finished book opens at %d/%d, want 0/2", card.Progress.Done, card.Progress.Total) } + if card.Progress.Stage != "drafting" { + t.Errorf("a fresh run's stage is %q, want drafting", card.Progress.Stage) + } // And the BOOK's own figure is the lifetime one, which is a different question. b, _, err := s.GetBook(ctx, "u1", book) if err != nil { @@ -715,8 +720,10 @@ func TestTheRunsBarNeverExceedsWhatItBought(t *testing.T) { if b.ChaptersDone != 2 { t.Fatalf("the book finished %d chapters: this fixture cannot bind a clamp at 1", b.ChaptersDone) } - if card.Progress.Done != 1 || card.Progress.Total != 1 { - t.Errorf("the bar reads %d/%d, want it clamped to what the run bought", + // Two chapters finished the last pass against ONE bought: the last-pass half of the bar clamps + // at 1, over the two passes the purchase covers (the draft half is honestly still at zero). + if card.Progress.Done != 1 || card.Progress.Total != 2 { + t.Errorf("the bar reads %d/%d, want the last-pass half clamped to what the run bought", card.Progress.Done, card.Progress.Total) } } @@ -829,13 +836,146 @@ func TestASecondRunOnADraftOnlyDeploymentStillOpensAtZero(t *testing.T) { } } -// The same rule one segment later: lifting the signing stop RE-TAKES the baseline, and it must -// re-take it on the pass the numerator will count from then on. On a deployment with no editor that -// is the draft column, and a baseline re-taken on the edit one opens the second segment at its -// ceiling. +// THE rule of the through-bar (owner's word of 20.08, row 200): the bar is ONE monotonic fraction +// over the run's whole work, so lifting the signing stop MOVES NOTHING — the re-basing that used to +// restart it from zero is gone, and «100%, then zero» with it. Through the stop the draft half +// stands, the edit half continues, and the caption follows the counters. // -// Mutation caught: naming units_edit_done outright in RestartRun's re-capture. -func TestLiftingTheStopOnADraftOnlyDeploymentRetakesTheRightBaseline(t *testing.T) { +// Mutation caught: re-taking either baseline in RestartRun; switching the numerator's wave on +// `bank_released` (the old segment counting). +func TestTheBarIsOneMonotonicFractionThroughTheSigningStop(t *testing.T) { + s, ctx := testDB(t) + book := readingBook(t, s, ctx, "u1") + if err := s.SaveStructure(ctx, book, twoChapters("k1")); err != nil { + t.Fatal(err) + } + at := time.Now().UTC() + run, err := s.StartRun(ctx, StartRunInput{UserID: "u1", BookID: book, VerifyBank: true, + CeilingChapters: 2, Ceiling: money.MicroUSD(300_000), Now: at}, 0, nil) + if err != nil { + t.Fatal(err) + } + // The first segment drafts the whole purchase and stops for the signature: half the work. + exec(t, s, ctx, `update books set edit_wave = true where id = $1`, book) + exec(t, s, ctx, `update chapters set units_draft_done = units_total where book_id = $1`, book) + first, err := s.ReadRun(ctx, "u1", run.ID) + if err != nil { + t.Fatal(err) + } + if first.Progress.Done != 2 || first.Progress.Total != 4 { + t.Fatalf("a fully drafted purchase reads %d/%d, want 2/4 — half the run's work", + first.Progress.Done, first.Progress.Total) + } + if first.Progress.Stage != "editing" { + t.Errorf("with the draft half done the stage is %q, want editing", first.Progress.Stage) + } + if _, err := s.RestartRun(ctx, RestartInput{RunID: run.ID, AttemptID: run.AttemptID, + UserID: "u1", BookID: book, Ceiling: money.MicroUSD(100_000), LiftBankStop: true, + Now: at.Add(time.Minute)}); err != nil { + t.Fatal(err) + } + lifted, err := s.ReadRun(ctx, "u1", run.ID) + if err != nil { + t.Fatal(err) + } + if lifted.Progress.Done != 2 || lifted.Progress.Total != 4 { + t.Errorf("lifting the stop moved the bar to %d/%d, want the 2/4 it stood at", + lifted.Progress.Done, lifted.Progress.Total) + } + // The edit wave finishes what was bought: the same bar reaches its own end. + exec(t, s, ctx, `update chapters set units_edit_done = units_total where book_id = $1`, book) + done, err := s.ReadRun(ctx, "u1", run.ID) + if err != nil { + t.Fatal(err) + } + if done.Progress.Done != 4 || done.Progress.Total != 4 { + t.Errorf("the finished work reads %d/%d, want 4/4", done.Progress.Done, done.Progress.Total) + } +} + +// A continuation run over a DRAFTED backlog owes only the last pass, and its denominator says so: +// a previous run drafted the chapters, stopped at the signature and was abandoned — the new run's +// work is the edit wave alone. Counting the draft wave it never performs left such a run finishing +// a clean `ready` at 50% with the caption stuck on drafting (refuter finding, P9). +// +// Mutation caught: runTotal reading `2 * ceiling` instead of folding in draftWork. +func TestARunOverADraftedBacklogOwesOnlyTheLastPass(t *testing.T) { + s, ctx := testDB(t) + book := readingBook(t, s, ctx, "u1") + if err := s.SaveStructure(ctx, book, twoChapters("k1")); err != nil { + t.Fatal(err) + } + at := time.Now().UTC() + // The whole book is drafted and none of it is edited — the state an abandoned signing run leaves. + exec(t, s, ctx, `update books set edit_wave = true where id = $1`, book) + exec(t, s, ctx, `update chapters set units_draft_done = units_total where book_id = $1`, book) + run, err := s.StartRun(ctx, StartRunInput{UserID: "u1", BookID: book, CeilingChapters: 2, + Ceiling: money.MicroUSD(300_000), Now: at}, 0, nil) + if err != nil { + t.Fatal(err) + } + if run.Progress.Done != 0 || run.Progress.Total != 2 { + t.Errorf("the receipt reads %d/%d, want 0/2 — the draft passes are not this run's work", + run.Progress.Done, run.Progress.Total) + } + if run.Progress.Stage != "editing" { + t.Errorf("stage is %q, want editing: the run owes no draft pass", run.Progress.Stage) + } + exec(t, s, ctx, `update chapters set units_edit_done = units_total where book_id = $1`, book) + got, err := s.ReadRun(ctx, "u1", run.ID) + if err != nil { + t.Fatal(err) + } + if got.Progress.Done != 2 || got.Progress.Total != 2 { + t.Errorf("the finished work reads %d/%d, want 2/2", got.Progress.Done, got.Progress.Total) + } +} + +// THE FLIP ORDER, pinned exactly as it happens in production (reviewer's blocker, P9): a book +// drafted on a no-editor pipeline (`edit_wave = false`), the operator deploys an editor, a +// continuation run starts — and the flag flips to true only AFTER the start, when the engine +// announces its wave shape with its first progress event. The baselines are captured before the +// flip; captured through the flag they sat on the draft column while the numerator moved to the +// edit one, and the bar read 0/N after ALL the bought work was done, forever. +// +// Mutation caught: taking `chapters_before` on the flag-following predicate at StartRun (the exact +// defect); pairing either numerator with the other column's baseline in runDone. +func TestAFlagThatFlipsAfterStartDoesNotStrandTheBar(t *testing.T) { + s, ctx := testDB(t) + book := readingBook(t, s, ctx, "u1") + if err := s.SaveStructure(ctx, book, twoChapters("k1")); err != nil { + t.Fatal(err) + } + at := time.Now().UTC() + // The book's draft era: everything drafted, nothing edited, and the flag SAYS no editor. + exec(t, s, ctx, `update books set edit_wave = false where id = $1`, book) + exec(t, s, ctx, `update chapters set units_draft_done = units_total where book_id = $1`, book) + run, err := s.StartRun(ctx, StartRunInput{UserID: "u1", BookID: book, CeilingChapters: 2, + Ceiling: money.MicroUSD(300_000), Now: at}, 0, nil) + if err != nil { + t.Fatal(err) + } + // The engine's first progress event announces an editor: the flag grows AFTER the start. + exec(t, s, ctx, `update books set edit_wave = true where id = $1`, book) + // The run does the whole of what it bought — the edit pass over the drafted chapters. + exec(t, s, ctx, `update chapters set units_edit_done = units_total where book_id = $1`, book) + got, err := s.ReadRun(ctx, "u1", run.ID) + if err != nil { + t.Fatal(err) + } + if got.Progress.Done != got.Progress.Total || got.Progress.Total != 2 { + t.Errorf("after all the bought work the bar reads %d/%d, want 2/2 — a baseline captured "+ + "through the flag never catches the numerator", got.Progress.Done, got.Progress.Total) + } + if got.Progress.Stage != "editing" { + t.Errorf("stage is %q, want editing: the run owed no draft pass", got.Progress.Stage) + } +} + +// A pipeline with no editor has ONE wave: the bar is what the draft pass finished over what was +// bought — never doubled, never re-counted — and lifting a signing stop moves nothing there either: +// the run's work was done when the draft was. +func TestADraftOnlyDeploymentCountsItsOneWaveOnce(t *testing.T) { s, ctx := testDB(t) book := readingBook(t, s, ctx, "u1") if err := s.SaveStructure(ctx, book, twoChapters("k1")); err != nil { @@ -847,16 +987,14 @@ func TestLiftingTheStopOnADraftOnlyDeploymentRetakesTheRightBaseline(t *testing. if err != nil { t.Fatal(err) } - // A pipeline with no editor drafts the whole book in the first segment. exec(t, s, ctx, `update books set edit_wave = false where id = $1`, book) exec(t, s, ctx, `update chapters set units_draft_done = units_total where book_id = $1`, book) first, err := s.ReadRun(ctx, "u1", run.ID) if err != nil { t.Fatal(err) } - if first.Progress.Done != 2 { - t.Fatalf("the first segment reads %d/%d: this fixture never drafted anything", - first.Progress.Done, first.Progress.Total) + if first.Progress.Done != 2 || first.Progress.Total != 2 { + t.Fatalf("a drafted draft-only purchase reads %d/%d, want 2/2", first.Progress.Done, first.Progress.Total) } if _, err := s.RestartRun(ctx, RestartInput{RunID: run.ID, AttemptID: run.AttemptID, UserID: "u1", BookID: book, Ceiling: money.MicroUSD(100_000), LiftBankStop: true, @@ -867,8 +1005,8 @@ func TestLiftingTheStopOnADraftOnlyDeploymentRetakesTheRightBaseline(t *testing. if err != nil { t.Fatal(err) } - if got.Progress.Done != 0 { - t.Errorf("the second segment opens at %d/%d, want 0 of the work it still has to do", + if got.Progress.Done != 2 || got.Progress.Total != 2 { + t.Errorf("lifting the stop moved a finished bar to %d/%d, want 2/2 — the work was done", got.Progress.Done, got.Progress.Total) } } diff --git a/platform/internal/pgstore/recut_test.go b/platform/internal/pgstore/recut_test.go index 4b2c6a90..42997f22 100644 --- a/platform/internal/pgstore/recut_test.go +++ b/platform/internal/pgstore/recut_test.go @@ -186,22 +186,30 @@ func TestAPageSizeIsClampedAndNeverRefused(t *testing.T) { } } -// The run's bar and its BASELINE count the same pass. +// The run's bar and its BASELINES count the same passes: each numerator is subtracted only from +// the baseline captured on ITS OWN column (draft against draft_before, edit against +// chapters_before), so a signing run over an already-drafted book opens at 0 done — through the +// old segment counting the mismatch opened it at its full ceiling. // -// `chapters_before` was captured on the edit column while the first segment of a signing run counts -// the draft one, so a new run over an already-drafted book opened at its full ceiling — the very -// defect the column was added to prevent. +// ⚠ The fixture keeps the two columns APART on purpose (one chapter edited, both drafted): with +// `units_edit_done = 0` everywhere both pairings answered the same 0, and this test's earlier +// claim named a mutation it could not see — the PD-1 class, in the pin rewritten to cure one. // -// Mutation caught: taking the baseline with `units_done` regardless of the run's own verify_bank. +// Mutation caught: subtracting the EDIT baseline (`chapters_before`) from the draft numerator in +// draftBar. The mirror pairing — the edit numerator against `draft_before` — needs edits ahead of +// drafts, which no real state produces; on its artificial fixture it is held by +// TestASecondRunsBarStartsAtZeroOverAHalfFinishedBook. func TestTheBarAndItsBaselineCountTheSamePass(t *testing.T) { s, ctx := testDB(t) book := readingBook(t, s, ctx, "u1") if err := s.SaveStructure(ctx, book, twoChapters("k1")); err != nil { t.Fatal(err) } - // The book has been DRAFTED end to end and edited nowhere — the state a signing run resumes from. + // Drafted end to end, ONE chapter already edited — the columns differ, so a swapped pairing has + // something to be wrong about. if _, err := s.pool.Exec(ctx, - `update chapters set units_draft_done = units_total, units_edit_done = 0 + `update chapters set units_draft_done = units_total, + units_edit_done = case when number = 1 then units_total else 0 end where book_id = $1`, book); err != nil { t.Fatal(err) } @@ -220,14 +228,18 @@ func TestTheBarAndItsBaselineCountTheSamePass(t *testing.T) { } } -// The SECOND segment measures from what the edit pass had finished when the stop was lifted. +// Lifting the signing stop leaves the bar where it stood, and the edit pass then moves it chapter +// by chapter — through the through-bar (row 200) there is no second segment and nothing re-bases. // -// Lifting the stop switches the numerator from the draft column to the edit one, so a baseline taken -// against the draft column at run start would be subtracted from a count of a different pass: the -// bar of a fully drafted book opened at its ceiling the moment signing was released. +// ⚠ REWRITTEN by pack P9 with the semantics it pins: the earlier edition was named +// «RestartsTheBarFromZero» and claimed to catch «dropping the re-capture from the re-open» — that +// re-capture was REMOVED with the segment model, and the test stayed green, so its words proved +// less than they said (the PD-1 class). What its fixture still pins is real and distinct: a fully +// drafted, unedited book under a signing run — the bar opens at 0 done, the lift moves nothing, +// and one edited chapter moves it by exactly one. // -// Mutation caught: dropping the re-capture from the re-open, or leaving bank_released alone. -func TestLiftingTheSigningStopRestartsTheBarFromZero(t *testing.T) { +// Mutation caught: pairing the edit numerator with the draft baseline in runDone. +func TestLiftingTheSigningStopLeavesTheBarWhereItStood(t *testing.T) { s, ctx := testDB(t) book := readingBook(t, s, ctx, "u1") if err := s.SaveStructure(ctx, book, twoChapters("k1")); err != nil { @@ -254,7 +266,7 @@ func TestLiftingTheSigningStopRestartsTheBarFromZero(t *testing.T) { t.Fatal(err) } if got.Progress.Done != 0 { - t.Fatalf("the second segment opens at %d/%d, want 0 done", got.Progress.Done, got.Progress.Total) + t.Fatalf("after the lift the bar reads %d/%d, want 0 done — the run has done nothing yet", got.Progress.Done, got.Progress.Total) } // One chapter finishes its EDIT pass: the bar moves by exactly one. if _, err := s.pool.Exec(ctx, ` diff --git a/platform/internal/pgstore/runs.go b/platform/internal/pgstore/runs.go index 03ecc51f..f7e20249 100644 --- a/platform/internal/pgstore/runs.go +++ b/platform/internal/pgstore/runs.go @@ -76,34 +76,32 @@ func (s *Store) StartRun(ctx context.Context, in StartRunInput, journalOffset in return err } runID := newID("run") - // The baseline is captured HERE, in the same statement that creates the run: what the book had - // already finished is what this run's bar is measured from (see runProgress). + // BOTH baselines are captured HERE, in the same statement that creates the run: what the book + // had already finished is what each half of this run's bar is measured from (see runDone) — + // `chapters_before` on the EDIT column, `draft_before` on the DRAFT one. Neither is re-based + // afterwards: the bar is one monotonic fraction through both waves, and a base that moved + // mid-run is how it used to restart from zero at the signing stop. // - // ⚠ Counted with the SAME predicate the numerator uses — BOTH halves of it. `$3` is the signing - // half (a signing run's first segment counts the draft wave); `finishedUnits` is the pipeline - // half, and leaving it out is the same defect wearing the other hat: on a deployment with no - // editor the numerator counts the draft column while a baseline taken on the edit one stays at - // zero, so a new run over an already-drafted book opens at its full ceiling. - // - // The shape is the BOOK's (finishedUnits), so it is already settled by whatever ran before — - // this run has announced nothing yet, and a book that has never run has no resolutions either. + // ⚠ FIXED predicates, deliberately NOT the flag-following finishedUnits: `edit_wave` can flip + // false→true AFTER this insert (the engine announces its wave shape with its first progress + // event), and a baseline captured through the flag would sit on the draft column while the + // numerator moved to the edit one — the bar then read 0/N forever (reviewer's blocker, P9). + // Captured flag-free, each baseline is subtracted only from the numerator of its own column + // (runDone pairs them at READ time), so no flip can strand the bar. const insertRun = ` insert into runs (id, book_id, status, verify_bank, ceiling_chapters, started_at, revision, - chapters_before) + chapters_before, draft_before) select $1, $2, 'translating', $3, $4, $5, b.revision + 1, (select count(*) from chapters c - where c.book_id = b.id and c.units_total > 0 - and (case when $3 then c.units_draft_done else ` + finishedUnits + ` end) - >= c.units_total) + where c.book_id = b.id and c.units_total > 0 and c.units_edit_done >= c.units_total), + (select count(*) from chapters c + where c.book_id = b.id and c.units_total > 0 and c.units_draft_done >= c.units_total) from books b where b.id = $2 and b.owner_id = $6 returning id, book_id, revision, status, verify_bank, ceiling_chapters, coalesce(paused_reason, ''), started_at, finished_at` err := tx.QueryRow(ctx, insertRun, runID, in.BookID, in.VerifyBank, in.CeilingChapters, in.Now, in.UserID). Scan(&out.ID, &out.BookID, &out.Revision, &out.Status, &out.VerifyBank, &out.CeilingChapters, &out.PausedReason, &out.StartedAt, &out.FinishedAt) - // A run that has just started has done none of what it bought, and the DENOMINATOR is what the - // screen reads its own scale against — so the receipt carries it rather than a zeroed pair. - out.Progress = Progress{Total: in.CeilingChapters} if errors.Is(err, pgx.ErrNoRows) { return ErrNoBook // the book is missing, or it is not this account's } @@ -113,6 +111,15 @@ func (s *Store) StartRun(ctx context.Context, in StartRunInput, journalOffset in if err != nil { return fmt.Errorf("pgstore: insert run: %w", err) } + // The receipt's bar is READ rather than zeroed: the numerator opens at 0 by construction + // (both halves measure THIS run's work from the baselines the same statement just took), but + // the DENOMINATOR is the run's own — a continuation run over a drafted backlog owes fewer + // draft passes (runTotal folds draftWork in), and the screen reads its scale against it. + if err := tx.QueryRow(ctx, `select `+runDone+`, `+runTotal+`, `+runStage+` + from books b `+lastRun+` where b.id = $1`, in.BookID). + Scan(&out.Progress.Done, &out.Progress.Total, &out.Progress.Stage); err != nil { + return fmt.Errorf("pgstore: read the new run's bar: %w", err) + } if err := tx.QueryRow(ctx, ` insert into run_attempts (run_id, attempt_no, started_at, last_offset, engine_run_id) values ($1, 1, $2, $3, $4) returning id`, @@ -663,6 +670,25 @@ func (s *Store) whyNotLive(ctx context.Context, userID, runID string) error { // The reconciler's own list is deliberately not reusable here: it selects the attempt that has not // ended, and every run this call is about has ended. What resume needs is the state the run stopped // in and the attempt whose money and journal position it stopped at. +// LatestRun answers which run of the book every book-scoped read resolves — the newest by the +// same ordering `lastRun` uses — and whether that row is still live. The resume gate compares +// against it: re-opening any OTHER run leaves the card and every progress frame quoting the wrong +// row (PD-402 keeps the read half), and the two reasons carry different words — a LIVE newer run +// is «the book is being translated», a finished one is «resume the latest». +func (s *Store) LatestRun(ctx context.Context, bookID string) (id string, live bool, err error) { + err = s.pool.QueryRow(ctx, ` + select id, finished_at is null from runs + where book_id = $1 order by started_at desc, id desc limit 1`, + bookID).Scan(&id, &live) + if errors.Is(err, pgx.ErrNoRows) { + return "", false, ErrNoRun + } + if err != nil { + return "", false, fmt.Errorf("pgstore: latest run: %w", err) + } + return id, live, nil +} + func (s *Store) ReadRunForResume(ctx context.Context, userID, runID string) (LiveRun, error) { rows, err := s.queryRuns(ctx, ` join run_attempts a on a.run_id = r.id @@ -833,20 +859,15 @@ func (s *Store) RestartRun(ctx context.Context, in RestartInput) (LiveRun, error // one-live-per-book index does not see, money marked resolved that this attempt has not spent // yet, and a stop request from the previous life that would classify this attempt's ending as // a stop nobody asked for. - // The bar's baseline is re-taken with the pass it will COUNT: lifting the stop moves the - // numerator off the draft column, and a baseline left on the other pass made the second segment - // open at its ceiling. WHICH pass it moves to comes from `finishedUnits` and not from the edit - // column outright — on a deployment with no editor those are different columns, and naming one - // of them here re-opens the same defect one segment later. + // ⚠ The bar's baselines are NOT re-taken here, and that is the through-bar's one rule (row + // 200): the bar counts both waves against the baselines of the run's START, so lifting the + // stop moves nothing — the draft half stands at what the first segment did and the last-pass + // half continues from where the run began. The re-basing that used to live here is what made + // the bar restart from zero at the signing stop. if _, err := tx.Exec(ctx, ` update runs r set status = 'translating', paused_reason = null, finished_at = null, settled_at = null, stop_requested_at = null, bank_released = bank_released or $2, - chapters_before = case when $2 - then (select count(*) from chapters c - where c.book_id = b.id and c.units_total > 0 - and `+finishedUnits+` >= c.units_total) - else chapters_before end, revision = b.revision + 1 from books b where r.id = $1 and b.id = r.book_id`, in.RunID, in.LiftBankStop); err != nil { diff --git a/platform/internal/pgstore/sink.go b/platform/internal/pgstore/sink.go index 24ff5e3c..ce527fbd 100644 --- a/platform/internal/pgstore/sink.go +++ b/platform/internal/pgstore/sink.go @@ -287,15 +287,16 @@ func (r *RunSink) unitDone(ctx context.Context, tx pgx.Tx, ev ingest.Envelope, u return r.emitChapter(ctx, tx, u, ev.Time) } -// emitProgress announces the run's bar as the WIRE counts it: one number in chapters, against what -// the run bought. The event that triggers it is in units and per phase — that split stays inside the -// platform. +// emitProgress announces the run's bar as the WIRE counts it: one monotonic number in chapter-passes +// through both waves, against what the run bought, plus the stage caption. The event that triggers +// it is in units and per phase — that split stays inside the platform. func (r *RunSink) emitProgress(ctx context.Context, tx pgx.Tx) error { var done, total int + var stage string var eta *int err := tx.QueryRow(ctx, ` - select `+runProgress+`, coalesce(r.ceiling_chapters, 0), r.eta_seconds - from books b `+lastRun+` where b.id = $1`, r.bookID).Scan(&done, &total, &eta) + select `+runDone+`, `+runTotal+`, `+runStage+`, r.eta_seconds + from books b `+lastRun+` where b.id = $1`, r.bookID).Scan(&done, &total, &stage, &eta) if errors.Is(err, pgx.ErrNoRows) { return nil } @@ -303,7 +304,7 @@ func (r *RunSink) emitProgress(ctx context.Context, tx pgx.Tx) error { return fmt.Errorf("pgstore: read the run bar: %w", err) } return emitFrame(ctx, tx, r.bookID, FrameProgress, map[string]any{ - "progress": map[string]any{"done": done, "total": total, "eta_seconds": eta}, + "progress": map[string]any{"done": done, "total": total, "stage": stage, "eta_seconds": eta}, }) } diff --git a/platform/internal/pgstore/sink_test.go b/platform/internal/pgstore/sink_test.go index 1311f1d9..f1d2ccdf 100644 --- a/platform/internal/pgstore/sink_test.go +++ b/platform/internal/pgstore/sink_test.go @@ -679,3 +679,84 @@ func TestAFlaggedUnitMovesTheChaptersNoteCounter(t *testing.T) { t.Errorf("a clean unit moved the note counter to %d", got) } } + +// The progress FRAME and the run's card answer ONE bar — same done, same total, same stage — and +// the pin rides the flipped-flag fixture on purpose: the frame is minted inside the transaction +// that flips `edit_wave` (recordWaveShape), which is exactly where a frame computed from different +// fragments than the card's would diverge first. This is the wire half of the through-bar (row +// 200): `stage` had no value pin at all, and a planted done/total swap in the payload outlived +// both batteries before it. +// +// Mutation caught: swapping done/total (or dropping stage) in emitProgress's payload; computing +// the frame from fragments other than the card's runDone/runTotal/runStage. +func TestTheProgressFrameMatchesTheCardOnAFlippedFlag(t *testing.T) { + s, ctx := testDB(t) + now := fundedAccount(t, s, ctx, "u1", "10") + seedBook(t, s, ctx, "bk1", "u1", 500) + if err := s.SaveStructure(ctx, "bk1", twoChapters("k1")); err != nil { + t.Fatal(err) + } + // The book's draft era: drafted end to end, the flag SAYS no editor, one chapter already edited + // by the time the engine speaks — so the bar is non-trivial in the very frame that flips. + exec(t, s, ctx, `update books set edit_wave = false where id = 'bk1'`) + exec(t, s, ctx, `update chapters set units_draft_done = units_total where book_id = 'bk1'`) + run, err := s.StartRun(ctx, StartRunInput{UserID: "u1", BookID: "bk1", CeilingChapters: 2, + Ceiling: money.MicroUSD(300_000), Now: now}, 0, nil) + if err != nil { + t.Fatal(err) + } + exec(t, s, ctx, `update chapters set units_edit_done = units_total + where book_id = 'bk1' and number = 1`) + sink := s.NewRunSink(run.AttemptID, run.ID, "bk1") + if err := sink.Begin(ctx, ingest.Hello{EngineRunID: EngineStreamID(run.ID, 1)}); err != nil { + t.Fatal(err) + } + // The engine's first progress event announces the editor: the flag flips INSIDE this apply, and + // the frame is minted right after it. + if err := apply(t, sink, 2, ingest.TypeProgress, ingest.Progress{ + Draft: ingest.Counter{Done: 3, Total: 3}, Edit: ingest.Counter{Done: 2, Total: 3}, + }); err != nil { + t.Fatal(err) + } + card, err := s.ReadRun(ctx, "u1", run.ID) + if err != nil { + t.Fatal(err) + } + if card.Progress.Done != 1 || card.Progress.Total != 2 || card.Progress.Stage != "editing" { + t.Fatalf("the fixture's card reads %d/%d %q: it cannot tell a swap from the truth", + card.Progress.Done, card.Progress.Total, card.Progress.Stage) + } + frames, err := s.ReadFrames(ctx, "bk1", 0, 100) + if err != nil { + t.Fatal(err) + } + var last json.RawMessage + for _, f := range frames { + if f.Event == FrameProgress { + last = f.Data + } + } + if last == nil { + t.Fatal("the progress event minted no frame") + } + var frame struct { + Progress struct { + Done int `json:"done"` + Total int `json:"total"` + Stage string `json:"stage"` + ETASeconds *int `json:"eta_seconds"` + } `json:"progress"` + } + if err := json.Unmarshal(last, &frame); err != nil { + t.Fatalf("the frame's payload: %v (%s)", err, last) + } + if frame.Progress.Done != card.Progress.Done || frame.Progress.Total != card.Progress.Total || + frame.Progress.Stage != card.Progress.Stage { + t.Errorf("the frame reads %d/%d %q and the card %d/%d %q — one bar, two answers", + frame.Progress.Done, frame.Progress.Total, frame.Progress.Stage, + card.Progress.Done, card.Progress.Total, card.Progress.Stage) + } + if (frame.Progress.ETASeconds == nil) != (card.Progress.ETASeconds == nil) { + t.Errorf("eta: frame %v, card %v", frame.Progress.ETASeconds, card.Progress.ETASeconds) + } +} diff --git a/platform/internal/readmodel/readmodel.go b/platform/internal/readmodel/readmodel.go index 5dedc5bb..a4fff36a 100644 --- a/platform/internal/readmodel/readmodel.go +++ b/platform/internal/readmodel/readmodel.go @@ -146,13 +146,28 @@ func (s *Service) refresh(ctx context.Context, b pgstore.OwedBook, cut *ingest.M ctx, cancel := context.WithTimeout(ctx, MaterializeBudget) defer cancel() var failed error - if err := s.refreshStructure(ctx, b.ID, b.Workdir, cut); err != nil { - failed = err - s.log().ErrorContext(ctx, "the chapter tree could not be refreshed", "err", err) + // ONE manifest read feeds both channels below: the structure is built from it, and the bank + // read-out's PATH now comes from its artifacts envelope (row 213) instead of from a copy of the + // engine's path convention. A manifest that cannot be read therefore fails both — which it + // always did for the tree, and for the bank changes only whose words the error carries. + manifest := ingest.Manifest{} + if cut != nil { + manifest = *cut + } else if read, err := s.Engine.Manifest(ctx, s.Binary, b.Workdir); err != nil { + failed = fmt.Errorf("readmodel: read the manifest: %w", err) + s.log().ErrorContext(ctx, "the manifest could not be read", "err", failed) + } else { + manifest = read } - if err := s.refreshBank(ctx, b.ID, b.Workdir); err != nil { - failed = errors.Join(failed, err) - s.log().ErrorContext(ctx, "the bank could not be refreshed", "err", err) + if failed == nil { + if err := s.refreshStructure(ctx, b.ID, b.Workdir, manifest); err != nil { + failed = err + s.log().ErrorContext(ctx, "the chapter tree could not be refreshed", "err", err) + } + if err := s.refreshBank(ctx, b.ID, manifest.Artifacts.BankExport); err != nil { + failed = errors.Join(failed, err) + s.log().ErrorContext(ctx, "the bank could not be refreshed", "err", err) + } } if failed != nil { return failed @@ -251,17 +266,7 @@ func (s *Service) defer_(ctx context.Context, b pgstore.OwedBook, cause error, c } } -func (s *Service) refreshStructure(ctx context.Context, bookID, workdir string, cut *ingest.Manifest) error { - manifest := ingest.Manifest{} - if cut != nil { - manifest = *cut - } else { - read, err := s.Engine.Manifest(ctx, s.Binary, workdir) - if err != nil { - return fmt.Errorf("readmodel: read the manifest: %w", err) - } - manifest = read - } +func (s *Service) refreshStructure(ctx context.Context, bookID, workdir string, manifest ingest.Manifest) error { // ⚠ THE FLOOR, and it guards the one write in this zone that can lose a whole book's text. // `SaveStructure` writes the tree by REPLACEMENT — every chapter outside the list it is given is // deleted and the cascade takes the pairs with it — so an empty or short list is not a small @@ -336,8 +341,8 @@ func (s *Service) refreshStructure(ctx context.Context, bookID, workdir string, return textErr } -func (s *Service) refreshBank(ctx context.Context, bookID, workdir string) error { - bank, err := runner.ReadBank(workdir) +func (s *Service) refreshBank(ctx context.Context, bookID, bankExport string) error { + bank, err := runner.ReadBank(bankExport) if errors.Is(err, runner.ErrNoBank) { // A book that has never produced terms. Not an empty bank written over a full one: nothing is // saved at all, so a read-out that has simply not been made yet cannot erase one that was. diff --git a/platform/internal/readmodel/readmodel_test.go b/platform/internal/readmodel/readmodel_test.go index 0b2858b1..f5e6d457 100644 --- a/platform/internal/readmodel/readmodel_test.go +++ b/platform/internal/readmodel/readmodel_test.go @@ -21,10 +21,18 @@ type fakeEngine struct { export ingest.Export manifestErr error exportErr error + // noEnvelope models an engine build from before the artifacts envelope (D39.158): its manifest + // names no bank read-out place at all. + noEnvelope bool } -func (f *fakeEngine) Manifest(context.Context, string, string) (ingest.Manifest, error) { - return f.manifest, f.manifestErr +func (f *fakeEngine) Manifest(_ context.Context, _, workdir string) (ingest.Manifest, error) { + m := f.manifest + if !f.noEnvelope { + // Derived from the workdir the way the real engine derives it — beside the project database. + m.Artifacts = ingest.StatusArtifacts{BankExport: filepath.Join(workdir, "bk_1.db.bank.json")} + } + return m, f.manifestErr } func (f *fakeEngine) Export(context.Context, string, string) (ingest.Export, error) { @@ -132,15 +140,16 @@ func tree() ingest.Manifest { func TestTheTextIsJoinedOntoTheTreeByTheEnginesOwnKey(t *testing.T) { store := &fakeStore{} svc := &Service{Store: store, Binary: "tmctl", Engine: &fakeEngine{ - manifest: tree(), + manifest: tree(), + noEnvelope: true, export: ingest.Export{TotalUnits: 3, Units: []ingest.UnitText{ {Chapter: 1, Unit: 4, Source: "第二节", Target: "Второй", State: ingest.StateTranslated}, {Chapter: 2, Unit: 0, Source: "第三节", State: ingest.StateWithheld}, }}, }} - // ⚠ The BANK half fails here — this workdir has no engine artifacts — and the tree lands anyway: - // each channel is a different question about the same book, and answering two of three is - // strictly better than answering none. + // ⚠ The BANK half fails here — this engine publishes no artifacts envelope — and the tree lands + // anyway: each channel is a different question about the same book, and answering two of three + // is strictly better than answering none. if err := svc.Refresh(t.Context(), owedBook(t.TempDir())); err == nil { t.Error("a workdir with no bank read-out reported no failure at all") } @@ -205,7 +214,6 @@ func TestAPairTheExportDidNotCarryDoesNotSpeakAboutItsText(t *testing.T) { {Chapter: 1, Unit: 0, Source: "第一节", Target: "Первый", State: ingest.StateTranslated}, }}, }} - // The bank half fails on an empty workdir, which is not what this pins. _ = svc.Refresh(t.Context(), owedBook(t.TempDir())) var known, unknown int for _, c := range store.structure.Chapters { @@ -227,18 +235,12 @@ func TestAPairTheExportDidNotCarryDoesNotSpeakAboutItsText(t *testing.T) { // // Mutation caught: treating ErrNoBank as an empty read-out. func TestAMissingBankReadOutSavesNothing(t *testing.T) { - // ⚠ The book IS configured and simply has no bank sidecar yet — which is the branch this pins. - // Without the configuration the read failed one step earlier, on the missing book.yaml, and the - // declared property was never reached: "a read-out that has not been made must not erase one that - // was" was asserted by a test that could not tell the two failures apart. - dir := t.TempDir() - if err := os.WriteFile(filepath.Join(dir, "book.yaml"), - []byte("book_id: bk_1\nproject_db: bk_1.db\n"), 0o644); err != nil { - t.Fatal(err) - } + // ⚠ The engine PUBLISHED the read-out's place (the artifacts envelope, row 213) and no file is + // there yet — which is the branch this pins. An engine that published no place at all is the + // loud failure next door, and telling the two apart is what the envelope is for. store := &fakeStore{} svc := &Service{Store: store, Binary: "tmctl", Engine: &fakeEngine{manifest: tree()}} - if err := svc.Refresh(t.Context(), owedBook(dir)); err != nil { + if err := svc.Refresh(t.Context(), owedBook(t.TempDir())); err != nil { t.Fatalf("a book that has never produced terms is not a failure: %v", err) } if store.bankSaved { @@ -249,26 +251,26 @@ func TestAMissingBankReadOutSavesNothing(t *testing.T) { } } -// A workdir with no configuration at all is a different fact, and it IS a failure: nothing about -// this book can be located, so the refresh says so instead of reporting an empty book. -func TestAWorkdirWithNoConfigurationIsAFailureRatherThanAnEmptyBank(t *testing.T) { +// An engine that publishes no artifacts envelope — a build from before D39.158 — is a different +// fact, and it IS a failure: the bank's place cannot be located, and reading that as "no bank" +// would silently stop every bank refresh on such a deployment. +func TestAnEngineWithoutTheEnvelopeIsAFailureRatherThanAnEmptyBank(t *testing.T) { store := &fakeStore{} - svc := &Service{Store: store, Binary: "tmctl", Engine: &fakeEngine{manifest: tree()}} + svc := &Service{Store: store, Binary: "tmctl", Engine: &fakeEngine{manifest: tree(), noEnvelope: true}} if err := svc.Refresh(t.Context(), owedBook(t.TempDir())); err == nil { - t.Fatal("a workdir with no configuration was read as a book with no bank") + t.Fatal("a manifest naming no bank read-out place was read as a book with no bank") } if store.bankSaved { - t.Error("an unreadable configuration saved a bank anyway") + t.Error("a missing envelope saved a bank anyway") } } -// configured is a workdir the bank channel can answer about: the book exists and has simply never -// produced terms, so the read-out is absent rather than unreadable. -func configured(t *testing.T) string { +// garbageBank is a workdir whose published bank read-out exists and is not a bank — the one way a +// book's bank channel fails while its neighbours' answer fine. +func garbageBank(t *testing.T) string { t.Helper() dir := t.TempDir() - if err := os.WriteFile(filepath.Join(dir, "book.yaml"), - []byte("book_id: bk_1\nproject_db: bk_1.db\n"), 0o644); err != nil { + if err := os.WriteFile(filepath.Join(dir, "bk_1.db.bank.json"), []byte("not a bank"), 0o644); err != nil { t.Fatal(err) } return dir @@ -284,7 +286,7 @@ func TestAPartialMaterializationDoesNotDischargeTheDebt(t *testing.T) { svc := &Service{Store: store, Binary: "tmctl", Engine: &fakeEngine{ manifest: tree(), exportErr: errors.New("the engine refused"), }} - if err := svc.Refresh(t.Context(), owedBook(configured(t))); err == nil { + if err := svc.Refresh(t.Context(), owedBook(t.TempDir())); err == nil { t.Fatal("a materialization with no text reported success") } if !store.saved { @@ -308,7 +310,7 @@ func TestACompleteMaterializationDischargesTheBoundaryItRead(t *testing.T) { {Chapter: 1, Unit: 0, Source: "第一节", Target: "Первый", State: ingest.StateTranslated}, }}, }} - if err := svc.Refresh(t.Context(), owedBook(configured(t))); err != nil { + if err := svc.Refresh(t.Context(), owedBook(t.TempDir())); err != nil { t.Fatal(err) } if len(store.cleared) != 1 || store.cleared[0].ID != "bk_1" || !store.cleared[0].OwedAt.Equal(boundary) { @@ -332,7 +334,7 @@ func TestTheDischargeSurvivesAContextTheReadsUsedUp(t *testing.T) { }} ctx, cancel := context.WithCancel(t.Context()) store.beforeClear = cancel // the budget runs out exactly between the last read and the record - if err := svc.Refresh(ctx, owedBook(configured(t))); err != nil { + if err := svc.Refresh(ctx, owedBook(t.TempDir())); err != nil { t.Fatal(err) } if len(store.cleared) != 1 { @@ -349,8 +351,8 @@ func TestTheDischargeSurvivesAContextTheReadsUsedUp(t *testing.T) { // Mutation caught: returning on the first error. func TestTheDrainMaterializesEveryOwedBookAndKeepsWhatFailed(t *testing.T) { store := &fakeStore{owed: []pgstore.OwedBook{ - {ID: "bk_1", Workdir: t.TempDir(), OwedAt: boundary}, // no configuration: the bank read fails - {ID: "bk_2", Workdir: configured(t), OwedAt: boundary}, + {ID: "bk_1", Workdir: garbageBank(t), OwedAt: boundary}, // its read-out is not a bank: the read fails + {ID: "bk_2", Workdir: t.TempDir(), OwedAt: boundary}, }} svc := &Service{Store: store, Binary: "tmctl", Engine: &fakeEngine{manifest: tree()}} if err := svc.Drain(t.Context()); err != nil { @@ -414,7 +416,7 @@ func TestTheManifestsRenderedHeadingHasNowhereToLand(t *testing.T) { // // Mutation caught: draining without claiming; ignoring a claim somebody else holds. func TestTheDrainSkipsABookSomebodyElseIsAlreadyMaterializing(t *testing.T) { - store := &fakeStore{owed: []pgstore.OwedBook{{ID: "bk_1", Workdir: configured(t), OwedAt: boundary}}} + store := &fakeStore{owed: []pgstore.OwedBook{{ID: "bk_1", Workdir: t.TempDir(), OwedAt: boundary}}} svc := &Service{Store: store, Binary: "tmctl", Engine: &fakeEngine{manifest: tree()}} store.unclaimable = true if err := svc.Drain(t.Context()); err != nil { @@ -591,9 +593,9 @@ func exitError(t *testing.T, code int) error { // Mutation caught: deferring to `now()`; not counting the attempt; never abandoning; abandoning on // the first failure. func TestAnUnpayableDebtBacksOffAndIsEventuallyGivenUpOn(t *testing.T) { - // A workdir with no engine configuration: the bank read fails, so the materialization never - // completes — the shape of a project directory an operator moved. - broken := t.TempDir() + // A book whose published bank read-out exists and is not a bank: the read fails on every pass, so + // the materialization never completes — the shape of a project directory an operator broke. + broken := garbageBank(t) for attempts := range maxAttempts { store := &fakeStore{owed: []pgstore.OwedBook{ {ID: "bk_1", Workdir: broken, OwedAt: boundary, Attempts: attempts}, diff --git a/platform/internal/runner/artifacts.go b/platform/internal/runner/artifacts.go index 789342be..d9bfb6c2 100644 --- a/platform/internal/runner/artifacts.go +++ b/platform/internal/runner/artifacts.go @@ -6,9 +6,6 @@ import ( "io" "io/fs" "os" - "path/filepath" - - "gopkg.in/yaml.v3" "textmachine/platform/internal/ingest" ) @@ -28,13 +25,20 @@ var ErrNoBank = errors.New("runner: this book has no bank read-out") // that is not one rather than sizing one that is. const maxBank = 256 << 20 -// ReadBank decodes a book's whole-bank read-out. -func ReadBank(workdir string) (ingest.Bank, error) { - db, err := projectDB(workdir) - if err != nil { - return ingest.Bank{}, err +// ReadBank decodes a book's whole-bank read-out at the path the ENGINE published for it — the +// `artifacts.bank_export` of its manifest and status documents (row 213). +// +// The path is taken rather than derived: this function used to parse `book.yaml` and re-derive +// `.bank.json` by copying the engine's default from backend/internal/config/book.go, +// which is another zone's convention — a changed default there would have silently walked this read +// off to a path where no file ever appears. +func ReadBank(path string) (ingest.Bank, error) { + if path == "" { + // An engine build from before the artifact envelope (D39.158). Loud, because the quiet + // alternative is a bank that silently never refreshes on such a deployment. + return ingest.Bank{}, errors.New("runner: the engine published no bank read-out path: its manifest carries no artifacts envelope") } - f, err := os.Open(db + ".bank.json") + f, err := os.Open(path) if errors.Is(err, fs.ErrNotExist) { return ingest.Bank{}, ErrNoBank } @@ -51,48 +55,3 @@ func ReadBank(workdir string) (ingest.Bank, error) { } return ingest.DecodeBank(raw) } - -// projectDB asks the book's own configuration where its project database is. -// -// ⚠ This READS `book.yaml` and does not contradict D39.110 §2b: that decision is about who OWNS the -// file — the platform renders one starting copy and never edits it again — not about whether the -// path it declares may be followed. The sidecars are named after `project_db` by the engine, so -// there is no second place to learn the name from, and the alternative (globbing the directory for -// `*.bank.json`) would guess where a configuration states. -// -// The path is resolved against the BOOK's directory, which is how the engine resolves every relative -// path in that file. -func projectDB(workdir string) (string, error) { - f, err := os.Open(filepath.Join(workdir, ConfigFile)) - if err != nil { - return "", fmt.Errorf("runner: open book configuration: %w", err) - } - defer f.Close() - raw, err := io.ReadAll(io.LimitReader(f, maxBookConfig+1)) - if err != nil { - return "", fmt.Errorf("runner: read book configuration: %w", err) - } - var cfg struct { - ProjectDB string `yaml:"project_db"` - BookID string `yaml:"book_id"` - } - if err := yaml.Unmarshal(raw, &cfg); err != nil { - return "", fmt.Errorf("runner: read book configuration: %w", err) - } - if cfg.ProjectDB == "" { - // ⚠ The key is OPTIONAL to the engine, which defaults to `.db` beside the file - // (backend/internal/config/book.go). Refusing here read the bank as missing on every - // deployment whose operator used the template as written, since it carries no `project_db`. - if cfg.BookID == "" { - return "", errors.New("runner: the book configuration names neither project_db nor book_id") - } - return filepath.Join(workdir, cfg.BookID+".db"), nil - } - if filepath.IsAbs(cfg.ProjectDB) { - return cfg.ProjectDB, nil - } - return filepath.Join(workdir, cfg.ProjectDB), nil -} - -// maxBookConfig bounds what will be read as a book configuration — a page of keys. -const maxBookConfig = 1 << 20 diff --git a/platform/internal/runner/artifacts_test.go b/platform/internal/runner/artifacts_test.go index bcbf2508..10481a74 100644 --- a/platform/internal/runner/artifacts_test.go +++ b/platform/internal/runner/artifacts_test.go @@ -1,41 +1,31 @@ package runner import ( + "errors" "os" "path/filepath" "testing" ) -// `project_db` is OPTIONAL to the engine, which defaults to `.db` beside the file — and the -// operator's own template carries no such key. Refusing here read the bank as missing on every -// deployment that used the template as written. +// The bank read-out is opened at the path the ENGINE published (`artifacts.bank_export`, row 213) — +// the derivation from `book.yaml` that used to live here copied another zone's path convention and +// is gone with this pin's previous edition. // -// Mutation caught: requiring the key. -func TestTheProjectDatabaseDefaultsTheWayTheEngineDefaultsIt(t *testing.T) { +// Mutation caught: treating an empty published path as "no bank yet" — on an engine build from +// before the envelope that would silently stop every bank refresh, so it must refuse loudly instead. +func TestTheBankIsReadAtThePathTheEnginePublished(t *testing.T) { dir := t.TempDir() - if err := os.WriteFile(filepath.Join(dir, ConfigFile), []byte("book_id: sample-zh\n"), 0o644); err != nil { + path := filepath.Join(dir, "sample-zh.db.bank.json") + if _, err := ReadBank(path); !errors.Is(err, ErrNoBank) { + t.Fatalf("a published place with no file yet answered %v, want ErrNoBank", err) + } + if err := os.WriteFile(path, []byte(`{"bank_version":"tm-bank-v1","book_id":"sample-zh","terms":[]}`), 0o644); err != nil { t.Fatal(err) } - got, err := projectDB(dir) - if err != nil { - t.Fatalf("a configuration without project_db: %v", err) + if _, err := ReadBank(path); err != nil { + t.Fatalf("the read-out at the published path: %v", err) } - if want := filepath.Join(dir, "sample-zh.db"); got != want { - t.Errorf("project database %q, want %q", got, want) - } - // Declared explicitly, it still wins. - if err := os.WriteFile(filepath.Join(dir, ConfigFile), - []byte("book_id: sample-zh\nproject_db: other.db\n"), 0o644); err != nil { - t.Fatal(err) - } - if got, err = projectDB(dir); err != nil || got != filepath.Join(dir, "other.db") { - t.Errorf("a declared project_db answered %q (%v)", got, err) - } - // Neither key: there is nothing to derive from, and that IS a failure. - if err := os.WriteFile(filepath.Join(dir, ConfigFile), []byte("title: x\n"), 0o644); err != nil { - t.Fatal(err) - } - if _, err := projectDB(dir); err == nil { - t.Error("a configuration naming neither key was accepted") + if _, err := ReadBank(""); err == nil || errors.Is(err, ErrNoBank) { + t.Errorf("an empty published path answered %v, want a loud refusal naming the missing envelope", err) } } diff --git a/platform/internal/runner/bankapply.go b/platform/internal/runner/bankapply.go new file mode 100644 index 00000000..a6856e8f --- /dev/null +++ b/platform/internal/runner/bankapply.go @@ -0,0 +1,124 @@ +package runner + +import ( + "bytes" + "context" + "errors" + "fmt" + "io" + "os/exec" + "path/filepath" + "syscall" + "time" + + "textmachine/platform/internal/ingest" +) + +// bankapply.go: the correction channel — `tmctl bank-apply` run as a direct child, like the other +// $0 commands (engine.go). It is NOT readEngine, because the exit code is DATA here and not only a +// verdict: the refusal band tells the caller's remedy apart (re-decide / re-send the same document / +// call the operator), and several classes still print the whole report on stdout on their way out. + +// BankApplyArgs is the verb's argv. `--dry-run` is the projection mode the contract's `preview` +// maps to (17-seam-inbound-law п.7). +func BankApplyArgs(workdir, decisionsPath string, preview bool) []string { + args := []string{"bank-apply", "--config", filepath.Join(workdir, ConfigFile), + "--decisions", decisionsPath} + if preview { + args = append(args, "--dry-run") + } + return args +} + +// maxBankReport bounds what is read back as a report. A report itemizes at most the 5000 decisions +// of one act; the cap refuses a process at the configured path that is not the engine. +const maxBankReport = 64 << 20 + +// bankStopGrace is how long the verb gets after SIGTERM before it is killed outright. Short next to +// the run supervisor's: the verb's own SIGTERM contract is "written nothing yet — drop the work" +// or "the writes are last and atomic", so what the grace protects is a write already in flight. +// +// 30 s, not a token 10: the engine's own measurement puts a maximum document (5000 minimal +// declines) at ~11.3 s of uninterruptible fold before the pre-write context check (backend +// bankdecisions.go, maxDecisions) — a 10 s grace expired BEFORE the phase it exists to protect +// could even begin, and the SIGKILL it fired mid-fold is what produced half-landed pairs with no +// report (workflow finding, P9). 30 s clears the measured worst case with a ~2.5× margin for a +// slower host; the cost of the width is only how long a wedged verb can outlive its budget. +const bankStopGrace = 30 * time.Second + +// BankApplyOutcome is what running the verb produced, exit code and report together — the caller +// maps the pair onto the contract, this layer only runs and reads. +type BankApplyOutcome struct { + // Report is the decoded report; Decoded says one arrived on stdout and parsed. Classes that + // speak on stderr alone (config, a held project, an unmigrated schema) legitimately leave it + // false. + Report ingest.BankReport + Decoded bool + // DecodeErr is why stdout did not parse as a report, when it carried bytes that did not. + DecodeErr error + // ExitCode is the engine's own exit, valid when Exited. A process that did not exit on its own + // — killed on the caller's deadline, or never started — leaves Exited false. + ExitCode int + Exited bool + // Stderr is the first line of the engine's stderr — for the operator's log, never for the wire. + Stderr string +} + +// BankApply runs the verb against a book and reads its report back. +// +// The returned error is "the verb could not be run or read at all" — a start failure, or the +// caller's context ending. An ordinary non-zero exit is NOT an error here: it comes back as the +// outcome's ExitCode, usually with the report beside it. +func (r *Runner) BankApply(ctx context.Context, binary, workdir, decisionsPath string, preview bool) (BankApplyOutcome, error) { + cmd := exec.CommandContext(ctx, binary, BankApplyArgs(workdir, decisionsPath, preview)...) + cmd.Dir = workdir + var out, errOut bytes.Buffer + cmd.Stdout = &limitedBuffer{buf: &out, limit: maxBankReport} + cmd.Stderr = &errOut + // SIGTERM, not the default SIGKILL: the engine's contract for it is graceful — nothing written + // before the pre-write check, atomic renames after — and a SIGKILL mid-rename is exactly the + // half-landed state class 15 exists to report, produced by us instead of by the host. The same + // signal systemd's KillSignal sends the run units. + cmd.Cancel = func() error { return cmd.Process.Signal(syscall.SIGTERM) } + cmd.WaitDelay = bankStopGrace + err := cmd.Run() + res := BankApplyOutcome{Stderr: string(firstLine(errOut.Bytes()))} + if cmd.ProcessState != nil && cmd.ProcessState.Exited() { + res.ExitCode, res.Exited = cmd.ProcessState.ExitCode(), true + } + if body := bytes.TrimSpace(out.Bytes()); len(body) > 0 { + if rep, derr := ingest.DecodeBankReport(body); derr != nil { + res.DecodeErr = derr + } else { + res.Report, res.Decoded = rep, true + } + } + if err != nil && !res.Exited { + // Killed, or never a process: the exit code carries nothing, so the error is the answer. + // The caller's own deadline is folded in — an *exec.ExitError from a SIGKILL says less than + // "the budget ran out". + return res, errors.Join(fmt.Errorf("runner: tmctl bank-apply: %w: %s", err, res.Stderr), ctx.Err()) + } + return res, nil +} + +// limitedBuffer refuses growth past its limit instead of buffering whatever a process feels like +// printing. Refusing the WRITE is what kills the child through the exec pipe machinery — exec's +// copy goroutine closes the READ end the moment a Write errors (os/exec writerDescriptor: «in case +// io.Copy stopped due to write error»), and the child dies on EPIPE at its next print. drain() +// carries its own Kill because the streamed commands read through StdoutPipe, where no goroutine +// closes anything for the caller — a workflow review read this file as if it were that one, and +// the refutation is pinned (TestAnEndlessBankApplyIsRefusedRatherThanRead). +type limitedBuffer struct { + buf *bytes.Buffer + limit int +} + +func (l *limitedBuffer) Write(p []byte) (int, error) { + if l.buf.Len()+len(p) > l.limit { + return 0, errors.New("the engine printed more than this platform will read, which no report produces") + } + return l.buf.Write(p) +} + +var _ io.Writer = (*limitedBuffer)(nil) diff --git a/platform/internal/runner/bankapply_live_test.go b/platform/internal/runner/bankapply_live_test.go new file mode 100644 index 00000000..f697e309 --- /dev/null +++ b/platform/internal/runner/bankapply_live_test.go @@ -0,0 +1,141 @@ +package runner + +import ( + "os" + "path/filepath" + "slices" + "testing" + + "gopkg.in/yaml.v3" + + "textmachine/platform/internal/ingest" +) + +// `--dry-run` is the whole of what separates a preview from a write, so its presence is pinned at +// the argv — the one place a dropped flag is a one-token diff. +// +// Mutation caught: passing preview through without the flag (every call becomes a write), or +// passing the flag unconditionally (every write becomes a no-op). +func TestAPreviewIsTheDryRunFlagAndNothingElse(t *testing.T) { + if !slices.Contains(BankApplyArgs("/w", "/d.json", true), "--dry-run") { + t.Error("a preview call carries no --dry-run: it would WRITE") + } + if slices.Contains(BankApplyArgs("/w", "/d.json", false), "--dry-run") { + t.Error("an apply call carries --dry-run: it would never write") + } +} + +// The canon's central promise about the preview, proven against the REAL engine: «"preview": true +// answers the same receipt without changing anything» (§applyBankCorrections). A regress of one +// constant turns the safe look into an irreversible write, and no unit test can make this claim — +// what writes is the engine, so the engine is what must be seen not writing. +// +// Gated like every live-engine test: a bare clone stays green and says why. +func TestALivePreviewWritesNothingAndALiveApplyWrites(t *testing.T) { + bin := os.Getenv("TM_PLATFORM_TEST_ENGINE_BIN") + tpl := os.Getenv("TM_PLATFORM_TEST_BOOK_TEMPLATE") + if bin == "" || tpl == "" { + t.Skip("TM_PLATFORM_TEST_ENGINE_BIN and TM_PLATFORM_TEST_BOOK_TEMPLATE not set: " + + "the preview's no-write promise is not checked against a real engine") + } + dir := t.TempDir() + writeProbeBook(t, tpl, dir, "bk_PREVIEWPROBE") + doc, err := ingest.EncodeDecisions("bk_PREVIEWPROBE", []ingest.BankDecision{ + {Action: "approve", Src: "测试", Sense: "", Dst: "проба", Note: "preview pin"}, + }) + if err != nil { + t.Fatal(err) + } + decisions := filepath.Join(t.TempDir(), "decisions.json") + if err := os.WriteFile(decisions, doc, 0o600); err != nil { + t.Fatal(err) + } + before := dirListing(t, dir) + rn := New(nil) + out, err := rn.BankApply(t.Context(), bin, dir, decisions, true) + if err != nil || !out.Exited || out.ExitCode != 0 || !out.Decoded { + t.Fatalf("the live preview did not answer: %+v (%v; stderr %q)", out, err, out.Stderr) + } + if out.Report.Mode != "projection" || !out.Report.Changed { + t.Fatalf("the preview's report: mode %q changed %v, want a projection that WOULD change", + out.Report.Mode, out.Report.Changed) + } + // Nothing appeared and nothing moved — except the lock file, which the verb's own contract + // names: a projection computed without the arbiter is a projection of nothing. + after := dirListing(t, dir) + for name, sum := range after { + if filepath.Ext(name) == ".lock" { + continue + } + if prev, ok := before[name]; !ok { + t.Errorf("the preview CREATED %s", name) + } else if prev != sum { + t.Errorf("the preview CHANGED %s", name) + } + } + // The same document applied for real is the counter-proof: the engine both can and does write + // here, so the silence above was the preview's doing and not a fixture that cannot write. + out, err = rn.BankApply(t.Context(), bin, dir, decisions, false) + if err != nil || out.ExitCode != 0 || !out.Decoded || out.Report.Mode != "apply" || !out.Report.Changed { + t.Fatalf("the live apply did not land: %+v (%v; stderr %q)", out, err, out.Stderr) + } + applied := dirListing(t, dir) + grew := false + for name := range applied { + if _, ok := after[name]; !ok && filepath.Ext(name) != ".lock" { + grew = true + } + } + if !grew { + t.Error("the apply wrote no decision file: this fixture proves nothing about the preview") + } +} + +// writeProbeBook renders a minimal live book: the deployment template with the identity, languages +// and source the intake would have filled in (the four keys books.Service.provision sets). +func writeProbeBook(t *testing.T, tpl, dir, bookID string) { + t.Helper() + raw, err := os.ReadFile(tpl) + if err != nil { + t.Fatal(err) + } + var cfg map[string]any + if err := yaml.Unmarshal(raw, &cfg); err != nil { + t.Fatal(err) + } + cfg["book_id"] = bookID + cfg["source_lang"] = "zh" + cfg["target_lang"] = "ru" + cfg["source_file"] = "source.txt" + out, err := yaml.Marshal(cfg) + if err != nil { + t.Fatal(err) + } + if err := os.WriteFile(filepath.Join(dir, ConfigFile), out, 0o600); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(filepath.Join(dir, "source.txt"), []byte("第1章 测试\n这是一个测试文本。\n"), 0o600); err != nil { + t.Fatal(err) + } +} + +// dirListing is the book directory as facts: every file name with a digest of its bytes. +func dirListing(t *testing.T, dir string) map[string]string { + t.Helper() + out := map[string]string{} + entries, err := os.ReadDir(dir) + if err != nil { + t.Fatal(err) + } + for _, e := range entries { + if e.IsDir() { + continue + } + b, err := os.ReadFile(filepath.Join(dir, e.Name())) + if err != nil { + t.Fatal(err) + } + out[e.Name()] = string(rune(len(b))) + "-" + string(b[:min(len(b), 64)]) + } + return out +} diff --git a/platform/internal/runner/bankapply_overflow_test.go b/platform/internal/runner/bankapply_overflow_test.go new file mode 100644 index 00000000..2415e4e6 --- /dev/null +++ b/platform/internal/runner/bankapply_overflow_test.go @@ -0,0 +1,25 @@ +package runner + +import ( + "path/filepath" + "testing" + "time" +) + +// The stdout cap must END an endless writer, not merely stop reading it. The mechanism is exec's +// own: when limitedBuffer refuses a Write, the copy goroutine closes the pipe's read end («in case +// io.Copy stopped due to write error», os/exec writerDescriptor) and the child dies on EPIPE at +// its next print — no explicit Kill here, unlike drain(), whose StdoutPipe reader gets no such +// help. A workflow review claimed the child blocks on a full pipe until the caller's deadline; +// this pin is the refutation, and it is what fails if the cap ever stops refusing the Write. +func TestAnEndlessBankApplyIsRefusedRatherThanRead(t *testing.T) { + bin := fakeEngine(t, `exec yes '{"report_version":"tm-bank-decisions-report-v2"}'`) + start := time.Now() + _, err := New(nil).BankApply(t.Context(), bin, t.TempDir(), filepath.Join(t.TempDir(), "d.json"), false) + if err == nil { + t.Fatal("an endless report was read to the end") + } + if took := time.Since(start); took > 30*time.Second { + t.Fatalf("the overflow did not end the child: the call took %v", took) + } +} diff --git a/platform/internal/runner/engine.go b/platform/internal/runner/engine.go index 2d2248e8..35c0ce5f 100644 --- a/platform/internal/runner/engine.go +++ b/platform/internal/runner/engine.go @@ -80,11 +80,21 @@ func (t CeilingTemplate) Args(ceiling money.MicroUSD) ([]string, error) { // // `--config` is not optional and its absence is not tolerated: tmctl requires it on every book // command, so a call without it fails at argument parsing and never reaches the book. -func TranslateArgs(workdir string, verifyBank bool, ceiling []string) []string { +// +// keysFile is the DEPLOYMENT's provider-key file, passed as `--keys-file` (row 211). It goes on +// `translate` alone because the engine refuses the flag on every other command (D20.4: the $0 read +// verbs must not demand keys), and it goes as an ARGUMENT rather than into the unit's environment: +// keys never pass through this process. Empty means the flag is not passed and the engine falls back +// to its conventional `.env` files — which on the SaaS path nothing writes, so a deployment that +// leaves this unset is one whose runs fail at the first provider call. +func TranslateArgs(workdir string, verifyBank bool, keysFile string, ceiling []string) []string { args := []string{"translate", "--config", filepath.Join(workdir, ConfigFile)} if verifyBank { args = append(args, "--verify-bank") } + if keysFile != "" { + args = append(args, "--keys-file", keysFile) + } return append(args, ceiling...) } diff --git a/platform/internal/runner/runner.go b/platform/internal/runner/runner.go index 535a7edf..c74f6bb2 100644 --- a/platform/internal/runner/runner.go +++ b/platform/internal/runner/runner.go @@ -70,8 +70,9 @@ type Spec struct { Args []string // Workdir is the book's project directory. Workdir string - // Env is the child's environment as KEY=VALUE. Provider keys arrive through it and are never - // logged. + // Env is the child's environment as KEY=VALUE. It carries the run's identity and never provider + // keys — those reach the engine as the `--keys-file` argument in Args (row 211) — and, like + // Args, it is never logged. Env []string // ExitMarker is the file ExecStopPost fills with what systemd saw. It is platform state and // lives in the platform's state directory, not in the book's directory — the engine owns that diff --git a/platform/internal/runner/runner_test.go b/platform/internal/runner/runner_test.go index 21827a58..17f7ff07 100644 --- a/platform/internal/runner/runner_test.go +++ b/platform/internal/runner/runner_test.go @@ -174,14 +174,14 @@ func TestTheCeilingIsRefusedUntilTheEngineCanBeToldIt(t *testing.T) { // tmctl requires --config on every command that touches a book, so an invocation without it never // reaches the book at all — measured on a binary built from HEAD. func TestEveryEngineInvocationNamesTheBookConfig(t *testing.T) { - tr := TranslateArgs("/srv/books/a", true, []string{"--ceiling-usd", "1.000000"}) + tr := TranslateArgs("/srv/books/a", true, "", []string{"--ceiling-usd", "1.000000"}) if !slices.Contains(tr, "--config") || !slices.Contains(tr, "/srv/books/a/book.yaml") { t.Errorf("translate argv without --config: %v", tr) } if !slices.Contains(tr, "--verify-bank") { t.Errorf("verify_bank did not reach the engine: %v", tr) } - if slices.Contains(TranslateArgs("/w", false, nil), "--verify-bank") { + if slices.Contains(TranslateArgs("/w", false, "", nil), "--verify-bank") { t.Error("verify-bank was sent for a run that did not ask for it") } st := StatusArgs("/srv/books/a") @@ -190,6 +190,22 @@ func TestEveryEngineInvocationNamesTheBookConfig(t *testing.T) { } } +// The deployment's key file reaches the engine as `--keys-file` on `translate` — and ONLY when one +// is configured: an empty path passed as a flag would be refused by the engine's own parser +// («--keys-file was given with no path», cmd/tmctl/invocation.go). +// +// Mutation caught: dropping the argument, or passing the flag with an empty value. +func TestTheDeploymentKeyFileReachesTranslate(t *testing.T) { + tr := TranslateArgs("/srv/books/a", false, "/etc/tm/keys.env", nil) + i := slices.Index(tr, "--keys-file") + if i < 0 || i+1 >= len(tr) || tr[i+1] != "/etc/tm/keys.env" { + t.Errorf("the key file did not reach the engine: %v", tr) + } + if slices.Contains(TranslateArgs("/srv/books/a", false, "", nil), "--keys-file") { + t.Error("an unset key file was still passed as a flag") + } +} + func TestTheMarkerSurvivesAndIsReadBack(t *testing.T) { path := filepath.Join(t.TempDir(), "runs", "X-1.exit") if _, err := ReadMarker(path); !errors.Is(err, ErrNoMarker) { diff --git a/platform/internal/runs/bank.go b/platform/internal/runs/bank.go new file mode 100644 index 00000000..0cce7477 --- /dev/null +++ b/platform/internal/runs/bank.go @@ -0,0 +1,360 @@ +package runs + +import ( + "context" + "errors" + "fmt" + "os" + "path/filepath" + "strings" + + "textmachine/platform/internal/ingest" + "textmachine/platform/internal/pgstore" + "textmachine/platform/internal/runner" +) + +// bank.go: the platform half of the bank-correction door (D39.156 pack 2в) — the service behind +// `POST /books/{bookId}/bank/corrections`. The call is SYNCHRONOUS by design: the engine's verb is +// $0, exits rather than waits on the signing stop, and its 5000-decision cap was chosen so one call +// fits a caller's timeout (backend/internal/pipeline/bankdecisions.go, maxDecisions) — so the +// handler accepts the document, runs the verb, and answers its report as the receipt. +// +// What makes the synchronous form safe is one per-book rule, held HERE and shared with Start and +// Resume: a corrections call and the spawn of a fresh run must not interleave on one book. The +// engine's flock already makes the interleaving harmless — the loser refuses with class 12 — but a +// spawned `translate` that dies on the flock is a wasted attempt with money machinery around it. +// +// The rule also decides which WORD each refusal carries, and the split is exact: `run_in_flight` +// is answered from the platform's own run row (below, under the mutex), and ONLY from it; a class +// 12 from the verb itself is by construction NOT a run — Start and Resume wait on this same mutex, +// the reconciler only restarts rows the check below already sees — so it is a transient holder of +// the project (a boundary materialization reading the book, an operator's manual tmctl) and is +// answered «busy, retry later», never with a run that does not exist (PD-400, первая половина). +// +// ⚠ NAMED BOUNDARY OF v1: the mutex is in-process. A second platform replica over one store would +// not see this one's calls, and the serialization narrows to per-replica: the cost is bounded and +// is exactly the noise the mutex prevents — a translate spawned into the verb's flock dies with a +// wasted attempt, a sibling verb answers «retry later». The boundary stops holding the day a second +// replica is deployed (PD-400, вторая половина); the cure then is an arbiter in the store, not a +// bigger mutex. + +// BankCorrectionsInput is one validated correction document, in seam vocabulary. The HANDLER owns +// the wire form and its validation; by this point the decisions are renderable as they stand. +type BankCorrectionsInput struct { + UserID string + BookID string + Preview bool + Decisions []ingest.BankDecision +} + +// BankReceipt is the whole answer of an accepted call, still in the engine's vocabulary where the +// two overlap — the projection into contract words (`depth`, pointers) is the HTTP layer's. +type BankReceipt struct { + Preview bool + Changed bool + Depth string + Accepted []ingest.AcceptedDecision + PreexistingFaults int + // Signature is nil when no run has reached a signing stop yet — there is nothing to count + // against (the report's empty `map`). + Signature *ingest.SignatureState +} + +// ErrBankRefused is the all-or-nothing refusal: the document was read and declined whole, nothing +// was written, and the USER re-decides. The per-decision reasons ride the error. +type ErrBankRefused struct { + Refusals []ingest.RejectedDecision +} + +func (e *ErrBankRefused) Error() string { + return fmt.Sprintf("runs: the correction document was refused whole (%d reasons)", len(e.Refusals)) +} + +// ErrBankIncomplete — the document was ACCEPTED and did not land whole (the engine's class 15). The +// remedy is to re-send the SAME document: the retry converges on the engine's byte no-op. +var ErrBankIncomplete = errors.New("runs: the correction write did not complete; re-sending the same document converges") + +// ErrBankUnavailable — the deployment cannot serve this call RIGHT NOW and the remedy is neither a +// re-decision nor a blind resend: an unmigrated project schema, a refusal class this build has no +// number for, a stop that landed mid-call. Temporary from the caller's seat; the operator has the +// log line. +var ErrBankUnavailable = errors.New("runs: the correction door cannot answer right now") + +// ErrBankDocumentTooLarge — the RENDERED decision document is over the engine's byte ceiling +// (ingest.MaxDecisionsDocument). The canon's word for the size bound is 413 «split the document», +// and it must be said before the verb is spawned: the engine's own copy of this cap answers as a +// refusal, which reads «re-decide». +var ErrBankDocumentTooLarge = errors.New("runs: the rendered decision document is over the engine's byte ceiling") + +// ApplyBankCorrections runs one correction document against a book, synchronously. +func (s *Service) ApplyBankCorrections(ctx context.Context, in BankCorrectionsInput) (BankReceipt, error) { + if s.Cfg.EngineBinary == "" { + // Unreachable through the mounted route — the door is not mounted without an engine — and + // still answered rather than assumed: a read replica reached directly must not panic. + return BankReceipt{}, errors.New("runs: this deployment has no engine to apply corrections with") + } + // The budget covers the WAIT as well as the verb: it is set before the queue, so K callers piled + // on one book cost each of them at most one budget, not K of them — and a caller whose client or + // deadline is gone leaves the queue instead of holding a place in it to do work for a dead + // request (workflow finding, P9). + // + // ⚠ The budget is the sweep's own TM_PLATFORM_RUN_BUDGET knob, and the engine sized its + // 5000-decision cap against the SHIPPED 60 s — a maximum document measures ~11.3 s of + // uninterruptible fold (backend bankdecisions.go, maxDecisions). An operator lowering the knob + // toward that figure makes a legal maximum document permanently unservable: every retry answers + // 503 and the «split it» word never comes (PD-408 carries this as the knob's named cost). + ctx, cancel := context.WithTimeout(ctx, s.runBudget()) + defer cancel() + // The book lock FIRST, the live-run check UNDER it: read before the lock, the answer could be + // a moment from wrong — a resume admitted while this call waited would spawn into the verb's + // flock. Serialized, either this call finishes before the spawn happens, or the run row exists + // and the refusal below names it truthfully. + unlock, err := s.lockBook(ctx, in.BookID) + if err != nil { + // The queue outlived the caller's budget or the caller itself: «busy, retry later» is the + // honest word — nothing was read, spawned or written. + s.log().InfoContext(ctx, "a correction call left the book queue before its turn", "err", err) + return BankReceipt{}, ErrBankUnavailable + } + defer unlock() + book, err := s.Store.ReadBookForRun(ctx, in.UserID, in.BookID) + if err != nil { + return BankReceipt{}, err + } + if !readyToTranslate(book.Status) { + // The same gate Start holds, for the same reason wearing this door's hat: a book still + // arriving or being cut has no configuration the verb could open — reaching the engine + // anyway came back as its config class, which the caller saw as a 500 about our own wiring, + // and the parse worker's transient hold on the project could even dress it as a run + // (refuter finding, P9). The remedy is «wait for the intake», which is what book_not_ready + // says and internal_error does not. + return BankReceipt{}, fmt.Errorf("%w: it is %s", ErrBookNotReady, book.Status) + } + if book.HasLiveRun && !book.LiveRunAwaitingBank { + // The book is being translated (admitted, queued or running). A signing stop is NOT this — + // but its row closes one sweep LATER than its screen opens: the journal's bank_stop event + // moves the status to `awaiting_bank` while `finished_at` waits for the exit marker + // (reconcile.finish), so for that window the row is live and the flock is already free or + // about to be. Refusing run_in_flight there refused the very screen the platform had just + // announced (workflow finding, P9) — the door opens on it instead, and if the engine is + // still winding down, its flock answers class 12: an honest transient «retry later». + return BankReceipt{}, pgstore.ErrRunInFlight + } + doc, err := ingest.EncodeDecisions(in.BookID, in.Decisions) + if err != nil { + return BankReceipt{}, err + } + if len(doc) > ingest.MaxDecisionsDocument { + // The engine's byte ceiling, measured HERE on the RENDERED document — the only form the + // engine will see — so its cap is never reached and the canon's 413 word is kept: the + // engine's own copy answers as a refusal of the set, which reads «re-decide» (workflow + // finding, P9). With HTML escaping off the band above the wire cap is the envelope's few + // bytes, and this gate is what keeps that band from answering with the wrong remedy. + return BankReceipt{}, ErrBankDocumentTooLarge + } + path, cleanup, err := s.decisionsFile(doc) + if err != nil { + return BankReceipt{}, err + } + defer cleanup() + out, err := s.Bank.BankApply(ctx, s.Cfg.EngineBinary, book.Workdir, path, in.Preview) + if err != nil { + s.log().ErrorContext(ctx, "the correction verb could not be run", "err", err) + if ctx.Err() != nil { + // The budget or the caller ended the call — and reaching THIS branch means the verb did + // NOT exit on its own (a SIGTERM the engine catches comes back as exit 5 through the + // verdict, with res.Exited true): the process was SIGKILLed after bankStopGrace, or + // never started. A kill can land between the verb's two renames — exactly the + // half-landed pair class 15 exists to report — but there is no report to carry that + // word, so the honest remainder is «retry later»: a re-send converges on the engine's + // byte no-op from either state (PD-407 keeps the imprecision). + return BankReceipt{}, ErrBankUnavailable + } + return BankReceipt{}, err + } + rec, err := s.bankVerdict(ctx, in, out) + var refused *ErrBankRefused + if errors.As(err, &refused) { + // The engine's cap refusals echo the document's PATH — this platform's own temp file. File + // paths are server topology and do not cross the wire (the same rule that keeps the report's + // `files` off it); the reason survives, the address does not. + for i := range refused.Refusals { + refused.Refusals[i].Reason = strings.ReplaceAll(refused.Refusals[i].Reason, path, "the decision document") + } + } + return rec, err +} + +// bankVerdict maps the verb's exit and report onto the door's answers. The mapping of each class is +// the contract's or argued in the zone journal (pack P9); what this function must never do is let +// two different remedies reach the caller as one word. +func (s *Service) bankVerdict(ctx context.Context, in BankCorrectionsInput, out runner.BankApplyOutcome) (BankReceipt, error) { + fail := func(what string) (BankReceipt, error) { + s.log().ErrorContext(ctx, "the correction verb answered outside its contract", + "what", what, "exit", out.ExitCode, "stderr", out.Stderr, "decode_err", out.DecodeErr) + return BankReceipt{}, fmt.Errorf("runs: bank-apply: %s (exit %d)", what, out.ExitCode) + } + switch out.ExitCode { + case ingest.ExitClean: + if !out.Decoded { + // Exit 0 always prints the report; a clean exit without one is not the engine. + return fail("a clean exit carried no report") + } + return s.bankReceipt(in, out.Report) + case ingest.ExitDecisionsRejected: + if !out.Decoded || len(out.Report.Rejected) == 0 { + // The DECISIONS class prints a report in every spelling, the caps included, and a refusal + // names its reasons — that is the class's own contract (pipeline.ApplyBankDecisions). A + // 409 whose `refusals` came out empty would violate the canon's own minItems. + return fail("a refusal carried no report or no reasons") + } + return BankReceipt{}, &ErrBankRefused{Refusals: out.Report.Rejected} + case ingest.ExitWriteIncomplete: + // The report says which file landed; that truth is server-side. What the caller needs is + // the remedy, and it is the same whether nothing landed or half did. + s.log().ErrorContext(ctx, "a correction write did not complete; the client re-sends the same document", + "stderr", out.Stderr) + return BankReceipt{}, ErrBankIncomplete + case ingest.ExitProjectLocked: + // NOT run_in_flight on THIS replica: a live run was excluded under the mutex before the verb + // was spawned (ApplyBankCorrections), and no path of this process admits one while it is held + // — so the holder is transient (a boundary materialization, an operator's manual tmctl, the + // stopped run's engine still winding down) and «busy, retry later» is honest. ⚠ Across the v1 + // boundary above the word CAN be wrong: a sibling replica's Start sees its own mutex and a + // clean runs table, and its translate — a real, hours-long run — wins this flock; the honest + // word there would be run_in_flight (PD-400, вторая половина: the accepted risk INCLUDES this + // false word, not just a wasted attempt). + s.log().ErrorContext(ctx, "corrections met a held project with no live run: a transient holder of the book's flock", + "stderr", out.Stderr) + return BankReceipt{}, ErrBankUnavailable + case ingest.ExitSchemaMismatch: + // The book's project predates the deployed engine: `tmctl migrate` is owed (deploy order, + // row 174). An operator's condition, not the user's — and temporary, which 503 says and a + // 500 would not. + s.log().ErrorContext(ctx, "corrections refused: the book's project schema is not the engine's; it awaits tmctl migrate", + "stderr", out.Stderr) + return BankReceipt{}, ErrBankUnavailable + case ingest.ExitStopped: + // The verb was wound down before it wrote anything (its own SIGTERM contract) — a deploy + // restart from the caller's seat. Retrying later converges either way. + s.log().ErrorContext(ctx, "the correction verb was stopped mid-call", "stderr", out.Stderr) + return BankReceipt{}, ErrBankUnavailable + case ingest.ExitConfigInvalid, ingest.ExitSourceUnreadable: + // Named BEFORE the band catch below: these are known classes whose fault sits on THIS side + // of the seam — the platform renders both of the verb's inputs — so they are an internal + // failure, not a "retry later" that would never come true. + return fail("the verb refused its caller's own wiring") + } + if ingest.Refused(out.ExitCode) { + // A refusal class this build has no mapping for (19 included): nothing was spent or written, + // but WHICH remedy applies is exactly what an unknown number cannot say — so the answer is + // the operator's pair of hands, not a guessed one of the other two. + s.log().ErrorContext(ctx, "corrections met a refusal class this build cannot map", + "exit", out.ExitCode, "stderr", out.Stderr) + return BankReceipt{}, ErrBankUnavailable + } + // Exit 1, the config class (10 — this platform renders both of the verb's inputs, so a broken + // one is OUR wiring), 11, and anything else: the deployment is what needs fixing. + return fail("an exit outside the door's contract") +} + +// bankReceipt shapes the report of an accepted call. The report's own preview echo is `mode`; it is +// cross-checked against what was asked rather than trusted, because the receipt of an APPLY served +// for a preview would tell a user their correction landed when nothing did. +func (s *Service) bankReceipt(in BankCorrectionsInput, rep ingest.BankReport) (BankReceipt, error) { + wantMode := "apply" + if in.Preview { + wantMode = "projection" + } + if rep.Mode != wantMode { + return BankReceipt{}, fmt.Errorf("runs: bank-apply answered mode %q to a %q call", rep.Mode, wantMode) + } + out := BankReceipt{ + Preview: in.Preview, Changed: rep.Changed, Depth: rep.Depth, + Accepted: rep.Accepted, + PreexistingFaults: len(rep.PreexistingProblems), + } + if rep.Signature.Map != "" { + sig := rep.Signature + out.Signature = &sig + } + return out, nil +} + +// SweepCorrectionScratch removes decision documents an unclean death left behind: cleanup rides a +// defer and dies with the process (SIGKILL, OOM, a deploy's expired grace), nothing else deletes +// by this mask, and each orphan carries up to a megabyte of a user's own corrections sitting in +// the state directory indefinitely (workflow finding, P9). Called once at boot, before the door +// serves — a file present THEN belongs to no live call by definition. +func (s *Service) SweepCorrectionScratch() { + stale, err := filepath.Glob(filepath.Join(s.Cfg.StateDir, "bank-corrections-*.json")) + if err != nil || len(stale) == 0 { + return + } + for _, f := range stale { + _ = os.Remove(f) + } + s.log().Info("swept correction documents an earlier process left behind", "count", len(stale)) +} + +// decisionsFile puts the rendered document where the engine can read it — the platform's OWN state +// directory, never the book's (D39.110: the engine owns that one). +func (s *Service) decisionsFile(doc []byte) (string, func(), error) { + if err := os.MkdirAll(s.Cfg.StateDir, 0o750); err != nil { + return "", nil, fmt.Errorf("runs: state directory: %w", err) + } + f, err := os.CreateTemp(s.Cfg.StateDir, "bank-corrections-*.json") + if err != nil { + return "", nil, fmt.Errorf("runs: write the decision document: %w", err) + } + cleanup := func() { _ = os.Remove(f.Name()) } + if _, err := f.Write(doc); err != nil { + _ = f.Close() + cleanup() + return "", nil, fmt.Errorf("runs: write the decision document: %w", err) + } + if err := f.Close(); err != nil { + cleanup() + return "", nil, fmt.Errorf("runs: write the decision document: %w", err) + } + return f.Name(), cleanup, nil +} + +// lockBook serializes this book's admissions, resumes and corrections against each other, and +// watches the caller's context while it WAITS: the critical sections are one engine verb or one +// database transaction, but the WAIT for them is only bounded when a caller whose deadline or +// client is gone can leave the queue — a sync.Mutex cannot be waited on under a context, and with +// one the K-th caller held its place for K budgets and then did the work for a dead request +// (workflow finding, P9). A one-slot channel is that same mutex, waitable. +func (s *Service) lockBook(ctx context.Context, bookID string) (unlock func(), err error) { + s.booksMu.Lock() + if s.books == nil { + s.books = make(map[string]*bookLock) + } + l := s.books[bookID] + if l == nil { + l = &bookLock{slot: make(chan struct{}, 1)} + s.books[bookID] = l + } + l.refs++ + s.booksMu.Unlock() + release := func() { + s.booksMu.Lock() + if l.refs--; l.refs == 0 { + delete(s.books, bookID) // books are unbounded over a process's life; idle locks are not kept + } + s.booksMu.Unlock() + } + select { + case l.slot <- struct{}{}: + return func() { <-l.slot; release() }, nil + case <-ctx.Done(): + release() + return nil, ctx.Err() + } +} + +type bookLock struct { + slot chan struct{} // capacity 1: the token in it IS the lock + refs int +} diff --git a/platform/internal/runs/bank_test.go b/platform/internal/runs/bank_test.go new file mode 100644 index 00000000..a5cdc9e3 --- /dev/null +++ b/platform/internal/runs/bank_test.go @@ -0,0 +1,389 @@ +package runs + +import ( + "context" + "errors" + "os" + "path/filepath" + "strings" + "sync" + "testing" + "time" + + "textmachine/platform/internal/ingest" + "textmachine/platform/internal/pgstore" + "textmachine/platform/internal/runner" +) + +// The correction door's service half (bank.go): the verdict over the engine's exit band, and the +// per-book serialization against the run lifecycle. + +type fakeBankApplier struct { + mu sync.Mutex + out runner.BankApplyOutcome + err error + calls int + lastDoc []byte + // entered/release, when set, gate the call so a test can hold the verb mid-flight. + entered chan struct{} + release chan struct{} +} + +func (f *fakeBankApplier) BankApply(_ context.Context, _, _, decisionsPath string, _ bool) (runner.BankApplyOutcome, error) { + f.mu.Lock() + f.calls++ + f.lastDoc, _ = os.ReadFile(decisionsPath) + entered, release := f.entered, f.release + f.mu.Unlock() + if entered != nil { + close(entered) + <-release + } + return f.out, f.err +} + +func okReport(mode string) ingest.BankReport { + return ingest.BankReport{ + Version: ingest.DecisionsReportVersion, BookID: "bk", Mode: mode, + Depth: ingest.DepthEditWave, Changed: true, + Accepted: []ingest.AcceptedDecision{{Index: 0, Action: "decline", ID: "tm_1", Src: "蛊", State: "applied"}}, + } +} + +// The verdict table: every exit class of the verb lands on ITS remedy and no two remedies merge. +func TestBankVerdictKeepsTheRemediesApart(t *testing.T) { + svc := service(t, &fakeRunner{}, nil, time.Now()) + in := BankCorrectionsInput{BookID: "bk", Preview: false} + refused := okReport("refused") + refused.Rejected = []ingest.RejectedDecision{{Index: 0, Reason: "inert"}} + cases := []struct { + name string + out runner.BankApplyOutcome + want error + }{ + {"refused = the user re-decides", runner.BankApplyOutcome{ExitCode: ingest.ExitDecisionsRejected, Exited: true, Report: refused, Decoded: true}, &ErrBankRefused{}}, + {"incomplete = re-send the same", runner.BankApplyOutcome{ExitCode: ingest.ExitWriteIncomplete, Exited: true, Report: okReport("write_incomplete"), Decoded: true}, ErrBankIncomplete}, + // Class 12 from the VERB is never a run: the live run was excluded under the mutex before the + // spawn, so the holder is transient and the word is «retry later» — `run_in_flight` comes only + // from the run row's own check (PD-400). + {"a held project = a transient holder, retry later", runner.BankApplyOutcome{ExitCode: ingest.ExitProjectLocked, Exited: true}, ErrBankUnavailable}, + {"an unmigrated schema = the operator", runner.BankApplyOutcome{ExitCode: ingest.ExitSchemaMismatch, Exited: true}, ErrBankUnavailable}, + {"a stop mid-call = retry later", runner.BankApplyOutcome{ExitCode: ingest.ExitStopped, Exited: true}, ErrBankUnavailable}, + {"an unknown refusal class = the operator", runner.BankApplyOutcome{ExitCode: ingest.ExitRefusedOther, Exited: true}, ErrBankUnavailable}, + } + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + _, err := svc.bankVerdict(t.Context(), in, tc.out) + var refusal *ErrBankRefused + if wantRefusal := errors.As(tc.want, new(*ErrBankRefused)); wantRefusal { + if !errors.As(err, &refusal) || len(refusal.Refusals) != 1 { + t.Fatalf("got %v, want the refusal with its reasons", err) + } + return + } + if !errors.Is(err, tc.want) { + t.Errorf("got %v, want %v", err, tc.want) + } + }) + } + // The config class and exit 1 are the deployment's own fault: an internal error, none of the + // three user-facing remedies. + for _, code := range []int{ingest.ExitFailure, ingest.ExitConfigInvalid, ingest.ExitSourceUnreadable} { + _, err := svc.bankVerdict(t.Context(), in, runner.BankApplyOutcome{ExitCode: code, Exited: true}) + if err == nil || errors.Is(err, ErrBankUnavailable) || errors.Is(err, ErrBankIncomplete) || + errors.Is(err, pgstore.ErrRunInFlight) { + t.Errorf("exit %d answered %v, want a plain internal failure", code, err) + } + } + // A clean exit whose report did not arrive is not the engine — never a receipt. + if _, err := svc.bankVerdict(t.Context(), in, runner.BankApplyOutcome{ExitCode: 0, Exited: true}); err == nil { + t.Error("a clean exit without a report was believed") + } + // The report's mode is cross-checked against the asked act: an APPLY report for a preview call + // would tell the user their correction landed when nothing may have. + preview := in + preview.Preview = true + if _, err := svc.bankVerdict(t.Context(), preview, + runner.BankApplyOutcome{ExitCode: 0, Exited: true, Report: okReport("apply"), Decoded: true}); err == nil { + t.Error("an apply-mode report was served as a preview receipt") + } +} + +// The receipt's shaping: the signature block is null exactly when no stop exists to count against. +func TestBankReceiptCarriesTheSignatureOnlyWhenAStopExists(t *testing.T) { + svc := service(t, &fakeRunner{}, nil, time.Now()) + in := BankCorrectionsInput{BookID: "bk", Preview: false} + rep := okReport("apply") + rep.PreexistingProblems = []string{"a delta that would not load"} + rec, err := svc.bankReceipt(in, rep) + if err != nil { + t.Fatal(err) + } + if rec.Signature != nil { + t.Errorf("no stop yet, and the receipt counts against %+v", rec.Signature) + } + if rec.PreexistingFaults != 1 || rec.Depth != ingest.DepthEditWave || !rec.Changed { + t.Errorf("receipt: %+v", rec) + } + rep.Signature = ingest.SignatureState{Map: "/x/y.mined-signature.yaml", Surfaces: 3, Undecided: 1} + if rec, err = svc.bankReceipt(in, rep); err != nil || rec.Signature == nil || rec.Signature.Surfaces != 3 { + t.Errorf("with a stop: %+v (%v)", rec, err) + } +} + +// Under a live run the door refuses at once with the run's own word — the verb is never spawned, so +// a transient flock can never masquerade as a translation (canon: wait for the stop or the end). +func TestCorrectionsRefuseWhileTheBookIsBeingTranslated(t *testing.T) { + f := newFixture(t, "10", 500) + fake := &fakeBankApplier{out: runner.BankApplyOutcome{ExitCode: 0, Exited: true}} + f.svc.Bank = fake + if _, err := f.svc.Start(f.ctx, StartRequest{UserID: "u1", BookID: f.bookID(t), CeilingChapters: 10}); err != nil { + t.Fatal(err) + } + _, err := f.svc.ApplyBankCorrections(f.ctx, BankCorrectionsInput{ + UserID: "u1", BookID: f.bookID(t), Preview: true, + Decisions: []ingest.BankDecision{{Action: "decline", ID: "tm_1"}}, + }) + if !errors.Is(err, pgstore.ErrRunInFlight) { + t.Fatalf("corrections under a live run answered %v, want run_in_flight", err) + } + if fake.calls != 0 { + t.Error("the verb was spawned under a live run") + } +} + +// The signing stop's row closes one sweep AFTER its screen opens: the journal's bank_stop event +// moves the status to awaiting_bank while finished_at waits for the exit marker. The door must +// open on that window — refusing run_in_flight there refuses the very screen the platform just +// announced (workflow finding, P9). The case above pins the other side: a live row NOT at the +// stop still refuses. +func TestTheDoorOpensOnTheSigningStopWindow(t *testing.T) { + f := newFixture(t, "10", 500) + run, err := f.svc.Start(f.ctx, StartRequest{UserID: "u1", BookID: f.bookID(t), VerifyBank: true, + CeilingChapters: 10}) + if err != nil { + t.Fatal(err) + } + if err := f.svc.Spawn(f.ctx, run.ID); err != nil { + t.Fatal(err) + } + fake := &fakeBankApplier{ + out: runner.BankApplyOutcome{ExitCode: 0, Exited: true, Report: okReport("apply"), Decoded: true}, + } + fake.out.Report.BookID = f.bookID(t) + f.svc.Bank = fake + // What the sink writes when the engine reports the bank stop: the status moves and the run + // stays LIVE, because the attempt has not ended yet (pgstore/sink.go, TypeBankStop). + if _, err := f.store.Pool().Exec(f.ctx, + `update runs set status = 'awaiting_bank' where id = $1`, run.ID); err != nil { + t.Fatal(err) + } + rec, err := f.svc.ApplyBankCorrections(f.ctx, BankCorrectionsInput{ + UserID: "u1", BookID: f.bookID(t), Preview: false, + Decisions: []ingest.BankDecision{{Action: "decline", ID: "tm_1"}}, + }) + if err != nil { + t.Fatalf("the door refused the signing stop's own window: %v", err) + } + if fake.calls != 1 { + t.Fatalf("the verb ran %d times, want 1", fake.calls) + } + if rec.Preview { + t.Error("an apply came back as a preview") + } +} + +// The engine's byte ceiling is measured on the RENDERED document BEFORE the verb is spawned, and +// the refusal is the size word (413 upstream), never the engine's «re-decide» class (workflow +// finding, P9: with HTML escaping off the band above the wire cap is the envelope's few bytes, +// and this gate is what answers it). +func TestARenderedDocumentOverTheEngineCapIsRefusedBeforeTheSpawn(t *testing.T) { + f := newFixture(t, "10", 500) + fake := &fakeBankApplier{} + f.svc.Bank = fake + _, err := f.svc.ApplyBankCorrections(f.ctx, BankCorrectionsInput{ + UserID: "u1", BookID: f.bookID(t), Preview: true, + Decisions: []ingest.BankDecision{{Action: "decline", ID: "tm_1", + Note: strings.Repeat("g", ingest.MaxDecisionsDocument)}}, + }) + if !errors.Is(err, ErrBankDocumentTooLarge) { + t.Fatalf("an over-cap render answered %v, want ErrBankDocumentTooLarge", err) + } + if fake.calls != 0 { + t.Error("the verb was spawned for a document it cannot read") + } +} + +// The happy path against a real store: the rendered document reaches the verb — versioned, with the +// book's own id — and the receipt answers the report. A stranger's book stays a 404. +func TestCorrectionsRenderTheSeamDocumentAndAnswerTheReceipt(t *testing.T) { + f := newFixture(t, "10", 500) + rep := okReport("projection") + rep.BookID = f.bookID(t) + fake := &fakeBankApplier{out: runner.BankApplyOutcome{ExitCode: 0, Exited: true, Report: rep, Decoded: true}} + f.svc.Bank = fake + rec, err := f.svc.ApplyBankCorrections(f.ctx, BankCorrectionsInput{ + UserID: "u1", BookID: f.bookID(t), Preview: true, + Decisions: []ingest.BankDecision{{Action: "decline", Src: "蛊"}}, + }) + if err != nil { + t.Fatal(err) + } + if !rec.Preview || len(rec.Accepted) != 1 { + t.Errorf("receipt: %+v", rec) + } + doc := string(fake.lastDoc) + if !strings.Contains(doc, `"decisions_version":"tm-bank-decisions-v1"`) || + !strings.Contains(doc, `"book_id":"`+f.bookID(t)+`"`) { + t.Errorf("the verb was handed %s", doc) + } + if _, err := f.svc.ApplyBankCorrections(f.ctx, BankCorrectionsInput{ + UserID: "u2", BookID: f.bookID(t), Preview: true, + Decisions: []ingest.BankDecision{{Action: "decline", Src: "蛊"}}, + }); !errors.Is(err, pgstore.ErrNoBook) { + t.Errorf("a stranger's book answered %v, want ErrNoBook", err) + } +} + +// The serialization the synchronous form stands on: a resume does not re-open the run while the +// verb is mid-flight on the same book — the spawned engine would die on the verb's flock, a wasted +// attempt (§3.2 of the pack; the mirror half is the run_in_flight refusal above). +func TestAResumeWaitsOutALiveCorrectionCall(t *testing.T) { + f := newFixture(t, "10", 500) + runID := f.stoppedRun(t, StartRequest{UserID: "u1", BookID: f.bookID(t), VerifyBank: true, + CeilingChapters: 100}, 0, runner.Marker{Result: "exit-code", Code: "exited", Status: "3", + At: f.now.Add(time.Second)}) + fake := &fakeBankApplier{ + out: runner.BankApplyOutcome{ExitCode: 0, Exited: true, Report: okReport("apply"), Decoded: true}, + entered: make(chan struct{}), release: make(chan struct{}), + } + fake.out.Report.BookID = f.bookID(t) + f.svc.Bank = fake + corrections := make(chan error, 1) + go func() { + _, err := f.svc.ApplyBankCorrections(f.ctx, BankCorrectionsInput{ + UserID: "u1", BookID: f.bookID(t), Preview: false, + Decisions: []ingest.BankDecision{{Action: "decline", ID: "tm_1"}}, + }) + corrections <- err + }() + <-fake.entered // the verb is mid-flight and the book's lock is held + resumed := make(chan error, 1) + go func() { + _, err := f.svc.Resume(f.ctx, "u1", runID) + resumed <- err + }() + select { + case err := <-resumed: + t.Fatalf("the resume completed (%v) while the correction verb held the book", err) + case <-time.After(150 * time.Millisecond): + } + close(fake.release) + if err := <-corrections; err != nil { + t.Fatalf("corrections: %v", err) + } + if err := <-resumed; err != nil { + t.Fatalf("resume after the verb: %v", err) + } +} + +// An idle book keeps no lock entry: the per-book map must not grow with every book a process ever +// touched — and a waiter that LEAVES on its context must not leak its entry either. +func TestTheBookLockTableDoesNotLeak(t *testing.T) { + svc := service(t, &fakeRunner{}, nil, time.Now()) + unlock, err := svc.lockBook(t.Context(), "bk_1") + if err != nil { + t.Fatal(err) + } + second := make(chan struct{}) + go func() { + u, err := svc.lockBook(t.Context(), "bk_1") + if err == nil { + u() + } + close(second) + }() + unlock() + <-second + svc.booksMu.Lock() + defer svc.booksMu.Unlock() + if len(svc.books) != 0 { + t.Errorf("%d lock entries survive their books", len(svc.books)) + } +} + +// A caller whose context ends while it WAITS leaves the queue with an error instead of holding a +// place in it to do the work for a dead request — the wait, not just the hold, is bounded +// (workflow finding, P9). +func TestAWaiterWhoseContextEndsLeavesTheBookQueue(t *testing.T) { + svc := service(t, &fakeRunner{}, nil, time.Now()) + unlock, err := svc.lockBook(t.Context(), "bk_1") + if err != nil { + t.Fatal(err) + } + ctx, cancel := context.WithCancel(t.Context()) + waited := make(chan error, 1) + go func() { + u, err := svc.lockBook(ctx, "bk_1") + if err == nil { + u() + } + waited <- err + }() + cancel() + select { + case err := <-waited: + if err == nil { + t.Fatal("a cancelled waiter took the lock anyway") + } + case <-time.After(2 * time.Second): + t.Fatal("a cancelled waiter is still in the queue") + } + unlock() + svc.booksMu.Lock() + defer svc.booksMu.Unlock() + if len(svc.books) != 0 { + t.Errorf("%d lock entries survive the cancelled waiter", len(svc.books)) + } +} + +// Only the book's LATEST run may be resumed: an older one re-opened over a newer would open its +// bar on the neighbour's finished chapters, and the whole read surface (lastRun by started_at) +// would keep quoting the finished neighbour while the resumed run burns money invisibly (workflow +// finding, P9; PD-402 keeps the read half). +func TestAnOlderRunCannotBeResumedOverANewerOne(t *testing.T) { + f := newFixture(t, "10", 500) + older := f.stoppedRun(t, StartRequest{UserID: "u1", BookID: f.bookID(t), CeilingChapters: 3}, 0, + runner.Marker{Result: "exit-code", Code: "exited", Status: "3", At: f.now.Add(time.Second)}) + later := f.now.Add(time.Minute) + f.svc.Now = func() time.Time { return later } + newer := f.stoppedRun(t, StartRequest{UserID: "u1", BookID: f.bookID(t), CeilingChapters: 3}, 0, + runner.Marker{Result: "exit-code", Code: "exited", Status: "3", At: later.Add(time.Second)}) + if _, err := f.svc.Resume(f.ctx, "u1", older); !errors.Is(err, ErrNotResumable) { + t.Fatalf("resuming an older run answered %v, want ErrNotResumable", err) + } + if _, err := f.svc.Resume(f.ctx, "u1", newer); err != nil { + t.Fatalf("the latest run must stay resumable: %v", err) + } +} + +// Boot sweeps decision documents an unclean death left in the state directory — nothing else +// deletes by that mask, and each carries up to a megabyte of a user's own corrections (workflow +// finding, P9; PD-409). Files outside the mask are not the sweep's to touch. +func TestBootSweepsOrphanedCorrectionDocuments(t *testing.T) { + svc := service(t, &fakeRunner{}, nil, time.Now()) + orphan := filepath.Join(svc.Cfg.StateDir, "bank-corrections-123.json") + stranger := filepath.Join(svc.Cfg.StateDir, "run-1.exit") + for _, p := range []string{orphan, stranger} { + if err := os.WriteFile(p, []byte("x"), 0o600); err != nil { + t.Fatal(err) + } + } + svc.SweepCorrectionScratch() + if _, err := os.Stat(orphan); !errors.Is(err, os.ErrNotExist) { + t.Error("the orphaned decision document survived the boot sweep") + } + if _, err := os.Stat(stranger); err != nil { + t.Error("the sweep touched a file outside its mask") + } +} diff --git a/platform/internal/runs/reconcile.go b/platform/internal/runs/reconcile.go index 529baf55..66aa84e0 100644 --- a/platform/internal/runs/reconcile.go +++ b/platform/internal/runs/reconcile.go @@ -1208,6 +1208,34 @@ func (s *Service) Resume(ctx context.Context, userID, runID string) (pgstore.Run if err := s.runnable(); err != nil { return pgstore.Run{}, err } + // The book's own serialization (bank.go): a resume must not re-open the run while the correction + // door is mid-verb on this book — the spawned engine would die on the verb's flock, a wasted + // attempt. Held through the re-open so the door's own live-run check reads a settled fact; the + // caller's context bounds the wait. + unlock, err := s.lockBook(ctx, l.BookID) + if err != nil { + return pgstore.Run{}, err + } + defer unlock() + // Only the book's LATEST run may be resumed. An older stopped run re-opened over a newer one + // counts the newer run's finished chapters into its own bar (its baselines predate them, and + // the clamp bounds the fraction at one, not the double count under it) — and because every + // book-scoped read resolves the run by `started_at desc`, the card and every progress frame + // would keep quoting the finished neighbour while this run burned money invisibly (workflow + // finding, P9; PD-402 keeps the read-side half). Checked under the book lock, so a Start + // admitted while this call waited cannot slip a newer row past it. The two shapes carry their + // own words: a LIVE newer run is «the book is being translated» — the same answer every door + // gives a working book — and only a finished one says «resume the latest». + latest, live, err := s.Store.LatestRun(ctx, l.BookID) + if err != nil { + return pgstore.Run{}, err + } + if latest != l.RunID { + if live { + return pgstore.Run{}, pgstore.ErrRunInFlight + } + return pgstore.Run{}, fmt.Errorf("%w: a newer run of this book exists, and the book's screens follow that one", ErrNotResumable) + } switch l.Status { case "stopped": case "awaiting_bank": diff --git a/platform/internal/runs/reconcile_test.go b/platform/internal/runs/reconcile_test.go index a8bd18a7..76c8a63e 100644 --- a/platform/internal/runs/reconcile_test.go +++ b/platform/internal/runs/reconcile_test.go @@ -304,6 +304,30 @@ func TestAStopTheUserAskedForOutranksACeilingThatArrivedWithIt(t *testing.T) { } } +// The configured key file reaches the UNIT's argv (row 211, the platform half of the live-run +// blocker): the spec is what systemd executes, so this is the level at which "the engine gets its +// keys" is either true or not. +// +// Mutation caught: dropping Cfg.KeysFile on the way into TranslateArgs. +func TestTheSpawnedUnitCarriesTheDeploymentKeyFile(t *testing.T) { + svc := service(t, &fakeRunner{}, nil, time.Now()) + svc.Cfg.KeysFile = "/etc/tm/keys.env" + l := pgstore.LiveRun{RunID: "r1", AttemptNo: 1, Workdir: "/srv/books/bk1", Ceiling: 1} + spec, err := svc.spec(l, money.MicroUSD(1_000_000)) + if err != nil { + t.Fatal(err) + } + i := slices.Index(spec.Args, "--keys-file") + if i < 0 || i+1 >= len(spec.Args) || spec.Args[i+1] != "/etc/tm/keys.env" { + t.Errorf("the unit's argv carries no key file: %v", spec.Args) + } + for _, kv := range spec.Env { + if strings.Contains(kv, "keys") || strings.Contains(kv, "KEY") { + t.Errorf("a key-related variable reached the unit's environment: %q", kv) + } + } +} + func service(t *testing.T, rn UnitRunner, eng EngineStatus, now time.Time) *Service { t.Helper() tpl, err := runner.ParseCeilingTemplate("--ceiling-usd {{usd}}") @@ -681,7 +705,7 @@ func (f *fixture) owed(t *testing.T) []pgstore.OwedBook { // // Mutation caught: passing l.VerifyBank unconditionally. func TestAResumedRunIsSpawnedWithoutTheSigningStop(t *testing.T) { - before := runner.TranslateArgs("/srv/books/bk1", true, nil) + before := runner.TranslateArgs("/srv/books/bk1", true, "", nil) if !slices.Contains(before, "--verify-bank") { t.Fatal("a run that asked for the signing stop is spawned without it") } diff --git a/platform/internal/runs/runs.go b/platform/internal/runs/runs.go index 2cafad0b..fbb1e45f 100644 --- a/platform/internal/runs/runs.go +++ b/platform/internal/runs/runs.go @@ -13,6 +13,7 @@ import ( "errors" "fmt" "log/slog" + "sync" "time" "textmachine/platform/internal/ingest" @@ -35,6 +36,13 @@ type EngineStatus interface { Status(ctx context.Context, binary, workdir string) (ingest.StatusReport, error) } +// BankApplier is the correction channel: `tmctl bank-apply` against a book (D39.156 pack 2в). An +// interface for the same reason EngineStatus is one: the door's decisions are pinned without an +// engine binary, and a test that wants the real verb is visibly a different test. +type BankApplier interface { + BankApply(ctx context.Context, binary, workdir, decisionsPath string, preview bool) (runner.BankApplyOutcome, error) +} + // Config is everything the runner needs that an operator chooses. type Config struct { // StateDir is where exit markers live. Platform state, deliberately NOT the book's directory: @@ -51,6 +59,10 @@ type Config struct { // unset variable takes the default — and then starting a run is refused rather than started under // the book's own limit. Ceiling runner.CeilingTemplate + // KeysFile is the deployment's provider-key file, passed to `translate` as `--keys-file` + // (row 211). The path is an engine argument: keys are never put into the unit's environment and + // never pass through this process. Empty passes no flag. + KeysFile string // MemoryMax and TasksMax bound ONE run's cgroup — the answer to PD-13. MemoryMax string TasksMax int @@ -75,6 +87,7 @@ type Service struct { Store *pgstore.Store Runner UnitRunner Engine EngineStatus + Bank BankApplier Pricing pricing.Model Queue Enqueuer Cfg Config @@ -85,6 +98,11 @@ type Service struct { // resynced remembers when each run was last reconciled from status. In memory on purpose: it is // a rate limit, not a fact — losing it on restart costs one extra status call. resynced map[string]time.Time + // books serializes one book's admissions, resumes and corrections against each other — the + // per-book rule of the correction door (see bank.go, lockBook). In memory on purpose: the + // sections it guards live inside one process's calls. + booksMu sync.Mutex + books map[string]*bookLock } // Enqueuer hands a run to the queue. @@ -221,6 +239,14 @@ func (s *Service) Start(ctx context.Context, in StartRequest) (pgstore.Run, erro if err := s.runnable(); err != nil { return pgstore.Run{}, err } + // The book's own serialization (bank.go): a run admitted while the correction door is mid-verb + // on this book would spawn a `translate` straight into the verb's flock — a wasted attempt. The + // door's own budget bounds each queued call, and the caller's context bounds THIS wait. + unlock, err := s.lockBook(ctx, in.BookID) + if err != nil { + return pgstore.Run{}, err + } + defer unlock() book, err := s.Store.ReadBookForRun(ctx, in.UserID, in.BookID) if err != nil { return pgstore.Run{}, err diff --git a/platform/internal/runs/spawn.go b/platform/internal/runs/spawn.go index cc6d43a3..a258120d 100644 --- a/platform/internal/runs/spawn.go +++ b/platform/internal/runs/spawn.go @@ -156,14 +156,18 @@ func (s *Service) spec(l pgstore.LiveRun, bookCap money.MicroUSD) (runner.Spec, // The path this ATTEMPT is pinned to, which for a resume is the one the run started with // (row 139). Falling back to the configured path is for the first attempt, which has none yet. Binary: s.engineBinary(l), - // ⚠ `--verify-bank` is NOT passed again once the stop has been lifted. That flag is what makes - // the engine halt at the bank boundary, and it halts there whenever undecided terms remain — - // its own words, "the stop clears once every proposed term is promoted or rejected". Resuming - // with it still set re-spawned the run straight back into the same stop, so a signature moved - // nothing. Dropping it takes the engine's auto path (pipeline/mining.go, D39.42 п.3), which is - // the owner's model exactly: the run continues and the undecided rows ride into the bank marked - // ⟨проверить⟩ (unified backlog row 191). - Args: runner.TranslateArgs(l.Workdir, l.VerifyBank && !l.BankReleased, ceiling), + // ⚠ `--verify-bank` is NOT passed again once the stop has been lifted. The reason is the + // owner's model, not the engine's old re-halt behaviour: the stop exists to present what is + // NEW, and after a lift this run has nothing new to present — the presentation memory (v16) + // covers the map's clusters, so even a re-passed flag would auto-continue at the same + // boundary. (An earlier edition of this comment quoted the D39.144-era contract — «the stop + // clears once every proposed term is promoted or rejected» — which the memory made false, + // and the reconciler's LiftBankStop guard built on that reading is what the P9 workflow + // review found leaking; the basis is corrected here rather than inherited again.) Dropping + // the flag takes the + // engine's auto path (pipeline/mining.go, D39.42 п.3): the run continues and undecided + // rows ride into the bank marked ⟨проверить⟩ (unified backlog row 191). + Args: runner.TranslateArgs(l.Workdir, l.VerifyBank && !l.BankReleased, s.Cfg.KeysFile, ceiling), Workdir: l.Workdir, Env: engineEnv(engineStreamID(l.RunID, l.AttemptNo)), ExitMarker: marker, @@ -262,9 +266,10 @@ func engineStreamID(runID string, attempt int) string { return pgstore.EngineStreamID(runID, attempt) } -// engineEnv is the environment of the run's unit. Provider keys are deliberately NOT here — the -// engine reads them from the .env beside its own book.yaml, so they never pass through this process -// — and the only thing this platform puts in it is the run's identity. +// engineEnv is the environment of the run's unit. Provider keys are deliberately NOT here — they +// reach the engine as the `--keys-file` ARGUMENT of `translate` (row 211, spec above), read by the +// engine itself, so they never pass through this process or the unit's environment — and the only +// thing this platform puts in it is the run's identity. func engineEnv(streamID string) []string { return []string{"TM_TRACE_ID=" + streamID} } func (s *Service) markerPath(runID string, attempt int) string {