textmachine/platform/docs/DEFECT_REGISTER.md

327 KiB
Raw Blame History

Регистр дефектов и уязвимостей платформы

Заведён по решению владельца 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-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). ⚠ Продуктовая часть (ноль на бете, агрегатный потолок, счётчик) — НЕ закрыта: ждёт слова владельца, носитель прежний open приёмка P2 (панель; расхождение — оркестратор №15)
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_usdstatus --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"} 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-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 фронт-сессии; зона правку спеки не делает и ждёт её open сессия 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 фронт-сессии; зона правку спеки не делает и ждёт её open сессия P5 (самопроверка против спеки)
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-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 open сессия P6 (живая проба на стенде)

| PD-217 | bug | minor | internal/pgstore/books.go BooksForMigration, deploy/README.md | Книга, застрявшая на daily_ceiling или на вечно незакрытом холде, блокирует апгрейд движка бессрочно, и выхода у оператора нет. Оба состояния снимаются только тем, чего платформа сделать не может: дневной потолок живёт в book.yaml оператора, и резюм по нему отказан 409; холд закрывается расчётом, который читает движок ЗАПИНЕННЫМ бинарём. Форсирующего флага у books --migratable нет намеренно — он и был бы способом пере-оплатить уже купленные вызовы. Лечится либо ручным разрешением в БД, либо каналом «признать попытку невосстановимой», которого в контракте нет | open | приёмка P6 (дофикс, ФП-7) | | PD-219 | bug | minor | internal/runs/reconcile.go drainJournal, reopen | Перезапуск после упавшего дрейна теряет непримененный хвост журнала навсегда. Новая попытка получает курсор с РАЗМЕРА журнала на момент допуска, поэтому строки, которые прошлый дрейн не успел применить, не прочитает уже никто. Счётчики восстанавливает ре-синк, а unit_resolutions — нет: их единственный источник — поток. Сегодня невидимо (читающей поверхности единиц ещё нет), к P7 станет расхождением read-модели | open | приёмка P6 (дофикс, ФП-7) | | PD-223 | bug | minor | internal/runs/reconcile.go Resume, reopen | Резюм прогона, остановленного ПЛАТФОРМЕННЫМ потолком, — цикл «холд-спавн-пауза» за клик. Движок останавливается на резервировании, которое не помещается, то есть НЕ доходит до своего потолка: расчёт списывает меньше холда, у прогона остаётся положительный остаток (обычно меньше одного вызова), exhausted не наступает, и резюм открывает попытку, которая упирается в тот же потолок сразу. Денег провайдера не тратится; цена — tmctl status и транзиентный юнит на клик. Порог здесь угадывать нельзя: размер резервирования — число движка, платформе не видное | open | приёмка P6 (дофикс, ФП-6) |

Открытые — info

