textmachine/platform/docs/P7_ACT5_FIX_PLAN.md

553 lines
58 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.

# Пак P7, акт 5 — исполнение фиксов по ревью акта 4
> ⚠ **РАБОЧИЙ ДОКУМЕНТ СЕССИИ, ОРКЕСТРАТОРУ ЧИТАТЬ НЕ НУЖНО.** Это мой процесс: как я шёл, что проверял, что передумал. Для лендинга нужны три вещи, и все они в других местах — что сделано и зачем: шапка `platform-PROGRESS.md` · статус каждого дефекта: `DEFECT_REGISTER.md` · правила, переживающие пак: `STACK_DECISIONS.md` §3336.
>
> Составлен 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`), поэтому неполная материализация
долг НЕ гасит. Мутации M1M4, 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.16.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; на управляемом при 12 мс RTT это 2345 с, всё время под
эксклюзивной блокировкой книги. Инструмент штатный — `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`, шапка «Текущее состояние».
- Дерево оставить незакоммиченным и НЕ застейдженным.