From 1c7a29b340391193826c6dde3939b482b35bba5f Mon Sep 17 00:00:00 2001 From: heaven Date: Fri, 11 Sep 2026 18:22:56 +0300 Subject: [PATCH] Give the defect row's bare line range a token, so the linter can read the target instead of passing it over --- platform/docs/DEFECT_REGISTER.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/platform/docs/DEFECT_REGISTER.md b/platform/docs/DEFECT_REGISTER.md index 9b3251b6..20c9535a 100644 --- a/platform/docs/DEFECT_REGISTER.md +++ b/platform/docs/DEFECT_REGISTER.md @@ -58,7 +58,7 @@ | 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:452-455` «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:1808`, перед `reopen` — измерено, что без него резюм над пропавшим каталогом БРАЛ холд остатка бюджета и возвращал прогон). Две беды разведены сентинелом `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:718`=`func (s *Service) sourceThere` зовётся из `Start` (после предиката книги, перед чтением счёта) и из `Resume` (`internal/runs/reconcile.go:1808`, перед `reopen` — измерено, что без него резюм над пропавшим каталогом БРАЛ холд остатка бюджета и возвращал прогон). Две беды разведены сентинелом `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) |