From 49f74d9fe9eb6386482da540dec209f48ef01291 Mon Sep 17 00:00:00 2001 From: heaven Date: Fri, 11 Sep 2026 05:56:36 +0300 Subject: [PATCH] Stop the order form promising a start the door will refuse, and refuse a run over a book whose directory is gone before the money moves --- platform/cmd/tmplatformd/runner.go | 7 +- platform/cmd/tmplatformd/runner_test.go | 13 + platform/docs/DEFECT_REGISTER.md | 29 +- platform/docs/STACK_DECISIONS.md | 36 +- platform/docs/platform-PROGRESS.md | 488 +++++++++++++++++++++- platform/internal/books/books.go | 33 +- platform/internal/gates/contract_test.go | 148 +++++++ platform/internal/gates/register_test.go | 15 +- platform/internal/httpapi/capabilities.go | 10 +- platform/internal/httpapi/problem.go | 14 + platform/internal/httpapi/v0.go | 63 ++- platform/internal/httpapi/v0_test.go | 118 ++++++ platform/internal/runs/admission_test.go | 362 ++++++++++++++++ platform/internal/runs/reconcile.go | 8 + platform/internal/runs/runs.go | 197 +++++++-- 15 files changed, 1468 insertions(+), 73 deletions(-) create mode 100644 platform/internal/runs/admission_test.go diff --git a/platform/cmd/tmplatformd/runner.go b/platform/cmd/tmplatformd/runner.go index defd34a9..914f9b5a 100644 --- a/platform/cmd/tmplatformd/runner.go +++ b/platform/cmd/tmplatformd/runner.go @@ -31,7 +31,12 @@ import ( // assigned — which is how RunBudget came to be declared, documented and left at zero. func runsConfig(cfg config.Config, marker []string, ceiling []string) runs.Config { return runs.Config{ - StateDir: cfg.Runner.StateDir, + StateDir: cfg.Runner.StateDir, + // From the INTAKE's section, and the one setting this mapping takes from outside the runner's: + // the root is the intake's to write and the door's to ask about. A run whose book directory is + // missing has to say whether the volume went with it, and that question is a path away + // (runs.sourceThere). + BooksDir: cfg.Intake.BooksDir, EngineBinary: cfg.Runner.EngineBinary, MarkerArgv: marker, Ceiling: ceiling, diff --git a/platform/cmd/tmplatformd/runner_test.go b/platform/cmd/tmplatformd/runner_test.go index bc7421dc..8e03cb01 100644 --- a/platform/cmd/tmplatformd/runner_test.go +++ b/platform/cmd/tmplatformd/runner_test.go @@ -64,9 +64,22 @@ func TestTheOperatorsRunnerKnobsReachTheReconciler(t *testing.T) { cfg.Runner.ResyncEvery = 5 * time.Minute cfg.Runner.RunBudget = 90 * time.Second cfg.Runner.KeysFile = "/etc/tm/keys.env" + // ⚠ The ONE setting this mapping takes from outside the runner's own section, and the reason it is + // set here in the INTAKE's: the root belongs to the intake and the run door only asks about it. + cfg.Intake.BooksDir = "/srv/textmachine/books" got := runsConfig(cfg, []string{"/usr/bin/tmplatformctl", "exit-marker"}, []string{"--max-cost"}) switch { + case got.BooksDir != cfg.Intake.BooksDir: + // ⛔ ITS OWN CASE BECAUSE ITS ABSENCE IS SILENT AND COSTS THE TRUTH AT THE DOOR. Unwired, the + // door cannot tell "this book's directory is gone" from "the volume under every book is gone": + // `books.Owns("", …)` is false for every book, so a book whose own directory vanished is + // answered `503` and «this deployment cannot start runs» instead of `409 book_not_ready` with + // `source_gone` — the PD-192 confusion the five pins of `runs.sourceThere` exist to prevent, + // back in one dropped line. Money is untouched either way, which is exactly why nothing else + // would notice: a mutation of this line survived the whole battery (adversarial pass, 11.09). + t.Errorf("BooksDir is %q, want the intake's %q: the run door cannot tell whose fault a missing directory is", + got.BooksDir, cfg.Intake.BooksDir) case got.RunBudget != cfg.Runner.RunBudget: t.Errorf("RunBudget is %v, want the operator's %v: TM_PLATFORM_RUN_BUDGET does nothing", got.RunBudget, cfg.Runner.RunBudget) diff --git a/platform/docs/DEFECT_REGISTER.md b/platform/docs/DEFECT_REGISTER.md index 4c1baeb1..709b37bc 100644 --- a/platform/docs/DEFECT_REGISTER.md +++ b/platform/docs/DEFECT_REGISTER.md @@ -33,31 +33,32 @@ | ID | Класс | Серьёзность | Где | Суть | Статус | Источник | |---|---|---|---|---|---|---| +| PD-466 | bug | info | `internal/runs/runs.go:707`=`func (s *Service) sourceThere` (дверь) против `internal/runs/runs.go:304`=`Refusal: startable(facts)` (форма) | **ФОРМА ЗАКАЗА ГОВОРИТ `covers_all` НАД КНИГОЙ, ЧЕЙ КАТАЛОГ ПРОПАЛ.** Правило допуска разрезано на две половины: факты КНИГИ из БД спрашивают обе площадки (`startable`), а ДИСК спрашивает только дверь. Половина из PD-455 закрыта, эта — нет: человек видит зелёный вердикт, жмёт и получает 409 `book_not_ready`/`source_gone`. ⚠ Денег не теряет и работы не портит — отказ приходит ДО холда (`PD-162`), — ломается то же обещание формы «вердикт ДО клика». **Почему не сделано в том же паке, а заведено:** опрашиваемый путь — не место для `os.Stat`, зависшее монтирование повесило бы каждый опрос каждого клиента; дешёвое лечение, если оно понадобится, — не стат на опросе, а ФАКТ в БД (интейк уже знает, что каталог создан, а пропажу видит свип), и тогда форма спрашивает его тем же предикатом. Пока не понадобилось: удаление каталога под живой книгой — авария оператора, а не ординарный путь | 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/runs.go:170`=`ErrNotResumable` (отказ, который получает проигравший) и `internal/runs/control_test.go:846` `TestTwoResumesOfOneRunTakeOneHoldAndBothAnswer` (пин гарантии) | **ДВОЙНОЙ КЛИК ПО «ПРОДОЛЖИТЬ»: проигравший гонку получает ОТКАЗ вместо прогона, когда два вызова не успевают пройти проверку состояния в одном окне.** Гарантия зоны сформулирована в шапке самого теста: «Both calls pass the state check together and race for attempt N+1 on the unique index; the loser must answer with the run the winner re-opened, not with a not-found». Замер 05.09: под нагрузкой машины (`load average 42–48` на 8 ядрах, тринадцать чужих `tmmutate`) вызовы СЕРИАЛИЗУЮТСЯ — победитель успевает перевести прогон в `translating` до того, как проигравший дойдёт до своей проверки, — и проигравший получает `runs: the run cannot be continued: it is translating` (`control_test.go:863`). ⚠ **Пользовательский смысл: человек, дважды нажавший «продолжить», видит ошибку, хотя прогон в этот момент СТАРТОВАЛ.** ⚠ **Деньги не затронуты:** отказ приходит ДО взятия холда, так что второй холд не берётся — та половина гарантии («ровно один холд») держится и на отказном пути. ⚠ **Это НЕ `PD-420`:** тот ряд про другой тест (`internal/pgstore/runs_test.go` `TestAClaimThatLostARaceToAReleaseIsRetriedAndNotAnError`); проверено грепом, имени этого теста в реестре не было ни в одном ряду. **Воспроизведение:** полный пакет `go test ./internal/runs/` при загруженной машине — упал 2 раза из 2; ИЗОЛИРОВАННО (`-run TestTwoResumesOfOneRunTakeOneHoldAndBothAnswer -count=5`) — 5/5 `ok` за 10 с. То есть окно существует и открывается голоданием по процессору, а не правкой кода. **Направление лечения (не решение):** проигравшему различать «прогон уже идёт, потому что его только что открыл параллельный резюм» от «прогон идёт сам по себе» — первое обязано вернуть прогон, второе законно отказывает. | open | замер зоны 05.09 при ревизии документации: батарея краснела дважды, разобрано до конкретного теста | +| PD-448 | bug | minor | `internal/runs/runs.go:201`=`ErrNotResumable` (отказ, который получает проигравший) и `internal/runs/control_test.go:881` `TestTwoResumesOfOneRunTakeOneHoldAndBothAnswer` (пин гарантии) | **ДВОЙНОЙ КЛИК ПО «ПРОДОЛЖИТЬ»: проигравший гонку получает ОТКАЗ вместо прогона, когда два вызова не успевают пройти проверку состояния в одном окне.** Гарантия зоны сформулирована в шапке самого теста: «Both calls pass the state check together and race for attempt N+1 on the unique index; the loser must answer with the run the winner re-opened, not with a not-found». Замер 05.09: под нагрузкой машины (`load average 42–48` на 8 ядрах, тринадцать чужих `tmmutate`) вызовы СЕРИАЛИЗУЮТСЯ — победитель успевает перевести прогон в `translating` до того, как проигравший дойдёт до своей проверки, — и проигравший получает `runs: the run cannot be continued: it is translating` (`control_test.go:863`). ⚠ **Пользовательский смысл: человек, дважды нажавший «продолжить», видит ошибку, хотя прогон в этот момент СТАРТОВАЛ.** ⚠ **Деньги не затронуты:** отказ приходит ДО взятия холда, так что второй холд не берётся — та половина гарантии («ровно один холд») держится и на отказном пути. ⚠ **Это НЕ `PD-420`:** тот ряд про другой тест (`internal/pgstore/runs_test.go` `TestAClaimThatLostARaceToAReleaseIsRetriedAndNotAnError`); проверено грепом, имени этого теста в реестре не было ни в одном ряду. **Воспроизведение:** полный пакет `go test ./internal/runs/` при загруженной машине — упал 2 раза из 2; ИЗОЛИРОВАННО (`-run TestTwoResumesOfOneRunTakeOneHoldAndBothAnswer -count=5`) — 5/5 `ok` за 10 с. То есть окно существует и открывается голоданием по процессору, а не правкой кода. **Направление лечения (не решение):** проигравшему различать «прогон уже идёт, потому что его только что открыл параллельный резюм» от «прогон идёт сам по себе» — первое обязано вернуть прогон, второе законно отказывает. ⚠ **РАЗБОР ПАКА «ПРАВДА У ДВЕРИ» 11.09 (строить было запрещено; кода по ряду не писалось).** **Окно дефекта — НЕ гонка за уникальный индекс, а интервал между чтением состояния и взятием замка книги:** `Resume` читает прогон на `internal/runs/reconcile.go:1652`=`l, err := s.Store.ReadRunForResume(ctx, userID, runID)`, замок берёт на `:1663`, а судит по `l.Status` на `:1696` — по значению, прочитанному ДО замка. Отсюда ДВА разных проигравших: прочитавший устаревший `stopped` доходит до `reopen`, падает на уникальном индексе попытки и получает ПРОГОН (`reconcile.go:1736`, холд не берётся — транзакция откатывается); прочитавший уже переведённый `translating` падает в `default:` и получает отказ — это и есть дефект. Замок книги (`internal/runs/bank.go:400`) ВНУТРИПРОЦЕССНЫЙ, поэтому в одном демоне два резюма идут последовательно, а шапка теста («both calls pass the state check together») описывает деплой из ДВУХ экземпляров. **Замер 11.09 (не по памяти, инструментом):** изолированно `-count=10` — 0 красных из 10 (контроль: 10 строк `--- PASS` с именем теста); полный пакет на простаивающей машине — 0 из 3; `GOMAXPROCS=1` — 0 из 5; ПОД нагрузкой (32 CPU-жруна, load 29–50) целевое подмножество — **4 красных из 20**, все с одним текстом `control_test.go:898: resume 1: runs: the run cannot be continued: it is translating`. ⚠ Полный пакет под нагрузкой до конца не прошёл ни разу (4 попытки уперлись в `-timeout 9m` на ДРУГИХ тестах), так что прежняя запись «упал 2 из 2 полным пакетом» сегодня НЕ пере-проверена — воспроизводится подмножеством. **Три пути и цена:** (а) `Idempotency-Key` на `resumeRun` — контрактный минор, и предмет он закрывает НЕ полностью: при одновременных кликах второй получает `409 key_in_flight` (`internal/httpapi/idempotency.go:110`), прогоном отвечает лишь повтор; (б) claim-токен или колонка — МИГРАЦИЯ плюс суждение по временному окну, то есть лотерея под другим именем (сегодня различить нечем: `run_attempts` несёт только `id · run_id · attempt_no · engine_run_id · started_at · ended_at · exit_code · last_seq`, `internal/pgstore/migrations/00002_readmodel.sql:72-87`); (в) перенести чтение под замок — ДЕТЕРМИНИРОВАННО хуже, проигравший тогда видит `translating` всегда. **РЕКОМЕНДАЦИЯ — четвёртый путь: сделать резюм идемпотентным по существу** — ветка `case "translating"` в том же switch отвечает ПРОГОНОМ, а не отказом, с единственным исключением «стоп уже запрошен» (`l.StopRequestedAt != nil`, `internal/pgstore/runs.go:322`). Тогда оба проигравших отвечают одинаково и гонки не остаётся; цена — одна строка кода и одна строка канонной таблицы, без миграции и без ключа. ⚠ Требует решения оркестратора: канон для `translating` обещает `409 run_not_resumable`, и это же расхождение канона с деревом (дерево уже отдаёт 202 проигравшему внутри окна) названо паком отдельным пунктом. ⚠ **ДИСПОЗИЦИЯ ОРКЕСТРАТОРА №23 (11.09): четвёртый путь ПРИНЯТ КАК НАПРАВЛЕНИЕ, строит его СЛЕДУЮЩИЙ платформенный пак, не пак «правда у двери».** Довод принятия — не тот, с которого начинался разбор: сомнение было в том, что идемпотентный `202` ломает канонную гарантию «202 означает, что работа реально пере-открыта», а проверка показала, что зона эту гарантию уже НЕ держит и держать не собиралась — дерево отдаёт `202` проигравшему, у которого пере-открыл не он, и это закреплено шапкой самого теста («the loser must answer with the run the winner re-opened»). То есть выбор не между починкой и обходом, а между двумя смыслами глагола, и один уже выбран зоной. Критерий, по которому это лечение: обход оставляет ответ зависимым от интерливинга и молчит об этом, лечение делает ответ ИНВАРИАНТНЫМ К ПОРЯДКУ — два одновременных резюма на `stopped` дают один наблюдаемый результат при любом порядке. **ТРИ ВЕЩИ ЕДУТ ВМЕСТЕ С ОДНОЙ СТРОКОЙ, и без них правка неверна:** (1) **вырез обязателен** — `translating` с УЖЕ ЗАПРОШЕННЫМ стопом остаётся 409, потому что «продолжается» там ложь; предикат `l.StopRequestedAt != nil` (`internal/pgstore/runs.go:322`=`StopRequestedAt *time.Time`), причина открытым словарём, предложено `stop_requested` — поле того же имени уже на проводе (`internal/httpapi/v0.go:242`=`StopRequested bool`). (2) **Проверка re-pass стоит ДО switch и должна пропускаться после новой ветки:** `internal/runs/reconcile.go:1687`=`if l.OrderedChapters == 0` против свитча на `:1696` — иначе двойной клик по re-pass снова получит отказ, то есть предмет ряда закроется для одной покупки и останется для другой. (3) **Остаток окна, который правка НЕ закрывает и который надо НАЗВАТЬ, а не строить под него:** `stop_requested` читается в том же незамкнутом снимке, а `Stop` замка книги не берёт вовсе (`internal/runs/reconcile.go` `Stop`, первый же оператор — `s.Store.RequestStop`), поэтому стоп, нажатый между снимком и ответом, даст один `202 continuing` на сворачивающийся прогон: миллисекунды, один кадр, денег не трогает. ⚠ **Канон НЕ двинут и это осознанно:** строка таблицы §`resumeRun` — минор `0.15.0`, а в `0.x` смена строки ломающая; бамп сегодня покрасил бы гейт версии на сданном дереве, где константа несёт `0.14.0`, поэтому канон и код едут ОДНИМ лендингом следующего пака. Расхождение до тех пор живёт названным, а не тихим.| open | замер зоны 05.09 при ревизии документации: батарея краснела дважды, разобрано до конкретного теста | | PD-443 | bug | minor | `internal/exports/exports.go` `Build`/`Sweep`, `internal/pgstore/queries/exports.sql`, миграция `00032_exports.sql` (`on delete cascade`) | **Артефакт экспорта может пережить СТРОКУ, которая одна умеет его найти, и тогда его не удалит никто.** Путь артефакта детерминирован (`//.`), но каталог со строками не сверяет ничто: GC удаляет только те пути, которые НЕСУТ строки. Три живых пути, найдены адверсариальным проходом по готовой работе: **(а)** демон убит между `rename` движка и `FinishExport` — путь в строку так и не попал, свип пере-водит её в `failed`, файл остаётся навсегда (не экзотика: `KillMode=mixed` в юните и дренаж очереди на 10 с делают это штатным исходом рестарта под нагрузкой); **(б)** книга удалена — `on delete cascade` сносит строки, файлы и каталог книги под `Dir` не трогает никто (сегодня у `DeleteBook` вызывающих нет, но внешний ключ уже стоит и ловушка взведена); **(в)** артефакт, чей unlink не прошёл, — ⚠ **ЭТОТ ПУТЬ ЗАКРЫТ ТЕМ ЖЕ ПАКОМ**: `path` теперь переживает смену состояния и снимается только после реального удаления (`UnlinkedExports`/`ForgetExportPath`), то есть unlink стал ПОВТОРЯЕМЫМ; пин `TestAFileTheSweepCouldNotRemoveKeepsItsRowPointingAtIt`. Четвёртый путь — временные файлы движка (`.<имя>.tmp-*`) после SIGKILL — тоже закрыт (`exports.Service.discard`), потому что `tmctl build` контекста не читает и его всегда добивает SIGKILL. ⚠ **Лечение (а) и (б) — одно и то же и это НЕ райдер:** сверка каталога со строками, то есть реконсилятор файловой системы со своим дизайном (что считать сиротой, как отличить чужой файл от своего, что делать с каталогом книги, которой нет). Делать его заодно с дверью значило бы решить мимоходом. **Радиус сегодня ограничен:** TTL двери — сутки по умолчанию, файлы лежат под одним каталогом деплоя, и оператор видит рост диска раньше, чем что-либо ломается. ⚠ **ДВА СИБЛИНГА, НАЗВАННЫЕ ПРИЁМКОЙ 04.09 — один закрыт, один остаётся здесь.** (I) STAGING-файл движка (`.<имя>.tmp-*`) переживает смерть демона ПОСРЕДИ сборки: `discard` живёт в том же процессе, поэтому при `kill -9` убирать его некому, а следующая сборка того же экспорта не случится — строка уже не `pending`. Это тот же класс, что (а), и лечится тем же реконсилятором каталога. (II) ⚠ **ВТОРОЙ ВОРКЕР НА ОДНОЙ СБОРКЕ — ЗАКРЫТО ТЕМ ЖЕ ДОФИКСОМ, а не только названо:** оба писали бы по ОДНОМУ пути (он выводится из id экспорта), и уборка проигравшего снесла бы файл, только что опубликованный выигравшим. Клейм сделан ИСКЛЮЧАЮЩИМ — `where … and state = 'pending' and started_at is null`, — поэтому второй воркер получает `ErrExportSettled` и до сборки не доходит. Сегодня недостижимо (River ведёт одно задание рода за раз), но именно это превращает вторую реплику из потерянного артефакта в дубль сборки. Пин — в `TestAQueuedBuildAndAClaimedOneAreJudgedOnDifferentClocks`. Воспроизведение (а): `kill -9` демона между строкой лога `export built` и следующей записью в БД. | open | адверсариальный проход пака «закрыть цикл» по своей же работе, 04.09 | -| PD-444 | bug | minor | `internal/httpapi/bank.go:142`=`Invalid(w, r)` (ветка строгого разбора) против `internal/httpapi/bank.go:146`=`Invalid(w, r, items...)` (ветка валидации); механизм — `internal/httpapi/problem.go:181`=`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:1025`=`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-444 | bug | minor | `internal/httpapi/bank.go:142`=`Invalid(w, r)` (ветка строгого разбора) против `internal/httpapi/bank.go:146`=`Invalid(w, r, items...)` (ветка валидации); механизм — `internal/httpapi/problem.go:195`=`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-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:484`=`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: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-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:581`=`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:578`=`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: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-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:249-257` «what it must not buy is this platform deciding on its own»). Проба end-to-end на стенде, восемь проходов при отказывающем движке: `status=translating unit="" failures=8`, гейдж 1, `reserved=0.300000` — прогон висит и деньги заморожены, пока не придёт человек. Плюс возврат холда только с флагом: без `--release-hold` холд ждёт до 30 минут (`PD-391`). Значит сужать строку до «клина с юнитом или базовой линией» НЕЛЬЗЯ: беспризорный деплой на удалённом каталоге по-прежнему висит вечно ⚠ Границы улики: числа пробы (`failures=8`, гейдж 1, `reserved=0.300000`) сняты рефутером на его стенде, и ЛОГ прогона в артефакты не попал — пере-ранить их приёмка не сможет, воспроизводить придётся по описанию. Механизм при этом проверяем чтением: `internal/pgstore/runs.go:721`=`select finished_at from runs where id = $1` ⚠ якорь пере-нацелен паком P11: прежняя строка отвечала `ErrNoRun` законченному прогону — она и была дефектом; теперь это ветвление, читаемое ПОД блокировкой книги | 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: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:707`=`func (s *Service) sourceThere` зовётся из `Start` (после предиката книги, перед чтением счёта) и из `Resume` (`internal/runs/reconcile.go:1721`, перед `reopen` — измерено, что без него резюм над пропавшим каталогом БРАЛ холд остатка бюджета и возвращал прогон). Две беды разведены сентинелом `PD-192` и отвечают разными словами: пропал каталог КНИГИ — `runs.ErrSourceGone` (409 `book_not_ready` + причина `source_gone`), пропал КОРЕНЬ хранилища — `runs.ErrStorageUnavailable` (503, вина деплоя, и книгу она не винит); предикат корня не скопирован, а вызван — `internal/books/books.go:574`=`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`.| 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) | @@ -72,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`), а не молчать | open | второй круг пака «форма заказа», 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:655`=`func startable`, и у него ДВЕ привязки разной авторитетности: `Start` зовёт его под замком книги (решает), `Order` — на опрашиваемом пути (`internal/runs/runs.go:304`=`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 @@ -81,7 +82,7 @@ | PD-465 | bug | info | `internal/pgstore/runs.go` (греп `ProofSpawns`), `internal/runs/spawn.go` (греп `RecordSpawn` и `Runner.Start`) | **ТРЕТИЙ СПОСОБ УЕХАТЬ ПОД ДОКАЗАТЕЛЬСТВОМ `run abandon` НЕ ЛОВИТСЯ: заявка на спавн коммитится ДО подъёма юнита.** Арбитр, построенный паком «деньги и правда на экране», сверяет id живой попытки и монотонный счётчик заявок под книжной блокировкой и закрывает два способа из трёх. Третий: `RecordSpawn` пишет `unit_name` и инкрементирует `spawns` одним оператором, а `Runner.Start` идёт СЛЕДУЮЩЕЙ строкой — доказательство, снятое между ними, читает счётчик УЖЕ увеличенным и слышит от systemd «юнита нет», потому что юнит ещё не создан; обе сверки сходятся, и списание попадает на попытку, чей юнит вот-вот поднимется. ⚠ Окно — миллисекунды между двумя операторами одной функции, и достижимо только если оператор снимает доказательство ровно в них. Цена та же, что у всякого неверного доказательства: живой движок тратит против своего книжного потолка, холд аккаунта закрыт, провайдеру платит деплой (ограничено потолком этого прогона, аккаунтом не эксплуатируемо). ⛔ **Лечение требует НОВОГО состояния — «заявка в полёте», которого сегодня не пишет никто**, поэтому пак его не строил: заморозка скоупа, и добавление способности — не закрытие находки. Кандидаты: писать `spawns` ПОСЛЕ подъёма юнита (меняет смысл счётчика на «успешно поднятых»), либо отдельная отметка, снимаемая на `Start`/`unclaim`. Найдено приёмкой оркестратора №23 по коду | open | приёмка пака «деньги и правда на экране», 07.09 | | PD-464 | bug | minor | `internal/books/parse.go` (греп `const UploadSettle`), бутовый гейт `internal/config/config.go` (греп `UploadSettle`) | ⛔ **КОНСТАНТА ОБЪЯВЛЯЕТ ИНВАРИАНТ, КОТОРОГО НЕ ДЕРЖИТ, И ТРИ ВЫВОДА ЕЁ ЗНАЧЕНИЯ РУКАМИ ПОДРЯД БЫЛИ НЕВЕРНЫ.** `UploadSettle` обещает покрыть всё, что загрузка делает ПОСЛЕ тела, и бутовый гейт на этом обещании допускает дедлайн. Замер 07.09 (двоичный поиск по `Load()`, хвост выведен ПО КОДУ пути): объявлено **3m30s**, реальный худший хвост **3m40s** (`StartParsing` 30 + разрез 90 + `FinishParse` 30 + `ReleaseParseClaim` 30 + `ReadBook` 30 + квитанция 10), недобор **10 с**. Гейт принимает дедлайн до **26m29s**, ложь начинается с **26m21s** — окно шириной восемь секунд, достижимое только ручной настройкой почти вплотную к потолку гейта; на дефолте `10m` запас **16m20s**. ⚠ **Наблюдаемое следствие — ДУБЛЬ КНИГИ, а не потеря:** претензия на ключ идемпотентности, пережившая `ClaimStale`, перехватывается повтором с новым токеном (`internal/pgstore/idempotency.go`, ветка сравнения возраста претензии с `ClaimStale`), и человек, повторивший «висящую» загрузку, получает две книги вместо реплея одной. ⛔ **ЛЕЧЕНИЕ — НЕ ПОДНЯТЬ ЧИСЛО:** оно выводилось руками трижды и трижды было неверным, каждый раз по новой причине (пропущен шаг · неверный бюджет квитанции · два последовательных шага записаны как альтернативы), поэтому четвёртый вывод руками — подпорка, а не починка (`D39.216`). Границу надо выводить ИЗ кода пути. ⚠ Достижимость худшего пути БЕЗ искусственного замедления **не измерена**: она требует конъюнкции «большая книга» и «три полных `writeBudget` подряд», а гейт живого движка на хосте замера не закрыт. Первый из семи пунктов, с которыми синхронный разрез уезжает отдельным паком ⚠ **ДИСПОЗИЦИЯ ПАКА «РАЗРЕЗ ПРИЁМА ДО ГОТОВНОСТИ» (08.09, ЗАЛАНДЁН `ddcbf0c`, акт `D39.229`): ЗАКРЫТО ОБЕИМИ ПОЛОВИНАМИ.** (1) Значение приведено к замеру: квитанция стала ТЕРМИНОМ суммы, `UploadSettle = CutBudget + 4*writeBudget + ReceiptBudget` = **220 с** — ровно тот худший хвост, что намерен слева; носитель у величины квитанции теперь ОДИН (`books.ReceiptBudget`, тратит её `httpapi.settleCtx`), и разойтись им нечем. (2) ⭐ И это НЕ то, что закрывает строку, потому что четвёртый вывод суммы руками был бы четвёртой заплатой: хвост целиком идёт под ОДНИМ отсоединённым дедлайном (`books.walk`), каждый шаг берёт `min(свой бюджет, остаток хвоста)` (`books.step`), и добавленный завтра шаг границу НЕ ДВИГАЕТ ПО ПОСТРОЕНИЮ — сумма теперь отвечает лишь за то, чтобы в обычном худшем случае ни один шаг не был урезан. Предъявлено: `TestNoStepOfAnUploadsTailOutlivesTheWalk` (в т.ч. 50 вложенных шагов), `TestTheCutOfAnUploadIsBoundedByTheWalkAndNotByItsOwnBudget`, `TestTheWalkLeavesTheReceiptItsShareOfTheSettleBudget`, `TestTheReceiptSpendsTheShareTheIntakeSetAsideForIt`; посадки M1/M2/M3/M7 красные с топичным текстом. ⚠ Попутно закрыт СОСЕДНИЙ промах того же гейта, которого строка не называла: он сверял дедлайн с `min(UploadGrace, ClaimStale)` и пропускал `ClaimGrace` — свип мог забрать claim у книги, чья загрузка ещё идёт (п.7 десятки). Окон в гейте теперь ТРИ. | fixed | пак «деньги и правда на экране», круг 5 самопроверки, 07.09 | | PD-463 | bug | minor | `internal/books/parse.go` (греп `ChaptersTotal < 2 && atIntake`), контракт `RejectReason` | **ВХОД СУЖЕН ДО ТОГО, ЧТО УМЕЕТ ВЫДАЧА, ТОЛЬКО НА СИНХРОННОЙ ВЕТВИ ПРИЁМА.** Отказ книге, которую движок нарезал в одну главу, стоит под условием `atIntake`: на ветви, куда книга уходит при деплойном классе или превышении бюджета разреза, тот же манифест доезжает до `FinishParse`, и «книга одним полотном» попадает в библиотеку молча. ⚠ **Закрыть на асинхронной ветви СЕГОДНЯ НЕЧЕМ, и это не недосмотр:** словарь `RejectReason` контракта закрыт четырьмя значениями, и ни одно не описывает «прочли, нарезали, а такую форму мы пока не отдаём» — ближайшее по словам `source_unreadable` ТЕРМИНАЛЬНО и УДАЛЯЕТ исходник, то есть уничтожает файл пользователя из-за НАШЕГО ограничения. ⇒ нужен либо пятый `RejectReason` (контрактный минор, зона оркестратора), либо решение, что асинхронная ветвь остаётся проницаемой. Достижимость узкая: ветвь берётся только когда синхронный разрез не дал вердикта. Найдено опровергателем по готовой работе | open | пак «деньги и правда на экране», 07.09 | -| PD-459 | bug | info | `internal/runs/reconcile.go:327`=`const settlementBackoffCap`, пины `internal/runs/stuck_settlement_test.go:202,582` | **Потолок отсрочки расплаты НЕ ЗАПИНЕН ЗНАЧЕНИЕМ: оба теста, которые его читают, сравниваются С САМОЙ КОНСТАНТОЙ.** Замер 06.09: `grep settlementBackoffCap` даёт 6 хитов в `reconcile.go` (строки 157, 162, 172, 309, 312, 327) и 4 в тестах, и в обоих тестах он стоит по обе стороны сравнения (`wait > settlementBackoffCap`) ⇒ мутация значения константы не краснит ничего. Константа несёт продуктовое свойство — отсрочка расплаты и есть resume-гейт пользователя, и её удлинение держит человека за блокировку, с которой он ничего сделать не может (довод в самом комментарии константы). ⇒ нужен пин на ЧИСЛО, а не на границу. Найдено чтением журнала при выносе эры P9–P13 | open | зона, 06.09, триаж журнала | +| PD-459 | bug | info | `internal/runs/reconcile.go:360`=`const settlementBackoffCap`, пины `internal/runs/stuck_settlement_test.go:202,582` | **Потолок отсрочки расплаты НЕ ЗАПИНЕН ЗНАЧЕНИЕМ: оба теста, которые его читают, сравниваются С САМОЙ КОНСТАНТОЙ.** Замер 06.09: `grep settlementBackoffCap` даёт 6 хитов в `reconcile.go` (строки 157, 162, 172, 309, 312, 327) и 4 в тестах, и в обоих тестах он стоит по обе стороны сравнения (`wait > settlementBackoffCap`) ⇒ мутация значения константы не краснит ничего. Константа несёт продуктовое свойство — отсрочка расплаты и есть resume-гейт пользователя, и её удлинение держит человека за блокировку, с которой он ничего сделать не может (довод в самом комментарии константы). ⇒ нужен пин на ЧИСЛО, а не на границу. Найдено чтением журнала при выносе эры P9–P13 | open | зона, 06.09, триаж журнала | | PD-460 | bug | info | `cmd/tmplatformctl/seed.go:44`=`usd := fs.String("usd", "25",` | **`seed` по умолчанию начисляет $25 и на стенде с ЖИВЫМИ ключами провайдера.** Команда дев-интейка не различает стенд с заглушкой и стенд, поднятый против настоящих моделей, а $25 на таком стенде — это разрешение потратить их. Кандидат лечения, названный ещё при наблюдении за живым потоком: дефолт 0 и обязательный `--usd`. ⚠ Не срочно и не эксплуатируемо аккаунтом: команда админская и требует доступа к БД. Найдено чтением журнала при выносе эры | open | зона, 06.09, триаж журнала | | PD-461 | bug | info | `docs/DEFECT_REGISTER.md` ряды `PD-99` и `PD-197`; гейт `docs/scripts/counts.py --check` | **ЧЕТЫРЕ СТРОКИ РЕГИСТРА НЕСУТ ЛИШНИЕ ПОЛЯ, И ГЕЙТ ФОРМЫ ИХ НЕ ВИДИТ.** Замер 07.09 (`awk` по разделителю полей): `PD-99` — 9 полей, `PD-197` — 8, `PD-375` — 9, `PD-422` — 11, контрольная строка `PD-441` — 7, как и шапка; то есть строк ЧЕТЫРЕ, а не две — первая редакция этого ряда назвала две, потому что искала только среди рядов, которые читала. Причина — неэкранированный вертикальный разделитель внутри бэктиков в теле ячейки; ⚠ первая редакция ЭТОГО ряда несла его сама (9 полей) — цитата команды с разделителем внутри бэктиков и есть тот же дефект, и он воспроизвёлся на строке, которая его описывает; `counts.py --check` печатает `битая форма: []`, то есть считает по форме строки и лишние поля за дефект не держит. Следствие ограничено (счёт по номерам не врёт), но любая будущая проверка ПО КОЛОНКАМ на этих двух строках прочтёт не то поле. Найдено чтением журнала при выносе эры | open | зона, 06.09, триаж журнала | | PD-462 | hardening | info | `internal/runs/*_test.go` фикстуры с рукописным `insert into`; гейт `internal/pgstore/sqlgate_test.go` | **ФИКСТУРЫ ПАКЕТА `runs` ПИШУТ КОЛОНКИ `pgstore` РУКОПИСНЫМ SQL, И ЭТОТ SQL НЕ СУДИТ НИ ОДИН ГЕЙТ.** `sqlgate` планирует против схемы только операторы пакета `pgstore`, поэтому фикстура, отставшая от миграции, краснеет не сообщением о схеме, а поведением теста, который она готовила. Цена сегодня мала (фикстуры правятся вместе с миграцией), растёт с числом колонок. Найдено чтением журнала при выносе эры | open | зона, 06.09, триаж журнала | @@ -93,7 +94,7 @@ | 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:14`=`const canonPath = zoneRoot + "/../docs/architecture/14-api-contract/openapi.yaml"`, `docs/ENGINEERING_STANDARDS.md` §3, промты ревью-паков зоны | **Копия `platform/`, вынутая из репозитория, даёт красную батарею по причине, не имеющей отношения к коду.** Гейт контрактной версии читает канон по пути ВЫШЕ модуля (`internal/gates/contract_test.go:14`=`const canonPath = zoneRoot + "/../docs/architecture/14-api-contract/openapi.yaml"`) и при его отсутствии `t.Fatalf`, а не сообщает, что запущен вне репозитория. Стоило времени КАЖДОМУ, кто работал в копии — все девять субагентов пака и координатор, — и один агент едва не завёл ложный красный находкой. ⚠ **ДИСПОЗИЦИЯ 24.08: лечение — РЕЦЕПТ, а не гейт. Три довода, каждый проверен исполнением:** **(1)** носители названы шире, чем есть — `ENGINEERING_STANDARDS` §3 копий на момент находки НЕ требовал (`grep -ci 'копи'` давал 0; ПОСЛЕ правки этого пака даёт 6 — рецепт туда и записан, см. ниже), норма копий живёт только в промте ревью-пака и в `D39.113`; значит сталкиваются не гейт и стандарт зоны, а гейт и РЕЦЕПТ ОДНОГО ПРОМТА. **(2)** «точечное решение, а не стиль» — НЕВЕРНО: в `backend/internal/standdata/standdata.go` живёт целый репо-паттерн чтения выше корня модуля с поиском корня по МАРКЕРУ и env-переопределением, и его потребители читают даже чужую зону (`internal/miner/miner_parity_test.go` читает `eval/`). Платформа реализовала тот же паттерн грубее — голым счётом `..`, — и комментарий `standdata` объясняет, чем именно это хуже. **(3)** довод «модуль обязан быть самодостаточным» к этой зоне не применяется: её собственный DoD определяет полную приёмку через ТРИ ВНЕШНИХ условия. Ценность зоны — не герметичность, а громкий учёт непроверенного. **ЛЕЧЕНИЕ — РЕЦЕПТ, А НЕ ГЕЙТ** (сам рецепт и его довод — `ENGINEERING_STANDARDS` §3 п.3). Второй, необязательный шаг: резолвить канон маркер-обходом по образцу `standdata.Root()` и дописать в сообщение `Fatalf` вторую гипотезу «ты вне репозитория» — это убивает стоимость повторной диагностики и остаётся падением, а не скипом. **ОТВЕРГНУТО с доводами:** гейт-переменная батареи с названным скипом (вне `make` гейт выключался бы сам — зона уже осудила эту форму словами `internal/gates/toolchain_test.go:55-57` «the Makefile is a convenience… a build that skips the battery gets a toolchain the battery would have refused»; плюс это легализует поломку рецепта как «условие среды») · переезд проверки на репо-уровень в `counts.py` как ЗАМЕНА (единственная репо-точка принуждения НИКОГДА не блокирует, только предупреждает — гейт остался бы без зубов; как ВТОРАЯ сеть законен) · кодоген из спеки (посылка протухла: `oapi-codegen` пере-подписан `D39.132` в КАНДИДАТА и P7 решил НЕ БРАТЬ с доводом; и класс он не убивает, а переносит — генерат коммитится внутрь модуля, то есть второй источник истины) · вендорить снапшот канона в модуль (то же самое) ⚠ **Рецепт вместе с доводом про маскировку дельты и правилом топичности вердикта записан пунктом 3 в `ENGINEERING_STANDARDS` §3** — долговечный зонный носитель, а не промт пака (промты после лендинга архивируются) | open | ревью-пак P8-REVIEW (координатор пака, цена замерена этой же сессией) | +| PD-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-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 (сверка) | @@ -117,7 +118,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`. Диспозиция по-прежнему нужна одна на класс | 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:707`=`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:301`=`runs: stat journal`, и на путях реконсилятора гарда нет вовсе.| 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 | самопроверка дофикса (ревью вне карты) | @@ -147,7 +148,7 @@ | PD-408 | doc | info | `internal/runs/bank.go` (бюджет двери = `s.runBudget()`), `internal/runner/bankapply.go` (`errOut` без лимита; `Stderr: firstLine`) | **Две операционные оговорки двери правок, названные воркфлоу-ревью; обе — цена конфигурации, не дефект пути.** (1) Бюджет двери — та же ручка `TM_PLATFORM_RUN_BUDGET`, что у прохода свипа; движок выбирал потолок 5000 решений против ЖЁСТКИХ 60 с («пять раз внутри бюджета»), и оператор, понизивший ручку (к чему соседние комментарии подталкивают), делает легальный документ-максимум навсегда неприменимым — вечный `503` вместо «разбей документ»; связка ручки и капа нигде не названа. (2) stderr глагола читается в НЕограниченный `bytes.Buffer`, хотя потребляется только первая строка, — не-тот бинарь по сконфигурированному пути (полудеплой, обёртка) может раздуть демона до OOM за 60-секундный бюджет; stdout той же команды капнут 64 МиБ | open | воркфлоу-ревью P9 28.08 (линзы door:lock-lifecycle · door:crash-windows), диспозиция оркестратора 28.08: строкой | | PD-428 | doc | info, деньги | `internal/pricing` (`TM_PLATFORM_USD_PER_CHAPTER`, `Pricing.Ceiling`) | **Цена продажи не знает о накладных, которые масштабируются КНИГОЙ, а не грантом.** Замер движкового охотника (лендинг `6ec9f8a`): терминолог переигрывается на КАЖДОЙ покупке ЦЕЛИКОМ по книге — три покупки по одному юниту дали три полнокнижных консолидации по $0.005460 каждая, при том что сам юнит дешевле. То есть книга на 500 юнитов, проданная по одному, оплатит 500 полнокнижных проходов. ⚠ **Сегодня это НЕ дефект платформы и заведено только как калибровка:** продажа идёт ГЛАВАМИ (`Ceiling(chapters)`), а не юнитами, так что нарезки, при которой накладные обгоняют полезную работу, в продукте нет. Строка существует, чтобы факт не потерялся к моменту, когда мелкая нарезка появится: любая будущая единица продажи мельче главы обязана нести в цене эту книжную составляющую, иначе COGS растёт быстрее выручки на самых дешёвых покупках. Носитель — константа цены, а не код движка ⚠⚠ **НОСИТЕЛЬ УМЕР И ПОСЫЛКА ПЕРЕВЕРНУЛАСЬ — паком «форма заказа» 05.09, строка пере-написана ПО СУЩЕСТВУ.** Названные тут `TM_PLATFORM_USD_PER_CHAPTER` и `Pricing.Ceiling(глав)` удалены вместе со ставкой (строка бэклога 280). ⚠ И оговорка ряда «сегодня это НЕ дефект платформы: продажа идёт ГЛАВАМИ, так что нарезки, при которой накладные обгоняют полезную работу, в продукте нет» — **больше не верна**: этот же пак ввёл заказ В ЗНАКАХ, который разрешается в ПРЕФИКС ЮНИТОВ, то есть единицу МЕЛЬЧЕ главы, и ровно её ряд и ждал. **Что с этим сделано и чего НЕ сделано, раздельно.** Книжная составляющая ТЕПЕРЬ ВИДНА и названа: движок публикует её отдельным числом `book_once_usd` (плоские $2.00 на боевом `pipeline-c1`), платформа читает его и НЕ кладёт в основу холда — иначе короткая книга непокупаема, — а кладёт СВЕРХ, когда баланс несёт, и говорит покупателю `term_consistency_funded: false`, когда не несёт (`pricing.Model.Hold`, пин `TestAFlatBookLevelBoundDoesNotPutATwoDollarThresholdUnderEveryPurchase`). **НЕ сделано главное, ради чего ряд заведён:** цена мелкой покупки по-прежнему не несёт книжной составляющей ПРОПОРЦИОНАЛЬНО — заказ в один юнит и заказ во всю книгу видят один и тот же бонд, поэтому COGS на самых дешёвых покупках растёт быстрее выручки ровно так, как ряд и предупреждал. Ряд остаётся `open` и с этого дня ПРЕДМЕТЕН, а не гипотетичен. Носитель — `pricing.Model.Hold` и `ingest.BookPrice.BookOnceUSD`. | open | движковый пак «деньги» (охотник), передано оркестратором №19 сессии P11 | | PD-421 | hardening | info | `internal/pgstore/sessions.go` `StillLive` и `SweepSessions`, `docs/STACK_DECISIONS.md` §13 | **Открытый поток теряет свою сессию по ПОДМЕТАНИЮ строки, а не по клаузе бездействия, — и это остаток закрытия `PD-379`, названный прямо.** Проверка живости потока намеренно НЕ содержит клаузы `idle_expires_at`: окно бездействия скользит на ЗАПРОСЕ, а поток — один запрос на всю жизнь, поэтому гашение по idle рвало бы связь активному читателю. Но `SweepSessions` раз в час УДАЛЯЕТ строки и по бездействию тоже, а «строки нет» ОБЯЗАНО значить «мертва» — иначе отозванная сессия держала бы поток до свипа, то есть дыра ровно в час. Следствие: сессия, протухшая по бездействию и подметённая, теряет поток с опозданием до часа. Это не idle гасит поток, а отсутствие строки; к тому моменту любой другой запрос того же вызывающего — 401. Лечение, если сочтётся недопустимым, — скольжение окна бездействия ИЗ потока, но это правка ПОЛИТИКИ §13: открытая вкладка держала бы сессию до абсолютного потолка, а это слово владельца | open | пак P11 (назван при закрытии `PD-379`) | -| PD-422 | bug | info | `internal/runs/runs.go:408`=`resnapshot := book.BankMoved`, `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:436`=`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-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, при закрытии смены | @@ -556,8 +557,8 @@ | 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-384 | bug | minor | `internal/runs/reconcile.go:278`=`case overran:` (`settleOne`) ⚠ якорь пере-нацелен паком P11: прежнее `if !overran {` было САМИМ дефектом — судить по цене вместо вердикта — и заменено свитчем по вердикту, `internal/runs/reconcile.go` `settle` (три тихих `return nil`) | **Расчёт денег, упавший ДЁШЕВО, не считается никогда: порог `StalledAfter` для него недостижим.** Вторая фаза считает неудачу ТОЛЬКО по исчерпанию бюджета (`overran := errors.Is(item.Err(), context.DeadlineExceeded)`, дальше `if !overran { return }`), а `settle` возвращает nil БЫСТРО в трёх случаях: движок не ответил, в отчёте нет committed, попытка без базовой линии. Каждый может быть ПОСТОЯННЫМ — запиненный бинарь движка снесён при выкате, проект заменён под платформой, попытка старой схемы. Тогда цикл вечен: `reconcile_failures` остаётся 0, `reconcile_after` NULL, гейдж и `tmplatformctl runs --stalled` пусты, холд заморожен. Замерено пробой: пять проходов одного нерассчитываемого прогона дали 5 вызовов движка, `reconcile_failures=0`, `StalledRuns(5)=0`. Плюс цена: `settle` зовёт `tmctl status` НА КАЖДОМ проходе без рейт-лимита, тогда как соседний `maybeResync` имеет `dueForResync` ровно из-за этой цены. ⚠ Рефутер сузил вес major → minor: холд ВИДЕН двум поверхностям, которых финдер не спросил — гейдж `tm_platform_oldest_open_hold_seconds` и `tmplatformctl balance --user`, печатающий каждый открытый холд суммой, книгой и id прогона; плюс каждый проход пишет WARN с id прогона. Воспроизведение: `docs/p8-review/axis3-queue/probe_settlement_surface_test.go.txt` ⚠ **Общий корень с `PD-385`, и там же он взвешен:** сужение ЭТОЙ строки опирается на операторскую поверхность, несостоятельность которой доказывает соседняя строка того же пака — круговое сужение разобрано в `PD-385`, поднятой до major ⚠ **ЗАКРЫТО ПАКОМ P11 (29.08).** Неудачей считается ВЕРДИКТ расчёта, а не только исчерпание бюджета: `settle` вернул три состояния (закрыт · гонка · не вычислим), `settleOne` судит по ним, поэтому все три тихих `return nil` теперь доходят до порога. Цена, названная строкой, закрыта тем же ходом: отсрочка ограничивает `tmctl status` вместо вызова каждым проходом. ⚠ Первая неудача НЕ откладывается — открытая резервация это ворота РЕЗЮМА пользователя (`reopen` отказывает, пока холд предыдущей попытки открыт), и минута ожидания после секундной аварии была бы регрессом; бэкофф идёт со второй и капнут пятью минутами, а не тридцатью. Посадка `r_firstfast` | fixed(пак P11 — лендинг оркестратора; статус проставлен зоной) | ревью-пак P8-REVIEW, ось 3 (проба на реальном сторе, сужено рефутером) | -| PD-391 | bug | minor | `internal/pgstore/sink.go:724`=`where a.reconcile_after is null or a.reconcile_after <= $1`, `internal/pgstore/runs.go` `AbandonRun`, `cmd/tmplatformctl/runs.go` (сообщение), `deploy/README.md` | **`run abandon` не возвращает холд «на ближайшем свипе»: отсрочка застрявшей попытки остаётся, и деньги ждут до 30 минут.** `AbandonRun` завершает прогон и попытку, но `run_attempts.reconcile_after` не трогает, а `UnsettledRuns` по нему фильтрует. Застрявший прогон по построению всегда отсрочен: `deferItem` ставит `now + backoff(failures+1)`, а `backoff` при пяти неудачах упирается в потолок 30 минут. Значит для ВСЕЙ популяции, ради которой команда построена, обещание CLI «its hold comes back whole on the next sweep» и та же фраза рантбука ложны: кредит остаётся вычтенным, `tm_platform_oldest_open_hold_seconds` продолжает расти ПОСЛЕ действия оператора, и оператор читает это как «я сделал, не помогло». Пин `PD-361` доказывает свойство слабее: он берёт прогон, созданный `Start` и брошенный СРАЗУ, у которого `reconcile_failures` 0 и `reconcile_after` NULL. Живая проба на состоянии, выращенном штатным механизмом: через 45 секунд и три свипа холд открыт, `balance` печатает «reserved 3.000000», гейдж 2439 с; ручной `update run_attempts set reconcile_after = now()` закрывает холд в тот же свип. Воспроизведение: `docs/p8-review/axis4-metrics/20-abandon-keeps-the-hold.sh` и `r3-abandon-hold.sh` ⚠ **ЗАКРЫТО ПАКОМ P11 (29.08).** `AbandonRun` снимает `reconcile_after` в ОБЕИХ ветках, живой и расчётной. Пин растит прогон до пяти неудач штатными путями и сверяет холд ПОСЛЕ свипа на НЕДВИНУТЫХ часах — то есть исполняет ровно то обещание CLI, которое было ложным. Названные строкой носители обещания исправлены: `deploy/README.md` и сообщение команды. Посадка `m391_defer` — поймана топично | fixed(пак P11 — лендинг оркестратора; статус проставлен зоной) | ревью-пак P8-REVIEW, ось 4 (живая проба, подтверждено рефутером на состоянии из штатного пути) | +| PD-384 | bug | minor | `internal/runs/reconcile.go:311`=`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-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, линза денег под гонкой) | diff --git a/platform/docs/STACK_DECISIONS.md b/platform/docs/STACK_DECISIONS.md index cc667b46..76bf7b8a 100644 --- a/platform/docs/STACK_DECISIONS.md +++ b/platform/docs/STACK_DECISIONS.md @@ -478,8 +478,16 @@ sed -e 's#^pipeline: ../configs/#pipeline: /backend/configs/#' \ /backend/example/book.yaml > $W/book-template.yaml ``` -⚠ Относительные `pipeline:`/`models:` — единственное отличие шаблона от репо-оригинала; без правки -движок не находит конфиги из чужого рабочего каталога. +⚠ Эти два пути — единственное отличие шаблона от репо-оригинала, и сторона у них такая: в ОРИГИНАЛЕ +они относительные (`../configs/…`), а в ШАБЛОНЕ обязаны стать АБСОЛЮТНЫМИ. Относительный путь +резолвится от рабочего каталога КНИГИ, а конфигов там нет, поэтому движок отвечает «не могу прочитать +файл моделей» — и падает не тест, а замер. ⚠ Прежняя редакция этой строки говорила «относительные — +единственное отличие шаблона», то есть называла сторону наоборот; спешно прочитанная, она стоила +приёмке 11.09 восьми красных живых проб `internal/runner`, которые были дефектом шаблона, а не дерева. +Проверка на месте, если сомневаетесь: `grep -E '^(pipeline|models):' <шаблон>` обязан показать пути, +начинающиеся со слэша. Замерено 11.09 на рабочем шаблоне стенда: оба абсолютные, и +`books.TestTheRenderedConfigurationIsOneTheEngineActuallyLoads` с ним зелёный против свежесобранного +`tmctl`. **Гейты батареи — их ЧЕТЫРЕ, и без них `make check` МОЛЧА скипует ~290 тестов** (вся читающая модель, миграции и шов). «Зелёная батарея» без них не значит ничего, поэтому `check` сам печатает @@ -497,6 +505,23 @@ sed -e 's#^pipeline: ../configs/#pipeline: /backend/configs/#' \ `MemoryMax` — пробой не предсказывается вовсе (`PD-423`), его знает только сам тест `TestARunIsBoundedByItsOwnCgroup`. +⚠⚠ **И ещё ДВА условия, которых в перечне выше нет, а в выдаче они есть — замер 11.09, пак «правда у +двери».** Со всеми четырьмя гейтами поднятыми батарея дала `20 ok · 0 FAIL · 5 скипов`, и оба условия +этих пяти — ниже. Пишутся сюда потому, что этот раздел объявлен ЕДИНСТВЕННЫМ носителем числа, а сессия, +честно сверившаяся с ним, назвала бы «условие одно» там, где их два. +- **Артефакт контраста движка:** пять живых движковых проб `internal/runner` скипаются со словами «this + deployment's pipeline enables the bank contour and names `<репо>/backend/configs/mining-contrast.zh.txt`, + which is not on this host». Файл — чужой зоны, и его отсутствие не поломка платформы; носитель — + **строка бэклога 251** (артефакт контраста банкового контура), сверено по трекеру 11.09. + ⚠ И «пять скипов» НЕ значит «живая движковая половина недостижима»: с шаблоном, чьи + `pipeline:`/`models:` абсолютные, пробы `internal/runner` дают **42 PASS · 0 FAIL** при тех же + пяти скипах (замер приёмки №23, 11.09). +- **Локальный адрес провайдера занят:** `runner.TestTheSnapshotGuardIsLoudWithoutTheFlagsAndPassesWithThem` + умеет скипнуться ВТОРЫМ условием — `listen tcp 127.0.0.1:11434: bind: address already in use` (чужой + стенд на том же хосте). ⚠ Один тест с ДВУМЯ условиями скипа: какое он назовёт, решает состояние хоста + в эту минуту, и два честных прогона одной смены назвали разные. Считать условия по ОДНОМУ прогону + поэтому нельзя. + ⚠⚠ **Движковый бинарь второго гейта обязан быть СОБРАН ИЗ ТЕКУЩЕГО `backend/`, а не переиспользован со стенда** (`cd /backend && go build -o $W/tmctl ./cmd/tmctl`, `PD-432`). Цена пропуска названа замером: стендовый `tmctl` от 24.08 против сегодняшнего @@ -522,7 +547,12 @@ system_messages not found in type config.CapabilitiesConfig`. Диагноз с состояние `cgroup.subtree_control` среза `tm-runs.slice` в момент прогона — назван КАНДИДАТОМ и только: диагноз `PD-423` не установлен, и вносить его в рецепт как проверку нельзя. -Ожидание при всех четырёх: 20 пакетов (`go list ./...`), exit 0, **скипов 0**, линтер «0 issues». Замерено 29.08: с гейтами — 0 скипов на обоих деревьях; без них — exit 0 и **287 скипов на HEAD +Ожидание при всех четырёх: 20 пакетов (`go list ./...`), exit 0, линтер «0 issues» — и **скипов 0 +ТОЛЬКО при условиях 29.08**; сегодня их ПЯТЬ, и это не поломка. ⛔ Две разные величины в одном +разделе — ловушка, и вот развилка: число скипов держат не четыре гейта, а ещё и два условия, +которых на дереве 29.08 не было (артефакт контраста банкового контура — строка бэклога 251 — и +занятый локальный адрес провайдера). Оба названы выше, в блоке «И ещё ДВА условия»; читать надо +ЕГО, а не эту строку, и сверять счёт скипов с ним. Замерено 29.08: с гейтами — 0 скипов на обоих деревьях; без них — exit 0 и **287 скипов на HEAD `fbe6cf3`**, **304 на дереве пака P11** (пак добавил 17 пинов, гейченных тем же DSN). Число зависит от дерева, и переносить его между ними нельзя. diff --git a/platform/docs/platform-PROGRESS.md b/platform/docs/platform-PROGRESS.md index 048849f2..914c068e 100644 --- a/platform/docs/platform-PROGRESS.md +++ b/platform/docs/platform-PROGRESS.md @@ -49,6 +49,492 @@ committed_usd, which is what would let this side say "at most Y"». Движок чтение оценочных строк из потока. Промт ещё не написан — если у вас есть довод против любой из четырёх позиций, скажите ДО того, как я его напишу. +## ПРАВДА У ДВЕРИ — ОТЧЁТ (11.09, `textmachine-8e`) + +> Предмет: платформа говорила человеку не то, что произойдёт. Три ряда — **`PD-455`** (форма обещает +> старт, в котором дверь откажет) · **`PD-162`** (прогон над пропавшим каталогом берёт холд и висит +> вечно) · **`PD-448`** (двойной клик по «продолжить»; строить было ЗАПРЕЩЕНО, заказан разбор). +> Вход: HEAD `05f690e`, дерево зоны чистое, батарея на входе зелёная и снятая ДО первой правки — +> `MAKE-EXIT=0 · 20 пакетов ok · 0 FAIL · линтер 0 issues · 5 скипов`. Сессия не коммитит: дерево +> передано оркестратору №23. + +### Комплектность против заказа — по пунктам §4 промта + +| Пункт | Исход | Чем предъявлено | +|---|---|---| +| §4.1 `PD-455` — форма обязана знать про живой прогон | **сделано** | предикат с двумя привязками + `blocked.code: run_in_flight`; пины; живая проба на стенде | +| §4.2 `PD-448` — исследуй и предложи, НЕ строй | **разбор, не строил** | замер воспроизводимости, три пути с ценой, рекомендация, расхождение канона отдельным пунктом | +| §4.3 `PD-162` — отказ ДО денег | **сделано у ОБЕИХ денежных дверей**, ряд остаётся открытым остатком | пины с контрольным прогоном; живая проба; ряд пере-написан | +| §4.4 интейк-гигиена | **не делал** (подписанный пропуск) | одна строка про следующий предмет `PD-175` — ниже | +| §4.5 якоря регистра | **сделано**, и их оказалось не четыре, а пятнадцать | `counts.py --lint` до и после | +| §5 самопроверка исполнением | **сделано** | батарея · `make vuln` · мутации на изолированной копии · живая проба · адверсариальный проход | + +### `PD-455` — одно определение, две привязки + +Правило допуска по фактам КНИГИ стояло инлайном в `Start` тремя проверками (статус · материализованное +дерево глав · живой прогон), и форма заказа о них не знала: вердикт считался из остатка книги и баланса, +а `ErrRunInFlight` жил только у двери. Человек видел `covers_all`, жал и получал 409. + +Сделано: три проверки вынесены в **один предикат** `platform/internal/runs/runs.go:655`=`func startable`, +у него две привязки РАЗНОЙ авторитетности — `Start` зовёт его под замком книги (решает), `Order` зовёт +на опрашиваемом пути (`runs.go:304`, совещательно и заведомо устаревает). Мерило пака — «сколько +ОПРЕДЕЛЕНИЙ придётся тронуть, если правило изменится» — выполнено: одно. + +Три решения, которые я принял внутри этой свободы, и довод каждого: + +1. **`PricedBook` НЕ расширен.** Рядом с ним стоит письменное возражение (`platform/internal/pgstore/books.go:1476`: + «It is a second read beside ReadBookForRun rather than more columns on it, because the two answer + different questions of different shapes»). Форма делает ВТОРОЕ чтение — `ReadBookForRun`, ту самую + запись, которой судит дверь. Возражение этим исполнено, а не переступлено: две разной формы записи + остаются двумя, и определение по-прежнему одно. Цена, которую я принял: +1 запрос на КАЖДЫЙ опрос + формы (рядом уже три чтения, одно из них с пер-главным агрегатом по `units`). Это моё суждение, а не + замер: бенчмарка я не снимал. +2. **`verdict` не тронут.** Он отвечает про ДЕНЬГИ, и баланс книгу действительно покрывает; четвёртое + значение закрытого enum сломало бы генерённых клиентов. Рост пошёл через `Blocked.code`, который канон + предавторизует. +3. **Порядок двух причин в `blocked`.** Член один, кандидатов два, и они не равны: свой живой прогон — + это «старта нет вовсе», чужой холд — «шкала короче, чем мог бы позволить баланс». Свой прогон + выигрывает: сказать второе, когда верно первое, значит отправить человека останавливать ЧУЖОЙ прогон, + после чего клик всё равно откажет. Пин: `httpapi.TestARunOfThisBooksOwnOutranksAnotherBooksHold`. + +Контракт: минор **0.14.0** ратифицирован оркестратором (`D39.244`) по моему пингу — `Blocked.code` +получил значение `run_in_flight`, смысл схемы расширен с «что держит шкалу короче» на «почему старт не +предлагается так, как обещал бы один баланс», `book_id` при этой причине — ЭТА ЖЕ книга. Константа +`httpapi.ContractVersion` переведена на `0.14.0` тем же деревом; гейт `gates.TestTheAnnouncedContractVersionIsTheOneTheCanonRatified` +зелёный. + +### `PD-162` — отказ до денег, и вторая дверь, которой ряд не называл + +Проверка каталога книги стоит на допуске ДО холда (`runs.go:707`=`func (s *Service) sourceThere`, зовётся +из `Start` после предиката книги и перед чтением счёта). ⚠ **Трудности, которую промт объявлял главной, +действительно нет** (§4.3 промта): каталог создаётся на интейке ДО строки в БД, а исключение первого +прогона принадлежит ЖУРНАЛУ внутри каталога (`journalSize` мапит ENOENT в нулевой офсет) — так что +`os.Stat(workdir)` на допуске не отвергает ничего законного, и никакой ветви-исключения я не строил. + +**Что я нашёл сверх заказа: резюм — вторая денежная дверь, и клин там тот же.** Измерено исполнением ДО +правки: тест `runs.TestAResumeOverAMissingDirectoryIsRefusedBeforeTheHold` на дереве без гарда дал +`a resume over a missing directory answered , want ErrSourceGone` — то есть резюм над пропавшим +каталогом ВОЗВРАЩАЛ прогон и брал холд остатка бюджета. Оркестратор подтвердил, что это в границах пака +(«предмет `PD-162` — прогон, который никогда не поедет, а не конкретная ручка»), и гард стоит теперь и там +(`platform/internal/runs/reconcile.go:1721`, перед `reopen`). + +**Вина книги и вина хоста разведены и отвечают РАЗНЫМИ словами** — это урок `PD-192`, который зона +уже оплатила один раз. Итоговое правило (третья и четвёртая строки приехали дофиксом по ревью): + +| что с каталогом | ответ | почему так | +|---|---|---| +| его нет, книга ПОД корнем интейка, сентинел на месте | `ErrSourceGone` → 409 `book_not_ready` + `cause: source_gone` (на резюме — 409 `run_not_resumable` + та же причина) | это и вправду конец этой книги, и ожидание не поможет — так причина и говорит | +| его нет вместе с СЕНТИНЕЛОМ корня (форма размонтирования) | `ErrStorageUnavailable` → 503 | вина ХОСТА; сказать здесь «ваша книга непригодна» — ровно тот дефект, который в интейке отклонил все книги тома | +| его нет, а книга лежит ВНЕ корня интейка (`tmplatformctl book add --workdir`, инстанс без `BooksDir`) | `ErrStorageUnavailable` → 503 | сентинела на том томе нет вовсе: чужой здоровый маркер уликой про этот том не является, и платформа не объявляет конец книги на основании каталога, которого не создавала | +| путь ЕСТЬ, но это не каталог (файл, симлинк на файл) | `ErrStorageUnavailable` → 503 | `os.Stat` на файле успешен, движок открывает каталог; писала этот файл не платформа | +| путь есть и НЕ ЧИТАЕТСЯ (права, I/O) | `ErrStorageUnavailable` → 503, в сообщении операция и errno БЕЗ пути | ошибка уходит в ERROR-строку, а норма зоны §2 запрещает id книги в логах | + +Предикат корня не скопирован, а **вызван**: `books.storageIsThere` стал экспортируемым +`platform/internal/books/books.go:574`=`func StorageIsThere(booksDir string) bool`, а «книга под нашим +корнем?» — `books.go:594`=`func Owns(booksDir, dir string) bool`; методы остались обёртками в одну +строку. Копия была бы вторым экземпляром правила, у которого первая редакция стоила зоны всех книг +хоста — и мутация, ломающая `StorageIsThere`, красит не только мой тест, но и ДВА чужих пина `PD-192` +в `internal/books`, что и есть доказательство, что предикат один. + +**Ряд остаётся ОТКРЫТЫМ, и остаток пере-написан честно:** класс шире удаления каталога — прогон, УЖЕ +живущий, который не поедет никогда (запинен к старой сборке движка, без `reserved_usd`, беспризорный +деплой), по-прежнему висит с холдом до прихода человека. Это половина эскроу (`П-18`), автоматического +терминального вердикта у реконсилятора нет и он не строился. + +### `PD-448` — разбор, а не стройка + +⚠ Ниже — ТОЛЬКО разбор; кода по этому ряду я не написал ни строки. + +**Механика, снятая чтением (адреса пере-сняты сегодня).** `Resume` читает состояние прогона ДО замка +книги: `platform/internal/runs/reconcile.go:1652`=`s.Store.ReadRunForResume`, затем +`reconcile.go:1663`=`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)`), и холд не берётся: транзакция + откатывается целиком; +* **проигравший СНАРУЖИ окна** (его чтение случилось уже после коммита победителя) видит + `translating`, падает в `default:` и получает `ErrNotResumable` — «the run cannot be continued: it + is translating». **Вот это и есть дефект ряда.** + +⇒ **Окно дефекта — НЕ гонка за индекс, а интервал между чтением состояния и взятием замка.** И отсюда +же объяснение, почему изолированно тест зелёный, а в полном пакете под нагрузкой красный: замок книги +(`platform/internal/runs/bank.go:400`=`func (s *Service) lockBook`) — ВНУТРИПРОЦЕССНЫЙ мьютекс, так что +два резюма одного демона идут друг за другом, и под голоданием по процессору горутины стартуют дальше +друг от друга — проигравший чаще успевает прочитать уже переведённое состояние. Формулировка в шапке +теста («both calls pass the state check together and race for attempt N+1») описывает состояние ДВУХ +экземпляров демона; в одном процессе это последовательность, а не гонка. + +**Воспроизводимость — замер сегодня, субагентом, инструментом и с числом прогонов** (вывод в +`scratchpad/pd448/`, 67 файлов): + +| что запускал (всё — `go test ./internal/runs/ -race`) | раз | красных | контроль | +|---|---|---|---| +| изолированно `-count=10 -run '^TestTwoResumes…$'`, load 0.17 | 10 | **0** | в выводе 10 строк `=== RUN` и 10 `--- PASS` с этим именем, других `---` нет | +| полный пакет на простаивающей машине, `-count=1` | 3 | **0** | в каждом прогоне 183 верхнеуровневых теста, целевой `--- PASS` = 1, `--- FAIL` = 0 | +| `GOMAXPROCS=1` (`-cpu 1`) | 5 | **0** | в каждом `=== RUN` = 1, `--- PASS` = 1 | +| **под нагрузкой** (32 CPU-жруна на 8 ядрах, load 29–50), подмножество `TestTwoResumes\|TestResumeIsRefused` | 20 | **4** | `--- PASS` 16, `--- FAIL` 4 | +| полный пакет под нагрузкой | 4 | **не измерено** | все четыре уперлись в `-timeout 9m` на ДРУГИХ тестах; целевой в каждом успел пройти зелёным до обрыва | + +Текст всех четырёх падений ОДИН и тот же, и он читается, а не считается цветом: +`control_test.go:898: resume 1: runs: the run cannot be continued: it is translating, want the run back` +— то есть падает ровно утверждение «проигравший обязан ответить прогоном», и источник ошибки — ветка +`default` из разбора выше. ⚠ **Запись ряда «полный пакет упал 2 из 2» сегодня НЕ пере-проверена:** +полный пакет под нагрузкой ни разу не дошёл до конца, и это названо «не измерено», а не «не +воспроизвелось». +⚠ Замер шёл на МОЁМ дереве (гард каталога в `Resume` уже стоял). Путь гонки он не трогает, а если и +влияет, то в сторону РЕЖЕ: лишний `os.Stat` внутри замка отодвигает коммит победителя, то есть чаще +оставляет проигравшего в «хорошей» ветке. Числа выше поэтому — нижняя граница, а не верхняя. + +**Чего сегодня нет, чтобы различить два случая.** Ни `LiveRun`, ни `run_attempts` не несут ни +заявителя, ни ключа идемпотентности: колонки таблицы — `id · run_id · attempt_no · engine_run_id · +started_at · ended_at · exit_code · last_seq` (`platform/internal/pgstore/migrations/00002_readmodel.sql:72-87`, +и ни один из четырёх поздних альтеров этого не добавил). То есть у проигравшего состояние побайтово то +же, что у законного резюма часового прогона. + +**Три пути промта, с ценой каждого:** + +| путь | что делает | цена | закрывает ли предмет | +|---|---|---|---| +| **(а)** `Idempotency-Key` на `resumeRun` | второй клик под ТЕМ ЖЕ ключом реплеит ответ первого | контрактный минор (параметр операции) + клиент обязан прислать один ключ на оба клика | **не полностью:** при ОДНОВРЕМЕННЫХ кликах второй получает `409 key_in_flight` + `Retry-After` (`platform/internal/httpapi/idempotency.go:110`), то есть снова ошибку; прогоном он ответит лишь на ПОВТОРЕ | +| **(б)** claim-токен или колонка в транзакции рестарта | записать, КТО и чем открыл попытку, и дать проигравшему это увидеть | **миграция** + суждение по временному окну («открыто секунду назад резюмом») | да, но окно — это лотерея под другим именем | +| **(в)** перенести чтение состояния ПОД замок | убрать интервал | одна строка | ⛔ **делает хуже ДЕТЕРМИНИРОВАННО:** проигравший тогда видит `translating` ВСЕГДА | + +**Рекомендация — четвёртый путь, которого в промте нет: сделать резюм идемпотентным ПО СУЩЕСТВУ.** +Ветка `case "translating"` в том же switch (`reconcile.go:1696`) отвечает **прогоном**, а не отказом — +с единственным исключением «стоп уже запрошен» (`l.StopRequestedAt != nil`; поле есть, +`platform/internal/pgstore/runs.go:322`), потому что такой прогон сворачивается, а не продолжается, и +202 там был бы ложью. Тогда оба проигравших — и внутри окна, и снаружи — отвечают прогоном, и мерить +больше нечего: гонки не остаётся, потому что исходы совпали. +Цена: **одна строка кода и одна строка канонной таблицы.** Ни миграции, ни ключа, ни нового состояния. +Семантика честная: `POST /resume` означает «пусть этот прогон идёт»; если он уже идёт — требование +выполнено, это ординарная идемпотентность записи. +**Довод ПРОТИВ, который я вижу сам:** клиент теряет различие «я продолжил» и «оно и так шло», а канон +этим свойством дорожит строкой выше — `paused` отвечает 409 именно потому, что 202 был бы «тихим +no-op». Разница, на которой я стою: у `paused` НИЧЕГО не переоткрывается и лекарство другое (новый +прогон), а у `translating` состояние ровно то, которого просил пользователь. +**Чего я НЕ знаю:** как это увидит фронт — у него сегодняшний `409 run_not_resumable` описан таблицей +канона, а его кода я не читал (чужая зона). И не знаю, есть ли деплой с ДВУМЯ экземплярами демона: при +одном экземпляре внутрипроцессный замок делает «оба прошли проверку вместе» невозможным, при двух — +возможным, и тогда путь (г) закрывает и эту форму тоже, а (а) и (б) — нет. + +**ДИСПОЗИЦИЯ ОРКЕСТРАТОРА №23, пришла в тот же день.** Четвёртый путь ПРИНЯТ как направление, строит +его СЛЕДУЮЩИЙ платформенный пак — не этот. Довод принятия оказался не тем, с которого я начинал: +сомнение было в том, что идемпотентный `202` ломает канонную гарантию «202 = работа реально +пере-открыта», а проверка показала, что зона её уже НЕ держит — дерево отдаёт `202` проигравшему, у +которого пере-открыл не он, и это закреплено шапкой самого теста. Значит выбор был не между починкой +и обходом, а между двумя смыслами глагола. Критерий, по которому это лечение: обход оставляет ответ +зависимым от интерливинга и молчит об этом, лечение делает его ИНВАРИАНТНЫМ К ПОРЯДКУ. +**Три вещи поехали в ряд `PD-448`, чтобы следующий пак их не потерял** — и каждую я пере-проверил +своим прибором, а не принял на слово: вырез «стоп уже запрошен» обязателен (`l.StopRequestedAt`, +`pgstore/runs.go:322`; слово `stop_requested` уже на проводе, `httpapi/v0.go:242`); проверка re-pass +стоит ДО свитча (`reconcile.go:1687` против `:1696`) и после новой ветки должна пропускаться, иначе +двойной клик по re-pass снова получит отказ; и остаток окна, который правка НЕ закрывает — +`stop_requested` читается в том же незамкнутом снимке, а `Stop` замка книги не берёт вовсе (первый же +его оператор — `RequestStop`), так что стоп между снимком и ответом даст один `202` на сворачивающийся +прогон: один кадр, денег не трогает. Канон при этом НЕ двинут осознанно: строка таблицы — минор +`0.15.0`, и бамп сегодня покрасил бы гейт версии на сданном дереве, где константа несёт `0.14.0`. + +### ⚠ Расхождение канона и дерева — отдельным пунктом (правит оркестратор) + +**1. `translating` у `resumeRun`.** Канон обещает `409 run_not_resumable` («nothing; it is already +running» — таблица §`resumeRun` в `docs/architecture/14-api-contract/openapi.yaml`), а отгруженный код +проигравшему гонки за уникальный индекс возвращает ПРОГОН, 202 (`reconcile.go:1736`). То есть одна +ручка отвечает на одно состояние двумя разными способами в зависимости от того, где её застали. Это +расхождение СТАРШЕ моего пака и им не создано; рекомендация (г) как раз и сводит обе стороны к одному +ответу. + +**2. Отказ резюма над пропавшим каталогом — ввёл Я, называю прямо.** `Resume` теперь может ответить +`409 book_not_ready` + `cause: source_gone` (`reconcile.go:1721`), а таблица §`resumeRun` перечисляет +ответы по СТАТУСУ прогона и такого отказа не знает. Генерённого клиента это не ломает: операция +объявляет общий `409 Conflict`, а `book_not_ready` — законное значение `ErrorCode`, которое эта же +поверхность уже отдаёт на соседней ручке. Но текст канона неполон, и строка таблицы нужна. + +### Одна строка про `PD-175` (§4.4) + +Следующим предметом ряда я считаю **ВЫХОД ИЗ `rejected`**, а не свип сирот и не потолок аплоадов. +Довод: у отклонённой книги сегодня выхода нет ВООБЩЕ — единственный переход в `not_started` стоит в +`platform/internal/pgstore/books.go:360` и требует `status = 'parsing'`, то есть перепарса не существует, +а ручки удаления нет в контракте вовсе (`PD-122`). Такая строка живёт в библиотеке человека навсегда, и +цена этого — доверие на КАЖДОЙ неудачной загрузке, тогда как сироты и потолок диска стоят операторского +диска, который в закрытой бете ограничен её же размером. Свип и ретеншен при этом остаются за гейтом +открытой регистрации (`D39.176` п.1), а счётный потолок аплоадов — это квота под другим именем, и он +запрещён. + +### Якоря регистра (§4.5): их было не четыре, а пятнадцать + +Промт называл три уехавших якоря в зоне плюс четвёртый, линтером не ловимый (без токена, внутри +`PD-162`). Все четыре починены. **Остальные одиннадцать убили МОИ СОБСТВЕННЫЕ переезды строк** — +восемь на первом круге правок и ещё три после дофикса по ревью, — и это ровно то правило зоны, по +которому «якорь, убитый твоим переездом, чинит тот, чей переезд его убил». Прибор до и после: +`python3 docs/scripts/counts.py --lint` из корня репозитория — **23 проблемных якоря → 12**, и группа +«по КОРНЮ ЦЕЛИ … internal 8» из выдачи исчезла целиком. +⚠ Двенадцатый живой ✗ — **не мой к починке**: он лежит в `docs/architecture/05-decisions-log.md:2661` +(зона оркестратора) и указывает В мою: `platform/internal/runs/runs.go:451` → `:479`. Убил его мой +переезд, поэтому адрес назван ему пингом, а файл я не трогаю. +⚠ И один урок про сам прибор: якорь БЕЗ токена (`path:line` без `=`) линтер не проверяет вовсе, так +что мои собственные такие ссылки (`v0.go:610` в ряду `PD-455`) уехали МОЛЧА и были пере-сняты рукой. +Ставя новый якорь, ставь токен. + +### Находка → что сделано → ЧЕМ ПРЕДЪЯВЛЕНО + +| находка | что сделано | чем предъявлено | +|---|---|---| +| форма заказа не спрашивала правило допуска (`PD-455`) | один предикат `startable`, две привязки; на проводе `blocked.code: run_in_flight` | пины (ниже) + живая проба на стенде: `blocked":{"code":"run_in_flight","book_id":"bk_7JDPZD3S7J22XS3I"}` и 409 на втором клике | +| **резюм — ВТОРАЯ денежная дверь с тем же клином** (ряд её не называл) | гард `sourceThere` и там, перед `reopen` | до правки тест дал `a resume over a missing directory answered , want ErrSourceGone`; после — зелёный 8/8 | +| **мой собственный `fmt.Errorf` унёс бы путь книги в ERROR-лог** (класс `PD-139`) | путь выброшен, оставлены операция и errno | пин `runs.TestADirectoryThatCannotBeReadIsTheDeploymentsAndCarriesNoPath`; в логе стенда `tmstand-truth` встречается 2 раза, обе — эхо конфига на буте | +| мои переезды строк убили 8 чужих якорей (3 были сломаны до меня) | пере-снял все 11 в своей зоне + 12-й без токена внутри `PD-162` | `counts.py --lint`: 23 → 12 проблемных, группа «internal 8» исчезла | +| 12-й якорь моего переезда живёт в ЧУЖОЙ зоне | НЕ правлю, сообщаю адрес оркестратору | `docs/architecture/05-decisions-log.md:2661`: `platform/internal/runs/runs.go:451` → `:479` | +| `PD-455` покинул класс тревог гейта регистра | id убран из `alarmBaseline` с причиной, ТЕМ ЖЕ деревом (инструкция самого гейта) | `ALARM PD-count: 14 … (baseline 14)`, строка `ALARM PD-455 LEFT` исчезла, тест зелёный | +| форма молчит про ПРОПАВШИЙ каталог | НЕ чинил, завёл ряд `PD-466` с доводом, почему опрос — не место для `os.Stat` | ряд в регистре | +| носитель числа скипов батареи (`STACK_DECISIONS` §«Гейты батареи») называет не все условия | дописал два измеренных условия | два прогона одной смены назвали РАЗНЫЕ условия для одного и того же теста — оба вывода в отчёте ниже | +| **рецепт стенда называл сторону НАОБОРОТ**: «относительные `pipeline:`/`models:` — отличие ШАБЛОНА», хотя относительные они в ОРИГИНАЛЕ, а в шаблоне обязаны быть абсолютными | сторона названа явно, добавлена проверка на месте и цена ошибки | замерено: рабочий шаблон стенда несёт АБСОЛЮТНЫЕ пути (строки 20–21), и с ним `books.TestTheRenderedConfigurationIsOneTheEngineActuallyLoads` зелёный против свежесобранного `tmctl`; по этой строке приёмка получила восемь красных живых проб, которые были дефектом шаблона, а не дерева | + +### Какой тест что пинит (норма зоны §3 п.4) + +| свойство несущего пути | пин | +|---|---| +| форма несёт отказ двери про живой прогон, и дверь отвечает то же | `runs.TestTheFormSaysWhatTheDoorWillRefuseOverABookThatIsAlreadyRunning` | +| на проводе это `blocked.code: run_in_flight` с `book_id` ЭТОЙ книги | `httpapi.TestTheFormCarriesTheRunInFlightThatWillRefuseTheClick` | +| свой живой прогон важнее чужого холда в одном члене `blocked` | `httpapi.TestARunOfThisBooksOwnOutranksAnotherBooksHold` | +| старт над пропавшим каталогом отказывает ДО холда | `runs.TestABookWhoseDirectoryIsGoneIsRefusedBeforeTheHold` | +| резюм над пропавшим каталогом отказывает ДО холда | `runs.TestAResumeOverAMissingDirectoryIsRefusedBeforeTheHold` | +| размонтированный том — вина деплоя, и книгу она не винит | `runs.TestAVanishedBooksVolumeIsTheDeploymentsFaultAndNotTheBooks` | +| нечитаемый каталог — тоже деплой, и путь книги в ошибку не попадает | `runs.TestADirectoryThatCannotBeReadIsTheDeploymentsAndCarriesNoPath` | +| два отказа выходят на провод по-разному (409+`source_gone` против 503) | `httpapi.TestAVanishedSourceAndAVanishedVolumeAnswerDifferently` | +| объявленная версия контракта = ратифицированный канон | `gates.TestTheAnnouncedContractVersionIsTheOneTheCanonRatified` (существующий) | + +### Числа, снятые ПОСЛЕ последней правки + +* **Батарея с ЧЕТЫРЬМЯ гейтами** (`TM_PLATFORM_TEST_DSN` на стендовый Postgres · `ENGINE_BIN` собран + из ТЕКУЩЕГО `backend/` по `PD-432` · `BOOK_TEMPLATE` стенда · достижимый пользовательский systemd): + `MAKE-EXIT=0` · **20 пакетов `ok` · 0 `FAIL`** · линтер `0 issues` · `sqlc diff` чисто · 5 скипов. + (Перечислены все двадцать: `cmd/tmplatformctl` `cmd/tmplatformd` `auth` `backup` `books` `config` + `exports` `gates` `httpapi` `ingest` `jobs` `login` `metrics` `money` `pgstore` `pricing` + `readmodel` `reqid` `runner` `runs` — «зелёная батарея» без полного списка ничего не значит.) +* **`make vuln`** (отдельная цель, в `check` не входит, блокер лендинга по §2 стандарта): + `No vulnerabilities found.`, `VULN-EXIT=0`. +* ⚠ **Оба числа ПЕРЕ-СНЯТЫ ПОСЛЕ ДОФИКСА по верификатору** (дофикс тронул `internal/gates` и + `cmd/tmplatformd`, то есть код, а не только доки): батарея снова `MAKE-EXIT=0 · 20 ok · 0 FAIL · + линтер 0 issues · 5 скипов`, `make vuln` снова `No vulnerabilities found.`. Числа совпали с + до-дофиксными — но это ИЗМЕРЕНО, а не унаследовано: правка кода после снятых чисел обнуляет их + независимо от того, насколько она мала. +* **Скипы: 5, и условий у них ДВА, а не одно** — это отдельная находка сегодняшнего дня. + Четыре пробы `internal/runner` скипаются словами «this deployment's pipeline enables the bank + contour and names `<репо>/backend/configs/mining-contrast.zh.txt`, which is not on this host» + (артефакт чужой зоны, его отсутствие — не поломка платформы). Пятая, + `runner.TestTheSnapshotGuardIsLoudWithoutTheFlagsAndPassesWithThem`, умеет скипнуться ВТОРЫМ + условием — `listen tcp 127.0.0.1:11434: bind: address already in use`, — и два честных прогона одной + смены назвали для неё РАЗНЫЕ условия. Носитель числа (`STACK_DECISIONS` §«Гейты батареи») знал ноль + из двух; оба дописаны туда же вместе с предупреждением, что по одному прогону условия не считают. +* **Денежные пины не флейковые:** четыре теста × 8 прогонов под `-race` = **32 строки `--- PASS`, + 0 `--- FAIL`** (`go test ./internal/runs/ -race -count=8 -run '<четыре имени>'`). +* **Живая проба на стенде** — свой демон (порт 18080, свой `BOOKS_DIR` под `$HOME`, своя база + `tmstand_truth`), книга заведена ЖИВЫМ интейком через `tmplatformctl seed`, и порт сверен с PID + своего демона перед сидом (шаг 3а рецепта). ⛔ `TM_PLATFORM_BOOKS_DIR` ни секунды не смотрел на + `<репо>/books`; проверка стоит в самом скрипте стенда, а не в намерении. + - форма над книгой в покое: `"blocked":null`, `verdict":"covers_all"`; + - первый клик: `202`, баланс 25.000000 → 22.862733 (холд взят); + - форма над той же книгой С ЖИВЫМ ПРОГОНОМ: + `"blocked":{"code":"run_in_flight","book_id":"bk_7JDPZD3S7J22XS3I"}` — то есть **ровно то, что + дверь и ответила** вторым кликом: `409 run_in_flight`; + - каталог книги удалён (руками, внутри своего стенда) → `409 book_not_ready` + `cause: source_gone`, + **баланс 25000000 до и 25000000 после** — холд не взят; + - сентинел `.tmplatform-books` убран (форма размонтирования) → `503 service_unavailable`, баланс тот + же, и в логе ERROR-строка `run refused: this deployment cannot start runs`; + - ⚠ **Живая движковая половина на этом хосте ДОСТИЖИМА, и «5 скипов» читать как «сюда не + добраться» нельзя:** приёмка №23 прогнала пробы `internal/runner` с шаблоном, чьи + `pipeline:`/`models:` абсолютные, и получила **42 PASS · 0 FAIL** при тех же пяти скипах; + условие одно — артефакт контраста, строка бэклога **251**, чужая зона. Числа приёмки, не мои. + - ⚠ $0: движок на стенде провайдерских ключей не имеет (`.env` рядом с `book.yaml` нет, ключей в + окружении 0), прогон честно умер `exit-code`, и холд вернулся сам. + +### Мутационные посадки — 18 штук, выживших НЕТ + +Изоляция по зонному стандарту §3 п.3: копия взята ВМЕСТЕ с каноном +(`cp -a --parents platform docs/architecture/14-api-contract <куда>/`), перед каждой правкой +скрипт утверждает `test -f go.mod` и что путь — копия, а не дерево. **База копии зелёная** +(`internal/runs` ok 57 с · `httpapi` ok 2.8 с · `gates` ok 0.46 с · `books` ok 25 с, exit 0), и +вердикты судятся по ДЕЛЬТЕ против неё. +⚠ Первый прогон базы упёрся в `-timeout 20m` на `internal/books` — под ЧУЖОЙ нагрузкой (load 46, +соседний замер), — и скрипт ОТКАЗАЛСЯ судить мутации против красной базы. Числа ниже со второго +прогона. Та же ловушка поймала и адверсариальный проход: его первая батарея была красной по +таймауту трёх пакетов из-за параллельной батареи, а не из-за дерева. + +| # | что сломано | падает | ТЕКСТ падения (по нему и засчитано) | +|---|---|---|---| +| 1 | дверь перестаёт спрашивать про живой прогон | форма | `the form says over a book that is being translated, want the door's run_in_flight` | +| 2 | форма перестаёт НЕСТИ ответ двери | форма | то же сообщение с другой стороны | +| 3 | провод предпочитает чужой холд своему прогону | `httpapi` ×1 | `blocked = map[book_id:bk_other code:credit_held], want this book's own run` | +| 4 | `blocked` называет чужую книгу | `httpapi` ×2 | `blocked.book_id = , want the book being asked about` | +| 5 | допуск перестаёт смотреть на каталог | `runs` ×5 | `a start over a missing directory answered , want ErrSourceGone` | +| **6** | **каталог смотрится ПОСЛЕ холда, а не до** | `runs` ×5 | **`a refused start moved money: {Balance:10000000 Reserved:0} -> {Balance:6180172 Reserved:3819828}`** — денежный пин не вакуумен: на кону были настоящие $3.819828 | +| 7 | резюм перестаёт смотреть на каталог | `runs` ×1 | `a resume over a missing directory answered , want ErrSourceGone` | +| 8 | пропавший ТОМ свалить на книгу | `runs` ×2 | `answered … its source directory is not on this deployment, want ErrStorageUnavailable` | +| 9 | пропавший КАТАЛОГ свалить на деплой | `runs` ×2 | `answered … the book storage is not mounted on this deployment, want ErrSourceGone` | +| **10** | **маркер СВОЕГО корня читается как улика про ЛЮБОЙ том** | `runs` ×1 | `a start over a book outside the root answered … not on this deployment, want the deployment's` — пин находки ревью | +| 11 | сентинел хранилища перестаёт спрашиваться | `runs` ×1 **плюс два ЧУЖИХ пина `PD-192`** в `internal/books` | `the host is at fault, not the book` | +| 12b | путь, который не каталог, проходит дверь | `runs` ×1 | `a start over a FILE in the book's place answered runs: stat journal: …` | +| 13 | пропавший источник перестаёт быть видом `book_not_ready` | `runs` ×1 | `the refusal is not a book_not_ready: runs: its source directory is not on this deployment` | +| 14 | причина `source_gone` замолкает у СТАРТА | `httpapi` ×1 | `cause = , want "source_gone" — without it the client offers waiting` | +| 15 | РЕЗЮМ отвечает словами старта | `httpapi` ×1 | `code = book_not_ready, want "run_not_resumable"` | +| **16** | **оба новых значения провода переименованы** | `httpapi` ×4 **и гейт** | `this build serves blocked.code: runInFlight and the canon's enum does not carry it: [credit_held run_in_flight]` — до дофикса эта посадка ВЫЖИВАЛА | +| 17 | объявленная версия контракта уезжает от канона | `gates` ×1 | `this build announces contract 0.13.1 and the ratified canon is 0.14.0` | + +**Итог: 18 посадок (17 + одна пере-посаженная), 17 пойманы, 0 выживших**, и каждая засчитана по +ТЕКСТУ падения, а не по цвету. +⚠ **Четыре посадки в ПЕРВОЙ редакции не собрались** — снятие блока оставляло неиспользованными `facts`, +импорт `books`, переменную `st`, — и «ни один тест не упал» там означало «пакет не собрался», то есть +НЕ ИЗМЕРЕНО, а вовсе не «дыра». Пере-посажены так, чтобы менялось РЕШЕНИЕ, а идентификаторы +оставались в деле, и только тогда засчитаны. Это тот самый случай, когда вердикт по неправой причине +отличается от поимки лишь тем, прочёл ли кто-нибудь текст. +⚠ **Посадка 11 — улика «предикат ВЫЗВАН, а не скопирован»:** ломая `books.StorageIsThere`, она красит +и мой тест, и ДВА чужих пина `PD-192`. Копия так бы не покраснела. +⚠ **Посадка 1 показала границу:** снятие `HasLiveRun` из предиката красит только ФОРМУ — дверь +по-прежнему отвергает вторую покупку, потому что за ней стоит частичный уникальный индекс +`runs_one_live_per_book` (`pgstore.StartRun`, ветка `isUnique`). У двери есть второй рубеж, у формы +его не было — это и есть предмет `PD-455` одной фразой. + +### Греп ОТКРЫТЫХ рядов регистра по СВОИМ ПОЛНЫМ путям (норма зоны §3 п.8) + +Прибор: греп по полным путям семи тронутых файлов с разбором таблицы регистра — **34 открытых ряда** +цитируют мои файлы. Диспозиции: + +* **тронуты механизмом — четыре:** `PD-455` закрыт · `PD-162` сужен, остаток пере-написан · `PD-448` + разобран и остаётся открытым · `PD-466` заведён этим паком. +* **назван, но не закрыт — один:** `PD-139` (путь каталога книги утекает в ERROR-логи внутри обёрнутых + ошибок). Моя новая ветка МОГЛА стать его третьим носителем и не стала — путь выброшен из ошибки, пин + `runs.TestADirectoryThatCannotBeReadIsTheDeploymentsAndCarriesNoPath`. Сам ряд я не лечил: два его + существующих носителя — тейлер и обёртки спавна — мой дифф не трогает. +* **`PD-175`** — §4.4, одна строка выше. +* **остальные 28 — совпадение по ИМЕНИ ФАЙЛА, а не по предмету** (реконсилятор, свип, телеметрия, + интейк, читающая модель). Проверяемо: в `reconcile.go` мой дифф — ОДИН хунк на 8 строк + (`git diff -- platform/internal/runs/reconcile.go | grep -E '^@@'` даёт ровно одну строку, + `@@ -1713,6 +1713,14 @@`), в `books.go` — экспорт одной функции, в `runner.go` — одно поле + конфигурации. Оставлены открытыми с этой причиной. + +### Что сказал адверсариальный проход (author≠reviewer, исполнением) + +Направленный второй читатель по пяти осям §5 промта, со своей копией дерева и своими посадками. +**Девять находок, четыре существенные, и три из них я сам не видел.** Что сделано с каждой: + +| находка прохода | вердикт | что сделано | +|---|---|---| +| **F3.** `sourceThere` спрашивал сентинел КОРНЯ ИНТЕЙКА про любую книгу — а книга от `tmplatformctl book add --workdir` живёт на томе, которого платформа не писала. Проход показал пробой: том такой книги пропал, корень интейка здоров ⇒ ответ был «конец КНИГИ». **Это `PD-192` заново, моими руками** | принята целиком | сентинел спрашивается только под своим корнем (`books.Owns`); где спрашивать нечего — ответ деплоя. Пин `runs.TestABookOutsideTheIntakesRootIsNotDeclaredDeadByAnotherVolumesMarker` | +| **F2.** Оба новых значения провода (`run_in_flight`, `source_gone`) не были запинены НИЧЕМ: все мои ассерты сравнивали провод с той же Go-константой. Проход переименовал обе — **вся батарея осталась зелёной** (класс `PD-1`) | принята целиком | ассерты переписаны на ЛИТЕРАЛЫ + гейт со ВТОРЫМ независимым источником: `gates.TestTheBlockedVocabularyServedIsTheOneTheCanonEnumerates` читает enum `Blocked.code` из канона и сверяет в обе стороны | +| **F1.** Отказ резюма выходил словом СТАРТА (`book_not_ready` на `runId`), которого таблица §`resumeRun` не знает, и не был покрыт ни одним тестом | принята | резюм отвечает своим словарём — `409 run_not_resumable` + `cause: source_gone`; это форма, какой канон уже пользуется для двух осей, перекрывающих таблицу. Пин `httpapi.TestAResumeOverAGoneSourceIsRefusedInTheResumeHandlesOwnWords`. Строка канонной таблицы всё равно нужна — пункт оркестратору | +| **F5.** «Одно определение» верно для предиката, но отображение «ответ предиката → член провода» — вторая рукописная вещь с МОЛЧАЛИВЫМ хвостом: новый отказ завтра оставит форму с `covers_all` | принята | хвост стал громким: незнакомый отказ пишет ERROR-строку оператору. Компайл-тайм это не ловит (предикат отвечает `error`), и я это говорю, а не прячу | +| **F8.** `os.Stat` успешен на ФАЙЛЕ: путь, который есть, но не каталог, проходил дверь | принята | `IsDir()`, ответ деплоя; пин `runs.TestAFileWhereTheBooksDirectoryShouldBeIsRefusedToo` | +| **F9.** Фраза «the wire this build serves is the 0.13.1 shape» осталась в настоящем времени над константой `0.14.0` | принята | время исправлено | +| **F4.** Свойство «путь книги не течёт в ERROR» запинено у `sourceThere` и сломано строкой позже на том же вызове: `journalSize` (`internal/runs/spawn.go:301`) отдаёт `*fs.PathError` целиком, и он уходит в `default:` → 500 | **не лечил, назвал** | это носитель ряда `PD-139`, у которого СВОЯ диспозиция («решение, а не побочный эффект»); я записал в ряд и своё решение по своей ветке, и второй носитель адресом. Класс закрыт на ОДИН носитель из двух, и в отчёте это сказано так | +| **F6.** `bank.go` несёт посимвольную копию первой ветки предиката | **не лечил, назвал** | копируется не ПРАВИЛО, а предложение: сам предикат `readyToTranslate` там и вызывается, а живой-прогон половина у двери банка законно ДРУГАЯ (исключение `awaiting_bank`, ратифицированное находкой P9). Сводить их значило бы либо сломать исключение, либо втащить внутрь флаг «я дверь банка» | +| **F7.** Реконсилятор при своём респавне каталог не спрашивает, и окно между проверкой и спавном остаётся | **граница, а не дефект** | ровно то, что абзац «ОТКРЫТЫМ остаётся» ряда `PD-162` и говорит; половина живого прогона — эскроу (`П-18`) | + +⚠ **Проход также подтвердил три моих утверждения исполнением, а не чтением:** автоматического +терминального вердикта живому прогону в дельте нет (грепы по `+367` добавленным строкам +`reconcile.go`); `PricedBook` не тронут (`pgstore/books.go` вообще отсутствует в `git status`); +контракт-первичность соблюдена — канон уехал на 0.14.0 РАНЬШЕ кода, коммитом `ba8542a`. +⚠ И назвал ловушку прибора, которую я знал по чужому опыту, а он встретил сам: его ПЕРВАЯ батарея была +красной по таймауту трёх пакетов — из-за параллельной батареи на той же машине, а не из-за дерева; +на чистом перепрогоне те же пакеты дали 48 с / 148 с / 123 с. + + +### Чего НЕ удалось / не измерено (§10) + +* **Полный пакет `internal/runs` под нагрузкой ни разу не дошёл до конца** — четыре попытки уперлись в + `-timeout 9m` на ДРУГИХ тестах (`TestALiftedQuarantineMaterializesTheJournalAgainFromTheCursor`, + `TestARestartHoldsWhatTheRunWasSoldForWhenTheRateHasMovedSince`, `TestADeadlockDoesNotStopTheProjection`). + Значит запись ряда `PD-448` «упал 2 из 2 полным пакетом» сегодня **не пере-проверена**; гонка + воспроизведена подмножеством. Это «не измерено», а не «не воспроизвелось». +* **Цена второго чтения на пути формы не замерена.** Я утверждаю, что +1 индексный запрос на опрос + приемлем рядом с уже стоящим там пер-главным агрегатом, но бенчмарка не снимал — это суждение. +* **Ветка «стат каталога упал не-ENOENT» проверена только правами** (`chmod 000` на корне): EIO, + залипшее сетевое монтирование и симлинк-в-никуда я не воспроизводил. +* **Гонка «каталог удалён МЕЖДУ проверкой и спавном» остаётся** — проверка сужает окно, а не закрывает + его; это уже половина живого прогона, то есть эскроу (`П-18`), и её пак не брал. +* **Фронт я не читал** (чужая зона), поэтому не знаю, как он сегодня рисует `blocked` и что сделает с + новым значением кода; канон обязывает клиента терпеть незнакомое значение, но проверить это я не мог. +* **Число скипов батареи снято дважды и дало РАЗНЫЕ условия** для одного и того же теста (см. ниже) — + считать условия по одному прогону нельзя, и я не знаю, сколько их всего. + +### Что я сам считаю слабым местом сделанного + +1. **Форма по-прежнему может обещать старт над пропавшим каталогом** (`PD-466`). Я разрезал правило на + «факты БД спрашивают обе площадки» и «диск спрашивает только дверь», и довод у разреза есть + (опрос — не место для `os.Stat` зависшего монтирования), но это именно РАЗРЕЗ: половина обещания + формы осталась неправдой, и я назвал её, а не закрыл. +2. **`Options.Refusal` — поле типа `error` в структуре-значении.** Оно даёт слою провода решать + словарь (что правильно: коды живут в `httpapi`), но это необычная форма, и следующий читатель может + принять её за «ошибку чтения формы», а не за «ответ двери». Комментарий это говорит; форма всё равно + на любителя. +3. **Обе главные находки прохода — мои СЛЕПЫЕ ПЯТНА, и они одного рода.** Я построил фикстуру + `onTheVolume` под свою же картину мира (книга лежит под корнем интейка) и посадил двенадцать + мутаций — но ни фикстура, ни каталог мутаций не могли найти случай, которого в моей картине не + было: книгу ВНЕ этого корня. И мутировал я ЛОГИКУ, ни разу не тронув СЛОВАРЬ, поэтому «переименуй + оба новых значения провода» в мой каталог не попало — а именно эта посадка и выживала. Вывод, к + которому я пришёл не сам: свои мутации проверяют то, что автор считает важным, и ровно поэтому + второй читатель не роскошь. +4. **Мой предикат не покрывает третью площадку, которая судит те же факты** — дверь правок банка + (`internal/runs/bank.go:124-142`) читает `readyToTranslate` и `HasLiveRun` своим кодом, потому что у + неё СВОЁ правило (исключение для `awaiting_bank`, ратифицированное находкой P9). Я сознательно её не + тронул: свести их в один предикат значило бы либо сломать исключение, либо втащить в предикат флаг + «я — дверь банка», после которого «одно определение» становится лозунгом. Но факт остаётся: слово + «одно определение» верно для ДВУХ площадок из трёх, и я предпочитаю сказать это прямо. + +### Дофикс по верификатору приёмки (11.09, тот же день) — один ВЫЖИВШИЙ мутант и одно тихо-зелёное + +Проход верификатора по замороженной копии: девять посадок, семь чистых поимок, **один выживший и одно +тихо-зелёное**. Оба — вне моей карты находок, оба однострочные, и оба ломали ровно тот механизм, что +пак объявил главным. Обе находки я пере-проверил исполнением, прежде чем чинить. + +1. **ВЫЖИВШИЙ: единственная проводка `BooksDir` в дверь не была утверждена ничем.** + `cmd/tmplatformd/runner.go:39` — единственное производственное присваивание, а соседний тест-свидетель + несёт комментарий «Mutation caught: dropping any assignment in runsConfig» и **`BooksDir` не + утверждал**. Посадка «снять строку» пережила полную батарею. Цена: с непроведённым корнем + `books.Owns("", …)` ложен для КАЖДОЙ книги, и книга, чей собственный каталог пропал, отвечает `503` + и операторским «this deployment cannot start runs» вместо `409 book_not_ready`+`source_gone` — то + есть путаница `PD-192` возвращается одной выпавшей строкой, молча, при целых деньгах. Запинено + своим случаем в `TestTheOperatorsRunnerKnobsReachTheReconciler`; посадка теперь краснеет текстом + «BooksDir is "", want the intake's …: the run door cannot tell whose fault a missing directory is». + ⚠ И класс тут тот же, что мы записали нормой сегодня: **комментарий теста утверждал ШИРЕ, чем тест + делал.** Теперь утверждение и комментарий сошлись — покрыты все одиннадцать полей. +2. **ТИХО-ЗЕЛЁНОЕ В МОЁМ ЖЕ НОВОМ ГЕЙТЕ.** Регулярка `(?s)\n Blocked:\n.*?\n +enum: \[…\]` + телом схемы не ограничена: с удалённым из канона enum она лениво дотягивалась до СЛЕДУЮЩЕГО + `enum:` и зачитывала `Usage.state` семьюдесятью строками ниже — красное по неправой причине; а если + те же два слова положить под соседнюю схему, гейт отвечал **`--- PASS` над каноном, который их + больше не ратифицирует**. Чтение теперь ограничено телом схемы (`enumOfSchema`), и у самого + читателя есть пин на трёх синтетических документах — `TestTheSchemaEnumReaderStopsAtTheSchemasOwnBody`. + Проверено посадкой верификатора на копии: удаление enum из канона даёт теперь МОЙ текст + «schema "Blocked" carries no enum», а не чужие значения. + ⚠ Горькая деталь: этот гейт я построил ровно как противоядие от своего слепого пятна по сигналу + прохода — и построил его с собственным слепым пятном того же рода. Прибор, проверяющий словарь, + сам обязан иметь пин; теперь имеет. +3. **Носитель числа скипов нёс ДВА разных числа.** Мой новый блок говорил «5 скипов», а нетронутая + строка того же раздела — «ожидание при всех четырёх: … **скипов 0**» (замер 29.08). Читатель, + попавший на вторую, объявил бы приёмку при пяти скипах или счёл бы пять поломкой. Разведено по + УСЛОВИЯМ: ноль относится к дереву 29.08, где не было ни артефакта контраста (строка бэклога 251), + ни занятого локального адреса провайдера; счёт скипов сверяется с блоком, который эти условия + называет. +4. **Якорь `PD-448` на сам тест уехал** (`control_test.go:846` → `:881`) — уехал ДО пака и промтом не + назывался, починен заодно. Плюс мой собственный `contract_test.go:16` → `:17`, который сдвинул + импорт, добавленный этим же дофиксом. + +⚠ **Чего в этом списке НЕТ и почему.** Верификатор назвал пятым пунктом отсутствие фразы «работа +завершена, править не планирую» в зонном журнале — **фраза там была и есть**, в конце моей +секции (на момент проверки — `platform-PROGRESS.md:481`, после этой вставки она уехала ниже; греп по +файлу даёт ТРИ хита: сама фраза, её упоминание в этом абзаце и пак 08.09). Проверил прибором, прежде +чем «чинить»: приёмку своей работы тоже надо проверять, иначе в журнал уедет вторая копия той же +фразы и следующая смена будет гадать, какая из них настоящая. + +**Работа завершена, править не планирую.** Дерево — 14 путей, все в `platform/` (13 изменённых плюс +новый `internal/runs/admission_test.go`), вне зоны не тронуто ничего; `docs/PROGRESS.md`, +`docs/experiments/`, `backend/docs/` в дереве — работа соседних смен. Сессия не коммитит: передано +оркестратору №23 вместе с тремя пунктами, которые правит он (якорь в журнале решений · строка +канонной таблицы §`resumeRun` · расхождение по `translating`, старше этого пака). + + ## ДОРАБОТКА ПО ИНВАРИАНТУ `D39.240` — ОТЧЁТ (11.09, `textmachine-5c`) > Три предмета: строки **401** (вторая половина), **394**, **392**. Вход HEAD `d61469f`, дерево зоны @@ -927,7 +1413,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:408`=`resnapshot := book.BankMoved || book.HasPriorRun`; +попала в его токен. `PD-422` держал `internal/runs/runs.go:436`=`resnapshot := book.BankMoved || book.HasPriorRun`; после экранирования токен перестал совпадать с кодом. Вылечено укорочением токена до `resnapshot := book.BankMoved` (единственный хит в файле). Счёт колонок и сверка токена тянут ячейку в разные стороны — следующий, кто пойдёт экранировать черты, наступит на то же. Ушло пингом. diff --git a/platform/internal/books/books.go b/platform/internal/books/books.go index a0e7af93..2f18aaa3 100644 --- a/platform/internal/books/books.go +++ b/platform/internal/books/books.go @@ -552,7 +552,8 @@ func (s *Service) markStorage() error { return nil } -// storageIsThere reports whether the books storage is the one this platform wrote into. +// StorageIsThere reports whether the books storage under booksDir is the one this platform wrote +// into. // // ⚠ It asks about the MARKER and not about the directory, and the difference is the whole point. A // volume mounted AT BooksDir leaves an empty mountpoint behind when it is unmounted, so the directory @@ -564,19 +565,37 @@ func (s *Service) markStorage() error { // // A host that has never taken an upload has no marker either, and answers "not there" — which is the // safe direction: it has no books to reject. -func (s *Service) storageIsThere() bool { - if s.Cfg.BooksDir == "" { +// +// ⚠ Exported and taking the root as an argument because the RUN DOOR asks the same question of the +// same storage (runs.sourceThere): a book admitted over a vanished directory holds the account's +// credit for good, and telling that apart from an unmounted volume is this predicate, not a second +// edition of it. A copy would be the same defect twice — the first edition of THIS one cost the +// intake every book on the host. +func StorageIsThere(booksDir string) bool { + if booksDir == "" { return false } - _, err := os.Stat(filepath.Join(s.Cfg.BooksDir, StorageMarker)) + _, err := os.Stat(filepath.Join(booksDir, StorageMarker)) return err == nil } -func (s *Service) owns(dir string) bool { - if s.Cfg.BooksDir == "" || dir == "" { +func (s *Service) storageIsThere() bool { return StorageIsThere(s.Cfg.BooksDir) } + +func (s *Service) owns(dir string) bool { return Owns(s.Cfg.BooksDir, dir) } + +// Owns reports whether dir is a path this platform's intake created, i.e. one UNDER booksDir. +// +// ⚠ Exported for the run door (runs.sourceThere), and the question it answers there is which fault a +// missing directory is. The storage marker only says something about booksDir, so it may only be +// consulted about books that live under it: a book placed by hand somewhere else — `tmplatformctl +// book add --workdir` — sits on a volume this platform never wrote and has no sentinel on, and +// reading the intake's marker as evidence about THAT volume is the PD-192 mistake with a longer +// path. +func Owns(booksDir, dir string) bool { + if booksDir == "" || dir == "" { return false } - rel, err := filepath.Rel(s.Cfg.BooksDir, dir) + rel, err := filepath.Rel(booksDir, dir) if err != nil { return false } diff --git a/platform/internal/gates/contract_test.go b/platform/internal/gates/contract_test.go index e11304c6..39ea8025 100644 --- a/platform/internal/gates/contract_test.go +++ b/platform/internal/gates/contract_test.go @@ -1,9 +1,12 @@ package gates import ( + "fmt" "os" "path/filepath" "regexp" + "sort" + "strings" "testing" "textmachine/platform/internal/httpapi" @@ -40,3 +43,148 @@ func TestTheAnnouncedContractVersionIsTheOneTheCanonRatified(t *testing.T) { t.Errorf("this build announces contract %s and the ratified canon is %s; the constant is raised in the same change as the code that implements a minor, never afterwards", got, want) } } + +// The `blocked` vocabulary this deployment serves must be the one the canon enumerates. +// +// ⛔ IT IS THE SECOND CHECK IN THIS ZONE WITH AN INDEPENDENT SOURCE, and it exists because the first +// one — the version number above — cannot see a SHAPE. Every other test of these values compares the +// wire to the same Go constant it was rendered from, which is self-consistent and passes at any +// value: rename both of them and the whole module stays green (measured by the pack's own adversarial +// pass, 11.09). That is the class PD-1 names, and `Blocked.code` is the one place it costs a +// contract: the canon declares a CLOSED enum there, so a value this build serves and the canon does +// not is a client generated against the canon meeting a word its stubs do not carry. +// +// It reads the canon rather than a copy of it, like its neighbour, and it asserts BOTH directions: +// a value served and not ratified is a wire ahead of the contract, a value ratified and not served +// is a contract ahead of the wire — and which of the two is a defect depends on the day, so the gate +// names what it saw instead of guessing. +func TestTheBlockedVocabularyServedIsTheOneTheCanonEnumerates(t *testing.T) { + src, err := os.ReadFile(filepath.Join(canonPath)) + if err != nil { + t.Fatalf("the ratified canon could not be read, so the served `blocked` vocabulary has nothing to be checked against: %v", err) + } + ratified, err := enumOfSchema(string(src), "Blocked") + if err != nil { + t.Fatalf("the canon's Blocked schema: %v — this gate reads the wrong document or the wrong shape", err) + } + // What this build can put in that member, written out rather than derived: a list a test walks is + // a list somebody has to extend deliberately, which is the whole point of a closed vocabulary. + served := map[string]bool{ + httpapi.CauseCreditHeld: true, + httpapi.CauseRunInFlight: true, + } + for v := range served { + if !ratified[v] { + t.Errorf("this build serves `blocked.code: %s` and the canon's enum does not carry it: %v", v, keys(ratified)) + } + } + for v := range ratified { + if !served[v] { + t.Errorf("the canon ratifies `blocked.code: %s` and this build never serves it: the wire is behind the contract", v) + } + } +} + +func keys(m map[string]bool) []string { + out := make([]string, 0, len(m)) + for k := range m { + out = append(out, k) + } + sort.Strings(out) + return out +} + +// enumOfSchema returns the values of the one `enum:` inside the named schema of the canon. +// +// ⛔ THE SEARCH IS BOUNDED BY THE SCHEMA'S OWN BODY, and the bound is the whole of this function. The +// first edition was one regexp — `\n Blocked:\n.*?\n +enum: \[…\]` — whose `.*?` was stopped by +// nothing: with the enum deleted from the canon it walked on and read the NEXT schema's, so a missing +// closed vocabulary came back as `[exhausted low ok]` from `Usage.state` seventy lines below. Red, and +// red for the wrong reason; move those two words under the following schema instead and the gate goes +// GREEN over a canon that no longer ratifies them at all. A gate that can read a neighbour's answer is +// not a second source, it is a coin. +// +// The body is every line indented deeper than the schema's own name, which is what YAML means by +// nesting and what the canon does everywhere. Pinned by TestTheSchemaEnumReaderStopsAtTheSchemasOwnBody, +// whose fixtures are exactly the two shapes above. +func enumOfSchema(canon, schema string) (map[string]bool, error) { + head := "\n " + schema + ":\n" + i := strings.Index(canon, head) + if i < 0 { + return nil, fmt.Errorf("no schema %q in the document", schema) + } + body := canon[i+len(head):] + // …up to the next line that is indented no deeper than the schema's name: the next schema, or the + // end of the section. + if end := regexp.MustCompile(`(?m)^ {0,4}\S`).FindStringIndex(body); end != nil { + body = body[:end[0]] + } + m := regexp.MustCompile(`(?m)^ +enum: \[([^\]]*)\]`).FindStringSubmatch(body) + if m == nil { + return nil, fmt.Errorf("schema %q carries no enum", schema) + } + out := map[string]bool{} + for _, v := range strings.Split(m[1], ",") { + if v = strings.TrimSpace(v); v != "" { + out[v] = true + } + } + return out, nil +} + +// The reader stops at the schema's own body — asserted on documents built for the question, because +// the live canon cannot show the failure this exists to prevent. +// +// Both fixtures are the adversarial pass's own posting: delete the enum and see whether a neighbour's +// is read instead. The first says the reader must REFUSE; the second says it must refuse even when the +// missing words are sitting a few lines below under another name, which is the shape that came back +// GREEN from the first edition. +func TestTheSchemaEnumReaderStopsAtTheSchemasOwnBody(t *testing.T) { + const withIt = ` + Blocked: + type: object + properties: + code: + type: string + enum: [credit_held, run_in_flight] + Usage: + properties: + state: + enum: [exhausted, low, ok] +` + got, err := enumOfSchema(withIt, "Blocked") + if err != nil || !got["credit_held"] || !got["run_in_flight"] || len(got) != 2 { + t.Fatalf("the reader did not read the schema's own enum: %v (%v)", keys(got), err) + } + // The enum is GONE from Blocked and a neighbour still has one. + const withoutIt = ` + Blocked: + type: object + properties: + code: + type: string + Usage: + properties: + state: + enum: [exhausted, low, ok] +` + if got, err := enumOfSchema(withoutIt, "Blocked"); err == nil { + t.Errorf("a schema with no enum answered %v: the reader walked into the next schema", keys(got)) + } + // And the same, with the missing words present further down under another name — the shape that + // makes a lenient reader answer RIGHT for the wrong document. + const movedAway = ` + Blocked: + type: object + properties: + code: + type: string + Somewhere: + properties: + code: + enum: [credit_held, run_in_flight] +` + if got, err := enumOfSchema(movedAway, "Blocked"); err == nil { + t.Errorf("the words were read from the wrong schema: %v", keys(got)) + } +} diff --git a/platform/internal/gates/register_test.go b/platform/internal/gates/register_test.go index 322c384e..b20b5f18 100644 --- a/platform/internal/gates/register_test.go +++ b/platform/internal/gates/register_test.go @@ -73,11 +73,16 @@ var alarmBaseline = []string{ // ровно один) и о том, что отказ приходит МОЛЧА для пользователя, дважды нажавшего кнопку. // Ниже major он стоит потому, что деньги не теряются: отказ случается ДО взятия холда. "PD-448", - // PD-455 — вошёл в класс 06.09 вторым кругом пака «форма заказа». Маркеры законны: ряд про то, что - // форма МОЛЧИТ о живом прогоне, и про ХОЛД, потому что отказ случается ровно до его взятия. Ниже - // major он стоит по той же причине, по которой его цена терпима: денег не теряется и работа не - // портится — ломается обещание «вердикт ДО клика», то есть продукт, а не деньги. - "PD-455", + // ⚠ `PD-455` LEFT the class on 11.09 and its id is dropped here in the same change, by the same + // instruction and the same precedent as PD-168 above. It was `open` because the order form was + // SILENT about a live run and said `covers_all` in front of a click the door would refuse; the + // form now carries that refusal as `blocked.code: run_in_flight` from the door's own predicate + // (`runs.startable`, contract 0.14.0), and the row is `fixed`. Pinned by + // `runs.TestTheFormSaysWhatTheDoorWillRefuseOverABookThatIsAlreadyRunning` and + // `httpapi.TestTheFormCarriesTheRunInFlightThatWillRefuseTheClick`. + // · The silence about a VANISHED DIRECTORY is a different row (`PD-466`, info) and it does not + // + // enter this class: it carries one marker, the hold it never reaches. // PD-456 — вошёл в класс 06.09 вторым кругом пака «форма заказа». Маркеры несёт ЗАКОННО: ряд про // деньги и про холд, потому что дефект — завышение оценки остатка (юнит, у которого сделан только // черновик, входит в цену обеими волнами). Ниже major он стоит потому, что **ошибка направлена diff --git a/platform/internal/httpapi/capabilities.go b/platform/internal/httpapi/capabilities.go index 567f0ef2..d543003a 100644 --- a/platform/internal/httpapi/capabilities.go +++ b/platform/internal/httpapi/capabilities.go @@ -40,8 +40,14 @@ import "net/http" // ⚠ 0.13.1 is the canon's CORRECTING minor (D39.235 §6) and this constant did not follow it for a day: // the canon moved first and the gate stood red the whole time, which is the reverse of the order the // paragraph above ratifies. The number is brought to the canon here rather than the other way round, -// because the canon is the one that is right — the wire this build serves is the 0.13.1 shape. -const ContractVersion = "0.13.1" +// because the canon was the one that was right — the wire this build served then was the 0.13.1 shape. +// +// ⚠ 0.14.0 grows `Blocked.code` by `run_in_flight` (D39.244): the order form now says that a book +// with a run of its own is not startable, instead of quoting `covers_all` over a click admission was +// going to refuse (PD-455). The value went into the canon's enum and the form serves it, which is a +// served SHAPE and therefore a minor — the vocabulary of second-level `cause` codes beside it stays +// open and moves no number. +const ContractVersion = "0.14.0" // Capabilities is what this deployment can do: one flat document, the same for every account. type Capabilities struct { diff --git a/platform/internal/httpapi/problem.go b/platform/internal/httpapi/problem.go index 9e93725e..81808ced 100644 --- a/platform/internal/httpapi/problem.go +++ b/platform/internal/httpapi/problem.go @@ -76,6 +76,20 @@ const ( CauseChapterOrdersUnavailable = "chapter_orders_unavailable" // CauseCreditHeld — another book of this account holds the credit; `blocked` names it. CauseCreditHeld = "credit_held" + // CauseRunInFlight — this book has a run of its own going, so there is no start to offer at all, + // whatever the balance covers. It fills `RunOptions.blocked` and names THIS book: the place the + // user can act is the book's own card, where that run is. + // + // ⚠ THE ONE VALUE HERE WHOSE VOCABULARY IS NOT THIS OPEN LIST. As a `cause` the rule above holds; + // as a `Blocked.code` it is the canon's own enum beside `credit_held`, which a client may be + // generated from — so it travels with the contract minor that adds it there, and never ahead of + // it. + CauseRunInFlight = "run_in_flight" + // CauseSourceGone — the book's project directory is not on this deployment any more, so a run + // over it could never move. Its own cause under `book_not_ready` because the remedy is opposite + // to that code's ordinary one: waiting clears an intake that is still running and never brings + // back a directory that is gone. + CauseSourceGone = "source_gone" // CauseKeyReused / CauseKeyInFlight — the two halves of `Idempotency-Key`. CauseKeyReused = "key_reused" CauseKeyInFlight = "key_in_flight" diff --git a/platform/internal/httpapi/v0.go b/platform/internal/httpapi/v0.go index 5d6b5580..acadd644 100644 --- a/platform/internal/httpapi/v0.go +++ b/platform/internal/httpapi/v0.go @@ -561,7 +561,8 @@ func (h *v0) runOptions(w http.ResponseWriter, r *http.Request) { if !ok { return } - opts, err := h.runs.Order(r.Context(), user, r.PathValue("bookId")) + bookID := r.PathValue("bookId") + opts, err := h.runs.Order(r.Context(), user, bookID) if err != nil { h.fail(w, r, err) return @@ -589,10 +590,35 @@ func (h *v0) runOptions(w http.ResponseWriter, r *http.Request) { structure := opts.Structure out.Order.Structure = &structure } - // Why the scale is smaller than the account could otherwise afford. Without it a second book - // shows a shrunken scale — or none — with no way to learn that the account's FIRST book is the - // reason (canon §RunOptions.blocked). - if opts.BlockedBy != "" { + // Why a start is not on offer as the balance alone would suggest — and the two reasons are + // ordered, because only one of them can be carried. + // + // A run of this book's OWN comes first: it is not a shorter scale but no start at all, and it is + // the answer the door will give whatever the money says. Without it the form put `covers_all` in + // front of a buyer whose click the door was already going to refuse (PD-455). Another book's hold + // is the second: there a start IS on offer, only shorter than the account could otherwise afford, + // and naming that book is what lets a client offer the user somewhere to go (canon + // §RunOptions.blocked). + // + // ⛔ AND A REFUSAL THIS SURFACE HAS NO WORD FOR IS SAID OUT LOUD RATHER THAN DROPPED. The predicate + // is one definition with two bindings, but the mapping from its answer to a wire member is a + // SECOND, hand-kept thing: a refusal added to it tomorrow would reach the door and leave this form + // saying `covers_all`, which is PD-455 all over again and silent. It cannot be a compile error — + // the predicate answers an `error` — so it is an operator's line instead. + if opts.Refusal != nil && !errors.Is(opts.Refusal, pgstore.ErrRunInFlight) { + h.log.ErrorContext(r.Context(), "the order form has no word for a refusal admission would give", + "err", opts.Refusal) + } + // ⚠ The door's other refusals have no line here because this path cannot reach them: an unpriced + // book is answered `not_priced` above, and a book that is not ready to be translated — still + // arriving, still being cut, rejected, or owing its chapter tree — is not priced either, since one + // transaction of the materializer writes the price and the tree together (pgstore/readmodel.go, + // `update books set … expected_micro_usd …` beside writeChapters). A word invented for them here + // would be a wire value nothing can produce. + switch { + case errors.Is(opts.Refusal, pgstore.ErrRunInFlight): + out.Blocked = &Blocked{Code: CauseRunInFlight, BookID: bookID} + case opts.BlockedBy != "": out.Blocked = &Blocked{Code: CauseCreditHeld, BookID: opts.BlockedBy} } h.writeJSON(w, r, http.StatusOK, out) @@ -713,6 +739,17 @@ func (h *v0) resumeRun(w http.ResponseWriter, r *http.Request) { return } run, err := h.runs.Resume(r.Context(), user, r.PathValue("runId")) + if errors.Is(err, runs.ErrSourceGone) { + // The same fact as at admission, in THIS operation's own vocabulary. `book_not_ready` is the + // word for a book that is still arriving or was rejected, and it is what a start answers; here + // the caller is asking about a RUN, and what it is being told is that this run cannot be + // continued — with the cause that says why waiting will not help. It is the shape the canon + // already uses for the two axes that override its status table (`ceiling_reached`, + // `credit_unavailable`): the same code, a narrower cause. + h.log.ErrorContext(r.Context(), "resume refused: the book's source directory is gone", "err", err) + FailCause(w, r, CodeRunNotResumable, CauseSourceGone) + return + } if err != nil { h.fail(w, r, err) return @@ -1126,6 +1163,12 @@ func (h *v0) fail(w http.ResponseWriter, r *http.Request, err error) { Invalid(w, r) case errors.Is(err, pgstore.ErrRunInFlight): Fail(w, r, CodeRunInFlight) + case errors.Is(err, runs.ErrSourceGone): + // The same 409 as below — the book is there and cannot be translated — with the cause that + // says waiting will not help. Logged at ERROR because it is also the operator's business: a + // directory under the books root went missing while its row lives on. + h.log.ErrorContext(r.Context(), "run refused: the book's source directory is gone", "err", err) + FailCause(w, r, CodeBookNotReady, CauseSourceGone) case errors.Is(err, runs.ErrBookNotReady): // The book is still being received or was rejected. 409 and not 404: the book exists and the // client can see it — what it cannot do is start a translation of it yet. @@ -1167,9 +1210,13 @@ func (h *v0) fail(w http.ResponseWriter, r *http.Request, err error) { // Its own cause since 0.7.0: nothing to re-pass — no correction since the last run, or a // resnapshot run already walked it in (canon §RunRequest.re_pass). FailCause(w, r, CodeCeilingUnavailable, CauseRePassUnavailable) - case errors.Is(err, runner.ErrCeilingNotWired), errors.Is(err, runs.ErrRunnerIncomplete): - // A DEPLOYMENT that cannot start runs: it has no way to tell the engine its ceiling (row 145) - // or no way to record how a unit ended. + case errors.Is(err, runner.ErrCeilingNotWired), errors.Is(err, runs.ErrRunnerIncomplete), + errors.Is(err, runs.ErrStorageUnavailable): + // A DEPLOYMENT that cannot start runs: it has no way to tell the engine its ceiling (row 145), + // no way to record how a unit ended, or no book storage under it. The last one is a book whose + // directory is missing together with the marker of the volume it lived on, and it is the + // host's fault rather than the book's — answering it as the book's is what PD-192 cost the + // intake, and there the answer had no way back. h.log.ErrorContext(r.Context(), "run refused: this deployment cannot start runs", "err", err) Fail(w, r, CodeServiceUnavailable) default: diff --git a/platform/internal/httpapi/v0_test.go b/platform/internal/httpapi/v0_test.go index 2a4730e3..838fa510 100644 --- a/platform/internal/httpapi/v0_test.go +++ b/platform/internal/httpapi/v0_test.go @@ -376,6 +376,124 @@ func TestTheOrderFormCarriesTheVerdictTheEstimateAndTheSlidersBounds(t *testing. } } +// The form carries the refusal the DOOR will give — and carries it as `blocked`, because the +// verdict has no room for it and must not grow any. +// +// PD-455: a book with a run of its own going was quoted `covers_all`, since the verdict is computed +// from what is left and what the balance covers and nothing asked whether the book was busy. The +// answer is not a fourth verdict value — `covers_all`/`covers_part`/`covers_none` is a closed enum +// and a fourth member breaks every generated client — but the member the canon already pre-authorised +// for growth, whose own instruction to a client is to tolerate a code it does not know. +// +// ⚠ The money half stays exactly as it was: the balance really does cover this book, and `verdict` +// answers about money. +func TestTheFormCarriesTheRunInFlightThatWillRefuseTheClick(t *testing.T) { + rn := &fakeRuns{bounds: runs.Options{ + Options: pricing.Options{ChaptersLeft: 3, AffordableChapters: 3, Verdict: pricing.VerdictCoversAll}, + Structure: "detected", ChapterOrders: true, + Refusal: pgstore.ErrRunInFlight, + }} + got := decode(t, call(t, v0Server(t, &fakeLibrary{}, rn), "GET", "/v0/books/bk_1/run-options", "")) + blocked, ok := got["blocked"].(map[string]any) + if !ok { + t.Fatalf("the form says nothing about the run that will refuse the click: %v", got["blocked"]) + } + // ⚠ THE LITERAL AND NOT THE CONSTANT. Compared to `CauseRunInFlight` this assertion is + // self-consistent and passes at any value — rename the constant and the whole module stays green, + // which the pack's adversarial pass demonstrated by doing it. The word is the canon's (the enum of + // `Blocked.code`), so it is written out here as a word; the second, independent source for it is + // `gates.TestTheBlockedVocabularyServedIsTheOneTheCanonEnumerates`, which reads the canon itself. + if blocked["code"] != "run_in_flight" { + t.Errorf("blocked.code = %v, want %q", blocked["code"], "run_in_flight") + } + // THIS book, not another one: the place the user can act is its own card, where that run is. + if blocked["book_id"] != "bk_1" { + t.Errorf("blocked.book_id = %v, want the book being asked about", blocked["book_id"]) + } + if order, _ := got["order"].(map[string]any); order["verdict"] != "covers_all" { + t.Errorf("the verdict moved to %v because a run is going; it answers about money", order["verdict"]) + } +} + +// `blocked` carries ONE fact and the two candidates are not equal: a run of this book's own means +// there is no start to offer at all, while another book's hold only makes the scale shorter than the +// account could afford. Told the second while the first is true, a client sends the user off to stop +// somebody else's run and the click still fails. +func TestARunOfThisBooksOwnOutranksAnotherBooksHold(t *testing.T) { + rn := &fakeRuns{bounds: runs.Options{ + Options: pricing.Options{ChaptersLeft: 3, AffordableChapters: 1, Verdict: pricing.VerdictCoversPart}, + Structure: "detected", ChapterOrders: true, + BlockedBy: "bk_other", Refusal: pgstore.ErrRunInFlight, + }} + got := decode(t, call(t, v0Server(t, &fakeLibrary{}, rn), "GET", "/v0/books/bk_1/run-options", "")) + blocked, _ := got["blocked"].(map[string]any) + if blocked["code"] != "run_in_flight" || blocked["book_id"] != "bk_1" { + t.Errorf("blocked = %v, want this book's own run to be what is named", blocked) + } +} + +// The two refusals a vanished book directory produces, on the wire. +// +// They are different answers because the FAULT is different, and the difference is the one PD-192 +// cost the intake: a book whose own directory is gone is that book's end and says so with a cause +// that tells the client waiting will not help, while a book whose whole volume is gone is the host's +// business and must not reach the user as a verdict about their book. +func TestAVanishedSourceAndAVanishedVolumeAnswerDifferently(t *testing.T) { + h := v0Server(t, &fakeLibrary{}, &fakeRuns{err: runs.ErrSourceGone}) + w := call(t, h, "POST", "/v0/books/bk_1/runs", `{"stop_for_signing":false,"chapters":10}`) + if w.Code != http.StatusConflict { + t.Fatalf("a book whose directory is gone: %d, want 409", w.Code) + } + got := decode(t, w) + if got["code"] != string(CodeBookNotReady) { + t.Errorf("code = %v, want %q", got["code"], CodeBookNotReady) + } + // The literal again, for the reason given above: a cause compared to its own constant is a + // tautology, and this one is what tells a client that waiting is not the remedy. + if cause, _ := got["cause"].(map[string]any); cause["code"] != "source_gone" { + t.Errorf("cause = %v, want %q — without it the client offers waiting, which never helps here", + got["cause"], "source_gone") + } + + h = v0Server(t, &fakeLibrary{}, &fakeRuns{err: runs.ErrStorageUnavailable}) + w = call(t, h, "POST", "/v0/books/bk_1/runs", `{"stop_for_signing":false,"chapters":10}`) + if w.Code != http.StatusServiceUnavailable { + t.Fatalf("an unmounted books volume: %d, want 503 — it is the deployment's fault, not the book's", w.Code) + } + if got := decode(t, w); got["code"] == string(CodeBookNotReady) { + t.Errorf("the host's fault reached the client as a verdict about the book: %v", got["code"]) + } +} + +// The SAME absence answered on the resume handle, and answered in that handle's own vocabulary. +// +// A caller of `POST /runs/{runId}/resume` is asking about a RUN, so what it is told is that this run +// cannot be continued — `run_not_resumable` — with the cause that says why waiting will not help. +// `book_not_ready`, which the start handle answers for the same fact, is the word for a book still +// arriving or rejected, and it would send a client to wait for an intake that finished long ago. +// +// ⚠ The pair is asserted together on purpose: one fact, two operations, two words, and the thing +// that makes them right is that each belongs to its own operation's table in the canon. +func TestAResumeOverAGoneSourceIsRefusedInTheResumeHandlesOwnWords(t *testing.T) { + h := v0Server(t, &fakeLibrary{}, &fakeRuns{err: runs.ErrSourceGone}) + w := call(t, h, "POST", "/v0/runs/run_1/resume", "") + if w.Code != http.StatusConflict { + t.Fatalf("a resume over a gone source: %d, want 409", w.Code) + } + got := decode(t, w) + if got["code"] != "run_not_resumable" { + t.Errorf("code = %v, want %q — the caller asked about a run, not about an intake", got["code"], "run_not_resumable") + } + if cause, _ := got["cause"].(map[string]any); cause["code"] != "source_gone" { + t.Errorf("cause = %v, want %q", got["cause"], "source_gone") + } + // The deployment's half of the same absence keeps the deployment's own answer here too. + h = v0Server(t, &fakeLibrary{}, &fakeRuns{err: runs.ErrStorageUnavailable}) + if w := call(t, h, "POST", "/v0/runs/run_1/resume", ""); w.Code != http.StatusServiceUnavailable { + t.Errorf("a resume over an unmounted volume: %d, want 503", w.Code) + } +} + // A book whose chapter cut cannot be sold against says so, and says WHY, instead of offering a // chapter slider over boundaries that do not name chapters. ⚠ `declared` is deliberately among them: // for an EPUB the engine cuts by spine DOCUMENTS, and «document» is not «chapter» (ratified 05.09). diff --git a/platform/internal/runs/admission_test.go b/platform/internal/runs/admission_test.go new file mode 100644 index 00000000..5261af15 --- /dev/null +++ b/platform/internal/runs/admission_test.go @@ -0,0 +1,362 @@ +package runs + +import ( + "errors" + "io/fs" + "os" + "path/filepath" + "strings" + "testing" + "time" + + "textmachine/platform/internal/books" + "textmachine/platform/internal/money" + "textmachine/platform/internal/pgstore" + "textmachine/platform/internal/pricing" + "textmachine/platform/internal/runner" +) + +// onTheVolume moves the fixture's book onto a books ROOT shaped like a deployment's — a marked +// storage directory with the book's own directory under it — and answers both paths. +// +// The shape matters to what is being tested: the door tells "this book's directory is gone" from +// "the volume under every book is gone" by the storage MARKER, so a fixture whose book sits in a +// bare temporary directory could only ever produce the second answer. +func onTheVolume(t *testing.T, f *fixture, bookID string) (root, dir string) { + t.Helper() + root = t.TempDir() + if err := os.WriteFile(filepath.Join(root, books.StorageMarker), []byte("test"), 0o600); err != nil { + t.Fatal(err) + } + dir = filepath.Join(root, bookID) + if err := os.MkdirAll(dir, 0o700); err != nil { + t.Fatal(err) + } + if _, err := f.store.Pool().Exec(f.ctx, `update books set workdir = $2 where id = $1`, bookID, dir); err != nil { + t.Fatal(err) + } + f.svc.Cfg.BooksDir = root + return root, dir +} + +// A run over a book whose directory is not there is refused AT THE DOOR, before the hold. +// +// What it used to do instead is the whole of PD-162: the offset the spawn reads maps a missing +// journal onto "zero, no error" — right for a first run — so nothing on the way in looked at the +// directory at all. The run was admitted, the money was held, every spawn failed and no path ever +// reached a terminal state: the user saw "translating" for good and the credit stayed frozen until +// a person came with `tmplatformctl run abandon`. +// +// The test ends by putting the directory back and starting the same run: that is what says the +// money was genuinely at stake here and the refusal is the directory's doing, not the fixture's. +func TestABookWhoseDirectoryIsGoneIsRefusedBeforeTheHold(t *testing.T) { + f := newFixture(t, "10", 500) + book := f.bookID(t) + _, dir := onTheVolume(t, f, book) + before := f.account(t) + if err := os.RemoveAll(dir); err != nil { + t.Fatal(err) + } + + _, err := f.svc.Start(f.ctx, StartRequest{UserID: "u1", BookID: book, Chapters: order(100)}) + if !errors.Is(err, ErrSourceGone) { + t.Fatalf("a start over a missing directory answered %v, want ErrSourceGone", err) + } + // It travels as a kind of `book_not_ready`, which is how it reaches the wire at all: the client + // is told the book cannot be translated, and the cause beside it says waiting will not help. + if !errors.Is(err, ErrBookNotReady) { + t.Errorf("the refusal is not a book_not_ready: %v", err) + } + acct := f.account(t) + if acct.Reserved != before.Reserved || acct.Balance != before.Balance { + t.Fatalf("a refused start moved money: %+v -> %+v", before, acct) + } + if acct.Balance != acct.LedgerSum { + t.Fatalf("the cached balance and the ledger disagree: %s vs %s", acct.Balance.USD(), acct.LedgerSum.USD()) + } + var rows int + if err := f.store.Pool().QueryRow(f.ctx, + `select count(*) from runs where book_id = $1`, book).Scan(&rows); err != nil { + t.Fatal(err) + } + if rows != 0 { + t.Fatalf("%d run rows after a refused start, want none", rows) + } + + // The control: the same call over the same book with its directory back takes the hold. Without + // it "no money moved" would be satisfied by a fixture that could not have spent any. + if err := os.MkdirAll(dir, 0o700); err != nil { + t.Fatal(err) + } + if _, err := f.svc.Start(f.ctx, StartRequest{UserID: "u1", BookID: book, Chapters: order(100)}); err != nil { + t.Fatalf("the same start with the directory in place: %v", err) + } + if got := f.account(t); got.Reserved != fixtureHold(100) { + t.Fatalf("the control start reserved %s, want %s — this fixture never had money at stake", + got.Reserved.USD(), fixtureHold(100).USD()) + } +} + +// The same absence with the storage MARKER gone with it is the deployment's fault, and it must not +// be answered as the book's. +// +// ⛔ THE ASYMMETRY IS THE POINT, and the intake paid for it once already (PD-192): an unmounted +// volume leaves an empty mountpoint, so every book under it reads exactly like a book whose own +// directory was removed. Read as the book's own end there, one sweep rejected every book on the host +// with a reason that blames the user's file and has no way back. Here the price is smaller and the +// same in kind — a user told their book cannot be translated while the truth is that an operator has +// a volume to mount — so the answer is the deployment's, and `errors.Is(err, ErrBookNotReady)` must +// stay FALSE. +func TestAVanishedBooksVolumeIsTheDeploymentsFaultAndNotTheBooks(t *testing.T) { + f := newFixture(t, "10", 500) + book := f.bookID(t) + root, _ := onTheVolume(t, f, book) + before := f.account(t) + // The whole root, marker included — an unmount, not a deletion under it. + if err := os.RemoveAll(root); err != nil { + t.Fatal(err) + } + + _, err := f.svc.Start(f.ctx, StartRequest{UserID: "u1", BookID: book, Chapters: order(100)}) + if !errors.Is(err, ErrStorageUnavailable) { + t.Fatalf("a start over an unmounted volume answered %v, want ErrStorageUnavailable", err) + } + if errors.Is(err, ErrBookNotReady) { + t.Errorf("the host's fault reached the user as the book's own: %v", err) + } + if acct := f.account(t); acct.Reserved != before.Reserved || acct.Balance != before.Balance { + t.Fatalf("a refused start moved money: %+v -> %+v", before, acct) + } + + // And the mount coming back is all it takes: nothing about the book was written down, so the + // same call succeeds without anybody repairing anything. + if err := os.MkdirAll(filepath.Join(root, book), 0o700); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(filepath.Join(root, books.StorageMarker), []byte("test"), 0o600); err != nil { + t.Fatal(err) + } + if _, err := f.svc.Start(f.ctx, StartRequest{UserID: "u1", BookID: book, Chapters: order(100)}); err != nil { + t.Fatalf("the same start after the volume came back: %v", err) + } + if got := f.account(t); got.Reserved != fixtureHold(100) { + t.Fatalf("the control start reserved %s, want %s — this fixture never had money at stake", + got.Reserved.USD(), fixtureHold(100).USD()) + } +} + +// The RESUME door is the second one money goes through, and it asks the same question. +// +// PD-162 is about a run that can never move, not about one handle: a resume re-opens the run, takes +// a hold of what is left of its budget and hands it to a spawn that will fail for exactly the same +// reason a first spawn would. Measured before the guard reached this path: the resume returned the +// run and the account's reserved figure went from nothing to the remainder of the budget, with no +// way back except an operator's `run abandon`. +func TestAResumeOverAMissingDirectoryIsRefusedBeforeTheHold(t *testing.T) { + f := newFixture(t, "10", 500) + book := f.bookID(t) + _, dir := onTheVolume(t, f, book) + runID := f.stopped(t, 100, money.MicroUSD(500_000), runner.Marker{Result: "exit-code", + Code: "exited", Status: "1", At: f.now.Add(time.Second)}) + before := f.account(t) + if before.Reserved != 0 { + t.Fatalf("the stopped run still holds %s: this test cannot tell a new hold from an old one", + before.Reserved.USD()) + } + if err := os.RemoveAll(dir); err != nil { + t.Fatal(err) + } + + if _, err := f.svc.Resume(f.ctx, "u1", runID); !errors.Is(err, ErrSourceGone) { + t.Fatalf("a resume over a missing directory answered %v, want ErrSourceGone", err) + } + acct := f.account(t) + if acct.Reserved != before.Reserved || acct.Balance != before.Balance { + t.Fatalf("a refused resume moved money: %+v -> %+v", before, acct) + } + if acct.Balance != acct.LedgerSum { + t.Fatalf("the cached balance and the ledger disagree: %s vs %s", acct.Balance.USD(), acct.LedgerSum.USD()) + } + + // The control, as at the other door: with the directory back the same call re-opens the run and + // takes the hold, so "no money moved" above is a fact about the guard and not about the fixture. + if err := os.MkdirAll(dir, 0o700); err != nil { + t.Fatal(err) + } + if _, err := f.svc.Resume(f.ctx, "u1", runID); err != nil { + t.Fatalf("the same resume with the directory in place: %v", err) + } + if got := f.account(t); got.Reserved != fixtureHold(100)-money.MicroUSD(500_000) { + t.Fatalf("the control resume reserved %s, want the remainder %s — this fixture never had money at stake", + got.Reserved.USD(), (fixtureHold(100) - money.MicroUSD(500_000)).USD()) + } +} + +// A book that does not live under the intake's root is never declared finished on the strength of a +// marker about somebody else's volume. +// +// ⛔ THE MARKER IS EVIDENCE ABOUT ONE ROOT AND NOTHING ELSE. `tmplatformctl book add --workdir` puts a +// book on any absolute path, and an instance with no `BooksDir` at all holds every book that way; for +// those, the intake's healthy marker says nothing whatsoever about the volume the book is on. Read as +// evidence anyway, a vanished mount under such a book would be announced as that book's own end — +// PD-192 with a longer path, and exactly the mistake this pair of errors exists to avoid. So where +// there is no sentinel to ask, the answer is the deployment's. +// +// Found by the pack's own adversarial pass, which probed the shape the tests did not have. +func TestABookOutsideTheIntakesRootIsNotDeclaredDeadByAnotherVolumesMarker(t *testing.T) { + f := newFixture(t, "10", 500) + book := f.bookID(t) + // A marked, healthy intake root — and a book that is NOT under it. + onTheVolume(t, f, book) + elsewhere := filepath.Join(t.TempDir(), "hand-placed") + if err := os.MkdirAll(elsewhere, 0o700); err != nil { + t.Fatal(err) + } + if _, err := f.store.Pool().Exec(f.ctx, + `update books set workdir = $2 where id = $1`, book, elsewhere); err != nil { + t.Fatal(err) + } + if !books.StorageIsThere(f.svc.Cfg.BooksDir) { + t.Fatal("the intake root lost its marker: this test would then measure the ordinary unmount") + } + if err := os.RemoveAll(elsewhere); err != nil { + t.Fatal(err) + } + + _, err := f.svc.Start(f.ctx, StartRequest{UserID: "u1", BookID: book, Chapters: order(100)}) + if !errors.Is(err, ErrStorageUnavailable) { + t.Fatalf("a start over a book outside the root answered %v, want the deployment's ErrStorageUnavailable", err) + } + if errors.Is(err, ErrBookNotReady) { + t.Errorf("a marker about ANOTHER volume was read as proof that this book is finished: %v", err) + } + if acct := f.account(t); acct.Reserved != 0 { + t.Errorf("a refused start reserved %s", acct.Reserved.USD()) + } +} + +// A FILE where the book's project directory should be is not a project directory, and `os.Stat` +// alone cannot tell the difference. +// +// The engine opens a directory; a path that answers `Stat` without being one would pass the door, +// take the money and die in the transient unit — the shape this whole guard exists to prevent. It is +// the deployment's word and not the book's: this platform did not write that file, and it is not +// something to announce as the end of somebody's book. +func TestAFileWhereTheBooksDirectoryShouldBeIsRefusedToo(t *testing.T) { + f := newFixture(t, "10", 500) + book := f.bookID(t) + _, dir := onTheVolume(t, f, book) + if err := os.RemoveAll(dir); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(dir, []byte("not a project"), 0o600); err != nil { + t.Fatal(err) + } + + _, err := f.svc.Start(f.ctx, StartRequest{UserID: "u1", BookID: book, Chapters: order(100)}) + if !errors.Is(err, ErrStorageUnavailable) { + t.Fatalf("a start over a FILE in the book's place answered %v, want ErrStorageUnavailable", err) + } + if acct := f.account(t); acct.Reserved != 0 { + t.Errorf("a refused start reserved %s", acct.Reserved.USD()) + } +} + +// A directory that is THERE and cannot be read is the host's business too — and the refusal carries +// no path. +// +// Two properties in one test because they are one decision. The error class: an unreadable directory +// is not the book's end (nothing about the book is wrong) and not an internal error (nothing is +// broken here), so it travels as the deployment's, like an unmounted volume. And the TEXT: `os.Stat` +// answers a *fs.PathError whose message carries the book's own directory, this error reaches an ERROR +// log through the handler, and a user's book has no business being in one (PD-139, PD-99). The errno +// is what an operator needs; the path is what the run id already resolves for anyone allowed to ask. +func TestADirectoryThatCannotBeReadIsTheDeploymentsAndCarriesNoPath(t *testing.T) { + f := newFixture(t, "10", 500) + book := f.bookID(t) + root, dir := onTheVolume(t, f, book) + // The book's directory stays; what goes is the right to look into the root. + if err := os.Chmod(root, 0o000); err != nil { + t.Fatal(err) + } + t.Cleanup(func() { _ = os.Chmod(root, 0o700) }) + if _, err := os.Stat(dir); !errors.Is(err, fs.ErrPermission) { + // A host where this cannot be arranged — running as root, or a filesystem that ignores the + // mode — would otherwise measure the ENOENT branch and report it as this one. + t.Skipf("this host still reads a 0000 directory (%v): the unreadable-directory branch is not exercised", err) + } + + _, err := f.svc.Start(f.ctx, StartRequest{UserID: "u1", BookID: book, Chapters: order(100)}) + if !errors.Is(err, ErrStorageUnavailable) { + t.Fatalf("a start over an unreadable directory answered %v, want ErrStorageUnavailable", err) + } + if errors.Is(err, ErrBookNotReady) { + t.Errorf("the host's fault reached the user as the book's own: %v", err) + } + if strings.Contains(err.Error(), dir) || strings.Contains(err.Error(), book) { + t.Errorf("the refusal carries the book's directory into a line that is logged at ERROR: %q", err) + } + // It still says WHAT happened: the operation and the errno, which is what an operator acts on. + if !strings.Contains(err.Error(), "stat") || !errors.Is(err, fs.ErrPermission) { + t.Errorf("the refusal says nothing an operator can act on: %q", err) + } + if acct := f.account(t); acct.Reserved != 0 { + t.Errorf("a refused start reserved %s", acct.Reserved.USD()) + } +} + +// The order form asks the DOOR'S OWN question and says so before the click. +// +// PD-455: the verdict was computed from what is left of the book and what the balance covers, and +// nothing on that path asked whether the book already had a run. A second purchase is refused by the +// door, so the form answered `covers_all` over a book whose click could only produce an error — and +// the facts to say so were a query away. +// +// The pin is the PAIR: the form's answer and the door's are taken from one predicate, so it asserts +// both, and the door's refusal is the one the form predicted. Money stays what it is — the balance +// really does cover this book — because the verdict answers about money and this is not about money. +func TestTheFormSaysWhatTheDoorWillRefuseOverABookThatIsAlreadyRunning(t *testing.T) { + f := newFixture(t, "10", 4) + book := f.bookID(t) + // Before anything runs the form promises a start, and nothing stands in its way. + clear, err := f.svc.Order(f.ctx, "u1", book) + if err != nil { + t.Fatal(err) + } + if clear.Refusal != nil { + t.Fatalf("the form refuses a book at rest: %v", clear.Refusal) + } + if clear.Verdict != pricing.VerdictCoversAll { + t.Fatalf("the fixture's balance does not cover its own book (%q): this test cannot say what it claims", + clear.Verdict) + } + + if _, err := f.svc.Start(f.ctx, StartRequest{UserID: "u1", BookID: book, Chapters: order(1)}); err != nil { + t.Fatal(err) + } + + opts, err := f.svc.Order(f.ctx, "u1", book) + if err != nil { + t.Fatal(err) + } + if !errors.Is(opts.Refusal, pgstore.ErrRunInFlight) { + t.Fatalf("the form says %v over a book that is being translated, want the door's run_in_flight", opts.Refusal) + } + // The money half is untouched, and that is deliberate: `verdict` answers "does the balance cover + // it", the balance does, and a fourth verdict value would break every generated client. + if opts.Verdict != pricing.VerdictCoversAll { + t.Errorf("the verdict moved to %q because a run is going; it answers about MONEY", opts.Verdict) + } + // …and the door answers exactly what the form predicted. + if _, err := f.svc.Start(f.ctx, StartRequest{UserID: "u1", BookID: book, Chapters: order(1)}); !errors.Is(err, pgstore.ErrRunInFlight) { + t.Fatalf("the door answered %v where the form predicted run_in_flight", err) + } + + // A SECOND book of the same account is not blocked by the first one's run: the predicate is about + // this book, and a form that answered otherwise would send the user to stop a run for nothing. + if second, err := f.svc.Order(f.ctx, "u1", f.secondBook(t)); err != nil { + t.Fatal(err) + } else if second.Refusal != nil { + t.Errorf("another book's run refuses this book's form: %v", second.Refusal) + } +} diff --git a/platform/internal/runs/reconcile.go b/platform/internal/runs/reconcile.go index 16f17594..ebc2c451 100644 --- a/platform/internal/runs/reconcile.go +++ b/platform/internal/runs/reconcile.go @@ -1713,6 +1713,14 @@ func (s *Service) Resume(ctx context.Context, userID, runID string) (pgstore.Run default: return pgstore.Run{}, fmt.Errorf("%w: it is %s", ErrNotResumable, l.Status) } + // The disk, asked HERE for the same reason admission asks it: what follows takes a hold of what + // is left of this run's budget, and a book whose directory is not there hands that money to a + // spawn that cannot succeed — one more run "translating" for good with the credit inside it + // (PD-162). The two doors money goes through are Start and this one, and neither is the place to + // discover the absence afterwards. + if err := s.sourceThere(l.Workdir); err != nil { + return pgstore.Run{}, err + } next, v, err := s.reopen(ctx, l, fromAFinishedRun) if errors.Is(err, pgstore.ErrStopRequested) { // Cannot happen through this path today — a resume works on a run that has ENDED and the guard diff --git a/platform/internal/runs/runs.go b/platform/internal/runs/runs.go index 8902d77d..0c0e99fd 100644 --- a/platform/internal/runs/runs.go +++ b/platform/internal/runs/runs.go @@ -12,10 +12,13 @@ import ( "context" "errors" "fmt" + "io/fs" "log/slog" + "os" "sync" "time" + "textmachine/platform/internal/books" "textmachine/platform/internal/ingest" "textmachine/platform/internal/money" "textmachine/platform/internal/pgstore" @@ -48,6 +51,13 @@ type Config struct { // StateDir is where exit markers live. Platform state, deliberately NOT the book's directory: // the engine owns that one and the platform does not write into it (D39.110). StateDir string + // BooksDir is the root the intake creates book directories under — the same path that package + // holds, and READ ONLY: this package never writes under it. It is here for one question, asked + // at the door when a book's directory is missing (sourceThere): is this book's own directory + // gone, or the whole volume? Empty answers "the storage is not there", which refuses the start + // as a deployment fault — the safe direction on an instance that was never configured to hold + // books, since it cannot tell the two apart and must not blame a book for it. + BooksDir string // EngineBinary is the VERSIONED path of tmctl (unified backlog row 139). An attempt is pinned to // the path it was started with, so a deploy of the engine mid-run cannot change what a resumed // attempt executes. @@ -161,6 +171,27 @@ var ErrRunnerIncomplete = errors.New("runs: this deployment cannot record how a // what cannot happen yet is a translation of it. var ErrBookNotReady = errors.New("runs: the book is not ready to be translated") +// ErrSourceGone is a book whose project directory is not on this deployment any more. +// +// ⚠ IT IS A KIND OF ErrBookNotReady rather than a code of its own, and the wrapping is what a client +// reads: the contract's `book_not_ready` already means "the book is there and a translation of it +// cannot happen", which is exactly this, and the second-level cause is where the difference belongs — +// waiting clears the ordinary one and never clears this one. What it must not become is silence +// after the money moved: a run over a directory that is not there spawns, fails, respawns and holds +// the account's credit until a person comes (PD-162). +var ErrSourceGone = fmt.Errorf("%w: its source directory is not on this deployment", ErrBookNotReady) + +// ErrStorageUnavailable is the book STORAGE ROOT missing under a book whose directory is missing +// with it — an unmounted volume, or an instance pointed at a path that is not there yet. +// +// ⛔ TOLD APART FROM ErrSourceGone BECAUSE THE FAULT IS THE HOST'S, and reading the host's fault as +// the book's is a defect this repository has already paid for once: in the intake it rejected every +// book on the volume with a reason that blames the user's file and has no way back (PD-192). Here the +// price would be smaller and the same in kind — a user told their book is unusable while the truth is +// that an operator has a volume to mount — so it travels as the deployment's own 503, beside the +// refusals of a runner that was never wired. +var ErrStorageUnavailable = errors.New("runs: the book storage is not mounted on this deployment") + // ErrNotStoppable is a stop asked for on a run that is already over. var ErrNotStoppable = errors.New("runs: the run is not live") @@ -211,6 +242,16 @@ type Options struct { pricing.Options // BlockedBy is the book whose hold is holding this one's order down, or "". BlockedBy string + // Refusal is what the door would answer a start on this book right now, from the book's own + // facts — nil when nothing about the book stands in the way. ADVISORY by construction: it is read + // outside the book lock, so a run can begin or end between this read and the click, and the start + // remains the only authority on whether a start may happen (canon §RunOptions). + // + // ⚠ What it is FOR is the systematic case rather than the one-second race: a run that has been + // going for minutes was invisible to this form, so the form said `covers_all` over a book the + // door would refuse and the user learned it by clicking (PD-455). Racing the door is not what + // this closes and not what a client should expect of it. + Refusal error // Structure is where the chapter cut came from, verbatim from the engine. ChapterOrders says // whether an order may be phrased in CHAPTERS against it at all — see ingest.ChapterOrdersOffered, // which trusts `detected` alone. @@ -240,6 +281,15 @@ func (s *Service) Order(ctx context.Context, userID, bookID string) (Options, er if !priced.Priced { return Options{}, ErrNotPriced } + // The door's own read of the book, and a SECOND read beside the one above rather than more + // columns on it. The two answer differently shaped questions — one row of facts about the book + // against one row per chapter — which is the reason PricedBook gives for being separate, and it + // holds in this direction too: the form asks the door's question with the door's own query, so + // there is one definition of "may a run start here" and not a copy of it phrased for the form. + facts, err := s.Store.ReadBookForRun(ctx, userID, bookID) + if err != nil { + return Options{}, err + } held, heldAmount, err := s.Store.CreditHeldBy(ctx, userID, bookID) if err != nil { return Options{}, err @@ -251,6 +301,7 @@ func (s *Service) Order(ctx context.Context, userID, bookID string) (Options, er Options: s.Pricing.Order(book, acct.Balance), Structure: priced.Structure, ChapterOrders: ingest.Manifest{Structure: priced.Structure}.ChapterOrdersOffered(), + Refusal: startable(facts), } for _, c := range book.Remaining { out.SourceChars += c.SourceChars @@ -340,39 +391,16 @@ func (s *Service) Start(ctx context.Context, in StartRequest) (pgstore.Run, erro if err != nil { return pgstore.Run{}, err } - if !readyToTranslate(book.Status) { - // A book whose intake has not finished has no chapter tree to translate and, while it is - // `uploading`, half a file on disk. Refused here rather than discovered by the engine inside a - // transient unit, where the only trace would be a marker reading "exit-code 1" — and after the - // account's money had already been held for it. - return pgstore.Run{}, fmt.Errorf("%w: it is %s", ErrBookNotReady, book.Status) + // The book's own half of admission, AUTHORITATIVE here because it is read under the lock. The + // order form asks the same predicate before the click, where the answer is advisory (Order). + if err := startable(book); err != nil { + return pgstore.Run{}, err } - if book.ChapterCount > 0 && !book.HasTree { - // The book DECLARES chapters and its tree has not been materialised: the intake committed - // `not_started` and the materialisation that follows it — in a separate transaction, outside - // the intake's — failed or has not run. Refused HERE, before the hold, because there is no - // honest bar for such a run and no way out of it either (PD-405). Every counter the screen - // shows is a count over `chapters`, so the run would read 0/total for its entire life while - // spending; and the tree's own debt is FROZEN while it runs, because the sweep that pays that - // debt skips a book with a live run. The window is the intake's normal one — measured at - // 0.018 s on the healthy path — and unbounded exactly when the materialisation broke, which is - // the only shape worth refusing. - // - // `book_not_ready` is the word for it, not a new one: the canon's own gloss is "still - // arriving, still being cut, or was rejected", and a book that owes its tree is still being - // cut. The remedy is to wait for the intake, which is what the status says. - // - // ⚠ ONE population does not clear by waiting, and the operator's handle for it exists: a book - // whose reading-surface debt was WRITTEN OFF after its attempts (AbandonReadModelDebt) owes - // nothing, so no sweep will build its tree and this refusal stands until somebody asks again — - // `tmplatformctl book refresh --book `, which is exactly what that command is for, and the - // books behind it are listed by `tmplatformctl books --abandoned`. Named here because this - // guard is what turns a book that merely showed a frozen bar into one that cannot be started: - // the refusal is the honest half, and being able to find the remedy is the other. - return pgstore.Run{}, fmt.Errorf("%w: its chapters are still being materialised", ErrBookNotReady) - } - if book.HasLiveRun { - return pgstore.Run{}, pgstore.ErrRunInFlight + // And the half only the disk can answer, asked BEFORE the money: a run over a book whose + // directory is not there can never move, and until it was asked here the platform took the hold + // first and discovered the absence afterwards, in a place with no way back (PD-162). + if err := s.sourceThere(book.Workdir); err != nil { + return pgstore.Run{}, err } acct, err := s.Store.ReadAccount(ctx, in.UserID) if err != nil { @@ -613,6 +641,111 @@ func readyToTranslate(status string) bool { return false } +// startable is what THIS BOOK cannot do, beside runnable's what this DEPLOYMENT cannot do: the whole +// rule a start is judged by before money or disk is looked at, and the one definition of it. +// +// ⚠ IT HAS TWO BINDINGS OF DIFFERENT AUTHORITY, and that asymmetry is the design rather than a +// shortcut. `Start` binds it under the book lock, where its answer decides whether the run happens; +// `Order` binds it on the path a client POLLS, where the same answer is advisory and can be stale +// before the user clicks — the canon says as much in its own words, «the only authority on whether +// THIS start may happen; every read below can be stale» (§RunOptions). What one definition buys is +// that the two cannot drift apart: while the live run was known to the door alone, the form answered +// `covers_all` over a book the door was about to refuse, and the click met a refusal the form had +// the facts to predict (PD-455). A rule changed here changes both. +func startable(book pgstore.BookRunContext) error { + if !readyToTranslate(book.Status) { + // A book whose intake has not finished has no chapter tree to translate and, while it is + // `uploading`, half a file on disk. Refused here rather than discovered by the engine inside a + // transient unit, where the only trace would be a marker reading "exit-code 1" — and after the + // account's money had already been held for it. + return fmt.Errorf("%w: it is %s", ErrBookNotReady, book.Status) + } + if book.ChapterCount > 0 && !book.HasTree { + // The book DECLARES chapters and its tree has not been materialised: the intake committed + // `not_started` and the materialisation that follows it — in a separate transaction, outside + // the intake's — failed or has not run. Refused HERE, before the hold, because there is no + // honest bar for such a run and no way out of it either (PD-405). Every counter the screen + // shows is a count over `chapters`, so the run would read 0/total for its entire life while + // spending; and the tree's own debt is FROZEN while it runs, because the sweep that pays that + // debt skips a book with a live run. The window is the intake's normal one — measured at + // 0.018 s on the healthy path — and unbounded exactly when the materialisation broke, which is + // the only shape worth refusing. + // + // `book_not_ready` is the word for it, not a new one: the canon's own gloss is "still + // arriving, still being cut, or was rejected", and a book that owes its tree is still being + // cut. The remedy is to wait for the intake, which is what the status says. + // + // ⚠ ONE population does not clear by waiting, and the operator's handle for it exists: a book + // whose reading-surface debt was WRITTEN OFF after its attempts (AbandonReadModelDebt) owes + // nothing, so no sweep will build its tree and this refusal stands until somebody asks again — + // `tmplatformctl book refresh --book `, which is exactly what that command is for, and the + // books behind it are listed by `tmplatformctl books --abandoned`. Named here because this + // guard is what turns a book that merely showed a frozen bar into one that cannot be started: + // the refusal is the honest half, and being able to find the remedy is the other. + return fmt.Errorf("%w: its chapters are still being materialised", ErrBookNotReady) + } + if book.HasLiveRun { + return pgstore.ErrRunInFlight + } + return nil +} + +// sourceThere is the DISK half of admission: the book's project directory, asked for before any +// money moves. +// +// ⛔ IT IS THE DOOR'S ALONE AND DELIBERATELY NOT THE FORM'S, unlike startable above. The form is +// polled, and a stat of a hung mount would take the poll down with it; the door is one call the user +// is already waiting on. The price of the asymmetry is named rather than hidden: the form stays +// silent about a vanished directory and says `covers_all` over it — what it no longer does is stay +// silent while the money is TAKEN, which is the half PD-162 is about. +// +// ⚠ There is no "the journal has not been written yet" case to tell apart here, and looking for one +// sends a reader to the wrong file: the directory is created ON INTAKE, before the row that points +// at it (books.Accept, MkdirAll ahead of the insert), so a row always names a directory that existed. +// The first-run exception belongs to the JOURNAL inside it — journalSize maps ENOENT to a zero +// offset, and that is the line this check stands beside rather than duplicates. +func (s *Service) sourceThere(workdir string) error { + if st, err := os.Stat(workdir); err == nil { + if st.IsDir() { + return nil + } + // Something is at the path and it is not a project directory. The platform never wrote that, + // so it is not this book's own end to announce — it is an anomaly an operator is owed. + return fmt.Errorf("%w: the book's directory is not a directory", ErrStorageUnavailable) + } else if !errors.Is(err, fs.ErrNotExist) { + // A directory that is there and cannot be read — a permission, an I/O error — is the host's + // business exactly as an absent volume is, and the run is refused before the money either way. + // + // ⚠ The PATH is dropped and only the operation and the errno are kept. `os.Stat` answers a + // *fs.PathError, whose text carries the book's own directory, and this error reaches an ERROR + // log through the handler: a user's book has no business being in one (PD-139, PD-99). The run + // id the log line already carries says which book to an operator who is allowed to ask. + var pe *fs.PathError + if errors.As(err, &pe) { + return fmt.Errorf("%w: %s: %w", ErrStorageUnavailable, pe.Op, pe.Err) + } + return fmt.Errorf("%w: %w", ErrStorageUnavailable, err) + } + // WHICH absence this is decides who is at fault, and the two are identical from one book: an + // unmounted volume leaves an empty mountpoint behind, so every book under it reads as "gone". + // Blaming the book there is the defect PD-192 cost the intake — a host-wide fault, answered as + // this book's own end — so the question is put to the storage marker the first upload wrote, not + // to the root's existence, and the two answers travel under different words: a deployment's 503 + // and the book's own 409. + // + // ⛔ AND THE MARKER IS ONLY EVIDENCE ABOUT THE ROOT IT LIVES IN. A book whose directory is NOT + // under the intake's root — `tmplatformctl book add --workdir` puts one anywhere, and so does an + // instance with no `BooksDir` at all — sits on a volume this platform never wrote and has no + // sentinel on. Reading the intake's healthy marker as evidence about THAT volume would announce a + // vanished mount as the book's own end, which is PD-192 again with a longer path. Where there is + // nothing to ask, the answer is the deployment's: this platform does not tell anyone their book is + // finished on the strength of a directory it never created. + if !books.Owns(s.Cfg.BooksDir, workdir) || !books.StorageIsThere(s.Cfg.BooksDir) { + return ErrStorageUnavailable + } + return ErrSourceGone +} + func (s *Service) enqueue(ctx context.Context, tx pgstore.Tx, runID string) error { if s.Queue == nil { return nil // no queue configured: the reconciler still picks the run up on its next sweep