textmachine/platform/docs/platform-PROGRESS.md

1832 lines
255 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

# Журнал зоны «Платформа»
> Весь прогресс платформы — ЗДЕСЬ (решение владельца 04.08): пинги, итоги сессий, открытые
> вопросы, предложения на ратификацию. В `docs/PROGRESS.md` платформа не пишет; оркестратор
> читает этот журнал при каждом лендинге зоны (свип «решений владельца» — норма D39.99 п.4).
## Текущее состояние
- **Запись оркестратора, 15.08 — P6 + ДОФИКС ПРИНЯТЫ И ЗАЛЕНДЕНЫ (D39.132).** Приёмка двумя
раундами: панель 5 адверсариальных верификаторов по P6 (две линзы — другой моделью; исполнением,
включая живой демон и живой PG) → фикс-лист ФП-1…ФП-8 → дофикс → финальная верификация
исполнением: батарея `make check` под `-race` с живым PG и ОБОИМИ гейтами пере-прогнана
оркестратором — EXIT=0, 17 пакетов, скипов 0; тестов **416** (`grep -rh '^func Test' platform
--include='*_test.go' | wc -l`; HEAD = 359); деньги пере-считаны из сырого леджера стендовой БД
двумя путями (сошлись до цента); живая проба рендера пере-снята своим tmctl из HEAD; 2
собственные посадки приёмки на дофикс — обе пойманы (`TestAFlaggedBookStillAnswersAboutItsMoney`
· `TestEachOfTheThreeBlockersAloneKeepsABookOutOfTheMigrationList` — вторая до дофикса переживала
всю батарею). **Исправлено оркестратором при лендинге:** откачено переименование закрытой строки
PD-198 → PD-199 и удалена заведённая под PD-198 строка-дубль с ложным обоснованием (регистр
246 → 245 слиянием; «ID стабилен навсегда»); PD-200 перенесена в «Закрытые — эра P6», PD-180 —
в «Открытые — info». Ратификации и диспозиция вопроса PD-241 — тело D39.132.
- **ДОФИКС P6 ЗАКРЫТ — дерево передаётся на лендинг одним пакетом с P6 (15.08).** Фикс-лист приёмки
ФП-1…ФП-8, раздел «Дофикс P6» ниже. Ни одна находка не опровергнута, но две подтвердились не так,
как их описала приёмка. Самая тяжёлая — денежная и на ОБЫЧНОМ пути: движок отвечает exit 2 из
`status --json` про любую книгу с помеченной единицей, а платформа читала это как «движок не
ответил», из-за чего расчёт такого прогона откладывался вечно и следующий прогон книги не
стартовал. Плюс аддендум владельца 15.08: `day_usd` убран из ПРИМЕРА шаблона книги. Батарея под
`-race` с обоими гейтами: EXIT=0, 17 пакетов, скипов 0, линтер 0 issues, `make vuln` чист,
**359 (HEAD) → 416** тестовых функций — счёт командами в разделе. Посадки 15 из 15.
- **P6 ПОСТРОЕН — дерево передаётся на лендинг (14.08).** Потребительская половина шва эмиттера
(П-15) · интейк формы Б (П-14) · дев-стенд с сидом и дев-входом (П-16) · порядок апгрейда движка
против деадлока v15 · попутные PD · гигиена зонных доков. **PD-113 закрыт — открытых major в
регистре НЕТ.** Батарея на стенде `~/.local/pgsql` (порт 55433) под `-race`: EXIT=0, 17 пакетов,
линтер 0 issues, скипов 0, `make vuln` чист, тестов зоны **359 → 401** (числа P6; счёт — команды
в разделе «Дофикс P6», прежний базис «396 → 403» не воспроизводился, PD-234). Посадки:
20 из 20 в первом круге (три переписаны после разбора переживших) + 6 из 6 на находки
адверсариальной панели, одна названа НЕПИНЯЕМОЙ по построению. Живые пробы на настоящем `tmctl`
и на фейке движка с правилом потолка — разделы ниже.
**Три вопроса на ратификацию оркестратору** (подробности в разделе P6): внутренняя причина
`daily_ceiling` НЕ проецируется на провод (контракт знает одно значение — PD-199, спек-правка
за S4) · направление `PLATFORM_DIRECTION` §3 предложено ПЕРЕ-ПОДПИСАТЬ (oapi-codegen и sqlc не
исполнены, срок «до первого хендлера» пройден) · батарея получила ВТОРОЙ гейт окружения
(`TM_PLATFORM_TEST_ENGINE_BIN` + `_BOOK_TEMPLATE`), без него один тест честно скипается.
- **P5 ПОСТРОЕН и ДОФИКШЕН по приёмке — дерево передаётся на лендинг (11.08).** *(→ принят в три раунда и заленден D39.130, `69d485a` — запись оркестратора 14.08 ниже.)* `POST /v0/books`
потоково с пер-маршрутным потолком тела и своим дедлайном чтения · статусы
`uploading → parsing → not_started | rejected` получили писателей, разбор — $0-команда движка
`tmctl manifest` · `POST /v0/runs/{id}/stop|resume` с намерением стопа в Postgres ДО сигнала ·
метрики Prometheus на отдельном слушателе · печать эффективной конфигурации.
- **Приёмка 11.08 (8 находок) → дофикс → кросс-семейное ревью дофикса (5 находок) → ре-чек
оркестратора (3 находки, 2 high) → ТРЕТИЙ РАУНД ЗАКРЫТ (14.08)**: разделы «Третий раунд»,
«Дофикс-2» и «Дофикс P5» ниже, движение — записками-планами. Батарея на стенде `~/.local/pgsql`
(порт 55433) под `-race`: EXIT=0, 16 пакетов, линтер 0 issues, скипов 0, `make vuln` чист, тестов
**261 → 359**. Посадки: 10/10 · 6/6 · 6/6 (две пережившие за все раунды — оба раза виноват был
слепой пин, переписан пин, не мутация); списки — в журнале.
**Тулчейн 1.26.6 НА РАТИФИКАЦИЮ** *(→ ратифицирован D39.130)*, и вчерашний клейм про него был ЛОЖЕН: гейт принимал 1.26.5
(регекс вместо сравнения). Теперь floor держат два механизма — `make version-check` сравнивает
версии, `go.mod` несёт `toolchain go1.26.6`, который читает всякая сборка; при `GOTOOLCHAIN=auto`
хост скачает нужный тулчейн, при `=local` остановится. Само сравнение запинено (`internal/gates`).
**ВОПРОС на ратификацию — кто пишет `book.yaml` при интейке**: половина П-9 стоит на нём, раздел
«Развилка» ниже. *(→ решено D39.130: бета = форма Б, стройка = П-14; движковая форма В = строка 170 единого.)*
- **Запись оркестратора, 14.08 — P5 ПРИНЯТ И ЗАЛЕНДЕН (`69d485a`, D39.130).** Финальная
верификация исполнением: батарея EXIT=0 на том же стенде · **гейт версий живьём отказывает
1.26.5 и `go1.27rc1`, принимает 1.26.6 и 1.26.10** (sort -V честный) · `toolchain go1.26.6`
в go.mod · сентинел `.tmplatform-books` на месте · `revision+1` остался ровно у двух заявленных
прогресс-писателей синка (мотивированное исключение ратифицировано) · тестов **359**, регистр
**198**, удалённых имён тестов против HEAD нет. **Ратифицировано (D39.130):** Go-floor 1.26.6
с toolchain-директивой (цена офлайн-хоста с `=local` названа и принята) · `book.yaml` при
интейке — форма **Б** (рендер из деплой-шаблона; интерпретация D39.110 §2b с аудит-следом,
стройка — следующим касанием зоны), форма **В** (`tmctl init`) — строка 170 единого бэклога
движку · контракт-диспозиции PD-172/173/174/180 — спек-правкой 0.2.3 задачей S4-промта ·
PD-196 → дописка строки 165 (различимость классов отказа `manifest`).
- **Пинг оркестратора, 14.08 (второй) — ЭМИТТЕР ДВИЖКА ЗАЛЕНДЕН (D39.131, `9cfe080`): `events.jsonl` начнёт появляться в каталогах книг.** Словарь = ваш `events.go` с диффом движка (принят приёмкой): `StreamVersion` **1.1** · `Ceiling.Scope: book|day` · `Finished.outcome += ceiling|stopped` · `eta_seconds` = темп ТЕКУЩЕГО прогона · `unit_done.unit` = лидерный `first_chunk_idx`. Ваша половина — **П-15** зонного бэклога (маппинг exit 4/5/1019 · `unit_done` присваиванием · scope · dev-супервизор · порядок деплоя против деадлока v15 — строка 174 единого, РЕШИТЬ ДО деплоя нового бинаря). PD-113/PD-196 в регистре остаются open до П-15 — движковая половина построена, потребительская нет.
- **Пинг оркестратора, 14.08 — гигиена зонных доков по аудит-свипу корпуса, закрыть следующим
касанием зоны (вместе с П-14):** `README.md:85` «тулчейн ≥1.26.5» → 1.26.6 c toolchain-директивой ·
`README.md:87` «River не подключена — П-3» и `STACK_DECISIONS.md:23` «River в go.mod НЕ добавлен» —
противоречат go.mod и построенной P4-очереди · `README.md:47-48` вопрос book.yaml → решён D39.130
(форма Б) · `PLATFORM_DIRECTION.md`: oapi-codegen «взять — доказано» против `ENGINEERING_STANDARDS:59`
«кандидат при P1» — оба не исполнены (ручки P4/P5 рукописные), sqlc «до первого хендлера» не
случился (PD-44 открыт), «River в go.mod не заводить» отстал — направление пере-подписать честно ·
`DEFECT_REGISTER.md`: PD-172/173/174/180 не несут диспозицию D39.130 (спек-правка 0.2.3 задачей S4) ·
открытые PD-180/185/196 живут в секциях «Закрытые…» · PD-178 стоит после PD-191 (порядок номеров) ·
PD-99 (fixed кодом) лежит в «Закрытых ратификацией» · путь `docs/scripts/counts.py` в шапке
регистра исполним только от корня репо · веса `high`/`medium-high` секций дофиксов против
объявленных major/minor/info · `STACK_DECISIONS.md:17` называет гейт «make tools-check», хотя
`version-check` отделён (FP5-9) · П-1/П-3 зонного бэклога не знают построенного P4 (вес «Ф3, после
контракта» отстал) · хвост (г) третьего раунда — перечислительность мета-пина systemd-гейта —
носителя не имеет: завести PD-строку или закрыть явно.
- **P4 «раннер» + дофикс + V2 ПРИНЯТЫ и ЗАЛЕНДЕНЫ — D39.123, код `d29e30c`** (ревью-шапка ниже):
транзиентный systemd-юнит на прогон · очередь River с холдом-до-спавна в одной транзакции ·
реконсилятор с базовой линией расчёта · тейлер `events.jsonl` с карантином проекции · пять
ручек `/v0` контракта **0.2.1** · дев-интейк. Батарея с живым PG 18.4 под `-race` зелёная,
скипов 0, тестов зоны **261**. Формула аргумента потолка — `committed + прирост` (PD-158,
ратифицирована ПОПРАВКОЙ к пингу оркестратора).
- **Регистр — 245 строк** (`python3 docs/scripts/counts.py` от корня репо; было 246 — лендинг слил
строку-дубль PD-198, испр. оркестратором №16 15.08): 178 закрыто · 1 закрыт ратификацией (PD-59)
· 4 риском · **62 открыто (17 minor, 45 info), из них 0 major** — PD-113 закрыт P6 вместе с
приходом эмиттера. Форма — 15 секций (добавлены эра P6 и дофикс P6).
- **Промт P5 ВЫДАН оркестратором 10.08** (`PLATFORM_P5_SESSION_PROMPT.md`, D39.128): П-9
`POST /books` (+PD-72 потолок тела; развилка «кто пишет book.yaml при интейке» — вопросом в этот
журнал ДО стройки той половины) · стоп/резюм-ручки PD-140 (различение «потолок vs авария» —
за эмиттером движка, строки 103/165, его промт выдан параллельно) · наблюдаемость П-11 +
печать конфигурации PD-114 · DEFECT_REGISTER секциями (пинг №16). Запуск — по слову владельца.
*(→ исполнен сессией P5 1114.08, принят D39.130; промт с баннером-исходом — `archive/PLATFORM_P5_SESSION_PROMPT_2026-08-10.md`.)*
Эскроу/uncertain (строка 136) — следующим денежным промтом, НЕ в P5.
- Эры P0P3 (вход OIDC · кредиты · админ-CLI · деплой · фикс-паки) — исполнены и залендены
(D39.107/109/112/114); разделы — в [archive/platform-PROGRESS-P0-P3.md](archive/platform-PROGRESS-P0-P3.md).
Стек и рецепт стенда — `STACK_DECISIONS.md`; критерии приёмки — `ENGINEERING_STANDARDS.md`.
## Дофикс P6 (15.08): фикс-лист приёмки ФП-1…ФП-8
Промт — `PLATFORM_P6_DOFIX_SESSION_PROMPT.md`. Мандат владельца исполнен буквально: каждая находка
проверялась по коду ДО починки, а не принималась на веру. Опровергнутых нет — подтвердились все, но
две подтвердились НЕ так, как их описала приёмка, и об этом сказано в строках ниже.
### Записка-план: ID → статус → улика
| ID | Статус | Улика |
|---|---|---|
| ФП-1 exit 2 у `status` | ЗАКРЫТ | Подтверждён чтением движка: `cmd/tmctl/render.go` `renderStatusJSON` печатает отчёт и возвращает сентинел при `flagged>0`, `main.go` `exitCode` отображает его в 2. Платформа звала `status` на ТРЁХ путях — расчёт, ре-синк, счётчик перед спавном — и каждый читал ненулевой код как «движок не ответил». Чинено одним знанием (`ingest.CompletedWithFlags`): exit 2 при разбираемом отчёте = ответ, exit 2 с нечитаемым stdout = отказ, всякий иной ненулевой = отказ. Дев-путь выровнен. Пины `runner.TestAFlaggedBookStillAnswersAboutItsMoney`, `TestExitTwoWithNothingToReadIsStillARefusal`, `TestAReportPrintedByAFailedCallIsNotAnAnswer`, `ingest.TestTheDevelopmentStatusReadsAFlaggedBookToo` и сквозной `runs.TestTheMoneyOfAFlaggedBookIsSettledThroughTheRealExitContract`. PD-224 |
| ФП-1 «какие ещё команды выходят 2» | ЗАКРЫТ ЧТЕНИЕМ | Сентинел `CompletedWithFlags` рождается в движке в четырёх местах, принадлежащих трём командам (`render.go:70` результат прогона, `:553` `status --json`, `:639` `status` человеку, `:691` `redrive`). Платформа зовёт `translate` (код читается реконсилятором, `OutcomeFlagged`), `status` (починен) и `manifest`а `renderManifest` возвращает nil для любого документа, который печатает, и другого пути к сентинелу у `manifestCmd` нет. `redrive` платформа не зовёт вовсе. Вывод записан доккомментом у `runner.Manifest`, вместе с тем, что случится, если движок это изменит: неклассифицированный код интейк уже читает как вину деплоя и файл пользователя не трогает |
| ФП-2 порядок апгрейда | ЗАКРЫТ | Подтверждён: шаг 2 звал голый `tmctl migrate` до установки нового бинаря — no-op старым. Порядок переписан: остановка демона (закрывает окно между списком и миграцией) → список → новый бинарь на версионированный путь → `migrate` ЕГО явным путём → переключение `TM_PLATFORM_ENGINE_BIN` → старт. «ДВА факта» приведены к трём. PD-225 |
| ФП-3 гейт миграции не запинен | ЗАКРЫТ | `pgstore.TestEachOfTheThreeBlockersAloneKeepsABookOutOfTheMigrationList`: по книге на каждый блокер, каждый по отдельности переводит вердикт в «нельзя», и список, который читает цикл деплой-заметки, содержит только свободную книгу. Живой прогон изолирован ИСКУССТВЕННО (его холд закрыт руками), потому что живой прогон всегда держит деньги — иначе один предикат прятался бы за другим. Три посадки, три падения. PD-226 |
| ФП-4 нечитаемый маркер хоронит потолок | ЗАКРЫТ | Подтверждён чтением: перечит причины стоял только на разбираемой ветке. Ветки слиты в одну (`case err == nil, errors.Is(err, runner.ErrBadMarker)`) — маркер на диске означает «юнит кончился» в обоих прочтениях, а решает о конце общий перечит. Пин `runs.TestACeilingSurvivesAMarkerThatCannotBeRead` (журнал с `ceiling{scope:day}` + маркер `{not json``paused`/`daily_ceiling`), посадка возвращает ровно то, что видела приёмка: `failed`/пусто. PD-227 |
| ФП-5 куки без Secure рядом с боевым OIDC | ЗАКРЫТ, форма выбрана иначе | Подтверждён: гейт ловил только связку с DEV_LOGIN. Легитимный сценарий, о котором спрашивал промт, СУЩЕСТВУЕТ — локальный провайдер по http на стенде, где `Secure`/`__Host-` не работают в принципе, — поэтому глухой отказ был бы неверен. Судит собственный адрес деплоя: `TM_PLATFORM_OIDC_REDIRECT_URL` и есть публичный URL платформы, значит `http://` там = стенд (запускается), всё прочее — отказ на буте. Пин `config.TestCookiesWithoutSecureAreRefusedNextToAProductionProvider` (4 отказных формы + 2 законных), посадка падает. PD-228 |
| ФП-6 незнакомый scope потолка | ВЗЯТ | Приор верен и подтверждён кодом: `ReadUsage` ключит аккаунтный halted-флаг ровно на `credit_exhausted`, а `CeilingPause` отдавал его всему, кроме `day`. Теперь называются только `book` и `day`, всё остальное = `ceiling_unknown`. Резюмируемость не меняется ни при одном из трёх, а ложного «кредит кончился» на аккаунте с деньгами больше нет. PD-229 |
| ФП-6 сид не тримит `--subject` | ВЗЯТ | Подтверждён чтением: демон тримит (`config.Load`), сид читал сырую переменную. Одна нормализация с обеих сторон; пин `TestASubjectThatIsOnlyPaddingIsNoSubject` — падённое значение обрывает прогулку ДО обращения к демону. PD-230 |
| ФП-6 комментарий у гарда `at >=` | ВЗЯТ, довод переписан | Приёмка права: `pipeline/events.go` штампует `at := e.now()` анонсирующего процесса, а непроецированная строка мёртвого прогона стирается `ForgetEvents` и анонсируется заново — со СВОИМ временем. Значит инверсии движок не порождает. Гард остаётся и обоснован тем, чем он есть на самом деле: потребитель at-least-once-потока, который ПРИСВАИВАЕТ, обязан не зависеть от порядка доставки. Довод приведён к факту и в коде, и в пинящем тесте. PD-231 |
| ФП-6 комментарий Resume про book-ceiling | ВЗЯТ, чурн — строкой | Приёмка права: движок останавливается на невместившемся РЕЗЕРВИРОВАНИИ, то есть до своего потолка, поэтому остаток положителен и ветка `exhausted` не срабатывает. Комментарий приведён к факту. Сам чурн (холд-спавн-пауза за клик, провайдерских денег не тратит) — строка PD-223, а не порог: размер резервирования — число движка, платформе не видное, и любой порог здесь был бы догадкой |
| ФП-6 exit 5 со стопом позже маркера | ВЗЯТ | Подтверждён: `stoppedOnRequest` отвергал по времени, и прогон, который пользователь отменил, закрывался `failed`. Exit 5 недостижим для прогона, кончившегося сам, а два штампа приходят с РАЗНЫХ часов (маркер пишет хост юнита, намерение — платформа). Теперь exit 5 при записанном намерении = `stopped` независимо от порядка. Гард «чистый финиш перебивает стоп» проверен отдельно и не сдвинулся: 0/2/3 отвечены раньше в том же switch — это пинится тремя строками того же теста. PD-233 |
| ФП-7 строки регистра | ЗАКРЫТ | PD-217…PD-222 — шесть находок приёмки, которые этим касанием не чинятся, плюс PD-223 своя. PD-198/PD-199 не тронуты. Ни одну из шести чинить сверх строк не стал: каждая либо требует решения вне зоны (PD-217, PD-218), либо лечится вместе с соседом (PD-219 — с читающей поверхностью P7), либо сегодня инертна (PD-220, PD-221) |
| ФП-8 базис тестов | ЗАКРЫТ | Числа пересчитаны командами (ниже), шапка P6 исправлена, откуда взялось «396» — не установлено. PD-234 |
| Аддендум владельца 15.08 (`day_usd`) | ИСПОЛНЕН | Раздел отдельным пунктом ниже: из ПРИМЕРА шаблона убран, ⚠-абзац переписан как опция оператора, платформенная обработка `daily_ceiling`/409 названа предохранителем. Кода не касается |
| Своё ревью (4 линзы) | 6 ПРИНЯТО И ПОЧИНЕНО, 5 СТРОКАМИ, 3 ОПРОВЕРЖЕНИЯ ПРИНЯТЫ | Таблица «находка → диспозиция» ниже. Самая тяжёлая — моя же: фикс ФП-4 сделал ветку решающей, а глоток ошибки перечитки в ней остался (PD-236) |
### Аддендум оркестратора к ФП-6а (решение владельца 15.08) — отдельным пунктом
`ceilings.day_usd: 5` убран из ПРИМЕРА платформенного шаблона книги (`deploy/README.md`), остался
только `book_usd`. Довод владельца, записанный там же: трату прогона уже жёстко ограничивает
купленный объём — платформа держит холд и передаёт движку `--ceiling-usd` флаш к нему, — поэтому
дневная ось поверх этого второй лимит на ту же трату, а стоит она дороже, чем даёт: остановленный ею
прогон резюмировать нельзя (409), и пользователю эта пауза необъяснима, потому что лимит не его и он
его не видит.
Движок и его `day_usd` не тронуты. ⚠-абзац переписан так, чтобы дневной лимит читался как ОПЦИЯ
оператора, а не дефолт, и там же сказано, что платформенная обработка `daily_ceiling`/409 из P6
остаётся ПРЕДОХРАНИТЕЛЕМ ровно на случай, когда оператор впишет лимит руками: она делает такую
остановку различимой и честной, а не дефолтом, который её вызывает. Код не менялся — это правка
рекомендации, и `day_usd` в фикстуре `render_test.go` остаётся намеренно: там он стоит за «ключ
оператора, который рендер обязан пронести нетронутым».
### Адверсариальное ревью дофикса (author≠reviewer, механизмом)
Четыре линзы субагентами по осям, названным промтом, плюс своя: шов против РЕАЛЬНОГО движка · деньги
(пересчёт расчёта флагнутого прогона из сырого леджера двумя путями) · конфиг-отказы (security,
**другая модель-семья** — Fable) · автомат состояний. Каждая работала на живом стенде и в КОПИИ репо
вне его; рабочее дерево ревьюеры не трогали.
Денежная ось отчиталась цифрами: холд 3.000000 и его возврат гасят друг друга, списание 1.250000
одно, и четыре независимых счёта сходятся — свёртка сырых строк `credit_ledger`, `грант (committed
baseline)`, `account_balances` и сумма `ReadAccount`: **8 750 000** micro-USD везде.
| Находка | Линза | Диспозиция |
|---|---|---|
| Неудачная перечитка причины хоронит потолок как `failed`; тот же глоток заново вооружает перезапуск | автомат состояний | **ПРИНЯТА, починена, запинена** — PD-236. Мой же фикс ФП-4 сделал ветку решающей, а глоток остался; ошибка чтения теперь возвращается наверх |
| Exit 2 берётся по коду, без согласия отчёта (измерено: чужой отчёт РАССЧИТЫВАЕТ 2.5 USD) | деньги | **ПРИНЯТА, починена, запинена** — PD-237 |
| Документ, который не манифест, приезжает пустым манифестом → удаление загрузки | шов | **ПРИНЯТА, починена, запинена** — PD-238 (+ различение «глав нет» и «глав нет, а юниты есть») |
| `Manifest` держится на непроверяемом инварианте чужой зоны | шов | **ПРИНЯТА, починена, запинена** — PD-239: правило читается, а не предполагается |
| `Status` читает stdout без потолка (найдено двумя линзами независимо) | шов + деньги | **ПРИНЯТА, починена, запинена** — PD-240; написание пина вскрыло вторую половину: дренаж бесконечного писателя не возвращается никогда |
| Подтест-пассажир в пине гейта ФП-5 (зелен при удалённом гейте) | security | **ПРИНЯТА, строка убрана** — PD-235 |
| Стоп пользователя переименовывается в `paused/credit_exhausted`, если потолок приехал в тот же дрейн | автомат состояний | **ПРИНЯТА строкой** — PD-241: порядок «причина раньше намерения» ратифицирован в P6, молча переворачивать не стал; денежно-видимая половина — PD-203 |
| `ceiling_unknown` проходит гейт резюма, отказывающий `daily_ceiling` | автомат состояний | **ПРИНЯТА строкой** — PD-242 (не регресс: прежнее значение резюмировалось так же) |
| `DecodeStatus` принимает `null`/`{}` за отчёт | шов | **ПРИНЯТА строкой** — PD-243; денежные пути перекрыты (PD-40 и новая проверка согласия), остаётся ETA |
| `settle` молча возвращает nil при `Engine == nil` | деньги | **ПРИНЯТА строкой** — PD-244 (в проде недостижимо) |
| У дев-супервизора нет потребителя вне тестов | шов | **ПРИНЯТА строкой** — PD-245; комментарий приведён к факту, судьбу пути решать оркестратору |
| Гейт ФП-5 обойти не удалось: 24 формы callback, `*_FILE`, схемные трюки | security | **ОПРОВЕРЖЕНИЕ ПРИНЯТО** — гейт устоял; ложных отказов законных стендов тоже не нашлось (прокси с TLS отказывается ПРАВИЛЬНО: браузер ходит по https, `Secure` там работает) |
| (B) exit 5 + намерение: полный перебор 216 клеток `outcome` против до-фиксной версии | автомат состояний | **ОПРОВЕРЖЕНИЕ ПРИНЯТО** — расходятся ровно две клетки, обе exit 5: одна начинает записывать код выхода, вторая и есть починка |
| PD-212 (Go выходит 2 при панике) расширяется ли новой терпимостью | шов | **ОПРОВЕРГНУТО ревьюером** — паника не оставляет целого документа, а неполный отвергается; строка остаётся про путь прогона |
### Живые пробы этого круга
- **Гейт ФП-5 — живым демоном, не только тестом.** С боевым callback:
`TM_PLATFORM_INSECURE_COOKIES=1` + полный OIDC + `TM_PLATFORM_OIDC_REDIRECT_URL=https://app.example.org/auth/callback`
`ERROR fatal`, **exit 1**, сервис не поднялся. С локальным `http://127.0.0.1:8099/auth/callback`
тот же демон проходит конфигурацию и стартует. То есть отказ ловит именно ту форму, которую нашла
приёмка, и не ловит законный стенд.
- **Exit-контракт — настоящими процессами.** Пины `runner` гоняют не мок, а `/bin/sh`-движок,
печатающий отчёт и выходящий 2/1/11/13; сквозной денежный тест `runs` подменяет движок
скриптом, чьи ответ и код лежат в двух файлах, — так что путь от кода выхода до строки леджера
проходится целиком, а не по обе стороны стаба.
- **Батарея дофикса** — `make check` при живом PG (`~/.local/pgsql`, порт 55433) и обоих гейтах
окружения: EXIT=0, 17 пакетов, линтер 0 issues, скипов 0, `make vuln` — без уязвимостей.
### Числа: командой, а не памятью (ФП-8)
```sh
git grep -h '^func Test' HEAD -- 'platform/**/*_test.go' | wc -l # 359 — состояние лендинга P5
grep -rh '^func Test' --include=*_test.go platform | wc -l # 416 — дерево после дофикса
```
То есть P6 + дофикс = **+57** тестовых функций; на конце P6 в дереве был 401 (счёт приёмки — сходится:
дофикс добавил 15, из них 6 — по находкам собственного ревью). Плюс 2 фаззера (`^func Fuzz`), в этот
счёт не входящие и исполняемые сид-корпусом.
Батарея (`go test ./... -race -count=1 -v` при обоих гейтах окружения):
```sh
grep -cE '^--- (PASS|FAIL|SKIP)' <лог> # 418 верхнеуровневых прогонов (416 тестов + 2 фаззера)
grep -cE '^ --- ' <лог> # 139 подтестов
grep -c -- '--- SKIP' <лог> # 0
```
`make check` EXIT=0, 17 пакетов, линтер 0 issues, `make vuln` — без уязвимостей. Стенд:
PG 18.4 `~/.local/pgsql` порт 55433, свой `tmctl` из КОПИИ `backend/` вне репозитория.
### Посадки (мутация → что упало)
| Мутация | Упало |
|---|---|
| `runner.Status`: вернуть ошибку на ЛЮБОЙ ненулевой код | `TestAFlaggedBookStillAnswersAboutItsMoney`; сквозной денежный — сообщением «холд флагнутого прогона всё ещё открыт: 3.000000», то есть ровно симптомом приёмки |
| `Supervisor.Status`: то же на дев-пути | `TestTheDevelopmentStatusReadsAFlaggedBookToo` |
| `reconcile`: вернуть перечит причины на одну ветку | `TestACeilingSurvivesAMarkerThatCannotBeRead``failed`/пусто вместо `paused`/`daily_ceiling` |
| `outcome`: убрать `case ingest.OutcomeStopped` | `TestAGracefulSignalIsAStopOnlyWhenThisPlatformAskedForOne` |
| `CeilingPause`: вернуть `credit_exhausted` по умолчанию | `TestTheCeilingScopeDecidesWhichPauseTheRunGets` |
| `config.Load`: убрать гейт INSECURE_COOKIES+OIDC | `TestCookiesWithoutSecureAreRefusedNextToAProductionProvider` |
| `seed`: убрать `TrimSpace` | `TestASubjectThatIsOnlyPaddingIsNoSubject` |
| `BooksForMigration`: убрать предикат живого прогона | `TestEachOfTheThreeBlockersAlone…``bk-live` объявлен мигрируемым |
| …убрать предикат резюмируемого | то же на `bk-paused` |
| …убрать предикат незакрытого холда | то же на `bk-hold` |
| `freshPausedReason`: вернуть падение на снапшот | `TestAnEndingIsNeverDecidedFromAReadThatFailed` — ошибка приходит уже от записи, а не от чтения |
| `Status`: брать exit 2 по коду и разбору, без согласия отчёта | `TestExitTwoIsTakenOnlyFromAReportThatAgreesWithIt` (4 подтеста) |
| `DecodeManifest`: убрать проверку `manifest_version` | `TestADocumentThatIsNotAManifestIsNotAnEmptyManifest` |
| `Parse`: читать «глав нет» в одиночку | `TestAManifestThatContradictsItselfNeverCostsTheUpload` |
| `Manifest`: вернуть «любой ненулевой = отказ» | `TestAManifestThatCompletesWithFlagsIsStillAManifest` |
| `drain`: дренить и за капом вместо убийства процесса | `TestAnEndlessStatusIsRefusedRatherThanRead` — виснет и падает по таймауту |
Пятнадцать из пятнадцати. Непинябельных в этом круге нет.
### Чего я НЕ сделал — явным списком
- **Контракт `openapi.yaml` и его зеркало** — не тронуты (PD-199 ждёт слова владельца, правит S4).
`ceiling_unknown` и `daily_ceiling` на провод по-прежнему не проецируются.
- **`tmctl migrate` живьём не гонялся** — движковой команды всё ещё нет в HEAD. Порядок в
`deploy/README.md` исправлен и стал исполнимым, но проверен ЧАСТИЧНО: список `--migratable` на
стенде, сам `migrate` — нет. Самолечение по `schema_mismatch` вслепую не строил (PD-201).
- **Читающая поверхность (P7), эскроу (строка 136), брокер** — нет.
- **PD-217…PD-222 и PD-241…PD-245 не чинены** — заведены строками, каждая с причиной, почему не
этим касанием.
- **Порядок «причина паузы раньше намерения стопа» не переворачивал** (PD-241): это ратифицированное
решение P6, и его смена двигает клетки за пределами фикс-листа — вопрос оркестратору.
- **Промт дофикса не трогал** (`PLATFORM_P6_DOFIX_SESSION_PROMPT.md` — артефакт оркестратора).
### Вопрос на ратификацию, добавленный этим кругом
К трём вопросам P6 добавляется один: **порядок «потолок раньше стопа» в `outcome`** (PD-241). Сегодня
прогон, который пользователь остановил, показывается как `paused/credit_exhausted`, если в тот же
дрейн приехал потолок, — и этим зажигает аккаунтный halted-флаг у аккаунта с деньгами. Обе половины
— и статус, и флаг (PD-203) — про одно: что именно платформа обязана показать, когда две правды
пришли вместе.
## P6 (14.08): потребительская половина шва · интейк формы Б · дев-стенд
Промт — `PLATFORM_P6_SESSION_PROMPT.md`, плюс **аддендум оркестратора** по ресёрчу деплоя (принят
эхом, отдельный пункт ниже). Всё, что здесь заявлено, проверено ИСПОЛНЕНИЕМ; там, где проверка
ждёт, это сказано словом «ждёт», а не пропущено.
### Записка-план: ID → статус → улика
| ID | Статус | Улика |
|---|---|---|
| П-15 (а) exit-коды | ЗАКРЫТО | `ingest/exit.go` — ОДИН словарь shell-контракта на зону (реконсилятор, интейк, дев-супервизор). 4 = `paused` двумя независимыми каналами · 5 без намерения = прерывание и перезапуск · 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/5``stopped`, второй попытки нет |
| Дев-вход + сид целиком | `tmplatformctl seed --url …` | аккаунт `dev/dev@stand`, грант, загрузка через ЖИВОЙ `POST /v0/books`, интейк `not_started, 3 chapters` |
| Гейт дев-входа | демон с `TM_PLATFORM_DEV_LOGIN` И боевым OIDC | `fatal: … cannot be enabled on a deployment that has an OIDC provider` — не стартует |
| `$` в `ExecStopPost` (PD-165) | `systemd-run --user --property=… --setenv=dir=EXPANDED` | путь доехал ЛИТЕРАЛЬНО (`…/dollar$dir/`), то есть экранирование в `$$` было бы ошибкой |
| Относительный `CTL_BIN` (PD-165) | `systemd-run` с `./tmplatformctl` | «neither a valid executable name nor an absolute path» — юнит не стартует вовсе |
**Найдено ЖИВОЙ пробой, а не чтением:** внутренняя причина `daily_ceiling` уезжала на провод, где
контракт знает одно значение и клиент, сгенерированный по спеке, её бы отверг. Починено
(`contractPausedReason`), строка PD-199 — вопрос владельцу контракта. Второе: сид упёрся в CSRF-слой
(`X-TM-Client` обязателен на unsafe-запросе с кукой) — ровно в ту дверь, которую обязан правильно
открывать фронт; это и есть причина, по которой сид ходит по HTTP, а не пишет строки сам.
### Посадки
**Первый круг — 20 из 20 пойманы**, но не с первого захода: три посадки ПЕРЕЖИЛИ, и каждая означала
слепой пин, а не безобидность мутации.
| Пережившая посадка | Почему пин был слеп | Что сделано |
|---|---|---|
| `mine := offset > 0` в тейлере | тест ставил чужой поток ПЕРЕД своим hello, а чужой hello сам выключает `mine` — мутация не наблюдалась | пин переписан на реальный сценарий: хинт попадает ВНУТРЬ чужого потока (после его hello), и мутация даёт `sequence gap` → карантин здорового прогона |
| `O_EXCL → O_TRUNC` в рендере | при существующем файле открытие не достигается — гарду это не свойство | тест переписан: первая ходка падает на ДВИЖКЕ (книга остаётся в интейке), шаблон удаляется, вторая ходка обязана его не искать. Ловится снятие short-circuit. ⚠ Сам `O_EXCL` НЕ пинится и это названо в тесте: две одновременные ходки пишут одинаковые байты, наблюдать нечего |
| `PauseRun` всегда рапортует успех | я запустил посадку по несуществующему имени теста | перезапущено против `TestAPauseFromAnOldSnapshotDoesNotCloseARunOverALiveAttempt` — падает |
**Второй круг (находки панели) — 6 из 6**, седьмая названа непиняемой: фолбэк причины в ветке
исчерпания бюджета недостижим по построению (в `restart` попадают только ветки, где причины нет),
и это записано в самом коде, а не заявлено закрытым.
### Адверсариальное ревью (author≠reviewer, механизмом)
Четыре независимых ревьюера по диффу с установкой ОПРОВЕРГАТЬ, из них **опровергатель другой
модели-семьи (Fable) на security-оси** — норма D39.120. Линзы: деньги и гонки · безопасность
дев-входа · соответствие потребителя реальному движку и судьба файла пользователя · повторное
использование, общность, честность комментариев.
| Находка | Линза | Диспозиция |
|---|---|---|
| Имя провайдера `dev` не зарезервировано: `TM_PLATFORM_OIDC_PROVIDER=dev` кладёт чужие `sub` в namespace дев-аккаунта | безопасность (Fable) | **ПРИНЯТА, закрыта.** Четвёртое из четырёх заявленных свойств было ЛОЖНЫМ без этого. `Load` отвергает имя, пин + посадка. PD-206 |
| `TM_PLATFORM_DEV_LOGIN=" "` монтирует вход с личностью «пробел» | безопасность (Fable) | **ПРИНЯТА, закрыта** тримом + пином |
| Дев-вход не получает второй CSRF-слой (`X-TM-Client` нужен только при уже имеющейся куке) | безопасность (Fable) | **ПРИНЯТА как остаток**, названа в коде и строкой PD-205: на стенде последствие ничтожно, но «POST, значит безопасно» — слабее, чем звучит. Тот же класс у боевого `/auth/login` |
| Ветка дев-входа стоит ПЕРВОЙ в switch — один регресс конфига даст ей перекрыть боевой OIDC | безопасность (Fable) | **ПРИНЯТА**, добавлен избыточный гард в `main.go`; он по построению недостижим, поэтому теста не имеет — сказано вслух |
| Грант дев-входа безусловен (боевой путь его придерживает у неподтверждённой личности) | безопасность (Fable) | **ПРИНЯТА как намеренная**: аккаунт с нулём кредита делает каждый прогон отказом. Не кран: строка леджера пишется только при INSERT личности — проверено в коде |
| `daily_ceiling` стирался двумя путями → ложный аккаунтный флаг + обход гарда резюма | деньги и гонки | **ПРИНЯТА, закрыта.** Третье значение `ceiling_unknown` + прогон с названной причиной не перезапускается. PD-209 |
| Окно усыновления между ДОПУСКОМ и спавном; `coalesce` отказывался чинить усыновлённое имя | деньги и гонки | **ПРИНЯТА, закрыта.** Имя даётся при создании строки попытки. PD-210 |
| Побочно: `Begin` стал недостижим, и с ним тихо выключился `chunker_version` | деньги и гонки | **ПРИНЯТА, закрыта** переносом эффекта в `effect`, пин + посадка. PD-210 |
| Апсерт юнита без гарда по времени: переанонс старой резолюции затирает новую | деньги и гонки | **ПРИНЯТА, закрыта** одним `where`; пин + посадка. PD-211 |
| Паника движка неотличима от «завершено с флагами» (Go-рантайм выходит РОВНО 2) | шов | **ПРИНЯТА, НЕ чинится здесь**: лечение — `recover` или другой номер, это движковая сторона. PD-212, запрос уходит строкой единого бэклога |
| Форма манифеста не версионируется → будущий дрейф даст `ChaptersTotal=0` → удаление файла | шов | **ПРИНЯТА как латентная**, строка PD-213. Сегодня формы совпадают поле-в-поле; лечить сверкой `manifest_version`, где незнакомая версия даёт НЕ-деструктивный класс |
| Чужая hello валит `Tail` до проверки «чья строка» → карантин здорового прогона навсегда | шов | **ПРИНЯТА**, строка PD-214: порядок проверок в `apply` лечится отдельно, класс не денежный |
| Код 13 (`schema_mismatch`) отсутствовал в словаре платформы | шов | **ПРИНЯТА, закрыта** (см. аддендум п.3) |
| Сид рапортовал «кредит начислен» о начислении, которого не было | повторное использование | **ПРИНЯТА, закрыта**: зовёт существующий хелпер `write`. Проверено на стенде — второй прогон печатает `no-op`. PD-208 |
| Петля перезапусков без счётчика и бэк-оффа; exit 12 терминален, хотя лок проходит сам | деньги и гонки | **ПРИНЯТЫ как остатки**, PD-215 и PD-216: лечатся вместе (перезапуск на 12 без бэк-оффа — это и есть PD-215) |
| `gopkg.in/yaml.v3` — последний релиз по этому пути модуля | повторное использование | **ПРИНЯТА как заметка**: переезд решается ОБЕИМИ зонами сразу, записано в `STACK_DECISIONS` §28 |
| Воскрешение прогона устаревшим снапшотом (нашёл сам при аудите диффа) | самоаудит | **ЗАКРЫТА** `RestartInput.OnlyIfLive`; пин + посадка «stopped → translating». PD-207 |
### Чего я НЕ сделал — явным списком
- **Читающая поверхность (SSE, главы/юниты/замечания, банк)** — пак P7, заведена строкой П-17.
Хранилище под первую половину уже есть: `unit_resolutions` несёт диспозицию каждого юнита.
- **Эскроу денег шва** (строка 136) и вместе с ним PD-154 — денежный промт, строка П-18.
- **Самолечение по `schema_mismatch`** — аддендум п.3 выше: вслепую не строю.
- **Спека `openapi.yaml`** — не трогал: её правит S4-сессия фронта. Отсюда PD-199.
- **`ReadUsage`** — не трогал, хотя ревью подтвердило: аккаунтный halted-флаг зажигается по паузе
ОДНОГО прогона (PD-203). Это продуктовая семантика уже построенной ручки; правка — на ратификацию,
а не тихо в паке про шов.
- **Порядок проверок в `apply` тейлера** (PD-214), **версионирование манифеста** (PD-213),
**бэк-офф перезапусков** (PD-215/216) — приняты строками, не построены: класс PD-83.
- **`O_EXCL` рендера** — не пинится и не заявлен пинённым.
- **Шаг `tmctl migrate` на стенде** — ждёт движковую половину.
### Вопросы на ратификацию
1. **`daily_ceiling` на проводе.** Контракт перечисляет одно значение `PausedReason`, платформа
различает два (плюс `ceiling_unknown`). Сейчас незнакомая контракту причина едет как `null`
ровно то, что спека сама велит клиенту рендерить. Назвать причину в спеке (тогда правка — одна
функция `contractPausedReason`) или подтвердить `null`? PD-199.
2. **`PLATFORM_DIRECTION` §3.** Две строки не исполнены: `oapi-codegen` («ВЗЯТЬ — доказано») и
`sqlc` («до первого хендлера» — срок пройден, PD-44 открыт с P1). Зона не переписывает
ратифицированное направление сама: в §3 добавлен баннер статуса с доводами за и против
пере-подписи. Решение за оркестратором.
3. **Второй гейт батареи.** Живая проба рендера против НАСТОЯЩЕГО движка сделана постоянным тестом
(иначе клейм «движок это грузит» протухнет молча), и она гейтится
`TM_PLATFORM_TEST_ENGINE_BIN` + `TM_PLATFORM_TEST_BOOK_TEMPLATE` — той же формой, что у БД-тестов.
На голом клоне это ОДИН честный скип. Если приёмка считает скипы при одном лишь DSN, число будет
не ноль — и это не регресс, а новая ось проверки; форму гейта готов поменять по слову.
## Третий раунд (14.08): ре-чек оркестратора FP5-9…FP5-11 и хвосты
Все три находки подтвердились на коде до правки; возражений нет. Две из трёх — мои вчерашние фиксы,
которые ЗАЯВЛЕНЫ шире, чем сделаны, и это ровно тот класс, за который приёмка уже била.
### FP5-9 [HIGH] Гейт тулчейна не отказывал 1.26.5 — и три дока это утверждали
Проверено исполнением: `make version-check GO_VERSION=go1.26.5` на прежнем правиле проходил.
Регекс `go1\.26\.([5-9]|[0-9]{2,})` принимал ровно ту версию, ради отказа от которой floor и
поднимали, а `GO_MIN_VERSION` жил только в тексте сообщения. Тот же регекс ставил 1.26.10 НИЖЕ
1.26.9. Клейм «хост на 1.26.5 получит отказ» был ложен в трёх местах (журнал ×2, `STACK_DECISIONS`,
коммент Makefile) — гейт «поднят» был только на словах.
Сделано, двумя независимыми механизмами:
1. `make version-check` СРАВНИВАЕТ версии (`sort -V`), а не матчит; префиксы `rc`/`devel` отвергаются
отдельной веткой — пререлиз не несёт фиксов, которые обещает его номер. Цель отделена от
`tools-check` и берёт версию из переменной `GO_VERSION`, чтобы её можно было судить версиями,
которых на хосте нет.
2. `go.mod` получил `toolchain go1.26.6`. **Решение и обоснование** (оркестратор просил решить): floor
ЯЗЫКА (`go 1.26.4`) остаётся общим с движком — один стенд собирает оба модуля; директива
`toolchain` поднимает только тулчейн, то есть stdlib, которым линкуется сетевой модуль. Её читает
ВСЯКАЯ сборка, включая ту, что не зовёт make: при `GOTOOLCHAIN=auto` (дефолт) хост скачает 1.26.6
вместо того, чтобы собрать уязвимый бинарь, при `=local` — остановится с ошибкой, называющей
версию. Обе ветки — тот ответ, который нужен; ни одна не зависит от того, прочитал ли кто-то
Makefile. Цена названа: офлайн-хост с `=local` и только 1.26.5 теперь не соберёт зону — это и есть
гейт.
Пин — на СРАВНЕНИЕ, а не на прогон: новый пакет `internal/gates` гоняет `version-check` таблицей
версий, которых на этой машине нет (1.26.4/1.26.5/1.25.9/1.9.9/rc/devel — отказ;
1.26.6/1.26.7/1.26.10/1.27.0/2.0.0 — приём), и вторым тестом требует, чтобы `go.mod` и
`GO_MIN_VERSION` называли одну версию. Две посадки падают. Три ложных клейма переписаны на описание
механизма. Строка PD-197.
### FP5-10 [HIGH] Гард корня не переживал unmount и маскировался бутовым `MkdirAll`
Тоже подтверждено: `Stat(BooksDir)` отвечает «есть» про пустой mountpoint, оставшийся после
размонтирования тома, смонтированного РОВНО в `BooksDir`, — а бут безусловным `MkdirAll` пересоздаёт
корень после рестарта. То есть мой вчерашний фикс закрывал только форму «каталог удалён вместе с
родителем» и не закрывал ту, ради которой писался.
Сделано по кандидату оркестратора — **сентинел провижининга**: файл `.tmplatform-books` в корне,
который пишет ПЕРВАЯ загрузка (`markStorage`, `O_EXCL`, поэтому две одновременные загрузки обе правы)
и не пишет больше никто — в частности, не пишет бут. Решение «хранилище на месте» принимается по
СЕНТИНЕЛУ (`storageIsThere`), а не по существованию каталога: ни unmount, ни `MkdirAll` его не
подделывают. Хост, который никогда не принимал загрузок, отвечает «нет» — безопасная сторона: у него
нет книг, которые можно отклонить.
Пин — `books.TestAnUnmountedVolumeLooksLikeAnEmptyRootAndStillIsNotTheBooksFault`: удаляем всё под
корнем и пересоздаём сам корень (ровно то, что оставляют unmount и бут), десять циклов ожидания —
книга жива, бюджет цел, движок не спрошен, сентинел не воскрес. **Первая редакция этого пина посадку
ПЕРЕЖИЛА** (мутация «не писать сентинел вовсе»): без сентинела всё ждёт, и тест этого не отличал —
добавлено утверждение, что принятая загрузка сентинел ПИШЕТ, иначе терминальность крэш-окна (FP5-3)
теряется. Обе посадки падают. PD-192 дополнена.
### FP5-11 Правило ревизии применено не ко всем писателям
Пять писателей жизненного цикла ПРОГОНА остались на `revision + 1` против правила, которое дофикс
ввёл для трёх интейковых: старт, реоткрытие, пауза (`runs.go`) и два закрытия (`sink.go`). Значит
staleness библиотеки, которую вчера закрыли на статусах интейка, оставалась на статусах прогона:
книга не-максимума аккаунта уходила `translating` — библиотека не двигалась.
Сделано: все пять берут `nextRevisionOfThisBooksLibrary`. Пин —
`pgstore.TestARunTransitionOnAnOlderBookStillMovesTheLibrary` (вторая книга держит максимум 41,
прогон стартует на первой; проверяются обе половины жизненного цикла — старт и пауза). Посадка
падает.
**Мотивированное исключение, названное вслух:** два писателя `books.revision` НЕ переведены —
прогресс-путь синка (`bumpBook` на `unitDone`/`progress`). Причина не вкусовая: `unitDone` вычисляет
`chapters.revision` из `books.revision + 1` ДО инкремента, и прыжок книги через пол развязал бы
книжную и главную шкалы, а это отдельная scope-семантика контракта. Она и не нужна: прогресс
существует только у книги, у которой ИДЁТ прогон, а старт прогона теперь сам поднимает книгу на
уровень библиотеки — дальше `+1` монотонен и виден. Если правило когда-нибудь понадобится и там,
оно приедет вместе с проверкой главной шкалы, а не молча.
### Хвосты ре-чека
- **(а)** Коммент `PauseRun` обещал проверку «в том же стейтменте», а стоит отдельный
`select … for update` в той же транзакции. Текст приведён к коду (транзакция и её замки).
- **(б)** Комменты `parse.go` («terminal on the first answer») описывали до-дофиксное поведение на
пути, который УДАЛЯЕТ файл пользователя, — самое опасное место для устаревшего текста. Переписаны:
через бюджет попыток идёт КАЖДЫЙ ответ движка, удаляет только последний.
- **(в)** Остаток FP5-5 закрыт кодом, а не строкой: проход, которому осталось меньше, чем нужно
разбору, книгу НЕ НАЧИНАЕТ (клейм берётся внутри `Parse`, поэтому отложенная книга не тратит
ничего) и говорит об этом одной строкой лога вместо ошибки на книгу. Пин —
`books.TestAPassTooShortForAParseStartsNoneAtAll`, посадка падает.
- **(г)** Мета-пин systemd-гейта перечислителен — принято как названный остаток: он ловит возврат к
сверке сообщений по словам systemd и требует, чтобы гейт вообще спрашивал менеджер, но исчерпать
все будущие формулировки перечислением нельзя.
### Посадки третьего раунда
| Посадка (что откатывается) | Пин, который обязан упасть | Итог |
|---|---|---|
| гейт снова матчит регексом (принимает 1.26.5) | `gates.TestTheToolchainGateComparesVersionsRatherThanMatchingThem` | поймана |
| `go.mod` снова без `toolchain` | `gates.TestGoModPinsTheSameToolchainTheBatteryDemands` | поймана |
| гард снова смотрит на существование корня | `books.TestAnUnmountedVolumeLooksLikeAnEmptyRootAndStillIsNotTheBooksFault` | поймана |
| сентинел не пишется загрузкой | тот же | ПЕРЕЖИЛА → пин усилен → поймана |
| старт прогона снова `revision + 1` | `pgstore.TestARunTransitionOnAnOlderBookStillMovesTheLibrary` | поймана |
| проход снова начинает книгу, на которую нет времени | `books.TestAPassTooShortForAParseStartsNoneAtAll` | поймана |
### Батарея третьего раунда
- `make check` с живым PG 18.4 (стенд `~/.local/pgsql`, порт 55433) под `-race`: **EXIT=0, 16
пакетов, скипов 0**, линтер **0 issues**, `make vuln` — чисто.
- Тесты `^func Test` исполнением: **354 → 359** (+5, удалённых 0).
- Посадки: **6 из 6** (одна со второго захода, см. таблицу).
### Записка-план третьего раунда: ID → статус → улика
| ID | Статус | Улика |
|---|---|---|
| FP5-9 | ЗАКРЫТО | `version-check` сравнивает (`sort -V`) + `toolchain go1.26.6` в `go.mod`; `internal/gates` ×2 пина, две посадки падают; три клейма переписаны. PD-197 |
| FP5-10 | ЗАКРЫТО | сентинел `.tmplatform-books`, пишет только загрузка; `books.TestAnUnmountedVolumeLooksLikeAnEmptyRootAndStillIsNotTheBooksFault`; две посадки падают. PD-192 дополнена |
| FP5-11 | ЗАКРЫТО с названным исключением | пять писателей прогона на `nextRevisionOfThisBooksLibrary`; `pgstore.TestARunTransitionOnAnOlderBookStillMovesTheLibrary`; посадка падает. Прогресс-путь синка НЕ тронут — обоснование выше. PD-122 дополнена |
| хвост (а) | ЗАКРЫТО | текст `PauseRun` приведён к коду |
| хвост (б) | ЗАКРЫТО | текст `parse.go` про терминальность приведён к коду. PD-198 |
| хвост (в) | ЗАКРЫТО кодом | проход не начинает книгу без полного бюджета; `books.TestAPassTooShortForAParseStartsNoneAtAll`; посадка падает. PD-189 дополнена |
| хвост (г) | ПРИНЯТО как остаток | перечислительность мета-пина названа, не «закрыта» |
## Дофикс-2 (13.08): кросс-семейное ревью САМОГО дофикса
Пропуск, который я закрыл только по вопросу владельца: дофикс приёмки был проверен исполнением
(пины · посадки · батарея · живая проба), но НЕ был отревьюен независимо — author≠reviewer для
дофикс-диффа не соблюдался. Запущены два ревьюера другой семьи (Fable 5, я Opus), read-only по
дереву, каждый со своей линзой — «деньги и гонки» и «интейк, потеря данных, правдивость чисел».
Вернули **пять дефектов с воспроизведением, одну гипотезу и два расхождения отчётности**. Все
подтверждены мной на коде до правки; возражений нет.
### Н1 [medium-high, интейк] Пропавший КОРЕНЬ хранилища читался как вина каждой книги
`os.Stat(workdir)` даёт ENOENT и когда пропал каталог одной книги, и когда не смонтирован сам
`BooksDir`. Первое — терминальный конец этой книги (и лечение крэш-окна отказа, FP5-3), второе —
беда хоста. Читая второе как первое, ОДИН проход свипа терминально отклонял ВСЕ книги в интейке с
`source_unreadable` — причиной, которая винит файл пользователя, терминальна по устройству и не
имеет обратного хода (ни перепарса, ни удаления, PD-175). То есть мой же фикс FP5-3 создал путь
массового уничтожения загрузок из-за размонтированного тома.
Сделано: `ErrStorageGone` отличён от `ErrDirectoryGone` — корень спрашивается ПЕРЕД тем, как винить
книгу; причина `storage_unavailable` никогда не терминальна, бюджет не тратит (движка не звали) и на
книге не хранится. Предикат `waitsForTheDeployment` собрал оба таких случая в одном месте.
Пин — `books.TestAVanishedStorageRootIsNotEveryBooksFault` (две книги, снесённый корень, десять
циклов ожидания: обе живы, бюджет цел, движок не спрошен ни разу, том вернулся — следующий проход
разбирает). Посадка падает. Строка PD-192.
### Н2 [minor, ревизия] Пол ревизии глотал весь статусный проход книги
Продолжение FP5-1 на соседнем пути: вставка научилась брать `greatest(max, пол) + 1`, а смены
статуса остались на `revision + 1`. Книга, загруженная ДО чужой отменённой загрузки, идёт
`uploading → parsing → not_started` тремя инкрементами СВОЕГО счётчика и всё ещё под полом —
`greatest(max, пол)` не двигается вовсе, а клиент по контракту отбрасывает чтение, которое не выше
применённого. Экран, только что загрузивший книгу, показывает её «приходящей», пока библиотеку не
сдвинет постороннее событие. Монотонность при этом не нарушена — это staleness, и именно поэтому
прошлый круг её не заметил.
Сделано: три писателя статуса (`StartParsing`, `FinishParse`, `RejectBook`) берут следующий номер
БИБЛИОТЕКИ, а не свой + 1 (`nextRevisionOfThisBooksLibrary`, владелец читается из обновляемой
строки). Пин — `books.TestAStatusChangeIsVisibleEvenUnderTheLibrarysFloor`, посадка падает. PD-122
переформулирована: остаток — собственный счётчик области (двое ОДНОВРЕМЕННЫХ писателей могут
вычислить одно и то же число; значение при этом растёт, поэтому клиент перезапрашивает).
### М1 [medium, деньги] `PauseRun` — третий закрывающий путь без гарда живой попытки
Тот же класс, что PD-181 и FP5-2, на пути, который прошлый круг не проверил: пауза закрывала прогон,
не спрашивая, жива ли ещё закрываемая попытка И этого ли она прогона (апдейт попытки шёл даже без
`run_id`). Сценарий ревьюера: два поколения реконсилятора при перекрывающемся деплое, у старого ниже
`PerChapter` в конфиге → его проход считает остаток исчерпанным и паузит прогон, который уже
рестартован в живую попытку 2. Итог — прогон `paused/credit_exhausted` под живым тратящим движком, а
открытый холд попытки 2 не виден НИ в `ListLiveRuns` (прогон завершён), НИ в `UnsettledRuns` (попытка
не завершена).
Сделано: тот же `exists (… a.id = $4 and a.run_id = runs.id and a.ended_at is null)`, что у соседей,
плюс `run_id` в апдейте попытки. Пин — `pgstore.TestAPauseFromAnOldSnapshotDoesNotCloseARunOverALiveAttempt`
(проверяет и то, что прогон жив, и то, что холд живой попытки виден в списке). Посадка падает. PD-193.
### М2 [low, проекция] Синк законченной попытки усыновлял чужой хендшейк
Форма, которой до P5 не существовало: стоп ДО спавна оставляет попытку закрытой и без
`engine_run_id`. Устаревший материализатор с такой попыткой принимал hello попытки, которая её
заменила, биндил чужой `engine_run_id` на себя и материализовал тот же журнал второй раз — счётчики
глав и юнитов удваивались. Деньги не двигались.
Сделано: бинд отказан для законченной попытки (`ended_at is null` в CAS). Пин —
`pgstore.TestAMaterializerOfAnEndedAttemptDoesNotAdoptTheNextAttemptsEngine`, посадка падает. PD-194.
### М3 [low, деньги] Возврат холда «прогона, который не запускался», ждал ответа движка
Ревьюер дал это гипотезой; я перепроверил и подтвердил. `settle` сперва требовал успешного
`tmctl status`, и только потом ветка «юнита не было — вернуть холод целиком». Но хост, который
производит эту ситуацию, — ровно тот, где движок не запускается: `Status` падает на каждом проходе,
и деньги прогона, который не начинался, остаются зарезервированными навсегда (видимыми, но
запертыми).
Сделано: ветка «нет юнита и нет базовой линии» идёт ДО вызова движка; `ReleaseUnspawned`
перепроверяет строку под замком, поэтому снапшот, который успели заспавнить, отвергается там и
settle идёт обычным путём. Пин —
`runs.TestTheHoldOfARunThatNeverStartedComesBackOnAHostWhoseEngineCannotAnswer` (движок отвечает
«no such file» на любой запрос; холд обязан вернуться, движок — не быть спрошенным). Посадка падает.
PD-195.
### Хвосты того же круга
- **Н3.** `StartParsing` и оба `abandon` в `Accept` шли на `context.WithoutCancel` без своего
дедлайна: отсоединённый — не значит бесконечный, повисший запрос держал бы горутину запроса,
которого уже нет. Переведены на `writeCtx` (30 с). **Пина нет и не заявляется**: это граница, а не
наблюдаемое поведение — чтобы его пинить, нужен стор, умеющий висеть.
- **Н4.** Опечатка оператора в рукописном `book.yaml` по-прежнему стоит файла пользователя: движок
отвечает exit 1 на всё, через пять циклов книга отклоняется как `source_unreadable` и исходник
удаляется. FP5-4 закрыл только «конфигурации нет вовсе». Заведено отдельной строкой риска PD-196,
а не спрятано в комментарий: настоящее лечение — различающий код выхода или `--dry-run` на стороне
движка, это не правка платформы.
- **Н5.** Пин FP5-7 сверял ДВЕ точные формулировки старого стринг-матча — третье написание прошло бы
мимо. Теперь тест разбирает файл гейта парсером и смотрит только КОД (комментарии там цитируют ту
самую строку — текстовый скан принял бы историю за дефект), запрещает в коде слова systemd и
требует, чтобы гейт вообще спрашивал менеджер. Посадка «вернуть матч по сообщению» падает.
- **Отчётность.** Ревьюер не смог проверить клейм «посадки 10 из 10»: списки лежали в скрэтчпаде
сессии, а не в дереве. Исправлено — списки посадок теперь ниже, в самом журнале.
- Ловушка счёта, в которую ревьюер едва не попал и которую стоит знать: `git grep '^func Test' HEAD --
platform` даёт 262 — лишняя строка из код-блока в архивном доке; счёт по `*_test.go` даёт 261.
### Списки посадок (то, чего не хватало отчёту)
| Раунд | Посадка (что откатывается) | Пин, который обязан упасть | Итог |
|---|---|---|---|
| Дофикс | вставка снова читает только `max(revision)` | `TestABookThatJoinsAfterACancelledUploadStillMovesTheRevision` | поймана |
| Дофикс | `FinishUnspawnedStop` без гарда живой попытки | `TestAStaleUnspawnedStopDoesNotCloseAResumedRun` | поймана |
| Дофикс | пропавший каталог снова = «нет конфигурации» | `TestABookWhoseDirectoryIsGoneIsRejectedRatherThanLeftWaiting` | поймана |
| Дофикс | ожидание конфигурации снова жжёт бюджет | `TestWaitingForAConfigurationDoesNotBringDeletionCloser` | поймана |
| Дофикс | проход интейка снова под бюджетом прогонов | `TestOneBookInTheSweepGetsABudgetAParseCanLiveIn` | поймана |
| Дофикс | просроченный дедлайн снова 500 | `TestAnUploadThatOutlivesItsDeadlineIsNotAnInternalError` | поймана |
| Дофикс | гейт systemd снова по тексту сообщения | `TestTheSystemdGateAsksAboutTheCapabilityAndNotAMessage` | поймана |
| Дофикс | пауза перебивает стоп | `TestAStopDuringSettlementOutranksThePause` | поймана |
| Дофикс | потолок частей формы снова `n > cap` | `TestAFormWithTooManyPartsIsRefusedAtTheCap` | ПЕРЕЖИЛА → пин переписан → поймана |
| Дофикс | wire-тест ревизии без различающей силы | `TestTheCardProjectsTheRevisionTheStoreGaveAndInventsNone` | поймана |
| Дофикс-2 | пропавший корень снова читается как вина книги | `TestAVanishedStorageRootIsNotEveryBooksFault` | поймана |
| Дофикс-2 | смена статуса снова `revision + 1` | `TestAStatusChangeIsVisibleEvenUnderTheLibrarysFloor` | поймана |
| Дофикс-2 | пауза снова без гарда живой попытки | `TestAPauseFromAnOldSnapshotDoesNotCloseARunOverALiveAttempt` | поймана |
| Дофикс-2 | синк снова биндит законченную попытку | `TestAMaterializerOfAnEndedAttemptDoesNotAdoptTheNextAttemptsEngine` | поймана |
| Дофикс-2 | возврат холда снова ждёт ответа движка | `TestTheHoldOfARunThatNeverStartedComesBackOnAHostWhoseEngineCannotAnswer` | поймана |
| Дофикс-2 | гейт systemd снова сверяет сообщение (усиленный пин) | тот же | поймана |
### Батарея дофикса-2
- `make check` с живым PG 18.4 под `-race`: **EXIT=0, 15 пакетов, скипов 0**, линтер **0 issues**.
- Тесты `^func Test` исполнением: **349 → 354** (+5 пинов, удалённых 0).
- Посадки: **6 из 6** (таблица выше, нижние шесть строк).
- ⚠ **`make vuln` покраснел между двумя прогонами ОДНОГО дня, и не из-за дерева.** База адвизори
опубликовала пять уязвимостей stdlib против Go **1.26.5** — `net/http`, `crypto/tls`, `net/url`,
`encoding/xml`, `encoding/asn1` (GO-2026-6218 / 6090 / 6089 / 6088 / 5972), все закрыты в
**1.26.6**, и govulncheck трассирует две из них в пути, которые эта служба зовёт (например
`pgstore.Open → pgx.ParseConfig → asn1.Unmarshal`). Поставил 1.26.6 в `~/.local` (sha256 сверен с
go.dev), поднял `GO_MIN_VERSION` в Makefile 1.26.5 → 1.26.6 с обоснованием на месте, обновил пин в
`STACK_DECISIONS.md`. На 1.26.6 батарея зелёная и скан чист.
**НА РАТИФИКАЦИЮ:** хост сборки, оставшийся на 1.26.5, должен получать отказ.
⚠ **Правка 14.08: в этой редакции он его НЕ получал** — гейт сравнивал регексом и принимал 1.26.5
(ре-чек, FP5-9). Как floor держится теперь — раздел «Третий раунд» выше.
### Записка-план дофикса-2: ID → статус → улика
| ID | Статус | Улика |
|---|---|---|
| Н1 | ЗАКРЫТО | `ErrStorageGone` + `waitsForTheDeployment`; `books.TestAVanishedStorageRootIsNotEveryBooksFault`; посадка падает. PD-192 |
| Н2 | ЗАКРЫТО | `nextRevisionOfThisBooksLibrary` у трёх писателей статуса; `books.TestAStatusChangeIsVisibleEvenUnderTheLibrarysFloor`; посадка падает. PD-122 переформулирована |
| М1 | ЗАКРЫТО | гард живой попытки в `PauseRun` + `run_id` в апдейте попытки; `pgstore.TestAPauseFromAnOldSnapshotDoesNotCloseARunOverALiveAttempt`; посадка падает. PD-193 |
| М2 | ЗАКРЫТО | `ended_at is null` в бинде; `pgstore.TestAMaterializerOfAnEndedAttemptDoesNotAdoptTheNextAttemptsEngine`; посадка падает. PD-194 |
| М3 | ЗАКРЫТО (гипотеза ревью подтверждена мной) | ветка «не спавнился» ДО вызова движка; `runs.TestTheHoldOfARunThatNeverStartedComesBackOnAHostWhoseEngineCannotAnswer`; посадка падает. PD-195 |
| Н3 | ЗАКРЫТО БЕЗ ПИНА (честно) | `writeCtx` на `StartParsing` и обоих `abandon`; граница, а не поведение — пин не заявляется |
| Н4 | СТРОКОЙ | PD-196: exit 1 движка на опечатку в `book.yaml` стоит файла; лечение — на стороне движка |
| Н5 | ЗАКРЫТО | пин FP5-7 разбирает КОД гейта парсером, а не текст файла; посадка падает |
| Отчётность | ЗАКРЫТО | списки посадок перенесены из скрэтчпада в журнал (таблица выше) |
| Тулчейн | НА РАТИФИКАЦИЮ | Go 1.26.5 → 1.26.6 по пяти адвизори stdlib; `make vuln` чист, батарея зелёная |
## Дофикс P5 (11.08): фикс-лист приёмки FP5-1…FP5-8
Пак принят УСЛОВНО с восемью находками (одна high). Ниже — что сделано по каждой, чем проверено и
что осталось. Каждое число снято исполнением, команда рядом. Возражений НЕТ: все восемь
подтвердились на коде до правки — три из них были прямыми последствиями МОИХ же решений, принятых в
разных файлах и не сверенных между собой.
### FP5-1 [HIGH] Пол ревизии библиотеки игнорировался на вставке
Приёмка права, и посылка перепроверена мной на дереве, не по памяти: `DeleteUpload` поднимал
`users.library_revision`, `ListBooks` читал `greatest(max, пол)`, а `nextLibraryRevision` — ТОЛЬКО
`max(books.revision)`. Книга, загруженная после отменённой загрузки, входила на или ниже пола, и
одна ревизия отвечала за три разных состояния библиотеки: до отменённой загрузки, во время неё и
после прихода следующей книги. Клейм «PD-122 закрыта в половине добавления» был ЛОЖЕН — тесты
покрывали только свежий аккаунт, где пол ещё ноль.
Сделано: вставка берёт `greatest(max(revision), users.library_revision) + 1`. Пин —
`books.TestABookThatJoinsAfterACancelledUploadStillMovesTheRevision`, и он гоняет именно ту
последовательность, которой не было: книга → отмена → книга; проверяет и ревизию БИБЛИОТЕКИ, и
ревизию самой новой книги (иначе её карточка устарела бы в момент появления). PD-122 пере-формулирована:
открытым остаётся один случай — смена статуса книги, которая не самая новая, не двигает ни одну из
половин.
### FP5-2 [деньги] `FinishUnspawnedStop` без гарда живой попытки
Тот же гард, что `FinishRun` получил кругом раньше (PD-181), на соседнем пути отсутствовал: путь
закрытия «стопа до спавна» проверял только `unit_name is null`. Устаревший проход мог закрыть уже
резюмированный прогон, и холд второй попытки выпадал из обоих списков; приёмка добавила к этому
следствие, которого я не назвал: каждый следующий резюм отвечал бы 409 навсегда, потому что
продолжать пришлось бы уже завершённый прогон.
Сделано: попытка обязана быть живой и принадлежать этому прогону (`ended_at is null and run_id = $`).
Пин — `runs.TestAStaleUnspawnedStopDoesNotCloseAResumedRun`; посадка «снять гард» падает.
Строка PD-186.
### FP5-3 Крэш-окно «снос каталога → запись строки»
Клейм «порядок самоизлечивающийся» был ложен, и ложным его сделала МОЯ ЖЕ вторая правка того же
круга: снесённый каталог читается как «нет конфигурации», а эту причину я тем же паком сделал
НЕтерминальной — книга оставалась в `parsing` навсегда, ожидая конфигурацию, которую положить некуда.
Диспозиция F11 («порядок выбран самоизлечивающийся») переписана: она стояла на посылке, которая к
моменту записи уже не была верна.
Сделано: пропавший КАТАЛОГ отличён от отсутствующей конфигурации (`ErrDirectoryGone`) и терминален
сразу — движка при этом не зовём вовсе. Пин —
`books.TestABookWhoseDirectoryIsGoneIsRejectedRatherThanLeftWaiting`. Строка PD-187.
### FP5-4 Ожидание конфигурации жгло бюджет разбора
`ClaimParse` считает КАЖДУЮ заявку, а книга без конфигурации заявляется раз в грацию бесконечно.
После пяти циклов ожидания бюджет исчерпан — и первый же ответ движка становится терминальным
мгновенно, вместе с удалением исходника. Движок отдаёт exit 1 и на опечатку в `book.yaml`, так что
цена ошибки оператора — файл пользователя.
Сделано: попытка возвращается (`RefundParseAttempt`), когда движок не был спрошен вовсе; возврат
условен на клейме, как и все прочие записи, заканчивающие проход. Пин —
`books.TestWaitingForAConfigurationDoesNotBringDeletionCloser`: после пятнадцати циклов ожидания
бюджет цел, и первый отказ движка после этого НЕ терминален и ничего не удаляет. Строка PD-188.
### FP5-5 Бэкстоп гнал разбор под бюджетом прохода
Проход интейка шёл под теми же двумя минутами, что и проход прогонов, а разбор внутри — это тот же
вызов движка, которому очередь даёт пятнадцать минут. Большая книга убивалась дедлайном свипа,
убийство читалось как «хост не может запустить движок», попытка списывалась — и так каждый проход,
пока книга не отклонялась ЗА СВОЙ РАЗМЕР. Вторую половину приёмка назвала точно: запись отказа шла
на уже просроченном контексте и терялась, то есть цикл был бесконечным.
Сделано: у прохода интейка свой бюджет (`jobs.JobTimeout + 1m`), у каждой книги внутри прохода —
свой (`jobs.JobTimeout`), а терминальные записи (`FinishParse`/`RejectBook`/возврат попытки) идут на
контексте, переживающем дедлайн вызова. Пин — `books.TestOneBookInTheSweepGetsABudgetAParseCanLiveIn`
(фейк движка читает дедлайн, который ему выдали). Строка PD-189.
### FP5-6 Просроченный дедлайн загрузки уходил 500
Сделано: 408 problem+json. Выбор кода назван вслух: медленный клиент — не сломанный сервис, а 408 по
RFC 9110 §15.5.9 означает ровно «полный запрос не пришёл за время, которое сервер готов был ждать», и
сообщает клиенту, что лечение — повтор. Кода 408 в перечне операции нет, поэтому он внесён в тот же
пакет вопросов владельцу контракта, что PD-172/PD-174/PD-180. Пин —
`httpapi.TestAnUploadThatOutlivesItsDeadlineIsNotAnInternalError`. Строка PD-190.
### FP5-7 Скип-гард systemd-тестов пинил СООБЩЕНИЕ
Приёмка права дважды, и вторая половина — исправление МОЕЙ ошибки в фактах. Гейт существовал и
скипал по подстроке «Failed to connect to bus», а systemd 259 отвечает «Failed to connect to user
scope bus» — гейт перестал гейтить от чужой правки строки. Моя строка PD-178 при этом утверждала, что
формы скипа для systemd в зоне НЕТ ВОВСЕ: это было неверно, и я записал предположение как факт.
⚠ И вторая посылка PD-178 устарела прямо в ходе дофикса: **на этом стенде пользовательский менеджер
systemd теперь ЖИВ** (`systemctl --user show` → `Version=259.5`), и все три теста проходят — тогда
как несколькими часами раньше в этой же сессии его не было (`Linger=no`, sudo нет, ручной запуск
выходил кодом 1). То есть состояние менеджера ПЛАВАЕТ между сессиями стенда, и строка переписана
именно так.
Сделано: гейт спрашивает СПОСОБНОСТЬ — доходит ли процесс до своего менеджера, — и скипает громко и
поимённо, симметрично форме БД-тестов. Пин —
`runner.TestTheSystemdGateAsksAboutTheCapabilityAndNotAMessage`: он падает, если гейт снова начнёт
сверять текст. PD-178 закрыта.
### FP5-8 Хвосты
- **(а) деньги.** Стоп, пришедший в окно расчёта, отвечал `paused/credit_exhausted` — «кончились
деньги» вместо «владелец остановил». Пауза теперь отказывает при висящем интенте
(`ErrStopRequested`), реконсилятор заканчивает прогон стопом. Пин
`runs.TestAStopDuringSettlementOutranksThePause`. Строка PD-191.
- **(б)** PD-153 дополнена: у СТОП-пути окно закрыто с обеих сторон (заявка не выдаётся прогону с
интентом; закрытие «стопа до спавна» спрашивает systemd, если у попытки есть базовая линия). Само
окно PD-153 — грация от `started_at` — не тронуто.
- **(в)** Потолок частей формы пропускал N+1 (`n > cap` вместо `n >= cap`). Пин
`httpapi.TestAFormWithTooManyPartsIsRefusedAtTheCap` — и ПЕРВАЯ его редакция посадку ПЕРЕЖИЛА:
она сверяла только код 400, а форма, у которой просто кончились части, тоже 400, так что обе
версии потолка проходили. Переписан на наблюдаемое следствие: файл стоит ровно на части `cap+1`,
и тест требует, чтобы интейку его НЕ отдали (`in.read == 0`) — при потолке на единицу больше файл
дочитывается и книга принимается. Посадка теперь падает.
- **(г)** Выключатель metrics-листенера был недостижим через окружение: пустое значение переменной
неотличимо от неустановленного и берёт дефолт. Теперь выключает СЛОВО (`off`/`none`), и это
записано там же, где документирован сам выключатель.
- **(д)** Wire-тест ревизии вернул различающую силу: фикстура снова несёт РАЗНЫЕ числа (книга 41,
прогон 12), и тест утверждает то, за что отвечает этот слой — он отдаёт число стора и не выдумывает
своё. Правило, что число берётся из книги, пинится в сторе.
- **(е)** `help` метрики `queue_depth` называет все состояния, которые она считает, включая `pending`.
- **(ж)** Счёт тестов в таблице задач пересчитан исполнением.
### Батарея дофикса
- `make check` с живым PostgreSQL 18.4 под `-race`: **все пакеты зелёные, включая `internal/runner`**
(менеджер systemd на стенде жив — см. FP5-7), линтер **0 issues**.
- Скипы: 0 при живом менеджере; при мёртвом — три ИМЕНОВАННЫХ скипа вместо трёх падений.
- Тесты `^func Test` исполнением: **340 → 349**, добавленных **9**, удалённых **0**, переименован
**1** (`TestTheCardsRevisionIsTheBooksAndNotTheRuns` → `TestTheCardProjectsTheRevisionTheStoreGaveAndInventsNone`,
FP5-8д — свойство то же, имя стало отвечать за то, что тест на самом деле утверждает). Против HEAD:
**261 → 349**. Команда: `comm` двух списков `^func Test` (HEAD и дерево) — единственная строка
расхождения и есть это переименование.
- Посадки дофикса: **10, поймано 10** — но честно: первый круг дал **9 из 10**, пережившая посадка
(в) показала, что пин сверял не то следствие; переписан пин, не мутация. Списки — `mut9.json`.
- `make vuln`: чисто.
**Живая проба дофикса на собранном бинаре** (`go build ./cmd/tmplatformd`, боевой PostgreSQL 18.4,
сессия заведена прямой вставкой в стенд и убрана после пробы):
- FP5-6: сырым сокетом отправлена шапка `POST /v0/books` с `Content-Length: 100000` и одной частью,
дальше клиент «думает» 4 секунды при `TM_PLATFORM_UPLOAD_DEADLINE=2s`. Ответ —
`HTTP/1.1 408 Request Timeout`, `Content-Type: application/problem+json`,
тело `{"type":"about:blank","title":"The upload did not finish in time","status":408}`, соединение
закрыто. Юнит-тест это же место видит через фейковый ридер: живой прогон подтверждает, что дедлайн
РЕАЛЬНО срабатывает на соединении, а не только маппинг ошибки.
- FP5-8г: с `TM_PLATFORM_METRICS_ADDR=off` в печати эффективной конфигурации стоит
`value=off source=environment`, в логе — `no TM_PLATFORM_METRICS_ADDR: this instance exposes no
metrics`, слушателя на порту нет (`ss -ltn` пуст). Со значением-адресом скрейп отдаёт 200.
- FP5-8е: `# HELP tm_platform_queue_depth Jobs in the queue that have not finished (pending,
available, running, scheduled or retryable)` — из живого скрейпа. Там же видно 9 семейств зоны из
десяти: `sweep_unfinished_total` — счётчик с лейблами и серии до первого инкремента не имеет,
это нормальная семантика prometheus, а не пропавшая метрика.
- Заодно: `tm_platform_http_requests_total{code="200",method="GET",route="GET /v0/books"}` — лейбл
маршрута это ПАТТЕРН (F9), и `GET /v0/books` живым Bearer'ом отвечает 200.
### Записка-план дофикса: ID → статус → улика
| ID | Статус | Улика |
|---|---|---|
| FP5-1 | ЗАКРЫТО | `nextLibraryRevision` = `greatest(max(revision), users.library_revision)+1`; `books.TestABookThatJoinsAfterACancelledUploadStillMovesTheRevision`; посадка «снова только максимум» падает |
| FP5-2 | ЗАКРЫТО | гард живой попытки в `FinishUnspawnedStop` (`ended_at is null and run_id = $2`); `runs.TestAStaleUnspawnedStopDoesNotCloseAResumedRun`; посадка падает. PD-186 |
| FP5-3 | ЗАКРЫТО | `ErrDirectoryGone` проверяется ПЕРЕД `ErrNotProvisioned` и терминален; `books.TestABookWhoseDirectoryIsGoneIsRejectedRatherThanLeftWaiting`; посадка падает. PD-187 |
| FP5-4 | ЗАКРЫТО | `RefundParseAttempt` условно на клейме; `books.TestWaitingForAConfigurationDoesNotBringDeletionCloser`; посадка падает. PD-188 |
| FP5-5 | ЗАКРЫТО | свой бюджет у прохода интейка и у каждой книги, терминальные записи на пережившем контексте; `books.TestOneBookInTheSweepGetsABudgetAParseCanLiveIn`; посадка падает. PD-189 |
| FP5-6 | ЗАКРЫТО кодом, вопрос владельцу контракта СТРОКОЙ | `os.ErrDeadlineExceeded` → 408; `httpapi.TestAnUploadThatOutlivesItsDeadlineIsNotAnInternalError`; посадка падает. PD-190 |
| FP5-7 | ЗАКРЫТО, PD-178 переписана и закрыта | гейт спрашивает способность соединиться с менеджером; `runner.TestTheSystemdGateAsksAboutTheCapabilityAndNotAMessage`; посадка падает. Менеджер на стенде сейчас ЖИВ (259.5) — три теста прошли |
| FP5-8а | ЗАКРЫТО | `PauseRun` отказывает при висящем интенте (`ErrStopRequested`); `runs.TestAStopDuringSettlementOutranksThePause`; посадка падает. PD-191 |
| FP5-8б | СТРОКОЙ | PD-153 дополнена: стоп-путь закрыт с обеих сторон; само окно грации не тронуто |
| FP5-8в | ЗАКРЫТО со второго захода | `n >= cap`; пин переписан на «файл за потолком не отдан интейку»; посадка падает |
| FP5-8г | ЗАКРЫТО | выключатель metrics-листенера — СЛОВО (`off`/`none`), пустая переменная неотличима от неустановленной; `config/effective_test.go` |
| FP5-8д | ЗАКРЫТО | фикстура снова несёт разные числа (книга 41, прогон 12); `httpapi.TestTheCardProjectsTheRevisionTheStoreGaveAndInventsNone`; посадка падает |
| FP5-8е | ЗАКРЫТО | `help` у `queue_depth` называет все состояния, включая `pending` |
| FP5-8ж | ЗАКРЫТО | числа в таблице задач пересчитаны исполнением (см. батарею выше) |
## Приёмка P5 оркестратором (11.08): ПРИНЯТ УСЛОВНО — дофикс-раунд ДО лендинга (прецедент P4)
**Что подтверждено исполнением.** Батарея пере-прогнана мной на СВЕЖЕПОДНЯТОМ стенде (zonky PG
18.4, рецепт §«Postgres без root»): **все пакеты зелёные, ВКЛЮЧАЯ `internal/runner`** — на текущем
стенде пользовательский менеджер systemd ЖИВ (`user@1000.service` active, systemd 259), и три
теста PD-178 прошли; линтер 0 issues; тестов **340**, удалённых против HEAD **0** (comm пуст).
Живые числа проб и мутационных прогонов read-only не пере-снимаются — приняты «со слов» с
пометкой. Ревью 10 агентами (6 линз + скептики): **8 находок подтверждено (1 high), 0
опровергнуто, 9 минорных.** Задеты деньги и данные пользователей ⇒ по прецеденту P4 пак
принимается УСЛОВНО, лендинг после дофикса и ре-чека.
**Фикс-лист (движение — запиской-планом ID → статус → улика, D39.121):**
| ID | Находка | Лечение |
|---|---|---|
| FP5-1 **HIGH** | **Пол ревизии библиотеки игнорируется на вставке** (`pgstore/books.go:99`): `nextLibraryRevision` читает только `max(books.revision)`, а `DeleteUpload` поднимает `users.library_revision`; книга, загруженная после отменённой загрузки, входит НА/НИЖЕ текущей ревизии — три разных состояния библиотеки под одной ревизией, revision-keyed refetch слеп. Клейм закрытия PD-122 ложен; тесты покрывают только свежий аккаунт | вставка учитывает пол (`greatest`); тест со сценарием «отмена → повторная загрузка»; PD-122 переформулировать |
| FP5-2 деньги | **`FinishUnspawnedStop` без гарда живой попытки** (`pgstore/sink.go:362`): закрытие по устаревшему снапшоту РЕЗЮМИРОВАННОГО прогона — холд попытки-2 вне `ListLiveRuns` И вне `UnsettledRuns`, движок без надзора, последующий резюм вечно 409. Ровно та гонка, от которой `FinishRun` уже получил гард (PD-181) | тот же гард «закрываемая попытка ещё живая»; пин + посадка |
| FP5-3 | **Крашокно `removeDir → RejectBook`** (`books/parse.go:188`): смерть между ними = книга навечно в `parsing` (ENOENT читается как `not_configured`, терминального пути нет) — клейм «порядок самоизлечивающийся» ложен, диспозиция F11 стояла на ложной посылке | терминальность переживает краш (запись/маркер до сноса либо «каталога нет вовсе» = терминал); диспозицию F11 переписать |
| FP5-4 | **`not_configured`-проходы жгут бюджет разбора** (`parse.go:138`): `ClaimParse` инкрементит `parse_attempts` безусловно ⇒ после ~5 циклов ожидания конфигурации ПЕРВЫЙ отказ движка терминален мгновенно — включая удаление исходника при опечатке `book.yaml` (exit 1 = «вина файла») | `not_configured` не тратит бюджет; пин «ожидание не приближает удаление» |
| FP5-5 | **Свип-бэкстоп гонит разбор под общим бюджетом прохода 2 мин против 15 мин очереди** (`cmd/tmplatformd/runner.go:151`): восстановление большой книги убивается дедлайном свипа, каждый цикл жжёт попытку, а reject на просроченном контексте не записывается — kill-loop | у бэкстоп-разбора свой бюджет ≥ `jobs.JobTimeout` (пара тестом, как `claimGrace`); reject на свежем контексте |
| FP5-6 | **Просроченный дедлайн загрузки уходит 500** (`httpapi/v0.go:404`) — код вне спеки и вне декларации PD-180 | замаппить в честный код problem+json; внести в пакет вопросов PD-180 |
| FP5-7 | **Скип-гард systemd-тестов пинит сообщение, которого systemd 259 не даёт** (`runner/systemd_test.go:25`: ждёт «Failed to connect to bus», стенд говорит «…user scope bus…») — на стенде без менеджера тесты ПАДАЮТ, а не скипаются; PD-178 не знает об этом гарде и устарела фактами (менеджер сейчас ЖИВ, батарея зелёная) | гейт по СПОСОБНОСТИ (проба соединения), скип поимённо — симметрия DSN-формы; PD-178 переписать: «состояние менеджера плавает между сессиями стенда» |
| FP5-8 хвосты | (а) стоп в окне settle отвечает `paused/credit_exhausted` вместо `stopped` (`reconcile.go:543`) · (б) PD-153-окно: полный возврат холда под стартующим движком через новый стоп-путь — дополнить строку · (в) потолок частей формы пропускает N+1 · (г) выключатель metrics-листенера недостижим через env · (д) wire-тест ревизии потерял различающую силу (фикстура 12→41 сравняла прогон с книгой) · (е) help `queue_depth` не называет `pending` · (ж) счёт тестов в таблице задач устарел | по весу: (а)(б)(д) — фиксы с пинами; (в)(г)(е)(ж) — мелкие правки/честные строки |
Развилка `book.yaml` (вопрос зоны) и четыре контракт-вопроса — на мне, ратифицирую при лендинге
дофикса. Дерево остаётся незакоммиченным до ре-чека.
**Ре-чек дофикса-2 (13.08, оркестратор; дата испр. 14.08 — стояло «11.08» ошибочно, дофикс-2 датирован 13.08): батарея воспроизведена зелёной (354 теста, Go 1.26.6,
стенд ~/.local/pgsql:55433), НО 3 находки подтверждены (2 high) — третий раунд по списку ниже,
лендинг после него:**
| ID | Находка | Лечение |
|---|---|---|
| FP5-9 **HIGH** | **Гейт тулчейна НЕ отказывает 1.26.5**: `Makefile:33` — регекс `go1\.26\.([5-9]\|…)` принимает 1.26.5, `GO_MIN_VERSION=1.26.6` живёт только в echo; `go.mod` — `go 1.26.4` БЕЗ `toolchain`; клейм «хост на 1.26.5 получит отказ» ложен в трёх доках (журнал ×2, STACK_DECISIONS §, коммент Makefile) | регекс/сравнение версий чинится по-настоящему (+ `toolchain go1.26.6` в go.mod — реши и обоснуй); пин на функцию сравнения, не на прогон; три клейма поправить |
| FP5-10 **HIGH** | **PD-192 не закрывает заявленный сценарий**: гард — только `Stat(BooksDir)` (`parse.go:235`); unmount тома, смонтированного РОВНО в BooksDir, оставляет пустой mountpoint → Stat успешен → терминальный reject ВСЕХ книг с удалением; безусловный `MkdirAll` на буте (`runner.go:129`) маскирует пропажу после рестарта | различать «хранилище пропало» от «книги нет» устойчиво к обеим формам (кандидат: сентинел-файл провижининга в BooksDir, который бут НЕ пересоздаёт; нет сентинела → ErrStorageGone, не терминалить, бюджет не жечь); пин на unmount-форму |
| FP5-11 | **Н2 закрыт не для всех писателей статуса**: пять писателей жизненного цикла прогона остались на `revision+1` (`pgstore/runs.go:108,517,620` · `sink.go:318,396`) против собственного правила дофикса (`books.go:111`) — staleness библиотеки на статусах прогона | те же «следующий номер библиотеки», что у трёх интейковых; пин на прогонный переход у книги не-максимума аккаунта |
| хвосты | (а) коммент `PauseRun` «в том же стейтменте» против фактического SELECT FOR UPDATE (`runs.go:580`) — текст к коду; (б) комменты `parse.go:41-52` «terminal on the first answer» описывают до-дофиксное поведение на пути удаления файла; (в) остаток FP5-5: в одном проходе бэкстопа полный бюджет получает только первая книга — вторая жжёт попытки на обрезанном контексте (узкий триггер — честная строка либо бюджет на КНИГУ); (г) мета-пин systemd-гейта перечислителен — остаток названный, не дефект | (а)(б) — правки текста; (в) — строка или фикс; (г) — знать |
## Сессия P5 (11.08): загрузка книги · стоп и резюм · наблюдаемость · форма регистра
**Каждое число ниже снято исполнением, команда стоит рядом.** Дерево не коммичено, в индексе ничего
не держу; зона записи — только `platform/`.
### Таблица задач
| Задача промта | Что сделано | Чем проверено |
|---|---|---|
| 1. `POST /books` (П-9, PD-72) | Потоковый multipart (`r.MultipartReader`), пер-маршрутный потолок тела, свой дедлайн чтения, каталог книги под `TM_PLATFORM_BOOKS_DIR`, статусы `uploading → parsing → not_started \| rejected` с писателями, разбор = `tmctl manifest --json`, свип-бэкстоп интейка | **44 теста** (пак + пины дофикса): 22 `internal/books` + 11 `httpapi/intake_test.go` + 6 `pgstore/intake_test.go` + 5 на шов манифеста (`runner/manifest_test.go`). 21 посадка пака, живая проба на боевом бинаре |
| ⚠ половина 1: кто пишет `book.yaml` | **НЕ построена — вопрос ниже.** Построен ШОВ: `books.ErrNotProvisioned`, книга без конфигурации отказывает разбором с причиной `not_configured` | `TestABookWithNoEngineConfigurationWaitsRatherThanDies` |
| 2. Стоп и резюм (PD-140) | `POST /v0/runs/{id}/stop` пишет намерение (`runs.stop_requested_at`, миграция 00014) ДО сигнала; `/resume` переиспользует `reopen` реконсилятора и ставит спавн в очередь, а не в запрос; реконсилятор классифицирует маркер по намерению, не рестартит стопнутый прогон и повторяет стоп живому юниту | **32 теста** (пак + пины дофикса): 28 `runs/control_test.go` + 4 `httpapi/control_test.go`. 25 посадок пака, живая проба |
| 3. Наблюдаемость (П-11) + PD-114 | `prometheus/client_golang` v1.24.1 на отдельном слушателе; 10 семейств метрик зоны, включая запросы и задержки по паттерну маршрута; бюджет свипа НА ПРОГОН (PD-169); печать эффективной конфигурации с источником и редакцией секретов и денег | **12 тестов** (пак + пин дофикса): 5 `internal/metrics` + 6 `config/effective_test.go` + 1 `internal/jobs`. 8 посадок пака, живая проба |
| 4. Регистр секциями (пинг №16) | Таблица разложена на 11 секций (открытые по весу · принятый риск · закрытые ратификацией · закрытые по эрам 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.0` — `ReserveOK`.
Сделано: `runs.meter` (`committed`, `reserved`) читается ОДНИМ вызовом `status --json` перед стартом
(`bookMeter`), аргумент = `committed + прирост` (`meter.bookCap`). ⚠ Слагаемое `reserved` из
ратифицированной формулы НЕ добавляется — это названное отклонение в консервативную сторону, разбор
и вопрос оркестратору ниже, PD-158. `reserved_usd`
внесён в аллоулист `ingest.StatusReport` **указателем** — отсутствие ≠ ноль ровно по той же причине,
что и у committed, только ошибка зеркальная: ноль вместо реального резерва даёт потолок НИЖЕ того, на
чём книга уже стоит. Рестарт идёт тем же путём и получает свежий отсчёт (спавн один на все случаи).
Фактически ушедшее значение хранится: `run_attempts.ceiling_arg_micro_usd` (миграция 00011) — оно не
равно `ceiling_micro_usd`, и после того как счётчик книги сдвинулся, восстановить его нечем.
Пины (посадки — ниже): `TestTheSecondRunOfABookIsGivenTheCumulativeCapAndNotItsOwnIncrement`,
`TestAResumeIsGivenACapComputedFromTheMeterAsItStandsNow`. Оба гоняют фейк `ceilingJudge`, который
судит `--ceiling-usd` **по правилу движка**, включая проход восстановления: открытие книги на запись
обнуляет остаточный `reserved_usd`, после чего потолок на уровне `committed` или ниже отвергается
первой же резервацией. Это и есть ответ на «почему батарея пака этого не поймала»: её фейк лишь
записывал аргумент, а фейк, который не может отказать, не проверяет ничего. Резюмный пин отделён от
арифметики специально: в нём смоделирован ОСТАВЛЕННЫЙ резерв убитого процесса ($0.20), и он
утверждает ДВА факта — что потолок пересчитан по свежему отсчёту и что остаток его не раздул
(PD-158).
Проба приёмки на этом дереве (её файл, только `Reserved` дописан в отчёты фейка):
`TestAcceptSecondRunCeilingArgument` — **PASS**, `run2 args: … --ceiling-usd 6.000000` при
committed книги $3.
Устаревшие посылки, названные приёмкой, сняты:
- `runner/engine.go` больше не пишет, что флага нет и что копировать форму из чужого дерева нельзя:
флаг заленден (`0e69bc1`), форма ратифицирована, поэтому `--ceiling-usd {{usd}}` стал **дефолтом**
конфигурации (`TM_PLATFORM_ENGINE_CEILING_ARG` остаётся переопределением для сборки с другим
написанием). `ErrCeilingNotWired` оставлен: тип по-прежнему допускает пустое значение, а пустой
шаблон не должен значить «без потолка». ⚠ Уточнено ревью доков: он не «недостижим» — переменная из
одних пробелов проходит подстановку дефолта и даёт пустой шаблон, то есть отказ в рантайме при
одном WARN на буте (PD-165 — тот же класс, что относительный `TM_PLATFORM_CTL_BIN`).
- `ingest/resync.go` больше не пишет, что у статуса нет фазового сплита: `progress{draft,edit}`
внесён в аллоулист и материализуется `ApplyStatus` теми же четырьмя счётчиками, что и поток. Этим
снято ⚠ затирания фаз резюнком — не гейтом, а тем, что затирать стало нечем. Гейт
«поток идёт → ре-синк не зовём» оставлен, но его обоснование переписано честно: он теперь про
ЦЕНУ (каждый вызов статуса — секунды CPU на пере-нарезку) и про то, что поток свежее.
### F2 [MAJOR] Инверсия блокировок была жива; транзиентный сбой уходил в карантин
Порядок написан в ОДНОМ месте и стал глобальным — `pgstore.lockBook`:
`books → runs → run_attempts → account_balances → reservations`. Книга блокируется первой в
`RunSink.Apply`, `RestartRun` и `StartRun` (последний добавлен не «за компанию»: он берёт баланс до
`update books`, и если бы книгу первым брал только рестарт, пара `StartRun ∥ RestartRun` дала бы ту
же инверсию на другой паре строк). `closeReservation` уже брал баланс раньше резервации — проверено,
не тронуто. Собственной сверкой всех транзакций пакета найден ещё один нарушитель — `DeleteBook`
(резервации, потом книга); цикла для него сегодня нет, но именно такое рассуждение записанный
инвариант и должен заменять, поэтому книга взята первой и там.
Классификация ошибки материализации вынесена в `runs.quarantines` и стала явной:
транзиентные SQLSTATE (`40P01`/`40001`, `pgstore.IsTransient`) и собственная отмена — повтор
следующим свипом; карантин остаётся только логическим ошибкам (пропасть, конфликт payload, битая
строка, строка длиннее буфера).
Пины. Конкурентный оставлен (`TestAMaterializerAndAReconcilerOnOneRunDoNotDeadlock`, 60×3 через
реальные API), но он **пробует**, а не доказывает: посадка «снять `lockBook` из `RestartRunего
ПЕРЕЖИЛА — рестарт по-настоящему берёт замок один раз за прогон, и окно слишком узкое. Поэтому
написаны два пина, утверждающие порядок ПРЯМО: `TestTheMaterializerTakesTheBookBeforeTheAttempt` и
`TestARestartTakesTheBookBeforeTheAttempt` — тест держит замок книги, ждёт, пока операция реально
заблокируется, и спрашивает строку попытки через `for update nowait`. Свободна — значит операция
ждёт книгу, ничего не держа. Занята — значит она взяла попытку по дороге, то есть в том порядке,
который и дедлочит. ⚠ Ожидание блокировки читается из `pg_stat_activity`, а не из `pg_locks`:
ждущий строку ждёт на `transactionid` держателя, а у таких строк `pg_locks.database` пуст, поэтому
запрос по своей базе их не видит (проверено — первая редакция теста падала именно так).
### F3, F4 [MAJOR, пины] Два свойства, построенных и не запиненных
- **Нечитаемый отсчёт ОТКАЗЫВАЕТ спавну** (сердце PD-124): `TestAnAttemptWhoseMeterCannotBeReadIsNotStartedAtAll`
— три формы нечитаемости (вызов упал · нет `committed_usd` · нет `reserved_usd`), в каждой
утверждается, что юнит не создан И право на спавн не заклеймлено (`unit_name` пуст), значит свип
повторит; хвост показывает, что после починки движка та же попытка стартует.
- **Устаревший exit-маркер стирается ДО старта**: `TestAStaleExitMarkerIsClearedBeforeTheUnitStarts`
— маркер создаётся до спавна, после спавна обязан отсутствовать, свип обязан оставить прогон живым
и холд открытым.
### F5 [MAJOR, деньги] Расчёт по устаревшему снапшоту
`pgstore.ReleaseUnspawned` перечитывает `run_attempts.unit_name` `for update` в ТОЙ ЖЕ транзакции,
что и деньги, и отказывает (`ErrAttemptSpawned`), если юнит есть. Реконсилятор тогда откладывает
расчёт: следующий проход видит юнит в снапшоте и считает по базовой линии попытки. Выбран SQL-гард,
а не перечитывание в Go, потому что гард атомарен с самим возвратом холда.
Пин: `TestAStaleSnapshotDoesNotGiveBackTheHoldOfAnAttemptThatSpent` — в проходе со старым снапшотом
холд не возвращается целиком, следующий свип списывает ровно потраченные $0.50.
⚠ Проба приёмки `TestAcceptStaleSnapshotReleasesTheHoldOfARunThatActuallySpent` на этом дереве **не проходит как написана** и это не
регрессия: она требует, чтобы деньги разрешились в ТОМ ЖЕ вызове `reconcile`, а исправление
откладывает их на следующий проход. Замеренная разница: было — баланс `10.000000` (весь холд назад,
списано ноль), стало — `7.000000` при `3.000000` в резерве (деньги на месте и видимы), после
следующего свипа — `9.500000`.
### F6, F7 [MINOR]
- Относительный `TM_PLATFORM_STATE_DIR` отвергается на буте (`filepath.IsAbs`), пин
`TestARelativeStateDirectoryIsRefusedAtBoot`.
- «Метка давности» ре-синка построена: `runs.last_resync_at` (миграция 00012) пишется КАЖДЫМ
ре-синком, пин `TestAResyncRecordsWhenItWasTaken` (нет метки до первого · метка равна времени
вызова · вторая переписывает первую). ⚠ Названная девиация: **на провод метка не выходит** — в
контракте v0 у прогона поля свежести нет, и заводить его самовольно не право зоны (PD-150).
### F8 [MINOR, отчёт/регистр]
Все пять исправлений внесены: числа тестов пересчитаны исполнением и приведены с командой (105 → 261,
удалённых 0); арифметика «36 новых» исправлена на 27 + 9 (после ратификации PD-112) и подтверждена
скриптом по таблице; заявление «каждый из 22 пинов проверен посадкой» снято как непроверяемое
(PD-142); строка PD-105 приведена к коду — карантинится ПОПЫТКА (её проекция), жизненный цикл
прогона продолжается; PD-122 дополнен: ревизия не двигается и на ДОБАВЛЕНИИ книги (`AddBook` пишет
`revision = 0`), класс тот же.
### Новые строки регистра (N1…N4) и ратификации
Заведены четыре по фикс-листу плюс две собственные (PD-156 — ниже, PD-151 — числа отчёта):
**PD-152** (стоп → `failed`: реальный `tmctl` ловит SIGTERM и выходит кодом 1, так что
ветка `stopped` достижима только для смерти ОТ сигнала — проба пака показала `stopped` на фейке,
который именно так и умирал) · **PD-153** (грация спавна от `started_at`, а не от времени заявки) ·
**PD-154** (`settled_at` может остаться NULL между `Settle` и `MarkSettled`; потребителей нет,
деньги целы) · **PD-155** (смена `StateDir` осиротляет маркеры — строка внесена в `deploy/README.md`).
Ратификации оркестратора внесены в регистр: **PD-112** закрыт ратификацией (`503` вносится в спеку
правкой владельца контракта) · **PD-114** переформулирован в задачу «печать эффективной конфигурации
на старте с редакцией секретов», env-only остаётся · **PD-115** — направление «по внешнему эталону на
ось» ратифицировано, носитель работы — строка · **PD-129** пере-диспозиция: строка была закрыта
ЛОЖНО, действительно закрыта дофиксом (PD-145) · `default_chapters` = верх шкалы остаётся, кода не
касается · PD-113 остаётся открытым до эмиттера.
### Батарея и посадки дофикса
- `make check` с живым PostgreSQL 18.4 под `-race`: **13 пакетов, 0 FAIL**, линтер **0 issues**.
- `go test ./... -count=1 -v | grep -c -- '--- SKIP'` = **0**.
- `make vuln`: **No vulnerabilities found**.
- Тесты: **105 → 261**, удалённых **0** (команда — раздел «Батарея и самопроверка» ниже).
- **Посадки: 24 из 24 пойманы** (скрипт восстанавливает файл после каждой):
`bookCap` возвращает прирост · записывается прирост вместо аргумента · отсутствующий
`reserved_usd` читается нулём · материализатор берёт попытку первой · рестарт берёт попытку первой ·
транзиентный сбой карантинится · нечитаемый отсчёт читается нулём · очистка маркера удалена ·
расчёт доверяет снапшоту (`Release` вместо `ReleaseUnspawned`) · относительный `StateDir`
принимается · метка ре-синка не пишется · метка пишется только однажды · ре-синк снова кладёт один
агрегат · шаблон потолка теряет дефолт · отказ деплоя проверяется ПОСЛЕ вызова движка ·
остаточная резервация раздувает потолок · `DeleteBook` берёт резервации раньше книги · отложенный
расчёт не ограничен верхней границей · транзиентный сбой карантинится (через РЕАЛЬНЫЙ путь) ·
повторная заявка перезаписывает базовую линию · рестарт Postgres карантинит проекцию · сброшенное
соединение карантинит проекцию · ретрай пересчитывает переданный потолок · счётчик назад списывает
молча.
⚠ Две посадки в первой редакции **ПЕРЕЖИЛИ** и это записано, а не подчищено: «рестарт берёт попытку
первой» (конкурентный пин её не ловил — написаны прямые пины порядка) и «метка пишется только
однажды» (тест ставил метку один раз — добавлена вторая проверка).
- Контрактная проба приёмки (47 wire-проверок, `-overlay` поверх пакета `httpapi`) пере-прогнана на
этом дереве целиком: **PASS**, формы не сломаны.
- Гоночная проба приёмки `TestAcceptConcurrentStartRunTakesExactlyOneHold`: **PASS** (8 конкурентных
`StartRun` → 1 прогон, 1 холд) — она проверяет то, что `lockBook` в `StartRun` мог бы изменить.
### Что нашла собственная сверка диффа (после фиксов, до сдачи)
Две правки, обе внесены и обе — про то, что дофикс мог испортить рядом:
1. **`DeleteBook` брал резервации раньше книги** — единственный оставшийся нарушитель порядка,
который я записал как глобальный. Цикла для него сегодня нет (проверено перебором пар: удаление
трогает только ЗАКРЫТЫЕ резервации, а `StartRun` вставляет новую), но инвариант с необъявленным
исключением перестаёт быть инвариантом — книга взята первой и там, и это запинено
(`TestDeletingABookTakesItBeforeItsReservations`; вспомогательный `assertBookFirst` обобщён на
любую вторую строку — попытку или резервацию).
2. **Отказ ДЕПЛОЯ проверялся после вызова движка.** Перенеся чтение отсчёта в начало `spawnAttempt`,
я поставил его ПЕРЕД дешёвыми отказами («нечем записать конец юнита», «нечем передать потолок»),
и инстанс с неполной конфигурацией платил бы секундами CPU движка за каждый прогон на каждом
свипе, чтобы прийти к ответу, зависящему только от конфигурации. Вынесено в `runs.runnable()`,
вызывается первым; пин `TestAMisconfiguredDeploymentIsRefusedWithoutAskingTheEngine` (посадка
«убрать ранний отказ» падает — 15-я в списке выше).
### Формула потолка: почему `reserved` в неё не входит (PD-158, ратифицировано 09.08)
Сдавалось как ВОПРОС с консервативным кодом; ре-чек V2 ратифицировал формулу зоны, пере-мерив обе
против гейта движка — та, что с `reserved`, переплачивала запасом ровно на leftover-reserved.
Первая формула — `committed + reserved + прирост` — читает `reserved_usd` из `status --json`. Но
`store.Open`, путь ЗАПИСИ, которым идёт каждый `translate`, выполняет `recoverReservations` и
обнуляет `reserved_usd` книги ДО того, как будет судима первая резервация
(`backend/internal/store/store.go:88`, тело — `:214`); `tmctl status` идёт read-only и этот проход
намеренно не делает — движок пишет об этом сам (`store.go:108`: «after a run crashes, reserved_usd
stays non-zero until the next WRITE command … status will show this leftover as reserved»).
Значит цифра, которую платформа кладёт в потолок, к моменту сравнения уже стёрта, и прогон получает
на её величину БОЛЬШЕ, чем его холд: движок останавливается позже, расчёт упирается в потолок холда,
леджер пишет «capped at the hold» — и это читается как перерасход движка, хотя это наша арифметика.
Путь достижим на каждом резюме после падения, потому что падение и оставляет резервацию.
**Что сделано:** `bookCap` считает `committed + прирост`. Отклонение в КОНСЕРВАТИВНУЮ сторону — более
узкий потолок может только остановить прогон раньше, перерасхода не даёт, — названо в коде и
запинено, и сдано ВОПРОСОМ, а не решением: отклонять ратифицированное молча не право зоны. Ответ
пришёл ре-чеком V2 — формула принята. Цифра `reserved`
по-прежнему читается и обязательна: она и есть доказательство, что отклонение безопасно — на спавне
другого писателя нет (эксклюзивный лок плюс один живой прогон на книгу), значит любой `reserved` по
построению остаток.
### Найдено при F1 и заведено, а не построено
**PD-157: дневной потолок книги `--ceiling-usd` не перекрывает.** Ратификация D39.122 говорит это
прямо, и движок требует хотя бы один из `book_usd`/`day_usd` (`backend/internal/config/book.go:250`),
а `book.yaml` пишет ОПЕРАТОР — платформа его не правит (D39.110 §2b) и в `book add` лишь проверяет
наличие. Значит книга с низким `day_usd` останавливает прогон на лимите, которого платформа не
выбирала: движок выходит кодом 1, прогон приезжает `failed`, деньги пользователя целы, причина ему
не видна. В `status --json` дневной фигуры нет вовсе, так что и диагностировать это платформа
сегодня не может. Не строил: фикс-лист этого не просил, а закрывается оно либо проверкой при
заведении книги, либо словом контракта о том, кто владеет потолками `book.yaml` у книг под
платформой — второе не право зоны.
### Селф-ревью дофикса: три верификатора, что нашли
Прогнаны три независимых ревьюера по разным линзам — один по фикс-листу и диффу, один НАМЕРЕННО без
отчёта («вне карты»), один по claim-fidelity доков против кода. Все трое работали read-only, мутации
гоняли в копиях дерева.
**Найдено и ПОЧИНЕНО в этом же дереве:**
1. **PD-159, деньги, major.** Отложенный расчёт прогона оплачивал работу СЛЕДУЮЩЕГО прогона той же
книги, а тот платил за неё ещё раз. Нашли ДВА верификатора независимо, каждый воспроизвёл
исполнением; я воспроизвёл третьим ($2.10 за прогон, стоивший $0.10) ПЕРЕД починкой. Причина —
расчёт читает пожизненный счётчик КНИГИ в момент повтора, а завершённый-но-нерассчитанный прогон
не мешает начать новый. Починено верхней границей `SpendBound` (наименьшая базовая линия среди
попыток книги, стартовавших позже — она снята после остановки этой попытки и до того, как та
что-либо добавила, то есть ТОЧНАЯ граница).
2. **PD-160, пин.** «Транзиентный сбой не карантинит» пинилось на чистой функции; удаление ветки,
которая её ВЫЗЫВАЕТ, переживало батарею. Написан пин, который гонит НАСТОЯЩИЙ дедлок через весь
путь.
3. **PD-161, деньги.** Повторная заявка права на спавн перезаписывала базовую линию — при
`systemd-run`, убитом ПОСЛЕ подачи запроса, попытка платила бы разницу от цифры, включающей её же
работу.
**Найдено, заведено и НЕ построено** (каждое — строкой с названным лечением): PD-152 расширен живым
замером — штатная перезагрузка закрывает все прогоны как `failed`, потому что `ExecStopPost`
отрабатывает и движок выходит кодом 1 (путь строки 138 покрывает только потерю питания) · PD-162
удалённый каталог книги заклинивает прогон навсегда с открытым холдом · PD-168 бюджет перезапуска
считается по текущей ставке (замерено: $5.50 вместо $2.50 при удвоении) · PD-163 ревизия области
читается вторым запросом · PD-164 неразбираемый маркер вечно валит реконсиляцию · PD-165
относительный `TM_PLATFORM_CTL_BIN` и `$` в `quoteArgv` · PD-166 `chunker_version` теряется ·
PD-167 расхождение док↔код в комментарии миграции · PD-169 бюджет свипа один на все прогоны ·
PD-170 асимметрия `Settle` с PD-97.
**Найдено в ДОКАХ и исправлено:** тело F1 противоречило собственному разделу PD-158 · описание фейка
`ceilingJudge` отстало от кода · §21 `STACK_DECISIONS` не догнал PD-158 · старый раздел «Сессия P4»
утверждал, что флага потолка в HEAD нет · числа тестов и посадок разъезжались между журналом и
регистром · PD-158 приписывал D39.122 буквальную формулу, которой в решении нет (она из пинга) ·
PD-122 требовал «свою миграцию», хотя колонка `users.library_revision` существует с 00001 и не
используется ни одним путём · «`ErrCeilingNotWired` недостижим через конфигурацию» — переменная из
пробелов до него доходит.
### Ре-чек V2 (оркестратор №15, 09.08): четыре хвоста, три — в новом коде дофикса
**V2-1 [MAJOR] — моя же классификация была неполна.** `IsTransient` знала только про дедлок и
сериализацию, поэтому ШТАТНЫЙ рестарт Postgres (57P01/57P02/57P03, класс 08, сетевой сброс) всё ещё
карантинил проекцию живого платного прогона НАВСЕГДА — пути снятия карантина в дереве нет. Клейм
PD-145 «карантин остаётся только логическим ошибкам» был в этой части неверен и пере-сформулирован.
Теперь покрыты класс 08, 57P0x, `pgconn.SafeToRetry` и любой `net.Error`; ошибки чтения ФАЙЛА при
этом остаются логическими — `*fs.PathError` не удовлетворяет `net.Error`, поэтому битый журнал
по-прежнему останавливает проекцию, как и должен. Шесть новых кейсов в таблице пина, две посадки.
**V2-2 [MINOR, деньги] — PD-161 был закрыт наполовину.** В БД значения сохранялись, а движку на
ретрае уходил потолок, пересчитанный по СВЕЖЕМУ счётчику, то есть включающий трату собственного
«призрака»; форензик-колонка 00011 на этом пути лгала (замер приёмки: handed 3.400000 против stored
3.000000 — воспроизведён посадкой на моём дереве до буквы). Теперь ретрай ПЕРЕДАЁТ сохранённый
аргумент. Пин расширен до `handed == stored`, плюс отдельный пин на гард в БД
(`TestASecondClaimOnOneAttemptKeepsWhatTheFirstRecorded`) — после правки в Go посадка на `coalesce`
переставала ловиться, и свойство без собственного пина не считается закрытым.
**V2-3 [MINOR, доки у денег]** — три текста утверждали отменённую формулу (`reconcile.go` про
рестарт, комментарий поля `Reserved`, строка PD-144) и два — несуществующий отказ загрузчика на
пустое переопределение. Всё приведено к факту. Туда же: **комментарий миграции 00011 нёс отменённую
формулу, а 00009 обещал перечитывание файла, которого тейлер не делает.** Обе исправлены, отпечатки в
`migrations.sha256` обновлены — с явной причиной в шапке файла: обе миграции написаны этим паком,
нигде не применялись, а миграция с лгущим комментарием хуже сдвинутого отпечатка до релиза.
Ре-чек назвал застывшим комментарий 00011 под номером PD-167, тогда как предмет PD-167 — 00009;
исправлены обе, расхождение в нумерации названо в строке.
**V2-4 [NOTE]** — счётчик книги ниже собственной базовой линии попытки списывал $0 молча. Клампить в
ноль правильно (платить за подмену БД проекта код решать не вправе), молчать — нет: добавлен WARN, не
INFO, с фактом и без цифр.
### Что дофикс НЕ трогал (осознанно)
Ручки стопа/резюма (PD-140) · эскроу и `uncertain` (строка 136) · `POST /books` (П-9) · собственный
счётчик ревизии области (PD-122) · грация от времени заявки (PD-153) — заведены строками, не
построены: фикс-лист их не просил, а расширять пак на приёмке — это то, за что заведён PD-83.
---
## Сессия P4 (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** | Инверсия порядка блокировок `books`↔`runs` между материализатором и финишером ⇒ взаимоблокировки | Книга блокируется первой везде |
| **PD-130/131** | Любая ошибка чтения журнала выглядела как «догнали»; хендшейк не обязан был нести `seq 1` | Только настоящий EOF; `seq != 1` = плохой хендшейк |
| **PD-132** | Холд прогона, который так и не стартовал, не возвращался | Нет линии и нет юнита ⇒ холд возвращается целиком |
| **PD-133/134/135** | Инстанс без движка не отдавал даже библиотеку; пиннинг версии писался и не читался; прерванный прогон вставал `paused` без причины | Чтения монтируются отдельно; `EngineBinary` читается и используется; `PauseRun` вместо `FinishRun` |
| **PD-117…121** | Потолок ниже минимума схемы отвечал 409 вместо 400 · `Usage.paused_reason` недостижим через путь реконсилятора · карточка несла ревизию ПРОГОНА (отстающую) · чужой курсор пагинации принимался · «exhausted» при остатке, на который прогон стартует | Все пять закрыты, каждая с пином |
| **PD-136/138** | Деплой-юнит нёс обе диспозиции сразу; `go.mod` не тидинут | Переписано; `go mod tidy` |
| **PD-142** | **Аудит силы пинов: 139 посадок, 107 поймано, 32 пережили** (8 из них — не ослабления: эквивалентный код или страховка DDL). Пережившие — не дефекты кода, а отсутствующие пины, часть на свойствах, объявленных закрытыми | 22 новых пина написаны; ⚠ «каждый проверен своей посадкой» СНЯТО приёмкой (PD-142/PD-151) — прогонов посадок было девять. Самые весомые: блокировка строки попытки под КОНКУРЕНЦИЕЙ (восемь горутин на одно событие — последовательная доставка посадку не ловила) и **CSRF на контрактной поверхности** (снятие переживало всё, а это старт платного прогона с амбиентной кукой) |
**Оставлено открытым и названо:** PD-122 (`Library.revision` не монотонна при удалении книги — сегодня
недостижимо, ручки удаления нет; правильное решение — свой счётчик области, это своя миграция и
несколько путей записи, поэтому НЕ сделано, а объявлено гейтом) · PD-137 (упорядочение на
пользовательский менеджер — UID site-specific, инструкция в шапке юнита) · PD-139 (пути книг в
ERROR-логах — диспозицию надо принять осознанно) · PD-140 (`Service.Stop` не подключён: ручек
стопа/резюма в скоупе промта не было) · PD-141 · PD-112/113/123.
**Что ревью подтвердило, а не опровергло:** привилегия-модель (с одним уточнением, принятым:
«никакое правило не сузит» верно для ТРАНЗИЕНТНЫХ юнитов; шаблонный системный юнит с фиксированным
`User=`, запускаемый `StartUnit`, — вариант, которого мой разбор не касался) · арифметика
`CeilingBounds` (баланс КАК ЕСТЬ, без второго вычитания холдов — проверено на живом PG пятью
раскладами) · отсутствие кросс-аккаунтных чтений и стартов · инвариант `balance == SUM(ledger)`
(сломать не удалось) · правила тейлера по областям и курсору.
**Возражение ревью, которое я принял только частично.** Верификатор предложил различать стоп по
потолку через `ceiling_pct` из `status --json`. Не взял: `ceiling_pct` считается против
`book_ceiling_usd`, то есть против потолка ИЗ `book.yaml`, а наш потолок приходит аргументом прогона
(строка 145), которого в HEAD НА ТОТ МОМЕНТ не было — значит смысл поля зависел от ещё не залендённой
формы (флаг заленден 09.08, `0e69bc1`; альтернатива от этого не становится верной — `ceiling_pct`
по-прежнему считается против потолка из `book.yaml`, а не против переданного аргумента). Взять его
сейчас значило бы построить дискриминатор на поведении, которого я не могу проверить. Записано в
PD-113 как названная и оценённая альтернатива.
**Повторная сквозная проба на боевом бинаре после починок** (чистая база, фейковый движок с
НАКОПИТЕЛЬНЫМ счётчиком книги, как у настоящего): грант $10.00 → прогон 1 (движок насчитал $0.40) →
баланс **9.600000** → прогон 2 на ТОЙ ЖЕ книге (счётчик книги $0.40 → $0.80) → баланс **9.200000**,
резервов ноль. До починки второй прогон списал бы $0.80.
### Вопросы (канал вопросов промта; НЕ тихая интерпретация)
1. **`503` вне перечня спеки** (PD-112) — вносить в спеку или назвать другой код?
2. **`default_chapters` = верх шкалы.** Когда книга дороже баланса, верх шкалы резервирует ВЕСЬ
баланс под одну книгу, и вторую начать нельзя (D39.110 §2в). Альтернатива — доля от максимума;
какая именно — вопрос продуктовый, не инженерный.
3. **Конфигурация (вопрос ВЛАДЕЛЬЦА 08.08, PD-114).** Ответ по существу: единого stdlib-пути в Go
нет; мейнстрим — либо 12-factor «только окружение» (наш выбор, записан в доккомменте `config.go`,
деплой уже использует systemd-нативные `EnvironmentFile=`+`LoadCredential=`), либо слоёная
`флаги > env > файл > дефолты` (koanf/viper). Ниже нормы у нас другое: **27 переменных**
(грепнуто), **ноль флагов** у демона, **нет печати эффективной конфигурации** при старте.
Вопрос владельца при этом нашёл реальный дефект в этом же паке: дефолт «$/глава» лежал в двух
местах — исправлено, резолвится только в `config`.
4. **Абстрактный вопрос владельца, и он подтверждается фактом (PD-115).** `ENGINEERING_STANDARDS` §2
называет внешнюю версионированную базовую линию ровно для ОДНОЙ оси — безопасности (ASVS 5.0 L2 +
API Top-10 2023). Всё остальное — собственная проза зоны. Разница видна эмпирически: PD-57/PD-58
нашлись именно сверкой с RFC 9700/9207 и NIST SP 800-63B. Там, где эталона нет, сверять не с чем —
грепнуто на 08.08: метрик и трейсинга **ноль**, процедуры бэкапа/восстановления в `deploy/` нет,
SLO не заданы. Предложение: §2 получает по эталону на ось; ратификация — оркестратора.
## Закрытые эры P0P3 — в архиве
Разделы сессий P0P3 и их ратификаций (0408.08) вынесены в
[archive/platform-PROGRESS-P0-P3.md](archive/platform-PROGRESS-P0-P3.md) (D39.124).
Решения оттуда живут в D-логе и `DEFECT_REGISTER.md`.
## Пинги оркестратора (живые ссылки для следующих сессий)
**Пинг оркестратора №15 (09.08, D39.122): движковый пак «блокеры контракта» ПРИНЯТ и заленден `0e69bc1` — пять поверхностей для платформы существуют. ФИНАЛЬНЫЕ формы (менялись трижды за приёмку — старые в переписке игнорировать):**
- **Манифест глав:** `<project_db>.manifest.json`, `manifest_version: "tm-manifest-v2"` (v1 движок сам отклоняет); id главы = 16 hex (стабилен через пере-нарезку); **`unit.id = <chapterID>:<cutTag>:<firstChunkIdx>`** — тег разреза 8 hex, при любой смене нарезки/данных пары unit.id УМИРАЮТ намеренно (id жив ⇒ якорь цел); поле `heading` — ВРЕМЕННЫЙ рендер движка «Глава N», НЕ метка книги (решение владельца 09.08; настоящие заголовки — строка 160 бэклога движка). $0-команда `tmctl manifest` строит дерево до первого прогона (первое касание создаёт БД проекта — то же поведение, что у status).
- **Прогресс:** `status --json` → `progress: {draft:{done,total}, edit:{done,total}}` на книге и в каждом элементе `chapters`; `done` = «разрешено волной» (ok/flagged/skipped); волна, которой нет, — `total: 0`; процент не отгружается — собирает клиент.
- **Банк:** `<project_db>.bank.json` (весь банк тремя статусами; id термов — длино-префиксированный хеш ключа уникальности, стабилен через пересборку) и `<project_db>.bank-stop.json` (полная таблица подписи; `conf: null` ≠ 0). Все сайдкары пишутся атомарно (temp+rename) — читать можно во время прогона.
- **Потолок (строка 145): `tmctl translate|redrive --ceiling-usd <usd>` — это КНИЖНЫЙ ПОТОЛОК В СИЛЕ, не бюджет прогона** (ратифицировано D39.122): леджер сравнивает значение с накопленным committed+reserved книги. Пересчёт пользовательского «прирост в главах» (D39.110) в абсолют — ОБЯЗАННОСТЬ платформы. **⚠ АМЕНДИРОВАНО D39.123 (PD-158): формула = `committed_usd + прирост×оценка`, БЕЗ reserved** — read-only `status` показывает leftover-reserved, который `store.Open` зануляет до первой судимой резервации; включение переплачивало бы запасом сверх холда (исполнено обеими формулами против гейта). `reserved_usd` читается обязательным полем как гард присутствия и улика leftover. Значение ниже уже потраченного откажет первой же резервации. День-потолок не перекрывается. Опция по желанию зоны: движок готов провести `--ceiling-usd` и в `status`, чтобы мониторинг видел действующий потолок capped-прогона (сегодня status показывает книжный) — скажите, заведём строку.
## Пинг оркестратора №16 — 09.08.2026 (форма DEFECT_REGISTER, строка 167)
Реестр дефектов дорос до 171 строки одной таблицей; докс-аудит D39.125 (worksheet `docs/archive/reports/DOC_AUDIT_INVENTORY_2026-08-09.md`, жалоба «зонная изоляция прячет половины тем») просит форму: разложить таблицу на СЕКЦИИ (open по весу · accepted-risk · fixed по эрам паков), сохранив построчную форму `| PD-N | … |` — `docs/scripts/counts.py` ключуется формой строки, не позицией, секции его не ломают. Заведите П-строкой в свой BACKLOG, исполнение — попутно следующим паком, НЕ срочно. Вопросы — через владельца.
## Пинг оркестратора №17 — 15.08.2026 (migrate принят, D39.134 — движковая половина самолечения ФИНАЛЬНА)
Бэкенд-пак `tmctl migrate` принят и заленден (`d55edd4`): exit 13 = `schema_mismatch` финализирован, токен `schema_mismatch found=N expected=M` на stderr стабилен, write-путь тоже отказывает БД новее бинаря. Ваш PD-201 (самолечение «поймал 13 → migrate → повтор») теперь можно строить — движок больше не сдвинется под ногами; строку реестра, ссылающуюся на «движкового migrate ещё нет», обновите. Хвосты вашей зоны, найденные приёмкой (вход промта P7, исполнение попутно): (1) `deploy/README.md:131` цитирует МЁРТВЫЙ текст ошибки схемы («schema vN … expects vM») — движок его больше не печатает; (2) та же мёртвая цитата и уплывший якорь `store.go:135-143` в строке П-1 вашего BACKLOG; (3) комментарий-образец в `tmplatformctl` (`runs.go:45`) зовёт голый `tmctl` из PATH — копипаст воспроизводит тихий no-op СТАРЫМ бинарём при живом деадлоке, ваш же README:139-141 требует версионированный путь. Вопросы — через владельца.
## Пинг оркестратора №17 (второй) — 15.08.2026 (S4 принят, контракт 0.2.3 в каноне — пять строк вашего регистра протухли; аудит корпуса)
Фронт S4 принят и заленден (D39.135), контракт **0.2.3** в каноне. Для вашего регистра: (1) **PD-172 · PD-173 · PD-174 · PD-180 — диспозиция «спек-правка 0.2.3 задачей S4» (D39.130 п.2в) ИСПОЛНЕНА** — правила стоят в `openapi.yaml` §createBook (PD-172 при этом уточнён ПО ВАШЕМУ коду: чтение останавливается на файле, обязательное после файла = 400 «как не слали», необязательное молча теряется — D39.135 п.2б); обновите статусы. (2) **PD-199 ратифицирован закрытым ещё D39.132 п.2а** («null на проводе подтверждён»), в регистре до сих пор open — привести. (3) **Ваше НОВОЕ обязательство в P7** (D39.135 п.2в): читать `BookIntake.title` (сегодня падает в «unknown field is IGNORED», `v0.go:369-376`) и проецировать `books.reject_reason` на провод (сегодня «kept for an operator and never projected», `v0.go:497`) — без этого 0.2.3 остаётся обещанием без носителя. (4) Кандидаты в P7 по аудиту корпуса 15.08: **PD-219** (упавший дрейн теряет хвост `unit_resolutions` навсегда — зона сама вешала его на читающую поверхность) · **PD-217** (книга на вечном холде блокирует апгрейд движка — вторая половина после D39.132) · **PD-162** (удалённый каталог книги = вечный прогон с открытым холдом). (5) ⚠ Ваш же журнал честно фиксирует: «`tmctl migrate` живьём не гонялся» — команда теперь СУЩЕСТВУЕТ и принята (D39.134); мнение оркестратора: прогнать деплой-рантбук end-to-end на дев-стенде настоящим migrate — обязательное предусловие первого выката, дешевле любого пака. Вопросы — через владельца.