Repair the register tables, file four open rows where open rows are read, and give the engine channel inventory a durable home

This commit is contained in:
Claude (backend session) 2026-08-22 18:05:18 +03:00
parent 6b0e064463
commit 619d0473d4
2 changed files with 38 additions and 7 deletions

View file

@ -30,9 +30,7 @@
| PD-162 | bug | minor | `internal/runs/spawn.go` `journalSize`, `internal/runs/reconcile.go` | **Книга, чей каталог удалён или перемещён, принимает прогон и заклинивает его навсегда с открытым холдом.** `journalSize` мапит ENOENT в «ноль, ошибки нет» (законно для первого прогона), поэтому `Start` отдаёт 202 и берёт холд; дальше `bookMeter` вечно падает (спавн отказывает), либо расчёт вечно откладывается — ни один путь не приходит к терминальному состоянию: прогон вечно `translating`, деньги вечно в холде, пользователю видно только «идёт». Дизайн «холд лучше догадки» осознан (строка 136), но отсутствие И валидации каталога на старте, И эскалации после N неудач — дыра. Закрывать вместе с эскроу/`uncertain` либо проверкой каталога при допуске. ⚠ Ре-чек V2 расширил КЛАСС строки: обязательность `reserved_usd` даёт тот же клин без всякого удаления каталога — прогон, запиненный к СТАРОЙ сборке движка (строка 139), у которой поля ещё нет, вечно отказывает спавну с открытым холдом. Лечится тем же терминальным состоянием после N неудач; отказ сам по себе верен (без цифры потолок считать нечем) ⚠ Сужено паком P5 с одной стороны и НЕ закрыто с другой: у ИНТЕЙКА терминальное состояние после N неудач теперь есть (`books.parseAttempts` = 5 → `rejected` с причиной `parser_unavailable`), и прогон на книге, не прошедшей интейк, отвергается до денег (`runs.ErrBookNotReady`). Клин ЖИВОГО прогона на удалённом каталоге не тронут: там нужен тот же счётчик неудач на стороне реконсилятора либо эскроу-половина строки 136 ⚠ Дополнено кросс-семейным ревью: терминальность интейка теперь НЕ применяется к `not_configured` — книга без конфигурации ждёт в `parsing` вместо уничтожения, потому что это состояние ДЕПЛОЯ, а не книги. Цена названа: пока развилка `book.yaml` не ратифицирована, такие книги копятся, и видит их метрика `tm_platform_books_in_intake{status="parsing"}`**ДИСПОЗИЦИЯ P7: не бралась.** Клин живого прогона на удалённом каталоге лечится терминальным состоянием после N неудач у реконсилятора, а это половина эскроу (строка 136) — строить её рядом с проектируемым целым значит ставить второй, более слабый ответ на тот же вопрос. P7 добавил к строке одно наблюдение: материализатор читающей поверхности зовёт движок в том же каталоге и на ту же ошибку отвечает логом, не трогая деньги | open | самопроверка дофикса (ревью вне карты) |
| PD-168 | bug | minor, деньги | `internal/runs/reconcile.go` `restart` | **Бюджет перезапуска пересчитывается по ТЕКУЩЕЙ ставке, а не по той, под которую брался холд:** `s.Pricing.Ceiling(l.CeilingChapters)` читает конфигурацию нынешнего деплоя. Смена `TM_PLATFORM_USD_PER_CHAPTER` между допуском и перезапуском ломает обе стороны — вверх: резервируется больше, чем пользователь видел на шкале (нарушение «явного согласия на оплату»); вниз: остаток уходит в минус и прогон ошибочно встаёт `paused/credit_exhausted`. Замерено верификатором: при удвоении ставки перезапуск зарезервировал $5.50 вместо $2.50. Исходная сумма восстановима без пересчёта — она лежит в холде первой попытки (`reservations.ceiling_micro_usd` / `run_attempts.ceiling_micro_usd`) | open | самопроверка дофикса (два верификатора, один исполнением) |
| PD-175 | hardening | minor | `internal/books/`, `internal/httpapi/v0.go` `createBook` | **Квоты на интейк нет: аутентифицированный аккаунт может писать на диск оператора неограниченно.** Пер-маршрутный потолок (PD-72) ограничивает ОДИН аплоад (64 МиБ по умолчанию), число аплоадов — ничто: ни лимита книг на аккаунт, ни ретеншена. Отклонённая по вине ИСТОЧНИКА книга свой файл теряет (каталог удаляется), а отклонённая по вине ДЕПЛОЯ — сохраняет намеренно (удалять чужую загрузку из-за своей поломки нельзя), и такие каталоги не чистит никто. Лечится квотой на аккаунт плюс свипом ретеншена по `rejected`; и то и другое — продуктовая политика (сколько книг входит в фри-тир), поэтому заведено, а не выбрано зоной ⚠ Третья половина того же вопроса — УДАЛЕНИЕ книги: `pgstore.DeleteBook` убирает строку и закрытые резервации и НЕ трогает каталог книги на диске, а ручки удаления в контракте нет вовсе (гейт PD-122). То есть сегодня утечки нет, потому что удалять нечем; день, когда ручка появится, — это и день, когда каталог обязан уходить вместе со строкой, и гард «только под `BooksDir`» для этого уже есть (`books.owns`). Диспозиция: закрывать ВМЕСТЕ с ручкой удаления, не раньше и не позже ⚠ Дополнено адверсариальным ревью P5 двумя фактами, которые делают строку острее, чем она написана: (1) пока развилка `book.yaml` не ратифицирована, ЛЮБАЯ загрузка приходит к `rejected/not_configured` — то есть путь «интейк пишет на диск и никто не убирает» сегодня ординарный, а не краевой; (2) у `rejected` нет ВЫХОДА вовсе: ни перепарса, ни удаления в контракте нет, строка остаётся в библиотеке навсегда. Обе половины закрываются тем же — квотой на аккаунт плюс свипом ретеншена, и обе продуктовые ⚠ Дополнено кросс-семейным ревью (Fable, 11.08): крэш-окно «строка закоммичена — каталог ещё не снесён» оставляет каталог-сироту, которого не найдёт никто (`StuckIntake` берёт только `uploading` и `parsing`). У отказа порядок перевёрнут в самоизлечивающийся — сначала каталог, потом строка, — а у брошенной загрузки перевернуть нельзя (каталог можно сносить только убедившись, что строки нет), так что окно там остаётся и закрывается тем же свипом ретеншена, что и вся строка | open | сессия P5 (самопроверка, ось «что этот маршрут создаёт») |
| PD-367 | bug | minor | `internal/books/parse.go:129`, `internal/ingest/manifest.go` `Whole` | **Пол на самосогласованность манифеста стоит только у материализатора, а интейк тот же документ ПРИНИМАЕТ.** Манифест `{ChaptersTotal: 120, UnitsTotal: 400}` с пустым списком глав `Whole()` отвергает, а `books.Parse` заводит книгу `not_started` с `chapter_count=120` и пустым деревом — по такой книге можно СТАРТОВАТЬ и ОПЛАТИТЬ прогон (потолок считается от `chapter_count`). Воспроизведено ревью на живом Postgres. ⚠ **Лекарство не построено и это решение, а не недоделка:** контракт интейка с манифестом — только счётчики (`FinishParse` берёт три поля, дерево объявлено ОТДЕЛЬНОЙ границей со своим долгом), и вся батарея интейка ездит на документах без списка глав (`books_test.go:145`), то есть `Whole()` в `parse.go` разворачивает запиненный контракт. Нужно решение владельца/оркестратора: расширять контракт интейка или оставить асимметрию осознанной | open | воркфлоу-ревью волны 2 (P8-FIX); рефутер подтвердил механику и опроверг предложенное лекарство |
| PD-368 | hardening | info | `internal/config/config.go`, `internal/runs/reconcile.go` `phaseBudget` | **Пара `TM_PLATFORM_SWEEP_BUDGET`/`TM_PLATFORM_RUN_BUDGET` не проверяется на когерентность на буте, и поднять ОДИН из них — тихий no-op:** фазе достаётся половина прохода, поэтому любое значение `RunBudget` от половины прохода и выше наблюдаемо неотличимо от дефолта. Ревью заходило сюда как в major («поднять бюджет = объявить здоровый прогон застрявшим») и **это опровергнуто исполнением**: поднятие ручки не меняет вообще ничего, вердикт при дефолтах существует и без оператора, а строгая проверка `RunBudget < SweepBudget/2` отвергла бы сами шиппящиеся дефолты (60 с против 60 с). Остаётся эргономика: поднимать надо `SweepBudget`, и об этом не сказано нигде, кроме доккоммента | open | воркфлоу-ревью волны 2 (P8-FIX), находка опровергнута, остаток зафиксирован |
| PD-369 | bug | minor | `internal/pgstore/idempotency.go` `ClaimIdempotency`, `claimRounds` | **Легитимный запрос получает 500 под конкуренцией на одном ключе идемпотентности.** Ретрай проигранной гонки ограничен `claimRounds = 3`, а восемь одновременных попыток одного ключа, каждая из которых сразу отдаёт его назад (как делает любой 4xx), могут отобрать гонку у одного и того же проигравшего трижды подряд — и он получает не один из четырёх контрактных ответов, а внутреннюю ошибку. **Замерено: 12 падения на ~80 прогонов собственного теста `TestAClaimThatLostARaceToAReleaseIsRetriedAndNotAnError`** (то есть тест ФЛЕЙКОВЫЙ, и это сам сигнал). Комментарий над константой признаёт границу («a caller that loses three rounds is meeting something other than a race») — но восемь racer'ов и есть гонка, а не «что-то другое». ⚠ **ВНЕ СКОУПА пака P8-FIX и НЕ ТРОГАЛОСЬ: файл не в диффе пака** (`git diff` пуст, последний носитель — `9b23e8c`); найдено попутно при прогоне батареи. Направление, не решение: либо граница по ВРЕМЕНИ вместо числа раундов, либо ответ `ErrKeyInFlight` вместо 500 при исчерпании | open | попутная находка сессии P8-FIX (флейк батареи), передано оркестратору |
| PD-371 | bug | minor | `internal/pgstore/runs.go` `RunsToReconcile`, `internal/runs/reconcile.go` `deferItem` | **Исключение «стоп перевешивает отсрочку» обходит отсрочку БЕЗ ГРАНИЦЫ, и для прогонов с запрошенным стопом голодание PD-169 внутри фазы возвращается.** Прогон, чей `stop_requested_at` не пуст, выбирается КАЖДЫМ проходом независимо от `reconcile_after`, сколько бы раз подряд он ни падал. **Измерено живьём при приёмке** (дев-демон на дереве пака, хост без пользовательской шины systemd, поэтому `Runner.Alive` падает по-настоящему): `reconcile_after` стоял на ~30 минут вперёд (`NEXT TRY 14:21:21Z`), а `FAILS` дорос до **6 за ~90 секунд**, то есть на каждом 15-секундном такте — отсрочка не действовала ни разу. На ДЕШЁВОЙ ошибке это безвредно и было именно так в пробе. На дорогой (шина или чтение журнала книги висит до конца бюджета) прогон снова держит голову списка весь бюджет фазы реконсиляции, а класс запускает ЛЮБОЙ пользователь кнопкой «остановить». Что смягчает и почему это не блокер: расчёт денег живёт во ВТОРОЙ фазе и не страдает (это и есть половина лечения PD-169), счётчик всё равно растёт, гейдж `tm_platform_runs_stalled` и `run abandon` работают — проверено той же пробой. Комментарий у `RunsToReconcile` называет цену НЕ-исключения («стоп ждал бы истечения бэкоффа») и не называет цену исключения. Направление, не решение: исключать до пересечения `StalledAfter`, а дальше подчинять стоп общему бэкоффу — переиздание стопа идемпотентно, и на пятой неудаче подряд «переиздать немедленно» уже ничего не покупает | open | приёмка P8-FIX (живая проба оркестратора №18, вне карты пака) |
| PD-372 | bug | minor | `internal/pgstore/runs.go` `DeferRun`, `internal/pgstore/books.go` `truncateReason`, `internal/pgstore/isolation_test.go` | **Починку текста ошибки пинит только САМА функция, но ни один из четырёх её вызовов.** `TestAnEnginesOwnErrorTextSurvivesBeingRecorded` зовёт `truncateReason` напрямую и доказывает, что Postgres принимает её результат, — а того, что вызывающий её ЗОВЁТ, не проверяет ничто. **Посажена мутация оркестратором вне списка автора:** `truncateReason(reason)``reason` в `DeferRun` — батарея (`./internal/pgstore/` + `./internal/runs/`) осталась ЗЕЛЁНОЙ. Цена ровно та, которую комментарий этой же функции называет вслух: невалидный UTF-8 из stderr движка Postgres отвергает, запись отказа не проходит, счётчик не растёт и попытка держит голову списка вечно — то есть механизм PD-169 отключается тем самым текстом, ради которого заведён. Класс — PD-1 («свойство без пинящего теста не закрыто»), и он тут в форме «пин есть, но не на пути». Лечение дешёвое: провести один случай через `DeferRun`/`DeferReadModelDebt` и прочитать колонку назад | open | приёмка P8-FIX (посадка мутации оркестратором №18) |
@ -42,6 +40,11 @@
| ID | Класс | Серьёзность | Где | Суть | Статус | Источник |
|---|---|---|---|---|---|---|
| PD-281 | bug | info | `internal/pgstore/readmodel.go` `runProgress` | **Полоса прогона над книгой, уже полной в считаемом проходе, стоит на `0/N` и не достигает единицы** — против канона §Progress («the fraction always reaches one»). Штатный цикл: книга доведена до конца → пользователь подписал решения → запустил прогон, чтобы движок их применил → прогресс платной работы невидим до `ready`. Остаток подхода «числитель считает ГЛАВЫ, законченные проходом», а не регрессия: до правки PD-263 бар был неверен в другую сторону. Лечение требует продуктового решения (считать главы, ПЕРЕразрешённые после `started_at`), а не тихой правки | open | приёмка правок P7 (fable-5) |
| PD-297 | bug | info | `internal/pgstore/readmodel.go` `writeChapters`/`writeUnits` | **Материализация дерева делает один round-trip на СТРОКУ под эксклюзивной блокировкой книги** — на корпусной книге (2283 главы, ~7 тыс. пар) это ≈11 тыс. последовательных обращений, и всё это время за блокировкой стоят `emitFrame` потока, `StartRun` и фолд юнитов. Штатный инструмент — `tx.SendBatch` (pgx v5, уже драйвер модуля) или `CopyFrom` во временную таблицу. НЕ сделано осознанно: рефутеры первой приёмки понизили до DOUBT/LOW, цена не замерена на форме этого деплоя (unix-сокет против управляемого PG по TCP — разница на два порядка), а путь — самый опасный на запись. Мерить прежде правки: время удержания блокировки на 2283-главной книге до и после ⚠ **P8-FIX: НЕ ВЗЯТ, причина названа и она не «не успели».** Сама эта строка объявляет замер на здешнем стенде НЕпредставительным (unix-сокет против управляемого PG по TCP — разница на два порядка), а корпусной книги нет: она появляется на холодном прогоне движка, которым гейчена строка 202 единого бэклога (решение владельца 20.08). Мерить нечем и не на чем, а правка самого опасного на запись пути без замера — ровно то, что эта строка запрещает. Берётся вместе с холодным прогоном | open | доработка 20.08 (сверка находок против дерева) |
| PD-298 | bug | info | `internal/pgstore/readmodel.go` `ListNotes`, `internal/pgstore/sink.go` `unitDone` | **Снятие флага с замечания дельта-чтение выразить не может.** Резолюция, пере-разрешённая как не-`flagged` (редрайв), обновляет строку и двигает `revision`, но дельта фильтруется предикатом `ur.flagged` — строка не возвращается, и клиент никогда не узнаёт, что замечание снято: оно остаётся на экране навсегда. Канон §AfterVersion: «A DELETION cannot be expressed this way», и требует одного из двух ответов — `resync_required` либо `400 version_too_old`; здесь не даётся ни один. ⚠ НЕ подтверждено, что движок вообще пере-издаёт `unit_done` для той же тройки (глава, юнит, волна) с `flagged=false` — комментарий `sink.go` это УТВЕРЖДАЕТ («a redrive re-attacks a flagged one»), но чтением движка не сверено. Порядок: сначала сверка у движка, потом либо счётчик замены для замечаний, либо строка «переход недостижим» ⚠ **ДИСПОЗИЦИЯ АКТА 5 (сверка с движком контрактной сессией 20.08): переход НЕДОСТИЖИМ и механизма не строим.** Движок объявляет вердикт юнита один раз на волну, его announce-once-леджер не пере-announce-ит, поэтому единственная пере-доставка — ТОТ ЖЕ вердикт. Инвариант записан в коде (`sink.go` `unitDone`): если что-то научится снимать флаг, фолд обязан выдать кадр — иначе счётчик разъедется со списком молча. Строка держится открытой этим долгом, а не живым дефектом | open | приёмка P7 → доработка 20.08 (сверка) |
| PD-299 | standards | info | `internal/httpapi/conditional.go` `acceptsGzip` | **`Accept-Encoding: identity;q=0` не отвечает `406`.** Клиент, потребовавший ЛЮБОГО кодирования кроме identity, получает identity. Половина RFC 9110 §12.5.3, которую правка PD-268 не закрыла: gzip-сторона (именованное кодирование выигрывает у `*`, нулевой вес — отказ) закрыта и пиньётся, эта — нет. Достижимо только специально сконструированным клиентом; ни один генерённый по контракту клиент так не делает | open | доработка 20.08 (сверка находок против дерева) |
| PD-368 | hardening | info | `internal/config/config.go`, `internal/runs/reconcile.go` `phaseBudget` | **Пара `TM_PLATFORM_SWEEP_BUDGET`/`TM_PLATFORM_RUN_BUDGET` не проверяется на когерентность на буте, и поднять ОДИН из них — тихий no-op:** фазе достаётся половина прохода, поэтому любое значение `RunBudget` от половины прохода и выше наблюдаемо неотличимо от дефолта. Ревью заходило сюда как в major («поднять бюджет = объявить здоровый прогон застрявшим») и **это опровергнуто исполнением**: поднятие ручки не меняет вообще ничего, вердикт при дефолтах существует и без оператора, а строгая проверка `RunBudget < SweepBudget/2` отвергла бы сами шиппящиеся дефолты (60 с против 60 с). Остаётся эргономика: поднимать надо `SweepBudget`, и об этом не сказано нигде, кроме доккоммента | open | воркфлоу-ревью волны 2 (P8-FIX), находка опровергнута, остаток зафиксирован |
| PD-373 | doc | info | `internal/readmodel/readmodel.go:64`=`const maxAttempts = 5`, `deploy/README.md:248`=`После пяти неудач` | **У числа попыток материализации ДВА носителя и ничего между ними.** Код держит `const maxAttempts = 5`, рантбук оператора пишет «После пяти неудач долг списывается». **Посажена мутация оркестратором вне списка автора:** `maxAttempts` 5 → 500000, батарея зелёная — пин `readmodel_test.go` ездит `for attempts := range maxAttempts`, то есть доказывает МЕХАНИЗМ при любом значении константы, что само по себе правильно. Незакрытым остаётся другое: подняли константу — рантбук молча начал лгать оператору о том, когда платформа сдаётся. ⚠ Тот же ход у `runs.StalledAfter` проверен и НАРУШЕНИЯ НЕ ДАЛ: там носитель ровно один (рантбук пишет «сколько неудач подряд» без числа), мутация тоже выжила и это законно. Лечение — либо гейт на второй носитель ровно той формы, что пак построил для `ContractVersion` (`internal/gates/contract_test.go` читает канон, а не копию числа), либо число уходит из прозы | open | приёмка P8-FIX (посадка мутации оркестратором №18) |
| PD-374 | doc | info | `docs/STACK_DECISIONS.md` «Гейты батареи», `internal/runner/systemd_test.go` `systemdOrSkip`, `Makefile` цель `check` | **Рецепт объявляет у батареи ДВА гейта и ждёт «скипов 0» — а условий три, и третье не названо.** Кроме `TM_PLATFORM_TEST_DSN` и пары `TM_PLATFORM_TEST_ENGINE_BIN`/`_BOOK_TEMPLATE` есть `systemdOrSkip`: без ДОСТИЖИМОГО пользовательского менеджера systemd три теста `internal/runner` скипаются. Поймано пере-прогоном батареи при приёмке: на этом хосте `/run/user/1000` не существует (сессия logind не поднята), поэтому «скипов 0» недостижимо в принципе — `make check` дал 18 пакетов, exit 0, линтер 0 issues и **3 скипа**. Мимо: сама цель `check` печатает над списком скипов «set TM_PLATFORM_TEST_DSN», отправляя читателя к ручке, которая тут ни при чём. Отчёт пака честен и это подтверждает — он мерил отдельно и получил 0, что верно на хосте с живым менеджером. Лечение: назвать третий гейт в рецепте вместе с двумя и не обещать «скипов 0» без него; заодно сделать сообщение цели `check` не называющим одну переменную из трёх | open | приёмка P8-FIX (пере-прогон батареи оркестратором №18) |
| PD-6 | hardening | info | `internal/auth/csrf.go:51` | GET освобождён от CSRF (верно), но SSE-хендшейк — GET с амбиентной кукой: origin-чек хендшейка потока (STACK §5) не покрыт ничем. Закрыть при постройке SSE (P1) | open | приёмка P0 (security-линза) |
@ -345,9 +348,11 @@
## Закрытые — эра P7 (читающая поверхность контракта 0.3.0)
| PD-185 | bug | info | `internal/pgstore/sink.go`, `internal/runs/runs.go` `readyToTranslate` | **Статус `finalizing` есть в контракте, в DDL и в обоих аллоулистах — писателя нет ни одного.** Тот же класс, что интейк-статусы до этого пака: слово контракта без пути, который его пишет. Сегодня недостижим, поэтому вреда нет; лечится либо писателем (финальная волна движка), либо снятием из аллоулистов, когда станет ясно, что его не будет — **закрыто:** 0.3.0 снял значение из контракта, P7 снял его из обоих Go-аллоулистов и из обоих CHECK-констрейнтов (миграция 00016), грепом `finalizing` в `internal/` и `cmd/` пусто | fixed(P7, дерево сессии) | кросс-семейное ревью P5 (Fable) |
> ⚠ Из этой эры ОТКРЫТЫМИ остались `PD-281` · `PD-297` · `PD-298` · `PD-299` — их тела живут в секции «Открытые — info», куда перенесены оркестратором №18 22.08. Строка со статусом `open` под заголовком «Закрытые» невидима тому, кто читает открытые, а именно так этот файл и читают.
| ID | Класс | Серьёзность | Где | Суть | Статус | Источник |
|---|---|---|---|---|---|---|
| PD-185 | bug | info | `internal/pgstore/sink.go`, `internal/runs/runs.go` `readyToTranslate` | **Статус `finalizing` есть в контракте, в DDL и в обоих аллоулистах — писателя нет ни одного.** Тот же класс, что интейк-статусы до этого пака: слово контракта без пути, который его пишет. Сегодня недостижим, поэтому вреда нет; лечится либо писателем (финальная волна движка), либо снятием из аллоулистов, когда станет ясно, что его не будет — **закрыто:** 0.3.0 снял значение из контракта, P7 снял его из обоих Go-аллоулистов и из обоих CHECK-констрейнтов (миграция 00016), грепом `finalizing` в `internal/` и `cmd/` пусто | fixed(P7, дерево сессии) | кросс-семейное ревью P5 (Fable) |
| PD-104 | bug | **minor, расхождение док↔код** | `internal/login/login.go:285-288`, `internal/config/config.go:73` | **Фри-тир начисляется АВТОМАТИЧЕСКИ, а реестр обещает обратное.** Код: дефолт `SignupGrantMicroUSD: 5 * 1_000_000` (`config.go:73`) проведён в демона (`main.go:99`) и логин отдаёт его в стор на каждой новой подтверждённой паре `(provider, subject)` — аккаунт создаётся С $5. Строка PD-30 при закрытии утверждает «аккаунт создаётся с нулём, начисление руками из админки» — один из двух текстов лжёт, и это чинится независимо от продуктового решения. Ограничитель у автогранта один — лимитер входа; агрегатного потолка, счётчика и алерта нет (грепнуто). **Разбор нормы и предложение «на бете дефолт в НОЛЬ» — D39.110 п.3, здесь не дублируется; ждёт слова владельца****ПОЛОВИНА ЗАКРЫТА (док↔код):** ячейка PD-30 исправлена — «аккаунт с нулём» относилось только к НЕподтверждённой личности, подтверждённая получает автогрант (дефолт $5). ⚠ Продуктовая часть (ноль на бете, агрегатный потолок, счётчик) — НЕ закрыта: ждёт слова владельца, носитель прежний ⚠ **ЗАКРЫТО P7 словом владельца 16.08 (D39.138 п.2л):** дефолт `SignupGrantMicroUSD` = **0** (`config.go`), начисление на бете — руками через `tmplatformctl grant`; возврат $5 идёт вместе с суточным агрегатным потолком, когда появятся платежи. Пин — `config.TestSignupGrantIsParsedNotGuessed` (пинит ИМЕННО ноль, чтобы восстановление дефолта мимо ратификации падало); протухшие «$5» вычищены из `PLATFORM_DIRECTION.md` §2 и `BACKLOG.md` П-7 | fixed(P7, дерево сессии) | приёмка P2 (панель; расхождение — оркестратор №15) |
| PD-172 | standards | minor | `docs/architecture/14-api-contract/openapi.yaml` `createBook` | **Проводное правило `POST /books` в контракте не записано: часть `file` ОБЯЗАНА идти последней.** Потоковый читатель (`r.MultipartReader`) отдаёт части в порядке провода, а строка книги — то, что делает загрузку видимой во время приёма и находимой, если она оборвалась, — не может быть записана до языков, которые в ней обязательны. Тем же правилом специфицирована браузерная загрузка S3 («the file or content must be the last field in the form»). Платформа отвечает 400 на форму, приславшую поля после файла, и это поведение спека не описывает. Вопрос владельцу контракта (прецедент 503/PD-112), не правка спеки зоной ⚠ **ДИСПОЗИЦИЯ D39.130:** вопрос владельцу контракта принят и превращён в спек-правку **0.2.3**, которую делает задача S4 фронт-сессии; зона правку спеки не делает и ждёт её ⚠ **ЗАКРЫТО:** правило записано в каноне 0.3.0 (§createBook: «The `file` part MUST come LAST… A part sent after the file is refused, never ignored: 400, `invalid_request`, с `errors[]`»), и P7 привёл к нему код — часть после файла ПРОВАЛИВАЕТ чтение, поэтому загрузка откатывается вместе с отказом (иначе пользователь получал 400 И книгу). Пин `httpapi.TestAPartAfterTheFileIsRefusedAndTheBookIsNotKept`, живая проба на стенде | fixed(P7, дерево сессии) | сессия P5 (сверка контракта с построенным маршрутом) |
| PD-173 | standards | info | `docs/architecture/14-api-contract/openapi.yaml` `Book` | **У отклонённой книги нет ПРИЧИНЫ на проводе.** `BookStatus.rejected` описан как «file could not be parsed», а почему именно — не поле контракта. Платформа хранит собственный закрытый словарь причин (`books.ReasonSourceUnreadable` · `ReasonNotConfigured` · `ReasonParserUnavailable`, колонка `books.reject_reason`, миграция 00013) для оператора и НЕ проецирует его: выдумывать поле не право зоны (прецедент PD-150 — `last_resync_at`). Следствие для пользователя: экран покажет «не удалось разобрать» без различения «файл не тот» и «наш движок был недоступен» — а это разные советы ⚠ **ДИСПОЗИЦИЯ D39.130:** вопрос владельцу контракта принят и превращён в спек-правку **0.2.3**, которую делает задача S4 фронт-сессии; зона правку спеки не делает и ждёт её ⚠ **ЗАКРЫТО:** канон 0.3.0 завёл `Book.reject_reason` со словарём `RejectReason`, P7 проецирует внутреннюю причину на него (`httpapi.contractRejectReason`) — с одним переименованием по эффекту: `parser_unavailable``processing_failed` (ФБ-9: имя значения это то, на что клиент вешает фразу, и оно не должно называть наш компонент) | fixed(P7, дерево сессии) | сессия P5 |
@ -380,7 +385,6 @@
| PD-278 | bug | minor | `internal/pgstore/readmodel.go` `SaveStructure` | **Первая материализация книги трактовалась как ПЕРЕ-нарезка и стирала работу оплаченного прогона.** `storedKey = ''` ≠ ключу манифеста ⇒ ветка ре-ката удаляла ВСЕ `unit_resolutions` книги. Достижимо штатно: интейк-материализация упала (PD-276), пользователь прогнал книгу, и первый успешный `SaveStructure` стирал резолюции завершённого прогона — карточка 0 глав, все замечания потеряны, восстановление только новым прогоном за деньги. Регрессия правки PD-264 — **закрыто:** удаление при `storedKey != "" && ключ сменился`; пин `pgstore.TestTheFirstMaterializationDoesNotDiscardAFinishedRunsWork` | fixed(приёмка правок P7, дерево сессии) | приёмка правок P7 (9 осей + fable-5) |
| PD-279 | bug | minor | `internal/pgstore/migrations/00018_recut_and_key_scope.sql` Down | **Down-путь 00018 не исполнялся на тех данных, которые Up впервые легально принимает:** строка с путём длиннее ~2704 байт не влезает в восстанавливаемый старый первичный ключ, и `goose DownTo(17)` падал детерминированно — то есть откат релиза ломался ровно в аварии, ради которой откат существует. Регрессия правки PD-261 — **закрыто:** Down снимает такие строки перед пересборкой ключа (таблица — кэш с ретенцией 24 ч); проверено живым PG в цикле `UP → данные → DOWN → UP` | fixed(приёмка правок P7, дерево сессии) | приёмка правок P7 (9 осей + fable-5) |
| PD-280 | standards | info | `internal/httpapi/idempotency.go`, `v0.go` `intakeFingerprint` | **HTTP-половина `Idempotency-Key` не имела тестов**, и именно там жили три дефекта: что попадает в отпечаток, какая область у клейма и на каком контексте ключ закрывается — из `pgstore` это не наблюдаемо (там опаковый дайджест) — **закрыто:** три пина (`TestTheSameUploadReFramedIsStillTheSameRequest`, `TestTheClaimCarriesTheRequestsOwnOperation`, `TestAnOverlongKeyIsRefusedBeforeAnythingIsClaimed`) | fixed(приёмка правок P7, дерево сессии) | приёмка правок P7 (9 осей + fable-5) |
| PD-281 | bug | info | `internal/pgstore/readmodel.go` `runProgress` | **Полоса прогона над книгой, уже полной в считаемом проходе, стоит на `0/N` и не достигает единицы** — против канона §Progress («the fraction always reaches one»). Штатный цикл: книга доведена до конца → пользователь подписал решения → запустил прогон, чтобы движок их применил → прогресс платной работы невидим до `ready`. Остаток подхода «числитель считает ГЛАВЫ, законченные проходом», а не регрессия: до правки PD-263 бар был неверен в другую сторону. Лечение требует продуктового решения (считать главы, ПЕРЕразрешённые после `started_at`), а не тихой правки | open | приёмка правок P7 (fable-5) |
| PD-283 | bug | minor | `internal/httpapi/stream.go` `pump` | **Подрезка буфера ДО чтения кадров пропускалась молча:** сверка `state.Oldest > from+1` делалась ПОСЛЕ того, как водяной знак перепрыгнул через дыру, поэтому `Oldest` оказывался ПОЗАДИ него и условие не срабатывало никогда — то есть правка PD-265 не закрывала собственный сценарий, а кадры `note`, которые канон запрещает терять, терялись. Батарея не видела: единственный пин стоял на подрезке МЕЖДУ чтениями — **закрыто:** дыра опознаётся по голове пачки (`frames[0].Position > from+1`; позиции непрерывны — единственный писатель `emitFrame`), кадры этой пачки не отправляются; пин `TestAHoleAtTheHeadOfTheBatchAsksTheClientToResync` | fixed(доработка 20.08, дерево сессии) | доработка 20.08 (сверка находок против дерева) |
| PD-284 | bug | minor | `internal/pgstore/idempotency.go` `CompleteIdempotency` | **Квитанция писалась в строку, которая этой попытке уже не принадлежит.** `where` не различал ни живую строку от исчезнувшей, ни свой клейм от перехваченного: воспроизведено живым PG — попытка, у которой клейм забрали по протуханию, дописывала свой ответ, и клиент получал квитанцию ЧУЖОГО запроса (`status=201 location=/v0/books/bk_old`). Тег команды при этом выбрасывался в `_`, поэтому «квитанция не записана» было ненаблюдаемо — **закрыто:** `and finished_at is null` + отдельная `ErrClaimLost`; окно перехвата у ЖИВОЙ попытки закрыто загрузочной проверкой `TM_PLATFORM_UPLOAD_DEADLINE < pgstore.ClaimStale` (та же форма, что уже стояла для `UploadGrace`); пин `TestAnUploadDeadlineLongerThanEitherWindowIsRefused`**ЭРРАТА (акт 5): закрытие было ПРЕЖДЕВРЕМЕННЫМ.** `and finished_at is null` закрывает только «загрузка дольше окна» и ничего не говорит про ВЛАДЕЛЬЦА клейма: строка перехвачена, а `finished_at` у преемника законно пуст. Целиком закрыто токеном клейма — PD-300 | fixed(доработка 20.08, дерево сессии) | доработка 20.08 (сверка находок против дерева) |
| PD-285 | standards | minor | `internal/runs/reconcile_test.go`, `internal/httpapi/idempotency_test.go` | **Два пина утверждали свойство, которого у кода нет.** `TestAResumedRunIsSpawnedWithoutTheSigningStop` гонял фикстуру, стартующую прогон БЕЗ `stop_for_signing`, поэтому подмена `l.VerifyBank && !l.BankReleased``l.VerifyBank` его не роняла — пин PD-277 был пустым. `TestTheSameUploadReFramedIsStillTheSameRequest` объявлял «тот же файл в другой оболочке — тот же запрос», тогда как боевой вызов кладёт в отпечаток `r.ContentLength`, который оболочку считает — **закрыто:** первый проходит весь путь (первая попытка обязана НЕСТИ флаг, вторая — нет), второй переименован и утверждает то, что код делает, с честной границей PD-262 | fixed(доработка 20.08, дерево сессии) | доработка 20.08 (сверка находок против дерева) |
@ -395,9 +399,6 @@
| PD-294 | standards | info | `internal/pgstore/readmodel.go` `SubmitBankDecisions` | **Транзакция решений банка не брала блокировку книги первой** — против глобального порядка зоны (`credits.go` `lockBook`: books → runs → run_attempts → account_balances → reservations). Она берёт блокировки `bank_decisions` и, через внешний ключ, `bank_terms`, которые `SaveBank` держит, уже взяв книгу; ретрая нет, `IsTransient` сюда не подключён, поэтому взаимоблокировка ушла бы клиенту как 500 — **закрыто:** `lockBook` в начале транзакции | fixed(доработка 20.08, дерево сессии) | доработка 20.08 (сверка находок против дерева) |
| PD-295 | bug | info | `internal/pgstore/migrations/00016_read_surface.sql` | **Индекс `unit_resolutions_book_idx` (00015) стал строгим префиксом-дубликатом** индекса, который добавляет 00016, и не снимался: второй индекс на самом горячем пути записи (строка на юнит на волну) — **закрыто:** `drop index` в Up, воссоздание в Down; заодно снято утверждение о «пине 18.4», которого нет в ратифицированной таблице стека (пол — 16). Перефингерпринт 00016 объяснён в шапке `migrations.sha256` | fixed(доработка 20.08, дерево сессии) | доработка 20.08 (сверка находок против дерева) |
| PD-296 | standards | info | `internal/httpapi/v0.go` `contractSurface`, `internal/httpapi/problem.go` `codes` | **Два места, которые надо править парой, правились по одному.** Маршруты регистрировались дюжиной вызовов `mux.Handle`, а тест «каждый контрактный маршрут требует сессии» ходил по литеральному списку из четырёх — семь новых маршрутов пака не покрывал никто. Коды ошибок несли статус и заголовок в двух отдельных `switch`, а тест — в третьем, рукописном: новая константа не покрывалась ничем — **закрыто:** маршруты и коды стали по ОДНОЙ таблице, тесты ходят по ней; счётчик кодов пинит закрытый словарь 0.3.0 в 16 значений | fixed(доработка 20.08, дерево сессии) | доработка 20.08 (сверка находок против дерева) |
| PD-297 | bug | info | `internal/pgstore/readmodel.go` `writeChapters`/`writeUnits` | **Материализация дерева делает один round-trip на СТРОКУ под эксклюзивной блокировкой книги** — на корпусной книге (2283 главы, ~7 тыс. пар) это ≈11 тыс. последовательных обращений, и всё это время за блокировкой стоят `emitFrame` потока, `StartRun` и фолд юнитов. Штатный инструмент — `tx.SendBatch` (pgx v5, уже драйвер модуля) или `CopyFrom` во временную таблицу. НЕ сделано осознанно: рефутеры первой приёмки понизили до DOUBT/LOW, цена не замерена на форме этого деплоя (unix-сокет против управляемого PG по TCP — разница на два порядка), а путь — самый опасный на запись. Мерить прежде правки: время удержания блокировки на 2283-главной книге до и после ⚠ **P8-FIX: НЕ ВЗЯТ, причина названа и она не «не успели».** Сама эта строка объявляет замер на здешнем стенде НЕпредставительным (unix-сокет против управляемого PG по TCP — разница на два порядка), а корпусной книги нет: она появляется на холодном прогоне движка, которым гейчена строка 202 единого бэклога (решение владельца 20.08). Мерить нечем и не на чем, а правка самого опасного на запись пути без замера — ровно то, что эта строка запрещает. Берётся вместе с холодным прогоном | open | доработка 20.08 (сверка находок против дерева) |
| PD-298 | bug | info | `internal/pgstore/readmodel.go` `ListNotes`, `internal/pgstore/sink.go` `unitDone` | **Снятие флага с замечания дельта-чтение выразить не может.** Резолюция, пере-разрешённая как не-`flagged` (редрайв), обновляет строку и двигает `revision`, но дельта фильтруется предикатом `ur.flagged` — строка не возвращается, и клиент никогда не узнаёт, что замечание снято: оно остаётся на экране навсегда. Канон §AfterVersion: «A DELETION cannot be expressed this way», и требует одного из двух ответов — `resync_required` либо `400 version_too_old`; здесь не даётся ни один. ⚠ НЕ подтверждено, что движок вообще пере-издаёт `unit_done` для той же тройки (глава, юнит, волна) с `flagged=false` — комментарий `sink.go` это УТВЕРЖДАЕТ («a redrive re-attacks a flagged one»), но чтением движка не сверено. Порядок: сначала сверка у движка, потом либо счётчик замены для замечаний, либо строка «переход недостижим» ⚠ **ДИСПОЗИЦИЯ АКТА 5 (сверка с движком контрактной сессией 20.08): переход НЕДОСТИЖИМ и механизма не строим.** Движок объявляет вердикт юнита один раз на волну, его announce-once-леджер не пере-announce-ит, поэтому единственная пере-доставка — ТОТ ЖЕ вердикт. Инвариант записан в коде (`sink.go` `unitDone`): если что-то научится снимать флаг, фолд обязан выдать кадр — иначе счётчик разъедется со списком молча. Строка держится открытой этим долгом, а не живым дефектом | open | приёмка P7 → доработка 20.08 (сверка) |
| PD-299 | standards | info | `internal/httpapi/conditional.go` `acceptsGzip` | **`Accept-Encoding: identity;q=0` не отвечает `406`.** Клиент, потребовавший ЛЮБОГО кодирования кроме identity, получает identity. Половина RFC 9110 §12.5.3, которую правка PD-268 не закрыла: gzip-сторона (именованное кодирование выигрывает у `*`, нулевой вес — отказ) закрыта и пиньётся, эта — нет. Достижимо только специально сконструированным клиентом; ни один генерённый по контракту клиент так не делает | open | доработка 20.08 (сверка находок против дерева) |
## Закрытые — акт 5 P7 (ревью акта 4 + ответ контрактной сессии, 20.08)

