# Пак P7, акт 5 — исполнение фиксов по ревью акта 4 > ⚠ **РАБОЧИЙ ДОКУМЕНТ СЕССИИ, ОРКЕСТРАТОРУ ЧИТАТЬ НЕ НУЖНО.** Это мой процесс: как я шёл, что проверял, что передумал. Для лендинга нужны три вещи, и все они в других местах — что сделано и зачем: шапка `platform-PROGRESS.md` · статус каждого дефекта: `DEFECT_REGISTER.md` · правила, переживающие пак: `STACK_DECISIONS.md` §33–36. > > Составлен 20.08.2026 сессией платформы ДЛЯ СЕБЯ ЖЕ после компакции контекста. > Ты — та же сессия, но контекста у тебя нет. Читай этот файл целиком, он самодостаточен. > > ✅ **ИСПОЛНЕН ЦЕЛИКОМ 20.08**, и закрыт адверсариальной самопроверкой собственного диффа > (37 находок, 18 пережили рефутеров — PD-315…PD-327; две из них были ПИНЫ этого акта, проходившие > под своей же мутацией). Очередь §6 закрыта вся, работа из §5а (A · E · G · J) сделана, строки > заведены в регистр (PD-300…PD-327 плюс правки PD-202/246/253/262/276/282/284/298). > Что где — **§5б**. Остаток на будущее — §8 (PD-297 и оси, которых не смотрел никто). > Итог сессии — `platform-PROGRESS.md`, шапка. ## 0. Где ты находишься Пак P7 (читающая поверхность по контракту 0.3.0) прошёл: приёмку (акт 1), правки (акт 2), приёмку правок (акт 3), доработку (акт 4). **Ничего не закоммичено — зона не коммитит, лендит оркестратор.** Состояние дерева НА КОНЕЦ АКТА 5: `make check` — 18 пакетов, exit 0, **скипов 0**, линтер 0 issues, тестовых функций **520**. Регистр — 314 строк, открытых 64 (12 minor, 52 info), major и BLOCKER нет. *(Ниже — состояние на НАЧАЛО акта 5, как оно было записано; исполнение — §5б.)* Тогда было: 503 теста, регистр 299 строк, открытых 68 — и ни находки ревью, ни правки акта 5 в регистр занесены не были. **Лендить было НЕЛЬЗЯ.** Акт 4 закончился адверсариальным ревью собственного диффа (9 линз × 2 рефутера, 75 агентов): **33 находки, 27 пережили хотя бы одного рефутера**, и часть из них — дефекты, которые внесли правки акта 4. ⚠ **20.08 вернулась контрактная сессия с ответом на записку зоны — §5а.** Часть диспозиций акта 4 там ПЕРЕВЁРНУТА, и появилась работа, которой в §6 нет. Читать §5а до §6. Куда делись 27: **6 закрыты в акте 5** (§5 — снятие стопа банка нашли четыре линзы, это четыре находки на один фикс, плюс две про пары) · **20 расписаны в §6** · **1 (round-trip на строку в `SaveStructure`) — в §8 п.1 как PD-297**, осознанно не взята. Сходится: 6 + 20 + 1 = 27. ⚠ Сырьё ревью — вне репозитория (`~/.claude/projects/.../subagents/workflows/wf_5ad930db-b5c/`), перезагрузку может не пережить. **Этот файл — и есть запись**: механизм, отказ и способ проверки по каждой находке перенесены сюда, возвращаться к сырью не нужно. ## 1. Что читать, в этом порядке 1. **Этот файл** — рабочий список. 2. `platform/docs/P7_ACCEPTANCE_HANDOFF.md` — §0–§3 (что было), **§2 целиком (стенд с нуля — рецепт проверен исполнением, там же грабли)**, §8.1а (три вопроса на владельце). 3. `platform/docs/DEFECT_REGISTER.md` — **источник истины по статусу каждого дефекта**. Проверка: `python3 docs/scripts/counts.py` от корня репозитория. 4. Канон `docs/architecture/14-api-contract/openapi.yaml` (`info.version: 0.3.0`) — нормативен для формы, побеждает любой другой док. Компаньон `README.md` рядом — провенанс, для формы НЕ нормативен. 5. `CLAUDE.md` в корне — гардрейлы зоны. ⚠ `platform/docs/CONTRACT_SYNC_FROM_PLATFORM.md` — записка, которую владелец отдаёт ОТДЕЛЬНОЙ контрактной сессии. **Ничего из неё не исполнять**: там вопросы, решения по которым принимает не зона. ## 2. Гардрейлы (нарушение любого дороже любого фикса) - Пишешь ТОЛЬКО в `platform/`. `backend/`, `eval/`, `frontend/`, `docs/` — read-only. - **Git не трогать вовсе** — ни `add`, ни `commit`, ни `reset`, ни `checkout`. Файлы застейдженными не оставлять. Лендит оркестратор. - **`.env` не читать никогда** (`backend/.env`, `eval/.env`). - **Тест/гейт не подгонять под зелень.** Несогласие с пином — вопрос, не правка. Исключение, которым ты будешь пользоваться в §6: если ты меняешь ПОВЕДЕНИЕ по ратифицированному основанию, фикстуру теста разрешено привести в соответствие — но это надо назвать вслух, а не сделать молча. - Итоги сессии — ТОЛЬКО в `platform/docs/platform-PROGRESS.md`. В `docs/PROGRESS.md` платформа не пишет. - Комментарии — короткие и по делу (владелец 20.08, дважды). Никакого нарратива ревью в коде («found by…», «two reviewers found…»), никаких замеров прозой. Код — короткое уверенное решение. - **Движок ОДИН и общий.** Go не ветвится по языку, паре или книге; добавление пары не должно требовать правки Go, и один факт не должен быть записан в двух местах. Ревью-вопрос по умолчанию: «заработает ли пара, которой в репо ещё НЕТ, без правки Go?» ## 3. ЧЕМУ НАУЧИЛ АКТ 4 — три ловушки, каждая стоила настоящего дефекта **(1) Правка, закрывающая находку, сама подлежит проверке исполнением.** Три места числились закрытыми и закрыты не были: правка не закрывала собственный сценарий, а зелёная батарея это «подтверждала» — пин писал тот же автор и фиксировал его намерение. Поймала только повторная сверка находки против дерева с посадкой мутации. **(2) Пин, который проходит под своей же мутацией, ХУЖЕ отсутствия пина.** В акте 5 это случилось на глазах: тест на снятие стопа банка прошёл под мутацией, потому что фикстура не доходила до проверяемого кода — **часы сервиса захвачены при конструировании (`Now: func() time.Time { return now }`), и `f.now = f.now.Add(...)` их НЕ двигает** (нужно `f.svc.Now = func() time.Time { return late }`). Лечение, которое надо применять всегда: **добавляй утверждение, что фикстура ДОШЛА до состояния** — «попыток должно стать две», «строк должно быть N», — иначе тест наблюдает пустоту. Проверка правки = посадка мутации: испортить код, увидеть падение, вернуть. **(3) Складывая двух писателей в одного, проверь ВСЕХ вызывающих общего пути.** Дефект акта 4: снятие стопа банка сложено внутрь `reopen`, а `reopen` зовут двое — пользовательский `resume` И реконсилятор. Условие по статусу дало реконсилятору право снимать стоп, который никто не подписывал. ## 4. Как проверять — команды ```sh # стенд (детали и грабли — §2 хендоффа) ~/.local/pgsql/bin/pg_ctl -D ~/.local/share/tmstand/pgdata \ -l ~/.local/share/tmstand/pg.log -o "-k /tmp -p 55433 -c listen_addresses=''" start source ~/tmstand-work/stand.env # ОБЯЗАТЕЛЬНО: без него батарея молча скипует ~200 тестов cd /home/ubuntu/projects/textmachine/platform && make check # 18 пакетов, exit 0, СКИПОВ 0 python3 docs/scripts/counts.py # от КОРНЯ репозитория ``` Грабли, каждая стоила времени (полный список — §2.5 хендоффа): - `curl` только с `--noproxy '*'`, иначе прокси отвечает 403 и это читается как дефект кода. - Демон стенда — ОТДЕЛЬНЫЙ бинарь, сам не пересобирается: убить по PID → `go build -o ~/tmstand-work/tmplatformd ./cmd/tmplatformd` → поднять. Иначе живая проба меряет вчерашний код. - Правил миграцию — пере-создай базу (`dropdb tmstand && createdb tmstand`): goose ключуется НОМЕРОМ. - После пере-создания базы старый cookie-jar мёртв: `curl -c ~/tmstand-work/stand/cookies -X POST http://127.0.0.1:8099/auth/dev-login`. - Красный `TestARunIsBoundedByItsOwnCgroup` — обычно НЕ регрессия кода: у `tm.slice` опустел `cgroup.subtree_control`. Лечение без root: `echo "+memory +pids" > /sys/fs/cgroup/user.slice/user-1000.slice/user@1000.service/tm.slice/cgroup.subtree_control`. - Postgres стенда живёт сокетом в `/tmp` и переживает не всё; `pg_ctl … status` покажет. ## 5. Что в акте 5 УЖЕ СДЕЛАНО — не переделывать (но занести в регистр) 1. **Реконсилятор больше не снимает стоп банка.** `internal/runs/reconcile.go`, в `RestartInput`: `LiftBankStop: from == fromAFinishedRun && l.Status == "awaiting_bank"`. Пин `runs.TestTheReconcilerDoesNotLiftABankStopNobodySigned` — проверен посадкой мутации, ловит обе половины (`bank_released` и argv спавна). **Это был самый тяжёлый дефект ревью: четыре независимые линзы, нарушение согласия пользователя.** 2. **Загрузочная проверка пар — на ДОСТУПНОЙ половине.** `config.AvailablePairs()` — одно место, которым пользуются и проверка, и `intakePairs` демона. Деплой, объявивший только `:unavailable`, больше не стартует. 3. **Интейк не трактует пустой список как «пускать всё»** (`books.canTranslate`: escape hatch снят), и сравнивает теги **регистронезависимо** (`strings.EqualFold`) — `zh-Hans` против `zh-hans` это одна пара по BCP 47. ⚠ Первичный сабтег регистр НЕ варьирует: канон `LangCode` — `^[a-z]{2,3}…`. Пины: `config.TestAnIntakeWithNoDeclaredPairsIsRefusedAtBoot`, `books.TestAnUndeclaredDeploymentRefusesAndADeclaredPairIgnoresCase`. 4. Фикстура `books.newFixture` объявляет свою пару — иначе она моделировала конфигурацию, которая больше не может существовать. ⚠ **Строк регистра на эти четыре ещё нет.** Заведи их вместе со строками по §6. ## 5а. ОТВЕТ КОНТРАКТНОЙ СЕССИИ (20.08) — читать ДО §6 Записка `CONTRACT_SYNC_FROM_PLATFORM.md` разобрана целиком, все 14 пунктов имеют диспозицию. Разбор — `docs/architecture/14-api-contract/README.md` §6в (по разделу на пункт), канон стал **0.4.0**. ⚠⚠ **СТАТУС: 0.4.0 лежит в дереве контрактной зоны, но НЕ РАТИФИЦИРОВАН** — ратифицирует оркестратор лендингом, до этого в силе 0.3.0. Читать и планировать можно; **вкладываться в реализацию до лендинга — на твоё усмотрение, форма может ещё сдвинуться словом владельца.** Если берёшь — бери в порядке ниже и держи правки отделимыми. ⚠ Ломающая правка ровно ОДНА — `Run.stop_requested`; остальное проза и второй уровень кода. ⚠ Ссылки §6в даны на рабочее дерево 20.08, а не на HEAD: у нас 81 незакоммиченный файл и номера строк разошлись. Адресуйся именами функций. **Три вопроса из §8.1а хендоффа ЗАКРЫТЫ этим ответом** — на владельце по контракту больше ничего нет. ### Что придётся сделать в коде (по убыванию цены) **(A) Исчерпанный прогон — 409 вместо 202. Заменяет диспозицию «оставили как есть».** `resume` прогона, потратившего купленный потолок, отвечает `409 run_not_resumable`, `cause.code: ceiling_reached`. ⚠ **И находка шире, чем зона подавала:** `exhausted` возвращается из ДВУХ мест `reopen`, и второе — «счёт не тянет холд» — отдельный случай, для него заведён новый `cause.code: credit_unavailable` (второй уровень не закрыт, типы не двигает). Подменять друг друга нельзя: `ceiling_reached` = «этот прогон закончен, нужен новый», `credit_unavailable` = «пополнить». Плюс канон теперь обещает обратное: **202 означает, что работа реально переоткрыта.** → Работа: развести два `exhausted` в `reopen`, смапить на две причины, сменить 202 на 409. Пересекается с 6.5 (там та же развилка нужна для `Usage`), делать вместе. **(G) Draft-only: «сделано» = ПОСЛЕДНИЙ проход, который книга на ЭТОМ деплое реально получает**, а не названная волна. Проекция — наша. ⚠ **И это ДЕНЬГИ, а не полоса** — перепроверено зоной 20.08: `ChaptersLeft = chapter_count − (главы, где units_done >= units_total)`, а `units_done` считает только волну `edit` (`sink.go`, и пересчёт в `readmodel.go`). На деплое без редактора вычитаемое всегда ноль, кламп не срабатывает, и сервис **бесконечно предлагает купить уже переведённые главы**. Обычно холд возвращается целиком (платформа держит потолок, а не цену, и неизменённая книга переигрывается с чекпойнтов за $0), поэтому «берёт за них деньги» контрактная сессия из канона СНЯЛА как непроверенное; плата возникает только если между прогонами сдвинулся снапшот. → Работа: считать по последнему реально пройденному проходу — и в полосе, и в `chaptersDone`, и в `ChaptersLeft`. **Снимает PD-202 из «сомнений» (§8 п.2) в работу.** **(E) Отпечаток интейка: «тот же запрос» = метаданные + имя файла + СОДЕРЖИМОЕ.** Фраза про «name and size» из канона убрана, `Content-Length` из нормы вышел совсем. Чем устанавливать тождество — наше дело (дайджест на лету). **Ключевое правило: не можешь установить тождество — НЕ реплей, отвечай `409 idempotency_conflict`.** Поля размера в форму не добавили осознанно: оно не закрывает «две разные книги одной длины». Полное лечение из нашего же регистра (сверка дайджеста принятых байтов с откатом, как у трейлинг-части) контракт теперь РАЗРЕШАЕТ. → Работа: убрать `r.ContentLength` из `intakeFingerprint`; считать дайджест принятых байтов и сверять на завершении; при невозможности установить тождество — 409. **Закрывает PD-262 по-настоящему** и снимает пункт из §7 «отклонено». **(J) `Run.stop_requested` — ОБЯЗАТЕЛЬНОЕ булево** (не необязательное: тотальная функция, нет стопа → `false`; необязательность дала бы два написания одного факта). Снимается при `resume`. В `projectRun`. → Работа: колонка уже есть (`runs.stop_requested_at`), нужно протянуть в `Run` и в проекцию. **Это единственная ломающая правка 0.4.0.** **(D) `unspecified` ратифицирован** — как ОБЯЗАННОСТЬ сервера, а не строка словаря причин. То, что зона уже отдаёт, стало легальным; выдумывать слово под незнакомую причину по-прежнему нельзя. → Работы нет. PD-246 можно закрывать в части «плейсхолдер не ратифицирован». **(H) PD-298 — механизма НЕ строим.** Сверка с движком (её сделала контрактная сессия) показала: переход «флаг снят» гасит announce-once-леджер, то есть недостижим. Но канон закрыл разрыв, который вопрос обнажил: строка поодиночке не отзывается, а коллекция, обязанная потерять строку, теряет её заменой целиком с `resync_required`. → Работы нет, но **инвариант записать**: если мы всё-таки запишем `flagged=false` поверх `true` — обязаны выдать кадр, а не дать `note_count` разъехаться со списком. PD-298 перевести в «недостижимо, проверено чтением движка» с этой оговоркой. ### Что ОТКЛОНЕНО — не переоткрывать - **(B) Суточный потолок движка, обе предложенные формы.** Владелец закрыл 15.08 (D39.132 п.2а): `day_usd` убран из шаблона, слово в контракт не заводится. Плюс предложенное зоной булево «лечится покупкой / не лечится» **неверно по смыслу**: потолок снимается сам в полночь UTC, и клиент, прочитавший «не лечится», спрячет кнопку навсегда там, где честный ответ «не сейчас». Вместо этого — одна фраза в `resumeRun`. Триггер пересмотра: день, когда `day_usd` вернётся в шаблон деплоя. ⚠ Поправка к нашей формулировке: платформа это поле не заводит, но шаблон пере-маршалится целиком, поэтому оператор внесёт его без единой правки Go. - **Признак «прогон ещё продолжаем» на `Run`** (предложение зоны). Посылка «знаем точно» верна, «дёшево» — нет: `ReadRun` это один запрос, а флагу нужны ТРИ чтения, которых в нём нет, и платится это на карточке, которую канон велит перечитывать на каждый кадр. Плюс факт устареет к клику, а «стоит ли предлагать старт» уже отвечен ратифицированным порядком чтения пяти мест (§RunOptions). Пересмотр — если ЗАМЕРИМ, что мёртвый клик частый, ИЛИ свернём три чтения в подзапросы того же запроса. ### Закрыто текстом — работы не требует - **(C)** Фраза §2.14 восстановлена; разбор зоны «словарь в компаньоне = нормативность с чёрного хода» принят дословно и стал основанием. Сверка на другие срезанные правила — §6в п.0. - **(F)** Валидатор легален на ЛЮБОМ безопасном чтении — список исключений не нужен. Наше отклонение в §7 теперь имеет ратифицированное основание. - **(I)** `content_refused` объявлен до производителя осознанно, **долг не заводить**; обязанность лимита попыток пере-привязана к производителю. - **(L)** Ссылка приложения А починена. **`410` — канон изменён в НАШУ сторону:** различать «была» и «не было» невыполнимо, расхождение перестало быть расхождением → **PD-253 закрывается**. ### Не наше **(K)** Пер-термный гейт движка против D39.144 — бэкенд и ратификация; `mined_rejects` — строка 192, отложена владельцем. Но контрактно видимая половина молчала и теперь записана: канон обещает `decline` «leave it out», а решение до движка не доезжает — предупреждение стоит на `BankDecision.action`. ## 5б. ЖУРНАЛ ИСПОЛНЕНИЯ (20.08, акт 5 продолжение) — что уже закрыто Каждый фикс проверен посадкой мутации (номера M — в порядке проверки, все поймались). - **6.1 + 6.2 + 6.4(а) — ЗАКРЫТЫ одним механизмом.** Миграция `00019`: колонка `books.read_model_owed_at`, ставится в транзакциях `FinishParse` и `FinishRun`, снимается `ClearReadModelDebt` **по РАВЕНСТВУ метки** (долг, поставленный позже, переживает материализацию). `AtRest` получил `and read_model_owed_at is null` (предикат `nothingIsRunning` вынесен в одну строку, его же читает очередь). Дрейн переехал в `readmodel.Drain` — `runs.pendingRefresh/DrainRefresh/deferRefresh/Reader` и `books.materializeMissingTrees/BooksWithNoTree` УДАЛЕНЫ (два механизма → один). Отказ канала текста теперь возвращается вызывающему (`Export` → `errors.Join`), поэтому неполная материализация долг НЕ гасит. Мутации M1–M4, M13. - **6.3 — ЗАКРЫТ.** Миграция `00020`: `idempotency_keys.claim_token`, `pgstore.ClaimToken` минтится при клейме и ПЕРЕ-минтится при перехвате; `complete` и `release` несут токен, несовпадение → `ErrClaimLost`. **PD-284 пере-открыть и закрыть заново этой правкой.** Мутации M5, M6, M9. - **6.6 — ЗАКРЫТ целиком (5 пинов).** `TestTheClaimOfARunCarriesTheBooksOwnPath` (новый, путь ≠ паттерн) · несжимаемый путь в `TestAnOverlongPathDoesNotBreakTheClaim` · `TestTheRunsBarNeverExceeds WhatItBought` (новый, кламп связан) · граница дедлайна загрузки (ниже) · `TestEveryCodeNamesExactly OneStatus` сверяется с ТРАНСКРИПЦИЕЙ канона, а не с той же таблицей. Мутации M7, M8, M10, M14. - **6.12 — ЗАКРЫТ.** Проверка одна вместо двух: `min(UploadGrace, ClaimStale)` и `+ books.UploadSettle` (то, что загрузка делает ПОСЛЕ тела). Мутации M11, M12. - **§5а (G) — ЗАКРЫТ.** `finishedUnits` = «последний проход, который книга на этом деплое реально получает»: движок объявляет форму (`runs.edit_total = 0` при `draft_total > 0` = редактора нет, `beginWaves`). Читают все пять мест (`chaptersDone`, `runProgress`, `emitChapter`, `ReadBookForRun`, `bookScope.wave`). Колонка `chapters.units_done` (дубль `units_edit_done`, читателей не осталось) снесена миграцией `00021`. **PD-202 закрывается этим.** **Закрыто во второй половине акта 5:** - **6.4(б)** — форма выбрана осознанно (пару вставляем, текст не трогаем, долг держит книгу в очереди); ре-кат с упавшим экспортом самолечится следующим проходом дрейна. - **6.5 + §5а (A)** — одним заходом: вердикт `exhausted` разведён на `ceilingSpent` и `creditUnavailable`, `resume` отвечает 409 с РАЗНЫМИ причинами, а остановка АККАУНТА читается с аккаунта (сканирование прогонов снято целиком). PD-304, PD-312, PD-282. - **6.7** — замерено до и после: 16.8 → 6.6 мс на страницу; счётчик замечаний переехал на главу (00022), согласие со списком стало конструктивным. Бенчмарк оставлен в дереве. PD-306. - **6.8** — `blocked` называет книгу с НАИБОЛЬШЕЙ суммой холдов. PD-307. - **6.9** — `startRun` отвечает 400. PD-308. **6.10** — табличный пин на дыру в один кадр. PD-309. - **6.11** — пять обрубков починены (не четыре: нашёлся ещё один, док-блок `StuckIntake`). PD-310. - **§5а (E)** — дайджест файла на лету + сверка повтора до ответа. PD-262 закрыт по-настоящему. - **§5а (J)** — `Run.stop_requested`; побочно три копии списка колонок сведены в `runRow`/`scanRun`. - **§5а (H)** — инвариант записан в `sink.go` `unitDone`, строка PD-298 обновлена. **Не взято, с причиной:** PD-297 (round-trip на строку в `SaveStructure`) — §8 п.1; оси, которых не смотрел никто — §8 п.6. ## 5в. САМОПРОВЕРКА АКТА 5 — что она изменила в механизмах Ревью правило не только строки, но и две конструкции: - **Долг на материализацию стал АРЕНДОЙ.** Колонка теперь означает «когда долг СЛЕДУЮЩИЙ РАЗ подлежит оплате», а не «когда возник»: тот, кто берётся платить, отодвигает срок (`ClaimReadModelDebt`), очередь берёт только `<= now()`, неоплаченный долг едет в конец. Без этого интейк и свип читали движком одну книгу одновременно, а книга, про которую движок ответить не может, держала голову очереди вечно. - **Форма пайплайна («есть ли редактор») переехала с прогона на КНИГУ** — `books.edit_wave`, миграция 00024, монотонно и от объявления движка. Читать её с последнего прогона нельзя: последний — самый новый, и он ещё ничего не объявил. Обе замены — ответ на находки, а не украшение; обе с пинами и посадкой мутации. ## 6. ОЧЕРЕДЬ ФИКСОВ — по убыванию вреда пользователю Каждый пункт: где · механизм · отказ · как чинить · чем проверить. Все они пережили рефутеров; дробь в заголовке — сколько рефутеров из двух НЕ опровергли (`2/2` — не опровергли оба; два слагаемых — две отдельные находки в одном пункте). ⚠ Порядок — по вреду ПОЛЬЗОВАТЕЛЮ, а не по удобству. 6.1–6.2 стоит делать одним заходом: это одна поломка с двух сторон. ### 6.1 [BLOCKER-класс, 1/2] Поток говорит `end` раньше, чем появляется дерево **Где.** `internal/pgstore/events.go` — предикат `AtRest` в `ReadStream`; следствие в `internal/httpapi/stream.go` (кадр `end` и ветка 204). **Механизм.** `books.Parse` коммитит `FinishParse` (`parsing → not_started`, `chapter_count` проставлен, кадр `status` выпущен) и ТОЛЬКО ПОТОМ зовёт `RefreshCut` — два процесса движка на собственном бюджете. В этом окне `AtRest` истинно (статус уже не `parsing`, живого прогона нет), и насос в той же итерации шлёт `end`; автоматический реконнект браузера получает **204 «не переподключайся»**. Клиент перестаёт смотреть ровно тогда, когда дерево вот-вот появится. Замерено ревьюером на живом PG: `chapters materialized=0 AtRest=true Position=3 Oldest=1`. ⚠ **Это ровно Ф-56** — та самая причина, ради которой поток строился. **Вторая половина того же (6.2)** — граница ПРОГОНА: прогон закрыт, текст ещё не материализован, `AtRest` истинно → `end` → 204, и готовый оплаченный перевод до читателя не доезжает. **Как чинить.** «В покое» обязано означать «и материализация не должна». Интейк-половина выражается предикатом, который уже есть у `BooksWithNoTree`. Прогонная половина требует ДОЛГА, которого сегодня нет — см. 6.2. **Рекомендация: чинить обе половины ОДНИМ механизмом** (6.2), иначе получится два предиката про одно. **Чем проверить.** Пин на `ReadStream`: книга с `chapter_count > 0` и пустым деревом обязана дать `AtRest == false`; плюс httpapi-пин, что такой поток НЕ шлёт `end`. ### 6.2 [HIGH, 2/2] Долг на материализацию живёт только в памяти процесса **Где.** `internal/runs/reconcile.go` — `deferRefresh`/`DrainRefresh`/`pendingRefresh` (слайс под мьютексом), и `refreshReadModel`, где отказ только логируется. **Механизм.** Прогон завершился и **уже оплачен**. Долг на материализацию кладётся в слайс в памяти. Две достижимые потери: (1) `Refresh` вернул ошибку — ветка логирует и НИЧЕГО не ставит обратно; (2) демон перезапустился (деплой, systemd, падение) — слайс умер вместе с процессом. В обоих случаях у книги дерево от прошлой границы ЕСТЬ, поэтому единственный бэкстоп (`BooksWithNoTree`, «дерева нет вовсе») её не видит, и текст оплаченного прогона не доедет до читателя никогда. **Как чинить (рекомендация зоны, но решение твоё).** Сделать долг ДОЛГОВЕЧНЫМ: колонка на `books` (например `read_model_owed_at timestamptz`), пишется в той же транзакции, что `FinishRun` и `FinishParse`, снимается `SaveStructure`. Тогда одним механизмом закрывается: - `DrainRefresh` берёт список из БД, а не из слайса (переживает перезапуск); - упавший `Refresh` оставляет колонку — повтор бесплатен; - предикат `AtRest` получает `and read_model_owed_at is null` — 6.1 обе половины; - **мой бэкстоп `BooksWithNoTree` и `materializeMissingTrees` становятся ИЗБЫТОЧНЫ и удаляются** — это удаление кода, а не добавление. Цена: миграция `00019` + правки в `pgstore` (колонка, три писателя, предикат), `runs` (источник списка), `books` (снять бэкстоп). ⚠ Правь НОВУЮ миграцию, `00016`–`00018` уже пере-фингерпринчены один раз; при правке любой из них пере-создавай базу стенда и обновляй `migrations.sha256`. **Чем проверить.** Пин: завершить прогон, уронить `Refresh`, убедиться, что долг ПЕРЕЖИЛ и следующий проход его забрал. Пин: книга с непогашенным долгом не «в покое». ### 6.3 [HIGH, 1/2 + 1/2] Ключ идемпотентности не знает своего владельца **Где.** `internal/pgstore/idempotency.go` — `ReleaseIdempotency` и `CompleteIdempotency`. **Механизм.** Строка ключа несёт `(user, method, path, key)` и НЕ несёт, какая попытка её держит. Отсюда два отказа, оба воспроизведены ревьюером на живом PG: - **Освобождение чужого клейма.** Попытка A застряла дольше `ClaimStale`; ретрай B получил перехват и уже создаёт книгу; A наконец падает, и `defer key.release(...)` — который добавил акт 4 — **удаляет живую строку B**. Третья попытка получает свежий клейм и делает работу ВТОРОЙ раз. - **Квитанция чужого запроса.** У A забрали клейм; `and finished_at is null` (правка акта 4) её не останавливает — строка B ещё не завершена, — поэтому A дописывает СВОЙ ответ, а завершение B получает `ErrClaimLost`. Клиент реплеит `location` книги, которой у него нет. ⚠ **Это значит, что PD-284 закрыт преждевременно:** запись в регистре утверждает, что окно закрыто загрузочной проверкой, а она закрывает только случай «загрузка дольше окна» и ничего не говорит про освобождение. **Пере-открой PD-284** и добавь строку про освобождение. **Как чинить.** Дать клейму владельца: токен (uuid/bytea), который пишет `ClaimIdempotency` и пере-минтит при перехвате; носить его в `httpapi.idempotent`; повесить `and claim_token = $N` и на `complete`, и на `release`. Несовпадение — `ErrClaimLost`, а не тихий успех. ⚠ **НЕ пытайся вместо токена сравнивать `claimed_at`:** Go даёт наносекунды, Postgres хранит микросекунды, равенство не сойдётся. Эту ловушку акт 4 уже обошёл, повторять не надо. **Чем проверить.** Пин живым PG, который ВХОДИТ в ветку перехвата: claim → перехват → завершение первой попытки обязано быть отвергнуто → реплей обязан отдать ответ ПЕРЕХВАТИВШЕГО. ### 6.4 [HIGH, 1/2 + MEDIUM, 2/2] Упавшее чтение пар отчитывается успехом, и книга остаётся пустой **Где.** `internal/readmodel/readmodel.go` `refreshStructure`; `internal/pgstore/readmodel.go` `writeUnits`. **Механизм, две половины одной семьи.** - `refreshStructure` только ЛОГИРУЕТ отказ `Export` и возвращает nil, поэтому интейк считает материализацию успешной. Пер-парный флаг `TextKnown` защищает только ветку `do update`, а **первая вставка пишет пустые `source`/`target` безусловно** — книга получает полное дерево пустых пар, и читатель не видит даже собственного исходника. - **На ре-кате флаг мёртв в принципе:** ре-кат минтит НОВЫЕ id пар (кат едет в id), значит каждая пара идёт по ветке INSERT, а старые строки удаляются. Ре-кат с упавшим экспортом стирает текст переведённой книги. **Как чинить.** (а) Отказ канала текста обязан быть ВИДЕН вызывающему (`errors.Join` в то, что возвращает `Refresh`/`RefreshCut`), чтобы интейк и свип знали, что поверхность неполна. (б) При `TextKnown == false` пара не должна вставляться с пустым текстом — либо не вставлять её вовсе (тогда дерево неполно и долг 6.2 держит книгу в очереди), либо вставлять и НЕ считать материализацию состоявшейся. Выбор формы — твой; обе половины должны попасть под один долг из 6.2. **Чем проверить.** Пин: дерево с текстом → ре-кат с упавшим экспортом → текст обязан уцелеть или книга обязана остаться должной. Посадка: вернуть безусловную вставку → падает. ### 6.5 [HIGH, 1/2] `Usage` зажигает флаг остановки АККАУНТА от потолка одного прогона **Где.** `internal/pgstore/books.go` (предикат `Usage.PausedReason` — ревьюер указывает :783), проекция в `internal/httpapi/v0.go`. **Механизм.** `reopen` возвращает `exhausted` для ДВУХ разных фактов — «прогон потратил свой потолок» и «на счету пусто», — а `restart` паузит оба значением `PausedCreditExhausted`; сюда же `CeilingPause(ScopeBook)` кладёт книжный потолок движка. Предикат `Usage` превращает любой из них в остановку АККАУНТА. Замерено: счёт $10, прогон на 10 глав ($0.30) — провод отвечает `{"state":"ok","remaining_percent":97,...,"halt_reason":"credit_exhausted"}`. Канон предупреждает об этом дословно (§AccountHaltReason): зажечь общий по аккаунту статус из причины, которая про аккаунт ничего не говорит, значит сказать пользователю с деньгами, что денег нет. **Как чинить.** Остановка аккаунта — факт про АККАУНТ: либо снять сканирование прогонов, либо соединить его с `balance <= 0`. Отдельно стоит развести два `exhausted` в `reopen` (это же нужно вопросу §8.1а п.1 хендоффа). **Чем проверить.** Пин живым PG: счёт с деньгами + прогон, потративший свой потолок → `halt_reason` обязан быть пуст. ### 6.6 [MEDIUM×3 + LOW×2] Пины, которые утверждают больше, чем проверяют Каждый — с мутацией, под которой он проходит СЕГОДНЯ. Все проверены ревьюером исполнением. | Пин | Мутация, которую он НЕ ловит | |---|---| | `httpapi.TestTheClaimCarriesTheRequestsOwnOperation` | `Path: r.URL.Path` → `r.Pattern`: единственный маршрут, который тест гоняет (`POST /v0/books`), имеет путь, совпадающий с паттерном. Нужен случай на `POST /books/{bookId}/runs` с фикстурой, где смонтированы И `Runs`, И `Keys` | | `pgstore.TestAnOverlongPathDoesNotBreakTheClaim` | `pathHash()` → `[]byte(k.Path)`: фикстура из 4000 повторяющихся байт СЖИМАЕТСЯ в индексном кортеже и до предела btree не доходит. Нужен несжимаемый путь | | `pgstore.TestASecondRunsBarStartsAtZeroOverAHalfFinishedBook` | снятие `least(…, ceiling_chapters)`: фикстура заканчивает ровно одну главу и покупает ровно одну, кламп не связывает. Либо поправить фикстуру, либо снять ложное «Mutation caught» из комментария | | `config.TestAnUploadDeadlineLongerThanEitherWindowIsRefused` | снятие проверки против `UploadGrace`: оба значения цикла ≥ `ClaimStale`, поэтому их ловит ВТОРАЯ проверка. Нужно значение между 30 мин и 1 ч | | `httpapi.TestEveryCodeNamesExactlyOneStatus` | `CodeGone: {StatusConflict, "Gone"}`: `statusOf(c)` это буквально `codes[c].status`, утверждение сравнивает запись таблицы с собой. Сверять надо С КАНОНОМ, а не с таблицей | ⚠ Последний — регрессия акта 4: слияние статуса и заголовка в одну таблицу сделало пин тавтологией. ### 6.7 [MEDIUM, 1/2] `note_count` стоит 96% времени `GET /v0/books` — регрессия акта 4 **Где.** `internal/pgstore/readmodel.go`, константа `noteCount`, вкомпилированная в `bookColumns`. **Механизм.** Акт 4 добавил джойн на `chapters`, чтобы счётчик и список замечаний описывали одно множество (PD-288). Замерено ревьюером: 40 корпусных книг, страница по умолчанию — **636 мс против 24 мс** с `note_count`, заменённым литералом. **Как чинить.** Не откатывать вслепую: расхождение двух контрактных полей реально. Варианты — покрывающий индекс под этот джойн; либо счётчик, эквивалентный джойну без него (номера глав плотные `1..chapter_count`, но опираться на это в SQL — неявная связь, назови её, если берёшь); либо материализованный счётчик. **Мерить до и после** — эта правка и появилась из-за незамеренного решения. ### 6.8 [MEDIUM, 1/2] `blocked` называет СТАРЕЙШИЙ холд, а не тот, что укоротил шкалу — регрессия акта 4 **Где.** `internal/pgstore/credits.go` `CreditHeldBy`. **Механизм.** Акт 4 научил функцию возвращать СУММУ чужих холдов, и решение «укорачивает ли шкала» принимается по сумме — верно. А книга по-прежнему выбирается `order by opened_at limit 1`. Замерено: $10, на книге A держится $0.03 (открыт первым), на книге B — $9.60; `blocked` называет A, и пользователь отменяет прогон, который ничего не освободит. **Как чинить.** Называть книгу с НАИБОЛЬШИМ холдом (или вернуть список). Пин на две книги с разными суммами. ### 6.9 [MEDIUM, 2/2] `startRun` отвечает 413 кодом, которого канон там не объявляет **Где.** `internal/httpapi/v0.go` — тело `startRun` идёт через общий лимит и `uploadFailed`. **Механизм.** JSON-тело свыше `DefaultMaxBody` даёт `413 payload_too_large`, тогда как соседняя JSON-запись `POST /books/{id}/bank/decisions` на то же условие даёт `400 invalid_request`. Канон перечисляет ответы `startRun`: 400/401/403/404/409/503 — 413 среди них нет, а §ErrorCode определяет `payload_too_large` как «свыше `intake_max_bytes`», то есть про ЗАГРУЗКУ. **Как чинить.** Ответ зоны — привести `startRun` к 400; но проверь канон сам, это контрактно видимо. ### 6.10 [MEDIUM, 1/2] Границы дыры в потоке не запинены на ОДИН кадр **Где.** `internal/httpapi/stream.go` — обе проверки дыры. **Механизм.** `emitFrame` подрезает по одному кадру за кадр, поэтому обычная дыра у живого соединения — шириной РОВНО В ОДИН кадр. Пин PD-283 гоняет дыру в 189 кадров, поэтому сдвиг любой из границ на единицу батарея не заметит. **Как чинить.** Табличный пин на дыру в один кадр по обеим границам. ### 6.11 [LOW, 2/2] Обрубки от прохода по болтливости — регрессия акта 4 **Где.** `internal/pgstore/runs.go` (у `EngineStreamID`), `cmd/tmplatformctl/seed.go`, `internal/httpapi/problem.go` (непарная скобка), `internal/books/render.go`. **Механизм.** Проход, срезавший нарратив ревью, обрубил четыре фразы на середине; один комментарий остался без подлежащего. Это тот же дефект, что был в акте 3 — **при следующем таком проходе читай результат целиком, а не только удаляемое.** ### 6.12 [MEDIUM, 1/2] Загрузочная граница оставляет секунду **Где.** `internal/config/config.go` — проверка `UploadDeadline < ClaimStale`. **Механизм.** Граница сравнивает дедлайн с окном и не оставляет ничего на работу ПОСЛЕ чтения тела: принимается `29m59s`, а терминальные записи интейка идут на `writeCtx` с бюджетом 30 секунд. **Побочно:** после 6.3 (токен клейма) эта граница перестаёт быть единственной защитой, но остаётся полезной. Решить, нужен ли запас, и назвать его явно. ## 7. ЧЕГО ДЕЛАТЬ НЕ НУЖНО **Опровергнуто рефутерами в ревью акта 4 — не переоткрывать:** - «Ре-кат с упавшим экспортом обнуляет всю книгу, включая source» — механизм описан неверно (реальная половина — в 6.4, в другой формулировке). - «Кадр `progress` минтится на юнит для счётчика, который движется по главам». - «Бэкстоп дерева гоняет движок на книге, чей workdir мог исчезнуть». - «Упавшее завершение оставляет ключ в полёте на 30 минут». - «`seed` спрашивает пару у деплоя, но грузит фиксированный демо-фрагмент» — демо-данные пары легитимны (CLAUDE.md). - «Единственная строка лога в зоне, кладущая id книги в индекс оператора». **Отклонено триажем акта 4 с причиной (см. хендофф §8.2), не пересматривать без нового довода:** строка журнала `:213` про 204 (верна) · `ETag`/`304` на `getUsage`/`getRunOptions` — **отклонение теперь РАТИФИЦИРОВАНО** (0.4.0: валидатор легален на любом безопасном чтении, §5а F) · полоса `0/N` (PD-281, решение владельца) · порядок неизвестного термина в отказе (канон порядок не фиксирует). ⚠ **`r.ContentLength` в отпечатке БОЛЬШЕ НЕ отклонён** — 0.4.0 сменил норму, работа в §5а (E). **Решено владельцем, не переоткрывать:** статус `awaiting_bank` при гонке со стопом пользователя остаётся (PD-273; лечение — признак в контракте) · грант при регистрации = 0 · sqlc отложен. **Не исполнять из `CONTRACT_SYNC_FROM_PLATFORM.md`** — это вход другой сессии. ## 8. Сомнения, которые надо держать в голове 1. **`PD-297` (round-trip на строку в `SaveStructure`).** Замерено ревьюером: 22 830 стейтментов и 4,56 с на ЛОКАЛЬНОМ Postgres; на управляемом при 1–2 мс RTT это 23–45 с, всё время под эксклюзивной блокировкой книги. Инструмент штатный — `tx.SendBatch` (pgx v5 уже драйвер модуля). Не сделано осознанно: путь самый опасный на запись, и первая приёмка понизила находку до DOUBT/LOW. **Если берёшь — мерь до и после, на корпусной книге.** 2. ~~`PD-202` (draft-only)~~ — **ОТВЕЧЕНО контрактной сессией, переехало в работу: §5а (G).** Форма задана («последний реально пройденный проход»), и это деньги: `ChaptersLeft` не подрезается вовсе. 3. ~~`PD-298` (снятие флага)~~ — **ОТВЕЧЕНО: переход недостижим**, сверку с движком сделала контрактная сессия. Механизма не строим; остаётся инвариант из §5а (H). 4. **Непрерывность `event_position`.** Обнаружение дыры в потоке опирается на то, что позиции идут без пропусков (единственный писатель — `pgstore.emitFrame`, в одной транзакции). Если появится второй писатель или позиция начнёт минтиться без вставки строки — потоки начнут ложно ре-синкаться. Инвариант новый, назван здесь, чтобы не потерялся. 5. **`inReadTx` (repeatable read + read only).** Опирается на то, что read-only повторяемое чтение в Postgres не может прерваться сериализационной ошибкой (в отличие от serializable). Проверено рассуждением и батареей, не отдельным стресс-тестом. 6. **Оси, которых не смотрел НИКТО** ни в одной из четырёх приёмок: деньги и леджер целиком · вход/сессии/CSRF · очередь и джобы · метрики. Пак их не менял, а приёмки смотрели дифф. ## 9. Сдача - `make check` с гейтами: 18 пакетов, exit 0, **скипов 0**, линтер 0 issues. - Каждая правка — с посадкой мутации (§3 п.2). - Живые пробы §6 хендоффа пере-ранить, если трогал HTTP или SSE. - Строки в `DEFECT_REGISTER.md` на каждую находку; **PD-284 пере-открыть** (6.3); **PD-253 закрыть** (канон изменён в нашу сторону, §5а L); **PD-262** переписать под новую норму (§5а E); **PD-202** — из «вопрос владельцу» в работу (§5а G); **PD-298** — «недостижимо, проверено чтением движка» (§5а H); **PD-246** закрыть в части плейсхолдера (§5а D). - Итог — в `platform/docs/platform-PROGRESS.md`, шапка «Текущее состояние». - Дерево оставить незакоммиченным и НЕ застейдженным.