444 lines
439 KiB
Markdown
444 lines
439 KiB
Markdown
# Регистр дефектов и уязвимостей платформы
|
||
|
||
> Заведён по решению владельца 04.08 («отдельно ведётся колонка багов и уязвимостей — тут опасно всё»).
|
||
> Правила: каждая находка любой сессии/приёмки/аудита — строкой сюда ДО закрытия; ID стабилен навсегда;
|
||
> закрытие — только с коммитом фикса и тестом, пинящим свойство (урок PD-1: свойство без пинящего теста
|
||
> считается НЕ закрытым). Класс: `vuln` — эксплуатируемо или ослабляет защиту · `bug` — неверное поведение ·
|
||
> `hardening` — защита в глубину / латентное · `doc` — док лжёт о коде · `standards` — расхождение с
|
||
> объявленной нормой зоны (введены приёмкой P2; словарь отставал от строк — испр. оркестратором №15). Статус: `open` · `fixed(<commit>)` · `accepted-risk(<кем, когда>)`.
|
||
|
||
> **Форма файла (пинг оркестратора №16, 09.08).** Таблица разложена на секции — открытые по весу, принятый риск, закрытые по эрам паков, — а построчная форма `| PD-N | … |` сохранена: `docs/scripts/counts.py` (путь от КОРНЯ репозитория — скрипт зоны `docs/`, зовётся `python3 docs/scripts/counts.py --check` оттуда же) ключуется формой строки, а не позицией, и секции его не ломают. ID остаётся стабильным навсегда, поэтому строка не переезжает между секциями иначе как при смене статуса, и внутри секции строки идут по номеру.
|
||
|
||
## Открытые — major
|
||
|
||
Несущий путь или контрактно видимое поведение. Каждая строка здесь — то, что решается до следующего пака, а не «когда-нибудь».
|
||
|
||
| ID | Класс | Серьёзность | Где | Суть | Статус | Источник |
|
||
|---|---|---|---|---|---|---|
|
||
|
||
## Открытые — minor
|
||
|
||
| ID | Класс | Серьёзность | Где | Суть | Статус | Источник |
|
||
|---|---|---|---|---|---|---|
|
||
| PD-89 | hardening | minor | `cmd/tmplatformctl/main.go:143-150` | **Сминченный ключ идемпотентности не печатается при ошибке записи:** PD-75 закрыл путь ПОСЛЕ коммита, но неоднозначный обрыв НА коммите остался — оператор видит ошибку, повторяет без `--key`, `newKey()` чеканит новый ключ, второе начисление проходит. Фикс: печатать ключ вместе с ошибкой, чтобы повтор был с тем же `--key` | open | приёмка P2 (панель) |
|
||
| PD-101 | bug | minor | `internal/login/login.go:507` | `login_events.ip_prefix` берётся из `r.RemoteAddr`, а в задуманном деплое перед сервисом стоит edge-прокси ⇒ префикс всегда сеть прокси. Журнал входов заведён как ответ на «откуда примерно я входил» — в шипуемой форме он систематически отвечает неверно. `X-Forwarded-For`/`Forwarded` нигде не читаются и доверенного прокси в конфиге нет (это правильный дефолт: доверять заголовку без edge нельзя) — значит решение про edge и про этот столбец принимается вместе | open | приёмка P2 (панель) |
|
||
| PD-102 | doc | minor | `internal/httpapi/serve.go:36-38` | Доккоммент `DefaultTimeouts` утверждает, что «an upload extends its own deadline as it makes progress» — это НЕВЕРНО: `ReadTimeout` в `net/http` (Go 1.26.5, `server.go:990` `wholeReqDeadline = t0.Add(ReadTimeout)`) выставляется один раз и по мере прихода байтов не продлевается. Комментарий несущий: он объясняет, почему `Read` короткий, и на нём будущая ручка загрузки книги (23 МБ по контракту) построит неверное ожидание — ей понадобится собственный дедлайн через `ResponseController`, а не «прогресс продлевает» | open | приёмка P2 (панель, сверено с исходником Go) |
|
||
| PD-103 | hardening | minor | `internal/auth/middleware.go:43,66` | У обращений к БД на аутентифицированном пути (`Lookup`/`Touch`) нет собственного дедлайна — только голый `r.Context()`, а `WriteTimeout` у сервера отсутствует по проекту (SSE) и `TimeoutHandler` в цепочке нет. Зависший Postgres паркует хендлеры и ждущих в пуле, пока клиент сам не уйдёт. `readyz` свой таймаут получил (PD-14) — горячий путь нет | open | приёмка P2 (панель) |
|
||
| PD-115 | standards | minor | `docs/ENGINEERING_STANDARDS.md` §2 | **Внешняя версионированная базовая линия объявлена ровно для ОДНОЙ оси — безопасности** (ASVS 5.0 L2 + OWASP API Top-10 2023, с указанием глав). Отказоустойчивость, наблюдаемость и контракт-первичность описаны собственной прозой зоны без внешнего эталона, а конфигурация, релиз/откат, ёмкость и восстановление не описаны вовсе. Разница не теоретическая: PD-57 и PD-58 нашлись ИМЕННО сверкой кода с RFC 9700/9207 и NIST SP 800-63B — механизм работает там, где эталон есть, и не может сработать там, где его нет. Грепнуто на 08.08: метрик и трейсинга ноль (ни prometheus, ни otel, ни expvar, ни pprof), процедуры бэкапа/восстановления в `deploy/README.md` нет, SLO не заданы. Предложение зоны: §2 получает по эталону на ось (наблюдаемость, ops, конфигурация) — **направление РАТИФИЦИРОВАНО 09.08 (оркестратор №15 по делегации владельца); носитель работы — эта строка, исполнение — своими паками** ⚠ Уточнено паком P5: ось НАБЛЮДАЕМОСТИ эталон получила — практики именования Prometheus (базовые единицы, `_total` у счётчиков, единица не в лейбле) плюс «четыре золотых сигнала» на вопрос «что мерить», записано в `STACK_DECISIONS` §24 и пинится `metrics.TestTheRunnersStateIsExposedWithItsUnits`. Оси ops/конфигурация/восстановление эталона по-прежнему не имеют — строка открыта ими | open (наблюдаемость закрыта P5; ops и конфигурация — нет) | абстрактный вопрос владельца 08.08 + сессия P4 |
|
||
| PD-157 | bug | minor | `internal/runs/spawn.go`, `cmd/tmplatformctl/runs.go` `book add` | **ДНЕВНОЙ потолок книги `--ceiling-usd` не перекрывает, а платформа его не видит и не задаёт.** Движок требует хотя бы один из `book_usd`/`day_usd` (`backend/internal/config/book.go:250`, Р7), флаг переопределяет только книжный (D39.122 прямо: «День-потолок не перекрывается»), а `book.yaml` пишет ОПЕРАТОР — платформа его не правит (D39.110 §2b) и в `book add` только проверяет наличие файла. Значит книга с низким `day_usd` останавливает прогон на лимите, которого платформа не выбирала: движок выходит кодом 1 (тот же путь, что у PD-113), прогон приезжает `failed`, а деньги пользователя целы и он не понимает, почему. В `status --json` дневной фигуры нет вовсе (есть `book_ceiling_usd`/`ceiling_pct`), поэтому даже диагностировать это платформа сегодня не может. Заведено, не построено: закрывать — либо проверкой `day_usd` при заведении книги, либо словом контракта о том, кто владеет потолками `book.yaml` у книг под платформой ⚠ **ПОЛОВИНА ЗАКРЫТА (P6): дневной потолок стал РАЗЛИЧИМ.** Поток несёт `ceiling.scope` со значениями book и day (D39.131), платформа хранит внутреннюю причину `daily_ceiling` (миграция 00015) и НЕ проецирует её как `credit_exhausted` — иначе экран сказал бы «кончились деньги» об аккаунте, на котором деньги есть, и зажёгся бы аккаунт-флаг `ReadUsage`. Резюм такого прогона отвечает 409 с диагностикой, а не гоняет попытки в цикл: `day_usd` живёт в `book.yaml` оператора, платформа его не ставит и поднять не может, а граница дня принадлежит ледджеру ДВИЖКА — таймер здесь был бы догадкой, которая тратит спавны. ОСТАЁТСЯ открытым то, ради чего строка заведена: платформа по-прежнему не выбирает и не видит `day_usd` (в `status --json` его нет), поэтому книга с низким дневным потолком остановится на лимите, которого никто на этой стороне не назначал. Пины: `pgstore.TestTheCeilingScopeDecidesWhichPauseTheRunGets` · `runs.TestAResumeOfARunTheEnginesDailyCeilingStoppedIsRefused` · `httpapi.TestOnlyTheContractsOwnPausedReasonReachesTheWire` | open | собственная сверка шва при F1 (чтение движка + D39.122) |
|
||
| 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-217 | bug | minor | `internal/pgstore/books.go` `BooksForMigration`, `deploy/README.md` | **Книга, застрявшая на `daily_ceiling` или на вечно незакрытом холде, блокирует апгрейд движка бессрочно, и выхода у оператора нет.** Оба состояния снимаются только тем, чего платформа сделать не может: дневной потолок живёт в `book.yaml` оператора, и резюм по нему отказан 409; холд закрывается расчётом, который читает движок ЗАПИНЕННЫМ бинарём. Форсирующего флага у `books --migratable` нет намеренно — он и был бы способом пере-оплатить уже купленные вызовы. Лечится либо ручным разрешением в БД, либо каналом «признать попытку невосстановимой», которого в контракте нет ⚠ **ПОЛОВИНА ЗАКРЫТА P7:** `paused` вышел из предиката `Resumable` — с 0.3.0 прогон, остановленный ЛЮБЫМ потолком, `resume` продолжить не может вовсе (409 `ceiling_reached`), значит к старой сборке ничего не пришпилено и мигрировать такую книгу безопасно; лечение пользователя — НОВЫЙ прогон, который спавнится ТЕКУЩИМ бинарём. Книга на дневном потолке апгрейд больше не запирает. Связь названа в коде: вернётся резюм паузы — вернётся и предикат. Пин `TestEachOfTheThreeBlockersAloneKeepsABookOutOfTheMigrationList` (перечень «безопасных» проверяется целиком, а не по одной книге). ⚠ ОСТАЁТСЯ вторая половина: книга с вечно незакрытым холдом блокирует по-прежнему, и это правильно — расчёт читает движок запиненным бинарём | open | приёмка P6 (дофикс, ФП-7) |
|
||
| PD-219 | bug | minor | `internal/runs/reconcile.go` `drainJournal`, `reopen` | **Перезапуск после упавшего дрейна теряет непримененный хвост журнала навсегда.** Новая попытка получает курсор с РАЗМЕРА журнала на момент допуска, поэтому строки, которые прошлый дрейн не успел применить, не прочитает уже никто. Счётчики восстанавливает ре-синк, а `unit_resolutions` — нет: их единственный источник — поток. Сегодня невидимо (читающей поверхности единиц ещё нет), к P7 станет расхождением read-модели ⚠ **ДИСПОЗИЦИЯ P7 (взято на учёт, НЕ закрыто):** предсказание строки сбылось ровно наполовину, и половины теперь названы. Что САМОЛЕЧИТСЯ: текст и состояние пары приходят не из потока, а из `tmctl export` на границе работы (`readmodel.Refresh`), поэтому недодрейненный хвост их не искажает. Что НЕ лечится: `unit_resolutions` — единственный источник ЗАМЕЧАНИЙ и счётчиков главы, и потерянная строка это замечание, которого пользователь не увидит никогда, плюс `units_done`, занижённый навсегда. Лечение — перечитывание хвоста журнала при переоткрытии прогона (курсор новой попытки начинается с размера журнала на допуске); это работа того же класса, что эскроу (строка 136), и в P7 не бралась осознанно | open | приёмка P6 (дофикс, ФП-7) |
|
||
## Открытые — info
|
||
|
||
| ID | Класс | Серьёзность | Где | Суть | Статус | Источник |
|
||
|---|---|---|---|---|---|---|
|
||
| PD-6 | hardening | info | `internal/auth/csrf.go:51` | GET освобождён от CSRF (верно), но SSE-хендшейк — GET с амбиентной кукой: origin-чек хендшейка потока (STACK §5) не покрыт ничем. Закрыть при постройке SSE (P1) | open | приёмка P0 (security-линза) |
|
||
| PD-23 | hardening | info | `internal/pgstore/migrations/00001_identity.sql` | Журнал входов растёт без ретенции и чистится только каскадом при удалении аккаунта. Нужен свип по возрасту (год?) — вопрос политики, не кода | open | самопроверка P1 |
|
||
| PD-44 | hardening | info | `internal/pgstore/` | `sqlc` не взят, хотя направление §3 предписывает взять его ДО появления денежных таблиц. Весь денежный SQL — сырые строки pgx. Нужна ратификация: адаптировать денежный пакет под sqlc в следующей сессии либо поправить направление ⚠ **P7: НЕ ВЗЯТ, ОТЛОЖЕН СЛОВОМ ВЛАДЕЛЬЦА 17.08** («отложим»). Пак был обязан его взять (D39.132 п.2б привязал sqlc к P7), и отступление названо вслух вместе с честным счётом ПРОТИВ себя: sqlc поймал бы ровно тот класс, который дважды укусил этот пак в рантайме (`column r.stop_for_signing does not exist`, `column chapters_before does not exist`) — оба стоили цикла тестов и оба поймала батарея, то есть на цикл позже, чем поймал бы компилятор. Что мешает взять его в read-модели: запросы собраны склейкой общих фрагментов (`bookColumns`, `lastRun`, `runProgress`, `derivedStatus`) — одна проекция книги на четыре пути, чтобы карточка, страница библиотеки и квитанция интейка не разъехались, — а sqlc требует литерального SQL на запрос; плюс страница и её ревизия это ТРАНЗАКЦИЯ из нескольких операторов, которую он не моделирует. Предложение зоны на будущее: взять его в P8 на отложенных ручках (`updateBook`/`deleteBook`/`getRun`, экспорт) — это однооператорные запросы, профиль, где он силён, — и по итогам решить про read-модель; конверсия 15 транзакционных запросов это отдельный пак, не довесок | open | ревью «вне карты» |
|
||
| PD-45 | hardening | info | `internal/ingest/procgroup_unix.go` | `syscall.Kill(-pid, SIGINT)` идёт мимо `os.Process`, поэтому в узком окне между проверкой живости и сигналом ребёнок может быть пожат, и сигнал уйдёт в переиспользованную группу. Окно ~микросекунды и родитель ещё не звал `Wait`; переписывать на pidfd-путь — отдельная работа | open | ревью «вне карты» |
|
||
| PD-86 | hardening | info | `internal/pgstore/sessions.go:23,50` | **Два клауза-близнеца не запинены, и абсолютный потолок держится ТРАНЗИТИВНО:** снятие `absolute_expires_at > $2` из `Lookup` батарею переживает, потому что потолок навязывается через `least($3, absolute_expires_at)` в `Touch` (это запинено — `TestSessionLifecycle`). Снятие `revoked_at is null` из `Touch` тоже переживает (класс PD-4). Дефекта сегодня нет ни в одном; риск в том, что каждый слой по отдельности выглядит избыточным, а вместе они — единственное, что ограничивает жизнь сессии | open | приёмка P2 (посадки мутаций) |
|
||
| PD-87 | hardening | info | `internal/httpapi/server.go:82`, `internal/login/login.go:31` | Ещё два незапиненных: снятие `LimitBody` с поддерева `/auth` и `stateTTL` 10 мин → 240 ч проходят батарею. Первое — родня PD-72 (та про общий внешний слой, эта про конкретное поддерево), второе — окно жизни неиспользованного авторизационного запроса | open | приёмка P2 (посадки мутаций) |
|
||
| PD-88 | bug | info | `internal/auth/cookie.go:62-66` | **TTL меньше секунды выпускает куку БЕЗ атрибута `Max-Age`:** `int(ttl.Seconds())` даёт 0, а Go при `MaxAge == 0` атрибут опускает ⇒ кука становится браузер-сессионной. Достижимо в последнюю секунду абсолютного срока (скольжение выдаёт `min(idle, остаток абсолютного)` при гарде `ttl > 0`) — то есть ровно тот исход, который самопроверка P2 называла нежелательным: кука переживает сессию, и следующий запрос даёт 401 вместо чистого «вы вышли». Подтверждено исполнением (ttl 500 мс/999 мс) | open | приёмка P2 (панель ×2, подтверждено исполнением) |
|
||
| PD-90 | bug | info | `cmd/tmplatformctl/main.go:112,134` | `grant` и `adjust` делят пространство ключей `source="admin"`: `--key`, потраченный грантом, молча гасит корректировку с тем же ключом. CLI честно скажет «ключ уже потрачен», но оператор ждал другой операции | open | приёмка P2 (панель) |
|
||
| PD-92 | bug | info | `internal/ingest/supervisor.go:118-121` | **Дренаж стоит ДО `cmd.Wait()`, поэтому `WaitDelay` его не размораживает:** `io.Copy(io.Discard, stdout)` ждёт EOF, а EOF придёт только когда закроются ВСЕ копии пишущего конца пайпа; внук, унаследовавший stdout и игнорирующий SIGINT, вешает `Run` навсегда — backstop `WaitDelay` действует внутри `Wait`, до которого управление не доходит. ⚠ Сегодня недостижимо и это пере-проверено приёмкой: в `backend/` вне тестов нет ни одного `exec.Command` и нет cgo. ⚠ **Пере-диспозиция (эррата №15): вес понижен до info** — весь пайп-путь `supervisor.go` объявлен ДЕВ-РЕЖИМОМ (D39.106 п.3), в проде родителя у движка нет и `cmd.StdoutPipe()` не существует; чинить только если дев-путь остаётся | open | приёмка P2 (панель, граница зоны пере-проверена) |
|
||
| PD-93 | bug | info | `internal/ingest/supervisor.go:109` | Фикс PD-77 («наша остановка — не сломанный синк») сверяет только `context.Canceled` и пропускает `context.DeadlineExceeded`: как только у `runCtx` появится дедлайн (потолок времени прогона — очевидная будущая ручка), штатное истечение снова поднимет ERROR «stream could not be materialized» | open | приёмка P2 (панель) |
|
||
| PD-94 | bug | info | `internal/httpapi/middleware.go:61-70` | **`Recover` глотает `http.ErrAbortHandler`** — sentinel, которым хендлер намеренно обрывает соединение (`net/http` его не логирует и рвёт коннект). Замерено приёмкой: паника `ErrAbortHandler` превращается в 500 с problem-телом, то есть усечённый поток становится неотличим от полного. Латентно (сегодня им никто не паникует), но именно SSE-хендлер — типовой его пользователь | open | приёмка P2 (замер оркестратора №15) |
|
||
| PD-96 | hardening | info | `internal/httpapi/server.go:39-43`, `internal/auth/csrf.go:28` | **`TrustedOrigins` обещает отдельно развёрнутый фронт, но CORS-слоя нет вовсе.** Живая проба: preflight `OPTIONS` с `Origin: https://app.example.org` получает 401 от гарда (браузерный preflight креденшелов не носит и не должен), заголовков `Access-Control-*` нет ни на одном ответе. Сценарий «фронт на другом origin» браузером сегодня неисполним: либо CORS приезжает вместе с контрактными ручками (П-1), либо фронт живёт на том же origin, и тогда `TrustedOrigins` — мёртвая ручка | open | приёмка P2 (панель + живая проба) |
|
||
| PD-98 | doc | info | `internal/pgstore/store.go:75-79` | Случай «схема НОВЕЕ бинаря» в `Ready` беззвучен — признано ⚠-комментарием на месте, но ни одной строки лога: оператор, запустивший старый бинарь на новой схеме, сигнала не получит | open | приёмка P2 (панель) |
|
||
| PD-107 | hardening | info | `internal/pgstore/migrations/00007_credits.sql:85`, `00002_readmodel.sql:13` | **Удаление аккаунта обходит защиту PD-25:** составной FK `reservations → books(id, owner_id) on delete restrict` блокирует `DeleteBook`, но `users` каскадит в `reservations` НАПРЯМУЮ, поэтому `delete from users` уносит и ОТКРЫТУЮ резервацию. Замерено приёмкой: аккаунт с открытым холдом удаляется. Учётной дыры нет — леджер и кэш баланса каскадятся тем же удалением, — но прогон, идущий против этого холда, останется без того, кто его закроет. Кода удаления аккаунта в дереве нет вовсе (грепнуто) ⇒ строка = гейт перед появлением такой операции (и перед ASVS 7.4.2 в полной форме). ⚠ Заодно ОПРОВЕРГНУТА обратная версия этой находки от панели («удаление падает на композитном FK даже при закрытых резервациях») — мой прогон: удаляется и с закрытой резервацией, и без неё | open | приёмка P2 (замер оркестратора №15; версия панели опровергнута) |
|
||
| PD-122 | bug | info | `internal/pgstore/books.go` `ListBooks` | **`Library.revision` НЕ монотонна: она выведена как `max(books.revision)` по книгам аккаунта и падает, когда удаляется книга, державшая максимум.** Контракт требует монотонности внутри области и предписывает клиенту ОТБРАСЫВАТЬ чтение с меньшей ревизией — то есть после такого удаления библиотека замирает, пока чей-нибудь книжный счётчик не перерастёт старый максимум. Замерено ревью: 10 → 0 после удаления книги. ⚠ Сегодня недостижимо через API: ручки удаления книги нет вовсе (`DeleteBook` есть в сторе, маршрута нет). Правильное решение — собственный счётчик области у аккаунта, который двигается на изменение состава и статусов. ⚠ Уточнено ревью доков 09.08: КОЛОНКА уже есть — `users.library_revision` из `00001_identity.sql:13`, и её не читает и не пишет ни один Go-путь (грепнуто), так что нужна не миграция, а пути записи и чтения; правка нескольких мест, поэтому она НЕ сделана в этом паке, а названа. ⚠ Дополнено дофиксом 09.08: ревизия не двигается и на ДОБАВЛЕНИИ книги — `AddBook` пишет новой книге `revision = 0` и счётчика области не трогает, а спека описывает ревизию библиотеки как «membership and statuses». Класс тот же и решение то же: собственный счётчик области. Гейт: закрыть ДО появления удаления книги или любого второго писателя состава ⚠ Сужено паком P5: ДОБАВЛЕНИЕ книги ревизию области двигает — новая книга входит в библиотеку с `revision = max(revision по владельцу) + 1` (`pgstore.nextLibraryRevision`, обе точки входа: дев-интейк и загрузка), и каждая смена статуса интейка её тоже двигает; пин `books.TestABookThatJoinsTheLibraryMovesItsRevision`, посадка «входить с нулём» падает. Открытым остаётся исходный случай: УДАЛЕНИЕ книги может уронить максимум, и лечится это собственным счётчиком области. Ручки удаления по-прежнему нет ⚠ ПЕРЕ-ФОРМУЛИРОВАНА дофиксом приёмки (FP5-1): клейм «добавление закрыто» был ЛОЖЕН. Пол области поднимался при удалении, а вставка читала только максимум — книга, загруженная после отменённой загрузки, входила НА или НИЖЕ пола, и одна ревизия отвечала за три разных состояния библиотеки. Теперь вставка берёт `greatest(max, пол) + 1`, пин `books.TestABookThatJoinsAfterACancelledUploadStillMovesTheRevision` гоняет именно сценарий «отмена → повторная загрузка». ОТКРЫТЫМ остаётся: смена статуса книги, которая НЕ самая новая, ревизию области не двигает — ни одна из половин не растёт; лечится тем же счётчиком области, который двигают все писатели состава и статусов ⚠ Дополнено ре-чеком (FP5-11): правило «брать следующий номер БИБЛИОТЕКИ» применено и к пяти писателям жизненного цикла прогона (старт, реоткрытие, пауза, два закрытия) — без них staleness оставалась на статусах прогона: книга не-максимума аккаунта уходила `translating`, а библиотека не двигалась. Пин `pgstore.TestARunTransitionOnAnOlderBookStillMovesTheLibrary`. Прогресс-путь синка НАМЕРЕННО не тронут (главная шкала считается от книжной до инкремента; прогресс бывает только у книги с идущим прогоном, а её старт уже поднял номер) | open (добавление закрыто P5; удаление — нет) | адверсариальное ревью (исполнением) |
|
||
| PD-123 | doc | info | `internal/pgstore/migrations/00009_runner.sql:11` | `runs.ceiling_chapters` имеет `default 0`, а контракт объявляет `Run.ceiling_chapters` `minimum: 1`. Сегодня недостижимо: единственный путь вставки — `StartRun`, и он отказывает на неположительном значении. Строка заведена как гейт: строка прогона, записанная мимо `StartRun` (миграция данных, правка оператором), спроецируется на провод нулём, которого схема клиента не допускает | open | адверсариальное ревью (чтение схемы) |
|
||
| PD-137 | hardening | info | `deploy/tmplatformd.service` `[Unit]` | `BindPaths=/run/user/%U` требует существования каталога на старте юнита, а создаёт его logind вместе с пользовательским менеджером; в `[Unit]` упорядочения на него нет. На первом бутe это гонка, которую лечит `Restart=on-failure` (сервис поднимается со второй попытки). Строка не закрыта кодом намеренно: UID сервисного пользователя site-specific, поэтому `After=user@<uid>.service` добавляется установкой — инструкция вписана в шапку юнита | open | адверсариальное ревью (чтение) |
|
||
| PD-139 | hardening | info | `internal/runs/reconcile.go` ERROR-строки | Путь каталога книги попадает в ERROR-логи внутри обёрнутых ошибок (`*fs.PathError` тейлера, обёртки спавна). PD-99 закрывал ДРУГОЕ — argv на INFO, — и та половина проверена (`grep` по логу сквозной пробы = 0). Здесь диспозиция иная и её надо принять осознанно: оператор чинит именно этот путь, а ERROR — не INFO. Заведено, чтобы это было решением, а не побочным эффектом; если норма зоны распространяется и на ERROR, путь придётся заменить на id прогона ⚠ Дополнено P5: у класса появилась ВТОРАЯ площадка — интейк. Путь книги уходит в ERROR только в двух местах и намеренно: терминальный отказ разбора (`err` движка несёт путь исходника) и неудавшееся удаление каталога. На INFO/WARN идентификатора книги нет вовсе, и это запинено `books.TestNoBookIdentifierReachesAnInfoLine`. Диспозиция по-прежнему нужна одна на класс | open | адверсариальное ревью (чтение) |
|
||
| PD-153 | bug | info | `internal/runs/reconcile.go` `spawnGrace` | **Грация спавна мерится от `run_attempts.started_at`, а не от момента заявки права на спавн:** окно между `RecordSpawn` и возвратом `systemd-run` не покрыто, и второй инстанс платформы, у которого грация уже истекла, может решить, что попытка потеряна, и перезапустить прогон, который вот-вот стартует. Дёшево закрывается временем заявки, записываемым в `RecordSpawn`, и грацией от него ⚠ Сужено дофиксом приёмки: у СТОП-пути окно закрыто с обеих сторон — заявка на спавн не выдаётся прогону с висящим интентом, а закрытие «стопа до спавна» спрашивает systemd про детерминированное имя юнита, если у попытки есть базовая линия (то есть заявка когда-то бралась). Само окно PD-153 — грация от `started_at`, а не от момента заявки — не тронуто | open | приёмка P4 (N2) |
|
||
| PD-154 | bug | info | `internal/runs/reconcile.go` `settle` | **`runs.settled_at` может остаться NULL между `Settle` и `MarkSettled`:** это два вызова, и падение между ними оставляет прогон с закрытой резервацией и без отметки. Потребителей у отметки сегодня нет (рабочий список расчёта построен на открытой резервации, а не на ней), деньги целы и второй расчёт отвергается самой резервацией. ⚠ Дофикс 09.08 добавил вторую половину той же строки: `settle` прерванной попытки ЖИВОГО прогона (путь `UnsettledRuns`) ставит `settled_at` прогону, который ещё идёт. Потребителей у колонки по-прежнему нет, дрейф только операторский. Заведено как известность, а не как долг: закрывается вместе с эскроу (строка 136) ⚠ **ПЕРЕ-ДИСПОЗИЦИЯ (P6): остаётся открытой в прежней формулировке.** Пак трогал `settle` (запиненный бинарь, аддендум оркестратора 14.08) и окно не закрывал: закрытие требует write-ahead intent и состояния `closing`, то есть эскроу строки 136, а строить половину эскроу рядом с проектируемым целым — это второй, более слабый ответ на тот же вопрос. Потребителей у колонки по-прежнему ноль | open | приёмка P4 (N3) |
|
||
| PD-166 | bug | info | `internal/ingest/tail.go`, `internal/pgstore/sink.go` `Begin` | **`chunker_version` из хендшейка теряется навсегда,** если краш пришёлся между двумя стейтментами `Begin` (привязка `engine_run_id` и запись версии — два отдельных автокоммита): при повторном чтении своего же `hello` тейлер видит, что поток уже привязан, и `Begin` больше не зовёт. Сегодня поле никем не читается (нужно для строки 100), поэтому info; закрывать — одной транзакцией в `Begin` | open | самопроверка дофикса (ревью вне карты) |
|
||
| PD-170 | hardening | info | `internal/pgstore/credits.go` `Settle` | `Settle` отбрасывает флаг `applied` строки расчёта, тогда как `releaseHold` на соседней строке из того же флага делает `ErrReleaseKeySpent` (PD-97). Недостижимо без правки леджера в обход кода — резервация должна быть открыта, чтобы дойти сюда, — но асимметрия в денежном пути стоит строки: либо симметричный отказ, либо явная причина, почему здесь он не нужен | open | самопроверка дофикса (ревью вне карты) |
|
||
| PD-176 | hardening | info | `internal/httpapi/middleware.go` `LimitBody`, `internal/metrics` | **Сигнал `requestTooLarge` от `http.MaxBytesReader` до сервера не доходит через наши обёртки.** `MaxBytesReader` пытается сказать *`net/http`* «запрос слишком большой» приведением `ResponseWriter` к НЕЭКСПОРТИРУЕМОМУ интерфейсу пакета `net/http`; наши обёртки (`statusRecorder`, `metrics.recorder`) его удовлетворить не могут в принципе — метод неэкспортируемый, а значит квалифицирован чужим пакетом. Следствие мягкое и замерено рассуждением по коду `net/http`: 413 отдаётся штатно, а соединение закрывается не немедленным сигналом, а обычным путём — после хендлера сервер дренирует остаток тела и, не сумев дочитать, закрывает соединение, причём дренаж ограничен `ReadTimeout` (PD-2). Лечение (если понадобится): ставить лимит без обёрток над `ResponseWriter` либо закрывать соединение самим через `Connection: close` | open | сессия P5 (чтение stdlib при постройке маршрута) |
|
||
| PD-177 | bug | info | `internal/books/books.go` `counter` | **`character_count` для не-UTF-8 источника — оценка, а не счёт.** Интейк считает символы потоково как байты, не являющиеся продолжением UTF-8 (`b&0xC0 != 0x80`), что для валидного UTF-8 ТОЧНО равно числу рун и не требует состояния между чанками. Движок принимает и GB18030, и UTF-16, декодируя их сам — на таком файле цифра неверна (для UTF-16 занижена примерно вдвое). Точный ответ есть на шаг позже: манифест несёт `source_bytes` и `encoding`, но не число символов. Лечится либо запросом числа символов у движка (строка единого бэклога), либо перерасчётом после разбора | open | сессия P5 (названо при постройке) |
|
||
| PD-201 | bug | info | `deploy/README.md`, движок `tmctl migrate` | **Самолечение деплой-деадлока v15 ЖДЁТ движковую половину.** Read-only `status` отказывает файлу проекта старее бинаря, платформа зовёт его перед каждым спавном и на расчёте денег — значит выкат движка запирает книги до `tmctl migrate`. Порядок апгрейда записан и исполним (`tmplatformctl books --migratable` пропускает книги с живыми прогонами, резюмируемыми попытками и незакрытыми холдами — все три блокируют по РАЗНЫМ причинам, аддендум оркестратора 14.08). Чего нет: движковый `migrate` придёт с машиноразличимой ошибкой «версия не совпала», и тогда платформа сможет лечиться сама — поймала → `migrate` (если у книги нет открытых попыток) → повтор. Вслепую не строится: без формы ошибки любой матчер был бы догадкой по тексту. ⚠ Проверка исполнением самого шага `migrate` тоже ждёт и НЕ помечена сделанной ⚠ **ПОЛОВИНА ЗАКРЫТА P7 (проверка исполнением):** рантбук деплоя прогнан end-to-end на стенде живым `migrate` — проектная БД, отведённая на схему v14 при бинаре v15, даёт `tmctl status --json` exit 13 с токеном `schema_mismatch found=14 expected=15`; `tmctl migrate` делает пред-миграционный бэкап и переводит v14 → v15; тот же `status` после — exit 0. Мёртвая цитата ошибки вычищена из `deploy/README.md` и из П-1 бэклога, пример в `tmplatformctl/runs.go` переписан на ВЕРСИОНИРОВАННЫЙ путь бинаря. ⚠ ОСТАЁТСЯ сам автомат самолечения («поймал 13 → migrate → повтор») — он не построен: строить его правильно значит решать, кто имеет право мигрировать книгу с открытыми попытками, а это тот же вопрос, что у `books --migratable` | open | аддендум оркестратора 14.08 (ресёрч деплоя) |
|
||
| PD-203 | bug | info | `internal/pgstore/books.go` `ReadUsage` | **Аккаунт объявляется исчерпанным по паузе ОДНОГО прогона.** `/usage` ставит `paused_reason` аккаунта, если у какой-нибудь книги последний прогон стоит `paused/credit_exhausted` — а это потолок ПРОГОНА (сколько глав купил пользователь), а не баланс: на аккаунте может лежать сколько угодно денег, и другой прогон стартует. Контракт про это поле говорит «Set when the account itself is in a halted state». Существовало до этого пака и не им создано; отдельной строкой, потому что различение потолков (PD-199) сделало вопрос «чей это потолок» отвечаемым ⚠ **СУЖЕНО P7, не закрыто:** `Usage.halt_reason` получил СВОЙ словарь (`AccountHaltReason`), то есть прогонная причина больше не может доехать до аккаунтного поля как чужое слово; и PD-241 убрал самый частый ложный источник — стоп пользователя, приезжавший `credit_exhausted`. Сам предикат («последний прогон ЛЮБОЙ книги стоит paused/credit_exhausted») не тронут: он про потолок ПРОГОНА, а не про баланс, и правильный ответ — читать баланс аккаунта, а не сканировать книги | open | сессия P6 (самопроверка вокруг PD-199) |
|
||
| PD-204 | standards | info | `internal/runner/systemd_test.go` мета-пин | **Мета-пин systemd-гейта ПЕРЕЧИСЛЯЕТ формы отказа вместо того, чтобы спрашивать способность.** Хвост (г) третьего раунда, у которого не было носителя. Сам гейт вылечен (PD-178: спрашивает, доходит ли процесс до своего менеджера), а тест, который его сторожит, по-прежнему знает список сообщений — у стража та же болезнь, от которой лечили охраняемого. Принято как ОСТАТОК с названной ценой: systemd меняет тексты между версиями, и мета-пин протухнет молча; вреда сегодня нет, потому что он не гейт, а страж гейта. Заведён по пингу оркестратора 14.08 | open | третий раунд P5, хвост (г) |
|
||
| PD-205 | hardening | info | `internal/login/dev.go` `login`, `internal/auth/csrf.go` `cookieUnsafe` | **Дев-вход защищён от login-CSRF только ОДНИМ слоем из двух.** `auth.CSRF` требует заголовок `X-TM-Client` лишь на unsafe-запросе, который УЖЕ несёт сессионную куку, а на входе куки по определению нет — значит остаётся только `http.CrossOriginProtection`, и клиент, не присылающий ни `Sec-Fetch-Site`, ни `Origin` (тот самый браузер до 2023, ради которого второй слой и заведён), может кросс-сайтом ввести браузер жертвы в ДЕВ-аккаунт. Последствие на стенде ничтожно — аккаунт один и общий, — но свойство слабее, чем «POST, значит безопасно», и записано, а не подразумевается. ⚠ Тот же класс у боевого `/auth/login` (он вообще GET) и по той же причине; лечится либо требованием заголовка на login-маршрутах, либо явным принятием | open | адверсариальное ревью P6 (кросс-семейное, Fable) |
|
||
| PD-212 | bug | info | движок `cmd/tmctl/main.go` `exitCode`, `internal/runs/reconcile.go` `outcome` | **Непойманная паника движка неотличима от «завершено с флагами».** Go-рантайм завершает процесс с кодом РОВНО 2 на непойманной панике, а 2 — это `CompletedWithFlags`, единственный «успешный» код контракта; `recover` в `cmd/tmctl`/`internal/pipeline` отсутствует (грепнуто ревьюером). Платформа читает только `$EXIT_CODE`/`$EXIT_STATUS` и записывает `ready` для прогона, который упал посреди работы. Деньги целы (расчёт берёт цифру из `status --json`), врёт статус. Лечится НЕ здесь: либо `recover` в main движка, либо другой номер для флагов — запрос уходит строкой единого бэклога через оркестратора. ⚠ Платформа МОГЛА бы различить по отсутствию терминальной строки `finished` в журнале, но сознательно не судит прогон по строке, которую крэш обрезает | open | адверсариальное ревью P6 (линза шва) |
|
||
| PD-213 | hardening | info | `internal/ingest/manifest.go`, `internal/books/parse.go` | **Форма манифеста не версионируется на стороне платформы — латентная мина на УДАЛЕНИЕ файла.** `DecodeManifest` не сверяет `manifest_version` ни с чем; `json.Unmarshal` тихо игнорирует незнакомые поля и оставляет отсутствующие нулями, поэтому смена формы движком (`tm-manifest-v2` → v3, переименование `chapters_total`) даст валидный разбор с `ChaptersTotal = 0`. А ноль глав интейк трактует как «источник прочли, книги нет» — тот же терминал и тот же бюджет, что exit 11, то есть после пяти попыток файл пользователя УДАЛЯЕТСЯ, хотя движок книгу прекрасно разобрал. Сегодня формы совпадают поле-в-поле, так что не эксплуатируется; в отличие от потока (мажор отвергается) и от полосы кодов (незнакомый номер безопасен), у манифеста аналога нет. Лечить сверкой `manifest_version` с известной, где незнакомая версия даёт НЕ-деструктивный класс | open | адверсариальное ревью P6 (линза шва) |
|
||
| PD-214 | bug | info | `internal/ingest/tail.go` `apply` | **Чужая или битая hello-строка в общем журнале книги гасит материализацию СВОЕГО потока.** Версия, непустой `engine_run_id` и `seq == 1` проверяются для ЛЮБОЙ hello-строки ДО того, как код решает, чья она: ветка «not mine» стоит после них. Значит чужой процесс (ручной `tmctl` оператора в каталоге книги, старый мажор, баг чужой сборки) валит `Tail` ошибкой, а `quarantines()` считает её терминальной — проекция здорового платящего прогона уходит в карантин НАВСЕГДА (снятия карантина в дереве нет), свежесть падает на медленный ре-синк. Данные целы, деньги целы. Лечится порядком: при известном `want` чужой id распознаётся ДО валидации хендшейка | open | адверсариальное ревью P6 (линза шва) |
|
||
| PD-215 | bug | info | `internal/runs/reconcile.go` `restart`, `interruptedBySomeoneElse` | **У петли перезапусков нет ни счётчика, ни бэк-оффа.** Каждый цикл — строка `run_attempts`, три строки леджера (холд/возврат/расчёт), два вызова `tmctl status` и транзиентный юнит. Источник, который на каждой попытке тратит ~0 (движок, мгновенно падающий по внешней причине), крутит это вечно: `remaining` не убывает, значит `exhausted` никогда не наступает. Денег не теряется, но это неограниченная работа и рост таблиц. Класс существовал и до пака (путь ребута, строка 138), а ветка «exit 5 без намерения = прерывание» его РАСШИРИЛА. Лечить счётчиком перезапусков на прогон или бэк-оффом по времени последней попытки | open | адверсариальное ревью P6 (линза денег и гонок) |
|
||
| PD-216 | bug | info | `internal/runs/reconcile.go` `outcome` (полоса отказов) | **Exit 12 (`project_locked`) закрывает прогон терминально, хотя это единственный класс полосы, который проходит САМ.** Обоснование «отказ воспроизводим по построению, поэтому перезапуск — петля» верно для 10/11/19 и неверно для лока: другой процесс отпустит проект. Денег не теряется (холд возвращается целиком, списывается 0), но прогон убит и пользователь покупает новый. Не исправлено намеренно: перезапуск на 12 без бэк-оффа — это PD-215 в чистом виде, поэтому оба лечатся вместе | open | адверсариальное ревью P6 (линза денег и гонок) |
|
||
| PD-243 | hardening | info | `internal/ingest/resync.go` `DecodeStatus` | **Декодер принимает `null`, `{}` и объект сплошь незнакомых полей за валидный отчёт.** На денежных путях это перекрыто (PD-40: `Spend == nil` откладывает расчёт) и при exit 2 — согласием отчёта (PD-237); остаётся `eta_seconds`, который присваивается безусловно, так что пустой отчёт стирает ETA живого прогона. Лечится проверкой того же класса, что в манифесте: отчёт обязан называть книгу | open | кросс-семейное ревью дофикса P6 (линза шва) |
|
||
| PD-244 | bug | info | `internal/runs/reconcile.go` `settle` | **Единственный выход `settle`, который оставляет холд открытым молча:** при `s.Engine == nil` возвращается nil — ни возврата, ни `MarkSettled`, ни строки в логе. В проде недостижимо (`cmd/tmplatformd/runner.go` всегда ставит Engine), но это ровно та тишина, из-за которой класс PD-162 искали глазами | open | кросс-семейное ревью дофикса P6 (денежная линза) |
|
||
| PD-246 | standards | info | `internal/ingest/notes.go`, движковый `pipeline/status.go` `flagReasonSeverity` | **Платформа держит РУКОПИСНУЮ КОПИЮ закрытого словаря чужой зоны, и единственный страж — строка в логе.** Флаг-причины принадлежат движку (`flagReasonSeverity`, 15 значений), карта «причина → контрактный код» живёт на платформе, а выпускаются две программы независимо. Значит существует окно, в котором движок уже эмитит причину, которую эта сборка не знает, и расхождение видно только по ERROR в логе — то есть тогда, когда кто-то его прочитает. ⚠ Проводу окно закрыто и это НЕ дефект: причина вне карты проецируется кодом `unspecified`, а канон уже предписывает клиенту нейтральную фразу для незнакомого кода — то есть значение отдано ветке, которая ратифицирована, а не изобретено правило. Открытым остаётся ДВОЕ: (1) фразы для `unspecified` в приложении А нет (пишет владелец, строка 148) — как и ступеней у всех 15 строк, где граница `attention`/`glance` сегодня догадка платформы; (2) гейта на расхождение нет и со стороны платформы быть не может — импортировать `backend/internal` запрещено ревью-гардом модулей (D39.85). **Предложение зоны (пинг оркестратору, строкой в бэклог ДВИЖКА):** публиковать список флаг-причин данными — артефакт рядом с манифестом либо `tmctl flag-reasons --json` ($0, таблица уже существует), — тогда тест платформы читает его и ПАДАЕТ, если в карте нет строки. Класс «словарь разъехался тихо» закрывается насовсем. ⚠ Обратное решение — чтобы движок эмитил сразу контрактный код — отвергнуто с доводом: это зеркальная утечка, продуктовое слово (`source_residue`, `term_not_applied`) поехало бы в движок, который о контракте знать не должен, и отменило бы причину самого переименования ⚠ **ЗАКРЫТА ЧАСТЬ ПРО ПЛЕЙСХОЛДЕР (акт 5, ответ контрактной сессии 20.08):** `unspecified` ратифицирован как ОБЯЗАННОСТЬ сервера, а не строка чужого словаря — то, что зона уже отдаёт, стало легальным. Открытым остаётся то, ради чего строка заведена: рукописная копия закрытого словаря движка и отсутствие стража, кроме строки в логе | open | сессия P7 (самопроверка против приложения А) |
|
||
| PD-247 | bug | info | `internal/httpapi/stream.go` `pump` | **Живой поток ОПРАШИВАЕТ базу дважды в секунду на каждое открытое соединение** (кадры + состояние книги). Кадры минтят писатели в своих транзакциях и в других процессах, поэтому подписки у этой стороны нет; выбран опрос, а не `LISTEN/NOTIFY`, потому что кадр это ПОКА, и секунда задержки на поке не наблюдаема рядом с переводом. Цена названа числом: 12 вкладок на книгу = 24 запроса/с к Postgres, оба по индексу и по одной книге. Лечится `pg_notify` в `emitFrame` + один слушатель на процесс — работа на полдня, которая нужна не раньше второго десятка одновременных читателей | open | сессия P7 (собственная оценка) |
|
||
| PD-248 | bug | info | `internal/readmodel/readmodel.go` `Refresh` | **Материализация читающей поверхности стоит ДВУХ полных ре-чанков исходника** — `tmctl manifest --json` и `tmctl export --json --pairs`, каждый из которых заново ингестит и режет книгу (~1,4–1,5 с CPU на 23 МБ, строка 100 единого бэклога). Зовётся на границах работы (конец интейка, конец прогона), то есть не на запрос пользователя, но на книге в 2283 главы это секунды CPU и десятки мегабайт JSON через пайп на каждый конец прогона. Дешевле было бы читать сайдкар манифеста напрямую (он уже лежит рядом с БД проекта) и просить у движка экспорт ТОЛЬКО изменившихся глав — второго канала у движка нет, это запрос строкой единого бэклога **Доработка 20.08:** интейк больше не платит за ТРЕТЬЮ ре-нарезку — `books.Parse` передаёт уже прочитанный манифест в `readmodel.RefreshCut`; остаются два (манифест + экспорт), и это цена самих каналов. | open | сессия P7 (собственная оценка) |
|
||
| PD-249 | bug | info | `internal/pgstore/runs.go` `ReadRunForSpawn` | **Чтение прогона под спавн линейно по числу ЖИВЫХ прогонов:** читает их все и ищет нужный в цикле. Сегодня незаметно (живых прогонов единицы), но это O(живых) на КАЖДУЮ задачу спавна, и растёт ровно тогда, когда платформа становится нужной. Находка §9 контракт-ревью (research/28), проверена чтением кода: запрос действительно без предиката по id | open | research/28 §9 (пинг оркестратора №17), сверено P7 |
|
||
| PD-250 | vuln | minor | `cmd/tmplatformd/main.go` (слушатель метрик) | **`/metrics` отдаётся БЕЗ аутентификации; вся защита — привязка к `127.0.0.1`.** Для одной VM это честная граница, и она записана (STACK §24). Но на хосте с несколькими пользователями любой локальный процесс читает оперативную картину сервиса, а на деплое, где слушатель однажды переедет на `0.0.0.0` «чтобы Prometheus дотянулся», защиты не останется вовсе. Денег в метриках нет (D39.84), поэтому это minor, а не major. Лечение — bearer-токен на слушателе или mTLS, решать при первом внешнем Prometheus | open | research/28 §9 (пинг оркестратора №17), сверено P7 |
|
||
| PD-251 | bug | info | `internal/login/login.go` (лимитер входа) | **Лимитер входа один на ПРОЦЕСС, а не на адрес:** один клиент, долбящий `/auth/login`, расходует общее ведро и запирает вход всем. Выбор объяснён комментарием (за прокси адрес клиента без доверенного `X-Forwarded-For` — это адрес прокси, и пер-адресное ведро тогда защищает не то), но следствие не было записано. Лечение появляется вместе с доверенным заголовком прокси на деплое | open | research/28 §9 (пинг оркестратора №17), сверено P7 |
|
||
| PD-252 | bug | info | `internal/runs/reconcile.go` (карантин проекции) | **Сырой Go-текст ошибки сохраняется причиной карантина в пользовательской строке БД.** На провод он не идёт (карантин не проецируется ни одним полем контракта), но это движковый и внутренний текст в таблице, которую читают дампами; тот же класс, что `Problem.detail`, только в базе. Лечение — закрытый словарь причин карантина, как у `books.reject_reason` | open | research/28 §9 (пинг оркестратора №17), сверено P7 |
|
||
| PD-254 | standards | info | `internal/httpapi/problem.go` `WriteStatusProblem`, `codeForStatus` | **Два писателя ошибок на одну форму тела.** `WriteProblem` выводит статус ИЗ кода (пара не может разойтись), а `WriteStatusProblem` идёт обратно — от статуса к коду — потому что вне версионного префикса (`/auth`, `/readyz`) есть статусы, которых в словаре контракта нет вовсе (405, 429). Свести в один писатель можно только назначив форму ответа для этих двух статусов, а это ратификация, не правка зоны. Пока — два пути и обратная функция рядом с прямой ⚠ **ЗАКРЫТО решением контрактной сессии 17.08 (релей владельца):** поверхность входа отвечает тем же конвертом и БЕЗ машинного кода — это ратифицированное решение, а не пробел («различать причины отказа клиент не может по замыслу», компаньон §2.14 с 0.2.3), а единственный осмысленный для пользователя случай `429` машинен без словаря, потому что лечение едет в `Retry-After`. Следствие для кода: писатель входа кода не эмитит, `codeForStatus` и `writeRaw` удалены — обратной функции, то есть второго источника истины, больше нет. Пин `httpapi.TestTheSignInSurfaceAnswersTheSameEnvelopeWithoutAVersionedCode` (шесть статусов). Отвергнуто с доводом: расширение `ErrorCode` (значения, недостижимые на описываемой им поверхности, ломают инвариант «код называет свой статус») и словарь в компаньоне (документ, не нормативный для формы, стал бы нормативным с чёрного хода) | fixed(P7, дерево сессии) | кросс-модельное ревью P7 (линза скоупа) |
|
||
| PD-255 | doc | info | `internal/pgstore/migrations/00016_read_surface.sql`, `internal/pgstore/readmodel.go` | **Плотность комментариев выше нормы зоны** («одна-две строки почему», владелец 26.07): миграция 00016 — 250 строк, из них около половины проза; у `readmodel.go` многие символы несут абзацы. Часть прозы несущая (порядок delete/insert в двух таблицах — ровно то, на чём пак и споткнулся), часть — эссе. Подрезано самое тяжёлое; остальное — предмет решения владельца о норме, а не тихой правки | open | кросс-модельное ревью P7 (линза энтропии) |
|
||
| PD-256 | bug | info | `internal/ingest/export.go` `DecodeExport` | **Сигнал дрейфа конфигурации движка не читается.** `tmctl export` отдаёт `config_drift`/`current_snapshot` — «текущая конфигурация рендерит не тот снапшот, что несут сохранённые строки», то есть диспозиции могут не описывать то, что прогон делал. Платформа материализует текст без этого признака: на проводе места для него нет (и не должно быть — это операторский факт), но в логе материализатора он был бы уместен | open | кросс-модельное ревью P7 (линза шва) |
|
||
| PD-245 | standards | info | `internal/ingest/supervisor.go` | **У дев-супервизора нет ни одного потребителя вне собственных тестов** (`grep` по зоне): это дев-путь D39.106 §3, который пережил постройку продового шва. Его чинят и держат в шаге с продовым (PD-224, PD-237) — но либо он должен быть подключён к дев-режиму демона, либо снят вместе со своими тестами; сейчас это код, который стоит сопровождения и ничего не обслуживает | open | кросс-семейное ревью дофикса P6 (линза шва) |
|
||
| PD-218 | bug | info | `internal/pgstore/migrations/00015_seam_ceiling_and_units.sql` | **Down-путь 00015 падает на данных, которые накатанная схема уже допускает:** он сужает `runs_paused_reason_check` обратно к одному значению, а строки с `daily_ceiling`/`ceiling_unknown` к этому моменту существуют. Откат транзакционный, поэтому падение ничего не портит, но плана отката ниже 15 нет — как и ниже 5 (`deploy/README.md`). Лечится либо переводом таких строк в down-пути, либо честной записью «ниже 15 не откатываемся» | open | приёмка P6 (дофикс, ФП-7) |
|
||
| PD-220 | hardening | info | `internal/config/config.go` `Load` | **Резерв имени `dev` сравнивается байт-в-байт:** `TM_PLATFORM_OIDC_PROVIDER=Dev` проходит гейт. Сегодня инертно, и ровно по той же причине: Postgres сравнивает `identities.provider` тоже байт-в-байт, поэтому в пространство имён дев-входа такой издатель не попадает. Станет опасным в день, когда сравнение личности станет регистронезависимым | open | приёмка P6 (дофикс, ФП-7) |
|
||
| PD-221 | bug | info | `internal/runs/spawn.go` `engineStreamID`, `internal/pgstore/runs.go` `EngineStreamID` | **Имя потока переиспользуется при повторном спавне ТОЙ ЖЕ попытки:** оно детерминировано по паре (прогон, номер попытки), а движок отвергает id, который уже писал события этой книги, и чеканит свой (`store.EventsUsed`, `pipeline/events.go`). Тогда платформа не узнаёт собственный поток и живёт на медленном ре-синке — свежесть, не деньги. Ре-спавн одной попытки бывает после отданной назад заявки на спавн | open | приёмка P6 (дофикс, ФП-7) |
|
||
| PD-222 | standards | info | `cmd/tmplatformctl/seed.go` | **HTTP-променад сида не покрыт тестом:** вход, грант, загрузка и ожидание интейка проверены только живым прогоном на стенде (P6), автоматически — лишь разбор аргументов (`TestASubjectThatIsOnlyPaddingIsNoSubject`). Дев-инструмент, но именно он — единственный потребитель контракта в репозитории, и его поломка видна только тому, кто поднимет стенд | open | приёмка P6 (дофикс, ФП-7) |
|
||
|
||
## Принятый риск
|
||
|
||
Не дефекты, а решения: цена названа и принята, чтобы это не выяснилось молчанием.
|
||
|
||
| ID | Класс | Серьёзность | Где | Суть | Статус | Источник |
|
||
|---|---|---|---|---|---|---|
|
||
| PD-22 | hardening | info | `deploy/` | Ограничителя одновременных соединений нет ни в процессе, ни описанного edge-прокси: `ReadTimeout` ограничивает УДЕРЖАНИЕ одного соединения 30 секундами, но не их число. Осознанно оставлено деплой-слою (`LimitNOFILE`, edge) — строка заведена, чтобы это было решением, а не забывчивостью | accepted-risk(платформа P1, 05.08) | самопроверка P1 |
|
||
| PD-42 | hardening | info | `internal/login/login.go` | Лимит `/auth/login` глобальный: один хост держит ведро пустым и выключает вход всем (замерено: 8 отказов из 10 у «легитимного» пользователя при фоне 5 rps). Пер-адресный лимит здесь неверен, пока нет доверенного edge-прокси — за прокси RemoteAddr один на всех. Место лимита — edge | accepted-risk(платформа P1, 05.08) | ревью безопасности (исполнением) |
|
||
| PD-71 | bug | info | `internal/pgstore/migrations/00005_identity_oauth.sql:72` | **Down-путь `00005` не исполним на данных, которые его же up-путь делает законными**, поэтому откат ниже версии 5 недоступен. Он восстанавливает `users_email_key` и `email NOT NULL`, а боевой код пишет `email = NULL` у неподтверждённой личности и кладёт один подтверждённый адрес на два аккаунта (следствие «почта не ключ»). **Перепроверено моим прогоном, не принято со слов ревью:** три реальных аккаунта (один с `email = NULL`, два с общим подтверждённым адресом) — `DownTo(5)` проходит, `DownTo(4)` падает с `could not create unique index "users_email_key" (SQLSTATE 23505)`; первым срабатывает индекс, до `NOT NULL` выполнение не доходит. Данные целы — down транзакционный, `Up()` вернул схему на версию 8 со всеми тремя аккаунтами, — но плана отката ниже 5 не существует. Править `00005` запрещает append-only, а чужой down-текст новая миграция не заменяет — **принято как ЦЕНА ПРАВИЛА:** записано в `STACK_DECISIONS §8` и в `deploy/README.md` разделом «Откат релиза: не ниже версии 5», чтобы оператор не узнал это в момент отката | accepted-risk(зона P2, 05.08) | ревью P2 (линза sql-money) |
|
||
| PD-179 | hardening | info | `cmd/tmplatformd/main.go` `serveMetrics`, `deploy/` | **Эндпоинт `/metrics` не аутентифицирован и защищён только адресом привязки.** Дефолт `127.0.0.1:9464`, то есть снаружи недостижим; экспозиция несёт операционную форму деплоя (глубина очереди, число прогонов, возраст холдов), но не пользовательские данные и не деньги. Второй модели авторизации ради скрейпера зона не заводит — это ровно тот довод, по которому админ-поверхность стала CLI (§10). Риск принят: оператор, поднявший `TM_PLATFORM_METRICS_ADDR` на внешний адрес, открывает её сам, и об этом сказано в `deploy/README.md` | accepted-risk(платформа P5, 11.08) | сессия P5 |
|
||
|
||
## Закрытые ратификацией
|
||
|
||
Строки, у которых лечением был не код, а решение владельца контракта или оркестратора.
|
||
|
||
| ID | Класс | Серьёзность | Где | Суть | Статус | Источник |
|
||
|---|---|---|---|---|---|---|
|
||
| PD-59 | bug | info | `docs/platform-PROGRESS.md`, вопрос оркестратору №4 | **Канал не меняем — но решает это не тот довод, который обсуждали.** Вопрос вынесен абстрактно (без контекста репозитория) двум независимым агентам, с доступом в сеть и без. По каналу они РАЗОШЛИСЬ, зато независимо сошлись на трёх вещах, которых не было ни в записке зоны, ни в первых трёх редакциях приёмки. **(1) SIGPIPE зависит от НОМЕРА дескриптора** (`os/signal`: обрыв на fd 1/2 убивает процесс, на любом другом — возвращает `EPIPE`). **Замерено:** поток на fd 1 → ребёнок УБИТ `broken pipe`; на fd 3 → `write` вернул EPIPE и процесс доработал до конца. Для нас это деньги: сегодня падение платформы убивает движок на следующей же записи события, а после переезда движок станет сиротой и часами будет жечь оплаченные вызовы, пока холд висит в леджере и некому его закрыть. Свойство несущее и нигде не записано. **(2) Настоящая защита — не выбор канала, а перехват на уровне дескриптора** в `main` движка: `dup(1)` в приватный fd, затем `dup3(2,1,0)`. Он герметичен там, где предложенный приёмкой `os.Stdout = os.Stderr` дыряв: переживает `var out = os.Stdout` в зависимости, cgo и унаследованный fd 1 у внуков. **(3) Дискриминатор, при котором переезд был бы прав** — «ребёнок исполняет чужой код, наследующий stdio». **Проверено: у нас нет** — `grep` по `backend/` не находит ни одного `exec.Command` вне тестов и ни одного cgo. Плюс сверено: ловушка `bufio.Scanner`, которую оба назвали самым вероятным латентным багом (переполнение строки читается как чистый EOF), у нас закрыта — `Buffer` поднят до 1 МиБ и `sc.Err()` проверяется (`decoder.go:45,115`) | **закрыт ратификацией**, работа уходит строкой 103 | приёмка P1 (четвёртая итерация: два независимых агента + замер SIGPIPE) |
|
||
|
||
## Закрытые — эра P1 (вход · кредиты · админ-CLI)
|
||
|
||
| ID | Класс | Серьёзность | Где | Суть | Статус | Источник |
|
||
|---|---|---|---|---|---|---|
|
||
| PD-1 | hardening | minor | `internal/pgstore/pg_test.go:89` | Свойство «в БД только SHA-256, не токен» НЕ запинено тестом: посадка «`Digest` возвращает плейнтекст» выживает — тест сверяет хранимое через тот же `auth.Digest` (self-consistent). Нужен тест с НЕЗАВИСИМО вычисленным хешом либо ассерт «плейнтекст в БД не находится» — **закрыто:** `internal/pgstore/pg_test.go` — `TestStoredCredentialIsAHashNotTheToken`: оракул SHA-256 считается в тесте, плюс поиск плейнтекста в отрендеренной строке. Посадка «`Digest` возвращает плейнтекст» ПАДАЕТ (проверено) | fixed(P1, дерево сессии) | приёмка P0 (посадка №1) |
|
||
| PD-2 | vuln | **major, ЖИВАЯ (не латентная)** | `cmd/tmplatformd/main.go:73-82` | Нет `ReadTimeout` ⇒ соединения пиннятся уже СЕГОДНЯ, без единой body-принимающей ручки: `net/http` дренирует непрочитанное тело <256 КБ ВНУТРИ `chunkWriter.writeHeader` до отправки заголовка ответа (`net/http/server.go:1389-1435`), и этот чтение-шаг наследует отсутствующий дедлайн. **Репродуцировано оркестратором на собранном бинаре:** 50 полу-кормленных POST на охраняемый `/v0/*` → сервер отработал и залогировал 50×401 `ms:0`, клиенты получили НОЛЬ байт, fd 7→57 и держались, пока не закрыл КЛИЕНТ (агент-скептик независимо пинил 500 соединений). Ограничителя соединений и документированного edge-прокси в зоне нет. Фикс — одна строка (`ReadTimeout`; для будущего SSE — per-conn дедлайны через `ResponseController`). Вторая половина (`MaxBytesReader`) сегодня не эксплуатируема (ни один хендлер не читает body) — гейт P1: закрыть ДО первого POST-хендлера — **закрыто:** `ReadTimeout` 30 с в `httpapi.DefaultTimeouts`; пин — `TestHalfFedRequestIsDroppedByTheServer` на РЕАЛЬНОМ `http.Server`. Живая проба: полу-кормленный POST теперь отпускается через 30.0 с (был бесконечно). Вторая половина закрыта `LimitBody` на поддереве `/v0` и `/auth`. Побочное обязательство «`ReadTimeout` рубил бы и SSE» ОПРОВЕРГНУТО в P2 (PD-51/PD-63): `net/http` снимает дедлайн сам, помощник `ClearReadDeadline` удалён как воспроизводивший ровно этот дефект; поток пинит `TestStreamOutlivesReadTimeout` | fixed(P1, дерево сессии) | приёмка P0 (security-линза + скептик + собственная репродукция) |
|
||
| PD-3 | bug | minor | `internal/httpapi/middleware.go:57` | `Recover` логирует сырой `r.URL.Path` на ERROR — id книг/прогонов утекают в лог, против собственной дисциплины AccessLog (route-pattern, не путь) — **закрыто:** `Recover` логирует `route`, не `r.URL.Path` | fixed(P1, дерево сессии) | приёмка P0 (security-линза) |
|
||
| PD-4 | hardening | minor | `internal/pgstore/sessions.go:41` | WHERE у `Touch` слабее, чем у `Lookup` (нет `idle_expires_at > now`): прямой вызов воскресил бы idle-истёкшую сессию. Через `Require` недостижимо (Touch только после успешного Lookup) — одна строка защиты в глубину — **закрыто:** клауза `idle_expires_at > $2` добавлена; пин — `TestTouchCannotResurrectAnIdleExpiredSession` (посадка падает) | fixed(P1, дерево сессии) | приёмка P0 (security-линза) |
|
||
| PD-5 | bug | minor | `internal/auth/middleware.go:36,45` | Ошибки стора невидимы: сбойный `Lookup` → 401 без единой строки лога (аутентификационный DB-outage выглядит как шторм 401), `Touch` глотается `_ =`. На проводе различать нельзя (оракул) — но лог обязан различать — **закрыто:** `Authenticator.Log`: сбой `Lookup` (кроме `ErrNoSession`) и сбой `Touch` уходят в ERROR с `request_id`; на проводе по-прежнему неразличимо | fixed(P1, дерево сессии) | приёмка P0 (security+blind линзы) |
|
||
| PD-7 | bug | info | `internal/pgstore/sessions.go:78` | `DeleteExpiredSessions` никем не вызывается — свип запланировать в P1 (периодическая джоба воркера) — **закрыто:** свип сессий раз в час в демоне (`sweepSessions`), плюс свип брошенных логинов раз в 15 минут | fixed(P1, дерево сессии) | приёмка P0 |
|
||
| PD-8 | hardening | info | `internal/auth/session.go:18` | Писателя куки ещё нет; `__Host-` требует Secure ⇒ локальный dev по HTTP куку не поставит. Решить формой в P1 (dev-профиль), префикс не ослаблять в проде — **закрыто:** `auth.Cookies{Insecure}` — dev-профиль меняет ИМЯ вместе с атрибутами (`tm_session` без `__Host-`), `TM_PLATFORM_INSECURE_COOKIES=1`, демон предупреждает в лог | fixed(P1, дерево сессии) | приёмка P0 |
|
||
| PD-9 | bug | minor | `cmd/tmplatformd/main.go:81` | `BaseContext` возвращает signal-контекст ⇒ SIGTERM мгновенно рубит контексты ВСЕХ in-flight запросов, и 15-секундный дренаж `Shutdown` мёртв для ctx-aware хендлеров. Fix: BaseContext без signal-ctx; сигнал ведёт только Shutdown — **закрыто:** `BaseContext` — собственный контекст, отменяется ПОСЛЕ `Shutdown`; пин — `TestShutdownDrainsInFlightRequests` (посадка «BaseContext = сигнальный ctx» падает) | fixed(P1, дерево сессии) | приёмка P0 (faults-линза) |
|
||
| PD-10 | bug | minor | `internal/ingest/decoder.go:42,61,67` | Три ужесточения декодера: (а) `hello` с пустым `engine_run_id` принимается — а это половина ключа идемпотентности; (б) seq самого hello не пинится к 1 — потеря пре-хендшейковых строк недетектируема; (в) mid-stream `hello` (любой версии, вкл. мажор 9.9) уходит в Sink как обычное событие — version-гейт держит только строку 1 — **закрыто:** три ужесточения + `ErrBadHandshake`/`ErrRepeatedHello`; пины — `TestHandshakeMustIdentifyTheStream` и `FuzzDecoder` (4.4 млн исполнений, инварианты — оракулы) | fixed(P1, дерево сессии) | приёмка P0 (faults-линза) |
|
||
| PD-11 | bug | minor | `internal/pgstore/migrations/00002_readmodel.sql:137,177` | Неиндексированные FK-каскады: `notes.chapter_id` и `bank_decisions.term_id` — каскадное удаление сканирует таблицы — **закрыто:** `notes_chapter_idx` + `bank_decisions_term_idx`; `notes.unit_id` уже был | fixed(P1, дерево сессии) | приёмка P0 (faults-линза) |
|
||
| PD-12 | bug | info | `internal/ingest/supervisor.go:82-84` | Сбой Sink в начале прогона ⇒ платформа дренирует ВЕСЬ оставшийся поток в `io.Discard` часами: ceiling/bank_stop-события выбрасываются, никто не оповещён. Нужна политика «БД платформы упала посреди прогона» (ретраи синка / деградация с алармом) — дизайн-вопрос P1 — **закрыто:** сбой синка ОСТАНАВЛИВАЕТ прогон (`stop()` после `Ingest`), а не дренирует его в `io.Discard`; пин — `TestFailingSinkStopsTheRun`. Политика ретраев самого синка — при постройке материализатора | fixed(P1, дерево сессии) | приёмка P0 (faults-линза) |
|
||
| PD-13 | bug | info | `internal/ingest/supervisor.go:64` | Краш платформы осиротляет процесс движка: ни process-group, ни pidfile, ни пути реаттача (поток невосстановим, повторный спавн упрётся в EXCLUSIVE-лок). Дизайн супервизии P1 — **закрыто:** группа процессов (`Setpgid` + сигнал группе) закрывает обычную остановку; краш платформы закрывает cgroup юнита — `deploy/tmplatformd.service` (проверен `systemd-analyze verify`, живого прогона под systemd не было) | fixed(P1, дерево сессии) | приёмка P0 (faults-линза) |
|
||
| PD-14 | hardening | info | `internal/httpapi/server.go:79` | `readyz`: ping без собственного таймаута (WriteTimeout нет намеренно — SSE), эндпоинт неаутентифицирован и без rate-limit — задушить дешёво; таймаут на ping + прикрыть на ops-слое — **закрыто:** собственный таймаут 2 с на ping; rate-limit на ops-слое (edge), в зоне не строим | fixed(P1, дерево сессии) | приёмка P0 |
|
||
| PD-15 | bug | info | `internal/ingest/resync.go:32` | Деньги в ре-синке — float64, а `usage_windows` хранит micro-USD именно против дрейфа: дрейф входит шагом раньше (JSON-парс + суммирование дельт). Принять осознанно или считать в целых — **закрыто:** деньги на шве — `money.MicroUSD` через `big.Rat`, округление ВВЕРХ; пины — `TestSpendConvertsExactlyAndRoundsUp`, `TestParseUSDIsExactAndRoundsUp` | fixed(P1, дерево сессии) | приёмка P0 (faults-линза) |
|
||
| PD-16 | bug | minor | `internal/httpapi/server.go:81` | `readyz` глотает ошибку ping вопреки собственному комменту «the reason stays in the log» — лога нет — **закрыто:** ошибка ping уходит в ERROR | fixed(P1, дерево сессии) | приёмка P0 (blind-линза) |
|
||
| PD-17 | bug | minor | `Makefile:41-42` | Баннер «did NOT run (no database)» печатается и при ПРОГНАННЫХ БД-тестах (безусловный); батарея гоняет сьют дважды ради имён скипов (второй прогон без -race) — **закрыто:** один прогон сьюта под `-race`, баннер печатается только при наличии скипов | fixed(P1, дерево сессии) | приёмка P0 (blind-линза) |
|
||
| PD-18 | bug | info | `internal/pgstore/migrations/00002_readmodel.sql:9,139` | Коммент шапки «engine vocabulary never crosses this seam» противоречит `notes.reason` (движковая причина хранится, не проецируется); коммент переписать честно — **закрыто:** шапка миграции переписана: исключение (`notes.reason`) названо там же | fixed(P1, дерево сессии) | приёмка P0 (canon-линза) |
|
||
| PD-19 | bug | info | `internal/ingest/resync.go:44` | `WorstFlagReason` задокументирован «stored», а колонки в `chapters` нет — доккоммент или схема, одно из двух — **закрыто:** `WorstFlagReason` убран из аллоулиста — в контракте v0 у главы нет читателя для него | fixed(P1, дерево сессии) | приёмка P0 (canon-линза) |
|
||
| PD-20 | bug | minor | `internal/ingest/supervisor.go:78` | Один сигнал остановки ТЕРЯЕТСЯ, если послан в первые миллисекунды жизни ребёнка: воспроизведено на стенде отдельным экспериментом (8 запусков, промах на нулевой задержке) и как флейк собственного теста PD-12 (1 падение из 3). Последствие серьёзнее самого промаха: единственный оставшийся механизм — SIGKILL по `WaitDelay`, а движок держит ЭКСКЛЮЗИВНЫЙ лок на файле проекта, и после kill лок остаётся — **закрыто:** `askToStop` повторяет SIGINT на 30/120/400 мс с проверкой «процесс ещё наш» через `os.Process`; пин — `TestFailingSinkStopsTheRun` (25 прогонов подряд зелёные, до фикса падал) | fixed(P1, дерево сессии) | самопроверка P1 (флейк собственного теста) |
|
||
| PD-21 | vuln | minor | `internal/login/login.go:safeReturnTo` | Открытый редирект в `?return_to`: `/\evil.example` проходил проверку — `url.Parse` читает это как обычный путь, а браузер нормализует `\` в `/` и получает протокол-относительный URL, то есть чужой хост. Найдено ПОСАДКОЙ мутации: ослабление проверки тест пережило, значит тест был слабый — **закрыто:** аллоулист (первый символ `/`, второй не `/`, обратных слэшей нет, `Scheme`/`Host`/`Opaque` пусты), тест переписан на «каждый враждебный вход даёт ПУСТО»; посадка теперь падает. Дефект не покидал дерево сессии | fixed(P1, дерево сессии) | самопроверка P1 (посадка мутации) |
|
||
| PD-24 | bug | **major** | `internal/pgstore/migrations/` | Переиспользование номера миграции: удалённый `00003_usage.sql` и новый `00003_credits.sql` заняли одну версию. goose применяет ТОЛЬКО по номеру (ни имени, ни хеша), поэтому база, доехавшая до версии 3, рапортует «migrations applied» и не получает ни одной новой таблицы, вход и кредиты падают в рантайме, а `DownTo` на ней ломается навсегда. Обоснование «до деплоя правим на месте» было допущением без механизма — **закрыто:** выпущенные 00001–00003 возвращены байт-в-байт, новое приехало номерами 00004–00007; гейт `migrations.sha256` + `TestReleasedMigrationsAreUnchanged`; апгрейд со старого релиза пинится `TestDatabaseAtAnOlderReleaseCatchesUp` | fixed(P1, дерево сессии) | ревью «вне карты» (исполнением) |
|
||
| PD-25 | bug | **major** | `internal/pgstore/credits.go`, `00007_credits.sql` | Ключ идемпотентности леджера не содержал `user_id`: грант с ключом, потраченным на другом аккаунте, молча проглатывался, а CLI печатал «granted». Плюс каскад удаления книги уносил ОТКРЫТУЮ резервацию, оставляя строку `hold` в леджере (деньги списаны, вернуть нечем), после чего освободившийся `engine_run_id` давал холд БЕЗ списания, а его релиз печатал деньги — **закрыто:** ключ стал `(user_id, source, source_id)`, пустой ключ запрещён DDL, `book_id` перешёл на составной FK к `books(id, owner_id)` с `on delete restrict`, `appendLedger` возвращает «применилось», `Hold` падает при повторе. Пины: `TestBookWithAnOpenHoldCannotBeDeleted`, `TestSecondHoldOnOneAttemptIsRefused`, `TestGrantIsIdempotentBySource` | fixed(P1, дерево сессии) | ревью денежного пути (исполнением) |
|
||
| PD-26 | bug | minor | `internal/pgstore/credits.go` | Инверсия порядка блокировок Hold↔Settle/Release: 41 взаимоблокировка на 300 раундов, замерено. `Settle`/`Release` брали строку резервации раньше баланса — **закрыто:** `lockBalance` первым во всех операциях | fixed(P1, дерево сессии) | ревью денежного пути (исполнением) |
|
||
| PD-27 | bug | minor | `internal/pgstore/credits.go` | `Settle` принимал любую сумму: одно завышенное `committed_usd` уводило баланс в минус, дальше каждый прогон получал `ErrInsufficientCredit` без диагностики — **закрыто:** расчёт capped потолком холда, факт записан в `note`; пин `TestSettlementIsCappedAtTheHold` | fixed(P1, дерево сессии) | ревью денежного пути · ревью «вне карты» |
|
||
| PD-28 | bug | minor | `internal/ingest/supervisor.go` | `cmd.Wait()` на отменённой команде возвращает `context.Canceled`, а не `*ExitError`, поэтому исход читался как `failed`: штатный SIGTERM пометил бы ВСЕ идущие прогоны провалившимися — **закрыто:** исход из `ProcessState`, факт остановки едет в ошибке; пин `TestStoppedRunKeepsTheEnginesOutcome` | fixed(P1, дерево сессии) | ревью стиля (клейм) + собственная проверка исполнением |
|
||
| PD-29 | vuln | minor | `internal/login/login.go` | `GET /auth/callback` — неаутентифицированная ручка, ПИШУЩАЯ в БД, без лимита и без ретеншена: замерено 2000 строк за 2.28 с с одного хоста (~76 млн строк/сутки), строки отказов недостижимы через API и не удалялись никогда — **закрыто:** лимитер на колбэке, ретеншен журнала 180 дней свипом | fixed(P1, дерево сессии) | ревью безопасности (исполнением) |
|
||
| PD-30 | vuln | minor | `internal/pgstore/identity.go` | Грант фри-тира выдавался за каждую новую пару `(provider, subject)` без учёта `email_verified`: провайдер с саморегистрацией превращал каждый новый `sub` в $5, потолок задавал только глобальный лимитер (~$864k/сутки на бумаге) — **закрыто:** грант только подтверждённой личности — НЕПОДТВЕРЖДЁННАЯ создаёт аккаунт с нулём, и его начисляют руками из админки. ⚠ Исправлено 08.08 (PD-104): прежняя редакция этой ячейки говорила «аккаунт создаётся с нулём» без оговорки и противоречила коду — ПОДТВЕРЖДЁННАЯ личность получает автогрант `TM_PLATFORM_SIGNUP_GRANT_USD` (дефолт $5, `config.go`), закрыт был только путь саморегистрации. ⚠ Продуктовое следствие — вопрос владельцу в журнале | fixed(P1, дерево сессии) | ревью безопасности (исполнением) |
|
||
| PD-31 | bug | minor | `internal/login/login.go` | `discover` держал мьютекс на время сетевого вызова без таймаута: шесть параллельных входов при медленном IdP заняли 4/8/12/16/20/24 с вместо ~4 — **закрыто:** запрос вне лока, свой таймаут 5 с | fixed(P1, дерево сессии) | ревью безопасности (исполнением) |
|
||
| PD-32 | vuln | minor | `internal/login/login.go`, `cmd/tmplatformd/main.go` | Имя провайдера захардкожено `"google"` независимо от issuer, а `State.Provider` писался и не сверялся: смена issuer тихо кладёт чужие `sub` в старое пространство имён (новые аккаунты, старые недостижимы), а при двух провайдерах стейт одного редимится колбэком другого (IdP mix-up) — **закрыто:** `TM_PLATFORM_OIDC_PROVIDER`, сверка `st.Provider` в колбэке | fixed(P1, дерево сессии) | ревью безопасности · ревью «вне карты» |
|
||
| PD-33 | vuln | minor | `internal/auth/csrf.go` | Требование `X-TM-Client` снималось ЛЮБЫМ заголовком `Authorization`, включая мусорный: покрытие CSRF-слоя выбирал атакующий (сегодня упиралось в 401, но пережило бы любое послабление в `present`) — **закрыто:** снимает только валидный Bearer, через ту же функцию, что аутентифицирует | fixed(P1, дерево сессии) | ревью безопасности (исполнением) |
|
||
| PD-34 | bug | minor | `internal/httpapi/serve.go`, `cmd/tmplatformd/main.go` | Две регрессии остановки: второй SIGTERM больше не прерывал дренаж (процесс жил ровно 15 с), а просроченный дренаж возвращал ошибку и давал exit 1 — при `Restart=on-failure` штатная остановка читается systemd как крах — **закрыто:** сигнал разрегистрируется при начале дренажа, просрочка логируется WARN и даёт exit 0, добавлена строка `stopped` | fixed(P1, дерево сессии) | ревью «вне карты» (исполнением) |
|
||
| PD-35 | bug | minor | `internal/httpapi/middleware.go` | Лимит тела стоял самым внешним слоем, поэтому обещанное «ручка загрузки регистрирует свой, больший лимит» не работало: вложенный `MaxBytesReader` не может ослабить внешний, а контракт требует загрузку книги (23 МБ) — **закрыто:** лимит стал пер-маршрутным аргументом `guard` | fixed(P1, дерево сессии) | ревью «вне карты» |
|
||
| PD-36 | hardening | minor | `internal/httpapi/middleware.go` | Не было HSTS, CSP и запрета фрейминга; `__Host-` защищает запись куки, а не первый навигационный запрос — **закрыто:** `Content-Security-Policy: default-src 'none'; frame-ancestors 'none'`, `X-Frame-Options: DENY`, HSTS в прод-профиле (в dev выключен: пин политики на localhost — долгая ошибка) | fixed(P1, дерево сессии) | ревью безопасности |
|
||
| PD-37 | bug | minor | `internal/login/login.go` | `safeReturnTo` заявляла защиту, которой не давала: проверка обратного слэша работала по уже раскодированной строке, а браузер декодирует цель редиректа ещё раз (`/%5c/evil.example`). Эксплуатируемого редиректа не получено, но три проверки из четырёх держались на поведении браузера — **закрыто:** проверка обеих форм, теста добавлены процент-кодированные входы | fixed(P1, дерево сессии) | ревью безопасности (исполнением) |
|
||
| PD-38 | hardening | info | `internal/pgstore/sessions.go`, `00005_identity_oauth.sql` | Отозванные сессии не удалялись до абсолютного срока (90 дней); журнал входов каскадно стирался вместе с аккаунтом, хотя объявлен доказательством для расследования — **закрыто:** свип берёт отозванные и idle-протухшие, `login_events.user_id` перешёл на `on delete set null` (строка анонимизируется, не уничтожается) | fixed(P1, дерево сессии) | ревью безопасности |
|
||
| PD-39 | bug | info | `internal/money/money.go` | Док обещал округление «от нуля», код округляет к `+∞`; отрицательные дроби не были покрыты тестом вовсе. Плюс `USD()` на `MinInt64` печатал мусор, а вход не имел ограничения длины (2 МБ → 6.1 с и сообщение об ошибке на 2 МБ) — **закрыто:** док приведён к коду, отрицательные кейсы запинены, потолок длины 64 символа, рендер без отрицания | fixed(P1, дерево сессии) | ревью денежного пути · ревью стиля |
|
||
| PD-40 | bug | info | `internal/ingest/resync.go` | Отсутствующий/`null`/пустой `committed_usd` декодировался в `0` — неотличимо от «попытка не стоила ничего»; на пути расчёта это освободило бы холд и не списало ничего — **закрыто:** `Spend` стал указателем, пустая строка — ошибка | fixed(P1, дерево сессии) | ревью «вне карты» |
|
||
| PD-41 | bug | info | `internal/login/login.go`, `internal/httpapi/` | Поверхность `/auth/*` отвечала stdlib-телами `text/plain` на 404/405 вопреки нормативу «ответы problem+json»; ошибки стора и сработавший лимитер не логировались; паника писалась без стека; успешный вход не оставлял следа, а недоступность провайдера классифицировалась как «токен отвергнут» — **закрыто:** метод проверяется в обёртке с problem+json, добавлены `login succeeded`, `sign-in rate limit engaged`, `provider_unreachable`, стек паники, `login_start_id` для склейки двух половин входа | fixed(P1, дерево сессии) | ревью логов (исполнением) |
|
||
|
||
## Закрытые — эра P2 (фикс-пак приёмки P1)
|
||
|
||
| ID | Класс | Серьёзность | Где | Суть | Статус | Источник |
|
||
|---|---|---|---|---|---|---|
|
||
| PD-46 | hardening | minor | `internal/httpapi/serve.go:33-40` | **Запинена ПРОВОДКА `ReadTimeout`, но не ЗНАЧЕНИЕ, с которым едет демон.** Тесты строят свой `Timeouts` (`fastTimeouts`), поэтому посадка «`DefaultTimeouts().Read = 0`» проходит ВСЮ батарею зелёной — а `main.go:121` берёт именно `DefaultTimeouts()`. Посадка «убрать `ReadTimeout` из `NewServer`» ловится (проверено), то есть дыра ровно в дефолтах. Это форма, в которой PD-2 пережил P0: свойство проверено не на том объекте, который едет в прод. Фикс — тест на сами значения `DefaultTimeouts` — **закрыто:** `httpapi.TestTheServerTheDaemonRunsHasEveryDeadlineSet` утверждает не литералы, а сам `*http.Server`, который строит `NewServer(…, DefaultTimeouts())`: каждый дедлайн >0, `WriteTimeout` ОБЯЗАН быть нулём (иначе резал бы SSE), `ReadHeaderTimeout <= ReadTimeout`, grace >0. Закрывает обе половины — значение и проводку. Пять посадок поймано поимённо: `DefaultTimeouts().Read=0`, снятие `ReadTimeout` из `NewServer`, снятие `IdleTimeout`, добавление `WriteTimeout` «для симметрии», снятие `Unwrap` | fixed(P2, дерево сессии) | приёмка P1 (посадка M23/M43) |
|
||
| PD-47 | bug | minor | `internal/login/login_test.go:266` | **Закрытие PD-37 заявлено неверно:** «в тесты добавлены процент-кодированные входы» — их там нет (список: `//evil.example/`, `https://…`, `http:/…`, `/\evil.example`, `/\/evil.example`, `/\tevil`, `evil.example`, ``). Посадка «судить только сырую форму, без второго декода» батарею ПЕРЕЖИВАЕТ. Побочно: посадка «убрать обратный слэш из `ContainsAny`» тоже переживает — на тестовых входах её дублирует проверка `s[1]`. Эксплуатируемого редиректа нет; не запинена именно та защита, ради которой заведён PD-37 — **закрыто:** в таблицу добавлены процент-кодированные входы (`/%5c/`, `/%5C/`, `/%09`, `/%00`, `/%0d%0a`) — их ловит ТОЛЬКО второй декод — и `/%2f/evil.example`, который ловит ТОЛЬКО проверка `s[1]` на декодированной форме; плюс `FuzzSafeReturnTo`, который пинит СВОЙСТВО независимым оракулом (`url.URL.ResolveReference` после браузерной нормализации `\`→`/`), 3,1 млн исполнений без контрпримера. Посадки «судить только сырую форму», «убрать класс символов», «убрать protocol-relative» падают каждая. ⚠ Побочно установлено: условия `u.Scheme/u.Host/u.Opaque` НЕДОСТИЖИМЫ как отказ (при `raw[0]=='/'` схемы и Opaque не бывает, Host требует `//`), пин на них невозможен — оставлены бэкстопом, это названо в коде | fixed(P2, дерево сессии) | приёмка P1 (посадки M15/M16) |
|
||
| PD-48 | hardening | minor | `internal/login/login.go:268-271` | **Правило PD-30 «грант только подтверждённой личности» не запинено ничем:** удаление `if !claims.EmailVerified { grant = 0 }` проходит все тесты `internal/login`. `pgstore.TestUnverifiedAddressStaysOffTheAccount` пинит другое свойство (адрес не поднимается на аккаунт), денежное — никто. По правилу шапки этого файла PD-30 закрытым не считается — **закрыто:** `login.TestSignupGrantGoesOnlyToAVerifiedIdentity` гоняет обе ветки через настоящий поток и сверяет САМ грант, дошедший до стора (`memStore` теперь его запоминает — раньше отбрасывал, потому правило и было незапинено). Посадка «убрать условие `EmailVerified`» падает | fixed(P2, дерево сессии) | приёмка P1 (посадка M14) |
|
||
| PD-49 | hardening | minor | `internal/login/login.go:239-242` | **Вторая половина PD-32 не запинена:** удаление сверки `st.Provider != h.cfg.Provider` проходит все тесты. Сегодня провайдер один, поэтому свойство латентное — но заведено оно ровно под появление второго (IdP mix-up) — **закрыто:** `login.TestStateFromAnotherProviderIsRefused` подменяет провайдера в сохранённой строке состояния — форма, которую даёт появление второго провайдера, — и требует 400, отсутствия сессии, причины `state_from_another_provider` в журнале и НУЛЯ обращений к token endpoint. Посадка «убрать сверку» падает. Норму при этом закрывает не она, а PD-57 | fixed(P2, дерево сессии) | приёмка P1 (посадка M19) |
|
||
| PD-50 | hardening | info | `internal/auth/csrf.go:55-60` | Закрытие PD-33 сформулировано сильнее кода: «снимает только ВАЛИДНЫЙ Bearer» — на деле `Present` только ПАРСИТ, поэтому `Authorization: Bearer <мусор>` требование `X-TM-Client` снимает. Привилегии это не даёт, проверено живой пробой (кука + мусорный Bearer + без заголовка → 401, не хендлер): безопасность держит правило «Bearer побеждает куку» в `Present`, а не «валидность». Посадка «снимать любым непустым Authorization» батарею переживает. Фикс — либо тест, либо честная формулировка доккоммента — **закрыто формулировкой + пином:** доккоммент `cookieUnsafe` переписан на то, что верно (`Present` ПАРСИТ, не валидирует; безопасность держит правило «есть `Authorization` ⇒ кука не участвует», а не валидность). `auth.TestAnAuthorizationHeaderTakesTheCookieOutOfPlay` пинит именно это на пяти формах заголовка; посадка «падать обратно на куку при неразобранном заголовке» падает | fixed(P2, дерево сессии) | приёмка P1 (посадка M30 + живая проба) |
|
||
| PD-51 | bug | minor | `internal/httpapi/serve.go:118-127`, `STACK_DECISIONS §12` | **Механизм заявлен неверно.** Утверждение «`ReadTimeout` убил бы и поток, поэтому стриминговый хендлер ОБЯЗАН снять read-дедлайн» на Go 1.26.5 не подтверждается: `connReader.startBackgroundRead` сам делает `SetReadDeadline(time.Time{})` (`net/http/server.go:687-698`) и для запроса без тела вызывается ДО хендлера (`:2062`). Проверено исполнением на трёх формах запроса (GET без тела · POST с непрочитанным телом · POST с вычитанным телом) — поздний кадр доезжает во всех шести комбинациях, звали `ClearReadDeadline` или нет. Следствие: `TestStreamOutlivesReadTimeout` НЕ МОЖЕТ упасть от выхолащивания `ClearReadDeadline` (проверено); он пинит только `Unwrap` (эта посадка ловится). Код безвреден, ложны обоснование и строка в таблице пинов — **закрыто, и вывод приёмки уточнён исполнением:** механизм подтверждён (`startBackgroundRead` снимает дедлайн сам, `server.go:687-698`, для запроса без остатка тела — до хендлера, `:2059-2062`; по ходу хендлера не перевзводится — проверено по всем call sites). Но «код безвреден» неверно: см. PD-63. `ClearReadDeadline` УДАЛЁН, `STACK_DECISIONS §12` переписан, `TestStreamOutlivesReadTimeout` переписан на настоящее свойство (поток переживает `Read` БЕЗ действий хендлера) и пинит `Unwrap` через ошибку `Flush` | fixed(P2, дерево сессии) | приёмка P1 (посадка M24/M42 + отдельная проба) |
|
||
| PD-52 | hardening | minor | `internal/pgstore/credits.go:233-243` | Порядок блокировок (PD-26) не запинен ни одним тестом — снятие `lockBalance` из `closeReservation` батарею переживает. Дефект воспроизведён приёмкой НЕЗАВИСИМО, в форме, которая действительно даёт цикл: конкурентные `Settle(run-1)` и повторный `Hold(run-1)` — **2 взаимоблокировки на 150 раундов с инверсией, 0 с фиксом**. Регрессионный тест написан приёмкой и лежит готовым к вставке в `docs/platform-PROGRESS.md`, раздел «Ратификация приёмкой P1». ⚠ Замер сессии «41 на 300» воспроизвести не удалось — их нагрузка не описана; принимается СО СЛОВ — **закрыто:** тест приёмки вставлен как `pgstore.TestHoldAndSettleOnTheSameAttemptDoNotDeadlock`. ⚠ Замер приёмки не копировался, а ПЕРЕПРОВЕРЕН на своём стенде (PostgreSQL 18.4): с инверсией падает 5 прогонов из 5, 5–10 взаимоблокировок на 150 раундов; с фиксом 5 прогонов из 5 зелёные. Замер сессии P1 «41 на 300» так и не воспроизведён и остаётся СО СЛОВ | fixed(P2, дерево сессии) | приёмка P1 (посадка M07 + собственная репродукция) |
|
||
| PD-53 | hardening | info | `internal/httpapi/server.go:73-75` | `DefaultMaxBody` не запинен: поднятие лимита поддерева до 1 ГиБ батарею переживает. Пер-маршрутность лимита (PD-35) — тоже только на ревью — **закрыто:** `httpapi.TestBodyCapIsPerRouteBecauseNestingOnlyTightens` фиксирует исполнением ПРИЧИНУ пер-маршрутности — вложенный БОЛЬШИЙ лимит не поднимает внешний, — поэтому возврат общего слоя молча урезал бы аплоуд-маршрут; `TestDefaultBodyCapStaysAContractSizedNumber` держит дефолт в полосе контрактного размера (посадка «1 ГиБ» падает), не превращаясь в change-detector на точное число | fixed(P2, дерево сессии) | приёмка P1 (посадка M26) |
|
||
| PD-54 | bug | minor | `docs/platform-PROGRESS.md:329-353` | В журнале ДВЕ несовместимые формы `GET /v0/usage`: новая кредитная (строка 172) и старая подписочная (строка 329) с `resets_at`, `windows[{period}]` и хранением в `usage_windows` — таблице, которую снесла миграция `00006`. Секция P0-эры не помечена superseded, а S3 идёт читать журнал именно за формой ручки — **закрыто:** подписочное тело ответа УДАЛЕНО из журнала, а не помечено баннером: S3 идёт туда за формой ручки и скопировал бы тело. Осталась одна форма — кредитная, в разделе «Что предлагаем в спеку (S3)»; из П-5 сохранены абзацы, не зависящие от модели денег, ссылка на хранение переведена на `credit_ledger` | fixed(P2, дерево сессии) | приёмка P1 (свип доков) |
|
||
| PD-55 | bug | info | `deploy/tmplatformd.service` | `MemoryMax=2G` объявлен как «bounds the control plane», но ограничивает cgroup ЮНИТА — а по собственному аргументу этого же файла (закрытие PD-13) в этом cgroup живёт каждый ребёнок-`tmctl`. Значит потолок общий на платформу и все идущие прогоны, и OOM-killer выберет самый жирный процесс — движок, который держит ЭКСКЛЮЗИВНЫЙ лок на файле проекта: ровно тот исход, ради которого запрещён SIGKILL. То же про `TasksMax=512`. Латентно до появления воркера. ⚠ Под systemd не проверялось (нет sudo) — вывод из семантики `MemoryMax=`, не из замера — **закрыто:** семантика сверена по man 5 systemd.resource-control («absolute limit on memory usage of the executed processes in this unit… out-of-memory killer is invoked inside the unit»). `MemoryMax=2G` заменён на `MemoryMax=80%` — потолок машины, а не сервиса, как «last line of defense» и без знания о железе; `TasksMax=512` оставлен с честным комментарием, что покрывает платформу и прогоны вместе; ограничение ОДНОГО прогона названо работой воркера (transient scope). Побочно найдено и закрыто следствие, которого в этой строке не было, — PD-64. ⚠ Под systemd не запускалось (нет sudo); `systemd-analyze verify` (systemd 259) — exit 0 | fixed(P2, дерево сессии) | приёмка P1 (ревью деплой-юнита) |
|
||
| PD-56 | bug | info | `internal/pgstore/credits.go:35-63` | `Grant`/`Adjust` на несуществующий аккаунт отдают оператору сырую ошибку Postgres с именем констрейнта (`credit_ledger_user_id_fkey`), тогда как `Balance` на том же входе отдаёт `ErrNoAccount`. Живая проба CLI. Косметика админ-поверхности, но опечатка в id читается как поломка БД — **закрыто:** `appendLedger` мапит нарушение `credit_ledger_user_id_fkey` в `ErrNoAccount`; `pgstore.TestMoneyOperationsAgreeOnAMissingAccount` требует одного ответа от `Grant`/`Adjust`/`Balance`/`ReadAccount`. Посадка «убрать сверку констрейнта» падает | fixed(P2, дерево сессии) | приёмка P1 (живая проба CLI) |
|
||
| PD-57 | hardening | minor | `internal/login/login.go:239-242` | **Защита от IdP mix-up не та, что требует действующая норма.** RFC 9700 §2.1 (OAuth Security BCP, янв. 2025) — клиент SHOULD применять параметр `iss` из авторизационного ответа (RFC 9207) либо иной контрмер НА ОСНОВЕ `iss`; MAY — различные redirect URI на провайдера. Реализована собственная сверка `st.Provider` с `h.cfg.Provider`, а внутри одного хендлера это сравнение конфигурации с самой собой: `start` пишет туда то же значение. `iss` авторизационного ответа не читается вообще (`iss` ID-токена библиотека проверяет — это другой шаг и другой момент). Сегодня не эксплуатируемо: провайдер один, код всегда редимится у него же. Заведено потому, что регистр объявляет PD-32 закрытием «класса IdP mix-up», а против нормы это неверно, и при втором провайдере выбор (`iss` или раздельные redirect URI) должен быть ОСОЗНАННЫМ, а не побочным эффектом конфигурации — **закрыто реализацией нормы, а не обещанием.** Первоисточники сверены: RFC 9700 §4.4.2 («When an OAuth client can only interact with one authorization server, a mix-up defense is not required» — то есть СЕГОДНЯ несоответствия нет, требование включается со вторым сервером), §4.4.2.2 объявляет раздельные redirect URI фолбэком («SHOULD therefore only be used if other options are not available»); альтернатива «`iss` из ID-токена» нам не подходит — при чистом code flow токен приходит уже ПОСЛЕ отдачи кода. Выбран `iss` авторизационного ответа: **Google его шлёт** (`authorization_response_iss_parameter_supported: true`, сверено живьём). Сделано: `auth_states.issuer` (миграция 00008), сверка до обмена кода, отказ на СОРВАННОМ параметре у поддерживающего провайдера (RFC 9207 §2.4). Пин — `login.TestAuthorizationResponseIssuerIsChecked` (4 случая); посадки «убрать вызов», «убрать ветку несовпадения», «убрать ветку сорванного параметра», «потерять issuer в сторе» падают | fixed(P2, дерево сессии) | приёмка P1 (сверка с RFC 9700 §2.1 / RFC 9207) |
|
||
| PD-58 | hardening | minor | `internal/config/config.go:60-61` | **Несоответствие собственной объявленной базовой линии.** `ENGINEERING_STANDARDS §2` берёт ASVS 5.0 L2, а L2 требует ДОКУМЕНТИРОВАТЬ сроки: 7.1.1 (срок бездействия и абсолютный предел + обоснование отклонений от NIST SP 800-63B), 7.1.2 (политика одновременных сессий), 7.1.3/7.6.1 (согласование срока НАШЕЙ сессии со сроком федеративной — у нас наша живёт своей жизнью, RP-initiated/back-channel logout нет). Сроки 14 суток бездействия и 90 суток абсолютных существуют только литералами в коде, обоснования нет ни в одном доке (проверено grep). Механические требования V7 при этом ВЫПОЛНЕНЫ и проверены: 7.2.3 энтропия (256 бит при требуемых 128), 7.2.4 ротация токена на аутентификации, 7.4.1 отзыв, 7.4.2 снос сессий при удалении аккаунта. ⚠ 7.4.5 (системный отзыв админом) покрыт только пер-пользовательским `revoke`; 7.5.2 (пользователь видит свои сессии) — работа П-1 — **закрыто, и значение выровнено вместо сочинения оправдания.** Тексты сверены дословно: ASVS 5.0 7.1.1/7.1.2/7.1.3, 7.6.1/7.6.2 и NIST SP 800-63B-4 §2.1.3 («overall timeout … SHOULD be no more than 30 days at AAL1; an inactivity timeout MAY be applied but is not required»). Абсолютный срок 90 суток был отклонением от SHOULD без причины, выдерживающей проверку, — снижен до **30 суток**; бездействие 14 суток остаётся и строже нормы. Документ — `STACK_DECISIONS §13`: уровень AAL1, оба срока, политика одновременных сессий (лимита нет — контракт предусматривает куку и Bearer одновременно; вместо лимита отзыв, «выйти везде» и журнал), рассогласование с федеративной сессией названо прямо (RP-initiated/back-channel logout нет), 7.6.2 выполнено. Пин — `config.TestSessionClocksStayWithinTheDeclaredBaseline` | fixed(P2, дерево сессии) | приёмка P1 (сверка с ASVS 5.0 V7) |
|
||
| PD-62 | bug | minor | `internal/pgstore/identity.go:26-35`, миграция `00005` | **`login.State.StartID` не персистился: колонки под него не было.** Поле заведено в P1 и логируется колбэком как `login_start_id` — то есть ОБЕ строки лога, которые должны сшивать две половины входа, в проде пустые. Батарея этого не видела, потому что тесты `internal/login` ходят в in-memory стор, который хранит структуру целиком: свойство проверялось не на том объекте, который едет (тот же класс, что PD-46). Воспроизведено против живой БД раунд-трипом `PutLoginState`→`TakeLoginState`: положили `REQ-ABC123`, получили `""` — **закрыто:** колонки `start_id` и `issuer` добавлены миграцией `00008`, `Put`/`Take` их несут; пин — `pgstore.TestLoginStateIsSingleUseAndExpires` сравнивает структуру ЦЕЛИКОМ (`reflect.DeepEqual`), поэтому следующее поле без колонки упадёт здесь же. Посадки «потерять start_id» и «потерять issuer» падают | fixed(P2, дерево сессии) | сессия P2 (найдено при правке PD-57) |
|
||
| PD-63 | vuln | minor | `internal/httpapi/serve.go:118-127` (удалён) | **`ClearReadDeadline` воспроизводил PD-2 — тем самым вызовом, который был заведён как его исправление.** Доккоммент объявлял его ОБЯЗАТЕЛЬНЫМ для стримингового хендлера. На полу-кормленном запросе (тело анонсировано, не дослано) дренаж внутри записи заголовка ответа — единственная граница соединения, и ограничен он `ReadTimeout`; снятие дедлайна ДО записи заголовка эту границу убирает. Замерено: хендлер остаётся внутри `WriteHeader` и через 4 с после ухода клиента, соединение держится. Вызов после флаша бесполезен — контекст уже отменён дренажем. Приёмка (PD-51) заключила «код безвреден», проверив только корректные запросы; случая, где функция помогает, нет вовсе — **закрыто:** функция УДАЛЕНА, §12 переписан, пин — `httpapi.TestHalfFedStreamingRequestIsCutLoose` (контекст стримингового хендлера отменяется в пределах `Read`) | fixed(P2, дерево сессии) | сессия P2 (собственный замер при верификации PD-51) |
|
||
| PD-64 | bug | minor | `deploy/tmplatformd.service` | **Дефолтный `OOMPolicy=stop` уронил бы платформу из-за одного прожорливого прогона.** Следствие того же факта, что PD-55 (дети-`tmctl` живут в cgroup юнита), но в той строке не названо: по man 5 systemd.service дефолт берётся из `DefaultOOMPolicy=` (системный — `stop`), а `stop` означает «the unit's processes are terminated cleanly by the service manager» — то есть OOM-килл ОДНОГО `tmctl` останавливает контрол-плейн и все остальные прогоны, после чего юнит уходит в `oom-kill` failed и его подхватывает `Restart=on-failure` — **закрыто:** `OOMPolicy=continue` проставлен явно с обоснованием; платформа переживает килл ребёнка и штатно закрывает его резервацию. ⚠ Под systemd не проверялось (нет sudo) — вывод из доки; `systemd-analyze verify` (systemd 259) — exit 0 | fixed(P2, дерево сессии) | сессия P2 (ревью деплой-юнита при PD-55) |
|
||
| PD-65 | vuln | minor | `internal/login/login.go:367-382` | **Обмен кода и загрузка JWKS шли БЕЗ дедлайна**, тогда как discovery на том же пути ограничивает себя пятью секундами и называет причину («`http.DefaultClient` не имеет собственного таймаута, а вызов делается, пока человек ждёт»). В проде `httpClient` равен nil, поэтому обмен идёт на `http.DefaultClient`, а go-oidc строит набор ключей от `context.Background()`; `WriteTimeout` у сервера нет по проекту — значит издатель, который принял соединение и не отвечает, держит хендлер, пока клиент сам не уйдёт. Хуже того, набор ключей ОБЩИЙ: одна зависшая загрузка паркует ВСЕ параллельные входы (замерено ревью: два независимых входа ждали 12 с за одной загрузкой) — **закрыто:** `identify` ограничен `providerTimeout` 10 с; пин — `login.TestAStalledProviderDoesNotHoldTheCallback` на обеих ногах (token и keys), посадка «убрать дедлайн» падает | fixed(P2, дерево сессии) | ревью P2 (линза oidc-security, подтверждено верификатором на боевой проводке) |
|
||
| PD-66 | bug | minor | `internal/httpapi/serve_test.go`, `cmd/tmplatformd/main.go:121` | **Мой собственный фикс PD-46 закрывал только половину и утверждал, что обе.** Тест строил свой сервер `NewServer(…, DefaultTimeouts())` и на него же смотрел; проводка демона осталась ненаблюдаемой, а `cmd/tmplatformd` тестов не имеет. Замерено ревью: замена аргумента на `Timeouts{Shutdown: 15s}` оставляет `make check` зелёным (0 issues) и бинарь снова пиннит соединения — PD-2 в полном объёме. То есть ровно та форма, которую PD-46 и называл: свойство проверено не на том объекте — **закрыто устранением КЛАССА, а не тестом:** `NewServer` больше не принимает `Timeouts` и берёт `DefaultTimeouts()` сам, передавать нечего; коротким дедлайнам тестов служит неэкспортируемый `serverWithTimeouts` | fixed(P2, дерево сессии) | ревью P2 (линза net-http) |
|
||
| PD-67 | vuln | minor | `internal/pgstore/credits.go:236` | **`FOR UPDATE` не был запинен ничем, а комментарий теста утверждал обратное** («Mutation caught: … or the FOR UPDATE that serialises it»). Последовательный тест лока не видит по построению, а инвариант «кэш = леджер» его тоже не ловит: без лока кэш и леджер уезжают ВМЕСТЕ, оба в минус. Лок — единственное, что мешает двум прогонам потратить один и тот же кредит — **закрыто:** `pgstore.TestConcurrentHoldsCannotOvercommitAnAccount` — 60 раундов по два конкурентных холда, каждый по отдельности посильный, вместе нет; посадка «убрать `for update`» падает 3 прогона из 3, баланс уходит в −$2. Комментарий последовательного теста исправлен | fixed(P2, дерево сессии) | ревью P2 (линза sql-money) |
|
||
| PD-68 | bug | minor | `internal/httpapi/server.go:111` | **`/readyz` рапортовал «готов» на базе БЕЗ схемы.** Готовность доказывалась одним `Ping`, который успешен на любом достижимом Postgres, включая пустой. `Migrate` выключен по умолчанию, а deploy-инструкция делает миграцию отдельным шагом — значит «процесс поднят, схема не накачена» это НОРМАЛЬНАЯ середина выката, и инстанс в этом окне отвечал 200 `ready`, проваливая каждый запрос, который затем обслуживал — **закрыто:** `Store.Ready` сверяет `goose_db_version` с максимальным номером миграции, вшитой в бинарь; схема ВПЕРЕДИ бинаря готовности не отменяет (иначе выкат ронял бы старый инстанс). Пин — `pgstore.TestReadinessRefusesADatabaseWithoutTheSchema`, посадка «свести готовность к `Ping`» падает. ⚠ Первая редакция фикса печатала причину В ТЕЛО ответа и ради этого тащила `pgstore` в `httpapi` — и слой, и утечка состояния выката на НЕаутентифицированной ручке; снято при самопроверке, причина уходит в ERROR-лог | fixed(P2, дерево сессии) | ревью P2 (линза вне карты) |
|
||
| PD-69 | bug | minor | `internal/pgstore/store.go:42` | **Явный `pool_max_conns` из DSN молча отбрасывался.** Проверка «MaxConns равен дефолту pgxpool» не отличает «оператор не выбирал» от «оператор выбрал ровно это число»: pgxpool кладёт свой дефолт в то же поле, что `ParseConfig` заполняет из `pool_max_conns`. Оператор, порезавший реплику под бюджет `max_connections`, получал наш 16 вместо своих 8 — и наоборот на 32-ядерной машине. С `pool_min_conns` хуже: дефолт pgx равен 0, поэтому явный 0 не мог пережить проверку НИКОГДА — **закрыто:** вопрос «упоминает ли DSN этот ключ» задан ПАРСЕРУ pgx, а не значению: `pgxpool` достаёт `pool_*` из `RuntimeParams` и удаляет их, поэтому второй `pgx.ParseConfig` их ещё видит — обе формы DSN, кавычки и service-файлы бесплатно. ⚠ Первая редакция фикса разбирала DSN РУКАМИ (33 строки собственного парсера) — велосипед, найден при самопроверке и снят; наши дефолты применяются только там, где оператор промолчал. Пин — `pgstore.TestExplicitPoolSizesInTheDSNSurvive`, включая случай, сломавший ПРЕДЫДУЩУЮ эвристику: пароль, содержащий имя ключа | fixed(P2, дерево сессии) | ревью P2 (линза вне карты) |
|
||
| PD-70 | bug | **major** | `internal/auth/middleware.go:57`, `internal/auth/cookie.go:62` | **Скользящее окно бездействия для БРАУЗЕРА не работало: Max-Age куки пишется один раз, на входе, и больше никем.** Серверная строка скользила (`Touch`), кука — нет, а `SetSession` зовётся ровно из одного места — колбэка входа. Следствие: для куки — единственной презентации, которую код вообще умеет выдавать, — срок жизни сессии был ФИКСИРОВАННЫЕ 14 суток от входа независимо от активности; человек, заходящий каждый день, выкидывался на 14-е сутки при живой серверной сессии, а абсолютный срок не мог наступить никогда. Это же делало ложным §13 — документ соответствия ASVS 7.1.1, который зона только что написала — **закрыто:** при скольжении окна кука переиздаётся с тем же токеном (ротация — акт границы входа, не скольжения); пин — `auth.TestSlidingTheIdleWindowRefreshesTheBrowsersCookie` (четыре случая: кука во второй половине окна, свежая кука, Bearer, упор в абсолютный срок). ⚠ Первая редакция фикса выдавала `Max-Age` равный idle-TTL безусловно — то есть кука могла пережить абсолютный срок и превратить каждый следующий запрос в 401 вместо чистого «вы вышли»; поймано самопроверкой, срок теперь берётся как `min(idle, остаток абсолютного)` | fixed(P2, дерево сессии) | ревью P2 (линза doc-vs-code) |
|
||
| PD-73 | vuln | minor | `internal/login/login.go:67-74`, `:126` | **Дедлайн `identify` ограничивал ОЖИДАЮЩЕГО, а не саму загрузку ключей — то есть мой фикс PD-65 был неполон.** `Provider.Verifier` берёт набор ключей, построенный на discovery, а go-oidc хранит его через `context.WithoutCancel` и ходит за ключами на `http.DefaultClient`, у которого таймаута нет. Загрузка, которая зависла, продолжает висеть после того, как ожидающий сдался, и все последующие входы встают на тот же `inflight` — то есть вход не поднимается и после того, как эндпоинт выздоровел, вплоть до перезапуска процесса. Воспроизведено ревью на боевой проводке — **закрыто:** `New` ВСЕГДА ставит `httpClient` с таймаутом `providerTimeout`, клиент передаётся `NewProvider` безусловно (`oidc.ClientContext`), и его подхватывает набор ключей; nil-случая больше нет — класс устранён, а не покрыт тестом. Пины — `TestTheDefaultProviderClientIsBounded` (посадка «клиент без таймаута» падает) и `TestAHungKeyFetchDoesNotPoisonLaterSignIns` (вход ПОСЛЕ выздоровления эндпоинта обязан пройти) | fixed(P2, дерево сессии) | ревью P2 (линза the-fixes) |
|
||
| PD-74 | bug | minor | `internal/auth/middleware.go:57` | **Скольжение окна залипало на последней четверти жизни сессии: каждый запрос становился записью.** `Touch` прижимает новый дедлайн через `least(now+IdleTTL, absolute_expires_at)`, поэтому как только `now+IdleTTL` перевалил за абсолютный потолок, `idle_expires_at` больше не двигается — а условие «осталось меньше половины окна» с этого момента истинно ВСЕГДА. На горячем пути это UPDATE по первичному ключу таблицы сессий и `Set-Cookie` на каждом аутентифицированном запросе (после PD-70 — ещё и кука). Найдено двумя линзами независимо — **закрыто:** скольжение выполняется только пока `IdleExpiresAt` строго меньше `AbsoluteExpiresAt`; пин — `auth.TestTheSlideStopsOnceItCannotMoveTheDeadline` (пять чтений дают ноль записей, а сессия с запасом по-прежнему скользит) | fixed(P2, дерево сессии) | ревью P2 (линзы session-security и вне карты, независимо) |
|
||
| PD-75 | bug | minor | `cmd/tmplatformctl/main.go:151` | **CLI сообщал о ПРИМЕНЁННОМ начислении как о провале, а повтор начислял второй раз.** `write` выполняет денежную операцию, затем отдельным запросом читает баланс, и ошибку ЧТЕНИЯ возвращает как результат команды. Оператор видит ошибку, повторяет — а `--key` необязателен, и без него `newKey` чеканит новый ключ идемпотентности, поэтому второй прогон начисляет ещё раз. Достаточно обрыва соединения между двумя запросами — **закрыто:** после коммита команда не может отчитаться провалом; баланс читается как любезность, его отказ печатается предупреждением на той же строке | fixed(P2, дерево сессии) | ревью P2 (линза вне карты) |
|
||
| PD-76 | bug | minor | `internal/login/login_test.go` | **Определяющее свойство пакета — «ничего выданного провайдером не персистится» — проверялось утверждением, которое не могло упасть.** `memStore.notes` объявлено и не заполнялось ни одним методом, поэтому `strings.Join(notes)` всегда пусто, а `Contains` всегда ложно. Свойство названо в доккомменте пакета первой строкой — **закрыто:** мок пишет в `saw` КАЖДУЮ строку, которую поток ему передал, а утверждение проверяет и непустоту записи, и отсутствие среди неё и access-токена, и любого JWT-образного значения. Посадка «положить в стор сырой id-токен» падает | fixed(P2, дерево сессии) | ревью P2 (линза water) |
|
||
| PD-77 | bug | info | `internal/ingest/supervisor.go:104` | **Штатная остановка живого прогона поднимала тревогу о сломанном синке.** `Ingest` проверяет `ctx.Err()` в начале цикла и возвращает `context.Canceled` как СВОЮ ошибку; `Run` отличить это от отказавшего синка не мог и на обычном SIGTERM писал ERROR «stream could not be materialized», который по замыслу означает «платформа ослепла, пока тратятся деньги», плюс звал `stop()` на уже останавливающемся прогоне — **закрыто:** отменённый `runCtx` больше не считается отказом синка | fixed(P2, дерево сессии) | ревью P2 (линза вне карты) |
|
||
| PD-78 | hardening | info | `internal/login/login.go` (было), `internal/httpapi/problem.go` (было), `internal/pgstore/identity.go`, `internal/httpapi/server.go` | **Свод воды и дублей, найденный линзой лаконичности; каждый пункт проверен удалением.** (а) `login.Routes` мемоизировал mux через `sync.Once` — при этом ВТОРОЙ и последующие `guard` молча игнорировались, то есть это была не оптимизация, а ловушка; снято. (б) `login.Fail` носил `*http.Request`, который никто не читал, и ради несовпадения сигнатур существовал шим `httpapi.Fail`; параметр и шим удалены, `WriteProblem` подключён напрямую. (в) `upsertIdentityOnce` держал собственный begin/rollback/commit при наличии `inTx` — второй экземпляр того же кода. (г) `Deps.APIPrefix` — ручка, которую не выставлял ни один вызыватель; заменена константой. (д) `Ready` делал `Ping` и следом запрос — два round trip на пробу каждые несколько секунд. (е) пять полей тестовых двойников, которые писались и не читались; `blockingSink` не блокировал. (ж) `money.USD` считал руками с комментарием про переполнение `MinInt64` — заменён на `big.Rat.FloatString(6)`, проверено побайтовое совпадение на всём диапазоне | fixed(P2, дерево сессии) | ревью P2 (линза water) + самопроверка |
|
||
|
||
## Закрытые — эра P3 (деплой и фикс-пак приёмки P2)
|
||
|
||
| ID | Класс | Серьёзность | Где | Суть | Статус | Источник |
|
||
|---|---|---|---|---|---|---|
|
||
| PD-79 | bug | minor | `internal/money/money.go:33-36` | **Строковый `"null"` читается как НОЛЬ денег.** Кавычки снимаются `strings.Trim` ДО проверки `s == "null"`, поэтому `"committed_usd":"null"` даёт настоящий `0` и НЕПУСТОЙ указатель, тогда как доккоммент поля обещает отказ на «absent, null and empty». Замерено приёмкой на живом декодере: голый `null` и отсутствие поля дают nil (защита работает), `""` даёт ошибку, а `"null"` — `Spend = 0 micro-USD, NON-NIL`. На пути расчёта это «попытка стоила ничего»: холд освобождается, списания нет. Латентно до воркера; чинится перестановкой проверки перед `Trim` — **закрыто:** литерал `null` судится ДО раскавычивания и оставляет значение нетронутым; кавычки снимает `encoding/json`, а не `strings.Trim` — слово `null`, пустая строка и экранированная цифра выходят тем, чем являются, и каждое встречает ту же единственную проверку синтаксиса, поэтому отдельной ветки «пусто или null» не нужно вовсе. Пины: `money.TestUnmarshalTellsTheNullLiteralFromTheWordNull` (обе формы плюс невмешательство в значение) и `ingest.TestSpendRefusesNonsense` на шве. Обе посадки — «снять кавычки первыми» и «слово `null` есть ноль» — поймать поимённо | fixed(P3, дерево сессии) | приёмка P2 (замер оркестратора №15 + панель) |
|
||
| PD-80 | vuln | **major** | `internal/login/login.go:158,217-226` | **Вход выключается тремя запросами в секунду, и 429 колбэка ДОБИВАЕТ начатые входы.** Ведро `rate.NewLimiter(2, 20)` одно на `/auth/login` И `/auth/callback` (`login.go:122`, единственный лимитер в зоне), а колбэк стирает login-куку ПЕРВОЙ строкой — до своей проверки лимитера. Следствие: анонимный поток на `/auth/login` не только закрывает вход всем (это PD-42, принято риском в форме «глобальный, не пер-адресный»), но и делает начатый вход невосстановимым: 429 приходит уже с `Set-Cookie: __Host-tm_login=; Max-Age=0`, поэтому повтор того же колбэка не пройдёт и после наполнения ведра. **Воспроизведено приёмкой на боевом бинаре:** 19 из 40 `/auth/login` прошли, дальше 429; честный колбэк с живым state получил 429 и стёртую куку. Независимо измерено панелью. Фикс дешёвый: лимитер прежде очистки куки + раздельные ведра для начала и конца входа; пер-адресный лимит остаётся вопросом edge (PD-42) — **закрыто:** два ведра вместо одного (`startLimit`/`finishLimit`, те же rate/burst — не делится именно ИСЧЕРПАНИЕ), и проверка лимитера ПЕРЕД `ClearLogin`. Пины: `TestFloodingTheStartOfSignInDoesNotCloseTheEnd` (поток на `/auth/login` не закрывает честный колбэк) и `TestARefusedCallbackKeepsTheLoginItRefused` (429 не стирает куку, состояние не съедено, повтор после снятия лимита доходит до 303). Посадки «одно ведро» и «очистка выше лимитера» падают | fixed(P3, дерево сессии) | приёмка P2 (живая проба + панель, две независимые линзы) |
|
||
| PD-83 | hardening | minor | `internal/httpapi/middleware.go:64` | **Фикс PD-3 не запинен в собственном месте:** посадка «`Recover` логирует `r.URL.Path` вместо `routeOf(r)`» батарею ПЕРЕЖИВАЕТ, тогда как та же посадка в `AccessLog` ловится поимённо (`TestAccessLogNamesTheRouteNotThePath`). По правилу шапки этого файла половина PD-3 закрытой не считается — **закрыто:** `TestPanicBecomesAProblemAndNamesTheRoute` — паника за мультиплексором с `{book}` в паттерне; сверяется и `route`, и отсутствие идентификатора книги во ВСЕЙ строке (в ней же стек). Посадка `r.URL.Path` падает | fixed(P3, дерево сессии) | приёмка P2 (посадка мутации) |
|
||
| PD-84 | hardening | minor | `internal/login/login.go:222` | **Лимитер колбэка (фикс PD-29) не запинен:** удаление всей проверки `h.limiter.Allow()` из `callback` оставляет батарею зелёной. Замер PD-29 (~880 строк/с с одного хоста) означает, что регрессия здесь тихо возвращает неаутентифицированного писателя в таблицу журнала — **закрыто:** `TestBothLegsOfSignInAreRateLimited` — десять колбэков подряд обязаны упереться в 429. Посадка «удалить проверку целиком» падает; её же ловит `TestARefusedCallbackKeepsTheLoginItRefused` | fixed(P3, дерево сессии) | приёмка P2 (посадка мутации) |
|
||
| PD-85 | hardening | minor | `internal/pgstore/identity.go:126-131` | **«Неподтверждённый адрес не поднимается на аккаунт» запинено только на ветке НОВОЙ личности:** снятие условия `in.EmailVerified` в ветке ВОЗВРАЩАЮЩЕГОСЯ входа (обновление `users.email`) проходит батарею — `TestUnverifiedAddressStaysOffTheAccount` покрывает первый вход и переход в verified, но не обратный случай — **закрыто:** `TestUnverifiedAddressStaysOffTheAccount` продлён третьим шагом — ВОЗВРАЩАЮЩИЙСЯ вход с новым НЕподтверждённым адресом: `users.email` не двигается, `identities.email` записывает то, что пришло. Посадка «снять `in.EmailVerified` в ветке возвращающегося» падает | fixed(P3, дерево сессии) | приёмка P2 (посадка мутации) |
|
||
| PD-91 | doc | minor | `deploy/README.md:33-42`, `deploy/tmplatformd.service:43,48` | **Установка, исполненная дословно, даёт нестартующий юнит:** `/srv/textmachine` не создаётся ни одной командой наброска, а `ReadWritePaths=` без префикса `-` на несуществующем пути валит сборку mount-namespace при `ProtectSystem=strict`. Заодно `ProtectHome=yes` против решения владельца «книги живут в `~/books`»: детям-`tmctl` домашние каталоги под этим юнитом недоступны — либо книги переезжают в `/srv/textmachine`, либо юнит получает `BindPaths=`. ⚠ Вывод из `systemd.exec(5)`, под systemd не исполнялось (sudo нет) — **закрыто:** каталог `/srv/textmachine` создаётся явной командой наброска (`--system` домашний каталог не создаёт), префикс `-` намеренно НЕ ставится (сервис без записываемого каталога обязан падать на старте, а не на первой записи через часы), `ProtectHome=yes` оставлен с названной ценой и двухстрочным выходом (`ProtectHome=tmpfs` + `BindPaths=`), корень библиотеки на СЕРВЕРЕ — `/srv/textmachine`, `~/books` объявлено конвенцией машины разработки. **Проверено живым прогоном** `systemd-run --user` (systemd 259), а не докой: несуществующий путь без `-` → `226/NAMESPACE`, созданный → `0/SUCCESS`, с `-` → `0/SUCCESS` (строка игнорируется); `ProtectHome=yes` → `Permission denied` на `/home/<user>`; `tmpfs`+`BindPaths` → каталог виден | fixed(P3, дерево сессии) | приёмка P2 (панель, сверено с докой) |
|
||
| PD-95 | doc | **major (для промта эмиттера)** | `internal/ingest/events.go:6-12`, `docs/platform-PROGRESS.md` §«Транспорт потока событий» | **Транспортная история зоны устарела против D39.106 в ДВУХ местах, и обе версии не совпадают с ратифицированной формой.** Ратифицировано (D39.106 п.2 + `research/25` §Форма): движок — транзиентный systemd-юнит на прогон, платформа ему **НЕ родитель**; события — `events.jsonl` в каталоге книги, append-only, как outbox-проекция уже закоммиченных строк SQLite (та же транзакция, что чекпойнт); платформа **тейлит** журнал, курсор `(engine_run_id, seq)` коммитится в одной Postgres-транзакции с эффектом. В `research/25` вариант «платформа — родитель + пайп (stdout/fd)» получил **0 голосов из 15** («время жизни движка — подмножество платформы: деплой/рестарт убивает или осиротляет прогон»), выделенный fd 3 — **0**, stdout/journald как источник событий — **0**. Что в зоне: (а) доккоммент `events.go` предлагает переезд на fd/сокет — отклонённая форма; (б) журнал зоны длинно доказывает «канал остаётся stdout» и объявляет переезд отклонённым, ссылаясь на PD-59, который **сам superseded** тем же D39.106 п.3 («PD-59 superseded; пайп-путь `supervisor.go` P1 = дев-режим; ответ PD-13 переезжает на cgroup юнита прогона»). Оба текста прочтёт эмиттер-сессия как задание. ⚠ **Приёмка №15 это пропустила и в первой редакции строки сама сослалась на снятый PD-59 — исправлено здесь же** — **закрыто:** доккоммент пакета переписан под D39.106 §2 — транзиентный systemd-юнит на прогон, платформа НЕ родитель, `events.jsonl` в каталоге книги как outbox-проекция коммитов SQLite, тейл с курсором `(engine_run_id, seq)`, повторное чтение строк — норма (PD-105). Отвергнутые формы перечислены со счётом голосов, чтобы не вернулись свежей идеей. Доккоммент `Supervisor` помечен ДЕВ-РЕЖИМОМ там же, где он описывает пайп. ⚠ В журнале зоны нашлась ВТОРАЯ копия снятого ответа — блок «ОТВЕЧЕНО приёмкой (PD-59)» в списке «Открытые вопросы после P1» п.4: эррата №15 пере-ставила другую секцию, эту не тронула. Текст оркестратора не переписан — над ним поставлен баннер SUPERSEDED с ратифицированной формой; проверить принадлежность правки — за приёмкой | fixed(P3, дерево сессии) | приёмка P2 (эррата оркестратора №15, 07.08) |
|
||
| PD-100 | bug | minor | `internal/login/login.go:245-261` | **Класс PD-5 закрыт в `auth/`, но не в `login/`:** колбэк глотает ошибку стора (`TakeLoginState`) и ошибку discovery, репортя их как обычный отказ (`unknown_state` / `discovery_failed`) — сама ошибка не доезжает ни до одной строки лога, хотя `pgstore/identity.go` намеренно отличает «состояния нет» от инфраструктурного сбоя. Аутентификационный DB-outage снова выглядит штормом обычных отказов — **закрыто:** сбой стора и сбой discovery уходят в ERROR; на проводе и в журнале — прежний отказ. `login.ErrNoState` заведён у владельца интерфейса (как `auth.ErrNoSession`), `pgstore.ErrNoLoginState` — то же значение под прежним именем. Пин `TestInfrastructureFailuresInTheCallbackAreLogged`: три случая, включая «обычное истечение НЕ логируется как авария» — ловит и посадку «логировать всегда» | fixed(P3, дерево сессии) | приёмка P2 (панель) |
|
||
| PD-106 | standards | minor | `cmd/tmplatformctl/` | **Админ-CLI — единственный писатель денег в дереве — не имеет ни одного теста.** В том числе не покрыто правило, которое он сам называет несущим («после коммита команда не может отчитаться провалом», фикс PD-75), и разбор флагов, и формат вывода. Батарея зоны его не видит вовсе (`[no test files]`) — **закрыто:** `cmd/tmplatformctl/main_test.go` — восемь тестов. Несущее правило («после коммита ничего не отчитывается провалом») пинится через `balanceReader` — интерфейс с одним методом, заведён ровно затем, что правило нельзя проверить на сторе, который всегда работает. Плюс: спент-ключ → «no-op», сбой ДО коммита → ошибка и ни строки вывода, ключ без `--key` уникален на 100 прогонах, разбор флагов десятью случаями и сквозной прогон пяти команд по живой БД. ⚠ Первая редакция теста флагов сверяла лишь «ошибка непуста» — две посадки её ПЕРЕЖИЛИ (команда падала на соединении, а не на аргументах); тест переписан на сверку сообщения | fixed(P3, дерево сессии) | приёмка P2 (панель) |
|
||
|
||
## Закрытые — эра P4 (раннер: юнит, очередь, реконсилятор, тейлер)
|
||
|
||
| ID | Класс | Серьёзность | Где | Суть | Статус | Источник |
|
||
|---|---|---|---|---|---|---|
|
||
| PD-43 | bug | info | `internal/pgstore/credits.go` | Денежный контур не имеет ни одного вызывающего вне тестов: `Hold`/`Settle`/`Release` не зовутся, `Sink` не реализован, `TypeSpend` не декодируется. При первом реальном прогоне баланс не изменится. Ожидаемо — воркера нет (П-1/П-3), но заведено строкой, чтобы это было решением, а не сюрпризом — **закрыто:** денежный контур получил вызывающих: `runs.Service.Start` берёт холд в ОДНОЙ транзакции с созданием прогона и записью очереди (`pgstore.StartRun`), реконсилятор закрывает его `Settle` по фигуре движка. Пины: `pgstore.TestAdmittingARunWritesTheRunTheAttemptAndTheHoldTogether` (посадка «убрать holdTx из транзакции» падает), `TestARunThatCannotBePaidForLeavesNothingBehind`, `runs.TestARunThatEndsIsFinishedAndSettledAtWhatTheEngineSpent`. Живая проба: грант $10 → прогон с потолком 100 глав → холд $3.00 → движок отчитался $0.42 → баланс $9.58 | fixed(P4, дерево сессии) | ревью «вне карты» |
|
||
| PD-81 | standards | minor | `internal/pgstore/credits.go:169-178` | **Заявленный `ErrDuplicateHold` на реальном пути недостижим:** при ЖИВОЙ резервации повторный `Hold` падает на первичном ключе `reservations_pkey` (`00007_credits.sql:66`) и уходит наверх сырой ошибкой Postgres SQLSTATE 23505; объявленная ошибка приходит только когда строку резервации уже смахнули, а ключ леджера остался. Замерено приёмкой на живом PG в обеих формах. Деньги целы (`balance == SUM(ledger)`, транзакция откатывается), но воркеру не на что смотреть, кроме текста ошибки — **закрыто:** при ЖИВОЙ резервации коллизия `reservations_pkey` мапится в `ErrDuplicateHold` (`credits.go` `holdTx`); объявленная ошибка стала достижимой на реальном пути. Пин — `TestASecondHoldOnALiveReservationIsADuplicateNotASqlstate` (сверяет и то, что отказ не двинул деньги) | fixed(P4, дерево сессии) | приёмка P2 (замер оркестратора №15 + панель) |
|
||
| PD-82 | bug | info | `internal/pgstore/credits.go:236-239` | `Hold` на НЕСУЩЕСТВУЮЩИЙ аккаунт отдаёт `ErrInsufficientCredit` (в `lockBalance` `ErrNoRows` трактуется как «нет кредита»), а не `ErrNoAccount`: обещание PD-56 «один ответ на несуществующий аккаунт» покрывает `Grant`/`Adjust`/`Balance`/`ReadAccount` и на `Hold` не распространяется. Замерено приёмкой — **закрыто:** `lockBalance` при отсутствии строки баланса спрашивает, существует ли аккаунт, и отвечает `ErrNoAccount` против `ErrInsufficientCredit`. ⚠ Одним запросом это не выражается: Postgres запрещает `FOR UPDATE` на nullable-стороне внешнего соединения — проверено, поэтому вторая проверка идёт только на редком пути. Пин — `TestMoneyOperationsTellAMissingAccountFromAnEmptyOne` | fixed(P4, дерево сессии) | приёмка P2 (замер оркестратора №15) |
|
||
| PD-97 | hardening | info | `internal/pgstore/credits.go:212-216` | `Settle`/`Release` отбрасывают флаг `applied` у `hold_release`: если ключ `("run_release", engineRunID)` уже потрачен, резервация закроется, а деньги не вернутся — тихий no-op на денежном пути. Требует нештатной последовательности (закрытие, смахивание строки, повторное открытие того же `engine_run_id`), но ровно на такой последовательности стоит `ErrDuplicateHold` — **закрыто:** `releaseHold` судит флаг `applied`; потраченный ключ релиза = `ErrReleaseKeySpent` и ОТКАТ транзакции, поэтому резервация остаётся ОТКРЫТОЙ и видимой оператору вместо тихого закрытия без возврата денег. Пин — `TestAReleaseWhoseKeyWasSpentIsRefusedRatherThanSilent` | fixed(P4, дерево сессии) | приёмка P2 (панель) |
|
||
| PD-99 | hardening | info | `internal/ingest/supervisor.go:102` | INFO-лог «engine started» пишет `args` целиком. Сегодня безвредно, но воркер будет передавать движку идентификатор книги и потолок аргументами ⇒ book-id и денежная сумма попадут в INFO платформы (D39.84 + норма зоны «id книги в логи не текут»). Закрыть вместе с воркером: логировать имя команды, не argv — **закрыто:** INFO-строка старта несёт имя команды и НЕ несёт argv (`runner.Start`, а также дев-путь `ingest/supervisor.go`), поэтому ни id книги, ни потолок в долларах в поток INFO не попадают. Пин — `runner.TestTheStartLineNamesTheCommandAndNotItsArguments` (посадка «вернуть \"args\"» падает). Живая проба на боевом бинаре: `grep -c 'ceiling-usd\|3.000000\|bk_' daemon.log` = **0** за полный прогон | fixed(P4, дерево сессии) | приёмка P2 (панель) |
|
||
| PD-105 | standards | **major (для промта эмиттера)** | `internal/ingest/decoder.go:96` | **Декодер и норматив зоны расходятся на дубле `seq`:** декодер объявляет его фатальным `ErrStreamGap`, а `ENGINEERING_STANDARDS §2` ратифицирует «at-least-once — норма, дубль — не ошибка». После фикса PD-12 цена выросла: сбой ингеста ОСТАНАВЛИВАЕТ прогон, поэтому одна задублированная строка убивает платный прогон, хотя ратифицированный путь ремонта — `status --json`. Внутри одного пайпа передоставки нет, так что отказ декодера защитим; непропорциональна РЕАКЦИЯ. Разрешать ратификацией вместе с промтом эмиттера (строка 103), не молча. ⚠ **Пере-диспозиция (эррата №15): вес ПОВЫШЕН до major-для-эмиттера** — при ратифицированном транспорте (тейл `events.jsonl` с курсором, D39.106) повторное чтение строк после краша читателя — НОРМА, а не аномалия пайпа, поэтому норматив «at-least-once, дубль не ошибка» буквально верен, и фатальный отказ декодера прямо ему противоречит — **ЗАКРЫТО РАТИФИКАЦИЕЙ (D39.119) И РЕАЛИЗАЦИЕЙ.** Транспорт — тейл `events.jsonl` с курсором, поэтому повторное чтение строк НОРМА: `ingest.Tail` пропускает `seq <= last_seq` идемпотентно и не возвращает ошибку, а `pgstore.RunSink.Apply` пере-проверяет тот же high-water mark ВНУТРИ транзакции эффекта. Тот же `seq` с ДРУГИМ payload = `ErrPayloadConflict` → карантин ПОПЫТКИ, то есть её проекции: материализация останавливается, а жизненный цикл прогона продолжается — движок тратит зарезервированные деньги, и наша неспособность прочитать журнал не повод их выбросить (`pgstore.Quarantine` пишет только `run_attempts.quarantine_reason`, свежесть переходит на ре-синк). Сверка по sha256 строки (`run_attempts.last_line_sha256`). Пропасть (`seq > last+1`) осталась ошибкой — строки потеряны, читать дальше нечего. Пины: `ingest.TestARedeliveredLineIsNormalAndChangesNothing` · `TestTheSameSeqWithADifferentPayloadIsRefused` · `TestALostLineIsReportedRatherThanSkipped` · `pgstore.TestARedeliveredCountingEventDoesNotCountTwice` (⚠ последний написан ПОСЛЕ того, как посадка пережила первую версию пина: прогресс — присваивание и потому идемпотентен сам по себе, считающий эффект — `unit_done` — нет). Фатальный `ErrStreamGap` на дубле в `decoder.go` остаётся только на ДЕВ-пути пайпа, где передоставки нет | fixed(P4, дерево сессии) | приёмка P2 (панель) |
|
||
| PD-108 | bug | **major** | `internal/ingest/supervisor.go:161` (было), `internal/runner/engine.go` | **Ратифицированный канал ремонта не мог работать НИ РАЗУ: `tmctl status --json` звался БЕЗ обязательного `--config`.** Движок требует его на каждой команде, которая трогает книгу, и падает на разборе аргументов до того, как увидит книгу. Найдено чтением `cmd/tmctl/invocation.go` (не доки) и **воспроизведено исполнением на бинаре, собранном из HEAD** в скрэтчпаде: `tmctl status --json` → `tmctl: --config book.yaml is required`, exit 1; с `--config <путь>` доходит до чтения файла. Дефект латентный ровно потому, что вызывающих у канала не было (PD-43) — то есть первый же резюнк воркера получил бы отказ вместо отчёта — **закрыто:** прод-путь `runner.StatusArgs`/`runner.Status` всегда несёт `--config <workdir>/book.yaml`; дев-путь `Supervisor.Status` исправлен там же. Пин — `runner.TestEveryEngineInvocationNamesTheBookConfig` | fixed(P4, дерево сессии) | сессия P4 (ревью вне карты: чтение парсера движка + проба на HEAD-бинаре) |
|
||
| PD-109 | bug | minor | `internal/runs/reconcile.go` | **Периодический резюнк ЗАТИРАЛ более точную проекцию потока своей грубой.** `status --json` не делит стадии по волнам (строка 99), поэтому его агрегат, положенный поверх «draft 7/20 ∥ edit 1/20», заменял пофазные счётчики одним числом — а отчёт движка, который ещё не досчитал, заменял их НУЛЯМИ. Плюс вторая половина: сторож «поток уже говорил» читал `LastSeq` из СНИМКА свипа, взятого ДО тейла, поэтому прогон, чьи первые события пришли в этом же свипе, выглядел молчащим. **Найдено живой пробой сквозного прогона**, не тестом: карточка книги показала `edit 10/10` через секунды после того, как журнал сказал `edit 0/10` — **закрыто:** резюнк работает только там, где чинить нечего (курсор не двигался ЛИБО материализация в карантине), сторож судит курсор ПОСЛЕ тейла, и `ApplyStatus` не опускает счётчики (`greatest`). Пины — `runs.TestALiveRunIsResyncedAtMostOncePerInterval` и `TestTheSweepMaterializesWhateverTheJournalHasGained` | fixed(P4, дерево сессии) | сессия P4 (живая проба сквозного прогона) |
|
||
| PD-110 | bug | minor | `internal/ingest/tail.go` | **Строки ДРУГОЙ попытки судились против НАШЕГО курсора.** Журнал пер-книжный и append-only, значит резюм дописывает второй `hello` со своим `engine_run_id` и seq, начинающимся заново; строка при этом не несёт идентификатора потока — его говорит только последний хендшейк выше. Первая редакция тейлера этого не отслеживала, поэтому `seq 2` предыдущей попытки встречался с нашим `seq 2` и читался как ИЗМЕНЁННЫЙ payload, то есть как сигнал порчи: здоровый резюмнутый прогон отправлял сам себя в карантин. Найдено собственным тестом до всякой интеграции — **закрыто:** читатель ведёт область (`mine`), и до хендшейка, который он ПРИЗНАЛ своим, ничего не материализуется и ничего не судится. Пины — `TestAnotherAttemptsStreamInTheSameJournalIsSkipped` · `TestARereadFromTheStartDoesNotMistakeAnotherAttemptForCorruption` · `TestEventsBeforeAnyHandshakeAreNotJudgedAgainstOurCursor` (обе посадки — `mine := true` и `mine := false` — падают) | fixed(P4, дерево сессии) | сессия P4 (собственный тест) |
|
||
| PD-111 | bug | minor | `internal/ingest/tail.go` | **`seq` хендшейка не персистился, поэтому ЛЮБОЙ резюм после него читался как пропасть.** `hello` — это seq 1 потока, но первая редакция обрабатывала его отдельно и курсор не двигала: `last_seq` оставался нулём при уже сдвинутом байтовом хинте, и следующая же строка (`seq 2`) давала `seq 2 after 0` → карантин на ровном месте — **закрыто:** хендшейк проходит те же правила, что любая строка, и двигает курсор; эффекта на read-model у него нет, эффект на курсор и есть смысл. Пин — `TestAHalfWrittenLineIsLeftForNextTime` (сверяет применённые seq 1,2 и продолжение 3,4 после дозаписи) | fixed(P4, дерево сессии) | сессия P4 (собственный тест) |
|
||
| PD-116 | bug | minor | `internal/pgstore/runs.go` `RecordSpawn`, `internal/runs/spawn.go` | **Спавн попытки мог прийти ОДНОВРЕМЕННО из воркера очереди и из реконсилятора, и оба видели «не запущено».** Воркер получает прогон заданием, реконсилятор находит его неспавненным на своём проходе — обе ветки законны и обе читали `unit_name` до записи. Дальше их спасала только уникальность ИМЕНИ юнита у systemd: второй `systemd-run` падал с «unit already exists». Выживание по чужому правилу — не корректность, и оно перестаёт работать в день, когда именование поменяется (например, резюм получит суффикс). Найдено собственным ревью кода на конкурентность, до отчёта — **закрыто:** `RecordSpawn` стал compare-and-set (`where id = $1 and unit_name is null`) и возвращает, досталось ли право; проигравший НЕ стартует и это не ошибка. Пин — `runs.TestOnlyOneOfTwoConcurrentSpawnersStartsTheEngine` (восемь конкурентных спавнеров, ровно один юнит); посадка «убрать `and unit_name is null`» падает | fixed(P4, дерево сессии) | сессия P4 (самопроверка на гонки) |
|
||
| PD-117 | bug | minor | `internal/httpapi/v0.go` `startRun` | **Потолок ниже минимума схемы отвечал `409`, а не `400`.** `RunRequest.ceiling_chapters` объявлен `minimum: 1`, и запрос с `0` или отрицательным — МАЛФОРМИРОВАННЫЙ; `409` же определён как «границы сдвинулись между чтением run-options и этим вызовом», поэтому клиент, получивший его, пере-читает run-options и повторяет запрос, который не может пройти НИКОГДА. Замерено ревью: `{"ceiling_chapters":0}` → 202 у хендлера и `409` после сервиса — **закрыто:** минимум схемы судится в хендлере, до сервиса. Пин — `TestACeilingBelowTheSchemaMinimumIsARejectedRequestAndNotAMovedBound` | fixed(P4, дерево сессии) | адверсариальное ревью (сверка со спекой, исполнением) |
|
||
| PD-118 | bug | minor | `internal/pgstore/books.go` `ReadUsage`, `runs.go` `PauseRun` | **`Usage.paused_reason` был НЕДОСТИЖИМ через собственный путь паузы платформы.** `ReadUsage` требовал `finished_at is null`, а `PauseRun` — путь реконсилятора — ставит `finished_at` тем же запросом, что и паузу. Значит поле заполнялось только когда стоп пришёл событием потока (`sink.go`, `finished_at` не трогает) и молчало, когда паузу вызвала платформа. Замерено ревью на живом PG — **закрыто:** состояние читается по ПОСЛЕДНЕМУ прогону каждой книги (lateral), без условия на `finished_at`. Пин — `TestTheAccountReportsAPauseTheReconcilerCaused` | fixed(P4, дерево сессии) | адверсариальное ревью (исполнением) |
|
||
| PD-119 | bug | minor | `internal/httpapi/v0.go` `getBook` | **Карточка книги несла ревизию ПРОГОНА, которая отстаёт от книжной.** Контракт: счётчик ОДИН на книгу и «каждое книго-скоупное чтение и id каждого кадра потока несут одно и то же число». `unit_done` двигает `books.revision` и `chapters.revision`, но не `runs.revision`, поэтому клиент, применивший кадр `id=2`, получал в карточке `0` и — по правилу самого контракта — обязан был чтение ОТБРОСИТЬ: карточка не обновлялась всю серию unit-done. Замерено ревью через настоящий `RunSink` (0→1→2 у книги при 0 у прогона) — **закрыто:** и `BookDetail.revision`, и `Run.revision` проецируются из счётчика КНИГИ. Пин — `TestTheCardsRevisionIsTheBooksAndNotTheRuns` | fixed(P4, дерево сессии) | адверсариальное ревью (исполнением) |
|
||
| PD-120 | vuln | minor | `internal/pgstore/books.go` курсор пагинации | **Курсор из ЧУЖОЙ библиотеки принимался молча.** Контракт прямо возлагает отказ на СЕРВЕР («rejecting a cursor from a dead epoch is the SERVER's duty, MUST, answered 400»), потому что клиенту токен непрозрачен по построению. Курсор нёс только `(added_at, id)` и не нёс метки коллекции, поэтому токен, построенный на библиотеке другого аккаунта, отдавал окно СВОИХ книг вызывающего вместо `400`. Замерено ревью на живом PG (чужой курсор → `err=nil`, 3 строки). Утечки чужих данных нет — выборка всегда `owner_id = $1`, — но клиент получает не то окно и обнаружить это не может — **закрыто:** курсор несёт метку области (`sha256("library"+owner)`, первые 8 байт), чужая метка = `ErrBadCursor` → 400. Пин — `TestACursorFromAnotherLibraryIsRefused` (плюс проверка, что свой курсор по-прежнему работает) | fixed(P4, дерево сессии) | адверсариальное ревью (исполнением) |
|
||
| PD-121 | bug | minor | `internal/httpapi/v0.go` `usageState` | **`/usage` говорил «exhausted» там, где прогон стартует.** Доля округляется ВНИЗ, поэтому $9 остатка от гранта $1000 дают `0%`, а состояние выводилось из доли: экран аккаунта показывал «ничего не осталось», пока `run-options` на той же секунде отдавал шкалу в 300 глав и прогон запускался. Замерено ревью — **закрыто:** «exhausted» — факт о балансе (`Usage.Spendable`), а не следствие округления; оба экрана отвечают из одного факта. Пины — `TestASmallRemainderIsLowAndNotExhausted`, `pgstore.TestASmallRemainderOfALargeGrantIsStillSpendable` | fixed(P4, дерево сессии) | адверсариальное ревью (исполнением) |
|
||
| PD-124 | bug | **major, деньги** | `internal/runs/reconcile.go` расчёт | **Расчёт брал ПОЖИЗНЕННУЮ трату КНИГИ и выставлял её как трату прогона.** `committed_usd` из `status --json` движок считает как `SELECT COALESCE(SUM(committed_usd),0) FROM spend WHERE book_id = ?` (`backend/internal/store/ledger.go` в HEAD, «for a book across all days») — сумма по книге за всю историю. Значит каждый следующий прогон книги оплачивал заново всё, что она стоила раньше; перерасход ограничен холдом (`Settle` каппит), и в леджере он выглядит строкой «capped at the hold», то есть как перерасход ДВИЖКА, а не как арифметика платформы. Замерено двумя независимыми верификаторами на живом PG: прогоны по $1.00 и $0.50 списали $2.50; после того как пожизненная сумма книги перерастает потолок, каждый прогон стоит ровно свой потолок независимо от работы — **закрыто:** попытка записывает БАЗОВУЮ ЛИНИЮ книги перед стартом (`spend_baseline_micro_usd`, миграция 00010, читается `status --json` ДО создания юнита) и платит РАЗНИЦУ; базовая линия не прочиталась = попытка не стартует (платный прогон, который нельзя корректно выставить, хуже прогона, стартующего свипом позже). Пин — `runs.TestASecondRunOnABookIsChargedOnlyForWhatItSpent` | fixed(P4, дерево сессии) | адверсариальное ревью ×2, независимо, исполнением |
|
||
| PD-125 | bug | **major, деньги** | `internal/runs/reconcile.go` `restart` | **Перезапуск при недоступном расчёте открывал ВТОРОЙ холд и терял первый навсегда.** `settle` законно ОТКЛАДЫВАЕТ (движка не спросить) и возвращает nil; `restart` читал это как успех, брал новый холд на остаток и уходил дальше, а старая резервация оставалась открытой — и не попадала ни в один список: `UnsettledRuns` фильтровал по ЗАВЕРШЁННОСТИ ПРОГОНА, а `ListLiveRuns` берёт только попытку с `ended_at is null`. Замерено: прогон с потолком $3.00 показал $6.00 зарезервированных и закончил с $3.00, навсегда снятыми с баланса, при нуле в списке несведённых — **закрыто:** перезапуск СПРАШИВАЕТ (`AttemptReservationOpen`) и откладывается, пока предыдущая попытка не сведена; `UnsettledRuns` теперь ключуется на ЗАВЕРШЁННОСТИ ПОПЫТКИ, поэтому брошенная резервация видна и при живом прогоне. Пины — `TestARestartIsDeferredWhileTheInterruptedAttemptIsUnsettled`, `TestAnInterruptedAttemptsHoldIsStillFoundWhileItsRunGoesOn` | fixed(P4, дерево сессии) | адверсариальное ревью ×2, независимо, исполнением |
|
||
| PD-126 | bug | **major, деньги** | `internal/runs/spawn.go` | **Юнит, который НЕ удалось создать, съедал бюджет прогона по свипу за раз.** Право на спавн записывалось до `Runner.Start`, и при отказе `systemd-run` оставалась запись «юнит есть» без юнита и без маркера — то есть в точности форма прерванного прогона. Каждый свип перезапускал прогон: расчёт, новый холд, отказ спавна, снова. Замерено: шесть свипов — попытка 7 и $0.60 списано за движок, который ни разу не стартовал; при `SweepEvery=15s` весь потолок уходит за минуты — **закрыто:** неудавшийся `Start` СНИМАЕТ право (`ReleaseSpawnClaim`), и следующий свип повторяет ту же попытку вместо перезапуска прогона. Пин — `TestAUnitThatCannotBeCreatedDoesNotEatTheRunsBudget` (шесть свипов: попытка остаётся первой, баланс не двигается) | fixed(P4, дерево сессии) | адверсариальное ревью (исполнением) |
|
||
| PD-127 | bug | **major** | `internal/runs/reconcile.go` `drainJournal` | **Одна нечитаемая строка журнала запирала прогон навсегда.** Тейл шёл ДО чтения маркера и возвращал ошибку из всей сверки, а карантинились только пропасть и конфликт payload; малформированная строка, строка длиннее буфера, подменённый файл и битый хендшейк возвращали жёсткую ошибку каждый свип. Замерено: пять свипов — статус `translating`, `finished_at` пуст, холд $3.00 держится, при том что маркер на диске и движок давно вышел — **закрыто:** ЛЮБАЯ неустранимая ошибка журнала = карантин ПРОЕКЦИИ, а жизненный цикл (маркер, живость, расчёт) продолжается; отмена контекста карантином не считается. Пин — `TestAnUnreadableJournalDoesNotStopTheRunFromFinishing` | fixed(P4, дерево сессии) | адверсариальное ревью (исполнением) |
|
||
| PD-128 | bug | minor | `internal/runs/reconcile.go` грация спавна | **Грация мерилась от старта ПРОГОНА, а не попытки**, поэтому у перезапущенной попытки её не было вовсе: она наследует `started_at` многочасовой давности и признаётся потерянной, как только systemd не успел ответить. Замерено: через секунду после перезапуска — попытка 3 и три созданных юнита — **закрыто:** `run_attempts.started_at` читается отдельным полем и грация мерится от него. Пин — `TestAnAdmittedRunIsGivenTimeBeforeItIsPresumedLost` (снимок с часовым прогоном и пятисекундной попыткой) | fixed(P4, дерево сессии) | адверсариальное ревью (исполнением) |
|
||
| PD-130 | bug | minor | `internal/ingest/tail.go` `readLine` | **Любая ошибка чтения превращалась в `io.EOF`,** то есть в «догнали, нового нет»: отказ диска читался бы как тишина, материализация вставала бы молча и ни один свип не сказал бы почему — **закрыто:** только настоящий EOF означает «догнали»; всё прочее возвращается ошибкой и уходит в карантин с причиной | fixed(P4, дерево сессии) | адверсариальное ревью (чтение кода) |
|
||
| PD-131 | bug | minor | `internal/ingest/tail.go` | **Хендшейк не обязан был нести `seq 1`.** На этом транспорте `hello` ДВИГАЕТ курсор, поэтому `hello` с seq 0 оставлял курсор нулём, а первое настоящее событие отбрасывалось как его дубль; отрицательный seq уходил в ветку «уже применено». Пайп-декодер это требование имел всегда (`decoder.go`), файловый читатель — нет — **закрыто:** `seq != 1` у хендшейка = `ErrBadHandshake` | fixed(P4, дерево сессии) | адверсариальное ревью (чтение кода) |
|
||
| PD-132 | bug | minor, деньги | `internal/runs/reconcile.go` `settle` | **Холд прогона, который так и не стартовал, не возвращался.** После введения базовой линии (PD-124) попытка без неё не сводилась вовсе, а попытка, которую никогда не спавнили, базовой линии и не имеет — её холд оставался зарезервированным навсегда. Найдено собственным тестом при починке PD-124 — **закрыто:** нет базовой линии И нет имени юнита ⇒ попытка не выполнялась, холд возвращается ЦЕЛИКОМ (`Release`); нет базовой линии, но юнит был ⇒ расчёт удерживается с ERROR-строкой, а не угадывается. Пин — `TestTheHoldOfARunThatNeverStartedComesBackWhole` | fixed(P4, дерево сессии) | самопроверка при починке PD-124 |
|
||
| PD-133 | bug | minor | `internal/httpapi/v0.go` `contractRoutes` | **Инстанс без движка не отдавал НИЧЕГО, вопреки собственной строке лога.** Маршруты монтировались только когда есть И read-model, И жизненный цикл прогонов, поэтому инстанс с библиотекой и без раннера отвечал `404` на `/v0/books`, а его же стартовая строка говорила «библиотека отдаётся только на чтение». Замерено ревью — **закрыто:** ЧТЕНИЯ монтируются при наличии read-model, ручки прогона — при наличии жизненного цикла. Пин — `TestAnInstanceWithoutARunnerStillServesTheLibrary` | fixed(P4, дерево сессии) | адверсариальное ревью (исполнением) |
|
||
| PD-134 | bug | minor | `internal/pgstore/runs.go`, `internal/runs/spawn.go` | **Пиннинг версии движка (строка 139) записывался и НИКОГДА не читался:** `run_attempts.engine_binary` не входил в выборку реконсилятора, а спавн и канал ремонта брали путь из ТЕКУЩЕГО конфига. Пин, который никто не читает, — это колонка, а не пин: резюм исполнял бы то, что выкатили сегодня, а `status --json` спрашивал бы о книге бинарь другой версии — **закрыто:** `EngineBinary` читается в `LiveRun` и используется и резюмом, и каналом ремонта; конфиг остаётся фолбэком только для ещё не спавненной попытки | fixed(P4, дерево сессии) | адверсариальное ревью (чтение кода) |
|
||
| PD-135 | bug | minor | `internal/runs/reconcile.go` `restart` | Прерванный прогон без остатка бюджета помечался `paused` БЕЗ `paused_reason` (`FinishRun` его не трогает), тогда как контракт описывает `PausedReason` как причину паузы, и экрану сказать нечего — **закрыто:** используется `PauseRun`, который причину ставит | fixed(P4, дерево сессии) | адверсариальное ревью (чтение кода) |
|
||
| PD-136 | doc | minor | `deploy/tmplatformd.service` | Юнит нёс ОБЕ диспозиции сразу: старый абзац подавал `ProtectHome=yes` как «нужную позу на сервере» прямо над строками, ставящими `tmpfs`+`BindPaths`, а рассуждение о ресурсных потолках всё ещё исходило из модели «дети живут в cgroup этого юнита», снятой D39.106. Оператор, читающий сверху вниз, получал противоречивые инструкции в одном файле — **закрыто:** снятые абзацы удалены, потолки прямо названы границей КОНТРОЛ-ПЛЕЙНА, прогоны — своим срезом | fixed(P4, дерево сессии) | адверсариальное ревью (чтение) |
|
||
| PD-138 | standards | info | `go.mod` | Прямые зависимости (`riverqueue/river`, `riverdriver/riverpgxv5`) стояли помеченными `// indirect`: `make check` тидинесс не проверяет, поэтому батарея этого не видела — **закрыто:** `go mod tidy`. ⚠ Строка оставлена как заявка: гейта на `go mod tidy` в батарее по-прежнему нет | fixed(P4, дерево сессии) | адверсариальное ревью |
|
||
| PD-142 | standards | minor | вся зона, тесты | ⚠ **Заявление «22 новых пина, каждый проверен своей посадкой» СНЯТО дофиксом 09.08 как непроверяемое в этом объёме:** прогонов посадок было девять (9/10, затем 9/9), то есть «каждый из 22» ими не покрывался, а поимённого списка соответствия пин↔посадка сессия не вела. Проверено исполнением и названо поимённо другое: 33 посадки самопроверки пака и 24 посадки дофикса (список — журнал, раздел «Дофикс P4»). **Аудит силы пинов посадками (139 мутаций, четвёртый верификатор): 107 поймано, 32 пережили, из них 8 — не ослабления** (эквивалентный код либо страховка DDL-констрейнтом). Пережившие — не дефекты КОДА, а отсутствующие пины на свойства, часть которых объявлена закрытой; по правилу шапки этого файла такое свойство закрытым не считается. **Закрыто: написаны 22 новых пина**; про «каждый проверен собственной посадкой» — см. начало строки, заявление снято (прогонов посадок было девять: 9/10, потом 9/9 после исправления двух ошибочно сформулированных мутаций). Самые весомые: блокировка строки попытки под КОНКУРЕНЦИЕЙ (`TestConcurrentDeliveriesOfOneEventCountItOnce` — восемь горутин на одно событие; последовательная доставка поглощается одним high-water mark и посадку не ловила) · CSRF на КОНТРАКТНОЙ поверхности (`TestACrossSiteRequestCannotStartARun` — снятие CSRF из гарда `/v0` переживало всё, а это старт платного прогона с амбиентной кукой) · `RunSpent` считает только свой прогон и не считает открытые холды · обе ветки отложенного расчёта · `MarkSettled` одноразов · хендшейк второго движка отвергается · монотонность байтового хинта · пустой payload · ETA-ноль · черновой юнит не «сделан» · `paused` при нехватке баланса · `principal` падает ЗАКРЫТО · внутренний текст не течёт в `Problem`. ⚠ Одна посадка («убрать `and ended_at is null` из `RestartRun`») пережила и НЕ является ослаблением: `unique (run_id, attempt_no)` отвергает всех проигравших гонку, так что ровно один перезапуск проходит и без неё — записано, а не подчищено | fixed(P4, дерево сессии) | адверсариальное ревью (аудит посадками) |
|
||
| PD-143 | bug | minor | `internal/runs/spawn.go` `spec`, `internal/pgstore/runs.go` `RestartRun` | **Пиннинг версии движка (строка 139) был закрыт НАПОЛОВИНУ: запиненный путь читался каналом ремонта, но НЕ исполнялся резюмом.** `spec()` брал `Cfg.EngineBinary`, а `RestartRun` не переносил `engine_binary` в новую попытку, поэтому перезапущенный прогон шёл на том бинаре, который выкачен СЕЙЧАС, — то есть перевод продолжала другая программа, и «резюм другой версией только явным флагом» не выполнялось. Найдено собственной пост-сверкой диффа с промтом (не ревью и не батареей: обе половины компилировались и все тесты были зелёными) — **закрыто:** новая попытка НАСЛЕДУЕТ `engine_binary` предыдущей, `spec()` исполняет запиненный путь, а переход на другую сборку требует явного `TM_PLATFORM_RESUME_MAY_CHANGE_ENGINE`. Пины — `runs.TestAResumeStaysOnTheEngineBuildTheRunStartedWith` и `TestAResumeMovesToANewEngineBuildOnlyWhenItIsAllowed`; обе посадки («`spec` берёт из конфига», «`RestartRun` не наследует») падают | fixed(P4, дерево сессии) | пост-сверка диффа с промтом |
|
||
|
||
## Закрытые — дофикс P4 и ре-чек V2
|
||
|
||
| ID | Класс | Серьёзность | Где | Суть | Статус | Источник |
|
||
|---|---|---|---|---|---|---|
|
||
| PD-112 | standards | minor | `internal/httpapi/v0.go`, контракт 0.2.0 | **Реализация отдаёт статусы, которых спека у операций НЕ перечисляет — четыре класса, все проверены исполнением адверсариальным ревью:** `503` на старте прогона (деплой не может передать потолок/записать конец юнита) · `403` от CSRF-слоя на любом небезопасном запросе (спека описывает требование `X-TM-Client` в `securitySchemes`, но статуса ему не даёт) · `500` у любой операции при отказе стора (спека не перечисляет 5xx нигде) · `404` у `GET /usage` при отсутствующем аккаунте (спека даёт только 200/401; практически недостижимо — сессия ссылается на строку `users` внешним ключом). Ниже — исходная постановка по `503`. **Отказ `503` на старте прогона НЕ входит в перечисленные спекой статусы операции** (`400/401/404/409`). Он поставлен осознанно: когда деплой не может передать движку потолок (строка 145) или записать конец юнита, запрос ВАЛИДЕН, объект существует и состояния конфликта нет — то есть каждый из разрешённых кодов сообщил бы неправду. Правка спеки — не право зоны (правило промта: расхождение = вопрос оркестратору). Строка ждала решения владельца контракта — **закрыто РАТИФИКАЦИЕЙ (оркестратор №15, 09.08): `503` вносится в спеку правкой владельца контракта при лендинге; код зоны не меняется** | fixed(ратификация 09.08; правка спеки за оркестратором) | сессия P4 (самопроверка против спеки) |
|
||
| PD-129 | bug | minor | `internal/pgstore/sink.go`, `runs.go` | **Инверсия порядка блокировок между материализатором и финишером:** `RunSink` берёт `books … for update` и затем правит `runs`, а `FinishRun`/`PauseRun` правили `runs` и затем `books`. Два реконсилятора на одном прогоне (перекрытие поколений деплоя) дают взаимоблокировку в обе стороны — замерено ревью, `SQLSTATE 40P01` на обеих формулировках. Порчи нет (Postgres откатывает одну сторону), цена — провалившийся проход свипа и секунда детекта — ⚠ **ПЕРЕ-ДИСПОЗИЦИЯ 09.08: строка была закрыта ЛОЖНО.** Правка P4 привела к книге-первой только `FinishRun`/`PauseRun`; сам материализатор (`RunSink.Apply`) продолжал брать `run_attempts … for update` ПЕРВЫМ, а `RestartRun` — обновлять попытку до всего остального, и приёмка воспроизвела дедлок через реальные API (258 из 300 пар). Закрывающая формулировка описывала половину правки как целое. Действительно закрыто дофиксом — см. PD-145 | fixed(дофикс P4, дерево сессии; см. PD-145) | адверсариальное ревью (исполнением) |
|
||
| PD-144 | bug | **major, деньги/шов** | `internal/runs/spawn.go`, `internal/ingest/resync.go` | **Движку передавался ПРИРОСТ там, где его флаг означает НАКОПЛЕННЫЙ книжный потолок.** `--ceiling-usd` переопределяет `ceilings.book_usd` и сравнивается с `committed + reserved` книги на КАЖДОЙ резервации (`backend/internal/store/ledger.go` `Reserve`, `backend/cmd/tmctl/invocation.go` — «It caps the book's CUMULATIVE committed+reserved spend, not this run's increment»). Значит второй прогон книги, у которой накоплено ≥ прироста, отвергается первой же резервацией: движок выходит кодом 1, платформа обязана назвать это `failed`, работа не сделана, ретраи идентичны. Приёмка доказала обе стороны исполнением; сквозная проба пака этого не видела, потому что её фейк кумулятив не моделировал — **закрыто:** `runs.meter.bookCap` = `committed + прирост` (ратифицировано 09.08 ре-чеком V2 после PD-158; `reserved` в сумму НЕ входит), обе величины читаются ОДНИМ вызовом `status --json` перед стартом (`bookMeter`), `reserved_usd` внесён в аллоулист УКАЗАТЕЛЕМ (отсутствие ≠ ноль, как у committed), рестарт получает свежий отсчёт по тому же пути, фактически ушедшее значение хранится (`run_attempts.ceiling_arg_micro_usd`, миграция 00011). Пины: `runs.TestTheSecondRunOfABookIsGivenTheCumulativeCapAndNotItsOwnIncrement` и `TestAResumeIsGivenACapComputedFromTheMeterAsItStandsNow` — оба через фейк `ceilingJudge`, который отвергает потолок ПО ПРАВИЛУ ДВИЖКА; `TestAnAttemptWhoseMeterCannotBeReadIsNotStartedAtAll` покрывает отсутствующий `reserved_usd`. Проба приёмки на этом дереве: `--ceiling-usd 6.000000` при committed книги $3 | fixed(дофикс P4, дерево сессии) | приёмка P4 (F1, двусторонним исполнением) |
|
||
| PD-145 | bug | **major** | `internal/pgstore/sink.go` `Apply`, `runs.go` `RestartRun`, `internal/runs/reconcile.go` | **Инверсия блокировок из PD-129 была жива, а транзиентный сбой из-за неё уходил в КАРАНТИН.** `RunSink.Apply` брал `run_attempts … for update` первым, `RestartRun` правил попытку до всего остального, а `FinishRun`/`PauseRun` берут книгу первой — приёмка воспроизвела 258 дедлоков на 300 пар через реальные API. Усилитель хуже самого дедлока: `40P01` из `Apply` попадал в ветку «любая ошибка журнала = карантин», то есть проекция ЖИВОГО платного прогона слепла навсегда из-за блокировки, которая разрешилась сама — **закрыто:** порядок написан в одном месте и стал глобальным (`pgstore.lockBook`: books → runs → run_attempts → account_balances → reservations), книга блокируется первой в `Apply`, `RestartRun`, `StartRun` и `DeleteBook` (последний найден собственной сверкой всех транзакций пакета: цикла для него нет, но инвариант, у которого есть исключение, перестаёт быть инвариантом); классификация ошибки вынесена в `runs.quarantines`. ⚠ **Формулировка «карантин остаётся только логическим ошибкам» была НЕВЕРНА в части и исправлена ре-чеком V2:** первая редакция `IsTransient` знала только про дедлок и сериализацию, поэтому обрыв соединения с Postgres — то есть ШТАТНЫЙ рестарт управляемой базы (57P01/57P02/57P03, класс 08, сетевой сброс) — по-прежнему карантинил проекцию живого платного прогона НАВСЕГДА (пути снятия карантина в дереве нет). Доказано исполнением приёмкой. Теперь `IsTransient` покрывает класс 08, 57P0x, `pgconn.SafeToRetry` и любой `net.Error`; ошибки чтения файла — `*fs.PathError` и `net.Error` не удовлетворяют, поэтому битый журнал по-прежнему останавливает проекцию, как и должен. Кейсы внесены в таблицу пина. Пины: `pgstore.TestTheMaterializerTakesTheBookBeforeTheAttempt` и `TestARestartTakesTheBookBeforeTheAttempt` (порядок утверждается ПРЯМО — блокировка книги удерживается, операция обязана ждать её, а строка попытки обязана остаться свободной под `for update nowait`), `TestAMaterializerAndAReconcilerOnOneRunDoNotDeadlock` (конкурентный, 60×3), `runs.TestOnlyAJournalWeCannotReadStopsTheProjection` | fixed(дофикс P4, дерево сессии) | приёмка P4 (F2, исполнением) |
|
||
| PD-146 | standards | **major (пин)** | `internal/runs/spawn.go` `bookMeter` | **Сердце PD-124 не было запинено: посадка «нечитаемый отсчёт → (0, nil)» пережила ПОЛНУЮ батарею.** С ней расчёт идёт против базовой линии 0, то есть прогон оплачивает всю пожизненную трату книги — ровно тот дефект, который PD-124 объявил закрытым. Отказ спавну был построен и не проверен ни одним тестом — **закрыто:** `TestAnAttemptWhoseMeterCannotBeReadIsNotStartedAtAll` — три формы нечитаемости (вызов упал · нет `committed_usd` · нет `reserved_usd`), и в каждой утверждается, что юнит не создан И попытка не заклеймлена (`unit_name` пуст), значит следующий свип её повторит; хвост теста показывает, что после починки движка та же попытка стартует | fixed(дофикс P4, дерево сессии) | приёмка P4 (F3, посадка) |
|
||
| PD-147 | standards | **major (пин)** | `internal/runs/spawn.go` `spawnAttempt` | **Очистка устаревшего exit-маркера перед стартом не была запинена: удаление `os.Remove(spec.ExitMarker)` пережило полную батарею.** Залежавшийся маркер той же попытки читается как её окончание при первом же взгляде реконсилятора: прогон, движок которого только что запущен, будет завершён и рассчитан заживо — **закрыто:** `TestAStaleExitMarkerIsClearedBeforeTheUnitStarts` — маркер создаётся ДО спавна, после спавна обязан отсутствовать, а свип обязан оставить прогон живым с открытым холдом | fixed(дофикс P4, дерево сессии) | приёмка P4 (F4, посадка) |
|
||
| PD-148 | bug | **major, деньги** | `internal/runs/reconcile.go` `settle`, `internal/pgstore/credits.go` | **Расчёт по УСТАРЕВШЕМУ снапшоту свипа возвращал холд целиком попытке, которая потратила.** Свип реконсилит по списку, прочитанному в начале прохода; быстрый прогон успевает стартовать, потратить и выйти, пока свип идёт по предыдущим — и ветка «попытки не было» судила по `l.UnitName == ""` из снапшота, хотя в БД спавн уже записан. Приёмка доказала исполнением: charged 0.000000 за попытку, потратившую 0.500000; недоплата безвозвратна и никем не ищется — **закрыто:** `pgstore.ReleaseUnspawned` перечитывает `run_attempts.unit_name` `for update` В ТОЙ ЖЕ транзакции, что и деньги, и отказывает (`ErrAttemptSpawned`), если юнит есть; реконсилятор откладывает расчёт до следующего прохода, где снапшот уже содержит юнит и попытка рассчитывается по своей базовой линии. Пин: `runs.TestAStaleSnapshotDoesNotGiveBackTheHoldOfAnAttemptThatSpent` (холд не возвращается целиком в проходе со старым снапшотом; следующий свип списывает ровно потраченное) | fixed(дофикс P4, дерево сессии) | приёмка P4 (F5, исполнением) |
|
||
| PD-149 | bug | minor | `internal/config/config.go` `loadRunner` | **Относительный `TM_PLATFORM_STATE_DIR` не абсолютизировался, а маркер пишется и читается из РАЗНЫХ рабочих каталогов:** ExecStopPost исполняется юнитом, у которого `WorkingDirectory` — каталог книги, а демон читает от своего cwd. Конец прогона становится невидим, реконсилятор перезапускает прогон бесконечно — **закрыто:** `filepath.IsAbs` на буте, отказ с именем переменной; пин `config.TestARelativeStateDirectoryIsRefusedAtBoot` | fixed(дофикс P4, дерево сессии) | приёмка P4 (F6) |
|
||
| PD-150 | standards | minor | `internal/pgstore/sink.go` `ApplyStatus` | **«Метка давности» ре-синка была обещана промтом («честно, с меткой давности») и не построена:** `now` в `ApplyStatus` не использовался, поля свежести не было, и у читателя замершей проекции карантинной попытки не было ничего, что сказало бы, насколько старые цифры он видит — **закрыто:** `runs.last_resync_at` (миграция 00012) пишется КАЖДЫМ ре-синком; пин `pgstore.TestAResyncRecordsWhenItWasTaken` (нет метки до первого · метка равна времени вызова · вторая переписывает первую). ⚠ Названная девиация: на провод метка НЕ выходит — в контракте v0 у прогона поля свежести нет; читается оператором и той ручкой, которая появится вместе с полем | fixed(дофикс P4, дерево сессии; половина «на провод» — за контрактом) | приёмка P4 (F7) |
|
||
| PD-151 | doc | minor | `docs/platform-PROGRESS.md` раздел «Сессия P4» | **Числа отчёта расходились с истиной, а одно заявление было внутренне противоречиво:** тело журнала давало «105 → 203, +98» (истина на момент приёмки — 242/+137/−0), «36 новых строк = 25+10» (истина — 26 закрыто + 10 открыто), и «22 новых пина, каждый проверен своей посадкой (9/10, затем 9/9)» — 22 не покрываются девятью прогонами — **закрыто:** числа пересчитаны ИСПОЛНЕНИЕМ и приведены с командой (`105 → 261`, удалённых 0, добавленных 156 — итог дофикса с ре-чеком V2); арифметика 36 = 26 + 10 исправлена; заявление «каждый» снято (см. PD-142). Шапка журнала была права и не тронута | fixed(дофикс P4, дерево сессии) | приёмка P4 (F8) |
|
||
| PD-155 | doc | info | `deploy/README.md` | **Смена `TM_PLATFORM_STATE_DIR` осиротляет exit-маркеры идущих прогонов:** маркер пишется по пути, вычисленному при спавне, а читается по пути из текущей конфигурации, поэтому после смены каталога конец прогона невидим и прогон перезапускается — **закрыто:** строка в `deploy/README.md` — менять каталог только при отсутствии живых прогонов | fixed(дофикс P4, дерево сессии) | приёмка P4 (N4) |
|
||
| PD-156 | bug | info | `internal/runs/spawn.go` `spawnAttempt` | **Отказ ДЕПЛОЯ проверялся после вызова движка.** Дофикс перенёс чтение денежного отсчёта в начало спавна и тем поставил его ПЕРЕД дешёвыми отказами «нечем записать конец юнита» / «нечем передать потолок»: инстанс с неполной конфигурацией платил бы секундами CPU движка (`status --json` пере-нарезает исходник, строка 100) за каждый прогон на каждом свипе, чтобы прийти к ответу, зависящему только от конфигурации — **закрыто:** `runs.runnable()` вызывается первым в `spawnAttempt`, `spec` и `Start`; пин `TestAMisconfiguredDeploymentIsRefusedWithoutAskingTheEngine` (посадка «убрать ранний отказ» падает) | fixed(дофикс P4, дерево сессии) | собственная сверка диффа дофикса |
|
||
| PD-158 | bug | minor, деньги | `internal/runs/spawn.go` `meter.bookCap` | **Формула потолка из пинга ратификации (`committed + reserved (из status --json) + прирост`; сам D39.122 §2в ратифицирует ОБЯЗАННОСТЬ платформы пересчитывать «прирост → абсолют», буквальной формулы в решении нет — овер-атрибуцию поправило ревью доков) даёт прогону запас БОЛЬШЕ его холда, когда предыдущий процесс умер с незакрытой резервацией.** Найдено сверкой формулы с кодом движка: `store.Open` — путь ЗАПИСИ, которым идёт каждый `translate` — выполняет `recoverReservations` и обнуляет `reserved_usd` книги ДО первой судимой резервации (`backend/internal/store/store.go:88` и `:214`); `tmctl status` читает read-only и этот проход намеренно не делает (`store.go:108` говорит об этом прямо). Значит цифра, которую видит платформа, — ОСТАТОК мёртвого процесса, и к моменту сравнения её уже нет: движок остановится позже на её величину, расчёт упрётся в потолок холда, а леджер запишет «capped at the hold», как будто перерасходовал движок. Величина ограничена размером остатка (обычно одна оценка вызова), но путь достижим на каждом резюме после падения. **Сделано: `bookCap` считает `committed + прирост`** — отклонение в консервативную сторону (более узкий потолок может только остановить прогон раньше, перерасхода не даёт), названо в коде и запинено `TestAResumeIsGivenACapComputedFromTheMeterAsItStandsNow` (второе утверждение: остаток НЕ раздул потолок). Сама цифра по-прежнему читается и обязательна (отсутствие ≠ ноль) — она и есть доказательство, что отклонение безопасно: на спавне другого писателя нет (эксклюзивный лок + один живой прогон на книгу), значит любой `reserved` — остаток. ⚠ **Строка остаётся ОТКРЫТОЙ как вопрос:** отклонение от ратифицированной формулы — не право зоны, нужен ответ оркестратора (принять формулу в виде `committed + прирост` либо назвать другой разбор) **ЗАКРЫТО РАТИФИКАЦИЕЙ (оркестратор №15, 09.08, ре-чек V2):** принята формула `committed + прирост`; аргументация подтверждена оркестратором исполнением обеих формул против гейта движка — ратифицированная переплачивала запасом ровно на leftover-reserved | fixed(ратификация 09.08) | собственная сверка формулы с кодом движка при F1 |
|
||
| PD-159 | bug | **major, деньги** | `internal/runs/reconcile.go` `settle`, `internal/pgstore/runs.go` `SpendBound` | **Отложенный расчёт прогона оплачивал работу СЛЕДУЮЩЕГО прогона той же книги, и тот платил за неё ещё раз.** Расчёт читает пожизненный счётчик КНИГИ в момент ПОВТОРА, а откладываться он вправе (движка не спросить). Завершённый-но-нерассчитанный прогон при этом не мешает новому: `HasLiveRun` смотрит только на `finished_at`. Замерено: прогон, стоивший $0.10, списан на $2.10 — своя трата плюс всё, что успел потратить преемник, — после чего преемник заплатил ту же сумму снова; переплата ограничена холдом. Найдено ДВУМЯ независимыми верификаторами самопроверки, каждый воспроизвёл исполнением, и третий раз воспроизведено мной перед починкой — **закрыто:** `SpendBound` — наименьшая базовая линия среди попыток этой книги, стартовавших ПОЗЖЕ; она снята до того, как та попытка что-либо добавила, и после того, как эта остановилась, поэтому является точной верхней границей. Расчёт берёт минимум из неё и текущего счётчика. Пин: `TestADeferredSettlementIsNotChargedForTheNextRunOfTheSameBook` (первый платит $0.10, второй — свои $2.00, баланс и леджер сходятся) | fixed(дофикс P4, дерево сессии) | самопроверка дофикса (два верификатора, независимо, исполнением) |
|
||
| PD-160 | standards | minor (пин) | `internal/runs/reconcile.go` `drainJournal` | **Проводка «транзиентный сбой НЕ карантинит» не пинилась: пин стоял на чистой функции `quarantines`, а удаление ветки, которая её ВЫЗЫВАЕТ, переживало батарею.** Регрессия этой формы тихо карантинит проекцию живого платящего прогона — то есть ровно тот дефект, который F2 объявил закрытым — **закрыто:** `TestADeadlockDoesNotStopTheProjection` гонит НАСТОЯЩИЙ дедлок через весь путь (транзакция берёт строки в обратном порядке, Postgres рвёт цикл; раунд, где жертвой стала наша сторона, и есть предмет теста) и утверждает на КАЖДОМ раунде, что попытка не в карантине. Посадка «убрать ветку» падает за 1.9 с | fixed(дофикс P4, дерево сессии) | самопроверка дофикса (посадка) |
|
||
| PD-161 | bug | minor, деньги | `internal/pgstore/runs.go` `RecordSpawn` | **Повторная заявка права на спавн ПЕРЕЗАПИСЫВАЛА базовую линию.** «Не удалось создать юнит» — не то же, что «юнит не создан»: `systemd-run`, убитый по таймауту ПОСЛЕ подачи запроса, рапортует ошибку и оставляет движок работать. Право отдаётся обратно (`ReleaseSpawnClaim`), следующий свип заявляет его снова и кладёт в базовую линию счётчик, который этот же движок двигает, — попытка потом оплачивает разницу от цифры, уже включающей её собственную работу (недоплата, которую никто не ищет) — **закрыто:** повторная заявка сохраняет и базовую линию, и записанный аргумент потолка (`coalesce` / `case when`), там где юнит действительно не создан значения совпадают. ⚠ **Ре-чек V2 показал, что этим строка закрыта НАПОЛОВИНУ:** в БД значения сохранялись, а движку на ретрае уходил потолок, пересчитанный по СВЕЖЕМУ счётчику — то есть включающий трату собственного «призрака», — и форензик-колонка 00011 на этом пути лгала (замерено приёмкой: handed 3.400000 против stored 3.000000). Закрыто по-настоящему: на ретрае (`SpendBaseline != nil && CeilingArg > 0`) спавн ПЕРЕДАЁТ сохранённый аргумент, а не пересчитанный. Пин: `TestAReclaimedAttemptKeepsTheBaselineItFirstRecorded` — теперь утверждает и базовую линию, и равенство handed == stored | fixed(дофикс P4, дерево сессии) | самопроверка дофикса (ревью вне карты, чтением) |
|
||
| PD-167 | doc | info | `internal/pgstore/migrations/00009_runner.sql:37-39` | Комментарий DDL обещает «хинт, не ведущий к `seq = last_seq + 1`, отбрасывается, и файл перечитывается с начала» — код так не делает: пропасть ведёт к карантину проекции и переходу на ре-синк. Расхождение док↔код, поведение верное — **закрыто ре-чеком V2:** комментарий приведён к тому, что делает тейлер. ⚠ Ре-чек назвал эту строку «застывшим комментарием миграции 00011»; предмет строки — комментарий 00009, а 00011 нёс отменённую формулу потолка и исправлен вместе с ней (обе миграции этим паком и написаны, нигде не применялись, поэтому их отпечатки в `migrations.sha256` обновлены с явной причиной в шапке файла) | fixed(ратификация + дофикс V2, дерево сессии) | самопроверка дофикса (ревью вне карты) |
|
||
| PD-171 | bug | minor, деньги | `internal/runs/reconcile.go` `settle` | **Счётчик книги НИЖЕ собственной базовой линии попытки списывал $0 молча.** Это вырожденный случай — БД проекта подменили или восстановили из копии, — и клампить в ноль правильно (платить аккаунту за подмену файла код решать не вправе), но молчать нельзя: расчёт в ноль обнаруживался бы только по балансу — **закрыто:** WARN (не INFO: предмет — деньги) с фактом и без цифр (D39.84); пин `TestAMeterThatWentBackwardsSettlesAtNothingAndSaysSo` проверяет и отсутствие списания, и наличие строки, и что цифры в неё не попали | fixed(дофикс V2, дерево сессии) | ре-чек V2 (оркестратор №15) |
|
||
|
||
## Закрытые — третий раунд P5 (ре-чек оркестратора, 14.08)
|
||
|
||
| ID | Класс | Вес | Где | Что и чем закрыто | Статус | Кем найдено |
|
||
|---|---|---|---|---|---|---|
|
||
| PD-197 | standards | **major (гейт)** | `Makefile` `tools-check`, `go.mod` | **Гейт тулчейна не гейтил, а три дока утверждали обратное.** Подъём floor 1.26.5 → 1.26.6 (пять адвизори stdlib) был сделан переменной `GO_MIN_VERSION`, которую читало только сообщение об ошибке, тогда как проверкой оставался регекс `go1\.26\.([5-9]|[0-9]{2,})` — он принимал ровно ту 1.26.5, ради отказа от которой floor и поднимали, и ставил 1.26.10 ниже 1.26.9. Клейм «хост на 1.26.5 получит отказ» стоял в журнале ×2, `STACK_DECISIONS` и комменте Makefile — **закрыто:** цель `version-check` СРАВНИВАЕТ версии (`sort -V`, пререлизы `rc`/`devel` отвергаются отдельно) и берёт версию из переменной, чтобы её судили версиями, которых на хосте нет; `go.mod` получил `toolchain go1.26.6` — его читает всякая сборка, мимо make тоже (`GOTOOLCHAIN=auto` скачает, `=local` остановится). Пины `gates.TestTheToolchainGateComparesVersionsRatherThanMatchingThem` (таблица из 11 версий) и `gates.TestGoModPinsTheSameToolchainTheBatteryDemands`, обе посадки падают; три клейма переписаны на описание механизма | fixed(третий раунд, дерево сессии) | ре-чек оркестратора (FP5-9) |
|
||
| PD-198 | doc | info | `internal/pgstore/runs.go` `PauseRun`, `internal/books/parse.go` | **Комментарии описывали до-фиксное поведение — в том числе на пути, который удаляет файл пользователя.** `PauseRun` обещал проверку стопа «в том же стейтменте» (стоит отдельный `select … for update` в той же транзакции); `parse.go` объявлял отказ источника «терминальным с первого ответа», хотя дофикс провёл КАЖДЫЙ ответ движка через бюджет попыток — **закрыто:** оба текста приведены к коду; правок поведения не потребовалось. ⚠ Дописка: P6 переписал текст `parse.go` ещё раз под полосу отказов, где терминален ровно один класс (PD-196). ⚠ **испр. оркестратором №16 15.08 при лендинге:** сессия P6 переименовала эту строку в PD-199 и завела под номером PD-198 вторую строку про ту же половину `parse.go` с ложным обоснованием «номер строки не получил» — переименование откачено (ID стабилен навсегда, коммит-первоисточник `69d485a`), содержимое второй строки слито сюда; номер PD-199 остаётся за открытой строкой `daily_ceiling` | fixed(третий раунд, дерево сессии) | ре-чек оркестратора (хвосты а, б) |
|
||
|
||
## Закрытые — дофикс-2 P5 (кросс-семейное ревью дофикса, 13.08)
|
||
|
||
| ID | Класс | Вес | Где | Что и чем закрыто | Статус | Кем найдено |
|
||
|---|---|---|---|---|---|---|
|
||
| PD-192 | bug | **major, данные пользователя** | `internal/books/parse.go` `manifest`, `Parse` | **Пропавший КОРЕНЬ хранилища читался как вина каждой книги.** `os.Stat(workdir)` даёт ENOENT и на снесённом каталоге книги, и на несмонтированном `BooksDir`; первое терминально по устройству (FP5-3), значит размонтированный том заставлял ОДИН проход свипа терминально отклонить ВСЕ книги в интейке с `source_unreadable` — причиной, которая винит файл пользователя и не имеет обратного хода (PD-175). Путь создан моим же фиксом FP5-3 — **закрыто:** `ErrStorageGone` отличён от `ErrDirectoryGone` (корень спрашивается прежде, чем винить книгу), причина `storage_unavailable` не терминальна, бюджета не тратит и на книге не хранится; предикат `waitsForTheDeployment` собрал оба «ждущих деплой» случая. Пин `books.TestAVanishedStorageRootIsNotEveryBooksFault`, посадка падает ⚠ **Дополнено ре-чеком (FP5-10): первая редакция закрывала не тот сценарий.** Гард спрашивал `Stat(BooksDir)`, а том, смонтированный РОВНО в `BooksDir`, оставляет после размонтирования пустой mountpoint — `Stat` успешен, и все книги снова терминально отклонялись; бут безусловным `MkdirAll` пересоздавал корень и маскировал пропажу. Теперь решение принимается по СЕНТИНЕЛУ провижининга `.tmplatform-books`, который пишет только первая загрузка (`markStorage`, `O_EXCL`) и не пишет бут: ни unmount, ни `MkdirAll` его не подделывают. Пин `books.TestAnUnmountedVolumeLooksLikeAnEmptyRootAndStillIsNotTheBooksFault`; первая редакция ПИНА посадку пережила (без сентинела ждёт всё) — добавлено утверждение, что загрузка сентинел пишет, иначе терялась терминальность крэш-окна FP5-3 | fixed(третий раунд, дерево сессии) | кросс-семейное ревью дофикса (Fable 5, линза интейка) |
|
||
| PD-193 | bug | **major, деньги** | `internal/pgstore/runs.go` `PauseRun` | **Третий закрывающий путь без гарда живой попытки** (после PD-181 и FP5-2): пауза не проверяла, что закрываемая попытка ещё жива и принадлежит этому прогону (апдейт попытки шёл даже без `run_id`). Проход старого поколения при перекрывающемся деплое паузит прогон, уже рестартованный в живую попытку 2: прогон отвечает `paused/credit_exhausted` под тратящим движком, а холд попытки 2 не виден ни в `ListLiveRuns`, ни в `UnsettledRuns` — **закрыто:** тот же `exists (a.id = $4 and a.run_id = runs.id and a.ended_at is null)`, что у соседей, плюс `run_id` в апдейте попытки. Пин `pgstore.TestAPauseFromAnOldSnapshotDoesNotCloseARunOverALiveAttempt`, посадка падает | fixed(дофикс-2, дерево сессии) | кросс-семейное ревью дофикса (Fable 5, линза денег), воспроизведено дважды |
|
||
| PD-194 | bug | minor | `internal/pgstore/sink.go` `Begin` | **Синк законченной попытки усыновлял хендшейк следующей.** Стоп ДО спавна оставляет попытку закрытой и без `engine_run_id` — форма, невозможная до P5; устаревший материализатор биндил на неё `engine_run_id` попытки-заместителя и материализовал тот же журнал второй раз, удваивая счётчики глав и юнитов (деньги не двигались) — **закрыто:** бинд отказан для законченной попытки. Пин `pgstore.TestAMaterializerOfAnEndedAttemptDoesNotAdoptTheNextAttemptsEngine`, посадка падает | fixed(дофикс-2, дерево сессии) | кросс-семейное ревью дофикса (Fable 5, линза денег) |
|
||
| PD-195 | bug | minor, деньги | `internal/runs/reconcile.go` `settle` | **Возврат холда «прогона, который не запускался», ждал ответа движка.** Ветка «юнита не было — вернуть холд целиком» стояла ПОСЛЕ обязательного `tmctl status`, а хост, производящий эту ситуацию, — ровно тот, где движок не запускается: `Status` падает на каждом проходе, и деньги остаются зарезервированными навсегда (видимыми, но запертыми) — **закрыто:** ветка идёт до вызова движка; `ReleaseUnspawned` перепроверяет строку под замком, поэтому снапшот, который успели заспавнить, отвергается там. Пин `runs.TestTheHoldOfARunThatNeverStartedComesBackOnAHostWhoseEngineCannotAnswer`, посадка падает | fixed(дофикс-2, дерево сессии) | кросс-семейное ревью дофикса (гипотеза), подтверждено зоной исполнением |
|
||
|
||
## Закрытые — эра P5 (загрузка книги · стоп и резюм · наблюдаемость)
|
||
|
||
| ID | Класс | Серьёзность | Где | Суть | Статус | Источник |
|
||
|---|---|---|---|---|---|---|
|
||
| PD-72 | hardening | info | `internal/httpapi/server.go:88` | **Отсутствие ОБЩЕГО лимита тела над маршрутами не наблюдаемо ничем.** Пер-маршрутность (PD-35/PD-53) держится на том, что вложенный `MaxBytesReader` только УЖЕСТОЧАЕТ: это запинено `TestBodyCapIsPerRouteBecauseNestingOnlyTightens`. Но возврат внешнего слоя в `New` батарею переживает, потому что ни один маршрут не просит потолок БОЛЬШЕ дефолтного — наблюдаемым дефект станет ровно тогда, когда появится загрузка книги. Строка заведена, чтобы это не выяснилось молча: тест обязан приехать ВМЕСТЕ с маршрутом загрузки — **закрыто:** маршрут загрузки приехал ВМЕСТЕ со своим потолком и своим тестом. `POST /v0/books` регистрируется `guard(d.Upload.MaxBytes, …)`, пин — `httpapi.TestAnUploadLargerThanTheRouteAllowsIsRefusedAsTooLarge` (413 + problem+json на теле выше потолка И 201 на теле ниже него: потолок, который отвергает всё, не потолок), плюс `TestTheUploadLimitBelongsToTheUploadRouteAlone` — заявка на прогон с телом выше ДЕФОЛТНОГО потолка отвергается, то есть поднятие лимита одного маршрута не подняло его для остальных. Посадка «вернуть `DefaultMaxBody` на маршрут загрузки» падает | fixed(P5, дерево сессии) | ревью P2 (линза doc-vs-code) |
|
||
| PD-114 | standards | minor | `internal/config/`, `deploy/` | **Конфигурация: 27 переменных окружения, НИ ОДНОГО флага у демона и никакой печати эффективной конфигурации при старте** (грепнуто). Выбор «только окружение» сам по себе мейнстрим (12-factor) и записан решением в доккомменте `config.go`; деплой использует systemd-нативные `EnvironmentFile=`+`LoadCredential=`. Ниже нормы другое: у оператора нет ни `-version`, ни «проверить конфиг и выйти», ни строки «вот что реально применилось» — не подхватившийся `EnvironmentFile` обнаруживается по поведению, а плоское пространство из 27 имён это та точка, где обычно переходят на файл. ⚠ Вопрос ВЛАДЕЛЬЦА (08.08), и он же нашёл этим вопросом реальный дефект в этом паке: дефолт «$/глава» лежал в двух местах (config клал ноль, разрешал вызыватель) — исправлено, дефолт резолвится только в `config`. **Ратифицировано 09.08 (оркестратор №15 по делегации владельца): «только окружение» ОСТАЁТСЯ; строка переформулирована в задачу — печать ЭФФЕКТИВНОЙ конфигурации при старте с редакцией секретов** (значение каждой настройки и откуда оно взялось: дефолт · переменная · файл; `*_FILE` печатается фактом наличия, не содержимым). Остаётся открытой до постройки — **закрыто:** `config.Config.Settings` несёт КАЖДУЮ прочитанную переменную с источником (`default` · `environment` · `file`), `LogEffective` печатает их построчно на старте. Редакция двойная и обе — правила проекта, а не вкус: секрет (DSN несёт пароль) и ЛЮБАЯ денежная сумма (D39.84/PD-99) печатаются фактом наличия и источником, без значения. Пины: `TestEverySettingThisServiceReadsIsPrinted` (сверка со СПИСКОМ `TM_PLATFORM_*` из исходника `config.go` — переменная, добавленная без записи, роняет батарею в том же коммите), `TestASecretIsNamedAndNeverPrinted`, `TestAConfiguredAmountIsNeverPrinted` | fixed(P5, дерево сессии) | вопрос владельца 08.08 + сессия P4 |
|
||
| PD-140 | bug | info | `internal/runs/reconcile.go` `Stop`, `internal/httpapi/` | `Service.Stop` построен и не подключён ни к чему: `httpapi.Runs` даёт только `Bounds`/`Start`, у `tmplatformctl` команды остановки нет. То есть контрол-плейн не умеет остановить прогон, который сам же запустил. Ручки `POST /runs/{id}/stop` и `/resume` в список работ промта не входили, поэтому это НЕ девиация пака, а честно названный хвост: остановить прогон сегодня можно только `systemctl --user stop`. Гейт: закрыть вместе с ручками стопа/резюма — **закрыто:** ручки `POST /v0/runs/{runId}/stop` и `/resume` построены по спеке (202 + `Run`, 404 чужому, 409 на невозможное действие), `runs.Service.Stop` записывает НАМЕРЕНИЕ стопа в Postgres ДО сигнала и зовёт systemd, `Resume` переиспользует механику перезапуска реконсилятора (`reopen`) вместо второй копии денежной арифметики. Пины: `TestTheStopIsRecordedBeforeSystemdIsAsked` (хук внутри фейка systemd читает БД и требует, чтобы намерение уже было), `TestAStopTheUnitDidNotTakeIsAskedAgainByTheSweep`, `TestResumeContinuesTheRunWithWhatIsLeftOfItsBudget`, `TestARunOfAnotherAccountCannotBeStoppedOrResumed` | fixed(P5, дерево сессии) | адверсариальное ревью (чтение) |
|
||
| PD-169 | hardening | info | `cmd/tmplatformd/runner.go` свип | **Свип имеет один бюджет времени (2 минуты) на ВСЕ прогоны, а каждый спавн/расчёт стоит вызова `tmctl status` (~1.5 с CPU и больше).** Десяток прогонов, чьи журналы нечитаемы, упирается в таймаут, и хвост списка (`order by started_at` — всегда один и тот же порядок) голодает неограниченно долго. Наблюдаемости, которая это показала бы, нет вовсе (П-11). Закрывать — бюджетом НА ПРОГОН плюс метрикой длительности свипа — **закрыто:** бюджет НА ПРОГОН (`runs.Config.RunBudget`, дефолт 60 с) плюс метрика длительности свипа и счётчик недоведённых проходов (`tm_platform_sweep_duration_seconds`, `tm_platform_sweep_unfinished_total`). Пин — `TestOneSlowRunDoesNotEatThePassOfTheWholeSweep`: первый прогон висит дольше бюджета, второй в том же проходе всё равно реконсилируется | fixed(P5, дерево сессии) | самопроверка дофикса (ревью вне карты) |
|
||
| PD-178 | standards | info | `internal/runner/systemd_test.go`, `Makefile` | **Гейт systemd-тестов сверял ТЕКСТ сообщения, а не способность хоста, и перестал гейтить.** `systemdOrSkip` скипал по подстроке «Failed to connect to bus», а systemd 259 отвечает «Failed to connect to user scope bus» — на хосте без пользовательского менеджера три теста ПАДАЛИ вместо скипа. ⚠ Первая редакция этой строки утверждала, что формы скипа для systemd в зоне нет вовсе — неверно: форма была и сломалась о чужую правку строки. ⚠ Вторая посылка тоже устарела: состояние пользовательского менеджера на стенде ПЛАВАЕТ между сессиями — в одной его нет (`Linger=no`, sudo нет), в следующей он жив (`systemctl --user show` отвечает `Version=259.5`) и все три теста проходят; замерено обоими способами в один день — **закрыто:** гейт спрашивает СПОСОБНОСТЬ (доходит ли процесс до своего менеджера), скип громкий и поимённый, как у БД-тестов; пин `runner.TestTheSystemdGateAsksAboutTheCapabilityAndNotAMessage` падает, если гейт снова начнёт сверять текст | fixed(P5-дофикс, дерево сессии) | сессия P5 (батарея на стенде) |
|
||
| PD-181 | bug | **minor, деньги** | `internal/pgstore/sink.go` `FinishRun`, `internal/runs/reconcile.go` `finishStopped` | **Устаревший снапшот свипа мог до-финишировать уже РЕЗЮМИРОВАННЫЙ прогон и оставить холд новой попытки вне всех списков.** Маркер завершённой попытки остаётся на диске (их никто не удаляет), а `FinishRun` гардил только `finished_at is null` — проход, держащий снапшот попытки 1, закрывал прогон, который стоп-резюм успел вернуть к жизни; попытка 2 с открытой резервацией не попадала ни в `ListLiveRuns` (там `finished_at is null`), ни в `UnsettledRuns` (там `ended_at is not null`). Достижимо стало ровно с появлением резюма, то есть этим паком — **закрыто:** `FinishRun` пишет только если закрываемая попытка ещё живая, и возвращает признак «закрыл», по которому вызыватель решает, считать ли деньги; пин `runs.TestAStaleSweepDoesNotReFinishAResumedRunFromTheOldAttemptsMarker`, посадка «снять гард» падает | fixed(P5, дерево сессии) | кросс-семейное ревью P5 (Fable, исполнением) |
|
||
| PD-182 | bug | **minor, деньги** | `internal/runs/reconcile.go` `finishStopped` | **Стоп закрывал прогон под ЖИВЫМ движком, если заявка на спавн была отдана назад после того, как юнит уже создался.** `ReleaseSpawnClaim` обнуляет `unit_name`, когда «юнит не удалось создать», а это не то же самое, что «не создался» — `systemd-run`, убитый после запроса, оставляет движок работать (зона это уже знает: ради этого случая сохраняется базовая линия). Путь стопа читал пустое имя как «процесса не было» и закрывал прогон; движок продолжал тратить, сигнала до него не доходило, следующий прогон книги впитывал его трату в свою базовую линию — **закрыто:** ненулевая `spend_baseline` при пустом имени юнита считается надгробием попытки спавна, имя юнита детерминировано, и платформа спрашивает systemd `Alive` прежде чем закрывать; живой юнит получает стоп. Пин `runs.TestAStopDoesNotCloseARunWhoseGivenBackClaimLeftAnEngineRunning` | fixed(P5, дерево сессии) | кросс-семейное ревью P5 (Fable, исполнением) |
|
||
| PD-183 | bug | **minor** | `internal/books/parse.go`, `internal/jobs/jobs.go` | **Грация клейма разбора (10 мин) была КОРОЧЕ таймаута задания очереди (15 мин): свип воровал клейм у живого парса.** В окне 10–15 минут на одной проектной директории оказывались два `tmctl manifest`; проигравший умирал на эксклюзивном локе движка с exit 1, а exit 1 — это то, чем движок говорит «источник не разобрать», и книга отклонялась ТЕРМИНАЛЬНО с удалением исходника — **закрыто:** грация написана как `jobs.JobTimeout + 5m`, пара утверждается тестом `books.TestTheClaimGraceOutlivesTheQueuesJobTimeout`; вдобавок терминальная запись возможна только пока клейм ещё наш (`parse_started_at = <наш>`), а один exit 1 больше не терминален вовсе | fixed(P5, дерево сессии) | кросс-семейное ревью P5 (Fable, исполнением) |
|
||
| PD-184 | bug | info | `internal/metrics/metrics.go` | **Лейбл `method` брался из запроса как есть.** Для маршрута он свёрнут в `(unmatched)`, а метод — токен, который выбирает вызывающий, и на неразобранном запросе он его же и придумывает: число рядов становится чужим ресурсом — **закрыто:** закрытый список методов, всё прочее — `(other)`; пин `metrics.TestAnUnroutedRequestGetsOneSeriesAndNotOnePerPath` | fixed(P5, дерево сессии) | кросс-семейное ревью P5 (Fable) |
|
||
| PD-186 | bug | **minor, деньги** | `internal/pgstore/sink.go` `FinishUnspawnedStop` | **Закрытие «стопа до спавна» не имело гарда живой попытки** — того самого, что получил `FinishRun` (PD-181). Устаревший проход мог закрыть уже РЕЗЮМИРОВАННЫЙ прогон, и холд второй попытки выпадал из обоих списков; вдобавок каждый следующий резюм отвечал 409 навсегда, потому что продолжать было бы уже завершённый прогон — **закрыто:** попытка обязана быть живой (`ended_at is null`) и принадлежать этому прогону; пин `runs.TestAStaleUnspawnedStopDoesNotCloseAResumedRun`, посадка «снять гард» падает | fixed(P5-дофикс, дерево сессии) | приёмка P5 (FP5-2) |
|
||
| PD-187 | bug | minor | `internal/books/parse.go` `reject`, `manifest` | **Крэш между сносом каталога и записью строки оставлял книгу в `parsing` НАВСЕГДА.** Порядок «каталог, потом строка» был выбран как самоизлечивающийся, и посылка была ложной: снесённый каталог читался как «нет конфигурации», а эта причина терминальной не становится никогда — книга ждала конфигурацию, которую некуда положить — **закрыто:** пропавший КАТАЛОГ отличён от отсутствующей конфигурации (`ErrDirectoryGone`) и терминален сразу; пин `books.TestABookWhoseDirectoryIsGoneIsRejectedRatherThanLeftWaiting` | fixed(P5-дофикс, дерево сессии) | приёмка P5 (FP5-3) |
|
||
| PD-188 | bug | minor | `internal/books/parse.go`, `internal/pgstore/books.go` `ClaimParse` | **Ожидание конфигурации ЖГЛО бюджет разбора.** `ClaimParse` считает каждую заявку, а книга без конфигурации заявляется раз в грацию бесконечно — после пяти циклов ожидания бюджет был исчерпан, и ПЕРВЫЙ же ответ движка становился терминальным мгновенно, с удалением исходника; движок отдаёт exit 1 и на опечатку в `book.yaml` — **закрыто:** попытка возвращается (`RefundParseAttempt`), когда движок не был спрошен вовсе; пин `books.TestWaitingForAConfigurationDoesNotBringDeletionCloser` | fixed(P5-дофикс, дерево сессии) | приёмка P5 (FP5-4) |
|
||
| PD-189 | bug | minor | `cmd/tmplatformd/runner.go`, `internal/books/parse.go` `Sweep` | **Бэкстоп гнал разбор под бюджетом прохода (2 мин) против 15 минут очереди.** Восстановление большой книги убивалось дедлайном свипа, убийство читалось как «хост не может запустить движок», попытка списывалась — и так каждый проход, пока книга не отклонялась ЗА СВОЙ РАЗМЕР; вдобавок запись отказа шла на просроченном контексте и терялась — **закрыто:** у прохода интейка свой бюджет (`jobs.JobTimeout + 1m`), у каждой книги внутри — свой (`jobs.JobTimeout`), терминальные записи идут на контексте, переживающем дедлайн; пин `books.TestOneBookInTheSweepGetsABudgetAParseCanLiveIn` ⚠ Дополнено ре-чеком (хвост в): бюджет КНИГИ не равен бюджету ПРОХОДА — вторая книга прохода получала остаток от первой и жгла попытку на обрезанном дедлайне. Проход, которому осталось меньше `jobs.JobTimeout`, книгу больше не НАЧИНАЕТ (клейм берётся внутри `Parse`, поэтому отложенная книга не тратит ничего). Пин `books.TestAPassTooShortForAParseStartsNoneAtAll` | fixed(третий раунд, дерево сессии) | приёмка P5 (FP5-5) |
|
||
| PD-190 | bug | info | `internal/httpapi/v0.go` `uploadFailed` | **Просроченный дедлайн загрузки уходил 500.** Медленный клиент — не сломанный сервис, и 500 говорит клиенту обратное о том, помогает ли повтор — **закрыто:** 408 problem+json; вопрос о коде вне перечня спеки внесён в пакет PD-180; пин `httpapi.TestAnUploadThatOutlivesItsDeadlineIsNotAnInternalError` | fixed(P5-дофикс, дерево сессии) | приёмка P5 (FP5-6) |
|
||
| PD-191 | bug | minor | `internal/pgstore/runs.go` `PauseRun` | **Стоп, пришедший в окно расчёта, отвечал `paused/credit_exhausted` вместо `stopped`** — то есть «кончились деньги» вместо «владелец остановил» — **закрыто:** пауза отказывает при висящем интенте (`ErrStopRequested`), реконсилятор заканчивает прогон стопом; пин `runs.TestAStopDuringSettlementOutranksThePause` | fixed(P5-дофикс, дерево сессии) | приёмка P5 (FP5-8а) |
|
||
|
||
|
||
## Закрытые — эра P6 (потребительская половина шва эмиттера · интейк формы Б · дев-стенд)
|
||
|
||
| ID | Класс | Серьёзность | Где | Суть | Статус | Источник |
|
||
|---|---|---|---|---|---|---|
|
||
| PD-200 | bug | minor | `internal/ingest/tail.go`, `internal/runs/spawn.go` | **Тейлер УСЫНОВЛЯЛ чужой поток, лежащий на смещении попытки** — и это было три дефекта в одной строке, а не наблюдение. Воспроизведено: журнал книги, в котором до нашего hello лежит чужой (`tmctl`, запущенный оператором руками в каталоге книги), давал (1) adoption чужого `engine_run_id` через `Begin`, (2) материализацию его событий на НАШУ попытку — включая `ceiling`, то есть `paused` у прогона, которого никто не паузил, и (3) отказ на нашем СОБСТВЕННОМ hello («seq 3, want 1») с карантином проекции платящего прогона навсегда. **Закрыто:** платформа НАЗЫВАЕТ поток до создания юнита (`runs.engineStreamID`, отдаётся движку как `TM_TRACE_ID`, строка 102) и пишет имя в той же транзакции, что claim; `mine` теперь спрашивает про ПРИМЕНЁННЫЕ строки (`LastSeq > 0`), а не про байтовый хинт, который у новой попытки ненулевой до первого чтения. Пины: `ingest.TestAForeignStreamAtOurOffsetIsNeverAdopted` · пин окружения юнита в `runs`. Живая проба: `engine_run_id = tm-stream-run_…-1` в БД стенда до старта движка | fixed(P6, дерево сессии) | watch-пункт промта P6, воспроизведён |
|
||
| PD-60 | bug | minor | `internal/ingest/supervisor.go`, шов | ⚠ **ПЕРЕ-ДИСПОЗИЦИЯ (эррата №15, 07.08): постановка строки устарела.** Она рассуждает про 64 КиБ пайпа, а ратифицированный транспорт (D39.106 п.2) — тейл `events.jsonl` с курсором: у файла обратного давления в этом смысле нет вовсе, зато появляются свои свойства (fsync-политика, ротация, отставание тейлера, поведение при заполненном диске). Строка живёт, но переформулируется вместе со строкой 103 — не «блокировать движок или ронять события», а «что делает движок, когда журнал не пишется». **Обратное давление не спроектировано, и канал тут ни при чём.** Пайп держит 64 КиБ; если синк платформы встанет на Postgres, движок заблокируется в `write(2)` — на часы, без контекста и дедлайна, прервать нечем. Сегодня не проявляется только потому, что материализатора ещё нет: `Ingest` кормит `Sink` синхронно, и латентность БД станет латентностью движка. Нужна ограниченная очередь у читателя и ЯВНАЯ политика на её переполнение: блокировать движок (корректно, но прогресс прогона привязан к доступности БД) или ронять события с маркером `events_dropped` (быстро, но журнал начинает врать). Выбрать и записать — обе позиции законны, молчаливой третьей нет ⚠ **ЗАКРЫТИЕ ПО ФАКТУ КОДА (P6):** политика выбрана ЯВНО и залендена движком (D39.131) — эмиттер ДЕГРАДИРУЕТ ГРОМКО и прогон продолжается: `emit` не возвращает ошибку никогда, первый отказ пишется ERROR один раз на серию, строки остаются в outbox и повторяются целым префиксом, так что транзиентный ENOSPC/EIO самолечится, а fsync на событие сознательно не делается (журнал — проекция коммитов SQLite при `synchronous=NORMAL`, проекция не может быть durable сильнее источника). Блокировать прогон отвергнуто явно. У платформы обратного давления в этом смысле нет по построению: она ТЕЙЛИТ файл, а не кормится пайпом; её собственная деградация — карантин проекции с ре-синком (PD-105). Открытого остатка у строки нет | fixed(P6, дерево сессии) | приёмка P1 (абстрактный разбор двумя агентами, оба независимо) |
|
||
| PD-61 | bug | info | шов, строка 103 | Два свойства эмиттера, которые надо задать ДО его постройки, иначе они станут миграцией. **(а) Сброс буфера на выходе:** `bufio.Writer` вокруг потока плюс `os.Exit`/`log.Fatal` пропускает `defer` и теряет последние события — ровно те, что сообщают об окончании прогона. **(б) Хвост при падении платформы:** содержимое непрочитанного пайпа умирает вместе с читателем. Если требование «платформа перезапустилась, прогон продолжается» когда-нибудь появится, ответ — НЕ сокет (он даёт переподключение без возобновления), а журнал файлом: движок дописывает NDJSON в `<jobdir>/events.ndjson`, платформа тейлит его с чекпойнтом смещения в Postgres. Это переживает и падение платформы, и даёт реплей бесплатно. Оба агента пришли к этому независимо; прецедент — Bazel Build Event Protocol (файл или gRPC, не пайп родителя) ⚠ **ЗАКРЫТИЕ ПО ФАКТУ КОДА (P6):** оба свойства заданы до постройки и построены. (а) Буфера НЕТ ВОВСЕ — `runevents.Journal` пишет строку одним `write(2)` без `bufio`, поэтому `os.Exit`/`log.Fatal`/паника не могут потерять ни `finished`, ни `ceiling`: терять нечего. (б) Ответ — журнал файлом с курсором `(engine_run_id, seq)`, ровно как строка и предлагала; ратифицирован D39.106 §2 и построен обеими сторонами. Открытого остатка нет | fixed(P6, дерево сессии) | приёмка P1 (абстрактный разбор) |
|
||
| PD-113 | bug | **major (контрактно видимый)** | `internal/runs/reconcile.go` `outcome`, движок `stagerun.go` | **Стоп по потолку сегодня НЕразличим от инфраструктурного отказа, и контракт при этом запрещает называть его `failed`.** Движок возвращает потолок ошибкой (`errReserveCeiling`, сверено в HEAD), а `exitCode` мапит всё нераспознанное в 1 — значит по коду выхода «деньги кончились» и «упало» это одно и то же число; события потолка не существует (строка 103). Платформа честно ставит `failed`, хотя `BookStatus` требует `paused` и «никогда не `failed`», потому что стоп резюмируем. Единственный путь, которым платформа СЕГОДНЯ узнаёт о потолке, — событие потока, которого нет; ветка под него построена и запинена (`TestACeilingHaltPausesTheRunWithItsReason`, `TestWhatTheUnitDidBecomesTheProductStatus`, случай «a ceiling halt survives any exit»). Закрывается приходом эмиттера (строка 103); ⚠ до тех пор экран покажет «ошибка» там, где верно «остановлено: лимиты» — **ЗАКРЫТО (P6, потребительская половина шва):** потолочный стоп приезжает `paused` ДВУМЯ независимыми каналами и ни по одному не `failed` — событием `ceiling` потока и кодом выхода 4, потому что журнал, который не удалось записать, всё равно оставляет код. Причина пишется вместе со статусом (`pgstore.RunEnding.PausedReason`), а не вторым оператором, и читается ПОСЛЕ дренажа журнала, а не из снапшота свипа. Пины: `runs.TestWhatTheUnitDidBecomesTheProductStatus` (таблица с exit 4 и обеими причинами) · `runs.TestACeilingHaltWithNoEventIsPausedWithItsReasonAndNotRestarted` (сквозь свип: `paused`, причина, НЕ перезапущен, холд закрыт) · `runs.TestACeilingEventArrivingInThisVerySweepStillDecidesTheEnding` (стейл-снапшот). Живая проба на стенде с фейком-движком, имеющим правило потолка: `ceiling{scope:book}` + exit 4 → `paused/credit_exhausted`, прогресс 4/6 из потока, расчёт $0.08 из $5.10 | fixed(P6, дерево сессии) | сессия P4 (сверка контракта с кодом движка) |
|
||
| PD-141 | bug | info | `internal/pgstore/runs.go` `PauseRun` | `PauseRun` возвращает nil, когда строка прогона уже завершена, поэтому вызывающий рапортует паузу, которой не произошло. Идемпотентность здесь нужна (свип повторяется), но молчаливая — нет: различить «поставил паузу» и «было уже поздно» вызывающий не может — **закрыто (P6):** `PauseRun` возвращает `paused bool`, вызывающий логирует «run paused» только по нему, а отказ называет вслух. Пин — тест стейл-паузы в `pgstore` утверждает именно `paused == false` | fixed(P6, дерево сессии) | адверсариальное ревью (чтение) |
|
||
| PD-152 | bug | info | `internal/runs/reconcile.go` `outcome` | **`stopped` для остановки, которую мы попросили, на реальном движке недостижим:** `tmctl` ЛОВИТ SIGTERM и выходит кодом 1, поэтому ветка «не вышел сам + `$SERVICE_RESULT=success`» срабатывает только для процесса, умершего ОТ сигнала. Проба пака показала `stopped` на фейке, который именно так и умирал. Следствие: пользовательский стоп приедет как `failed`. ⚠ **Дофикс 09.08 расширил строку: то же самое ломает ШТАТНУЮ ПЕРЕЗАГРУЗКУ.** При ребуте пользовательский менеджер останавливает юниты корректно, `ExecStopPost` ОТРАБАТЫВАЕТ и маркер пишется — ревьюер снял живьём на этом хосте (транзиентный юнит, процесс ловит TERM и выходит 1): `RESULT=exit-code CODE=exited STATUS=1`. То есть на буте свип видит маркер и закрывает все живые прогоны как `failed` вместо перезапуска, а путь строки 138 покрывает только потерю питания (нет маркера) — и собственный тест `TestARunInterruptedByARebootComesBackWithTheBudgetItHasLeft` моделирует именно её. Закрывается в паке ручек стопа — попытка помечается «stop requested» до сигнала — и приходом различимого кода выхода graceful-stop у движка (строка бэклога движку, заводит оркестратор); платформенной догадки здесь быть не должно ⚠ **Половина закрыта паком P5 (D39.128-ветка), половина осталась и это названо:** ПОЛЬЗОВАТЕЛЬСКИЙ стоп больше не приезжает как `failed` — платформа пишет намерение стопа (`runs.stop_requested_at`, миграция 00014) ДО сигнала и классифицирует маркер по нему (`outcome`/`stoppedOnRequest`), с гардом гонки «стоп против самостоятельного финиша» по времени маркера и чистым исходом движка (0/2/3) поверх намерения; пины `TestARunTheUserStoppedIsNotReportedAsFailed`, `TestARunThatEndedBeforeTheStopKeepsItsOwnOutcome`, `TestACleanExitOutranksAStopThatArrivedTooLate`. **ШТАТНАЯ ПЕРЕЗАГРУЗКА хоста по-прежнему закрывает живые прогоны как `failed`** — намерения там нет ни у кого, и платформенной догадки здесь быть не должно: остаётся за различимым кодом выхода graceful-stop у движка (строка 165 единого бэклога) ⚠ **ОСТАТОК ЗАКРЫТ (P6):** различимый код пришёл (exit 5 = пойманный сигнал, D39.131), и платформа читает его так: с ЗАПИСАННЫМ намерением — пользовательский стоп, без него — ПРЕРЫВАНИЕ, то есть путь перезапуска строки 138, а не похороны. Решение названо асимметрией: прочитать ребут как стоп пользователя значит оставить все прогоны хоста мёртвыми после штатной перезагрузки, прочитать ручной `systemctl stop` как прерывание стоит одного перезапуска. Пины: `runs.TestAGracefulSignalIsAStopOnlyWhenThisPlatformAskedForOne` · `TestAGracefulSignalNobodyAskedForBringsTheRunBack` · `TestAGracefulSignalWeAskedForEndsTheRun`. Живая проба на стенде: `systemctl --user stop` → попытка 1 `interrupted`, попытка 2 открыта; наш `/stop` → `stopped`, второй попытки нет | fixed(P6, дерево сессии) | приёмка P4 (N1) |
|
||
| PD-163 | bug | info | `internal/pgstore/books.go` `ListBooks`, `GetBook` | **Ревизия области читается ВТОРЫМ запросом после страницы,** поэтому под конкурентной материализацией она новее строк: клиент, соблюдающий контрактное «отбрасывай чтение с меньшей ревизией», навсегда потеряет кадры между двумя запросами. Потребителя (SSE) сегодня нет; закрывать до первого потока — читать страницу и ревизию одной транзакцией — **закрыто (P6):** страница и ревизия области читаются ОДНОЙ транзакцией (`ListBooks` → `listBooksTx`), и то же сделано карточке книги (`GetBook` + `lastRunTx`), где ревизия книги читалась до строк прогона | fixed(P6, дерево сессии) | самопроверка дофикса (ревью вне карты) |
|
||
| PD-164 | bug | info | `internal/runner/marker.go`, `internal/runs/reconcile.go` | **Маркер, который существует, но не разбирается, вечно валит реконсиляцию своего прогона:** аналога карантина у этого пути нет, и ошибка чтения маркера возвращается наверх на каждом свипе. Запись атомарна (temp+fsync+rename), так что нужен внешний фактор (правка оператором, битый том). Закрывать — так же, как журнал: неисправимая ошибка маркера должна вести к терминальному состоянию с причиной, а не к вечному повтору — **закрыто (P6):** неразбираемый маркер отделён от нечитаемого — `runner.ErrBadMarker` против ошибки ввода-вывода — и ведёт к терминальному концу прогона с `exit_result = marker-unreadable` (значение, которого systemd не производит), а не к вечному повтору. Ошибка ЧТЕНИЯ по-прежнему возвращается наверх и повторяется, как и должна. Пин — `runs.TestAnUnreadableExitMarkerEndsTheRunInsteadOfWedgingIt` (плюс «холд закрыт») | fixed(P6, дерево сессии) | самопроверка дофикса (ревью вне карты) |
|
||
| PD-165 | hardening | info | `cmd/tmplatformd/runner.go` `markerArgv`, `internal/runner/runner.go` `quoteArgv` | **Относительный `TM_PLATFORM_CTL_BIN` проходит `os.Stat`, но systemd требует АБСОЛЮТНЫЙ путь в `ExecStopPost`** — каждый старт падает, и виден только цикл заявка-откат. Тот же класс: `quoteArgv` не экранирует `$` (подстановка переменных systemd в Exec-строках), поэтому каталог состояния с таким символом молча ломает командную строку маркера. ⚠ `%` проверен и БЕЗОПАСЕН — спецификаторы в значениях `--property=` не раскрываются (замер §17); `$` в этой сессии исполнением не проверялся. Лечится тем же `filepath.IsAbs`, что и `StateDir` (PD-149), плюс отказ на подозрительных символах — **закрыто (P6), причём двумя разными ответами:** относительный `TM_PLATFORM_CTL_BIN` теперь отвергается на буте (`markerArgv`, пин `TestARelativeExitMarkerCommandIsRefusedAtBoot`) — воспроизведено исполнением, systemd 259 отвечает «neither a valid executable name nor an absolute path» и не стартует юнит целиком. А `$` ЗАМЕРЕН и оказался безопасен: через `systemd-run --user --property=ExecStopPost=…` путь с `$dir` доехал ЛИТЕРАЛЬНО, и доехал даже при `--setenv=dir=EXPANDED` (маркер лёг в `…/dollar$dir/`, не в `…/dollarEXPANDED/`) — то есть экранирование в `$$` было бы ошибкой, а не защитой. Тот же результат, что у `%` (§17) | fixed(P6, дерево сессии) | самопроверка дофикса (ревью вне карты) |
|
||
| PD-196 | bug | minor | `internal/books/parse.go` `defer_`, движок | **Опечатка оператора в рукописном `book.yaml` стоит файла пользователя.** Движок отображает ВСЕ свои отказы на exit 1 (его собственный комментарий), поэтому «этот источник нечитаем» неотличимо от «конфиг синтаксически битый»: после пяти циклов книга отклоняется как `source_unreadable` и исходник удаляется. FP5-4 закрыл только «конфигурации нет вовсе». Настоящее лечение — различающий код выхода или `--dry-run` на стороне ДВИЖКА, правкой платформы не закрывается; до тех пор цена названа вслух, а не спрятана в комментарий — **ЗАКРЫТО (P6, потребительская половина):** движковая половина приехала полосой отказов (D39.131), и интейк судит по КОДУ, а не по типу ошибки: `source_unreadable` — ТОЛЬКО exit 11, `config_invalid` (10) — деплойный класс, который ждёт человека и не тратит бюджет попыток, всё прочее (12 · 19 · обычный exit 1 · сигнал · движок не запустился) — `parser_unavailable`, который отклоняет книгу, но НЕ удаляет её файл. Пины: `books.TestOnlyTheOneRefusalClassAboutTheTextEverCostsTheUpload` (четыре класса × «файл на месте») · `TestAnEngineThatDidNotAnswerIsNeverAVerdictAboutTheBook` · `ingest.TestOutcomeOfCoversTheWholeExitContract`. Живая проба: настоящий `tmctl` без ключей провайдера вышел 10 на стенде — прогон `failed`, холд вернулся целиком, файл цел | fixed(P6, дерево сессии) | кросс-семейное ревью дофикса (Fable 5) |
|
||
| PD-206 | vuln | **minor (условная)** | `internal/config/config.go` `Load`, `internal/login/dev.go` | **Имя провайдера дев-входа не было зарезервировано, и `TM_PLATFORM_OIDC_PROVIDER` — свободный ввод оператора.** Ключ личности — `(provider, subject)`; издатель, настроенный под именем `dev`, кладёт свои `sub` в то же пространство, куда пишет дев-стенд, и `sub`, совпавший с дев-субъектом, разрешается в ДЕВ-аккаунт с его засеянным кредитом. Класс PD-32/§10a (тихое связывание чужих аккаунтов), но здесь — одна переменная окружения. Четвёртое из четырёх заявленных свойств недостижимости дев-входа в проде было без этого ЛОЖНЫМ. **Закрыто:** `Load` отвергает `TM_PLATFORM_OIDC_PROVIDER == login.DevProvider`; заодно `TM_PLATFORM_DEV_LOGIN` тримится, иначе значение из пробелов монтировало вход с личностью «пробел». Пины — `config.TestAnIssuerCannotBeConfiguredUnderTheDevelopmentProvidersName` и `TestAWhitespaceDevelopmentSubjectMountsNothing`, обе посадки падают. Плюс второй, ИЗБЫТОЧНЫЙ гард в `main.go` (ветка дев-входа стоит первой в switch, и без него регресс одной строки конфига дал бы дев-входу перекрыть боевой OIDC) — он по построению недостижим, пока держится первый, поэтому теста не имеет, и это названо | fixed(P6, дерево сессии) | адверсариальное ревью P6 (кросс-семейное, Fable) |
|
||
| PD-207 | bug | **minor, деньги** | `internal/pgstore/runs.go` `RestartRun`, `internal/runs/reconcile.go` `restart` | **Устаревший снапшот свипа ВОСКРЕШАЛ прогон, который другое поколение уже закрыло.** `RestartRun` отказывал только по намерению стопа на ЖИВОМ прогоне (`stopRequested != nil && finished == nil`), а на прогоне уже ЗАВЕРШЁННОМ проходил: чистил `finished_at`, брал новый холд и спавнил движок для прогона, владельцу которого уже сказали, что тот кончился. Воспроизведено: пользователь жмёт стоп, поколение A закрывает прогон как `stopped`, поколение B со снапшотом ДО стопа видит маркер exit 5 без намерения и перезапускает. Класс PD-181, и эта сессия его РАСШИРИЛА, добавив ветку «exit 5 без намерения = перезапуск». **Закрыто:** `RestartInput.OnlyIfLive` — реконсилятор просит гарантию «прогон ещё жив», резюм (который работает по определению с завершённым) не просит. Пин — `runs.TestAStalePassDoesNotResurrectARunAnotherPassHasFinished`, посадка падает с «stopped → translating» | fixed(P6, дерево сессии) | самоаудит лендинг-диффа P6 |
|
||
| PD-208 | bug | info | `cmd/tmplatformctl/seed.go` | **Сид рапортовал «кредит начислен» о начислении, которого не было.** Он звал `store.Grant` напрямую и ВЫБРАСЫВАЛ возвращаемый `applied`, а ключ идемпотентности детерминирован по аккаунту — значит второй прогон `seed` на том же стенде печатал «granted», начислив ноль. Тот же класс, ради которого в этом же пакете уже был написан хелпер `write` (его собственный комментарий: «Applied» и «ключ уже потрачен» — разные исходы, и оператору говорят, какой он получил), плюс вторая половина: неудачное ЧТЕНИЕ баланса после закоммиченной записи роняло весь сид, тогда как `write` печатает предупреждение и продолжает. **Закрыто:** сид зовёт `write`; проверено исполнением на стенде — второй прогон печатает «no-op: key … was already used» | fixed(P6, дерево сессии) | адверсариальное ревью P6 (линза повторного использования) |
|
||
| PD-209 | bug | **minor, деньги/статус** | `internal/runs/reconcile.go` `outcome`, `reconcile`, `restart`; `internal/pgstore/books.go` | **Причина `daily_ceiling` стиралась ДВУМЯ путями, и оба возвращали ложный «кредит кончился».** (A) exit 4 БЕЗ материализованного события закрывался как `credit_exhausted` — а это не редкий угол: карантинная попытка не дренится вовсе, значит КАЖДЫЙ её потолочный стоп шёл этим путём. (B) прогон, чей поток уже сказал `ceiling` (синк ставит `paused` без `finished_at`, то есть прогон остаётся живым), при исчезнувшем без маркера юните уходил в `restart`, а его ветка исчерпания жёстко зашивала `credit_exhausted` поверх. Последствия обе: `ReadUsage` зажигает АККАУНТНЫЙ halted-флаг на аккаунте с деньгами, и гард резюма дневного потолка обходится — резюм разрешён, новая попытка мгновенно упирается в тот же дневной лимит, цикл повторяем пользователем. **Закрыто:** третье значение `ceiling_unknown` (миграция 00015) для потолка, чей scope платформа не установила — резюмируемо, но НЕ зажигает аккаунтный флаг; прогон с уже названной причиной закрывается как пауза, а не перезапускается. Пины: `runs.TestACeilingHaltWithNoEventIsPausedWithItsReasonAndNotRestarted` · `TestACeilingThatTheStreamReportedSurvivesAUnitThatVanished` · таблица `TestWhatTheUnitDidBecomesTheProductStatus`; три посадки падают, четвёртая (фолбэк причины в ветке исчерпания) ПЕРЕЖИВАЕТ по построению и это названо в коде | fixed(P6, дерево сессии) | адверсариальное ревью P6 (линза денег и гонок) |
|
||
| PD-210 | bug | **minor, шов** | `internal/pgstore/runs.go` `StartRun`/`RestartRun`/`RecordSpawn`, `internal/pgstore/sink.go` `Begin`/`effect` | **Окно усыновления чужого потока оставалось между ДОПУСКОМ и спавном.** Имя потока писалось при `RecordSpawn`, а допущенная попытка уже попадает в `ListLiveRuns` и уже дренится — с `want == ""`, то есть с усыновлением первого встречного hello. Окно — секунды внутри `tmctl status` или целый интервал свипа; `coalesce(engine_run_id, …)` в `RecordSpawn` при этом ОТКАЗЫВАЛСЯ исправить усыновлённое чужое имя на собственное, и прогон слеп на всю жизнь, а чужой `ceiling` материализовался на него (с новым `outcome` это перебивает даже чистый exit 0). **Закрыто:** имя даётся в той же вставке, что создаёт строку попытки (`pgstore.EngineStreamID` — формат переехал туда, где известен id прогона), `RecordSpawn` присваивает, а не сохраняет чужое. Побочное следствие, найденное тем же ревью: `Begin` стал недостижим для новых попыток, и вместе с ним тихо выключилось обновление `chunker_version` — эффект перенесён в `effect`, куда hello приезжает обычным событием. Пины: `runs.TestAnAttemptIsNamedBeforeAnythingCanBeSpawnedForIt` · `pgstore.TestTheChunkerVersionOfTheStreamReachesTheBook`; обе посадки падают | fixed(P6, дерево сессии) | адверсариальное ревью P6 (линза денег и гонок) |
|
||
| PD-211 | bug | info | `internal/pgstore/sink.go` `unitDone` | **Апсерт разрешённого юнита был «побеждает последний ПРИШЕДШИЙ», а не последний по времени.** Именованный остаток эмиттера — строка, закоммиченная в outbox и не дошедшая до файла, — переанонсируется СЛЕДУЮЩИМ процессом: приезжает со своим seq (то есть проходит high-water mark) и несёт время СТАРОГО события. Юнит, передреденный внутри предыдущей попытки из flagged в shipped, регрессировал обратно. Счётчики не страдают (они считают строки) — страдает ровно та диспозиция, из которой читающая поверхность выводит состояние юнита. **Закрыто:** `where excluded.at >= unit_resolutions.at`; пин `pgstore.TestAReAnnouncedOlderResolutionDoesNotOverwriteANewerOne`, посадка падает | fixed(P6, дерево сессии) | адверсариальное ревью P6 (линза денег и гонок) |
|
||
|
||
## Закрытые — дофикс P6 (приёмка оркестратора №16, 15.08)
|
||
|
||
| ID | Класс | Серьёзность | Где | Суть | Статус | Источник |
|
||
|---|---|---|---|---|---|---|
|
||
| PD-224 | bug | **major (деньги, несущий путь)** | `internal/runner/engine.go` `Status`, `internal/ingest/supervisor.go` `Status` | **`status --json` с exit 2 читался как ОТКАЗ, а движок так отвечает про любую книгу с помеченной единицей** — отчёт при этом уже напечатан (`cmd/tmctl/render.go` `renderStatusJSON`). Следствия на ОБЫЧНОМ пути: расчёт денег такого прогона откладывался вечно (холд висел — класс PD-162), `bookMeter` отказывал каждому следующему прогону книги, ре-синк умирал; дев-путь имел тот же дефект — **закрыто:** exit 2 при разбираемом отчёте = ответ, exit 2 с нечитаемым stdout остаётся отказом, знание живёт одним местом (`ingest.CompletedWithFlags`). Перечень команд, способных выйти 2, снят чтением движка: `translate`, `status`, `redrive`; `manifest` — нет. Пины `runner.TestAFlaggedBookStillAnswersAboutItsMoney` и сквозной `runs.TestTheMoneyOfAFlaggedBookIsSettledThroughTheRealExitContract` (настоящий процесс, деньги сверены балансом и суммой леджера) | fixed(дофикс P6, дерево сессии) | приёмка P6 (ФП-1) |
|
||
| PD-225 | doc | minor | `deploy/README.md` | **Порядок апгрейда движка гонял `migrate` СТАРЫМ бинарём:** шаг миграции стоял ДО установки нового, то есть был no-op, и деадлок v15 оставался; там же «ДВА факта» вместо трёх — **закрыто:** новый бинарь на версионированный путь ДО миграции, `migrate` его явным путём, `TM_PLATFORM_ENGINE_BIN` переключается после; окно между списком и миграцией закрыто остановкой демона на время апгрейда; три факта названы тремя | fixed(дофикс P6, дерево сессии) | приёмка P6 (ФП-2) |
|
||
| PD-226 | standards | minor | `internal/pgstore/books.go` `BooksForMigration` | **Денежный гейт миграции книг не был запинен:** посадка «убрать блокер незакрытого холда» пережила ВСЮ батарею — **закрыто:** таблица по трём блокерам, каждый по отдельности переводит вердикт книги в «нельзя», и список, который читает цикл деплой-заметки, содержит только свободную книгу; все три посадки падают. Пин `pgstore.TestEachOfTheThreeBlockersAloneKeepsABookOutOfTheMigrationList` | fixed(дофикс P6, дерево сессии) | приёмка P6 (ФП-3) |
|
||
| PD-227 | bug | minor | `internal/runs/reconcile.go` `reconcile` | **Ветка нечитаемого маркера хоронила потолочную паузу как `failed` с пустой причиной:** единственная из веток конца попытки, которая не перечитывала причину после дрейна, — а маркер, который не разбирается, не несёт и кода выхода, так что поток остаётся единственным свидетелем (воспроизведено приёмкой на живом PG) — **закрыто:** обе маркерные ветки слиты, перечит один и общий. Пин `runs.TestACeilingSurvivesAMarkerThatCannotBeRead` | fixed(дофикс P6, дерево сессии) | приёмка P6 (ФП-4) |
|
||
| PD-228 | vuln | **minor (условная)** | `internal/config/config.go` `Load` | **`TM_PLATFORM_INSECURE_COOKIES` не отвергался рядом с боевым OIDC:** гейт ловил только связку с DEV_LOGIN, а дев-рецепт экспортирует обе переменные, так что «стендовое окружение уехало в прод» ловилось наполовину — демон стартовал с боевым Google-входом, куками без Secure и без `__Host-`, с выключенным HSTS (воспроизведено приёмкой живым демоном) — **закрыто:** судит не догадка, а собственный адрес деплоя: callback OIDC и есть публичный URL платформы, поэтому `http` там = стенд (законно, работает), всё прочее — отказ на буте. Пин `config.TestCookiesWithoutSecureAreRefusedNextToAProductionProvider` | fixed(дофикс P6, дерево сессии) | приёмка P6 (ФП-5) |
|
||
| PD-229 | bug | minor | `internal/pgstore/books.go` `CeilingPause` | **Незнакомый scope потолка читался как `credit_exhausted`** и зажигал аккаунтный halted-флаг (`ReadUsage` ключится ровно на это значение) на аккаунте, у которого деньги есть, — при том что этот же пак завёл `ceiling_unknown` ровно для «потолок был, чей — не установлено» — **закрыто:** `book` и `day` называются, всё остальное = `ceiling_unknown`; резюмируемость не меняется ни при одном из трёх, а ложного «кредит кончился» больше нет. Пин — таблица `pgstore.TestTheCeilingScopeDecidesWhichPauseTheRunGets` | fixed(дофикс P6, дерево сессии) | приёмка P6 (ФП-6) |
|
||
| PD-230 | bug | info | `cmd/tmplatformctl/seed.go` | **Сид не тримил `--subject`, а демон тримит:** падённое значение одной переменной окружения давало вход под одной личностью и поиск другой — аккаунт создавался, одноразовый грант сгорал, сид обрывался (воспроизведено приёмкой) — **закрыто:** одна нормализация с обеих сторон. Пин `TestASubjectThatIsOnlyPaddingIsNoSubject` | fixed(дофикс P6, дерево сессии) | приёмка P6 (ФП-6) |
|
||
| PD-231 | doc | info | `internal/pgstore/sink.go` `unitDone` | **Гард `at >=` обосновывался несуществующим поведением движка:** «переанонс несёт СТАРОЕ время события» — по коду движка неверно, `at` штампует анонсирующий процесс, а непроецированная строка мёртвого прогона стирается `ForgetEvents` и анонсируется заново со своим временем — **закрыто:** гард остаётся (потребитель at-least-once обязан быть независимым от порядка доставки), довод приведён к факту здесь и в пинящем тесте | fixed(дофикс P6, дерево сессии) | приёмка P6 (ФП-6) |
|
||
| PD-232 | doc | info | `internal/runs/reconcile.go` `Resume` | **Комментарий про book-ceiling утверждал «remaining ноль и резюм ничего не меняет»** — по арифметике остаток положителен, потому что движок останавливается на невместившемся резервировании, а не на самом потолке — **закрыто:** комментарий приведён к факту, сам чурн заведён строкой PD-223 | fixed(дофикс P6, дерево сессии) | приёмка P6 (ФП-6) |
|
||
| PD-233 | bug | minor | `internal/runs/reconcile.go` `outcome` | **Exit 5 со стопом, записанным ПОЗЖЕ маркера, закрывался `failed`:** намерение снимало «прерывание», а сравнение времён отвергало «стоп» — при том что exit 5 недостижим для прогона, кончившегося сам, и два сравниваемых штампа приходят с разных часов (маркер пишет хост юнита, намерение — платформа) — **закрыто:** exit 5 при записанном намерении = `stopped` независимо от порядка; чистые концы 0/2/3 отвечены раньше в том же switch и не сдвинулись, что пинится отдельно | fixed(дофикс P6, дерево сессии) | приёмка P6 (ФП-6) |
|
||
| PD-234 | doc | info | `docs/platform-PROGRESS.md` | **Базис «396 → 403» в шапке P6 не воспроизводился** (в HEAD 359 `^func Test`, откуда 396 — неизвестно) — **закрыто:** числа пересчитаны, а рядом со строкой написана команда счёта, чтобы следующая сессия сверяла, а не переписывала | fixed(дофикс P6, дерево сессии) | приёмка P6 (ФП-8) |
|
||
| PD-235 | standards | info | `internal/config/config_test.go` | **Подтест-пассажир в пине нового гейта:** строка «callback пуст» оставалась зелёной при УДАЛЁННОМ гейте — её отвергает проверка неполного OIDC этажом выше, то есть про сам гейт она не говорила ничего. Класс тот же, что «слепой пин» из P6: покрытие, которого нет — **закрыто:** строка убрана из цикла (неполный OIDC пинится своим тестом), у гейта осталось два настоящих свидетеля, оба падают на посадке | fixed(дофикс P6, дерево сессии) | кросс-семейное ревью дофикса P6 (security-линза) |
|
||
| PD-236 | bug | **minor, контрактно видимый** | `internal/runs/reconcile.go` `freshPausedReason` | **Одна неудачная перечитка причины хоронила потолочную паузу как `failed` без причины.** Перечит падал обратно на снапшот — а он пуст ровно в том случае, ради которого перечит и существует (событие приехало в ЭТОТ дрейн), — и вызывающие закрывали прогон на этой пустоте. На ветке испорченного маркера кода выхода нет, а сама ветка закрывает прогон по построению (PD-164), значит следующего прохода не будет: обычный перезапуск Postgres превращал резюмируемый стоп в `failed`. Тот же глоток заново вооружал перезапуск, который ветка «поток сказал ceiling» построена предотвращать — **закрыто:** ошибка чтения возвращается наверх, ничего не решается на неустановленном факте. Пин `runs.TestAnEndingIsNeverDecidedFromAReadThatFailed` | fixed(дофикс P6, дерево сессии) | кросс-семейное ревью дофикса P6 (линза автомата состояний) |
|
||
| PD-237 | bug | **minor, деньги** | `internal/runner/engine.go` `Status` | **Exit 2 принимался по коду и разбору, без согласия самого отчёта.** Движок отдаёт этот код из `status --json` при одном условии — `flagged > 0`, — и оно не проверялось: измерено ревью, что отчёт с `flagged:0`, отчёт про ЧУЖУЮ книгу и документ без `book_id` при exit 2 РАССЧИТЫВАЛИСЬ (2.5 USD против холда) вместо отложенного расчёта. Достижимо не движком, а тем, что стоит на запиненном пути и им не является: обёртка, подменённый на месте каталог версии, недокатанный деплой — **закрыто:** три условия вместе (код, разбор, согласие отчёта). Пин `runner.TestExitTwoIsTakenOnlyFromAReportThatAgreesWithIt` | fixed(дофикс P6, дерево сессии) | кросс-семейное ревью дофикса P6 (денежная линза) |
|
||
| PD-238 | bug | **minor** | `internal/ingest/manifest.go` `DecodeManifest` | **Документ, который не манифест, приезжал ПУСТЫМ манифестом — а пустой манифест удаляет загрузку пользователя.** `{}`, `null` и любой объект незнакомых полей декодировались в нули, интейк читал нулевые главы как «движок разрезал источник и книги в нём нет» и сносил каталог. Версия намеренно не гейтится по ЗНАЧЕНИЮ (иначе всякий релиз движка — релиз платформы), и в этом зазоре единственным сторожем деструктивного пути было имя JSON-поля — **закрыто:** требуется НАЛИЧИЕ `manifest_version` (не значение), а интейк отдельно различает «глав нет» и «глав нет, но юниты есть» — второе не вина книги. Пины `ingest.TestADocumentThatIsNotAManifestIsNotAnEmptyManifest`, `books.TestAManifestThatContradictsItselfNeverCostsTheUpload` | fixed(дофикс P6, дерево сессии) | кросс-семейное ревью дофикса P6 (линза шва) |
|
||
| PD-239 | hardening | info | `internal/runner/engine.go` `Manifest` | **Интейк держался на непроверяемом инварианте чужой зоны:** «`manifest` не может выйти 2» — верно сегодня (сентинел строится в четырёх местах `render.go`, ни одно не манифестное), но в движке этого не пинит ничто, а цена ошибки — вся книжная очередь хоста, потратившая бюджет попыток на документ, который движок уже напечатал — **закрыто:** правило читается, а не предполагается: exit 2 = команда отработала, и на интейковом канале тоже. Пин `runner.TestAManifestThatCompletesWithFlagsIsStillAManifest` | fixed(дофикс P6, дерево сессии) | кросс-семейное ревью дофикса P6 (линза шва) |
|
||
| PD-240 | hardening | info | `internal/runner/engine.go` `Status`, `drain` | **Канал расчёта читал stdout без потолка,** тогда как соседний `Manifest` кап имеет (64 МиБ) — при том что `Status` зовётся на каждом завершившемся прогоне. Найдено ДВУМЯ линзами независимо. Написание пина вскрыло вторую половину: дренаж, который «нельзя не делать» (иначе EPIPE), на бесконечном писателе не возвращается никогда, то есть кап был во власти того, что ограничивает — **закрыто:** кап 16 МиБ (замерено: настоящая книга в 2283 главы даёт 1.1 МБ), за капом процесс убивается, а не дренится; обе команды ходят через один `drain`. Пин `runner.TestAnEndlessStatusIsRefusedRatherThanRead` (посадка виснет и падает по таймауту) | fixed(дофикс P6, дерево сессии) | кросс-семейное ревью дофикса P6 (линзы шва и денег) |
|
||
|
||
## Закрытые — эра 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) |
|
||
| ID | Класс | Серьёзность | Где | Суть | Статус | Источник |
|
||
|---|---|---|---|---|---|---|
|
||
| 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 |
|
||
| PD-174 | standards | minor | `internal/httpapi/v0.go` `contractRoutes` | **`POST /books` на инстансе без интейка отвечает 404, которого спека для этого маршрута не предусматривает.** Форма унаследована от уже принятого решения зоны — непостроенный/несмонтированный маршрут остаётся охраняемым 404 (`d.Runs == nil` так же), и это честнее 503 на КАЖДЫЙ аплоад, который инстанс всё равно не примет. Но контрактно это тот же класс, что PD-112: код ответа, которого в перечне операции нет. Вопрос владельцу контракта — назвать поведение для инстанса, не принимающего загрузки ⚠ Дополнено адверсариальным ревью P5: тот же класс есть и у РЕЗЮМА — `503` на деплое без маркер-команды или без шаблона потолка, тогда как 0.2.1 ратифицировала 503 только для СТАРТА прогона. Вопрос один на оба маршрута ⚠ **ДИСПОЗИЦИЯ D39.130:** вопрос владельцу контракта принят и превращён в спек-правку **0.2.3**, которую делает задача S4 фронт-сессии; зона правку спеки не делает и ждёт её ⚠ **ЗАКРЫТО каноном 0.3.0:** `createBook` объявил `404` для деплоя, который книг не принимает (`intake_enabled` у `GET /capabilities`), а `503` объявлен ответом и `startRun`, и `resumeRun`. Обе формы теперь в перечне операций, вопрос закрыт ратификацией, а не кодом | fixed(P7, ратификация 0.3.0) | сессия P5 (самопроверка против спеки) |
|
||
| PD-180 | standards | info | `docs/architecture/14-api-contract/openapi.yaml` `createBook` | **Ответ 201 несёт `parsing`, а не `uploading`, и «responds immediately» недостижимо как класс.** Спека описывает createBook словами «Responds immediately; the book enters `uploading`», но ответ по HTTP не может уйти раньше, чем прочитано тело: к моменту 201 файл уже принят, и честный статус — `parsing`. `uploading` при этом РЕАЛЬНО наблюдаем, но только параллельным чтением библиотеки (пин `books.TestABookIsVisibleAsUploadingWhileItsFileIsStillArriving`). Сюда же — отказы интейка, которых спека не описывает: больше 16 частей формы и поле длиннее 1 КиБ дают 400, тело сверх потолка — 413 с порогом, которого в контракте нет, а тело медленнее дедлайна маршрута отвечало обрывом соединения без problem-ответа (дофикс это исправил, см. ниже). Вопрос владельцу контракта одним пакетом с PD-172/PD-174 ⚠ Дополнено дофиксом приёмки: в тот же пакет вопросов входит **408** на просроченный дедлайн загрузки — тело, не успевшее прийти за отведённое маршруту время, это медленный клиент, и 408 по RFC 9110 §15.5.9 говорит именно это, включая то, что лечение — повтор; кода 408 в перечне операции нет ⚠ **ДИСПОЗИЦИЯ D39.130:** вопрос владельцу контракта принят и превращён в спек-правку **0.2.3**, которую делает задача S4 фронт-сессии; зона правку спеки не делает и ждёт её ⚠ **ЗАКРЫТО каноном 0.3.0:** «Responds immediately» снято, `201` описан как несущий `parsing` дословно; `408`, `413` и `400`-отказы интейка объявлены ответами операции, перечень помечен НЕисчерпывающим (авторитет — `code` ответа) | fixed(P7, ратификация 0.3.0) | адверсариальное ревью P5 (сверка провода со спекой) |
|
||
| PD-199 | standards | minor | `internal/httpapi/v0.go` `contractPausedReason`, контракт `openapi.yaml` §PausedReason | **У платформы две причины паузы, у контракта одна — и вторая на провод не выходит.** `PausedReason` спеки перечисляет `credit_exhausted`; с приходом `ceiling.scope` (D39.131) платформа различает потолок, который поставила САМА, и дневной потолок из `book.yaml` оператора, который она не выбирала и на котором у аккаунта деньги ЕСТЬ. Проецировать второй как первый нельзя — экран скажет «кончились деньги», и зажжётся аккаунт-флаг `ReadUsage`; выдумывать слово контракта не право зоны (прецеденты PD-173, PD-150). Поэтому внутренняя причина `daily_ceiling` хранится и проецируется как `null`, что спека сама и предписывает клиенту рендерить для незнакомой причины. ⚠ Найдено ЖИВОЙ ПРОБОЙ, а не чтением: до фикса значение уезжало на провод, и клиент, сгенерированный по спеке, его бы отверг. Вопрос владельцу контракта: назвать причину (тогда правка — одна функция) или подтвердить null ⚠ **ЗАКРЫТО ратификацией D39.132 п.2а** («null на проводе подтверждён») — статус приведён P7; проекция осталась той же (`httpapi.contractPausedReason`), а `Usage` получил СВОЙ словарь `AccountHaltReason`, чтобы прогонная причина больше не могла зажечь аккаунтный флаг | fixed(P7, ратификация D39.132) | сессия P6 (живая проба на стенде) |
|
||
| PD-241 | bug | minor | `internal/runs/reconcile.go` `outcome` | **Стоп, о котором попросил ПОЛЬЗОВАТЕЛЬ, переименовывается в `paused/credit_exhausted`, если в тот же дрейн приехал потолок:** причина паузы проверяется раньше намерения стопа, и аккаунтный halted-флаг (`ReadUsage` ключится на `credit_exhausted`) зажигается на аккаунте, у которого 97% баланса на месте (воспроизведено ревью). Денег не теряется и прогон резюмируем, но `/usage` говорит «кредит кончился» человеку, у которого он есть. Порядок «причина раньше намерения» — ратифицированное решение P6, и молча его переворачивать этим касанием я не стал; денежно-видимая половина — это PD-203 ⚠ **ЗАКРЫТО P7** (ратификация D39.132 п.2д «следующим касанием зоны» — это касание): порядок в `outcome()` перевёрнут — намерение стопа проверяется ПОСЛЕ собственных окончаний движка и ДО причины паузы, так что стоп пользователя приезжает `stopped` без причины, а аккаунтный флаг не зажигается. Пин `runs.TestAStopTheUserAskedForOutranksACeilingThatArrivedWithIt` (все три причины паузы + контроль: без намерения решает потолок) | fixed(P7, дерево сессии) | кросс-семейное ревью дофикса P6 (линза автомата состояний) |
|
||
| PD-223 | bug | minor | `internal/runs/reconcile.go` `Resume`, `reopen` | **Резюм прогона, остановленного ПЛАТФОРМЕННЫМ потолком, — цикл «холд-спавн-пауза» за клик.** Движок останавливается на резервировании, которое не помещается, то есть НЕ доходит до своего потолка: расчёт списывает меньше холда, у прогона остаётся положительный остаток (обычно меньше одного вызова), `exhausted` не наступает, и резюм открывает попытку, которая упирается в тот же потолок сразу. Денег провайдера не тратится; цена — `tmctl status` и транзиентный юнит на клик. Порог здесь угадывать нельзя: размер резервирования — число движка, платформе не видное ⚠ **ЗАКРЫТО P7 сменой контракта:** резюм прогона, остановленного ЛЮБЫМ потолком, теперь отвечает 409 `run_not_resumable` · `cause.code: ceiling_reached` (канон 0.3.0 §resumeRun), то есть цикла «холд-спавн-пауза за клик» не существует — лечение это НОВЫЙ прогон с бОльшим потолком. Пин `runs.TestAResumeOfARunPausedAtALimitIsRefusedWhoseverLimitItWas` | fixed(P7, дерево сессии) | приёмка P6 (дофикс, ФП-6) |
|
||
| PD-242 | bug | info | `internal/runs/reconcile.go` `Resume` | **`ceiling_unknown` проходит гейт резюма, который отказывает `daily_ceiling`:** дневной потолок, приехавший ТОЛЬКО кодом выхода (карантин проекции — случай, который собственный комментарий `outcome` называет не-редким), записывается как «чей потолок, не установлено» и резюмируется свободно, упираясь в тот же лимит того же дня. Не регресс (прежнее `credit_exhausted` резюмировалось так же) и тот же класс чурна, что PD-223 ⚠ **ЗАКРЫТО P7 тем же ходом, что PD-223:** гейт больше не различает причины — резюм отказан при ЛЮБОЙ паузе, поэтому `ceiling_unknown` не может его пройти | fixed(P7, дерево сессии) | кросс-семейное ревью дофикса P6 (линза автомата состояний) |
|
||
| PD-257 | bug | **major** | `internal/pgstore/readmodel.go` `writeChapters`, `migrations/00002:109` | **Пере-нарезка со сдвигом нумерации валила читающую модель НАВСЕГДА.** Id главы у движка — хеш её текста и переживает ре-кат, `number` позиционный и сдвигается; апсерт по `id` ставил номер, который ещё держит соседка, и `unique (book_id, number)` ронял всю транзакцию `SaveStructure`. Вход детерминирован → дерево замирало молча, `structure_version` не двигался, клиенту никто не говорил пере-синхронизироваться. Достижимо апгрейдом движка/лангпака (правило заголовков роняет заголовочную главу). Delete-first НЕ лечит: сталкиваются ВЫЖИВШИЕ главы — **закрыто:** констрейнт стал `deferrable initially deferred` (00018); пины `pgstore.TestARecutThatShiftsChapterNumbersIsMaterialized` (вставка в начало И обратная форма) | fixed(приёмка P7, дерево сессии) | приёмка P7 (воркфлоу 12 осей + кросс-модель) |
|
||
| PD-258 | bug | **major** | `internal/readmodel/readmodel.go` `refreshStructure`, `pgstore.writeUnits` | **Упавший `tmctl export` затирал текст книги пустотой.** Дерево и пары — два вызова движка; при отказе второго все пары уходили в `SaveStructure` с пустыми `source`/`target` и `pending`, а кат не менялся ⇒ те же id ⇒ ветка `do update` перезаписывала переведённую книгу пустыми строками до следующего успешного экспорта — **закрыто:** `Structure.TextRead` несёт факт «канал ответил», апсерт трогает текст только при нём; пин `pgstore.TestAFailedExportDoesNotEraseTheTextAlreadyMaterialized` | fixed(приёмка P7, дерево сессии) | приёмка P7 (воркфлоу 12 осей + кросс-модель) |
|
||
| PD-259 | bug | minor | `internal/pgstore/idempotency.go` `ClaimIdempotency` | **Две ОДНОВРЕМЕННЫЕ первые попытки давали 500 вместо 409.** `select ... for update` по несуществующей строке не блокирует ничего, обе доходили до голого `insert`, проигравшая ловила 23505 — ни один из двух конфликтов контракта, наружу `internal_error` на том самом случае, ради которого заголовок существует — **закрыто:** `on conflict (user_id, key) do nothing` + перечитывание (идиома `identities` того же пакета); пин `pgstore.TestTwoSimultaneousFirstClaimsAnswerInFlightAndNeverAnUnexpectedError` (8 гонщиков) | fixed(приёмка P7, дерево сессии) | приёмка P7 (воркфлоу 12 осей + кросс-модель) |
|
||
| PD-260 | bug | minor | `internal/httpapi/v0.go` `createBook`/`startRun`, `httpapi/idempotency.go` | **Оборвавшийся клиент получал ВТОРУЮ книгу.** `complete`/`release` писали на `r.Context()`, который отменяет ровно тот клиент, который будет ретраить: ключ висел «в полёте» `claimStale`=30 мин, потом takeover переделывал работу. Рядом в зоне уже жило решение того же случая (`books.writeCtx`) — **закрыто:** `settleCtx` = `WithoutCancel` + свой дедлайн | fixed(приёмка P7, дерево сессии) | приёмка P7 (воркфлоу 12 осей + кросс-модель) |
|
||
| PD-261 | bug | minor | `internal/pgstore/migrations/00017:30`, `httpapi/idempotency.go` | **`path` в первичном ключе неограничен:** длинный адрес переполняет кортеж btree, клейм падает, и ручка отвечает 500 там, где контракт обещает 409 — **закрыто:** путь КЛЮЧУЕТСЯ дайджестом (`path_sha256`), сам остаётся читаемой колонкой (00018); пин `TestAnOverlongPathDoesNotBreakTheClaim`. ⚠ **Первая редакция правки выбросила путь из ключа целиком и этим сломала канон** («scoped to (principal, method, path); the same key on another operation is another key»); поймано приёмкой правок — пятью независимыми осями сразу, — откачено, область восстановлена; пин `TestOneKeyOnAnotherOperationIsAnotherKey` | fixed(приёмка P7, дерево сессии) | приёмка P7 (воркфлоу 12 осей + кросс-модель) |
|
||
| PD-263 | bug | minor | `internal/pgstore/runs.go` `StartRun`, `ReleaseBankStop` | **Полоса прогона и её база считали РАЗНЫЕ проходы.** `chapters_before` снимался по edit-колонке, а первый сегмент прогона со стопом на подпись считает по draft-колонке ⇒ новый прогон над уже начерновленной книгой открывался на полном потолке — ровно тот дефект, ради которого колонку и заводили. Симметрично: при снятии стопа числитель переключается на edit, а база оставалась draft'овой — **закрыто:** база снимается тем же предикатом, что числитель, и ПЕРЕ-снимается в `ReleaseBankStop`; пин `pgstore.TestTheBarAndItsBaselineCountTheSamePass` | fixed(приёмка P7, дерево сессии) | приёмка P7 (воркфлоу 12 осей + кросс-модель) |
|
||
| PD-264 | bug | minor | `internal/pgstore/readmodel.go` `writeChapters` | **После пере-нарезки глава наследовала прогресс и замечания чужой главы.** `unit_resolutions` адресованы движковыми (номер главы, ординал), которые новый кат пере-указывает, и не чистились — **закрыто:** ре-кат удаляет резолюции ушедшего ката (переводить их не по чему); пин `pgstore.TestARecutDoesNotLetAChapterInheritTheProgressOfTheOneBeforeIt` | fixed(приёмка P7, дерево сессии) | приёмка P7 (воркфлоу 12 осей + кросс-модель) |
|
||
| PD-265 | bug | minor | `internal/httpapi/stream.go` `pump` | **Кадры, подрезанные буфером при ЖИВОМ соединении, пропадали молча** — без `resync_required`, хотя `note` доставляется однажды и молчаливый пропуск = замечание потеряно навсегда — **закрыто:** `state.Oldest > from+1` в цикле даёт тот же ответ, что и на рукопожатии; пин `httpapi.TestFramesPrunedWhileTheConnectionIsOpenAskTheClientToResync` (без него мутация проходила батарею) | fixed(приёмка P7, дерево сессии) | приёмка P7 (воркфлоу 12 осей + кросс-модель) |
|
||
| PD-266 | bug | minor | `internal/httpapi/stream.go` `stream.write` | **На маршруте потока не было дедлайна записи:** клиент, открывший поток и переставший читать, держал горутину, соединение и слот сервера вечно — тихий уход не отменяет `r.Context()` — **закрыто:** `SetWriteDeadline` на каждый кадр через `ResponseController`; пин `httpapi.TestEveryFrameIsWrittenUnderADeadline` | fixed(приёмка P7, дерево сессии) | приёмка P7 (воркфлоу 12 осей + кросс-модель) |
|
||
| PD-267 | bug | info | `internal/httpapi/stream.go` `streamEvents` | **`Last-Event-ID` больше позиции книги принимался и отвечал 204.** Такого id сервер не выдавал (реальный триггер — восстановление БД из бэкапа двигает `event_position` назад), и 204 велит клиенту прекратить переподключение по числу, которое нельзя проверить — **закрыто:** id, с которого сервер продолжить не может, отвечается `resync_required` — так канон и требует («rather than silently starting from now»); ветка `204` для «at or past на книге в покое» сохранена, она тоже каноническая. ⚠ Первая редакция правки стартовала свежий поток и этим теряла кадры молча — поймано приёмкой правок тремя осями; пин `httpapi.TestAnEventIDTheServerCannotResumeFromIsToldToResync` | fixed(приёмка P7, дерево сессии) | приёмка P7 (воркфлоу 12 осей + кросс-модель) |
|
||
| PD-268 | bug | minor | `internal/runs/reconcile.go` `finish`, `Sweep` | **Материализация читающей поверхности голодала свип.** `refreshReadModel` брал 5 минут (`WithoutCancel`) ВНУТРИ пер-прогонного бюджета в 60 с, то есть уходил из-под `withBudget` и держал расчёт денег всех прогонов позади себя — ровно та голодовка, ради которой `withBudget` и заведён (PD-169) — **закрыто:** работа копится (`deferRefresh`) и сливается ОТДЕЛЬНЫМ проходом демона (`DrainRefresh`, `pass("readmodel", …)`). ⚠ Первая редакция ставила `defer drainRefresh` внутрь `Sweep` — а `defer` отрабатывает ДО возврата, то есть работа оставалась в бюджете прохода и ничего не лечила; поймано приёмкой правок | fixed(приёмка P7, дерево сессии) | приёмка P7 (воркфлоу 12 осей + кросс-модель) |
|
||
| PD-269 | bug | minor | `internal/runner/artifacts.go` `projectDB` | **Банк не читался на штатном деплое.** Требовался ключ `project_db`, который движок делает НЕОБЯЗАТЕЛЬНЫМ (дефолт `<book_id>.db`, `backend/internal/config/book.go:163`), а канонический шаблон оператора его не содержит — **закрыто:** тот же дефолт, что у движка; пин `runner.TestTheProjectDatabaseDefaultsTheWayTheEngineDefaultsIt` | fixed(приёмка P7, дерево сессии) | приёмка P7 (воркфлоу 12 осей + кросс-модель) |
|
||
| PD-270 | bug | info | `internal/httpapi/problem.go` `WriteProblem` | **`Problem` без `Code` уходил как 500 БЕЗ обязательного `code`.** Канон требует код на каждом ответе `/v0`, 500 включая; `omitempty` существует ради поверхности, которую канон не описывает, и протекал обратно. Живого вызова не было — латентная ловушка, бьющая по принципу самого файла («расходящаяся пара непредставима») — **закрыто:** пустой код → `internal_error`; пин `httpapi.TestAProblemWithNoCodeStillAnswersOne` | fixed(приёмка P7, дерево сессии) | приёмка P7 (воркфлоу 12 осей + кросс-модель) |
|
||
| PD-271 | bug | info | `internal/httpapi/conditional.go` `acceptsGzip`, `writeJSON` | **Договорённость о кодировании решалась по ПЕРВОМУ токену** (`*, gzip;q=0` читалось как согласие) и **HEAD не получал ни ETag, ни 304**, хотя маршрутизатор отдаёт ему тот же обработчик (Go 1.22+) — **закрыто:** именованное кодирование выигрывает у `*` в любом порядке; валидатор отвечает и на HEAD; пины `TestTheNamedCodingOutranksTheWildcardInEitherOrder`, `TestHeadCarriesTheSameValidatorAsGet` | fixed(приёмка P7, дерево сессии) | приёмка P7 (воркфлоу 12 осей + кросс-модель) |
|
||
| PD-272 | bug | info | `internal/pgstore/readmodel.go` `scope.tag`, `SubmitBankDecisions` | **Курсор не был привязан к КНИГЕ** (только к версии структуры) и принимался на другой книге; **решения банка** брали блокировки строк в порядке клиентского массива, и два пересекающихся сабмита взаимно блокировались — **закрыто:** книга входит в тег области; решения применяются в порядке `term_id`, отказ по-прежнему называет клиентский индекс; пин `pgstore.TestACursorMintedForOneBookIsRefusedOnAnother` | fixed(приёмка P7, дерево сессии) | приёмка P7 (воркфлоу 12 осей + кросс-модель) |
|
||
| PD-273 | standards | info | `internal/runs/reconcile.go` `outcome`, контракт §Run | **ЗАКРЫТ решением владельца 17.08: статус остаётся `awaiting_bank`, лечение — в КОНТРАКТЕ.** Разбор: нажать «стоп» на прогоне, УЖЕ стоящем в `awaiting_bank`, нельзя (`RequestStop` требует `finished_at is null`) ⇒ случай ровно один — гонка: стоп запрошен во время перевода, а движок в этом окне домайнил банк и вышел кодом 3. Оба состояния продолжаемы, но ярлык несёт ПРОВОДКУ: `ReleaseBankStop` вызывается только при `l.Status == "awaiting_bank"`, поэтому переименование в `stopped` оставило бы `bank_released = false`, вернуло бы `--verify-bank` на resume и **пере-открыло бы PD-277**. Плюс `awaiting_bank` информативнее: работа стоит на самой дешёвой точке — черновик и майнинг оплачены, редакторская волна не начата. **Настоящая дыра — ПРОВОД: `Run` не умеет сказать «остановлено по вашей просьбе»** (`status` + `stop_for_signing` = опция запуска, а не состояние), поэтому честную фразу клиент нарисовать не может. Строка бэклога для оркестратора — `archive/P7_ACCEPTANCE_HANDOFF_2026-08-17.md` §7(и) | closed (решение владельца 17.08) | приёмка P7 |
|
||
| PD-274 | doc | **major** | `deploy/README.md:80`, `docs/PLATFORM_DIRECTION.md:68` | **Деплой-нота включала обратно грант, который PD-104 выключил.** В копируемом блоке окружения стояло `TM_PLATFORM_SIGNUP_GRANT_USD=5`, то есть развёртывание по ноте давало каждой новой личности безлимитный самообслуживаемый грант; в `PLATFORM_DIRECTION.md` §2 «Дефолт $5» пережил правку первого абзаца. Плюс `TM_PLATFORM_LANGUAGE_PAIRS` не назван вовсе, а пустой список делает `/capabilities` и интейк противоположными — **закрыто:** строка снята с объяснением, пары внесены, оба текста актуализированы | fixed(приёмка P7, дерево сессии) | приёмка P7 (воркфлоу 12 осей + кросс-модель) |
|
||
| PD-275 | standards | minor | `internal/pgstore/readmodel_test.go`, `httpapi/reading_test.go`, `httpapi/problem_test.go` | **Три пина не проверяли того, что объявляли.** `TestAMissingBankReadOutSavesNothing` падал на шаг раньше (нет `book.yaml`) и в ветку `ErrNoBank` не входил; `TestALimitIsClampedOrIgnoredButNeverRefused` не мог наблюдать подрезку (фейк выбрасывал `limit`); `title(code) == ""` недостижимо ни для одного кода. Плюс подсистема `Idempotency-Key` целиком без тестов — **закрыто:** три пина переписаны на наблюдаемое свойство, подсистема ключей закрыта семью тестами, карта замечаний (`ingest/notes.go`) — тремя | fixed(приёмка P7, дерево сессии) | приёмка P7 (воркфлоу 12 осей + кросс-модель) |
|
||
| PD-276 | bug | info | `internal/books/parse.go` `Parse` | **Материализация на границе интейка шла на контексте задачи,** уже потраченном разбором: на большой книге `Refresh` (ещё два процесса движка) не укладывался, и дерево оставалось пустым, а свип этого не повторяет — **закрыто:** свой отвязанный бюджет, как на границе прогона. Бэкстоп-свип для книг с `chapter_count > 0` и пустым деревом **построен доработкой 20.08** (`books.materializeMissingTrees`, `pgstore.BooksWithNoTree`; пин `TestABookWhoseTreeNeverLandedIsMaterializedByTheSweep`) ⚠ **ЭРРАТА (акт 5): названный здесь бэкстоп и его пин БОЛЬШЕ НЕ СУЩЕСТВУЮТ.** `books.materializeMissingTrees`, `pgstore.BooksWithNoTree` и `TestABookWhoseTreeNeverLandedIsMaterializedByTheSweep` удалены вместе с механизмом, который их заменил: долг на материализацию стал колонкой (PD-302), и он покрывает не только «дерева нет вовсе», но и «дерево на границу старше». Свойство закрыто, доказательство переехало: `books.TestAParsedBookOwesAReadingSurfaceUntilOneIsMaterialized` (плюс проверка, что метка ставится ТОЙ ЖЕ транзакцией, что и конец разбора) | fixed(приёмка P7 + доработка 20.08, дерево сессии) | приёмка P7 (воркфлоу 12 осей + кросс-модель) |
|
||
| PD-277 | bug | **major** | `internal/runs/spawn.go`, шов с движком | **Экран подписи банка не был подключён к движку: `resume` перезапускал движок с тем же `--verify-bank`,** и тот немедленно вставал в тот же стоп — подпись не двигала ничего. Отчёт пака помечал пункт «исполнен», сверив НЕ ту ветку движка (`if !r.VerifyBank`, по которой resume не идёт). ⚠ Приёмка сперва оформила это как вопрос владельцу («нужен новый канал в движок») — **рамка была неверна**: строка единого бэклога 191(в) (слово владельца 16.08, D39.144) уже требовала «проводку `resume = снятие стопа` до движка», а движок без флага уже делает модель владельца — неподписанное едет авто-строками с пометкой ⟨проверить⟩ (`backend/internal/pipeline/mining.go:211-231`, D39.42 п.3) — **закрыто:** на resume флаг не передаётся (`l.VerifyBank && !l.BankReleased`, `bank_released` протянут в `LiveRun`); пин `runs.TestAResumedRunIsSpawnedWithoutTheSigningStop`. ⚠ ОСТАТОК, не блокер: `decline` пользователя до движка не доезжает — движок читает `mined_rejects`, платформа этот файл не пишет, поэтому отклонённый термин уедет в банк авто-строкой. Территория строки бэклога **192** («пост-ридинговый цикл»), отложенной владельцем до полигонных итогов | fixed(приёмка P7, дерево сессии) | приёмка P7 |
|
||
| 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 (сверка находок против дерева) |
|
||
| PD-286 | bug | info | `internal/httpapi/stream.go` `write`, `streamEvents` | **Ошибка `Flush` выбрасывалась**, а на маленьком кадре это единственный вызов, который видит залипший сокет: `Fprintf` пишет в bufio и возвращает nil, поэтому поток рапортовал «кадр ушёл» о кадре, который не ушёл, и залипший клиент получал `writeTimeout` на каждый heartbeat вместо одного. Плюс **HEAD на маршрут потока** проходил в насос и держал горутину до конца прогона (RFC 9110 §9.3.2: HEAD отдаёт те же заголовки и не тело) — **закрыто:** ошибка `Flush` убивает поток, HEAD отвечает заголовками; пины `TestAFrameThatCouldNotBeFlushedEndsTheStream`, `TestHeadOnTheStreamAnswersTheHeadersAndNothingElse` | fixed(доработка 20.08, дерево сессии) | доработка 20.08 (сверка находок против дерева) |
|
||
| PD-287 | bug | minor | `internal/runs/runs.go` `Bounds`, `Start` | **`blocked` называл чужую книгу всякий раз, когда у аккаунта есть открытый холд**, независимо от того, укорачивает ли он шкалу, — а канон §RunOptions обещает, что поле объясняет ИМЕННО укорачивание («why the scale is smaller than the account could otherwise afford»). Пользователь книги из трёх глав видел «другая книга держит кредит» и шёл останавливать прогон впустую. Симметрично `CreditHeldError` в `Start` срабатывал на ЛЮБОМ выходе за шкалу, включая выход по размеру книги. `Bounds` не имел ни одного теста — **закрыто:** `CreditHeldBy` возвращает и СУММУ чужих холдов, поле заполняется только если возврат этой суммы удлинил бы шкалу (в `Start` — только если без неё запрос бы прошёл); пин `TestBlockedNamesAnotherBookOnlyWhenItsHoldShortensTheScale` | fixed(доработка 20.08, дерево сессии) | доработка 20.08 (сверка находок против дерева) |
|
||
| PD-288 | bug | info | `internal/pgstore/readmodel.go` `noteCount` vs `ListNotes` | **`Book.note_count` и `GET /notes` описывали разные множества:** счётчик считал все флагнутые резолюции книги без джойна, список — только те, чья глава существует (INNER JOIN, иначе `chapter_id` нечем заполнить). Карточка обещала N замечаний, список отдавал меньше с `next_cursor: null`. Достижимо штатно, пока дерево не материализовано (PD-276) — **закрыто:** счётчик считается тем же джойном | fixed(доработка 20.08, дерево сессии) | доработка 20.08 (сверка находок против дерева) |
|
||
| PD-289 | bug | info | `internal/pgstore/sink.go` `unitDone` | **Кадры строились из ОТВЕРГНУТОЙ доставки:** апсерт резолюции отбрасывает событие старше сохранённого (`where excluded.at >= unit_resolutions.at`), но тег команды не проверялся — счётчики главы пересчитывались и `emitChapter` слал кадр `note`, собранный из полей устаревшего события, под тем же id замечания. Кадр хранится wire-ready и реплеится дословно, поэтому расхождение пережило бы правку — **закрыто:** `RowsAffected() == 0` ⇒ ничего не изменилось, ничего не объявляется | fixed(доработка 20.08, дерево сессии) | доработка 20.08 (сверка находок против дерева) |
|
||
| PD-290 | bug | info | `internal/pgstore/credits.go` `inTx` → `inReadTx`, гейты `ListNotes`/`ListBank` | **Гейт дельта-чтения и строки, которыми он управляет, читались в РАЗНЫХ снапшотах:** READ COMMITTED берёт снапшот на каждый стейтмент, поэтому замена банка/дерева между проверкой `version_too_old` и запросом строк не отвергалась и не отражалась — короткий список, который выглядит полным (класс PD-163, объявленный закрытым «одной транзакцией») — **закрыто:** пути чтения открываются `repeatable read` + `read only` (одна функция `inReadTx`; read-only повторяемое чтение не может прерваться, в отличие от serializable) | fixed(доработка 20.08, дерево сессии) | доработка 20.08 (сверка находок против дерева) |
|
||
| PD-291 | bug | info | `internal/readmodel/readmodel.go`, `internal/pgstore/readmodel.go` `writeUnits` | **Юнит, который есть в манифесте и выпал из экспорта** (кат сдвинулся между двумя вызовами движка), перезаписывал сохранённый текст пустым `pending`: флаг «текст прочитан» был на всей структуре, а не на паре. Правка PD-266 закрывала только полный отказ экспорта — **закрыто:** флаг переехал на ПАРУ (`StructureUnit.TextKnown`), структурный снят как избыточный; пин `TestAPairTheExportDidNotCarryDoesNotSpeakAboutItsText` | fixed(доработка 20.08, дерево сессии) | доработка 20.08 (сверка находок против дерева) |
|
||
| PD-292 | standards | info | `internal/pgstore/runs.go` `RestartRun`, `internal/runs/reconcile.go` `Resume` | **Снятие стопа банка было ОТДЕЛЬНЫМ писателем после переоткрытия прогона:** отказ между двумя записями оставлял возобновлённый прогон с `bank_released = false` — весь второй сегмент бар считал draft-колонку против купленного потолка, и канала починки не было (свип это поле не трогает, повторный resume отказал бы: прогон уже `translating`) — **закрыто:** снятие уехало ВНУТРЬ транзакции `RestartRun` (`LiftBankStop`), метод `ReleaseBankStop` снят, пин переписан на реальный путь | fixed(доработка 20.08, дерево сессии) | доработка 20.08 (сверка находок против дерева) |
|
||
| PD-293 | bug | info | `internal/runs/reconcile.go` `DrainRefresh` | **Проход материализации не мог быть подрезан своим бюджетом:** `DrainRefresh` не смотрел `ctx` между книгами, а каждая книга берёт отвязанные 5 минут, поэтому K медленных книг переезжали объявленные 10 минут прохода и задерживали интейк, телеметрию и СЛЕДУЮЩИЙ такт свипа — то есть голодание PD-169 переехало уровнем выше — **закрыто:** проверка между книгами, недоделанное возвращается в очередь (`deferRefresh` дедуплицирует по книге) ⚠ **ЭРРАТА (акт 5): механизм заменён, свойство сохранено.** `DrainRefresh`/`deferRefresh` удалены вместе с очередью в памяти; проверка бюджета между книгами живёт в `readmodel.Drain`, а «недоделанное возвращается» стало долговечным долгом с арендой (PD-302, PD-320, PD-322) | fixed(доработка 20.08, дерево сессии) | доработка 20.08 (сверка находок против дерева) |
|
||
| 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-главной книге до и после | 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)
|
||
|
||
Находки адверсариального ревью собственного диффа акта 4 (9 линз × 2 рефутера) и работа, которую
|
||
принёс ответ контрактной сессии на записку зоны. Каждая строка закрыта посадкой мутации: код испорчен
|
||
названным образом, пин обязан упасть, код возвращён.
|
||
|
||
| ID | Класс | Серьёзность | Где | Суть | Статус | Источник |
|
||
|---|---|---|---|---|---|---|
|
||
| PD-202 | bug | info | `internal/pgstore/readmodel.go` `finishedUnits`, `sink.go` `unitDone` | **`chapters.units_done` не двигался вовсе у пайплайна без волны редактора.** Колонка считала ТОЛЬКО волну `edit`, поэтому на деплое без редактора ни одна глава не была «сделана» никогда: полоса книги стояла на нуле, а `ChaptersLeft` не подрезался — сервис бесконечно предлагал купить уже переведённые главы. ⚠ Это ДЕНЬГИ, а не полоса. Закрыто формой, которую задала контрактная сессия: «сделано» = ПОСЛЕДНИЙ проход, который книга на ЭТОМ деплое реально получает, и форму движок объявляет сам — у пайплайна без редактора знаменатель волны `edit` равен нулю (`beginWaves`), значит `runs.edit_total = 0` при ненулевом `draft_total` и есть «редактора нет». Читают одно выражение все пять мест (`chaptersDone`, `runProgress`, `emitChapter`, `ReadBookForRun`, `bookScope.wave`); до первого отчёта прогона форма НЕИЗВЕСТНА и ответ остаётся волной `edit` — неизвестное не должно читаться как «сделано». Колонка-дубль `units_done` снесена (00021, PD-314). Пин: `pgstore.TestADraftOnlyDeploymentStillClampsTheScale` | fixed(акт 5) | вопрос зоны P7 → ответ контрактной сессии 20.08 (E-G) |
|
||
| PD-253 | standards | info | `internal/pgstore/readmodel.go` `ListUnits`, канон §listUnits | **`410 Gone` отвечал и на главу, которой никогда не было.** Расхождение снято НЕ кодом: контрактная сессия приняла довод зоны — различать «была и больше нет» от «не было никогда» невыполнимо без надгробий, которых у платформы нет и заводить которые дороже вопроса, — и изменила канон в нашу сторону. Расхождение перестало быть расхождением | fixed(канон 0.4.0) | приёмка P7 (акт 1) → ответ контрактной сессии 20.08 (L) |
|
||
| PD-262 | bug | minor | `internal/httpapi/v0.go` `intakeFingerprint`, `internal/httpapi/idempotency.go` | **Тождество интейка решалось по ОБЪЯВЛЕННОМУ, и объявленное его не несёт.** `Content-Length` стоял вместо размера файла и не закрывал ни одной половины: он считает и multipart-обвязку (повтор из другой клиентской библиотеки — «другой запрос»), а chunked-тело не объявляет ничего вовсе — две РАЗНЫЕ книги под одним ключом с одинаковым объявлением были неразличимы, и вторая получала `Location` первой. Закрыто нормой 0.4.0 («метаданные + имя файла + СОДЕРЖИМОЕ»): `Content-Length` из отпечатка убран, файл дайджестится на лету (`io.TeeReader`), дайджест хранится с квитанцией (00023), а повтор ЧИТАЕТСЯ и сверяется до того, как ему что-то ответят — совпал, реплей; не совпал, `409 key_reused`; не прочитан — не реплей. Пины: `httpapi.TestTheFingerprintIsExactlyTheDeclaredParts`, `httpapi.TestTheClaimsFourAnswersReachTheClient/another_FILE…`; живая проба 20.08: тот же файл → 201 с тем же id, другой файл → 409 `key_reused`, книг в библиотеке одна | fixed(акт 5) | приёмка P7 (акт 1) → норма 0.4.0 (E) |
|
||
| PD-282 | standards | minor | `internal/runs/reconcile.go` `Resume`, канон §resumeRun | **`resume` прогона, потратившего весь купленный потолок, отвечал 202 и НИЧЕГО не менял** — молчаливый no-op, который тот же раздел канона запрещает в соседней строке. Контрактная сессия ратифицировала обратное обещание: **202 означает, что работа реально переоткрыта**, и такой вызов — `409 run_not_resumable`, `cause: ceiling_reached`, в ЛЮБОМ статусе. Закрыто вместе с разведением двух `exhausted` (PD-312). Пин: `runs.TestResumeOfARunWithNothingLeftIsRefusedWithTheCeilingReached` | fixed(акт 5) | триаж акта 4 → ответ контрактной сессии 20.08 (A) |
|
||
| PD-300 | bug | minor | `internal/pgstore/idempotency.go`, `internal/httpapi/idempotency.go` | **Ключ идемпотентности не знал своего владельца, и обе половины стоили пользователю книги.** Строка несёт `(user, method, path, key)` и не несёт, КАКАЯ попытка её держит: (1) попытка, застрявшая дольше `ClaimStale`, наконец падала и её `defer release` УДАЛЯЛ живую строку преемника, который уже создавал книгу — третья попытка получала свежий клейм и делала работу второй раз; (2) у попытки забрали клейм, а она дописывала СВОЙ ответ, и клиент реплеил `Location` книги, которой у него нет. `and finished_at is null` не сужает ни одну: строка преемника законно не завершена. Обе воспроизведены ревьюером на живом PG. Закрыто токеном клейма (00020): `ClaimIdempotency` минтит его и ПЕРЕ-минтит при перехвате, `complete` и `release` его предъявляют, несовпадение → `ErrClaimLost`. ⚠ Сравнивать вместо токена `claimed_at` нельзя — Go даёт наносекунды, Postgres хранит микросекунды. Пины: `pgstore.TestAnAttemptThatLostItsClaimCanNeitherAnswerForItNorTakeItAway`, `httpapi.TestEveryEndingPresentsTheClaimItWasGranted` | fixed(акт 5) | ревью акта 4 (2 линзы, воспроизведено на живом PG) |
|
||
| PD-301 | bug | major | `internal/pgstore/events.go` `ReadStream`, `internal/httpapi/stream.go` | **Поток говорил `end` раньше, чем появлялось дерево — то есть ровно Ф-56, ради которой он и строился.** `books.Parse` коммитит `FinishParse` (`parsing → not_started`) и только ПОТОМ зовёт материализацию — два процесса движка на собственном бюджете. В этом окне «в покое» было истинно, насос слал `end`, а автоматический реконнект браузера получал **204 «не переподключайся»**: клиент переставал смотреть ровно тогда, когда главы вот-вот появятся. Та же дыра на границе ПРОГОНА: прогон закрыт, текст ещё не материализован, готовый ОПЛАЧЕННЫЙ перевод до читателя не доезжает. Закрыто долгом (PD-302): «в покое» теперь означает «и материализация не должна». Пин: `pgstore.TestABookThatOwesAReadingSurfaceIsNotAtRest`; живая проба 20.08: при непогашенном долге поток НЕ шлёт `end`, реконнект отвечает 200 вместо 204 | fixed(акт 5) | ревью акта 4 (замерено на живом PG) |
|
||
| PD-302 | bug | minor | `internal/runs/reconcile.go` (`deferRefresh`/`DrainRefresh`), `internal/books/parse.go` | **Долг на материализацию жил только в памяти процесса, и терялся двумя достижимыми путями:** `Refresh` вернул ошибку — ветка логировала и НИЧЕГО не ставила обратно; демон перезапустился — слайс умер с процессом. В обоих случаях дерево от прошлой границы ЕСТЬ, поэтому единственный бэкстоп («дерева нет вовсе») книгу не видел, и текст оплаченного прогона не доезжал до читателя никогда. Закрыто ДОЛГОВЕЧНЫМ долгом: колонка `books.read_model_owed_at` (00019) ставится в транзакциях `FinishParse` и `FinishRun`, гасится по РАВЕНСТВУ метки (долг, поставленный позже, переживает материализацию), очередь берётся из БД. Механизмов стало на два меньше: `runs.pendingRefresh/DrainRefresh/deferRefresh/Reader` и `books.materializeMissingTrees/BooksWithNoTree` удалены, дрейн переехал в `readmodel.Drain` — пакет, чья это работа. Пины: `runs.TestAFinishedRunLeavesItsBookOwingAReadingSurface`, `books.TestAParsedBookOwesAReadingSurfaceUntilOneIsMaterialized`, `pgstore.TestADebtStampedDuringAMaterializationSurvivesIt`, `readmodel.TestTheDrainMaterializesEveryOwedBookAndKeepsWhatFailed` | fixed(акт 5) | ревью акта 4 (2 линзы) |
|
||
| PD-303 | bug | minor | `internal/readmodel/readmodel.go` `refreshStructure` | **Упавшее чтение пар отчитывалось УСПЕХОМ:** отказ `Export` только логировался, `refreshStructure` возвращал nil, и интейк считал материализацию состоявшейся — книга получала полное дерево пустых пар, а читатель не видел даже собственного исходника. Закрыто возвратом отказа вызывающему (`errors.Join`): неполная материализация НЕ гасит долг, и книга остаётся в очереди дрейна, пока один проход не ответит по всем трём каналам. Пин: `readmodel.TestAPartialMaterializationDoesNotDischargeTheDebt` | fixed(акт 5) | ревью акта 4 |
|
||
| PD-304 | bug | minor | `internal/pgstore/books.go` `ReadUsage` | **`Usage` зажигал остановку АККАУНТА от потолка ОДНОГО прогона.** Предикат сканировал `paused_reason` последнего прогона каждой книги, а `credit_exhausted` там значит «этот прогон потратил своё». Замерено: счёт $10, прогон на 10 глав ($0.30) — провод отвечал `{"state":"ok","remaining_percent":97,…,"halt_reason":"credit_exhausted"}`, то есть пользователю с деньгами говорили, что денег нет, рядом с процентом, говорящим обратное. Канон предупреждает об этом дословно (§AccountHaltReason). Закрыто чтением остановки С АККАУНТА: нечего тратить — остановлен, и ничем иным. Пины: `pgstore.TestUsageIsAShareAndAnAccountWithNoGrantsIsExhausted`, `pgstore.TestAPauseTheReconcilerCausedIsVisibleOnTheRun`; живая проба 20.08: $10 → `halt_reason: null` | fixed(акт 5) | ревью акта 4 (замерено на проводе) |
|
||
| PD-305 | standards | minor | `httpapi.TestTheClaimCarriesTheRequestsOwnOperation`, `pgstore.TestAnOverlongPathDoesNotBreakTheClaim`, `pgstore.TestASecondRunsBarStartsAtZeroOverAHalfFinishedBook`, `config.TestAnUploadDeadlineLongerThanEitherWindowIsRefused`, `httpapi.TestEveryCodeNamesExactlyOneStatus` | **Пять пинов проходили под мутацией, которую сами называют** — каждый проверен ревьюером исполнением. Причины разные и все поучительные: единственный гоняемый маршрут имел путь, совпадающий с паттерном · 4000 повторяющихся байт СЖИМАЮТСЯ в индексном кортеже и до предела btree не доходят · фикстура заканчивала ровно одну главу и покупала ровно одну, поэтому кламп не связывал · оба значения цикла нарушали ВТОРУЮ проверку, поэтому снятие первой ничего не меняло · `statusOf(c)` это буквально `codes[c].status`, то есть сравнение таблицы с собой (регрессия акта 4: слияние статуса и заголовка сделало пин тавтологией). Закрыты по-разному, но каждый — так, чтобы названная мутация падала: новый пин на маршрут, где путь ≠ паттерн · несжимаемый путь (падает `index row size 4040 exceeds btree maximum 2704`) · новый пин, где прогон покупает МЕНЬШЕ, чем книга успевает закончить · одна проверка против ТЕСНЕЙШЕГО окна вместо двух · сверка с ТРАНСКРИПЦИЕЙ канона §ErrorCode, а не с той же таблицей | fixed(акт 5) | ревью акта 4 (все пять проверены исполнением) |
|
||
| PD-306 | bug | minor | `internal/pgstore/readmodel.go` `noteCount` | **`note_count` стоил 83% времени `GET /v0/books` — регрессия акта 4.** Джойн на `chapters` добавили, чтобы счётчик и список замечаний описывали одно множество (PD-288), и решение не было замерено. Замер зоны на корпусе 40 книг × 500 глав (по 1000 замечаний, после `vacuum analyze`): 16.8 мс на страницу против 3.4 мс со счётчиком, заменённым литералом, из них 9 мс — сам джойн. Стоимость неустранима по форме: счёт идёт по КАЖДОМУ замечанию каждой книги страницы. Закрыто счётчиком на главе (00022, возвращает колонку, снесённую 00016 — вместе с писателем, которого ей не хватало): фолд `unitDone` и пере-расчёт `writeChapters` ведут его в тех же операторах, что и волновые счётчики, из тех же строк, а согласие счётчика со СПИСКОМ становится конструктивным — замечание, у главы которого нет строки, не имеет ни адреса на проводе, ни счётчика. Замер после: **6.6 мс на страницу**. Пины: `pgstore.TestTheCardsNoteCountAndTheNotesListDescribeOneSet`, `pgstore.TestAFlaggedUnitMovesTheChaptersNoteCounter`, бенчмарк `pgstore.BenchmarkLibraryPage` | fixed(акт 5) | ревью акта 4 (замерено) |
|
||
| PD-307 | bug | minor | `internal/pgstore/credits.go` `CreditHeldBy` | **`blocked` называл СТАРЕЙШИЙ холд, а не тот, что укоротил шкалу — регрессия акта 4.** Решение «укорачивает ли» стало приниматься по СУММЕ чужих холдов (верно), а книга по-прежнему выбиралась `order by opened_at limit 1`. Замерено: $10, на книге A держится $0.03 (открыт первым), на книге B — $9.60; `blocked` называл A, и пользователь отменял прогон, который ничего не освободит. Закрыто выбором книги с НАИБОЛЬШЕЙ суммой холдов (`group by book_id order by sum(...) desc, min(opened_at)`) — ничьи решаются старшинством, чтобы ответ был устойчив между двумя чтениями. Пин: `pgstore.TestTheHoldThatIsNamedIsTheOneThatWouldFreeTheMost` | fixed(акт 5) | ревью акта 4 (замерено) |
|
||
| PD-308 | standards | minor | `internal/httpapi/v0.go` `startRun` | **`startRun` отвечал `413` — кодом, которого канон у этой операции не объявляет** (400/401/403/404/409/503), а `§ErrorCode` определяет `payload_too_large` как «свыше `intake_max_bytes`», то есть про ЗАГРУЗКУ. Соседняя JSON-запись на то же условие отвечала 400, значит одна из двух врала о том, что клиент сделал не так. Закрыто приведением к `400 invalid_request`. Пин: `httpapi.TestAnOverlongRunRequestIsInvalidAndNotTooLarge`; живая проба 20.08 | fixed(акт 5) | ревью акта 4 (2 линзы) |
|
||
| PD-309 | standards | minor | `internal/httpapi/stream.go` `pump` | **Границы дыры в потоке не были запинены на ОДИН кадр.** `emitFrame` минтит по кадру за раз, поэтому обычная дыра у живого соединения шириной ровно в один кадр, а пин PD-283 гоняет дыру в 189 кадров — обе границы отвечают на такую одинаково, и сдвиг любой из них на единицу батарея не замечала. Закрыто табличным пином на четыре случая по обеим границам. Пин: `httpapi.TestTheGapBoundariesAreExactAtOneFrame` | fixed(акт 5) | ревью акта 4 |
|
||
| PD-310 | doc | info | `internal/pgstore/runs.go`, `internal/pgstore/books.go`, `internal/httpapi/problem.go`, `internal/books/render.go` | **Проход по болтливости обрубил четыре комментария на середине** — осиротевшая строка без подлежащего у `EngineStreamID`, непарная скобка и склеенное надвое предложение в `WriteStatusProblem`, удвоенная клауза в `render.go`, док-блок `StuckIntake`, приклеенный к чужой функции. Тот же дефект был в акте 3. Закрыто починкой всех пяти; правило записано там же, где ошиблись: **при таком проходе читать РЕЗУЛЬТАТ целиком, а не только удаляемое** | fixed(акт 5) | ревью акта 4 (2 линзы) |
|
||
| PD-311 | hardening | minor | `internal/config/config.go` `loadIntake` | **Загрузочная граница дедлайна оставляла секунду.** Проверка сравнивала дедлайн с окном и не оставляла ничего на работу ПОСЛЕ чтения тела: принимался `29m59s`, а терминальные записи интейка идут на собственный бюджет и квитанция ключа пишется после них — клейм становился перехватываемым за мгновение до того, как его завершили. Плюс проверок было ДВЕ, и та, что слабее, не могла сработать никогда (`ClaimStale` теснее `UploadGrace`), а пин её «покрывал» вторым значением, которое ловила соседняя. Закрыто одной проверкой против ТЕСНЕЙШЕГО окна с явным запасом `books.UploadSettle`. Пин: `config.TestAnUploadDeadlineIsRefusedUnlessTheWholeUploadFitsTheTighterWindow` | fixed(акт 5) | ревью акта 4 |
|
||
| PD-312 | bug | minor | `internal/runs/reconcile.go` `reopen`, `internal/httpapi/v0.go` | **Два РАЗНЫХ факта возвращались одним вердиктом `exhausted`:** «прогон потратил свой потолок» и «счёт не тянет холд». Лечения у них противоположные — новый прогон против пополнения, — а ответ был один, поэтому пользователя с непотраченными главами отправляли покупать прогон, который ему не нужен. Разведено на `ceilingSpent` и `creditUnavailable`; второму контрактная сессия завела `cause.code: credit_unavailable` (второй уровень открыт, типов не двигает). Реконсилятор по-прежнему паузит оба одинаково — это не конфляция, а его работа: состояние, в котором владелец может действовать. Пин: `runs.TestResumeOverAnEmptyAccountSaysTheCreditIsUnavailableAndNotTheCeiling` (включая «пополнил — тот же вызов продолжает тот же прогон») | fixed(акт 5) | вопрос владельца 20.08 → норма 0.4.0 (A) |
|
||
| PD-313 | standards | minor | `internal/pgstore/books.go` `Run`, `internal/httpapi/project.go` | **Клик пользователя «стоп» не доезжал до клиента.** Столбец `runs.stop_requested_at` был, на проводе его не было, и никакой `status` его не заменяет: стоп не мгновенен, поэтому стоп, попросленный во время перевода, встречается с собственной остановкой прогона на подписи банка — прогон приезжает `awaiting_bank`, а этот статус предлагает ПРОДОЛЖИТЬ на клик, который значил «останови». Закрыто ОБЯЗАТЕЛЬНЫМ булевым `Run.stop_requested` (0.4.0, единственная ломающая правка), снимается при resume вместе с намерением. Побочно: три места читали `Run` тремя одинаковыми списками из четырнадцати колонок — сведены в `runRow`+`scanRun`. Пины: `pgstore.TestTheStopIntentTravelsOnEveryRunAndAResumeClearsIt`, `httpapi` (обязательность поля); живая проба 20.08 | fixed(акт 5) | норма 0.4.0 (J) |
|
||
| PD-314 | standards | info | `internal/pgstore/migrations/00021_drop_units_done.sql` | **`chapters.units_done` был байт-в-байт дублем `units_edit_done`** и писался двумя местами. После PD-202 читателей у него не осталось вовсе, а имя, читающееся как «сделано», над значением «отредактировано» — ровно та ловушка, из которой вырос PD-202. Колонка снесена вместе с обоими писателями | fixed(акт 5) | уборка зоны при PD-202 |
|
||
|
||
## Закрытые — самопроверка акта 5 (адверсариальное ревью собственного диффа, 20.08)
|
||
|
||
Ревью зоны против СОБСТВЕННОЙ работы акта 5: 9 линз × 2 рефутера + критик полноты, 84 агента, 0
|
||
ошибок. **37 находок, 18 пережили хотя бы одного рефутера**, и часть из них — дефекты, которые внесли
|
||
правки самого акта 5. Три из восемнадцати воспроизведены агентами исполнением на живом Postgres, две
|
||
— посадкой мутации в дерево. Отдельно ценно то, что ревью поймало КЛАСС, ради которого этот пак его и
|
||
гоняет: два пина, написанные в акте 5, проходили под мутацией, которую сами называют.
|
||
|
||
| ID | Класс | Серьёзность | Где | Суть | Статус | Источник |
|
||
|---|---|---|---|---|---|---|
|
||
| PD-315 | bug | major | `internal/pgstore/sink.go` `FinishUnspawnedStop`, `internal/pgstore/runs.go` `PauseRun` | **Транзакций, которые ЗАКАНЧИВАЮТ прогон, три, а долг на материализацию ставила одна.** Собственная посылка акта («обе границы ставят его в транзакции, которая границу закрывает») оказалась ложной: пауза на потолке и стоп, приехавший на попытку без юнита, оставляли книгу `finished_at`-нутой и НЕ должной. Оба случая с текстом: прогон, вставший на потолке, перевёл всё до него; стоп на попытке, у которой сняли клейм спавна, ловит движок, который реконсилятор сам же и рассчитывает (спенд-базлайн там держится намеренно). Читателя нет — очередь дрейна ключуется этой колонкой, и на закрытую книгу больше не смотрит никто. Закрыто фрагментом `owesAReadingSurface`, который несут все три, и пином по ТАБЛИЦЕ из трёх концовок. Пин: `pgstore.TestEveryEndingOfARunLeavesTheBookOwingASurface` | fixed(акт 5, самопроверка) | ревью акта 5, линза «долг» (2/2 рефутера не опровергли) |
|
||
| PD-316 | bug | major | `internal/pgstore/readmodel.go` `finishedUnits` | **Форма пайплайна читалась с ПОСЛЕДНЕГО прогона, а последний — самый НОВЫЙ, и только что допущенный не объявил ничего.** На деплое без редактора `chapters_done` каждой книги падал в ноль в момент создания прогона и поднимался обратно на первом progress-событии — счётчик, которому канон запрещает ходить назад, и вместе с ним шкала покупки, которая в этот момент снова предлагает уже переведённое. Закрыто переносом факта на КНИГУ (миграция 00024, `books.edit_wave`): пишется объявлением движка через оба канала (поток и resync), монотонно — книга, прошедшая редактирующий пайплайн, остаётся такой. Пины: `pgstore.TestAdmittingARunDoesNotWalkTheBooksProgressBackwards`, `pgstore.TestTheWaveShapeIsWrittenByTheEngineAndOnlyGrows` | fixed(акт 5, самопроверка) | ревью акта 5, линза «волны» (2/2) |
|
||
| PD-317 | bug | major | `internal/pgstore/runs.go` `StartRun`, `RestartRun` | **Базлайн полосы и её числитель считали РАЗНЫЕ проходы.** Числитель научился про деплой без редактора, а `chapters_before` в обоих местах по-прежнему называл `units_edit_done` — второй прогон над уже начерновленной книгой открывался на своём потолке, и снятие стопа подписи открывало второй сегмент там же. Воспроизведено ревьюером исполнением. Закрыто одним выражением на оба места. ⚠ **Первый написанный на это пин ПРОШЁЛ под своей мутацией** — фикстура останавливалась на шаг раньше того места, где перекос виден. Пины: `pgstore.TestASecondRunOnADraftOnlyDeploymentStillOpensAtZero`, `pgstore.TestLiftingTheStopOnADraftOnlyDeploymentRetakesTheRightBaseline` | fixed(акт 5, самопроверка) | ревью акта 5, линзы «волны» и «доки» |
|
||
| PD-318 | standards | major | `internal/books/books_test.go`, `internal/runs/reconcile_test.go` | **Два пина акта 5 проходили под мутацией, которую сами называют.** Оба утверждали КОНЕЧНОЕ состояние («книга должна поверхность»), а названная мутация — вынос метки из транзакции границы во второй оператор — конечное состояние не меняет: меняется окно, в котором книга разобрана и не должна ничего, а колонка — единственный ретрай. Ревьюер посадил обе мутации и обе прошли всю батарею. Закрыто утверждением про АТОМАРНОСТЬ: `xmin` (транзакция, последней писавшая строку) у книги и у кадра, который та же транзакция выпустила, обязан совпадать. ⚠ Первая редакция этого пина в прогонах сравнивала книгу с самим ПРОГОНОМ и падала на зелёном дереве: расчёт штампует прогон ещё раз мгновением позже | fixed(акт 5, самопроверка) | ревью акта 5, линза «пины» (исполнением, 2/2) |
|
||
| PD-319 | bug | major | `internal/pgstore/idempotency.go` `ClaimIdempotency` | **Клейм, проигравший гонку ОСВОБОЖДЕНИЮ, отвечал 500 — на том самом случае, ради которого заголовок существует.** Две попытки приходят вместе; победитель вставки падает быстро (любой 4xx возвращает ключ немедленно), и пере-чтение проигравшего на свежем READ COMMITTED снапшоте не находит ничего. Это тот же 500, который двумя строками выше закрывал `on conflict do nothing`. Воспроизведено ревьюером на живом PG (6 из 12 гонщиков). Закрыто повтором клейма с начала: пропавшая строка — не ответ никому, ключ просто снова свободен. Пин: `pgstore.TestAClaimThatLostARaceToAReleaseIsRetriedAndNotAnError` | fixed(акт 5, самопроверка) | ревью акта 5, линза «идемпотентность» (2/2) |
|
||
| PD-320 | bug | minor | `internal/pgstore/books.go` `BooksOwedReadModel`, `internal/books/parse.go` | **Дрейн получал книгу, которую интейк в этот момент материализует.** Долг становится виден в момент коммита разбора, а его собственный плательщик только входит в чтение движка (до 5 минут); свип идёт каждые 15 секунд в том же процессе и клейма у списка не было. Любая книга, материализующаяся дольше интервала свипа, читалась движком ДВАЖДЫ одновременно — ровно та цена, которую этот же пак экономил `RefreshCut`-ом (PD-248). Закрыто арендой: `ClaimReadModelDebt` отодвигает срок долга, очередь берёт только то, что `<= now()`, интейк клеймит свой долг перед материализацией. Пины: `pgstore.TestAClaimedDebtLeavesTheQueueUntilItsWindowLapses`, `readmodel.TestTheDrainSkipsABookSomebodyElseIsAlreadyMaterializing`, `books.TestTheIntakeClaimsItsOwnDebtBeforeMaterializing` | fixed(акт 5, самопроверка) | ревью акта 5, линза «SQL» |
|
||
| PD-321 | bug | minor | `internal/readmodel/readmodel.go` `refresh` | **Погашение долга шло на том же контексте, что и чтения движка, которые его исчерпали.** На большой книге чтения законно съедают весь бюджет, и запись «сделано» после них не доезжает — материализация СЛУЧИЛАСЬ и не записана, поэтому следующий проход делает две полных ре-нарезки заново, и так каждый проход. Закрыто отвязанным коротким бюджетом — то же правило, по которому живут терминальные записи интейка. Пин: `readmodel.TestTheDischargeSurvivesAContextTheReadsUsedUp` | fixed(акт 5, самопроверка) | ревью акта 5, зонд линзы «долг» |
|
||
| PD-322 | bug | minor | `internal/readmodel/readmodel.go` `Drain` | **Книга, про которую движок не может ответить никогда, держала голову очереди вечно.** Очередь берётся старейшими долгами, поэтому четырёх таких книг (каталог, который оператор перенёс; проектная база, которую эта сборка не читает) хватало, чтобы всё, что за ними, не получило оплаченный текст вовсе. Закрыто переносом неоплаченного долга в КОНЕЦ очереди — с той же сверкой метки, что и погашение. Пины: `pgstore.TestADeferredDebtGoesToTheBackAndNeverOverwritesANewerOne`, `readmodel.TestTheDrainMaterializesEveryOwedBookAndKeepsWhatFailed` | fixed(акт 5, самопроверка) | собственная сверка зоны при чтении своего же дрейна |
|
||
| PD-323 | standards | minor | `internal/httpapi/v0.go` `createBook` | **Повтор под завершённым ключом ИГНОРИРОВАЛ часть, присланную после файла, и отвечал 201**, тогда как то же тело под свежим ключом — 400 `missing_or_late`. Одни и те же байты были законны или нет в зависимости от того, какой ключ на них надет, и это противоречило собственному док-комментарию маршрута. Закрыто проверкой хвоста и на пути реплея. Пин: `httpapi.TestTheClaimsFourAnswersReachTheClient/another_FILE…`; живая проба 20.08 | fixed(акт 5, самопроверка) | ревью акта 5, линза «идемпотентность» |
|
||
| PD-324 | bug | minor | `internal/pgstore/books.go` `ReadUsage` | **Доля считалась только по строкам `kind = 'grant'`, а на бете грант при регистрации НОЛЬ и оператор пополняет счёт через `adjust`.** Замерено на проводе: счёт с $20, с которого можно стартовать прогоны, отвечал `{"state":"low","remaining_percent":0}` — та же «у вас нет денег» пользователю с деньгами, что и PD-304, с другого конца. Знаменатель стал «всё, что когда-либо ДОБАВИЛИ» (гранты и положительные корректировки); отрицательная корректировка остаётся на стороне трат. Пин: `pgstore.TestTheShareCountsEveryWayCreditWasAdded`; живая проба 20.08 | fixed(акт 5, самопроверка) | ревью акта 5, критик полноты (взаимодействие двух правок, которого не видела ни одна линза) |
|
||
| PD-325 | doc | major | `deploy/README.md` §дев-стенд | **Загрузочный гейт пар, который завёл этот же акт, отвергал собственный рецепт стенда:** копипаст-блок «Рецепт целиком» ставит `BOOKS_DIR` и `ENGINE_BIN`, но не `TM_PLATFORM_LANGUAGE_PAIRS` — демон не поднимался вовсе, и следующий шаг рецепта (`tmplatformctl seed`) бил в мёртвый порт. Боевой блок в том же файле пары получил, стендовый — нет. Это рецепт, которым поднимается ФРОНТ. Закрыто | fixed(акт 5, самопроверка) | ревью акта 5, критик полноты |
|
||
| PD-326 | doc | info | `internal/pgstore/events.go`, `internal/books/books.go`, `internal/httpapi/v0.go`, `internal/runs/reconcile.go` | **Четыре комментария, лгущих о коде под ними** — тот же класс, что PD-310, найденный тем же ревью в собственных правках акта: `nothingIsRunning` описывал материализатор как читающий его «по отрицанию» (полярность одна и та же, и инженер, действующий по комментарию, отправил бы дрейн читать движок ровно во время живого прогона) · `BooksOwedReadModel` утверждал, что чтение движка берёт проект эксклюзивно (ревьюер проверил ДВИЖОК: `NewReadOnlyRunner` открывает БЕЗ flock, намеренно) · `canTranslate` нёс две первых строки док-комментария · блок проекций объявлял себя «канон 0.3.0, поле в поле», уже содержа `stop_requested`, которого в 0.3.0 нет · ветка стопа банка всё ещё обещала, что резюм ждёт ПОЛНОГО набора решений — гейт, снятый D39.144 и удалённый из кода этим же паком | fixed(акт 5, самопроверка) | ревью акта 5, линзы «доки» и «SQL» |
|
||
| PD-327 | standards | minor | `internal/httpapi/idempotency.go`, `internal/httpapi/v0.go` `intakeFingerprint` | **Тождество интейка теперь решается СОДЕРЖИМЫМ файла, а ратифицированный 0.3.0 сравнение по байтам запрещает** («compared over the DECLARED parts — the metadata and the file's name and size — and never over the bytes themselves»). Новое поведение — это норма 0.4.0 (§E ответа контрактной сессии) и безопасная сторона: правило 0.3.0 и есть то, что позволяло двум разным книгам делить один ключ (PD-262). Строка заведена не как дефект, а как **зависимость от ратификации**: до лендинга 0.4.0 оркестратором зона отвечает `409 key_reused` там, где действующий канон обещает реплей. Закрывается лендингом канона | open (ждёт ратификации 0.4.0) | ревью акта 5, линза «провод» |
|