View file

@ -331,6 +331,36 @@
совпадает ровно тогда, когда обе записи сделала одна транзакция. Найдено тем, что два пина этого
же акта прошли под мутацией, которую сами называют.
## Инвентарь каналов движка — полная таблица (собран паком P8-FIX 22.08 ЧТЕНИЕМ кода движка)
> Перенесено оркестратором №18 при лендинге P8-FIX из отчёта пака: это был его артефакт §4.4, и
> ДРУГОГО носителя у таблицы в репозитории нет (грепом — `research/23` описывает ФОРМУ шва, а не
> перечень каналов с их атомарностью). Собран чтением `backend/`, не по нашим докам. Ценность в двух
> колонках, которых нельзя получить из кода платформы: **атомарность записи** (какие сайдкары можно
> читать на живом прогоне, а какие рвутся) и **какие каналы движка платформа не потребляет вовсе**.
> ⚠ Якоря в таблице — на код ДВИЖКА, он живёт своей жизнью: при расхождении первичен код.
| Канал | Писатель в движке | Атомарность | Читатель/писатель платформы | Согласовано? |
|---|---|---|---|---|
| `<project_db>.manifest.json` | `pipeline/manifest.go` `writeFileAtomic` | **атомарно** (temp+`Sync`+rename, `pipeline/artifact.go`) | `internal/runner/engine.go` (`tmctl manifest`) → `ingest.DecodeManifest``internal/books/parse.go`, `internal/readmodel` | ✅ и с P8-FIX строже: `ingest.Manifest.Whole()` зеркалит `BookManifest.selfConsistent` движка по тем правилам, которые платформа читает (главы, юниты, нумерация). Верхняя граница чанков движка counterpart'а не имеет НАМЕРЕННО — платформа счётчиков чанков не берёт |
| `<project_db>.bank.json` | `pipeline/bankexport.go` `writeFileAtomic` | **атомарно** | `internal/runner/artifacts.go``ingest.DecodeBank``pgstore.SaveBank` | ✅ |
| `<project_db>.bank-stop.json` | `pipeline/mining.go` `writeFileAtomic` | **атомарно** | **читателя НЕТ** (грепом по зоне — ноль вхождений) | ⚠ канал существует и не потребляется: полная таблица подписи платформе сегодня не нужна, она проецирует банк |
| `<project_db>.bank-stop.txt` | `pipeline/mining.go` `os.WriteFile` | **НЕ атомарно** (усечение первым делом) | читателя нет | ✅ **и это важное правило, а не наблюдение: брать его на ЖИВОМ прогоне нельзя** — прочитаешь обрезанный файл без всякой ошибки |
| `<project_db>.mined-signature.yaml` | `pipeline/mining.go` `os.WriteFile` | **НЕ атомарно** | читателя нет | ✅ то же правило |
| `<project_db>.auto-bank.yaml` | `pipeline/mining.go` `os.WriteFile` | **НЕ атомарно** | читателя нет | ✅ то же правило |
| `mined_delta` (путь из `book.yaml`) | **писателя в движке НЕТ** — только читатель `loadMinedDelta`; формат `seed.File` (`terms:`), грузится `membank.LoadGlossarySeed`, `Source` пере-штампуется на `"mined"` | — | **писателя НЕТ и у платформы** | ❌ **разрыв — это строка 199(а) единого бэклога**, развилка ждёт ратификации |
| `mined_rejects` | читатель `loadMinedRejects`; формат `rejects: [{src, note}]` — это ФИЛЬТР ПРЕДЛОЖЕНИЙ, в банк не входит | — | писателя нет | ❌ тот же разрыв |
| `events.jsonl` (NDJSON эмиттера) | движок, StreamVersion 1.1 | append-only | `internal/ingest/tail.go` + `pgstore.RunSink` | ✅ |
| exit-коды `tmctl` | контракт движка | — | `ingest.OutcomeOf`, `internal/runs/reconcile.go` `outcome` | ✅ |
| `tmctl status --json` | движок | — | `internal/runner/engine.go``ingest.StatusReport`; расчёт денег | ✅ форму P8-FIX не менял; изменил лишь то, СКОЛЬКО раз его зовут при неудаче (бэкофф отсрочки) |
| `book.yaml` | оператор (шаблон) + **платформа ОДИН раз** (`internal/books/render.go`, `O_EXCL`) | — | платформа пишет пять фиксированных ключей: `book_id · title · source_lang · target_lang · source_file` | ⚠ **декодер движка СТРОГИЙ** (`config/book.go`, `dec.KnownFields(true)`): незнакомый ключ — жёсткая ошибка, не предупреждение. Значит любое расширение набора ключей платформой это РАТИФИКАЦИЯ, а не правка: сборка движка, которая ключа ещё или уже не знает, перестанет грузить КАЖДУЮ новую книгу. Прямо относится к развилке 199(а) |
**Два следствия, которые стоит держать в голове при любой работе со швом.** Первое: относительные
пути в `book.yaml` резолвятся от каталога КНИГИ (`config/book.go` `resolve()`), поэтому один шаблон
на все книги даёт пер-книжные пути без всякой подстановки. Второе: объявленный ключ с
НЕсуществующим файлом валит загрузку конфига целиком — то есть «объявим ключ, файл создадим потом»
не работает, движок просто не стартует.
## Стенд разработчика — воспроизводимый рецепт
> ⚠ Перенесено оркестратором №18 21.08 из рабочего хендоффа приёмки P7 при его архивации. Первая