ID Класс Серьёзность Где Суть Статус Источник
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 фронт-сессии; зона правку спеки не делает и ждёт её open адверсариальное ревью P5 (сверка провода со спекой)
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 в следующей сессии либо поправить направление 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-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 фронт-сессии; зона правку спеки не делает и ждёт её open сессия P5
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-185 bug info internal/pgstore/sink.go, internal/runs/runs.go readyToTranslate Статус finalizing есть в контракте, в DDL и в обоих аллоулистах — писателя нет ни одного. Тот же класс, что интейк-статусы до этого пака: слово контракта без пути, который его пишет. Сегодня недостижим, поэтому вреда нет; лечится либо писателем (финальная волна движка), либо снятием из аллоулистов, когда станет ясно, что его не будет open кросс-семейное ревью P5 (Fable)
PD-201 bug info deploy/README.md, движок tmctl migrate Самолечение деплой-деадлока v15 ЖДЁТ движковую половину. Read-only status отказывает файлу проекта старее бинаря, платформа зовёт его перед каждым спавном и на расчёте денег — значит выкат движка запирает книги до tmctl migrate. Порядок апгрейда записан и исполним (tmplatformctl books --migratable пропускает книги с живыми прогонами, резюмируемыми попытками и незакрытыми холдами — все три блокируют по РАЗНЫМ причинам, аддендум оркестратора 14.08). Чего нет: движковый migrate придёт с машиноразличимой ошибкой «версия не совпала», и тогда платформа сможет лечиться сама — поймала → migrate (если у книги нет открытых попыток) → повтор. Вслепую не строится: без формы ошибки любой матчер был бы догадкой по тексту. ⚠ Проверка исполнением самого шага migrate тоже ждёт и НЕ помечена сделанной open аддендум оркестратора 14.08 (ресёрч деплоя)
PD-202 bug info internal/pgstore/sink.go unitDone, миграция 00002 chapters.units_done не двигается вовсе у пайплайна без волны редактора. Колонка считает ТОЛЬКО волну edit (так было и до фолда присваиванием — семантика сохранена намеренно), а ReadBookForRun строит из неё ChaptersLeft, то есть шкалу потолка следующего прогона. Книга, переведённая draft-only пайплайном, навсегда остаётся «ни одной главы не сделано». Сегодня безвредно — писателя у chapters в проде нет вовсе (манифест глав не материализуется, строка 100), — но лечится вместе с K-10: решить, что значит «глава готова», когда волн одна open сессия P6 (при постройке фолда)
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) сделало вопрос «чей это потолок» отвечаемым 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-241 bug minor internal/runs/reconcile.go outcome Стоп, о котором попросил ПОЛЬЗОВАТЕЛЬ, переименовывается в paused/credit_exhausted, если в тот же дрейн приехал потолок: причина паузы проверяется раньше намерения стопа, и аккаунтный halted-флаг (ReadUsage ключится на credit_exhausted) зажигается на аккаунте, у которого 97% баланса на месте (воспроизведено ревью). Денег не теряется и прогон резюмируем, но /usage говорит «кредит кончился» человеку, у которого он есть. Порядок «причина раньше намерения» — ратифицированное решение P6, и молча его переворачивать этим касанием я не стал; денежно-видимая половина — это PD-203 open кросс-семейное ревью дофикса P6 (линза автомата состояний)
PD-242 bug info internal/runs/reconcile.go Resume ceiling_unknown проходит гейт резюма, который отказывает daily_ceiling: дневной потолок, приехавший ТОЛЬКО кодом выхода (карантин проекции — случай, который собственный комментарий outcome называет не-редким), записывается как «чей потолок, не установлено» и резюмируется свободно, упираясь в тот же лимит того же дня. Не регресс (прежнее credit_exhausted резюмировалось так же) и тот же класс чурна, что PD-223 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-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.goTestStoredCredentialIsAHashNotTheToken: оракул 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 на ней ломается навсегда. Обоснование «до деплоя правим на месте» было допущением без механизма — закрыто: выпущенные 0000100003 возвращены байт-в-байт, новое приехало номерами 0000400007; гейт 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, 510 взаимоблокировок на 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). Воспроизведено против живой БД раунд-трипом PutLoginStateTakeLoginState: положили 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=yesPermission 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 на НЕСУЩЕСТВУЮЩИЙ аккаунт отдаёт ErrInsufficientCreditlockBalance 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 --jsontmctl: --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(третий раунд, дерево сессии)
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 мин): свип воровал клейм у живого парса. В окне 1015 минут на одной проектной директории оказывались два 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 открыта; наш /stopstopped, второй попытки нет fixed(P6, дерево сессии) приёмка P4 (N1)
PD-163 bug info internal/pgstore/books.go ListBooks, GetBook Ревизия области читается ВТОРЫМ запросом после страницы, поэтому под конкурентной материализацией она новее строк: клиент, соблюдающий контрактное «отбрасывай чтение с меньшей ревизией», навсегда потеряет кадры между двумя запросами. Потребителя (SSE) сегодня нет; закрывать до первого потока — читать страницу и ревизию одной транзакцией — закрыто (P6): страница и ревизия области читаются ОДНОЙ транзакцией (ListBookslistBooksTx), и то же сделано карточке книги (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 (линзы шва и денег)