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