# Регистр дефектов и уязвимостей платформы > Заведён по решению владельца 04.08 («отдельно ведётся колонка багов и уязвимостей — тут опасно всё»). > Правила: каждая находка любой сессии/приёмки/аудита — строкой сюда ДО закрытия; ID стабилен навсегда; > закрытие — только с коммитом фикса и тестом, пинящим свойство (урок PD-1: свойство без пинящего теста > считается НЕ закрытым). Класс: `vuln` — эксплуатируемо или ослабляет защиту · `bug` — неверное поведение · > `hardening` — защита в глубину / латентное. Статус: `open` · `fixed()` · `accepted-risk(<кем, когда>)`. | ID | Класс | Серьёзность | Где | Суть | Статус | Источник | |---|---|---|---|---|---|---| | PD-1 | hardening | minor | `internal/pgstore/pg_test.go:89` | Свойство «в БД только SHA-256, не токен» НЕ запинено тестом: посадка «`Digest` возвращает плейнтекст» выживает — тест сверяет хранимое через тот же `auth.Digest` (self-consistent). Нужен тест с НЕЗАВИСИМО вычисленным хешом либо ассерт «плейнтекст в БД не находится» — **закрыто:** `internal/pgstore/pg_test.go` — `TestStoredCredentialIsAHashNotTheToken`: оракул SHA-256 считается в тесте, плюс поиск плейнтекста в отрендеренной строке. Посадка «`Digest` возвращает плейнтекст» ПАДАЕТ (проверено) | fixed(P1, дерево сессии) | приёмка P0 (посадка №1) | | PD-2 | vuln | **major, ЖИВАЯ (не латентная)** | `cmd/tmplatformd/main.go:73-82` | Нет `ReadTimeout` ⇒ соединения пиннятся уже СЕГОДНЯ, без единой body-принимающей ручки: `net/http` дренирует непрочитанное тело <256 КБ ВНУТРИ `chunkWriter.writeHeader` до отправки заголовка ответа (`net/http/server.go:1389-1435`), и этот чтение-шаг наследует отсутствующий дедлайн. **Репродуцировано оркестратором на собранном бинаре:** 50 полу-кормленных POST на охраняемый `/v0/*` → сервер отработал и залогировал 50×401 `ms:0`, клиенты получили НОЛЬ байт, fd 7→57 и держались, пока не закрыл КЛИЕНТ (агент-скептик независимо пинил 500 соединений). Ограничителя соединений и документированного edge-прокси в зоне нет. Фикс — одна строка (`ReadTimeout`; для будущего SSE — per-conn дедлайны через `ResponseController`). Вторая половина (`MaxBytesReader`) сегодня не эксплуатируема (ни один хендлер не читает body) — гейт P1: закрыть ДО первого POST-хендлера — **закрыто:** `ReadTimeout` 30 с в `httpapi.DefaultTimeouts`; пин — `TestHalfFedRequestIsDroppedByTheServer` на РЕАЛЬНОМ `http.Server`. Живая проба: полу-кормленный POST теперь отпускается через 30.0 с (был бесконечно). Вторая половина закрыта `LimitBody` на поддереве `/v0` и `/auth`. Побочное обязательство «`ReadTimeout` рубил бы и SSE» ОПРОВЕРГНУТО в P2 (PD-51/PD-63): `net/http` снимает дедлайн сам, помощник `ClearReadDeadline` удалён как воспроизводивший ровно этот дефект; поток пинит `TestStreamOutlivesReadTimeout` | fixed(P1, дерево сессии) | приёмка P0 (security-линза + скептик + собственная репродукция) | | PD-3 | bug | minor | `internal/httpapi/middleware.go:57` | `Recover` логирует сырой `r.URL.Path` на ERROR — id книг/прогонов утекают в лог, против собственной дисциплины AccessLog (route-pattern, не путь) — **закрыто:** `Recover` логирует `route`, не `r.URL.Path` | fixed(P1, дерево сессии) | приёмка P0 (security-линза) | | PD-4 | hardening | minor | `internal/pgstore/sessions.go:41` | WHERE у `Touch` слабее, чем у `Lookup` (нет `idle_expires_at > now`): прямой вызов воскресил бы idle-истёкшую сессию. Через `Require` недостижимо (Touch только после успешного Lookup) — одна строка защиты в глубину — **закрыто:** клауза `idle_expires_at > $2` добавлена; пин — `TestTouchCannotResurrectAnIdleExpiredSession` (посадка падает) | fixed(P1, дерево сессии) | приёмка P0 (security-линза) | | PD-5 | bug | minor | `internal/auth/middleware.go:36,45` | Ошибки стора невидимы: сбойный `Lookup` → 401 без единой строки лога (аутентификационный DB-outage выглядит как шторм 401), `Touch` глотается `_ =`. На проводе различать нельзя (оракул) — но лог обязан различать — **закрыто:** `Authenticator.Log`: сбой `Lookup` (кроме `ErrNoSession`) и сбой `Touch` уходят в ERROR с `request_id`; на проводе по-прежнему неразличимо | fixed(P1, дерево сессии) | приёмка P0 (security+blind линзы) | | PD-6 | hardening | info | `internal/auth/csrf.go:51` | GET освобождён от CSRF (верно), но SSE-хендшейк — GET с амбиентной кукой: origin-чек хендшейка потока (STACK §5) не покрыт ничем. Закрыть при постройке SSE (P1) | open | приёмка P0 (security-линза) | | 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`, `TestParseUSDIsExactAndRoundsAwayFromZero` | 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-22 | hardening | info | `deploy/` | Ограничителя одновременных соединений нет ни в процессе, ни описанного edge-прокси: `ReadTimeout` ограничивает УДЕРЖАНИЕ одного соединения 30 секундами, но не их число. Осознанно оставлено деплой-слою (`LimitNOFILE`, edge) — строка заведена, чтобы это было решением, а не забывчивостью | accepted-risk(платформа P1, 05.08) | самопроверка P1 | | PD-23 | hardening | info | `internal/pgstore/migrations/00001_identity.sql` | Журнал входов растёт без ретенции и чистится только каскадом при удалении аккаунта. Нужен свип по возрасту (год?) — вопрос политики, не кода | open | самопроверка P1 | | PD-24 | bug | **major** | `internal/pgstore/migrations/` | Переиспользование номера миграции: удалённый `00003_usage.sql` и новый `00003_credits.sql` заняли одну версию. goose применяет ТОЛЬКО по номеру (ни имени, ни хеша), поэтому база, доехавшая до версии 3, рапортует «migrations applied» и не получает ни одной новой таблицы, вход и кредиты падают в рантайме, а `DownTo` на ней ломается навсегда. Обоснование «до деплоя правим на месте» было допущением без механизма — **закрыто:** выпущенные 00001–00003 возвращены байт-в-байт, новое приехало номерами 00004–00007; гейт `migrations.sha256` + `TestReleasedMigrationsAreUnchanged`; апгрейд со старого релиза пинится `TestDatabaseAtAnOlderReleaseCatchesUp` | fixed(P1, дерево сессии) | ревью «вне карты» (исполнением) | | PD-25 | bug | **major** | `internal/pgstore/credits.go`, `00007_credits.sql` | Ключ идемпотентности леджера не содержал `user_id`: грант с ключом, потраченным на другом аккаунте, молча проглатывался, а CLI печатал «granted». Плюс каскад удаления книги уносил ОТКРЫТУЮ резервацию, оставляя строку `hold` в леджере (деньги списаны, вернуть нечем), после чего освободившийся `engine_run_id` давал холд БЕЗ списания, а его релиз печатал деньги — **закрыто:** ключ стал `(user_id, source, source_id)`, пустой ключ запрещён DDL, `book_id` перешёл на составной FK к `books(id, owner_id)` с `on delete restrict`, `appendLedger` возвращает «применилось», `Hold` падает при повторе. Пины: `TestBookWithAnOpenHoldCannotBeDeleted`, `TestSecondHoldOnOneAttemptIsRefused`, `TestGrantIsIdempotentBySource` | fixed(P1, дерево сессии) | ревью денежного пути (исполнением) | | PD-26 | bug | minor | `internal/pgstore/credits.go` | Инверсия порядка блокировок Hold↔Settle/Release: 41 взаимоблокировка на 300 раундов, замерено. `Settle`/`Release` брали строку резервации раньше баланса — **закрыто:** `lockBalance` первым во всех операциях | fixed(P1, дерево сессии) | ревью денежного пути (исполнением) | | PD-27 | bug | minor | `internal/pgstore/credits.go` | `Settle` принимал любую сумму: одно завышенное `committed_usd` уводило баланс в минус, дальше каждый прогон получал `ErrInsufficientCredit` без диагностики — **закрыто:** расчёт capped потолком холда, факт записан в `note`; пин `TestSettlementIsCappedAtTheHold` | fixed(P1, дерево сессии) | ревью денежного пути · ревью «вне карты» | | PD-28 | bug | minor | `internal/ingest/supervisor.go` | `cmd.Wait()` на отменённой команде возвращает `context.Canceled`, а не `*ExitError`, поэтому исход читался как `failed`: штатный SIGTERM пометил бы ВСЕ идущие прогоны провалившимися — **закрыто:** исход из `ProcessState`, факт остановки едет в ошибке; пин `TestStoppedRunKeepsTheEnginesOutcome` | fixed(P1, дерево сессии) | ревью стиля (клейм) + собственная проверка исполнением | | PD-29 | vuln | minor | `internal/login/login.go` | `GET /auth/callback` — неаутентифицированная ручка, ПИШУЩАЯ в БД, без лимита и без ретеншена: замерено 2000 строк за 2.28 с с одного хоста (~76 млн строк/сутки), строки отказов недостижимы через API и не удалялись никогда — **закрыто:** лимитер на колбэке, ретеншен журнала 180 дней свипом | fixed(P1, дерево сессии) | ревью безопасности (исполнением) | | PD-30 | vuln | minor | `internal/pgstore/identity.go` | Грант фри-тира выдавался за каждую новую пару `(provider, subject)` без учёта `email_verified`: провайдер с саморегистрацией превращал каждый новый `sub` в $5, потолок задавал только глобальный лимитер (~$864k/сутки на бумаге) — **закрыто:** грант только подтверждённой личности, аккаунт создаётся с нулём, начисление руками из админки. ⚠ Продуктовое следствие — вопрос владельцу в журнале | 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, дерево сессии) | ревью логов (исполнением) | | PD-42 | hardening | info | `internal/login/login.go` | Лимит `/auth/login` глобальный: один хост держит ведро пустым и выключает вход всем (замерено: 8 отказов из 10 у «легитимного» пользователя при фоне 5 rps). Пер-адресный лимит здесь неверен, пока нет доверенного edge-прокси — за прокси RemoteAddr один на всех. Место лимита — edge | accepted-risk(платформа P1, 05.08) | ревью безопасности (исполнением) | | PD-43 | bug | info | `internal/pgstore/credits.go` | Денежный контур не имеет ни одного вызывающего вне тестов: `Hold`/`Settle`/`Release` не зовутся, `Sink` не реализован, `TypeSpend` не декодируется. При первом реальном прогоне баланс не изменится. Ожидаемо — воркера нет (П-1/П-3), но заведено строкой, чтобы это было решением, а не сюрпризом | open | ревью «вне карты» | | 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-46 | hardening | minor | `internal/httpapi/serve.go:33-40` | **Запинена ПРОВОДКА `ReadTimeout`, но не ЗНАЧЕНИЕ, с которым едет демон.** Тесты строят свой `Timeouts` (`fastTimeouts`), поэтому посадка «`DefaultTimeouts().Read = 0`» проходит ВСЮ батарею зелёной — а `main.go:121` берёт именно `DefaultTimeouts()`. Посадка «убрать `ReadTimeout` из `NewServer`» ловится (проверено), то есть дыра ровно в дефолтах. Это форма, в которой PD-2 пережил P0: свойство проверено не на том объекте, который едет в прод. Фикс — тест на сами значения `DefaultTimeouts` — **закрыто:** `httpapi.TestTheServerTheDaemonRunsHasEveryDeadlineSet` утверждает не литералы, а сам `*http.Server`, который строит `NewServer(…, DefaultTimeouts())`: каждый дедлайн >0, `WriteTimeout` ОБЯЗАН быть нулём (иначе резал бы SSE), `ReadHeaderTimeout <= ReadTimeout`, grace >0. Закрывает обе половины — значение и проводку. Пять посадок поймано поимённо: `DefaultTimeouts().Read=0`, снятие `ReadTimeout` из `NewServer`, снятие `IdleTimeout`, добавление `WriteTimeout` «для симметрии», снятие `Unwrap` | fixed(P2, дерево сессии) | приёмка P1 (посадка M23/M43) | | PD-47 | bug | minor | `internal/login/login_test.go:266` | **Закрытие PD-37 заявлено неверно:** «в тесты добавлены процент-кодированные входы» — их там нет (список: `//evil.example/`, `https://…`, `http:/…`, `/\evil.example`, `/\/evil.example`, `/\tevil`, `evil.example`, ``). Посадка «судить только сырую форму, без второго декода» батарею ПЕРЕЖИВАЕТ. Побочно: посадка «убрать обратный слэш из `ContainsAny`» тоже переживает — на тестовых входах её дублирует проверка `s[1]`. Эксплуатируемого редиректа нет; не запинена именно та защита, ради которой заведён PD-37 — **закрыто:** в таблицу добавлены процент-кодированные входы (`/%5c/`, `/%5C/`, `/%09`, `/%00`, `/%0d%0a`) — их ловит ТОЛЬКО второй декод — и `/%2f/evil.example`, который ловит ТОЛЬКО проверка `s[1]` на декодированной форме; плюс `FuzzSafeReturnTo`, который пинит СВОЙСТВО независимым оракулом (`url.URL.ResolveReference` после браузерной нормализации `\`→`/`), 3,1 млн исполнений без контрпримера. Посадки «судить только сырую форму», «убрать класс символов», «убрать protocol-relative» падают каждая. ⚠ Побочно установлено: условия `u.Scheme/u.Host/u.Opaque` НЕДОСТИЖИМЫ как отказ (при `raw[0]=='/'` схемы и Opaque не бывает, Host требует `//`), пин на них невозможен — оставлены бэкстопом, это названо в коде | fixed(P2, дерево сессии) | приёмка P1 (посадки M15/M16) | | PD-48 | hardening | minor | `internal/login/login.go:268-271` | **Правило PD-30 «грант только подтверждённой личности» не запинено ничем:** удаление `if !claims.EmailVerified { grant = 0 }` проходит все тесты `internal/login`. `pgstore.TestUnverifiedAddressStaysOffTheAccount` пинит другое свойство (адрес не поднимается на аккаунт), денежное — никто. По правилу шапки этого файла PD-30 закрытым не считается — **закрыто:** `login.TestSignupGrantGoesOnlyToAVerifiedIdentity` гоняет обе ветки через настоящий поток и сверяет САМ грант, дошедший до стора (`memStore` теперь его запоминает — раньше отбрасывал, потому правило и было незапинено). Посадка «убрать условие `EmailVerified`» падает | fixed(P2, дерево сессии) | приёмка P1 (посадка M14) | | PD-49 | hardening | minor | `internal/login/login.go:239-242` | **Вторая половина PD-32 не запинена:** удаление сверки `st.Provider != h.cfg.Provider` проходит все тесты. Сегодня провайдер один, поэтому свойство латентное — но заведено оно ровно под появление второго (IdP mix-up) — **закрыто:** `login.TestStateFromAnotherProviderIsRefused` подменяет провайдера в сохранённой строке состояния — форма, которую даёт появление второго провайдера, — и требует 400, отсутствия сессии, причины `state_from_another_provider` в журнале и НУЛЯ обращений к token endpoint. Посадка «убрать сверку» падает. Норму при этом закрывает не она, а PD-57 | fixed(P2, дерево сессии) | приёмка P1 (посадка M19) | | PD-50 | hardening | info | `internal/auth/csrf.go:55-60` | Закрытие PD-33 сформулировано сильнее кода: «снимает только ВАЛИДНЫЙ Bearer» — на деле `Present` только ПАРСИТ, поэтому `Authorization: Bearer <мусор>` требование `X-TM-Client` снимает. Привилегии это не даёт, проверено живой пробой (кука + мусорный Bearer + без заголовка → 401, не хендлер): безопасность держит правило «Bearer побеждает куку» в `Present`, а не «валидность». Посадка «снимать любым непустым Authorization» батарею переживает. Фикс — либо тест, либо честная формулировка доккоммента — **закрыто формулировкой + пином:** доккоммент `cookieUnsafe` переписан на то, что верно (`Present` ПАРСИТ, не валидирует; безопасность держит правило «есть `Authorization` ⇒ кука не участвует», а не валидность). `auth.TestAnAuthorizationHeaderTakesTheCookieOutOfPlay` пинит именно это на пяти формах заголовка; посадка «падать обратно на куку при неразобранном заголовке» падает | fixed(P2, дерево сессии) | приёмка P1 (посадка M30 + живая проба) | | PD-51 | bug | minor | `internal/httpapi/serve.go:118-127`, `STACK_DECISIONS §12` | **Механизм заявлен неверно.** Утверждение «`ReadTimeout` убил бы и поток, поэтому стриминговый хендлер ОБЯЗАН снять read-дедлайн» на Go 1.26.5 не подтверждается: `connReader.startBackgroundRead` сам делает `SetReadDeadline(time.Time{})` (`net/http/server.go:687-698`) и для запроса без тела вызывается ДО хендлера (`:2062`). Проверено исполнением на трёх формах запроса (GET без тела · POST с непрочитанным телом · POST с вычитанным телом) — поздний кадр доезжает во всех шести комбинациях, звали `ClearReadDeadline` или нет. Следствие: `TestStreamOutlivesReadTimeout` НЕ МОЖЕТ упасть от выхолащивания `ClearReadDeadline` (проверено); он пинит только `Unwrap` (эта посадка ловится). Код безвреден, ложны обоснование и строка в таблице пинов — **закрыто, и вывод приёмки уточнён исполнением:** механизм подтверждён (`startBackgroundRead` снимает дедлайн сам, `server.go:687-698`, для запроса без остатка тела — до хендлера, `:2059-2062`; по ходу хендлера не перевзводится — проверено по всем call sites). Но «код безвреден» неверно: см. PD-63. `ClearReadDeadline` УДАЛЁН, `STACK_DECISIONS §12` переписан, `TestStreamOutlivesReadTimeout` переписан на настоящее свойство (поток переживает `Read` БЕЗ действий хендлера) и пинит `Unwrap` через ошибку `Flush` | fixed(P2, дерево сессии) | приёмка P1 (посадка M24/M42 + отдельная проба) | | PD-52 | hardening | minor | `internal/pgstore/credits.go:233-243` | Порядок блокировок (PD-26) не запинен ни одним тестом — снятие `lockBalance` из `closeReservation` батарею переживает. Дефект воспроизведён приёмкой НЕЗАВИСИМО, в форме, которая действительно даёт цикл: конкурентные `Settle(run-1)` и повторный `Hold(run-1)` — **2 взаимоблокировки на 150 раундов с инверсией, 0 с фиксом**. Регрессионный тест написан приёмкой и лежит готовым к вставке в `docs/platform-PROGRESS.md`, раздел «Ратификация приёмкой P1». ⚠ Замер сессии «41 на 300» воспроизвести не удалось — их нагрузка не описана; принимается СО СЛОВ — **закрыто:** тест приёмки вставлен как `pgstore.TestHoldAndSettleOnTheSameAttemptDoNotDeadlock`. ⚠ Замер приёмки не копировался, а ПЕРЕПРОВЕРЕН на своём стенде (PostgreSQL 18.4): с инверсией падает 5 прогонов из 5, 5–10 взаимоблокировок на 150 раундов; с фиксом 5 прогонов из 5 зелёные. Замер сессии P1 «41 на 300» так и не воспроизведён и остаётся СО СЛОВ | fixed(P2, дерево сессии) | приёмка P1 (посадка M07 + собственная репродукция) | | PD-53 | hardening | info | `internal/httpapi/server.go:73-75` | `DefaultMaxBody` не запинен: поднятие лимита поддерева до 1 ГиБ батарею переживает. Пер-маршрутность лимита (PD-35) — тоже только на ревью — **закрыто:** `httpapi.TestBodyCapIsPerRouteBecauseNestingOnlyTightens` фиксирует исполнением ПРИЧИНУ пер-маршрутности — вложенный БОЛЬШИЙ лимит не поднимает внешний, — поэтому возврат общего слоя молча урезал бы аплоуд-маршрут; `TestDefaultBodyCapStaysAContractSizedNumber` держит дефолт в полосе контрактного размера (посадка «1 ГиБ» падает), не превращаясь в change-detector на точное число | fixed(P2, дерево сессии) | приёмка P1 (посадка M26) | | PD-54 | bug | minor | `docs/platform-PROGRESS.md:329-353` | В журнале ДВЕ несовместимые формы `GET /v0/usage`: новая кредитная (строка 172) и старая подписочная (строка 329) с `resets_at`, `windows[{period}]` и хранением в `usage_windows` — таблице, которую снесла миграция `00006`. Секция P0-эры не помечена superseded, а S3 идёт читать журнал именно за формой ручки — **закрыто:** подписочное тело ответа УДАЛЕНО из журнала, а не помечено баннером: S3 идёт туда за формой ручки и скопировал бы тело. Осталась одна форма — кредитная, в разделе «Что предлагаем в спеку (S3)»; из П-5 сохранены абзацы, не зависящие от модели денег, ссылка на хранение переведена на `credit_ledger` | fixed(P2, дерево сессии) | приёмка P1 (свип доков) | | PD-55 | bug | info | `deploy/tmplatformd.service` | `MemoryMax=2G` объявлен как «bounds the control plane», но ограничивает cgroup ЮНИТА — а по собственному аргументу этого же файла (закрытие PD-13) в этом cgroup живёт каждый ребёнок-`tmctl`. Значит потолок общий на платформу и все идущие прогоны, и OOM-killer выберет самый жирный процесс — движок, который держит ЭКСКЛЮЗИВНЫЙ лок на файле проекта: ровно тот исход, ради которого запрещён SIGKILL. То же про `TasksMax=512`. Латентно до появления воркера. ⚠ Под systemd не проверялось (нет sudo) — вывод из семантики `MemoryMax=`, не из замера — **закрыто:** семантика сверена по man 5 systemd.resource-control («absolute limit on memory usage of the executed processes in this unit… out-of-memory killer is invoked inside the unit»). `MemoryMax=2G` заменён на `MemoryMax=80%` — потолок машины, а не сервиса, как «last line of defense» и без знания о железе; `TasksMax=512` оставлен с честным комментарием, что покрывает платформу и прогоны вместе; ограничение ОДНОГО прогона названо работой воркера (transient scope). Побочно найдено и закрыто следствие, которого в этой строке не было, — PD-64. ⚠ Под systemd не запускалось (нет sudo); `systemd-analyze verify` (systemd 259) — exit 0 | fixed(P2, дерево сессии) | приёмка P1 (ревью деплой-юнита) | | PD-56 | bug | info | `internal/pgstore/credits.go:35-63` | `Grant`/`Adjust` на несуществующий аккаунт отдают оператору сырую ошибку Postgres с именем констрейнта (`credit_ledger_user_id_fkey`), тогда как `Balance` на том же входе отдаёт `ErrNoAccount`. Живая проба CLI. Косметика админ-поверхности, но опечатка в id читается как поломка БД — **закрыто:** `appendLedger` мапит нарушение `credit_ledger_user_id_fkey` в `ErrNoAccount`; `pgstore.TestMoneyOperationsAgreeOnAMissingAccount` требует одного ответа от `Grant`/`Adjust`/`Balance`/`ReadAccount`. Посадка «убрать сверку констрейнта» падает | fixed(P2, дерево сессии) | приёмка P1 (живая проба CLI) | | PD-57 | hardening | minor | `internal/login/login.go:239-242` | **Защита от IdP mix-up не та, что требует действующая норма.** RFC 9700 §2.1 (OAuth Security BCP, янв. 2025) — клиент SHOULD применять параметр `iss` из авторизационного ответа (RFC 9207) либо иной контрмер НА ОСНОВЕ `iss`; MAY — различные redirect URI на провайдера. Реализована собственная сверка `st.Provider` с `h.cfg.Provider`, а внутри одного хендлера это сравнение конфигурации с самой собой: `start` пишет туда то же значение. `iss` авторизационного ответа не читается вообще (`iss` ID-токена библиотека проверяет — это другой шаг и другой момент). Сегодня не эксплуатируемо: провайдер один, код всегда редимится у него же. Заведено потому, что регистр объявляет PD-32 закрытием «класса IdP mix-up», а против нормы это неверно, и при втором провайдере выбор (`iss` или раздельные redirect URI) должен быть ОСОЗНАННЫМ, а не побочным эффектом конфигурации — **закрыто реализацией нормы, а не обещанием.** Первоисточники сверены: RFC 9700 §4.4.2 («When an OAuth client can only interact with one authorization server, a mix-up defense is not required» — то есть СЕГОДНЯ несоответствия нет, требование включается со вторым сервером), §4.4.2.2 объявляет раздельные redirect URI фолбэком («SHOULD therefore only be used if other options are not available»); альтернатива «`iss` из ID-токена» нам не подходит — при чистом code flow токен приходит уже ПОСЛЕ отдачи кода. Выбран `iss` авторизационного ответа: **Google его шлёт** (`authorization_response_iss_parameter_supported: true`, сверено живьём). Сделано: `auth_states.issuer` (миграция 00008), сверка до обмена кода, отказ на СОРВАННОМ параметре у поддерживающего провайдера (RFC 9207 §2.4). Пин — `login.TestAuthorizationResponseIssuerIsChecked` (4 случая); посадки «убрать вызов», «убрать ветку несовпадения», «убрать ветку сорванного параметра», «потерять issuer в сторе» падают | fixed(P2, дерево сессии) | приёмка P1 (сверка с RFC 9700 §2.1 / RFC 9207) | | PD-58 | hardening | minor | `internal/config/config.go:60-61` | **Несоответствие собственной объявленной базовой линии.** `ENGINEERING_STANDARDS §2` берёт ASVS 5.0 L2, а L2 требует ДОКУМЕНТИРОВАТЬ сроки: 7.1.1 (срок бездействия и абсолютный предел + обоснование отклонений от NIST SP 800-63B), 7.1.2 (политика одновременных сессий), 7.1.3/7.6.1 (согласование срока НАШЕЙ сессии со сроком федеративной — у нас наша живёт своей жизнью, RP-initiated/back-channel logout нет). Сроки 14 суток бездействия и 90 суток абсолютных существуют только литералами в коде, обоснования нет ни в одном доке (проверено grep). Механические требования V7 при этом ВЫПОЛНЕНЫ и проверены: 7.2.3 энтропия (256 бит при требуемых 128), 7.2.4 ротация токена на аутентификации, 7.4.1 отзыв, 7.4.2 снос сессий при удалении аккаунта. ⚠ 7.4.5 (системный отзыв админом) покрыт только пер-пользовательским `revoke`; 7.5.2 (пользователь видит свои сессии) — работа П-1 — **закрыто, и значение выровнено вместо сочинения оправдания.** Тексты сверены дословно: ASVS 5.0 7.1.1/7.1.2/7.1.3, 7.6.1/7.6.2 и NIST SP 800-63B-4 §2.1.3 («overall timeout … SHOULD be no more than 30 days at AAL1; an inactivity timeout MAY be applied but is not required»). Абсолютный срок 90 суток был отклонением от SHOULD без причины, выдерживающей проверку, — снижен до **30 суток**; бездействие 14 суток остаётся и строже нормы. Документ — `STACK_DECISIONS §13`: уровень AAL1, оба срока, политика одновременных сессий (лимита нет — контракт предусматривает куку и Bearer одновременно; вместо лимита отзыв, «выйти везде» и журнал), рассогласование с федеративной сессией названо прямо (RP-initiated/back-channel logout нет), 7.6.2 выполнено. Пин — `config.TestSessionClocksStayWithinTheDeclaredBaseline` | fixed(P2, дерево сессии) | приёмка P1 (сверка с ASVS 5.0 V7) | | PD-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) | | PD-60 | bug | minor | `internal/ingest/supervisor.go`, шов | **Обратное давление не спроектировано, и канал тут ни при чём.** Пайп держит 64 КиБ; если синк платформы встанет на Postgres, движок заблокируется в `write(2)` — на часы, без контекста и дедлайна, прервать нечем. Сегодня не проявляется только потому, что материализатора ещё нет: `Ingest` кормит `Sink` синхронно, и латентность БД станет латентностью движка. Нужна ограниченная очередь у читателя и ЯВНАЯ политика на её переполнение: блокировать движок (корректно, но прогресс прогона привязан к доступности БД) или ронять события с маркером `events_dropped` (быстро, но журнал начинает врать). Выбрать и записать — обе позиции законны, молчаливой третьей нет | open | приёмка P1 (абстрактный разбор двумя агентами, оба независимо) | | PD-61 | bug | info | шов, строка 103 | Два свойства эмиттера, которые надо задать ДО его постройки, иначе они станут миграцией. **(а) Сброс буфера на выходе:** `bufio.Writer` вокруг потока плюс `os.Exit`/`log.Fatal` пропускает `defer` и теряет последние события — ровно те, что сообщают об окончании прогона. **(б) Хвост при падении платформы:** содержимое непрочитанного пайпа умирает вместе с читателем. Если требование «платформа перезапустилась, прогон продолжается» когда-нибудь появится, ответ — НЕ сокет (он даёт переподключение без возобновления), а журнал файлом: движок дописывает NDJSON в `/events.ndjson`, платформа тейлит его с чекпойнтом смещения в Postgres. Это переживает и падение платформы, и даёт реплей бесплатно. Оба агента пришли к этому независимо; прецедент — Bazel Build Event Protocol (файл или gRPC, не пайп родителя) | open | приёмка P1 (абстрактный разбор) | | PD-62 | bug | minor | `internal/pgstore/identity.go:26-35`, миграция `00005` | **`login.State.StartID` не персистился: колонки под него не было.** Поле заведено в P1 и логируется колбэком как `login_start_id` — то есть ОБЕ строки лога, которые должны сшивать две половины входа, в проде пустые. Батарея этого не видела, потому что тесты `internal/login` ходят в in-memory стор, который хранит структуру целиком: свойство проверялось не на том объекте, который едет (тот же класс, что PD-46). Воспроизведено против живой БД раунд-трипом `PutLoginState`→`TakeLoginState`: положили `REQ-ABC123`, получили `""` — **закрыто:** колонки `start_id` и `issuer` добавлены миграцией `00008`, `Put`/`Take` их несут; пин — `pgstore.TestLoginStateIsSingleUseAndExpires` сравнивает структуру ЦЕЛИКОМ (`reflect.DeepEqual`), поэтому следующее поле без колонки упадёт здесь же. Посадки «потерять start_id» и «потерять issuer» падают | fixed(P2, дерево сессии) | сессия P2 (найдено при правке PD-57) | | PD-63 | vuln | minor | `internal/httpapi/serve.go:118-127` (удалён) | **`ClearReadDeadline` воспроизводил PD-2 — тем самым вызовом, который был заведён как его исправление.** Доккоммент объявлял его ОБЯЗАТЕЛЬНЫМ для стримингового хендлера. На полу-кормленном запросе (тело анонсировано, не дослано) дренаж внутри записи заголовка ответа — единственная граница соединения, и ограничен он `ReadTimeout`; снятие дедлайна ДО записи заголовка эту границу убирает. Замерено: хендлер остаётся внутри `WriteHeader` и через 4 с после ухода клиента, соединение держится. Вызов после флаша бесполезен — контекст уже отменён дренажем. Приёмка (PD-51) заключила «код безвреден», проверив только корректные запросы; случая, где функция помогает, нет вовсе — **закрыто:** функция УДАЛЕНА, §12 переписан, пин — `httpapi.TestHalfFedStreamingRequestIsCutLoose` (контекст стримингового хендлера отменяется в пределах `Read`) | fixed(P2, дерево сессии) | сессия P2 (собственный замер при верификации PD-51) | | PD-64 | bug | minor | `deploy/tmplatformd.service` | **Дефолтный `OOMPolicy=stop` уронил бы платформу из-за одного прожорливого прогона.** Следствие того же факта, что PD-55 (дети-`tmctl` живут в cgroup юнита), но в той строке не названо: по man 5 systemd.service дефолт берётся из `DefaultOOMPolicy=` (системный — `stop`), а `stop` означает «the unit's processes are terminated cleanly by the service manager» — то есть OOM-килл ОДНОГО `tmctl` останавливает контрол-плейн и все остальные прогоны, после чего юнит уходит в `oom-kill` failed и его подхватывает `Restart=on-failure` — **закрыто:** `OOMPolicy=continue` проставлен явно с обоснованием; платформа переживает килл ребёнка и штатно закрывает его резервацию. ⚠ Под systemd не проверялось (нет sudo) — вывод из доки; `systemd-analyze verify` (systemd 259) — exit 0 | fixed(P2, дерево сессии) | сессия P2 (ревью деплой-юнита при PD-55) | | PD-65 | vuln | minor | `internal/login/login.go:367-382` | **Обмен кода и загрузка JWKS шли БЕЗ дедлайна**, тогда как discovery на том же пути ограничивает себя пятью секундами и называет причину («`http.DefaultClient` не имеет собственного таймаута, а вызов делается, пока человек ждёт»). В проде `httpClient` равен nil, поэтому обмен идёт на `http.DefaultClient`, а go-oidc строит набор ключей от `context.Background()`; `WriteTimeout` у сервера нет по проекту — значит издатель, который принял соединение и не отвечает, держит хендлер, пока клиент сам не уйдёт. Хуже того, набор ключей ОБЩИЙ: одна зависшая загрузка паркует ВСЕ параллельные входы (замерено ревью: два независимых входа ждали 12 с за одной загрузкой) — **закрыто:** `identify` ограничен `providerTimeout` 10 с; пин — `login.TestAStalledProviderDoesNotHoldTheCallback` на обеих ногах (token и keys), посадка «убрать дедлайн» падает | fixed(P2, дерево сессии) | ревью P2 (линза oidc-security, подтверждено верификатором на боевой проводке) | | PD-66 | bug | minor | `internal/httpapi/serve_test.go`, `cmd/tmplatformd/main.go:121` | **Мой собственный фикс PD-46 закрывал только половину и утверждал, что обе.** Тест строил свой сервер `NewServer(…, DefaultTimeouts())` и на него же смотрел; проводка демона осталась ненаблюдаемой, а `cmd/tmplatformd` тестов не имеет. Замерено ревью: замена аргумента на `Timeouts{Shutdown: 15s}` оставляет `make check` зелёным (0 issues) и бинарь снова пиннит соединения — PD-2 в полном объёме. То есть ровно та форма, которую PD-46 и называл: свойство проверено не на том объекте — **закрыто устранением КЛАССА, а не тестом:** `NewServer` больше не принимает `Timeouts` и берёт `DefaultTimeouts()` сам, передавать нечего; коротким дедлайнам тестов служит неэкспортируемый `serverWithTimeouts` | fixed(P2, дерево сессии) | ревью P2 (линза net-http) | | PD-67 | vuln | minor | `internal/pgstore/credits.go:236` | **`FOR UPDATE` не был запинен ничем, а комментарий теста утверждал обратное** («Mutation caught: … or the FOR UPDATE that serialises it»). Последовательный тест лока не видит по построению, а инвариант «кэш = леджер» его тоже не ловит: без лока кэш и леджер уезжают ВМЕСТЕ, оба в минус. Лок — единственное, что мешает двум прогонам потратить один и тот же кредит — **закрыто:** `pgstore.TestConcurrentHoldsCannotOvercommitAnAccount` — 60 раундов по два конкурентных холда, каждый по отдельности посильный, вместе нет; посадка «убрать `for update`» падает 3 прогона из 3, баланс уходит в −$2. Комментарий последовательного теста исправлен | fixed(P2, дерево сессии) | ревью P2 (линза sql-money) | | PD-68 | bug | minor | `internal/httpapi/server.go:111` | **`/readyz` рапортовал «готов» на базе БЕЗ схемы.** Готовность доказывалась одним `Ping`, который успешен на любом достижимом Postgres, включая пустой. `Migrate` выключен по умолчанию, а deploy-инструкция делает миграцию отдельным шагом — значит «процесс поднят, схема не накачена» это НОРМАЛЬНАЯ середина выката, и инстанс в этом окне отвечал 200 `ready`, проваливая каждый запрос, который затем обслуживал — **закрыто:** `Store.Ready` сверяет `goose_db_version` с максимальным номером миграции, вшитой в бинарь; схема ВПЕРЕДИ бинаря готовности не отменяет (иначе выкат ронял бы старый инстанс). Пин — `pgstore.TestReadinessRefusesADatabaseWithoutTheSchema`, посадка «свести готовность к `Ping`» падает. ⚠ Первая редакция фикса печатала причину В ТЕЛО ответа и ради этого тащила `pgstore` в `httpapi` — и слой, и утечка состояния выката на НЕаутентифицированной ручке; снято при самопроверке, причина уходит в ERROR-лог | fixed(P2, дерево сессии) | ревью P2 (линза вне карты) | | PD-69 | bug | minor | `internal/pgstore/store.go:42` | **Явный `pool_max_conns` из DSN молча отбрасывался.** Проверка «MaxConns равен дефолту pgxpool» не отличает «оператор не выбирал» от «оператор выбрал ровно это число»: pgxpool кладёт свой дефолт в то же поле, что `ParseConfig` заполняет из `pool_max_conns`. Оператор, порезавший реплику под бюджет `max_connections`, получал наш 16 вместо своих 8 — и наоборот на 32-ядерной машине. С `pool_min_conns` хуже: дефолт pgx равен 0, поэтому явный 0 не мог пережить проверку НИКОГДА — **закрыто:** вопрос «упоминает ли DSN этот ключ» задан ПАРСЕРУ pgx, а не значению: `pgxpool` достаёт `pool_*` из `RuntimeParams` и удаляет их, поэтому второй `pgx.ParseConfig` их ещё видит — обе формы DSN, кавычки и service-файлы бесплатно. ⚠ Первая редакция фикса разбирала DSN РУКАМИ (33 строки собственного парсера) — велосипед, найден при самопроверке и снят; наши дефолты применяются только там, где оператор промолчал. Пин — `pgstore.TestExplicitPoolSizesInTheDSNSurvive`, включая случай, сломавший ПРЕДЫДУЩУЮ эвристику: пароль, содержащий имя ключа | fixed(P2, дерево сессии) | ревью P2 (линза вне карты) | | PD-70 | bug | **major** | `internal/auth/middleware.go:57`, `internal/auth/cookie.go:62` | **Скользящее окно бездействия для БРАУЗЕРА не работало: Max-Age куки пишется один раз, на входе, и больше никем.** Серверная строка скользила (`Touch`), кука — нет, а `SetSession` зовётся ровно из одного места — колбэка входа. Следствие: для куки — единственной презентации, которую код вообще умеет выдавать, — срок жизни сессии был ФИКСИРОВАННЫЕ 14 суток от входа независимо от активности; человек, заходящий каждый день, выкидывался на 14-е сутки при живой серверной сессии, а абсолютный срок не мог наступить никогда. Это же делало ложным §13 — документ соответствия ASVS 7.1.1, который зона только что написала — **закрыто:** при скольжении окна кука переиздаётся с тем же токеном (ротация — акт границы входа, не скольжения); пин — `auth.TestSlidingTheIdleWindowRefreshesTheBrowsersCookie` (четыре случая: кука во второй половине окна, свежая кука, Bearer, упор в абсолютный срок). ⚠ Первая редакция фикса выдавала `Max-Age` равный idle-TTL безусловно — то есть кука могла пережить абсолютный срок и превратить каждый следующий запрос в 401 вместо чистого «вы вышли»; поймано самопроверкой, срок теперь берётся как `min(idle, остаток абсолютного)` | fixed(P2, дерево сессии) | ревью P2 (линза doc-vs-code) | | PD-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-72 | hardening | info | `internal/httpapi/server.go:88` | **Отсутствие ОБЩЕГО лимита тела над маршрутами не наблюдаемо ничем.** Пер-маршрутность (PD-35/PD-53) держится на том, что вложенный `MaxBytesReader` только УЖЕСТОЧАЕТ: это запинено `TestBodyCapIsPerRouteBecauseNestingOnlyTightens`. Но возврат внешнего слоя в `New` батарею переживает, потому что ни один маршрут не просит потолок БОЛЬШЕ дефолтного — наблюдаемым дефект станет ровно тогда, когда появится загрузка книги. Строка заведена, чтобы это не выяснилось молча: тест обязан приехать ВМЕСТЕ с маршрутом загрузки | open | ревью 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) + самопроверка | | 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` | open | приёмка 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) | open | приёмка P2 (живая проба + панель, две независимые линзы) | | 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)`, транзакция откатывается), но воркеру не на что смотреть, кроме текста ошибки | open | приёмка P2 (замер оркестратора №15 + панель) | | PD-82 | bug | info | `internal/pgstore/credits.go:236-239` | `Hold` на НЕСУЩЕСТВУЮЩИЙ аккаунт отдаёт `ErrInsufficientCredit` (в `lockBalance` `ErrNoRows` трактуется как «нет кредита»), а не `ErrNoAccount`: обещание PD-56 «один ответ на несуществующий аккаунт» покрывает `Grant`/`Adjust`/`Balance`/`ReadAccount` и на `Hold` не распространяется. Замерено приёмкой | open | приёмка P2 (замер оркестратора №15) | | PD-83 | hardening | minor | `internal/httpapi/middleware.go:64` | **Фикс PD-3 не запинен в собственном месте:** посадка «`Recover` логирует `r.URL.Path` вместо `routeOf(r)`» батарею ПЕРЕЖИВАЕТ, тогда как та же посадка в `AccessLog` ловится поимённо (`TestAccessLogNamesTheRouteNotThePath`). По правилу шапки этого файла половина PD-3 закрытой не считается | open | приёмка P2 (посадка мутации) | | PD-84 | hardening | minor | `internal/login/login.go:222` | **Лимитер колбэка (фикс PD-29) не запинен:** удаление всей проверки `h.limiter.Allow()` из `callback` оставляет батарею зелёной. Замер PD-29 (~880 строк/с с одного хоста) означает, что регрессия здесь тихо возвращает неаутентифицированного писателя в таблицу журнала | open | приёмка P2 (посадка мутации) | | PD-85 | hardening | minor | `internal/pgstore/identity.go:126-131` | **«Неподтверждённый адрес не поднимается на аккаунт» запинено только на ветке НОВОЙ личности:** снятие условия `in.EmailVerified` в ветке ВОЗВРАЩАЮЩЕГОСЯ входа (обновление `users.email`) проходит батарею — `TestUnverifiedAddressStaysOffTheAccount` покрывает первый вход и переход в verified, но не обратный случай | open | приёмка P2 (посадка мутации) | | 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-89 | hardening | minor | `cmd/tmplatformctl/main.go:143-150` | **Сминченный ключ идемпотентности не печатается при ошибке записи:** PD-75 закрыл путь ПОСЛЕ коммита, но неоднозначный обрыв НА коммите остался — оператор видит ошибку, повторяет без `--key`, `newKey()` чеканит новый ключ, второе начисление проходит. Фикс: печатать ключ вместе с ошибкой, чтобы повтор был с тем же `--key` | open | приёмка P2 (панель) | | PD-90 | bug | info | `cmd/tmplatformctl/main.go:112,134` | `grant` и `adjust` делят пространство ключей `source="admin"`: `--key`, потраченный грантом, молча гасит корректировку с тем же ключом. CLI честно скажет «ключ уже потрачен», но оператор ждал другой операции | open | приёмка 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 нет) | open | приёмка P2 (панель, сверено с докой) | | PD-92 | bug | minor | `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 | 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-95 | doc | minor | `internal/ingest/events.go:6-12` | **Доккоммент несёт предложение, которое уже отвечено и ОТКЛОНЕНО:** новый ⚠-абзац предлагает увести поток со stdout на выделенный дескриптор/сокет, тогда как PD-59 закрыт ратификацией «канал остаётся stdout» (мотив — SIGPIPE-семантика fd 1, которая нам служит), и та же сессия записала это в свой журнал. Код и решение расходятся в файле, который эмиттер-сессия прочтёт как задание | open | приёмка P2 (свип решений) | | 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-97 | hardening | info | `internal/pgstore/credits.go:212-216` | `Settle`/`Release` отбрасывают флаг `applied` у `hold_release`: если ключ `("run_release", engineRunID)` уже потрачен, резервация закроется, а деньги не вернутся — тихий no-op на денежном пути. Требует нештатной последовательности (закрытие, смахивание строки, повторное открытие того же `engine_run_id`), но ровно на такой последовательности стоит `ErrDuplicateHold` | open | приёмка P2 (панель) | | PD-98 | doc | info | `internal/pgstore/store.go:75-79` | Случай «схема НОВЕЕ бинаря» в `Ready` беззвучен — признано ⚠-комментарием на месте, но ни одной строки лога: оператор, запустивший старый бинарь на новой схеме, сигнала не получит | open | приёмка P2 (панель) | | PD-99 | hardening | info | `internal/ingest/supervisor.go:102` | INFO-лог «engine started» пишет `args` целиком. Сегодня безвредно, но воркер будет передавать движку идентификатор книги и потолок аргументами ⇒ book-id и денежная сумма попадут в INFO платформы (D39.84 + норма зоны «id книги в логи не текут»). Закрыть вместе с воркером: логировать имя команды, не argv | open | приёмка P2 (панель) | | 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 снова выглядит штормом обычных отказов | 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 | hardening | minor | `internal/login/login.go:285-288` | **Фри-тир печатается НЕАУТЕНТИФИЦИРОВАННЫМ потоком без агрегатного потолка:** $5 за каждую новую пару `(provider, subject)` с подтверждённой почтой, единственный ограничитель — тот же лимитер входа. Агрегатного лимита грантов, счётчика аномалий и алерта нет нигде. PD-30 закрыл половину («только подтверждённой личности»); вторая половина — суточный потолок и наблюдаемость — вопрос владельцу (вынесен приёмкой) | open | приёмка P2 (панель) | | PD-105 | standards | minor | `internal/ingest/decoder.go:96` | **Декодер и норматив зоны расходятся на дубле `seq`:** декодер объявляет его фатальным `ErrStreamGap`, а `ENGINEERING_STANDARDS §2` ратифицирует «at-least-once — норма, дубль — не ошибка». После фикса PD-12 цена выросла: сбой ингеста ОСТАНАВЛИВАЕТ прогон, поэтому одна задублированная строка убивает платный прогон, хотя ратифицированный путь ремонта — `status --json`. Внутри одного пайпа передоставки нет, так что отказ декодера защитим; непропорциональна РЕАКЦИЯ. Разрешать ратификацией вместе с промтом эмиттера (строка 103), не молча | open | приёмка P2 (панель) | | PD-106 | standards | minor | `cmd/tmplatformctl/` | **Админ-CLI — единственный писатель денег в дереве — не имеет ни одного теста.** В том числе не покрыто правило, которое он сам называет несущим («после коммита команда не может отчитаться провалом», фикс PD-75), и разбор флагов, и формат вывода. Батарея зоны его не видит вовсе (`[no test files]`) | 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; версия панели опровергнута) |