textmachine/platform/docs/DEFECT_REGISTER.md

107 KiB
Raw Blame History

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

Заведён по решению владельца 04.08 («отдельно ведётся колонка багов и уязвимостей — тут опасно всё»). Правила: каждая находка любой сессии/приёмки/аудита — строкой сюда ДО закрытия; ID стабилен навсегда; закрытие — только с коммитом фикса и тестом, пинящим свойство (урок PD-1: свойство без пинящего теста считается НЕ закрытым). Класс: vuln — эксплуатируемо или ослабляет защиту · bug — неверное поведение · hardening — защита в глубину / латентное. Статус: open · fixed(<commit>) · accepted-risk(<кем, когда>).

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-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 на ней ломается навсегда. Обоснование «до деплоя правим на месте» было допущением без механизма — закрыто: выпущенные 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/сутки на бумаге) — закрыто: грант только подтверждённой личности, аккаунт создаётся с нулём, начисление руками из админки. ⚠ Продуктовое следствие — вопрос владельцу в журнале 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, 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-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 в <jobdir>/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). Воспроизведено против живой БД раунд-трипом 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-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 на НЕСУЩЕСТВУЮЩИЙ аккаунт отдаёт ErrInsufficientCreditlockBalance 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; версия панели опровергнута)