diff --git a/platform/docs/archive/platform-PROGRESS-P4-P6.md b/platform/docs/archive/platform-PROGRESS-P4-P6.md new file mode 100644 index 00000000..1e7d5a2a --- /dev/null +++ b/platform/docs/archive/platform-PROGRESS-P4-P6.md @@ -0,0 +1,1700 @@ +# Архив журнала зоны «Платформа» — записи паков P4–P6 с дофиксами (08–15.08.2026) + +> **⚠ АРХИВ (вынесено оркестратором №17 16.08, слово владельца: журналы забивали контекст сессиям).** +> Все паки здесь ИСПОЛНЕНЫ и ПРИНЯТЫ (P4 — D39.123 · P5 — D39.130 · P6+дофикс — D39.132). Живой журнал +> держит: текущее состояние · пинги оркестратора (входы P7). Инструкции отсюда не исполнять. + +## Дофикс P6 (15.08): фикс-лист приёмки ФП-1…ФП-8 + +Промт — `PLATFORM_P6_DOFIX_SESSION_PROMPT.md`. Мандат владельца исполнен буквально: каждая находка +проверялась по коду ДО починки, а не принималась на веру. Опровергнутых нет — подтвердились все, но +две подтвердились НЕ так, как их описала приёмка, и об этом сказано в строках ниже. + +### Записка-план: ID → статус → улика + +| ID | Статус | Улика | +|---|---|---| +| ФП-1 exit 2 у `status` | ЗАКРЫТ | Подтверждён чтением движка: `cmd/tmctl/render.go` `renderStatusJSON` печатает отчёт и возвращает сентинел при `flagged>0`, `main.go` `exitCode` отображает его в 2. Платформа звала `status` на ТРЁХ путях — расчёт, ре-синк, счётчик перед спавном — и каждый читал ненулевой код как «движок не ответил». Чинено одним знанием (`ingest.CompletedWithFlags`): exit 2 при разбираемом отчёте = ответ, exit 2 с нечитаемым stdout = отказ, всякий иной ненулевой = отказ. Дев-путь выровнен. Пины `runner.TestAFlaggedBookStillAnswersAboutItsMoney`, `TestExitTwoWithNothingToReadIsStillARefusal`, `TestAReportPrintedByAFailedCallIsNotAnAnswer`, `ingest.TestTheDevelopmentStatusReadsAFlaggedBookToo` и сквозной `runs.TestTheMoneyOfAFlaggedBookIsSettledThroughTheRealExitContract`. PD-224 | +| ФП-1 «какие ещё команды выходят 2» | ЗАКРЫТ ЧТЕНИЕМ | Сентинел `CompletedWithFlags` рождается в движке в четырёх местах, принадлежащих трём командам (`render.go:70` результат прогона, `:553` `status --json`, `:639` `status` человеку, `:691` `redrive`). Платформа зовёт `translate` (код читается реконсилятором, `OutcomeFlagged`), `status` (починен) и `manifest` — а `renderManifest` возвращает nil для любого документа, который печатает, и другого пути к сентинелу у `manifestCmd` нет. `redrive` платформа не зовёт вовсе. Вывод записан доккомментом у `runner.Manifest`, вместе с тем, что случится, если движок это изменит: неклассифицированный код интейк уже читает как вину деплоя и файл пользователя не трогает | +| ФП-2 порядок апгрейда | ЗАКРЫТ | Подтверждён: шаг 2 звал голый `tmctl migrate` до установки нового бинаря — no-op старым. Порядок переписан: остановка демона (закрывает окно между списком и миграцией) → список → новый бинарь на версионированный путь → `migrate` ЕГО явным путём → переключение `TM_PLATFORM_ENGINE_BIN` → старт. «ДВА факта» приведены к трём. PD-225 | +| ФП-3 гейт миграции не запинен | ЗАКРЫТ | `pgstore.TestEachOfTheThreeBlockersAloneKeepsABookOutOfTheMigrationList`: по книге на каждый блокер, каждый по отдельности переводит вердикт в «нельзя», и список, который читает цикл деплой-заметки, содержит только свободную книгу. Живой прогон изолирован ИСКУССТВЕННО (его холд закрыт руками), потому что живой прогон всегда держит деньги — иначе один предикат прятался бы за другим. Три посадки, три падения. PD-226 | +| ФП-4 нечитаемый маркер хоронит потолок | ЗАКРЫТ | Подтверждён чтением: перечит причины стоял только на разбираемой ветке. Ветки слиты в одну (`case err == nil, errors.Is(err, runner.ErrBadMarker)`) — маркер на диске означает «юнит кончился» в обоих прочтениях, а решает о конце общий перечит. Пин `runs.TestACeilingSurvivesAMarkerThatCannotBeRead` (журнал с `ceiling{scope:day}` + маркер `{not json` → `paused`/`daily_ceiling`), посадка возвращает ровно то, что видела приёмка: `failed`/пусто. PD-227 | +| ФП-5 куки без Secure рядом с боевым OIDC | ЗАКРЫТ, форма выбрана иначе | Подтверждён: гейт ловил только связку с DEV_LOGIN. Легитимный сценарий, о котором спрашивал промт, СУЩЕСТВУЕТ — локальный провайдер по http на стенде, где `Secure`/`__Host-` не работают в принципе, — поэтому глухой отказ был бы неверен. Судит собственный адрес деплоя: `TM_PLATFORM_OIDC_REDIRECT_URL` и есть публичный URL платформы, значит `http://` там = стенд (запускается), всё прочее — отказ на буте. Пин `config.TestCookiesWithoutSecureAreRefusedNextToAProductionProvider` (4 отказных формы + 2 законных), посадка падает. PD-228 | +| ФП-6 незнакомый scope потолка | ВЗЯТ | Приор верен и подтверждён кодом: `ReadUsage` ключит аккаунтный halted-флаг ровно на `credit_exhausted`, а `CeilingPause` отдавал его всему, кроме `day`. Теперь называются только `book` и `day`, всё остальное = `ceiling_unknown`. Резюмируемость не меняется ни при одном из трёх, а ложного «кредит кончился» на аккаунте с деньгами больше нет. PD-229 | +| ФП-6 сид не тримит `--subject` | ВЗЯТ | Подтверждён чтением: демон тримит (`config.Load`), сид читал сырую переменную. Одна нормализация с обеих сторон; пин `TestASubjectThatIsOnlyPaddingIsNoSubject` — падённое значение обрывает прогулку ДО обращения к демону. PD-230 | +| ФП-6 комментарий у гарда `at >=` | ВЗЯТ, довод переписан | Приёмка права: `pipeline/events.go` штампует `at := e.now()` анонсирующего процесса, а непроецированная строка мёртвого прогона стирается `ForgetEvents` и анонсируется заново — со СВОИМ временем. Значит инверсии движок не порождает. Гард остаётся и обоснован тем, чем он есть на самом деле: потребитель at-least-once-потока, который ПРИСВАИВАЕТ, обязан не зависеть от порядка доставки. Довод приведён к факту и в коде, и в пинящем тесте. PD-231 | +| ФП-6 комментарий Resume про book-ceiling | ВЗЯТ, чурн — строкой | Приёмка права: движок останавливается на невместившемся РЕЗЕРВИРОВАНИИ, то есть до своего потолка, поэтому остаток положителен и ветка `exhausted` не срабатывает. Комментарий приведён к факту. Сам чурн (холд-спавн-пауза за клик, провайдерских денег не тратит) — строка PD-223, а не порог: размер резервирования — число движка, платформе не видное, и любой порог здесь был бы догадкой | +| ФП-6 exit 5 со стопом позже маркера | ВЗЯТ | Подтверждён: `stoppedOnRequest` отвергал по времени, и прогон, который пользователь отменил, закрывался `failed`. Exit 5 недостижим для прогона, кончившегося сам, а два штампа приходят с РАЗНЫХ часов (маркер пишет хост юнита, намерение — платформа). Теперь exit 5 при записанном намерении = `stopped` независимо от порядка. Гард «чистый финиш перебивает стоп» проверен отдельно и не сдвинулся: 0/2/3 отвечены раньше в том же switch — это пинится тремя строками того же теста. PD-233 | +| ФП-7 строки регистра | ЗАКРЫТ | PD-217…PD-222 — шесть находок приёмки, которые этим касанием не чинятся, плюс PD-223 своя. PD-198/PD-199 не тронуты. Ни одну из шести чинить сверх строк не стал: каждая либо требует решения вне зоны (PD-217, PD-218), либо лечится вместе с соседом (PD-219 — с читающей поверхностью P7), либо сегодня инертна (PD-220, PD-221) | +| ФП-8 базис тестов | ЗАКРЫТ | Числа пересчитаны командами (ниже), шапка P6 исправлена, откуда взялось «396» — не установлено. PD-234 | +| Аддендум владельца 15.08 (`day_usd`) | ИСПОЛНЕН | Раздел отдельным пунктом ниже: из ПРИМЕРА шаблона убран, ⚠-абзац переписан как опция оператора, платформенная обработка `daily_ceiling`/409 названа предохранителем. Кода не касается | +| Своё ревью (4 линзы) | 6 ПРИНЯТО И ПОЧИНЕНО, 5 СТРОКАМИ, 3 ОПРОВЕРЖЕНИЯ ПРИНЯТЫ | Таблица «находка → диспозиция» ниже. Самая тяжёлая — моя же: фикс ФП-4 сделал ветку решающей, а глоток ошибки перечитки в ней остался (PD-236) | + +### Аддендум оркестратора к ФП-6а (решение владельца 15.08) — отдельным пунктом + +`ceilings.day_usd: 5` убран из ПРИМЕРА платформенного шаблона книги (`deploy/README.md`), остался +только `book_usd`. Довод владельца, записанный там же: трату прогона уже жёстко ограничивает +купленный объём — платформа держит холд и передаёт движку `--ceiling-usd` флаш к нему, — поэтому +дневная ось поверх этого второй лимит на ту же трату, а стоит она дороже, чем даёт: остановленный ею +прогон резюмировать нельзя (409), и пользователю эта пауза необъяснима, потому что лимит не его и он +его не видит. + +Движок и его `day_usd` не тронуты. ⚠-абзац переписан так, чтобы дневной лимит читался как ОПЦИЯ +оператора, а не дефолт, и там же сказано, что платформенная обработка `daily_ceiling`/409 из P6 +остаётся ПРЕДОХРАНИТЕЛЕМ ровно на случай, когда оператор впишет лимит руками: она делает такую +остановку различимой и честной, а не дефолтом, который её вызывает. Код не менялся — это правка +рекомендации, и `day_usd` в фикстуре `render_test.go` остаётся намеренно: там он стоит за «ключ +оператора, который рендер обязан пронести нетронутым». + +### Адверсариальное ревью дофикса (author≠reviewer, механизмом) + +Четыре линзы субагентами по осям, названным промтом, плюс своя: шов против РЕАЛЬНОГО движка · деньги +(пересчёт расчёта флагнутого прогона из сырого леджера двумя путями) · конфиг-отказы (security, +**другая модель-семья** — Fable) · автомат состояний. Каждая работала на живом стенде и в КОПИИ репо +вне его; рабочее дерево ревьюеры не трогали. + +Денежная ось отчиталась цифрами: холд 3.000000 и его возврат гасят друг друга, списание 1.250000 +одно, и четыре независимых счёта сходятся — свёртка сырых строк `credit_ledger`, `грант − (committed +− baseline)`, `account_balances` и сумма `ReadAccount`: **8 750 000** micro-USD везде. + +| Находка | Линза | Диспозиция | +|---|---|---| +| Неудачная перечитка причины хоронит потолок как `failed`; тот же глоток заново вооружает перезапуск | автомат состояний | **ПРИНЯТА, починена, запинена** — PD-236. Мой же фикс ФП-4 сделал ветку решающей, а глоток остался; ошибка чтения теперь возвращается наверх | +| Exit 2 берётся по коду, без согласия отчёта (измерено: чужой отчёт РАССЧИТЫВАЕТ 2.5 USD) | деньги | **ПРИНЯТА, починена, запинена** — PD-237 | +| Документ, который не манифест, приезжает пустым манифестом → удаление загрузки | шов | **ПРИНЯТА, починена, запинена** — PD-238 (+ различение «глав нет» и «глав нет, а юниты есть») | +| `Manifest` держится на непроверяемом инварианте чужой зоны | шов | **ПРИНЯТА, починена, запинена** — PD-239: правило читается, а не предполагается | +| `Status` читает stdout без потолка (найдено двумя линзами независимо) | шов + деньги | **ПРИНЯТА, починена, запинена** — PD-240; написание пина вскрыло вторую половину: дренаж бесконечного писателя не возвращается никогда | +| Подтест-пассажир в пине гейта ФП-5 (зелен при удалённом гейте) | security | **ПРИНЯТА, строка убрана** — PD-235 | +| Стоп пользователя переименовывается в `paused/credit_exhausted`, если потолок приехал в тот же дрейн | автомат состояний | **ПРИНЯТА строкой** — PD-241: порядок «причина раньше намерения» ратифицирован в P6, молча переворачивать не стал; денежно-видимая половина — PD-203 | +| `ceiling_unknown` проходит гейт резюма, отказывающий `daily_ceiling` | автомат состояний | **ПРИНЯТА строкой** — PD-242 (не регресс: прежнее значение резюмировалось так же) | +| `DecodeStatus` принимает `null`/`{}` за отчёт | шов | **ПРИНЯТА строкой** — PD-243; денежные пути перекрыты (PD-40 и новая проверка согласия), остаётся ETA | +| `settle` молча возвращает nil при `Engine == nil` | деньги | **ПРИНЯТА строкой** — PD-244 (в проде недостижимо) | +| У дев-супервизора нет потребителя вне тестов | шов | **ПРИНЯТА строкой** — PD-245; комментарий приведён к факту, судьбу пути решать оркестратору | +| Гейт ФП-5 обойти не удалось: 24 формы callback, `*_FILE`, схемные трюки | security | **ОПРОВЕРЖЕНИЕ ПРИНЯТО** — гейт устоял; ложных отказов законных стендов тоже не нашлось (прокси с TLS отказывается ПРАВИЛЬНО: браузер ходит по https, `Secure` там работает) | +| (B) exit 5 + намерение: полный перебор 216 клеток `outcome` против до-фиксной версии | автомат состояний | **ОПРОВЕРЖЕНИЕ ПРИНЯТО** — расходятся ровно две клетки, обе exit 5: одна начинает записывать код выхода, вторая и есть починка | +| PD-212 (Go выходит 2 при панике) расширяется ли новой терпимостью | шов | **ОПРОВЕРГНУТО ревьюером** — паника не оставляет целого документа, а неполный отвергается; строка остаётся про путь прогона | + +### Живые пробы этого круга + +- **Гейт ФП-5 — живым демоном, не только тестом.** С боевым callback: + `TM_PLATFORM_INSECURE_COOKIES=1` + полный OIDC + `TM_PLATFORM_OIDC_REDIRECT_URL=https://app.example.org/auth/callback` + → `ERROR fatal`, **exit 1**, сервис не поднялся. С локальным `http://127.0.0.1:8099/auth/callback` + тот же демон проходит конфигурацию и стартует. То есть отказ ловит именно ту форму, которую нашла + приёмка, и не ловит законный стенд. +- **Exit-контракт — настоящими процессами.** Пины `runner` гоняют не мок, а `/bin/sh`-движок, + печатающий отчёт и выходящий 2/1/11/13; сквозной денежный тест `runs` подменяет движок + скриптом, чьи ответ и код лежат в двух файлах, — так что путь от кода выхода до строки леджера + проходится целиком, а не по обе стороны стаба. +- **Батарея дофикса** — `make check` при живом PG (`~/.local/pgsql`, порт 55433) и обоих гейтах + окружения: EXIT=0, 17 пакетов, линтер 0 issues, скипов 0, `make vuln` — без уязвимостей. + +### Числа: командой, а не памятью (ФП-8) + +```sh +git grep -h '^func Test' HEAD -- 'platform/**/*_test.go' | wc -l # 359 — состояние лендинга P5 +grep -rh '^func Test' --include=*_test.go platform | wc -l # 416 — дерево после дофикса +``` + +То есть P6 + дофикс = **+57** тестовых функций; на конце P6 в дереве был 401 (счёт приёмки — сходится: +дофикс добавил 15, из них 6 — по находкам собственного ревью). Плюс 2 фаззера (`^func Fuzz`), в этот +счёт не входящие и исполняемые сид-корпусом. + +Батарея (`go test ./... -race -count=1 -v` при обоих гейтах окружения): + +```sh +grep -cE '^--- (PASS|FAIL|SKIP)' <лог> # 418 верхнеуровневых прогонов (416 тестов + 2 фаззера) +grep -cE '^ --- ' <лог> # 139 подтестов +grep -c -- '--- SKIP' <лог> # 0 +``` + +`make check` EXIT=0, 17 пакетов, линтер 0 issues, `make vuln` — без уязвимостей. Стенд: +PG 18.4 `~/.local/pgsql` порт 55433, свой `tmctl` из КОПИИ `backend/` вне репозитория. + +### Посадки (мутация → что упало) + +| Мутация | Упало | +|---|---| +| `runner.Status`: вернуть ошибку на ЛЮБОЙ ненулевой код | `TestAFlaggedBookStillAnswersAboutItsMoney`; сквозной денежный — сообщением «холд флагнутого прогона всё ещё открыт: 3.000000», то есть ровно симптомом приёмки | +| `Supervisor.Status`: то же на дев-пути | `TestTheDevelopmentStatusReadsAFlaggedBookToo` | +| `reconcile`: вернуть перечит причины на одну ветку | `TestACeilingSurvivesAMarkerThatCannotBeRead` — `failed`/пусто вместо `paused`/`daily_ceiling` | +| `outcome`: убрать `case ingest.OutcomeStopped` | `TestAGracefulSignalIsAStopOnlyWhenThisPlatformAskedForOne` | +| `CeilingPause`: вернуть `credit_exhausted` по умолчанию | `TestTheCeilingScopeDecidesWhichPauseTheRunGets` | +| `config.Load`: убрать гейт INSECURE_COOKIES+OIDC | `TestCookiesWithoutSecureAreRefusedNextToAProductionProvider` | +| `seed`: убрать `TrimSpace` | `TestASubjectThatIsOnlyPaddingIsNoSubject` | +| `BooksForMigration`: убрать предикат живого прогона | `TestEachOfTheThreeBlockersAlone…` — `bk-live` объявлен мигрируемым | +| …убрать предикат резюмируемого | то же на `bk-paused` | +| …убрать предикат незакрытого холда | то же на `bk-hold` | +| `freshPausedReason`: вернуть падение на снапшот | `TestAnEndingIsNeverDecidedFromAReadThatFailed` — ошибка приходит уже от записи, а не от чтения | +| `Status`: брать exit 2 по коду и разбору, без согласия отчёта | `TestExitTwoIsTakenOnlyFromAReportThatAgreesWithIt` (4 подтеста) | +| `DecodeManifest`: убрать проверку `manifest_version` | `TestADocumentThatIsNotAManifestIsNotAnEmptyManifest` | +| `Parse`: читать «глав нет» в одиночку | `TestAManifestThatContradictsItselfNeverCostsTheUpload` | +| `Manifest`: вернуть «любой ненулевой = отказ» | `TestAManifestThatCompletesWithFlagsIsStillAManifest` | +| `drain`: дренить и за капом вместо убийства процесса | `TestAnEndlessStatusIsRefusedRatherThanRead` — виснет и падает по таймауту | + +Пятнадцать из пятнадцати. Непинябельных в этом круге нет. + +### Чего я НЕ сделал — явным списком + +- **Контракт `openapi.yaml` и его зеркало** — не тронуты (PD-199 ждёт слова владельца, правит S4). + `ceiling_unknown` и `daily_ceiling` на провод по-прежнему не проецируются. +- **`tmctl migrate` живьём не гонялся** — движковой команды всё ещё нет в HEAD. Порядок в + `deploy/README.md` исправлен и стал исполнимым, но проверен ЧАСТИЧНО: список `--migratable` на + стенде, сам `migrate` — нет. Самолечение по `schema_mismatch` вслепую не строил (PD-201). +- **Читающая поверхность (P7), эскроу (строка 136), брокер** — нет. +- **PD-217…PD-222 и PD-241…PD-245 не чинены** — заведены строками, каждая с причиной, почему не + этим касанием. +- **Порядок «причина паузы раньше намерения стопа» не переворачивал** (PD-241): это ратифицированное + решение P6, и его смена двигает клетки за пределами фикс-листа — вопрос оркестратору. +- **Промт дофикса не трогал** (`PLATFORM_P6_DOFIX_SESSION_PROMPT.md` — артефакт оркестратора). + +### Вопрос на ратификацию, добавленный этим кругом + +К трём вопросам P6 добавляется один: **порядок «потолок раньше стопа» в `outcome`** (PD-241). Сегодня +прогон, который пользователь остановил, показывается как `paused/credit_exhausted`, если в тот же +дрейн приехал потолок, — и этим зажигает аккаунтный halted-флаг у аккаунта с деньгами. Обе половины +— и статус, и флаг (PD-203) — про одно: что именно платформа обязана показать, когда две правды +пришли вместе. + +## P6 (14.08): потребительская половина шва · интейк формы Б · дев-стенд + +Промт — `PLATFORM_P6_SESSION_PROMPT.md`, плюс **аддендум оркестратора** по ресёрчу деплоя (принят +эхом, отдельный пункт ниже). Всё, что здесь заявлено, проверено ИСПОЛНЕНИЕМ; там, где проверка +ждёт, это сказано словом «ждёт», а не пропущено. + +### Записка-план: ID → статус → улика + +| ID | Статус | Улика | +|---|---|---| +| П-15 (а) exit-коды | ЗАКРЫТО | `ingest/exit.go` — ОДИН словарь shell-контракта на зону (реконсилятор, интейк, дев-супервизор). 4 = `paused` двумя независимыми каналами · 5 без намерения = прерывание и перезапуск · 10–19 = полоса. PD-113 и PD-152 закрыты, PD-196 закрыт потребительской половиной. Пины: `TestWhatTheUnitDidBecomesTheProductStatus` (13 строк таблицы) · `TestOutcomeOfCoversTheWholeExitContract` · `TestAGracefulSignalIsAStopOnlyWhenThisPlatformAskedForOne` | +| П-15 (б) фолд присваиванием | ЗАКРЫТО | таблица `unit_resolutions` (миграция 00015) по тройке (chapter, unit, wave); счётчики глав — `count(*)`, не `+1`. Пин `pgstore.TestAUnitAnnouncedTwiceIsCountedOnce` (новый seq с тем же юнитом — то, что курсор поглотить не может) | +| П-15 (в) 1.1 · scope · outcome | ЗАКРЫТО | `StreamVersion = "1.1"`, `Ceiling.Scope`, `Outcome` как ОДИН словарь для терминальной строки и кода выхода. Дневной потолок различим: внутренняя причина `daily_ceiling`, резюм отвечает 409 с диагностикой. Пины `TestTheCeilingScopeDecidesWhichPauseTheRunGets` · `TestAResumeOfARunTheEnginesDailyCeilingStoppedIsRefused` | +| П-15 (г) дев-супервизор | ЗАКРЫТО | `outcomeOf` удалён, дев-путь читает ту же `ingest.OutcomeOf`; два словаря exit-кодов в зоне больше не существуют | +| П-15 watch: foreign-hello | ВОСПРОИЗВЕДЁН и ЗАКРЫТ | дефект оказался тяжелее наблюдения: чужой поток усыновлялся, его `ceiling` материализовался на нашу попытку (ложная `paused`), а собственный hello потом валил проекцию в карантин НАВСЕГДА. Платформа теперь НАЗЫВАЕТ поток (`TM_TRACE_ID`), и называет его при создании строки попытки, а не при спавне. PD-200, PD-210 | +| П-14 интейк формы Б | ЗАКРЫТО | рендер в ОДНОМ месте (`books.provision`), из `TM_PLATFORM_BOOK_TEMPLATE`, работой с YAML-УЗЛОМ (комментарии и незнакомые ключи оператора живы, значения — строки), `O_EXCL`. Битый шаблон = класс, который ЖДЁТ. Проверено настоящим `tmctl manifest`: 3 главы, exit 0 | +| П-16 дев-стенд | ЗАКРЫТО | `tmplatformctl seed` ходит по HTTP теми же дверями, что пользователь; дев-вход с четырьмя гардами непроходимости в проде + пятым после ревью; рецепт страницей в `deploy/README.md`. Прогнано на стенде целиком | +| Деплой-порядок v15 | ЗАПИСАН, проверка ЖДЁТ | правило «эмиттер-бинарь не на стенд до `tmctl migrate`» + порядок в `deploy/README.md`, шаг сделан ИСПОЛНИМЫМ (`tmplatformctl books --migratable`). Сам `tmctl migrate` не гонялся — движковая половина не заленджена; помечено ждущим, НЕ сделанным (PD-201) | +| Попутные PD | ЗАКРЫТЫ 141 · 163 · 164 · 165; 154 ПЕРЕ-ДИСПОЗИЦИРОВАН | PD-165 закрыт ДВУМЯ разными ответами: относительный `CTL_BIN` отвергается на буте (воспроизведено: systemd 259 не стартует юнит), а `$` ЗАМЕРЕН и оказался безопасен — экранирование было бы ошибкой | +| Гигиена доков | ЗАКРЫТО | README · STACK_DECISIONS · BACKLOG · регистр (секции, порядок, веса, шапка) · PLATFORM_DIRECTION — с ПРЕДЛОЖЕНИЕМ пере-подписи, а не тихой правкой | + +### Аддендум оркестратора (ресёрч деплоя 14.08) — отдельным пунктом, как просили + +1. **«Прогоны остановлены» = нет живых юнитов И нет открытых резюмируемых попыток.** Записано в + деплой-док и сделано ИСПОЛНИМЫМ: `tmplatformctl books` печатает колонку MIGRATE и причину отказа, + `--migratable` — только безопасные каталоги, по одному на строку, чтобы шаг деплоя был циклом, а + не абзацем. Блокируют ТРИ разных факта, и они названы порознь, потому что снимаются по-разному: + живой прогон · резюмируемый прогон (его попытка запинена к СТАРОМУ бинарю, и миграция заставила + бы резюм перейти на новый — риск пере-оплаты уже купленных вызовов) · незакрытый холд (расчёт + читает `committed_usd` тем же запиненным бинарём, после миграции цифру уже не прочитать). +2. **Расчёт денег зовёт ЗАПИНЕННЫЙ бинарь.** Проверено чтением и исполнением: код был уже верен + (`s.engineBinary(l)` на всех трёх путях — расчёт, ре-синк, чтение счётчика перед спавном), но + ПИНА не было, а фейк движка выбрасывал аргумент. Фейк теперь записывает, чем его звали; пин + `runs.TestTheMoneyOfAFinishedRunIsReadWithTheBuildItRanWith`, посадка «звать `Cfg.EngineBinary`» + падает с именем нового бинаря в сообщении. +3. **Самолечение по машиноразличимой ошибке — НЕ построено, и вот что построено вместо.** Пока я + работал, параллельная бэкенд-сессия завела в дереве движка exit-код **13** (`schema_mismatch`) и + класс `RefusalSchemaMismatch` — незакоммиченными. Вслепую я не строил и автоматику «поймала → + `migrate` → повтор» не делал: она означает, что ПЛАТФОРМА исполняет мутирующую команду движка, и + это решение под ратификацию вместе с движковой половиной. Что сделано: код 13 заведён в словаре и + отображён на класс, который ЖДЁТ человека и не тратит бюджет попыток, — то есть книга на хосте + посреди апгрейда не отклоняется, а дожидается `migrate`. ⚠ Номер прочитан из НЕЗАКОММИЧЕННОГО + дерева движка; если он приедет другим, менять одну строку `ingest.ExitSchemaMismatch`, и ошибка в + обе стороны недеструктивна (неизвестный код полосы и так падает в безопасный класс). PD-201. + +### Живые пробы (команды и что они показали) + +Стенд: PG 18.4 `~/.local/pgsql` порт 55433 · демон `tmplatformd` на 127.0.0.1:8099 · свой `tmctl`, +собранный из КОПИИ `backend/` вне репозитория · фейк движка с ПРАВИЛОМ потолка (умеет отказать — +иначе он ничего не проверяет, урок D39.123). + +| Что проверялось | Чем | Результат | +|---|---|---| +| Рендер даёт конфиг, который движок ГРУЗИТ | `tmctl manifest --config <рендер> --json` | exit 0, `chapters_total: 3`, `book_id` = id платформы; комментарий оператора и его `ceilings` в файле целы, плейсхолдер `book_id: WILL_BE_REPLACED` заменён, не продублирован | +| Полоса отказов на НАСТОЯЩЕМ движке | прогон без ключей провайдера на стенде | маркер `exit-code/exited/10` → прогон `failed`, холд вернулся ЦЕЛИКОМ ($30 → $30), файл цел, `ceiling_arg = 90000` (3 главы × $0.03) | +| Потолок книги (PD-113) | фейк с правилом, `--usd 0.10` | журнал: `ceiling{scope:book}` + `finished{ceiling}`, exit 4 → карточка `paused`/`credit_exhausted`, прогресс 4/6 ИЗ ПОТОКА, расчёт $0.08 из $5.10 | +| Дневной потолок и резюм | тот же фейк, `SCENARIO=daily-ceiling` | хранится `paused/daily_ceiling`, на проводе `paused_reason: null`, повторный резюм — **409** и НИ ОДНОГО спавна | +| exit 5 без намерения | `systemctl --user stop` по живому юниту | маркер `exited/5` → попытка 1 `interrupted`, попытка 2 открыта, прогон `translating` | +| exit 5 с намерением | `POST /v0/runs/{id}/stop` | маркер `exited/5` → `stopped`, второй попытки нет | +| Дев-вход + сид целиком | `tmplatformctl seed --url …` | аккаунт `dev/dev@stand`, грант, загрузка через ЖИВОЙ `POST /v0/books`, интейк `not_started, 3 chapters` | +| Гейт дев-входа | демон с `TM_PLATFORM_DEV_LOGIN` И боевым OIDC | `fatal: … cannot be enabled on a deployment that has an OIDC provider` — не стартует | +| `$` в `ExecStopPost` (PD-165) | `systemd-run --user --property=… --setenv=dir=EXPANDED` | путь доехал ЛИТЕРАЛЬНО (`…/dollar$dir/`), то есть экранирование в `$$` было бы ошибкой | +| Относительный `CTL_BIN` (PD-165) | `systemd-run` с `./tmplatformctl` | «neither a valid executable name nor an absolute path» — юнит не стартует вовсе | + +⚠ **Найдено ЖИВОЙ пробой, а не чтением:** внутренняя причина `daily_ceiling` уезжала на провод, где +контракт знает одно значение и клиент, сгенерированный по спеке, её бы отверг. Починено +(`contractPausedReason`), строка PD-199 — вопрос владельцу контракта. Второе: сид упёрся в CSRF-слой +(`X-TM-Client` обязателен на unsafe-запросе с кукой) — ровно в ту дверь, которую обязан правильно +открывать фронт; это и есть причина, по которой сид ходит по HTTP, а не пишет строки сам. + +### Посадки + +**Первый круг — 20 из 20 пойманы**, но не с первого захода: три посадки ПЕРЕЖИЛИ, и каждая означала +слепой пин, а не безобидность мутации. + +| Пережившая посадка | Почему пин был слеп | Что сделано | +|---|---|---| +| `mine := offset > 0` в тейлере | тест ставил чужой поток ПЕРЕД своим hello, а чужой hello сам выключает `mine` — мутация не наблюдалась | пин переписан на реальный сценарий: хинт попадает ВНУТРЬ чужого потока (после его hello), и мутация даёт `sequence gap` → карантин здорового прогона | +| `O_EXCL → O_TRUNC` в рендере | при существующем файле открытие не достигается — гарду это не свойство | тест переписан: первая ходка падает на ДВИЖКЕ (книга остаётся в интейке), шаблон удаляется, вторая ходка обязана его не искать. Ловится снятие short-circuit. ⚠ Сам `O_EXCL` НЕ пинится и это названо в тесте: две одновременные ходки пишут одинаковые байты, наблюдать нечего | +| `PauseRun` всегда рапортует успех | я запустил посадку по несуществующему имени теста | перезапущено против `TestAPauseFromAnOldSnapshotDoesNotCloseARunOverALiveAttempt` — падает | + +**Второй круг (находки панели) — 6 из 6**, седьмая названа непиняемой: фолбэк причины в ветке +исчерпания бюджета недостижим по построению (в `restart` попадают только ветки, где причины нет), +и это записано в самом коде, а не заявлено закрытым. + +### Адверсариальное ревью (author≠reviewer, механизмом) + +Четыре независимых ревьюера по диффу с установкой ОПРОВЕРГАТЬ, из них **опровергатель другой +модели-семьи (Fable) на security-оси** — норма D39.120. Линзы: деньги и гонки · безопасность +дев-входа · соответствие потребителя реальному движку и судьба файла пользователя · повторное +использование, общность, честность комментариев. + +| Находка | Линза | Диспозиция | +|---|---|---| +| Имя провайдера `dev` не зарезервировано: `TM_PLATFORM_OIDC_PROVIDER=dev` кладёт чужие `sub` в namespace дев-аккаунта | безопасность (Fable) | **ПРИНЯТА, закрыта.** Четвёртое из четырёх заявленных свойств было ЛОЖНЫМ без этого. `Load` отвергает имя, пин + посадка. PD-206 | +| `TM_PLATFORM_DEV_LOGIN=" "` монтирует вход с личностью «пробел» | безопасность (Fable) | **ПРИНЯТА, закрыта** тримом + пином | +| Дев-вход не получает второй CSRF-слой (`X-TM-Client` нужен только при уже имеющейся куке) | безопасность (Fable) | **ПРИНЯТА как остаток**, названа в коде и строкой PD-205: на стенде последствие ничтожно, но «POST, значит безопасно» — слабее, чем звучит. Тот же класс у боевого `/auth/login` | +| Ветка дев-входа стоит ПЕРВОЙ в switch — один регресс конфига даст ей перекрыть боевой OIDC | безопасность (Fable) | **ПРИНЯТА**, добавлен избыточный гард в `main.go`; он по построению недостижим, поэтому теста не имеет — сказано вслух | +| Грант дев-входа безусловен (боевой путь его придерживает у неподтверждённой личности) | безопасность (Fable) | **ПРИНЯТА как намеренная**: аккаунт с нулём кредита делает каждый прогон отказом. Не кран: строка леджера пишется только при INSERT личности — проверено в коде | +| `daily_ceiling` стирался двумя путями → ложный аккаунтный флаг + обход гарда резюма | деньги и гонки | **ПРИНЯТА, закрыта.** Третье значение `ceiling_unknown` + прогон с названной причиной не перезапускается. PD-209 | +| Окно усыновления между ДОПУСКОМ и спавном; `coalesce` отказывался чинить усыновлённое имя | деньги и гонки | **ПРИНЯТА, закрыта.** Имя даётся при создании строки попытки. PD-210 | +| Побочно: `Begin` стал недостижим, и с ним тихо выключился `chunker_version` | деньги и гонки | **ПРИНЯТА, закрыта** переносом эффекта в `effect`, пин + посадка. PD-210 | +| Апсерт юнита без гарда по времени: переанонс старой резолюции затирает новую | деньги и гонки | **ПРИНЯТА, закрыта** одним `where`; пин + посадка. PD-211 | +| Паника движка неотличима от «завершено с флагами» (Go-рантайм выходит РОВНО 2) | шов | **ПРИНЯТА, НЕ чинится здесь**: лечение — `recover` или другой номер, это движковая сторона. PD-212, запрос уходит строкой единого бэклога | +| Форма манифеста не версионируется → будущий дрейф даст `ChaptersTotal=0` → удаление файла | шов | **ПРИНЯТА как латентная**, строка PD-213. Сегодня формы совпадают поле-в-поле; лечить сверкой `manifest_version`, где незнакомая версия даёт НЕ-деструктивный класс | +| Чужая hello валит `Tail` до проверки «чья строка» → карантин здорового прогона навсегда | шов | **ПРИНЯТА**, строка PD-214: порядок проверок в `apply` лечится отдельно, класс не денежный | +| Код 13 (`schema_mismatch`) отсутствовал в словаре платформы | шов | **ПРИНЯТА, закрыта** (см. аддендум п.3) | +| Сид рапортовал «кредит начислен» о начислении, которого не было | повторное использование | **ПРИНЯТА, закрыта**: зовёт существующий хелпер `write`. Проверено на стенде — второй прогон печатает `no-op`. PD-208 | +| Петля перезапусков без счётчика и бэк-оффа; exit 12 терминален, хотя лок проходит сам | деньги и гонки | **ПРИНЯТЫ как остатки**, PD-215 и PD-216: лечатся вместе (перезапуск на 12 без бэк-оффа — это и есть PD-215) | +| `gopkg.in/yaml.v3` — последний релиз по этому пути модуля | повторное использование | **ПРИНЯТА как заметка**: переезд решается ОБЕИМИ зонами сразу, записано в `STACK_DECISIONS` §28 | +| Воскрешение прогона устаревшим снапшотом (нашёл сам при аудите диффа) | самоаудит | **ЗАКРЫТА** `RestartInput.OnlyIfLive`; пин + посадка «stopped → translating». PD-207 | + +### Чего я НЕ сделал — явным списком + +- **Читающая поверхность (SSE, главы/юниты/замечания, банк)** — пак P7, заведена строкой П-17. + Хранилище под первую половину уже есть: `unit_resolutions` несёт диспозицию каждого юнита. +- **Эскроу денег шва** (строка 136) и вместе с ним PD-154 — денежный промт, строка П-18. +- **Самолечение по `schema_mismatch`** — аддендум п.3 выше: вслепую не строю. +- **Спека `openapi.yaml`** — не трогал: её правит S4-сессия фронта. Отсюда PD-199. +- **`ReadUsage`** — не трогал, хотя ревью подтвердило: аккаунтный halted-флаг зажигается по паузе + ОДНОГО прогона (PD-203). Это продуктовая семантика уже построенной ручки; правка — на ратификацию, + а не тихо в паке про шов. +- **Порядок проверок в `apply` тейлера** (PD-214), **версионирование манифеста** (PD-213), + **бэк-офф перезапусков** (PD-215/216) — приняты строками, не построены: класс PD-83. +- **`O_EXCL` рендера** — не пинится и не заявлен пинённым. +- **Шаг `tmctl migrate` на стенде** — ждёт движковую половину. + +### Вопросы на ратификацию + +1. **`daily_ceiling` на проводе.** Контракт перечисляет одно значение `PausedReason`, платформа + различает два (плюс `ceiling_unknown`). Сейчас незнакомая контракту причина едет как `null` — + ровно то, что спека сама велит клиенту рендерить. Назвать причину в спеке (тогда правка — одна + функция `contractPausedReason`) или подтвердить `null`? PD-199. +2. **`PLATFORM_DIRECTION` §3.** Две строки не исполнены: `oapi-codegen` («ВЗЯТЬ — доказано») и + `sqlc` («до первого хендлера» — срок пройден, PD-44 открыт с P1). Зона не переписывает + ратифицированное направление сама: в §3 добавлен баннер статуса с доводами за и против + пере-подписи. Решение за оркестратором. +3. **Второй гейт батареи.** Живая проба рендера против НАСТОЯЩЕГО движка сделана постоянным тестом + (иначе клейм «движок это грузит» протухнет молча), и она гейтится + `TM_PLATFORM_TEST_ENGINE_BIN` + `TM_PLATFORM_TEST_BOOK_TEMPLATE` — той же формой, что у БД-тестов. + На голом клоне это ОДИН честный скип. Если приёмка считает скипы при одном лишь DSN, число будет + не ноль — и это не регресс, а новая ось проверки; форму гейта готов поменять по слову. + +## Третий раунд (14.08): ре-чек оркестратора FP5-9…FP5-11 и хвосты + +Все три находки подтвердились на коде до правки; возражений нет. Две из трёх — мои вчерашние фиксы, +которые ЗАЯВЛЕНЫ шире, чем сделаны, и это ровно тот класс, за который приёмка уже била. + +### FP5-9 [HIGH] Гейт тулчейна не отказывал 1.26.5 — и три дока это утверждали + +Проверено исполнением: `make version-check GO_VERSION=go1.26.5` на прежнем правиле проходил. +Регекс `go1\.26\.([5-9]|[0-9]{2,})` принимал ровно ту версию, ради отказа от которой floor и +поднимали, а `GO_MIN_VERSION` жил только в тексте сообщения. Тот же регекс ставил 1.26.10 НИЖЕ +1.26.9. Клейм «хост на 1.26.5 получит отказ» был ложен в трёх местах (журнал ×2, `STACK_DECISIONS`, +коммент Makefile) — гейт «поднят» был только на словах. + +Сделано, двумя независимыми механизмами: + +1. `make version-check` СРАВНИВАЕТ версии (`sort -V`), а не матчит; префиксы `rc`/`devel` отвергаются + отдельной веткой — пререлиз не несёт фиксов, которые обещает его номер. Цель отделена от + `tools-check` и берёт версию из переменной `GO_VERSION`, чтобы её можно было судить версиями, + которых на хосте нет. +2. `go.mod` получил `toolchain go1.26.6`. **Решение и обоснование** (оркестратор просил решить): floor + ЯЗЫКА (`go 1.26.4`) остаётся общим с движком — один стенд собирает оба модуля; директива + `toolchain` поднимает только тулчейн, то есть stdlib, которым линкуется сетевой модуль. Её читает + ВСЯКАЯ сборка, включая ту, что не зовёт make: при `GOTOOLCHAIN=auto` (дефолт) хост скачает 1.26.6 + вместо того, чтобы собрать уязвимый бинарь, при `=local` — остановится с ошибкой, называющей + версию. Обе ветки — тот ответ, который нужен; ни одна не зависит от того, прочитал ли кто-то + Makefile. Цена названа: офлайн-хост с `=local` и только 1.26.5 теперь не соберёт зону — это и есть + гейт. + +Пин — на СРАВНЕНИЕ, а не на прогон: новый пакет `internal/gates` гоняет `version-check` таблицей +версий, которых на этой машине нет (1.26.4/1.26.5/1.25.9/1.9.9/rc/devel — отказ; +1.26.6/1.26.7/1.26.10/1.27.0/2.0.0 — приём), и вторым тестом требует, чтобы `go.mod` и +`GO_MIN_VERSION` называли одну версию. Две посадки падают. Три ложных клейма переписаны на описание +механизма. Строка PD-197. + +### FP5-10 [HIGH] Гард корня не переживал unmount и маскировался бутовым `MkdirAll` + +Тоже подтверждено: `Stat(BooksDir)` отвечает «есть» про пустой mountpoint, оставшийся после +размонтирования тома, смонтированного РОВНО в `BooksDir`, — а бут безусловным `MkdirAll` пересоздаёт +корень после рестарта. То есть мой вчерашний фикс закрывал только форму «каталог удалён вместе с +родителем» и не закрывал ту, ради которой писался. + +Сделано по кандидату оркестратора — **сентинел провижининга**: файл `.tmplatform-books` в корне, +который пишет ПЕРВАЯ загрузка (`markStorage`, `O_EXCL`, поэтому две одновременные загрузки обе правы) +и не пишет больше никто — в частности, не пишет бут. Решение «хранилище на месте» принимается по +СЕНТИНЕЛУ (`storageIsThere`), а не по существованию каталога: ни unmount, ни `MkdirAll` его не +подделывают. Хост, который никогда не принимал загрузок, отвечает «нет» — безопасная сторона: у него +нет книг, которые можно отклонить. + +Пин — `books.TestAnUnmountedVolumeLooksLikeAnEmptyRootAndStillIsNotTheBooksFault`: удаляем всё под +корнем и пересоздаём сам корень (ровно то, что оставляют unmount и бут), десять циклов ожидания — +книга жива, бюджет цел, движок не спрошен, сентинел не воскрес. **Первая редакция этого пина посадку +ПЕРЕЖИЛА** (мутация «не писать сентинел вовсе»): без сентинела всё ждёт, и тест этого не отличал — +добавлено утверждение, что принятая загрузка сентинел ПИШЕТ, иначе терминальность крэш-окна (FP5-3) +теряется. Обе посадки падают. PD-192 дополнена. + +### FP5-11 Правило ревизии применено не ко всем писателям + +Пять писателей жизненного цикла ПРОГОНА остались на `revision + 1` против правила, которое дофикс +ввёл для трёх интейковых: старт, реоткрытие, пауза (`runs.go`) и два закрытия (`sink.go`). Значит +staleness библиотеки, которую вчера закрыли на статусах интейка, оставалась на статусах прогона: +книга не-максимума аккаунта уходила `translating` — библиотека не двигалась. + +Сделано: все пять берут `nextRevisionOfThisBooksLibrary`. Пин — +`pgstore.TestARunTransitionOnAnOlderBookStillMovesTheLibrary` (вторая книга держит максимум 41, +прогон стартует на первой; проверяются обе половины жизненного цикла — старт и пауза). Посадка +падает. + +⚠ **Мотивированное исключение, названное вслух:** два писателя `books.revision` НЕ переведены — +прогресс-путь синка (`bumpBook` на `unitDone`/`progress`). Причина не вкусовая: `unitDone` вычисляет +`chapters.revision` из `books.revision + 1` ДО инкремента, и прыжок книги через пол развязал бы +книжную и главную шкалы, а это отдельная scope-семантика контракта. Она и не нужна: прогресс +существует только у книги, у которой ИДЁТ прогон, а старт прогона теперь сам поднимает книгу на +уровень библиотеки — дальше `+1` монотонен и виден. Если правило когда-нибудь понадобится и там, +оно приедет вместе с проверкой главной шкалы, а не молча. + +### Хвосты ре-чека + +- **(а)** Коммент `PauseRun` обещал проверку «в том же стейтменте», а стоит отдельный + `select … for update` в той же транзакции. Текст приведён к коду (транзакция и её замки). +- **(б)** Комменты `parse.go` («terminal on the first answer») описывали до-дофиксное поведение на + пути, который УДАЛЯЕТ файл пользователя, — самое опасное место для устаревшего текста. Переписаны: + через бюджет попыток идёт КАЖДЫЙ ответ движка, удаляет только последний. +- **(в)** Остаток FP5-5 закрыт кодом, а не строкой: проход, которому осталось меньше, чем нужно + разбору, книгу НЕ НАЧИНАЕТ (клейм берётся внутри `Parse`, поэтому отложенная книга не тратит + ничего) и говорит об этом одной строкой лога вместо ошибки на книгу. Пин — + `books.TestAPassTooShortForAParseStartsNoneAtAll`, посадка падает. +- **(г)** Мета-пин systemd-гейта перечислителен — принято как названный остаток: он ловит возврат к + сверке сообщений по словам systemd и требует, чтобы гейт вообще спрашивал менеджер, но исчерпать + все будущие формулировки перечислением нельзя. + +### Посадки третьего раунда + +| Посадка (что откатывается) | Пин, который обязан упасть | Итог | +|---|---|---| +| гейт снова матчит регексом (принимает 1.26.5) | `gates.TestTheToolchainGateComparesVersionsRatherThanMatchingThem` | поймана | +| `go.mod` снова без `toolchain` | `gates.TestGoModPinsTheSameToolchainTheBatteryDemands` | поймана | +| гард снова смотрит на существование корня | `books.TestAnUnmountedVolumeLooksLikeAnEmptyRootAndStillIsNotTheBooksFault` | поймана | +| сентинел не пишется загрузкой | тот же | ПЕРЕЖИЛА → пин усилен → поймана | +| старт прогона снова `revision + 1` | `pgstore.TestARunTransitionOnAnOlderBookStillMovesTheLibrary` | поймана | +| проход снова начинает книгу, на которую нет времени | `books.TestAPassTooShortForAParseStartsNoneAtAll` | поймана | + +### Батарея третьего раунда + +- `make check` с живым PG 18.4 (стенд `~/.local/pgsql`, порт 55433) под `-race`: **EXIT=0, 16 + пакетов, скипов 0**, линтер **0 issues**, `make vuln` — чисто. +- Тесты `^func Test` исполнением: **354 → 359** (+5, удалённых 0). +- Посадки: **6 из 6** (одна со второго захода, см. таблицу). + +### Записка-план третьего раунда: ID → статус → улика + +| ID | Статус | Улика | +|---|---|---| +| FP5-9 | ЗАКРЫТО | `version-check` сравнивает (`sort -V`) + `toolchain go1.26.6` в `go.mod`; `internal/gates` ×2 пина, две посадки падают; три клейма переписаны. PD-197 | +| FP5-10 | ЗАКРЫТО | сентинел `.tmplatform-books`, пишет только загрузка; `books.TestAnUnmountedVolumeLooksLikeAnEmptyRootAndStillIsNotTheBooksFault`; две посадки падают. PD-192 дополнена | +| FP5-11 | ЗАКРЫТО с названным исключением | пять писателей прогона на `nextRevisionOfThisBooksLibrary`; `pgstore.TestARunTransitionOnAnOlderBookStillMovesTheLibrary`; посадка падает. Прогресс-путь синка НЕ тронут — обоснование выше. PD-122 дополнена | +| хвост (а) | ЗАКРЫТО | текст `PauseRun` приведён к коду | +| хвост (б) | ЗАКРЫТО | текст `parse.go` про терминальность приведён к коду. PD-198 | +| хвост (в) | ЗАКРЫТО кодом | проход не начинает книгу без полного бюджета; `books.TestAPassTooShortForAParseStartsNoneAtAll`; посадка падает. PD-189 дополнена | +| хвост (г) | ПРИНЯТО как остаток | перечислительность мета-пина названа, не «закрыта» | + + +## Дофикс-2 (13.08): кросс-семейное ревью САМОГО дофикса + +Пропуск, который я закрыл только по вопросу владельца: дофикс приёмки был проверен исполнением +(пины · посадки · батарея · живая проба), но НЕ был отревьюен независимо — author≠reviewer для +дофикс-диффа не соблюдался. Запущены два ревьюера другой семьи (Fable 5, я Opus), read-only по +дереву, каждый со своей линзой — «деньги и гонки» и «интейк, потеря данных, правдивость чисел». +Вернули **пять дефектов с воспроизведением, одну гипотезу и два расхождения отчётности**. Все +подтверждены мной на коде до правки; возражений нет. + +### Н1 [medium-high, интейк] Пропавший КОРЕНЬ хранилища читался как вина каждой книги + +`os.Stat(workdir)` даёт ENOENT и когда пропал каталог одной книги, и когда не смонтирован сам +`BooksDir`. Первое — терминальный конец этой книги (и лечение крэш-окна отказа, FP5-3), второе — +беда хоста. Читая второе как первое, ОДИН проход свипа терминально отклонял ВСЕ книги в интейке с +`source_unreadable` — причиной, которая винит файл пользователя, терминальна по устройству и не +имеет обратного хода (ни перепарса, ни удаления, PD-175). То есть мой же фикс FP5-3 создал путь +массового уничтожения загрузок из-за размонтированного тома. + +Сделано: `ErrStorageGone` отличён от `ErrDirectoryGone` — корень спрашивается ПЕРЕД тем, как винить +книгу; причина `storage_unavailable` никогда не терминальна, бюджет не тратит (движка не звали) и на +книге не хранится. Предикат `waitsForTheDeployment` собрал оба таких случая в одном месте. +Пин — `books.TestAVanishedStorageRootIsNotEveryBooksFault` (две книги, снесённый корень, десять +циклов ожидания: обе живы, бюджет цел, движок не спрошен ни разу, том вернулся — следующий проход +разбирает). Посадка падает. Строка PD-192. + +### Н2 [minor, ревизия] Пол ревизии глотал весь статусный проход книги + +Продолжение FP5-1 на соседнем пути: вставка научилась брать `greatest(max, пол) + 1`, а смены +статуса остались на `revision + 1`. Книга, загруженная ДО чужой отменённой загрузки, идёт +`uploading → parsing → not_started` тремя инкрементами СВОЕГО счётчика и всё ещё под полом — +`greatest(max, пол)` не двигается вовсе, а клиент по контракту отбрасывает чтение, которое не выше +применённого. Экран, только что загрузивший книгу, показывает её «приходящей», пока библиотеку не +сдвинет постороннее событие. Монотонность при этом не нарушена — это staleness, и именно поэтому +прошлый круг её не заметил. + +Сделано: три писателя статуса (`StartParsing`, `FinishParse`, `RejectBook`) берут следующий номер +БИБЛИОТЕКИ, а не свой + 1 (`nextRevisionOfThisBooksLibrary`, владелец читается из обновляемой +строки). Пин — `books.TestAStatusChangeIsVisibleEvenUnderTheLibrarysFloor`, посадка падает. PD-122 +переформулирована: остаток — собственный счётчик области (двое ОДНОВРЕМЕННЫХ писателей могут +вычислить одно и то же число; значение при этом растёт, поэтому клиент перезапрашивает). + +### М1 [medium, деньги] `PauseRun` — третий закрывающий путь без гарда живой попытки + +Тот же класс, что PD-181 и FP5-2, на пути, который прошлый круг не проверил: пауза закрывала прогон, +не спрашивая, жива ли ещё закрываемая попытка И этого ли она прогона (апдейт попытки шёл даже без +`run_id`). Сценарий ревьюера: два поколения реконсилятора при перекрывающемся деплое, у старого ниже +`PerChapter` в конфиге → его проход считает остаток исчерпанным и паузит прогон, который уже +рестартован в живую попытку 2. Итог — прогон `paused/credit_exhausted` под живым тратящим движком, а +открытый холд попытки 2 не виден НИ в `ListLiveRuns` (прогон завершён), НИ в `UnsettledRuns` (попытка +не завершена). + +Сделано: тот же `exists (… a.id = $4 and a.run_id = runs.id and a.ended_at is null)`, что у соседей, +плюс `run_id` в апдейте попытки. Пин — `pgstore.TestAPauseFromAnOldSnapshotDoesNotCloseARunOverALiveAttempt` +(проверяет и то, что прогон жив, и то, что холд живой попытки виден в списке). Посадка падает. PD-193. + +### М2 [low, проекция] Синк законченной попытки усыновлял чужой хендшейк + +Форма, которой до P5 не существовало: стоп ДО спавна оставляет попытку закрытой и без +`engine_run_id`. Устаревший материализатор с такой попыткой принимал hello попытки, которая её +заменила, биндил чужой `engine_run_id` на себя и материализовал тот же журнал второй раз — счётчики +глав и юнитов удваивались. Деньги не двигались. + +Сделано: бинд отказан для законченной попытки (`ended_at is null` в CAS). Пин — +`pgstore.TestAMaterializerOfAnEndedAttemptDoesNotAdoptTheNextAttemptsEngine`, посадка падает. PD-194. + +### М3 [low, деньги] Возврат холда «прогона, который не запускался», ждал ответа движка + +Ревьюер дал это гипотезой; я перепроверил и подтвердил. `settle` сперва требовал успешного +`tmctl status`, и только потом ветка «юнита не было — вернуть холод целиком». Но хост, который +производит эту ситуацию, — ровно тот, где движок не запускается: `Status` падает на каждом проходе, +и деньги прогона, который не начинался, остаются зарезервированными навсегда (видимыми, но +запертыми). + +Сделано: ветка «нет юнита и нет базовой линии» идёт ДО вызова движка; `ReleaseUnspawned` +перепроверяет строку под замком, поэтому снапшот, который успели заспавнить, отвергается там и +settle идёт обычным путём. Пин — +`runs.TestTheHoldOfARunThatNeverStartedComesBackOnAHostWhoseEngineCannotAnswer` (движок отвечает +«no such file» на любой запрос; холд обязан вернуться, движок — не быть спрошенным). Посадка падает. +PD-195. + +### Хвосты того же круга + +- **Н3.** `StartParsing` и оба `abandon` в `Accept` шли на `context.WithoutCancel` без своего + дедлайна: отсоединённый — не значит бесконечный, повисший запрос держал бы горутину запроса, + которого уже нет. Переведены на `writeCtx` (30 с). **Пина нет и не заявляется**: это граница, а не + наблюдаемое поведение — чтобы его пинить, нужен стор, умеющий висеть. +- **Н4.** Опечатка оператора в рукописном `book.yaml` по-прежнему стоит файла пользователя: движок + отвечает exit 1 на всё, через пять циклов книга отклоняется как `source_unreadable` и исходник + удаляется. FP5-4 закрыл только «конфигурации нет вовсе». Заведено отдельной строкой риска PD-196, + а не спрятано в комментарий: настоящее лечение — различающий код выхода или `--dry-run` на стороне + движка, это не правка платформы. +- **Н5.** Пин FP5-7 сверял ДВЕ точные формулировки старого стринг-матча — третье написание прошло бы + мимо. Теперь тест разбирает файл гейта парсером и смотрит только КОД (комментарии там цитируют ту + самую строку — текстовый скан принял бы историю за дефект), запрещает в коде слова systemd и + требует, чтобы гейт вообще спрашивал менеджер. Посадка «вернуть матч по сообщению» падает. +- **Отчётность.** Ревьюер не смог проверить клейм «посадки 10 из 10»: списки лежали в скрэтчпаде + сессии, а не в дереве. Исправлено — списки посадок теперь ниже, в самом журнале. +- Ловушка счёта, в которую ревьюер едва не попал и которую стоит знать: `git grep '^func Test' HEAD -- + platform` даёт 262 — лишняя строка из код-блока в архивном доке; счёт по `*_test.go` даёт 261. + +### Списки посадок (то, чего не хватало отчёту) + +| Раунд | Посадка (что откатывается) | Пин, который обязан упасть | Итог | +|---|---|---|---| +| Дофикс | вставка снова читает только `max(revision)` | `TestABookThatJoinsAfterACancelledUploadStillMovesTheRevision` | поймана | +| Дофикс | `FinishUnspawnedStop` без гарда живой попытки | `TestAStaleUnspawnedStopDoesNotCloseAResumedRun` | поймана | +| Дофикс | пропавший каталог снова = «нет конфигурации» | `TestABookWhoseDirectoryIsGoneIsRejectedRatherThanLeftWaiting` | поймана | +| Дофикс | ожидание конфигурации снова жжёт бюджет | `TestWaitingForAConfigurationDoesNotBringDeletionCloser` | поймана | +| Дофикс | проход интейка снова под бюджетом прогонов | `TestOneBookInTheSweepGetsABudgetAParseCanLiveIn` | поймана | +| Дофикс | просроченный дедлайн снова 500 | `TestAnUploadThatOutlivesItsDeadlineIsNotAnInternalError` | поймана | +| Дофикс | гейт systemd снова по тексту сообщения | `TestTheSystemdGateAsksAboutTheCapabilityAndNotAMessage` | поймана | +| Дофикс | пауза перебивает стоп | `TestAStopDuringSettlementOutranksThePause` | поймана | +| Дофикс | потолок частей формы снова `n > cap` | `TestAFormWithTooManyPartsIsRefusedAtTheCap` | ПЕРЕЖИЛА → пин переписан → поймана | +| Дофикс | wire-тест ревизии без различающей силы | `TestTheCardProjectsTheRevisionTheStoreGaveAndInventsNone` | поймана | +| Дофикс-2 | пропавший корень снова читается как вина книги | `TestAVanishedStorageRootIsNotEveryBooksFault` | поймана | +| Дофикс-2 | смена статуса снова `revision + 1` | `TestAStatusChangeIsVisibleEvenUnderTheLibrarysFloor` | поймана | +| Дофикс-2 | пауза снова без гарда живой попытки | `TestAPauseFromAnOldSnapshotDoesNotCloseARunOverALiveAttempt` | поймана | +| Дофикс-2 | синк снова биндит законченную попытку | `TestAMaterializerOfAnEndedAttemptDoesNotAdoptTheNextAttemptsEngine` | поймана | +| Дофикс-2 | возврат холда снова ждёт ответа движка | `TestTheHoldOfARunThatNeverStartedComesBackOnAHostWhoseEngineCannotAnswer` | поймана | +| Дофикс-2 | гейт systemd снова сверяет сообщение (усиленный пин) | тот же | поймана | + +### Батарея дофикса-2 + +- `make check` с живым PG 18.4 под `-race`: **EXIT=0, 15 пакетов, скипов 0**, линтер **0 issues**. +- Тесты `^func Test` исполнением: **349 → 354** (+5 пинов, удалённых 0). +- Посадки: **6 из 6** (таблица выше, нижние шесть строк). +- ⚠ **`make vuln` покраснел между двумя прогонами ОДНОГО дня, и не из-за дерева.** База адвизори + опубликовала пять уязвимостей stdlib против Go **1.26.5** — `net/http`, `crypto/tls`, `net/url`, + `encoding/xml`, `encoding/asn1` (GO-2026-6218 / 6090 / 6089 / 6088 / 5972), все закрыты в + **1.26.6**, и govulncheck трассирует две из них в пути, которые эта служба зовёт (например + `pgstore.Open → pgx.ParseConfig → asn1.Unmarshal`). Поставил 1.26.6 в `~/.local` (sha256 сверен с + go.dev), поднял `GO_MIN_VERSION` в Makefile 1.26.5 → 1.26.6 с обоснованием на месте, обновил пин в + `STACK_DECISIONS.md`. На 1.26.6 батарея зелёная и скан чист. + **НА РАТИФИКАЦИЮ:** хост сборки, оставшийся на 1.26.5, должен получать отказ. + ⚠ **Правка 14.08: в этой редакции он его НЕ получал** — гейт сравнивал регексом и принимал 1.26.5 + (ре-чек, FP5-9). Как floor держится теперь — раздел «Третий раунд» выше. + +### Записка-план дофикса-2: ID → статус → улика + +| ID | Статус | Улика | +|---|---|---| +| Н1 | ЗАКРЫТО | `ErrStorageGone` + `waitsForTheDeployment`; `books.TestAVanishedStorageRootIsNotEveryBooksFault`; посадка падает. PD-192 | +| Н2 | ЗАКРЫТО | `nextRevisionOfThisBooksLibrary` у трёх писателей статуса; `books.TestAStatusChangeIsVisibleEvenUnderTheLibrarysFloor`; посадка падает. PD-122 переформулирована | +| М1 | ЗАКРЫТО | гард живой попытки в `PauseRun` + `run_id` в апдейте попытки; `pgstore.TestAPauseFromAnOldSnapshotDoesNotCloseARunOverALiveAttempt`; посадка падает. PD-193 | +| М2 | ЗАКРЫТО | `ended_at is null` в бинде; `pgstore.TestAMaterializerOfAnEndedAttemptDoesNotAdoptTheNextAttemptsEngine`; посадка падает. PD-194 | +| М3 | ЗАКРЫТО (гипотеза ревью подтверждена мной) | ветка «не спавнился» ДО вызова движка; `runs.TestTheHoldOfARunThatNeverStartedComesBackOnAHostWhoseEngineCannotAnswer`; посадка падает. PD-195 | +| Н3 | ЗАКРЫТО БЕЗ ПИНА (честно) | `writeCtx` на `StartParsing` и обоих `abandon`; граница, а не поведение — пин не заявляется | +| Н4 | СТРОКОЙ | PD-196: exit 1 движка на опечатку в `book.yaml` стоит файла; лечение — на стороне движка | +| Н5 | ЗАКРЫТО | пин FP5-7 разбирает КОД гейта парсером, а не текст файла; посадка падает | +| Отчётность | ЗАКРЫТО | списки посадок перенесены из скрэтчпада в журнал (таблица выше) | +| Тулчейн | НА РАТИФИКАЦИЮ | Go 1.26.5 → 1.26.6 по пяти адвизори stdlib; `make vuln` чист, батарея зелёная | + + +## Дофикс P5 (11.08): фикс-лист приёмки FP5-1…FP5-8 + +Пак принят УСЛОВНО с восемью находками (одна high). Ниже — что сделано по каждой, чем проверено и +что осталось. Каждое число снято исполнением, команда рядом. Возражений НЕТ: все восемь +подтвердились на коде до правки — три из них были прямыми последствиями МОИХ же решений, принятых в +разных файлах и не сверенных между собой. + +### FP5-1 [HIGH] Пол ревизии библиотеки игнорировался на вставке + +Приёмка права, и посылка перепроверена мной на дереве, не по памяти: `DeleteUpload` поднимал +`users.library_revision`, `ListBooks` читал `greatest(max, пол)`, а `nextLibraryRevision` — ТОЛЬКО +`max(books.revision)`. Книга, загруженная после отменённой загрузки, входила на или ниже пола, и +одна ревизия отвечала за три разных состояния библиотеки: до отменённой загрузки, во время неё и +после прихода следующей книги. Клейм «PD-122 закрыта в половине добавления» был ЛОЖЕН — тесты +покрывали только свежий аккаунт, где пол ещё ноль. + +Сделано: вставка берёт `greatest(max(revision), users.library_revision) + 1`. Пин — +`books.TestABookThatJoinsAfterACancelledUploadStillMovesTheRevision`, и он гоняет именно ту +последовательность, которой не было: книга → отмена → книга; проверяет и ревизию БИБЛИОТЕКИ, и +ревизию самой новой книги (иначе её карточка устарела бы в момент появления). PD-122 пере-формулирована: +открытым остаётся один случай — смена статуса книги, которая не самая новая, не двигает ни одну из +половин. + +### FP5-2 [деньги] `FinishUnspawnedStop` без гарда живой попытки + +Тот же гард, что `FinishRun` получил кругом раньше (PD-181), на соседнем пути отсутствовал: путь +закрытия «стопа до спавна» проверял только `unit_name is null`. Устаревший проход мог закрыть уже +резюмированный прогон, и холд второй попытки выпадал из обоих списков; приёмка добавила к этому +следствие, которого я не назвал: каждый следующий резюм отвечал бы 409 навсегда, потому что +продолжать пришлось бы уже завершённый прогон. + +Сделано: попытка обязана быть живой и принадлежать этому прогону (`ended_at is null and run_id = $`). +Пин — `runs.TestAStaleUnspawnedStopDoesNotCloseAResumedRun`; посадка «снять гард» падает. +Строка PD-186. + +### FP5-3 Крэш-окно «снос каталога → запись строки» + +Клейм «порядок самоизлечивающийся» был ложен, и ложным его сделала МОЯ ЖЕ вторая правка того же +круга: снесённый каталог читается как «нет конфигурации», а эту причину я тем же паком сделал +НЕтерминальной — книга оставалась в `parsing` навсегда, ожидая конфигурацию, которую положить некуда. +Диспозиция F11 («порядок выбран самоизлечивающийся») переписана: она стояла на посылке, которая к +моменту записи уже не была верна. + +Сделано: пропавший КАТАЛОГ отличён от отсутствующей конфигурации (`ErrDirectoryGone`) и терминален +сразу — движка при этом не зовём вовсе. Пин — +`books.TestABookWhoseDirectoryIsGoneIsRejectedRatherThanLeftWaiting`. Строка PD-187. + +### FP5-4 Ожидание конфигурации жгло бюджет разбора + +`ClaimParse` считает КАЖДУЮ заявку, а книга без конфигурации заявляется раз в грацию бесконечно. +После пяти циклов ожидания бюджет исчерпан — и первый же ответ движка становится терминальным +мгновенно, вместе с удалением исходника. Движок отдаёт exit 1 и на опечатку в `book.yaml`, так что +цена ошибки оператора — файл пользователя. + +Сделано: попытка возвращается (`RefundParseAttempt`), когда движок не был спрошен вовсе; возврат +условен на клейме, как и все прочие записи, заканчивающие проход. Пин — +`books.TestWaitingForAConfigurationDoesNotBringDeletionCloser`: после пятнадцати циклов ожидания +бюджет цел, и первый отказ движка после этого НЕ терминален и ничего не удаляет. Строка PD-188. + +### FP5-5 Бэкстоп гнал разбор под бюджетом прохода + +Проход интейка шёл под теми же двумя минутами, что и проход прогонов, а разбор внутри — это тот же +вызов движка, которому очередь даёт пятнадцать минут. Большая книга убивалась дедлайном свипа, +убийство читалось как «хост не может запустить движок», попытка списывалась — и так каждый проход, +пока книга не отклонялась ЗА СВОЙ РАЗМЕР. Вторую половину приёмка назвала точно: запись отказа шла +на уже просроченном контексте и терялась, то есть цикл был бесконечным. + +Сделано: у прохода интейка свой бюджет (`jobs.JobTimeout + 1m`), у каждой книги внутри прохода — +свой (`jobs.JobTimeout`), а терминальные записи (`FinishParse`/`RejectBook`/возврат попытки) идут на +контексте, переживающем дедлайн вызова. Пин — `books.TestOneBookInTheSweepGetsABudgetAParseCanLiveIn` +(фейк движка читает дедлайн, который ему выдали). Строка PD-189. + +### FP5-6 Просроченный дедлайн загрузки уходил 500 + +Сделано: 408 problem+json. Выбор кода назван вслух: медленный клиент — не сломанный сервис, а 408 по +RFC 9110 §15.5.9 означает ровно «полный запрос не пришёл за время, которое сервер готов был ждать», и +сообщает клиенту, что лечение — повтор. Кода 408 в перечне операции нет, поэтому он внесён в тот же +пакет вопросов владельцу контракта, что PD-172/PD-174/PD-180. Пин — +`httpapi.TestAnUploadThatOutlivesItsDeadlineIsNotAnInternalError`. Строка PD-190. + +### FP5-7 Скип-гард systemd-тестов пинил СООБЩЕНИЕ + +Приёмка права дважды, и вторая половина — исправление МОЕЙ ошибки в фактах. Гейт существовал и +скипал по подстроке «Failed to connect to bus», а systemd 259 отвечает «Failed to connect to user +scope bus» — гейт перестал гейтить от чужой правки строки. Моя строка PD-178 при этом утверждала, что +формы скипа для systemd в зоне НЕТ ВОВСЕ: это было неверно, и я записал предположение как факт. + +⚠ И вторая посылка PD-178 устарела прямо в ходе дофикса: **на этом стенде пользовательский менеджер +systemd теперь ЖИВ** (`systemctl --user show` → `Version=259.5`), и все три теста проходят — тогда +как несколькими часами раньше в этой же сессии его не было (`Linger=no`, sudo нет, ручной запуск +выходил кодом 1). То есть состояние менеджера ПЛАВАЕТ между сессиями стенда, и строка переписана +именно так. + +Сделано: гейт спрашивает СПОСОБНОСТЬ — доходит ли процесс до своего менеджера, — и скипает громко и +поимённо, симметрично форме БД-тестов. Пин — +`runner.TestTheSystemdGateAsksAboutTheCapabilityAndNotAMessage`: он падает, если гейт снова начнёт +сверять текст. PD-178 закрыта. + +### FP5-8 Хвосты + +- **(а) деньги.** Стоп, пришедший в окно расчёта, отвечал `paused/credit_exhausted` — «кончились + деньги» вместо «владелец остановил». Пауза теперь отказывает при висящем интенте + (`ErrStopRequested`), реконсилятор заканчивает прогон стопом. Пин + `runs.TestAStopDuringSettlementOutranksThePause`. Строка PD-191. +- **(б)** PD-153 дополнена: у СТОП-пути окно закрыто с обеих сторон (заявка не выдаётся прогону с + интентом; закрытие «стопа до спавна» спрашивает systemd, если у попытки есть базовая линия). Само + окно PD-153 — грация от `started_at` — не тронуто. +- **(в)** Потолок частей формы пропускал N+1 (`n > cap` вместо `n >= cap`). Пин + `httpapi.TestAFormWithTooManyPartsIsRefusedAtTheCap` — и ПЕРВАЯ его редакция посадку ПЕРЕЖИЛА: + она сверяла только код 400, а форма, у которой просто кончились части, тоже 400, так что обе + версии потолка проходили. Переписан на наблюдаемое следствие: файл стоит ровно на части `cap+1`, + и тест требует, чтобы интейку его НЕ отдали (`in.read == 0`) — при потолке на единицу больше файл + дочитывается и книга принимается. Посадка теперь падает. +- **(г)** Выключатель metrics-листенера был недостижим через окружение: пустое значение переменной + неотличимо от неустановленного и берёт дефолт. Теперь выключает СЛОВО (`off`/`none`), и это + записано там же, где документирован сам выключатель. +- **(д)** Wire-тест ревизии вернул различающую силу: фикстура снова несёт РАЗНЫЕ числа (книга 41, + прогон 12), и тест утверждает то, за что отвечает этот слой — он отдаёт число стора и не выдумывает + своё. Правило, что число берётся из книги, пинится в сторе. +- **(е)** `help` метрики `queue_depth` называет все состояния, которые она считает, включая `pending`. +- **(ж)** Счёт тестов в таблице задач пересчитан исполнением. + +### Батарея дофикса + +- `make check` с живым PostgreSQL 18.4 под `-race`: **все пакеты зелёные, включая `internal/runner`** + (менеджер systemd на стенде жив — см. FP5-7), линтер **0 issues**. +- Скипы: 0 при живом менеджере; при мёртвом — три ИМЕНОВАННЫХ скипа вместо трёх падений. +- Тесты `^func Test` исполнением: **340 → 349**, добавленных **9**, удалённых **0**, переименован + **1** (`TestTheCardsRevisionIsTheBooksAndNotTheRuns` → `TestTheCardProjectsTheRevisionTheStoreGaveAndInventsNone`, + FP5-8д — свойство то же, имя стало отвечать за то, что тест на самом деле утверждает). Против HEAD: + **261 → 349**. Команда: `comm` двух списков `^func Test` (HEAD и дерево) — единственная строка + расхождения и есть это переименование. +- Посадки дофикса: **10, поймано 10** — но честно: первый круг дал **9 из 10**, пережившая посадка + (в) показала, что пин сверял не то следствие; переписан пин, не мутация. Списки — `mut9.json`. +- `make vuln`: чисто. + +**Живая проба дофикса на собранном бинаре** (`go build ./cmd/tmplatformd`, боевой PostgreSQL 18.4, +сессия заведена прямой вставкой в стенд и убрана после пробы): + +- FP5-6: сырым сокетом отправлена шапка `POST /v0/books` с `Content-Length: 100000` и одной частью, + дальше клиент «думает» 4 секунды при `TM_PLATFORM_UPLOAD_DEADLINE=2s`. Ответ — + `HTTP/1.1 408 Request Timeout`, `Content-Type: application/problem+json`, + тело `{"type":"about:blank","title":"The upload did not finish in time","status":408}`, соединение + закрыто. Юнит-тест это же место видит через фейковый ридер: живой прогон подтверждает, что дедлайн + РЕАЛЬНО срабатывает на соединении, а не только маппинг ошибки. +- FP5-8г: с `TM_PLATFORM_METRICS_ADDR=off` в печати эффективной конфигурации стоит + `value=off source=environment`, в логе — `no TM_PLATFORM_METRICS_ADDR: this instance exposes no + metrics`, слушателя на порту нет (`ss -ltn` пуст). Со значением-адресом скрейп отдаёт 200. +- FP5-8е: `# HELP tm_platform_queue_depth Jobs in the queue that have not finished (pending, + available, running, scheduled or retryable)` — из живого скрейпа. Там же видно 9 семейств зоны из + десяти: `sweep_unfinished_total` — счётчик с лейблами и серии до первого инкремента не имеет, + это нормальная семантика prometheus, а не пропавшая метрика. +- Заодно: `tm_platform_http_requests_total{code="200",method="GET",route="GET /v0/books"}` — лейбл + маршрута это ПАТТЕРН (F9), и `GET /v0/books` живым Bearer'ом отвечает 200. + +### Записка-план дофикса: ID → статус → улика + +| ID | Статус | Улика | +|---|---|---| +| FP5-1 | ЗАКРЫТО | `nextLibraryRevision` = `greatest(max(revision), users.library_revision)+1`; `books.TestABookThatJoinsAfterACancelledUploadStillMovesTheRevision`; посадка «снова только максимум» падает | +| FP5-2 | ЗАКРЫТО | гард живой попытки в `FinishUnspawnedStop` (`ended_at is null and run_id = $2`); `runs.TestAStaleUnspawnedStopDoesNotCloseAResumedRun`; посадка падает. PD-186 | +| FP5-3 | ЗАКРЫТО | `ErrDirectoryGone` проверяется ПЕРЕД `ErrNotProvisioned` и терминален; `books.TestABookWhoseDirectoryIsGoneIsRejectedRatherThanLeftWaiting`; посадка падает. PD-187 | +| FP5-4 | ЗАКРЫТО | `RefundParseAttempt` условно на клейме; `books.TestWaitingForAConfigurationDoesNotBringDeletionCloser`; посадка падает. PD-188 | +| FP5-5 | ЗАКРЫТО | свой бюджет у прохода интейка и у каждой книги, терминальные записи на пережившем контексте; `books.TestOneBookInTheSweepGetsABudgetAParseCanLiveIn`; посадка падает. PD-189 | +| FP5-6 | ЗАКРЫТО кодом, вопрос владельцу контракта СТРОКОЙ | `os.ErrDeadlineExceeded` → 408; `httpapi.TestAnUploadThatOutlivesItsDeadlineIsNotAnInternalError`; посадка падает. PD-190 | +| FP5-7 | ЗАКРЫТО, PD-178 переписана и закрыта | гейт спрашивает способность соединиться с менеджером; `runner.TestTheSystemdGateAsksAboutTheCapabilityAndNotAMessage`; посадка падает. Менеджер на стенде сейчас ЖИВ (259.5) — три теста прошли | +| FP5-8а | ЗАКРЫТО | `PauseRun` отказывает при висящем интенте (`ErrStopRequested`); `runs.TestAStopDuringSettlementOutranksThePause`; посадка падает. PD-191 | +| FP5-8б | СТРОКОЙ | PD-153 дополнена: стоп-путь закрыт с обеих сторон; само окно грации не тронуто | +| FP5-8в | ЗАКРЫТО со второго захода | `n >= cap`; пин переписан на «файл за потолком не отдан интейку»; посадка падает | +| FP5-8г | ЗАКРЫТО | выключатель metrics-листенера — СЛОВО (`off`/`none`), пустая переменная неотличима от неустановленной; `config/effective_test.go` | +| FP5-8д | ЗАКРЫТО | фикстура снова несёт разные числа (книга 41, прогон 12); `httpapi.TestTheCardProjectsTheRevisionTheStoreGaveAndInventsNone`; посадка падает | +| FP5-8е | ЗАКРЫТО | `help` у `queue_depth` называет все состояния, включая `pending` | +| FP5-8ж | ЗАКРЫТО | числа в таблице задач пересчитаны исполнением (см. батарею выше) | + + +## Приёмка P5 оркестратором (11.08): ПРИНЯТ УСЛОВНО — дофикс-раунд ДО лендинга (прецедент P4) + +**Что подтверждено исполнением.** Батарея пере-прогнана мной на СВЕЖЕПОДНЯТОМ стенде (zonky PG +18.4, рецепт §«Postgres без root»): **все пакеты зелёные, ВКЛЮЧАЯ `internal/runner`** — на текущем +стенде пользовательский менеджер systemd ЖИВ (`user@1000.service` active, systemd 259), и три +теста PD-178 прошли; линтер 0 issues; тестов **340**, удалённых против HEAD **0** (comm пуст). +Живые числа проб и мутационных прогонов read-only не пере-снимаются — приняты «со слов» с +пометкой. Ревью 10 агентами (6 линз + скептики): **8 находок подтверждено (1 high), 0 +опровергнуто, 9 минорных.** Задеты деньги и данные пользователей ⇒ по прецеденту P4 пак +принимается УСЛОВНО, лендинг после дофикса и ре-чека. + +**Фикс-лист (движение — запиской-планом ID → статус → улика, D39.121):** + +| ID | Находка | Лечение | +|---|---|---| +| FP5-1 **HIGH** | **Пол ревизии библиотеки игнорируется на вставке** (`pgstore/books.go:99`): `nextLibraryRevision` читает только `max(books.revision)`, а `DeleteUpload` поднимает `users.library_revision`; книга, загруженная после отменённой загрузки, входит НА/НИЖЕ текущей ревизии — три разных состояния библиотеки под одной ревизией, revision-keyed refetch слеп. Клейм закрытия PD-122 ложен; тесты покрывают только свежий аккаунт | вставка учитывает пол (`greatest`); тест со сценарием «отмена → повторная загрузка»; PD-122 переформулировать | +| FP5-2 деньги | **`FinishUnspawnedStop` без гарда живой попытки** (`pgstore/sink.go:362`): закрытие по устаревшему снапшоту РЕЗЮМИРОВАННОГО прогона — холд попытки-2 вне `ListLiveRuns` И вне `UnsettledRuns`, движок без надзора, последующий резюм вечно 409. Ровно та гонка, от которой `FinishRun` уже получил гард (PD-181) | тот же гард «закрываемая попытка ещё живая»; пин + посадка | +| FP5-3 | **Крашокно `removeDir → RejectBook`** (`books/parse.go:188`): смерть между ними = книга навечно в `parsing` (ENOENT читается как `not_configured`, терминального пути нет) — клейм «порядок самоизлечивающийся» ложен, диспозиция F11 стояла на ложной посылке | терминальность переживает краш (запись/маркер до сноса либо «каталога нет вовсе» = терминал); диспозицию F11 переписать | +| FP5-4 | **`not_configured`-проходы жгут бюджет разбора** (`parse.go:138`): `ClaimParse` инкрементит `parse_attempts` безусловно ⇒ после ~5 циклов ожидания конфигурации ПЕРВЫЙ отказ движка терминален мгновенно — включая удаление исходника при опечатке `book.yaml` (exit 1 = «вина файла») | `not_configured` не тратит бюджет; пин «ожидание не приближает удаление» | +| FP5-5 | **Свип-бэкстоп гонит разбор под общим бюджетом прохода 2 мин против 15 мин очереди** (`cmd/tmplatformd/runner.go:151`): восстановление большой книги убивается дедлайном свипа, каждый цикл жжёт попытку, а reject на просроченном контексте не записывается — kill-loop | у бэкстоп-разбора свой бюджет ≥ `jobs.JobTimeout` (пара тестом, как `claimGrace`); reject на свежем контексте | +| FP5-6 | **Просроченный дедлайн загрузки уходит 500** (`httpapi/v0.go:404`) — код вне спеки и вне декларации PD-180 | замаппить в честный код problem+json; внести в пакет вопросов PD-180 | +| FP5-7 | **Скип-гард systemd-тестов пинит сообщение, которого systemd 259 не даёт** (`runner/systemd_test.go:25`: ждёт «Failed to connect to bus», стенд говорит «…user scope bus…») — на стенде без менеджера тесты ПАДАЮТ, а не скипаются; PD-178 не знает об этом гарде и устарела фактами (менеджер сейчас ЖИВ, батарея зелёная) | гейт по СПОСОБНОСТИ (проба соединения), скип поимённо — симметрия DSN-формы; PD-178 переписать: «состояние менеджера плавает между сессиями стенда» | +| FP5-8 хвосты | (а) стоп в окне settle отвечает `paused/credit_exhausted` вместо `stopped` (`reconcile.go:543`) · (б) PD-153-окно: полный возврат холда под стартующим движком через новый стоп-путь — дополнить строку · (в) потолок частей формы пропускает N+1 · (г) выключатель metrics-листенера недостижим через env · (д) wire-тест ревизии потерял различающую силу (фикстура 12→41 сравняла прогон с книгой) · (е) help `queue_depth` не называет `pending` · (ж) счёт тестов в таблице задач устарел | по весу: (а)(б)(д) — фиксы с пинами; (в)(г)(е)(ж) — мелкие правки/честные строки | + +Развилка `book.yaml` (вопрос зоны) и четыре контракт-вопроса — на мне, ратифицирую при лендинге +дофикса. Дерево остаётся незакоммиченным до ре-чека. + +**Ре-чек дофикса-2 (13.08, оркестратор; дата испр. 14.08 — стояло «11.08» ошибочно, дофикс-2 датирован 13.08): батарея воспроизведена зелёной (354 теста, Go 1.26.6, +стенд ~/.local/pgsql:55433), НО 3 находки подтверждены (2 high) — третий раунд по списку ниже, +лендинг после него:** + +| ID | Находка | Лечение | +|---|---|---| +| FP5-9 **HIGH** | **Гейт тулчейна НЕ отказывает 1.26.5**: `Makefile:33` — регекс `go1\.26\.([5-9]\|…)` принимает 1.26.5, `GO_MIN_VERSION=1.26.6` живёт только в echo; `go.mod` — `go 1.26.4` БЕЗ `toolchain`; клейм «хост на 1.26.5 получит отказ» ложен в трёх доках (журнал ×2, STACK_DECISIONS §, коммент Makefile) | регекс/сравнение версий чинится по-настоящему (+ `toolchain go1.26.6` в go.mod — реши и обоснуй); пин на функцию сравнения, не на прогон; три клейма поправить | +| FP5-10 **HIGH** | **PD-192 не закрывает заявленный сценарий**: гард — только `Stat(BooksDir)` (`parse.go:235`); unmount тома, смонтированного РОВНО в BooksDir, оставляет пустой mountpoint → Stat успешен → терминальный reject ВСЕХ книг с удалением; безусловный `MkdirAll` на буте (`runner.go:129`) маскирует пропажу после рестарта | различать «хранилище пропало» от «книги нет» устойчиво к обеим формам (кандидат: сентинел-файл провижининга в BooksDir, который бут НЕ пересоздаёт; нет сентинела → ErrStorageGone, не терминалить, бюджет не жечь); пин на unmount-форму | +| FP5-11 | **Н2 закрыт не для всех писателей статуса**: пять писателей жизненного цикла прогона остались на `revision+1` (`pgstore/runs.go:108,517,620` · `sink.go:318,396`) против собственного правила дофикса (`books.go:111`) — staleness библиотеки на статусах прогона | те же «следующий номер библиотеки», что у трёх интейковых; пин на прогонный переход у книги не-максимума аккаунта | +| хвосты | (а) коммент `PauseRun` «в том же стейтменте» против фактического SELECT FOR UPDATE (`runs.go:580`) — текст к коду; (б) комменты `parse.go:41-52` «terminal on the first answer» описывают до-дофиксное поведение на пути удаления файла; (в) остаток FP5-5: в одном проходе бэкстопа полный бюджет получает только первая книга — вторая жжёт попытки на обрезанном контексте (узкий триггер — честная строка либо бюджет на КНИГУ); (г) мета-пин systemd-гейта перечислителен — остаток названный, не дефект | (а)(б) — правки текста; (в) — строка или фикс; (г) — знать | + +## Сессия P5 (11.08): загрузка книги · стоп и резюм · наблюдаемость · форма регистра + +**Каждое число ниже снято исполнением, команда стоит рядом.** Дерево не коммичено, в индексе ничего +не держу; зона записи — только `platform/`. + +### Таблица задач + +| Задача промта | Что сделано | Чем проверено | +|---|---|---| +| 1. `POST /books` (П-9, PD-72) | Потоковый multipart (`r.MultipartReader`), пер-маршрутный потолок тела, свой дедлайн чтения, каталог книги под `TM_PLATFORM_BOOKS_DIR`, статусы `uploading → parsing → not_started \| rejected` с писателями, разбор = `tmctl manifest --json`, свип-бэкстоп интейка | **44 теста** (пак + пины дофикса): 22 `internal/books` + 11 `httpapi/intake_test.go` + 6 `pgstore/intake_test.go` + 5 на шов манифеста (`runner/manifest_test.go`). 21 посадка пака, живая проба на боевом бинаре | +| ⚠ половина 1: кто пишет `book.yaml` | **НЕ построена — вопрос ниже.** Построен ШОВ: `books.ErrNotProvisioned`, книга без конфигурации отказывает разбором с причиной `not_configured` | `TestABookWithNoEngineConfigurationWaitsRatherThanDies` | +| 2. Стоп и резюм (PD-140) | `POST /v0/runs/{id}/stop` пишет намерение (`runs.stop_requested_at`, миграция 00014) ДО сигнала; `/resume` переиспользует `reopen` реконсилятора и ставит спавн в очередь, а не в запрос; реконсилятор классифицирует маркер по намерению, не рестартит стопнутый прогон и повторяет стоп живому юниту | **32 теста** (пак + пины дофикса): 28 `runs/control_test.go` + 4 `httpapi/control_test.go`. 25 посадок пака, живая проба | +| 3. Наблюдаемость (П-11) + PD-114 | `prometheus/client_golang` v1.24.1 на отдельном слушателе; 10 семейств метрик зоны, включая запросы и задержки по паттерну маршрута; бюджет свипа НА ПРОГОН (PD-169); печать эффективной конфигурации с источником и редакцией секретов и денег | **12 тестов** (пак + пин дофикса): 5 `internal/metrics` + 6 `config/effective_test.go` + 1 `internal/jobs`. 8 посадок пака, живая проба | +| 4. Регистр секциями (пинг №16) | Таблица разложена на 11 секций (открытые по весу · принятый риск · закрытые ратификацией · закрытые по эрам P1–P5), построчная форма сохранена | `python3 docs/scripts/counts.py --check` — зелено; список ID до и после перекладки совпадает посимвольно | + +### ⚠ ВОПРОС НА РАТИФИКАЦИЮ: кто пишет `book.yaml` при интейке + +Промт гейтит эту половину явно, и она НЕ построена. Что известно и чего стоит каждый вариант: + +**Факт.** Каталог новой книги создаёт платформа (иначе `POST /books` некуда писать), а разбор — +`tmctl manifest --config <каталог>/book.yaml`; движок требует в конфиге `book_id`, пару языков, +пути `pipeline`/`models`/`source_file` и хотя бы один потолок (`backend/internal/config/book.go` +проверяет всё это на загрузке). D39.110 §2b говорит «платформа `book.yaml` не правит» — в контексте +того решения речь шла о ПОТОЛКАХ, которые платформа передаёт аргументом прогона, а не о создании +файла для книги, которой ещё не существует. + +| Вариант | Что делает платформа | Цена | +|---|---|---| +| **А. Оператор кладёт файл** (то, что построено) | Ничего: каталог и исходник создаются, конфигурации ждём; без неё разбор отказывает и после бюджета попыток книга становится `rejected` с причиной `not_configured` | Загрузка через UI не доходит до конца без ручной работы оператора НА КАЖДУЮ книгу; бета с чужими пользователями на этом не живёт. Зато D39.110 §2b соблюдён буквально | +| **Б. Рендер из деплой-шаблона при интейке** (кандидат промта) | Один раз, при создании каталога, рендерит `book.yaml` из шаблона деплоя (`TM_PLATFORM_BOOK_TEMPLATE`): языки и жанр из `BookIntake`, `source_file` — имя, которое платформа же и записала, потолки и пути пайплайна — из шаблона оператора. Дальше файл принадлежит оператору: платформа его не читает и не трогает | Платформа становится писателем файла движка ОДИН раз. Нужен шаблон в деплое (ещё одна переменная и ещё один файл, который можно забыть). Если оператор потом файл поправил — ничего не происходит, платформа туда не возвращается | +| **В. Движок сам заводит книгу** | Платформа зовёт новую $0-команду движка (`tmctl init --source-lang zh --target-lang ru …`), движок пишет свой конфиг сам | Архитектурно чище всех: файл движка пишет движок, и требования к полям живут там же, где проверяются. Но это строка ЕДИНОГО бэклога и релиз движка, то есть П-9 стоит до неё | + +**Что предлагает зона:** Б как бета-меру и В как правильную форму, строкой единого бэклога. При Б +шов уже готов — `books.ErrNotProvisioned` заменяется рендером в ОДНОМ месте +(`books.Service.manifest`), остальной интейк не меняется. **Ратифицирует оркестратор; тихо +интерпретировать эту развилку сессия не стала.** + +Вторым вопросом того же корня: **название книги.** `BookIntake` поля `title` не несёт, манифест +движка названия не даёт вовсе (он даёт id, счёты и идентичность разреза). Сегодня заголовок берётся +из ИМЕНИ ЗАГРУЖЕННОГО ФАЙЛА (`книга.epub` → «книга»), пустое остаётся пустым — синтезировать +«Книга 1» значит показать читателю ярлык, которого никто не писал. Если ратифицируется Б, имя +логично брать оттуда же; если владелец хочет поле `title` в форме — это правка спеки, не зоны. + +### Дизайн стопа: намерение, а не догадка по коду выхода + +Кандидат-дизайн промта взят целиком; вот его границы. + +- **Что записывается.** `runs.stop_requested_at` пишется ОДНИМ оператором с проверкой владения и + живости (`pgstore.RequestStop`: `update … from books where owner_id = $2 and finished_at is null + returning …` плюс имя юнита живой попытки). Ноль строк — два разных ответа, и они разделены + вторым запросом ТОЛЬКО на этом пути: видит владелец прогон → `ErrRunNotLive` (409), не видит → + `ErrNoRun` (404). Идемпотентно через `coalesce`: второй стоп не сдвигает ПЕРВУЮ отметку, потому + что она — улика о том, что случилось раньше. +- **Порядок.** Запись коммитится ДО обращения к systemd. Запинено не косвенно: фейк раннера зовёт + хук ВНУТРИ своего `Stop`, хук читает `stop_requested_at` из БД и требует, чтобы она уже была + (`TestTheStopIsRecordedBeforeSystemdIsAsked`). Посадка «спросить systemd раньше» падает. +- **Гонка «стоп против самостоятельного финиша».** Намерение перекрашивает исход, только если оно + РАНЬШЕ маркера (`stoppedOnRequest`: `!l.StopRequestedAt.After(m.At)`), а чистые коды движка + (0 · 2 · 3) намерение перебивают всегда — завершённый перевод не должен показываться отменённым. + Три пина: стоп → `stopped`, маркер раньше стопа → `failed`, чистый выход → `ready`. +- **Три следствия, каждое запинено.** Прогон с намерением НЕ перезапускается реконсилятором (иначе + деньги уходят на работу, которую владелец отменил) · живой юнит с намерением получает стоп + ПОВТОРНО (закрывает «платформа умерла между записью и вызовом») · стоп до спавна заканчивает + прогон и возвращает холд ЦЕЛИКОМ (проверено балансом, не статусом). +- **Чего дизайн НЕ закрывает, и это названо:** штатная перезагрузка хоста. Там намерения нет ни у + кого, маркер честно скажет `exited/1`, и прогон закроется как `failed` вместо перезапуска. + Различить это может только движок различимым кодом выхода graceful-stop (строка 165 единого + бэклога) — платформенной догадки здесь быть не должно. PD-152 остаётся открытой этой половиной. + +Резюм по контракту: `stopped`/`paused` → новая попытка с ОСТАТКОМ бюджета (`потолок − RunSpent`); +`awaiting_bank` → 409 «подпись неполна», и она неполна у любого деплоя, потому что банк сегодня не +материализуется вовсе (компаньон §3); нечего продолжать → 202 с прогоном КАК ЕСТЬ, без переписывания +его состояния (прогон, который остановил пользователь, остаётся `stopped`, а не становится `paused` +из-за того, что резюм не нашёл денег). Механика перезапуска переиспользована: `restart` +реконсилятора и `Resume` — один `reopen`; решение «паузить» вынесено к вызывающим, потому что +реконсилятор паузит ЖИВОЙ прогон, а резюм работает с уже завершённым и переписывать ему исход не +вправе. + +### Наблюдаемость: что выбрано и почему не stdlib + +`expvar` рассмотрен первым (норма §1 «stdlib прежде библиотеки») и этой работы не несёт: нет +лейблов — «запросы по маршруту и коду» не выражаются вовсе; нет гистограмм — на вопрос о задержке +остаётся среднее, единственная статистика, которая прячет хвост; его JSON не читает ни один скрейпер. +Сэкономил бы он зависимость, а стоил бы написания недостающих трёх руками — ровно того самописного +пути, который та же норма и запрещает. Взят `prometheus/client_golang` **v1.24.1** (релиз +24.07.2026, пин сверен живьём через `proxy.golang.org`), OpenTelemetry отклонён как тяжёлый для +одной VM: коллектор процессом, протокол экспорта настройкой и всё равно scrape-эндпоинт на конце. +Эталон оси (половина PD-115) — практики именования Prometheus плюс четыре золотых сигнала, внесены в +`ENGINEERING_STANDARDS` §2. Разбор — `STACK_DECISIONS` §24. + +Отдаётся ОТДЕЛЬНЫМ слушателем (`TM_PLATFORM_METRICS_ADDR`, дефолт `127.0.0.1:9464`), не маршрутом +под `/v0`: экспозиция несёт операционную форму деплоя, а второй модели авторизации ради скрейпера +зона не заводит — тот же довод, что сделал админ-поверхность CLI (§10). Риск принят строкой PD-179 и +записан в `deploy/README.md`. Величины снимает СВИП, а не скрейп: коллектор, ходящий в Postgres на +каждый запрос, отдал бы нагрузку на контрол-плейн тому, у кого есть доступ к порту. + +Метрики зоны: глубина очереди · возраст самого старого открытого холда · попытки в карантине · +живые прогоны · отставание тейлера в байтах · книги в интейке по статусу · длительность свипа и +счётчик проходов, не уложившихся в бюджет (PD-169) · запросы и задержки по паттерну маршрута. + +### Что нашла ЖИВАЯ ПРОБА, чего не нашли тесты + +Проба гоняет боевые `tmplatformd`/`tmplatformctl` против живого Postgres реальными HTTP-вызовами; +подменены две чужие стороны и обе процессом — движок (как в P4) и systemd (стенда с пользовательским +менеджером нет, PD-178). Нашла две вещи, и обе исправлены: + +1. **Резюм спавнил движок ВНУТРИ запроса** — запрос висел, пока прогон не кончится. На фейке это + видно как таймаут 120 с; на настоящем systemd висело бы меньше, но по-прежнему секундами: + `spawnAttempt` читает отсчёт книги (`tmctl status` — секунды CPU движка) и ходит к systemd. + Исправлено: резюм ставит спавн в ОЧЕРЕДЬ (`EnqueueRunNow`), как это делает допуск прогона; + реконсилятор — бэкстоп, если запись потерялась. Пин переписан на новое свойство, посадка + «спавнить в запросе» падает. +2. **Идентификатор книги тёк в INFO-логи** (`book accepted`, `book parsed`, `book rejected`) — + против нормы `ENGINEERING_STANDARDS` §Наблюдаемость («id пользователя/книги в логи не текут») и + против собственной дисциплины раннера, который в INFO называет ПРОГОН и никогда книгу. Снято; + на ERROR путь книги остаётся в двух местах намеренно (терминальный отказ разбора и неудавшееся + удаление каталога) — это открытый класс PD-139, строка дополнена. Пин — + `TestNoBookIdentifierReachesAnInfoLine`. + +Прогон пробы после исправлений (числа с экрана): + +| Шаг | Наблюдение | +|---|---| +| печать конфигурации | 29 настроек с источником; DSN — `(set)/environment`, обе денежные — `(an amount; not logged)`; утечек значений в лог **0** | +| `POST /v0/books` (38 символов) | **201**, `status: parsing`, `title: 蛊真人` из имени файла, `character_count: 38` (руны, не байты) | +| статусы | `parsing → not_started`, `chapter_count: 2` из манифеста, каталог книги: `book.yaml`, `source.txt` | +| файл больше 64 КиБ | **413** problem+json (PD-72) | +| источник, который движок не разобрал | **`rejected`**, каталог книги удалён | +| `POST .../runs` (2 главы) | **202**, юнит создан, движок запущен, холд **$0.06** | +| **стоп живого прогона** | **202**, и в САМОМ ответе прогон ещё `translating` — стоп ПРИНЯТ, а не завершён (движок дописывает чекпойнт). Через свип: юнит погашен, маркер `run_…-1.exit` записан, книга и прогон → **`stopped`**, `finished_at` проставлен, расчёт: баланс **9.990000** (списано $0.01 — то, что отчитал движок), холд закрыт | +| **резюм** | **202**; прогон → `translating`, попытка **2** с НОВЫМ юнитом, холд **$0.05** = потолок $0.06 минус потраченное | +| резюм живого прогона | **409** · стоп несуществующего прогона — **404** | +| метрики | `queue_depth 0` · `live_runs 1` · `oldest_open_hold_seconds 2.63` · `quarantined_attempts 0` · `tailer_lag_bytes 0` · `books_in_intake{uploading,parsing} 0/0` · `sweep_duration_seconds_count{runs} 8` · `http_requests_total{code="201",route="POST /v0/books"} 2` | +| гигиена логов | сумм, argv движка и id книг — **0**; ERROR-строк — **0** | + +⚠ В пробе `book.yaml` кладёт СТЕНД, играя оператора, — это и есть та половина, что стоит на +ратификации. Заодно этим прогоняется путь «поломка деплоя ретраится, а не отвергает книгу»: первая +попытка разбора отказывает как `not_configured`, следующий свип разбирает книгу уже с конфигурацией. + +### Батарея, посадки и счёт + +- `make check` с живым PostgreSQL 18.4 под `-race`: **все пакеты зелёные, кроме `internal/runner`** + (три systemd-теста, PD-178 — разбор ниже), линтер **0 issues**. +- Скипы: `go test ./... -count=1 -v | grep -c -- '--- SKIP'` = **0**. +- `make vuln`: **No vulnerabilities found**. +- Тесты `^func Test` исполнением: **261 → 340**, добавленных **79**, удалённых **0** + (`comm -23` списка HEAD и списка дерева — пусто). +- **Посадки: 74 прогона в КОПИИ зоны вне репозитория** (скрипт восстанавливает копию после каждой; + списки — `mut1…mut6.json` рабочего каталога сессии). Итог: **65 пойманы своим кругом, 7 пережили, + 2 не применились по образцу — и все девять разобраны, а не подчищены**: + - ДВЕ пережили по-настоящему, и обе были дефектами ТЕСТА, а не кода: «брошенная загрузка чистится + на отменённом контексте» (тест не отменял контекст, то есть проверял не то свойство) и «отмена + загрузки роняет ревизию» (тест сравнивал с числом ДО загрузки вместо того, которое клиент мог + увидеть ВО ВРЕМЯ неё). Оба теста усилены — первый отменяет контекст ровно так, как ушедший + клиент, второй читает библиотеку из середины аплоада, — и пере-посадки ПОЙМАНЫ; + - три пережили как СЛАБЫЕ мутации: `_ = unit` не меняет порядка вызовов; переименование ветки + `awaiting_bank` эквивалентно, потому что обе ветки отвечают отказом; «заявка разбора отдаётся + назад» не моделировала снятия заявки. Все три переписаны в настоящие (перестановка вызовов, + «пустить `awaiting_bank` в резюмируемые», снятие условия свежести) и ПОЙМАНЫ; + - одна пережила на УСТАРЕВШЕЙ копии (посадка на `Unwrap` метрик гонялась до того, как телеметрия + была вписана в цепочку теста дедлайна) — пере-посадка ПОЙМАНА; + - две не применились: образец встречался дважды либо расходился пробелами; переписаны и ПОЙМАНЫ. + ⚠ Чего эта цифра НЕ значит (урок PD-142/PD-151): поимённого соответствия «каждый пин — своя + посадка» нет и не заявляется. 62 посадки против 73 новых тестов покрывают несущие свойства — + деньги, гонки снапшота, переходы статусов, потолки, редакцию логов, гарды файловой системы; не + покрыты вспомогательные, где тест утверждает форму ответа или таблицу значений. + Несущие посадки: потолок тела возвращается к дефолту · дедлайн загрузки не расширяется · `Unwrap` + снят · стоп классифицируется по коду выхода · гард гонки «стоп vs финиш» снят · стопнутый прогон + рестартится · рестарт стирает свежий интент · закрытие неспавненного стопа не перепроверяет юнит · + право на спавн выдаётся завершённому прогону · спавн игнорирует интент · резюм берёт второй холд · + резюм даёт полный потолок · резюм спавнит в запросе · резюм судит деплой раньше владения · конфликт + второго живого прогона снова 500 · прогон стартует на книге вне интейка · книга, отклонённая + движком, сохраняет файл · поломка деплоя считается виной книги · бюджет попыток снят · заявка + разбора не compare-and-set и не спейсит ретраи · свип сносит каталог живой книги · новая книга + входит с ревизией 0 · отмена загрузки роняет ревизию · пол ревизии игнорируется · ревизия прогона + берётся из своей колонки (два места) · корневой гард каталога принимает любой путь · символы + считаются байтами · строка книги пишется после тела · сумма и секрет печатаются · лейбл метрики — + путь вместо маршрута · настройка читается без записи · свип без бюджета на прогон · вердикт + остановленной попытки переписывается резюмом. + +### ⚠ Оспаривание посылки: батарея зоны на этом стенде не может быть зелёной (PD-178) + +Промт требует «0 FAIL». Три теста `internal/runner` падают, и это свойство СТЕНДА, а не пака: на +хосте нет пользовательского менеджера systemd (`/run/systemd/system` есть, PID 1 — systemd **259**, +но `user@1000.service` не поднят, `Linger=no`, sudo нет; попытка поднять менеджер руками выходит +кодом 1 без вывода). Проверено, что это не регрессия: `git archive HEAD platform`, распакованный в +копию вне репозитория, даёт те же три падения с тем же текстом «Failed to connect to user scope bus». +Форма зоны для внешнего предусловия существует и она другая — БД-тесты СКИПАЮТСЯ вслух по +`TM_PLATFORM_TEST_DSN`, и `make check` называет скипы поимённо. Приводить одно к другому сессия не +стала: правка теста ради зелени запрещена (D39.121), решение — оркестратора. + +⚠ Отсюда же ограничение живой пробы, названное прямо: **systemd-клеймы P4 этим паком НЕ пере-мерены** +(cgroup прогона, `--collect`, поведение маркера на трёх исходах). Проба подменяет systemd процессом, +моделируя ровно ту семантику, от которой зависит стоп: юнит переживает создателя, `ExecStopPost` +отрабатывает с `SERVICE_RESULT`/`EXIT_CODE`/`EXIT_STATUS`, `stop` = SIGTERM главному процессу. +⚠ Стенд с P4 при этом изменился: там был systemd 255, здесь 259. + +### Регистр: закрыто, сужено, заведено + +- **Закрыто (4):** PD-72 (потолок тела приехал с маршрутом и своим тестом) · PD-140 (ручки стопа и + резюма) · PD-114 (печать эффективной конфигурации) · PD-169 (бюджет свипа на прогон плюс метрика). +- **Сужено, но открыто (4 + 1):** PD-152 (пользовательский стоп закрыт, ребут — за движком) · + PD-115 (ось наблюдаемости получила эталон; ops и конфигурация — нет) · PD-122 (добавление книги + двигает ревизию области; удаление — по-прежнему нет) · PD-162 (терминальное состояние появилось у + ИНТЕЙКА; клин живого прогона на удалённом каталоге не тронут) · PD-139 дополнена вторым местом. +- **Заведено (14, из них 4 закрыты тем же паком):** PD-172 «файл последним» не записано в контракте · PD-173 у `rejected` нет + причины на проводе · PD-174 `POST /books` отвечает 404 там, где спека кода не даёт (и резюм — + 503) · PD-175 квоты интейка нет · PD-176 `requestTooLarge` не доходит через обёртки · + PD-177 `character_count` для не-UTF-8 — оценка · PD-178 systemd-тесты падают, а не скипаются · + PD-179 `/metrics` без аутентификации (принятый риск) · PD-180 201 несёт `parsing`, а не + `uploading`, плюс отказы интейка вне спеки · PD-181…PD-184 (кросс-семейное ревью, закрыты + здесь же) · PD-185 `finalizing` без писателя. +- Регистр: **185 строк** (`python3 docs/scripts/counts.py`): 132 закрыто · 1 ратификацией · 4 риском · + 48 открыто (1 major — PD-113, прежний). +- Форма файла по пингу №16: 11 секций, построчная форма `| PD-N | … |` сохранена, `--check` зелёный. + Проверено, что ни одна строка не потеряна: список ID до и после перекладки сортированно совпадает + (`diff <(grep -o '^| PD-[0-9]* |' до) <(… после)` — пусто). + +### Вопросы владельцу контракта (через оркестратора) + +1. **Порядок частей формы.** `POST /books` требует, чтобы `file` шёл ПОСЛЕДНИМ (PD-172). Это + свойство потокового приёма, а не прихоть: строка книги пишется до тела, а языки в ней + обязательны. Тем же правилом специфицирована браузерная загрузка S3. Спека молчит — вносить? +2. **Причина отказа.** `rejected` без причины на проводе (PD-173) не даёт экрану различить «файл не + тот» и «наш движок был недоступен», а это разные советы пользователю. Поле заводить не право + зоны; в БД причина есть. +3. **Инстанс без интейка** отвечает на `POST /books` охраняемым 404 (PD-174) — по форме зоны для + несмонтированных маршрутов, но кода этого спека не перечисляет (класс PD-112). Там же: резюм на + деплое без маркер-команды отвечает 503, а 0.2.1 ратифицировала 503 только для СТАРТА прогона. +4. **«Responds immediately; the book enters `uploading`»** недостижимо как класс (PD-180): по HTTP + ответ не может уйти раньше, чем прочитано тело, поэтому 201 несёт `parsing`. Плюс отказы интейка, + которых спека не описывает вовсе: число частей формы, длина текстового поля, порог 413 и обрыв + соединения по дедлайну маршрута. + +### Кросс-семейное ревью (D39.120 п.1а): что нашла ДРУГАЯ модель после трёх своих + +⚠ **Сначала о разрыве, который это закрывает.** Три ревьюера выше запускались без указания модели и +унаследовали модель сессии — то есть все четверо (автор и трое) были одной семьи. D39.120 п.1(а) +просит в панели ≥1 опровергателя ДРУГОЙ моделью на оценочных линзах; механические линзы (сверка +провода, посадки, батарея) от смены модели не выигрывают, а «деньги и гонки» — линза именно +оценочная. Пробел назван владельцем, не найден сессией. Запущены два ревьюера **Fable 5** (слепой +поиск по диффу и атака на денежные инварианты), обоим предписано воспроизводить исполнением. +⚠ Оговорка из той же ноты, п.1(в): Fable — та же семья Anthropic, поэтому семейный слепой участок +этим НЕ снимается; первичны пере-ран и исполнение, а внешняя калибровка — не право сессии. + +**Результат: другая модель нашла 5 дефектов, которые три однофамильца пропустили, и два из них — +уничтожение пользовательских данных.** Все воспроизведены исполнением, все закрыты, каждый с пином и +посадкой. + +| Находка | Чем это было | Как закрыто | +|---|---|---| +| **Грация клейма (10 мин) КОРОЧЕ таймаута задания очереди (15 мин)** — обе константы поставил этот же пак, в разных файлах, не сверив | В окне 10–15 минут свип отдаёт клейм второму парсеру: два `tmctl manifest` на одной директории, проигравший умирает на эксклюзивном локе движка с exit 1 — а exit 1 у движка означает «источник не разобрать». Книга отклонялась ТЕРМИНАЛЬНО, исходник пользователя удалялся. Воспроизведено тестом ревьюера | Грация написана как `jobs.JobTimeout + 5m`, пара утверждается тестом; терминальная запись возможна только пока клейм ЕЩЁ НАШ (`parse_started_at = <наш>`). PD-183 | +| **Один exit 1 движка = удаление загрузки** | Движок маппит на exit 1 ВСЕ свои отказы (его собственный комментарий): полный диск, лок от ручного `tmctl` оператора, украденный клейм. Первый же такой отказ уничтожал файл безвозвратно — ре-парса и удаления в контракте нет | Отказ ИСТОЧНИКА идёт через тот же бюджет попыток, что и поломка деплоя; удаление — только на терминальном шаге. Пин расширен: первый отказ обязан оставить файл на месте | +| **`not_configured` был терминальным** — а конфигурацию не пишет никто, пока развилка не ратифицирована | Значит на СЕГОДНЯШНЕМ деплое любая загрузка умирала бы в течение часа, с удалением, из-за пробела, которого пользователь не видит, а оператор закрывает одним файлом | `not_configured` НЕ становится терминальным никогда: книга ждёт в `parsing` с целым исходником, и это видно метрикой интейка. ⚠ Тест на этом свойстве ПЕРЕВЁРНУТ намеренно — он пинил «пять попыток и отказ», и это и было ошибкой | +| **Устаревший снапшот до-финиширует РЕЗЮМИРОВАННЫЙ прогон** (деньги) | Маркер завершённой попытки остаётся на диске; проход со старым снапшотом закрывал вернувшийся к жизни прогон, и холд попытки 2 выпадал из ОБОИХ списков — ни живых прогонов, ни несведённых. Достижимо стало ровно с появлением резюма | `FinishRun` пишет, только если закрываемая попытка ещё живая, и возвращает признак «закрыл». PD-181 | +| **Стоп закрывал прогон под ЖИВЫМ движком** (деньги) | «Заявка отдана назад» ≠ «процесса нет»: `systemd-run`, убитый после запроса, оставляет движок работать — зона это уже знает и ради этого хранит базовую линию, но стоп-путь читал пустое имя юнита как «его не было» | Ненулевая базовая линия при пустом имени = надгробие спавна; имя юнита детерминировано, платформа спрашивает systemd `Alive` и живому юниту шлёт стоп. PD-182 | +| Заявка на спавн игнорировала висящий интент стопа (мой комментарий утверждал обратное) | Стоп, попавший в окно, пока воркер читает отсчёт книги, не мешал старту движка | `stop_requested_at is null` — в том же CAS; комментарий приведён к коду | +| `TM_PLATFORM_UPLOAD_DEADLINE` мог пережить грацию свипа | Оператор, поднявший дедлайн выше часа, получал удаление строки и каталога из-под ещё идущей загрузки | Пара проверяется на буте | +| Полностью принятая загрузка выбрасывалась, если клиент ушёл до 201 | Телефон уснул — файл и минуты линии в корзину | `StartParsing` идёт на контексте, переживающем запрос | +| Лейбл `method` — токен вызывающего | Для маршрута кардинальность закрыта, для метода — нет | Закрытый список, прочее — `(other)`. PD-184 | +| `runner.Manifest` подвисал/убивал движок на превышении потолка | Правку я собирался внести раньше, и она НЕ доехала (скрипт упал на другой ассерции) — поймано только здесь | Остаток дренится, превышение — честная ошибка | +| Статус `finalizing` без писателя | Слово контракта, которого не пишет ни один путь — тот же класс, что интейк-статусы до этого пака | Строкой PD-185, не кодом: писателя выдумывать не право зоны | + +**Посадок этого круга: 12, поймано 11.** Пережила одна и она названа, а не подчищена: «отказ сносит +каталог ПОСЛЕ строки, а не до» — свойство про ПОРЯДОК двух побочных эффектов, наблюдаемое только +через смерть процесса между ними; конечное состояние в обоих порядках одинаково, и юнит-тестом это +не ловится. Порядок выбран самоизлечивающийся (сначала каталог, потом строка) и обоснован в коде. + +⚠ **Что этот круг говорит о предыдущем.** Три однофамильных ревьюера прошли по тем же файлам и не +увидели ни рассинхрона двух констант, поставленных в одном паке, ни того, что «exit 1 = вина файла» +уничтожает данные на первом же транзиенте. Это ровно та коррелированная слепота, ради которой +D39.120 просит другую модель, — и здесь она подтверждена эмпирически, а не принята на веру. + +### Записка-план после ревью: ID → статус → улика (D39.121) + +| ID | Что | Статус | Улика | +|---|---|---|---| +| R1 | Право на спавн выдаётся законченному прогону (деньги, критично) | ЗАКРЫТО | гард в `RecordSpawn`; `TestTheQueueWorkerDoesNotStartARunThatWasAlreadyStopped`; посадка «снять гард» падает | +| R2 | Рестарт стирает свежий интент стопа (деньги) | ЗАКРЫТО | `RestartRun` берёт строку прогона `for update` и отдаёт `ErrStopRequested`; `TestAStopPressedWhileTheReconcilerRestartsIsNotLost`; посадка падает | +| R3 | Закрытие неспавненного стопа роняет только что стартовавший движок (деньги) | ЗАКРЫТО | `FinishUnspawnedStop` с перепроверкой под замком; `TestARunSpawnedWhileTheSweepWasClosingItIsNotAbandoned`; посадка падает | +| R4 | Спавн не читает интент стопа (деньги) | ЗАКРЫТО | отказ в `spawnAttempt`; тот же пин, что у R1; посадка падает | +| R5 | Интейк и телеметрия голодают на общем дедлайне | ЗАКРЫТО | у каждого прохода свой бюджет (`cmd/tmplatformd/runner.go`); прогон пробы: `sweep_duration_seconds_count{intake}` растёт вместе с `{runs}` | +| R6 | Свип сносит каталог книги, которую не удалил | ЗАКРЫТО | `abandon` возвращается на ошибке удаления; `TestTheSweepNeverRemovesTheSourceOfABookItCouldNotDelete`; посадка падает | +| R7 | Отмена загрузки роняет ревизию библиотеки назад | ЗАКРЫТО | пол `users.library_revision`; `TestCancellingAnUploadDoesNotWindTheLibraryBack`; две посадки падают | +| R8 | Бюджет разбора считает тики, а не время | ЗАКРЫТО | заявка держится; пин расширен проверкой «до грации не делается ничего»; посадка падает | +| R9 | Таймаут задания очереди — молчаливая минута River | ЗАКРЫТО | `JobTimeout` выбран явно; `jobs_test.go` пинит вид и политику заданий | +| R10 | `runner.Manifest` подвисает на превышении потолка | ЗАКРЫТО | остаток дренится, превышение — ошибка; `TestAManifestThatIsNotJSONIsAnError` и соседние гоняют реальный процесс | +| R11 | Ревизия прогона на проводе — из его колонки | ЗАКРЫТО | стор отдаёт книжную на всех путях; `pgstore.TestEveryRunTheStoreHandsOutCarriesItsBooksRevision`, `runs.TestEveryRunCarryingAnswerUsesTheBooksRevision`; посадка падает | +| R12 | Резюм судит деплой раньше владения | ЗАКРЫТО | порядок переставлен; `TestOwnershipIsJudgedBeforeTheDeploymentsHealth` | +| R13 | Резюм второго живого прогона книги → 500 | ЗАКРЫТО | `ErrRunInFlight` → 409; `TestResumeIsRefusedWhenTheBookHasAnotherLiveRun` | +| R14 | Два резюма подряд → 404 проигравшему | ЗАКРЫТО | проигравший перечитывает прогон; `TestTwoResumesOfOneRunTakeOneHoldAndBothAnswer` | +| R15 | Каталог-сирота, если строка исчезла между телом и `parsing` | ЗАКРЫТО | `Accept` убирает за собой и на этой ветке | +| R16 | 201 несёт `parsing`, а не `uploading` | СТРОКОЙ, не кодом | PD-180: по HTTP ответ не может уйти раньше тела; `uploading` наблюдаем параллельным чтением, пин есть | +| R17 | Отказы интейка, которых спека не описывает (части формы, длина поля, порог 413, обрыв по дедлайну) | СТРОКОЙ | PD-180, вопросом владельцу контракта одним пакетом с PD-172/PD-174 | +| R18 | 503 на резюме у несконфигурированного деплоя | СТРОКОЙ | PD-174 расширен: класс тот же, что у ратифицированного 503 на старте | +| R19 | Любая загрузка сегодня приходит к `rejected`, и выхода из `rejected` нет | СТРОКОЙ | PD-175 расширен; корень — развилка `book.yaml`, вопрос выше | +| R20 | Пути книг в ERROR-логах интейка | СТРОКОЙ | PD-139 дополнена; на INFO/WARN идентификаторов нет, и это запинено | +| R21 | `requestTooLarge` не доходит через обёртки | СТРОКОЙ | PD-176: 413 отдаётся штатно, соединение закрывается обычным путём под `ReadTimeout` | +| R22 | Порядок блокировок в `RestartRun` (строка прогона бралась последней) | ЗАКРЫТО попутно | R2 берёт её сразу после книги — документированный порядок восстановлен | +| F1 | Грация клейма короче таймаута задания: свип крадёт парс, книга терминально отклоняется с удалением файла | ЗАКРЫТО | `claimGrace = jobs.JobTimeout + 5m`; `books.TestTheClaimGraceOutlivesTheQueuesJobTimeout`; посадка падает | +| F2 | Один exit 1 движка уничтожал загрузку | ЗАКРЫТО | отказ источника идёт через бюджет; удаление только на терминальном шаге; посадка «отклонять с первого раза» падает | +| F3 | `not_configured` терминален — то есть каждая сегодняшняя загрузка обречена | ЗАКРЫТО (тест перевёрнут намеренно) | `TestABookWithNoEngineConfigurationWaitsRatherThanDies`; посадка «отклонять после бюджета» падает | +| F4 | Устаревший снапшот до-финиширует резюмированный прогон, холд вне списков | ЗАКРЫТО | гард живой попытки в `FinishRun`; `TestAStaleSweepDoesNotReFinishAResumedRunFromTheOldAttemptsMarker`; посадка падает | +| F5 | Стоп закрывает прогон под живым движком после отданной заявки | ЗАКРЫТО | `Alive` по детерминированному имени юнита; `TestAStopDoesNotCloseARunWhoseGivenBackClaimLeftAnEngineRunning`; посадка падает | +| F6 | Заявка на спавн игнорирует интент стопа | ЗАКРЫТО | условие в CAS; `TestAStopCommittedWhileTheWorkerReadsTheMeterStopsTheSpawn`; посадка падает | +| F7 | Дедлайн загрузки мог пережить грацию свипа | ЗАКРЫТО | проверка пары на буте; `config.TestAnUploadDeadlineLongerThanTheSweepsGraceIsRefused`; посадка падает | +| F8 | Принятая загрузка выбрасывалась, если клиент ушёл до 201 | ЗАКРЫТО | контекст переживает запрос; `TestAnUploadSurvivesAClientThatHangsUpAfterTheLastByte`; посадка падает | +| F9 | Лейбл `method` — токен вызывающего | ЗАКРЫТО | закрытый список; посадка падает | +| F10 | `runner.Manifest` подвисает на превышении потолка | ЗАКРЫТО | дренаж остатка + ошибка; правка была задумана раньше и НЕ доехала — поймана только кросс-семейным кругом | +| F11 | Порядок «каталог до строки» в отказе | ⚠ ПЕРЕСМОТРЕНО дофиксом → FP5-3 | диспозиция «порядок самоизлечивающийся» стояла на посылке, которую тот же пак отменил (`not_configured` стал НЕтерминальным), поэтому крэш-окно оставляло книгу в `parsing` навсегда; закрыто отдельной причиной `ErrDirectoryGone` (PD-187), пин и посадка есть | +| F12 | `finalizing` — статус без писателя | СТРОКОЙ | PD-185 | + +### Что НЕ делалось (по промту) + +Эскроу/`uncertain`/`closing` (строка 136) · формула потолка PD-158 и SpendBound PD-159 · ставка +$0.03 · эмиттер-сторона движка и словарь `events.go` · П-2 · платёжный провайдер · удаление аккаунта +(PD-107) · счётчик ревизии ОБЛАСТИ целиком (PD-122 сужена, не закрыта) · правка контракта (четыре +вопроса выше вместо неё). + +## Ратификация приёмкой P4 (оркестратор №15, 09.08) + +**Вердикт: P4 + дофикс + V2 ПРИНЯТЫ и ЗАЛЕНДЕНЫ — D39.123, код `d29e30c` (53 файла, +9867/−170, +только `platform/`).** Три раунда, 13 агентов приёмки; каждое заявление пере-ранено исполнением. + +**Метод и что подтвердилось.** 7-агентный воркфлоу (слепой дифф · опровергатель-Opus D39.120 · +пере-ран батареи · 10 моих посадок · 47 wire-проверок контракта · охотник-деньги · +охотник-systemd с РЕАЛЬНЫМ tmctl из HEAD) → фикс-лист F1–F8 → ре-чек 4 контурами (обе пробы +приёмки, пере-мутации, PD-158/159 адверсариально на Opus, охота по дельте) → V2 → финальный +ре-чек (5/5 пере-мутаций убиты поимённо). Реальный движок принял платформенный argv целиком, +имена полей `status --json` совпали с парсером побайтно, exit-контракт и маркер сошлись; четыре +systemd-клейма пере-мерены; батарея EXIT=0 с живым PG 18.4 под `-race`, скипов 0; тестов +105→261 (+156/−0); дерево на лендинге байт-равно проверенному. + +**Пять MAJOR приёмки и PD-159 селф-ревью — все закрыты с пинами**; разборы в разделах «Дофикс +P4» и «Ре-чек V2» ниже. Несущие: аргумент потолка против кумулятивной семантики движка (F1; +вскрыт тем, что сквозная проба шла на фейке без кумулятива) · дедлок books↔run_attempts 258/300 +с вечным карантином проекции на транзиентном 40P01 (F2; ложно закрытая PD-129 пере-открыта и +закрыта честно) · settle по устаревшему снапшоту (F5) · двойная оплата при отложенном расчёте +(PD-159, найдена селф-ревью сессии — воспроизведена приёмкой на до-фиксном дереве). + +**Ратифицировано (D39.123 п.2):** linger-privilege-модель · **ПОПРАВКА формулы потолка: +`committed + прирост`, БЕЗ reserved (PD-158)** — сессия была права, отказавшись молча исполнять +формулу моего пинга: исполнение обеих против гейта движка показало, что пинг переплачивал +запасом ровно на leftover-reserved · карантин ПРОЕКЦИИ (PD-105-уточнение) · ставка $0.03 · +`default_chapters` = верх шкалы · 503 в контракт (0.2.1; PD-112 закрыт) · PD-114 → задача +«печать эффективной конфигурации» · PD-115 — направление принято. PD-113 — единственный +открытый major, до эмиттера; его цена после PD-158 выросла (стоп по потолку = ровно исчерпание +холда). Движку заведены строки 165 (различимые exit-коды потолка/стопа) и 166 (оценка $/глава). + +**Урок приёмки в обе стороны:** (а) фейк шва обязан моделировать СЕМАНТИКУ чужой стороны, не +только форму — обе жёсткие находки (F1, N1-stop→failed) прошли бы фейком; класс закрыт +кумулятивным ceilingJudge в пинах; (б) моя ошибка нумерации (PD-167 = коммент 00009, не 00011) +поймана сессией — исправлена в строке; (в) две посадки сессии, пережившие первую редакцию пина, +задекларированы, не подчищены — норма «заявление=команда» работает в обе стороны. + +## Дофикс P4 (09.08): фикс-лист приёмки — F1…F8, N1…N4 + +Пак принят УСЛОВНО с пятью MAJOR. Ниже — что сделано по каждому пункту, чем это проверено и что +осталось. Каждое число здесь снято исполнением; команда стоит рядом с числом. + +### F1 [MAJOR, деньги/шов] Аргумент потолка — по ратифицированной формуле + +Приёмка права, и её посылка перепроверена мной на HEAD ОБЕИХ зон, не по памяти: +`backend/cmd/tmctl/invocation.go` объявляет флаг словами «It caps the book's CUMULATIVE +committed+reserved spend, not this run's increment», а `backend/internal/store/ledger.go` `Reserve` +сравнивает `SUM(committed_usd + reserved_usd)` книги с `c.BookUSD` на КАЖДОЙ резервации. Движковая +проба приёмки пере-прогнана в КОПИИ бэкенда (в чужую зону не писал): +`TestAcceptSecondRunDeniedWhenCeilingIsPassedAsIncrement` — при committed $3.00 потолок `3.0` даёт +`ReserveDeniedBook`, `6.0` — `ReserveOK`. + +Сделано: `runs.meter` (`committed`, `reserved`) читается ОДНИМ вызовом `status --json` перед стартом +(`bookMeter`), аргумент = `committed + прирост` (`meter.bookCap`). ⚠ Слагаемое `reserved` из +ратифицированной формулы НЕ добавляется — это названное отклонение в консервативную сторону, разбор +и вопрос оркестратору ниже, PD-158. `reserved_usd` +внесён в аллоулист `ingest.StatusReport` **указателем** — отсутствие ≠ ноль ровно по той же причине, +что и у committed, только ошибка зеркальная: ноль вместо реального резерва даёт потолок НИЖЕ того, на +чём книга уже стоит. Рестарт идёт тем же путём и получает свежий отсчёт (спавн один на все случаи). +Фактически ушедшее значение хранится: `run_attempts.ceiling_arg_micro_usd` (миграция 00011) — оно не +равно `ceiling_micro_usd`, и после того как счётчик книги сдвинулся, восстановить его нечем. + +Пины (посадки — ниже): `TestTheSecondRunOfABookIsGivenTheCumulativeCapAndNotItsOwnIncrement`, +`TestAResumeIsGivenACapComputedFromTheMeterAsItStandsNow`. Оба гоняют фейк `ceilingJudge`, который +судит `--ceiling-usd` **по правилу движка**, включая проход восстановления: открытие книги на запись +обнуляет остаточный `reserved_usd`, после чего потолок на уровне `committed` или ниже отвергается +первой же резервацией. Это и есть ответ на «почему батарея пака этого не поймала»: её фейк лишь +записывал аргумент, а фейк, который не может отказать, не проверяет ничего. Резюмный пин отделён от +арифметики специально: в нём смоделирован ОСТАВЛЕННЫЙ резерв убитого процесса ($0.20), и он +утверждает ДВА факта — что потолок пересчитан по свежему отсчёту и что остаток его не раздул +(PD-158). + +Проба приёмки на этом дереве (её файл, только `Reserved` дописан в отчёты фейка): +`TestAcceptSecondRunCeilingArgument` — **PASS**, `run2 args: … --ceiling-usd 6.000000` при +committed книги $3. + +Устаревшие посылки, названные приёмкой, сняты: +- `runner/engine.go` больше не пишет, что флага нет и что копировать форму из чужого дерева нельзя: + флаг заленден (`0e69bc1`), форма ратифицирована, поэтому `--ceiling-usd {{usd}}` стал **дефолтом** + конфигурации (`TM_PLATFORM_ENGINE_CEILING_ARG` остаётся переопределением для сборки с другим + написанием). `ErrCeilingNotWired` оставлен: тип по-прежнему допускает пустое значение, а пустой + шаблон не должен значить «без потолка». ⚠ Уточнено ревью доков: он не «недостижим» — переменная из + одних пробелов проходит подстановку дефолта и даёт пустой шаблон, то есть отказ в рантайме при + одном WARN на буте (PD-165 — тот же класс, что относительный `TM_PLATFORM_CTL_BIN`). +- `ingest/resync.go` больше не пишет, что у статуса нет фазового сплита: `progress{draft,edit}` + внесён в аллоулист и материализуется `ApplyStatus` теми же четырьмя счётчиками, что и поток. Этим + снято ⚠ затирания фаз резюнком — не гейтом, а тем, что затирать стало нечем. Гейт + «поток идёт → ре-синк не зовём» оставлен, но его обоснование переписано честно: он теперь про + ЦЕНУ (каждый вызов статуса — секунды CPU на пере-нарезку) и про то, что поток свежее. + +### F2 [MAJOR] Инверсия блокировок была жива; транзиентный сбой уходил в карантин + +Порядок написан в ОДНОМ месте и стал глобальным — `pgstore.lockBook`: +`books → runs → run_attempts → account_balances → reservations`. Книга блокируется первой в +`RunSink.Apply`, `RestartRun` и `StartRun` (последний добавлен не «за компанию»: он берёт баланс до +`update books`, и если бы книгу первым брал только рестарт, пара `StartRun ∥ RestartRun` дала бы ту +же инверсию на другой паре строк). `closeReservation` уже брал баланс раньше резервации — проверено, +не тронуто. Собственной сверкой всех транзакций пакета найден ещё один нарушитель — `DeleteBook` +(резервации, потом книга); цикла для него сегодня нет, но именно такое рассуждение записанный +инвариант и должен заменять, поэтому книга взята первой и там. + +Классификация ошибки материализации вынесена в `runs.quarantines` и стала явной: +транзиентные SQLSTATE (`40P01`/`40001`, `pgstore.IsTransient`) и собственная отмена — повтор +следующим свипом; карантин остаётся только логическим ошибкам (пропасть, конфликт payload, битая +строка, строка длиннее буфера). + +Пины. Конкурентный оставлен (`TestAMaterializerAndAReconcilerOnOneRunDoNotDeadlock`, 60×3 через +реальные API), но он **пробует**, а не доказывает: посадка «снять `lockBook` из `RestartRun`» его +ПЕРЕЖИЛА — рестарт по-настоящему берёт замок один раз за прогон, и окно слишком узкое. Поэтому +написаны два пина, утверждающие порядок ПРЯМО: `TestTheMaterializerTakesTheBookBeforeTheAttempt` и +`TestARestartTakesTheBookBeforeTheAttempt` — тест держит замок книги, ждёт, пока операция реально +заблокируется, и спрашивает строку попытки через `for update nowait`. Свободна — значит операция +ждёт книгу, ничего не держа. Занята — значит она взяла попытку по дороге, то есть в том порядке, +который и дедлочит. ⚠ Ожидание блокировки читается из `pg_stat_activity`, а не из `pg_locks`: +ждущий строку ждёт на `transactionid` держателя, а у таких строк `pg_locks.database` пуст, поэтому +запрос по своей базе их не видит (проверено — первая редакция теста падала именно так). + +### F3, F4 [MAJOR, пины] Два свойства, построенных и не запиненных + +- **Нечитаемый отсчёт ОТКАЗЫВАЕТ спавну** (сердце PD-124): `TestAnAttemptWhoseMeterCannotBeReadIsNotStartedAtAll` + — три формы нечитаемости (вызов упал · нет `committed_usd` · нет `reserved_usd`), в каждой + утверждается, что юнит не создан И право на спавн не заклеймлено (`unit_name` пуст), значит свип + повторит; хвост показывает, что после починки движка та же попытка стартует. +- **Устаревший exit-маркер стирается ДО старта**: `TestAStaleExitMarkerIsClearedBeforeTheUnitStarts` + — маркер создаётся до спавна, после спавна обязан отсутствовать, свип обязан оставить прогон живым + и холд открытым. + +### F5 [MAJOR, деньги] Расчёт по устаревшему снапшоту + +`pgstore.ReleaseUnspawned` перечитывает `run_attempts.unit_name` `for update` в ТОЙ ЖЕ транзакции, +что и деньги, и отказывает (`ErrAttemptSpawned`), если юнит есть. Реконсилятор тогда откладывает +расчёт: следующий проход видит юнит в снапшоте и считает по базовой линии попытки. Выбран SQL-гард, +а не перечитывание в Go, потому что гард атомарен с самим возвратом холда. + +Пин: `TestAStaleSnapshotDoesNotGiveBackTheHoldOfAnAttemptThatSpent` — в проходе со старым снапшотом +холд не возвращается целиком, следующий свип списывает ровно потраченные $0.50. +⚠ Проба приёмки `TestAcceptStaleSnapshotReleasesTheHoldOfARunThatActuallySpent` на этом дереве **не проходит как написана** и это не +регрессия: она требует, чтобы деньги разрешились в ТОМ ЖЕ вызове `reconcile`, а исправление +откладывает их на следующий проход. Замеренная разница: было — баланс `10.000000` (весь холд назад, +списано ноль), стало — `7.000000` при `3.000000` в резерве (деньги на месте и видимы), после +следующего свипа — `9.500000`. + +### F6, F7 [MINOR] + +- Относительный `TM_PLATFORM_STATE_DIR` отвергается на буте (`filepath.IsAbs`), пин + `TestARelativeStateDirectoryIsRefusedAtBoot`. +- «Метка давности» ре-синка построена: `runs.last_resync_at` (миграция 00012) пишется КАЖДЫМ + ре-синком, пин `TestAResyncRecordsWhenItWasTaken` (нет метки до первого · метка равна времени + вызова · вторая переписывает первую). ⚠ Названная девиация: **на провод метка не выходит** — в + контракте v0 у прогона поля свежести нет, и заводить его самовольно не право зоны (PD-150). + +### F8 [MINOR, отчёт/регистр] + +Все пять исправлений внесены: числа тестов пересчитаны исполнением и приведены с командой (105 → 261, +удалённых 0); арифметика «36 новых» исправлена на 27 + 9 (после ратификации PD-112) и подтверждена +скриптом по таблице; заявление «каждый из 22 пинов проверен посадкой» снято как непроверяемое +(PD-142); строка PD-105 приведена к коду — карантинится ПОПЫТКА (её проекция), жизненный цикл +прогона продолжается; PD-122 дополнен: ревизия не двигается и на ДОБАВЛЕНИИ книги (`AddBook` пишет +`revision = 0`), класс тот же. + +### Новые строки регистра (N1…N4) и ратификации + +Заведены четыре по фикс-листу плюс две собственные (PD-156 — ниже, PD-151 — числа отчёта): +**PD-152** (стоп → `failed`: реальный `tmctl` ловит SIGTERM и выходит кодом 1, так что +ветка `stopped` достижима только для смерти ОТ сигнала — проба пака показала `stopped` на фейке, +который именно так и умирал) · **PD-153** (грация спавна от `started_at`, а не от времени заявки) · +**PD-154** (`settled_at` может остаться NULL между `Settle` и `MarkSettled`; потребителей нет, +деньги целы) · **PD-155** (смена `StateDir` осиротляет маркеры — строка внесена в `deploy/README.md`). + +Ратификации оркестратора внесены в регистр: **PD-112** закрыт ратификацией (`503` вносится в спеку +правкой владельца контракта) · **PD-114** переформулирован в задачу «печать эффективной конфигурации +на старте с редакцией секретов», env-only остаётся · **PD-115** — направление «по внешнему эталону на +ось» ратифицировано, носитель работы — строка · **PD-129** пере-диспозиция: строка была закрыта +ЛОЖНО, действительно закрыта дофиксом (PD-145) · `default_chapters` = верх шкалы остаётся, кода не +касается · PD-113 остаётся открытым до эмиттера. + +### Батарея и посадки дофикса + +- `make check` с живым PostgreSQL 18.4 под `-race`: **13 пакетов, 0 FAIL**, линтер **0 issues**. +- `go test ./... -count=1 -v | grep -c -- '--- SKIP'` = **0**. +- `make vuln`: **No vulnerabilities found**. +- Тесты: **105 → 261**, удалённых **0** (команда — раздел «Батарея и самопроверка» ниже). +- **Посадки: 24 из 24 пойманы** (скрипт восстанавливает файл после каждой): + `bookCap` возвращает прирост · записывается прирост вместо аргумента · отсутствующий + `reserved_usd` читается нулём · материализатор берёт попытку первой · рестарт берёт попытку первой · + транзиентный сбой карантинится · нечитаемый отсчёт читается нулём · очистка маркера удалена · + расчёт доверяет снапшоту (`Release` вместо `ReleaseUnspawned`) · относительный `StateDir` + принимается · метка ре-синка не пишется · метка пишется только однажды · ре-синк снова кладёт один + агрегат · шаблон потолка теряет дефолт · отказ деплоя проверяется ПОСЛЕ вызова движка · + остаточная резервация раздувает потолок · `DeleteBook` берёт резервации раньше книги · отложенный + расчёт не ограничен верхней границей · транзиентный сбой карантинится (через РЕАЛЬНЫЙ путь) · + повторная заявка перезаписывает базовую линию · рестарт Postgres карантинит проекцию · сброшенное + соединение карантинит проекцию · ретрай пересчитывает переданный потолок · счётчик назад списывает + молча. + ⚠ Две посадки в первой редакции **ПЕРЕЖИЛИ** и это записано, а не подчищено: «рестарт берёт попытку + первой» (конкурентный пин её не ловил — написаны прямые пины порядка) и «метка пишется только + однажды» (тест ставил метку один раз — добавлена вторая проверка). +- Контрактная проба приёмки (47 wire-проверок, `-overlay` поверх пакета `httpapi`) пере-прогнана на + этом дереве целиком: **PASS**, формы не сломаны. +- Гоночная проба приёмки `TestAcceptConcurrentStartRunTakesExactlyOneHold`: **PASS** (8 конкурентных + `StartRun` → 1 прогон, 1 холд) — она проверяет то, что `lockBook` в `StartRun` мог бы изменить. + +### Что нашла собственная сверка диффа (после фиксов, до сдачи) + +Две правки, обе внесены и обе — про то, что дофикс мог испортить рядом: + +1. **`DeleteBook` брал резервации раньше книги** — единственный оставшийся нарушитель порядка, + который я записал как глобальный. Цикла для него сегодня нет (проверено перебором пар: удаление + трогает только ЗАКРЫТЫЕ резервации, а `StartRun` вставляет новую), но инвариант с необъявленным + исключением перестаёт быть инвариантом — книга взята первой и там, и это запинено + (`TestDeletingABookTakesItBeforeItsReservations`; вспомогательный `assertBookFirst` обобщён на + любую вторую строку — попытку или резервацию). +2. **Отказ ДЕПЛОЯ проверялся после вызова движка.** Перенеся чтение отсчёта в начало `spawnAttempt`, + я поставил его ПЕРЕД дешёвыми отказами («нечем записать конец юнита», «нечем передать потолок»), + и инстанс с неполной конфигурацией платил бы секундами CPU движка за каждый прогон на каждом + свипе, чтобы прийти к ответу, зависящему только от конфигурации. Вынесено в `runs.runnable()`, + вызывается первым; пин `TestAMisconfiguredDeploymentIsRefusedWithoutAskingTheEngine` (посадка + «убрать ранний отказ» падает — 15-я в списке выше). + +### Формула потолка: почему `reserved` в неё не входит (PD-158, ратифицировано 09.08) + +Сдавалось как ВОПРОС с консервативным кодом; ре-чек V2 ратифицировал формулу зоны, пере-мерив обе +против гейта движка — та, что с `reserved`, переплачивала запасом ровно на leftover-reserved. + +Первая формула — `committed + reserved + прирост` — читает `reserved_usd` из `status --json`. Но +`store.Open`, путь ЗАПИСИ, которым идёт каждый `translate`, выполняет `recoverReservations` и +обнуляет `reserved_usd` книги ДО того, как будет судима первая резервация +(`backend/internal/store/store.go:88`, тело — `:214`); `tmctl status` идёт read-only и этот проход +намеренно не делает — движок пишет об этом сам (`store.go:108`: «after a run crashes, reserved_usd +stays non-zero until the next WRITE command … status will show this leftover as reserved»). + +Значит цифра, которую платформа кладёт в потолок, к моменту сравнения уже стёрта, и прогон получает +на её величину БОЛЬШЕ, чем его холд: движок останавливается позже, расчёт упирается в потолок холда, +леджер пишет «capped at the hold» — и это читается как перерасход движка, хотя это наша арифметика. +Путь достижим на каждом резюме после падения, потому что падение и оставляет резервацию. + +**Что сделано:** `bookCap` считает `committed + прирост`. Отклонение в КОНСЕРВАТИВНУЮ сторону — более +узкий потолок может только остановить прогон раньше, перерасхода не даёт, — названо в коде и +запинено, и сдано ВОПРОСОМ, а не решением: отклонять ратифицированное молча не право зоны. Ответ +пришёл ре-чеком V2 — формула принята. Цифра `reserved` +по-прежнему читается и обязательна: она и есть доказательство, что отклонение безопасно — на спавне +другого писателя нет (эксклюзивный лок плюс один живой прогон на книгу), значит любой `reserved` по +построению остаток. + +### Найдено при F1 и заведено, а не построено + +**PD-157: дневной потолок книги `--ceiling-usd` не перекрывает.** Ратификация D39.122 говорит это +прямо, и движок требует хотя бы один из `book_usd`/`day_usd` (`backend/internal/config/book.go:250`), +а `book.yaml` пишет ОПЕРАТОР — платформа его не правит (D39.110 §2b) и в `book add` лишь проверяет +наличие. Значит книга с низким `day_usd` останавливает прогон на лимите, которого платформа не +выбирала: движок выходит кодом 1, прогон приезжает `failed`, деньги пользователя целы, причина ему +не видна. В `status --json` дневной фигуры нет вовсе, так что и диагностировать это платформа +сегодня не может. Не строил: фикс-лист этого не просил, а закрывается оно либо проверкой при +заведении книги, либо словом контракта о том, кто владеет потолками `book.yaml` у книг под +платформой — второе не право зоны. + +### Селф-ревью дофикса: три верификатора, что нашли + +Прогнаны три независимых ревьюера по разным линзам — один по фикс-листу и диффу, один НАМЕРЕННО без +отчёта («вне карты»), один по claim-fidelity доков против кода. Все трое работали read-only, мутации +гоняли в копиях дерева. + +**Найдено и ПОЧИНЕНО в этом же дереве:** +1. **PD-159, деньги, major.** Отложенный расчёт прогона оплачивал работу СЛЕДУЮЩЕГО прогона той же + книги, а тот платил за неё ещё раз. Нашли ДВА верификатора независимо, каждый воспроизвёл + исполнением; я воспроизвёл третьим ($2.10 за прогон, стоивший $0.10) ПЕРЕД починкой. Причина — + расчёт читает пожизненный счётчик КНИГИ в момент повтора, а завершённый-но-нерассчитанный прогон + не мешает начать новый. Починено верхней границей `SpendBound` (наименьшая базовая линия среди + попыток книги, стартовавших позже — она снята после остановки этой попытки и до того, как та + что-либо добавила, то есть ТОЧНАЯ граница). +2. **PD-160, пин.** «Транзиентный сбой не карантинит» пинилось на чистой функции; удаление ветки, + которая её ВЫЗЫВАЕТ, переживало батарею. Написан пин, который гонит НАСТОЯЩИЙ дедлок через весь + путь. +3. **PD-161, деньги.** Повторная заявка права на спавн перезаписывала базовую линию — при + `systemd-run`, убитом ПОСЛЕ подачи запроса, попытка платила бы разницу от цифры, включающей её же + работу. + +**Найдено, заведено и НЕ построено** (каждое — строкой с названным лечением): PD-152 расширен живым +замером — штатная перезагрузка закрывает все прогоны как `failed`, потому что `ExecStopPost` +отрабатывает и движок выходит кодом 1 (путь строки 138 покрывает только потерю питания) · PD-162 +удалённый каталог книги заклинивает прогон навсегда с открытым холдом · PD-168 бюджет перезапуска +считается по текущей ставке (замерено: $5.50 вместо $2.50 при удвоении) · PD-163 ревизия области +читается вторым запросом · PD-164 неразбираемый маркер вечно валит реконсиляцию · PD-165 +относительный `TM_PLATFORM_CTL_BIN` и `$` в `quoteArgv` · PD-166 `chunker_version` теряется · +PD-167 расхождение док↔код в комментарии миграции · PD-169 бюджет свипа один на все прогоны · +PD-170 асимметрия `Settle` с PD-97. + +**Найдено в ДОКАХ и исправлено:** тело F1 противоречило собственному разделу PD-158 · описание фейка +`ceilingJudge` отстало от кода · §21 `STACK_DECISIONS` не догнал PD-158 · старый раздел «Сессия P4» +утверждал, что флага потолка в HEAD нет · числа тестов и посадок разъезжались между журналом и +регистром · PD-158 приписывал D39.122 буквальную формулу, которой в решении нет (она из пинга) · +PD-122 требовал «свою миграцию», хотя колонка `users.library_revision` существует с 00001 и не +используется ни одним путём · «`ErrCeilingNotWired` недостижим через конфигурацию» — переменная из +пробелов до него доходит. + +### Ре-чек V2 (оркестратор №15, 09.08): четыре хвоста, три — в новом коде дофикса + +**V2-1 [MAJOR] — моя же классификация была неполна.** `IsTransient` знала только про дедлок и +сериализацию, поэтому ШТАТНЫЙ рестарт Postgres (57P01/57P02/57P03, класс 08, сетевой сброс) всё ещё +карантинил проекцию живого платного прогона НАВСЕГДА — пути снятия карантина в дереве нет. Клейм +PD-145 «карантин остаётся только логическим ошибкам» был в этой части неверен и пере-сформулирован. +Теперь покрыты класс 08, 57P0x, `pgconn.SafeToRetry` и любой `net.Error`; ошибки чтения ФАЙЛА при +этом остаются логическими — `*fs.PathError` не удовлетворяет `net.Error`, поэтому битый журнал +по-прежнему останавливает проекцию, как и должен. Шесть новых кейсов в таблице пина, две посадки. + +**V2-2 [MINOR, деньги] — PD-161 был закрыт наполовину.** В БД значения сохранялись, а движку на +ретрае уходил потолок, пересчитанный по СВЕЖЕМУ счётчику, то есть включающий трату собственного +«призрака»; форензик-колонка 00011 на этом пути лгала (замер приёмки: handed 3.400000 против stored +3.000000 — воспроизведён посадкой на моём дереве до буквы). Теперь ретрай ПЕРЕДАЁТ сохранённый +аргумент. Пин расширен до `handed == stored`, плюс отдельный пин на гард в БД +(`TestASecondClaimOnOneAttemptKeepsWhatTheFirstRecorded`) — после правки в Go посадка на `coalesce` +переставала ловиться, и свойство без собственного пина не считается закрытым. + +**V2-3 [MINOR, доки у денег]** — три текста утверждали отменённую формулу (`reconcile.go` про +рестарт, комментарий поля `Reserved`, строка PD-144) и два — несуществующий отказ загрузчика на +пустое переопределение. Всё приведено к факту. Туда же: **комментарий миграции 00011 нёс отменённую +формулу, а 00009 обещал перечитывание файла, которого тейлер не делает.** Обе исправлены, отпечатки в +`migrations.sha256` обновлены — с явной причиной в шапке файла: обе миграции написаны этим паком, +нигде не применялись, а миграция с лгущим комментарием хуже сдвинутого отпечатка до релиза. +⚠ Ре-чек назвал застывшим комментарий 00011 под номером PD-167, тогда как предмет PD-167 — 00009; +исправлены обе, расхождение в нумерации названо в строке. + +**V2-4 [NOTE]** — счётчик книги ниже собственной базовой линии попытки списывал $0 молча. Клампить в +ноль правильно (платить за подмену БД проекта код решать не вправе), молчать — нет: добавлен WARN, не +INFO, с фактом и без цифр. + +### Что дофикс НЕ трогал (осознанно) + +Ручки стопа/резюма (PD-140) · эскроу и `uncertain` (строка 136) · `POST /books` (П-9) · собственный +счётчик ревизии области (PD-122) · грация от времени заявки (PD-153) — заведены строками, не +построены: фикс-лист их не просил, а расширять пак на приёмке — это то, за что заведён PD-83. + +--- + +## Сессия P4 (08–09.08): раннер — юнит-на-прогон, очередь, реконсилятор, тейлер, первые ручки /v0 + +**Каждое число ниже — с командой, которой получено.** Дерево не коммичено; в индексе ничего не держу. + +### Работа 1 — транзиентный systemd-юнит на прогон + +`internal/runner/runner.go` (юнит), `marker.go` (маркер выхода), `engine.go` (инвокация движка). + +**Привилегия-модель решена ДО постройки, и второй вариант отвергнут по СВОЙСТВУ, а не по вкусу.** +Выбран linger-пользователь: юниты создаются в СОБСТВЕННОМ менеджере платформы, новых привилегий в +рантайме ноль. Polkit-вариант research/25 непригоден: **polkit авторизует ГЛАГОЛ, а не свойства** — +`StartTransientUnit` отдаёт выбор `ExecStart=`/`User=` вызывающему, а транзиентный СИСТЕМНЫЙ юнит без +`User=` идёт от root, то есть правило выдаёт сервису root и сузить это нечем. Плюс до systemd v257 в +запрос авторизации не попадает даже ИМЯ юнита (systemd issue #17224 → PR #34651, влит 09.10.2024), +так что на стенде (systemd 255, `systemctl --version`) «узкое правило» не узкое вовсе. Разбор — +`STACK_DECISIONS` §15. + +**Замерено на стенде, не выведено из доки** (все команды — `systemd-run --user`, стенд без root): + +| Свойство | Как замерено | Результат | +|---|---|---| +| Юнит переживает того, кто его создал | спавнер-скрипт стартует юнит и выходит | `ActiveState=active`, маркер `success` | +| `--collect` ⇒ состояние systemd не истина | `systemctl --user show` после выхода | `LoadState=not-found`, `ExecMainStatus=0` для прогона, вышедшего с кодом **3** | +| Маркер несёт исход | `ExecStopPost` на трёх исходах | `exit-code/exited/3` · `success/killed/TERM` · `oom-kill/killed/TERM` | +| **Лимиты в `app.slice` НЕ применяются** | `cat app.slice/cgroup.subtree_control` | **пусто**; лист без `memory.max`; процесс, потрогавший 400 МиБ, пережил `MemoryMax=64M` | +| Лимиты в своём срезе применяются | то же в `tm-runs.slice` | `memory.max=67108864`, `pids.max=32`, тот же процесс — `oom-kill`, `Memory peak: 64.0M` | +| `%`-спецификаторы в `--property=` не раскрываются | `ExecStopPost` с `100%_done`/`%n` | доехали буквально ⇒ экранировать `%` не нужно и было бы неверно | +| `ProtectHome=yes` прячет `/run/user/` | проба под юнитом | `Permission denied` ⇒ своя же шина недостижима; `ProtectHome=tmpfs`+`BindPaths` ⇒ сокет виден | +| Шине хватает `DBUS_SESSION_BUS_ADDRESS` | `env -u XDG_RUNTIME_DIR` | работает; без обоих — `Failed to connect to bus` | + +**Ответ PD-13 живёт в cgroup ПРОГОНА и измерен.** ⚠ И там же найдено собственным флейком: `MemoryMax` +БЕЗ `MemorySwapMax=0` не ограничивает прогон — ядро выдавливает страницы в своп (у среза +`swap peak: 692.5M`), процесс выживает и тормозит до скорости диска. Для перевода это хуже отказа: +тормозящий часами прогон продолжает платить за каждый прошедший вызов. Снято: `MemorySwapMax=0` идёт +вместе с `MemoryMax` (`runner.go`), три прогона пина подряд зелёные, три посадки «убрать своп-лимит» +подряд красные. + +Пиннинг версии (строка 139): `run_attempts.engine_binary` пишется ДО создания юнита, новая попытка +его НАСЛЕДУЕТ, резюм исполняет именно его, а переход на другую сборку требует явного +`TM_PLATFORM_RESUME_MAY_CHANGE_ENGINE`. ⚠ Первая редакция закрывала эту строку НАПОЛОВИНУ — путь +читался каналом ремонта и не исполнялся резюмом; поймано моей же пост-сверкой диффа с промтом, не +батареей (обе половины компилировались и всё было зелёным). PD-143. + +### Работа 2 — очередь River + воркер + +`go.mod`: `github.com/riverqueue/river v0.42.0` + `riverdriver/riverpgxv5` (пин из `STACK_DECISIONS` +подтверждён). `internal/runs/queue.go`. + +**Холд берётся ДО спавна и в ОДНОЙ транзакции с созданием прогона, попытки и записи очереди** +(`pgstore.StartRun`, `runs.go:66`): холд без прогона — деньги, зарезервированные ни за чем; прогон без +холда — трата незарезервированного; запись очереди без обоих — воркер, спавнящий движок против книги, +у которой нет бухгалтерии. Очередь вставляет своё задание через колбэк с той же `pgx.Tx`. + +**Гард «один живой прогон на книгу» — исполнением:** частичный уникальный индекс +`runs_one_live_per_book` мапится в доменную `ErrRunInFlight` (не сырой SQLSTATE), ручка отдаёт 409. +Живая проба: второй `POST .../runs` на ту же книгу → **409**, `Reserved` не сдвинулся. + +`MaxAttempts: 1` у задания — намеренно против рефлекса очередей: повтор здесь не доделывает работу, а +спавнит ВТОРОЙ движок; восстановление — работа реконсилятора. Живая проба: повторная доставка задания +на живой прогон не создала второго юнита (`len(started)` = 1). + +### Работа 3 — реконсилятор + +`internal/runs/reconcile.go`. На КАЖДЫЙ выход юнита — через **ExecStopPost-маркер**, не D-Bus: сигнал, +посланный в момент, когда платформа лежит (а она лежит несколько раз в неделю по замыслу), теряется; +файл — нет. На БУТЕ тот же свип перезапускает прерванные прогоны (строка 138): истина — каталоги книг +и Postgres, systemd спрашивается только «жив ли юнит», и это улика, не вердикт. + +Разграничитель «не стартовал ещё» / «умер без слова» — время (`spawnGrace` 60 с). Перезапуск даёт +новой попытке ОСТАТОК бюджета (`budget − RunSpent`), иначе один прогон потратил бы потолок дважды; +если остатка нет — `paused`/`credit_exhausted`, не `failed`. + +**Расчёт — из фигуры движка, прочитанной ПОСЛЕ выхода процесса** (`status --json`, ратифицированный +канал ремонта; лока нет, гадать не о чем). Не прочиталась — резервация остаётся ОТКРЫТОЙ и попадает в +`UnsettledRuns` для следующего свипа. ⚠ Эскроу-половина research/25 (write-ahead intent, `uncertain`, +`closing`) НЕ строилась: это строка 136 и её собственный промт. + +**Резюнк — честно редкий и честно грубый:** по умолчанию 5 минут (`TM_PLATFORM_RESYNC_EVERY`), и +только там, где чинить нечего — курсор не двигался ЛИБО материализация в карантине. Причина в цене: +каждый вызов заново ингестит и режет исходник (1.4–1.5 с CPU на книге 23 МБ, строка 100). + +### Работа 4 — тейлер `events.jsonl` + +`internal/ingest/tail.go`. Курсор `(engine_run_id, seq)` коммитится в ОДНОЙ транзакции с эффектом +(`pgstore.RunSink.Apply`), байтовое смещение — хинт. Файла ещё нет (эмиттер = строка 103) — тейлер +спокойно ждёт (`ErrNoJournal` не ошибка свипа). + +**PD-105 ратифицирован этим паком и реализован:** дубль `seq` — НОРМА, пропускается идемпотентно и +без фатала; тот же `seq` с ДРУГИМ payload — `ErrPayloadConflict` → карантин ПРОЕКЦИИ (не прогона: +движок тратит уже зарезервированные деньги, и наша неспособность читать его журнал — не повод это +выбросить). Пропасть остаётся ошибкой. Строка PD-105 реестра обновлена. + +Частичная последняя строка не читается как событие (писатель ещё пишет). Журнал пер-книжный и +append-only, поэтому резюм дописывает ВТОРОЙ `hello`: читатель ведёт область и не судит чужие строки. + +### Работа 5 — ручки `/v0` по контракту 0.2.0 + +`internal/httpapi/v0.go`: `GET /books` · `GET /books/{bookId}` · `GET /books/{bookId}/run-options` · +`POST /books/{bookId}/runs` · `GET /usage`. Прогресс/статус прогона отдаётся внутри карточки книги — +отдельной ручки прогона в спеке НЕТ, и выдумывать её я не стал. + +`CeilingBounds`: максимум = `Balance` КАК ЕСТЬ (холд — дебет в момент взятия), подрезан И остатком +книги; `max_chapters: 0` легален. `default_chapters` = верх шкалы — продуктовая политика платформы +(D39.115 §4), компромисс назван вслух в коде и вынесен вопросом ниже. + +**Интейк:** дев-инструмент `tmplatformctl book add` (Go-подкоманда, а не шелл-скрипт: тестируется). +`POST /books` (multipart) НЕ взят — обоснование строкой **П-9** зонного бэклога. + +**Пересчёт «главы→доллары»** — константа платформы `$0.03`, провенанс: exp08 v2 ($10.94 на 500 глав = +$0.0219) через ревизию D30.4 (+15–25% → $0.0252–0.0274), округление ВВЕРХ. Движковой поверхности +оценки не выдумывал; потребность — строка **П-10**. + +### Работа 6 — регистр + +Закрыты: **PD-43** (денежный контур получил вызывающих) · **PD-81** · **PD-82** · **PD-97** · +**PD-99** · **PD-105**. Заведены **PD-108…PD-116** (из них PD-108/110/111/116 — дефекты, найденные и +закрытые внутри этого же пака; PD-112…115 открыты вопросами). Строки эмиттер-стороны (PD-60/61) не тронуты. + +### Сквозная проба на боевом бинаре (не тестами) + +Поднят `tmplatformd` против живого PG, с фейковым `tmctl` ($0, говорит ровно две поверхности: +инвокацию с потолком и `status --json`). + +| Шаг | Наблюдение | +|---|---| +| `book add` + `GET /v0/books` | книга в библиотеке, `status: not_started` | +| `GET run-options` | `min 1 / max 333 / default 333` — $10 / $0.03 = 333, подрезано 500 главами книги | +| `POST runs` (100 глав) | `202`, форма `Run` по спеке; юнит `tm-run--1.service` **active running** | +| потолок, дошедший до движка | `--ceiling-usd 3.000000` = 100 × $0.03 | +| второй `POST` на ту же книгу | **409** | +| `/v0/usage` при открытом холде | `remaining_percent: 70` (холд — дебет) | +| прогресс из журнала | `draft 4/10, edit 0/10` — пофазно, без затирания резюнком | +| выход юнита | книга и прогон → `ready`, `finished_at` проставлен | +| расчёт | грант $10 → движок отчитался $0.42 → баланс **9.580000** (не потолок $3) | +| гигиена логов | `grep -c 'ceiling-usd\|3.000000\|bk_' daemon.log` = **0** | +| systemd после `--collect` | юнитов **0** | + +**Ратифицированное свойство D39.106 — прогон переживает деплой — проверено убийством платформы:** +`pkill -9` по имени процесса → API отвечает `000`, юнит `active running`, журнал за 6 с вырос +`10 → 13` строк. Платформа поднята заново → карточка показала `draft 21/300` (догнала всё, что было +записано, пока её не было), через 6 с — `24/300`, юнит ОДИН, в логе новой платформы +`grep -c 'run unit started'` = **0** (второго движка не создано). Остановка через systemd → маркер +`success/killed/TERM` → статус **`stopped`** (не `failed`). + +### Батарея и самопроверка + +- `make check` с `TM_PLATFORM_TEST_DSN` (живой PostgreSQL 18.4, `-race`): все 13 пакетов зелёные, + линтер **0 issues**. Прогнана дважды подряд. +- Скипы: `go test ./... -count=1 -v | grep -c -- '--- SKIP'` = **0**. +- `make vuln`: **No vulnerabilities found**. +- Дифф `^func Test` ИСПОЛНЕНИЕМ: **105 → 261**, удалённых **0**, добавленных **156** (число после + дофикса 09.08). Команда, которой считано: + `H=$(git grep -h '^func Test' HEAD -- 'platform/**/*.go' | sed 's/(.*//' | sort -u)` · + `T=$(grep -rh '^func Test' platform --include=*_test.go | sed 's/(.*//' | sort -u)` · + `comm -23 <(echo "$H") <(echo "$T")` — пусто, то есть удалённых ноль. + ⚠ **Исправлено приёмкой (PD-151):** здесь стояло «105 → 203, +98», и это было неверно на момент + написания — приёмка пересчитала 242/+137/−0, дофикс с ре-чеком довели до 261/+156/−0. Шапка журнала при этом + была права. + ⚠ Две мои промежуточные редакции этого счёта были неверны, и это записано, а не подчищено: + сначала pathspec не тот, потом грep без `*.go` — второй ловил КОПИЮ теста, вставленную в этот же + журнал приёмкой P1 (`platform-PROGRESS.md:683`), и рисовал ложное «удалён + `TestHoldAndSettleOnTheSameAttemptDoNotDeadlock`». Тест на месте: `credits_test.go:308`. +- **Посадки (мутации): 12 из 12** в самопроверке; **139 в аудите ревью** — 107 поймано, 32 пережили + (8 не ослабления), по ним написано **22 новых пина**. ⚠ **Заявление «каждый проверен своей + посадкой» СНЯТО приёмкой (PD-142/PD-151):** прогонов посадок было девять (9/10, затем 9/9), и + поимённого соответствия пин↔посадка сессия не вела — 22 ими не покрываются. Итого посадок, + проверенных поимённо: **33 моих в паке** (ниже) и **24 в дофиксе** (раздел «Дофикс P4»), все + пойманы. Каждая восстанавливалась после прогона: + холд вне транзакции допуска · мапинг `runs_one_live_per_book` · high-water mark в синке · + `greatest` у spend · payload-конфликт · детект пропасти · частичная строка как событие · + область чужого потока (обе стороны: `mine := true` и `mine := false`) · срез прогона · + половина шкалы (та самая ошибка «минус Reserved») · грация спавна · потолок как `failed` · + argv в INFO-логе · `MemorySwapMax` · compare-and-set права на спавн. + ⚠ **Одна посадка ПЕРЕЖИЛА первую редакцию пина** и это записано, а не подчищено: high-water mark + проверялся событием `progress`, а `progress` — присваивание, то есть идемпотентен сам по себе. + Настоящий пин — считающий эффект (`unit_done`): `TestARedeliveredCountingEventDoesNotCountTwice`. + +### Девиации и то, чего не сделал + +1. **`503` на старте прогона не входит в перечисленные спекой статусы операции** — PD-112, вопрос + владельцу контракта. Каждый разрешённый код в этой ситуации соврал бы. +2. **Стоп по потолку сегодня отдаётся как `failed`** — PD-113, контрактно видимо. Движок возвращает + потолок ошибкой (`errReserveCeiling`, сверено в HEAD), события потолка нет (строка 103). Ветка под + событие построена и запинена; выдумывать порог «сколько процентов потолка = стоп» я не стал. +3. **`POST /books` не взят** — П-9, с обоснованием. PD-72 закрывается вместе с ним. +4. **Эскроу/`uncertain`/`closing` не строились** — строка 136, следующий денежный промт. +5. **Стоп/резюм прогона как ручки контракта** не в списке работ промта — не строились; банк-стоп + заканчивает попытку и прогон, резюм = новый прогон. +6. **`Supervisor.Status` дев-пути починен** (PD-108) — он в моей зоне и это мой канал ремонта. + +### Адверсариальное ревью (author≠reviewer), и почему это главный результат пака + +Четыре независимых верификатора, ни один не читал отчёт (его на тот момент не существовало): +(1) промт+дифф вслепую · (2) охота за дефектами ВНЕ карты · (3) сверка провода с openapi 0.2.0 · +(4) сила пинов посадками. Каждому запрещено писать в репозиторий; эксперименты — в копиях. + +**Нашли три ДЕНЕЖНЫЕ ошибки и три запирающие прогон. Все закрыты в этом дереве, каждая с пином.** + +| Находка | Чем это было | Как закрыто | +|---|---|---| +| **PD-124** (major) | Расчёт брал `committed_usd` — ПОЖИЗНЕННУЮ сумму КНИГИ (`SUM(committed_usd) … WHERE book_id`) — как трату прогона. Второй прогон книги оплачивал первый заново; после того как сумма книги перерастает потолок, каждый прогон стоит ровно свой потолок. **Воспроизвели ДВА верификатора независимо** | Попытка пишет базовую линию книги ДО старта (миграция 00010) и платит разницу; линию не прочитать = не стартуем | +| **PD-125** (major) | Перезапуск при отложенном расчёте брал ВТОРОЙ холд и терял первый навсегда: списки его не находили | Перезапуск спрашивает `AttemptReservationOpen`; список несведённых ключуется на завершённости ПОПЫТКИ | +| **PD-126** (major) | Юнит, который не удалось создать, оставлял «право на спавн» — и каждый свип перезапускал прогон, съедая потолок по свипу за раз | Неудавшийся `Start` снимает право; повторяется та же попытка | +| **PD-127** (major) | Одна нечитаемая строка журнала возвращала ошибку ДО чтения маркера: прогон навсегда `translating` с висящим холдом | Любая ошибка журнала = карантин ПРОЕКЦИИ, жизненный цикл продолжается | +| **PD-128** | Грация спавна мерилась от старта ПРОГОНА ⇒ у перезапущенной попытки её не было | Мерится от старта попытки | +| **PD-129** | Инверсия порядка блокировок `books`↔`runs` между материализатором и финишером ⇒ взаимоблокировки | Книга блокируется первой везде | +| **PD-130/131** | Любая ошибка чтения журнала выглядела как «догнали»; хендшейк не обязан был нести `seq 1` | Только настоящий EOF; `seq != 1` = плохой хендшейк | +| **PD-132** | Холд прогона, который так и не стартовал, не возвращался | Нет линии и нет юнита ⇒ холд возвращается целиком | +| **PD-133/134/135** | Инстанс без движка не отдавал даже библиотеку; пиннинг версии писался и не читался; прерванный прогон вставал `paused` без причины | Чтения монтируются отдельно; `EngineBinary` читается и используется; `PauseRun` вместо `FinishRun` | +| **PD-117…121** | Потолок ниже минимума схемы отвечал 409 вместо 400 · `Usage.paused_reason` недостижим через путь реконсилятора · карточка несла ревизию ПРОГОНА (отстающую) · чужой курсор пагинации принимался · «exhausted» при остатке, на который прогон стартует | Все пять закрыты, каждая с пином | +| **PD-136/138** | Деплой-юнит нёс обе диспозиции сразу; `go.mod` не тидинут | Переписано; `go mod tidy` | +| **PD-142** | **Аудит силы пинов: 139 посадок, 107 поймано, 32 пережили** (8 из них — не ослабления: эквивалентный код или страховка DDL). Пережившие — не дефекты кода, а отсутствующие пины, часть на свойствах, объявленных закрытыми | 22 новых пина написаны; ⚠ «каждый проверен своей посадкой» СНЯТО приёмкой (PD-142/PD-151) — прогонов посадок было девять. Самые весомые: блокировка строки попытки под КОНКУРЕНЦИЕЙ (восемь горутин на одно событие — последовательная доставка посадку не ловила) и **CSRF на контрактной поверхности** (снятие переживало всё, а это старт платного прогона с амбиентной кукой) | + +**Оставлено открытым и названо:** PD-122 (`Library.revision` не монотонна при удалении книги — сегодня +недостижимо, ручки удаления нет; правильное решение — свой счётчик области, это своя миграция и +несколько путей записи, поэтому НЕ сделано, а объявлено гейтом) · PD-137 (упорядочение на +пользовательский менеджер — UID site-specific, инструкция в шапке юнита) · PD-139 (пути книг в +ERROR-логах — диспозицию надо принять осознанно) · PD-140 (`Service.Stop` не подключён: ручек +стопа/резюма в скоупе промта не было) · PD-141 · PD-112/113/123. + +**Что ревью подтвердило, а не опровергло:** привилегия-модель (с одним уточнением, принятым: +«никакое правило не сузит» верно для ТРАНЗИЕНТНЫХ юнитов; шаблонный системный юнит с фиксированным +`User=`, запускаемый `StartUnit`, — вариант, которого мой разбор не касался) · арифметика +`CeilingBounds` (баланс КАК ЕСТЬ, без второго вычитания холдов — проверено на живом PG пятью +раскладами) · отсутствие кросс-аккаунтных чтений и стартов · инвариант `balance == SUM(ledger)` +(сломать не удалось) · правила тейлера по областям и курсору. + +**Возражение ревью, которое я принял только частично.** Верификатор предложил различать стоп по +потолку через `ceiling_pct` из `status --json`. Не взял: `ceiling_pct` считается против +`book_ceiling_usd`, то есть против потолка ИЗ `book.yaml`, а наш потолок приходит аргументом прогона +(строка 145), которого в HEAD НА ТОТ МОМЕНТ не было — значит смысл поля зависел от ещё не залендённой +формы (флаг заленден 09.08, `0e69bc1`; альтернатива от этого не становится верной — `ceiling_pct` +по-прежнему считается против потолка из `book.yaml`, а не против переданного аргумента). Взять его +сейчас значило бы построить дискриминатор на поведении, которого я не могу проверить. Записано в +PD-113 как названная и оценённая альтернатива. + +**Повторная сквозная проба на боевом бинаре после починок** (чистая база, фейковый движок с +НАКОПИТЕЛЬНЫМ счётчиком книги, как у настоящего): грант $10.00 → прогон 1 (движок насчитал $0.40) → +баланс **9.600000** → прогон 2 на ТОЙ ЖЕ книге (счётчик книги $0.40 → $0.80) → баланс **9.200000**, +резервов ноль. До починки второй прогон списал бы $0.80. + +### Вопросы (канал вопросов промта; НЕ тихая интерпретация) + +1. **`503` вне перечня спеки** (PD-112) — вносить в спеку или назвать другой код? +2. **`default_chapters` = верх шкалы.** Когда книга дороже баланса, верх шкалы резервирует ВЕСЬ + баланс под одну книгу, и вторую начать нельзя (D39.110 §2в). Альтернатива — доля от максимума; + какая именно — вопрос продуктовый, не инженерный. +3. **Конфигурация (вопрос ВЛАДЕЛЬЦА 08.08, PD-114).** Ответ по существу: единого stdlib-пути в Go + нет; мейнстрим — либо 12-factor «только окружение» (наш выбор, записан в доккомменте `config.go`, + деплой уже использует systemd-нативные `EnvironmentFile=`+`LoadCredential=`), либо слоёная + `флаги > env > файл > дефолты` (koanf/viper). Ниже нормы у нас другое: **27 переменных** + (грепнуто), **ноль флагов** у демона, **нет печати эффективной конфигурации** при старте. + Вопрос владельца при этом нашёл реальный дефект в этом же паке: дефолт «$/глава» лежал в двух + местах — исправлено, резолвится только в `config`. +4. **Абстрактный вопрос владельца, и он подтверждается фактом (PD-115).** `ENGINEERING_STANDARDS` §2 + называет внешнюю версионированную базовую линию ровно для ОДНОЙ оси — безопасности (ASVS 5.0 L2 + + API Top-10 2023). Всё остальное — собственная проза зоны. Разница видна эмпирически: PD-57/PD-58 + нашлись именно сверкой с RFC 9700/9207 и NIST SP 800-63B. Там, где эталона нет, сверять не с чем — + грепнуто на 08.08: метрик и трейсинга **ноль**, процедуры бэкапа/восстановления в `deploy/` нет, + SLO не заданы. Предложение: §2 получает по эталону на ось; ратификация — оркестратора. + diff --git a/platform/docs/platform-PROGRESS.md b/platform/docs/platform-PROGRESS.md index 8197a55e..004b224a 100644 --- a/platform/docs/platform-PROGRESS.md +++ b/platform/docs/platform-PROGRESS.md @@ -109,1699 +109,7 @@ (D39.107/109/112/114); разделы — в [archive/platform-PROGRESS-P0-P3.md](archive/platform-PROGRESS-P0-P3.md). Стек и рецепт стенда — `STACK_DECISIONS.md`; критерии приёмки — `ENGINEERING_STANDARDS.md`. -## Дофикс P6 (15.08): фикс-лист приёмки ФП-1…ФП-8 - -Промт — `PLATFORM_P6_DOFIX_SESSION_PROMPT.md`. Мандат владельца исполнен буквально: каждая находка -проверялась по коду ДО починки, а не принималась на веру. Опровергнутых нет — подтвердились все, но -две подтвердились НЕ так, как их описала приёмка, и об этом сказано в строках ниже. - -### Записка-план: ID → статус → улика - -| ID | Статус | Улика | -|---|---|---| -| ФП-1 exit 2 у `status` | ЗАКРЫТ | Подтверждён чтением движка: `cmd/tmctl/render.go` `renderStatusJSON` печатает отчёт и возвращает сентинел при `flagged>0`, `main.go` `exitCode` отображает его в 2. Платформа звала `status` на ТРЁХ путях — расчёт, ре-синк, счётчик перед спавном — и каждый читал ненулевой код как «движок не ответил». Чинено одним знанием (`ingest.CompletedWithFlags`): exit 2 при разбираемом отчёте = ответ, exit 2 с нечитаемым stdout = отказ, всякий иной ненулевой = отказ. Дев-путь выровнен. Пины `runner.TestAFlaggedBookStillAnswersAboutItsMoney`, `TestExitTwoWithNothingToReadIsStillARefusal`, `TestAReportPrintedByAFailedCallIsNotAnAnswer`, `ingest.TestTheDevelopmentStatusReadsAFlaggedBookToo` и сквозной `runs.TestTheMoneyOfAFlaggedBookIsSettledThroughTheRealExitContract`. PD-224 | -| ФП-1 «какие ещё команды выходят 2» | ЗАКРЫТ ЧТЕНИЕМ | Сентинел `CompletedWithFlags` рождается в движке в четырёх местах, принадлежащих трём командам (`render.go:70` результат прогона, `:553` `status --json`, `:639` `status` человеку, `:691` `redrive`). Платформа зовёт `translate` (код читается реконсилятором, `OutcomeFlagged`), `status` (починен) и `manifest` — а `renderManifest` возвращает nil для любого документа, который печатает, и другого пути к сентинелу у `manifestCmd` нет. `redrive` платформа не зовёт вовсе. Вывод записан доккомментом у `runner.Manifest`, вместе с тем, что случится, если движок это изменит: неклассифицированный код интейк уже читает как вину деплоя и файл пользователя не трогает | -| ФП-2 порядок апгрейда | ЗАКРЫТ | Подтверждён: шаг 2 звал голый `tmctl migrate` до установки нового бинаря — no-op старым. Порядок переписан: остановка демона (закрывает окно между списком и миграцией) → список → новый бинарь на версионированный путь → `migrate` ЕГО явным путём → переключение `TM_PLATFORM_ENGINE_BIN` → старт. «ДВА факта» приведены к трём. PD-225 | -| ФП-3 гейт миграции не запинен | ЗАКРЫТ | `pgstore.TestEachOfTheThreeBlockersAloneKeepsABookOutOfTheMigrationList`: по книге на каждый блокер, каждый по отдельности переводит вердикт в «нельзя», и список, который читает цикл деплой-заметки, содержит только свободную книгу. Живой прогон изолирован ИСКУССТВЕННО (его холд закрыт руками), потому что живой прогон всегда держит деньги — иначе один предикат прятался бы за другим. Три посадки, три падения. PD-226 | -| ФП-4 нечитаемый маркер хоронит потолок | ЗАКРЫТ | Подтверждён чтением: перечит причины стоял только на разбираемой ветке. Ветки слиты в одну (`case err == nil, errors.Is(err, runner.ErrBadMarker)`) — маркер на диске означает «юнит кончился» в обоих прочтениях, а решает о конце общий перечит. Пин `runs.TestACeilingSurvivesAMarkerThatCannotBeRead` (журнал с `ceiling{scope:day}` + маркер `{not json` → `paused`/`daily_ceiling`), посадка возвращает ровно то, что видела приёмка: `failed`/пусто. PD-227 | -| ФП-5 куки без Secure рядом с боевым OIDC | ЗАКРЫТ, форма выбрана иначе | Подтверждён: гейт ловил только связку с DEV_LOGIN. Легитимный сценарий, о котором спрашивал промт, СУЩЕСТВУЕТ — локальный провайдер по http на стенде, где `Secure`/`__Host-` не работают в принципе, — поэтому глухой отказ был бы неверен. Судит собственный адрес деплоя: `TM_PLATFORM_OIDC_REDIRECT_URL` и есть публичный URL платформы, значит `http://` там = стенд (запускается), всё прочее — отказ на буте. Пин `config.TestCookiesWithoutSecureAreRefusedNextToAProductionProvider` (4 отказных формы + 2 законных), посадка падает. PD-228 | -| ФП-6 незнакомый scope потолка | ВЗЯТ | Приор верен и подтверждён кодом: `ReadUsage` ключит аккаунтный halted-флаг ровно на `credit_exhausted`, а `CeilingPause` отдавал его всему, кроме `day`. Теперь называются только `book` и `day`, всё остальное = `ceiling_unknown`. Резюмируемость не меняется ни при одном из трёх, а ложного «кредит кончился» на аккаунте с деньгами больше нет. PD-229 | -| ФП-6 сид не тримит `--subject` | ВЗЯТ | Подтверждён чтением: демон тримит (`config.Load`), сид читал сырую переменную. Одна нормализация с обеих сторон; пин `TestASubjectThatIsOnlyPaddingIsNoSubject` — падённое значение обрывает прогулку ДО обращения к демону. PD-230 | -| ФП-6 комментарий у гарда `at >=` | ВЗЯТ, довод переписан | Приёмка права: `pipeline/events.go` штампует `at := e.now()` анонсирующего процесса, а непроецированная строка мёртвого прогона стирается `ForgetEvents` и анонсируется заново — со СВОИМ временем. Значит инверсии движок не порождает. Гард остаётся и обоснован тем, чем он есть на самом деле: потребитель at-least-once-потока, который ПРИСВАИВАЕТ, обязан не зависеть от порядка доставки. Довод приведён к факту и в коде, и в пинящем тесте. PD-231 | -| ФП-6 комментарий Resume про book-ceiling | ВЗЯТ, чурн — строкой | Приёмка права: движок останавливается на невместившемся РЕЗЕРВИРОВАНИИ, то есть до своего потолка, поэтому остаток положителен и ветка `exhausted` не срабатывает. Комментарий приведён к факту. Сам чурн (холд-спавн-пауза за клик, провайдерских денег не тратит) — строка PD-223, а не порог: размер резервирования — число движка, платформе не видное, и любой порог здесь был бы догадкой | -| ФП-6 exit 5 со стопом позже маркера | ВЗЯТ | Подтверждён: `stoppedOnRequest` отвергал по времени, и прогон, который пользователь отменил, закрывался `failed`. Exit 5 недостижим для прогона, кончившегося сам, а два штампа приходят с РАЗНЫХ часов (маркер пишет хост юнита, намерение — платформа). Теперь exit 5 при записанном намерении = `stopped` независимо от порядка. Гард «чистый финиш перебивает стоп» проверен отдельно и не сдвинулся: 0/2/3 отвечены раньше в том же switch — это пинится тремя строками того же теста. PD-233 | -| ФП-7 строки регистра | ЗАКРЫТ | PD-217…PD-222 — шесть находок приёмки, которые этим касанием не чинятся, плюс PD-223 своя. PD-198/PD-199 не тронуты. Ни одну из шести чинить сверх строк не стал: каждая либо требует решения вне зоны (PD-217, PD-218), либо лечится вместе с соседом (PD-219 — с читающей поверхностью P7), либо сегодня инертна (PD-220, PD-221) | -| ФП-8 базис тестов | ЗАКРЫТ | Числа пересчитаны командами (ниже), шапка P6 исправлена, откуда взялось «396» — не установлено. PD-234 | -| Аддендум владельца 15.08 (`day_usd`) | ИСПОЛНЕН | Раздел отдельным пунктом ниже: из ПРИМЕРА шаблона убран, ⚠-абзац переписан как опция оператора, платформенная обработка `daily_ceiling`/409 названа предохранителем. Кода не касается | -| Своё ревью (4 линзы) | 6 ПРИНЯТО И ПОЧИНЕНО, 5 СТРОКАМИ, 3 ОПРОВЕРЖЕНИЯ ПРИНЯТЫ | Таблица «находка → диспозиция» ниже. Самая тяжёлая — моя же: фикс ФП-4 сделал ветку решающей, а глоток ошибки перечитки в ней остался (PD-236) | - -### Аддендум оркестратора к ФП-6а (решение владельца 15.08) — отдельным пунктом - -`ceilings.day_usd: 5` убран из ПРИМЕРА платформенного шаблона книги (`deploy/README.md`), остался -только `book_usd`. Довод владельца, записанный там же: трату прогона уже жёстко ограничивает -купленный объём — платформа держит холд и передаёт движку `--ceiling-usd` флаш к нему, — поэтому -дневная ось поверх этого второй лимит на ту же трату, а стоит она дороже, чем даёт: остановленный ею -прогон резюмировать нельзя (409), и пользователю эта пауза необъяснима, потому что лимит не его и он -его не видит. - -Движок и его `day_usd` не тронуты. ⚠-абзац переписан так, чтобы дневной лимит читался как ОПЦИЯ -оператора, а не дефолт, и там же сказано, что платформенная обработка `daily_ceiling`/409 из P6 -остаётся ПРЕДОХРАНИТЕЛЕМ ровно на случай, когда оператор впишет лимит руками: она делает такую -остановку различимой и честной, а не дефолтом, который её вызывает. Код не менялся — это правка -рекомендации, и `day_usd` в фикстуре `render_test.go` остаётся намеренно: там он стоит за «ключ -оператора, который рендер обязан пронести нетронутым». - -### Адверсариальное ревью дофикса (author≠reviewer, механизмом) - -Четыре линзы субагентами по осям, названным промтом, плюс своя: шов против РЕАЛЬНОГО движка · деньги -(пересчёт расчёта флагнутого прогона из сырого леджера двумя путями) · конфиг-отказы (security, -**другая модель-семья** — Fable) · автомат состояний. Каждая работала на живом стенде и в КОПИИ репо -вне его; рабочее дерево ревьюеры не трогали. - -Денежная ось отчиталась цифрами: холд 3.000000 и его возврат гасят друг друга, списание 1.250000 -одно, и четыре независимых счёта сходятся — свёртка сырых строк `credit_ledger`, `грант − (committed -− baseline)`, `account_balances` и сумма `ReadAccount`: **8 750 000** micro-USD везде. - -| Находка | Линза | Диспозиция | -|---|---|---| -| Неудачная перечитка причины хоронит потолок как `failed`; тот же глоток заново вооружает перезапуск | автомат состояний | **ПРИНЯТА, починена, запинена** — PD-236. Мой же фикс ФП-4 сделал ветку решающей, а глоток остался; ошибка чтения теперь возвращается наверх | -| Exit 2 берётся по коду, без согласия отчёта (измерено: чужой отчёт РАССЧИТЫВАЕТ 2.5 USD) | деньги | **ПРИНЯТА, починена, запинена** — PD-237 | -| Документ, который не манифест, приезжает пустым манифестом → удаление загрузки | шов | **ПРИНЯТА, починена, запинена** — PD-238 (+ различение «глав нет» и «глав нет, а юниты есть») | -| `Manifest` держится на непроверяемом инварианте чужой зоны | шов | **ПРИНЯТА, починена, запинена** — PD-239: правило читается, а не предполагается | -| `Status` читает stdout без потолка (найдено двумя линзами независимо) | шов + деньги | **ПРИНЯТА, починена, запинена** — PD-240; написание пина вскрыло вторую половину: дренаж бесконечного писателя не возвращается никогда | -| Подтест-пассажир в пине гейта ФП-5 (зелен при удалённом гейте) | security | **ПРИНЯТА, строка убрана** — PD-235 | -| Стоп пользователя переименовывается в `paused/credit_exhausted`, если потолок приехал в тот же дрейн | автомат состояний | **ПРИНЯТА строкой** — PD-241: порядок «причина раньше намерения» ратифицирован в P6, молча переворачивать не стал; денежно-видимая половина — PD-203 | -| `ceiling_unknown` проходит гейт резюма, отказывающий `daily_ceiling` | автомат состояний | **ПРИНЯТА строкой** — PD-242 (не регресс: прежнее значение резюмировалось так же) | -| `DecodeStatus` принимает `null`/`{}` за отчёт | шов | **ПРИНЯТА строкой** — PD-243; денежные пути перекрыты (PD-40 и новая проверка согласия), остаётся ETA | -| `settle` молча возвращает nil при `Engine == nil` | деньги | **ПРИНЯТА строкой** — PD-244 (в проде недостижимо) | -| У дев-супервизора нет потребителя вне тестов | шов | **ПРИНЯТА строкой** — PD-245; комментарий приведён к факту, судьбу пути решать оркестратору | -| Гейт ФП-5 обойти не удалось: 24 формы callback, `*_FILE`, схемные трюки | security | **ОПРОВЕРЖЕНИЕ ПРИНЯТО** — гейт устоял; ложных отказов законных стендов тоже не нашлось (прокси с TLS отказывается ПРАВИЛЬНО: браузер ходит по https, `Secure` там работает) | -| (B) exit 5 + намерение: полный перебор 216 клеток `outcome` против до-фиксной версии | автомат состояний | **ОПРОВЕРЖЕНИЕ ПРИНЯТО** — расходятся ровно две клетки, обе exit 5: одна начинает записывать код выхода, вторая и есть починка | -| PD-212 (Go выходит 2 при панике) расширяется ли новой терпимостью | шов | **ОПРОВЕРГНУТО ревьюером** — паника не оставляет целого документа, а неполный отвергается; строка остаётся про путь прогона | - -### Живые пробы этого круга - -- **Гейт ФП-5 — живым демоном, не только тестом.** С боевым callback: - `TM_PLATFORM_INSECURE_COOKIES=1` + полный OIDC + `TM_PLATFORM_OIDC_REDIRECT_URL=https://app.example.org/auth/callback` - → `ERROR fatal`, **exit 1**, сервис не поднялся. С локальным `http://127.0.0.1:8099/auth/callback` - тот же демон проходит конфигурацию и стартует. То есть отказ ловит именно ту форму, которую нашла - приёмка, и не ловит законный стенд. -- **Exit-контракт — настоящими процессами.** Пины `runner` гоняют не мок, а `/bin/sh`-движок, - печатающий отчёт и выходящий 2/1/11/13; сквозной денежный тест `runs` подменяет движок - скриптом, чьи ответ и код лежат в двух файлах, — так что путь от кода выхода до строки леджера - проходится целиком, а не по обе стороны стаба. -- **Батарея дофикса** — `make check` при живом PG (`~/.local/pgsql`, порт 55433) и обоих гейтах - окружения: EXIT=0, 17 пакетов, линтер 0 issues, скипов 0, `make vuln` — без уязвимостей. - -### Числа: командой, а не памятью (ФП-8) - -```sh -git grep -h '^func Test' HEAD -- 'platform/**/*_test.go' | wc -l # 359 — состояние лендинга P5 -grep -rh '^func Test' --include=*_test.go platform | wc -l # 416 — дерево после дофикса -``` - -То есть P6 + дофикс = **+57** тестовых функций; на конце P6 в дереве был 401 (счёт приёмки — сходится: -дофикс добавил 15, из них 6 — по находкам собственного ревью). Плюс 2 фаззера (`^func Fuzz`), в этот -счёт не входящие и исполняемые сид-корпусом. - -Батарея (`go test ./... -race -count=1 -v` при обоих гейтах окружения): - -```sh -grep -cE '^--- (PASS|FAIL|SKIP)' <лог> # 418 верхнеуровневых прогонов (416 тестов + 2 фаззера) -grep -cE '^ --- ' <лог> # 139 подтестов -grep -c -- '--- SKIP' <лог> # 0 -``` - -`make check` EXIT=0, 17 пакетов, линтер 0 issues, `make vuln` — без уязвимостей. Стенд: -PG 18.4 `~/.local/pgsql` порт 55433, свой `tmctl` из КОПИИ `backend/` вне репозитория. - -### Посадки (мутация → что упало) - -| Мутация | Упало | -|---|---| -| `runner.Status`: вернуть ошибку на ЛЮБОЙ ненулевой код | `TestAFlaggedBookStillAnswersAboutItsMoney`; сквозной денежный — сообщением «холд флагнутого прогона всё ещё открыт: 3.000000», то есть ровно симптомом приёмки | -| `Supervisor.Status`: то же на дев-пути | `TestTheDevelopmentStatusReadsAFlaggedBookToo` | -| `reconcile`: вернуть перечит причины на одну ветку | `TestACeilingSurvivesAMarkerThatCannotBeRead` — `failed`/пусто вместо `paused`/`daily_ceiling` | -| `outcome`: убрать `case ingest.OutcomeStopped` | `TestAGracefulSignalIsAStopOnlyWhenThisPlatformAskedForOne` | -| `CeilingPause`: вернуть `credit_exhausted` по умолчанию | `TestTheCeilingScopeDecidesWhichPauseTheRunGets` | -| `config.Load`: убрать гейт INSECURE_COOKIES+OIDC | `TestCookiesWithoutSecureAreRefusedNextToAProductionProvider` | -| `seed`: убрать `TrimSpace` | `TestASubjectThatIsOnlyPaddingIsNoSubject` | -| `BooksForMigration`: убрать предикат живого прогона | `TestEachOfTheThreeBlockersAlone…` — `bk-live` объявлен мигрируемым | -| …убрать предикат резюмируемого | то же на `bk-paused` | -| …убрать предикат незакрытого холда | то же на `bk-hold` | -| `freshPausedReason`: вернуть падение на снапшот | `TestAnEndingIsNeverDecidedFromAReadThatFailed` — ошибка приходит уже от записи, а не от чтения | -| `Status`: брать exit 2 по коду и разбору, без согласия отчёта | `TestExitTwoIsTakenOnlyFromAReportThatAgreesWithIt` (4 подтеста) | -| `DecodeManifest`: убрать проверку `manifest_version` | `TestADocumentThatIsNotAManifestIsNotAnEmptyManifest` | -| `Parse`: читать «глав нет» в одиночку | `TestAManifestThatContradictsItselfNeverCostsTheUpload` | -| `Manifest`: вернуть «любой ненулевой = отказ» | `TestAManifestThatCompletesWithFlagsIsStillAManifest` | -| `drain`: дренить и за капом вместо убийства процесса | `TestAnEndlessStatusIsRefusedRatherThanRead` — виснет и падает по таймауту | - -Пятнадцать из пятнадцати. Непинябельных в этом круге нет. - -### Чего я НЕ сделал — явным списком - -- **Контракт `openapi.yaml` и его зеркало** — не тронуты (PD-199 ждёт слова владельца, правит S4). - `ceiling_unknown` и `daily_ceiling` на провод по-прежнему не проецируются. -- **`tmctl migrate` живьём не гонялся** — движковой команды всё ещё нет в HEAD. Порядок в - `deploy/README.md` исправлен и стал исполнимым, но проверен ЧАСТИЧНО: список `--migratable` на - стенде, сам `migrate` — нет. Самолечение по `schema_mismatch` вслепую не строил (PD-201). -- **Читающая поверхность (P7), эскроу (строка 136), брокер** — нет. -- **PD-217…PD-222 и PD-241…PD-245 не чинены** — заведены строками, каждая с причиной, почему не - этим касанием. -- **Порядок «причина паузы раньше намерения стопа» не переворачивал** (PD-241): это ратифицированное - решение P6, и его смена двигает клетки за пределами фикс-листа — вопрос оркестратору. -- **Промт дофикса не трогал** (`PLATFORM_P6_DOFIX_SESSION_PROMPT.md` — артефакт оркестратора). - -### Вопрос на ратификацию, добавленный этим кругом - -К трём вопросам P6 добавляется один: **порядок «потолок раньше стопа» в `outcome`** (PD-241). Сегодня -прогон, который пользователь остановил, показывается как `paused/credit_exhausted`, если в тот же -дрейн приехал потолок, — и этим зажигает аккаунтный halted-флаг у аккаунта с деньгами. Обе половины -— и статус, и флаг (PD-203) — про одно: что именно платформа обязана показать, когда две правды -пришли вместе. - -## P6 (14.08): потребительская половина шва · интейк формы Б · дев-стенд - -Промт — `PLATFORM_P6_SESSION_PROMPT.md`, плюс **аддендум оркестратора** по ресёрчу деплоя (принят -эхом, отдельный пункт ниже). Всё, что здесь заявлено, проверено ИСПОЛНЕНИЕМ; там, где проверка -ждёт, это сказано словом «ждёт», а не пропущено. - -### Записка-план: ID → статус → улика - -| ID | Статус | Улика | -|---|---|---| -| П-15 (а) exit-коды | ЗАКРЫТО | `ingest/exit.go` — ОДИН словарь shell-контракта на зону (реконсилятор, интейк, дев-супервизор). 4 = `paused` двумя независимыми каналами · 5 без намерения = прерывание и перезапуск · 10–19 = полоса. PD-113 и PD-152 закрыты, PD-196 закрыт потребительской половиной. Пины: `TestWhatTheUnitDidBecomesTheProductStatus` (13 строк таблицы) · `TestOutcomeOfCoversTheWholeExitContract` · `TestAGracefulSignalIsAStopOnlyWhenThisPlatformAskedForOne` | -| П-15 (б) фолд присваиванием | ЗАКРЫТО | таблица `unit_resolutions` (миграция 00015) по тройке (chapter, unit, wave); счётчики глав — `count(*)`, не `+1`. Пин `pgstore.TestAUnitAnnouncedTwiceIsCountedOnce` (новый seq с тем же юнитом — то, что курсор поглотить не может) | -| П-15 (в) 1.1 · scope · outcome | ЗАКРЫТО | `StreamVersion = "1.1"`, `Ceiling.Scope`, `Outcome` как ОДИН словарь для терминальной строки и кода выхода. Дневной потолок различим: внутренняя причина `daily_ceiling`, резюм отвечает 409 с диагностикой. Пины `TestTheCeilingScopeDecidesWhichPauseTheRunGets` · `TestAResumeOfARunTheEnginesDailyCeilingStoppedIsRefused` | -| П-15 (г) дев-супервизор | ЗАКРЫТО | `outcomeOf` удалён, дев-путь читает ту же `ingest.OutcomeOf`; два словаря exit-кодов в зоне больше не существуют | -| П-15 watch: foreign-hello | ВОСПРОИЗВЕДЁН и ЗАКРЫТ | дефект оказался тяжелее наблюдения: чужой поток усыновлялся, его `ceiling` материализовался на нашу попытку (ложная `paused`), а собственный hello потом валил проекцию в карантин НАВСЕГДА. Платформа теперь НАЗЫВАЕТ поток (`TM_TRACE_ID`), и называет его при создании строки попытки, а не при спавне. PD-200, PD-210 | -| П-14 интейк формы Б | ЗАКРЫТО | рендер в ОДНОМ месте (`books.provision`), из `TM_PLATFORM_BOOK_TEMPLATE`, работой с YAML-УЗЛОМ (комментарии и незнакомые ключи оператора живы, значения — строки), `O_EXCL`. Битый шаблон = класс, который ЖДЁТ. Проверено настоящим `tmctl manifest`: 3 главы, exit 0 | -| П-16 дев-стенд | ЗАКРЫТО | `tmplatformctl seed` ходит по HTTP теми же дверями, что пользователь; дев-вход с четырьмя гардами непроходимости в проде + пятым после ревью; рецепт страницей в `deploy/README.md`. Прогнано на стенде целиком | -| Деплой-порядок v15 | ЗАПИСАН, проверка ЖДЁТ | правило «эмиттер-бинарь не на стенд до `tmctl migrate`» + порядок в `deploy/README.md`, шаг сделан ИСПОЛНИМЫМ (`tmplatformctl books --migratable`). Сам `tmctl migrate` не гонялся — движковая половина не заленджена; помечено ждущим, НЕ сделанным (PD-201) | -| Попутные PD | ЗАКРЫТЫ 141 · 163 · 164 · 165; 154 ПЕРЕ-ДИСПОЗИЦИРОВАН | PD-165 закрыт ДВУМЯ разными ответами: относительный `CTL_BIN` отвергается на буте (воспроизведено: systemd 259 не стартует юнит), а `$` ЗАМЕРЕН и оказался безопасен — экранирование было бы ошибкой | -| Гигиена доков | ЗАКРЫТО | README · STACK_DECISIONS · BACKLOG · регистр (секции, порядок, веса, шапка) · PLATFORM_DIRECTION — с ПРЕДЛОЖЕНИЕМ пере-подписи, а не тихой правкой | - -### Аддендум оркестратора (ресёрч деплоя 14.08) — отдельным пунктом, как просили - -1. **«Прогоны остановлены» = нет живых юнитов И нет открытых резюмируемых попыток.** Записано в - деплой-док и сделано ИСПОЛНИМЫМ: `tmplatformctl books` печатает колонку MIGRATE и причину отказа, - `--migratable` — только безопасные каталоги, по одному на строку, чтобы шаг деплоя был циклом, а - не абзацем. Блокируют ТРИ разных факта, и они названы порознь, потому что снимаются по-разному: - живой прогон · резюмируемый прогон (его попытка запинена к СТАРОМУ бинарю, и миграция заставила - бы резюм перейти на новый — риск пере-оплаты уже купленных вызовов) · незакрытый холд (расчёт - читает `committed_usd` тем же запиненным бинарём, после миграции цифру уже не прочитать). -2. **Расчёт денег зовёт ЗАПИНЕННЫЙ бинарь.** Проверено чтением и исполнением: код был уже верен - (`s.engineBinary(l)` на всех трёх путях — расчёт, ре-синк, чтение счётчика перед спавном), но - ПИНА не было, а фейк движка выбрасывал аргумент. Фейк теперь записывает, чем его звали; пин - `runs.TestTheMoneyOfAFinishedRunIsReadWithTheBuildItRanWith`, посадка «звать `Cfg.EngineBinary`» - падает с именем нового бинаря в сообщении. -3. **Самолечение по машиноразличимой ошибке — НЕ построено, и вот что построено вместо.** Пока я - работал, параллельная бэкенд-сессия завела в дереве движка exit-код **13** (`schema_mismatch`) и - класс `RefusalSchemaMismatch` — незакоммиченными. Вслепую я не строил и автоматику «поймала → - `migrate` → повтор» не делал: она означает, что ПЛАТФОРМА исполняет мутирующую команду движка, и - это решение под ратификацию вместе с движковой половиной. Что сделано: код 13 заведён в словаре и - отображён на класс, который ЖДЁТ человека и не тратит бюджет попыток, — то есть книга на хосте - посреди апгрейда не отклоняется, а дожидается `migrate`. ⚠ Номер прочитан из НЕЗАКОММИЧЕННОГО - дерева движка; если он приедет другим, менять одну строку `ingest.ExitSchemaMismatch`, и ошибка в - обе стороны недеструктивна (неизвестный код полосы и так падает в безопасный класс). PD-201. - -### Живые пробы (команды и что они показали) - -Стенд: PG 18.4 `~/.local/pgsql` порт 55433 · демон `tmplatformd` на 127.0.0.1:8099 · свой `tmctl`, -собранный из КОПИИ `backend/` вне репозитория · фейк движка с ПРАВИЛОМ потолка (умеет отказать — -иначе он ничего не проверяет, урок D39.123). - -| Что проверялось | Чем | Результат | -|---|---|---| -| Рендер даёт конфиг, который движок ГРУЗИТ | `tmctl manifest --config <рендер> --json` | exit 0, `chapters_total: 3`, `book_id` = id платформы; комментарий оператора и его `ceilings` в файле целы, плейсхолдер `book_id: WILL_BE_REPLACED` заменён, не продублирован | -| Полоса отказов на НАСТОЯЩЕМ движке | прогон без ключей провайдера на стенде | маркер `exit-code/exited/10` → прогон `failed`, холд вернулся ЦЕЛИКОМ ($30 → $30), файл цел, `ceiling_arg = 90000` (3 главы × $0.03) | -| Потолок книги (PD-113) | фейк с правилом, `--usd 0.10` | журнал: `ceiling{scope:book}` + `finished{ceiling}`, exit 4 → карточка `paused`/`credit_exhausted`, прогресс 4/6 ИЗ ПОТОКА, расчёт $0.08 из $5.10 | -| Дневной потолок и резюм | тот же фейк, `SCENARIO=daily-ceiling` | хранится `paused/daily_ceiling`, на проводе `paused_reason: null`, повторный резюм — **409** и НИ ОДНОГО спавна | -| exit 5 без намерения | `systemctl --user stop` по живому юниту | маркер `exited/5` → попытка 1 `interrupted`, попытка 2 открыта, прогон `translating` | -| exit 5 с намерением | `POST /v0/runs/{id}/stop` | маркер `exited/5` → `stopped`, второй попытки нет | -| Дев-вход + сид целиком | `tmplatformctl seed --url …` | аккаунт `dev/dev@stand`, грант, загрузка через ЖИВОЙ `POST /v0/books`, интейк `not_started, 3 chapters` | -| Гейт дев-входа | демон с `TM_PLATFORM_DEV_LOGIN` И боевым OIDC | `fatal: … cannot be enabled on a deployment that has an OIDC provider` — не стартует | -| `$` в `ExecStopPost` (PD-165) | `systemd-run --user --property=… --setenv=dir=EXPANDED` | путь доехал ЛИТЕРАЛЬНО (`…/dollar$dir/`), то есть экранирование в `$$` было бы ошибкой | -| Относительный `CTL_BIN` (PD-165) | `systemd-run` с `./tmplatformctl` | «neither a valid executable name nor an absolute path» — юнит не стартует вовсе | - -⚠ **Найдено ЖИВОЙ пробой, а не чтением:** внутренняя причина `daily_ceiling` уезжала на провод, где -контракт знает одно значение и клиент, сгенерированный по спеке, её бы отверг. Починено -(`contractPausedReason`), строка PD-199 — вопрос владельцу контракта. Второе: сид упёрся в CSRF-слой -(`X-TM-Client` обязателен на unsafe-запросе с кукой) — ровно в ту дверь, которую обязан правильно -открывать фронт; это и есть причина, по которой сид ходит по HTTP, а не пишет строки сам. - -### Посадки - -**Первый круг — 20 из 20 пойманы**, но не с первого захода: три посадки ПЕРЕЖИЛИ, и каждая означала -слепой пин, а не безобидность мутации. - -| Пережившая посадка | Почему пин был слеп | Что сделано | -|---|---|---| -| `mine := offset > 0` в тейлере | тест ставил чужой поток ПЕРЕД своим hello, а чужой hello сам выключает `mine` — мутация не наблюдалась | пин переписан на реальный сценарий: хинт попадает ВНУТРЬ чужого потока (после его hello), и мутация даёт `sequence gap` → карантин здорового прогона | -| `O_EXCL → O_TRUNC` в рендере | при существующем файле открытие не достигается — гарду это не свойство | тест переписан: первая ходка падает на ДВИЖКЕ (книга остаётся в интейке), шаблон удаляется, вторая ходка обязана его не искать. Ловится снятие short-circuit. ⚠ Сам `O_EXCL` НЕ пинится и это названо в тесте: две одновременные ходки пишут одинаковые байты, наблюдать нечего | -| `PauseRun` всегда рапортует успех | я запустил посадку по несуществующему имени теста | перезапущено против `TestAPauseFromAnOldSnapshotDoesNotCloseARunOverALiveAttempt` — падает | - -**Второй круг (находки панели) — 6 из 6**, седьмая названа непиняемой: фолбэк причины в ветке -исчерпания бюджета недостижим по построению (в `restart` попадают только ветки, где причины нет), -и это записано в самом коде, а не заявлено закрытым. - -### Адверсариальное ревью (author≠reviewer, механизмом) - -Четыре независимых ревьюера по диффу с установкой ОПРОВЕРГАТЬ, из них **опровергатель другой -модели-семьи (Fable) на security-оси** — норма D39.120. Линзы: деньги и гонки · безопасность -дев-входа · соответствие потребителя реальному движку и судьба файла пользователя · повторное -использование, общность, честность комментариев. - -| Находка | Линза | Диспозиция | -|---|---|---| -| Имя провайдера `dev` не зарезервировано: `TM_PLATFORM_OIDC_PROVIDER=dev` кладёт чужие `sub` в namespace дев-аккаунта | безопасность (Fable) | **ПРИНЯТА, закрыта.** Четвёртое из четырёх заявленных свойств было ЛОЖНЫМ без этого. `Load` отвергает имя, пин + посадка. PD-206 | -| `TM_PLATFORM_DEV_LOGIN=" "` монтирует вход с личностью «пробел» | безопасность (Fable) | **ПРИНЯТА, закрыта** тримом + пином | -| Дев-вход не получает второй CSRF-слой (`X-TM-Client` нужен только при уже имеющейся куке) | безопасность (Fable) | **ПРИНЯТА как остаток**, названа в коде и строкой PD-205: на стенде последствие ничтожно, но «POST, значит безопасно» — слабее, чем звучит. Тот же класс у боевого `/auth/login` | -| Ветка дев-входа стоит ПЕРВОЙ в switch — один регресс конфига даст ей перекрыть боевой OIDC | безопасность (Fable) | **ПРИНЯТА**, добавлен избыточный гард в `main.go`; он по построению недостижим, поэтому теста не имеет — сказано вслух | -| Грант дев-входа безусловен (боевой путь его придерживает у неподтверждённой личности) | безопасность (Fable) | **ПРИНЯТА как намеренная**: аккаунт с нулём кредита делает каждый прогон отказом. Не кран: строка леджера пишется только при INSERT личности — проверено в коде | -| `daily_ceiling` стирался двумя путями → ложный аккаунтный флаг + обход гарда резюма | деньги и гонки | **ПРИНЯТА, закрыта.** Третье значение `ceiling_unknown` + прогон с названной причиной не перезапускается. PD-209 | -| Окно усыновления между ДОПУСКОМ и спавном; `coalesce` отказывался чинить усыновлённое имя | деньги и гонки | **ПРИНЯТА, закрыта.** Имя даётся при создании строки попытки. PD-210 | -| Побочно: `Begin` стал недостижим, и с ним тихо выключился `chunker_version` | деньги и гонки | **ПРИНЯТА, закрыта** переносом эффекта в `effect`, пин + посадка. PD-210 | -| Апсерт юнита без гарда по времени: переанонс старой резолюции затирает новую | деньги и гонки | **ПРИНЯТА, закрыта** одним `where`; пин + посадка. PD-211 | -| Паника движка неотличима от «завершено с флагами» (Go-рантайм выходит РОВНО 2) | шов | **ПРИНЯТА, НЕ чинится здесь**: лечение — `recover` или другой номер, это движковая сторона. PD-212, запрос уходит строкой единого бэклога | -| Форма манифеста не версионируется → будущий дрейф даст `ChaptersTotal=0` → удаление файла | шов | **ПРИНЯТА как латентная**, строка PD-213. Сегодня формы совпадают поле-в-поле; лечить сверкой `manifest_version`, где незнакомая версия даёт НЕ-деструктивный класс | -| Чужая hello валит `Tail` до проверки «чья строка» → карантин здорового прогона навсегда | шов | **ПРИНЯТА**, строка PD-214: порядок проверок в `apply` лечится отдельно, класс не денежный | -| Код 13 (`schema_mismatch`) отсутствовал в словаре платформы | шов | **ПРИНЯТА, закрыта** (см. аддендум п.3) | -| Сид рапортовал «кредит начислен» о начислении, которого не было | повторное использование | **ПРИНЯТА, закрыта**: зовёт существующий хелпер `write`. Проверено на стенде — второй прогон печатает `no-op`. PD-208 | -| Петля перезапусков без счётчика и бэк-оффа; exit 12 терминален, хотя лок проходит сам | деньги и гонки | **ПРИНЯТЫ как остатки**, PD-215 и PD-216: лечатся вместе (перезапуск на 12 без бэк-оффа — это и есть PD-215) | -| `gopkg.in/yaml.v3` — последний релиз по этому пути модуля | повторное использование | **ПРИНЯТА как заметка**: переезд решается ОБЕИМИ зонами сразу, записано в `STACK_DECISIONS` §28 | -| Воскрешение прогона устаревшим снапшотом (нашёл сам при аудите диффа) | самоаудит | **ЗАКРЫТА** `RestartInput.OnlyIfLive`; пин + посадка «stopped → translating». PD-207 | - -### Чего я НЕ сделал — явным списком - -- **Читающая поверхность (SSE, главы/юниты/замечания, банк)** — пак P7, заведена строкой П-17. - Хранилище под первую половину уже есть: `unit_resolutions` несёт диспозицию каждого юнита. -- **Эскроу денег шва** (строка 136) и вместе с ним PD-154 — денежный промт, строка П-18. -- **Самолечение по `schema_mismatch`** — аддендум п.3 выше: вслепую не строю. -- **Спека `openapi.yaml`** — не трогал: её правит S4-сессия фронта. Отсюда PD-199. -- **`ReadUsage`** — не трогал, хотя ревью подтвердило: аккаунтный halted-флаг зажигается по паузе - ОДНОГО прогона (PD-203). Это продуктовая семантика уже построенной ручки; правка — на ратификацию, - а не тихо в паке про шов. -- **Порядок проверок в `apply` тейлера** (PD-214), **версионирование манифеста** (PD-213), - **бэк-офф перезапусков** (PD-215/216) — приняты строками, не построены: класс PD-83. -- **`O_EXCL` рендера** — не пинится и не заявлен пинённым. -- **Шаг `tmctl migrate` на стенде** — ждёт движковую половину. - -### Вопросы на ратификацию - -1. **`daily_ceiling` на проводе.** Контракт перечисляет одно значение `PausedReason`, платформа - различает два (плюс `ceiling_unknown`). Сейчас незнакомая контракту причина едет как `null` — - ровно то, что спека сама велит клиенту рендерить. Назвать причину в спеке (тогда правка — одна - функция `contractPausedReason`) или подтвердить `null`? PD-199. -2. **`PLATFORM_DIRECTION` §3.** Две строки не исполнены: `oapi-codegen` («ВЗЯТЬ — доказано») и - `sqlc` («до первого хендлера» — срок пройден, PD-44 открыт с P1). Зона не переписывает - ратифицированное направление сама: в §3 добавлен баннер статуса с доводами за и против - пере-подписи. Решение за оркестратором. -3. **Второй гейт батареи.** Живая проба рендера против НАСТОЯЩЕГО движка сделана постоянным тестом - (иначе клейм «движок это грузит» протухнет молча), и она гейтится - `TM_PLATFORM_TEST_ENGINE_BIN` + `TM_PLATFORM_TEST_BOOK_TEMPLATE` — той же формой, что у БД-тестов. - На голом клоне это ОДИН честный скип. Если приёмка считает скипы при одном лишь DSN, число будет - не ноль — и это не регресс, а новая ось проверки; форму гейта готов поменять по слову. - -## Третий раунд (14.08): ре-чек оркестратора FP5-9…FP5-11 и хвосты - -Все три находки подтвердились на коде до правки; возражений нет. Две из трёх — мои вчерашние фиксы, -которые ЗАЯВЛЕНЫ шире, чем сделаны, и это ровно тот класс, за который приёмка уже била. - -### FP5-9 [HIGH] Гейт тулчейна не отказывал 1.26.5 — и три дока это утверждали - -Проверено исполнением: `make version-check GO_VERSION=go1.26.5` на прежнем правиле проходил. -Регекс `go1\.26\.([5-9]|[0-9]{2,})` принимал ровно ту версию, ради отказа от которой floor и -поднимали, а `GO_MIN_VERSION` жил только в тексте сообщения. Тот же регекс ставил 1.26.10 НИЖЕ -1.26.9. Клейм «хост на 1.26.5 получит отказ» был ложен в трёх местах (журнал ×2, `STACK_DECISIONS`, -коммент Makefile) — гейт «поднят» был только на словах. - -Сделано, двумя независимыми механизмами: - -1. `make version-check` СРАВНИВАЕТ версии (`sort -V`), а не матчит; префиксы `rc`/`devel` отвергаются - отдельной веткой — пререлиз не несёт фиксов, которые обещает его номер. Цель отделена от - `tools-check` и берёт версию из переменной `GO_VERSION`, чтобы её можно было судить версиями, - которых на хосте нет. -2. `go.mod` получил `toolchain go1.26.6`. **Решение и обоснование** (оркестратор просил решить): floor - ЯЗЫКА (`go 1.26.4`) остаётся общим с движком — один стенд собирает оба модуля; директива - `toolchain` поднимает только тулчейн, то есть stdlib, которым линкуется сетевой модуль. Её читает - ВСЯКАЯ сборка, включая ту, что не зовёт make: при `GOTOOLCHAIN=auto` (дефолт) хост скачает 1.26.6 - вместо того, чтобы собрать уязвимый бинарь, при `=local` — остановится с ошибкой, называющей - версию. Обе ветки — тот ответ, который нужен; ни одна не зависит от того, прочитал ли кто-то - Makefile. Цена названа: офлайн-хост с `=local` и только 1.26.5 теперь не соберёт зону — это и есть - гейт. - -Пин — на СРАВНЕНИЕ, а не на прогон: новый пакет `internal/gates` гоняет `version-check` таблицей -версий, которых на этой машине нет (1.26.4/1.26.5/1.25.9/1.9.9/rc/devel — отказ; -1.26.6/1.26.7/1.26.10/1.27.0/2.0.0 — приём), и вторым тестом требует, чтобы `go.mod` и -`GO_MIN_VERSION` называли одну версию. Две посадки падают. Три ложных клейма переписаны на описание -механизма. Строка PD-197. - -### FP5-10 [HIGH] Гард корня не переживал unmount и маскировался бутовым `MkdirAll` - -Тоже подтверждено: `Stat(BooksDir)` отвечает «есть» про пустой mountpoint, оставшийся после -размонтирования тома, смонтированного РОВНО в `BooksDir`, — а бут безусловным `MkdirAll` пересоздаёт -корень после рестарта. То есть мой вчерашний фикс закрывал только форму «каталог удалён вместе с -родителем» и не закрывал ту, ради которой писался. - -Сделано по кандидату оркестратора — **сентинел провижининга**: файл `.tmplatform-books` в корне, -который пишет ПЕРВАЯ загрузка (`markStorage`, `O_EXCL`, поэтому две одновременные загрузки обе правы) -и не пишет больше никто — в частности, не пишет бут. Решение «хранилище на месте» принимается по -СЕНТИНЕЛУ (`storageIsThere`), а не по существованию каталога: ни unmount, ни `MkdirAll` его не -подделывают. Хост, который никогда не принимал загрузок, отвечает «нет» — безопасная сторона: у него -нет книг, которые можно отклонить. - -Пин — `books.TestAnUnmountedVolumeLooksLikeAnEmptyRootAndStillIsNotTheBooksFault`: удаляем всё под -корнем и пересоздаём сам корень (ровно то, что оставляют unmount и бут), десять циклов ожидания — -книга жива, бюджет цел, движок не спрошен, сентинел не воскрес. **Первая редакция этого пина посадку -ПЕРЕЖИЛА** (мутация «не писать сентинел вовсе»): без сентинела всё ждёт, и тест этого не отличал — -добавлено утверждение, что принятая загрузка сентинел ПИШЕТ, иначе терминальность крэш-окна (FP5-3) -теряется. Обе посадки падают. PD-192 дополнена. - -### FP5-11 Правило ревизии применено не ко всем писателям - -Пять писателей жизненного цикла ПРОГОНА остались на `revision + 1` против правила, которое дофикс -ввёл для трёх интейковых: старт, реоткрытие, пауза (`runs.go`) и два закрытия (`sink.go`). Значит -staleness библиотеки, которую вчера закрыли на статусах интейка, оставалась на статусах прогона: -книга не-максимума аккаунта уходила `translating` — библиотека не двигалась. - -Сделано: все пять берут `nextRevisionOfThisBooksLibrary`. Пин — -`pgstore.TestARunTransitionOnAnOlderBookStillMovesTheLibrary` (вторая книга держит максимум 41, -прогон стартует на первой; проверяются обе половины жизненного цикла — старт и пауза). Посадка -падает. - -⚠ **Мотивированное исключение, названное вслух:** два писателя `books.revision` НЕ переведены — -прогресс-путь синка (`bumpBook` на `unitDone`/`progress`). Причина не вкусовая: `unitDone` вычисляет -`chapters.revision` из `books.revision + 1` ДО инкремента, и прыжок книги через пол развязал бы -книжную и главную шкалы, а это отдельная scope-семантика контракта. Она и не нужна: прогресс -существует только у книги, у которой ИДЁТ прогон, а старт прогона теперь сам поднимает книгу на -уровень библиотеки — дальше `+1` монотонен и виден. Если правило когда-нибудь понадобится и там, -оно приедет вместе с проверкой главной шкалы, а не молча. - -### Хвосты ре-чека - -- **(а)** Коммент `PauseRun` обещал проверку «в том же стейтменте», а стоит отдельный - `select … for update` в той же транзакции. Текст приведён к коду (транзакция и её замки). -- **(б)** Комменты `parse.go` («terminal on the first answer») описывали до-дофиксное поведение на - пути, который УДАЛЯЕТ файл пользователя, — самое опасное место для устаревшего текста. Переписаны: - через бюджет попыток идёт КАЖДЫЙ ответ движка, удаляет только последний. -- **(в)** Остаток FP5-5 закрыт кодом, а не строкой: проход, которому осталось меньше, чем нужно - разбору, книгу НЕ НАЧИНАЕТ (клейм берётся внутри `Parse`, поэтому отложенная книга не тратит - ничего) и говорит об этом одной строкой лога вместо ошибки на книгу. Пин — - `books.TestAPassTooShortForAParseStartsNoneAtAll`, посадка падает. -- **(г)** Мета-пин systemd-гейта перечислителен — принято как названный остаток: он ловит возврат к - сверке сообщений по словам systemd и требует, чтобы гейт вообще спрашивал менеджер, но исчерпать - все будущие формулировки перечислением нельзя. - -### Посадки третьего раунда - -| Посадка (что откатывается) | Пин, который обязан упасть | Итог | -|---|---|---| -| гейт снова матчит регексом (принимает 1.26.5) | `gates.TestTheToolchainGateComparesVersionsRatherThanMatchingThem` | поймана | -| `go.mod` снова без `toolchain` | `gates.TestGoModPinsTheSameToolchainTheBatteryDemands` | поймана | -| гард снова смотрит на существование корня | `books.TestAnUnmountedVolumeLooksLikeAnEmptyRootAndStillIsNotTheBooksFault` | поймана | -| сентинел не пишется загрузкой | тот же | ПЕРЕЖИЛА → пин усилен → поймана | -| старт прогона снова `revision + 1` | `pgstore.TestARunTransitionOnAnOlderBookStillMovesTheLibrary` | поймана | -| проход снова начинает книгу, на которую нет времени | `books.TestAPassTooShortForAParseStartsNoneAtAll` | поймана | - -### Батарея третьего раунда - -- `make check` с живым PG 18.4 (стенд `~/.local/pgsql`, порт 55433) под `-race`: **EXIT=0, 16 - пакетов, скипов 0**, линтер **0 issues**, `make vuln` — чисто. -- Тесты `^func Test` исполнением: **354 → 359** (+5, удалённых 0). -- Посадки: **6 из 6** (одна со второго захода, см. таблицу). - -### Записка-план третьего раунда: ID → статус → улика - -| ID | Статус | Улика | -|---|---|---| -| FP5-9 | ЗАКРЫТО | `version-check` сравнивает (`sort -V`) + `toolchain go1.26.6` в `go.mod`; `internal/gates` ×2 пина, две посадки падают; три клейма переписаны. PD-197 | -| FP5-10 | ЗАКРЫТО | сентинел `.tmplatform-books`, пишет только загрузка; `books.TestAnUnmountedVolumeLooksLikeAnEmptyRootAndStillIsNotTheBooksFault`; две посадки падают. PD-192 дополнена | -| FP5-11 | ЗАКРЫТО с названным исключением | пять писателей прогона на `nextRevisionOfThisBooksLibrary`; `pgstore.TestARunTransitionOnAnOlderBookStillMovesTheLibrary`; посадка падает. Прогресс-путь синка НЕ тронут — обоснование выше. PD-122 дополнена | -| хвост (а) | ЗАКРЫТО | текст `PauseRun` приведён к коду | -| хвост (б) | ЗАКРЫТО | текст `parse.go` про терминальность приведён к коду. PD-198 | -| хвост (в) | ЗАКРЫТО кодом | проход не начинает книгу без полного бюджета; `books.TestAPassTooShortForAParseStartsNoneAtAll`; посадка падает. PD-189 дополнена | -| хвост (г) | ПРИНЯТО как остаток | перечислительность мета-пина названа, не «закрыта» | - - -## Дофикс-2 (13.08): кросс-семейное ревью САМОГО дофикса - -Пропуск, который я закрыл только по вопросу владельца: дофикс приёмки был проверен исполнением -(пины · посадки · батарея · живая проба), но НЕ был отревьюен независимо — author≠reviewer для -дофикс-диффа не соблюдался. Запущены два ревьюера другой семьи (Fable 5, я Opus), read-only по -дереву, каждый со своей линзой — «деньги и гонки» и «интейк, потеря данных, правдивость чисел». -Вернули **пять дефектов с воспроизведением, одну гипотезу и два расхождения отчётности**. Все -подтверждены мной на коде до правки; возражений нет. - -### Н1 [medium-high, интейк] Пропавший КОРЕНЬ хранилища читался как вина каждой книги - -`os.Stat(workdir)` даёт ENOENT и когда пропал каталог одной книги, и когда не смонтирован сам -`BooksDir`. Первое — терминальный конец этой книги (и лечение крэш-окна отказа, FP5-3), второе — -беда хоста. Читая второе как первое, ОДИН проход свипа терминально отклонял ВСЕ книги в интейке с -`source_unreadable` — причиной, которая винит файл пользователя, терминальна по устройству и не -имеет обратного хода (ни перепарса, ни удаления, PD-175). То есть мой же фикс FP5-3 создал путь -массового уничтожения загрузок из-за размонтированного тома. - -Сделано: `ErrStorageGone` отличён от `ErrDirectoryGone` — корень спрашивается ПЕРЕД тем, как винить -книгу; причина `storage_unavailable` никогда не терминальна, бюджет не тратит (движка не звали) и на -книге не хранится. Предикат `waitsForTheDeployment` собрал оба таких случая в одном месте. -Пин — `books.TestAVanishedStorageRootIsNotEveryBooksFault` (две книги, снесённый корень, десять -циклов ожидания: обе живы, бюджет цел, движок не спрошен ни разу, том вернулся — следующий проход -разбирает). Посадка падает. Строка PD-192. - -### Н2 [minor, ревизия] Пол ревизии глотал весь статусный проход книги - -Продолжение FP5-1 на соседнем пути: вставка научилась брать `greatest(max, пол) + 1`, а смены -статуса остались на `revision + 1`. Книга, загруженная ДО чужой отменённой загрузки, идёт -`uploading → parsing → not_started` тремя инкрементами СВОЕГО счётчика и всё ещё под полом — -`greatest(max, пол)` не двигается вовсе, а клиент по контракту отбрасывает чтение, которое не выше -применённого. Экран, только что загрузивший книгу, показывает её «приходящей», пока библиотеку не -сдвинет постороннее событие. Монотонность при этом не нарушена — это staleness, и именно поэтому -прошлый круг её не заметил. - -Сделано: три писателя статуса (`StartParsing`, `FinishParse`, `RejectBook`) берут следующий номер -БИБЛИОТЕКИ, а не свой + 1 (`nextRevisionOfThisBooksLibrary`, владелец читается из обновляемой -строки). Пин — `books.TestAStatusChangeIsVisibleEvenUnderTheLibrarysFloor`, посадка падает. PD-122 -переформулирована: остаток — собственный счётчик области (двое ОДНОВРЕМЕННЫХ писателей могут -вычислить одно и то же число; значение при этом растёт, поэтому клиент перезапрашивает). - -### М1 [medium, деньги] `PauseRun` — третий закрывающий путь без гарда живой попытки - -Тот же класс, что PD-181 и FP5-2, на пути, который прошлый круг не проверил: пауза закрывала прогон, -не спрашивая, жива ли ещё закрываемая попытка И этого ли она прогона (апдейт попытки шёл даже без -`run_id`). Сценарий ревьюера: два поколения реконсилятора при перекрывающемся деплое, у старого ниже -`PerChapter` в конфиге → его проход считает остаток исчерпанным и паузит прогон, который уже -рестартован в живую попытку 2. Итог — прогон `paused/credit_exhausted` под живым тратящим движком, а -открытый холд попытки 2 не виден НИ в `ListLiveRuns` (прогон завершён), НИ в `UnsettledRuns` (попытка -не завершена). - -Сделано: тот же `exists (… a.id = $4 and a.run_id = runs.id and a.ended_at is null)`, что у соседей, -плюс `run_id` в апдейте попытки. Пин — `pgstore.TestAPauseFromAnOldSnapshotDoesNotCloseARunOverALiveAttempt` -(проверяет и то, что прогон жив, и то, что холд живой попытки виден в списке). Посадка падает. PD-193. - -### М2 [low, проекция] Синк законченной попытки усыновлял чужой хендшейк - -Форма, которой до P5 не существовало: стоп ДО спавна оставляет попытку закрытой и без -`engine_run_id`. Устаревший материализатор с такой попыткой принимал hello попытки, которая её -заменила, биндил чужой `engine_run_id` на себя и материализовал тот же журнал второй раз — счётчики -глав и юнитов удваивались. Деньги не двигались. - -Сделано: бинд отказан для законченной попытки (`ended_at is null` в CAS). Пин — -`pgstore.TestAMaterializerOfAnEndedAttemptDoesNotAdoptTheNextAttemptsEngine`, посадка падает. PD-194. - -### М3 [low, деньги] Возврат холда «прогона, который не запускался», ждал ответа движка - -Ревьюер дал это гипотезой; я перепроверил и подтвердил. `settle` сперва требовал успешного -`tmctl status`, и только потом ветка «юнита не было — вернуть холод целиком». Но хост, который -производит эту ситуацию, — ровно тот, где движок не запускается: `Status` падает на каждом проходе, -и деньги прогона, который не начинался, остаются зарезервированными навсегда (видимыми, но -запертыми). - -Сделано: ветка «нет юнита и нет базовой линии» идёт ДО вызова движка; `ReleaseUnspawned` -перепроверяет строку под замком, поэтому снапшот, который успели заспавнить, отвергается там и -settle идёт обычным путём. Пин — -`runs.TestTheHoldOfARunThatNeverStartedComesBackOnAHostWhoseEngineCannotAnswer` (движок отвечает -«no such file» на любой запрос; холд обязан вернуться, движок — не быть спрошенным). Посадка падает. -PD-195. - -### Хвосты того же круга - -- **Н3.** `StartParsing` и оба `abandon` в `Accept` шли на `context.WithoutCancel` без своего - дедлайна: отсоединённый — не значит бесконечный, повисший запрос держал бы горутину запроса, - которого уже нет. Переведены на `writeCtx` (30 с). **Пина нет и не заявляется**: это граница, а не - наблюдаемое поведение — чтобы его пинить, нужен стор, умеющий висеть. -- **Н4.** Опечатка оператора в рукописном `book.yaml` по-прежнему стоит файла пользователя: движок - отвечает exit 1 на всё, через пять циклов книга отклоняется как `source_unreadable` и исходник - удаляется. FP5-4 закрыл только «конфигурации нет вовсе». Заведено отдельной строкой риска PD-196, - а не спрятано в комментарий: настоящее лечение — различающий код выхода или `--dry-run` на стороне - движка, это не правка платформы. -- **Н5.** Пин FP5-7 сверял ДВЕ точные формулировки старого стринг-матча — третье написание прошло бы - мимо. Теперь тест разбирает файл гейта парсером и смотрит только КОД (комментарии там цитируют ту - самую строку — текстовый скан принял бы историю за дефект), запрещает в коде слова systemd и - требует, чтобы гейт вообще спрашивал менеджер. Посадка «вернуть матч по сообщению» падает. -- **Отчётность.** Ревьюер не смог проверить клейм «посадки 10 из 10»: списки лежали в скрэтчпаде - сессии, а не в дереве. Исправлено — списки посадок теперь ниже, в самом журнале. -- Ловушка счёта, в которую ревьюер едва не попал и которую стоит знать: `git grep '^func Test' HEAD -- - platform` даёт 262 — лишняя строка из код-блока в архивном доке; счёт по `*_test.go` даёт 261. - -### Списки посадок (то, чего не хватало отчёту) - -| Раунд | Посадка (что откатывается) | Пин, который обязан упасть | Итог | -|---|---|---|---| -| Дофикс | вставка снова читает только `max(revision)` | `TestABookThatJoinsAfterACancelledUploadStillMovesTheRevision` | поймана | -| Дофикс | `FinishUnspawnedStop` без гарда живой попытки | `TestAStaleUnspawnedStopDoesNotCloseAResumedRun` | поймана | -| Дофикс | пропавший каталог снова = «нет конфигурации» | `TestABookWhoseDirectoryIsGoneIsRejectedRatherThanLeftWaiting` | поймана | -| Дофикс | ожидание конфигурации снова жжёт бюджет | `TestWaitingForAConfigurationDoesNotBringDeletionCloser` | поймана | -| Дофикс | проход интейка снова под бюджетом прогонов | `TestOneBookInTheSweepGetsABudgetAParseCanLiveIn` | поймана | -| Дофикс | просроченный дедлайн снова 500 | `TestAnUploadThatOutlivesItsDeadlineIsNotAnInternalError` | поймана | -| Дофикс | гейт systemd снова по тексту сообщения | `TestTheSystemdGateAsksAboutTheCapabilityAndNotAMessage` | поймана | -| Дофикс | пауза перебивает стоп | `TestAStopDuringSettlementOutranksThePause` | поймана | -| Дофикс | потолок частей формы снова `n > cap` | `TestAFormWithTooManyPartsIsRefusedAtTheCap` | ПЕРЕЖИЛА → пин переписан → поймана | -| Дофикс | wire-тест ревизии без различающей силы | `TestTheCardProjectsTheRevisionTheStoreGaveAndInventsNone` | поймана | -| Дофикс-2 | пропавший корень снова читается как вина книги | `TestAVanishedStorageRootIsNotEveryBooksFault` | поймана | -| Дофикс-2 | смена статуса снова `revision + 1` | `TestAStatusChangeIsVisibleEvenUnderTheLibrarysFloor` | поймана | -| Дофикс-2 | пауза снова без гарда живой попытки | `TestAPauseFromAnOldSnapshotDoesNotCloseARunOverALiveAttempt` | поймана | -| Дофикс-2 | синк снова биндит законченную попытку | `TestAMaterializerOfAnEndedAttemptDoesNotAdoptTheNextAttemptsEngine` | поймана | -| Дофикс-2 | возврат холда снова ждёт ответа движка | `TestTheHoldOfARunThatNeverStartedComesBackOnAHostWhoseEngineCannotAnswer` | поймана | -| Дофикс-2 | гейт systemd снова сверяет сообщение (усиленный пин) | тот же | поймана | - -### Батарея дофикса-2 - -- `make check` с живым PG 18.4 под `-race`: **EXIT=0, 15 пакетов, скипов 0**, линтер **0 issues**. -- Тесты `^func Test` исполнением: **349 → 354** (+5 пинов, удалённых 0). -- Посадки: **6 из 6** (таблица выше, нижние шесть строк). -- ⚠ **`make vuln` покраснел между двумя прогонами ОДНОГО дня, и не из-за дерева.** База адвизори - опубликовала пять уязвимостей stdlib против Go **1.26.5** — `net/http`, `crypto/tls`, `net/url`, - `encoding/xml`, `encoding/asn1` (GO-2026-6218 / 6090 / 6089 / 6088 / 5972), все закрыты в - **1.26.6**, и govulncheck трассирует две из них в пути, которые эта служба зовёт (например - `pgstore.Open → pgx.ParseConfig → asn1.Unmarshal`). Поставил 1.26.6 в `~/.local` (sha256 сверен с - go.dev), поднял `GO_MIN_VERSION` в Makefile 1.26.5 → 1.26.6 с обоснованием на месте, обновил пин в - `STACK_DECISIONS.md`. На 1.26.6 батарея зелёная и скан чист. - **НА РАТИФИКАЦИЮ:** хост сборки, оставшийся на 1.26.5, должен получать отказ. - ⚠ **Правка 14.08: в этой редакции он его НЕ получал** — гейт сравнивал регексом и принимал 1.26.5 - (ре-чек, FP5-9). Как floor держится теперь — раздел «Третий раунд» выше. - -### Записка-план дофикса-2: ID → статус → улика - -| ID | Статус | Улика | -|---|---|---| -| Н1 | ЗАКРЫТО | `ErrStorageGone` + `waitsForTheDeployment`; `books.TestAVanishedStorageRootIsNotEveryBooksFault`; посадка падает. PD-192 | -| Н2 | ЗАКРЫТО | `nextRevisionOfThisBooksLibrary` у трёх писателей статуса; `books.TestAStatusChangeIsVisibleEvenUnderTheLibrarysFloor`; посадка падает. PD-122 переформулирована | -| М1 | ЗАКРЫТО | гард живой попытки в `PauseRun` + `run_id` в апдейте попытки; `pgstore.TestAPauseFromAnOldSnapshotDoesNotCloseARunOverALiveAttempt`; посадка падает. PD-193 | -| М2 | ЗАКРЫТО | `ended_at is null` в бинде; `pgstore.TestAMaterializerOfAnEndedAttemptDoesNotAdoptTheNextAttemptsEngine`; посадка падает. PD-194 | -| М3 | ЗАКРЫТО (гипотеза ревью подтверждена мной) | ветка «не спавнился» ДО вызова движка; `runs.TestTheHoldOfARunThatNeverStartedComesBackOnAHostWhoseEngineCannotAnswer`; посадка падает. PD-195 | -| Н3 | ЗАКРЫТО БЕЗ ПИНА (честно) | `writeCtx` на `StartParsing` и обоих `abandon`; граница, а не поведение — пин не заявляется | -| Н4 | СТРОКОЙ | PD-196: exit 1 движка на опечатку в `book.yaml` стоит файла; лечение — на стороне движка | -| Н5 | ЗАКРЫТО | пин FP5-7 разбирает КОД гейта парсером, а не текст файла; посадка падает | -| Отчётность | ЗАКРЫТО | списки посадок перенесены из скрэтчпада в журнал (таблица выше) | -| Тулчейн | НА РАТИФИКАЦИЮ | Go 1.26.5 → 1.26.6 по пяти адвизори stdlib; `make vuln` чист, батарея зелёная | - - -## Дофикс P5 (11.08): фикс-лист приёмки FP5-1…FP5-8 - -Пак принят УСЛОВНО с восемью находками (одна high). Ниже — что сделано по каждой, чем проверено и -что осталось. Каждое число снято исполнением, команда рядом. Возражений НЕТ: все восемь -подтвердились на коде до правки — три из них были прямыми последствиями МОИХ же решений, принятых в -разных файлах и не сверенных между собой. - -### FP5-1 [HIGH] Пол ревизии библиотеки игнорировался на вставке - -Приёмка права, и посылка перепроверена мной на дереве, не по памяти: `DeleteUpload` поднимал -`users.library_revision`, `ListBooks` читал `greatest(max, пол)`, а `nextLibraryRevision` — ТОЛЬКО -`max(books.revision)`. Книга, загруженная после отменённой загрузки, входила на или ниже пола, и -одна ревизия отвечала за три разных состояния библиотеки: до отменённой загрузки, во время неё и -после прихода следующей книги. Клейм «PD-122 закрыта в половине добавления» был ЛОЖЕН — тесты -покрывали только свежий аккаунт, где пол ещё ноль. - -Сделано: вставка берёт `greatest(max(revision), users.library_revision) + 1`. Пин — -`books.TestABookThatJoinsAfterACancelledUploadStillMovesTheRevision`, и он гоняет именно ту -последовательность, которой не было: книга → отмена → книга; проверяет и ревизию БИБЛИОТЕКИ, и -ревизию самой новой книги (иначе её карточка устарела бы в момент появления). PD-122 пере-формулирована: -открытым остаётся один случай — смена статуса книги, которая не самая новая, не двигает ни одну из -половин. - -### FP5-2 [деньги] `FinishUnspawnedStop` без гарда живой попытки - -Тот же гард, что `FinishRun` получил кругом раньше (PD-181), на соседнем пути отсутствовал: путь -закрытия «стопа до спавна» проверял только `unit_name is null`. Устаревший проход мог закрыть уже -резюмированный прогон, и холд второй попытки выпадал из обоих списков; приёмка добавила к этому -следствие, которого я не назвал: каждый следующий резюм отвечал бы 409 навсегда, потому что -продолжать пришлось бы уже завершённый прогон. - -Сделано: попытка обязана быть живой и принадлежать этому прогону (`ended_at is null and run_id = $`). -Пин — `runs.TestAStaleUnspawnedStopDoesNotCloseAResumedRun`; посадка «снять гард» падает. -Строка PD-186. - -### FP5-3 Крэш-окно «снос каталога → запись строки» - -Клейм «порядок самоизлечивающийся» был ложен, и ложным его сделала МОЯ ЖЕ вторая правка того же -круга: снесённый каталог читается как «нет конфигурации», а эту причину я тем же паком сделал -НЕтерминальной — книга оставалась в `parsing` навсегда, ожидая конфигурацию, которую положить некуда. -Диспозиция F11 («порядок выбран самоизлечивающийся») переписана: она стояла на посылке, которая к -моменту записи уже не была верна. - -Сделано: пропавший КАТАЛОГ отличён от отсутствующей конфигурации (`ErrDirectoryGone`) и терминален -сразу — движка при этом не зовём вовсе. Пин — -`books.TestABookWhoseDirectoryIsGoneIsRejectedRatherThanLeftWaiting`. Строка PD-187. - -### FP5-4 Ожидание конфигурации жгло бюджет разбора - -`ClaimParse` считает КАЖДУЮ заявку, а книга без конфигурации заявляется раз в грацию бесконечно. -После пяти циклов ожидания бюджет исчерпан — и первый же ответ движка становится терминальным -мгновенно, вместе с удалением исходника. Движок отдаёт exit 1 и на опечатку в `book.yaml`, так что -цена ошибки оператора — файл пользователя. - -Сделано: попытка возвращается (`RefundParseAttempt`), когда движок не был спрошен вовсе; возврат -условен на клейме, как и все прочие записи, заканчивающие проход. Пин — -`books.TestWaitingForAConfigurationDoesNotBringDeletionCloser`: после пятнадцати циклов ожидания -бюджет цел, и первый отказ движка после этого НЕ терминален и ничего не удаляет. Строка PD-188. - -### FP5-5 Бэкстоп гнал разбор под бюджетом прохода - -Проход интейка шёл под теми же двумя минутами, что и проход прогонов, а разбор внутри — это тот же -вызов движка, которому очередь даёт пятнадцать минут. Большая книга убивалась дедлайном свипа, -убийство читалось как «хост не может запустить движок», попытка списывалась — и так каждый проход, -пока книга не отклонялась ЗА СВОЙ РАЗМЕР. Вторую половину приёмка назвала точно: запись отказа шла -на уже просроченном контексте и терялась, то есть цикл был бесконечным. - -Сделано: у прохода интейка свой бюджет (`jobs.JobTimeout + 1m`), у каждой книги внутри прохода — -свой (`jobs.JobTimeout`), а терминальные записи (`FinishParse`/`RejectBook`/возврат попытки) идут на -контексте, переживающем дедлайн вызова. Пин — `books.TestOneBookInTheSweepGetsABudgetAParseCanLiveIn` -(фейк движка читает дедлайн, который ему выдали). Строка PD-189. - -### FP5-6 Просроченный дедлайн загрузки уходил 500 - -Сделано: 408 problem+json. Выбор кода назван вслух: медленный клиент — не сломанный сервис, а 408 по -RFC 9110 §15.5.9 означает ровно «полный запрос не пришёл за время, которое сервер готов был ждать», и -сообщает клиенту, что лечение — повтор. Кода 408 в перечне операции нет, поэтому он внесён в тот же -пакет вопросов владельцу контракта, что PD-172/PD-174/PD-180. Пин — -`httpapi.TestAnUploadThatOutlivesItsDeadlineIsNotAnInternalError`. Строка PD-190. - -### FP5-7 Скип-гард systemd-тестов пинил СООБЩЕНИЕ - -Приёмка права дважды, и вторая половина — исправление МОЕЙ ошибки в фактах. Гейт существовал и -скипал по подстроке «Failed to connect to bus», а systemd 259 отвечает «Failed to connect to user -scope bus» — гейт перестал гейтить от чужой правки строки. Моя строка PD-178 при этом утверждала, что -формы скипа для systemd в зоне НЕТ ВОВСЕ: это было неверно, и я записал предположение как факт. - -⚠ И вторая посылка PD-178 устарела прямо в ходе дофикса: **на этом стенде пользовательский менеджер -systemd теперь ЖИВ** (`systemctl --user show` → `Version=259.5`), и все три теста проходят — тогда -как несколькими часами раньше в этой же сессии его не было (`Linger=no`, sudo нет, ручной запуск -выходил кодом 1). То есть состояние менеджера ПЛАВАЕТ между сессиями стенда, и строка переписана -именно так. - -Сделано: гейт спрашивает СПОСОБНОСТЬ — доходит ли процесс до своего менеджера, — и скипает громко и -поимённо, симметрично форме БД-тестов. Пин — -`runner.TestTheSystemdGateAsksAboutTheCapabilityAndNotAMessage`: он падает, если гейт снова начнёт -сверять текст. PD-178 закрыта. - -### FP5-8 Хвосты - -- **(а) деньги.** Стоп, пришедший в окно расчёта, отвечал `paused/credit_exhausted` — «кончились - деньги» вместо «владелец остановил». Пауза теперь отказывает при висящем интенте - (`ErrStopRequested`), реконсилятор заканчивает прогон стопом. Пин - `runs.TestAStopDuringSettlementOutranksThePause`. Строка PD-191. -- **(б)** PD-153 дополнена: у СТОП-пути окно закрыто с обеих сторон (заявка не выдаётся прогону с - интентом; закрытие «стопа до спавна» спрашивает systemd, если у попытки есть базовая линия). Само - окно PD-153 — грация от `started_at` — не тронуто. -- **(в)** Потолок частей формы пропускал N+1 (`n > cap` вместо `n >= cap`). Пин - `httpapi.TestAFormWithTooManyPartsIsRefusedAtTheCap` — и ПЕРВАЯ его редакция посадку ПЕРЕЖИЛА: - она сверяла только код 400, а форма, у которой просто кончились части, тоже 400, так что обе - версии потолка проходили. Переписан на наблюдаемое следствие: файл стоит ровно на части `cap+1`, - и тест требует, чтобы интейку его НЕ отдали (`in.read == 0`) — при потолке на единицу больше файл - дочитывается и книга принимается. Посадка теперь падает. -- **(г)** Выключатель metrics-листенера был недостижим через окружение: пустое значение переменной - неотличимо от неустановленного и берёт дефолт. Теперь выключает СЛОВО (`off`/`none`), и это - записано там же, где документирован сам выключатель. -- **(д)** Wire-тест ревизии вернул различающую силу: фикстура снова несёт РАЗНЫЕ числа (книга 41, - прогон 12), и тест утверждает то, за что отвечает этот слой — он отдаёт число стора и не выдумывает - своё. Правило, что число берётся из книги, пинится в сторе. -- **(е)** `help` метрики `queue_depth` называет все состояния, которые она считает, включая `pending`. -- **(ж)** Счёт тестов в таблице задач пересчитан исполнением. - -### Батарея дофикса - -- `make check` с живым PostgreSQL 18.4 под `-race`: **все пакеты зелёные, включая `internal/runner`** - (менеджер systemd на стенде жив — см. FP5-7), линтер **0 issues**. -- Скипы: 0 при живом менеджере; при мёртвом — три ИМЕНОВАННЫХ скипа вместо трёх падений. -- Тесты `^func Test` исполнением: **340 → 349**, добавленных **9**, удалённых **0**, переименован - **1** (`TestTheCardsRevisionIsTheBooksAndNotTheRuns` → `TestTheCardProjectsTheRevisionTheStoreGaveAndInventsNone`, - FP5-8д — свойство то же, имя стало отвечать за то, что тест на самом деле утверждает). Против HEAD: - **261 → 349**. Команда: `comm` двух списков `^func Test` (HEAD и дерево) — единственная строка - расхождения и есть это переименование. -- Посадки дофикса: **10, поймано 10** — но честно: первый круг дал **9 из 10**, пережившая посадка - (в) показала, что пин сверял не то следствие; переписан пин, не мутация. Списки — `mut9.json`. -- `make vuln`: чисто. - -**Живая проба дофикса на собранном бинаре** (`go build ./cmd/tmplatformd`, боевой PostgreSQL 18.4, -сессия заведена прямой вставкой в стенд и убрана после пробы): - -- FP5-6: сырым сокетом отправлена шапка `POST /v0/books` с `Content-Length: 100000` и одной частью, - дальше клиент «думает» 4 секунды при `TM_PLATFORM_UPLOAD_DEADLINE=2s`. Ответ — - `HTTP/1.1 408 Request Timeout`, `Content-Type: application/problem+json`, - тело `{"type":"about:blank","title":"The upload did not finish in time","status":408}`, соединение - закрыто. Юнит-тест это же место видит через фейковый ридер: живой прогон подтверждает, что дедлайн - РЕАЛЬНО срабатывает на соединении, а не только маппинг ошибки. -- FP5-8г: с `TM_PLATFORM_METRICS_ADDR=off` в печати эффективной конфигурации стоит - `value=off source=environment`, в логе — `no TM_PLATFORM_METRICS_ADDR: this instance exposes no - metrics`, слушателя на порту нет (`ss -ltn` пуст). Со значением-адресом скрейп отдаёт 200. -- FP5-8е: `# HELP tm_platform_queue_depth Jobs in the queue that have not finished (pending, - available, running, scheduled or retryable)` — из живого скрейпа. Там же видно 9 семейств зоны из - десяти: `sweep_unfinished_total` — счётчик с лейблами и серии до первого инкремента не имеет, - это нормальная семантика prometheus, а не пропавшая метрика. -- Заодно: `tm_platform_http_requests_total{code="200",method="GET",route="GET /v0/books"}` — лейбл - маршрута это ПАТТЕРН (F9), и `GET /v0/books` живым Bearer'ом отвечает 200. - -### Записка-план дофикса: ID → статус → улика - -| ID | Статус | Улика | -|---|---|---| -| FP5-1 | ЗАКРЫТО | `nextLibraryRevision` = `greatest(max(revision), users.library_revision)+1`; `books.TestABookThatJoinsAfterACancelledUploadStillMovesTheRevision`; посадка «снова только максимум» падает | -| FP5-2 | ЗАКРЫТО | гард живой попытки в `FinishUnspawnedStop` (`ended_at is null and run_id = $2`); `runs.TestAStaleUnspawnedStopDoesNotCloseAResumedRun`; посадка падает. PD-186 | -| FP5-3 | ЗАКРЫТО | `ErrDirectoryGone` проверяется ПЕРЕД `ErrNotProvisioned` и терминален; `books.TestABookWhoseDirectoryIsGoneIsRejectedRatherThanLeftWaiting`; посадка падает. PD-187 | -| FP5-4 | ЗАКРЫТО | `RefundParseAttempt` условно на клейме; `books.TestWaitingForAConfigurationDoesNotBringDeletionCloser`; посадка падает. PD-188 | -| FP5-5 | ЗАКРЫТО | свой бюджет у прохода интейка и у каждой книги, терминальные записи на пережившем контексте; `books.TestOneBookInTheSweepGetsABudgetAParseCanLiveIn`; посадка падает. PD-189 | -| FP5-6 | ЗАКРЫТО кодом, вопрос владельцу контракта СТРОКОЙ | `os.ErrDeadlineExceeded` → 408; `httpapi.TestAnUploadThatOutlivesItsDeadlineIsNotAnInternalError`; посадка падает. PD-190 | -| FP5-7 | ЗАКРЫТО, PD-178 переписана и закрыта | гейт спрашивает способность соединиться с менеджером; `runner.TestTheSystemdGateAsksAboutTheCapabilityAndNotAMessage`; посадка падает. Менеджер на стенде сейчас ЖИВ (259.5) — три теста прошли | -| FP5-8а | ЗАКРЫТО | `PauseRun` отказывает при висящем интенте (`ErrStopRequested`); `runs.TestAStopDuringSettlementOutranksThePause`; посадка падает. PD-191 | -| FP5-8б | СТРОКОЙ | PD-153 дополнена: стоп-путь закрыт с обеих сторон; само окно грации не тронуто | -| FP5-8в | ЗАКРЫТО со второго захода | `n >= cap`; пин переписан на «файл за потолком не отдан интейку»; посадка падает | -| FP5-8г | ЗАКРЫТО | выключатель metrics-листенера — СЛОВО (`off`/`none`), пустая переменная неотличима от неустановленной; `config/effective_test.go` | -| FP5-8д | ЗАКРЫТО | фикстура снова несёт разные числа (книга 41, прогон 12); `httpapi.TestTheCardProjectsTheRevisionTheStoreGaveAndInventsNone`; посадка падает | -| FP5-8е | ЗАКРЫТО | `help` у `queue_depth` называет все состояния, включая `pending` | -| FP5-8ж | ЗАКРЫТО | числа в таблице задач пересчитаны исполнением (см. батарею выше) | - - -## Приёмка P5 оркестратором (11.08): ПРИНЯТ УСЛОВНО — дофикс-раунд ДО лендинга (прецедент P4) - -**Что подтверждено исполнением.** Батарея пере-прогнана мной на СВЕЖЕПОДНЯТОМ стенде (zonky PG -18.4, рецепт §«Postgres без root»): **все пакеты зелёные, ВКЛЮЧАЯ `internal/runner`** — на текущем -стенде пользовательский менеджер systemd ЖИВ (`user@1000.service` active, systemd 259), и три -теста PD-178 прошли; линтер 0 issues; тестов **340**, удалённых против HEAD **0** (comm пуст). -Живые числа проб и мутационных прогонов read-only не пере-снимаются — приняты «со слов» с -пометкой. Ревью 10 агентами (6 линз + скептики): **8 находок подтверждено (1 high), 0 -опровергнуто, 9 минорных.** Задеты деньги и данные пользователей ⇒ по прецеденту P4 пак -принимается УСЛОВНО, лендинг после дофикса и ре-чека. - -**Фикс-лист (движение — запиской-планом ID → статус → улика, D39.121):** - -| ID | Находка | Лечение | -|---|---|---| -| FP5-1 **HIGH** | **Пол ревизии библиотеки игнорируется на вставке** (`pgstore/books.go:99`): `nextLibraryRevision` читает только `max(books.revision)`, а `DeleteUpload` поднимает `users.library_revision`; книга, загруженная после отменённой загрузки, входит НА/НИЖЕ текущей ревизии — три разных состояния библиотеки под одной ревизией, revision-keyed refetch слеп. Клейм закрытия PD-122 ложен; тесты покрывают только свежий аккаунт | вставка учитывает пол (`greatest`); тест со сценарием «отмена → повторная загрузка»; PD-122 переформулировать | -| FP5-2 деньги | **`FinishUnspawnedStop` без гарда живой попытки** (`pgstore/sink.go:362`): закрытие по устаревшему снапшоту РЕЗЮМИРОВАННОГО прогона — холд попытки-2 вне `ListLiveRuns` И вне `UnsettledRuns`, движок без надзора, последующий резюм вечно 409. Ровно та гонка, от которой `FinishRun` уже получил гард (PD-181) | тот же гард «закрываемая попытка ещё живая»; пин + посадка | -| FP5-3 | **Крашокно `removeDir → RejectBook`** (`books/parse.go:188`): смерть между ними = книга навечно в `parsing` (ENOENT читается как `not_configured`, терминального пути нет) — клейм «порядок самоизлечивающийся» ложен, диспозиция F11 стояла на ложной посылке | терминальность переживает краш (запись/маркер до сноса либо «каталога нет вовсе» = терминал); диспозицию F11 переписать | -| FP5-4 | **`not_configured`-проходы жгут бюджет разбора** (`parse.go:138`): `ClaimParse` инкрементит `parse_attempts` безусловно ⇒ после ~5 циклов ожидания конфигурации ПЕРВЫЙ отказ движка терминален мгновенно — включая удаление исходника при опечатке `book.yaml` (exit 1 = «вина файла») | `not_configured` не тратит бюджет; пин «ожидание не приближает удаление» | -| FP5-5 | **Свип-бэкстоп гонит разбор под общим бюджетом прохода 2 мин против 15 мин очереди** (`cmd/tmplatformd/runner.go:151`): восстановление большой книги убивается дедлайном свипа, каждый цикл жжёт попытку, а reject на просроченном контексте не записывается — kill-loop | у бэкстоп-разбора свой бюджет ≥ `jobs.JobTimeout` (пара тестом, как `claimGrace`); reject на свежем контексте | -| FP5-6 | **Просроченный дедлайн загрузки уходит 500** (`httpapi/v0.go:404`) — код вне спеки и вне декларации PD-180 | замаппить в честный код problem+json; внести в пакет вопросов PD-180 | -| FP5-7 | **Скип-гард systemd-тестов пинит сообщение, которого systemd 259 не даёт** (`runner/systemd_test.go:25`: ждёт «Failed to connect to bus», стенд говорит «…user scope bus…») — на стенде без менеджера тесты ПАДАЮТ, а не скипаются; PD-178 не знает об этом гарде и устарела фактами (менеджер сейчас ЖИВ, батарея зелёная) | гейт по СПОСОБНОСТИ (проба соединения), скип поимённо — симметрия DSN-формы; PD-178 переписать: «состояние менеджера плавает между сессиями стенда» | -| FP5-8 хвосты | (а) стоп в окне settle отвечает `paused/credit_exhausted` вместо `stopped` (`reconcile.go:543`) · (б) PD-153-окно: полный возврат холда под стартующим движком через новый стоп-путь — дополнить строку · (в) потолок частей формы пропускает N+1 · (г) выключатель metrics-листенера недостижим через env · (д) wire-тест ревизии потерял различающую силу (фикстура 12→41 сравняла прогон с книгой) · (е) help `queue_depth` не называет `pending` · (ж) счёт тестов в таблице задач устарел | по весу: (а)(б)(д) — фиксы с пинами; (в)(г)(е)(ж) — мелкие правки/честные строки | - -Развилка `book.yaml` (вопрос зоны) и четыре контракт-вопроса — на мне, ратифицирую при лендинге -дофикса. Дерево остаётся незакоммиченным до ре-чека. - -**Ре-чек дофикса-2 (13.08, оркестратор; дата испр. 14.08 — стояло «11.08» ошибочно, дофикс-2 датирован 13.08): батарея воспроизведена зелёной (354 теста, Go 1.26.6, -стенд ~/.local/pgsql:55433), НО 3 находки подтверждены (2 high) — третий раунд по списку ниже, -лендинг после него:** - -| ID | Находка | Лечение | -|---|---|---| -| FP5-9 **HIGH** | **Гейт тулчейна НЕ отказывает 1.26.5**: `Makefile:33` — регекс `go1\.26\.([5-9]\|…)` принимает 1.26.5, `GO_MIN_VERSION=1.26.6` живёт только в echo; `go.mod` — `go 1.26.4` БЕЗ `toolchain`; клейм «хост на 1.26.5 получит отказ» ложен в трёх доках (журнал ×2, STACK_DECISIONS §, коммент Makefile) | регекс/сравнение версий чинится по-настоящему (+ `toolchain go1.26.6` в go.mod — реши и обоснуй); пин на функцию сравнения, не на прогон; три клейма поправить | -| FP5-10 **HIGH** | **PD-192 не закрывает заявленный сценарий**: гард — только `Stat(BooksDir)` (`parse.go:235`); unmount тома, смонтированного РОВНО в BooksDir, оставляет пустой mountpoint → Stat успешен → терминальный reject ВСЕХ книг с удалением; безусловный `MkdirAll` на буте (`runner.go:129`) маскирует пропажу после рестарта | различать «хранилище пропало» от «книги нет» устойчиво к обеим формам (кандидат: сентинел-файл провижининга в BooksDir, который бут НЕ пересоздаёт; нет сентинела → ErrStorageGone, не терминалить, бюджет не жечь); пин на unmount-форму | -| FP5-11 | **Н2 закрыт не для всех писателей статуса**: пять писателей жизненного цикла прогона остались на `revision+1` (`pgstore/runs.go:108,517,620` · `sink.go:318,396`) против собственного правила дофикса (`books.go:111`) — staleness библиотеки на статусах прогона | те же «следующий номер библиотеки», что у трёх интейковых; пин на прогонный переход у книги не-максимума аккаунта | -| хвосты | (а) коммент `PauseRun` «в том же стейтменте» против фактического SELECT FOR UPDATE (`runs.go:580`) — текст к коду; (б) комменты `parse.go:41-52` «terminal on the first answer» описывают до-дофиксное поведение на пути удаления файла; (в) остаток FP5-5: в одном проходе бэкстопа полный бюджет получает только первая книга — вторая жжёт попытки на обрезанном контексте (узкий триггер — честная строка либо бюджет на КНИГУ); (г) мета-пин systemd-гейта перечислителен — остаток названный, не дефект | (а)(б) — правки текста; (в) — строка или фикс; (г) — знать | - -## Сессия P5 (11.08): загрузка книги · стоп и резюм · наблюдаемость · форма регистра - -**Каждое число ниже снято исполнением, команда стоит рядом.** Дерево не коммичено, в индексе ничего -не держу; зона записи — только `platform/`. - -### Таблица задач - -| Задача промта | Что сделано | Чем проверено | -|---|---|---| -| 1. `POST /books` (П-9, PD-72) | Потоковый multipart (`r.MultipartReader`), пер-маршрутный потолок тела, свой дедлайн чтения, каталог книги под `TM_PLATFORM_BOOKS_DIR`, статусы `uploading → parsing → not_started \| rejected` с писателями, разбор = `tmctl manifest --json`, свип-бэкстоп интейка | **44 теста** (пак + пины дофикса): 22 `internal/books` + 11 `httpapi/intake_test.go` + 6 `pgstore/intake_test.go` + 5 на шов манифеста (`runner/manifest_test.go`). 21 посадка пака, живая проба на боевом бинаре | -| ⚠ половина 1: кто пишет `book.yaml` | **НЕ построена — вопрос ниже.** Построен ШОВ: `books.ErrNotProvisioned`, книга без конфигурации отказывает разбором с причиной `not_configured` | `TestABookWithNoEngineConfigurationWaitsRatherThanDies` | -| 2. Стоп и резюм (PD-140) | `POST /v0/runs/{id}/stop` пишет намерение (`runs.stop_requested_at`, миграция 00014) ДО сигнала; `/resume` переиспользует `reopen` реконсилятора и ставит спавн в очередь, а не в запрос; реконсилятор классифицирует маркер по намерению, не рестартит стопнутый прогон и повторяет стоп живому юниту | **32 теста** (пак + пины дофикса): 28 `runs/control_test.go` + 4 `httpapi/control_test.go`. 25 посадок пака, живая проба | -| 3. Наблюдаемость (П-11) + PD-114 | `prometheus/client_golang` v1.24.1 на отдельном слушателе; 10 семейств метрик зоны, включая запросы и задержки по паттерну маршрута; бюджет свипа НА ПРОГОН (PD-169); печать эффективной конфигурации с источником и редакцией секретов и денег | **12 тестов** (пак + пин дофикса): 5 `internal/metrics` + 6 `config/effective_test.go` + 1 `internal/jobs`. 8 посадок пака, живая проба | -| 4. Регистр секциями (пинг №16) | Таблица разложена на 11 секций (открытые по весу · принятый риск · закрытые ратификацией · закрытые по эрам P1–P5), построчная форма сохранена | `python3 docs/scripts/counts.py --check` — зелено; список ID до и после перекладки совпадает посимвольно | - -### ⚠ ВОПРОС НА РАТИФИКАЦИЮ: кто пишет `book.yaml` при интейке - -Промт гейтит эту половину явно, и она НЕ построена. Что известно и чего стоит каждый вариант: - -**Факт.** Каталог новой книги создаёт платформа (иначе `POST /books` некуда писать), а разбор — -`tmctl manifest --config <каталог>/book.yaml`; движок требует в конфиге `book_id`, пару языков, -пути `pipeline`/`models`/`source_file` и хотя бы один потолок (`backend/internal/config/book.go` -проверяет всё это на загрузке). D39.110 §2b говорит «платформа `book.yaml` не правит» — в контексте -того решения речь шла о ПОТОЛКАХ, которые платформа передаёт аргументом прогона, а не о создании -файла для книги, которой ещё не существует. - -| Вариант | Что делает платформа | Цена | -|---|---|---| -| **А. Оператор кладёт файл** (то, что построено) | Ничего: каталог и исходник создаются, конфигурации ждём; без неё разбор отказывает и после бюджета попыток книга становится `rejected` с причиной `not_configured` | Загрузка через UI не доходит до конца без ручной работы оператора НА КАЖДУЮ книгу; бета с чужими пользователями на этом не живёт. Зато D39.110 §2b соблюдён буквально | -| **Б. Рендер из деплой-шаблона при интейке** (кандидат промта) | Один раз, при создании каталога, рендерит `book.yaml` из шаблона деплоя (`TM_PLATFORM_BOOK_TEMPLATE`): языки и жанр из `BookIntake`, `source_file` — имя, которое платформа же и записала, потолки и пути пайплайна — из шаблона оператора. Дальше файл принадлежит оператору: платформа его не читает и не трогает | Платформа становится писателем файла движка ОДИН раз. Нужен шаблон в деплое (ещё одна переменная и ещё один файл, который можно забыть). Если оператор потом файл поправил — ничего не происходит, платформа туда не возвращается | -| **В. Движок сам заводит книгу** | Платформа зовёт новую $0-команду движка (`tmctl init --source-lang zh --target-lang ru …`), движок пишет свой конфиг сам | Архитектурно чище всех: файл движка пишет движок, и требования к полям живут там же, где проверяются. Но это строка ЕДИНОГО бэклога и релиз движка, то есть П-9 стоит до неё | - -**Что предлагает зона:** Б как бета-меру и В как правильную форму, строкой единого бэклога. При Б -шов уже готов — `books.ErrNotProvisioned` заменяется рендером в ОДНОМ месте -(`books.Service.manifest`), остальной интейк не меняется. **Ратифицирует оркестратор; тихо -интерпретировать эту развилку сессия не стала.** - -Вторым вопросом того же корня: **название книги.** `BookIntake` поля `title` не несёт, манифест -движка названия не даёт вовсе (он даёт id, счёты и идентичность разреза). Сегодня заголовок берётся -из ИМЕНИ ЗАГРУЖЕННОГО ФАЙЛА (`книга.epub` → «книга»), пустое остаётся пустым — синтезировать -«Книга 1» значит показать читателю ярлык, которого никто не писал. Если ратифицируется Б, имя -логично брать оттуда же; если владелец хочет поле `title` в форме — это правка спеки, не зоны. - -### Дизайн стопа: намерение, а не догадка по коду выхода - -Кандидат-дизайн промта взят целиком; вот его границы. - -- **Что записывается.** `runs.stop_requested_at` пишется ОДНИМ оператором с проверкой владения и - живости (`pgstore.RequestStop`: `update … from books where owner_id = $2 and finished_at is null - returning …` плюс имя юнита живой попытки). Ноль строк — два разных ответа, и они разделены - вторым запросом ТОЛЬКО на этом пути: видит владелец прогон → `ErrRunNotLive` (409), не видит → - `ErrNoRun` (404). Идемпотентно через `coalesce`: второй стоп не сдвигает ПЕРВУЮ отметку, потому - что она — улика о том, что случилось раньше. -- **Порядок.** Запись коммитится ДО обращения к systemd. Запинено не косвенно: фейк раннера зовёт - хук ВНУТРИ своего `Stop`, хук читает `stop_requested_at` из БД и требует, чтобы она уже была - (`TestTheStopIsRecordedBeforeSystemdIsAsked`). Посадка «спросить systemd раньше» падает. -- **Гонка «стоп против самостоятельного финиша».** Намерение перекрашивает исход, только если оно - РАНЬШЕ маркера (`stoppedOnRequest`: `!l.StopRequestedAt.After(m.At)`), а чистые коды движка - (0 · 2 · 3) намерение перебивают всегда — завершённый перевод не должен показываться отменённым. - Три пина: стоп → `stopped`, маркер раньше стопа → `failed`, чистый выход → `ready`. -- **Три следствия, каждое запинено.** Прогон с намерением НЕ перезапускается реконсилятором (иначе - деньги уходят на работу, которую владелец отменил) · живой юнит с намерением получает стоп - ПОВТОРНО (закрывает «платформа умерла между записью и вызовом») · стоп до спавна заканчивает - прогон и возвращает холд ЦЕЛИКОМ (проверено балансом, не статусом). -- **Чего дизайн НЕ закрывает, и это названо:** штатная перезагрузка хоста. Там намерения нет ни у - кого, маркер честно скажет `exited/1`, и прогон закроется как `failed` вместо перезапуска. - Различить это может только движок различимым кодом выхода graceful-stop (строка 165 единого - бэклога) — платформенной догадки здесь быть не должно. PD-152 остаётся открытой этой половиной. - -Резюм по контракту: `stopped`/`paused` → новая попытка с ОСТАТКОМ бюджета (`потолок − RunSpent`); -`awaiting_bank` → 409 «подпись неполна», и она неполна у любого деплоя, потому что банк сегодня не -материализуется вовсе (компаньон §3); нечего продолжать → 202 с прогоном КАК ЕСТЬ, без переписывания -его состояния (прогон, который остановил пользователь, остаётся `stopped`, а не становится `paused` -из-за того, что резюм не нашёл денег). Механика перезапуска переиспользована: `restart` -реконсилятора и `Resume` — один `reopen`; решение «паузить» вынесено к вызывающим, потому что -реконсилятор паузит ЖИВОЙ прогон, а резюм работает с уже завершённым и переписывать ему исход не -вправе. - -### Наблюдаемость: что выбрано и почему не stdlib - -`expvar` рассмотрен первым (норма §1 «stdlib прежде библиотеки») и этой работы не несёт: нет -лейблов — «запросы по маршруту и коду» не выражаются вовсе; нет гистограмм — на вопрос о задержке -остаётся среднее, единственная статистика, которая прячет хвост; его JSON не читает ни один скрейпер. -Сэкономил бы он зависимость, а стоил бы написания недостающих трёх руками — ровно того самописного -пути, который та же норма и запрещает. Взят `prometheus/client_golang` **v1.24.1** (релиз -24.07.2026, пин сверен живьём через `proxy.golang.org`), OpenTelemetry отклонён как тяжёлый для -одной VM: коллектор процессом, протокол экспорта настройкой и всё равно scrape-эндпоинт на конце. -Эталон оси (половина PD-115) — практики именования Prometheus плюс четыре золотых сигнала, внесены в -`ENGINEERING_STANDARDS` §2. Разбор — `STACK_DECISIONS` §24. - -Отдаётся ОТДЕЛЬНЫМ слушателем (`TM_PLATFORM_METRICS_ADDR`, дефолт `127.0.0.1:9464`), не маршрутом -под `/v0`: экспозиция несёт операционную форму деплоя, а второй модели авторизации ради скрейпера -зона не заводит — тот же довод, что сделал админ-поверхность CLI (§10). Риск принят строкой PD-179 и -записан в `deploy/README.md`. Величины снимает СВИП, а не скрейп: коллектор, ходящий в Postgres на -каждый запрос, отдал бы нагрузку на контрол-плейн тому, у кого есть доступ к порту. - -Метрики зоны: глубина очереди · возраст самого старого открытого холда · попытки в карантине · -живые прогоны · отставание тейлера в байтах · книги в интейке по статусу · длительность свипа и -счётчик проходов, не уложившихся в бюджет (PD-169) · запросы и задержки по паттерну маршрута. - -### Что нашла ЖИВАЯ ПРОБА, чего не нашли тесты - -Проба гоняет боевые `tmplatformd`/`tmplatformctl` против живого Postgres реальными HTTP-вызовами; -подменены две чужие стороны и обе процессом — движок (как в P4) и systemd (стенда с пользовательским -менеджером нет, PD-178). Нашла две вещи, и обе исправлены: - -1. **Резюм спавнил движок ВНУТРИ запроса** — запрос висел, пока прогон не кончится. На фейке это - видно как таймаут 120 с; на настоящем systemd висело бы меньше, но по-прежнему секундами: - `spawnAttempt` читает отсчёт книги (`tmctl status` — секунды CPU движка) и ходит к systemd. - Исправлено: резюм ставит спавн в ОЧЕРЕДЬ (`EnqueueRunNow`), как это делает допуск прогона; - реконсилятор — бэкстоп, если запись потерялась. Пин переписан на новое свойство, посадка - «спавнить в запросе» падает. -2. **Идентификатор книги тёк в INFO-логи** (`book accepted`, `book parsed`, `book rejected`) — - против нормы `ENGINEERING_STANDARDS` §Наблюдаемость («id пользователя/книги в логи не текут») и - против собственной дисциплины раннера, который в INFO называет ПРОГОН и никогда книгу. Снято; - на ERROR путь книги остаётся в двух местах намеренно (терминальный отказ разбора и неудавшееся - удаление каталога) — это открытый класс PD-139, строка дополнена. Пин — - `TestNoBookIdentifierReachesAnInfoLine`. - -Прогон пробы после исправлений (числа с экрана): - -| Шаг | Наблюдение | -|---|---| -| печать конфигурации | 29 настроек с источником; DSN — `(set)/environment`, обе денежные — `(an amount; not logged)`; утечек значений в лог **0** | -| `POST /v0/books` (38 символов) | **201**, `status: parsing`, `title: 蛊真人` из имени файла, `character_count: 38` (руны, не байты) | -| статусы | `parsing → not_started`, `chapter_count: 2` из манифеста, каталог книги: `book.yaml`, `source.txt` | -| файл больше 64 КиБ | **413** problem+json (PD-72) | -| источник, который движок не разобрал | **`rejected`**, каталог книги удалён | -| `POST .../runs` (2 главы) | **202**, юнит создан, движок запущен, холд **$0.06** | -| **стоп живого прогона** | **202**, и в САМОМ ответе прогон ещё `translating` — стоп ПРИНЯТ, а не завершён (движок дописывает чекпойнт). Через свип: юнит погашен, маркер `run_…-1.exit` записан, книга и прогон → **`stopped`**, `finished_at` проставлен, расчёт: баланс **9.990000** (списано $0.01 — то, что отчитал движок), холд закрыт | -| **резюм** | **202**; прогон → `translating`, попытка **2** с НОВЫМ юнитом, холд **$0.05** = потолок $0.06 минус потраченное | -| резюм живого прогона | **409** · стоп несуществующего прогона — **404** | -| метрики | `queue_depth 0` · `live_runs 1` · `oldest_open_hold_seconds 2.63` · `quarantined_attempts 0` · `tailer_lag_bytes 0` · `books_in_intake{uploading,parsing} 0/0` · `sweep_duration_seconds_count{runs} 8` · `http_requests_total{code="201",route="POST /v0/books"} 2` | -| гигиена логов | сумм, argv движка и id книг — **0**; ERROR-строк — **0** | - -⚠ В пробе `book.yaml` кладёт СТЕНД, играя оператора, — это и есть та половина, что стоит на -ратификации. Заодно этим прогоняется путь «поломка деплоя ретраится, а не отвергает книгу»: первая -попытка разбора отказывает как `not_configured`, следующий свип разбирает книгу уже с конфигурацией. - -### Батарея, посадки и счёт - -- `make check` с живым PostgreSQL 18.4 под `-race`: **все пакеты зелёные, кроме `internal/runner`** - (три systemd-теста, PD-178 — разбор ниже), линтер **0 issues**. -- Скипы: `go test ./... -count=1 -v | grep -c -- '--- SKIP'` = **0**. -- `make vuln`: **No vulnerabilities found**. -- Тесты `^func Test` исполнением: **261 → 340**, добавленных **79**, удалённых **0** - (`comm -23` списка HEAD и списка дерева — пусто). -- **Посадки: 74 прогона в КОПИИ зоны вне репозитория** (скрипт восстанавливает копию после каждой; - списки — `mut1…mut6.json` рабочего каталога сессии). Итог: **65 пойманы своим кругом, 7 пережили, - 2 не применились по образцу — и все девять разобраны, а не подчищены**: - - ДВЕ пережили по-настоящему, и обе были дефектами ТЕСТА, а не кода: «брошенная загрузка чистится - на отменённом контексте» (тест не отменял контекст, то есть проверял не то свойство) и «отмена - загрузки роняет ревизию» (тест сравнивал с числом ДО загрузки вместо того, которое клиент мог - увидеть ВО ВРЕМЯ неё). Оба теста усилены — первый отменяет контекст ровно так, как ушедший - клиент, второй читает библиотеку из середины аплоада, — и пере-посадки ПОЙМАНЫ; - - три пережили как СЛАБЫЕ мутации: `_ = unit` не меняет порядка вызовов; переименование ветки - `awaiting_bank` эквивалентно, потому что обе ветки отвечают отказом; «заявка разбора отдаётся - назад» не моделировала снятия заявки. Все три переписаны в настоящие (перестановка вызовов, - «пустить `awaiting_bank` в резюмируемые», снятие условия свежести) и ПОЙМАНЫ; - - одна пережила на УСТАРЕВШЕЙ копии (посадка на `Unwrap` метрик гонялась до того, как телеметрия - была вписана в цепочку теста дедлайна) — пере-посадка ПОЙМАНА; - - две не применились: образец встречался дважды либо расходился пробелами; переписаны и ПОЙМАНЫ. - ⚠ Чего эта цифра НЕ значит (урок PD-142/PD-151): поимённого соответствия «каждый пин — своя - посадка» нет и не заявляется. 62 посадки против 73 новых тестов покрывают несущие свойства — - деньги, гонки снапшота, переходы статусов, потолки, редакцию логов, гарды файловой системы; не - покрыты вспомогательные, где тест утверждает форму ответа или таблицу значений. - Несущие посадки: потолок тела возвращается к дефолту · дедлайн загрузки не расширяется · `Unwrap` - снят · стоп классифицируется по коду выхода · гард гонки «стоп vs финиш» снят · стопнутый прогон - рестартится · рестарт стирает свежий интент · закрытие неспавненного стопа не перепроверяет юнит · - право на спавн выдаётся завершённому прогону · спавн игнорирует интент · резюм берёт второй холд · - резюм даёт полный потолок · резюм спавнит в запросе · резюм судит деплой раньше владения · конфликт - второго живого прогона снова 500 · прогон стартует на книге вне интейка · книга, отклонённая - движком, сохраняет файл · поломка деплоя считается виной книги · бюджет попыток снят · заявка - разбора не compare-and-set и не спейсит ретраи · свип сносит каталог живой книги · новая книга - входит с ревизией 0 · отмена загрузки роняет ревизию · пол ревизии игнорируется · ревизия прогона - берётся из своей колонки (два места) · корневой гард каталога принимает любой путь · символы - считаются байтами · строка книги пишется после тела · сумма и секрет печатаются · лейбл метрики — - путь вместо маршрута · настройка читается без записи · свип без бюджета на прогон · вердикт - остановленной попытки переписывается резюмом. - -### ⚠ Оспаривание посылки: батарея зоны на этом стенде не может быть зелёной (PD-178) - -Промт требует «0 FAIL». Три теста `internal/runner` падают, и это свойство СТЕНДА, а не пака: на -хосте нет пользовательского менеджера systemd (`/run/systemd/system` есть, PID 1 — systemd **259**, -но `user@1000.service` не поднят, `Linger=no`, sudo нет; попытка поднять менеджер руками выходит -кодом 1 без вывода). Проверено, что это не регрессия: `git archive HEAD platform`, распакованный в -копию вне репозитория, даёт те же три падения с тем же текстом «Failed to connect to user scope bus». -Форма зоны для внешнего предусловия существует и она другая — БД-тесты СКИПАЮТСЯ вслух по -`TM_PLATFORM_TEST_DSN`, и `make check` называет скипы поимённо. Приводить одно к другому сессия не -стала: правка теста ради зелени запрещена (D39.121), решение — оркестратора. - -⚠ Отсюда же ограничение живой пробы, названное прямо: **systemd-клеймы P4 этим паком НЕ пере-мерены** -(cgroup прогона, `--collect`, поведение маркера на трёх исходах). Проба подменяет systemd процессом, -моделируя ровно ту семантику, от которой зависит стоп: юнит переживает создателя, `ExecStopPost` -отрабатывает с `SERVICE_RESULT`/`EXIT_CODE`/`EXIT_STATUS`, `stop` = SIGTERM главному процессу. -⚠ Стенд с P4 при этом изменился: там был systemd 255, здесь 259. - -### Регистр: закрыто, сужено, заведено - -- **Закрыто (4):** PD-72 (потолок тела приехал с маршрутом и своим тестом) · PD-140 (ручки стопа и - резюма) · PD-114 (печать эффективной конфигурации) · PD-169 (бюджет свипа на прогон плюс метрика). -- **Сужено, но открыто (4 + 1):** PD-152 (пользовательский стоп закрыт, ребут — за движком) · - PD-115 (ось наблюдаемости получила эталон; ops и конфигурация — нет) · PD-122 (добавление книги - двигает ревизию области; удаление — по-прежнему нет) · PD-162 (терминальное состояние появилось у - ИНТЕЙКА; клин живого прогона на удалённом каталоге не тронут) · PD-139 дополнена вторым местом. -- **Заведено (14, из них 4 закрыты тем же паком):** PD-172 «файл последним» не записано в контракте · PD-173 у `rejected` нет - причины на проводе · PD-174 `POST /books` отвечает 404 там, где спека кода не даёт (и резюм — - 503) · PD-175 квоты интейка нет · PD-176 `requestTooLarge` не доходит через обёртки · - PD-177 `character_count` для не-UTF-8 — оценка · PD-178 systemd-тесты падают, а не скипаются · - PD-179 `/metrics` без аутентификации (принятый риск) · PD-180 201 несёт `parsing`, а не - `uploading`, плюс отказы интейка вне спеки · PD-181…PD-184 (кросс-семейное ревью, закрыты - здесь же) · PD-185 `finalizing` без писателя. -- Регистр: **185 строк** (`python3 docs/scripts/counts.py`): 132 закрыто · 1 ратификацией · 4 риском · - 48 открыто (1 major — PD-113, прежний). -- Форма файла по пингу №16: 11 секций, построчная форма `| PD-N | … |` сохранена, `--check` зелёный. - Проверено, что ни одна строка не потеряна: список ID до и после перекладки сортированно совпадает - (`diff <(grep -o '^| PD-[0-9]* |' до) <(… после)` — пусто). - -### Вопросы владельцу контракта (через оркестратора) - -1. **Порядок частей формы.** `POST /books` требует, чтобы `file` шёл ПОСЛЕДНИМ (PD-172). Это - свойство потокового приёма, а не прихоть: строка книги пишется до тела, а языки в ней - обязательны. Тем же правилом специфицирована браузерная загрузка S3. Спека молчит — вносить? -2. **Причина отказа.** `rejected` без причины на проводе (PD-173) не даёт экрану различить «файл не - тот» и «наш движок был недоступен», а это разные советы пользователю. Поле заводить не право - зоны; в БД причина есть. -3. **Инстанс без интейка** отвечает на `POST /books` охраняемым 404 (PD-174) — по форме зоны для - несмонтированных маршрутов, но кода этого спека не перечисляет (класс PD-112). Там же: резюм на - деплое без маркер-команды отвечает 503, а 0.2.1 ратифицировала 503 только для СТАРТА прогона. -4. **«Responds immediately; the book enters `uploading`»** недостижимо как класс (PD-180): по HTTP - ответ не может уйти раньше, чем прочитано тело, поэтому 201 несёт `parsing`. Плюс отказы интейка, - которых спека не описывает вовсе: число частей формы, длина текстового поля, порог 413 и обрыв - соединения по дедлайну маршрута. - -### Кросс-семейное ревью (D39.120 п.1а): что нашла ДРУГАЯ модель после трёх своих - -⚠ **Сначала о разрыве, который это закрывает.** Три ревьюера выше запускались без указания модели и -унаследовали модель сессии — то есть все четверо (автор и трое) были одной семьи. D39.120 п.1(а) -просит в панели ≥1 опровергателя ДРУГОЙ моделью на оценочных линзах; механические линзы (сверка -провода, посадки, батарея) от смены модели не выигрывают, а «деньги и гонки» — линза именно -оценочная. Пробел назван владельцем, не найден сессией. Запущены два ревьюера **Fable 5** (слепой -поиск по диффу и атака на денежные инварианты), обоим предписано воспроизводить исполнением. -⚠ Оговорка из той же ноты, п.1(в): Fable — та же семья Anthropic, поэтому семейный слепой участок -этим НЕ снимается; первичны пере-ран и исполнение, а внешняя калибровка — не право сессии. - -**Результат: другая модель нашла 5 дефектов, которые три однофамильца пропустили, и два из них — -уничтожение пользовательских данных.** Все воспроизведены исполнением, все закрыты, каждый с пином и -посадкой. - -| Находка | Чем это было | Как закрыто | -|---|---|---| -| **Грация клейма (10 мин) КОРОЧЕ таймаута задания очереди (15 мин)** — обе константы поставил этот же пак, в разных файлах, не сверив | В окне 10–15 минут свип отдаёт клейм второму парсеру: два `tmctl manifest` на одной директории, проигравший умирает на эксклюзивном локе движка с exit 1 — а exit 1 у движка означает «источник не разобрать». Книга отклонялась ТЕРМИНАЛЬНО, исходник пользователя удалялся. Воспроизведено тестом ревьюера | Грация написана как `jobs.JobTimeout + 5m`, пара утверждается тестом; терминальная запись возможна только пока клейм ЕЩЁ НАШ (`parse_started_at = <наш>`). PD-183 | -| **Один exit 1 движка = удаление загрузки** | Движок маппит на exit 1 ВСЕ свои отказы (его собственный комментарий): полный диск, лок от ручного `tmctl` оператора, украденный клейм. Первый же такой отказ уничтожал файл безвозвратно — ре-парса и удаления в контракте нет | Отказ ИСТОЧНИКА идёт через тот же бюджет попыток, что и поломка деплоя; удаление — только на терминальном шаге. Пин расширен: первый отказ обязан оставить файл на месте | -| **`not_configured` был терминальным** — а конфигурацию не пишет никто, пока развилка не ратифицирована | Значит на СЕГОДНЯШНЕМ деплое любая загрузка умирала бы в течение часа, с удалением, из-за пробела, которого пользователь не видит, а оператор закрывает одним файлом | `not_configured` НЕ становится терминальным никогда: книга ждёт в `parsing` с целым исходником, и это видно метрикой интейка. ⚠ Тест на этом свойстве ПЕРЕВЁРНУТ намеренно — он пинил «пять попыток и отказ», и это и было ошибкой | -| **Устаревший снапшот до-финиширует РЕЗЮМИРОВАННЫЙ прогон** (деньги) | Маркер завершённой попытки остаётся на диске; проход со старым снапшотом закрывал вернувшийся к жизни прогон, и холд попытки 2 выпадал из ОБОИХ списков — ни живых прогонов, ни несведённых. Достижимо стало ровно с появлением резюма | `FinishRun` пишет, только если закрываемая попытка ещё живая, и возвращает признак «закрыл». PD-181 | -| **Стоп закрывал прогон под ЖИВЫМ движком** (деньги) | «Заявка отдана назад» ≠ «процесса нет»: `systemd-run`, убитый после запроса, оставляет движок работать — зона это уже знает и ради этого хранит базовую линию, но стоп-путь читал пустое имя юнита как «его не было» | Ненулевая базовая линия при пустом имени = надгробие спавна; имя юнита детерминировано, платформа спрашивает systemd `Alive` и живому юниту шлёт стоп. PD-182 | -| Заявка на спавн игнорировала висящий интент стопа (мой комментарий утверждал обратное) | Стоп, попавший в окно, пока воркер читает отсчёт книги, не мешал старту движка | `stop_requested_at is null` — в том же CAS; комментарий приведён к коду | -| `TM_PLATFORM_UPLOAD_DEADLINE` мог пережить грацию свипа | Оператор, поднявший дедлайн выше часа, получал удаление строки и каталога из-под ещё идущей загрузки | Пара проверяется на буте | -| Полностью принятая загрузка выбрасывалась, если клиент ушёл до 201 | Телефон уснул — файл и минуты линии в корзину | `StartParsing` идёт на контексте, переживающем запрос | -| Лейбл `method` — токен вызывающего | Для маршрута кардинальность закрыта, для метода — нет | Закрытый список, прочее — `(other)`. PD-184 | -| `runner.Manifest` подвисал/убивал движок на превышении потолка | Правку я собирался внести раньше, и она НЕ доехала (скрипт упал на другой ассерции) — поймано только здесь | Остаток дренится, превышение — честная ошибка | -| Статус `finalizing` без писателя | Слово контракта, которого не пишет ни один путь — тот же класс, что интейк-статусы до этого пака | Строкой PD-185, не кодом: писателя выдумывать не право зоны | - -**Посадок этого круга: 12, поймано 11.** Пережила одна и она названа, а не подчищена: «отказ сносит -каталог ПОСЛЕ строки, а не до» — свойство про ПОРЯДОК двух побочных эффектов, наблюдаемое только -через смерть процесса между ними; конечное состояние в обоих порядках одинаково, и юнит-тестом это -не ловится. Порядок выбран самоизлечивающийся (сначала каталог, потом строка) и обоснован в коде. - -⚠ **Что этот круг говорит о предыдущем.** Три однофамильных ревьюера прошли по тем же файлам и не -увидели ни рассинхрона двух констант, поставленных в одном паке, ни того, что «exit 1 = вина файла» -уничтожает данные на первом же транзиенте. Это ровно та коррелированная слепота, ради которой -D39.120 просит другую модель, — и здесь она подтверждена эмпирически, а не принята на веру. - -### Записка-план после ревью: ID → статус → улика (D39.121) - -| ID | Что | Статус | Улика | -|---|---|---|---| -| R1 | Право на спавн выдаётся законченному прогону (деньги, критично) | ЗАКРЫТО | гард в `RecordSpawn`; `TestTheQueueWorkerDoesNotStartARunThatWasAlreadyStopped`; посадка «снять гард» падает | -| R2 | Рестарт стирает свежий интент стопа (деньги) | ЗАКРЫТО | `RestartRun` берёт строку прогона `for update` и отдаёт `ErrStopRequested`; `TestAStopPressedWhileTheReconcilerRestartsIsNotLost`; посадка падает | -| R3 | Закрытие неспавненного стопа роняет только что стартовавший движок (деньги) | ЗАКРЫТО | `FinishUnspawnedStop` с перепроверкой под замком; `TestARunSpawnedWhileTheSweepWasClosingItIsNotAbandoned`; посадка падает | -| R4 | Спавн не читает интент стопа (деньги) | ЗАКРЫТО | отказ в `spawnAttempt`; тот же пин, что у R1; посадка падает | -| R5 | Интейк и телеметрия голодают на общем дедлайне | ЗАКРЫТО | у каждого прохода свой бюджет (`cmd/tmplatformd/runner.go`); прогон пробы: `sweep_duration_seconds_count{intake}` растёт вместе с `{runs}` | -| R6 | Свип сносит каталог книги, которую не удалил | ЗАКРЫТО | `abandon` возвращается на ошибке удаления; `TestTheSweepNeverRemovesTheSourceOfABookItCouldNotDelete`; посадка падает | -| R7 | Отмена загрузки роняет ревизию библиотеки назад | ЗАКРЫТО | пол `users.library_revision`; `TestCancellingAnUploadDoesNotWindTheLibraryBack`; две посадки падают | -| R8 | Бюджет разбора считает тики, а не время | ЗАКРЫТО | заявка держится; пин расширен проверкой «до грации не делается ничего»; посадка падает | -| R9 | Таймаут задания очереди — молчаливая минута River | ЗАКРЫТО | `JobTimeout` выбран явно; `jobs_test.go` пинит вид и политику заданий | -| R10 | `runner.Manifest` подвисает на превышении потолка | ЗАКРЫТО | остаток дренится, превышение — ошибка; `TestAManifestThatIsNotJSONIsAnError` и соседние гоняют реальный процесс | -| R11 | Ревизия прогона на проводе — из его колонки | ЗАКРЫТО | стор отдаёт книжную на всех путях; `pgstore.TestEveryRunTheStoreHandsOutCarriesItsBooksRevision`, `runs.TestEveryRunCarryingAnswerUsesTheBooksRevision`; посадка падает | -| R12 | Резюм судит деплой раньше владения | ЗАКРЫТО | порядок переставлен; `TestOwnershipIsJudgedBeforeTheDeploymentsHealth` | -| R13 | Резюм второго живого прогона книги → 500 | ЗАКРЫТО | `ErrRunInFlight` → 409; `TestResumeIsRefusedWhenTheBookHasAnotherLiveRun` | -| R14 | Два резюма подряд → 404 проигравшему | ЗАКРЫТО | проигравший перечитывает прогон; `TestTwoResumesOfOneRunTakeOneHoldAndBothAnswer` | -| R15 | Каталог-сирота, если строка исчезла между телом и `parsing` | ЗАКРЫТО | `Accept` убирает за собой и на этой ветке | -| R16 | 201 несёт `parsing`, а не `uploading` | СТРОКОЙ, не кодом | PD-180: по HTTP ответ не может уйти раньше тела; `uploading` наблюдаем параллельным чтением, пин есть | -| R17 | Отказы интейка, которых спека не описывает (части формы, длина поля, порог 413, обрыв по дедлайну) | СТРОКОЙ | PD-180, вопросом владельцу контракта одним пакетом с PD-172/PD-174 | -| R18 | 503 на резюме у несконфигурированного деплоя | СТРОКОЙ | PD-174 расширен: класс тот же, что у ратифицированного 503 на старте | -| R19 | Любая загрузка сегодня приходит к `rejected`, и выхода из `rejected` нет | СТРОКОЙ | PD-175 расширен; корень — развилка `book.yaml`, вопрос выше | -| R20 | Пути книг в ERROR-логах интейка | СТРОКОЙ | PD-139 дополнена; на INFO/WARN идентификаторов нет, и это запинено | -| R21 | `requestTooLarge` не доходит через обёртки | СТРОКОЙ | PD-176: 413 отдаётся штатно, соединение закрывается обычным путём под `ReadTimeout` | -| R22 | Порядок блокировок в `RestartRun` (строка прогона бралась последней) | ЗАКРЫТО попутно | R2 берёт её сразу после книги — документированный порядок восстановлен | -| F1 | Грация клейма короче таймаута задания: свип крадёт парс, книга терминально отклоняется с удалением файла | ЗАКРЫТО | `claimGrace = jobs.JobTimeout + 5m`; `books.TestTheClaimGraceOutlivesTheQueuesJobTimeout`; посадка падает | -| F2 | Один exit 1 движка уничтожал загрузку | ЗАКРЫТО | отказ источника идёт через бюджет; удаление только на терминальном шаге; посадка «отклонять с первого раза» падает | -| F3 | `not_configured` терминален — то есть каждая сегодняшняя загрузка обречена | ЗАКРЫТО (тест перевёрнут намеренно) | `TestABookWithNoEngineConfigurationWaitsRatherThanDies`; посадка «отклонять после бюджета» падает | -| F4 | Устаревший снапшот до-финиширует резюмированный прогон, холд вне списков | ЗАКРЫТО | гард живой попытки в `FinishRun`; `TestAStaleSweepDoesNotReFinishAResumedRunFromTheOldAttemptsMarker`; посадка падает | -| F5 | Стоп закрывает прогон под живым движком после отданной заявки | ЗАКРЫТО | `Alive` по детерминированному имени юнита; `TestAStopDoesNotCloseARunWhoseGivenBackClaimLeftAnEngineRunning`; посадка падает | -| F6 | Заявка на спавн игнорирует интент стопа | ЗАКРЫТО | условие в CAS; `TestAStopCommittedWhileTheWorkerReadsTheMeterStopsTheSpawn`; посадка падает | -| F7 | Дедлайн загрузки мог пережить грацию свипа | ЗАКРЫТО | проверка пары на буте; `config.TestAnUploadDeadlineLongerThanTheSweepsGraceIsRefused`; посадка падает | -| F8 | Принятая загрузка выбрасывалась, если клиент ушёл до 201 | ЗАКРЫТО | контекст переживает запрос; `TestAnUploadSurvivesAClientThatHangsUpAfterTheLastByte`; посадка падает | -| F9 | Лейбл `method` — токен вызывающего | ЗАКРЫТО | закрытый список; посадка падает | -| F10 | `runner.Manifest` подвисает на превышении потолка | ЗАКРЫТО | дренаж остатка + ошибка; правка была задумана раньше и НЕ доехала — поймана только кросс-семейным кругом | -| F11 | Порядок «каталог до строки» в отказе | ⚠ ПЕРЕСМОТРЕНО дофиксом → FP5-3 | диспозиция «порядок самоизлечивающийся» стояла на посылке, которую тот же пак отменил (`not_configured` стал НЕтерминальным), поэтому крэш-окно оставляло книгу в `parsing` навсегда; закрыто отдельной причиной `ErrDirectoryGone` (PD-187), пин и посадка есть | -| F12 | `finalizing` — статус без писателя | СТРОКОЙ | PD-185 | - -### Что НЕ делалось (по промту) - -Эскроу/`uncertain`/`closing` (строка 136) · формула потолка PD-158 и SpendBound PD-159 · ставка -$0.03 · эмиттер-сторона движка и словарь `events.go` · П-2 · платёжный провайдер · удаление аккаунта -(PD-107) · счётчик ревизии ОБЛАСТИ целиком (PD-122 сужена, не закрыта) · правка контракта (четыре -вопроса выше вместо неё). - -## Ратификация приёмкой P4 (оркестратор №15, 09.08) - -**Вердикт: P4 + дофикс + V2 ПРИНЯТЫ и ЗАЛЕНДЕНЫ — D39.123, код `d29e30c` (53 файла, +9867/−170, -только `platform/`).** Три раунда, 13 агентов приёмки; каждое заявление пере-ранено исполнением. - -**Метод и что подтвердилось.** 7-агентный воркфлоу (слепой дифф · опровергатель-Opus D39.120 · -пере-ран батареи · 10 моих посадок · 47 wire-проверок контракта · охотник-деньги · -охотник-systemd с РЕАЛЬНЫМ tmctl из HEAD) → фикс-лист F1–F8 → ре-чек 4 контурами (обе пробы -приёмки, пере-мутации, PD-158/159 адверсариально на Opus, охота по дельте) → V2 → финальный -ре-чек (5/5 пере-мутаций убиты поимённо). Реальный движок принял платформенный argv целиком, -имена полей `status --json` совпали с парсером побайтно, exit-контракт и маркер сошлись; четыре -systemd-клейма пере-мерены; батарея EXIT=0 с живым PG 18.4 под `-race`, скипов 0; тестов -105→261 (+156/−0); дерево на лендинге байт-равно проверенному. - -**Пять MAJOR приёмки и PD-159 селф-ревью — все закрыты с пинами**; разборы в разделах «Дофикс -P4» и «Ре-чек V2» ниже. Несущие: аргумент потолка против кумулятивной семантики движка (F1; -вскрыт тем, что сквозная проба шла на фейке без кумулятива) · дедлок books↔run_attempts 258/300 -с вечным карантином проекции на транзиентном 40P01 (F2; ложно закрытая PD-129 пере-открыта и -закрыта честно) · settle по устаревшему снапшоту (F5) · двойная оплата при отложенном расчёте -(PD-159, найдена селф-ревью сессии — воспроизведена приёмкой на до-фиксном дереве). - -**Ратифицировано (D39.123 п.2):** linger-privilege-модель · **ПОПРАВКА формулы потолка: -`committed + прирост`, БЕЗ reserved (PD-158)** — сессия была права, отказавшись молча исполнять -формулу моего пинга: исполнение обеих против гейта движка показало, что пинг переплачивал -запасом ровно на leftover-reserved · карантин ПРОЕКЦИИ (PD-105-уточнение) · ставка $0.03 · -`default_chapters` = верх шкалы · 503 в контракт (0.2.1; PD-112 закрыт) · PD-114 → задача -«печать эффективной конфигурации» · PD-115 — направление принято. PD-113 — единственный -открытый major, до эмиттера; его цена после PD-158 выросла (стоп по потолку = ровно исчерпание -холда). Движку заведены строки 165 (различимые exit-коды потолка/стопа) и 166 (оценка $/глава). - -**Урок приёмки в обе стороны:** (а) фейк шва обязан моделировать СЕМАНТИКУ чужой стороны, не -только форму — обе жёсткие находки (F1, N1-stop→failed) прошли бы фейком; класс закрыт -кумулятивным ceilingJudge в пинах; (б) моя ошибка нумерации (PD-167 = коммент 00009, не 00011) -поймана сессией — исправлена в строке; (в) две посадки сессии, пережившие первую редакцию пина, -задекларированы, не подчищены — норма «заявление=команда» работает в обе стороны. - -## Дофикс P4 (09.08): фикс-лист приёмки — F1…F8, N1…N4 - -Пак принят УСЛОВНО с пятью MAJOR. Ниже — что сделано по каждому пункту, чем это проверено и что -осталось. Каждое число здесь снято исполнением; команда стоит рядом с числом. - -### F1 [MAJOR, деньги/шов] Аргумент потолка — по ратифицированной формуле - -Приёмка права, и её посылка перепроверена мной на HEAD ОБЕИХ зон, не по памяти: -`backend/cmd/tmctl/invocation.go` объявляет флаг словами «It caps the book's CUMULATIVE -committed+reserved spend, not this run's increment», а `backend/internal/store/ledger.go` `Reserve` -сравнивает `SUM(committed_usd + reserved_usd)` книги с `c.BookUSD` на КАЖДОЙ резервации. Движковая -проба приёмки пере-прогнана в КОПИИ бэкенда (в чужую зону не писал): -`TestAcceptSecondRunDeniedWhenCeilingIsPassedAsIncrement` — при committed $3.00 потолок `3.0` даёт -`ReserveDeniedBook`, `6.0` — `ReserveOK`. - -Сделано: `runs.meter` (`committed`, `reserved`) читается ОДНИМ вызовом `status --json` перед стартом -(`bookMeter`), аргумент = `committed + прирост` (`meter.bookCap`). ⚠ Слагаемое `reserved` из -ратифицированной формулы НЕ добавляется — это названное отклонение в консервативную сторону, разбор -и вопрос оркестратору ниже, PD-158. `reserved_usd` -внесён в аллоулист `ingest.StatusReport` **указателем** — отсутствие ≠ ноль ровно по той же причине, -что и у committed, только ошибка зеркальная: ноль вместо реального резерва даёт потолок НИЖЕ того, на -чём книга уже стоит. Рестарт идёт тем же путём и получает свежий отсчёт (спавн один на все случаи). -Фактически ушедшее значение хранится: `run_attempts.ceiling_arg_micro_usd` (миграция 00011) — оно не -равно `ceiling_micro_usd`, и после того как счётчик книги сдвинулся, восстановить его нечем. - -Пины (посадки — ниже): `TestTheSecondRunOfABookIsGivenTheCumulativeCapAndNotItsOwnIncrement`, -`TestAResumeIsGivenACapComputedFromTheMeterAsItStandsNow`. Оба гоняют фейк `ceilingJudge`, который -судит `--ceiling-usd` **по правилу движка**, включая проход восстановления: открытие книги на запись -обнуляет остаточный `reserved_usd`, после чего потолок на уровне `committed` или ниже отвергается -первой же резервацией. Это и есть ответ на «почему батарея пака этого не поймала»: её фейк лишь -записывал аргумент, а фейк, который не может отказать, не проверяет ничего. Резюмный пин отделён от -арифметики специально: в нём смоделирован ОСТАВЛЕННЫЙ резерв убитого процесса ($0.20), и он -утверждает ДВА факта — что потолок пересчитан по свежему отсчёту и что остаток его не раздул -(PD-158). - -Проба приёмки на этом дереве (её файл, только `Reserved` дописан в отчёты фейка): -`TestAcceptSecondRunCeilingArgument` — **PASS**, `run2 args: … --ceiling-usd 6.000000` при -committed книги $3. - -Устаревшие посылки, названные приёмкой, сняты: -- `runner/engine.go` больше не пишет, что флага нет и что копировать форму из чужого дерева нельзя: - флаг заленден (`0e69bc1`), форма ратифицирована, поэтому `--ceiling-usd {{usd}}` стал **дефолтом** - конфигурации (`TM_PLATFORM_ENGINE_CEILING_ARG` остаётся переопределением для сборки с другим - написанием). `ErrCeilingNotWired` оставлен: тип по-прежнему допускает пустое значение, а пустой - шаблон не должен значить «без потолка». ⚠ Уточнено ревью доков: он не «недостижим» — переменная из - одних пробелов проходит подстановку дефолта и даёт пустой шаблон, то есть отказ в рантайме при - одном WARN на буте (PD-165 — тот же класс, что относительный `TM_PLATFORM_CTL_BIN`). -- `ingest/resync.go` больше не пишет, что у статуса нет фазового сплита: `progress{draft,edit}` - внесён в аллоулист и материализуется `ApplyStatus` теми же четырьмя счётчиками, что и поток. Этим - снято ⚠ затирания фаз резюнком — не гейтом, а тем, что затирать стало нечем. Гейт - «поток идёт → ре-синк не зовём» оставлен, но его обоснование переписано честно: он теперь про - ЦЕНУ (каждый вызов статуса — секунды CPU на пере-нарезку) и про то, что поток свежее. - -### F2 [MAJOR] Инверсия блокировок была жива; транзиентный сбой уходил в карантин - -Порядок написан в ОДНОМ месте и стал глобальным — `pgstore.lockBook`: -`books → runs → run_attempts → account_balances → reservations`. Книга блокируется первой в -`RunSink.Apply`, `RestartRun` и `StartRun` (последний добавлен не «за компанию»: он берёт баланс до -`update books`, и если бы книгу первым брал только рестарт, пара `StartRun ∥ RestartRun` дала бы ту -же инверсию на другой паре строк). `closeReservation` уже брал баланс раньше резервации — проверено, -не тронуто. Собственной сверкой всех транзакций пакета найден ещё один нарушитель — `DeleteBook` -(резервации, потом книга); цикла для него сегодня нет, но именно такое рассуждение записанный -инвариант и должен заменять, поэтому книга взята первой и там. - -Классификация ошибки материализации вынесена в `runs.quarantines` и стала явной: -транзиентные SQLSTATE (`40P01`/`40001`, `pgstore.IsTransient`) и собственная отмена — повтор -следующим свипом; карантин остаётся только логическим ошибкам (пропасть, конфликт payload, битая -строка, строка длиннее буфера). - -Пины. Конкурентный оставлен (`TestAMaterializerAndAReconcilerOnOneRunDoNotDeadlock`, 60×3 через -реальные API), но он **пробует**, а не доказывает: посадка «снять `lockBook` из `RestartRun`» его -ПЕРЕЖИЛА — рестарт по-настоящему берёт замок один раз за прогон, и окно слишком узкое. Поэтому -написаны два пина, утверждающие порядок ПРЯМО: `TestTheMaterializerTakesTheBookBeforeTheAttempt` и -`TestARestartTakesTheBookBeforeTheAttempt` — тест держит замок книги, ждёт, пока операция реально -заблокируется, и спрашивает строку попытки через `for update nowait`. Свободна — значит операция -ждёт книгу, ничего не держа. Занята — значит она взяла попытку по дороге, то есть в том порядке, -который и дедлочит. ⚠ Ожидание блокировки читается из `pg_stat_activity`, а не из `pg_locks`: -ждущий строку ждёт на `transactionid` держателя, а у таких строк `pg_locks.database` пуст, поэтому -запрос по своей базе их не видит (проверено — первая редакция теста падала именно так). - -### F3, F4 [MAJOR, пины] Два свойства, построенных и не запиненных - -- **Нечитаемый отсчёт ОТКАЗЫВАЕТ спавну** (сердце PD-124): `TestAnAttemptWhoseMeterCannotBeReadIsNotStartedAtAll` - — три формы нечитаемости (вызов упал · нет `committed_usd` · нет `reserved_usd`), в каждой - утверждается, что юнит не создан И право на спавн не заклеймлено (`unit_name` пуст), значит свип - повторит; хвост показывает, что после починки движка та же попытка стартует. -- **Устаревший exit-маркер стирается ДО старта**: `TestAStaleExitMarkerIsClearedBeforeTheUnitStarts` - — маркер создаётся до спавна, после спавна обязан отсутствовать, свип обязан оставить прогон живым - и холд открытым. - -### F5 [MAJOR, деньги] Расчёт по устаревшему снапшоту - -`pgstore.ReleaseUnspawned` перечитывает `run_attempts.unit_name` `for update` в ТОЙ ЖЕ транзакции, -что и деньги, и отказывает (`ErrAttemptSpawned`), если юнит есть. Реконсилятор тогда откладывает -расчёт: следующий проход видит юнит в снапшоте и считает по базовой линии попытки. Выбран SQL-гард, -а не перечитывание в Go, потому что гард атомарен с самим возвратом холда. - -Пин: `TestAStaleSnapshotDoesNotGiveBackTheHoldOfAnAttemptThatSpent` — в проходе со старым снапшотом -холд не возвращается целиком, следующий свип списывает ровно потраченные $0.50. -⚠ Проба приёмки `TestAcceptStaleSnapshotReleasesTheHoldOfARunThatActuallySpent` на этом дереве **не проходит как написана** и это не -регрессия: она требует, чтобы деньги разрешились в ТОМ ЖЕ вызове `reconcile`, а исправление -откладывает их на следующий проход. Замеренная разница: было — баланс `10.000000` (весь холд назад, -списано ноль), стало — `7.000000` при `3.000000` в резерве (деньги на месте и видимы), после -следующего свипа — `9.500000`. - -### F6, F7 [MINOR] - -- Относительный `TM_PLATFORM_STATE_DIR` отвергается на буте (`filepath.IsAbs`), пин - `TestARelativeStateDirectoryIsRefusedAtBoot`. -- «Метка давности» ре-синка построена: `runs.last_resync_at` (миграция 00012) пишется КАЖДЫМ - ре-синком, пин `TestAResyncRecordsWhenItWasTaken` (нет метки до первого · метка равна времени - вызова · вторая переписывает первую). ⚠ Названная девиация: **на провод метка не выходит** — в - контракте v0 у прогона поля свежести нет, и заводить его самовольно не право зоны (PD-150). - -### F8 [MINOR, отчёт/регистр] - -Все пять исправлений внесены: числа тестов пересчитаны исполнением и приведены с командой (105 → 261, -удалённых 0); арифметика «36 новых» исправлена на 27 + 9 (после ратификации PD-112) и подтверждена -скриптом по таблице; заявление «каждый из 22 пинов проверен посадкой» снято как непроверяемое -(PD-142); строка PD-105 приведена к коду — карантинится ПОПЫТКА (её проекция), жизненный цикл -прогона продолжается; PD-122 дополнен: ревизия не двигается и на ДОБАВЛЕНИИ книги (`AddBook` пишет -`revision = 0`), класс тот же. - -### Новые строки регистра (N1…N4) и ратификации - -Заведены четыре по фикс-листу плюс две собственные (PD-156 — ниже, PD-151 — числа отчёта): -**PD-152** (стоп → `failed`: реальный `tmctl` ловит SIGTERM и выходит кодом 1, так что -ветка `stopped` достижима только для смерти ОТ сигнала — проба пака показала `stopped` на фейке, -который именно так и умирал) · **PD-153** (грация спавна от `started_at`, а не от времени заявки) · -**PD-154** (`settled_at` может остаться NULL между `Settle` и `MarkSettled`; потребителей нет, -деньги целы) · **PD-155** (смена `StateDir` осиротляет маркеры — строка внесена в `deploy/README.md`). - -Ратификации оркестратора внесены в регистр: **PD-112** закрыт ратификацией (`503` вносится в спеку -правкой владельца контракта) · **PD-114** переформулирован в задачу «печать эффективной конфигурации -на старте с редакцией секретов», env-only остаётся · **PD-115** — направление «по внешнему эталону на -ось» ратифицировано, носитель работы — строка · **PD-129** пере-диспозиция: строка была закрыта -ЛОЖНО, действительно закрыта дофиксом (PD-145) · `default_chapters` = верх шкалы остаётся, кода не -касается · PD-113 остаётся открытым до эмиттера. - -### Батарея и посадки дофикса - -- `make check` с живым PostgreSQL 18.4 под `-race`: **13 пакетов, 0 FAIL**, линтер **0 issues**. -- `go test ./... -count=1 -v | grep -c -- '--- SKIP'` = **0**. -- `make vuln`: **No vulnerabilities found**. -- Тесты: **105 → 261**, удалённых **0** (команда — раздел «Батарея и самопроверка» ниже). -- **Посадки: 24 из 24 пойманы** (скрипт восстанавливает файл после каждой): - `bookCap` возвращает прирост · записывается прирост вместо аргумента · отсутствующий - `reserved_usd` читается нулём · материализатор берёт попытку первой · рестарт берёт попытку первой · - транзиентный сбой карантинится · нечитаемый отсчёт читается нулём · очистка маркера удалена · - расчёт доверяет снапшоту (`Release` вместо `ReleaseUnspawned`) · относительный `StateDir` - принимается · метка ре-синка не пишется · метка пишется только однажды · ре-синк снова кладёт один - агрегат · шаблон потолка теряет дефолт · отказ деплоя проверяется ПОСЛЕ вызова движка · - остаточная резервация раздувает потолок · `DeleteBook` берёт резервации раньше книги · отложенный - расчёт не ограничен верхней границей · транзиентный сбой карантинится (через РЕАЛЬНЫЙ путь) · - повторная заявка перезаписывает базовую линию · рестарт Postgres карантинит проекцию · сброшенное - соединение карантинит проекцию · ретрай пересчитывает переданный потолок · счётчик назад списывает - молча. - ⚠ Две посадки в первой редакции **ПЕРЕЖИЛИ** и это записано, а не подчищено: «рестарт берёт попытку - первой» (конкурентный пин её не ловил — написаны прямые пины порядка) и «метка пишется только - однажды» (тест ставил метку один раз — добавлена вторая проверка). -- Контрактная проба приёмки (47 wire-проверок, `-overlay` поверх пакета `httpapi`) пере-прогнана на - этом дереве целиком: **PASS**, формы не сломаны. -- Гоночная проба приёмки `TestAcceptConcurrentStartRunTakesExactlyOneHold`: **PASS** (8 конкурентных - `StartRun` → 1 прогон, 1 холд) — она проверяет то, что `lockBook` в `StartRun` мог бы изменить. - -### Что нашла собственная сверка диффа (после фиксов, до сдачи) - -Две правки, обе внесены и обе — про то, что дофикс мог испортить рядом: - -1. **`DeleteBook` брал резервации раньше книги** — единственный оставшийся нарушитель порядка, - который я записал как глобальный. Цикла для него сегодня нет (проверено перебором пар: удаление - трогает только ЗАКРЫТЫЕ резервации, а `StartRun` вставляет новую), но инвариант с необъявленным - исключением перестаёт быть инвариантом — книга взята первой и там, и это запинено - (`TestDeletingABookTakesItBeforeItsReservations`; вспомогательный `assertBookFirst` обобщён на - любую вторую строку — попытку или резервацию). -2. **Отказ ДЕПЛОЯ проверялся после вызова движка.** Перенеся чтение отсчёта в начало `spawnAttempt`, - я поставил его ПЕРЕД дешёвыми отказами («нечем записать конец юнита», «нечем передать потолок»), - и инстанс с неполной конфигурацией платил бы секундами CPU движка за каждый прогон на каждом - свипе, чтобы прийти к ответу, зависящему только от конфигурации. Вынесено в `runs.runnable()`, - вызывается первым; пин `TestAMisconfiguredDeploymentIsRefusedWithoutAskingTheEngine` (посадка - «убрать ранний отказ» падает — 15-я в списке выше). - -### Формула потолка: почему `reserved` в неё не входит (PD-158, ратифицировано 09.08) - -Сдавалось как ВОПРОС с консервативным кодом; ре-чек V2 ратифицировал формулу зоны, пере-мерив обе -против гейта движка — та, что с `reserved`, переплачивала запасом ровно на leftover-reserved. - -Первая формула — `committed + reserved + прирост` — читает `reserved_usd` из `status --json`. Но -`store.Open`, путь ЗАПИСИ, которым идёт каждый `translate`, выполняет `recoverReservations` и -обнуляет `reserved_usd` книги ДО того, как будет судима первая резервация -(`backend/internal/store/store.go:88`, тело — `:214`); `tmctl status` идёт read-only и этот проход -намеренно не делает — движок пишет об этом сам (`store.go:108`: «after a run crashes, reserved_usd -stays non-zero until the next WRITE command … status will show this leftover as reserved»). - -Значит цифра, которую платформа кладёт в потолок, к моменту сравнения уже стёрта, и прогон получает -на её величину БОЛЬШЕ, чем его холд: движок останавливается позже, расчёт упирается в потолок холда, -леджер пишет «capped at the hold» — и это читается как перерасход движка, хотя это наша арифметика. -Путь достижим на каждом резюме после падения, потому что падение и оставляет резервацию. - -**Что сделано:** `bookCap` считает `committed + прирост`. Отклонение в КОНСЕРВАТИВНУЮ сторону — более -узкий потолок может только остановить прогон раньше, перерасхода не даёт, — названо в коде и -запинено, и сдано ВОПРОСОМ, а не решением: отклонять ратифицированное молча не право зоны. Ответ -пришёл ре-чеком V2 — формула принята. Цифра `reserved` -по-прежнему читается и обязательна: она и есть доказательство, что отклонение безопасно — на спавне -другого писателя нет (эксклюзивный лок плюс один живой прогон на книгу), значит любой `reserved` по -построению остаток. - -### Найдено при F1 и заведено, а не построено - -**PD-157: дневной потолок книги `--ceiling-usd` не перекрывает.** Ратификация D39.122 говорит это -прямо, и движок требует хотя бы один из `book_usd`/`day_usd` (`backend/internal/config/book.go:250`), -а `book.yaml` пишет ОПЕРАТОР — платформа его не правит (D39.110 §2b) и в `book add` лишь проверяет -наличие. Значит книга с низким `day_usd` останавливает прогон на лимите, которого платформа не -выбирала: движок выходит кодом 1, прогон приезжает `failed`, деньги пользователя целы, причина ему -не видна. В `status --json` дневной фигуры нет вовсе, так что и диагностировать это платформа -сегодня не может. Не строил: фикс-лист этого не просил, а закрывается оно либо проверкой при -заведении книги, либо словом контракта о том, кто владеет потолками `book.yaml` у книг под -платформой — второе не право зоны. - -### Селф-ревью дофикса: три верификатора, что нашли - -Прогнаны три независимых ревьюера по разным линзам — один по фикс-листу и диффу, один НАМЕРЕННО без -отчёта («вне карты»), один по claim-fidelity доков против кода. Все трое работали read-only, мутации -гоняли в копиях дерева. - -**Найдено и ПОЧИНЕНО в этом же дереве:** -1. **PD-159, деньги, major.** Отложенный расчёт прогона оплачивал работу СЛЕДУЮЩЕГО прогона той же - книги, а тот платил за неё ещё раз. Нашли ДВА верификатора независимо, каждый воспроизвёл - исполнением; я воспроизвёл третьим ($2.10 за прогон, стоивший $0.10) ПЕРЕД починкой. Причина — - расчёт читает пожизненный счётчик КНИГИ в момент повтора, а завершённый-но-нерассчитанный прогон - не мешает начать новый. Починено верхней границей `SpendBound` (наименьшая базовая линия среди - попыток книги, стартовавших позже — она снята после остановки этой попытки и до того, как та - что-либо добавила, то есть ТОЧНАЯ граница). -2. **PD-160, пин.** «Транзиентный сбой не карантинит» пинилось на чистой функции; удаление ветки, - которая её ВЫЗЫВАЕТ, переживало батарею. Написан пин, который гонит НАСТОЯЩИЙ дедлок через весь - путь. -3. **PD-161, деньги.** Повторная заявка права на спавн перезаписывала базовую линию — при - `systemd-run`, убитом ПОСЛЕ подачи запроса, попытка платила бы разницу от цифры, включающей её же - работу. - -**Найдено, заведено и НЕ построено** (каждое — строкой с названным лечением): PD-152 расширен живым -замером — штатная перезагрузка закрывает все прогоны как `failed`, потому что `ExecStopPost` -отрабатывает и движок выходит кодом 1 (путь строки 138 покрывает только потерю питания) · PD-162 -удалённый каталог книги заклинивает прогон навсегда с открытым холдом · PD-168 бюджет перезапуска -считается по текущей ставке (замерено: $5.50 вместо $2.50 при удвоении) · PD-163 ревизия области -читается вторым запросом · PD-164 неразбираемый маркер вечно валит реконсиляцию · PD-165 -относительный `TM_PLATFORM_CTL_BIN` и `$` в `quoteArgv` · PD-166 `chunker_version` теряется · -PD-167 расхождение док↔код в комментарии миграции · PD-169 бюджет свипа один на все прогоны · -PD-170 асимметрия `Settle` с PD-97. - -**Найдено в ДОКАХ и исправлено:** тело F1 противоречило собственному разделу PD-158 · описание фейка -`ceilingJudge` отстало от кода · §21 `STACK_DECISIONS` не догнал PD-158 · старый раздел «Сессия P4» -утверждал, что флага потолка в HEAD нет · числа тестов и посадок разъезжались между журналом и -регистром · PD-158 приписывал D39.122 буквальную формулу, которой в решении нет (она из пинга) · -PD-122 требовал «свою миграцию», хотя колонка `users.library_revision` существует с 00001 и не -используется ни одним путём · «`ErrCeilingNotWired` недостижим через конфигурацию» — переменная из -пробелов до него доходит. - -### Ре-чек V2 (оркестратор №15, 09.08): четыре хвоста, три — в новом коде дофикса - -**V2-1 [MAJOR] — моя же классификация была неполна.** `IsTransient` знала только про дедлок и -сериализацию, поэтому ШТАТНЫЙ рестарт Postgres (57P01/57P02/57P03, класс 08, сетевой сброс) всё ещё -карантинил проекцию живого платного прогона НАВСЕГДА — пути снятия карантина в дереве нет. Клейм -PD-145 «карантин остаётся только логическим ошибкам» был в этой части неверен и пере-сформулирован. -Теперь покрыты класс 08, 57P0x, `pgconn.SafeToRetry` и любой `net.Error`; ошибки чтения ФАЙЛА при -этом остаются логическими — `*fs.PathError` не удовлетворяет `net.Error`, поэтому битый журнал -по-прежнему останавливает проекцию, как и должен. Шесть новых кейсов в таблице пина, две посадки. - -**V2-2 [MINOR, деньги] — PD-161 был закрыт наполовину.** В БД значения сохранялись, а движку на -ретрае уходил потолок, пересчитанный по СВЕЖЕМУ счётчику, то есть включающий трату собственного -«призрака»; форензик-колонка 00011 на этом пути лгала (замер приёмки: handed 3.400000 против stored -3.000000 — воспроизведён посадкой на моём дереве до буквы). Теперь ретрай ПЕРЕДАЁТ сохранённый -аргумент. Пин расширен до `handed == stored`, плюс отдельный пин на гард в БД -(`TestASecondClaimOnOneAttemptKeepsWhatTheFirstRecorded`) — после правки в Go посадка на `coalesce` -переставала ловиться, и свойство без собственного пина не считается закрытым. - -**V2-3 [MINOR, доки у денег]** — три текста утверждали отменённую формулу (`reconcile.go` про -рестарт, комментарий поля `Reserved`, строка PD-144) и два — несуществующий отказ загрузчика на -пустое переопределение. Всё приведено к факту. Туда же: **комментарий миграции 00011 нёс отменённую -формулу, а 00009 обещал перечитывание файла, которого тейлер не делает.** Обе исправлены, отпечатки в -`migrations.sha256` обновлены — с явной причиной в шапке файла: обе миграции написаны этим паком, -нигде не применялись, а миграция с лгущим комментарием хуже сдвинутого отпечатка до релиза. -⚠ Ре-чек назвал застывшим комментарий 00011 под номером PD-167, тогда как предмет PD-167 — 00009; -исправлены обе, расхождение в нумерации названо в строке. - -**V2-4 [NOTE]** — счётчик книги ниже собственной базовой линии попытки списывал $0 молча. Клампить в -ноль правильно (платить за подмену БД проекта код решать не вправе), молчать — нет: добавлен WARN, не -INFO, с фактом и без цифр. - -### Что дофикс НЕ трогал (осознанно) - -Ручки стопа/резюма (PD-140) · эскроу и `uncertain` (строка 136) · `POST /books` (П-9) · собственный -счётчик ревизии области (PD-122) · грация от времени заявки (PD-153) — заведены строками, не -построены: фикс-лист их не просил, а расширять пак на приёмке — это то, за что заведён PD-83. - ---- - -## Сессия P4 (08–09.08): раннер — юнит-на-прогон, очередь, реконсилятор, тейлер, первые ручки /v0 - -**Каждое число ниже — с командой, которой получено.** Дерево не коммичено; в индексе ничего не держу. - -### Работа 1 — транзиентный systemd-юнит на прогон - -`internal/runner/runner.go` (юнит), `marker.go` (маркер выхода), `engine.go` (инвокация движка). - -**Привилегия-модель решена ДО постройки, и второй вариант отвергнут по СВОЙСТВУ, а не по вкусу.** -Выбран linger-пользователь: юниты создаются в СОБСТВЕННОМ менеджере платформы, новых привилегий в -рантайме ноль. Polkit-вариант research/25 непригоден: **polkit авторизует ГЛАГОЛ, а не свойства** — -`StartTransientUnit` отдаёт выбор `ExecStart=`/`User=` вызывающему, а транзиентный СИСТЕМНЫЙ юнит без -`User=` идёт от root, то есть правило выдаёт сервису root и сузить это нечем. Плюс до systemd v257 в -запрос авторизации не попадает даже ИМЯ юнита (systemd issue #17224 → PR #34651, влит 09.10.2024), -так что на стенде (systemd 255, `systemctl --version`) «узкое правило» не узкое вовсе. Разбор — -`STACK_DECISIONS` §15. - -**Замерено на стенде, не выведено из доки** (все команды — `systemd-run --user`, стенд без root): - -| Свойство | Как замерено | Результат | -|---|---|---| -| Юнит переживает того, кто его создал | спавнер-скрипт стартует юнит и выходит | `ActiveState=active`, маркер `success` | -| `--collect` ⇒ состояние systemd не истина | `systemctl --user show` после выхода | `LoadState=not-found`, `ExecMainStatus=0` для прогона, вышедшего с кодом **3** | -| Маркер несёт исход | `ExecStopPost` на трёх исходах | `exit-code/exited/3` · `success/killed/TERM` · `oom-kill/killed/TERM` | -| **Лимиты в `app.slice` НЕ применяются** | `cat app.slice/cgroup.subtree_control` | **пусто**; лист без `memory.max`; процесс, потрогавший 400 МиБ, пережил `MemoryMax=64M` | -| Лимиты в своём срезе применяются | то же в `tm-runs.slice` | `memory.max=67108864`, `pids.max=32`, тот же процесс — `oom-kill`, `Memory peak: 64.0M` | -| `%`-спецификаторы в `--property=` не раскрываются | `ExecStopPost` с `100%_done`/`%n` | доехали буквально ⇒ экранировать `%` не нужно и было бы неверно | -| `ProtectHome=yes` прячет `/run/user/` | проба под юнитом | `Permission denied` ⇒ своя же шина недостижима; `ProtectHome=tmpfs`+`BindPaths` ⇒ сокет виден | -| Шине хватает `DBUS_SESSION_BUS_ADDRESS` | `env -u XDG_RUNTIME_DIR` | работает; без обоих — `Failed to connect to bus` | - -**Ответ PD-13 живёт в cgroup ПРОГОНА и измерен.** ⚠ И там же найдено собственным флейком: `MemoryMax` -БЕЗ `MemorySwapMax=0` не ограничивает прогон — ядро выдавливает страницы в своп (у среза -`swap peak: 692.5M`), процесс выживает и тормозит до скорости диска. Для перевода это хуже отказа: -тормозящий часами прогон продолжает платить за каждый прошедший вызов. Снято: `MemorySwapMax=0` идёт -вместе с `MemoryMax` (`runner.go`), три прогона пина подряд зелёные, три посадки «убрать своп-лимит» -подряд красные. - -Пиннинг версии (строка 139): `run_attempts.engine_binary` пишется ДО создания юнита, новая попытка -его НАСЛЕДУЕТ, резюм исполняет именно его, а переход на другую сборку требует явного -`TM_PLATFORM_RESUME_MAY_CHANGE_ENGINE`. ⚠ Первая редакция закрывала эту строку НАПОЛОВИНУ — путь -читался каналом ремонта и не исполнялся резюмом; поймано моей же пост-сверкой диффа с промтом, не -батареей (обе половины компилировались и всё было зелёным). PD-143. - -### Работа 2 — очередь River + воркер - -`go.mod`: `github.com/riverqueue/river v0.42.0` + `riverdriver/riverpgxv5` (пин из `STACK_DECISIONS` -подтверждён). `internal/runs/queue.go`. - -**Холд берётся ДО спавна и в ОДНОЙ транзакции с созданием прогона, попытки и записи очереди** -(`pgstore.StartRun`, `runs.go:66`): холд без прогона — деньги, зарезервированные ни за чем; прогон без -холда — трата незарезервированного; запись очереди без обоих — воркер, спавнящий движок против книги, -у которой нет бухгалтерии. Очередь вставляет своё задание через колбэк с той же `pgx.Tx`. - -**Гард «один живой прогон на книгу» — исполнением:** частичный уникальный индекс -`runs_one_live_per_book` мапится в доменную `ErrRunInFlight` (не сырой SQLSTATE), ручка отдаёт 409. -Живая проба: второй `POST .../runs` на ту же книгу → **409**, `Reserved` не сдвинулся. - -`MaxAttempts: 1` у задания — намеренно против рефлекса очередей: повтор здесь не доделывает работу, а -спавнит ВТОРОЙ движок; восстановление — работа реконсилятора. Живая проба: повторная доставка задания -на живой прогон не создала второго юнита (`len(started)` = 1). - -### Работа 3 — реконсилятор - -`internal/runs/reconcile.go`. На КАЖДЫЙ выход юнита — через **ExecStopPost-маркер**, не D-Bus: сигнал, -посланный в момент, когда платформа лежит (а она лежит несколько раз в неделю по замыслу), теряется; -файл — нет. На БУТЕ тот же свип перезапускает прерванные прогоны (строка 138): истина — каталоги книг -и Postgres, systemd спрашивается только «жив ли юнит», и это улика, не вердикт. - -Разграничитель «не стартовал ещё» / «умер без слова» — время (`spawnGrace` 60 с). Перезапуск даёт -новой попытке ОСТАТОК бюджета (`budget − RunSpent`), иначе один прогон потратил бы потолок дважды; -если остатка нет — `paused`/`credit_exhausted`, не `failed`. - -**Расчёт — из фигуры движка, прочитанной ПОСЛЕ выхода процесса** (`status --json`, ратифицированный -канал ремонта; лока нет, гадать не о чем). Не прочиталась — резервация остаётся ОТКРЫТОЙ и попадает в -`UnsettledRuns` для следующего свипа. ⚠ Эскроу-половина research/25 (write-ahead intent, `uncertain`, -`closing`) НЕ строилась: это строка 136 и её собственный промт. - -**Резюнк — честно редкий и честно грубый:** по умолчанию 5 минут (`TM_PLATFORM_RESYNC_EVERY`), и -только там, где чинить нечего — курсор не двигался ЛИБО материализация в карантине. Причина в цене: -каждый вызов заново ингестит и режет исходник (1.4–1.5 с CPU на книге 23 МБ, строка 100). - -### Работа 4 — тейлер `events.jsonl` - -`internal/ingest/tail.go`. Курсор `(engine_run_id, seq)` коммитится в ОДНОЙ транзакции с эффектом -(`pgstore.RunSink.Apply`), байтовое смещение — хинт. Файла ещё нет (эмиттер = строка 103) — тейлер -спокойно ждёт (`ErrNoJournal` не ошибка свипа). - -**PD-105 ратифицирован этим паком и реализован:** дубль `seq` — НОРМА, пропускается идемпотентно и -без фатала; тот же `seq` с ДРУГИМ payload — `ErrPayloadConflict` → карантин ПРОЕКЦИИ (не прогона: -движок тратит уже зарезервированные деньги, и наша неспособность читать его журнал — не повод это -выбросить). Пропасть остаётся ошибкой. Строка PD-105 реестра обновлена. - -Частичная последняя строка не читается как событие (писатель ещё пишет). Журнал пер-книжный и -append-only, поэтому резюм дописывает ВТОРОЙ `hello`: читатель ведёт область и не судит чужие строки. - -### Работа 5 — ручки `/v0` по контракту 0.2.0 - -`internal/httpapi/v0.go`: `GET /books` · `GET /books/{bookId}` · `GET /books/{bookId}/run-options` · -`POST /books/{bookId}/runs` · `GET /usage`. Прогресс/статус прогона отдаётся внутри карточки книги — -отдельной ручки прогона в спеке НЕТ, и выдумывать её я не стал. - -`CeilingBounds`: максимум = `Balance` КАК ЕСТЬ (холд — дебет в момент взятия), подрезан И остатком -книги; `max_chapters: 0` легален. `default_chapters` = верх шкалы — продуктовая политика платформы -(D39.115 §4), компромисс назван вслух в коде и вынесен вопросом ниже. - -**Интейк:** дев-инструмент `tmplatformctl book add` (Go-подкоманда, а не шелл-скрипт: тестируется). -`POST /books` (multipart) НЕ взят — обоснование строкой **П-9** зонного бэклога. - -**Пересчёт «главы→доллары»** — константа платформы `$0.03`, провенанс: exp08 v2 ($10.94 на 500 глав = -$0.0219) через ревизию D30.4 (+15–25% → $0.0252–0.0274), округление ВВЕРХ. Движковой поверхности -оценки не выдумывал; потребность — строка **П-10**. - -### Работа 6 — регистр - -Закрыты: **PD-43** (денежный контур получил вызывающих) · **PD-81** · **PD-82** · **PD-97** · -**PD-99** · **PD-105**. Заведены **PD-108…PD-116** (из них PD-108/110/111/116 — дефекты, найденные и -закрытые внутри этого же пака; PD-112…115 открыты вопросами). Строки эмиттер-стороны (PD-60/61) не тронуты. - -### Сквозная проба на боевом бинаре (не тестами) - -Поднят `tmplatformd` против живого PG, с фейковым `tmctl` ($0, говорит ровно две поверхности: -инвокацию с потолком и `status --json`). - -| Шаг | Наблюдение | -|---|---| -| `book add` + `GET /v0/books` | книга в библиотеке, `status: not_started` | -| `GET run-options` | `min 1 / max 333 / default 333` — $10 / $0.03 = 333, подрезано 500 главами книги | -| `POST runs` (100 глав) | `202`, форма `Run` по спеке; юнит `tm-run--1.service` **active running** | -| потолок, дошедший до движка | `--ceiling-usd 3.000000` = 100 × $0.03 | -| второй `POST` на ту же книгу | **409** | -| `/v0/usage` при открытом холде | `remaining_percent: 70` (холд — дебет) | -| прогресс из журнала | `draft 4/10, edit 0/10` — пофазно, без затирания резюнком | -| выход юнита | книга и прогон → `ready`, `finished_at` проставлен | -| расчёт | грант $10 → движок отчитался $0.42 → баланс **9.580000** (не потолок $3) | -| гигиена логов | `grep -c 'ceiling-usd\|3.000000\|bk_' daemon.log` = **0** | -| systemd после `--collect` | юнитов **0** | - -**Ратифицированное свойство D39.106 — прогон переживает деплой — проверено убийством платформы:** -`pkill -9` по имени процесса → API отвечает `000`, юнит `active running`, журнал за 6 с вырос -`10 → 13` строк. Платформа поднята заново → карточка показала `draft 21/300` (догнала всё, что было -записано, пока её не было), через 6 с — `24/300`, юнит ОДИН, в логе новой платформы -`grep -c 'run unit started'` = **0** (второго движка не создано). Остановка через systemd → маркер -`success/killed/TERM` → статус **`stopped`** (не `failed`). - -### Батарея и самопроверка - -- `make check` с `TM_PLATFORM_TEST_DSN` (живой PostgreSQL 18.4, `-race`): все 13 пакетов зелёные, - линтер **0 issues**. Прогнана дважды подряд. -- Скипы: `go test ./... -count=1 -v | grep -c -- '--- SKIP'` = **0**. -- `make vuln`: **No vulnerabilities found**. -- Дифф `^func Test` ИСПОЛНЕНИЕМ: **105 → 261**, удалённых **0**, добавленных **156** (число после - дофикса 09.08). Команда, которой считано: - `H=$(git grep -h '^func Test' HEAD -- 'platform/**/*.go' | sed 's/(.*//' | sort -u)` · - `T=$(grep -rh '^func Test' platform --include=*_test.go | sed 's/(.*//' | sort -u)` · - `comm -23 <(echo "$H") <(echo "$T")` — пусто, то есть удалённых ноль. - ⚠ **Исправлено приёмкой (PD-151):** здесь стояло «105 → 203, +98», и это было неверно на момент - написания — приёмка пересчитала 242/+137/−0, дофикс с ре-чеком довели до 261/+156/−0. Шапка журнала при этом - была права. - ⚠ Две мои промежуточные редакции этого счёта были неверны, и это записано, а не подчищено: - сначала pathspec не тот, потом грep без `*.go` — второй ловил КОПИЮ теста, вставленную в этот же - журнал приёмкой P1 (`platform-PROGRESS.md:683`), и рисовал ложное «удалён - `TestHoldAndSettleOnTheSameAttemptDoNotDeadlock`». Тест на месте: `credits_test.go:308`. -- **Посадки (мутации): 12 из 12** в самопроверке; **139 в аудите ревью** — 107 поймано, 32 пережили - (8 не ослабления), по ним написано **22 новых пина**. ⚠ **Заявление «каждый проверен своей - посадкой» СНЯТО приёмкой (PD-142/PD-151):** прогонов посадок было девять (9/10, затем 9/9), и - поимённого соответствия пин↔посадка сессия не вела — 22 ими не покрываются. Итого посадок, - проверенных поимённо: **33 моих в паке** (ниже) и **24 в дофиксе** (раздел «Дофикс P4»), все - пойманы. Каждая восстанавливалась после прогона: - холд вне транзакции допуска · мапинг `runs_one_live_per_book` · high-water mark в синке · - `greatest` у spend · payload-конфликт · детект пропасти · частичная строка как событие · - область чужого потока (обе стороны: `mine := true` и `mine := false`) · срез прогона · - половина шкалы (та самая ошибка «минус Reserved») · грация спавна · потолок как `failed` · - argv в INFO-логе · `MemorySwapMax` · compare-and-set права на спавн. - ⚠ **Одна посадка ПЕРЕЖИЛА первую редакцию пина** и это записано, а не подчищено: high-water mark - проверялся событием `progress`, а `progress` — присваивание, то есть идемпотентен сам по себе. - Настоящий пин — считающий эффект (`unit_done`): `TestARedeliveredCountingEventDoesNotCountTwice`. - -### Девиации и то, чего не сделал - -1. **`503` на старте прогона не входит в перечисленные спекой статусы операции** — PD-112, вопрос - владельцу контракта. Каждый разрешённый код в этой ситуации соврал бы. -2. **Стоп по потолку сегодня отдаётся как `failed`** — PD-113, контрактно видимо. Движок возвращает - потолок ошибкой (`errReserveCeiling`, сверено в HEAD), события потолка нет (строка 103). Ветка под - событие построена и запинена; выдумывать порог «сколько процентов потолка = стоп» я не стал. -3. **`POST /books` не взят** — П-9, с обоснованием. PD-72 закрывается вместе с ним. -4. **Эскроу/`uncertain`/`closing` не строились** — строка 136, следующий денежный промт. -5. **Стоп/резюм прогона как ручки контракта** не в списке работ промта — не строились; банк-стоп - заканчивает попытку и прогон, резюм = новый прогон. -6. **`Supervisor.Status` дев-пути починен** (PD-108) — он в моей зоне и это мой канал ремонта. - -### Адверсариальное ревью (author≠reviewer), и почему это главный результат пака - -Четыре независимых верификатора, ни один не читал отчёт (его на тот момент не существовало): -(1) промт+дифф вслепую · (2) охота за дефектами ВНЕ карты · (3) сверка провода с openapi 0.2.0 · -(4) сила пинов посадками. Каждому запрещено писать в репозиторий; эксперименты — в копиях. - -**Нашли три ДЕНЕЖНЫЕ ошибки и три запирающие прогон. Все закрыты в этом дереве, каждая с пином.** - -| Находка | Чем это было | Как закрыто | -|---|---|---| -| **PD-124** (major) | Расчёт брал `committed_usd` — ПОЖИЗНЕННУЮ сумму КНИГИ (`SUM(committed_usd) … WHERE book_id`) — как трату прогона. Второй прогон книги оплачивал первый заново; после того как сумма книги перерастает потолок, каждый прогон стоит ровно свой потолок. **Воспроизвели ДВА верификатора независимо** | Попытка пишет базовую линию книги ДО старта (миграция 00010) и платит разницу; линию не прочитать = не стартуем | -| **PD-125** (major) | Перезапуск при отложенном расчёте брал ВТОРОЙ холд и терял первый навсегда: списки его не находили | Перезапуск спрашивает `AttemptReservationOpen`; список несведённых ключуется на завершённости ПОПЫТКИ | -| **PD-126** (major) | Юнит, который не удалось создать, оставлял «право на спавн» — и каждый свип перезапускал прогон, съедая потолок по свипу за раз | Неудавшийся `Start` снимает право; повторяется та же попытка | -| **PD-127** (major) | Одна нечитаемая строка журнала возвращала ошибку ДО чтения маркера: прогон навсегда `translating` с висящим холдом | Любая ошибка журнала = карантин ПРОЕКЦИИ, жизненный цикл продолжается | -| **PD-128** | Грация спавна мерилась от старта ПРОГОНА ⇒ у перезапущенной попытки её не было | Мерится от старта попытки | -| **PD-129** | Инверсия порядка блокировок `books`↔`runs` между материализатором и финишером ⇒ взаимоблокировки | Книга блокируется первой везде | -| **PD-130/131** | Любая ошибка чтения журнала выглядела как «догнали»; хендшейк не обязан был нести `seq 1` | Только настоящий EOF; `seq != 1` = плохой хендшейк | -| **PD-132** | Холд прогона, который так и не стартовал, не возвращался | Нет линии и нет юнита ⇒ холд возвращается целиком | -| **PD-133/134/135** | Инстанс без движка не отдавал даже библиотеку; пиннинг версии писался и не читался; прерванный прогон вставал `paused` без причины | Чтения монтируются отдельно; `EngineBinary` читается и используется; `PauseRun` вместо `FinishRun` | -| **PD-117…121** | Потолок ниже минимума схемы отвечал 409 вместо 400 · `Usage.paused_reason` недостижим через путь реконсилятора · карточка несла ревизию ПРОГОНА (отстающую) · чужой курсор пагинации принимался · «exhausted» при остатке, на который прогон стартует | Все пять закрыты, каждая с пином | -| **PD-136/138** | Деплой-юнит нёс обе диспозиции сразу; `go.mod` не тидинут | Переписано; `go mod tidy` | -| **PD-142** | **Аудит силы пинов: 139 посадок, 107 поймано, 32 пережили** (8 из них — не ослабления: эквивалентный код или страховка DDL). Пережившие — не дефекты кода, а отсутствующие пины, часть на свойствах, объявленных закрытыми | 22 новых пина написаны; ⚠ «каждый проверен своей посадкой» СНЯТО приёмкой (PD-142/PD-151) — прогонов посадок было девять. Самые весомые: блокировка строки попытки под КОНКУРЕНЦИЕЙ (восемь горутин на одно событие — последовательная доставка посадку не ловила) и **CSRF на контрактной поверхности** (снятие переживало всё, а это старт платного прогона с амбиентной кукой) | - -**Оставлено открытым и названо:** PD-122 (`Library.revision` не монотонна при удалении книги — сегодня -недостижимо, ручки удаления нет; правильное решение — свой счётчик области, это своя миграция и -несколько путей записи, поэтому НЕ сделано, а объявлено гейтом) · PD-137 (упорядочение на -пользовательский менеджер — UID site-specific, инструкция в шапке юнита) · PD-139 (пути книг в -ERROR-логах — диспозицию надо принять осознанно) · PD-140 (`Service.Stop` не подключён: ручек -стопа/резюма в скоупе промта не было) · PD-141 · PD-112/113/123. - -**Что ревью подтвердило, а не опровергло:** привилегия-модель (с одним уточнением, принятым: -«никакое правило не сузит» верно для ТРАНЗИЕНТНЫХ юнитов; шаблонный системный юнит с фиксированным -`User=`, запускаемый `StartUnit`, — вариант, которого мой разбор не касался) · арифметика -`CeilingBounds` (баланс КАК ЕСТЬ, без второго вычитания холдов — проверено на живом PG пятью -раскладами) · отсутствие кросс-аккаунтных чтений и стартов · инвариант `balance == SUM(ledger)` -(сломать не удалось) · правила тейлера по областям и курсору. - -**Возражение ревью, которое я принял только частично.** Верификатор предложил различать стоп по -потолку через `ceiling_pct` из `status --json`. Не взял: `ceiling_pct` считается против -`book_ceiling_usd`, то есть против потолка ИЗ `book.yaml`, а наш потолок приходит аргументом прогона -(строка 145), которого в HEAD НА ТОТ МОМЕНТ не было — значит смысл поля зависел от ещё не залендённой -формы (флаг заленден 09.08, `0e69bc1`; альтернатива от этого не становится верной — `ceiling_pct` -по-прежнему считается против потолка из `book.yaml`, а не против переданного аргумента). Взять его -сейчас значило бы построить дискриминатор на поведении, которого я не могу проверить. Записано в -PD-113 как названная и оценённая альтернатива. - -**Повторная сквозная проба на боевом бинаре после починок** (чистая база, фейковый движок с -НАКОПИТЕЛЬНЫМ счётчиком книги, как у настоящего): грант $10.00 → прогон 1 (движок насчитал $0.40) → -баланс **9.600000** → прогон 2 на ТОЙ ЖЕ книге (счётчик книги $0.40 → $0.80) → баланс **9.200000**, -резервов ноль. До починки второй прогон списал бы $0.80. - -### Вопросы (канал вопросов промта; НЕ тихая интерпретация) - -1. **`503` вне перечня спеки** (PD-112) — вносить в спеку или назвать другой код? -2. **`default_chapters` = верх шкалы.** Когда книга дороже баланса, верх шкалы резервирует ВЕСЬ - баланс под одну книгу, и вторую начать нельзя (D39.110 §2в). Альтернатива — доля от максимума; - какая именно — вопрос продуктовый, не инженерный. -3. **Конфигурация (вопрос ВЛАДЕЛЬЦА 08.08, PD-114).** Ответ по существу: единого stdlib-пути в Go - нет; мейнстрим — либо 12-factor «только окружение» (наш выбор, записан в доккомменте `config.go`, - деплой уже использует systemd-нативные `EnvironmentFile=`+`LoadCredential=`), либо слоёная - `флаги > env > файл > дефолты` (koanf/viper). Ниже нормы у нас другое: **27 переменных** - (грепнуто), **ноль флагов** у демона, **нет печати эффективной конфигурации** при старте. - Вопрос владельца при этом нашёл реальный дефект в этом же паке: дефолт «$/глава» лежал в двух - местах — исправлено, резолвится только в `config`. -4. **Абстрактный вопрос владельца, и он подтверждается фактом (PD-115).** `ENGINEERING_STANDARDS` §2 - называет внешнюю версионированную базовую линию ровно для ОДНОЙ оси — безопасности (ASVS 5.0 L2 + - API Top-10 2023). Всё остальное — собственная проза зоны. Разница видна эмпирически: PD-57/PD-58 - нашлись именно сверкой с RFC 9700/9207 и NIST SP 800-63B. Там, где эталона нет, сверять не с чем — - грепнуто на 08.08: метрик и трейсинга **ноль**, процедуры бэкапа/восстановления в `deploy/` нет, - SLO не заданы. Предложение: §2 получает по эталону на ось; ратификация — оркестратора. +*(Записи паков P4–P6 с дофиксами (08–15.08) — в [archive/platform-PROGRESS-P4-P6.md](archive/platform-PROGRESS-P4-P6.md); вынесено 16.08 по слову владельца.)* ## Закрытые эры P0–P3 — в архиве