diff --git a/platform/docs/DEFECT_REGISTER.md b/platform/docs/DEFECT_REGISTER.md index 75dfc92c..845ad63c 100644 --- a/platform/docs/DEFECT_REGISTER.md +++ b/platform/docs/DEFECT_REGISTER.md @@ -26,39 +26,39 @@ | 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-волну), то есть **посылка формулы стала истинной, а сама формула — нужной**: непочерновленный остаток заказа это реальная работа прогона. Удалить её значило бы сломать полосу по-новому. ⇒ снята ПРИЧИНА, носитель оставлен. ⛔ **Испр. 06.09: здесь стояло «полоса при этом осталась в ГЛАВАХ, перевод её в юниты в этот минор не входит» — НЕВЕРНО, и это был третий носитель одной моей ошибки (отчёт зоны, состав минора, этот ряд).** `runDone`/`runTotal` первой ветвью считают в ЮНИТАХ, когда `runs.ordered_units` не null (`internal/pgstore/readmodel.go`, греп `ordered_units is not null`), и это пиньнуто `runs.TestACharacterOrdersBarIsCountedInWhatItActuallyBought`. `draftWork` при этом остаётся носителем ГЛАВНОЙ ветви — той, что считает заказ в главах, — и довод выше её и касается. ⇒ настоящее следствие: канон 0.11.0 описывает `Progress` как «in chapters» для ВСЕХ трёх форм прогона, а сборка для символьного заказа шлёт юниты; недостающий случай внесён в состав минора и отдан оркестратору 06.09. | fixed(628cc56) | воркфлоу-ревью 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. ⚠ **ДИСПОЗИЦИЯ ПАКА «ДЕНЬГИ И ПРАВДА НА ЭКРАНЕ» (06–07.09, ЗАЛАНДЁН `fda0679`, акт `D39.221`):** остаток закрыт транзакционным арбитром, и окон оказалось ДВА, а не одно. Второе в ряду не названо: `AbandonRun` под книжной блокировкой читает ЛЮБУЮ живую попытку, поэтому разблокировавшаяся расплата плюс `restart` подставляют под доказательство о попытке N попытку N+1 с живым процессом. Доказательство приходит парой (`AbandonOrder.ProofAttemptID` + `ProofSpawns`) и сверяется в той же транзакции; расхождение — `ErrProofOvertaken`. `unit_name` в свидетели не годится (`ReleaseSpawnClaim` возвращает его в NULL), отсюда монотонная колонка `run_attempts.spawns`, инкремент внутри самого CAS `RecordSpawn` (миграция `00034`).| fixed | приёмка оркестратора №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`. | fixed(628cc56) | платный сквозной прогон через 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;"` ⚠ **ДИСПОЗИЦИЯ ПАКА «ДЕНЬГИ И ПРАВДА НА ЭКРАНЕ» (06–07.09, ЗАЛАНДЁН `fda0679`, акт `D39.221`; ⚠ СТАТУС РЯДА ЛЕНДИНГ НЕ ФЛИПАЕТ — ряд двухзонный, и открытым его держит движковая половина, ушедшая строкой единого бэклога **331**):** платформенная половина закрыта — каждая строка `settlement` несёт БАЗИС (`pgstore.SettlementBasis`, `complete`/`halted` по тому, кончил ли движок попытку сам), операторская таблица печатает `≥` и легенду. ⛔ Формулировка «показать маржу ПОЛЬЗОВАТЕЛЮ» СНЯТА решением оркестратора №23: направление ущерба — по деплою, баланс пользователя завышен в его же пользу, показывать ему нечего. Движковая половина названа поимённо и ушла строкой бэклога. ⚠ **ЗАКРЫТО 17.09 ПРИЁМКОЙ ПАКА «ПРОГОН НЕ ВРЁТ О СЕБЕ»; статус проставлен зоной по норме «закрытие — коммит плюс пинящий тест». Ряд ПРОТУХ, а не был забыт, и механизм протухания стоит назвать: его текст последний раз менял `fda0679` (07.09) — лендинг ПЛАТФОРМЕННОЙ половины, — то есть строка писана ДО того, как приехала движковая (`3f05fab`, 10.09), и описывает мир, которого в дереве больше нет. Вдобавок она ссылается на строку единого бэклога **331**, которой в таблице нет с того же дня.** **Что в дереве вместо дефекта** (адреса пере-сняты зоной, не приняты со слов): доставленный обрыв перехвачен ДО нулевой ветки — `backend/internal/pipeline/stagerun.go:746`=`if errors.As(err, &cut) && cut.Delivered {`, — и книжится ОЦЕНКОЙ РЕЗЕРВАЦИИ с чекпойнтом (`backend/internal/pipeline/cutcall.go:101`=`func (r *Runner) settleCutCall`, ветка `:112`=`if cut.Billable {`); вторая половина, публикация оценочных строк, — `backend/internal/pipeline/paidtail.go:210` (его собственный комментарий говорит «It is the engine's half of PD-441») и `backend/internal/pipeline/status.go:942`=`rep.EstimatedRows, rep.EstimatedUSD = estimatedSpend(usage, committed)`. **Пины, поимённо:** `backend/internal/pipeline/cutcall_test.go:596`=`func TestSettleUSDForCutCallIsTheReservationEstimate` · `cutcall_test.go:1074`=`func TestMoneyWithNoCheckpointLeftIsStillAnEstimate` · `burnedpregates_test.go:1142`=`func TestTheCutRowPublishesItsEstimateAndItsGap` · `backend/cmd/tmctl/render_test.go:602` (поверхность говорит «≥ X» и НИКОГДА «≥ X, до Y», ряд назван там по номеру). **Ратифицировано `D39.234` п.4**, который и называет 331 движковой половиной этого ряда со словами «её условие закрытия … исполнено». ⚠ **Двусторонние ссылки на живые остатки темы, которые НЕ являются предметом этого ряда и имеют своих носителей:** строка единого бэклога **376** — различитель сужен до «провайдер ответил 2xx» (`backend/internal/llm/attemptcut.go:221`=`Billable: answered && tr.afterHeaders()`), направление сужения — НЕДОсчёт, ратифицировано; строка **382** — пометка «это ОЦЕНКА» по шву не едет, и следствие в деньгах именно платформенное: зона умеет сказать «≥ X» и никогда «≥ X, до Y». Замер зоны 17.09: `estimated_rows` \| `estimated_usd` \| `EstimatedRows` \| `EstimatedUSD` в `platform/` — **0 хитов при 221 прочитанном `.go`**, контроль на населённость прибора — те же имена в `backend/` дают **10**. ⚠ Приёмка тем же вопросом получила 9: шаблоны разные (зона брала и `snake_case`, и `CamelCase`), и это не спор о числе, а два прибора. | fixed(3f05fab) | платный сквозной прогон через API, пак «закрыть цикл» 04.09 (усилено разбором оркестратора №22 по коду движка) | +| 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;"` ⚠ **ДИСПОЗИЦИЯ ПАКА «ДЕНЬГИ И ПРАВДА НА ЭКРАНЕ» (06–07.09, ЗАЛАНДЁН `fda0679`, акт `D39.221`; ⚠ СТАТУС РЯДА ЛЕНДИНГ НЕ ФЛИПАЕТ — ряд двухзонный, и открытым его держит движковая половина, ушедшая строкой единого бэклога **331**):** платформенная половина закрыта — каждая строка `settlement` несёт БАЗИС (`pgstore.SettlementBasis`, `complete`/`halted` по тому, кончил ли движок попытку сам), операторская таблица печатает `≥` и легенду. ⛔ Формулировка «показать маржу ПОЛЬЗОВАТЕЛЮ» СНЯТА решением оркестратора №23: направление ущерба — по деплою, баланс пользователя завышен в его же пользу, показывать ему нечего. Движковая половина названа поимённо и ушла строкой бэклога. ⚠ **ЗАКРЫТО 17.09 ПРИЁМКОЙ ПАКА «ПРОГОН НЕ ВРЁТ О СЕБЕ»; статус проставлен зоной по норме «закрытие — коммит плюс пинящий тест». Ряд ПРОТУХ, а не был забыт, и механизм протухания стоит назвать: его текст последний раз менял `fda0679` (07.09) — лендинг ПЛАТФОРМЕННОЙ половины, — то есть строка писана ДО того, как приехала движковая (`3f05fab`, 10.09), и описывает мир, которого в дереве больше нет. Вдобавок она ссылается на строку единого бэклога **331**, которой в таблице нет с того же дня.** **Что в дереве вместо дефекта** (адреса пере-сняты зоной, не приняты со слов): доставленный обрыв перехвачен ДО нулевой ветки — `backend/internal/pipeline/stagerun.go:746`=`if errors.As(err, &cut) && cut.Delivered {`, — и книжится ОЦЕНКОЙ РЕЗЕРВАЦИИ с чекпойнтом (`backend/internal/pipeline/cutcall.go:101`=`func (r *Runner) settleCutCall`, ветка `:112`=`if cut.Billable {`); вторая половина, публикация оценочных строк, — `backend/internal/pipeline/paidtail.go:210` (его собственный комментарий говорит «It is the engine's half of PD-441») и `backend/internal/pipeline/status.go:942`=`rep.EstimatedRows, rep.EstimatedUSD = estimatedSpend(usage, committed)`. **Пины, поимённо:** `backend/internal/pipeline/cutcall_test.go:596`=`func TestSettleUSDForCutCallIsTheReservationEstimate` · `backend/internal/pipeline/cutcall_test.go:1074`=`func TestMoneyWithNoCheckpointLeftIsStillAnEstimate` · `backend/internal/pipeline/burnedpregates_test.go:1142`=`func TestTheCutRowPublishesItsEstimateAndItsGap` · `backend/cmd/tmctl/render_test.go:602` (поверхность говорит «≥ X» и НИКОГДА «≥ X, до Y», ряд назван там по номеру). **Ратифицировано `D39.234` п.4**, который и называет 331 движковой половиной этого ряда со словами «её условие закрытия … исполнено». ⚠ **Двусторонние ссылки на живые остатки темы, которые НЕ являются предметом этого ряда и имеют своих носителей:** строка единого бэклога **376** — различитель сужен до «провайдер ответил 2xx» (`backend/internal/llm/attemptcut.go:221`=`Billable: answered && tr.afterHeaders()`), направление сужения — НЕДОсчёт, ратифицировано; строка **382** — пометка «это ОЦЕНКА» по шву не едет, и следствие в деньгах именно платформенное: зона умеет сказать «≥ X» и никогда «≥ X, до Y». Замер зоны 17.09: `estimated_rows` \| `estimated_usd` \| `EstimatedRows` \| `EstimatedUSD` в `platform/` — **0 хитов при 221 прочитанном `.go`**, контроль на населённость прибора — те же имена в `backend/` дают **10**. ⚠ Приёмка тем же вопросом получила 9: шаблоны разные (зона брала и `snake_case`, и `CamelCase`), и это не спор о числе, а два прибора. | fixed(3f05fab) | платный сквозной прогон через 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 с троттлингом и вход в канал починки — оператор, который смотрит в ЖУРНАЛ, парковку видит; невидима она тому, кто смотрит только в таблицу. Строка остаётся открытой ровно на этом. ⚠ **ДИСПОЗИЦИЯ ПАКА «ДЕНЬГИ И ПРАВДА НА ЭКРАНЕ» (06–07.09, ЗАЛАНДЁН `fda0679`, акт `D39.221`):** остаток видимости закрыт колонкой `run_attempts.parked_at` (миграция `00034`), гейджем `tm_platform_parked_attempts` и своей ячейкой PARKED в `tmplatformctl runs`. Довод прошлого пака против колонки на проверке не устоял: парковка не состояние жизненного цикла, а проекция, пере-выводимая из (курсор, журнал) каждым проходом, поэтому снимает её тот же проход — в отличие от карантина, который снимает человек. Метка ставится ОДИН раз, чтобы «припаркован с какого момента» имело ответ.| fixed | самопроверка пака P13 (веер, линза шва; воспроизведено сессией на копии) | ## Открытые — minor | ID | Класс | Серьёзность | Где | Суть | Статус | Источник | |---|---|---|---|---|---|---| -| PD-466 | bug | info | `internal/runs/runs.go:718`=`func (s *Service) sourceThere` (дверь) против `internal/runs/runs.go:315`=`Refusal: startable(facts)` (форма) | **ФОРМА ЗАКАЗА ГОВОРИТ `covers_all` НАД КНИГОЙ, ЧЕЙ КАТАЛОГ ПРОПАЛ.** Правило допуска разрезано на две половины: факты КНИГИ из БД спрашивают обе площадки (`startable`), а ДИСК спрашивает только дверь. Половина из PD-455 закрыта, эта — нет: человек видит зелёный вердикт, жмёт и получает 409 `book_not_ready`/`source_gone`. ⚠ Денег не теряет и работы не портит — отказ приходит ДО холда (`PD-162`), — ломается то же обещание формы «вердикт ДО клика». **Почему не сделано в том же паке, а заведено:** опрашиваемый путь — не место для `os.Stat`, зависшее монтирование повесило бы каждый опрос каждого клиента; дешёвое лечение, если оно понадобится, — не стат на опросе, а ФАКТ в БД (интейк уже знает, что каталог создан, а пропажу видит свип), и тогда форма спрашивает его тем же предикатом. Пока не понадобилось: удаление каталога под живой книгой — авария оператора, а не ординарный путь ⚠ **ДИСПОЗИЦИЯ ПАКА «ОТВЕТ ДВЕРИ» 11.09: НЕ СТРОИТЬ СЕЙЧАС, и довод сильнее прежнего.** Названное дешёвым лечение — «не стат, а ФАКТ в БД» — дешёвым не является: писателя у этого факта в дереве НЕТ. Пропажу каталога сегодня видит только проход бэкапа (`internal/backup/backup.go`, ветка `fs.ErrNotExist`) и разбор интейка — для книги, уже прошедшей приём, никто её в базу не пишет. ⇒ завести факт значит завести проход, который статит каталог КАЖДОЙ книги, а такт реконсилятора ПОСЛЕДОВАТЕЛЕН, и его собственный файл уже замерил цену: проход, который блокируется, задерживает расчёт денег, ретраи интейка, GC экспортов и все гейджи (`cmd/tmplatformd/runner.go`, довод отдельной горутины для бэкапа). То есть цена зависшего монтирования, которую ряд увёл с опрашиваемого пути, вернулась бы на денежный. ⚠ И второе: КЭШИРОВАННОЕ «каталога нет» — это класс `PD-192` с более длинным фитилём. Предикат двери верен именно тем, что спрашивает в момент решения и различает вину книги и вину хоста сентинелом; флаг в базе может соврать в обе стороны между записью и чтением, а соврав в сторону «книга мертва», повторяет ошибку, за которую зона уже заплатила дважды. ⇒ Ряд остаётся `open` как ЧЕСТНЫЙ остаток: обещание формы «вердикт ДО клика» над пропавшим каталогом ложно, денег это не двигает, дверь остаётся авторитетом, а `Refusal` формы совещателен по построению и это записано в его собственном комментарии. | open | самопроверка пака «правда у двери», 11.09 | +| PD-466 | bug | info | `internal/runs/runs.go:741`=`func (s *Service) sourceThere` (дверь) против `internal/runs/runs.go:338`=`Refusal: startable(facts)` (форма) | **ФОРМА ЗАКАЗА ГОВОРИТ `covers_all` НАД КНИГОЙ, ЧЕЙ КАТАЛОГ ПРОПАЛ.** Правило допуска разрезано на две половины: факты КНИГИ из БД спрашивают обе площадки (`startable`), а ДИСК спрашивает только дверь. Половина из PD-455 закрыта, эта — нет: человек видит зелёный вердикт, жмёт и получает 409 `book_not_ready`/`source_gone`. ⚠ Денег не теряет и работы не портит — отказ приходит ДО холда (`PD-162`), — ломается то же обещание формы «вердикт ДО клика». **Почему не сделано в том же паке, а заведено:** опрашиваемый путь — не место для `os.Stat`, зависшее монтирование повесило бы каждый опрос каждого клиента; дешёвое лечение, если оно понадобится, — не стат на опросе, а ФАКТ в БД (интейк уже знает, что каталог создан, а пропажу видит свип), и тогда форма спрашивает его тем же предикатом. Пока не понадобилось: удаление каталога под живой книгой — авария оператора, а не ординарный путь ⚠ **ДИСПОЗИЦИЯ ПАКА «ОТВЕТ ДВЕРИ» 11.09: НЕ СТРОИТЬ СЕЙЧАС, и довод сильнее прежнего.** Названное дешёвым лечение — «не стат, а ФАКТ в БД» — дешёвым не является: писателя у этого факта в дереве НЕТ. Пропажу каталога сегодня видит только проход бэкапа (`internal/backup/backup.go`, ветка `fs.ErrNotExist`) и разбор интейка — для книги, уже прошедшей приём, никто её в базу не пишет. ⇒ завести факт значит завести проход, который статит каталог КАЖДОЙ книги, а такт реконсилятора ПОСЛЕДОВАТЕЛЕН, и его собственный файл уже замерил цену: проход, который блокируется, задерживает расчёт денег, ретраи интейка, GC экспортов и все гейджи (`cmd/tmplatformd/runner.go`, довод отдельной горутины для бэкапа). То есть цена зависшего монтирования, которую ряд увёл с опрашиваемого пути, вернулась бы на денежный. ⚠ И второе: КЭШИРОВАННОЕ «каталога нет» — это класс `PD-192` с более длинным фитилём. Предикат двери верен именно тем, что спрашивает в момент решения и различает вину книги и вину хоста сентинелом; флаг в базе может соврать в обе стороны между записью и чтением, а соврав в сторону «книга мертва», повторяет ошибку, за которую зона уже заплатила дважды. ⇒ Ряд остаётся `open` как ЧЕСТНЫЙ остаток: обещание формы «вердикт ДО клика» над пропавшим каталогом ложно, денег это не двигает, дверь остаётся авторитетом, а `Refusal` формы совещателен по построению и это записано в его собственном комментарии. | open | самопроверка пака «правда у двери», 11.09 | | 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:68`=`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/reconcile.go:1737`=`if l.Status == "translating" {` (ответ) · `internal/runs/reconcile.go:1885`=`func (s *Service) continuing` (один ответ на оба пути) · `internal/runs/resumeorder_test.go:323`=`func TestTwoResumesOfOneRunTakeOneHoldAndBothAnswer` и `internal/runs/resumeorder_test.go:97`=`func TestAResumeOfARunAlreadyGoingAnswersWithThatRun` (оба порядка) | **ОТВЕТ РЕЗЮМА ЗАВИСЕЛ ОТ ПОРЯДКА: резюм ЖИВОГО прогона получал `409` вместо прогона.** `Resume` читал прогон ДО замка книги, а судил по прочитанному ПОСЛЕ него, и у одного и того же двойного клика было два ответа: прочитавший устаревший `stopped` доходил до пере-открытия, проигрывал уникальному индексу попытки и получал ПРОГОН; прочитавший уже переведённый `translating` падал в `default:` и получал отказ. ⛔ **ПРЕДМЕТ БЫЛ ОПИСАН ЗАНИЖЕННО, и это исправлено замером 11.09, а не мнением.** Прежняя редакция называла предметом гонку в миллисекунды, видимую при `load average 42–48`; счётчики путей в копии дерева показали обратное: конкурентная фикстура старого пина **5 раз из 5** берёт ветку устаревшего снимка и до дефекта НЕ ДОХОДИТ (`winner=1 staleLoser=1 freshRefusal=0`), а два `Resume` подряд на простаивающей машине дают отказ **5 из 5** (`first: translating`, `second: runs: the run cannot be continued: it is translating`). ⇒ отказ получал ЛЮБОЙ резюм живого прогона, человеку довольно секунды между кликами, а гонка — узкий частный случай. Старый пин был зелёным 20 из 20 именно потому, что его фикстура измеряла половину сценария. **Лечение (`D39.246` п.6, ратифицировано направлением до стройки).** Ветка `case "translating"` отвечает ПРОГОНОМ — тем же оператором `continuing`, что и проигравший уникальному индексу, так что два пути не могут разойтись правкой одного. Вырез: прогон с УЖЕ запрошенным стопом остаётся `409` с причиной `stop_requested` (`D39.240` — остановка жёсткая, и «продолжается» там ложь). Ветка стоит ВЫШЕ проверки re-pass, а сама проверка уехала под свитч, к прочим стражам ПЕРЕ-ОТКРЫТИЯ: живой re-pass продолжается, а «покупается заново» — ответ только законченному. Канон правится тем же лендингом, минор **0.15.0**: меняется строка таблицы §`resumeRun` и фраза «a `202` therefore means work was actually reopened», которая была неверна и до этого пака. ⚠ **ОСТАТОК ОКНА, названный, а не закрытый:** `stop_requested` читается в снимке, взятом до замка книги, а `Stop` замка книги не берёт вовсе, поэтому стоп между снимком и ответом даёт один `202` на сворачивающийся прогон — один кадр, денег не двигает, и тело ответа несёт `stop_requested` из СВЕЖЕГО чтения строки. Закрыть его значило бы сериализовать `Stop` за замком книги, то есть поставить ожидание на путь, чей смысл в том, что он не ждёт. ⚠ **Где на самом деле живёт «ровно один холд» — замерено мутацией, а не объявлено:** снятие ответной ветки НЕ приводит ко второму холду (его держит страж открытой резервации `reopen`), и счёт холдов краснеет только при ДВУХСАЙТОВОЙ посадке — ветка снята И страж снят (`2 open holds of 6.639656 … want exactly one of 3.319828`). То есть гарантию держит база, а пин над ней — регрессионный сторож. | fixed(пак «ответ двери», дерево сессии) | замер зоны 05.09 при ревизии документации: батарея краснела дважды, разобрано до конкретного теста | +| PD-448 | bug | minor | `internal/runs/reconcile.go:1839`=`if l.Status == "translating" {` (ответ) · `internal/runs/reconcile.go:1987`=`func (s *Service) continuing` (один ответ на оба пути) · `internal/runs/resumeorder_test.go:323`=`func TestTwoResumesOfOneRunTakeOneHoldAndBothAnswer` и `internal/runs/resumeorder_test.go:97`=`func TestAResumeOfARunAlreadyGoingAnswersWithThatRun` (оба порядка) | **ОТВЕТ РЕЗЮМА ЗАВИСЕЛ ОТ ПОРЯДКА: резюм ЖИВОГО прогона получал `409` вместо прогона.** `Resume` читал прогон ДО замка книги, а судил по прочитанному ПОСЛЕ него, и у одного и того же двойного клика было два ответа: прочитавший устаревший `stopped` доходил до пере-открытия, проигрывал уникальному индексу попытки и получал ПРОГОН; прочитавший уже переведённый `translating` падал в `default:` и получал отказ. ⛔ **ПРЕДМЕТ БЫЛ ОПИСАН ЗАНИЖЕННО, и это исправлено замером 11.09, а не мнением.** Прежняя редакция называла предметом гонку в миллисекунды, видимую при `load average 42–48`; счётчики путей в копии дерева показали обратное: конкурентная фикстура старого пина **5 раз из 5** берёт ветку устаревшего снимка и до дефекта НЕ ДОХОДИТ (`winner=1 staleLoser=1 freshRefusal=0`), а два `Resume` подряд на простаивающей машине дают отказ **5 из 5** (`first: translating`, `second: runs: the run cannot be continued: it is translating`). ⇒ отказ получал ЛЮБОЙ резюм живого прогона, человеку довольно секунды между кликами, а гонка — узкий частный случай. Старый пин был зелёным 20 из 20 именно потому, что его фикстура измеряла половину сценария. **Лечение (`D39.246` п.6, ратифицировано направлением до стройки).** Ветка `case "translating"` отвечает ПРОГОНОМ — тем же оператором `continuing`, что и проигравший уникальному индексу, так что два пути не могут разойтись правкой одного. Вырез: прогон с УЖЕ запрошенным стопом остаётся `409` с причиной `stop_requested` (`D39.240` — остановка жёсткая, и «продолжается» там ложь). Ветка стоит ВЫШЕ проверки re-pass, а сама проверка уехала под свитч, к прочим стражам ПЕРЕ-ОТКРЫТИЯ: живой re-pass продолжается, а «покупается заново» — ответ только законченному. Канон правится тем же лендингом, минор **0.15.0**: меняется строка таблицы §`resumeRun` и фраза «a `202` therefore means work was actually reopened», которая была неверна и до этого пака. ⚠ **ОСТАТОК ОКНА, названный, а не закрытый:** `stop_requested` читается в снимке, взятом до замка книги, а `Stop` замка книги не берёт вовсе, поэтому стоп между снимком и ответом даёт один `202` на сворачивающийся прогон — один кадр, денег не двигает, и тело ответа несёт `stop_requested` из СВЕЖЕГО чтения строки. Закрыть его значило бы сериализовать `Stop` за замком книги, то есть поставить ожидание на путь, чей смысл в том, что он не ждёт. ⚠ **Где на самом деле живёт «ровно один холд» — замерено мутацией, а не объявлено:** снятие ответной ветки НЕ приводит ко второму холду (его держит страж открытой резервации `reopen`), и счёт холдов краснеет только при ДВУХСАЙТОВОЙ посадке — ветка снята И страж снят (`2 open holds of 6.639656 … want exactly one of 3.319828`). То есть гарантию держит база, а пин над ней — регрессионный сторож. | fixed(пак «ответ двери», дерево сессии) | замер зоны 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:199`=`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:1252`=`PausedCreditExhausted = ingest.PausedCreditExhausted` и `internal/httpapi/v0.go:1062`=`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`. | fixed(628cc56) | живой платный прогон пака «закрыть цикл», наблюдение H14, 04.09 | +| PD-446 | doc | minor | канон `docs/architecture/14-api-contract/` §`PausedReason`; носители в зоне — `internal/pgstore/books.go:1330`=`PausedCreditExhausted = ingest.PausedCreditExhausted` и `internal/httpapi/v0.go:1083`=`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`. | fixed(628cc56) | живой платный прогон пака «закрыть цикл», наблюдение 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` держит обе половины (неподъёмный заказ отбит ДО холда и деньги не двинулись; заказ больше книги = вся книга, а не бОльшая резервация). Проверено посадкой: снятие отказа краснит этот тест ИМЕНЕМ. | fixed(628cc56) | ревью-пак 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:489`=`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-386 | bug | minor | `cmd/tmplatformd/runner.go:490`=`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:608`=`сколько всего может занять один проход свипа`, `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:586`=`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:583`=`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-389 | hardening | minor | `cmd/tmplatformd/runner.go:587`=`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:584`=`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:105`=`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:453`=`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:721`=`select finished_at from runs where id = $1` ⚠ якорь пере-нацелен паком P11: прежняя строка отвечала `ErrNoRun` законченному прогону — она и была дефектом; теперь это ветвление, читаемое ПОД блокировкой книги ⚠ **СУЖЕНО 11.09 паком «ПРАВДА У ДВЕРИ»: половина «каталога нет» закрыта у ОБЕИХ денежных дверей, половина ЖИВОГО прогона — нет.** Каталог книги спрашивается ДО холда: `internal/runs/runs.go:718`=`func (s *Service) sourceThere` зовётся из `Start` (после предиката книги, перед чтением счёта) и из `Resume` (`internal/runs/reconcile.go:1815`=`sourceThere(l.Workdir); err != nil`, перед `reopen` на `internal/runs/reconcile.go:1818`=`s.reopen(ctx, l, fromAFinishedRun)` — измерено, что без него резюм над пропавшим каталогом БРАЛ холд остатка бюджета и возвращал прогон). Две беды разведены сентинелом `PD-192` и отвечают разными словами: пропал каталог КНИГИ — `runs.ErrSourceGone` (409 `book_not_ready` + причина `source_gone`), пропал КОРЕНЬ хранилища — `runs.ErrStorageUnavailable` (503, вина деплоя, и книгу она не винит); предикат корня не скопирован, а вызван — `internal/books/books.go:606`=`func StorageIsThere`. Пины: `runs.TestABookWhoseDirectoryIsGoneIsRefusedBeforeTheHold`, `runs.TestAVanishedBooksVolumeIsTheDeploymentsFaultAndNotTheBooks`, `runs.TestAResumeOverAMissingDirectoryIsRefusedBeforeTheHold` (у каждого — контрольный прогон с вернувшимся каталогом, который ДОЛЖЕН взять холд: иначе «деньги не двинулись» доказывала бы фикстура, в которой их и не было), `httpapi.TestAVanishedSourceAndAVanishedVolumeAnswerDifferently`. **ОТКРЫТЫМ остаётся ровно остаток, и он шире удаления каталога:** прогон, УЖЕ живущий, который не поедет никогда — запиненный к старой сборке движка, без `reserved_usd`, с беспризорным деплоем, — по-прежнему висит с холдом до прихода человека. Это половина эскроу (`П-18`), и автоматического терминального вердикта у реконсилятора нет и не планируется вне него. ⚠ **Дофикс того же дня по адверсариальному проходу — три уточнения, каждое нашёл проход, а не автор.** (1) Сентинел спрашивается ТОЛЬКО про книги, лежащие под корнем интейка (`books.Owns`): книга, положенная `tmplatformctl book add --workdir` куда угодно, живёт на томе, который платформа не писала и сентинела на нём не имеет — читать чужой маркер как улику про этот том значило бы объявлять пропавшее монтирование концом книги, то есть `PD-192` с более длинным путём; где спрашивать нечего, ответ — деплоя. Пин `runs.TestABookOutsideTheIntakesRootIsNotDeclaredDeadByAnotherVolumesMarker`. (2) Путь, который ЕСТЬ, но не каталог (файл, симлинк на файл), больше не проходит дверь: `os.Stat` на нём успешен, а движок открывает каталог — пин `runs.TestAFileWhereTheBooksDirectoryShouldBeIsRefusedToo`. (3) На РЕЗЮМЕ отказ говорит словарём своей операции — `409 run_not_resumable` + `cause: source_gone`, а не `book_not_ready`: спрашивают про ПРОГОН, и это та же форма, какой канон уже пользуется для двух осей, перекрывающих его таблицу статусов; пин `httpapi.TestAResumeOverAGoneSourceIsRefusedInTheResumeHandlesOwnWords`. ⚠ **ЗАМЕР 11.09, пак «ответ двери»: ряд ОПИСЫВАЕТ КЛИН ШИРЕ, ЧЕМ ОН ЕСТЬ, и одна его половина закрыта построением.** Прогон с пропавшим каталогом прогнан восемь проходов свипа: `status=translating failures=1…8 openHolds=1 held=0.444828`, с пятого прохода строка в списке оператора. **Но «ни один путь не приходит к терминальному состоянию» неверно:** собственный СТОП пользователя доводит прогон до конца (`status=stopped finished=true`) — терминальное состояние есть и оно в руках у того, чьи это деньги. Не возвращается только ХОЛД: расчёт заблокирован, потому что счётчик движка нечитаем. **И второй носитель, названный ре-чеком (отказ спавна без всякого удаления каталога), закрыт ПОЛНОСТЬЮ:** попытка, у которой нет ни юнита, ни базовой линии, после стопа отдаёт холд ЦЕЛИКОМ и автоматически — замер: `AFTER 6 PASSES: status=translating openHolds=1`, затем `AFTER STOP: status=stopped openHolds=0 reserved=0.000000 balance=20.000000`. Это делает существующий путь `ReleaseUnspawned`, и строить под него нечего. ⛔ **ДИСПОЗИЦИЯ ПАКА «ОТВЕТ ДВЕРИ»: автоматическое терминальное состояние после N неудач НЕ строится, и довод не в цене работы.** (1) Единственный оставшийся случай — попытка, которая ДОШЛА до движка и чей счётчик нечитаем, — требует ответить на вопрос «сколько списать, когда мерить нечем», а это и есть эскроу `П-18`: отдать холд целиком значит не взять денег за работу, уже оплаченную провайдеру, взять целиком — наказать пользователя за аварию оператора. Обе стороны цены названы в самом CLI (`run abandon`: «would return the WHOLE hold and charge nothing for what the run actually spent»). (2) Счётчик неудач такой вопрос не решает — в дереве это уже записано и довод стоит: «I could not ask» никогда не «the run is gone» (`internal/runs/reconcile.go`, комментарий `backoff`). (3) Снятие холда обязано ехать с КОРРЕКТНОЙ остановкой процесса, иначе вечный холд меняется на потерянные деньги плюс живой движок, тратящий против прогона, который никто не считает; ровно это и отказывается делать `run abandon` без доказательства systemd. ⚠ **Что остаётся продуктовым пробелом и НЕ закрыто:** человек видит «идёт» и не знает, что жать стоп. Кнопка есть, повода нажать — нет. | open | самопроверка дофикса (ревью вне карты) | +| PD-162 | bug | minor | `internal/runs/spawn.go` `journalSize`, `internal/runs/reconcile.go` | **Книга, чей каталог удалён или перемещён, принимает прогон и заклинивает его навсегда с открытым холдом.** `journalSize` мапит ENOENT в «ноль, ошибки нет» (законно для первого прогона), поэтому `Start` отдаёт 202 и берёт холд; дальше `bookMeter` вечно падает (спавн отказывает), либо расчёт вечно откладывается — ни один путь не приходит к терминальному состоянию: прогон вечно `translating`, деньги вечно в холде, пользователю видно только «идёт». Дизайн «холд лучше догадки» осознан (строка 136), но отсутствие И валидации каталога на старте, И эскалации после N неудач — дыра. Закрывать вместе с эскроу/`uncertain` либо проверкой каталога при допуске. ⚠ Ре-чек V2 расширил КЛАСС строки: обязательность `reserved_usd` даёт тот же клин без всякого удаления каталога — прогон, запиненный к СТАРОЙ сборке движка (строка 139), у которой поля ещё нет, вечно отказывает спавну с открытым холдом. Лечится тем же терминальным состоянием после N неудач; отказ сам по себе верен (без цифры потолок считать нечем) ⚠ Сужено паком P5 с одной стороны и НЕ закрыто с другой: у ИНТЕЙКА терминальное состояние после N неудач теперь есть (`books.parseAttempts` = 5 → `rejected` с причиной `parser_unavailable`), и прогон на книге, не прошедшей интейк, отвергается до денег (`runs.ErrBookNotReady`). Клин ЖИВОГО прогона на удалённом каталоге не тронут: там нужен тот же счётчик неудач на стороне реконсилятора либо эскроу-половина строки 136 ⚠ Дополнено кросс-семейным ревью: терминальность интейка теперь НЕ применяется к `not_configured` — книга без конфигурации ждёт в `parsing` вместо уничтожения, потому что это состояние ДЕПЛОЯ, а не книги. Цена названа: пока развилка `book.yaml` не ратифицирована, такие книги копятся, и видит их метрика `tm_platform_books_in_intake{status="parsing"}` ⚠ **ДИСПОЗИЦИЯ P7: не бралась.** Клин живого прогона на удалённом каталоге лечится терминальным состоянием после N неудач у реконсилятора, а это половина эскроу (строка 136) — строить её рядом с проектируемым целым значит ставить второй, более слабый ответ на тот же вопрос. P7 добавил к строке одно наблюдение: материализатор читающей поверхности зовёт движок в том же каталоге и на ту же ошибку отвечает логом, не трогая деньги ⚠ **ПАК P8-REVIEW 24.08, сужено рефутером:** для половины «спавн отказал» (попытка до движка не дошла) построены ДИАГНОЗ (`runs --stalled`, гейдж `tm_platform_runs_stalled`) и РУЧНОЙ вердикт оператора (`tmplatformctl run abandon --run --reason [--release-hold]`, коммит `31f1f82`, строка П-20 зонного бэклога). **Автоматического терминального состояния после N неудач НЕТ и оно не планируется вне эскроу** — это записанное решение (`internal/runs/reconcile.go:453`=`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:721`=`select finished_at from runs where id = $1` ⚠ якорь пере-нацелен паком P11: прежняя строка отвечала `ErrNoRun` законченному прогону — она и была дефектом; теперь это ветвление, читаемое ПОД блокировкой книги ⚠ **СУЖЕНО 11.09 паком «ПРАВДА У ДВЕРИ»: половина «каталога нет» закрыта у ОБЕИХ денежных дверей, половина ЖИВОГО прогона — нет.** Каталог книги спрашивается ДО холда: `internal/runs/runs.go:741`=`func (s *Service) sourceThere` зовётся из `Start` (после предиката книги, перед чтением счёта) и из `Resume` (`internal/runs/reconcile.go:1917`=`sourceThere(l.Workdir); err != nil`, перед `reopen` на `internal/runs/reconcile.go:1920`=`s.reopen(ctx, l, fromAFinishedRun)` — измерено, что без него резюм над пропавшим каталогом БРАЛ холд остатка бюджета и возвращал прогон). Две беды разведены сентинелом `PD-192` и отвечают разными словами: пропал каталог КНИГИ — `runs.ErrSourceGone` (409 `book_not_ready` + причина `source_gone`), пропал КОРЕНЬ хранилища — `runs.ErrStorageUnavailable` (503, вина деплоя, и книгу она не винит); предикат корня не скопирован, а вызван — `internal/books/books.go:606`=`func StorageIsThere`. Пины: `runs.TestABookWhoseDirectoryIsGoneIsRefusedBeforeTheHold`, `runs.TestAVanishedBooksVolumeIsTheDeploymentsFaultAndNotTheBooks`, `runs.TestAResumeOverAMissingDirectoryIsRefusedBeforeTheHold` (у каждого — контрольный прогон с вернувшимся каталогом, который ДОЛЖЕН взять холд: иначе «деньги не двинулись» доказывала бы фикстура, в которой их и не было), `httpapi.TestAVanishedSourceAndAVanishedVolumeAnswerDifferently`. **ОТКРЫТЫМ остаётся ровно остаток, и он шире удаления каталога:** прогон, УЖЕ живущий, который не поедет никогда — запиненный к старой сборке движка, без `reserved_usd`, с беспризорным деплоем, — по-прежнему висит с холдом до прихода человека. Это половина эскроу (`П-18`), и автоматического терминального вердикта у реконсилятора нет и не планируется вне него. ⚠ **Дофикс того же дня по адверсариальному проходу — три уточнения, каждое нашёл проход, а не автор.** (1) Сентинел спрашивается ТОЛЬКО про книги, лежащие под корнем интейка (`books.Owns`): книга, положенная `tmplatformctl book add --workdir` куда угодно, живёт на томе, который платформа не писала и сентинела на нём не имеет — читать чужой маркер как улику про этот том значило бы объявлять пропавшее монтирование концом книги, то есть `PD-192` с более длинным путём; где спрашивать нечего, ответ — деплоя. Пин `runs.TestABookOutsideTheIntakesRootIsNotDeclaredDeadByAnotherVolumesMarker`. (2) Путь, который ЕСТЬ, но не каталог (файл, симлинк на файл), больше не проходит дверь: `os.Stat` на нём успешен, а движок открывает каталог — пин `runs.TestAFileWhereTheBooksDirectoryShouldBeIsRefusedToo`. (3) На РЕЗЮМЕ отказ говорит словарём своей операции — `409 run_not_resumable` + `cause: source_gone`, а не `book_not_ready`: спрашивают про ПРОГОН, и это та же форма, какой канон уже пользуется для двух осей, перекрывающих его таблицу статусов; пин `httpapi.TestAResumeOverAGoneSourceIsRefusedInTheResumeHandlesOwnWords`. ⚠ **ЗАМЕР 11.09, пак «ответ двери»: ряд ОПИСЫВАЕТ КЛИН ШИРЕ, ЧЕМ ОН ЕСТЬ, и одна его половина закрыта построением.** Прогон с пропавшим каталогом прогнан восемь проходов свипа: `status=translating failures=1…8 openHolds=1 held=0.444828`, с пятого прохода строка в списке оператора. **Но «ни один путь не приходит к терминальному состоянию» неверно:** собственный СТОП пользователя доводит прогон до конца (`status=stopped finished=true`) — терминальное состояние есть и оно в руках у того, чьи это деньги. Не возвращается только ХОЛД: расчёт заблокирован, потому что счётчик движка нечитаем. **И второй носитель, названный ре-чеком (отказ спавна без всякого удаления каталога), закрыт ПОЛНОСТЬЮ:** попытка, у которой нет ни юнита, ни базовой линии, после стопа отдаёт холд ЦЕЛИКОМ и автоматически — замер: `AFTER 6 PASSES: status=translating openHolds=1`, затем `AFTER STOP: status=stopped openHolds=0 reserved=0.000000 balance=20.000000`. Это делает существующий путь `ReleaseUnspawned`, и строить под него нечего. ⛔ **ДИСПОЗИЦИЯ ПАКА «ОТВЕТ ДВЕРИ»: автоматическое терминальное состояние после N неудач НЕ строится, и довод не в цене работы.** (1) Единственный оставшийся случай — попытка, которая ДОШЛА до движка и чей счётчик нечитаем, — требует ответить на вопрос «сколько списать, когда мерить нечем», а это и есть эскроу `П-18`: отдать холд целиком значит не взять денег за работу, уже оплаченную провайдеру, взять целиком — наказать пользователя за аварию оператора. Обе стороны цены названы в самом CLI (`run abandon`: «would return the WHOLE hold and charge nothing for what the run actually spent»). (2) Счётчик неудач такой вопрос не решает — в дереве это уже записано и довод стоит: «I could not ask» никогда не «the run is gone» (`internal/runs/reconcile.go`, комментарий `backoff`). (3) Снятие холда обязано ехать с КОРРЕКТНОЙ остановкой процесса, иначе вечный холд меняется на потерянные деньги плюс живой движок, тратящий против прогона, который никто не считает; ровно это и отказывается делать `run abandon` без доказательства systemd. ⚠ **Что остаётся продуктовым пробелом и НЕ закрыто:** человек видит «идёт» и не знает, что жать стоп. Кнопка есть, повода нажать — нет. | 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) | @@ -73,7 +73,7 @@ | 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 | | PD-454 | bug | minor | `Makefile` цель `check`; гейт `internal/gates.TestTheBatteryCannotReportCleanlinessWithoutItsLog` | **БАТАРЕЯ УМЕЛА ВЫДАТЬ ЧИСТУЮ СПРАВКУ О ПРОГОНЕ, КОТОРОГО НЕ ИЗМЕРЯЛА.** Каждая строка, которую печатает `make check` о прогоне — список пакетов, ряды `ALARM`, список падений, счёт скипов, — есть ГРЕП по одному файлу. Греп отвечает на ОТСУТСТВУЮЩИЙ файл ровно тем же, чем на чистый: ничем. Поэтому рецепт доходил до финального `else` и печатал «every test ran: no host condition was missing» о прогоне, чей лог исчез. ⚠ **Наблюдено 06.09, а не выведено:** три подряд `grep: .check.log: No such file or directory`, следом эта самая строка — на прогоне, где скипов было ПЯТЬ. Код возврата при этом 0, потому что статус берётся от `go test` ДО грепов. Причина — ФИКСИРОВАННОЕ имя лога, общее для всех прогонов в каталоге, а два прогона в одном каталоге здесь НОРМА: батарею гоняют и зона, и оркестратор, и первый закончивший удаляет улику второго посреди рецепта. ⇒ лечение двумя половинами, и они не дублируют друг друга: имя лога стало ПОПРОГОННЫМ (`$$` — PID шелла), что снимает сегодняшнюю ПРИЧИНУ, и добавлен гвард «нет лога ⇒ выйти красным ДО первого чтения», что снимает КЛАСС — отказавшийся редирект, полный диск, рука. Проверено исполнением: подменённый `GO`, не пишущий лога, даёт красный выход и две строки объяснения вместо справки. ⚠ Гейт держит ФОРМУ, а не эту починку: лог может быть попрогонным или нет, гвард может быть `-s` или `-f`, но ни одно чтение не смеет идти раньше проверки, которая умеет выйти. **ПЯТЬ** мутаций ловятся — гвард удалён · гвард после первого чтения · тест есть, `exit` убран · **`exit 1` → `exit 0`** · **`exit` без кода**. ⚠ **Две последние добавлены ВТОРОЙ редакцией гейта, и нашёл их не я, а приёмка оркестратора:** первая редакция требовала лишь, чтобы после гварда был шаг, начинающийся с `exit`, и `exit 0` проходил зелёным — то есть батарея объявляла вслух, что улики нет, и возвращала УСПЕХ. Замерено им исполнением (`exit 0` + подменённый `GO`: `MAKE-EXIT = 0` при напечатанном «THE BATTERY LEFT NO LOG»), воспроизведено зоной. **Это тот же дефект, переодетый, и в одном отношении ХУЖЕ исходного:** прежняя ложная справка была СТРОКОЙ, которую человек ловит глазами, эта — КОД ВОЗВРАТА, который потребляет машина (CI, лендинг). ⚠ И сообщение гейта обещало «exits RED», проверяя лишь наличие выхода, — та же болезнь, что он лечит, в нём самом; формулировка подтянута под проверяемое. Теперь требуется ЛИТЕРАЛ ненулевого кода: голый `exit` несёт статус предыдущей команды (`echo`, то есть ноль), а `exit $$var` из рецепта не судится вовсе. ⚠ Первая редакция гейта СЧИТАЛА ЧТЕНИЕМ слово `grep` внутри объясняющего `echo` самого гварда — ровно тот substring-vs-invocation капкан, о котором соседний гейт пишет абзацем выше; поймано прогоном, не рассуждением. **Девятая форма ложной зелени (`D39.202`)** ⚠ **ЛЕЧЕНИЕ В ДЕРЕВЕ, статус флипает ЛЕНДИНГ** | fixed(201363b) | замер зоны 06.09, заказ оркестратора | -| PD-455 | bug | minor | `internal/httpapi/v0.go` `wireOrderOptions`; `internal/runs/runs.go` `Options` | **ФОРМА ЗАКАЗА ОТВЕЧАЕТ `covers_all` НАД КНИГОЙ, У КОТОРОЙ УЖЕ ИДЁТ ПРОГОН, и молчит о том, что клик получит отказ.** Вердикт считается из остатка и баланса и про ЖИВОЙ прогон не спрашивает; допуск же отвергает вторую покупку `ErrRunInFlight`. ⇒ человек видит «хватает на всю книгу», жмёт и получает отказ, причину которого форма знала до клика. ⚠ Денег не теряет и работы не портит — отказ случается ДО взятия холда, — ломается ровно обещание формы «вердикт ДО клика», ради которого пак и писался. ⚠ Найдено ВТОРЫМ КРУГОМ этого же пака и названо в отчёте поимённо (пункт ⑸ списка «не починено»), но носителя вне отчёта не имело до 06.09: отчёт — хроника, ряд — индекс, и находка без ряда не находится. Лечение: форма обязана нести признак живого прогона (или вердикт `blocked`), а не молчать ⚠ **ЗАКРЫТО 11.09 паком «ПРАВДА У ДВЕРИ».** Правило допуска по фактам КНИГИ (статус · дерево глав · живой прогон) вынесено в ОДИН предикат `internal/runs/runs.go:666`=`func startable`, и у него ДВЕ привязки разной авторитетности: `Start` зовёт его под замком книги (решает), `Order` — на опрашиваемом пути (`internal/runs/runs.go:315`=`Refusal: startable(facts)`, совещательно и заведомо устаревает). `PricedBook` НЕ расширен — форма делает ВТОРОЕ чтение `ReadBookForRun`, то самое, которым судит дверь, что и есть довод написанного рядом возражения. На провод отказ выходит как `blocked.code: run_in_flight` с `book_id` ЭТОЙ книги (`internal/httpapi/v0.go:619`), контракт 0.14.0, `D39.244`; `verdict` не тронут — он отвечает про ДЕНЬГИ, и четвёртого значения у закрытого enum нет. Пины: `runs.TestTheFormSaysWhatTheDoorWillRefuseOverABookThatIsAlreadyRunning` (форма и дверь отвечают ОДНО, и вторая книга аккаунта не блокируется), `httpapi.TestTheFormCarriesTheRunInFlightThatWillRefuseTheClick`, `httpapi.TestARunOfThisBooksOwnOutranksAnotherBooksHold` (при двух кандидатах `blocked` несёт свой прогон, а не чужой холд). ⚠ Что НЕ закрыто и вынесено отдельной строкой `PD-466`: форма по-прежнему молчит про ПРОПАВШИЙ каталог книги — дисковая половина допуска живёт только у двери. ⚠ Дофикс по адверсариальному проходу: отображение «ответ предиката → член провода» — ВТОРАЯ, рукописная вещь рядом с одним определением, и молчаливого хвоста у неё больше нет. Отказ, для которого у этой поверхности слова НЕТ, пишет ERROR-строку оператору (`internal/httpapi/v0.go`, греп `has no word for a refusal`), потому что иначе завтрашний новый отказ доедет до двери и оставит форму с `covers_all` — то есть этот же ряд заново и снова молча. Плюс словарь провода теперь имеет ВТОРОЙ независимый источник: `gates.TestTheBlockedVocabularyServedIsTheOneTheCanonEnumerates` читает enum `Blocked.code` из канона и сверяет с обслуживаемым набором — до этого переименование обоих значений переживало всю батарею (класс `PD-1`), что проход и показал исполнением.| fixed(пак «правда у двери», дерево сессии) | второй круг пака «форма заказа», 06.09 | +| PD-455 | bug | minor | `internal/httpapi/v0.go` `wireOrderOptions`; `internal/runs/runs.go` `Options` | **ФОРМА ЗАКАЗА ОТВЕЧАЕТ `covers_all` НАД КНИГОЙ, У КОТОРОЙ УЖЕ ИДЁТ ПРОГОН, и молчит о том, что клик получит отказ.** Вердикт считается из остатка и баланса и про ЖИВОЙ прогон не спрашивает; допуск же отвергает вторую покупку `ErrRunInFlight`. ⇒ человек видит «хватает на всю книгу», жмёт и получает отказ, причину которого форма знала до клика. ⚠ Денег не теряет и работы не портит — отказ случается ДО взятия холда, — ломается ровно обещание формы «вердикт ДО клика», ради которого пак и писался. ⚠ Найдено ВТОРЫМ КРУГОМ этого же пака и названо в отчёте поимённо (пункт ⑸ списка «не починено»), но носителя вне отчёта не имело до 06.09: отчёт — хроника, ряд — индекс, и находка без ряда не находится. Лечение: форма обязана нести признак живого прогона (или вердикт `blocked`), а не молчать ⚠ **ЗАКРЫТО 11.09 паком «ПРАВДА У ДВЕРИ».** Правило допуска по фактам КНИГИ (статус · дерево глав · живой прогон) вынесено в ОДИН предикат `internal/runs/runs.go:689`=`func startable`, и у него ДВЕ привязки разной авторитетности: `Start` зовёт его под замком книги (решает), `Order` — на опрашиваемом пути (`internal/runs/runs.go:338`=`Refusal: startable(facts)`, совещательно и заведомо устаревает). `PricedBook` НЕ расширен — форма делает ВТОРОЕ чтение `ReadBookForRun`, то самое, которым судит дверь, что и есть довод написанного рядом возражения. На провод отказ выходит как `blocked.code: run_in_flight` с `book_id` ЭТОЙ книги (`internal/httpapi/v0.go:619`), контракт 0.14.0, `D39.244`; `verdict` не тронут — он отвечает про ДЕНЬГИ, и четвёртого значения у закрытого enum нет. Пины: `runs.TestTheFormSaysWhatTheDoorWillRefuseOverABookThatIsAlreadyRunning` (форма и дверь отвечают ОДНО, и вторая книга аккаунта не блокируется), `httpapi.TestTheFormCarriesTheRunInFlightThatWillRefuseTheClick`, `httpapi.TestARunOfThisBooksOwnOutranksAnotherBooksHold` (при двух кандидатах `blocked` несёт свой прогон, а не чужой холд). ⚠ Что НЕ закрыто и вынесено отдельной строкой `PD-466`: форма по-прежнему молчит про ПРОПАВШИЙ каталог книги — дисковая половина допуска живёт только у двери. ⚠ Дофикс по адверсариальному проходу: отображение «ответ предиката → член провода» — ВТОРАЯ, рукописная вещь рядом с одним определением, и молчаливого хвоста у неё больше нет. Отказ, для которого у этой поверхности слова НЕТ, пишет ERROR-строку оператору (`internal/httpapi/v0.go`, греп `has no word for a refusal`), потому что иначе завтрашний новый отказ доедет до двери и оставит форму с `covers_all` — то есть этот же ряд заново и снова молча. Плюс словарь провода теперь имеет ВТОРОЙ независимый источник: `gates.TestTheBlockedVocabularyServedIsTheOneTheCanonEnumerates` читает enum `Blocked.code` из канона и сверяет с обслуживаемым набором — до этого переименование обоих значений переживало всю батарею (класс `PD-1`), что проход и показал исполнением.| fixed(пак «правда у двери», дерево сессии) | второй круг пака «форма заказа», 06.09 | | PD-456 | bug | minor | `internal/pgstore/books.go` `ReadBookForOrder` (сумма по недоставленным юнитам) | **ГЛАВА, УЖЕ ЗАДРАФЧЕННАЯ, НО НЕ ОТРЕДАКТИРОВАННАЯ, ОЦЕНИВАЕТСЯ ПОЛНОСТЬЮ.** Остаток книги считается по юнитам, которые ещё не доставлены В ТЕКУЩЕЙ ВОЛНЕ; стадию, на которой юнит уже стоит, счётчики не различают. ⇒ юнит, у которого черновик сделан и осталась только редактура, входит в оценку по ПОЛНОЙ цене обеих волн. ⚠ Ошибка направлена ВВЕРХ — покупателю предлагают зарезервировать больше, чем нужно, — то есть безопасна по деньгам и ВРЕДНА по продукту: завышенный холд сокращает то, что человек может купить, а на коротком балансе делает покупку невозможной там, где она возможна. ⚠ Названо в отчёте пака (пункт ⑹) без носителя вне него. Лечение требует, чтобы проекция движка различала остаток по ВОЛНАМ, а не только по юнитам, — это вопрос к манифесту, а не только к платформе | open | второй круг пака «форма заказа», 06.09 | ## Открытые — info @@ -91,13 +91,13 @@ | PD-462 | hardening | info | `internal/runs/*_test.go` фикстуры с рукописным `insert into`; гейт `internal/pgstore/sqlgate_test.go` | **ФИКСТУРЫ ПАКЕТА `runs` ПИШУТ КОЛОНКИ `pgstore` РУКОПИСНЫМ SQL, И ЭТОТ SQL НЕ СУДИТ НИ ОДИН ГЕЙТ.** `sqlgate` планирует против схемы только операторы пакета `pgstore`, поэтому фикстура, отставшая от миграции, краснеет не сообщением о схеме, а поведением теста, который она готовила. Цена сегодня мала (фикстуры правятся вместе с миграцией), растёт с числом колонок. Найдено чтением журнала при выносе эры | open | зона, 06.09, триаж журнала | | 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:361`=`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:1231`=`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-378 | bug | info | `internal/pgstore/books.go:1309`=`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:290`=`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:17`=`const canonPath = zoneRoot + "/../docs/architecture/14-api-contract/openapi.yaml"`, `docs/ENGINEERING_STANDARDS.md` §3, промты ревью-паков зоны | **Копия `platform/`, вынутая из репозитория, даёт красную батарею по причине, не имеющей отношения к коду.** Гейт контрактной версии читает канон по пути ВЫШЕ модуля (`internal/gates/contract_test.go:17`=`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-395 | doc | info | `internal/gates/contract_test.go:19`=`const canonPath = zoneRoot + "/../docs/architecture/14-api-contract/openapi.yaml"`, `docs/ENGINEERING_STANDARDS.md` §3, промты ревью-паков зоны | **Копия `platform/`, вынутая из репозитория, даёт красную батарею по причине, не имеющей отношения к коду.** Гейт контрактной версии читает канон по пути ВЫШЕ модуля (`internal/gates/contract_test.go:19`=`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 (сверка) | @@ -121,7 +121,7 @@ | 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`. Диспозиция по-прежнему нужна одна на класс ⚠ **Пак «правда у двери» 11.09 принял по СВОЕЙ новой ветке ПРОТИВОПОЛОЖНОЕ решение и называет его, чтобы оно было решением, а не побочным эффектом.** Отказ допуска над нечитаемым каталогом (`internal/runs/runs.go:718`=`func (s *Service) sourceThere`) несёт ОПЕРАЦИЮ и errno, но НЕ путь: норма зоны §2 запрещает id книги и пользователя в логах, а эта ошибка уходит в ERROR-строку хендлера. Цена названа: оператор видит «каталог книги пропал» и не видит, ЧЬЕЙ именно — книгу он опознаёт по её же экрану (409 `book_not_ready`/`source_gone`) и по `tmplatformctl`. Пин `runs.TestADirectoryThatCannotBeReadIsTheDeploymentsAndCarriesNoPath` держит обе половины: класс ошибки и отсутствие пути в её тексте. ⚠ **Ряд этим НЕ закрыт**, и второй его носитель замерен той же мутацией: со снятым гардом допуска тот же сценарий отвечает `runs: stat journal: stat <…>/events.jsonl: permission denied` — `internal/runs/spawn.go:309`=`runs: stat journal`, и на путях реконсилятора гарда нет вовсе. ⛔ **ОДНА ДИСПОЗИЦИЯ НА КЛАСС, принята паком «ответ двери» 11.09 ПОСЛЕ ЗАМЕРА, а не выбрана.** Сначала посылка: путь каталога и идентификатор книги — ОДНО И ТО ЖЕ. Интейк кладёт книгу в `/` (`internal/books/books.go`, `dir := filepath.Join(s.Cfg.BooksDir, id)`), и на живом стенде холодного прогона это так у **5 книг из 5** (`select id, workdir from books` в `tm_coldrun_door`: `bk_EQWUDWUXXALA7BFP` → `…/books/bk_EQWUDWUXXALA7BFP`). ⇒ строка лога с путём несёт тот самый идентификатор, который норма зоны §2 держит вне логов, и «две диспозиции» стоять не могут. **Правило, которое принято:** платформа никогда не КЛАДЁТ идентичность книги в лог сама. Где предложение сочиняет она — называются операция и errno, без пути (`sourceThere` делал это и раньше; `journalSize` приведён к тому же, пин `runs.TestTheJournalStatFailureNamesTheOperationAndNotTheBook`). Где текст чужой программы — он несётся как написан, потому что переписывать чужой диагноз хуже, чем нести его; работа платформы тогда — сделать так, чтобы обычный случай до чужого текста не доходил. **Что из этого построено.** Замер показал утечку НИЖЕ уровня ERROR, то есть там, где интейк свою половину уже запинил: на клине пропавшего каталога путь книги ехал на WARN. ⚠ **Число «10 из 20», стоявшее здесь, снято: оно с зонда, которого в дереве нет, и из дерева не воспроизводится** (нашёл адверсариальный проход, не автор). Воспроизводимые числа печатают сами пины: на мёртвом юните **14 строк, 6 называют нечитаемый каталог**, на ЖИВОМ — **4 и 3**. И носителей утечки оказалось ДВА, а не один: второй — `resync failed`, который повторяется каждый `ResyncEvery`, пока юнит жив, и первый круг пака его не видел, потому что его пин держал юнит мёртвым. Теперь заблокированный расчёт КЛАССИФИЦИРУЕТСЯ своим же предикатом (`internal/runs/reconcile.go`, `if why := s.sourceThere(l.Workdir)`) и говорит своими словами, различая пропавший каталог книги и не смонтированный том; текст движка остаётся только там, где каталог НА МЕСТЕ и назвать причину платформе нечем. Пины: `runs.TestNoBookDirectoryReachesALineBelowError` (мёртвый юнит; контрольный фильтр: 14 строк, 14 несут id прогона, 6 называют нечитаемый каталог), `runs.TestNoBookDirectoryReachesALineBelowErrorWhileTheUnitIsAlive` (ЖИВОЙ юнит, путь резинка: 4 строки, 4 несут id, 3 называют каталог) и `runs.TestAnEngineThatFailsOverAPresentDirectoryIsQuotedAsItWrote` — вторая половина, без которой первая была бы верна и для логгера, который просто замолчал. ⚠ **Что ОСТАЁТСЯ и почему ряд не закрыт:** на ERROR путь по-прежнему законен — это и есть названное исключение, и оно условно, а не по вкусу: текст, который платформа не сочиняла, на пути, который оператор обязан чинить. Закрыть исключение можно, дав `pgstore.StalledRun` колонку каталога (сегодня её там нет), — тогда лог сможет нести класс, а путь оператор возьмёт из `tmplatformctl runs`. Это работа следующего пака, и цена названа здесь, чтобы её не выводили заново. | 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`. Диспозиция по-прежнему нужна одна на класс ⚠ **Пак «правда у двери» 11.09 принял по СВОЕЙ новой ветке ПРОТИВОПОЛОЖНОЕ решение и называет его, чтобы оно было решением, а не побочным эффектом.** Отказ допуска над нечитаемым каталогом (`internal/runs/runs.go:741`=`func (s *Service) sourceThere`) несёт ОПЕРАЦИЮ и errno, но НЕ путь: норма зоны §2 запрещает id книги и пользователя в логах, а эта ошибка уходит в ERROR-строку хендлера. Цена названа: оператор видит «каталог книги пропал» и не видит, ЧЬЕЙ именно — книгу он опознаёт по её же экрану (409 `book_not_ready`/`source_gone`) и по `tmplatformctl`. Пин `runs.TestADirectoryThatCannotBeReadIsTheDeploymentsAndCarriesNoPath` держит обе половины: класс ошибки и отсутствие пути в её тексте. ⚠ **Ряд этим НЕ закрыт**, и второй его носитель замерен той же мутацией: со снятым гардом допуска тот же сценарий отвечает `runs: stat journal: stat <…>/events.jsonl: permission denied` — `internal/runs/spawn.go:309`=`runs: stat journal`, и на путях реконсилятора гарда нет вовсе. ⛔ **ОДНА ДИСПОЗИЦИЯ НА КЛАСС, принята паком «ответ двери» 11.09 ПОСЛЕ ЗАМЕРА, а не выбрана.** Сначала посылка: путь каталога и идентификатор книги — ОДНО И ТО ЖЕ. Интейк кладёт книгу в `/` (`internal/books/books.go`, `dir := filepath.Join(s.Cfg.BooksDir, id)`), и на живом стенде холодного прогона это так у **5 книг из 5** (`select id, workdir from books` в `tm_coldrun_door`: `bk_EQWUDWUXXALA7BFP` → `…/books/bk_EQWUDWUXXALA7BFP`). ⇒ строка лога с путём несёт тот самый идентификатор, который норма зоны §2 держит вне логов, и «две диспозиции» стоять не могут. **Правило, которое принято:** платформа никогда не КЛАДЁТ идентичность книги в лог сама. Где предложение сочиняет она — называются операция и errno, без пути (`sourceThere` делал это и раньше; `journalSize` приведён к тому же, пин `runs.TestTheJournalStatFailureNamesTheOperationAndNotTheBook`). Где текст чужой программы — он несётся как написан, потому что переписывать чужой диагноз хуже, чем нести его; работа платформы тогда — сделать так, чтобы обычный случай до чужого текста не доходил. **Что из этого построено.** Замер показал утечку НИЖЕ уровня ERROR, то есть там, где интейк свою половину уже запинил: на клине пропавшего каталога путь книги ехал на WARN. ⚠ **Число «10 из 20», стоявшее здесь, снято: оно с зонда, которого в дереве нет, и из дерева не воспроизводится** (нашёл адверсариальный проход, не автор). Воспроизводимые числа печатают сами пины: на мёртвом юните **14 строк, 6 называют нечитаемый каталог**, на ЖИВОМ — **4 и 3**. И носителей утечки оказалось ДВА, а не один: второй — `resync failed`, который повторяется каждый `ResyncEvery`, пока юнит жив, и первый круг пака его не видел, потому что его пин держал юнит мёртвым. Теперь заблокированный расчёт КЛАССИФИЦИРУЕТСЯ своим же предикатом (`internal/runs/reconcile.go`, `if why := s.sourceThere(l.Workdir)`) и говорит своими словами, различая пропавший каталог книги и не смонтированный том; текст движка остаётся только там, где каталог НА МЕСТЕ и назвать причину платформе нечем. Пины: `runs.TestNoBookDirectoryReachesALineBelowError` (мёртвый юнит; контрольный фильтр: 14 строк, 14 несут id прогона, 6 называют нечитаемый каталог), `runs.TestNoBookDirectoryReachesALineBelowErrorWhileTheUnitIsAlive` (ЖИВОЙ юнит, путь резинка: 4 строки, 4 несут id, 3 называют каталог) и `runs.TestAnEngineThatFailsOverAPresentDirectoryIsQuotedAsItWrote` — вторая половина, без которой первая была бы верна и для логгера, который просто замолчал. ⚠ **Что ОСТАЁТСЯ и почему ряд не закрыт:** на ERROR путь по-прежнему законен — это и есть названное исключение, и оно условно, а не по вкусу: текст, который платформа не сочиняла, на пути, который оператор обязан чинить. Закрыть исключение можно, дав `pgstore.StalledRun` колонку каталога (сегодня её там нет), — тогда лог сможет нести класс, а путь оператор возьмёт из `tmplatformctl runs`. Это работа следующего пака, и цена названа здесь, чтобы её не выводили заново. | 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 | самопроверка дофикса (ревью вне карты) | @@ -151,7 +151,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:447`=`resnapshot := book.BankMoved`, `internal/pgstore/books.go:1104`=`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` (первая покупка флага НЕ несёт, вторая несёт, правки банка не было). | fixed(628cc56) | бэкенд-пак «деньги» + пак P11 (сверка шва) | +| PD-422 | bug | info | `internal/runs/runs.go:470`=`resnapshot := book.BankMoved`, `internal/pgstore/books.go:1139`=`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` (первая покупка флага НЕ несёт, вторая несёт, правки банка не было). | fixed(628cc56) | бэкенд-пак «деньги» + пак 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 (гейт класса, первый прогон) | | PD-457 | bug | info | `sqlc.yaml` (`overrides`), колонка `books.source_chars` | **У NULLABLE `bigint` НЕТ ПОДСТАНОВКИ ТИПА, ХОТЯ КОНФИГ ОБЕЩАЕТ УКАЗАТЕЛЬ ДЛЯ NULLABLE-ЦЕЛЫХ.** Миграция 00033 делает `books.source_chars` нуллабельной намеренно («движок не сказал» — не «ноль»), а `sqlc` для неё подстановки не имеет. Сегодня не ломает ничего: рукописный код читает колонку как `*int64` и конвертирует сам, `sqlc diff` чист. ⚠ Риск ОТЛОЖЕННЫЙ, а не отсутствующий, и наступит на ПЕРВОМ запросе `queries/*.sql`, который её выберет, — скан NULL в не-указатель. **Тот же класс, что и денежные колонки** (строка бэклога 304, заведена оркестратором), но другая колонка и другая подстановка: та про `*_micro_usd`, эта про размер текста. ⚠ Названо в отчёте пака (пункт ⑵) без носителя вне него | open | второй круг пака «форма заказа», 06.09 | | PD-458 | bug | info | `Makefile` цель `check`, подметание в первом шаге; гейт `internal/gates.TestTheBatterySweepsOnlyLogsWhoseWriterIsGone` | **ПОЧИНКА `PD-454` ЗАБРАЛА СВОЙСТВО, КОТОРОГО НИКТО НЕ НАЗЫВАЛ: ЛОГИ СТАЛИ НАКАПЛИВАТЬСЯ.** Прежнее ФИКСИРОВАННОЕ имя лога само себя ограничивало — сколько бы прогонов ни падало, оставался ОДИН несвежий файл, и следующий его перезаписывал. Попрогонное имя убрало коллизию ВМЕСТЕ с этим ограничением: каждый упавший или прерванный прогон оставляет свой `.check.log.` навсегда. ⚠ Замер, а не опасение: за одну ночь накопилось **четыре** (все нулевого размера, от прерванных прогонов зоны). В коммит не попадают (`.gitignore` = `.check.log*`), и цена НЕ в этом: рецепт НАРОЧНО сохраняет лог при падении, чтобы его прочли, и куча мусора от прерванных прогонов от этого одного файла НЕОТЛИЧИМА. То есть гниёт ровно та читаемость, ради которой лог и сохраняется. ⇒ подметание перед прогоном, и оно **условно по живости писателя** (`kill -0` — POSIX, без `/proc`): голое `rm -f .check.log.*` снесло бы лог ПАРАЛЛЕЛЬНОГО прогона и вернуло бы ровно ту коллизию, ради устранения которой заводилось попрогонное имя. Перезанятый PID оставляет файл — консервативное направление, единственное, что не уничтожает улику. Нечисловой хвост пропускается. ⚠ Следствие, названное, а не оставленное на открытие: лог, сохранённый УПАВШИМ прогоном, подметается СЛЕДУЮЩИМ — читать до пере-запуска. Три мутации ловятся: голое `rm -f .check.log.*` · цикл без проверки живости · подметание убрано совсем. ⚠ Найдено ЗОНОЙ при ответе на вопрос оркестратора «остались ли сомнения» — то есть вопросом, а не гейтом; ни один сторож второго порядка у починки не стоял | fixed(`0632a30`) | зона, 06.09, при закрытии смены | @@ -559,9 +559,9 @@ | 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:685`=`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:556`=`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:721`=`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:1274`=`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-376 | bug | minor, деньги | `internal/pgstore/runs.go:1275`=`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:312`=`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:765`=`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-391 | bug | minor | `internal/pgstore/sink.go:785`=`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:437`=`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:266`=`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, линза денег под гонкой) | @@ -613,7 +613,7 @@ | 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:1223`=`case balance <= 0:`), а ⚠-комментарий рядом прямо описывает замену (`platform/internal/pgstore/books.go:1192`=`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-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:1301`=`case balance <= 0:`), а ⚠-комментарий рядом прямо описывает замену (`platform/internal/pgstore/books.go:1270`=`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 | @@ -633,4 +633,4 @@ | ID | Класс | Серьёзность | Где | Суть | Статус | Источник | |---|---|---|---|---|---|---| | PD-442 | bug | minor | `internal/pgstore/migrations/00002_readmodel.sql` (таблица `exports`), канон `14-api-contract/openapi.yaml` §Export | **В схеме с самой первой миграции читающей модели жила таблица `exports`, у которой НЕТ ни одного читателя и ни одного писателя, и её форма ПРОТИВОРЕЧИТ ратифицированному контракту.** Она несла `ready boolean` плюс `failed_reason text`, а канон объясняет прямо, почему так нельзя: «A state and not a boolean: a boolean merges three situations into "not ready" and a poll on it never ends» (§`Export.state`). То есть будущая дверь выдачи, честно построенная на существующей таблице, отдала бы поллинг, который не кончается — ровно тот дефект, который канон и запрещает. Обнаружено при постройке двери: `sqlc generate` отказался с `relation "exports" already exists`, и это единственный сторож, который у класса был. Проверка отсутствия потребителей: `grep -rn '\bexports\b' --include=*.go internal/ cmd/` до пака давал ОДИН хит и тот в комментарии (`internal/httpapi/capabilities.go`). ⚠ Мигрировать было нечего — ни строки ни разу не записывалось, — поэтому `00032_exports.sql` таблицу СНОСИТ и создаёт заново в форме канона; down-путь восстанавливает форму `00002` дословно, потому что откат обязан вернуть то, что выпущенная миграция оставила. Пин формы: `internal/pgstore/exports_test.go` (четыре состояния, партиальные индексы свипа, каскад с книгой) | fixed(дерево пака «закрыть цикл», 04.09) | пак «закрыть цикл» 04.09 (найдено постройкой двери выдачи) | -| PD-447 | doc | minor | `docs/STACK_DECISIONS.md` §«Как поднять локально» и `deploy/README.md` шаг 3–4 (оба исправлены); носители дефолта — `internal/config/config.go:337`=`TM_PLATFORM_ADDR` и `cmd/tmplatformctl/seed.go:41`=`base URL of the running tmplatformd` | **Оба стендовых рецепта зоны принимали ОТВЕТ ПО АДРЕСУ за доказательство того, что отвечает СВОЙ процесс, и потому проходили при мёртвом собственном демоне.** Замерено исполнением 04.09: на машине разработки четвёртые сутки жил чужой `tmplatformd` на `127.0.0.1:8080` со своей базой; демон рецепта умирает в этой ситуации на `bind: address already in use` — молча, он запущен фоном, — а смоук следующей строкой получает `healthz=200` и `readyz=ready` ОТ ЧУЖОГО ПРОЦЕССА. ⚠ **Дороже смоука — шаг сида:** `tmplatformctl seed` дарит `$25` кредита и грузит книгу ЧЕРЕЗ ЖИВОЙ ИНТЕЙК, то есть по этому рецепту деньги и данные уезжают в чужой деплой. Второй адрес там же был написан отдельным литералом (`--url http://127.0.0.1:8080`), что и есть штатный способ разъехаться с `TM_PLATFORM_ADDR` незаметно. ⚠ **Три лечения на три РАЗНЫЕ половины, ни одно не заменяет другое:** явный `_ADDR` уводит с общего дефолта; проба по `pid` слушателя отвечает на вопрос, на который `200` не отвечает в принципе — ЧЕЙ это процесс; `--noproxy '*'` нужен потому, что при заданных `http_proxy` голый `curl` на `127.0.0.1` уходит во внешний прокси. Проба предъявлена в обе стороны: свой pid на своём порту — проходит, чужой демон против своего pid — отвергается. ⚠ **Дефолт `8080` в коде НЕ меняется, и это решение:** коллизия — `tmplatformd` против `tmplatformd`, любой другой номер даст ту же аварию на втором одновременном стенде, а цену смены заплатят все существующие деплои и доки. Чинится посылка, а не номер. **Вес minor, а не major, по радиусу:** пишет только дев-стенды, боевого пути этим рецептом нет, деньги — стендовый грант. | fixed(`adf5e53`) | найдено оркестратором №22 при независимой проверке отзыва причины смертей демона, 04.09; починено зоной | +| PD-447 | doc | minor | `docs/STACK_DECISIONS.md` §«Как поднять локально» и `deploy/README.md` шаг 3–4 (оба исправлены); носители дефолта — `internal/config/config.go:345`=`TM_PLATFORM_ADDR` и `cmd/tmplatformctl/seed.go:41`=`base URL of the running tmplatformd` | **Оба стендовых рецепта зоны принимали ОТВЕТ ПО АДРЕСУ за доказательство того, что отвечает СВОЙ процесс, и потому проходили при мёртвом собственном демоне.** Замерено исполнением 04.09: на машине разработки четвёртые сутки жил чужой `tmplatformd` на `127.0.0.1:8080` со своей базой; демон рецепта умирает в этой ситуации на `bind: address already in use` — молча, он запущен фоном, — а смоук следующей строкой получает `healthz=200` и `readyz=ready` ОТ ЧУЖОГО ПРОЦЕССА. ⚠ **Дороже смоука — шаг сида:** `tmplatformctl seed` дарит `$25` кредита и грузит книгу ЧЕРЕЗ ЖИВОЙ ИНТЕЙК, то есть по этому рецепту деньги и данные уезжают в чужой деплой. Второй адрес там же был написан отдельным литералом (`--url http://127.0.0.1:8080`), что и есть штатный способ разъехаться с `TM_PLATFORM_ADDR` незаметно. ⚠ **Три лечения на три РАЗНЫЕ половины, ни одно не заменяет другое:** явный `_ADDR` уводит с общего дефолта; проба по `pid` слушателя отвечает на вопрос, на который `200` не отвечает в принципе — ЧЕЙ это процесс; `--noproxy '*'` нужен потому, что при заданных `http_proxy` голый `curl` на `127.0.0.1` уходит во внешний прокси. Проба предъявлена в обе стороны: свой pid на своём порту — проходит, чужой демон против своего pid — отвергается. ⚠ **Дефолт `8080` в коде НЕ меняется, и это решение:** коллизия — `tmplatformd` против `tmplatformd`, любой другой номер даст ту же аварию на втором одновременном стенде, а цену смены заплатят все существующие деплои и доки. Чинится посылка, а не номер. **Вес minor, а не major, по радиусу:** пишет только дев-стенды, боевого пути этим рецептом нет, деньги — стендовый грант. | fixed(`adf5e53`) | найдено оркестратором №22 при независимой проверке отзыва причины смертей демона, 04.09; починено зоной | diff --git a/platform/docs/platform-PROGRESS.md b/platform/docs/platform-PROGRESS.md index aeb99477..127e18f7 100644 --- a/platform/docs/platform-PROGRESS.md +++ b/platform/docs/platform-PROGRESS.md @@ -289,7 +289,7 @@ go version → go1.26.7 ≥ 1.26.6 | Находка | Чья ошибка | Что сделано | Чем предъявлено | |---|---|---|---| -| **F1.** Канон описывал происхождение согласия У́ЖЕ, чем оно есть: «даёт `resume` над поправленным банком» — а `runs.go:470`=`resnapshot := book.BankMoved \|\| book.HasPriorRun` плюс `:579`=`func rebillConsent` дают non-null согласие и на ОБЫЧНОЙ второй покупке книги | моя, в тексте канона | описание расширено до истинной популяции: согласие несёт всякий прогон, идущий с пере-снапшотом, и назван довод, почему вторая покупка — защита, а не аппетит (проекция, выросшая за виденное покупателем, обязана ОТКАЗАТЬ, а не быть купленной молча). Фраза «never by the platform's own restart» оставлена: свип передаёт нули | пере-снято мной по коду до правки; канон разобран YAML-парсером | +| **F1.** Канон описывал происхождение согласия У́ЖЕ, чем оно есть: «даёт `resume` над поправленным банком» — а `platform/internal/runs/runs.go:470`=`resnapshot := book.BankMoved` плюс `:579`=`func rebillConsent` дают non-null согласие и на ОБЫЧНОЙ второй покупке книги | моя, в тексте канона | описание расширено до истинной популяции: согласие несёт всякий прогон, идущий с пере-снапшотом, и назван довод, почему вторая покупка — защита, а не аппетит (проекция, выросшая за виденное покупателем, обязана ОТКАЗАТЬ, а не быть купленной молча). Фраза «never by the platform's own restart» оставлена: свип передаёт нули | пере-снято мной по коду до правки; канон разобран YAML-парсером | | **F2.** `progress_lagging` объявлен «Always sent», а в `Run.required` его не было ⇒ клиент, сгенерированный по канону, сделал бы член опциональным — канон противоречил бы себе на одном свойстве | моя | член внесён в `required` (12 вместо 11), рядом — довод и ссылка на зеркальный корректирующий минор `0.13.1` | `requiredOf` парсит канон: `progress_lagging` в required = да, `rebill_consent_micro_usd` = нет (nullable по замыслу) | | **F2⭐ класс, а не случай** | предложение оркестратора, аргумент мой | построен гейт `httpapi.TestEveryAlwaysSentMemberOfARunIsPromisedByTheCanon`: он ходит по ЖСОНу, который провод отдаёт для пустого прогона, и требует, чтобы каждый всегда-посылаемый член был либо в `required`, либо в ИМЕННОМ реестре исключений с причиной. Реестр честен в обе стороны: исключённый член, который канон уже обещает, и исключённый член, которого провод не шлёт, — обе строки краснеют | две посадки каталога: канон перестаёт обещать член · провод переименовывает член | | **F4.** `failureReasonsInDDL` читает миграцию целиком, не различая `+goose Up` и `+goose Down` | моя | условие, при котором вывод перестаёт держаться, дописано в хелпере, и названо лечение (резать файл по маркеру `+goose Down`, а не нацеливать гейт на файл по имени) | по нашему же правилу про выводы в комментариях | @@ -1563,7 +1563,7 @@ re-pass не был запинен) и M26 (ветвь проигравшего) дерево глав · живой прогон), и форма заказа о них не знала: вердикт считался из остатка книги и баланса, а `ErrRunInFlight` жил только у двери. Человек видел `covers_all`, жал и получал 409. -Сделано: три проверки вынесены в **один предикат** `platform/internal/runs/runs.go:666`=`func startable`, +Сделано: три проверки вынесены в **один предикат** `platform/internal/runs/runs.go:689`=`func startable`, у него две привязки РАЗНОЙ авторитетности — `Start` зовёт его под замком книги (решает), `Order` зовёт на опрашиваемом пути (`runs.go:304`, совещательно и заведомо устаревает). Мерило пака — «сколько ОПРЕДЕЛЕНИЙ придётся тронуть, если правило изменится» — выполнено: одно. @@ -1593,7 +1593,7 @@ re-pass не был запинен) и M26 (ветвь проигравшего) ### `PD-162` — отказ до денег, и вторая дверь, которой ряд не называл -Проверка каталога книги стоит на допуске ДО холда (`runs.go:707`=`func (s *Service) sourceThere`, зовётся +Проверка каталога книги стоит на допуске ДО холда (`platform/internal/runs/runs.go:741`=`func (s *Service) sourceThere`, зовётся из `Start` после предиката книги и перед чтением счёта). ⚠ **Трудности, которую промт объявлял главной, действительно нет** (§4.3 промта): каталог создаётся на интейке ДО строки в БД, а исключение первого прогона принадлежит ЖУРНАЛУ внутри каталога (`journalSize` мапит ENOENT в нулевой офсет) — так что @@ -1619,7 +1619,7 @@ re-pass не был запинен) и M26 (ветвь проигравшего) Предикат корня не скопирован, а **вызван**: `books.storageIsThere` стал экспортируемым `platform/internal/books/books.go:606`=`func StorageIsThere(booksDir string) bool`, а «книга под нашим -корнем?» — `books.go:594`=`func Owns(booksDir, dir string) bool`; методы остались обёртками в одну +корнем?» — `platform/internal/books/books.go:626`=`func Owns(booksDir, dir string) bool`; методы остались обёртками в одну строку. Копия была бы вторым экземпляром правила, у которого первая редакция стоила зоны всех книг хоста — и мутация, ломающая `StorageIsThere`, красит не только мой тест, но и ДВА чужих пина `PD-192` в `internal/books`, что и есть доказательство, что предикат один. @@ -1634,14 +1634,14 @@ re-pass не был запинен) и M26 (ветвь проигравшего) ⚠ Ниже — ТОЛЬКО разбор; кода по этому ряду я не написал ни строки. **Механика, снятая чтением (адреса пере-сняты сегодня).** `Resume` читает состояние прогона ДО замка -книги: `platform/internal/runs/reconcile.go:1702`=`s.Store.ReadRunForResume`, затем -`reconcile.go:1663`=`unlock, err := s.lockBook(ctx, l.BookID)`, и только потом судит по `l.Status` +книги: `platform/internal/runs/reconcile.go:1804`=`s.Store.ReadRunForResume`, затем +`platform/internal/runs/reconcile.go:1815`=`unlock, err := s.lockBook(ctx, l.BookID)`, и только потом судит по `l.Status` (`reconcile.go:1696`) — по значению, прочитанному ДО замка. Из этого следуют два разных проигравших: * **проигравший ВНУТРИ окна** (его чтение случилось до коммита победителя) держит устаревший `stopped`, доходит до `reopen`, его вставка `run_attempts` падает на уникальном индексе `run_attempts_run_id_attempt_no_key` → `ErrNoRun` → `Resume` отвечает **прогоном** - (`reconcile.go:1736`=`return s.Store.ReadRun(ctx, userID, runID)`), и холд не берётся: транзакция + (`platform/internal/runs/reconcile.go:1973`=`return s.Store.ReadRun(ctx, userID, runID)`), и холд не берётся: транзакция откатывается целиком; * **проигравший СНАРУЖИ окна** (его чтение случилось уже после коммита победителя) видит `translating`, падает в `default:` и получает `ErrNotResumable` — «the run cannot be continued: it @@ -1649,7 +1649,7 @@ re-pass не был запинен) и M26 (ветвь проигравшего) ⇒ **Окно дефекта — НЕ гонка за индекс, а интервал между чтением состояния и взятием замка.** И отсюда же объяснение, почему изолированно тест зелёный, а в полном пакете под нагрузкой красный: замок книги -(`platform/internal/runs/bank.go:400`=`func (s *Service) lockBook`) — ВНУТРИПРОЦЕССНЫЙ мьютекс, так что +(`platform/internal/runs/bank.go:439`=`func (s *Service) lockBook`) — ВНУТРИПРОЦЕССНЫЙ мьютекс, так что два резюма одного демона идут друг за другом, и под голоданием по процессору горутины стартуют дальше друг от друга — проигравший чаще успевает прочитать уже переведённое состояние. Формулировка в шапке теста («both calls pass the state check together and race for attempt N+1») описывает состояние ДВУХ @@ -2901,7 +2901,7 @@ ratified canon is 0.13.0». Улика: файл-носитель (`internal/htt | `docs/PROGRESS.md`, `docs/architecture/05-decisions-log.md` | **4** | ЧУЖАЯ зона — ушли пингом с готовыми адресами и токенами | ⭐ **Находка из этого же хода:** экранирование `\|` в ячейке регистра (§4.5) **ломает якорь**, если черта -попала в его токен. `PD-422` держал `internal/runs/runs.go:447`=`resnapshot := book.BankMoved || book.HasPriorRun`; +попала в его токен. `PD-422` держал `internal/runs/runs.go:470`=`resnapshot := book.BankMoved || book.HasPriorRun`; после экранирования токен перестал совпадать с кодом. Вылечено укорочением токена до `resnapshot := book.BankMoved` (единственный хит в файле). Счёт колонок и сверка токена тянут ячейку в разные стороны — следующий, кто пойдёт экранировать черты, наступит на то же. Ушло пингом.