From 0a680a3d7f1dfa1427076deb3d8aa030de5a6395 Mon Sep 17 00:00:00 2001 From: heaven Date: Mon, 31 Aug 2026 12:02:54 +0300 Subject: [PATCH] Land platform pack P12 with contract minor 0.9.0: the bank-stop bypass is gone, a chapter counts by the pass current for it, and the boundary now judges prose too --- docs/PROGRESS.md | 7 +- docs/README.md | 2 +- docs/architecture/05-decisions-index.md | 3 +- docs/architecture/05-decisions-log.md | 32 +- docs/architecture/14-api-contract/README.md | 50 ++- .../architecture/14-api-contract/openapi.yaml | 42 ++- .../prompts}/PLATFORM_P12_SESSION_PROMPT.md | 0 platform/BACKLOG.md | 2 +- platform/README.md | 21 +- platform/deploy/README.md | 22 ++ platform/docs/DEFECT_REGISTER.md | 83 +++-- platform/docs/STACK_DECISIONS.md | 95 ++++- platform/docs/platform-PROGRESS.md | 347 ++++++++++++++++++ platform/internal/books/books_test.go | 142 ++++++- platform/internal/books/parse.go | 56 ++- platform/internal/books/render_test.go | 6 +- platform/internal/httpapi/capabilities.go | 2 +- platform/internal/httpapi/idempotency.go | 23 +- platform/internal/httpapi/idempotency_test.go | 19 + platform/internal/httpapi/project.go | 1 + platform/internal/httpapi/v0.go | 1 + platform/internal/ingest/manifest.go | 38 ++ platform/internal/pgstore/books.go | 23 +- platform/internal/pgstore/idempotency.go | 33 +- platform/internal/pgstore/idempotency_test.go | 15 + .../pgstore/lastrun_live_priority_test.go | 228 ++++++++++++ platform/internal/pgstore/migrations.sha256 | 3 + .../migrations/00029_drop_bank_released.sql | 25 ++ .../pgstore/migrations/00030_shape_epoch.sql | 51 +++ .../00031_drop_run_wave_counters.sql | 33 ++ platform/internal/pgstore/perf_test.go | 17 + platform/internal/pgstore/readmodel.go | 165 +++++++-- platform/internal/pgstore/readmodel_test.go | 80 +++- platform/internal/pgstore/recut_test.go | 2 +- platform/internal/pgstore/runs.go | 64 ++-- platform/internal/pgstore/runs_test.go | 34 +- .../pgstore/session_slide_seam_test.go | 123 +++++++ platform/internal/pgstore/shape_epoch_test.go | 246 +++++++++++++ platform/internal/pgstore/sink.go | 92 +++-- platform/internal/pgstore/sink_test.go | 99 +++-- platform/internal/runs/bank.go | 28 +- platform/internal/runs/bank_test.go | 49 +++ platform/internal/runs/control_test.go | 261 +++++++++++-- platform/internal/runs/reconcile.go | 149 ++++++-- platform/internal/runs/reconcile_test.go | 43 ++- platform/internal/runs/runs.go | 24 ++ platform/internal/runs/spawn.go | 22 +- .../internal/runs/stuck_settlement_test.go | 95 +++++ platform/internal/runs/sweep_test.go | 36 +- 49 files changed, 2700 insertions(+), 334 deletions(-) rename docs/{ => archive/prompts}/PLATFORM_P12_SESSION_PROMPT.md (100%) create mode 100644 platform/internal/pgstore/lastrun_live_priority_test.go create mode 100644 platform/internal/pgstore/migrations/00029_drop_bank_released.sql create mode 100644 platform/internal/pgstore/migrations/00030_shape_epoch.sql create mode 100644 platform/internal/pgstore/migrations/00031_drop_run_wave_counters.sql create mode 100644 platform/internal/pgstore/session_slide_seam_test.go create mode 100644 platform/internal/pgstore/shape_epoch_test.go diff --git a/docs/PROGRESS.md b/docs/PROGRESS.md index aa68f122..343c7c8a 100644 --- a/docs/PROGRESS.md +++ b/docs/PROGRESS.md @@ -1,6 +1,6 @@ # Журнал прогресса -> **⟶ ТЕКУЩЕЕ СОСТОЯНИЕ** (на 2026-08-31, голова D39.179 — КУРС: движок и платформа до «работает и отдаёт результат», фронт ЗАМОРОЖЕН и P7 его НЕ размораживает (D39.147). **ОЧЕРЕДЬ №21** (единственный носитель — здесь; роль передана 31.08, №20 закрыт нотой передачи D39.177): **(1) РАЗРЫВ ЦИКЛА: книга не отдаётся файлом — ни одной стороной.** Канон обещает `createExport`/`getExport`, платформа их НЕ монтирует (смонтировано 15 операций из 20; нет также `getRun`, `updateBook`, `deleteBook`), а движок писателя не имеет вовсе — `tmctl export` пишет только в stdout (JSON `tm-export-v1` либо текст с аудит-баннерами). ⚠ Прежняя редакция обосновывала это грепом `epub|fb2|docx` («21 хит, все на стороне ингеста») — довод СНЯТ 30.08: он и был хрупким (сырой счёт хитов), и стал ложным после лендинга. Долг числится с P8 (`platform/BACKLOG.md` П-17), с тех пор легло пять паков и ни один его не взял. **Слово «отдаёт результат» сегодня ложно по букве** — ⚠ 30.08: ДВИЖКОВАЯ половина ЗАКРЫТА паком «писатель книги» (D39.175, `8adcb86`): `tmctl build` отдаёт EPUB 3 + чистый txt с шестью состояниями честности; открытой остаётся дверь платформы (п.4) · **(2) холодный прогон движка на текущем HEAD — ПРОМТ ВЫДАН 31.08** (`BACKEND_COLDRUN_V16_SESSION_PROMPT.md`, D39.179), прогон НЕ НАЧАТ. Гейт строк 16/154/157/160/198/202/216, слово владельца 20.08: с 27.08 легло ~15 нот (шов, стоп банка флажком, схема v16, класс отказа 15, объёмный потолок, гейт схемы) и НИ ОДНА не проверена деньгами. ⚠ **Цена: слово владельца 31.08 — ≈$0.60, потолок $0.80, ДЕСЯТЬ глав ПОЛНОЙ цепью включая редакторскую волну.** Прежняя «≈$0.05» СНЯТА как протухшая: пере-пин DeepSeek 16.08 даёт множитель 4.47 на том же заходе (D39.179 п.1). ⚠ **ГЕЙТ КЛЮЧЕЙ СНЯТ словом владельца 30.08** (D39.176 п.6), но конвенционного `.env` НЕТ НИ У ОДНОЙ книги корпуса — прогон обязан передавать `--keys-file` абсолютным путём; заполнены только `projects/textmachine/backend/.env` и `.../eval/.env`. Для п.3 нужен ДРУГОЙ файл (путь платформы `TM_PLATFORM_ENGINE_KEYS_PATH`) — вопрос владельцу открыт · **(3) живой сквозной прогон через API платформы** — форма манифеста, сайдкара банка и карта причин флагов сочинены платформой САМОСТОЯТЕЛЬНО и первыми настоящими данными движка не проверялись · **(4) дверь выдачи** — строго ПОСЛЕ (3) (⚠ слово владельца 30.08: дверь ВСЕГДА строит файл — отказ юзеру не отдаётся, неполная книга уходит с пометкой, т.е. дверь зовёт `tmctl build --partial`; отказ по умолчанию остаётся операторской ручкой CLI; D29.1(б) «fail-closed» читать как «никогда молча» — честность несут пометки) · **(5) проводка `--max-units`** — снятие гейта, а не стройка: механизм в движке стоит (D39.170), живое основание гейта — `PD-422` · **(6) разморозка фронта** — ПОСЛЕ (4): зеркало отстало на ШЕСТЬ миноров (`frontend/docs/api-contract/openapi.yaml` — `0.2.3` против канона `0.8.0`, и первый из них — ЛОМАЮЩИЙ 0.3.0), 16 операций против 20; отставание ратифицировано D39.142 п.5 на время фриза и две ручки разъехались, размораживать раньше значит перегенерировать типы дважды. ⚠ **ДВА ЗАГЕЙЧЕННЫХ ТРИГГЕРА НАЖАТЫ И НЕ ОТРАБОТАНЫ** (`13-tech-debt-anchors.md` §Б-108): `go.work` — у платформы свой модуль есть, файла в корне НЕТ; вендор-сверка маппинга эффорта `deepseek-v4-pro` — сработала 13.08, носители противоречат, закрывается ТОЛЬКО вендор-докой. ⚠ **ЧТО В ПРЕЖНЕЙ ОЧЕРЕДИ БЫЛО НЕВЕРНО, проверено деревом 30.08:** пункт про `sqlc` отдельной сессией ИСПОЛНЕН (D39.172, `63fcee5`); «работа полигона в дереве НЕЗАКОММИЧЕНА, читать `git status -- eval/`» ОПРОВЕРГНУТО — она лежит коммитом `28c2d0f` на ветке `polygon`, запушена, в `main` не влита, а названная команда возвращает пустоту и уводит в вывод «работы нет»; «развязка git с origin» ОПРОВЕРГНУТА — `main` и `origin/main` на одном коммите. +> **⟶ ТЕКУЩЕЕ СОСТОЯНИЕ** (на 2026-08-31, голова D39.180 — КУРС: движок и платформа до «работает и отдаёт результат», фронт ЗАМОРОЖЕН и P7 его НЕ размораживает (D39.147). **ОЧЕРЕДЬ №21** (единственный носитель — здесь; роль передана 31.08, №20 закрыт нотой передачи D39.177; ⚠ **пак P12 ПРИНЯТ И ЗАЛЕНДЖЕН 31.08 — D39.180**, вместе с контрактным минором **0.9.0**; `PD-425` закрыт им же): **(1) РАЗРЫВ ЦИКЛА: книга не отдаётся файлом — ни одной стороной.** Канон обещает `createExport`/`getExport`, платформа их НЕ монтирует (смонтировано 15 операций из 20; нет также `getRun`, `updateBook`, `deleteBook`), а движок писателя не имеет вовсе — `tmctl export` пишет только в stdout (JSON `tm-export-v1` либо текст с аудит-баннерами). ⚠ Прежняя редакция обосновывала это грепом `epub|fb2|docx` («21 хит, все на стороне ингеста») — довод СНЯТ 30.08: он и был хрупким (сырой счёт хитов), и стал ложным после лендинга. Долг числится с P8 (`platform/BACKLOG.md` П-17), с тех пор легло пять паков и ни один его не взял. **Слово «отдаёт результат» сегодня ложно по букве** — ⚠ 30.08: ДВИЖКОВАЯ половина ЗАКРЫТА паком «писатель книги» (D39.175, `8adcb86`): `tmctl build` отдаёт EPUB 3 + чистый txt с шестью состояниями честности; открытой остаётся дверь платформы (п.4) · **(2) холодный прогон движка на текущем HEAD — ПРОМТ ВЫДАН 31.08** (`BACKEND_COLDRUN_V16_SESSION_PROMPT.md`, D39.179), прогон НЕ НАЧАТ. Гейт строк 16/154/157/160/198/202/216, слово владельца 20.08: с 27.08 легло ~15 нот (шов, стоп банка флажком, схема v16, класс отказа 15, объёмный потолок, гейт схемы) и НИ ОДНА не проверена деньгами. ⚠ **Цена: слово владельца 31.08 — ≈$0.60, потолок $0.80, ДЕСЯТЬ глав ПОЛНОЙ цепью включая редакторскую волну.** Прежняя «≈$0.05» СНЯТА как протухшая: пере-пин DeepSeek 16.08 даёт множитель 4.47 на том же заходе (D39.179 п.1). ⚠ **ГЕЙТ КЛЮЧЕЙ СНЯТ словом владельца 30.08** (D39.176 п.6), но конвенционного `.env` НЕТ НИ У ОДНОЙ книги корпуса — прогон обязан передавать `--keys-file` абсолютным путём; заполнены только `projects/textmachine/backend/.env` и `.../eval/.env`. Для п.3 нужен ДРУГОЙ файл (путь платформы `TM_PLATFORM_ENGINE_KEYS_PATH`) — вопрос владельцу открыт · **(3) живой сквозной прогон через API платформы** — форма манифеста, сайдкара банка и карта причин флагов сочинены платформой САМОСТОЯТЕЛЬНО и первыми настоящими данными движка не проверялись · **(4) дверь выдачи** — строго ПОСЛЕ (3) (⚠ слово владельца 30.08: дверь ВСЕГДА строит файл — отказ юзеру не отдаётся, неполная книга уходит с пометкой, т.е. дверь зовёт `tmctl build --partial`; отказ по умолчанию остаётся операторской ручкой CLI; D29.1(б) «fail-closed» читать как «никогда молча» — честность несут пометки) · **(5) проводка `--max-units`** — снятие гейта, а не стройка: механизм в движке стоит (D39.170), живое основание гейта — `PD-422` · **(6) разморозка фронта** — ПОСЛЕ (4): зеркало отстало на ШЕСТЬ миноров (`frontend/docs/api-contract/openapi.yaml` — `0.2.3` против канона `0.8.0`, и первый из них — ЛОМАЮЩИЙ 0.3.0), 16 операций против 20; отставание ратифицировано D39.142 п.5 на время фриза и две ручки разъехались, размораживать раньше значит перегенерировать типы дважды. ⚠ **ДВА ЗАГЕЙЧЕННЫХ ТРИГГЕРА НАЖАТЫ И НЕ ОТРАБОТАНЫ** (`13-tech-debt-anchors.md` §Б-108): `go.work` — у платформы свой модуль есть, файла в корне НЕТ; вендор-сверка маппинга эффорта `deepseek-v4-pro` — сработала 13.08, носители противоречат, закрывается ТОЛЬКО вендор-докой. ⚠ **ЧТО В ПРЕЖНЕЙ ОЧЕРЕДИ БЫЛО НЕВЕРНО, проверено деревом 30.08:** пункт про `sqlc` отдельной сессией ИСПОЛНЕН (D39.172, `63fcee5`); «работа полигона в дереве НЕЗАКОММИЧЕНА, читать `git status -- eval/`» ОПРОВЕРГНУТО — она лежит коммитом `28c2d0f` на ветке `polygon`, запушена, в `main` не влита, а названная команда возвращает пустоту и уводит в вывод «работы нет»; «развязка git с origin» ОПРОВЕРГНУТА — `main` и `origin/main` на одном коммите. > - ⚠ **ОБЯЗАТЕЛЬСТВА ЛЕНДИНГА P9 ИСПОЛНЕНЫ — но НЕ лендингом** (снято оркестратором №20 30.08, сверено статусами регистра): `PD-400`.2 → `accepted-risk`, `PD-401` и `PD-166` → `fixed` перевёл аудит доков **D39.167** (`373ccc7`, 28.08), чья находка №1 и говорит: «регистр расходился с МОИМИ ЖЕ актами лендинга — четыре строки». Прежние два буллета висели здесь двое суток после исполнения, и проверочная команда одного из них сама возвращала `fixed`. **Урок в норму приёмки: акт лендинга, объявивший закрытие строки, обязан быть сверен статусом В ТОТ ЖЕ заход.** > - ⚠ **ЛЕНТА КОНТРАКТА за 27–28.08, свёрнуто аудитом 28.08 (D39.167):** **0.5.0** — D39.161 (снос отменённой пер-термной модели подписи + дверь правок банка) · **0.6.0** — D39.162/D39.163 (сквозная полоса прогресса; `Progress.stage` ВТОРЫМ и последним исключением границы) · **0.7.0** — D39.166 (покупка пере-прохода членом `RunRequest.re_pass`). Прожитые дампы приёмки P9 и P10 вынесены в `archive/PROGRESS-2026-08-backend.md` и в тела нот; здесь остаётся только лента версий. > - ⚠ **27.08: ВЫДАН ПРОМТ КОНТРАКТНОГО МИНОРА** (`CONTRACT_MINOR_SESSION_PROMPT.md`, пункт 2б очереди D39.156). **ВОПРОС ЗАКРЫТ СЛОВОМ ВЛАДЕЛЬЦА 27.08 (D39.160):** сквозная полоса прогресса (строка **200**) из минора ИСКЛЮЧЕНА и едет с паком (2в). Причина механическая: гейт платформы требует, чтобы объявленная деплоем версия равнялась канону, поэтому канон и деплой обязаны совпасть В МОМЕНТ ЛЕНДИНГА; снос двери этому не мешает (платформа свою половину снесла 22.08), а смена семантики прогресса мешает — сервер её не считает и не будет до пака (2в), и `0.5.0` у старого прогресса был бы ровно той ложью, против которой гейт стоит. Прежнее «одним куском» покупалось тем, что иначе фронт перегенерируется дважды, — но фронт ЗАМОРОЖЕН и перегенерируется один раз независимо от числа миноров. Промт ВЫДАН: прошёл оба рубежа роли, девять находок опровергателя применены. @@ -13,7 +13,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 §примечания). -> - **ЕДИНЫЙ БЭКЛОГ — секция «Бэклог» ниже** (одна таблица, единственный трекер; каждая петля обязана иметь диспозицию: решено / отложено-с-записью / отклонено; ведёт оркестратор). **СЧЁТ ОЧЕРЕДИ на 30.08 (скриптом по таблице — `python3 docs/scripts/counts.py`; обновлять при каждом лендинге):** всего **184** строк · зона бэкенд **87** строго / **124** широко (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-лога) — ⚠ ЖИВА, испр. 30.08: D39.157 п.6 объявил класс закрытым 24.08, но носителя в таблице не было, и 27.08 строка заведена ЗАНОВО со свежим замером; построена только первая половина лекарства (структурная сверка владельца ноты в `counts.py`)**; **236 ЗАКРЫТА D39.175** — движковая половина построена, платформенный остаток несут очередь №20 п.4 и `platform/BACKLOG.md` П-17; **238–239 — финальное ревью пака «писатель книги», D39.175; 240 — ревизия P9 оркестратором №20 30.08, находка Д3 без носителя; 241 — самопротиворечие канона на дочитанной книге, находка сессии P12, эррата 30.08-б; 242 — разъехавшаяся форма реестра нот; 243 — НЕПРИМЕНЁННЫЙ остаток того же аудита, 19 находок**) (⚠ колонки счётчик читает С КОНЦА — испр. D39.116) · **блокеров очереди 0** (236 закрыта D39.175), платные прогоны разблокированы · «скоро» **57** (перечень — грепом по таблице, рукописный список снят D39.126) · гейт-строки эксп-22: 55·153 (плюс 150 — руки владельца); строка 5 — остаток гейчен ре-пробой 74, носитель события теперь строка 188 (аудит D39.140); остальное «когда-нибудь». ⚠ Счёт — НИЖНЯЯ граница долга, не потолок (разбор — легенда таблицы ниже). +> - **ЕДИНЫЙ БЭКЛОГ — секция «Бэклог» ниже** (одна таблица, единственный трекер; каждая петля обязана иметь диспозицию: решено / отложено-с-записью / отклонено; ведёт оркестратор). **СЧЁТ ОЧЕРЕДИ на 30.08 (скриптом по таблице — `python3 docs/scripts/counts.py`; обновлять при каждом лендинге):** всего **185** строк · зона бэкенд **87** строго / **124** широко (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-лога) — ⚠ ЖИВА, испр. 30.08: D39.157 п.6 объявил класс закрытым 24.08, но носителя в таблице не было, и 27.08 строка заведена ЗАНОВО со свежим замером; построена только первая половина лекарства (структурная сверка владельца ноты в `counts.py`)**; **236 ЗАКРЫТА D39.175** — движковая половина построена, платформенный остаток несут очередь №20 п.4 и `platform/BACKLOG.md` П-17; **238–239 — финальное ревью пака «писатель книги», D39.175; 240 — ревизия P9 оркестратором №20 30.08, находка Д3 без носителя; 241 — самопротиворечие канона на дочитанной книге, находка сессии P12, эррата 30.08-б; 242 — разъехавшаяся форма реестра нот; 243 — НЕПРИМЕНЁННЫЙ остаток того же аудита, 19 находок**) (⚠ колонки счётчик читает С КОНЦА — испр. D39.116) · **блокеров очереди 0** (236 закрыта D39.175), платные прогоны разблокированы · «скоро» **57** (перечень — грепом по таблице, рукописный список снят 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). ## 📌 СОСТОЯНИЕ ПАКОВ (снимок 30.08, оркестратор №20; приёмки P11 и пака «деньги» — записи оркестратора №19 от 29.08) @@ -180,7 +180,7 @@ | 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) | | 201 | **Движковое «Глава N» доезжает читателю ВНУТРИ текста, обходя дисциплину `heading: null`** (линза шва P7, пере-прочитано оркестратором): `backend/internal/pipeline/export.go:334`=`ApplyHeading` (испр. оркестратором №20 30.08: якорь уезжал с `:230` и с `:266` — код растёт) приклеивает детерминированный порядковый к экспортному тексту первой юнит-главы (`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:556`=`различать причины отказа клиент не может по замыслу`) · **(д) ИСПОЛНЕН** — клетка приложения А заполнена `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(а) [ЗАКРЫТА D39.166]**. Ратификации здесь НЕ нужно; остаётся снять обход одной боевой строкой (`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:1610`=`Карта «причина → КОД контракта`). Реально пуста другая клетка: соответствие причин движка кодам замечаний, и правила её заполнения в компаньоне уже написаны (фраза по ДОККОММЕНТУ `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:559`=`различать причины отказа клиент не может по замыслу`) · **(д) ИСПОЛНЕН** — клетка приложения А заполнена `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(а) [ЗАКРЫТА D39.166]**. Ратификации здесь НЕ нужно; остаётся снять обход одной боевой строкой (`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:1654`=`Карта «причина → КОД контракта`). Реально пуста другая клетка: соответствие причин движка кодам замечаний, и правила её заполнения в компаньоне уже написаны (фраза по ДОККОММЕНТУ `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` | @@ -203,6 +203,7 @@ | 241 | **Канон противоречит сам себе на ДОЧИТАННОЙ книге, и принятая правка банка умирает молча** (названо телом D39.165 §3 28.08, носителя не получило; заведено оркестратором №20 30.08 по находке сессии P12). Две фразы: правка банка «takes effect on the NEXT run» (`docs/architecture/14-api-contract/openapi.yaml:504`=`takes effect on the NEXT run`) против «finished work is not bought twice» + подъём потолка только у ПРИОСТАНОВЛЕННОЙ книги (`:590`=`finished work is not bought twice`). У дочитанной книги следующего прогона купить нечем: пользователь правит банк, получает квитанцию об успехе — и правка не доезжает ни до одной строки никогда. ⚠ Смежное лечение уже существует и не связано с этой дырой: пере-проход ПОКУПАЕТСЯ членом `RunRequest.re_pass` (канон 0.7.0, D39.166) — то есть механизм есть, а фраза канона его не признаёт для дочитанной книги. Решать формой: либо канон признаёт пере-проход входом для дочитанной книги, либо дверь правок отказывает такой книге ЧЕСТНО вместо ложной квитанции | контракт (+платформа) | скоро (следующее касание контракта) | контрактный минор + пин на дочитанной книге | D39.165 §3, эррата 30.08-б, находка сессии P12 | | 242 | **Реестр D-нот: колонки «статус» и «тело» разъехались на 74 строках из 224** (замер аудита доков 30.08). У свежих нот в «статусе» стоит заметка «ЖИВОЕ: …», а слово `жив` уехало в «тело»; шапка таблицы описывает другой порядок. Гейт `counts.py --check` этот класс НЕ ловит по устройству (сверяет полноту номеров, не форму ячеек). Лечение — не ручная перекладка (дороже пользы и легко внести новую ложь), а машинное: правило формы в `counts.py` + разовый прогон приведения. Пока не сделано — в легенде реестра стоит правило чтения (испр. 30.08) | оркестратор | когда-нибудь | правило в `counts.py` + разовое приведение | аудит доков 30.08 | | 243 | **Остаток аудита доков 30–31.08: 19 удержанных находок НЕ применены** (аудит — 5 линз + верификатор на каждую, 57 находок, 55 удержаны, 36 применены сменой №20). Перечень: ~~**arch-3**~~ **ЗАКРЫТ 31.08 (D39.179 п.5)** — и был вдвое больше заявленного: не 13 якорей, а **51** (34 уехали, 1 мёртв, 16 верны), а дороже адресов оказались ЛОЖНЫЕ КЛЕЙМЫ (цитата контракта, которой нет; потерянный третий член `Usage`; живой К-13 при закрытом D39.132; метод `Settle`, которого нет). Чинилась ФОРМА, не номера: сверяемых гейтом по содержимому стало 89→127 · **arch-5** (правило языка знает две корзины, `reader.txt` — третья) · **arch-7** (§2 закона шва: дисциплина флока не описывает новый глагол-писатель) · **arch-9** (реестр загейченных триггеров не знает нажатых 30.08) · **arch-10** (`16-events-emitter.md`: словарь и `StreamVersion` разошлись с кодом обеих зон) · **arch-11** (`10-prompt-architecture.md`: описывает несуществующий с пака-15 ключ `prompts:`) · **arch-12** (шапка `09-target` сама зовёт себя неполной — строку под таблицей слоёв надо внести) · **contract-8** (эррата 15.08-в в шапке D-лога указывает на промт, уехавший в архив) · **contract-10** (`Export.failure_code` «becomes an enum with the first built format» — формат построен, условие наступило) · **progress-6/7/8/10/11/12/13/14/15** (мелочь CURRENT-STATE и строк бэклога: протухшие «запушена»/«строка 200»/якорь `miner_emit.go`/дубль 238 внутри 160 и пр.). ⚠ Отчёт аудита жил в песочнице сессии №20 и умирает с ней — здесь ЕДИНСТВЕННЫЙ носитель списка; воспроизведение = повторный аудит (5 агентов, ~2 часа) | оркестратор | скоро | пройти списком; major (arch-3) первым — док про деньги врёт указателями | аудит доков 30–31.08, сдача смены №20 | +| 244 | **Гейт якорей на пре-коммите ломается всякий раз, когда зона правит КОД и СВОЙ реестр одним незакоммиченным деревом** — и врёт в обе стороны. Механика: хук судит ЗАКОММИЧЕННЫЙ док против РАБОЧЕГО кода, поэтому пере-нацеленные, но не залёнджённые якоря читаются как битые. Замерено на приёмке P12 31.08: у оркестратора хук напечатал 13 битых якорей `platform/docs/DEFECT_REGISTER.md`, у зоны `counts.py --lint` на том же дереве дал **0**; права зона — в HEAD `internal/runs/runs.go:323`, в рабочем `:347`, и токен по новому адресу на месте. ⚠ Обратная сторона опаснее и не замерена: якорь, ставший битым ПО-НАСТОЯЩЕМУ, тем же механизмом может оказаться зелёным. Свойство общее, не про этот пак | оркестратор | когда-нибудь | либо судить обе стороны из одного состояния (`--from-index` уже есть — проверить, покрывает ли он случай), либо печатать, что сравниваются РАЗНЫЕ состояния | приёмка P12 оркестратором №21, 31.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 1fddb7a7..6a628ad2 100644 --- a/docs/README.md +++ b/docs/README.md @@ -23,7 +23,7 @@ |---|---|---| | Оркестратор | [ORCHESTRATOR_SESSION_PROMPT.md](ORCHESTRATOR_SESSION_PROMPT.md) | роль и нормы; счётчик роли — CURRENT-STATE | | Бэкенд | [BACKEND_COLDRUN_V16_SESSION_PROMPT.md](BACKEND_COLDRUN_V16_SESSION_PROMPT.md) | **ВЫДАН 31.08** (**D39.179**): холодный прогон движка на схеме v16 — ПЕРВЫЙ ПЛАТНЫЙ сквозной прогон на текущем HEAD. Санкция владельца: ≈$0.60, потолок $0.80, десять глав ПОЛНОЙ цепью включая редакторскую волну; прежняя «≈$0.05» снята как протухшая. Гейт строк **16·154·157·160·198·202·216**. Оба рубежа пройдены: механическая сверка 12 блоков + опровергатель в четыре линзы — **53 находки, 8 блокеров**, все применены. Прежний пак «писатель книги» ПРИНЯТ И ЗАЛЕНДЖЕН 30.08 (**D39.175**, `8adcb86`): `tmctl build` — EPUB 3 + чистый txt, шесть состояний честности, отказ exit 16 / `--partial`. Открыто строками **238/239**; вопросы владельцу (лестница D25.1 · лейбл К-3 · RTL) — CURRENT-STATE | - | Платформа | [PLATFORM_P12_SESSION_PROMPT.md](PLATFORM_P12_SESSION_PROMPT.md) | **ВЫДАН 30.08** сессии `textmachine-main-b5` (эхо принято; имя не переживает рестарт — адрес сверять `ListAgents`). Пак P12 «долги под ногами»: 7 открытых major (PD-425 деньги · PD-402/405 ложь экрана · PD-403/404 повторная продажа глав — решение владельца D39.165 §2 релеем · PD-424 замороженный холд) + снятие обхода `--verify-bank` (обязательство D39.158) + PD-369/431/432 + гигиена регистра. Оба рубежа пройдены: опровергатель (3 линзы + верификаторы) дал **21 удержанную находку**, все применены. Прежний пак `sqlc` — D39.172, промт в архиве: 40 запросов на типизированный слой, пин 1.31.1, `sqlc diff` в `make check`. ⚠ Решение принято НЕ доводом покрытия (3 из 42), а шестью ВЫЖИВШИМИ мутациями: `sqlgate` не видит Go-сторону вызова. Промт отработан — `archive/prompts/`. Открыто строками: `Touch` без проверки затронутых строк · `observe.go` неконвертируем | + | Платформа | активного НЕТ | пак **P12 «долги под ногами» ПРИНЯТ И ЗАЛЕНДЖЕН 31.08** (**D39.180**) вместе с контрактным минором **0.9.0**. Приёмка: 5 линз, 6 major / 16 minor / 0 блокеров, 8 дофиксов исполнены; числа пере-ранены оркестратором при полном условии хоста. Обязательство D39.158 (снятие обхода `--verify-bank`) закрыто целиком, живой пробой на двух ярусах. Регистр: открытых 97 против 107, major 2 против 7. Промт отработан — `archive/prompts/`. Открыто: `PD-424` сужена до терминальной ручки · новые `PD-433`/`PD-434`/`PD-435` | | Полигон | [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.8.0** ПРИНЯТ и заленджен 29.08 (**D39.169**) вместе с паком P11: кадр `session_ended` — отзыв сессии гасит открытый поток и НАЗЫВАЕТ причину; кадр СОЕДИНЕНИЯ, своего номера не потребляет. До него в тот же день — **0.7.0** (D39.166, пере-проход как член `RunRequest.re_pass`), **0.6.0** (D39.162/163) и **0.5.0** (D39.161). ⚠ Названная цена 0.7.0 СНЯТА 29.08: строка 231 закрыта паком «деньги» (D39.170) — читающий путь движка сворачивает банк, поэтому смета доезжает до покупателя ДО покупки | diff --git a/docs/architecture/05-decisions-index.md b/docs/architecture/05-decisions-index.md index e015cf37..5016959e 100644 --- a/docs/architecture/05-decisions-index.md +++ b/docs/architecture/05-decisions-index.md @@ -1,4 +1,4 @@ -# Реестр D-нот — карта актуальности v2 (D1–D39.179; +# Реестр D-нот — карта актуальности v2 (D1–D39.180; > ⚠ **СЛАБОЕ МЕСТО, КОТОРОЕ БЫЛО ЗДЕСЬ (вписано 22.08, ЗАКРЫТО 24.08 — D39.157 п.6).** Колонка ТЕЛА > у нот D39.107…D39.123 говорила «жив», хотя тела уехали в слайс подрезкой D39.139; семнадцать строк @@ -241,3 +241,4 @@ | D39.177 | 31.08 | **Передача роли оркестратора №20 → №21.** Смена приняла пак «писатель книги» (D39.175) и закрыла продуктовый лист платформы (D39.176), выдала промт P12 и ратифицировала минор 0.9.0 по пингу сессии. Три урока: опровергатель промта окупился в третий и четвёртый раз (62 находки; самая дорогая — «копия книги» через симлинк мигрировала бы ОРИГИНАЛ) · долг без носителя переживает приёмки (две находки ревью P9 прожили двое суток без строки) · якорь по номеру в файл живой сессии заведомо мёртв. Четыре ошибки названы; ТРИ из них поймал ВЛАДЕЛЕЦ вопросами «ты уверен / ты дочитал / почему не закрыл». Аудит всех живых доков: 57 находок, 55 применены. | жив | ЖИВОЕ: очередь №21 в CURRENT-STATE; пять обязательств переходят; незакоммиченное дерево пака P12 — норма, лендить при приёмке | передача процесс нормы аудит | | D39.178 | 30.08 (записано 31.08) | **Слово владельца о двери выдачи**: дверь `createExport` ВСЕГДА строит файл, отказ пользователю не отдаётся — неполная книга уходит с пометкой (`tmctl build --partial`), отказ exit 16 остаётся операторской ручкой CLI; D29.1(б) fail-closed читать как «никогда МОЛЧА»; дверь сверяет `BuildReport` — предупреждение оператору, не читателю. Ратификация жила в CURRENT-STATE без грепаемого носителя — пробел закрыт аудитом доков. | жив | ЖИВОЕ: лестница D25.1 (ступень копии без пометки) не решена, ждёт владельца перед дверью | контракт продукт выдача | | D39.179 | 31.08 | **Слово владельца о цене холодного прогона**: ≈$0.60, потолок $0.80, десять глав ПОЛНОЙ цепью включая редакторскую волну; прежняя санкция «≈$0.05» снята как протухшая (пере-пин DeepSeek, множитель 4.47). Промт `BACKEND_COLDRUN_V16_SESSION_PROMPT.md` выдан — 53 находки двух рубежей применены. Норма формы указателей заострена: стабильна не ЗОНА, а ЧИСТЫЙ ФАЙЛ. `arch-3` закрыт: 51 якорь пере-проверен, 34 уехали, сверяемых по содержимому 89→127 | жив | ЖИВОЕ: прогон не начат; строки 16·154·157·160·198·202·216 гейчены им | деньги прогон доки якоря | +| D39.180 | 31.08 | **Пак платформы P12 «долги под ногами» ПРИНЯТ И ЗАЛЕНДЖЕН** вместе с контрактным минором **0.9.0**. Приёмка — 5 линз, 6 major / 16 minor / 0 блокеров, 8 дофиксов исполнены. Обязательство D39.158 (снятие обхода `--verify-bank`) закрыто целиком, живой пробой на двух ярусах. Регистр: открытых 97 против 107, major 2 против 7. Три урока: тихо-зелёное с заголовком против тела (D39.171); **граница контракта судит ПРОЗУ, а не только поля** — непрозрачность поля не спасает; строка, закрытая несуществующим пином | жив | ЖИВОЕ: PD-424 сужена до терминальной ручки · новые PD-433/434/435 · строка бэклога 244 | платформа контракт приёмка | diff --git a/docs/architecture/05-decisions-log.md b/docs/architecture/05-decisions-log.md index aa0e99dd..4e959fbf 100644 --- a/docs/architecture/05-decisions-log.md +++ b/docs/architecture/05-decisions-log.md @@ -1,4 +1,4 @@ -# Журнал решений оркестратора — контракт D1–D39.179 (живой файл: карта · эрраты · живые тела · голова D39.124+ (подрезка D39.139); тела закрытых эр — в слайсах `docs/archive/architecture/`, указатель ниже; реестр всех нот — `05-decisions-index.md`) +# Журнал решений оркестратора — контракт D1–D39.180 (живой файл: карта · эрраты · живые тела · голова 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+). @@ -1673,3 +1673,33 @@ head-1 это семнадцать бюджетируемых операций, 4. **Норма формы указателей заострена (уточняет D39.177 §2в): стабильна не ЗОНА, а ЧИСТЫЙ ФАЙЛ.** Номер строки ставится только туда, где `git status <цель>` показывает файл чистым; файл с незакоммиченной правкой — чей угодно, включая `docs/` — получает греп-указатель без номера. Ось «зона» была НЕВЕРНА и поймана опровергателем на моей же правке: `14-api-contract/openapi.yaml` лежит в зоне оркестратора и при этом двигается паком контракта 0.9.0. Носитель нормы — шапка `15-money-path.md`. 5. **`arch-3` (major остатка аудита, строка 243) ЗАКРЫТ, и он был вдвое больше заявленного.** Аудит 30–31.08 называл «13 якорей, 9 не в тот код»; пере-проверка панелью нашла **51 указатель: 34 уехали, 1 мёртв, 16 верны**. Дороже адресов оказались КЛЕЙМЫ: дословная цитата контракта, которой в контракте нет; потерянный третий обязательный член `Usage`; живой К-13 при закрытом D39.132; метод `Settle`, которого у движкового `Store` нет; «смена потолка не двигает снапшот» без оговорки про `edit_ceiling_out`. **Корень механический:** 9 якорей из 12 РОДИЛИСЬ верными (`085dbb9`) и сгнили дрейфом, а гейт молчал по построению — по содержимому сверялись 89 якорей из ~818. Поэтому чинилась ФОРМА, не номера: сверяемых по содержимому стало **127**. + +## D39.180 — ПАК ПЛАТФОРМЫ P12 «ДОЛГИ ПОД НОГАМИ» ПРИНЯТ И ЗАЛЕНДЖЕН вместе с контрактным минором 0.9.0 (31.08, оркестратор №21). ✅ + +**Приёмка:** пять линз (слепая к отчёту · охотник вне карты · исполнением · контракт · регистр) — **6 major, 16 minor, блокеров НЕТ**; восемь дофиксов заказаны и исполнены зоной, дерево на время прохода морожено дважды (D39.172). Механика пака — его отчёт в `platform/docs/platform-PROGRESS.md`, здесь не дублируется. + +**Числа пере-ранены оркестратором, не приняты со слов:** `make check` при полном условии хоста (DSN живого Postgres + движковый бинарь) — 18 пакетов, EXIT=0, 0 FAIL, линтер 0 issues, `sqlc diff` чист; `counts.py --lint` 0, `--check` сходится; регистр 435 строк, открытых 97 против 107 на входе, major **2** против 7. + +### 1. Что закрыто + +`PD-425` (детач пост-verb записи) · `PD-402` (порядок `lastRun` одной константой, 11 носителей) · `PD-405` · `PD-411` · `PD-369` · `PD-431`/`PD-432` · `PD-367`/`PD-213` · гигиена регистра. **Обязательство D39.158 исполнено целиком:** обход `--verify-bank` снят вместе с колонкой `bank_released` (миграция 00029), живой пробой предъявлен на обоих ярусах — движковом (exit 3 → повтор exit 0) и сквозном через демон. + +### 2. Три находки приёмки, стоящие переноса + +**(а) Тихо-зелёное там, где заголовок теста спорит со своим телом.** Выведенный первый сегмент полосы накрывал только момент стояния у стопа ⇒ всю ЧЕРНОВУЮ волну прогона с подписью пер-главные счётчики показывали НОЛЬ готовых глав. Три носителя утверждали обратное — два комментария и заголовок пина, чьё тело пинило ровно этот ноль. Класс D39.171. Лечение зоны сильнее заказанного: правило написано ОДИН раз константой `chapterUnitsDone` и читается обоими читателями, книго-широкая копия удалена. **Пере-проверено СОБСТВЕННЫМИ посадками оркестратора вне списка автора: три мутации — снятие терма `r.verify_bank`, всегда-черновик, подмена колонки — каждая даёт РОВНО ОДИН красный тест, новый пин.** ⚠ Терм `r.verify_bank`, который прежняя редакция объявляла непроверяемым батареей, теперь ловится. + +**(б) Граница контракта судит ПРОЗУ, а не только поля.** Минор 0.9.0 ратифицирован №20 как непрозрачный счётчик — и суждение верно, — но проза `ShapeEpoch` называла форму конвейера открытым текстом («the shape of the pipeline … an editing pass added or removed on the deployment», дважды). Канон запрещает это своими словами: «no phase or stage names… **The rule binds the PROSE too — every description here is compiled into the generated client's source**». Переформулировано формо-нейтрально оркестратором тем же лендингом. **Норма на будущее: непрозрачность ПОЛЯ не спасает, если проза рядом прозрачна.** Попутно снята битая ссылка ратифицированного канона на схему `BookStage`, которой в контракте нет вовсе, и подняты три примера `contract_version`, застрявшие на `0.8.0`. Провенанс минора — `14-api-contract/README.md` §2.22. + +**(в) Строка закрывалась пином, которого нет.** `PD-403` ссылалась на `TestARunOnADeploymentThatDroppedTheEditorCanReachOne` — греп по зоне пуст: имя пережило откат редакции. Зона оставила его ЭРРАТОЙ, а не удалила молча, — это дороже удаления, потому что останавливает следующую сессию. + +### 3. Что зона нашла в себе САМА, и это главное + +Адверсариальный проход сессии по СВОЕЙ ГОТОВОЙ работе дал три регрессии, включая прямое отступление от того, что она сама обещала пингом: полоса ПРОГОНА была переведена на эпоху, а эпоха присваивается и ходит в обе стороны — замерено на живом Postgres (4/4, затем 2/2 на двух попытках одного прогона при неизменной `structure_version`). Откачено, цена подписана строкой `PD-435`. **Без мандата промта на проход ИСПОЛНЕНИЕМ находка уехала бы в приёмку зелёной, с пингом зоны в качестве алиби** — формулировка самой сессии. Ещё дважды тот же класс поймал её на дофиксах: правя украденные док-комментарии, она украла ещё один, а правкой рантбука убила два якоря регистра — оба записаны в отчёт, а не только исправлены. + +### 4. Открыто и почему (подписано, не замолчано) + +`PD-424` сужена до терминальной РУЧКИ (новая разрушительная операторская поверхность над деньгами — дизайн своего размера) · `PD-410` пере-диспозиционирована направлением D39.165 §1 · `PD-420` — одна точка против «1 из 4» в самой строке · `PD-423` — диагноз не установлен. Новые строки пака: **PD-433** (непокрытая гоночная ветвь), **PD-434** (ресинк не материализует прогресс), **PD-435** (остаток `PD-403`). Вес `PD-435` оставлен `minor` с доводом В САМОЙ строке: денег он не двигает — врёт дробь и подпись, а не оплата; диспозиция оркестратора — принять довод, переоценка не меняет работу. + +### 5. Строка бэклога 244 — свойство гейта, найденное приёмкой + +Гейт якорей на пре-коммите судит ЗАКОММИЧЕННЫЙ док против РАБОЧЕГО кода, поэтому ломается всякий раз, когда зона правит код и свой реестр одним незакоммиченным деревом. Замерено: хук оркестратора напечатал 13 битых якорей, `--lint` зоны на том же дереве — 0; права зона. ⚠ Обратная сторона опаснее и НЕ замерена: по-настоящему битый якорь тем же механизмом может оказаться зелёным. diff --git a/docs/architecture/14-api-contract/README.md b/docs/architecture/14-api-contract/README.md index 164ed7b5..c072eea2 100644 --- a/docs/architecture/14-api-contract/README.md +++ b/docs/architecture/14-api-contract/README.md @@ -3,7 +3,7 @@ батч 0.3.0 — D39.138, 16.08.2026, оркестратор №17; синк с платформой 0.4.0 — 20.08.2026, контрактная сессия; РАТИФИЦИРОВАН D39.152, 20.08.2026, оркестратор №18) -СТАТУС: РАТИФИЦИРОВАН как контракт API v0 по 0.8.0 включительно (D39.169; перечень миноров и их +СТАТУС: РАТИФИЦИРОВАН как контракт API v0 по 0.9.0 включительно (D39.169; перечень миноров и их провенанс — ниже по файлу, здесь НЕ дублируется). Нормативная поверхность — openapi.yaml РЯДОМ. ⚠ Зонная копия frontend/docs/api-contract/ ВРЕМЕННО ОТСТАЁТ (0.2.3 при каноне 0.8.0) — ратифицировано D39.142 п.5 на время фриза фронта; синк байт-в-байт + перегенерация типов = @@ -52,7 +52,7 @@ cmp-сверка обязательна (D39.138 п.3). > сверку со стандартами, разобранные альтернативы). При расхождении по ФОРМЕ побеждает YAML; > при вопросе «почему так» — этот файл. > -> **Статус: РАТИФИЦИРОВАН по 0.8.0 включительно.** 0.2.0 (D39.115, 08.08) · 0.2.1 (D39.123, 09.08) · +> **Статус: РАТИФИЦИРОВАН по 0.9.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) + дверь правок @@ -65,7 +65,10 @@ cmp-сверка обязательна (D39.138 п.3). > **0.8.0 (D39.169, 29.08) — кадр `session_ended`: отзыв сессии гасит открытый поток и НАЗЫВАЕТ > причину, вместо молчаливого обрыва, который клиент отвечает переподключением в `401`. Кадр > СОЕДИНЕНИЯ: несёт id последнего исторического кадра и своего номера не потребляет, поэтому вход -> заново продолжает ровно с места остановки. Приехал ОДНИМ лендингом с платформенным паком P11**. +> заново продолжает ровно с места остановки. Приехал ОДНИМ лендингом с платформенным паком P11** · +> **0.9.0 (ратифицирован №20 31.08 по пингу зоны ДО стройки, заленджен №21 с паком P12) — ВТОРАЯ +> граница пересчёта `chapters_done` и непрозрачный `Book.shape_epoch`, которым она себя называет; +> провенанс — §2.22**. > Дом канона — этот каталог; > `frontend/docs/api-contract/openapi.yaml` — байт-зеркало. > @@ -922,6 +925,47 @@ the original request was never applied». Каждый `POST /books` созда --- + +### 2.22. Вторая граница пересчёта и `shape_epoch` (0.9.0) — ✓ выведено из построенного сервера + +**Что.** У `chapters_done` было ОДНО событие, легитимно пересчитывающее счёт, — пере-нарезка книги, и +она называла себя `structure_version`. Событий оказалось ДВА: счёт пересчитывается и когда деплой +меняет, что для главы значит «готово». Второе событие получило свою координату — `Book.shape_epoch`, +целое, растущее. + +**Почему координата, а не молчание.** Без неё клиент видит скачок `chapters_done` в обе стороны и не +может отличить легитимный пересчёт от ошибки сервера: «никогда не движется назад» переставало быть +правдой, а замены ему не было. Инвариант переписан точно: счёт не движется назад **внутри одной пары** +(`structure_version`, `shape_epoch`); через любую из двух границ — пересчитывается, и клиент +пере-читает, а не считает это ошибкой. + +**Почему это НЕ третье исключение границы (D39.163).** Поле НЕПРОЗРАЧНО по построению: целое, которое +сравнивают с предыдущим, и оно сообщает только ЧТО поколение сменилось. Оно не отвечает на вопрос +«как переводится книга» — ни имени фазы, ни стадии, ни модели. Ратификация №20 (31.08) стоит именно на +этом различении, и он честно записал его как СВОЁ суждение, а не как факт. + +⚠ **И на приёмке это суждение чуть не было пробито ПРОЗОЙ.** Первая редакция минора описывала событие +границы словами «the shape of the pipeline … an editing pass added or removed on the deployment» — то +есть называла фазу конвейера открытым текстом, дважды. Граница запрещает это прямо и своими же +словами: «no phase or stage names… **The rule binds the PROSE too — every description here is compiled +into the generated client's source**» (§Boundaries). Поймано линзой контракта при приёмке P12, +переформулировано формо-нейтрально («когда деплой меняет, что для главы значит „готово“») оркестратором +№21 ТЕМ ЖЕ лендингом. **Урок для следующего минора: непрозрачность поля не спасает, если проза рядом +прозрачна** — граница судит и описания, потому что они компилируются в исходник клиента. + +⚠ Попутно тем же проходом снята битая ссылка ратифицированного канона: проза `ShapeEpoch` ссылалась на +схему `BookStage`, которой в контракте НЕТ ВОВСЕ (`grep -c BookStage` = 0 после правки; до неё +единственным вхождением была сама ссылка). Заменена на указатель формы, уже принятой в файле — «см. +**Boundaries**». И три примера `contract_version`, оставшиеся на `0.8.0`, подняты до `0.9.0`. + +**Механика — в зоне платформы:** пара колонок `books.shape_epoch` + `books.epoch_editor` (миграция +`00030_shape_epoch.sql`, бэкфилл `epoch_editor = edit_wave`, поэтому первая граница — только реальная +смена формы). ⚠ **Полоса ПРОГОНА на эпоху НЕ переведена:** зона попробовала и откатила своим же +адверсариальным проходом — эпоха присваивается и ходит в обе стороны, отчего полоса переставала быть +монотонной (замерено: один прогон читал 4/4, потом 2/2 на своих же двух попытках при неизменной +`structure_version`). Через эпоху считается ТОЛЬКО пожизненный счёт книги; остаток — открытая строка +регистра платформы `PD-435`. + ## 3. Зависимости: чтение → источник → строка бэклога **Правило, введённое 0.3.0 (Б-21): предупреждение о недостроенном ОБЯЗАНО нести номер строки diff --git a/docs/architecture/14-api-contract/openapi.yaml b/docs/architecture/14-api-contract/openapi.yaml index eb420d47..a47c3c06 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.8.0 + version: 0.9.0 summary: Ratified contract between the frontend and the TextMachine platform. description: | **RATIFIED contract.** Canonical copy: `docs/architecture/14-api-contract/`; @@ -1239,6 +1239,28 @@ components: anchor to that would restart them constantly. examples: [3] + ShapeEpoch: + type: integer + minimum: 0 + description: | + Generation of the book's COUNT. It moves when the deployment changes what "finished" means + for a chapter — the one event besides re-cutting that legitimately recomputes + `chapters_done` and `chapter_count`'s remainder. + + OPAQUE, and a client must treat it as such: an integer to compare with the last one it saw, + never a fact to interpret. It says only THAT the generation changed, never how the book is + translated — what a deployment does to a chapter is not part of this contract, and a member + that leaked it would be a third exception to a boundary that has two (see **Boundaries**: + there are two, and there is no third). A larger value is not a breaking change and needs no + version of its own. + + What a client MUST do when it sees a new value: re-read the book's counters. A jump in + `chapters_done` across a shape epoch is the count being recomputed on a legitimate boundary, + not a server walking a counter backwards — the same relationship `structure_version` has with + a re-cut. Distinct from `StructureVersion`, which is about which chapters EXIST: a shape epoch + moves no chapter boundary, so it invalidates no cursor, no pair identifier and no term window. + examples: [1] + NextCursor: type: [string, 'null'] description: | @@ -1280,7 +1302,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.8.0'] + examples: ['0.9.0'] language_pairs: type: array description: | @@ -1521,6 +1543,7 @@ components: - character_count - added_at - note_count + - shape_epoch properties: id: { $ref: '#/components/schemas/Id' } revision: @@ -1544,6 +1567,7 @@ components: Why the book was rejected; meaningful only while `status` is `rejected`, and `null` everywhere else — including on a rejected book whose reason the service cannot name. structure_version: { $ref: '#/components/schemas/StructureVersion' } + shape_epoch: { $ref: '#/components/schemas/ShapeEpoch' } chapter_count: type: integer minimum: 0 @@ -1554,9 +1578,13 @@ components: description: | Chapters fully translated — "finished" as `Progress` defines it, the last pass this deployment gives a chapter. Against `chapter_count` this is the book's own progress — - what a library row shows — and it never moves backwards WITHIN one `structure_version`; - cutting the book again recomputes both numbers. The bar of a RUNNING run is - `Run.progress`, which measures what that run bought. + what a library row shows — and it never moves backwards WITHIN one pair of + (`structure_version`, `shape_epoch`). TWO events recompute both numbers, and each has its + own coordinate to announce itself by: cutting the book again, and the deployment + changing what "finished" means for a chapter. Across + either boundary the count is recomputed and may jump in either direction; a client + re-reads rather than treating the jump as an error. Within one pair it only grows. The bar + of a RUNNING run is `Run.progress`, which measures what that run bought. ⚠ It is also the number a service reads to decide **how much of the book is left to buy**, so a count that cannot reach `chapter_count` keeps offering work already finished. @@ -2642,8 +2670,8 @@ components: properties: contract: type: string - description: Contract version this deployment serves, e.g. `0.8.0`. - examples: ['0.8.0'] + description: Contract version this deployment serves, e.g. `0.9.0`. + examples: ['0.9.0'] EventStatus: allOf: diff --git a/docs/PLATFORM_P12_SESSION_PROMPT.md b/docs/archive/prompts/PLATFORM_P12_SESSION_PROMPT.md similarity index 100% rename from docs/PLATFORM_P12_SESSION_PROMPT.md rename to docs/archive/prompts/PLATFORM_P12_SESSION_PROMPT.md diff --git a/platform/BACKLOG.md b/platform/BACKLOG.md index 4b39dad0..ba452417 100644 --- a/platform/BACKLOG.md +++ b/platform/BACKLOG.md @@ -21,7 +21,7 @@ | П-9 | **Загрузка книги `POST /books` (multipart)** — единственная ручка контракта 0.2.0, которую пак раннера НЕ взял. Причина названа, а не умолчана: это не «ещё один хендлер», а хранилище (куда лёг файл, кто его чистит при отказе), разбор (статусы `uploading`/`parsing`/`rejected` существуют в схеме и ни одним писателем не заполняются), каталог проекта движка (кто пишет `book.yaml` — платформа его НЕ правит, D39.110) и пер-маршрутный потолок тела вместе с тестом, которого ждёт PD-72. Пока её нет, книги заводятся дев-инструментом `tmplatformctl book add`, и библиотека читает их так же, как читала бы загруженные: read-model один. ⚠ PD-72 закрывается ВМЕСТЕ с этой ручкой, не раньше — **ИСПОЛНЕНО P5 (11.08), с одной НАЗВАННОЙ половиной на ратификации:** ручка построена потоково (`r.MultipartReader`, пер-маршрутный потолок тела, свой дедлайн чтения), хранилище — каталог книги под `TM_PLATFORM_BOOKS_DIR`, статусы `uploading → parsing → not_started \| rejected` получили писателей, разбор зовёт `tmctl manifest`, PD-72 закрыт своим тестом. НЕ построено и вынесено вопросом в журнал: кто пишет стартовый `book.yaml` при интейке (D39.110 §2b) — шов `books.ErrNotProvisioned` назван, без конфигурации книга отказывает разбором **→ развилка РЕШЕНА D39.130: бета = форма Б (стройка = П-14); движковая форма В (`tmctl init`) = строка 170 единого** | Ф3, следующий пак | сессия P4 (границей промта D39.119 §5а), исполнено P5 | | П-10 | **Честная оценка «$/глава» от ДВИЖКА.** Сейчас ставка — константа платформы ($0.03, провенанс exp08 v2 через D30.4, `STACK_DECISIONS` §20), и это осознанная бета-мера: движковой поверхности оценки не существует, а выдумывать её запрещено. Ставка решает только ДЛИНУ шкалы (деньги защищены холдом и потолком движка), но на книге, которая заметно дороже или дешевле средней, шкала врёт пользователю о том, сколько глав он покупает. Нужна оценка от движка по конкретной книге — запрос уходит строкой ЕДИНОГО бэклога через оркестратора, не сюда | когда-нибудь (до первого платящего) | сессия P4 | | П-11 | **Наблюдаемость раннера.** Метрик и трейсинга в зоне нет вовсе (грепнуто: ни prometheus, ни otel, ни expvar, ни pprof), а у раннера появились величины, которые без них не видны: глубина очереди, возраст незакрытых холдов, число прогонов в карантине, отставание тейлера, длительность свипа. Сегодня всё это читается только глазами по логам и SQL. Связано с PD-115 (у зоны нет внешнего эталона ни по одной оси, кроме безопасности) — **ИСПОЛНЕНО P5 (11.08):** `prometheus/client_golang` v1.24.1 на отдельном слушателе `TM_PLATFORM_METRICS_ADDR`; глубина очереди · возраст самого старого открытого холда · карантины · отставание тейлера · книги в интейке · длительность свипа и счётчик недоведённых проходов · запросы и задержки по паттерну маршрута. Ось наблюдаемости получила внешний эталон (практики именования Prometheus + золотые сигналы, `ENGINEERING_STANDARDS` §2) — половина PD-115 | Ф3 | сессия P4, исполнено P5 | -| П-12 | **Квота интейка и ретеншен отклонённых книг.** `POST /books` даёт аутентифицированному аккаунту писать на диск оператора: один аплоад ограничен (64 МиБ), число аплоадов — ничем. Отклонённая по вине источника книга каталог теряет, отклонённая по вине деплоя — сохраняет намеренно, и не чистит их никто. Нужны лимит книг на аккаунт (сколько книг входит во фри-тир — продуктовая политика, не инженерная) и свип ретеншена. Строка регистра — PD-175 | до первого чужого пользователя | сессия P5 | +| П-12 | **Квота интейка и ретеншен отклонённых книг.** `POST /books` даёт аутентифицированному аккаунту писать на диск оператора: один аплоад ограничен (64 МиБ), число аплоадов — ничем. Отклонённая по вине источника книга каталог теряет, отклонённая по вине деплоя — сохраняет намеренно, и не чистит их никто. Строка регистра — PD-175 ⚠ **ПРОДУКТОВАЯ ПОЛОВИНА СНЯТА ЦЕЛИКОМ — D39.176 п.1 (слово владельца 30.08), диспозиция записана паком P12 31.08:** продуктовых КВОТ НЕТ и не будет, фри-тир-лимиты не проектируются, живём на покупке API и зачислениях из админки. То есть «сколько книг входит во фри-тир» — больше НЕ развилка и НЕ ждёт владельца; вопроса нет. Остаётся ИНЖЕНЕРНАЯ гигиена, решаемая зоной без чьего-либо слова: ретеншен отклонённых (`rejected`) книг · свип каталогов-сирот · потолок диска. Гейт у неё один и он не продуктовый — открытая регистрация, которой в закрытой бете нет. Диспозиция дописана СЮДА и в `PD-175`, потому что доставлена она была пингом, а пинг уезжает в архив | инженерная гигиена, до открытой регистрации | сессия P5; продуктовая половина снята D39.176 | | П-13 | **Оценка размера книги в символах — у ДВИЖКА.** `character_count` контракта платформа считает потоково на приёме (байты, не являющиеся продолжением UTF-8), что точно для UTF-8 и приблизительно для GB18030/UTF-16, которые движок принимает и декодирует сам. Манифест несёт `source_bytes` и `encoding`, но не число символов. Запрос уходит строкой ЕДИНОГО бэклога через оркестратора; строка регистра — PD-177 **→ строка 171 единого заведена 14.08** | когда-нибудь | сессия P5 | | П-15 | **ИСПОЛНЕНО P6 (14.08).** Платформенная половина шва эмиттера (движковая залендена D39.131, `9cfe080`; события уже пишутся — StreamVersion 1.1):** (а) маппинг exit-кодов движка: 4 = потолок (`paused`, не `failed`), 5 = graceful stop, 10–19 = полоса отказов — сегодня outcome() знает только 0/2/3 и читает `paused_reason` из stale-снапшота ДО drain (ceiling-стоп материализуется `failed`, Resume 409), а `refusedTheSource()` не смотрит на код вовсе (опечатка `book.yaml`/лок ведут к удалению загрузки — PD-196 у потребителя не закрыт); (б) фолд `unit_done` ПРИСВАИВАНИЕМ по тройке (chapter, unit, wave), не инкрементом — ратифицировано D39.131 п.2г (at-least-once; ⚠ уточнение дофикса P6: присваивание самолечит ПОВТОРНУЮ доставку, а не пропущенную — недодрейненный хвост новая попытка не перечитывает, её курсор начинается с размера журнала на допуске; строка PD-219); (в) принять `Ceiling.Scope` (book\|day — диагностика PD-157; resume дневного потолка не гонять в цикл) и outcome `ceiling\|stopped`; (г) dev-супервизор `outcomeOf` стейл (0/2/3); (д) порядок деплоя против деадлока v15 — строка 174 единого (решение ДО деплоя эмиттер-бинаря); watch: foreign-hello adoption при пре-существующем журнале в workdir | ИСПОЛНЕНО P6: (а) коды 4/5/10–19 через `ingest.OutcomeOf`, потолок = `paused` двумя каналами (PD-113 закрыт), интейк судит по коду (PD-196 закрыт), exit 5 без намерения = прерывание и перезапуск (PD-152 закрыт); (б) фолд присваиванием по `unit_resolutions` (миграция 00015); (в) StreamVersion 1.1, `Ceiling.Scope`, внутренняя причина `daily_ceiling`, резюм дневного потолка = 409 (PD-157 половина); (г) dev-супервизор читает ту же таблицу; watch воспроизведён и закрыт (PD-200); порядок деплоя — `deploy/README.md` + `tmplatformctl books --migratable` | приёмка D39.131 | | П-14 | **ИСПОЛНЕНО P6 (14.08).** Стройка интейка формы Б (ратификация D39.130): при создании книги платформа рендерит стартовый `book.yaml` из деплой-шаблона (шов `books.ErrNotProvisioned` уже назван P5); требования к полям — читать `backend/internal/config/book.go` как справочник, шаблон — деплой-артефакт оператора, не код; при приходе формы В (строка 170 единого, `tmctl init`) рендер заменяется вызовом движка | ИСПОЛНЕНО P6: рендер в ОДНОМ месте (`books.provision`), `TM_PLATFORM_BOOK_TEMPLATE`, работа с YAML-узлом (комментарии и незнакомые ключи оператора живы, значения — строки), `O_EXCL` (никогда не перезаписываем), битый шаблон = класс, который ЖДЁТ. Проверено настоящим `tmctl manifest` (3 главы) | ратификация D39.130 (приёмка P5) | diff --git a/platform/README.md b/platform/README.md index 8bf9ffa1..6b18c402 100644 --- a/platform/README.md +++ b/platform/README.md @@ -126,11 +126,22 @@ Prometheus; на контрактную поверхность они не вы | `internal/pricing`, `internal/money` | шкала глав и целые микро-доллары | | `internal/metrics`, `internal/reqid`, `internal/jobs`, `internal/config`, `internal/gates` | телеметрия, id запроса, очередь, конфигурация, гейты тулчейна | -Каналов движка ПЯТЬ, и других нет (⚠ до 29.08 фраза начиналась «три канала, и других нет» и тут же -перечисляла пять — счёт правился, а слово нет): `tmctl manifest --json` (структура), -`tmctl export --json --pairs` (ТЕКСТ пар — единственный носитель), `.bank.json` (банк), -`tmctl status --json` (канал ремонта; с лендингом `6ec9f8a` он ещё и оценивает пере-проход ДО -покупки) и `events.jsonl` (поток). Живой SQLite движка не читается никогда (D39.85). +Каналы движка, которыми зона пользуется, — СЕМЬ (⚠ счёт в этой фразе отставал от списка ТРИЖДЫ: до +29.08 она начиналась «три канала», перечисляя пять; на 30.08 говорила «пять» при шести; 31.08 — +«шесть» при семи, забыв `bank-apply`. Поэтому здесь больше нет слов «и других нет»: список ниже +СЧИТАЕТСЯ при правке, а притязание на исчерпывающесть снято как трижды не оправдавшееся — +исчерпывающий перечень с атомарностью живёт в `docs/STACK_DECISIONS.md`, «Инвентарь каналов движка», +и правится вместе с ним): `tmctl manifest --json` (структура), `tmctl export --json --pairs` +(ТЕКСТ пар — единственный носитель), `.bank.json` (банк), `tmctl status --json` (канал +ремонта; с лендингом `6ec9f8a` он ещё и оценивает пере-проход ДО покупки), `events.jsonl` (поток) и — +с лендингом пака «писатель книги» (D39.175) — `tmctl build` с файловым сайдкаром +`.book.`, чьи пути публикуются в `StatusArtifacts.book_files`. Шестой канал ещё никем +в зоне не потребляется: его будет читать дверь выдачи `createExport`/`getExport`, и она обязана +СТРОИТЬ (звать `tmctl build`), а не подбирать лежащий рядом файл — там копия прежней сборки +(D39.175 п.2). Седьмой — `tmctl bank-apply`: документ решений уходит движку файлом, отчёт +`tm-bank-report-v1` приходит обратно на stdout; это ЕДИНСТВЕННЫЙ канал, по которому зона ПИШЕТ в +проект движка, и потому у него своя дверь (`POST /books/{bookId}/bank/corrections`) и свой класс +отказов. Живой SQLite движка не читается никогда (D39.85). ## Чего здесь НЕ будет diff --git a/platform/deploy/README.md b/platform/deploy/README.md index c90e349d..296b3659 100644 --- a/platform/deploy/README.md +++ b/platform/deploy/README.md @@ -196,6 +196,17 @@ Read-only команды движка отказывают файлу проек он неотличим от осторожности. Порядок ниже действует; читать его как ДЕЙСТВУЮЩИЙ, а не как «когда приедет». +⚠⚠ **ВТОРАЯ, НЕЗАВИСИМАЯ причина не выкатывать движок раньше платформы, дописана 31.08 (дофикс приёмки +P12): подъём ФОРМЫ МАНИФЕСТА движка требует платформенного билда, иначе выбывает КАЖДАЯ новая книга.** +Платформа с P12 сверяет `manifest_version` с известной ей формой (`ingest.KnownManifestVersion`, +зеркало `manifestVersion` движка) и на незнакомую отвечает НЕ-деструктивным классом +`parser_unavailable` — файл пользователя цел, и это осознанный выбор: без сверки переименованный ключ +декодируется в нули, а ноль глав интейк читает как «источник прочли, книги нет», то есть УДАЛЯЕТ +аплоад (`PD-213`). Но класс всё равно ТЕРМИНАЛЕН по бюджету попыток: движок впереди платформы ⇒ каждая +загруженная книга уходит в `rejected` после пяти попыток. Симптом — интейк массово отклоняет при +здоровом на вид движке; лечение — выкатить платформенный билд, знающий новую форму. Правило то же, что +у схемы хранилища: **сначала платформа, потом движок**, и оба конца этого правила теперь записаны. + Порядок (действующий). ⚠ Ключевое: **`migrate` гоняется НОВЫМ бинарём** — старый уводит файл в свою же схему, то есть не делает ничего, и деадлок остаётся. Поэтому бинарь кладётся ДО миграции, а `TM_PLATFORM_ENGINE_BIN` переключается ПОСЛЕ. @@ -285,6 +296,17 @@ tmplatformctl run abandon --run --reason "почему" [--release-hold] ПОСЛЕ действия оператора, и `tm_platform_oldest_open_hold_seconds` продолжал расти. Читалось это как «я сделал, не помогло». +⚠ **Оговорка, дописанная паком P12 30.08 (`PD-418`): ветвление `run abandon` идёт по +`runs.finished_at`, а не по наличию осиротевшей попытки.** Значит команда лечит `settling`-строку +только у прогона, который УЖЕ кончился. Живой прогон с нерассчитанной ПРЕДЫДУЩЕЙ попыткой ушёл бы в +живую ветку: осиротевший холд команда не тронет, а ответит про процесс. Сегодня это состояние +недостижимо — единственный не-тестовый путь ко второй открытой резервации, `reopen`, отказывается +стартовать следующую попытку, пока холд предыдущей открыт, — и пак P12 его достижимее НЕ сделал +(блокированная расплата теперь считается неудачей и видна, но рестарт по-прежнему не происходит, +`PD-424`). Оговорка стоит здесь, потому что документ иначе обещает оператору то, чего код не делает, +и разойдётся заметно, если этот инвариант когда-нибудь ослабнет. Долговечное лечение — ветвить по +НАЛИЧИЮ осиротевшей попытки вместо `finished_at`, как уже делает settling-ветвь `StalledRuns`. + **Строка `PHASE = settling` — та же команда, другой исход.** Прогон уже кончился, свипа, который довёл бы его расчёт, не будет никогда, поэтому `run abandon` закрывает деньги В СВОЕЙ транзакции независимо от флага: резервация закрывается, холд возвращается ЦЕЛИКОМ, прогон помечается diff --git a/platform/docs/DEFECT_REGISTER.md b/platform/docs/DEFECT_REGISTER.md index d0ba636c..9ed02cd0 100644 --- a/platform/docs/DEFECT_REGISTER.md +++ b/platform/docs/DEFECT_REGISTER.md @@ -25,21 +25,19 @@ | ID | Класс | Серьёзность | Где | Суть | Статус | Источник | |---|---|---|---|---|---|---| -| PD-402 | bug | **major** | `internal/runs/reconcile.go:1221` `Resume` (switch по статусу, «последний ли» не спрашивается), `internal/pgstore/readmodel.go:484` `lastRun` (`order by started_at desc`), `internal/pgstore/runs.go` `RestartRun` (`started_at` не пере-штампуется) | **Возобновление НЕ-последнего прогона книги ломает полосу и карточку сразу двумя путями.** (1) Полоса возобновлённого открывается на ЧУЖОЙ работе: базы не пере-снимаются (это правильно для последнего прогона), но между стопом и resume по книге легально прошёл ЦЕЛЫЙ другой прогон, и оба числителя уже накрутили его главы — на нажатие «продолжить» пользователь видит 100% при нуле собственной работы, клэмп режет только превышение единицы, не двойной счёт до неё. (2) `lastRun` выбирает строку по `started_at desc`, а RestartRun `started_at` не трогает — карточка книги, `derivedStatus` и КАЖДЫЙ progress-кадр живого прогона считаются по ЧУЖОМУ финишировавшему: экран показывает `ready` и замороженный бар, пока живой прогон тратит деньги невидимо. Найдено воркфлоу-ревью и подтверждено его overlay-прогонами (4 независимые линзы сошлись на одном корне); ответ на сам resume (`ReadRun` по id) при этом честный — клиент получает от одной ревизии книги два разных бара. Лечение — вопрос формы: запрет resume не-последнего ИЛИ live-приоритет в `lastRun`; решение следующего пака | open | воркфлоу-ревью P9 28.08 (линзы bar:monotonicity · bar:double-count · bar:baselines-x-migration · bar:stage-caption), диспозиция оркестратора 28.08: строкой | -| PD-403 | bug | **major** | `internal/pgstore/sink.go:415` `recordWaveShape` (`edit_wave` только растёт), `internal/pgstore/readmodel.go:388` `finishedUnits`, `internal/pgstore/books.go` `ReadBookForRun.ChaptersLeft` | **Книга с уже сложившимся `edit_wave=true` на деплое, где оператор УБРАЛ редактора, продаёт уже переведённые главы повторно — и ни один прогон больше не доводит полосу до единицы.** Безредакторный движок шлёт `editTotal=0`, но флаг не возвращается (`or $2` пишет только false→true); прогон делает 100% купленного черновика и закрывается `ready` на 50% со stage `editing`; `chaptersDone` (edit-колонка) мёрзнет, `ChaptersLeft` не падает — шкала покупки предлагает те же главы снова (симптом `PD-202`, записанной fixed этим самым флагом), а follow-up прогон над этой покупкой читает 0/C с первой секунды. Комментарий `sink.go:406-409` о собственном коде неверен обеими фразами (запрещённое направление называет разрешённым, «shows up as more chapters finished» не случается). Подтверждено overlay-прогонами воркфлоу. Корень продуктовый — смена формы конвейера на живой книге; лечение за решением владельца/оркестратора | open | воркфлоу-ревью P9 28.08 (линзы bar:stage-caption · bar:double-count · bar:monotonicity), диспозиция оркестратора 28.08: строкой | -| PD-404 | bug | **major** | `internal/pgstore/sink.go:415` `recordWaveShape`, `internal/pgstore/readmodel.go:388` `finishedUnits` (живой флаг), потребители `bookColumns.chaptersDone` и `ReadBookForRun.ChaptersLeft` | **Единственное разрешённое движение флага (false→true, оператор ДОБАВИЛ редактора) уводит `Book.chapters_done` НАЗАД в пределах одного `structure_version` — то, что канон запрещает прямо.** Книга с начерченным заделом: карточка 7/10; первое progress-событие прогона с редактором переворачивает флаг, `finishedUnits` переезжает на edit-колонку — 7 → 0 одной транзакцией при РАСТУЩЕЙ ревизии (клиент обязан отрисовать спад), `ChaptersLeft` раздувается обратно и шкала снова предлагает купить купленное. Класс `PD-316` (записана fixed) на непокрытом триггере: её пин ловит только admission, а не движение самого флага. Крайний случай той же арифметики: книга, начерченная ПОЛНОСТЬЮ под `false`, редактором НЕдочитываема ни одним действием API — `ChaptersLeft=0` не допускает прогон, а флип случается только в progress-событии допущенного прогона. Подтверждено воркфлоу на in-tree фикстуре (`sink_test.go:701`) | open | воркфлоу-ревью P9 28.08 (линзы race:flip-read-consistency · bar:stage-caption · bar:baselines-x-migration), диспозиция оркестратора 28.08: строкой | -| PD-405 | bug | **major** | `internal/pgstore/sink.go:219` (ложный комментарий-обоснование), `internal/books/parse.go` (инлайн-материализация после коммита `not_started`), `internal/pgstore/books.go` `BooksOwedReadModel` (`nothingIsRunning`) | **Прогон над книгой без материализованного дерева читает 0/total ВСЮ ЖИЗНЬ при исправно доезжающих progress-событиях — а комментарий, которым этот случай объявлен закрытым, описывает архитектуру, которой больше нет.** Окно штатное: интейк коммитит `not_started` ДО материализации; если она упала/отложилась и пользователь успел стартовать прогон, долг дерева заморожен до конца прогона (`nothingIsRunning`), `unitDone` находит 0 строк глав и выходит, вся полоса выведена из `chapters` → 0/total до `ready`. Комментарий `sink.go:219` «the counters the screen reads today come from the progress event» ложен — эти колонки не читает никто (носитель — `PD-411`). Достижимость ЗАМЕРЕНА логом живого стенда: на здоровом пути окно «`not_started` без строк `chapters`» живёт **0.018 с** (обе загрузки пробоя P9: `book parsed` → `the reading surface was refreshed`, 0.0179 и 0.0189 с) — опасная форма только УПАВШАЯ/отложенная материализация, и тогда длина окна = вся жизнь прогона. Лечение — честная привязка полосы к бездеревному окну (отказ старта до дерева ИЛИ полоса из progress-событий как раньше); НЕ легло фикс-раундом — открыта целиком, замер достижимости ратифицирован актом D39.162 | open | воркфлоу-ревью P9 28.08 (линзы bar:stage-caption · bar:double-count), диспозиция оркестратора 28.08: строкой, достижимость замером (акт D39.162) | -| PD-410 | bug | **major** | `internal/pgstore/readmodel.go:460` `draftWork` (и вся модель «купленный диапазон в обеих волнах»), контраст: движку передаётся ТОЛЬКО `--ceiling-usd` (`internal/runs/spawn.go` `bookCap`), draft-волна фанится по ВСЕМ чанкам книги (`backend/internal/pipeline/waverun.go`) | **Полоса и платёжная модель считают, что прогон работает над диапазоном «C глав от edit-фронта» в обеих волнах, а движок гонит волны последовательно от СВОИХ фронтов до денег — и на continuation над недочерновленной книгой (`draft_before ≥ chapters_before + C`) формула даёт `draftWork=0`: движок тратит весь потолок на черновики, полоса стоит 0/C весь прогон со stage `editing` на прогоне, который только черновит.** Зеркало дефекта, который `draftWork` чинил (тот закрывал «ready на 50%», этот открывает «0% при сделанной работе»). Это НЕ локальная ошибка формулы: любая полоса из (баз, C, D, E) где-то соврёт, пока платёжная модель («довести до конца C глав от `e0`») и движковый план работ («деньги в порядке волн от фронтов») не согласованы — лечение требует либо передавать движку план (диапазон/волны), либо выводить total полосы из движкового плана; `ceiling_chapters` сегодня до движка не доезжает вовсе. Архитектурное, ОТДЕЛЬНЫМ паком по слову оркестратора 28.08 («остановись и скажи»); подтверждено воркфлоу арифметикой на существующем зелёном пине (`TestARunOverADraftedBacklogOwesOnlyTheLastPass` держит `draftWork=0` в родственной точке) | open | воркфлоу-ревью P9 28.08 (линза bar:double-count), стоп-решение сессии P9 28.08: тянет архитектуру, не чинится в паке | -| PD-424 | bug | **major** | `internal/runs/reconcile.go` `reopen` (вердикт `deferred`) и `reconcileOne`, `internal/pgstore/runs.go` `StalledRuns`, `internal/pgstore/observe.go` | **ЖИВОЙ прогон с намертво заблокированной расплатой невидим на ВСЕХ поверхностях `PD-385` и не поддаётся `run abandon` — счётчик неудач не растёт НИКОГДА.** Цепь, воспроизведённая охотником приёмки на десяти проходах: юнит исчез без маркера → `restart` → `settle` возвращает `settlementBlocked, nil` (не ошибку) → `reopen` отказывается открыть новую попытку поверх незакрытого холда и возвращает вердикт `deferred` → `case deferred: return nil` → `reconcileOne` считает проход УСПЕШНЫМ и вдобавок зовёт `ClearRunDeferral`. Итог: `reconcile_failures` 0, `reconcile_after` NULL, `StalledRuns(5)` пуст, гейдж 0, холд заморожен, движок дёргается каждым проходом. Вывести из состояния может только пользователь, нажав Stop, — и никто ему об этом не говорит. ⚠ Это ТА ЖЕ болезнь, что `PD-384`/`PD-385`, но в ЖИВОЙ фазе, куда пак P11 не дошёл: он лечил вторую фазу (расплату) и её поверхности. ⚠ **Почему пак не взял её в себя (решение сессии, не пропуск):** лечение упирается в вопрос, которого нет ни в одной строке заказа, — чем должен считаться вердикт `deferred` для СЧЁТЧИКА: не-событием (как сейчас), успехом или неудачей. Это дизайн реконсиляционной фазы, а пак был про фазу расплаты; вмешаться в него по дороге значило бы «распухнуть вокруг самого срочного», против чего промт предупреждает прямо. Форма лечения, которую сессия предлагает: `reopen`, вернувший `deferred`, обязан сообщать `established=false` (тогда `ClearRunDeferral` хотя бы не стирает счёт), а сам блокированный расчёт — считаться неудачей ТОЙ фазы, которая им владеет | open | приёмка оркестратора №19 по паку P11 (охотник вне карты, воспроизведено 10 проходами) | -| PD-425 | bug | **major**, деньги | `internal/httpapi/bank.go:149` (`r.Context()`), `internal/runs/bank.go` `RecordBankMove` | **Дверь банковских коррекций теряет пост-verb факт НАВСЕГДА при обрыве клиента, и следующая покупка платит за это холдом.** Хендлер берёт `r.Context()`, и `RecordBankMove` пишется на нём же; клиент закрыл вкладку до ответа — контекст отменён, запись не легла, `bank_moved_at` остаётся NULL. Второго писателя у факта нет, свипа нет, то есть состояние вечное. Деньги дальше: `re_pass` отвечает 409, а ОБЫЧНЫЙ прогон допускается с `resnapshot=false` и убивается снапшот-гардом движка ПОСЛЕ взятия холда — холд взят, попытка потрачена впустую. ⚠ Средство, которое дверь называет, недостижимо по построению: она отвечает `503` со смыслом «клиент пере-шлёт», а запись падает именно потому, что клиента уже нет. ⚠ Шаблон лечения лежит В ЭТОЙ ЖЕ ЗОНЕ и здесь не применён: `internal/pgstore/idempotency.go:150` и `books.go:205,215` делают `WithTimeout(WithoutCancel(ctx), …)` ровно с этой мотивировкой. ⚠ Не предмет пака P11 (дверь построена паком P9/P10), поэтому заведено строкой, а не починено: правка денежного пути чужого пака требует своих пинов и своей приёмки | open | приёмка оркестратора №19 по паку P11 (охотник вне карты) | +| PD-410 | bug | **major** | `internal/pgstore/readmodel.go:460` `draftWork` (и вся модель «купленный диапазон в обеих волнах»), контраст: движку передаётся ТОЛЬКО `--ceiling-usd` (`internal/runs/spawn.go` `bookCap`), draft-волна фанится по ВСЕМ чанкам книги (`backend/internal/pipeline/waverun.go`) | **Полоса и платёжная модель считают, что прогон работает над диапазоном «C глав от edit-фронта» в обеих волнах, а движок гонит волны последовательно от СВОИХ фронтов до денег — и на continuation над недочерновленной книгой (`draft_before ≥ chapters_before + C`) формула даёт `draftWork=0`: движок тратит весь потолок на черновики, полоса стоит 0/C весь прогон со stage `editing` на прогоне, который только черновит.** Зеркало дефекта, который `draftWork` чинил (тот закрывал «ready на 50%», этот открывает «0% при сделанной работе»). Это НЕ локальная ошибка формулы: любая полоса из (баз, C, D, E) где-то соврёт, пока платёжная модель («довести до конца C глав от `e0`») и движковый план работ («деньги в порядке волн от фронтов») не согласованы — лечение требует либо передавать движку план (диапазон/волны), либо выводить total полосы из движкового плана; `ceiling_chapters` сегодня до движка не доезжает вовсе. Архитектурное, ОТДЕЛЬНЫМ паком по слову оркестратора 28.08 («остановись и скажи»); подтверждено воркфлоу арифметикой на существующем зелёном пине (`TestARunOverADraftedBacklogOwesOnlyTheLastPass` держит `draftWork=0` в родственной точке) ⚠ **ПЕРЕ-ДИСПОЗИЦИОНИРОВАНА паком P12 (30.08): направление ЕСТЬ, регистр отставал от D-лога.** Формулировка «ждёт решения» была верна до 28.08 и с тех пор ложна — D39.165 §1 дал три части: **(а)** цена от ОБЪЁМА ИСХОДНИКА, носитель уже существует и лучше знаков — `chapters.units_total`, который интейк пишет вызовом `tmctl manifest` за $0 ДО первой оплаты; посимвольных колонок заводить не надо; **(б)** настоящий стоп объёма — В ДВИЖКЕ рядом с денежным, обязан НАЗЫВАТЬ, по какому потолку встал, и проверяться ПЕРЕД началом юнита; **(в)** хвост качества — в тариф и МЕРИТЬ, срезы телеметрии уже есть. ⛔ **(г) константу по сегодняшним числам НЕ калибровать** — гейт строка бэклога 202. Что остаётся открытым: не решение, а МЕХАНИКА, и она — отдельный пак (слово оркестратора 28.08), паком P12 не тронута. | open | воркфлоу-ревью P9 28.08 (линза bar:double-count), стоп-решение сессии P9 28.08: тянет архитектуру, не чинится в паке | +| PD-424 | bug | **major** | `internal/runs/reconcile.go` `reopen` (вердикт `deferred`) и `reconcileOne`, `internal/pgstore/runs.go` `StalledRuns`, `internal/pgstore/observe.go` | **ЖИВОЙ прогон с намертво заблокированной расплатой невидим на ВСЕХ поверхностях `PD-385` и не поддаётся `run abandon` — счётчик неудач не растёт НИКОГДА.** Цепь, воспроизведённая охотником приёмки на десяти проходах: юнит исчез без маркера → `restart` → `settle` возвращает `settlementBlocked, nil` (не ошибку) → `reopen` отказывается открыть новую попытку поверх незакрытого холда и возвращает вердикт `deferred` → `case deferred: return nil` → `reconcileOne` считает проход УСПЕШНЫМ и вдобавок зовёт `ClearRunDeferral`. Итог: `reconcile_failures` 0, `reconcile_after` NULL, `StalledRuns(5)` пуст, гейдж 0, холд заморожен, движок дёргается каждым проходом. Вывести из состояния может только пользователь, нажав Stop, — и никто ему об этом не говорит. ⚠ Это ТА ЖЕ болезнь, что `PD-384`/`PD-385`, но в ЖИВОЙ фазе, куда пак P11 не дошёл: он лечил вторую фазу (расплату) и её поверхности. ⚠ **Почему пак не взял её в себя (решение сессии, не пропуск):** лечение упирается в вопрос, которого нет ни в одной строке заказа, — чем должен считаться вердикт `deferred` для СЧЁТЧИКА: не-событием (как сейчас), успехом или неудачей. Это дизайн реконсиляционной фазы, а пак был про фазу расплаты; вмешаться в него по дороге значило бы «распухнуть вокруг самого срочного», против чего промт предупреждает прямо. Форма лечения, которую сессия предлагает: `reopen`, вернувший `deferred`, обязан сообщать `established=false` (тогда `ClearRunDeferral` хотя бы не стирает счёт), а сам блокированный расчёт — считаться неудачей ТОЙ фазы, которая им владеет ⚠ **СУЖЕНА пак P12 (30–31.08), НЕ ЗАКРЫТА — половина «невидим» вылечена, половина «ручки нет» стоит.** Приор зоны принят и исполнен: блокированная расплата — НЕУДАЧА владеющей фазы, и она считается. `restart` больше не выбрасывает вердикт `settle`: на `settlementBlocked` он возвращает `errSettlementBlocked` ДО `reopen` (прежний путь платил за запрос, чтобы узнать то, что `settle` только что сказал, писал WARN, винящий рестарт в блокировке расплаты, и отвечал `nil`, который вызывающий считал успешным проходом). `reconcileOne` ключует на этом сентинеле причину — ОДНА фраза на оба фазовых пути — и ЗАДЕРЖКУ: расписание РАСПЛАТЫ, а не живой фазы, потому что открытая резервация и есть resume-гейт пользователя, и получасовой живой бэкофф держал бы его resume за блокировку, с которой он ничего сделать не может. Вердикт `deferred` у `reopen` оставлен как был: после правки он достижим только на `settlementRaced` с моментально открытой резервацией — самоисправляется следующим проходом, и фаза расплаты прямо аргументирует, что гонка не должна попадать на счётчик оператора. Гард выключения (`ctx.Err()`) стоит: счёт, придуманный ОСТАНОВКОЙ демона, — тот самый дефект, который соседняя фаза уже нашла. Итог, проверенный пином `runs.TestALiveRunWhoseSettlementIsBlockedIsCountedAndReachesTheOperator`: `reconcile_failures` доходит до порога, строка появляется в `runs --stalled` (половина `live`, не `settling`), гейдж `tm_platform_runs_stalled` = 1, отсрочка не превышает `settlementBackoffCap`. Посадка M13 КРАСНАЯ адресно. ⚠ **ЧТО ОСТАЁТСЯ ОТКРЫТЫМ и почему не взято этим паком:** терминальная РУЧКА. `run abandon` ветвится по `runs.finished_at` и на живом прогоне уходит в живую ветку, а `AbandonRun` отказывает над попыткой, ещё называющей юнит. Ручка «процесса нет, закрой деньги» — НОВАЯ разрушительная операторская поверхность над деньгами, и её дизайн (кто вправе звать, чем доказывается отсутствие процесса, что будет, если процесс вернётся) — решение своего размера, а не хвост этой правки. Сегодня пользователь выводит прогон из состояния кнопкой Stop, и теперь об этом хотя бы говорят все три поверхности `PD-385`. | open | приёмка оркестратора №19 по паку P11 (охотник вне карты, воспроизведено 10 проходами) | ## Открытые — minor | ID | Класс | Серьёзность | Где | Суть | Статус | Источник | |---|---|---|---|---|---|---| -| 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-435 | bug | minor | `internal/pgstore/readmodel.go` `runDone`/`runTotal`/`runStage` (читают монотонный `editWave`), `internal/pgstore/runs.go` `StartRun` (снятие базлайнов) | **Полоса прогона на деплое, где редактора УБРАЛИ, не доходит до единицы: знаменатель тарифицирует edit-волну, которой не будет.** Вторая половина `PD-403`; первая (счёт книги) закрыта паком P12 эпохой формы конвейера, эта — нет, и попытка закрыть её тем же носителем была ОТКАЧЕНА тем же паком: полоса, переведённая на ПРИСВАИВАЕМУЮ эпоху, перестаёт быть монотонной — замерено, один прогон читал 4/4, затем 2/2 на своих же двух попытках при неизменной `structure_version`, что канон запрещает прямо (строка 200, «одна монотонная дробь на всю работу прогона»). ⚠ **Почему это не однострочник:** правильный носитель — форма, под которой работает ЭТОТ прогон, записанная на самом прогоне; а в `StartRun` она ещё НЕ ИЗВЕСТНА — движок объявляет её первым progress-событием прогона, и попытка снять её раньше это ровно `PD-401`, закрытый пином. Значит запись должна происходить на ПЕРВОМ объявлении прогона, и тогда нужен разбор, что делать со второй попыткой того же прогона, объявившей другую форму (сегодняшний ответ — ничего, потому что флаг монотонен). Лечение: колонка формы на `runs`, заполняемая первым объявлением, плюс решение о смене формы между попытками одного прогона. Пин обратной стороны уже стоит и покраснеет, если кто-то снова переведёт полосу на эпоху: `pgstore.TestTheRunsBarIsMonotoneAcrossAShapeBoundaryItSpans`. ⚠ **Почему `minor`, когда родительская `PD-403` была `major` — вопрос приёмки, отвечаю доводом, а не весом.** У `PD-403` мажорной её делали ДЕНЬГИ: шкала покупки продавала уже переведённые главы повторно. Эта половина ЗАКРЫТА — `ChaptersLeft` считается через эпоху, дважды не продаётся ничего, и `PD-410` (тоже про деньги, ручка «купить N глав» ограничивает не главы, а доллары) остаётся `major` именно поэтому. Здесь не двигается ни один микро-доллар: прогон делает всю купленную работу, закрывается `ready`, леджер сходится, следующая покупка предлагает правильный остаток. Врёт ДРОБЬ и подпись (`editing` на деплое без редактора) — контрактно видимо и потому не `info`, но это отчёт о работе, а не её оплата. Плюс достижимость: нужна смена ФОРМЫ деплоя под книгой, которая уже прошла редактирующий пайплайн, — не обычный путь, в отличие от `PD-410`, который кусает на КАЖДОЙ покупке. Если приёмка сочтёт довод слабым — поднять до `major` дешевле, чем спорить: работа от веса не меняется | open | адверсариальный проход пака P12 (30–31.08), замерено на живом Postgres через продовые пути записи | +| PD-433 | hardening | minor | `internal/pgstore/identity.go:106`=`if !errors.Is(err, errIdentityRace)` (ретрай), `:118`=`var errIdentityRace`, ветвь `on conflict … do nothing` в `upsertIdentityOnce` | **Ветвь, обслуживающая ЛЕГИТИМНУЮ гонку двух первых логинов одной новой личности, не исполняется НИ ОДНИМ тестом.** Форма та же, что у `PD-380` и `PD-86`: единственная точка принуждения объявленного свойства, мутация переживает полную батарею, транзитивной страховки нет. Механика: под READ COMMITTED `for update` в `LockIdentity` не блокирует строку, которой ещё нет, поэтому оба гонщика проходят; проигравший видит `created == 0`, получает `errIdentityRace` и ретраит — и именно ретрай спасает его от 500 на пути логина. ⛔ **Снос ветви — только с доказательством недостижимости:** она обслуживает легитимную гонку, и её снос = 500 на легитимном пути (класс `PD-369`, который этот же пак и закрывал). Диспозиция: запинить конкурентным тестом ЛИБО доказать недостижимость. Форма теста, если пинить: в пакете `pgstore`, ~40 раундов, в каждом СВЕЖАЯ личность и 4 горутины на `UpsertIdentity`; утверждать, что все четыре вернули `nil`, что userID у всех ОДИН, что `identities` держит одну строку, что грант посева записан ОДИН раз и что число строк в `users` равно числу раундов — последнее ловит настоящую утечку, осиротевшего пользователя от проигравшего, который успел `CreateUser` до проигрыша. Проверять посадкой (снять арм `errIdentityRace` — тест обязан покраснеть), потому что вероятностный тест без посадки не доказывает, что он вообще кусает | open | пак P12 (30.08), греп непокрытых ветвей по своим путям | +| PD-434 | bug | minor | `internal/pgstore/sink.go` `ApplyStatus`, `internal/runs/reconcile.go` `maybeResync` | **Канал ПОЧИНКИ прогресса не чинит прогресс: ресинк не материализует ничего, что читает экран.** `maybeResync` существует ровно для прогона, чей поток в карантине или чей курсор не двигался — «это теперь единственный источник», говорит его собственный комментарий. Но `ApplyStatus` писал пер-волновые цифры отчёта ТОЛЬКО в `runs.draft_done/draft_total/edit_done/edit_total`, у которых не было ни одного читателя (`PD-411`), и не трогает ни `unit_resolutions`, ни `chapters` — а вся полоса выведена из `chapters`. Значит полоса такого прогона стоит всю его жизнь, как бы исправно ни отвечал `tmctl status`. ⚠ **Строка заведена паком P12 при сносе `PD-411`, и это главное в ней:** мёртвые колонки ПРЯТАЛИ гап — код выглядел так, будто канал починки чинит, и комментарий `maybeResync` прямо утверждал «ApplyStatus now materializes the same four counters as the stream». Обе лгущие фразы исправлены, сам гап НЕ лечится сносом и вынесен сюда, чтобы снос не выдал себя за лечение. Лечение: либо ресинк складывает отчёт в `chapters` (и тогда нужен разбор, как он не спорит с потоком, который те же строки пишет из `unit_resolutions`), либо зона признаёт, что карантинный прогон полосы не показывает, и говорит об этом на поверхности. Пин формы «ресинк НЕ двигает счётчики глав» уже стоит — `pgstore.TestTheResyncRecordsFreshnessAndShapeAndNoProgress` — и он покраснеет, если канал научится материализовать: это указатель на строку, а не её лечение | open | пак P12 (30.08), вскрыто сносом `PD-411` | +| PD-375 | bug | minor | `internal/runs/runs.go:347`=`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-377 | doc | minor | `internal/pgstore/credits.go:162`=`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-386 | bug | minor | `cmd/tmplatformd/runner.go:269`=`pass("runs", sweepBudget, s.runs.Sweep)` и четыре следующих прохода того же `one()`, `cmd/tmplatformd/runner_test.go` | **Такт свипа последователен, и бюджеты пяти проходов СКЛАДЫВАЮТСЯ: сумма объявленных — 37 минут при `TM_PLATFORM_SWEEP_EVERY` 15 секунд.** Пак P8-FIX дал каждому проходу свой бюджет и закрыл «один проход съедает дедлайн другого», но проходы по-прежнему идут подряд в одной горутине: runs 2м, readmodel 10м, intake 21м, idempotency 2м, observe 2м. Ничто эту сумму не ограничивает и ничто её не пинит — у функции `sweep` нет ни одного теста (в пакете два теста, оба про другое), и посадка «фаза runs уходит из головы такта в хвост» пережила ПОЛНУЮ батарею. Замерено пробой на реальной функции: за 4 секунды при такте 100 мс фаза прогонов отработала 40 раз сама по себе и 9 раз позади прохода материализации. Следствия по оси: калибровки самого лечения заданы в проходах и минутах и молча растягиваются (`StalledAfter` 5 «неудач подряд» и бэкофф, про который комментарий обещает «about a quarter of an hour», превращаются в часы); обещание `Stop` «the reconciler re-issues the stop on its next pass» задерживается на ту же величину; телеметрия стоит ПОСЛЕДНЕЙ. ⚠ Рефутер сузил: 37 минут — сумма ОБЪЯВЛЕННЫХ бюджетов, а не достижимая длительность (idempotency это один индексированный DELETE, а проход интейка сам себя режет). Воспроизведение: `docs/p8-review/axis3-queue/probe_sweep_serialisation_test.go.txt` | open | ревью-пак P8-REVIEW, ось 3 (проба на реальной функции + посадка мутации, сужено рефутером) | -| PD-387 | doc | minor | `deploy/README.md:311`=`сколько всего может занять один проход свипа`, `cmd/tmplatformd/runner.go` `one`, `internal/config/config.go` `SweepBudget` | **Рантбук называет `TM_PLATFORM_SWEEP_BUDGET` ручкой «одного прохода свипа» и не говорит, КАКОГО из пяти, — а два бюджета из пяти оператору недоступны вовсе.** Один такт прогоняет ПЯТЬ проходов подряд, и только три берут `sweepBudget`; проход материализации берёт `refreshSweepBudget` 10 минут, проход интейка — `intakeSweepBudget` = `jobs.JobTimeout` + `readmodel.MaterializeBudget` + минута = 21 минута, и обе константы оператору недоступны вовсе. Собственный доккомментарий кода при этом ТОЧЕН («SweepBudget is what ONE pass of the RUN sweep may take») — расходится именно операторская проза, и расходится в разделе «Застрявшая работа: что оператор делает, когда свип не справляется», то есть там, где по ней и будут действовать. Родня `PD-368`: та про то, что поднимать надо пару, эта про то, что ручка не покрывает такт ⚠ **Заголовок ИСПРАВЛЕН после интервальной самоверификации, и первая редакция была завышена:** она писала, что рантбук обещает ограничение ТАКТА целиком, а `deploy/README.md:311` буквально говорит «сколько всего может занять один проход свипа» — то есть ровно то, что ручка и делает. Дефект уже и точнее: строка не называет, какой из ПЯТИ проходов такта она ограничивает, `refreshSweepBudget` и `intakeSweepBudget` не являются ручками вовсе, и раздел, в котором эта таблица стоит, — «Застрявшая работа: что оператор делает, когда свип не справляется». Арифметика 37 минут пере-проверена и держится: 3×2 + 10 + 21. Воспроизведение — `docs/p8-review/axis3-queue/probe_sweep_serialisation_test.go.txt` (последовательность тактов) плюс `sed -n '264,272p' deploy/README.md` | open | ревью-пак P8-REVIEW, ось 3 (находка рефутера) | +| PD-387 | doc | minor | `deploy/README.md:333`=`сколько всего может занять один проход свипа`, `cmd/tmplatformd/runner.go` `one`, `internal/config/config.go` `SweepBudget` | **Рантбук называет `TM_PLATFORM_SWEEP_BUDGET` ручкой «одного прохода свипа» и не говорит, КАКОГО из пяти, — а два бюджета из пяти оператору недоступны вовсе.** Один такт прогоняет ПЯТЬ проходов подряд, и только три берут `sweepBudget`; проход материализации берёт `refreshSweepBudget` 10 минут, проход интейка — `intakeSweepBudget` = `jobs.JobTimeout` + `readmodel.MaterializeBudget` + минута = 21 минута, и обе константы оператору недоступны вовсе. Собственный доккомментарий кода при этом ТОЧЕН («SweepBudget is what ONE pass of the RUN sweep may take») — расходится именно операторская проза, и расходится в разделе «Застрявшая работа: что оператор делает, когда свип не справляется», то есть там, где по ней и будут действовать. Родня `PD-368`: та про то, что поднимать надо пару, эта про то, что ручка не покрывает такт ⚠ **Заголовок ИСПРАВЛЕН после интервальной самоверификации, и первая редакция была завышена:** она писала, что рантбук обещает ограничение ТАКТА целиком, а `deploy/README.md:333` буквально говорит «сколько всего может занять один проход свипа» — то есть ровно то, что ручка и делает. Дефект уже и точнее: строка не называет, какой из ПЯТИ проходов такта она ограничивает, `refreshSweepBudget` и `intakeSweepBudget` не являются ручками вовсе, и раздел, в котором эта таблица стоит, — «Застрявшая работа: что оператор делает, когда свип не справляется». Арифметика 37 минут пере-проверена и держится: 3×2 + 10 + 21. Воспроизведение — `docs/p8-review/axis3-queue/probe_sweep_serialisation_test.go.txt` (последовательность тактов) плюс `sed -n '264,272p' deploy/README.md` | open | ревью-пак P8-REVIEW, ось 3 (находка рефутера) | | PD-389 | hardening | minor | `cmd/tmplatformd/runner.go:320`=`s.metrics.ObserveRunner(metrics.Runner{`, `cmd/tmplatformd/runner.go` `pass`, `internal/metrics/metrics_test.go` `TestTheRunnersStateIsExposedWithItsUnits` | **Шов телеметрии не покрыт НИЧЕМ, и это доказуемо без прогона батареи: `sweep` и `observe` — неэкспортируемые функции пакета `main`, то есть из другого пакета их не может вызвать ни один тест в принципе,** а единственный тест-файл каталога несёт два теста, оба про другое. Проверено тремя посадками, пережившими полную батарею: `StalledRuns: o.StalledRuns` в ноль (наблюдаемая половина закрытого BLOCKER `PD-346`), `errors.Is(err, context.DeadlineExceeded)` в false (`sweep_unfinished_total` больше не может вырасти — `PD-351` со стороны ВЫЗЫВАЮЩЕГО, куда пин `TestAPassThatRanOutOfTimeSaysSo` по построению не достаёт), и перестановка `queue_depth` с `live_runs`. Дыра шире шва: пин формы, на который ссылается `STACK_DECISIONS` §24, задаёт литерал `metrics.Runner` из ШЕСТИ полей из восьми — `StalledRuns` и `AbandonedSurfaces` в него не входят, поэтому мутация внутри самого `ObserveRunner` тоже выживает. Пере-проверено координатором пака независимо: снятие `m.stalledRuns.Set(...)` и снятие `m.abandonedSurfaces.Set(...)` по отдельности проходят ПОЛНУЮ батарею (18 пакетов), при том что снятие соседнего инкремента `sweepUnfinished` тем же пином ловится. То есть операторская ручка, построенная паком P8-FIX в ответ на `PD-169`, не пиньётся ничем. Воспроизведение: `docs/p8-review/mutations-full.log` и `docs/p8-review/axis4-metrics/60-mutations.sh` | open | ревью-пак P8-REVIEW, ось 4 (посадки финдера, рефутера и координатора) | | PD-390 | hardening | minor | `cmd/tmplatformd/runner.go:317`=`log.Warn("the control plane's own state could not be read", "err", err)`, `internal/pgstore/observe.go` `Observe`, `internal/metrics/metrics.go` `ObserveRunner` | **Гейджи замирают при отказе телеметрического чтения, и признака устаревания в экспозиции нет.** `STACK_DECISIONS` §24 объявляет «значения снимает СВИП, а не скрейп», но у снятого значения нет ни отметки свежести, ни счётчика неудач: `observe()` при ошибке пишет один WARN и возвращается, НЕ тронув ни одного гейджа, а Prometheus такой ряд устаревшим не помечает — цель жива, ряд на месте, значение старое. Живой замер: при сломанном чтении и одновременно вылеченном мире экспозиция продолжала утверждать «1 застрявший прогон, 1 карантин, 1 живой прогон, холд возрастом 1201 с», тогда как в базе застрявших было 0; при этом `sweep_duration_seconds_count` рос по всем четырём проходам, то есть все «жив ли свип» сигналы оставались зелёными. `Observe` — ОДИН стейтмент на все восемь чисел, поэтому любая его поломка гасит все гейджи разом, а сам `observe()` вызывается ВНЕ `pass()`, поэтому своего ряда в `sweep_duration_seconds` у него нет и его отказ там не виден. Обратное направление хуже: процесс, у которого чтение не удалось НИ РАЗУ, отдаёт нули как здоровье, и `/readyz` с `/healthz` при этом зелёные. ⚠ Вторая половина того же корня, найденная рефутером: инстанс-ЧИТАТЕЛЬ (пустой `TM_PLATFORM_ENGINE_BIN` — объявленная форма деплоя) не запускает свип вовсе, `observe()` не зовётся ни разу, а метрики созданы раньше и регистрируют все восемь гейджей безусловно, поэтому реплика уверенно отвечает `runs_stalled 0`, `queue_depth 0`, `oldest_open_hold_seconds 0` про контрол-плейн, который она не измеряет — и тут нет даже WARN-строки. Лечится дёшево: `*_last_success_timestamp_seconds` либо счётчик неудач наблюдения плюс проведение `observe` через тот же `pass`. Воспроизведение: `docs/p8-review/axis4-metrics/30-stale-gauges.sh`, `r1-boot-with-blind-telemetry.sh`, `r6-read-replica-zeroes.sh` | open | ревью-пак P8-REVIEW, ось 4 (живой замер, расширено рефутером) | | PD-392 | hardening | minor | `internal/metrics/metrics.go:80`=`Namespace: namespace, Name: "quarantined_attempts"`, `internal/metrics/metrics.go` `oldest_open_hold_seconds`, `cmd/tmplatformctl/runs.go` `listRuns` | **Две метрики без ручки — тот самый класс, за который `PD-169` стоял BLOCKER'ом, в двух других местах.** Help гейджа карантина сам называет цену («Such a run keeps going and keeps spending»), но команды, называющей строку за этим числом, нет: `grep -rn quarantine cmd/` не даёт ни одного хита, и в таблице `runs` колонки карантина тоже нет. У возраста холда ручка формально есть — `balance --user`, — но она требует идентификатор аккаунта, которого гейдж не даёт, а документированный случай самого гейджа («A hold outlives its run only when a settlement could not be made») — это холд ЗАКОНЧЕННОГО прогона, которого список не показывает по построению. Живая проба на состоянии, произведённом ШТАТНЫМ операторским сценарием (abandon застрявшего прогона): при `tm_platform_oldest_open_hold_seconds 10813` команды отвечают «no run is live», «no run is failing to reconcile» и «no book has been given up on», а единственный путь к строке — psql, то есть ровно то, что эти числа заводились заменить. Глобального списка открытых холдов в CLI нет. Воспроизведение: `docs/p8-review/axis4-metrics/50-gauges-without-a-handle.sh` и `r5-hold-without-a-handle.sh` ⚠ **Общий корень с `PD-385`, и там же он взвешен:** сужение ЭТОЙ строки опирается на операторскую поверхность, несостоятельность которой доказывает соседняя строка того же пака — круговое сужение разобрано в `PD-385`, поднятой до major | open | ревью-пак P8-REVIEW, ось 4 (живая проба, подтверждено рефутером) | @@ -49,35 +47,28 @@ | 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:567`=`select finished_at from runs where id = $1` ⚠ якорь пере-нацелен паком P11: прежняя строка отвечала `ErrNoRun` законченному прогону — она и была дефектом; теперь это ветвление, читаемое ПОД блокировкой книги | 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:1252`=`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 (флейк батареи), передано оркестратору | +| 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:564`=`select finished_at from runs where id = $1` ⚠ якорь пере-нацелен паком P11: прежняя строка отвечала `ErrNoRun` законченному прогону — она и была дефектом; теперь это ветвление, читаемое ПОД блокировкой книги | 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:1363`=`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`). У отказа порядок перевёрнут в самоизлечивающийся — сначала каталог, потом строка, — а у брошенной загрузки перевернуть нельзя (каталог можно сносить только убедившись, что строки нет), так что окно там остаётся и закрывается тем же свипом ретеншена, что и вся строка ⚠ **ПРОДУКТОВАЯ ПОЛОВИНА СНЯТА — D39.176 п.1 (слово владельца 30.08); диспозиция записана паком P12 31.08 по пингу аудита доков.** Квот НЕТ и не будет, фри-тир-лимиты не проектируются: живём на покупке API, бонусы зачисляются из админки. Вопрос «сколько книг во фри-тир» не ждёт владельца — его больше нет. Остаётся ИНЖЕНЕРНАЯ половина, и она НЕ требует ничьего слова: ретеншен `rejected`-книг, свип каталогов-сирот, потолок диска — зона решает сама. Гейт один и не продуктовый: открытая регистрация, которой в закрытой бете нет. Записано в СТРОКУ, а не только пингом, потому что пинг уезжает в архив (та же причина, по которой правится `П-12` бэклога). | open | сессия P5 (самопроверка, ось «что этот маршрут создаёт») | | PD-371 | bug | minor | `internal/pgstore/runs.go` `RunsToReconcile`, `internal/runs/reconcile.go` `deferItem` | **Исключение «стоп перевешивает отсрочку» обходит отсрочку БЕЗ ГРАНИЦЫ, и для прогонов с запрошенным стопом голодание PD-169 внутри фазы возвращается.** Прогон, чей `stop_requested_at` не пуст, выбирается КАЖДЫМ проходом независимо от `reconcile_after`, сколько бы раз подряд он ни падал. **Измерено живьём при приёмке** (дев-демон на дереве пака, хост без пользовательской шины systemd, поэтому `Runner.Alive` падает по-настоящему): `reconcile_after` стоял на ~30 минут вперёд (`NEXT TRY 14:21:21Z`), а `FAILS` дорос до **6 за ~90 секунд**, то есть на каждом 15-секундном такте — отсрочка не действовала ни разу. На ДЕШЁВОЙ ошибке это безвредно и было именно так в пробе. На дорогой (шина или чтение журнала книги висит до конца бюджета) прогон снова держит голову списка весь бюджет фазы реконсиляции, а класс запускает ЛЮБОЙ пользователь кнопкой «остановить». Что смягчает и почему это не блокер: расчёт денег живёт во ВТОРОЙ фазе и не страдает (это и есть половина лечения PD-169), счётчик всё равно растёт, гейдж `tm_platform_runs_stalled` и `run abandon` работают — проверено той же пробой. Комментарий у `RunsToReconcile` называет цену НЕ-исключения («стоп ждал бы истечения бэкоффа») и не называет цену исключения. Направление, не решение: исключать до пересечения `StalledAfter`, а дальше подчинять стоп общему бэкоффу — переиздание стопа идемпотентно, и на пятой неудаче подряд «переиздать немедленно» уже ничего не покупает | open | приёмка P8-FIX (живая проба оркестратора №18, вне карты пака) | | PD-372 | bug | minor | `internal/pgstore/runs.go` `DeferRun`, `internal/pgstore/books.go` `truncateReason`, `internal/pgstore/isolation_test.go` | **Починку текста ошибки пинит только САМА функция, но ни один из четырёх её вызовов.** `TestAnEnginesOwnErrorTextSurvivesBeingRecorded` зовёт `truncateReason` напрямую и доказывает, что Postgres принимает её результат, — а того, что вызывающий её ЗОВЁТ, не проверяет ничто. **Посажена мутация оркестратором вне списка автора:** `truncateReason(reason)` → `reason` в `DeferRun` — батарея (`./internal/pgstore/` + `./internal/runs/`) осталась ЗЕЛЁНОЙ. Цена ровно та, которую комментарий этой же функции называет вслух: невалидный UTF-8 из stderr движка Postgres отвергает, запись отказа не проходит, счётчик не растёт и попытка держит голову списка вечно — то есть механизм PD-169 отключается тем самым текстом, ради которого заведён. Класс — PD-1 («свойство без пинящего теста не закрыто»), и он тут в форме «пин есть, но не на пути». Лечение дешёвое: провести один случай через `DeferRun`/`DeferReadModelDebt` и прочитать колонку назад | open | приёмка P8-FIX (посадка мутации оркестратором №18) | | PD-217 | bug | minor | `internal/pgstore/books.go` `BooksForMigration`, `deploy/README.md` | **Книга, застрявшая на `daily_ceiling` или на вечно незакрытом холде, блокирует апгрейд движка бессрочно, и выхода у оператора нет.** Оба состояния снимаются только тем, чего платформа сделать не может: дневной потолок живёт в `book.yaml` оператора, и резюм по нему отказан 409; холд закрывается расчётом, который читает движок ЗАПИНЕННЫМ бинарём. Форсирующего флага у `books --migratable` нет намеренно — он и был бы способом пере-оплатить уже купленные вызовы. Лечится либо ручным разрешением в БД, либо каналом «признать попытку невосстановимой», которого в контракте нет ⚠ **ПОЛОВИНА ЗАКРЫТА P7:** `paused` вышел из предиката `Resumable` — с 0.3.0 прогон, остановленный ЛЮБЫМ потолком, `resume` продолжить не может вовсе (409 `ceiling_reached`), значит к старой сборке ничего не пришпилено и мигрировать такую книгу безопасно; лечение пользователя — НОВЫЙ прогон, который спавнится ТЕКУЩИМ бинарём. Книга на дневном потолке апгрейд больше не запирает. Связь названа в коде: вернётся резюм паузы — вернётся и предикат. Пин `TestEachOfTheThreeBlockersAloneKeepsABookOutOfTheMigrationList` (перечень «безопасных» проверяется целиком, а не по одной книге). ⚠ ОСТАЁТСЯ вторая половина: книга с вечно незакрытым холдом блокирует по-прежнему, и это правильно — расчёт читает движок запиненным бинарём | open | приёмка P6 (дофикс, ФП-7) | | PD-219 | bug | minor | `internal/runs/reconcile.go` `drainJournal`, `reopen` | **Перезапуск после упавшего дрейна теряет непримененный хвост журнала навсегда.** Новая попытка получает курсор с РАЗМЕРА журнала на момент допуска, поэтому строки, которые прошлый дрейн не успел применить, не прочитает уже никто. Счётчики восстанавливает ре-синк, а `unit_resolutions` — нет: их единственный источник — поток. Сегодня невидимо (читающей поверхности единиц ещё нет), к P7 станет расхождением read-модели ⚠ **ДИСПОЗИЦИЯ P7 (взято на учёт, НЕ закрыто):** предсказание строки сбылось ровно наполовину, и половины теперь названы. Что САМОЛЕЧИТСЯ: текст и состояние пары приходят не из потока, а из `tmctl export` на границе работы (`readmodel.Refresh`), поэтому недодрейненный хвост их не искажает. Что НЕ лечится: `unit_resolutions` — единственный источник ЗАМЕЧАНИЙ и счётчиков главы, и потерянная строка это замечание, которого пользователь не увидит никогда, плюс `units_done`, занижённый навсегда. Лечение — перечитывание хвоста журнала при переоткрытии прогона (курсор новой попытки начинается с размера журнала на допуске); это работа того же класса, что эскроу (строка 136), и в P7 не бралась осознанно | open | приёмка P6 (дофикс, ФП-7) | -| PD-380 | hardening | minor | `internal/pgstore/sessions.go:96`=`AbsoluteExpiresAt: now.Add(maxAge),`, `internal/config/config_test.go` | **Абсолютный потолок сессии не запинен в единственном месте, где он становится фактом в базе.** `CreateSession` — единственный писатель `sessions.absolute_expires_at`, и мутация этого выражения проходит ПОЛНЫЙ пакет `pgstore`: ни один сессионный тест не краснеет. Пин, который `STACK_DECISIONS` §13 называет носителем потолка, смотрит только на результат `config.Load()` (что значение конфигурации не выше ASVS-предела), то есть проверяет НАСТРОЙКУ, а не то, что она доезжает до строки. Родня `PD-86`, но на шаг раньше: там не запинены клаузы ЧТЕНИЯ и потолок держится транзитивно через `Touch`, здесь не запинена сама ЗАПИСЬ, а транзитивной страховки у неё нет. Воспроизведение: `docs/p8-review/axis2-auth/mutations-axis2.sh` ⚠ **Пере-проверено на ПОЛНОЙ батарее координатором пака (мутация M12):** `now.Add(maxAge)` → `now.Add(100*maxAge)` в `CreateSession`, дельта против чистой копии ПУСТА на всех 18 пакетах. ⚠ Первая редакция этой посадки НЕ СОБРАЛАСЬ — комментарий вставлялся в середину строки и съедал хвост `; err != nil {`; это ошибка харнесса, а не находка, и она названа в логе `docs/p8-review/mutations-round2.log`, чтобы приёмка не пере-открыла её как расхождение ⚠ **ВЕС ПОДНЯТ info → minor закрывающим ревью, довод — симметрия с `PD-375`:** форма идентична (единственная точка принуждения объявленной границы не исполняется ни одним тестом, мутация переживает ПОЛНУЮ батарею, транзитивной страховки нет — `Touch` зажимает по значению ИЗ ТОЙ ЖЕ испорченной строки), а вес расходился только по валюте: там деньги и minor, здесь механизм ASVS 7.3.2 уровня 2 при объявленной зоной базовой линии L2 и info. Асимметрия была отпечатком того самого храповика «вреда сегодня нет», который это ревью и нашло ⚠ **Якорь пере-нацелен паком `sqlc` (29.08):** прежний токен — `s.pool.Exec` в `CreateSession` — исчез, потому что SQL этого запроса уехал в `internal/pgstore/queries/sessions.sql` и исполняется генерённым кодом. Новая цель — строка, где `maxAge` СТАНОВИТСЯ значением (`AbsoluteExpiresAt: now.Add(maxAge)`): именно она несёт факт, о котором строка, и она переживёт следующую генерацию. Сам дефект не тронут — потолок по-прежнему не запинен. ⚠ **ЗАКРЫТО паком `sqlc` (`63fcee5`, D39.172), пере-проверено ПОСАДКОЙ, а не рассуждением.** Пин — `pgstore.TestTheTwoSessionDeadlinesAreNotInterchangeable` (`sessions_test.go`): он создаёт сессию с РАЗНЕСЁННЫМИ сроками (`idleTTL` 1 ч против `maxAge` 24 ч — фикстура, где они совпадают, здесь ничего не доказывает, потому что `Touch` зажимает idle к absolute) и утверждает `AbsoluteExpiresAt == now.Add(maxAge)` после `CreateSession`, то есть ровно в единственном месте, где потолок становится фактом в базе. Именно та мутация, которой строка заведена — M12, `now.Add(maxAge)` → `now.Add(100*maxAge)` — теперь КРАСНАЯ адресно (замер 29.08: `AbsoluteExpiresAt = 2026-12-07…, want 2026-08-30…`). Пак строку не искал: тест писался против перестановки двух сроков, и потолок оказался запинен тем же утверждением — поэтому закрытие подтверждено пере-прогоном ИМЕННО M12, а не сходством формулировок | fixed | ревью-пак P8-REVIEW, ось 2 (посадка мутации, пере-посажена рефутером на полном пакете) | | PD-407 | bug | minor | `internal/runs/bank.go` (ветка `ctx.Err()` после `BankApply`), `internal/runner/bankapply.go:40` `bankStopGrace` | **Единственный путь двери правок, способный оставить полу-приземлённую пару файлов БЕЗ отчёта, отвечает общим `503 service_unavailable` вместо своего слова `bank_corrections_incomplete` — а комментарий ветки обосновывает её контрактом SIGTERM, который сюда не попадает.** Ветка `ctx.Err() != nil` достижима только при `res.Exited=false`, т.е. когда глагол НЕ вышел сам — SIGKILL по `WaitDelay` (грейс 10 с) или несостоявшийся старт; глагол, поймавший SIGTERM, выходит 5 и идёт через `bankVerdict`. SIGKILL между двумя rename — ровно класс 15, и адресат его слова другой («машина шлёт ТО ЖЕ, никто не пере-решает»). Смягчение: ре-сенд сходится байтовым no-op движка в обе стороны, цена — неточное слово. Рядом: `bankStopGrace` 10 с КОРОЧЕ самого длинного непрерываемого участка глагола (движковый замер: 5000 declines ≈ 11.3 с до пред-записной проверки ctx) — на документе-максимуме SIGKILL опережает штатный останов для большинства моментов отмены ⚠ **ГРЕЙС-ПОЛОВИНА ЗАКРЫТА фикс-раундом P9 (28.08, D39.162):** `bankStopGrace` поднят до 30 с, число обосновано движковым замером в комментарии константы (×2.5 запас на медленный хост); комментарий SIGKILL-ветки переписан честно (описывал соседний путь). ОТКРЫТЫМ остаётся слово: полу-приземлённая пара без отчёта по-прежнему отвечается общим `503` вместо `bank_corrections_incomplete` — смягчение (ре-сенд сходится) в силе | open (грейс-половина закрыта D39.162) | воркфлоу-ревью P9 28.08 (линзы door:interleave · door:crash-windows), диспозиция оркестратора 28.08: строкой; грейс — фикс-раундом | -| PD-411 | standards | minor | `internal/pgstore/sink.go:137` и `:447` (два писателя), потребителей НЕТ (греп: ни одного select) | **`runs.draft_done/draft_total/edit_done/edit_total` — носитель с ДВУМЯ писателями и НУЛЁМ читателей: класс A второго яруса онтологии банка (`18-bank-ontology.md`), сосед `PD-314` по форме.** Полоса целиком выведена из `chapters` (runDone/runTotal), эти колонки не читает ни один SELECT — а пишутся они на КАЖДОМ progress-событии внутри транзакции, держащей блокировку строки книги, двумя РАЗНЫМИ дисциплинами (поток присваивает, resync берёт `greatest`), и обоснование `greatest` на `sink.go:444-446` защищает полосу, которой не существует, — следующая сессия будет искать монотонность бара не там, где он живёт. Лечение: снос колонок миграцией + снос обоих писателей и лгущих комментариев; в паке P9 НЕ делается (слово оркестратора 28.08: миграцию на снос в этом паке не заводить) | open | воркфлоу-ревью P9 28.08 (линзы race:flip-read-consistency · bar:double-count · bar:stage-caption), диспозиция оркестратора 28.08: отдельной строкой класса A | | PD-412 | bug | minor | `internal/pgstore/readmodel.go:434-481` (draftChapters ×3 текстовых вхождения, editChapters, chaptersDone, noteCount), `internal/pgstore/books.go` `bookColumns`/`lastRunTx`/`listBooksTx` | **Карточка книги стоит 5 коррелированных сканов `chapters` на один GET, и 2 из них повторяются на КАЖДУЮ строку страницы библиотеки (×100).** PG повторные текстовые вхождения одного подзапроса НЕ дедуплицирует (доказано воркфлоу side-effect-последовательностью на живом PG 18.4), CASE-ветки честно short-circuit; замер на фикстуре в форме миграции 00002 и книге 2283 глав: выражение как написано — 1.359 мс, те же три числа одним `LEFT JOIN LATERAL (count(*) filter (...))` — 0.307 мс (×4.4); контрольный EXPLAIN сессии на реальной схеме tmp9stand: 6 SubPlan в плане, 5 исполняются. Лечение названо (один LATERAL-проход рядом с существующим lastRun); проект уже применял этот класс лекарства уровнем ниже (миграция 00022, `note_count`) | open | воркфлоу-ревью P9 28.08 (линза cost:per-request, замер) + контрольный EXPLAIN сессии P9, диспозиция оркестратора 28.08: строкой | | PD-413 | bug | minor | `internal/pgstore/sink.go:293-309` `emitProgress` (тот же 3-скан агрегат) под `lockBook` из `RunSink.Apply` (`sink.go:80-104`), источник событий: движок шлёт progress на КАЖДЫЙ разрешённый юнит каждой волны | **Каждое progress-событие пересобирает полосный агрегат по ВСЕЙ книге — под удержанной блокировкой строки книги: стоимость прогона растёт как O(глав × юнитов) вместо O(юнитов).** Изменение счётчиков ОДНОЙ главы (unitDone трогает одну строку) на следующей же строке потока триггерит 3 полных скана `chapters` (≈1 мс на книге 2283 глав, замер воркфлоу), сериализуя относительно себя любого другого писателя книги (RequestStop, дверь правок, следующее событие) — за прогон это десятки тысяч повторов, секунды суммарного удержания блокировки. Лечение — то же, что у `PD-412` (один LATERAL), плюс возможная инкрементальность; оркестратор 28.08: «вторая пахнет хуже первой» | open | воркфлоу-ревью P9 28.08 (линза cost:per-request, замер), диспозиция оркестратора 28.08: строкой | -| PD-418 | bug | minor | `internal/pgstore/runs.go` `StalledRuns` (settling-ветвь), `internal/pgstore/observe.go`, `AbandonRun` (ветвление по `runs.finished_at`), `deploy/README.md` | **У settling-строки ЖИВОГО прогона нет ручки, а рантбук обещает оператору обратное.** `PD-385` называет ДВЕ популяции, и вторая — «прогон, который ЖИВ, но чья ПРЕДЫДУЩАЯ попытка не рассчиталась после рестарта». Пак P11 дал ей обе поверхности ВИДИМОСТИ (таблица и гейдж ключуются по концу ПОПЫТКИ, так что строка показывается), но `run abandon` ветвится по `runs.finished_at` и на живом прогоне уходит в живую ветку: осиротевший холд он не тронет, а ответит про процесс. Рантбук при этом описывает `PHASE=settling` как то, что лечится этой командой. ⚠ Сегодня состояние НЕДОСТИЖИМО и это часть строки, а не оговорка: единственный не-тестовый путь к второй открытой резервации — `reopen`, который отказывается стартовать следующую попытку, пока холд предыдущей открыт. То есть документ расходится с кодом на состоянии, которого код пока не производит, — и разойдётся заметно, если этот инвариант когда-нибудь ослабнет. Лечение — либо ветвление по НАЛИЧИЮ осиротевшей попытки вместо `finished_at`, либо оговорка в рантбуке | open | пак P11 (самопроход, линза соответствия заказу) + приёмка оркестратора №19 | +| PD-418 | bug | minor | `internal/pgstore/runs.go` `StalledRuns` (settling-ветвь), `internal/pgstore/observe.go`, `AbandonRun` (ветвление по `runs.finished_at`), `deploy/README.md` | **У settling-строки ЖИВОГО прогона нет ручки, а рантбук обещает оператору обратное.** `PD-385` называет ДВЕ популяции, и вторая — «прогон, который ЖИВ, но чья ПРЕДЫДУЩАЯ попытка не рассчиталась после рестарта». Пак P11 дал ей обе поверхности ВИДИМОСТИ (таблица и гейдж ключуются по концу ПОПЫТКИ, так что строка показывается), но `run abandon` ветвится по `runs.finished_at` и на живом прогоне уходит в живую ветку: осиротевший холд он не тронет, а ответит про процесс. Рантбук при этом описывает `PHASE=settling` как то, что лечится этой командой. ⚠ Сегодня состояние НЕДОСТИЖИМО и это часть строки, а не оговорка: единственный не-тестовый путь к второй открытой резервации — `reopen`, который отказывается стартовать следующую попытку, пока холд предыдущей открыт. То есть документ расходится с кодом на состоянии, которого код пока не производит, — и разойдётся заметно, если этот инвариант когда-нибудь ослабнет. Лечение — либо ветвление по НАЛИЧИЮ осиротевшей попытки вместо `finished_at`, либо оговорка в рантбуке ⚠ **пак P12 (30–31.08): сделана дешёвая половина (оговорка в рантбуке), долговечная подписана диспозицией.** В `platform/deploy/README.md` дописано, что ветвление `run abandon` идёт по `runs.finished_at`, а не по наличию осиротевшей попытки, и что живой прогон с нерассчитанной ПРЕДЫДУЩЕЙ попыткой ушёл бы в живую ветку — то есть документ больше не обещает оператору того, чего код не делает. ⚠ **Пак P12 состояние достижимее НЕ сделал, и это проверено:** лечение `PD-424` заставляет блокированную расплату считаться и быть видимой, но рестарт по-прежнему НЕ происходит, значит второй открытой резервации не возникает; пин `PD-424` утверждает это прямо (строка отчитывается как `live`, а не `settling`). Долговечное лечение — ветвить по НАЛИЧИЮ осиротевшей попытки вместо `finished_at`, как уже делает settling-ветвь `StalledRuns`; строка остаётся открытой на нём. | open | пак P11 (самопроход, линза соответствия заказу) + приёмка оркестратора №19 | | PD-419 | bug | minor | `internal/pgstore/migrations_test.go` `TestReleasedMigrationsAreUnchanged`, `internal/pgstore/migrations.sha256` | **Гейт выпущенных миграций слеп к ПЕРЕ-ПОДПИСИ и по построению не может отличить её от нарушения.** Он сверяет файлы против `migrations.sha256`, лежащего в ТОМ ЖЕ дереве, поэтому ловит ровно один сценарий: правку миграции тем, кто забыл про манифест. Автор, который правит ВЫПУЩЕННУЮ миграцию и пере-подписывает её строку одним движением, проходит молча. Оба отказа, ради которых гейт написан, остаются достижимыми через пере-подпись: файл, отредактированный после накатки, больше никогда не запускается (goose применяет по НОМЕРУ и хранит только его), а переиспользованный номер лишает базу отката. Комментарий гейта при этом заявляет «this is the check that makes that true rather than intended». Единственный носитель «что уже выпущено», не лежащий рядом с правкой, — git: гейт мог бы брать `git show HEAD:…migrations.sha256` и требовать побайтового совпадения строк СУЩЕСТВУЮЩИХ там миграций, свободно допуская новые; прогон в дереве без git обязан тогда ГРОМКО скипаться, иначе гейт возвращается туда же, откуда ушёл | open | приёмка оркестратора №19 по паку P11 (замечена на законной пере-подписи `00028`) | -| PD-420 | bug | minor | `internal/pgstore/runs_test.go` `TestAClaimThatLostARaceToAReleaseIsRetriedAndNotAnError` | **Тест гонки холда против релиза краснеет под ПАРАЛЛЕЛЬНЫМИ батареями, а зона ратифицировала рецепт, который их требует.** `D39.159` §2 предписывает сажать мутации в КОПИЮ дерева, и всякая сессия, которая делает это всерьёз, гоняет несколько батарей разом. Замер пака P11: при трёх параллельных прогонах краснеет в ЧИСТЫХ копиях (2 раза из 15); в СЕРИЙНОМ прогоне зелен — пере-проверено трижды подряд отдельным прогоном, все три `ok`. ⚠ **ВТОРАЯ ТОЧКА, 29.08, внешнее ревью:** упал 1 раз из 4 ПОЛНЫХ прогонов зоны со всеми гейтами — то есть краснеет и без параллельных копий, просто редко. Изолированно 5/5, пакет целиком 2/2, три последующих полных прогона чистые; сообщение поймать не удалось, и ревьюер честно остановился на «редком флейке контенции», диагноз НЕ установлен. Пакет ни одним коммитом эры P11 не тронут. ⚠ Обе точки вместе сдвигают формулировку: это не «краснеет от параллельной нагрузки», а «редкая гонка, которую нагрузка делает вероятнее», — и мой первый диагноз («флейк параллельных батарей») был выведен из совпадения, ровно как в `PD-423`. Меряет ресурс, общий для копий на машине. ⚠ Цена не косметическая: **красная ЧИСТАЯ копия маскирует дельту**, а красный прогон посадки читается как «мутация поймана», когда она не поймана, — ровно та ложная улика, ради которой мутации и сажают. Обход пака — судить по ДЕЛЬТЕ множеств, а не по коду выхода; лечение — изоляция ресурса либо честный скип под нагрузкой ⚠ **ПОПРАВКА, внесённая до сдачи:** первая редакция этой строки называла вторым фигурантом `TestARunIsBoundedByItsOwnCgroup` — НЕВЕРНО, и это моя ошибка вывода из совпадения. Он краснеет не от нагрузки, а от состояния ХОСТА, и вынесен отдельной строкой `PD-423` | open | пак P11, мутационная кампания (15 прогонов) + пере-проверка серийными прогонами | -| PD-423 | standards | minor | `internal/runner/systemd_test.go` `TestARunIsBoundedByItsOwnCgroup`, `docs/STACK_DECISIONS.md` «Гейты батареи» | **Батарея зоны требует ЧЕТВЁРТОГО условия хоста, которого рецепт не называет: пользовательский менеджер systemd должен РЕАЛЬНО применять `MemoryMax` к транзиентным юнитам.** Замерено на этом хосте 29.08: тест трижды подряд зелен в полных батареях (`baseline2`, `final2`, `final4`), затем **пять раз подряд красен в изоляции** — при неизменном коде пакета, которого пак не касался вовсе. Причина установлена ВНЕ батареи и вне Go: `systemd-run --user --scope -p MemoryMax=64M …` даёт процессу спокойно занять 400 МиБ и выйти с кодом 0. ⚠ **МЕХАНИЗМ уточнён приёмкой оркестратора №19, и уточнение решает, воспроизводимо ли это:** «systemd не применяет потолок» верно по симптому и мимо по причине. Свойство ДОЕХАЛО — `systemctl --user show -p MemoryMax` печатает `67108864`; делегирование в порядке — `memory pids` и в `cgroup.controllers`, и в `subtree_control`. Пропал не потолок, а cgroup-КАТАЛОГ: в `app.slice` нет ни одного scope-каталога, а `cut -d: -f3 /proc/self/cgroup` для оболочки даёт **`/init.scope`**. То есть вызывающий процесс живёт ВНЕ `user@.service`; `systemd-run --user` заводит юнит в модели менеджера, а процесс остаётся в исходном cgroup, и лимита не получает никто — молча. ⚠ Отсюда и наблюдение «условие отваливается между двумя прогонами одной сессии»: оно зависит от того, из какого cgroup стартовал прогон. Тест при этом ПРАВ и его сообщение точное («check that the leaf cgroup of tm-runs.slice has memory.max»): он ловит ровно то, ради чего написан, — что потолок памяти прогона на этом хосте иллюзорен. ⚠ Следствие для процесса, а не только для теста: рецепт `STACK_DECISIONS` называет три условия батареи, а их четыре, и четвёртое — свойство ХОСТА, которое может отвалиться между двумя прогонами в одной сессии, что здесь и произошло. Сессия, наступившая на это, потратит время на поиск дефекта в своём диффе. ⚠ **Условие для рецепта формулируется НЕ так, как оно там сейчас стоит.** «Достижимый пользовательский менеджер systemd» выполнено — менеджер отвечает, `tm-runs.slice` виден, — и всё равно лимит не применяется. Правильная формулировка: **вызывающий процесс обязан жить ВНУТРИ `user@.service`** (проверка: `cut -d: -f3 /proc/self/cgroup` не должен давать `/init.scope`). Оболочка, поднятая вне пользовательского входа — под WSL это обычный случай, — проходит первую проверку и валит вторую, и следующий читатель решит, что условие выполнено, и пойдёт искать дефект в Go. Лечение: назвать условие в рецепте именно так и дать тесту различать «хост не применяет лимит» (честный скип с причиной) и «раннер не передал лимит» (настоящий отказ) ⚠⚠ **ВТОРАЯ ТОЧКА, 29.08, и она ПРОТИВОРЕЧИТ механизму выше — строку не закрывать, а пере-проверить.** Пак `sqlc` на ТОМ ЖЕ хосте и при том же `cut -d: -f3 /proc/self/cgroup` = **`/init.scope`** получил тест **зелёным 5 из 5 в ИЗОЛЯЦИИ** (протокол, в котором приёмка №19 видела 5 из 5 красных) плюс трижды в полных батареях; скипов в этих прогонах **ноль** — проверено по логам, то есть `systemdOrSkip` и проверка `python3` не срабатывали и тест НЕ был пустым: он требует настоящего `oom-kill` после касания 400 МиБ под `MemoryMax=64M`. Прямая проба механизма: `systemd-run --user --scope -p MemoryMax=64M -- sh -c 'cut -d: -f3 /proc/self/cgroup'` печатает **`/user.slice/user-1000.slice/user@1000.service/app.slice/run-….scope`**, то есть процесс ВСЁ-ТАКИ попадает внутрь `user@.service`, а не остаётся в исходном cgroup. Сам `tm-runs.slice` при этом существует и лежит глубже, чем ищут: `user@1000.service/**tm.slice**/tm-runs.slice` (`cgroup.controllers` = `memory pids`). **Следствие практическое:** предложенная этой же строкой одна команда-проверка (`/proc/self/cgroup` не должен давать `/init.scope`) на этом хосте даёт ЛОЖНЫЙ ОТРИЦАТЕЛЬНЫЙ — она говорит «условие не выполнено» там, где лимит применяется и тест честно зелёный. Значит cgroup ВЫЗЫВАЮЩЕГО процесса условие не предсказывает, диагноз строки неполон, и в рецепт `STACK_DECISIONS` эту команду в нынешнем виде вносить нельзя. Что различает две точки — не установлено; кандидат — состояние `cgroup.subtree_control` целевого среза в момент прогона (сейчас у `tm-runs.slice` он пуст, а systemd включает контроллер сам при старте юнита с лимитом). | open | пак P11 (финальная батарея; воспроизведено голым `systemd-run` вне Go) | +| PD-420 | bug | minor | `internal/pgstore/runs_test.go` `TestAClaimThatLostARaceToAReleaseIsRetriedAndNotAnError` | **Тест гонки холда против релиза краснеет под ПАРАЛЛЕЛЬНЫМИ батареями, а зона ратифицировала рецепт, который их требует.** `D39.159` §2 предписывает сажать мутации в КОПИЮ дерева, и всякая сессия, которая делает это всерьёз, гоняет несколько батарей разом. Замер пака P11: при трёх параллельных прогонах краснеет в ЧИСТЫХ копиях (2 раза из 15); в СЕРИЙНОМ прогоне зелен — пере-проверено трижды подряд отдельным прогоном, все три `ok`. ⚠ **ВТОРАЯ ТОЧКА, 29.08, внешнее ревью:** упал 1 раз из 4 ПОЛНЫХ прогонов зоны со всеми гейтами — то есть краснеет и без параллельных копий, просто редко. Изолированно 5/5, пакет целиком 2/2, три последующих полных прогона чистые; сообщение поймать не удалось, и ревьюер честно остановился на «редком флейке контенции», диагноз НЕ установлен. Пакет ни одним коммитом эры P11 не тронут. ⚠ Обе точки вместе сдвигают формулировку: это не «краснеет от параллельной нагрузки», а «редкая гонка, которую нагрузка делает вероятнее», — и мой первый диагноз («флейк параллельных батарей») был выведен из совпадения, ровно как в `PD-423`. Меряет ресурс, общий для копий на машине. ⚠ Цена не косметическая: **красная ЧИСТАЯ копия маскирует дельту**, а красный прогон посадки читается как «мутация поймана», когда она не поймана, — ровно та ложная улика, ради которой мутации и сажают. Обход пака — судить по ДЕЛЬТЕ множеств, а не по коду выхода; лечение — изоляция ресурса либо честный скип под нагрузкой ⚠ **ПОПРАВКА, внесённая до сдачи:** первая редакция этой строки называла вторым фигурантом `TestARunIsBoundedByItsOwnCgroup` — НЕВЕРНО, и это моя ошибка вывода из совпадения. Он краснеет не от нагрузки, а от состояния ХОСТА, и вынесен отдельной строкой `PD-423` ⚠ **пак P12 (30–31.08): пере-замерена ПОСЛЕ фикса `PD-369`, серийно — 80 из 80 зелёных, 0 FAIL, 0 SKIP** (`-count=80`, счёт по `^--- PASS`). Это снимает ПЕРВУЮ из двух точек строки по построению: флейк был не в тесте, а в том, что исчерпание раундов отвечало 500, и тест это честно ловил. ⚠ **ВТОРАЯ точка (редкая гонка контенции, 1 из 4 полных прогонов, диагноз НЕ установлен) пере-замерена под ТРЕМЯ параллельными полными батареями в чистых копиях: 18/18 пакетов в каждой, 0 красных, EXIT=0 ×3.** Это ОДНА точка, а не опровержение: строка сама говорит, что краснеет редко (1 из 4), и три чистых прогона такой частоты не исключают. Строка остаётся открытой на ней с дополненным замером; закрыть её может только серия, а не прогон. | open | пак P11, мутационная кампания (15 прогонов) + пере-проверка серийными прогонами | +| PD-423 | standards | minor | `internal/runner/systemd_test.go` `TestARunIsBoundedByItsOwnCgroup`, `docs/STACK_DECISIONS.md` «Гейты батареи» | **Батарея зоны требует ЧЕТВЁРТОГО условия хоста, которого рецепт не называет: пользовательский менеджер systemd должен РЕАЛЬНО применять `MemoryMax` к транзиентным юнитам.** Замерено на этом хосте 29.08: тест трижды подряд зелен в полных батареях (`baseline2`, `final2`, `final4`), затем **пять раз подряд красен в изоляции** — при неизменном коде пакета, которого пак не касался вовсе. Причина установлена ВНЕ батареи и вне Go: `systemd-run --user --scope -p MemoryMax=64M …` даёт процессу спокойно занять 400 МиБ и выйти с кодом 0. ⚠ **МЕХАНИЗМ уточнён приёмкой оркестратора №19, и уточнение решает, воспроизводимо ли это:** «systemd не применяет потолок» верно по симптому и мимо по причине. Свойство ДОЕХАЛО — `systemctl --user show -p MemoryMax` печатает `67108864`; делегирование в порядке — `memory pids` и в `cgroup.controllers`, и в `subtree_control`. Пропал не потолок, а cgroup-КАТАЛОГ: в `app.slice` нет ни одного scope-каталога, а `cut -d: -f3 /proc/self/cgroup` для оболочки даёт **`/init.scope`**. То есть вызывающий процесс живёт ВНЕ `user@.service`; `systemd-run --user` заводит юнит в модели менеджера, а процесс остаётся в исходном cgroup, и лимита не получает никто — молча. ⚠ Отсюда и наблюдение «условие отваливается между двумя прогонами одной сессии»: оно зависит от того, из какого cgroup стартовал прогон. Тест при этом ПРАВ и его сообщение точное («check that the leaf cgroup of tm-runs.slice has memory.max»): он ловит ровно то, ради чего написан, — что потолок памяти прогона на этом хосте иллюзорен. ⚠ Следствие для процесса, а не только для теста: рецепт `STACK_DECISIONS` называет три условия батареи, а их четыре, и четвёртое — свойство ХОСТА, которое может отвалиться между двумя прогонами в одной сессии, что здесь и произошло. Сессия, наступившая на это, потратит время на поиск дефекта в своём диффе. ⚠ **Условие для рецепта формулируется НЕ так, как оно там сейчас стоит.** «Достижимый пользовательский менеджер systemd» выполнено — менеджер отвечает, `tm-runs.slice` виден, — и всё равно лимит не применяется. Правильная формулировка: **вызывающий процесс обязан жить ВНУТРИ `user@.service`** (проверка: `cut -d: -f3 /proc/self/cgroup` не должен давать `/init.scope`). Оболочка, поднятая вне пользовательского входа — под WSL это обычный случай, — проходит первую проверку и валит вторую, и следующий читатель решит, что условие выполнено, и пойдёт искать дефект в Go. Лечение: назвать условие в рецепте именно так и дать тесту различать «хост не применяет лимит» (честный скип с причиной) и «раннер не передал лимит» (настоящий отказ) ⚠⚠ **ВТОРАЯ ТОЧКА, 29.08, и она ПРОТИВОРЕЧИТ механизму выше — строку не закрывать, а пере-проверить.** Пак `sqlc` на ТОМ ЖЕ хосте и при том же `cut -d: -f3 /proc/self/cgroup` = **`/init.scope`** получил тест **зелёным 5 из 5 в ИЗОЛЯЦИИ** (протокол, в котором приёмка №19 видела 5 из 5 красных) плюс трижды в полных батареях; скипов в этих прогонах **ноль** — проверено по логам, то есть `systemdOrSkip` и проверка `python3` не срабатывали и тест НЕ был пустым: он требует настоящего `oom-kill` после касания 400 МиБ под `MemoryMax=64M`. Прямая проба механизма: `systemd-run --user --scope -p MemoryMax=64M -- sh -c 'cut -d: -f3 /proc/self/cgroup'` печатает **`/user.slice/user-1000.slice/user@1000.service/app.slice/run-….scope`**, то есть процесс ВСЁ-ТАКИ попадает внутрь `user@.service`, а не остаётся в исходном cgroup. Сам `tm-runs.slice` при этом существует и лежит глубже, чем ищут: `user@1000.service/**tm.slice**/tm-runs.slice` (`cgroup.controllers` = `memory pids`). **Следствие практическое:** предложенная этой же строкой одна команда-проверка (`/proc/self/cgroup` не должен давать `/init.scope`) на этом хосте даёт ЛОЖНЫЙ ОТРИЦАТЕЛЬНЫЙ — она говорит «условие не выполнено» там, где лимит применяется и тест честно зелёный. Значит cgroup ВЫЗЫВАЮЩЕГО процесса условие не предсказывает, диагноз строки неполон, и в рецепт `STACK_DECISIONS` эту команду в нынешнем виде вносить нельзя. Что различает две точки — не установлено; кандидат — состояние `cgroup.subtree_control` целевого среза в момент прогона (сейчас у `tm-runs.slice` он пуст, а systemd включает контроллер сам при старте юнита с лимитом). ⚠ **ТРЕТЬЯ ТОЧКА, пак P12 (30–31.08), и она согласна со второй:** у оболочки этой сессии `cut -d: -f3 /proc/self/cgroup` даёт **`/`** (не `/init.scope` и не путь внутри `user@.service`), а `TestARunIsBoundedByItsOwnCgroup` при этом ЗЕЛЁН в полной батарее со скипами 0 — проверено четырьмя полными прогонами. Прямая проба `systemd-run --user --scope` кладёт процесс в `…/user@1000.service/app.slice/run-….scope`; `tm-runs.slice` существует, `cgroup.controllers` = `memory pids`, а его `cgroup.subtree_control` ПУСТ — и тест всё равно зелен. То есть команда-проверка не предсказывает условие ни в одну сторону, и она **СНЯТА из рецепта** `STACK_DECISIONS` этим паком (см. `PD-432`). Что различает точки — по-прежнему НЕ УСТАНОВЛЕНО; строка остаётся открытой на диагнозе, а не на рецепте. | open | пак P11 (финальная батарея; воспроизведено голым `systemd-run` вне Go) | | PD-426 | bug | minor | `internal/pgstore/runs.go` `Quarantine`, `internal/ingest/tail.go` (четыре отказа выше ветки `default`) | **Карантин проекции не снимается НИЧЕМ, а попасть в него можно по чужому законному handshake'у.** Первое: `quarantine_reason` пишется, и во всём дереве нет ни одного места, которое его очищает, — то есть состояние терминально для проекции живого оплаченного прогона. Второе: в `ingest/tail.go` четыре отказа стоят ВЫШЕ ветки `default`, которая говорит «другой поток начинается здесь, нас не касается», при том что журнал ПЕР-КНИЖНЫЙ и append-only, так что чужие handshake'ы в нём законны. Вместе: чужой handshake в журнале книги карантинит проекцию прогона, за который заплачено, навсегда. ⚠ Не предмет пака P11 (тейлер и карантин — эры P4/P5), заведено строкой | open | приёмка оркестратора №19 по паку P11 (охотник вне карты) | -| PD-427 | doc | minor | `internal/ingest/resync.go:37-43` (аллоулист `StatusReport`), опровергнуто `backend/internal/pipeline/status.go` `projectStoredMemory` (лендинг `6ec9f8a`, D39.170) | **Комментарий несёт ПОСЫЛКУ, которую сняли, и читается как действующий довод.** Он объясняет, почему платформа сознательно НЕ берёт `rebill_units`/`rebill_usd` через шов: «status проецирует СОХРАНЁННУЮ память, и сразу после `bank-apply` — в единственный момент, когда согласие хотело бы цифру, — он честно читает ноль». Это было верно и ратифицировано (эррата 28.08-к). Движковый пак «деньги» починил ровно это: `foldMemoryForRead` стал ПЕРВЫМ ответом читающего пути, а `projectStoredMemory` понижена до фолбэка, и комментарий движка объявляет это дословно — «IT IS NO LONGER THE READ PATH'S FIRST ANSWER». Слепое окно закрыто, `status` отвечает «сколько будет стоить» ДО покупки, оставаясь $0-глаголом без записи. ⚠ **Комментарий неверен ДВАЖДЫ:** не только посылка, но и предсказанное лечение — он обещает, что «пара вернётся с движковым ГЛАГОЛОМ, который умеет свернуть и оценить коррекцию ВНЕ прогона», а нового глагола не появилось: починили существующий `status`. ⚠ **ПРОВОДКУ ПОЛЕЙ ЭТА СТРОКА НЕ ОТКРЫВАЕТ** (слово оркестратора при передаче): она гейчена вместе с `tmctl translate --max-units`, и тот гейт в силе — движковый потолок объёма на майнящей банк книге пробивался, лечение легло, но проводка ждёт отдельного решения. То есть предмет строки — ровно устаревший ДОВОД, а не отсутствие полей. Класс — «указатель пережил то, на что указывал», тот же, что `PD-310`/`PD-326`/`PD-366`, только в прозе шва. Зеркалит строку 234 единого бэклога | open | оркестратор №19 при лендинге движкового пака (`6ec9f8a`), проверено чтением обеих сторон сессией P11 | | PD-250 | vuln | minor | `cmd/tmplatformd/main.go` (слушатель метрик) | **`/metrics` отдаётся БЕЗ аутентификации; вся защита — привязка к `127.0.0.1`.** Для одной VM это честная граница, и она записана (STACK §24). Но на хосте с несколькими пользователями любой локальный процесс читает оперативную картину сервиса, а на деплое, где слушатель однажды переедет на `0.0.0.0` «чтобы Prometheus дотянулся», защиты не останется вовсе. Денег в метриках нет (D39.84), поэтому это minor, а не major. Лечение — bearer-токен на слушателе или mTLS, решать при первом внешнем Prometheus ⚠ **ПАК P8-REVIEW 24.08: это ТОТ ЖЕ факт, что `PD-179`, и он стоит в регистре в ДВУХ статусах одновременно** (`accepted-risk(платформа P5, 11.08)` против `open`). Код и ручка одни: `cmd/tmplatformd/main.go` `serveMetrics` без аутентификации, дефолт `127.0.0.1:9464`; рантбук `deploy/README.md` называет строкой риска именно `PD-179`. Своя добавка у этой строки есть (многопользовательский хост), но статус один факт должен нести один. Предложение пака: свести ⚠⚠ **Условие сведения, найденное рефутером:** у `PD-179` довод про ОДНУ VM, а добавка этой строки — многопользовательский хост, где любой локальный непривилегированный процесс скрейпит экспозицию, — в `PD-179` ОТСУТСТВУЕТ. Плюс при сведении из выборок безопасности исчезает класс `vuln` (у `PD-179` он `hardening`). Сводить только ВМЕСТЕ с перенесённой фразой и с пометкой класса | open | research/28 §9 (пинг оркестратора №17), сверено P7 | -| PD-431 | hardening | minor | `internal/pgstore/sessions.go:49` `Touch`, `internal/pgstore/queries/sessions.sql` `TouchSession`, пин отсутствует | **`Touch` выбрасывает `RowsAffected`, поэтому запрос, не обновивший НИ ОДНОЙ строки, неотличим от успеха — и батарея остаётся зелёной.** Замерено адверсариальным проходом пака `sqlc` 29.08: `where token_sha256 = $1` → `where 1 = 0 and token_sha256 = $1`, апдейт не трогает ничего — **18 пакетов, `EXIT=0`, ноль красных**. Контроль на том же стенде (обезвреженный `delete from reservations` в `DeleteBook`) даёт две красных, то есть харнесс работает и молчание относится к `Touch`, а не к прогону. Продуктовый смысл: скольжение окна бездействия — то, что держит активного пользователя в сессии, — не проверяет ни один живой тест; два его вызова (`pg_test.go:115`, `TestTouchCannotResurrectAnIdleExpiredSession`) решаются предикатами самого SQL и остаются истинными, если `Touch` не делает ничего. ⚠ **Класс, который ПОКРЫТИЕМ не ловится в принципе:** для `Exec`, чей тег отброшен, «оператор исполнился» — это всё, что покрытие когда-либо докажет; нужен мутационный проход по ЗАПИСЯМ. Именно поэтому пак `sqlc`, считавший непокрытые строки, нашёл три опасных оператора, а этот — четвёртый — не нашёл. ⚠ **Паком `sqlc` НЕ чинится СОЗНАТЕЛЬНО, и довод принят оркестратором №19:** сделать запрос `:execrows` и проверять число строк — изменение ПОВЕДЕНИЯ (сегодня ноль строк это норма: `Touch` зовут после успешного `Lookup`, но и гонка с отзывом законна), а пак конвертировал, а не менял семантику. Лечение — решение о контрактном поведении: либо проверять тег и отвечать, либо оставить и запинить скольжение окна сквозным тестом через `auth.Authenticator` (сегодня ни один тест не связывает живой `*pgstore.Store` с ним) | open | адверсариальный проход пака `sqlc` (П-19), 29.08 | -| PD-432 | doc | minor | `docs/STACK_DECISIONS.md` §«Гейты батареи» (рецепт стенда), `~/.local/bin/tmctl` | **Рецепт второго гейта батареи не говорит, что движковый бинарь надо ПЕРЕСОБРАТЬ, и устаревший бинарь даёт КРАСНУЮ батарею, которая читается как дефект кода зоны.** Замерено 29.08 паком `sqlc`: `tmctl` стенда от 24.08 против сегодняшнего `backend/configs/models.yaml` — три красных теста в `internal/books` и `internal/runner`, сообщение `tmctl: config: parse …/models.yaml: yaml: unmarshal errors: line 137: field system_messages not found in type config.CapabilitiesConfig`. Диагноз стоит времени именно потому, что выглядит как ошибка платформы: падает платформенный тест, а лжёт бинарь движка, собранный до того, как в конфиг движка приехало поле. Лечение `go build -o <стенд>/tmctl ./cmd/tmctl` из `backend/` — после него те же пакеты зелёные. ⚠ Класс тот же, что у `PD-423`: условие батареи, которое рецепт называет неполно, и следующая сессия ищет дефект в своём диффе. Дописать в рецепт: движковый бинарь стенда обязан быть собран из ТЕКУЩЕГО `backend/`, а не переиспользован | open | пак `sqlc` (П-19), 29.08, найдено первым же прогоном полной батареи | ## Открытые — info | ID | Класс | Серьёзность | Где | Суть | Статус | Источник | |---|---|---|---|---|---|---| -| 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-396 | standards | info | `internal/pgstore/books.go:311`=`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:1124`=`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` ⚠ **ПОЛОВИНА ЗАКРЫТА паком `sqlc` (`63fcee5`), и обе половины пере-проверены посадками — строка остаётся `open` ровно на остатке.** ЗАКРЫТ сам свип-предикат: `DeleteOldLoginEvents` теперь зовёт `pgstore.TestTheLoginJournalRetentionDeletesOnlyWhatIsOlderThanTheCutoff`, и он утверждает ЧИСЛО удалённых строк плюс границу (в фикстуре есть строка РОВНО на отсечке, потому что предикат строгий и без неё `<` неотличимо от `<=`). Мутация M16 (`and 1=0`) теперь красная адресно — «deleted 0 rows, want exactly 1». **НЕ закрыто и остаётся живым: сам СРОК хранения и вызов свипа.** Мутация M15 — `loginJournalRetention` 180 суток → 180 ЛЕТ (`cmd/tmplatformd/main.go:257`) — пере-прогнана 29.08 на полной батарее конвертированного дерева: **EXIT=0, ноль красных**. Причина остатка структурная и не лечится в `pgstore`: константа и `sweepLogins` живут в пакете `main`, куда тест `pgstore` не достаёт. То есть класс `PD-1` здесь снят с ЗАПРОСА и стоит на КОНФИГУРАЦИИ | open | ревью-пак P8-REVIEW, ось 2 (находка рефутера, живая проба координатора) | @@ -89,11 +80,10 @@ | PD-298 | bug | info | `internal/pgstore/readmodel.go` `ListNotes`, `internal/pgstore/sink.go` `unitDone` | **Снятие флага с замечания дельта-чтение выразить не может.** Резолюция, пере-разрешённая как не-`flagged` (редрайв), обновляет строку и двигает `revision`, но дельта фильтруется предикатом `ur.flagged` — строка не возвращается, и клиент никогда не узнаёт, что замечание снято: оно остаётся на экране навсегда. Канон §AfterVersion: «A DELETION cannot be expressed this way», и требует одного из двух ответов — `resync_required` либо `400 version_too_old`; здесь не даётся ни один. ⚠ НЕ подтверждено, что движок вообще пере-издаёт `unit_done` для той же тройки (глава, юнит, волна) с `flagged=false` — комментарий `sink.go` это УТВЕРЖДАЕТ («a redrive re-attacks a flagged one»), но чтением движка не сверено. Порядок: сначала сверка у движка, потом либо счётчик замены для замечаний, либо строка «переход недостижим» ⚠ **ДИСПОЗИЦИЯ АКТА 5 (сверка с движком контрактной сессией 20.08): переход НЕДОСТИЖИМ и механизма не строим.** Движок объявляет вердикт юнита один раз на волну, его announce-once-леджер не пере-announce-ит, поэтому единственная пере-доставка — ТОТ ЖЕ вердикт. Инвариант записан в коде (`sink.go` `unitDone`): если что-то научится снимать флаг, фолд обязан выдать кадр — иначе счётчик разъедется со списком молча. Строка держится открытой этим долгом, а не живым дефектом | open | приёмка P7 → доработка 20.08 (сверка) | | PD-299 | standards | info | `internal/httpapi/conditional.go` `acceptsGzip` | **`Accept-Encoding: identity;q=0` не отвечает `406`.** Клиент, потребовавший ЛЮБОГО кодирования кроме identity, получает identity. Половина RFC 9110 §12.5.3, которую правка PD-268 не закрыла: gzip-сторона (именованное кодирование выигрывает у `*`, нулевой вес — отказ) закрыта и пиньётся, эта — нет. Достижимо только специально сконструированным клиентом; ни один генерённый по контракту клиент так не делает | open | доработка 20.08 (сверка находок против дерева) | | PD-368 | hardening | info | `internal/config/config.go`, `internal/runs/reconcile.go` `phaseBudget` | **Пара `TM_PLATFORM_SWEEP_BUDGET`/`TM_PLATFORM_RUN_BUDGET` не проверяется на когерентность на буте, и поднять ОДИН из них — тихий no-op:** фазе достаётся половина прохода, поэтому любое значение `RunBudget` от половины прохода и выше наблюдаемо неотличимо от дефолта. Ревью заходило сюда как в major («поднять бюджет = объявить здоровый прогон застрявшим») и **это опровергнуто исполнением**: поднятие ручки не меняет вообще ничего, вердикт при дефолтах существует и без оператора, а строгая проверка `RunBudget < SweepBudget/2` отвергла бы сами шиппящиеся дефолты (60 с против 60 с). Остаётся эргономика: поднимать надо `SweepBudget`, и об этом не сказано нигде, кроме доккоммента ⚠ **ПАК P8-REVIEW 24.08: остаток («поднимать надо `SweepBudget`, и об этом не сказано нигде, кроме доккомментария») УЖЕ ЗАКРЫТ, и закрыт тем же паком, чья приёмка эту строку написала.** `deploy/README.md` несёт таблицу обеих ручек и прямо под ней ⚠-абзац «Поднимать надо ПАРУ, а не одну… `RUN_BUDGET` выше половины `SWEEP_BUDGET` наблюдаемо ничего не меняет», со ссылкой на этот же номер. Верным остаётся только то, что когерентность пары не проверяется на буте. Предложение пака: сузить до этого или закрыть. ⚠ Связанное, но ДРУГОЕ: сама ручка не покрывает такт свипа целиком — `PD-387` ⚠⚠ **Уточнение рефутера, меняющее диспозицию: ЗАКРЫВАТЬ строку ЦЕЛИКОМ нельзя, только сузить.** Абзац рантбука приехал коммитом `31f1f82` — ТЕМ ЖЕ, который внёс саму строку, то есть остаток родился уже закрытым. А проверки когерентности пары на буте по-прежнему нет: `internal/config/config.go:460-463` грузит обе ручки без сверки. Сузить до этого и оставить `open`. ⚠ И соседство: абзац стоит под строкой `deploy/README.md:311`, которая сама предмет открытой `PD-387` | open | воркфлоу-ревью волны 2 (P8-FIX), находка опровергнута, остаток зафиксирован | -| PD-373 | doc | info | `internal/readmodel/readmodel.go:64`=`const maxAttempts = 5`, `deploy/README.md:296`=`После пяти неудач` | **У числа попыток материализации ДВА носителя и ничего между ними.** Код держит `const maxAttempts = 5`, рантбук оператора пишет «После пяти неудач долг списывается». **Посажена мутация оркестратором вне списка автора:** `maxAttempts` 5 → 500000, батарея зелёная — пин `readmodel_test.go` ездит `for attempts := range maxAttempts`, то есть доказывает МЕХАНИЗМ при любом значении константы, что само по себе правильно. Незакрытым остаётся другое: подняли константу — рантбук молча начал лгать оператору о том, когда платформа сдаётся. ⚠ Тот же ход у `runs.StalledAfter` проверен и НАРУШЕНИЯ НЕ ДАЛ: там носитель ровно один (рантбук пишет «сколько неудач подряд» без числа), мутация тоже выжила и это законно. Лечение — либо гейт на второй носитель ровно той формы, что пак построил для `ContractVersion` (`internal/gates/contract_test.go` читает канон, а не копию числа), либо число уходит из прозы | open | приёмка P8-FIX (посадка мутации оркестратором №18) | +| PD-373 | doc | info | `internal/readmodel/readmodel.go:64`=`const maxAttempts = 5`, `deploy/README.md:318`=`После пяти неудач` | **У числа попыток материализации ДВА носителя и ничего между ними.** Код держит `const maxAttempts = 5`, рантбук оператора пишет «После пяти неудач долг списывается». **Посажена мутация оркестратором вне списка автора:** `maxAttempts` 5 → 500000, батарея зелёная — пин `readmodel_test.go` ездит `for attempts := range maxAttempts`, то есть доказывает МЕХАНИЗМ при любом значении константы, что само по себе правильно. Незакрытым остаётся другое: подняли константу — рантбук молча начал лгать оператору о том, когда платформа сдаётся. ⚠ Тот же ход у `runs.StalledAfter` проверен и НАРУШЕНИЯ НЕ ДАЛ: там носитель ровно один (рантбук пишет «сколько неудач подряд» без числа), мутация тоже выжила и это законно. Лечение — либо гейт на второй носитель ровно той формы, что пак построил для `ContractVersion` (`internal/gates/contract_test.go` читает канон, а не копию числа), либо число уходит из прозы | open | приёмка P8-FIX (посадка мутации оркестратором №18) | | PD-374 | doc | info | `docs/STACK_DECISIONS.md` «Гейты батареи», `internal/runner/systemd_test.go` `systemdOrSkip`, `Makefile` цель `check` | **Рецепт объявляет у батареи ДВА гейта и ждёт «скипов 0» — а условий три, и третье не названо.** Кроме `TM_PLATFORM_TEST_DSN` и пары `TM_PLATFORM_TEST_ENGINE_BIN`/`_BOOK_TEMPLATE` есть `systemdOrSkip`: без ДОСТИЖИМОГО пользовательского менеджера systemd три теста `internal/runner` скипаются. Поймано пере-прогоном батареи при приёмке: на этом хосте `/run/user/1000` не существует (сессия logind не поднята), поэтому «скипов 0» недостижимо в принципе — `make check` дал 18 пакетов, exit 0, линтер 0 issues и **3 скипа**. Мимо: сама цель `check` печатает над списком скипов «set TM_PLATFORM_TEST_DSN», отправляя читателя к ручке, которая тут ни при чём. Отчёт пака честен и это подтверждает — он мерил отдельно и получил 0, что верно на хосте с живым менеджером. Лечение: назвать третий гейт в рецепте вместе с двумя и не обещать «скипов 0» без него; заодно сделать сообщение цели `check` не называющим одну переменную из трёх ⚠ **ПАК P8-REVIEW 24.08: половина лечения УЖЕ в дереве.** `docs/ENGINEERING_STANDARDS.md` §3 п.1 переписан и называет ТРИ условия поимённо, включая достижимый пользовательский менеджер systemd, со ссылкой на этот номер. Названные строкой носители не тронуты: `docs/STACK_DECISIONS.md` по-прежнему пишет «Гейты батареи — их ДВА» и «скипов 0», а сообщение цели `check` в `Makefile` называет одну переменную из трёх. Предложение пака: сузить до этих двух. ⚠ На ЭТОМ хосте все три условия выполнимы (`/run/user/1000` жив), поэтому пак снял базовую линию со скипами 0 — см. `docs/p8-review/battery-final.log` | open | приёмка P8-FIX (пере-прогон батареи оркестратором №18) | | PD-6 | hardening | info | `internal/auth/csrf.go:51` | GET освобождён от CSRF (верно), но SSE-хендшейк — GET с амбиентной кукой: origin-чек хендшейка потока (STACK §5) не покрыт ничем. Закрыть при постройке SSE (P1) ⚠ **ПАК P8-REVIEW 24.08: условие закрытия НАСТУПИЛО, лекарство не приехало, а защита оказалась ТРАНЗИТИВНОЙ.** SSE построен (`internal/httpapi/v0.go` маршрут `/books/{bookId}/events`, `internal/httpapi/stream.go` `streamEvents`, коммит `9b23e8c`), origin-чека не появилось: GET освобождён в `internal/auth/csrf.go` `cookieUnsafe`, а `http.CrossOriginProtection` судит только unsafe-методы. Живая проба: хендшейк с `Origin: https://evil.example` и амбиентной кукой отвечает 200 и стримит, без куки 401, заголовок `X-TM-Client` не требуется (`docs/p8-review/sse-origin-probe.txt`). ⚠ Вес поднимать НЕ предлагается, и это измеренная поправка к предложению аудита: браузерный случай сегодня закрыт ТРЕМЯ механизмами, ни один из которых не является чеком хендшейка — кука `SameSite=Lax` не уходит на кросс-сайтовый подзапрос, префикс `__Host-` не отдаёт её соседнему поддомену, а CORS-слоя нет вовсе (`PD-96`), поэтому кросс-origin `EventSource` браузер странице не отдаст. Это ровно класс `PD-86`: свойство держится, и держат его посторонние механизмы, ни один из которых не запинен как защита хендшейка. Диспозиция — вопрос приёмке: чек хендшейка или явное принятие с записью трёх носителей | open | приёмка P0 (security-линза) | | PD-23 | hardening | info | `internal/pgstore/migrations/00001_identity.sql` | Журнал входов растёт без ретенции и чистится только каскадом при удалении аккаунта. Нужен свип по возрасту (год?) — вопрос политики, не кода ⚠ **ПРОВЕРЕНО ПАКОМ P8-REVIEW 24.08 И ПРОТИВ ДЕРЕВА ЛОЖНО: ретенция ПОСТРОЕНА и РАБОТАЕТ.** `internal/pgstore/identity.go` `DeleteOldLoginEvents` (`delete from login_events where at < $1`) зовётся из `cmd/tmplatformd/main.go` циклом `sweepLogins` каждые 15 минут с окном `loginJournalRetention = 180 * 24 * time.Hour`, и свип монтируется в ОБЕИХ ветках входа. Приехало коммитом `9b23e8c` (лендинг P7), строка не обновлялась с P1. Доказано не чтением: строка возрастом 200 суток исчезла на ближайшем тике, демон напечатал `"login sweep" events=1` (`docs/p8-review/pd23-result.txt`). Предложение пака: ЗАКРЫТЬ, срок 180 суток отметить как выбранную политику. Незапиненность самого механизма вынесена отдельной строкой `PD-383` | open | самопроверка P1 | -| PD-44 | hardening | info | `internal/pgstore/` | `sqlc` не взят, хотя направление §3 предписывает взять его ДО появления денежных таблиц. Весь денежный SQL — сырые строки pgx ⚠ **P8-FIX: половина, которая ЛЕЧИТ класс, построена; сам инструмент — вопрос владельцу.** Рантайм-ошибки «нет такой колонки» (`r.stop_for_signing`, `chapters_before`) случились в СКЛЕЕННОМ SQL read-модели, куда sqlc по построению не доходит, поэтому тем же пунктом заведён постоянный гейт, который доходит: `pgstore.TestEverySQLStatementParsesAgainstTheMigratedSchema` сворачивает КАЖДЫЙ SQL пакета из исходника (литералы, конкатенации, именованные константы) и планирует его Postgres'ом (`explain (generic_plan)`) против мигрированной схемы — **162 оператора, все планируются**. Посадка мутации в СКЛЕЕННЫЙ фрагмент (`c.units_edit_done` → несуществующая колонка) гейтом ловится; несворачиваемый SQL — ОШИБКА гейта, а не пропуск (единственное исключение — `store.go` `Ready`, где имя таблицы принадлежит goose, и оно выписано таблицей в самом гейте). Тем же гейтом закрыт открытый вопрос фикс-листа «есть ли в read-модели запрос, которого не касается ни один тест»: теперь его касаются все, на каждом прогоне батареи. **ГРАНИЦА sqlc, названная явно (промт §4.1 «реши сам и аргументируй»):** склеек в пакете **25 мест из 147**, фрагментов-констант **15**, у `lastRun` девять потребителей, у `nextRevisionOfThisBooksLibrary` восемь — read-модель для sqlc недостижима, и это пере-считано, а не вспомнено. Свободных от склейки файлов целиком пять: `credits.go`(15) · `identity.go`(13) · `idempotency.go`(7) · `sessions.go`(5) · `observe.go`(1) = **41 запрос**; это единственный кусок, где инструмент силён и ничего не ломает. Конверсия этих 41 — отдельный пак: одиннадцать из пятнадцати денежных запросов идут внутри ЧУЖОЙ транзакции (`WithTx`), генерённый код коммитится, нужен пин версии `sqlc` и гейт «сгенерённое актуально», а чинить он будет класс, который гейт выше уже закрыл. Взять его в этом паке на две-три ручки — ровно то, от чего предостерёг фикс-лист: купить инструмент туда, где не болит. **Предложение зоны: sqlc следующим паком на этот блок из 41 запроса; решение — владельца** ⚠ **РЕШЕНО владельцем 22.08: sqlc берётся ОТДЕЛЬНОЙ СЕССИЕЙ, не внутри пака** — изменений много (генерённый код в дереве, пин версии инструмента, гейт актуальности, `WithTx` для одиннадцати денежных запросов), и мешать их с содержательной работой нельзя. Граница неизменна: 41 запрос в 5 файлах (`credits`·`identity`·`idempotency`·`sessions`·`observe`), остальные 25 склеенных мест недостижимы по построению. ⚠ **ЗАКРЫТО: инструмент взят и заленджен** — `63fcee5`, ратификация `D39.172`, отдельным паком, как решил владелец 22.08 (`D39.154`). Конвертировано 40 запросов из этого самого блока; носители — `platform/sqlc.yaml`, `internal/pgstore/queries/*.sql`, генерённые `*.sql.go` в том же пакете. Актуальность генерации гейчена дважды: `sqlc diff` пререквизитом `make check` и `pgstore.TestEveryGeneratedQueryMatchesItsSourceFile` в батарее (работает без установленного sqlc). ⚠ **Числа в теле выше УСТАРЕЛИ и исправлены паком:** «41 запрос» и `sessions.go`(5) — это счёт до пака P11, добавившего `StillLive`; на 29.08 в наборе **42 места вызова / 41 различный текст SQL** (константа `read` в `idempotency.go` исполнялась из двух мест), из них **конвертируемых 40**. Сороковой не `observe.go`: `Observe` спрашивает `river_job` через `to_regclass`, а эту таблицу мигрирует River сам, вне goose-миграций, поэтому sqlc отвергает запрос — и добавить схему River в конфиг значило бы завести ВТОРОЙ носитель чужой схемы. Гейт `sqlgate` после конверсии видит **172** оператора против пола 140 | fixed | ревью «вне карты»; гейт и граница — P8-FIX | | PD-45 | hardening | info | `internal/ingest/procgroup_unix.go` | `syscall.Kill(-pid, SIGINT)` идёт мимо `os.Process`, поэтому в узком окне между проверкой живости и сигналом ребёнок может быть пожат, и сигнал уйдёт в переиспользованную группу. Окно ~микросекунды и родитель ещё не звал `Wait`; переписывать на pidfd-путь — отдельная работа | open | ревью «вне карты» | | PD-86 | hardening | info | `internal/pgstore/queries/sessions.sql` `LookupSession` / `TouchSession` | **Два клауза-близнеца не запинены, и абсолютный потолок держится ТРАНЗИТИВНО:** снятие `absolute_expires_at > $2` из `Lookup` батарею переживает, потому что потолок навязывается через `least($3, absolute_expires_at)` в `Touch` (это запинено — `TestSessionLifecycle`). Снятие `revoked_at is null` из `Touch` тоже переживает (класс PD-4). Дефекта сегодня нет ни в одном; риск в том, что каждый слой по отдельности выглядит избыточным, а вместе они — единственное, что ограничивает жизнь сессии ⚠ **Указатель пере-нацелен паком `sqlc` (`63fcee5`), и это НЕ смена дефекта:** оба клауза уехали из `sessions.go:23,50` в `queries/sessions.sql`, потому что SQL этих запросов теперь генерируется. Прежний указатель `counts.py --lint` не ловил — у него нет `=токена`, так что он проверял только существование файла и молча указывал на строки, где этих клауз давно нет. Сами клаузы целы и по-прежнему не запинены | open | приёмка P2 (посадки мутаций) | | PD-87 | hardening | info | `internal/httpapi/server.go:82`, `internal/login/login.go:31` | Ещё два незапиненных: снятие `LimitBody` с поддерева `/auth` и `stateTTL` 10 мин → 240 ч проходят батарею. Первое — родня PD-72 (та про общий внешний слой, эта про конкретное поддерево), второе — окно жизни неиспользованного авторизационного запроса | open | приёмка P2 (посадки мутаций) | @@ -115,11 +105,9 @@ | PD-176 | hardening | info | `internal/httpapi/middleware.go` `LimitBody`, `internal/metrics` | **Сигнал `requestTooLarge` от `http.MaxBytesReader` до сервера не доходит через наши обёртки.** `MaxBytesReader` пытается сказать *`net/http`* «запрос слишком большой» приведением `ResponseWriter` к НЕЭКСПОРТИРУЕМОМУ интерфейсу пакета `net/http`; наши обёртки (`statusRecorder`, `metrics.recorder`) его удовлетворить не могут в принципе — метод неэкспортируемый, а значит квалифицирован чужим пакетом. Следствие мягкое и замерено рассуждением по коду `net/http`: 413 отдаётся штатно, а соединение закрывается не немедленным сигналом, а обычным путём — после хендлера сервер дренирует остаток тела и, не сумев дочитать, закрывает соединение, причём дренаж ограничен `ReadTimeout` (PD-2). Лечение (если понадобится): ставить лимит без обёрток над `ResponseWriter` либо закрывать соединение самим через `Connection: close` | open | сессия P5 (чтение stdlib при постройке маршрута) | | PD-177 | bug | info | `internal/books/books.go` `counter` | **`character_count` для не-UTF-8 источника — оценка, а не счёт.** Интейк считает символы потоково как байты, не являющиеся продолжением UTF-8 (`b&0xC0 != 0x80`), что для валидного UTF-8 ТОЧНО равно числу рун и не требует состояния между чанками. Движок принимает и GB18030, и UTF-16, декодируя их сам — на таком файле цифра неверна (для UTF-16 занижена примерно вдвое). Точный ответ есть на шаг позже: манифест несёт `source_bytes` и `encoding`, но не число символов. Лечится либо запросом числа символов у движка (строка единого бэклога), либо перерасчётом после разбора | open | сессия P5 (названо при постройке) | | PD-201 | bug | info | `deploy/README.md`, движок `tmctl migrate` | **Самолечение деплой-деадлока v15 ЖДЁТ движковую половину.** Read-only `status` отказывает файлу проекта старее бинаря, платформа зовёт его перед каждым спавном и на расчёте денег — значит выкат движка запирает книги до `tmctl migrate`. Порядок апгрейда записан и исполним (`tmplatformctl books --migratable` пропускает книги с живыми прогонами, резюмируемыми попытками и незакрытыми холдами — все три блокируют по РАЗНЫМ причинам, аддендум оркестратора 14.08). Чего нет: движковый `migrate` придёт с машиноразличимой ошибкой «версия не совпала», и тогда платформа сможет лечиться сама — поймала → `migrate` (если у книги нет открытых попыток) → повтор. Вслепую не строится: без формы ошибки любой матчер был бы догадкой по тексту. ⚠ Проверка исполнением самого шага `migrate` тоже ждёт и НЕ помечена сделанной ⚠ **ПОЛОВИНА ЗАКРЫТА P7 (проверка исполнением):** рантбук деплоя прогнан end-to-end на стенде живым `migrate` — проектная БД, отведённая на схему v14 при бинаре v15, даёт `tmctl status --json` exit 13 с токеном `schema_mismatch found=14 expected=15`; `tmctl migrate` делает пред-миграционный бэкап и переводит v14 → v15; тот же `status` после — exit 0. Мёртвая цитата ошибки вычищена из `deploy/README.md` и из П-1 бэклога, пример в `tmplatformctl/runs.go` переписан на ВЕРСИОНИРОВАННЫЙ путь бинаря. ⚠ ОСТАЁТСЯ сам автомат самолечения («поймал 13 → migrate → повтор») — он не построен: строить его правильно значит решать, кто имеет право мигрировать книгу с открытыми попытками, а это тот же вопрос, что у `books --migratable` | open | аддендум оркестратора 14.08 (ресёрч деплоя) | -| PD-203 | bug | info | `internal/pgstore/books.go` `ReadUsage` | **Аккаунт объявляется исчерпанным по паузе ОДНОГО прогона.** `/usage` ставит `paused_reason` аккаунта, если у какой-нибудь книги последний прогон стоит `paused/credit_exhausted` — а это потолок ПРОГОНА (сколько глав купил пользователь), а не баланс: на аккаунте может лежать сколько угодно денег, и другой прогон стартует. Контракт про это поле говорит «Set when the account itself is in a halted state». Существовало до этого пака и не им создано; отдельной строкой, потому что различение потолков (PD-199) сделало вопрос «чей это потолок» отвечаемым ⚠ **СУЖЕНО P7, не закрыто:** `Usage.halt_reason` получил СВОЙ словарь (`AccountHaltReason`), то есть прогонная причина больше не может доехать до аккаунтного поля как чужое слово; и PD-241 убрал самый частый ложный источник — стоп пользователя, приезжавший `credit_exhausted`. Сам предикат («последний прогон ЛЮБОЙ книги стоит paused/credit_exhausted») не тронут: он про потолок ПРОГОНА, а не про баланс, и правильный ответ — читать баланс аккаунта, а не сканировать книги ⚠ **ПРОВЕРЕНО ПАКОМ P8-REVIEW 24.08: диспозиция «сам предикат не тронут» ЛОЖНА против того самого коммита, которым P7 и заленджен.** `internal/pgstore/books.go` `ReadUsage` читает БАЛАНС аккаунта, а не сканирует книги, и аккаунтная причина ставится только при `balance <= 0`; ⚠-комментарий на месте прямо описывает замену («It used to be read off the paused reason of each book's latest run»). Единственный писатель `Usage.PausedReason` — эта строка, проверено грепом по `PausedCreditExhausted`. Приехало `9b23e8c` (`git log -L` по функции). Предложение пака: ЗАКРЫТЬ | open | сессия P6 (самопроверка вокруг PD-199) | | PD-204 | standards | info | `internal/runner/systemd_test.go` мета-пин | **Мета-пин systemd-гейта ПЕРЕЧИСЛЯЕТ формы отказа вместо того, чтобы спрашивать способность.** Хвост (г) третьего раунда, у которого не было носителя. Сам гейт вылечен (PD-178: спрашивает, доходит ли процесс до своего менеджера), а тест, который его сторожит, по-прежнему знает список сообщений — у стража та же болезнь, от которой лечили охраняемого. Принято как ОСТАТОК с названной ценой: systemd меняет тексты между версиями, и мета-пин протухнет молча; вреда сегодня нет, потому что он не гейт, а страж гейта. Заведён по пингу оркестратора 14.08 | open | третий раунд P5, хвост (г) | | PD-205 | hardening | info | `internal/login/dev.go` `login`, `internal/auth/csrf.go` `cookieUnsafe` | **Дев-вход защищён от login-CSRF только ОДНИМ слоем из двух.** `auth.CSRF` требует заголовок `X-TM-Client` лишь на unsafe-запросе, который УЖЕ несёт сессионную куку, а на входе куки по определению нет — значит остаётся только `http.CrossOriginProtection`, и клиент, не присылающий ни `Sec-Fetch-Site`, ни `Origin` (тот самый браузер до 2023, ради которого второй слой и заведён), может кросс-сайтом ввести браузер жертвы в ДЕВ-аккаунт. Последствие на стенде ничтожно — аккаунт один и общий, — но свойство слабее, чем «POST, значит безопасно», и записано, а не подразумевается. ⚠ Тот же класс у боевого `/auth/login` (он вообще GET) и по той же причине; лечится либо требованием заголовка на login-маршрутах, либо явным принятием | open | адверсариальное ревью P6 (кросс-семейное, Fable) | | PD-212 | bug | info | движок `cmd/tmctl/main.go` `exitCode`, `internal/runs/reconcile.go` `outcome` | **Непойманная паника движка неотличима от «завершено с флагами».** Go-рантайм завершает процесс с кодом РОВНО 2 на непойманной панике, а 2 — это `CompletedWithFlags`, единственный «успешный» код контракта; `recover` в `cmd/tmctl`/`internal/pipeline` отсутствует (грепнуто ревьюером). Платформа читает только `$EXIT_CODE`/`$EXIT_STATUS` и записывает `ready` для прогона, который упал посреди работы. Деньги целы (расчёт берёт цифру из `status --json`), врёт статус. Лечится НЕ здесь: либо `recover` в main движка, либо другой номер для флагов — запрос уходит строкой единого бэклога через оркестратора. ⚠ Платформа МОГЛА бы различить по отсутствию терминальной строки `finished` в журнале, но сознательно не судит прогон по строке, которую крэш обрезает | open | адверсариальное ревью P6 (линза шва) | -| PD-213 | hardening | info | `internal/ingest/manifest.go`, `internal/books/parse.go` | **Форма манифеста не версионируется на стороне платформы — латентная мина на УДАЛЕНИЕ файла.** `DecodeManifest` не сверяет `manifest_version` ни с чем; `json.Unmarshal` тихо игнорирует незнакомые поля и оставляет отсутствующие нулями, поэтому смена формы движком (`tm-manifest-v2` → v3, переименование `chapters_total`) даст валидный разбор с `ChaptersTotal = 0`. А ноль глав интейк трактует как «источник прочли, книги нет» — тот же терминал и тот же бюджет, что exit 11, то есть после пяти попыток файл пользователя УДАЛЯЕТСЯ, хотя движок книгу прекрасно разобрал. Сегодня формы совпадают поле-в-поле, так что не эксплуатируется; в отличие от потока (мажор отвергается) и от полосы кодов (незнакомый номер безопасен), у манифеста аналога нет. Лечить сверкой `manifest_version` с известной, где незнакомая версия даёт НЕ-деструктивный класс | open | адверсариальное ревью P6 (линза шва) | | PD-214 | bug | info | `internal/ingest/tail.go` `apply` | **Чужая или битая hello-строка в общем журнале книги гасит материализацию СВОЕГО потока.** Версия, непустой `engine_run_id` и `seq == 1` проверяются для ЛЮБОЙ hello-строки ДО того, как код решает, чья она: ветка «not mine» стоит после них. Значит чужой процесс (ручной `tmctl` оператора в каталоге книги, старый мажор, баг чужой сборки) валит `Tail` ошибкой, а `quarantines()` считает её терминальной — проекция здорового платящего прогона уходит в карантин НАВСЕГДА (снятия карантина в дереве нет), свежесть падает на медленный ре-синк. Данные целы, деньги целы. Лечится порядком: при известном `want` чужой id распознаётся ДО валидации хендшейка | open | адверсариальное ревью P6 (линза шва) | | PD-215 | bug | info | `internal/runs/reconcile.go` `restart`, `interruptedBySomeoneElse` | **У петли перезапусков нет ни счётчика, ни бэк-оффа.** Каждый цикл — строка `run_attempts`, три строки леджера (холд/возврат/расчёт), два вызова `tmctl status` и транзиентный юнит. Источник, который на каждой попытке тратит ~0 (движок, мгновенно падающий по внешней причине), крутит это вечно: `remaining` не убывает, значит `exhausted` никогда не наступает. Денег не теряется, но это неограниченная работа и рост таблиц. Класс существовал и до пака (путь ребута, строка 138), а ветка «exit 5 без намерения = прерывание» его РАСШИРИЛА. Лечить счётчиком перезапусков на прогон или бэк-оффом по времени последней попытки | open | адверсариальное ревью P6 (линза денег и гонок) | | PD-216 | bug | info | `internal/runs/reconcile.go` `outcome` (полоса отказов) | **Exit 12 (`project_locked`) закрывает прогон терминально, хотя это единственный класс полосы, который проходит САМ.** Обоснование «отказ воспроизводим по построению, поэтому перезапуск — петля» верно для 10/11/19 и неверно для лока: другой процесс отпустит проект. Денег не теряется (холд возвращается целиком, списывается 0), но прогон убит и пользователь покупает новый. Не исправлено намеренно: перезапуск на 12 без бэк-оффа — это PD-215 в чистом виде, поэтому оба лечатся вместе | open | адверсариальное ревью P6 (линза денег и гонок) | @@ -544,11 +532,11 @@ ## Закрытые — эра P11 (отзыв сессии на открытом потоке · застрявший расчёт · денежные пины) -| PD-379 | vuln | **major** | `internal/httpapi/stream.go:160`=`h.pump(r.Context(), s, who, bookID, state, last, resuming)` ⚠ якорь пере-нацелен паком P11: сигнатуру `pump` изменил он сам (принципал вместо голого id — в этом и лечение), `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)` отмечено выполненным со словами «отзыв прекращает использование». Против дерева это неверно для единственного длинноживущего канала платформы. Найдено интервальной самоверификацией пака ⚠ **ЗАКРЫТО ПАКОМ P11 (29.08).** `auth.Principal` несёт непубличную способность пере-спросить свою сессию, `pump` зовёт её ПЕРВЫМ ДЕЛОМ на каждом тике, отказ даёт терминальный кадр `session_ended` (канон 0.8.0) с watermark СОЕДИНЕНИЯ, а не головой истории. Запрос стора `StillLive` намеренно БЕЗ клаузы idle: окно бездействия скользит на запросе, а поток — один запрос на всю жизнь, поэтому гашение по idle рвало бы связь активному читателю. Доказано ДВУМЯ раздельными живыми сценариями: (а) длинные потолки + `tmplatformctl revoke` → поток кончился через 1 с (`~/tm-p11/probes/a-revocation-ends-the-stream.txt`); (б) idle 10 с / абсолютный 40 с, БЕЗ отзыва → поток пережил окно бездействия и кончился ровно на потолке (`b-the-ceiling-ends-the-stream.txt`). Посадки `r_nocheck`, `r_idle`, `r_head`, `r_open`, `r_wirename` — пойманы. Эррата `STACK_DECISIONS` §13 снята, галочка ASVS 7.4.1 в архиве восстановлена. ⚠ Остаток отдельной строкой: строку сессии удаляет часовой свип и по бездействию тоже, поэтому «строки нет» обязано значить «мертва» | fixed(пак P11 — лендинг оркестратора; статус проставлен зоной по норме §3.6 «закрытие дефекта — коммит + пинящий тест») | ревью-пак P8-REVIEW, ось 2 (живая проба, подтверждено рефутером и координатором) | -| PD-385 | bug | **major** | `internal/pgstore/runs.go:464`=`where a.ended_at is null and a.reconcile_failures >= $1` ⚠ якорь пере-нацелен паком P11: прежняя строка (`where r.finished_at is null and …`) была ОДНИМ предикатом на обе половины и её больше нет — выборка разложена на две ветви, и это ровно лечение, `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:567`=`select finished_at from runs where id = $1` ⚠ якорь пере-нацелен паком P11: прежняя строка отвечала `ErrNoRun` законченному прогону — она и была дефектом; теперь это ветвление, читаемое ПОД блокировкой книги отвечает `ErrNoRun` законченному прогону. Следствие, названное прямо: для всей популяции «закончен, но не рассчитан» деньги пользователя заморожены бессрочно, поверхности ПОРОГА слепы, ERROR на пороге называет команду, которая откажет, и единственный выход — сырой SQL в проде. Составной инвариант, на котором принят пак P8-FIX (`D39.154`: гейдж плюс `runs --stalled` плюс `run abandon` как ответ на `PD-169`), для этой популяции ЛОЖЕН ЦЕЛИКОМ — а «решается до следующего пака» есть определение major-секции самого регистра. Носителем major сделана ЭТА строка как самая полная по улике (живая проба на состоянии из штатных путей плюс отказ ручки); `PD-384` и `PD-392` несут ссылку сюда, чтобы не плодить второй major на тот же корень ⚠ **ЗАКРЫТО ПАКОМ P11 (29.08).** Популяция «кончился, а деньги нет» вошла в `StalledRuns` (колонка `PHASE`), в гейдж `tm_platform_runs_stalled` и получила терминальную ручку: `run abandon` закрывает КАЖДЫЙ осиротевший холд прогона, снимает отсрочку и штампует `settled_at`; допуск сужен до `reconcile_failures >= 1`, иначе команда отдавала бы целиком холд расчёта, который просто ещё не закрылся. Доказано до/после на состоянии из ШТАТНЫХ путей (интейк → HTTP-старт → спавн → выход движка → снят запиненный бинарь): было «no run is live» / «is not a live run» / гейдж 0 при открытой резервации 90000 микро, стало строка `settling` с холдом и возврат денег целиком (`~/tm-p11/probes/pd385-before.txt`, `pd385-after.txt`). ⚠ Вторая названная строкой популяция — ЖИВОЙ прогон с нерассчитанной ПРЕДЫДУЩЕЙ попыткой — получила обе поверхности видимости, но не ручку: отдельной строкой | fixed(пак P11 — лендинг оркестратора; статус проставлен зоной) | ревью-пак P8-REVIEW, ось 3 (живая проба, сужено рефутером) | -| PD-376 | bug | minor, деньги | `internal/pgstore/runs.go:899`=`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` ⚠ **ЗАКРЫТО ПАКОМ P11 (29.08).** Взят готовый пин пака — вариант `r1_` как более сильный (ходит настоящими дверями `StartRun`/`RecordSpawn`, наименьшая базовая линия стоит В СЕРЕДИНЕ, так что «первая поздняя» и «последняя поздняя» тоже падают) — и усилен ВТОРОЙ книгой того же аккаунта, чтобы исполнялся и фильтр `r.book_id`. Посадка `min`→`max`: чистая копия EXIT=0, с мутацией EXIT=1, единственный красный — этот пин. ⚠ **ДИСПОЗИЦИЯ, которую строка оставляла приёмке: `PD-159` НЕ пере-открывается.** Основание прежнее (D39.159 §5): дефект из кода ушёл, недоставало ПИНА, — а теперь пин есть, то есть пробел закрыт там, где он был. Двусторонняя ссылка сохраняется: `PD-159` несёт `ОСПОРЕНО(PD-376)`, эта строка называет `PD-159` | fixed(пак P11 — лендинг оркестратора; статус проставлен зоной) | ревью-пак P8-REVIEW, ось 1 (посадка мутации, подтверждено рефутером собственным пином) | -| PD-384 | bug | minor | `internal/runs/reconcile.go:262`=`case overran:` (`settleOne`) ⚠ якорь пере-нацелен паком P11: прежнее `if !overran {` было САМИМ дефектом — судить по цене вместо вердикта — и заменено свитчем по вердикту, `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 ⚠ **ЗАКРЫТО ПАКОМ P11 (29.08).** Неудачей считается ВЕРДИКТ расчёта, а не только исчерпание бюджета: `settle` вернул три состояния (закрыт · гонка · не вычислим), `settleOne` судит по ним, поэтому все три тихих `return nil` теперь доходят до порога. Цена, названная строкой, закрыта тем же ходом: отсрочка ограничивает `tmctl status` вместо вызова каждым проходом. ⚠ Первая неудача НЕ откладывается — открытая резервация это ворота РЕЗЮМА пользователя (`reopen` отказывает, пока холд предыдущей попытки открыт), и минута ожидания после секундной аварии была бы регрессом; бэкофф идёт со второй и капнут пятью минутами, а не тридцатью. Посадка `r_firstfast` | fixed(пак P11 — лендинг оркестратора; статус проставлен зоной) | ревью-пак P8-REVIEW, ось 3 (проба на реальном сторе, сужено рефутером) | -| PD-391 | bug | minor | `internal/pgstore/sink.go:696`=`where a.reconcile_after is null or a.reconcile_after <= $1`, `internal/pgstore/runs.go` `AbandonRun`, `cmd/tmplatformctl/runs.go` (сообщение), `deploy/README.md` | **`run abandon` не возвращает холд «на ближайшем свипе»: отсрочка застрявшей попытки остаётся, и деньги ждут до 30 минут.** `AbandonRun` завершает прогон и попытку, но `run_attempts.reconcile_after` не трогает, а `UnsettledRuns` по нему фильтрует. Застрявший прогон по построению всегда отсрочен: `deferItem` ставит `now + backoff(failures+1)`, а `backoff` при пяти неудачах упирается в потолок 30 минут. Значит для ВСЕЙ популяции, ради которой команда построена, обещание CLI «its hold comes back whole on the next sweep» и та же фраза рантбука ложны: кредит остаётся вычтенным, `tm_platform_oldest_open_hold_seconds` продолжает расти ПОСЛЕ действия оператора, и оператор читает это как «я сделал, не помогло». Пин `PD-361` доказывает свойство слабее: он берёт прогон, созданный `Start` и брошенный СРАЗУ, у которого `reconcile_failures` 0 и `reconcile_after` NULL. Живая проба на состоянии, выращенном штатным механизмом: через 45 секунд и три свипа холд открыт, `balance` печатает «reserved 3.000000», гейдж 2439 с; ручной `update run_attempts set reconcile_after = now()` закрывает холд в тот же свип. Лечится одной строкой в той же транзакции — снять отсрочку вместе с терминальным вердиктом. Воспроизведение: `docs/p8-review/axis4-metrics/20-abandon-keeps-the-hold.sh` и `r3-abandon-hold.sh` ⚠ **ЗАКРЫТО ПАКОМ P11 (29.08).** `AbandonRun` снимает `reconcile_after` в ОБЕИХ ветках, живой и расчётной. Пин растит прогон до пяти неудач штатными путями и сверяет холд ПОСЛЕ свипа на НЕДВИНУТЫХ часах — то есть исполняет ровно то обещание CLI, которое было ложным. Названные строкой носители обещания исправлены: `deploy/README.md` и сообщение команды. Посадка `m391_defer` — поймана топично | fixed(пак P11 — лендинг оркестратора; статус проставлен зоной) | ревью-пак P8-REVIEW, ось 4 (живая проба, подтверждено рефутером на состоянии из штатного пути) | +| PD-379 | vuln | **major** | `internal/httpapi/stream.go:160`=`h.pump(r.Context(), s, who, bookID, state, last, resuming)` ⚠ якорь пере-нацелен паком P11: сигнатуру `pump` изменил он сам (принципал вместо голого id — в этом и лечение), `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:614`=`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)` отмечено выполненным со словами «отзыв прекращает использование». Против дерева это неверно для единственного длинноживущего канала платформы. Найдено интервальной самоверификацией пака ⚠ **ЗАКРЫТО ПАКОМ P11 (29.08).** `auth.Principal` несёт непубличную способность пере-спросить свою сессию, `pump` зовёт её ПЕРВЫМ ДЕЛОМ на каждом тике, отказ даёт терминальный кадр `session_ended` (канон 0.8.0) с watermark СОЕДИНЕНИЯ, а не головой истории. Запрос стора `StillLive` намеренно БЕЗ клаузы idle: окно бездействия скользит на запросе, а поток — один запрос на всю жизнь, поэтому гашение по idle рвало бы связь активному читателю. Доказано ДВУМЯ раздельными живыми сценариями: (а) длинные потолки + `tmplatformctl revoke` → поток кончился через 1 с (`~/tm-p11/probes/a-revocation-ends-the-stream.txt`); (б) idle 10 с / абсолютный 40 с, БЕЗ отзыва → поток пережил окно бездействия и кончился ровно на потолке (`b-the-ceiling-ends-the-stream.txt`). Посадки `r_nocheck`, `r_idle`, `r_head`, `r_open`, `r_wirename` — пойманы. Эррата `STACK_DECISIONS` §13 снята, галочка ASVS 7.4.1 в архиве восстановлена. ⚠ Остаток отдельной строкой: строку сессии удаляет часовой свип и по бездействию тоже, поэтому «строки нет» обязано значить «мертва» | fixed(пак P11 — лендинг оркестратора; статус проставлен зоной по норме §3.6 «закрытие дефекта — коммит + пинящий тест») | ревью-пак P8-REVIEW, ось 2 (живая проба, подтверждено рефутером и координатором) | +| PD-385 | bug | **major** | `internal/pgstore/runs.go:461`=`where a.ended_at is null and a.reconcile_failures >= $1` ⚠ якорь пере-нацелен паком P11: прежняя строка (`where r.finished_at is null and …`) была ОДНИМ предикатом на обе половины и её больше нет — выборка разложена на две ветви, и это ровно лечение, `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:564`=`select finished_at from runs where id = $1` ⚠ якорь пере-нацелен паком P11: прежняя строка отвечала `ErrNoRun` законченному прогону — она и была дефектом; теперь это ветвление, читаемое ПОД блокировкой книги отвечает `ErrNoRun` законченному прогону. Следствие, названное прямо: для всей популяции «закончен, но не рассчитан» деньги пользователя заморожены бессрочно, поверхности ПОРОГА слепы, ERROR на пороге называет команду, которая откажет, и единственный выход — сырой SQL в проде. Составной инвариант, на котором принят пак P8-FIX (`D39.154`: гейдж плюс `runs --stalled` плюс `run abandon` как ответ на `PD-169`), для этой популяции ЛОЖЕН ЦЕЛИКОМ — а «решается до следующего пака» есть определение major-секции самого регистра. Носителем major сделана ЭТА строка как самая полная по улике (живая проба на состоянии из штатных путей плюс отказ ручки); `PD-384` и `PD-392` несут ссылку сюда, чтобы не плодить второй major на тот же корень ⚠ **ЗАКРЫТО ПАКОМ P11 (29.08).** Популяция «кончился, а деньги нет» вошла в `StalledRuns` (колонка `PHASE`), в гейдж `tm_platform_runs_stalled` и получила терминальную ручку: `run abandon` закрывает КАЖДЫЙ осиротевший холд прогона, снимает отсрочку и штампует `settled_at`; допуск сужен до `reconcile_failures >= 1`, иначе команда отдавала бы целиком холд расчёта, который просто ещё не закрылся. Доказано до/после на состоянии из ШТАТНЫХ путей (интейк → HTTP-старт → спавн → выход движка → снят запиненный бинарь): было «no run is live» / «is not a live run» / гейдж 0 при открытой резервации 90000 микро, стало строка `settling` с холдом и возврат денег целиком (`~/tm-p11/probes/pd385-before.txt`, `pd385-after.txt`). ⚠ Вторая названная строкой популяция — ЖИВОЙ прогон с нерассчитанной ПРЕДЫДУЩЕЙ попыткой — получила обе поверхности видимости, но не ручку: отдельной строкой | fixed(пак P11 — лендинг оркестратора; статус проставлен зоной) | ревью-пак P8-REVIEW, ось 3 (живая проба, сужено рефутером) | +| PD-376 | bug | minor, деньги | `internal/pgstore/runs.go:896`=`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` ⚠ **ЗАКРЫТО ПАКОМ P11 (29.08).** Взят готовый пин пака — вариант `r1_` как более сильный (ходит настоящими дверями `StartRun`/`RecordSpawn`, наименьшая базовая линия стоит В СЕРЕДИНЕ, так что «первая поздняя» и «последняя поздняя» тоже падают) — и усилен ВТОРОЙ книгой того же аккаунта, чтобы исполнялся и фильтр `r.book_id`. Посадка `min`→`max`: чистая копия EXIT=0, с мутацией EXIT=1, единственный красный — этот пин. ⚠ **ДИСПОЗИЦИЯ, которую строка оставляла приёмке: `PD-159` НЕ пере-открывается.** Основание прежнее (D39.159 §5): дефект из кода ушёл, недоставало ПИНА, — а теперь пин есть, то есть пробел закрыт там, где он был. Двусторонняя ссылка сохраняется: `PD-159` несёт `ОСПОРЕНО(PD-376)`, эта строка называет `PD-159` | fixed(пак P11 — лендинг оркестратора; статус проставлен зоной) | ревью-пак P8-REVIEW, ось 1 (посадка мутации, подтверждено рефутером собственным пином) | +| PD-384 | bug | minor | `internal/runs/reconcile.go:278`=`case overran:` (`settleOne`) ⚠ якорь пере-нацелен паком P11: прежнее `if !overran {` было САМИМ дефектом — судить по цене вместо вердикта — и заменено свитчем по вердикту, `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 ⚠ **ЗАКРЫТО ПАКОМ P11 (29.08).** Неудачей считается ВЕРДИКТ расчёта, а не только исчерпание бюджета: `settle` вернул три состояния (закрыт · гонка · не вычислим), `settleOne` судит по ним, поэтому все три тихих `return nil` теперь доходят до порога. Цена, названная строкой, закрыта тем же ходом: отсрочка ограничивает `tmctl status` вместо вызова каждым проходом. ⚠ Первая неудача НЕ откладывается — открытая резервация это ворота РЕЗЮМА пользователя (`reopen` отказывает, пока холд предыдущей попытки открыт), и минута ожидания после секундной аварии была бы регрессом; бэкофф идёт со второй и капнут пятью минутами, а не тридцатью. Посадка `r_firstfast` | fixed(пак P11 — лендинг оркестратора; статус проставлен зоной) | ревью-пак P8-REVIEW, ось 3 (проба на реальном сторе, сужено рефутером) | +| PD-391 | bug | minor | `internal/pgstore/sink.go:724`=`where a.reconcile_after is null or a.reconcile_after <= $1`, `internal/pgstore/runs.go` `AbandonRun`, `cmd/tmplatformctl/runs.go` (сообщение), `deploy/README.md` | **`run abandon` не возвращает холд «на ближайшем свипе»: отсрочка застрявшей попытки остаётся, и деньги ждут до 30 минут.** `AbandonRun` завершает прогон и попытку, но `run_attempts.reconcile_after` не трогает, а `UnsettledRuns` по нему фильтрует. Застрявший прогон по построению всегда отсрочен: `deferItem` ставит `now + backoff(failures+1)`, а `backoff` при пяти неудачах упирается в потолок 30 минут. Значит для ВСЕЙ популяции, ради которой команда построена, обещание CLI «its hold comes back whole on the next sweep» и та же фраза рантбука ложны: кредит остаётся вычтенным, `tm_platform_oldest_open_hold_seconds` продолжает расти ПОСЛЕ действия оператора, и оператор читает это как «я сделал, не помогло». Пин `PD-361` доказывает свойство слабее: он берёт прогон, созданный `Start` и брошенный СРАЗУ, у которого `reconcile_failures` 0 и `reconcile_after` NULL. Живая проба на состоянии, выращенном штатным механизмом: через 45 секунд и три свипа холд открыт, `balance` печатает «reserved 3.000000», гейдж 2439 с; ручной `update run_attempts set reconcile_after = now()` закрывает холд в тот же свип. Лечится одной строкой в той же транзакции — снять отсрочку вместе с терминальным вердиктом. Воспроизведение: `docs/p8-review/axis4-metrics/20-abandon-keeps-the-hold.sh` и `r3-abandon-hold.sh` ⚠ **ЗАКРЫТО ПАКОМ P11 (29.08).** `AbandonRun` снимает `reconcile_after` в ОБЕИХ ветках, живой и расчётной. Пин растит прогон до пяти неудач штатными путями и сверяет холд ПОСЛЕ свипа на НЕДВИНУТЫХ часах — то есть исполняет ровно то обещание CLI, которое было ложным. Названные строкой носители обещания исправлены: `deploy/README.md` и сообщение команды. Посадка `m391_defer` — поймана топично | fixed(пак P11 — лендинг оркестратора; статус проставлен зоной) | ревью-пак P8-REVIEW, ось 4 (живая проба, подтверждено рефутером на состоянии из штатного пути) | | 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:398`=`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` ⚠ **ЗАКРЫТО ПАКОМ P11 (29.08), и закрыто ВТОРЫМ из двух предложенных строкой способов.** Триггер на `update`/`delete` по `credit_ledger` ОТКЛОНЁН с двумя основаниями, проверенными в дереве: он сработает на КАСКАДНОМ удалении пользователя, которое миграция `00007` объявляет границей append-only, и сломает законную фикстуру `TestAReleaseWhoseKeyWasSpentIsRefusedRatherThanSilent`, которая правит леджер намеренно. Вместо него: проза `Adjust` и `appendLedger` сделана честной («держит КОД, а не схема», с перечнем того, что схема ДЕРЖИТ), плюс ГЕЙТ `TestTheLedgerIsAppendOnlyInTheCodeThatWritesIt` — ни один `update`/`delete` по `credit_ledger` (в том числе схемо-квалифицированный) не написан ни в одном из 172 SQL пакета. Посадка `r_ledgeredit` настоящей формой (`tx.Exec` внутри `appendLedger`). ⚠ Что осталось НЕзакрытым и названо: миграция данных, операторский `psql` и будущий инструмент идут мимо пакета по построению | fixed(пак P11 — лендинг оркестратора; статус проставлен зоной; ⚠ закрыт РАЗБОРОМ с отказом от триггера, см. тело) | ревью-пак P8-REVIEW, ось 1 (проба агента, пере-проверена координатором; строка заведена по аудиту полноты) | | PD-394 | hardening | info | `internal/pgstore/credits.go:227`=`if spent < 0 {`, констрейнт `credit_ledger_sign` в `internal/pgstore/migrations/00007_credits.sql` | **Гард отрицательного расхода в `Settle` не запинен: снятие проходит ПОЛНУЮ батарею (18 пакетов).** Класс тот же, что у `PD-333`/`PD-334` — оговорка денежного пути, которую ни один тест не исполняет. Цена НАЗВАНА и она ограничена схемой, а не кодом: отрицательный `spent` дал бы `settlement` с положительной суммой, а это ловит констрейнт `credit_ledger_sign` — проверено прямым INSERT на стенде, Postgres отвечает `violates check constraint "credit_ledger_sign"`. То есть сегодня вреда нет, и защита ТРАНЗИТИВНА: держит её схема, а не гард, который для этого написан. Родня `PD-86` (там потолок сессии держится через соседнюю функцию). Достижимость самого отрицательного значения сегодня нулевая — единственный источник `attemptSpend` клампит в ноль, и этот кламп запинен. Воспроизведение: `docs/p8-review/plant.py` (мутация M4) и `mutations-full.log` ⚠ Улика воспроизводима из артефактов: `plant.py` знает эту мутацию под именем M4, лог — `docs/p8-review/mutations-full.log` ⚠ **ЗАКРЫТО ПАКОМ P11 (29.08).** Гард получил ИМЯ (`ErrNegativeSpend`) и пин на `errors.Is`. ⚠ Имя понадобилось не для красоты: ПЕРВАЯ редакция пина проверяла лишь «вернулась ошибка» — и посаженная мутация её прошла, потому что ошибку вернул констрейнт `credit_ledger_sign`, то есть ровно та транзитивная защита, о которой строка и написана. Посадка `r_negative` на исправленном пине — поймана | fixed(пак P11 — лендинг оркестратора; статус проставлен зоной) | ревью-пак P8-REVIEW, ось 1 (посадка мутации координатора) | | PD-414 | bug | minor, деньги | `internal/pgstore/credits.go` `Settle` (`appendLedger` для `run_settle`) | **`Settle` ВЫБРАСЫВАЛ флаг `applied` своей леджер-записи — единственный из трёх вызовов `appendLedger`, который его не читал.** Соседи проверяют: `holdTx` отвечает `ErrDuplicateHold`, `releaseHold` — `ErrReleaseKeySpent` С ОТКАТОМ (`PD-97`). Здесь потраченный ключ `run_settle` НЕ СПИСЫВАЛ НИЧЕГО, при том что холд уже возвращён целиком, а вызывающему возвращался `nil`: аккаунт получает работу даром. ⚠ Достижимость сегодня НУЛЕВАЯ, и это записано, чтобы приёмка не искала траекторию: резервация закрывается под `state = 'open'`, поэтому второй `Settle` получает `ErrNoReservation` и сюда не доходит, а потратить ключ можно только пере-открыв резервацию на той же попытке — что `holdTx` отказывает ровно по этой причине. Класс — ровно `PD-394`: неисполняемая сегодня оговорка денежного пути. Найдено самопроходом пака P11 (линза денег), не строкой заказа ⚠ **ЗАКРЫТО ПАКОМ P11 (29.08):** флаг читается, новый сентинел `ErrSettlementKeySpent`, откат как у `releaseHold`; пин `TestASettlementWhoseKeyWasSpentIsRefusedRatherThanSilent`, посадка `r_applied` | fixed(пак P11 — лендинг оркестратора; статус проставлен зоной) | самопроход пака P11 (§4.5, линза денег под гонкой) | @@ -576,3 +564,32 @@ | PD-255 | doc | info | `internal/pgstore/migrations/00016_read_surface.sql`, `internal/pgstore/readmodel.go` | **Плотность комментариев выше нормы зоны** («одна-две строки почему», владелец 26.07): миграция 00016 — 250 строк, из них около половины проза; у `readmodel.go` многие символы несут абзацы. Часть прозы несущая (порядок delete/insert в двух таблицах — ровно то, на чём пак и споткнулся), часть — эссе. Подрезано самое тяжёлое; остальное — предмет решения владельца о норме, а не тихой правки ⚠ **ЗАКРЫТО решением владельца 21.08: НОРМА СМЕНИЛАСЬ, и строка была открыта против снятой формулы.** Счёт строк снят как негодный гейт — комментарий на три строки может быть нужен, на одну достаточен; режется ВОДА (пересказ решений, провенанс, изложение исследования вместо ссылки), а всё, что из одной функции НЕ выводится — порядок блокировок, инварианты между таблицами, цена забывания, вендор-квирк — остаётся, сколько бы строк ни заняло. Формулировка — `docs/architecture/12-go-style-notes` §1. Обе названные здесь прозы под новой нормой законны: порядок delete/insert между двумя таблицами из функции не выводится, а сам дефект пака это доказал. Тихой правки не было и не будет | fixed(решение владельца 21.08) | кросс-модельное ревью P7 (линза энтропии) | | PD-429 | bug | minor | `internal/config/effective_test.go` `TestAConfiguredAmountIsNeverPrinted` | **Денежный тест краснел на совпадении с ЧАСАМИ, то есть на факте, которого не существует.** Он искал суммы подстрокой во всём буфере `slog.NewJSONHandler`, а тот несёт `time` в RFC3339Nano — девять знаков дробной части. Замерено на 200 000 таймстемпов: `7.50` встречается в **205**, `42500` в 5, `0.0425` в 3 — и это НА СТРОКУ, а прогон печатает по строке на настройку, поэтому совпадения приходят вспышками (все строки одного прогона делят одну секунду). Воспроизведено `-count=3000`: падение на `42500`. ⚠ **Утечки не было и нет:** значение скрывается (`config.go` пишет `valueAmount`), и этот же тест двумя строками выше сам это и проверяет. ⚠ **Почему это чинится, а не оставляется под запретом D39.121:** запрет защищает тест, ловящий НАСТОЯЩИЙ дефект, — такой нельзя ослаблять ради зелени. Здесь дефектен САМ тест: ложно-положительное срабатывание на данных, к предмету не относящихся. Оставить как есть — худший исход: флейк в ДЕНЕЖНОМ тесте приучает читать красное как шум, и в день настоящей утечки красное не отличат. ⚠ **Лечение НЕ ослабляет:** каждая строка разбирается как JSON, поле `time` выбрасывается, поиск идёт по значениям и ключам всего остального (`loggedFields`), то есть проверка перестала зависеть от формата времени. ⚠ **Честная оговорка, установленная ПОСАДКОЙ:** охват при этом НЕ вырос — сырой поиск покрывал и `msg`, и посадка «сумма печатается в msg» валит ОБЕ формы. Выигрыш ровно один и он назван: убрано ложное срабатывание. Проверка: `-count=5000` чисто (падало на 3000), посадка в `msg` — красная. ⚠ **Родня, сегодня безопасная по причине, которую стоит знать:** `internal/runs/sweep_test.go` ищет `1.000000`/`0.200000` в буфере `slog.NewTextHandler`, а у текстового обработчика дробная часть ТРИ знака (замерено: `.437` против `.437860545` у JSON), поэтому шестизначные хвосты там не совпадут никогда — переключение того теста на JSON-обработчик воскресит этот же дефект ⚠ **ДОФИКС ПО ВНЕШНЕМУ РЕВЬЮ (29.08), два пункта, оба в коде самой починки:** (1) helper звался ВНУТРИ цикла по искомым суммам, то есть буфер разбирался четырежды — вынесен наружу; (2) серьёзнее: пропуск ключа `time` стоял внутри РЕКУРСИВНОГО обхода, то есть вложенная группа с именем `time` получала тихое освобождение от денежной проверки. Это дыра, направленная не в ту сторону, в helper'е, чей смысл — что не освобождён никто; плоские строки делали обе версии неотличимыми, и так такая дыра доживает до смены формы. Теперь `delete` на ВЕРХНЕМ уровне, где поле и пишет обработчик, а обход безусловен. Запинено `TestTheTimestampExemptionDoesNotReachInsideTheLine` (синтетическая строка с вложенным `time`), посадка «вернуть пропуск в обход» — красная | fixed(пак P11, дофикс 29.08 + дофикс-2 по внешнему ревью — лендинг оркестратора; статус проставлен зоной) | охотник приёмки; механизм пере-проверен оркестратором №19 и независимо пере-замерен сессией P11; дофикс-2 — внешнее ревью | | PD-430 | hardening | minor, деньги | `internal/pgstore/credits.go` `ReadAccount`, пин `internal/pgstore/readaccount_test.go` | **Три денежные цифры, которые `ReadAccount` возвращает, были ВЗАИМОЗАМЕНЯЕМЫ для всей батареи: перестановка целей `Scan` оставляла зелёными все 18 пакетов.** Причина ровно в том, что делает эту функцию ценной: она — ЕДИНСТВЕННОЕ место, где кэш баланса и сумма леджера сравниваются, и все денежные тесты зоны утверждают, что они РАВНЫ. Поэтому единственное, чего ни один из них не видит, — это их обмен. ⚠ Мутант НЕ эквивалентен, доказано пробой на дрейфе (кэш 4.00 при леджере 1.00): чистый код отвечает `Balance=4.000000 LedgerSum=1.000000`, с перестановкой — `1.000000/4.000000`, при полностью зелёной батарее. Цена условна, но нацелена: дрейф — ровно то состояние, ради выявления которого функция и существует, и именно в нём `tmplatformctl balance --user` печатал бы СУММУ ЛЕДЖЕРА под словом «баланс», а любое решение по `Account.Balance` принималось бы по леджеру вместо кэша; предупреждение о дрейфе при этом продолжало бы срабатывать (сравнение обмен переживает), то есть оператор получил бы верную тревогу при двух неверных числах. Класс — `PD-394`/`PD-376`: денежный путь, который верен, и верен лишь потому, что никто не опечатался. ⚠ **Найдено не мной:** сессия пака `sqlc` (`textmachine-1b`) замерила, что гейт `TestEverySQLStatementParsesAgainstTheMigratedSchema` видит только СТРОКУ SQL и никогда Go-сторону вызова — шесть её посадок (перестановки целей `Scan`, сломанная арность) выжили на полной батарее. Я пере-проверила это независимо на самом денежном из чтений зоны и подтвердила. ⚠⚠ **Пак `sqlc` дыру НЕ расширяет и НЕ сужает её остаток — он убирает одну из двух осей, и это поправка к первой редакции ЭТОЙ строки.** Я написала было, что с ним актуальность выросла, потому что «раньше перестановку хотя бы теоретически ловили типы». **Неверно, и опровергнуто чтением:** в HEAD `Account.Balance`, `.Reserved` и `.LedgerSum` — все три `money.MicroUSD`, так что компилятор молчал ровно так же, и дыра была того же размера. Что пак действительно меняет (замерено сессией `textmachine-1b`: переставила две денежные колонки в SELECT, регенерировала, Go не трогала — `Scan` переставился следом и значения остались в своих полях): осей дрейфа было ДВЕ — расхождение SELECT-списка с целями `Scan` и рукописная проекция имя-в-имя, — а стала ОДНА, потому что первые две теперь генерируются вместе и разойтись не могут. Остаётся ровно то, что закрывает этот пин. Пин проверен против ОБЕИХ реализаций: ловит перестановку и в рукописном `Scan`, и в генерённом пути (последнее пере-проверено самой `textmachine-1b` на её дереве, а не с моих слов) | fixed(пак P11, дофикс 29.08 — лендинг оркестратора; статус проставлен зоной) | сессия пака `sqlc` (`textmachine-1b`, §3.1 — что sqlc даёт сверх гейта); пере-проверено посадкой сессией P11 на денежном чтении | + +## Закрытые — эра P12 (30–31.08: деньги двери банка · честность экрана · эпоха формы конвейера · снятие обхода `--verify-bank`) + +> Пак «долги под ногами»: семь открытых major и висящие обязательства зоны. Пять из семи major закрыты здесь, шестая +> (`PD-424`) СУЖЕНА до половины «ручки нет» и осталась открытой, седьмая (`PD-410`) пере-диспозиционирована — у неё есть +> направление владельца и нет механики, и она отдельный пак по слову оркестратора. Канон двинулся один раз: минор +> **0.9.0** под эпоху формы конвейера (вторая граница пересчёта `chapters_done` + непрозрачное поле `Book.shape_epoch`), +> ратифицирован оркестратором ДО стройки пункта. +> +> ⚠ **Две строки ниже (`PD-380`, `PD-44`) паком P12 не чинились** — они уже несли свой `fixed(...)` и лежали под +> заголовками «Открытые», то есть ровно класс, ради которого 29.08 заведена секция переноса. Двинулась только позиция. + +| ID | Класс | Серьёзность | Где | Суть | Статус | Источник | +|---|---|---|---|---|---|---| +| PD-425 | bug | **major**, деньги | `internal/httpapi/bank.go:149` (`r.Context()`), `internal/runs/bank.go` `RecordBankMove` | **Дверь банковских коррекций теряет пост-verb факт НАВСЕГДА при обрыве клиента, и следующая покупка платит за это холдом.** Хендлер берёт `r.Context()`, и `RecordBankMove` пишется на нём же; клиент закрыл вкладку до ответа — контекст отменён, запись не легла, `bank_moved_at` остаётся NULL. Второго писателя у факта нет, свипа нет, то есть состояние вечное. Деньги дальше: `re_pass` отвечает 409, а ОБЫЧНЫЙ прогон допускается с `resnapshot=false` и убивается снапшот-гардом движка ПОСЛЕ взятия холда — холд взят, попытка потрачена впустую. ⚠ Средство, которое дверь называет, недостижимо по построению: она отвечает `503` со смыслом «клиент пере-шлёт», а запись падает именно потому, что клиента уже нет. ⚠ Шаблон лечения лежит В ЭТОЙ ЖЕ ЗОНЕ и здесь не применён: `internal/pgstore/idempotency.go:150` и `books.go:205,215` делают `WithTimeout(WithoutCancel(ctx), …)` ровно с этой мотивировкой. ⚠ Не предмет пака P11 (дверь построена паком P9/P10), поэтому заведено строкой, а не починено: правка денежного пути чужого пака требует своих пинов и своей приёмки ⚠ **ЗАКРЫТО пак P12 (30–31.08).** `RecordBankMove` идёт на СОБСТВЕННОМ контексте: `context.WithTimeout(context.WithoutCancel(ctx), recordBudget)` (`internal/runs/bank.go`, тот же класс, что `internal/httpapi/idempotency.go:151` `settleCtx` и `internal/books/parse.go:271` `writeCtx`). Детачится РОВНО запись и ничего больше — разбор шире: детач на двери снёс бы два намеренно построенных свойства (мёртвый клиент покидает очередь книги; бюджет verb'а), а детач самого verb'а не нужен, потому что ранний обрыв даёт SIGTERM → движок проверяет контекст ДО своих записей → exit 5 → `ErrBankUnavailable`, и не теряется ничего. `recordBudget` (10 с) а не 30-секундный `writeBudget` интейка: запись идёт ПОД блокировкой книги. Пин — `runs.TestTheBankMoveFactSurvivesAClientThatHungUp` (гейт `entered`/`release` держит verb в полёте, отмена приходит В ЭТОТ момент — контекст, отменённый ДО вызова, до записи не доходит вовсе, его съедает `lockBook`). Посадка M1 (снять `WithoutCancel`) — КРАСНАЯ адресно, соседний пин зелен. ⚠ **Битый якорь строки исправлен:** `internal/pgstore/idempotency.go:150` — это чтение ключа, никакого detach там нет; истинные якоря класса — `internal/httpapi/idempotency.go:151`, `internal/books/books.go:205,215` (тело `internal/books/parse.go:271`) и, чего строка не знала, `internal/runs/reconcile.go:119,277` — ТОТ ЖЕ пакет. ⚠ **Остаточное окно названо, а не закрыто:** смерть ПРОЦЕССА между verb и записью (SIGKILL, OOM, истёкший grace деплоя) по-прежнему теряет факт навсегда — `WithoutCancel` переживает клиента, не процесс. Окно сузилось с «вся фаза записи движка плюс round-trip БД» до «round-trip БД». Свипа быть не может: после факта у платформы нет свидетеля свёрнутой коррекции, ровно поэтому дверь штампует из СОБСТВЕННОЙ квитанции. | fixed(пак P12) | приёмка оркестратора №19 по паку P11 (охотник вне карты) | +| 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`; решение следующего пака ⚠ **ЗАКРЫТА read-половина пак P12 (30–31.08)** (write-половина закрыта F12 ранее). Порядок «живой прогон ПЕРВЫМ» вынесен в одну константу `newestRun` = `order by (finished_at is null) desc, started_at desc, id desc limit 1` (`internal/pgstore/readmodel.go`) и вклеен во ВСЕ 10 мест склейки `lastRun` — плюс, и это половина, которой лечение read-модели не сделало бы: ТОТ ЖЕ порядок вклеен в `LatestRun` (`internal/pgstore/runs.go`), где `order by started_at desc` был выписан РУКОЙ. Оставить его — значило ПЕРЕВЕРНУТЬ дефект: экран пошёл бы за живым прогоном, а `Resume` отказал бы ему как «не последнему». Тотальность порядка обеспечивает `runs_one_live_per_book` (00002:66, уникальный по `book_id` при `finished_at is null`) — живой ровно один. Пины судят ПО МЕСТАМ, не зеленью батареи: `pgstore.TestALiveRunOutranksAFinishedOneOnEveryBookScopedRead` (карточка, её `Run`, строка библиотеки, `LatestRun`), `pgstore.TestTheStreamsFramesFollowTheLiveRunAndNotTheFinishedOne` (кадр статуса, `bookScope`, полоса) и `pgstore.TestWithNoLiveRunTheBookStillResolvesToTheNewestStarted` — обычный случай не тронут. Посадки M6 (снять живой терм) и M7 (расклеить `LatestRun`) — обе КРАСНЫЕ адресно. ⚠ **Цена, названная честно:** индекс `runs_book_idx (book_id, started_at desc)` этот порядок больше не обслуживает целиком — сортировка идёт по выражению. Латераль работает по ОДНОЙ книге, где прогонов единицы, так что замера это не потребовало; если у книги когда-нибудь станут сотни прогонов, носитель — новый индекс, а не откат порядка. | fixed(пак P12) | воркфлоу-ревью 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-прогонами воркфлоу. Корень продуктовый — смена формы конвейера на живой книге; лечение за решением владельца/оркестратора ⚠ **ЗАКРЫТА пак P12 (30–31.08), по решению владельца D39.165 §2** (регистр отставал от D-лога: «лечение за решением владельца» стояло, хотя направление дано 28.08). Носитель границы — НОВАЯ пара колонок `books.shape_epoch` + `books.epoch_editor` (миграция 00030): форма текущей эпохи ПРИСВАИВАЕТСЯ, счётчик считает пересечения. `books.edit_wave` остаётся и остаётся МОНОТОННЫМ — пин D39.153 §4б не тронут; уехал только пожизненный счёт, `finishedUnits` читает `epochWave` = `coalesce(b.epoch_editor, b.edit_wave, true)`. Движение `structure_version` отвергнуто: оно инвалидирует курсоры, идентификаторы пар и окна термов, а нарезка книги не менялась — это была бы ложь о структуре ради счётчика. ⚠ **ЗАКРЫТА ОДНА половина из двух, и вторая ОСТАЁТСЯ ОТКРЫТОЙ — см. `PD-435`.** Закрыт СЧЁТ КНИГИ: шкала покупки перестаёт продавать переведённое, `ChaptersLeft` падает, книга дочитывается. НЕ закрыта ПОЛОСА ПРОГОНА: на деплое, где редактора убрали, она по-прежнему тарифицирует edit-волну, которой не будет, и до единицы не доходит. ⚠ **Первая редакция этой строки закрывала обе, и это была ошибка пака, пойманная его же адверсариальным проходом и ЗАМЕРЕННАЯ:** полосу перевели на эпоху, а эпоха ПРИСВАИВАЕТСЯ и ходит в обе стороны — один прогон читал 4/4, потом 2/2 на своих же двух попытках, та же строка, та же `structure_version`, дробь назад. Канон держит полосу прогона одной монотонной дробью (строка 200), поэтому полоса возвращена на МОНОТОННЫЙ `books.edit_wave`, а на эпоху уехал только пожизненный счёт книги. `PD-401` не вскрыт (парность числителя и базлайна не тронута) и теперь запинен с той стороны тоже: `pgstore.TestTheRunsBarIsMonotoneAcrossAShapeBoundaryItSpans`. Ложный абзац `sink.go` о собственном коде СНЕСЁН целиком, не пере-нацелен. Канон — минор **0.9.0** (ратификация оркестратора 30.08): фраза `chapters_done` называет ВТОРУЮ границу пересчёта рядом с пере-нарезкой, и появилось поле `Book.shape_epoch` — НЕПРОЗРАЧНЫЙ счётчик поколения; сама форма («есть ли редактор») на провод НЕ выносится, это было бы третьим исключением границы, которого D39.163 не даёт. `STACK_DECISIONS` §35 пере-подписан, прежняя формулировка оставлена с датой и причиной. Пины: `pgstore.TestRemovingTheEditorRecomputesTheBooksCountInsteadOfFreezingIt` и `pgstore.TestTheEpochMovesOnBoundariesAndNotOnEveryAnnouncement`. ⚠ **Первая редакция этой строки называла третьим пином `TestARunOnADeploymentThatDroppedTheEditorCanReachOne` — теста с таким именем в зоне НЕТ ни одного** (греп пуст): он существовал в откаченной редакции и имя пережило откат. Свойство, которое он обещал, несёт открытая `PD-435`, а не пин. Посадки M8 (счёт обратно на монотонный флаг) и M9 (эпоха накапливает вместо присвоения) — обе КРАСНЫЕ адресно. | fixed(пак P12) | воркфлоу-ревью 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`) ⚠ **ЗАКРЫТА пак P12 (30–31.08) ТЕМ ЖЕ носителем, что `PD-403`** — это одна арифметика с двух сторон, и закрывать её порознь значило бы оставить строку, чья репродукция больше не описывает дефект. Разрешённое движение флага (редактора ДОБАВИЛИ) по-прежнему пересчитывает счёт — так решил владелец, — но теперь пересчёт ОБЪЯВЛЯЕТ СЕБЯ: `books.shape_epoch` двигается той же транзакцией, и клиент видит новое поколение, а не ход счётчика назад. Крайний случай строки (книга, начерченная ПОЛНОСТЬЮ под `false`, недочитываемая редактором ни одним действием API) уходит с ней же: `ChaptersLeft` считается через эпоху, то есть после прихода редактора книга снова имеет что купить. Пин — `pgstore.TestAddingTheEditorMovesTheEpochWithTheCountItRecomputes`; посадка M9 КРАСНАЯ адресно. | fixed(пак P12) | воркфлоу-ревью P9 28.08 (линзы race:flip-read-consistency · bar:stage-caption · bar:baselines-x-migration), диспозиция оркестратора 28.08: строкой | +| PD-405 | bug | **major** | `internal/pgstore/sink.go:219` (ложный комментарий-обоснование), `internal/books/parse.go` (инлайн-материализация после коммита `not_started`), `internal/pgstore/books.go` `BooksOwedReadModel` (`nothingIsRunning`) | **Прогон над книгой без материализованного дерева читает 0/total ВСЮ ЖИЗНЬ при исправно доезжающих progress-событиях — а комментарий, которым этот случай объявлен закрытым, описывает архитектуру, которой больше нет.** Окно штатное: интейк коммитит `not_started` ДО материализации; если она упала/отложилась и пользователь успел стартовать прогон, долг дерева заморожен до конца прогона (`nothingIsRunning`), `unitDone` находит 0 строк глав и выходит, вся полоса выведена из `chapters` → 0/total до `ready`. Комментарий `sink.go:219` «the counters the screen reads today come from the progress event» ложен — эти колонки не читает никто (носитель — `PD-411`). Достижимость ЗАМЕРЕНА логом живого стенда: на здоровом пути окно «`not_started` без строк `chapters`» живёт **0.018 с** (обе загрузки пробоя P9: `book parsed` → `the reading surface was refreshed`, 0.0179 и 0.0189 с) — опасная форма только УПАВШАЯ/отложенная материализация, и тогда длина окна = вся жизнь прогона. Лечение — честная привязка полосы к бездеревному окну (отказ старта до дерева ИЛИ полоса из progress-событий как раньше); НЕ легло фикс-раундом — открыта целиком, замер достижимости ратифицирован актом D39.162 ⚠ **ЗАКРЫТА пак P12 (30–31.08), формой «отказ старта до дерева»** (из двух названных зоной лечений). Довод против второго — полосы из progress-событий: те колонки НИКТО не читает (`PD-411`), то есть «как раньше» означало бы построить полосу заново поверх мёртвого носителя; а разделение `nothingIsRunning` тронуло бы конъюнкт, общий с `ReadStream` и лизинг-дисциплиной §34. Отказ стоит ДО взятия холда: `internal/runs/runs.go` `Start`, предикат `book.ChapterCount > 0 && !book.HasTree` (новое поле `BookRunContext.HasTree`, `exists(select 1 from chapters …)`). Слово на проводе — существующее `book_not_ready` (409), НЕ новое: собственная глосса канона говорит «still arriving, still being CUT, or was rejected», а книга, должная дерево, ещё нарезается. Ложный комментарий `sink.go` («the counters the screen reads today come from the progress event») СНЕСЁН — он и был прикрытием этой строки. ⚠ **Что вскрылось при этом и названо строкой:** ВСЯ runs-батарея ездила на книге, объявляющей главы и не материализовавшей ни одной, — фикстуры РАСШИРЕНЫ деревом (`newFixture`, `secondBook`), не обойдены. Пин — `runs.TestARunIsRefusedOverABookWhoseChaptersWereNeverMaterialised` (проверяет и то, что отказ НЕ ДВИНУЛ ДЕНЬГИ) и `runs.TestABookThatDeclaresNoChaptersIsNotRefusedAsUnmaterialised` (книга, объявившая НОЛЬ глав, под гард не попадает — её отказывает то, что должно: покупать нечего); посадки M10 и M19 КРАСНЫЕ адресно. ⚠ **Названо адверсариальным проходом пака и подписано в коде, а не замолчано:** одна популяция этим отказом становится НЕзапускаемой до действия оператора — книга, чей долг читательской поверхности СПИСАН после исчерпания попыток (`AbandonReadModelDebt`): она никому ничего не должна, свип её не подберёт, и отказ стоит, пока не попросят заново. Ручка существует и названа прямо в комментарии гарда — `tmplatformctl book refresh --book `, список — `tmplatformctl books --abandoned`. До пака такая книга запускалась и показывала мёртвую полосу; теперь она отказывает и указывает на лечение. | fixed(пак P12) | воркфлоу-ревью P9 28.08 (линзы bar:stage-caption · bar:double-count), диспозиция оркестратора 28.08: строкой, достижимость замером (акт D39.162) | +| PD-411 | standards | minor | `internal/pgstore/sink.go:137` и `:447` (два писателя), потребителей НЕТ (греп: ни одного select) | **`runs.draft_done/draft_total/edit_done/edit_total` — носитель с ДВУМЯ писателями и НУЛЁМ читателей: класс A второго яруса онтологии банка (`18-bank-ontology.md`), сосед `PD-314` по форме.** Полоса целиком выведена из `chapters` (runDone/runTotal), эти колонки не читает ни один SELECT — а пишутся они на КАЖДОМ progress-событии внутри транзакции, держащей блокировку строки книги, двумя РАЗНЫМИ дисциплинами (поток присваивает, resync берёт `greatest`), и обоснование `greatest` на `sink.go:444-446` защищает полосу, которой не существует, — следующая сессия будет искать монотонность бара не там, где он живёт. Лечение: снос колонок миграцией + снос обоих писателей и лгущих комментариев; в паке P9 НЕ делается (слово оркестратора 28.08: миграцию на снос в этом паке не заводить) ⚠ **ЗАКРЫТА пак P12 (30–31.08)** (запрет сноса был пер-паковым, P9): колонки снесены миграцией 00031, оба писателя и оба лгущих комментария удалены. Четыре теста, наблюдавшие свойства ЧЕРЕЗ эти колонки, пере-нацелены на то, что progress-событие materialises на самом деле — ETA и объявленную ФОРМУ; это не ослабление, а восстановление предмета: у утверждения над снесённой колонкой предмета нет. ⚠ **СНОС ВСКРЫЛ ГАП, и он заведён отдельной строкой, а не замолчан:** РЕСИНК — канал починки для прогона, чей поток в карантине, — писал прогресс ТОЛЬКО в эти колонки. Он не трогает ни `unit_resolutions`, ни `chapters`, значит не материализует ничего, что читает экран, и полоса такого прогона стоит всю его жизнь, как бы исправно ни отвечал `tmctl status`. Мёртвые колонки это ПРЯТАЛИ: код выглядел так, будто канал починки чинит. | fixed(пак P12) | воркфлоу-ревью P9 28.08 (линзы race:flip-read-consistency · bar:double-count · bar:stage-caption), диспозиция оркестратора 28.08: отдельной строкой класса A | +| 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 при исчерпании ⚠ **ЗАКРЫТА пак P12 (30–31.08), формой `ErrKeyInFlight`** (из двух названных зоной). Довод против границы по ВРЕМЕНИ — она дефект не закрывает: чем бы ни ограничивался цикл, он обязан чем-то ЗАВЕРШИТЬСЯ, и пока терминал — голый `fmt.Errorf`, это по-прежнему 500, просто реже; две «формы» лежат на разных осях — ответ обязателен, граница свободна. Плюс собственная часовая дисциплина файла: всё время в `ClaimIdempotency` берётся из ИНЖЕКТИРУЕМОГО `now`, а дедлайн по стенным часам внутри цикла ввёл бы второй, скрытый источник времени, непроверяемый под замороженными часами. Сделано: `pgstore.ErrKeyContended` ОБОРАЧИВАЕТ `ErrKeyInFlight` (лекарство у вызывающего то же — подождать `Retry-After` и повторить), словарь кодов контракта НЕ расширен — `idempotency_conflict`/`key_in_flight` уже есть; на HTTP один писатель ответа (`keyInFlight`), случай контенции стоит ПЕРЕД более широким и пишет WARN: громко у нас, тихо на проводе. ⚠ Пин НЕ ПРАВЛЕН (D39.121): `TestAClaimThatLostARaceToAReleaseIsRetriedAndNotAnError` принимал `ErrKeyInFlight` и по построению принимает обёртку. Новые пины: `pgstore.TestGivingUpOnAContendedKeyIsOneOfTheContractsOwnAnswers` и подтест «a claim that lost the race too often is told to wait, not answered 500». **Пере-замер серией ПОСЛЕ фикса:** `go test ./internal/pgstore -run '^TestAClaimThatLostARaceToAReleaseIsRetriedAndNotAnError$' -count=80` ⇒ **80 PASS, 0 FAIL, 0 SKIP** (счёт по `^--- PASS`, а не по коду выхода: all-skip читался бы как зелень). | fixed(пак P12) | попутная находка сессии P8-FIX (флейк батареи), передано оркестратору | +| PD-431 | hardening | minor | `internal/pgstore/sessions.go:49` `Touch`, `internal/pgstore/queries/sessions.sql` `TouchSession`, пин отсутствует | **`Touch` выбрасывает `RowsAffected`, поэтому запрос, не обновивший НИ ОДНОЙ строки, неотличим от успеха — и батарея остаётся зелёной.** Замерено адверсариальным проходом пака `sqlc` 29.08: `where token_sha256 = $1` → `where 1 = 0 and token_sha256 = $1`, апдейт не трогает ничего — **18 пакетов, `EXIT=0`, ноль красных**. Контроль на том же стенде (обезвреженный `delete from reservations` в `DeleteBook`) даёт две красных, то есть харнесс работает и молчание относится к `Touch`, а не к прогону. Продуктовый смысл: скольжение окна бездействия — то, что держит активного пользователя в сессии, — не проверяет ни один живой тест; два его вызова (`pg_test.go:115`, `TestTouchCannotResurrectAnIdleExpiredSession`) решаются предикатами самого SQL и остаются истинными, если `Touch` не делает ничего. ⚠ **Класс, который ПОКРЫТИЕМ не ловится в принципе:** для `Exec`, чей тег отброшен, «оператор исполнился» — это всё, что покрытие когда-либо докажет; нужен мутационный проход по ЗАПИСЯМ. Именно поэтому пак `sqlc`, считавший непокрытые строки, нашёл три опасных оператора, а этот — четвёртый — не нашёл. ⚠ **Паком `sqlc` НЕ чинится СОЗНАТЕЛЬНО, и довод принят оркестратором №19:** сделать запрос `:execrows` и проверять число строк — изменение ПОВЕДЕНИЯ (сегодня ноль строк это норма: `Touch` зовут после успешного `Lookup`, но и гонка с отзывом законна), а пак конвертировал, а не менял семантику. Лечение — решение о контрактном поведении: либо проверять тег и отвечать, либо оставить и запинить скольжение окна сквозным тестом через `auth.Authenticator` (сегодня ни один тест не связывает живой `*pgstore.Store` с ним) ⚠ **ЗАКРЫТА пак P12 (30–31.08), ЧЕСТНОСТЬЮ ПРОВЕРКИ — поведение НЕ менялось** (слово оркестратора: ноль строк — законная гонка с отзывом). Новый сквозной пин `pgstore.TestTheIdleWindowSlidesThroughTheAuthenticatorAgainstALiveStore`: живой `*pgstore.Store` подставлен `auth.Authenticator` как его же `auth.SessionStore` — интерфейс он удовлетворял всегда, production не менялся, не хватало теста, который соединит два конца. Живёт в пакете `pgstore`, потому что `pgstore` импортирует `auth`, а не наоборот: тот же тест в `auth` — цикл импорта. Утверждение идёт через ПОЗДНИЙ `Lookup` за первоначальным дедлайном, а не через код ответа: ошибка `Touch` логируется и проглатывается, так что 200 не доказывает ничего. Второе утверждение — что скользнули ПРАВИЛЬНЫЕ часы (абсолютный потолок не двинулся). Посадка M12 — ИМЕННО та, которой строка заведена (`where 1 = 0 and token_sha256 = $1` в `TouchSession`, в исходнике и в генерённой константе): новый тест КРАСНЫЙ адресно, оба прежних `Touch`-теста ЗЕЛЁНЫЕ — то есть дельта ровно та, о которой строка. Добавлен второй пин на отзыв (`TestARevokedSessionIsDeniedThroughTheAuthenticatorAgainstALiveStore`). | fixed(пак P12) | адверсариальный проход пака `sqlc` (П-19), 29.08 | +| PD-432 | doc | minor | `docs/STACK_DECISIONS.md` §«Гейты батареи» (рецепт стенда), `~/.local/bin/tmctl` | **Рецепт второго гейта батареи не говорит, что движковый бинарь надо ПЕРЕСОБРАТЬ, и устаревший бинарь даёт КРАСНУЮ батарею, которая читается как дефект кода зоны.** Замерено 29.08 паком `sqlc`: `tmctl` стенда от 24.08 против сегодняшнего `backend/configs/models.yaml` — три красных теста в `internal/books` и `internal/runner`, сообщение `tmctl: config: parse …/models.yaml: yaml: unmarshal errors: line 137: field system_messages not found in type config.CapabilitiesConfig`. Диагноз стоит времени именно потому, что выглядит как ошибка платформы: падает платформенный тест, а лжёт бинарь движка, собранный до того, как в конфиг движка приехало поле. Лечение `go build -o <стенд>/tmctl ./cmd/tmctl` из `backend/` — после него те же пакеты зелёные. ⚠ Класс тот же, что у `PD-423`: условие батареи, которое рецепт называет неполно, и следующая сессия ищет дефект в своём диффе. Дописать в рецепт: движковый бинарь стенда обязан быть собран из ТЕКУЩЕГО `backend/`, а не переиспользован ⚠ **ЗАКРЫТА пак P12 (30–31.08):** в рецепт «Гейты батареи» дописано, что движковый бинарь второго гейта обязан быть СОБРАН из текущего `backend/`, с симптомом и командой. ⚠ **Той же правкой снята ОПРОВЕРГНУТАЯ команда-проверка четвёртого условия** (`cut -d: -f3 /proc/self/cgroup` ≠ `/init.scope`): к двум точкам `PD-423` пак добавил ТРЕТЬЮ — у оболочки этой сессии команда даёт `/`, а `TestARunIsBoundedByItsOwnCgroup` при этом зелен в полной батарее со скипами 0. Рецепт теперь велит судить по САМОМУ тесту; кандидат-замена (`cgroup.subtree_control` целевого среза) назван КАНДИДАТОМ и в рецепт не внесён — диагноз `PD-423` не установлен. ⚠ **Тем же классом найдено и починено на стенде: КОНФИГИ движка тоже протухают** — стендовая копия `backend/configs` не имела `langpacks/ru`, появившегося позже; правило дописано в рецепт рядом с бинарём. | fixed(пак P12) | пак `sqlc` (П-19), 29.08, найдено первым же прогоном полной батареи | +| 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` разворачивает запиненный контракт. Нужно решение владельца/оркестратора: расширять контракт интейка или оставить асимметрию осознанной ⚠ **ЗАКРЫТА пак P12 (30–31.08) по решению оркестратора: пол самосогласованности СИММЕТРИЧЕН, интейк отказывает.** `ingest.Manifest.Readable()` — ВТОРОЙ предикат рядом с `Whole()`, не расширение `DecodeManifest` (у того четыре пина намеренно декодируют частичные и чужие документы, и его собственная дока объявляет правило «значение не гейтим» решением). Стоит ПЕРВЫМ в `books.Parse`, до ветвей по `ChaptersTotal`: ниже этой черты документ, прочитанный неверно, неотличим от книги, в которой ничего нет, а ЭТО чтение удаляет аплоад. Класс — `parser_unavailable`: бюджет попыток тратится, файл остаётся. ⚠ **ПРАВКА ЗАПИНЕННОГО КОНТРАКТА, заказанная промтом P12 §3.8** — не подгонка под зелень: батарея интейка ездила на документах без списка глав, и фикстуры РАСШИРЕНЫ (`wholeManifest`), а не обойдены; поимённо пере-подписаны фикстуры `books_test.newFixture` и два манифеста `render_test`. Пин — `books.TestTheIntakeRefusesTheDocumentItsOwnMaterialiserWouldReject` (ровно документ строки: 120 глав, 400 пар, пустой список; проверяет и что `chapter_count` НЕ записан, и что файл цел); посадка M15 КРАСНАЯ адресно. | fixed(пак P12) | воркфлоу-ревью волны 2 (P8-FIX); рефутер подтвердил механику и опроверг предложенное лекарство | +| PD-213 | hardening | info | `internal/ingest/manifest.go`, `internal/books/parse.go` | **Форма манифеста не версионируется на стороне платформы — латентная мина на УДАЛЕНИЕ файла.** `DecodeManifest` не сверяет `manifest_version` ни с чем; `json.Unmarshal` тихо игнорирует незнакомые поля и оставляет отсутствующие нулями, поэтому смена формы движком (`tm-manifest-v2` → v3, переименование `chapters_total`) даст валидный разбор с `ChaptersTotal = 0`. А ноль глав интейк трактует как «источник прочли, книги нет» — тот же терминал и тот же бюджет, что exit 11, то есть после пяти попыток файл пользователя УДАЛЯЕТСЯ, хотя движок книгу прекрасно разобрал. Сегодня формы совпадают поле-в-поле, так что не эксплуатируется; в отличие от потока (мажор отвергается) и от полосы кодов (незнакомый номер безопасен), у манифеста аналога нет. Лечить сверкой `manifest_version` с известной, где незнакомая версия даёт НЕ-деструктивный класс ⚠ **ЗАКРЫТА пак P12 (30–31.08) тем же классом «незнакомое → не-деструктивно»,** как и предписывала строка. `ingest.KnownManifestVersion` (`tm-manifest-v2`, зеркало `backend/internal/pipeline/manifest.go:46`) сверяется в `Readable()` перед всем остальным, и незнакомая версия даёт `parser_unavailable` — файл цел. Это ФОРМЕННЫЙ гейт, а не пин значения: сама строка и дока поля правы, что пиннинг значения везде сделал бы каждый релиз движка релизом платформы, поэтому версия сверяется РОВНО в одном месте — там, где неверное чтение разрушительно. Пин — `books.TestAManifestShapeThisBuildDoesNotKnowNeverDeletesTheUpload`: v3-манифест переживает весь бюджет попыток с целым файлом, а ЗНАКОМАЯ форма с честно нулевыми счётчиками по-прежнему удаляет каталог (иначе гейт съел бы настоящий вердикт о тексте пользователя). Посадка M14 (снять сверку версии) КРАСНАЯ адресно. | fixed(пак P12) | адверсариальное ревью P6 (линза шва) | +| PD-427 | doc | minor | `internal/ingest/resync.go:37-43` (аллоулист `StatusReport`), опровергнуто `backend/internal/pipeline/status.go` `projectStoredMemory` (лендинг `6ec9f8a`, D39.170) | **Комментарий несёт ПОСЫЛКУ, которую сняли, и читается как действующий довод.** Он объясняет, почему платформа сознательно НЕ берёт `rebill_units`/`rebill_usd` через шов: «status проецирует СОХРАНЁННУЮ память, и сразу после `bank-apply` — в единственный момент, когда согласие хотело бы цифру, — он честно читает ноль». Это было верно и ратифицировано (эррата 28.08-к). Движковый пак «деньги» починил ровно это: `foldMemoryForRead` стал ПЕРВЫМ ответом читающего пути, а `projectStoredMemory` понижена до фолбэка, и комментарий движка объявляет это дословно — «IT IS NO LONGER THE READ PATH'S FIRST ANSWER». Слепое окно закрыто, `status` отвечает «сколько будет стоить» ДО покупки, оставаясь $0-глаголом без записи. ⚠ **Комментарий неверен ДВАЖДЫ:** не только посылка, но и предсказанное лечение — он обещает, что «пара вернётся с движковым ГЛАГОЛОМ, который умеет свернуть и оценить коррекцию ВНЕ прогона», а нового глагола не появилось: починили существующий `status`. ⚠ **ПРОВОДКУ ПОЛЕЙ ЭТА СТРОКА НЕ ОТКРЫВАЕТ** (слово оркестратора при передаче): она гейчена вместе с `tmctl translate --max-units`, и тот гейт в силе — движковый потолок объёма на майнящей банк книге пробивался, лечение легло, но проводка ждёт отдельного решения. То есть предмет строки — ровно устаревший ДОВОД, а не отсутствие полей. Класс — «указатель пережил то, на что указывал», тот же, что `PD-310`/`PD-326`/`PD-366`, только в прозе шва. Зеркалит строку 234 единого бэклога ⚠ **ЗАКРЫТА пак P12 (30–31.08) — акт закрытия, не работа:** комментарий `internal/ingest/resync.go` уже исправлен 29.08 аудитом доков, снятая посылка из него ушла, предсказание про «новый движковый глагол» тоже. Проверено чтением обеих сторон. Проводку полей строка не открывала и не открывает — гейт `--max-units` в силе. | fixed(пак P12, акт закрытия) | оркестратор №19 при лендинге движкового пака (`6ec9f8a`), проверено чтением обеих сторон сессией P11 | +| PD-203 | bug | info | `internal/pgstore/books.go` `ReadUsage` | **Аккаунт объявляется исчерпанным по паузе ОДНОГО прогона.** `/usage` ставит `paused_reason` аккаунта, если у какой-нибудь книги последний прогон стоит `paused/credit_exhausted` — а это потолок ПРОГОНА (сколько глав купил пользователь), а не баланс: на аккаунте может лежать сколько угодно денег, и другой прогон стартует. Контракт про это поле говорит «Set when the account itself is in a halted state». Существовало до этого пака и не им создано; отдельной строкой, потому что различение потолков (PD-199) сделало вопрос «чей это потолок» отвечаемым ⚠ **СУЖЕНО P7, не закрыто:** `Usage.halt_reason` получил СВОЙ словарь (`AccountHaltReason`), то есть прогонная причина больше не может доехать до аккаунтного поля как чужое слово; и PD-241 убрал самый частый ложный источник — стоп пользователя, приезжавший `credit_exhausted`. Сам предикат («последний прогон ЛЮБОЙ книги стоит paused/credit_exhausted») не тронут: он про потолок ПРОГОНА, а не про баланс, и правильный ответ — читать баланс аккаунта, а не сканировать книги ⚠ **ПРОВЕРЕНО ПАКОМ P8-REVIEW 24.08: диспозиция «сам предикат не тронут» ЛОЖНА против того самого коммита, которым P7 и заленджен.** `internal/pgstore/books.go` `ReadUsage` читает БАЛАНС аккаунта, а не сканирует книги, и аккаунтная причина ставится только при `balance <= 0`; ⚠-комментарий на месте прямо описывает замену («It used to be read off the paused reason of each book's latest run»). Единственный писатель `Usage.PausedReason` — эта строка, проверено грепом по `PausedCreditExhausted`. Приехало `9b23e8c` (`git log -L` по функции). Предложение пака: ЗАКРЫТЬ ⚠ **ЗАКРЫТА пак P12 (30–31.08): предикат пере-проверен и предложение P8-REVIEW подтверждено.** `internal/pgstore/books.go` `ReadUsage` читает БАЛАНС аккаунта и ставит аккаунтную причину только при `balance <= 0`; книги не сканируются. Канон на той же стороне: `AccountHaltReason` — «a state of the account, not of a run», отдельный словарь ровно затем, чтобы прогонная причина не зажигала аккаунтный флаг. То есть дефект строки не воспроизводится, и это закрытие, а не пере-открытие. | fixed(пак P12) | сессия P6 (самопроверка вокруг PD-199) | +| PD-380 | hardening | minor | `internal/pgstore/sessions.go:96`=`AbsoluteExpiresAt: now.Add(maxAge),`, `internal/config/config_test.go` | **Абсолютный потолок сессии не запинен в единственном месте, где он становится фактом в базе.** `CreateSession` — единственный писатель `sessions.absolute_expires_at`, и мутация этого выражения проходит ПОЛНЫЙ пакет `pgstore`: ни один сессионный тест не краснеет. Пин, который `STACK_DECISIONS` §13 называет носителем потолка, смотрит только на результат `config.Load()` (что значение конфигурации не выше ASVS-предела), то есть проверяет НАСТРОЙКУ, а не то, что она доезжает до строки. Родня `PD-86`, но на шаг раньше: там не запинены клаузы ЧТЕНИЯ и потолок держится транзитивно через `Touch`, здесь не запинена сама ЗАПИСЬ, а транзитивной страховки у неё нет. Воспроизведение: `docs/p8-review/axis2-auth/mutations-axis2.sh` ⚠ **Пере-проверено на ПОЛНОЙ батарее координатором пака (мутация M12):** `now.Add(maxAge)` → `now.Add(100*maxAge)` в `CreateSession`, дельта против чистой копии ПУСТА на всех 18 пакетах. ⚠ Первая редакция этой посадки НЕ СОБРАЛАСЬ — комментарий вставлялся в середину строки и съедал хвост `; err != nil {`; это ошибка харнесса, а не находка, и она названа в логе `docs/p8-review/mutations-round2.log`, чтобы приёмка не пере-открыла её как расхождение ⚠ **ВЕС ПОДНЯТ info → minor закрывающим ревью, довод — симметрия с `PD-375`:** форма идентична (единственная точка принуждения объявленной границы не исполняется ни одним тестом, мутация переживает ПОЛНУЮ батарею, транзитивной страховки нет — `Touch` зажимает по значению ИЗ ТОЙ ЖЕ испорченной строки), а вес расходился только по валюте: там деньги и minor, здесь механизм ASVS 7.3.2 уровня 2 при объявленной зоной базовой линии L2 и info. Асимметрия была отпечатком того самого храповика «вреда сегодня нет», который это ревью и нашло ⚠ **Якорь пере-нацелен паком `sqlc` (29.08):** прежний токен — `s.pool.Exec` в `CreateSession` — исчез, потому что SQL этого запроса уехал в `internal/pgstore/queries/sessions.sql` и исполняется генерённым кодом. Новая цель — строка, где `maxAge` СТАНОВИТСЯ значением (`AbsoluteExpiresAt: now.Add(maxAge)`): именно она несёт факт, о котором строка, и она переживёт следующую генерацию. Сам дефект не тронут — потолок по-прежнему не запинен. ⚠ **ЗАКРЫТО паком `sqlc` (`63fcee5`, D39.172), пере-проверено ПОСАДКОЙ, а не рассуждением.** Пин — `pgstore.TestTheTwoSessionDeadlinesAreNotInterchangeable` (`sessions_test.go`): он создаёт сессию с РАЗНЕСЁННЫМИ сроками (`idleTTL` 1 ч против `maxAge` 24 ч — фикстура, где они совпадают, здесь ничего не доказывает, потому что `Touch` зажимает idle к absolute) и утверждает `AbsoluteExpiresAt == now.Add(maxAge)` после `CreateSession`, то есть ровно в единственном месте, где потолок становится фактом в базе. Именно та мутация, которой строка заведена — M12, `now.Add(maxAge)` → `now.Add(100*maxAge)` — теперь КРАСНАЯ адресно (замер 29.08: `AbsoluteExpiresAt = 2026-12-07…, want 2026-08-30…`). Пак строку не искал: тест писался против перестановки двух сроков, и потолок оказался запинен тем же утверждением — поэтому закрытие подтверждено пере-прогоном ИМЕННО M12, а не сходством формулировок | fixed | ревью-пак P8-REVIEW, ось 2 (посадка мутации, пере-посажена рефутером на полном пакете) | +| PD-44 | hardening | info | `internal/pgstore/` | `sqlc` не взят, хотя направление §3 предписывает взять его ДО появления денежных таблиц. Весь денежный SQL — сырые строки pgx ⚠ **P8-FIX: половина, которая ЛЕЧИТ класс, построена; сам инструмент — вопрос владельцу.** Рантайм-ошибки «нет такой колонки» (`r.stop_for_signing`, `chapters_before`) случились в СКЛЕЕННОМ SQL read-модели, куда sqlc по построению не доходит, поэтому тем же пунктом заведён постоянный гейт, который доходит: `pgstore.TestEverySQLStatementParsesAgainstTheMigratedSchema` сворачивает КАЖДЫЙ SQL пакета из исходника (литералы, конкатенации, именованные константы) и планирует его Postgres'ом (`explain (generic_plan)`) против мигрированной схемы — **162 оператора, все планируются**. Посадка мутации в СКЛЕЕННЫЙ фрагмент (`c.units_edit_done` → несуществующая колонка) гейтом ловится; несворачиваемый SQL — ОШИБКА гейта, а не пропуск (единственное исключение — `store.go` `Ready`, где имя таблицы принадлежит goose, и оно выписано таблицей в самом гейте). Тем же гейтом закрыт открытый вопрос фикс-листа «есть ли в read-модели запрос, которого не касается ни один тест»: теперь его касаются все, на каждом прогоне батареи. **ГРАНИЦА sqlc, названная явно (промт §4.1 «реши сам и аргументируй»):** склеек в пакете **25 мест из 147**, фрагментов-констант **15**, у `lastRun` девять потребителей, у `nextRevisionOfThisBooksLibrary` восемь — read-модель для sqlc недостижима, и это пере-считано, а не вспомнено. Свободных от склейки файлов целиком пять: `credits.go`(15) · `identity.go`(13) · `idempotency.go`(7) · `sessions.go`(5) · `observe.go`(1) = **41 запрос**; это единственный кусок, где инструмент силён и ничего не ломает. Конверсия этих 41 — отдельный пак: одиннадцать из пятнадцати денежных запросов идут внутри ЧУЖОЙ транзакции (`WithTx`), генерённый код коммитится, нужен пин версии `sqlc` и гейт «сгенерённое актуально», а чинить он будет класс, который гейт выше уже закрыл. Взять его в этом паке на две-три ручки — ровно то, от чего предостерёг фикс-лист: купить инструмент туда, где не болит. **Предложение зоны: sqlc следующим паком на этот блок из 41 запроса; решение — владельца** ⚠ **РЕШЕНО владельцем 22.08: sqlc берётся ОТДЕЛЬНОЙ СЕССИЕЙ, не внутри пака** — изменений много (генерённый код в дереве, пин версии инструмента, гейт актуальности, `WithTx` для одиннадцати денежных запросов), и мешать их с содержательной работой нельзя. Граница неизменна: 41 запрос в 5 файлах (`credits`·`identity`·`idempotency`·`sessions`·`observe`), остальные 25 склеенных мест недостижимы по построению. ⚠ **ЗАКРЫТО: инструмент взят и заленджен** — `63fcee5`, ратификация `D39.172`, отдельным паком, как решил владелец 22.08 (`D39.154`). Конвертировано 40 запросов из этого самого блока; носители — `platform/sqlc.yaml`, `internal/pgstore/queries/*.sql`, генерённые `*.sql.go` в том же пакете. Актуальность генерации гейчена дважды: `sqlc diff` пререквизитом `make check` и `pgstore.TestEveryGeneratedQueryMatchesItsSourceFile` в батарее (работает без установленного sqlc). ⚠ **Числа в теле выше УСТАРЕЛИ и исправлены паком:** «41 запрос» и `sessions.go`(5) — это счёт до пака P11, добавившего `StillLive`; на 29.08 в наборе **42 места вызова / 41 различный текст SQL** (константа `read` в `idempotency.go` исполнялась из двух мест), из них **конвертируемых 40**. Сороковой не `observe.go`: `Observe` спрашивает `river_job` через `to_regclass`, а эту таблицу мигрирует River сам, вне goose-миграций, поэтому sqlc отвергает запрос — и добавить схему River в конфиг значило бы завести ВТОРОЙ носитель чужой схемы. Гейт `sqlgate` после конверсии видит **172** оператора против пола 140 | fixed | ревью «вне карты»; гейт и граница — P8-FIX | diff --git a/platform/docs/STACK_DECISIONS.md b/platform/docs/STACK_DECISIONS.md index e9b19c13..c14724cd 100644 --- a/platform/docs/STACK_DECISIONS.md +++ b/platform/docs/STACK_DECISIONS.md @@ -339,14 +339,54 @@ материализацию, начатую раньше. Каждый писатель этой колонки обязан идти через сверку метки — безусловная запись возвращает любой из трёх. -35. **«Есть ли у пайплайна редактор» — свойство КНИГИ (`books.edit_wave`), монотонное, от объявления - движка.** Движок называет форму сам: пайплайн без редактора даёт волне `edit` знаменатель ноль - (`beginWaves`). Читать это с последнего прогона НЕЛЬЗЯ — последний прогон самый новый, а только - что допущенный ещё ничего не объявил, и `chapters_done` падал в ноль в момент допуска. Флаг - только растёт: книга, прошедшая редактирующий пайплайн, остаётся такой, иначе полу-сделанные - главы начали бы считаться сделанными. От этого же флага зависит БАЗЛАЙН полосы прогона: базлайн - и числитель обязаны считать один проход, оба конца этого правила — в `StartRun` и в снятии стопа - подписи. +35. **«Есть ли у пайплайна редактор» — свойство КНИГИ, от объявления движка; но фактов ДВА, и в этом + вся правка (пере-подписано паком P12, 30.08, по решению владельца D39.165 §2; прежняя редакция + ниже).** Движок называет форму сам: пайплайн без редактора даёт волне `edit` знаменатель ноль + (`beginWaves`). Читать это с последнего ПРОГОНА по-прежнему НЕЛЬЗЯ — последний прогон самый + новый, а только что допущенный ещё ничего не объявил, и `chapters_done` падал в ноль в момент + допуска (это часть правила не менялась). + - `books.edit_wave` — ИСТОРИЧЕСКИЙ факт, монотонный, пин D39.153 §4б стоит и не отменён: книга, + прошедшая редактирующий пайплайн, остаётся такой. Он больше НЕ авторитет для счёта. + - `books.epoch_editor` + `books.shape_epoch` (миграция 00030) — ЭПОХА: форма как она стоит + СЕЙЧАС (присваивается, не накапливается) и счётчик пересечений границы. Через эпоху считается + ПОЖИЗНЕННЫЙ СЧЁТ КНИГИ — и ТОЛЬКО он. + - **Полоса ПРОГОНА остаётся на монотонном `books.edit_wave`.** Это не оговорка, а замеренное + ограничение: `epoch_editor` присваивается и ходит в обе стороны, поэтому полоса, читающая + эпоху, перестаёт быть монотонной — один прогон читал **4/4, затем 2/2** на своих же двух + попытках при неизменной `structure_version`. Канон держит полосу одной монотонной дробью + (строка 200), так что читать её через эпоху НЕЛЬЗЯ, как бы соблазнительно ни выглядела + симметрия со счётом книги. Цена ограничения названа и открыта строкой `PD-435`: на деплое, где + редактора убрали, полоса тарифицирует edit-волну, которой не будет, и до единицы не доходит. + Правильный носитель для неё — форма, под которой работает ЭТОТ прогон, записанная на самом + прогоне; в `StartRun` она ещё не известна (движок объявляет её первым progress-событием — это + ровно `PD-401`), поэтому это отдельная работа, а не хвост этой. + - Пер-главный счётчик (`units_done` на проводе) — третий читатель и НЕ выводится ни из того, ни + из другого целиком: правило `chapterUnitsDone` написано ОДИН раз и вычисляется ПО ГЛАВЕ (для + прогона с подписью глава, которой не касался редактор, считается по черновику). Книго-широкий + предикат здесь запрещён: кадры глав шлются поглавно, и он менял бы ответ про главы, которых не + касалось ни одно событие. + **Почему разделено:** один факт отвечал на два вопроса и мог быть прав только для одного. + Прочитанный как авторитет счёта, монотонный флаг неверен в ОБЕ стороны — вниз он не ходит: + редактора убрали ⇒ счёт мёрзнет против edit-колонки, которую больше никто не заполнит, полоса + ни одного прогона не доходит до единицы, а шкала покупки снова продаёт переведённое (`PD-403`, + это деньги); редактора добавили ⇒ флаг переворачивается на первом progress-событии и книга + 7/10 показывает 0/10 назад внутри одной `structure_version`, что канон запрещал прямо + (`PD-404`). Решение владельца 28.08: смена формы конвейера — СОБЫТИЕ КНИГИ, как пере-нарезка, и + счёт легально пересчитывается на границе; `shape_epoch` — то, чем клиент отличает законный + пересчёт от хода назад (канон **0.9.0**, поле непрозрачное, саму форму на провод не выносим). + ⚠ **Прежняя редакция ЭТОГО абзаца (30.08) утверждала обратное — «полосу прогона это НЕ ломает, + эпоха всегда объявление её собственного прогона» — и была НЕВЕРНА; заменена 31.08 по замеру.** + Довод звучал убедительно и не выдержал исполнения: у одного прогона две попытки, и вторая + объявляет СВОЮ форму, поэтому эпоха между ними ходит вниз. `PD-401` при этом действительно был + не про то, какой флаг выбирает волну, а про парность числителя и базлайна своей колонки — она не + тронута, базлайны по-прежнему берутся в `StartRun` и не пере-снимаются. Но парности мало: + монотонность полосы держит именно МОНОТОННЫЙ флаг. + > Прежняя формулировка (до 30.08), оставлена с датой и причиной: «свойство КНИГИ + > (`books.edit_wave`), монотонное… Флаг только растёт: книга, прошедшая редактирующий пайплайн, + > остаётся такой, иначе полу-сделанные главы начали бы считаться сделанными. От этого же флага + > зависит БАЗЛАЙН полосы прогона». Причина замены: правило было верно для СОХРАННОСТИ счёта и + > ложно для его ПРАВИЛЬНОСТИ — монотонность защищала направление «редактора добавили» и морозила + > «редактора убрали», и обе стороны стояли открытыми строками регистра. 36. **Утверждение про АТОМАРНОСТЬ пишется через `xmin`.** Пин, проверяющий конечное состояние, не видит выноса записи из транзакции во второй оператор — состояние то же, меняется окно. `xmin` @@ -373,6 +413,8 @@ | `.auto-bank.yaml` | `pipeline/mining.go` `os.WriteFile` | **НЕ атомарно** | читателя нет | ✅ то же правило | | `mined_delta` (путь из `book.yaml`) | **писателя в движке НЕТ** — только читатель `loadMinedDelta`; формат `seed.File` (`terms:`), грузится `membank.LoadGlossarySeed`, `Source` пере-штампуется на `"mined"` | — | **писателя НЕТ и у платформы** | ❌ **разрыв — это строка 199(а) единого бэклога**, развилка ждёт ратификации | | `mined_rejects` | читатель `loadMinedRejects`; формат `rejects: [{src, note}]` — это ФИЛЬТР ПРЕДЛОЖЕНИЙ, в банк не входит | — | писателя нет | ❌ тот же разрыв | +| `tmctl bank-apply` + документ решений | зона ПИШЕТ (`internal/runs/bank.go` `decisionsFile` → файл во временном каталоге), движок отвечает отчётом `tm-bank-report-v1` на stdout | документ пишется целиком до вызова; отчёт — одноразовая выдача на вызов | `internal/runner/bankapply.go` → `ingest.DecodeBankReport` → `internal/runs/bank.go` `bankVerdict` | ⚠ **СЕДЬМОЙ канал, дописан 31.08 дофиксом приёмки P12** (в первой редакции этой таблицы его не было, и `platform/README.md` трижды подряд объявлял список исчерпывающим при неполном счёте). ЕДИНСТВЕННЫЙ канал, по которому платформа ПИШЕТ в проект движка, — отсюда и своя дверь (`POST /books/{bookId}/bank/corrections`), и свой класс отказов, и класс `write_incomplete` = exit 15. ⚠ Пост-verb факт этого канала (`bank_moved_at`) пишется на ОТДЕЛЬНОМ, отцепленном контексте — иначе обрыв клиента теряет его навсегда (`PD-425`) | +| `tmctl build` + сайдкар `.book.` | движок (`pipeline`, пак «писатель книги», D39.175) | файл пишется целиком до публикации пути; ПУТИ публикуются в `StatusArtifacts.book_files` (`status --json` / `manifest --json`), stdout — конверт `tm-build-v1` | **читателя НЕТ** (грепом по зоне — ноль вхождений) | ⚠ **ШЕСТОЙ канал, дописан паком P12 31.08 по пингу аудита доков.** Его будет читать дверь выдачи `createExport`/`getExport`, и она обязана СТРОИТЬ — звать `tmctl build` — а НЕ подбирать файл, лежащий рядом с БД: там копия ПРЕЖНЕЙ сборки (D39.175 п.2, слово владельца). Сверять `BuildReport` (`config_drift`/`stale_unknown`). ⚠ Новый класс отказа движка: exit **16** `book_incomplete` — книга с дырами без `--partial`; раскладка на провод — при постройке двери. ⚠ И ловушка на будущее: интейк на exit **11** действует ДЕСТРУКТИВНО, а `tmctl build` книги из нуля юнитов выходит именно 11 — сегодня недостижимо (интейк зовёт `manifest`, не `build`), учесть при подключении `build` к автоматике | | `tmctl export --json --pairs` | движок (`pipeline/export.go`) | — (одноразовая выдача на вызов) | `internal/runner/engine.go` `ExportArgs`/`Export` → `ingest.DecodeExport` → `internal/readmodel` | ✅ ⚠ **Единственный канал, несущий ТЕКСТ пары** — исходник и перевод; манифест несёт только структуру. Пропущен в первой редакции этой таблицы (найдено аудитом доков в тот же день) | | `events.jsonl` (NDJSON эмиттера) | движок, StreamVersion 1.1 | append-only | `internal/ingest/tail.go` + `pgstore.RunSink` | ✅ | | exit-коды `tmctl` | контракт движка | — | `ingest.OutcomeOf`, `internal/runs/reconcile.go` `outcome` | ✅ | @@ -415,14 +457,35 @@ sed -e 's#^pipeline: ../configs/#pipeline: /backend/configs/#' \ скипнутых: `TM_PLATFORM_TEST_DSN` (Postgres) · пара `TM_PLATFORM_TEST_ENGINE_BIN` + `TM_PLATFORM_TEST_BOOK_TEMPLATE` (живой рендер конфигурации и живой прогон движка) · ДОСТИЖИМЫЙ пользовательский менеджер systemd (`/run/user/`; без него три теста `internal/runner` -скипаются молча — `PD-374`) ⚠ **и ЧЕТВЁРТОЕ условие, которого здесь не было до 29.08: вызывающий -процесс обязан жить ВНУТРИ `user@.service`.** Проверка одной командой: -`cut -d: -f3 /proc/self/cgroup` не должен давать `/init.scope`. Оболочка, поднятая вне -пользовательского входа (под WSL — обычный случай), проходит проверку «менеджер достижим» и всё -равно не получает лимитов: `systemd-run --user` заводит юнит в модели менеджера, а процесс остаётся -в исходном cgroup, поэтому `MemoryMax` не применяется НИ К КОМУ и молча. Симптом — -`TestARunIsBoundedByItsOwnCgroup` красный при зелёном всём остальном (`PD-423`). Ожидание при всех трёх: 18 пакетов, exit 0, **скипов 0**, линтер -«0 issues». Замерено 29.08: с гейтами — 0 скипов на обоих деревьях; без них — exit 0 и **287 скипов на HEAD +скипаются молча — `PD-374`) ⚠ **и ЧЕТВЁРТОЕ условие, которого здесь не было до 29.08: хост обязан +РЕАЛЬНО применять `MemoryMax` к транзиентному юниту.** + +⚠⚠ **Движковый бинарь второго гейта обязан быть СОБРАН ИЗ ТЕКУЩЕГО `backend/`, а не переиспользован +со стенда** (`cd /backend && go build -o $W/tmctl ./cmd/tmctl`) — дописано паком P12 30.08 по +строке `PD-432`. Цена пропуска названа замером: стендовый `tmctl` от 24.08 против сегодняшнего +`backend/configs/models.yaml` дал ТРИ красных теста в `internal/books` и `internal/runner` с +сообщением `tmctl: config: parse …/models.yaml: yaml: unmarshal errors: line 137: field +system_messages not found in type config.CapabilitiesConfig`. Диагноз стоит времени именно потому, +что выглядит как дефект зоны: падает платформенный тест, а лжёт бинарь движка, собранный до того, +как в конфиг движка приехало поле. То же правило и той же причины — для КОНФИГОВ стенда, если они +скопированы рядом с бинарём: пак P12 нашёл стендовую копию `backend/configs` без `langpacks/ru`, +появившегося позже. + +⛔ **Команда-проверка четвёртого условия СНЯТА и не подлежит восстановлению без нового диагноза.** +Прежняя редакция предлагала одну строку: `cut -d: -f3 /proc/self/cgroup` не должен давать +`/init.scope`. Она **ОПРОВЕРГНУТА** — второй точкой `PD-423` (29.08: тест зелен 5 из 5 в изоляции +при `/init.scope`, прямая проба `systemd-run --user --scope` показала, что процесс ВСЁ-ТАКИ попадает +внутрь `user@.service`) и третьей точкой пака P12 (30.08: у оболочки `cut -d: -f3 +/proc/self/cgroup` = `/`, и `TestARunIsBoundedByItsOwnCgroup` при этом зелен в полной батарее со +скипами 0). То есть cgroup ВЫЗЫВАЮЩЕГО процесса условие не предсказывает — команда даёт ложный +отрицательный, и сессия, честно исполнившая её, объявит гейт невыполненным на хосте, где он выполнен. +**Судить по самому тесту:** `TestARunIsBoundedByItsOwnCgroup` зелен при 0 скипов ⇒ условие есть. +Красный — сначала проверять СРЕДУ (`cgroup.subtree_control` целевого среза, рецепт в «граблях» +ниже), и только потом искать дефект в своём диффе. Кандидат на замену команды — +состояние `cgroup.subtree_control` среза `tm-runs.slice` в момент прогона — назван КАНДИДАТОМ и +только: диагноз `PD-423` не установлен, и вносить его в рецепт как проверку нельзя. + +Ожидание при всех трёх: 18 пакетов, exit 0, **скипов 0**, линтер «0 issues». Замерено 29.08: с гейтами — 0 скипов на обоих деревьях; без них — exit 0 и **287 скипов на HEAD `fbe6cf3`**, **304 на дереве пака P11** (пак добавил 17 пинов, гейченных тем же DSN). Число зависит от дерева, и переносить его между ними нельзя. diff --git a/platform/docs/platform-PROGRESS.md b/platform/docs/platform-PROGRESS.md index 44d81854..cceedbc9 100644 --- a/platform/docs/platform-PROGRESS.md +++ b/platform/docs/platform-PROGRESS.md @@ -4,6 +4,15 @@ > вопросы, предложения на ратификацию. В `docs/PROGRESS.md` платформа не пишет; оркестратор > читает этот журнал при каждом лендинге зоны (свип «решений владельца» — норма D39.99 п.4). +## ПИНГ оркестратора №20 → зоне платформы (30.08, аудит доков: три места твоей зоны врут после пака «писатель книги») + +Инвентарь всех живых доков против дерева (5 аудиторов + верификатор на находку) дал по твоей зоне три расхождения. Правку вношу НЕ я — твоя зона; прошу одним заходом, удобнее всего с паком P12: +1. **`platform/README.md`, «Каналов движка ПЯТЬ, и других нет»** — фраза объявлена исчерпывающей и уже правилась по счёту 29.08. После D39.175 канал ШЕСТОЙ: глагол `tmctl build` и его файловый сайдкар `.book.`, чьи пути публикуются в `artifacts.book_files`. Именно этот канал будет читать дверь выдачи — молчание списка обойдётся дороже прочих. +2. **`platform/docs/STACK_DECISIONS.md`, «Инвентарь каналов движка»** — тот же пропуск: нет ни строки `tmctl build`, ни сайдкара книги. К этой таблице зона ходит за атомарностью сайдкаров, поэтому пропуск читается как «канала нет». +3. **`platform/BACKLOG.md` П-12 (и `PD-175`)** — формулируют хвост как ПРОДУКТОВУЮ развилку, ждущую слова владельца («сколько книг во фри-тир»). D39.176 п.1 снял её целиком: продуктовых квот НЕТ и не будет, фри-тир-лимиты не проектируются; остаётся ИНЖЕНЕРНАЯ гигиена (ретеншен `rejected`, свип сирот, потолок диска), решаемая зоной без слова владельца. Новость доставлена пингом выше, но долговечной диспозиции нет ни в бэклоге, ни в регистре — а пинг в журнале уедет в архив. + +⚠ Попутно: `platform/docs/DEFECT_REGISTER.md` держит пять якорей в код, уехавших под правками пака P12 (гейт `counts.py --lint` их называет). Это твоя зона — чинит она; оркестратор чужой регистр не правит. + ## ПИНГ оркестратора №20 → зоне платформы (30.08, слова владельца по продуктовому листу — D39.176) Ревизия пака P9 собрала твой продуктовый лист и вынесла владельцу; ответы получены, ратификация — **D39.176**: @@ -21,6 +30,344 @@ - ⚠ Находка приёмки: интейк на exit **11** действует ДЕСТРУКТИВНО (удаляет аплоад), а `tmctl build` книги из нуля юнитов выходит именно 11 (`sourceHasNoContent`). Сегодня недостижимо — интейк зовёт `manifest`, не `build`; учесть при подключении `build` к автоматике. - `tm-export-v1` расширен АДДИТИВНО: `heading` · `ghost_units` (отдельное верхнеуровневое поле — в `chunks` ghost-строки НЕ попадают) · `text_modified`; `DecodeExport` проверен приёмкой: новые поля переживает, `unitState` не задет. +## ДОФИКСЫ ПРИЁМКИ P12 ИСПОЛНЕНЫ — восемь пунктов, из них один про поведение; батарея зелёная (сессия `textmachine-main-b5`, 31.08) + +Приёмка оркестратора №21: 6 major, 16 minor, блокеров нет. Ниже — что сделано по каждому пункту. + +**1 (major, код) — пер-главный счётчик читал нули всю черновую волну.** Диагноз приёмки верен и это моя +регрессия: снятая мной вторая редакция предиката накрывала только момент стояния у стопа. Взял вариант +**(а)**, пер-главный, но НЕ ровно кандидатом приёмки: правило написано ОДИН раз константой +`chapterUnitsDone` и читается ОБОИМИ читателями — кадром главы в sink'е и `ListChapters`. Причина, по +которой не двумя выражениями: гейт `TestEverySQLStatementParsesAgainstTheMigratedSchema` требует +компайл-тайм-константы и функцию-помощник отверг — правильно; а две копии одного правила это ровно то, +как страница начинает спорить с кадром, который клиенту только что толкнули. Следствие: `ListChapters` +теперь джойнит книгу и её текущий прогон, и `scope.wave` — вторая копия правила, книго-широкая — +удалена совсем. Три состояния пинит `TestAChapterOfASigningRunCountsByThePassCurrentForThatChapter` +(черновая волна · своя-глава-против-чужой · переход на edit-колонку по СВОЕЙ главе), и он ловит ОБЕ +откаченные редакции: посадка **M20** (сузить до момента у стопа) и **M21** (сделать терм книго-широким) +— обе красные адресно. **Цена джойна замерена, а не оценена:** страница 100 глав — **0.617 мс против +0.496 мс** без джойна (`BenchmarkChapterPage`, тот же вакуумированный корпус, что у `LibraryPage`; +бенчмарк добавлен тем же деревом). +24% и +0.12 мс абсолютных за одно правило вместо двух. + +**2 (major, док) — §35 агитировал за откаченную регрессию.** Абзац переписан: через эпоху считается +ТОЛЬКО пожизненный счёт книги; полоса прогона остаётся на монотонном `edit_wave` с замеренной причиной +(4/4 → 2/2 на двух попытках одного прогона) и ссылкой на `PD-435`; довод «полосу это НЕ ломает» помечен +как прежняя редакция, неверная, с датой. Добавлен третий читатель — пер-главное правило и запрет +книго-широкого предиката в нём. + +**3 (major, регистр) — `PD-403` ссылалась на пин, которого нет.** Имя снято; в строке прямо написано, +что теста с таким именем в зоне нет ни одного и что свойство несёт открытая `PD-435`, а не пин. + +**4 (minor) — шапка против тела.** Старый тест заменён целиком (п.1), противоречия не осталось. ⚠ Но +моя же вставка при этом УКРАЛА док-комментарий у `TestTheBarIsOneMonotonicFractionThroughTheSigningStop` +— тот же класс, что п.6; возвращён на место. + +**5 (minor, обязательно сейчас) — миграция 00030 неверно цитировала D39.163.** Ссылка была на «словарь +стадий закрыт, третьего исключения нет»; D39.163 про то, что ВТОРОЕ исключение выдано под двумя +условиями. Переписано по существу: границу канона (§Boundaries) непрозрачному счётчику поколения +исключение не нужно вовсе, потому он им и является. Хеш в `migrations.sha256` пере-записан. + +**6 (minor) — четыре украденных док-комментария.** Все четыре возвращены своим функциям: +`ingest.Manifest.Whole()`, `pgstore.TestAClaimThatLostARaceToAReleaseIsRetriedAndNotAnError`, +`books.TestAManifestThatContradictsItselfNeverCostsTheUpload` и (мой собственный, п.4) +`pgstore.TestTheBarIsOneMonotonicFractionThroughTheSigningStop`. + +**7 (minor) — шесть номерных якорей в `bank.go`.** Переведены на символы и грепы (`writeCtx`, +`settleCtx`, `runner.BankApply`, греп `bank_moved_at =`, `if book.BankMoved`, `pgstore.StartRun`); +номерных якорей в файле теперь **ноль**. В сам комментарий вписано, почему по символам. + +**8 (minor, три места).** `readmodel.go` «small and bounded» ушло вместе со старым комментарием при +п.1. `platform/README.md`: добавлен СЕДЬМОЙ канал (`tmctl bank-apply` — единственный, которым зона +ПИШЕТ в проект движка), и **притязание «и других нет» снято** как трижды не оправдавшееся, со ссылкой +на исчерпывающий инвентарь в `STACK_DECISIONS`; туда же добавлена строка `bank-apply`. +`deploy/README.md`: вторая, независимая причина порядка «сначала платформа, потом движок» — подъём +формы манифеста без платформенного билда уводит КАЖДУЮ книгу в `parser_unavailable` и по бюджету +попыток в `rejected`, при здоровом на вид движке. + +**Диспозиция по `PD-435`: оставляю `minor`, довод в самой строке.** Мажорной `PD-403` делали ДЕНЬГИ — +шкала продавала переведённое; эта половина закрыта, и `PD-410` остаётся `major` именно поэтому. Здесь +не двигается ни один микро-доллар: прогон делает всю купленную работу, закрывается `ready`, леджер +сходится, остаток считается верно. Врёт дробь и подпись — контрактно видимо и потому не `info`, но это +отчёт о работе, а не её оплата; плюс нужна смена формы деплоя под уже отредактированной книгой, тогда +как `PD-410` кусает на каждой покупке. Сочтёшь довод слабым — поднимай, работа от веса не меняется. + +### Числа дофиксов (каждое — командой) + +- `make check` при трёх гейтах + условии хоста: **18 пакетов, EXIT=0, скипов 0**, линтер «0 issues», + `sqlc diff` чист. +- `counts.py --check`: битая форма пусто, хвост вне словаря пусто; открытых **97** (major 2 · minor 32 · + info 63). +- `counts.py --lint`: **0 проблемных якорей в 101 живом доке.** ⚠ По дороге пришлось пере-нацелить ещё + ДВА — их убила моя же правка `deploy/README.md` из п.8; правило «якорь, убитый твоим переездом, + чинишь ты» сработало на мне в тот же заход. +- Новых посадок: **M20**, **M21** (обе на правило п.1), обе красные адресно. +- Новый бенчмарк `BenchmarkChapterPage`: 0.617 мс против 0.496 мс до джойна. +- Тестов после дофиксов: **+12 новых, 3 пере-подписаны** (дифф `^func Test` исполнением + новые файлы). + +## ПАК P12 ОТРАБОТАН — семь major разобраны, обход `--verify-bank` снят и пробит живьём, канон 0.9.0; адверсариальный проход нашёл в СВОЕЙ работе три регрессии и они откачены (сессия `textmachine-main-b5`, 31.08) + +Дерево передаётся оркестратору `textmachine-main-5c`. Не коммичу. ⚠ В дереве лежат ТВОИ незакоммиченные +правки — `docs/PROGRESS.md`, `docs/architecture/05-decisions-log.md` (D39.178), `05-decisions-index.md`; +я их не трогал и в свой состав не включаю. + +### Таблица комплектности против §3 (пункт → сделано → чем ДОКАЗАНО) + +| § | Взято? | Чем доказано | +|---|---|---| +| 3.1 `PD-425` | **да** | детач РОВНО записи (`WithoutCancel`+`recordBudget`); пин `TestTheBankMoveFactSurvivesAClientThatHungUp`, посадка M1 красная адресно | +| 3.2 `PD-403` | **половина** | счёт книги — да (эпоха, миграция 00030, канон 0.9.0); полоса прогона — НЕТ, откачено, новая строка `PD-435` | +| 3.2 `PD-404` | **да** | тот же носитель; пин `TestAddingTheEditorMovesTheEpochWithTheCountItRecomputes`, посадка M9 | +| 3.2 `PD-405` | **да** | отказ старта до дерева, ДО холда; пины ×2, посадки M10/M19 | +| 3.2 `PD-411` | **да** | миграция 00031, оба писателя сняты; вскрытый гап заведён строкой `PD-434` | +| 3.3 `PD-402` | **да** | константа `newestRun` в 10 склейках + `LatestRun`; 3 пина по местам, посадки M6/M7 | +| 3.4 `PD-424` | **половина** | счёт неудач и видимость — да (пин + M13); терминальная РУЧКА — нет, диспозиция подписана | +| 3.4 `PD-418` | **дешёвая половина** | оговорка в рантбуке; долговечное лечение — диспозицией, состояние по-прежнему недостижимо | +| 3.5 `PD-369` | **да** | `ErrKeyContended` оборачивает `ErrKeyInFlight`; серия **80/80 PASS, 0 FAIL, 0 SKIP** | +| 3.5 `PD-420` | **пере-замерена** | 3 параллельные полные батареи в чистых копиях: 18/18 каждая, 0 красных. Одна точка, не опровержение — строка открыта | +| 3.6 `PD-431` | **да** | сквозной пин через `auth.Authenticator` с ЖИВЫМ стором; посадка M12 — ИМЕННО та, которой строка заведена | +| 3.6 `PD-432` | **да** | рецепт дописан; опровергнутая команда `/proc/self/cgroup` снята (моя точка — третья) | +| 3.7 обход | **да, весь состав (а)+(б)+(в)** | миграция 00029; **живой пробой на стенде**, см. ниже | +| 3.7 строка 240 | **да** | и потом ПЕРЕ-починена: адверсариальный проход нашёл, что гард читал устаревший снапшот | +| 3.8 `PD-367` | **да** | симметричный пол; фикстуры расширены (`wholeManifest`), не обойдены; посадка M15 | +| 3.8 `PD-213` | **да** | форменный гейт версии манифеста; посадка M14 | +| 3.9 гигиена | **да** | 13 строк закрыто, 15 перенесено в секцию эры P12, 4 новые строки, 4 пере-диспозиции | + +### Живой пробой §3.7 — сделан НАСТОЯЩИЙ, и вот чем он отличается от достижимой половины + +Промт предупреждал, что пробой может упереться в данные стенда, и он уперся: движок падает ГРОМКО, если +`--verify-bank` передан книге, которая не умеет майнить (`backend/internal/pipeline/mining.go:57-67`), а у +стендовой книги не было ни `langpack_root`, ни секции `mining:`. Достроил стенд НАСТОЯЩИМ артефактом, а не +выдумкой: контраст — `jieba_dict_general_zh.txt`, sha256 сошёлся с пином `docs/experiments/16-bank-mining.md:183`. + +**Что доказано исполнением, в двух ярусах.** +1. Движковый: `tmctl translate --verify-bank` ⇒ **EXIT 3**, стоп, `bank_stop_presented` = 2 поверхности + (方源, 青茅山). Тот же вызов ВТОРОЙ раз ⇒ **EXIT 0**, журнал движка: «`--verify-bank` is raised and the + delta is non-empty, but every cluster in it was already presented by an earlier stop — continuing». +2. Сквозной через платформу: свежий `tmplatformd` (демона №19 убил ПО PID), интейк → `POST /runs` + `stop_for_signing:true` → **`awaiting_bank` 2/4 stage=editing** → `POST /resume` → **`ready` 4/4**. + Прямая улика, что флаг ДОЕХАЛ на возобновлённой попытке: тот самый WARN движка в journald за окно + резюма (он эмитится ТОЛЬКО при поднятом флаге) плюс записанный `auto-bank.yaml`. + +То есть «стоп банка доезжает флажком движка, а не обходом» — не мнение и не половина. + +### ⚠ АДВЕРСАРИАЛЬНЫЙ ПРОХОД ПО СВОЕЙ ГОТОВОЙ РАБОТЕ НАШЁЛ ТРИ РЕГРЕССИИ, ВНЕСЁННЫЕ ЭТИМ ЖЕ ПАКОМ + +Дерево на время прохода было заморожено. 5 линз → 19 находок → адверсариальная верификация каждой +(рефутер по умолчанию опровергает): **14 подтверждено, 5 опровергнуто**. Существенны три, и все три — мои. + +**1. Полоса прогона перестала быть монотонной, и я обещал обратное.** Я перевёл `runDone`/`runTotal`/ +`runStage` на эпоху — а эпоха ПРИСВАИВАЕТСЯ и ходит в обе стороны. Замерено на живом Postgres через +продовые пути: один прогон читал **4/4, затем 2/2** на своих же двух попытках при неизменной +`structure_version`. Канон держит полосу одной монотонной дробью (строка 200). **Откачено:** полоса +вернулась на монотонный `books.edit_wave`, на эпоху уехал только пожизненный счёт книги — ровно то, что я +писал тебе в пинге и в записке-плане и от чего отступил по ходу. Цена названа: вторая половина `PD-403` +(полоса не доходит до единицы на деплое без редактора) НЕ закрыта, заведена `PD-435` с разбором, почему это +не однострочник. Обратная сторона запинена: `TestTheRunsBarIsMonotoneAcrossAShapeBoundaryItSpans`. + +**2. Гард строки 240 читал УСТАРЕВШИЙ снапшот, то есть не работал в главном случае.** `LiveRun` — копия по +значению ДО дрейна журнала, а событие банк-стопа приходит именно в этом дрейне. Замерено: после дрейна +строка в БД читает `awaiting_bank`, снапшот всё ещё `translating`, и оплаченный стоп рестартился насквозь. +Плюс вторая дыра: ветвь `interruptedBySomeoneElse` (внешний SIGTERM, exit 5) отвечает РАНЬШЕ `outcome`, где +лежало моё правило. **Починено:** статус пере-читывается после дрейна вместе с ceiling-причиной +(`freshRunState`), и ветвь внешнего сигнала исключает `awaiting_bank`. Пин на ОБА пути — +`TestABankStopArrivingInThisSweepsOwnDrainIsNotRestartedPast`, посадки M16/M17 красные адресно. + +**3. Производный сегмент полосы флипался НЕОБЪЯВЛЕННО.** Мой предикат имел второй терм `draftBar < draftWork` +— книго-широкий, а кадры глав шлются ПОГЛАВНО: он переворачивался на draft-юните ЧУЖОЙ главы, молча меняя то, +что пере-чтение говорит о главах, которых не касалось ни одно событие. Замерено на настоящем sink'е. +**Починено:** терм снят, остался `r.verify_bank and r.status = 'awaiting_bank'` — а статус двигается только +на банк-стопе и на резюме, и оба бампают ревизию и шлют кадр, то есть каждый флип объявлен. + +**Плюс восемь честностей, каждая исправлена:** тавтологическое утверждение в моём же пине PD-402 +(`sc.bookID` копируется из аргумента — не могло упасть никогда; заменено на `wave`, посадка M6b теперь +красная) · SHAPE-утверждение в пере-нацеленном тесте ресинка выполнялось СВОЕЙ ЖЕ фикстурой (поток уже +записал ту же форму; развёл формы, посадка M18 красная) · заголовок пина PD-405 обещал посадку, которую +ничто не ловило (завёл недостающий случай, M19) · комментарий в `runs_test` утверждал, что эпоха делает +дубль-половину кусачей — не делает, поправлено · `&& emptyBook(m)` стал недостижимо-ложным за новым полом — +конъюнкт снят вместе с осиротевшим хелпером · док-комментарий `ApplyStatus` всё ещё обещал четыре снесённые +колонки · `shape_epoch` попал в `properties` канона мимо `required`, хотя сервер шлёт его всегда · книга со +списанным долгом поверхности стала НЕзапускаемой — ручка существует (`tmplatformctl book refresh`) и теперь +названа прямо в комментарии гарда и в строке регистра. + +### Числа сдачи (каждое — командой) + +- **`make check` при ТРЁХ гейтах + условии хоста: 18 пакетов, EXIT=0, скипов 0, линтер «0 issues», `sqlc diff` чист.** + ⚠ Четвёртое условие сужу по САМОМУ тесту (`TestARunIsBoundedByItsOwnCgroup` зелен при 0 скипов), а не по + опровергнутой команде: у моей оболочки `cut -d: -f3 /proc/self/cgroup` = `/`, и это ТРЕТЬЯ точка против неё. +- ⚠ **`sqlc` на машине НЕ БЫЛО**, а `make check` держит `sqlc diff` пререквизитом — то есть батарея зоны + была недостижима, пока я не поставил пин `v1.31.1`. Стоит знать при следующем подъёме окружения. +- Три параллельные полные батареи в чистых копиях: 18/18 каждая, 0 красных (замер для `PD-420`). +- `PD-369`, серия: `-count=80` ⇒ **80 PASS / 0 FAIL / 0 SKIP** (счёт по `^--- PASS`, не по коду выхода). +- Тесты: **+12 новых, 3 пере-подписаны** (дифф `^func Test` исполнением + новые файлы). +- Мутационных посадок: **19**, каждая в СВОЕЙ копии дерева ВМЕСТЕ с каноном, вердикт по ДЕЛЬТЕ против + чистой базы ТОЙ ЖЕ копии. Все 19 красные адресно. +- Миграции: 00029 (снос `bank_released`), 00030 (эпоха), 00031 (снос четырёх мёртвых колонок). Down-путь + гоняется существующим `TestARollbackSurvivesTheDataTheNewVocabulariesWrote` (DownTo(5)+Up) — зелен. +- Регистр: 435 строк, открытых **97** (major 2, minor 32, info 63) против 107 на входе; `counts.py --check` + чист по форме и словарю. + +### Пере-подписанные пины — поимённо (D39.121: заказанная смена контракта, не подгонка) + +1. `TestAResumedRunIsSpawnedWithoutTheSigningStop` → **`...WithTheFlagItStartedWith`**, утверждение + ИНВЕРТИРОВАНО. Заказано §3.7 промта; прежнее было верно для движка ДО памяти v16. +2. `TestTheReconcilerDoesNotLiftABankStopNobodySigned` → **`...DoesNotRestartPastABankStopNobodySigned`**. + Пинившийся гард (`LiftBankStop`) с памятью v16 не защищал НИЧЕГО; защита переехала на уровень выше. +3. `TestTheResyncMaterializesThePhaseSplitAndNeverLowersACounter` → + **`TestTheResyncRecordsFreshnessAndShapeAndNoProgress`**. Предмет исчез вместе с колонками; утверждение + СУЖЕНО до истинного, а потерянное свойство заведено строкой `PD-434`, а не замолчано. +4. Фикстуры интейка (`books_test.newFixture`, два манифеста `render_test`) — расширены `wholeManifest`. +5. Фикстуры runs (`newFixture`, `secondBook`) — получили дерево глав. +6. `TestTheBarIsOneMonotonicFractionThroughTheSigningStop`, `TestADraftOnlyDeploymentCountsItsOneWaveOnce`, + `TestLiftingTheSigningStopLeavesTheBarWhereItStood` — снято поле `LiftBankStop`, утверждения не тронуты. + +⚠ Чужой пин НЕ правил: `seam_test.TestAnEndingIsNeverDecidedFromAReadThatFailed` матчится на текст ошибки, +который задел мой рефактор — сохранил формулировку валидной, а не переписал чужое утверждение. + +### Диспозиции по норме §3 п.8 (греп ОТКРЫТЫХ строк по СВОИМ файлам ПОЛНЫМИ путями) + +Совпадений **57** на 38 моих файлах. Из них: **13 закрыто** этим паком · **4 пере-диспозиционировано** +(`PD-410` — направление D39.165 §1 есть, механика отдельным паком; `PD-420` — пере-замерена; `PD-423` — +третья точка, команда снята из рецепта, диагноз по-прежнему не установлен; `PD-418` — оговорка написана) · +**2 сужены** (`PD-403` до половины, `PD-424` до ручки) · остальные **38 — со-локация по файлу, не +взаимодействие**: их якоря я проверил, ни один мой переезд их не убил. + +### Obstacle — что НЕ удалось и что осталось НЕПРОВЕРЕННЫМ + +- **Не проверено:** сколько ещё раз параллельные батареи должны пройти чисто, чтобы снять вторую точку + `PD-420`. Три чистых прогона при заявленной частоте «1 из 4» ничего не исключают. Строка открыта честно. +- **Не проверено:** диагноз `PD-423`. Я добавил третью точку и снял ложную команду из рецепта, но что + РАЗЛИЧАЕТ точки — по-прежнему не установлено. Кандидат (`cgroup.subtree_control` целевого среза) назван + кандидатом и в рецепт не внесён. +- **Не сделано, подписано:** терминальная ручка `PD-424` (`run abandon` над живым прогоном) — новая + разрушительная операторская поверхность над деньгами, дизайн своего размера. +- **Не сделано, подписано:** долговечное лечение `PD-418` (ветвление по осиротевшей попытке). +- **Не сделано, заведено строкой:** `PD-434` (ресинк не материализует прогресс) и `PD-435` (полоса на + деплое без редактора). Обе вскрыты этим паком и обе НЕ лечатся тем, что пак делал. +- **Подтверждено:** остаточное окно `PD-425` — смерть ПРОЦЕССА между verb и записью теряет факт навсегда. + `WithoutCancel` переживает клиента, не процесс; свипа быть не может (у платформы нет свидетеля + свёрнутой коррекции). Названо в строке при закрытии. +- **Стенд остался в рабочем виде и изменён:** конфиги пере-собраны из текущего `backend/` (не хватало + `langpacks/ru`), `tmctl` пере-собран, база `tmstand` ПЕРЕ-СОЗДАНА (миграции сносят колонки), демон — + мой свежий на 8080. Добавлены `pipeline-zero-mining.yaml`, `book-template-zero-mining.yaml` и книга + `probe-verify-bank`. ⚠ `TM_PLATFORM_BOOK_TEMPLATE` демона сейчас указывает на майнящий шаблон. + +### Пинг аудита доков (три места зоны) — ИСПОЛНЕН тем же деревом + +1. `platform/README.md`: «каналов ПЯТЬ» → **ШЕСТЬ**, шестой назван (`tmctl build` + сайдкар + `.book.`, пути в `StatusArtifacts.book_files`) вместе с правилом, что дверь выдачи обязана + СТРОИТЬ, а не подбирать лежащий рядом файл. ⚠ Счёт в этой фразе отставал от списка ДВАЖДЫ, поэтому в неё + вписано, что список считается, а не запоминается. +2. `platform/docs/STACK_DECISIONS.md`, «Инвентарь каналов движка»: строка `tmctl build` добавлена с + атомарностью, отсутствующим читателем, exit **16** `book_incomplete` и ловушкой exit 11 у интейка. +3. `platform/BACKLOG.md` П-12 **и** `PD-175`: продуктовая половина снята целиком (D39.176 п.1), осталась + инженерная гигиена без чьего-либо слова. Записано в ОБА носителя, а не только пингом — пинг уезжает в архив. + +⚠ **Плюс попутное из того же пинга: 13 битых якорей регистра пере-нацелены** (не пять — гейт `counts.py --lint` +на моём дереве называл тринадцать; часть убил мой же переезд, часть жила раньше). Все тринадцать наведены +ПО ТОКЕНУ, а не по номеру строки; двусмысленный (`case overran:` — два кандидата) разрешён вручную в пользу +`settleOne`, о котором строка. `--lint`: **0 проблемных якорей в 101 живом доке.** + +### Вопросы оркестратору + +1. **`PD-435` — чей пак?** Носитель ясен (форма прогона, записанная на первом объявлении ЭТОГО прогона), но + он требует решения о том, что делать со второй попыткой одного прогона, объявившей другую форму. Это + продолжение §3.2, не его хвост. +2. **`PD-434`** (ресинк не чинит полосу): лечить его — значит дать ресинку писать в `chapters` и разобрать, + как он не спорит с потоком, который те же строки пишет из `unit_resolutions`. Тоже отдельно. +3. Канон 0.9.0 у меня в дереве; баннер-компаньон `14-api-contract/README.md` по-прежнему говорит «по 0.8.0 + включительно» — по его же правилу это ТВОЙ акт лендинга, я не трогал. + +## ЗАПИСКА-ПЛАН: пак P12 «долги под ногами» — что беру, чего не беру, чем докажу (сессия `textmachine-main-b5`, 30.08) + +Пишется ДО первой правки кода, как требует §6 промта. Все числа ниже — МОИ, получены командами на +сегодняшнем дереве (HEAD `4bb3404`, чисто), не унаследованы из промта. + +### Синхронизация с деревом (числа — командами) + +| Что | Команда | Замер 30.08 | +|---|---|---| +| Базовая батарея | `make check` при ТРЁХ гейтах | **18 пакетов, EXIT=0, скипов 0**, линтер «0 issues», `sqlc diff` чист | +| Открытых строк регистра | парсер таблицы по колонке статуса | **107** (major 7 · minor 35 · info 65) | +| Мест склейки `lastRun` | `grep -n 'lastRun' internal/pgstore/*.go` без тестов | **10** — промт прав; **плюс ОДИННАДЦАТЫЙ носитель того же порядка**, `LatestRun` (`internal/pgstore/runs.go:985`), где `order by started_at desc, id desc` выписан РУКОЙ, мимо константы | +| Тулчейн | `go version` · `golangci-lint --version` · `sqlc version` | Go **1.26.7** · линтер **2.12.2** · sqlc **v1.31.1** | +| Стенд | `sha256sum ~/.local/bin/tmctl` против свежей сборки | **РАСХОДЯТСЯ** — `PD-432` живая: стендовый `tmctl` от 29.08 13:12, с тех пор в `backend/` 4 коммита | + +⚠ **Расхождение с записью подъёма окружения, называю сразу:** `sqlc` на машине НЕ БЫЛО (`which sqlc` +пусто), а `make check` держит `sqlc-check` пререквизитом — то есть батарея зоны на этом хосте была +недостижима до того, как я поставил пин `v1.31.1` (`go install github.com/sqlc-dev/sqlc/cmd/sqlc@v1.31.1`). +Гейтов у батареи по-прежнему три + условие хоста; четвёртое условие я НЕ вывожу из команды +`/proc/self/cgroup` — см. §3.6 ниже, у меня она даёт **третью** точку против себя. + +### Что беру (в порядке ранжирования промта) + +**Первый эшелон.** + +1. **§3.1 `PD-425` — детач пост-verb записи.** Детачю РОВНО `RecordBankMove` + (`internal/runs/bank.go:182`), не дверь и не verb. Довод против более широкого детача — свой, не + унаследованный: (а) детач на `httpapi/bank.go:149` снёс бы два намеренно построенных свойства — + «мёртвый клиент покидает очередь книги» (`runs/bank.go:96-99`) и бюджет verb'а; (б) детач самого + verb'а не нужен, потому что ранний обрыв клиента даёт SIGTERM → движок проверяет контекст ДО + своих записей → exit 5 → `ErrBankUnavailable`, и НИЧЕГО не потеряно; потеря требует отмены ПОСЛЕ + последней проверки контекста движком. Единственный ctx-связанный ввод-вывод после verb — эта одна + запись. Таймаут — `recordBudget` (10 с, `runs/reconcile.go:370`, тот же пакет): она идёт ПОД + блокировкой книги, и 30-секундный `writeBudget` интейка утроил бы удержание. + Пин: посадка «контекст отменён между verb и записью» через гейт `entered`/`release` фикстуры. +2. **§3.7 снятие обхода `--verify-bank` — весь состав пинга №21, включая (в).** Порядок односторонний: + пере-сборка `tmctl` → `tmctl migrate` по стендовой книге → код. Колонку `bank_released` СНОСИТЬ + НОВОЙ миграцией (00016 не трогаю) и сегмент полосы пере-вывести предикатом внутри латерали — + оставить писателя снятым, а колонку живой значило бы заморозить `false` и врать полосой. + Пере-подписываемые пины перечислю поимённо в отчёте. +3. **§3.3 `PD-402` read-половина.** Одна константа порядка `(finished_at is null) desc, started_at desc, + id desc` вместо `started_at desc` — и та же константа в `LatestRun`, иначе экран пойдёт за живым + прогоном, а `Resume` откажет ему как «не последнему»: PD-402 перевернётся, а не закроется. + Судится СВОИМИ тестами по каждому из 10 мест, не зеленью батареи. + +**Второй эшелон.** + +4. **§3.2 эпоха формы конвейера (`PD-403`/`PD-404`/`PD-411`/`PD-405`).** Форма выбрана — новая пара + колонок `books.shape_epoch` + `books.epoch_editor`; `edit_wave` остаётся и остаётся монотонным + (пин D39.153 §4б не трогаю), с живого флага уезжает только ПОЖИЗНЕННЫЙ счёт. ⚠ Эта форма требует + правки ФРАЗЫ канона (`openapi.yaml:1554-1559` — вторая граница пересчёта рядом с пере-нарезкой), + поэтому **пинг оркестратору отправлен ДО стройки**, как велит промт; пункт не строится до ответа. + Отказ ратификации ⇒ строки уезжают следующим паком ДИСПОЗИЦИЕЙ, а не молчаливой отсрочкой. + `PD-411` (мёртвые четыре колонки) беру ЭТИМ паком: оба её писателя стоят ровно в тех двух + функциях, которые эпоха и правит, и оставить мёртвый носитель рядом с новым живым — способ + заставить следующую сессию взять неверный. +5. **§3.6 `PD-431` + `PD-432`** и **§3.9 гигиена регистра** — дёшевы, тем же деревом. + По `PD-431` поведение НЕ меняю (слово оркестратора): пин — сквозной тест через `auth.Authenticator` + с ЖИВЫМ `*pgstore.Store`. ⚠ Направление импорта решает, где он живёт: `pgstore` импортирует `auth`, + обратно нельзя, значит тест в пакете `pgstore`. +6. **§3.4 `PD-424`+`PD-418`, §3.5 `PD-369`+`PD-420`, §3.8 `PD-367`+`PD-213`** — беру по остатку + ресурса, в этом порядке. Приор зоны по `PD-424` (`deferred` ⇒ неудача владеющей фазы) принимаю; + по `PD-369` выбираю форму `ErrKeyInFlight`, а не границу по времени — довод в отчёте. + +### Чего НЕ беру (и это решение, а не пропуск) + +Дверь выдачи `createExport`/`getExport` · `PD-410` · эскроу П-18 · `PD-412`/`PD-413` · `PD-422` +(гейчена проводкой `--max-units`) · `PD-421` (снят замером оркестратора 30.08, эррата 30.08-а). +`backend/` не правлю — единственная разрешённая операция вне зоны — `tmctl migrate` по стендовой книге. + +### Открытые оси (форма решится исполнением; затык = пинг, не интерпретация) + +- Живой пробой §3.7 упирается в ДАННЫЕ стенда, а не в код: движок падает громко, если `--verify-bank` + передан книге, которая не умеет майнить, а у стендовой книги нет ни `langpack_root`, ни секции + `mining:` в `pipeline-zero.yaml`. Проверю это исполнением ПЕРВЫМ делом; если подтвердится — либо + до-оснащу стендовую книгу, либо назову в отчёте, что полный пробой оплаченного стопа требует + данных стенда, которых нет, и НЕ выдам достижимую половину за полную. +- Строка бэклога 240 (молчащий стоп подписи) закрывается ТОЙ ЖЕ работой: снять гард вместе с обходом + ЛИБО доказать недостижимость памятью `bank_stop_presented` (v16). Что из двух — решу исполнением. +- `PD-405`: между «отказ старта до дерева» и «полоса из progress-событий» склоняюсь к первому + (меньше кода, отказ ДО взятия холда), но второй тронул бы `nothingIsRunning`, общий с `ReadStream` + и лизинг-дисциплиной §34 — цена названа, выбор подпишу замером. + +### Чем докажу (DoD §3, поимённо) + +`make check` целиком при трёх гейтах + условии хоста, скипы вслух · мутационные посадки в КОПИИ дерева +ВМЕСТЕ с каноном (`cp -a --parents platform docs/architecture/14-api-contract`), вердикт по ДЕЛЬТЕ +против чистой базы ТОЙ ЖЕ копии · греп ОТКРЫТЫХ строк регистра по СВОИМ файлам ПОЛНЫМИ путями, +каждое совпадение — с диспозицией · дифф `^func Test` исполнением · адверсариальный проход по своей +готовой работе ПЕРЕД сдачей, дерево на это время заморожено. + ## ЗАПИСКА-ПЛАН: пак `sqlc` (П-19) — что беру, чего не беру, чем докажу (сессия `textmachine-1b`, 29.08) Пишется ДО стройки, как требует §6 промта. Числа ниже — мои, получены на сегодняшнем дереве diff --git a/platform/internal/books/books_test.go b/platform/internal/books/books_test.go index 4d9e92f9..0c694bf9 100644 --- a/platform/internal/books/books_test.go +++ b/platform/internal/books/books_test.go @@ -133,14 +133,36 @@ type fixture struct { now time.Time } +// wholeManifest is a manifest that DESCRIBES ITSELF: every count agrees with what the document +// actually carries, and every chapter and pair has an identity. +// +// ⚠ It exists because the intake's floor became symmetric with the materialiser's (register row +// PD-367), and the fixtures were EXTENDED to satisfy it rather than the floor relaxed to admit them. +// The old fixture declared 500 chapters and carried none — the very document the materialiser has +// always refused and the intake accepted, founding a book with `chapter_count = 500` and an empty +// tree over which a run could be started and PAID FOR. A fixture that models an impossible document +// proves nothing about the possible ones. +func wholeManifest(chapters, unitsPerChapter int) ingest.Manifest { + m := ingest.Manifest{ + Version: "tm-manifest-v2", ChaptersTotal: chapters, UnitsTotal: chapters * unitsPerChapter, + SourceSHA256: strings.Repeat("ab", 32), ChunkerVersion: "chunk-2026.07", + } + for i := 1; i <= chapters; i++ { + c := ingest.ManifestChapter{ID: "c" + strconv.Itoa(i), Number: i, UnitsTotal: unitsPerChapter} + for u := range unitsPerChapter { + c.Units = append(c.Units, ingest.ManifestUnit{ + ID: "c" + strconv.Itoa(i) + ":cut:" + strconv.Itoa(u), FirstChunkIdx: u}) + } + m.Chapters = append(m.Chapters, c) + } + return m +} + func newFixture(t *testing.T) *fixture { t.Helper() store, ctx := intakeDB(t) root := t.TempDir() - eng := &fakeEngine{manifest: ingest.Manifest{ - Version: "tm-manifest-v2", ChaptersTotal: 500, UnitsTotal: 950, - SourceSHA256: strings.Repeat("ab", 32), ChunkerVersion: "chunk-2026.07", - }} + eng := &fakeEngine{manifest: wholeManifest(500, 2)} now := time.Now().UTC().Truncate(time.Millisecond) f := &fixture{store: store, engine: eng, ctx: ctx, root: root, now: now} f.svc = &Service{ @@ -1285,13 +1307,119 @@ func TestAPassTooShortForAParseStartsNoneAtAll(t *testing.T) { } } +// THE PD-367 DOCUMENT: counts that describe a book, and no book. `{chapters_total: 120, +// units_total: 400}` with an empty chapter list is exactly what the MATERIALISER has always refused +// — and the intake accepted it, founded a book with `chapter_count = 120` over an empty tree, and let +// a run be STARTED AND PAID FOR against a count nothing could ever fill. +// +// The floor is symmetric now, and symmetric is the whole property: one document gets ONE answer from +// both ends of the intake. Its class is the seam's — unknown or inconsistent is non-destructive and +// loud — so the upload survives and the book is never founded. +// +// ⚠ This is a COMMISSIONED change to a pinned intake contract (P12 §3.8), not a test bent to a green: +// the intake's own battery rode documents with no chapter list, and those fixtures were EXTENDED +// (`wholeManifest`) rather than the floor relaxed to admit them. +// +// Mutation caught: moving the floor below the `ChaptersTotal < 1` branches, or dropping Whole() from +// Readable. +func TestTheIntakeRefusesTheDocumentItsOwnMaterialiserWouldReject(t *testing.T) { + f := newFixture(t) + book := f.accept(t, "book.txt", "первая глава") + dir := f.provision(t, book) + // Counts that describe 120 chapters and 400 pairs; a document carrying neither. + f.engine.set(ingest.Manifest{Version: "tm-manifest-v2", ChaptersTotal: 120, UnitsTotal: 400, + SourceSHA256: strings.Repeat("ab", 32), ChunkerVersion: "chunk-2026.07"}, nil) + if err := f.svc.Parse(f.ctx, book.ID); err != nil { + t.Fatal(err) + } + got := f.card(t, book.ID) + if got.Status == "not_started" { + t.Fatalf("the intake founded a book on a document its own materialiser refuses: status %q,"+ + " chapter_count %d — a run over it can be started and PAID FOR against a count nothing fills", + got.Status, got.ChapterCount) + } + if got.ChapterCount != 0 { + t.Errorf("chapter_count was written as %d from a document that carries no chapters", got.ChapterCount) + } + // NON-DESTRUCTIVE, through the whole budget: a document this build cannot read says nothing about + // the user's text. + for range parseAttempts + 1 { + f.now = f.now.Add(claimGrace + time.Minute) + if err := f.svc.Parse(f.ctx, book.ID); err != nil { + t.Fatal(err) + } + } + if got := f.card(t, book.ID); got.Status != "rejected" { + t.Errorf("the book is %q after its whole budget; the budget still bounds a broken deployment", got.Status) + } + if _, err := os.Stat(filepath.Join(dir, SourceName+".txt")); err != nil { + t.Fatalf("a document this build could not read cost the user their upload: %v", err) + } +} + +// PD-213, the LATENT MINE: an engine whose manifest shape moved must never look like a book with +// nothing in it. +// +// The mechanism, and it is one field rename away: `DecodeManifest` does not gate the version, +// `json.Unmarshal` ignores unknown fields and leaves missing ones zero — so a v3 manifest that +// renamed `chapters_total` decodes into a valid Manifest reading zero chapters and zero units. Zero +// and zero is exactly what the intake reads as «the engine read the source and there is no book in +// it», which is the ONE verdict that DELETES the user's file after five attempts. The engine would +// have parsed the book perfectly. +// +// The version gate makes that unreachable by turning the same document into `parser_unavailable`, +// which keeps the file. It is a SHAPE gate, not a value pin — the counts and identities below it are +// stable, and pinning values everywhere would make every engine release a platform release. +// +// Mutation caught: dropping the version check from Readable, which restores the deletion path. +func TestAManifestShapeThisBuildDoesNotKnowNeverDeletesTheUpload(t *testing.T) { + f := newFixture(t) + book := f.accept(t, "book.txt", "первая глава") + dir := f.provision(t, book) + // The engine of tomorrow: a version this build has never heard of, and — because the keys moved + // under the allowlist — every count decoding to zero. Indistinguishable from an empty book to a + // reader that does not ask which shape it is holding. + f.engine.set(ingest.Manifest{Version: "tm-manifest-v3"}, nil) + for range parseAttempts + 1 { + f.now = f.now.Add(claimGrace + time.Minute) + if err := f.svc.Parse(f.ctx, book.ID); err != nil { + t.Fatal(err) + } + } + if got := f.card(t, book.ID); got.Status != "rejected" { + t.Errorf("the book is %q; the budget still bounds a deployment this build cannot read", got.Status) + } + if _, err := os.Stat(filepath.Join(dir, SourceName+".txt")); err != nil { + t.Fatalf("an engine upgrade deleted the user's upload: %v", err) + } + // And the KNOWN shape, whose counts genuinely agree on nothing, still ends the way it must: that + // verdict is a statement about the user's text, and it is only reachable past the gate. + empty := f.accept(t, "empty.txt", " ") + emptyDir := f.provision(t, empty) + f.engine.set(ingest.Manifest{Version: ingest.KnownManifestVersion}, nil) + for range parseAttempts + 1 { + f.now = f.now.Add(claimGrace + time.Minute) + if err := f.svc.Parse(f.ctx, empty.ID); err != nil { + t.Fatal(err) + } + } + if _, err := os.Stat(emptyDir); !errors.Is(err, os.ErrNotExist) { + t.Errorf("a source with no book in it kept its directory: %v", err) + } +} + // A manifest that counts no chapters and still counts UNITS is not an empty book — it is a document // this build is reading wrong, most likely because a field moved under it. The difference decides // whether the user's upload is deleted, so the two must not share a verdict. // -// The engine's manifest is not version-gated on purpose (the value would make every engine release a -// platform release), which leaves exactly this gap: the destructive verdict was guarded by a JSON -// field name. Found by the seam lens of the dofix review. +// ⚠ Its FIRST case now dies one line earlier than it used to, and the test is kept rather than +// retired because the property it states is unchanged: `tm-manifest-v9` is refused by the version +// gate `Readable` added (PD-213) before the count branches are reached at all. The count branch it +// was written against is still there and still reachable — by a KNOWN shape whose counts contradict +// each other — which is what the second half of this test exercises. The sentence that used to close +// this comment ("the engine's manifest is not version-gated on purpose, which leaves exactly this +// gap") describes the state before P12: the gate exists now, and it is a SHAPE gate rather than the +// value pin that sentence was arguing against. // // Mutation caught: reading `ChaptersTotal < 1` alone as the source being unreadable. func TestAManifestThatContradictsItselfNeverCostsTheUpload(t *testing.T) { diff --git a/platform/internal/books/parse.go b/platform/internal/books/parse.go index 3f9edb5b..4ec3eace 100644 --- a/platform/internal/books/parse.go +++ b/platform/internal/books/parse.go @@ -126,19 +126,46 @@ func (s *Service) Parse(ctx context.Context, bookID string) error { } m, err := s.manifest(ctx, claim) if err == nil { - if m.ChaptersTotal < 1 && emptyBook(m) { - // The engine SUCCEEDED and reported a book with no chapters in it. Same verdict as exit 11 - // and the same budget: a manifest is not a place a deployment fault can hide. - return s.defer_(ctx, claim, ReasonSourceUnreadable) + // THE FLOOR, and it is FIRST for a reason that is the whole of it: below this line a document + // that could not be read correctly is indistinguishable from a book with nothing in it, and + // that reading DELETES the user's upload after the attempt budget. + // + // It asks two things at once because both have the same answer (ingest.Readable): is this the + // manifest shape this build reads, and does the document describe its own contents. The first + // is the mine register row PD-213 names — a renamed key decodes to zeroes and the zeroes read + // as an empty book. The second is PD-367: the MATERIALISER has always refused a document whose + // counts and contents disagree, and the intake accepted the same document, founded a book on + // its counts and let a run be started and PAID FOR over an empty tree. One document must not + // get two answers from two ends of the same intake. + // + // `parser_unavailable` and never `source_unreadable`: a count read from the wrong key is not + // evidence about the user's text, and the class that keeps the file is the only honest one for + // «this build cannot read what the engine sent». It still spends the attempt budget, so a + // deployment that is genuinely broken stops rather than retries forever. + if rerr := m.Readable(); rerr != nil { + s.log().ErrorContext(ctx, "the manifest does not describe itself, so it is not being read correctly", + "chapters", m.ChaptersTotal, "units", m.UnitsTotal, "manifest_version", m.Version, + "reason", ReasonParserUnavailable, "err", rerr) + return s.defer_(ctx, claim, ReasonParserUnavailable) } if m.ChaptersTotal < 1 { - // No chapters, but the same document counts units or chunks — which no book has and no - // engine produces. It is a manifest this build cannot read (a field that moved, most - // likely), and the difference matters because the branch above is the one that DELETES the - // upload: a count read from the wrong key is not evidence that the source is empty. - s.log().ErrorContext(ctx, "the manifest counts no chapters and still counts work: it is not being read correctly", - "units", m.UnitsTotal, "manifest_version", m.Version, "reason", ReasonParserUnavailable) - return s.defer_(ctx, claim, ReasonParserUnavailable) + // The engine SUCCEEDED and reported a book with no chapters in it. Same verdict as exit 11 + // and the same budget: a manifest is not a place a deployment fault can hide. + // + // Reachable only for a document that PASSED the floor above — the version this build reads, + // and every count agreeing with its own contents — which is what makes «there is no book in + // these bytes» a statement about the user's text rather than about our reader. + // + // ⚠ The second conjunct this branch used to carry (`&& emptyBook(m)`, i.e. `UnitsTotal < 1`) + // is GONE rather than kept for safety, because past the floor it can no longer be false and + // a condition nothing can falsify is a condition no test can defend. Whole() forces + // `ChaptersTotal == len(Chapters)` and `UnitsTotal == sum(len(c.Units))`, so zero chapters + // implies zero units by arithmetic. What the conjunct used to guard — a document counting + // units while counting no chapters, the shape of a key that moved — is refused ABOVE now, + // non-destructively, which is the same verdict it used to produce and one branch earlier. + // Removed on the finding of this pack's own adversarial pass, which showed it surviving + // deletion against the whole battery. + return s.defer_(ctx, claim, ReasonSourceUnreadable) } // Counts and versions, no book id: the same rule as everywhere else on this side of the log // (ENGINEERING_STANDARDS §Наблюдаемость). What an operator needs per book — including why a @@ -312,13 +339,6 @@ func (s *Service) manifest(ctx context.Context, claim pgstore.ParseClaim) (inges return s.Engine.Manifest(ctx, s.Cfg.EngineBinary, workdir) } -// emptyBook reports a manifest that agrees with itself about there being nothing to translate. -// -// The counts are read TOGETHER because only one of them is destructive on its own: a document whose -// chapter count is zero while its unit count is not is not an empty book, it is a document being -// read wrong, and the two must not share a verdict (see Parse). -func emptyBook(m ingest.Manifest) bool { return m.UnitsTotal < 1 } - // intakeReason turns what the engine ANSWERED into this platform's own word for it. // // ⚠ This is the consumer half of PD-196, and the whole defect lived in the sentence this function diff --git a/platform/internal/books/render_test.go b/platform/internal/books/render_test.go index 61e770ef..07d75017 100644 --- a/platform/internal/books/render_test.go +++ b/platform/internal/books/render_test.go @@ -250,8 +250,7 @@ func TestARenderedConfigurationIsNeverRewritten(t *testing.T) { if err := os.Remove(tpl); err != nil { t.Fatal(err) } - f.engine.set(ingest.Manifest{Version: "tm-manifest-v2", ChaptersTotal: 3, - SourceSHA256: strings.Repeat("ab", 32), ChunkerVersion: "chunk-2026.07"}, nil) + f.engine.set(wholeManifest(3, 1), nil) // Another walk of the same book — a re-parse after the claim went stale. f.now = f.now.Add(claimGrace + time.Minute) if err := f.svc.Parse(f.ctx, book.ID); err != nil { @@ -314,8 +313,7 @@ func TestABrokenTemplateStopsNoIntakeAndSpendsNoBudget(t *testing.T) { if err := os.WriteFile(tpl, []byte(templateYAML), 0o600); err != nil { t.Fatal(err) } - f.engine.set(ingest.Manifest{Version: "tm-manifest-v2", ChaptersTotal: 3, - SourceSHA256: strings.Repeat("ab", 32), ChunkerVersion: "chunk-2026.07"}, nil) + f.engine.set(wholeManifest(3, 1), nil) f.now = f.now.Add(claimGrace + time.Minute) if err := f.svc.Parse(f.ctx, book.ID); err != nil { t.Fatal(err) diff --git a/platform/internal/httpapi/capabilities.go b/platform/internal/httpapi/capabilities.go index b7490493..e375a91f 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.8.0" +const ContractVersion = "0.9.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/idempotency.go b/platform/internal/httpapi/idempotency.go index cac31bf9..f6938213 100644 --- a/platform/internal/httpapi/idempotency.go +++ b/platform/internal/httpapi/idempotency.go @@ -94,9 +94,20 @@ func (h *v0) beginIdempotent(w http.ResponseWriter, r *http.Request, user string case errors.Is(err, pgstore.ErrKeyReused): FailCause(w, r, CodeIdempotencyConflict, CauseKeyReused) return nil, false + case errors.Is(err, pgstore.ErrKeyContended): + // The claim loop gave up: this key was taken and handed back under us as fast as the racers + // could fail. The ANSWER is the same as an ordinary in-flight repeat, because the caller's + // remedy is the same one — wait and present it again — and it must not be a 500: nothing was + // wrong with the request and there is nothing its client could fix (PD-369). Ordered BEFORE + // the case below because it wraps it. + // + // Loud on OUR side and quiet on the wire, which is the whole mitigation for "the fix hides a + // storm": repeated contention on one key is an operator's signal, never a caller's fault. + h.log.WarnContext(r.Context(), "an idempotency key is under repeated contention", "err", err) + h.keyInFlight(w, r) + return nil, false case errors.Is(err, pgstore.ErrKeyInFlight): - WriteProblem(w, r, Problem{Code: CodeIdempotencyConflict, - Cause: &Cause{Code: CauseKeyInFlight}, RetryAfter: keyInFlightRetry}) + h.keyInFlight(w, r) return nil, false case err != nil: h.log.ErrorContext(r.Context(), "the idempotency key could not be claimed", "err", err) @@ -129,6 +140,14 @@ func (h *v0) replay(w http.ResponseWriter, r *http.Request, stored *pgstore.Idem } } +// keyInFlight is the contract's answer to "this key is busy, come back": ONE writer, because the two +// paths that reach it — a first attempt still running, and a claim that lost the race too often — +// must not drift into two different words for one remedy. +func (h *v0) keyInFlight(w http.ResponseWriter, r *http.Request) { + WriteProblem(w, r, Problem{Code: CodeIdempotencyConflict, + Cause: &Cause{Code: CauseKeyInFlight}, RetryAfter: keyInFlightRetry}) +} + // replayIfIdentical answers a repeat whose identity is its BODY: the stored answer when the bytes // digest to what the first attempt accepted, and `key_reused` when they do not — which is what the // canon calls a different request under the same key. diff --git a/platform/internal/httpapi/idempotency_test.go b/platform/internal/httpapi/idempotency_test.go index cb6f9a55..6f780031 100644 --- a/platform/internal/httpapi/idempotency_test.go +++ b/platform/internal/httpapi/idempotency_test.go @@ -368,6 +368,25 @@ func TestTheClaimsFourAnswersReachTheClient(t *testing.T) { t.Error("no Retry-After on the answer that asks the client to wait") } }) + t.Run("a claim that lost the race too often is told to wait, not answered 500", func(t *testing.T) { + // PD-369. Contention on one key is a legitimate request meeting other legitimate requests, and + // the answer must be one of the contract's own — 409 `key_in_flight` with a Retry-After — not + // an internal error the caller can do nothing with. The two paths share ONE writer + // (`keyInFlight`), and the case order matters: ErrKeyContended WRAPS ErrKeyInFlight, so the + // narrower case has to come first or this test reads the same as the one above it. + keys := newFakeKeys() + keys.err = pgstore.ErrKeyContended + w := idemPost(t, idemServer(t, keys, &fakeIntake{}), "one-per-gesture", "AAAABBBBCCCC", nil) + if w.Code != http.StatusConflict || !contains(w.Body.String(), `"key_in_flight"`) { + t.Errorf("a contended key answered %d %s, want 409 key_in_flight", w.Code, w.Body.String()) + } + if w.Header().Get("Retry-After") == "" { + t.Error("no Retry-After on the answer that asks the client to wait") + } + if contains(w.Body.String(), `"internal_error"`) { + t.Error("a legitimate request under contention was answered with an internal error") + } + }) t.Run("an attempt that did not complete gives the key back", func(t *testing.T) { keys := newFakeKeys() h := idemServer(t, keys, &fakeIntake{err: errors.New("the storage is gone")}) diff --git a/platform/internal/httpapi/project.go b/platform/internal/httpapi/project.go index 0b152451..d0dc8436 100644 --- a/platform/internal/httpapi/project.go +++ b/platform/internal/httpapi/project.go @@ -18,6 +18,7 @@ func projectBook(b pgstore.Book) wireBook { SourceLang: b.SourceLang, TargetLang: b.TargetLang, Status: b.Status, StructureVersion: b.StructureVersion, + ShapeEpoch: b.ShapeEpoch, ChapterCount: b.ChapterCount, ChaptersDone: b.ChaptersDone, AddedAt: b.AddedAt, diff --git a/platform/internal/httpapi/v0.go b/platform/internal/httpapi/v0.go index e97de6f5..45d22201 100644 --- a/platform/internal/httpapi/v0.go +++ b/platform/internal/httpapi/v0.go @@ -165,6 +165,7 @@ type wireBook struct { Status string `json:"status"` RejectReason *string `json:"reject_reason"` StructureVersion int `json:"structure_version"` + ShapeEpoch int `json:"shape_epoch"` ChapterCount int `json:"chapter_count"` ChaptersDone int `json:"chapters_done"` CharacterCount *int64 `json:"character_count"` diff --git a/platform/internal/ingest/manifest.go b/platform/internal/ingest/manifest.go index fa6e83d4..99b0ae7e 100644 --- a/platform/internal/ingest/manifest.go +++ b/platform/internal/ingest/manifest.go @@ -78,6 +78,44 @@ type ManifestUnit struct { FirstChunkIdx int `json:"first_chunk_idx"` } +// KnownManifestVersion is the shape of the engine's manifest this build was written against. It +// MIRRORS `manifestVersion` in backend/internal/pipeline/manifest.go and moves when that moves. +// +// It is a shape gate and not a value pin, which is the distinction the Version field's own comment +// draws: the counts and identities below are stable, so pinning the value everywhere would make +// every engine release a platform release. What it guards is the ONE place where reading a manifest +// wrong is destructive — see Readable. +const KnownManifestVersion = "tm-manifest-v2" + +// Readable is the floor the INTAKE stands on: is this a manifest this build can read at all. +// +// Two questions, and they are one floor because the answer to both is the same non-destructive class. +// +// The version, which is register row PD-213 and a latent mine: `DecodeManifest` does not gate the +// value, `json.Unmarshal` silently ignores unknown fields and leaves missing ones zero — so a shape +// change on the engine's side (`chapters_total` renamed, say) decodes into a perfectly valid Manifest +// with ChaptersTotal 0 and UnitsTotal 0. Zero chapters and zero units is exactly what the intake +// reads as "the engine read the source and there is no book in it", which is the ONE verdict that +// DELETES the user's file after the attempt budget. The engine would have parsed the book perfectly. +// +// The self-consistency, which is register row PD-367: `Whole()` already guarded the materialiser, and +// the intake accepted the very documents the materialiser would refuse — `{chapters_total: 120, +// units_total: 400}` with an empty chapter list founded a book with `chapter_count = 120` and an +// empty tree, over which a run could be STARTED AND PAID FOR. The floor is symmetric now: one +// document, one answer, on both sides of the intake. +// +// The class is the law of the seam's: unknown or inconsistent is answered NON-DESTRUCTIVELY and +// LOUDLY. The caller maps this to `parser_unavailable`, which spends the attempt budget and keeps the +// file — never `source_unreadable`, which is a statement about the USER's text and is the one that +// deletes it. +func (m Manifest) Readable() error { + if m.Version != KnownManifestVersion { + return fmt.Errorf("ingest: the manifest identifies as %q and this build reads %s", + m.Version, KnownManifestVersion) + } + return m.Whole() +} + // Whole reports whether the document DESCRIBES ITSELF: every list is as long as the count printed // beside it. // diff --git a/platform/internal/pgstore/books.go b/platform/internal/pgstore/books.go index e4562bbf..a2fc83c6 100644 --- a/platform/internal/pgstore/books.go +++ b/platform/internal/pgstore/books.go @@ -39,7 +39,12 @@ type Book struct { // projection of it (httpapi.contractRejectReason). RejectReason string StructureVersion int - ChapterCount int + // ShapeEpoch is the generation of the book's COUNT: it moves when the pipeline's shape changes + // under the book (an editor added or removed), which is the one other event besides a re-cut that + // legitimately recomputes `ChaptersDone`. A coordinate, like StructureVersion — the shape itself + // is deliberately not published (canon 0.9.0). + ShapeEpoch int + ChapterCount int // ChaptersDone is the book's own lifetime progress — chapters fully translated — as opposed to // the bar of a run, which measures what that run bought. ChaptersDone int @@ -991,6 +996,17 @@ type BookRunContext struct { // 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 + // HasTree is whether the book's chapter tree has been MATERIALISED — the `chapters` rows the whole + // read model counts over — as opposed to merely declared by `chapter_count`. + // + // The two are separate facts with separate writers, and the window between them is ordinary: the + // intake commits `not_started` in one transaction and materialises the tree afterwards, outside + // it. On the healthy path the window is milliseconds; when the materialisation FAILED or was + // deferred it is unbounded, and a run admitted inside it is a run whose bar can never move — every + // counter the screen shows is a count over `chapters`, and there are no rows to count (PD-405). + // Worse, the tree's debt is frozen for the run's whole life, because the sweep that would pay it + // skips a book with a live run. So the run is refused at admission instead, before its hold. + HasTree bool } // ReadBookForRun gathers the facts a run start is judged on. @@ -1004,11 +1020,12 @@ func (s *Store) ReadBookForRun(ctx context.Context, userID, bookID string) (Book 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'), - b.bank_moved_at is not null + b.bank_moved_at is not null, + exists (select 1 from chapters c where c.book_id = b.id) 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.ChapterCount, &out.HasLiveRun, &out.LiveRunAwaitingBank, &out.BankMoved) + &out.ChapterCount, &out.HasLiveRun, &out.LiveRunAwaitingBank, &out.BankMoved, &out.HasTree) if errors.Is(err, pgx.ErrNoRows) { return BookRunContext{}, ErrNoBook } diff --git a/platform/internal/pgstore/idempotency.go b/platform/internal/pgstore/idempotency.go index d8b17fd7..599a2828 100644 --- a/platform/internal/pgstore/idempotency.go +++ b/platform/internal/pgstore/idempotency.go @@ -77,16 +77,43 @@ func NewClaimToken() ClaimToken { return ClaimToken(newID("clm")) } // to anybody: the key is simply free again, and the claim is taken from the top. var errKeyVanished = errors.New("pgstore: the idempotency key was released mid-claim") -// claimRounds bounds that retry. Two attempts of one key can hand it back and forth at most as fast -// as they can fail, and a caller that loses three rounds is meeting something other than a race. +// claimRounds bounds that retry: an unbounded spin over a key that keeps being handed back would +// hold a pool connection for as long as the contention lasts. +// +// ⚠ Its predecessor's justification — "a caller that loses three rounds is meeting something other +// than a race" — was FALSE and it is corrected rather than inherited: eight racers on one key, each +// giving it straight back as any 4xx does, take the race from the same loser three times running +// often enough to be measured (1–2 failures per ~80 runs of this package's own race test, which is +// why that test was itself flaky — PD-369). Losing the bound IS a race. What changed is not the +// number but the ANSWER: exhausting it is now a contractual reply, so the bound decides how long a +// caller spins, never whether it gets a 500. const claimRounds = 3 +// ErrKeyContended is the retry loop giving up: the key was taken and handed back under this attempt +// `claimRounds` times running. +// +// It WRAPS ErrKeyInFlight, and that is the whole design rather than a convenience. The caller's +// remedy is identical — wait Retry-After and present the key again — and contention on a key is +// precisely what the contract's `key_in_flight` describes, so no new vocabulary is minted and no +// version moves. It keeps an identity of its own so the HTTP layer can say so in a log: repeated +// contention on one key is worth an operator seeing, an ordinary in-flight repeat is not. +// +// ⚠ What it replaces is a bare error that mapped to 500. A legitimate request — the right key, the +// right body, arriving into a burst of its own retries — was answered with an internal error, which +// is neither true nor actionable: nothing was wrong with it, and there was nothing for its client to +// fix. That is the whole of PD-369 (=П-21). +var ErrKeyContended = fmt.Errorf( + "pgstore: the idempotency key was claimed and released under us %d times: %w", claimRounds, ErrKeyInFlight) + // ClaimIdempotency takes the right to perform a request once. // // It answers one of four things, and the four are the whole semantics: a token to go ahead with, a // stored response to replay, ErrKeyInFlight while the first attempt is still running, ErrKeyReused // when the key was used for a different request. The token is what the caller must present to // complete or release: it is the proof that the claim is still this attempt's. +// +// Losing the retry bound folds into the THIRD of those four — ErrKeyContended wraps ErrKeyInFlight — +// so there is no fifth answer and no path out of here that a client cannot act on. func (s *Store) ClaimIdempotency(ctx context.Context, k IdempotencyKey, now time.Time) (*IdempotentResponse, ClaimToken, error) { // ⚠ Retried, because losing a race is not one of the four answers. Two attempts arrive together; // one wins the insert and then FAILS FAST — a 4xx gives the key straight back — and the loser's @@ -100,7 +127,7 @@ func (s *Store) ClaimIdempotency(ctx context.Context, k IdempotencyKey, now time } return out, token, err } - return nil, "", fmt.Errorf("pgstore: the idempotency key was claimed and released under us %d times", claimRounds) + return nil, "", ErrKeyContended } func (s *Store) claim(ctx context.Context, k IdempotencyKey, now time.Time) (*IdempotentResponse, ClaimToken, error) { diff --git a/platform/internal/pgstore/idempotency_test.go b/platform/internal/pgstore/idempotency_test.go index ab2a89a1..d8dd7eb1 100644 --- a/platform/internal/pgstore/idempotency_test.go +++ b/platform/internal/pgstore/idempotency_test.go @@ -256,6 +256,21 @@ func TestTheSweepForgetsOnlyWhatIsPastTheWindow(t *testing.T) { } } +// Giving up on a contended key is a CONTRACTUAL answer, and this one line is what the HTTP layer's +// whole mapping rests on: `ErrKeyContended` wraps `ErrKeyInFlight`, so a caller that only knows the +// four documented answers still recognises it and still has a remedy (PD-369). +// +// Cheap, no database, no flake — and it is the property, not a restatement of it: unwrap the sentinel +// and the door answers 500 on a legitimate request again. +// +// Mutation caught: declaring ErrKeyContended with errors.New instead of wrapping. +func TestGivingUpOnAContendedKeyIsOneOfTheContractsOwnAnswers(t *testing.T) { + if !errors.Is(ErrKeyContended, ErrKeyInFlight) { + t.Fatal("ErrKeyContended does not answer as ErrKeyInFlight: a claim that lost the race too" + + " often reaches the wire as an internal error, on a request that was never wrong") + } +} + // A claim can lose a RACE rather than a conflict, and losing a race is not one of the four answers. // Two attempts arrive together; one wins the insert and then fails fast — a 4xx gives the key back // immediately — and the loser's re-read, on a fresh READ COMMITTED snapshot, finds nothing at all. diff --git a/platform/internal/pgstore/lastrun_live_priority_test.go b/platform/internal/pgstore/lastrun_live_priority_test.go new file mode 100644 index 00000000..059ac533 --- /dev/null +++ b/platform/internal/pgstore/lastrun_live_priority_test.go @@ -0,0 +1,228 @@ +package pgstore + +import ( + "testing" + "time" + + "textmachine/platform/internal/money" +) + +// resumedBehindAFinishedRun builds the shape PD-402 is about, and it is a shape the product reaches +// on an ordinary afternoon: a run stops, ANOTHER run of the same book legitimately runs and finishes +// while it is stopped, and then the user presses «continue» on the first one. +// +// The trap is that `RestartRun` does NOT re-stamp `started_at` — deliberately, because the run's +// baselines and its bar are the ones it began with — so the live run is now OLDER than the finished +// one. Every book-scoped read resolves the book to one run through the `lastRun` join, and ordered by +// `started_at` alone that join hands them the FINISHED one. +// +// Returns the book and the id of the LIVE (older, resumed) run. +func resumedBehindAFinishedRun(t *testing.T, s *Store, at time.Time) (book, live string) { + t.Helper() + ctx := t.Context() + book = readingBook(t, s, ctx, "u1") + if err := s.SaveStructure(ctx, book, twoChapters("k1")); err != nil { + t.Fatal(err) + } + first, err := s.StartRun(ctx, StartRunInput{UserID: "u1", BookID: book, CeilingChapters: 1, + Ceiling: money.MicroUSD(100_000), Now: at}, 0, nil) + if err != nil { + t.Fatal(err) + } + if _, err := s.FinishRun(ctx, RunEnding{RunID: first.ID, AttemptID: first.AttemptID, + Status: "stopped", Now: at.Add(time.Minute)}); err != nil { + t.Fatal(err) + } + // The OTHER run: started later, and finished. This is the row that must not win. + second, err := s.StartRun(ctx, StartRunInput{UserID: "u1", BookID: book, CeilingChapters: 1, + Ceiling: money.MicroUSD(100_000), Now: at.Add(2 * time.Minute)}, 0, nil) + if err != nil { + t.Fatal(err) + } + if _, err := s.FinishRun(ctx, RunEnding{RunID: second.ID, AttemptID: second.AttemptID, + Status: "ready", Now: at.Add(3 * time.Minute)}); err != nil { + t.Fatal(err) + } + // …and the user resumes the FIRST one. Its `started_at` stays where it was. + if _, err := s.RestartRun(ctx, RestartInput{RunID: first.ID, AttemptID: first.AttemptID, + UserID: "u1", BookID: book, Ceiling: money.MicroUSD(100_000), + Now: at.Add(4 * time.Minute)}); err != nil { + t.Fatal(err) + } + var started time.Time + if err := s.pool.QueryRow(ctx, `select started_at from runs where id = $1`, first.ID). + Scan(&started); err != nil { + t.Fatal(err) + } + if !started.Equal(at) { + t.Fatalf("the resumed run's started_at moved to %s: this fixture cannot observe PD-402, because"+ + " the live run is no longer the older one", started) + } + return book, first.ID +} + +// A LIVE run outranks a finished one on EVERY book-scoped surface, and the money is why: while the +// screen quotes somebody else's finished run, the live one is spending. +// +// This is PD-402's read half, judged per SPLICE SITE rather than by the colour of the battery — the +// ordering is a fragment concatenated into ten queries, and a fragment is exactly the kind of thing +// that can be right in the one place a test happens to look. +// +// Mutation caught: dropping the `(finished_at is null) desc` term from `newestRun`. +func TestALiveRunOutranksAFinishedOneOnEveryBookScopedRead(t *testing.T) { + s, ctx := testDB(t) + at := time.Now().UTC().Truncate(time.Microsecond).Add(-time.Hour) + book, live := resumedBehindAFinishedRun(t, s, at) + + // SPLICE books.go:925 + :949 — the card and its Run object. + card, run, err := s.GetBook(ctx, "u1", book) + if err != nil { + t.Fatal(err) + } + if run == nil || run.ID != live { + t.Errorf("the card quotes run %+v, want the LIVE %s: the user is shown a finished run's state"+ + " while their own is spending", run, live) + } + if card.Status != "translating" { + t.Errorf("the card reads %q while a run of this book is live, want translating", card.Status) + } + + // SPLICE books.go:860 — the library row. + lib, err := s.ListBooks(ctx, "u1", 10, "") + if err != nil { + t.Fatal(err) + } + if len(lib.Books) != 1 { + t.Fatalf("%d books in the library, want the one", len(lib.Books)) + } + if lib.Books[0].Status != "translating" { + t.Errorf("the library row reads %q, want translating", lib.Books[0].Status) + } + + // SPLICE runs.go:985 — the WRITE gate, spliced from the same constant. If it disagreed with the + // reads above, PD-402 would be inverted rather than closed: the screen would follow the live run + // and the resume would refuse it as «not the latest». + latest, isLive, err := s.LatestRun(ctx, book) + if err != nil { + t.Fatal(err) + } + if latest != live || !isLive { + t.Errorf("the resume gate resolves the book to (%s, live=%v), want (%s, true)", latest, isLive, live) + } +} + +// The ORDINARY case is untouched: with a finished run and a newer LIVE one, the newer still wins, and +// with two finished runs the newest-started still wins. A fix that made the live term decide +// everything would have re-ordered the history of every book that has one. +// +// Mutation caught: dropping the `started_at desc` term from `newestRun`. +func TestWithNoLiveRunTheBookStillResolvesToTheNewestStarted(t *testing.T) { + s, ctx := testDB(t) + at := time.Now().UTC().Truncate(time.Microsecond).Add(-time.Hour) + book := readingBook(t, s, ctx, "u1") + if err := s.SaveStructure(ctx, book, twoChapters("k1")); err != nil { + t.Fatal(err) + } + older, err := s.StartRun(ctx, StartRunInput{UserID: "u1", BookID: book, CeilingChapters: 1, + Ceiling: money.MicroUSD(100_000), Now: at}, 0, nil) + if err != nil { + t.Fatal(err) + } + if _, err := s.FinishRun(ctx, RunEnding{RunID: older.ID, AttemptID: older.AttemptID, + Status: "ready", Now: at.Add(time.Minute)}); err != nil { + t.Fatal(err) + } + newer, err := s.StartRun(ctx, StartRunInput{UserID: "u1", BookID: book, CeilingChapters: 1, + Ceiling: money.MicroUSD(100_000), Now: at.Add(2 * time.Minute)}, 0, nil) + if err != nil { + t.Fatal(err) + } + if _, err := s.FinishRun(ctx, RunEnding{RunID: newer.ID, AttemptID: newer.AttemptID, + Status: "stopped", Now: at.Add(3 * time.Minute)}); err != nil { + t.Fatal(err) + } + _, run, err := s.GetBook(ctx, "u1", book) + if err != nil { + t.Fatal(err) + } + if run == nil || run.ID != newer.ID { + t.Errorf("with both runs finished the card quotes %+v, want the newest-started %s", run, newer.ID) + } + latest, isLive, err := s.LatestRun(ctx, book) + if err != nil { + t.Fatal(err) + } + if latest != newer.ID || isLive { + t.Errorf("the resume gate resolves to (%s, live=%v), want (%s, false)", latest, isLive, newer.ID) + } +} + +// The FRAMES a watching client is pushed follow the live run too, which is the half of PD-402 that +// costs money rather than trust: the bar and the caption are what a user watches while a run spends, +// and both are read through the same join as the card. +// +// Mutation caught: dropping the `(finished_at is null) desc` term — the status frame then says +// `ready` and the progress frame quotes the finished run's ceiling, on a book whose live run is going. +func TestTheStreamsFramesFollowTheLiveRunAndNotTheFinishedOne(t *testing.T) { + s, ctx := testDB(t) + at := time.Now().UTC().Truncate(time.Microsecond).Add(-time.Hour) + book, live := resumedBehindAFinishedRun(t, s, at) + + // SPLICE readmodel.go:507 — the status frame. Written by every materialisation; the resume above + // emitted one, so the newest frame of this book is the answer. + var status string + if err := s.pool.QueryRow(ctx, ` + select data->>'status' from book_events + where book_id = $1 and event = $2 order by position desc limit 1`, + book, FrameStatus).Scan(&status); err != nil { + t.Fatal(err) + } + if status != "translating" { + t.Errorf("the status frame says %q while a run of this book is live, want translating: the"+ + " screen is quoting a finished run's ending", status) + } + + // SPLICE bookScope, which decides WHICH WAVE's column a chapter read reports. Resolved to the + // wrong run it reports the wrong pass's text state for the whole page. + // + // ⚠ The assertion is on `wave`, and the first edition of this test asserted `sc.bookID` instead — + // which `bookScope` copies from its own argument before it queries anything, so it was true for + // every possible implementation and every ordering constant. Caught by this pack's adversarial + // pass, which showed the mutant ordering leaving that line green while the rest of the test went + // red. `wave` is the field the resolved run actually decides. + scopeSigning := func() bool { + t.Helper() + tx, err := s.pool.Begin(ctx) + if err != nil { + t.Fatal(err) + } + defer func() { _ = tx.Rollback(ctx) }() + sc, err := bookScope(ctx, tx, "u1", book) + if err != nil { + t.Fatal(err) + } + return sc.signing + } + // The LIVE run asked the engine to stop for the bank; the finished run that would otherwise win + // never did. Which of the two the scope resolves to decides which pass every chapter of the page + // counts by, so it is the run-level fact this splice is worth asserting on. + exec(t, s, ctx, `update runs set verify_bank = true where id = $1`, live) + exec(t, s, ctx, `update books set edit_wave = true, epoch_editor = true where id = $1`, book) + if !scopeSigning() { + t.Error("a chapter read of this book carries the FINISHED run's facts, not the live one's:" + + " every chapter of the page then counts by the wrong pass") + } + + // SPLICE sink.go:299 — the progress frame. Its denominator is the resolved run's ceiling, so the + // bar of the LIVE run is what a client sees, not the finished run's completed one. + var runID string + var done, total int + if err := s.pool.QueryRow(ctx, `select r.id, `+runDone+`, `+runTotal+` + from books b `+lastRun+` where b.id = $1`, book).Scan(&runID, &done, &total); err != nil { + t.Fatal(err) + } + if runID != live { + t.Errorf("the bar is computed over run %s, want the live %s: the user watches a frozen bar"+ + " while their own run spends", runID, live) + } +} diff --git a/platform/internal/pgstore/migrations.sha256 b/platform/internal/pgstore/migrations.sha256 index 0f1df62b..fd066d35 100644 --- a/platform/internal/pgstore/migrations.sha256 +++ b/platform/internal/pgstore/migrations.sha256 @@ -44,3 +44,6 @@ c21877113b5966bc8a200ba69ce752d4ac295bdfd34e681afbd887523edb6ceb 00019_read_mod 14de53c75746c2b9ab872dcf9c9e0c4bd1ea2eb9e9778fe6a6e2cbe390d8961d 00026_run_draft_baseline.sql 6b0f226e2b5acb90e930337f6548ba777f985deb4472a9f900709acb504ab99f 00027_bank_move.sql 9546ad2c636a57b631391aeb9aab10dcbd06adc677f9bd55f0850296346e141b 00028_settling_attempts.sql +5db5672a3f94866cf23a98520f35491b94b880b85461eac1a333d0bb8364ffe2 00029_drop_bank_released.sql +976d9d22e3543a29e85aef5698b9e304e8d69ba7f432b3f2d62f27024cb9b1f5 00030_shape_epoch.sql +60d31ea77deaae32026a841ab614b11c15ca596fdb19eac5c692af01c6579b9e 00031_drop_run_wave_counters.sql diff --git a/platform/internal/pgstore/migrations/00029_drop_bank_released.sql b/platform/internal/pgstore/migrations/00029_drop_bank_released.sql new file mode 100644 index 00000000..69892765 --- /dev/null +++ b/platform/internal/pgstore/migrations/00029_drop_bank_released.sql @@ -0,0 +1,25 @@ +-- +goose Up + +-- `runs.bank_released` goes, and with it the last of the platform's stand-in for a decision that now +-- belongs to the engine. The column was one half of the `--verify-bank` workaround (00016): the +-- platform masked the flag on a resumed attempt because the engine of that era re-halted on +-- EVERYTHING still undecided, so re-passing it put the run straight back into the stop it had just +-- been released from. The engine grew a presented memory instead (its storage schema v16, D39.158): +-- a stop now fires only on a cluster no earlier stop has shown, so the flag can ride every attempt +-- and the boundary is decided once, by the side that owns it (law of the seam, п.1). Ping #21 named +-- the removal and its ORDER — engine landed, every book migrated, and only then this. +-- +-- The column had a SECOND reader that the workaround's removal does not by itself retire: the bar's +-- segment, which asks "does a chapter of this run count when its DRAFT is done, or when the last +-- pass is". That question is answered by derivation now (`readmodel.firstSegment`), and the swap is +-- a repair rather than a port. The bit only ever moved on a USER's resume, so a run that raised the +-- flag and was never stopped — an empty delta, or a map the engine's memory had already shown, which +-- after D39.158 is the ORDINARY case — carried `false` for its whole life and counted the draft +-- column through the entire edit wave. The screen called a chapter finished that had only been +-- drafted, for half a run. +-- +-- 00016 is NOT edited to do this: released migrations are append-only, and goose keys on the number. +alter table runs drop column bank_released; + +-- +goose Down +alter table runs add column bank_released boolean not null default false; diff --git a/platform/internal/pgstore/migrations/00030_shape_epoch.sql b/platform/internal/pgstore/migrations/00030_shape_epoch.sql new file mode 100644 index 00000000..be05d1b6 --- /dev/null +++ b/platform/internal/pgstore/migrations/00030_shape_epoch.sql @@ -0,0 +1,51 @@ +-- +goose Up + +-- The book's pipeline SHAPE — does its work go through an editor — becomes two facts instead of one, +-- because the single fact was answering two different questions and could only be right for one. +-- +-- `books.edit_wave` (00024) stays exactly as it is, monotone, and keeps its pin (D39.153 §4б): it is +-- the HISTORICAL fact, "has this book ever been through an editing pipeline", and monotonicity is what +-- stops a half-edited chapter counting as finished. What it is NOT, and was being used as, is the +-- authority on how the book's LIFETIME count is computed. Read that way it is wrong in both +-- directions, and both were open register rows: +-- +-- PD-403, editor REMOVED — the flag cannot come back down, so the count stays pinned to an edit +-- column no run will ever fill: the book freezes half-done, the run closes `ready` at 50%, and the +-- purchase scale goes on offering chapters that are already translated. The user is sold work twice. +-- +-- PD-404, editor ADDED — the flag flips true on the first progress event of the run, the count moves +-- to the edit column mid-transaction, and a book showing 7/10 shows 0/10, backwards, inside one +-- `structure_version`, which the canon forbids in as many words. +-- +-- The owner ruled on 28.08 (D39.165 §2): a change of pipeline shape is an EVENT OF THE BOOK, like +-- cutting it again, and the count is legitimately RECOMPUTED when the boundary is crossed. So the +-- boundary needs a carrier, and this is it. +-- +-- `epoch_editor` — the shape of the CURRENT epoch, ASSIGNED rather than accumulated. NULL until the +-- engine has announced a shape at all; from then on it is what the engine last said. This is what +-- the lifetime count reads. +-- `shape_epoch` — how many boundaries this book has crossed. It is a COORDINATE, like +-- `structure_version`: a client tells "the count was legitimately recomputed" from "the count moved +-- backwards illegally" by seeing it change. It is the only half of this pair that goes on the wire, +-- and it goes as an OPAQUE counter (canon 0.9.0): the shape itself — whether this book gets an +-- editing pass — is NOT published, because the canon's boundary forbids the wire everything about +-- HOW a book is translated (§Boundaries: names of models, phases and stages, internal vocabularies, +-- sums of money). D39.163 granted that boundary its SECOND exception, `Progress.stage`, under two +-- stated conditions; an opaque generation counter needs no exception at all, which is why this +-- column is one. +-- +-- Backfill: every existing book gets epoch 0 and its CURRENT flag as the epoch's shape, so nothing +-- moves on deployment — the first announcement that DIFFERS is the first boundary. Books that never +-- announced (edit_wave IS NULL) stay NULL and keep falling back to the flag's own default. +alter table books + add column shape_epoch integer not null default 0, + add column epoch_editor boolean; + +update books set epoch_editor = edit_wave where edit_wave is not null; + +-- ⚠ 00024's header comment describes the frozen rule this migration supersedes, and it is IMMUTABLE +-- (released migrations are append-only, TestReleasedMigrationsAreUnchanged). It is corrected here +-- rather than there: the monotone flag it introduced is still correct for the question IT answers. + +-- +goose Down +alter table books drop column epoch_editor, drop column shape_epoch; diff --git a/platform/internal/pgstore/migrations/00031_drop_run_wave_counters.sql b/platform/internal/pgstore/migrations/00031_drop_run_wave_counters.sql new file mode 100644 index 00000000..850d7e9d --- /dev/null +++ b/platform/internal/pgstore/migrations/00031_drop_run_wave_counters.sql @@ -0,0 +1,33 @@ +-- +goose Up + +-- `runs.draft_done / draft_total / edit_done / edit_total` go: a carrier with TWO writers and ZERO +-- readers (register row PD-411). They are written on EVERY progress event, inside the transaction +-- that holds the book row's lock, by two DIFFERENT disciplines — the stream assigns, the resync takes +-- `greatest` — and not one SELECT in the zone reads any of them. The bar the screen actually shows is +-- derived from `chapters` (readmodel.runDone / runTotal), and it always was. +-- +-- The cost of leaving them was not the four columns: it was that the code around them argued for a +-- bar that does not exist there. The `greatest()` rationale defended the monotonicity of a counter +-- nobody reads, and `sink.unitDone`'s comment told the next reader that "the counters the screen reads +-- today come from the progress event" — so a session looking for where the bar lives would find this, +-- and be wrong. +-- +-- ⚠ WHAT THE REMOVAL EXPOSES, and it is a real gap rather than a side effect of this migration: the +-- RESYNC — the repair channel for a run whose stream is quarantined or never moved — wrote progress +-- into these four columns and nowhere else. It touches neither `unit_resolutions` nor `chapters`, so +-- it materialises nothing the screen reads, and a quarantined run's bar stands still for the whole of +-- its life however faithfully `tmctl status` answers. The dead columns HID that: the code looked as +-- though the repair channel repaired something. Filed as its own register row rather than repaired +-- here — this migration removes a lie, it does not get to invent the cure. +alter table runs + drop column draft_done, + drop column draft_total, + drop column edit_done, + drop column edit_total; + +-- +goose Down +alter table runs + add column draft_done integer not null default 0, + add column draft_total integer not null default 0, + add column edit_done integer not null default 0, + add column edit_total integer not null default 0; diff --git a/platform/internal/pgstore/perf_test.go b/platform/internal/pgstore/perf_test.go index aeb19a1d..4d85522b 100644 --- a/platform/internal/pgstore/perf_test.go +++ b/platform/internal/pgstore/perf_test.go @@ -60,3 +60,20 @@ func BenchmarkLibraryPage(b *testing.B) { } b.ReportMetric(float64(time.Since(start).Milliseconds())/float64(b.N), "ms/page") } + +// BenchmarkChapterPage measures the read this pack changed: the chapter listing now joins the book +// and its current run so it can splice the SAME `chapterUnitsDone` text the sink's chapter frame uses +// (one rule, one text — the alternative was a second copy free to disagree with the frame a client +// was just pushed). +// +// The join is what needs a number rather than an argument, so here it is. Same corpus and same world +// as the library page above: vacuumed, 500 chapters in the book being paged. +func BenchmarkChapterPage(b *testing.B) { + s, ctx := testDB(b) + corpus(b, s, 1, 500, 10) + for b.Loop() { + if _, err := s.ListChapters(ctx, "u1", "bk000", 100, ""); err != nil { + b.Fatal(err) + } + } +} diff --git a/platform/internal/pgstore/readmodel.go b/platform/internal/pgstore/readmodel.go index 9d190154..7085c5f2 100644 --- a/platform/internal/pgstore/readmodel.go +++ b/platform/internal/pgstore/readmodel.go @@ -366,11 +366,58 @@ const derivedStatus = `case when b.status in ('uploading', 'parsing', 'not_start // once because a second copy is how a projection drifts: the intake's 201, the library page and the // card all answer the same fields or the client sees a book change shape by the route it came from. const bookColumns = `b.id, b.title, b.source_lang, b.target_lang, ` + derivedStatus + `, b.reject_reason, - b.structure_version, b.chapter_count, ` + chaptersDone + `, b.character_count, ` + noteCount + `, + b.structure_version, b.shape_epoch, b.chapter_count, ` + chaptersDone + `, b.character_count, ` + noteCount + `, b.added_at, b.revision` -// editWave is whether the pipeline this book's work goes through has an EDITOR, over a `books b`. -// Unknown reads as "it has one": a chapter only half-done must not count as finished. +// epochWave is whether the pipeline this book's work goes through has an EDITOR — the shape of its +// CURRENT epoch, as the engine last announced it, over a `books b`. Three fallbacks in order: the +// epoch's own answer; the historical monotone flag, for a book whose last announcement predates the +// epoch columns; and finally "it has one", because a chapter only half-done must not count as +// finished. +// +// ⚠ It reads the EPOCH and not the monotone `edit_wave`, and the difference is two register rows. +// Read through the flag the count is wrong in BOTH directions, because the flag cannot come down: +// with the editor REMOVED the count freezes against an edit column no run will ever fill — the book +// stays half-done, no run's bar can reach one, and the purchase scale goes on offering chapters +// already translated, which is the user paying twice (PD-403); with the editor ADDED the flag flips +// on the run's first progress event and a book showing 7/10 shows 0/10, backwards, inside one +// `structure_version`, which the canon forbade in as many words (PD-404). +// +// The owner ruled the cure on 28.08 (D39.165 §2): a change of pipeline shape is an EVENT of the book, +// like cutting it again, and the count is legitimately recomputed at the boundary. `books.shape_epoch` +// is what makes that recomputation legible rather than indistinguishable from a server walking a +// counter down (canon 0.9.0). +// +// ⚠ `books.edit_wave` is NOT repealed and is NOT dead: it is still written, still monotone, still +// pinned (D39.153 §4б), and it is the second fallback above — the answer for every book whose shape +// was recorded before this epoch existed. What changed is that it stopped being the AUTHORITY. +// +// ⚠ Safe for the RUN's bar as well as the book's count, and that is worth stating because the bar is +// where PD-401 lived. The epoch moves only when a progress event announces a shape, and progress +// events come from the book's own live run — one per book by construction — so the epoch a bar reads +// is always that run's own announcement. PD-401 was never about WHICH flag chose the wave: it was +// about pairing each numerator with the baseline captured on ITS OWN column, and that pairing is +// untouched here. +const epochWave = `coalesce(b.epoch_editor, b.edit_wave, true)` + +// editWave is the HISTORICAL, monotone answer to the same question, and it is what the RUN's bar +// reads. Unknown reads as "it has one", for the same reason. +// +// ⚠ The two are separated HERE and the separation is load-bearing, so it is worth being exact about +// which fact belongs to which reader. `epoch_editor` is ASSIGNED on every announcement, so it moves +// in BOTH directions; `edit_wave` only ever grows. That is right for the BOOK, whose lifetime count +// the owner ruled may be recomputed at a shape boundary (D39.165 §2) with `shape_epoch` announcing +// it — and WRONG for the run's bar, which the canon holds to one monotonic fraction over the run's +// whole work (row 200). An adversarial pass of this same pack landed the mistake and measured it: +// with the bar on the epoch, one run whose second attempt announced a draft-only shape read 4/4 and +// then 2/2 — the same row, the same `structure_version`, the fraction walking backwards. The bar +// stays on the flag. +// +// ⚠ Consequence, named rather than hidden: on a deployment where the editor was REMOVED, a run's bar +// still tariffs an edit wave that will not happen and cannot reach one. That is the second half of +// register row PD-403, and it stays OPEN — closing it needs a per-RUN record of the shape that run is +// actually working under, which is a decision of its own size (the shape is not known at StartRun — +// the engine announces it with the run's first progress event, which is exactly what PD-401 found). const editWave = `coalesce(b.edit_wave, true)` // finishedUnits is how many of a chapter's units are FINISHED: resolved by the LAST pass the book @@ -385,13 +432,51 @@ const editWave = `coalesce(b.edit_wave, true)` // moved BACKWARDS: the latest run is the newest, and a run just admitted has announced nothing, so // every book on a deployment with no editor fell back to zero chapters done until that run's first // progress event. -const finishedUnits = `(case when ` + editWave + ` then c.units_edit_done else c.units_draft_done end)` +const finishedUnits = `(case when ` + epochWave + ` then c.units_edit_done else c.units_draft_done end)` -// segmentUnits is the same count for the current SEGMENT of a run, which is the work between two -// stops: in the first segment of a run that stops for signing a chapter counts when the DRAFT is -// done with it, and otherwise when the last pass is. -const segmentUnits = `(case when r.verify_bank and not r.bank_released - then c.units_draft_done else ` + finishedUnits + ` end)` +// chapterUnitsDone is how many pairs of ONE chapter the CURRENT PASS has finished — the number the +// wire calls `units_done` — written once here and bound by each reader to the facts it has at hand. +// +// It is a rule about a chapter and it is evaluated PER CHAPTER, which is the property that matters +// most and the one two earlier editions of it lost. `signing` is the run-level fact (this run asked +// the engine to stop for the bank); everything else is columns of `c`. Nothing in it is book-wide, so +// a unit event on one chapter cannot change what a re-read says about another — and every chapter +// frame the sink pushes is announced by an event of that same chapter. +// +// The three arms, in order: +// - A run that stops for signing, over a chapter the editor has not touched: the DRAFT pass is the +// current one for that chapter, and its progress is the only honest number there is. This arm +// covers the whole draft wave AND the stop itself, which is the point — a user watching a signing +// run is watching the draft pass for most of it. +// - Otherwise the book's epoch decides which pass is the last one, exactly as `finishedUnits` does +// for the book's lifetime count. +// +// ⚠ The stored `bank_released` bit this replaces went out with the `--verify-bank` workaround +// (D39.158, ping #21), and the replacement is deliberately NOT a like-for-like port — the bit was +// wrong in a way that outlived every run: it moved only on a USER's resume, so a run that raised the +// flag and was never stopped by the engine (an empty delta, or a map the engine's presented memory +// had already shown — after D39.158 the ORDINARY case) kept it false for its whole life and counted +// DRAFTS as finished through the entire edit wave. +// +// ⚠ Two editions of this rule were landed and withdrawn inside one pack, and the reasons are worth +// keeping because they are the two ways to get it wrong. The first asked whether the run still owed +// draft passes — BOOK-wide, so it flipped on a draft unit of some OTHER chapter and silently changed +// chapters no event had touched. The second asked only whether the run was standing AT the stop, +// which is announced and per-run but far too narrow: it read the edit column — zeroes — through the +// whole draft wave, so a signing run showed nothing done for most of its life. Neither survived a +// measurement; this one is per-chapter and covers the wave. +// ⚠ ONE constant and not a function of its inputs, and that is the battery's rule rather than taste: +// every SQL of this package must fold to a compile-time constant so +// `TestEverySQLStatementParsesAgainstTheMigratedSchema` can plan it against the migrated schema. A +// helper taking its facts as arguments produced a non-constant expression at both call sites and the +// gate refused it — correctly. So the two readers share the TEXT: both join the book and its current +// run, which is what the sink's chapter frame already did and what the chapter listing now does too. +const chapterUnitsDone = `(case when r.verify_bank and c.units_edit_done = 0 then c.units_draft_done + when ` + epochWave + ` then c.units_edit_done + else c.units_draft_done end)` + +// segmentUnits is the name the sink reads it by. +const segmentUnits = chapterUnitsDone // chaptersDone is the BOOK's own progress: chapters fully translated, against chapter_count. It is a // different question from the bar of a run — which measures what that run bought. @@ -410,8 +495,8 @@ const noteCount = `(select coalesce(sum(c.note_count), 0) from chapters c where func scanBook(row pgx.Row) (Book, error) { var b Book err := row.Scan(&b.ID, &b.Title, &b.SourceLang, &b.TargetLang, &b.Status, &b.RejectReason, - &b.StructureVersion, &b.ChapterCount, &b.ChaptersDone, &b.CharacterCount, &b.NoteCount, - &b.AddedAt, &b.Revision) + &b.StructureVersion, &b.ShapeEpoch, &b.ChapterCount, &b.ChaptersDone, &b.CharacterCount, + &b.NoteCount, &b.AddedAt, &b.Revision) if errors.Is(err, pgx.ErrNoRows) { return Book{}, ErrNoBook } @@ -493,11 +578,29 @@ const ( then 'editing' else 'drafting' end)` ) +// newestRun is the order in which a book RESOLVES to one of its runs: the LIVE one first, however the +// clocks fell, and only then the newest-started. +// +// ⚠ The live term is not a tie-break, it is the whole point (PD-402). `RestartRun` re-opens a run +// WITHOUT re-stamping `started_at` — deliberately, because the run's baselines and its bar are the +// ones it started with — so a resumed run is older than the finished run that legitimately ran +// between its stop and its resume. Ordered by `started_at` alone the book then resolves to the +// FINISHED one: the card says `ready`, the bar stands frozen at somebody else's work, and the live +// run spends money invisibly behind them. The index `runs_one_live_per_book` (00002:66, unique on +// `book_id` where `finished_at is null`) is what makes this a TOTAL order and not a new ambiguity — +// at most one row can win the first term. +// +// It lives in one constant because the read model is not its only user: the resume gate reads the +// same question through `LatestRun`, and the two answering differently would INVERT this defect +// rather than fix it — the screen would follow the live run while the resume refused it as "not the +// latest one". +const newestRun = ` order by (finished_at is null) desc, started_at desc, id desc limit 1` + // lastRun is the join every book-scoped read uses: the current or last run of the book. const lastRun = `left join lateral ( - select id, status, paused_reason, failure_reason, verify_bank, bank_released, ceiling_chapters, + select id, status, paused_reason, failure_reason, verify_bank, ceiling_chapters, chapters_before, draft_before, eta_seconds, started_at, finished_at, stop_requested_at - from runs where book_id = b.id order by started_at desc, id desc limit 1) r on true` + from runs r where book_id = b.id` + newestRun + `) r on true` func readBookStatusTx(ctx context.Context, tx pgx.Tx, bookID string) (bookStatus, error) { var st bookStatus @@ -581,13 +684,17 @@ func (s *Store) ListChapters(ctx context.Context, userID, bookID string, limit i return err } out.Page = Page{Revision: scope.revision, StructureVersion: scope.structure} + // The SAME TEXT the sink's chapter frame is computed from, which is why this query joins the + // book and its current run at all: a page that answered a different number from the frame it + // was pushed a moment ago is a screen disagreeing with itself, and two copies of one rule is + // how that happens. rows, err := tx.Query(ctx, ` select c.id, c.number, c.units_total, - case when $4 = 'draft' then c.units_draft_done else c.units_edit_done end, + `+segmentUnits+`, c.note_count - from chapters c + from chapters c join books b on b.id = c.book_id `+lastRun+` where c.book_id = $1 and ($2::integer is null or c.number > $2::integer) - order by c.number limit $3`, bookID, after, limit+1, scope.wave) + order by c.number limit $3`, bookID, after, limit+1) if err != nil { return fmt.Errorf("pgstore: list chapters: %w", err) } @@ -939,11 +1046,12 @@ type scope struct { structure int structureReset int64 bankReset int64 - // wave is which pass the chapter counters are read from — the platform's own split, which the - // wire never carries. The FIRST segment of a run that stops for signing is the draft pass; so is - // every pass on a deployment whose pipeline has no editor (finishedUnits). Everything else counts - // a chapter when its work is finished end to end. - wave string + // signing is whether the book's current run asked the engine to stop for the bank — a FACT, never + // a pre-decided wave. Which pass a chapter counts by is `chapterUnitsDone`, written once and + // evaluated per chapter; a scope that answered "draft" or "edit" by itself was a SECOND copy of + // that rule — book-wide, applied to every chapter of a page, and free to disagree with the frame + // the sink had just pushed for one of them. + signing bool } // The BOOK is in the tag: without it a cursor minted for one book was accepted on another at the @@ -954,26 +1062,21 @@ func (s scope) tag(collection string) string { func bookScope(ctx context.Context, tx pgx.Tx, userID, bookID string) (scope, error) { out := scope{bookID: bookID} - var stopForSigning, released *bool - var edits bool + var signing *bool err := tx.QueryRow(ctx, ` select b.revision, b.structure_version, b.structure_reset_revision, b.bank_reset_revision, - r.verify_bank, r.bank_released, `+editWave+` + r.verify_bank from books b `+lastRun+` where b.id = $1 and b.owner_id = $2`, bookID, userID). - Scan(&out.revision, &out.structure, &out.structureReset, &out.bankReset, - &stopForSigning, &released, &edits) + Scan(&out.revision, &out.structure, &out.structureReset, &out.bankReset, &signing) if errors.Is(err, pgx.ErrNoRows) { return scope{}, ErrNoBook } if err != nil { return scope{}, fmt.Errorf("pgstore: read book scope: %w", err) } - out.wave = "edit" - signing := stopForSigning != nil && *stopForSigning && released != nil && !*released - if signing || !edits { - out.wave = "draft" - } + // A book with no run at all has no signing fact; that is not "signing". + out.signing = signing != nil && *signing return out, nil } diff --git a/platform/internal/pgstore/readmodel_test.go b/platform/internal/pgstore/readmodel_test.go index 609545fb..5c967d30 100644 --- a/platform/internal/pgstore/readmodel_test.go +++ b/platform/internal/pgstore/readmodel_test.go @@ -836,13 +836,85 @@ func TestASecondRunOnADraftOnlyDeploymentStillOpensAtZero(t *testing.T) { } } +// A chapter of a signing run counts by the pass that is CURRENT FOR THAT CHAPTER, and the rule is +// evaluated per chapter — which is the whole of it, and what two withdrawn editions got wrong. +// +// Why the draft arm exists at all: under an editor pipeline `units_edit_done` is zero for the whole +// draft wave, so a counter read only off the last pass shows a user nothing done for most of a run +// they are paying for. A run that stops for signing is exactly the run whose user is asked to LOOK at +// that half, so it is the one that must show it. +// +// Why it is per chapter: the frames the sink pushes are per chapter, announced by an event of that +// same chapter. A book-wide term flips on a unit of some OTHER chapter and silently changes what a +// re-read says about chapters nothing touched — measured on the real sink, and the reason the first +// edition of this rule was withdrawn. The second edition asked only whether the run was standing AT +// the stop: announced and per-run, but it read zeroes through the entire draft wave, which is the +// defect the acceptance found. This one moves only on the chapter's own column. +// +// Mutation caught: dropping the `c.units_edit_done = 0` arm (the draft wave shows nothing again); +// replacing it with a run-level or book-wide term (the flip stops being the chapter's own). +func TestAChapterOfASigningRunCountsByThePassCurrentForThatChapter(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().Truncate(time.Microsecond) + if _, err := s.StartRun(ctx, StartRunInput{UserID: "u1", BookID: book, VerifyBank: true, + CeilingChapters: 2, Ceiling: money.MicroUSD(300_000), Now: at}, 0, nil); err != nil { + t.Fatal(err) + } + exec(t, s, ctx, `update books set edit_wave = true, epoch_editor = true where id = $1`, book) + units := func(number int) int { + t.Helper() + var got int + if err := s.pool.QueryRow(ctx, + `select `+segmentUnits+` from chapters c join books b on b.id = c.book_id `+lastRun+` + where c.book_id = $1 and c.number = $2`, book, number).Scan(&got); err != nil { + t.Fatal(err) + } + return got + } + // THE DRAFT WAVE, which is most of the run: chapter 1 is half drafted and nothing is edited. + exec(t, s, ctx, `update chapters set units_draft_done = 1 where book_id = $1 and number = 1`, book) + if got := units(1); got != 1 { + t.Errorf("mid-draft the chapter counts %d, want its draft progress 1: a user watching the half"+ + " they are asked to sign off is shown nothing done", got) + } + // …and it stays the draft pass when that chapter's draft finishes. The whole wave, not a moment. + exec(t, s, ctx, `update chapters set units_draft_done = units_total where book_id = $1 and number = 1`, book) + if got := units(1); got != 2 { + t.Errorf("with its draft finished the chapter counts %d, want 2", got) + } + // PER CHAPTER: chapter 2 has not been drafted, and finishing chapter 1 said nothing about it. + if got := units(2); got != 0 { + t.Errorf("chapter 2 counts %d after work on chapter 1 alone: the rule is not the chapter's own,"+ + " and a re-read now disagrees with a frame nothing announced", got) + } + // THE EDIT PASS begins for chapter 1: its counter follows the pass that is current for IT, while + // chapter 2 — still untouched by the editor — goes on reporting its own draft progress. + exec(t, s, ctx, `update chapters set units_draft_done = units_total where book_id = $1`, book) + exec(t, s, ctx, `update chapters set units_edit_done = 1 where book_id = $1 and number = 1`, book) + if got := units(1); got != 1 { + t.Errorf("with one unit edited the chapter counts %d, want the edit pass's 1", got) + } + if got := units(2); got != 1 { + t.Errorf("chapter 2 counts %d, want its own draft progress 1: chapter 1's editor did not reach it", got) + } + // A run that never asked for the stop is untouched by the draft arm: it counts by the last pass, + // exactly as it did before this rule existed. + exec(t, s, ctx, `update runs set verify_bank = false where book_id = $1`, book) + if got := units(2); got != 0 { + t.Errorf("an ordinary editing run counts %d for an unedited chapter, want 0", got) + } +} + // THE rule of the through-bar (owner's word of 20.08, row 200): the bar is ONE monotonic fraction // over the run's whole work, so lifting the signing stop MOVES NOTHING — the re-basing that used to // restart it from zero is gone, and «100%, then zero» with it. Through the stop the draft half // stands, the edit half continues, and the caption follows the counters. // -// Mutation caught: re-taking either baseline in RestartRun; switching the numerator's wave on -// `bank_released` (the old segment counting). +// Mutation caught: re-taking either baseline in RestartRun. func TestTheBarIsOneMonotonicFractionThroughTheSigningStop(t *testing.T) { s, ctx := testDB(t) book := readingBook(t, s, ctx, "u1") @@ -870,7 +942,7 @@ func TestTheBarIsOneMonotonicFractionThroughTheSigningStop(t *testing.T) { t.Errorf("with the draft half done the stage is %q, want editing", first.Progress.Stage) } if _, err := s.RestartRun(ctx, RestartInput{RunID: run.ID, AttemptID: run.AttemptID, - UserID: "u1", BookID: book, Ceiling: money.MicroUSD(100_000), LiftBankStop: true, + UserID: "u1", BookID: book, Ceiling: money.MicroUSD(100_000), Now: at.Add(time.Minute)}); err != nil { t.Fatal(err) } @@ -997,7 +1069,7 @@ func TestADraftOnlyDeploymentCountsItsOneWaveOnce(t *testing.T) { t.Fatalf("a drafted draft-only purchase reads %d/%d, want 2/2", first.Progress.Done, first.Progress.Total) } if _, err := s.RestartRun(ctx, RestartInput{RunID: run.ID, AttemptID: run.AttemptID, - UserID: "u1", BookID: book, Ceiling: money.MicroUSD(100_000), LiftBankStop: true, + UserID: "u1", BookID: book, Ceiling: money.MicroUSD(100_000), Now: at.Add(time.Minute)}); err != nil { t.Fatal(err) } diff --git a/platform/internal/pgstore/recut_test.go b/platform/internal/pgstore/recut_test.go index 42997f22..befe26ef 100644 --- a/platform/internal/pgstore/recut_test.go +++ b/platform/internal/pgstore/recut_test.go @@ -257,7 +257,7 @@ func TestLiftingTheSigningStopLeavesTheBarWhereItStood(t *testing.T) { } // Through the path a resume actually takes: the stop is lifted by the re-open itself. if _, err := s.RestartRun(ctx, RestartInput{RunID: started.ID, AttemptID: started.AttemptID, - UserID: "u1", BookID: book, Ceiling: money.MicroUSD(100_000), LiftBankStop: true, + UserID: "u1", BookID: book, Ceiling: money.MicroUSD(100_000), Now: time.Now().UTC()}); err != nil { t.Fatal(err) } diff --git a/platform/internal/pgstore/runs.go b/platform/internal/pgstore/runs.go index b3b664b9..d8c1fe2e 100644 --- a/platform/internal/pgstore/runs.go +++ b/platform/internal/pgstore/runs.go @@ -208,9 +208,6 @@ type LiveRun struct { Position Position Quarantined bool VerifyBank bool - // 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. @@ -826,7 +823,7 @@ func abandonSettlement(ctx context.Context, tx pgx.Tx, runID, reason string, now // 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.resnapshot, + select r.id, r.book_id, b.owner_id, b.workdir, r.verify_bank, 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, ''), @@ -847,7 +844,7 @@ func (s *Store) queryRuns(ctx context.Context, tail string, args ...any) ([]Live var l LiveRun 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.Resnapshot, + if err := rows.Scan(&l.RunID, &l.BookID, &l.UserID, &l.Workdir, &l.VerifyBank, &l.Resnapshot, &acceptRebill, &l.CeilingChapters, &l.PausedReason, &l.StartedAt, &l.Status, &l.StopRequestedAt, &l.AttemptID, &l.AttemptNo, &l.UnitName, &l.EngineRunID, @@ -974,15 +971,21 @@ func (s *Store) whyNotLive(ctx context.Context, userID, runID string) error { // The reconciler's own list is deliberately not reusable here: it selects the attempt that has not // ended, and every run this call is about has ended. What resume needs is the state the run stopped // in and the attempt whose money and journal position it stopped at. -// LatestRun answers which run of the book every book-scoped read resolves — the newest by the -// same ordering `lastRun` uses — and whether that row is still live. The resume gate compares -// against it: re-opening any OTHER run leaves the card and every progress frame quoting the wrong -// row (PD-402 keeps the read half), and the two reasons carry different words — a LIVE newer run -// is «the book is being translated», a finished one is «resume the latest». +// LatestRun answers which run of the book every book-scoped read resolves — a LIVE run first and +// then the newest-started, the `newestRun` order the read model's `lastRun` splices — and whether +// that row is still live. The resume gate compares against it: re-opening any OTHER run leaves the +// card and every progress frame quoting the wrong row, and the two reasons carry different words — +// a LIVE newer run is «the book is being translated», a finished one is «resume the latest». +// +// ⚠ The order is SPLICED and not written out again, and that is the half of PD-402 the read fix +// could not do alone: this query used to hand-write `order by started_at desc` while the read model +// spliced a constant, and the two are the same question. Answered differently they invert the +// defect instead of closing it — every surface would follow the live run while this gate refused to +// resume it, calling it "not the latest". func (s *Store) LatestRun(ctx context.Context, bookID string) (id string, live bool, err error) { err = s.pool.QueryRow(ctx, ` select id, finished_at is null from runs - where book_id = $1 order by started_at desc, id desc limit 1`, + where book_id = $1`+newestRun, bookID).Scan(&id, &live) if errors.Is(err, pgx.ErrNoRows) { return "", false, ErrNoRun @@ -1066,11 +1069,7 @@ type RestartInput struct { // already been told it ended — and the run the user sees finished starts spending again. A resume // does exactly that on purpose, which is why the guard is the caller's to ask for. OnlyIfLive bool - // LiftBankStop clears the signing stop as part of THIS transaction. In its own statement the pair - // could half-land: the run re-opens, the write fails, and the bar then counts the draft pass - // against a ceiling nothing will ever reach, with no channel that repairs it. - LiftBankStop bool - Now time.Time + Now time.Time } // ErrRunFinished is a restart refused because another pass has already ended the run. Not an error of @@ -1177,12 +1176,11 @@ func (s *Store) RestartRun(ctx context.Context, in RestartInput) (LiveRun, error if _, err := tx.Exec(ctx, ` update runs r set status = 'translating', paused_reason = null, finished_at = null, settled_at = null, stop_requested_at = null, - bank_released = bank_released or $2, - resnapshot = r.resnapshot or $3, - accept_rebill_micro = greatest(r.accept_rebill_micro, $4), + resnapshot = r.resnapshot or $2, + accept_rebill_micro = greatest(r.accept_rebill_micro, $3), revision = b.revision + 1 from books b - where r.id = $1 and b.id = r.book_id`, in.RunID, in.LiftBankStop, + where r.id = $1 and b.id = r.book_id`, in.RunID, 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 @@ -1215,11 +1213,11 @@ 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.resnapshot, r.accept_rebill_micro, + const q = `select b.workdir, r.verify_bank, 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` var acceptRebill int64 - if err := s.pool.QueryRow(ctx, q, in.RunID).Scan(&out.Workdir, &out.VerifyBank, &out.BankReleased, + if err := s.pool.QueryRow(ctx, q, in.RunID).Scan(&out.Workdir, &out.VerifyBank, &out.Resnapshot, &acceptRebill, &out.CeilingChapters); err != nil { return LiveRun{}, fmt.Errorf("pgstore: read restarted run: %w", err) } @@ -1253,26 +1251,26 @@ func (s *Store) AttemptReservationOpen(ctx context.Context, runID string, attemp return open, nil } -// RunPausedReason reads what the run's projection currently says about a ceiling. +// RunPausedReason reads what the run's projection currently says: its STATUS and its ceiling reason. // -// It exists because the reconciler's snapshot is taken BEFORE the journal is drained, and the -// ceiling event that decides whether an ending is `paused` or `failed` can arrive in that very -// drain. Judging the ending from the snapshot answered `failed` for a run whose own stream had just +// It exists because the reconciler's snapshot is taken BEFORE the journal is drained, and the events +// that decide how an ending is classified can arrive in that very drain — the ceiling event, and +// (since the bank-stop protection of unified-backlog row 240) the bank-stop event, which writes +// `awaiting_bank` on the row and nothing the snapshot carries. Judging the ending from the snapshot answered `failed` for a run whose own stream had just // said `ceiling` — one sweep of staleness, on the one branch where being wrong is contractually // visible (acceptance of D39.131, п.3). // // An empty string is "nothing said so", not an error: most runs never pause. -func (s *Store) RunPausedReason(ctx context.Context, runID string) (string, error) { - var reason string - err := s.pool.QueryRow(ctx, - `select coalesce(paused_reason, '') from runs where id = $1`, runID).Scan(&reason) +func (s *Store) RunPausedReason(ctx context.Context, runID string) (status, reason string, err error) { + err = s.pool.QueryRow(ctx, + `select status, coalesce(paused_reason, '') from runs where id = $1`, runID).Scan(&status, &reason) if errors.Is(err, pgx.ErrNoRows) { - return "", nil // the run is gone; the caller's next write refuses on its own + return "", "", nil // the run is gone; the caller's next write refuses on its own } if err != nil { - return "", fmt.Errorf("pgstore: read paused reason: %w", err) + return "", "", fmt.Errorf("pgstore: read run state: %w", err) } - return reason, nil + return status, reason, nil } // PauseRun records a run that cannot go on, without pretending it failed. diff --git a/platform/internal/pgstore/runs_test.go b/platform/internal/pgstore/runs_test.go index c6fdd5d3..cdc32342 100644 --- a/platform/internal/pgstore/runs_test.go +++ b/platform/internal/pgstore/runs_test.go @@ -218,31 +218,49 @@ func TestAnEventAndItsCursorMoveTogetherAndADuplicateChangesNothing(t *testing.T t.Fatal(err) } var lastSeq, offset int64 - var draftDone, editDone int + var epoch int + var editor *bool var eta *int + // ⚠ The observable is the ETA and the announced SHAPE, not the per-wave counters this line also + // used to write: those columns had two writers and no reader and were dropped with PD-411, so an + // assertion over them said only that a fold ran. These two are what a progress line still leaves + // in the read model. + // + // ⚠ What the DUPLICATE half of this test proves is the CURSOR, and only the cursor — said plainly + // because the first edition of this comment claimed the epoch made it bite, and this pack's + // adversarial pass showed it cannot: a re-delivery carries the same shape, so the epoch's + // `epoch_editor <> $2` guard is false by construction and the counter would not move even with the + // dedup guard deleted. The idempotence of the FOLD is pinned where it can be observed, on the + // counters a duplicate would actually double: TestAUnitAnnouncedTwiceIsCountedOnce. read := func() { t.Helper() if err := s.pool.QueryRow(ctx, `select last_seq, last_offset from run_attempts where id = $1`, run.AttemptID).Scan(&lastSeq, &offset); err != nil { t.Fatal(err) } - if err := s.pool.QueryRow(ctx, `select draft_done, edit_done, eta_seconds from runs where id = $1`, - run.ID).Scan(&draftDone, &editDone, &eta); err != nil { + if err := s.pool.QueryRow(ctx, `select eta_seconds from runs where id = $1`, + run.ID).Scan(&eta); err != nil { + t.Fatal(err) + } + if err := s.pool.QueryRow(ctx, `select epoch_editor, shape_epoch from books where id = 'bk1'`). + Scan(&editor, &epoch); err != nil { t.Fatal(err) } } read() - if lastSeq != 2 || offset != 128 || draftDone != 3 || editDone != 1 || eta == nil || *eta != 42 { - t.Fatalf("after one event: seq=%d offset=%d draft=%d edit=%d eta=%v", lastSeq, offset, draftDone, editDone, eta) + if lastSeq != 2 || offset != 128 || eta == nil || *eta != 42 { + t.Fatalf("after one event: seq=%d offset=%d eta=%v", lastSeq, offset, eta) + } + if editor == nil || !*editor || epoch != 0 { + t.Fatalf("the line announced an edit total of 10: shape folded to editor=%v epoch=%d", editor, epoch) } // The same line again — the ordinary consequence of a reader restart. if err := sink.Apply(ctx, ev, cur); err != nil { t.Fatalf("a redelivered event was refused: %v", err) } - before := draftDone read() - if draftDone != before || lastSeq != 2 { - t.Errorf("a duplicate moved something: draft %d→%d, seq %d", before, draftDone, lastSeq) + if epoch != 0 || lastSeq != 2 { + t.Errorf("a duplicate moved something: epoch %d, seq %d", epoch, lastSeq) } // The handshake's chunker version is what tells the platform its chapter numbering was produced // by a different chunker (row 100). diff --git a/platform/internal/pgstore/session_slide_seam_test.go b/platform/internal/pgstore/session_slide_seam_test.go new file mode 100644 index 00000000..f8e4297a --- /dev/null +++ b/platform/internal/pgstore/session_slide_seam_test.go @@ -0,0 +1,123 @@ +package pgstore + +import ( + "net/http" + "net/http/httptest" + "testing" + "time" + + "textmachine/platform/internal/auth" +) + +// The idle window SLIDES, through the real middleware, against a live store — and nothing pinned +// that until this test (register row PD-431). +// +// What kept it unpinned is a shape that coverage cannot see: `Touch` throws away the command tag, so +// an UPDATE that matches NO ROW is indistinguishable from one that slid the window. Measured by the +// `sqlc` pack's adversarial pass on 29.08: `where token_sha256 = $1` → `where 1 = 0 and +// token_sha256 = $1` left eighteen packages green and zero tests red, while the control mutation on +// the same stand went red twice — so the harness worked and the silence belonged to `Touch`. The two +// tests that call it are both decided by predicates of the SQL itself and stay true when it does +// nothing at all. +// +// ⚠ The behaviour is NOT changed and must not be: zero rows is a legitimate race with a revocation +// arriving between the Lookup and the Touch (the orchestrator's word, and the reason the `sqlc` pack +// deliberately left it alone). What was missing was the CHECK, so this is the check. +// +// Two things make it honest rather than decorative. It asserts through a LATER Lookup rather than +// through the response, because a failed Touch is logged and swallowed — the 200 proves nothing. And +// it runs the live `*pgstore.Store` as the middleware's `auth.SessionStore`, which no test did: the +// interface is satisfied already, so nothing in production had to change for this to be possible, +// only somebody had to connect the two ends. It lives in package `pgstore` because `pgstore` imports +// `auth` and not the other way round — the same test in package `auth` is an import cycle. +// +// Mutation caught: `where 1 = 0 and token_sha256 = $1` in TouchSession — the update that touches +// nothing; and swapping the two deadlines, which slides the wrong clock. +func TestTheIdleWindowSlidesThroughTheAuthenticatorAgainstALiveStore(t *testing.T) { + s, ctx := testDB(t) + seedUser(t, s, ctx, "u_slide") + + const idleTTL, maxAge = time.Hour, 24 * time.Hour + t0 := time.Now().UTC().Truncate(time.Millisecond) + token := auth.NewToken() + digest := auth.Digest(token) + if err := s.CreateSession(ctx, digest, "u_slide", t0, idleTTL, maxAge); err != nil { + t.Fatal(err) + } + + // A request in the window's SECOND half — which is the only time the middleware slides at all — + // and far from the absolute ceiling, which is its other condition. + at := t0.Add(40 * time.Minute) + served := false + a := &auth.Authenticator{ + Sessions: s, // ⚠ THE POINT: the live store, as the middleware's own dependency. + IdleTTL: idleTTL, + Now: func() time.Time { return at }, + Deny: http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { + t.Error("a live session was denied") + w.WriteHeader(http.StatusUnauthorized) + }), + } + r := httptest.NewRequest(http.MethodGet, "/v0/books", nil) + r.Header.Set("Authorization", "Bearer "+token) + a.Require(http.HandlerFunc(func(http.ResponseWriter, *http.Request) { served = true })). + ServeHTTP(httptest.NewRecorder(), r) + if !served { + t.Fatal("the request never reached the handler: this fixture cannot observe the slide") + } + + // PAST the original deadline. A window that did not slide is a session that no longer exists. + after := t0.Add(90 * time.Minute) + got, err := s.Lookup(ctx, digest, after) + if err != nil { + t.Fatalf("the session is gone at t0+90m (%v): the request at t0+40m did not slide the idle"+ + " window, so an active user is signed out on the idle TTL counted from LOGIN", err) + } + if got.UserID != "u_slide" { + t.Fatalf("the slid session belongs to %q", got.UserID) + } + + // And it slid the RIGHT clock. A Touch that moved the absolute ceiling instead would pass the + // assertion above and quietly turn the session's hard limit into a sliding one. + if want := at.Add(idleTTL); !got.IdleExpiresAt.Equal(want) { + t.Errorf("the idle deadline is %s, want %s (the request's own time plus the TTL)", + got.IdleExpiresAt, want) + } + if want := t0.Add(maxAge); !got.AbsoluteExpiresAt.Equal(want) { + t.Errorf("the absolute deadline moved to %s, want it fixed at %s: the whole point of two"+ + " clocks is that this one does not slide", got.AbsoluteExpiresAt, want) + } +} + +// The other end of the same seam: a REVOKED session is denied through the live store, and the +// handler never runs. Cheap, and it exercises the Lookup failure path that only fakes had reached. +func TestARevokedSessionIsDeniedThroughTheAuthenticatorAgainstALiveStore(t *testing.T) { + s, ctx := testDB(t) + seedUser(t, s, ctx, "u_revoked") + t0 := time.Now().UTC().Truncate(time.Millisecond) + token := auth.NewToken() + if err := s.CreateSession(ctx, auth.Digest(token), "u_revoked", t0, time.Hour, 24*time.Hour); err != nil { + t.Fatal(err) + } + if err := s.RevokeSession(ctx, auth.Digest(token), t0); err != nil { + t.Fatal(err) + } + denied := false + a := &auth.Authenticator{ + Sessions: s, + IdleTTL: time.Hour, + Now: func() time.Time { return t0.Add(time.Minute) }, + Deny: http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { + denied = true + w.WriteHeader(http.StatusUnauthorized) + }), + } + r := httptest.NewRequest(http.MethodGet, "/v0/books", nil) + r.Header.Set("Authorization", "Bearer "+token) + a.Require(http.HandlerFunc(func(http.ResponseWriter, *http.Request) { + t.Error("a revoked session reached the handler") + })).ServeHTTP(httptest.NewRecorder(), r) + if !denied { + t.Error("a revoked session was not denied") + } +} diff --git a/platform/internal/pgstore/shape_epoch_test.go b/platform/internal/pgstore/shape_epoch_test.go new file mode 100644 index 00000000..29674074 --- /dev/null +++ b/platform/internal/pgstore/shape_epoch_test.go @@ -0,0 +1,246 @@ +package pgstore + +import ( + "testing" + "time" + + "textmachine/platform/internal/money" +) + +// announceShape is one progress event of a run, which is where the engine states the shape of the +// pipeline it is running: an edit denominator of zero means this deployment has no editor. +func announceShape(t *testing.T, s *Store, book string, draftTotal, editTotal int) { + t.Helper() + tx, err := s.pool.Begin(t.Context()) + if err != nil { + t.Fatal(err) + } + defer func() { _ = tx.Rollback(t.Context()) }() + if err := recordWaveShape(t.Context(), tx, book, draftTotal, editTotal); err != nil { + t.Fatal(err) + } + if err := tx.Commit(t.Context()); err != nil { + t.Fatal(err) + } +} + +func shapeEpoch(t *testing.T, s *Store, book string) (epoch int, editor *bool) { + t.Helper() + if err := s.pool.QueryRow(t.Context(), + `select shape_epoch, epoch_editor from books where id = $1`, book).Scan(&epoch, &editor); err != nil { + t.Fatal(err) + } + return epoch, editor +} + +// draftedBook is a book whose chapters are fully DRAFTED and not edited: the state every editor +// pipeline passes through, and the state both halves of this file start from. +func draftedBook(t *testing.T, s *Store) string { + t.Helper() + ctx := t.Context() + book := readingBook(t, s, ctx, "u1") + if err := s.SaveStructure(ctx, book, twoChapters("k1")); err != nil { + t.Fatal(err) + } + exec(t, s, ctx, `update chapters set units_draft_done = units_total where book_id = $1`, book) + return book +} + +// THE EDITOR IS REMOVED, and the book stops being frozen half-done (PD-403). +// +// The old rule read the book's lifetime count through the MONOTONE flag, and the flag cannot come +// back down. So a book that had been through an editing pipeline, on a deployment where the operator +// has since removed the editor, counted against an edit column that no run would ever fill again: the +// count froze, the run closed `ready` at half, and — this is the money — `ChaptersLeft` never fell, +// so the purchase scale went on offering chapters that were already translated. The user is sold the +// same work twice. +// +// The owner ruled the cure on 28.08 (D39.165 §2): a change of pipeline shape is an EVENT of the book, +// like cutting it again, and the count is legitimately recomputed at the boundary. The monotone flag +// is NOT repealed by this — it still refuses to come down, and its pin still stands. What moved is +// which fact the count reads. +// +// Mutation caught: pointing finishedUnits back at the monotone `editWave`; making `epoch_editor` +// accumulate (`or`) instead of assign. +func TestRemovingTheEditorRecomputesTheBooksCountInsteadOfFreezingIt(t *testing.T) { + s, ctx := testDB(t) + book := draftedBook(t, s) + + // Epoch 1: the deployment HAS an editor. Nothing is edited yet, so nothing is finished. + announceShape(t, s, book, 2, 2) + before, _, err := s.GetBook(ctx, "u1", book) + if err != nil { + t.Fatal(err) + } + if before.ChaptersDone != 0 { + t.Fatalf("under an editor a merely drafted book reads %d done, want 0", before.ChaptersDone) + } + epoch, editor := shapeEpoch(t, s, book) + if epoch != 0 || editor == nil || !*editor { + t.Fatalf("first announcement left epoch=%d editor=%v, want the epoch unmoved and the shape recorded", + epoch, editor) + } + + // The operator removes the editor and the next run announces a zero edit denominator. + announceShape(t, s, book, 2, 0) + + after, _, err := s.GetBook(ctx, "u1", book) + if err != nil { + t.Fatal(err) + } + if after.ChaptersDone != 2 { + t.Errorf("with the editor gone the book still reads %d/%d done, want 2: it is frozen against an"+ + " edit column no run will fill, and the purchase scale goes on selling chapters already translated", + after.ChaptersDone, after.ChapterCount) + } + // The recomputation ANNOUNCES itself, or the client cannot tell it from a count walking backwards. + if after.ShapeEpoch == before.ShapeEpoch { + t.Errorf("the count was recomputed at epoch %d without moving it: the jump is indistinguishable"+ + " from an illegal move and the canon's amnesty is unobservable", after.ShapeEpoch) + } + // ⚠ The monotone flag is NOT repealed. D39.153 §4б still holds and its pin still stands. + var flag *bool + if err := s.pool.QueryRow(ctx, `select edit_wave from books where id = $1`, book).Scan(&flag); err != nil { + t.Fatal(err) + } + if flag == nil || !*flag { + t.Errorf("edit_wave came down to %v: the historical flag is monotone (D39.153 §4б) and this"+ + " work does not repeal it — only the LIFETIME COUNT stopped being read off it", flag) + } + + // And the purchase scale, which is the money half of PD-403: what is left to buy must have fallen. + rc, err := s.ReadBookForRun(ctx, "u1", book) + if err != nil { + t.Fatal(err) + } + if rc.ChaptersLeft != 0 { + t.Errorf("the purchase scale still offers %d chapters of a fully translated book", rc.ChaptersLeft) + } +} + +// THE EDITOR IS ADDED — the one direction the old flag could move — and the count still recomputes, +// but it now says so (PD-404). +// +// The flag flips false→true on the first progress event of the run that has an editor, and the +// lifetime count moves to the edit column in the same transaction: a book showing 2/2 shows 0/2, +// backwards, inside ONE `structure_version` — which the canon forbade in as many words. The owner's +// ruling makes the jump legitimate; what makes it legible is that the epoch moves with it, so a +// client sees a new generation rather than a server walking a counter down. +// +// Mutation caught: not counting a false→true boundary (an epoch that only moves one way). +func TestAddingTheEditorMovesTheEpochWithTheCountItRecomputes(t *testing.T) { + s, ctx := testDB(t) + book := draftedBook(t, s) + + // Epoch 0: a draft-only deployment. Every drafted chapter is finished, because drafting is the + // last pass the book gets. + announceShape(t, s, book, 2, 0) + before, _, err := s.GetBook(ctx, "u1", book) + if err != nil { + t.Fatal(err) + } + if before.ChaptersDone != 2 { + t.Fatalf("a draft-only deployment reads %d done on a fully drafted book, want 2", before.ChaptersDone) + } + + // The operator ADDS an editor. The work already done is now half-work, and the count says so. + announceShape(t, s, book, 2, 2) + after, _, err := s.GetBook(ctx, "u1", book) + if err != nil { + t.Fatal(err) + } + if after.ChaptersDone != 0 { + t.Errorf("after the editor arrived the book reads %d done, want 0: a drafted chapter is no"+ + " longer finished work", after.ChaptersDone) + } + if after.ShapeEpoch <= before.ShapeEpoch { + t.Errorf("the count went %d → %d — BACKWARDS inside one structure_version — and the epoch"+ + " stayed at %d: without the boundary this is exactly what the canon forbids", + before.ChaptersDone, after.ChaptersDone, after.ShapeEpoch) + } +} + +// The epoch counts BOUNDARIES, not announcements: a run re-stating the shape it already has moves +// nothing. Otherwise every progress event of every run would look to a client like a recomputation, +// and the signal would mean nothing by the time it mattered. +// +// Mutation caught: incrementing unconditionally, or on the FIRST announcement (which crosses nothing). +func TestTheEpochMovesOnBoundariesAndNotOnEveryAnnouncement(t *testing.T) { + s, _ := testDB(t) + book := draftedBook(t, s) + announceShape(t, s, book, 2, 2) + first, _ := shapeEpoch(t, s, book) + for range 3 { + announceShape(t, s, book, 2, 2) + } + same, _ := shapeEpoch(t, s, book) + if same != first { + t.Errorf("three restatements of the same shape moved the epoch %d → %d", first, same) + } + announceShape(t, s, book, 2, 0) + crossed, _ := shapeEpoch(t, s, book) + if crossed != first+1 { + t.Errorf("one boundary moved the epoch %d → %d, want exactly one step", first, crossed) + } + // …and back again is another boundary, not a return to the old number: the epoch is a generation + // counter, not a state. + announceShape(t, s, book, 2, 2) + back, _ := shapeEpoch(t, s, book) + if back != crossed+1 { + t.Errorf("crossing back left the epoch at %d, want %d: it counts crossings, not shapes", back, crossed+1) + } +} + +// THE RUN'S BAR is NOT on the epoch, and this test is what holds that line. +// +// ⚠ Its predecessor asserted the opposite — that a run on a deployment which dropped the editor can +// reach one — and that was this pack's own mistake, caught by its adversarial pass and measured on a +// live database: pointing the bar at the ASSIGNABLE epoch let one run read 4/4 and then 2/2 across +// its own two attempts, the same row, the same `structure_version`, the fraction walking backwards. +// The canon holds a run's bar to one monotonic fraction over the run's whole work (row 200), so the +// bar stays on the MONOTONE `books.edit_wave` and only the BOOK's lifetime count moved to the epoch. +// +// What that leaves open is stated in the register rather than hidden here: on a deployment where the +// editor was removed, a run's bar still tariffs an edit wave that will not happen (PD-403's second +// half). Closing it needs a per-RUN record of the shape that run works under — and the shape is not +// known at StartRun, because the engine announces it with the run's FIRST progress event, which is +// precisely what PD-401 found. That is a decision of its own size. +// +// Mutation caught: pointing runDone/runTotal/runStage at epochWave. +func TestTheRunsBarIsMonotoneAcrossAShapeBoundaryItSpans(t *testing.T) { + s, ctx := testDB(t) + book := draftedBook(t, s) + // An editor deployment: the run buys two chapters and the bar tariffs both waves. + announceShape(t, s, book, 2, 2) + at := time.Now().UTC().Truncate(time.Microsecond) + run, err := s.StartRun(ctx, StartRunInput{UserID: "u1", BookID: book, CeilingChapters: 2, + Ceiling: money.MicroUSD(300_000), Now: at}, 0, nil) + if err != nil { + t.Fatal(err) + } + exec(t, s, ctx, `update chapters set units_edit_done = units_total where book_id = $1`, book) + before, err := s.ReadRun(ctx, "u1", run.ID) + if err != nil { + t.Fatal(err) + } + // The operator removes the editor and the run's SECOND attempt announces a draft-only shape. + announceShape(t, s, book, 2, 0) + after, err := s.ReadRun(ctx, "u1", run.ID) + if err != nil { + t.Fatal(err) + } + if after.Progress.Done < before.Progress.Done || after.Progress.Total < before.Progress.Total { + t.Errorf("the bar of ONE run walked backwards across a shape boundary it spans: %d/%d → %d/%d."+ + " The canon holds a run's bar to one monotonic fraction, and a run does not get to un-do"+ + " work it was paid for because the deployment changed shape under it", + before.Progress.Done, before.Progress.Total, after.Progress.Done, after.Progress.Total) + } + // …while the BOOK's count DID move: that is the ruling, and the epoch announces it. + card, _, err := s.GetBook(ctx, "u1", book) + if err != nil { + t.Fatal(err) + } + if card.ShapeEpoch == 0 { + t.Error("the book crossed a shape boundary and its epoch did not move") + } +} diff --git a/platform/internal/pgstore/sink.go b/platform/internal/pgstore/sink.go index ce527fbd..b8e6ce58 100644 --- a/platform/internal/pgstore/sink.go +++ b/platform/internal/pgstore/sink.go @@ -133,10 +133,13 @@ func (r *RunSink) effect(ctx context.Context, tx pgx.Tx, ev ingest.Envelope) err if err := decode(ev, &p); err != nil { return err } - if err := r.bump(ctx, tx, ` - update runs set draft_done = $2, draft_total = $3, edit_done = $4, edit_total = $5, - eta_seconds = $6, revision = $7 where id = $1`, - r.runID, p.Draft.Done, p.Draft.Total, p.Edit.Done, p.Edit.Total, etaOrNil(p.ETASeconds)); err != nil { + // The event's own per-wave counters are NOT stored, and that is deliberate rather than an + // omission (register row PD-411): they had two writers and no reader, while the bar the screen + // shows is derived from `chapters`. What this event is kept for is the ETA and — through + // recordWaveShape below — the SHAPE the engine is announcing. + if err := r.bump(ctx, tx, + `update runs set eta_seconds = $2, revision = $3 where id = $1`, + r.runID, etaOrNil(p.ETASeconds)); err != nil { return err } if err := recordWaveShape(ctx, tx, r.bookID, p.Draft.Total, p.Edit.Total); err != nil { @@ -214,9 +217,14 @@ func (r *RunSink) effect(ctx context.Context, tx pgx.Tx, ev ingest.Envelope) err // seam, and a value from outside it would land in a column whose constraint refuses it and take the // whole materialization down with it. Under the minor-version rule an unknown value is ignored. // -// ⚠ Bounded by what exists, unchanged: without the engine's persisted chapter manifest (unified -// backlog row 100) there is no `chapters` row to carry the derived counters, so the fold records the -// unit and stops there. The counters the screen reads today come from the progress event. +// ⚠ Bounded by what exists: without a materialised chapter tree there is no `chapters` row to carry +// the derived counters, so the fold records the unit and stops there. The sentence that used to close +// this paragraph — "the counters the screen reads today come from the progress event" — was FALSE and +// was register row PD-405's cover: those columns had no reader at all (PD-411, dropped in 00031), and +// the whole bar is derived from `chapters`. A run over a book with no tree therefore read 0/total for +// its entire life. The window is closed at admission now — a run is refused before its hold is taken +// while the book owes its tree (runs.readyToTranslate's caller) — so what remains here is the honest +// residue: an announcement that arrives before the tree exists is recorded and counted later. func (r *RunSink) unitDone(ctx context.Context, tx pgx.Tx, ev ingest.Envelope, u ingest.UnitDone) error { if u.Wave != ingest.WaveDraft && u.Wave != ingest.WaveEdit { return nil @@ -399,21 +407,36 @@ func decode(ev ingest.Envelope, into any) error { return nil } -// recordWaveShape keeps the book's answer to "does this pipeline have an editor" — the fact -// finishedUnits reads. The engine announces the two denominators at the start of a run, so the FIRST -// progress event of a book's first run is what settles it. +// recordWaveShape keeps the book's answer to "does this pipeline have an editor". The engine +// announces the two denominators at the start of a run, so the FIRST progress event of a book's first +// run is what settles it, and every later announcement is compared against what stands. // -// Monotone in one direction on purpose: once a book has been through an editing pipeline, a chapter -// of it is finished when it is EDITED, and a later run reporting no editor must not make half-done -// chapters count as done. The other direction — an operator removing the editor — shows up as more -// chapters finished, which is honest and never walks a counter backwards. +// TWO facts are written, because one fact was answering two questions and could only be right for +// one of them. The predecessor of this comment claimed that removing the editor "shows up as more +// chapters finished, which is honest and never walks a counter backwards" — its own code did neither +// (the flag cannot come down at all, so the count froze), and it is deleted rather than repaired. +// +// - `edit_wave` — the HISTORICAL fact, monotone, unchanged and still pinned (D39.153 §4б): once a +// book has been through an editing pipeline, a chapter of it is finished when it is EDITED, and a +// later run reporting no editor must not make half-done chapters count as done. +// - `epoch_editor` + `shape_epoch` — the EPOCH: the shape as it stands NOW, assigned rather than +// accumulated, and a counter of how many times it has changed. The lifetime count reads the +// epoch, and crossing a boundary legitimately recomputes it — the owner's ruling of 28.08 +// (D39.165 §2): a change of pipeline shape is an event of the book, like cutting it again. +// +// The two writes are ONE statement for the reason the pair is worth having at all: a boundary whose +// counter landed and whose shape did not would recompute nothing while announcing that it had. +// Both call sites already hold the book row's lock, so no new ordering is introduced. func recordWaveShape(ctx context.Context, tx pgx.Tx, bookID string, draftTotal, editTotal int) error { if draftTotal+editTotal == 0 { return nil // this event announced no shape at all } - if _, err := tx.Exec(ctx, - `update books set edit_wave = coalesce(edit_wave, false) or $2 where id = $1`, - bookID, editTotal > 0); err != nil { + if _, err := tx.Exec(ctx, ` + update books set edit_wave = coalesce(edit_wave, false) or $2, + shape_epoch = shape_epoch + + (case when epoch_editor is not null and epoch_editor <> $2 then 1 else 0 end), + epoch_editor = $2 + where id = $1`, bookID, editTotal > 0); err != nil { return fmt.Errorf("pgstore: record the wave shape: %w", err) } return nil @@ -428,28 +451,33 @@ func etaOrNil(s int) *int { // ApplyStatus folds a `tmctl status --json` report into the read model. // -// This is the RESYNC channel: a snapshot of a run that keeps moving, taken every few minutes. Since -// D39.122 the report carries the per-wave split, so it materializes through the same four counters -// as the stream and no longer flattens them — what stays true of it is that it is STALE by up to -// the poll interval, which is why it stamps last_resync_at rather than leaving a reader to guess -// how old the figures on a quarantined run are. +// This is the RESYNC channel: a snapshot of a run that keeps moving, taken every few minutes. It is +// STALE by up to the poll interval, which is why it stamps last_resync_at rather than leaving a +// reader to guess how old the figures on a quarantined run are. +// +// ⚠ The sentence that used to stand here — "since D39.122 the report carries the per-wave split, so +// it materializes through the same four counters as the stream" — was true of four columns nothing +// ever read, and they are gone (register row PD-411, migration 00031). What this channel actually +// materialises, and what it does NOT, is spelled out in the body below; the short version is that it +// does not repair the bar, and that gap has a register row of its own. func (s *Store) ApplyStatus(ctx context.Context, runID, bookID string, rep ingest.StatusReport, now time.Time) error { return s.inTx(ctx, func(tx pgx.Tx) error { var rev int64 if err := tx.QueryRow(ctx, `select revision + 1 from books where id = $1 for update`, bookID).Scan(&rev); err != nil { return fmt.Errorf("pgstore: read revision: %w", err) } - // greatest(), so a resync can never move a counter BACKWARDS. A report taken before the - // engine's own figures caught up would otherwise walk a visible progress bar back down — the - // one thing the contract asks a client never to do and which the server must not do either. - // It is also what makes this safe to run over a projection the stream has already moved. + // ⚠ WHAT THIS DOES NOT DO, said plainly because the code used to imply otherwise. The report's + // per-wave figures are NOT materialised anywhere the screen reads. They used to be written to + // `runs.draft_done/…`, which nothing selected (PD-411, columns dropped in 00031), and the + // `greatest()` that guarded them was defending the monotonicity of a bar that does not live + // there — the bar is derived from `chapters`, which this path does not touch. So the repair + // channel repairs the ETA, the freshness stamp and the wave SHAPE, and a run whose stream is + // quarantined shows the progress its stream last managed to deliver and no more. That is an + // open gap with a register row of its own; it is named here rather than hidden behind a column + // that made the code look as though the channel were doing the job. if _, err := tx.Exec(ctx, ` - update runs set draft_done = greatest(draft_done, $2), draft_total = greatest(draft_total, $3), - edit_done = greatest(edit_done, $4), edit_total = greatest(edit_total, $5), - eta_seconds = $6, last_resync_at = $7, revision = $8 - where id = $1`, runID, rep.Progress.Draft.Done, rep.Progress.Draft.Total, - rep.Progress.Edit.Done, rep.Progress.Edit.Total, - etaOrNil(int(rep.ETASeconds)), now, rev); err != nil { + update runs set eta_seconds = $2, last_resync_at = $3, revision = $4 + where id = $1`, runID, etaOrNil(int(rep.ETASeconds)), now, rev); err != nil { return fmt.Errorf("pgstore: apply status: %w", err) } if err := recordWaveShape(ctx, tx, bookID, rep.Progress.Draft.Total, rep.Progress.Edit.Total); err != nil { diff --git a/platform/internal/pgstore/sink_test.go b/platform/internal/pgstore/sink_test.go index f1d2ccdf..3914b2ac 100644 --- a/platform/internal/pgstore/sink_test.go +++ b/platform/internal/pgstore/sink_test.go @@ -439,44 +439,101 @@ func TestAZeroEtaIsStoredAsAbsent(t *testing.T) { // aggregate (backlog row 99) the resync could put it in the edit counter and nothing else, and the // old test asserted that the draft counter stayed untouched. The split landed on 09.08 (D39.122), so // asserting the old shape would now pin a limitation instead of a property. -func TestTheResyncMaterializesThePhaseSplitAndNeverLowersACounter(t *testing.T) { +// ⚠ RE-POINTED with PD-411 and the claim NARROWED to what is true, which is the finding rather than +// a concession. Its predecessor was called «the resync materializes the phase split and never lowers +// a counter» and it asserted over `runs.draft_done/draft_total/edit_done/edit_total` — four columns +// with two writers and NO reader, dropped in migration 00031. So the property it proved was real of +// those columns and meaningless of the screen: the bar is derived from `chapters`, which this channel +// does not write at all. There is nothing left for a resync to walk backwards, because there is +// nothing left for a resync to move. +// +// What that leaves is a GAP, not a passing test: the repair channel for a run whose stream is +// quarantined materialises no progress, so such a run's bar stands still however faithfully `tmctl +// status` answers. It has a register row of its own. This test pins the honest remainder — the ETA, +// the freshness stamp and the announced SHAPE — and the gap is stated here so the next reader does +// not re-derive it from a green test. +func TestTheResyncRecordsFreshnessAndShapeAndNoProgress(t *testing.T) { s, sink, runID, _ := sinkFixture(t) + // ⚠ The stream announces a DRAFT-ONLY shape, and the resync below announces an EDITING one. The + // two must differ or the shape assertion proves nothing: this pack's adversarial pass caught the + // first edition, where the stream had already written the same shape and deleting the + // recordWaveShape call from ApplyStatus entirely left the whole eighteen-package battery green. if err := apply(t, sink, 2, ingest.TypeProgress, ingest.Progress{ - Draft: ingest.Counter{Done: 9, Total: 20}, Edit: ingest.Counter{Done: 7, Total: 20}}); err != nil { + Draft: ingest.Counter{Done: 9, Total: 20}, Edit: ingest.Counter{Done: 0, Total: 0}, + ETASeconds: 99}); err != nil { t.Fatal(err) } - // A report taken BEFORE the engine's own figures caught up, folded over a projection the stream - // has already moved: nothing may walk backwards. - stale := ingest.StatusReport{TotalUnits: 20, Done: 3, - Progress: ingest.Progress{Draft: ingest.Counter{Done: 5, Total: 20}, Edit: ingest.Counter{Done: 3, Total: 20}}} - if err := s.ApplyStatus(t.Context(), runID, "bk1", stale, time.Now().UTC()); err != nil { + if editor, _ := bookShape(t, s, "bk1"); editor == nil || *editor { + t.Fatalf("the stream announced a draft-only shape and the book reads %v: the fixture cannot"+ + " observe what the RESYNC writes", editor) + } + before := bookRevision(t, s, "bk1") + rep := ingest.StatusReport{TotalUnits: 20, Done: 11, + Progress: ingest.Progress{Draft: ingest.Counter{Done: 20, Total: 20}, + Edit: ingest.Counter{Done: 11, Total: 20}}, ETASeconds: 42} + at := time.Now().UTC() + if err := s.ApplyStatus(t.Context(), runID, "bk1", rep, at); err != nil { t.Fatal(err) } - var draft, edit int + var eta *int + var stamp *time.Time if err := s.pool.QueryRow(t.Context(), - `select draft_done, edit_done from runs where id=$1`, runID).Scan(&draft, &edit); err != nil { + `select eta_seconds, last_resync_at from runs where id=$1`, runID).Scan(&eta, &stamp); err != nil { t.Fatal(err) } - if draft != 9 || edit != 7 { - t.Errorf("a stale resync walked the projection back to draft=%d edit=%d, from draft=9 edit=7", draft, edit) + if eta == nil || *eta != 42 { + t.Errorf("the resync's eta folded to %v, want 42", eta) } - // A fresher one moves BOTH waves, which is the half no aggregate could do. - fresh := ingest.StatusReport{TotalUnits: 20, Done: 11, - Progress: ingest.Progress{Draft: ingest.Counter{Done: 20, Total: 20}, Edit: ingest.Counter{Done: 11, Total: 20}}} - if err := s.ApplyStatus(t.Context(), runID, "bk1", fresh, time.Now().UTC()); err != nil { - t.Fatal(err) + if stamp == nil { + t.Error("the resync left no freshness stamp: how stale its figures are is the one thing only it can say") } - var dtotal, etotal int + // The SHAPE the report announced reaches the book — this is the resync's other real effect, and + // the one that decides how the whole library counts this book. The stream said draft-only; only + // ApplyStatus can have written what is read here. + editor, epoch := bookShape(t, s, "bk1") + if editor == nil || !*editor { + t.Errorf("the report announced an edit total of 20 and the book's shape folded to %v: the"+ + " resync's own announcement never reached the book", editor) + } + if epoch != 1 { + t.Errorf("the resync crossed a shape boundary and the epoch reads %d, want 1", epoch) + } + // And a client is told to re-read, because a resync is a materialisation like any other. + if after := bookRevision(t, s, "bk1"); after <= before { + t.Errorf("the resync left the book at revision %d: a watching client is never told to re-read", after) + } + // ⚠ THE GAP, asserted so it cannot be forgotten: the bar is derived from `chapters`, and the + // resync does not write there. A quarantined run shows what its stream last delivered. + var draftDone int if err := s.pool.QueryRow(t.Context(), - `select draft_done, draft_total, edit_done, edit_total from runs where id=$1`, runID). - Scan(&draft, &dtotal, &edit, &etotal); err != nil { + `select units_draft_done from chapters where id='c1'`).Scan(&draftDone); err != nil { t.Fatal(err) } - if draft != 20 || dtotal != 20 || edit != 11 || etotal != 20 { - t.Errorf("after a fresher resync: draft %d/%d, edit %d/%d", draft, dtotal, edit, etotal) + if draftDone != 0 { + t.Errorf("the resync moved a chapter counter to %d: if this channel has learned to materialise"+ + " progress, the register row that says it cannot is stale and the bar's source has changed", draftDone) } } +func bookShape(t *testing.T, s *Store, bookID string) (editor *bool, epoch int) { + t.Helper() + if err := s.pool.QueryRow(t.Context(), + `select epoch_editor, shape_epoch from books where id = $1`, bookID).Scan(&editor, &epoch); err != nil { + t.Fatal(err) + } + return editor, epoch +} + +func bookRevision(t *testing.T, s *Store, bookID string) int64 { + t.Helper() + var rev int64 + if err := s.pool.QueryRow(t.Context(), `select revision from books where id = $1`, bookID). + Scan(&rev); err != nil { + t.Fatal(err) + } + return rev +} + // What a resync produces is stale by up to the poll interval, and the ONLY thing that says how stale // is the stamp it leaves. A run being tailed has none: the stream carries its own freshness. func TestAResyncRecordsWhenItWasTaken(t *testing.T) { diff --git a/platform/internal/runs/bank.go b/platform/internal/runs/bank.go index 163d787a..28151579 100644 --- a/platform/internal/runs/bank.go +++ b/platform/internal/runs/bank.go @@ -179,7 +179,33 @@ func (s *Service) ApplyBankCorrections(ctx context.Context, in BankCorrectionsIn // 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 { + // + // ⚠ On a context of its OWN, detached from the caller — the same rule as the intake's + // `writeCtx` (books/parse.go) and the idempotency receipt's `settleCtx` + // (httpapi/idempotency.go) — and here it is the difference between a fact and nothing. + // + // ⚠ The references above and below are by SYMBOL, not by line: this pack moved four of the six + // numbered anchors an earlier edition of this comment carried, which is the same rot the + // register's own anchors are graded for. Grep the name. + // + // Two mechanics make the request's context the wrong one to write on. The verb comes back + // CLEAN on an already-cancelled call: `runner.BankApply` returns (outcome, nil) whenever the + // process exited, and the engine's last look at its context is BEFORE its writes — so a + // correction whose files LANDED routinely arrives here with ctx already dead, either because + // the tab closed or because the verb ate the whole budget set at the top of this function. + // And the remedy this door then names — «re-send the same document» — is addressed to + // precisely the client that is gone. The fact has no second writer and no sweep (grep + // `bank_moved_at =` — one statement sets it, in `pgstore.RecordBankMove`), so on r.Context() + // it is lost FOREVER: `bank_moved_at` stays NULL, the next ordinary run is admitted with + // resnapshot=false (`runs.Start`, grep `if book.BankMoved`) and dies on the engine's snapshot + // guard AFTER its hold was taken (`pgstore.StartRun`, the hold is taken before anything is + // spawned). That is PD-425, and it is money. + // + // recordBudget rather than the intake's 30 s: this write runs UNDER the book lock taken at + // the top, so its tail is a delay the next caller of this book pays. + c, cancel := context.WithTimeout(context.WithoutCancel(ctx), recordBudget) + defer cancel() + if ferr := s.Store.RecordBankMove(c, 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 diff --git a/platform/internal/runs/bank_test.go b/platform/internal/runs/bank_test.go index b7e6efb4..4c6c7b3e 100644 --- a/platform/internal/runs/bank_test.go +++ b/platform/internal/runs/bank_test.go @@ -447,6 +447,55 @@ func TestACorrectionRecordsTheBankMoveAndAPreviewDoesNot(t *testing.T) { } } +// The bank-move fact outlives the client that asked for it. THE money case (PD-425): the correction +// verb comes back CLEAN on an already-cancelled call — runner.BankApply returns (outcome, nil) +// whenever the process exited, and the engine's last look at its context is before its writes — so a +// correction whose files LANDED reaches the fact-write with the request context already dead. On +// r.Context() the write is refused by pgx, `bank_moved_at` stays NULL forever (no second writer, no +// sweep), and the next ordinary run is admitted without --resnapshot and dies on the engine's +// snapshot guard AFTER its hold: the user pays for an attempt that does nothing. The remedy the door +// names for a failed write — «re-send the same document» — is by construction addressed to a client +// that is gone. +// +// The verb is held mid-flight and the cancellation lands WHILE it runs, because that is the only +// shape that reaches the write: a context cancelled before the call never gets past lockBook, which +// selects on ctx.Done and answers ErrBankUnavailable. +// +// Mutation caught: RecordBankMove on the caller's context. +func TestTheBankMoveFactSurvivesAClientThatHungUp(t *testing.T) { + f := newFixture(t, "10", 500) + fake := &fakeBankApplier{ + out: runner.BankApplyOutcome{ExitCode: 0, Exited: true, Report: okReport("apply"), Decoded: true}, + entered: make(chan struct{}), + release: make(chan struct{}), + } + fake.out.Report.BookID = f.bookID(t) + f.svc.Bank = fake + ctx, cancel := context.WithCancel(f.ctx) + defer cancel() + done := make(chan error, 1) + go func() { + _, err := f.svc.ApplyBankCorrections(ctx, BankCorrectionsInput{ + UserID: "u1", BookID: f.bookID(t), Preview: false, + Decisions: []ingest.BankDecision{{Action: "decline", ID: "tm_1"}}, + }) + done <- err + }() + <-fake.entered // the verb is mid-flight and the book lock is held + cancel() // the tab closes; the engine's writes have already landed + close(fake.release) + if err := <-done; err != nil { + t.Fatalf("the door refused a correction whose files landed: %v", err) + } + book, err := f.svc.Store.ReadBookForRun(f.ctx, "u1", f.bookID(t)) + if err != nil { + t.Fatal(err) + } + if !book.BankMoved { + t.Fatal("the bank-move fact died with the client: the next ordinary run is admitted without --resnapshot and dies on the engine's guard after taking its hold") + } +} + // 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). diff --git a/platform/internal/runs/control_test.go b/platform/internal/runs/control_test.go index 9f9aa1dc..bda4f8f1 100644 --- a/platform/internal/runs/control_test.go +++ b/platform/internal/runs/control_test.go @@ -2,6 +2,8 @@ package runs import ( "errors" + "os" + "path/filepath" "slices" "sync" "testing" @@ -55,6 +57,80 @@ func (f *fixture) stoppedRun(t *testing.T, in StartRequest, spent money.MicroUSD return run.ID } +// A run is REFUSED over a book whose chapter tree was never materialised, and refused BEFORE its +// hold — which is the whole point of where the guard sits (PD-405). +// +// The window is the intake's own and it is ordinary: `not_started` is committed in one transaction +// and the tree is materialised after it, outside. On the healthy path that is milliseconds (measured +// at 0.018 s, act D39.162); when the materialisation FAILED or was deferred it is unbounded, and that +// is the shape that costs money. A run admitted there can never move: every counter the screen shows +// is a count over `chapters` and there are no rows to count, so the bar reads 0/total for the run's +// whole life — and the tree's own debt is frozen meanwhile, because the sweep that would pay it skips +// a book with a live run. The user pays for a run that shows nothing and cannot be repaired. +// +// Mutation caught: moving the guard below the hold; dropping the `ChapterCount > 0` term (which would +// refuse a book that legitimately declares no chapters). +func TestARunIsRefusedOverABookWhoseChaptersWereNeverMaterialised(t *testing.T) { + f := newFixture(t, "10", 500) + book := f.bookID(t) + // The tree goes away: the state the intake is in between its commit and its materialisation, and + // the state it STAYS in when that materialisation broke. + if _, err := f.store.Pool().Exec(f.ctx, `delete from chapters where book_id = $1`, book); err != nil { + t.Fatal(err) + } + before := f.account(t) + _, err := f.svc.Start(f.ctx, StartRequest{UserID: "u1", BookID: book, CeilingChapters: 10}) + if !errors.Is(err, ErrBookNotReady) { + t.Fatalf("a run over a book with no chapter tree was answered %v, want book_not_ready: its bar"+ + " can never move and its tree's debt is frozen for as long as it runs", err) + } + // BEFORE the hold, and this is the half that is money rather than honesty. + after := f.account(t) + if after.Balance != before.Balance || after.LedgerSum != before.LedgerSum { + t.Errorf("the refusal moved money: balance %s → %s, ledger %s → %s", + before.Balance.USD(), after.Balance.USD(), before.LedgerSum.USD(), after.LedgerSum.USD()) + } + // And the same book with its tree back is startable: the guard refuses the STATE, not the book. + if _, err := f.store.Pool().Exec(f.ctx, ` + insert into chapters (id, book_id, number, units_total) + select 'c' || g, $1, g, 1 from generate_series(1, 500) g`, book); err != nil { + t.Fatal(err) + } + if _, err := f.svc.Start(f.ctx, StartRequest{UserID: "u1", BookID: book, CeilingChapters: 10}); err != nil { + t.Fatalf("the book was refused with its tree materialised: %v", err) + } +} + +// The OTHER half of the same guard, and it exists because this pack's adversarial pass showed the +// header above claiming a mutation nothing caught: dropping the `ChapterCount > 0` term left the +// whole eighteen-package battery green, because no test built the book it would newly refuse. +// +// A book that declares NO chapters has no tree to be missing, so the guard must not touch it. It is +// refused, but by the thing that should refuse it — there is nothing to buy — and the two refusals +// carry different remedies: "wait for the intake" is a lie told about a book whose intake is over. +// +// Mutation caught: dropping the `ChapterCount > 0` term from the admission guard. +func TestABookThatDeclaresNoChaptersIsNotRefusedAsUnmaterialised(t *testing.T) { + f := newFixture(t, "10", 0) + book := f.bookID(t) + var count int + if err := f.store.Pool().QueryRow(f.ctx, + `select chapter_count from books where id = $1`, book).Scan(&count); err != nil { + t.Fatal(err) + } + if count != 0 { + t.Fatalf("the fixture book declares %d chapters: this test is about the one that declares none", count) + } + _, err := f.svc.Start(f.ctx, StartRequest{UserID: "u1", BookID: book, CeilingChapters: 1}) + if errors.Is(err, ErrBookNotReady) { + t.Errorf("a book that declares no chapters was told its chapters are still being materialised:"+ + " %v — its intake is OVER, and the honest refusal is that there is nothing to buy", err) + } + if err == nil { + t.Error("a run was admitted over a book with nothing in it") + } +} + // secondBook registers another book of the same account, with a directory of its own. func (f *fixture) secondBook(t *testing.T) string { t.Helper() @@ -63,6 +139,13 @@ func (f *fixture) secondBook(t *testing.T) string { if err != nil { t.Fatal(err) } + // …with its chapter TREE, for the same reason the main fixture has one: a book that declares + // chapters and materialised none cannot be started over (PD-405). + if _, err := f.store.Pool().Exec(f.ctx, ` + insert into chapters (id, book_id, number, units_total) + select $1 || ':c' || g, $1, g, 1 from generate_series(1, 100) g`, id); err != nil { + t.Fatal(err) + } return id } @@ -77,19 +160,6 @@ func (f *fixture) lastAttempt(t *testing.T, runID string) pgstore.LiveRun { return l } -// bankReleased reads the bit that tells the first segment of a run from the second. Read with SQL -// because it is deliberately NOT on the wire: the client sees one counter, and which pass it is -// counting is the platform's own business. -func (f *fixture) bankReleased(t *testing.T, runID string) bool { - t.Helper() - var released bool - if err := f.store.Pool().QueryRow(f.ctx, - `select bank_released from runs where id = $1`, runID).Scan(&released); err != nil { - t.Fatal(err) - } - return released -} - func (f *fixture) run(t *testing.T, runID string) pgstore.Run { t.Helper() r, err := f.store.ReadRun(f.ctx, "u1", runID) @@ -379,11 +449,6 @@ func TestResumeLiftsABankStopWithTheDecisionsAsTheyStand(t *testing.T) { if got.Status != "translating" { t.Fatalf("the resumed run is %q, want it going again", got.Status) } - // The SEGMENT moved with it: the bar the client reads starts again from zero for the work that - // follows the stop, which is what `bank_released` is for. - if !f.bankReleased(t, runID) { - t.Error("the signing stop was lifted without recording that the second segment had begun") - } } // A run that spent the whole ceiling it bought is REFUSED: there is no work this call could pay for, @@ -579,13 +644,21 @@ func TestOneSlowRunDoesNotEatThePassOfTheWholeSweep(t *testing.T) { } } -// ONLY the user's resume may clear a bank-signing stop. The reconciler restarts INTERRUPTIONS, and a -// run standing at the signing stop is not one: the stop writes `awaiting_bank` without `finished_at`, -// so the run stays in the sweep's list, and a restart that lifted the stop would spawn the rest of the -// ceiling without `--verify-bank` — running past the pause the user paid for, with no click of theirs. +// A run standing at the bank-signing stop is NOT an interruption, so the sweep does not restart it — +// it closes it AT the stop, and only the user's resume takes it further. // -// Mutation caught: keying LiftBankStop on l.Status alone, which is the form that shipped this morning. -func TestTheReconcilerDoesNotLiftABankStopNobodySigned(t *testing.T) { +// ⚠ RE-SIGNED by pack P12 (unified-backlog row 240), and the re-signing is the point rather than an +// edit to keep a test green. This test used to pin a guard that keyed on WHICH BIT a restart wrote +// (`LiftBankStop`), and that guard was written against an engine that re-halted on everything still +// undecided: keeping `--verify-bank` on the restarted attempt was what re-created the pause. The +// engine's presented memory (storage v16, D39.158) ended that — a map whose clusters were already +// shown does not stop again, whatever the flag says — so from that day the guard protected NOTHING: +// the sweep's restart marched straight through the paid pause with one WARN in the engine's own +// journal. What holds instead is the decision one level up, and it is the same rule the ceiling +// branch beside it already follows: an engine that ended ON PURPOSE is not restarted. +// +// Mutation caught: restarting a run whose status is `awaiting_bank` when its unit vanishes. +func TestTheReconcilerDoesNotRestartPastABankStopNobodySigned(t *testing.T) { f := newFixture(t, "10", 500) run, err := f.svc.Start(f.ctx, StartRequest{UserID: "u1", BookID: f.bookID(t), VerifyBank: true, CeilingChapters: 10}) @@ -609,22 +682,30 @@ func TestTheReconcilerDoesNotLiftABankStopNobodySigned(t *testing.T) { if err := f.svc.Sweep(f.ctx); err != nil { t.Fatal(err) } - var released bool var status string var attempts int + var finished *time.Time if err := f.store.Pool().QueryRow(f.ctx, - `select r.bank_released, r.status, (select count(*) from run_attempts where run_id = r.id) - from runs r where r.id = $1`, run.ID).Scan(&released, &status, &attempts); err != nil { + `select r.status, r.finished_at, (select count(*) from run_attempts where run_id = r.id) + from runs r where r.id = $1`, run.ID).Scan(&status, &finished, &attempts); err != nil { t.Fatal(err) } - // The fixture must actually reach the restart, or it observes nothing: this assertion is what - // caught the first version of this test, which passed under its own mutation. - if attempts < 2 { - t.Fatalf("the sweep did not restart the run at all (%d attempts): this fixture cannot observe the property", attempts) + // The sweep must have LOOKED at this run, or the test observes nothing: the same guard against a + // fixture that never reaches the decision which caught the first version of this test. + if !f.runner.askedAlive() { + t.Fatal("the sweep never reached this run: the fixture cannot observe the property") } - if released { - t.Errorf("the sweep lifted a signing stop the user never signed (status now %q)", status) + if attempts != 1 { + t.Errorf("the sweep restarted a run standing at the signing stop (%d attempts): the pause the user paid for was spent on a march through it", attempts) } + if status != "awaiting_bank" { + t.Errorf("the run left the signing stop as %q, want it still standing there for the user to answer", status) + } + if finished == nil { + t.Error("the run was left LIVE at a stop whose engine is gone: nothing ends it and nothing resumes it") + } + // Whatever the sweep did spawn, it carried the flag: after D39.158 the platform never masks it, + // and where the engine stops is the engine's decision. for i, spec := range f.runner.starts() { if !slices.Contains(spec.Args, "--verify-bank") { t.Errorf("attempt %d was spawned without the signing stop: %v", i+1, spec.Args) @@ -632,6 +713,91 @@ func TestTheReconcilerDoesNotLiftABankStopNobodySigned(t *testing.T) { } } +// THE PAID STOP SURVIVES THE TWO PATHS THAT REACH IT THROUGH THIS SWEEP'S OWN DRAIN, and both were +// open until this pack's adversarial pass measured them. +// +// The trap they share: the sweep's `LiveRun` is a by-value snapshot taken BEFORE the journal is +// drained, and the bank-stop event lands in that very drain — it writes `awaiting_bank` on the row +// and nothing the snapshot carries. A guard that asked the snapshot therefore asked a value that +// could not yet know, and the run was restarted past the pause the user paid for. After D39.158 that +// restart does not even re-stop: it carries `--verify-bank` and the engine's presented memory +// auto-continues over a map it has already shown, one WARN in the engine's own journal. +// +// Sub-test A — the unit VANISHED, and the stop arrived in this pass's drain. +// Sub-test B — a marker exists reading exit 5, an outside SIGTERM. That branch (`interruptedBySomeoneElse`) +// answers BEFORE `outcome` is ever consulted, so the rule written into `outcome` could not see it. +// +// Mutation caught: reading l.Status from the pre-drain snapshot; dropping the awaiting_bank exclusion +// from the interrupted-by-someone-else branch. +func TestABankStopArrivingInThisSweepsOwnDrainIsNotRestartedPast(t *testing.T) { + bankStop := func(t *testing.T, f *fixture) { + t.Helper() + body := hello(t, f) + `{"seq":2,"type":"bank_stop","data":{}}` + "\n" + if err := os.WriteFile(filepath.Join(f.workdir, ingest.JournalFile), []byte(body), 0o600); err != nil { + t.Fatal(err) + } + } + start := func(t *testing.T) (*fixture, string) { + t.Helper() + f := newFixture(t, "10", 500) + run, err := f.svc.Start(f.ctx, StartRequest{UserID: "u1", BookID: f.bookID(t), VerifyBank: true, + CeilingChapters: 10}) + if err != nil { + t.Fatal(err) + } + if err := f.svc.Spawn(f.ctx, run.ID); err != nil { + t.Fatal(err) + } + bankStop(t, f) + return f, run.ID + } + assertStandsAtTheStop := func(t *testing.T, f *fixture, runID string) { + t.Helper() + var status string + var attempts int + if err := f.store.Pool().QueryRow(f.ctx, + `select r.status, (select count(*) from run_attempts where run_id = r.id) + from runs r where r.id = $1`, runID).Scan(&status, &attempts); err != nil { + t.Fatal(err) + } + if attempts != 1 { + t.Errorf("the sweep opened attempt %d over a run standing at the signing stop: the pause the"+ + " user paid for was spent on a march through it", attempts) + } + if status != "awaiting_bank" { + t.Errorf("the run left the signing stop as %q, want it still standing there", status) + } + } + + t.Run("the unit vanished", func(t *testing.T) { + f, runID := start(t) + f.runner.alive = false + late := f.now.Add(2 * time.Hour) + f.svc.Now = func() time.Time { return late } + if err := f.svc.Sweep(f.ctx); err != nil { + t.Fatal(err) + } + assertStandsAtTheStop(t, f, runID) + }) + + t.Run("an outside signal left a marker", func(t *testing.T) { + f, runID := start(t) + f.runner.alive = false + live := f.live(t) + // Exit 5 with NO stop recorded by this platform: the shape interruptedBySomeoneElse exists for. + if err := runner.WriteMarker(f.svc.markerPath(runID, live.AttemptNo), + runner.Marker{Unit: live.UnitName, Result: "exit-code", Code: "exited", Status: "5"}); err != nil { + t.Fatal(err) + } + late := f.now.Add(2 * time.Hour) + f.svc.Now = func() time.Time { return late } + if err := f.svc.Sweep(f.ctx); err != nil { + t.Fatal(err) + } + assertStandsAtTheStop(t, f, runID) + }) +} + // `blocked` answers ONE question — why is this scale shorter than the account could afford — so it // names another book only when giving that book's hold back would lengthen the scale. A hold that // costs this book nothing must not send the user off to stop a run for no gain. @@ -1110,16 +1276,31 @@ func TestAStopDuringSettlementOutranksThePause(t *testing.T) { } } -// waveCounts reads what the projection actually folded out of the journal, per wave. +// waveShape reads what a progress line actually folds into the read model: the ETA it carried, and +// the SHAPE the engine announced with it — whether this book's pipeline has an editor, and which +// generation of that answer the book is on. // -// ⚠ It exists because `Book.Progress` left the wire in 0.3.0 and the sweep's own test was then -// weakened to counting FRAMES — which says a frame was emitted, not that the numbers behind it are -// right. The counters are still in the database; only the way to read them changed. -func (f *fixture) waveCounts(t *testing.T, runID string) (draft, edit int) { +// ⚠ It replaces `waveCounts`, which read `runs.draft_done/edit_done`. Those columns were dropped with +// register row PD-411 — two writers, no reader — so an assertion over them proved that a fold had +// happened, not that anything a client can see was right. The re-point is to the numbers this event +// STILL materialises, which is the honest remainder rather than a weakened claim: read the wrong +// field of the line and the shape comes out wrong, which is what the assertion catches. +func (f *fixture) waveShape(t *testing.T, bookID string) (editor *bool, epoch int) { t.Helper() if err := f.store.Pool().QueryRow(f.ctx, - `select draft_done, edit_done from runs where id = $1`, runID).Scan(&draft, &edit); err != nil { + `select epoch_editor, shape_epoch from books where id = $1`, bookID).Scan(&editor, &epoch); err != nil { t.Fatal(err) } - return draft, edit + return editor, epoch +} + +// runETA is the other half of what a progress line leaves behind. +func (f *fixture) runETA(t *testing.T, runID string) *int { + t.Helper() + var eta *int + if err := f.store.Pool().QueryRow(f.ctx, + `select eta_seconds from runs where id = $1`, runID).Scan(&eta); err != nil { + t.Fatal(err) + } + return eta } diff --git a/platform/internal/runs/reconcile.go b/platform/internal/runs/reconcile.go index d89ba94a..e71d5cde 100644 --- a/platform/internal/runs/reconcile.go +++ b/platform/internal/runs/reconcile.go @@ -133,10 +133,19 @@ func (s *Service) reconcileOne(ctx context.Context, l pgstore.LiveRun) { return } reason := "the run took its whole budget without finishing" - if err != nil { + blocked := errors.Is(err, errSettlementBlocked) + switch { + case blocked: + // ONE sentence for one fact, whichever phase reports it: the operator's table prints this + // under LAST ERROR, and «the settlement could not be computed» read from two phases in two + // different wordings would look like two different problems. + reason = settleReason(settlementBlocked, nil, false) + s.log().ErrorContext(ctx, "the run's settlement is blocked, so it cannot be restarted; it is deferred", + "run", l.RunID, "attempt", l.AttemptNo) + case err != nil: reason = err.Error() s.log().ErrorContext(ctx, "run could not be reconciled", "run", l.RunID, "err", err) - } else { + default: s.log().WarnContext(ctx, "the run took its whole budget without finishing; it is deferred", "run", l.RunID, "attempt", l.AttemptNo) } @@ -148,6 +157,13 @@ func (s *Service) reconcileOne(ctx context.Context, l pgstore.LiveRun) { // `settlementBackoffCap` was shortened to five minutes to prevent. The read costs one indexed // lookup and only on a failure. delay := backoff(l.ReconcileFailures + 1) + if blocked { + // The SETTLEMENT's schedule, not the live phase's, because it is the settlement that is stuck + // and the settlement's deferral is the USER's resume gate (settlementBackoffCap's own + // argument). Half an hour of live backoff would put their resume behind it for a blockage + // they can do nothing about. + delay = settlementDelay(l.ReconcileFailures) + } switch ended, qerr := s.Store.AttemptEnded(c, l.AttemptID); { case qerr != nil: s.log().ErrorContext(c, "could not tell whether the attempt had ended; deferring as a live run", @@ -325,6 +341,26 @@ func settleReason(verdict settleVerdict, err error, overran bool) string { return "the settlement could not be computed" } +// errSettlementBlocked is a RESTART that could not be attempted because the money of the previous +// attempt could not be closed. It is an error rather than a silent return, and that is the whole of +// register row PD-424. +// +// The chain it ends: a unit vanishes without a marker → `restart` → `settle` answers +// `settlementBlocked` WITHOUT an error → the old code threw the verdict away, walked into `reopen`, +// which refuses to open an attempt over an unclosed hold and answers `deferred` → `case deferred: +// return nil` → `reconcileOne` reads a successful pass and CLEARS the deferral. Every surface stayed +// clean: `reconcile_failures` never left 0, `reconcile_after` stayed NULL, `StalledRuns` was empty, +// the stalled gauge read 0 — while the hold stayed frozen and the engine was poked every fifteen +// seconds forever. Nothing but the user pressing Stop could end it, and nobody told them. +// +// The question the row said had to be answered first — what `deferred` MEANS for the counter — is +// answered here and only here: a settlement that cannot be COMPUTED is a failure of the phase that +// owns it, and it is counted by that phase. `reopen`'s own `deferred` verdict is left exactly as it +// was, because after this it can only be reached on a `settlementRaced` whose reservation is briefly +// open — self-correcting on the next pass, and the settlement phase argues in as many words that a +// race must not reach an operator's counter. +var errSettlementBlocked = errors.New("runs: the attempt's settlement could not be computed, so the run cannot be restarted") + // settleVerdict is what one pass over an attempt's money produced. Three states and not a boolean, // because the middle one must NOT reach the failure counter: see settleOne. type settleVerdict int @@ -472,11 +508,16 @@ func (s *Service) reconcile(ctx context.Context, l pgstore.LiveRun) (bool, error // readable branch buried exactly that case: a ceiling halt whose marker was corrupted came out // `failed` with no reason at all, on the one path where the exit code cannot say otherwise // either. - reason, rerr := s.freshPausedReason(ctx, l) + status, reason, rerr := s.freshRunState(ctx, l) if rerr != nil { return moved, rerr // nothing is closed on facts we could not establish; the next pass tries again } l.PausedReason = reason + if status != "" { + // The snapshot is pre-drain; the row is not. Everything below that reasons about WHERE this + // run stands has to reason about now. + l.Status = status + } if err != nil { // What the marker says cannot be read. Retrying is what the register row is about: the // write is atomic (temp+fsync+rename), so a marker that does not parse was changed by @@ -486,8 +527,16 @@ func (s *Service) reconcile(ctx context.Context, l pgstore.LiveRun) (bool, error "run", l.RunID, "attempt", l.AttemptNo, "err", err) return true, s.finish(ctx, l, runner.Marker{Unit: l.UnitName, Result: runner.UnreadableMarkerResult}) } - if interruptedBySomeoneElse(l, marker) { + if interruptedBySomeoneElse(l, marker) && l.Status != "awaiting_bank" { // Exit 5 is a caught SIGTERM, and nothing here recorded asking for one. See the function. + // + // ⚠ …unless the run is standing at the BANK-SIGNING STOP, and that exclusion is the same + // rule as the ceiling's one level down: an outside signal landing on a run that has already + // paused on purpose does not un-pause it. Without it this branch RESTARTS past the paid + // stop before `outcome` — the only other place that knows about the stop — is ever + // consulted, and after D39.158 that restart marches straight through the boundary on the + // engine's presented memory. Found by this pack's own adversarial pass, which reproduced it + // with a marker reading exit 5 on an `awaiting_bank` row. s.log().InfoContext(ctx, "the run was signalled by something that is not this platform's stop; restarting it", "run", l.RunID, "attempt", l.AttemptNo) return true, s.restart(ctx, l) @@ -536,10 +585,13 @@ func (s *Service) reconcile(ctx context.Context, l pgstore.LiveRun) (bool, error // just cancelled. The intent decides, because it is the one fact here that is ours. return true, s.finishStopped(ctx, l) } - reason, err := s.freshPausedReason(ctx, l) + status, reason, err := s.freshRunState(ctx, l) if err != nil { return moved, err // same rule as above: a restart is a decision, and this one is not established yet } + if status != "" { + l.Status = status + } if l.PausedReason = reason; l.PausedReason != "" { // The stream already said a ceiling stopped this run, and a ceiling halt is not an // interruption: the engine ended on purpose and the next process would meet the same limit. @@ -550,6 +602,24 @@ func (s *Service) reconcile(ctx context.Context, l pgstore.LiveRun) (bool, error "run", l.RunID, "attempt", l.AttemptNo) return true, s.finish(ctx, l, runner.Marker{Unit: l.UnitName, Result: runner.UnitVanishedResult}) } + if l.Status == "awaiting_bank" { + // The stream already said this run stopped at the BANK boundary, and a signing stop is not an + // interruption for exactly the reason the ceiling above is not: the engine ended on purpose, + // at a pause the user paid for and has not answered yet. Restarting here would spend a fresh + // hold on a march straight THROUGH that pause — after D39.158 the next attempt carries + // `--verify-bank` and the engine's presented memory (storage v16) auto-continues over a map it + // has already shown, one WARN in its own journal and nothing on any surface of ours. The stop + // the user paid for would simply cease to exist, which is unified-backlog row 240. + // + // ⚠ This is what protects the paid stop now; the platform's old guard did not. That guard + // (`LiftBankStop`, removed with the workaround) only decided which BIT the restart wrote, and + // the engine of the memory era stopped consulting the flag for this question — so keying the + // protection on the argv could not hold whatever the bit said. The protection belongs where + // the decision is: whether this platform restarts the run at all. + s.log().InfoContext(ctx, "the run stands at the bank-signing stop and its unit is gone; closing it there rather than restarting past a stop nobody has answered", + "run", l.RunID, "attempt", l.AttemptNo) + return true, s.finish(ctx, l, runner.Marker{Unit: l.UnitName, Result: runner.UnitVanishedResult}) + } return true, s.restart(ctx, l) } @@ -569,12 +639,24 @@ func (s *Service) reconcile(ctx context.Context, l pgstore.LiveRun) (bool, error // resumable ceiling halt into `failed` with no reason. "I could not ask" is not an answer here any // more than it is when systemd does not reply; the sweep logs it and the next pass reads the same // world. -func (s *Service) freshPausedReason(ctx context.Context, l pgstore.LiveRun) (string, error) { - reason, err := s.Store.RunPausedReason(ctx, l.RunID) +// +// ⚠ It re-reads the STATUS as well as the reason, and that is not a convenience. The bank-stop event +// writes `awaiting_bank` on the run row in the very drain this pass performs (pgstore/sink.go, +// TypeBankStop), and the snapshot is a by-value copy taken before it — so a guard that asked +// `l.Status` about the signing stop asked a value that could not yet know. Measured on the real +// journal by this pack's adversarial pass: after the drain the row read `awaiting_bank` while the +// snapshot still read `translating`, and the paid stop was restarted past anyway. The two facts are +// re-read together because they are re-read for the same reason and on the same branches. +func (s *Service) freshRunState(ctx context.Context, l pgstore.LiveRun) (status, reason string, err error) { + status, reason, err = s.Store.RunPausedReason(ctx, l.RunID) if err != nil { - return "", fmt.Errorf("runs: the run's paused reason could not be re-read after the drain: %w", err) + // ⚠ The wording keeps "paused reason" deliberately: `seam_test.TestAnEndingIsNeverDecidedFromAReadThatFailed` + // matches on it, and that pin is about the PROPERTY — an ending is never decided past a read + // that failed — which this function still has. Renaming the function was this pack's business; + // silently retiring somebody else's assertion is not. + return "", "", fmt.Errorf("runs: the run's status and paused reason could not be re-read after the drain: %w", err) } - return reason, nil + return status, reason, nil } // finishStopped closes a run whose end this platform asked for and whose unit left no marker to read: @@ -714,10 +796,13 @@ func (s *Service) maybeResync(ctx context.Context, l pgstore.LiveRun) (bool, err // whose cursor has never moved, or one whose materialization was quarantined and for which this // is now the only source. // - // ⚠ This gate used to carry a second job that it no longer has to: before the engine reported the - // per-wave split (row 99, landed D39.122) a resync could only fold ONE aggregate over the two - // counters the stream keeps apart, which is how a live run showed "edit 10/10" seconds after its - // journal said "edit 0/10". ApplyStatus now materializes the same four counters as the stream. + // ⚠ What a resync DOES materialise is narrower than this comment used to claim, and the claim is + // corrected rather than inherited: "ApplyStatus now materializes the same four counters as the + // stream" was true of four columns nothing ever read (PD-411, dropped in migration 00031). The + // bar is derived from `chapters`, which this channel does not write — so a run whose stream is + // quarantined keeps the progress its stream last delivered, however faithfully the engine answers + // here. The repair channel repairs the ETA, the freshness stamp and the wave shape. That gap has + // a register row of its own. if l.Position.LastSeq > 0 && !l.Quarantined { return false, nil } @@ -878,6 +963,16 @@ func outcome(l pgstore.LiveRun, m runner.Marker) (status, pausedReason string, e // have been killed on its way out, and the fact does not become less true. return "paused", l.PausedReason, nil } + if l.Status == "awaiting_bank" { + // Same rule, same reason, for the bank-signing stop: the journal's bank-stop event moved this + // run to the pause BEFORE any marker was read, and a process killed on its way out of that + // pause does not un-pause it. Without this the run would be recorded `failed`/`interrupted` + // and the paid stop would be answered with a retry — the very march past the boundary that + // unified-backlog row 240 names. It sits AFTER the engine's own endings (a clean exit above + // wins: a run that finished by itself is not waiting for a signature) and after the user's + // own stop, whose word is ours and outranks a pause we are only reporting. + return "awaiting_bank", "", exitCodeOrNil(code, exited) + } if exited && ingest.OutcomeOf(code) == ingest.OutcomeCeiling { // No event reached us — the branch above would have taken it — so WHICH ceiling stopped the // run is genuinely unknown here, and that is what gets recorded. It is not a rare corner: a @@ -1127,8 +1222,24 @@ func (s *Service) settle(ctx context.Context, l pgstore.LiveRun) (settleVerdict, // spend and the interrupted attempt moved it (D39.122, and meter.bookCap for why the reserved figure // is deliberately not in that sum). func (s *Service) restart(ctx context.Context, l pgstore.LiveRun) error { - if _, err := s.settle(ctx, l); err != nil { + switch v, err := s.settle(ctx, l); { + case err != nil: return err + case v == settlementBlocked: + // ⚠ Returned HERE rather than fallen through, and the early return is not tidiness (PD-424). + // `reopen`'s first act is to ask whether the previous attempt's reservation is still open, and + // in this state the answer is guaranteed to be yes — so the old path paid for a query to learn + // what `settle` had just said, then logged a WARN blaming the RESTART for a blockage that + // belongs to the settlement, and finally answered `nil`, which the caller counts as a + // successful pass. + // + // A shutdown is not a failure: the same guard the settlement phase carries (settleOne). + // Stopping the daemon cancels every item in flight, and a count a SHUTDOWN invented is the + // exact defect that phase found and named. + if ctx.Err() == nil { + return errSettlementBlocked + } + return nil } next, v, err := s.reopen(ctx, l, fromALiveRun) if errors.Is(err, pgstore.ErrStopRequested) { @@ -1293,15 +1404,7 @@ func (s *Service) reopen(ctx context.Context, l pgstore.LiveRun, from liveness) 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), - // A run re-opening OUT of the signing stop clears it in the same transaction: the bit decides - // which pass the wire's bar counts, and a separate write could half-land. - // - // ⚠ Only the USER's resume may clear it. A bank stop writes `awaiting_bank` without - // `finished_at`, so the run stays in the reconciler's list; keyed on the status alone, a - // restart of that run — a reboot that lost the exit marker, an outside SIGTERM — lifted a stop - // nobody signed and spawned the rest of the ceiling without `--verify-bank`. - LiftBankStop: from == fromAFinishedRun && l.Status == "awaiting_bank", + OnlyIfLive: bool(from), UserID: l.UserID, BookID: l.BookID, Ceiling: remaining, diff --git a/platform/internal/runs/reconcile_test.go b/platform/internal/runs/reconcile_test.go index 12184766..337d53e1 100644 --- a/platform/internal/runs/reconcile_test.go +++ b/platform/internal/runs/reconcile_test.go @@ -31,6 +31,10 @@ type fakeRunner struct { alive bool aliveErr error startErr error + // aliveCalls counts the "is the unit still there" questions. A test whose property is that the + // sweep DID NOT act needs to know the sweep reached the run at all — otherwise a fixture that + // never got that far reads as a passing assertion. + aliveCalls int // onStop runs INSIDE the fake's Stop, which is what makes the ORDER of "write the intent, then // ask systemd" assertable: by the time this runs, the database must already know. onStop func(unit string) @@ -63,7 +67,18 @@ func (f *fakeRunner) stops() []string { return append([]string(nil), f.stopped...) } -func (f *fakeRunner) Alive(context.Context, string) (bool, error) { return f.alive, f.aliveErr } +func (f *fakeRunner) Alive(context.Context, string) (bool, error) { + f.mu.Lock() + f.aliveCalls++ + f.mu.Unlock() + return f.alive, f.aliveErr +} + +func (f *fakeRunner) askedAlive() bool { + f.mu.Lock() + defer f.mu.Unlock() + return f.aliveCalls > 0 +} func (f *fakeRunner) starts() []runner.Spec { f.mu.Lock() @@ -695,16 +710,22 @@ func (f *fixture) owed(t *testing.T) []pgstore.OwedBook { return owed } -// A resumed run is spawned WITHOUT the engine's signing flag. +// A resumed run is spawned WITH the flag it was started with: this platform does not decide where +// the engine stops. // -// `--verify-bank` is what halts the engine at the bank boundary, and it halts there whenever -// undecided terms remain. Re-passing it put the run straight back into the stop it had just been -// released from, so a signature moved nothing. Dropping it takes the engine's auto path, which is -// the owner's model: the run continues and undecided rows ride into the bank marked ⟨проверить⟩ -// (unified backlog row 191). +// ⚠ RE-SIGNED by pack P12 (order D39.158, ping #21), and this is a commissioned change of contract, +// not a test bent to a green. Its predecessor pinned the OPPOSITE — that a resumed attempt drops +// `--verify-bank` — and it was right for the engine of its day: that engine halted wherever undecided +// terms remained, so re-passing the flag put the run straight back into the stop it had just been +// released from, and the platform masked it to get past. The engine now carries a presented memory +// (storage schema v16): a stop fires only on a cluster no earlier stop has SHOWN, so the same flag +// on a resumed attempt auto-continues at that same boundary by the engine's own predicate, and the +// undecided rows still ride to the editor marked ⟨проверить⟩ (unified backlog row 191). Two answers +// to one question is what the law of the seam forbids; the answer is the engine's. // -// Mutation caught: passing l.VerifyBank unconditionally. -func TestAResumedRunIsSpawnedWithoutTheSigningStop(t *testing.T) { +// Mutation caught: masking l.VerifyBank on a resumed attempt — the removed workaround, in any of its +// forms. +func TestAResumedRunIsSpawnedWithTheFlagItStartedWith(t *testing.T) { 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") @@ -728,7 +749,7 @@ func TestAResumedRunIsSpawnedWithoutTheSigningStop(t *testing.T) { if len(after) != 2 { t.Fatalf("%d units started, want the resumed attempt to be the second", len(after)) } - if slices.Contains(after[1].Args, "--verify-bank") { - t.Errorf("the resumed attempt carries the signing stop again: %v", after[1].Args) + if !slices.Contains(after[1].Args, "--verify-bank") { + t.Errorf("the resumed attempt was spawned without the flag its run asked for: %v", after[1].Args) } } diff --git a/platform/internal/runs/runs.go b/platform/internal/runs/runs.go index 8037b6be..b6c517e3 100644 --- a/platform/internal/runs/runs.go +++ b/platform/internal/runs/runs.go @@ -268,6 +268,30 @@ func (s *Service) Start(ctx context.Context, in StartRequest) (pgstore.Run, erro // account's money had already been held for it. return pgstore.Run{}, fmt.Errorf("%w: it is %s", ErrBookNotReady, book.Status) } + if book.ChapterCount > 0 && !book.HasTree { + // The book DECLARES chapters and its tree has not been materialised: the intake committed + // `not_started` and the materialisation that follows it — in a separate transaction, outside + // the intake's — failed or has not run. Refused HERE, before the hold, because there is no + // honest bar for such a run and no way out of it either (PD-405). Every counter the screen + // shows is a count over `chapters`, so the run would read 0/total for its entire life while + // spending; and the tree's own debt is FROZEN while it runs, because the sweep that pays that + // debt skips a book with a live run. The window is the intake's normal one — measured at + // 0.018 s on the healthy path — and unbounded exactly when the materialisation broke, which is + // the only shape worth refusing. + // + // `book_not_ready` is the word for it, not a new one: the canon's own gloss is "still + // arriving, still being cut, or was rejected", and a book that owes its tree is still being + // cut. The remedy is to wait for the intake, which is what the status says. + // + // ⚠ ONE population does not clear by waiting, and the operator's handle for it exists: a book + // whose reading-surface debt was WRITTEN OFF after its attempts (AbandonReadModelDebt) owes + // nothing, so no sweep will build its tree and this refusal stands until somebody asks again — + // `tmplatformctl book refresh --book `, which is exactly what that command is for, and the + // books behind it are listed by `tmplatformctl books --abandoned`. Named here because this + // guard is what turns a book that merely showed a frozen bar into one that cannot be started: + // the refusal is the honest half, and being able to find the remedy is the other. + return pgstore.Run{}, fmt.Errorf("%w: its chapters are still being materialised", ErrBookNotReady) + } if book.HasLiveRun { return pgstore.Run{}, pgstore.ErrRunInFlight } diff --git a/platform/internal/runs/spawn.go b/platform/internal/runs/spawn.go index b06994dd..b1a0434f 100644 --- a/platform/internal/runs/spawn.go +++ b/platform/internal/runs/spawn.go @@ -156,18 +156,16 @@ func (s *Service) spec(l pgstore.LiveRun, bookCap money.MicroUSD) (runner.Spec, // The path this ATTEMPT is pinned to, which for a resume is the one the run started with // (row 139). Falling back to the configured path is for the first attempt, which has none yet. Binary: s.engineBinary(l), - // ⚠ `--verify-bank` is NOT passed again once the stop has been lifted. The reason is the - // owner's model, not the engine's old re-halt behaviour: the stop exists to present what is - // NEW, and after a lift this run has nothing new to present — the presentation memory (v16) - // covers the map's clusters, so even a re-passed flag would auto-continue at the same - // boundary. (An earlier edition of this comment quoted the D39.144-era contract — «the stop - // clears once every proposed term is promoted or rejected» — which the memory made false, - // and the reconciler's LiftBankStop guard built on that reading is what the P9 workflow - // review found leaking; the basis is corrected here rather than inherited again.) Dropping - // the flag takes the - // engine's auto path (pipeline/mining.go, D39.42 п.3): the run continues and undecided - // rows ride into the bank marked ⟨проверить⟩ (unified backlog row 191). - Args: runner.TranslateArgs(l.Workdir, l.VerifyBank && !l.BankReleased, s.Cfg.KeysFile, + // ⚠ `--verify-bank` rides EVERY attempt of a run that asked for it, resumes included, and the + // platform no longer decides where the engine stops. That decision moved into the engine with + // its presented memory (storage schema v16, D39.158): a stop fires only on a cluster no + // earlier stop has shown, so a resumed attempt carrying the flag auto-continues at the same + // boundary by the engine's own predicate and its undecided rows ride to the editor marked + // ⟨проверить⟩ (backend/internal/pipeline/mining.go, D39.42 п.3). Masking the flag here was + // this platform's stand-in for that predicate while the engine still re-halted on everything + // undecided; keeping the stand-in after the engine grew the memory would mean two answers to + // one question, and the law of the seam gives it to the engine. + Args: runner.TranslateArgs(l.Workdir, l.VerifyBank, s.Cfg.KeysFile, l.Resnapshot, l.AcceptRebill, ceiling), Workdir: l.Workdir, Env: engineEnv(engineStreamID(l.RunID, l.AttemptNo)), diff --git a/platform/internal/runs/stuck_settlement_test.go b/platform/internal/runs/stuck_settlement_test.go index d5cb1ce8..23b639ea 100644 --- a/platform/internal/runs/stuck_settlement_test.go +++ b/platform/internal/runs/stuck_settlement_test.go @@ -109,6 +109,101 @@ func TestARunWhoseSettlementIsStuckReachesTheOperatorsSurfaces(t *testing.T) { _ = clock } +// THE LIVE HALF of the same disease, which pack P11 did not reach (register row PD-424): a run that +// is still LIVE and whose settlement is permanently blocked. +// +// The chain, reproduced rather than written by hand: the unit vanishes without a marker → the sweep +// calls `restart` → `settle` cannot compute the money and answers `settlementBlocked` WITHOUT an +// error → the old code discarded that verdict and walked into `reopen`, which refuses to open an +// attempt over an unclosed hold and answers `deferred` → `case deferred: return nil` → the pass reads +// as a SUCCESS and even clears the deferral. Every surface stayed clean over a frozen hold that the +// engine was poked about every fifteen seconds, forever, and the only way out was the user pressing +// Stop — which nobody told them. +// +// The design question the row said had to be answered first is answered by this test's expectations: +// a settlement that cannot be COMPUTED is a failure of the phase that owns it, it is COUNTED, and the +// count carries the run to the operator's surfaces like any other stall. +// +// Mutations caught: discarding settle's verdict in `restart`; clearing the deferral on a pass that +// could not restart; deferring a blocked settlement on the LIVE phase's half-hour backoff instead of +// the settlement cap, which would put the user's own resume behind it. +func TestALiveRunWhoseSettlementIsBlockedIsCountedAndReachesTheOperator(t *testing.T) { + f := newFixture(t, "10", 500) + run, err := f.svc.Start(f.ctx, StartRequest{UserID: "u1", BookID: f.bookID(t), CeilingChapters: 10}) + if err != nil { + t.Fatal(err) + } + if err := f.svc.Spawn(f.ctx, run.ID); err != nil { + t.Fatal(err) + } + // The unit is gone and left NO marker — a reboot, or a kill from outside — so the run is still + // live and the reconciler's remedy is a restart. + f.runner.alive = false + // …and the engine cannot give a committed figure, so the money of the attempt being closed cannot + // be computed. Absent is not zero (PD-40). + f.engine.set(ingest.StatusReport{TotalUnits: 10, Done: 10}, nil) + + clock := f.now + for i := 0; i <= StalledAfter; i++ { + clock = f.now.Add(time.Duration(i+1) * time.Hour) + f.svc.Now = func() time.Time { return clock } + if err := f.svc.Sweep(f.ctx); err != nil { + t.Fatal(err) + } + } + a := f.lastAttempt(t, run.ID) + if a.ReconcileFailures < StalledAfter { + t.Fatalf("after %d passes over a blocked settlement the count is %d, want at least %d: the"+ + " passes are being read as successful, so the hold stays frozen and no surface ever says so", + StalledAfter+1, a.ReconcileFailures, StalledAfter) + } + // The run is STILL LIVE — this is the population P11 did not reach — and it is now visible. + live := f.live(t) + if live.RunID != run.ID { + t.Fatalf("the run is no longer live: %+v", live) + } + stalled, err := f.store.StalledRuns(f.ctx, StalledAfter) + if err != nil { + t.Fatal(err) + } + if len(stalled) != 1 || stalled[0].RunID != run.ID { + t.Fatalf("`runs --stalled` shows %+v, want the live run whose settlement is blocked", stalled) + } + if stalled[0].HeldMicroUSD == 0 { + t.Error("the row shows no hold, and the frozen hold is the whole reason it is worth printing") + } + // It is the LIVE half, and the row says so. That matters for the remedy an operator reaches for: + // a `settling` row's engine is already gone, this one's run is still the live row of its book — + // and it also says PD-418's state is not what this fix makes reachable (that one needs a + // PREVIOUS attempt that ended unsettled, which `reopen` still refuses to create). + if stalled[0].Settling { + t.Error("the row calls itself a settling one: this is a LIVE run whose restart is blocked, and" + + " an operator sent to the settling remedy would be looking for an engine that is still there") + } + obs, err := f.store.Observe(f.ctx, StalledAfter) + if err != nil { + t.Fatal(err) + } + if obs.StalledRuns != 1 { + t.Errorf("tm_platform_runs_stalled reads %d, want 1: nothing pages an operator over a live"+ + " run that cannot be restarted", obs.StalledRuns) + } + // The deferral is on the SETTLEMENT's schedule. The live phase doubles to half an hour, and the + // open reservation is the user's own resume gate — so a blockage they can do nothing about must + // not also hold their resume for thirty minutes. + var after *time.Time + if err := f.store.Pool().QueryRow(f.ctx, + `select reconcile_after from run_attempts where id = $1`, a.AttemptID).Scan(&after); err != nil { + t.Fatal(err) + } + if after == nil { + t.Fatal("the run was not deferred at all: it is retried on every pass forever") + } + if wait := after.Sub(clock); wait > settlementBackoffCap { + t.Errorf("the next look at this run is in %s, past the settlement cap of %s", wait, settlementBackoffCap) + } +} + // A settlement that CLOSED is not in the plain operator table, which is the open-reservation half of // the settling predicate. // diff --git a/platform/internal/runs/sweep_test.go b/platform/internal/runs/sweep_test.go index b02b94f4..a09b1f0d 100644 --- a/platform/internal/runs/sweep_test.go +++ b/platform/internal/runs/sweep_test.go @@ -101,8 +101,22 @@ func newFixture(t *testing.T, balance string, chapters int) *fixture { t.Fatal(err) } workdir := t.TempDir() - if _, err := store.AddBook(ctx, pgstore.NewBook{OwnerID: "u1", Title: "蛊真人", SourceLang: "zh", - TargetLang: "ru", ChapterCount: chapters, Workdir: workdir, Now: now}); err != nil { + book, err := store.AddBook(ctx, pgstore.NewBook{OwnerID: "u1", Title: "蛊真人", SourceLang: "zh", + TargetLang: "ru", ChapterCount: chapters, Workdir: workdir, Now: now}) + if err != nil { + t.Fatal(err) + } + // ⚠ The book gets its chapter TREE, and the fixture was extended rather than the guard relaxed + // (PD-405). Before this it declared N chapters and materialised none — the exact state a run may + // no longer be started over, because every counter the screen shows is a count over `chapters` + // and a run admitted there reads 0/total for its whole life while spending. The whole runs battery + // was riding that state, which is why the guard's arrival is what surfaced it. + // + // The rows are EMPTY (nothing drafted, nothing edited), which is what the fixture already meant: + // with no rows at all every derived counter was zero too, so no assertion here changes. + if _, err := store.Pool().Exec(ctx, ` + insert into chapters (id, book_id, number, units_total) + select 'c' || g, $1, g, 1 from generate_series(1, $2) g`, book, chapters); err != nil { t.Fatal(err) } model, err := pricing.New(pricing.DefaultPerChapter) @@ -376,7 +390,7 @@ func TestTheSweepMaterializesWhateverTheJournalHasGained(t *testing.T) { f.runner.alive = true journal := filepath.Join(f.workdir, ingest.JournalFile) body := hello(t, f) + - `{"seq":2,"type":"progress","data":{"draft":{"done":7,"total":20},"edit":{"done":1,"total":20}}}` + "\n" + `{"seq":2,"type":"progress","data":{"draft":{"done":7,"total":20},"edit":{"done":1,"total":20},"eta_seconds":42}}` + "\n" if err := os.WriteFile(journal, []byte(body), 0o600); err != nil { t.Fatal(err) } @@ -390,9 +404,15 @@ func TestTheSweepMaterializesWhateverTheJournalHasGained(t *testing.T) { t.Fatalf("the progress line materialized no frame") } // ⚠ And the NUMBERS behind that frame. Counting frames only says one was emitted; the projection - // folding the journal wrong emits exactly the same frame. - if draft, edit := f.waveCounts(t, run.ID); draft != 7 || edit != 1 { - t.Fatalf("the journal folded to draft=%d edit=%d, want 7 and 1", draft, edit) + // folding the journal wrong emits exactly the same frame. What a progress line materialises is the + // ETA it carried and the SHAPE it announced — the per-wave counters it also used to write had no + // reader and went with PD-411 — so those are what is asserted: read the wrong field of the line + // and both come out wrong. + if eta := f.runETA(t, run.ID); eta == nil || *eta != 42 { + t.Fatalf("the journal's eta folded to %v, want 42", eta) + } + if editor, _ := f.waveShape(t, f.bookID(t)); editor == nil || !*editor { + t.Fatalf("the line announced an edit wave of 20 and the book's shape folded to %v", editor) } live := f.live(t) if live.EngineRunID != engineStreamID(run.ID, 1) || live.Position.LastSeq != 2 || live.Position.Offset != int64(len(body)) { @@ -406,8 +426,8 @@ func TestTheSweepMaterializesWhateverTheJournalHasGained(t *testing.T) { if n := framesOfKind(t, f, pgstore.FrameProgress); n != 1 { t.Errorf("a second sweep re-materialized the line: %d progress frames", n) } - if draft, _ := f.waveCounts(t, run.ID); draft != 7 { - t.Errorf("a second sweep changed the projection: draft=%d, want 7", draft) + if _, epoch := f.waveShape(t, f.bookID(t)); epoch != 0 { + t.Errorf("a second sweep crossed a shape boundary that was never crossed: epoch=%d, want 0", epoch) } }