# Регистр дефектов и уязвимостей платформы > Заведён по решению владельца 04.08 («отдельно ведётся колонка багов и уязвимостей — тут опасно всё»). > ⚠ **Токен `ОСПОРЕНО(PD-N)`** (введён паком P8-REVIEW 24.08; правило ратифицировано D39.159, оговорка к PD-1 > дописана 27.08 по вычитке старшего): строка стоит `fixed`, а пак доказал, что её пин доказывает свойство > СЛАБЕЕ, чем строка гласит. Статус при этом НЕ меняется — это акт лендинга — и строка **не пере-открывается**, > пока описанный ею дефект из кода ушёл: иначе регистр объявляет вернувшимся баг, который не воспроизводится, > и считает один пробел двумя открытыми. Живой пробел несёт открытая строка-преемник с ДВУСТОРОННЕЙ ссылкой, > её номер стоит в скобках токена; пере-открытие — только когда дефект ВОСПРОИЗВОДИТСЯ. > Грепается: `grep -n 'ОСПОРЕНО(' …`. > > Правила: каждая находка любой сессии/приёмки/аудита — строкой сюда ДО закрытия; ID стабилен навсегда; > закрытие — только с коммитом фикса и тестом, пинящим свойство (урок PD-1: свойство без пинящего теста > считается НЕ закрытым; оговорка к нему — токен `ОСПОРЕНО` выше). Класс: `vuln` — эксплуатируемо или ослабляет защиту · `bug` — неверное поведение · > `hardening` — защита в глубину / латентное · `doc` — док лжёт о коде · `standards` — расхождение с > объявленной нормой зоны (введены приёмкой P2; словарь отставал от строк — испр. оркестратором №15). Статус: `open` · `fixed()` · `accepted-risk(<кем, когда>)`. > **Форма файла (пинг оркестратора №16, 09.08).** Таблица разложена на секции — открытые по весу, принятый риск, закрытые по эрам паков, — а построчная форма `| PD-N | … |` сохранена: `docs/scripts/counts.py` (путь от КОРНЯ репозитория — скрипт зоны `docs/`, зовётся `python3 docs/scripts/counts.py --check` оттуда же) ключуется формой строки, а не позицией, и секции его не ломают. ID остаётся стабильным навсегда, поэтому строка не переезжает между секциями иначе как при смене статуса, и внутри секции строки идут по номеру. ## Открытые — major Несущий путь или контрактно видимое поведение. Каждая строка здесь — то, что решается до следующего пака, а не «когда-нибудь». | ID | Класс | Серьёзность | Где | Суть | Статус | Источник | |---|---|---|---|---|---|---| | PD-410 | bug | **major** | `internal/pgstore/readmodel.go`, греп `draftWork` (⚠ адрес пере-нацелен 06.09: строка уехала на ~130 позиций правками пака формы заказа); ⚠ **контраст ряда «движку передаётся ТОЛЬКО `--ceiling-usd`» БОЛЬШЕ НЕ ВЕРЕН** — `--max-units` едет в argv с 05.09 (`internal/runs/spawn.go`, греп `maxUnitsFor`), и draft-волна по ВСЕМ чанкам книги больше не фанится: её гейтит `volumeScope.allows` (`backend/internal/pipeline/volume.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 не тронута. ⚠ **ПРИЧИНА СНЯТА ПАКОМ «ФОРМА ЗАКАЗА» (05.09), статус флипает ЛЕНДИНГ; и снята она НЕ так, как обещала строка 280 — расхождение названо.** Строка велела УДАЛИТЬ `draftWork`; зона этого не сделала и объясняет почему: дефект `draftWork` в том, что draft-волна фанилась по ВСЕЙ книге, а формула считала купленный диапазон. С проводкой `translate --max-units` волна больше по всей книге не фанится (`backend/internal/pipeline/volume.go`, `volumeScope.allows` гейтит и draft-волну), то есть **посылка формулы стала истинной, а сама формула — нужной**: непочерновленный остаток заказа это реальная работа прогона. Удалить её значило бы сломать полосу по-новому. ⇒ снята ПРИЧИНА, носитель оставлен. Полоса при этом осталась в ГЛАВАХ (канон §Progress говорит «in chapters»), перевод её в юниты — отдельный контрактный вопрос, в этот минор не входит. | 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** | ⚠ **два из трёх адресов УМЕРЛИ вместе с лечением, и это пере-написано, а не пере-нацелено:** `DefaultPerChapter` и `BookRunContext.ChaptersLeft` удалены паком формы заказа. Живой носитель лечения — `internal/pricing/pricing.go`, греп `func (m Model) Hold`; аргумент потолка по-прежнему `internal/runs/spawn.go`, греп `func (m meter) bookCap`; движковая половина — `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 на черновую волну, а санкция владельца на платные прогоны закрылась вместе с паком; арифметика замкнута, живой прогон короткой книги остаётся неснятым. ⚠ **ЛЕЧЕНИЕ В ДЕРЕВЕ ПАКА «ФОРМА ЗАКАЗА» (05.09), статус флипает ЛЕНДИНГ.** Константа `DefaultPerChapter` удалена целиком: цена берётся из проекции движка (`manifest --json`: `expected_usd` по главам, `step_max_usd`), а холд считается `k × ожидаемое(заказ) + step_max` — **аддитивно, не `max(...)`**, потому что последнему вызову заказа нужен запас под ЦЕЛУЮ резервацию сверх уже списанного. Пины: `pricing.TestTheLastTwoChaptersOfABookAreBuyable` (последние две главы и последняя глава покупаются), `pricing.TestTheHoldIsTheCushionedBillPlusOneWholeReservation`. ⚠ И вторая половина той же стены, найденная этим же паком: книжный `expected_usd` ВКЛЮЧАЕТ `book_once_usd` — плоские $2.00 на боевом `pipeline-c1` независимо от длины книги, — поэтому в основу холда он НЕ кладётся (иначе пятиглавая книга за $0.25 снова непокупаема); пин `pricing.TestAFlatBookLevelBoundDoesNotPutATwoDollarThresholdUnderEveryPurchase`. | 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 по коду движка) | | PD-438 | bug | **major** | `internal/ingest/tail.go` `apply` (греп `ErrForeignStreamAhead`), `internal/ingest/tail.go` `tailFrom` (греп `mine := want`), `internal/runs/reconcile.go` `drainJournal` | **Владение потоком не переживало проход свипа, и чужие события ложились на оплаченную попытку.** `mine` — возвращаемое значение, а не колонка: следующий проход выводит его заново из `pos.LastSeq > 0`. Пока тейлер ПРОХОДИЛ мимо чужого handshake'а, двигая байтовый хинт, следующий проход читал чужую область с курсором, который говорит «это наше»: чужой `ceiling` ставил `paused` живому оплаченному прогону, `unit_done` писал главы, которых никто не покупал, а чужой `seq`, попавший на наш, давал `ErrPayloadConflict` и карантинил здоровую проекцию. ⚠ **Воспроизведено в двух вариантах, до и после правки P13** (копия дерева, `Tail` дважды — форма, в которой его зовёт `drainJournal`): на `HEAD 494c4ef` законный чужой hello травит проекцию (`pass 2 applied a FOREIGN event (seq [2])`), а чужой мажор карантинит; после пере-упорядочивания пункта 3 P13 ОБА травят — то есть заказанная правка расширила старый класс с законных чужих строк на любые. Конфликт payload воспроизводится одинаково на обеих версиях. ⚠ Лечение в дереве пака P13 (03.09): чтение ОСТАНАВЛИВАЕТСЯ на чужом handshake'е, если платформа именовала поток попытки и наши строки уже применены (`pos.LastSeq > 0`), и курсор на нём остаётся — владение выводится заново каждый проход, ценой одного пере-чтения строки. Прежняя семантика «пройти мимо» сохранена там, где она безопасна: пока `LastSeq == 0`, наш handshake ещё может лежать ниже по файлу (пин `TestAForeignStreamBeforeOursIsWalkedPastRatherThanStoppedOn`), и у безымянной легаси-попытки (`want == ""`) — пин `TestAnotherAttemptsStreamInTheSameJournalIsSkipped` зелёный. Пины: `TestAForeignStreamDoesNotOwnOurAttemptOnTheNextPass` (три формы чужого hello × три следующих строки). ⚠ **ДОФИКС 03.09: обоснование «вред ограничен по времени» ОПРОВЕРГНУТО приёмкой исполнением, и оно было моё.** Довод «чужой handshake ⇒ нашего процесса в книге уже нет» неверен: платформа отдаёт КАЖДОМУ спавну одной и той же попытки один и тот же id потока (`internal/runs/spawn.go`, греп `engineStreamID(l.RunID, l.AttemptNo)`), а движок, найдя, что этот id уже писал события книги, минтит СВЕЖИЙ и продолжает (`backend/internal/pipeline/events.go`, греп `fresh := obs.NewTraceID()`). Значит «чужой» hello может писать ЖИВОЙ процесс нашей же попытки, и прогон способен простоять припаркованным весь свой срок. Радиус сужен опровергателем приёмки: путь — конъюнкция двух признанных сбоев (претензия на юнит, отчитавшаяся ошибкой, плюс исчезновение юнита без exit-marker), а не рядовой рестарт. **Наблюдаемость дана в том же дофиксе:** тейлер сообщает о парковке отдельным сигналом (`internal/ingest/tail.go`, греп `ErrForeignStreamAhead`) вместо тихого `nil`; свип пишет WARN с прогоном, попыткой и смещением; и парковка теперь СТОИТ РЯДОМ с карантином в условии канала починки (`maybeResync`, греп `!l.Quarantined && !parked`), то есть у припаркованной попытки снова есть свежесть через `tmctl status`. Пины: `TestTheParkIsReportedSoTheCallerCanTellItFromBeingCaughtUp` (без DSN), `TestAParkedAttemptStillGetsTheRepairChannel` (DSN). ⚠ Чего по-прежнему НЕТ: строки в `runs`, гейджа и колонки — парковка живёт длину одного прохода и в БД не пишется; дать ей строку значит завести колонку, то есть миграцию, а она этим нарядом не заказана ⚠ **ОСТАТОК НАЗВАН 04.09 приёмкой:** лечение в дереве и заленджено (`6ae3e76`), но ПРИПАРКОВАННАЯ попытка не видна ни в `runs`, ни гейджем, ни колонкой — у этого класса нет ни одного из трёх сигналов, которые были у карантина, а `maybeResync` до дофикса такую попытку пропускал. Дофикс дал ей имя (`ErrForeignStreamAhead`), WARN с троттлингом и вход в ресинк; поверхностная видимость для ОПЕРАТОРА остаётся открытой и идёт райдером платформенного пака. ⚠ **ДИСПОЗИЦИЯ ПАКА «ЗАКРЫТЬ ЦИКЛ» (04.09): ОТЛОЖЕНО СОЗНАТЕЛЬНО, и вот довод, а не отсутствие времени.** Все три недостающих сигнала — строка в `runs`, гейдж, колонка — читают ХРАНИЛИЩЕ, а парковка в хранилище не пишется: она живёт длину одного прохода и выводится заново каждым свипом. Дать ей любой из трёх значит завести колонку на `run_attempts`, то есть миграцию и новое состояние, которое надо СНИМАТЬ, — а снимать его некому, кроме того же свипа, который его поставил. Это не райдер, а отдельная работа со своим дизайном («парковка как СОСТОЯНИЕ, а не как исход прохода»), и сделать её заодно с дверью выдачи значило бы решить её мимоходом. Что у класса ЕСТЬ сегодня: имя (`ErrForeignStreamAhead`), WARN с троттлингом и вход в канал починки — оператор, который смотрит в ЖУРНАЛ, парковку видит; невидима она тому, кто смотрит только в таблицу. Строка остаётся открытой ровно на этом. | open | самопроверка пака P13 (веер, линза шва; воспроизведено сессией на копии) | ## Открытые — minor | ID | Класс | Серьёзность | Где | Суть | Статус | Источник | |---|---|---|---|---|---|---| | PD-452 | bug | info | `backend/cmd/tmctl/backup.go:29`=`func backupDirFor(dbPath string) string`, `backend/cmd/tmctl/backup.go` `preflightBackup`, контраст: удаляющего кода нет НИ В ОДНОЙ зоне | **ПРЕДРЕЙСОВЫЕ ТОЧКИ ДВИЖКА В КАТАЛОГЕ КНИГИ РАСТУТ БЕЗ ПРЕДЕЛА: их не чистит никто.** Гард движка снимает ПОЛНУЮ копию проектной базы (`VACUUM INTO`) перед КАЖДЫМ платным прогоном и перед каждой `tmctl migrate`, в `<каталог книги>/backups/`. Проверено грепом по обеим зонам: писатели — `backupCmd`, `preflightBackup` и `migrate`, **удаляющего вхождения нет** (`grep -rn backupDirFor backend/ platform/ --include=*.go` и `grep -rn '"backups"' backend/ platform/ --include=*.go` — только записи). ⇒ книга, купленная главами, накапливает по копии базы на главу, и на большой библиотеке это кончается диском — тем самым, потерю которого точки восстановления и должны переживать. ⚠ **Точки платформы это НЕ лечат и не заменяют:** они снимаются по расписанию и отвечают на «диск пропал», а предрейсовые — на «откатить вот этот прогон»; поэтому платформа их не копирует (иначе рост квадратичный) и не удаляет (чужой каталог по замыслу движка — «the durable backup directory for a book»). **Чья половина:** политика удержания предрейсовых точек не принята НИ ОДНОЙ зоной; операторский обход назван в рантбуке (`find … -mtime +30`), но обход в порядок работы записывать нельзя. Ряд заведён, чтобы решение было принято, а не унаследовано | open | пак операций 05.09, самопроверка построенного (первая редакция моего же доккомментария утверждала, что копии платформы предрейсовые «замещают» — неверно) | | PD-453 | bug | info | `internal/backup/backup.go` `isLiveDatabase` (правило по суффиксу), контраст: `backend/internal/config/book.go` (греп `b.ProjectDB =`) — `project_db` свободная строка, а не конвенция | **ТОЧКА ВОССТАНОВЛЕНИЯ УЗНАЁТ ЖИВУЮ БАЗУ ПО СУФФИКСУ `.db`, А НЕ ПО ИМЕНИ, КОТОРОЕ ЕЙ ДАЛ ОПЕРАТОР.** Платформе запрещено выводить путь проектной базы движка (закон шва п.1), поэтому копир формулирует правило «чего НЕ копировать». Два следствия для книги, чей `project_db` назван иначе: **(а)** имя без `.db` (`project.sqlite`) — живой, возможно рваный файл ложится в точку РЯДОМ с движковым снимком `project.db`, и у восстанавливающего оператора два кандидата вместо одного (потери нет, есть неоднозначность; шаг 4 рантбука велит смотреть в `book.yaml`, но именно в этом случае в каталоге лежит файл с «правильным» именем и соблазн `mv` пропустить); **(б)** путь ВНЕ каталога книги — `runner.backupPathIn` откажет по гарду, книга станет ДЫРОЙ (`complete: false`, громко, с 05.09), но копия движка при этом уже написана в свой `backups/` и никем не удаляется. ⚠ **Законный канал закрыть это ЕСТЬ и он не использован:** движок публикует `project_db` в конверте `StatusArtifacts` (`status --json`/`manifest --json`; закон шва перечисляет его прямо), платформа декодирует из конверта только `bank_export` (`internal/ingest/manifest.go`). Не взято в паке операций сознательно: чтение конверта — это лишний `status`/`manifest` НА КНИГУ ЗА ПРОХОД, а он по построению пере-ингестит книгу («status дорог по построению»), то есть цена — удвоение самой дорогой части бэкапа ради случая, которого сегодняшний шаблон деплоя не создаёт. Решать паку, который либо удешевит конверт, либо примет цену | open | пак операций 05.09, самопроверка построенного | | PD-450 | bug | minor | `internal/httpapi/exports.go:212`=`func (h *v0) exportName` (имя файла берётся из титула КНИГИ платформы) против `backend/internal/bookfile/epub.go:66`=`xmlText(strings.TrimSpace(b.Title))` (титул берётся из `book.yaml`); ручка — `internal/httpapi/v0.go` `updateBook` | **ПОСЛЕ ПЕРЕИМЕНОВАНИЯ ИМЯ ФАЙЛА И ИМЯ ВНУТРИ ФАЙЛА РАСХОДЯТСЯ: пользователь скачивает `Мастер Гу.epub`, открывает — и читалка показывает старое имя.** Замерено живым прогоном 05.09 в прод-конфигурации: `PATCH /v0/books/{id}` `{"title":"Мастер Гу"}` → `Content-Disposition: … filename*=UTF-8''%D0%9C…%D0%93%D1%83.epub`, а `dc:title` в скачанном архиве = `P12 verify-bank probe`. ⚠ **Половина платформы сделана и это ВСЁ, что она может сделать законно:** канон §updateBook объявляет титул DISPLAY-полем, которое «reaches nothing else — not the translation, whose configuration is written once at intake and never rewritten», а движок сворачивает `title` в `BriefHash` (`backend/internal/config/book.go`, греп `Title is part of the brief`) → в снапшот → в каждый request-hash, поэтому запись титула в `book.yaml` отказала бы следующему прогону недопереведённой книги и стоила бы ПЕРЕ-ОПЛАТЫ всей книги. **Лечение — в зоне движка** и уже заказано решением владельца 05.09: строка бэклога 284 (титул и заголовки глав переводятся отдельной стадией с подписью владельца). Пока она открыта — ряд открыт | open | пак операций 05.09, замер живым прогоном | | PD-449 | doc | minor | `internal/runner/backup.go:126`=`func backupPathIn` (разбор строки), `backend/cmd/tmctl/backup.go` `backupCmd` (`backup OK: %s (integrity_check green, VACUUM INTO)`), пин — `internal/runner/backup_live_test.go` `TestTheRealEngineNamesItsRestorePointInTheLineThisPlatformParses` | **ЕДИНСТВЕННЫЙ КАНАЛ ШВА БЕЗ ВЕРСИИ: путь точки восстановления читается из ЧЕЛОВЕЧЕСКОЙ строки stdout движка.** У `tmctl backup` нет ни `--out`, ни `--json`, а каталог он выбирает сам (`/../backups/`), поэтому законных вариантов ровно два — разобрать его прозу или ВЫВЕСТИ путь самой, что запрещено законом шва п.1 («никогда не выводит путь сама») и что зона уже однажды откатывала (`internal/runner/artifacts.go`, «The path is taken rather than derived»). Выбран разбор, СТРОГИЙ (незнакомая строка = ошибка, не догадка) и с гардом «путь внутри каталога книги»; закон шва п.3 при этом требует версии у каждого документа глагола, и её тут нет. **Лечение — движковое:** `--out` (тогда путь выбирает платформа и разбор не нужен вовсе) либо версионированный JSON-выход. Цена бездействия сегодня НУЛЕВАЯ: живой пин ловит смену формы на первой же батарее с движковым гейтом | open | пак операций 05.09, самопроверка построенного | | PD-451 | hardening | info | `cmd/tmplatformd/runner.go` (греп `a PostgreSQL tool the backup needs`), `internal/backup/backup.go` `dumpPostgres` | **Бут НАЗЫВАЕТ правило «мажор `pg_dump` = мажор сервера», но не СВЕРЯЕТ их** — у демона есть соединение с базой и он мог бы. Сегодня несовпадение ловит первый же пасс (он идёт на буте) строкой `pg_dump: error: aborting because of server version mismatch`, то есть громко и сразу, плюс гейдж `tm_platform_backup_age_seconds` остаётся `+Inf`; но диагноз оператор ставит по чужому тексту, а не по нашему. Замерено исполнением рецепта в чистом контейнере 05.09: `postgresql-client` дистрибутива дал мажор 15 против сервера 18. Лечение — сверка `server_version_num` с `pg_dump --version` на буте | open | пак операций 05.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:173`=`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:1182`=`PausedCreditExhausted = ingest.PausedCreditExhausted` и `internal/httpapi/v0.go:994`=`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`), словарь аккаунта не трогается. Пока минор не принят, ряд держит вопрос открытым. ⚠ **ЗАКРЫТО ДЕРЕВОМ ПАКА «ФОРМА ЗАКАЗА» (05.09), статус флипает ЛЕНДИНГ.** У паузы прогона появилось СВОЁ слово: `run_limit_reached` (`ingest.PausedRunLimitReached`), и `CeilingPause(ScopeBook)` отдаёт теперь его, а не `credit_exhausted`. Аккаунтное слово осталось за аккаунтом (`ReadUsage`, `balance <= 0`). ⚠ Разделены и ДВА вердикта реконсилятора, которые делили одно слово: `ceilingSpent` → `run_limit_reached`, `creditUnavailable` → `credit_exhausted` (`internal/runs/reconcile.go`, греп `if v == creditUnavailable`) — лечения противоположны (купить снова против пополнить), и пользователь, которому сказали не то, идёт делать не то. Миграция 00033 пере-называет и старые строки. Пины: `pgstore.TestTheCeilingScopeDecidesWhichPauseTheRunGets`, `runs.TestAnInterruptedRunWithNothingLeftIsPausedAndStaysPausedThroughAResume`, `runs.TestAnInterruptedRunThatTheBalanceCannotCarryIsPaused`. | open | живой платный прогон пака «закрыть цикл», наблюдение H14, 04.09 | | PD-435 | bug | minor | `internal/pgstore/readmodel.go` `runDone`/`runTotal`/`runStage` (читают монотонный `editWave`), `internal/pgstore/runs.go` `StartRun` (снятие базлайнов) | **Полоса прогона на деплое, где редактора УБРАЛИ, не доходит до единицы: знаменатель тарифицирует edit-волну, которой не будет.** Вторая половина `PD-403`; первая (счёт книги) закрыта паком P12 эпохой формы конвейера, эта — нет, и попытка закрыть её тем же носителем была ОТКАЧЕНА тем же паком: полоса, переведённая на ПРИСВАИВАЕМУЮ эпоху, перестаёт быть монотонной — замерено, один прогон читал 4/4, затем 2/2 на своих же двух попытках при неизменной `structure_version`, что канон запрещает прямо (строка 200, «одна монотонная дробь на всю работу прогона»). ⚠ **Почему это не однострочник:** правильный носитель — форма, под которой работает ЭТОТ прогон, записанная на самом прогоне; а в `StartRun` она ещё НЕ ИЗВЕСТНА — движок объявляет её первым progress-событием прогона, и попытка снять её раньше это ровно `PD-401`, закрытый пином. Значит запись должна происходить на ПЕРВОМ объявлении прогона, и тогда нужен разбор, что делать со второй попыткой того же прогона, объявившей другую форму (сегодняшний ответ — ничего, потому что флаг монотонен). Лечение: колонка формы на `runs`, заполняемая первым объявлением, плюс решение о смене формы между попытками одного прогона. Пин обратной стороны уже стоит и покраснеет, если кто-то снова переведёт полосу на эпоху: `pgstore.TestTheRunsBarIsMonotoneAcrossAShapeBoundaryItSpans`. ⚠ **Почему `minor`, когда родительская `PD-403` была `major` — вопрос приёмки, отвечаю доводом, а не весом.** У `PD-403` мажорной её делали ДЕНЬГИ: шкала покупки продавала уже переведённые главы повторно. Эта половина ЗАКРЫТА — `ChaptersLeft` считается через эпоху, дважды не продаётся ничего, и `PD-410` (тоже про деньги, ручка «купить N глав» ограничивает не главы, а доллары) остаётся `major` именно поэтому. Здесь не двигается ни один микро-доллар: прогон делает всю купленную работу, закрывается `ready`, леджер сходится, следующая покупка предлагает правильный остаток. Врёт ДРОБЬ и подпись (`editing` на деплое без редактора) — контрактно видимо и потому не `info`, но это отчёт о работе, а не её оплата. Плюс достижимость: нужна смена ФОРМЫ деплоя под книгой, которая уже прошла редактирующий пайплайн, — не обычный путь, в отличие от `PD-410`, который кусает на КАЖДОЙ покупке. Если приёмка сочтёт довод слабым — поднять до `major` дешевле, чем спорить: работа от веса не меняется | open | адверсариальный проход пака P12 (30–31.08), замерено на живом Postgres через продовые пути записи | | PD-433 | hardening | minor | `internal/pgstore/identity.go:106`=`if !errors.Is(err, errIdentityRace)` (ретрай), `:118`=`var errIdentityRace`, ветвь `on conflict … do nothing` в `upsertIdentityOnce` | **Ветвь, обслуживающая ЛЕГИТИМНУЮ гонку двух первых логинов одной новой личности, не исполняется НИ ОДНИМ тестом.** Форма та же, что у `PD-380` и `PD-86`: единственная точка принуждения объявленного свойства, мутация переживает полную батарею, транзитивной страховки нет. Механика: под READ COMMITTED `for update` в `LockIdentity` не блокирует строку, которой ещё нет, поэтому оба гонщика проходят; проигравший видит `created == 0`, получает `errIdentityRace` и ретраит — и именно ретрай спасает его от 500 на пути логина. ⛔ **Снос ветви — только с доказательством недостижимости:** она обслуживает легитимную гонку, и её снос = 500 на легитимном пути (класс `PD-369`, который этот же пак и закрывал). Диспозиция: запинить конкурентным тестом ЛИБО доказать недостижимость. Форма теста, если пинить: в пакете `pgstore`, ~40 раундов, в каждом СВЕЖАЯ личность и 4 горутины на `UpsertIdentity`; утверждать, что все четыре вернули `nil`, что userID у всех ОДИН, что `identities` держит одну строку, что грант посева записан ОДИН раз и что число строк в `users` равно числу раундов — последнее ловит настоящую утечку, осиротевшего пользователя от проигравшего, который успел `CreateUser` до проигрыша. Проверять посадкой (снять арм `errIdentityRace` — тест обязан покраснеть), потому что вероятностный тест без посадки не доказывает, что он вообще кусает | open | пак P12 (30.08), греп непокрытых ветвей по своим путям | | PD-434 | bug | minor | `internal/pgstore/sink.go` `ApplyStatus`, `internal/runs/reconcile.go` `maybeResync` | **Канал ПОЧИНКИ прогресса не чинит прогресс: ресинк не материализует ничего, что читает экран.** `maybeResync` существует ровно для прогона, чей поток в карантине или чей курсор не двигался — «это теперь единственный источник», говорит его собственный комментарий. Но `ApplyStatus` писал пер-волновые цифры отчёта ТОЛЬКО в `runs.draft_done/draft_total/edit_done/edit_total`, у которых не было ни одного читателя (`PD-411`), и не трогает ни `unit_resolutions`, ни `chapters` — а вся полоса выведена из `chapters`. Значит полоса такого прогона стоит всю его жизнь, как бы исправно ни отвечал `tmctl status`. ⚠ **Строка заведена паком P12 при сносе `PD-411`, и это главное в ней:** мёртвые колонки ПРЯТАЛИ гап — код выглядел так, будто канал починки чинит, и комментарий `maybeResync` прямо утверждал «ApplyStatus now materializes the same four counters as the stream». Обе лгущие фразы исправлены, сам гап НЕ лечится сносом и вынесен сюда, чтобы снос не выдал себя за лечение. Лечение: либо ресинк складывает отчёт в `chapters` (и тогда нужен разбор, как он не спорит с потоком, который те же строки пишет из `unit_resolutions`), либо зона признаёт, что карантинный прогон полосы не показывает, и говорит об этом на поверхности. Пин формы «ресинк НЕ двигает счётчики глав» уже стоит — `pgstore.TestTheResyncRecordsFreshnessAndShapeAndNoProgress` — и он покраснеет, если канал научится материализовать: это указатель на строку, а не её лечение | open | пак P12 (30.08), вскрыто сносом `PD-411` | | PD-375 | bug | minor | `internal/runs/runs.go`, греп `quote.Hold > acct.Balance`, `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` ⚠ **НОСИТЕЛЬ УМЕР ВМЕСТЕ С ФОРМОЙ ЗАКАЗА (05.09), строка пере-написана ПО СУЩЕСТВУ, а не пере-нацелена.** Проверки `in.CeilingChapters < bounds.Min || > bounds.Max` больше НЕТ: шкала в главах и `pricing.Bounds` удалены вместе с per-chapter константой (строка бэклога 280), заказ судится ценой — `Quote` клампит объём остатком книги, а `quote.Hold > acct.Balance` отбивает то, что баланс не несёт. **Класс дефекта — «несущую половину не исполняет ни один тест» — закрыт НОВЫМ пином, а не исчезновением кода:** `TestAnOrderTheBalanceCannotCarryIsRefusedBeforeTheHold` держит обе половины (неподъёмный заказ отбит ДО холда и деньги не двинулись; заказ больше книги = вся книга, а не бОльшая резервация). Проверено посадкой: снятие отказа краснит этот тест ИМЕНЕМ. | 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:474`=`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:592`=`сколько всего может занять один проход свипа`, `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:562`=`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:559`=`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:91`=`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 (живая проба, подтверждено рефутером) | | PD-101 | bug | minor | `internal/login/login.go:507` | `login_events.ip_prefix` берётся из `r.RemoteAddr`, а в задуманном деплое перед сервисом стоит edge-прокси ⇒ префикс всегда сеть прокси. Журнал входов заведён как ответ на «откуда примерно я входил» — в шипуемой форме он систематически отвечает неверно. `X-Forwarded-For`/`Forwarded` нигде не читаются и доверенного прокси в конфиге нет (это правильный дефолт: доверять заголовку без edge нельзя) — значит решение про edge и про этот столбец принимается вместе ⚠ **ПАК P8-REVIEW 24.08: якорь дрейфанул и носителей ДВА.** `internal/login/login.go:507` сегодня это `h.mu.Lock()` внутри `checkIssuer`; чтение адреса живёт на `:527`=`ev.IPPrefix = ipPrefix(r.RemoteAddr)`, и второй, строкой не названный, — `internal/login/dev.go:195` с тем же выражением. Суть верна | open | приёмка P2 (панель) | | 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: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:683`=`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, вне карты пака) | | 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-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-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`, либо оговорка в рантбуке ⚠ **пак P12 (30–31.08): сделана дешёвая половина (оговорка в рантбуке), долговечная подписана диспозицией.** В `platform/deploy/README.md` дописано, что ветвление `run abandon` идёт по `runs.finished_at`, а не по наличию осиротевшей попытки, и что живой прогон с нерассчитанной ПРЕДЫДУЩЕЙ попыткой ушёл бы в живую ветку — то есть документ больше не обещает оператору того, чего код не делает. ⚠ **Пак P12 состояние достижимее НЕ сделал, и это проверено:** лечение `PD-424` заставляет блокированную расплату считаться и быть видимой, но рестарт по-прежнему НЕ происходит, значит второй открытой резервации не возникает; пин `PD-424` утверждает это прямо (строка отчитывается как `live`, а не `settling`). Долговечное лечение — ветвить по НАЛИЧИЮ осиротевшей попытки вместо `finished_at`, как уже делает settling-ветвь `StalledRuns`. ⚠ **СДЕЛАНО паком «закрыть цикл» (04.09), и это ровно названная долговечная половина.** Из запроса осиротевших попыток (`abandonSettlement`) убрано `r.finished_at is not null`, а живая ветвь `AbandonRun` спрашивает про орфана ПЕРВЫМ делом — значит живой прогон с нерассчитанной ПРЕДЫДУЩЕЙ попыткой попадает в settling-обработку по построению, и рантбук больше не обещает того, чего код не делает. ⚠ Живая попытка при этом НЕ трогается и прогон НЕ заканчивается: застряли деньги одной попытки, а не перевод; `settled_at` живому прогону не ставится, иначе список расчётов перестал бы видеть попытку, чей холд ещё открыт. Пин: `internal/runs/abandon_orphan_test.go` `TestALiveRunsOrphanedAttemptIsSettledWithoutEndingTheRun` — состояние он пишет ПРЯМО, потому что код его сам по-прежнему не производит, и это ровно та оговорка, ради которой строка заведена. Строка остаётся открытой на дешёвой половине: рантбук `deploy/README.md` описывает ветвление по `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`. ⚠ **ВТОРАЯ ТОЧКА, 29.08, внешнее ревью:** упал 1 раз из 4 ПОЛНЫХ прогонов зоны со всеми гейтами — то есть краснеет и без параллельных копий, просто редко. Изолированно 5/5, пакет целиком 2/2, три последующих полных прогона чистые; сообщение поймать не удалось, и ревьюер честно остановился на «редком флейке контенции», диагноз НЕ установлен. Пакет ни одним коммитом эры P11 не тронут. ⚠ Обе точки вместе сдвигают формулировку: это не «краснеет от параллельной нагрузки», а «редкая гонка, которую нагрузка делает вероятнее», — и мой первый диагноз («флейк параллельных батарей») был выведен из совпадения, ровно как в `PD-423`. Меряет ресурс, общий для копий на машине. ⚠ Цена не косметическая: **красная ЧИСТАЯ копия маскирует дельту**, а красный прогон посадки читается как «мутация поймана», когда она не поймана, — ровно та ложная улика, ради которой мутации и сажают. Обход пака — судить по ДЕЛЬТЕ множеств, а не по коду выхода; лечение — изоляция ресурса либо честный скип под нагрузкой ⚠ **пак P12 (30–31.08): пере-замерена ПОСЛЕ фикса `PD-369`, серийно — 80 из 80 зелёных, 0 FAIL, 0 SKIP** (`-count=80`, счёт по `^--- PASS`). Это снимает ПЕРВУЮ из двух точек строки по построению: флейк был не в тесте, а в том, что исчерпание раундов отвечало 500, и тест это честно ловил. ⚠ **ВТОРАЯ точка (редкая гонка контенции, 1 из 4 полных прогонов, диагноз НЕ установлен) пере-замерена под ТРЕМЯ параллельными полными батареями в чистых копиях: 18/18 пакетов в каждой, 0 красных, EXIT=0 ×3.** Это ОДНА точка, а не опровержение: строка сама говорит, что краснеет редко (1 из 4), и три чистых прогона такой частоты не исключают. Строка остаётся открытой на ней с дополненным замером; закрыть её может только серия, а не прогон. | open | пак P11, мутационная кампания (15 прогонов) + пере-проверка серийными прогонами | | PD-423 | standards | minor | `internal/runner/systemd_test.go` `TestARunIsBoundedByItsOwnCgroup`, `docs/STACK_DECISIONS.md` «Гейты батареи» | **Батарея зоны требует ЧЕТВЁРТОГО условия хоста, которого рецепт не называет: пользовательский менеджер systemd должен РЕАЛЬНО применять `MemoryMax` к транзиентным юнитам.** Замерено на этом хосте 29.08: тест трижды подряд зелен в полных батареях (`baseline2`, `final2`, `final4`), затем **пять раз подряд красен в изоляции** — при неизменном коде пакета, которого пак не касался вовсе. Причина установлена ВНЕ батареи и вне Go: `systemd-run --user --scope -p MemoryMax=64M …` даёт процессу спокойно занять 400 МиБ и выйти с кодом 0. ⚠ **МЕХАНИЗМ уточнён приёмкой оркестратора №19, и уточнение решает, воспроизводимо ли это:** «systemd не применяет потолок» верно по симптому и мимо по причине. Свойство ДОЕХАЛО — `systemctl --user show -p MemoryMax` печатает `67108864`; делегирование в порядке — `memory pids` и в `cgroup.controllers`, и в `subtree_control`. Пропал не потолок, а cgroup-КАТАЛОГ: в `app.slice` нет ни одного scope-каталога, а `cut -d: -f3 /proc/self/cgroup` для оболочки даёт **`/init.scope`**. То есть вызывающий процесс живёт ВНЕ `user@.service`; `systemd-run --user` заводит юнит в модели менеджера, а процесс остаётся в исходном cgroup, и лимита не получает никто — молча. ⚠ Отсюда и наблюдение «условие отваливается между двумя прогонами одной сессии»: оно зависит от того, из какого cgroup стартовал прогон. Тест при этом ПРАВ и его сообщение точное («check that the leaf cgroup of tm-runs.slice has memory.max»): он ловит ровно то, ради чего написан, — что потолок памяти прогона на этом хосте иллюзорен. ⚠ Следствие для процесса, а не только для теста: рецепт `STACK_DECISIONS` называет три условия батареи, а их четыре, и четвёртое — свойство ХОСТА, которое может отвалиться между двумя прогонами в одной сессии, что здесь и произошло. Сессия, наступившая на это, потратит время на поиск дефекта в своём диффе. ⚠ Предложенная этой строкой формулировка четвёртого условия («вызывающий процесс обязан жить ВНУТРИ `user@.service`», проверка `cut -d: -f3 /proc/self/cgroup` ≠ `/init.scope`) ОПРОВЕРГНУТА точками 2 и 3 ниже и СНЯТА из рецепта (`PD-432`). Живой остаток лечения — дать тесту различать «хост не применяет лимит» (честный скип с причиной) и «раннер не передал лимит» (настоящий отказ) ⚠⚠ **ВТОРАЯ ТОЧКА, 29.08, и она ПРОТИВОРЕЧИТ механизму выше — строку не закрывать, а пере-проверить.** Пак `sqlc` на ТОМ ЖЕ хосте и при том же `cut -d: -f3 /proc/self/cgroup` = **`/init.scope`** получил тест **зелёным 5 из 5 в ИЗОЛЯЦИИ** (протокол, в котором приёмка №19 видела 5 из 5 красных) плюс трижды в полных батареях; скипов в этих прогонах **ноль** — проверено по логам, то есть `systemdOrSkip` и проверка `python3` не срабатывали и тест НЕ был пустым: он требует настоящего `oom-kill` после касания 400 МиБ под `MemoryMax=64M`. Прямая проба механизма: `systemd-run --user --scope -p MemoryMax=64M -- sh -c 'cut -d: -f3 /proc/self/cgroup'` печатает **`/user.slice/user-1000.slice/user@1000.service/app.slice/run-….scope`**, то есть процесс ВСЁ-ТАКИ попадает внутрь `user@.service`, а не остаётся в исходном cgroup. Сам `tm-runs.slice` при этом существует и лежит глубже, чем ищут: `user@1000.service/**tm.slice**/tm-runs.slice` (`cgroup.controllers` = `memory pids`). **Следствие практическое:** предложенная этой же строкой одна команда-проверка (`/proc/self/cgroup` не должен давать `/init.scope`) на этом хосте даёт ЛОЖНЫЙ ОТРИЦАТЕЛЬНЫЙ — она говорит «условие не выполнено» там, где лимит применяется и тест честно зелёный. Значит cgroup ВЫЗЫВАЮЩЕГО процесса условие не предсказывает, диагноз строки неполон, и в рецепт `STACK_DECISIONS` эту команду в нынешнем виде вносить нельзя. Что различает две точки — не установлено; кандидат — состояние `cgroup.subtree_control` целевого среза в момент прогона (сейчас у `tm-runs.slice` он пуст, а systemd включает контроллер сам при старте юнита с лимитом). ⚠ **ТРЕТЬЯ ТОЧКА, пак P12 (30–31.08), и она согласна со второй:** у оболочки этой сессии `cut -d: -f3 /proc/self/cgroup` даёт **`/`** (не `/init.scope` и не путь внутри `user@.service`), а `TestARunIsBoundedByItsOwnCgroup` при этом ЗЕЛЁН в полной батарее со скипами 0 — проверено четырьмя полными прогонами. Прямая проба `systemd-run --user --scope` кладёт процесс в `…/user@1000.service/app.slice/run-….scope`; `tm-runs.slice` существует, `cgroup.controllers` = `memory pids`, а его `cgroup.subtree_control` ПУСТ — и тест всё равно зелен. То есть команда-проверка не предсказывает условие ни в одну сторону, и она **СНЯТА из рецепта** `STACK_DECISIONS` этим паком (см. `PD-432`). Что различает точки — по-прежнему НЕ УСТАНОВЛЕНО; строка остаётся открытой на диагнозе, а не на рецепте. | open | пак P11 (финальная батарея; воспроизведено голым `systemd-run` вне Go) | | PD-250 | vuln | minor | `cmd/tmplatformd/main.go` (слушатель метрик) | **`/metrics` отдаётся БЕЗ аутентификации; вся защита — привязка к `127.0.0.1`.** Для одной VM это честная граница, и она записана (STACK §24). Но на хосте с несколькими пользователями любой локальный процесс читает оперативную картину сервиса, а на деплое, где слушатель однажды переедет на `0.0.0.0` «чтобы Prometheus дотянулся», защиты не останется вовсе. Денег в метриках нет (D39.84), поэтому это minor, а не major. Лечение — bearer-токен на слушателе или mTLS, решать при первом внешнем Prometheus ⚠ **ПАК P8-REVIEW 24.08: это ТОТ ЖЕ факт, что `PD-179`, и он стоит в регистре в ДВУХ статусах одновременно** (`accepted-risk(платформа P5, 11.08)` против `open`). Код и ручка одни: `cmd/tmplatformd/main.go` `serveMetrics` без аутентификации, дефолт `127.0.0.1:9464`; рантбук `deploy/README.md` называет строкой риска именно `PD-179`. Своя добавка у этой строки есть (многопользовательский хост), но статус один факт должен нести один. Предложение пака: свести ⚠⚠ **Условие сведения, найденное рефутером:** у `PD-179` довод про ОДНУ VM, а добавка этой строки — многопользовательский хост, где любой локальный непривилегированный процесс скрейпит экспозицию, — в `PD-179` ОТСУТСТВУЕТ. Плюс при сведении из выборок безопасности исчезает класс `vuln` (у `PD-179` он `hardening`). Сводить только ВМЕСТЕ с перенесённой фразой и с пометкой класса | open | research/28 §9 (пинг оркестратора №17), сверено P7 | ## Открытые — info | ID | Класс | Серьёзность | Где | Суть | Статус | Источник | |---|---|---|---|---|---|---| | PD-445 | doc | info | `internal/httpapi/bank.go:386`=`Undecided int` (проекция квитанции); честный счёт — `internal/pgstore/readmodel.go:324` | **`signature.undecided` не сдвинулся после ПРИНЯТОЙ правки (82 → 82), и оператору нечем отличить «правка не зашла» от «зашла, но счёт про другое».** Замерено 04.09: `preview` и `apply` вернули `signature.surfaces 82, undecided 82` при `accepted[0].state = applied`. ⚠ **Арифметика ВЕРНА:** счёт неопределённости — движковый и считает МАЙНЕННЫЕ поверхности, а термин правки был не из них, поэтому двинуться числу было не от чего. Дефект — в обратной связи: единственное число, которое оператор видит после правки, на успешную правку не реагирует вовсе, и чтобы убедиться, что правка ЗАШЛА, потребовалась отдельная сверка по другому пути. ⚠ Вес info, потому что квитанция уже несёт точный ответ (`accepted[].state`) — это не ложь механизма, а то, что глаз читает первым. | open | живой платный прогон пака «закрыть цикл», наблюдение H13, 04.09 | | PD-396 | standards | info | `internal/pgstore/books.go:326`=`chunker_version = $4, parse_started_at = null,`, `internal/pgstore/sink.go:127`=`update books set chunker_version = $2 where id = $1`, `internal/ingest/resync.go:36`=`UnsignedBankTerms int `json:"unsigned_bank_terms"`` | **Мёртвые поля шва: у `books.chunker_version` ДВА писателя и НОЛЬ читателей, и это второй экземпляр класса, первый назвал оркестратор.** Колонку пишет интейк из манифеста движка (`FinishParse`) и пишет тейлер из хендшейка потока (`effect`); ни одного `select` по ней в зоне нет — грепом ноль. Сегодня это безвредно, но следствие названо ЗАРАНЕЕ, потому что оно семантическое, а не техническое: в день, когда читатель появится, РАСХОЖДЕНИЕ двух писателей (чанкер прогона против чанкера разбора) станет значением, и решать, какой из них правда, придётся задним числом — по колонке, у которой уже накоплена история из обоих источников. Родня — п.4 пинга оркестратора №19: `ingest.StatusReport.UnsignedBankTerms` разбирается из ответа движка и не используется НИ ОДНОЙ строкой продакшн-кода (грепом — только объявление и его доккомментарий, где поле описано как опора экрана подписи). Формулировка оркестратора применима дословно к обоим: мёртвое поле в структуре шва читается как контракт. ⚠ Заведено ОТДЕЛЬНОЙ строкой, а не дописком в `PD-166`: та про потерю значения между автокоммитами, и её свойство построено. Диспозиция — вопрос владельца, а не зоны: либо назначить владельца колонки (один писатель), либо записать расхождение как ожидаемое до появления читателя Воспроизведение — две команды без конвейера (символ вертикальной черты в ячейку регистра не влезает): `grep -rn chunker_version platform/internal --include=*.go` даёт два `update` и ни одного `select`, `grep -rn UnsignedBankTerms platform/internal platform/cmd --include=*.go` даёт только объявление и его доккомментарий | open | ревью-пак P8-REVIEW (побочная находка сверки реестра, оформлена по указанию закрывающего ревью) | | PD-378 | bug | info | `internal/pgstore/books.go:1161`=`u.RemainingPercent = int(balance * 100 / granted)`, `internal/httpapi/v0.go` `usageState`, канон `14-api-contract` `remaining_percent` | **`/v0/usage` отдаёт `remaining_percent` вне контрактных 0..100 и зажигает предупреждение «low» на полном счёте: `balance * 100` переполняет int64.** Порог измерен точно: баланс 92 233 720 368 547 758 микро ещё даёт 99%, следующий микро-доллар даёт минус 99. Ответ нарушает схему (`minimum: 0`, `maximum: 100`), и хуже того `usageState` видит отрицательное значение ниже порога `lowCredit` и отдаёт `state: "low"` — «денег почти нет» счёту на сто миллиардов. Замерено на проводе: до гранта `{"state":"ok","remaining_percent":96}`, после `grant --usd 100000000000` → `{"state":"low","remaining_percent":-84}`; соседняя арифметика (`pricing.Scale`, `balance`) при том же балансе отвечает верно, то есть переполнение локально именно в этой строке. ⚠ Рефутер сузил minor → info: чтобы туда попасть, оператор должен добавить на счёт не меньше 92.23 млрд долларов, ни одна пользовательская ручка кредит не пишет; прецедент веса — `PD-39`. ⚠ Оговорка рефутера в другую сторону: более правдоподобный носитель — не разовая команда, а конфиг `TM_PLATFORM_SIGNUP_GRANT_USD`, у которого верхней границы нет и значение НАМЕРЕННО не печатается в стартовый лог, так что промах в нём сломал бы `/usage` каждому новому аккаунту невидимо. Воспроизведение: `docs/p8-review/axis1-money/a1-usage-overflow.sh` (сам откатывает грант) | open | ревью-пак P8-REVIEW, ось 1 (живой провод, сужено рефутером с измеренным порогом) | | PD-381 | hardening | info | `internal/auth/middleware.go:38`=`a.Deny.ServeHTTP(w, r)` и та же строка на `:52`, `internal/auth/cookie.go` `ClearSession` | **401 по мёртвой сессии не стирает куку: браузер продолжает слать отозванный токен до конца её `Max-Age` (по умолчанию 14 суток).** Обе ветки отказа зовут `a.Deny.ServeHTTP` и к `a.Cookies` не обращаются, перекрытия выше по стеку нет — живой 401 не несёт ни одной строки `Set-Cookie`. Норму формулирует сам код: комментарий `ClearLogin` говорит, что кука, пережившая свой круг, это «a replay waiting for an accident», а `PD-88` заведена ровно на тот исход, при котором кука переживает сессию. Дешёвое лечение — чистить куку на пути отказа, где она была предъявлена. Отдельно от `PD-88` (та про `Max-Age` меньше секунды) и от `PD-5`/`PD-70`/`PD-74`/`PD-103` Воспроизведение: `docs/p8-review/axis2-auth/csrf-matrix.sh` и `session-clocks-probe.sh` (обе пробы поднимают демон и печатают ПОЛНЫЕ заголовки ответа, включая отсутствие `Set-Cookie` на 401); проверка чтением — `grep -n 'Cookies' internal/auth/middleware.go`, ни одного вхождения на путях отказа | open | ревью-пак P8-REVIEW, ось 2 (чтение + живая проба, подтверждено рефутером) | | PD-382 | hardening | info | `internal/auth/cookie.go:52`=`func (c Cookies) ClearSession(w http.ResponseWriter) { c.set(w, c.SessionName(), "", -time.Second) }`, `internal/auth/cookie.go` `ClearLogin` | **Путь ИСТЕЧЕНИЯ куки не запинен: две мутации, стирающие выход из браузера, прошли батарею целиком.** `ClearSession` и `ClearLogin` — единственные места, где кука получает отрицательный `Max-Age`, и порча этого выражения ничего не роняет. ⚠ Рефутер поправил ЦЕНУ, названную первой редакцией находки: `set` пишет значение вызывающего, а обе `Clear`-ручки передают ПУСТУЮ строку, поэтому мутант не перевыпускает куку с живым токеном — он оставляет пустую куку, и следующий запрос всё равно приходит без сессии. То есть вреда сегодня нет, а не запинено СВОЙСТВО «выход удаляет куку из браузера», и это класс `PD-1`, родня `PD-86`/`PD-87`. Воспроизведение: `docs/p8-review/axis2-auth/mutations-axis2.sh` ⚠ **Пере-проверено на ПОЛНОЙ батарее координатором пака (мутации M13 и M14), и по дороге поймана СВОЯ ошибка метода:** первый прогон M13 дал красный, но упавшим оказался `TestAClaimThatLostARaceToAReleaseIsRetriedAndNotAnError` — известный флейк `PD-369`, к `ClearSession` отношения не имеющий. То есть вердикт, вынесенный по ЦВЕТУ батареи, а не по ТОПИЧНОСТИ упавшего теста, даёт ложное «пойман» и тихо теряет находку. Пере-прогон обеих мутаций даёт пустую дельту против чистой копии. Правило записано здесь, потому что цена его забывания — потерянная находка о недостающем пине: `docs/p8-review/mutations-round2.log` | open | ревью-пак P8-REVIEW, ось 2 (посадка мутации, цена поправлена рефутером) | | PD-383 | hardening | info | `cmd/tmplatformd/main.go:257`=`const loginJournalRetention = 180 * 24 * time.Hour`, `cmd/tmplatformd/main.go` `sweepLogins`, `internal/pgstore/identity.go` `DeleteOldLoginEvents` | **Ретенция журнала входов работает и не запинена ничем: и срок хранения, и сам свип переживают батарею.** Механизм построен (константа 180 суток, тикер 15 минут, `delete from login_events where at < $1`, монтируется в обеих ветках входа) и проверен ЖИВЬЁМ: строка возрастом 200 суток исчезла на ближайшем тике, демон напечатал `"login sweep" events=1`. Но ни срок, ни вызов не пинятся: `DeleteOldLoginEvents` не зовёт ни один тест, а `sweepLogins` — неэкспортируемая функция пакета `main` без теста. Класс `PD-1` на механизме, который ЛЕЧИТ уже закрытую строку. Воспроизведение: `docs/p8-review/pd23-retention-probe.sh` и `pd23-result.txt` ⚠ **Пере-проверено на ПОЛНОЙ батарее координатором пака (мутации M15 и M16):** срок хранения поднят со 180 суток до 180 ЛЕТ и, отдельно, предикат свипа обезврежен (`delete from login_events where at < $1 and 1=0`) — обе дельты против чистой копии ПУСТЫ на всех 18 пакетах. Лог — `docs/p8-review/mutations-round2.log` ⚠ **ПОЛОВИНА ЗАКРЫТА паком `sqlc` (`63fcee5`), и обе половины пере-проверены посадками — строка остаётся `open` ровно на остатке.** ЗАКРЫТ сам свип-предикат: `DeleteOldLoginEvents` теперь зовёт `pgstore.TestTheLoginJournalRetentionDeletesOnlyWhatIsOlderThanTheCutoff`, и он утверждает ЧИСЛО удалённых строк плюс границу (в фикстуре есть строка РОВНО на отсечке, потому что предикат строгий и без неё `<` неотличимо от `<=`). Мутация M16 (`and 1=0`) теперь красная адресно — «deleted 0 rows, want exactly 1». **НЕ закрыто и остаётся живым: сам СРОК хранения и вызов свипа.** Мутация M15 — `loginJournalRetention` 180 суток → 180 ЛЕТ (`cmd/tmplatformd/main.go:257`) — пере-прогнана 29.08 на полной батарее конвертированного дерева: **EXIT=0, ноль красных**. Причина остатка структурная и не лечится в `pgstore`: константа и `sweepLogins` живут в пакете `main`, куда тест `pgstore` не достаёт. То есть класс `PD-1` здесь снят с ЗАПРОСА и стоит на КОНФИГУРАЦИИ | open | ревью-пак P8-REVIEW, ось 2 (находка рефутера, живая проба координатора) | | PD-388 | hardening | info | `internal/readmodel/readmodel.go:198`=`if deadline, ok := ctx.Deadline(); ok && time.Until(deadline) < MaterializeBudget {`, `cmd/tmplatformd/runner.go` `refreshSweepBudget`, `internal/books/parse.go` (запиненный близнец) | **Пара чисел в разных пакетах, не выводимая и не запиненная, и на неверной её стороне материализатор молча не делает ничего.** `Drain` начинает книгу, только если у прохода осталось не меньше ЦЕЛОГО `MaterializeBudget` (5 минут), а число прохода живёт в `cmd/tmplatformd` голым литералом 10 минут, без ссылки на константу, которую обязано превышать. Это единственный член семьи без страховки: `intakeSweepBudget` ВЫВЕДЕН формулой и разъехаться не может, а `claimGrace` и выведен, и запинен отдельным тестом. Цена неверной стороны — не деградация, а полное молчаливое отключение: замерено пробой, проход 4м59с даёт claims=0, вызовов движка 0 и nil, ошибки нет, лога нет, `sweep_unfinished_total` не растёт. Мутация «`refreshSweepBudget` 10м → 1м» пережила ПОЛНУЮ батарею. ⚠ Вторая половина, найденная рефутером: сам гейт бюджета между книгами — живой носитель ЗАКРЫТОЙ `PD-293`, чья эррата прямо на него ссылается, — не покрыт ни одним тестом (во всём дереве нет теста, который даёт `Drain` дедлайн), поэтому его можно выключить целиком, и батарея останется зелёной; точный близнец у интейка при этом запинен своим `TestAPassTooShortForAParseStartsNoneAtAll`. ⚠ Рефутер опроверг приписку финдера «проход по построению начинает максимум 2 книги из 4»: гейт сравнивает остаток перед КАЖДОЙ книгой, и при проходе 10 минут стартуют все четыре. Воспроизведение: `docs/p8-review/axis3-queue/probe_drain_budget_pair_test.go.txt` | open | ревью-пак P8-REVIEW, ось 3 (посадка мутации, расширено рефутером на носитель PD-293) | | PD-393 | hardening | info | `internal/metrics/metrics.go:216`=`if unfinished {`, `internal/metrics/metrics.go` (два счётчика из двенадцати коллекторов), `deploy/README.md` (рецепт алерта) | **Счётчиков событий на весь демон два, и оба отвечают не на тот вопрос: «ошибки» из четырёх золотых сигналов закрыты только для HTTP.** `sweep_unfinished_total` поднимается ИСКЛЮЧИТЕЛЬНО на `context.DeadlineExceeded`, поэтому проход, упавший обычной ошибкой, регистрируется как быстрый здоровый проход — замерено: 4 строки ERROR «runs sweep failed» в журнале и НИ ОДНОГО изменения в экспозиции, кроме счётчика длительности. Ни у чего остального счётчика нет вовсе: отказ спавна, неудача расчёта, карантин, ненулевой выход движка, упавший прогон. Всё состояние снимается гейджами раз в такт, поэтому событие, уместившееся между двумя проходами, в экспозиции не существует, и на вопрос «сколько прогонов сегодня упало» ответить нечем. Отдельная мелочь того же корня: `sweep_unfinished_total` — `CounterVec`, и пока он ни разу не вырос, семейства в экспозиции НЕТ вовсе, поэтому готовый рецепт алерта рантбука («растёт `tm_platform_sweep_unfinished_total`») даёт «no data», а не ноль; практика Prometheus велит инициализировать известные наборы лейблов нулём. ⚠ Речь о СЧЁТЧИКАХ СОБЫТИЙ, не о суммах денег: запрет `D39.84` на денежные числа в метриках соблюдён, проверено. Воспроизведение: `docs/p8-review/axis4-metrics/40-exposition-check.sh` | open | ревью-пак P8-REVIEW, ось 4 (живой замер, заголовок сужен рефутером) | | PD-395 | doc | info | `internal/gates/contract_test.go:14`=`const canonPath = zoneRoot + "/../docs/architecture/14-api-contract/openapi.yaml"`, `docs/ENGINEERING_STANDARDS.md` §3, промты ревью-паков зоны | **Копия `platform/`, вынутая из репозитория, даёт красную батарею по причине, не имеющей отношения к коду.** Гейт контрактной версии читает канон по пути ВЫШЕ модуля (`internal/gates/contract_test.go:14`=`const canonPath = zoneRoot + "/../docs/architecture/14-api-contract/openapi.yaml"`) и при его отсутствии `t.Fatalf`, а не сообщает, что запущен вне репозитория. Стоило времени КАЖДОМУ, кто работал в копии — все девять субагентов пака и координатор, — и один агент едва не завёл ложный красный находкой. ⚠ **ДИСПОЗИЦИЯ 24.08: лечение — РЕЦЕПТ, а не гейт. Три довода, каждый проверен исполнением:** **(1)** носители названы шире, чем есть — `ENGINEERING_STANDARDS` §3 копий на момент находки НЕ требовал (`grep -ci 'копи'` давал 0; ПОСЛЕ правки этого пака даёт 6 — рецепт туда и записан, см. ниже), норма копий живёт только в промте ревью-пака и в `D39.113`; значит сталкиваются не гейт и стандарт зоны, а гейт и РЕЦЕПТ ОДНОГО ПРОМТА. **(2)** «точечное решение, а не стиль» — НЕВЕРНО: в `backend/internal/standdata/standdata.go` живёт целый репо-паттерн чтения выше корня модуля с поиском корня по МАРКЕРУ и env-переопределением, и его потребители читают даже чужую зону (`internal/miner/miner_parity_test.go` читает `eval/`). Платформа реализовала тот же паттерн грубее — голым счётом `..`, — и комментарий `standdata` объясняет, чем именно это хуже. **(3)** довод «модуль обязан быть самодостаточным» к этой зоне не применяется: её собственный DoD определяет полную приёмку через ТРИ ВНЕШНИХ условия. Ценность зоны — не герметичность, а громкий учёт непроверенного. **ЛЕЧЕНИЕ — РЕЦЕПТ, А НЕ ГЕЙТ** (сам рецепт и его довод — `ENGINEERING_STANDARDS` §3 п.3). Второй, необязательный шаг: резолвить канон маркер-обходом по образцу `standdata.Root()` и дописать в сообщение `Fatalf` вторую гипотезу «ты вне репозитория» — это убивает стоимость повторной диагностики и остаётся падением, а не скипом. **ОТВЕРГНУТО с доводами:** гейт-переменная батареи с названным скипом (вне `make` гейт выключался бы сам — зона уже осудила эту форму словами `internal/gates/toolchain_test.go:55-57` «the Makefile is a convenience… a build that skips the battery gets a toolchain the battery would have refused»; плюс это легализует поломку рецепта как «условие среды») · переезд проверки на репо-уровень в `counts.py` как ЗАМЕНА (единственная репо-точка принуждения НИКОГДА не блокирует, только предупреждает — гейт остался бы без зубов; как ВТОРАЯ сеть законен) · кодоген из спеки (посылка протухла: `oapi-codegen` пере-подписан `D39.132` в КАНДИДАТА и P7 решил НЕ БРАТЬ с доводом; и класс он не убивает, а переносит — генерат коммитится внутрь модуля, то есть второй источник истины) · вендорить снапшот канона в модуль (то же самое) ⚠ **Рецепт вместе с доводом про маскировку дельты и правилом топичности вердикта записан пунктом 3 в `ENGINEERING_STANDARDS` §3** — долговечный зонный носитель, а не промт пака (промты после лендинга архивируются) | open | ревью-пак P8-REVIEW (координатор пака, цена замерена этой же сессией) | | PD-281 | bug | info | `internal/pgstore/readmodel.go` `runProgress` | **Полоса прогона над книгой, уже полной в считаемом проходе, стоит на `0/N` и не достигает единицы** — против канона §Progress («the fraction always reaches one»). Штатный цикл: книга доведена до конца → пользователь подписал решения → запустил прогон, чтобы движок их применил → прогресс платной работы невидим до `ready`. Остаток подхода «числитель считает ГЛАВЫ, законченные проходом», а не регрессия: до правки PD-263 бар был неверен в другую сторону. Лечение требует продуктового решения (считать главы, ПЕРЕразрешённые после `started_at`), а не тихой правки ⚠ **Дописка пака P9 (27.08), диспозиция: НЕ ТРОНУТА.** Сквозная полоса (строка 200) переписала `runProgress` в `runDone`/`runTotal`, но ровно для сценария этой строки — книга полна в считаемом (последнем) проходе, значит и в черновом — ничего не меняет: обе базы равны счёту глав, `draftWork = C`, полоса стоит `0/2C` и до единицы не доходит; числитель по-прежнему считает завершения глав от баз прогона. Знаменатель сменился только для промежуточного класса «черновик впереди редактуры», который эта строка не описывает. Живой факт того же пробоя: старт нового прогона над ПОЛНОЙ книгой сегодня вообще отвечает 409 (шкале нечего продать, `ChaptersLeft = 0`) — то есть у правки готовой книги нет и входа, которым её сложили бы в прогон; это смежный продуктовый вопрос той же строки. Лечение прежнее — продуктовое решение «считать пере-разрешения после `started_at`». Канонному минору к полосе НЕ наследовать обещание «the fraction always reaches one» без этой оговорки | open | приёмка правок P7 (fable-5) | | PD-297 | bug | info | `internal/pgstore/readmodel.go` `writeChapters`/`writeUnits` | **Материализация дерева делает один round-trip на СТРОКУ под эксклюзивной блокировкой книги** — на корпусной книге (2283 главы, ~7 тыс. пар) это ≈11 тыс. последовательных обращений, и всё это время за блокировкой стоят `emitFrame` потока, `StartRun` и фолд юнитов. Штатный инструмент — `tx.SendBatch` (pgx v5, уже драйвер модуля) или `CopyFrom` во временную таблицу. НЕ сделано осознанно: рефутеры первой приёмки понизили до DOUBT/LOW, цена не замерена на форме этого деплоя (unix-сокет против управляемого PG по TCP — разница на два порядка), а путь — самый опасный на запись. Мерить прежде правки: время удержания блокировки на 2283-главной книге до и после ⚠ **P8-FIX: НЕ ВЗЯТ, причина названа и она не «не успели».** Сама эта строка объявляет замер на здешнем стенде НЕпредставительным (unix-сокет против управляемого PG по TCP — разница на два порядка), а корпусной книги нет: она появляется на холодном прогоне движка, которым гейчена строка 202 единого бэклога (решение владельца 20.08). Мерить нечем и не на чем, а правка самого опасного на запись пути без замера — ровно то, что эта строка запрещает. Берётся вместе с холодным прогоном | open | доработка 20.08 (сверка находок против дерева) | | PD-298 | bug | info | `internal/pgstore/readmodel.go` `ListNotes`, `internal/pgstore/sink.go` `unitDone` | **Снятие флага с замечания дельта-чтение выразить не может.** Резолюция, пере-разрешённая как не-`flagged` (редрайв), обновляет строку и двигает `revision`, но дельта фильтруется предикатом `ur.flagged` — строка не возвращается, и клиент никогда не узнаёт, что замечание снято: оно остаётся на экране навсегда. Канон §AfterVersion: «A DELETION cannot be expressed this way», и требует одного из двух ответов — `resync_required` либо `400 version_too_old`; здесь не даётся ни один. ⚠ НЕ подтверждено, что движок вообще пере-издаёт `unit_done` для той же тройки (глава, юнит, волна) с `flagged=false` — комментарий `sink.go` это УТВЕРЖДАЕТ («a redrive re-attacks a flagged one»), но чтением движка не сверено. Порядок: сначала сверка у движка, потом либо счётчик замены для замечаний, либо строка «переход недостижим» ⚠ **ДИСПОЗИЦИЯ АКТА 5 (сверка с движком контрактной сессией 20.08): переход НЕДОСТИЖИМ и механизма не строим.** Движок объявляет вердикт юнита один раз на волну, его announce-once-леджер не пере-announce-ит, поэтому единственная пере-доставка — ТОТ ЖЕ вердикт. Инвариант записан в коде (`sink.go` `unitDone`): если что-то научится снимать флаг, фолд обязан выдать кадр — иначе счётчик разъедется со списком молча. Строка держится открытой этим долгом, а не живым дефектом | open | приёмка P7 → доработка 20.08 (сверка) | | PD-299 | standards | info | `internal/httpapi/conditional.go` `acceptsGzip` | **`Accept-Encoding: identity;q=0` не отвечает `406`.** Клиент, потребовавший ЛЮБОГО кодирования кроме identity, получает identity. Половина RFC 9110 §12.5.3, которую правка PD-268 не закрыла: gzip-сторона (именованное кодирование выигрывает у `*`, нулевой вес — отказ) закрыта и пиньётся, эта — нет. Достижимо только специально сконструированным клиентом; ни один генерённый по контракту клиент так не делает | open | доработка 20.08 (сверка находок против дерева) | | 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:577`=`После пяти неудач` | **У числа попыток материализации ДВА носителя и ничего между ними.** Код держит `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 | | PD-45 | hardening | info | `internal/ingest/procgroup_unix.go` | `syscall.Kill(-pid, SIGINT)` идёт мимо `os.Process`, поэтому в узком окне между проверкой живости и сигналом ребёнок может быть пожат, и сигнал уйдёт в переиспользованную группу. Окно ~микросекунды и родитель ещё не звал `Wait`; переписывать на pidfd-путь — отдельная работа | open | ревью «вне карты» | | PD-86 | hardening | info | `internal/pgstore/queries/sessions.sql` `LookupSession` / `TouchSession` | **Два клауза-близнеца не запинены, и абсолютный потолок держится ТРАНЗИТИВНО:** снятие `absolute_expires_at > $2` из `Lookup` батарею переживает, потому что потолок навязывается через `least($3, absolute_expires_at)` в `Touch` (это запинено — `TestSessionLifecycle`). Снятие `revoked_at is null` из `Touch` тоже переживает (класс PD-4). Дефекта сегодня нет ни в одном; риск в том, что каждый слой по отдельности выглядит избыточным, а вместе они — единственное, что ограничивает жизнь сессии ⚠ **Указатель пере-нацелен паком `sqlc` (`63fcee5`), и это НЕ смена дефекта:** оба клауза уехали из `sessions.go:23,50` в `queries/sessions.sql`, потому что SQL этих запросов теперь генерируется. Прежний указатель `counts.py --lint` не ловил — у него нет `=токена`, так что он проверял только существование файла и молча указывал на строки, где этих клауз давно нет. Сами клаузы целы и по-прежнему не запинены | open | приёмка P2 (посадки мутаций) | | PD-87 | hardening | info | `internal/httpapi/server.go:82`, `internal/login/login.go:31` | Ещё два незапиненных: снятие `LimitBody` с поддерева `/auth` и `stateTTL` 10 мин → 240 ч проходят батарею. Первое — родня PD-72 (та про общий внешний слой, эта про конкретное поддерево), второе — окно жизни неиспользованного авторизационного запроса | open | приёмка P2 (посадки мутаций) | | PD-88 | bug | info | `internal/auth/cookie.go:62-66` | **TTL меньше секунды выпускает куку БЕЗ атрибута `Max-Age`:** `int(ttl.Seconds())` даёт 0, а Go при `MaxAge == 0` атрибут опускает ⇒ кука становится браузер-сессионной. Достижимо в последнюю секунду абсолютного срока (скольжение выдаёт `min(idle, остаток абсолютного)` при гарде `ttl > 0`) — то есть ровно тот исход, который самопроверка P2 называла нежелательным: кука переживает сессию, и следующий запрос даёт 401 вместо чистого «вы вышли». Подтверждено исполнением (ttl 500 мс/999 мс) | open | приёмка P2 (панель ×2, подтверждено исполнением) | | PD-90 | bug | info | `cmd/tmplatformctl/main.go` (греп `case "books":` и `Idempotency is OPT-IN`) | `grant` и `adjust` делят пространство ключей `source="admin"`: `--key`, потраченный грантом, молча гасит корректировку с тем же ключом. CLI честно скажет «ключ уже потрачен», но оператор ждал другой операции ⚠ **ПАК P8-REVIEW 24.08: якорь дрейфанул.** `cmd/tmplatformctl/main.go:112` сегодня это `case "books":`; пара, которую строка описывает, живёт на `:149`=`return store.Grant(ctx, *user, micro, "admin", id, *note, now)` и `:171`=`return store.Adjust(ctx, *user, micro, "admin", id, *note, now)`. Суть верна: одно пространство ключей `source="admin"` | open | приёмка P2 (панель) | | PD-92 | bug | info | `internal/ingest/supervisor.go:118-121` | **Дренаж стоит ДО `cmd.Wait()`, поэтому `WaitDelay` его не размораживает:** `io.Copy(io.Discard, stdout)` ждёт EOF, а EOF придёт только когда закроются ВСЕ копии пишущего конца пайпа; внук, унаследовавший stdout и игнорирующий SIGINT, вешает `Run` навсегда — backstop `WaitDelay` действует внутри `Wait`, до которого управление не доходит. ⚠ Сегодня недостижимо и это пере-проверено приёмкой: в `backend/` вне тестов нет ни одного `exec.Command` и нет cgo. ⚠ **Пере-диспозиция (эррата №15): вес понижен до info** — весь пайп-путь `supervisor.go` объявлен ДЕВ-РЕЖИМОМ (D39.106 п.3), в проде родителя у движка нет и `cmd.StdoutPipe()` не существует; чинить только если дев-путь остаётся | open | приёмка P2 (панель, граница зоны пере-проверена) | | PD-93 | bug | info | `internal/ingest/supervisor.go:109` | Фикс PD-77 («наша остановка — не сломанный синк») сверяет только `context.Canceled` и пропускает `context.DeadlineExceeded`: как только у `runCtx` появится дедлайн (потолок времени прогона — очевидная будущая ручка), штатное истечение снова поднимет ERROR «stream could not be materialized» | open | приёмка P2 (панель) | | PD-94 | bug | info | `internal/httpapi/middleware.go:61-70` | **`Recover` глотает `http.ErrAbortHandler`** — sentinel, которым хендлер намеренно обрывает соединение (`net/http` его не логирует и рвёт коннект). Замерено приёмкой: паника `ErrAbortHandler` превращается в 500 с problem-телом, то есть усечённый поток становится неотличим от полного. Латентно (сегодня им никто не паникует), но именно SSE-хендлер — типовой его пользователь | open | приёмка P2 (замер оркестратора №15) | | PD-96 | hardening | info | `internal/httpapi/server.go:39-43`, `internal/auth/csrf.go:28` | **`TrustedOrigins` обещает отдельно развёрнутый фронт, но CORS-слоя нет вовсе.** Живая проба: preflight `OPTIONS` с `Origin: https://app.example.org` получает 401 от гарда (браузерный preflight креденшелов не носит и не должен), заголовков `Access-Control-*` нет ни на одном ответе. Сценарий «фронт на другом origin» браузером сегодня неисполним: либо CORS приезжает вместе с контрактными ручками (П-1), либо фронт живёт на том же origin, и тогда `TrustedOrigins` — мёртвая ручка | open | приёмка P2 (панель + живая проба) | | PD-98 | doc | info | `internal/pgstore/store.go:75-79` | Случай «схема НОВЕЕ бинаря» в `Ready` беззвучен — признано ⚠-комментарием на месте, но ни одной строки лога: оператор, запустивший старый бинарь на новой схеме, сигнала не получит | open | приёмка P2 (панель) | | PD-107 | hardening | info | `internal/pgstore/migrations/00007_credits.sql:85`, `00002_readmodel.sql:13` | **Удаление аккаунта обходит защиту PD-25:** составной FK `reservations → books(id, owner_id) on delete restrict` блокирует `DeleteBook`, но `users` каскадит в `reservations` НАПРЯМУЮ, поэтому `delete from users` уносит и ОТКРЫТУЮ резервацию. Замерено приёмкой: аккаунт с открытым холдом удаляется. Учётной дыры нет — леджер и кэш баланса каскадятся тем же удалением, — но прогон, идущий против этого холда, останется без того, кто его закроет. Кода удаления аккаунта в дереве нет вовсе (грепнуто) ⇒ строка = гейт перед появлением такой операции (и перед ASVS 7.4.2 в полной форме). ⚠ Заодно ОПРОВЕРГНУТА обратная версия этой находки от панели («удаление падает на композитном FK даже при закрытых резервациях») — мой прогон: удаляется и с закрытой резервацией, и без неё | open | приёмка P2 (замер оркестратора №15; версия панели опровергнута) | | PD-122 | bug | info | `internal/pgstore/books.go` `ListBooks` | **`Library.revision` НЕ монотонна: она выведена как `max(books.revision)` по книгам аккаунта и падает, когда удаляется книга, державшая максимум.** Контракт требует монотонности внутри области и предписывает клиенту ОТБРАСЫВАТЬ чтение с меньшей ревизией — то есть после такого удаления библиотека замирает, пока чей-нибудь книжный счётчик не перерастёт старый максимум. Замерено ревью: 10 → 0 после удаления книги. ⚠ Сегодня недостижимо через API: ручки удаления книги нет вовсе (`DeleteBook` есть в сторе, маршрута нет). Правильное решение — собственный счётчик области у аккаунта, который двигается на изменение состава и статусов. ⚠ Уточнено ревью доков 09.08: КОЛОНКА уже есть — `users.library_revision` из `00001_identity.sql:13`, и её не читает и не пишет ни один Go-путь (грепнуто), так что нужна не миграция, а пути записи и чтения; правка нескольких мест, поэтому она НЕ сделана в этом паке, а названа. ⚠ Сужено паком P5: ДОБАВЛЕНИЕ книги ревизию области двигает — новая книга входит в библиотеку с `revision = max(revision по владельцу) + 1` (`pgstore.nextLibraryRevision`, обе точки входа: дев-интейк и загрузка), и каждая смена статуса интейка её тоже двигает; пин `books.TestABookThatJoinsTheLibraryMovesItsRevision`, посадка «входить с нулём» падает. Открытым остаётся исходный случай: УДАЛЕНИЕ книги может уронить максимум, и лечится это собственным счётчиком области. Ручки удаления по-прежнему нет. **Гейт строки: закрыть ДО появления ручки удаления книги** — обратная сторона этой же пары стоит в `PD-175` (греп `гейт PD-122`) ⚠ ПЕРЕ-ФОРМУЛИРОВАНА дофиксом приёмки (FP5-1): клейм «добавление закрыто» был ЛОЖЕН. Пол области поднимался при удалении, а вставка читала только максимум — книга, загруженная после отменённой загрузки, входила НА или НИЖЕ пола, и одна ревизия отвечала за три разных состояния библиотеки. Теперь вставка берёт `greatest(max, пол) + 1`, пин `books.TestABookThatJoinsAfterACancelledUploadStillMovesTheRevision` гоняет именно сценарий «отмена → повторная загрузка». ОТКРЫТЫМ остаётся: смена статуса книги, которая НЕ самая новая, ревизию области не двигает — ни одна из половин не растёт; лечится тем же счётчиком области, который двигают все писатели состава и статусов ⚠ Дополнено ре-чеком (FP5-11): правило «брать следующий номер БИБЛИОТЕКИ» применено и к пяти писателям жизненного цикла прогона (старт, реоткрытие, пауза, два закрытия) — без них staleness оставалась на статусах прогона: книга не-максимума аккаунта уходила `translating`, а библиотека не двигалась. Пин `pgstore.TestARunTransitionOnAnOlderBookStillMovesTheLibrary`. Прогресс-путь синка НАМЕРЕННО не тронут (главная шкала считается от книжной до инкремента; прогресс бывает только у книги с идущим прогоном, а её старт уже поднял номер) | open (добавление закрыто P5; удаление — нет) | адверсариальное ревью (исполнением) | | PD-123 | doc | info | `internal/pgstore/migrations/00009_runner.sql:11` | `runs.ceiling_chapters` имеет `default 0`, а контракт объявляет `Run.ceiling_chapters` `minimum: 1`. Сегодня недостижимо: единственный путь вставки — `StartRun`, и он отказывает на неположительном значении. Строка заведена как гейт: строка прогона, записанная мимо `StartRun` (миграция данных, правка оператором), спроецируется на провод нулём, которого схема клиента не допускает | open | адверсариальное ревью (чтение схемы) | | PD-137 | hardening | info | `deploy/tmplatformd.service` `[Unit]` | `BindPaths=/run/user/%U` требует существования каталога на старте юнита, а создаёт его logind вместе с пользовательским менеджером; в `[Unit]` упорядочения на него нет. На первом бутe это гонка, которую лечит `Restart=on-failure` (сервис поднимается со второй попытки). Строка не закрыта кодом намеренно: UID сервисного пользователя site-specific, поэтому `After=user@.service` добавляется установкой — инструкция вписана в шапку юнита | open | адверсариальное ревью (чтение) | | PD-139 | hardening | info | `internal/runs/reconcile.go` ERROR-строки | Путь каталога книги попадает в ERROR-логи внутри обёрнутых ошибок (`*fs.PathError` тейлера, обёртки спавна). PD-99 закрывал ДРУГОЕ — argv на INFO, — и та половина проверена (`grep` по логу сквозной пробы = 0). Здесь диспозиция иная и её надо принять осознанно: оператор чинит именно этот путь, а ERROR — не INFO. Заведено, чтобы это было решением, а не побочным эффектом; если норма зоны распространяется и на ERROR, путь придётся заменить на id прогона ⚠ Дополнено P5: у класса появилась ВТОРАЯ площадка — интейк. Путь книги уходит в ERROR только в двух местах и намеренно: терминальный отказ разбора (`err` движка несёт путь исходника) и неудавшееся удаление каталога. На INFO/WARN идентификатора книги нет вовсе, и это запинено `books.TestNoBookIdentifierReachesAnInfoLine`. Диспозиция по-прежнему нужна одна на класс | open | адверсариальное ревью (чтение) | | PD-153 | bug | info | `internal/runs/reconcile.go` `spawnGrace` | **Грация спавна мерится от `run_attempts.started_at`, а не от момента заявки права на спавн:** окно между `RecordSpawn` и возвратом `systemd-run` не покрыто, и второй инстанс платформы, у которого грация уже истекла, может решить, что попытка потеряна, и перезапустить прогон, который вот-вот стартует. Дёшево закрывается временем заявки, записываемым в `RecordSpawn`, и грацией от него ⚠ Сужено дофиксом приёмки: у СТОП-пути окно закрыто с обеих сторон — заявка на спавн не выдаётся прогону с висящим интентом, а закрытие «стопа до спавна» спрашивает systemd про детерминированное имя юнита, если у попытки есть базовая линия (то есть заявка когда-то бралась). Само окно PD-153 — грация от `started_at`, а не от момента заявки — не тронуто | open | приёмка P4 (N2) | | PD-154 | bug | info | `internal/runs/reconcile.go` `settle` | **`runs.settled_at` может остаться NULL между `Settle` и `MarkSettled`:** это два вызова, и падение между ними оставляет прогон с закрытой резервацией и без отметки. Потребителей у отметки сегодня нет (рабочий список расчёта построен на открытой резервации, а не на ней), деньги целы и второй расчёт отвергается самой резервацией. ⚠ Дофикс 09.08 добавил вторую половину той же строки: `settle` прерванной попытки ЖИВОГО прогона (путь `UnsettledRuns`) ставит `settled_at` прогону, который ещё идёт. Потребителей у колонки по-прежнему нет, дрейф только операторский. Заведено как известность, а не как долг: закрывается вместе с эскроу (строка 136) ⚠ **ПЕРЕ-ДИСПОЗИЦИЯ (P6): остаётся открытой в прежней формулировке.** Пак трогал `settle` (запиненный бинарь, аддендум оркестратора 14.08) и окно не закрывал: закрытие требует write-ahead intent и состояния `closing`, то есть эскроу строки 136, а строить половину эскроу рядом с проектируемым целым — это второй, более слабый ответ на тот же вопрос. Потребителей у колонки по-прежнему ноль | open | приёмка P4 (N3) | | PD-170 | hardening | info | `internal/pgstore/credits.go` `Settle` | `Settle` отбрасывает флаг `applied` строки расчёта, тогда как `releaseHold` на соседней строке из того же флага делает `ErrReleaseKeySpent` (PD-97). Недостижимо без правки леджера в обход кода — резервация должна быть открыта, чтобы дойти сюда, — но асимметрия в денежном пути стоит строки: либо симметричный отказ, либо явная причина, почему здесь он не нужен | open | самопроверка дофикса (ревью вне карты) | | PD-176 | hardening | info | `internal/httpapi/middleware.go` `LimitBody`, `internal/metrics` | **Сигнал `requestTooLarge` от `http.MaxBytesReader` до сервера не доходит через наши обёртки.** `MaxBytesReader` пытается сказать *`net/http`* «запрос слишком большой» приведением `ResponseWriter` к НЕЭКСПОРТИРУЕМОМУ интерфейсу пакета `net/http`; наши обёртки (`statusRecorder`, `metrics.recorder`) его удовлетворить не могут в принципе — метод неэкспортируемый, а значит квалифицирован чужим пакетом. Следствие мягкое и замерено рассуждением по коду `net/http`: 413 отдаётся штатно, а соединение закрывается не немедленным сигналом, а обычным путём — после хендлера сервер дренирует остаток тела и, не сумев дочитать, закрывает соединение, причём дренаж ограничен `ReadTimeout` (PD-2). Лечение (если понадобится): ставить лимит без обёрток над `ResponseWriter` либо закрывать соединение самим через `Connection: close` | open | сессия P5 (чтение stdlib при постройке маршрута) | | PD-177 | bug | info | `internal/books/books.go` `counter`, пин — `internal/books/counter_test.go` `TestTheIntakeCounterCountsTheWriteStreamAndNotCharacters` | ⚠ **ПЕРЕ-ФОРМУЛИРОВАН 05.09 — ряд был УЖЕ ряда: для EPUB это не «оценка», а ДРУГАЯ ВЕЛИЧИНА.** Интейк принимает `.epub` (`extensionOf`, движок диспетчеризует читатель по расширению), и тогда счётчик считает не-продолжающие байты ZIP-АРХИВА — свойство контейнера, к числу знаков книги отношения не имеющее. Верно ровно для UTF-8-текста; для GB18030/UTF-16 — приближение (прежняя формулировка ряда). Что сделано в зоне 05.09: имя и доккомментарий больше не лгут (`counter.runes`, не `characters`), и факт ЗАПИНЕН тестом, чтобы следующий читатель узнал его из батареи, а не с экрана. Что НЕ сделано и не может быть сделано здесь: поле `character_count` стоит в каноне `required` с типом `[integer, 'null']`, и `null` уже несёт смысл «книга ещё загружается» — значит «не заполнять» ломает и контракт, и потребителя, а лечение требует минора канона (признак точности рядом с числом ЛИБО смена семантики `null`) или суммы знаков из манифеста движка, которой там пока нет (строка бэклога 278). Родня — строка бэклога 282 **`character_count` для не-UTF-8 источника — оценка, а не счёт.** Интейк считает символы потоково как байты, не являющиеся продолжением UTF-8 (`b&0xC0 != 0x80`), что для валидного UTF-8 ТОЧНО равно числу рун и не требует состояния между чанками. Движок принимает и GB18030, и UTF-16, декодируя их сам — на таком файле цифра неверна (для UTF-16 занижена примерно вдвое). Точный ответ есть на шаг позже: манифест несёт `source_bytes` и `encoding`, но не число символов. Лечится либо запросом числа символов у движка (строка единого бэклога), либо перерасчётом после разбора | open | сессия P5 (названо при постройке) | | PD-201 | bug | info | `deploy/README.md`, движок `tmctl migrate` | **Самолечение деплой-деадлока v15 ЖДЁТ движковую половину.** Read-only `status` отказывает файлу проекта старее бинаря, платформа зовёт его перед каждым спавном и на расчёте денег — значит выкат движка запирает книги до `tmctl migrate`. Порядок апгрейда записан и исполним (`tmplatformctl books --migratable` пропускает книги с живыми прогонами, резюмируемыми попытками и незакрытыми холдами — все три блокируют по РАЗНЫМ причинам, аддендум оркестратора 14.08). Чего нет: движковый `migrate` придёт с машиноразличимой ошибкой «версия не совпала», и тогда платформа сможет лечиться сама — поймала → `migrate` (если у книги нет открытых попыток) → повтор. Вслепую не строится: без формы ошибки любой матчер был бы догадкой по тексту. ⚠ Проверка исполнением самого шага `migrate` тоже ждёт и НЕ помечена сделанной ⚠ **ПОЛОВИНА ЗАКРЫТА P7 (проверка исполнением):** рантбук деплоя прогнан end-to-end на стенде живым `migrate` — проектная БД, отведённая на схему v14 при бинаре v15, даёт `tmctl status --json` exit 13 с токеном `schema_mismatch found=14 expected=15`; `tmctl migrate` делает пред-миграционный бэкап и переводит v14 → v15; тот же `status` после — exit 0. Мёртвая цитата ошибки вычищена из `deploy/README.md` и из П-1 бэклога, пример в `tmplatformctl/runs.go` переписан на ВЕРСИОНИРОВАННЫЙ путь бинаря. ⚠ ОСТАЁТСЯ сам автомат самолечения («поймал 13 → migrate → повтор») — он не построен: строить его правильно значит решать, кто имеет право мигрировать книгу с открытыми попытками, а это тот же вопрос, что у `books --migratable` | open | аддендум оркестратора 14.08 (ресёрч деплоя) | | PD-204 | standards | info | `internal/runner/systemd_test.go` мета-пин | **Мета-пин systemd-гейта ПЕРЕЧИСЛЯЕТ формы отказа вместо того, чтобы спрашивать способность.** Хвост (г) третьего раунда, у которого не было носителя. Сам гейт вылечен (PD-178: спрашивает, доходит ли процесс до своего менеджера), а тест, который его сторожит, по-прежнему знает список сообщений — у стража та же болезнь, от которой лечили охраняемого. Принято как ОСТАТОК с названной ценой: systemd меняет тексты между версиями, и мета-пин протухнет молча; вреда сегодня нет, потому что он не гейт, а страж гейта. Заведён по пингу оркестратора 14.08 | open | третий раунд P5, хвост (г) | | PD-205 | hardening | info | `internal/login/dev.go` `login`, `internal/auth/csrf.go` `cookieUnsafe` | **Дев-вход защищён от login-CSRF только ОДНИМ слоем из двух.** `auth.CSRF` требует заголовок `X-TM-Client` лишь на unsafe-запросе, который УЖЕ несёт сессионную куку, а на входе куки по определению нет — значит остаётся только `http.CrossOriginProtection`, и клиент, не присылающий ни `Sec-Fetch-Site`, ни `Origin` (тот самый браузер до 2023, ради которого второй слой и заведён), может кросс-сайтом ввести браузер жертвы в ДЕВ-аккаунт. Последствие на стенде ничтожно — аккаунт один и общий, — но свойство слабее, чем «POST, значит безопасно», и записано, а не подразумевается. ⚠ Тот же класс у боевого `/auth/login` (он вообще GET) и по той же причине; лечится либо требованием заголовка на login-маршрутах, либо явным принятием | open | адверсариальное ревью P6 (кросс-семейное, Fable) | | PD-212 | bug | info | движок `cmd/tmctl/main.go` `exitCode`, `internal/runs/reconcile.go` `outcome` | **Непойманная паника движка неотличима от «завершено с флагами».** Go-рантайм завершает процесс с кодом РОВНО 2 на непойманной панике, а 2 — это `CompletedWithFlags`, единственный «успешный» код контракта; `recover` в `cmd/tmctl`/`internal/pipeline` отсутствует (грепнуто ревьюером). Платформа читает только `$EXIT_CODE`/`$EXIT_STATUS` и записывает `ready` для прогона, который упал посреди работы. Деньги целы (расчёт берёт цифру из `status --json`), врёт статус. Лечится НЕ здесь: либо `recover` в main движка, либо другой номер для флагов — запрос уходит строкой единого бэклога через оркестратора. ⚠ Платформа МОГЛА бы различить по отсутствию терминальной строки `finished` в журнале, но сознательно не судит прогон по строке, которую крэш обрезает ⚠⚠ **ПРОТУХЛА 02.09, чиню фактом: ДВИЖОК ЭТО СДЕЛАЛ, и посылка строки («`recover` отсутствует») больше не верна.** `backend/cmd/tmctl/main.go` несёт `exitOf` — `main` идёт под `recover`, паника заворачивается в `obs.NewPanicError` и `exitCode` проверяет `*obs.PanicError` **ПЕРВЫМ**, отдавая **1**, а не 2 (греп `func exitOf` и `case errors.As(err, &panicked)`); волновые воркеры несут свой `recover` на шве (греп `recover()` в `backend/internal/pipeline/waverun.go`). Пины движка — `backend/cmd/tmctl/panic_exit_test.go`; лендинг `f840867`. То есть «паника читается как завершено-с-флагами» больше не воспроизводится. **Статус не перевожу — закрытие есть акт лендинга и лекарство в ЧУЖОЙ зоне**; оркестратору: строка готова к закрытию, либо к пере-формулировке в остаток (различение крэша по обрезанному журналу платформа по-прежнему не строит и строить не собирается) | open | адверсариальное ревью P6 (линза шва) | | PD-215 | bug | info | `internal/runs/reconcile.go` `restart`, `interruptedBySomeoneElse` | **У петли перезапусков нет ни счётчика, ни бэк-оффа.** Каждый цикл — строка `run_attempts`, три строки леджера (холд/возврат/расчёт), два вызова `tmctl status` и транзиентный юнит. Источник, который на каждой попытке тратит ~0 (движок, мгновенно падающий по внешней причине), крутит это вечно: `remaining` не убывает, значит `exhausted` никогда не наступает. Денег не теряется, но это неограниченная работа и рост таблиц. Класс существовал и до пака (путь ребута, строка 138), а ветка «exit 5 без намерения = прерывание» его РАСШИРИЛА. Лечить счётчиком перезапусков на прогон или бэк-оффом по времени последней попытки | open | адверсариальное ревью P6 (линза денег и гонок) | | PD-216 | bug | info | `internal/runs/reconcile.go` `outcome` (полоса отказов) | **Exit 12 (`project_locked`) закрывает прогон терминально, хотя это единственный класс полосы, который проходит САМ.** Обоснование «отказ воспроизводим по построению, поэтому перезапуск — петля» верно для 10/11/19 и неверно для лока: другой процесс отпустит проект. Денег не теряется (холд возвращается целиком, списывается 0), но прогон убит и пользователь покупает новый. Не исправлено намеренно: перезапуск на 12 без бэк-оффа — это PD-215 в чистом виде, поэтому оба лечатся вместе | open | адверсариальное ревью P6 (линза денег и гонок) | | PD-243 | hardening | info | `internal/ingest/resync.go` `DecodeStatus` | **Декодер принимает `null`, `{}` и объект сплошь незнакомых полей за валидный отчёт.** На денежных путях это перекрыто (PD-40: `Spend == nil` откладывает расчёт) и при exit 2 — согласием отчёта (PD-237); остаётся `eta_seconds`, который присваивается безусловно, так что пустой отчёт стирает ETA живого прогона. Лечится проверкой того же класса, что в манифесте: отчёт обязан называть книгу | open | кросс-семейное ревью дофикса P6 (линза шва) | | PD-244 | bug | info | `internal/runs/reconcile.go` `settle` | **Единственный выход `settle`, который оставляет холд открытым молча:** при `s.Engine == nil` возвращается nil — ни возврата, ни `MarkSettled`, ни строки в логе. В проде недостижимо (`cmd/tmplatformd/runner.go` всегда ставит Engine), но это ровно та тишина, из-за которой класс PD-162 искали глазами | open | кросс-семейное ревью дофикса P6 (денежная линза) | | PD-246 | standards | info | `internal/ingest/notes.go`, движковый `pipeline/status.go` `flagReasonSeverity` | **Платформа держит РУКОПИСНУЮ КОПИЮ закрытого словаря чужой зоны, и единственный страж — строка в логе.** Флаг-причины принадлежат движку (`flagReasonSeverity`, 15 значений), карта «причина → контрактный код» живёт на платформе, а выпускаются две программы независимо. Значит существует окно, в котором движок уже эмитит причину, которую эта сборка не знает, и расхождение видно только по ERROR в логе — то есть тогда, когда кто-то его прочитает. ⚠ Проводу окно закрыто и это НЕ дефект: причина вне карты проецируется кодом `unspecified`, а канон уже предписывает клиенту нейтральную фразу для незнакомого кода — то есть значение отдано ветке, которая ратифицирована, а не изобретено правило. Открытым остаётся ДВОЕ: (1) фразы для `unspecified` в приложении А нет (пишет владелец, строка 148) — как и ступеней у всех 15 строк, где граница `attention`/`glance` сегодня догадка платформы; (2) гейта на расхождение нет и со стороны платформы быть не может — импортировать `backend/internal` запрещено ревью-гардом модулей (D39.85). **Предложение зоны (пинг оркестратору, строкой в бэклог ДВИЖКА):** публиковать список флаг-причин данными — артефакт рядом с манифестом либо `tmctl flag-reasons --json` ($0, таблица уже существует), — тогда тест платформы читает его и ПАДАЕТ, если в карте нет строки. Класс «словарь разъехался тихо» закрывается насовсем. ⚠ Обратное решение — чтобы движок эмитил сразу контрактный код — отвергнуто с доводом: это зеркальная утечка, продуктовое слово (`source_residue`, `term_not_applied`) поехало бы в движок, который о контракте знать не должен, и отменило бы причину самого переименования ⚠ **ЗАКРЫТА ЧАСТЬ ПРО ПЛЕЙСХОЛДЕР (акт 5, ответ контрактной сессии 20.08):** `unspecified` ратифицирован как ОБЯЗАННОСТЬ сервера, а не строка чужого словаря — то, что зона уже отдаёт, стало легальным. Открытым остаётся то, ради чего строка заведена: рукописная копия закрытого словаря движка и отсутствие стража, кроме строки в логе ⚠ **СОБЫТИЕ, РАДИ КОТОРОГО СТРОКА ЗАВЕДЕНА, НАСТУПИЛО — 04.09, и оно поймано ПИНГОМ, а не стражем.** Бэкенд-сессия (`textmachine-main-7e`) завела ШЕСТНАДЦАТУЮ причину `FlagOffTargetLang = "off_target_lang"` (`backend/internal/pipeline/disposition.go`) — ответ не на том языке, который просили, и это не эхо исходника. ⚠ Счёт пере-снят СВОЕЙ рукой, а не принят на слово: `grep -c '^\t"[a-z_]*":' internal/ingest/notes.go` → **15** ключей, `flagReasonSeverity` движка на HEAD → те же 15 имён, шестнадцатое живёт пока только в НЕЗАЛЕНДЖЕННОМ дереве бэкенда. То есть окно, которое строка описывает, открыто ровно сейчас, и предсказанная деградация верна: причина вне карты проецируется в `unspecified` и пишет ERROR в лог. **Карту НЕ дополняю, и это не лень:** контрактного кода у новой причины ещё нет, а изобрести его значило бы править приложение А канона своей рукой — состав минора идёт оркестратору на ратификацию (`off_target_lang` → новый код замечания + ступень). **И самое существенное:** класс поймал не гейт, а ПИСЬМО соседней сессии — то есть стража по-прежнему нет, и предложение зоны (движок публикует причины ДАННЫМИ, строка 204 единого бэклога) остаётся единственным, что закрывает его насовсем ⚠ **СОБЫТИЕ, КОТОРОЕ ЭТОТ РЯД ПРЕДСКАЗЫВАЛ, НАСТУПИЛО 04.09 — и ряд остаётся ОТКРЫТЫМ, потому что наступление не есть лечение.** Движок завёл шестнадцатую причину `off_target_lang`, канон ратифицировал ей код `wrong_language` (минор `0.10.0`, `D39.194`), а рукописная карта платформы знала пятнадцать — то есть причина ехала читателю как `unspecified` со ступенью «взгляд», и единственным стражем был предсказанный этим рядом лог (`internal/httpapi/reading.go` «notes carry a flag reason this build cannot name»). Строка добавлена, батарея зелёная, но **окно между двумя независимо выпускаемыми программами закрывает не строка, а механизм**, которого по-прежнему нет. Замер стоимости окна теперь есть: между лендингом движковой причины и лендингом платформенной строки прошёл один рабочий день, и всё это время код на проводе был ложным. | open | сессия P7 (самопроверка против приложения А) | | PD-247 | bug | info | `internal/httpapi/stream.go` `pump` | **Живой поток ОПРАШИВАЕТ базу дважды в секунду на каждое открытое соединение** (кадры + состояние книги). Кадры минтят писатели в своих транзакциях и в других процессах, поэтому подписки у этой стороны нет; выбран опрос, а не `LISTEN/NOTIFY`, потому что кадр это ПОКА, и секунда задержки на поке не наблюдаема рядом с переводом. Цена названа числом: 12 вкладок на книгу = 24 запроса/с к Postgres, оба по индексу и по одной книге. Лечится `pg_notify` в `emitFrame` + один слушатель на процесс — работа на полдня, которая нужна не раньше второго десятка одновременных читателей | open | сессия P7 (собственная оценка) | | PD-248 | bug | info | `internal/readmodel/readmodel.go` `Refresh` | **Материализация читающей поверхности стоит ДВУХ полных ре-чанков исходника** — `tmctl manifest --json` и `tmctl export --json --pairs`, каждый из которых заново ингестит и режет книгу (~1,4–1,5 с CPU на 23 МБ, строка 100 единого бэклога). Зовётся на границах работы (конец интейка, конец прогона), то есть не на запрос пользователя, но на книге в 2283 главы это секунды CPU и десятки мегабайт JSON через пайп на каждый конец прогона. Дешевле было бы читать сайдкар манифеста напрямую (он уже лежит рядом с БД проекта) и просить у движка экспорт ТОЛЬКО изменившихся глав — второго канала у движка нет, это запрос строкой единого бэклога **Доработка 20.08:** интейк больше не платит за ТРЕТЬЮ ре-нарезку — `books.Parse` передаёт уже прочитанный манифест в `readmodel.RefreshCut`; остаются два (манифест + экспорт), и это цена самих каналов. | open | сессия P7 (собственная оценка) | | PD-249 | bug | info | `internal/pgstore/runs.go` `ReadRunForSpawn` | **Чтение прогона под спавн линейно по числу ЖИВЫХ прогонов:** читает их все и ищет нужный в цикле. Сегодня незаметно (живых прогонов единицы), но это O(живых) на КАЖДУЮ задачу спавна, и растёт ровно тогда, когда платформа становится нужной. Находка §9 контракт-ревью (research/28), проверена чтением кода: запрос действительно без предиката по id | open | research/28 §9 (пинг оркестратора №17), сверено P7 | | PD-251 | bug | info | `internal/login/login.go` (лимитер входа) | **Лимитер входа один на ПРОЦЕСС, а не на адрес:** один клиент, долбящий `/auth/login`, расходует общее ведро и запирает вход всем. Выбор объяснён комментарием (за прокси адрес клиента без доверенного `X-Forwarded-For` — это адрес прокси, и пер-адресное ведро тогда защищает не то), но следствие не было записано. Лечение появляется вместе с доверенным заголовком прокси на деплое ⚠ **ПАК P8-REVIEW 24.08: это ТОТ ЖЕ факт, что `PD-42`, и он стоит в регистре в ДВУХ статусах одновременно.** `PD-42` (`accepted-risk(платформа P1, 05.08)`) описывает то же самое ведро с замером «8 отказов из 10 при фоне 5 rps», и код один — `internal/login/login.go` `startLimit` с комментарием «Deliberately GLOBAL rather than per-address». Клейм этой строки «следствие не было записано» неверен: оно записано раньше и принято. Предложение пака: свести — либо закрыть эту дублем, либо снять принятие риска с `PD-42` ⚠⚠ **Условие сведения, найденное рефутером и обязательное к переносу: НИ ОДНА из двух строк не называет ВТОРОЕ глобальное ведро.** Кроме `startLimit` на `/auth/login` есть `finishLimit` на `/auth/callback` (`internal/login/login.go:71,129,232`) — тот же дефект на второй половине потока входа, и он не покрыт ни одной строкой регистра. Сводить `PD-251` в `PD-42` можно только назвав в объединённой строке ОБА ведра и ПЕРЕ-подтвердив принятие риска явно, а не унаследовав его: иначе открытый баг молча превращается в принятый риск, а половина дефекта исчезает из регистра вовсе | open | research/28 §9 (пинг оркестратора №17), сверено P7 | | PD-252 | bug | info | `internal/runs/reconcile.go` (карантин проекции) | **Сырой Go-текст ошибки сохраняется причиной карантина в пользовательской строке БД.** На провод он не идёт (карантин не проецируется ни одним полем контракта), но это движковый и внутренний текст в таблице, которую читают дампами; тот же класс, что `Problem.detail`, только в базе. Лечение — закрытый словарь причин карантина, как у `books.reject_reason` | open | research/28 §9 (пинг оркестратора №17), сверено P7 | | PD-256 | bug | info | `internal/ingest/export.go` `DecodeExport` | **Сигнал дрейфа конфигурации движка не читается.** `tmctl export` отдаёт `config_drift`/`current_snapshot` — «текущая конфигурация рендерит не тот снапшот, что несут сохранённые строки», то есть диспозиции могут не описывать то, что прогон делал. Платформа материализует текст без этого признака: на проводе места для него нет (и не должно быть — это операторский факт), но в логе материализатора он был бы уместен | open | кросс-модельное ревью P7 (линза шва) | | PD-345 | hardening | info | `internal/runner/engine.go` `readEngine`, `firstLine` | **stderr движка читается БЕЗ потолка, вплотную к намеренному потолку на stdout.** `doc` берётся через `io.LimitReader(out, limit+1)` и переполнение отдельно судится (`drain`, «a cap that is at the mercy of the thing it bounds is not a cap»), а `errOut` — простой `bytes.Buffer`, и `firstLine` отдаёт из него префикс любой длины. Асимметрия и есть находка: рядом стоит комментарий, объясняющий, почему потолок обязателен, и на соседнем канале его нет. Сегодня цена ограничена (процесс короткий, движок свой), но строка уезжает в ошибку, в лог и — до P8-FIX — в колонку БД (это половина `PD-340`, там закрыта на стороне хранения). ⚠ **Найдено ревью P8-FIX и НЕ чинится этим паком: вне его скоупа**, названо, чтобы не потеряться | open | ревью P8-FIX (контракт-конформность, соседство с PD-340) | | PD-245 | standards | info | `internal/ingest/supervisor.go` | **У дев-супервизора нет ни одного потребителя вне собственных тестов** (`grep` по зоне): это дев-путь D39.106 §3, который пережил постройку продового шва. Его чинят и держат в шаге с продовым (PD-224, PD-237) — но либо он должен быть подключён к дев-режиму демона, либо снят вместе со своими тестами; сейчас это код, который стоит сопровождения и ничего не обслуживает | open | кросс-семейное ревью дофикса P6 (линза шва) | | PD-218 | bug | info | `internal/pgstore/migrations/00015_seam_ceiling_and_units.sql` | **Down-путь 00015 падает на данных, которые накатанная схема уже допускает:** он сужает `runs_paused_reason_check` обратно к одному значению, а строки с `daily_ceiling`/`ceiling_unknown` к этому моменту существуют. Откат транзакционный, поэтому падение ничего не портит, но плана отката ниже 15 нет — как и ниже 5 (`deploy/README.md`). Лечится либо переводом таких строк в down-пути, либо честной записью «ниже 15 не откатываемся» | open | приёмка P6 (дофикс, ФП-7) | | PD-220 | hardening | info | `internal/config/config.go` `Load` | **Резерв имени `dev` сравнивается байт-в-байт:** `TM_PLATFORM_OIDC_PROVIDER=Dev` проходит гейт. Сегодня инертно, и ровно по той же причине: Postgres сравнивает `identities.provider` тоже байт-в-байт, поэтому в пространство имён дев-входа такой издатель не попадает. Станет опасным в день, когда сравнение личности станет регистронезависимым | open | приёмка P6 (дофикс, ФП-7) | | PD-221 | bug | info | `internal/runs/spawn.go` `engineStreamID`, `internal/pgstore/runs.go` `EngineStreamID` | **Имя потока переиспользуется при повторном спавне ТОЙ ЖЕ попытки:** оно детерминировано по паре (прогон, номер попытки), а движок отвергает id, который уже писал события этой книги, и чеканит свой (`store.EventsUsed`, `pipeline/events.go`). Тогда платформа не узнаёт собственный поток и живёт на медленном ре-синке — свежесть, не деньги. Ре-спавн одной попытки бывает после отданной назад заявки на спавн | open | приёмка P6 (дофикс, ФП-7) | | PD-222 | standards | info | `cmd/tmplatformctl/seed.go` | **HTTP-променад сида не покрыт тестом:** вход, грант, загрузка и ожидание интейка проверены только живым прогоном на стенде (P6), автоматически — лишь разбор аргументов (`TestASubjectThatIsOnlyPaddingIsNoSubject`). Дев-инструмент, но именно он — единственный потребитель контракта в репозитории, и его поломка видна только тому, кто поднимет стенд | open | приёмка P6 (дофикс, ФП-7) | | PD-408 | doc | info | `internal/runs/bank.go` (бюджет двери = `s.runBudget()`), `internal/runner/bankapply.go` (`errOut` без лимита; `Stderr: firstLine`) | **Две операционные оговорки двери правок, названные воркфлоу-ревью; обе — цена конфигурации, не дефект пути.** (1) Бюджет двери — та же ручка `TM_PLATFORM_RUN_BUDGET`, что у прохода свипа; движок выбирал потолок 5000 решений против ЖЁСТКИХ 60 с («пять раз внутри бюджета»), и оператор, понизивший ручку (к чему соседние комментарии подталкивают), делает легальный документ-максимум навсегда неприменимым — вечный `503` вместо «разбей документ»; связка ручки и капа нигде не названа. (2) stderr глагола читается в НЕограниченный `bytes.Buffer`, хотя потребляется только первая строка, — не-тот бинарь по сконфигурированному пути (полудеплой, обёртка) может раздуть демона до OOM за 60-секундный бюджет; stdout той же команды капнут 64 МиБ | open | воркфлоу-ревью P9 28.08 (линзы door:lock-lifecycle · door:crash-windows), диспозиция оркестратора 28.08: строкой | | PD-428 | doc | info, деньги | `internal/pricing` (`TM_PLATFORM_USD_PER_CHAPTER`, `Pricing.Ceiling`) | **Цена продажи не знает о накладных, которые масштабируются КНИГОЙ, а не грантом.** Замер движкового охотника (лендинг `6ec9f8a`): терминолог переигрывается на КАЖДОЙ покупке ЦЕЛИКОМ по книге — три покупки по одному юниту дали три полнокнижных консолидации по $0.005460 каждая, при том что сам юнит дешевле. То есть книга на 500 юнитов, проданная по одному, оплатит 500 полнокнижных проходов. ⚠ **Сегодня это НЕ дефект платформы и заведено только как калибровка:** продажа идёт ГЛАВАМИ (`Ceiling(chapters)`), а не юнитами, так что нарезки, при которой накладные обгоняют полезную работу, в продукте нет. Строка существует, чтобы факт не потерялся к моменту, когда мелкая нарезка появится: любая будущая единица продажи мельче главы обязана нести в цене эту книжную составляющую, иначе COGS растёт быстрее выручки на самых дешёвых покупках. Носитель — константа цены, а не код движка ⚠⚠ **НОСИТЕЛЬ УМЕР И ПОСЫЛКА ПЕРЕВЕРНУЛАСЬ — паком «форма заказа» 05.09, строка пере-написана ПО СУЩЕСТВУ.** Названные тут `TM_PLATFORM_USD_PER_CHAPTER` и `Pricing.Ceiling(глав)` удалены вместе со ставкой (строка бэклога 280). ⚠ И оговорка ряда «сегодня это НЕ дефект платформы: продажа идёт ГЛАВАМИ, так что нарезки, при которой накладные обгоняют полезную работу, в продукте нет» — **больше не верна**: этот же пак ввёл заказ В ЗНАКАХ, который разрешается в ПРЕФИКС ЮНИТОВ, то есть единицу МЕЛЬЧЕ главы, и ровно её ряд и ждал. **Что с этим сделано и чего НЕ сделано, раздельно.** Книжная составляющая ТЕПЕРЬ ВИДНА и названа: движок публикует её отдельным числом `book_once_usd` (плоские $2.00 на боевом `pipeline-c1`), платформа читает его и НЕ кладёт в основу холда — иначе короткая книга непокупаема, — а кладёт СВЕРХ, когда баланс несёт, и говорит покупателю `term_consistency_funded: false`, когда не несёт (`pricing.Model.Hold`, пин `TestAFlatBookLevelBoundDoesNotPutATwoDollarThresholdUnderEveryPurchase`). **НЕ сделано главное, ради чего ряд заведён:** цена мелкой покупки по-прежнему не несёт книжной составляющей ПРОПОРЦИОНАЛЬНО — заказ в один юнит и заказ во всю книгу видят один и тот же бонд, поэтому COGS на самых дешёвых покупках растёт быстрее выручки ровно так, как ряд и предупреждал. Ряд остаётся `open` и с этого дня ПРЕДМЕТЕН, а не гипотетичен. Носитель — `pricing.Model.Hold` и `ingest.BookPrice.BookOnceUSD`. | open | движковый пак «деньги» (охотник), передано оркестратором №19 сессии P11 | | PD-421 | hardening | info | `internal/pgstore/sessions.go` `StillLive` и `SweepSessions`, `docs/STACK_DECISIONS.md` §13 | **Открытый поток теряет свою сессию по ПОДМЕТАНИЮ строки, а не по клаузе бездействия, — и это остаток закрытия `PD-379`, названный прямо.** Проверка живости потока намеренно НЕ содержит клаузы `idle_expires_at`: окно бездействия скользит на ЗАПРОСЕ, а поток — один запрос на всю жизнь, поэтому гашение по idle рвало бы связь активному читателю. Но `SweepSessions` раз в час УДАЛЯЕТ строки и по бездействию тоже, а «строки нет» ОБЯЗАНО значить «мертва» — иначе отозванная сессия держала бы поток до свипа, то есть дыра ровно в час. Следствие: сессия, протухшая по бездействию и подметённая, теряет поток с опозданием до часа. Это не idle гасит поток, а отсутствие строки; к тому моменту любой другой запрос того же вызывающего — 401. Лечение, если сочтётся недопустимым, — скольжение окна бездействия ИЗ потока, но это правка ПОЛИТИКИ §13: открытая вкладка держала бы сессию до абсолютного потолка, а это слово владельца | open | пак P11 (назван при закрытии `PD-379`) | | PD-422 | bug | info | `internal/runs/runs.go:408`=`resnapshot := book.BankMoved || book.HasPriorRun`, `internal/pgstore/books.go:1034`=`HasPriorRun bool` | **`--resnapshot` платформа передаёт УСЛОВНО, а условие ставит только ПРАВКА банка — рост авто-банка от майнинга его не ставит.** Флаг выводится под `if book.BankMoved`, а единственный писатель `bank_moved_at` — дверь правок банка. На книге, которая МАЙНИТ банк, вторая покупка без правок банка идёт без флага, и движковый джоб-гард останавливает прогон (`exit 1` ⇒ `failed` на стороне платформы): авто-банк растёт от покупки к покупке, edit-снапшот съезжает, а гард банк-онли-движение от смены конфига не отличает. ⚠ Сегодня БЕСПРЕДМЕТНО: проводка `tmctl translate --max-units` на платформе гейчена оркестратором до лечения, а без неё вторая покупка этой формы не возникает. Строка заведена, чтобы условность не всплыла сюрпризом при снятии гейта. Найдено бэкенд-сессией `textmachine-e4` (пак «деньги»), проверено чтением платформенной стороны сессией P11 ⚠ **БЕСПРЕДМЕТНОСТЬ КОНЧИЛАСЬ И ДЕФЕКТ ЗАКРЫТ ТЕМ ЖЕ ПАКОМ (05.09), статус флипает ЛЕНДИНГ.** Гейт на проводку `--max-units` снят строкой 280, флаг едет в argv — значит условность `--resnapshot` перестала быть теоретической ровно в тот момент. Условие расширено: `resnapshot := book.BankMoved || book.HasPriorRun` (`internal/runs/runs.go`, греп `book.HasPriorRun`). Довод, почему флаг на КАЖДОМ продолжении безопасен: гард срабатывает ПО ДЖОБУ, то есть только на главах, которых прогон касается, а объёмный потолок допускает НОВУЮ книгу прежде пере-делки (`backend/internal/pipeline/volume.go`, проход `unitFresh` затем `unitRework`) — продолжение тратит грант на недоставленные главы. Согласие при этом фондированное: собственный холд прогона, никогда бланкетная форма. Пин: `runs.TestASecondPurchaseCarriesResnapshotEvenWithoutABankCorrection` (первая покупка флага НЕ несёт, вторая несёт, правки банка не было). | open | бэкенд-пак «деньги» + пак P11 (сверка шва) | | PD-439 | standards | info | `docs/DEFECT_REGISTER.md` (строки `PD-59`, `PD-115`, `PD-122`, `PD-273`, `PD-380`, `PD-407`, `PD-44`), шапка регистра (словарь статусов), `internal/gates/register_test.go` (греп `registerStatus`) | **Семь строк несут в колонке статуса не статус, и каждая из них невидима для всякого счёта, который эту колонку читает.** Словарь шапки — `open` · `fixed()` · `accepted-risk(<кем, когда>)`; в дереве встречаются `open (наблюдаемость закрыта P5; ops и конфигурация — нет)`, `open (грейс-половина закрыта D39.162)`, `open (добавление закрыто P5; удаление — нет)`, `closed (решение владельца 17.08)`, `fixed` без коммита (дважды) и `**закрыт ратификацией**, работа уходит строкой 103`. Зонный `awk` сверяет ячейку с `open` ТОЧНО, поэтому три аннотированных открытых ряда не попадают ни в одно число, которое зона печатала (включая «open=96»), а `fixed` без коммита нарушает правило «закрытие — только с коммитом фикса». ⚠ Найдено новым гейтом класса: он читает статус по словарю с аннотацией, считает такие ряды открытыми, называет нечитаемые поимённо и краснеет, если нечитаемый ряд стоит под заголовком «Открытые» и несёт маркеры тревоги. Строки НЕ правлю: смена статуса — акт лендинга, а здесь под вопросом и форма, и содержание вердикта | open | самопроверка пака P13 (гейт класса, первый прогон) | ## Принятый риск Не дефекты, а решения: цена названа и принята, чтобы это не выяснилось молчанием. | ID | Класс | Серьёзность | Где | Суть | Статус | Источник | |---|---|---|---|---|---|---| | 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` разделом «Откат релиза: не ниже версии 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 | ## Закрытые ратификацией Строки, у которых лечением был не код, а решение владельца контракта или оркестратора. | ID | Класс | Серьёзность | Где | Суть | Статус | Источник | |---|---|---|---|---|---|---| | PD-59 | bug | info | `docs/platform-PROGRESS.md`, вопрос оркестратору №4 | **Канал не меняем — но решает это не тот довод, который обсуждали.** Вопрос вынесен абстрактно (без контекста репозитория) двум независимым агентам, с доступом в сеть и без. По каналу они РАЗОШЛИСЬ, зато независимо сошлись на трёх вещах, которых не было ни в записке зоны, ни в первых трёх редакциях приёмки. **(1) SIGPIPE зависит от НОМЕРА дескриптора** (`os/signal`: обрыв на fd 1/2 убивает процесс, на любом другом — возвращает `EPIPE`). **Замерено:** поток на fd 1 → ребёнок УБИТ `broken pipe`; на fd 3 → `write` вернул EPIPE и процесс доработал до конца. Для нас это деньги: сегодня падение платформы убивает движок на следующей же записи события, а после переезда движок станет сиротой и часами будет жечь оплаченные вызовы, пока холд висит в леджере и некому его закрыть. Свойство несущее и нигде не записано. **(2) Настоящая защита — не выбор канала, а перехват на уровне дескриптора** в `main` движка: `dup(1)` в приватный fd, затем `dup3(2,1,0)`. Он герметичен там, где предложенный приёмкой `os.Stdout = os.Stderr` дыряв: переживает `var out = os.Stdout` в зависимости, cgo и унаследованный fd 1 у внуков. **(3) Дискриминатор, при котором переезд был бы прав** — «ребёнок исполняет чужой код, наследующий stdio». **Проверено: у нас нет** — `grep` по `backend/` не находит ни одного `exec.Command` вне тестов и ни одного cgo. Плюс сверено: ловушка `bufio.Scanner`, которую оба назвали самым вероятным латентным багом (переполнение строки читается как чистый EOF), у нас закрыта — `Buffer` поднят до 1 МиБ и `sc.Err()` проверяется (`decoder.go:45,115`) | **закрыт ратификацией**, работа уходит строкой 103 | приёмка P1 (четвёртая итерация: два независимых агента + замер SIGPIPE) | ## Закрытые — эра P1 (вход · кредиты · админ-CLI) | ID | Класс | Серьёзность | Где | Суть | Статус | Источник | |---|---|---|---|---|---|---| | PD-1 | hardening | minor | `internal/pgstore/pg_test.go:89` | Свойство «в БД только SHA-256, не токен» НЕ запинено тестом: посадка «`Digest` возвращает плейнтекст» выживает — тест сверяет хранимое через тот же `auth.Digest` (self-consistent). — **закрыто:** `internal/pgstore/pg_test.go` — `TestStoredCredentialIsAHashNotTheToken`: оракул SHA-256 считается в тесте, плюс поиск плейнтекста в отрендеренной строке. Посадка «`Digest` возвращает плейнтекст» ПАДАЕТ (проверено) | fixed(P1, дерево сессии) | приёмка P0 (посадка №1) | | PD-2 | vuln | **major, ЖИВАЯ (не латентная)** | `cmd/tmplatformd/main.go:73-82` | Нет `ReadTimeout` ⇒ соединения пиннятся уже СЕГОДНЯ, без единой body-принимающей ручки: `net/http` дренирует непрочитанное тело <256 КБ ВНУТРИ `chunkWriter.writeHeader` до отправки заголовка ответа (`net/http/server.go:1389-1435`), и этот чтение-шаг наследует отсутствующий дедлайн. **Репродуцировано оркестратором на собранном бинаре:** 50 полу-кормленных POST на охраняемый `/v0/*` → сервер отработал и залогировал 50×401 `ms:0`, клиенты получили НОЛЬ байт, fd 7→57 и держались, пока не закрыл КЛИЕНТ (агент-скептик независимо пинил 500 соединений). Ограничителя соединений и документированного edge-прокси в зоне нет. — **закрыто:** `ReadTimeout` 30 с в `httpapi.DefaultTimeouts`; пин — `TestHalfFedRequestIsDroppedByTheServer` на РЕАЛЬНОМ `http.Server`. Живая проба: полу-кормленный POST теперь отпускается через 30.0 с (был бесконечно). Вторая половина закрыта `LimitBody` на поддереве `/v0` и `/auth`. Побочное обязательство «`ReadTimeout` рубил бы и SSE» ОПРОВЕРГНУТО в P2 (PD-51/PD-63): `net/http` снимает дедлайн сам, помощник `ClearReadDeadline` удалён как воспроизводивший ровно этот дефект; поток пинит `TestStreamOutlivesReadTimeout` | fixed(P1, дерево сессии) | приёмка P0 (security-линза + скептик + собственная репродукция) | | PD-3 | bug | minor | `internal/httpapi/middleware.go:57` | `Recover` логирует сырой `r.URL.Path` на ERROR — id книг/прогонов утекают в лог, против собственной дисциплины AccessLog (route-pattern, не путь) — **закрыто:** `Recover` логирует `route`, не `r.URL.Path` | fixed(P1, дерево сессии) | приёмка P0 (security-линза) | | PD-4 | hardening | minor | `internal/pgstore/sessions.go:41` | WHERE у `Touch` слабее, чем у `Lookup` (нет `idle_expires_at > now`): прямой вызов воскресил бы idle-истёкшую сессию. Через `Require` недостижимо (Touch только после успешного Lookup) — **закрыто:** клауза `idle_expires_at > $2` добавлена; пин — `TestTouchCannotResurrectAnIdleExpiredSession` (посадка падает) | fixed(P1, дерево сессии) | приёмка P0 (security-линза) | | PD-5 | bug | minor | `internal/auth/middleware.go:36,45` | Ошибки стора невидимы: сбойный `Lookup` → 401 без единой строки лога (аутентификационный DB-outage выглядит как шторм 401), `Touch` глотается `_ =`. На проводе различать нельзя (оракул) — но лог обязан различать — **закрыто:** `Authenticator.Log`: сбой `Lookup` (кроме `ErrNoSession`) и сбой `Touch` уходят в ERROR с `request_id`; на проводе по-прежнему неразличимо | fixed(P1, дерево сессии) | приёмка P0 (security+blind линзы) | | PD-7 | bug | info | `internal/pgstore/sessions.go:78` | `DeleteExpiredSessions` никем не вызывается — **закрыто:** свип сессий раз в час в демоне (`sweepSessions`), плюс свип брошенных логинов раз в 15 минут | fixed(P1, дерево сессии) | приёмка P0 | | PD-8 | hardening | info | `internal/auth/session.go:18` | Писателя куки ещё нет; `__Host-` требует Secure ⇒ локальный dev по HTTP куку не поставит. — **закрыто:** `auth.Cookies{Insecure}` — dev-профиль меняет ИМЯ вместе с атрибутами (`tm_session` без `__Host-`), `TM_PLATFORM_INSECURE_COOKIES=1`, демон предупреждает в лог | fixed(P1, дерево сессии) | приёмка P0 | | PD-9 | bug | minor | `cmd/tmplatformd/main.go:81` | `BaseContext` возвращает signal-контекст ⇒ SIGTERM мгновенно рубит контексты ВСЕХ in-flight запросов, и 15-секундный дренаж `Shutdown` мёртв для ctx-aware хендлеров. — **закрыто:** `BaseContext` — собственный контекст, отменяется ПОСЛЕ `Shutdown`; пин — `TestShutdownDrainsInFlightRequests` (посадка «BaseContext = сигнальный ctx» падает) | fixed(P1, дерево сессии) | приёмка P0 (faults-линза) | | PD-10 | bug | minor | `internal/ingest/decoder.go:42,61,67` | Три ужесточения декодера: (а) `hello` с пустым `engine_run_id` принимается — а это половина ключа идемпотентности; (б) seq самого hello не пинится к 1 — потеря пре-хендшейковых строк недетектируема; (в) mid-stream `hello` (любой версии, вкл. мажор 9.9) уходит в Sink как обычное событие — version-гейт держит только строку 1 — **закрыто:** три ужесточения + `ErrBadHandshake`/`ErrRepeatedHello`; пины — `TestHandshakeMustIdentifyTheStream` и `FuzzDecoder` (4.4 млн исполнений, инварианты — оракулы) | fixed(P1, дерево сессии) | приёмка P0 (faults-линза) | | PD-11 | bug | minor | `internal/pgstore/migrations/00002_readmodel.sql:137,177` | Неиндексированные FK-каскады: `notes.chapter_id` и `bank_decisions.term_id` — каскадное удаление сканирует таблицы — **закрыто:** `notes_chapter_idx` + `bank_decisions_term_idx`; `notes.unit_id` уже был | fixed(P1, дерево сессии) | приёмка P0 (faults-линза) | | PD-12 | bug | info | `internal/ingest/supervisor.go:82-84` | Сбой Sink в начале прогона ⇒ платформа дренирует ВЕСЬ оставшийся поток в `io.Discard` часами: ceiling/bank_stop-события выбрасываются, никто не оповещён. — **закрыто:** сбой синка ОСТАНАВЛИВАЕТ прогон (`stop()` после `Ingest`), а не дренирует его в `io.Discard`; пин — `TestFailingSinkStopsTheRun`. Политика ретраев самого синка — при постройке материализатора | fixed(P1, дерево сессии) | приёмка P0 (faults-линза) | | PD-13 | bug | info | `internal/ingest/supervisor.go:64` | Краш платформы осиротляет процесс движка: ни process-group, ни pidfile, ни пути реаттача (поток невосстановим, повторный спавн упрётся в EXCLUSIVE-лок) — **закрыто:** группа процессов (`Setpgid` + сигнал группе) закрывает обычную остановку; краш платформы закрывает cgroup юнита — `deploy/tmplatformd.service` (проверен `systemd-analyze verify`, живого прогона под systemd не было) | fixed(P1, дерево сессии) | приёмка P0 (faults-линза) | | PD-14 | hardening | info | `internal/httpapi/server.go:79` | `readyz`: ping без собственного таймаута (WriteTimeout нет намеренно — SSE), эндпоинт неаутентифицирован и без rate-limit — задушить дешёво — **закрыто:** собственный таймаут 2 с на ping; rate-limit на ops-слое (edge), в зоне не строим | fixed(P1, дерево сессии) | приёмка P0 | | PD-15 | bug | info | `internal/ingest/resync.go:32` | Деньги в ре-синке — float64, а `usage_windows` хранит micro-USD именно против дрейфа: дрейф входит шагом раньше (JSON-парс + суммирование дельт) — **закрыто:** деньги на шве — `money.MicroUSD` через `big.Rat`, округление ВВЕРХ; пины — `TestSpendConvertsExactlyAndRoundsUp`, `TestParseUSDIsExactAndRoundsUp` | fixed(P1, дерево сессии) | приёмка P0 (faults-линза) | | PD-16 | bug | minor | `internal/httpapi/server.go:81` | `readyz` глотает ошибку ping вопреки собственному комменту «the reason stays in the log» — лога нет — **закрыто:** ошибка ping уходит в ERROR | fixed(P1, дерево сессии) | приёмка P0 (blind-линза) | | PD-17 | bug | minor | `Makefile:41-42` | Баннер «did NOT run (no database)» печатается и при ПРОГНАННЫХ БД-тестах (безусловный); батарея гоняет сьют дважды ради имён скипов (второй прогон без -race) — **закрыто:** один прогон сьюта под `-race`, баннер печатается только при наличии скипов | fixed(P1, дерево сессии) | приёмка P0 (blind-линза) | | PD-18 | bug | info | `internal/pgstore/migrations/00002_readmodel.sql:9,139` | Коммент шапки «engine vocabulary never crosses this seam» противоречит `notes.reason` (движковая причина хранится, не проецируется) — **закрыто:** шапка миграции переписана: исключение (`notes.reason`) названо там же | fixed(P1, дерево сессии) | приёмка P0 (canon-линза) | | PD-19 | bug | info | `internal/ingest/resync.go:44` | `WorstFlagReason` задокументирован «stored», а колонки в `chapters` нет — **закрыто:** `WorstFlagReason` убран из аллоулиста — в контракте v0 у главы нет читателя для него | fixed(P1, дерево сессии) | приёмка P0 (canon-линза) | | PD-20 | bug | minor | `internal/ingest/supervisor.go:78` | Один сигнал остановки ТЕРЯЕТСЯ, если послан в первые миллисекунды жизни ребёнка: воспроизведено на стенде отдельным экспериментом (8 запусков, промах на нулевой задержке) и как флейк собственного теста PD-12 (1 падение из 3). Последствие серьёзнее самого промаха: единственный оставшийся механизм — SIGKILL по `WaitDelay`, а движок держит ЭКСКЛЮЗИВНЫЙ лок на файле проекта, и после kill лок остаётся — **закрыто:** `askToStop` повторяет SIGINT на 30/120/400 мс с проверкой «процесс ещё наш» через `os.Process`; пин — `TestFailingSinkStopsTheRun` (25 прогонов подряд зелёные, до фикса падал) | fixed(P1, дерево сессии) | самопроверка P1 (флейк собственного теста) | | PD-21 | vuln | minor | `internal/login/login.go:safeReturnTo` | Открытый редирект в `?return_to`: `/\evil.example` проходил проверку — `url.Parse` читает это как обычный путь, а браузер нормализует `\` в `/` и получает протокол-относительный URL, то есть чужой хост. Найдено ПОСАДКОЙ мутации: ослабление проверки тест пережило, значит тест был слабый — **закрыто:** аллоулист (первый символ `/`, второй не `/`, обратных слэшей нет, `Scheme`/`Host`/`Opaque` пусты), тест переписан на «каждый враждебный вход даёт ПУСТО»; посадка теперь падает. Дефект не покидал дерево сессии | fixed(P1, дерево сессии) | самопроверка P1 (посадка мутации) | | PD-24 | bug | **major** | `internal/pgstore/migrations/` | Переиспользование номера миграции: удалённый `00003_usage.sql` и новый `00003_credits.sql` заняли одну версию. goose применяет ТОЛЬКО по номеру (ни имени, ни хеша), поэтому база, доехавшая до версии 3, рапортует «migrations applied» и не получает ни одной новой таблицы, вход и кредиты падают в рантайме, а `DownTo` на ней ломается навсегда. Обоснование «до деплоя правим на месте» было допущением без механизма — **закрыто:** выпущенные 00001–00003 возвращены байт-в-байт, новое приехало номерами 00004–00007; гейт `migrations.sha256` + `TestReleasedMigrationsAreUnchanged`; апгрейд со старого релиза пинится `TestDatabaseAtAnOlderReleaseCatchesUp` | fixed(P1, дерево сессии) | ревью «вне карты» (исполнением) | | PD-25 | bug | **major** | `internal/pgstore/credits.go`, `00007_credits.sql` | Ключ идемпотентности леджера не содержал `user_id`: грант с ключом, потраченным на другом аккаунте, молча проглатывался, а CLI печатал «granted». Плюс каскад удаления книги уносил ОТКРЫТУЮ резервацию, оставляя строку `hold` в леджере (деньги списаны, вернуть нечем), после чего освободившийся `engine_run_id` давал холд БЕЗ списания, а его релиз печатал деньги — **закрыто:** ключ стал `(user_id, source, source_id)`, пустой ключ запрещён DDL, `book_id` перешёл на составной FK к `books(id, owner_id)` с `on delete restrict`, `appendLedger` возвращает «применилось», `Hold` падает при повторе. Пины: `TestBookWithAnOpenHoldCannotBeDeleted`, `TestSecondHoldOnOneAttemptIsRefused`, `TestGrantIsIdempotentBySource` | fixed(P1, дерево сессии) | ревью денежного пути (исполнением) | | PD-26 | bug | minor | `internal/pgstore/credits.go` | Инверсия порядка блокировок Hold↔Settle/Release: 41 взаимоблокировка на 300 раундов, замерено. `Settle`/`Release` брали строку резервации раньше баланса — **закрыто:** `lockBalance` первым во всех операциях | fixed(P1, дерево сессии) | ревью денежного пути (исполнением) | | PD-27 | bug | minor | `internal/pgstore/credits.go` | `Settle` принимал любую сумму: одно завышенное `committed_usd` уводило баланс в минус, дальше каждый прогон получал `ErrInsufficientCredit` без диагностики — **закрыто:** расчёт capped потолком холда, факт записан в `note`; пин `TestSettlementIsCappedAtTheHold` | fixed(P1, дерево сессии) | ревью денежного пути · ревью «вне карты» | | PD-28 | bug | minor | `internal/ingest/supervisor.go` | `cmd.Wait()` на отменённой команде возвращает `context.Canceled`, а не `*ExitError`, поэтому исход читался как `failed`: штатный SIGTERM пометил бы ВСЕ идущие прогоны провалившимися — **закрыто:** исход из `ProcessState`, факт остановки едет в ошибке; пин `TestStoppedRunKeepsTheEnginesOutcome` | fixed(P1, дерево сессии) | ревью стиля (клейм) + собственная проверка исполнением | | PD-29 | vuln | minor | `internal/login/login.go` | `GET /auth/callback` — неаутентифицированная ручка, ПИШУЩАЯ в БД, без лимита и без ретеншена: замерено 2000 строк за 2.28 с с одного хоста (~76 млн строк/сутки), строки отказов недостижимы через API и не удалялись никогда — **закрыто:** лимитер на колбэке, ретеншен журнала 180 дней свипом | fixed(P1, дерево сессии) | ревью безопасности (исполнением) | | PD-30 | vuln | minor | `internal/pgstore/identity.go` | Грант фри-тира выдавался за каждую новую пару `(provider, subject)` без учёта `email_verified`: провайдер с саморегистрацией превращал каждый новый `sub` в $5, потолок задавал только глобальный лимитер (~$864k/сутки на бумаге) — **закрыто:** грант только подтверждённой личности — НЕПОДТВЕРЖДЁННАЯ создаёт аккаунт с нулём, и его начисляют руками из админки. ⚠ Уточнено 08.08 (PD-104): ПОДТВЕРЖДЁННАЯ личность получает автогрант `TM_PLATFORM_SIGNUP_GRANT_USD` (дефолт $5, `config.go`), закрыт был только путь саморегистрации. ⚠ Продуктовое следствие — вопрос владельцу в журнале | fixed(P1, дерево сессии) | ревью безопасности (исполнением) | | PD-31 | bug | minor | `internal/login/login.go` | `discover` держал мьютекс на время сетевого вызова без таймаута: шесть параллельных входов при медленном IdP заняли 4/8/12/16/20/24 с вместо ~4 — **закрыто:** запрос вне лока, свой таймаут 5 с | fixed(P1, дерево сессии) | ревью безопасности (исполнением) | | PD-32 | vuln | minor | `internal/login/login.go`, `cmd/tmplatformd/main.go` | Имя провайдера захардкожено `"google"` независимо от issuer, а `State.Provider` писался и не сверялся: смена issuer тихо кладёт чужие `sub` в старое пространство имён (новые аккаунты, старые недостижимы), а при двух провайдерах стейт одного редимится колбэком другого (IdP mix-up) — **закрыто:** `TM_PLATFORM_OIDC_PROVIDER`, сверка `st.Provider` в колбэке | fixed(P1, дерево сессии) | ревью безопасности · ревью «вне карты» | | PD-33 | vuln | minor | `internal/auth/csrf.go` | Требование `X-TM-Client` снималось ЛЮБЫМ заголовком `Authorization`, включая мусорный: покрытие CSRF-слоя выбирал атакующий (сегодня упиралось в 401, но пережило бы любое послабление в `present`) — **закрыто:** снимает только валидный Bearer, через ту же функцию, что аутентифицирует | fixed(P1, дерево сессии) | ревью безопасности (исполнением) | | PD-34 | bug | minor | `internal/httpapi/serve.go`, `cmd/tmplatformd/main.go` | Две регрессии остановки: второй SIGTERM больше не прерывал дренаж (процесс жил ровно 15 с), а просроченный дренаж возвращал ошибку и давал exit 1 — при `Restart=on-failure` штатная остановка читается systemd как крах — **закрыто:** сигнал разрегистрируется при начале дренажа, просрочка логируется WARN и даёт exit 0, добавлена строка `stopped` | fixed(P1, дерево сессии) | ревью «вне карты» (исполнением) | | PD-35 | bug | minor | `internal/httpapi/middleware.go` | Лимит тела стоял самым внешним слоем, поэтому обещанное «ручка загрузки регистрирует свой, больший лимит» не работало: вложенный `MaxBytesReader` не может ослабить внешний, а контракт требует загрузку книги (23 МБ) — **закрыто:** лимит стал пер-маршрутным аргументом `guard` | fixed(P1, дерево сессии) | ревью «вне карты» | | PD-36 | hardening | minor | `internal/httpapi/middleware.go` | Не было HSTS, CSP и запрета фрейминга; `__Host-` защищает запись куки, а не первый навигационный запрос — **закрыто:** `Content-Security-Policy: default-src 'none'; frame-ancestors 'none'`, `X-Frame-Options: DENY`, HSTS в прод-профиле (в dev выключен: пин политики на localhost — долгая ошибка) | fixed(P1, дерево сессии) | ревью безопасности | | PD-37 | bug | minor | `internal/login/login.go` | `safeReturnTo` заявляла защиту, которой не давала: проверка обратного слэша работала по уже раскодированной строке, а браузер декодирует цель редиректа ещё раз (`/%5c/evil.example`). Эксплуатируемого редиректа не получено, но три проверки из четырёх держались на поведении браузера — **закрыто:** проверка обеих форм, теста добавлены процент-кодированные входы | fixed(P1, дерево сессии) | ревью безопасности (исполнением) | | PD-38 | hardening | info | `internal/pgstore/sessions.go`, `00005_identity_oauth.sql` | Отозванные сессии не удалялись до абсолютного срока (90 дней); журнал входов каскадно стирался вместе с аккаунтом, хотя объявлен доказательством для расследования — **закрыто:** свип берёт отозванные и idle-протухшие, `login_events.user_id` перешёл на `on delete set null` (строка анонимизируется, не уничтожается) | fixed(P1, дерево сессии) | ревью безопасности | | PD-39 | bug | info | `internal/money/money.go` | Док обещал округление «от нуля», код округляет к `+∞`; отрицательные дроби не были покрыты тестом вовсе. Плюс `USD()` на `MinInt64` печатал мусор, а вход не имел ограничения длины (2 МБ → 6.1 с и сообщение об ошибке на 2 МБ) — **закрыто:** док приведён к коду, отрицательные кейсы запинены, потолок длины 64 символа, рендер без отрицания | fixed(P1, дерево сессии) | ревью денежного пути · ревью стиля | | PD-40 | bug | info | `internal/ingest/resync.go` | Отсутствующий/`null`/пустой `committed_usd` декодировался в `0` — неотличимо от «попытка не стоила ничего»; на пути расчёта это освободило бы холд и не списало ничего — **закрыто:** `Spend` стал указателем, пустая строка — ошибка | fixed(P1, дерево сессии) | ревью «вне карты» | | PD-41 | bug | info | `internal/login/login.go`, `internal/httpapi/` | Поверхность `/auth/*` отвечала stdlib-телами `text/plain` на 404/405 вопреки нормативу «ответы problem+json»; ошибки стора и сработавший лимитер не логировались; паника писалась без стека; успешный вход не оставлял следа, а недоступность провайдера классифицировалась как «токен отвергнут» — **закрыто:** метод проверяется в обёртке с problem+json, добавлены `login succeeded`, `sign-in rate limit engaged`, `provider_unreachable`, стек паники, `login_start_id` для склейки двух половин входа | fixed(P1, дерево сессии) | ревью логов (исполнением) | ## Закрытые — эра P2 (фикс-пак приёмки P1) | ID | Класс | Серьёзность | Где | Суть | Статус | Источник | |---|---|---|---|---|---|---| | PD-46 | hardening | minor | `internal/httpapi/serve.go:33-40` | **Запинена ПРОВОДКА `ReadTimeout`, но не ЗНАЧЕНИЕ, с которым едет демон.** Тесты строят свой `Timeouts` (`fastTimeouts`), поэтому посадка «`DefaultTimeouts().Read = 0`» проходит ВСЮ батарею зелёной — а `main.go:121` берёт именно `DefaultTimeouts()`. Посадка «убрать `ReadTimeout` из `NewServer`» ловится (проверено), то есть дыра ровно в дефолтах. Это форма, в которой PD-2 пережил P0: свойство проверено не на том объекте, который едет в прод. — **закрыто:** `httpapi.TestTheServerTheDaemonRunsHasEveryDeadlineSet` утверждает не литералы, а сам `*http.Server`, который строит `NewServer(…, DefaultTimeouts())`: каждый дедлайн >0, `WriteTimeout` ОБЯЗАН быть нулём (иначе резал бы SSE), `ReadHeaderTimeout <= ReadTimeout`, grace >0. Закрывает обе половины — значение и проводку. Пять посадок поймано поимённо: `DefaultTimeouts().Read=0`, снятие `ReadTimeout` из `NewServer`, снятие `IdleTimeout`, добавление `WriteTimeout` «для симметрии», снятие `Unwrap` | fixed(P2, дерево сессии) | приёмка P1 (посадка M23/M43) | | PD-47 | bug | minor | `internal/login/login_test.go:266` | **Закрытие PD-37 заявлено неверно:** «в тесты добавлены процент-кодированные входы» — их там нет (список: `//evil.example/`, `https://…`, `http:/…`, `/\evil.example`, `/\/evil.example`, `/\tevil`, `evil.example`, ``). Посадка «судить только сырую форму, без второго декода» батарею ПЕРЕЖИВАЕТ. Побочно: посадка «убрать обратный слэш из `ContainsAny`» тоже переживает — на тестовых входах её дублирует проверка `s[1]`. — **закрыто:** в таблицу добавлены процент-кодированные входы (`/%5c/`, `/%5C/`, `/%09`, `/%00`, `/%0d%0a`) — их ловит ТОЛЬКО второй декод — и `/%2f/evil.example`, который ловит ТОЛЬКО проверка `s[1]` на декодированной форме; плюс `FuzzSafeReturnTo`, который пинит СВОЙСТВО независимым оракулом (`url.URL.ResolveReference` после браузерной нормализации `\`→`/`), 3,1 млн исполнений без контрпримера. Посадки «судить только сырую форму», «убрать класс символов», «убрать protocol-relative» падают каждая. ⚠ Побочно установлено: условия `u.Scheme/u.Host/u.Opaque` НЕДОСТИЖИМЫ как отказ (при `raw[0]=='/'` схемы и Opaque не бывает, Host требует `//`), пин на них невозможен — оставлены бэкстопом, это названо в коде | fixed(P2, дерево сессии) | приёмка P1 (посадки M15/M16) | | PD-48 | hardening | minor | `internal/login/login.go:268-271` | **Правило PD-30 «грант только подтверждённой личности» не запинено ничем:** удаление `if !claims.EmailVerified { grant = 0 }` проходит все тесты `internal/login`. `pgstore.TestUnverifiedAddressStaysOffTheAccount` пинит другое свойство (адрес не поднимается на аккаунт), денежное — никто. По правилу шапки этого файла PD-30 закрытым не считается — **закрыто:** `login.TestSignupGrantGoesOnlyToAVerifiedIdentity` гоняет обе ветки через настоящий поток и сверяет САМ грант, дошедший до стора (`memStore` теперь его запоминает — раньше отбрасывал, потому правило и было незапинено). Посадка «убрать условие `EmailVerified`» падает | fixed(P2, дерево сессии) | приёмка P1 (посадка M14) | | PD-49 | hardening | minor | `internal/login/login.go:239-242` | **Вторая половина PD-32 не запинена:** удаление сверки `st.Provider != h.cfg.Provider` проходит все тесты. Сегодня провайдер один, поэтому свойство латентное — но заведено оно ровно под появление второго (IdP mix-up) — **закрыто:** `login.TestStateFromAnotherProviderIsRefused` подменяет провайдера в сохранённой строке состояния — форма, которую даёт появление второго провайдера, — и требует 400, отсутствия сессии, причины `state_from_another_provider` в журнале и НУЛЯ обращений к token endpoint. Посадка «убрать сверку» падает. Норму при этом закрывает не она, а PD-57 | fixed(P2, дерево сессии) | приёмка P1 (посадка M19) | | PD-50 | hardening | info | `internal/auth/csrf.go:55-60` | Закрытие PD-33 сформулировано сильнее кода: «снимает только ВАЛИДНЫЙ Bearer» — на деле `Present` только ПАРСИТ, поэтому `Authorization: Bearer <мусор>` требование `X-TM-Client` снимает. Привилегии это не даёт, проверено живой пробой (кука + мусорный Bearer + без заголовка → 401, не хендлер): безопасность держит правило «Bearer побеждает куку» в `Present`, а не «валидность». Посадка «снимать любым непустым Authorization» батарею переживает. — **закрыто формулировкой + пином:** доккоммент `cookieUnsafe` переписан на то, что верно (`Present` ПАРСИТ, не валидирует; безопасность держит правило «есть `Authorization` ⇒ кука не участвует», а не валидность). `auth.TestAnAuthorizationHeaderTakesTheCookieOutOfPlay` пинит именно это на пяти формах заголовка; посадка «падать обратно на куку при неразобранном заголовке» падает | fixed(P2, дерево сессии) | приёмка P1 (посадка M30 + живая проба) | | PD-51 | bug | minor | `internal/httpapi/serve.go:118-127`, `STACK_DECISIONS §12` | **Механизм заявлен неверно.** Утверждение «`ReadTimeout` убил бы и поток, поэтому стриминговый хендлер ОБЯЗАН снять read-дедлайн» на Go 1.26.5 не подтверждается: `connReader.startBackgroundRead` сам делает `SetReadDeadline(time.Time{})` (`net/http/server.go:687-698`) и для запроса без тела вызывается ДО хендлера (`:2062`). Проверено исполнением на трёх формах запроса (GET без тела · POST с непрочитанным телом · POST с вычитанным телом) — поздний кадр доезжает во всех шести комбинациях, звали `ClearReadDeadline` или нет. Следствие: `TestStreamOutlivesReadTimeout` НЕ МОЖЕТ упасть от выхолащивания `ClearReadDeadline` (проверено); он пинит только `Unwrap` (эта посадка ловится). Код безвреден, ложны обоснование и строка в таблице пинов — **закрыто, и вывод приёмки уточнён исполнением:** механизм подтверждён (`startBackgroundRead` снимает дедлайн сам, `server.go:687-698`, для запроса без остатка тела — до хендлера, `:2059-2062`; по ходу хендлера не перевзводится — проверено по всем call sites). Но «код безвреден» неверно: см. PD-63. `ClearReadDeadline` УДАЛЁН, `STACK_DECISIONS §12` переписан, `TestStreamOutlivesReadTimeout` переписан на настоящее свойство (поток переживает `Read` БЕЗ действий хендлера) и пинит `Unwrap` через ошибку `Flush` | fixed(P2, дерево сессии) | приёмка P1 (посадка M24/M42 + отдельная проба) | | PD-52 | hardening | minor | `internal/pgstore/credits.go:233-243` | Порядок блокировок (PD-26) не запинен ни одним тестом — снятие `lockBalance` из `closeReservation` батарею переживает. Дефект воспроизведён приёмкой НЕЗАВИСИМО, в форме, которая действительно даёт цикл: конкурентные `Settle(run-1)` и повторный `Hold(run-1)` — **2 взаимоблокировки на 150 раундов с инверсией, 0 с фиксом**. Регрессионный тест написан приёмкой и лежит готовым к вставке в `docs/platform-PROGRESS.md`, раздел «Ратификация приёмкой P1». — **закрыто:** тест приёмки вставлен как `pgstore.TestHoldAndSettleOnTheSameAttemptDoNotDeadlock`. ⚠ Замер приёмки не копировался, а ПЕРЕПРОВЕРЕН на своём стенде (PostgreSQL 18.4): с инверсией падает 5 прогонов из 5, 5–10 взаимоблокировок на 150 раундов; с фиксом 5 прогонов из 5 зелёные. Замер сессии P1 «41 на 300» так и не воспроизведён и остаётся СО СЛОВ | fixed(P2, дерево сессии) | приёмка P1 (посадка M07 + собственная репродукция) | | PD-53 | hardening | info | `internal/httpapi/server.go:73-75` | `DefaultMaxBody` не запинен: поднятие лимита поддерева до 1 ГиБ батарею переживает. — **закрыто:** `httpapi.TestBodyCapIsPerRouteBecauseNestingOnlyTightens` фиксирует исполнением ПРИЧИНУ пер-маршрутности — вложенный БОЛЬШИЙ лимит не поднимает внешний, — поэтому возврат общего слоя молча урезал бы аплоуд-маршрут; `TestDefaultBodyCapStaysAContractSizedNumber` держит дефолт в полосе контрактного размера (посадка «1 ГиБ» падает), не превращаясь в change-detector на точное число | fixed(P2, дерево сессии) | приёмка P1 (посадка M26) | | PD-54 | bug | minor | зонный журнал, секция эры P0 (⚠ якорь на строку снят 22.08: описанное тело было УДАЛЕНО при закрытии строки, а сам журнал с тех пор дважды срезан в слайсы — адресовать было нечего) | В журнале ДВЕ несовместимые формы `GET /v0/usage`: новая кредитная (строка 172) и старая подписочная (строка 329) с `resets_at`, `windows[{period}]` и хранением в `usage_windows` — таблице, которую снесла миграция `00006`. Секция P0-эры не помечена superseded, а S3 идёт читать журнал именно за формой ручки — **закрыто:** подписочное тело ответа УДАЛЕНО из журнала, а не помечено баннером: S3 идёт туда за формой ручки и скопировал бы тело. Осталась одна форма — кредитная, в разделе «Что предлагаем в спеку (S3)»; из П-5 сохранены абзацы, не зависящие от модели денег, ссылка на хранение переведена на `credit_ledger` | fixed(P2, дерево сессии) | приёмка P1 (свип доков) | | PD-55 | bug | info | `deploy/tmplatformd.service` | `MemoryMax=2G` объявлен как «bounds the control plane», но ограничивает cgroup ЮНИТА — а по собственному аргументу этого же файла (закрытие PD-13) в этом cgroup живёт каждый ребёнок-`tmctl`. Значит потолок общий на платформу и все идущие прогоны, и OOM-killer выберет самый жирный процесс — движок, который держит ЭКСКЛЮЗИВНЫЙ лок на файле проекта: ровно тот исход, ради которого запрещён SIGKILL. То же про `TasksMax=512`. Латентно до появления воркера. ⚠ Под systemd не проверялось (нет sudo) — вывод из семантики `MemoryMax=`, не из замера — **закрыто:** семантика сверена по man 5 systemd.resource-control («absolute limit on memory usage of the executed processes in this unit… out-of-memory killer is invoked inside the unit»). `MemoryMax=2G` заменён на `MemoryMax=80%` — потолок машины, а не сервиса, как «last line of defense» и без знания о железе; `TasksMax=512` оставлен с честным комментарием, что покрывает платформу и прогоны вместе; ограничение ОДНОГО прогона названо работой воркера (transient scope). Побочно найдено и закрыто следствие, которого в этой строке не было, — PD-64. ⚠ Под systemd не запускалось (нет sudo); `systemd-analyze verify` (systemd 259) — exit 0 | fixed(P2, дерево сессии) | приёмка P1 (ревью деплой-юнита) | | PD-56 | bug | info | `internal/pgstore/credits.go:35-63` | `Grant`/`Adjust` на несуществующий аккаунт отдают оператору сырую ошибку Postgres с именем констрейнта (`credit_ledger_user_id_fkey`), тогда как `Balance` на том же входе отдаёт `ErrNoAccount`. Живая проба CLI. Косметика админ-поверхности, но опечатка в id читается как поломка БД — **закрыто:** `appendLedger` мапит нарушение `credit_ledger_user_id_fkey` в `ErrNoAccount`; `pgstore.TestMoneyOperationsAgreeOnAMissingAccount` требует одного ответа от `Grant`/`Adjust`/`Balance`/`ReadAccount`. Посадка «убрать сверку констрейнта» падает | fixed(P2, дерево сессии) | приёмка P1 (живая проба CLI) | | PD-57 | hardening | minor | `internal/login/login.go:239-242` | **Защита от IdP mix-up не та, что требует действующая норма.** RFC 9700 §2.1 (OAuth Security BCP, янв. 2025) — клиент SHOULD применять параметр `iss` из авторизационного ответа (RFC 9207) либо иной контрмер НА ОСНОВЕ `iss`; MAY — различные redirect URI на провайдера. Реализована собственная сверка `st.Provider` с `h.cfg.Provider`, а внутри одного хендлера это сравнение конфигурации с самой собой: `start` пишет туда то же значение. `iss` авторизационного ответа не читается вообще (`iss` ID-токена библиотека проверяет — это другой шаг и другой момент). Сегодня не эксплуатируемо: провайдер один, код всегда редимится у него же. Заведено потому, что регистр объявляет PD-32 закрытием «класса IdP mix-up», а против нормы это неверно, и при втором провайдере выбор (`iss` или раздельные redirect URI) должен быть ОСОЗНАННЫМ, а не побочным эффектом конфигурации — **закрыто реализацией нормы, а не обещанием.** Первоисточники сверены: RFC 9700 §4.4.2 («When an OAuth client can only interact with one authorization server, a mix-up defense is not required» — то есть СЕГОДНЯ несоответствия нет, требование включается со вторым сервером), §4.4.2.2 объявляет раздельные redirect URI фолбэком («SHOULD therefore only be used if other options are not available»); альтернатива «`iss` из ID-токена» нам не подходит — при чистом code flow токен приходит уже ПОСЛЕ отдачи кода. Выбран `iss` авторизационного ответа: **Google его шлёт** (`authorization_response_iss_parameter_supported: true`, сверено живьём). Сделано: `auth_states.issuer` (миграция 00008), сверка до обмена кода, отказ на СОРВАННОМ параметре у поддерживающего провайдера (RFC 9207 §2.4). Пин — `login.TestAuthorizationResponseIssuerIsChecked` (4 случая); посадки «убрать вызов», «убрать ветку несовпадения», «убрать ветку сорванного параметра», «потерять issuer в сторе» падают | fixed(P2, дерево сессии) | приёмка P1 (сверка с RFC 9700 §2.1 / RFC 9207) | | PD-58 | hardening | minor | `internal/config/config.go:60-61` | **Несоответствие собственной объявленной базовой линии.** `ENGINEERING_STANDARDS §2` берёт ASVS 5.0 L2, а L2 требует ДОКУМЕНТИРОВАТЬ сроки: 7.1.1 (срок бездействия и абсолютный предел + обоснование отклонений от NIST SP 800-63B), 7.1.2 (политика одновременных сессий), 7.1.3/7.6.1 (согласование срока НАШЕЙ сессии со сроком федеративной — у нас наша живёт своей жизнью, RP-initiated/back-channel logout нет). Сроки 14 суток бездействия и 90 суток абсолютных существуют только литералами в коде, обоснования нет ни в одном доке (проверено grep). Механические требования V7 при этом ВЫПОЛНЕНЫ и проверены: 7.2.3 энтропия (256 бит при требуемых 128), 7.2.4 ротация токена на аутентификации, 7.4.1 отзыв, 7.4.2 снос сессий при удалении аккаунта. ⚠ 7.4.5 (системный отзыв админом) покрыт только пер-пользовательским `revoke`; 7.5.2 (пользователь видит свои сессии) — работа П-1 — **закрыто, и значение выровнено вместо сочинения оправдания.** Тексты сверены дословно: ASVS 5.0 7.1.1/7.1.2/7.1.3, 7.6.1/7.6.2 и NIST SP 800-63B-4 §2.1.3 («overall timeout … SHOULD be no more than 30 days at AAL1; an inactivity timeout MAY be applied but is not required»). Абсолютный срок 90 суток был отклонением от SHOULD без причины, выдерживающей проверку, — снижен до **30 суток**; бездействие 14 суток остаётся и строже нормы. Документ — `STACK_DECISIONS §13`: уровень AAL1, оба срока, политика одновременных сессий (лимита нет — контракт предусматривает куку и Bearer одновременно; вместо лимита отзыв, «выйти везде» и журнал), рассогласование с федеративной сессией названо прямо (RP-initiated/back-channel logout нет), 7.6.2 выполнено. Пин — `config.TestSessionClocksStayWithinTheDeclaredBaseline` | fixed(P2, дерево сессии) | приёмка P1 (сверка с ASVS 5.0 V7) | | PD-62 | bug | minor | `internal/pgstore/identity.go:26-35`, миграция `00005` | **`login.State.StartID` не персистился: колонки под него не было.** Поле заведено в P1 и логируется колбэком как `login_start_id` — то есть ОБЕ строки лога, которые должны сшивать две половины входа, в проде пустые. Батарея этого не видела, потому что тесты `internal/login` ходят в in-memory стор, который хранит структуру целиком: свойство проверялось не на том объекте, который едет (тот же класс, что PD-46). Воспроизведено против живой БД раунд-трипом `PutLoginState`→`TakeLoginState`: положили `REQ-ABC123`, получили `""` — **закрыто:** колонки `start_id` и `issuer` добавлены миграцией `00008`, `Put`/`Take` их несут; пин — `pgstore.TestLoginStateIsSingleUseAndExpires` сравнивает структуру ЦЕЛИКОМ (`reflect.DeepEqual`), поэтому следующее поле без колонки упадёт здесь же. Посадки «потерять start_id» и «потерять issuer» падают | fixed(P2, дерево сессии) | сессия P2 (найдено при правке PD-57) | | PD-63 | vuln | minor | `internal/httpapi/serve.go:118-127` (удалён) | **`ClearReadDeadline` воспроизводил PD-2 — тем самым вызовом, который был заведён как его исправление.** Доккоммент объявлял его ОБЯЗАТЕЛЬНЫМ для стримингового хендлера. На полу-кормленном запросе (тело анонсировано, не дослано) дренаж внутри записи заголовка ответа — единственная граница соединения, и ограничен он `ReadTimeout`; снятие дедлайна ДО записи заголовка эту границу убирает. Замерено: хендлер остаётся внутри `WriteHeader` и через 4 с после ухода клиента, соединение держится. Вызов после флаша бесполезен — контекст уже отменён дренажем. Приёмка (PD-51) заключила «код безвреден», проверив только корректные запросы; случая, где функция помогает, нет вовсе — **закрыто:** функция УДАЛЕНА, §12 переписан, пин — `httpapi.TestHalfFedStreamingRequestIsCutLoose` (контекст стримингового хендлера отменяется в пределах `Read`) | fixed(P2, дерево сессии) | сессия P2 (собственный замер при верификации PD-51) | | PD-64 | bug | minor | `deploy/tmplatformd.service` | **Дефолтный `OOMPolicy=stop` уронил бы платформу из-за одного прожорливого прогона.** Следствие того же факта, что PD-55 (дети-`tmctl` живут в cgroup юнита), но в той строке не названо: по man 5 systemd.service дефолт берётся из `DefaultOOMPolicy=` (системный — `stop`), а `stop` означает «the unit's processes are terminated cleanly by the service manager» — то есть OOM-килл ОДНОГО `tmctl` останавливает контрол-плейн и все остальные прогоны, после чего юнит уходит в `oom-kill` failed и его подхватывает `Restart=on-failure` — **закрыто:** `OOMPolicy=continue` проставлен явно с обоснованием; платформа переживает килл ребёнка и штатно закрывает его резервацию. ⚠ Под systemd не проверялось (нет sudo) — вывод из доки; `systemd-analyze verify` (systemd 259) — exit 0 | fixed(P2, дерево сессии) | сессия P2 (ревью деплой-юнита при PD-55) | | PD-65 | vuln | minor | `internal/login/login.go:367-382` | **Обмен кода и загрузка JWKS шли БЕЗ дедлайна**, тогда как discovery на том же пути ограничивает себя пятью секундами и называет причину («`http.DefaultClient` не имеет собственного таймаута, а вызов делается, пока человек ждёт»). В проде `httpClient` равен nil, поэтому обмен идёт на `http.DefaultClient`, а go-oidc строит набор ключей от `context.Background()`; `WriteTimeout` у сервера нет по проекту — значит издатель, который принял соединение и не отвечает, держит хендлер, пока клиент сам не уйдёт. Хуже того, набор ключей ОБЩИЙ: одна зависшая загрузка паркует ВСЕ параллельные входы (замерено ревью: два независимых входа ждали 12 с за одной загрузкой) — **закрыто:** `identify` ограничен `providerTimeout` 10 с; пин — `login.TestAStalledProviderDoesNotHoldTheCallback` на обеих ногах (token и keys), посадка «убрать дедлайн» падает | fixed(P2, дерево сессии) | ревью P2 (линза oidc-security, подтверждено верификатором на боевой проводке) | | PD-66 | bug | minor | `internal/httpapi/serve_test.go`, `cmd/tmplatformd/main.go:121` | **Мой собственный фикс PD-46 закрывал только половину и утверждал, что обе.** Тест строил свой сервер `NewServer(…, DefaultTimeouts())` и на него же смотрел; проводка демона осталась ненаблюдаемой, а `cmd/tmplatformd` тестов не имеет. Замерено ревью: замена аргумента на `Timeouts{Shutdown: 15s}` оставляет `make check` зелёным (0 issues) и бинарь снова пиннит соединения — PD-2 в полном объёме. То есть ровно та форма, которую PD-46 и называл: свойство проверено не на том объекте — **закрыто устранением КЛАССА, а не тестом:** `NewServer` больше не принимает `Timeouts` и берёт `DefaultTimeouts()` сам, передавать нечего; коротким дедлайнам тестов служит неэкспортируемый `serverWithTimeouts` | fixed(P2, дерево сессии) | ревью P2 (линза net-http) | | PD-67 | vuln | minor | `internal/pgstore/credits.go:236` | **`FOR UPDATE` не был запинен ничем, а комментарий теста утверждал обратное** («Mutation caught: … or the FOR UPDATE that serialises it»). Последовательный тест лока не видит по построению, а инвариант «кэш = леджер» его тоже не ловит: без лока кэш и леджер уезжают ВМЕСТЕ, оба в минус. Лок — единственное, что мешает двум прогонам потратить один и тот же кредит — **закрыто:** `pgstore.TestConcurrentHoldsCannotOvercommitAnAccount` — 60 раундов по два конкурентных холда, каждый по отдельности посильный, вместе нет; посадка «убрать `for update`» падает 3 прогона из 3, баланс уходит в −$2. Комментарий последовательного теста исправлен | fixed(P2, дерево сессии) | ревью P2 (линза sql-money) | | PD-68 | bug | minor | `internal/httpapi/server.go:111` | **`/readyz` рапортовал «готов» на базе БЕЗ схемы.** Готовность доказывалась одним `Ping`, который успешен на любом достижимом Postgres, включая пустой. `Migrate` выключен по умолчанию, а deploy-инструкция делает миграцию отдельным шагом — значит «процесс поднят, схема не накачена» это НОРМАЛЬНАЯ середина выката, и инстанс в этом окне отвечал 200 `ready`, проваливая каждый запрос, который затем обслуживал — **закрыто:** `Store.Ready` сверяет `goose_db_version` с максимальным номером миграции, вшитой в бинарь; схема ВПЕРЕДИ бинаря готовности не отменяет (иначе выкат ронял бы старый инстанс). Пин — `pgstore.TestReadinessRefusesADatabaseWithoutTheSchema`, посадка «свести готовность к `Ping`» падает. | fixed(P2, дерево сессии) | ревью P2 (линза вне карты) | | PD-69 | bug | minor | `internal/pgstore/store.go:42` | **Явный `pool_max_conns` из DSN молча отбрасывался.** Проверка «MaxConns равен дефолту pgxpool» не отличает «оператор не выбирал» от «оператор выбрал ровно это число»: pgxpool кладёт свой дефолт в то же поле, что `ParseConfig` заполняет из `pool_max_conns`. Оператор, порезавший реплику под бюджет `max_connections`, получал наш 16 вместо своих 8 — и наоборот на 32-ядерной машине. С `pool_min_conns` хуже: дефолт pgx равен 0, поэтому явный 0 не мог пережить проверку НИКОГДА — **закрыто:** вопрос «упоминает ли DSN этот ключ» задан ПАРСЕРУ pgx, а не значению: `pgxpool` достаёт `pool_*` из `RuntimeParams` и удаляет их, поэтому второй `pgx.ParseConfig` их ещё видит — обе формы DSN, кавычки и service-файлы бесплатно. Пин — `pgstore.TestExplicitPoolSizesInTheDSNSurvive`, включая случай, сломавший ПРЕДЫДУЩУЮ эвристику: пароль, содержащий имя ключа | fixed(P2, дерево сессии) | ревью P2 (линза вне карты) | | PD-70 | bug | **major** | `internal/auth/middleware.go:57`, `internal/auth/cookie.go:62` | **Скользящее окно бездействия для БРАУЗЕРА не работало: Max-Age куки пишется один раз, на входе, и больше никем.** Серверная строка скользила (`Touch`), кука — нет, а `SetSession` зовётся ровно из одного места — колбэка входа. Следствие: для куки — единственной презентации, которую код вообще умеет выдавать, — срок жизни сессии был ФИКСИРОВАННЫЕ 14 суток от входа независимо от активности; человек, заходящий каждый день, выкидывался на 14-е сутки при живой серверной сессии, а абсолютный срок не мог наступить никогда. Это же делало ложным §13 — документ соответствия ASVS 7.1.1, который зона только что написала — **закрыто:** при скольжении окна кука переиздаётся с тем же токеном (ротация — акт границы входа, не скольжения); пин — `auth.TestSlidingTheIdleWindowRefreshesTheBrowsersCookie` (четыре случая: кука во второй половине окна, свежая кука, Bearer, упор в абсолютный срок). ⚠ Срок куки — `min(idle, остаток абсолютного)`: безусловный idle-TTL дал бы куку, пережившую абсолютный срок, и 401 вместо чистого «вы вышли» | fixed(P2, дерево сессии) | ревью P2 (линза doc-vs-code) | | PD-73 | vuln | minor | `internal/login/login.go:67-74`, `:126` | **Дедлайн `identify` ограничивал ОЖИДАЮЩЕГО, а не саму загрузку ключей — то есть мой фикс PD-65 был неполон.** `Provider.Verifier` берёт набор ключей, построенный на discovery, а go-oidc хранит его через `context.WithoutCancel` и ходит за ключами на `http.DefaultClient`, у которого таймаута нет. Загрузка, которая зависла, продолжает висеть после того, как ожидающий сдался, и все последующие входы встают на тот же `inflight` — то есть вход не поднимается и после того, как эндпоинт выздоровел, вплоть до перезапуска процесса. Воспроизведено ревью на боевой проводке — **закрыто:** `New` ВСЕГДА ставит `httpClient` с таймаутом `providerTimeout`, клиент передаётся `NewProvider` безусловно (`oidc.ClientContext`), и его подхватывает набор ключей; nil-случая больше нет — класс устранён, а не покрыт тестом. Пины — `TestTheDefaultProviderClientIsBounded` (посадка «клиент без таймаута» падает) и `TestAHungKeyFetchDoesNotPoisonLaterSignIns` (вход ПОСЛЕ выздоровления эндпоинта обязан пройти) | fixed(P2, дерево сессии) | ревью P2 (линза the-fixes) | | PD-74 | bug | minor | `internal/auth/middleware.go:57` | **Скольжение окна залипало на последней четверти жизни сессии: каждый запрос становился записью.** `Touch` прижимает новый дедлайн через `least(now+IdleTTL, absolute_expires_at)`, поэтому как только `now+IdleTTL` перевалил за абсолютный потолок, `idle_expires_at` больше не двигается — а условие «осталось меньше половины окна» с этого момента истинно ВСЕГДА. На горячем пути это UPDATE по первичному ключу таблицы сессий и `Set-Cookie` на каждом аутентифицированном запросе (после PD-70 — ещё и кука). Найдено двумя линзами независимо — **закрыто:** скольжение выполняется только пока `IdleExpiresAt` строго меньше `AbsoluteExpiresAt`; пин — `auth.TestTheSlideStopsOnceItCannotMoveTheDeadline` (пять чтений дают ноль записей, а сессия с запасом по-прежнему скользит) | fixed(P2, дерево сессии) | ревью P2 (линзы session-security и вне карты, независимо) | | PD-75 | bug | minor | `cmd/tmplatformctl/main.go` `write()` (греп `Past this point the write is committed`) | **CLI сообщал о ПРИМЕНЁННОМ начислении как о провале, а повтор начислял второй раз.** `write` выполняет денежную операцию, затем отдельным запросом читает баланс, и ошибку ЧТЕНИЯ возвращает как результат команды. Оператор видит ошибку, повторяет — а `--key` необязателен, и без него `newKey` чеканит новый ключ идемпотентности, поэтому второй прогон начисляет ещё раз. Достаточно обрыва соединения между двумя запросами — **закрыто:** после коммита команда не может отчитаться провалом; баланс читается как любезность, его отказ печатается предупреждением на той же строке | fixed(P2, дерево сессии) | ревью P2 (линза вне карты) | | PD-76 | bug | minor | `internal/login/login_test.go` | **Определяющее свойство пакета — «ничего выданного провайдером не персистится» — проверялось утверждением, которое не могло упасть.** `memStore.notes` объявлено и не заполнялось ни одним методом, поэтому `strings.Join(notes)` всегда пусто, а `Contains` всегда ложно. Свойство названо в доккомменте пакета первой строкой — **закрыто:** мок пишет в `saw` КАЖДУЮ строку, которую поток ему передал, а утверждение проверяет и непустоту записи, и отсутствие среди неё и access-токена, и любого JWT-образного значения. Посадка «положить в стор сырой id-токен» падает | fixed(P2, дерево сессии) | ревью P2 (линза water) | | PD-77 | bug | info | `internal/ingest/supervisor.go:104` | **Штатная остановка живого прогона поднимала тревогу о сломанном синке.** `Ingest` проверяет `ctx.Err()` в начале цикла и возвращает `context.Canceled` как СВОЮ ошибку; `Run` отличить это от отказавшего синка не мог и на обычном SIGTERM писал ERROR «stream could not be materialized», который по замыслу означает «платформа ослепла, пока тратятся деньги», плюс звал `stop()` на уже останавливающемся прогоне — **закрыто:** отменённый `runCtx` больше не считается отказом синка | fixed(P2, дерево сессии) | ревью P2 (линза вне карты) | | PD-78 | hardening | info | `internal/login/login.go` (было), `internal/httpapi/problem.go` (было), `internal/pgstore/identity.go`, `internal/httpapi/server.go` | **Свод воды и дублей, найденный линзой лаконичности; каждый пункт проверен удалением.** (а) `login.Routes` мемоизировал mux через `sync.Once` — при этом ВТОРОЙ и последующие `guard` молча игнорировались, то есть это была не оптимизация, а ловушка; снято. (б) `login.Fail` носил `*http.Request`, который никто не читал, и ради несовпадения сигнатур существовал шим `httpapi.Fail`; параметр и шим удалены, `WriteProblem` подключён напрямую. (в) `upsertIdentityOnce` держал собственный begin/rollback/commit при наличии `inTx` — второй экземпляр того же кода. (г) `Deps.APIPrefix` — ручка, которую не выставлял ни один вызыватель; заменена константой. (д) `Ready` делал `Ping` и следом запрос — два round trip на пробу каждые несколько секунд. (е) пять полей тестовых двойников, которые писались и не читались; `blockingSink` не блокировал. (ж) `money.USD` считал руками с комментарием про переполнение `MinInt64` — заменён на `big.Rat.FloatString(6)`, проверено побайтовое совпадение на всём диапазоне | fixed(P2, дерево сессии) | ревью P2 (линза water) + самопроверка | ## Закрытые — эра P3 (деплой и фикс-пак приёмки P2) | ID | Класс | Серьёзность | Где | Суть | Статус | Источник | |---|---|---|---|---|---|---| | PD-79 | bug | minor | `internal/money/money.go:33-36` | **Строковый `"null"` читается как НОЛЬ денег.** Кавычки снимаются `strings.Trim` ДО проверки `s == "null"`, поэтому `"committed_usd":"null"` даёт настоящий `0` и НЕПУСТОЙ указатель, тогда как доккоммент поля обещает отказ на «absent, null and empty». Замерено приёмкой на живом декодере: голый `null` и отсутствие поля дают nil (защита работает), `""` даёт ошибку, а `"null"` — `Spend = 0 micro-USD, NON-NIL`. На пути расчёта это «попытка стоила ничего»: холд освобождается, списания нет. Латентно до воркера — **закрыто:** литерал `null` судится ДО раскавычивания и оставляет значение нетронутым; кавычки снимает `encoding/json`, а не `strings.Trim` — слово `null`, пустая строка и экранированная цифра выходят тем, чем являются, и каждое встречает ту же единственную проверку синтаксиса, поэтому отдельной ветки «пусто или null» не нужно вовсе. Пины: `money.TestUnmarshalTellsTheNullLiteralFromTheWordNull` (обе формы плюс невмешательство в значение) и `ingest.TestSpendRefusesNonsense` на шве. Обе посадки — «снять кавычки первыми» и «слово `null` есть ноль» — поймать поимённо | fixed(P3, дерево сессии) | приёмка P2 (замер оркестратора №15 + панель) | | PD-80 | vuln | **major** | `internal/login/login.go:158,217-226` | **Вход выключается тремя запросами в секунду, и 429 колбэка ДОБИВАЕТ начатые входы.** Ведро `rate.NewLimiter(2, 20)` одно на `/auth/login` И `/auth/callback` (`login.go:122`, единственный лимитер в зоне), а колбэк стирает login-куку ПЕРВОЙ строкой — до своей проверки лимитера. Следствие: анонимный поток на `/auth/login` не только закрывает вход всем (это PD-42, принято риском в форме «глобальный, не пер-адресный»), но и делает начатый вход невосстановимым: 429 приходит уже с `Set-Cookie: __Host-tm_login=; Max-Age=0`, поэтому повтор того же колбэка не пройдёт и после наполнения ведра. **Воспроизведено приёмкой на боевом бинаре:** 19 из 40 `/auth/login` прошли, дальше 429; честный колбэк с живым state получил 429 и стёртую куку. Независимо измерено панелью. — **закрыто:** два ведра вместо одного (`startLimit`/`finishLimit`, те же rate/burst — не делится именно ИСЧЕРПАНИЕ), и проверка лимитера ПЕРЕД `ClearLogin`. Пины: `TestFloodingTheStartOfSignInDoesNotCloseTheEnd` (поток на `/auth/login` не закрывает честный колбэк) и `TestARefusedCallbackKeepsTheLoginItRefused` (429 не стирает куку, состояние не съедено, повтор после снятия лимита доходит до 303). Посадки «одно ведро» и «очистка выше лимитера» падают | fixed(P3, дерево сессии) | приёмка P2 (живая проба + панель, две независимые линзы) | | PD-83 | hardening | minor | `internal/httpapi/middleware.go:64` | **Фикс PD-3 не запинен в собственном месте:** посадка «`Recover` логирует `r.URL.Path` вместо `routeOf(r)`» батарею ПЕРЕЖИВАЕТ, тогда как та же посадка в `AccessLog` ловится поимённо (`TestAccessLogNamesTheRouteNotThePath`). По правилу шапки этого файла половина PD-3 закрытой не считается — **закрыто:** `TestPanicBecomesAProblemAndNamesTheRoute` — паника за мультиплексором с `{book}` в паттерне; сверяется и `route`, и отсутствие идентификатора книги во ВСЕЙ строке (в ней же стек). Посадка `r.URL.Path` падает | fixed(P3, дерево сессии) | приёмка P2 (посадка мутации) | | PD-84 | hardening | minor | `internal/login/login.go:222` | **Лимитер колбэка (фикс PD-29) не запинен:** удаление всей проверки `h.limiter.Allow()` из `callback` оставляет батарею зелёной. Замер PD-29 (~880 строк/с с одного хоста) означает, что регрессия здесь тихо возвращает неаутентифицированного писателя в таблицу журнала — **закрыто:** `TestBothLegsOfSignInAreRateLimited` — десять колбэков подряд обязаны упереться в 429. Посадка «удалить проверку целиком» падает; её же ловит `TestARefusedCallbackKeepsTheLoginItRefused` | fixed(P3, дерево сессии) | приёмка P2 (посадка мутации) | | PD-85 | hardening | minor | `internal/pgstore/identity.go:126-131` | **«Неподтверждённый адрес не поднимается на аккаунт» запинено только на ветке НОВОЙ личности:** снятие условия `in.EmailVerified` в ветке ВОЗВРАЩАЮЩЕГОСЯ входа (обновление `users.email`) проходит батарею — `TestUnverifiedAddressStaysOffTheAccount` покрывает первый вход и переход в verified, но не обратный случай — **закрыто:** `TestUnverifiedAddressStaysOffTheAccount` продлён третьим шагом — ВОЗВРАЩАЮЩИЙСЯ вход с новым НЕподтверждённым адресом: `users.email` не двигается, `identities.email` записывает то, что пришло. Посадка «снять `in.EmailVerified` в ветке возвращающегося» падает | fixed(P3, дерево сессии) | приёмка P2 (посадка мутации) | | PD-91 | doc | minor | `deploy/README.md:33-42`, `deploy/tmplatformd.service:43,48` | **Установка, исполненная дословно, даёт нестартующий юнит:** `/srv/textmachine` не создаётся ни одной командой наброска, а `ReadWritePaths=` без префикса `-` на несуществующем пути валит сборку mount-namespace при `ProtectSystem=strict`. Заодно `ProtectHome=yes` против решения владельца «книги живут в `~/books`»: детям-`tmctl` домашние каталоги под этим юнитом недоступны — либо книги переезжают в `/srv/textmachine`, либо юнит получает `BindPaths=`. ⚠ Вывод из `systemd.exec(5)`, под systemd не исполнялось (sudo нет) — **закрыто:** каталог `/srv/textmachine` создаётся явной командой наброска (`--system` домашний каталог не создаёт), префикс `-` намеренно НЕ ставится (сервис без записываемого каталога обязан падать на старте, а не на первой записи через часы), `ProtectHome=yes` оставлен с названной ценой и двухстрочным выходом (`ProtectHome=tmpfs` + `BindPaths=`), корень библиотеки на СЕРВЕРЕ — `/srv/textmachine`, `~/books` объявлено конвенцией машины разработки. **Проверено живым прогоном** `systemd-run --user` (systemd 259), а не докой: несуществующий путь без `-` → `226/NAMESPACE`, созданный → `0/SUCCESS`, с `-` → `0/SUCCESS` (строка игнорируется); `ProtectHome=yes` → `Permission denied` на `/home/`; `tmpfs`+`BindPaths` → каталог виден | fixed(P3, дерево сессии) | приёмка P2 (панель, сверено с докой) | | PD-95 | doc | **major (для промта эмиттера)** | `internal/ingest/events.go:6-12`, `docs/platform-PROGRESS.md` §«Транспорт потока событий» | **Транспортная история зоны устарела против D39.106 в ДВУХ местах, и обе версии не совпадают с ратифицированной формой.** Ратифицировано (D39.106 п.2 + `research/25` §Форма): движок — транзиентный systemd-юнит на прогон, платформа ему **НЕ родитель**; события — `events.jsonl` в каталоге книги, append-only, как outbox-проекция уже закоммиченных строк SQLite (та же транзакция, что чекпойнт); платформа **тейлит** журнал, курсор `(engine_run_id, seq)` коммитится в одной Postgres-транзакции с эффектом. В `research/25` вариант «платформа — родитель + пайп (stdout/fd)» получил **0 голосов из 15** («время жизни движка — подмножество платформы: деплой/рестарт убивает или осиротляет прогон»), выделенный fd 3 — **0**, stdout/journald как источник событий — **0**. Что в зоне: (а) доккоммент `events.go` предлагает переезд на fd/сокет — отклонённая форма; (б) журнал зоны длинно доказывает «канал остаётся stdout» и объявляет переезд отклонённым, ссылаясь на PD-59, который **сам superseded** тем же D39.106 п.3 («PD-59 superseded; пайп-путь `supervisor.go` P1 = дев-режим; ответ PD-13 переезжает на cgroup юнита прогона»). Оба текста прочтёт эмиттер-сессия как задание. — **закрыто:** доккоммент пакета переписан под D39.106 §2 — транзиентный systemd-юнит на прогон, платформа НЕ родитель, `events.jsonl` в каталоге книги как outbox-проекция коммитов SQLite, тейл с курсором `(engine_run_id, seq)`, повторное чтение строк — норма (PD-105). Отвергнутые формы перечислены со счётом голосов, чтобы не вернулись свежей идеей. Доккоммент `Supervisor` помечен ДЕВ-РЕЖИМОМ там же, где он описывает пайп. ⚠ В журнале зоны нашлась ВТОРАЯ копия снятого ответа — блок «ОТВЕЧЕНО приёмкой (PD-59)» в списке «Открытые вопросы после P1» п.4: эррата №15 пере-ставила другую секцию, эту не тронула. Текст оркестратора не переписан — над ним поставлен баннер SUPERSEDED с ратифицированной формой; проверить принадлежность правки — за приёмкой | fixed(P3, дерево сессии) | приёмка P2 (эррата оркестратора №15, 07.08) | | PD-100 | bug | minor | `internal/login/login.go:245-261` | **Класс PD-5 закрыт в `auth/`, но не в `login/`:** колбэк глотает ошибку стора (`TakeLoginState`) и ошибку discovery, репортя их как обычный отказ (`unknown_state` / `discovery_failed`) — сама ошибка не доезжает ни до одной строки лога, хотя `pgstore/identity.go` намеренно отличает «состояния нет» от инфраструктурного сбоя. Аутентификационный DB-outage снова выглядит штормом обычных отказов — **закрыто:** сбой стора и сбой discovery уходят в ERROR; на проводе и в журнале — прежний отказ. `login.ErrNoState` заведён у владельца интерфейса (как `auth.ErrNoSession`), `pgstore.ErrNoLoginState` — то же значение под прежним именем. Пин `TestInfrastructureFailuresInTheCallbackAreLogged`: три случая, включая «обычное истечение НЕ логируется как авария» — ловит и посадку «логировать всегда» | fixed(P3, дерево сессии) | приёмка P2 (панель) | | PD-106 | standards | minor | `cmd/tmplatformctl/` | **Админ-CLI — единственный писатель денег в дереве — не имеет ни одного теста.** В том числе не покрыто правило, которое он сам называет несущим («после коммита команда не может отчитаться провалом», фикс PD-75), и разбор флагов, и формат вывода. Батарея зоны его не видит вовсе (`[no test files]`) — **закрыто:** `cmd/tmplatformctl/main_test.go` — восемь тестов. Несущее правило («после коммита ничего не отчитывается провалом») пинится через `balanceReader` — интерфейс с одним методом, заведён ровно затем, что правило нельзя проверить на сторе, который всегда работает. Плюс: спент-ключ → «no-op», сбой ДО коммита → ошибка и ни строки вывода, ключ без `--key` уникален на 100 прогонах, разбор флагов десятью случаями и сквозной прогон пяти команд по живой БД. | fixed(P3, дерево сессии) | приёмка P2 (панель) | ## Закрытые — эра P4 (раннер: юнит, очередь, реконсилятор, тейлер) | ID | Класс | Серьёзность | Где | Суть | Статус | Источник | |---|---|---|---|---|---|---| | PD-43 | bug | info | `internal/pgstore/credits.go` | Денежный контур не имеет ни одного вызывающего вне тестов: `Hold`/`Settle`/`Release` не зовутся, `Sink` не реализован, `TypeSpend` не декодируется. При первом реальном прогоне баланс не изменится. — **закрыто:** денежный контур получил вызывающих: `runs.Service.Start` берёт холд в ОДНОЙ транзакции с созданием прогона и записью очереди (`pgstore.StartRun`), реконсилятор закрывает его `Settle` по фигуре движка. Пины: `pgstore.TestAdmittingARunWritesTheRunTheAttemptAndTheHoldTogether` (посадка «убрать holdTx из транзакции» падает), `TestARunThatCannotBePaidForLeavesNothingBehind`, `runs.TestARunThatEndsIsFinishedAndSettledAtWhatTheEngineSpent`. Живая проба: грант $10 → прогон с потолком 100 глав → холд $3.00 → движок отчитался $0.42 → баланс $9.58 | fixed(P4, дерево сессии) | ревью «вне карты» | | PD-81 | standards | minor | `internal/pgstore/credits.go:169-178` | **Заявленный `ErrDuplicateHold` на реальном пути недостижим:** при ЖИВОЙ резервации повторный `Hold` падает на первичном ключе `reservations_pkey` (`00007_credits.sql:66`) и уходит наверх сырой ошибкой Postgres SQLSTATE 23505; объявленная ошибка приходит только когда строку резервации уже смахнули, а ключ леджера остался. Замерено приёмкой на живом PG в обеих формах. Деньги целы (`balance == SUM(ledger)`, транзакция откатывается), но воркеру не на что смотреть, кроме текста ошибки — **закрыто:** при ЖИВОЙ резервации коллизия `reservations_pkey` мапится в `ErrDuplicateHold` (`credits.go` `holdTx`); объявленная ошибка стала достижимой на реальном пути. Пин — `TestASecondHoldOnALiveReservationIsADuplicateNotASqlstate` (сверяет и то, что отказ не двинул деньги) | fixed(P4, дерево сессии) | приёмка P2 (замер оркестратора №15 + панель) | | PD-82 | bug | info | `internal/pgstore/credits.go:236-239` | `Hold` на НЕСУЩЕСТВУЮЩИЙ аккаунт отдаёт `ErrInsufficientCredit` (в `lockBalance` `ErrNoRows` трактуется как «нет кредита»), а не `ErrNoAccount`: обещание PD-56 «один ответ на несуществующий аккаунт» покрывает `Grant`/`Adjust`/`Balance`/`ReadAccount` и на `Hold` не распространяется. Замерено приёмкой — **закрыто:** `lockBalance` при отсутствии строки баланса спрашивает, существует ли аккаунт, и отвечает `ErrNoAccount` против `ErrInsufficientCredit`. ⚠ Одним запросом это не выражается: Postgres запрещает `FOR UPDATE` на nullable-стороне внешнего соединения — проверено, поэтому вторая проверка идёт только на редком пути. Пин — `TestMoneyOperationsTellAMissingAccountFromAnEmptyOne` | fixed(P4, дерево сессии) | приёмка P2 (замер оркестратора №15) | | PD-97 | hardening | info | `internal/pgstore/credits.go:212-216` | `Settle`/`Release` отбрасывают флаг `applied` у `hold_release`: если ключ `("run_release", engineRunID)` уже потрачен, резервация закроется, а деньги не вернутся — тихий no-op на денежном пути. Требует нештатной последовательности (закрытие, смахивание строки, повторное открытие того же `engine_run_id`), но ровно на такой последовательности стоит `ErrDuplicateHold` — **закрыто:** `releaseHold` судит флаг `applied`; потраченный ключ релиза = `ErrReleaseKeySpent` и ОТКАТ транзакции, поэтому резервация остаётся ОТКРЫТОЙ и видимой оператору вместо тихого закрытия без возврата денег. Пин — `TestAReleaseWhoseKeyWasSpentIsRefusedRatherThanSilent` | fixed(P4, дерево сессии) | приёмка P2 (панель) | | PD-99 | hardening | info | `internal/ingest/supervisor.go:102` | INFO-лог «engine started» пишет `args` целиком. Сегодня безвредно, но воркер будет передавать движку идентификатор книги и потолок аргументами ⇒ book-id и денежная сумма попадут в INFO платформы (D39.84 + норма зоны «id книги в логи не текут») — **закрыто:** INFO-строка старта несёт имя команды и НЕ несёт argv (`runner.Start`, а также дев-путь `ingest/supervisor.go`), поэтому ни id книги, ни потолок в долларах в поток INFO не попадают. Пин — `runner.TestTheStartLineNamesTheCommandAndNotItsArguments` (посадка «вернуть \"args\"» падает). Живая проба на боевом бинаре: `grep -c 'ceiling-usd\|3.000000\|bk_' daemon.log` = **0** за полный прогон | fixed(P4, дерево сессии) | приёмка P2 (панель) | | PD-105 | standards | **major (для промта эмиттера)** | `internal/ingest/decoder.go:96` | **Декодер и норматив зоны расходятся на дубле `seq`:** декодер объявляет его фатальным `ErrStreamGap`, а `ENGINEERING_STANDARDS §2` ратифицирует «at-least-once — норма, дубль — не ошибка». После фикса PD-12 цена выросла: сбой ингеста ОСТАНАВЛИВАЕТ прогон, поэтому одна задублированная строка убивает платный прогон, хотя ратифицированный путь ремонта — `status --json`. Внутри одного пайпа передоставки нет, так что отказ декодера защитим; непропорциональна РЕАКЦИЯ. ⚠ **Пере-диспозиция (эррата №15): вес ПОВЫШЕН до major-для-эмиттера** — при ратифицированном транспорте (тейл `events.jsonl` с курсором, D39.106) повторное чтение строк после краша читателя — НОРМА, а не аномалия пайпа, поэтому норматив «at-least-once, дубль не ошибка» буквально верен, и фатальный отказ декодера прямо ему противоречит — **ЗАКРЫТО РАТИФИКАЦИЕЙ (D39.119) И РЕАЛИЗАЦИЕЙ.** Транспорт — тейл `events.jsonl` с курсором, поэтому повторное чтение строк НОРМА: `ingest.Tail` пропускает `seq <= last_seq` идемпотентно и не возвращает ошибку, а `pgstore.RunSink.Apply` пере-проверяет тот же high-water mark ВНУТРИ транзакции эффекта. Тот же `seq` с ДРУГИМ payload = `ErrPayloadConflict` → карантин ПОПЫТКИ, то есть её проекции: материализация останавливается, а жизненный цикл прогона продолжается — движок тратит зарезервированные деньги, и наша неспособность прочитать журнал не повод их выбросить (`pgstore.Quarantine` пишет только `run_attempts.quarantine_reason`, свежесть переходит на ре-синк). Сверка по sha256 строки (`run_attempts.last_line_sha256`). Пропасть (`seq > last+1`) осталась ошибкой — строки потеряны, читать дальше нечего. Пины: `ingest.TestARedeliveredLineIsNormalAndChangesNothing` · `TestTheSameSeqWithADifferentPayloadIsRefused` · `TestALostLineIsReportedRatherThanSkipped` · `pgstore.TestARedeliveredCountingEventDoesNotCountTwice` (⚠ последний написан ПОСЛЕ того, как посадка пережила первую версию пина: прогресс — присваивание и потому идемпотентен сам по себе, считающий эффект — `unit_done` — нет). Фатальный `ErrStreamGap` на дубле в `decoder.go` остаётся только на ДЕВ-пути пайпа, где передоставки нет | fixed(P4, дерево сессии) | приёмка P2 (панель) | | PD-108 | bug | **major** | `internal/ingest/supervisor.go:161` (было), `internal/runner/engine.go` | **Ратифицированный канал ремонта не мог работать НИ РАЗУ: `tmctl status --json` звался БЕЗ обязательного `--config`.** Движок требует его на каждой команде, которая трогает книгу, и падает на разборе аргументов до того, как увидит книгу. Найдено чтением `cmd/tmctl/invocation.go` (не доки) и **воспроизведено исполнением на бинаре, собранном из HEAD** в скрэтчпаде: `tmctl status --json` → `tmctl: --config book.yaml is required`, exit 1; с `--config <путь>` доходит до чтения файла. Дефект латентный ровно потому, что вызывающих у канала не было (PD-43) — то есть первый же резюнк воркера получил бы отказ вместо отчёта — **закрыто:** прод-путь `runner.StatusArgs`/`runner.Status` всегда несёт `--config /book.yaml`; дев-путь `Supervisor.Status` исправлен там же. Пин — `runner.TestEveryEngineInvocationNamesTheBookConfig` | fixed(P4, дерево сессии) | сессия P4 (ревью вне карты: чтение парсера движка + проба на HEAD-бинаре) | | PD-109 | bug | minor | `internal/runs/reconcile.go` | **Периодический резюнк ЗАТИРАЛ более точную проекцию потока своей грубой.** `status --json` не делит стадии по волнам (строка 99), поэтому его агрегат, положенный поверх «draft 7/20 ∥ edit 1/20», заменял пофазные счётчики одним числом — а отчёт движка, который ещё не досчитал, заменял их НУЛЯМИ. Плюс вторая половина: сторож «поток уже говорил» читал `LastSeq` из СНИМКА свипа, взятого ДО тейла, поэтому прогон, чьи первые события пришли в этом же свипе, выглядел молчащим. **Найдено живой пробой сквозного прогона**, не тестом: карточка книги показала `edit 10/10` через секунды после того, как журнал сказал `edit 0/10` — **закрыто:** резюнк работает только там, где чинить нечего (курсор не двигался ЛИБО материализация в карантине), сторож судит курсор ПОСЛЕ тейла, и `ApplyStatus` не опускает счётчики (`greatest`). Пины — `runs.TestALiveRunIsResyncedAtMostOncePerInterval` и `TestTheSweepMaterializesWhateverTheJournalHasGained` | fixed(P4, дерево сессии) | сессия P4 (живая проба сквозного прогона) | | PD-110 | bug | minor | `internal/ingest/tail.go` | **Строки ДРУГОЙ попытки судились против НАШЕГО курсора.** Журнал пер-книжный и append-only, значит резюм дописывает второй `hello` со своим `engine_run_id` и seq, начинающимся заново; строка при этом не несёт идентификатора потока — его говорит только последний хендшейк выше. Первая редакция тейлера этого не отслеживала, поэтому `seq 2` предыдущей попытки встречался с нашим `seq 2` и читался как ИЗМЕНЁННЫЙ payload, то есть как сигнал порчи: здоровый резюмнутый прогон отправлял сам себя в карантин. Найдено собственным тестом до всякой интеграции — **закрыто:** читатель ведёт область (`mine`), и до хендшейка, который он ПРИЗНАЛ своим, ничего не материализуется и ничего не судится. Пины — `TestAnotherAttemptsStreamInTheSameJournalIsSkipped` · `TestARereadFromTheStartDoesNotMistakeAnotherAttemptForCorruption` · `TestEventsBeforeAnyHandshakeAreNotJudgedAgainstOurCursor` (обе посадки — `mine := true` и `mine := false` — падают) | fixed(P4, дерево сессии) | сессия P4 (собственный тест) | | PD-111 | bug | minor | `internal/ingest/tail.go` | **`seq` хендшейка не персистился, поэтому ЛЮБОЙ резюм после него читался как пропасть.** `hello` — это seq 1 потока, но первая редакция обрабатывала его отдельно и курсор не двигала: `last_seq` оставался нулём при уже сдвинутом байтовом хинте, и следующая же строка (`seq 2`) давала `seq 2 after 0` → карантин на ровном месте — **закрыто:** хендшейк проходит те же правила, что любая строка, и двигает курсор; эффекта на read-model у него нет, эффект на курсор и есть смысл. Пин — `TestAHalfWrittenLineIsLeftForNextTime` (сверяет применённые seq 1,2 и продолжение 3,4 после дозаписи) | fixed(P4, дерево сессии) | сессия P4 (собственный тест) | | PD-116 | bug | minor | `internal/pgstore/runs.go` `RecordSpawn`, `internal/runs/spawn.go` | **Спавн попытки мог прийти ОДНОВРЕМЕННО из воркера очереди и из реконсилятора, и оба видели «не запущено».** Воркер получает прогон заданием, реконсилятор находит его неспавненным на своём проходе — обе ветки законны и обе читали `unit_name` до записи. Дальше их спасала только уникальность ИМЕНИ юнита у systemd: второй `systemd-run` падал с «unit already exists». Выживание по чужому правилу — не корректность, и оно перестаёт работать в день, когда именование поменяется (например, резюм получит суффикс). Найдено собственным ревью кода на конкурентность, до отчёта — **закрыто:** `RecordSpawn` стал compare-and-set (`where id = $1 and unit_name is null`) и возвращает, досталось ли право; проигравший НЕ стартует и это не ошибка. Пин — `runs.TestOnlyOneOfTwoConcurrentSpawnersStartsTheEngine` (восемь конкурентных спавнеров, ровно один юнит); посадка «убрать `and unit_name is null`» падает | fixed(P4, дерево сессии) | сессия P4 (самопроверка на гонки) | | PD-117 | bug | minor | `internal/httpapi/v0.go` `startRun` | **Потолок ниже минимума схемы отвечал `409`, а не `400`.** `RunRequest.ceiling_chapters` объявлен `minimum: 1`, и запрос с `0` или отрицательным — МАЛФОРМИРОВАННЫЙ; `409` же определён как «границы сдвинулись между чтением run-options и этим вызовом», поэтому клиент, получивший его, пере-читает run-options и повторяет запрос, который не может пройти НИКОГДА. Замерено ревью: `{"ceiling_chapters":0}` → 202 у хендлера и `409` после сервиса — **закрыто:** минимум схемы судится в хендлере, до сервиса. Пин — `TestACeilingBelowTheSchemaMinimumIsARejectedRequestAndNotAMovedBound` | fixed(P4, дерево сессии) | адверсариальное ревью (сверка со спекой, исполнением) | | PD-118 | bug | minor | `internal/pgstore/books.go` `ReadUsage`, `runs.go` `PauseRun` | **`Usage.paused_reason` был НЕДОСТИЖИМ через собственный путь паузы платформы.** `ReadUsage` требовал `finished_at is null`, а `PauseRun` — путь реконсилятора — ставит `finished_at` тем же запросом, что и паузу. Значит поле заполнялось только когда стоп пришёл событием потока (`sink.go`, `finished_at` не трогает) и молчало, когда паузу вызвала платформа. Замерено ревью на живом PG — **закрыто:** состояние читается по ПОСЛЕДНЕМУ прогону каждой книги (lateral), без условия на `finished_at`. Пин — `TestTheAccountReportsAPauseTheReconcilerCaused` | fixed(P4, дерево сессии) | адверсариальное ревью (исполнением) | | PD-119 | bug | minor | `internal/httpapi/v0.go` `getBook` | **Карточка книги несла ревизию ПРОГОНА, которая отстаёт от книжной.** Контракт: счётчик ОДИН на книгу и «каждое книго-скоупное чтение и id каждого кадра потока несут одно и то же число». `unit_done` двигает `books.revision` и `chapters.revision`, но не `runs.revision`, поэтому клиент, применивший кадр `id=2`, получал в карточке `0` и — по правилу самого контракта — обязан был чтение ОТБРОСИТЬ: карточка не обновлялась всю серию unit-done. Замерено ревью через настоящий `RunSink` (0→1→2 у книги при 0 у прогона) — **закрыто:** и `BookDetail.revision`, и `Run.revision` проецируются из счётчика КНИГИ. Пин — `TestTheCardsRevisionIsTheBooksAndNotTheRuns` | fixed(P4, дерево сессии) | адверсариальное ревью (исполнением) | | PD-120 | vuln | minor | `internal/pgstore/books.go` курсор пагинации | **Курсор из ЧУЖОЙ библиотеки принимался молча.** Контракт прямо возлагает отказ на СЕРВЕР («rejecting a cursor from a dead epoch is the SERVER's duty, MUST, answered 400»), потому что клиенту токен непрозрачен по построению. Курсор нёс только `(added_at, id)` и не нёс метки коллекции, поэтому токен, построенный на библиотеке другого аккаунта, отдавал окно СВОИХ книг вызывающего вместо `400`. Замерено ревью на живом PG (чужой курсор → `err=nil`, 3 строки). Утечки чужих данных нет — выборка всегда `owner_id = $1`, — но клиент получает не то окно и обнаружить это не может — **закрыто:** курсор несёт метку области (`sha256("library"+owner)`, первые 8 байт), чужая метка = `ErrBadCursor` → 400. Пин — `TestACursorFromAnotherLibraryIsRefused` (плюс проверка, что свой курсор по-прежнему работает) | fixed(P4, дерево сессии) | адверсариальное ревью (исполнением) | | PD-121 | bug | minor | `internal/httpapi/v0.go` `usageState` | **`/usage` говорил «exhausted» там, где прогон стартует.** Доля округляется ВНИЗ, поэтому $9 остатка от гранта $1000 дают `0%`, а состояние выводилось из доли: экран аккаунта показывал «ничего не осталось», пока `run-options` на той же секунде отдавал шкалу в 300 глав и прогон запускался. Замерено ревью — **закрыто:** «exhausted» — факт о балансе (`Usage.Spendable`), а не следствие округления; оба экрана отвечают из одного факта. Пины — `TestASmallRemainderIsLowAndNotExhausted`, `pgstore.TestASmallRemainderOfALargeGrantIsStillSpendable` | fixed(P4, дерево сессии) | адверсариальное ревью (исполнением) | | PD-124 | bug | **major, деньги** | `internal/runs/reconcile.go` расчёт | **Расчёт брал ПОЖИЗНЕННУЮ трату КНИГИ и выставлял её как трату прогона.** `committed_usd` из `status --json` движок считает как `SELECT COALESCE(SUM(committed_usd),0) FROM spend WHERE book_id = ?` (`backend/internal/store/ledger.go` в HEAD, «for a book across all days») — сумма по книге за всю историю. Значит каждый следующий прогон книги оплачивал заново всё, что она стоила раньше; перерасход ограничен холдом (`Settle` каппит), и в леджере он выглядит строкой «capped at the hold», то есть как перерасход ДВИЖКА, а не как арифметика платформы. Замерено двумя независимыми верификаторами на живом PG: прогоны по $1.00 и $0.50 списали $2.50; после того как пожизненная сумма книги перерастает потолок, каждый прогон стоит ровно свой потолок независимо от работы — **закрыто:** попытка записывает БАЗОВУЮ ЛИНИЮ книги перед стартом (`spend_baseline_micro_usd`, миграция 00010, читается `status --json` ДО создания юнита) и платит РАЗНИЦУ; базовая линия не прочиталась = попытка не стартует (платный прогон, который нельзя корректно выставить, хуже прогона, стартующего свипом позже). Пин — `runs.TestASecondRunOnABookIsChargedOnlyForWhatItSpent` | fixed(P4, дерево сессии) | адверсариальное ревью ×2, независимо, исполнением | | PD-125 | bug | **major, деньги** | `internal/runs/reconcile.go` `restart` | **Перезапуск при недоступном расчёте открывал ВТОРОЙ холд и терял первый навсегда.** `settle` законно ОТКЛАДЫВАЕТ (движка не спросить) и возвращает nil; `restart` читал это как успех, брал новый холд на остаток и уходил дальше, а старая резервация оставалась открытой — и не попадала ни в один список: `UnsettledRuns` фильтровал по ЗАВЕРШЁННОСТИ ПРОГОНА, а `ListLiveRuns` берёт только попытку с `ended_at is null`. Замерено: прогон с потолком $3.00 показал $6.00 зарезервированных и закончил с $3.00, навсегда снятыми с баланса, при нуле в списке несведённых — **закрыто:** перезапуск СПРАШИВАЕТ (`AttemptReservationOpen`) и откладывается, пока предыдущая попытка не сведена; `UnsettledRuns` теперь ключуется на ЗАВЕРШЁННОСТИ ПОПЫТКИ, поэтому брошенная резервация видна и при живом прогоне. Пины — `TestARestartIsDeferredWhileTheInterruptedAttemptIsUnsettled`, `TestAnInterruptedAttemptsHoldIsStillFoundWhileItsRunGoesOn` | fixed(P4, дерево сессии) | адверсариальное ревью ×2, независимо, исполнением | | PD-126 | bug | **major, деньги** | `internal/runs/spawn.go` | **Юнит, который НЕ удалось создать, съедал бюджет прогона по свипу за раз.** Право на спавн записывалось до `Runner.Start`, и при отказе `systemd-run` оставалась запись «юнит есть» без юнита и без маркера — то есть в точности форма прерванного прогона. Каждый свип перезапускал прогон: расчёт, новый холд, отказ спавна, снова. Замерено: шесть свипов — попытка 7 и $0.60 списано за движок, который ни разу не стартовал; при `SweepEvery=15s` весь потолок уходит за минуты — **закрыто:** неудавшийся `Start` СНИМАЕТ право (`ReleaseSpawnClaim`), и следующий свип повторяет ту же попытку вместо перезапуска прогона. Пин — `TestAUnitThatCannotBeCreatedDoesNotEatTheRunsBudget` (шесть свипов: попытка остаётся первой, баланс не двигается) | fixed(P4, дерево сессии) | адверсариальное ревью (исполнением) | | PD-127 | bug | **major** | `internal/runs/reconcile.go` `drainJournal` | **Одна нечитаемая строка журнала запирала прогон навсегда.** Тейл шёл ДО чтения маркера и возвращал ошибку из всей сверки, а карантинились только пропасть и конфликт payload; малформированная строка, строка длиннее буфера, подменённый файл и битый хендшейк возвращали жёсткую ошибку каждый свип. Замерено: пять свипов — статус `translating`, `finished_at` пуст, холд $3.00 держится, при том что маркер на диске и движок давно вышел — **закрыто:** ЛЮБАЯ неустранимая ошибка журнала = карантин ПРОЕКЦИИ, а жизненный цикл (маркер, живость, расчёт) продолжается; отмена контекста карантином не считается. Пин — `TestAnUnreadableJournalDoesNotStopTheRunFromFinishing` | fixed(P4, дерево сессии) | адверсариальное ревью (исполнением) | | PD-128 | bug | minor | `internal/runs/reconcile.go` грация спавна | **Грация мерилась от старта ПРОГОНА, а не попытки**, поэтому у перезапущенной попытки её не было вовсе: она наследует `started_at` многочасовой давности и признаётся потерянной, как только systemd не успел ответить. Замерено: через секунду после перезапуска — попытка 3 и три созданных юнита — **закрыто:** `run_attempts.started_at` читается отдельным полем и грация мерится от него. Пин — `TestAnAdmittedRunIsGivenTimeBeforeItIsPresumedLost` (снимок с часовым прогоном и пятисекундной попыткой) | fixed(P4, дерево сессии) | адверсариальное ревью (исполнением) | | PD-130 | bug | minor | `internal/ingest/tail.go` `readLine` | **Любая ошибка чтения превращалась в `io.EOF`,** то есть в «догнали, нового нет»: отказ диска читался бы как тишина, материализация вставала бы молча и ни один свип не сказал бы почему — **закрыто:** только настоящий EOF означает «догнали»; всё прочее возвращается ошибкой и уходит в карантин с причиной | fixed(P4, дерево сессии) | адверсариальное ревью (чтение кода) | | PD-131 | bug | minor | `internal/ingest/tail.go` | **Хендшейк не обязан был нести `seq 1`.** На этом транспорте `hello` ДВИГАЕТ курсор, поэтому `hello` с seq 0 оставлял курсор нулём, а первое настоящее событие отбрасывалось как его дубль; отрицательный seq уходил в ветку «уже применено». Пайп-декодер это требование имел всегда (`decoder.go`), файловый читатель — нет — **закрыто:** `seq != 1` у хендшейка = `ErrBadHandshake` | fixed(P4, дерево сессии) | адверсариальное ревью (чтение кода) | | PD-132 | bug | minor, деньги | `internal/runs/reconcile.go` `settle` | **Холд прогона, который так и не стартовал, не возвращался.** После введения базовой линии (PD-124) попытка без неё не сводилась вовсе, а попытка, которую никогда не спавнили, базовой линии и не имеет — её холд оставался зарезервированным навсегда. Найдено собственным тестом при починке PD-124 — **закрыто:** нет базовой линии И нет имени юнита ⇒ попытка не выполнялась, холд возвращается ЦЕЛИКОМ (`Release`); нет базовой линии, но юнит был ⇒ расчёт удерживается с ERROR-строкой, а не угадывается. Пин — `TestTheHoldOfARunThatNeverStartedComesBackWhole` | fixed(P4, дерево сессии) | самопроверка при починке PD-124 | | 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. Оператор, читающий сверху вниз, получал противоречивые инструкции в одном файле — **закрыто:** снятые абзацы удалены, потолки прямо названы границей КОНТРОЛ-ПЛЕЙНА, прогоны — своим срезом ⚠ **ОСПОРЕНО(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, дерево сессии) | пост-сверка диффа с промтом | ## Закрытые — дофикс P4 и ре-чек V2 | ID | Класс | Серьёзность | Где | Суть | Статус | Источник | |---|---|---|---|---|---|---| | 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, сетевой сброс) — по-прежнему карантинил проекцию живого платного прогона НАВСЕГДА (пути снятия карантина в дереве нет) ⚠ **«Снятия карантина в дереве нет» — верно на дату ряда и НЕВЕРНО с пака 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, исполнением) | | PD-149 | bug | minor | `internal/config/config.go` `loadRunner` | **Относительный `TM_PLATFORM_STATE_DIR` не абсолютизировался, а маркер пишется и читается из РАЗНЫХ рабочих каталогов:** ExecStopPost исполняется юнитом, у которого `WorkingDirectory` — каталог книги, а демон читает от своего cwd. Конец прогона становится невидим, реконсилятор перезапускает прогон бесконечно — **закрыто:** `filepath.IsAbs` на буте, отказ с именем переменной; пин `config.TestARelativeStateDirectoryIsRefusedAtBoot` | fixed(дофикс P4, дерево сессии) | приёмка P4 (F6) | | PD-150 | standards | minor | `internal/pgstore/sink.go` `ApplyStatus` | **«Метка давности» ре-синка была обещана промтом («честно, с меткой давности») и не построена:** `now` в `ApplyStatus` не использовался, поля свежести не было, и у читателя замершей проекции карантинной попытки не было ничего, что сказало бы, насколько старые цифры он видит — **закрыто:** `runs.last_resync_at` (миграция 00012) пишется КАЖДЫМ ре-синком; пин `pgstore.TestAResyncRecordsWhenItWasTaken` (нет метки до первого · метка равна времени вызова · вторая переписывает первую). ⚠ Названная девиация: на провод метка НЕ выходит — в контракте v0 у прогона поля свежести нет; читается оператором и той ручкой, которая появится вместе с полем | fixed(дофикс P4, дерево сессии; половина «на провод» — за контрактом) | приёмка P4 (F7) | | 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: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, дерево сессии) | самопроверка дофикса (ревью вне карты, чтением) | | PD-167 | doc | info | `internal/pgstore/migrations/00009_runner.sql:37-39` | Комментарий DDL обещает «хинт, не ведущий к `seq = last_seq + 1`, отбрасывается, и файл перечитывается с начала» — код так не делает: пропасть ведёт к карантину проекции и переходу на ре-синк. Расхождение док↔код, поведение верное — **закрыто ре-чеком V2:** комментарий приведён к тому, что делает тейлер. ⚠ Ре-чек назвал эту строку «застывшим комментарием миграции 00011»; предмет строки — комментарий 00009, а 00011 нёс отменённую формулу потолка и исправлен вместе с ней (обе миграции этим паком и написаны, нигде не применялись, поэтому их отпечатки в `migrations.sha256` обновлены с явной причиной в шапке файла) | fixed(ратификация + дофикс V2, дерево сессии) | самопроверка дофикса (ревью вне карты) | | PD-171 | bug | minor, деньги | `internal/runs/reconcile.go` `settle` | **Счётчик книги НИЖЕ собственной базовой линии попытки списывал $0 молча.** Это вырожденный случай — БД проекта подменили или восстановили из копии, — и клампить в ноль правильно (платить аккаунту за подмену файла код решать не вправе), но молчать нельзя: расчёт в ноль обнаруживался бы только по балансу — **закрыто:** WARN (не INFO: предмет — деньги) с фактом и без цифр (D39.84); пин `TestAMeterThatWentBackwardsSettlesAtNothingAndSaysSo` проверяет и отсутствие списания, и наличие строки, и что цифры в неё не попали | fixed(дофикс V2, дерево сессии) | ре-чек V2 (оркестратор №15) | ## Закрытые — третий раунд P5 (ре-чек оркестратора, 14.08) | ID | Класс | Вес | Где | Что и чем закрыто | Статус | Кем найдено | |---|---|---|---|---|---|---| | PD-197 | standards | **major (гейт)** | `Makefile` `tools-check`, `go.mod` | **Гейт тулчейна не гейтил, а три дока утверждали обратное.** Подъём floor 1.26.5 → 1.26.6 (пять адвизори stdlib) был сделан переменной `GO_MIN_VERSION`, которую читало только сообщение об ошибке, тогда как проверкой оставался регекс `go1\.26\.([5-9]|[0-9]{2,})` — он принимал ровно ту 1.26.5, ради отказа от которой floor и поднимали, и ставил 1.26.10 ниже 1.26.9. Клейм «хост на 1.26.5 получит отказ» стоял в журнале ×2, `STACK_DECISIONS` и комменте Makefile — **закрыто:** цель `version-check` СРАВНИВАЕТ версии (`sort -V`, пререлизы `rc`/`devel` отвергаются отдельно) и берёт версию из переменной, чтобы её судили версиями, которых на хосте нет; `go.mod` получил `toolchain go1.26.6` — его читает всякая сборка, мимо make тоже (`GOTOOLCHAIN=auto` скачает, `=local` остановится). Пины `gates.TestTheToolchainGateComparesVersionsRatherThanMatchingThem` (таблица из 11 версий) и `gates.TestGoModPinsTheSameToolchainTheBatteryDemands`, обе посадки падают; три клейма переписаны на описание механизма | fixed(третий раунд, дерево сессии) | ре-чек оркестратора (FP5-9) | | PD-198 | doc | info | `internal/pgstore/runs.go` `PauseRun`, `internal/books/parse.go` | **Комментарии описывали до-фиксное поведение — в том числе на пути, который удаляет файл пользователя.** `PauseRun` обещал проверку стопа «в том же стейтменте» (стоит отдельный `select … for update` в той же транзакции); `parse.go` объявлял отказ источника «терминальным с первого ответа», хотя дофикс провёл КАЖДЫЙ ответ движка через бюджет попыток — **закрыто:** оба текста приведены к коду; правок поведения не потребовалось. ⚠ Дописка: P6 переписал текст `parse.go` ещё раз под полосу отказов, где терминален ровно один класс (PD-196). ⚠ **испр. оркестратором №16 15.08 при лендинге:** переименование этой строки в PD-199 откачено (ID стабилен навсегда, коммит-первоисточник `69d485a`), содержимое заведённой рядом второй строки слито сюда | fixed(третий раунд, дерево сессии) | ре-чек оркестратора (хвосты а, б) | ## Закрытые — дофикс-2 P5 (кросс-семейное ревью дофикса, 13.08) | ID | Класс | Вес | Где | Что и чем закрыто | Статус | Кем найдено | |---|---|---|---|---|---|---| | PD-192 | bug | **major, данные пользователя** | `internal/books/parse.go` `manifest`, `Parse` | **Пропавший КОРЕНЬ хранилища читался как вина каждой книги.** `os.Stat(workdir)` даёт ENOENT и на снесённом каталоге книги, и на несмонтированном `BooksDir`; первое терминально по устройству (FP5-3), значит размонтированный том заставлял ОДИН проход свипа терминально отклонить ВСЕ книги в интейке с `source_unreadable` — причиной, которая винит файл пользователя и не имеет обратного хода (PD-175). Путь создан моим же фиксом FP5-3 — **закрыто:** `ErrStorageGone` отличён от `ErrDirectoryGone` (корень спрашивается прежде, чем винить книгу), причина `storage_unavailable` не терминальна, бюджета не тратит и на книге не хранится; предикат `waitsForTheDeployment` собрал оба «ждущих деплой» случая. Пин `books.TestAVanishedStorageRootIsNotEveryBooksFault`, посадка падает ⚠ **Дополнено ре-чеком (FP5-10): первая редакция закрывала не тот сценарий.** Гард спрашивал `Stat(BooksDir)`, а том, смонтированный РОВНО в `BooksDir`, оставляет после размонтирования пустой mountpoint — `Stat` успешен, и все книги снова терминально отклонялись; бут безусловным `MkdirAll` пересоздавал корень и маскировал пропажу. Теперь решение принимается по СЕНТИНЕЛУ провижининга `.tmplatform-books`, который пишет только первая загрузка (`markStorage`, `O_EXCL`) и не пишет бут: ни unmount, ни `MkdirAll` его не подделывают. Пин `books.TestAnUnmountedVolumeLooksLikeAnEmptyRootAndStillIsNotTheBooksFault`; первая редакция ПИНА посадку пережила (без сентинела ждёт всё) — добавлено утверждение, что загрузка сентинел пишет, иначе терялась терминальность крэш-окна FP5-3 | fixed(третий раунд, дерево сессии) | кросс-семейное ревью дофикса (Fable 5, линза интейка) | | PD-193 | bug | **major, деньги** | `internal/pgstore/runs.go` `PauseRun` | **Третий закрывающий путь без гарда живой попытки** (после PD-181 и FP5-2): пауза не проверяла, что закрываемая попытка ещё жива и принадлежит этому прогону (апдейт попытки шёл даже без `run_id`). Проход старого поколения при перекрывающемся деплое паузит прогон, уже рестартованный в живую попытку 2: прогон отвечает `paused/credit_exhausted` под тратящим движком, а холд попытки 2 не виден ни в `ListLiveRuns`, ни в `UnsettledRuns` — **закрыто:** тот же `exists (a.id = $4 and a.run_id = runs.id and a.ended_at is null)`, что у соседей, плюс `run_id` в апдейте попытки. Пин `pgstore.TestAPauseFromAnOldSnapshotDoesNotCloseARunOverALiveAttempt`, посадка падает | fixed(дофикс-2, дерево сессии) | кросс-семейное ревью дофикса (Fable 5, линза денег), воспроизведено дважды | | PD-194 | bug | minor | `internal/pgstore/sink.go` `Begin` | **Синк законченной попытки усыновлял хендшейк следующей.** Стоп ДО спавна оставляет попытку закрытой и без `engine_run_id` — форма, невозможная до P5; устаревший материализатор биндил на неё `engine_run_id` попытки-заместителя и материализовал тот же журнал второй раз, удваивая счётчики глав и юнитов (деньги не двигались) — **закрыто:** бинд отказан для законченной попытки. Пин `pgstore.TestAMaterializerOfAnEndedAttemptDoesNotAdoptTheNextAttemptsEngine`, посадка падает | fixed(дофикс-2, дерево сессии) | кросс-семейное ревью дофикса (Fable 5, линза денег) | | PD-195 | bug | minor, деньги | `internal/runs/reconcile.go` `settle` | **Возврат холда «прогона, который не запускался», ждал ответа движка.** Ветка «юнита не было — вернуть холд целиком» стояла ПОСЛЕ обязательного `tmctl status`, а хост, производящий эту ситуацию, — ровно тот, где движок не запускается: `Status` падает на каждом проходе, и деньги остаются зарезервированными навсегда (видимыми, но запертыми) — **закрыто:** ветка идёт до вызова движка; `ReleaseUnspawned` перепроверяет строку под замком, поэтому снапшот, который успели заспавнить, отвергается там. Пин `runs.TestTheHoldOfARunThatNeverStartedComesBackOnAHostWhoseEngineCannotAnswer`, посадка падает | fixed(дофикс-2, дерево сессии) | кросс-семейное ревью дофикса (гипотеза), подтверждено зоной исполнением | ## Закрытые — эра P5 (загрузка книги · стоп и резюм · наблюдаемость) | ID | Класс | Серьёзность | Где | Суть | Статус | Источник | |---|---|---|---|---|---|---| | PD-72 | hardening | info | `internal/httpapi/server.go:88` | **Отсутствие ОБЩЕГО лимита тела над маршрутами не наблюдаемо ничем.** Пер-маршрутность (PD-35/PD-53) держится на том, что вложенный `MaxBytesReader` только УЖЕСТОЧАЕТ: это запинено `TestBodyCapIsPerRouteBecauseNestingOnlyTightens`. Но возврат внешнего слоя в `New` батарею переживает, потому что ни один маршрут не просит потолок БОЛЬШЕ дефолтного — наблюдаемым дефект станет ровно тогда, когда появится загрузка книги. — **закрыто:** маршрут загрузки приехал ВМЕСТЕ со своим потолком и своим тестом. `POST /v0/books` регистрируется `guard(d.Upload.MaxBytes, …)`, пин — `httpapi.TestAnUploadLargerThanTheRouteAllowsIsRefusedAsTooLarge` (413 + problem+json на теле выше потолка И 201 на теле ниже него: потолок, который отвергает всё, не потолок), плюс `TestTheUploadLimitBelongsToTheUploadRouteAlone` — заявка на прогон с телом выше ДЕФОЛТНОГО потолка отвергается, то есть поднятие лимита одного маршрута не подняло его для остальных. Посадка «вернуть `DefaultMaxBody` на маршрут загрузки» падает | fixed(P5, дерево сессии) | ревью P2 (линза doc-vs-code) | | PD-114 | standards | minor | `internal/config/`, `deploy/` | **Конфигурация: 27 переменных окружения, НИ ОДНОГО флага у демона и никакой печати эффективной конфигурации при старте** (грепнуто). Выбор «только окружение» сам по себе мейнстрим (12-factor) и записан решением в доккомменте `config.go`; деплой использует systemd-нативные `EnvironmentFile=`+`LoadCredential=`. Ниже нормы другое: у оператора нет ни `-version`, ни «проверить конфиг и выйти», ни строки «вот что реально применилось» — не подхватившийся `EnvironmentFile` обнаруживается по поведению, а плоское пространство из 27 имён это та точка, где обычно переходят на файл. ⚠ Вопрос ВЛАДЕЛЬЦА (08.08), и он же нашёл этим вопросом реальный дефект в этом паке: дефолт «$/глава» лежал в двух местах (config клал ноль, разрешал вызыватель) — исправлено, дефолт резолвится только в `config`. **Ратифицировано 09.08 (оркестратор №15 по делегации владельца): «только окружение» ОСТАЁТСЯ; строка переформулирована в задачу — печать ЭФФЕКТИВНОЙ конфигурации при старте с редакцией секретов** (значение каждой настройки и откуда оно взялось: дефолт · переменная · файл; `*_FILE` печатается фактом наличия, не содержимым) — **закрыто:** `config.Config.Settings` несёт КАЖДУЮ прочитанную переменную с источником (`default` · `environment` · `file`), `LogEffective` печатает их построчно на старте. Редакция двойная и обе — правила проекта, а не вкус: секрет (DSN несёт пароль) и ЛЮБАЯ денежная сумма (D39.84/PD-99) печатаются фактом наличия и источником, без значения. Пины: `TestEverySettingThisServiceReadsIsPrinted` (сверка со СПИСКОМ `TM_PLATFORM_*` из исходника `config.go` — переменная, добавленная без записи, роняет батарею в том же коммите), `TestASecretIsNamedAndNeverPrinted`, `TestAConfiguredAmountIsNeverPrinted` | fixed(P5, дерево сессии) | вопрос владельца 08.08 + сессия P4 | | PD-140 | bug | info | `internal/runs/reconcile.go` `Stop`, `internal/httpapi/` | `Service.Stop` построен и не подключён ни к чему: `httpapi.Runs` даёт только `Bounds`/`Start`, у `tmplatformctl` команды остановки нет. То есть контрол-плейн не умеет остановить прогон, который сам же запустил. Ручки `POST /runs/{id}/stop` и `/resume` в список работ промта не входили, поэтому это НЕ девиация пака, а честно названный хвост: остановить прогон сегодня можно только `systemctl --user stop`. — **закрыто:** ручки `POST /v0/runs/{runId}/stop` и `/resume` построены по спеке (202 + `Run`, 404 чужому, 409 на невозможное действие), `runs.Service.Stop` записывает НАМЕРЕНИЕ стопа в Postgres ДО сигнала и зовёт systemd, `Resume` переиспользует механику перезапуска реконсилятора (`reopen`) вместо второй копии денежной арифметики. Пины: `TestTheStopIsRecordedBeforeSystemdIsAsked` (хук внутри фейка systemd читает БД и требует, чтобы намерение уже было), `TestAStopTheUnitDidNotTakeIsAskedAgainByTheSweep`, `TestResumeContinuesTheRunWithWhatIsLeftOfItsBudget`, `TestARunOfAnotherAccountCannotBeStoppedOrResumed` | fixed(P5, дерево сессии) | адверсариальное ревью (чтение) | | PD-178 | standards | info | `internal/runner/systemd_test.go`, `Makefile` | **Гейт systemd-тестов сверял ТЕКСТ сообщения, а не способность хоста, и перестал гейтить.** `systemdOrSkip` скипал по подстроке «Failed to connect to bus», а systemd 259 отвечает «Failed to connect to user scope bus» — на хосте без пользовательского менеджера три теста ПАДАЛИ вместо скипа. ⚠ Состояние пользовательского менеджера на стенде ПЛАВАЕТ между сессиями — в одной его нет (`Linger=no`, sudo нет), в следующей он жив (`systemctl --user show` отвечает `Version=259.5`) и все три теста проходят; замерено обоими способами в один день — **закрыто:** гейт спрашивает СПОСОБНОСТЬ (доходит ли процесс до своего менеджера), скип громкий и поимённый, как у БД-тестов; пин `runner.TestTheSystemdGateAsksAboutTheCapabilityAndNotAMessage` падает, если гейт снова начнёт сверять текст | fixed(P5-дофикс, дерево сессии) | сессия P5 (батарея на стенде) | | PD-181 | bug | **minor, деньги** | `internal/pgstore/sink.go` `FinishRun`, `internal/runs/reconcile.go` `finishStopped` | **Устаревший снапшот свипа мог до-финишировать уже РЕЗЮМИРОВАННЫЙ прогон и оставить холд новой попытки вне всех списков.** Маркер завершённой попытки остаётся на диске (их никто не удаляет), а `FinishRun` гардил только `finished_at is null` — проход, держащий снапшот попытки 1, закрывал прогон, который стоп-резюм успел вернуть к жизни; попытка 2 с открытой резервацией не попадала ни в `ListLiveRuns` (там `finished_at is null`), ни в `UnsettledRuns` (там `ended_at is not null`). Достижимо стало ровно с появлением резюма, то есть этим паком — **закрыто:** `FinishRun` пишет только если закрываемая попытка ещё живая, и возвращает признак «закрыл», по которому вызыватель решает, считать ли деньги; пин `runs.TestAStaleSweepDoesNotReFinishAResumedRunFromTheOldAttemptsMarker`, посадка «снять гард» падает | fixed(P5, дерево сессии) | кросс-семейное ревью P5 (Fable, исполнением) | | PD-182 | bug | **minor, деньги** | `internal/runs/reconcile.go` `finishStopped` | **Стоп закрывал прогон под ЖИВЫМ движком, если заявка на спавн была отдана назад после того, как юнит уже создался.** `ReleaseSpawnClaim` обнуляет `unit_name`, когда «юнит не удалось создать», а это не то же самое, что «не создался» — `systemd-run`, убитый после запроса, оставляет движок работать (зона это уже знает: ради этого случая сохраняется базовая линия). Путь стопа читал пустое имя как «процесса не было» и закрывал прогон; движок продолжал тратить, сигнала до него не доходило, следующий прогон книги впитывал его трату в свою базовую линию — **закрыто:** ненулевая `spend_baseline` при пустом имени юнита считается надгробием попытки спавна, имя юнита детерминировано, и платформа спрашивает systemd `Alive` прежде чем закрывать; живой юнит получает стоп. Пин `runs.TestAStopDoesNotCloseARunWhoseGivenBackClaimLeftAnEngineRunning` | fixed(P5, дерево сессии) | кросс-семейное ревью P5 (Fable, исполнением) | | PD-183 | bug | **minor** | `internal/books/parse.go`, `internal/jobs/jobs.go` | **Грация клейма разбора (10 мин) была КОРОЧЕ таймаута задания очереди (15 мин): свип воровал клейм у живого парса.** В окне 10–15 минут на одной проектной директории оказывались два `tmctl manifest`; проигравший умирал на эксклюзивном локе движка с exit 1, а exit 1 — это то, чем движок говорит «источник не разобрать», и книга отклонялась ТЕРМИНАЛЬНО с удалением исходника — **закрыто:** грация написана как `jobs.JobTimeout + 5m`, пара утверждается тестом `books.TestTheClaimGraceOutlivesTheQueuesJobTimeout`; вдобавок терминальная запись возможна только пока клейм ещё наш (`parse_started_at = <наш>`), а один exit 1 больше не терминален вовсе | fixed(P5, дерево сессии) | кросс-семейное ревью P5 (Fable, исполнением) | | PD-184 | bug | info | `internal/metrics/metrics.go` | **Лейбл `method` брался из запроса как есть.** Для маршрута он свёрнут в `(unmatched)`, а метод — токен, который выбирает вызывающий, и на неразобранном запросе он его же и придумывает: число рядов становится чужим ресурсом — **закрыто:** закрытый список методов, всё прочее — `(other)`; пин `metrics.TestAnUnroutedRequestGetsOneSeriesAndNotOnePerPath` | fixed(P5, дерево сессии) | кросс-семейное ревью P5 (Fable) | | PD-186 | bug | **minor, деньги** | `internal/pgstore/sink.go` `FinishUnspawnedStop` | **Закрытие «стопа до спавна» не имело гарда живой попытки** — того самого, что получил `FinishRun` (PD-181). Устаревший проход мог закрыть уже РЕЗЮМИРОВАННЫЙ прогон, и холд второй попытки выпадал из обоих списков; вдобавок каждый следующий резюм отвечал 409 навсегда, потому что продолжать было бы уже завершённый прогон — **закрыто:** попытка обязана быть живой (`ended_at is null`) и принадлежать этому прогону; пин `runs.TestAStaleUnspawnedStopDoesNotCloseAResumedRun`, посадка «снять гард» падает | fixed(P5-дофикс, дерево сессии) | приёмка P5 (FP5-2) | | PD-187 | bug | minor | `internal/books/parse.go` `reject`, `manifest` | **Крэш между сносом каталога и записью строки оставлял книгу в `parsing` НАВСЕГДА.** Порядок «каталог, потом строка» был выбран как самоизлечивающийся, и посылка была ложной: снесённый каталог читался как «нет конфигурации», а эта причина терминальной не становится никогда — книга ждала конфигурацию, которую некуда положить — **закрыто:** пропавший КАТАЛОГ отличён от отсутствующей конфигурации (`ErrDirectoryGone`) и терминален сразу; пин `books.TestABookWhoseDirectoryIsGoneIsRejectedRatherThanLeftWaiting` | fixed(P5-дофикс, дерево сессии) | приёмка P5 (FP5-3) | | PD-188 | bug | minor | `internal/books/parse.go`, `internal/pgstore/books.go` `ClaimParse` | **Ожидание конфигурации ЖГЛО бюджет разбора.** `ClaimParse` считает каждую заявку, а книга без конфигурации заявляется раз в грацию бесконечно — после пяти циклов ожидания бюджет был исчерпан, и ПЕРВЫЙ же ответ движка становился терминальным мгновенно, с удалением исходника; движок отдаёт exit 1 и на опечатку в `book.yaml` — **закрыто:** попытка возвращается (`RefundParseAttempt`), когда движок не был спрошен вовсе; пин `books.TestWaitingForAConfigurationDoesNotBringDeletionCloser` | fixed(P5-дофикс, дерево сессии) | приёмка P5 (FP5-4) | | PD-189 | bug | minor | `cmd/tmplatformd/runner.go`, `internal/books/parse.go` `Sweep` | **Бэкстоп гнал разбор под бюджетом прохода (2 мин) против 15 минут очереди.** Восстановление большой книги убивалось дедлайном свипа, убийство читалось как «хост не может запустить движок», попытка списывалась — и так каждый проход, пока книга не отклонялась ЗА СВОЙ РАЗМЕР; вдобавок запись отказа шла на просроченном контексте и терялась — **закрыто:** у прохода интейка свой бюджет (`jobs.JobTimeout + 1m`), у каждой книги внутри — свой (`jobs.JobTimeout`), терминальные записи идут на контексте, переживающем дедлайн; пин `books.TestOneBookInTheSweepGetsABudgetAParseCanLiveIn` ⚠ Дополнено ре-чеком (хвост в): бюджет КНИГИ не равен бюджету ПРОХОДА — вторая книга прохода получала остаток от первой и жгла попытку на обрезанном дедлайне. Проход, которому осталось меньше `jobs.JobTimeout`, книгу больше не НАЧИНАЕТ (клейм берётся внутри `Parse`, поэтому отложенная книга не тратит ничего). Пин `books.TestAPassTooShortForAParseStartsNoneAtAll` | fixed(третий раунд, дерево сессии) | приёмка P5 (FP5-5) | | PD-190 | bug | info | `internal/httpapi/v0.go` `uploadFailed` | **Просроченный дедлайн загрузки уходил 500.** Медленный клиент — не сломанный сервис, и 500 говорит клиенту обратное о том, помогает ли повтор — **закрыто:** 408 problem+json; вопрос о коде вне перечня спеки внесён в пакет PD-180; пин `httpapi.TestAnUploadThatOutlivesItsDeadlineIsNotAnInternalError` | fixed(P5-дофикс, дерево сессии) | приёмка P5 (FP5-6) | | PD-191 | bug | minor | `internal/pgstore/runs.go` `PauseRun` | **Стоп, пришедший в окно расчёта, отвечал `paused/credit_exhausted` вместо `stopped`** — то есть «кончились деньги» вместо «владелец остановил» — **закрыто:** пауза отказывает при висящем интенте (`ErrStopRequested`), реконсилятор заканчивает прогон стопом; пин `runs.TestAStopDuringSettlementOutranksThePause` | fixed(P5-дофикс, дерево сессии) | приёмка P5 (FP5-8а) | ## Закрытые — эра P6 (потребительская половина шва эмиттера · интейк формы Б · дев-стенд) | ID | Класс | Серьёзность | Где | Суть | Статус | Источник | |---|---|---|---|---|---|---| | PD-200 | bug | minor | `internal/ingest/tail.go`, `internal/runs/spawn.go` | **Тейлер УСЫНОВЛЯЛ чужой поток, лежащий на смещении попытки** — и это было три дефекта в одной строке, а не наблюдение. Воспроизведено: журнал книги, в котором до нашего hello лежит чужой (`tmctl`, запущенный оператором руками в каталоге книги), давал (1) adoption чужого `engine_run_id` через `Begin`, (2) материализацию его событий на НАШУ попытку — включая `ceiling`, то есть `paused` у прогона, которого никто не паузил, и (3) отказ на нашем СОБСТВЕННОМ hello («seq 3, want 1») с карантином проекции платящего прогона навсегда. **Закрыто:** платформа НАЗЫВАЕТ поток до создания юнита (`runs.engineStreamID`, отдаётся движку как `TM_TRACE_ID`, строка 102) и пишет имя в той же транзакции, что claim; `mine` теперь спрашивает про ПРИМЕНЁННЫЕ строки (`LastSeq > 0`), а не про байтовый хинт, который у новой попытки ненулевой до первого чтения. Пины: `ingest.TestAForeignStreamAtOurOffsetIsNeverAdopted` · пин окружения юнита в `runs`. Живая проба: `engine_run_id = tm-stream-run_…-1` в БД стенда до старта движка | fixed(P6, дерево сессии) | watch-пункт промта P6, воспроизведён | | PD-60 | bug | minor | `internal/ingest/supervisor.go`, шов | ⚠ **ПЕРЕ-ДИСПОЗИЦИЯ (эррата №15, 07.08): постановка строки устарела.** Она рассуждает про 64 КиБ пайпа, а ратифицированный транспорт (D39.106 п.2) — тейл `events.jsonl` с курсором: у файла обратного давления в этом смысле нет вовсе, зато появляются свои свойства (fsync-политика, ротация, отставание тейлера, поведение при заполненном диске). ⚠ **ЗАКРЫТИЕ ПО ФАКТУ КОДА (P6):** политика выбрана ЯВНО и залендена движком (D39.131) — эмиттер ДЕГРАДИРУЕТ ГРОМКО и прогон продолжается: `emit` не возвращает ошибку никогда, первый отказ пишется ERROR один раз на серию, строки остаются в outbox и повторяются целым префиксом, так что транзиентный ENOSPC/EIO самолечится, а fsync на событие сознательно не делается (журнал — проекция коммитов SQLite при `synchronous=NORMAL`, проекция не может быть durable сильнее источника). Блокировать прогон отвергнуто явно. У платформы обратного давления в этом смысле нет по построению: она ТЕЙЛИТ файл, а не кормится пайпом; её собственная деградация — карантин проекции с ре-синком (PD-105). Открытого остатка у строки нет | fixed(P6, дерево сессии) | приёмка P1 (абстрактный разбор двумя агентами, оба независимо) | | PD-61 | bug | info | шов, строка 103 | Два свойства эмиттера, которые надо задать ДО его постройки, иначе они станут миграцией. **(а) Сброс буфера на выходе:** `bufio.Writer` вокруг потока плюс `os.Exit`/`log.Fatal` пропускает `defer` и теряет последние события — ровно те, что сообщают об окончании прогона. **(б) Хвост при падении платформы:** содержимое непрочитанного пайпа умирает вместе с читателем. Если требование «платформа перезапустилась, прогон продолжается» когда-нибудь появится, ответ — НЕ сокет (он даёт переподключение без возобновления), а журнал файлом: движок дописывает NDJSON в `/events.ndjson`, платформа тейлит его с чекпойнтом смещения в Postgres. Это переживает и падение платформы, и даёт реплей бесплатно. Оба агента пришли к этому независимо; прецедент — Bazel Build Event Protocol (файл или gRPC, не пайп родителя) ⚠ **ЗАКРЫТИЕ ПО ФАКТУ КОДА (P6):** оба свойства заданы до постройки и построены. (а) Буфера НЕТ ВОВСЕ — `runevents.Journal` пишет строку одним `write(2)` без `bufio`, поэтому `os.Exit`/`log.Fatal`/паника не могут потерять ни `finished`, ни `ceiling`: терять нечего. (б) Ответ — журнал файлом с курсором `(engine_run_id, seq)`, ровно как строка и предлагала; ратифицирован D39.106 §2 и построен обеими сторонами. Открытого остатка нет | fixed(P6, дерево сессии) | приёмка P1 (абстрактный разбор) | | PD-113 | bug | **major (контрактно видимый)** | `internal/runs/reconcile.go` `outcome`, движок `stagerun.go` | **Стоп по потолку сегодня НЕразличим от инфраструктурного отказа, и контракт при этом запрещает называть его `failed`.** Движок возвращает потолок ошибкой (`errReserveCeiling`, сверено в HEAD), а `exitCode` мапит всё нераспознанное в 1 — значит по коду выхода «деньги кончились» и «упало» это одно и то же число; события потолка не существует (строка 103). Платформа честно ставит `failed`, хотя `BookStatus` требует `paused` и «никогда не `failed`», потому что стоп резюмируем. Единственный путь, которым платформа СЕГОДНЯ узнаёт о потолке, — событие потока, которого нет; ветка под него построена и запинена (`TestACeilingHaltPausesTheRunWithItsReason`, `TestWhatTheUnitDidBecomesTheProductStatus`, случай «a ceiling halt survives any exit»). — **ЗАКРЫТО (P6, потребительская половина шва):** потолочный стоп приезжает `paused` ДВУМЯ независимыми каналами и ни по одному не `failed` — событием `ceiling` потока и кодом выхода 4, потому что журнал, который не удалось записать, всё равно оставляет код. Причина пишется вместе со статусом (`pgstore.RunEnding.PausedReason`), а не вторым оператором, и читается ПОСЛЕ дренажа журнала, а не из снапшота свипа. Пины: `runs.TestWhatTheUnitDidBecomesTheProductStatus` (таблица с exit 4 и обеими причинами) · `runs.TestACeilingHaltWithNoEventIsPausedWithItsReasonAndNotRestarted` (сквозь свип: `paused`, причина, НЕ перезапущен, холд закрыт) · `runs.TestACeilingEventArrivingInThisVerySweepStillDecidesTheEnding` (стейл-снапшот). Живая проба на стенде с фейком-движком, имеющим правило потолка: `ceiling{scope:book}` + exit 4 → `paused/credit_exhausted`, прогресс 4/6 из потока, расчёт $0.08 из $5.10 | fixed(P6, дерево сессии) | сессия P4 (сверка контракта с кодом движка) | | PD-141 | bug | info | `internal/pgstore/runs.go` `PauseRun` | `PauseRun` возвращает nil, когда строка прогона уже завершена, поэтому вызывающий рапортует паузу, которой не произошло. Идемпотентность здесь нужна (свип повторяется), но молчаливая — нет: различить «поставил паузу» и «было уже поздно» вызывающий не может — **закрыто (P6):** `PauseRun` возвращает `paused bool`, вызывающий логирует «run paused» только по нему, а отказ называет вслух. Пин — тест стейл-паузы в `pgstore` утверждает именно `paused == false` | fixed(P6, дерево сессии) | адверсариальное ревью (чтение) | | PD-152 | bug | info | `internal/runs/reconcile.go` `outcome` | **`stopped` для остановки, которую мы попросили, на реальном движке недостижим:** `tmctl` ЛОВИТ SIGTERM и выходит кодом 1, поэтому ветка «не вышел сам + `$SERVICE_RESULT=success`» срабатывает только для процесса, умершего ОТ сигнала. Проба пака показала `stopped` на фейке, который именно так и умирал. Следствие: пользовательский стоп приедет как `failed`. ⚠ **Дофикс 09.08 расширил строку: то же самое ломает ШТАТНУЮ ПЕРЕЗАГРУЗКУ.** При ребуте пользовательский менеджер останавливает юниты корректно, `ExecStopPost` ОТРАБАТЫВАЕТ и маркер пишется — ревьюер снял живьём на этом хосте (транзиентный юнит, процесс ловит TERM и выходит 1): `RESULT=exit-code CODE=exited STATUS=1`. То есть на буте свип видит маркер и закрывает все живые прогоны как `failed` вместо перезапуска, а путь строки 138 покрывает только потерю питания (нет маркера) — и собственный тест `TestARunInterruptedByARebootComesBackWithTheBudgetItHasLeft` моделирует именно её. ⚠ **Половина закрыта паком P5 (D39.128-ветка), половина осталась и это названо:** ПОЛЬЗОВАТЕЛЬСКИЙ стоп больше не приезжает как `failed` — платформа пишет намерение стопа (`runs.stop_requested_at`, миграция 00014) ДО сигнала и классифицирует маркер по нему (`outcome`/`stoppedOnRequest`), с гардом гонки «стоп против самостоятельного финиша» по времени маркера и чистым исходом движка (0/2/3) поверх намерения; пины `TestARunTheUserStoppedIsNotReportedAsFailed`, `TestARunThatEndedBeforeTheStopKeepsItsOwnOutcome`, `TestACleanExitOutranksAStopThatArrivedTooLate`. ⚠ **ОСТАТОК ЗАКРЫТ (P6):** различимый код пришёл (exit 5 = пойманный сигнал, D39.131), и платформа читает его так: с ЗАПИСАННЫМ намерением — пользовательский стоп, без него — ПРЕРЫВАНИЕ, то есть путь перезапуска строки 138, а не похороны. Решение названо асимметрией: прочитать ребут как стоп пользователя значит оставить все прогоны хоста мёртвыми после штатной перезагрузки, прочитать ручной `systemctl stop` как прерывание стоит одного перезапуска. Пины: `runs.TestAGracefulSignalIsAStopOnlyWhenThisPlatformAskedForOne` · `TestAGracefulSignalNobodyAskedForBringsTheRunBack` · `TestAGracefulSignalWeAskedForEndsTheRun`. Живая проба на стенде: `systemctl --user stop` → попытка 1 `interrupted`, попытка 2 открыта; наш `/stop` → `stopped`, второй попытки нет | fixed(P6, дерево сессии) | приёмка P4 (N1) | | PD-163 | bug | info | `internal/pgstore/books.go` `ListBooks`, `GetBook` | **Ревизия области читается ВТОРЫМ запросом после страницы,** поэтому под конкурентной материализацией она новее строк: клиент, соблюдающий контрактное «отбрасывай чтение с меньшей ревизией», навсегда потеряет кадры между двумя запросами. Потребителя (SSE) сегодня нет — **закрыто (P6):** страница и ревизия области читаются ОДНОЙ транзакцией (`ListBooks` → `listBooksTx`), и то же сделано карточке книги (`GetBook` + `lastRunTx`), где ревизия книги читалась до строк прогона | fixed(P6, дерево сессии) | самопроверка дофикса (ревью вне карты) | | PD-164 | bug | info | `internal/runner/marker.go`, `internal/runs/reconcile.go` | **Маркер, который существует, но не разбирается, вечно валит реконсиляцию своего прогона:** аналога карантина у этого пути нет, и ошибка чтения маркера возвращается наверх на каждом свипе. Запись атомарна (temp+fsync+rename), так что нужен внешний фактор (правка оператором, битый том). — **закрыто (P6):** неразбираемый маркер отделён от нечитаемого — `runner.ErrBadMarker` против ошибки ввода-вывода — и ведёт к терминальному концу прогона с `exit_result = marker-unreadable` (значение, которого systemd не производит), а не к вечному повтору. Ошибка ЧТЕНИЯ по-прежнему возвращается наверх и повторяется, как и должна. Пин — `runs.TestAnUnreadableExitMarkerEndsTheRunInsteadOfWedgingIt` (плюс «холд закрыт») | fixed(P6, дерево сессии) | самопроверка дофикса (ревью вне карты) | | PD-165 | hardening | info | `cmd/tmplatformd/runner.go` `markerArgv`, `internal/runner/runner.go` `quoteArgv` | **Относительный `TM_PLATFORM_CTL_BIN` проходит `os.Stat`, но systemd требует АБСОЛЮТНЫЙ путь в `ExecStopPost`** — каждый старт падает, и виден только цикл заявка-откат. Тот же класс: `quoteArgv` не экранирует `$` (подстановка переменных systemd в Exec-строках), поэтому каталог состояния с таким символом молча ломает командную строку маркера. ⚠ `%` проверен и БЕЗОПАСЕН — спецификаторы в значениях `--property=` не раскрываются (замер §17); `$` в этой сессии исполнением не проверялся. — **закрыто (P6), причём двумя разными ответами:** относительный `TM_PLATFORM_CTL_BIN` теперь отвергается на буте (`markerArgv`, пин `TestARelativeExitMarkerCommandIsRefusedAtBoot`) — воспроизведено исполнением, systemd 259 отвечает «neither a valid executable name nor an absolute path» и не стартует юнит целиком. А `$` ЗАМЕРЕН и оказался безопасен: через `systemd-run --user --property=ExecStopPost=…` путь с `$dir` доехал ЛИТЕРАЛЬНО, и доехал даже при `--setenv=dir=EXPANDED` (маркер лёг в `…/dollar$dir/`, не в `…/dollarEXPANDED/`) — то есть экранирование в `$$` было бы ошибкой, а не защитой. Тот же результат, что у `%` (§17) | fixed(P6, дерево сессии) | самопроверка дофикса (ревью вне карты) | | PD-196 | bug | minor | `internal/books/parse.go` `defer_`, движок | **Опечатка оператора в рукописном `book.yaml` стоит файла пользователя.** Движок отображает ВСЕ свои отказы на exit 1 (его собственный комментарий), поэтому «этот источник нечитаем» неотличимо от «конфиг синтаксически битый»: после пяти циклов книга отклоняется как `source_unreadable` и исходник удаляется. FP5-4 закрыл только «конфигурации нет вовсе». — **ЗАКРЫТО (P6, потребительская половина):** движковая половина приехала полосой отказов (D39.131), и интейк судит по КОДУ, а не по типу ошибки: `source_unreadable` — ТОЛЬКО exit 11, `config_invalid` (10) — деплойный класс, который ждёт человека и не тратит бюджет попыток, всё прочее (12 · 19 · обычный exit 1 · сигнал · движок не запустился) — `parser_unavailable`, который отклоняет книгу, но НЕ удаляет её файл. Пины: `books.TestOnlyTheOneRefusalClassAboutTheTextEverCostsTheUpload` (четыре класса × «файл на месте») · `TestAnEngineThatDidNotAnswerIsNeverAVerdictAboutTheBook` · `ingest.TestOutcomeOfCoversTheWholeExitContract`. Живая проба: настоящий `tmctl` без ключей провайдера вышел 10 на стенде — прогон `failed`, холд вернулся целиком, файл цел | fixed(P6, дерево сессии) | кросс-семейное ревью дофикса (Fable 5) | | PD-206 | vuln | **minor (условная)** | `internal/config/config.go` `Load`, `internal/login/dev.go` | **Имя провайдера дев-входа не было зарезервировано, и `TM_PLATFORM_OIDC_PROVIDER` — свободный ввод оператора.** Ключ личности — `(provider, subject)`; издатель, настроенный под именем `dev`, кладёт свои `sub` в то же пространство, куда пишет дев-стенд, и `sub`, совпавший с дев-субъектом, разрешается в ДЕВ-аккаунт с его засеянным кредитом. Класс PD-32/§10a (тихое связывание чужих аккаунтов), но здесь — одна переменная окружения. Четвёртое из четырёх заявленных свойств недостижимости дев-входа в проде было без этого ЛОЖНЫМ. **Закрыто:** `Load` отвергает `TM_PLATFORM_OIDC_PROVIDER == login.DevProvider`; заодно `TM_PLATFORM_DEV_LOGIN` тримится, иначе значение из пробелов монтировало вход с личностью «пробел». Пины — `config.TestAnIssuerCannotBeConfiguredUnderTheDevelopmentProvidersName` и `TestAWhitespaceDevelopmentSubjectMountsNothing`, обе посадки падают. Плюс второй, ИЗБЫТОЧНЫЙ гард в `main.go` (ветка дев-входа стоит первой в switch, и без него регресс одной строки конфига дал бы дев-входу перекрыть боевой OIDC) — он по построению недостижим, пока держится первый, поэтому теста не имеет, и это названо | fixed(P6, дерево сессии) | адверсариальное ревью P6 (кросс-семейное, Fable) | | PD-207 | bug | **minor, деньги** | `internal/pgstore/runs.go` `RestartRun`, `internal/runs/reconcile.go` `restart` | **Устаревший снапшот свипа ВОСКРЕШАЛ прогон, который другое поколение уже закрыло.** `RestartRun` отказывал только по намерению стопа на ЖИВОМ прогоне (`stopRequested != nil && finished == nil`), а на прогоне уже ЗАВЕРШЁННОМ проходил: чистил `finished_at`, брал новый холд и спавнил движок для прогона, владельцу которого уже сказали, что тот кончился. Воспроизведено: пользователь жмёт стоп, поколение A закрывает прогон как `stopped`, поколение B со снапшотом ДО стопа видит маркер exit 5 без намерения и перезапускает. Класс PD-181, и эта сессия его РАСШИРИЛА, добавив ветку «exit 5 без намерения = перезапуск». **Закрыто:** `RestartInput.OnlyIfLive` — реконсилятор просит гарантию «прогон ещё жив», резюм (который работает по определению с завершённым) не просит. Пин — `runs.TestAStalePassDoesNotResurrectARunAnotherPassHasFinished`, посадка падает с «stopped → translating» | fixed(P6, дерево сессии) | самоаудит лендинг-диффа P6 | | PD-208 | bug | info | `cmd/tmplatformctl/seed.go` | **Сид рапортовал «кредит начислен» о начислении, которого не было.** Он звал `store.Grant` напрямую и ВЫБРАСЫВАЛ возвращаемый `applied`, а ключ идемпотентности детерминирован по аккаунту — значит второй прогон `seed` на том же стенде печатал «granted», начислив ноль. Тот же класс, ради которого в этом же пакете уже был написан хелпер `write` (его собственный комментарий: «Applied» и «ключ уже потрачен» — разные исходы, и оператору говорят, какой он получил), плюс вторая половина: неудачное ЧТЕНИЕ баланса после закоммиченной записи роняло весь сид, тогда как `write` печатает предупреждение и продолжает. **Закрыто:** сид зовёт `write`; проверено исполнением на стенде — второй прогон печатает «no-op: key … was already used» | fixed(P6, дерево сессии) | адверсариальное ревью P6 (линза повторного использования) | | PD-209 | bug | **minor, деньги/статус** | `internal/runs/reconcile.go` `outcome`, `reconcile`, `restart`; `internal/pgstore/books.go` | **Причина `daily_ceiling` стиралась ДВУМЯ путями, и оба возвращали ложный «кредит кончился».** (A) exit 4 БЕЗ материализованного события закрывался как `credit_exhausted` — а это не редкий угол: карантинная попытка не дренится вовсе, значит КАЖДЫЙ её потолочный стоп шёл этим путём. (B) прогон, чей поток уже сказал `ceiling` (синк ставит `paused` без `finished_at`, то есть прогон остаётся живым), при исчезнувшем без маркера юните уходил в `restart`, а его ветка исчерпания жёстко зашивала `credit_exhausted` поверх. Последствия обе: `ReadUsage` зажигает АККАУНТНЫЙ halted-флаг на аккаунте с деньгами, и гард резюма дневного потолка обходится — резюм разрешён, новая попытка мгновенно упирается в тот же дневной лимит, цикл повторяем пользователем. **Закрыто:** третье значение `ceiling_unknown` (миграция 00015) для потолка, чей scope платформа не установила — резюмируемо, но НЕ зажигает аккаунтный флаг; прогон с уже названной причиной закрывается как пауза, а не перезапускается. Пины: `runs.TestACeilingHaltWithNoEventIsPausedWithItsReasonAndNotRestarted` · `TestACeilingThatTheStreamReportedSurvivesAUnitThatVanished` · таблица `TestWhatTheUnitDidBecomesTheProductStatus`; три посадки падают, четвёртая (фолбэк причины в ветке исчерпания) ПЕРЕЖИВАЕТ по построению и это названо в коде | fixed(P6, дерево сессии) | адверсариальное ревью P6 (линза денег и гонок) | | PD-210 | bug | **minor, шов** | `internal/pgstore/runs.go` `StartRun`/`RestartRun`/`RecordSpawn`, `internal/pgstore/sink.go` `Begin`/`effect` | **Окно усыновления чужого потока оставалось между ДОПУСКОМ и спавном.** Имя потока писалось при `RecordSpawn`, а допущенная попытка уже попадает в `ListLiveRuns` и уже дренится — с `want == ""`, то есть с усыновлением первого встречного hello. Окно — секунды внутри `tmctl status` или целый интервал свипа; `coalesce(engine_run_id, …)` в `RecordSpawn` при этом ОТКАЗЫВАЛСЯ исправить усыновлённое чужое имя на собственное, и прогон слеп на всю жизнь, а чужой `ceiling` материализовался на него (с новым `outcome` это перебивает даже чистый exit 0). **Закрыто:** имя даётся в той же вставке, что создаёт строку попытки (`pgstore.EngineStreamID` — формат переехал туда, где известен id прогона), `RecordSpawn` присваивает, а не сохраняет чужое. Побочное следствие, найденное тем же ревью: `Begin` стал недостижим для новых попыток, и вместе с ним тихо выключилось обновление `chunker_version` — эффект перенесён в `effect`, куда hello приезжает обычным событием. Пины: `runs.TestAnAttemptIsNamedBeforeAnythingCanBeSpawnedForIt` · `pgstore.TestTheChunkerVersionOfTheStreamReachesTheBook`; обе посадки падают | fixed(P6, дерево сессии) | адверсариальное ревью P6 (линза денег и гонок) | | PD-211 | bug | info | `internal/pgstore/sink.go` `unitDone` | **Апсерт разрешённого юнита был «побеждает последний ПРИШЕДШИЙ», а не последний по времени.** Именованный остаток эмиттера — строка, закоммиченная в outbox и не дошедшая до файла, — переанонсируется СЛЕДУЮЩИМ процессом: приезжает со своим seq (то есть проходит high-water mark) и несёт время СТАРОГО события. Юнит, передреденный внутри предыдущей попытки из flagged в shipped, регрессировал обратно. Счётчики не страдают (они считают строки) — страдает ровно та диспозиция, из которой читающая поверхность выводит состояние юнита. **Закрыто:** `where excluded.at >= unit_resolutions.at`; пин `pgstore.TestAReAnnouncedOlderResolutionDoesNotOverwriteANewerOne`, посадка падает | fixed(P6, дерево сессии) | адверсариальное ревью P6 (линза денег и гонок) | ## Закрытые — дофикс P6 (приёмка оркестратора №16, 15.08) | ID | Класс | Серьёзность | Где | Суть | Статус | Источник | |---|---|---|---|---|---|---| | PD-224 | bug | **major (деньги, несущий путь)** | `internal/runner/engine.go` `Status`, `internal/ingest/supervisor.go` `Status` | **`status --json` с exit 2 читался как ОТКАЗ, а движок так отвечает про любую книгу с помеченной единицей** — отчёт при этом уже напечатан (`cmd/tmctl/render.go` `renderStatusJSON`). Следствия на ОБЫЧНОМ пути: расчёт денег такого прогона откладывался вечно (холд висел — класс PD-162), `bookMeter` отказывал каждому следующему прогону книги, ре-синк умирал; дев-путь имел тот же дефект — **закрыто:** exit 2 при разбираемом отчёте = ответ, exit 2 с нечитаемым stdout остаётся отказом, знание живёт одним местом (`ingest.CompletedWithFlags`). Перечень команд, способных выйти 2, снят чтением движка: `translate`, `status`, `redrive`; `manifest` — нет. Пины `runner.TestAFlaggedBookStillAnswersAboutItsMoney` и сквозной `runs.TestTheMoneyOfAFlaggedBookIsSettledThroughTheRealExitContract` (настоящий процесс, деньги сверены балансом и суммой леджера) | fixed(дофикс P6, дерево сессии) | приёмка P6 (ФП-1) | | PD-225 | doc | minor | `deploy/README.md` | **Порядок апгрейда движка гонял `migrate` СТАРЫМ бинарём:** шаг миграции стоял ДО установки нового, то есть был no-op, и деадлок v15 оставался; там же «ДВА факта» вместо трёх — **закрыто:** новый бинарь на версионированный путь ДО миграции, `migrate` его явным путём, `TM_PLATFORM_ENGINE_BIN` переключается после; окно между списком и миграцией закрыто остановкой демона на время апгрейда; три факта названы тремя | fixed(дофикс P6, дерево сессии) | приёмка P6 (ФП-2) | | PD-226 | standards | minor | `internal/pgstore/books.go` `BooksForMigration` | **Денежный гейт миграции книг не был запинен:** посадка «убрать блокер незакрытого холда» пережила ВСЮ батарею — **закрыто:** таблица по трём блокерам, каждый по отдельности переводит вердикт книги в «нельзя», и список, который читает цикл деплой-заметки, содержит только свободную книгу; все три посадки падают. Пин `pgstore.TestEachOfTheThreeBlockersAloneKeepsABookOutOfTheMigrationList` | fixed(дофикс P6, дерево сессии) | приёмка P6 (ФП-3) | | PD-227 | bug | minor | `internal/runs/reconcile.go` `reconcile` | **Ветка нечитаемого маркера хоронила потолочную паузу как `failed` с пустой причиной:** единственная из веток конца попытки, которая не перечитывала причину после дрейна, — а маркер, который не разбирается, не несёт и кода выхода, так что поток остаётся единственным свидетелем (воспроизведено приёмкой на живом PG) — **закрыто:** обе маркерные ветки слиты, перечит один и общий. Пин `runs.TestACeilingSurvivesAMarkerThatCannotBeRead` | fixed(дофикс P6, дерево сессии) | приёмка P6 (ФП-4) | | PD-228 | vuln | **minor (условная)** | `internal/config/config.go` `Load` | **`TM_PLATFORM_INSECURE_COOKIES` не отвергался рядом с боевым OIDC:** гейт ловил только связку с DEV_LOGIN, а дев-рецепт экспортирует обе переменные, так что «стендовое окружение уехало в прод» ловилось наполовину — демон стартовал с боевым Google-входом, куками без Secure и без `__Host-`, с выключенным HSTS (воспроизведено приёмкой живым демоном) — **закрыто:** судит не догадка, а собственный адрес деплоя: callback OIDC и есть публичный URL платформы, поэтому `http` там = стенд (законно, работает), всё прочее — отказ на буте. Пин `config.TestCookiesWithoutSecureAreRefusedNextToAProductionProvider` | fixed(дофикс P6, дерево сессии) | приёмка P6 (ФП-5) | | PD-229 | bug | minor | `internal/pgstore/books.go` `CeilingPause` | **Незнакомый scope потолка читался как `credit_exhausted`** и зажигал аккаунтный halted-флаг (`ReadUsage` ключится ровно на это значение) на аккаунте, у которого деньги есть, — при том что этот же пак завёл `ceiling_unknown` ровно для «потолок был, чей — не установлено» — **закрыто:** `book` и `day` называются, всё остальное = `ceiling_unknown`; резюмируемость не меняется ни при одном из трёх, а ложного «кредит кончился» больше нет. Пин — таблица `pgstore.TestTheCeilingScopeDecidesWhichPauseTheRunGets` | fixed(дофикс P6, дерево сессии) | приёмка P6 (ФП-6) | | PD-230 | bug | info | `cmd/tmplatformctl/seed.go` | **Сид не тримил `--subject`, а демон тримит:** падённое значение одной переменной окружения давало вход под одной личностью и поиск другой — аккаунт создавался, одноразовый грант сгорал, сид обрывался (воспроизведено приёмкой) — **закрыто:** одна нормализация с обеих сторон. Пин `TestASubjectThatIsOnlyPaddingIsNoSubject` | fixed(дофикс P6, дерево сессии) | приёмка P6 (ФП-6) | | PD-231 | doc | info | `internal/pgstore/sink.go` `unitDone` | **Гард `at >=` обосновывался несуществующим поведением движка:** «переанонс несёт СТАРОЕ время события» — по коду движка неверно, `at` штампует анонсирующий процесс, а непроецированная строка мёртвого прогона стирается `ForgetEvents` и анонсируется заново со своим временем — **закрыто:** гард остаётся (потребитель at-least-once обязан быть независимым от порядка доставки), довод приведён к факту здесь и в пинящем тесте | fixed(дофикс P6, дерево сессии) | приёмка P6 (ФП-6) | | PD-232 | doc | info | `internal/runs/reconcile.go` `Resume` | **Комментарий про book-ceiling утверждал «remaining ноль и резюм ничего не меняет»** — по арифметике остаток положителен, потому что движок останавливается на невместившемся резервировании, а не на самом потолке — **закрыто:** комментарий приведён к факту, сам чурн заведён строкой PD-223 | fixed(дофикс P6, дерево сессии) | приёмка P6 (ФП-6) | | PD-233 | bug | minor | `internal/runs/reconcile.go` `outcome` | **Exit 5 со стопом, записанным ПОЗЖЕ маркера, закрывался `failed`:** намерение снимало «прерывание», а сравнение времён отвергало «стоп» — при том что exit 5 недостижим для прогона, кончившегося сам, и два сравниваемых штампа приходят с разных часов (маркер пишет хост юнита, намерение — платформа) — **закрыто:** exit 5 при записанном намерении = `stopped` независимо от порядка; чистые концы 0/2/3 отвечены раньше в том же switch и не сдвинулись, что пинится отдельно | fixed(дофикс P6, дерево сессии) | приёмка P6 (ФП-6) | | PD-234 | doc | info | `docs/platform-PROGRESS.md` | **Базис «396 → 403» в шапке P6 не воспроизводился** (в HEAD 359 `^func Test`, откуда 396 — неизвестно) — **закрыто:** числа пересчитаны, а рядом со строкой написана команда счёта, чтобы следующая сессия сверяла, а не переписывала | fixed(дофикс P6, дерево сессии) | приёмка P6 (ФП-8) | | PD-235 | standards | info | `internal/config/config_test.go` | **Подтест-пассажир в пине нового гейта:** строка «callback пуст» оставалась зелёной при УДАЛЁННОМ гейте — её отвергает проверка неполного OIDC этажом выше, то есть про сам гейт она не говорила ничего. Класс тот же, что «слепой пин» из P6: покрытие, которого нет — **закрыто:** строка убрана из цикла (неполный OIDC пинится своим тестом), у гейта осталось два настоящих свидетеля, оба падают на посадке | fixed(дофикс P6, дерево сессии) | кросс-семейное ревью дофикса P6 (security-линза) | | PD-236 | bug | **minor, контрактно видимый** | `internal/runs/reconcile.go` `freshPausedReason` | **Одна неудачная перечитка причины хоронила потолочную паузу как `failed` без причины.** Перечит падал обратно на снапшот — а он пуст ровно в том случае, ради которого перечит и существует (событие приехало в ЭТОТ дрейн), — и вызывающие закрывали прогон на этой пустоте. На ветке испорченного маркера кода выхода нет, а сама ветка закрывает прогон по построению (PD-164), значит следующего прохода не будет: обычный перезапуск Postgres превращал резюмируемый стоп в `failed`. Тот же глоток заново вооружал перезапуск, который ветка «поток сказал ceiling» построена предотвращать — **закрыто:** ошибка чтения возвращается наверх, ничего не решается на неустановленном факте. Пин `runs.TestAnEndingIsNeverDecidedFromAReadThatFailed` | fixed(дофикс P6, дерево сессии) | кросс-семейное ревью дофикса P6 (линза автомата состояний) | | PD-237 | bug | **minor, деньги** | `internal/runner/engine.go` `Status` | **Exit 2 принимался по коду и разбору, без согласия самого отчёта.** Движок отдаёт этот код из `status --json` при одном условии — `flagged > 0`, — и оно не проверялось: измерено ревью, что отчёт с `flagged:0`, отчёт про ЧУЖУЮ книгу и документ без `book_id` при exit 2 РАССЧИТЫВАЛИСЬ (2.5 USD против холда) вместо отложенного расчёта. Достижимо не движком, а тем, что стоит на запиненном пути и им не является: обёртка, подменённый на месте каталог версии, недокатанный деплой — **закрыто:** три условия вместе (код, разбор, согласие отчёта). Пин `runner.TestExitTwoIsTakenOnlyFromAReportThatAgreesWithIt` | fixed(дофикс P6, дерево сессии) | кросс-семейное ревью дофикса P6 (денежная линза) | | PD-238 | bug | **minor** | `internal/ingest/manifest.go` `DecodeManifest` | **Документ, который не манифест, приезжал ПУСТЫМ манифестом — а пустой манифест удаляет загрузку пользователя.** `{}`, `null` и любой объект незнакомых полей декодировались в нули, интейк читал нулевые главы как «движок разрезал источник и книги в нём нет» и сносил каталог. Версия намеренно не гейтится по ЗНАЧЕНИЮ (иначе всякий релиз движка — релиз платформы), и в этом зазоре единственным сторожем деструктивного пути было имя JSON-поля — **закрыто:** требуется НАЛИЧИЕ `manifest_version` (не значение), а интейк отдельно различает «глав нет» и «глав нет, но юниты есть» — второе не вина книги. Пины `ingest.TestADocumentThatIsNotAManifestIsNotAnEmptyManifest`, `books.TestAManifestThatContradictsItselfNeverCostsTheUpload` | fixed(дофикс P6, дерево сессии) | кросс-семейное ревью дофикса P6 (линза шва) | | PD-239 | hardening | info | `internal/runner/engine.go` `Manifest` | **Интейк держался на непроверяемом инварианте чужой зоны:** «`manifest` не может выйти 2» — верно сегодня (сентинел строится в четырёх местах `render.go`, ни одно не манифестное), но в движке этого не пинит ничто, а цена ошибки — вся книжная очередь хоста, потратившая бюджет попыток на документ, который движок уже напечатал — **закрыто:** правило читается, а не предполагается: exit 2 = команда отработала, и на интейковом канале тоже. Пин `runner.TestAManifestThatCompletesWithFlagsIsStillAManifest` | fixed(дофикс P6, дерево сессии) | кросс-семейное ревью дофикса P6 (линза шва) | | PD-240 | hardening | info | `internal/runner/engine.go` `Status`, `drain` | **Канал расчёта читал stdout без потолка,** тогда как соседний `Manifest` кап имеет (64 МиБ) — при том что `Status` зовётся на каждом завершившемся прогоне. Найдено ДВУМЯ линзами независимо. Написание пина вскрыло вторую половину: дренаж, который «нельзя не делать» (иначе EPIPE), на бесконечном писателе не возвращается никогда, то есть кап был во власти того, что ограничивает — **закрыто:** кап 16 МиБ (замерено: настоящая книга в 2283 главы даёт 1.1 МБ), за капом процесс убивается, а не дренится; обе команды ходят через один `drain`. Пин `runner.TestAnEndlessStatusIsRefusedRatherThanRead` (посадка виснет и падает по таймауту) | fixed(дофикс P6, дерево сессии) | кросс-семейное ревью дофикса P6 (линзы шва и денег) | ## Закрытые — эра P7 (читающая поверхность контракта 0.3.0) > ⚠ Из этой эры ОТКРЫТЫМИ остались `PD-281` · `PD-297` · `PD-298` · `PD-299` — их тела живут в секции «Открытые — info», куда перенесены оркестратором №18 22.08. Строка со статусом `open` под заголовком «Закрытые» невидима тому, кто читает открытые, а именно так этот файл и читают. | ID | Класс | Серьёзность | Где | Суть | Статус | Источник | |---|---|---|---|---|---|---| | PD-185 | bug | info | `internal/pgstore/sink.go`, `internal/runs/runs.go` `readyToTranslate` | **Статус `finalizing` есть в контракте, в DDL и в обоих аллоулистах — писателя нет ни одного.** Тот же класс, что интейк-статусы до этого пака: слово контракта без пути, который его пишет. Сегодня недостижим, поэтому вреда нет; лечится либо писателем (финальная волна движка), либо снятием из аллоулистов, когда станет ясно, что его не будет — **закрыто:** 0.3.0 снял значение из контракта, P7 снял его из обоих Go-аллоулистов и из обоих CHECK-констрейнтов (миграция 00016), грепом `finalizing` в `internal/` и `cmd/` пусто | fixed(P7, дерево сессии) | кросс-семейное ревью P5 (Fable) | | PD-104 | bug | **minor, расхождение док↔код** | `internal/login/login.go:285-288`, `internal/config/config.go:73` | **Фри-тир начисляется АВТОМАТИЧЕСКИ, а реестр обещает обратное.** Код: дефолт `SignupGrantMicroUSD: 5 * 1_000_000` (`config.go:73`) проведён в демона (`main.go:99`) и логин отдаёт его в стор на каждой новой подтверждённой паре `(provider, subject)` — аккаунт создаётся С $5. Строка PD-30 при закрытии утверждает «аккаунт создаётся с нулём, начисление руками из админки» — один из двух текстов лжёт, и это чинится независимо от продуктового решения. Ограничитель у автогранта один — лимитер входа; агрегатного потолка, счётчика и алерта нет (грепнуто). ⚠ **ЗАКРЫТО P7 словом владельца 16.08 (D39.138 п.2л):** дефолт `SignupGrantMicroUSD` = **0** (`config.go`), начисление на бете — руками через `tmplatformctl grant`; возврат $5 идёт вместе с суточным агрегатным потолком, когда появятся платежи. Пин — `config.TestSignupGrantIsParsedNotGuessed` (пинит ИМЕННО ноль, чтобы восстановление дефолта мимо ратификации падало); протухшие «$5» вычищены из `PLATFORM_DIRECTION.md` §2 и `BACKLOG.md` П-7 | fixed(P7, дерево сессии) | приёмка P2 (панель; расхождение — оркестратор №15) | | PD-172 | standards | minor | `docs/architecture/14-api-contract/openapi.yaml` `createBook` | **Проводное правило `POST /books` в контракте не записано: часть `file` ОБЯЗАНА идти последней.** Потоковый читатель (`r.MultipartReader`) отдаёт части в порядке провода, а строка книги — то, что делает загрузку видимой во время приёма и находимой, если она оборвалась, — не может быть записана до языков, которые в ней обязательны. Тем же правилом специфицирована браузерная загрузка S3 («the file or content must be the last field in the form»). Платформа отвечает 400 на форму, приславшую поля после файла, и это поведение спека не описывает. ⚠ **ЗАКРЫТО:** правило записано в каноне 0.3.0 (§createBook: «The `file` part MUST come LAST… A part sent after the file is refused, never ignored: 400, `invalid_request`, с `errors[]`»), и P7 привёл к нему код — часть после файла ПРОВАЛИВАЕТ чтение, поэтому загрузка откатывается вместе с отказом (иначе пользователь получал 400 И книгу). Пин `httpapi.TestAPartAfterTheFileIsRefusedAndTheBookIsNotKept`, живая проба на стенде | fixed(P7, дерево сессии) | сессия P5 (сверка контракта с построенным маршрутом) | | PD-173 | standards | info | `docs/architecture/14-api-contract/openapi.yaml` `Book` | **У отклонённой книги нет ПРИЧИНЫ на проводе.** `BookStatus.rejected` описан как «file could not be parsed», а почему именно — не поле контракта. Платформа хранит собственный закрытый словарь причин (`books.ReasonSourceUnreadable` · `ReasonNotConfigured` · `ReasonParserUnavailable`, колонка `books.reject_reason`, миграция 00013) для оператора и НЕ проецирует его: выдумывать поле не право зоны (прецедент PD-150 — `last_resync_at`). Следствие для пользователя: экран покажет «не удалось разобрать» без различения «файл не тот» и «наш движок был недоступен» — а это разные советы ⚠ **ЗАКРЫТО:** канон 0.3.0 завёл `Book.reject_reason` со словарём `RejectReason`, P7 проецирует внутреннюю причину на него (`httpapi.contractRejectReason`) — с одним переименованием по эффекту: `parser_unavailable` → `processing_failed` (ФБ-9: имя значения это то, на что клиент вешает фразу, и оно не должно называть наш компонент) | fixed(P7, дерево сессии) | сессия P5 | | PD-174 | standards | minor | `internal/httpapi/v0.go` `contractRoutes` | **`POST /books` на инстансе без интейка отвечает 404, которого спека для этого маршрута не предусматривает.** Форма унаследована от уже принятого решения зоны — непостроенный/несмонтированный маршрут остаётся охраняемым 404 (`d.Runs == nil` так же), и это честнее 503 на КАЖДЫЙ аплоад, который инстанс всё равно не примет. Но контрактно это тот же класс, что PD-112: код ответа, которого в перечне операции нет. ⚠ Дополнено адверсариальным ревью P5: тот же класс есть и у РЕЗЮМА — `503` на деплое без маркер-команды или без шаблона потолка, тогда как 0.2.1 ратифицировала 503 только для СТАРТА прогона ⚠ **ЗАКРЫТО каноном 0.3.0:** `createBook` объявил `404` для деплоя, который книг не принимает (`intake_enabled` у `GET /capabilities`), а `503` объявлен ответом и `startRun`, и `resumeRun`. Обе формы теперь в перечне операций, вопрос закрыт ратификацией, а не кодом | fixed(P7, ратификация 0.3.0) | сессия P5 (самопроверка против спеки) | | PD-180 | standards | info | `docs/architecture/14-api-contract/openapi.yaml` `createBook` | **Ответ 201 несёт `parsing`, а не `uploading`, и «responds immediately» недостижимо как класс.** Спека описывает createBook словами «Responds immediately; the book enters `uploading`», но ответ по HTTP не может уйти раньше, чем прочитано тело: к моменту 201 файл уже принят, и честный статус — `parsing`. `uploading` при этом РЕАЛЬНО наблюдаем, но только параллельным чтением библиотеки (пин `books.TestABookIsVisibleAsUploadingWhileItsFileIsStillArriving`). Сюда же — отказы интейка, которых спека не описывает: больше 16 частей формы и поле длиннее 1 КиБ дают 400, тело сверх потолка — 413 с порогом, которого в контракте нет, а тело медленнее дедлайна маршрута отвечало обрывом соединения без problem-ответа (дофикс это исправил, см. ниже). ⚠ Дополнено дофиксом приёмки: в тот же пакет вопросов входит **408** на просроченный дедлайн загрузки — тело, не успевшее прийти за отведённое маршруту время, это медленный клиент, и 408 по RFC 9110 §15.5.9 говорит именно это, включая то, что лечение — повтор; кода 408 в перечне операции нет ⚠ **ЗАКРЫТО каноном 0.3.0:** «Responds immediately» снято, `201` описан как несущий `parsing` дословно; `408`, `413` и `400`-отказы интейка объявлены ответами операции, перечень помечен НЕисчерпывающим (авторитет — `code` ответа) | fixed(P7, ратификация 0.3.0) | адверсариальное ревью P5 (сверка провода со спекой) | | PD-199 | standards | minor | `internal/httpapi/v0.go` `contractPausedReason`, контракт `openapi.yaml` §PausedReason | **У платформы две причины паузы, у контракта одна — и вторая на провод не выходит.** `PausedReason` спеки перечисляет `credit_exhausted`; с приходом `ceiling.scope` (D39.131) платформа различает потолок, который поставила САМА, и дневной потолок из `book.yaml` оператора, который она не выбирала и на котором у аккаунта деньги ЕСТЬ. Проецировать второй как первый нельзя — экран скажет «кончились деньги», и зажжётся аккаунт-флаг `ReadUsage`; выдумывать слово контракта не право зоны (прецеденты PD-173, PD-150). Поэтому внутренняя причина `daily_ceiling` хранится и проецируется как `null`, что спека сама и предписывает клиенту рендерить для незнакомой причины. ⚠ Найдено ЖИВОЙ ПРОБОЙ, а не чтением: до фикса значение уезжало на провод, и клиент, сгенерированный по спеке, его бы отверг. ⚠ **ЗАКРЫТО ратификацией D39.132 п.2а** («null на проводе подтверждён») — статус приведён P7; проекция осталась той же (`httpapi.contractPausedReason`), а `Usage` получил СВОЙ словарь `AccountHaltReason`, чтобы прогонная причина больше не могла зажечь аккаунтный флаг | fixed(P7, ратификация D39.132) | сессия P6 (живая проба на стенде) | | PD-241 | bug | minor | `internal/runs/reconcile.go` `outcome` | **Стоп, о котором попросил ПОЛЬЗОВАТЕЛЬ, переименовывается в `paused/credit_exhausted`, если в тот же дрейн приехал потолок:** причина паузы проверяется раньше намерения стопа, и аккаунтный halted-флаг (`ReadUsage` ключится на `credit_exhausted`) зажигается на аккаунте, у которого 97% баланса на месте (воспроизведено ревью). Денег не теряется и прогон резюмируем, но `/usage` говорит «кредит кончился» человеку, у которого он есть. Порядок «причина раньше намерения» — ратифицированное решение P6, и молча его переворачивать этим касанием я не стал; денежно-видимая половина — это PD-203 ⚠ **ЗАКРЫТО P7** (ратификация D39.132 п.2д «следующим касанием зоны» — это касание): порядок в `outcome()` перевёрнут — намерение стопа проверяется ПОСЛЕ собственных окончаний движка и ДО причины паузы, так что стоп пользователя приезжает `stopped` без причины, а аккаунтный флаг не зажигается. Пин `runs.TestAStopTheUserAskedForOutranksACeilingThatArrivedWithIt` (все три причины паузы + контроль: без намерения решает потолок) | fixed(P7, дерево сессии) | кросс-семейное ревью дофикса P6 (линза автомата состояний) | | PD-223 | bug | minor | `internal/runs/reconcile.go` `Resume`, `reopen` | **Резюм прогона, остановленного ПЛАТФОРМЕННЫМ потолком, — цикл «холд-спавн-пауза» за клик.** Движок останавливается на резервировании, которое не помещается, то есть НЕ доходит до своего потолка: расчёт списывает меньше холда, у прогона остаётся положительный остаток (обычно меньше одного вызова), `exhausted` не наступает, и резюм открывает попытку, которая упирается в тот же потолок сразу. Денег провайдера не тратится; цена — `tmctl status` и транзиентный юнит на клик. Порог здесь угадывать нельзя: размер резервирования — число движка, платформе не видное ⚠ **ЗАКРЫТО P7 сменой контракта:** резюм прогона, остановленного ЛЮБЫМ потолком, теперь отвечает 409 `run_not_resumable` · `cause.code: ceiling_reached` (канон 0.3.0 §resumeRun), то есть цикла «холд-спавн-пауза за клик» не существует — лечение это НОВЫЙ прогон с бОльшим потолком. Пин `runs.TestAResumeOfARunPausedAtALimitIsRefusedWhoseverLimitItWas` | fixed(P7, дерево сессии) | приёмка P6 (дофикс, ФП-6) | | PD-242 | bug | info | `internal/runs/reconcile.go` `Resume` | **`ceiling_unknown` проходит гейт резюма, который отказывает `daily_ceiling`:** дневной потолок, приехавший ТОЛЬКО кодом выхода (карантин проекции — случай, который собственный комментарий `outcome` называет не-редким), записывается как «чей потолок, не установлено» и резюмируется свободно, упираясь в тот же лимит того же дня. Не регресс (прежнее `credit_exhausted` резюмировалось так же) и тот же класс чурна, что PD-223 ⚠ **ЗАКРЫТО P7 тем же ходом, что PD-223:** гейт больше не различает причины — резюм отказан при ЛЮБОЙ паузе, поэтому `ceiling_unknown` не может его пройти | fixed(P7, дерево сессии) | кросс-семейное ревью дофикса P6 (линза автомата состояний) | | PD-257 | bug | **major** | `internal/pgstore/readmodel.go` `writeChapters`, `migrations/00002:109` | **Пере-нарезка со сдвигом нумерации валила читающую модель НАВСЕГДА.** Id главы у движка — хеш её текста и переживает ре-кат, `number` позиционный и сдвигается; апсерт по `id` ставил номер, который ещё держит соседка, и `unique (book_id, number)` ронял всю транзакцию `SaveStructure`. Вход детерминирован → дерево замирало молча, `structure_version` не двигался, клиенту никто не говорил пере-синхронизироваться. Достижимо апгрейдом движка/лангпака (правило заголовков роняет заголовочную главу). Delete-first НЕ лечит: сталкиваются ВЫЖИВШИЕ главы — **закрыто:** констрейнт стал `deferrable initially deferred` (00018); пины `pgstore.TestARecutThatShiftsChapterNumbersIsMaterialized` (вставка в начало И обратная форма) | fixed(приёмка P7, дерево сессии) | приёмка P7 (воркфлоу 12 осей + кросс-модель) | | PD-258 | bug | **major** | `internal/readmodel/readmodel.go` `refreshStructure`, `pgstore.writeUnits` | **Упавший `tmctl export` затирал текст книги пустотой.** Дерево и пары — два вызова движка; при отказе второго все пары уходили в `SaveStructure` с пустыми `source`/`target` и `pending`, а кат не менялся ⇒ те же id ⇒ ветка `do update` перезаписывала переведённую книгу пустыми строками до следующего успешного экспорта — **закрыто:** `Structure.TextRead` несёт факт «канал ответил», апсерт трогает текст только при нём; пин `pgstore.TestAFailedExportDoesNotEraseTheTextAlreadyMaterialized` | fixed(приёмка P7, дерево сессии) | приёмка P7 (воркфлоу 12 осей + кросс-модель) | | PD-259 | bug | minor | `internal/pgstore/idempotency.go` `ClaimIdempotency` | **Две ОДНОВРЕМЕННЫЕ первые попытки давали 500 вместо 409.** `select ... for update` по несуществующей строке не блокирует ничего, обе доходили до голого `insert`, проигравшая ловила 23505 — ни один из двух конфликтов контракта, наружу `internal_error` на том самом случае, ради которого заголовок существует — **закрыто:** `on conflict (user_id, key) do nothing` + перечитывание (идиома `identities` того же пакета); пин `pgstore.TestTwoSimultaneousFirstClaimsAnswerInFlightAndNeverAnUnexpectedError` (8 гонщиков) | fixed(приёмка P7, дерево сессии) | приёмка P7 (воркфлоу 12 осей + кросс-модель) | | PD-260 | bug | minor | `internal/httpapi/v0.go` `createBook`/`startRun`, `httpapi/idempotency.go` | **Оборвавшийся клиент получал ВТОРУЮ книгу.** `complete`/`release` писали на `r.Context()`, который отменяет ровно тот клиент, который будет ретраить: ключ висел «в полёте» `claimStale`=30 мин, потом takeover переделывал работу. Рядом в зоне уже жило решение того же случая (`books.writeCtx`) — **закрыто:** `settleCtx` = `WithoutCancel` + свой дедлайн | fixed(приёмка P7, дерево сессии) | приёмка P7 (воркфлоу 12 осей + кросс-модель) | | PD-261 | bug | minor | `internal/pgstore/migrations/00017:30`, `httpapi/idempotency.go` | **`path` в первичном ключе неограничен:** длинный адрес переполняет кортеж btree, клейм падает, и ручка отвечает 500 там, где контракт обещает 409 — **закрыто:** путь КЛЮЧУЕТСЯ дайджестом (`path_sha256`), сам остаётся читаемой колонкой (00018); пин `TestAnOverlongPathDoesNotBreakTheClaim`. ⚠ Область ключа — `(principal, method, path)`: «the same key on another operation is another key», пин `TestOneKeyOnAnotherOperationIsAnotherKey` | fixed(приёмка P7, дерево сессии) | приёмка P7 (воркфлоу 12 осей + кросс-модель) | | PD-263 | bug | minor | `internal/pgstore/runs.go` `StartRun`, `ReleaseBankStop` | **Полоса прогона и её база считали РАЗНЫЕ проходы.** `chapters_before` снимался по edit-колонке, а первый сегмент прогона со стопом на подпись считает по draft-колонке ⇒ новый прогон над уже начерновленной книгой открывался на полном потолке — ровно тот дефект, ради которого колонку и заводили. Симметрично: при снятии стопа числитель переключается на edit, а база оставалась draft'овой — **закрыто:** база снимается тем же предикатом, что числитель, и ПЕРЕ-снимается в `ReleaseBankStop`; пин `pgstore.TestTheBarAndItsBaselineCountTheSamePass` | fixed(приёмка P7, дерево сессии) | приёмка P7 (воркфлоу 12 осей + кросс-модель) | | PD-264 | bug | minor | `internal/pgstore/readmodel.go` `writeChapters` | **После пере-нарезки глава наследовала прогресс и замечания чужой главы.** `unit_resolutions` адресованы движковыми (номер главы, ординал), которые новый кат пере-указывает, и не чистились — **закрыто:** ре-кат удаляет резолюции ушедшего ката (переводить их не по чему); пин `pgstore.TestARecutDoesNotLetAChapterInheritTheProgressOfTheOneBeforeIt` | fixed(приёмка P7, дерево сессии) | приёмка P7 (воркфлоу 12 осей + кросс-модель) | | PD-265 | bug | minor | `internal/httpapi/stream.go` `pump` | **Кадры, подрезанные буфером при ЖИВОМ соединении, пропадали молча** — без `resync_required`, хотя `note` доставляется однажды и молчаливый пропуск = замечание потеряно навсегда — **закрыто:** `state.Oldest > from+1` в цикле даёт тот же ответ, что и на рукопожатии; пин `httpapi.TestFramesPrunedWhileTheConnectionIsOpenAskTheClientToResync` (без него мутация проходила батарею) | fixed(приёмка P7, дерево сессии) | приёмка P7 (воркфлоу 12 осей + кросс-модель) | | PD-266 | bug | minor | `internal/httpapi/stream.go` `stream.write` | **На маршруте потока не было дедлайна записи:** клиент, открывший поток и переставший читать, держал горутину, соединение и слот сервера вечно — тихий уход не отменяет `r.Context()` — **закрыто:** `SetWriteDeadline` на каждый кадр через `ResponseController`; пин `httpapi.TestEveryFrameIsWrittenUnderADeadline` | fixed(приёмка P7, дерево сессии) | приёмка P7 (воркфлоу 12 осей + кросс-модель) | | PD-267 | bug | info | `internal/httpapi/stream.go` `streamEvents` | **`Last-Event-ID` больше позиции книги принимался и отвечал 204.** Такого id сервер не выдавал (реальный триггер — восстановление БД из бэкапа двигает `event_position` назад), и 204 велит клиенту прекратить переподключение по числу, которое нельзя проверить — **закрыто:** id, с которого сервер продолжить не может, отвечается `resync_required` — так канон и требует («rather than silently starting from now»); ветка `204` для «at or past на книге в покое» сохранена, она тоже каноническая. Пин `httpapi.TestAnEventIDTheServerCannotResumeFromIsToldToResync` | fixed(приёмка P7, дерево сессии) | приёмка P7 (воркфлоу 12 осей + кросс-модель) | | PD-268 | bug | minor | `internal/runs/reconcile.go` `finish`, `Sweep` | **Материализация читающей поверхности голодала свип.** `refreshReadModel` брал 5 минут (`WithoutCancel`) ВНУТРИ пер-прогонного бюджета в 60 с, то есть уходил из-под `withBudget` и держал расчёт денег всех прогонов позади себя — ровно та голодовка, ради которой `withBudget` и заведён (PD-169) — **закрыто:** работа копится (`deferRefresh`) и сливается ОТДЕЛЬНЫМ проходом демона (`DrainRefresh`, `pass("readmodel", …)`). | fixed(приёмка P7, дерево сессии) | приёмка P7 (воркфлоу 12 осей + кросс-модель) | | PD-269 | bug | minor | `internal/runner/artifacts.go` `projectDB` | **Банк не читался на штатном деплое.** Требовался ключ `project_db`, который движок делает НЕОБЯЗАТЕЛЬНЫМ (дефолт `.db`, `backend/internal/config/book.go:163`), а канонический шаблон оператора его не содержит — **закрыто:** тот же дефолт, что у движка; пин `runner.TestTheProjectDatabaseDefaultsTheWayTheEngineDefaultsIt` | fixed(приёмка P7, дерево сессии) | приёмка P7 (воркфлоу 12 осей + кросс-модель) | | PD-270 | bug | info | `internal/httpapi/problem.go` `WriteProblem` | **`Problem` без `Code` уходил как 500 БЕЗ обязательного `code`.** Канон требует код на каждом ответе `/v0`, 500 включая; `omitempty` существует ради поверхности, которую канон не описывает, и протекал обратно. Живого вызова не было — латентная ловушка, бьющая по принципу самого файла («расходящаяся пара непредставима») — **закрыто:** пустой код → `internal_error`; пин `httpapi.TestAProblemWithNoCodeStillAnswersOne` | fixed(приёмка P7, дерево сессии) | приёмка P7 (воркфлоу 12 осей + кросс-модель) | | 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:107`=`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 | | PD-278 | bug | minor | `internal/pgstore/readmodel.go` `SaveStructure` | **Первая материализация книги трактовалась как ПЕРЕ-нарезка и стирала работу оплаченного прогона.** `storedKey = ''` ≠ ключу манифеста ⇒ ветка ре-ката удаляла ВСЕ `unit_resolutions` книги. Достижимо штатно: интейк-материализация упала (PD-276), пользователь прогнал книгу, и первый успешный `SaveStructure` стирал резолюции завершённого прогона — карточка 0 глав, все замечания потеряны, восстановление только новым прогоном за деньги. Регрессия правки PD-264 — **закрыто:** удаление при `storedKey != "" && ключ сменился`; пин `pgstore.TestTheFirstMaterializationDoesNotDiscardAFinishedRunsWork` | fixed(приёмка правок P7, дерево сессии) | приёмка правок P7 (9 осей + fable-5) | | PD-279 | bug | minor | `internal/pgstore/migrations/00018_recut_and_key_scope.sql` Down | **Down-путь 00018 не исполнялся на тех данных, которые Up впервые легально принимает:** строка с путём длиннее ~2704 байт не влезает в восстанавливаемый старый первичный ключ, и `goose DownTo(17)` падал детерминированно — то есть откат релиза ломался ровно в аварии, ради которой откат существует. Регрессия правки PD-261 — **закрыто:** Down снимает такие строки перед пересборкой ключа (таблица — кэш с ретенцией 24 ч); проверено живым PG в цикле `UP → данные → DOWN → UP` | fixed(приёмка правок P7, дерево сессии) | приёмка правок P7 (9 осей + fable-5) | | PD-280 | standards | info | `internal/httpapi/idempotency.go`, `v0.go` `intakeFingerprint` | **HTTP-половина `Idempotency-Key` не имела тестов**, и именно там жили три дефекта: что попадает в отпечаток, какая область у клейма и на каком контексте ключ закрывается — из `pgstore` это не наблюдаемо (там опаковый дайджест) — **закрыто:** три пина (`TestTheSameUploadReFramedIsStillTheSameRequest`, `TestTheClaimCarriesTheRequestsOwnOperation`, `TestAnOverlongKeyIsRefusedBeforeAnythingIsClaimed`) | fixed(приёмка правок P7, дерево сессии) | приёмка правок P7 (9 осей + fable-5) | | PD-283 | bug | minor | `internal/httpapi/stream.go` `pump` | **Подрезка буфера ДО чтения кадров пропускалась молча:** сверка `state.Oldest > from+1` делалась ПОСЛЕ того, как водяной знак перепрыгнул через дыру, поэтому `Oldest` оказывался ПОЗАДИ него и условие не срабатывало никогда — то есть правка PD-265 не закрывала собственный сценарий, а кадры `note`, которые канон запрещает терять, терялись. Батарея не видела: единственный пин стоял на подрезке МЕЖДУ чтениями — **закрыто:** дыра опознаётся по голове пачки (`frames[0].Position > from+1`; позиции непрерывны — единственный писатель `emitFrame`), кадры этой пачки не отправляются; пин `TestAHoleAtTheHeadOfTheBatchAsksTheClientToResync` | fixed(доработка 20.08, дерево сессии) | доработка 20.08 (сверка находок против дерева) | | PD-284 | bug | minor | `internal/pgstore/idempotency.go` `CompleteIdempotency` | **Квитанция писалась в строку, которая этой попытке уже не принадлежит.** `where` не различал ни живую строку от исчезнувшей, ни свой клейм от перехваченного: воспроизведено живым PG — попытка, у которой клейм забрали по протуханию, дописывала свой ответ, и клиент получал квитанцию ЧУЖОГО запроса (`status=201 location=/v0/books/bk_old`). Тег команды при этом выбрасывался в `_`, поэтому «квитанция не записана» было ненаблюдаемо — **закрыто:** `and finished_at is null` + отдельная `ErrClaimLost`; окно перехвата у ЖИВОЙ попытки закрыто загрузочной проверкой `TM_PLATFORM_UPLOAD_DEADLINE < pgstore.ClaimStale` (та же форма, что уже стояла для `UploadGrace`); пин `TestAnUploadDeadlineLongerThanEitherWindowIsRefused` ⚠ **ЭРРАТА (акт 5): закрытие было ПРЕЖДЕВРЕМЕННЫМ.** `and finished_at is null` закрывает только «загрузка дольше окна» и ничего не говорит про ВЛАДЕЛЬЦА клейма: строка перехвачена, а `finished_at` у преемника законно пуст. Целиком закрыто токеном клейма — PD-300 | fixed(доработка 20.08, дерево сессии) | доработка 20.08 (сверка находок против дерева) | | PD-285 | standards | minor | `internal/runs/reconcile_test.go`, `internal/httpapi/idempotency_test.go` | **Два пина утверждали свойство, которого у кода нет.** `TestAResumedRunIsSpawnedWithoutTheSigningStop` гонял фикстуру, стартующую прогон БЕЗ `stop_for_signing`, поэтому подмена `l.VerifyBank && !l.BankReleased` → `l.VerifyBank` его не роняла — пин PD-277 был пустым. `TestTheSameUploadReFramedIsStillTheSameRequest` объявлял «тот же файл в другой оболочке — тот же запрос», тогда как боевой вызов кладёт в отпечаток `r.ContentLength`, который оболочку считает — **закрыто:** первый проходит весь путь (первая попытка обязана НЕСТИ флаг, вторая — нет), второй переименован и утверждает то, что код делает, с честной границей PD-262 | fixed(доработка 20.08, дерево сессии) | доработка 20.08 (сверка находок против дерева) | | PD-286 | bug | info | `internal/httpapi/stream.go` `write`, `streamEvents` | **Ошибка `Flush` выбрасывалась**, а на маленьком кадре это единственный вызов, который видит залипший сокет: `Fprintf` пишет в bufio и возвращает nil, поэтому поток рапортовал «кадр ушёл» о кадре, который не ушёл, и залипший клиент получал `writeTimeout` на каждый heartbeat вместо одного. Плюс **HEAD на маршрут потока** проходил в насос и держал горутину до конца прогона (RFC 9110 §9.3.2: HEAD отдаёт те же заголовки и не тело) — **закрыто:** ошибка `Flush` убивает поток, HEAD отвечает заголовками; пины `TestAFrameThatCouldNotBeFlushedEndsTheStream`, `TestHeadOnTheStreamAnswersTheHeadersAndNothingElse` | fixed(доработка 20.08, дерево сессии) | доработка 20.08 (сверка находок против дерева) | | PD-287 | bug | minor | `internal/runs/runs.go` `Bounds`, `Start` | **`blocked` называл чужую книгу всякий раз, когда у аккаунта есть открытый холд**, независимо от того, укорачивает ли он шкалу, — а канон §RunOptions обещает, что поле объясняет ИМЕННО укорачивание («why the scale is smaller than the account could otherwise afford»). Пользователь книги из трёх глав видел «другая книга держит кредит» и шёл останавливать прогон впустую. Симметрично `CreditHeldError` в `Start` срабатывал на ЛЮБОМ выходе за шкалу, включая выход по размеру книги. `Bounds` не имел ни одного теста — **закрыто:** `CreditHeldBy` возвращает и СУММУ чужих холдов, поле заполняется только если возврат этой суммы удлинил бы шкалу (в `Start` — только если без неё запрос бы прошёл); пин `TestBlockedNamesAnotherBookOnlyWhenItsHoldShortensTheScale` | fixed(доработка 20.08, дерево сессии) | доработка 20.08 (сверка находок против дерева) | | PD-288 | bug | info | `internal/pgstore/readmodel.go` `noteCount` vs `ListNotes` | **`Book.note_count` и `GET /notes` описывали разные множества:** счётчик считал все флагнутые резолюции книги без джойна, список — только те, чья глава существует (INNER JOIN, иначе `chapter_id` нечем заполнить). Карточка обещала N замечаний, список отдавал меньше с `next_cursor: null`. Достижимо штатно, пока дерево не материализовано (PD-276) — **закрыто:** счётчик считается тем же джойном | fixed(доработка 20.08, дерево сессии) | доработка 20.08 (сверка находок против дерева) | | PD-289 | bug | info | `internal/pgstore/sink.go` `unitDone` | **Кадры строились из ОТВЕРГНУТОЙ доставки:** апсерт резолюции отбрасывает событие старше сохранённого (`where excluded.at >= unit_resolutions.at`), но тег команды не проверялся — счётчики главы пересчитывались и `emitChapter` слал кадр `note`, собранный из полей устаревшего события, под тем же id замечания. Кадр хранится wire-ready и реплеится дословно, поэтому расхождение пережило бы правку — **закрыто:** `RowsAffected() == 0` ⇒ ничего не изменилось, ничего не объявляется | fixed(доработка 20.08, дерево сессии) | доработка 20.08 (сверка находок против дерева) | | PD-290 | bug | info | `internal/pgstore/credits.go` `inTx` → `inReadTx`, гейты `ListNotes`/`ListBank` | **Гейт дельта-чтения и строки, которыми он управляет, читались в РАЗНЫХ снапшотах:** READ COMMITTED берёт снапшот на каждый стейтмент, поэтому замена банка/дерева между проверкой `version_too_old` и запросом строк не отвергалась и не отражалась — короткий список, который выглядит полным (класс PD-163, объявленный закрытым «одной транзакцией») — **закрыто:** пути чтения открываются `repeatable read` + `read only` (одна функция `inReadTx`; read-only повторяемое чтение не может прерваться, в отличие от serializable) | fixed(доработка 20.08, дерево сессии) | доработка 20.08 (сверка находок против дерева) | | PD-291 | bug | info | `internal/readmodel/readmodel.go`, `internal/pgstore/readmodel.go` `writeUnits` | **Юнит, который есть в манифесте и выпал из экспорта** (кат сдвинулся между двумя вызовами движка), перезаписывал сохранённый текст пустым `pending`: флаг «текст прочитан» был на всей структуре, а не на паре. Правка PD-266 закрывала только полный отказ экспорта — **закрыто:** флаг переехал на ПАРУ (`StructureUnit.TextKnown`), структурный снят как избыточный; пин `TestAPairTheExportDidNotCarryDoesNotSpeakAboutItsText` | fixed(доработка 20.08, дерево сессии) | доработка 20.08 (сверка находок против дерева) | | PD-292 | standards | info | `internal/pgstore/runs.go` `RestartRun`, `internal/runs/reconcile.go` `Resume` | **Снятие стопа банка было ОТДЕЛЬНЫМ писателем после переоткрытия прогона:** отказ между двумя записями оставлял возобновлённый прогон с `bank_released = false` — весь второй сегмент бар считал draft-колонку против купленного потолка, и канала починки не было (свип это поле не трогает, повторный resume отказал бы: прогон уже `translating`) — **закрыто:** снятие уехало ВНУТРЬ транзакции `RestartRun` (`LiftBankStop`), метод `ReleaseBankStop` снят, пин переписан на реальный путь | fixed(доработка 20.08, дерево сессии) | доработка 20.08 (сверка находок против дерева) | | PD-293 | bug | info | `internal/runs/reconcile.go` `DrainRefresh` | **Проход материализации не мог быть подрезан своим бюджетом:** `DrainRefresh` не смотрел `ctx` между книгами, а каждая книга берёт отвязанные 5 минут, поэтому K медленных книг переезжали объявленные 10 минут прохода и задерживали интейк, телеметрию и СЛЕДУЮЩИЙ такт свипа — то есть голодание PD-169 переехало уровнем выше — **закрыто:** проверка между книгами, недоделанное возвращается в очередь (`deferRefresh` дедуплицирует по книге) ⚠ **ЭРРАТА (акт 5): механизм заменён, свойство сохранено.** `DrainRefresh`/`deferRefresh` удалены вместе с очередью в памяти; проверка бюджета между книгами живёт в `readmodel.Drain`, а «недоделанное возвращается» стало долговечным долгом с арендой (PD-302, PD-320, PD-322) ⚠ **ПАК P8-REVIEW 24.08: живой носитель, который эррата этой строки прямо называет (проверка бюджета между книгами в `readmodel.Drain`), не покрыт НИ ОДНИМ тестом** — во всём дереве нет теста, который вообще даёт `Drain` дедлайн, поэтому гейт можно выключить целиком и батарея останется зелёной; точный близнец у интейка при этом запинен своим тестом. Вынесено строкой `PD-388`; статус паком не менялся ⚠ **ОСПОРЕНО(PD-388)** | fixed(доработка 20.08, дерево сессии) | доработка 20.08 (сверка находок против дерева) | | PD-294 | standards | info | `internal/pgstore/readmodel.go` `SubmitBankDecisions` | **Транзакция решений банка не брала блокировку книги первой** — против глобального порядка зоны (`credits.go` `lockBook`: books → runs → run_attempts → account_balances → reservations). Она берёт блокировки `bank_decisions` и, через внешний ключ, `bank_terms`, которые `SaveBank` держит, уже взяв книгу; ретрая нет, `IsTransient` сюда не подключён, поэтому взаимоблокировка ушла бы клиенту как 500 — **закрыто:** `lockBook` в начале транзакции | fixed(доработка 20.08, дерево сессии) | доработка 20.08 (сверка находок против дерева) | | PD-295 | bug | info | `internal/pgstore/migrations/00016_read_surface.sql` | **Индекс `unit_resolutions_book_idx` (00015) стал строгим префиксом-дубликатом** индекса, который добавляет 00016, и не снимался: второй индекс на самом горячем пути записи (строка на юнит на волну) — **закрыто:** `drop index` в Up, воссоздание в Down; заодно снято утверждение о «пине 18.4», которого нет в ратифицированной таблице стека (пол — 16). Перефингерпринт 00016 объяснён в шапке `migrations.sha256` | fixed(доработка 20.08, дерево сессии) | доработка 20.08 (сверка находок против дерева) | | PD-296 | standards | info | `internal/httpapi/v0.go` `contractSurface`, `internal/httpapi/problem.go` `codes` | **Два места, которые надо править парой, правились по одному.** Маршруты регистрировались дюжиной вызовов `mux.Handle`, а тест «каждый контрактный маршрут требует сессии» ходил по литеральному списку из четырёх — семь новых маршрутов пака не покрывал никто. Коды ошибок несли статус и заголовок в двух отдельных `switch`, а тест — в третьем, рукописном: новая константа не покрывалась ничем — **закрыто:** маршруты и коды стали по ОДНОЙ таблице, тесты ходят по ней; счётчик кодов пинит закрытый словарь 0.3.0 в 16 значений | fixed(доработка 20.08, дерево сессии) | доработка 20.08 (сверка находок против дерева) | ## Закрытые — акт 5 P7 (ревью акта 4 + ответ контрактной сессии, 20.08) Находки адверсариального ревью собственного диффа акта 4 (9 линз × 2 рефутера) и работа, которую принёс ответ контрактной сессии на записку зоны. Каждая строка закрыта посадкой мутации: код испорчен названным образом, пин обязан упасть, код возвращён. | ID | Класс | Серьёзность | Где | Суть | Статус | Источник | |---|---|---|---|---|---|---| | PD-202 | bug | info | `internal/pgstore/readmodel.go` `finishedUnits`, `sink.go` `unitDone` | **`chapters.units_done` не двигался вовсе у пайплайна без волны редактора.** Колонка считала ТОЛЬКО волну `edit`, поэтому на деплое без редактора ни одна глава не была «сделана» никогда: полоса книги стояла на нуле, а `ChaptersLeft` не подрезался — сервис бесконечно предлагал купить уже переведённые главы. ⚠ Это ДЕНЬГИ, а не полоса. Закрыто формой, которую задала контрактная сессия: «сделано» = ПОСЛЕДНИЙ проход, который книга на ЭТОМ деплое реально получает, и форму движок объявляет сам — у пайплайна без редактора знаменатель волны `edit` равен нулю (`beginWaves`), значит `runs.edit_total = 0` при ненулевом `draft_total` и есть «редактора нет». Читают одно выражение все пять мест (`chaptersDone`, `runProgress`, `emitChapter`, `ReadBookForRun`, `bookScope.wave`); до первого отчёта прогона форма НЕИЗВЕСТНА и ответ остаётся волной `edit` — неизвестное не должно читаться как «сделано». Колонка-дубль `units_done` снесена (00021, PD-314). Пин: `pgstore.TestADraftOnlyDeploymentStillClampsTheScale` | fixed(акт 5) | вопрос зоны P7 → ответ контрактной сессии 20.08 (E-G) | | PD-253 | standards | info | `internal/pgstore/readmodel.go` `ListUnits`, канон §listUnits | **`410 Gone` отвечал и на главу, которой никогда не было.** Расхождение снято НЕ кодом: контрактная сессия приняла довод зоны — различать «была и больше нет» от «не было никогда» невыполнимо без надгробий, которых у платформы нет и заводить которые дороже вопроса, — и изменила канон в нашу сторону. Расхождение перестало быть расхождением | fixed(канон 0.4.0) | приёмка P7 (акт 1) → ответ контрактной сессии 20.08 (L) | | PD-262 | bug | minor | `internal/httpapi/v0.go` `intakeFingerprint`, `internal/httpapi/idempotency.go` | **Тождество интейка решалось по ОБЪЯВЛЕННОМУ, и объявленное его не несёт.** `Content-Length` стоял вместо размера файла и не закрывал ни одной половины: он считает и multipart-обвязку (повтор из другой клиентской библиотеки — «другой запрос»), а chunked-тело не объявляет ничего вовсе — две РАЗНЫЕ книги под одним ключом с одинаковым объявлением были неразличимы, и вторая получала `Location` первой. Закрыто нормой 0.4.0 («метаданные + имя файла + СОДЕРЖИМОЕ»): `Content-Length` из отпечатка убран, файл дайджестится на лету (`io.TeeReader`), дайджест хранится с квитанцией (00023), а повтор ЧИТАЕТСЯ и сверяется до того, как ему что-то ответят — совпал, реплей; не совпал, `409 key_reused`; не прочитан — не реплей. Пины: `httpapi.TestTheFingerprintIsExactlyTheDeclaredParts`, `httpapi.TestTheClaimsFourAnswersReachTheClient/another_FILE…`; живая проба 20.08: тот же файл → 201 с тем же id, другой файл → 409 `key_reused`, книг в библиотеке одна | fixed(акт 5) | приёмка P7 (акт 1) → норма 0.4.0 (E) | | PD-282 | standards | minor | `internal/runs/reconcile.go` `Resume`, канон §resumeRun | **`resume` прогона, потратившего весь купленный потолок, отвечал 202 и НИЧЕГО не менял** — молчаливый no-op, который тот же раздел канона запрещает в соседней строке. Контрактная сессия ратифицировала обратное обещание: **202 означает, что работа реально переоткрыта**, и такой вызов — `409 run_not_resumable`, `cause: ceiling_reached`, в ЛЮБОМ статусе. Закрыто вместе с разведением двух `exhausted` (PD-312). Пин: `runs.TestResumeOfARunWithNothingLeftIsRefusedWithTheCeilingReached` | fixed(акт 5) | триаж акта 4 → ответ контрактной сессии 20.08 (A) | | PD-300 | bug | minor | `internal/pgstore/idempotency.go`, `internal/httpapi/idempotency.go` | **Ключ идемпотентности не знал своего владельца, и обе половины стоили пользователю книги.** Строка несёт `(user, method, path, key)` и не несёт, КАКАЯ попытка её держит: (1) попытка, застрявшая дольше `ClaimStale`, наконец падала и её `defer release` УДАЛЯЛ живую строку преемника, который уже создавал книгу — третья попытка получала свежий клейм и делала работу второй раз; (2) у попытки забрали клейм, а она дописывала СВОЙ ответ, и клиент реплеил `Location` книги, которой у него нет. `and finished_at is null` не сужает ни одну: строка преемника законно не завершена. Обе воспроизведены ревьюером на живом PG. Закрыто токеном клейма (00020): `ClaimIdempotency` минтит его и ПЕРЕ-минтит при перехвате, `complete` и `release` его предъявляют, несовпадение → `ErrClaimLost`. ⚠ Сравнивать вместо токена `claimed_at` нельзя — Go даёт наносекунды, Postgres хранит микросекунды. Пины: `pgstore.TestAnAttemptThatLostItsClaimCanNeitherAnswerForItNorTakeItAway`, `httpapi.TestEveryEndingPresentsTheClaimItWasGranted` | fixed(акт 5) | ревью акта 4 (2 линзы, воспроизведено на живом PG) | | PD-301 | bug | major | `internal/pgstore/events.go` `ReadStream`, `internal/httpapi/stream.go` | **Поток говорил `end` раньше, чем появлялось дерево — то есть ровно Ф-56, ради которой он и строился.** `books.Parse` коммитит `FinishParse` (`parsing → not_started`) и только ПОТОМ зовёт материализацию — два процесса движка на собственном бюджете. В этом окне «в покое» было истинно, насос слал `end`, а автоматический реконнект браузера получал **204 «не переподключайся»**: клиент переставал смотреть ровно тогда, когда главы вот-вот появятся. Та же дыра на границе ПРОГОНА: прогон закрыт, текст ещё не материализован, готовый ОПЛАЧЕННЫЙ перевод до читателя не доезжает. Закрыто долгом (PD-302): «в покое» теперь означает «и материализация не должна». Пин: `pgstore.TestABookThatOwesAReadingSurfaceIsNotAtRest`; живая проба 20.08: при непогашенном долге поток НЕ шлёт `end`, реконнект отвечает 200 вместо 204 | fixed(акт 5) | ревью акта 4 (замерено на живом PG) | | PD-302 | bug | minor | `internal/runs/reconcile.go` (`deferRefresh`/`DrainRefresh`), `internal/books/parse.go` | **Долг на материализацию жил только в памяти процесса, и терялся двумя достижимыми путями:** `Refresh` вернул ошибку — ветка логировала и НИЧЕГО не ставила обратно; демон перезапустился — слайс умер с процессом. В обоих случаях дерево от прошлой границы ЕСТЬ, поэтому единственный бэкстоп («дерева нет вовсе») книгу не видел, и текст оплаченного прогона не доезжал до читателя никогда. Закрыто ДОЛГОВЕЧНЫМ долгом: колонка `books.read_model_owed_at` (00019) ставится в транзакциях `FinishParse` и `FinishRun`, гасится по РАВЕНСТВУ метки (долг, поставленный позже, переживает материализацию), очередь берётся из БД. Механизмов стало на два меньше: `runs.pendingRefresh/DrainRefresh/deferRefresh/Reader` и `books.materializeMissingTrees/BooksWithNoTree` удалены, дрейн переехал в `readmodel.Drain` — пакет, чья это работа. Пины: `runs.TestAFinishedRunLeavesItsBookOwingAReadingSurface`, `books.TestAParsedBookOwesAReadingSurfaceUntilOneIsMaterialized`, `pgstore.TestADebtStampedDuringAMaterializationSurvivesIt`, `readmodel.TestTheDrainMaterializesEveryOwedBookAndKeepsWhatFailed` | fixed(акт 5) | ревью акта 4 (2 линзы) | | PD-303 | bug | minor | `internal/readmodel/readmodel.go` `refreshStructure` | **Упавшее чтение пар отчитывалось УСПЕХОМ:** отказ `Export` только логировался, `refreshStructure` возвращал nil, и интейк считал материализацию состоявшейся — книга получала полное дерево пустых пар, а читатель не видел даже собственного исходника. Закрыто возвратом отказа вызывающему (`errors.Join`): неполная материализация НЕ гасит долг, и книга остаётся в очереди дрейна, пока один проход не ответит по всем трём каналам. Пин: `readmodel.TestAPartialMaterializationDoesNotDischargeTheDebt` | fixed(акт 5) | ревью акта 4 | | PD-304 | bug | minor | `internal/pgstore/books.go` `ReadUsage` | **`Usage` зажигал остановку АККАУНТА от потолка ОДНОГО прогона.** Предикат сканировал `paused_reason` последнего прогона каждой книги, а `credit_exhausted` там значит «этот прогон потратил своё». Замерено: счёт $10, прогон на 10 глав ($0.30) — провод отвечал `{"state":"ok","remaining_percent":97,…,"halt_reason":"credit_exhausted"}`, то есть пользователю с деньгами говорили, что денег нет, рядом с процентом, говорящим обратное. Канон предупреждает об этом дословно (§AccountHaltReason). Закрыто чтением остановки С АККАУНТА: нечего тратить — остановлен, и ничем иным. Пины: `pgstore.TestUsageIsAShareAndAnAccountWithNoGrantsIsExhausted`, `pgstore.TestAPauseTheReconcilerCausedIsVisibleOnTheRun`; живая проба 20.08: $10 → `halt_reason: null` | fixed(акт 5) | ревью акта 4 (замерено на проводе) | | PD-305 | standards | minor | `httpapi.TestTheClaimCarriesTheRequestsOwnOperation`, `pgstore.TestAnOverlongPathDoesNotBreakTheClaim`, `pgstore.TestASecondRunsBarStartsAtZeroOverAHalfFinishedBook`, `config.TestAnUploadDeadlineLongerThanEitherWindowIsRefused`, `httpapi.TestEveryCodeNamesExactlyOneStatus` | **Пять пинов проходили под мутацией, которую сами называют** — каждый проверен ревьюером исполнением. Причины разные и все поучительные: единственный гоняемый маршрут имел путь, совпадающий с паттерном · 4000 повторяющихся байт СЖИМАЮТСЯ в индексном кортеже и до предела btree не доходят · фикстура заканчивала ровно одну главу и покупала ровно одну, поэтому кламп не связывал · оба значения цикла нарушали ВТОРУЮ проверку, поэтому снятие первой ничего не меняло · `statusOf(c)` это буквально `codes[c].status`, то есть сравнение таблицы с собой (регрессия акта 4: слияние статуса и заголовка сделало пин тавтологией). Закрыты по-разному, но каждый — так, чтобы названная мутация падала: новый пин на маршрут, где путь ≠ паттерн · несжимаемый путь (падает `index row size 4040 exceeds btree maximum 2704`) · новый пин, где прогон покупает МЕНЬШЕ, чем книга успевает закончить · одна проверка против ТЕСНЕЙШЕГО окна вместо двух · сверка с ТРАНСКРИПЦИЕЙ канона §ErrorCode, а не с той же таблицей | fixed(акт 5) | ревью акта 4 (все пять проверены исполнением) | | PD-306 | bug | minor | `internal/pgstore/readmodel.go` `noteCount` | **`note_count` стоил 83% времени `GET /v0/books` — регрессия акта 4.** Джойн на `chapters` добавили, чтобы счётчик и список замечаний описывали одно множество (PD-288), и решение не было замерено. Замер зоны на корпусе 40 книг × 500 глав (по 1000 замечаний, после `vacuum analyze`): 16.8 мс на страницу против 3.4 мс со счётчиком, заменённым литералом, из них 9 мс — сам джойн. Стоимость неустранима по форме: счёт идёт по КАЖДОМУ замечанию каждой книги страницы. Закрыто счётчиком на главе (00022, возвращает колонку, снесённую 00016 — вместе с писателем, которого ей не хватало): фолд `unitDone` и пере-расчёт `writeChapters` ведут его в тех же операторах, что и волновые счётчики, из тех же строк, а согласие счётчика со СПИСКОМ становится конструктивным — замечание, у главы которого нет строки, не имеет ни адреса на проводе, ни счётчика. Замер после: **6.6 мс на страницу**. Пины: `pgstore.TestTheCardsNoteCountAndTheNotesListDescribeOneSet`, `pgstore.TestAFlaggedUnitMovesTheChaptersNoteCounter`, бенчмарк `pgstore.BenchmarkLibraryPage` | fixed(акт 5) | ревью акта 4 (замерено) | | PD-307 | bug | minor | `internal/pgstore/credits.go` `CreditHeldBy` | **`blocked` называл СТАРЕЙШИЙ холд, а не тот, что укоротил шкалу — регрессия акта 4.** Решение «укорачивает ли» стало приниматься по СУММЕ чужих холдов (верно), а книга по-прежнему выбиралась `order by opened_at limit 1`. Замерено: $10, на книге A держится $0.03 (открыт первым), на книге B — $9.60; `blocked` называл A, и пользователь отменял прогон, который ничего не освободит. Закрыто выбором книги с НАИБОЛЬШЕЙ суммой холдов (`group by book_id order by sum(...) desc, min(opened_at)`) — ничьи решаются старшинством, чтобы ответ был устойчив между двумя чтениями. Пин: `pgstore.TestTheHoldThatIsNamedIsTheOneThatWouldFreeTheMost` | fixed(акт 5) | ревью акта 4 (замерено) | | PD-308 | standards | minor | `internal/httpapi/v0.go` `startRun` | **`startRun` отвечал `413` — кодом, которого канон у этой операции не объявляет** (400/401/403/404/409/503), а `§ErrorCode` определяет `payload_too_large` как «свыше `intake_max_bytes`», то есть про ЗАГРУЗКУ. Соседняя JSON-запись на то же условие отвечала 400, значит одна из двух врала о том, что клиент сделал не так. Закрыто приведением к `400 invalid_request`. Пин: `httpapi.TestAnOverlongRunRequestIsInvalidAndNotTooLarge`; живая проба 20.08 | fixed(акт 5) | ревью акта 4 (2 линзы) | | PD-309 | standards | minor | `internal/httpapi/stream.go` `pump` | **Границы дыры в потоке не были запинены на ОДИН кадр.** `emitFrame` минтит по кадру за раз, поэтому обычная дыра у живого соединения шириной ровно в один кадр, а пин PD-283 гоняет дыру в 189 кадров — обе границы отвечают на такую одинаково, и сдвиг любой из них на единицу батарея не замечала. Закрыто табличным пином на четыре случая по обеим границам. Пин: `httpapi.TestTheGapBoundariesAreExactAtOneFrame` | fixed(акт 5) | ревью акта 4 | | PD-310 | doc | info | `internal/pgstore/runs.go`, `internal/pgstore/books.go`, `internal/httpapi/problem.go`, `internal/books/render.go` | **Проход по болтливости обрубил четыре комментария на середине** — осиротевшая строка без подлежащего у `EngineStreamID`, непарная скобка и склеенное надвое предложение в `WriteStatusProblem`, удвоенная клауза в `render.go`, док-блок `StuckIntake`, приклеенный к чужой функции. Тот же дефект был в акте 3. Закрыто починкой всех пяти; правило записано там же, где ошиблись: **при таком проходе читать РЕЗУЛЬТАТ целиком, а не только удаляемое** | fixed(акт 5) | ревью акта 4 (2 линзы) | | PD-311 | hardening | minor | `internal/config/config.go` `loadIntake` | **Загрузочная граница дедлайна оставляла секунду.** Проверка сравнивала дедлайн с окном и не оставляла ничего на работу ПОСЛЕ чтения тела: принимался `29m59s`, а терминальные записи интейка идут на собственный бюджет и квитанция ключа пишется после них — клейм становился перехватываемым за мгновение до того, как его завершили. Плюс проверок было ДВЕ, и та, что слабее, не могла сработать никогда (`ClaimStale` теснее `UploadGrace`), а пин её «покрывал» вторым значением, которое ловила соседняя. Закрыто одной проверкой против ТЕСНЕЙШЕГО окна с явным запасом `books.UploadSettle`. Пин: `config.TestAnUploadDeadlineIsRefusedUnlessTheWholeUploadFitsTheTighterWindow` | fixed(акт 5) | ревью акта 4 | | PD-312 | bug | minor | `internal/runs/reconcile.go` `reopen`, `internal/httpapi/v0.go` | **Два РАЗНЫХ факта возвращались одним вердиктом `exhausted`:** «прогон потратил свой потолок» и «счёт не тянет холд». Лечения у них противоположные — новый прогон против пополнения, — а ответ был один, поэтому пользователя с непотраченными главами отправляли покупать прогон, который ему не нужен. Разведено на `ceilingSpent` и `creditUnavailable`; второму контрактная сессия завела `cause.code: credit_unavailable` (второй уровень открыт, типов не двигает). Реконсилятор по-прежнему паузит оба одинаково — это не конфляция, а его работа: состояние, в котором владелец может действовать. Пин: `runs.TestResumeOverAnEmptyAccountSaysTheCreditIsUnavailableAndNotTheCeiling` (включая «пополнил — тот же вызов продолжает тот же прогон») | fixed(акт 5) | вопрос владельца 20.08 → норма 0.4.0 (A) | | PD-313 | standards | minor | `internal/pgstore/books.go` `Run`, `internal/httpapi/project.go` | **Клик пользователя «стоп» не доезжал до клиента.** Столбец `runs.stop_requested_at` был, на проводе его не было, и никакой `status` его не заменяет: стоп не мгновенен, поэтому стоп, попросленный во время перевода, встречается с собственной остановкой прогона на подписи банка — прогон приезжает `awaiting_bank`, а этот статус предлагает ПРОДОЛЖИТЬ на клик, который значил «останови». Закрыто ОБЯЗАТЕЛЬНЫМ булевым `Run.stop_requested` (0.4.0, единственная ломающая правка), снимается при resume вместе с намерением. Побочно: три места читали `Run` тремя одинаковыми списками из четырнадцати колонок — сведены в `runRow`+`scanRun`. Пины: `pgstore.TestTheStopIntentTravelsOnEveryRunAndAResumeClearsIt`, `httpapi` (обязательность поля); живая проба 20.08 | fixed(акт 5) | норма 0.4.0 (J) | | PD-314 | standards | info | `internal/pgstore/migrations/00021_drop_units_done.sql` | **`chapters.units_done` был байт-в-байт дублем `units_edit_done`** и писался двумя местами. После PD-202 читателей у него не осталось вовсе, а имя, читающееся как «сделано», над значением «отредактировано» — ровно та ловушка, из которой вырос PD-202. Колонка снесена вместе с обоими писателями | fixed(акт 5) | уборка зоны при PD-202 | ## Закрытые — самопроверка акта 5 (адверсариальное ревью собственного диффа, 20.08) Ревью зоны против СОБСТВЕННОЙ работы акта 5: 9 линз × 2 рефутера + критик полноты, 84 агента, 0 ошибок. **37 находок, 18 пережили хотя бы одного рефутера**, и часть из них — дефекты, которые внесли правки самого акта 5. Три из восемнадцати воспроизведены агентами исполнением на живом Postgres, две — посадкой мутации в дерево. Отдельно ценно то, что ревью поймало КЛАСС, ради которого этот пак его и гоняет: два пина, написанные в акте 5, проходили под мутацией, которую сами называют. | ID | Класс | Серьёзность | Где | Суть | Статус | Источник | |---|---|---|---|---|---|---| | PD-315 | bug | major | `internal/pgstore/sink.go` `FinishUnspawnedStop`, `internal/pgstore/runs.go` `PauseRun` | **Транзакций, которые ЗАКАНЧИВАЮТ прогон, три, а долг на материализацию ставила одна.** Собственная посылка акта («обе границы ставят его в транзакции, которая границу закрывает») оказалась ложной: пауза на потолке и стоп, приехавший на попытку без юнита, оставляли книгу `finished_at`-нутой и НЕ должной. Оба случая с текстом: прогон, вставший на потолке, перевёл всё до него; стоп на попытке, у которой сняли клейм спавна, ловит движок, который реконсилятор сам же и рассчитывает (спенд-базлайн там держится намеренно). Читателя нет — очередь дрейна ключуется этой колонкой, и на закрытую книгу больше не смотрит никто. Закрыто фрагментом `owesAReadingSurface`, который несут все три, и пином по ТАБЛИЦЕ из трёх концовок. Пин: `pgstore.TestEveryEndingOfARunLeavesTheBookOwingASurface` | fixed(акт 5, самопроверка) | ревью акта 5, линза «долг» (2/2 рефутера не опровергли) | | PD-316 | bug | major | `internal/pgstore/readmodel.go` `finishedUnits` | **Форма пайплайна читалась с ПОСЛЕДНЕГО прогона, а последний — самый НОВЫЙ, и только что допущенный не объявил ничего.** На деплое без редактора `chapters_done` каждой книги падал в ноль в момент создания прогона и поднимался обратно на первом progress-событии — счётчик, которому канон запрещает ходить назад, и вместе с ним шкала покупки, которая в этот момент снова предлагает уже переведённое. Закрыто переносом факта на КНИГУ (миграция 00024, `books.edit_wave`): пишется объявлением движка через оба канала (поток и resync), монотонно — книга, прошедшая редактирующий пайплайн, остаётся такой. Пины: `pgstore.TestAdmittingARunDoesNotWalkTheBooksProgressBackwards`, `pgstore.TestTheWaveShapeIsWrittenByTheEngineAndOnlyGrows` | fixed(акт 5, самопроверка) | ревью акта 5, линза «волны» (2/2) | | PD-317 | bug | major | `internal/pgstore/runs.go` `StartRun`, `RestartRun` | **Базлайн полосы и её числитель считали РАЗНЫЕ проходы.** Числитель научился про деплой без редактора, а `chapters_before` в обоих местах по-прежнему называл `units_edit_done` — второй прогон над уже начерновленной книгой открывался на своём потолке, и снятие стопа подписи открывало второй сегмент там же. Воспроизведено ревьюером исполнением. Закрыто одним выражением на оба места. ⚠ **Первый написанный на это пин ПРОШЁЛ под своей мутацией** — фикстура останавливалась на шаг раньше того места, где перекос виден. Пины: `pgstore.TestASecondRunOnADraftOnlyDeploymentStillOpensAtZero`, `pgstore.TestLiftingTheStopOnADraftOnlyDeploymentRetakesTheRightBaseline` | fixed(акт 5, самопроверка) | ревью акта 5, линзы «волны» и «доки» | | PD-318 | standards | major | `internal/books/books_test.go`, `internal/runs/reconcile_test.go` | **Два пина акта 5 проходили под мутацией, которую сами называют.** Оба утверждали КОНЕЧНОЕ состояние («книга должна поверхность»), а названная мутация — вынос метки из транзакции границы во второй оператор — конечное состояние не меняет: меняется окно, в котором книга разобрана и не должна ничего, а колонка — единственный ретрай. Ревьюер посадил обе мутации и обе прошли всю батарею. Закрыто утверждением про АТОМАРНОСТЬ: `xmin` (транзакция, последней писавшая строку) у книги и у кадра, который та же транзакция выпустила, обязан совпадать. ⚠ Первая редакция этого пина в прогонах сравнивала книгу с самим ПРОГОНОМ и падала на зелёном дереве: расчёт штампует прогон ещё раз мгновением позже | fixed(акт 5, самопроверка) | ревью акта 5, линза «пины» (исполнением, 2/2) | | PD-319 | bug | major | `internal/pgstore/idempotency.go` `ClaimIdempotency` | **Клейм, проигравший гонку ОСВОБОЖДЕНИЮ, отвечал 500 — на том самом случае, ради которого заголовок существует.** Две попытки приходят вместе; победитель вставки падает быстро (любой 4xx возвращает ключ немедленно), и пере-чтение проигравшего на свежем READ COMMITTED снапшоте не находит ничего. Это тот же 500, который двумя строками выше закрывал `on conflict do nothing`. Воспроизведено ревьюером на живом PG (6 из 12 гонщиков). Закрыто повтором клейма с начала: пропавшая строка — не ответ никому, ключ просто снова свободен. Пин: `pgstore.TestAClaimThatLostARaceToAReleaseIsRetriedAndNotAnError` | fixed(акт 5, самопроверка) | ревью акта 5, линза «идемпотентность» (2/2) | | PD-320 | bug | minor | `internal/pgstore/books.go` `BooksOwedReadModel`, `internal/books/parse.go` | **Дрейн получал книгу, которую интейк в этот момент материализует.** Долг становится виден в момент коммита разбора, а его собственный плательщик только входит в чтение движка (до 5 минут); свип идёт каждые 15 секунд в том же процессе и клейма у списка не было. Любая книга, материализующаяся дольше интервала свипа, читалась движком ДВАЖДЫ одновременно — ровно та цена, которую этот же пак экономил `RefreshCut`-ом (PD-248). Закрыто арендой: `ClaimReadModelDebt` отодвигает срок долга, очередь берёт только то, что `<= now()`, интейк клеймит свой долг перед материализацией. Пины: `pgstore.TestAClaimedDebtLeavesTheQueueUntilItsWindowLapses`, `readmodel.TestTheDrainSkipsABookSomebodyElseIsAlreadyMaterializing`, `books.TestTheIntakeClaimsItsOwnDebtBeforeMaterializing` | fixed(акт 5, самопроверка) | ревью акта 5, линза «SQL» | | PD-321 | bug | minor | `internal/readmodel/readmodel.go` `refresh` | **Погашение долга шло на том же контексте, что и чтения движка, которые его исчерпали.** На большой книге чтения законно съедают весь бюджет, и запись «сделано» после них не доезжает — материализация СЛУЧИЛАСЬ и не записана, поэтому следующий проход делает две полных ре-нарезки заново, и так каждый проход. Закрыто отвязанным коротким бюджетом — то же правило, по которому живут терминальные записи интейка. Пин: `readmodel.TestTheDischargeSurvivesAContextTheReadsUsedUp` | fixed(акт 5, самопроверка) | ревью акта 5, зонд линзы «долг» | | PD-322 | bug | minor | `internal/readmodel/readmodel.go` `Drain` | **Книга, про которую движок не может ответить никогда, держала голову очереди вечно.** Очередь берётся старейшими долгами, поэтому четырёх таких книг (каталог, который оператор перенёс; проектная база, которую эта сборка не читает) хватало, чтобы всё, что за ними, не получило оплаченный текст вовсе. Закрыто переносом неоплаченного долга в КОНЕЦ очереди — с той же сверкой метки, что и погашение. Пины: `pgstore.TestADeferredDebtGoesToTheBackAndNeverOverwritesANewerOne`, `readmodel.TestTheDrainMaterializesEveryOwedBookAndKeepsWhatFailed` | fixed(акт 5, самопроверка) | собственная сверка зоны при чтении своего же дрейна | | PD-323 | standards | minor | `internal/httpapi/v0.go` `createBook` | **Повтор под завершённым ключом ИГНОРИРОВАЛ часть, присланную после файла, и отвечал 201**, тогда как то же тело под свежим ключом — 400 `missing_or_late`. Одни и те же байты были законны или нет в зависимости от того, какой ключ на них надет, и это противоречило собственному док-комментарию маршрута. Закрыто проверкой хвоста и на пути реплея. Пин: `httpapi.TestTheClaimsFourAnswersReachTheClient/another_FILE…`; живая проба 20.08 | fixed(акт 5, самопроверка) | ревью акта 5, линза «идемпотентность» | | PD-324 | bug | minor | `internal/pgstore/books.go` `ReadUsage` | **Доля считалась только по строкам `kind = 'grant'`, а на бете грант при регистрации НОЛЬ и оператор пополняет счёт через `adjust`.** Замерено на проводе: счёт с $20, с которого можно стартовать прогоны, отвечал `{"state":"low","remaining_percent":0}` — та же «у вас нет денег» пользователю с деньгами, что и PD-304, с другого конца. Знаменатель стал «всё, что когда-либо ДОБАВИЛИ» (гранты и положительные корректировки); отрицательная корректировка остаётся на стороне трат. Пин: `pgstore.TestTheShareCountsEveryWayCreditWasAdded`; живая проба 20.08 | fixed(акт 5, самопроверка) | ревью акта 5, критик полноты (взаимодействие двух правок, которого не видела ни одна линза) | | PD-325 | doc | major | `deploy/README.md` §дев-стенд | **Загрузочный гейт пар, который завёл этот же акт, отвергал собственный рецепт стенда:** копипаст-блок «Рецепт целиком» ставит `BOOKS_DIR` и `ENGINE_BIN`, но не `TM_PLATFORM_LANGUAGE_PAIRS` — демон не поднимался вовсе, и следующий шаг рецепта (`tmplatformctl seed`) бил в мёртвый порт. Боевой блок в том же файле пары получил, стендовый — нет. Это рецепт, которым поднимается ФРОНТ. Закрыто | fixed(акт 5, самопроверка) | ревью акта 5, критик полноты | | PD-326 | doc | info | `internal/pgstore/events.go`, `internal/books/books.go`, `internal/httpapi/v0.go`, `internal/runs/reconcile.go` | **Четыре комментария, лгущих о коде под ними** — тот же класс, что PD-310, найденный тем же ревью в собственных правках акта: `nothingIsRunning` описывал материализатор как читающий его «по отрицанию» (полярность одна и та же, и инженер, действующий по комментарию, отправил бы дрейн читать движок ровно во время живого прогона) · `BooksOwedReadModel` утверждал, что чтение движка берёт проект эксклюзивно (ревьюер проверил ДВИЖОК: `NewReadOnlyRunner` открывает БЕЗ flock, намеренно) · `canTranslate` нёс две первых строки док-комментария · блок проекций объявлял себя «канон 0.3.0, поле в поле», уже содержа `stop_requested`, которого в 0.3.0 нет · ветка стопа банка всё ещё обещала, что резюм ждёт ПОЛНОГО набора решений — гейт, снятый D39.144 и удалённый из кода этим же паком | fixed(акт 5, самопроверка) | ревью акта 5, линзы «доки» и «SQL» | | PD-327 | standards | minor | `internal/httpapi/idempotency.go`, `internal/httpapi/v0.go` `intakeFingerprint` | **Тождество интейка теперь решается СОДЕРЖИМЫМ файла, а ратифицированный 0.3.0 сравнение по байтам запрещает** («compared over the DECLARED parts — the metadata and the file's name and size — and never over the bytes themselves»). Новое поведение — это норма 0.4.0 (§E ответа контрактной сессии) и безопасная сторона: правило 0.3.0 и есть то, что позволяло двум разным книгам делить один ключ (PD-262). Строка заведена не как дефект, а как **зависимость от ратификации**: до лендинга 0.4.0 оркестратором зона отвечает `409 key_reused` там, где действующий канон обещает реплей. Закрывается лендингом канона ⚠ **ЗАКРЫТО P8-FIX:** условие закрытия наступило — канон 0.4.0 РАТИФИЦИРОВАН (D39.152), а константа, которой деплой объявляет версию клиенту, поднята 0.3.0 → 0.4.0 (`internal/httpapi/capabilities.go`). Чтобы это не повторилось, поднятие ГЕЙЧЕНО: `gates.TestTheAnnouncedContractVersionIsTheOneTheCanonRatified` читает `info.version` из САМОГО канона, а не из копии числа — прежние тесты сверяли провод с константой и потому проходили при любом её значении (посадка «вернуть 0.3.0» гейтом ловится) | fixed(P8-FIX) | ревью акта 5, линза «провод» | ## Закрытые — эра P8-FIX (фикс-лист приёмки P7, 21–22.08) Пак фикс-листа приёмки P7 (пинг оркестратора №18) плюс врезанный первым блокер выката, который в паке P7 не рождался и прожил два пака помеченным закрытым. | ID | Класс | Серьёзность | Где | Суть | Статус | Источник | |---|---|---|---|---|---|---| | PD-169 | bug | **BLOCKER** | `cmd/tmplatformd/runner.go` свип, `internal/runs/reconcile.go`, `internal/pgstore/runs.go` | **ЭТА СТРОКА СОЛГАЛА ДВУМ ПРИЁМКАМ.** Она стояла `fixed(P5)`, а её пин (`TestOneSlowRunDoesNotEatThePassOfTheWholeSweep`) гоняет `Sweep` ВООБЩЕ БЕЗ дедлайна прохода — то есть доказывает пер-прогонный бюджет и по построению не может увидеть проход. Живое свойство: `sweepBudget` 2 мин на проход против `defaultRunBudget` 60 с на прогон = **двух медленных прогонов хватает, чтобы съесть проход целиком**, после чего `ctx.Err()` обрывает цикл и `UnsettledRuns` — единственный ретрай отложенного расчёта — не вызывается ВООБЩЕ. Не для этих двух книг: для ВСЕЙ инсталляции, каждый проход, потому что `order by r.started_at` ставит заклиненный прогон (по построению самый старый) в голову списка детерминированно. Цена: холды чужих аккаунтов не возвращаются, а книга с незакрытым прогоном не пускает новый (`runs_one_live_per_book`) — деньги заморожены, книга заморожена, пользователю видно «идёт». Ручки не было: `runs.Config.RunBudget` объявлен и НИКЕМ не присваивался, переменной окружения нет ни для одного из двух чисел, `tmplatformctl` не умеет ни закрыть прогон, ни вернуть холд — наблюдаемость (`tm_platform_sweep_unfinished_total`) росла, а сделать было нельзя ничего **— ЗАКРЫТО P8-FIX четырьмя механизмами, а не подъёмом константы:** (1) проход РАЗДЕЛЁН на фазы, реконсиляция получает половину и не может съесть расчёт (`runs.phaseBudget`); (2) прогон, ВЫБРАВШИЙ свой бюджет или упавший, получает отсрочку с растущим бэкоффом (`run_attempts.reconcile_after`, миграция 00025) и перестаёт держать голову списка — свип читает `RunsToReconcile`, а не `ListLiveRuns`; (3) после `runs.StalledAfter` неудач подряд прогон считается ЗАСТРЯВШИМ: гейдж `tm_platform_runs_stalled` и `tmplatformctl runs --stalled` называют строку, число неудач, последнюю ошибку и что она держит; (4) терминальный вердикт — ОПЕРАТОРА: `tmplatformctl run abandon --run [--release-hold]`, который отказывается закрывать прогон, чья попытка ещё называет юнит. Оба числа стали ручками (`TM_PLATFORM_SWEEP_BUDGET`, `TM_PLATFORM_RUN_BUDGET` — второе присваивается впервые). Пины: `TestTheSettlementPhaseIsReachedWhenTheRunPhaseSpendsThePass` (проход с НАСТОЯЩИМ дедлайном и ДВУМЯ зависшими прогонами — та самая арифметика) · `TestARunTheSweepCannotFinishStopsHoldingTheHeadOfTheList` · `TestARunThatKeepsFailingBecomesTheOperatorsProblem` · `TestAbandoningAStalledRunIsRefusedOverALiveUnitAndAlwaysGivesTheHoldBack`. Прежний пин P5 НЕ удалён: он доказывает другое, настоящее свойство | fixed(P8-FIX) | второй рубеж приёмки P7 (пинг №18, врезан первым) | | PD-328 | bug | minor | `internal/httpapi/capabilities.go`, `internal/httpapi/stream.go` | **Деплой объявлял клиенту версию контракта, которой не отдаёт:** `ContractVersion` = `0.3.0` при формах 0.4.0 и ратифицированном каноне (D39.152). Пока мажор `0`, различие МИНОРА несёт ломающие правки по замыслу, и клиент, сгенерированный под объявленную версию, ОТКАЗЫВАЕТСЯ работать. Практического вреда не было (фронт заморожен на 0.2.3), но это ровно тот класс, что живёт до первого выката. Тесты, которые «покрывали» константу, сверяли ПРОВОД С НЕЙ ЖЕ — self-consistent, проходят при любом значении (класс PD-1) — **починено подъёмом до 0.4.0 плюс гейт против САМОГО канона** (`internal/gates/contract_test.go` читает `info.version` из `docs/architecture/14-api-contract/openapi.yaml`); отсутствие канона = падение, а не скип | fixed(P8-FIX) | фикс-лист приёмки, п. 1 | | PD-329 | hardening | minor | `internal/pgstore/events.go` `emitFrame` | **Два поля, которые канон требует на КАЖДОМ кадре (`EventBase`), не были запинены ничем:** снятие `revision` и `structure_version` проходило ВСЮ батарею (воспроизведено посадкой обеих мутаций 21.08 — 18 пакетов, exit 0). Асимметрия и есть причина: поля штампуются в ОДНОМ месте, поэтому каждый кадр получает их даром, и батарея, читающая кадры ради их собственных payload'ов, от этого не выигрывает ничего. Цена на проводе: клиент применяет кадр, только если его ревизия не ниже удерживаемой, — кадр без ревизии либо отбрасывается всяким читателем, либо применяется вне порядка **— закрыто `pgstore.TestEveryFrameCarriesTheBooksRevisionAndStructureVersion`**, написанным над БУФЕРОМ, а не над одним эмиттером: кадр нового вида покрыт в день, когда его заводят. Обе мутации ловятся | fixed(P8-FIX) | фикс-лист приёмки, п. 2 (дыра найдена посадкой оркестратора) | | PD-330 | bug | minor | `internal/readmodel/readmodel.go` `Drain`, `internal/pgstore/books.go` | **У долга материализации не было ни предела попыток, ни канала «признать безнадёжным»:** claim → fail → `DeferReadModelDebt` пишет `now()` → свип через 15 с → вечно. «Конец очереди» был очередью из одного. Три следствия, каждое непрерывное: до 5 минут движковых процессов на проход навсегда · поток такой книги НЕ заканчивается НИКОГДА (`AtRest` требует `read_model_owed_at is null`) — браузер переподключается к ней вечно · при живом манифесте и падающем экспорте `SaveStructure` коммитится каждый проход ⇒ `revision++` и кадр каждые 15 с о том, что ничего не изменилось. У интейка предел был всегда (`parse_attempts`=5) **— закрыто бэкоффом с потолком плюс списанием после `readmodel.maxAttempts` (то же число 5 и по той же причине: один вопрос — один ответ)**. Сдаться здесь БЕЗОПАСНО в отличие от прогона: деньги той границы уже рассчитаны, теряется свежесть текста; и это не окончательно — следующая граница работы ставит свежий долг и обнуляет бюджет (`owesAReadingSurface`). Оператору: гейдж `tm_platform_reading_surfaces_abandoned`, `tmplatformctl books --abandoned`, `tmplatformctl book refresh --book `. Миграция 00025 | fixed(P8-FIX) | фикс-лист приёмки, п. 3 | | PD-331 | bug | info | `internal/runs/runs.go:62-65`, `cmd/tmplatformd/runner.go` | **Ручка, которая существовала в структуре, была задокументирована и ничего не делала:** `runs.Config.RunBudget` объявлен с доккоммментом про голодание, а `startRunner` его НЕ присваивал — единственным ограничителем одного прогона внутри прохода был пакетный дефолт, и ни один деплой не мог его изменить. Класс «мёртвое поле, читающееся как механизм» **— присвоено, плюс `TM_PLATFORM_RUN_BUDGET` и `TM_PLATFORM_SWEEP_BUDGET` (оба печатаются на буте с источником, PD-114)** | fixed(P8-FIX) | фикс-лист приёмки, врезка про блокер свипа | | PD-332 | bug | minor | `internal/readmodel/readmodel.go` `refreshStructure`, `internal/ingest/manifest.go` | **У материализатора не было ПОЛА на пустой манифест — латентная потеря всего текста книги.** `in.Chapters` строился только из `manifest.Chapters` и ни разу не сверялся со счётчиками того же документа, лежащими рядом; пустой список едет в `SaveStructure`, где `delete from chapters where book_id = $1 and not (id = any($2))` на пустом массиве истинен для ВСЕХ глав, каскад сносит текст, а при пустом `Key` уходят ещё и все `unit_resolutions`. Сегодняшним движком недостижимо, и именно поэтому опасно молча: ловится не сломанный движок, а документ, который ЭТА сборка прочла неверно (переименованный ключ декодируется аллоулистом в пустой список). Асимметрия и была находкой: на ИНТЕЙКЕ ровно этот случай отловлен и прибит мутацией с P6 — там «нет глав» это вердикт, УДАЛЯЮЩИЙ загрузку **— закрыто `ingest.Manifest.Whole()`, зеркалом собственного правила движка (`BookManifest.selfConsistent`, `backend/internal/pipeline/manifest.go:460`), плюс отказ на нулевом счёте глав; оба входа (`Refresh` и `RefreshCut` интейка) под одним полом**. Пины — `readmodel.TestAManifestThatDoesNotDescribeItselfNeverReachesTheTree` (четыре формы) и `TestTheIntakesOwnCutIsHeldToTheSameFloor` | fixed(P8-FIX) | фикс-лист приёмки, п. 10 | | PD-333 | hardening | minor | `internal/pgstore/credits.go` `inReadTx` | **Изоляция читающих чтений не была запинена ничем:** снятие `RepeatableRead`+`ReadOnly` проходит ВСЮ батарею (воспроизведено посадкой 21.08), при том что под инвариантом лежат шесть ручек выдачи, а носитель прямо называет цену — «every frame in that window was lost for good» (PD-163): READ COMMITTED берёт снапшот НА ОПЕРАТОР, страница и ревизия, которой она подписана, приезжают из двух миров, и клиент, соблюдающий контрактное «отбрасывай чтение с меньшей ревизией», теряет всё, что материализовалось между ними **— закрыто двумя пинами, потому что свойств два и падают они независимо:** `TestAReadingTransactionIsOneSnapshotAndRefusesToWrite` проверяет ПОВЕДЕНИЕ против Postgres (чужой коммит невидим второму чтению; запись внутри = SQLSTATE 25006), а `TestEveryPagedReadingHandleTakesTheReadingTransaction` — что шесть ручек через него и ходят (ручка, переведённая на `inTx`, в тесте без конкурентного писателя читает столь же правильно). Четыре посадки, четыре поймано | fixed(P8-FIX) | аудит 21.08, п. 12 фикс-листа | | PD-334 | bug | minor, деньги | `internal/pgstore/credits.go` `CreditHeldBy` | **Оговорка `and book_id <> $2` не исполнялась ни одним тестом:** пин, который выглядит покрывающим, в своей фикстуре даёт исключаемой книге НОЛЬ холдов, поэтому мутант, снимающий оговорку, проходит батарею (воспроизведено 21.08). Сумма кормит контрактный `blocked`, то есть ответ на вопрос «почему шкала короче, чем аккаунт может себе позволить»: с собственным холдом книги в сумме экран приглашает пользователя погасить прогон ЭТОЙ ЖЕ книги ради ЭТОЙ ЖЕ книги — а холд уже вычтен из баланса, так что совет не может оказаться верным даже случайно **— закрыто `TestABooksOwnHoldIsNotWhatIsHoldingItDown`** с фикстурой, где у спрашиваемой книги холд САМЫЙ БОЛЬШОЙ, поэтому забывчивый ответ называет её же | fixed(P8-FIX) | аудит 21.08, п. 13 фикс-листа | | PD-335 | doc | info | `internal/books/books.go` `canTranslate` | **Задвоенная первая строка доккомментария** («reports whether this deployment declares the pair» + «judges an upload against what this deployment declared») — класс PD-310/326, след прохода по болтливости. Снята одна из двух, вторая отделена пустой строкой от абзаца, который к ней и относится ⚠ Якорь фикс-листа указывал на `pgstore/books.go:234-235`; в дереве это `internal/books/books.go:234-235` — файлов с этим именем два | fixed(P8-FIX) | фикс-лист приёмки, п. 7 | | PD-336 | doc | info | `internal/pgstore/perf_test.go`, `internal/pgstore/migrations/00022_chapter_note_count.sql` | **Один замер рассказан четырьмя носителями тремя разными числами, и ни одно не несло УСЛОВИЙ:** «83%» (журнал, регистр PD-306), «96% of the page» (`perf_test.go`), «16.8 против 3.4, джойн сам 9 мс» (миграция 00022). Числа честны и меряют РАЗНОЕ: 96% — холодный, невакуумированный корпус акта 4 (636 мс против 24), 80–83% — вакуумированный корпус 40×500 **— сведено указанием условий при каждом числе; нормативным для вакуумированных цифр остаётся миграция 00022, которая их и несёт вместе со своими условиями, остальные на неё указывают. Удалять «96%» как фантом НЕЛЬЗЯ — оно выводится из своего замера.** ⚠ Расхождение с буквой пинга названо: пинг просил свести условия В миграцию 00022, но она РЕЛИЗНАЯ и гейт неизменности (`migrations.sha256`) её правку запрещает; правка гейта ради этого была бы подгонкой под зелень. Поэтому условия дописаны в носители, которые править законно | fixed(P8-FIX) | фикс-лист приёмки, п. 9 | | PD-337 | bug | info | `internal/runs/reconcile.go` `phaseBudget` | **Дефект СОБСТВЕННОЙ правки этого пака, найденный её же пином:** доля фазы считалась абсолютным дедлайном от `s.now()` — инъектируемых часов сервиса, — тогда как контекст истекает по РЕАЛЬНОМУ времени. Фаза получала не половину прохода, а разницу между двумя часами: в пине это дало проход вдвое длиннее заказанного, на деплое с замороженных часов не бывает — но пин, замерявший длительность, поймал это до лендинга. Урок записан там же, где ошиблись | fixed(P8-FIX) | самопроверка P8-FIX | | PD-338 | bug | info | `internal/pgstore/sqlgate_test.go` | **Первая редакция гейта SQL проверяла не тот SQL:** сбор именованных констант спускался `ast.Inspect` внутрь тел функций, а `q` — имя дюжины разных операторов в пакете, поэтому запрос мог быть просверен против ЧУЖОГО текста и пройти. Тот же класс, ради которого гейт и написан (PD-169: проверка, доказывающая свойство слабее объявленного). Поймано первым же прогоном самого гейта; область собрана из ТОП-УРОВНЕВЫХ деклараций плюс локальных констант функции | fixed(P8-FIX) | самопроверка P8-FIX | | PD-339 | bug | minor | `internal/runs/reconcile.go` `reconcileOne` | **Дефект собственной правки, менявший смысл всего механизма:** отсрочка ключевалась на ОШИБКЕ реконсиляции, а самый частый клин — зависший `tmctl status` живого прогона — ошибки НЕ возвращает: `maybeResync` её глотает намеренно и правильно («a status call that fails is not a run that failed»). То есть прогон, выедающий бюджет молча, остался бы в голове списка навсегда — ровно случай, ради которого механизм строится. Найдено пином, не чтением. Условие теперь «ошибка ИЛИ выбран собственный бюджет», причём `ctx.Err() == nil` отделяет «прогон потратил своё» от «проход кончился», чтобы прогон не наказывался за чужую занятость | fixed(P8-FIX) | самопроверка P8-FIX | | PD-340 | bug | **major** | `internal/pgstore/books.go` `truncateReason`, `internal/pgstore/runs.go` `DeferRun` | **Дефект СОБСТВЕННОГО кода пака, и самоподрывной: обрезка причины отказа шла ПОБАЙТОВО.** В эти колонки попадает первая строка stderr ДВИЖКА дословно и без ограничения длины (`runner.firstLine`), а продукт переводит с китайского и японского — сообщение, цитирующее текст книги, длинное и многобайтовое по природе. Срез через середину руны даёт невалидный UTF-8, Postgres такой `text` отвергает (SQLSTATE 22021) — **и отказывает та самая запись, которая фиксирует неудачу**: срок не сдвинут, счётчик не вырос, элемент снова в голове очереди, навсегда. То есть механизм пака воссоздавал бы ровно то голодание, которое чинит, своей же бухгалтерией. Плюс сырой stderr вообще не обязан быть валидным UTF-8 — поэтому починка это `strings.ToValidUTF8` ПЕРЕД срезом и срез по границе руны. Пин — `TestAnEnginesOwnErrorTextSurvivesBeingRecorded`, и он утверждает не про строку, а про то, что **Postgres её принимает**: правило чужое, и тест, меряющий только строку, прошёл бы при любой кодировке. Обе мутации ловятся | fixed(P8-FIX) | самопроверка P8-FIX, ревьюер контракт-конформности | | PD-341 | bug | info | `internal/pgstore/books.go` `AbandonReadModelDebt` | **Два числа об одном факте: `books --abandoned` печатал на единицу меньше, чем лог рядом.** Списание не инкрементило `read_model_attempts`, а строка лога берёт `Attempts+1` — оператор видел «4 попытки» под сообщением «attempts=5». Попытка, исчерпавшая бюджет, — тоже попытка; счётчик теперь считает её. Пин — `TestAWrittenOffDebtLetsTheStreamEndAndTheNextBoundaryBringsItBack` (сверяет ПЯТЬ) | fixed(P8-FIX) | самопроверка P8-FIX | | PD-342 | hardening | minor | `internal/pgstore/runs.go` `AbandonRun` | **Контрактно видимый исход операторского вердикта не был закреплён ничем:** мутант, кладущий `status='stopped', failure_reason=null`, проходил всю батарею. Канон требует, чтобы статус книги был статусом её текущего или последнего прогона («a client holding both never has to decide which wins»), так что расхождение — не косметика, а два ответа без правила выбора. Закрыто ассертом внутри `TestAbandoningAStalledRunIsRefusedOverALiveUnitAndAlwaysGivesTheHoldBack`: сверяются и статус прогона, и причина, и равенство статусу книги | fixed(P8-FIX) | ревьюер контракт-конформности | | PD-343 | hardening | minor | `internal/httpapi/stream.go` `base` | **Вторая половина `EventBase` не была закреплена ничем — и это ДРУГОЕ место, чем то, что чинил пункт 2 фикс-листа.** Поля штампуются двумя независимыми писателями: исторические кадры — `pgstore.emitFrame` (запинен PD-329), СВЯЗНЫЕ (`hello`, `resync_required`, `end`) — `httpapi.base`. У второго покрытие было тоньше: единственный тест смотрел у `hello` только `contract` и `structure_version`, а `end` и `resync_required` не смотрел никто. Канон требует оба поля на КАЖДОМ кадре. Закрыто `TestEveryConnectionFrameCarriesTheBooksRevisionAndStructureVersion` по двум формам связи; посадка «`base` без revision» ловится | fixed(P8-FIX) | ревьюер контракт-конформности | | PD-344 | standards | info | `internal/readmodel/readmodel_test.go`, `internal/ingest/manifest.go`, `internal/pgstore/sqlgate_test.go` | **Три пина этого же пака измеряли МЕНЬШЕ, чем заявляли** — тот самый класс, ради которого пак и существует: (а) рост бэкоффа материализации проверялся у ФУНКЦИИ, поэтому плоский `retryIn(1)` в её ВЫЗОВЕ переживал батарею; (б) книжная сверка `UnitsTotal` в `Manifest.Whole()` не проверялась — её случай неотличим от по-главной проверки только на первый взгляд: каждая глава может описывать себя верно, пока документ в целом называет другое число пар, а это ровно то, что делает переименованное поле; (в) таблица исключений гейта SQL ключевалась НОМЕРОМ СТРОКИ, то есть переехавший на неё вызов был бы проверен чужим текстом. Всё три закрыты; у (в) добавлен гейт «исключение, которое больше не нужно, — ошибка», чтобы мёртвая строка таблицы не прикрывала то, что под неё переедет | fixed(P8-FIX) | ревьюер контракт-конформности | | PD-346 | bug | **BLOCKER** | `internal/runs/reconcile.go` `reconcileOne` | **Механизм, ради которого написан весь пак, НЕ ВКЛЮЧАЛСЯ НА ЕГО СОБСТВЕННЫХ ДЕФОЛТАХ — и это было бы вторым PD-169 подряд.** Отсрочка ставилась только если `item.Err() != nil && ctx.Err() == nil`, то есть «элемент выбрал СВОЙ бюджет, пока у фазы время ещё было». Но контекст элемента — ПОТОМОК фазового и создаётся ПОЗЖЕ, поэтому дедлайн фазы никогда не позже; на залендённых числах (проход 2 мин ⇒ фаза 60 с против бюджета прогона 60 с) это один и тот же миг. Квалификатор был ложен ровно тогда, когда прогон завис, срабатывала другая ветка — и **зависший прогон записывался как УСПЕШНО сверенный**, обнуляя накопленный счётчик. Механизмы 2, 3 и 4 (отсрочка · гейдж `runs_stalled` · `tmplatformctl runs`/`run abandon`) на любом дефолтном деплое были недостижимы, при том что ВСЕ их пины проходили: каждый из них выставлял бюджет прогона много меньше прохода. Найдено ревьюером, написавшим пин на РЕАЛЬНОМ соотношении **— починено снятием квалификатора: истечение бюджета И ЕСТЬ неудача.** То, ради чего квалификатор стоял (не наказывать прогон, оказавшийся последним в занятом тике), отдано осознанно по асимметрии: незаслуженная отсрочка стоит минуту и стирается первым же успехом, пропущенная — вечность; а прогон, обрезанный пять проходов подряд, это и есть голодание, о котором оператор обязан услышать. Пин — `TestTheDeferralEngagesAtTheRatioThisZoneShips`, написанный на shipped-соотношении и только на нём | fixed(P8-FIX) | ревьюер «шов и деньги», волна 1 | | PD-347 | bug | major | `internal/pgstore/runs.go` `AbandonRun` | **Новая операторская ручка брала блокировки ПРОТИВ глобального порядка** (§22 `books → runs → run_attempts → …`): `for update of r, a` и только потом `lockBook` — ровно инверсия, которую `lockBook` называет причиной «258 дедлоков из 300». Замерено ревьюером: **7 сорванных транзакций на 60 конкурентных пар с `FinishRun` и 3 с `PauseRun`** — обе на пути расчёта денег. Деньги не бились (кэш сходился с леджером), цена — сорванный проход свипа или отказ операторской команды сырым текстом Postgres. Починено: `book_id` читается без блокировки, книга блокируется ПЕРВОЙ, прогон и попытка пере-читаются под ней (та же форма, что у `FinishRun`) | fixed(P8-FIX) | ревьюер «шов и деньги» | | PD-348 | bug | major, деньги | `internal/pgstore/runs.go` `AbandonRun` | **Ручка могла закрыть прогон, чей движок ЖИВ, и отдать его холд целиком.** Гейд читал только `unit_name`, а `ReleaseSpawnClaim` обнуляет имя, СОХРАНЯЯ `spend_baseline_micro_usd` — и `RecordSpawn` прямо пишет, что это надгробие возможно живого процесса («could not be created is not was not created»): `systemd-run`, убитый после того, как уже попросил, оставляет движок работать. Реконсилятор этот случай отрабатывает (`finishStopped` спрашивает systemd), новая ручка — нет. Итог по замеру ревьюера: `status=failed`, холд возвращён, прогон вне `ListLiveRuns` и `UnsettledRuns` — движок, если жив, тратит против книжного потолка без резервации и вне всех списков. Починено: гейд читает ОБА свидетельства, а сообщение называет выводимое имя юнита, потому что оператору идти к `systemctl` | fixed(P8-FIX) | ревьюер «шов и деньги» | | PD-349 | bug | minor | `internal/pgstore/runs.go` `AbandonRun` | **Протухший `paused_reason` оставался на `failed`-прогоне и уезжал на провод** — канон 0.4.0 разрешает машинную причину только при `status: paused` («null otherwise»). Живой прогон получает её из журнала (событие `ceiling`), а ручка меняла статус и колонку не трогала; `FinishRun` ровно для этого держит `nullif($5,'')`. Замерено ревьюером на проводе: `status=failed paused_reason=credit_exhausted`. Починено обнулением в том же операторе | fixed(P8-FIX) | ревьюер «шов и деньги» | | PD-350 | bug | major | `internal/ingest/manifest.go` `Whole` | **У нового пола не было свидетеля для полей-ИДЕНТИЧНОСТЕЙ — и это тот самый класс, ради которого пол написан.** Пол сверял только списки, у которых рядом напечатан счётчик; у `chapters[].id` и `units[].id` счётчика нет, а `derivedID` берёт строку дословно. Переименование любого из двух ключей декодируется аллоулистом в `""` для ВСЕХ строк, все они схлопываются в один производный id, и replacement-запись сносит остальную книгу — при документе, проходящем каждый объявленный им счёт. Замерено ревьюером: 3 пары → **1 строка**, `err=nil`; 2 главы → 1 глава. Починено отказом на пустом `id`: движок пустого не выдаёт никогда (`buildManifest`), так что цена нулевая, и это единственная проверка здесь, которую счётчики сделать не могут | fixed(P8-FIX) | ревьюер «шов и деньги» | | PD-351 | bug | minor | `internal/runs/reconcile.go`, `cmd/tmplatformd/runner.go` `pass` | **Пак чинил класс и по дороге снёс единственный сигнал, которым этот класс виден.** `tm_platform_sweep_unfinished_total` поднимается вызывающим по `errors.Is(err, context.DeadlineExceeded)`, а разделённый на фазы `Sweep` начал возвращать `nil` в обеих фазах — счётчик, который PD-169 называет наблюдаемостью голодания, перестал мочь вырасти вообще. Вместе с PD-346 (гейдж застрявших оставался нулём) инсталляция теряла ОБА сигнала. Починено: фаза, не дошедшая до конца списка, возвращает обёрнутый `DeadlineExceeded` с числами «дошли до N из M»; расчётная фаза не дублирует счёт. Пин — `TestAPassThatRanOutOfTimeSaysSo` | fixed(P8-FIX) | ревьюер «шов и деньги» | | PD-352 | bug | info | `cmd/tmplatformctl/runs.go` `listRuns`, `internal/pgstore/runs.go` `StalledRuns` | **`tmplatformctl runs` без флага не показывал ни одного здорового живого прогона**, хотя строка usage обещает «live runs and what the reconciler cannot finish»: пол листинга был 1 неудача. То есть на работающем деплое команда отвечала «no run is failing to reconcile» — и тот же ответ давала, пока прогон УЖЕ клинил, а отсрочка его ещё не посчитала. Плюс в строке не было `spend_micro_usd`: решение «вернуть холд ЦЕЛИКОМ» принималось вслепую к тому, сколько работы списывается (колонка писалась и не читалась нигде). Починено обоим; пин — `TestTheRunsListingShowsLiveRunsAndNarrowsToStalledOnes` на уровне КОМАНДЫ | fixed(P8-FIX) | ревьюер «шов и деньги» | | PD-353 | bug | **BLOCKER** | `internal/runs/reconcile.go` `settlePhase`, `internal/pgstore/sink.go` `UnsettledRuns` | **Блокер пака был починен на ПОЛОВИНЕ прохода.** Разделение на фазы не даёт одной фазе съесть другую и ничего не делает с голоданием ВНУТРИ фазы, а список расчёта денег упорядочен так же — `order by a.ended_at`, старейший первым. Одна завершённая попытка, чей `tmctl status` не отвечает (проект на отвалившемся маунте — и ровно то состояние, которое оставляет `run abandon`), съедала весь бюджет расчёта каждый проход: холды ЧУЖИХ аккаунтов по уже законченным книгам не возвращались, пока тот проект недоступен. По ошибке это не ловилось: `settle` при нечитаемой трате возвращает nil намеренно и правильно, то есть клин рапортовал УСПЕХ, потратив проход **— закрыто тем же механизмом, что и первая фаза: сигналом стало ИСТЕЧЕНИЕ БЮДЖЕТА, `UnsettledRuns` фильтруется отсрочкой, `DeferRun` перестал требовать живую попытку (множества фаз не пересекаются по `ended_at`)**. Пин `TestASettlementNobodyCanFinishStopsHoldingTheHeadOfTheMoneyList`; пин волны 1, закреплявший ОБРАТНОЕ («асимметрия списков»), развёрнут вместе с механизмом и это названо в нём вслух | fixed(P8-FIX) | воркфлоу-ревью волны 2: ПЯТЬ независимых линз, включая Fable 5 | | PD-354 | bug | major | `internal/runs/reconcile.go` `reconcileOne`, `maybeResync` | **Счётчик неудач не мог дорасти до собственного порога.** Дорогой вопрос движку задаётся не чаще `ResyncEvery` (дефолт 5 мин) и пропускается совсем, пока движется поток, — поэтому прогон с зависшим `status` падал, получал отсрочку, возвращался через минуту на проход, который движка НЕ СПРАШИВАЛ, и обнулял счётчик этой тишиной. 1,0,1,0 навсегда: `StalledAfter` недостижим, гейдж пуст, бэкофф не растёт, оператору не говорят — то есть механизмы 3 и 4 пака были мертвы на любой инсталляции **— закрыто правилом «счётчик снимается УЛИКОЙ, а не её отсутствием»**: `reconcile` сообщает, установил ли проход хоть что-нибудь (журнал сдвинулся · движок ответил · попытка закончилась), и только тогда зовётся `ClearRunDeferral`. Пин `TestTheFailureCountSurvivesThePassesThatAskTheEngineNothing` | fixed(P8-FIX) | воркфлоу-ревью волны 2 | | PD-355 | bug | major, деньги | `internal/pgstore/runs.go` `StalledRuns`, `cmd/tmplatformctl/runs.go` | **Колонка SPENT операторской таблицы показывала пожизненную трату КНИГИ, а не этой попытки.** В `run_attempts.spend_micro_usd` лежит цифра движка дословно, а она кумулятивна по книге за все прогоны (`ingest.Spend`), — то есть на второй книге оператор, решающий `--release-hold`, видел всё, что книга стоила с загрузки. Таблица заведена ровно ради этого решения **— закрыто разностью с базовой линией (та же арифметика, что у `attemptSpend`, с тем же клампом), а попытка БЕЗ базовой линии печатает `?`, а не ноль**: это ровно тот случай, где `settle` отказывается считать деньги вообще | fixed(P8-FIX) | воркфлоу-ревью волны 2 | | PD-356 | bug | major | `internal/pgstore/readmodel.go` `SaveStructure`, `internal/readmodel/readmodel.go` | **Провалившийся экспорт на СМЕНЕ КАДРА стирал весь оплаченный текст книги.** `TextKnown` бережёт пару только там, где её строка ВЫЖИВАЕТ: при смене кадра меняются все идентичности, старые строки удаляются каскадом, новые вставляются с тем, что принёс вызывающий, — то есть с пустотой, если манифест прочёлся, а экспорт нет. Дальше списание долга (PD-330) замораживало пустую поверхность навсегда, опровергая собственное обоснование «книга сохраняет прежнюю поверхность». Замерено ревью: тот же кадр — текст цел, новый кадр — `source=""`, `target=""` **— закрыто отказом: `Structure.TextRead` отличает «экспорт ответил пусто» от «экспорт не ответил», и разрушительная замена на второй не делается** (`ErrTextUnknownForANewCut`), долг остаётся следующему дрейну | fixed(P8-FIX) | воркфлоу-ревью волны 2 | | PD-357 | bug | minor | `internal/readmodel/readmodel.go` `giveUpOrRetry`, `internal/ingest/exit.go` | **Бюджет попыток книги тратился на общехостовые отказы:** ни одной ветки по ПРИЧИНЕ — подменённый бинарь движка, чужой лок проекта, немигрированная схема стоили книге попытку из пяти, а применимы они ко всем книгам хоста разом, так что несколько проходов списали бы всю библиотеку. Соседний пакет держит ровно эту норму и называет её вслух (`books.waitsForTheDeployment`) **— закрыто предикатом `ingest.DeploymentFault` (там, где живёт контракт кодов выхода) и `pgstore.AttemptCost`: такой отказ откладывается с бэкоффом, но попытки не стоит**. Пин `TestABrokenDeploymentDoesNotSpendABooksAttempts` (три формы) | fixed(P8-FIX) | воркфлоу-ревью волны 2 | | PD-358 | bug | minor | `cmd/tmplatformctl/runs.go` `oneLine` | **Заголовок книги из загрузки пользователя подделывал строки и колонки операторских таблиц**, а stderr движка резался ПО БАЙТУ. Интейк вычищает управляющие символы только из заголовка, выведенного из ИМЕНИ ФАЙЛА, поэтому названный руками приезжает в таблицу с табами и переводами строк; продукт переводит zh/ja, так что срез по фиксированному смещению попадает внутрь трёхбайтовой руны примерно в двух случаях из трёх **— закрыто заменой управляющих символов и срезом по границе руны**. Пин `TestTheOperatorsTableCannotBeForgedByABooksOwnText`; ESC пинится на самой функции, потому что tabwriter съедает таб в отступ и подделку в выводе не видно. ⚠ Две линзы ревью разошлись во ВЕСЕ этой находки (одна сочла микрополировкой, вторая довела цепочку до многобайтового stderr движка); починено как дешёвое и бесспорное | fixed(P8-FIX) | воркфлоу-ревью волны 2 | | PD-359 | hardening | minor | `internal/pgstore/books.go` `truncateReason` | **NUL обходил починку PD-340:** `strings.ToValidUTF8` чинит невалидный UTF-8, а U+0000 валиден — и Postgres отвергает его тем же SQLSTATE 22021, то есть падает ровно та запись, которая фиксирует отказ. ⚠ **Живого входа не найдено и это сказано прямо**: движок держит NUL-гейт на всех ветках декода и не цитирует байты файла в сообщении об отказе; закрыта дыра в защите, а не воспроизведённый дефект | fixed(P8-FIX) | воркфлоу-ревью волны 2 | | PD-360 | bug | minor | `internal/pgstore/sqlgate_test.go` | **Собственный гейт пака давал ложную зелень:** таблица исключений `unresolvable` ключёвана функцией, поэтому ВТОРОЙ несворачиваемый запрос в той же функции молча проверялся текстом первого и ещё и увеличивал отчёт «N statements planned» — подделка выглядела как рост покрытия. Бьёт по объявленному свойству самого гейта («запись пишется руками, значит второй такой сайт — чьё-то решение, а не тихая дыра») **— закрыто счётом сайтов: запись покрывает ровно один, второй сообщается** | fixed(P8-FIX) | воркфлоу-ревью волны 2 | | PD-361 | bug | minor | `internal/pgstore/runs.go` `AbandonRun`, `cmd/tmplatformctl/runs.go` | **`--release-hold` не мог изменить исход по деньгам, а его ветка оставляла прогон нерассчитанным навсегда.** Гейд живости допускает к `abandon` только попытку, не дошедшую до движка, — значит она ничего не потратила и холд возвращается ЦЕЛИКОМ обычным расчётом на следующем проходе; флаг решает только КОГДА. При этом доккоммент обещал «холд остаётся открытым, пока есть шанс, что движок ответит» — то, что гейд уже сделал невозможным, — а ветка флага закрывала резервацию, минуя список расчёта, и `settled_at` не ставил никто **— закрыто: обещание исправлено, ветка сама штампует `settled_at`, флаг оставлен и назван тем, чем является (нужен, когда демон остановлен — состояние, ради которого этот CLI и существует)**. Пин переименован по факту: `TestAbandoningAStalledRunIsRefusedOverALiveUnitAndAlwaysGivesTheHoldBack` ⚠ **ПАК P8-REVIEW 24.08: пин этой строки доказывает возврат холда на прогоне, которого команда не обслуживает** — он берёт прогон, созданный `Start` и брошенный сразу, у которого `reconcile_failures` 0 и `reconcile_after` NULL. У всей популяции, ради которой построен `run abandon`, отсрочка стоит, и холд ждёт до 30 минут. Вынесено строкой `PD-391`; статус паком не менялся ⚠ **ОСПОРЕНО(PD-391)** | fixed(P8-FIX) | воркфлоу-ревью волны 2 | | PD-362 | bug | minor | `internal/runs/reconcile.go` `ranOut`, `reconcileOne` | **Штатная остановка демона читалась как голодание инсталляции:** обе фазы сообщали об исчерпании через `DeadlineExceeded`, а отмена контекста — тот же `ctx.Done()`, поэтому обычный рестарт поднимал `tm_platform_sweep_unfinished_total` и, этажом ниже, писал каждому недообработанному прогону отказ, которого у него не было **— закрыто различением: голодание — только истечение СОБСТВЕННЫХ часов фазы**. Пин `TestAPassTheDaemonStoppedIsNotReportedAsStarvation` | fixed(P8-FIX) | воркфлоу-ревью волны 2 | | PD-363 | bug | minor | `internal/pgstore/runs.go` `RunsToReconcile` | **Отсрочка не снималась НОВОЙ уликой:** пользователь жал стоп, движок умирал, маркер лежал на диске — а прогон висел `translating` с зарезервированным холдом до конца бэкоффа (минуты на прогоне, отложенном пару раз), потому что свип — единственный читатель живых прогонов, а отсрочка есть утверждение о ПРОШЛОМ. Регрессия этого же пака: до него `Sweep` читал `ListLiveRuns`, и стоп отрабатывал на ближайшем тике **— закрыто одним предикатом: интент на стоп перевешивает отсрочку** | fixed(P8-FIX) | воркфлоу-ревью волны 2 | | PD-364 | bug | info | `internal/pgstore/migrations/00025_stalled_work.sql` | **Индекс, который не мог обслужить запрос, о котором его комментарий говорил «зеркалит».** Замерено ревью: планировщик берёт существующий `run_attempts_live_idx` (`ended_at is null`), новый индекс не сканируется ни разу, стоит одну пустую страницу, а стоимость свипа ограничена числом ОДНОВРЕМЕННО живых прогонов, а не длиной истории **— индекс УДАЛЁН, а не переописан**; обе фазы свипа упираются в уже существующие индексы. ⚠ Миграция правится в СВОЁМ ЖЕ незакоммиченном паке (не выкачена нигде), отпечаток в `migrations.sha256` пере-записан — это НЕ правка релизной миграции | fixed(P8-FIX) | воркфлоу-ревью волны 2 | | PD-365 | hardening | info | `cmd/tmplatformd/runner.go` | **Присвоение операторских ручек в демоне не было засвидетельствовано ничем:** `startRunner` требует БД, шину systemd и очередь, поэтому его конфиг-литерал наблюдаем только прогоном всего демона — именно так `RunBudget` и прожил целый пак объявленным, задокументированным и равным нулю (PD-331). Снятие обеих строк оставляло батарею зелёной **— закрыто выносом отображения в чистую `runsConfig` и пином `TestTheOperatorsRunnerKnobsReachTheReconciler`**; посадка ловится | fixed(P8-FIX) | воркфлоу-ревью волны 2 | | PD-366 | bug | info | `internal/pgstore/perf_test.go` | **Комментарий лгал о коде под ним** (класс PD-310/PD-326): условия замера объявляли, что бенчмарк этого файла воспроизводит ХОЛОДНЫЙ корпус, «потому что сеет и меряет без вакуума», — а `corpus` заканчивается `vacuum analyze`, то есть меряет ровно вакуумированный мир и его нормативные 16.8/3.4 **— формулировка исправлена по факту** | fixed(P8-FIX) | воркфлоу-ревью волны 2 | ## Закрытые — эра P11 (отзыв сессии на открытом потоке · застрявший расчёт · денежные пины) | PD-379 | vuln | **major** | `internal/httpapi/stream.go:160`=`h.pump(r.Context(), s, who, bookID, state, last, resuming)` ⚠ якорь пере-нацелен паком P11: сигнатуру `pump` изменил он сам (принципал вместо голого id — в этом и лечение), `internal/httpapi/server.go` `guard`, `internal/auth/session.go` `SessionStore` | **Открытый поток событий переживает и отзыв сессии, и оба её потолка: «выйти везде» не выключает уже установленный канал.** Аутентификация происходит РОВНО ОДИН РАЗ, в `auth.Authenticator.Require` внутри `guard`; дальше `streamEvents` уходит в `pump`, и цикл до конца соединения читает только `ReadFrames`/`ReadStream`, строку сессии не смотрит ни разу. Значит `POST /auth/logout`, `POST /auth/logout-all`, `tmplatformctl revoke` и оба потолка (idle и абсолютный) уже открытый `GET /v0/books/{bookId}/events` не прекращают. Пере-проверено трижды независимо (финдер, рефутер в отдельной копии, координатор); на демоне с `SESSION_IDLE=5s`/`SESSION_MAX_AGE=10s` поток жил +40 с после отзыва. Бьёт по объявленной норме: ASVS 5.0 7.4.1 — требование УРОВНЯ 1 при объявленном зоной L2, и `STACK_DECISIONS` §13 отказывается от лимита одновременных сессий ИМЕННО в обмен на мгновенный отзыв. ⚠ Побочно, тем же прогоном: `/auth/logout-all` на ДЕВ-профиле не смонтирован вовсе (404) — из пары ручек, которой §13 обосновывает свою политику, на стенде доступна одна. Воспроизведение: `docs/p8-review/axis2-auth/sse-outlives-revocation.sh` и `sse-outlives-absolute-ceiling.sh`, снимок координатора `docs/p8-review/sse-outlives-revocation.txt` ⚠ **ВЕС ПОДНЯТ minor → major ПОСЛЕ РЕВЬЮ СТАРШЕЙ МОДЕЛЬЮ (fable-5), и поднят по трём доводам, которых сужение не учло.** **(1)** Граница «соединение само закрывается, когда книга приходит в покой» — не гарантия кода: у книги, чей долг материализации списан как неоплатный, поток НЕ КОНЧАЕТСЯ НИКОГДА, и это собственный комментарий зоны — `internal/pgstore/books.go:629`=`a book whose event stream can NEVER end`. То есть окно утечки не ограничено прогоном. **(2)** Вес отказавшего КОМПЕНСИРУЮЩЕГО контроля наследуется от рисков, которые он компенсирует, а не от схемы кадра: `STACK_DECISIONS` §13 отказывается и от лимита одновременных сессий, и от собственной границы федеративной сессии ИМЕННО в обмен на мгновенный отзыв и два срока — а открытый поток ускользает от всех трёх разом, и у §13 не остаётся содержания. **(3)** Провалено требование УРОВНЯ 1 при объявленном зоной L2 — это дыра ниже собственного пола, а не отклонение от лучших практик. ⚠ **ЗАКРЫТО ПАКОМ P11 (29.08).** `auth.Principal` несёт непубличную способность пере-спросить свою сессию, `pump` зовёт её ПЕРВЫМ ДЕЛОМ на каждом тике, отказ даёт терминальный кадр `session_ended` (канон 0.8.0) с watermark СОЕДИНЕНИЯ, а не головой истории. Запрос стора `StillLive` намеренно БЕЗ клаузы idle: окно бездействия скользит на запросе, а поток — один запрос на всю жизнь, поэтому гашение по idle рвало бы связь активному читателю. Доказано ДВУМЯ раздельными живыми сценариями: (а) длинные потолки + `tmplatformctl revoke` → поток кончился через 1 с (`~/tm-p11/probes/a-revocation-ends-the-stream.txt`); (б) idle 10 с / абсолютный 40 с, БЕЗ отзыва → поток пережил окно бездействия и кончился ровно на потолке (`b-the-ceiling-ends-the-stream.txt`). Посадки `r_nocheck`, `r_idle`, `r_head`, `r_open`, `r_wirename` — пойманы. Эррата `STACK_DECISIONS` §13 снята, галочка ASVS 7.4.1 в архиве восстановлена. ⚠ Остаток отдельной строкой: строку сессии удаляет часовой свип и по бездействию тоже, поэтому «строки нет» обязано значить «мертва» | fixed(пак P11 — лендинг оркестратора; статус проставлен зоной по норме §3.6 «закрытие дефекта — коммит + пинящий тест») | ревью-пак P8-REVIEW, ось 2 (живая проба, подтверждено рефутером и координатором) | | PD-385 | bug | **major** | `internal/pgstore/runs.go:535`=`where a.ended_at is null and a.reconcile_failures >= $1` ⚠ якорь пере-нацелен паком P11: прежняя строка (`where r.finished_at is null and …`) была ОДНИМ предикатом на обе половины и её больше нет — выборка разложена на две ветви, и это ровно лечение, `internal/pgstore/observe.go` (гейдж), `internal/pgstore/runs.go` `AbandonRun` | **Прогон, чей РАСЧЁТ доведён до `StalledAfter`, не виден операторским поверхностям порога, а лог-строка на пересечении порога шлёт оператора именно туда.** Обе фазы делят один счётчик через общий `deferItem`, но операторская половина построена только для ЖИВЫХ прогонов: `StalledRuns` джойнит `a.ended_at is null` и фильтрует `r.finished_at is null`, гейдж `tm_platform_runs_stalled` считает по тому же предикату, а `AbandonRun` читает `where id = $1 and finished_at is null` и отвечает `ErrNoRun`. Живая проба на состоянии, выращенном штатными путями (интейк, HTTP-старт, отказ спавна, `run abandon`): `runs --stalled` отвечает «no run is failing to reconcile», `runs` — «no run is live», `run abandon` — «is not a live run», гейдж 0, при этом в базе `settled_at` NULL, `reconcile_failures` 5 и открытая резервация на 90000 микро. Тот же слепой угол закрывает прогон, который ЖИВ, но чья ПРЕДЫДУЩАЯ попытка не рассчиталась после рестарта. ⚠ Рефутер опроверг заголовочный абсолют «невидим ВСЕМ поверхностям»: `tmplatformctl balance --user` печатает этот холд строкой, `tm_platform_oldest_open_hold_seconds` растёт без потолка, а `tmplatformctl books` показывает «WHY NOT: unsettled hold»; ноль в улике финдера был артефактом его фикстуры. Остаётся то, ради чего строка заведена: три поверхности ПОРОГА слепы, терминальной ручки для такого прогона нет, а ERROR на пороге называет команду, которая на нём молчит. Воспроизведение: `docs/p8-review/axis3-queue/live-stalled-settlement.sh` ⚠ **ВЕС ПОДНЯТ minor → major ЗАКРЫВАЮЩИМ РЕВЬЮ СТАРШЕЙ МОДЕЛИ, и довод не про эту строку в одиночку, а про КРУГОВОЕ сужение четырёх строк пака.** `PD-384` сужен до minor тем, что холд «виден» гейджу `tm_platform_oldest_open_hold_seconds` и команде `balance --user`. Но `PD-392` доказывает ЖИВОЙ ПРОБОЙ, что у этого гейджа ручки НЕТ: идентификатора он не даёт, `balance --user` требует аккаунт, которого гейдж не называет, глобального списка открытых холдов в CLI нет, а документированный случай самого гейджа это ровно данная популяция — при `oldest_open_hold_seconds 10813` все три команды отвечают «no run is live», «no run is failing to reconcile», «no book has been given up on». `PD-390` доказывает, что тот же гейдж умеет ЗАМИРАТЬ и отдавать нули как здоровье. `PD-389` — что его сеттеры не запинены ничем. То есть каждое из четырёх сужений держится поверхностью, несостоятельность которой доказывает соседняя строка ТОГО ЖЕ пака, и по кругу. А терминальной ручки для этой популяции нет ПО ПОСТРОЕНИЮ: `internal/pgstore/runs.go:683`=`select finished_at from runs where id = $1` ⚠ якорь пере-нацелен паком P11: прежняя строка отвечала `ErrNoRun` законченному прогону — она и была дефектом; теперь это ветвление, читаемое ПОД блокировкой книги. Следствие, названное прямо: для всей популяции «закончен, но не рассчитан» деньги пользователя заморожены бессрочно, поверхности ПОРОГА слепы, ERROR на пороге называет команду, которая откажет, и единственный выход — сырой SQL в проде. Составной инвариант, на котором принят пак P8-FIX (`D39.154`: гейдж плюс `runs --stalled` плюс `run abandon` как ответ на `PD-169`), для этой популяции ЛОЖЕН ЦЕЛИКОМ — а «решается до следующего пака» есть определение major-секции самого регистра. Носителем major сделана ЭТА строка как самая полная по улике (живая проба на состоянии из штатных путей плюс отказ ручки); `PD-384` и `PD-392` несут ссылку сюда, чтобы не плодить второй major на тот же корень ⚠ **ЗАКРЫТО ПАКОМ P11 (29.08).** Популяция «кончился, а деньги нет» вошла в `StalledRuns` (колонка `PHASE`), в гейдж `tm_platform_runs_stalled` и получила терминальную ручку: `run abandon` закрывает КАЖДЫЙ осиротевший холд прогона, снимает отсрочку и штампует `settled_at`; допуск сужен до `reconcile_failures >= 1`, иначе команда отдавала бы целиком холд расчёта, который просто ещё не закрылся. Доказано до/после на состоянии из ШТАТНЫХ путей (интейк → HTTP-старт → спавн → выход движка → снят запиненный бинарь): было «no run is live» / «is not a live run» / гейдж 0 при открытой резервации 90000 микро, стало строка `settling` с холдом и возврат денег целиком (`~/tm-p11/probes/pd385-before.txt`, `pd385-after.txt`). ⚠ Вторая названная строкой популяция — ЖИВОЙ прогон с нерассчитанной ПРЕДЫДУЩЕЙ попыткой — получила обе поверхности видимости, но не ручку: отдельной строкой | fixed(пак P11 — лендинг оркестратора; статус проставлен зоной) | ревью-пак P8-REVIEW, ось 3 (живая проба, сужено рефутером) | | PD-376 | bug | minor, деньги | `internal/pgstore/runs.go:1221`=`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`. ⚠ **Пере-проверено на ПОЛНОЙ батарее координатором пака (мутация M11):** улика пере-снята на полной батарее — прогон `go test ./... -count=1` со всеми тремя гейтами под той же мутацией даёт ПУСТУЮ дельту против чистой базовой линии той же копии — то есть мутацию не ловит ни один из 18 пакетов. Лог — `docs/p8-review/mutations-round2.log` ⚠ **ЗАКРЫТО ПАКОМ P11 (29.08).** Взят готовый пин пака — вариант `r1_` как более сильный (ходит настоящими дверями `StartRun`/`RecordSpawn`, наименьшая базовая линия стоит В СЕРЕДИНЕ, так что «первая поздняя» и «последняя поздняя» тоже падают) — и усилен ВТОРОЙ книгой того же аккаунта, чтобы исполнялся и фильтр `r.book_id`. Посадка `min`→`max`: чистая копия EXIT=0, с мутацией EXIT=1, единственный красный — этот пин. ⚠ **ДИСПОЗИЦИЯ, которую строка оставляла приёмке: `PD-159` НЕ пере-открывается.** Основание прежнее (D39.159 §5): дефект из кода ушёл, недоставало ПИНА, — а теперь пин есть, то есть пробел закрыт там, где он был. Двусторонняя ссылка сохраняется: `PD-159` несёт `ОСПОРЕНО(PD-376)`, эта строка называет `PD-159` | fixed(пак P11 — лендинг оркестратора; статус проставлен зоной) | ревью-пак P8-REVIEW, ось 1 (посадка мутации, подтверждено рефутером собственным пином) | | PD-384 | bug | minor | `internal/runs/reconcile.go:278`=`case overran:` (`settleOne`) ⚠ якорь пере-нацелен паком P11: прежнее `if !overran {` было САМИМ дефектом — судить по цене вместо вердикта — и заменено свитчем по вердикту, `internal/runs/reconcile.go` `settle` (три тихих `return nil`) | **Расчёт денег, упавший ДЁШЕВО, не считается никогда: порог `StalledAfter` для него недостижим.** Вторая фаза считает неудачу ТОЛЬКО по исчерпанию бюджета (`overran := errors.Is(item.Err(), context.DeadlineExceeded)`, дальше `if !overran { return }`), а `settle` возвращает nil БЫСТРО в трёх случаях: движок не ответил, в отчёте нет committed, попытка без базовой линии. Каждый может быть ПОСТОЯННЫМ — запиненный бинарь движка снесён при выкате, проект заменён под платформой, попытка старой схемы. Тогда цикл вечен: `reconcile_failures` остаётся 0, `reconcile_after` NULL, гейдж и `tmplatformctl runs --stalled` пусты, холд заморожен. Замерено пробой: пять проходов одного нерассчитываемого прогона дали 5 вызовов движка, `reconcile_failures=0`, `StalledRuns(5)=0`. Плюс цена: `settle` зовёт `tmctl status` НА КАЖДОМ проходе без рейт-лимита, тогда как соседний `maybeResync` имеет `dueForResync` ровно из-за этой цены. ⚠ Рефутер сузил вес major → minor: холд ВИДЕН двум поверхностям, которых финдер не спросил — гейдж `tm_platform_oldest_open_hold_seconds` и `tmplatformctl balance --user`, печатающий каждый открытый холд суммой, книгой и id прогона; плюс каждый проход пишет WARN с id прогона. Воспроизведение: `docs/p8-review/axis3-queue/probe_settlement_surface_test.go.txt` ⚠ **Общий корень с `PD-385`, и там же он взвешен:** сужение ЭТОЙ строки опирается на операторскую поверхность, несостоятельность которой доказывает соседняя строка того же пака — круговое сужение разобрано в `PD-385`, поднятой до major ⚠ **ЗАКРЫТО ПАКОМ P11 (29.08).** Неудачей считается ВЕРДИКТ расчёта, а не только исчерпание бюджета: `settle` вернул три состояния (закрыт · гонка · не вычислим), `settleOne` судит по ним, поэтому все три тихих `return nil` теперь доходят до порога. Цена, названная строкой, закрыта тем же ходом: отсрочка ограничивает `tmctl status` вместо вызова каждым проходом. ⚠ Первая неудача НЕ откладывается — открытая резервация это ворота РЕЗЮМА пользователя (`reopen` отказывает, пока холд предыдущей попытки открыт), и минута ожидания после секундной аварии была бы регрессом; бэкофф идёт со второй и капнут пятью минутами, а не тридцатью. Посадка `r_firstfast` | fixed(пак P11 — лендинг оркестратора; статус проставлен зоной) | ревью-пак P8-REVIEW, ось 3 (проба на реальном сторе, сужено рефутером) | | PD-391 | bug | minor | `internal/pgstore/sink.go:724`=`where a.reconcile_after is null or a.reconcile_after <= $1`, `internal/pgstore/runs.go` `AbandonRun`, `cmd/tmplatformctl/runs.go` (сообщение), `deploy/README.md` | **`run abandon` не возвращает холд «на ближайшем свипе»: отсрочка застрявшей попытки остаётся, и деньги ждут до 30 минут.** `AbandonRun` завершает прогон и попытку, но `run_attempts.reconcile_after` не трогает, а `UnsettledRuns` по нему фильтрует. Застрявший прогон по построению всегда отсрочен: `deferItem` ставит `now + backoff(failures+1)`, а `backoff` при пяти неудачах упирается в потолок 30 минут. Значит для ВСЕЙ популяции, ради которой команда построена, обещание CLI «its hold comes back whole on the next sweep» и та же фраза рантбука ложны: кредит остаётся вычтенным, `tm_platform_oldest_open_hold_seconds` продолжает расти ПОСЛЕ действия оператора, и оператор читает это как «я сделал, не помогло». Пин `PD-361` доказывает свойство слабее: он берёт прогон, созданный `Start` и брошенный СРАЗУ, у которого `reconcile_failures` 0 и `reconcile_after` NULL. Живая проба на состоянии, выращенном штатным механизмом: через 45 секунд и три свипа холд открыт, `balance` печатает «reserved 3.000000», гейдж 2439 с; ручной `update run_attempts set reconcile_after = now()` закрывает холд в тот же свип. Воспроизведение: `docs/p8-review/axis4-metrics/20-abandon-keeps-the-hold.sh` и `r3-abandon-hold.sh` ⚠ **ЗАКРЫТО ПАКОМ P11 (29.08).** `AbandonRun` снимает `reconcile_after` в ОБЕИХ ветках, живой и расчётной. Пин растит прогон до пяти неудач штатными путями и сверяет холд ПОСЛЕ свипа на НЕДВИНУТЫХ часах — то есть исполняет ровно то обещание CLI, которое было ложным. Названные строкой носители обещания исправлены: `deploy/README.md` и сообщение команды. Посадка `m391_defer` — поймана топично | fixed(пак P11 — лендинг оркестратора; статус проставлен зоной) | ревью-пак P8-REVIEW, ось 4 (живая проба, подтверждено рефутером на состоянии из штатного пути) | | PD-397 | hardening | info | `internal/pgstore/credits.go:52-53`=`A ledger row is never edited: the correction is another row`, `internal/pgstore/credits.go:398`=`The two are never written apart`, миграция `internal/pgstore/migrations/00007_credits.sql` | **Два самых сильных денежных инварианта объявлены ПРОЗОЙ и держатся ТОЛЬКО кодом — схема их не навязывает.** `Adjust` обещает «леджер не правится, коррекция это ещё одна строка, и именно это делает сумму воспроизводимой»; `appendLedger` обещает «кэш и леджер никогда не пишутся врозь, потому что отстающий кэш — это второй ответ про деньги». Проба прямым SQL по стенду показывает, что DDL допускает нарушение обоих: `UPDATE` и `DELETE` строки леджера ПРИНЯТЫ, кэш баланса выставляется ЛОЖЬЮ и ОТРИЦАТЕЛЬНЫМ тоже. Пере-проверено координатором пака независимо от агента — все четыре приняты, и откат пробы сам же оставил расхождение кэша с леджером в 1 микро-доллар, которое поймало только сведение двумя путями, а не база. ⚠ Что схема при этом ДЕРЖИТ и что находкой НЕ является (иначе строка читается как «денежных констрейнтов нет»): знак по каждому виду строки, обязательная нота у коррекции, закрытый словарь видов, непустые `source`/`source_id`, уникальность ключа идемпотентности в пределах аккаунта, положительность сумм резервации, согласованность состояния и времени закрытия, владение книгой через композитный внешний ключ, и переполнение bigint в кэше. То есть DDL закрывает ФОРМУ строки и не закрывает ИСТОРИЮ. Цена названа и она не про сегодняшний код: пути правки леджера в Go нет, поэтому эксплуатации нет — опасны миграция данных, операторский `psql` и будущий инструмент, каждый из которых по построению идёт мимо кода, а прозу в доккомментарии не читает. Лечится либо триггером на `update`/`delete` по `credit_ledger`, либо явной записью «append-only — дисциплина кода, не схемы» рядом с обещанием. Воспроизведение: `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` ⚠ **ЗАКРЫТО ПАКОМ 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 | | PD-417 | bug | minor | `internal/pgstore/runs.go` `StalledRuns`, `internal/pgstore/observe.go`, миграция `00028_settling_attempts.sql` | **Расширение операторских выборок на вторую популяцию (`PD-385`) оставило их без индекса: последовательный проход по всем попыткам на запросе, который рантбук советует для cron, и на гейдже, который демон гоняет каждые 15 секунд вечно.** У живой половины индекс был с `00009` (`run_attempts_live_idx`, частичный по `ended_at is null`), у settling-половины — никакого. Замер на 200 000 попыток, `explain (analyze, buffers)`, таблица 2×2: **без индекса плохи ОБЕ формы** (3647 буферов дизъюнкцией, 3653 через `UNION ALL`), **с индексом хороши обе** (10 и 18). То есть катастрофу снимает ИНДЕКС, а не форма запроса. `UNION ALL` оставлен по другому доводу: каждая ветвь несёт путь доступа СТРУКТУРНО, тогда как `BitmapOr` — выбор планировщика по статистике, а статистика ЗДОРОВОГО деплоя (пустая застрявшая популяция) ровно обратна той, на которой мерилось. Класс — `PD-364` с другой стороны: там индекс не мог обслужить свой запрос, тут запрос перерос свои индексы ⚠ **ЗАКРЫТО ПАКОМ P11 (29.08)** миграцией `00028` (частичный индекс по `ended_at is not null and reconcile_failures >= 1`) плюс двумя ветвями | fixed(пак P11 — лендинг оркестратора; статус проставлен зоной) | самопроход пака P11 (линза цены в БД), пере-замерено по требованию артефакта | ## Закрытые — перенос по секциям (аудит 29.08: статус был переведён раньше, а строка осталась под открытым заголовком) > Ни одна строка здесь не меняла СТАТУС — каждая уже несла свой `fixed(...)`; двигалась только > позиция. Читающий открытый список по весу видел закрытую работу и мог пойти чинить построенное — > класс, который `ENGINEERING_STANDARDS` §3.8 называет стоившим зоне порядка десятой доли строк. > Счёт по статусам от переноса не изменился (`counts.py` ключуется формой строки, а не секцией). | PD-370 | bug | **major** | `internal/httpapi/reading.go`, `internal/httpapi/v0.go`, `internal/pgstore/readmodel.go`, канон `14-api-contract` §BankDecision | **Была построена и стоит в каноне модель, которую владелец отменил 16.08.** `D39.144` ратифицировал: подписывается ВЕСЬ банк ОДНИМ ОК, «пер-термная подпись = сотни кликов — НЕ модель продукта»; пер-термно существует не подпись, а ПРАВКА термина (до подписи) и правка с пере-генерацией задетых глав (после прочтения, гейчено 192). Гейт полноты нота сняла, а САМ ГЛАГОЛ остался — чистка дошла до resume и счётчиков и не дошла до словаря. Колонки были write-only: их не читал ни один SELECT, то есть реализации не существовало **— ЗОННАЯ ПОЛОВИНА ЗАКРЫТА 22.08 по слову владельца: снят write-путь целиком** (маршрут `POST /books/{bookId}/bank/decisions`, хендлер, wire-типы, метод интерфейса `Library`, `SubmitBankDecisions`, типы `BankDecision`/`BankReceipt`, `UnknownTermError`), на его месте — пометка для будущей сессии с ратифицированной моделью и с тем, что уже есть в схеме. Пол размера поверхности 15 → 14 сдвинут ЯВНО и с причиной в комментарии: гейт сработал, а не был подогнан. **КОНТРАКТНАЯ ПОЛОВИНА ЗАКРЫТА 27.08 минором 0.5.0 (D39.161):** путь `POST /books/{bookId}/bank/decisions`, глагол `submitBankDecisions` и три схемы снесены из канона — ноль вхождений, замер в `docs/CONTRACT_MINOR_REPORT.md` и пере-мер приёмки D39.161 п.2; счётчики `pending_decisions`/`complete` сняты со всех трёх носителей (`BankPage`, `EventBank`, квитанция). Преемник — дверь `POST …/bank/corrections` (выведена из `tmctl bank-apply`), СМОНТИРОВАНА паком P9 (D39.162); `Capabilities.bank_corrections_enabled` следует `cfg.RunsEnabled()`. Итог: зонная половина снята 22.08 (слово владельца), контрактная — 27.08 (D39.161); ⚠ НЕ наследовать этой строкой заказ на счётчики в `wireBankPage` — он жил отдельной строкой `PD-399` (закрыта паком P9 своим пином). | fixed(D39.161 — контрактная половина; 22.08 слово владельца — зонная; перевод по аудиту 28.08) | поправка владельца 22.08; ратификация D39.144; аудит 31 агента 22.08 · минор D39.161 · аудит документации 28.08 | | PD-406 | bug | **major** | `internal/httpapi/stream.go:137`=`helloID := state.Position` | **Служебный кадр `hello` на ПЕРЕПОДКЛЮЧЕНИИ несёт `id = state.Position` (голова истории книги) вместо предъявленного клиентом `last` — и переводит `Last-Event-ID` браузера ВПЕРЁД через кадры, которые это соединение ещё не отдало.** По WHATWG поле `id` фиксируется в last event ID buffer на диспатче; обрыв сразу после hello (прокси, вывод инстанса из ротации, ошибка первого `ReadFrames`) теряет кадры `last+1..Position` НАВСЕГДА — включая `note`, которые контракт запрещает терять («MUST NOT be coalesced or dropped»), без `resync_required`; усилитель — `revision` в data того же hello выше потерянных строк, так что предписанный контрактом дельта-догон `?after_version=` возвращает пусто, обезоружены обе починки сразу. Это ровно вариант, который `14-api-contract/README.md` (§Last-Event-ID) называет отвергнутым. ⚠ **ЗАКРЫТА фикс-раундом P9 (28.08, акт D39.162):** при `resuming` hello несёт `last` клиента (свежий коннект — голову, как и `from` ниже); пин `TestAResumingHelloCarriesTheClientsOwnWatermark` (+ посадка «hello обратно на голову» поймана) | fixed(фикс-раунд P9, D39.162) | воркфлоу-ревью P9 28.08 (линза race:flip-read-consistency), лечение по слову оркестратора 28.08 | | PD-401 | bug | minor | `internal/pgstore/runs.go` `StartRun` (снятие баз), `internal/pgstore/sink.go` `recordWaveShape`, `internal/pgstore/readmodel.go` `runDone`/`runTotal` | **Прогон, стартовавший при `edit_wave = false` и получивший переворот флага ПОСЛЕ старта, делает всю купленную работу с полосой на `0/N` — и это навсегда, а не «окном».** Механика: движок объявляет форму волн первым progress-событием ПОСЛЕ старта (`recordWaveShape`; флаг только растёт, `STACK_DECISIONS` §35), а базы полосы снимались в `StartRun` через флаг-зависимый предикат МОМЕНТА СТАРТА и не пере-базируются никогда — сидят на черновой колонке, числитель после переворота уезжает на редакторскую, разрыв не закрывается. Достижимо штатно: книга прочерчена на пайплайне без редактора, оператор ставит редактора, куплено продолжение; воспроизведено ревьюером приёмки заменой одной строки в пине пака (`edit_wave = false` до старта, переворот после → `0/4` при всей сделанной работе). Тот же симптом «полоса не достигает единицы», что у `PD-281`, — на том «промежуточном классе», которым диспозиция `PD-281` и объясняется. ⚠ Остаток и ПОСЛЕ лечения: на смешанном заделе (купленный диапазон шире начернённого) переворот двигает знаменатель вверх в окне первого progress-события — транзиентный провал доли ⚠ **ЗАКРЫТА лендингом P9 (58bae30, акт D39.162):** лечение (фиксированные базы + пара живым флагом + миграция 00026 + пин `TestAFlagThatFlipsAfterStartDoesNotStrandTheBar`) заленжено и зелено. Транзиентный остаток окна первого progress-события остаётся named-границей (PD-401-остаток в словаре зоны) | fixed(лендинг 58bae30, акт D39.162; перевод по аудиту 28.08) | пак P9: опровергатель формы полосы (первая редакция) · блокер ревьюера приёмки 27.08 (пере-формулировка) · аудит документации 28.08 | | PD-409 | hardening | minor | `internal/runs/bank.go` `decisionsFile` (`bank-corrections-*.json` в StateDir, удаление только `defer cleanup()`) | **Отрендеренный документ решений переживает любую нечистую смерть демона и остаётся в StateDir навсегда: ничто в дереве каталог не подметает.** SIGKILL/OOM/паника/истёкший грейс деплоя посреди вызова двери — и файл до 1 МиБ с полными src/dst правленых терминов пользователя лежит под 0600 бессрочно в каталоге, где оператор считает маркеры прогонов; единственная существующая уборка (`os.Remove` exit-маркера в `spawn.go`) эти файлы не видит. Накапливается через деплои: N нечистых смертей = N сирот. ⚠ **ЗАКРЫТА фикс-раундом P9 (28.08, акт D39.162):** `Service.SweepCorrectionScratch()` подметает по маске на буте (вызов в композиционном корне `tmplatformd` до подъёма HTTP, файлы вне маски не трогаются); пин `TestBootSweepsOrphanedCorrectionDocuments` (посадка «маска мимо» поймана). Честная оговорка: сам ВЫЗОВ из main юнитом не запинен — ловится чтением | fixed(фикс-раунд P9, D39.162) | воркфлоу-ревью P9 28.08 (линзы door:crash-windows · door:lock-lifecycle), лечение по слову оркестратора 28.08 | | PD-398 | standards | info | `docs/scripts/counts.py` `malformed`, строки `PD-99` и `PD-197` этого файла | **Гейт формы регистра ловит только НЕДОСТАЧУ ячеек, а не избыток — и в файле уже есть две строки, которые он пропускает.** `malformed()` сравнивает `if n < shape`, поэтому строка с лишним символом вертикальной черты внутри инлайн-кода проходит молча: счёт ячеек через `awk` с разделителем-чертой даёт `PD-99` (11 ячеек) и `PD-197` (10), а `counts.py --check` печатает `битая форма: []`. Обе строки допаковые, обе сломаны греп-альтернацией и регекспом в тексте. ⚠ Что СМЯГЧАЕТ и почему это `info`, а не выше: `col()` считает колонки С КОНЦА, поэтому статус и вес таких строк читаются ВЕРНО, и сегодняшние числа зоны не врут — пере-проверено, `PD-99` и `PD-197` попадают в свои корзины правильно. Дыра латентная: лишняя черта в одной из ТРЁХ последних колонок сдвинет уже их, и гейт снова промолчит. ⚠⚠ **ВТОРАЯ ПОЛОВИНА ПОСТРОЕНА 27.08 (оркестратор №19), смягчение строки ОПРОВЕРГНУТО.** Довод «читаем с конца, значит лишняя черта безвредна» неверен: он защищает СТАТУС (третья с конца), но не ВЕС — тот читался `col(l,6)`, то есть тоже с конца, а от конца он далеко, и любая лишняя черта в «сути» сдвигала его молча. Живой пример — сама `PD-197`: вес читался из ячейки «где». Эмпирика, принесённая зоной: три случая за двое суток, все у тех, кто в этот момент про этот класс ПИСАЛ (правка `PD-396` · первая редакция этой строки · пере-формулировка её приёмкой), и во всех трёх «битая форма» оставалась пустой. **Построено три вещи, каждая проверена исполнением:** (1) парсер уважает markdown-экранирование — `PD-99` несла корректно экранированные черты, и ломался парсер, а не строка; (2) ВЕС регистра читается с НАЧАЛА (`cells(l)[3]`), потому что ведущие ячейки коротки и черт не несут, хвостовые тоже, а свободный текст живёт в СЕРЕДИНЕ — оба конца безопасны, середина нет; (3) новый гейт `tail_vocab` судит СЛОВАРЬ хвоста (статус и вес), а не счёт ячеек, с одним грандфазерным исключением `PD-59`. Проверено подсадкой: черта в статусную ячейку краснит гейт немедленно; на чистом дереве EXIT=0, числа не сдвинулись (398/96, 3/27/66). ⚠ Правка `n != shape` по-прежнему ОТКЛОНЕНА и теперь на точном основании: после уважения экранирования в бэклоге остаются пять строк с законной сырой чертой внутри инлайн-кода, и их хвост чист. ⚠ **ЗАКРЫТА 27.08: пин появился.** `selftest_tail_vocab()` в `docs/scripts/counts.py` гоняется на КАЖДОМ `--check` и несёт четыре утверждения — чистая строка молчит · сырая черта в СТАТУСНОЙ ячейке краснит · экранирование законно и лишней ячейкой не считается · черта в СЕРЕДИНЕ не сдвигает вес. Проверен ПОСАДКОЙ, а не заявлением: три мутации гейта (снять уважение к экранированию · вернуть вес на чтение с конца · обезвредить словарь статуса) — пин ловит все три, базовая линия молчит. ⚠ Первая редакция пина МОЛЧАЛА на второй мутации: утверждение про вес проверяло `cells()`, а мутация меняет то, чем пользуется `register()`, — пин смотрел не туда. Переписано на настоящий путь; называю, потому что пин, проверенный одним прогоном вместо посадки, — это ровно тот дефект, который эта строка и описывает | fixed(приёмка 27.08, дерево сессии) | ревью-пак P8-REVIEW (наткнулся при правке собственной строки, подтверждено редакторским аудитом) | | PD-399 | standards | minor | `internal/pgstore/readmodel.go:330` `bankCountsTx`, `internal/httpapi/reading_test.go:113` | **`GET /books/{bookId}/bank` шлёт два поля, которых канон 0.5.0 больше НЕ объявляет.** Минор снёс `pending_decisions` и `complete` из `BankPage` и `EventBank` вместе с пер-термной моделью, которую они обслуживали, а проекция платформы их по-прежнему кладёт на провод, и это НЕ нули: `bankCountsTx` считает `proposed`-строки, и её собственный комментарий это говорит. Клиент 0.5.0 лишние поля игнорирует по общему правилу канона, поэтому вес minor, но аллоулист-норма нарушена, а комментарий «the wire fields are the canon's» с этого минора ЛОЖЕН. ⚠ Присутствие полей ЗАПИНЕНО (`reading_test.go:113`), значит снятие — правка с пином, а не вычёркивание. ⚠ Найдено САМОЙ контрактной сессией и принесено пингом: промт (§3.2-бис) утверждал «поля навсегда нули», а нота D39.160 п.2 — «после сноса канон и деплой совпадут точно»; верно по ПУТЯМ, неверно по ПОЛЯМ. Ошибка оркестратора, исправлена эрратой ⚠ **ЗАКРЫТА паком P9 (27.08): поля сняты со всех трёх носителей** — `BankCounts`/`payload()` (кадр `EventBank`), `wireBankPage`/`listBank` (провод), подзапрос к мёртвой `bank_decisions` ушёл из `bankCountsTx`; пин `reading_test.go` ПЕРЕПИСАН и теперь пинит ОТСУТСТВИЕ снятых полей на первой странице (`TestTheBankAggregatesRideOnTheFirstPageOnly`) | fixed(пак P9, дерево сессии) | пинг контрактной сессии при сдаче минора 27.08, пере-проверен приёмкой | | PD-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, рефутер:** механизм, который строка описывает (крах между ДВУМЯ автокоммитами `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 | 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-обработчик воскресит этот же дефект ⚠ **ДОФИКС ПО ВНЕШНЕМУ РЕВЬЮ (29.08), два пункта, оба в коде самой починки:** (1) helper звался ВНУТРИ цикла по искомым суммам, то есть буфер разбирался четырежды — вынесен наружу; (2) серьёзнее: пропуск ключа `time` стоял внутри РЕКУРСИВНОГО обхода, то есть вложенная группа с именем `time` получала тихое освобождение от денежной проверки. Это дыра, направленная не в ту сторону, в helper'е, чей смысл — что не освобождён никто; плоские строки делали обе версии неотличимыми, и так такая дыра доживает до смены формы. Теперь `delete` на ВЕРХНЕМ уровне, где поле и пишет обработчик, а обход безусловен. Запинено `TestTheTimestampExemptionDoesNotReachInsideTheLine` (синтетическая строка с вложенным `time`), посадка «вернуть пропуск в обход» — красная | fixed(пак P11, дофикс 29.08 + дофикс-2 по внешнему ревью — лендинг оркестратора; статус проставлен зоной) | охотник приёмки; механизм пере-проверен оркестратором №19 и независимо пере-замерен сессией P11; дофикс-2 — внешнее ревью | | PD-430 | hardening | minor, деньги | `internal/pgstore/credits.go` `ReadAccount`, пин `internal/pgstore/readaccount_test.go` | **Три денежные цифры, которые `ReadAccount` возвращает, были ВЗАИМОЗАМЕНЯЕМЫ для всей батареи: перестановка целей `Scan` оставляла зелёными все 18 пакетов.** Причина ровно в том, что делает эту функцию ценной: она — ЕДИНСТВЕННОЕ место, где кэш баланса и сумма леджера сравниваются, и все денежные тесты зоны утверждают, что они РАВНЫ. Поэтому единственное, чего ни один из них не видит, — это их обмен. ⚠ Мутант НЕ эквивалентен, доказано пробой на дрейфе (кэш 4.00 при леджере 1.00): чистый код отвечает `Balance=4.000000 LedgerSum=1.000000`, с перестановкой — `1.000000/4.000000`, при полностью зелёной батарее. Цена условна, но нацелена: дрейф — ровно то состояние, ради выявления которого функция и существует, и именно в нём `tmplatformctl balance --user` печатал бы СУММУ ЛЕДЖЕРА под словом «баланс», а любое решение по `Account.Balance` принималось бы по леджеру вместо кэша; предупреждение о дрейфе при этом продолжало бы срабатывать (сравнение обмен переживает), то есть оператор получил бы верную тревогу при двух неверных числах. Класс — `PD-394`/`PD-376`: денежный путь, который верен, и верен лишь потому, что никто не опечатался. ⚠ **Найдено не мной:** сессия пака `sqlc` (`textmachine-1b`) замерила, что гейт `TestEverySQLStatementParsesAgainstTheMigratedSchema` видит только СТРОКУ SQL и никогда Go-сторону вызова — шесть её посадок (перестановки целей `Scan`, сломанная арность) выжили на полной батарее. Я пере-проверила это независимо на самом денежном из чтений зоны и подтвердила. ⚠⚠ **Пак `sqlc` дыру НЕ расширяет и НЕ сужает её остаток — он убирает одну из двух осей.** Типы её не ловили и до него: в HEAD `Account.Balance`, `.Reserved` и `.LedgerSum` — все три `money.MicroUSD`, так что компилятор молчал ровно так же, и дыра была того же размера. Что пак действительно меняет (замерено сессией `textmachine-1b`: переставила две денежные колонки в SELECT, регенерировала, Go не трогала — `Scan` переставился следом и значения остались в своих полях): осей дрейфа было ДВЕ — расхождение SELECT-списка с целями `Scan` и рукописная проекция имя-в-имя, — а стала ОДНА, потому что первые две теперь генерируются вместе и разойтись не могут. Остаётся ровно то, что закрывает этот пин. Пин проверен против ОБЕИХ реализаций: ловит перестановку и в рукописном `Scan`, и в генерённом пути (последнее пере-проверено самой `textmachine-1b` на её дереве, а не с моих слов) | fixed(пак P11, дофикс 29.08 — лендинг оркестратора; статус проставлен зоной) | сессия пака `sqlc` (`textmachine-1b`, §3.1 — что sqlc даёт сверх гейта); пере-проверено посадкой сессией P11 на денежном чтении | ## Закрытые — эра P12 (30–31.08: деньги двери банка · честность экрана · эпоха формы конвейера · снятие обхода `--verify-bank`) > Пак «долги под ногами»: семь открытых major и висящие обязательства зоны; итог по каждой > живёт в её собственной строке ниже и здесь не пересказывается. > Канон двинулся один раз: минор > **0.9.0** под эпоху формы конвейера (вторая граница пересчёта `chapters_done` + непрозрачное поле `Book.shape_epoch`), > ратифицирован оркестратором ДО стройки пункта. > > ⚠ **Две строки ниже (`PD-380`, `PD-44`) паком P12 не чинились** — они уже несли свой `fixed(...)` и лежали под > заголовками «Открытые», то есть ровно класс, ради которого 29.08 заведена секция переноса. Двинулась только позиция. | ID | Класс | Серьёзность | Где | Суть | Статус | Источник | |---|---|---|---|---|---|---| | PD-425 | bug | **major**, деньги | `internal/httpapi/bank.go:149` (`r.Context()`), `internal/runs/bank.go` `RecordBankMove` | **Дверь банковских коррекций теряет пост-verb факт НАВСЕГДА при обрыве клиента, и следующая покупка платит за это холдом.** Хендлер берёт `r.Context()`, и `RecordBankMove` пишется на нём же; клиент закрыл вкладку до ответа — контекст отменён, запись не легла, `bank_moved_at` остаётся NULL. Второго писателя у факта нет, свипа нет, то есть состояние вечное. Деньги дальше: `re_pass` отвечает 409, а ОБЫЧНЫЙ прогон допускается с `resnapshot=false` и убивается снапшот-гардом движка ПОСЛЕ взятия холда — холд взят, попытка потрачена впустую. ⚠ Средство, которое дверь называет, недостижимо по построению: она отвечает `503` со смыслом «клиент пере-шлёт», а запись падает именно потому, что клиента уже нет. ⚠ Шаблон лечения лежит В ЭТОЙ ЖЕ ЗОНЕ и здесь не применён: `internal/pgstore/idempotency.go:150` и `books.go:205,215` делают `WithTimeout(WithoutCancel(ctx), …)` ровно с этой мотивировкой. ⚠ **ЗАКРЫТО пак P12 (30–31.08).** `RecordBankMove` идёт на СОБСТВЕННОМ контексте: `context.WithTimeout(context.WithoutCancel(ctx), recordBudget)` (`internal/runs/bank.go`, тот же класс, что `internal/httpapi/idempotency.go:151` `settleCtx` и `internal/books/parse.go:271` `writeCtx`). Детачится РОВНО запись и ничего больше — разбор шире: детач на двери снёс бы два намеренно построенных свойства (мёртвый клиент покидает очередь книги; бюджет verb'а), а детач самого verb'а не нужен, потому что ранний обрыв даёт SIGTERM → движок проверяет контекст ДО своих записей → exit 5 → `ErrBankUnavailable`, и не теряется ничего. `recordBudget` (10 с) а не 30-секундный `writeBudget` интейка: запись идёт ПОД блокировкой книги. Пин — `runs.TestTheBankMoveFactSurvivesAClientThatHungUp` (гейт `entered`/`release` держит verb в полёте, отмена приходит В ЭТОТ момент — контекст, отменённый ДО вызова, до записи не доходит вовсе, его съедает `lockBook`). Посадка M1 (снять `WithoutCancel`) — КРАСНАЯ адресно, соседний пин зелен. ⚠ **Битый якорь строки исправлен:** `internal/pgstore/idempotency.go:150` — это чтение ключа, никакого detach там нет; истинные якоря класса — `internal/httpapi/idempotency.go:151`, `internal/books/books.go:205,215` (тело `internal/books/parse.go:271`) и, чего строка не знала, `internal/runs/reconcile.go:119,277` — ТОТ ЖЕ пакет. ⚠ **Остаточное окно названо, а не закрыто:** смерть ПРОЦЕССА между verb и записью (SIGKILL, OOM, истёкший grace деплоя) по-прежнему теряет факт навсегда — `WithoutCancel` переживает клиента, не процесс. Окно сузилось с «вся фаза записи движка плюс round-trip БД» до «round-trip БД». Свипа быть не может: после факта у платформы нет свидетеля свёрнутой коррекции, ровно поэтому дверь штампует из СОБСТВЕННОЙ квитанции. | fixed(пак P12) | приёмка оркестратора №19 по паку P11 (охотник вне карты) | | PD-402 | bug | **major** | `internal/runs/reconcile.go:1221` `Resume` (switch по статусу, «последний ли» не спрашивается), `internal/pgstore/readmodel.go:484` `lastRun` (`order by started_at desc`), `internal/pgstore/runs.go` `RestartRun` (`started_at` не пере-штампуется) | **Возобновление НЕ-последнего прогона книги ломает полосу и карточку сразу двумя путями.** (1) Полоса возобновлённого открывается на ЧУЖОЙ работе: базы не пере-снимаются (это правильно для последнего прогона), но между стопом и resume по книге легально прошёл ЦЕЛЫЙ другой прогон, и оба числителя уже накрутили его главы — на нажатие «продолжить» пользователь видит 100% при нуле собственной работы, клэмп режет только превышение единицы, не двойной счёт до неё. (2) `lastRun` выбирает строку по `started_at desc`, а RestartRun `started_at` не трогает — карточка книги, `derivedStatus` и КАЖДЫЙ progress-кадр живого прогона считаются по ЧУЖОМУ финишировавшему: экран показывает `ready` и замороженный бар, пока живой прогон тратит деньги невидимо. Найдено воркфлоу-ревью и подтверждено его overlay-прогонами (4 независимые линзы сошлись на одном корне); ответ на сам resume (`ReadRun` по id) при этом честный — клиент получает от одной ревизии книги два разных бара. ⚠ **ЗАКРЫТА read-половина пак P12 (30–31.08)** (write-половина закрыта F12 ранее). Порядок «живой прогон ПЕРВЫМ» вынесен в одну константу `newestRun` = `order by (finished_at is null) desc, started_at desc, id desc limit 1` (`internal/pgstore/readmodel.go`) и вклеен во ВСЕ 10 мест склейки `lastRun` — плюс, и это половина, которой лечение read-модели не сделало бы: ТОТ ЖЕ порядок вклеен в `LatestRun` (`internal/pgstore/runs.go`), где `order by started_at desc` был выписан РУКОЙ. Оставить его — значило ПЕРЕВЕРНУТЬ дефект: экран пошёл бы за живым прогоном, а `Resume` отказал бы ему как «не последнему». Тотальность порядка обеспечивает `runs_one_live_per_book` (00002:66, уникальный по `book_id` при `finished_at is null`) — живой ровно один. Пины судят ПО МЕСТАМ, не зеленью батареи: `pgstore.TestALiveRunOutranksAFinishedOneOnEveryBookScopedRead` (карточка, её `Run`, строка библиотеки, `LatestRun`), `pgstore.TestTheStreamsFramesFollowTheLiveRunAndNotTheFinishedOne` (кадр статуса, `bookScope`, полоса) и `pgstore.TestWithNoLiveRunTheBookStillResolvesToTheNewestStarted` — обычный случай не тронут. Посадки M6 (снять живой терм) и M7 (расклеить `LatestRun`) — обе КРАСНЫЕ адресно. ⚠ **Цена, названная честно:** индекс `runs_book_idx (book_id, started_at desc)` этот порядок больше не обслуживает целиком — сортировка идёт по выражению. Латераль работает по ОДНОЙ книге, где прогонов единицы, так что замера это не потребовало; если у книги когда-нибудь станут сотни прогонов, носитель — новый индекс, а не откат порядка. | fixed(пак P12) | воркфлоу-ревью P9 28.08 (линзы bar:monotonicity · bar:double-count · bar:baselines-x-migration · bar:stage-caption), диспозиция оркестратора 28.08: строкой | | PD-403 | bug | **major** | `internal/pgstore/sink.go:415` `recordWaveShape` (`edit_wave` только растёт), `internal/pgstore/readmodel.go:388` `finishedUnits`, `internal/pgstore/books.go` `ReadBookForRun.ChaptersLeft` | **Книга с уже сложившимся `edit_wave=true` на деплое, где оператор УБРАЛ редактора, продаёт уже переведённые главы повторно — и ни один прогон больше не доводит полосу до единицы.** Безредакторный движок шлёт `editTotal=0`, но флаг не возвращается (`or $2` пишет только false→true); прогон делает 100% купленного черновика и закрывается `ready` на 50% со stage `editing`; `chaptersDone` (edit-колонка) мёрзнет, `ChaptersLeft` не падает — шкала покупки предлагает те же главы снова (симптом `PD-202`, записанной fixed этим самым флагом), а follow-up прогон над этой покупкой читает 0/C с первой секунды. Комментарий `sink.go:406-409` о собственном коде неверен обеими фразами (запрещённое направление называет разрешённым, «shows up as more chapters finished» не случается). Подтверждено overlay-прогонами воркфлоу. Корень продуктовый — смена формы конвейера на живой книге. ⚠ **ЗАКРЫТА пак P12 (30–31.08), по решению владельца D39.165 §2.** Носитель границы — НОВАЯ пара колонок `books.shape_epoch` + `books.epoch_editor` (миграция 00030): форма текущей эпохи ПРИСВАИВАЕТСЯ, счётчик считает пересечения. `books.edit_wave` остаётся и остаётся МОНОТОННЫМ — пин D39.153 §4б не тронут; уехал только пожизненный счёт, `finishedUnits` читает `epochWave` = `coalesce(b.epoch_editor, b.edit_wave, true)`. Движение `structure_version` отвергнуто: оно инвалидирует курсоры, идентификаторы пар и окна термов, а нарезка книги не менялась — это была бы ложь о структуре ради счётчика. ⚠ **ЗАКРЫТА ОДНА половина из двух, и вторая ОСТАЁТСЯ ОТКРЫТОЙ — см. `PD-435`.** Закрыт СЧЁТ КНИГИ: шкала покупки перестаёт продавать переведённое, `ChaptersLeft` падает, книга дочитывается. НЕ закрыта ПОЛОСА ПРОГОНА: на деплое, где редактора убрали, она по-прежнему тарифицирует edit-волну, которой не будет, и до единицы не доходит. ⚠ Полоса прогона ОСТАВЛЕНА на МОНОТОННОМ `books.edit_wave` — канон держит её одной монотонной дробью (строка 200), а эпоха ПРИСВАИВАЕТСЯ и ходит в обе стороны; попытка перевести полосу на эпоху была откачена этим же паком по замеру (`PD-435`). На эпоху уехал только пожизненный счёт книги. `PD-401` не вскрыт (парность числителя и базлайна не тронута) и теперь запинен с той стороны тоже: `pgstore.TestTheRunsBarIsMonotoneAcrossAShapeBoundaryItSpans`. Ложный абзац `sink.go` о собственном коде СНЕСЁН целиком, не пере-нацелен. Канон — минор **0.9.0** (ратификация оркестратора 30.08): фраза `chapters_done` называет ВТОРУЮ границу пересчёта рядом с пере-нарезкой, и появилось поле `Book.shape_epoch` — НЕПРОЗРАЧНЫЙ счётчик поколения; сама форма («есть ли редактор») на провод НЕ выносится, это было бы третьим исключением границы, которого D39.163 не даёт. `STACK_DECISIONS` §35 пере-подписан, прежняя формулировка оставлена с датой и причиной. Пины: `pgstore.TestRemovingTheEditorRecomputesTheBooksCountInsteadOfFreezingIt` и `pgstore.TestTheEpochMovesOnBoundariesAndNotOnEveryAnnouncement`. Посадки M8 (счёт обратно на монотонный флаг) и M9 (эпоха накапливает вместо присвоения) — обе КРАСНЫЕ адресно. | fixed(пак P12) | воркфлоу-ревью P9 28.08 (линзы bar:stage-caption · bar:double-count · bar:monotonicity), диспозиция оркестратора 28.08: строкой | | PD-404 | bug | **major** | `internal/pgstore/sink.go:415` `recordWaveShape`, `internal/pgstore/readmodel.go:388` `finishedUnits` (живой флаг), потребители `bookColumns.chaptersDone` и `ReadBookForRun.ChaptersLeft` | **Единственное разрешённое движение флага (false→true, оператор ДОБАВИЛ редактора) уводит `Book.chapters_done` НАЗАД в пределах одного `structure_version` — то, что канон запрещает прямо.** Книга с начерченным заделом: карточка 7/10; первое progress-событие прогона с редактором переворачивает флаг, `finishedUnits` переезжает на edit-колонку — 7 → 0 одной транзакцией при РАСТУЩЕЙ ревизии (клиент обязан отрисовать спад), `ChaptersLeft` раздувается обратно и шкала снова предлагает купить купленное. Класс `PD-316` (записана fixed) на непокрытом триггере: её пин ловит только admission, а не движение самого флага. Крайний случай той же арифметики: книга, начерченная ПОЛНОСТЬЮ под `false`, редактором НЕдочитываема ни одним действием API — `ChaptersLeft=0` не допускает прогон, а флип случается только в progress-событии допущенного прогона. Подтверждено воркфлоу на in-tree фикстуре (`sink_test.go:701`) ⚠ **ЗАКРЫТА пак P12 (30–31.08) ТЕМ ЖЕ носителем, что `PD-403`** — это одна арифметика с двух сторон, и закрывать её порознь значило бы оставить строку, чья репродукция больше не описывает дефект. Разрешённое движение флага (редактора ДОБАВИЛИ) по-прежнему пересчитывает счёт — так решил владелец, — но теперь пересчёт ОБЪЯВЛЯЕТ СЕБЯ: `books.shape_epoch` двигается той же транзакцией, и клиент видит новое поколение, а не ход счётчика назад. Крайний случай строки (книга, начерченная ПОЛНОСТЬЮ под `false`, недочитываемая редактором ни одним действием API) уходит с ней же: `ChaptersLeft` считается через эпоху, то есть после прихода редактора книга снова имеет что купить. Пин — `pgstore.TestAddingTheEditorMovesTheEpochWithTheCountItRecomputes`; посадка M9 КРАСНАЯ адресно. | fixed(пак P12) | воркфлоу-ревью P9 28.08 (линзы race:flip-read-consistency · bar:stage-caption · bar:baselines-x-migration), диспозиция оркестратора 28.08: строкой | | PD-405 | bug | **major** | `internal/pgstore/sink.go:219` (ложный комментарий-обоснование), `internal/books/parse.go` (инлайн-материализация после коммита `not_started`), `internal/pgstore/books.go` `BooksOwedReadModel` (`nothingIsRunning`) | **Прогон над книгой без материализованного дерева читает 0/total ВСЮ ЖИЗНЬ при исправно доезжающих progress-событиях — а комментарий, которым этот случай объявлен закрытым, описывает архитектуру, которой больше нет.** Окно штатное: интейк коммитит `not_started` ДО материализации; если она упала/отложилась и пользователь успел стартовать прогон, долг дерева заморожен до конца прогона (`nothingIsRunning`), `unitDone` находит 0 строк глав и выходит, вся полоса выведена из `chapters` → 0/total до `ready`. Комментарий `sink.go:219` «the counters the screen reads today come from the progress event» ложен — эти колонки не читает никто (носитель — `PD-411`). Достижимость ЗАМЕРЕНА логом живого стенда: на здоровом пути окно «`not_started` без строк `chapters`» живёт **0.018 с** (обе загрузки пробоя P9: `book parsed` → `the reading surface was refreshed`, 0.0179 и 0.0189 с) — опасная форма только УПАВШАЯ/отложенная материализация, и тогда длина окна = вся жизнь прогона. Лечение — честная привязка полосы к бездеревному окну (отказ старта до дерева ИЛИ полоса из progress-событий как раньше); замер достижимости ратифицирован актом D39.162 ⚠ **ЗАКРЫТА пак P12 (30–31.08), формой «отказ старта до дерева»** (из двух названных зоной лечений). Довод против второго — полосы из progress-событий: те колонки НИКТО не читает (`PD-411`), то есть «как раньше» означало бы построить полосу заново поверх мёртвого носителя; а разделение `nothingIsRunning` тронуло бы конъюнкт, общий с `ReadStream` и лизинг-дисциплиной §34. Отказ стоит ДО взятия холда: `internal/runs/runs.go` `Start`, предикат `book.ChapterCount > 0 && !book.HasTree` (новое поле `BookRunContext.HasTree`, `exists(select 1 from chapters …)`). Слово на проводе — существующее `book_not_ready` (409), НЕ новое: собственная глосса канона говорит «still arriving, still being CUT, or was rejected», а книга, должная дерево, ещё нарезается. Ложный комментарий `sink.go` («the counters the screen reads today come from the progress event») СНЕСЁН — он и был прикрытием этой строки. ⚠ **Что вскрылось при этом и названо строкой:** ВСЯ runs-батарея ездила на книге, объявляющей главы и не материализовавшей ни одной, — фикстуры РАСШИРЕНЫ деревом (`newFixture`, `secondBook`), не обойдены. Пин — `runs.TestARunIsRefusedOverABookWhoseChaptersWereNeverMaterialised` (проверяет и то, что отказ НЕ ДВИНУЛ ДЕНЬГИ) и `runs.TestABookThatDeclaresNoChaptersIsNotRefusedAsUnmaterialised` (книга, объявившая НОЛЬ глав, под гард не попадает — её отказывает то, что должно: покупать нечего); посадки M10 и M19 КРАСНЫЕ адресно. ⚠ **Названо адверсариальным проходом пака и подписано в коде, а не замолчано:** одна популяция этим отказом становится НЕзапускаемой до действия оператора — книга, чей долг читательской поверхности СПИСАН после исчерпания попыток (`AbandonReadModelDebt`): она никому ничего не должна, свип её не подберёт, и отказ стоит, пока не попросят заново. Ручка существует и названа прямо в комментарии гарда — `tmplatformctl book refresh --book `, список — `tmplatformctl books --abandoned`. До пака такая книга запускалась и показывала мёртвую полосу; теперь она отказывает и указывает на лечение. | fixed(пак P12) | воркфлоу-ревью P9 28.08 (линзы bar:stage-caption · bar:double-count), диспозиция оркестратора 28.08: строкой, достижимость замером (акт D39.162) | | PD-411 | standards | minor | `internal/pgstore/sink.go:137` и `:447` (два писателя), потребителей НЕТ (греп: ни одного select) | **`runs.draft_done/draft_total/edit_done/edit_total` — носитель с ДВУМЯ писателями и НУЛЁМ читателей: класс A второго яруса онтологии банка (`18-bank-ontology.md`), сосед `PD-314` по форме.** Полоса целиком выведена из `chapters` (runDone/runTotal), эти колонки не читает ни один SELECT — а пишутся они на КАЖДОМ progress-событии внутри транзакции, держащей блокировку строки книги, двумя РАЗНЫМИ дисциплинами (поток присваивает, resync берёт `greatest`), и обоснование `greatest` на `sink.go:444-446` защищает полосу, которой не существует, — следующая сессия будет искать монотонность бара не там, где он живёт. ⚠ **ЗАКРЫТА пак P12 (30–31.08)** (запрет сноса в P9 был пер-паковым — слово оркестратора 28.08): колонки снесены миграцией 00031, оба писателя и оба лгущих комментария удалены. Четыре теста, наблюдавшие свойства ЧЕРЕЗ эти колонки, пере-нацелены на то, что progress-событие materialises на самом деле — ETA и объявленную ФОРМУ; это не ослабление, а восстановление предмета: у утверждения над снесённой колонкой предмета нет. ⚠ **СНОС ВСКРЫЛ ГАП, и он заведён отдельной строкой, а не замолчан:** РЕСИНК — канал починки для прогона, чей поток в карантине, — писал прогресс ТОЛЬКО в эти колонки. Он не трогает ни `unit_resolutions`, ни `chapters`, значит не материализует ничего, что читает экран, и полоса такого прогона стоит всю его жизнь, как бы исправно ни отвечал `tmctl status`. Мёртвые колонки это ПРЯТАЛИ: код выглядел так, будто канал починки чинит. | fixed(пак P12) | воркфлоу-ревью P9 28.08 (линзы race:flip-read-consistency · bar:double-count · bar:stage-caption), диспозиция оркестратора 28.08: отдельной строкой класса A | | PD-369 | bug | minor | `internal/pgstore/idempotency.go` `ClaimIdempotency`, `claimRounds` | **Легитимный запрос получает 500 под конкуренцией на одном ключе идемпотентности.** Ретрай проигранной гонки ограничен `claimRounds = 3`, а восемь одновременных попыток одного ключа, каждая из которых сразу отдаёт его назад (как делает любой 4xx), могут отобрать гонку у одного и того же проигравшего трижды подряд — и он получает не один из четырёх контрактных ответов, а внутреннюю ошибку. **Замерено: 1–2 падения на ~80 прогонов собственного теста `TestAClaimThatLostARaceToAReleaseIsRetriedAndNotAnError`** (то есть тест ФЛЕЙКОВЫЙ, и это сам сигнал). Комментарий над константой признаёт границу («a caller that loses three rounds is meeting something other than a race») — но восемь racer'ов и есть гонка, а не «что-то другое». ⚠ Направление, не решение: либо граница по ВРЕМЕНИ вместо числа раундов, либо ответ `ErrKeyInFlight` вместо 500 при исчерпании ⚠ **ЗАКРЫТА пак P12 (30–31.08), формой `ErrKeyInFlight`** (из двух названных зоной). Довод против границы по ВРЕМЕНИ — она дефект не закрывает: чем бы ни ограничивался цикл, он обязан чем-то ЗАВЕРШИТЬСЯ, и пока терминал — голый `fmt.Errorf`, это по-прежнему 500, просто реже; две «формы» лежат на разных осях — ответ обязателен, граница свободна. Плюс собственная часовая дисциплина файла: всё время в `ClaimIdempotency` берётся из ИНЖЕКТИРУЕМОГО `now`, а дедлайн по стенным часам внутри цикла ввёл бы второй, скрытый источник времени, непроверяемый под замороженными часами. Сделано: `pgstore.ErrKeyContended` ОБОРАЧИВАЕТ `ErrKeyInFlight` (лекарство у вызывающего то же — подождать `Retry-After` и повторить), словарь кодов контракта НЕ расширен — `idempotency_conflict`/`key_in_flight` уже есть; на HTTP один писатель ответа (`keyInFlight`), случай контенции стоит ПЕРЕД более широким и пишет WARN: громко у нас, тихо на проводе. ⚠ Пин НЕ ПРАВЛЕН (D39.121): `TestAClaimThatLostARaceToAReleaseIsRetriedAndNotAnError` принимал `ErrKeyInFlight` и по построению принимает обёртку. Новые пины: `pgstore.TestGivingUpOnAContendedKeyIsOneOfTheContractsOwnAnswers` и подтест «a claim that lost the race too often is told to wait, not answered 500». **Пере-замер серией ПОСЛЕ фикса:** `go test ./internal/pgstore -run '^TestAClaimThatLostARaceToAReleaseIsRetriedAndNotAnError$' -count=80` ⇒ **80 PASS, 0 FAIL, 0 SKIP** (счёт по `^--- PASS`, а не по коду выхода: all-skip читался бы как зелень). | fixed(пак P12) | попутная находка сессии P8-FIX (флейк батареи), передано оркестратору | | PD-431 | hardening | minor | `internal/pgstore/sessions.go:49` `Touch`, `internal/pgstore/queries/sessions.sql` `TouchSession`, пин отсутствует | **`Touch` выбрасывает `RowsAffected`, поэтому запрос, не обновивший НИ ОДНОЙ строки, неотличим от успеха — и батарея остаётся зелёной.** Замерено адверсариальным проходом пака `sqlc` 29.08: `where token_sha256 = $1` → `where 1 = 0 and token_sha256 = $1`, апдейт не трогает ничего — **18 пакетов, `EXIT=0`, ноль красных**. Контроль на том же стенде (обезвреженный `delete from reservations` в `DeleteBook`) даёт две красных, то есть харнесс работает и молчание относится к `Touch`, а не к прогону. Продуктовый смысл: скольжение окна бездействия — то, что держит активного пользователя в сессии, — не проверяет ни один живой тест; два его вызова (`pg_test.go:115`, `TestTouchCannotResurrectAnIdleExpiredSession`) решаются предикатами самого SQL и остаются истинными, если `Touch` не делает ничего. ⚠ **Класс, который ПОКРЫТИЕМ не ловится в принципе:** для `Exec`, чей тег отброшен, «оператор исполнился» — это всё, что покрытие когда-либо докажет; нужен мутационный проход по ЗАПИСЯМ. Именно поэтому пак `sqlc`, считавший непокрытые строки, нашёл три опасных оператора, а этот — четвёртый — не нашёл. ⚠ **ЗАКРЫТА пак P12 (30–31.08), ЧЕСТНОСТЬЮ ПРОВЕРКИ — поведение НЕ менялось** (слово оркестратора: ноль строк — законная гонка с отзывом). Новый сквозной пин `pgstore.TestTheIdleWindowSlidesThroughTheAuthenticatorAgainstALiveStore`: живой `*pgstore.Store` подставлен `auth.Authenticator` как его же `auth.SessionStore` — интерфейс он удовлетворял всегда, production не менялся, не хватало теста, который соединит два конца. Живёт в пакете `pgstore`, потому что `pgstore` импортирует `auth`, а не наоборот: тот же тест в `auth` — цикл импорта. Утверждение идёт через ПОЗДНИЙ `Lookup` за первоначальным дедлайном, а не через код ответа: ошибка `Touch` логируется и проглатывается, так что 200 не доказывает ничего. Второе утверждение — что скользнули ПРАВИЛЬНЫЕ часы (абсолютный потолок не двинулся). Посадка M12 — ИМЕННО та, которой строка заведена (`where 1 = 0 and token_sha256 = $1` в `TouchSession`, в исходнике и в генерённой константе): новый тест КРАСНЫЙ адресно, оба прежних `Touch`-теста ЗЕЛЁНЫЕ — то есть дельта ровно та, о которой строка. Добавлен второй пин на отзыв (`TestARevokedSessionIsDeniedThroughTheAuthenticatorAgainstALiveStore`). | fixed(пак P12) | адверсариальный проход пака `sqlc` (П-19), 29.08 | | PD-432 | doc | minor | `docs/STACK_DECISIONS.md` §«Гейты батареи» (рецепт стенда), `~/.local/bin/tmctl` | **Рецепт второго гейта батареи не говорит, что движковый бинарь надо ПЕРЕСОБРАТЬ, и устаревший бинарь даёт КРАСНУЮ батарею, которая читается как дефект кода зоны.** Замерено 29.08 паком `sqlc`: `tmctl` стенда от 24.08 против сегодняшнего `backend/configs/models.yaml` — три красных теста в `internal/books` и `internal/runner`, сообщение `tmctl: config: parse …/models.yaml: yaml: unmarshal errors: line 137: field system_messages not found in type config.CapabilitiesConfig`. Диагноз стоит времени именно потому, что выглядит как ошибка платформы: падает платформенный тест, а лжёт бинарь движка, собранный до того, как в конфиг движка приехало поле. ⚠ Класс тот же, что у `PD-423`: условие батареи, которое рецепт называет неполно, и следующая сессия ищет дефект в своём диффе. ⚠ **ЗАКРЫТА пак P12 (30–31.08):** в рецепт «Гейты батареи» дописано, что движковый бинарь второго гейта обязан быть СОБРАН из текущего `backend/`, с симптомом и командой. ⚠ **Той же правкой снята ОПРОВЕРГНУТАЯ команда-проверка четвёртого условия** (`cut -d: -f3 /proc/self/cgroup` ≠ `/init.scope`): к двум точкам `PD-423` пак добавил ТРЕТЬЮ — у оболочки этой сессии команда даёт `/`, а `TestARunIsBoundedByItsOwnCgroup` при этом зелен в полной батарее со скипами 0. Рецепт теперь велит судить по САМОМУ тесту; кандидат-замена (`cgroup.subtree_control` целевого среза) назван КАНДИДАТОМ и в рецепт не внесён — диагноз `PD-423` не установлен. ⚠ **Тем же классом найдено и починено на стенде: КОНФИГИ движка тоже протухают** — стендовая копия `backend/configs` не имела `langpacks/ru`, появившегося позже; правило дописано в рецепт рядом с бинарём. | fixed(пак P12) | пак `sqlc` (П-19), 29.08, найдено первым же прогоном полной батареи | | PD-367 | bug | minor | `internal/books/parse.go:129`, `internal/ingest/manifest.go` `Whole` | **Пол на самосогласованность манифеста стоит только у материализатора, а интейк тот же документ ПРИНИМАЕТ.** Манифест `{ChaptersTotal: 120, UnitsTotal: 400}` с пустым списком глав `Whole()` отвергает, а `books.Parse` заводит книгу `not_started` с `chapter_count=120` и пустым деревом — по такой книге можно СТАРТОВАТЬ и ОПЛАТИТЬ прогон (потолок считается от `chapter_count`). Воспроизведено ревью на живом Postgres. ⚠ **ЗАКРЫТА пак P12 (30–31.08) по решению оркестратора: пол самосогласованности СИММЕТРИЧЕН, интейк отказывает.** `ingest.Manifest.Readable()` — ВТОРОЙ предикат рядом с `Whole()`, не расширение `DecodeManifest` (у того четыре пина намеренно декодируют частичные и чужие документы, и его собственная дока объявляет правило «значение не гейтим» решением). Стоит ПЕРВЫМ в `books.Parse`, до ветвей по `ChaptersTotal`: ниже этой черты документ, прочитанный неверно, неотличим от книги, в которой ничего нет, а ЭТО чтение удаляет аплоад. Класс — `parser_unavailable`: бюджет попыток тратится, файл остаётся. ⚠ **ПРАВКА ЗАПИНЕННОГО КОНТРАКТА, заказанная промтом P12 §3.8** — не подгонка под зелень: батарея интейка ездила на документах без списка глав, и фикстуры РАСШИРЕНЫ (`wholeManifest`), а не обойдены; поимённо пере-подписаны фикстуры `books_test.newFixture` и два манифеста `render_test`. Пин — `books.TestTheIntakeRefusesTheDocumentItsOwnMaterialiserWouldReject` (ровно документ строки: 120 глав, 400 пар, пустой список; проверяет и что `chapter_count` НЕ записан, и что файл цел); посадка M15 КРАСНАЯ адресно. | fixed(пак P12) | воркфлоу-ревью волны 2 (P8-FIX); рефутер подтвердил механику и опроверг предложенное лекарство | | PD-213 | hardening | info | `internal/ingest/manifest.go`, `internal/books/parse.go` | **Форма манифеста не версионируется на стороне платформы — латентная мина на УДАЛЕНИЕ файла.** `DecodeManifest` не сверяет `manifest_version` ни с чем; `json.Unmarshal` тихо игнорирует незнакомые поля и оставляет отсутствующие нулями, поэтому смена формы движком (`tm-manifest-v2` → v3, переименование `chapters_total`) даст валидный разбор с `ChaptersTotal = 0`. А ноль глав интейк трактует как «источник прочли, книги нет» — тот же терминал и тот же бюджет, что exit 11, то есть после пяти попыток файл пользователя УДАЛЯЕТСЯ, хотя движок книгу прекрасно разобрал. Сегодня формы совпадают поле-в-поле, так что не эксплуатируется; в отличие от потока (мажор отвергается) и от полосы кодов (незнакомый номер безопасен), у манифеста аналога нет. Лечить сверкой `manifest_version` с известной, где незнакомая версия даёт НЕ-деструктивный класс ⚠ **ЗАКРЫТА пак P12 (30–31.08) тем же классом «незнакомое → не-деструктивно»,** как и предписывала строка. `ingest.KnownManifestVersion` (`tm-manifest-v2`, зеркало `backend/internal/pipeline/manifest.go:46`) сверяется в `Readable()` перед всем остальным, и незнакомая версия даёт `parser_unavailable` — файл цел. Это ФОРМЕННЫЙ гейт, а не пин значения: сама строка и дока поля правы, что пиннинг значения везде сделал бы каждый релиз движка релизом платформы, поэтому версия сверяется РОВНО в одном месте — там, где неверное чтение разрушительно. Пин — `books.TestAManifestShapeThisBuildDoesNotKnowNeverDeletesTheUpload`: v3-манифест переживает весь бюджет попыток с целым файлом, а ЗНАКОМАЯ форма с честно нулевыми счётчиками по-прежнему удаляет каталог (иначе гейт съел бы настоящий вердикт о тексте пользователя). Посадка M14 (снять сверку версии) КРАСНАЯ адресно. | fixed(пак P12) | адверсариальное ревью P6 (линза шва) | | PD-427 | doc | minor | `internal/ingest/resync.go:37-43` (аллоулист `StatusReport`), опровергнуто `backend/internal/pipeline/status.go` `projectStoredMemory` (лендинг `6ec9f8a`, D39.170) | **Комментарий несёт ПОСЫЛКУ, которую сняли, и читается как действующий довод.** Он объясняет, почему платформа сознательно НЕ берёт `rebill_units`/`rebill_usd` через шов: «status проецирует СОХРАНЁННУЮ память, и сразу после `bank-apply` — в единственный момент, когда согласие хотело бы цифру, — он честно читает ноль». Это было верно и ратифицировано (эррата 28.08-к). Движковый пак «деньги» починил ровно это: `foldMemoryForRead` стал ПЕРВЫМ ответом читающего пути, а `projectStoredMemory` понижена до фолбэка, и комментарий движка объявляет это дословно — «IT IS NO LONGER THE READ PATH'S FIRST ANSWER». Слепое окно закрыто, `status` отвечает «сколько будет стоить» ДО покупки, оставаясь $0-глаголом без записи. ⚠ **Комментарий неверен ДВАЖДЫ:** не только посылка, но и предсказанное лечение — он обещает, что «пара вернётся с движковым ГЛАГОЛОМ, который умеет свернуть и оценить коррекцию ВНЕ прогона», а нового глагола не появилось: починили существующий `status`. ⚠ **ПРОВОДКУ ПОЛЕЙ ЭТА СТРОКА НЕ ОТКРЫВАЕТ** (слово оркестратора при передаче): она гейчена вместе с `tmctl translate --max-units`, и тот гейт в силе — движковый потолок объёма на майнящей банк книге пробивался, лечение легло, но проводка ждёт отдельного решения. То есть предмет строки — ровно устаревший ДОВОД, а не отсутствие полей. Класс — «указатель пережил то, на что указывал», тот же, что `PD-310`/`PD-326`/`PD-366`, только в прозе шва. Зеркалит строку 234 единого бэклога ⚠ **ЗАКРЫТА пак P12 (30–31.08) — акт закрытия, не работа:** комментарий `internal/ingest/resync.go` уже исправлен 29.08 аудитом доков, снятая посылка из него ушла, предсказание про «новый движковый глагол» тоже. Проверено чтением обеих сторон. Проводку полей строка не открывала и не открывает — гейт `--max-units` в силе. | fixed(пак P12, акт закрытия) | оркестратор №19 при лендинге движкового пака (`6ec9f8a`), проверено чтением обеих сторон сессией P11 | | PD-203 | bug | info | `internal/pgstore/books.go` `ReadUsage` | **Аккаунт объявляется исчерпанным по паузе ОДНОГО прогона.** `/usage` ставит `paused_reason` аккаунта, если у какой-нибудь книги последний прогон стоит `paused/credit_exhausted` — а это потолок ПРОГОНА (сколько глав купил пользователь), а не баланс: на аккаунте может лежать сколько угодно денег, и другой прогон стартует. Контракт про это поле говорит «Set when the account itself is in a halted state». Существовало до этого пака и не им создано; отдельной строкой, потому что различение потолков (PD-199) сделало вопрос «чей это потолок» отвечаемым ⚠ **СУЖЕНО P7:** `Usage.halt_reason` получил СВОЙ словарь (`AccountHaltReason`), и `PD-241` убрал самый частый ложный источник — стоп пользователя, приезжавший `credit_exhausted` ⚠ **ПАК P8-REVIEW 24.08 ПРЕДЛОЖИЛ ЗАКРЫТЬ, проверив предикат по коду:** `internal/pgstore/books.go` `ReadUsage` читает БАЛАНС аккаунта, а не сканирует книги (`platform/internal/pgstore/books.go:1153`=`case balance <= 0:`), а ⚠-комментарий рядом прямо описывает замену (`platform/internal/pgstore/books.go:1122`=`It used to be read off the`); единственный писатель `Usage.PausedReason` — эта же строка (греп `PausedCreditExhausted` по `internal/pgstore/books.go`), приехало `9b23e8c` ⚠ **ЗАКРЫТА пак P12 (30–31.08): предикат пере-проверен и предложение P8-REVIEW подтверждено.** `internal/pgstore/books.go` `ReadUsage` читает БАЛАНС аккаунта и ставит аккаунтную причину только при `balance <= 0`; книги не сканируются. Канон на той же стороне: `AccountHaltReason` — «a state of the account, not of a run», отдельный словарь ровно затем, чтобы прогонная причина не зажигала аккаунтный флаг. То есть дефект строки не воспроизводится, и это закрытие, а не пере-открытие. | fixed(пак P12) | сессия P6 (самопроверка вокруг PD-199) | | PD-380 | hardening | minor | `internal/pgstore/sessions.go:96`=`AbsoluteExpiresAt: now.Add(maxAge),`, `internal/config/config_test.go` | **Абсолютный потолок сессии не запинен в единственном месте, где он становится фактом в базе.** `CreateSession` — единственный писатель `sessions.absolute_expires_at`, и мутация этого выражения проходит ПОЛНЫЙ пакет `pgstore`: ни один сессионный тест не краснеет. Пин, который `STACK_DECISIONS` §13 называет носителем потолка, смотрит только на результат `config.Load()` (что значение конфигурации не выше ASVS-предела), то есть проверяет НАСТРОЙКУ, а не то, что она доезжает до строки. Родня `PD-86`, но на шаг раньше: там не запинены клаузы ЧТЕНИЯ и потолок держится транзитивно через `Touch`, здесь не запинена сама ЗАПИСЬ, а транзитивной страховки у неё нет. Воспроизведение: `docs/p8-review/axis2-auth/mutations-axis2.sh` ⚠ **Пере-проверено на ПОЛНОЙ батарее координатором пака (мутация M12):** `now.Add(maxAge)` → `now.Add(100*maxAge)` в `CreateSession`, дельта против чистой копии ПУСТА на всех 18 пакетах. ⚠ **ВЕС ПОДНЯТ info → minor закрывающим ревью, довод — симметрия с `PD-375`:** форма идентична (единственная точка принуждения объявленной границы не исполняется ни одним тестом, мутация переживает ПОЛНУЮ батарею, транзитивной страховки нет — `Touch` зажимает по значению ИЗ ТОЙ ЖЕ испорченной строки), а вес расходился только по валюте: там деньги и minor, здесь механизм ASVS 7.3.2 уровня 2 при объявленной зоной базовой линии L2 и info. Асимметрия была отпечатком того самого храповика «вреда сегодня нет», который это ревью и нашло ⚠ **Якорь пере-нацелен паком `sqlc` (29.08):** прежний токен — `s.pool.Exec` в `CreateSession` — исчез, потому что SQL этого запроса уехал в `internal/pgstore/queries/sessions.sql` и исполняется генерённым кодом. Новая цель — строка, где `maxAge` СТАНОВИТСЯ значением (`AbsoluteExpiresAt: now.Add(maxAge)`): именно она несёт факт, о котором строка, и она переживёт следующую генерацию. Сам дефект не тронут — потолок по-прежнему не запинен. ⚠ **ЗАКРЫТО паком `sqlc` (`63fcee5`, D39.172), пере-проверено ПОСАДКОЙ, а не рассуждением.** Пин — `pgstore.TestTheTwoSessionDeadlinesAreNotInterchangeable` (`sessions_test.go`): он создаёт сессию с РАЗНЕСЁННЫМИ сроками (`idleTTL` 1 ч против `maxAge` 24 ч — фикстура, где они совпадают, здесь ничего не доказывает, потому что `Touch` зажимает idle к absolute) и утверждает `AbsoluteExpiresAt == now.Add(maxAge)` после `CreateSession`, то есть ровно в единственном месте, где потолок становится фактом в базе. Именно та мутация, которой строка заведена — M12, `now.Add(maxAge)` → `now.Add(100*maxAge)` — теперь КРАСНАЯ адресно (замер 29.08: `AbsoluteExpiresAt = 2026-12-07…, want 2026-08-30…`). Пак строку не искал: тест писался против перестановки двух сроков, и потолок оказался запинен тем же утверждением — поэтому закрытие подтверждено пере-прогоном ИМЕННО M12, а не сходством формулировок | fixed | ревью-пак P8-REVIEW, ось 2 (посадка мутации, пере-посажена рефутером на полном пакете) | | PD-44 | hardening | info | `internal/pgstore/` | `sqlc` не взят, хотя направление §3 предписывает взять его ДО появления денежных таблиц. Весь денежный SQL — сырые строки pgx ⚠ **P8-FIX: половина, которая ЛЕЧИТ класс, построена; сам инструмент — вопрос владельцу.** Рантайм-ошибки «нет такой колонки» (`r.stop_for_signing`, `chapters_before`) случились в СКЛЕЕННОМ SQL read-модели, куда sqlc по построению не доходит, поэтому тем же пунктом заведён постоянный гейт, который доходит: `pgstore.TestEverySQLStatementParsesAgainstTheMigratedSchema` сворачивает КАЖДЫЙ SQL пакета из исходника (литералы, конкатенации, именованные константы) и планирует его Postgres'ом (`explain (generic_plan)`) против мигрированной схемы — **162 оператора, все планируются**. Посадка мутации в СКЛЕЕННЫЙ фрагмент (`c.units_edit_done` → несуществующая колонка) гейтом ловится; несворачиваемый SQL — ОШИБКА гейта, а не пропуск (единственное исключение — `store.go` `Ready`, где имя таблицы принадлежит goose, и оно выписано таблицей в самом гейте). Тем же гейтом закрыт открытый вопрос фикс-листа «есть ли в read-модели запрос, которого не касается ни один тест»: теперь его касаются все, на каждом прогоне батареи. **ГРАНИЦА sqlc:** read-модель для него недостижима по построению — склеек в пакете **25 мест из 147**; конвертируем только блок из пяти файлов без склейки (`credits`·`identity`·`idempotency`·`sessions`·`observe`). ⚠ **РЕШЕНО владельцем 22.08 (D39.154): sqlc берётся ОТДЕЛЬНОЙ СЕССИЕЙ, не внутри пака** — генерённый код в дереве, пин версии инструмента, гейт актуальности, `WithTx` для денежных запросов; мешать это с содержательной работой нельзя. ⚠ **ЗАКРЫТО: инструмент взят и заленджен** — `63fcee5`, ратификация `D39.172`, отдельным паком, как решил владелец 22.08 (`D39.154`). Конвертировано 40 запросов из этого самого блока; носители — `platform/sqlc.yaml`, `internal/pgstore/queries/*.sql`, генерённые `*.sql.go` в том же пакете. Актуальность генерации гейчена дважды: `sqlc diff` пререквизитом `make check` и `pgstore.TestEveryGeneratedQueryMatchesItsSourceFile` в батарее (работает без установленного sqlc). ⚠ **Замер набора на 29.08** (прежний счёт «41 запрос в 5 файлах» снят — он был сделан до пака P11, добавившего `StillLive`): в наборе **42 места вызова / 41 различный текст SQL** (константа `read` в `idempotency.go` исполнялась из двух мест), из них **конвертируемых 40**. Сороковой не `observe.go`: `Observe` спрашивает `river_job` через `to_regclass`, а эту таблицу мигрирует River сам, вне goose-миграций, поэтому sqlc отвергает запрос — и добавить схему River в конфиг значило бы завести ВТОРОЙ носитель чужой схемы. Гейт `sqlgate` после конверсии видит **172** оператора против пола 140 | fixed | ревью «вне карты»; гейт и граница — P8-FIX | ## Закрытые — эра P13 (03.09: бюджет из холда · ключ на пути ошибки · владение потоком · гейты хоста и класса) | ID | Класс | Серьёзность | Где | Суть | Статус | Источник | |---|---|---|---|---|---|---| | 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()` считает её терминальной — проекция здорового платящего прогона уходит в карантин НАВСЕГДА (снятия карантина в дереве нет) ⚠ **«Снятия карантина в дереве нет» — верно на дату ряда и НЕВЕРНО с пака 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 (базовая линия батареи при полных условиях, до правок) | ## Закрытые — эра «закрыть цикл» (04.09: живой платный прогон через API · дверь выдачи · терминальная ручка для осиротевшей попытки) | 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:330`=`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; починено зоной |