From 7d45e24cb6bd5f67e62d9c0f2600245ebf364aed Mon Sep 17 00:00:00 2001 From: heaven Date: Fri, 28 Aug 2026 20:52:07 +0300 Subject: [PATCH] Land the pack that lets a bank correction reach translated text: the run no longer dies on the snapshot guard and a re-pass can be bought --- docs/PROGRESS.md | 13 +- docs/README.md | 2 +- docs/architecture/05-decisions-index.md | 3 +- docs/architecture/05-decisions-log.md | 58 +++- docs/architecture/14-api-contract/README.md | 42 ++- .../architecture/14-api-contract/openapi.yaml | 49 ++- docs/architecture/17-seam-inbound-law.md | 4 +- ...PLATFORM_P10_SESSION_PROMPT_2026-08-28.md} | 0 docs/research/25-seam-cold-review.md | 2 +- platform/docs/DEFECT_REGISTER.md | 14 +- platform/docs/platform-PROGRESS.md | 300 +++++++++++++++++- platform/internal/httpapi/capabilities.go | 2 +- platform/internal/httpapi/problem.go | 4 + platform/internal/httpapi/v0.go | 40 ++- platform/internal/httpapi/v0_test.go | 27 ++ platform/internal/ingest/resync.go | 19 +- platform/internal/pgstore/books.go | 46 ++- platform/internal/pgstore/migrations.sha256 | 1 + .../pgstore/migrations/00027_bank_move.sql | 27 ++ platform/internal/pgstore/readmodel.go | 25 +- platform/internal/pgstore/readmodel_test.go | 38 +++ platform/internal/pgstore/runs.go | 52 ++- platform/internal/runner/engine.go | 18 +- platform/internal/runner/runner_test.go | 28 +- .../runner/translate_resnapshot_live_test.go | 175 ++++++++++ platform/internal/runs/bank.go | 32 ++ platform/internal/runs/bank_test.go | 247 ++++++++++++++ platform/internal/runs/reconcile.go | 44 ++- platform/internal/runs/reconcile_test.go | 2 +- platform/internal/runs/runs.go | 56 ++++ platform/internal/runs/spawn.go | 3 +- 31 files changed, 1303 insertions(+), 70 deletions(-) rename docs/{PLATFORM_P10_SESSION_PROMPT.md => archive/prompts/PLATFORM_P10_SESSION_PROMPT_2026-08-28.md} (100%) create mode 100644 platform/internal/pgstore/migrations/00027_bank_move.sql create mode 100644 platform/internal/runner/translate_resnapshot_live_test.go diff --git a/docs/PROGRESS.md b/docs/PROGRESS.md index 4ae6bba3..3cd5a74c 100644 --- a/docs/PROGRESS.md +++ b/docs/PROGRESS.md @@ -1,6 +1,6 @@ # Журнал прогресса -> **⟶ ТЕКУЩЕЕ СОСТОЯНИЕ** (на 2026-08-28, голова D39.165 — КУРС: движок и платформа до «работает и отдаёт результат», фронт ЗАМОРОЖЕН и 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) — без неё труба доставки правок банка не строится, а верхнего края у неё теперь нет вовсе. ⚠ **ДВА ВОПРОСА, СНЯТЫЕ ВЛАДЕЛЬЦЕМ 28.08 (D39.165) — оставлены здесь как след, потому что реестр ожиданий о них не знал вовсе и это была дыра учёта.** Ответы: смена формы конвейера — форма «ЭПОХА» (событие книги, канон уже разрешает пересчёт через границу `structure_version`); единица продажи — цена от ОБЪЁМА исходника по `chapters.units_total` + потолок объёма в движке + хвост качества в тариф, калибровка гейчена строкой 202. Прежняя формулировка: (дыру учёта нашла разведка 28.08: нота сказала «ждёт слова владельца», а реестр ожиданий об этом не знал): **(i) смена формы конвейера на ЖИВОЙ книге** — что деплой обязан делать с книгой, начерченной по старой форме, когда форму сменили (носители `PD-403`/`PD-404`; ⚠ проект отвечал на СОСЕДНИЙ вопрос дважды — D39.152 п.4 и D39.153 §4б — и два ратифицированных ответа В КОМПОЗИЦИИ и дают дефект: липкость флага ратифицирована ПРОТИВ отката назад, но в обратную сторону она морозит счёт, и комментарий `platform/internal/pgstore/sink.go:405-408` описывает поведение, которого его же код НЕ делает); **(ii) `PD-410`** — платформа продаёт ГЛАВЫ, движок останавливается по ДЕНЬГАМ, и единица продажи не равна единице остановки · (г) **контракт: сквозная полоса прогресса ЗАКРЫТА** — минор **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.) +> **⟶ ТЕКУЩЕЕ СОСТОЯНИЕ** (на 2026-08-28, голова D39.166 — КУРС: движок и платформа до «работает и отдаёт результат», фронт ЗАМОРОЖЕН и 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) — без неё труба доставки правок банка не строится, а верхнего края у неё теперь нет вовсе. ⚠ **ДВА ВОПРОСА, СНЯТЫЕ ВЛАДЕЛЬЦЕМ 28.08 (D39.165) — оставлены здесь как след, потому что реестр ожиданий о них не знал вовсе и это была дыра учёта.** Ответы: смена формы конвейера — форма «ЭПОХА» (событие книги, канон уже разрешает пересчёт через границу `structure_version`); единица продажи — цена от ОБЪЁМА исходника по `chapters.units_total` + потолок объёма в движке + хвост качества в тариф, калибровка гейчена строкой 202. Прежняя формулировка: (дыру учёта нашла разведка 28.08: нота сказала «ждёт слова владельца», а реестр ожиданий об этом не знал): **(i) смена формы конвейера на ЖИВОЙ книге** — что деплой обязан делать с книгой, начерченной по старой форме, когда форму сменили (носители `PD-403`/`PD-404`; ⚠ проект отвечал на СОСЕДНИЙ вопрос дважды — D39.152 п.4 и D39.153 §4б — и два ратифицированных ответа В КОМПОЗИЦИИ и дают дефект: липкость флага ратифицирована ПРОТИВ отката назад, но в обратную сторону она морозит счёт, и комментарий `platform/internal/pgstore/sink.go:405-408` описывает поведение, которого его же код НЕ делает); **(ii) `PD-410`** — платформа продаёт ГЛАВЫ, движок останавливается по ДЕНЬГАМ, и единица продажи не равна единице остановки · (г) **контракт: сквозная полоса прогресса ЗАКРЫТА** — минор **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** строк · зона бэкенд **83** строго / **121** широко (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**, платные прогоны разблокированы · «скоро» **49** (перечень — грепом по таблице, рукописный список снят D39.126) · гейт-строки эксп-22: 55·153 (плюс 150 — руки владельца); строка 5 — остаток гейчен ре-пробой 74, носитель события теперь строка 188 (аудит D39.140); остальное «когда-нибудь». ⚠ Счёт — НИЖНЯЯ граница долга, не потолок (разбор — легенда таблицы ниже). +> - **ЕДИНЫЙ БЭКЛОГ — секция «Бэклог» ниже** (одна таблица, единственный трекер; каждая петля обязана иметь диспозицию: решено / отложено-с-записью / отклонено; ведёт оркестратор). **СЧЁТ ОЧЕРЕДИ на 27.08 (скриптом по таблице — `python3 docs/scripts/counts.py`; обновлять при каждом лендинге):** всего **177** строк · зона бэкенд **83** строго / **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), нестрогий загрузчик сида, дубль конвенции пути у платформы — 211 ЗАКРЫТА и 213 сужена лендингом P9 (D39.162); 214–216 — разбор журнала трассировки банка: подпись не оставляет следа, $0-репин недостижим с платформы, три несущих сценария не проверены живым движком; 217–218, 220 — аудит переезда машины 24.08: абсолютные пути полигона, дефолты корпуса в движке, бутстрап гейта доков чужой замороженной зоной; 219 (съехавшие line-якоря D-лога) ЗАКРЫТА тем же заходом — D39.157 п.6**) (⚠ колонки счётчик читает С КОНЦА — испр. D39.116) · **блокеров очереди 0**, платные прогоны разблокированы · «скоро» **49** (перечень — грепом по таблице, рукописный список снят 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; правки — только через оркестратора) @@ -144,23 +144,24 @@ | 196 | **Остаточный класс строки 176: неперехватываемые `fatal error` Go** (конкурентная запись в map, OOM, стек-оверфлоу) по-прежнему дают exit 2, то есть читаются платформой как «завершено с флагами» — `recover` их не ловит по устройству языка (подтверждено линзой шва исполнением). Строка 176 закрыта для ПАНИК, не для fatal. Лечение — вне exit-контракта: сторож процесса у платформы либо маркер живости в артефакте прогона | бэкенд/платформа | когда-нибудь | отдельное решение | приёмка №18, линза шва | | 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 · (б) с циклом чтения. ⚠⚠ **МИНА ВЗВЕДЕНА ЛЕНДИНГАМИ 28.08 — статус половины (б) сменился с «латентной» на ЖИВУЮ:** до 28.08 это не кусало, потому что до движка не доезжало ни ключей, ни правок; вчера заленджены и `--keys-file` (D39.162), и дверь правок банка — значит **первый же продолжающий прогон после первой же правки банка УПАДЁТ ГРОМКО** (`backend/internal/pipeline/stagerun.go:54-58`, отказ прочитан приёмкой дословно: «already-paid checkpoints become invalid… re-run with --resnapshot to accept this explicitly»). Чинить ПЕРВЫМ в паке строки 215 — это тот же флаг в той же функции | расследование 20.08, D39.153; мина — D39.165 | +| 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 · (б) с циклом чтения. ⚠⚠ **МИНА ВЗВЕДЕНА ЛЕНДИНГАМИ 28.08 — статус половины (б) сменился с «латентной» на ЖИВУЮ:** до 28.08 это не кусало, потому что до движка не доезжало ни ключей, ни правок; вчера заленджены и `--keys-file` (D39.162), и дверь правок банка — значит **первый же продолжающий прогон после первой же правки банка УПАДЁТ ГРОМКО** (`backend/internal/pipeline/stagerun.go:54-58`, отказ прочитан приёмкой дословно: «already-paid checkpoints become invalid… re-run with --resnapshot to accept this explicitly»). ⚠ **ЗАКРЫТА лендингом P10 (D39.166):** флаги доезжают, факт «банк двигался» берётся из СОБСТВЕННОЙ квитанции двери, гашение — только чистым финишем. Половина (а) — вход — закрыта ещё D39.162 | расследование 20.08, D39.153; мина — D39.165 | | 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: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) | +| 203 | **Хвосты контракта после синка 0.4.0 — ОТКРЫТА ЧЕТВЕРТЬ, остальное исполнено** (тела релеев — `platform/docs/archive/P7_ACCEPTANCE_HANDOFF_2026-08-17.md` §7; сюда переписаны НЕ тела, а статус — один носитель на факт). ⚠ Сверено грепом при лендинге, потому что первая редакция этой строки уже разошлась с деревом: **(б) ИСПОЛНЕН** — срезанная фраза компаньона §2.14 стоит на месте (`14-api-contract/README.md:550`=`различать причины отказа клиент не может по замыслу`) · **(д) ИСПОЛНЕН** — клетка приложения А заполнена `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:1602`=`Карта «причина → КОД контракта`). Реально пуста другая клетка: соответствие причин движка кодам замечаний, и правила её заполнения в компаньоне уже написаны (фраза по ДОККОММЕНТУ `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` | | 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 реестра памяти | | 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-флагов) ⚠ **ДИСПОЗИЦИЯ 28.08 (D39.165): форма решена, строка становится ПАКОМ вместе с миной строки 199.** Платформа читает смету из уже существующего `status --json` (`rebill_units`/`rebill_usd`), показывает «затронуто N юнитов» БЕЗ долларов (D39.84), допускает старт на книге с нулевым остатком, если банк двигался после последнего прогона, и спавнит `translate --resnapshot` с принятым согласием. Движок при этом $0 — его половина построена. ⚠ Область D39.144 п.1 сужена владельцем явно: открыть дверь к построенному механизму ≠ проектировать продуктовый цикл пост-ридинга | платформа + бэкенд | **скоро (первый пак после лендингов 28.08)** | платформенный пак + минор контракта | трассировка цепи банка 22.08, пере-проверено №19; форма — D39.165 | +| 214 | **Подпись банка не оставляет на банке НИ ОДНОГО следа.** Единственная долговечная запись акта подписи — булев `bank_released` на прогоне (`platform/internal/pgstore/runs.go:897`=`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-флагов) ⚠ **ДИСПОЗИЦИЯ 28.08 (D39.165): форма решена, строка становится ПАКОМ вместе с миной строки 199.** Платформа читает смету из уже существующего `status --json` (`rebill_units`/`rebill_usd`), показывает «затронуто N юнитов» БЕЗ долларов (D39.84), допускает старт на книге с нулевым остатком, если банк двигался после последнего прогона, и спавнит `translate --resnapshot` с принятым согласием. Движок при этом $0 — его половина построена. ⚠ Область D39.144 п.1 сужена владельцем явно: открыть дверь к построенному механизму ≠ проектировать продуктовый цикл пост-ридинга ⚠ **ДВЕРЬ ЗАКРЫТА лендингом P10 (D39.166): пере-проход ПРОДАЁТСЯ** членом `RunRequest.re_pass`, согласие даётся деньгами. Отложенная половина — смета до покупки — переехала строкой **231**, там же названа причина | платформа + бэкенд | закрыта D39.166 (остаток — 231) | платформенный пак + минор контракта | трассировка цепи банка 22.08, пере-проверено №19; форма — D39.165 | | 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:955`=`id, err := seed.SignatureMapID(content)`), а шов его не читает — отдать идентификатор в квитанцию и судить по нему. ⚠ Якорь находки был неверен (`pipeline/signaturemap.go` не существует) — исправлен приёмкой чтением. Цена сегодня мала (поле информационное, гейтом не служит) и растёт вместе с экраном подписи: именно он будет решать «что я подписываю» по этому числу | бэкенд | когда-нибудь (гейт: заказ экрана подписи) | аддитивная правка квитанции | воркфлоу-ревью P9, D39.162 | | 228 | **Отклонённая поверхность возвращается АЛИАСОМ уцелевшей строки — движок не держит того, что канон уже обещает** (находка воркфлоу-ревью P9 в форме Д1, УЗКО пере-сформулирована бэкенд-сессией 28.08 и принята приёмкой): канон говорит дословно «declining a surface removes EVERY window of that surface» (`docs/architecture/14-api-contract/openapi.yaml:2038`), а эмиссия майнера энтити-широка (`backend/internal/pipeline/miner_emit.go`, `clusterTouches`), тогда как фильтр авто-банка ключуется только по собственному `src` строки (`backend/internal/pipeline/mining.go:662`). ⚠ **Правильная форма — снять АЛИАС со строки, а не снести строку:** расширение `decline` до энтити противоречило бы ратифицированному контракту, и именно поэтому бэкенд-сессия применила право §9 и НЕ чинила это попутно. Предмет — банковая онтология (`18-bank-ontology.md`), не тихая порча | бэкенд | скоро (гейт: заказ по читающей стороне банка) | отдельный заказ узкой формы | воркфлоу-ревью P9; форма — бэкенд-сессия 28.08, D39.164 | | 229 | **Снапшот не фолдит модель ВНУТРЕННИХ гейтов — флип провода под неизменным `request_hash`** (самонаходка бэкенд-сессии 28.08, подтверждена приёмкой): снапшот фолдит `Capability` СТАДИЙНЫХ моделей и их эскалации (`backend/internal/pipeline/snapshot.go:316-340`), но модель `gates.terminology.model` / `gates.repair.model` (`backend/internal/config/internal_call.go:72`) не фолдится сознательно — а терминолог шлёт ДВА системных сообщения, так что смена оси `capabilities.system_messages` у провайдера, которым пользуется только гейт, меняет байты запроса при неизменном хеше: тихий false-hit класса D5.2. ⚠ **Сегодня ЛАТЕНТНА и денег не стоит — проверено приёмкой: гейта `terminology` нет НИ В ОДНОМ конфиге репозитория** (`grep -c terminology configs/pipeline-c1.yaml` = 0). Триггер починки — день, когда гейт включат с провайдером, объявляющим НЕдефолтную возможность. ⚠ Цена лечения — денежная: фолд гейт-моделей сдвигает хеши и обесценивает чекпойнты; дешёвая форма — фолдить ТОЛЬКО недефолтное (приём `omitempty`, прецедент `MinMaxTokens`), тогда сегодняшние снапшоты остаются байт-равными | бэкенд | когда-нибудь (гейт: включение внутреннего гейта либо следующее касание снапшот-контракта) | правка снапшот-контракта | самонаходка бэкенд-сессии, D39.164 | | 230 | **Инертный `decline` подписанного сид-терма отвечает `already_applied` вместо единственной работающей инструкции** (названный размен пака «тихая порча», D39.164): сузив отказ по поверхности ради СХОДИМОСТИ повтора, движок потерял поучение в одном углу — когда отказ и записан, и по-прежнему инертен против `glossary_seed`, пользователь получает «уже применено» вместо «убери терм из сида». Сходимость сочтена более тяжёлой обязанностью (на ней стоит вся раскладка класса 15 и синхронная дверь платформы), но размен РЕАЛЕН. **Форма закрытия — поле отчёта со стоячим фактом**, то есть аддитивная правка формы шва: платформенный `BankReport` — аллоулист, лишнее поле на провод не уедет само | бэкенд + контракт | скоро (с ближайшим касанием отчёта двери) | аддитивное поле отчёта | размен пака «тихая порча», D39.164 | +| 231 | **Смета пере-прохода не доезжает до покупателя: движковой оценки ВНЕ прогона не существует** (названная цена минора 0.7.0, D39.166). `bank-apply` пишет только ФАЙЛЫ решений, а `status --json` считает ре-билл от СОХРАНЁННОГО глоссария (`backend/internal/pipeline/status.go:733-744`=`projectStoredMemory materializes r.memory from the STORED glossary`) — свёртка происходит внутри СЛЕДУЮЩЕГО `translate`, поэтому сразу после правки движок честно отвечает «ничего не двигалось». Следствие: показать «затронуто N юнитов» ДО покупки нечем, и согласие сегодня даётся ДЕНЬГАМИ (потолок пере-прохода = холд). **Форма лечения — движковый глагол либо флаг «свернуть банк и оценить, ничего не покупая»**; родня строки **124(в)** (`resnapshot --dry-run`, поименован там же). ⚠ Поля `rebill_units`/`rebill_usd` СНЯТЫ с аллоулиста шва до появления потребителя — вернутся с этим глаголом | бэкенд (+платформа вторым концом) | скоро | движковый глагол + минор контракта | D39.166; посылка D39.165 §3 исправлена эрратой 28.08-к | | 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 | diff --git a/docs/README.md b/docs/README.md index 10bff06d..b0a5c72b 100644 --- a/docs/README.md +++ b/docs/README.md @@ -23,7 +23,7 @@ |---|---|---| | Оркестратор | [ORCHESTRATOR_SESSION_PROMPT.md](ORCHESTRATOR_SESSION_PROMPT.md) | роль и нормы; счётчик роли — CURRENT-STATE | | Бэкенд | активного НЕТ | пак «тихая порча» ОТРАБОТАН, ПРИНЯТ и ЗАЛЕНДЖЕН 28.08 (**D39.164**): инъекция банка больше не теряется на провайдере с одним системным слотом, дыра выдачи видна читателю В ТЕКСТЕ, повтор принятого документа сходится. Промт — в `archive/prompts/`. ⚠ Заказ §3.2 был ПЕРЕ-ФОРМУЛИРОВАН находкой исполнителя (маркер существовал и врал), а подсказка промта про место правки ОТКЛОНЕНА им с грунтом — оба решения ратифицированы. Свободные строки зоны: **228** (алиас возвращает отклонённую поверхность), **230** (размен сходимости), **141**-остаток, **131**. Четыре промта входной двери шва — в `archive/prompts/` (D39.158) | - | Платформа | [PLATFORM_P10_SESSION_PROMPT.md](PLATFORM_P10_SESSION_PROMPT.md) | **ВЫДАН 28.08**, пак **P10** — основание D39.165. Мина под правкой банка (продолжающий прогон падает на гарде снапшота) + дверь пере-прохода (продать «улучшить переведённое» нечем, а движок это умеет). Оба рубежа пройдены: механический (поймал фантомную D-ссылку) и опровергатель — **8 находок, все применены**, включая две фатальные: одного `--resnapshot` не хватает (флагманский кейс кончался бы `failed` после взятого холда) и мой запрет `rebill_usd` через шов был неверен (D39.84 — про проекцию в API, не про шов). ⚠ Мина СУЖЕНА эрратой 28.08-и. Пак **P9** отработан и заленджен (D39.162), промт — в `archive/prompts/` | + | Платформа | активного НЕТ | пак **P10** ОТРАБОТАН, ПРИНЯТ и ЗАЛЕНДЖЕН 28.08 (**D39.166**) вместе с минором **0.7.0**: мина под правкой банка обезврежена, пере-проход можно КУПИТЬ. Промт — в `archive/prompts/`. ⚠ **Первая сдача пака была НЕВЕРНОЙ**, и это нашёл ШИРОКИЙ самопроход сессии (заказан владельцем 28.08, канон дополнен `40a649c`): три фатальные находки, включая «мина, объявленная обезвреженной, стоит». Две ошибки оркестратора вынесены эрратой 28.08-к. Следующая работа зоны — кодовый пак по 23 открытым строкам P8-REVIEW либо эпоха смены формы конвейера (`PD-403`/`PD-404`, D39.165 §2) | | Полигон | [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.6.0** ПРИНЯТ и заленджен 28.08 (**D39.162**/**D39.163**): полоса прогресса объявлена сквозной, `Progress.stage` заведён ВТОРЫМ ограниченным исключением из границы «ничего о том, КАК переводится книга». До него — **0.5.0** (D39.161, дверь правок банка). Хвост компаньона — строка **203**, следующий минор по её пункту (к) | diff --git a/docs/architecture/05-decisions-index.md b/docs/architecture/05-decisions-index.md index 567f982f..4747a4ed 100644 --- a/docs/architecture/05-decisions-index.md +++ b/docs/architecture/05-decisions-index.md @@ -1,4 +1,4 @@ -# Реестр D-нот — карта актуальности v2 (D1–D39.165; +# Реестр D-нот — карта актуальности v2 (D1–D39.166; > ⚠ **СЛАБОЕ МЕСТО, КОТОРОЕ БЫЛО ЗДЕСЬ (вписано 22.08, ЗАКРЫТО 24.08 — D39.157 п.6).** Колонка ТЕЛА > у нот D39.107…D39.123 говорила «жив», хотя тела уехали в слайс подрезкой D39.139; семнадцать строк @@ -226,4 +226,5 @@ | D39.163 | 28.08 | **Граница контракта получает ВТОРОЕ исключение — `Progress.stage`**, ограниченное двумя условиями: значение ВЫВОДИТСЯ платформой из тех же счётчиков (не проброс из движка) и словарь ОТКРЫТ (клиент рисует незнакомое нейтрально, версия не поднимается). Ради канона мультиязычности: пара с иной формой работы не требует нового клиента. Оговорка по `PD-410`: подпись — модель платформы, не отчёт движка. Третьего исключения нота НЕ разрешает. | ✅ | | D39.164 | 28.08 | **Бэкенд-пак «тихая порча» принят и заленджен**: ось `SystemMessages` склеивает системный ряд у провайдера с одним слотом (инъекция банка перестала теряться), дыра выдачи видна в потоке текста, порядок записи решений сменён на rejects-first и повтор документа сходится. Приёмка — четырьмя посадками оркестратора, все пойманы топично. Заказ §3.2 пере-формулирован находкой исполнителя (маркер существовал и ВРАЛ), подсказка промта про место правки отклонена с грунтом и это ратифицировано. Строки 208 и 193 закрыты, 141 наполовину; заведены 228/229/230. | ✅ | | D39.165 | 28.08 | **Три продуктовых развилки сняты словом владельца.** (1) Единица продажи: цена от ОБЪЁМА исходника — носитель `chapters.units_total`, известен на интейке за $0; потолок объёма в ДВИЖКЕ рядом с денежным; хвост качества в тариф и мерить; константу по сегодняшним числам НЕ калибровать (гейт — строка 202). Измерено: реальная глава $0.0115–$0.0190 против константы $0.03, «купить N глав» — денежная ручка с подписью «главы». (2) Смена формы конвейера — СОБЫТИЕ КНИГИ («эпоха»), канон уже разрешает пересчёт через границу `structure_version`; монотонность флага не отменяется. (3) Продажа пере-прохода: дверь к уже построенному движковому механизму, смета из `status --json` без долларов; ⚠ МИНА — первый продолжающий прогон после правки банка УПАДЁТ без `--resnapshot`, чинить первым. Область D39.144 п.1 сужена явно. | ✅ | +| D39.166 | 28.08 | **Пак P10 принят и заленджен + контрактный минор 0.7.0**: мина под правкой банка обезврежена, пере-проход продаётся членом `RunRequest.re_pass`, согласие даётся ДЕНЬГАМИ (потолок = холд), полоса — «одна неделимая единица работы». ⚠ Первая сдача пака была НЕВЕРНОЙ: широкий самопроход сессии (заказан владельцем) нашёл три фатальные — мина СТОЯЛА, $0-цена муровала дверь, глава-полоса мертва. Две ошибки оркестратора вынесены эрратой 28.08-к. Пересборка сессии лучше заказа (факт от своей квитанции, фондированный консент). Новый класс: предложение канону, пережившее пересборку кода, — гипотеза, пока не сверено с деревом. | ✅ | diff --git a/docs/architecture/05-decisions-log.md b/docs/architecture/05-decisions-log.md index 5d84dcbb..12028fda 100644 --- a/docs/architecture/05-decisions-log.md +++ b/docs/architecture/05-decisions-log.md @@ -1,4 +1,4 @@ -# Журнал решений оркестратора — контракт D1–D39.165 (живой файл: карта · эрраты · живые тела · голова D39.124+ (подрезка D39.139); тела закрытых эр — в слайсах `docs/archive/architecture/`, указатель ниже; реестр всех нот — `05-decisions-index.md`) +# Журнал решений оркестратора — контракт D1–D39.166 (живой файл: карта · эрраты · живые тела · голова 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+). @@ -19,6 +19,7 @@ > ⚠ **Эррата 27.08-ж (D39.160 п.2) — ошибка ОРКЕСТРАТОРА, найденная исполнителем пака.** Нота утверждала, что после сноса отменённой двери «канон и деплой СОВПАДУТ точно», а промт минора (§3.2-бис) — что счётчики `pending_decisions`/`complete` «навсегда нули». **Верно по ПУТЯМ, неверно по ПОЛЯМ:** проекция `GET /bank` продолжает их слать, и это не нули — `bankCountsTx` (`platform/internal/pgstore/readmodel.go:330`) считает `proposed`-строки, о чём говорит её собственный комментарий. Клиент 0.5.0 лишние поля игнорирует, но аллоулист-норма нарушена до монтажа (2в). Носитель — `PD-399`. ⚠ Контрактная сессия принесла это ПИНГОМ по §11 промта, вместо того чтобы тихо подогнать работу под неверную посылку; это и есть поведение, которого норма требует. > ⚠ **Эррата 27.08-з (D39.156, состав пункта 2в): «воркер решений → глагол перед возобновлением» СНЯТ — посылка изменилась.** Пункт писался, когда двери в контракте не было и подразумевалось НАКОПЛЕНИЕ: платформа копит решения у себя и скармливает их движку перед `resume`. С дверью канона 0.5.0 накопления не существует — правка ПРИМЕНЯЕТСЯ в момент подачи, и гарантия «до возобновления» у синхронной формы СИЛЬНЕЕ воркерной: применено прежде, чем клиент получил `200`. Проверено исполнением с обеих сторон: движок на стопе ВЫХОДИТ (`cmd/tmctl/main.go:95`, код 3), флок не-блокирующий и отпускается ядром на выходе процесса (`store/store.go:187`), между стопом и возобновлением живого процесса на проекте нет — запинено `pipeline/bankchain_test.go:62-64`; кап 5000 решений выведен ИМЕННО из синхронности («the call stops fitting the caller's timeout»). Остаётся не воркер, а пер-книжная сериализация в обработчике. `Resume` «с решениями как они есть» не тронут. > ⚠ **Эррата 28.08-и (D39.165 §3, размер мины) — ошибка ОРКЕСТРАТОРА, найденная опровергателем промта P10.** Нота утверждает: «первый же ПРОДОЛЖАЮЩИЙ прогон после первой же правки банка УПАДЁТ». **Переоценено.** Гард снапшота стреляет по СУЩЕСТВУЮЩЕМУ джобу (`backend/internal/pipeline/stagerun.go:47` — `EnsureJob` создаёт джоб стадии в момент, когда стадия впервые исполняется), а правка в ГЛАВНОМ окне — стоп подписи `awaiting_bank` — двигает edit-снапшот, когда edit-джобов ЕЩЁ НЕТ: возобновление создаёт их свежими, и гард молчит. Драфт-волна mined-строк не видит вовсе (`seeding.go:143-153`). **Дефект СТОИТ, но его триггер уже: правка, сделанная ПОСЛЕ появления edit-джобов** — пауза потолком посреди редактуры и ДОЧИТАННАЯ книга. ⚠ Срочность при этом НЕ падает: флагманский случай продукта («поправил имя героя в дочитанной книге») — ровно тот, где edit-джобы существуют, то есть мина бьёт именно по нему. **Цена ошибки была бы прямой:** репро на потоке `awaiting_bank` показало бы ЗЕЛЕНЬ без фикса, и пак мог быть отозван как мнимый. Промт P10 §4.2 исправлен: репро обязано фиксировать состояние «edit-джоб существует ДО правки». +> ⚠ **Эррата 28.08-к (D39.165 §3 + решение оркестратора о глава-полосе) — ДВЕ ошибки, обе найдены широким самопроходом платформенной сессии, обе доказаны исполнением.** **(1) Посылка «смета УЖЕ публикуется в `status --json`» верна только ПОСЛЕ свёртки.** `bank-apply` пишет только ФАЙЛЫ решений, а `status` считает ре-билл от СОХРАНЁННОГО глоссария (`backend/internal/pipeline/status.go:733-744`, `projectStoredMemory` — его собственный комментарий: «A seed-FILE edit not yet re-run is NOT reflected here… that drift surfaces on the next translate's re-seed»). Свёртка происходит внутри СЛЕДУЮЩЕГО `translate`, поэтому сразу после правки движок отвечает `units=0`/`drift=false`. Следствие: продажа «затронуто N юнитов» и холд от сметы В ТЕКУЩЕМ ШВЕ НЕДОСТИЖИМЫ — для них нужен движковый глагол «свернуть банк и оценить ВНЕ translate», которого нет. **(2) Решение оркестратора «полоса пере-прохода — в ГЛАВАХ» ОТМЕНЯЕТСЯ: его посылка опровергнута.** Я рассудил, что $0-репин двигает полосу, потому что идёт через тот же `resumeFromChunkStatus`, — и не проверил анонс. Движок анонсирует юнит ОДИН РАЗ на жизнь книги (announce-once, `backend/internal/pipeline/events.go:49,143-162`), пере-проход не ре-анонсирует ни репины, ни пере-переводы ⇒ `done` остался бы НУЛЁМ навсегда. Это ровно тот класс, от которого предостерегает памятка «не выводить из соседнего механизма, не проверив свой». ⚠ **Что при этом НЕ отменяется:** запрет класть ЮНИТЫ в поле, объявленное в главах, стоит — но объявленная в каноне «одна единица работы» запретом не является, потому что она НЕ молчаливая. > ⚠ **Навигация (актуализация 07.08, эра D39.1xx):** append-only-дисциплина (D23.3) означает, что > НЕВЕРНЫЙ ФАКТ внутри старой ноты не переписывается, а получает эрратау — и тогда он опасен ровно @@ -1032,3 +1033,58 @@ device». Починено `TestMain`, проверено двумя прого ### ПОРЯДОК РАБОТ, вытекающий из решений **Мина и дверь — ОДИН пак:** оба закрываются теми же двумя флагами в одной функции сборки аргументов. Дальше — строка **202** (живой прогон текущего стека), потому что без неё цена не калибруется. Эпоха формы конвейера и потолок объёма — параллельно, они ни от чего не гейчены. + +## D39.166 — ПЛАТФОРМЕННЫЙ ПАК P10 ПРИНЯТ И ЗАЛЕНДЖЕН + КОНТРАКТНЫЙ МИНОР 0.7.0: мина под правкой банка обезврежена, пере-проход можно КУПИТЬ (28.08, оркестратор №19). ✅ + +**Что заленджено.** Продолжающий прогон по книге с применённой правкой банка больше не умирает на +движковом гарде снапшота · пере-проход продаётся членом `RunRequest.re_pass` (канон **0.7.0**) · +согласие на пере-плату даётся ДЕНЬГАМИ (потолок = холд прогона) · полоса такого прогона объявлена +формой «одна неделимая единица работы». + +⚠ **ГЛАВНОЕ СОБЫТИЕ ЭТОГО ПАКА — НЕ КОД, А ТО, ЧТО ЕГО ПЕРВАЯ СДАЧА БЫЛА НЕВЕРНОЙ И ЭТО НАШЛА САМА +СЕССИЯ.** Пак был сдан, числа сходились, батарея была зелёной — и я собирался его лендить. По +требованию владельца (28.08) сессия провела ШИРОКИЙ адверсариальный проход по своей готовой работе: +6 линз, 42 находки, **три фатальные**, все доказаны исполнением на настоящем движке. +1. **Мина, объявленная обезвреженной, СТОЯЛА.** `bank-apply` пишет только ФАЙЛЫ решений, а `status` + считает ре-билл от СОХРАНЁННОГО глоссария (`backend/internal/pipeline/status.go:733-744`, чей + собственный комментарий это и говорит) — свёртка происходит внутри СЛЕДУЮЩЕГО `translate`. Значит + сразу после правки движок отвечает «ничего не двигалось», факт не взводился, флаги не выдавались. + Живой тест сессии дёргал движковый гард НАПРЯМУЮ и платформенную цепь не покрывал — класс, о + котором она сама же предупреждала в том же отчёте. +2. **$0-цена мурует дверь:** `rebill_usd` у движка `omitempty`, юниты по нулевой цене приходят без + цены, и отказ «нет цены» превращал ЛЕГШУЮ правку в вечный ре-сенд. +3. **Глава-полоса МЕРТВА** — см. эррату 28.08-к. + +**ДВЕ ОШИБКИ ОРКЕСТРАТОРА, обе вынесены эрратой 28.08-к, обе найдены этим проходом.** Посылка +D39.165 «смета уже публикуется в `status`» верна только ПОСЛЕ свёртки. И моё решение «полоса +пере-прохода в ГЛАВАХ» опрокинуто: я вывел «репин двигает полосу» из того, что он идёт общим путём +резюма, и НЕ проверил анонс — движок анонсирует работу один раз за жизнь книги. Вывод из соседнего +механизма без проверки своего. + +**ПЕРЕСБОРКА СЕССИИ ЛУЧШЕ МОЕГО ЗАКАЗА, и это записано как есть.** Факт «банк двигался» берётся из +СОБСТВЕННОЙ квитанции двери (`changed` либо `already_applied` — сходимость ретрая держится +состояниями), а не спрашивается у движка: спрашивать было моей ошибкой. Гашение факта — ЯВНОЕ и +только чистым финишем, с названной в коде асимметрией: застрявший факт стоит одного безвредного +`--resnapshot`, ложно снятый — смерти на гарде. **Консент ФОНДИРОВАННЫЙ** — согласие даётся +деньгами, которые пользователь уже внёс, и потолок равен бюджету; это удовлетворяет мой же ⛔ +«согласие обязано быть явным», ничего не выдумывая. + +**Приёмка — исполнением.** Батарея пере-прогнана мной с живым Postgres: 18 пакетов, `RUN=803`, FAIL +0, гонок 0; гейт версии краснел предсказуемо и позеленел после подъёма константы. Посадил ДВЕ свои +мутации: «полоса снова из глав» роняет `TestARePassRunsBarIsOneUnitOfWork`, «гашение без чистого +финиша» роняет `TestAFailedRunKeepsTheFactAndAReadyRunRetiresIt` — обе топично. + +⚠ **ПРИЁМКА ПОЙМАЛА ЕЩЁ ОДНО, И ЭТО НОВЫЙ КЛАСС.** Предложение сессии по контрактной половине +ПРОТУХЛО ОТ ЕЁ ЖЕ ПЕРЕСБОРКИ: оно описывало поле сметы, глава-полосу и код отказа, которых после +пересборки не стало. Прими я его переносом — на провод уехало бы поле БЕЗ ПРОИЗВОДИТЕЛЯ, ровно тот +класс, который этот же пак вычищает в другом месте. **Формулировка урока (её же): предложение +канону, пережившее пересборку кода, — тоже гипотеза, пока не сверено с деревом.** Парный класс к +«фейк ходит мимо шва», только в доках. + +**Названная цена минора.** Смету «затронуто N юнитов» до покупки провод НЕ несёт — в текущем шве она +недостижима. Возвращается вместе с движковым глаголом «свернуть банк и оценить вне прогона» +(заведён строкой бэклога). Поля `rebill_units`/`rebill_usd` сняты из аллоулиста шва: поле без +потребителя — тот самый класс дефекта, и основание, по которому они брались, снято эрратой. + +**Строки.** Мина строки **199**(б) ЗАКРЫТА. Строка **215** закрыта в части двери; отложенная +половина — смета — переехала в новую строку. Заведена строка на движковый глагол сметы. diff --git a/docs/architecture/14-api-contract/README.md b/docs/architecture/14-api-contract/README.md index 29bd4501..d820d2af 100644 --- a/docs/architecture/14-api-contract/README.md +++ b/docs/architecture/14-api-contract/README.md @@ -51,14 +51,16 @@ cmp-сверка обязательна (D39.138 п.3). > сверку со стандартами, разобранные альтернативы). При расхождении по ФОРМЕ побеждает YAML; > при вопросе «почему так» — этот файл. > -> **Статус: РАТИФИЦИРОВАН по 0.6.0 включительно.** 0.2.0 (D39.115, 08.08) · 0.2.1 (D39.123, 09.08) · +> **Статус: РАТИФИЦИРОВАН по 0.7.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 (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**. Дом канона — этот каталог; +> с платформенным паком P9; вывод и провенанс — §2.20** · +> **0.7.0 (D39.166, 28.08) — покупка ПЕРЕ-ПРОХОДА: правка банка доезжает в уже переведённый текст; +> провенанс — §2.21**. Дом канона — этот каталог; > `frontend/docs/api-contract/openapi.yaml` — байт-зеркало. > > ⚠ **0.5.0 ломающий по построению (мажор `0`): снесены путь `POST …/bank/decisions` и три его @@ -876,6 +878,42 @@ the original request was never applied». Каждый `POST /books` созда --- +### 2.21. Покупка пере-прохода (0.7.0) — ✓ выведено из построенного механизма движка + +**Что появилось.** `RunRequest.re_pass` — булев член; `ceiling_chapters` вышел из `required` и +объявлен взаимоисключающим с ним. Плюс объявленная форма полосы такого прогона и ограничение фразы +«finished work is not bought twice». + +**Зачем.** Главный пользовательский цикл продукта — «поправил термин → правка доехала в уже +переведённый текст» — до 0.7.0 был НЕВЫРАЗИМ на проводе: у дочитанной книги остаток нулевой, шкала +даёт максимум 0, валидного `ceiling_chapters` не существует физически. Канон при этом обещал, что +правка «takes effect on the NEXT run», а следующего прогона купить было нечем — **две фразы канона +складывались в дедлок**, и 0.7.0 его снимает. + +**Провенанс — движок, а не проектирование.** Механизм построен и ратифицирован задолго до этого +минора: `translate --resnapshot` определяет затронутые правкой юниты байт-сверкой отрендеренного +запроса, незатронутые пере-привязывает за $0, а согласие на пере-плату берётся флагом +`--accept-rebill`. Минор не изобретает механику — он открывает к ней дверь. ⚠ Проектирование самого +продуктового ЦИКЛА пост-ридинга (отдельная ручка «перегенерировать», dry-run, доезд правки до +черновика) остаётся гейченным полигоном; область D39.144 сужена явно D39.165. + +⚠ **Чего минор НЕ объявляет, и это названная цена, а не забывчивость.** Смету «затронуто N юнитов» +ДО покупки провод не несёт. Причина установлена исполнением и стоила пересборки пака: `bank-apply` +пишет только ФАЙЛЫ решений, а движковый `status` считает ре-билл от СОХРАНЁННОГО глоссария +(`backend/internal/pipeline/status.go:733-744` — его собственный комментарий предупреждает, что +правка файла, ещё не прогнанная, здесь не отражается). Свёртка происходит внутри СЛЕДУЮЩЕГО +`translate`, поэтому сразу после правки движок честно отвечает «ничего не двигалось». Смета вернётся +на провод вместе с движковым глаголом «свернуть банк и оценить ВНЕ прогона» — до тех пор согласие +даётся ДЕНЬГАМИ: потолок пере-прохода равен холду, который пользователь уже внёс. + +⚠ **Форма полосы объявлена, а не выведена, и причина названа в самом каноне.** `total` = 1, `done` = +0 → 1 на чистом финише. Первое решение оркестратора («полоса в главах») ОТМЕНЕНО эрратой 28.08-к: +движок анонсирует работу ОДИН РАЗ за жизнь книги, поэтому пере-проход, который переделывает уже +анонсированное, новых анонсов не производит — глава-полоса стояла бы на нуле навсегда. Более дробная +полоса была бы полосой, которая не движется. + +--- + ## 3. Зависимости: чтение → источник → строка бэклога **Правило, введённое 0.3.0 (Б-21): предупреждение о недостроенном ОБЯЗАНО нести номер строки diff --git a/docs/architecture/14-api-contract/openapi.yaml b/docs/architecture/14-api-contract/openapi.yaml index 14441941..37f0ef4a 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.6.0 + version: 0.7.0 summary: Ratified contract between the frontend and the TextMachine platform. description: | **RATIFIED contract.** Canonical copy: `docs/architecture/14-api-contract/`; @@ -589,6 +589,12 @@ paths: **Raising the limit of a stopped run is done by starting a NEW run** with a larger `ceiling_chapters`: a paused book is startable, finished work is not bought twice, and the new run continues where the old stopped. `resume` does not do this. + + ⚠ **"Not bought twice" bounds THIS path, not the whole surface.** A re-pass + (`RunRequest.re_pass`) deliberately returns to finished chapters — but it buys the DELIVERY + of a correction into them, not the work again: what the correction does not touch is re-used + at no cost, and only what it does touch is paid for. A book that is finished has no other way + forward, which is what the member exists for. parameters: - $ref: '#/components/parameters/ClientHeader' - $ref: '#/components/parameters/IdempotencyKey' @@ -1274,7 +1280,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.6.0'] + examples: ['0.7.0'] language_pairs: type: array description: | @@ -1355,6 +1361,13 @@ components: 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. + ⚠ **A RE-PASS run counts ONE INDIVISIBLE unit of work: `total` is `1`, and `done` is `0` + until it finishes clean, then `1`.** This is a declared shape, not chapters — do not render + it as "0 of 1 chapters". The reason is a property of the service, named rather than hidden: + it announces a piece of work ONCE over a book's life, so a re-pass, which re-does work + already announced, produces no new announcements to count. A finer-looking bar would be a bar + that never moves, and a bar that never moves is worse than an honest one that moves once. + ⚠ **"Finished" means the work THIS deployment does on a chapter is finished** — the last pass the book actually gets, whatever that is. @@ -2309,8 +2322,25 @@ components: RunRequest: type: object - required: [stop_for_signing, ceiling_chapters] + required: [stop_for_signing] properties: + re_pass: + type: boolean + description: | + Buy a RE-PASS instead of new chapters: carry the corrections already recorded on the + book's memory bank into text that is already translated. `ceiling_chapters` is then + neither required nor legal — a re-pass buys no chapters, it walks the book the service + has already produced and re-does only what the corrections actually touch. + + **Legal only while the book carries a correction the service has not yet walked in.** + Otherwise the start answers `409` with `cause.code: re_pass_unavailable` — either no + correction was recorded since the last run, or the one recorded touched nothing that was + paid for. + + ⚠ **What it costs is bounded by the hold like any other run**, and most of the work is + free: the parts of the book a correction does not reach are re-used, not re-bought. The + service does not quote the affected amount BEFORE the purchase — it cannot yet, and + saying otherwise would be a promise this deployment does not keep. stop_for_signing: type: boolean description: | @@ -2325,8 +2355,13 @@ components: Limit of THIS run, in chapters, within the bounds from `GET /books/{bookId}/run-options`. - Required: a run started without a declared limit would spend past the boundary the user - is entitled to set BEFORE it begins rather than learn about after. `0` is not legal. + Required for an ordinary run, and a run started without a declared limit would spend + past the boundary the user is entitled to set BEFORE it begins rather than learn about + after. `0` is not legal. + + ⚠ **Absent — and refused — when `re_pass` is true:** a re-pass does not buy chapters, so + a chapter limit would describe nothing. The two members are mutually exclusive, and a + request carrying both is `400`. RunOptions: type: object @@ -2604,8 +2639,8 @@ components: properties: contract: type: string - description: Contract version this deployment serves, e.g. `0.6.0`. - examples: ['0.6.0'] + description: Contract version this deployment serves, e.g. `0.7.0`. + examples: ['0.7.0'] EventStatus: allOf: diff --git a/docs/architecture/17-seam-inbound-law.md b/docs/architecture/17-seam-inbound-law.md index d856ed79..2a23deca 100644 --- a/docs/architecture/17-seam-inbound-law.md +++ b/docs/architecture/17-seam-inbound-law.md @@ -42,7 +42,7 @@ **(б) фикс-имена ратифицированной конвенции шва** — `events.jsonl` никто платформе не «сообщает», это константа (`platform/internal/ingest/tail.go:17`=`const JournalFile = "events.jsonl"`, D39.106); без этого выноса буква ломает главный канал самого шва. *Прецеденты:* потолок аргументом (D39.110 п.2б → `--ceiling-usd`, D39.122) · `--verify-bank` - флагом (`platform/internal/runner/engine.go:90`=`func TranslateArgs(workdir string, verifyBank bool`) · дефолт `project_db` по конвенции каталога + флагом (`platform/internal/runner/engine.go:99`=`func TranslateArgs(workdir string, verifyBank bool`) · дефолт `project_db` по конвенции каталога (`backend/internal/config/book.go:189`=`b.ProjectDB = filepath.Join(dir, b.BookID+".db")`) · конвенционное опциональное чтение `.auto-bank.yaml` (`backend/internal/pipeline/mining.go:617`=`.auto-bank.yaml`). @@ -56,7 +56,7 @@ *Почему жёстко:* движок пере-читает сид ПОСРЕДИ прогона (`mining.go:221`), запись в живой прогон въехала бы в снапшот недетерминированно. Потолок посреди прогона менять НЕЛЬЗЯ — он едет со стартом и после не меняется, лекарство — новый прогон - (`platform/internal/runs/reconcile.go:1251`=`answering 202 with the run in the state`). Дверь «менять потолок файлом» закрыта; + (`platform/internal/runs/reconcile.go:1291`=`answering 202 with the run in the state`). Дверь «менять потолок файлом» закрыта; не выдумывать её заново из `research/25`. 3. **Каждый JSON-выход глагола несёт версию документа.** Сегодня асимметрия: `tm-bank-v1` diff --git a/docs/PLATFORM_P10_SESSION_PROMPT.md b/docs/archive/prompts/PLATFORM_P10_SESSION_PROMPT_2026-08-28.md similarity index 100% rename from docs/PLATFORM_P10_SESSION_PROMPT.md rename to docs/archive/prompts/PLATFORM_P10_SESSION_PROMPT_2026-08-28.md diff --git a/docs/research/25-seam-cold-review.md b/docs/research/25-seam-cold-review.md index 57bb5108..6cc0ee21 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` — адресуем НОМЕРОМ, а не строкой: line-якорь в журнал решений не переживает вставку эрраты, и он уже съезжал дважды (гейт якорей, 28.08); первый хит темы — только в D39.110. ⚠ Испр. 23.08: прежняя редакция ссылалась на слайс `archive/architecture/05-decisions-D39-106-123.md`, где тела D39.106 НЕТ — греп по нему подтвердил бы эррату вакуумно). Строка была КАНДИДАТОМ панели, > а живой канон решил ось иначе: **потолок едет со стартом прогона и после не меняется**, лекарство при упоре — НОВЫЙ прогон -> (`platform/internal/runs/reconcile.go:1251`=`answering 202 with the run in the state`). ⚠ Испр. 23.08: прежняя редакция звала «строку 69», но вставка самого баннера сдвинула нумерацию — 69-я строка теперь несёт ЖИВОЙ денежный механизм («холд не освобождается по выходу юнита»), и исполнение прежней инструкции пометило бы отвергнутым НЕ ТО. Номера строк здесь больше не называем, только подстроку. Читать НАЗВАННЫЙ ВЫШЕ пункт как отвергнутого +> (`platform/internal/runs/reconcile.go:1291`=`answering 202 with the run in the state`). ⚠ Испр. 23.08: прежняя редакция звала «строку 69», но вставка самого баннера сдвинула нумерацию — 69-я строка теперь несёт ЖИВОЙ денежный механизм («холд не освобождается по выходу юнита»), и исполнение прежней инструкции пометило бы отвергнутым НЕ ТО. Номера строк здесь больше не называем, только подстроку. Читать НАЗВАННЫЙ ВЫШЕ пункт как отвергнутого > кандидата, а не как обязательный механизм; закон входной двери шва (`../architecture/17-seam-inbound-law.md` п.2) это фиксирует: > посреди прогона внутрь идёт ТОЛЬКО сигнал. diff --git a/platform/docs/DEFECT_REGISTER.md b/platform/docs/DEFECT_REGISTER.md index 5d17c407..b46964f1 100644 --- a/platform/docs/DEFECT_REGISTER.md +++ b/platform/docs/DEFECT_REGISTER.md @@ -27,7 +27,7 @@ |---|---|---|---|---|---|---| | 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: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-385 | bug | **major** | `internal/pgstore/runs.go:406`=`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:466`=`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: строкой | @@ -39,8 +39,8 @@ | ID | Класс | Серьёзность | Где | Суть | Статус | Источник | |---|---|---|---|---|---|---| -| 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-375 | bug | minor | `internal/runs/runs.go:323`=`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:616`=`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: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 (проба на реальной функции + посадка мутации, сужено рефутером) | @@ -55,8 +55,8 @@ | 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: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-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:466`=`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:1106`=`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); рефутер подтвердил механику и опроверг предложенное лекарство | | PD-369 | bug | minor | `internal/pgstore/idempotency.go` `ClaimIdempotency`, `claimRounds` | **Легитимный запрос получает 500 под конкуренцией на одном ключе идемпотентности.** Ретрай проигранной гонки ограничен `claimRounds = 3`, а восемь одновременных попыток одного ключа, каждая из которых сразу отдаёт его назад (как делает любой 4xx), могут отобрать гонку у одного и того же проигравшего трижды подряд — и он получает не один из четырёх контрактных ответов, а внутреннюю ошибку. **Замерено: 1–2 падения на ~80 прогонов собственного теста `TestAClaimThatLostARaceToAReleaseIsRetriedAndNotAnError`** (то есть тест ФЛЕЙКОВЫЙ, и это сам сигнал). Комментарий над константой признаёт границу («a caller that loses three rounds is meeting something other than a race») — но восемь racer'ов и есть гонка, а не «что-то другое». ⚠ **ВНЕ СКОУПА пака P8-FIX и НЕ ТРОГАЛОСЬ: файл не в диффе пака** (`git diff` пуст, последний носитель — `9b23e8c`); найдено попутно при прогоне батареи. Направление, не решение: либо граница по ВРЕМЕНИ вместо числа раундов, либо ответ `ErrKeyInFlight` вместо 500 при исчерпании | open | попутная находка сессии P8-FIX (флейк батареи), передано оркестратору | @@ -79,8 +79,8 @@ | 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 — «после сноса канон и деплой совпадут точно»; верно по ПУТЯМ, неверно по ПОЛЯМ. Ошибка оркестратора, исправлена эрратой ⚠ **ЗАКРЫТА паком 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: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-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:36`=`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:1107`=`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 (находка рефутера, живая проба координатора) | diff --git a/platform/docs/platform-PROGRESS.md b/platform/docs/platform-PROGRESS.md index d7a8590b..9882d512 100644 --- a/platform/docs/platform-PROGRESS.md +++ b/platform/docs/platform-PROGRESS.md @@ -4,6 +4,304 @@ > вопросы, предложения на ратификацию. В `docs/PROGRESS.md` платформа не пишет; оркестратор > читает этот журнал при каждом лендинге зоны (свип «решений владельца» — норма D39.99 п.4). +## ПЕРЕСБОР P10 ПО ДИСПОЗИЦИЯМ ИСПОЛНЕН — форма БЕЗ сметы, все принятые корни закрыты, посадки 4/4 (сессия платформы, 28.08, после самопрохода ниже) + +Диспозиции оркестратора (эррата 28.08-к; пересборка принята целиком; §3.2 — «до твоего холда»; +полоса — «одна единица работы»; resume — «купи заново»; якоря journal-доков не чинить до лендинга). + +### Что легло (карта корней → лечение) + +- **K1/K2/K5/K10/K11-острота — умерли вместе со сметой.** `recordBankMove`(со status), + `rebillConsent`, `rebillCentGuard`, `ErrRebillOutgrown`, сметные колонки books — УПРАЗДНЕНЫ; + миграция 00027 пересобрана (только `bank_moved_at` + консенты строки прогона), sha256 + пере-подписан. Факт — от СВОЕЙ квитанции: `corrected(rec)` = `changed:true` ЛИБО хоть один + `already_applied` (ретрай-сходимость держится состояниями квитанции; провал записи → + `bank_corrections_incomplete`, ре-сенд дописывает). `tmctl status` НЕ зовётся ни в двери, ни в + Start, ни в Resume — «status — ремонт, не поллинг» восстановлен, ошибки движка больше не валят + допуск, пиннинг-вопрос (K9-бинарь) беспредметен. +- **K6/K8/часы — умерли вместе с временны́м предикатом.** Предикат факта = `bank_moved_at is not + null`; гашение ЯВНОЕ и только успехом: `reconcile.finish` при `l.Resnapshot && status=="ready"` → + `ClearBankMove` (вне транзакции finish НАМЕРЕННО: асимметрия «застрявший факт = один безвредный + `--resnapshot`; ложно-снятый = смерть на гарде» — комментарий в коде). failed/stopped/paused + оставляют факт → петля K6 разорвана; правка в окне `awaiting_bank` видна resume того же прогона + (K8) — лишний флаг безвреден по построению гарда (читается только при реальном сдвиге снапшота). +- **K7 — консент ФОНДИРОВАН**: `--accept-rebill=<холд ЭТОГО прогона>` (обычная покупка — + `Ceiling(C)`; resume — полный бюджет строки; re-pass — свой холд). Бланкет-оговорка (K11) + сужена до честной: кап не «различает» источники дрейфа — он ограничивает трату деньгами, + которые пользователь дал. +- **K4 — resume re-pass закрыт словом**: `ErrNotResumable` «a re-pass is bought again» ДО + reopen-арифметики (Ceiling(0)-ловушка недостижима); прерванный re-pass оставляет факт → повторная + покупка доступна (запинено). +- **§3.2 «до твоего холда»**: холд re-pass = `Ceiling(chapter_count)` — честный потолок «вплоть до + полного пере-перевода», незатронутое $0, разница released; гейт — только факт (`BankMoved`); + `BookRunContext` вырос полем `ChapterCount`. +- **K3 — полоса «одна единица работы»**: `runDone` C=0 → 0, и 1 при `finished_at∧ready`; + `runTotal` C=0 → литерал 1 (0/0 недостижим; `chapter_count`-мутабельность total'а умерла); + `stage='re_pass'`. Канонная оговорка — за оркестратором. +- **K12-live**: живой тест теперь гоняет ОБА флага против настоящего движка + (`--accept-rebill=0.030000` в проходной половине — движковый гейт принял). +- `Options.RebillUnits` снят (показ «N юнитов» отложен вместе со сметой — строка бэклога + оркестратора на движковый глагол). + +### Пины и посадки пересбора + +Переписаны/добавлены: `TestACorrectionRecordsTheBankMoveAndAPreviewDoesNot` (+ветка already_applied +пере-штампует после ClearBankMove) · `TestAStartOverAMovedBankCarriesBothConsentsToTheSpawn` +(`--accept-rebill=0.060000` = холд 2 глав) · `TestAFailedRunKeepsTheFactAndAReadyRunRetiresIt` +(жизненный цикл факта через настоящий Sweep-путь) · `TestTheRePassDoorAdmitsOnAMovedBankAndRefusesWithoutOne` +(холд −150000 = вся 5-главная книга) · `TestAResumeOverAMovedBankGrantsTheConsents` (90000 = бюджет +строки) · `TestARePassIsBoughtAgainNotResumed` · `TestARePassRunsBarIsOneUnitOfWork` (0/1 → 1/1; +C=0 без консента отвергнут). Посадки: M-B2 (критерий квитанции мёртв) · M-C2 (гасит любой исход) · +M-G (консент не фондирован) · M-H (ветка полосы снята → 0/0 пойман) — **4/4 топично**. + +### Числа пересбора (командой) + +`go test ./... -race -count=1 -v` с гейтами → **EXIT=0, 18 пакетов ok, SKIP=0, RUN=803**, +FAIL/DATA RACE — 0; `golangci-lint` → **0 issues**; +`gofmt -l` пусто. Опись: `git status --short -- platform/` — дифф СЖАЛСЯ против сданного +(упразднений больше, чем добавлений). + +### Дописка: провод 0.7.0 смонтирован (четыре пункта приёмки) + +1. `ContractVersion` → **0.7.0**, гейт версии зелёный. +2. **`re_pass` на проводе**: `wireRunRequest.re_pass`; `ceiling_chapters` обязателен только для + обычной покупки; оба вместе → 400 malformed (канонное взаимоисключение); `StartRequest` + собирается по форме. Пин `TestARePassRequestIsItsOwnPurchaseShape` (три стороны: доходит до + сервиса как re-pass · оба вместе 400 · «нечего» → 409 со своим cause). +3. **`cause.code: re_pass_unavailable`** — свой код взамен временного `bounds_moved` + (`CauseRePassUnavailable`, маппинг `ErrRePassUnavailable`). +4. **`rebill_units`/`rebill_usd` СНЯТЫ из аллоулиста шва** (слово приёмки: поле без потребителя — + класс, который пак лечит; основание взятия снято эрратой 28.08-к) — вместе с декод-пином; + в шапке `StatusReport` осталось ИМЕНОВАННОЕ объяснение, почему пара не берётся (тайминг + свёртки) и с чем вернётся (движковый глагол сметы). + +Батарея после провода (финальная этого раунда): `go test ./... -race -count=1 -v` с гейтами → +**EXIT=0, 18 пакетов ok, SKIP=0, RUN=803**, FAIL/DATA RACE — 0; линт **0 issues**; `gofmt -l` пусто. + +### Остатки, названные честно + +- ~~`rebill_units`/`rebill_usd` остаются в аллоулисте~~ — СНЯТО приёмкой (см. дописку выше): + пара убрана из шва целиком до движкового глагола сметы. +- Правки банка, сделанные в ОДНИ СУТКИ жизни P9-двери ДО деплоя P10, факта не имеют (миграционный + in-flight): их продолжение может поймать гард; лечение — повторный apply того же документа после + деплоя (byte no-op проставит факт по already_applied-ветке). +- Консент-гейт движка живьём деньгами по-прежнему не пробит ($0-цены; кандидат строки 202) — из + прежнего Obstacle, не изменилось. + +## ⚠ ШИРОКИЙ САМОПРОХОД P10 (заказ оркестратора 28.08): ПАК В СДАННОЙ ФОРМЕ НЕСОСТОЯТЕЛЕН — 42 находки/6 линз, три корня валят ФОРМУ; диспозиция и СТОП до слова оркестратора (сессия платформы, 28.08) + +Заказ «найди, где автор неправ» исполнен воркфлоу (6 линз: 1×Fable на деньги + 3×opus + 2×sonnet, +42 находки, 57 not_refuted; полные траектории — журнал wf_323e81c4-3e3). **Запись ниже — по норме +«дефекты, внесённые самим паком, первыми»; сданная выше запись «ПАК P10 ОТРАБОТАН» в части §3.1-мины +и §3.2-полосы ОПРОВЕРГНУТА этим проходом.** По дереву НИЧЕГО не менено после сдачи — пересбор по +диспозиции ниже требует слова оркестратора (два корня пересматривают его решения: D39.165-предпосылку +и главу-форму полосы). + +### Корни (дедуплицировано из 42; K1-K3 — фатальные для формы) + +| # | Корень | Улика | +|---|---|---| +| K1 | **СЛЕПОЕ ОКНО: смета в момент правки НЕ СУЩЕСТВУЕТ.** `tmctl status` считает дрифт/ре-билл от stored memory, а `bank-apply` пишет только файлы решений — свёртка происходит ВНУТРИ следующего translate. Сразу после apply живой движок отдаёт `rebill_units=0, config_drift=false` (исполнено агентами на настоящем tmctl, дважды подряд) ⇒ `recordBankMove` не пишет НИЧЕГО, факт не взводится, флаги не выдаются, **мина стоит** — а мой live-тест проверял движковый гард НАПРЯМУЮ (TranslateArgs руками) и платформенную цепь не покрывал: класс M-F («фейк мимо шва») в центре моего же §3.1, `fakeEngine.report={RebillUnits:3}` — проекция, которой настоящий движок в этом окне не отдаёт. Рушится ВСЁ на смете: материализация · сравнение живой/мат · продажа «затронуто N юнитов» · `rebillConsent`. ⚠ Предпосылка D39.165 §3 «смета уже публикуется в status --json» верна только ПОСЛЕ свёртки — для двери она недостижима без нового движкового глагола/флага (свёртка вне translate) | `bank.go:308-320` vs `backend/internal/pipeline/status.go:634-660`; живое исполнение 3 линз независимо | +| K2 | **$0-цена мурует дверь**: `rebill_usd` у движка `float64,omitempty` — честные «units>0 по $0.00» приходят как units>0 БЕЗ цены; мой отказ «no price» → вечный `ErrBankIncomplete`, ре-сенд не сходится. Все $0/локальные деплои | `bank.go:316-320` vs `status.go:208-209`; 4 линзы | +| K3 | **Полоса-глава пере-прохода МЕРТВА**: движок анонсирует юнит ОДИН РАЗ НА ЖИЗНЬ КНИГИ (announce-once ledger, ключ без снапшота) — пере-проход не ре-анонсирует ни репины, ни пере-переводы, `unit_resolutions.at` не двигается, done=0 навсегда (и `runs.draft_done`-канал тоже молчит). ⚠ Предпосылка глава-формы («каждый визит ре-резолвит юниты») опровергнута первоисточником — решение оркестратора требует пересмотра с этой уликой | `readmodel.go:481` vs `backend/internal/store/outbox.go:58-74`, `events.go:331-336`; 2 линзы | +| K4 | Пере-проход не переживает прерываний И запечатывает дверь: reopen budget=`Pricing.Ceiling(0)`=0 → `ceilingSpent` → resume 409 `ceiling_reached`, а факт погашен `finished_at` мёртвого прогона → RePass «nothing to re-pass». Ребут → `paused/credit_exhausted` (лживое слово) через ветку «Unreachable today» | `reconcile.go:1094-1102`; исполнено тестом агента (PASS) | +| K5 | «Холд строго положителен по построению» — ЛОЖЬ: гейт RePass судит МАТЕРИАЛИЗОВАННЫЕ units, холд — ЖИВОЙ consent; живой 0 при мат>0 → «hold must be positive» → **500 internal_error** (исполнено) | `runs.go:313` vs `bank.go:345-348` | +| K6 | Факт гасится ЛЮБЫМ `finished_at` — включая failed-прогон, умерший на гарде с $0: **вечная петля** Start-без-флагов→гард→failed→… (исполнено агентом). Формулировка отчёта «успешный финиш гасит» не соответствовала коду | `books.go:1008-1012` | +| K7 | Консент не фондирован на обычной покупке: `--accept-rebill=5.01` при `--ceiling-usd 0.03` — прогон обязан сжечь бюджет на ре-билл и встать на потолке; и частичный ре-пин при этом гасит факт | `runs.go:295-306` vs `rebill.go:292-301` | +| K8 | Правка в окне `awaiting_bank`: факт невидим для resume того же прогона (AND not-exists-live) и потом гасится его же finished_at — окно, которое P9 открывал, P10 не обслуживает | `books.go:1008-1011` | +| K9 | `rebillConsent` в Resume читает ТЕКУЩИЙ `Cfg.EngineBinary`, не пиннутый `l.EngineBinary` — против дисциплины строки 139 своей же зоны | `bank.go:340` vs `spawn.go:245-254` | +| K10 | Ошибка движкового status в Start/Resume валит допуск ЦЕЛИКОМ словом 500; status (`projectRebill`→`withText`: полный ре-чанк+хеш-рендер книги) стоит ДО bounds-проверки и под мьютексом — «status — ремонт, не поллинг» нарушен трижды | `runs.go:300-306`, `reconcile.go:1119-1131` | +| K11 | Мой «капнутый консент» — бланкет в кепке: derived FROM the projection he bounds; мат. смета сама включает ДО-правочный деплой-дрейф → ⛔-различение работает только на дрейф ПОСЛЕ правки | `bank.go:355` vs `rebill.go:292-302` | +| K12 | Россыпь: два часовых источника предиката (now() БД vs s.now()) · `chapter_count` мутабелен в total (против канона «total = покупка») · комментарий «flagship = resume паузы» называет случай, который код отвергает (`paused`→`ceiling_reached`) · D39.165-половина «показывает N юнитов» не доставлена (Options.RebillUnits без провода — и без сметы недоставима) · миграционный in-flight без консентов · live-тест не гоняет `--accept-rebill` живьём (acceptRebill=0 во всех трёх вызовах — имя теста шире правды) | таблица находок, журнал wf | + +### Диспозиция (МОЁ предложение; исполняю ПОСЛЕ слова оркестратора) + +**Чинить своё пересбором на форму БЕЗ сметы (закрывает K1,K2,K5,K6,K7,K8,K9,K10,K11 разом):** +1. Факт «банк двигался» — от СВОЕЙ квитанции, не от движка: взвод при apply с `changed==true` ЛИБО + хоть одним `already_applied` (ретрай-сходимость держится состояниями квитанции, status не нужен); + `recordBankMove`/`rebillConsent`/`ErrRebillOutgrown`/сметные колонки — УПРАЗДНИТЬ (миграция 00027 + пересобирается: только `bank_moved_at` + консенты строки). +2. Гашение факта — ЯВНОЕ, не временнОе: `finish` при `l.Resnapshot && outcome==ready` → сброс + (K6-петля умирает; K8-окно работает — «лишний» `--resnapshot` при недвинутом снапшоте безвреден + по построению гарда: читается только при несовпадении снапшота; часовой dispute умирает вместе с + предикатом времени). +3. Консент — ФОНДИРОВАННЫЙ: `--accept-rebill=<холд ЭТОГО прогона>` (Pricing.Ceiling(C)) — «согласен + пере-платить не больше, чем этот прогон вообще может потратить»; удовлетворяет ⛔-«либо согласие + явным» деньгами, которые пользователь уже дал; K7 умирает (кап=бюджет), K11 сужается до честной + оговорки. +4. K4: resume пере-прохода закрыть честным словом (`ErrNotResumable`: «пере-проход не резюмится — + купи заново»; факт при нефинальном исходе стоит по п.2 → пере-покупка доступна). +**СТОП — решения оркестратора (не чиню):** +5. **§3.2-продажа** «затронуто N юнитов + холд от сметы» в текущем шве НЕДОСТИЖИМА (K1): либо + движковый глагол/флаг «свернуть и оценить» (пинг бэкенду, их пак жив), либо продажа вслепую с + иным холдом, либо §3.2 откладывается. D39.165-предпосылка требует эрраты. +6. **Полоса пере-прохода** (K3): глава-форма мертва первоисточником; варианты — движок ре-анонсирует + при ре-резолве (глагольная половина) / полоса «одна работа» (0→1 на финише) / без полосы. Твоё + слово. +7. Канонные хвосты §3.4 — как решишь по 5-6. + +Дерево не менялось после сдачи; жду слова. + +Дерево передаётся на лендинг (правки P10 поверх заленженного P9). Вопрос формы §3.2 решён +оркестратором в ходе пака (глава-полоса, `re_pass`-форма запроса — его слово в канале). + +### Таблица комплектности против §3 (пункт → сделано → каким ИСПОЛНЕНИЕМ подтверждено) + +| §3 | Что сделано | Исполнение | +|---|---|---| +| §3.1 мина | Носитель факта: миграция `00027` (`books.bank_moved_at` + материализованная смета `rebill_units`/`rebill_usd_micro`; `runs.resnapshot`/`accept_rebill_micro`); предикат факта — `bank_moved_at > max(finished_at)` книги, ОДНО сравнение закрывает все три края промта (превью/no-op не пишут — запись от ПРОЕКЦИИ, не от `changed`, и потому ретрай после провала записи сходится; правка в окне `awaiting_bank` даёт проекцию 0 — её волна без джобов — и факта не оставляет; успешный финиш нового прогона гасит факт сам). Дверь правок после каждого apply снимает `tmctl status` под тем же `lockBook` и пишет факт+смету (`recordBankMove`); провал записи → `bank_corrections_incomplete` (ре-сенд сходится байтовым no-op). Start/Resume решают ОБА флага один раз под мьютексом → строка прогона → `TranslateArgs` рендерит `--resnapshot` и ВСЕГДА КАПНУТЫЙ `--accept-rebill=<материализованная сумма + цент float-запаса>` (никогда bare-форму); реконсилерские рестарты флаги не пере-выводят (argv стабилен — дисциплина `verify_bank`). **Различение банкового сдвига от деплойного (⛔-пункт)** — сравнением ЖИВОЙ проекции с материализованной: материализованная снята В МОМЕНТ правки (цена банкового сдвига до всякого дрейфа), рост сверх неё+цент → `ErrRebillOutgrown` → 409 ДО холда; движковый гейт «потолок ниже проекции — отказ» остаётся вторым рубежом | **ЖИВОЕ РЕПРО на настоящем движке** `TestTheSnapshotGuardIsLoudWithoutTheFlagsAndPassesWithThem` (runner, гейтед): полный прайм-прогон обеих волн на $0-заглушке (порт local-провайдера, in-test) → живой `bank-apply` двигает банк → **без флага гард стреляет громко и НАЗЫВАЕТ `--resnapshot`** (состояние эрраты 28.08-и ДОКАЗАНО этим же отказом — зелёный здесь = вырожденная фикстура = Fatal) → с флагом проходит ($0-репин + пере-перевод затронутых). Пины: `TestACorrectionRecordsTheBankMoveAndAPreviewDoesNot` · `TestAStartOverAMovedBankCarriesBothConsentsToTheSpawn` (argv из спавна: `--accept-rebill=0.130000`) · `TestAResumeOverAMovedBankGrantsTheConsents` · `TestAGrownProjectionRefusesBeforeTheHold` (runs не вырос, холд не взят) · `TestTheConsentFlagsRenderExactlyWhenGranted` · `TestTheRebillProjectionCrossesTheSeam` (декод сырого JSON). Посадки M-A..M-F — 6/6 пойманы | +| §3.2 дверь | `StartRequest.RePass`: на книге с фактом и `RebillUnits>0` допускается прогон с `ceiling_chapters=0`; **холд = согласованная смета + цент** (центы, не главы×$0.03; строго положителен по построению — `CeilingTemplate` не откажет, `bookCap` остаётся положительным приращением); без факта — `ErrRePassUnavailable` (временно 409 `bounds_moved`; свой cause — в §3.4). **Полоса пере-прохода — ГЛАВА-форма по слову оркестратора**: `done` = distinct-главы, которых пере-проход коснулся (`unit_resolutions.at >= r.started_at` — движковые event-времена), `total` = `chapter_count`, `stage='re_pass'` (открытый словарь D39.163); инвариант «0/0 недостижим» держится глава-формой + пол `greatest(chapter_count,1)`; `C=0` без консента по-прежнему отвергается | `TestTheRePassDoorAdmitsOnAMovedBankAndRefusesWithoutOne` (обе стороны; холд −130000 в леджере, C=0 в строке) · `TestARePassRunsBarWalksTheBook` (0/2 `re_pass` на старте → 1/2 после касания главы; C=0 без консента отвергнут) | +| §3.3 смета | `rebill_units`+`rebill_usd` в аллоулисте `StatusReport` (ОБА, по поправке промта; доллары → micro-USD НА ШВЕ как `Spend`, указатель — absent ≠ бесплатно; units>0 без цены = отказ шва, не нулевое согласие); `runs.Options.RebillUnits` — юниты для показа до покупки (провод — контрактная половина); наружу доллары НЕ выходят | декод-пин `TestTheRebillProjectionCrossesTheSeam`; опровергатель на пропуск СПАВНЕН с мандатом промта (§4.5) — вердикт аддендумом ниже | +| §3.4 контракт | ОПИСАНО предложением ниже, канон не тронут | секция «Предложение §3.4» ниже | + +### Числа (каждое — командой) + +- Батарея с гейтами: `go test ./... -race -count=1 -v` → **EXIT=0, 18 пакетов ok, + `grep -c -- '--- SKIP'` → 0, `grep -c '^=== RUN'` → 802**, FAIL/DATA RACE — 0. ⚠ Финальный прогон + после последней правки (Options.RebillUnits) — дописка ниже. +- **Скипы БЕЗ DSN (⚠-требование §4.1, командой):** `env -u TM_PLATFORM_TEST_DSN … go test ./...` + → EXIT=0, `grep -c -- '--- SKIP'` → **286** (из 783 RUN) — «зелень без DSN» не проверяет треть + зоны запусками и ещё часть скипами в TestMain; ловушка D39.162 воспроизведена числом. +- Линтер `golangci-lint run` → **0 issues** (4 находки — noctx×2/staticcheck/gofmt в новом + live-тесте — починены); `gofmt -l` пусто. +- Посадки: **M-A** argv теряет `--resnapshot` → `TestTheConsentFlags` · **M-B** дверь не пишет факт + → `TestACorrectionRecords` · **M-C** сравнение смет снято → `TestAGrownProjection` · **M-D** + RePass без факта → `TestTheRePassDoor` · **M-E** глава-ветка полосы снята → `TestARePassRunsBar` + (0/0 пойман) · **M-F** json-тег сметы сломан → `TestTheRebillProjectionCrossesTheSeam`. + **6/6 пойманы топично** (копия `~/tm-p9-mut2`, база зелёная). ⚠ Урок M-F честно: ПЕРВАЯ посадка + прошла все сервис-тесты — `fakeEngine` отдаёт Go-структуру МИМО json-тегов; ловец — только пин на + декоде сырого JSON. Класс «фейк ходит мимо шва» — знать при ревью любых шов-полей. + +### Предложение §3.4 (контрактная половина — правит оркестратор) + +1. **`RunOptions`**: поле `re_pass_units` (int, ≥0; 0 = пере-прохода нет) — «затронуто N юнитов»; + имя с `re_pass`, не «rebill» (движковое слово на проводе не живёт). Внутренний носитель готов + (`runs.Options.RebillUnits`). +2. **`POST /runs`**: булев член `re_pass` (по твоей ноте-решению; без `ceiling_chapters`), легален + только при `re_pass_units > 0` в опциях; ответ — обычный Run с полосой глава-формы. +3. **`Progress`**: оговорка к `total` («what this run bought» → для пере-прохода «работа, которую + прогон обходит» — твоя формулировка в канале); `stage` получает значение `re_pass` в примерах + открытого словаря. +4. **`openapi.yaml:590`** «finished work is not bought twice» → оговорка: пере-проход покупает НЕ + работу заново, а доставку правки в уже купленное; цена — только затронутые юниты, незатронутое + $0 (D39.165 §3). +5. **§2.12 компаньона**: «ре-билл» остаётся запретным СЛОВОМ провода, но исключение для СЧЁТА + работы: `re_pass_units` — счёт той же природы, что `total_units`; доллары проекции запретными + остаются (D39.84). +6. **Cause-коды** взамен временного `bounds_moved`: `re_pass_outgrown` (живая проекция выросла сверх + показанной — пере-читай опции) и `re_pass_unavailable` (пере-прохода нет — банк не двигался или + не тронул оплаченного). Оба 409 на `startRun`. + +### Аддендум: вердикт опровергателя §3.3 (заказ §4.5) и финальная батарея + +Опровергатель (мандат промта: «покажи, где пропуск нарушает §2.12 или D39.84») вернулся с +разбором по трём основаниям и полной трассой значений: +- **§2.12 — НЕ нарушает**, и довод сильнее моего: «пять денежных полей» §2.12 — поимённо + `RebillUSD`/`CommittedUSD`/`ReservedUSD`/`BookCeilingUSD`/`ProjectedBookUSD`, и ДВА из пяти + (`Spend`, `Reserved`) уже ЛЕГАЛЬНО пересекают шов в заленженном аллоулисте — чтение «§2.12 + запрещает шов» делало бы их нарушениями задним числом; `rebill_usd` — третье из пяти. D39.165 §3 + допускает оба поля поимённо и пофайльно. Правка §2.12 остаётся ОБЯЗАТЕЛЬНОЙ (безусловная + формулировка учит обратному) — уже в предложении §3.4. ⚠ Плюс его находка ДЛЯ ОРКЕСТРАТОРА: + якоря §2.12 (`pipeline/status.go:58-130`, `:37-55`) ПРОТУХЛИ — волновая машинерия D39.122 + сдвинула структуры (деньги теперь в `ChapterPassport`:153-175 и `StatusReport`:178-269). +- **D39.84 — не нарушает** (запрет — поля в UI; сам D39.84 называет «rebill-согласие движка» как + нетронутую легитимную механику; доля-не-сумма проверена на `wireUsage`). +- **Шапка `resync.go:10-13` НАРУШАЛА — моя вина, поймано им, починено этим же деревом:** список + «absent on purpose» всё ещё называл `rebill` и формулировал правило как «must not cross the + seam» — два чтения в одном док-комментарии. Вычеркнуто, различение шов/провод внесено в шапку + с именем находки. +- **Путей утечки на провод/в лог НЕ найдено** — полная трасса обоих значений (БД → строка прогона + → argv [не логируется по PD-99] → движок; синк ре-синка ре-билл-поля НЕ материализует; options и + projectRun денег не несут; INFO-строки несут только id и счёт глав; метрики/SSE чисты). +- Попутные его факты: (а) `re_pass` в `stage` легален (открытый словарь), но канону значение + записать — уже п.3 предложения §3.4; (б) проводного носителя `re_pass` у двери нет — ВЕРНО, это + и есть контрактная половина (п.2 предложения): платформенная половина пака сознательно + недостижима с провода до канонного члена. + +**Финальная батарея** (после ВСЕХ правок, включая Options.RebillUnits и шапку resync): +`go test ./... -race -count=1 -v` с тремя гейтами → **EXIT=0, 18 пакетов ok, SKIP=0, RUN=802**, +FAIL/DATA RACE — 0; линтер **0 issues**; `gofmt -l` пусто. + +### Obstacle — что НЕ удалось и что НЕ проверено + +- **Консент-гейт движка живьём НЕ пробит деньгами**: на $0-ценах local-пары проекция всегда $0 и + под порогом — живой отказ «over the consent threshold» требует ненулевого прайса (живого ключа + или прайс-таблицы с ценой). Консент-половина доказана юнитами (argv, сумма, отказ роста) и + движковым контрактом (`--accept-rebill=` ниже проекции — отказ; текст `rebill.go:322` + сверен), НЕ живым прогоном. Требует стенда с ненулевым прайсом — кандидат строки 202. +- **`ur.at >= r.started_at`** сравнивает движковое event-время с платформенным `now()` одного + хоста; на разъехавшихся часах мульти-хостового будущего полоса пере-прохода может недосчитать + главы (транзиентно, до следующего касания). Названо в комментарии `rePassDone`. +- **Идемпотентный ключ повторного `POST /runs {re_pass}`** — не строился (как и у обычного Start + вне идемпотентности ключа запроса); повтор после успеха отвечает `run_in_flight`/`ErrRePassUnavailable` + (факт погашен финишем) — вырожденных дублей не нашёл, но специального пина нет. +- Опровергатель §3.3 — спавнен, вердикт аддендумом (на момент записи ещё бежал). + +## ПАК P10 — ЗАПИСКА-ПЛАН (сессия платформы, 28.08, ДО правок; промт `docs/PLATFORM_P10_SESSION_PROMPT.md`, D39.165) + +Карта чтения промта пройдена целиком; все движковые факты промта пере-проверены чтением +(`stagerun.go:52-58` гард и `:88-107` $0-репин · `rebill.go:322` плоский отказ и `:38` порог · +`status.go:202-209` смета · `main.go:252-262` ортогональность и кумулятивность капа · `book.go` +промпты/langpack в снапшоте) — расхождений с промтом НЕТ. + +### Выбранные формы (обоснование — рядом; посадочная проверка в §4 промта) + +1. **Носитель факта «банк двигался» — колонки на `books`**: `bank_moved_at timestamptz` (момент + успешного apply с `changed:true`; превью и no-op НЕ пишут) + материализованная смета + `rebill_units int` / `rebill_usd_micro bigint` (снятая `tmctl status` в самой двери правок сразу + после apply, под тем же `lockBook`, $0). **Предикат факта: `bank_moved_at > finished_at + ПОСЛЕДНЕГО прогона** (или прогон отсутствует ⇒ факта нет — все джобы будут свежими). Эта + семантика закрывает все три края промта БЕЗ спец-случаев: правка в окне `awaiting_bank` легла + ДО финиша того же прогона ⇒ факт не встаёт ⇒ resume без флагов, гард молчит (edit-джобов нет — + эррата 28.08-и); правка после паузы потолком посреди редактуры ⇒ факт стоит ⇒ resume несёт + флаги; правка на дочитанной ⇒ факт стоит ⇒ дверь §3.2. Успешный финиш пере-прохода гасит факт + сам (новый `finished_at` > `bank_moved_at`), без отдельного сброса. +2. **Стабильность argv при респавне — по образцу `verify_bank`**: решение о флагах принимается ОДИН + раз в Start/Resume под `lockBook` и пишется в строку прогона (`runs.resnapshot bool`, + `runs.accept_rebill_micro bigint`); `spawn.spec` читает строку. Флаг не может появиться посреди + прогона по построению. +3. **Различение банкового сдвига от деплойного — сравнением СМЕТ, не чтением снапшотов** (снапшоты + — словарь движка и через шов не ходят). Смета материализуется В МОМЕНТ правки банка — это + чистая цена БАНКОВОГО сдвига. На Start/Resume под мьютексом смета пере-снимается живьём ($0); + если живая проекция ВЫШЕ материализованной сверх допуска — сдвиг не (только) банковый + (деплой двинул промпты/langpack) ⇒ **явный отказ 409 ДО холда** с ремеди «пере-смотри смету» + (обновить материализованную = пере-показать пользователю). Согласие движку — ВСЕГДА + `--accept-rebill=<материализованная сумма>`: сумма, которую видел пользователь; движковый гейт + «потолок ниже проекции — отказ» остаётся вторым рубежом. «Свежая квитанция» сама по себе + основанием не является — основание всегда КОНКРЕТНАЯ согласованная сумма (D20.2-Q2). +4. **Дверь §3.2**: допуск в `Start` — на книге с фактом шкала не отбивает старт; **холд = + строго положительное приращение от СМЕТЫ** (`max(rebill_usd, пол в 1 цент)` — не главы×$0.03; + пере-проход стоит центы, незатронутое $0), `bookCap = committed + этот холд` (кумулятивная + семантика не трогается). +5. **Шов §3.3**: `rebill_units`+`rebill_usd` в аллоулист `StatusReport` (оба, по поправке промта; + доллары в micro-USD на шве, как `Spend`; наружу в API — только юниты). Опровергатель на пропуск + — заказ §4.5, спавню с мандатом «покажи, где это нарушает §2.12 или D39.84». + +### Открытые оси (форма решится при исполнении; затык = пинг, не интерпретация) + +- **Как клиент ПРОСИТ пере-проход и что показывает полоса такого прогона.** Дочитанная книга: + `Scale.Max=0`, валидного `ceiling_chapters` у клиента нет. Черновая форма: options объявляет + `rebill_units>0`, Start принимает пере-проходную форму запроса (внутренне `ceiling_chapters=0` + легализуется ТОЛЬКО при факте) — но `runTotal=0` полосе запрещён, полоса пере-прохода — из + юнитов сметы. Это контрактная половина — опишу предложением §3.4, до слова оркестратора провод + не трогаю. «Вид покупки» на провод НЕ несу (D39.163: «третьего исключения нет»); если форма + без него не встанет — довод по существу отдельно. +- Допуск против «частично дочитанной» книги (остаток >0 И банк двигался): обычная покупка уже + доступна — несёт ли она флаги? Да (мина бьёт по продолжению с edit-джобами) — это §3.1, не §3.2. + +### Порядок работ (§3.1 первым, как заказано) + +1. Миграция (3 колонки books + 2 колонки runs) → 2. запись факта+сметы в двери правок → 3. флаги +в Start/Resume + `TranslateArgs` → 4. репро мины на ЖИВОМ tmctl (edit-джоб ДО правки — состояние +эрраты; прецедент `bankapply_live_test.go`) → 5. дверь допуска + холд от сметы → 6. аллоулист шва + +опровергатель → 7. options/предложения §3.4 в журнал → 8. посадки, батарея (скипы с DSN и без — +командой), оси ревью 1–3. + +### Не-делать (из промта, себе) + +`backend/` и `docs/` не трогаю · $0.03 не трогаю · цикл пост-ридинга гейчен (§0-бис) · чужие 20 +позиций полигона · `docs/PROGRESS.md` не пишу. + ## ФИКС-РАУНД ПО ДИСПОЗИЦИЯМ ОРКЕСТРАТОРА ИСПОЛНЕН — 13 фиксов, 9/9 посадок пойманы, одна находка воркфлоу ОПРОВЕРГНУТА исполнением, канон 0.6.0 принят (сессия платформы, 28.08, после записи ниже) Диспозиции пришли двумя сообщениями оркестратора (28.08) + третьим — канонная половина полосы @@ -522,7 +820,7 @@ mutation-catch (спутать пары «числитель×база»); ко ⚠ Пере-именование банк-экспорта в фикс-имя — ЛОМАЮЩЕЕ, ему место в окне строки 161, не здесь. 4. **Поле, которое декодируется и не читается.** `ingest.StatusReport.UnsignedBankTerms` - (`internal/ingest/resync.go:30`=`UnsignedBankTerms`) разбирается из ответа движка и не используется + (`internal/ingest/resync.go:36`=`UnsignedBankTerms`) разбирается из ответа движка и не используется НИ ОДНОЙ строкой продакшн-кода зоны. Либо потребитель потерян при спиле пер-термного пути, либо поле лишнее — решать вам; я называю факт, потому что мёртвое поле в структуре шва читается как контракт. diff --git a/platform/internal/httpapi/capabilities.go b/platform/internal/httpapi/capabilities.go index 160ea52e..f2343dec 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.6.0" +const ContractVersion = "0.7.0" // Capabilities is what this deployment can do: one flat document, the same for every account. type Capabilities struct { diff --git a/platform/internal/httpapi/problem.go b/platform/internal/httpapi/problem.go index 9c2238c2..c589cc86 100644 --- a/platform/internal/httpapi/problem.go +++ b/platform/internal/httpapi/problem.go @@ -61,6 +61,10 @@ const ( CauseCreditUnavailable = "credit_unavailable" // CauseBoundsMoved — the scale moved between the read and the call. CauseBoundsMoved = "bounds_moved" + // CauseRePassUnavailable — a re-pass was asked for and there is nothing to re-pass: no + // correction was recorded since the last run, or a resnapshot run has already walked it in + // (canon §RunRequest.re_pass, 0.7.0). + CauseRePassUnavailable = "re_pass_unavailable" // CauseCreditHeld — another book of this account holds the credit; `blocked` names it. CauseCreditHeld = "credit_held" // CauseKeyReused / CauseKeyInFlight — the two halves of `Idempotency-Key`. diff --git a/platform/internal/httpapi/v0.go b/platform/internal/httpapi/v0.go index 5fb42b54..5959eec3 100644 --- a/platform/internal/httpapi/v0.go +++ b/platform/internal/httpapi/v0.go @@ -211,11 +211,13 @@ type wireRunOptions struct { } type wireRunRequest struct { - // Pointers because both fields are REQUIRED and "absent" has to be told from "false" and from - // "zero": a run started without a declared ceiling would spend past the limit the user is - // entitled to set beforehand (canon §RunRequest). + // Pointers because "absent" has to be told from "false" and from "zero": a run started without + // a declared ceiling would spend past the limit the user is entitled to set beforehand (canon + // §RunRequest). Since 0.7.0 `ceiling_chapters` is required for an ORDINARY run only: `re_pass` + // buys no chapters and the two members are mutually exclusive — both at once is 400. StopForSigning *bool `json:"stop_for_signing"` CeilingChapters *int `json:"ceiling_chapters"` + RePass *bool `json:"re_pass"` } type wireUsage struct { @@ -316,22 +318,30 @@ func (h *v0) startRun(w http.ResponseWriter, r *http.Request) { Invalid(w, r) return } + rePass := req.RePass != nil && *req.RePass var missing []Item if req.StopForSigning == nil { missing = append(missing, Item{Pointer: "/stop_for_signing", Code: ItemMissing}) } - if req.CeilingChapters == nil { + if req.CeilingChapters == nil && !rePass { missing = append(missing, Item{Pointer: "/ceiling_chapters", Code: ItemMissing}) } if len(missing) > 0 { Invalid(w, r, missing...) return } + // The two purchases are mutually exclusive (canon §RunRequest, 0.7.0): a re-pass buys no + // chapters, so a chapter limit beside it would describe nothing — and guessing which of the + // two the caller meant is exactly the quiet half-belief this surface refuses elsewhere. + if rePass && req.CeilingChapters != nil { + Invalid(w, r, Item{Pointer: "/ceiling_chapters", Code: ItemMalformed}) + return + } // The schema's own minimum (RunRequest.ceiling_chapters, minimum: 1) belongs HERE and not in the // service: a request that violates the schema is malformed, and answering it with 409 would tell // the client the bounds had moved — so it would re-read run-options and retry, forever, a request // that can never succeed. - if *req.CeilingChapters < 1 { + if !rePass && *req.CeilingChapters < 1 { Invalid(w, r, Item{Pointer: "/ceiling_chapters", Code: ItemOutOfRange}) return } @@ -342,12 +352,16 @@ func (h *v0) startRun(w http.ResponseWriter, r *http.Request) { return } defer key.release(r.Context()) // every exit settles the key; `complete` below cancels it - run, err := h.runs.Start(r.Context(), runs.StartRequest{ - UserID: user, - BookID: r.PathValue("bookId"), - VerifyBank: *req.StopForSigning, - CeilingChapters: *req.CeilingChapters, - }) + in := runs.StartRequest{ + UserID: user, + BookID: r.PathValue("bookId"), + VerifyBank: *req.StopForSigning, + RePass: rePass, + } + if !rePass { + in.CeilingChapters = *req.CeilingChapters + } + run, err := h.runs.Start(r.Context(), in) if err != nil { h.fail(w, r, err) return @@ -797,6 +811,10 @@ func (h *v0) fail(w http.ResponseWriter, r *http.Request, err error) { // 409 and not 400: the request was legal when the bounds were read, and a hold taken for // another book between that read and this call is what moved them (canon §startRun). FailCause(w, r, CodeCeilingUnavailable, CauseBoundsMoved) + case errors.Is(err, runs.ErrRePassUnavailable): + // Its own cause since 0.7.0: nothing to re-pass — no correction since the last run, or a + // resnapshot run already walked it in (canon §RunRequest.re_pass). + FailCause(w, r, CodeCeilingUnavailable, CauseRePassUnavailable) case errors.Is(err, runner.ErrCeilingNotWired), errors.Is(err, runs.ErrRunnerIncomplete): // A DEPLOYMENT that cannot start runs: it has no way to tell the engine its ceiling (row 145) // or no way to record how a unit ended. diff --git a/platform/internal/httpapi/v0_test.go b/platform/internal/httpapi/v0_test.go index f3bb1930..8ada346e 100644 --- a/platform/internal/httpapi/v0_test.go +++ b/platform/internal/httpapi/v0_test.go @@ -704,3 +704,30 @@ func TestOnlyTheContractsOwnPausedReasonReachesTheWire(t *testing.T) { } func ptr[T any](v T) *T { return &v } + +// The 0.7.0 re-pass purchase on the wire (canon §RunRequest): `re_pass` without a chapter limit +// reaches the service as the re-pass; the two members together are 400 (mutually exclusive); and +// «nothing to re-pass» answers 409 with its own cause word. +func TestARePassRequestIsItsOwnPurchaseShape(t *testing.T) { + f := &fakeRuns{run: pgstore.Run{ID: "run_1", Status: "translating"}} + h := v0Server(t, &fakeLibrary{}, f) + if w := call(t, h, "POST", "/v0/books/bk_1/runs", + `{"stop_for_signing":false,"re_pass":true}`); w.Code != http.StatusAccepted { + t.Fatalf("a re-pass request answered %d: %s", w.Code, w.Body) + } + if !f.got.RePass || f.got.CeilingChapters != 0 { + t.Fatalf("the service saw %+v, want RePass with no chapters", f.got) + } + if w := call(t, h, "POST", "/v0/books/bk_1/runs", + `{"stop_for_signing":false,"re_pass":true,"ceiling_chapters":3}`); w.Code != http.StatusBadRequest { + t.Fatalf("both purchases at once answered %d, want 400", w.Code) + } + f.err = runs.ErrRePassUnavailable + w := call(t, h, "POST", "/v0/books/bk_1/runs", `{"stop_for_signing":false,"re_pass":true}`) + if w.Code != http.StatusConflict { + t.Fatalf("nothing-to-re-pass answered %d, want 409", w.Code) + } + if body := w.Body.String(); !strings.Contains(body, `"re_pass_unavailable"`) { + t.Fatalf("the 409 does not carry its own cause: %s", body) + } +} diff --git a/platform/internal/ingest/resync.go b/platform/internal/ingest/resync.go index b623c5a3..ca4eace3 100644 --- a/platform/internal/ingest/resync.go +++ b/platform/internal/ingest/resync.go @@ -8,9 +8,15 @@ import ( ) // StatusReport is the ALLOWLISTED subset of `tmctl status --json` (pipeline.StatusReport) that the -// platform materializes. Everything absent here is absent on purpose: snapshot ids, drift, rebill, -// routing, content labels and the operator's flag taxonomy are engine vocabulary that must not -// cross the seam (contract §2.12), and unknown JSON fields are simply ignored by encoding/json. +// platform materializes. Everything absent here is absent on purpose: snapshot ids, drift itself, +// routing, content labels and the operator's flag taxonomy are engine vocabulary with no consumer +// on this side. The rule the list enforces is about the WIRE, not the seam: §2.12 forbids these +// words from reaching a client, and D39.84 forbids projecting money into an API response or an +// INFO log — crossing the seam into the credit and consent machinery is legal and precedented +// (Spend and Reserved below; the re-bill pair, D39.165 §3). An earlier edition of this header read +// the rule as "must not cross the seam", which its own field comments had already corrected — the +// refuter of P10 caught the two readings standing side by side. Unknown JSON fields are simply +// ignored by encoding/json. type StatusReport struct { BookID string `json:"book_id"` TotalUnits int `json:"total_units"` @@ -28,6 +34,13 @@ type StatusReport struct { ETASeconds float64 `json:"eta_seconds"` // UnsignedBankTerms backs the signing screen's "N of M decided" while a stop is standing. UnsignedBankTerms int `json:"unsigned_bank_terms"` + // The engine's re-bill projection (rebill_units/rebill_usd) is deliberately NOT taken, and the + // reason is not vocabulary but TIMING: status projects the STORED memory, and a correction + // reaches it only when the next translate folds the bank in — so right after an apply, the one + // moment a consent would want the figure, it honestly reads zero (P10 errata 28.08-к). The + // consents are funded from the run's own hold instead, and a field without a consumer is the + // defect class this project names — the pair returns with the engine verb that can fold and + // price a correction OUTSIDE a run (orchestrator's backlog row). // Spend is the engine's committed spend, converted to integer micro-USD AT THE SEAM. The wire // value is a JSON decimal; binding it to a float64 would put drift one step before the integer // column that exists to prevent drift (PD-15). It is stored in the credit tables and NEVER diff --git a/platform/internal/pgstore/books.go b/platform/internal/pgstore/books.go index c2bd5666..e4562bbf 100644 --- a/platform/internal/pgstore/books.go +++ b/platform/internal/pgstore/books.go @@ -971,6 +971,9 @@ type BookRunContext struct { // from intake and nothing per chapter, so a book with no materialized chapters answers with its // whole length — which is right for a book that has never run. ChaptersLeft int + // ChapterCount is the book's whole length — what a re-pass hold prices, since a re-pass may in + // the worst case re-translate everything the correction touched, up to the book. + ChapterCount 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 @@ -979,6 +982,15 @@ type BookRunContext struct { // 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 + // BankMoved is the re-pass door's fact (P10, errata 28.08-к): a correction landed that the + // already-translated text does not carry, and no --resnapshot run has finished `ready` since. + // A plain standing flag, deliberately NOT a timestamp comparison: the door stamps it from its + // own receipt and reconcile.finish clears it explicitly on a ready resnapshot run, so there is + // no clock pairing to skew and no failed run to retire it early (the adversarial pass's K6 and + // its clock dispute both die with the predicate). The asymmetry is chosen: a STALE standing + // fact costs one harmless --resnapshot (the engine reads the flag only when a snapshot actually + // moved), a falsely-retired one kills the next run on the guard after its hold. + BankMoved bool } // ReadBookForRun gathers the facts a run start is judged on. @@ -988,13 +1000,15 @@ 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), + b.chapter_count, 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') + and lr.status = 'awaiting_bank'), + b.bank_moved_at is not null 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, &out.LiveRunAwaitingBank) + &out.ChapterCount, &out.HasLiveRun, &out.LiveRunAwaitingBank, &out.BankMoved) if errors.Is(err, pgx.ErrNoRows) { return BookRunContext{}, ErrNoBook } @@ -1007,6 +1021,34 @@ func (s *Store) ReadBookForRun(ctx context.Context, userID, bookID string) (Book return out, nil } +// RecordBankMove is the correction door's write: the bank moved. Stamped from the door's OWN +// receipt — never from the engine's status projection, which is blind to a correction until the +// next translate folds the bank into memory (P10 errata 28.08-к, the adversarial pass's K1). A +// plain flag: what re-passing would COST is unknowable at this moment by construction, so nothing +// pretends to price it. +func (s *Store) RecordBankMove(ctx context.Context, bookID string) error { + tag, err := s.pool.Exec(ctx, `update books set bank_moved_at = now() where id = $1`, bookID) + if err != nil { + return fmt.Errorf("pgstore: record bank move: %w", err) + } + if tag.RowsAffected() == 0 { + return ErrNoBook + } + return nil +} + +// ClearBankMove retires the fact — called by the reconciler when a run that CARRIED --resnapshot +// finished `ready`: the correction has demonstrably reached the translated text. Deliberately not +// keyed on time and not called for failed/stopped/paused endings: a partial re-pin leaves +// superseded units behind, and the next admission must still carry the flags. +func (s *Store) ClearBankMove(ctx context.Context, bookID string) error { + if _, err := s.pool.Exec(ctx, + `update books set bank_moved_at = null where id = $1`, bookID); err != nil { + return fmt.Errorf("pgstore: clear bank move: %w", err) + } + return nil +} + // Usage is the credit state, in the only form that crosses the boundary: a share, never a sum // (D39.84, contract §Usage). type Usage struct { diff --git a/platform/internal/pgstore/migrations.sha256 b/platform/internal/pgstore/migrations.sha256 index 294e2f68..1fbee4bb 100644 --- a/platform/internal/pgstore/migrations.sha256 +++ b/platform/internal/pgstore/migrations.sha256 @@ -42,3 +42,4 @@ c21877113b5966bc8a200ba69ce752d4ac295bdfd34e681afbd887523edb6ceb 00019_read_mod 0213ea7636b536d1aad46b617ee399cccbf8efd87b3eb282340cdc9430a96b0e 00024_book_edit_wave.sql 1ad95e77c78d556a83e35c2fceac09475240c7a919fd3554e34cb5bf62ef52d1 00025_stalled_work.sql 14de53c75746c2b9ab872dcf9c9e0c4bd1ea2eb9e9778fe6a6e2cbe390d8961d 00026_run_draft_baseline.sql +6b0f226e2b5acb90e930337f6548ba777f985deb4472a9f900709acb504ab99f 00027_bank_move.sql diff --git a/platform/internal/pgstore/migrations/00027_bank_move.sql b/platform/internal/pgstore/migrations/00027_bank_move.sql new file mode 100644 index 00000000..4e8f3e49 --- /dev/null +++ b/platform/internal/pgstore/migrations/00027_bank_move.sql @@ -0,0 +1,27 @@ +-- +goose Up + +-- The carrier of "the bank moved" — the fact the correction door creates and nothing recorded +-- until now (the receipt died with the caller; P10, D39.165 §3 + errata 28.08-к). The door stamps +-- it from its OWN receipt (an apply that changed files, or answered already_applied on a retry) — +-- never from the engine's status projection, which is BLIND to a correction until the next +-- translate folds the bank into memory (the adversarial pass's K1). The fact is retired +-- EXPLICITLY: a run that carried --resnapshot and finished `ready` clears it in reconcile.finish — +-- no timestamp comparison, no clock pairing, and a failed or interrupted run leaves the fact +-- standing, so the next admission still carries the flags instead of dying on the engine's +-- snapshot guard forever. +alter table books add column bank_moved_at timestamptz; + +-- The run's own consents, decided ONCE at admission (Start/Resume, under the book lock) and read +-- by every spawn of every attempt — the discipline verify_bank rides: argv must be STABLE across +-- respawns, a flag must not appear mid-run because a sweep re-derived it from fresher state. +-- accept_rebill_micro is the FUNDED cap: the run's own hold — consent to re-pay no more than this +-- run may spend at all — never a projection (which does not exist at admission time, K1) and never +-- the bare blanket form. +alter table runs add column resnapshot boolean not null default false; +alter table runs add column accept_rebill_micro bigint not null default 0; + +-- +goose Down + +alter table runs drop column accept_rebill_micro; +alter table runs drop column resnapshot; +alter table books drop column bank_moved_at; diff --git a/platform/internal/pgstore/readmodel.go b/platform/internal/pgstore/readmodel.go index 8cbc0a55..9d190154 100644 --- a/platform/internal/pgstore/readmodel.go +++ b/platform/internal/pgstore/readmodel.go @@ -470,13 +470,26 @@ const ( // 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)` + // A RE-PASS run (P10, D39.165 §3 + errata 28.08-к) buys no chapters — ceiling_chapters is 0, + // the one shape that writes it — and its bar is ONE UNIT OF WORK: 0 until the run finishes + // clean, 1 then. Declared, not smuggled (the orchestrator's canon note): no finer honest + // granularity exists — the engine announces a unit once for the life of the book and + // re-announces nothing on a re-pass (announce-once ledger; the first edition counted chapters + // by resolution times and read 0/N forever — adversarial K3). The 0/0 frame stays unreachable: + // the denominator is the literal 1. + runDone = `(case when r.ceiling_chapters = 0 then + (case when r.finished_at is not null and r.status = 'ready' then 1 else 0 end) + when ` + editWave + ` then ` + draftBar + ` + ` + editBar + ` else ` + draftOnlyBar + ` end)` + runTotal = `(case when r.ceiling_chapters = 0 then 1 + 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 + ` + // (open vocabulary, like the correction receipt's `depth`; D39.163 — new values move no + // version). 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 — always + // `drafting` where drafting is the only pass there is, and `re_pass` for the whole of a re-pass + // run, which is neither. + runStage = `(case when r.ceiling_chapters = 0 then 're_pass' + when ` + editWave + ` and ` + draftBar + ` >= ` + draftWork + ` then 'editing' else 'drafting' end)` ) diff --git a/platform/internal/pgstore/readmodel_test.go b/platform/internal/pgstore/readmodel_test.go index 486adf38..609545fb 100644 --- a/platform/internal/pgstore/readmodel_test.go +++ b/platform/internal/pgstore/readmodel_test.go @@ -1308,3 +1308,41 @@ func TestAWrittenOffSurfaceCanBeAskedForAgain(t *testing.T) { t.Error("re-arming a debt that is already owed reported that it did something") } } + +// A re-pass run's bar is ONE UNIT OF WORK (P10 §3.2, errata 28.08-к): 0 while it runs, 1 when it +// finishes clean — declared in the canon, not smuggled into a chapter count (the engine +// re-announces nothing on a re-pass, so no finer honest granularity exists). The 0/0 frame stays +// unreachable (the denominator is the literal 1), and a zero ceiling WITHOUT the re-pass consent +// stays refused. +func TestARePassRunsBarIsOneUnitOfWork(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, CeilingChapters: 0, + Resnapshot: true, AcceptRebill: money.MicroUSD(130_000), Ceiling: money.MicroUSD(130_000), + Now: at}, 0, nil) + if err != nil { + t.Fatal(err) + } + if run.Progress.Done != 0 || run.Progress.Total != 1 || run.Progress.Stage != "re_pass" { + t.Fatalf("a fresh re-pass opens at %d/%d %q, want 0/1 re_pass", + run.Progress.Done, run.Progress.Total, run.Progress.Stage) + } + exec(t, s, ctx, `update runs set status = 'ready', finished_at = $2 where id = $1`, + run.ID, at.Add(time.Minute)) + got, err := s.ReadRun(ctx, "u1", run.ID) + if err != nil { + t.Fatal(err) + } + if got.Progress.Done != 1 || got.Progress.Total != 1 || got.Progress.Stage != "re_pass" { + t.Fatalf("a finished re-pass reads %d/%d %q, want 1/1 re_pass", + got.Progress.Done, got.Progress.Total, got.Progress.Stage) + } + if _, err := s.StartRun(ctx, StartRunInput{UserID: "u1", BookID: book, CeilingChapters: 0, + Ceiling: money.MicroUSD(100_000), Now: at.Add(time.Hour)}, 0, nil); err == nil { + t.Fatal("a zero-chapter run without the re-pass consent was admitted") + } +} diff --git a/platform/internal/pgstore/runs.go b/platform/internal/pgstore/runs.go index f7e20249..42ca4ed6 100644 --- a/platform/internal/pgstore/runs.go +++ b/platform/internal/pgstore/runs.go @@ -38,6 +38,13 @@ type StartRunInput struct { // impossible even while the platform is blind (D39.100). Ceiling money.MicroUSD Now time.Time + // Resnapshot/AcceptRebill are the re-pass consents, decided ONCE at admission and stored on the + // run the same way VerifyBank is: every spawn of every attempt reads the row, so argv is stable + // across respawns and a flag cannot appear mid-run (P10 §3.1). AcceptRebill is the CONCRETE sum + // the platform consents to (`--accept-rebill=`) — the projection the user was shown, never + // a blanket yes (D20.2-Q2). + Resnapshot bool + AcceptRebill money.MicroUSD } // StartedRun is what the caller needs after a successful start. @@ -64,7 +71,11 @@ type StartedRun struct { // a second call because River owns its SQL and this package owns its own: neither reaches into the // other, and the atomicity is still real. func (s *Store) StartRun(ctx context.Context, in StartRunInput, journalOffset int64, enqueue func(context.Context, Tx, string) error) (StartedRun, error) { - if in.CeilingChapters <= 0 { + // Zero is ONE legal shape: the re-pass run (P10), which buys no chapters — it re-walks the book + // under the consents, so Resnapshot is what marks it. Everything else still needs a positive + // ceiling; the bar of a zero-ceiling row switches to the re-pass form (readmodel.runTotal), so + // the 0/0 frame stays unreachable. + if in.CeilingChapters < 0 || (in.CeilingChapters == 0 && !in.Resnapshot) { return StartedRun{}, fmt.Errorf("pgstore: a run needs a positive chapter ceiling, got %d", in.CeilingChapters) } out := StartedRun{JournalOffset: journalOffset} @@ -90,8 +101,9 @@ func (s *Store) StartRun(ctx context.Context, in StartRunInput, journalOffset in // (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, + resnapshot, accept_rebill_micro, chapters_before, draft_before) - select $1, $2, 'translating', $3, $4, $5, b.revision + 1, + select $1, $2, 'translating', $3, $4, $5, b.revision + 1, $7, $8, (select count(*) from chapters c where c.book_id = b.id and c.units_total > 0 and c.units_edit_done >= c.units_total), (select count(*) from chapters c @@ -99,7 +111,8 @@ func (s *Store) StartRun(ctx context.Context, in StartRunInput, journalOffset in 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). + err := tx.QueryRow(ctx, insertRun, runID, in.BookID, in.VerifyBank, in.CeilingChapters, in.Now, in.UserID, + in.Resnapshot, int64(in.AcceptRebill)). Scan(&out.ID, &out.BookID, &out.Revision, &out.Status, &out.VerifyBank, &out.CeilingChapters, &out.PausedReason, &out.StartedAt, &out.FinishedAt) if errors.Is(err, pgx.ErrNoRows) { @@ -198,6 +211,11 @@ type LiveRun struct { // BankReleased is whether this run's signing stop has already been lifted. It decides whether the // next attempt is spawned WITH the engine's `--verify-bank`: resume means the stop is over. BankReleased bool + // Resnapshot/AcceptRebill are the run's re-pass consents (P10): read by every spawn so the argv + // is the admission's decision, never a sweep's re-derivation. AcceptRebill == 0 means no consent + // was given (the flag is not passed); a consent is always a concrete sum. + Resnapshot bool + AcceptRebill money.MicroUSD // CeilingChapters is the run's whole budget, in the unit the user chose; Ceiling is what THIS // attempt was allowed to spend. They differ after a restart, which gets what is left. CeilingChapters int @@ -525,7 +543,8 @@ func (s *Store) AbandonRun(ctx context.Context, runID, reason string, giveTheHol // runColumns is the reconciler's view of a run. Written once because the two queries that use it // differ only in which runs they select, and a scan list copied twice is a scan list that drifts. const runColumns = ` - select r.id, r.book_id, b.owner_id, b.workdir, r.verify_bank, r.bank_released, r.ceiling_chapters, + select r.id, r.book_id, b.owner_id, b.workdir, r.verify_bank, r.bank_released, r.resnapshot, + r.accept_rebill_micro, r.ceiling_chapters, coalesce(r.paused_reason, ''), r.started_at, r.status, r.stop_requested_at, a.id, a.attempt_no, coalesce(a.unit_name, ''), coalesce(a.engine_run_id, ''), a.last_offset, a.last_seq, a.last_line_sha256, a.quarantine_reason is not null, @@ -543,9 +562,10 @@ func (s *Store) queryRuns(ctx context.Context, tail string, args ...any) ([]Live var out []LiveRun for rows.Next() { var l LiveRun - var ceiling, ceilingArg int64 + var ceiling, ceilingArg, acceptRebill int64 var baseline *int64 - if err := rows.Scan(&l.RunID, &l.BookID, &l.UserID, &l.Workdir, &l.VerifyBank, &l.BankReleased, &l.CeilingChapters, + if err := rows.Scan(&l.RunID, &l.BookID, &l.UserID, &l.Workdir, &l.VerifyBank, &l.BankReleased, &l.Resnapshot, + &acceptRebill, &l.CeilingChapters, &l.PausedReason, &l.StartedAt, &l.Status, &l.StopRequestedAt, &l.AttemptID, &l.AttemptNo, &l.UnitName, &l.EngineRunID, &l.Position.Offset, &l.Position.LastSeq, &l.Position.LastHash, &l.Quarantined, &ceiling, @@ -554,6 +574,7 @@ func (s *Store) queryRuns(ctx context.Context, tail string, args ...any) ([]Live } l.Ceiling = money.MicroUSD(ceiling) l.CeilingArg = money.MicroUSD(ceilingArg) + l.AcceptRebill = money.MicroUSD(acceptRebill) if baseline != nil { v := money.MicroUSD(*baseline) l.SpendBaseline = &v @@ -747,6 +768,12 @@ type RestartInput struct { // EngineBinary overrides the pinned path. Nil means INHERIT — which is the default, because a // resume is the same run continuing and row 139 lets another version in only on purpose. EngineBinary *string + // Resnapshot/AcceptRebill GRANT the re-pass consents on re-open (a resume of a run the bank + // moved under — P10 §3.1). Widening only: the row keeps a consent it already carries, and the + // sum only grows (a smaller fresh projection is covered by the larger consent already given). + // The reconciler's restarts pass zero values and change nothing. + Resnapshot bool + AcceptRebill money.MicroUSD // OnlyIfLive refuses to re-open a run that has already FINISHED. The reconciler sets it and a // resume does not, and the difference is money. // @@ -868,9 +895,12 @@ func (s *Store) RestartRun(ctx context.Context, in RestartInput) (LiveRun, error 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, + resnapshot = r.resnapshot or $3, + accept_rebill_micro = greatest(r.accept_rebill_micro, $4), revision = b.revision + 1 from books b - where r.id = $1 and b.id = r.book_id`, in.RunID, in.LiftBankStop); err != nil { + where r.id = $1 and b.id = r.book_id`, in.RunID, in.LiftBankStop, + in.Resnapshot, int64(in.AcceptRebill)); err != nil { // Clearing finished_at puts the run back under the one-live-run-per-book index, and the // book may already have a NEWER live run — nothing stops an account starting one after it // stopped this one. That is a conflict and not a failure: the whole transaction rolls back, @@ -902,11 +932,15 @@ func (s *Store) RestartRun(ctx context.Context, in RestartInput) (LiveRun, error return LiveRun{}, err } // The fields the caller needs to spawn but this transaction did not read. - const q = `select b.workdir, r.verify_bank, r.bank_released, r.ceiling_chapters from runs r + const q = `select b.workdir, r.verify_bank, r.bank_released, r.resnapshot, r.accept_rebill_micro, + r.ceiling_chapters from runs r join books b on b.id = r.book_id where r.id = $1` - if err := s.pool.QueryRow(ctx, q, in.RunID).Scan(&out.Workdir, &out.VerifyBank, &out.BankReleased, &out.CeilingChapters); err != nil { + var acceptRebill int64 + if err := s.pool.QueryRow(ctx, q, in.RunID).Scan(&out.Workdir, &out.VerifyBank, &out.BankReleased, + &out.Resnapshot, &acceptRebill, &out.CeilingChapters); err != nil { return LiveRun{}, fmt.Errorf("pgstore: read restarted run: %w", err) } + out.AcceptRebill = money.MicroUSD(acceptRebill) return out, nil } diff --git a/platform/internal/runner/engine.go b/platform/internal/runner/engine.go index 35c0ce5f..60bdbe3b 100644 --- a/platform/internal/runner/engine.go +++ b/platform/internal/runner/engine.go @@ -87,7 +87,17 @@ func (t CeilingTemplate) Args(ceiling money.MicroUSD) ([]string, error) { // 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 { +// +// resnapshot and acceptRebill are the re-pass consents (P10, D39.165 §3), decided at ADMISSION and +// stored on the run row — this function only renders what the row says, so a respawned attempt +// carries the same argv. They are the engine's own ORTHOGONAL pair (tmctl: --resnapshot re-pins +// jobs onto the current snapshot, --accept-rebill consents to the re-payment): a run over a moved +// bank needs both, because the snapshot guard and the consent gate refuse independently. The +// consent is always CAPPED — `--accept-rebill=`, the concrete sum the admission agreed to — +// never the bare blanket form: a projection grown past it (deploy drift on top of the bank's move) +// must refuse, not be bought silently. Zero means no consent and no flag. +func TranslateArgs(workdir string, verifyBank bool, keysFile string, resnapshot bool, + acceptRebill money.MicroUSD, ceiling []string) []string { args := []string{"translate", "--config", filepath.Join(workdir, ConfigFile)} if verifyBank { args = append(args, "--verify-bank") @@ -95,6 +105,12 @@ func TranslateArgs(workdir string, verifyBank bool, keysFile string, ceiling []s if keysFile != "" { args = append(args, "--keys-file", keysFile) } + if resnapshot { + args = append(args, "--resnapshot") + } + if acceptRebill > 0 { + args = append(args, "--accept-rebill="+acceptRebill.USD()) + } return append(args, ceiling...) } diff --git a/platform/internal/runner/runner_test.go b/platform/internal/runner/runner_test.go index 17f7ff07..1f44225e 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, "", false, 0, []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, "", false, 0, nil), "--verify-bank") { t.Error("verify-bank was sent for a run that did not ask for it") } st := StatusArgs("/srv/books/a") @@ -196,12 +196,12 @@ func TestEveryEngineInvocationNamesTheBookConfig(t *testing.T) { // // 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) + tr := TranslateArgs("/srv/books/a", false, "/etc/tm/keys.env", false, 0, 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") { + if slices.Contains(TranslateArgs("/srv/books/a", false, "", false, 0, nil), "--keys-file") { t.Error("an unset key file was still passed as a flag") } } @@ -264,3 +264,23 @@ func TestQuoteArgvEscapesWhatWouldSplit(t *testing.T) { t.Errorf("quoted argv: %s", got) } } + +// The re-pass consents render exactly and only when granted (P10): --resnapshot bare, the consent +// always CAPPED to the concrete sum — the bare blanket form must never appear. +func TestTheConsentFlagsRenderExactlyWhenGranted(t *testing.T) { + tr := TranslateArgs("/w", false, "", true, 1_230_000, nil) + if !slices.Contains(tr, "--resnapshot") { + t.Errorf("no --resnapshot: %v", tr) + } + if !slices.Contains(tr, "--accept-rebill=1.230000") { + t.Errorf("the consent is not the capped sum: %v", tr) + } + for _, a := range TranslateArgs("/w", false, "", false, 0, nil) { + if a == "--resnapshot" || a == "--accept-rebill" || strings.HasPrefix(a, "--accept-rebill=") { + t.Errorf("an ungranted consent reached the argv: %v", a) + } + } + if slices.Contains(TranslateArgs("/w", false, "", true, 0, nil), "--accept-rebill=0.000000") { + t.Error("a zero consent rendered a flag: zero means no consent") + } +} diff --git a/platform/internal/runner/translate_resnapshot_live_test.go b/platform/internal/runner/translate_resnapshot_live_test.go new file mode 100644 index 00000000..b6f0cd84 --- /dev/null +++ b/platform/internal/runner/translate_resnapshot_live_test.go @@ -0,0 +1,175 @@ +package runner + +import ( + "encoding/json" + "fmt" + "net" + "net/http" + "os" + "os/exec" + "path/filepath" + "regexp" + "strings" + "testing" + "time" + + "textmachine/platform/internal/ingest" +) + +// The P10 mine, reproduced against the REAL engine (D39.165 §3; workflow errata 28.08-и): a bank +// correction moves the snapshot, and the engine's guard stops a CONTINUING run loudly unless +// `--resnapshot` travels — which this platform did not pass until P10. The state must be the +// errata's: the edit jobs EXIST before the correction (a finished book is the flagship case) — +// against fresh jobs the guard is silent and the reproduction is green without the fix, proving +// nothing. That precondition is therefore ASSERTED, not assumed: the no-flag run must die, and die +// naming the flag. +// +// Free of provider keys and of paid calls: the pipeline is the deployment's local $0 pair, served +// by an in-test OpenAI-compatible stub (the P9 live-probe's provider, ported); the guard itself +// fires before any model call. +// +// Mutation caught: TranslateArgs dropping either consent flag; the guard notice changing shape. +func TestTheSnapshotGuardIsLoudWithoutTheFlagsAndPassesWithThem(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 snapshot-guard mine is not reproduced against a real engine") + } + // The deployment's models.yaml points the $0 pair at this fixed address; a busy port means + // another stand is live on this host — skip loudly rather than fight it. + ln, err := new(net.ListenConfig).Listen(t.Context(), "tcp", "127.0.0.1:11434") + if err != nil { + t.Skipf("the local provider address is busy (another stand?): %v", err) + } + srv := &http.Server{Handler: http.HandlerFunc(fakeProvider)} + go func() { _ = srv.Serve(ln) }() + defer srv.Close() + + dir := t.TempDir() + writeProbeBook(t, tpl, dir, "bk_MINEPROBE") + // Chapter 1 carries the term (its units re-render after the correction and re-translate at $0 + // stub prices); chapter 2 does not (its units are the $0 re-pin the flag permits). + source := "第一章 山路\n\n方源走在山路上,方源看着远方的云。\n\n第二章 夜色\n\n老人在夜色中叹息,风吹过山谷。\n" + if err := os.WriteFile(filepath.Join(dir, "source.txt"), []byte(source), 0o644); err != nil { + t.Fatal(err) + } + ceiling := []string{"--ceiling-usd", "1.000000"} + run := func(args []string) (int, string) { + cmd := exec.CommandContext(t.Context(), bin, args...) + cmd.Dir = dir + out, _ := cmd.CombinedOutput() + return cmd.ProcessState.ExitCode(), string(out) + } + // Prime: a full run through BOTH waves. After it the edit jobs exist — the errata's state. + if code, out := run(TranslateArgs(dir, false, "", false, 0, ceiling)); code != 0 { + t.Fatalf("the priming run failed (%d): %s", code, lastLines(out, 6)) + } + // The bank moves through the live verb — the exact door P9 landed. + doc, err := ingest.EncodeDecisions("bk_MINEPROBE", []ingest.BankDecision{ + {Action: "approve", Src: "方源", Sense: "", Dst: "Фан Юань-P10", Kind: "name", Note: "P10 mine probe"}, + }) + 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) + } + if out, err := New(nil).BankApply(t.Context(), bin, dir, decisions, false); err != nil || + out.ExitCode != 0 || !out.Report.Changed { + t.Fatalf("the live correction did not land: %+v (%v; stderr %q)", out, err, out.Stderr) + } + // Post-fix invariant, half one — and the errata's precondition in the same breath: WITHOUT the + // flag the continuing run dies loudly, naming the flag. A green run here means the edit jobs + // did not exist and this fixture proves nothing about the mine. + code, out := run(TranslateArgs(dir, false, "", false, 0, ceiling)) + if code == 0 { + t.Fatal("no snapshot guard fired on a moved bank: the edit-job precondition is not met " + + "(errata 28.08-и) and this reproduction is degenerate") + } + if !strings.Contains(out, "--resnapshot") { + t.Fatalf("the guard's stop does not name --resnapshot (%d): %s", code, lastLines(out, 6)) + } + // Half two: the flags the platform now stores on the run row and renders through TranslateArgs + // carry the same run through — re-pins at $0, re-translates the term's units at stub prices. + // The engine names its pre-run restore point by the SECOND; on this $0 stub two translates fit + // in one, and the second refuses to overwrite the first's backup — wait the second out. + time.Sleep(1100 * time.Millisecond) + // The consent travels live too — the funded cap the platform renders (P10): the projection here + // is $0 (stub prices), well under the cap, so the engine accepts and the flag's whole live path + // is exercised rather than left to the argv pin alone. + if code, out := run(TranslateArgs(dir, false, "", true, 30_000, ceiling)); code != 0 { + t.Fatalf("with the consents the run still dies (%d): %s", code, lastLines(out, 6)) + } +} + +func lastLines(s string, n int) string { + lines := strings.Split(strings.TrimSpace(s), "\n") + if len(lines) > n { + lines = lines[len(lines)-n:] + } + return strings.Join(lines, "\n") +} + +// fakeProvider is the P9 live-probe's $0 stand-in (fake_provider.py), ported: prose for the +// translator and editor, TSV for the terminology roles. The probe proves the CHAIN, not quality. +func fakeProvider(w http.ResponseWriter, r *http.Request) { + var req struct { + Model string `json:"model"` + Messages []struct { + Role string `json:"role"` + Content string `json:"content"` + } `json:"messages"` + } + _ = json.NewDecoder(r.Body).Decode(&req) + var system, user strings.Builder + for _, m := range req.Messages { + switch m.Role { + case "system": + system.WriteString(m.Content) + case "user": + user.WriteString(m.Content) + } + } + content := proseFor(system.String(), user.String()) + resp := map[string]any{ + "id": "fake-1", "object": "chat.completion", "model": req.Model, + "choices": []map[string]any{{ + "index": 0, + "message": map[string]any{"role": "assistant", "content": content}, + "finish_reason": "stop", + }}, + "usage": map[string]any{"prompt_tokens": 100, "completion_tokens": max(1, len(content)/3), + "total_tokens": 100 + max(1, len(content)/3)}, + } + w.Header().Set("Content-Type", "application/json") + _ = json.NewEncoder(w).Encode(resp) +} + +var termKeys = regexp.MustCompile(`(?m)^key: (.+)$`) + +func proseFor(system, user string) string { + if strings.Contains(system, "терминолог") { + var b strings.Builder + for i, m := range termKeys.FindAllStringSubmatch(user, -1) { + fmt.Fprintf(&b, "%s\tЗаглушка-%d\t55\n", m[1], i+1) + } + return b.String() + } + if strings.Contains(system, "классиф") || strings.Contains(system, "класс (name | place | title | term)") { + var b strings.Builder + for _, m := range termKeys.FindAllStringSubmatch(user, -1) { + fmt.Fprintf(&b, "%s\tname\n", m[1]) + } + return b.String() + } + const filler = "Фан Юань неторопливо шёл по горной тропе, и ветер приносил запах трав. " + + "Старейшина посмотрел на него и тяжело вздохнул, вспоминая давние годы. " + + "В долине клубился туман, и где-то вдалеке кричала ночная птица. " + out := filler + for len(out) < len(user) { + out += filler + } + return out[:max(len(user), len(filler))] +} diff --git a/platform/internal/runs/bank.go b/platform/internal/runs/bank.go index 0cce7477..163d787a 100644 --- a/platform/internal/runs/bank.go +++ b/platform/internal/runs/bank.go @@ -173,6 +173,22 @@ func (s *Service) ApplyBankCorrections(ctx context.Context, in BankCorrectionsIn return BankReceipt{}, err } rec, err := s.bankVerdict(ctx, in, out) + if err == nil && !in.Preview && corrected(rec) { + // The fact «the bank moved» is stamped from the door's OWN receipt — the one thing that + // cannot be blind to what the door just did. Asking the engine's status here was the first + // edition's mistake (adversarial pass, K1): status projects the STORED memory, and a + // correction reaches it only when the next translate folds the bank in — right after an + // apply it honestly answers «nothing moved». + if ferr := s.Store.RecordBankMove(ctx, in.BookID); ferr != nil { + // The correction LANDED — the engine's files moved — but the fact did not reach the + // store, and the next run's consents are decided from it. «Re-send the same document» + // is the honest remedy and it CONVERGES: the engine answers the re-send with its byte + // no-op, whose receipt reads already_applied — which is exactly why corrected() accepts + // that state too, or a failed write here would never be retried into place. + s.log().ErrorContext(ctx, "a correction landed but the bank-move fact did not", "err", ferr) + return BankReceipt{}, ErrBankIncomplete + } + } 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 @@ -281,6 +297,22 @@ func (s *Service) bankReceipt(in BankCorrectionsInput, rep ingest.BankReport) (B return out, nil } +// corrected answers whether this receipt proves the bank carries the document: a write that +// changed files, or a retry whose every accepted state reads already_applied — the byte no-op of +// a document that landed before. Both prove the move; a no-op that neither changed nor re-found +// anything proves none. +func corrected(rec BankReceipt) bool { + if rec.Changed { + return true + } + for _, a := range rec.Accepted { + if a.State == "already_applied" { + return true + } + } + return false +} + // 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 diff --git a/platform/internal/runs/bank_test.go b/platform/internal/runs/bank_test.go index a5cdc9e3..b7e6efb4 100644 --- a/platform/internal/runs/bank_test.go +++ b/platform/internal/runs/bank_test.go @@ -387,3 +387,250 @@ func TestBootSweepsOrphanedCorrectionDocuments(t *testing.T) { t.Error("the sweep touched a file outside its mask") } } + +// The bank-move fact is stamped by an APPLY from the door's OWN receipt and never by a preview: +// the engine's status projection is blind to a correction until the next translate folds the bank +// in (errata 28.08-к), so the receipt — changed, or already_applied on a retry — is the only +// witness that cannot lie about what the door just did (P10 §3.1). +func TestACorrectionRecordsTheBankMoveAndAPreviewDoesNot(t *testing.T) { + f := newFixture(t, "10", 500) + fake := &fakeBankApplier{ + out: runner.BankApplyOutcome{ExitCode: 0, Exited: true, Report: okReport("projection"), Decoded: true}, + } + fake.out.Report.BookID = f.bookID(t) + f.svc.Bank = fake + if _, err := f.svc.ApplyBankCorrections(f.ctx, BankCorrectionsInput{ + UserID: "u1", BookID: f.bookID(t), Preview: true, + Decisions: []ingest.BankDecision{{Action: "decline", ID: "tm_1"}}, + }); err != nil { + t.Fatal(err) + } + book, err := f.svc.Store.ReadBookForRun(f.ctx, "u1", f.bookID(t)) + if err != nil { + t.Fatal(err) + } + if book.BankMoved { + t.Fatal("a PREVIEW recorded a bank move") + } + fake.mu.Lock() + fake.out.Report.Mode = "apply" + fake.mu.Unlock() + if _, err := f.svc.ApplyBankCorrections(f.ctx, BankCorrectionsInput{ + UserID: "u1", BookID: f.bookID(t), Preview: false, + Decisions: []ingest.BankDecision{{Action: "decline", ID: "tm_1"}}, + }); err != nil { + t.Fatal(err) + } + if book, err = f.svc.Store.ReadBookForRun(f.ctx, "u1", f.bookID(t)); err != nil || !book.BankMoved { + t.Fatalf("the apply left no fact: %+v (%v)", book, err) + } + // A retry of the same document answers changed:false with already_applied states — and still + // proves the move, which is what makes a failed fact-write retryable (corrected()). + retry := okReport("apply") + retry.Changed = false + retry.Accepted = []ingest.AcceptedDecision{{Index: 0, Action: "decline", ID: "tm_1", State: "already_applied"}} + retry.BookID = f.bookID(t) + if err := f.svc.Store.ClearBankMove(f.ctx, f.bookID(t)); err != nil { + t.Fatal(err) + } + fake.mu.Lock() + fake.out.Report = retry + fake.mu.Unlock() + if _, err := f.svc.ApplyBankCorrections(f.ctx, BankCorrectionsInput{ + UserID: "u1", BookID: f.bookID(t), Preview: false, + Decisions: []ingest.BankDecision{{Action: "decline", ID: "tm_1"}}, + }); err != nil { + t.Fatal(err) + } + if book, err = f.svc.Store.ReadBookForRun(f.ctx, "u1", f.bookID(t)); err != nil || !book.BankMoved { + t.Fatalf("an already_applied retry did not re-stamp the fact: %+v (%v)", book, err) + } +} + +// A start over a moved bank decides BOTH consents once, stores them on the row, and the spawn +// renders them — the argv is the admission's decision, stable across respawns (P10 §3.1). The cap +// is FUNDED: the run's own hold, never a projection (which does not exist at admission). +func TestAStartOverAMovedBankCarriesBothConsentsToTheSpawn(t *testing.T) { + f := newFixture(t, "10", 500) + 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)}) + 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 + if _, err := f.svc.ApplyBankCorrections(f.ctx, BankCorrectionsInput{ + UserID: "u1", BookID: f.bookID(t), Preview: false, + Decisions: []ingest.BankDecision{{Action: "decline", ID: "tm_1"}}, + }); err != nil { + t.Fatal(err) + } + run, err := f.svc.Start(f.ctx, StartRequest{UserID: "u1", BookID: f.bookID(t), CeilingChapters: 2}) + if err != nil { + t.Fatal(err) + } + if err := f.svc.Spawn(f.ctx, run.ID); err != nil { + t.Fatal(err) + } + f.runner.mu.Lock() + spec := f.runner.started[len(f.runner.started)-1] + f.runner.mu.Unlock() + joined := strings.Join(spec.Args, " ") + if !strings.Contains(joined, "--resnapshot") { + t.Errorf("the spawn carries no --resnapshot: %q", joined) + } + // Two chapters at the fixture's default rate: the consent IS the hold, funded by construction. + if !strings.Contains(joined, "--accept-rebill=0.060000") { + t.Errorf("the spawn's consent is not the run's own hold: %q", joined) + } +} + +// The fact is retired EXPLICITLY and only by success: a run that died on the guard (exit 1, $0) +// leaves it standing — so the next admission still carries the flags instead of looping the guard +// forever (adversarial K6) — and a ready resnapshot run clears it. +func TestAFailedRunKeepsTheFactAndAReadyRunRetiresIt(t *testing.T) { + f := newFixture(t, "10", 500) + 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)}) + 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 + if _, err := f.svc.ApplyBankCorrections(f.ctx, BankCorrectionsInput{ + UserID: "u1", BookID: f.bookID(t), Preview: false, + Decisions: []ingest.BankDecision{{Action: "decline", ID: "tm_1"}}, + }); err != nil { + t.Fatal(err) + } + f.stoppedRun(t, StartRequest{UserID: "u1", BookID: f.bookID(t), CeilingChapters: 2}, 0, + runner.Marker{Result: "exit-code", Code: "exited", Status: "1", At: f.now.Add(2 * time.Second)}) + book, err := f.svc.Store.ReadBookForRun(f.ctx, "u1", f.bookID(t)) + if err != nil { + t.Fatal(err) + } + if !book.BankMoved { + t.Fatal("a FAILED run retired the fact: the next run would die on the guard with no flags") + } + f.stoppedRun(t, StartRequest{UserID: "u1", BookID: f.bookID(t), CeilingChapters: 2}, 0, + runner.Marker{Result: "exit-code", Code: "exited", Status: "0", At: f.now.Add(3 * time.Second)}) + if book, err = f.svc.Store.ReadBookForRun(f.ctx, "u1", f.bookID(t)); err != nil || book.BankMoved { + t.Fatalf("a READY resnapshot run did not retire the fact: %+v (%v)", book, err) + } +} + +// The re-pass door (P10 §3.2, the deferred-projection form): on a book whose bank moved, a +// re-pass admission PASSES — a zero-chapter row whose hold is the whole book's chapter price +// («up to your hold»; untouched units come back at $0 and the difference is released) — and on a +// book without the move it refuses with its own word. +func TestTheRePassDoorAdmitsOnAMovedBankAndRefusesWithoutOne(t *testing.T) { + f := newFixture(t, "10", 5) + if _, err := f.svc.Start(f.ctx, StartRequest{UserID: "u1", BookID: f.bookID(t), RePass: true}); !errors.Is(err, ErrRePassUnavailable) { + t.Fatalf("a re-pass with no moved bank answered %v, want ErrRePassUnavailable", err) + } + 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)}) + 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 + if _, err := f.svc.ApplyBankCorrections(f.ctx, BankCorrectionsInput{ + UserID: "u1", BookID: f.bookID(t), Preview: false, + Decisions: []ingest.BankDecision{{Action: "decline", ID: "tm_1"}}, + }); err != nil { + t.Fatal(err) + } + run, err := f.svc.Start(f.ctx, StartRequest{UserID: "u1", BookID: f.bookID(t), RePass: true}) + if err != nil { + t.Fatalf("the re-pass door refused a moved bank: %v", err) + } + var chapters int + var hold int64 + if err := f.store.Pool().QueryRow(f.ctx, + `select ceiling_chapters from runs where id = $1`, run.ID).Scan(&chapters); err != nil { + t.Fatal(err) + } + if err := f.store.Pool().QueryRow(f.ctx, + `select amount_micro_usd from credit_ledger where kind = 'hold' order by id desc limit 1`). + Scan(&hold); err != nil { + t.Fatal(err) + } + if chapters != 0 { + t.Errorf("a re-pass bought %d chapters, want 0", chapters) + } + // Five chapters at the fixture's default rate: the whole book's price, held; the unspent part + // is released on settlement. + if hold != -150_000 { + t.Errorf("the re-pass hold is %d micro-USD, want the whole book's -150000", hold) + } +} + +// A RESUME over a moved bank grants the consents on re-open — a correction after a ceiling pause +// mid-edit is the mine's flagship shape — and they land on the ROW, where every later spawn of +// every attempt reads them (P10 §3.1). +func TestAResumeOverAMovedBankGrantsTheConsents(t *testing.T) { + f := newFixture(t, "10", 500) + runID := 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)}) + 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 + if _, err := f.svc.ApplyBankCorrections(f.ctx, BankCorrectionsInput{ + UserID: "u1", BookID: f.bookID(t), Preview: false, + Decisions: []ingest.BankDecision{{Action: "decline", ID: "tm_1"}}, + }); err != nil { + t.Fatal(err) + } + if _, err := f.svc.Resume(f.ctx, "u1", runID); err != nil { + t.Fatal(err) + } + var resnapshot bool + var consent int64 + if err := f.store.Pool().QueryRow(f.ctx, + `select resnapshot, accept_rebill_micro from runs where id = $1`, runID). + Scan(&resnapshot, &consent); err != nil { + t.Fatal(err) + } + // The funded cap: the run's whole budget, three chapters at the fixture's rate. + if !resnapshot || consent != 90_000 { + t.Fatalf("the resumed row carries resnapshot=%v consent=%d, want true/90000", resnapshot, consent) + } +} + +// A re-pass is bought again, not resumed (P10, adversarial K4): its budget is not chapter-derived, +// and an interrupted one leaves the fact standing — the purchase is simply available again. +func TestARePassIsBoughtAgainNotResumed(t *testing.T) { + f := newFixture(t, "10", 5) + 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)}) + 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 + if _, err := f.svc.ApplyBankCorrections(f.ctx, BankCorrectionsInput{ + UserID: "u1", BookID: f.bookID(t), Preview: false, + Decisions: []ingest.BankDecision{{Action: "decline", ID: "tm_1"}}, + }); err != nil { + t.Fatal(err) + } + run, err := f.svc.Start(f.ctx, StartRequest{UserID: "u1", BookID: f.bookID(t), RePass: true}) + if err != nil { + t.Fatal(err) + } + // The re-pass is interrupted: the row closes as stopped, the fact stays (only ready retires it). + if _, err := f.store.Pool().Exec(f.ctx, + `update runs set status = 'stopped', finished_at = $2, stop_requested_at = $2, settled_at = $2 + where id = $1`, run.ID, f.now.Add(2*time.Second)); err != nil { + t.Fatal(err) + } + if _, err := f.svc.Resume(f.ctx, "u1", run.ID); !errors.Is(err, ErrNotResumable) { + t.Fatalf("resuming a re-pass answered %v, want ErrNotResumable (bought again)", err) + } + if _, err := f.svc.Start(f.ctx, StartRequest{UserID: "u1", BookID: f.bookID(t), RePass: true}); err != nil { + t.Fatalf("re-buying the interrupted re-pass was refused: %v", err) + } +} diff --git a/platform/internal/runs/reconcile.go b/platform/internal/runs/reconcile.go index 66aa84e0..aa7dadb9 100644 --- a/platform/internal/runs/reconcile.go +++ b/platform/internal/runs/reconcile.go @@ -659,6 +659,18 @@ func (s *Service) finish(ctx context.Context, l pgstore.LiveRun, m runner.Marker return nil } s.log().InfoContext(ctx, "run finished", "run", l.RunID, "status", status, "result", m.Result) + if l.Resnapshot && status == "ready" { + // The bank-move fact is retired EXPLICITLY, and only here: a run that carried the consents + // and finished clean has demonstrably walked the correction into the text. Every other + // ending — failed, stopped, paused — leaves the fact standing, so the next admission still + // carries the flags instead of dying on the engine's snapshot guard (adversarial K6: a + // timestamp predicate let a failed run retire the fact and loop the guard forever). The + // write is outside FinishRun's transaction on purpose: if it fails, the stale fact costs + // one harmless --resnapshot on the next run, never a dead one. + if err := s.Store.ClearBankMove(ctx, l.BookID); err != nil { + s.log().ErrorContext(ctx, "the finished re-snapshot run could not retire the bank-move fact", "err", err) + } + } // Settling immediately rather than waiting for the next sweep: the hold is the account's money // and every second it stays reserved is a second the user cannot start another book. The text the // run produced is NOT materialized here: `FinishRun` recorded that debt in the transaction that @@ -1110,9 +1122,29 @@ func (s *Service) reopen(ctx context.Context, l pgstore.LiveRun, from liveness) if s.Cfg.AllowEngineVersionChange && s.Cfg.EngineBinary != "" { version = &s.Cfg.EngineBinary } + // The re-pass consents, granted on the USER's resume alone (P10 §3.1): a resume of a run the + // bank moved under respawns into the engine's snapshot guard, and without the flags it dies + // loudly after the money moved. The cap is the run's own full budget — funded consent, the same + // figure Start grants (a projection to cap against does not exist here; errata 28.08-к). The + // reconciler's restarts (fromALiveRun) pass zeroes and change nothing: their run was admitted + // with its consents already on the row, and argv is the admission's decision, never a sweep's + // re-derivation. + resnapshot, consent := false, money.MicroUSD(0) + if from == fromAFinishedRun { + book, err := s.Store.ReadBookForRun(ctx, l.UserID, l.BookID) + if err != nil { + return pgstore.LiveRun{}, deferred, err + } + if book.BankMoved { + resnapshot = true + consent = s.Pricing.Ceiling(l.CeilingChapters) + } + } next, err := s.Store.RestartRun(ctx, pgstore.RestartInput{ - RunID: l.RunID, - AttemptID: l.AttemptID, + RunID: l.RunID, + AttemptID: l.AttemptID, + Resnapshot: resnapshot, + AcceptRebill: consent, // The reconciler's own guard against a stale snapshot: if another pass finished this run // while this one was settling, it must not be brought back to life. OnlyIfLive: bool(from), @@ -1236,6 +1268,14 @@ func (s *Service) Resume(ctx context.Context, userID, runID string) (pgstore.Run } return pgstore.Run{}, fmt.Errorf("%w: a newer run of this book exists, and the book's screens follow that one", ErrNotResumable) } + if l.CeilingChapters == 0 { + // A re-pass is bought again, not resumed. Its budget is not chapter-derived, so reopen's + // remaining-budget arithmetic has nothing to compute (Ceiling(0)=0 read as «spent» — the + // adversarial pass's K4, which also sealed the door); and nothing needs resuming: an + // interrupted re-pass leaves the fact standing (only a READY resnapshot run retires it), so + // the purchase is simply available again. + return pgstore.Run{}, fmt.Errorf("%w: a re-pass is bought again rather than resumed", 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 76c8a63e..12184766 100644 --- a/platform/internal/runs/reconcile_test.go +++ b/platform/internal/runs/reconcile_test.go @@ -705,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, "", false, 0, 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 fbb1e45f..8037b6be 100644 --- a/platform/internal/runs/runs.go +++ b/platform/internal/runs/runs.go @@ -136,6 +136,11 @@ func (s *Service) log() *slog.Logger { // read and this call, because a hold taken for another book lowers what is left. var ErrCeilingOutOfBounds = errors.New("runs: the requested ceiling is outside the bounds") +// ErrRePassUnavailable — a re-pass was asked for on a book that has nothing to re-pass: the bank +// has not moved since the last run, or its move touched no already-paid unit. The remedy is the +// options read, which is what says whether a re-pass exists to buy (P10 §3.2). +var ErrRePassUnavailable = errors.New("runs: the book has nothing to re-pass") + // ErrRunnerIncomplete is a DEPLOYMENT that cannot start runs — the exit-marker command is missing, // so a unit that ended would have no way to say so and its hold would stay reserved forever. // @@ -225,6 +230,11 @@ type StartRequest struct { BookID string VerifyBank bool CeilingChapters int + // RePass buys the re-pass instead of chapters (P10, D39.165 §3): legal only when the options + // announced work (the bank moved and touched paid units), it admits a zero-chapter run whose + // hold is the materialized projection and whose bar walks the book (the re-pass form). The + // wire's carrier is the contract half's `re_pass` member; CeilingChapters is ignored with it. + RePass bool } // Start admits a run. @@ -265,6 +275,50 @@ func (s *Service) Start(ctx context.Context, in StartRequest) (pgstore.Run, erro if err != nil { return pgstore.Run{}, err } + // The re-pass consents, decided HERE — under the book lock, once — and stored on the run row + // so every spawn of every attempt renders the same argv (P10 §3.1). A run admitted over a moved + // bank needs both engine flags: without --resnapshot the snapshot guard stops it loudly AFTER + // the hold, and without a capped --accept-rebill the consent gate does the same on any + // correction worth more than the ~half-cent threshold. The cap is FUNDED: the run's own hold — + // «re-pay no more than this run may spend at all» — because a projection to cap against does + // not exist at admission time (the engine's status is blind to a correction until the next + // translate folds the bank in; errata 28.08-к) and an unfunded cap let a run burn its whole + // budget on re-billing (adversarial K7). Never the bare blanket form. + resnapshot, consent := false, money.MicroUSD(0) + if book.BankMoved { + resnapshot = true + consent = s.Pricing.Ceiling(in.CeilingChapters) + } + if in.RePass { + // The re-pass purchase (P10 §3.2, the deferred-projection form): «a re-pass costs up to + // your hold». What it would actually cost is unknowable at admission (errata 28.08-к), so + // the hold is the honest ceiling of the work bought — the whole book's chapter price, of + // which untouched units come back at $0 and the difference is released on settlement. The + // consent cap equals the hold: funded by construction, strictly positive because a + // translatable book has chapters. + if !book.BankMoved { + return pgstore.Run{}, ErrRePassUnavailable + } + offset, err := journalSize(book.Workdir) + if err != nil { + return pgstore.Run{}, err + } + hold := s.Pricing.Ceiling(max(book.ChapterCount, 1)) + started, err := s.Store.StartRun(ctx, pgstore.StartRunInput{ + UserID: in.UserID, + BookID: in.BookID, + CeilingChapters: 0, + Ceiling: hold, + Now: s.now(), + Resnapshot: true, + AcceptRebill: hold, + }, offset, s.enqueue) + if err != nil { + return pgstore.Run{}, err + } + s.log().InfoContext(ctx, "re-pass admitted", "run", started.ID) + return started.Run, nil + } bounds := s.Pricing.Scale(acct.Balance, book.ChaptersLeft) if in.CeilingChapters < bounds.Min || in.CeilingChapters > bounds.Max { // WHY the scale does not fit decides what the client can offer next: bounds that moved are @@ -289,6 +343,8 @@ func (s *Service) Start(ctx context.Context, in StartRequest) (pgstore.Run, erro CeilingChapters: in.CeilingChapters, Ceiling: s.Pricing.Ceiling(in.CeilingChapters), Now: s.now(), + Resnapshot: resnapshot, + AcceptRebill: consent, }, offset, s.enqueue) if err != nil { return pgstore.Run{}, err diff --git a/platform/internal/runs/spawn.go b/platform/internal/runs/spawn.go index a258120d..b06994dd 100644 --- a/platform/internal/runs/spawn.go +++ b/platform/internal/runs/spawn.go @@ -167,7 +167,8 @@ func (s *Service) spec(l pgstore.LiveRun, bookCap money.MicroUSD) (runner.Spec, // 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), + Args: runner.TranslateArgs(l.Workdir, l.VerifyBank && !l.BankReleased, s.Cfg.KeysFile, + l.Resnapshot, l.AcceptRebill, ceiling), Workdir: l.Workdir, Env: engineEnv(engineStreamID(l.RunID, l.AttemptNo)), ExitMarker: marker,