textmachine/platform/docs/archive/platform-PROGRESS-P4-P6.md

231 KiB
Raw Permalink Blame History

Архив журнала зоны «Платформа» — записи паков P4P6 с дофиксами (0815.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 jsonpaused/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/callbackERROR 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)

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 при обоих гейтах окружения):

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: вернуть перечит причины на одну ветку TestACeilingSurvivesAMarkerThatCannotBeReadfailed/пусто вместо 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 без намерения = прерывание и перезапуск · 1019 = полоса. 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/5stopped, второй попытки нет
Дев-вход + сид целиком 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.5net/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 showVersion=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 (TestTheCardsRevisionIsTheBooksAndNotTheRunsTestTheCardProjectsTheRevisionTheStoreGaveAndInventsNone, 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.modgo 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 секций (открытые по весу · принятый риск · закрытые ратификацией · закрытые по эрам P1P5), построчная форма сохранена 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 мин)обе константы поставил этот же пак, в разных файлах, не сверив В окне 1015 минут свип отдаёт клейм второму парсеру: два 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) → фикс-лист F1F8 → ре-чек 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.0ReserveOK.

Сделано: 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 дописан в отчёты фейка): TestAcceptSecondRunCeilingArgumentPASS, 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 (0809.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/<uid> проба под юнитом 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.41.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 (+1525% → $0.02520.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-<id>-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 Инверсия порядка блокировок booksruns между материализатором и финишером ⇒ взаимоблокировки Книга блокируется первой везде
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 получает по эталону на ось; ратификация — оркестратора.