200 KiB
Пак P7 — приёмка, правки, приёмка правок, доработка. Хендофф
⚠ АРХИВ (оркестратор №18, 20.08). Пак P7 принят и заленден (D39.153,
9b23e8c), сессия закрыта владельцем. Документ ИСТОРИЧЕСКИЙ — рабочий процесс приёмки актов 1–4; инструкции НЕ исполнять.Живое из него ВЫНЕСЕНО, чтобы не держать второй носитель: рецепт стенда и грабли (§2) живут в
platform/docs/STACK_DECISIONS.md, раздел «Стенд разработчика» (⚠ первая редакция этого баннера утверждала перенос, когда переехали только две грабли — рецепт целиком перенесён 21.08 по находке аудита; сборка бинарей, шаблон книги, ОБА гейта батареи и переменные дев-демона теперь там) · релеи §7 — строка 203 единого бэклога, статус каждого сверен грепом и половина исполнена · строка про «остановлено по вашей просьбе» (§7и) закрыта полемstop_requestedканона 0.4.0 · строка в бэклог движка (§7в) заведена как 204. ⚠ §0 этого файла объявляет «вопросов на владельце нет» — это НЕВЕРНО и поймано ревью: PD-255 просит именно решение владельца, оно вынесено в CURRENT-STATE.
⚠ РАБОЧИЙ ДОКУМЕНТ СЕССИИ, ОРКЕСТРАТОРУ ЧИТАТЬ НЕ НУЖНО. Это мой процесс: как я шёл, что проверял, что передумал. Для лендинга нужны три вещи, и все они в других местах — что сделано и зачем: шапка
platform-PROGRESS.md· статус каждого дефекта:DEFECT_REGISTER.md· правила, переживающие пак:STACK_DECISIONS.md§33–36.Составлен 17.08.2026 сессией приёмки платформы; акт 4 — доработка 20.08 (этот же документ). Точка входа для сессии без контекста: читать целиком §0–§3, дальше по нужде.
⚠ АКТ 5 ИСПОЛНЕН 20.08 — этот документ описывает акты 1–4 и с тех пор частично историчен. Акт 4 закрылся адверсариальным ревью собственного диффа: 33 находки, 27 пережили рефутеров, часть — дефекты самих правок акта 4. Всё это закрыто актом 5 вместе с работой, которую принёс ответ контрактной сессии. Что и как —
platform/docs/P7_ACT5_FIX_PLAN.md§5б; итог — шапкаplatform-PROGRESS.md; статус каждого дефекта —DEFECT_REGISTER.md(источник истины). Живым в этом файле остаётся §2 (стенд с нуля, рецепт проверен исполнением) и §9 (архив находок акта 1). Три вопроса §8.1а закрыты ответом контракта.
0. Что было сделано и в каком состоянии дерево
Четыре акта, все исполнены, ни один не закоммичен (зона не коммитит — лендит оркестратор).
Акт 1 — приёмка пака P7. Категорийное ревью по 12 осям, каждая находка через двух независимых рефутеров с разными линзами (существование / оправданность правки), рефутерам велено опровергать по умолчанию. 80 находок: 54 подтверждено, 16 правдоподобно, 10 опровергнуто. После дедупа — 46 уникальных дефектов, три блокирующих класса. Батарея пака была ЗЕЛЁНОЙ на всех трёх блокерах.
Акт 2 — правки. Починено всё, что лежит в зоне платформы. Новая миграция 00018, весь блок
идемпотентности, полоса прогресса, пере-нарезка, SSE, свип, деплой-нота, карта замечаний.
Акт 3 — приёмка ПРАВОК (9 осей + fable-5 на тяжёлой логике; лимит агентов задан владельцем). 76 находок → 50 уникальных мест. ⚠ Двенадцать дефектов внесли сами правки, и батарея не увидела ни одного: три из них были запинены под ошибку автора. Все двенадцать исправлены.
Акт 4 — доработка 20.08. Все 74 находки обеих приёмок сверены против ТЕКУЩЕГО дерева поштучно
(10 агентов, каждый обязан цитировать живой код; исход UNVERIFIED отдельно от «опровергнуто»).
⚠ Три места числились закрытыми и закрыты не были — правка не закрывала собственный сценарий:
подрезка буфера потока (PD-283), запись квитанции идемпотентности в чужую строку (PD-284,
воспроизведено живым PG) и ПУСТОЙ пин на снятие --verify-bank (PD-285). Заведены PD-282…PD-299.
Цифры на конец дня (все — исполнением, не по памяти)
Батарея make check с обоими гейтами |
18 пакетов, exit 0, скипов 0, линтер 0 issues |
| Тестовых функций | 458 → 501 |
| Регистр платформы | 299 строк, открытых 68; вес открытых: 14 minor, 54 info — major и BLOCKER нет |
| Файлов зоны изменено/добавлено | 79 |
| Открытых вопросов НА ВЛАДЕЛЬЦЕ | нет — три вопроса §8.1а закрыты ответом контрактной сессии 20.08 (канон 0.4.0; работа зоны — P7_ACT5_FIX_PLAN.md §5а) |
⚠ Главное, что нельзя потерять при компакции
- Ни одна из трёх блокирующих поломок пака и ни один из двенадцати дефектов правок не был найден батареей. Все — независимым чтением и сходимостью осей. Зелёная батарея в этой зоне не является свидетельством: пины пишет автор правки, и они фиксируют его намерение, а не требование контракта.
- Трижды за день я находил настоящий дефект и придумывал ответ вместо сверки с уже написанным — канон про идемпотентность, канон про поток, бэклог про подпись банка. Каждый раз «своё» решение расходилось с ратифицированным. Правило: сначала найти, где это уже решено, потом решать.
- Правка, закрывающая находку, сама подлежит проверке исполнением. Три из них не закрывали собственный сценарий, и все три были «подтверждены» зелёной батареей: пин писал тот же автор и фиксировал его намерение. Единственное, что их поймало, — повторная сверка находки против дерева с посадкой мутации.
- §8.2 триажирован поштучно (акт 4): 17 из 23 сделаны, 5 отклонены с причиной, 1 — релей. Незакрытое живёт в РЕГИСТРЕ, а не здесь.
0а. Принципы приёмки, которыми работали (переносить в следующую сессию)
- Находка без
file:lineи цитаты — не находка. Формулировка обязана быть отказом: конкретный вход/состояние → конкретный неверный результат. - Адверсариальная верификация: на каждую находку ≥2 рефутера, которым велено ОПРОВЕРГАТЬ и ставить «не существует» при сомнении. Бремя доказательства на находке.
- «Не проверено» ≠ «опровергнуто». Три исхода, не два. В первом прогоне 110 рефутеров умерли по
лимиту, и харнесс молча похоронил 55 находок, включая оба блокера. Сверять
agents_doneпротивagent_count. - Сходимость независимых осей — сильнейший сигнал. Место, найденное несколькими ревьюерами, не видевшими работы друг друга: по области ключа сошлись пять осей, по потоку три. Где сошлись — там ошибка почти наверняка.
- Шесть ловушек ревьюера: след отчёта (проверять только названное автором) · связность вместо истинности · соглашательство с уверенным тоном · рационализация задним числом · слепое пятно одного корпуса · экономия усилия на неудобном.
- Тест под зелень не подгонять. Несогласие с пином — вопрос оркестратору, не правка. Сработало
дважды за день: откат правки
outcome()и восстановление ослабленногоsweep_test. - Проверять исполнением. Каждое число отчёта — командой; живые пробы на дев-стенде; мутацию проверять запуском, а не рассуждением.
1. Где что лежит
| Что | Где |
|---|---|
| Этот план | platform/docs/P7_ACCEPTANCE_HANDOFF.md |
| Статус каждого дефекта — ИСТОЧНИК ИСТИНЫ | platform/docs/DEFECT_REGISTER.md, строки приёмки PD-257…PD-281 |
| Отчёт САМОЙ сессии написания (её заявки, которые приёмка проверяла) | platform/docs/platform-PROGRESS.md, раздел «Сессия P7» |
| Исходное задание | platform/docs/PLATFORM_P7_SESSION_PROMPT.md |
Этот файл самодостаточен. §9 несёт по каждому дефекту место, цитату, сценарий отказа и способ
починки — читать сырьё ревью не нужно, и оно намеренно НЕ сохранено (решение владельца 17.08:
«raw review сохранять в репозиторий не обязательно»). Транскрипты агентов остались вне репозитория,
в ~/.claude/projects/.../subagents/workflows/ — wf_aebef4f1-801/journal.jsonl (акт 1, 223 результата)
и wf_ba7450bb-f91/journal.jsonl (акт 3, 76 находок сырьём). Перезагрузку могут не пережить; ничто в
этом файле на них не опирается — §8.2 несёт незакрытое, регистр несёт закрытое.
2. Стенд с нуля — ПОСЛЕ ПЕРЕЗАГРУЗКИ
⚠ Что перезагрузку НЕ переживает: весь скратчпад в /tmp — три собранных бинаря (tmctl,
tmplatformd, tmplatformctl), book-template.yaml, оба env-файла, каталоги stand/{books,state}
и cookie-jar. Что переживает: кластер Postgres (~/.local/share/tmstand/pgdata, на диске),
сам Postgres (~/.local/pgsql, 18.4), и этот репозиторий. Поэтому ниже — полный рецепт пересборки,
а не ссылки на скратчпад.
✅ Рецепт проверен исполнением 17.08: скратчпад в /tmp действительно исчез в тот же день, стенд
пересобран по этим шагам с нуля и батарея прошла. Рабочий путь — ~/tmstand-work.
2.1 Поднять Postgres
~/.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
~/.local/pgsql/bin/psql -h /tmp -p 55433 -U postgres -l # проверка: должны быть postgres и tmstand
Если кластера нет вовсе (диск чистили) — он ставился micromamba без root:
micromamba create -p ~/.local/pgsql -c conda-forge postgresql=18.4, затем
~/.local/pgsql/bin/initdb -D ~/.local/share/tmstand/pgdata -U postgres, затем старт как выше и
createdb -h /tmp -p 55433 -U postgres tmstand.
2.2 Собрать бинари и шаблон
W=~/tmstand-work && mkdir -p $W/stand/{books,state} # ЛЮБОЙ путь вне /tmp — чтобы пережил
cd /home/ubuntu/projects/textmachine/backend && go build -o $W/tmctl ./cmd/tmctl
cd /home/ubuntu/projects/textmachine/platform && go build -o $W/tmplatformd ./cmd/tmplatformd
cd /home/ubuntu/projects/textmachine/platform && go build -o $W/tmplatformctl ./cmd/tmplatformctl
# шаблон книги = backend/example/book.yaml, у которого ДВА пути переписаны в абсолютные
sed -e 's#^pipeline: ../configs/#pipeline: /home/ubuntu/projects/textmachine/backend/configs/#' \
-e 's#^models: ../configs/#models: /home/ubuntu/projects/textmachine/backend/configs/#' \
/home/ubuntu/projects/textmachine/backend/example/book.yaml > $W/book-template.yaml
⚠ Относительные pipeline:/models: — единственное отличие шаблона от репо-оригинала; без правки
движок не находит конфиги из чужого рабочего каталога.
2.3 Гейты батареи
Без этих переменных make check молча скипует ~200 тестов — вся читающая модель, миграции и
шов. «Зелёная батарея» без них не значит ничего.
cat > $W/stand.env <<'EOF'
export TM_PLATFORM_TEST_DSN='postgres://postgres@/postgres?host=/tmp&port=55433&sslmode=disable'
export GOTOOLCHAIN=auto
EOF
echo "export TM_PLATFORM_TEST_ENGINE_BIN=$W/tmctl" >> $W/stand.env
echo "export TM_PLATFORM_TEST_BOOK_TEMPLATE=$W/book-template.yaml" >> $W/stand.env
source $W/stand.env
cd /home/ubuntu/projects/textmachine/platform && make check
# ЖДАТЬ: 18 пакетов, exit 0, СКИПОВ 0, линтер «0 issues»
grep -c SKIP <вывод> # обязан быть 0 — это гейт промта, а не пожелание
2.4 Дев-демон и пробы
cat > $W/stand/env <<EOF
export TM_PLATFORM_DSN='postgres://postgres@/tmstand?host=/tmp&port=55433&sslmode=disable'
export TM_PLATFORM_MIGRATE=1
export TM_PLATFORM_ADDR=127.0.0.1:8099
export TM_PLATFORM_DEV_LOGIN=probe
export TM_PLATFORM_INSECURE_COOKIES=1
export TM_PLATFORM_BOOKS_DIR=$W/stand/books
export TM_PLATFORM_STATE_DIR=$W/stand/state
export TM_PLATFORM_ENGINE_BIN=$W/tmctl
export TM_PLATFORM_BOOK_TEMPLATE=$W/book-template.yaml
export TM_PLATFORM_CTL_BIN=$W/tmplatformctl
export TM_PLATFORM_LANGUAGE_PAIRS='zh>ru,ja>ru:unavailable'
export TM_PLATFORM_METRICS_ADDR=127.0.0.1:9499
EOF
cd $W && source ./stand/env && ./tmplatformd >> ./stand/daemon.log 2>&1 &
sleep 3 && curl -s --noproxy '*' -o /dev/null -w 'readyz=%{http_code}\n' http://127.0.0.1:8099/readyz
./tmplatformctl seed --url http://127.0.0.1:8099 # кладёт cookie в ./stand/cookies
q() { curl -s --noproxy '*' -b $W/stand/cookies -H 'X-TM-Client: probe' "$@"; }
q http://127.0.0.1:8099/v0/books
2.5 Грабли стенда, каждая стоила времени
curlобязательно с--noproxy '*'— иначе прокси отвечает 403 и это читается как дефект кода.- Демон нельзя убивать
pkill -f <путь к бинарю>— шаблон совпадает с собственной командной строкой оболочки, она убивает сама себя (exit 144). Убивать по PID. - goose ключуется НОМЕРОМ миграции. Правка уже применённой
00016невидима:gooseвидит номер в своей таблице и не перезапускает. При любой правке миграции стенд пере-создавать (dropdb tmstand && createdb tmstand), иначе тестируется старая схема. - Красный тест cgroup — обычно НЕ регрессия кода.
TestARunIsBoundedByItsOwnCgroupпадает («the limit is not being enforced»), когда уtm.sliceопустелcgroup.subtree_controlи memory-контроллер не делегирован вниз. Проверка и лечение, без root:B=/sys/fs/cgroup/user.slice/user-1000.slice/user@1000.service/tm.slice; cat $B/cgroup.subtree_control→ пусто ⇒echo "+memory +pids" > $B/cgroup.subtree_control. Поймано 20.08: тест краснел на правках, которые этого пути не касаются вовсе. - Postgres стенда переживает не всё: сокет живёт в
/tmp, и уборка/tmp(или smart shutdown) роняет соединение —pg_ctl … statusскажет «no server running». Поднимать по §2.1; после правки МИГРАЦИИ базу пере-создавать (dropdb tmstand && createdb tmstand), иначе goose её не перезапустит. - Сид сам логин НЕ кладёт в cookie-jar curl'а. После пере-создания базы старый jar несёт мёртвый
токен и всё отвечает 401:
curl -c $W/stand/cookies -X POST http://127.0.0.1:8099/auth/dev-login. - Демон на стенде — ОТДЕЛЬНЫЙ бинарь, он не пересобирается сам. Живая проба после правки кода
меряет вчерашний код и читается как «правка не сработала». Порядок: убить по PID →
go build -o $W/tmplatformd ./cmd/tmplatformd→ (если менялась миграция) пере-создать БД → поднять заново.
3. Дефект метода приёмки — чтобы не повторить
Первый прогон харнесса потерял 55 находок из 80, включая оба блокирующих. Причина в моей пост-обработке: 110 рефутеров умерли по лимиту сессии, а скрипт считал «ноль вернувшихся вердиктов» за «опровергнуто» и молча хоронил находку. Поймано только сверкой чисел, а не чтением сводки.
Починено в харнессе (сам он в репозитории не оставлен — решение владельца): введён отдельный исход UNVERIFIED, плюс добавлен подсчёт
сходимости независимых осей (одно место, найденное разными ревьюерами, — сильнейший сигнал; в
первом прогоне он терялся). Урок для любого следующего харнесса: «проверка не проводилась» и
«проверка провалена» — разные исходы, и сливать их нельзя.
4. Три блокирующих класса — ВСЕ ЗАКРЫТЫ (раздел исторический)
⚠ Ничего из этого чинить не надо — оставлено как доказательная база и как образец того, какие дефекты батарея не видит. Статусы: Б-1 → PD-277 fixed · Б-2 → PD-257 fixed · Б-3 → PD-258 fixed. Тексты ниже описывают состояние ДО правок; где рамка оказалась неверной, это отмечено в §8а.
Б-1 · Экран подписи банка не подключён к движку
Самый крупный дефект приёмки: целая продуктовая функция выглядит готовой (ручка + счётчик + экран + зелёные тесты) и не соединена. Цепочка из четырёх звеньев, каждое проверено:
- Решения не покидают Postgres.
SubmitBankDecisionsпишет вbank_decisions(internal/pgstore/readmodel.go:853). Единственный читатель этой таблицы во всей зоне — счётчикpending_decisions(readmodel.go:306). Грепом: ни одна строка Go не пишет подписные файлы движка (mined-delta,mined-rejects, signature map). resumeперезапускает движок с тем же флагом.internal/runs/spawn.go:159→TranslateArgs(l.Workdir, l.VerifyBank, ceiling)→if verifyBank { args = append(args, "--verify-bank") }. Колонкуverify_bankне сбрасывает никто.ReleaseBankStopтрогает другую колонкуbank_released, и та уходит только в SQL полосы прогресса.- Движок держит ровно тот пер-термный гейт полноты, который снят D39.144.
backend/internal/pipeline/mining.go:143—if len(mined) == 0продолжить, иначе:246return true(стоп).minedсчитается ЗА ВЫЧЕТОМ уже подписанных и отклонённых. Собственный лог движка: «the stop clears once every proposed term is promoted or rejected». - Итог: пользователь подписывает → жмёт «продолжить» → прогон перезапускается → движок
пере-майнит → снова встаёт в
awaiting_bank. Подпись не влияет ни на что.
Почему не поймали. Отчёт (platform-PROGRESS.md:143) помечает пункт «исполнен» и пишет: «Граница
паузы движка сверена чтением (pipeline/mining.go: авто-продолжение с неподписанным банком —
дефолт)». Утверждение верное и относится к ветке if !r.VerifyBank, по которой платформа на
resume не идёт. Пин TestResumeLiftsABankStopWithTheDecisionsAsTheyStand утверждает только переход
состояния ПЛАТФОРМЫ на фейковом движке.
Промт это предвидел дословно (§4.4): «Границу паузы движка сверь чтением его кода (строка 191); расхождение — пинг». Расхождение есть, пинга нет.
Граница проверки: живьём end-to-end НЕ воспроизводилось — стендовая книга банк намайнить не может
(mining.go:48 требует langpack + contrast-артефакт), настоящий прогон платный. Вывод построен
чтением кода по обе стороны шва и грепами.
Диспозиция: канал «решения → движок» лежит ВНЕ зоны платформы → см. §7, пункт (ж).
Б-2 · Пере-нарезка книги валит читающую модель навсегда
internal/pgstore/readmodel.go:153-172. Главы апсертятся on conflict (id) до удаления
выбывших, а на таблице висит unique (book_id, number) (migrations/00002_readmodel.sql:109).
Идентичность главы у движка контентная (manifestChapterID — SHA-256 по тексту главы,
backend/internal/pipeline/manifest.go:117), номер — позиционный.
Воспроизведено живым Postgres (пин уже в дереве, см. ниже):
pgstore: write chapter: ERROR: duplicate key value violates unique constraint
"chapters_book_id_number_key" (SQLSTATE 23505)
⚠ Поправка рефутеров, важная — триггер уже, чем кажется. Исходник живой книги неизменяем: канон
openapi.yaml:258 «Only title may be changed», повторная загрузка файла делает НОВУЮ книгу,
ClaimParse срабатывает только при status='parsing'. Поэтому сценарий «владелец правит главу»
недостижим. Достижимый путь другой: manifestKey сворачивает Langpack/Norm/Chunker/Segmentation,
а backend/internal/chunk/chunker.go:132-136 выбрасывает главу, пустую после stripHeading, при том
что id привязан к НЕобрезанному тексту. Смена правил заголовков в langpack между двумя прогонами
одной книги роняет/возвращает заголовочные главы и сдвигает все последующие номера, оставляя id на
месте. Refresh зовётся в конце прогона (runs/reconcile.go:1086) — значит первый прогон после
апгрейда движка или langpack влетает в 23505. Зеркальная форма — смена нормализации ингеста.
Серьёзность по единогласной поправке: HIGH, не BLOCKER (пользовательским запросом не достаётся,
данные не портятся). Но отказ невосстановим: транзакция откатывается целиком, вход детерминирован,
дерево замерзает молча, structure_version не двигается, клиенту никто не говорит пере-синхронизироваться.
⚠ Правка сложнее, чем кажется: delete-first, которым автор вылечил тот же класс у пар
(readmodel.go:195-200), лечит не все достижимые формы — обратная (правило заголовков убрали)
коллидирует и при нём, потому что выжившая глава лежит в keep и не удаляется. Долговременное
решение — chapters_book_id_number_key в DEFERRABLE INITIALLY DEFERRED либо двухфазная
перенумерация.
Тест-репро — ⚠ УЖЕ В ДЕРЕВЕ и не нуждается в переносе: internal/pgstore/recut_test.go,
TestARecutThatShiftsChapterNumbersIsMaterialized (закрывает обе формы — вставку главы в начало и
обратную). Текст ниже — исходная разовая проба, оставлена как доказательство отказа ДО правки:
// Ре-кат, вставляющий главу В НАЧАЛО книги. Идентичность главы у движка контентная, номер —
// позиционный, поэтому выжившая глава c1 переезжает с номера 1 на номер 2.
func TestARecutThatInsertsAChapterAtTheHead(t *testing.T) {
s, ctx := testDB(t)
book := readingBook(t, s, ctx, "u1")
first := Structure{ManifestKey: "k1", Chapters: []StructureChapter{
{EngineID: "c1", Number: 1, Units: []StructureUnit{{EngineID: "c1:cut1:0", Ordinal: 0, Source: "первая", State: "pending"}}},
{EngineID: "c2", Number: 2, Units: []StructureUnit{{EngineID: "c2:cut1:0", Ordinal: 0, Source: "вторая", State: "pending"}}},
}}
if err := s.SaveStructure(ctx, book, first); err != nil {
t.Fatalf("первая материализация: %v", err)
}
recut := Structure{ManifestKey: "k2", Chapters: []StructureChapter{
{EngineID: "c0", Number: 1, Units: []StructureUnit{{EngineID: "c0:cut2:0", Ordinal: 0, Source: "нулевая", State: "pending"}}},
{EngineID: "c1", Number: 2, Units: []StructureUnit{{EngineID: "c1:cut2:0", Ordinal: 0, Source: "первая", State: "pending"}}},
{EngineID: "c2", Number: 3, Units: []StructureUnit{{EngineID: "c2:cut2:0", Ordinal: 0, Source: "вторая", State: "pending"}}},
}}
if err := s.SaveStructure(ctx, book, recut); err != nil {
t.Fatalf("пере-нарезка со вставкой в начало падает: %v", err)
}
page, err := s.ListChapters(ctx, "u1", book, 0, "")
if err != nil {
t.Fatal(err)
}
if len(page.Chapters) != 3 {
t.Fatalf("после ре-ката глав %d, ждали 3", len(page.Chapters))
}
}
Б-3 · Упавший tmctl export затирает текст книги пустотой
internal/readmodel/readmodel.go:91-94. При упавшем Export карта текста пуста, и все пары уходят в
SaveStructure с пустыми source/target и pending. Кат не менялся → id пар те же → срабатывает
on conflict (id) do update set source = excluded.source, target = excluded.target
(pgstore/readmodel.go:206-212) → уже материализованный текст перезаписывается пустыми строками.
Единственный из трёх, кому рефутеры не понизили серьёзность (HIGH единогласно). Комментарий рядом
(«the pairs then carry an empty target and pending, which is exactly what they are») верен только
для книги, у которой текста ещё не было — классическая «связность вместо истинности».
Чинить: при пустом экспорте на книге с уже материализованным текстом не трогать source/target
(условный апсерт), либо не сохранять структуру при упавшем экспорте, если дерево уже есть.
5. Мои собственные находки, вне воркфлоу
Проверены исполнением в этой сессии, вне воркфлоу-осей.
- ✅ ЗАКРЫТО (PD-270).
Problem{}безCodeдаёт 500 БЕЗ обязательногоcode—internal/httpapi/problem.go:164(json:"code,omitempty"). Проверено одноразовым тестом:WriteProblem(w, r, Problem{})→{"type":"about:blank","title":"Internal error","status":500,"request_id":"..."}. Канон требуетrequired: [type, title, status, code, request_id]и отдельно про 500: «it carriesinternal_errorin the same shape as every other error». Живого вызова нет — все шесть сегодняшних вызовов код задают, — ловушка латентная. Но файл объявляет своим принципом «пара код+статус непредставима расходящейся» (problem.go:82-84), а ОТСУТСТВИЕ кода представимо. Чинить: вWriteProblemпустойCode→CodeInternalError, две строки. Плюс:omitemptyсуществует только радиWriteStatusProblem, а тот структуру не использует — собираетProblemсам. - ⚠ Изменено ВОПРЕКИ опровержению — защита в глубину. Рефутер отклонил это как недостижимое
(домен закрыт CHECK) — см. первую строку «Опровергнуто рефутерами». Правка внесена всё равно
(
else status), потому что она строго безопаснее и не длиннее; отказом не была и не является.case ... else 'approved'в миграции —migrations/00016_read_surface.sql:144-146. Fail-open в значение «пользователь подписал». Сегодня недостижимо (CHECK ограничивает домен), ноelse statusстрого безопаснее и не длиннее. - ⬜ Принято как есть (PD-253).
410на опечатку в id главы —v0.go:687-691,readmodel.go:544. Канон прямо: «404would mean a typo». Расхождение уже задекларировано автором (PD-253, строка селф-ревью Р-20) — принято как есть, действий не требует. - ✅ Три выброса подрезаны. Плотность комментариев, померено — база зоны (файлы, которых пак не касался) 57%
комментариев к коду. Новые файлы в основном НИЖЕ базы:
readmodel.go24%,reading.go12%,project.go20%,stream.go39%,conditional.go51%. Валового перелива НЕТ. Выбиваются три:ingest/notes.go137%,ingest/vocabulary.go119%,ingest/export.go67%. Резать предметно их, а не «везде подсушить».
6. Что живьём подтверждено РАБОЧИМ — не пере-проверять
⚠ Пере-ранено 20.08 на пере-созданной базе (миграция 00016 менялась) — всё ниже прошло снова,
плюс новое: HEAD на потоке → 200, text/event-stream, тело 0 байт, насос не запускается ·
Last-Event-ID за последним кадром на книге в покое → 204 (ветка каноническая, журнал зоны
:213 верен) · идемпотентность интейка: тот же ключ + тот же файл → тот же 201 с тем же
Location, другой файл → 409 · сид берёт пару из /capabilities, а не из зашитого дефолта.
Пробы дев-стенда 17.08, все прошли:
- ETag/304: точный тег → 304,
*→ 304, чужой → 200; страница 1 и страница 2 дают РАЗНЫЕ теги (канон требует именно этого, «ETag = ревизия страницы» недостаточен); на 304 сохраняютсяETagиVary. - gzip: 1132 Б → 239 Б;
q=0отказ,q=0.5сжатие,*сжатие,identityбез сжатия. Ниже порога 1 КБ не сжимает — это поведение, а не поломка. - SSE:
hello→end→закрытие; переподключение сLast-Event-IDпоследнего кадра → 204;Content-Type: text/event-stream,X-Accel-Buffering: no,Cache-Control: no-store, сжатия нет даже приAccept-Encoding: gzip. limit: не отвергается ни при0, ни при-5, ни приabc, ни при99999.- Дельты: устаревший
after_version→ 400cause.code: version_too_old; нечисловой → 400 с указателем/after_version. - Интейк:
ja>ru(объявлена unavailable) и мусорная пара → 400unsupported_pairна ОБОИХ полях; часть после файла → 400 с указателем на виновную часть; пустойtitle→ книга названа по файлу. - Идемпотентность: тот же ключ + то же тело → тот же 201; тот же ключ + другое тело → 409
idempotency_conflict/key_reused. - Решения банка: неизвестный
term_id→ 400 всей пачки, item-кодunknown. - Механическая сверка 17 wire-форм с каноном (скрипт раскрывает
allOf, сверяетproperties/required/omitempty): 16 из 17 чисто, единственная зацепка —Problem.code, разобрана в §5 п.1.Problem.localizedв каноне ЕСТЬ, но канон сам говорит «No code in this version carries it» — не построено законно.
7. ОБЯЗАТЕЛЬСТВА ВНЕ ЗОНЫ — только через оркестратора
⚠ Ничего из этого платформа править не имеет права. Каждый пункт — релей владельцу/оркестратору. Пункты (а)–(е) висят с сессии написания кода и до сих пор не закрыты; (ж)–(к) добавила приёмка.
(а) Две фразы в канон и компаньон про /auth. Вердикт контрактной сессии (вариант A уточнённый):
/auth/* отвечает конвертом БЕЗ машинного code; codeForStatus снять — «это ваша зона и ничьего
разрешения не требует» (снят). Две фразы в канон и компаньон — зона контракта, релей через владельца.
Приёмка подтверждает: нарушения канона тут НЕТ — servers.url канона = /v0, путей /auth/* канон
не объявляет вовсе, WriteStatusProblem зовётся только с server.go:164,175 (readiness) и
main.go:120,144 (логин).
(б) Восстановить срезанное правило компаньона §2.14. Фраза «различать причины отказа клиент не
может по замыслу» жила в компаньоне 0.2.3 и была молча срезана коммитом лендинга батча 8d82096
(«Land contract batch 0.3.0…»), проверено git log -S. Приёмка пере-проверила: фразы в
docs/architecture/14-api-contract/README.md сегодня НЕТ (грep даёт 0). ⚠ Отдельно оркестратору:
класс этой потери структурно непроверяем — приёмка контракта сверяла мультимножество модальных
глаголов, а в этой фразе модального глагола нет.
(в) Строка в бэклог ДВИЖКА: публиковать причины флагов ДАННЫМИ. Карта «причина движка → код
замечания» (internal/ingest/notes.go) — рукописная копия закрытого словаря чужой зоны, а импортировать
движок платформе нельзя (D39.85). Пока движок не публикует свои причины данными, карта расходится
молча. Носитель — PD-246.
(г) Ратифицировать фразу unspecified и границу ступеней замечаний. Сегодня и то и другое —
догадка платформы. Ступени attention/glance: два значения при девяти рангах у движка.
(д) Правка контракта по Note.code — ответ владельцу на вопрос «нужна ли пометочка в сам
контракт», данный устно 16.08, здесь зафиксирован, чтобы не потерялся:
- Обязательный минимум — компаньон, приложение А, последняя строка (
README.md:899): заполнить пустую клетку кода значениемunspecifiedс пометкой «не значение словаря, а ОБЯЗАННОСТЬ сервера». Компаньон не нормативен для формы — вопроса о версии не возникает. - Желательное — одна фраза в канон к
Note.code(openapi.yaml:1693-1700): «A deployment that meets a reason its build of the map cannot name answers the stable placeholderunspecifiedrather than the producer's own word, so the branch above is exercised by a real value in ordinary operation and not only in theory.» Форма — обязанность сервера, а не запись словаря: канон осознанно отказывается перечислятьNote.code, и начинать перечисление одним значением значит ровно то, от чего он отказался. - Бампа версии не требует:
Note.codeобъявленtype: string, не enum — генерённые типы не сужаются; фраза описывает поведение, которое единственный потребитель 0.3.0 уже реализует. ⚠ Прецедент D39.144 опорой НЕ служит — его довод «спека ещё никем не потреблена» больше не верен. - Цена: описания компилируются в
frontend/src/api/schema.ts→ при разморозке фронта зеркало плюс регенерация типов.
(е) sqlc — отложен явно, словом владельца. Промт привязывал инструмент к паку (D39.132 п.2б,
overrides на денежные типы). Решение: отложить, занести в отчёт. Занесено здесь.
(ж) НОВОЕ, самое крупное: канал «решения подписи → движок». См. Б-1. Сегодня канала нет вовсе; подпись физически не может снять стоп движка. Требует решения на уровне шва: либо платформа получает право писать подписные файлы движка, либо движок принимает решения другим каналом, либо продуктовое поведение пере-определяется. Зона движка + контракт, не платформа.
(з) НОВОЕ: расхождение семантики --verify-bank. D39.144 ратифицировал «подпись = ОДИН акт», а
движок держит пер-термный гейт полноты («the stop clears once every proposed term is promoted or
rejected»). Одно из двух должно уступить — это ратификация, а не правка кода.
(и) НОВОЕ, решение владельца 17.08: Run не умеет сказать «остановлено по вашей просьбе».
Готовая строка для ЕДИНОГО БЭКЛОГА (docs/PROGRESS.md §Бэклог) — вставить как есть, номер присвоит
оркестратор:
| N |
Runне выражает «остановлено по просьбе пользователя» (решение владельца 17.08). Стоп, запрошенный во время перевода, может встретиться с самостоятельным выходом движка на границе подписи банка (exit 3) — окно узкое, но реальное; прогон закрывается какawaiting_bank, и экран отвечает кнопкой «продолжить» на клик «стоп». Статус менять НЕЛЬЗЯ:awaiting_bankнесёт проводку (ReleaseBankStopключуется им; переименование вернуло бы--verify-bankна resume и пере-открыло бы PD-277) и он информативнее — работа стоит на самой дешёвой точке, черновик и майнинг оплачены. Лечение в контракте:Runполучает признак «стоп был запрошен» (предложение зоны — необязательное булевоstop_requested; форму решает контрактная сессия), клиент рисует «остановлено — работа на подписи банка, продолжить дёшево». Закрывает класс шире одного случая: «система сделала не то, что я просил». Родня: PD-273 (закрыт этим решением), PD-277, строка 191 | контракт → платформа+фронт | скоро | правка контракта (минор, аддитивная) → проекция вprojectRun| приёмка P7, решение владельца 17.08 |
Обоснование, если контрактная сессия спросит: status сегодня отвечает на ДВА вопроса сразу — «где
стоит работа» и «кто её остановил», — и любой выбор ярлыка одну половину теряет. Отдельный признак
разводит их и не трогает enum, поэтому правка аддитивная: генерённые клиенты не сужаются.
(к) НОВОЕ: ключ project_db в book.yaml. internal/runner/artifacts.go:82 требует ключ, движок
делает его НЕОБЯЗАТЕЛЬНЫМ (дефолт <book_id>.db), а канонический шаблон оператора из deploy-руководства
его не содержит → на штатном деплое банк не читается вовсе. Касается шаблона оператора и контракта
шва — согласовать, прежде чем чинить в платформе.
⚠ Релеи (а)–(о) отправлены и (л)(м)(н)(о) ЗАКРЫТЫ ответом контрактной сессии 20.08 — канон 0.4.0,
разбор в docs/architecture/14-api-contract/README.md §6в, работа зоны в P7_ACT5_FIX_PLAN.md §5а.
Тексты ниже — провенанс.
(л) НОВОЕ: канон §resumeRun не покрывает «денег не осталось». См. §8.1а п.1 и PD-282. Зона ответила самым мягким из доступных — 202 с прогоном без изменений — и записала, что это чтение, а не ратификация. Нужна строка таблицы канона.
(м) НОВОЕ: ETag/304 на getUsage и getRunOptions. Валидатор ставится на КАЖДЫЙ GET, потому
что он живёт в writeJSON, через который идут все чтения. Канон объявляет условное чтение на
коллекциях, карточке книги и /capabilities и про остальные молчит; RFC 9110 позволяет. Хочет ли
контракт сузить — вопрос контрактной сессии, а не зоны: список исключений в middleware протухает
за один пак.
(н) НОВОЕ: канон называет частью отпечатка интейка размер, которого форма не объявляет. См.
§8.1а п.3 и PD-262. Либо BookIntake получает объявленный размер, либо фраза §createBook приводится
к сравнимому.
(о) НОВОЕ, мелкое: ссылка приложения А компаньона протухла. README.md:902 указывает на место
дефолтного ранга в движке; живое — backend/internal/pipeline/status.go:237-262
(flagReasonSeverity), а :174 теперь про GlossaryMissFlagged. Лучше именем функции без номера.
8. Что осталось — единственный живой список
⚠ Всё, что было в этом разделе как «план на завтра», ИСПОЛНЕНО (правки того же дня + приёмка
правок). Ниже — только незакрытое. Источник истины по статусу каждого дефекта —
DEFECT_REGISTER.md: python3 docs/scripts/counts.py печатает открытые.
8.1 Решено владельцем 17.08 — вопросов на нём БОЛЬШЕ НЕТ
| Было вопросом | Чем закрылось |
|---|---|
| Б-1 / PD-277 — подпись банка не доезжает до движка | НЕ вопрос, а невыполненная половина уже принятого решения. Строка бэклога 191(в) (слово владельца 16.08, D39.144) требовала «проводку resume = снятие стопа до движка»; движок при выключенном --verify-bank уже делает модель владельца — неподписанное едет авто-строками с пометкой ⟨проверить⟩ (mining.go:211-231, D39.42 п.3). Платформа же передавала флаг обратно. Починено: на resume флаг не передаётся (l.VerifyBank && !l.BankReleased); пин TestAResumedRunIsSpawnedWithoutTheSigningStop. ⚠ Остаток: decline пользователя до движка не доезжает (движок читает mined_rejects, платформа его не пишет) — территория строки 192, отложенной владельцем |
| PD-273 — стоп пользователя против стопа банка | Решение владельца 17.08: статус остаётся awaiting_bank, лечение в контракте. Ярлык несёт проводку (ReleaseBankStop ключуется им) и информативнее. Настоящая дыра — провод не выражает «остановлено по вашей просьбе»; строка бэклога готова — §7(и) |
PD-281 — полоса 0/N для уже полной книги |
Не новый вопрос: это цикл строки бэклога 192 («пост-ридинговый цикл правок банка»), где сам предмет пере-генерации ещё не определён и владелец явно отложил проектирование до полигонных итогов. Перевешено на 192 |
8.1а НА ВЛАДЕЛЬЦЕ — три вопроса — ВСЕ ТРИ ЗАКРЫТЫ 20.08
⚠ Контрактная сессия ответила на записку зоны (CONTRACT_SYNC_FROM_PLATFORM.md); канон стал 0.4.0
(в дереве, НЕ ратифицирован). Что решено и что из этого работа зоны — P7_ACT5_FIX_PLAN.md §5а.
Ниже — исходные формулировки, оставлены как провенанс.
Исходные три вопроса (историческое)
Ни один не решается в зоне: это смысл проводов, а не механика. Код в каждом случае оставлен как был, и оставлен ЧЕСТНО — комментарий говорит, что это чтение неназванного случая, а не ратифицированный ответ.
resumeпрогона, потратившего весь потолок, отвечает 202 и ничего не меняет (PD-282). Таблица канона §resumeRun строки для «денег не осталось» не содержит:stoppedиawaiting_bankтам оба «continues it — 202», отказ объявлен только дляpaused. Это молчаливый no-op, который тот же раздел запрещает в соседней ветке. Нужна строка в контракт.- Что такое «глава сделана» на деплое без волны редактора (PD-202). Сегодня
units_doneсчитает толькоedit, значит draft-only конвейер — который движок поддерживает штатно — даёт вечные нули вchapters_done,Chapter.units_doneи полосе прогона. Правильная форма («последняя волна, которую книга видела») меняет смысл контрактно видимого счётчика. - Канон называет частью отпечатка интейка «the file's size», которого форма не объявляет
(PD-262).
BookIntakeнесётtitle/source_lang/target_lang/fileи никакого размера, поэтому единственная доступная величина — длина ЗАПРОСА, считающая multipart-оболочку. Либо в форму добавляется объявленный размер, либо фраза канона приводится к тому, что сервер может сравнить.
8.2 Триаж 23 мест акта 3 — ИСПОЛНЕН (доработка 20.08)
⚠ Раздел закрыт. Каждому месту дана диспозиция и проверена исполнением; статус по каждому дефекту —
в DEFECT_REGISTER.md, здесь только карта «что решили и почему».
Взято и сделано — 17. Строка потока: дыра в голове пачки (PD-283) · ошибка Flush и HEAD на
потоке (PD-286) · табличный пин границы ре-синка. Идемпотентность: defer вместо прямых веток ·
CompleteIdempotency (PD-284) · UploadDeadline < ClaimStale в загрузочной проверке. Прогоны:
blocked и CreditHeldError (PD-287) · снятие стопа банка внутрь транзакции (PD-292) · поля
run/book в журнальной строке · DrainRefresh смотрит бюджет прохода (PD-293). Чтение: флаг
текста переехал на ПАРУ (PD-291) · note_count тем же джойном (PD-288) · кадр из отвергнутой
доставки (PD-289) · чтения на одном снапшоте (PD-290) · блокировка книги в решениях банка (PD-294).
Прочее: мёртвая ветка exportPending · шапка ingest/notes.go · Status переписан поверх
readEngine (одна копия плюмбинга вместо двух).
Отклонено с причиной — 5.
| Место | Почему НЕ берём |
|---|---|
журнал зоны :213 про 204 |
Строка ВЕРНА: ветка «id за последним кадром на книге в покое → 204» каноническая и стоит первой. Перепроверено живой пробой 20.08 |
ETag/304 на getUsage и getRunOptions |
Опровергнуто ещё в акте 1 (см. §10). Канон требует валидатор на коллекциях, карточке и /capabilities и НЕ запрещает его на прочих GET; RFC 9110 разрешает. Хотеть сузить — правка канона, релей §7(м) |
полоса 0/N на уже полной книге |
Решено владельцем 17.08: PD-281, перевешено на строку единого бэклога 192 |
r.ContentLength в отпечатке интейка |
PD-262. Канон называет частью сравнения «the file's size», которого форма BookIntake не объявляет вовсе — это дыра КОНТРАКТА, релей §7(н). Код сравнивает объявленную длину ЗАПРОСА и говорит это вслух; пин переименован, чтобы не утверждать чужого свойства (PD-285) |
| порядок неизвестного термина в отказе | Канон (openapi.yaml:466-470) обещает errors[] с указателем на элемент и порядок НЕ фиксирует. Изменение наблюдаемого поведения, не нарушение |
Релей — 1. Ссылка приложения А компаньона на место дефолтного ранга протухла: живое место —
backend/internal/pipeline/status.go:237-262 (flagReasonSeverity), а :174 теперь про
GlossaryMissFlagged. Зона контракта — §7(о).
bufferFrames (было DOUBT). Значение не менялось; закрыт вопрос, ПОЧЕМУ запаса в две пачки
достаточно, и это записано у константы: framesPerRead — половина буфера, полная пачка перечитывается
сразу же, а не через тик, поэтому отстать можно только при писателе быстрее 256 кадров/с; и промах
теперь ОБЪЯВЛЯЕТСЯ (resync_required), а не теряется молча. Замер частоты кадров под настоящим
прогоном остаётся долгом журнала.
8.2а Крупное, известное и НЕ из этого списка
declineпользователя не доезжает до движка. Движок читаетmined_rejects, платформа его не пишет ⇒ отклонённый термин уедет в банк авто-строкой. Территория строки единого бэклога 192 («пост-ридинговый цикл»), отложенной владельцем до полигонных итогов.- PD-297 — материализация дерева делает round-trip на СТРОКУ под блокировкой книги (≈11 тыс. на
корпусной книге). Инструмент штатный (
pgx.SendBatch), но цена не замерена на форме этого деплоя, а путь — самый опасный на запись. Мерить прежде правки. - PD-202 — draft-only конвейер даёт вечные нули:
units_doneсчитает только волнуedit. Правильная форма меняет смысл контрактного счётчика ⇒ вопрос владельцу, не тихая правка. - PD-298 — снятие флага с замечания дельта-чтение выразить не может; сперва сверка у движка, что переход вообще достижим.
- Отложено в P8 явно:
updateBook/deleteBook/getRun·createExport/getExport· эскроу П-18.
8.2б Что делать СЛЕДУЮЩЕЙ сессии, по порядку
- Поштучная сверка §9 (46 находок акта 1) ИСПОЛНЕНА доработкой 20.08 вместе с §8.2 — все 74 позиции сведены с деревом. Повторять не нужно; §9 остаётся архивом доказательной базы.
- Гипотеза владельца «в коде ещё полно багов» проверена ЧАСТИЧНО. Проверено: весь дифф пака и обе приёмки поштучно. НЕ гонялось ни разу: деньги и леджер целиком · вход/сессии/CSRF · очередь и джобы · метрики. Пак их не менял, но и не смотрел никто.
- Мутационная проба батареи на путях, которых правки не касались.
- Лендинг через оркестратора вместе с пакетом релеев §7.
8.3 Как проверить, что ничего не разъехалось
source ~/tmstand-work/stand.env && cd platform && make check # 18 пакетов, exit 0, СКИПОВ 0
python3 docs/scripts/counts.py # от корня; открытых 68, major и BLOCKER — нет
Живые пробы — §6; чем проверять каждую правку — §8б.
8а. Сверка с ИСХОДНЫМ ПРОМТОМ — что реализовано, что перевёрнуто, что техдолг
Промт: platform/docs/PLATFORM_P7_SESSION_PROMPT.md. Ниже — состояние на конец дня, каждая строка
проверена командой (грепом по дереву или прогоном), а не по отчёту автора.
Реализовано и подтверждено
| Пункт | Проверка |
|---|---|
| §4.0 рантбук деплоя | deploy/README.md несёт живой exit 13 + токен schema_mismatch found=N expected=M; cmd/tmplatformctl/runs.go показывает ВЕРСИОНИРОВАННЫЙ путь |
§4.1а BookIntake.title |
читается (titleGiven); пустая строка = «назови по файлу» — живая проба |
§4.1б reject_reason |
проецируется словарём канона (ingest.ContractRejectReason) |
| §4.2 PD-104 грант → 0 | дефолт 0; протухшие «$5» вычищены из deploy/README.md и PLATFORM_DIRECTION.md (PD-274) |
| §4.3 модель ошибок | 16 корневых кодов, карта А-2 пройдена построчно, WWW-Authenticate на 401 |
| §4.4 читающая поверхность | 15 из 17 путей канона; heading → null; пофазность на провод НЕ вынесена; Note.code из ◆-карты |
| §4.5 конформность | 17 wire-форм сверены механически; limit подрезается; finalizing выведен (0 вхождений в Go и в CHECK); Location на 201; Idempotency-Key на обеих ручках; blocked{code,book_id} |
| §4.6 регистр | PD-185 закрыт; PD-241 исполнен; заведены PD-257…PD-281 |
| §5 скипов 0 как гейт | пере-ранено: 18 пакетов, скипов 0 |
ПЕРЕВЁРНУТО ревью — сделано иначе, чем говорил промт или чем сделал пак
| Что | Как было | Как стало и почему |
|---|---|---|
Область Idempotency-Key |
правки схлопнули её до (user, key) |
Восстановлена каноническая (principal, method, path); переполнение btree снято тем, что путь ключуется дайджестом. Канон: «the same key on another operation is another key» |
Last-Event-ID из будущего |
правка отвечала «свежий поток» | resync_required, как требует канон («rather than silently starting from now»); ветка 204 для «at or past на книге в покое» восстановлена — она тоже каноническая |
| Стоп банка против стопа пользователя | правка внесена | ОТКАЧЕНА: пин reconcile_test.go:258-266 перечисляет exit 3 среди концовок, до которых движок дошёл сам. Решение владельца 17.08: статус остаётся awaiting_bank, лечение — признак в контракте (PD-273) |
| Подпись банка → движок | приёмка подала как «нужен новый канал, вопрос владельцу» | Рамка была неверна. Строка единого бэклога 191(в) уже требовала «проводку resume = снятие стопа»; движок без --verify-bank уже делает модель владельца. Починено снятием флага (PD-277) |
| Ступень замечания | выводилась из ранга движка | Решается построчно: вывод из ранга привязывал провод к внутреннему триаж-порядку чужой зоны и слал empty_answer тихой ступенью |
| Материализация после прогона | внутри пер-прогонного бюджета | Отдельный проход демона pass("readmodel", 10 мин): defer внутри Sweep отрабатывает ДО возврата и ничего не выносил |
ТЕХДОЛГ — состояние после доработки 20.08
- sqlc — привязан к паку промтом (D39.132 п.2б), не внедрён (0 вхождений). Отложен словом владельца, промт требовал «отступление = ПИНГ» — пинг оформлен, §7(е). ⚠ Живой долг.
- oapi-codegen — кандидат, решение не принято. ⚠ Живой долг.
- ✅
claimStaleпривязан к дедлайну загрузки — загрузочная проверкаUploadDeadline < pgstore.ClaimStale, той же формы, что уже стояла дляUploadGrace(PD-284). - ✅
blockedназывает чужую книгу только когда её холд действительно укорачивает шкалу (PD-287). - Draft-only конвейер — PD-202, вопрос владельцу (§8.1а п.2). ⚠ Живой долг, но не тихий.
- ✅ Лишнее чтение манифеста на интейке снято:
books.Parseпередаёт уже прочитанный манифест вreadmodel.RefreshCut, ре-нарезок стало две вместо трёх (PD-248 актуализирован). - ✅ §8.2 триажирован поштучно: 17 сделано, 5 отклонено с причиной, 1 релей.
- PD-297 — round-trip на строку под блокировкой книги. Мерить прежде правки. ⚠ Живой долг.
- Оси, которых не смотрел НИКТО: деньги и леджер · вход/сессии/CSRF · очередь и джобы · метрики. Пак их не менял, и приёмка смотрела дифф. ⚠ Живой долг.
8б. Чем проверять каждую правку — исполнением
Правка считается сделанной, когда падает посадка мутации, а не когда «выглядит верно».
| Что чиню | Команда проверки | Что обязано произойти |
|---|---|---|
| Б-2 пере-нарезка | добавить пин из §4 Б-2 в internal/pgstore/readmodel_test.go, затем source $W/stand.env && go test ./internal/pgstore/ -run Recut -v |
До правки — SQLSTATE 23505; после — 3 главы, structure_version сдвинулся. Посадка: вернуть insert перед delete → тест обязан упасть |
| Б-2 обратная форма | второй пин: правило заголовков УБРАЛИ (глава возвращается в начало) | Должен проходить и он — delete-first его НЕ лечит, нужен DEFERRABLE |
| Б-3 пустой экспорт | пин: материализовать дерево с текстом, затем Refresh с падающим Export |
source/target обязаны остаться прежними. Посадка: вернуть безусловный set source = excluded.source → падает |
| Идемпотентность, гонка | пин: две горутины с одним ключом через ClaimIdempotency |
Одна идёт, вторая получает ErrKeyInFlight, НЕ 500 |
| Идемпотентность, контекст | пин: отменить r.Context() до complete, затем повторить запрос |
Повтор обязан получить replay, а не key_in_flight |
| Идемпотентность, отпечаток | живая проба: тот же ключ, файл ДРУГОГО размера | 409 key_reused, а не replay первого 201 |
| Полоса прогресса | пин: второй прогон со stop_for_signing над начерновленной книгой |
progress.done стартует с 0, а не с 100% |
| SSE, подрезанные кадры | пин: при живом соединении выпустить > bufferFrames кадров |
Клиент получает resync_required, а не молчаливый пропуск |
| SSE, дедлайн | проба: открыть поток и перестать читать | Соединение обязано закрыться по дедлайну, а не жить вечно |
Problem{} без кода |
одноразовый тест из §5 п.1 | В теле обязан появиться "code":"internal_error" |
| Деньги в деплой-ноте | grep -n SIGNUP_GRANT deploy/README.md |
Строки =5 быть не должно |
| Дыра в потоке ДО чтения | пин: кадры возвращаются с позиции выше from+1 |
resync_required, а не пропуск. Посадка: gap := false → падает |
Flush не выдаёт кадр за отправленный |
пин с писателем, чей FlushError падает со второго раза |
поток обрывается, end не приходит |
| Квитанция идемпотентности | пин живым PG: завершить попытку, у которой клейм забрали | ErrClaimLost, а не тихий nil |
blocked |
пин живым PG: книга из 4 глав + холд на другой книге при $20 | blocked == "", потому что шкалу режет длина книги |
| Снятие стопа банка | пин через RestartRun{LiftBankStop: true} |
полоса второго сегмента с нуля; посадка: снять пере-захват базы → падает |
| Текст пары | пин: экспорт вернул ОДНУ пару из трёх | у двух TextKnown == false, сохранённый текст цел |
| Бэкстоп дерева | пин: книга разобрана, дерево не легло, Sweep |
материализация запрошена; посадка: снять вызов → падает |
| Вся зона, финально | source $W/stand.env && make check + живые пробы §6 |
18 пакетов, exit 0, скипов 0, линтер 0 issues; пробы §6 без регрессий |
⚠ Скипы — главный обман этой зоны. Без TM_PLATFORM_TEST_DSN батарея скипует ~200 тестов и
всё равно печатает ok по каждому пакету. Любая проверка правки начинается с source $W/stand.env.
9. Полный список дефектов
⚠ Это АРХИВ находок ПЕРВОЙ приёмки (акт 1), а не список задач.
✅ Поштучная сверка всех 46 против дерева ИСПОЛНЕНА 20.08 (вместе с 23 местами §8.2 и пятью
находками критика полноты — 74 позиции). Что она изменила: три «закрытых» оказались не закрыты
(PD-283/284/285), а из мелких дошли до правки П7-07 (PD-288), П7-09 (PD-290), П7-13 (PD-293),
П7-19 (PD-294), П7-32 и П7-43 (PD-296), П7-39 (PD-295), П7-40, П7-28/29. Остались открытыми
осознанно: П7-27 (PD-297, мерить прежде правки), П7-35 (PD-202, вопрос владельцу), П7-45 (PD-298,
сперва сверка у движка), П7-46 (PD-282, вопрос контракту), П7-24 второй половиной (PD-299).
Тексты ниже — доказательная база, а не список задач: статус каждого дефекта в
DEFECT_REGISTER.md, и «чем чинить» в них намеренно устарело.
Все 46 после дедупа, по серьёзности. «Оси» — сколько независимых ревьюеров нашли это место, не видя работы друг друга; «поправка серьёзности» — голоса рефутеров.
BLOCKER — 3
П7-01 · platform/internal/pgstore/readmodel.go:156 · CONFIRMED
- Оси, нашедшие независимо (4): go-style, prompt-audit, readmodel-sql, workarounds
- Поправка серьёзности от рефутеров: HIGH×5
- Что не так: writeChapters upserts chapters on conflict (id) only, while the table also carries
unique (book_id, number)and stale chapters are deleted AFTER the inserts — so every re-cut that changes a chapter's text or shifts chapter numbers aborts SaveStructure with a duplicate-key error and the book's tree can never be refreshed again. - Сценарий отказа: Book cut as [ch1(text A, number 1), ch2(text B, number 2)]. The owner edits chapter 1; the engine's chapter id is SHA-256 over the chapter's ingested text (backend/internal/pipeline/manifest.go, manifestChapterID), so chapter 1 comes back with a NEW engine id and the SAME number 1. writeChapters inserts (new id, number 1) while the old row still holds number 1, and the delete at line 169-170 has not run yet. Reproduced end to end against live Postgres through s.SaveStructure:
pgstore: write chapter: ERROR: duplicate key value violates unique constraint "chapters_book_id_number_key" (SQLSTATE 23505). The deletion-and-renumber shape (chapter 1 removed, chapter 2 slides to number 1) fails identically. The whole transaction rolls back: manifest_key, revision, structure_version and every pair stay at the previous cut, and because the input is deterministic the refresh fails the same way forever — the reader keeps serving the text of a book that no longer exists and structure_version never moves, so no client is ever told to re-sync. The pack's own re-cut test (readmodel_test.go:104) only ever REMOVES a trailing chapter, which is the one re-cut shape that does not renumber, and the session's obstacle list admits the real chunker re-cut was never exercised. - Цитата:
on conflict (id) do update set number = excluded.number, - Чем чинить: Delete chapters outside the cut BEFORE the insert loop, exactly as writeUnits does for the same class of collision (line 195-204). Note that delete-first still does not cover a pure permutation of numbers among surviving chapters; if that is reachable, make
chapters_book_id_number_keyDEFERRABLE INITIALLY DEFERRED or renumber in two phases. - Дубли той же поломки:
readmodel.go:153(workarounds);readmodel.go:153(go-style);readmodel.go:156(prompt-audit)
П7-02 · platform/internal/httpapi/v0.go:478 · CONFIRMED
- Оси, нашедшие независимо (3): comment-verbosity, http-conditional-idem, workarounds
- Поправка серьёзности от рефутеров: HIGH×3, LOW×1
- Что не так:
key.completeиkey.releaseпишут в БД на контексте ЗАПРОСА, тогда как обычная причина обоих вызовов — клиент, который уже отвалился; в том же пакете рядом (books.writeCtx, runs.refreshReadModel) для ровно этого случая используетсяcontext.WithoutCancel. - Сценарий отказа: Загрузка книги с
Idempotency-Key: k. Клиент на мобильной сети вешает трубку, дождавшись конца заливки, но не дождавшись 201.books.Acceptэто ПРЕДУСМОТРЕЛ и пишет строку на отцепленном контексте (books.go:202-210: «a client that hung up while waiting for the 201 must not cost the upload it already finished») — книга создана. Затем :478 вызываетCompleteIdempotencyнаr.Context(), который уже отменён → pgx возвращаетcontext canceled→ запись ответа НЕ сохранена, строка ключа остаётся сfinished_at IS NULL. Повтор пользователя (контракт прямо советует ретрай) следующие 30 минут (claimStale, pgstore/idempotency.go:43) получает 409key_in_flight, а после 30 минут перехватывает протухший клейм и создаёт ВТОРУЮ книгу — ровно тот дубль, ради которого заголовок построен. Симметрично :465:books.Acceptпри обрыве откатывает строку черезwriteCtx, аReleaseIdempotencyна том же отменённом контексте падает, и ключ залипает на 30 минут при том, что работы не осталось вовсе. - Цитата:
key.complete(r.Context(), http.StatusCreated, location, h.writeJSON(w, r, http.StatusCreated, projectBook(book))) // :478-479 key.release(r.Context()) // :465 - Чем чинить: Передавать в
complete/releaseотцепленный ограниченный контекст — тот жеcontext.WithTimeout(context.WithoutCancel(ctx), …), что уже оформлен какbooks.writeCtx(parse.go:256) и применён вruns.refreshReadModel(reconcile.go:1080). Лучше — отцеплять внутри самихidempotent.complete/release, чтобы вызывающий не мог забыть. - Дубли той же поломки:
v0.go:478(http-conditional-idem);v0.go:455(comment-verbosity)
П7-03 · platform/internal/runs/spawn.go:159 · CONFIRMED
- Оси, нашедшие независимо (1): runs-lifecycle
- Поправка серьёзности от рефутеров: HIGH×2
- Что не так: Гейт полноты банка демонтирован только на половине пути:
resumeизawaiting_bankперезапускает движок с тем же--verify-bank, поэтому движок снова упирается в тот же стоп подписи — снять его через API невозможно ни при каком состоянии решений. - Сценарий отказа: Прогон с
stop_for_signing: trueдоходит до стопа банка → exit 3 →awaiting_bank. Пользователь подписывает термины (POST /bank/decisionsпишет вbank_decisionsPostgres) и жмёт «продолжить».Resume(reconcile.go:1045-1052) ставитbank_released=true,RestartRunберёт НОВЫЙ холд на остаток бюджета, воркер спавнитtmctl translate --config … --verify-bank(l.VerifyBank читается из неизменной колонкиruns.verify_bank). Движок повторно майнит:backend/internal/pipeline/mining.go:83читает дельту из ФАЙЛОВmined_delta/mined_rejectsрядом с book.yaml, которых платформа не пишет (единственные записи в workdir — интейк: books.go:247, render.go:109), поэтому дельта та же непустая →runBankMiningStopвозвращает stopped=true →bank_stop→ exit 3 → сноваawaiting_bank. Каждый клик = новый холд +tmctl status+ transient unit + прогон черновой волны, книга НИКОГДА не уходит в edit-волну. Плюс побочный эффект:bank_releasedуже true, так что полоса навсегда переключилась наunits_edit_doneи читается 0/N. Обоснование в коде (reconcile.go:987-992 «движок всегда продолжает с неподписанным банком по умолчанию, D39.42 п.3») цитирует веткуif !r.VerifyBank(mining.go:209) — то есть ровно тот случай, который этот спавн не создаёт. ТестTestResumeLiftsABankStopWithTheDecisionsAsTheyStand(control_test.go:359) слепой: фейковый Runner argv не проверяет. - Цитата:
Args: runner.TranslateArgs(l.Workdir, l.VerifyBank, ceiling), - Чем чинить: Продолжение после снятия стопа спавнить БЕЗ
--verify-bank: передавать вTranslateArgsнеl.VerifyBank, аl.VerifyBank && !bankReleased(колонка уже есть и уже читается полосой), добавивbank_releasedвrunColumns/LiveRun. Пин — на argv спавна после resume.
HIGH — 15
П7-04 · platform/internal/pgstore/readmodel.go:179 · CONFIRMED
- Оси, нашедшие независимо (4): contract-semantics, readmodel-sql, regressions, workarounds
- Поправка серьёзности от рефутеров: MEDIUM×2, LOW×1
- Что не так: После пере-нарезки счётчики глав пере-считываются по НОВОМУ номеру главы против
unit_resolutions, которые несут ординалы СТАРОЙ нарезки, — то есть глава наследует прогресс той главы, что раньше занимала её номер; ровно то, что комментарий двумя строками выше объявляет предотвращённым. - Сценарий отказа: Книга частично переведена: прогон №1 под нарезкой A разрешил главы 1–10 (потолок 10), строки
unit_resolutionsс ординалами A остались для глав 11–50. Оператор обновляет движок,chunkerVersion/langpack/normменяют сегментацию →manifest_keyиной →recut(readmodel.go:124), главы переписываются с НОВЫМИnumberнарезки B. Затем этот UPDATE считаетunits_edit_doneновой главы 11 как число разрешений сchapter = 11— это разрешения СТАРОЙ главы 11, текст которой теперь лежит в главе 12. Наружу уходят неверные значения сразу в четырёх местах канона:Chapter.units_done(readmodel.go:477),Book.chapters_done(chaptersDone, readmodel.go:345 — глава без единого перевода попадает в «переведено полностью», если унаследованный счёт ≥ еёunits_total),Run.progress.done(runProgress, readmodel.go:382) иReadBookForRun.ChaptersLeft(books.go:706) — то есть шкалаGET /run-optionsи потолок, за который платят. ТестTestACutThatChangedMovesTheVersionAndRemovesWhatIsNotInIt(readmodel_test.go:89) сценарий не покрывает: в нём нет ни одногоunit_resolutionsи нет пере-нумерации. - Цитата:
// The chapters' own counters are derived from what the stream resolved, and a re-cut re-numbers // chapters — so they are recomputed here rather than carried across, or a chapter would inherit // the - Чем чинить: Хранить в
unit_resolutionsидентичность главы, переживающую нарезку (производныйchapter_id, как уchapters), либо пере-указывать/чистить разрешения внутри той же транзакцииSaveStructure, когдаrecut. Пере-счёт поc.numberкорректен только внутри однойstructure_version. - Дубли той же поломки:
readmodel.go:182(readmodel-sql);readmodel.go:182(workarounds);readmodel.go:179(regressions)
П7-05 · platform/internal/pgstore/runs.go:86 · CONFIRMED
- Оси, нашедшие независимо (4): regressions, runs-lifecycle, sql-migrations, workarounds
- Поправка серьёзности от рефутеров: MEDIUM×6, LOW×1
- Что не так:
runs.chapters_before— the segment baseline added by 00016 — is captured with the EDIT-wave predicate (units_done), while the bar's first segment counts the DRAFT wave, so a run that has done nothing opens at 100%. - Сценарий отказа: Book of 10 chapters; a previous run drafted 6 of them and died before any edit (
units_draft_done = units_total,units_edit_done = 0,units_done = 0). User starts a NEW run with stop_for_signing=true buying 4 chapters.chapters_beforeis stored as 0 (nothing is edit-complete), whilerunProgress(readmodel.go:382-385) counts chapters whereunits_draft_done >= units_total= 6. Reproduced on live PG 18.4:select least(greatest(6 - 0, 0), 4)→ the receipt and the card answerprogress {done: 4, total: 4}— 100% — for a run that has translated nothing, contradicting canon §Progress ("the counter starts again from zero") and the column's own DDL comment at 00016_read_surface.sql:59-64. The pin the author cites (TestASecondRunsBarStartsAtZeroOverAHalfFinishedBook, readmodel_test.go:488) starts the run WITHOUT VerifyBank, so it only exercises the edit arm where baseline and numerator agree, and cannot see this. - Цитата:
where c.book_id = b.id and c.units_total > 0 and c.units_done >= c.units_total) - Чем чинить: Capture the baseline with the same predicate the bar reads:
case when $3 then c.units_draft_done else c.units_edit_done end >= c.units_total(the run's own verify_bank), and re-capture it whenReleaseBankStopflips the arm — otherwise the baseline and the numerator are still measured on two different waves after a resume. - Дубли той же поломки:
runs.go:86(runs-lifecycle);runs.go:86(workarounds);runs.go:86(regressions)
П7-06 · platform/internal/httpapi/v0.go:487 · CONFIRMED
- Оси, нашедшие независимо (3): contract-semantics, http-conditional-idem, prompt-audit
- Поправка серьёзности от рефутеров: MEDIUM×1, LOW×3
- Что не так: Отпечаток мультипарт-запроса не включает РАЗМЕР файла, который канон называет частью сравнения; два разных запроса под одним ключом получают одинаковый отпечаток и молча реплеятся.
- Сценарий отказа: openapi.yaml:201-203 (нормативный источник формы): «"The same request" is compared over the DECLARED parts — the metadata and the file's name and size — and never over the bytes themselves». Пользователь заливает
roman.txt(title «Роман», zh→ru) под ключом k1, замечает, что подсунул обрезанный файл, исправляет и заливает ИСПРАВЛЕННЫЙroman.txt(другой размер, тот же ключ — клиент чинит «ту же операцию»). Отпечаток совпадает побайтно →ClaimIdempotencyнаходит завершённую строку →beginIdempotent(httpapi/idempotency.go:85-97) отдаёт СТАРЫЙ 201 и старый Location. Второй файл не читается вообще, в библиотеке остаётся обрезанная книга, клиент получил «Created». Канон требует здесь409 key_reused. Комментарий v0.go:483-485 объявляет пропуск размера решением («The size is deliberately absent»), но это расхождение с ратифицированным контрактом, а не свобода реализации; то же расхождение продублировано в 00017_idempotency.sql:19-21. - Цитата:
return []byte(strconv.FormatBool(titleGiven) + "\x00" + in.Title + "\x00" + in.SourceLang + "\x00" + in.TargetLang + "\x00" + in.Filename) - Чем чинить: Либо включить объявленный размер (поле формы
size, сверяемое с фактически прочитанным) вintakeFingerprint, либо вынести пингом оркестратору правку канона — сейчас код тихо расходится с формой. - Дубли той же поломки:
v0.go:487(prompt-audit);v0.go:487(contract-semantics)
П7-07 · platform/internal/pgstore/readmodel.go:692 · CONFIRMED
- Оси, нашедшие независимо (2): contract-semantics, readmodel-sql
- Поправка серьёзности от рефутеров: MEDIUM×3
- Что не так: Замечания привязываются к главе INNER JOIN-ом по ординалу (
c.number = ur.chapter), аBook.note_countсчитает разрешения вообще без джойна, — после пере-нарезки замечание адресуется чужой главе и чужой паре, а счётчик книги расходится с тем, что список способен вернуть. - Сценарий отказа: (1) Пере-нарезка со сдвигом нумерации (см. находку выше): разрешение, записанное для главы 7 нарезки A, отдаётся в
GET /books/{id}/notesи вUnit.notesсchapter_idНОВОЙ главы 7 иunit_idпары с ординалом 3 в ней — замечание «term_not_applied» показывается читателю против отрывка, о котором оно никогда не было. Канон §Note: «A note addresses a chapter, and usually a pair inside it». (2) Пере-нарезка, уменьшающая число глав (ровно форма пина readmodel_test.go:89):writeChaptersудаляет главы вне нарезки (readmodel.go:169-170), разрешения удалённых глав не чистит НИКТО (grep -n 'delete from unit_resolutions'по platform/ пуст) —Book.note_count=select count(*) from unit_resolutions ur where ur.book_id = b.id and ur.flagged(readmodel.go:351) продолжает их считать, а список из-за INNER JOIN — нет. Карточка книги говорит «12 замечаний»,GET /notesотдаёт 8 строк сnext_cursor: null. (3) Если пара пропала, а разрешение осталось, LEFT JOIN даёт NULL иunit_idОПУСКАЕТСЯ (reading.go:62) — а по канону отсутствиеunit_idзначит «замечание о главе целиком», то есть на проводе оказывается другой факт. - Цитата:
join chapters c on c.book_id = ur.book_id and c.number = ur.chapter left join units u on u.chapter_id = c.id and u.ordinal = ur.unit - Чем чинить: Одно решение с находкой выше — привязать разрешения к переживающей нарезку идентичности главы/пары. Пока этого нет — считать
note_countтем же джойном, что и список, иначе два обязательных поля канона описывают разные множества. - Дубли той же поломки:
readmodel.go:698(readmodel-sql)
П7-08 · platform/internal/readmodel/readmodel.go:91 · CONFIRMED
- Оси, нашедшие независимо (2): go-style, regressions
- Поправка серьёзности от рефутеров: HIGH×2
- Что не так: A failed
tmctl exportis logged and then ignored, and refreshStructure goes on to hand SaveStructure a tree whose every pair carries empty source, empty target and statepending— which the upsert writes over a book's already-materialized text. - Сценарий отказа: Book bk_1 finished a run: 2283 chapters of source and translation are in
units. A later run ends; runs.refreshReadModel calls Refresh on a 5-minute budget (reconcile.go refreshRunBudget). Manifest succeeds; Export — the SLOWER of the two, since--pairsdemands the full re-chunk plus the whole 57 MB text — exceeds the budget, or exits non-zero for any reason readEngine does not treat as CompletedWithFlags, or overflows maxExport.erris logged,textstays the zero Export,byKeyis empty, and every StructureUnit is built withState: ingest.StatePendingand no Source/Target (line 106-111). SaveStructure's unit upsert then runsdo update set ... source = excluded.source, target = excluded.target, state = excluded.state(pgstore/readmodel.go:209-211), so all 2283 chapters lose BOTH columns — includingsource, which has no other channel at all. Neither DB check blocks it (units_translated_has_textandunits_unshipped_is_emptyboth pass for pending/''). The revision bumps and a status frame goes out, so the client refetches and renders an empty book. For a book whose translation isreadythere is no further work boundary, so nothing ever repairs it. The sibling channel one function down does the opposite and says why: refreshBank returns early on ErrNoBank so that "nothing is saved at all, so a read-out that has simply not been made yet cannot erase one that was" — and ingest/export.go:82 names this same hazard for a malformed document ("a materializer would then replace a book's whole text with nothing") while the failed-COMMAND path walks straight into it. - Цитата:
text, err := s.Engine.Export(ctx, s.Binary, workdir) if err != nil { s.log().ErrorContext(ctx, "the pairs could not be read; the tree is materialized without text", "err", err) } byKey := make(m - Чем чинить: On
err != nilfrom Export, do not write text at all: either return the error before SaveStructure, or carry atextRead boolinto pgstore.Structure so writeUnits omitssource/target/statefrom the conflict-update set (do update set ordinal = ..., revision = ...only) when the text channel did not answer. TestAFailedTextReadStillLandsTheTree pins the tree landing, which is right; it must be extended to assert that an existing pair's text survives. - Дубли той же поломки:
readmodel.go:91(regressions)
П7-09 · platform/internal/pgstore/readmodel.go:770 · PLAUSIBLE
- Оси, нашедшие независимо (1): readmodel-sql
- Поправка серьёзности от рефутеров: DOUBT×1, LOW×1
- Что не так: The version_too_old guard and the page's revision stamp are read in a separate STATEMENT from the rows they govern; inTx runs at READ COMMITTED, so each statement takes a fresh snapshot and a wholesale replacement committed between the two is neither refused nor reflected — the exact PD-163 failure the "one transaction" comment claims to have closed.
- Сценарий отказа: Client holds delta watermark 2.
bookScope(line 910) reads bank_reset_revision = 0, so the guard at line 770 passes. Between that statement and the row query at line 784 a SaveBank commits that deletes two of the three terms and sets bank_reset_revision = 3. The row query, in the SAME transaction, sees the post-replacement state and returns only the surviving row. The client merges a delta that silently omits two deleted terms and is never told to re-read — literally the outcome the error's own doc comment says it exists to prevent ("a short list that looks complete", line 37). Reproduced against live Postgres inside s.inTx:show transaction_isolation= "read committed"; statement 1 saw bank_reset_revision = 0 and statement 2 of the same transaction saw bank_reset_revision = 3 and 1 row. The same shape hits ListNotes' structureReset check (line 680) and everyout.Page = Page{Revision: scope.revision}stamp — a page can carry rows newer than the revision printed on it, which is the registered PD-163 defect that GetBook's comment (books.go:632-637) says was fixed by putting the reads in one transaction. One transaction at READ COMMITTED is not one snapshot. - Цитата:
if after != nil && *after < scope.bankReset { - Чем чинить: Open the read paths with
s.pool.BeginTx(ctx, pgx.TxOptions{IsoLevel: pgx.RepeatableRead})(a read-only snapshot costs nothing here), or fold the reset marks and the revision into the same statement as the rows.
П7-10 · platform/internal/httpapi/stream.go:130 · CONFIRMED
- Оси, нашедшие независимо (1): sse-stream
- Поправка серьёзности от рефутеров: MEDIUM×2, DOUBT×1, LOW×1
- Что не так: pump никогда не сверяет свой водяной знак
fromсо свежимstate.Oldest, поэтому кадры, подрезанные буфером ПОКА соединение открыто, пропускаются молча — безresync_required, — что канон запрещает именно дляnote. - Сценарий отказа: Клиент подключён,
from = 100. Пока он не читает сокет (см. соседнюю находку про write deadline) или пока идёт реплей журнала при перезапуске демона, писатели чеканят > 512 кадров;emitFrameподрезает буфер (events.go:85-88,delete from book_events where book_id = $1 and position <= position-512). СледующийReadFrames(ctx, bookID, 100, 256)(stream.go:117) возвращает уже с позиции 289 — строк 101..288 в таблице нет.fromпрыгает на 544, клиенту не отправляется ничего про дыру. Внутри 101..288 лежат кадрыnote(sink.go:352,emitFrame(..., FrameNote, ...)) — замечание, которого пользователь не увидит никогда. openapi.yaml:580-581: «noteis an ADDITION: it MUST NOT be coalesced or dropped — a lost one is lost silently and forever», и строка 583 прямо говорит, что эта гарантия действует ВНУТРИ одного соединения. Проверка на подрезку в паке есть ровно одна и только на рукопожатии — stream.go:95if resuming && state.Oldest > 0 && last+1 < state.Oldest;state.Oldestперечитывается каждый оборот на stream.go:132 и выбрасывается (грепом: единственное использование поля — строка 95). Запас крошечный по конструкции:bufferFrames = 512приframesPerRead = 256, то есть отставание на две пачки уже фатально. - Цитата:
if len(frames) > 0 { // The watermark moves past every frame READ, coalesced ones included: a frame replaced by // a later one of its kind has been superseded, not lost. from = frames[len(f - Чем чинить: Тот же тест, что на рукопожатии, повторять в цикле после
state, err = h.lib.ReadStream(...):if state.Oldest > 0 && from+1 < state.Oldest { s.send("resync_required", state.Position, base(state)); return }. Заодно поднятьbufferFramesзаметно вышеframesPerRead. - Дубли той же поломки:
stream.go:133(sse-stream)
П7-11 · platform/internal/httpapi/stream.go:247 · CONFIRMED
- Оси, нашедшие независимо (1): sse-stream
- Поправка серьёзности от рефутеров: MEDIUM×1
- Что не так: На маршруте потока нет НИКАКОГО дедлайна записи: клиент, который открыл поток и перестал читать, вечно держит горутину, TCP-соединение и слот сервера, потому что
r.Context()при этом не отменяется. - Сценарий отказа:
GET /v0/books/{id}/eventsоткрыт клиентом, который не вычитывает сокет (фоновая вкладка на залипшем мобильном линке,curlс остановленным чтением, злонамеренный клиент). Как только окно приёма и send-буфер заполнены,Fprintf(stream.go:247) иrc.Flush()(stream.go:251) блокируются НАВСЕГДА:serve.go:36сознательно снялWriteTimeout(«No WriteTimeout: the SSE stream is a long-lived response and a write deadline set here would cut it»),SetWriteDeadlineне зовётся нигде в зоне (грепом единственный вызов на ResponseController —v0.go:378, и этоSetReadDeadlineинтейка), аr.Context()отменяется только когда пир ЗАКРЫЛ соединение — молчащий клиент его не закрывает, поэтому фоновое чтение net/http не видит ни EOF, ни RST. Итог: горутина обработчика, соединение и его слот удерживаются без ограничения по времени, по одной штуке на каждого такого клиента;ctx.Done()вselect(stream.go:154) до этого места вообще не доходит. Эта же блокировка — механизм соседней находки: пока запись висит, буфер кадров подрезается под клиентом. Норма зоны для ровно этого класса уже сформулирована в STACK_DECISIONS §25 — «маршрут ставит собственный КОНЕЧНЫЙ дедлайн черезhttp.ResponseController»; запрещено СНЯТИЕ дедлайна, а не его выставление, так что запрет PD-51 этот случай не покрывает. - Цитата:
if _, err := fmt.Fprintf(s.w, "event: %s\nid: %d\ndata: %s\n\n", event, id, data); err != nil { - Чем чинить: В
stream.raw/stream.commentперед каждой записью выставлять конечный дедлайн через тот жеrc:rc.SetWriteDeadline(time.Now().Add(writeGrace))(grace порядка десятков секунд, больше heartbeat), и таймаут трактовать как мёртвого клиента —s.dead = true, возврат.
П7-12 · platform/internal/pgstore/idempotency.go:67 · CONFIRMED
- Оси, нашедшие независимо (1): http-conditional-idem
- Поправка серьёзности от рефутеров: MEDIUM×2
- Что не так: Два одновременных запроса с одним Idempotency-Key дают 500 internal_error вместо контрактного 409 key_in_flight: клейм — это
SELECT … FOR UPDATEпо НЕсуществующей строке (ничего не блокирует) плюс голый INSERT безon conflict. - Сценарий отказа: Клиент двойным кликом шлёт два
POST /v0/books/bk1/runsсIdempotency-Key: k1. tx1:… where … for update→ pgx.ErrNoRows → INSERT (не закоммичен). tx2 в READ COMMITTED той же строки не видит → тоже ErrNoRows → её INSERT блокируется на PK(user_id, method, path, key)(00017_idempotency.sql:30) и после коммита tx1 падает с SQLSTATE 23505. Ошибка заворачивается вfmt.Errorf("pgstore: claim idempotency key: %w", err), не является ни ErrKeyReused, ни ErrKeyInFlight → httpapi/idempotency.go:80case err != nil→Fail(w, r, CodeInternalError)→ 500. Канон openapi.yaml:906-908: «a repeat while the first is still in flight is409,cause.code: key_in_flight». Докстринг функции при этом утверждает «It answers one of four things, and the four are the whole semantics» — пятый ответ существует и не назван. - Цитата:
insert into idempotency_keys (user_id, method, path, key, request_sha256, claimed_at) - Чем чинить:
insert … on conflict (user_id, method, path, key) do nothing+ повторное чтение строки, либо распознаватьpgconn.PgError.Code == "23505"и отдавать ErrKeyInFlight. - Дубли той же поломки:
idempotency.go:43(http-conditional-idem);idempotency.go:50(http-conditional-idem)
П7-13 · platform/internal/runs/reconcile.go:1084 · CONFIRMED
- Оси, нашедшие независимо (1): runs-lifecycle
- Поправка серьёзности от рефутеров: MEDIUM×2, HIGH×1
- Что не так:
refreshReadModelотвязывается от отмены прохода и берёт 5 минут — больше, чем бюджет ВСЕГО прохода свипа (2 мин) и чем пер-прогонный бюджет (60 с), — и зовётся синхронно внутри цикла свипа, поэтому одна большая книга съедает проход целиком и цикл расчётов холдов в этом проходе не выполняется вовсе. - Сценарий отказа:
refreshRunBudget = 5 * time.Minute(reconcile.go:1073) противsweepBudget = 2 * time.Minute(cmd/tmplatformd/runner.go:192) иdefaultRunBudget = 60 * time.Second(reconcile.go:70).WithoutCancelрвёт оба дедлайна. Вход: книга 2283 главы завершает прогон;Refreshделает два полных ре-чанка (tmctl manifest --json+tmctl export --json --pairs, стоимость не замерена — их же PD-248), скажем 3 минуты.Sweepвисит внутриfinish3 минуты; когда управление возвращается,ctxпрохода (2 мин) уже истёк, иif err := ctx.Err(); err != nil { return err }(reconcile.go:38) обрывает цикл живых прогонов, а доs.Store.UnsettledRuns(ctx)(reconcile.go:45) проход не доходит НИ РАЗУ — то есть холды всех остальных завершившихся прогонов остаются открытыми ещё как минимум на интервал свипа.one()в тикере последовательный, поэтому такты пропускаются. Это ровно голодание PD-169, которое пер-прогонный бюджет и заводился ограничить, а комментарий на строке 1071-1072 утверждает обратное («bounded because it must not become the reason a sweep pass stops reaching the runs behind it»). - Цитата:
c, cancel := context.WithTimeout(context.WithoutCancel(ctx), refreshRunBudget) - Чем чинить: Либо refreshRunBudget < оставшегося бюджета прохода (и не рвать отмену:
context.WithTimeout(ctx, …)), либо вынести материализацию из цикла свипа в отдельную очередь/воркер с собственным дедлайном, чтобы медленная книга не тратила бюджет чужих прогонов. - Дубли той же поломки:
reconcile.go:1089(runs-lifecycle)
П7-14 · platform/internal/runner/artifacts.go:82 · CONFIRMED
- Оси, нашедшие независимо (1): regressions
- Поправка серьёзности от рефутеров: —
- Что не так: Чтение банка требует ключа
project_dbвbook.yaml, но движок делает его НЕОБЯЗАТЕЛЬНЫМ (дефолт<book_id>.db), а канонический шаблон оператора из deploy-руководства его не содержит — на документированном деплое банк не материализуется никогда. - Сценарий отказа: Оператор берёт шаблон из
platform/deploy/README.md:96-108(там нетproject_db), платформа рендеритbook.yamlиз него —render.go:188-194дописывает только book_id/title/source_lang/target_lang/source_file. Движок при загрузке подставляет дефолт (backend/internal/config/book.go:163-164:if b.ProjectDB == "" { b.ProjectDB = filepath.Join(dir, b.BookID+".db") }) и пишет сайдкар в<workdir>/<book_id>.db.bank.json. Платформа же на КАЖДОМ обновлении читающей поверхности (конец интейка —books/parse.go:232-236, конец прогона —runs/reconcile.go:705) падает вprojectDB→refreshBankвозвращает ошибку → только строка лога «the bank could not be refreshed».bank_termsне пишет больше никто (SaveBankвызывается только изreadmodel.refreshBank), поэтомуGET /v0/books/{id}/bankвечно отдаёт пустой список и экран подписи пуст — при том чтоresumeтеперь снимает стоп подписи «с решениями как есть». Обходной путь, который подсказывает то же руководство («Пути внутри шаблона — АБСОЛЮТНЫЕ», README:110), дляproject_dbсмертелен: абсолютный путь в общем шаблоне направит ВСЕ книги деплоя в одну БД проекта. Сессия сама признаёт, что банк живым прогоном не проверялся (platform-PROGRESS.md:270). - Цитата:
if cfg.ProjectDB == "" { return "", errors.New("runner: the book configuration names no project_db") } - Чем чинить: Повторить дефолт движка: при пустом
project_dbвернутьfilepath.Join(workdir, bookID+".db")(bookID придётся передать или прочитать из того же yaml какbook_id), либо дописыватьproject_dbотносительным путём в рендереbook.yaml. И в обоих случаях — назвать ключ в deploy/README.
П7-15 · platform/internal/readmodel/readmodel_test.go:128 · CONFIRMED
- Оси, нашедшие независимо (1): test-quality
- Поправка серьёзности от рефутеров: —
- Что не так: TestAMissingBankReadOutSavesNothing never enters the ErrNoBank branch it declares it pins — ReadBank fails one step earlier on the missing book.yaml, so the declared mutation survives (verified by execution).
- Сценарий отказа: t.TempDir() has no book.yaml, so runner.ReadBank -> projectDB returns "runner: open book configuration: …", NOT runner.ErrNoBank; refreshBank takes the generic
if err != nil { return err }path. I replacedif errors.Is(err, runner.ErrNoBank) { return nil }in readmodel.go:127-131 withreturn s.Store.SaveBank(ctx, bookID, nil)andgo test ./internal/readmodel/...stayed green. That mutation in production wipes bank_terms (and cascades bank_decisions) for every book that has a rendered book.yaml but no <project_db>.bank.json yet — i.e. every book between intake and its first bank read-out. - Цитата:
if err := svc.Refresh(t.Context(), "bk_1", t.TempDir()); err == nil { t.Fatal("a workdir with no configuration was read as a book with no bank") - Чем чинить: Write the fixture the test claims: a temp workdir containing a book.yaml with
project_db:pointing at a path whose.bank.jsondoes not exist, assert Refresh returns nil error AND store.bankSaved == false. Keep the current case as a separate test for the unreadable-configuration path.
П7-16 · platform/internal/httpapi/idempotency.go:71 · CONFIRMED
- Оси, нашедшие независимо (1): test-quality
- Поправка серьёзности от рефутеров: MEDIUM×1
- Что не так: The whole
Idempotency-Keysubsystem — both halves, HTTP and store — ships with zero tests while being wired into production. - Сценарий отказа:
grep -rn "ClaimIdempotency|CompleteIdempotency|ReleaseIdempotency|ErrKeyReused|ErrKeyInFlight" --include=*_test.goreturns nothing;go tool covergives beginIdempotent 11.5% (only theraw == "" || h.keys == nilearly return), complete 40%, release 50%. cmd/tmplatformd/runner.go:36 setsdeps.Keys = db, so this is live code. Nothing pins: the >255 refusal, 409key_reusedon a changed fingerprint, 409key_in_flight+ Retry-After, the verbatim replay of status/Location/body, release-on-408, or theclaimStaletakeover at pgstore/idempotency.go:85 — under which two concurrent uploads 30 min apart both get the claim and the user gets two books, exactly what the header exists to prevent. - Цитата:
replay, err := h.keys.ClaimIdempotency(r.Context(), key, time.Now()) - Чем чинить: Add an httpapi test with a fake IdempotencyKeys covering the four ClaimIdempotency outcomes plus the length refusal, and a DB-gated pgstore test for claim → in-flight → complete → replay → reused → stale takeover.
П7-17 · platform/internal/httpapi/reading_test.go:282 · CONFIRMED
- Оси, нашедшие независимо (1): test-quality
- Поправка серьёзности от рефутеров: MEDIUM×2
- Что не так: TestALimitIsClampedOrIgnoredButNeverRefused names clamping as the property it protects but structurally cannot observe it: the fake discards
limit, and no test anywhere passes a limit above maxPage. - Сценарий отказа: fakeLibrary.ListChapters (v0_test.go:352) ignores its
limit intargument entirely, solimit=100000proves only "not 400". I mutated pgstore/readmodel.go:937case limit > maxPage: return maxPagetoreturn DefaultPage— the exact defect canon §Limit names ("answering the default instead of clamping is what made 'ask for more, get fewer rows than a smaller request' discoverable only by experiment") — and the entire suite stayed green. clampPage is referenced by no test at all; readmodel_test.go/books_test.go only ever pass 0 or 1, so a live DSN would not catch it either. v0_test.go's TestABadCursorIsRefusedAndABadLimitIsNot has the same blind spot. - Цитата:
if w := call(t, h, "GET", "/v0/books/bk_1/chapters?"+q, ""); w.Code != http.StatusOK { - Чем чинить: Pin clampPage directly (clampPage(2000) == maxPage, clampPage(0) == DefaultPage), and make fakeLibrary record the limit it was handed so the HTTP test can assert 100000 arrives as 100000 rather than 0.
П7-18 · platform/internal/pgstore/readmodel.go:384 · CONFIRMED
- Оси, нашедшие независимо (1): prompt-audit
- Поправка серьёзности от рефутеров: MEDIUM×2
- Что не так: Полоса прогона и её база считаются по РАЗНЫМ осям:
chapters_beforeснимается по волне edit, а первый сегмент прогона со стопом на подпись считается по волне draft — новый прогон соstop_for_signingнад уже начерновленной книгой открывается сразу на 100%. - Сценарий отказа: База снимается в
pgstore/runs.go:86как(select count(*) ... and c.units_done >= c.units_total), гдеunits_done— счётчик волны edit (sink.go:257-259). Вход: прогон №1 соstop_for_signingпрошёл черновиком 10 глав и завершилсяawaiting_bank(finished_atпроставлен,runs_one_live_per_bookбольше не держит). Пользователь стартует НОВЫЙ прогон соstop_for_signing: true,ceiling_chapters: 10.chapters_before= 0 (edit не прошла ни одна глава),bank_released= false ⇒ числитель = число draft-полных глав = 10. Квитанция 202 иGET /books/{id}отдаютprogress {done: 10, total: 10}в момент старта, до единицы выполненной работы; канон §Progress требует «When a stop is cleared the counter starts again from zero». ПинTestASecondRunsBarStartsAtZeroOverAHalfFinishedBookветку не ловит — он стартует прогон безVerifyBank, обе стороны на оси edit. - Цитата: `and (case when r.verify_bank and not r.bank_released then c.units_draft_done else c.units_edit_done end) >= c.units_total) - r.chapters_before, 0), r.ceiling_chapters)``
- Чем чинить: Снимать
chapters_beforeтем жеcase, которым считается числитель (поverify_bank/bank_releasedсоздаваемого прогона); вынести выражение в один SQL-фрагмент, чтобы база и числитель не могли разойтись.
MEDIUM — 17
П7-19 · platform/internal/pgstore/readmodel.go:851 · CONFIRMED
- Оси, нашедшие независимо (2): go-style, readmodel-sql
- Поправка серьёзности от рефутеров: MEDIUM×2
- Что не так: SubmitBankDecisions never locks the book row first and takes its bank_decisions row locks in the CLIENT's array order, so two concurrent submissions on one book that list the same terms in different orders deadlock; there is no retry, so one user gets a 500.
- Сценарий отказа: Two POSTs of /books/{id}/bank/decisions land concurrently on the same book carrying the same terms in opposite order (two tabs, a client that re-sorts the bank page, a retry after a timeout). Each iteration locks one bank_decisions row via ON CONFLICT DO UPDATE, so the lock order is the order of
in. Reproduced 3 out of 3 runs against live Postgres with two 300-item batches, one reversed:submit B => pgstore: record decision: ERROR: deadlock detected (SQLSTATE 40P01). inTx does not retry and IsTransient (credits.go:450, written for exactly this) is wired only into runs/reconcile.go:355, so the error reaches h.fail as an unmapped 500 and the user's signing work is lost. This violates the zone's written invariant that a transaction touching more than one of these tables takesbooksFIRST (credits.go:410-417) — SaveBank obeys it via lockBook (line 227), this path does not. - Цитата:
for i, d := range in { - Чем чинить: Call lockBook(ctx, tx, bookID) at the top of the transaction (it serializes the two submissions), and/or sort
inby TermID before the loop so the lock order is the server's rather than the caller's. - Дубли той же поломки:
readmodel.go:848(go-style)
П7-20 · platform/internal/pgstore/migrations/00017_idempotency.sql:30 · CONFIRMED
- Оси, нашедшие независимо (1): sql-migrations
- Поправка серьёзности от рефутеров: LOW×2
- Что не так: The primary key carries the unbounded request
path, so a long URL makes the btree index tuple exceed its maximum and the claim insert fails with SQLSTATE 54000 — answered 500 instead of 404. - Сценарий отказа:
POST /v0/books/<3000 incompressible chars>/runswith anyIdempotency-Keyheader. Go's ServeMux matches{bookId}on a segment of any length andserve.go:79setsMaxHeaderBytes: 1 << 16, so the request reaches the handler;v0.go:301claims the key BEFORE the book id is resolved, andidempotency.go:69storesPath: r.URL.Pathverbatim. Reproduced on PG 18.4:ERROR: index row size 3048 exceeds btree version 4 maximum 2704 for index "idempotency_keys_pkey"(1200/1600/2000/2600-char paths insert fine; 3000 fails).beginIdempotentmaps that toFail(w, r, CodeInternalError)— any authenticated user gets a 500 plus an ERROR log line on demand, cheaply and repeatedly, where the contract requires 404 for a book that does not exist. The 255-char cap exists forkey(maxKeyLength) and for nothing else in the tuple. - Цитата:
primary key (user_id, method, path, key) - Чем чинить: Key on a digest of the path instead of the path itself (
path_sha256 bytea, keepingpathas a non-key column for operators), or bound the stored path inbeginIdempotentthe way the key is bounded.
П7-21 · platform/internal/pgstore/migrations/00016_read_surface.sql:126 · CONFIRMED
- Оси, нашедшие независимо (1): sql-migrations
- Поправка серьёзности от рефутеров: MEDIUM×1
- Что не так: The only index the migration adds to
unit_resolutionsis(book_id, revision), which the notes projection cannot use —GET /books/{id}/noteshas no index matching its access path and scans every resolution row of the book on every page. - Сценарий отказа:
ListNotes(readmodel.go:688-697) filtersur.book_id = $1 and ur.flaggedand orders byur.at, ur.chapter, ur.unit, ur.wave; nothing indexesatand nothing indexesflagged. Measured on PG 18.4 with the size PLATFORM_DIRECTION §5б names (2283 chapters, 34,245 pairs, 77,990 resolutions, 13,698 flagged): one page of 50 costs 51 ms and 5,113 shared buffers — a Seq Scan ofunit_resolutions(64,292 rows discarded), a Seq Scan of ALL 34,245unitsrows including their source/target text (4,281 buffers ≈ 34 MB) to hash-join, then a top-N sort of all 13,698 matches. Walking the list is 274 such pages: ~14 s and ~9 GB of buffer traffic, each page holding aninTxread transaction against a pool bounded at 16 connections. Withcreate index … on unit_resolutions (book_id, at, chapter, unit, wave) where flaggedthe identical query drops to 0.402 ms / ~190 buffers (nested-loop intounits_chapter_id_ordinal_key) — a 125× difference, measured, not estimated. - Цитата:
create index unit_resolutions_revision_idx on unit_resolutions (book_id, revision); - Чем чинить: Add
create index unit_resolutions_notes_idx on unit_resolutions (book_id, at, chapter, unit, wave) where flagged;in 00016 — it serves the ordering, the filter and the keyset in one.
П7-22 · platform/internal/pgstore/readmodel.go:904 · CONFIRMED
- Оси, нашедшие независимо (1): readmodel-sql
- Поправка серьёзности от рефутеров: —
- Что не так: A cursor's scope tag binds the collection and the structure version but NOT the book, so a next_cursor minted for one book is accepted by the same collection of a different book and silently returns a page starting at a foreign position instead of the refusal the contract makes the server's duty.
- Сценарий отказа: Books A and B both sit at structure_version 1. A client (or a pasted/replayed request) presents A's chapters cursor — encoding chapter number 100 — to GET /books/B/chapters. decodeCursorParts recomputes cursorScope("chapters\x001"), which matches, so the cursor is accepted and the query runs as
where c.book_id = B and c.number > 100: chapters 1-100 of book B are skipped and the client is told nothing. The same holds for the notes cursor (a foreign timestamp silently truncates B's list) and the bank cursor. The file's own header calls cursors "BOUND to the collection they came from" and says rejecting one that no longer applies is the SERVER's duty because the token is opaque to the client (lines 944-947); books.go already binds its library cursor to the userID and has a test asserting ErrBadCursor for a foreign one (books_test.go:242). Only the units cursor is safe here, and only incidentally — it embeds a chapterID that already hashes the book in. - Цитата:
return collection + "\x00" + strconv.Itoa(s.structure) - Чем чинить: Include bookID in scope.tag, i.e.
collection + "\x00" + bookID + "\x00" + strconv.Itoa(s.structure).
П7-23 · platform/internal/httpapi/stream.go:109 · CONFIRMED
- Оси, нашедшие независимо (1): sse-stream
- Поправка серьёзности от рефутеров: —
- Что не так:
Last-Event-IDбольше последней позиции книги принимается как водяной знак без проверки, и на живой книге даёт мёртвый поток вместоresync_required. - Сценарий отказа: Запрос с
Last-Event-ID: 99999на книгу, у которойevent_position = 40и идёт прогон. Проверка 204 (stream.go:52) не срабатывает —state.AtRestложно; проверка ре-синка (stream.go:95) не срабатывает —state.Oldest≤ 40, и100000 < Oldestложно. Дальшеfrom = 99999, иReadFrames(ctx, bookID, 99999, 256)(events.go:133,where position > $2) физически не может вернуть ни строки, пока книга не начеканит 100000-й кадр. Клиент держит открытый поток, получает ТОЛЬКО heartbeat-комментарии весь прогон — ниprogress, ниstatus, ниnote— и в конце одинend; полоса прогресса не двигается, замечания не приходят, и ничто не говорит клиенту, что его позиция негодна. openapi.yaml:593-594: «If the server cannot resume from the id it sendsresync_requiredrather than silently starting from now» — это ровно тот случай, и он не покрыт. Источник такого id не гипотетический: клиент, который держит одинlastEventIdна несколько книг (id пер-КНИЖНЫЙ,events.go:16-18), либо reporting-БД, восстановленная из бэкапа позади курсора клиента. - Цитата:
from := state.Position if resuming { from = last } - Чем чинить: В
lastEventID/streamEventsпосле чтения состояния:if resuming && last > state.Position { s.send("resync_required", state.Position, base(state)); return }— позиция из будущего это не «догнал», это id, которого эта книга не выдавала.
П7-24 · platform/internal/httpapi/conditional.go:136 · CONFIRMED
- Оси, нашедшие независимо (1): http-conditional-idem
- Поправка серьёзности от рефутеров: LOW×2
- Что не так:
acceptsGzipвозвращает решение по ПЕРВОМУ подходящему токену, поэтому*раньше явногоgzip;q=0побеждает: клиент получает gzip, который явно запретил (RFC 9110 §12.5.3 — более специфичная запись приоритетнее). - Сценарий отказа: Проверено исполнением (реплика функции, тот же код):
Accept-Encoding: *, gzip;q=0→ true,Accept-Encoding: *;q=1.0, gzip;q=0→ true,identity;q=0, *→ true. То есть клиент, объявивший «любое кодирование, кроме gzip», получаетContent-Encoding: gzipи не может декодировать тело. СимметричноAccept-Encoding: identity;q=0(клиент требует ЛЮБОГО кодирования) → false → сервер отдаёт identity, который клиент запретил, вместо 406. Комментарий 111-117 прямо заявляет, что «the only case that has to be right is a client that explicitly REFUSES it» — именно этот случай и сломан; регрессию внесла правка Р-17 отчёта («acceptsGzip игнорировал*»), тестов на неё нет (reading_test.go:241 проверяет только простойgzip). - Цитата:
return true - Чем чинить: Собрать ВСЕ токены, взять вес наиболее специфичного (
gzipважнее*), и трактоватьidentity;q=0/*;q=0как обязательность кодирования; либо взять готовый парсер Accept-Encoding вместо ручного.
П7-25 · platform/internal/httpapi/conditional.go:54 · CONFIRMED
- Оси, нашедшие независимо (1): http-conditional-idem
- Поправка серьёзности от рефутеров: LOW×2
- Что не так: HEAD доходит до тех же обработчиков (Go 1.22+ ServeMux паттерн
GETматчит и HEAD), но не получает ни ETag, ни 304, потому что условная ветка сравнивает метод строго с GET. - Сценарий отказа: Проверено исполнением:
mux.Handle("GET /v0/books", …)+ запрос HEAD → обработчик достигнут сr.Method == "HEAD", статус 200. ЗначитHEAD /v0/booksсIf-None-Match: W/"<текущий тег>"отвечает 200 без заголовка ETag вместо 304. Нарушены сразу: канон openapi.yaml:47 («Every collection read and the book card answer anETagand honourIf-None-Matchwith304»), RFC 9110 §13.1.2 (If-None-Match обязан вычисляться для GET и HEAD) и §9.3.2 (HEAD должен нести те же заголовки, что и GET). Клиент, дешёво проверяющий валидатор через HEAD, не получает его вовсе. - Цитата:
if r.Method == http.MethodGet { - Чем чинить:
if r.Method == http.MethodGet || r.Method == http.MethodHead {.
П7-26 · platform/internal/runs/reconcile.go:489 · PLAUSIBLE
- Оси, нашедшие независимо (1): runs-lifecycle
- Поправка серьёзности от рефутеров: DOUBT×1
- Что не так: Стоп банка (exit 3) выигрывает у записанного намерения стопа пользователя: прогон, который пользователь отменил, закрывается как
awaiting_bank, а неstopped. - Сценарий отказа: Ветка стоит в switch ДО
stoppedOnRequest(reconcile.go:516) иl.StopRequestedAtне смотрит. Вход: пользователь жмёт «стоп» (RequestStopпишетstop_requested_at, шлёт SIGTERM), а движок в ту же секунду доходит до границы майнинга и выходит 3.outcomeвозвращаетawaiting_bank,FinishRunзакрывает прогон и книгу в этом статусе. Пользователь, отменивший работу, получает книгу в состоянии «подпишите банк и продолжайте» с активной кнопкой продолжения (которая по находке №1 ещё и зациклена), а факт стопа виден только в служебной колонке. Это ровно тот класс, который пак ратифицировал в другую сторону для потолка (PD-241, комментарий на строках 505-515: «намерение стопа выигрывает у причины паузы»), — здесь правило не применено. - Цитата:
return "awaiting_bank", "", &code - Чем чинить: Внести
OutcomeBankStopпод то же правило, чтоOutcomeCeiling: еслиl.StopRequestedAt != nilиstoppedOnRequest, отдаватьstopped(собственные ЗАВЕРШЕНИЯ движка —clean/flagged— остаются выигрывающими, они не прерывание).
П7-27 · platform/internal/pgstore/readmodel.go:205 · PLAUSIBLE
- Оси, нашедшие независимо (1): go-style
- Поправка серьёзности от рефутеров: DOUBT×1, LOW×1
- Что не так: writeChapters/writeUnits issue one
tx.Execround trip per row while holdingselect ... for updateon the book, where pgx v5 — already the module's driver — offers SendBatch/CopyFrom; on a real book that is ~11k round trips of exclusive lock on the one row every other writer of that book needs. - Сценарий отказа: SaveStructure opens with
select manifest_key, revision, structure_version from books where id = $1 for update(line 116) and holds that row lock until commit. For the corpus book the pack sizes itself against — 2283 chapters (research/28, quoted at reading.go/PLATFORM_DIRECTION §5б) — the body between is one Exec per chapter (2283) plus one delete and one Exec per unit per chapter (~7000 at three units/chapter): ~11,600 sequential round trips inside that lock. Every other writer of the same book serializes behind it: emitFrame'supdate books set event_position = event_position + 1(events.go:58-61), StartRun's status write, RunSink.bump'sselect revision + 1 ... for update. A user pressing Start on a large book while its tree materializes waits the whole materialization before getting a 202, and the SSE stream of that book emits nothing for the same window. The zone standard puts proven tooling ahead of hand-rolled work (ENGINEERING_STANDARDS §1) and 12-go-style-notes §1 puts stdlib/dependency ahead of one's own mechanism; PD-248 records the CPU cost of the two re-chunks but not the round-trip count or the lock hold. - Цитата:
for i, u := range units { if _, err := tx.Exec(ctx,insert into units (id, chapter_id, ordinal, source, target, state, revision)` - Чем чинить: Batch the per-row statements through
tx.SendBatch(pgx.Batch) — one flush per chapter or per whole tree — or stage the units withtx.CopyFrominto a temp table and upsert once. Either collapses 11k round trips to a handful without changing the SQL semantics or the lock.
П7-28 · platform/internal/pgstore/sink.go:336 · PLAUSIBLE
- Оси, нашедшие независимо (1): comment-verbosity
- Поправка серьёзности от рефутеров: LOW×1
- Что не так: Нарратив ревью («два ревьюера нашли», «found by cross-model review») внесён в код в девяти местах пака; каждый пункт дословно продублирован строкой собственного отчёта пака.
- Сценарий отказа: Все девять сайтов новые в паке: sink.go:336, sink.go:120 («found by the adversarial review of this pack»), pgstore/readmodel.go:415 («Found by cross-model review.»), ingest/notes.go:9, ingest/vocabulary.go:5-8 («Two independent reviewers found the same leak in the same pack»), runs/reconcile.go:455 и :580, httpapi/conditional.go:116 («which was the first version of this»), migrations/00016:226 («a reviewer reproduced the abort»). Каждому соответствует строка таблицы в platform-PROGRESS.md: Р-1, Р-2, Р-3, Р-6, Р-7, Р-17. Отказ читателя: комментарий описывает КОД, КОТОРОГО В ДЕРЕВЕ НЕТ («It carried
u.Reason… until»), проверить его по дереву нельзя, а при следующем переезде карты он молча протухнет — ровно тот класс, который проект заводит строками как «лживый комментарий». Норма владельца 26.07: «одна-две строки „почему“, улики — в отчёт пакета»; прецедент исполнения — docs/archive/reports/CHECKER_PACKAGE_2026-08-02.md:150 «подрезаны 4 doc-простыни (метрики → отчёт) по норме владельца 26.07». - Цитата:
//u.Reason—glossary_miss,hard_refusal— until two independent reviewers found the same // leak: a frame is stored wire-ready and replayed verbatim, so a word that gets in here is a word / - Чем чинить: В каждом из девяти мест оставить ПРАВИЛО без истории. Пример для sink.go:334-338 → две строки: «Кадр несёт контрактные
code/severityчерез ту же карту, что и путь чтения: кадр хранится wire-ready и реплеится дословно». Суммарно ~25 строк на вырез; атрибуция ревью уже есть в отчёте.
П7-29 · platform/internal/httpapi/conditional.go:27 · PLAUSIBLE
- Оси, нашедшие независимо (1): comment-verbosity
- Поправка серьёзности от рефутеров: DOUBT×1, LOW×1
- Что не так: Замеры бенчмарка внесены в шапку файла как факт; пак сам уже разошёлся в числах об одной и той же книге.
- Сценарий отказа: Ни одно из чисел не запинено тестом и не пере-считывается ничем; читатель через пак цитирует «26.1 KB → 10.4 KB» как текущее свойство деплоя. Расхождение уже внутри одного пака: pgstore/readmodel.go:531 «a 2283-chapter book is 57 MB of pairs» против runner/engine.go:132 (строка добавлена этим же паком) «a 2283-chapter book is ~60 MB of source plus its translation» — одна величина, два числа. Третья копия замера: runner/artifacts.go:27 «A thousand-row bank measures 167 KB (research/28 §1.9)» — то же число, что в conditional.go:28. Норма «метрики → отчёт» исполнялась в проекте прежде (archive/reports/CHECKER_PACKAGE_2026-08-02.md:150).
- Цитата:
// The measurements this closes are the review's own, on a real book: a chapter 26.1 KB → 10.4 KB, // the chapter tree 250 KB → 39 KB, a thousand-row bank 167 KB → 17 KB, and a focus refetch across // - Чем чинить: Вырезать conditional.go:27-29 целиком (3 строки) — замеры живут в research/28 и в отчёте пака. В artifacts.go:27-28 оставить «кап на три порядка выше любого реального read-out», без числа. В readmodel.go:531 и engine.go:132 оставить один порядок величины и одно место, где он назван.
П7-30 · platform/internal/pgstore/runs.go:350 · CONFIRMED
- Оси, нашедшие независимо (1): workarounds
- Поправка серьёзности от рефутеров: LOW×2
- Что не так:
RequestStopсодержит посимвольную копию константыrunProgress, которая лежит в том же пакете, — при том что весь остальной пак построен на именованных SQL-фрагментах именно затем, чтобы второй копии не было. - Сценарий отказа: Алиасы
rиbв этом UPDATE ровно те, что нужны константе, — подстановкаrunProgressкомпилируется как есть, так что копия не вынуждена ничем. Норма пака сформулирована им самим двумя файлами раньше: «Written once because a second copy is how a projection drifts: the intake's 201, the library page and the card all answer the same fields or the client sees a book change shape by the route it came from» (readmodel.go:336-337). Отказ: при следующей правке сегментного предиката (а она уже нужна — см. находку поchapters_before) три места (runProgress,emitProgress,lastRunTx,ReadRun) поедут, аRequestStop— нет, и квитанция стопа станет единственным ответом, где полоса считается по старому правилу. Ровно тот класс расхождения, который эта же сессия чинила находкой Р-8. - Цитата:
returning r.id, r.book_id, b.revision, r.status, r.verify_bank, r.ceiling_chapters, least(greatest((select count(*) from chapters c where c.book_id = b.id an - Чем чинить: Заменить блок на
+ runProgress +в конкатенации запроса, как это уже сделано вReadRun(runs.go:441),lastRunTx(books.go:667) иemitProgress(sink.go:283).
П7-31 · platform/internal/pgstore/readmodel_test.go:473 · CONFIRMED
- Оси, нашедшие независимо (1): test-quality
- Поправка серьёзности от рефутеров: LOW×1, MEDIUM×1
- Что не так: The leak-grep that looks like the guard on the internal→contract vocabulary runs over a fixture that can never carry the word, and the three translation functions plus the 13-entry note map have no direct test at all.
- Сценарий отказа: In that test the book's reject_reason is '' (it was never rejected), so ContractRejectReason is only ever called with '' and returns '' whatever its body says. I mutated ingest/vocabulary.go:81-82 to
return RejectParserUnavailable— forwarding the platform's internal word straight onto the wire asreject_reason, a direct breach of this pack's own anti-leak invariant — and, in the same run, changedcjk_artifactto return "content_withheld" and droppedhard_refusalfrom the refusal case in ingest/notes.go.go test ./internal/...stayed green on all three. Onlyglossary_miss → term_not_appliedis asserted anywhere; 12 of the 13 note mappings and 2 of the 3 vocabulary functions are unpinned. - Цитата:
for _, leak := range []string{"daily_ceiling", "ceiling_unknown", "parser_unavailable"} { - Чем чинить: Add a table test in internal/ingest for NoteCode over every reason in the map plus one unknown, and for ContractPausedReason/ContractHaltReason/ContractRejectReason over every internal constant — including the assertion that no output equals its input for the three that must not survive translation.
П7-32 · platform/internal/httpapi/problem_test.go:116 · PLAUSIBLE
- Оси, нашедшие независимо (1): test-quality
- Поправка серьёзности от рефутеров: DOUBT×1, LOW×1
- Что не так: Tautological assertion:
title(code) == ""can never be true for any Code, because title() has a non-empty default branch — so a title/status mismatch, the exact class the test says it exists to catch, passes. - Сценарий отказа: problem.go:146-147 ends in
default: return "Internal error". I deletedcase CodeGone: return "Gone"from title() andgo test ./internal/httpapi/...stayed green — a 410 then answers{"status":410,"title":"Internal error","code":"gone"}, precisely the "mismatched pair a reviewer cannot see, because both halves look right on their own" the test's own doc comment cites. Separately the code list is a hand-written literal map, so a Code added to the const block at problem.go:29-44 is covered by nothing. - Цитата:
if title(code) == "" { t.Errorf("%s has no developer-facing title, which the schema requires", code) - Чем чинить: Assert the title VALUE per code (or at minimum
title(c) != title(CodeInternalError)for every non-internal code), and drive the loop from a slice of all declared codes so a new constant fails the test until it is listed.
П7-33 · platform/internal/httpapi/stream_test.go:136 · CONFIRMED
- Оси, нашедшие независимо (1): test-quality
- Поправка серьёзности от рефутеров: —
- Что не так: The resync guard's own code comment names the wrong form it must not regress to, and no test distinguishes the two: the mutation survives in both directions.
- Сценарий отказа: stream.go:95 is
if resuming && state.Oldest > 0 && last+1 < state.Oldest {with the comment "⚠last+1 < Oldestand notlast > 0 && last < Oldest:0is a legitimate id". I substituted exactly that wrong form and the whole suite stayed green, because this is the only test of the path and it uses last=17, which both forms answer identically. Under the mutation: a client that opened a stream on a fresh book (hello id 0), went offline while the book emitted 900 frames and the buffer pruned to Oldest=389, reconnects withLast-Event-ID: 0→last > 0is false → no resync_required, the pump starts it from position 0 and everynoteframe in the gap is lost silently and forever, which is the one thing the contract forbids. The opposite edge (last=Oldest-1, which must NOT resync) is also unpinned. - Цитата:
got := frames(t, streamOf(t, lib, "17").Body.String()) - Чем чинить: Add two cases to this test: Last-Event-ID "0" with Oldest=800 must answer resync_required, and Last-Event-ID equal to Oldest-1 must NOT.
П7-34 · platform/docs/PLATFORM_DIRECTION.md:68 · PLAUSIBLE
- Оси, нашедшие независимо (1): prompt-audit
- Поправка серьёзности от рефутеров: DOUBT×1, LOW×1
- Что не так: Протухший «$5» из
PLATFORM_DIRECTION.md§2 НЕ вычищен, хотя и записка-план пака, и закрытая строка регистра PD-104 утверждают обратное — п.2 промта исполнен частично при заявленном «исполнен». - Сценарий отказа: Промт п.2: «Протухшие «$5»:
PLATFORM_DIRECTION.md§2, BACKLOG П-7, регистр». Записка-план и закрытая строка PD-104 обе утверждают: «протухшие «$5» вычищены изPLATFORM_DIRECTION.md§2». Фактически в §2 правлен только первый абзац (строки 50-52), а через четыре абзаца, в том же §2 и именно в тематическом абзаце про фри-тир, стоит безоговорочное «Дефолт $5, настраиваемый пер-аккаунт». Читатель получает значение, противоречащее коду (config.go:241:SignupGrantMicroUSD: 0) и слову владельца 16.08 — ровно тот док↔код-разрыв, ради закрытия которого PD-104 и заводился. - Цитата:
**Фри-тир = ГРАНТ в леджер, управляется из админки** (владелец 05.08). Дефолт **$5**, настраиваемый - Чем чинить: Заменить «Дефолт $5» на «Дефолт 0» со ссылкой на D39.138 п.2л (или снять предложение, оставив ⚠-оговорку вверху §2) и поправить утверждение о вычистке в PD-104 и в записке-плане.
П7-35 · platform/internal/pgstore/readmodel.go:346 · CONFIRMED
- Оси, нашедшие независимо (1): prompt-audit
- Поправка серьёзности от рефутеров: LOW×2
- Что не так: Вся прогресс-часть читающей поверхности жёстко завязана на существование волны edit: при draft-only конвейере движка
Book.chapters_done,Chapter.units_doneиRun.progress.doneнавсегда остаются нулём — Go-логика ветвится по конфигурации, которой в репо нет. - Сценарий отказа:
units_doneиunits_edit_doneпишутся ТОЛЬКО из строкunit_resolutionsсwave = 'edit'(sink.go:255-260,readmodel.go:180-183). При draft-only конвейере движок не эмитит ни одногоunit_doneсwave: edit— только draft (backend/internal/pipeline/waverun.go:167-179, веткаif !editWave: «the draft IS the shipping output»). Вход: оператор развернулbook.yaml/pipeline без edit-стадий (движок это поддерживает штатно, шаблон принадлежит оператору — D39.130, вplatform/deploy/его нет). Результат: прогон безstop_for_signingдоходит доready, аGET /books/{id}отдаётchapters_done: 0приchapter_count: N,GET /chapters—units_done: 0у каждой главы,Run.progress—{done: 0, total: N}: дробь никогда не достигает единицы вопреки канону §Progress. Ни строки регистра, ни строки obstacle на этот класс нет. - Цитата: `where c.book_id = b.id and c.units_total > 0 and c.units_done >= c.units_total)``
- Чем чинить: Считать главу пройденной по ПОСЛЕДНЕЙ волне, которую книга реально видела (например
maxпо волнам еёunit_resolutions), а не по литералу'edit'; либо объявить draft-only неподдерживаемым явно — строкой регистра и отказом, а не молчаливым нулём на проводе.
LOW — 9
П7-36 · platform/internal/config/config.go:175 · CONFIRMED
- Оси, нашедшие независимо (3): go-style, regressions, workarounds
- Поправка серьёзности от рефутеров: LOW×4
- Что не так: pairSpec's doc comment documents a syntax the regex on the next line rejects: it writes the separator as
-while the pattern requires>. - Сценарий отказа: An operator reading the only in-code documentation of the variable sets TM_PLATFORM_LANGUAGE_PAIRS="zh-ru,ja-ru:unavailable", exactly as written. parsePairs returns
config: TM_PLATFORM_LANGUAGE_PAIRS: "zh-ru" is not>[:unavailable], e.g. zh>ruand Load fails, so the daemon does not boot. The very next doc comment (parsePairs, lines 179-183) explains that the separator is>precisely because a code may contain a hyphen — so the two comments on adjacent declarations contradict each other, and the wrong one is the one attached to the pattern. - Цитата:
// pairSpec is the configured form of one pair:zh-rufor a pair this deployment can run, and //ja-ru:unavailablefor one it knows about and cannot. var pairSpec = regexp.MustCompile(^([a-z]{2,3` - Чем чинить: Rewrite the pairSpec comment to
zh>ru/ja>ru:unavailable. - Дубли той же поломки:
config.go:175(workarounds);config.go:175(regressions)
П7-37 · platform/internal/httpapi/stream.go:32 · CONFIRMED
- Оси, нашедшие независимо (2): sse-stream, workarounds
- Поправка серьёзности от рефутеров: LOW×2, DOUBT×1
- Что не так: Комментарий, которым обоснован выбор опроса вместо LISTEN/NOTIFY, называет цену вдвое ниже реальной и не в тех единицах — и противоречит строке PD-247, заведённой этим же паком.
- Сценарий отказа: Каждый оборот
pumpделает ДВА обращения к БД —h.lib.ReadFrames(...)(stream.go:117) иh.lib.ReadStream(...)(stream.go:132), — и оборот приходит по тикеруpollEvery = time.Second. То есть 2 запроса/с на СОЕДИНЕНИЕ, а не 1 на «наблюдаемую книгу»: 12 вкладок на одной книге дают 24 запроса/с, а не 1. Второй из них к «таблице, ограниченной по книге» тоже не сводится —ReadStream(events.go:111-116) читаетbooks, делаетnot existsпоrunsиmin(position)поbook_events. Число «24 запроса/с на 12 вкладок» уже записано автором в DEFECT_REGISTER PD-247, так что в паке два текста про одну величину, и лгущий — тот, что в коде. Удержание при этом не ограничено ничем:TypeBankStop(sink.go:154-159) ставит прогонуstatus = 'awaiting_bank', НЕ трогаяfinished_at, поэтомуAtRest(events.go:113-114) ложно всё время, пока владелец не подписал банк, иendне приходит — эти 2 запроса/с на вкладку идут часами в пул из 16 соединений (pgstore/store.go:24defaultMaxConns = 16). - Цитата:
// subscribes: one indexed query per second per watched book, against a table bounded per book. - Чем чинить: Привести комментарий к PD-247 («два запроса в секунду на соединение»), либо снять расхождение по существу — сливать кадры и состояние в один round-trip, а состояние читать не каждый тик.
- Дубли той же поломки:
stream.go:34(workarounds)
П7-38 · platform/internal/runner/engine.go:175 · PLAUSIBLE
- Оси, нашедшие независимо (2): go-style, workarounds
- Поправка серьёзности от рефутеров: LOW×1, DOUBT×2
- Что не так: readEngine names a parameter
cap, shadowing the predeclared identifier for the whole function body. - Сценарий отказа: Inside readEngine the builtin
capis unreachable, so a later edit that needs it — e.g. sizing a buffer withcap(doc)while adding a retry or a second reader — compiles as a call on an int64 and fails with "cannot call non-function cap (variable of type int64)", or worse reads correctly to a reviewer and wrongly to the compiler in a context where int64 is callable-looking. Go Code Review Comments and the Google Go Style Guide's naming section both forbid reusing predeclared identifiers; the enabled linter set has nopredeclared/revivecheck, so nothing caught it. The same function already carries a neutral name for its sibling parameter (what). - Цитата:
func readEngine(ctx context.Context, binary, workdir string, args []string, cap int64, what string) ([]byte, error) { - Чем чинить: Rename the parameter to
limitormaxBytes(the call sites already pass maxManifest/maxExport/maxStatus, somaxBytesreads truest). - Дубли той же поломки:
engine.go:175(workarounds)
П7-39 · platform/internal/pgstore/migrations/00015_seam_ceiling_and_units.sql:75 · CONFIRMED
- Оси, нашедшие независимо (1): sql-migrations
- Поправка серьёзности от рефутеров: LOW×1
- Что не так: 00016 makes
unit_resolutions_book_idxa strict prefix duplicate of the index it adds, and does not drop it — a second index maintained on the hottest insert path for nothing. - Сценарий отказа: After 00016 the table carries both
(book_id)and(book_id, revision). Verified on PG 18.4: droppingunit_resolutions_book_idxleaves the library'snoteCountsubquery (readmodel.go:351) on the same plan shape viaBitmap Index Scan on unit_resolutions_revision_idx, so no query loses an access path. The cost is real on the write side:unit_resolutionstakes one insert per unit per wave (≈70,000 rows for a 2283-chapter book, sink.go:239-246), and the duplicate index measures 616 kB against the composite's 640 kB — roughly 50% extra index-write work per materialized unit, permanently. - Цитата:
create index unit_resolutions_book_idx on unit_resolutions (book_id); - Чем чинить:
drop index unit_resolutions_book_idx;in 00016's Up, re-created in its Down.
П7-40 · platform/internal/pgstore/migrations/00016_read_surface.sql:200 · PLAUSIBLE
- Оси, нашедшие независимо (1): sql-migrations
- Поправка серьёзности от рефутеров: DOUBT×1
- Что не так: The safety argument for using a PostgreSQL 15+ feature cites a version pin that the ratified stack decision does not contain — the declared floor is 16, not 18.4.
- Сценарий отказа:
platform/docs/STACK_DECISIONS.md:18reads| PostgreSQL | **18.x** (проверено на 18.4), floor **16** | … | floor 16, потому что River тестируется на трёх последних мажорных |. The recipe pins nothing at 18.4; it ratifies a floor of 16 and names 18.4 only as the version tested. An operator or a later migration author who takes this comment at face value believes the deployment minimum is 18.4 and may reach for an 18-only feature, which the ratified floor does not permit. (The feature actually used here,unique nulls not distinct, needs 15 and is safe under floor 16 — the claim is wrong, the code is not.) - Цитата:
-- pins 18.4 (docs/STACK_DECISIONS.md) and readiness refuses a schema behind the binary, so the - Чем чинить: Restate as the ratified fact: floor 16 per STACK_DECISIONS.md §stack table, which is above the 15 this migration needs.
П7-41 · platform/internal/httpapi/problem.go:259 · PLAUSIBLE
- Оси, нашедшие независимо (1): comment-verbosity
- Поправка серьёзности от рефутеров: DOUBT×1
- Что не так: Doc-комментарий
WriteStatusProblem— 20 строк на 14 строк кода, из них половина: разбор отвергнутых альтернатив и история удалённой функции. - Сценарий отказа: Читатель, которому нужно узнать, что делает писатель ошибок для /auth и /readyz, обязан пройти проектный спор (две отвергнутые формы) и историю снятой
codeForStatus(строки 265-268: «ThecodeForStatusthat used to sit here was a second source of truth»). Тот же спор уже записан дважды вне кода: строка Р-22 таблицы селф-ревью в platform-PROGRESS.md и §3 PLATFORM_DIRECTION.md. ФункцииcodeForStatusв дереве нет — комментарий описывает несуществующий код. - Цитата:
// The alternative shapes were weighed and both refused: addingmethod_not_allowed/ //too_many_requeststoErrorCodeputs values in the VERSION's vocabulary that its own surface // can never an - Чем чинить: Оставить строки 249-257 (что это за поверхность и почему нет
code— ратифицировано компаньоном §2.14). Вырезать 259-268 (10 строк): альтернативы — в направление зоны, история удалённой функции — в отчёт.
П7-42 · platform/deploy/README.md:92 · CONFIRMED
- Оси, нашедшие независимо (1): regressions
- Поправка серьёзности от рефутеров: LOW×1
- Что не так: Deploy-руководство по-прежнему утверждает, что платформа подставляет в
book.yamlключgenre, — пак это удалил, и в списке протухших мест зоны (аддендум2e2894c) этот пункт не значится. - Сценарий отказа: Рендер больше не пишет
genre(platform/internal/books/render.go:188-194— ключа в списке нет). Оператор, следующий этому тексту, не кладётgenreв шаблон (в приведённом там же примере шаблона его и нет, README:96-108) и получает книги вообще без жанра: движок его не требует, но складывает вBriefHash(backend/internal/config/book.go:309), то есть каждая книга деплоя переводится с пустым жанровым входом и никто об этом не узнаёт. Если же оператор жанр в шаблон впишет — он будет ОДИН на все книги деплоя, о чём руководство тоже не предупреждает. - Цитата:
Платформа подставляет ровно то, что знает только она:book_id·title·source_lang·target_lang·genre·source_file. Всё остальное едет из шаблона нетронутым — включая ключи, о которых - Чем чинить: Убрать
genreиз перечисления подставляемых ключей и добавить абзац: жанр теперь целиком принадлежит шаблону/книге и правится оператором вручную вbook.yamlконкретной книги.
П7-43 · platform/internal/httpapi/v0_test.go:418 · CONFIRMED
- Оси, нашедшие независимо (1): test-quality
- Поправка серьёзности от рефутеров: LOW×1
- Что не так: TestEveryContractRouteRequiresASession says "Every contract route" but its enumeration was not extended for the seven routes this pack added, so the 401-before-404 property is pinned for 5 of 12 routes.
- Сценарий отказа: v0.go:65-101 now registers /capabilities, /books/{id}/chapters, /books/{id}/chapters/{id}/units, /books/{id}/notes, /books/{id}/bank, POST /books/{id}/bank/decisions and /books/{id}/events; none is in the list. A route registered in the
d.Library != nilblock withoutd.Auth.Requireanswers 500 from principal() (v0.go:633-639) rather than 401 to a caller with a dead session, and nothing in the suite objects — I unguarded the chapters route and only the unrelated shape test failed, on a panic, never on the status. The incidental 500 backstop is why this is LOW rather than higher, but the property the test names is no longer covered for the majority of the surface. - Цитата:
for _, path := range []string{"/v0/books", "/v0/books/bk_1", "/v0/books/bk_1/run-options", "/v0/usage"} { - Чем чинить: Drive the loop from the route table rather than a literal — or at minimum append the seven new paths and the POST — and assert 401 for a dead session on each.
П7-44 · platform/docs/DEFECT_REGISTER.md:63 · CONFIRMED
- Оси, нашедшие независимо (1): prompt-audit
- Поправка серьёзности от рефутеров: —
- Что не так: Строка PD-185 осталась
open, хотя пак исполнил ровно её названное лечение — снялfinalizingиз обоих Go-аллоулистов и из обоих CHECK-констрейнтов; §6 промта требует держать регистр в согласии с деревом. - Сценарий отказа: Тело PD-185 называет два лечения: «либо писателем (финальная волна движка), либо снятием из аллоулистов». P7 исполнил второе:
runs/runs.go:271иpgstore/sink.go:538больше не содержатfinalizing, миграция 00016 пересобралаbooks_status_checkиruns_status_checkбез него, и записка-план отчёта («finalizing вон из словарей») помечает пункт «исполнен». Статус строки при этомopen, а её колонка «Где» указывает на два файла, где значения уже нет. Следующая сессия, читающая регистр как источник открытых долгов, пойдёт искать несуществующего писателя; заявленный паком счёт «63 открытых» на единицу завышен. - Цитата:
| PD-185 | bug | info |internal/pgstore/sink.go,internal/runs/runs.goreadyToTranslate| **Статусfinalizingесть в контракте, в DDL и в обоих аллоулистах — писателя нет ни одного.** - Чем чинить: Перевести PD-185 в
fixed(P7, дерево сессии)с уликой (миграция 00016 + два свитча) и перенести в закрытую секцию эры P7, рядом с PD-172/173/174/180/199.
DOUBT — 2
П7-45 · platform/internal/pgstore/sink.go:243 · PLAUSIBLE
- Оси, нашедшие независимо (1): contract-semantics
- Поправка серьёзности от рефутеров: DOUBT×1
- Что не так: Разрешение, переехавшее из
flaggedв не-flagged, — это УДАЛЕНИЕ из коллекции замечаний, которое дельта-чтение выразить не может, а ниresync_required, ниversion_too_oldпри этом не выдаются. - Сценарий отказа: Комментарий этого же метода (sink.go:222-224) утверждает, что переход происходит: «A unit can legitimately be resolved twice with different dispositions — a redrive re-attacks a flagged one — and the read model wants the second». Тогда: клиент прочитал замечания, водяной знак R1, держит nt_X. Редрайв пере-разрешает (глава 7, юнит 3, edit) с
flagged=false; строка обновляется,revisionедет на R2,bank_reset_revision/structure_reset_revisionне двигаются. Дельта?after_version=R1фильтруется предикатомur.flagged(readmodel.go:694) — строка не возвращается, и клиент никогда не узнаёт, что замечание снято: оно остаётся на экране навсегда. Канон §AfterVersion: «A DELETION cannot be expressed this way. Two answers close that:resync_required… and400withcause.code: version_too_old» — здесь не даётся ни один. DOUBT, потому что я не подтвердил чтением движка, что он действительно пере-издаётunit_doneдля той же тройки (глава, юнит, волна) сflagged=false: ретраи и эскалация вstagerun.goпроисходят ДО эмиссии события, а меж-прогонный ре-обход идёт из чекпоинта с тем же флагом. Смягчение: кадрchapterнесёт упавшийnote_countсо своим id, так что экран главы само-лечится — не лечится книжный список замечаний. - Цитата:
do update set shipped = excluded.shipped, flagged = excluded.flagged, - Чем чинить: Либо помечать снятие флага как замену коллекции (двинуть
structure_reset_revision-аналог для замечаний, чтобы устаревший водяной знак получил 400), либо сначала подтвердить у движка, что переход недостижим, и записать это в регистр — сегодня платформа код под него держит, а канонический ответ на него не даёт.
П7-46 · platform/internal/runs/reconcile.go:1026 · PLAUSIBLE
- Оси, нашедшие независимо (1): workarounds
- Поправка серьёзности от рефутеров: LOW×1
- Что не так: Ветка
exhaustedвResumeотвечает 202 с прогоном в том же состоянии — ровно тот молчаливый no-op, который веткаpausedчетырьмя строками выше отвергает как запрещённый каноном; и её длинное обоснование теперь спорит про случай, который до неё больше не доходит. - Сценарий отказа: После перевода
pausedна безусловный 409ceiling_reached(:1000) вexhaustedмогут попасть толькоstoppedиawaiting_bank. Дляawaiting_bankэто значит: пользователь жмёт «продолжить», получает 202,ReleaseBankStopне вызывается (:1045 недостижим), книга остаётсяawaiting_bank— клиент не может отличить это от настоящего продолжения, что веткаpausedсама называет «exactly the silent no-op the contract now forbids». Плюс ⚠-абзац внутри ветки на 12 строк разбирает арифметику книжного потолка уpaused-прогона, который сюда больше не приходит, и заканчивается фразой «The pause the platform CANNOT lift … is refused above» — оправдание пережило решение, которое оправдывало. Отказом не подаю: не проверил исполнением, чтоexhaustedдостижим дляawaiting_bank(нужен прогон с исчерпанным остатком бюджета на банковском стопе). - Цитата:
case exhausted: // Nothing left to continue with: the call returns the run in the state it was in, and the state // is NOT rewritten on the way out — a run the user stopped stays stopped, because - Чем чинить: Либо отвечать на
exhaustedтем же 409 с причиной, что иpaused(нечего продолжать — потолок исчерпан), либо, если 202 намеренный, объяснить в комментарии, чем он отличается от no-op'а, запрещённого строкой выше. Мёртвую половину обоснования вычистить вместе с этим.
10. Приложения
Опровергнуто рефутерами — НЕ переоткрывать
| место | что заявлялось | почему отклонено |
|---|---|---|
platform/internal/pgstore/migrations/00016_read_surface.sql:146 |
Both directions of the bank-status translation send an unrecognised value to approved — the one value that means "the user signed" — which |
Citation is accurate (00016_read_surface.sql:146 else 'approved' end,), but the claimed failure has no reachable input, and the "norm inconsistency" argument misreads the two seams. Five independent grounds: 1. The domain is CLOSED, so |
platform/internal/pgstore/sink.go:268 |
P7 derives Book.note_count from unit_resolutions, which makes the fold's early return — taken whenever the book has no chapter rows — bump a | Механика прочитана верно, но заявленный ОТКАЗ не наступает — он держится на трёх посылках, каждая из которых опровергается кодом/каноном. Что подтвердилось (и только это): ветка без дерева реальна — platform/internal/books/parse.go:163-166 логирует провал Refresh и продолжает |
platform/internal/httpapi/conditional.go:54 |
ETag и 304 выдаются на GET /v0/run-options и GET /v0/usage, где канон 0.3.0 их НЕ объявляет — сгенерированный по контракту клиент встреч |
Цитата и код прочитаны верно — conditional.go:54 « if r.Method == http.MethodGet {» действительно ставит ETag (:56) безусловно на любой GET, и runOptions (v0.go:258), usage (v0.go:607) идут через writeJSON; маршруты GET (v0.go:83, v0.go:88). Верна и опись канона |
platform/internal/httpapi/project.go:63 |
Обоснование ловушки heading выписано целиком ЧЕТЫРЕЖДЫ в продакшн-коде (плюс дважды в тестах); при появлении настоящих заголовков правится |
Проверил все четыре «копии» чтением. Заявленный отказ («правится одна строка, а три копии остаются лгать») не наступает — две из четырёх процитированы неверно. (1) reading.go:23-24 — НЕ копия обоснования. Дословно: `// Heading is the chapter's label as it comes from the DATA of |
platform/internal/pgstore/migrations/00016_read_surface.sql:61 |
DDL 00016 пересказывает прозой обоснования, которые уже живут в Go рядом с кодом, — тремя кластерами, дословно; 143 комментарных строки из 2 | Опровергнуто по трём независимым основаниям. 1) НАЗВАННЫЙ ОТКАЗ ФАКТИЧЕСКИ НЕВОЗМОЖЕН — находка неверно прочитала собственную цитату. Отказ сформулирован так: «снимается кламп least(…, ceiling_chapters) — DDL-комментарий продолжает описывать снятую арифметику». Прочитал окрест |
platform/internal/ingest/notes.go:35 |
19 комментарных строк на одну константу-строку, включая риторику «оба были взвешены» и трекерные коды без пояснения смысла. | Цитата воспроизведена точно (platform/internal/ingest/notes.go:35-37: «The alternatives are both worse and both were weighed: forwarding the engine's own word is the / leak the whole map exists to prevent…»), блок действительно 26–44 = 19 строк перед `const NoteCodeUnspecified = |
platform/internal/pgstore/readmodel.go:931 |
Одна и та же цитата-обоснование политики limit выписана в двух пакетах; копия в HTTP-слое сама признаёт, что решение живёт не здесь. |
Цитаты дословны (readmodel.go:930-932 и v0.go:642-649 — проверено пронумерованным выводом), но заявленный ОТКАЗ механически невозможен, и по трём независимым причинам. 1) clampPage физически не может «начать отвергать верх». Сигнатура — func clampPage(limit int) int (readmod |
platform/internal/books/books.go:103 |
Объявление переменной ошибки несёт 7 строк истории до-0.3.0 поведения с художественной концовкой. | Проверил чтением books.go:98-110, docs/architecture/14-api-contract/README.md:106-118 и httpapi/v0.go:557-587. Заявленный отказ не воспроизводится, а счёт строк завышен. 1) Отказ «чтобы понять, что значение делает СЕЙЧАС, нужно прочесть, чего оно не делало раньше» опровергается |
platform/internal/httpapi/stream.go:18 |
Шапка stream.go несёт историю фронта и трекерный код Ф-56 без расшифровки вместо правила. | Заявленный отказ («читатель платформы, встретив Ф-56, не может узнать, что это, не выходя из зоны») опровергается самой цитатой. platform/internal/httpapi/stream.go:18-19: «The stream is what made the frontend poll every three // seconds for the end of a parse that takes minute |
platform/internal/books/books.go:229 |
Спецслучай «пар не объявлено → пропускаем всё» делает дефолтный деплой таким, что /capabilities отвечает language_pairs: [], а интейк пр |
Опровергнуто: посылка «из коробки объявленная поверхность расходится с принуждаемой» ложна — в дефолтной конфигурации интейка НЕТ ВООБЩЕ, поэтому расходиться нечему. 1) Дефолты, на которые ссылается находка, не дают работающего интейка. platform/internal/config/config.go:441 — |
Критик полноты — отдельные находки
- [HIGH]
platform/deploy/README.md:80— Пак сделал дефолт стартового гранта нулём именно как денежный предохранитель — но оставил=5в том единственном файле, из которого оператор копирует боевое окружение, так что деплой по ноте включает ровно тот безлимитный самообслуживаемый грант, который пак только что выключил.- отказ: Оператор разворачивает по
platform/deploy/README.md(§«/etc/tmplatform/env— несекретное окружение»), копируя блок целиком.config.go:277(l.amount("TM_PLATFORM_SIGNUP_GRANT_USD", c.SignupGrantMicroUSD, false)) перекрывает новый дефолт 0 значением 5 → каждая новая OIDC-личность получает $5 автоматически. Это ровно то, чтоconfig.go:70-76объявляет опасным на время беты («a self-service grant is an unbounded one, rate-limited only by how - чинить: Убрать строку из блока окружения деплой-ноты (или заменить на
# TM_PLATFORM_SIGNUP_GRANT_USD=с фразой «бета: грант руками черезtmplatformctl grant»); заодно проверить, что ни один живой/etc/tmplatform/envеё не несёт.
- отказ: Оператор разворачивает по
- [MEDIUM]
platform/internal/config/config.go:280—TM_PLATFORM_LANGUAGE_PAIRSдефолтится пустым и в деплой-ноте не назван вовсе, а пустой список делает/capabilitiesи интейк противоположными: провод говорит «этот деплой не умеет ни одной пары», интейк принимает любую.- отказ: Деплой по
platform/deploy/README.md(блок окружения, строки 74–83, переменной там НЕТ) →cfg.LanguagePairsпуст →cmd/tmplatformd/runner.go:169не кладёт ни одной пары →capabilities.go:60отдаёт"language_pairs": []. Канон (openapi.yaml§Capabilities.language_pairs и §LangCode: «GET /capabilitiesnames the pairs this deployment can run, and one outside that set is refused at intake») предписывает клиенту предлагать только перечисле - чинить: Внести
TM_PLATFORM_LANGUAGE_PAIRS=zh>ruв блок окруженияdeploy/README.md; либо (лучше) отказывать в старте при пустом списке, раз/capabilitiesбез него врёт — «выключено» не состояние (D39.110).
- отказ: Деплой по
- [MEDIUM]
platform/internal/books/parse.go:164— Материализация читающей поверхности на границе ИНТЕЙКА идёт на контексте очередной задачи, уже съеденном разбором, — тогда как та же работа на границе прогона намеренно отвязана и получает свой бюджет; отказ здесь только логируется, и ни один свип его не повторяет.- отказ: Задача разбора живёт
jobs.JobTimeout = 15 минут(jobs.go:102). К строке 164 из этого бюджета уже потрачен полный ингест+нарезка (s.manifest(ctx, claim)), аRefreshзапускает ЕЩЁ два процесса движка на том жеctx:tmctl manifestиtmctl export --json --pairs, каждый — полная ре-нарезка источника. Большая книга (2283 главы, PD-248) упирается в дедлайн:exec.CommandContextубиваетexport,readmodel/readmodel.go:93только логирует, - чинить: Дать интейк-материализации собственный отвязанный бюджет, как на границе прогона, и завести бэкстоп-свип для книг, у которых
chapter_count > 0, а дерева нет или у пар пустойsource(та же дыра, что MEDIUM на reconcile.go:1089, но на первой материализации, где терять нечего было и не из чего восстановить).
- отказ: Задача разбора живёт
- [MEDIUM]
platform/internal/readmodel/readmodel.go:83—refreshStructureзаново вызываетtmctl manifest, хотя вызывающий (books.Parse) только что декодировал ТОТ ЖЕ документ целиком, — интейк платит три полные ре-нарезки книги вместо двух, и это не то число, которое пак сам записал в PD-248.- отказ: На загрузке 23 МБ книги движок режет источник трижды: (1)
books/parse.go:128s.manifest(ctx, claim)— возвращает полныйingest.Manifest, который с этого пака НЕСЁТ дерево (ingest/manifest.go:40Chapters []ManifestChapter) иKey;Parseберёт из него три поля и выбрасывает остальное; (2) эта строка —tmctl manifestповторно, ради того же документа (manifestCmdвbackend/cmd/tmctl/main.go:376перестраивает и переписывает сайдкар - чинить: Пробросить уже прочитанный
ingest.ManifestвRefresh(параметром или вторым методомRefreshWith(manifest, ...)), оставив самостоятельное чтение манифеста только для границы прогона, где вызывающий его не держит.
- отказ: На загрузке 23 МБ книги движок режет источник трижды: (1)
- [DOUBT]
platform/internal/pgstore/sink.go:274— Кадрыchapterиnoteстроятся из ПРИШЕДШЕГО события даже тогда, когда предикатexcluded.at >= unit_resolutions.atотверг запись как устаревшую, — поток и чтение расходятся под одним и тем же id замечания, и починить это нечем.- отказ: Вставка на строках 239–246 идемпотентна по (book, chapter, unit, wave) и отбрасывает более старую доставку (
where excluded.at >= unit_resolutions.at). Следующий стейтмент — пересчёт счётчиков главы — затрагивает строку главы ВСЕГДА (условие толькоc.book_id = $1 and c.number = $2), поэтомуtag.RowsAffected() != 0и путь доходит до этой строки.emitChapterдальше берётu.Reason,u.Flaggedиev.Timeиз отвергнутого события: кадр `note - чинить: Строить кадр из того, что ЛЕЖИТ в
unit_resolutionsпосле апсерта (returning shipped, flagged, reason, at), а не из аргументов события; при отвергнутой вставке кадр не слать вовсе.
- отказ: Вставка на строках 239–246 идемпотентна по (book, chapter, unit, wave) и отбрасывает более старую доставку (
Покрытие по словам критика: ЧТО ОТКРЫЛ САМ И ПРИЗНАЮ ТИХИМ (файлы без единой находки у 8 предыдущих осей): httpapi/capabilities.go · httpapi/project.go · httpapi/reading.go · httpapi/problem.go · pgstore/events.go · runner/artifacts.go · ingest/{export,bank,notes,vocabulary,manifest}.go · readmodel/readmodel.go · books/{books,parse,render}.go · login/{login,dev}.go · auth/csrf.go · config/config.go · cmd/tmplatformd/{main,runner}.go · cmd/tmplatformctl/{main,runs,seed}.go · httpapi/{server,middleware}.go · migrations 00016/00017. Три находки из пяти вышли отсюда (parse.go, readmodel.go, config.go), две — из доков пака, которые не смотрел никто (deploy/README.md, хотя он В ДИФФЕ).
ЗАКРЫЛ ФАКТОМ, НАХОДОК НЕТ (называю, чтобы не переискивали): (1) Ось «имена полей и required-списки», которую contract-semantics явно не брала («не трогал по заданию») и go-style назвала чужой, — прошёл целиком: сверил каждую wire-структуру (v0.go wireBook/wireRun/wireProgress/wireBookDetail/wireCeilingBounds/wireRunOptions/wireUsage
Что каждая ось НЕ покрыла (её собственными словами)
- contract-semantics — Не проверено и почему. (1) НИ ОДИН DB-тест не исполнялся:
go test ./internal/pgstore/ -vдаёт 98 SKIP безTM_PLATFORM_TEST_DSN, поэтому все утверждения о SQL — из чтения запросов и миграций, не из живого прогона; находки 1–2 я подтверждал цепочкой «кто пишетunit_resolutions→ кто их читает → кто их чистит (никто,grep 'delete from unit_resolutions'по platform/ пуст)», а не наблюдением. (2)POST/GET /books/{id}/exports,updateBook,deleteBook,getRun— в каноне объявлены, вcontractRoutes(v0.go:59-102) не смонтированы и отве - sql-migrations — Что проверено ИСПОЛНЕНИЕМ на живом PG 18.4 (своя БД на стенде :55433, все три тестовые базы за собой удалены): полный цикл через НАСТОЯЩИЙ goose (не psql-сплит) — up→15, посев строк СТАРОЙ формы (books/runs/chapters/units/notes/bank_terms всех шести комбинаций status×origin, bank_decisions promote+decline, unit_resolutions, exports), up→17, посев строк НОВОЙ формы (book_events, idempotency_keys), DownTo(15), up→17 повторно, DownTo(0) — все зелёные; порядок drop→translate→add верен в ОБЕ стороны, перевод 0→NULL инъективен (обратный NULL→0 не мож
- readmodel-sql — Прочитал readmodel.go целиком (1092 стр.) плюс всё, от чего он зависит: миграции 00002/00004/00015/00016, books.go (bookColumns/lastRun/GetBook/listBooksTx/DefaultPage), credits.go (inTx/lockBook/IsTransient), sink.go (фолд юнитов, emitChapter/emitProgress), events.go, readmodel/readmodel.go, runner/artifacts.go, ingest/manifest.go, httpapi/reading.go, backend/internal/pipeline/manifest.go (происхождение id глав и юнитов) и §Chapter/§Book/§limit контракта 0.3.0. Проверял ИСПОЛНЕНИЕМ на живом Postgres 16 (docker, снесён после): полная батарея ./
- sse-stream — —
- http-conditional-idem — —
- runs-lifecycle — Прошёл целиком: runs/reconcile.go, runs/runs.go, runs/spawn.go, pgstore/runs.go, pgstore/sink.go, плюс смежное по необходимости — runner/{marker,engine,artifacts}.go, ingest/exit.go, readmodel/readmodel.go, cmd/tmplatformd/runner.go, миграция 00016, и движковый backend/internal/pipeline/mining.go для сверки границы стопа банка. Проверил всю таблицу комбинаций outcome() (exited×код×намерение стопа×PausedReason×m.Result×m.At), три значения failure_reason и путь неизвестной причины (m.Result вне списка → interrupted; неизвестный код полосы отказов
- go-style — Covered by execution:
go vet ./...clean,gofmt -l .empty,golangci-lint 2.12.2 run ./...→ 0 issues (config .golangci.yml enables errcheck/govet/staticcheck/errorlint/nilerr/rowserrcheck/sqlclosecheck/copyloopvar etc.), so every finding above is outside what the pinned linter sees. Read in full: pgstore/{readmodel,events,idempotency}.go, migrations 00016/00017, httpapi/{reading,stream,conditional,idempotency,problem,project,capabilities,v0,middleware,server}.go, readmodel/readmodel.go, ingest/{export,bank,notes,vocabulary,manifest}.go, - comment-verbosity — ЧТО ПРОВЕРЕНО ИСПОЛНЕНИЕМ: прочитаны как файлы все 22 новых файла (readmodel.go 1092 стр. и обе миграции — целиком, построчно), просмотрены добавленные комментарные строки во всех 46 изменённых (
git diff -U0 | grep '^+.*//').go build ./... && go vet ./...— чисто. Отчёт автора (platform-PROGRESS.md, «Сессия P7») открыт ПОСЛЕДНИМ; его строка Р-21 признаёт рост массы комментариев и говорит «подрезано самое тяжёлое, остальное — предмет пинга» — мои находки называют то, что осталось, поимённо. ЗАСЛУЖЕННО ДЛИННЫЕ — НЕ СРЕЗАТЬ ПРИ ПРИЁМКЕ (пров - workarounds — Прочитано целиком как файлы (не по диффу): pgstore/{readmodel.go 1092 стр, events.go, idempotency.go, sink.go}, миграции 00016/00017, httpapi/{reading,stream,conditional,idempotency,problem,project,capabilities,v0}.go, ingest/{export,bank,notes,vocabulary,manifest}.go, runner/{artifacts.go, engine.go diff}, readmodel/readmodel.go, runs/{runs.go, reconcile.go diff + Resume целиком}, books/{books,parse,render} diff, cmd/tmplatformd diff, config.go diff. Сверено с каноном openapi.yaml 0.3.0 (§Revision, §BankPage, §BankDecision, §Capabilities, §Pag
- regressions — Ось «регрессии на границе старого и нового» пройдена по всем изменённым файлам (
git diff -- platform/), плюс те новые файлы, куда старый код теперь ходит (pgstore/readmodel.go— bookColumns/lastRun/runProgress/derivedStatus/clampPage,pgstore/events.go— emitFrame/emitStatus,readmodel/,runner/artifacts.go,ingest/{vocabulary,bank,export,manifest}.go). Проверено и признано ЧИСТЫМ: удалениеgenre(все читатели обновлены: ctl, seed, render, ParseClaim, NewUpload); удалениеnotes/note_count/heading(писателей на HEAD не было — - test-quality — Ось «качество пинов» пройдена так: (1) полный
git diff -- platform/**/*_test.goпрочитан целиком — из ослаблений существующих тестов реально спорным остаётся только замена значения на счётчик кадров вruns/sweep_test.go:389,401,588(book.Progress.DraftDone != 7 || EditDone != 1→framesOfKind(...) != 0/1); я не подал это находкой, потому что числа событияprogressпо-прежнему пинятся вpgstore/sink_test.go:457,471, а сам вызовemitProgressловится счётчиком — потеря есть, отказа доказать не смог. Ревизии `TestABadPageRequestIsRefu - prompt-audit — По своей оси прошёл §4 пп.0–6 и §5 нормы поштучно, читая КОД и канон; отчёт автора открыл последним. Проверено исполнением:
go build ./...иgo vet ./...(оба чисто),go test ./internal/...(все 16 пакетов ok), пересчёт^func Test= 458 (совпало с заявленным),git diff --stat= 46 файлов +2353/−646 (в отчёте +2329 — расхождение на правку самого отчёта после замера, находкой не считаю). Прочитаны целиком все 22 новых файла, обе новые миграции и весь дифф; wire-структуры Book/Run/Progress/Chapter/Unit/Note/BankTerm/BankPage/BankDecision
Сходимость независимых осей
| осей | место | оси |
|---|---|---|
| 4 | platform/internal/pgstore/readmodel.go#4 |
contract-semantics, readmodel-sql, workarounds, regressions |
| 4 | platform/internal/pgstore/runs.go#2 |
sql-migrations, runs-lifecycle, workarounds, regressions |
| 4 | platform/internal/pgstore/readmodel.go#3 |
readmodel-sql, go-style, workarounds, prompt-audit |
| 3 | platform/internal/httpapi/v0.go#12 |
contract-semantics, http-conditional-idem, prompt-audit |
| 3 | platform/internal/httpapi/stream.go#0 |
sse-stream, comment-verbosity, workarounds |
| 3 | platform/internal/httpapi/v0.go#11 |
http-conditional-idem, comment-verbosity, workarounds |
| 3 | platform/internal/config/config.go#4 |
go-style, workarounds, regressions |
| 2 | platform/internal/pgstore/readmodel.go#17 |
contract-semantics, readmodel-sql |
| 2 | platform/internal/pgstore/sink.go#6 |
contract-semantics, readmodel-sql |
| 2 | platform/internal/pgstore/readmodel.go#21 |
readmodel-sql, go-style |
| 2 | platform/internal/readmodel/readmodel.go#2 |
go-style, regressions |
| 2 | platform/internal/runner/engine.go#4 |
go-style, workarounds |