textmachine/platform/docs/archive/P7_ACT5_FIX_PLAN_2026-08-20.md

58 KiB
Raw Permalink Blame History

Пак P7, акт 5 — исполнение фиксов по ревью акта 4

АРХИВ (оркестратор №18, 20.08). Задача закрыта целиком: акт 5 исполнен, пак P7 принят с фикс-листом и заленден (D39.153, 9b23e8c). Документ ИСТОРИЧЕСКИЙ — рабочий процесс сессии; инструкции НЕ исполнять.

Живое из него переехало: замер «636 мс против 24 мс» (§ про note_count) — единственный носитель числа 96% в perf_test.go, разбор — D39.153 п.2; фикс-лист приёмки — пинг оркестратора №18 в platform-PROGRESS.md.

РАБОЧИЙ ДОКУМЕНТ СЕССИИ, ОРКЕСТРАТОРУ ЧИТАТЬ НЕ НУЖНО. Это мой процесс: как я шёл, что проверял, что передумал. Для лендинга нужны три вещи, и все они в других местах — что сделано и зачем: шапка 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. Как проверять — команды

# стенд (детали и грабли — §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.Drainruns.pendingRefresh/DrainRefresh/deferRefresh/Reader и books.materializeMissingTrees/BooksWithNoTree УДАЛЕНЫ (два механизма → один). Отказ канала текста теперь возвращается вызывающему (Exporterrors.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.8blocked называет книгу с НАИБОЛЬШЕЙ суммой холдов. PD-307.
  • 6.9startRun отвечает 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.godeferRefresh/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 (снять бэкстоп). ⚠ Правь НОВУЮ миграцию, 0001600018 уже пере-фингерпринчены один раз; при правке любой из них пере-создавай базу стенда и обновляй migrations.sha256.

Чем проверить. Пин: завершить прогон, уронить Refresh, убедиться, что долг ПЕРЕЖИЛ и следующий проход его забрал. Пин: книга с непогашенным долгом не «в покое».

6.3 [HIGH, 1/2 + 1/2] Ключ идемпотентности не знает своего владельца

Где. internal/pgstore/idempotency.goReleaseIdempotency и 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.Pathr.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, шапка «Текущее состояние».
  • Дерево оставить незакоммиченным и НЕ застейдженным.