58 KiB
Пак 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§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. Что читать, в этом порядке
- Этот файл — рабочий список.
platform/docs/P7_ACCEPTANCE_HANDOFF.md— §0–§3 (что было), §2 целиком (стенд с нуля — рецепт проверен исполнением, там же грабли), §8.1а (три вопроса на владельце).platform/docs/DEFECT_REGISTER.md— источник истины по статусу каждого дефекта. Проверка:python3 docs/scripts/counts.pyот корня репозитория.- Канон
docs/architecture/14-api-contract/openapi.yaml(info.version: 0.3.0) — нормативен для формы, побеждает любой другой док. КомпаньонREADME.mdрядом — провенанс, для формы НЕ нормативен. 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 УЖЕ СДЕЛАНО — не переделывать (но занести в регистр)
- Реконсилятор больше не снимает стоп банка.
internal/runs/reconcile.go, вRestartInput:LiftBankStop: from == fromAFinishedRun && l.Status == "awaiting_bank". Пинruns.TestTheReconcilerDoesNotLiftABankStopNobodySigned— проверен посадкой мутации, ловит обе половины (bank_releasedи argv спавна). Это был самый тяжёлый дефект ревью: четыре независимые линзы, нарушение согласия пользователя. - Загрузочная проверка пар — на ДОСТУПНОЙ половине.
config.AvailablePairs()— одно место, которым пользуются и проверка, иintakePairsдемона. Деплой, объявивший только:unavailable, больше не стартует. - Интейк не трактует пустой список как «пускать всё» (
books.canTranslate: escape hatch снят), и сравнивает теги регистронезависимо (strings.EqualFold) —zh-Hansпротивzh-hansэто одна пара по BCP 47. ⚠ Первичный сабтег регистр НЕ варьирует: канонLangCode—^[a-z]{2,3}…. Пины:config.TestAnIntakeWithNoDeclaredPairsIsRefusedAtBoot,books.TestAnUndeclaredDeploymentRefusesAndADeclaredPairIgnoresCase. - Фикстура
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.gounitDone, строка 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. Сомнения, которые надо держать в голове
PD-297(round-trip на строку вSaveStructure). Замерено ревьюером: 22 830 стейтментов и 4,56 с на ЛОКАЛЬНОМ Postgres; на управляемом при 1–2 мс RTT это 23–45 с, всё время под эксклюзивной блокировкой книги. Инструмент штатный —tx.SendBatch(pgx v5 уже драйвер модуля). Не сделано осознанно: путь самый опасный на запись, и первая приёмка понизила находку до DOUBT/LOW. Если берёшь — мерь до и после, на корпусной книге.— ОТВЕЧЕНО контрактной сессией, переехало в работу: §5а (G). Форма задана («последний реально пройденный проход»), и это деньги:PD-202(draft-only)ChaptersLeftне подрезается вовсе.— ОТВЕЧЕНО: переход недостижим, сверку с движком сделала контрактная сессия. Механизма не строим; остаётся инвариант из §5а (H).PD-298(снятие флага)- Непрерывность
event_position. Обнаружение дыры в потоке опирается на то, что позиции идут без пропусков (единственный писатель —pgstore.emitFrame, в одной транзакции). Если появится второй писатель или позиция начнёт минтиться без вставки строки — потоки начнут ложно ре-синкаться. Инвариант новый, назван здесь, чтобы не потерялся. inReadTx(repeatable read + read only). Опирается на то, что read-only повторяемое чтение в Postgres не может прерваться сериализационной ошибкой (в отличие от serializable). Проверено рассуждением и батареей, не отдельным стресс-тестом.- Оси, которых не смотрел НИКТО ни в одной из четырёх приёмок: деньги и леджер целиком · вход/сессии/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, шапка «Текущее состояние». - Дерево оставить незакоммиченным и НЕ застейдженным.