diff --git a/platform/docs/DEFECT_REGISTER.md b/platform/docs/DEFECT_REGISTER.md index 6f3269b1..ebb5e22d 100644 --- a/platform/docs/DEFECT_REGISTER.md +++ b/platform/docs/DEFECT_REGISTER.md @@ -37,7 +37,7 @@ | ID | Класс | Серьёзность | Где | Суть | Статус | Источник | |---|---|---|---|---|---|---| | PD-375 | bug | minor | `internal/runs/runs.go:323`=`if in.CeilingChapters < bounds.Min`, `internal/httpapi/v0_test.go:359`, `internal/runs/control_test.go` | **Верхнюю половину проверки `ceiling_chapters` не исполняет НИ ОДИН тест: снятие второго операнда `in.CeilingChapters > bounds.Max` проходит ВСЮ батарею.** Собственный доккомментарий называет проверку несущей — «число, решающее СКОЛЬКО ДЕНЕГ резервируется, не может быть выбрано вызывающим односторонне», — и канон требует того же от сервера (`max_chapters` уже прижат к остатку, «a client MUST NOT clamp it again»). Единственный тест, называющий `ErrCeilingOutOfBounds`, это таблица соответствия ошибки коду 409 в `httpapi/v0_test.go`, которая `Start` не зовёт, а самая тесная фикстура просит 10 глав у книги, где их 100. Код сегодня ВЕРЕН; дефект в том, что править эту строку можно безнаказанно. Замерено на живом стенде: чистая сборка отвечает `409 ceiling_unavailable/bounds_moved`, сборка с мутацией отдаёт 202 и открывает резервацию `24000000` микро на книге из ТРЁХ глав, а движку уходит `--ceiling-usd 24.200000`. Вес: рефутер сузил major → minor, потому что код верен и ни один сегодняшний клиент до вреда не доходит. Воспроизведение: `docs/p8-review/axis1-money/a1-mut5-ceiling-bound.sh`, готовый пин `docs/p8-review/axis1-money/r1_ceiling_bound_test.go.txt` | open | ревью-пак P8-REVIEW, ось 1 (посадка мутации + живая проба, подтверждено рефутером) | -| PD-377 | doc | minor | `internal/pgstore/credits.go:163`=`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-377 | doc | minor | `internal/pgstore/credits.go:162`=`The ceiling to hand the engine is the amount held`, `internal/runs/spawn.go` `bookCap`, `cmd/tmplatformctl/main.go` `balance` | **Доккомментарий `Hold` на входе в денежный путь неверен ОБЕИМИ половинами с миграции 00011.** Он обещает «the ceiling to hand the engine is the amount held; read it back with OpenReservations». Движку передаётся не сумма холда, а `run_attempts.ceiling_arg_micro_usd` = committed книги плюс прирост (`spawn.go` `bookCap`), и сама 00011 говорит это прямым текстом («It is NOT ceiling_micro_usd»); начиная со ВТОРОГО прогона книги числа расходятся тем сильнее, чем дороже книга — на стенде до 17 раз (`60000` холда против `1060000` в аргументе). Вторая половина не работает даже механически: `Reservation.Ceiling` — поле, которое только сканируется и не читается никем, единственный потребитель `OpenReservations` печатает `Amount`/`BookID`/`OpenedAt`/`EngineRunID`. ⚠ Рефутер снял два из трёх исходных якорей: доккомментарии в применённых миграциях `00007`/`00009` зона не правит после лендинга (у всех 25 файлов миграций ровно по одному коммиту), а исправление уже лежит в следующем файле того же каталога. Остаётся Go-доккомментарий. Воспроизведение: `docs/p8-review/axis1-money/a1-ceiling-column-doc.sql` | open | ревью-пак P8-REVIEW, ось 1 (живой стенд, сужено рефутером до одного якоря) | | PD-386 | bug | minor | `cmd/tmplatformd/runner.go:269`=`pass("runs", sweepBudget, s.runs.Sweep)` и четыре следующих прохода того же `one()`, `cmd/tmplatformd/runner_test.go` | **Такт свипа последователен, и бюджеты пяти проходов СКЛАДЫВАЮТСЯ: сумма объявленных — 37 минут при `TM_PLATFORM_SWEEP_EVERY` 15 секунд.** Пак P8-FIX дал каждому проходу свой бюджет и закрыл «один проход съедает дедлайн другого», но проходы по-прежнему идут подряд в одной горутине: runs 2м, readmodel 10м, intake 21м, idempotency 2м, observe 2м. Ничто эту сумму не ограничивает и ничто её не пинит — у функции `sweep` нет ни одного теста (в пакете два теста, оба про другое), и посадка «фаза runs уходит из головы такта в хвост» пережила ПОЛНУЮ батарею. Замерено пробой на реальной функции: за 4 секунды при такте 100 мс фаза прогонов отработала 40 раз сама по себе и 9 раз позади прохода материализации. Следствия по оси: калибровки самого лечения заданы в проходах и минутах и молча растягиваются (`StalledAfter` 5 «неудач подряд» и бэкофф, про который комментарий обещает «about a quarter of an hour», превращаются в часы); обещание `Stop` «the reconciler re-issues the stop on its next pass» задерживается на ту же величину; телеметрия стоит ПОСЛЕДНЕЙ. ⚠ Рефутер сузил: 37 минут — сумма ОБЪЯВЛЕННЫХ бюджетов, а не достижимая длительность (idempotency это один индексированный DELETE, а проход интейка сам себя режет). Воспроизведение: `docs/p8-review/axis3-queue/probe_sweep_serialisation_test.go.txt` | open | ревью-пак P8-REVIEW, ось 3 (проба на реальной функции + посадка мутации, сужено рефутером) | | PD-387 | doc | minor | `deploy/README.md:311`=`сколько всего может занять один проход свипа`, `cmd/tmplatformd/runner.go` `one`, `internal/config/config.go` `SweepBudget` | **Рантбук называет `TM_PLATFORM_SWEEP_BUDGET` ручкой «одного прохода свипа» и не говорит, КАКОГО из пяти, — а два бюджета из пяти оператору недоступны вовсе.** Один такт прогоняет ПЯТЬ проходов подряд, и только три берут `sweepBudget`; проход материализации берёт `refreshSweepBudget` 10 минут, проход интейка — `intakeSweepBudget` = `jobs.JobTimeout` + `readmodel.MaterializeBudget` + минута = 21 минута, и обе константы оператору недоступны вовсе. Собственный доккомментарий кода при этом ТОЧЕН («SweepBudget is what ONE pass of the RUN sweep may take») — расходится именно операторская проза, и расходится в разделе «Застрявшая работа: что оператор делает, когда свип не справляется», то есть там, где по ней и будут действовать. Родня `PD-368`: та про то, что поднимать надо пару, эта про то, что ручка не покрывает такт ⚠ **Заголовок ИСПРАВЛЕН после интервальной самоверификации, и первая редакция была завышена:** она писала, что рантбук обещает ограничение ТАКТА целиком, а `deploy/README.md:311` буквально говорит «сколько всего может занять один проход свипа» — то есть ровно то, что ручка и делает. Дефект уже и точнее: строка не называет, какой из ПЯТИ проходов такта она ограничивает, `refreshSweepBudget` и `intakeSweepBudget` не являются ручками вовсе, и раздел, в котором эта таблица стоит, — «Застрявшая работа: что оператор делает, когда свип не справляется». Арифметика 37 минут пере-проверена и держится: 3×2 + 10 + 21. Воспроизведение — `docs/p8-review/axis3-queue/probe_sweep_serialisation_test.go.txt` (последовательность тактов) плюс `sed -n '264,272p' deploy/README.md` | open | ревью-пак P8-REVIEW, ось 3 (находка рефутера) | | PD-389 | hardening | minor | `cmd/tmplatformd/runner.go:320`=`s.metrics.ObserveRunner(metrics.Runner{`, `cmd/tmplatformd/runner.go` `pass`, `internal/metrics/metrics_test.go` `TestTheRunnersStateIsExposedWithItsUnits` | **Шов телеметрии не покрыт НИЧЕМ, и это доказуемо без прогона батареи: `sweep` и `observe` — неэкспортируемые функции пакета `main`, то есть из другого пакета их не может вызвать ни один тест в принципе,** а единственный тест-файл каталога несёт два теста, оба про другое. Проверено тремя посадками, пережившими полную батарею: `StalledRuns: o.StalledRuns` в ноль (наблюдаемая половина закрытого BLOCKER `PD-346`), `errors.Is(err, context.DeadlineExceeded)` в false (`sweep_unfinished_total` больше не может вырасти — `PD-351` со стороны ВЫЗЫВАЮЩЕГО, куда пин `TestAPassThatRanOutOfTimeSaysSo` по построению не достаёт), и перестановка `queue_depth` с `live_runs`. Дыра шире шва: пин формы, на который ссылается `STACK_DECISIONS` §24, задаёт литерал `metrics.Runner` из ШЕСТИ полей из восьми — `StalledRuns` и `AbandonedSurfaces` в него не входят, поэтому мутация внутри самого `ObserveRunner` тоже выживает. Пере-проверено координатором пака независимо: снятие `m.stalledRuns.Set(...)` и снятие `m.abandonedSurfaces.Set(...)` по отдельности проходят ПОЛНУЮ батарею (18 пакетов), при том что снятие соседнего инкремента `sweepUnfinished` тем же пином ловится. То есть операторская ручка, построенная паком P8-FIX в ответ на `PD-169`, не пиньётся ничем. Воспроизведение: `docs/p8-review/mutations-full.log` и `docs/p8-review/axis4-metrics/60-mutations.sh` | open | ревью-пак P8-REVIEW, ось 4 (посадки финдера, рефутера и координатора) | @@ -58,14 +58,14 @@ | PD-372 | bug | minor | `internal/pgstore/runs.go` `DeferRun`, `internal/pgstore/books.go` `truncateReason`, `internal/pgstore/isolation_test.go` | **Починку текста ошибки пинит только САМА функция, но ни один из четырёх её вызовов.** `TestAnEnginesOwnErrorTextSurvivesBeingRecorded` зовёт `truncateReason` напрямую и доказывает, что Postgres принимает её результат, — а того, что вызывающий её ЗОВЁТ, не проверяет ничто. **Посажена мутация оркестратором вне списка автора:** `truncateReason(reason)` → `reason` в `DeferRun` — батарея (`./internal/pgstore/` + `./internal/runs/`) осталась ЗЕЛЁНОЙ. Цена ровно та, которую комментарий этой же функции называет вслух: невалидный UTF-8 из stderr движка Postgres отвергает, запись отказа не проходит, счётчик не растёт и попытка держит голову списка вечно — то есть механизм PD-169 отключается тем самым текстом, ради которого заведён. Класс — PD-1 («свойство без пинящего теста не закрыто»), и он тут в форме «пин есть, но не на пути». Лечение дешёвое: провести один случай через `DeferRun`/`DeferReadModelDebt` и прочитать колонку назад | open | приёмка P8-FIX (посадка мутации оркестратором №18) | | PD-217 | bug | minor | `internal/pgstore/books.go` `BooksForMigration`, `deploy/README.md` | **Книга, застрявшая на `daily_ceiling` или на вечно незакрытом холде, блокирует апгрейд движка бессрочно, и выхода у оператора нет.** Оба состояния снимаются только тем, чего платформа сделать не может: дневной потолок живёт в `book.yaml` оператора, и резюм по нему отказан 409; холд закрывается расчётом, который читает движок ЗАПИНЕННЫМ бинарём. Форсирующего флага у `books --migratable` нет намеренно — он и был бы способом пере-оплатить уже купленные вызовы. Лечится либо ручным разрешением в БД, либо каналом «признать попытку невосстановимой», которого в контракте нет ⚠ **ПОЛОВИНА ЗАКРЫТА P7:** `paused` вышел из предиката `Resumable` — с 0.3.0 прогон, остановленный ЛЮБЫМ потолком, `resume` продолжить не может вовсе (409 `ceiling_reached`), значит к старой сборке ничего не пришпилено и мигрировать такую книгу безопасно; лечение пользователя — НОВЫЙ прогон, который спавнится ТЕКУЩИМ бинарём. Книга на дневном потолке апгрейд больше не запирает. Связь названа в коде: вернётся резюм паузы — вернётся и предикат. Пин `TestEachOfTheThreeBlockersAloneKeepsABookOutOfTheMigrationList` (перечень «безопасных» проверяется целиком, а не по одной книге). ⚠ ОСТАЁТСЯ вторая половина: книга с вечно незакрытым холдом блокирует по-прежнему, и это правильно — расчёт читает движок запиненным бинарём | open | приёмка P6 (дофикс, ФП-7) | | PD-219 | bug | minor | `internal/runs/reconcile.go` `drainJournal`, `reopen` | **Перезапуск после упавшего дрейна теряет непримененный хвост журнала навсегда.** Новая попытка получает курсор с РАЗМЕРА журнала на момент допуска, поэтому строки, которые прошлый дрейн не успел применить, не прочитает уже никто. Счётчики восстанавливает ре-синк, а `unit_resolutions` — нет: их единственный источник — поток. Сегодня невидимо (читающей поверхности единиц ещё нет), к P7 станет расхождением read-модели ⚠ **ДИСПОЗИЦИЯ P7 (взято на учёт, НЕ закрыто):** предсказание строки сбылось ровно наполовину, и половины теперь названы. Что САМОЛЕЧИТСЯ: текст и состояние пары приходят не из потока, а из `tmctl export` на границе работы (`readmodel.Refresh`), поэтому недодрейненный хвост их не искажает. Что НЕ лечится: `unit_resolutions` — единственный источник ЗАМЕЧАНИЙ и счётчиков главы, и потерянная строка это замечание, которого пользователь не увидит никогда, плюс `units_done`, занижённый навсегда. Лечение — перечитывание хвоста журнала при переоткрытии прогона (курсор новой попытки начинается с размера журнала на допуске); это работа того же класса, что эскроу (строка 136), и в P7 не бралась осознанно | open | приёмка P6 (дофикс, ФП-7) | -| PD-380 | hardening | minor | `internal/pgstore/sessions.go:98`=`if _, err := s.pool.Exec(ctx, q, digest, userID, now, now.Add(idleTTL), now.Add(maxAge)); err != nil {`, `internal/config/config_test.go` | **Абсолютный потолок сессии не запинен в единственном месте, где он становится фактом в базе.** `CreateSession` — единственный писатель `sessions.absolute_expires_at`, и мутация этого выражения проходит ПОЛНЫЙ пакет `pgstore`: ни один сессионный тест не краснеет. Пин, который `STACK_DECISIONS` §13 называет носителем потолка, смотрит только на результат `config.Load()` (что значение конфигурации не выше ASVS-предела), то есть проверяет НАСТРОЙКУ, а не то, что она доезжает до строки. Родня `PD-86`, но на шаг раньше: там не запинены клаузы ЧТЕНИЯ и потолок держится транзитивно через `Touch`, здесь не запинена сама ЗАПИСЬ, а транзитивной страховки у неё нет. Воспроизведение: `docs/p8-review/axis2-auth/mutations-axis2.sh` ⚠ **Пере-проверено на ПОЛНОЙ батарее координатором пака (мутация M12):** `now.Add(maxAge)` → `now.Add(100*maxAge)` в `CreateSession`, дельта против чистой копии ПУСТА на всех 18 пакетах. ⚠ Первая редакция этой посадки НЕ СОБРАЛАСЬ — комментарий вставлялся в середину строки и съедал хвост `; err != nil {`; это ошибка харнесса, а не находка, и она названа в логе `docs/p8-review/mutations-round2.log`, чтобы приёмка не пере-открыла её как расхождение ⚠ **ВЕС ПОДНЯТ info → minor закрывающим ревью, довод — симметрия с `PD-375`:** форма идентична (единственная точка принуждения объявленной границы не исполняется ни одним тестом, мутация переживает ПОЛНУЮ батарею, транзитивной страховки нет — `Touch` зажимает по значению ИЗ ТОЙ ЖЕ испорченной строки), а вес расходился только по валюте: там деньги и minor, здесь механизм ASVS 7.3.2 уровня 2 при объявленной зоной базовой линии L2 и info. Асимметрия была отпечатком того самого храповика «вреда сегодня нет», который это ревью и нашло | open | ревью-пак P8-REVIEW, ось 2 (посадка мутации, пере-посажена рефутером на полном пакете) | +| PD-380 | hardening | minor | `internal/pgstore/sessions.go:91`=`AbsoluteExpiresAt: now.Add(maxAge),`, `internal/config/config_test.go` | **Абсолютный потолок сессии не запинен в единственном месте, где он становится фактом в базе.** `CreateSession` — единственный писатель `sessions.absolute_expires_at`, и мутация этого выражения проходит ПОЛНЫЙ пакет `pgstore`: ни один сессионный тест не краснеет. Пин, который `STACK_DECISIONS` §13 называет носителем потолка, смотрит только на результат `config.Load()` (что значение конфигурации не выше ASVS-предела), то есть проверяет НАСТРОЙКУ, а не то, что она доезжает до строки. Родня `PD-86`, но на шаг раньше: там не запинены клаузы ЧТЕНИЯ и потолок держится транзитивно через `Touch`, здесь не запинена сама ЗАПИСЬ, а транзитивной страховки у неё нет. Воспроизведение: `docs/p8-review/axis2-auth/mutations-axis2.sh` ⚠ **Пере-проверено на ПОЛНОЙ батарее координатором пака (мутация M12):** `now.Add(maxAge)` → `now.Add(100*maxAge)` в `CreateSession`, дельта против чистой копии ПУСТА на всех 18 пакетах. ⚠ Первая редакция этой посадки НЕ СОБРАЛАСЬ — комментарий вставлялся в середину строки и съедал хвост `; err != nil {`; это ошибка харнесса, а не находка, и она названа в логе `docs/p8-review/mutations-round2.log`, чтобы приёмка не пере-открыла её как расхождение ⚠ **ВЕС ПОДНЯТ info → minor закрывающим ревью, довод — симметрия с `PD-375`:** форма идентична (единственная точка принуждения объявленной границы не исполняется ни одним тестом, мутация переживает ПОЛНУЮ батарею, транзитивной страховки нет — `Touch` зажимает по значению ИЗ ТОЙ ЖЕ испорченной строки), а вес расходился только по валюте: там деньги и minor, здесь механизм ASVS 7.3.2 уровня 2 при объявленной зоной базовой линии L2 и info. Асимметрия была отпечатком того самого храповика «вреда сегодня нет», который это ревью и нашло ⚠ **Якорь пере-нацелен паком `sqlc` (29.08):** прежний токен — `s.pool.Exec` в `CreateSession` — исчез, потому что SQL этого запроса уехал в `internal/pgstore/queries/sessions.sql` и исполняется генерённым кодом. Новая цель — строка, где `maxAge` СТАНОВИТСЯ значением (`AbsoluteExpiresAt: now.Add(maxAge)`): именно она несёт факт, о котором строка, и она переживёт следующую генерацию. Сам дефект не тронут — потолок по-прежнему не запинен. | open | ревью-пак P8-REVIEW, ось 2 (посадка мутации, пере-посажена рефутером на полном пакете) | | PD-407 | bug | minor | `internal/runs/bank.go` (ветка `ctx.Err()` после `BankApply`), `internal/runner/bankapply.go:40` `bankStopGrace` | **Единственный путь двери правок, способный оставить полу-приземлённую пару файлов БЕЗ отчёта, отвечает общим `503 service_unavailable` вместо своего слова `bank_corrections_incomplete` — а комментарий ветки обосновывает её контрактом SIGTERM, который сюда не попадает.** Ветка `ctx.Err() != nil` достижима только при `res.Exited=false`, т.е. когда глагол НЕ вышел сам — SIGKILL по `WaitDelay` (грейс 10 с) или несостоявшийся старт; глагол, поймавший SIGTERM, выходит 5 и идёт через `bankVerdict`. SIGKILL между двумя rename — ровно класс 15, и адресат его слова другой («машина шлёт ТО ЖЕ, никто не пере-решает»). Смягчение: ре-сенд сходится байтовым no-op движка в обе стороны, цена — неточное слово. Рядом: `bankStopGrace` 10 с КОРОЧЕ самого длинного непрерываемого участка глагола (движковый замер: 5000 declines ≈ 11.3 с до пред-записной проверки ctx) — на документе-максимуме SIGKILL опережает штатный останов для большинства моментов отмены ⚠ **ГРЕЙС-ПОЛОВИНА ЗАКРЫТА фикс-раундом P9 (28.08, D39.162):** `bankStopGrace` поднят до 30 с, число обосновано движковым замером в комментарии константы (×2.5 запас на медленный хост); комментарий SIGKILL-ветки переписан честно (описывал соседний путь). ОТКРЫТЫМ остаётся слово: полу-приземлённая пара без отчёта по-прежнему отвечается общим `503` вместо `bank_corrections_incomplete` — смягчение (ре-сенд сходится) в силе | open (грейс-половина закрыта D39.162) | воркфлоу-ревью P9 28.08 (линзы door:interleave · door:crash-windows), диспозиция оркестратора 28.08: строкой; грейс — фикс-раундом | | PD-411 | standards | minor | `internal/pgstore/sink.go:137` и `:447` (два писателя), потребителей НЕТ (греп: ни одного select) | **`runs.draft_done/draft_total/edit_done/edit_total` — носитель с ДВУМЯ писателями и НУЛЁМ читателей: класс A второго яруса онтологии банка (`18-bank-ontology.md`), сосед `PD-314` по форме.** Полоса целиком выведена из `chapters` (runDone/runTotal), эти колонки не читает ни один SELECT — а пишутся они на КАЖДОМ progress-событии внутри транзакции, держащей блокировку строки книги, двумя РАЗНЫМИ дисциплинами (поток присваивает, resync берёт `greatest`), и обоснование `greatest` на `sink.go:444-446` защищает полосу, которой не существует, — следующая сессия будет искать монотонность бара не там, где он живёт. Лечение: снос колонок миграцией + снос обоих писателей и лгущих комментариев; в паке P9 НЕ делается (слово оркестратора 28.08: миграцию на снос в этом паке не заводить) | open | воркфлоу-ревью P9 28.08 (линзы race:flip-read-consistency · bar:double-count · bar:stage-caption), диспозиция оркестратора 28.08: отдельной строкой класса A | | PD-412 | bug | minor | `internal/pgstore/readmodel.go:434-481` (draftChapters ×3 текстовых вхождения, editChapters, chaptersDone, noteCount), `internal/pgstore/books.go` `bookColumns`/`lastRunTx`/`listBooksTx` | **Карточка книги стоит 5 коррелированных сканов `chapters` на один GET, и 2 из них повторяются на КАЖДУЮ строку страницы библиотеки (×100).** PG повторные текстовые вхождения одного подзапроса НЕ дедуплицирует (доказано воркфлоу side-effect-последовательностью на живом PG 18.4), CASE-ветки честно short-circuit; замер на фикстуре в форме миграции 00002 и книге 2283 глав: выражение как написано — 1.359 мс, те же три числа одним `LEFT JOIN LATERAL (count(*) filter (...))` — 0.307 мс (×4.4); контрольный EXPLAIN сессии на реальной схеме tmp9stand: 6 SubPlan в плане, 5 исполняются. Лечение названо (один LATERAL-проход рядом с существующим lastRun); проект уже применял этот класс лекарства уровнем ниже (миграция 00022, `note_count`) | open | воркфлоу-ревью P9 28.08 (линза cost:per-request, замер) + контрольный EXPLAIN сессии P9, диспозиция оркестратора 28.08: строкой | | PD-413 | bug | minor | `internal/pgstore/sink.go:293-309` `emitProgress` (тот же 3-скан агрегат) под `lockBook` из `RunSink.Apply` (`sink.go:80-104`), источник событий: движок шлёт progress на КАЖДЫЙ разрешённый юнит каждой волны | **Каждое progress-событие пересобирает полосный агрегат по ВСЕЙ книге — под удержанной блокировкой строки книги: стоимость прогона растёт как O(глав × юнитов) вместо O(юнитов).** Изменение счётчиков ОДНОЙ главы (unitDone трогает одну строку) на следующей же строке потока триггерит 3 полных скана `chapters` (≈1 мс на книге 2283 глав, замер воркфлоу), сериализуя относительно себя любого другого писателя книги (RequestStop, дверь правок, следующее событие) — за прогон это десятки тысяч повторов, секунды суммарного удержания блокировки. Лечение — то же, что у `PD-412` (один LATERAL), плюс возможная инкрементальность; оркестратор 28.08: «вторая пахнет хуже первой» | open | воркфлоу-ревью P9 28.08 (линза cost:per-request, замер), диспозиция оркестратора 28.08: строкой | | PD-418 | bug | minor | `internal/pgstore/runs.go` `StalledRuns` (settling-ветвь), `internal/pgstore/observe.go`, `AbandonRun` (ветвление по `runs.finished_at`), `deploy/README.md` | **У settling-строки ЖИВОГО прогона нет ручки, а рантбук обещает оператору обратное.** `PD-385` называет ДВЕ популяции, и вторая — «прогон, который ЖИВ, но чья ПРЕДЫДУЩАЯ попытка не рассчиталась после рестарта». Пак P11 дал ей обе поверхности ВИДИМОСТИ (таблица и гейдж ключуются по концу ПОПЫТКИ, так что строка показывается), но `run abandon` ветвится по `runs.finished_at` и на живом прогоне уходит в живую ветку: осиротевший холд он не тронет, а ответит про процесс. Рантбук при этом описывает `PHASE=settling` как то, что лечится этой командой. ⚠ Сегодня состояние НЕДОСТИЖИМО и это часть строки, а не оговорка: единственный не-тестовый путь к второй открытой резервации — `reopen`, который отказывается стартовать следующую попытку, пока холд предыдущей открыт. То есть документ расходится с кодом на состоянии, которого код пока не производит, — и разойдётся заметно, если этот инвариант когда-нибудь ослабнет. Лечение — либо ветвление по НАЛИЧИЮ осиротевшей попытки вместо `finished_at`, либо оговорка в рантбуке | open | пак P11 (самопроход, линза соответствия заказу) + приёмка оркестратора №19 | | PD-419 | bug | minor | `internal/pgstore/migrations_test.go` `TestReleasedMigrationsAreUnchanged`, `internal/pgstore/migrations.sha256` | **Гейт выпущенных миграций слеп к ПЕРЕ-ПОДПИСИ и по построению не может отличить её от нарушения.** Он сверяет файлы против `migrations.sha256`, лежащего в ТОМ ЖЕ дереве, поэтому ловит ровно один сценарий: правку миграции тем, кто забыл про манифест. Автор, который правит ВЫПУЩЕННУЮ миграцию и пере-подписывает её строку одним движением, проходит молча. Оба отказа, ради которых гейт написан, остаются достижимыми через пере-подпись: файл, отредактированный после накатки, больше никогда не запускается (goose применяет по НОМЕРУ и хранит только его), а переиспользованный номер лишает базу отката. Комментарий гейта при этом заявляет «this is the check that makes that true rather than intended». Единственный носитель «что уже выпущено», не лежащий рядом с правкой, — git: гейт мог бы брать `git show HEAD:…migrations.sha256` и требовать побайтового совпадения строк СУЩЕСТВУЮЩИХ там миграций, свободно допуская новые; прогон в дереве без git обязан тогда ГРОМКО скипаться, иначе гейт возвращается туда же, откуда ушёл | open | приёмка оркестратора №19 по паку P11 (замечена на законной пере-подписи `00028`) | -| PD-420 | bug | minor | `internal/pgstore/runs_test.go` `TestAClaimThatLostARaceToAReleaseIsRetriedAndNotAnError` | **Тест гонки холда против релиза краснеет под ПАРАЛЛЕЛЬНЫМИ батареями, а зона ратифицировала рецепт, который их требует.** `D39.159` §2 предписывает сажать мутации в КОПИЮ дерева, и всякая сессия, которая делает это всерьёз, гоняет несколько батарей разом. Замер пака P11: при трёх параллельных прогонах краснеет в ЧИСТЫХ копиях (2 раза из 15); в СЕРИЙНОМ прогоне зелен — пере-проверено трижды подряд отдельным прогоном, все три `ok`. Меряет ресурс, общий для копий на машине. ⚠ Цена не косметическая: **красная ЧИСТАЯ копия маскирует дельту**, а красный прогон посадки читается как «мутация поймана», когда она не поймана, — ровно та ложная улика, ради которой мутации и сажают. Обход пака — судить по ДЕЛЬТЕ множеств, а не по коду выхода; лечение — изоляция ресурса либо честный скип под нагрузкой ⚠ **ПОПРАВКА, внесённая до сдачи:** первая редакция этой строки называла вторым фигурантом `TestARunIsBoundedByItsOwnCgroup` — НЕВЕРНО, и это моя ошибка вывода из совпадения. Он краснеет не от нагрузки, а от состояния ХОСТА, и вынесен отдельной строкой `PD-423` | open | пак P11, мутационная кампания (15 прогонов) + пере-проверка серийными прогонами | +| PD-420 | bug | minor | `internal/pgstore/runs_test.go` `TestAClaimThatLostARaceToAReleaseIsRetriedAndNotAnError` | **Тест гонки холда против релиза краснеет под ПАРАЛЛЕЛЬНЫМИ батареями, а зона ратифицировала рецепт, который их требует.** `D39.159` §2 предписывает сажать мутации в КОПИЮ дерева, и всякая сессия, которая делает это всерьёз, гоняет несколько батарей разом. Замер пака P11: при трёх параллельных прогонах краснеет в ЧИСТЫХ копиях (2 раза из 15); в СЕРИЙНОМ прогоне зелен — пере-проверено трижды подряд отдельным прогоном, все три `ok`. ⚠ **ВТОРАЯ ТОЧКА, 29.08, внешнее ревью:** упал 1 раз из 4 ПОЛНЫХ прогонов зоны со всеми гейтами — то есть краснеет и без параллельных копий, просто редко. Изолированно 5/5, пакет целиком 2/2, три последующих полных прогона чистые; сообщение поймать не удалось, и ревьюер честно остановился на «редком флейке контенции», диагноз НЕ установлен. Пакет ни одним коммитом эры P11 не тронут. ⚠ Обе точки вместе сдвигают формулировку: это не «краснеет от параллельной нагрузки», а «редкая гонка, которую нагрузка делает вероятнее», — и мой первый диагноз («флейк параллельных батарей») был выведен из совпадения, ровно как в `PD-423`. Меряет ресурс, общий для копий на машине. ⚠ Цена не косметическая: **красная ЧИСТАЯ копия маскирует дельту**, а красный прогон посадки читается как «мутация поймана», когда она не поймана, — ровно та ложная улика, ради которой мутации и сажают. Обход пака — судить по ДЕЛЬТЕ множеств, а не по коду выхода; лечение — изоляция ресурса либо честный скип под нагрузкой ⚠ **ПОПРАВКА, внесённая до сдачи:** первая редакция этой строки называла вторым фигурантом `TestARunIsBoundedByItsOwnCgroup` — НЕВЕРНО, и это моя ошибка вывода из совпадения. Он краснеет не от нагрузки, а от состояния ХОСТА, и вынесен отдельной строкой `PD-423` | open | пак P11, мутационная кампания (15 прогонов) + пере-проверка серийными прогонами | | PD-423 | standards | minor | `internal/runner/systemd_test.go` `TestARunIsBoundedByItsOwnCgroup`, `docs/STACK_DECISIONS.md` «Гейты батареи» | **Батарея зоны требует ЧЕТВЁРТОГО условия хоста, которого рецепт не называет: пользовательский менеджер systemd должен РЕАЛЬНО применять `MemoryMax` к транзиентным юнитам.** Замерено на этом хосте 29.08: тест трижды подряд зелен в полных батареях (`baseline2`, `final2`, `final4`), затем **пять раз подряд красен в изоляции** — при неизменном коде пакета, которого пак не касался вовсе. Причина установлена ВНЕ батареи и вне Go: `systemd-run --user --scope -p MemoryMax=64M …` даёт процессу спокойно занять 400 МиБ и выйти с кодом 0. ⚠ **МЕХАНИЗМ уточнён приёмкой оркестратора №19, и уточнение решает, воспроизводимо ли это:** «systemd не применяет потолок» верно по симптому и мимо по причине. Свойство ДОЕХАЛО — `systemctl --user show -p MemoryMax` печатает `67108864`; делегирование в порядке — `memory pids` и в `cgroup.controllers`, и в `subtree_control`. Пропал не потолок, а cgroup-КАТАЛОГ: в `app.slice` нет ни одного scope-каталога, а `cut -d: -f3 /proc/self/cgroup` для оболочки даёт **`/init.scope`**. То есть вызывающий процесс живёт ВНЕ `user@.service`; `systemd-run --user` заводит юнит в модели менеджера, а процесс остаётся в исходном cgroup, и лимита не получает никто — молча. ⚠ Отсюда и наблюдение «условие отваливается между двумя прогонами одной сессии»: оно зависит от того, из какого cgroup стартовал прогон. Тест при этом ПРАВ и его сообщение точное («check that the leaf cgroup of tm-runs.slice has memory.max»): он ловит ровно то, ради чего написан, — что потолок памяти прогона на этом хосте иллюзорен. ⚠ Следствие для процесса, а не только для теста: рецепт `STACK_DECISIONS` называет три условия батареи, а их четыре, и четвёртое — свойство ХОСТА, которое может отвалиться между двумя прогонами в одной сессии, что здесь и произошло. Сессия, наступившая на это, потратит время на поиск дефекта в своём диффе. ⚠ **Условие для рецепта формулируется НЕ так, как оно там сейчас стоит.** «Достижимый пользовательский менеджер systemd» выполнено — менеджер отвечает, `tm-runs.slice` виден, — и всё равно лимит не применяется. Правильная формулировка: **вызывающий процесс обязан жить ВНУТРИ `user@.service`** (проверка: `cut -d: -f3 /proc/self/cgroup` не должен давать `/init.scope`). Оболочка, поднятая вне пользовательского входа — под WSL это обычный случай, — проходит первую проверку и валит вторую, и следующий читатель решит, что условие выполнено, и пойдёт искать дефект в Go. Лечение: назвать условие в рецепте именно так и дать тесту различать «хост не применяет лимит» (честный скип с причиной) и «раннер не передал лимит» (настоящий отказ) | open | пак P11 (финальная батарея; воспроизведено голым `systemd-run` вне Go) | | PD-426 | bug | minor | `internal/pgstore/runs.go` `Quarantine`, `internal/ingest/tail.go` (четыре отказа выше ветки `default`) | **Карантин проекции не снимается НИЧЕМ, а попасть в него можно по чужому законному handshake'у.** Первое: `quarantine_reason` пишется, и во всём дереве нет ни одного места, которое его очищает, — то есть состояние терминально для проекции живого оплаченного прогона. Второе: в `ingest/tail.go` четыре отказа стоят ВЫШЕ ветки `default`, которая говорит «другой поток начинается здесь, нас не касается», при том что журнал ПЕР-КНИЖНЫЙ и append-only, так что чужие handshake'ы в нём законны. Вместе: чужой handshake в журнале книги карантинит проекцию прогона, за который заплачено, навсегда. ⚠ Не предмет пака P11 (тейлер и карантин — эры P4/P5), заведено строкой | open | приёмка оркестратора №19 по паку P11 (охотник вне карты) | | PD-427 | doc | minor | `internal/ingest/resync.go:37-43` (аллоулист `StatusReport`), опровергнуто `backend/internal/pipeline/status.go` `projectStoredMemory` (лендинг `6ec9f8a`, D39.170) | **Комментарий несёт ПОСЫЛКУ, которую сняли, и читается как действующий довод.** Он объясняет, почему платформа сознательно НЕ берёт `rebill_units`/`rebill_usd` через шов: «status проецирует СОХРАНЁННУЮ память, и сразу после `bank-apply` — в единственный момент, когда согласие хотело бы цифру, — он честно читает ноль». Это было верно и ратифицировано (эррата 28.08-к). Движковый пак «деньги» починил ровно это: `foldMemoryForRead` стал ПЕРВЫМ ответом читающего пути, а `projectStoredMemory` понижена до фолбэка, и комментарий движка объявляет это дословно — «IT IS NO LONGER THE READ PATH'S FIRST ANSWER». Слепое окно закрыто, `status` отвечает «сколько будет стоить» ДО покупки, оставаясь $0-глаголом без записи. ⚠ **Комментарий неверен ДВАЖДЫ:** не только посылка, но и предсказанное лечение — он обещает, что «пара вернётся с движковым ГЛАГОЛОМ, который умеет свернуть и оценить коррекцию ВНЕ прогона», а нового глагола не появилось: починили существующий `status`. ⚠ **ПРОВОДКУ ПОЛЕЙ ЭТА СТРОКА НЕ ОТКРЫВАЕТ** (слово оркестратора при передаче): она гейчена вместе с `tmctl translate --max-units`, и тот гейт в силе — движковый потолок объёма на майнящей банк книге пробивался, лечение легло, но проводка ждёт отдельного решения. То есть предмет строки — ровно устаревший ДОВОД, а не отсутствие полей. Класс — «указатель пережил то, на что указывал», тот же, что `PD-310`/`PD-326`/`PD-366`, только в прозе шва. Зеркалит строку 234 единого бэклога | open | оркестратор №19 при лендинге движкового пака (`6ec9f8a`), проверено чтением обеих сторон сессией P11 | @@ -547,8 +547,8 @@ | PD-376 | bug | minor, деньги | `internal/pgstore/runs.go:899`=`select min(a.spend_baseline_micro_usd)`, пин `internal/runs/sweep_test.go` `TestADeferredSettlementIsNotChargedForTheNextRunOfTheSameBook` | **`PD-159` стоит `fixed`, а её пин доказывает свойство СЛАБЕЕ, чем читается: слово `min` не исполняет никто, и мутация `min` → `max` проходит батарею.** Строка PD-159 закрывает двойную оплату формулой «SpendBound — НАИМЕНЬШАЯ базовая линия среди попыток этой книги, стартовавших ПОЗЖЕ», а названный ею пин кладёт в книгу РОВНО ОДНУ более позднюю попытку — на множестве из одного элемента `min` и `max` совпадают. Прогон `./internal/pgstore/` и `./internal/runs/` под мутацией зелёный; независимый пин на той же мутации падает (`the bound is 0.500000, want 0.200000`), то есть мутация поведенческая, а не эквивалентная. Достижимость: при монотонном росте книжного счётчика `min` и `max` расходятся уже при ДВУХ более поздних попытках, а две даёт один преемник, переживший рестарт или резюм; тогда границей становится базовая линия, УЖЕ содержащая трату предыдущего прогона — это ровно PD-159 на одну попытку дальше. Переплата ограничена холдом. Класс — «реестр умеет врать», тот же разбор, каким был найден PD-169. Готовый пин: `docs/p8-review/axis1-money/a1_spendbound_test.go.txt` и независимый `r1_spendbound_test.go.txt`. ⚠ Статус PD-159 этим паком НЕ менялся: пере-открывать её или оставить закрытой с этой строкой как носителем живого пробела — диспозиция приёмки ⚠ **Пере-проверено на ПОЛНОЙ батарее координатором пака (мутация M11):** первая улика была снята на пакетном сабсете `./internal/pgstore/ ./internal/runs/`, и упрёк «сабсет слабее полной батареи» справедлив. Прогон `go test ./... -count=1` со всеми тремя гейтами под той же мутацией даёт ПУСТУЮ дельту против чистой базовой линии той же копии — то есть мутацию не ловит ни один из 18 пакетов. Лог — `docs/p8-review/mutations-round2.log` ⚠ **ЗАКРЫТО ПАКОМ P11 (29.08).** Взят готовый пин пака — вариант `r1_` как более сильный (ходит настоящими дверями `StartRun`/`RecordSpawn`, наименьшая базовая линия стоит В СЕРЕДИНЕ, так что «первая поздняя» и «последняя поздняя» тоже падают) — и усилен ВТОРОЙ книгой того же аккаунта, чтобы исполнялся и фильтр `r.book_id`. Посадка `min`→`max`: чистая копия EXIT=0, с мутацией EXIT=1, единственный красный — этот пин. ⚠ **ДИСПОЗИЦИЯ, которую строка оставляла приёмке: `PD-159` НЕ пере-открывается.** Основание прежнее (D39.159 §5): дефект из кода ушёл, недоставало ПИНА, — а теперь пин есть, то есть пробел закрыт там, где он был. Двусторонняя ссылка сохраняется: `PD-159` несёт `ОСПОРЕНО(PD-376)`, эта строка называет `PD-159` | fixed(пак P11 — лендинг оркестратора; статус проставлен зоной) | ревью-пак P8-REVIEW, ось 1 (посадка мутации, подтверждено рефутером собственным пином) | | PD-384 | bug | minor | `internal/runs/reconcile.go:262`=`case overran:` (`settleOne`) ⚠ якорь пере-нацелен паком P11: прежнее `if !overran {` было САМИМ дефектом — судить по цене вместо вердикта — и заменено свитчем по вердикту, `internal/runs/reconcile.go` `settle` (три тихих `return nil`) | **Расчёт денег, упавший ДЁШЕВО, не считается никогда: порог `StalledAfter` для него недостижим.** Вторая фаза считает неудачу ТОЛЬКО по исчерпанию бюджета (`overran := errors.Is(item.Err(), context.DeadlineExceeded)`, дальше `if !overran { return }`), а `settle` возвращает nil БЫСТРО в трёх случаях: движок не ответил, в отчёте нет committed, попытка без базовой линии. Каждый может быть ПОСТОЯННЫМ — запиненный бинарь движка снесён при выкате, проект заменён под платформой, попытка старой схемы. Тогда цикл вечен: `reconcile_failures` остаётся 0, `reconcile_after` NULL, гейдж и `tmplatformctl runs --stalled` пусты, холд заморожен. Замерено пробой: пять проходов одного нерассчитываемого прогона дали 5 вызовов движка, `reconcile_failures=0`, `StalledRuns(5)=0`. Плюс цена: `settle` зовёт `tmctl status` НА КАЖДОМ проходе без рейт-лимита, тогда как соседний `maybeResync` имеет `dueForResync` ровно из-за этой цены. ⚠ Рефутер сузил вес major → minor: холд ВИДЕН двум поверхностям, которых финдер не спросил — гейдж `tm_platform_oldest_open_hold_seconds` и `tmplatformctl balance --user`, печатающий каждый открытый холд суммой, книгой и id прогона; плюс каждый проход пишет WARN с id прогона. Воспроизведение: `docs/p8-review/axis3-queue/probe_settlement_surface_test.go.txt` ⚠ **Общий корень с `PD-385`, и там же он взвешен:** сужение ЭТОЙ строки опирается на операторскую поверхность, несостоятельность которой доказывает соседняя строка того же пака — круговое сужение разобрано в `PD-385`, поднятой до major ⚠ **ЗАКРЫТО ПАКОМ P11 (29.08).** Неудачей считается ВЕРДИКТ расчёта, а не только исчерпание бюджета: `settle` вернул три состояния (закрыт · гонка · не вычислим), `settleOne` судит по ним, поэтому все три тихих `return nil` теперь доходят до порога. Цена, названная строкой, закрыта тем же ходом: отсрочка ограничивает `tmctl status` вместо вызова каждым проходом. ⚠ Первая неудача НЕ откладывается — открытая резервация это ворота РЕЗЮМА пользователя (`reopen` отказывает, пока холд предыдущей попытки открыт), и минута ожидания после секундной аварии была бы регрессом; бэкофф идёт со второй и капнут пятью минутами, а не тридцатью. Посадка `r_firstfast` | fixed(пак P11 — лендинг оркестратора; статус проставлен зоной) | ревью-пак P8-REVIEW, ось 3 (проба на реальном сторе, сужено рефутером) | | PD-391 | bug | minor | `internal/pgstore/sink.go:696`=`where a.reconcile_after is null or a.reconcile_after <= $1`, `internal/pgstore/runs.go` `AbandonRun`, `cmd/tmplatformctl/runs.go` (сообщение), `deploy/README.md` | **`run abandon` не возвращает холд «на ближайшем свипе»: отсрочка застрявшей попытки остаётся, и деньги ждут до 30 минут.** `AbandonRun` завершает прогон и попытку, но `run_attempts.reconcile_after` не трогает, а `UnsettledRuns` по нему фильтрует. Застрявший прогон по построению всегда отсрочен: `deferItem` ставит `now + backoff(failures+1)`, а `backoff` при пяти неудачах упирается в потолок 30 минут. Значит для ВСЕЙ популяции, ради которой команда построена, обещание CLI «its hold comes back whole on the next sweep» и та же фраза рантбука ложны: кредит остаётся вычтенным, `tm_platform_oldest_open_hold_seconds` продолжает расти ПОСЛЕ действия оператора, и оператор читает это как «я сделал, не помогло». Пин `PD-361` доказывает свойство слабее: он берёт прогон, созданный `Start` и брошенный СРАЗУ, у которого `reconcile_failures` 0 и `reconcile_after` NULL. Живая проба на состоянии, выращенном штатным механизмом: через 45 секунд и три свипа холд открыт, `balance` печатает «reserved 3.000000», гейдж 2439 с; ручной `update run_attempts set reconcile_after = now()` закрывает холд в тот же свип. Лечится одной строкой в той же транзакции — снять отсрочку вместе с терминальным вердиктом. Воспроизведение: `docs/p8-review/axis4-metrics/20-abandon-keeps-the-hold.sh` и `r3-abandon-hold.sh` ⚠ **ЗАКРЫТО ПАКОМ P11 (29.08).** `AbandonRun` снимает `reconcile_after` в ОБЕИХ ветках, живой и расчётной. Пин растит прогон до пяти неудач штатными путями и сверяет холд ПОСЛЕ свипа на НЕДВИНУТЫХ часах — то есть исполняет ровно то обещание CLI, которое было ложным. Названные строкой носители обещания исправлены: `deploy/README.md` и сообщение команды. Посадка `m391_defer` — поймана топично | fixed(пак P11 — лендинг оркестратора; статус проставлен зоной) | ревью-пак P8-REVIEW, ось 4 (живая проба, подтверждено рефутером на состоянии из штатного пути) | -| PD-397 | hardening | info | `internal/pgstore/credits.go:52-53`=`A ledger row is never edited: the correction is another row`, `internal/pgstore/credits.go:399`=`The two are never written apart`, миграция `internal/pgstore/migrations/00007_credits.sql` | **Два самых сильных денежных инварианта объявлены ПРОЗОЙ и держатся ТОЛЬКО кодом — схема их не навязывает.** `Adjust` обещает «леджер не правится, коррекция это ещё одна строка, и именно это делает сумму воспроизводимой»; `appendLedger` обещает «кэш и леджер никогда не пишутся врозь, потому что отстающий кэш — это второй ответ про деньги». Проба прямым SQL по стенду показывает, что DDL допускает нарушение обоих: `UPDATE` и `DELETE` строки леджера ПРИНЯТЫ, кэш баланса выставляется ЛОЖЬЮ и ОТРИЦАТЕЛЬНЫМ тоже. Пере-проверено координатором пака независимо от агента — все четыре приняты, и откат пробы сам же оставил расхождение кэша с леджером в 1 микро-доллар, которое поймало только сведение двумя путями, а не база. ⚠ Что схема при этом ДЕРЖИТ и что находкой НЕ является (иначе строка читается как «денежных констрейнтов нет»): знак по каждому виду строки, обязательная нота у коррекции, закрытый словарь видов, непустые `source`/`source_id`, уникальность ключа идемпотентности в пределах аккаунта, положительность сумм резервации, согласованность состояния и времени закрытия, владение книгой через композитный внешний ключ, и переполнение bigint в кэше. То есть DDL закрывает ФОРМУ строки и не закрывает ИСТОРИЮ. Цена названа и она не про сегодняшний код: пути правки леджера в Go нет, поэтому эксплуатации нет — опасны миграция данных, операторский `psql` и будущий инструмент, каждый из которых по построению идёт мимо кода, а прозу в доккомментарии не читает. Лечится либо триггером на `update`/`delete` по `credit_ledger`, либо явной записью «append-only — дисциплина кода, не схемы» рядом с обещанием. ⚠ Заведено ЗАПОЗДАЛО и это отдельный факт: работа была сделана агентом оси 1 по прямому требованию промта («попробуй нарушить каждый прямым SQL; констрейнт, которого нет, это находка»), артефакт `docs/p8-review/axis1-money/constraint-probe.out` лежал в сдаче, а строки не имел — нашёл редакторский аудит полноты. Воспроизведение: `docs/p8-review/axis1-money/constraint-probe.sh` ⚠ **ЗАКРЫТО ПАКОМ P11 (29.08), и закрыто ВТОРЫМ из двух предложенных строкой способов.** Триггер на `update`/`delete` по `credit_ledger` ОТКЛОНЁН с двумя основаниями, проверенными в дереве: он сработает на КАСКАДНОМ удалении пользователя, которое миграция `00007` объявляет границей append-only, и сломает законную фикстуру `TestAReleaseWhoseKeyWasSpentIsRefusedRatherThanSilent`, которая правит леджер намеренно. Вместо него: проза `Adjust` и `appendLedger` сделана честной («держит КОД, а не схема», с перечнем того, что схема ДЕРЖИТ), плюс ГЕЙТ `TestTheLedgerIsAppendOnlyInTheCodeThatWritesIt` — ни один `update`/`delete` по `credit_ledger` (в том числе схемо-квалифицированный) не написан ни в одном из 172 SQL пакета. Посадка `r_ledgeredit` настоящей формой (`tx.Exec` внутри `appendLedger`). ⚠ Что осталось НЕзакрытым и названо: миграция данных, операторский `psql` и будущий инструмент идут мимо пакета по построению | fixed(пак P11 — лендинг оркестратора; статус проставлен зоной; ⚠ закрыт РАЗБОРОМ с отказом от триггера, см. тело) | ревью-пак P8-REVIEW, ось 1 (проба агента, пере-проверена координатором; строка заведена по аудиту полноты) | -| PD-394 | hardening | info | `internal/pgstore/credits.go:225`=`if spent < 0 {`, констрейнт `credit_ledger_sign` в `internal/pgstore/migrations/00007_credits.sql` | **Гард отрицательного расхода в `Settle` не запинен: снятие проходит ПОЛНУЮ батарею (18 пакетов).** Класс тот же, что у `PD-333`/`PD-334` — оговорка денежного пути, которую ни один тест не исполняет. Цена НАЗВАНА и она ограничена схемой, а не кодом: отрицательный `spent` дал бы `settlement` с положительной суммой, а это ловит констрейнт `credit_ledger_sign` — проверено прямым INSERT на стенде, Postgres отвечает `violates check constraint "credit_ledger_sign"`. То есть сегодня вреда нет, и защита ТРАНЗИТИВНА: держит её схема, а не гард, который для этого написан. Родня `PD-86` (там потолок сессии держится через соседнюю функцию). Достижимость самого отрицательного значения сегодня нулевая — единственный источник `attemptSpend` клампит в ноль, и этот кламп запинен. Воспроизведение: `docs/p8-review/plant.py` (мутация M4) и `mutations-full.log` ⚠ Улика воспроизводима из артефактов: `plant.py` знает эту мутацию под именем M4, лог — `docs/p8-review/mutations-full.log` ⚠ **ЗАКРЫТО ПАКОМ P11 (29.08).** Гард получил ИМЯ (`ErrNegativeSpend`) и пин на `errors.Is`. ⚠ Имя понадобилось не для красоты: ПЕРВАЯ редакция пина проверяла лишь «вернулась ошибка» — и посаженная мутация её прошла, потому что ошибку вернул констрейнт `credit_ledger_sign`, то есть ровно та транзитивная защита, о которой строка и написана. Посадка `r_negative` на исправленном пине — поймана | fixed(пак P11 — лендинг оркестратора; статус проставлен зоной) | ревью-пак P8-REVIEW, ось 1 (посадка мутации координатора) | +| PD-397 | hardening | info | `internal/pgstore/credits.go:52-53`=`A ledger row is never edited: the correction is another row`, `internal/pgstore/credits.go:398`=`The two are never written apart`, миграция `internal/pgstore/migrations/00007_credits.sql` | **Два самых сильных денежных инварианта объявлены ПРОЗОЙ и держатся ТОЛЬКО кодом — схема их не навязывает.** `Adjust` обещает «леджер не правится, коррекция это ещё одна строка, и именно это делает сумму воспроизводимой»; `appendLedger` обещает «кэш и леджер никогда не пишутся врозь, потому что отстающий кэш — это второй ответ про деньги». Проба прямым SQL по стенду показывает, что DDL допускает нарушение обоих: `UPDATE` и `DELETE` строки леджера ПРИНЯТЫ, кэш баланса выставляется ЛОЖЬЮ и ОТРИЦАТЕЛЬНЫМ тоже. Пере-проверено координатором пака независимо от агента — все четыре приняты, и откат пробы сам же оставил расхождение кэша с леджером в 1 микро-доллар, которое поймало только сведение двумя путями, а не база. ⚠ Что схема при этом ДЕРЖИТ и что находкой НЕ является (иначе строка читается как «денежных констрейнтов нет»): знак по каждому виду строки, обязательная нота у коррекции, закрытый словарь видов, непустые `source`/`source_id`, уникальность ключа идемпотентности в пределах аккаунта, положительность сумм резервации, согласованность состояния и времени закрытия, владение книгой через композитный внешний ключ, и переполнение bigint в кэше. То есть DDL закрывает ФОРМУ строки и не закрывает ИСТОРИЮ. Цена названа и она не про сегодняшний код: пути правки леджера в Go нет, поэтому эксплуатации нет — опасны миграция данных, операторский `psql` и будущий инструмент, каждый из которых по построению идёт мимо кода, а прозу в доккомментарии не читает. Лечится либо триггером на `update`/`delete` по `credit_ledger`, либо явной записью «append-only — дисциплина кода, не схемы» рядом с обещанием. ⚠ Заведено ЗАПОЗДАЛО и это отдельный факт: работа была сделана агентом оси 1 по прямому требованию промта («попробуй нарушить каждый прямым SQL; констрейнт, которого нет, это находка»), артефакт `docs/p8-review/axis1-money/constraint-probe.out` лежал в сдаче, а строки не имел — нашёл редакторский аудит полноты. Воспроизведение: `docs/p8-review/axis1-money/constraint-probe.sh` ⚠ **ЗАКРЫТО ПАКОМ P11 (29.08), и закрыто ВТОРЫМ из двух предложенных строкой способов.** Триггер на `update`/`delete` по `credit_ledger` ОТКЛОНЁН с двумя основаниями, проверенными в дереве: он сработает на КАСКАДНОМ удалении пользователя, которое миграция `00007` объявляет границей append-only, и сломает законную фикстуру `TestAReleaseWhoseKeyWasSpentIsRefusedRatherThanSilent`, которая правит леджер намеренно. Вместо него: проза `Adjust` и `appendLedger` сделана честной («держит КОД, а не схема», с перечнем того, что схема ДЕРЖИТ), плюс ГЕЙТ `TestTheLedgerIsAppendOnlyInTheCodeThatWritesIt` — ни один `update`/`delete` по `credit_ledger` (в том числе схемо-квалифицированный) не написан ни в одном из 172 SQL пакета. Посадка `r_ledgeredit` настоящей формой (`tx.Exec` внутри `appendLedger`). ⚠ Что осталось НЕзакрытым и названо: миграция данных, операторский `psql` и будущий инструмент идут мимо пакета по построению | fixed(пак P11 — лендинг оркестратора; статус проставлен зоной; ⚠ закрыт РАЗБОРОМ с отказом от триггера, см. тело) | ревью-пак P8-REVIEW, ось 1 (проба агента, пере-проверена координатором; строка заведена по аудиту полноты) | +| PD-394 | hardening | info | `internal/pgstore/credits.go:227`=`if spent < 0 {`, констрейнт `credit_ledger_sign` в `internal/pgstore/migrations/00007_credits.sql` | **Гард отрицательного расхода в `Settle` не запинен: снятие проходит ПОЛНУЮ батарею (18 пакетов).** Класс тот же, что у `PD-333`/`PD-334` — оговорка денежного пути, которую ни один тест не исполняет. Цена НАЗВАНА и она ограничена схемой, а не кодом: отрицательный `spent` дал бы `settlement` с положительной суммой, а это ловит констрейнт `credit_ledger_sign` — проверено прямым INSERT на стенде, Postgres отвечает `violates check constraint "credit_ledger_sign"`. То есть сегодня вреда нет, и защита ТРАНЗИТИВНА: держит её схема, а не гард, который для этого написан. Родня `PD-86` (там потолок сессии держится через соседнюю функцию). Достижимость самого отрицательного значения сегодня нулевая — единственный источник `attemptSpend` клампит в ноль, и этот кламп запинен. Воспроизведение: `docs/p8-review/plant.py` (мутация M4) и `mutations-full.log` ⚠ Улика воспроизводима из артефактов: `plant.py` знает эту мутацию под именем M4, лог — `docs/p8-review/mutations-full.log` ⚠ **ЗАКРЫТО ПАКОМ P11 (29.08).** Гард получил ИМЯ (`ErrNegativeSpend`) и пин на `errors.Is`. ⚠ Имя понадобилось не для красоты: ПЕРВАЯ редакция пина проверяла лишь «вернулась ошибка» — и посаженная мутация её прошла, потому что ошибку вернул констрейнт `credit_ledger_sign`, то есть ровно та транзитивная защита, о которой строка и написана. Посадка `r_negative` на исправленном пине — поймана | fixed(пак P11 — лендинг оркестратора; статус проставлен зоной) | ревью-пак P8-REVIEW, ось 1 (посадка мутации координатора) | | PD-414 | bug | minor, деньги | `internal/pgstore/credits.go` `Settle` (`appendLedger` для `run_settle`) | **`Settle` ВЫБРАСЫВАЛ флаг `applied` своей леджер-записи — единственный из трёх вызовов `appendLedger`, который его не читал.** Соседи проверяют: `holdTx` отвечает `ErrDuplicateHold`, `releaseHold` — `ErrReleaseKeySpent` С ОТКАТОМ (`PD-97`). Здесь потраченный ключ `run_settle` НЕ СПИСЫВАЛ НИЧЕГО, при том что холд уже возвращён целиком, а вызывающему возвращался `nil`: аккаунт получает работу даром. ⚠ Достижимость сегодня НУЛЕВАЯ, и это записано, чтобы приёмка не искала траекторию: резервация закрывается под `state = 'open'`, поэтому второй `Settle` получает `ErrNoReservation` и сюда не доходит, а потратить ключ можно только пере-открыв резервацию на той же попытке — что `holdTx` отказывает ровно по этой причине. Класс — ровно `PD-394`: неисполняемая сегодня оговорка денежного пути. Найдено самопроходом пака P11 (линза денег), не строкой заказа ⚠ **ЗАКРЫТО ПАКОМ P11 (29.08):** флаг читается, новый сентинел `ErrSettlementKeySpent`, откат как у `releaseHold`; пин `TestASettlementWhoseKeyWasSpentIsRefusedRatherThanSilent`, посадка `r_applied` | fixed(пак P11 — лендинг оркестратора; статус проставлен зоной) | самопроход пака P11 (§4.5, линза денег под гонкой) | | PD-415 | doc | minor | `docs/STACK_DECISIONS.md` «Стенд разработчика», `internal/runner/translate_resnapshot_live_test.go` | **Рецепт стенда давал КРАСНУЮ батарею, а не скип, и красноту эту следующая сессия принимала за свою поломку.** Рецепт рендерит шаблон книги из `backend/example/book.yaml`, а тот указывает на `configs/pipeline-c1.yaml` — ПЛАТНЫЙ DeepSeek. Живой тест P10 `TestTheSnapshotGuardIsLoudWithoutTheFlagsAndPassesWithThem`, включаемый вторым гейтом, гоняет настоящий движок и падает `tmctl: missing API keys (fill in backend/.env)` — при том что его собственный комментарий обещает «free of provider keys and of paid calls». Тест прав: у стенда есть $0-пара (`local-qwen3-8b`, провайдер на `127.0.0.1:11434`, заглушку тест поднимает сам), но в репо НЕТ пайплайна, который бы её называл. ⚠ Цена не только во времени: гейт гоняет НАСТОЯЩИЙ движок, то есть платный пайплайн в шаблоне — ещё и риск оплаченных вызовов ⚠ **ЗАКРЫТО ПАКОМ P11 (29.08):** раздел переписан — три гейта вместо двух, замеренные числа (0 скипов с гейтами, 287 без на HEAD и 304 на дереве пака) и рецепт $0-шаблона, который обязан лежать РЯДОМ с `prompts/` (промпты резолвятся от каталога пайплайна). Пере-проверено чужими руками: оркестратор при приёмке наступил на ту же граблю и вышел по предупреждению за минуту | fixed(пак P11 — лендинг оркестратора; статус проставлен зоной) | пак P11, сборка стенда | | PD-416 | bug | minor | `internal/pgstore/sqlgate_test.go` `usedException` | **Дефект, который пак P11 ВНЁС в чужой гейт, и который поймала его же мутационная обвязка.** Счётчик исполнения исключений `unresolvable` был ПАКЕТНОГО уровня, а `collectSQL` с приходом второго гейта (`TestTheLedgerIsAppendOnlyInTheCodeThatWritesIt`) стал вызываться дважды за прогон: общий счётчик читал первый визит ВТОРОГО вызывающего как второй визит ПЕРВОГО и падал с «`Ready` holds a second statement this gate cannot read» о функции, которая держит ровно одно. Проявлялось как КРАСНЫЕ ЧИСТЫЕ копии в мутационной кампании там, где копируемое дерево было зелёным, — то есть маскировало дельту, что для кампании худший исход. ⚠ **ЗАКРЫТО ПАКОМ P11 (29.08):** счётчик стал per-extraction (`newExceptionCounter`), инвариант «одно исключение — один сайт, каждое исключение исполнено» сохранён полностью | fixed(пак P11 — лендинг оркестратора; статус проставлен зоной) | мутационная обвязка пака P11 | @@ -572,4 +572,5 @@ | PD-166 | bug | info | `internal/ingest/tail.go`, `internal/pgstore/sink.go` `Begin` | **`chunker_version` из хендшейка теряется навсегда,** если краш пришёлся между двумя стейтментами `Begin` (привязка `engine_run_id` и запись версии — два отдельных автокоммита): при повторном чтении своего же `hello` тейлер видит, что поток уже привязан, и `Begin` больше не зовёт. Сегодня поле никем не читается (нужно для строки 100), поэтому info; закрывать — одной транзакцией в `Begin` ⚠ **ПАК P8-REVIEW 24.08: первая фраза сужена построенным.** У колонки `books.chunker_version` появился ВТОРОЙ независимый писатель — интейк (`internal/pgstore/books.go` `FinishParse` пишет её из манифеста движка). Значит крах между двумя стейтментами `Begin` оставляет не пустую колонку, а СТАРОЕ значение разбора: теряется дельта, а не значение. Читателя по-прежнему нет, вес `info` верен ⚠⚠ **МОЯ ДОПИСКА ВЫШЕ ОПРОВЕРГНУТА РЕФУТЕРОМ, и опровергнута верно — снимаю её.** Механизм, который строка описывает (крах между ДВУМЯ автокоммитами `Begin`), СЕГОДНЯ НЕДОСТИЖИМ: запись версии переехала из `Begin` в `effect` и идёт в ОДНОЙ транзакции с курсором (`internal/pgstore/sink.go:105-127`), а сам `Begin` объявлен legacy (`sink.go:33-38`) и для попыток этой сборки недостижим — `engine_run_id` присваивается в INSERT попытки и бэкфилится в `RecordSpawn`. Доказано исполнением: при уцелевшей привязке и нулевом курсоре хендшейк идёт в `Apply`, а не в `Begin` (`Begin calls=0 Apply calls=1`), то есть терять между двумя стейтментами нечего. Посадка мутации (вырезан `case ingest.TypeHello` из `effect`) роняет `TestTheChunkerVersionOfTheStreamReachesTheBook` — атомарный писатель есть и запинен. **Правильная диспозиция — не «сузить до потери дельты», а ЗАКРЫТЬ как построенное:** атомарность, которой строка требовала, существует. Читателя у колонки по-прежнему нет, и это отдельный факт, а не этот дефект ⚠ **ЗАКРЫТА актом лендинга P9 (D39.162):** акт объявил закрытие состоявшимся, статус переведён по аудиту документации 28.08 — обязательство акта висело неисполненным (класс «строка open при легшем лечении», носитель — строка бэклога 225) | fixed(акт D39.162, перевод по аудиту 28.08) | самопроверка дофикса (ревью вне карты) · аудит документации 28.08 | | PD-254 | standards | info | `internal/httpapi/problem.go` `WriteStatusProblem`, `codeForStatus` | **Два писателя ошибок на одну форму тела.** `WriteProblem` выводит статус ИЗ кода (пара не может разойтись), а `WriteStatusProblem` идёт обратно — от статуса к коду — потому что вне версионного префикса (`/auth`, `/readyz`) есть статусы, которых в словаре контракта нет вовсе (405, 429). Свести в один писатель можно только назначив форму ответа для этих двух статусов, а это ратификация, не правка зоны. Пока — два пути и обратная функция рядом с прямой ⚠ **ЗАКРЫТО решением контрактной сессии 17.08 (релей владельца):** поверхность входа отвечает тем же конвертом и БЕЗ машинного кода — это ратифицированное решение, а не пробел («различать причины отказа клиент не может по замыслу», компаньон §2.14 с 0.2.3), а единственный осмысленный для пользователя случай `429` машинен без словаря, потому что лечение едет в `Retry-After`. Следствие для кода: писатель входа кода не эмитит, `codeForStatus` и `writeRaw` удалены — обратной функции, то есть второго источника истины, больше нет. Пин `httpapi.TestTheSignInSurfaceAnswersTheSameEnvelopeWithoutAVersionedCode` (шесть статусов). Отвергнуто с доводом: расширение `ErrorCode` (значения, недостижимые на описываемой им поверхности, ломают инвариант «код называет свой статус») и словарь в компаньоне (документ, не нормативный для формы, стал бы нормативным с чёрного хода) | fixed(P7, дерево сессии) | кросс-модельное ревью P7 (линза скоупа) | | PD-255 | doc | info | `internal/pgstore/migrations/00016_read_surface.sql`, `internal/pgstore/readmodel.go` | **Плотность комментариев выше нормы зоны** («одна-две строки почему», владелец 26.07): миграция 00016 — 250 строк, из них около половины проза; у `readmodel.go` многие символы несут абзацы. Часть прозы несущая (порядок delete/insert в двух таблицах — ровно то, на чём пак и споткнулся), часть — эссе. Подрезано самое тяжёлое; остальное — предмет решения владельца о норме, а не тихой правки ⚠ **ЗАКРЫТО решением владельца 21.08: НОРМА СМЕНИЛАСЬ, и строка была открыта против снятой формулы.** Счёт строк снят как негодный гейт — комментарий на три строки может быть нужен, на одну достаточен; режется ВОДА (пересказ решений, провенанс, изложение исследования вместо ссылки), а всё, что из одной функции НЕ выводится — порядок блокировок, инварианты между таблицами, цена забывания, вендор-квирк — остаётся, сколько бы строк ни заняло. Формулировка — `docs/architecture/12-go-style-notes` §1. Обе названные здесь прозы под новой нормой законны: порядок delete/insert между двумя таблицами из функции не выводится, а сам дефект пака это доказал. Тихой правки не было и не будет | fixed(решение владельца 21.08) | кросс-модельное ревью P7 (линза энтропии) | -| PD-429 | bug | minor | `internal/config/effective_test.go` `TestAConfiguredAmountIsNeverPrinted` | **Денежный тест краснел на совпадении с ЧАСАМИ, то есть на факте, которого не существует.** Он искал суммы подстрокой во всём буфере `slog.NewJSONHandler`, а тот несёт `time` в RFC3339Nano — девять знаков дробной части. Замерено на 200 000 таймстемпов: `7.50` встречается в **205**, `42500` в 5, `0.0425` в 3 — и это НА СТРОКУ, а прогон печатает по строке на настройку, поэтому совпадения приходят вспышками (все строки одного прогона делят одну секунду). Воспроизведено `-count=3000`: падение на `42500`. ⚠ **Утечки не было и нет:** значение скрывается (`config.go` пишет `valueAmount`), и этот же тест двумя строками выше сам это и проверяет. ⚠ **Почему это чинится, а не оставляется под запретом D39.121:** запрет защищает тест, ловящий НАСТОЯЩИЙ дефект, — такой нельзя ослаблять ради зелени. Здесь дефектен САМ тест: ложно-положительное срабатывание на данных, к предмету не относящихся. Оставить как есть — худший исход: флейк в ДЕНЕЖНОМ тесте приучает читать красное как шум, и в день настоящей утечки красное не отличат. ⚠ **Лечение НЕ ослабляет:** каждая строка разбирается как JSON, поле `time` выбрасывается, поиск идёт по значениям и ключам всего остального (`loggedFields`), то есть проверка перестала зависеть от формата времени. ⚠ **Честная оговорка, установленная ПОСАДКОЙ:** охват при этом НЕ вырос — сырой поиск покрывал и `msg`, и посадка «сумма печатается в msg» валит ОБЕ формы. Выигрыш ровно один и он назван: убрано ложное срабатывание. Проверка: `-count=5000` чисто (падало на 3000), посадка в `msg` — красная. ⚠ **Родня, сегодня безопасная по причине, которую стоит знать:** `internal/runs/sweep_test.go` ищет `1.000000`/`0.200000` в буфере `slog.NewTextHandler`, а у текстового обработчика дробная часть ТРИ знака (замерено: `.437` против `.437860545` у JSON), поэтому шестизначные хвосты там не совпадут никогда — переключение того теста на JSON-обработчик воскресит этот же дефект | fixed(пак P11, дофикс 29.08 — лендинг оркестратора; статус проставлен зоной) | охотник приёмки; механизм пере-проверен оркестратором №19 и независимо пере-замерен сессией P11 | +| PD-429 | bug | minor | `internal/config/effective_test.go` `TestAConfiguredAmountIsNeverPrinted` | **Денежный тест краснел на совпадении с ЧАСАМИ, то есть на факте, которого не существует.** Он искал суммы подстрокой во всём буфере `slog.NewJSONHandler`, а тот несёт `time` в RFC3339Nano — девять знаков дробной части. Замерено на 200 000 таймстемпов: `7.50` встречается в **205**, `42500` в 5, `0.0425` в 3 — и это НА СТРОКУ, а прогон печатает по строке на настройку, поэтому совпадения приходят вспышками (все строки одного прогона делят одну секунду). Воспроизведено `-count=3000`: падение на `42500`. ⚠ **Утечки не было и нет:** значение скрывается (`config.go` пишет `valueAmount`), и этот же тест двумя строками выше сам это и проверяет. ⚠ **Почему это чинится, а не оставляется под запретом D39.121:** запрет защищает тест, ловящий НАСТОЯЩИЙ дефект, — такой нельзя ослаблять ради зелени. Здесь дефектен САМ тест: ложно-положительное срабатывание на данных, к предмету не относящихся. Оставить как есть — худший исход: флейк в ДЕНЕЖНОМ тесте приучает читать красное как шум, и в день настоящей утечки красное не отличат. ⚠ **Лечение НЕ ослабляет:** каждая строка разбирается как JSON, поле `time` выбрасывается, поиск идёт по значениям и ключам всего остального (`loggedFields`), то есть проверка перестала зависеть от формата времени. ⚠ **Честная оговорка, установленная ПОСАДКОЙ:** охват при этом НЕ вырос — сырой поиск покрывал и `msg`, и посадка «сумма печатается в msg» валит ОБЕ формы. Выигрыш ровно один и он назван: убрано ложное срабатывание. Проверка: `-count=5000` чисто (падало на 3000), посадка в `msg` — красная. ⚠ **Родня, сегодня безопасная по причине, которую стоит знать:** `internal/runs/sweep_test.go` ищет `1.000000`/`0.200000` в буфере `slog.NewTextHandler`, а у текстового обработчика дробная часть ТРИ знака (замерено: `.437` против `.437860545` у JSON), поэтому шестизначные хвосты там не совпадут никогда — переключение того теста на JSON-обработчик воскресит этот же дефект ⚠ **ДОФИКС ПО ВНЕШНЕМУ РЕВЬЮ (29.08), два пункта, оба в коде самой починки:** (1) helper звался ВНУТРИ цикла по искомым суммам, то есть буфер разбирался четырежды — вынесен наружу; (2) серьёзнее: пропуск ключа `time` стоял внутри РЕКУРСИВНОГО обхода, то есть вложенная группа с именем `time` получала тихое освобождение от денежной проверки. Это дыра, направленная не в ту сторону, в helper'е, чей смысл — что не освобождён никто; плоские строки делали обе версии неотличимыми, и так такая дыра доживает до смены формы. Теперь `delete` на ВЕРХНЕМ уровне, где поле и пишет обработчик, а обход безусловен. Запинено `TestTheTimestampExemptionDoesNotReachInsideTheLine` (синтетическая строка с вложенным `time`), посадка «вернуть пропуск в обход» — красная | fixed(пак P11, дофикс 29.08 + дофикс-2 по внешнему ревью — лендинг оркестратора; статус проставлен зоной) | охотник приёмки; механизм пере-проверен оркестратором №19 и независимо пере-замерен сессией P11; дофикс-2 — внешнее ревью | +| PD-430 | hardening | minor, деньги | `internal/pgstore/credits.go` `ReadAccount`, пин `internal/pgstore/readaccount_test.go` | **Три денежные цифры, которые `ReadAccount` возвращает, были ВЗАИМОЗАМЕНЯЕМЫ для всей батареи: перестановка целей `Scan` оставляла зелёными все 18 пакетов.** Причина ровно в том, что делает эту функцию ценной: она — ЕДИНСТВЕННОЕ место, где кэш баланса и сумма леджера сравниваются, и все денежные тесты зоны утверждают, что они РАВНЫ. Поэтому единственное, чего ни один из них не видит, — это их обмен. ⚠ Мутант НЕ эквивалентен, доказано пробой на дрейфе (кэш 4.00 при леджере 1.00): чистый код отвечает `Balance=4.000000 LedgerSum=1.000000`, с перестановкой — `1.000000/4.000000`, при полностью зелёной батарее. Цена условна, но нацелена: дрейф — ровно то состояние, ради выявления которого функция и существует, и именно в нём `tmplatformctl balance --user` печатал бы СУММУ ЛЕДЖЕРА под словом «баланс», а любое решение по `Account.Balance` принималось бы по леджеру вместо кэша; предупреждение о дрейфе при этом продолжало бы срабатывать (сравнение обмен переживает), то есть оператор получил бы верную тревогу при двух неверных числах. Класс — `PD-394`/`PD-376`: денежный путь, который верен, и верен лишь потому, что никто не опечатался. ⚠ **Найдено не мной:** сессия пака `sqlc` (`textmachine-1b`) замерила, что гейт `TestEverySQLStatementParsesAgainstTheMigratedSchema` видит только СТРОКУ SQL и никогда Go-сторону вызова — шесть её посадок (перестановки целей `Scan`, сломанная арность) выжили на полной батарее. Я пере-проверила это независимо на самом денежном из чтений зоны и подтвердила. ⚠⚠ **Пак `sqlc` дыру НЕ расширяет и НЕ сужает её остаток — он убирает одну из двух осей, и это поправка к первой редакции ЭТОЙ строки.** Я написала было, что с ним актуальность выросла, потому что «раньше перестановку хотя бы теоретически ловили типы». **Неверно, и опровергнуто чтением:** в HEAD `Account.Balance`, `.Reserved` и `.LedgerSum` — все три `money.MicroUSD`, так что компилятор молчал ровно так же, и дыра была того же размера. Что пак действительно меняет (замерено сессией `textmachine-1b`: переставила две денежные колонки в SELECT, регенерировала, Go не трогала — `Scan` переставился следом и значения остались в своих полях): осей дрейфа было ДВЕ — расхождение SELECT-списка с целями `Scan` и рукописная проекция имя-в-имя, — а стала ОДНА, потому что первые две теперь генерируются вместе и разойтись не могут. Остаётся ровно то, что закрывает этот пин. Пин проверен против ОБЕИХ реализаций: ловит перестановку и в рукописном `Scan`, и в генерённом пути (последнее пере-проверено самой `textmachine-1b` на её дереве, а не с моих слов) | fixed(пак P11, дофикс 29.08 — лендинг оркестратора; статус проставлен зоной) | сессия пака `sqlc` (`textmachine-1b`, §3.1 — что sqlc даёт сверх гейта); пере-проверено посадкой сессией P11 на денежном чтении | diff --git a/platform/internal/config/effective_test.go b/platform/internal/config/effective_test.go index 9cd548d0..cd260e3d 100644 --- a/platform/internal/config/effective_test.go +++ b/platform/internal/config/effective_test.go @@ -37,6 +37,36 @@ func effective(t *testing.T) (map[string]Setting, string) { return by, buf.String() } +// The exemption `loggedFields` grants is exactly ONE field of ONE line, and nothing deeper. +// +// It exists because the first version of that helper skipped any key named `time` at any DEPTH, so a +// nested group called `time` would have been silently exempt from the money check — a hole aimed the +// wrong way, in a helper whose entire purpose is that no field escapes. Flat lines made the two +// behave identically, which is how such a hole survives until the shape changes and nobody looks. +// +// Mutation caught: moving the `delete` back inside the recursive walk. +func TestTheTimestampExemptionDoesNotReachInsideTheLine(t *testing.T) { + const line = `{"time":"2026-08-29T03:00:00.123456789+03:00","level":"INFO","msg":"config",` + + `"nested":{"time":"7.50","key":"TM_PLATFORM_SIGNUP_GRANT_USD"}}` + fields := loggedFields(t, line) + var top, deep bool + for _, f := range fields { + if strings.Contains(f, "2026-08-29T03:00:00") { + top = true // the handler's own clock leaked through + } + if f == "7.50" { + deep = true // a value under a nested key called `time` was still looked at + } + } + if top { + t.Error("the handler's own timestamp reached the assertion: the false positive is back") + } + if !deep { + t.Error("a value nested under a key called `time` was exempted from the check: the exemption " + + "is meant to cover the handler's field, not any field that happens to share its name") + } +} + // loggedFields is every key and every value the boot log printed, MINUS the handler's own timestamp. // // ⚠ It exists because the money test used to search the RAW buffer, and that buffer carries an @@ -69,9 +99,6 @@ func loggedFields(t *testing.T, log string) []string { switch v := v.(type) { case map[string]any: for k, sub := range v { - if k == slog.TimeKey { - continue // the handler's own clock is not part of what this deployment printed - } out = append(out, k) walk(sub) } @@ -94,6 +121,12 @@ func loggedFields(t *testing.T, log string) []string { // that every field is looked at. t.Fatalf("the boot log emitted a line that is not JSON: %q (%v)", l, err) } + // ⚠ Dropped at the TOP LEVEL only, which is where the handler writes it. The first version of + // this helper skipped any key named `time` at any DEPTH, and that is a hole pointing the wrong + // way: a nested group called `time` would have been quietly exempt from a check whose whole + // purpose is that nothing is exempt. Today every line is flat, so the two behaved alike — which + // is exactly how such a hole survives to the day the shape changes. + delete(m, slog.TimeKey) walk(m) } if len(out) == 0 { @@ -182,8 +215,9 @@ func TestAConfiguredAmountIsNeverPrinted(t *testing.T) { t.Errorf("%s: value %q, want it withheld", k, got[k].Value) } } + fields := loggedFields(t, line) // parsed ONCE: the log does not change between amounts for _, amount := range []string{"7.50", "7500000", "0.0425", "42500"} { - for _, field := range loggedFields(t, line) { + for _, field := range fields { if strings.Contains(field, amount) { t.Errorf("the boot line carries the amount %q, in the field %q", amount, field) }