diff --git a/platform/BACKLOG.md b/platform/BACKLOG.md index 6aabb4a6..ccea2207 100644 --- a/platform/BACKLOG.md +++ b/platform/BACKLOG.md @@ -29,8 +29,8 @@ | П-15 | **ИСПОЛНЕНО P6 (14.08).** Платформенная половина шва эмиттера (движковая залендена D39.131, `9cfe080`) | ИСПОЛНЕНО P6: коды выхода движка 4/5/10–19 через `ingest.OutcomeOf`, потолок = `paused` двумя каналами (PD-113 закрыт), интейк судит по коду (PD-196 закрыт), exit 5 без намерения = прерывание и перезапуск (PD-152 закрыт); фолд `unit_done` ПРИСВАИВАНИЕМ по тройке (chapter, unit, wave) через `unit_resolutions` (миграция 00015; ратифицировано D39.131 п.2г, at-least-once); StreamVersion 1.1, `Ceiling.Scope` (book/day), внутренняя причина `daily_ceiling`, резюм дневного потолка = 409 (PD-157 половина); dev-супервизор читает ту же таблицу; foreign-hello adoption воспроизведён и закрыт (PD-200); порядок деплоя — `deploy/README.md` + `tmplatformctl books --migratable`. ⚠ Граница присваивания (PD-219): оно самолечит ПОВТОРНУЮ доставку, а не пропущенную — недодрейненный хвост новая попытка не перечитывает, её курсор начинается с размера журнала на допуске | приёмка D39.131 | | П-14 | **ИСПОЛНЕНО P6 (14.08).** Стройка интейка формы Б (ратификация D39.130) | ИСПОЛНЕНО P6: рендер в ОДНОМ месте (`books.provision`); правила шаблона и его отказов — `deploy/README.md` §«Шаблон книги», требования к полям — `backend/internal/config/book.go` как справочник. При приходе формы В (строка 170 единого, `tmctl init`) рендер заменяется вызовом движка. Проверено настоящим `tmctl manifest` (3 главы) | ратификация D39.130 (приёмка P5) | | П-16 | **ИСПОЛНЕНО P6 (14.08).** Дев-сид стенда: тестовый пользователь, чтобы фронт разрабатывался на живой платформе, а не на своих моках (слово владельца 14.08). Границы: фикстуры фронта остаются батарее его гейтов (детерминированные состояния — не работа стенда); полный снос `frontend/src/mock/` — триггер Ф-29, после читающей поверхности | ИСПОЛНЕНО P6: `tmplatformctl seed` и дев-вход `TM_PLATFORM_DEV_LOGIN` с четырьмя гардами непроходимости в проде — рецепт и разбор гардов в `deploy/README.md` §«Дев-стенд». Прогнано на стенде целиком | владелец 14.08 | -| П-17 | **ИСПОЛНЕНО P7 (16–17.08).** Читающая поверхность — остаток П-1, названный отдельной строкой | ИСПОЛНЕНО P7 по канону 0.3.0; состав поверхности — `platform/README.md` §«Что здесь будет» и §«Карта зоны» (`internal/readmodel`). ⚠ `submitBankDecisions` СНЯТ 22.08 вместе с пер-термной моделью подписи — D39.144, слово владельца; см. PD-370. **НЕ вошло и отложено в P8:** `updateBook`/`deleteBook`/`getRun`, экспорт (`createExport`/`getExport`), эскроу (П-18). ⚠ **ЭКСПОРТ ПОСТРОЕН 04.09** паком «закрыть цикл» — `createExport`/`getExport` плюс третий адрес `.../content`, который канон описывает словами (`Export.url`), а `operationId` не даёт; смонтировано 18 маршрутов. Остаются непостроенными `updateBook` · `deleteBook` · `getRun` (и `deleteBook` теперь стоит дороже: у книги появились АРТЕФАКТЫ на диске, и удаление обязано снимать и строки `exports`, и файлы — каскад по строкам есть, по файлам нет) | П-1, D39.84/85, контракт 14 | +| П-17 | **ИСПОЛНЕНО P7 (16–17.08).** Читающая поверхность — остаток П-1, названный отдельной строкой | ИСПОЛНЕНО P7 по канону 0.3.0; состав поверхности — `platform/README.md` §«Что здесь будет» и §«Карта зоны» (`internal/readmodel`). ⚠ `submitBankDecisions` СНЯТ 22.08 вместе с пер-термной моделью подписи — D39.144, слово владельца; см. PD-370. **НЕ вошло и отложено в P8:** `updateBook`/`deleteBook`/`getRun`, экспорт (`createExport`/`getExport`), эскроу (П-18). ⚠ **ЭКСПОРТ ПОСТРОЕН 04.09** паком «закрыть цикл» — `createExport`/`getExport` плюс третий адрес `.../content` (канон дал ему `operationId` `downloadExport` минором `0.10.0` — `58bca19`, `D39.194` п.3); смонтировано 18 маршрутов. Остаются непостроенными `updateBook` · `deleteBook` · `getRun` (и `deleteBook` теперь стоит дороже: у книги появились АРТЕФАКТЫ на диске, и удаление обязано снимать и строки `exports`, и файлы — каскад по строкам есть, по файлам нет) | П-1, D39.84/85, контракт 14 | | П-18 | **Эскроу денег шва: write-ahead intent · `uncertain` · `closing`** (строка 136 единого бэклога) — денежный промт, сознательно НЕ взятый ни P5, ни P6. Здесь же закрывается PD-154 (`settled_at` между `Settle` и `MarkSettled`): половина эскроу рядом с проектируемым целым — второй, более слабый ответ на тот же вопрос | денежный промт | строка 136, PD-154 | | П-19 | **Конверсия свободного от склейки блока под `sqlc`** — ⚠ **ИСПОЛНЕНО отдельной сессией 29.08 и заленджено (D39.172; `PD-44` закрыт).** Отдельной сессией, а не внутри пака, — решение владельца 22.08: изменений много, мешать их с чем-либо нельзя. Что построено (пин v1.31.1, `sqlc.yaml`, генерённый код в дереве, два гейта актуальности, override `*.*_micro_usd` на деньги) — `docs/STACK_DECISIONS.md`, строка «Кодоген SQL». ⚠ Живая ГРАНИЦА набора, которую стоит помнить при следующем касании: **42 места вызова / 41 текст SQL / 40 конвертируемых**; `observe.go` структурно не конвертируется — River мигрирует `river_job` сам, и внесение чужой схемы в конфиг завело бы ВТОРОЙ её носитель (испр. D39.172), поэтому худший позиционный дрейф набора — семь `int64` подряд — остался рукописным. Остальные 25 склеенных мест недостижимы по построению (`lastRun` — девять потребителей, `nextRevisionOfThisBooksLibrary` — восемь), и это ровно та часть, где рантайм-ошибки и случались. ⚠ Пак покупал типизированные скан-структуры и раннюю обратную связь, а НЕ корректность: класс «нет такой колонки» закрыт постоянным гейтом `TestEverySQLStatementParsesAgainstTheMigratedSchema` (пол — 140 операторов; после конверсии видит 172 — числа в `PD-44`) | ИСПОЛНЕНО (D39.172) | PD-44, фикс-лист приёмки P7 п. 4, решения владельца 20 и 22.08 | | П-20 | **Хвост PD-162 после P8-FIX: холд прогона, чей движок не ответит НИКОГДА, возвращается только рукой.** Пак дал оператору и диагноз (`tmplatformctl runs --stalled`, гейдж `tm_platform_runs_stalled`), и терминальный вердикт (`run abandon [--release-hold]`), и это сознательная граница: автоматически решать про деньги прогона, о котором нельзя спросить, — та же ошибка «я не смог спросить = его нет», только со счётчиком впереди. Автоматический ответ на этот вопрос и есть эскроу — write-ahead intent · `uncertain` · `closing` (строка 136 единого бэклога, П-18). Пока эскроу нет, ручка остаётся ручной, и это записано, а не подразумевается | вместе с П-18 | P8-FIX | -| П-21 | **Ключ идемпотентности под конкуренцией даёт 500 (PD-369).** ⚠ Не работа пака P8-FIX и файл им не тронут — найдено попутно, прогоном батареи: собственный тест `TestAClaimThatLostARaceToAReleaseIsRetriedAndNotAnError` ФЛЕЙКОВЫЙ, 1–2 падения на ~80 прогонов. Причина не в тесте: `ClaimIdempotency` ретраит проигранную гонку ровно `claimRounds = 3` раза, а восемь одновременных попыток одного ключа, каждая из которых сразу отдаёт ключ назад (как делает любой 4xx), могут отобрать гонку у одного проигравшего трижды подряд — и он получает внутреннюю ошибку вместо одного из четырёх контрактных ответов. Направление, а не решение: граница по ВРЕМЕНИ вместо числа раундов либо `ErrKeyInFlight` при исчерпании — это решение о контрактном поведении | до первого чужого пользователя | попутная находка сессии P8-FIX | +| П-21 | **Ключ идемпотентности под конкуренцией даёт 500 (PD-369).** ⚠ Не работа пака P8-FIX и файл им не тронут — найдено попутно, прогоном батареи: собственный тест `TestAClaimThatLostARaceToAReleaseIsRetriedAndNotAnError` ФЛЕЙКОВЫЙ, 1–2 падения на ~80 прогонов. Причина не в тесте: `ClaimIdempotency` ретраит проигранную гонку ровно `claimRounds = 3` раза, а восемь одновременных попыток одного ключа, каждая из которых сразу отдаёт ключ назад (как делает любой 4xx), могут отобрать гонку у одного проигравшего трижды подряд — и он получает внутреннюю ошибку вместо одного из четырёх контрактных ответов. ⚠ **ЗАКРЫТО паком P12** — второй из двух названных форм, ответом, а не границей по времени: `ErrKeyContended` оборачивает `ErrKeyInFlight` (`internal/pgstore/idempotency.go:92`), словарь кодов контракта не расширен, исчерпание раундов стало контрактным ответом вместо `500`. Регистр — `PD-369` = `fixed(пак P12)`. Строка держалась развилкой после того, как развилка была разрешена кодом | ИСПОЛНЕНО (пак P12) | попутная находка сессии P8-FIX | diff --git a/platform/README.md b/platform/README.md index 89387d6d..8c05d696 100644 --- a/platform/README.md +++ b/platform/README.md @@ -1,7 +1,7 @@ # platform — control plane (SaaS-слой) Зона записи сессии «Платформа». Что построено и каким паком — секция «Что здесь будет» ниже и -шапка «Текущее состояние» зонного журнала. Направление зоны — `docs/PLATFORM_DIRECTION.md`, +ВЕРХ зонного журнала: он обратно-хронологический, свежее — выше (раздел «Состояние эры P8» в конце файла — ИСТОРИЯ, не состояние). Направление зоны — `docs/PLATFORM_DIRECTION.md`, критерии приёмки — `docs/ENGINEERING_STANDARDS.md`, дефекты — `docs/DEFECT_REGISTER.md`, зонный журнал — `docs/platform-PROGRESS.md` (весь прогресс зоны здесь, решение владельца 04.08), стек — `docs/STACK_DECISIONS.md`. @@ -45,7 +45,7 @@ Prometheus; на контрактную поверхность они не вы | Вопрос | Ответ лежит здесь | |---|---| -| что сделано последним паком и зачем | `docs/platform-PROGRESS.md`, шапка «Текущее состояние» | +| что сделано последним паком и зачем | `docs/platform-PROGRESS.md`, **ВЕРХ файла** — журнал обратно-хронологический, свежее выше; отчёт последнего пака — в верхней трети, ниже могут стоять более свежие записи смены | | статус конкретного дефекта | `docs/DEFECT_REGISTER.md` (источник истины; счёт — `python3 docs/scripts/counts.py` от корня репозитория) | | правила, которые переживают пак и не выводятся из одной функции | `docs/STACK_DECISIONS.md` §22 (порядок блокировок), §33–36 (долг материализации, форма пайплайна, атомарность) | | что зона обязана уметь и по какой норме | `docs/PLATFORM_DIRECTION.md`, `docs/ENGINEERING_STANDARDS.md` | @@ -95,7 +95,7 @@ Prometheus; на контрактную поверхность они не вы реконсилятор, который читает мир (Postgres · журнал книги · маркер выхода) и не ждёт процесса; - читающая поверхность контракта — **есть (P7)**: дерево глав и пары с текстом, замечания, ЧТЕНИЕ банка, `GET /capabilities`, машинная модель ошибок (`code` + `request_id`), условные чтения - (`ETag`/304) и сжатие JSON, `Idempotency-Key` на двух создающих вызовах. ⚠ Снята ПЕР-ТЕРМНАЯ + (`ETag`/304) и сжатие JSON, `Idempotency-Key` на трёх создающих вызовах. ⚠ Снята ПЕР-ТЕРМНАЯ МОДЕЛЬ ПОДПИСИ (22.08, `PD-370`, D39.144: подпись — это `resume`), а НЕ правка термина: дверь `POST /books/{bookId}/bank/corrections` построена паком P9 — `httpapi/bank.go`, маршрут в `contractSurface` (`httpapi/v0.go`), канон `14-api-contract/openapi.yaml`, акты D39.161/162/166, @@ -126,7 +126,7 @@ Prometheus; на контрактную поверхность они не вы |---|---| | `cmd/tmplatformd` | демон: сборка зависимостей, очередь, свипы. `runner.go` — единственное место, где зона склеивается | | `cmd/tmplatformctl` | админ-CLI и `exit-marker`, который systemd зовёт на конце прогона | -| `internal/httpapi` | ВСЯ контрактная поверхность: `v0.go` (маршруты, библиотека, интейк, прогоны) · `reading.go` (главы, пары, замечания, банк) · `stream.go` (SSE) · `capabilities.go` · `problem.go` (модель ошибок) · `conditional.go` (ETag/304 + gzip; SSE через него НЕ проходит — потому и не сжимается) · `idempotency.go` · `project.go` (переводы словарей: read-модель → провод) · `bank.go` (дверь правок банка) | +| `internal/httpapi` | ВСЯ контрактная поверхность: `v0.go` (маршруты, библиотека, интейк, прогоны) · `reading.go` (главы, пары, замечания, банк) · `stream.go` (SSE) · `capabilities.go` · `problem.go` (модель ошибок) · `conditional.go` (ETag/304 + gzip; SSE через него НЕ проходит — потому и не сжимается) · `idempotency.go` · `project.go` (переводы словарей: read-модель → провод) · `bank.go` (дверь правок банка) · `exports.go` (дверь выдачи книги файлом) | | `internal/auth`, `internal/login` | сессии, CSRF, вход. Принципал создаётся ТОЛЬКО в мидлваре | | `internal/books` | интейк: приём файла, каталог книги, рендер стартового `book.yaml`, разбор через `tmctl manifest` | | `internal/runs` | жизнь прогона: допуск, спавн транзиентного юнита, реконсилятор, деньги на границе попытки | diff --git a/platform/deploy/README.md b/platform/deploy/README.md index ba46aab6..2cc9a22d 100644 --- a/platform/deploy/README.md +++ b/platform/deploy/README.md @@ -21,12 +21,18 @@ (`MemoryMax=80%`, `OOMPolicy=continue`) живьём не проверялись — вывод из `systemd.resource-control(5)`/`systemd.service(5)`. -## Откат релиза: не ниже версии 5 +## Откат релиза: не ниже версии 15 -`goose down` до версии 4 и ниже НЕ РАБОТАЕТ на живой базе: down-путь `00005` восстанавливает +⚠ **Граница была названа «5» и это уже неверно.** `goose down` до версии 14 и ниже НЕ РАБОТАЕТ на +живой базе: down-путь `00015` сужает `runs_paused_reason_check` обратно к одному `credit_exhausted`, +а боевой код пишет ещё два значения — `daily_ceiling` и `ceiling_unknown` (оба через +`internal/pgstore/books.go` `CeilingPause`). Значит `DownTo(<15)` на базе, где такая пауза +случалась, падает РАНЬШЕ, чем дойдёт до `00005`. Носитель — открытый ряд `PD-218`. + +Ниже 5 не работает и подавно: down-путь `00005` восстанавливает `users_email_key` и `email NOT NULL`, а обе формы нарушают строки, которые пишет боевой код (неподтверждённая личность даёт `email = NULL`; один адрес законно принадлежит двум аккаунтам). -Откат транзакционный, поэтому падение ничего не портит — но планировать откат ниже 5 нельзя, +Откат транзакционный, поэтому падение ничего не портит — но планировать откат ниже 15 нельзя, план отката — накатить вперёд. Разбор: `docs/STACK_DECISIONS.md` §8. ## Что юнит закрывает содержательно @@ -41,8 +47,13 @@ (`TM_PLATFORM_RUN_MEMORY_MAX`/`_TASKS_MAX`), и это ИЗМЕРЕНО — в дефолтном `app.slice` пользовательского менеджера лимиты принимаются и не применяются (`STACK_DECISIONS` §16). Дев-путь супервизора с группой процессов остаётся дев-путём. -- **`TimeoutStopSec=90`** больше, чем дренаж платформы (15 с) плюс grace движка (30 с). Меньше — - и systemd прибьёт `tmctl` посреди остановки, оставив лок проекта. +- **`TimeoutStopSec=90`** ограничивает остановку САМОГО ДЕМОНА: дренаж HTTP плюс останов очереди, + с запасом. ⚠ Прежняя редакция объясняла его «grace движка 30 с» — такой грации нет: `stopGrace` + равен 10 МИНУТАМ (`internal/runner/runner.go:59`) и штампуется как `TimeoutStopSec` на + ТРАНЗИЕНТНОМ юните прогона, а не на этом. ⚠ Но и обратное неверно: четыре движковых вызова — + `manifest`/`status`, сборка экспорта, `bank-apply` и сам `systemd-run` — идут ПРЯМЫМИ ДЕТЬМИ + демона, лежат в его cgroup, и этот таймаут на них распространяется. Разбор — комментарий в + `deploy/tmplatformd.service` над строкой `TimeoutStopSec=`. - **Секреты через `LoadCredential=`,** а не через окружение: переменная окружения видна в `/proc//environ` и наследуется каждым ребёнком-`tmctl`. Конфиг читает `*_FILE` первым. @@ -56,8 +67,11 @@ useradd --system --home-dir /srv/textmachine tmplatform install -d -m0750 -o tmplatform -g tmplatform /srv/textmachine install -D -m0755 tmplatformd /usr/local/bin/tmplatformd install -D -m0755 tmplatformctl /usr/local/bin/tmplatformctl -# ⚠ БЕЗ ЭТОГО НИ ОДИН ПРОГОН НЕ СТАРТУЕТ. Прогоны — транзиентные юниты в пользовательском -# менеджере `tmplatform`, а он существует вне сессии входа только при включённом linger. +# ⚠ ДЛЯ БОЕВОГО ДЕПЛОЯ ОБЯЗАТЕЛЬНО. Прогоны — транзиентные юниты в пользовательском менеджере +# `tmplatform`, а он ПЕРЕЖИВАЕТ выход из сессии только при включённом linger. +# ⚠ Формулировка «без этого не стартует НИ ОДИН прогон» снята 04.09 как ложная для стенда: в +# ЖИВОЙ сессии менеджер уже есть, и замер зоны (H2) дал `Linger=no` при шести стартовавших +# прогонах. Сессия, честно исполнившая прежнюю проверку, решала, что стенд сломан. # Проверка: `systemctl --user -M tmplatform@ is-system-running` отвечает, а не «Failed to connect». loginctl enable-linger tmplatform # Движок кладётся по ВЕРСИОНИРОВАННОМУ пути: попытка прогона пиннится к тому, с которого началась @@ -82,10 +96,12 @@ TM_PLATFORM_BOOKS_DIR=/srv/textmachine/books TM_PLATFORM_BOOK_TEMPLATE=/srv/textmachine/book-template.yaml TM_PLATFORM_METRICS_ADDR=127.0.0.1:9464 -# ⚠⚠ ДОПИСАНО 29.08. БЕЗ ЭТИХ ЧЕТЫРЁХ ИНСТАНС НЕ ЗАПУСТИТ НИ ОДНОГО ПЕРЕВОДА, и это не -# «неполный пример», а окружение, при котором каждый ОПЛАЧЕННЫЙ прогон падает. +# ⚠⚠ ДОПИСАНО 29.08, ПЕРЕ-СНЯТО 04.09. Прежняя редакция говорила «без этих четырёх инстанс не +# запустит ни одного перевода» — неверно для ДВУХ из четырёх, разбор ниже под блоком. Держи их +# все, но знай, ЧТО именно ломает каждая: считать обязательными все четыре дешевле не выходит — +# оператор, увидев, что без CTL_BIN всё работает, перестаёт верить и остальным трём. TM_PLATFORM_ENGINE_BIN=/opt/textmachine/engine/<версия>/tmctl # пусто = инстанс только читает -TM_PLATFORM_CTL_BIN=/opt/textmachine/bin/tmplatformctl # его зовёт юнит как ExecStopPost +TM_PLATFORM_CTL_BIN=/usr/local/bin/tmplatformctl # его зовёт юнит как ExecStopPost TM_PLATFORM_STATE_DIR=/var/lib/tmplatform/state # маркеры выхода; ТОЛЬКО абсолютный TM_PLATFORM_ENGINE_KEYS_PATH=/etc/tmplatform/engine-keys # KEY=VALUE, едет --keys-file @@ -96,13 +112,33 @@ TM_PLATFORM_EXPORTS_DIR=/var/lib/tmplatform/exports # ТОЛЬКО абсол TM_PLATFORM_EXPORT_TTL=24h # сколько живут артефакт и ссылка ``` -⚠ **Почему каждая из четырёх обязательна, а не желательна:** -- **`ENGINE_BIN`** пусто — инстанс объявляет себя читающей репликой и не спавнит ничего; -- **`CTL_BIN`** — путь, который юнит зовёт в `ExecStopPost`, чтобы записать маркер выхода; без него - прогон завершается, а платформа об этом не узнаёт никогда; -- **`STATE_DIR`** — каталог маркеров. ⚠ Дефолт лежит ВНЕ `ReadWritePaths=` юнита, а `deploy/` - ставит `ProtectSystem=strict`, поэтому без явного значения запись маркера запрещена файловой - системой, а не логикой; +⚠ **Что из этой четвёрки ДЕЙСТВИТЕЛЬНО гейтит прогон — одна переменная, а не четыре.** Остальные +три ломаются иначе, и знать чем дешевле, чем считать их все обязательными: +- **`ENGINE_BIN`** — ЕДИНСТВЕННАЯ, чьё отсутствие останавливает прогоны прямо: пусто ⇒ инстанс + объявляет себя читающей репликой, не спавнит ничего и не принимает загрузок + (`cmd/tmplatformd/runner.go:59-61`); +- **`CTL_BIN`** — путь, который юнит зовёт в `ExecStopPost`, чтобы записать маркер выхода. + ⚠ **ПУСТО — НЕ отказ:** берётся `tmplatformctl` РЯДОМ с демоном (`internal/config/config.go:115`, + сиблинг-дефолт `cmd/tmplatformd/runner.go:468-476`), а рантбук ставит его туда же, куда и демона + (`install -D -m0755 tmplatformctl /usr/local/bin/tmplatformctl` в блоке установки выше). + Отказывает **НЕВЕРНЫЙ** путь: `os.Stat` не находит его на буте (`runner.go:87` — + WARN, не паника), `MarkerArgv` остаётся пустым, и каждый старт/резюм отвечает `503` + (`internal/runs/runs.go:415-418` `ErrRunnerIncomplete` → `internal/httpapi/v0.go:844-848`). + ⚠ Прежняя редакция объясняла его ОТСУТСТВИЕ симптомом «прогон завершается, а платформа не узнаёт» + — такого исхода в коде нет: ровно чтобы он не наступил, зона и отказывает на старте + (`internal/runs/runs.go:151-152`); +- **`STATE_DIR`** — каталог маркеров. ⚠ **Прежняя редакция объясняла его ложно** («дефолт вне + `ReadWritePaths=`, запись запрещена файловой системой»): дефолт — `/var/lib/tmplatform` + (`internal/config/config.go:463`), а это ровно то, что юниту даёт `StateDirectory=tmplatform` + (строка `StateDirectory=tmplatform` в `deploy/tmplatformd.service`), — systemd создаёт каталог + и делает его записываемым ПОД + `ProtectSystem=strict`, а сам маркер пишет `ExecStopPost` ТРАНЗИЕНТНОГО юнита прогона, на + котором песочницы нет вовсе. ⚠ Задавать явно всё же стоит, но по ТРЁМ ДРУГИМ причинам, и они + настоящие: путь обязан быть абсолютным (демон отказывает на старте, `internal/config/config.go:484`) · + каталог обязан принадлежать пользователю ПРОГОНОВ — по его владельцу админ-CLI отличает свой + systemd от чужого и без этого отказывается судить о судьбе прогона + (`cmd/tmplatformctl/runs.go:360` `notTheRunsOwnManager`) · и он не должен меняться при живых + прогонах: смена осиротляет exit-маркеры идущих (`PD-155`); - **`EXPORT_FORMATS`** — не из четвёрки и не обязательна, но у неё своя ловушка: это ДЕКЛАРАЦИЯ ОПЕРАТОРА о том, что умеет РАЗВЁРНУТЫЙ здесь движок, а не открытие. Список форматов живёт Go-переменной внутри движка, импортировать который платформе запрещено @@ -111,8 +147,19 @@ TM_PLATFORM_EXPORT_TTL=24h # сколько живут арте кодом `deployment_error`: честно, но чинить это вам. ⚠ `EXPORTS_DIR` абсолютен по причине острее, чем у `STATE_DIR`: путь уходит движку аргументом `--out`, а движок работает с каталогом КНИГИ как рабочим, поэтому относительный писал бы артефакты - внутрь чужого проекта; -- **`ENGINE_KEYS_PATH`** — ЕДИНСТВЕННЫЙ канал провайдерских ключей в движок (едет аргументом + внутрь чужого проекта. ⚠ **И он обязан быть ЗАПИСЫВАЕМ ВНУТРИ ПЕСОЧНИЦЫ ЮНИТА** — для + `BOOKS_DIR` эта оговорка написана, для экспорта её не было. Дефолт `/exports` + лежит под `StateDirectory=tmplatform` и потому пишется; любой путь вне его надо добавить + в `ReadWritePaths=`. ⚠ Симптом НЕ тот, которого ждёшь: каталог создаёт САМ ДЕМОН НА БУТЕ + (`cmd/tmplatformd/runner.go:208`), и невозможность создать — **ОТКАЗ СТАРТА** демона + (`exports directory <путь>: …`), а не отказ отдельного экспорта. Каталог, который есть, + но доступен только на чтение, роняет создание подкаталога КНИГИ на первом же экспорте + (`internal/exports/exports.go:358`), и код отказа там `build_failed`, а не + `deployment_error`; +- **`ENGINE_KEYS_PATH`** (четвёртая из «четвёрки»; **старту НЕ мешает** — демон пишет один WARN + и поднимается здоровым, `cmd/tmplatformd/runner.go:80`, принимает книгу и БЕРЁТ ХОЛД, а падает + первый платный вызов, то есть уже после резервирования денег пользователя — замер H1 зоны) — + ЕДИНСТВЕННЫЙ канал провайдерских ключей в движок (едет аргументом `--keys-file`, мимо процесса платформы и мимо окружения юнита). Без него движок ищет `.env` рядом с `book.yaml`, которого SaaS-путь не пишет, и каждый платный прогон падает `exit 10` («missing API keys»). На буте это WARN, а не отказ, — то есть тихо. diff --git a/platform/deploy/tmplatformd.service b/platform/deploy/tmplatformd.service index 74131e39..ea6ee025 100644 --- a/platform/deploy/tmplatformd.service +++ b/platform/deploy/tmplatformd.service @@ -38,8 +38,17 @@ Group=tmplatform # its children the way the engine expects; SIGKILL to everything left when the timeout runs out. KillMode=mixed KillSignal=SIGTERM -# Longer than the platform's own drain (15s) plus the engine's stop grace (30s), or systemd would -# SIGKILL a tmctl mid-shutdown and leave its project lock behind. +# This bounds the shutdown of THIS unit: the HTTP drain plus the queue stop, with slack. +# ⚠ Two corrections, 04.09, and the second reverses the first attempt at fixing this comment. +# (1) The edition before that justified the number by "the engine's stop grace (30s)". There is no +# 30s grace anywhere: `stopGrace` is 10 MINUTES (internal/runner/runner.go:59) and it is stamped +# as TimeoutStopSec on the TRANSIENT RUN unit (runner.go:166), not honoured by this one. +# (2) The first correction then claimed no tmctl is a child of this unit — ALSO false, and in the +# more dangerous direction. Only `translate` is a transient unit. Four engine invocations run as +# DIRECT CHILDREN of this daemon: manifest/status (internal/runner/engine.go:201), the export +# build (build.go:88), bank-apply (bankapply.go:73) and systemd-run itself (runner.go:107). +# Those DO sit in this unit's cgroup and this timeout DOES apply to them. What the header's +# D39.106 rules out is a translation being killed here — not every tmctl. TimeoutStopSec=90 Restart=on-failure RestartSec=5s @@ -90,9 +99,15 @@ RestrictRealtime=yes LockPersonality=yes RestrictAddressFamilies=AF_INET AF_INET6 AF_UNIX -# These bound the unit's CGROUP, and by the argument at the top of this file every tmctl the -# platform spawns lives in it. So they do not bound "the control plane" — they bound the control -# plane plus every run in flight, together (PD-55). Two consequences follow, and both are decided +# These bound the unit's CGROUP: the control plane PLUS every engine process the daemon spawns +# in-process — manifest/status, the export build, bank-apply — but NOT translation runs, which are +# transient units in the user manager (the header's D39.106). +# ⚠ Both earlier editions of this sentence were wrong, in opposite directions, and the pair is worth +# keeping: the first said "every tmctl the platform spawns lives in it… the control plane plus every +# run in flight, together (PD-55)" — pre-D39.106, and contradicted the header of this same file; the +# 04.09 correction then said the cgroup holds the control plane and nothing else — which erased the +# four in-process children that really are in it. PD-136 recorded the first paragraph as DELETED; it +# was rewritten, not deleted, and only on 04.09. Two consequences follow, and both are decided # here rather than discovered in production: # # 1. The ceiling is a machine backstop, not a service sizing. systemd.resource-control(5) calls @@ -106,8 +121,10 @@ RestrictAddressFamilies=AF_INET AF_INET6 AF_UNIX # (TM_PLATFORM_RUN_*). That is the answer to PD-13, and it is measured (STACK_DECISIONS §16). MemoryMax=80% OOMPolicy=continue -# Platform plus concurrent runs, each a Go process with a few dozen threads: room for roughly a -# dozen runs on one VM, which is more than a single box will carry. +# ⚠ The previous justification counted "platform plus concurrent RUNS" — a surviving conclusion of +# the premise removed above: runs are not in this cgroup. What to count is the daemon plus its +# IN-PROCESS children (manifest/status, the export build, bank-apply, systemd-run), each a Go +# process with a few dozen threads. The number is unchanged: it is generous for that set too. TasksMax=512 LimitNOFILE=8192 diff --git a/platform/docs/DEFECT_REGISTER.md b/platform/docs/DEFECT_REGISTER.md index 7fa97a11..e0fae9d9 100644 --- a/platform/docs/DEFECT_REGISTER.md +++ b/platform/docs/DEFECT_REGISTER.md @@ -23,7 +23,7 @@ | ID | Класс | Серьёзность | Где | Суть | Статус | Источник | |---|---|---|---|---|---|---| -| 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): направление ЕСТЬ** — D39.165 §1, четыре части (цена от объёма исходника · стоп объёма в движке · хвост качества в тариф). ⛔ **(г) константу по сегодняшним числам НЕ калибровать** — гейт строка бэклога 202. Что остаётся открытым: не решение, а МЕХАНИКА, и она — отдельный пак (слово оркестратора 28.08), паком P12 не тронута. | open | воркфлоу-ревью P9 28.08 (линза bar:double-count), стоп-решение сессии P9 28.08: тянет архитектуру, не чинится в паке | +| PD-410 | bug | **major** | `internal/pgstore/readmodel.go:545` `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): направление ЕСТЬ** — D39.165 §1, четыре части (цена от объёма исходника · стоп объёма в движке · хвост качества в тариф). ⛔ **(г) константу по сегодняшним числам НЕ калибровать** — гейт строка бэклога 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 не дошёл: он лечил вторую фазу (расплату) и её поверхности. ⚠ **СУЖЕНА пак 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`. ⚠ **РУЧКА ПОСТРОЕНА паком «закрыть цикл» (04.09) — строка СУЖЕНА до своего остатка, не закрыта.** Форма: та же команда `tmplatformctl run abandon`, потому что рантбук уже посылает оператора именно к ней; новой команды нет. **Кто вправе звать** — оператор из CLI и только оттуда: терминальный вердикт над деньгами на HTTP-поверхность не выходит, как и `run unquarantine`. **Чем доказывается отсутствие процесса** — систему СПРАШИВАЮТ, а не верят ей на слово: команда сама зовёт `runner.Alive` по имени юнита стоящей попытки, и ответов **ЧЕТЫРЕ**, а не два — «нет» пропускает, «есть» отказывает, **недостижимая шина отказывает тоже** (отсутствие ответа не есть отсутствие процесса), и — четвёртый, найденный адверсариальным проходом уже по готовой работе — **«это НЕ ТОТ systemd» отказывает тоже**: `systemctl --user` отвечает про менеджер СПРАШИВАЮЩЕГО, а прогоны живут у пользователя демона, и там юнит, о котором чужой менеджер не слышал, неотличим от кончившегося (замерено: `ActiveState=inactive`, выход 0). Различитель — владелец `TM_PLATFORM_STATE_DIR`, ⚠ **поэтому переменная деплоя обязана быть экспортирована в оболочке оператора, иначе команда ОТКАЖЕТ** (рантбук об этом теперь говорит). Пины: `cmd/tmplatformctl/abandon_proof_test.go` — четыре ответа различителя, три отказа и путь «gone» через саму команду, плюс отказ над ЖИВЫМ транзиентным юнитом под systemd-гейтом. Второе условие проверяет хранилище ВНУТРИ транзакции: `reconcile_failures >= abandonAfter`, тот же пол, что у settling-ветви, — видеть и иметь право уничтожить это разные разрешения. **Деньги:** попытка с базовой линией и отчитанной цифрой рассчитывается по РАЗНИЦЕ (та же арифметика, что печатает `StalledRuns`), холд закрывается как `settled` с ценой в леджере; попытку без базовой линии ценить нечем — холд возвращается ЦЕЛЫМ, довод settling-ветви, доехавший до живой половины. **Что будет, если процесс вернётся** — названо прямо, потому что это цена ручки: живой движок продолжит тратить против СВОЕГО книжного потолка, а холд аккаунта уже закрыт, значит эту трату не оплатит никто и провайдеру платит ДЕПЛОЙ. Она ограничена (потолком этого же прогона) и не эксплуатируема аккаунтом: попасть в состояние можно только через прогон, который собственный реконсилятор деплоя не смог закончить. Тем же касанием закрыта долговечная половина `PD-418`. Пины: `internal/runs/abandon_orphan_test.go` — отказ без доказательства · отказ ниже пола · вердикт `orphan` со списанием по отчёту · целый холд там, где цены нет · осиротевшая попытка ЖИВОГО прогона. ⚠ **ОСТАТОК, ради которого строка остаётся открытой:** отсутствие процесса доказывается ОДИН раз, в момент команды, и между этим ответом и коммитом транзакции есть окно, в которое юнит может подняться заново. Окно узкое, его цена названа выше, но оно есть, и закрыть его может только фактически транзакционная проба — арбитр в хранилище, а не ещё одна проверка в CLI. | open | приёмка оркестратора №19 по паку P11 (охотник вне карты, воспроизведено 10 проходами) | | PD-440 | bug | **major** | `internal/pricing/pricing.go` `DefaultPerChapter`, `internal/runs/spawn.go` `bookCap`, `internal/pgstore/books.go` `ChaptersLeft`; движковая половина — `backend/internal/pipeline/stagerun.go` (резерв под шаг при `waves.workers` параллельных вызовах) | **КНИГУ ЧЕРЕЗ API НЕЛЬЗЯ ДОЧИТАТЬ НИКАКИМИ ДЕНЬГАМИ — покупка перестаёт покупать.** Прирост книжного потолка за прогон равен `chaptersLeft × DefaultPerChapter`, то есть максимум `chaptersLeft × $0.03`. Движок перед редакторским шагом РЕЗЕРВИРУЕТ оценку на каждый параллельный вызов (`waves.workers`, на стенде 4 × ≈$0.069 = ≈$0.28) и останавливается на ПЕРВОЙ отказанной резервации, не дожидаясь уже летящих. Как только прирост становится меньше стоимости одного шага волны, каждый следующий прогон встаёт МГНОВЕННО и не продвигает книгу ни на юнит — а прирост только УБЫВАЕТ, потому что `chaptersLeft` уменьшается с каждой дочитанной главой. ⚠ **Воспроизведено живьём дважды подряд, платным прогоном 04.09** (книга `bk_SS5VES2JELESJSTR`, 10 глав, после первой готовой главы `chaptersLeft = 9`, прирост $0.27): прогоны `run_CR2RN76NAKYKAJE5` и `run_VSXPMJE53KJQQAOS` — оба `paused/credit_exhausted` за 10–15 секунд, `committed` не сдвинулся ни на микро-доллар (278319 до и после), холд вернулся целиком. Движковая строка обоих: `book USD ceiling reached ($0.548319) (committed=$0.278319 reserved=$0.207805, denied estimate=$0.069828) … reserve ceiling reached`. Это ПРОДУКТОВЫЙ СТОП, а не «шкала короче»: занижение ставки ×4.47 (`D39.179` п.1) до сих пор читалось как неудобство, и вот порог, за которым оно становится недостижимостью результата. ⚠ **Денег сам стоп НЕ жжёт** — отменённые вызовы этих двух прогонов не успели уйти (латентность 20 и 31 мс, `model_actual` пуст, 0 токенов); неучтённый расход — отдельная строка `PD-441`. ⚠ **Чинится СОГЛАСОВАННО, одной стороной нельзя:** поднять ставку — платформенная половина и она меняет всю шкалу покупки; не бросать летящие вызовы и/или считать резерв по фактической конкуренции — движковая. Проверка без Go: `sqlite3 -header -column "file:?mode=ro" "select trace_id, count(*), round(sum(cost_usd),6) from request_log group by trace_id;"` ⚠ **РАДИУС ПЕРЕ-СНЯТ 04.09 ЗАМЕРОМ ЗА $0 (заказ оркестратора №22): дефект НЕ про хвост длинной книги, а про ПОРОГ, ниже которого книга не переводится ВООБЩЕ.** Порог считается из измеренного, а не из оценки: движок в собственном отказе назвал резерв ОДНОГО редакторского вызова до шестого знака — `committed=$0.278319 reserved=$0.207805, denied estimate=$0.069828, ch5/chunk0/edit` при потолке `$0.548319`. Отсюда волна из четырёх стоит **$0.277633 — наблюдённая величина: три реально стоявших резерва ($0.207805) плюс отказанный ($0.069828)**. ⚠ Не `4 × $0.069828 = $0.279312`: первая редакция ряда взяла именно это произведение, а оно СКОНСТРУИРОВАНО — вызовы волны не равны между собой, промпты чанков разной длины, и `3 × $0.069828 = $0.209484` расходится с наблюдёнными `$0.207805` на `$0.001679` (средний реальный резерв $0.069268 против отказанного $0.069828). Произведение оставлено рядом как ВЕРХНЯЯ оценка, и держать оба стоит: порог считается по обоим одинаково — `$0.277633 / $0.03 = 9.25` и `$0.279312 / $0.03 = 9.31`, — то есть **вывод не зависит от выбора числа**. ⚠ Счёт «три» выведен, а не напечатан: он следует из `waves.workers: 4` минус отказанный, и подтверждается отношением `$0.207805 / $0.069828 = 2.976`. Порог — `chaptersLeft × $0.03 ≥ стоимости волны`, то есть **книга проходит только с 10 непереведённых глав и больше**. Поправка числа — оркестратора №22, 04.09. Следствия: **последние ДЕВЯТЬ глав любой книги недостижимы**, и **книга из ≤9 глав не переводится никогда** — ни первой покупкой, ни повторными, потому что потолок КУМУЛЯТИВНЫЙ (`bookCap = committed + increment`, `internal/runs/spawn.go:212-214`) и запас каждого прогона равен ровно инкременту, сколько бы книга ни потратила раньше. ⚠ **Предъявлено живьём и бесплатно:** пятиглавая книга `bk_P5UCXKHDO4HFWSH3` заведена через настоящий интейк ($0 — `manifest` без ключей), и `GET /v0/books/{id}/run-options` вернул `max_chapters: 5`, то есть **максимум, который пользователь вообще может купить этой книге, — $0.15 против $0.279312 за волну**; для книги пака после первой дочитанной главы тот же вызов вернул `max_chapters: 9` ($0.27). Разрезка короткой книги ПОБАЙТНО та же (юниты по главам 2·1·1·2·1 против тех же 2·1·1·2·1 у первых пяти глав длинной), то есть короткая книга содержит ровно тот кусок `ch5/chunk0`, чей резерв измерен. ⚠ **ЧЕГО В ЗАМЕРЕ НЕТ, называю прямо:** сама короткая книга НЕ запускалась — её прогон способен потратить до $0.15 на черновую волну, а санкция владельца на платные прогоны закрылась вместе с паком; арифметика замкнута, живой прогон короткой книги остаётся неснятым. | open | платный сквозной прогон через API, пак «закрыть цикл» 04.09 (воспроизведено дважды) | | PD-441 | bug | **major, деньги** | движковая половина — `backend/internal/pipeline/stagerun.go`, ветвь с комментарием `No 2xx ever arrived: nothing was billed` (`releaseReservation` + строка `request_log` с нулевой ценой); платформенная — `internal/runs/reconcile.go` `settleOne`, который берёт цифру движка как истину | **ЛЕДЖЕР ДЕНЕГ — НИЖНЯЯ ГРАНИЦА, и потолок сам её создаёт: halt отменяет вызовы, которые УЖЕ УШЛИ к провайдеру, и их цена не записывается никуда.** Замерено 04.09 на `run_FS3O4UO5EDTKVF42`: волна пустила четыре редакторских вызова, один дошёл (6846+13729 токенов, **$0.063404**, 156444 мс) и своим коммитом сорвал книжный потолок, а три оставшихся были отменены В ТОТ ЖЕ МИГ, отработав **120756, 156469 и 156470 мс** — с `cost_usd 0`, нулевыми токенами и пустым `model_actual`. Вызов, проживший 2–2.6 минуты, до провайдера дошёл почти наверняка. **Порядок величин:** соседние ЗАВЕРШЁННЫЕ вызовы того же прогона стоили $0.017409 и $0.063404 ⇒ незаписанное — порядка **$0.05–0.19 на ОДНО срабатывание потолка** против **$0.278319** за всю книгу. То есть механизм, который защищает от перерасхода, сам создаёт неучтённый расход до двух третей стоимости книги за одно срабатывание. ⚠ **Дыра не в принципе, а в ПОКРЫТИИ, и это делает строку заказом, а не наблюдением.** Консервативная оценка в движке уже построена и ратифицирована (`stagerun.go`, «paid 2xx with zero usage; settling the reservation estimate to keep the ceiling honest», pack-13 point-9): проект уже принял правило «нельзя ослеплять потолок нулём там, где вызов был платным». Но условие требует `resp` — ОТВЕТА. Вызов, отменённый в полёте, ответа не получает, уходит другой веткой, и та честно пишет «nothing was billed» — что верно для транспортного отказа, не выпустившего запрос, и НЕВЕРНО для отмены на 156-й секунде. **Направление лечения (именно направление):** сеттлить оценку и при отмене вызова, который успел уйти, различая по ФАКТУ ухода; латентность — кандидат-различитель (20 мс против 156 000 мс разводит два случая без всякого рассуждения), но порог обязан быть обоснован замером, а не назначен. ⚠ Списание провайдером НЕ ДОКАЗАНО: биллинга провайдера у зоны нет, утверждается ровно наблюдаемое — вызовы шли минутами и их цены в леджере нет. ⚠ Правка `stagerun.go` — ЧУЖАЯ зона, отсюда только строка. Смежная строка единого бэклога — **78**. Воспроизведение: `sqlite3 -header -column "file:?mode=ro" "select id, ts, stage, role, latency_ms, cost_usd, err from request_log where err='context canceled' and latency_ms > 1000;"` | open | платный сквозной прогон через API, пак «закрыть цикл» 04.09 (усилено разбором оркестратора №22 по коду движка) | @@ -33,7 +33,7 @@ | ID | Класс | Серьёзность | Где | Суть | Статус | Источник | |---|---|---|---|---|---|---| -| PD-447 | doc | minor | `docs/STACK_DECISIONS.md` §«Как поднять локально» и `deploy/README.md` шаг 3–4 (оба исправлены); носители дефолта — `internal/config/config.go:281`=`TM_PLATFORM_ADDR` и `cmd/tmplatformctl/seed.go:41`=`base URL of the running tmplatformd` | **Оба стендовых рецепта зоны принимали ОТВЕТ ПО АДРЕСУ за доказательство того, что отвечает СВОЙ процесс, и потому проходили при мёртвом собственном демоне.** Замерено исполнением 04.09: на машине разработки четвёртые сутки жил чужой `tmplatformd` на `127.0.0.1:8080` со своей базой; демон рецепта умирает в этой ситуации на `bind: address already in use` — молча, он запущен фоном, — а смоук следующей строкой получает `healthz=200` и `readyz=ready` ОТ ЧУЖОГО ПРОЦЕССА. ⚠ **Дороже смоука — шаг сида:** `tmplatformctl seed` дарит `$25` кредита и грузит книгу ЧЕРЕЗ ЖИВОЙ ИНТЕЙК, то есть по этому рецепту деньги и данные уезжают в чужой деплой. Второй адрес там же был написан отдельным литералом (`--url http://127.0.0.1:8080`), что и есть штатный способ разъехаться с `TM_PLATFORM_ADDR` незаметно. ⚠ **Три лечения на три РАЗНЫЕ половины, ни одно не заменяет другое:** явный `_ADDR` уводит с общего дефолта; проба по `pid` слушателя отвечает на вопрос, на который `200` не отвечает в принципе — ЧЕЙ это процесс; `--noproxy '*'` нужен потому, что при заданных `http_proxy` голый `curl` на `127.0.0.1` уходит во внешний прокси. Проба предъявлена в обе стороны: свой pid на своём порту — проходит, чужой демон против своего pid — отвергается. ⚠ **Дефолт `8080` в коде НЕ меняется, и это решение:** коллизия — `tmplatformd` против `tmplatformd`, любой другой номер даст ту же аварию на втором одновременном стенде, а цену смены заплатят все существующие деплои и доки. Чинится посылка, а не номер. **Вес minor, а не major, по радиусу:** пишет только дев-стенды, боевого пути этим рецептом нет, деньги — стендовый грант. | fixed(`adf5e53`) | найдено оркестратором №22 при независимой проверке отзыва причины смертей демона, 04.09; починено зоной | +| PD-448 | bug | minor | `internal/runs/runs.go:170`=`ErrNotResumable` (отказ, который получает проигравший) и `internal/runs/control_test.go:846` `TestTwoResumesOfOneRunTakeOneHoldAndBothAnswer` (пин гарантии) | **ДВОЙНОЙ КЛИК ПО «ПРОДОЛЖИТЬ»: проигравший гонку получает ОТКАЗ вместо прогона, когда два вызова не успевают пройти проверку состояния в одном окне.** Гарантия зоны сформулирована в шапке самого теста: «Both calls pass the state check together and race for attempt N+1 on the unique index; the loser must answer with the run the winner re-opened, not with a not-found». Замер 05.09: под нагрузкой машины (`load average 42–48` на 8 ядрах, тринадцать чужих `tmmutate`) вызовы СЕРИАЛИЗУЮТСЯ — победитель успевает перевести прогон в `translating` до того, как проигравший дойдёт до своей проверки, — и проигравший получает `runs: the run cannot be continued: it is translating` (`control_test.go:863`). ⚠ **Пользовательский смысл: человек, дважды нажавший «продолжить», видит ошибку, хотя прогон в этот момент СТАРТОВАЛ.** ⚠ **Деньги не затронуты:** отказ приходит ДО взятия холда, так что второй холд не берётся — та половина гарантии («ровно один холд») держится и на отказном пути. ⚠ **Это НЕ `PD-420`:** тот ряд про другой тест (`internal/pgstore/runs_test.go` `TestAClaimThatLostARaceToAReleaseIsRetriedAndNotAnError`); проверено грепом, имени этого теста в реестре не было ни в одном ряду. **Воспроизведение:** полный пакет `go test ./internal/runs/` при загруженной машине — упал 2 раза из 2; ИЗОЛИРОВАННО (`-run TestTwoResumesOfOneRunTakeOneHoldAndBothAnswer -count=5`) — 5/5 `ok` за 10 с. То есть окно существует и открывается голоданием по процессору, а не правкой кода. **Направление лечения (не решение):** проигравшему различать «прогон уже идёт, потому что его только что открыл параллельный резюм» от «прогон идёт сам по себе» — первое обязано вернуть прогон, второе законно отказывает. | open | замер зоны 05.09 при ревизии документации: батарея краснела дважды, разобрано до конкретного теста | | PD-443 | bug | minor | `internal/exports/exports.go` `Build`/`Sweep`, `internal/pgstore/queries/exports.sql`, миграция `00032_exports.sql` (`on delete cascade`) | **Артефакт экспорта может пережить СТРОКУ, которая одна умеет его найти, и тогда его не удалит никто.** Путь артефакта детерминирован (`//.`), но каталог со строками не сверяет ничто: GC удаляет только те пути, которые НЕСУТ строки. Три живых пути, найдены адверсариальным проходом по готовой работе: **(а)** демон убит между `rename` движка и `FinishExport` — путь в строку так и не попал, свип пере-водит её в `failed`, файл остаётся навсегда (не экзотика: `KillMode=mixed` в юните и дренаж очереди на 10 с делают это штатным исходом рестарта под нагрузкой); **(б)** книга удалена — `on delete cascade` сносит строки, файлы и каталог книги под `Dir` не трогает никто (сегодня у `DeleteBook` вызывающих нет, но внешний ключ уже стоит и ловушка взведена); **(в)** артефакт, чей unlink не прошёл, — ⚠ **ЭТОТ ПУТЬ ЗАКРЫТ ТЕМ ЖЕ ПАКОМ**: `path` теперь переживает смену состояния и снимается только после реального удаления (`UnlinkedExports`/`ForgetExportPath`), то есть unlink стал ПОВТОРЯЕМЫМ; пин `TestAFileTheSweepCouldNotRemoveKeepsItsRowPointingAtIt`. Четвёртый путь — временные файлы движка (`.<имя>.tmp-*`) после SIGKILL — тоже закрыт (`exports.Service.discard`), потому что `tmctl build` контекста не читает и его всегда добивает SIGKILL. ⚠ **Лечение (а) и (б) — одно и то же и это НЕ райдер:** сверка каталога со строками, то есть реконсилятор файловой системы со своим дизайном (что считать сиротой, как отличить чужой файл от своего, что делать с каталогом книги, которой нет). Делать его заодно с дверью значило бы решить мимоходом. **Радиус сегодня ограничен:** TTL двери — сутки по умолчанию, файлы лежат под одним каталогом деплоя, и оператор видит рост диска раньше, чем что-либо ломается. ⚠ **ДВА СИБЛИНГА, НАЗВАННЫЕ ПРИЁМКОЙ 04.09 — один закрыт, один остаётся здесь.** (I) STAGING-файл движка (`.<имя>.tmp-*`) переживает смерть демона ПОСРЕДИ сборки: `discard` живёт в том же процессе, поэтому при `kill -9` убирать его некому, а следующая сборка того же экспорта не случится — строка уже не `pending`. Это тот же класс, что (а), и лечится тем же реконсилятором каталога. (II) ⚠ **ВТОРОЙ ВОРКЕР НА ОДНОЙ СБОРКЕ — ЗАКРЫТО ТЕМ ЖЕ ДОФИКСОМ, а не только названо:** оба писали бы по ОДНОМУ пути (он выводится из id экспорта), и уборка проигравшего снесла бы файл, только что опубликованный выигравшим. Клейм сделан ИСКЛЮЧАЮЩИМ — `where … and state = 'pending' and started_at is null`, — поэтому второй воркер получает `ErrExportSettled` и до сборки не доходит. Сегодня недостижимо (River ведёт одно задание рода за раз), но именно это превращает вторую реплику из потерянного артефакта в дубль сборки. Пин — в `TestAQueuedBuildAndAClaimedOneAreJudgedOnDifferentClocks`. Воспроизведение (а): `kill -9` демона между строкой лога `export built` и следующей записью в БД. | open | адверсариальный проход пака «закрыть цикл» по своей же работе, 04.09 | | PD-444 | bug | minor | `internal/httpapi/bank.go:142`=`Invalid(w, r)` (ветка строгого разбора) против `internal/httpapi/bank.go:146`=`Invalid(w, r, items...)` (ветка валидации); механизм — `internal/httpapi/problem.go:164`=`Errors []Item` | **Дверь правок банка отвечает `400 invalid_request` БЕЗ единого указателя на то, ЧТО не так.** Замерено живым прогоном 04.09: две попытки с чужими именами членов (`decisions` вместо `corrections`) вернули голое тело, оба ответа `Content-Length: 119` — ни `errors[]`, ни имени члена, ни позиции. ⚠ **Сам отказ ВЕРЕН и оспаривать его нечего:** схема канона объявляет `additionalProperties: false` на обоих уровнях, и незнакомый член — это клиент, уверенный, что он что-то задал; молчаливая версия этого — правка, применённая наполовину. Дефект в другом: канон нигде не требует МОЛЧАТЬ о том, какой член виноват, а механизм у зоны уже построен и на соседней ветке применяется — `validateCorrections` возвращает `[]Item` и отдаёт его в `Invalid`. Ветка строгого разбора теряет даже то, что у неё в руках: `encoding/json` называет поле в тексте ошибки (`json: unknown field "decisions"`), а обработчик его не только не отдаёт, но и не логирует — соседняя ветка «тело не доехало» логирует. Цена: клиент двери — редактор пользователя, и `400` без адреса отлаживается перебором. ⚠ Лечение НЕ трогает канон: `errors[]` в нём уже объявлен, довести до ответа нужно ветку разбора. | open | живой платный прогон пака «закрыть цикл», наблюдение H12, 04.09 | | PD-446 | doc | minor | канон `docs/architecture/14-api-contract/` §`PausedReason`; носители в зоне — `internal/pgstore/books.go:1140`=`PausedCreditExhausted = ingest.PausedCreditExhausted` и `internal/httpapi/v0.go:713`=`ContractHaltReason` | **`paused_reason: credit_exhausted` при 34% НЕТРОНУТОГО баланса — слово называет кредит там, где исчерпан потолок ПРОГОНА.** Замерено 04.09: в один и тот же момент прогон стоял с `paused_reason: credit_exhausted`, а `GET /v0/usage` отвечал `state: ok, halt_reason: null` при остатке `0.102494` из `0.30`. ⚠ **Оба ответа ВЕРНЫ, и счётная половина этого дефекта уже вылечена — пере-открывать её нельзя:** halt читается с АККАУНТА, а не с паузы последнего прогона (`ReadUsage`, разбор в `books.go` над телом), и именно поэтому `halt_reason` здесь честно пуст. Остаётся ОДНО: у `PausedReason` и `AccountHaltReason` разные словари с одним и тем же единственным значением, и это слово — `credit_exhausted`. Пользователь, читающий «кредит исчерпан» рядом с «остаток 34%», получает противоречие, которого в фактах нет. ⚠ Зона правку канона своей рукой не делает: кандидат в состав минора — значение `PausedReason` обязано называть ПРОГОН (`run_ceiling_reached`), словарь аккаунта не трогается. Пока минор не принят, ряд держит вопрос открытым. | open | живой платный прогон пака «закрыть цикл», наблюдение H14, 04.09 | @@ -43,7 +43,7 @@ | PD-375 | bug | minor | `internal/runs/runs.go:354`=`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:349`=`out := []sweepPass{{"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:447`=`сколько всего может занять один проход свипа`, `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`: та про то, что поднимать надо пару, эта про то, что ручка не покрывает такт ⚠ Арифметика 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:494`=`сколько всего может занять один проход свипа`, `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`: та про то, что поднимать надо пару, эта про то, что ручка не покрывает такт ⚠ Арифметика 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:437`=`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:434`=`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 ⚠ **ПАК P13 03.09: половина про КАРАНТИН закрыта в дереве, строка сужается до возраста холда.** `grep -rn quarantine cmd/` теперь даёт хиты: `tmplatformctl runs` печатает колонку QUARANTINE с причиной, `tmplatformctl run unquarantine --run ` снимает её (`PD-426`). Ручки для `oldest_open_hold_seconds` по-прежнему нет — этот остаток и держит строку открытой. Статус — акт лендинга | open | ревью-пак P8-REVIEW, ось 4 (живая проба, подтверждено рефутером) | @@ -51,7 +51,7 @@ | PD-102 | doc | minor | `internal/httpapi/serve.go:36-38` | Доккоммент `DefaultTimeouts` утверждает, что «an upload extends its own deadline as it makes progress» — это НЕВЕРНО: `ReadTimeout` в `net/http` (Go 1.26.5, `server.go:990` `wholeReqDeadline = t0.Add(ReadTimeout)`) выставляется один раз и по мере прихода байтов не продлевается. Комментарий несущий: он объясняет, почему `Read` короткий, и на нём будущая ручка загрузки книги (23 МБ по контракту) построит неверное ожидание — ей понадобится собственный дедлайн через `ResponseController`, а не «прогресс продлевает» | open | приёмка P2 (панель, сверено с исходником Go) | | 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` · `internal/runs/seam_test.go:146`=`func TestAResumeOfARunPausedAtALimitIsRefusedWhoseverLimitItWas` (переименование, коммит `9b23e8c`) · `httpapi.TestOnlyTheContractsOwnPausedReasonReachesTheWire` ⚠ Дополнено рефутером: контракт 0.3.0 РАСШИРИЛ свойство — резюм отбивается `ErrCeilingReached` на ЛЮБУЮ паузу, а различение day против credit пинится не этим тестом, а `pgstore.TestTheCeilingScopeDecidesWhichPauseTheRunGets` и `httpapi.TestOnlyTheContractsOwnPausedReasonReachesTheWire`. Посадка мутации (снят `ErrCeilingReached` в ветке `paused`) роняет пин под НОВЫМ именем — свойство держится | open | собственная сверка шва при F1 (чтение движка + D39.122) | +| 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:332`=`book_usd/day_usd` (⚠ пере-нацелен ВТОРОЙ раз, 04.09: цель уехала с `:321`; первая попытка того же дня промахнулась на строку — условие на Go-поля стоит на `:331`, а процитированные литералы на `:332`), Р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` · `internal/runs/seam_test.go:146`=`func TestAResumeOfARunPausedAtALimitIsRefusedWhoseverLimitItWas` (переименование, коммит `9b23e8c`) · `httpapi.TestOnlyTheContractsOwnPausedReasonReachesTheWire` ⚠ Дополнено рефутером: контракт 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, сужено рефутером:** для половины «спавн отказал» (попытка до движка не дошла) построены ДИАГНОЗ (`runs --stalled`, гейдж `tm_platform_runs_stalled`) и РУЧНОЙ вердикт оператора (`tmplatformctl run abandon --run --reason [--release-hold]`, коммит `31f1f82`, строка П-20 зонного бэклога). **Автоматического терминального состояния после 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:613`=`select finished_at from runs where id = $1` ⚠ якорь пере-нацелен паком P11: прежняя строка отвечала `ErrNoRun` законченному прогону — она и была дефектом; теперь это ветвление, читаемое ПОД блокировкой книги | 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`-книг, свип каталогов-сирот, потолок диска — зона решает сама. Гейт один и не продуктовый: открытая регистрация, которой в закрытой бете нет. | 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, вне карты пака) | @@ -84,7 +84,7 @@ | 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` (греп `деньги В СВОЕЙ транзакции`), которая сама предмет открытой `PD-387` | open | воркфлоу-ревью волны 2 (P8-FIX), находка опровергнута, остаток зафиксирован | -| PD-373 | doc | info | `internal/readmodel/readmodel.go:64`=`const maxAttempts = 5`, `deploy/README.md:432`=`После пяти неудач` | **У числа попыток материализации ДВА носителя и ничего между ними.** Код держит `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:479`=`После пяти неудач` | **У числа попыток материализации ДВА носителя и ничего между ними.** Код держит `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` ⚠ **ПАК P13 03.09: половина `Makefile` вылечена в дереве.** Цель `check` над списком скипов печатает условия хоста через новую цель `make conditions`, и перечень ВЫВОДИТСЯ из тестовых исходников по ВЫЗОВУ, а не по имени в прозе: `os.Getenv("TM_PLATFORM_TEST_…")` даёт переменные, `exec.LookPath("…")` — бинари, которых требуют хелперы (`systemd-run`, `python3`, `make`), плюс своя проба `systemdOrSkip` (достижимый пользовательский менеджер systemd); отдельной строкой назван CREATEDB, который спрашивается только при создании скретч-базы и пробой не проверяется. Литерала с одной переменной больше нет. Гейт `internal/gates` `TestTheBatteryNamesEveryHostConditionItsTestsRead` держит цель к исходникам: краснеет, когда перечень перестаёт выводиться (литеральный список), когда колонка состояния печатает слово вместо ответа хоста и когда колонка «read by» приписывает условие пакету, который его не читает. ⚠ Новая переменная в любом тесте гейт НЕ краснит — цель и гейт выводят её из одних и тех же исходников, поэтому она появляется в обоих; краснеет именно ОТКАЗ от вывода. `STACK_DECISIONS` «Гейты батареи» уже называет четыре условия. Статус — акт лендинга | 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 | @@ -143,7 +143,7 @@ |---|---|---|---|---|---|---| | PD-22 | hardening | info | `deploy/` | Ограничителя одновременных соединений нет ни в процессе, ни описанного edge-прокси: `ReadTimeout` ограничивает УДЕРЖАНИЕ одного соединения 30 секундами, но не их число. Осознанно оставлено деплой-слою (`LimitNOFILE`, edge) — строка заведена, чтобы это было решением, а не забывчивостью | accepted-risk(платформа P1, 05.08) | самопроверка P1 | | PD-42 | hardening | info | `internal/login/login.go` | Лимит `/auth/login` глобальный: один хост держит ведро пустым и выключает вход всем (замерено: 8 отказов из 10 у «легитимного» пользователя при фоне 5 rps). Пер-адресный лимит здесь неверен, пока нет доверенного edge-прокси — за прокси RemoteAddr один на всех. Место лимита — edge | accepted-risk(платформа P1, 05.08) | ревью безопасности (исполнением) | -| PD-71 | bug | info | `internal/pgstore/migrations/00005_identity_oauth.sql:72` | **Down-путь `00005` не исполним на данных, которые его же up-путь делает законными**, поэтому откат ниже версии 5 недоступен. Он восстанавливает `users_email_key` и `email NOT NULL`, а боевой код пишет `email = NULL` у неподтверждённой личности и кладёт один подтверждённый адрес на два аккаунта (следствие «почта не ключ»). **Перепроверено моим прогоном, не принято со слов ревью:** три реальных аккаунта (один с `email = NULL`, два с общим подтверждённым адресом) — `DownTo(5)` проходит, `DownTo(4)` падает с `could not create unique index "users_email_key" (SQLSTATE 23505)`; первым срабатывает индекс, до `NOT NULL` выполнение не доходит. Данные целы — down транзакционный, `Up()` вернул схему на версию 8 со всеми тремя аккаунтами, — но плана отката ниже 5 не существует. Править `00005` запрещает append-only, а чужой down-текст новая миграция не заменяет — **принято как ЦЕНА ПРАВИЛА:** записано в `STACK_DECISIONS §8` и в `deploy/README.md` разделом «Откат релиза: не ниже версии 5», чтобы оператор не узнал это в момент отката | accepted-risk(зона P2, 05.08) | ревью P2 (линза sql-money) | +| PD-71 | bug | info | `internal/pgstore/migrations/00005_identity_oauth.sql:72` | **Down-путь `00005` не исполним на данных, которые его же up-путь делает законными**, поэтому откат ниже версии 5 недоступен. Он восстанавливает `users_email_key` и `email NOT NULL`, а боевой код пишет `email = NULL` у неподтверждённой личности и кладёт один подтверждённый адрес на два аккаунта (следствие «почта не ключ»). **Перепроверено моим прогоном, не принято со слов ревью:** три реальных аккаунта (один с `email = NULL`, два с общим подтверждённым адресом) — `DownTo(5)` проходит, `DownTo(4)` падает с `could not create unique index "users_email_key" (SQLSTATE 23505)`; первым срабатывает индекс, до `NOT NULL` выполнение не доходит. Данные целы — down транзакционный, `Up()` вернул схему на версию 8 со всеми тремя аккаунтами, — но плана отката ниже 5 не существует. Править `00005` запрещает append-only, а чужой down-текст новая миграция не заменяет — **принято как ЦЕНА ПРАВИЛА:** записано в `STACK_DECISIONS §8` и в `deploy/README.md` разделом «Откат релиза: не ниже версии 15» (⚠ раздел переименован 04.09: реальный пол — 15, прежнее имя «…не ниже версии 5» в дереве больше не встречается), чтобы оператор не узнал это в момент отката | accepted-risk(зона P2, 05.08) | ревью P2 (линза sql-money) | | PD-179 | hardening | info | `cmd/tmplatformd/main.go` `serveMetrics`, `deploy/` | **Эндпоинт `/metrics` не аутентифицирован и защищён только адресом привязки.** Дефолт `127.0.0.1:9464`, то есть снаружи недостижим; экспозиция несёт операционную форму деплоя (глубина очереди, число прогонов, возраст холдов), но не пользовательские данные и не деньги. Второй модели авторизации ради скрейпера зона не заводит — это ровно тот довод, по которому админ-поверхность стала CLI (§10). Риск принят: оператор, поднявший `TM_PLATFORM_METRICS_ADDR` на внешний адрес, открывает её сам, и об этом сказано в `deploy/README.md` | accepted-risk(платформа P5, 11.08) | сессия P5 | | PD-400 | doc | info | `internal/runs/bank.go` `bankVerdict` (класс 12) и шапка файла, `internal/runner/engine.go` `Manifest`/`Export` | **Слово `run_in_flight` у двери правок покрывало больше, чем прогон; пер-книжный мьютекс внутрипроцессный.** Разобрана на две половины по слову владельца 27.08 («техдолг в паке не держим»), диспозиция каждой явная. **(1) ЗАКРЫТА этим же деревом:** раскладка слова сделана точной по построению — `run_in_flight` отвечается ТОЛЬКО из проверки собственной строки прогона под мьютексом, а класс 12 от самого глагола под тем же мьютексом прогоном быть НЕ МОЖЕТ (Start/Resume ждут этот мьютекс, реконсилер рестартует только живые строки, которые проверка видит) — это транзиентный держатель флока (границная материализация, ручной tmctl), и он едет `503 service_unavailable` «занято, повтори позже», а не словом про прогон, которого нет; пин — кейс «a held project = a transient holder» в `TestBankVerdictKeepsTheRemediesApart`. **(2) ГРАНИЦА v1, названная с условием и ценой** (на перевод в «Принятый риск» словом лендинга; ⚠ цена ПЕРЕ-ОПИСАНА по воркфлоу-ревью 28.08 и слову оркестратора — прежняя редакция говорила «ложного слова нет», и это неверно): мьютекс `runs.Service.books` — внутрипроцессный; вторая реплика платформы над одним хранилищем сужает сериализацию до пер-репличной. Цена ДВУСТОРОННЯЯ: (а) translate, заспавненный в флок глагола, умирает холостой попыткой — деньги и данные целы; (б) обратная сторона ТОЙ ЖЕ гонки: глагол проигрывает флок НАСТОЯЩЕМУ прогону, который соседняя реплика допустила своим мьютексом по чистой на тот миг таблице, — и класс 12 тогда отвечается `503` «транзиентный держатель, повтори позже» про живой многочасовой прогон, то есть ЛОЖНЫМ словом (канонно верное там — `409 run_in_flight`). Принятие риска включает эту ложь, а не только холостую попытку; оговорка внесена и в комментарий ветки класса 12 (`bank.go`). Условие: граница ПЕРЕСТАЁТ держать в день второй реплики; лечение тогда — арбитр в хранилище, не больший мьютекс. Сегодняшний деплой однорепличный по всей зоне (in-memory `resynced` в том же сервисе — тот же допуск) | accepted-risk(акт D39.162; половина 1 — fixed тем же актом; перевод по аудиту 28.08. Цена риска — по пере-описанию Р8 в теле: «ни денег, ни порчи» держится, «ни ложного слова» — НЕТ, мульти-репличный класс 12 может ответить 503 про живой прогон) | пак P9: опровергатель раскладки кодов (находка 2) · разбор по слову владельца 27.08 · воркфлоу-ревью 28.08 (линза code-layout): цена включает ложное слово · аудит документации 28.08 | ## Закрытые ратификацией @@ -275,7 +275,7 @@ | PD-133 | bug | minor | `internal/httpapi/v0.go` `contractRoutes` | **Инстанс без движка не отдавал НИЧЕГО, вопреки собственной строке лога.** Маршруты монтировались только когда есть И read-model, И жизненный цикл прогонов, поэтому инстанс с библиотекой и без раннера отвечал `404` на `/v0/books`, а его же стартовая строка говорила «библиотека отдаётся только на чтение». Замерено ревью — **закрыто:** ЧТЕНИЯ монтируются при наличии read-model, ручки прогона — при наличии жизненного цикла. Пин — `TestAnInstanceWithoutARunnerStillServesTheLibrary` | fixed(P4, дерево сессии) | адверсариальное ревью (исполнением) | | PD-134 | bug | minor | `internal/pgstore/runs.go`, `internal/runs/spawn.go` | **Пиннинг версии движка (строка 139) записывался и НИКОГДА не читался:** `run_attempts.engine_binary` не входил в выборку реконсилятора, а спавн и канал ремонта брали путь из ТЕКУЩЕГО конфига. Пин, который никто не читает, — это колонка, а не пин: резюм исполнял бы то, что выкатили сегодня, а `status --json` спрашивал бы о книге бинарь другой версии — **закрыто:** `EngineBinary` читается в `LiveRun` и используется и резюмом, и каналом ремонта; конфиг остаётся фолбэком только для ещё не спавненной попытки | fixed(P4, дерево сессии) | адверсариальное ревью (чтение кода) | | PD-135 | bug | minor | `internal/runs/reconcile.go` `restart` | Прерванный прогон без остатка бюджета помечался `paused` БЕЗ `paused_reason` (`FinishRun` его не трогает), тогда как контракт описывает `PausedReason` как причину паузы, и экрану сказать нечего — **закрыто:** используется `PauseRun`, который причину ставит | fixed(P4, дерево сессии) | адверсариальное ревью (чтение кода) | -| PD-136 | doc | minor | `deploy/tmplatformd.service` | Юнит нёс ОБЕ диспозиции сразу: старый абзац подавал `ProtectHome=yes` как «нужную позу на сервере» прямо над строками, ставящими `tmpfs`+`BindPaths`, а рассуждение о ресурсных потолках всё ещё исходило из модели «дети живут в cgroup этого юнита», снятой D39.106. Оператор, читающий сверху вниз, получал противоречивые инструкции в одном файле — **закрыто:** снятые абзацы удалены, потолки прямо названы границей КОНТРОЛ-ПЛЕЙНА, прогоны — своим срезом | fixed(P4, дерево сессии) | адверсариальное ревью (чтение) | +| PD-136 | doc | minor | `deploy/tmplatformd.service` | Юнит нёс ОБЕ диспозиции сразу: старый абзац подавал `ProtectHome=yes` как «нужную позу на сервере» прямо над строками, ставящими `tmpfs`+`BindPaths`, а рассуждение о ресурсных потолках всё ещё исходило из модели «дети живут в cgroup этого юнита», снятой D39.106. Оператор, читающий сверху вниз, получал противоречивые инструкции в одном файле — **закрыто:** снятые абзацы удалены, потолки прямо названы границей КОНТРОЛ-ПЛЕЙНА, прогоны — своим срезом ⚠ **ОСПОРЕНО(PD-136) собственной проверкой 04.09, статус НЕ меняется (норма шапки):** ряд стоял `fixed` с формулировкой «снятые абзацы удалены», а абзац про ресурсные потолки был НА МЕСТЕ и открывался ровно снятой посылкой — «by the argument at the top of this file every tmctl the platform spawns lives in it… the control plane plus every run in flight, together (PD-55)», — противореча и шапке того же файла (`⚠ A RUN IS NOT A CHILD OF THIS UNIT`, D39.106), и последнему абзацу того же блока (`Runs are not in this cgroup at all`). То есть один файл держал ТРИ утверждения, из которых два спорят с третьим, **26 дней** — противоречие возникло 09.08, когда в юнит въехала шапка `D39.106` (`git log -S 'A RUN IS NOT A CHILD OF THIS UNIT'` → `d29e30c`), и снято 04.09. ⚠ Абзац **не снят, а переписан** 04.09 (говорить «снят» было бы повторением той же ошибки, за которую ряд и оспорен): посылка заменена на проверенную — cgroup держит демона И его внутрипроцессных движковых детей (manifest/status, сборка экспорта, bank-apply), но НЕ прогоны. ⚠ Первая попытка правки того же дня сказала «control plane and nothing else» — это ложь в противоположную сторону, тоже исправлена. Заодно снято ложное обоснование `TimeoutStopSec=90` («грация движка 30 с» при `stopGrace = 10 * time.Minute`, который к тому же стоит на ТРАНЗИЕНТНОМ юните прогона, а не на этом). **Урок ряда, ради которого пометка и стоит: «удалено» в закрывающей формулировке — это утверждение о ДЕРЕВЕ, и его надо проверять грепом, а не памятью автора.** | fixed(P4, дерево сессии) | адверсариальное ревью (чтение) | | PD-138 | standards | info | `go.mod` | Прямые зависимости (`riverqueue/river`, `riverdriver/riverpgxv5`) стояли помеченными `// indirect`: `make check` тидинесс не проверяет, поэтому батарея этого не видела — **закрыто:** `go mod tidy`. ⚠ Строка оставлена как заявка: гейта на `go mod tidy` в батарее по-прежнему нет | fixed(P4, дерево сессии) | адверсариальное ревью | | PD-142 | standards | minor | вся зона, тесты | ⚠ **Заявление «22 новых пина, каждый проверен своей посадкой» СНЯТО дофиксом 09.08 как непроверяемое в этом объёме:** прогонов посадок было девять (9/10, затем 9/9), то есть «каждый из 22» ими не покрывался, а поимённого списка соответствия пин↔посадка сессия не вела. Проверено исполнением и названо поимённо другое: 33 посадки самопроверки пака и 24 посадки дофикса (список — журнал, раздел «Дофикс P4»). **Аудит силы пинов посадками (139 мутаций, четвёртый верификатор): 107 поймано, 32 пережили, из них 8 — не ослабления** (эквивалентный код либо страховка DDL-констрейнтом). Пережившие — не дефекты КОДА, а отсутствующие пины на свойства, часть которых объявлена закрытой; по правилу шапки этого файла такое свойство закрытым не считается. **Закрыто: написаны 22 новых пина**; заявление «каждый проверен собственной посадкой» снято — см. начало строки. Самые весомые: блокировка строки попытки под КОНКУРЕНЦИЕЙ (`TestConcurrentDeliveriesOfOneEventCountItOnce` — восемь горутин на одно событие; последовательная доставка поглощается одним high-water mark и посадку не ловила) · CSRF на КОНТРАКТНОЙ поверхности (`TestACrossSiteRequestCannotStartARun` — снятие CSRF из гарда `/v0` переживало всё, а это старт платного прогона с амбиентной кукой) · `RunSpent` считает только свой прогон и не считает открытые холды · обе ветки отложенного расчёта · `MarkSettled` одноразов · хендшейк второго движка отвергается · монотонность байтового хинта · пустой payload · ETA-ноль · черновой юнит не «сделан» · `paused` при нехватке баланса · `principal` падает ЗАКРЫТО · внутренний текст не течёт в `Problem`. ⚠ Одна посадка («убрать `and ended_at is null` из `RestartRun`») пережила и НЕ является ослаблением: `unique (run_id, attempt_no)` отвергает всех проигравших гонку, так что ровно один перезапуск проходит и без неё — записано, а не подчищено | fixed(P4, дерево сессии) | адверсариальное ревью (аудит посадками) | | PD-143 | bug | minor | `internal/runs/spawn.go` `spec`, `internal/pgstore/runs.go` `RestartRun` | **Пиннинг версии движка (строка 139) был закрыт НАПОЛОВИНУ: запиненный путь читался каналом ремонта, но НЕ исполнялся резюмом.** `spec()` брал `Cfg.EngineBinary`, а `RestartRun` не переносил `engine_binary` в новую попытку, поэтому перезапущенный прогон шёл на том бинаре, который выкачен СЕЙЧАС, — то есть перевод продолжала другая программа, и «резюм другой версией только явным флагом» не выполнялось. Найдено собственной пост-сверкой диффа с промтом (не ревью и не батареей: обе половины компилировались и все тесты были зелёными) — **закрыто:** новая попытка НАСЛЕДУЕТ `engine_binary` предыдущей, `spec()` исполняет запиненный путь, а переход на другую сборку требует явного `TM_PLATFORM_RESUME_MAY_CHANGE_ENGINE`. Пины — `runs.TestAResumeStaysOnTheEngineBuildTheRunStartedWith` и `TestAResumeMovesToANewEngineBuildOnlyWhenItIsAllowed`; обе посадки («`spec` берёт из конфига», «`RestartRun` не наследует») падают | fixed(P4, дерево сессии) | пост-сверка диффа с промтом | @@ -287,7 +287,7 @@ | PD-112 | standards | minor | `internal/httpapi/v0.go`, контракт 0.2.0 | **Реализация отдаёт статусы, которых спека у операций НЕ перечисляет — четыре класса, все проверены исполнением адверсариальным ревью:** `503` на старте прогона (деплой не может передать потолок/записать конец юнита) · `403` от CSRF-слоя на любом небезопасном запросе (спека описывает требование `X-TM-Client` в `securitySchemes`, но статуса ему не даёт) · `500` у любой операции при отказе стора (спека не перечисляет 5xx нигде) · `404` у `GET /usage` при отсутствующем аккаунте (спека даёт только 200/401; практически недостижимо — сессия ссылается на строку `users` внешним ключом). Строка ждала решения владельца контракта — **закрыто РАТИФИКАЦИЕЙ (оркестратор №15, 09.08): `503` вносится в спеку правкой владельца контракта при лендинге; код зоны не меняется** | fixed(ратификация 09.08; правка спеки за оркестратором) | сессия P4 (самопроверка против спеки) | | PD-129 | bug | minor | `internal/pgstore/sink.go`, `runs.go` | **Инверсия порядка блокировок между материализатором и финишером:** `RunSink` берёт `books … for update` и затем правит `runs`, а `FinishRun`/`PauseRun` правили `runs` и затем `books`. Два реконсилятора на одном прогоне (перекрытие поколений деплоя) дают взаимоблокировку в обе стороны — замерено ревью, `SQLSTATE 40P01` на обеих формулировках. Порчи нет (Postgres откатывает одну сторону), цена — провалившийся проход свипа и секунда детекта — ⚠ **ПЕРЕ-ДИСПОЗИЦИЯ 09.08: строка была закрыта ЛОЖНО.** Правка P4 привела к книге-первой только `FinishRun`/`PauseRun`; сам материализатор (`RunSink.Apply`) продолжал брать `run_attempts … for update` ПЕРВЫМ, а `RestartRun` — обновлять попытку до всего остального, и приёмка воспроизвела дедлок через реальные API (258 из 300 пар). Закрывающая формулировка описывала половину правки как целое. Действительно закрыто дофиксом — см. PD-145 | fixed(дофикс P4, дерево сессии; см. PD-145) | адверсариальное ревью (исполнением) | | PD-144 | bug | **major, деньги/шов** | `internal/runs/spawn.go`, `internal/ingest/resync.go` | **Движку передавался ПРИРОСТ там, где его флаг означает НАКОПЛЕННЫЙ книжный потолок.** `--ceiling-usd` переопределяет `ceilings.book_usd` и сравнивается с `committed + reserved` книги на КАЖДОЙ резервации (`backend/internal/store/ledger.go` `Reserve`, `backend/cmd/tmctl/invocation.go` — «It caps the book's CUMULATIVE committed+reserved spend, not this run's increment»). Значит второй прогон книги, у которой накоплено ≥ прироста, отвергается первой же резервацией: движок выходит кодом 1, платформа обязана назвать это `failed`, работа не сделана, ретраи идентичны. Приёмка доказала обе стороны исполнением; сквозная проба пака этого не видела, потому что её фейк кумулятив не моделировал — **закрыто:** `runs.meter.bookCap` = `committed + прирост` (ратифицировано 09.08 ре-чеком V2 после PD-158; `reserved` в сумму НЕ входит), обе величины читаются ОДНИМ вызовом `status --json` перед стартом (`bookMeter`), `reserved_usd` внесён в аллоулист УКАЗАТЕЛЕМ (отсутствие ≠ ноль, как у committed), рестарт получает свежий отсчёт по тому же пути, фактически ушедшее значение хранится (`run_attempts.ceiling_arg_micro_usd`, миграция 00011). Пины: `runs.TestTheSecondRunOfABookIsGivenTheCumulativeCapAndNotItsOwnIncrement` и `TestAResumeIsGivenACapComputedFromTheMeterAsItStandsNow` — оба через фейк `ceilingJudge`, который отвергает потолок ПО ПРАВИЛУ ДВИЖКА; `TestAnAttemptWhoseMeterCannotBeReadIsNotStartedAtAll` покрывает отсутствующий `reserved_usd`. Проба приёмки на этом дереве: `--ceiling-usd 6.000000` при committed книги $3 | fixed(дофикс P4, дерево сессии) | приёмка P4 (F1, двусторонним исполнением) | -| PD-145 | bug | **major** | `internal/pgstore/sink.go` `Apply`, `runs.go` `RestartRun`, `internal/runs/reconcile.go` | **Инверсия блокировок из PD-129 была жива, а транзиентный сбой из-за неё уходил в КАРАНТИН.** `RunSink.Apply` брал `run_attempts … for update` первым, `RestartRun` правил попытку до всего остального, а `FinishRun`/`PauseRun` берут книгу первой — приёмка воспроизвела 258 дедлоков на 300 пар через реальные API. Усилитель хуже самого дедлока: `40P01` из `Apply` попадал в ветку «любая ошибка журнала = карантин», то есть проекция ЖИВОГО платного прогона слепла навсегда из-за блокировки, которая разрешилась сама — **закрыто:** порядок написан в одном месте и стал глобальным (`pgstore.lockBook`: books → runs → run_attempts → account_balances → reservations), книга блокируется первой в `Apply`, `RestartRun`, `StartRun` и `DeleteBook` (последний найден собственной сверкой всех транзакций пакета: цикла для него нет, но инвариант, у которого есть исключение, перестаёт быть инвариантом); классификация ошибки вынесена в `runs.quarantines`. ⚠ **Формулировка «карантин остаётся только логическим ошибкам» была НЕВЕРНА в части и исправлена ре-чеком V2:** первая редакция `IsTransient` знала только про дедлок и сериализацию, поэтому обрыв соединения с Postgres — то есть ШТАТНЫЙ рестарт управляемой базы (57P01/57P02/57P03, класс 08, сетевой сброс) — по-прежнему карантинил проекцию живого платного прогона НАВСЕГДА (пути снятия карантина в дереве нет). Доказано исполнением приёмкой. Теперь `IsTransient` покрывает класс 08, 57P0x, `pgconn.SafeToRetry` и любой `net.Error`; ошибки чтения файла — `*fs.PathError` и `net.Error` не удовлетворяют, поэтому битый журнал по-прежнему останавливает проекцию, как и должен. Кейсы внесены в таблицу пина. Пины: `pgstore.TestTheMaterializerTakesTheBookBeforeTheAttempt` и `TestARestartTakesTheBookBeforeTheAttempt` (порядок утверждается ПРЯМО — блокировка книги удерживается, операция обязана ждать её, а строка попытки обязана остаться свободной под `for update nowait`), `TestAMaterializerAndAReconcilerOnOneRunDoNotDeadlock` (конкурентный, 60×3), `runs.TestOnlyAJournalWeCannotReadStopsTheProjection` | fixed(дофикс P4, дерево сессии) | приёмка P4 (F2, исполнением) | +| PD-145 | bug | **major** | `internal/pgstore/sink.go` `Apply`, `runs.go` `RestartRun`, `internal/runs/reconcile.go` | **Инверсия блокировок из PD-129 была жива, а транзиентный сбой из-за неё уходил в КАРАНТИН.** `RunSink.Apply` брал `run_attempts … for update` первым, `RestartRun` правил попытку до всего остального, а `FinishRun`/`PauseRun` берут книгу первой — приёмка воспроизвела 258 дедлоков на 300 пар через реальные API. Усилитель хуже самого дедлока: `40P01` из `Apply` попадал в ветку «любая ошибка журнала = карантин», то есть проекция ЖИВОГО платного прогона слепла навсегда из-за блокировки, которая разрешилась сама — **закрыто:** порядок написан в одном месте и стал глобальным (`pgstore.lockBook`: books → runs → run_attempts → account_balances → reservations), книга блокируется первой в `Apply`, `RestartRun`, `StartRun` и `DeleteBook` (последний найден собственной сверкой всех транзакций пакета: цикла для него нет, но инвариант, у которого есть исключение, перестаёт быть инвариантом); классификация ошибки вынесена в `runs.quarantines`. ⚠ **Формулировка «карантин остаётся только логическим ошибкам» была НЕВЕРНА в части и исправлена ре-чеком V2:** первая редакция `IsTransient` знала только про дедлок и сериализацию, поэтому обрыв соединения с Postgres — то есть ШТАТНЫЙ рестарт управляемой базы (57P01/57P02/57P03, класс 08, сетевой сброс) — по-прежнему карантинил проекцию живого платного прогона НАВСЕГДА (пути снятия карантина в дереве нет) ⚠ **«Снятия карантина в дереве нет» — верно на дату ряда и НЕВЕРНО с пака P13:** ручка построена (`PD-426`, `tmplatformctl run unquarantine --run `); фраза оставлена как часть замера, поправка 04.09.. Доказано исполнением приёмкой. Теперь `IsTransient` покрывает класс 08, 57P0x, `pgconn.SafeToRetry` и любой `net.Error`; ошибки чтения файла — `*fs.PathError` и `net.Error` не удовлетворяют, поэтому битый журнал по-прежнему останавливает проекцию, как и должен. Кейсы внесены в таблицу пина. Пины: `pgstore.TestTheMaterializerTakesTheBookBeforeTheAttempt` и `TestARestartTakesTheBookBeforeTheAttempt` (порядок утверждается ПРЯМО — блокировка книги удерживается, операция обязана ждать её, а строка попытки обязана остаться свободной под `for update nowait`), `TestAMaterializerAndAReconcilerOnOneRunDoNotDeadlock` (конкурентный, 60×3), `runs.TestOnlyAJournalWeCannotReadStopsTheProjection` | fixed(дофикс P4, дерево сессии) | приёмка P4 (F2, исполнением) | | PD-146 | standards | **major (пин)** | `internal/runs/spawn.go` `bookMeter` | **Сердце PD-124 не было запинено: посадка «нечитаемый отсчёт → (0, nil)» пережила ПОЛНУЮ батарею.** С ней расчёт идёт против базовой линии 0, то есть прогон оплачивает всю пожизненную трату книги — ровно тот дефект, который PD-124 объявил закрытым. Отказ спавну был построен и не проверен ни одним тестом — **закрыто:** `TestAnAttemptWhoseMeterCannotBeReadIsNotStartedAtAll` — три формы нечитаемости (вызов упал · нет `committed_usd` · нет `reserved_usd`), и в каждой утверждается, что юнит не создан И попытка не заклеймлена (`unit_name` пуст), значит следующий свип её повторит; хвост теста показывает, что после починки движка та же попытка стартует | fixed(дофикс P4, дерево сессии) | приёмка P4 (F3, посадка) | | PD-147 | standards | **major (пин)** | `internal/runs/spawn.go` `spawnAttempt` | **Очистка устаревшего exit-маркера перед стартом не была запинена: удаление `os.Remove(spec.ExitMarker)` пережило полную батарею.** Залежавшийся маркер той же попытки читается как её окончание при первом же взгляде реконсилятора: прогон, движок которого только что запущен, будет завершён и рассчитан заживо — **закрыто:** `TestAStaleExitMarkerIsClearedBeforeTheUnitStarts` — маркер создаётся ДО спавна, после спавна обязан отсутствовать, а свип обязан оставить прогон живым с открытым холдом | fixed(дофикс P4, дерево сессии) | приёмка P4 (F4, посадка) | | PD-148 | bug | **major, деньги** | `internal/runs/reconcile.go` `settle`, `internal/pgstore/credits.go` | **Расчёт по УСТАРЕВШЕМУ снапшоту свипа возвращал холд целиком попытке, которая потратила.** Свип реконсилит по списку, прочитанному в начале прохода; быстрый прогон успевает стартовать, потратить и выйти, пока свип идёт по предыдущим — и ветка «попытки не было» судила по `l.UnitName == ""` из снапшота, хотя в БД спавн уже записан. Приёмка доказала исполнением: charged 0.000000 за попытку, потратившую 0.500000; недоплата безвозвратна и никем не ищется — **закрыто:** `pgstore.ReleaseUnspawned` перечитывает `run_attempts.unit_name` `for update` В ТОЙ ЖЕ транзакции, что и деньги, и отказывает (`ErrAttemptSpawned`), если юнит есть; реконсилятор откладывает расчёт до следующего прохода, где снапшот уже содержит юнит и попытка рассчитывается по своей базовой линии. Пин: `runs.TestAStaleSnapshotDoesNotGiveBackTheHoldOfAnAttemptThatSpent` (холд не возвращается целиком в проходе со старым снапшотом; следующий свип списывает ровно потраченное) | fixed(дофикс P4, дерево сессии) | приёмка P4 (F5, исполнением) | @@ -296,7 +296,7 @@ | PD-151 | doc | minor | `docs/platform-PROGRESS.md` раздел «Сессия P4» | **Числа отчёта расходились с истиной, а одно заявление было внутренне противоречиво:** тело журнала давало «105 → 203, +98» (истина на момент приёмки — 242/+137/−0), «36 новых строк = 25+10» (истина — 26 закрыто + 10 открыто), и «22 новых пина, каждый проверен своей посадкой (9/10, затем 9/9)» — 22 не покрываются девятью прогонами — **закрыто:** числа пересчитаны ИСПОЛНЕНИЕМ и приведены с командой (`105 → 261`, удалённых 0, добавленных 156 — итог дофикса с ре-чеком V2); арифметика 36 = 26 + 10 исправлена; заявление «каждый» снято (см. PD-142). Шапка журнала была права и не тронута | fixed(дофикс P4, дерево сессии) | приёмка P4 (F8) | | PD-155 | doc | info | `deploy/README.md` | **Смена `TM_PLATFORM_STATE_DIR` осиротляет exit-маркеры идущих прогонов:** маркер пишется по пути, вычисленному при спавне, а читается по пути из текущей конфигурации, поэтому после смены каталога конец прогона невидим и прогон перезапускается — **закрыто:** строка в `deploy/README.md` — менять каталог только при отсутствии живых прогонов | fixed(дофикс P4, дерево сессии) | приёмка P4 (N4) | | PD-156 | bug | info | `internal/runs/spawn.go` `spawnAttempt` | **Отказ ДЕПЛОЯ проверялся после вызова движка.** Дофикс перенёс чтение денежного отсчёта в начало спавна и тем поставил его ПЕРЕД дешёвыми отказами «нечем записать конец юнита» / «нечем передать потолок»: инстанс с неполной конфигурацией платил бы секундами CPU движка (`status --json` пере-нарезает исходник, строка 100) за каждый прогон на каждом свипе, чтобы прийти к ответу, зависящему только от конфигурации — **закрыто:** `runs.runnable()` вызывается первым в `spawnAttempt`, `spec` и `Start`; пин `TestAMisconfiguredDeploymentIsRefusedWithoutAskingTheEngine` (посадка «убрать ранний отказ» падает) | fixed(дофикс P4, дерево сессии) | собственная сверка диффа дофикса | -| PD-158 | bug | minor, деньги | `internal/runs/spawn.go` `meter.bookCap` | **Формула потолка из пинга ратификации (`committed + reserved (из status --json) + прирост`; сам D39.122 §2в ратифицирует ОБЯЗАННОСТЬ платформы пересчитывать «прирост → абсолют», буквальной формулы в решении нет — овер-атрибуцию поправило ревью доков) даёт прогону запас БОЛЬШЕ его холда, когда предыдущий процесс умер с незакрытой резервацией.** Найдено сверкой формулы с кодом движка: `store.Open` — путь ЗАПИСИ, которым идёт каждый `translate` — выполняет `recoverReservations` и обнуляет `reserved_usd` книги ДО первой судимой резервации (`backend/internal/store/store.go:88` и `:214`); `tmctl status` читает read-only и этот проход намеренно не делает (`store.go:108` говорит об этом прямо). Значит цифра, которую видит платформа, — ОСТАТОК мёртвого процесса, и к моменту сравнения её уже нет: движок остановится позже на её величину, расчёт упрётся в потолок холда, а леджер запишет «capped at the hold», как будто перерасходовал движок. Величина ограничена размером остатка (обычно одна оценка вызова), но путь достижим на каждом резюме после падения. **Сделано: `bookCap` считает `committed + прирост`** — отклонение в консервативную сторону (более узкий потолок может только остановить прогон раньше, перерасхода не даёт), названо в коде и запинено `TestAResumeIsGivenACapComputedFromTheMeterAsItStandsNow` (второе утверждение: остаток НЕ раздул потолок). Сама цифра по-прежнему читается и обязательна (отсутствие ≠ ноль) — она и есть доказательство, что отклонение безопасно: на спавне другого писателя нет (эксклюзивный лок + один живой прогон на книгу), значит любой `reserved` — остаток. **ЗАКРЫТО РАТИФИКАЦИЕЙ (оркестратор №15, 09.08, ре-чек V2):** принята формула `committed + прирост`; аргументация подтверждена оркестратором исполнением обеих формул против гейта движка — ратифицированная переплачивала запасом ровно на leftover-reserved | fixed(ратификация 09.08) | собственная сверка формулы с кодом движка при F1 | +| PD-158 | bug | minor, деньги | `internal/runs/spawn.go` `meter.bookCap` | **Формула потолка из пинга ратификации (`committed + reserved (из status --json) + прирост`; сам D39.122 §2в ратифицирует ОБЯЗАННОСТЬ платформы пересчитывать «прирост → абсолют», буквальной формулы в решении нет — овер-атрибуцию поправило ревью доков) даёт прогону запас БОЛЬШЕ его холда, когда предыдущий процесс умер с незакрытой резервацией.** Найдено сверкой формулы с кодом движка: `store.Open` — путь ЗАПИСИ, которым идёт каждый `translate` — выполняет `recoverReservations` и обнуляет `reserved_usd` книги ДО первой судимой резервации (`backend/internal/store/store.go:110` и `:278` — якоря пере-нацелены 04.09, цели уехали с `:88`/`:214`); `tmctl status` читает read-only и этот проход намеренно не делает (`store.go:117-132` `OpenReadOnly` говорит об этом прямо; якорь пере-нацелен 04.09 с `:108`). Значит цифра, которую видит платформа, — ОСТАТОК мёртвого процесса, и к моменту сравнения её уже нет: движок остановится позже на её величину, расчёт упрётся в потолок холда, а леджер запишет «capped at the hold», как будто перерасходовал движок. Величина ограничена размером остатка (обычно одна оценка вызова), но путь достижим на каждом резюме после падения. **Сделано: `bookCap` считает `committed + прирост`** — отклонение в консервативную сторону (более узкий потолок может только остановить прогон раньше, перерасхода не даёт), названо в коде и запинено `TestAResumeIsGivenACapComputedFromTheMeterAsItStandsNow` (второе утверждение: остаток НЕ раздул потолок). Сама цифра по-прежнему читается и обязательна (отсутствие ≠ ноль) — она и есть доказательство, что отклонение безопасно: на спавне другого писателя нет (эксклюзивный лок + один живой прогон на книгу), значит любой `reserved` — остаток. **ЗАКРЫТО РАТИФИКАЦИЕЙ (оркестратор №15, 09.08, ре-чек V2):** принята формула `committed + прирост`; аргументация подтверждена оркестратором исполнением обеих формул против гейта движка — ратифицированная переплачивала запасом ровно на leftover-reserved | fixed(ратификация 09.08) | собственная сверка формулы с кодом движка при F1 | | PD-159 | bug | **major, деньги** | `internal/runs/reconcile.go` `settle`, `internal/pgstore/runs.go` `SpendBound` | **Отложенный расчёт прогона оплачивал работу СЛЕДУЮЩЕГО прогона той же книги, и тот платил за неё ещё раз.** Расчёт читает пожизненный счётчик КНИГИ в момент ПОВТОРА, а откладываться он вправе (движка не спросить). Завершённый-но-нерассчитанный прогон при этом не мешает новому: `HasLiveRun` смотрит только на `finished_at`. Замерено: прогон, стоивший $0.10, списан на $2.10 — своя трата плюс всё, что успел потратить преемник, — после чего преемник заплатил ту же сумму снова; переплата ограничена холдом. Найдено ДВУМЯ независимыми верификаторами самопроверки, каждый воспроизвёл исполнением, и третий раз воспроизведено мной перед починкой — **закрыто:** `SpendBound` — наименьшая базовая линия среди попыток этой книги, стартовавших ПОЗЖЕ; она снята до того, как та попытка что-либо добавила, и после того, как эта остановилась, поэтому является точной верхней границей. Расчёт берёт минимум из неё и текущего счётчика. Пин: `TestADeferredSettlementIsNotChargedForTheNextRunOfTheSameBook` (первый платит $0.10, второй — свои $2.00, баланс и леджер сходятся) ⚠ **ПАК P8-REVIEW 24.08: пин этой строки доказывает свойство СЛАБЕЕ, чем она гласит.** Формула «НАИМЕНЬШАЯ базовая линия среди попыток, стартовавших позже» не исполняется: названный пин кладёт РОВНО ОДНУ более позднюю попытку, а на множестве из одного элемента `min` и `max` совпадают, и мутация `min` → `max` в `internal/pgstore/runs.go` `AbandonRun` (греп `column is empty, so the message names it`) проходит батарею. Живой пробел вынесен строкой `PD-376` с готовым пином; статус этой строки паком НЕ менялся — если приёмка считает, что правило `PD-1` требует пере-открытия, это одна правка, улика уже на месте ⚠ **ОСПОРЕНО(PD-376)** | fixed(дофикс P4, дерево сессии) | самопроверка дофикса (два верификатора, независимо, исполнением) | | PD-160 | standards | minor (пин) | `internal/runs/reconcile.go` `drainJournal` | **Проводка «транзиентный сбой НЕ карантинит» не пинилась: пин стоял на чистой функции `quarantines`, а удаление ветки, которая её ВЫЗЫВАЕТ, переживало батарею.** Регрессия этой формы тихо карантинит проекцию живого платящего прогона — то есть ровно тот дефект, который F2 объявил закрытым — **закрыто:** `TestADeadlockDoesNotStopTheProjection` гонит НАСТОЯЩИЙ дедлок через весь путь (транзакция берёт строки в обратном порядке, Postgres рвёт цикл; раунд, где жертвой стала наша сторона, и есть предмет теста) и утверждает на КАЖДОМ раунде, что попытка не в карантине. Посадка «убрать ветку» падает за 1.9 с | fixed(дофикс P4, дерево сессии) | самопроверка дофикса (посадка) | | PD-161 | bug | minor, деньги | `internal/pgstore/runs.go` `RecordSpawn` | **Повторная заявка права на спавн ПЕРЕЗАПИСЫВАЛА базовую линию.** «Не удалось создать юнит» — не то же, что «юнит не создан»: `systemd-run`, убитый по таймауту ПОСЛЕ подачи запроса, рапортует ошибку и оставляет движок работать. Право отдаётся обратно (`ReleaseSpawnClaim`), следующий свип заявляет его снова и кладёт в базовую линию счётчик, который этот же движок двигает, — попытка потом оплачивает разницу от цифры, уже включающей её собственную работу (недоплата, которую никто не ищет) — **закрыто:** повторная заявка сохраняет и базовую линию, и записанный аргумент потолка (`coalesce` / `case when`), там где юнит действительно не создан значения совпадают. ⚠ **Ре-чек V2 показал, что этим строка закрыта НАПОЛОВИНУ:** в БД значения сохранялись, а движку на ретрае уходил потолок, пересчитанный по СВЕЖЕМУ счётчику — то есть включающий трату собственного «призрака», — и форензик-колонка 00011 на этом пути лгала (замерено приёмкой: handed 3.400000 против stored 3.000000). Закрыто по-настоящему: на ретрае (`SpendBaseline != nil && CeilingArg > 0`) спавн ПЕРЕДАЁТ сохранённый аргумент, а не пересчитанный. Пин: `TestAReclaimedAttemptKeepsTheBaselineItFirstRecorded` — теперь утверждает и базовую линию, и равенство handed == stored | fixed(дофикс P4, дерево сессии) | самопроверка дофикса (ревью вне карты, чтением) | @@ -414,7 +414,7 @@ | PD-271 | bug | info | `internal/httpapi/conditional.go` `acceptsGzip`, `writeJSON` | **Договорённость о кодировании решалась по ПЕРВОМУ токену** (`*, gzip;q=0` читалось как согласие) и **HEAD не получал ни ETag, ни 304**, хотя маршрутизатор отдаёт ему тот же обработчик (Go 1.22+) — **закрыто:** именованное кодирование выигрывает у `*` в любом порядке; валидатор отвечает и на HEAD; пины `TestTheNamedCodingOutranksTheWildcardInEitherOrder`, `TestHeadCarriesTheSameValidatorAsGet` | fixed(приёмка P7, дерево сессии) | приёмка P7 (воркфлоу 12 осей + кросс-модель) | | PD-272 | bug | info | `internal/pgstore/readmodel.go` `scope.tag`, `SubmitBankDecisions` | **Курсор не был привязан к КНИГЕ** (только к версии структуры) и принимался на другой книге; **решения банка** брали блокировки строк в порядке клиентского массива, и два пересекающихся сабмита взаимно блокировались — **закрыто:** книга входит в тег области; решения применяются в порядке `term_id`, отказ по-прежнему называет клиентский индекс; пин `pgstore.TestACursorMintedForOneBookIsRefusedOnAnother` | fixed(приёмка P7, дерево сессии) | приёмка P7 (воркфлоу 12 осей + кросс-модель) | | PD-273 | standards | info | `internal/runs/reconcile.go` `outcome`, контракт §Run | **ЗАКРЫТ решением владельца 17.08: статус остаётся `awaiting_bank`, лечение — в КОНТРАКТЕ.** Разбор: нажать «стоп» на прогоне, УЖЕ стоящем в `awaiting_bank`, нельзя (`RequestStop` требует `finished_at is null`) ⇒ случай ровно один — гонка: стоп запрошен во время перевода, а движок в этом окне домайнил банк и вышел кодом 3. Оба состояния продолжаемы, но ярлык несёт ПРОВОДКУ: `ReleaseBankStop` вызывается только при `l.Status == "awaiting_bank"`, поэтому переименование в `stopped` оставило бы `bank_released = false`, вернуло бы `--verify-bank` на resume и **пере-открыло бы PD-277**. Плюс `awaiting_bank` информативнее: работа стоит на самой дешёвой точке — черновик и майнинг оплачены, редакторская волна не начата. **Настоящая дыра — ПРОВОД: `Run` не умеет сказать «остановлено по вашей просьбе»** (`status` + `stop_for_signing` = опция запуска, а не состояние), поэтому честную фразу клиент нарисовать не может. Строка бэклога для оркестратора — `archive/P7_ACCEPTANCE_HANDOFF_2026-08-17.md` §7(и) | closed (решение владельца 17.08) | приёмка P7 | -| PD-274 | doc | **major** | `deploy/README.md:80`, `docs/PLATFORM_DIRECTION.md:68` | **Деплой-нота включала обратно грант, который PD-104 выключил.** В копируемом блоке окружения стояло `TM_PLATFORM_SIGNUP_GRANT_USD=5`, то есть развёртывание по ноте давало каждой новой личности безлимитный самообслуживаемый грант; в `PLATFORM_DIRECTION.md` §2 «Дефолт $5» пережил правку первого абзаца. Плюс `TM_PLATFORM_LANGUAGE_PAIRS` не назван вовсе, а пустой список делает `/capabilities` и интейк противоположными — **закрыто:** строка снята с объяснением, пары внесены, оба текста актуализированы | fixed(приёмка P7, дерево сессии) | приёмка P7 (воркфлоу 12 осей + кросс-модель) | +| PD-274 | doc | **major** | `deploy/README.md:94`=`TM_PLATFORM_LANGUAGE_PAIRS`, `docs/PLATFORM_DIRECTION.md:68` | **Деплой-нота включала обратно грант, который PD-104 выключил.** В копируемом блоке окружения стояло `TM_PLATFORM_SIGNUP_GRANT_USD=5`, то есть развёртывание по ноте давало каждой новой личности безлимитный самообслуживаемый грант; в `PLATFORM_DIRECTION.md` §2 «Дефолт $5» пережил правку первого абзаца. Плюс `TM_PLATFORM_LANGUAGE_PAIRS` не назван вовсе, а пустой список делает `/capabilities` и интейк противоположными — **закрыто:** строка снята с объяснением, пары внесены, оба текста актуализированы | fixed(приёмка P7, дерево сессии) | приёмка P7 (воркфлоу 12 осей + кросс-модель) | | PD-275 | standards | minor | `internal/pgstore/readmodel_test.go`, `httpapi/reading_test.go`, `httpapi/problem_test.go` | **Три пина не проверяли того, что объявляли.** `TestAMissingBankReadOutSavesNothing` падал на шаг раньше (нет `book.yaml`) и в ветку `ErrNoBank` не входил; `TestALimitIsClampedOrIgnoredButNeverRefused` не мог наблюдать подрезку (фейк выбрасывал `limit`); `title(code) == ""` недостижимо ни для одного кода. Плюс подсистема `Idempotency-Key` целиком без тестов — **закрыто:** три пина переписаны на наблюдаемое свойство, подсистема ключей закрыта семью тестами, карта замечаний (`ingest/notes.go`) — тремя | fixed(приёмка P7, дерево сессии) | приёмка P7 (воркфлоу 12 осей + кросс-модель) | | PD-276 | bug | info | `internal/books/parse.go` `Parse` | **Материализация на границе интейка шла на контексте задачи,** уже потраченном разбором: на большой книге `Refresh` (ещё два процесса движка) не укладывался, и дерево оставалось пустым, а свип этого не повторяет — **закрыто:** свой отвязанный бюджет, как на границе прогона. Бэкстоп-свип для книг с пустым деревом был построен доработкой 20.08 и СНЯТ актом 5 (⚠ `books.materializeMissingTrees`, `pgstore.BooksWithNoTree` и пин `TestABookWhoseTreeNeverLandedIsMaterializedByTheSweep` в дереве больше не существуют) вместе с механизмом, который его заменил: долг на материализацию стал колонкой (PD-302), и он покрывает не только «дерева нет вовсе», но и «дерево на границу старше». Свойство закрыто, доказательство переехало: `books.TestAParsedBookOwesAReadingSurfaceUntilOneIsMaterialized` (плюс проверка, что метка ставится ТОЙ ЖЕ транзакцией, что и конец разбора) | fixed(приёмка P7 + доработка 20.08, дерево сессии) | приёмка P7 (воркфлоу 12 осей + кросс-модель) | | PD-277 | bug | **major** | `internal/runs/spawn.go`, шов с движком | **Экран подписи банка не был подключён к движку: `resume` перезапускал движок с тем же `--verify-bank`,** и тот немедленно вставал в тот же стоп — подпись не двигала ничего. Отчёт пака помечал пункт «исполнен», сверив НЕ ту ветку движка (`if !r.VerifyBank`, по которой resume не идёт). ⚠ Нового канала в движок не требовалось: строка единого бэклога 191(в) (слово владельца 16.08, D39.144) уже требовала «проводку `resume = снятие стопа` до движка», а движок без флага уже делает модель владельца — неподписанное едет авто-строками с пометкой ⟨проверить⟩ (`backend/internal/pipeline/mining.go:211-231`, D39.42 п.3) — **закрыто:** на resume флаг не передаётся (`l.VerifyBank && !l.BankReleased`, `bank_released` протянут в `LiveRun`); пин `runs.TestAResumedRunIsSpawnedWithoutTheSigningStop`. ⚠ **ПЕРЕ-ДИСПОЗИЦИЯ 22.08:** прежний остаток строки («честность `decline`»: отклонённый термин уезжал в банк авто-строкой) снят вместе с глаголом — `decline` больше нет ни в одной ручке зоны (PD-370). Живой остаток другой и он НЕ здесь: ратифицированная модель оставляет пер-термной ПРАВКУ («поправить/добавить термин»), ручки под неё нет ни в зоне, ни в каноне, и это строка 199(а) единого бэклога плюс контрактная половина PD-370. | fixed(приёмка P7, дерево сессии) | приёмка P7 | @@ -602,7 +602,7 @@ |---|---|---|---|---|---|---| | PD-89 | hardening | minor | `cmd/tmplatformctl/main.go` `grant` (греп `return write(ctx, store, out, *user, *key`) | **Сминченный ключ идемпотентности не печатается при ошибке записи:** PD-75 закрыл путь ПОСЛЕ коммита, но неоднозначный обрыв НА коммите остался — оператор видит ошибку, повторяет без `--key`, `newKey()` чеканит новый ключ, второе начисление проходит. Фикс — печатать ключ вместе с ошибкой ⚠ **ПАК P13 03.09: лечение в дереве.** Якорь — `cmd/tmplatformctl/main.go` `write()` (греп `is spent under this key`). Ошибка операции возвращается с ключом и рецептом повтора (`… (key K: if the write did commit it is spent under this key, so a repeat under the same key — for grant and adjust, --key K — is applied at most once)`) — едет на stderr вместе с ошибкой, `out` пуст, чтобы скрипт, читающий поток исходов, не принял отказ за исход. Формулировка называет ключ, а не флаг, потому что `seed` зовёт тот же `write()` с фиксированным ключом `seed-` и флага `--key` не имеет: его простой повтор и так идёт под тем же ключом. Пин `TestAFailedWriteNamesTheKeyItUsedSoTheRetryCannotCreditTwice` (без DSN). Статус — акт лендинга | fixed(6ae3e76) | приёмка P2 (панель) | | 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` `reopen` (греп `budget, err := s.Store.RunBudget`) (до P13 стояло `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` — то есть денежный путь дёргает КЛИЕНТ, а не только реконсилятор ⚠ **ПАК P13 03.09: лечение в дереве, ОБА места.** `reopen` читает бюджет из холда ПЕРВОЙ попытки (`pgstore.RunBudget`: `reservations.amount_micro_usd` по ключу `#1`) и тем же числом выдаёт funded consent на `Resume`; ставка в `reopen` больше не читается (`grep -c 'Pricing\.' internal/runs/reconcile.go` → 0). Холда нет — `ErrNoFirstHold`, без фолбэка на ставку. Пины: `TestARestartHoldsWhatTheRunWasSoldForWhenTheRateHasMovedSince` (свип; ×2, ÷2, ниже потраченного), `TestAResumeHoldsWhatTheRunWasSoldForWhenTheRateHasMovedSince` (замер ряда: $2.50, не $5.50), `TestAResumeOverAMovedBankGrantsTheConsentTheRunWasSoldFor`, `TestAContinuationWithoutTheFirstHoldIsRefusedRatherThanRepriced`. Объявленное следствие: прерванный ре-проход (`ceiling_chapters = 0`) свип теперь продолжает на остатке холда, а не ставит `paused` по `Ceiling(0) = 0` — пин `TestAnInterruptedRePassIsRestartedWithWhatIsLeftOfItsHold`. Статус — акт лендинга | fixed(6ae3e76) | самопроверка дофикса (два верификатора, один исполнением) | -| PD-214 | bug | info | `internal/ingest/tail.go` `apply` | **Чужая или битая hello-строка в общем журнале книги гасит материализацию СВОЕГО потока.** Версия, непустой `engine_run_id` и `seq == 1` проверяются для ЛЮБОЙ hello-строки ДО того, как код решает, чья она: ветка «not mine» стоит после них. Значит чужой процесс (ручной `tmctl` оператора в каталоге книги, старый мажор, баг чужой сборки) валит `Tail` ошибкой, а `quarantines()` считает её терминальной — проекция здорового платящего прогона уходит в карантин НАВСЕГДА (снятия карантина в дереве нет), свежесть падает на медленный ре-синк. Данные целы, деньги целы. Лечится порядком: при известном `want` чужой id распознаётся ДО валидации хендшейка ⚠ **ПАК P13 03.09: лечение в дереве ровно этим порядком.** При известном `want` чужой `engine_run_id` распознаётся сразу после декода payload и ДО `checkVersion` / пустого id / `seq != 1` (декод остаётся выше: без него принадлежность неизвестна); при `want == ""` валидация полная — усыновление чужого мажора было бы тем же дефектом с другой стороны. Пины (без DSN): `TestAForeignHandshakeThisBuildCannotReadIsSkippedRatherThanQuarantiningOurs` (пять форм чужой строки), `TestOurOwnHandshakeThisBuildCannotReadStillStopsTheProjection`, `TestAnAdoptedHandshakeIsStillValidatedInFull`, `TestAHandshakeThatDoesNotDecodeIsRefusedEvenWhenTheReaderKnowsItsName`. ⚠ **Одного пере-упорядочивания оказалось МАЛО, и это отдельная строка `PD-438`:** пока владение потоком не переживало проход свипа, тот же класс возвращался строкой ниже — чужие события следующего прохода ложились на нашу попытку. Обе половины вылечены тем же деревом. Статус — акт лендинга | fixed(6ae3e76) | адверсариальное ревью P6 (линза шва) | +| PD-214 | bug | info | `internal/ingest/tail.go` `apply` | **Чужая или битая hello-строка в общем журнале книги гасит материализацию СВОЕГО потока.** Версия, непустой `engine_run_id` и `seq == 1` проверяются для ЛЮБОЙ hello-строки ДО того, как код решает, чья она: ветка «not mine» стоит после них. Значит чужой процесс (ручной `tmctl` оператора в каталоге книги, старый мажор, баг чужой сборки) валит `Tail` ошибкой, а `quarantines()` считает её терминальной — проекция здорового платящего прогона уходит в карантин НАВСЕГДА (снятия карантина в дереве нет) ⚠ **«Снятия карантина в дереве нет» — верно на дату ряда и НЕВЕРНО с пака P13:** ручка построена (`PD-426`, `tmplatformctl run unquarantine --run `); фраза оставлена как часть замера, поправка 04.09., свежесть падает на медленный ре-синк. Данные целы, деньги целы. Лечится порядком: при известном `want` чужой id распознаётся ДО валидации хендшейка ⚠ **ПАК P13 03.09: лечение в дереве ровно этим порядком.** При известном `want` чужой `engine_run_id` распознаётся сразу после декода payload и ДО `checkVersion` / пустого id / `seq != 1` (декод остаётся выше: без него принадлежность неизвестна); при `want == ""` валидация полная — усыновление чужого мажора было бы тем же дефектом с другой стороны. Пины (без DSN): `TestAForeignHandshakeThisBuildCannotReadIsSkippedRatherThanQuarantiningOurs` (пять форм чужой строки), `TestOurOwnHandshakeThisBuildCannotReadStillStopsTheProjection`, `TestAnAdoptedHandshakeIsStillValidatedInFull`, `TestAHandshakeThatDoesNotDecodeIsRefusedEvenWhenTheReaderKnowsItsName`. ⚠ **Одного пере-упорядочивания оказалось МАЛО, и это отдельная строка `PD-438`:** пока владение потоком не переживало проход свипа, тот же класс возвращался строкой ниже — чужие события следующего прохода ложились на нашу попытку. Обе половины вылечены тем же деревом. Статус — акт лендинга | fixed(6ae3e76) | адверсариальное ревью P6 (линза шва) | | 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), заведено строкой ⚠ **ПАК P13 03.09: лечение в дереве, обе половины.** Снятие — `tmplatformctl run unquarantine --run ` (`pgstore.Unquarantine`: чистит `quarantine_reason` ЖИВОЙ попытки, курсор не трогает — те же байты нечитаемы ⇒ следующий свип карантинит снова с той же причиной; три отказа своими словами: нет прогона · нет живой попытки · не в карантине); причина видна колонкой QUARANTINE в `tmplatformctl runs`. Порядок в тейлере — `PD-214`. Формулировка «по чужому ЗАКОННОМУ handshake'у» — переупрощение ровно наполовину: в ОДНОМ проходе законный чужой hello и раньше пропускался (`TestAnotherAttemptsStreamInTheSameJournalIsSkipped`), карантинил чужой ИЛИ БИТЫЙ (другой мажор · пустой id · `seq != 1`); а ЧЕРЕЗ проход законный чужой hello и был путём и в карантин, и в порчу проекции — `PD-438`. Пины: `TestALiftedQuarantineMaterializesTheJournalAgainFromTheCursor` (свип, DSN), `TestLiftingAQuarantineClearsItAndSaysWhatItWas` (CLI, DSN). Статус — акт лендинга | fixed(6ae3e76) | приёмка оркестратора №19 по паку P11 (охотник вне карты) | | PD-436 | doc | minor | `deploy/README.md` (греп `безопасного порядка НЕТ ни в одну сторону`), `internal/ingest/manifest.go` `Readable` (греп `m.Version != KnownManifestVersion`) | **Рантбук зоны советовал деструктивный порядок деплоя при бампе формы манифеста: «Правило то же, что у схемы хранилища: сначала платформа, потом движок».** Неверно дважды. Первое: гейт интейка — строгое равенство ОДНОЙ константе, окна двух форм нет, поэтому платформа, выкаченная первой со знанием новой формы, отправляет в `parser_unavailable` КАЖДУЮ книгу ещё не обновлённого движка — тот же симптом и тот же бюджет попыток до `rejected`, что абзац описывал для обратного порядка; безопасного порядка у бампа формы НЕТ, это стоп-мир (приём закрыт → оба билда → приём открыт) с дренажем «трёх фактов» перед ним, пока в гейте не построено окно двух форм. Второе: правило схемы хранилища, на которое ссылался абзац, — «движок первым → `migrate` → платформа» (D39.158 п.6), то есть ОБРАТНОЕ названному, и про другой шов. ⚠ Лечение в дереве пака P13 (03.09): абзац рантбука переписан; окно двух форм НЕ строится — не заказано. Статус — акт лендинга | fixed(6ae3e76) | сборка промта P13 оркестратором; подтверждено чтением `manifest.go` сессией P13 | | PD-437 | standards | info | `internal/runner/translate_resnapshot_live_test.go` `TestTheSnapshotGuardIsLoudWithoutTheFlagsAndPassesWithThem`, `docs/STACK_DECISIONS.md` «Стенд разработчика» (рецепт шаблона), движок `backend/configs/pipeline-c1.yaml` · `models.yaml` | **Живой тест снапшот-гарда обещает «free of provider keys», а на шаблоне, собранном по рецепту стенда, движок отказывает ему за ключом ДО гарда.** Замер 03.09 при всех четырёх условиях хоста на чистой копии `HEAD 494c4ef` с движком из того же `HEAD`: батарея 661 теста, 660 PASS, 0 скипов и ровно один FAIL — `the priming run failed (10): tmctl: missing API keys (fill in backend/.env): provider deepseek: env DEEPSEEK_API_KEY is not set (needed for model deepseek-v4-flash)`. Причина: пробная книга (`writeProbeBook`) наследует `pipeline:` шаблона, а шаблон по рецепту = `backend/example/book.yaml` → `pipeline-c1.yaml`, чей переводчик — `deepseek-v4-flash`; тест ждёт шаблон, чей пайплайн — локальная $0-пара на `127.0.0.1:11434`, но ни рецепт стенда, ни сам тест этого условия не называют. Следствие: «скипов 0 и батарея зелёная» на хосте без ключа недостижимы, а ключ в окружении сделал бы тест платным. Подстановка фиктивного ключа — обход, не лечение; чинить надо либо посылку теста (свой `pipeline:` с $0-парой поверх шаблона), либо рецепт ⚠ **ПАК P13 03.09, по прямому слову владельца («чини»): вылечена ПОСЫЛКА ТЕСТА.** `writeProbeBook` рендерит пробной книге СВОЙ пайплайн (`zeroCostPipeline`, греп в `internal/runner/bankapply_live_test.go`): берёт пайплайн шаблона, заменяет модель каждой стадии на модель ЛОКАЛЬНОГО провайдера, снимает хопы эскалации и `label_models`, обнуляет `escalation.budget_usd`. Локальная модель НЕ вписана константой, а находится в `models.yaml` того же шаблона по `kind: local` — деплой без локального провайдера даёт громкий скип, а не чужой ключ; механизм сверен по коду движка (`backend/internal/config/models.go` `checkKeysFor` пропускает провайдера с `kind == "local"`). Промпты и калибровка пары резолвятся от каталога конфига, поэтому рендер лежит в ЗЕРКАЛЕ каталога деплоя (ссылки на `prompts`/`pairs`/`langpacks`), и вне каталога книги — иначе он попадал в опись пробы «превью ничего не пишет». Пины: `TestTheProbePipelineLeavesNoPaidModelReachable` (рендер на синтетическом конфиге, без движка) и сам `TestTheSnapshotGuardIsLoudWithoutTheFlagsAndPassesWithThem`, который теперь ЗЕЛЁН без ключей. Рецепт стенда НЕ трогала: он про деплой оператора, а обещание «free of provider keys» давал тест. Статус — акт лендинга | fixed(6ae3e76) | пак P13 (базовая линия батареи при полных условиях, до правок) | @@ -612,3 +612,4 @@ | ID | Класс | Серьёзность | Где | Суть | Статус | Источник | |---|---|---|---|---|---|---| | PD-442 | bug | minor | `internal/pgstore/migrations/00002_readmodel.sql` (таблица `exports`), канон `14-api-contract/openapi.yaml` §Export | **В схеме с самой первой миграции читающей модели жила таблица `exports`, у которой НЕТ ни одного читателя и ни одного писателя, и её форма ПРОТИВОРЕЧИТ ратифицированному контракту.** Она несла `ready boolean` плюс `failed_reason text`, а канон объясняет прямо, почему так нельзя: «A state and not a boolean: a boolean merges three situations into "not ready" and a poll on it never ends» (§`Export.state`). То есть будущая дверь выдачи, честно построенная на существующей таблице, отдала бы поллинг, который не кончается — ровно тот дефект, который канон и запрещает. Обнаружено при постройке двери: `sqlc generate` отказался с `relation "exports" already exists`, и это единственный сторож, который у класса был. Проверка отсутствия потребителей: `grep -rn '\bexports\b' --include=*.go internal/ cmd/` до пака давал ОДИН хит и тот в комментарии (`internal/httpapi/capabilities.go`). ⚠ Мигрировать было нечего — ни строки ни разу не записывалось, — поэтому `00032_exports.sql` таблицу СНОСИТ и создаёт заново в форме канона; down-путь восстанавливает форму `00002` дословно, потому что откат обязан вернуть то, что выпущенная миграция оставила. Пин формы: `internal/pgstore/exports_test.go` (четыре состояния, партиальные индексы свипа, каскад с книгой) | fixed(дерево пака «закрыть цикл», 04.09) | пак «закрыть цикл» 04.09 (найдено постройкой двери выдачи) | +| PD-447 | doc | minor | `docs/STACK_DECISIONS.md` §«Как поднять локально» и `deploy/README.md` шаг 3–4 (оба исправлены); носители дефолта — `internal/config/config.go:281`=`TM_PLATFORM_ADDR` и `cmd/tmplatformctl/seed.go:41`=`base URL of the running tmplatformd` | **Оба стендовых рецепта зоны принимали ОТВЕТ ПО АДРЕСУ за доказательство того, что отвечает СВОЙ процесс, и потому проходили при мёртвом собственном демоне.** Замерено исполнением 04.09: на машине разработки четвёртые сутки жил чужой `tmplatformd` на `127.0.0.1:8080` со своей базой; демон рецепта умирает в этой ситуации на `bind: address already in use` — молча, он запущен фоном, — а смоук следующей строкой получает `healthz=200` и `readyz=ready` ОТ ЧУЖОГО ПРОЦЕССА. ⚠ **Дороже смоука — шаг сида:** `tmplatformctl seed` дарит `$25` кредита и грузит книгу ЧЕРЕЗ ЖИВОЙ ИНТЕЙК, то есть по этому рецепту деньги и данные уезжают в чужой деплой. Второй адрес там же был написан отдельным литералом (`--url http://127.0.0.1:8080`), что и есть штатный способ разъехаться с `TM_PLATFORM_ADDR` незаметно. ⚠ **Три лечения на три РАЗНЫЕ половины, ни одно не заменяет другое:** явный `_ADDR` уводит с общего дефолта; проба по `pid` слушателя отвечает на вопрос, на который `200` не отвечает в принципе — ЧЕЙ это процесс; `--noproxy '*'` нужен потому, что при заданных `http_proxy` голый `curl` на `127.0.0.1` уходит во внешний прокси. Проба предъявлена в обе стороны: свой pid на своём порту — проходит, чужой демон против своего pid — отвергается. ⚠ **Дефолт `8080` в коде НЕ меняется, и это решение:** коллизия — `tmplatformd` против `tmplatformd`, любой другой номер даст ту же аварию на втором одновременном стенде, а цену смены заплатят все существующие деплои и доки. Чинится посылка, а не номер. **Вес minor, а не major, по радиусу:** пишет только дев-стенды, боевого пути этим рецептом нет, деньги — стендовый грант. | fixed(`adf5e53`) | найдено оркестратором №22 при независимой проверке отзыва причины смертей демона, 04.09; починено зоной | diff --git a/platform/docs/PLATFORM_DIRECTION.md b/platform/docs/PLATFORM_DIRECTION.md index fd8790f3..cef5018f 100644 --- a/platform/docs/PLATFORM_DIRECTION.md +++ b/platform/docs/PLATFORM_DIRECTION.md @@ -106,8 +106,10 @@ hosted IdP (связка PII + доступность, а свою сессию > ⚠ **`oapi-codegen` ПЕРЕ-ПОДПИСАН `D39.132` п.2б: КАНДИДАТ**, решение за паком, который его возьмёт. > Довод отказа P7 (20.08) остаётся действующим аргументом, а не историей: > -> - **`oapi-codegen`.** Довод «на P7 ручек станет больше, значит дешевле сейчас» проверен фактом: 20 -> операций контракта, из них построено 14, и КАЖДАЯ требует рукописной проекции read-модели в +> - **`oapi-codegen`.** Довод «на P7 ручек станет больше, значит дешевле сейчас» проверен фактом: на 20.08 — 20 +> операций контракта, из них построено 14; **на 04.09 — 21 и 18** (пере-счёт: `operationId:` в +> `openapi.yaml`, записи `contractSurface` в `internal/httpapi/v0.go`; непостроены `updateBook`, +> `deleteBook`, `getRun`), то есть довод не устарел, а усилился. И КАЖДАЯ требует рукописной проекции read-модели в > контрактные словари (`internal/httpapi/project.go`) — генератор даёт имена полей, а переводит > словари всё равно человек. Плюс замеренное 05.08: 3.1-условия (`if action=approve → dst`) > генератор игнорирует, и исполнителем правила остаётся констрейнт БД. Взамен пак поставил ДРУГОЙ diff --git a/platform/docs/STACK_DECISIONS.md b/platform/docs/STACK_DECISIONS.md index 0e04ef3c..7ed65b72 100644 --- a/platform/docs/STACK_DECISIONS.md +++ b/platform/docs/STACK_DECISIONS.md @@ -13,7 +13,7 @@ |---|---|---|---| | Go (язык, `go.mod`) | **1.26.4** | 02.06.2026 | Тот же floor, что у движка (`backend/go.mod`) — общий стенд собирает оба модуля одним тулчейном | | Go (тулчейн сборки, `make version-check` + `toolchain` в `go.mod`) | **≥1.26.6** | 13.08.2026 | Поднят с 1.26.5 не по вкусу, а по `make vuln`: база адвизори опубликовала пять уязвимостей stdlib против 1.26.5 — `net/http`, `crypto/tls`, `net/url`, `encoding/xml`, `encoding/asn1` (GO-2026-6218/6090/6089/6088/5972), все закрыты в 1.26.6, и две трассируются в пути, которые эта служба зовёт (`pgstore.Open → pgx.ParseConfig → asn1.Unmarshal`). На 1.26.6 батарея зелёная и скан чист. ⚠ Как floor РЕАЛЬНО держится: `make version-check` СРАВНИВАЕТ версии (`sort -V`, префиксы `rc`/`devel` отвергаются), и `go.mod` несёт `toolchain go1.26.6` — его читает всякая сборка, даже мимо make: при `GOTOOLCHAIN=auto` хост скачает нужный тулчейн, при `=local` остановится с ошибкой. Само сравнение запинено (`internal/gates`) | -| PostgreSQL | **18.x** (проверено на 18.4), floor **16** | 18.4 — май 2026 | 18 — текущая мажорная (19 в бете, в прод не берём); floor 16, потому что River тестируется на трёх последних мажорных | +| PostgreSQL | **18.x** (проверено на 18.4), floor **16** (⚠ **ничем НЕ гейчен, в отличие от Go-floor выше:** `Open` только разбирает DSN, `Ready` сверяет `goose_db_version` И наличие схемы очереди (`select to_regclass('river_job')`), но не версию сервера, `server_version` в зоне не читается нигде — Postgres ниже 16 поднимет службу и упадёт впервые на запросе, чьего синтаксиса не знает) | 18.4 — май 2026 | 18 — текущая мажорная (19 в бете, в прод не берём); floor 16, потому что River тестируется на трёх последних мажорных | | HTTP | stdlib `net/http` + `ServeMux` | — | Роутер-библиотека не нужна: `ServeMux` с 1.22 умеет метод+wildcards, а `Request.Pattern` даёт лог по маршруту, не по пути | | CSRF | stdlib `http.CrossOriginProtection` | Go 1.25 | Ровно тот механизм, что описан в §5 (Sec-Fetch-Site → Origin), теперь в тулчейне — свой велосипед не пишем | | Postgres-драйвер | `github.com/jackc/pgx/v5` **v5.10.0** | 03.06.2026 | Живой pool, `pgconn.PgError` для проверки констрейнтов, `stdlib` для goose | @@ -22,7 +22,7 @@ | OIDC-вход | `golang.org/x/oauth2` **v0.36.0** + `github.com/coreos/go-oidc/v3` **v3.20.0** | 11.02.2026 · 08.07.2026 | Ратифицировано `PLATFORM_DIRECTION.md` §1 (там же — что отдано библиотекам и что пишем сами); сверено живьём 05.08. Транзитивно приходит `go-jose/v4` v4.1.4 | | Рейт-лимит в процессе | `golang.org/x/time` **v0.15.0** | 11.02.2026 | `rate.Limiter` на ОБЕИХ неаутентифицированных ручках, которые ПИШУТ: `/auth/login` (строка состояния) и `/auth/callback` (строка журнала на каждом отказе — замерено ~880 строк/с с одного хоста, пока лимита не было) | | Линтер | `golangci-lint` **2.12.2** | 06.05.2026 | Тот же пин, что у движка: находки версионно-зависимы, разъезд пинов = разные гейты в одном репо | -| Кодоген SQL | `sqlc` **v1.31.1** | 22.04.2026 | **Инструмент разработчика, НЕ зависимость модуля** — в `go.mod` не входит, рантайм-граф не растёт ни на один пакет; пин держит `make tools-check`, как у линтера, и по более острой причине: генерённый код лежит В ДЕРЕВЕ, поэтому другая версия молча даёт другой диф и `sqlc diff` краснеет на чистом клоне. **Зачем:** генерирует слой запросов свободного от склейки блока `internal/pgstore` (40 запросов из `queries/*.sql`) — SQL и его `Scan` перестают быть двумя списками, которые сверяет человек ПО ПОЗИЦИИ. Взят по решению владельца 20.08 (`D39.153` п.6а, уточнение `D39.154`), носитель — `BACKLOG.md` П-19. ⚠ Довод «гейт `sqlgate_test.go` это уже закрыл» ПРОВЕРЕН и не подтвердился: гейт получает только СТРОКУ SQL и Go-сторону вызова не видит — шесть посаженных перестановок целей `Scan` и сломанных арностей ВЫЖИЛИ на полной батарее (замер 29.08, `platform-PROGRESS.md`). Схему читает ТОТ ЖЕ каталог `migrations/`, что гейтит манифест (второго носителя нет); деньги держит подстановочный override `*.*_micro_usd` → `money.MicroUSD` — без него генератор выдаёт голый `int64`, с ним снятие override становится ОШИБКОЙ СБОРКИ. Актуальность генерации гейчена дважды: `sqlc diff` в `make check` и `pgstore.TestEveryGeneratedQueryMatchesItsSourceFile` в батарее, который работает и без установленного sqlc | +| Кодоген SQL | `sqlc` **v1.31.1** | 22.04.2026 | **Инструмент разработчика, НЕ зависимость модуля** — в `go.mod` не входит, рантайм-граф не растёт ни на один пакет; пин держит `make tools-check`, как у линтера, и по более острой причине: генерённый код лежит В ДЕРЕВЕ, поэтому другая версия молча даёт другой диф и `sqlc diff` краснеет на чистом клоне. **Зачем:** генерирует слой запросов свободного от склейки блока `internal/pgstore` (50 запросов из `queries/*.sql`) — SQL и его `Scan` перестают быть двумя списками, которые сверяет человек ПО ПОЗИЦИИ. Взят по решению владельца 20.08 (`D39.153` п.6а, уточнение `D39.154`), носитель — `BACKLOG.md` П-19. ⚠ Довод «гейт `sqlgate_test.go` это уже закрыл» ПРОВЕРЕН и не подтвердился: гейт получает только СТРОКУ SQL и Go-сторону вызова не видит — шесть посаженных перестановок целей `Scan` и сломанных арностей ВЫЖИЛИ на полной батарее (замер 29.08, `platform-PROGRESS.md`). Схему читает ТОТ ЖЕ каталог `migrations/`, что гейтит манифест (второго носителя нет); деньги держит подстановочный override `*.*_micro_usd` → `money.MicroUSD` — без него генератор выдаёт голый `int64`, с ним снятие override становится ОШИБКОЙ СБОРКИ. Актуальность генерации гейчена дважды: `sqlc diff` в `make check` и `pgstore.TestEveryGeneratedQueryMatchesItsSourceFile` в батарее, который работает и без установленного sqlc | | Уязвимости | `govulncheck` **v1.6.0** | 09.07.2026 | Отдельная цель `make vuln`, не часть `check`: ей нужна сеть, а батарея обязана быть зелёной на голом клоне офлайн | **Redis нет** — архитектурное «нет» в силе, с доводом: иначе он приползёт по частям. Дом решения — @@ -61,7 +61,7 @@ изменить строку в манифесте, где это видно ревьюеру. Апгрейд со старого релиза проверен исполнением (`TestDatabaseAtAnOlderReleaseCatchesUp`), down-путь — тоже. - > ⚠ **Цена правила, названная честно: откат НИЖЕ версии 5 недоступен.** Down-путь `00005` + > ⚠ **Цена правила, названная честно: откат НИЖЕ версии 15 недоступен, а ниже 5 — тем более.** Down-путь `00005` > восстанавливает `users_email_key` и `email NOT NULL` — ровно то, что его же up-путь снял, — а > обе эти формы нарушаются строками, которые пишет боевой код: `email = NULL` у неподтверждённой > личности и один подтверждённый адрес на двух аккаунтах (прямое следствие «почта не ключ»). @@ -69,13 +69,23 @@ > а новая миграция чужой down-текст не заменяет. Данные при этом целы: down транзакционный, > `Up()` возвращает схему на текущую версию (проверено прогоном: `DownTo(4)` падает на > `users_email_key`, SQLSTATE 23505). Принято как цена правила. + > + > ⚠ **ГРАНИЦА УЕХАЛА С 5 НА 15 ТЕМ ЖЕ МЕХАНИЗМОМ, и до 04.09 доки этого не знали.** Down-путь + > `00015` сужает `runs_paused_reason_check` обратно к одному `credit_exhausted`, а его же + > up-путь легализовал `daily_ceiling` и `ceiling_unknown` — оба пишет боевой код через + > `internal/pgstore/books.go` `CeilingPause`. ⚠ **Пол зависит от ДАННЫХ, а не от схемы:** + > `DownTo(<15)` падает только там, где такая пауза случалась, — поэтому тестовая база, + > в которой её не было, честно катится до 5 (`internal/pgstore/pg_test.go`), и зелёный тест + > НЕ опровергает границу. Носитель — открытый ряд `PD-218`. 9. **Ключ личности — `(provider, subject)`; почта не ключ.** `users.email` стала NULLABLE и БЕЗ уникального индекса; неизвестная пара всегда создаёт НОВЫЙ аккаунт. Разбор и цена решения — `platform/docs/archive/platform-PROGRESS-P0-P3.md:496`=`### Политика коллизии почты` (раздел закрыт вместе с эрой P0–P3, в живом журнале его нет). 10. **Админ-поверхность — CLI (`tmplatformctl`), не HTTP-ручка.** Ручке понадобилась бы вторая - модель авторизации (роли, эскалация, отзыв админской куки) ради пяти операций - (`grant` · `adjust` · `balance` · `logins` · `revoke`), тогда как + модель авторизации (роли, эскалация, отзыв админской куки) ради ПЯТИ операций, какими они были на дату решения + (`grant` · `adjust` · `balance` · `logins` · `revoke`); на 04.09 их **двенадцать** — прибавились + `book add` · `book refresh` · `books` · `runs` · `run abandon` · `run unquarantine` · `seed`, + то есть довод не устарел, а усилился. Тогда как граница доверия «есть шелл на машине и доступ к DSN» уже обеспечена машиной. Браузерная панель, если понадобится, обернёт те же вызовы стора. 10а. **Имя провайдера — `TM_PLATFORM_OIDC_PROVIDER`, и оно должно меняться ВМЕСТЕ с издателем.** @@ -220,7 +230,7 @@ ## Что решено дофиксом P4 (09.08) -21. **`--ceiling-usd` — это КНИЖНЫЙ потолок в силе, а не бюджет прогона, и пересчёт лежит на платформе.** Флаг переопределяет `ceilings.book_usd` и сравнивается с накопленным `committed + reserved` книги на КАЖДОЙ резервации (`backend/internal/store/ledger.go` `Reserve`; сам флаг документирует это словами «not this run's increment»). Пользователь же покупает ПРИРОСТ в главах (D39.110), поэтому платформа обязана перевести одно в другое — эта обязанность и ратифицирована D39.122. Формула зоны: **`аргумент = committed + прирост×ставка`**. ⚠ Слагаемого `reserved` в ней нет, вопреки букве пинга ратификации, и это НАЗВАННОЕ отклонение в консервативную сторону: `store.Open` (путь записи каждого `translate`) обнуляет остаточный `reserved_usd` книги ДО первой судимой резервации (`backend/internal/store/store.go:88`, `:214`), а read-only `status` этот проход не делает — значит прочитанная цифра к моменту сравнения уже стёрта, и её добавление отдало бы прогону запас БОЛЬШЕ его холда. Разбор — PD-158; **ратифицировано 09.08** (оркестратор пере-мерил обе формулы против гейта движка: ратифицированная переплачивала запасом ровно на leftover-reserved). Обе величины читаются ОДНИМ вызовом `status --json` перед стартом — они должны быть согласованы между собой, а второй вызов стоит секунды CPU на пере-нарезку. +21. **`--ceiling-usd` — это КНИЖНЫЙ потолок в силе, а не бюджет прогона, и пересчёт лежит на платформе.** Флаг переопределяет `ceilings.book_usd` и сравнивается с накопленным `committed + reserved` книги на КАЖДОЙ резервации (`backend/internal/store/ledger.go` `Reserve`; сам флаг документирует это словами «not this run's increment»). Пользователь же покупает ПРИРОСТ в главах (D39.110), поэтому платформа обязана перевести одно в другое — эта обязанность и ратифицирована D39.122. Формула зоны: **`аргумент = committed + прирост×ставка`**. ⚠ Слагаемого `reserved` в ней нет, вопреки букве пинга ратификации, и это НАЗВАННОЕ отклонение в консервативную сторону: `store.Open` (путь записи каждого `translate`) обнуляет остаточный `reserved_usd` книги ДО первой судимой резервации (`backend/internal/store/store.go:110`, `:278`), а read-only `status` этот проход не делает — значит прочитанная цифра к моменту сравнения уже стёрта, и её добавление отдало бы прогону запас БОЛЬШЕ его холда. Разбор — PD-158; **ратифицировано 09.08** (оркестратор пере-мерил обе формулы против гейта движка: ратифицированная переплачивала запасом ровно на leftover-reserved). Обе величины читаются ОДНИМ вызовом `status --json` перед стартом — они должны быть согласованы между собой, а второй вызов стоит секунды CPU на пере-нарезку. Три следствия, каждое построено: `reserved_usd` лежит в аллоулисте УКАЗАТЕЛЕМ и обязателен (отсутствие ≠ ноль) — он не входит в потолок, но именно он доказывает, что отклонение безопасно: на спавне другого писателя нет, значит любой резерв по построению остаток; рестарт считает по СВЕЖЕМУ отсчёту, потому что прерванная попытка счётчик сдвинула; фактически ушедшее значение хранится (`run_attempts.ceiling_arg_micro_usd`) — после сдвига счётчика его нечем восстановить, а «какой лимит был у того процесса» это первый вопрос к прогону, вставшему рано. @@ -230,7 +240,7 @@ Порядок утверждается ПРЯМЫМ пином, а не конкурентным прогоном: тест держит замок книги, дожидается, пока операция реально заблокируется, и проверяет строку попытки через `for update nowait`. Конкурентная проба оставлена, но она пробует — посадка «снять книгу-первой из `RestartRun`» её пережила, потому что рестарт берёт замок один раз за прогон. -23. **«Ошибка материализации» — два разных факта, и различает их `runs.quarantines`.** Транзиентное — повтор следующим свипом; сюда входят не только дедлок и сериализация (`40P01`/`40001`), но и всё, во что превращается ШТАТНЫЙ рестарт управляемого Postgres: класс 08, `57P01`/`57P02`/`57P03`, `pgconn.SafeToRetry`, любой `net.Error`. ⚠ Снятия карантина в дереве нет, поэтому ошибочный карантин необратим и ослепляет проекцию живого платного прогона навсегда. Граница держится на типах: ошибка чтения ФАЙЛА — `*fs.PathError`, а он `net.Error` не удовлетворяет; пропасть, конфликт payload, битая строка — карантин ПОПЫТКИ (её проекции), прогон при этом продолжается и продолжает платить. +23. **«Ошибка материализации» — два разных факта, и различает их `runs.quarantines`.** Транзиентное — повтор следующим свипом; сюда входят не только дедлок и сериализация (`40P01`/`40001`), но и всё, во что превращается ШТАТНЫЙ рестарт управляемого Postgres: класс 08, `57P01`/`57P02`/`57P03`, `pgconn.SafeToRetry`, любой `net.Error`. ⚠ Пока карантин стоит, проекция живого ПЛАТНОГО прогона слепа, а прогон идёт и тратит; **снятие построено паком P13** (`PD-426`, `fixed(6ae3e76)`) — `tmplatformctl run unquarantine --run ` (`internal/pgstore/runs.go` `Unquarantine`) чистит `quarantine_reason` ЖИВОЙ попытки и НЕ трогает курсор: те же нечитаемые байты следующий свип карантинит снова с той же причиной, и это честный ответ, а не сбой команды. Три отказа своими словами: нет такого прогона · у прогона нет живой попытки · попытка не в карантине. Пины — `TestLiftingAQuarantineClearsItAndSaysWhatItWas` и `TestALiftedQuarantineMaterializesTheJournalAgainFromTheCursor`; рантбук — `deploy/README.md` §«Застрявшая работа». Граница держится на типах: ошибка чтения ФАЙЛА — `*fs.PathError`, а он `net.Error` не удовлетворяет; пропасть, конфликт payload, битая строка — карантин ПОПЫТКИ (её проекции), прогон при этом продолжается и продолжает платить. ## Что решено сессией P5 (11.08) — загрузка книги, стоп/резюм, наблюдаемость @@ -388,8 +398,8 @@ | `.bank-stop.txt` | `pipeline/mining.go` `os.WriteFile` (греп `bankStopTablePath`) | **НЕ атомарно** (усечение первым делом) | читателя нет | ✅ **и это важное правило, а не наблюдение: брать его на ЖИВОМ прогоне нельзя** — прочитаешь обрезанный файл без всякой ошибки | | `.mined-signature.yaml` | `pipeline/mining.go` `writeFileAtomic` (греп `signatureMapPath`) | **атомарно** | читателя нет | ✅ ⚠ испр. 02.09: писатель сменился на атомарный, прежняя строка «`os.WriteFile`, НЕ атомарно» была протухшей | | `.auto-bank.yaml` | `pipeline/mining.go` `writeFileAtomic` (греп `autoBankPath`) | **атомарно** | читателя нет | ✅ то же исправление | -| `mined_delta` (путь из `book.yaml`) | **писателя в движке НЕТ** — только читатель `loadMinedDelta`; формат `seed.File` (`terms:`), грузится `membank.LoadGlossarySeed`, `Source` пере-штампуется на `"mined"` | — | **писателя НЕТ и у платформы** | ❌ **разрыв — это строка 199(а) единого бэклога**, развилка ждёт ратификации | -| `mined_rejects` | читатель `loadMinedRejects`; формат `rejects: [{src, note}]` — это ФИЛЬТР ПРЕДЛОЖЕНИЙ, в банк не входит | — | писателя нет | ❌ тот же разрыв | +| `.mined-delta.yaml` — путь **ДЕРИВИРОВАННЫЙ**, в `book.yaml` не объявляется (`backend/internal/config/book.go` `MinedDelta string \`yaml:"-"\``, «DERIVED, never read from the file») | **писатель В ДВИЖКЕ ЕСТЬ:** `pipeline/bankdecisions.go` `writeDecisionFiles` (глагол `tmctl bank-apply`); читатель `loadMinedDelta`; формат `seed.File` (`terms:`), грузится `membank.LoadGlossarySeed`, `Source` пере-штампуется на `"mined"` | **атомарно** (стейдж + переименование, каталог книги синкается) | платформа их НЕ пишет и писать не должна — `pipeline/status.go`: «the engine remains their only writer»; её половина канала — документ решений, строкой ниже | ✅ **разрыв ЗАКРЫТ** (D39.156 п.3 · D39.166). ⚠ Ключи `mined_delta:`/`mined_rejects:` в `book.yaml` **РЕТАЙРНУТЫ**: непустое значение — жёсткая ошибка загрузки конфига (`config/book.go`, `RetiredMinedDelta`), то есть ключ в шаблоне платформы уронил бы КАЖДУЮ новую книгу | +| `.mined-rejects.yaml` — путь деривируется так же (`MinedRejects string \`yaml:"-"\``) | писатель тот же (`writeDecisionFiles`); читатель `loadMinedRejects`; формат `rejects: [{src, note}]` — ФИЛЬТР ПРЕДЛОЖЕНИЙ, в банк не входит | **атомарно**, переименовывается ПЕРВЫМ | платформа не пишет (тот же запрет) | ✅ то же: закрыт D39.156 п.3 / D39.166, ключ ретайрнут | | `tmctl bank-apply` + документ решений | зона ПИШЕТ (`internal/runs/bank.go` `decisionsFile` → файл во временном каталоге), движок отвечает отчётом `tm-bank-report-v1` на stdout | документ пишется целиком до вызова; отчёт — одноразовая выдача на вызов | `internal/runner/bankapply.go` → `ingest.DecodeBankReport` → `internal/runs/bank.go` `bankVerdict` | ⚠ ЕДИНСТВЕННЫЙ канал, по которому платформа ПИШЕТ в проект движка, — отсюда и своя дверь (`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` | `internal/runner/build.go` (`BuildArgs`/`Build`) → `ingest.DecodeBuild` → `internal/exports` (04.09, пак «закрыть цикл») | ✅ **Читатель есть с 04.09, и он СТРОИТ, а не подбирает** (D39.175 п.2). Три правила канала, каждое куплено кодом движка: **(1) `--out` обязателен.** Без него `build` пишет рядом с БД и УДАЛЯЕТ форматы, о которых не просили (`pipeline/bookbuild.go`, цикл `RemovedFiles`) — то есть экспорт `txt` снёс бы операторский `epub`, а два экспорта одной книги затирали бы артефакт друг друга. С `--out` уборки нет вовсе, каждый экспорт — свой неизменяемый файл, и TTL с GC становятся платформенными. **(2) `--partial` обязателен** — дверь ВСЕГДА строит (D39.178 п.1), и exit **16** через неё недостижим по построению: увидели 16 — значит флаг не доехал, это дефект нашей проводки, а не книга с дырами. **(3) `--keys-file` НЕ передаётся**: глагол $0 и без ключей, движок отказывает во флаге на всём, кроме `translate` (D20.4). ⚠ Ловушка exit **11** УЧТЕНА, а не унаследована: у `build` он значит «в книге ноль выходных юнитов», и дверь кладёт его в свой код `book_empty`, не приближаясь к словарю интейка, который на 11 УДАЛЯЕТ загрузку. `BuildReport` сверяется и уходит ОПЕРАТОРУ в лог (`config_drift`/`stale_unknown`/удалённые копии), на провод не идёт | | `tmctl export --json --pairs` | движок (`pipeline/export.go`) | — (одноразовая выдача на вызов) | `internal/runner/engine.go` `ExportArgs`/`Export` → `ingest.DecodeExport` → `internal/readmodel` | ✅ ⚠ **Единственный канал, несущий ТЕКСТ пары** — исходник и перевод; манифест несёт только структуру | @@ -466,7 +476,7 @@ system_messages not found in type config.CapabilitiesConfig`. Диагноз с состояние `cgroup.subtree_control` среза `tm-runs.slice` в момент прогона — назван КАНДИДАТОМ и только: диагноз `PD-423` не установлен, и вносить его в рецепт как проверку нельзя. -Ожидание при всех четырёх: 18 пакетов, exit 0, **скипов 0**, линтер «0 issues». Замерено 29.08: с гейтами — 0 скипов на обоих деревьях; без них — exit 0 и **287 скипов на HEAD +Ожидание при всех четырёх: 19 пакетов, exit 0, **скипов 0**, линтер «0 issues». Замерено 29.08: с гейтами — 0 скипов на обоих деревьях; без них — exit 0 и **287 скипов на HEAD `fbe6cf3`**, **304 на дереве пака P11** (пак добавил 17 пинов, гейченных тем же DSN). Число зависит от дерева, и переносить его между ними нельзя. @@ -578,10 +588,16 @@ KEY=VALUE; едет движку АРГУМЕНТОМ `--keys-file` на `transl ⚠ Все они печатаются на старте с источником (`default`/`environment`/`file`) — PD-114; секреты и денежные суммы печатаются фактом наличия, без значения. -Админ-команды: `tmplatformctl grant --user --usd 5 [--note ...] [--key ...]` · -`balance --user ` · `logins --user ` · `revoke --user ` · `books [--migratable]` -(какие книги безопасно мигрировать при апгрейде движка — `deploy/README.md`) · `seed` (дев-стенд: -аккаунт, кредит и книга через ЖИВОЙ интейк, §31). +Админ-команды (список сверен с `usage` самого бинаря, `cmd/tmplatformctl/main.go:44-62`, — прежняя +редакция была короче на шесть): `tmplatformctl grant --user --usd [--note ...] [--key ...]` · +`adjust --user --usd --note [--key ...]` (КОРРЕКЦИЯ баланса второй строкой леджера, со знаком; деньги кладёт `grant` — строки леджера не редактируются, `cmd/tmplatformctl/main.go:135,160`) · `balance --user ` · `logins --user [--limit ]` · +`revoke --user ` · `book add` (дев-интейк) · `book refresh --book ` (попросить читающую +поверхность заново у книги, на которой её бросили) · `books [--migratable] [--abandoned]` (какие книги +безопасно мигрировать при апгрейде движка, какие потеряли поверхность — `deploy/README.md`) · +`runs [--stalled]` · `run abandon --run --reason [--release-hold]` (терминальный вердикт +оператора застрявшему прогону) · `run unquarantine --run ` (материализовать журнал карантинной +попытки заново) · `seed` (дев-стенд: аккаунт, кредит и книга через ЖИВОЙ интейк, §31). +⚠ `exit-marker ` в этот список не входит: её зовёт systemd как `ExecStopPost`, не оператор. ⚠⚠ **УСЛОВИЯ СТЕНДА КЛЮЧУЮТСЯ ПОЛЬЗОВАТЕЛЕМ И `$HOME`, А НЕ ИМЕНЕМ МАШИНЫ.** Замерено 04.09: две смены на ОДНОМ `hostname` (`DESKTOP-IN1MCEA`) видят противоположные условия — у одного пользователя diff --git a/platform/docs/platform-PROGRESS.md b/platform/docs/platform-PROGRESS.md index 7126ce94..8dbad5f3 100644 --- a/platform/docs/platform-PROGRESS.md +++ b/platform/docs/platform-PROGRESS.md @@ -1,7 +1,88 @@ # Журнал зоны «Платформа» +## НАХОДКА В ЧУЖОЙ ЗОНЕ, ЖИВШАЯ ТОЛЬКО В ПЕРЕПИСКЕ — зеркало контракта у фронта (04.09, `textmachine-main-34`) + +⚠ **Пишу сюда, потому что переписка между сессиями умирает вместе с сессиями, а канон велит долговечному знанию жить в репозитории.** Находка сделана по вопросу оркестратора «не построен ли фронт под старую версию контракта», передана ему сообщением — и до этой записи существовала ТОЛЬКО там. + +**Фронт держит СВОЮ копию контракта:** `frontend/docs/api-contract/openapi.yaml`, `version: 0.2.3`, файл последний раз двигали лендингом `267aa35` от 30.08. Канон сегодня — `0.10.0`. Отставание **восемь миноров**. + +⚠ **Хуже отставания то, что их гейт его не видит и увидеть не может:** `frontend/src/api/contract.test.ts` читает `${cwd}/docs/api-contract/openapi.yaml` — **своё же зеркало**, а не канон. Тест «версия фикстуры = версия спеки» сверяет копию с копией и остаётся зелёным при любом отставании; соседний тест сверяет только МАЖОР, а он у `0.2.3` и `0.10.0` одинаковый — ноль. Их собственный комментарий это признаёт: «It drifted a whole session unnoticed, because the client compares the MAJOR». + +**Сравнение, ради которого запись и стоит в ЭТОМ журнале:** гейт платформы (`internal/gates/contract_test.go`) читает КАНОН с диска и поймал расхождение `0.9.0 → 0.10.0` в тот же час, когда оно возникло. Один класс гейта, разный источник истины — разница между «поймано за час» и «не поймано за восемь миноров». **Гейт, читающий свою копию факта, доказывает лишь, что копия согласна с копией.** + +⚠ **Что при этом НЕ подтвердилось** (и это важно, потому что на гипотезе строился поиск): версия `0.2.3` уже описывала потолок в ГЛАВАХ — `CeilingBounds`: «Bounds of the run-ceiling scale, in CHAPTERS. The chapters → money conversion lives on the platform and is not exposed here in any form». Значит память владельца о денежном ползунке зеркалом фронта НЕ объясняется: искать надо раньше `0.2.3` и вне контрактных носителей. + +**Зону фронта не трогала и в их журнал не писала** (`frontend/docs/frontend-PROGRESS.md` — их зона). Передано оркестратору; рекомендация — перенацелить их гейт версии на канон, иначе следующая смена единицы уедет к ним тем же способом, молча. + +## ОТВЕТ НА ПИНГ №22 — ревизия отработана, и ЧЕТЫРЕ ЕЁ ПУНКТА ОКАЗАЛИСЬ ЧАСТИЧНО НЕВЕРНЫ (04.09, `textmachine-main-34`) + +⚠ **Ни одно утверждение пинга не принято на веру.** Канон велит грунтовать выводы `file:line` и верифицировать спорное адверсариально, а пинг — заявления другой сессии. Каждый из 11 пунктов проверен отдельным агентом, которому задача ставилась **опровергать**, а не подтверждать. Итог: **7 подтверждено, 4 частично** — и в «частичных» есть прямо опровергнутое. + +### ⛔ ЧТО В САМОМ ПИНГЕ ОКАЗАЛОСЬ НЕВЕРНЫМ — это важнее списка починок + +1. **`PD-439` «семь строк вне словаря → две» — ОПРОВЕРГНУТО, ряд НЕ тронут.** Пере-счёт всех 447 строк регистра против словаря шапки дал ровно **семь** нарушителей, и это ровно те семь ID, которые ряд называет. Правка сделала бы регистр ЛОЖНЫМ. Это единственный пункт пинга, исполнение которого ухудшило бы дерево. +2. **«Писатель `mined_delta` есть с обеих сторон» — неверно для платформы.** Движок — единственный писатель по замыслу (`pipeline/status.go`: «the engine remains their only writer»), платформа пишет только документ решений в свой каталог. В таблицу это ушло в исправленном виде. +3. **«Каталог экспортов вне песочницы даёт каждому экспорту `deployment_error`» — неверный симптом.** Каталог создаёт САМ ДЕМОН на буте (`cmd/tmplatformd/runner.go:208`), и невозможность создать — **отказ старта демона**, а не отказ экспорта; если каталог есть, но только на чтение, падает подкаталог книги и код отказа `build_failed` (`internal/exports/exports.go:358`). Записан верный симптом, а не тот, что в пинге. +4. **Пункты 10(b) и 10(c) НЕ написаны сознательно:** обе формулировки внесли бы в доки ложь. Половина (b) уже покрыта исправлением пункта 5. +5. **Два моих же верификатора разошлись про `STATE_DIR`** — один утверждал, что дефолт вне `ReadWritePaths=`, другой что он и есть `StateDirectory=`. Разрешено чтением юнита: прав второй (`StateDirectory=tmplatform` → `/var/lib/tmplatform`, systemd создаёт и делает записываемым). Довод «зона поймала бы это сама» тут не работает — ровно это ложное объяснение и стояло в рантбуке полтора месяца. + +### ⛔ ВТОРОЙ КРУГ: САМОПРОВЕРКА НАШЛА ОШИБКИ В МОЕЙ ЖЕ ПОЧИНКЕ — ни одного чистого файла из восьми + +⚠ **Это главный результат ревизии, и он про метод, а не про доки.** Закончив правки, я пустила по СВОЕЙ готовой работе восемь агентов — по одному на изменённый файл, с задачей искать (1) ложь, ВНЕСЁННУЮ правкой, и (2) носители, которые правка сделала протухшими. **Чистых файлов не оказалось ни одного.** + +**Самое опасное: я заменила одну ложь ПРОТИВОПОЛОЖНОЙ.** Юнит утверждал, что в его cgroup лежат все прогоны (до-`D39.106` посылка). Я написала «control plane and nothing else» — и это тоже неверно: **четыре движковых вызова идут ПРЯМЫМИ ДЕТЬМИ демона** и в его cgroup лежат — `manifest`/`status` (`internal/runner/engine.go:201`), сборка экспорта (`build.go:88`), `bank-apply` (`bankapply.go:73`) и сам `systemd-run` (`runner.go:107`). Транзиентный юнит — только `translate`. В юните теперь стоит проверенная формулировка и НАЗВАНЫ обе прежние ошибки, потому что читателю через полгода важно знать, что маятник качался дважды. + +**Остальное, найденное по себе:** +· `TimeoutStopSec=90` я объяснила через `stopGrace = 10 минут` — но это `TimeoutStopSec` ТРАНЗИЕНТНОГО юнита прогона (`runner.go:166`), а не грация, которую соблюдает демон. И тот же снятый довод дословно лежал ВТОРЫМ носителем в рантбуке — не заметила. +· **Три ссылки на номера строк, которые сдвинул мой же дифф:** «строка 58», `tmplatformd.service:67`, и позже ещё четыре токенных якоря. Правя чужие уехавшие якоря, я наплодила своих. Лечение — не аккуратность, а **форма**: адрес пишется с токеном (`путь:строка`=`токен`), тогда гейт его проверяет; без токена он молча врёт. +· `adjust` я описала как «пополнение» — он **коррекция** со знаком, деньги кладёт `grant` (`cmd/tmplatformctl/main.go:135,160`). +· «`Ready` сверяет лишь `goose_db_version`» — проверок **две**: ещё наличие схемы очереди (`select to_regclass('river_job')`). +· В ряду `PD-136` написала «абзац снят» — он **переписан**. Ровно та ошибка, за которую ряд и оспорен: «удалено» — утверждение о дереве. Там же «полтора месяца» пере-считано по истории в **26 дней** (`git log -S 'A RUN IS NOT A CHILD OF THIS UNIT'` → `d29e30c`, 09.08), и токен оспаривания приведён к грепаемой форме `ОСПОРЕНО(PD-136)`. +· Пере-нацелила ПОЛОВИНУ пары якорей: `store.go:88→:110` сделала, `:214→:278` в §21 забыла — и те же три мёртвых адреса нашлись в ПРОД-комментарии `internal/runs/spawn.go`. +· `PD-157` пере-нацелила на `:331` — литералы `book_usd/day_usd` на `:332`; промах на строку. +· Добавила второй пункт `ENGINE_KEYS_PATH`, не убрав старый: список «четвёрки» стал из шести пунктов про пять переменных. +· Мой же указатель в `README.md` («отчёт последнего пака первый сверху») стал ложным от МОИХ ЖЕ добавлений в верх журнала. +· Переименование раздела осиротило `PD-71`, который звал его старым именем; правки в рантбуке осиротили якорь `PD-274`; §10 продолжал считать пять админ-операций при двенадцати. + +⚠ **Вывод, ради которого этот раздел и написан: правка документации — это тоже изменение с побочными эффектами, и она нуждается в ревью исполнением ровно так же, как код.** Восемь агентов по своей работе нашли больше, чем одиннадцать по чужой. Мандат самопроверки в промтах зон (`CLAUDE.md`) сформулирован для кода и запросов к моделям — этот замер говорит, что он нужен и для доков. + +⚠ **Носители ВНЕ зоны, которые эта волна сделала протухшими, я не трогала** — чужая зона, передано оркестратору пингом: `docs/architecture/14-api-contract/README.md` (строки про «15 операций из 20», «на двух создающих вызовах», открытую половину двери выдачи) и `docs/PROGRESS.md:6`. ✅ **ДВА из трёх закрыты им же в тот же день, коммит `9e103d9`** (две строки: 999 и 1008) — живых вхождений «15 операций из 20» и «на двух создающих вызовах» в `docs/` не осталось. ⚠ **Третий НЕ закрыт:** `docs/architecture/14-api-contract/README.md:1005` по-прежнему говорит «открыта только дверь платформы», хотя она построена лендингом `adf5e53`; и `docs/PROGRESS.md:6` держит цифры зеркала фронта «СЕМЬ миноров, `0.2.3` против `0.9.0`» при каноне `0.10.0` и восьми минорах. Оба остаются на оркестраторе. ⚠ Первая редакция этой строки сказала «закрыты» про все три — обобщение по двум грепнутым фразам. ⚠ Строку правлю сама, потому что без этой отметки она бы утверждала о чужой зоне то, что там уже неверно, — тот же класс, за который эта же ревизия и затевалась. + +### ⛔ ТРЕТИЙ КРУГ: аудит ДВУХ файлов, которые второй круг не покрыл, нашёл ещё шесть моих ошибок + +⚠ **Второй круг я пустила по восьми файлам и НЕ включила в него самый крупный — этот журнал.** Пробел закрыт отдельным проходом, и он окупился: шесть внесённых мной ошибок, из них две — повторение того же класса, который эта ревизия и разбирает. + +1. **«Семь якорей пере-нацелены по проверенным целям» — проверка была верна и протухла от МОИХ ЖЕ дальнейших правок тех же файлов.** Я снял якоря автоматически по токену, убедился, что красных ноль, а потом ещё трижды правил `deploy/README.md` — и три якоря снова уехали. **Утверждение о проверке живёт ровно до следующей правки; честно только то, что проверено ПОСЛЕДНИМ действием.** Формулировка снята, якоря пере-сняты циклом «чинить, пока линтер не чист». +2. **В пометке ряда `Д3` я поменял два адреса местами:** `const base, cap` стоит на `reconcile.go:424` (это первый якорь самого ряда), а `stoppedOnRequest` объявлена на `:1136` и зовётся на `:1005`. Написал ровно наоборот. +3. **Дату лендинга зеркала фронта взял из текста коммита, а не из истории:** `267aa35` — **15.08**, не 30.08. Ошибка занижала возраст расхождения на две недели, то есть работала против собственного вывода. +4. **«Носители вне зоны закрыты» — закрыты ДВА из трёх.** Обобщил по двум грепнутым фразам; третий (`14-api-contract/README.md:1005`, «открыта только дверь платформы») и цифры зеркала в `docs/PROGRESS.md:6` живы. +5. **Ложь «отчёт последнего пака стоит первым» я исправил в `README.md` и оставил во втором носителе** — в примечании к переименованному заголовку, то есть ровно там, куда идёт сбитый онбордингом читатель. +6. **«Линтер якорей при всём этом давал НОЛЬ»** — верно про дерево ДО волны; красным он стал от неё самой. + +⚠ **Счёт кругов, ради которого раздел и стоит: одиннадцать агентов по ЧУЖОЙ работе нашли 11 пунктов, восемь по СВОЕЙ — ещё около двадцати, два по оставшимся файлам — ещё шесть.** Плотность находок на своей работе оказалась не ниже, чем на чужой. Это довод не в пользу большего числа агентов, а в пользу того, что **правка доков без ревью исполнением — такой же непроверенный код**. + +### ЧТО ИСПРАВЛЕНО + +· **Блокер деплоя (п.1):** `TM_PLATFORM_CTL_BIN` указывал на `/opt/textmachine/bin/`, которого рантбук НЕ создаёт ⇒ `os.Stat` не находит ⇒ `MarkerArgv` пуст ⇒ **каждый старт и резюм отвечает `503`**. Значение приведено к пути, куда рантбук ставит бинарь. ⚠ И снято ложное объяснение: пустая переменная — НЕ отказ (сиблинг-дефолт), отказывает НЕВЕРНЫЙ путь. +· **Сверх-утверждение «без этих четырёх» (п.6):** из четырёх прогоны гейтит ОДНА (`ENGINE_BIN`); у `CTL_BIN` и `STATE_DIR` рабочие дефолты, `ENGINE_KEYS_PATH` даёт WARN и роняет первый ПЛАТНЫЙ вызов. Каждая из четырёх описана тем, что ломает именно она. +· **Linger:** «без него не стартует НИ ОДИН прогон» снято как ложное для стенда — замер зоны H2 дал `Linger=no` при шести стартовавших прогонах; для боевого деплоя требование осталось. +· **Инвентарь каналов (п.2):** путь `mined-delta` **деривируется**, ключи в `book.yaml` **ретайрнуты** (ключ в шаблоне уронил бы КАЖДУЮ новую книгу), писатель в движке есть. Две строки таблицы переписаны. +· **§23 карантин (п.3):** «снятия нет, карантин необратим» — ручка построена паком P13 (`PD-426`), названа с командой, поведением и двумя пинами. +· **Пол отката (п.4):** 5 → **15**, в трёх носителях (рантбук, §8, комментарий `pg_test.go`). ⚠ И названо то, чего не было ни в пинге, ни в доках: **пол зависит от ДАННЫХ, а не от схемы** — тестовая база честно катится до 5, и зелёный тест границу НЕ опровергает. +· **Юнит (п.5):** снято обоснование `TimeoutStopSec=90` «грацией движка 30 с» (реально `stopGrace = 10 минут`, и прогон юниту не ребёнок) и снята до-`D39.106` посылка про cgroup, спорившая с шапкой ТОГО ЖЕ файла и с последним абзацем ТОГО ЖЕ блока. +· **`PD-136` оспорен собственной проверкой:** ряд стоял `fixed` с формулировкой «снятые абзацы удалены», а абзац был на месте полтора месяца. Статус не меняю (норма шапки), пометка стоит. **Урок ряда: «удалено» в закрывающей формулировке — утверждение о ДЕРЕВЕ, и его проверяют грепом, а не памятью автора.** +· **Открытое/закрытое (п.7):** `П-21` держал развилку, разрешённую кодом (`PD-369`, пак P12) · `П-17` говорил, что `operationId` третьему адресу не дан — дан минором `0.10.0` · `PD-447` со статусом `fixed(adf5e53)` стоял в секции «Открытые — minor» (**моя ошибка того же дня**) и перенесён в закрытую эру. +· **Ловушка онбординга (п.8):** заголовок «Текущее состояние», лежавший на строке ~2587 из ~2851 и кончавшийся 24.08, **переименован** в «Состояние эры P8 — ИСТОРИЧЕСКИЙ раздел». Оба указателя `README.md` ведут теперь на ВЕРХ журнала. Лечил переименованием, а не предупреждением: заголовок, обещающий текущее, и есть ловушка. +· **Числа и адреса (п.9):** `Idempotency-Key` на **трёх** создающих вызовах (и тот же счёт исправлен в коде — `internal/httpapi/idempotency.go`), карта `httpapi` знает `exports.go`, 18→19 пакетов, 40→50 запросов, список админ-команд сверен с `usage` бинаря (был короче на шесть), `20/14`→`21/18` операций **с датировкой обеих редакций**, семь якорей пере-нацелены. ⚠ **Формулировку «по проверенным целям» снимаю:** проверка была верна в момент снятия и протухла от МОИХ ЖЕ последующих правок тех же файлов. Верно только то, что проверено ПОСЛЕДНИМ действием, и оно — в строке гейтов ниже. +· **Ряд `Д3` помечен, но НЕ пере-нацелен:** он внутри ЗАКРЫТОГО раунда ревью, и пере-указание якорей в законченном раунде переписывает чужой замер. +· **Противоречие в этом журнале (п.11):** два абзаца спорили, погашен ли чужой стенд. Верен первый. Заодно исправлен счёт баз на кластере: их четыре, а не две. + +⚠ **Почему пункт 1 не поймал ни один гейт и не поймает:** линтер якорей на дереве ДО этой волны давал НОЛЬ (красным он стал уже от неё самой — разбор во втором круге ниже). Всё найденное — ложь в ПРОЗЕ по верным адресам. Гейт проверяет, что цель существует и что в ней есть токен; он не проверяет, что ПРЕДЛОЖЕНИЕ вокруг адреса истинно. Это граница инструмента, а не его дефект, и закрывает её только чтение под конкретный вопрос. + ## ⚠ ПИНГ ОРКЕСТРАТОРА №22 (04.09, вечер) — РЕВИЗИЯ ДОКУМЕНТАЦИИ ЗОНЫ ЦЕЛИКОМ +⚠ **Адреса ВНУТРИ этого пинга сняты ДО починки и частью уже не действительны** — текст пинга не переписываю (чужой замер), но читателю это знать надо: ряд `PD-447` уехал из «Открытых — minor» в закрытую эру, отчего каждая строка регистра ниже сдвинулась; заголовок «Текущее состояние» переименован; в `deploy/README.md` вставлено около сорока строк, и номера там больше не те. Что стало с каждым пунктом — в секции «ОТВЕТ НА ПИНГ №22» выше. + Внешний ревизор прочитал документацию зоны от первой строки до последней (README · BACKLOG · deploy/README 561 стр. · юнит · STACK_DECISIONS 650 · этот журнал 2775 · регистр 615). Линтер якорей при этом ноль: **всё найденное — ложь в ПРОЗЕ, которую гейт не видит по построению.** Правит ЗОНА, @@ -110,7 +191,7 @@ README, получает состояние двухнедельной давн **Рецепт самодостаточен:** `<скретчпад сессии>/stand/UP.sh` — поднимает Postgres, если он упал, поднимает демона, дожидается `readyz` и кладёт cookie-jar. Пробы — `. <скретчпад>/stand/api.sh`, дальше `api GET /v0/capabilities`. -⚠ **СТЕНД ОПУЩЕН 04.09 после окончания приёмки — но опущен НАПОЛОВИНУ, и вторая половина жива НАРОЧНО.** Остановлен только демон на `8099`. ⛔ **Postgres на `55433` НЕ гасится ни при каких условиях.** На кластере ДВЕ базы: `tmstand34` (этот стенд) и `tmstand`. ⚠ **Причина запрета сменилась в тот же день и стала СИЛЬНЕЕ — пишу обе, потому что проверяемая и ложная причина хуже отсутствия причины.** Было: `tmstand` — база ЧУЖОГО ЖИВОГО стенда (демон на `8080`, `pid 1090739`, аптайм с ~31.08), и остановка кластера уронила бы чужую сессию. Стало: демона того стенда владелец велел погасить как артефакт, оркестратор погасил по PID, порт `8080` свободен — **но база осталась, и теперь она НИЧЬЯ**. Снести кластер значит снести данные, за которыми уже НЕКОМУ ПРИЙТИ, и пропажу никто не заметит. Запрет продублирован в самом `UP.sh` — читают рецепт, а не журнал. +⚠ **СТЕНД ОПУЩЕН 04.09 после окончания приёмки — но опущен НАПОЛОВИНУ, и вторая половина жива НАРОЧНО.** Остановлен только демон на `8099`. ⛔ **Postgres на `55433` НЕ гасится ни при каких условиях.** На кластере не две базы, а четыре (`postgres`, `tmstand34`, `tmstand`, `tm_migr_probe`); значение имеют две — `tmstand34` (этот стенд) и `tmstand`. ⚠ **Причина запрета сменилась в тот же день и стала СИЛЬНЕЕ — пишу обе, потому что проверяемая и ложная причина хуже отсутствия причины.** Было: `tmstand` — база ЧУЖОГО ЖИВОГО стенда (демон на `8080`, `pid 1090739`, аптайм с ~31.08), и остановка кластера уронила бы чужую сессию. Стало: демона того стенда владелец велел погасить как артефакт, оркестратор погасил по PID, порт `8080` свободен — **но база осталась, и теперь она НИЧЬЯ**. Снести кластер значит снести данные, за которыми уже НЕКОМУ ПРИЙТИ, и пропажу никто не заметит. Запрет продублирован в самом `UP.sh` — читают рецепт, а не журнал. ⚠ **Данные платного прогона пережили остановку и сверены ПОСЛЕ неё** (`psql` по `tmstand34`): 2 книги · 10 глав · 14 экспортов · 5 прогонов; деньги по `credit_ledger` — `grant 0.600000`, `settlement -0.278319`, холды в ноль (`-1.512494` / `+1.512494`), **остаток `0.321681 USD`**. Списание сходится с отчётом пака до микродоллара. Поднять обратно — `UP.sh`; порт `8099` свободен. @@ -150,7 +231,7 @@ README, получает состояние двухнедельной давн ⚠ **Дефолт `8080` в коде НЕ меняется, и это решение, а не недосмотр.** Коллизия здесь — `tmplatformd` против `tmplatformd`, то есть дефолта против самого себя: любой другой номер даст ту же аварию на втором одновременном стенде, а цену смены заплатят все существующие деплои и доки, говорящие `8080`. Чинится не номер, а посылка «по адресу отвечают — значит это мой сервис». Ряд — `PD-447`, статус `fixed(дерево)`. -⚠ **Чужой стенд не тронут** (`pid 1090739`, база `tmstand`, аптайм с ~31.08): он не наш, а `pkill` по имени на этой машине бьёт по чужим. Вопрос владельцу о его судьбе несёт оркестратор. +⚠ **Чужой стенд ЭТОЙ СЕССИЕЙ не тронут** (`pid 1090739`, база `tmstand`, аптайм с ~31.08): он не наш, а `pkill` по имени на этой машине бьёт по чужим; вопрос о его судьбе понёс владельцу оркестратор. ⚠ **Судьба решилась в тот же день, и абзац здесь отстал от ответа:** владелец велел погасить тот демон как артефакт, оркестратор погасил его по PID — `1090739` мёртв, порт `8080` свободен. **База `tmstand` при этом осталась и стала НИЧЬЕЙ, поэтому ⛔ запрет гасить кластер Postgres на `55433` не снят, а усилился:** за этими данными уже некому прийти, и пропажу никто не заметит. Разбор целиком — секция «СТЕНД ПЛАТНОГО ПРОГОНА» выше. ## НАБЛЮДЕНИЕ ЗА ЖИВЫМ ПОТОКОМ — что видела сессия, прогнавшая весь путь на настоящем движке и настоящих деньгах (заказ владельца через оркестратора №22, 04.09; сессия `textmachine-main-34`) @@ -387,7 +468,7 @@ run_CR2RN76NAKYKAJE5 · run_VSXPMJE53KJQQAOS `grep -rn 'createExport\|getExport' internal/ --include=*.go | wc -l` → было **0**, стало **не 0**; смонтировано **18** маршрутов вместо 15. -**Три маршрута, а не два.** Канон называет две операции и ТРЕТИЙ адрес описывает словами (`Export.url`), `operationId` ему не давая. Он живёт в `contractSurface` с остальными — иначе не получил бы ни гарда сессии, ни капа тела, ни пина «без сессии 401». +**Три маршрута, а не два.** ⚠ **Устарело тем же днём:** `operationId` `downloadExport` канон дал минором `0.10.0` (`58bca19`, `D39.194` п.3); маршрутов по-прежнему три. Канон называет две операции и ТРЕТИЙ адрес описывает словами (`Export.url`), `operationId` ему не давая. Он живёт в `contractSurface` с остальными — иначе не получил бы ни гарда сессии, ни капа тела, ни пина «без сессии 401». **Решения, которые промт оставил мне, и доводы:** @@ -517,7 +598,10 @@ make vuln # No vulnerabilities found. **Прибавка пака — 62 тест-функции, и вот они поимённо** (`grep -c '^func Test' <файл>` по новым файлам плюс `git diff --unified=0 … | grep -c '^+func Test'` по дописанным): `internal/exports/exports_test.go` 18 · `internal/httpapi/exports_test.go` 12 · `internal/pgstore/exports_test.go` 9 · `internal/runs/abandon_orphan_test.go` 6 · `internal/ingest/build_test.go` 5 · `internal/config/exports_test.go` 4 · `internal/runner/build_live_test.go` 3 · `cmd/tmplatformctl/abandon_proof_test.go` 3 · `cmd/tmplatformd/runner_test.go` +2. ⚠ Функций 62, а `=== RUN` верхнего уровня больше: часть из них — `t.Run` подтестами, и оба числа верны о разном. ⚠ Прежняя редакция этого абзаца называла `exports_test.go` 10 при девяти и «+54» — приёмка поймала обе, числа пере-сняты командой (счёт: `grep -c '^func Test' <файл>` по новым файлам плюс `git diff --unified=0 … | grep -c '^+func Test'` по дописанным). Пакетов 19 вместо 18 — прибавился `internal/exports`. Скипов и красных как не было, так и нет. ⚠ Из них **двадцать одна заведена НЕ при постройке**: двенадцать — собственным адверсариальным проходом и ратификацией 04.09, ещё девять — дофиксом по вердикту приёмки (доказательство ручки `PD-424`, согласие поллинга со ссылкой, публикация на отцепленном контексте, кап списания, исключающий клейм). ⚠ Числа сняты на замороженном дереве последним действием; движковый бинарь — из ЧИСТОГО `HEAD 3ba2d26`, до правок параллельной бэкенд-сессии, поэтому живые тесты не читают её движущиеся файлы. -**Док-гейты:** `python3 docs/scripts/counts.py --check` от корня — «Литералы сходятся с пере-счётом (5 проверок)», регистр `битая форма: []`, `хвост вне словаря: []`, **447 строк, открытых 101** (major 5 — было 3, прибавились `PD-440` и `PD-441`; minor прибавили `PD-443`, `PD-444`, `PD-446`, info — `PD-445`; `PD-442` и `PD-447` закрыты деревом). ⚠ Числа 442/97 в первой редакции были сняты ДО дофикса, 443/98 — до трёх рядов живого прогона (`H12`/`H13`/`H14`, заведены 04.09 по разнарядке оркестратора №22); поправлено оба раза. ⚠ **Заодно исправлена ЧУЖАЯ ошибка формы, которую я же и внесла:** ряд `PD-443` помечен `minor`, а стоял в секции `## Открытые — major`; перенесён в свою секцию. Гейт веса это НЕ ловил и поймать не мог — `counts.py` считает вес по ЯЧЕЙКЕ, а не по секции (`counts.py:272`), поэтому число `major 5` было верным при неверной раскладке. ⚠ Обратный случай остаётся и он НЕ мой: `PD-438` помечен `**major**` и стоит в секции minor — назван оркестратору, своей рукой чужой ряд не двигаю. `--lint` (кап вывода снят, иначе счёт лжёт — см. ниже) — **в моей зоне красных якорей НЕТ**: восемь хитов на четыре адреса пере-нацелены 04.09 на архивные копии промтов со сдвигом номеров **+7**. +⚠ **БАТАРЕЯ ПОСЛЕ РЕВИЗИИ — ЗЕЛЕНА, и путь к этому стоит записать, потому что дважды она была КРАСНОЙ по причине, не связанной с деревом.** Итог на спокойной машине: `make check` **выход 0**, скипов 0, падений 0, линтер `0 issues`, `ALARM PD-count 12 (baseline 12)`, «every test ran: no host condition was missing». +⚠ **Два прежних красных прогона — ГОЛОДАНИЕ ПО ПРОЦЕССОРУ, и различить это можно было только замером:** на машине шли тринадцать чужих `tmmutate` и батареи движка, `load average 42–48` на восьми ядрах. `TestAnEndlessBankApplyIsRefusedRatherThanRead` мерит ВРЕМЯ — перелив обязан убить процесс за 30 с, занял 71; ошибка при этом БЫЛА обнаружена, упало только утверждение о сроке, а изолированно тест даёт 3/3 `ok` за 1.9 с. `internal/runs` вставал по таймауту пакета без единого упавшего теста. **Правило, выведенное из этого: красный тест, мерящий время, на загруженной машине — не результат; прежде чем звать его дефектом, гоняй изолированно и смотри `uptime`.** ⚠ Обратная половина правила тоже сработала: второй красный оказался НАСТОЯЩИМ дефектом (`PD-448`), и отличило их именно то, что первый изолированно зелен, а второй краснел 2 из 2 в пакете. + +**Док-гейты:** `python3 docs/scripts/counts.py --check` от корня — «Литералы сходятся с пере-счётом (5 проверок)», регистр `битая форма: []`, `хвост вне словаря: []`, **448 строк, открытых 102** (прибавился `PD-448`) (major 5 — было 3, прибавились `PD-440` и `PD-441`; minor прибавили `PD-443`, `PD-444`, `PD-446`, info — `PD-445`; `PD-442` и `PD-447` закрыты деревом). ⚠ Числа 442/97 в первой редакции были сняты ДО дофикса, 443/98 — до трёх рядов живого прогона (`H12`/`H13`/`H14`, заведены 04.09 по разнарядке оркестратора №22); поправлено оба раза. ⚠ **Заодно исправлена ЧУЖАЯ ошибка формы, которую я же и внесла:** ряд `PD-443` помечен `minor`, а стоял в секции `## Открытые — major`; перенесён в свою секцию. Гейт веса это НЕ ловил и поймать не мог — `counts.py` считает вес по ЯЧЕЙКЕ, а не по секции (`counts.py:272`), поэтому число `major 5` было верным при неверной раскладке. ⚠ Обратный случай остаётся и он НЕ мой: `PD-438` помечен `**major**` и стоит в секции minor — назван оркестратору, своей рукой чужой ряд не двигаю. `--lint` (кап вывода снят, иначе счёт лжёт — см. ниже) — **в моей зоне красных якорей НЕТ**: восемь хитов на четыре адреса пере-нацелены 04.09 на архивные копии промтов со сдвигом номеров **+7**. ⚠ **МЕТОД, стоивший мне ложного отчёта — называю, потому что он повторится у следующего.** `counts.py --lint` печатает не все находки: **кап вывода 25** (`counts.py:630`), а сортировка ставит вперёд тяжёлые — и хвост «файла нет» уезжает под черту. Я сняла свой счёт командой `--lint | grep '✗ platform/'` по ОБРЕЗАННОМУ выводу, получила ноль и доложила «в зоне красных нет». В зоне было **восемь**. ⚠ Вторая попытка была хуже первой: копия скрипта в `/tmp` дала ноль красных — и это был не результат, а ПАДЕНИЕ (`ROOT = Path(__file__).resolve().parents[2]`, у копии вне дерева родителей не хватает; трейсбек ушёл в тот же поток, а `$?` я прочитала от `tail`, не от python). **Ложный ноль от упавшего гейта неотличим от зелени, если смотреть только на число.** Рабочий приём: копия кладётся ВНУТРЬ дерева на той же глубине (`platform/docs/_lint_full.py` — своя зона, удаляется сразу), тогда корень резолвится верно: `sed 's/CAP = 25/CAP = 999/' docs/scripts/counts.py > platform/docs/_lint_full.py && python3 platform/docs/_lint_full.py --lint`. ⚠ И третий капкан того же дня: **цитата мёртвого адреса САМА становится якорем**. Первая редакция пере-нацеливания назвала прежние адреса в форме `` `путь:номер` `` — гейт прочитал их как якоря и вернул шесть красных. Прежний адрес пишется порознь: имя файла в кавычках, номер словами. @@ -528,8 +612,8 @@ make vuln # No vulnerabilities found. 1. **`Export.failure_code` становится enum** — условие, которое канон сам себе поставил («becomes an enum with the first built format»), наступило (`contract-10` строки 243). Предлагаемый набор — то, что эта реализация ПРОИЗВОДИТ, а не то, что можно вообразить: `book_empty` (в книге ноль выходных юнитов) · `deployment_error` (движок отказал в конфигурации ДЕПЛОЯ: формат, которого он не знает, `book.yaml` без языкового тега, нечитаемый langpack, `--out`, который хост не берёт — чинит ОПЕРАТОР). ⚠ Имя не про формат нарочно: класс 10 покрывает все пять причин, и `format_unavailable` солгало бы про четыре из них · `build_interrupted` (сборка не кончилась и никто за ней не вернётся; повтор сходится) · `build_failed` (класс, которому эта сборка не нашла лучшего слова; у оператора есть строка лога). ⚠ Четыре и ни одного «прочее»: `build_failed` и есть «прочее», названное честно. 2. **Механизм ссылки и её TTL.** Предлагаю записать то, что построено: ссылка АУТЕНТИФИЦИРОВАННАЯ на том же ориджине, а не подписанная капабилити. Довод в шапке `internal/httpapi/exports.go` и в пункте 2 выше; коротко — она строго сильнее токена и не требует деплойного секрета, который никто не ротирует. Формулировку канона «Minted for THIS response» стоит заменить на «bound to the authenticated owner», потому что нынешняя читается как обещание одноразового значения, которого продукт не даёт и без которого честнее. 3. **Строка 241 — канон противоречит себе на ДОЧИТАННОЙ книге** (правка банка «takes effect on the NEXT run» против «finished work is not bought twice»). ⚠ **Живьём НЕ воспроизведено:** книга пака до конца не дочитана (`PD-440`), так что состояния «дочитанная книга с принятой правкой» у меня не было. Несу как есть, без замера. -4. **Третий адрес двери** (`.../exports/{exportId}/content`) канон описывает словами и `operationId` ему не даёт. Предлагаю дать: сегодня это единственный маршрут поверхности, которого нет в перечне операций, и генерируемый клиент про него не знает — а браузер по нему НАВИГИРУЕТ, что и есть его способ существования. -5. **Шестнадцатая причина флага.** Бэкенд-сессия завела `off_target_lang` (`backend/internal/pipeline/disposition.go`, ещё не заленджено). Приложению А нужен ряд: новый код замечания + ступень. ⚠ Фраза пишется по ДОККОММЕНТУ константы, а не по её имени — правило уже записано в компаньоне канона. Карту `internal/ingest/notes.go` НЕ дополняла: контрактного кода ещё нет, а изобрести его значило бы править канон своей рукой. +4. **Третий адрес двери** (`.../exports/{exportId}/content`) канон описывает словами и `operationId` ему не даёт. Предлагаю дать: сегодня это единственный маршрут поверхности, которого нет в перечне операций, и генерируемый клиент про него не знает — а браузер по нему НАВИГИРУЕТ, что и есть его способ существования. **✅ РАТИФИЦИРОВАН оркестратором 04.09** нотой `D39.194` п.3 (контрактный минор `0.10.0`, `58bca19`): `operationId` — `downloadExport`, с объявленными `206` на Range, `410` на протухшую ссылку и `Content-Disposition` в заголовках ответа. +5. **Шестнадцатая причина флага.** Бэкенд-сессия завела `off_target_lang` (`backend/internal/pipeline/disposition.go`, ещё не заленджено). Приложению А нужен ряд: новый код замечания + ступень. ⚠ Фраза пишется по ДОККОММЕНТУ константы, а не по её имени — правило уже записано в компаньоне канона. Карту `internal/ingest/notes.go` НЕ дополняла: контрактного кода ещё нет, а изобрести его значило бы править канон своей рукой. **✅ РАТИФИЦИРОВАН и ИСПОЛНЕН 04.09:** канон дал код `wrong_language` минором `0.10.0` (`D39.194`), и карта дополнена шестнадцатой строкой `off_target_lang → {wrong_language, StepAttention}` (`internal/ingest/notes.go`, коммит `7e204db`); ступень — провизорное решение зоны, колонка канона ⬜ ждёт слов владельца. 6. **`Export.url` объявлен `format: uri`, а отдаётся ОТНОСИТЕЛЬНЫЙ путь** — расхождение с ратифицированным контрактом, найдено адверсариальным проходом. Предлагала минор `uri` → `uri-reference`; **✅ РАТИФИЦИРОВАН оркестратором 04.09** нотой `D39.194` (контрактный минор `0.10.0`), правит он. ⚠ **ВХОЖДЕНИЕ ОДНО — как я и называла изначально.** Первая редакция этого пункта повторяла его сверку «вхождений ДВА»; он её ОТОЗВАЛ, прочитав второе место перед правкой: там поле `type` проблемы RFC 9457 со значением `about:blank`, а это по стандарту абсолютный URI, и трогать его нельзя. ⚠ Класс ошибки назван им прямо и стоит того, чтобы жить здесь, а не в оговорке: **ратификация по грепу без чтения предмета** — совпадение строки принято за совпадение смысла. Тот же класс, что мой собственный счёт красных якорей по обрезанному выводу. Довод принят в усиленном виде: абсолютный URL заставил бы сервис знать свой публичный ориджин, которого за edge-прокси он надёжно не знает — тот же класс, что `PD-101`. Кода это не касается: относительный путь и был тем, что дверь отдаёт. 7. **`409 book_not_ready` на `createExport` — ✅ РЕШЕНО 04.09 ОРКЕСТРАТОРОМ И ИСПОЛНЕНО: код СНЯТ.** Я вынесла это как «канон спорит сам с собой»; оркестратор прочитал описание целиком и показал, что противоречия нет — есть одна недвусмысленная фраза «Nothing about a book's state conflicts with exporting it», которую нарушал КОД. Он прав, и `book_not_ready` на этой двери был изобретением кода. Снято: запрос принимается ВСЕГДА, а что получает книга без дерева глав — отвечает сам экспорт. Пины: `TestABookThatWasNeverCutIsAcceptedAndAnsweredByItsExportRatherThanRefused`, `TestNoBookStateIsARefusalOnThisDoor`, `TestTheWorkersReadKnowsWhetherTheBookWasEverCut`; посадки P1/P2/P3 — все три красят адресно. @@ -2345,7 +2429,7 @@ D39.162, держать её как находку нельзя · Р5 → F5 · | # | Вес | Что | Где | |---|---|---|---| | Д2 | breaks | **Неидемпотентный decline** поверхности подписанного сида, имеющей строку в дельте: первый вызов ПРИНЯТ, повтор ТОГО ЖЕ документа — 409 (гейт `decisions.go:374` судит ДО-состояние, которое свёртка сама стирает); при классе 15 предписанный ре-сенд отбивается ЦЕЛИКОМ — **обещание сходимости 503-ретрая, на котором стоит синхронная дверь, ломается**. Доказано исполнением через СОБСТВЕННЫЙ оракул репозитория (оракул 4 фаззера); фаззер структурно не достаёт (фикс-книга не пересекает сид с дельтой). Лечение: судить по ПОСТ-состоянию, как соседний `refuseInertDeclines` | `membank/decisions.go:374` | -| Д3 | breaks | **Потеря/порча маркера выхода после стопа** (совместная с платформой): рестарт/ретрай проходит границу банка НАСКВОЗЬ — память предъявления покрывает карту, движок «continuing» одним WARN себе в журнал — оплаченный `verify_bank` стоп исчезает молча и навсегда (память append-only). Гард `LiftBankStop` (`reconcile.go:1122-1126`) писан против движка ДО памяти v16 и этот путь не держит | `reconcile.go:424`, `mining.go:216-227` | +| Д3 | breaks | **Потеря/порча маркера выхода после стопа** (совместная с платформой): рестарт/ретрай проходит границу банка НАСКВОЗЬ — память предъявления покрывает карту, движок «continuing» одним WARN себе в журнал — оплаченный `verify_bank` стоп исчезает молча и навсегда (память append-only). Гард `LiftBankStop` (`reconcile.go:1122-1126`) писан против движка ДО памяти v16 и этот путь не держит | `reconcile.go:424`, `mining.go:216-227` | ⚠ **ПОМЕТКА 04.09, ряд НЕ пере-нацелен и пере-нацелен быть не может:** он внутри ЗАКРЫТОГО раунда ревью, а пере-указание якорей в законченном раунде переписывает чужой замер. Оба его адреса сегодня мертвы: гард `LiftBankStop` СНЯТ (в дереве осталось только `reconcile.go:615` «removed with the workaround»), а `reconcile.go:1122-1126` — теперь чужой код (`const base, cap`); защита переехала в ветку, которую называет `stoppedOnRequest` (`reconcile.go:424`). Читать ряд как запись о том, что было верно ТОГДА. ### Чистые оси (проверено — не опровергнуто) @@ -2584,7 +2668,13 @@ mutation-catch (спутать пары «числитель×база»); ко описи): называть их — правильно, и опись, недобравшая три файла из пяти, действительно стоила бы лендеру правки НОРМЫ. Продолжайте так же. -## Текущее состояние +## Состояние эры P8 (записи до 24.08) — ИСТОРИЧЕСКИЙ раздел, НЕ текущее состояние + +⚠ **Заголовок переименован 04.09.** Он звался «Текущее состояние», лежал на строке ~2587 из ~2851 и держал +записи, кончающиеся 24.08: паков P9–P13 и «закрыть цикл» под ним НЕТ. `README.md` посылал сюда за +состоянием зоны, и сессия, честно исполнившая онбординг, получала картину недельной давности. **Текущее +состояние — ВЕРХ этого файла:** журнал обратно-хронологический; отчёт последнего пака — в верхней +трети, ниже него могут стоять более свежие записи смены. **⛔ ПИНГ ОРКЕСТРАТОРА №19 — 23.08, ЗОНЕ ПЛАТФОРМЫ.** ⚠ Его собственная шапка звала находки «тремя», а нумеровала ЧЕТЫРЕ — считать по нумерации. Живых из четырёх ДВЕ: находка 1 ниже дословно, находка 4 — строкой регистра. Две закрыты паком P9 и проверяются грепом, а не памятью: блокер провайдерских ключей (строка **211** единого бэклога) — ключи едут аргументом `--keys-file`, греп `TM_PLATFORM_ENGINE_KEYS_PATH`; дубль движковой конвенции пути (строка **213**) — `runner.projectDB` снесён, путь берётся из `artifacts.bank_export` манифеста. Находка 4 (мёртвое поле `ingest.StatusReport.UnsignedBankTerms`) жива и несёт свой якорь строкой `PD-396` (греп `UnsignedBankTerms` в регистре и в `platform/internal/ingest/resync.go`). @@ -2761,7 +2851,7 @@ migrate → повтор») можно строить; строка открыт приёмкой (мёртвая цитата текста ошибки схемы в рантбуке и в строке П-1; образец в `tmplatformctl`, зовущий голый `tmctl` из PATH), закрыты правками и проверяются грепом: `grep -rn 'expects vM' platform/deploy platform/BACKLOG.md` — ноль строк; рантбук несёт живой токен -(`platform/deploy/README.md:202`=`schema_mismatch found=N expected=M`); образец несёт +(`platform/deploy/README.md:249`=`schema_mismatch found=N expected=M`); образец несёт версионированный путь (`platform/cmd/tmplatformctl/runs.go:53`=`The VERSIONED path and never a bare`). ⚠ **Живым остаётся мнение оркестратора:** прогнать деплой-рантбук end-to-end на дев-стенде diff --git a/platform/internal/gates/register_test.go b/platform/internal/gates/register_test.go index 54194956..bf29db9f 100644 --- a/platform/internal/gates/register_test.go +++ b/platform/internal/gates/register_test.go @@ -54,6 +54,11 @@ var alarmMarkers = []string{"деньг", "холд", "молча", "блоки // to write a base against a closure that did not exist. It exists now, and the gate says so: // `ALARM PD-168 LEFT the class (status "fixed(6ae3e76)", weight "minor, деньги")`. var alarmBaseline = []string{ + // PD-448 — гонка двух резюмов: проигравший получает отказ вместо прогона. Внесён 05.09 той же + // правкой, что и ряд. Маркеры класса он несёт законно: ряд говорит о ДЕНЬГАХ (холд берётся + // ровно один) и о том, что отказ приходит МОЛЧА для пользователя, дважды нажавшего кнопку. + // Ниже major он стоит потому, что деньги не теряются: отказ случается ДО взятия холда. + "PD-448", "PD-94", "PD-107", "PD-162", "PD-201", "PD-212", "PD-217", "PD-244", "PD-418", "PD-420", "PD-428", "PD-433", } diff --git a/platform/internal/httpapi/idempotency.go b/platform/internal/httpapi/idempotency.go index f6938213..50cab1af 100644 --- a/platform/internal/httpapi/idempotency.go +++ b/platform/internal/httpapi/idempotency.go @@ -11,7 +11,7 @@ import ( "textmachine/platform/internal/pgstore" ) -// idempotency.go: `Idempotency-Key` on the two writes that create something (canon +// idempotency.go: `Idempotency-Key` on the three writes that create something (canon // §IdempotencyKey). // // The semantics are entirely the server's duty and are written out here rather than inferred: diff --git a/platform/internal/pgstore/pg_test.go b/platform/internal/pgstore/pg_test.go index af730e72..7225d074 100644 --- a/platform/internal/pgstore/pg_test.go +++ b/platform/internal/pgstore/pg_test.go @@ -229,10 +229,15 @@ func TestARollbackSurvivesTheDataTheNewVocabulariesWrote(t *testing.T) { t.Fatal(err) } defer closeProvider() - // Down to version 5 and no further, which is the DOCUMENTED floor of a real rollback: 00005's - // own down path restores `email NOT NULL` and the unique index, and both are violated by rows - // production writes (`deploy/README.md`, "Откат релиза: не ниже версии 5"). Rolling to zero here - // would test that known limit instead of this migration. + // Down to version 5 and no further. 00005's own down path restores `email NOT NULL` and the + // unique index, and both are violated by rows production writes, so rolling to zero here would + // test that known limit instead of this migration. + // + // ⚠ The DOCUMENTED floor of a real rollback is 15, not 5 (`deploy/README.md`, "Откат релиза: не + // ниже версии 15"): 00015's down path narrows `runs_paused_reason_check` back to one value while + // production writes three. That floor is about DATA, not schema — it bites only a base that has + // held such a pause — so this base rolls past it honestly, and this test passing is not evidence + // against the documented floor. if _, err := p.DownTo(ctx, 5); err != nil { t.Fatalf("rolling back a database that holds real rows: %v", err) } diff --git a/platform/internal/runs/spawn.go b/platform/internal/runs/spawn.go index b1a0434f..93bc55fa 100644 --- a/platform/internal/runs/spawn.go +++ b/platform/internal/runs/spawn.go @@ -194,8 +194,9 @@ type meter struct { // ⚠ The RESERVED half of the engine's own comparison is deliberately not added, and the reason is a // line the first version of the formula did not account for. `store.Open` — the WRITE path every // `translate` takes — runs `recoverReservations`, which zeroes every `reserved_usd` of the book -// before the first reservation is judged (backend/internal/store/store.go:88 and :214). `tmctl -// status` is read-only and deliberately does NOT run that pass (store.go:108 says so), so the figure +// before the first reservation is judged (backend/internal/store/store.go:110 and :278 — re-aimed +// 04.09, the targets had drifted from :88 and :214). `tmctl status` is read-only and deliberately +// does NOT run that pass (store.go:117-132 `OpenReadOnly` says so), so the figure // the platform reads is a LEFTOVER of a crashed process, guaranteed to be gone by the time the cap // is compared against anything. Adding it hands the run that much headroom BEYOND its hold: the // engine stops late, settlement is capped at the hold, and the account underpays while the ledger