211 KiB
211 KiB
Регистр дефектов и уязвимостей платформы
Заведён по решению владельца 04.08 («отдельно ведётся колонка багов и уязвимостей — тут опасно всё»). Правила: каждая находка любой сессии/приёмки/аудита — строкой сюда ДО закрытия; ID стабилен навсегда; закрытие — только с коммитом фикса и тестом, пинящим свойство (урок PD-1: свойство без пинящего теста считается НЕ закрытым). Класс:
vuln— эксплуатируемо или ослабляет защиту ·bug— неверное поведение ·hardening— защита в глубину / латентное ·doc— док лжёт о коде ·standards— расхождение с объявленной нормой зоны (введены приёмкой P2; словарь отставал от строк — испр. оркестратором №15). Статус: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.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/сутки на бумаге) — закрыто: грант только подтверждённой личности — НЕПОДТВЕРЖДЁННАЯ создаёт аккаунт с нулём, и его начисляют руками из админки. ⚠ Исправлено 08.08 (PD-104): прежняя редакция этой ячейки говорила «аккаунт создаётся с нулём» без оговорки и противоречила коду — ПОДТВЕРЖДЁННАЯ личность получает автогрант TM_PLATFORM_SIGNUP_GRANT_USD (дефолт $5, config.go), закрыт был только путь саморегистрации. ⚠ Продуктовое следствие — вопрос владельцу в журнале |
fixed(P1, дерево сессии) | ревью безопасности (исполнением) |
| PD-31 | bug | minor | internal/login/login.go |
discover держал мьютекс на время сетевого вызова без таймаута: шесть параллельных входов при медленном IdP заняли 4/8/12/16/20/24 с вместо ~4 — закрыто: запрос вне лока, свой таймаут 5 с |
fixed(P1, дерево сессии) | ревью безопасности (исполнением) |
| PD-32 | vuln | minor | internal/login/login.go, cmd/tmplatformd/main.go |
Имя провайдера захардкожено "google" независимо от issuer, а State.Provider писался и не сверялся: смена issuer тихо кладёт чужие sub в старое пространство имён (новые аккаунты, старые недостижимы), а при двух провайдерах стейт одного редимится колбэком другого (IdP mix-up) — закрыто: TM_PLATFORM_OIDC_PROVIDER, сверка st.Provider в колбэке |
fixed(P1, дерево сессии) | ревью безопасности · ревью «вне карты» |
| PD-33 | vuln | minor | internal/auth/csrf.go |
Требование X-TM-Client снималось ЛЮБЫМ заголовком Authorization, включая мусорный: покрытие CSRF-слоя выбирал атакующий (сегодня упиралось в 401, но пережило бы любое послабление в present) — закрыто: снимает только валидный Bearer, через ту же функцию, что аутентифицирует |
fixed(P1, дерево сессии) | ревью безопасности (исполнением) |
| PD-34 | bug | minor | internal/httpapi/serve.go, cmd/tmplatformd/main.go |
Две регрессии остановки: второй SIGTERM больше не прерывал дренаж (процесс жил ровно 15 с), а просроченный дренаж возвращал ошибку и давал exit 1 — при Restart=on-failure штатная остановка читается systemd как крах — закрыто: сигнал разрегистрируется при начале дренажа, просрочка логируется WARN и даёт exit 0, добавлена строка stopped |
fixed(P1, дерево сессии) | ревью «вне карты» (исполнением) |
| PD-35 | bug | minor | internal/httpapi/middleware.go |
Лимит тела стоял самым внешним слоем, поэтому обещанное «ручка загрузки регистрирует свой, больший лимит» не работало: вложенный MaxBytesReader не может ослабить внешний, а контракт требует загрузку книги (23 МБ) — закрыто: лимит стал пер-маршрутным аргументом guard |
fixed(P1, дерево сессии) | ревью «вне карты» |
| PD-36 | hardening | minor | internal/httpapi/middleware.go |
Не было HSTS, CSP и запрета фрейминга; __Host- защищает запись куки, а не первый навигационный запрос — закрыто: Content-Security-Policy: default-src 'none'; frame-ancestors 'none', X-Frame-Options: DENY, HSTS в прод-профиле (в dev выключен: пин политики на localhost — долгая ошибка) |
fixed(P1, дерево сессии) | ревью безопасности |
| PD-37 | bug | minor | internal/login/login.go |
safeReturnTo заявляла защиту, которой не давала: проверка обратного слэша работала по уже раскодированной строке, а браузер декодирует цель редиректа ещё раз (/%5c/evil.example). Эксплуатируемого редиректа не получено, но три проверки из четырёх держались на поведении браузера — закрыто: проверка обеих форм, теста добавлены процент-кодированные входы |
fixed(P1, дерево сессии) | ревью безопасности (исполнением) |
| PD-38 | hardening | info | internal/pgstore/sessions.go, 00005_identity_oauth.sql |
Отозванные сессии не удалялись до абсолютного срока (90 дней); журнал входов каскадно стирался вместе с аккаунтом, хотя объявлен доказательством для расследования — закрыто: свип берёт отозванные и idle-протухшие, login_events.user_id перешёл на on delete set null (строка анонимизируется, не уничтожается) |
fixed(P1, дерево сессии) | ревью безопасности |
| PD-39 | bug | info | internal/money/money.go |
Док обещал округление «от нуля», код округляет к +∞; отрицательные дроби не были покрыты тестом вовсе. Плюс USD() на MinInt64 печатал мусор, а вход не имел ограничения длины (2 МБ → 6.1 с и сообщение об ошибке на 2 МБ) — закрыто: док приведён к коду, отрицательные кейсы запинены, потолок длины 64 символа, рендер без отрицания |
fixed(P1, дерево сессии) | ревью денежного пути · ревью стиля |
| PD-40 | bug | info | internal/ingest/resync.go |
Отсутствующий/null/пустой committed_usd декодировался в 0 — неотличимо от «попытка не стоила ничего»; на пути расчёта это освободило бы холд и не списало ничего — закрыто: Spend стал указателем, пустая строка — ошибка |
fixed(P1, дерево сессии) | ревью «вне карты» |
| PD-41 | bug | info | internal/login/login.go, internal/httpapi/ |
Поверхность /auth/* отвечала stdlib-телами text/plain на 404/405 вопреки нормативу «ответы problem+json»; ошибки стора и сработавший лимитер не логировались; паника писалась без стека; успешный вход не оставлял следа, а недоступность провайдера классифицировалась как «токен отвергнут» — закрыто: метод проверяется в обёртке с problem+json, добавлены login succeeded, sign-in rate limit engaged, provider_unreachable, стек паники, login_start_id для склейки двух половин входа |
fixed(P1, дерево сессии) | ревью логов (исполнением) |
| 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), но заведено строкой, чтобы это было решением, а не сюрпризом — закрыто: денежный контур получил вызывающих: runs.Service.Start берёт холд в ОДНОЙ транзакции с созданием прогона и записью очереди (pgstore.StartRun), реконсилятор закрывает его Settle по фигуре движка. Пины: pgstore.TestAdmittingARunWritesTheRunTheAttemptAndTheHoldTogether (посадка «убрать holdTx из транзакции» падает), TestARunThatCannotBePaidForLeavesNothingBehind, runs.TestARunThatEndsIsFinishedAndSettledAtWhatTheEngineSpent. Живая проба: грант $10 → прогон с потолком 100 глав → холд $3.00 → движок отчитался $0.42 → баланс $9.58 |
fixed(P4, дерево сессии) | ревью «вне карты» |
| 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, шов |
⚠ ПЕРЕ-ДИСПОЗИЦИЯ (эррата №15, 07.08): постановка строки устарела. Она рассуждает про 64 КиБ пайпа, а ратифицированный транспорт (D39.106 п.2) — тейл events.jsonl с курсором: у файла обратного давления в этом смысле нет вовсе, зато появляются свои свойства (fsync-политика, ротация, отставание тейлера, поведение при заполненном диске). Строка живёт, но переформулируется вместе со строкой 103 — не «блокировать движок или ронять события», а «что делает движок, когда журнал не пишется». Обратное давление не спроектировано, и канал тут ни при чём. Пайп держит 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). Воспроизведено против живой БД раунд-трипом 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 — закрыто: литерал null судится ДО раскавычивания и оставляет значение нетронутым; кавычки снимает encoding/json, а не strings.Trim — слово null, пустая строка и экранированная цифра выходят тем, чем являются, и каждое встречает ту же единственную проверку синтаксиса, поэтому отдельной ветки «пусто или null» не нужно вовсе. Пины: money.TestUnmarshalTellsTheNullLiteralFromTheWordNull (обе формы плюс невмешательство в значение) и ingest.TestSpendRefusesNonsense на шве. Обе посадки — «снять кавычки первыми» и «слово null есть ноль» — поймать поимённо |
fixed(P3, дерево сессии) | приёмка P2 (замер оркестратора №15 + панель) |
| PD-80 | vuln | major | internal/login/login.go:158,217-226 |
Вход выключается тремя запросами в секунду, и 429 колбэка ДОБИВАЕТ начатые входы. Ведро rate.NewLimiter(2, 20) одно на /auth/login И /auth/callback (login.go:122, единственный лимитер в зоне), а колбэк стирает login-куку ПЕРВОЙ строкой — до своей проверки лимитера. Следствие: анонимный поток на /auth/login не только закрывает вход всем (это PD-42, принято риском в форме «глобальный, не пер-адресный»), но и делает начатый вход невосстановимым: 429 приходит уже с Set-Cookie: __Host-tm_login=; Max-Age=0, поэтому повтор того же колбэка не пройдёт и после наполнения ведра. Воспроизведено приёмкой на боевом бинаре: 19 из 40 /auth/login прошли, дальше 429; честный колбэк с живым state получил 429 и стёртую куку. Независимо измерено панелью. Фикс дешёвый: лимитер прежде очистки куки + раздельные ведра для начала и конца входа; пер-адресный лимит остаётся вопросом edge (PD-42) — закрыто: два ведра вместо одного (startLimit/finishLimit, те же rate/burst — не делится именно ИСЧЕРПАНИЕ), и проверка лимитера ПЕРЕД ClearLogin. Пины: TestFloodingTheStartOfSignInDoesNotCloseTheEnd (поток на /auth/login не закрывает честный колбэк) и TestARefusedCallbackKeepsTheLoginItRefused (429 не стирает куку, состояние не съедено, повтор после снятия лимита доходит до 303). Посадки «одно ведро» и «очистка выше лимитера» падают |
fixed(P3, дерево сессии) | приёмка P2 (живая проба + панель, две независимые линзы) |
| PD-81 | standards | minor | internal/pgstore/credits.go:169-178 |
Заявленный ErrDuplicateHold на реальном пути недостижим: при ЖИВОЙ резервации повторный Hold падает на первичном ключе reservations_pkey (00007_credits.sql:66) и уходит наверх сырой ошибкой Postgres SQLSTATE 23505; объявленная ошибка приходит только когда строку резервации уже смахнули, а ключ леджера остался. Замерено приёмкой на живом PG в обеих формах. Деньги целы (balance == SUM(ledger), транзакция откатывается), но воркеру не на что смотреть, кроме текста ошибки — закрыто: при ЖИВОЙ резервации коллизия reservations_pkey мапится в ErrDuplicateHold (credits.go holdTx); объявленная ошибка стала достижимой на реальном пути. Пин — TestASecondHoldOnALiveReservationIsADuplicateNotASqlstate (сверяет и то, что отказ не двинул деньги) |
fixed(P4, дерево сессии) | приёмка P2 (замер оркестратора №15 + панель) |
| PD-82 | bug | info | internal/pgstore/credits.go:236-239 |
Hold на НЕСУЩЕСТВУЮЩИЙ аккаунт отдаёт ErrInsufficientCredit (в lockBalance ErrNoRows трактуется как «нет кредита»), а не ErrNoAccount: обещание PD-56 «один ответ на несуществующий аккаунт» покрывает Grant/Adjust/Balance/ReadAccount и на Hold не распространяется. Замерено приёмкой — закрыто: lockBalance при отсутствии строки баланса спрашивает, существует ли аккаунт, и отвечает ErrNoAccount против ErrInsufficientCredit. ⚠ Одним запросом это не выражается: Postgres запрещает FOR UPDATE на nullable-стороне внешнего соединения — проверено, поэтому вторая проверка идёт только на редком пути. Пин — TestMoneyOperationsTellAMissingAccountFromAnEmptyOne |
fixed(P4, дерево сессии) | приёмка P2 (замер оркестратора №15) |
| PD-83 | hardening | minor | internal/httpapi/middleware.go:64 |
Фикс PD-3 не запинен в собственном месте: посадка «Recover логирует r.URL.Path вместо routeOf(r)» батарею ПЕРЕЖИВАЕТ, тогда как та же посадка в AccessLog ловится поимённо (TestAccessLogNamesTheRouteNotThePath). По правилу шапки этого файла половина PD-3 закрытой не считается — закрыто: TestPanicBecomesAProblemAndNamesTheRoute — паника за мультиплексором с {book} в паттерне; сверяется и route, и отсутствие идентификатора книги во ВСЕЙ строке (в ней же стек). Посадка r.URL.Path падает |
fixed(P3, дерево сессии) | приёмка P2 (посадка мутации) |
| PD-84 | hardening | minor | internal/login/login.go:222 |
Лимитер колбэка (фикс PD-29) не запинен: удаление всей проверки h.limiter.Allow() из callback оставляет батарею зелёной. Замер PD-29 (~880 строк/с с одного хоста) означает, что регрессия здесь тихо возвращает неаутентифицированного писателя в таблицу журнала — закрыто: TestBothLegsOfSignInAreRateLimited — десять колбэков подряд обязаны упереться в 429. Посадка «удалить проверку целиком» падает; её же ловит TestARefusedCallbackKeepsTheLoginItRefused |
fixed(P3, дерево сессии) | приёмка P2 (посадка мутации) |
| PD-85 | hardening | minor | internal/pgstore/identity.go:126-131 |
«Неподтверждённый адрес не поднимается на аккаунт» запинено только на ветке НОВОЙ личности: снятие условия in.EmailVerified в ветке ВОЗВРАЩАЮЩЕГОСЯ входа (обновление users.email) проходит батарею — TestUnverifiedAddressStaysOffTheAccount покрывает первый вход и переход в verified, но не обратный случай — закрыто: TestUnverifiedAddressStaysOffTheAccount продлён третьим шагом — ВОЗВРАЩАЮЩИЙСЯ вход с новым НЕподтверждённым адресом: users.email не двигается, identities.email записывает то, что пришло. Посадка «снять in.EmailVerified в ветке возвращающегося» падает |
fixed(P3, дерево сессии) | приёмка P2 (посадка мутации) |
| PD-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 нет) — закрыто: каталог /srv/textmachine создаётся явной командой наброска (--system домашний каталог не создаёт), префикс - намеренно НЕ ставится (сервис без записываемого каталога обязан падать на старте, а не на первой записи через часы), ProtectHome=yes оставлен с названной ценой и двухстрочным выходом (ProtectHome=tmpfs + BindPaths=), корень библиотеки на СЕРВЕРЕ — /srv/textmachine, ~/books объявлено конвенцией машины разработки. Проверено живым прогоном systemd-run --user (systemd 259), а не докой: несуществующий путь без - → 226/NAMESPACE, созданный → 0/SUCCESS, с - → 0/SUCCESS (строка игнорируется); ProtectHome=yes → Permission denied на /home/<user>; tmpfs+BindPaths → каталог виден |
fixed(P3, дерево сессии) | приёмка P2 (панель, сверено с докой) |
| PD-92 | bug | info | internal/ingest/supervisor.go:118-121 |
Дренаж стоит ДО cmd.Wait(), поэтому WaitDelay его не размораживает: io.Copy(io.Discard, stdout) ждёт EOF, а EOF придёт только когда закроются ВСЕ копии пишущего конца пайпа; внук, унаследовавший stdout и игнорирующий SIGINT, вешает Run навсегда — backstop WaitDelay действует внутри Wait, до которого управление не доходит. ⚠ Сегодня недостижимо и это пере-проверено приёмкой: в backend/ вне тестов нет ни одного exec.Command и нет cgo. ⚠ Пере-диспозиция (эррата №15): вес понижен до info — весь пайп-путь supervisor.go объявлен ДЕВ-РЕЖИМОМ (D39.106 п.3), в проде родителя у движка нет и cmd.StdoutPipe() не существует; чинить только если дев-путь остаётся |
open | приёмка P2 (панель, граница зоны пере-проверена) |
| PD-93 | bug | info | internal/ingest/supervisor.go:109 |
Фикс PD-77 («наша остановка — не сломанный синк») сверяет только context.Canceled и пропускает context.DeadlineExceeded: как только у runCtx появится дедлайн (потолок времени прогона — очевидная будущая ручка), штатное истечение снова поднимет ERROR «stream could not be materialized» |
open | приёмка P2 (панель) |
| PD-94 | bug | info | internal/httpapi/middleware.go:61-70 |
Recover глотает http.ErrAbortHandler — sentinel, которым хендлер намеренно обрывает соединение (net/http его не логирует и рвёт коннект). Замерено приёмкой: паника ErrAbortHandler превращается в 500 с problem-телом, то есть усечённый поток становится неотличим от полного. Латентно (сегодня им никто не паникует), но именно SSE-хендлер — типовой его пользователь |
open | приёмка P2 (замер оркестратора №15) |
| PD-95 | doc | major (для промта эмиттера) | internal/ingest/events.go:6-12, docs/platform-PROGRESS.md §«Транспорт потока событий» |
Транспортная история зоны устарела против D39.106 в ДВУХ местах, и обе версии не совпадают с ратифицированной формой. Ратифицировано (D39.106 п.2 + research/25 §Форма): движок — транзиентный systemd-юнит на прогон, платформа ему НЕ родитель; события — events.jsonl в каталоге книги, append-only, как outbox-проекция уже закоммиченных строк SQLite (та же транзакция, что чекпойнт); платформа тейлит журнал, курсор (engine_run_id, seq) коммитится в одной Postgres-транзакции с эффектом. В research/25 вариант «платформа — родитель + пайп (stdout/fd)» получил 0 голосов из 15 («время жизни движка — подмножество платформы: деплой/рестарт убивает или осиротляет прогон»), выделенный fd 3 — 0, stdout/journald как источник событий — 0. Что в зоне: (а) доккоммент events.go предлагает переезд на fd/сокет — отклонённая форма; (б) журнал зоны длинно доказывает «канал остаётся stdout» и объявляет переезд отклонённым, ссылаясь на PD-59, который сам superseded тем же D39.106 п.3 («PD-59 superseded; пайп-путь supervisor.go P1 = дев-режим; ответ PD-13 переезжает на cgroup юнита прогона»). Оба текста прочтёт эмиттер-сессия как задание. ⚠ Приёмка №15 это пропустила и в первой редакции строки сама сослалась на снятый PD-59 — исправлено здесь же — закрыто: доккоммент пакета переписан под D39.106 §2 — транзиентный systemd-юнит на прогон, платформа НЕ родитель, events.jsonl в каталоге книги как outbox-проекция коммитов SQLite, тейл с курсором (engine_run_id, seq), повторное чтение строк — норма (PD-105). Отвергнутые формы перечислены со счётом голосов, чтобы не вернулись свежей идеей. Доккоммент Supervisor помечен ДЕВ-РЕЖИМОМ там же, где он описывает пайп. ⚠ В журнале зоны нашлась ВТОРАЯ копия снятого ответа — блок «ОТВЕЧЕНО приёмкой (PD-59)» в списке «Открытые вопросы после P1» п.4: эррата №15 пере-ставила другую секцию, эту не тронула. Текст оркестратора не переписан — над ним поставлен баннер SUPERSEDED с ратифицированной формой; проверить принадлежность правки — за приёмкой |
fixed(P3, дерево сессии) | приёмка P2 (эррата оркестратора №15, 07.08) |
| PD-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 — закрыто: releaseHold судит флаг applied; потраченный ключ релиза = ErrReleaseKeySpent и ОТКАТ транзакции, поэтому резервация остаётся ОТКРЫТОЙ и видимой оператору вместо тихого закрытия без возврата денег. Пин — TestAReleaseWhoseKeyWasSpentIsRefusedRatherThanSilent |
fixed(P4, дерево сессии) | приёмка 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 — закрыто: INFO-строка старта несёт имя команды и НЕ несёт argv (runner.Start, а также дев-путь ingest/supervisor.go), поэтому ни id книги, ни потолок в долларах в поток INFO не попадают. Пин — runner.TestTheStartLineNamesTheCommandAndNotItsArguments (посадка «вернуть "args"» падает). Живая проба на боевом бинаре: grep -c 'ceiling-usd|3.000000|bk_' daemon.log = 0 за полный прогон |
fixed(P4, дерево сессии) | приёмка P2 (панель) |
| PD-100 | bug | minor | internal/login/login.go:245-261 |
Класс PD-5 закрыт в auth/, но не в login/: колбэк глотает ошибку стора (TakeLoginState) и ошибку discovery, репортя их как обычный отказ (unknown_state / discovery_failed) — сама ошибка не доезжает ни до одной строки лога, хотя pgstore/identity.go намеренно отличает «состояния нет» от инфраструктурного сбоя. Аутентификационный DB-outage снова выглядит штормом обычных отказов — закрыто: сбой стора и сбой discovery уходят в ERROR; на проводе и в журнале — прежний отказ. login.ErrNoState заведён у владельца интерфейса (как auth.ErrNoSession), pgstore.ErrNoLoginState — то же значение под прежним именем. Пин TestInfrastructureFailuresInTheCallbackAreLogged: три случая, включая «обычное истечение НЕ логируется как авария» — ловит и посадку «логировать всегда» |
fixed(P3, дерево сессии) | приёмка P2 (панель) |
| PD-101 | bug | minor | internal/login/login.go:507 |
login_events.ip_prefix берётся из r.RemoteAddr, а в задуманном деплое перед сервисом стоит edge-прокси ⇒ префикс всегда сеть прокси. Журнал входов заведён как ответ на «откуда примерно я входил» — в шипуемой форме он систематически отвечает неверно. X-Forwarded-For/Forwarded нигде не читаются и доверенного прокси в конфиге нет (это правильный дефолт: доверять заголовку без edge нельзя) — значит решение про edge и про этот столбец принимается вместе |
open | приёмка P2 (панель) |
| PD-102 | doc | minor | internal/httpapi/serve.go:36-38 |
Доккоммент DefaultTimeouts утверждает, что «an upload extends its own deadline as it makes progress» — это НЕВЕРНО: ReadTimeout в net/http (Go 1.26.5, server.go:990 wholeReqDeadline = t0.Add(ReadTimeout)) выставляется один раз и по мере прихода байтов не продлевается. Комментарий несущий: он объясняет, почему Read короткий, и на нём будущая ручка загрузки книги (23 МБ по контракту) построит неверное ожидание — ей понадобится собственный дедлайн через ResponseController, а не «прогресс продлевает» |
open | приёмка P2 (панель, сверено с исходником Go) |
| PD-103 | hardening | minor | internal/auth/middleware.go:43,66 |
У обращений к БД на аутентифицированном пути (Lookup/Touch) нет собственного дедлайна — только голый r.Context(), а WriteTimeout у сервера отсутствует по проекту (SSE) и TimeoutHandler в цепочке нет. Зависший Postgres паркует хендлеры и ждущих в пуле, пока клиент сам не уйдёт. readyz свой таймаут получил (PD-14) — горячий путь нет |
open | приёмка P2 (панель) |
| PD-104 | bug | minor, расхождение док↔код | internal/login/login.go:285-288, internal/config/config.go:73 |
Фри-тир начисляется АВТОМАТИЧЕСКИ, а реестр обещает обратное. Код: дефолт SignupGrantMicroUSD: 5 * 1_000_000 (config.go:73) проведён в демона (main.go:99) и логин отдаёт его в стор на каждой новой подтверждённой паре (provider, subject) — аккаунт создаётся С $5. Строка PD-30 при закрытии утверждает «аккаунт создаётся с нулём, начисление руками из админки» — один из двух текстов лжёт, и это чинится независимо от продуктового решения. Ограничитель у автогранта один — лимитер входа; агрегатного потолка, счётчика и алерта нет (грепнуто). Разбор нормы и предложение «на бете дефолт в НОЛЬ» — D39.110 п.3, здесь не дублируется; ждёт слова владельца — ПОЛОВИНА ЗАКРЫТА (док↔код): ячейка PD-30 исправлена — «аккаунт с нулём» относилось только к НЕподтверждённой личности, подтверждённая получает автогрант (дефолт $5). ⚠ Продуктовая часть (ноль на бете, агрегатный потолок, счётчик) — НЕ закрыта: ждёт слова владельца, носитель прежний |
open | приёмка P2 (панель; расхождение — оркестратор №15) |
| PD-105 | standards | major (для промта эмиттера) | internal/ingest/decoder.go:96 |
Декодер и норматив зоны расходятся на дубле seq: декодер объявляет его фатальным ErrStreamGap, а ENGINEERING_STANDARDS §2 ратифицирует «at-least-once — норма, дубль — не ошибка». После фикса PD-12 цена выросла: сбой ингеста ОСТАНАВЛИВАЕТ прогон, поэтому одна задублированная строка убивает платный прогон, хотя ратифицированный путь ремонта — status --json. Внутри одного пайпа передоставки нет, так что отказ декодера защитим; непропорциональна РЕАКЦИЯ. Разрешать ратификацией вместе с промтом эмиттера (строка 103), не молча. ⚠ Пере-диспозиция (эррата №15): вес ПОВЫШЕН до major-для-эмиттера — при ратифицированном транспорте (тейл events.jsonl с курсором, D39.106) повторное чтение строк после краша читателя — НОРМА, а не аномалия пайпа, поэтому норматив «at-least-once, дубль не ошибка» буквально верен, и фатальный отказ декодера прямо ему противоречит — ЗАКРЫТО РАТИФИКАЦИЕЙ (D39.119) И РЕАЛИЗАЦИЕЙ. Транспорт — тейл events.jsonl с курсором, поэтому повторное чтение строк НОРМА: ingest.Tail пропускает seq <= last_seq идемпотентно и не возвращает ошибку, а pgstore.RunSink.Apply пере-проверяет тот же high-water mark ВНУТРИ транзакции эффекта. Тот же seq с ДРУГИМ payload = ErrPayloadConflict → карантин ПОПЫТКИ, то есть её проекции: материализация останавливается, а жизненный цикл прогона продолжается — движок тратит зарезервированные деньги, и наша неспособность прочитать журнал не повод их выбросить (pgstore.Quarantine пишет только run_attempts.quarantine_reason, свежесть переходит на ре-синк). Сверка по sha256 строки (run_attempts.last_line_sha256). Пропасть (seq > last+1) осталась ошибкой — строки потеряны, читать дальше нечего. Пины: ingest.TestARedeliveredLineIsNormalAndChangesNothing · TestTheSameSeqWithADifferentPayloadIsRefused · TestALostLineIsReportedRatherThanSkipped · pgstore.TestARedeliveredCountingEventDoesNotCountTwice (⚠ последний написан ПОСЛЕ того, как посадка пережила первую версию пина: прогресс — присваивание и потому идемпотентен сам по себе, считающий эффект — unit_done — нет). Фатальный ErrStreamGap на дубле в decoder.go остаётся только на ДЕВ-пути пайпа, где передоставки нет |
fixed(P4, дерево сессии) | приёмка P2 (панель) |
| PD-106 | standards | minor | cmd/tmplatformctl/ |
Админ-CLI — единственный писатель денег в дереве — не имеет ни одного теста. В том числе не покрыто правило, которое он сам называет несущим («после коммита команда не может отчитаться провалом», фикс PD-75), и разбор флагов, и формат вывода. Батарея зоны его не видит вовсе ([no test files]) — закрыто: cmd/tmplatformctl/main_test.go — восемь тестов. Несущее правило («после коммита ничего не отчитывается провалом») пинится через balanceReader — интерфейс с одним методом, заведён ровно затем, что правило нельзя проверить на сторе, который всегда работает. Плюс: спент-ключ → «no-op», сбой ДО коммита → ошибка и ни строки вывода, ключ без --key уникален на 100 прогонах, разбор флагов десятью случаями и сквозной прогон пяти команд по живой БД. ⚠ Первая редакция теста флагов сверяла лишь «ошибка непуста» — две посадки её ПЕРЕЖИЛИ (команда падала на соединении, а не на аргументах); тест переписан на сверку сообщения |
fixed(P3, дерево сессии) | приёмка P2 (панель) |
| PD-107 | hardening | info | internal/pgstore/migrations/00007_credits.sql:85, 00002_readmodel.sql:13 |
Удаление аккаунта обходит защиту PD-25: составной FK reservations → books(id, owner_id) on delete restrict блокирует DeleteBook, но users каскадит в reservations НАПРЯМУЮ, поэтому delete from users уносит и ОТКРЫТУЮ резервацию. Замерено приёмкой: аккаунт с открытым холдом удаляется. Учётной дыры нет — леджер и кэш баланса каскадятся тем же удалением, — но прогон, идущий против этого холда, останется без того, кто его закроет. Кода удаления аккаунта в дереве нет вовсе (грепнуто) ⇒ строка = гейт перед появлением такой операции (и перед ASVS 7.4.2 в полной форме). ⚠ Заодно ОПРОВЕРГНУТА обратная версия этой находки от панели («удаление падает на композитном FK даже при закрытых резервациях») — мой прогон: удаляется и с закрытой резервацией, и без неё |
open | приёмка P2 (замер оркестратора №15; версия панели опровергнута) |
| PD-108 | bug | major | internal/ingest/supervisor.go:161 (было), internal/runner/engine.go |
Ратифицированный канал ремонта не мог работать НИ РАЗУ: tmctl status --json звался БЕЗ обязательного --config. Движок требует его на каждой команде, которая трогает книгу, и падает на разборе аргументов до того, как увидит книгу. Найдено чтением cmd/tmctl/invocation.go (не доки) и воспроизведено исполнением на бинаре, собранном из HEAD в скрэтчпаде: tmctl status --json → tmctl: --config book.yaml is required, exit 1; с --config <путь> доходит до чтения файла. Дефект латентный ровно потому, что вызывающих у канала не было (PD-43) — то есть первый же резюнк воркера получил бы отказ вместо отчёта — закрыто: прод-путь runner.StatusArgs/runner.Status всегда несёт --config <workdir>/book.yaml; дев-путь Supervisor.Status исправлен там же. Пин — runner.TestEveryEngineInvocationNamesTheBookConfig |
fixed(P4, дерево сессии) | сессия P4 (ревью вне карты: чтение парсера движка + проба на HEAD-бинаре) |
| PD-109 | bug | minor | internal/runs/reconcile.go |
Периодический резюнк ЗАТИРАЛ более точную проекцию потока своей грубой. status --json не делит стадии по волнам (строка 99), поэтому его агрегат, положенный поверх «draft 7/20 ∥ edit 1/20», заменял пофазные счётчики одним числом — а отчёт движка, который ещё не досчитал, заменял их НУЛЯМИ. Плюс вторая половина: сторож «поток уже говорил» читал LastSeq из СНИМКА свипа, взятого ДО тейла, поэтому прогон, чьи первые события пришли в этом же свипе, выглядел молчащим. Найдено живой пробой сквозного прогона, не тестом: карточка книги показала edit 10/10 через секунды после того, как журнал сказал edit 0/10 — закрыто: резюнк работает только там, где чинить нечего (курсор не двигался ЛИБО материализация в карантине), сторож судит курсор ПОСЛЕ тейла, и ApplyStatus не опускает счётчики (greatest). Пины — runs.TestALiveRunIsResyncedAtMostOncePerInterval и TestTheSweepMaterializesWhateverTheJournalHasGained |
fixed(P4, дерево сессии) | сессия P4 (живая проба сквозного прогона) |
| PD-110 | bug | minor | internal/ingest/tail.go |
Строки ДРУГОЙ попытки судились против НАШЕГО курсора. Журнал пер-книжный и append-only, значит резюм дописывает второй hello со своим engine_run_id и seq, начинающимся заново; строка при этом не несёт идентификатора потока — его говорит только последний хендшейк выше. Первая редакция тейлера этого не отслеживала, поэтому seq 2 предыдущей попытки встречался с нашим seq 2 и читался как ИЗМЕНЁННЫЙ payload, то есть как сигнал порчи: здоровый резюмнутый прогон отправлял сам себя в карантин. Найдено собственным тестом до всякой интеграции — закрыто: читатель ведёт область (mine), и до хендшейка, который он ПРИЗНАЛ своим, ничего не материализуется и ничего не судится. Пины — TestAnotherAttemptsStreamInTheSameJournalIsSkipped · TestARereadFromTheStartDoesNotMistakeAnotherAttemptForCorruption · TestEventsBeforeAnyHandshakeAreNotJudgedAgainstOurCursor (обе посадки — mine := true и mine := false — падают) |
fixed(P4, дерево сессии) | сессия P4 (собственный тест) |
| PD-111 | bug | minor | internal/ingest/tail.go |
seq хендшейка не персистился, поэтому ЛЮБОЙ резюм после него читался как пропасть. hello — это seq 1 потока, но первая редакция обрабатывала его отдельно и курсор не двигала: last_seq оставался нулём при уже сдвинутом байтовом хинте, и следующая же строка (seq 2) давала seq 2 after 0 → карантин на ровном месте — закрыто: хендшейк проходит те же правила, что любая строка, и двигает курсор; эффекта на read-model у него нет, эффект на курсор и есть смысл. Пин — TestAHalfWrittenLineIsLeftForNextTime (сверяет применённые seq 1,2 и продолжение 3,4 после дозаписи) |
fixed(P4, дерево сессии) | сессия P4 (собственный тест) |
| PD-112 | standards | minor | internal/httpapi/v0.go, контракт 0.2.0 |
Реализация отдаёт статусы, которых спека у операций НЕ перечисляет — четыре класса, все проверены исполнением адверсариальным ревью: 503 на старте прогона (деплой не может передать потолок/записать конец юнита) · 403 от CSRF-слоя на любом небезопасном запросе (спека описывает требование X-TM-Client в securitySchemes, но статуса ему не даёт) · 500 у любой операции при отказе стора (спека не перечисляет 5xx нигде) · 404 у GET /usage при отсутствующем аккаунте (спека даёт только 200/401; практически недостижимо — сессия ссылается на строку users внешним ключом). Ниже — исходная постановка по 503. Отказ 503 на старте прогона НЕ входит в перечисленные спекой статусы операции (400/401/404/409). Он поставлен осознанно: когда деплой не может передать движку потолок (строка 145) или записать конец юнита, запрос ВАЛИДЕН, объект существует и состояния конфликта нет — то есть каждый из разрешённых кодов сообщил бы неправду. Правка спеки — не право зоны (правило промта: расхождение = вопрос оркестратору). Строка ждала решения владельца контракта — закрыто РАТИФИКАЦИЕЙ (оркестратор №15, 09.08): 503 вносится в спеку правкой владельца контракта при лендинге; код зоны не меняется |
fixed(ратификация 09.08; правка спеки за оркестратором) | сессия P4 (самопроверка против спеки) |
| PD-113 | bug | major (контрактно видимый) | internal/runs/reconcile.go outcome, движок stagerun.go |
Стоп по потолку сегодня НЕразличим от инфраструктурного отказа, и контракт при этом запрещает называть его failed. Движок возвращает потолок ошибкой (errReserveCeiling, сверено в HEAD), а exitCode мапит всё нераспознанное в 1 — значит по коду выхода «деньги кончились» и «упало» это одно и то же число; события потолка не существует (строка 103). Платформа честно ставит failed, хотя BookStatus требует paused и «никогда не failed», потому что стоп резюмируем. Единственный путь, которым платформа СЕГОДНЯ узнаёт о потолке, — событие потока, которого нет; ветка под него построена и запинена (TestACeilingHaltPausesTheRunWithItsReason, TestWhatTheUnitDidBecomesTheProductStatus, случай «a ceiling halt survives any exit»). Закрывается приходом эмиттера (строка 103); ⚠ до тех пор экран покажет «ошибка» там, где верно «остановлено: лимиты» |
open | сессия P4 (сверка контракта с кодом движка) |
| PD-114 | standards | minor | internal/config/, deploy/ |
Конфигурация: 27 переменных окружения, НИ ОДНОГО флага у демона и никакой печати эффективной конфигурации при старте (грепнуто). Выбор «только окружение» сам по себе мейнстрим (12-factor) и записан решением в доккомменте config.go; деплой использует systemd-нативные EnvironmentFile=+LoadCredential=. Ниже нормы другое: у оператора нет ни -version, ни «проверить конфиг и выйти», ни строки «вот что реально применилось» — не подхватившийся EnvironmentFile обнаруживается по поведению, а плоское пространство из 27 имён это та точка, где обычно переходят на файл. ⚠ Вопрос ВЛАДЕЛЬЦА (08.08), и он же нашёл этим вопросом реальный дефект в этом паке: дефолт «$/глава» лежал в двух местах (config клал ноль, разрешал вызыватель) — исправлено, дефолт резолвится только в config. Ратифицировано 09.08 (оркестратор №15 по делегации владельца): «только окружение» ОСТАЁТСЯ; строка переформулирована в задачу — печать ЭФФЕКТИВНОЙ конфигурации при старте с редакцией секретов (значение каждой настройки и откуда оно взялось: дефолт · переменная · файл; *_FILE печатается фактом наличия, не содержимым). Остаётся открытой до постройки |
open (переформулирована ратификацией 09.08) | вопрос владельца 08.08 + сессия P4 |
| PD-115 | standards | minor | docs/ENGINEERING_STANDARDS.md §2 |
Внешняя версионированная базовая линия объявлена ровно для ОДНОЙ оси — безопасности (ASVS 5.0 L2 + OWASP API Top-10 2023, с указанием глав). Отказоустойчивость, наблюдаемость и контракт-первичность описаны собственной прозой зоны без внешнего эталона, а конфигурация, релиз/откат, ёмкость и восстановление не описаны вовсе. Разница не теоретическая: PD-57 и PD-58 нашлись ИМЕННО сверкой кода с RFC 9700/9207 и NIST SP 800-63B — механизм работает там, где эталон есть, и не может сработать там, где его нет. Грепнуто на 08.08: метрик и трейсинга ноль (ни prometheus, ни otel, ни expvar, ни pprof), процедуры бэкапа/восстановления в deploy/README.md нет, SLO не заданы. Предложение зоны: §2 получает по эталону на ось (наблюдаемость, ops, конфигурация) — направление РАТИФИЦИРОВАНО 09.08 (оркестратор №15 по делегации владельца); носитель работы — эта строка, исполнение — своими паками |
open (направление ратифицировано 09.08) | абстрактный вопрос владельца 08.08 + сессия P4 |
| PD-116 | bug | minor | internal/pgstore/runs.go RecordSpawn, internal/runs/spawn.go |
Спавн попытки мог прийти ОДНОВРЕМЕННО из воркера очереди и из реконсилятора, и оба видели «не запущено». Воркер получает прогон заданием, реконсилятор находит его неспавненным на своём проходе — обе ветки законны и обе читали unit_name до записи. Дальше их спасала только уникальность ИМЕНИ юнита у systemd: второй systemd-run падал с «unit already exists». Выживание по чужому правилу — не корректность, и оно перестаёт работать в день, когда именование поменяется (например, резюм получит суффикс). Найдено собственным ревью кода на конкурентность, до отчёта — закрыто: RecordSpawn стал compare-and-set (where id = $1 and unit_name is null) и возвращает, досталось ли право; проигравший НЕ стартует и это не ошибка. Пин — runs.TestOnlyOneOfTwoConcurrentSpawnersStartsTheEngine (восемь конкурентных спавнеров, ровно один юнит); посадка «убрать and unit_name is null» падает |
fixed(P4, дерево сессии) | сессия P4 (самопроверка на гонки) |
| PD-117 | bug | minor | internal/httpapi/v0.go startRun |
Потолок ниже минимума схемы отвечал 409, а не 400. RunRequest.ceiling_chapters объявлен minimum: 1, и запрос с 0 или отрицательным — МАЛФОРМИРОВАННЫЙ; 409 же определён как «границы сдвинулись между чтением run-options и этим вызовом», поэтому клиент, получивший его, пере-читает run-options и повторяет запрос, который не может пройти НИКОГДА. Замерено ревью: {"ceiling_chapters":0} → 202 у хендлера и 409 после сервиса — закрыто: минимум схемы судится в хендлере, до сервиса. Пин — TestACeilingBelowTheSchemaMinimumIsARejectedRequestAndNotAMovedBound |
fixed(P4, дерево сессии) | адверсариальное ревью (сверка со спекой, исполнением) |
| PD-118 | bug | minor | internal/pgstore/books.go ReadUsage, runs.go PauseRun |
Usage.paused_reason был НЕДОСТИЖИМ через собственный путь паузы платформы. ReadUsage требовал finished_at is null, а PauseRun — путь реконсилятора — ставит finished_at тем же запросом, что и паузу. Значит поле заполнялось только когда стоп пришёл событием потока (sink.go, finished_at не трогает) и молчало, когда паузу вызвала платформа. Замерено ревью на живом PG — закрыто: состояние читается по ПОСЛЕДНЕМУ прогону каждой книги (lateral), без условия на finished_at. Пин — TestTheAccountReportsAPauseTheReconcilerCaused |
fixed(P4, дерево сессии) | адверсариальное ревью (исполнением) |
| PD-119 | bug | minor | internal/httpapi/v0.go getBook |
Карточка книги несла ревизию ПРОГОНА, которая отстаёт от книжной. Контракт: счётчик ОДИН на книгу и «каждое книго-скоупное чтение и id каждого кадра потока несут одно и то же число». unit_done двигает books.revision и chapters.revision, но не runs.revision, поэтому клиент, применивший кадр id=2, получал в карточке 0 и — по правилу самого контракта — обязан был чтение ОТБРОСИТЬ: карточка не обновлялась всю серию unit-done. Замерено ревью через настоящий RunSink (0→1→2 у книги при 0 у прогона) — закрыто: и BookDetail.revision, и Run.revision проецируются из счётчика КНИГИ. Пин — TestTheCardsRevisionIsTheBooksAndNotTheRuns |
fixed(P4, дерево сессии) | адверсариальное ревью (исполнением) |
| PD-120 | vuln | minor | internal/pgstore/books.go курсор пагинации |
Курсор из ЧУЖОЙ библиотеки принимался молча. Контракт прямо возлагает отказ на СЕРВЕР («rejecting a cursor from a dead epoch is the SERVER's duty, MUST, answered 400»), потому что клиенту токен непрозрачен по построению. Курсор нёс только (added_at, id) и не нёс метки коллекции, поэтому токен, построенный на библиотеке другого аккаунта, отдавал окно СВОИХ книг вызывающего вместо 400. Замерено ревью на живом PG (чужой курсор → err=nil, 3 строки). Утечки чужих данных нет — выборка всегда owner_id = $1, — но клиент получает не то окно и обнаружить это не может — закрыто: курсор несёт метку области (sha256("library"+owner), первые 8 байт), чужая метка = ErrBadCursor → 400. Пин — TestACursorFromAnotherLibraryIsRefused (плюс проверка, что свой курсор по-прежнему работает) |
fixed(P4, дерево сессии) | адверсариальное ревью (исполнением) |
| PD-121 | bug | minor | internal/httpapi/v0.go usageState |
/usage говорил «exhausted» там, где прогон стартует. Доля округляется ВНИЗ, поэтому $9 остатка от гранта $1000 дают 0%, а состояние выводилось из доли: экран аккаунта показывал «ничего не осталось», пока run-options на той же секунде отдавал шкалу в 300 глав и прогон запускался. Замерено ревью — закрыто: «exhausted» — факт о балансе (Usage.Spendable), а не следствие округления; оба экрана отвечают из одного факта. Пины — TestASmallRemainderIsLowAndNotExhausted, pgstore.TestASmallRemainderOfALargeGrantIsStillSpendable |
fixed(P4, дерево сессии) | адверсариальное ревью (исполнением) |
| PD-122 | bug | info | internal/pgstore/books.go ListBooks |
Library.revision НЕ монотонна: она выведена как max(books.revision) по книгам аккаунта и падает, когда удаляется книга, державшая максимум. Контракт требует монотонности внутри области и предписывает клиенту ОТБРАСЫВАТЬ чтение с меньшей ревизией — то есть после такого удаления библиотека замирает, пока чей-нибудь книжный счётчик не перерастёт старый максимум. Замерено ревью: 10 → 0 после удаления книги. ⚠ Сегодня недостижимо через API: ручки удаления книги нет вовсе (DeleteBook есть в сторе, маршрута нет). Правильное решение — собственный счётчик области у аккаунта, который двигается на изменение состава и статусов. ⚠ Уточнено ревью доков 09.08: КОЛОНКА уже есть — users.library_revision из 00001_identity.sql:13, и её не читает и не пишет ни один Go-путь (грепнуто), так что нужна не миграция, а пути записи и чтения; правка нескольких мест, поэтому она НЕ сделана в этом паке, а названа. ⚠ Дополнено дофиксом 09.08: ревизия не двигается и на ДОБАВЛЕНИИ книги — AddBook пишет новой книге revision = 0 и счётчика области не трогает, а спека описывает ревизию библиотеки как «membership and statuses». Класс тот же и решение то же: собственный счётчик области. Гейт: закрыть ДО появления удаления книги или любого второго писателя состава |
open | адверсариальное ревью (исполнением) |
| PD-123 | doc | info | internal/pgstore/migrations/00009_runner.sql:11 |
runs.ceiling_chapters имеет default 0, а контракт объявляет Run.ceiling_chapters minimum: 1. Сегодня недостижимо: единственный путь вставки — StartRun, и он отказывает на неположительном значении. Строка заведена как гейт: строка прогона, записанная мимо StartRun (миграция данных, правка оператором), спроецируется на провод нулём, которого схема клиента не допускает |
open | адверсариальное ревью (чтение схемы) |
| PD-124 | bug | major, деньги | internal/runs/reconcile.go расчёт |
Расчёт брал ПОЖИЗНЕННУЮ трату КНИГИ и выставлял её как трату прогона. committed_usd из status --json движок считает как SELECT COALESCE(SUM(committed_usd),0) FROM spend WHERE book_id = ? (backend/internal/store/ledger.go в HEAD, «for a book across all days») — сумма по книге за всю историю. Значит каждый следующий прогон книги оплачивал заново всё, что она стоила раньше; перерасход ограничен холдом (Settle каппит), и в леджере он выглядит строкой «capped at the hold», то есть как перерасход ДВИЖКА, а не как арифметика платформы. Замерено двумя независимыми верификаторами на живом PG: прогоны по $1.00 и $0.50 списали $2.50; после того как пожизненная сумма книги перерастает потолок, каждый прогон стоит ровно свой потолок независимо от работы — закрыто: попытка записывает БАЗОВУЮ ЛИНИЮ книги перед стартом (spend_baseline_micro_usd, миграция 00010, читается status --json ДО создания юнита) и платит РАЗНИЦУ; базовая линия не прочиталась = попытка не стартует (платный прогон, который нельзя корректно выставить, хуже прогона, стартующего свипом позже). Пин — runs.TestASecondRunOnABookIsChargedOnlyForWhatItSpent |
fixed(P4, дерево сессии) | адверсариальное ревью ×2, независимо, исполнением |
| PD-125 | bug | major, деньги | internal/runs/reconcile.go restart |
Перезапуск при недоступном расчёте открывал ВТОРОЙ холд и терял первый навсегда. settle законно ОТКЛАДЫВАЕТ (движка не спросить) и возвращает nil; restart читал это как успех, брал новый холд на остаток и уходил дальше, а старая резервация оставалась открытой — и не попадала ни в один список: UnsettledRuns фильтровал по ЗАВЕРШЁННОСТИ ПРОГОНА, а ListLiveRuns берёт только попытку с ended_at is null. Замерено: прогон с потолком $3.00 показал $6.00 зарезервированных и закончил с $3.00, навсегда снятыми с баланса, при нуле в списке несведённых — закрыто: перезапуск СПРАШИВАЕТ (AttemptReservationOpen) и откладывается, пока предыдущая попытка не сведена; UnsettledRuns теперь ключуется на ЗАВЕРШЁННОСТИ ПОПЫТКИ, поэтому брошенная резервация видна и при живом прогоне. Пины — TestARestartIsDeferredWhileTheInterruptedAttemptIsUnsettled, TestAnInterruptedAttemptsHoldIsStillFoundWhileItsRunGoesOn |
fixed(P4, дерево сессии) | адверсариальное ревью ×2, независимо, исполнением |
| PD-126 | bug | major, деньги | internal/runs/spawn.go |
Юнит, который НЕ удалось создать, съедал бюджет прогона по свипу за раз. Право на спавн записывалось до Runner.Start, и при отказе systemd-run оставалась запись «юнит есть» без юнита и без маркера — то есть в точности форма прерванного прогона. Каждый свип перезапускал прогон: расчёт, новый холд, отказ спавна, снова. Замерено: шесть свипов — попытка 7 и $0.60 списано за движок, который ни разу не стартовал; при SweepEvery=15s весь потолок уходит за минуты — закрыто: неудавшийся Start СНИМАЕТ право (ReleaseSpawnClaim), и следующий свип повторяет ту же попытку вместо перезапуска прогона. Пин — TestAUnitThatCannotBeCreatedDoesNotEatTheRunsBudget (шесть свипов: попытка остаётся первой, баланс не двигается) |
fixed(P4, дерево сессии) | адверсариальное ревью (исполнением) |
| PD-127 | bug | major | internal/runs/reconcile.go drainJournal |
Одна нечитаемая строка журнала запирала прогон навсегда. Тейл шёл ДО чтения маркера и возвращал ошибку из всей сверки, а карантинились только пропасть и конфликт payload; малформированная строка, строка длиннее буфера, подменённый файл и битый хендшейк возвращали жёсткую ошибку каждый свип. Замерено: пять свипов — статус translating, finished_at пуст, холд $3.00 держится, при том что маркер на диске и движок давно вышел — закрыто: ЛЮБАЯ неустранимая ошибка журнала = карантин ПРОЕКЦИИ, а жизненный цикл (маркер, живость, расчёт) продолжается; отмена контекста карантином не считается. Пин — TestAnUnreadableJournalDoesNotStopTheRunFromFinishing |
fixed(P4, дерево сессии) | адверсариальное ревью (исполнением) |
| PD-128 | bug | minor | internal/runs/reconcile.go грация спавна |
Грация мерилась от старта ПРОГОНА, а не попытки, поэтому у перезапущенной попытки её не было вовсе: она наследует started_at многочасовой давности и признаётся потерянной, как только systemd не успел ответить. Замерено: через секунду после перезапуска — попытка 3 и три созданных юнита — закрыто: run_attempts.started_at читается отдельным полем и грация мерится от него. Пин — TestAnAdmittedRunIsGivenTimeBeforeItIsPresumedLost (снимок с часовым прогоном и пятисекундной попыткой) |
fixed(P4, дерево сессии) | адверсариальное ревью (исполнением) |
| PD-129 | bug | minor | internal/pgstore/sink.go, runs.go |
Инверсия порядка блокировок между материализатором и финишером: RunSink берёт books … for update и затем правит runs, а FinishRun/PauseRun правили runs и затем books. Два реконсилятора на одном прогоне (перекрытие поколений деплоя) дают взаимоблокировку в обе стороны — замерено ревью, SQLSTATE 40P01 на обеих формулировках. Порчи нет (Postgres откатывает одну сторону), цена — провалившийся проход свипа и секунда детекта — ⚠ ПЕРЕ-ДИСПОЗИЦИЯ 09.08: строка была закрыта ЛОЖНО. Правка P4 привела к книге-первой только FinishRun/PauseRun; сам материализатор (RunSink.Apply) продолжал брать run_attempts … for update ПЕРВЫМ, а RestartRun — обновлять попытку до всего остального, и приёмка воспроизвела дедлок через реальные API (258 из 300 пар). Закрывающая формулировка описывала половину правки как целое. Действительно закрыто дофиксом — см. PD-145 |
fixed(дофикс P4, дерево сессии; см. PD-145) | адверсариальное ревью (исполнением) |
| PD-130 | bug | minor | internal/ingest/tail.go readLine |
Любая ошибка чтения превращалась в io.EOF, то есть в «догнали, нового нет»: отказ диска читался бы как тишина, материализация вставала бы молча и ни один свип не сказал бы почему — закрыто: только настоящий EOF означает «догнали»; всё прочее возвращается ошибкой и уходит в карантин с причиной |
fixed(P4, дерево сессии) | адверсариальное ревью (чтение кода) |
| PD-131 | bug | minor | internal/ingest/tail.go |
Хендшейк не обязан был нести seq 1. На этом транспорте hello ДВИГАЕТ курсор, поэтому hello с seq 0 оставлял курсор нулём, а первое настоящее событие отбрасывалось как его дубль; отрицательный seq уходил в ветку «уже применено». Пайп-декодер это требование имел всегда (decoder.go), файловый читатель — нет — закрыто: seq != 1 у хендшейка = ErrBadHandshake |
fixed(P4, дерево сессии) | адверсариальное ревью (чтение кода) |
| PD-132 | bug | minor, деньги | internal/runs/reconcile.go settle |
Холд прогона, который так и не стартовал, не возвращался. После введения базовой линии (PD-124) попытка без неё не сводилась вовсе, а попытка, которую никогда не спавнили, базовой линии и не имеет — её холд оставался зарезервированным навсегда. Найдено собственным тестом при починке PD-124 — закрыто: нет базовой линии И нет имени юнита ⇒ попытка не выполнялась, холд возвращается ЦЕЛИКОМ (Release); нет базовой линии, но юнит был ⇒ расчёт удерживается с ERROR-строкой, а не угадывается. Пин — TestTheHoldOfARunThatNeverStartedComesBackWhole |
fixed(P4, дерево сессии) | самопроверка при починке PD-124 |
| PD-133 | bug | minor | internal/httpapi/v0.go contractRoutes |
Инстанс без движка не отдавал НИЧЕГО, вопреки собственной строке лога. Маршруты монтировались только когда есть И read-model, И жизненный цикл прогонов, поэтому инстанс с библиотекой и без раннера отвечал 404 на /v0/books, а его же стартовая строка говорила «библиотека отдаётся только на чтение». Замерено ревью — закрыто: ЧТЕНИЯ монтируются при наличии read-model, ручки прогона — при наличии жизненного цикла. Пин — TestAnInstanceWithoutARunnerStillServesTheLibrary |
fixed(P4, дерево сессии) | адверсариальное ревью (исполнением) |
| PD-134 | bug | minor | internal/pgstore/runs.go, internal/runs/spawn.go |
Пиннинг версии движка (строка 139) записывался и НИКОГДА не читался: run_attempts.engine_binary не входил в выборку реконсилятора, а спавн и канал ремонта брали путь из ТЕКУЩЕГО конфига. Пин, который никто не читает, — это колонка, а не пин: резюм исполнял бы то, что выкатили сегодня, а status --json спрашивал бы о книге бинарь другой версии — закрыто: EngineBinary читается в LiveRun и используется и резюмом, и каналом ремонта; конфиг остаётся фолбэком только для ещё не спавненной попытки |
fixed(P4, дерево сессии) | адверсариальное ревью (чтение кода) |
| PD-135 | bug | minor | internal/runs/reconcile.go restart |
Прерванный прогон без остатка бюджета помечался paused БЕЗ paused_reason (FinishRun его не трогает), тогда как контракт описывает PausedReason как причину паузы, и экрану сказать нечего — закрыто: используется PauseRun, который причину ставит |
fixed(P4, дерево сессии) | адверсариальное ревью (чтение кода) |
| PD-136 | doc | minor | deploy/tmplatformd.service |
Юнит нёс ОБЕ диспозиции сразу: старый абзац подавал ProtectHome=yes как «нужную позу на сервере» прямо над строками, ставящими tmpfs+BindPaths, а рассуждение о ресурсных потолках всё ещё исходило из модели «дети живут в cgroup этого юнита», снятой D39.106. Оператор, читающий сверху вниз, получал противоречивые инструкции в одном файле — закрыто: снятые абзацы удалены, потолки прямо названы границей КОНТРОЛ-ПЛЕЙНА, прогоны — своим срезом |
fixed(P4, дерево сессии) | адверсариальное ревью (чтение) |
| PD-137 | hardening | info | deploy/tmplatformd.service [Unit] |
BindPaths=/run/user/%U требует существования каталога на старте юнита, а создаёт его logind вместе с пользовательским менеджером; в [Unit] упорядочения на него нет. На первом бутe это гонка, которую лечит Restart=on-failure (сервис поднимается со второй попытки). Строка не закрыта кодом намеренно: UID сервисного пользователя site-specific, поэтому After=user@<uid>.service добавляется установкой — инструкция вписана в шапку юнита |
open | адверсариальное ревью (чтение) |
| PD-138 | standards | info | go.mod |
Прямые зависимости (riverqueue/river, riverdriver/riverpgxv5) стояли помеченными // indirect: make check тидинесс не проверяет, поэтому батарея этого не видела — закрыто: go mod tidy. ⚠ Строка оставлена как заявка: гейта на go mod tidy в батарее по-прежнему нет |
fixed(P4, дерево сессии) | адверсариальное ревью |
| PD-139 | hardening | info | internal/runs/reconcile.go ERROR-строки |
Путь каталога книги попадает в ERROR-логи внутри обёрнутых ошибок (*fs.PathError тейлера, обёртки спавна). PD-99 закрывал ДРУГОЕ — argv на INFO, — и та половина проверена (grep по логу сквозной пробы = 0). Здесь диспозиция иная и её надо принять осознанно: оператор чинит именно этот путь, а ERROR — не INFO. Заведено, чтобы это было решением, а не побочным эффектом; если норма зоны распространяется и на ERROR, путь придётся заменить на id прогона |
open | адверсариальное ревью (чтение) |
| PD-140 | bug | info | internal/runs/reconcile.go Stop, internal/httpapi/ |
Service.Stop построен и не подключён ни к чему: httpapi.Runs даёт только Bounds/Start, у tmplatformctl команды остановки нет. То есть контрол-плейн не умеет остановить прогон, который сам же запустил. Ручки POST /runs/{id}/stop и /resume в список работ промта не входили, поэтому это НЕ девиация пака, а честно названный хвост: остановить прогон сегодня можно только systemctl --user stop. Гейт: закрыть вместе с ручками стопа/резюма |
open | адверсариальное ревью (чтение) |
| PD-141 | bug | info | internal/pgstore/runs.go PauseRun |
PauseRun возвращает nil, когда строка прогона уже завершена, поэтому вызывающий рапортует паузу, которой не произошло. Идемпотентность здесь нужна (свип повторяется), но молчаливая — нет: различить «поставил паузу» и «было уже поздно» вызывающий не может |
open | адверсариальное ревью (чтение) |
| PD-142 | standards | minor | вся зона, тесты | ⚠ Заявление «22 новых пина, каждый проверен своей посадкой» СНЯТО дофиксом 09.08 как непроверяемое в этом объёме: прогонов посадок было девять (9/10, затем 9/9), то есть «каждый из 22» ими не покрывался, а поимённого списка соответствия пин↔посадка сессия не вела. Проверено исполнением и названо поимённо другое: 33 посадки самопроверки пака и 24 посадки дофикса (список — журнал, раздел «Дофикс P4»). Аудит силы пинов посадками (139 мутаций, четвёртый верификатор): 107 поймано, 32 пережили, из них 8 — не ослабления (эквивалентный код либо страховка DDL-констрейнтом). Пережившие — не дефекты КОДА, а отсутствующие пины на свойства, часть которых объявлена закрытой; по правилу шапки этого файла такое свойство закрытым не считается. Закрыто: написаны 22 новых пина; про «каждый проверен собственной посадкой» — см. начало строки, заявление снято (прогонов посадок было девять: 9/10, потом 9/9 после исправления двух ошибочно сформулированных мутаций). Самые весомые: блокировка строки попытки под КОНКУРЕНЦИЕЙ (TestConcurrentDeliveriesOfOneEventCountItOnce — восемь горутин на одно событие; последовательная доставка поглощается одним high-water mark и посадку не ловила) · CSRF на КОНТРАКТНОЙ поверхности (TestACrossSiteRequestCannotStartARun — снятие CSRF из гарда /v0 переживало всё, а это старт платного прогона с амбиентной кукой) · RunSpent считает только свой прогон и не считает открытые холды · обе ветки отложенного расчёта · MarkSettled одноразов · хендшейк второго движка отвергается · монотонность байтового хинта · пустой payload · ETA-ноль · черновой юнит не «сделан» · paused при нехватке баланса · principal падает ЗАКРЫТО · внутренний текст не течёт в Problem. ⚠ Одна посадка («убрать and ended_at is null из RestartRun») пережила и НЕ является ослаблением: unique (run_id, attempt_no) отвергает всех проигравших гонку, так что ровно один перезапуск проходит и без неё — записано, а не подчищено |
fixed(P4, дерево сессии) | адверсариальное ревью (аудит посадками) |
| PD-143 | bug | minor | internal/runs/spawn.go spec, internal/pgstore/runs.go RestartRun |
Пиннинг версии движка (строка 139) был закрыт НАПОЛОВИНУ: запиненный путь читался каналом ремонта, но НЕ исполнялся резюмом. spec() брал Cfg.EngineBinary, а RestartRun не переносил engine_binary в новую попытку, поэтому перезапущенный прогон шёл на том бинаре, который выкачен СЕЙЧАС, — то есть перевод продолжала другая программа, и «резюм другой версией только явным флагом» не выполнялось. Найдено собственной пост-сверкой диффа с промтом (не ревью и не батареей: обе половины компилировались и все тесты были зелёными) — закрыто: новая попытка НАСЛЕДУЕТ engine_binary предыдущей, spec() исполняет запиненный путь, а переход на другую сборку требует явного TM_PLATFORM_RESUME_MAY_CHANGE_ENGINE. Пины — runs.TestAResumeStaysOnTheEngineBuildTheRunStartedWith и TestAResumeMovesToANewEngineBuildOnlyWhenItIsAllowed; обе посадки («spec берёт из конфига», «RestartRun не наследует») падают |
fixed(P4, дерево сессии) | пост-сверка диффа с промтом |
| PD-144 | bug | major, деньги/шов | internal/runs/spawn.go, internal/ingest/resync.go |
Движку передавался ПРИРОСТ там, где его флаг означает НАКОПЛЕННЫЙ книжный потолок. --ceiling-usd переопределяет ceilings.book_usd и сравнивается с committed + reserved книги на КАЖДОЙ резервации (backend/internal/store/ledger.go Reserve, backend/cmd/tmctl/invocation.go — «It caps the book's CUMULATIVE committed+reserved spend, not this run's increment»). Значит второй прогон книги, у которой накоплено ≥ прироста, отвергается первой же резервацией: движок выходит кодом 1, платформа обязана назвать это failed, работа не сделана, ретраи идентичны. Приёмка доказала обе стороны исполнением; сквозная проба пака этого не видела, потому что её фейк кумулятив не моделировал — закрыто: runs.meter.bookCap = committed + прирост (ратифицировано 09.08 ре-чеком V2 после PD-158; reserved в сумму НЕ входит), обе величины читаются ОДНИМ вызовом status --json перед стартом (bookMeter), reserved_usd внесён в аллоулист УКАЗАТЕЛЕМ (отсутствие ≠ ноль, как у committed), рестарт получает свежий отсчёт по тому же пути, фактически ушедшее значение хранится (run_attempts.ceiling_arg_micro_usd, миграция 00011). Пины: runs.TestTheSecondRunOfABookIsGivenTheCumulativeCapAndNotItsOwnIncrement и TestAResumeIsGivenACapComputedFromTheMeterAsItStandsNow — оба через фейк ceilingJudge, который отвергает потолок ПО ПРАВИЛУ ДВИЖКА; TestAnAttemptWhoseMeterCannotBeReadIsNotStartedAtAll покрывает отсутствующий reserved_usd. Проба приёмки на этом дереве: --ceiling-usd 6.000000 при committed книги $3 |
fixed(дофикс P4, дерево сессии) | приёмка P4 (F1, двусторонним исполнением) |
| PD-145 | bug | major | internal/pgstore/sink.go Apply, runs.go RestartRun, internal/runs/reconcile.go |
Инверсия блокировок из PD-129 была жива, а транзиентный сбой из-за неё уходил в КАРАНТИН. RunSink.Apply брал run_attempts … for update первым, RestartRun правил попытку до всего остального, а FinishRun/PauseRun берут книгу первой — приёмка воспроизвела 258 дедлоков на 300 пар через реальные API. Усилитель хуже самого дедлока: 40P01 из Apply попадал в ветку «любая ошибка журнала = карантин», то есть проекция ЖИВОГО платного прогона слепла навсегда из-за блокировки, которая разрешилась сама — закрыто: порядок написан в одном месте и стал глобальным (pgstore.lockBook: books → runs → run_attempts → account_balances → reservations), книга блокируется первой в Apply, RestartRun, StartRun и DeleteBook (последний найден собственной сверкой всех транзакций пакета: цикла для него нет, но инвариант, у которого есть исключение, перестаёт быть инвариантом); классификация ошибки вынесена в runs.quarantines. ⚠ Формулировка «карантин остаётся только логическим ошибкам» была НЕВЕРНА в части и исправлена ре-чеком V2: первая редакция IsTransient знала только про дедлок и сериализацию, поэтому обрыв соединения с Postgres — то есть ШТАТНЫЙ рестарт управляемой базы (57P01/57P02/57P03, класс 08, сетевой сброс) — по-прежнему карантинил проекцию живого платного прогона НАВСЕГДА (пути снятия карантина в дереве нет). Доказано исполнением приёмкой. Теперь IsTransient покрывает класс 08, 57P0x, pgconn.SafeToRetry и любой net.Error; ошибки чтения файла — *fs.PathError и net.Error не удовлетворяют, поэтому битый журнал по-прежнему останавливает проекцию, как и должен. Кейсы внесены в таблицу пина. Пины: pgstore.TestTheMaterializerTakesTheBookBeforeTheAttempt и TestARestartTakesTheBookBeforeTheAttempt (порядок утверждается ПРЯМО — блокировка книги удерживается, операция обязана ждать её, а строка попытки обязана остаться свободной под for update nowait), TestAMaterializerAndAReconcilerOnOneRunDoNotDeadlock (конкурентный, 60×3), runs.TestOnlyAJournalWeCannotReadStopsTheProjection |
fixed(дофикс P4, дерево сессии) | приёмка P4 (F2, исполнением) |
| PD-146 | standards | major (пин) | internal/runs/spawn.go bookMeter |
Сердце PD-124 не было запинено: посадка «нечитаемый отсчёт → (0, nil)» пережила ПОЛНУЮ батарею. С ней расчёт идёт против базовой линии 0, то есть прогон оплачивает всю пожизненную трату книги — ровно тот дефект, который PD-124 объявил закрытым. Отказ спавну был построен и не проверен ни одним тестом — закрыто: TestAnAttemptWhoseMeterCannotBeReadIsNotStartedAtAll — три формы нечитаемости (вызов упал · нет committed_usd · нет reserved_usd), и в каждой утверждается, что юнит не создан И попытка не заклеймлена (unit_name пуст), значит следующий свип её повторит; хвост теста показывает, что после починки движка та же попытка стартует |
fixed(дофикс P4, дерево сессии) | приёмка P4 (F3, посадка) |
| PD-147 | standards | major (пин) | internal/runs/spawn.go spawnAttempt |
Очистка устаревшего exit-маркера перед стартом не была запинена: удаление os.Remove(spec.ExitMarker) пережило полную батарею. Залежавшийся маркер той же попытки читается как её окончание при первом же взгляде реконсилятора: прогон, движок которого только что запущен, будет завершён и рассчитан заживо — закрыто: TestAStaleExitMarkerIsClearedBeforeTheUnitStarts — маркер создаётся ДО спавна, после спавна обязан отсутствовать, а свип обязан оставить прогон живым с открытым холдом |
fixed(дофикс P4, дерево сессии) | приёмка P4 (F4, посадка) |
| PD-148 | bug | major, деньги | internal/runs/reconcile.go settle, internal/pgstore/credits.go |
Расчёт по УСТАРЕВШЕМУ снапшоту свипа возвращал холд целиком попытке, которая потратила. Свип реконсилит по списку, прочитанному в начале прохода; быстрый прогон успевает стартовать, потратить и выйти, пока свип идёт по предыдущим — и ветка «попытки не было» судила по l.UnitName == "" из снапшота, хотя в БД спавн уже записан. Приёмка доказала исполнением: charged 0.000000 за попытку, потратившую 0.500000; недоплата безвозвратна и никем не ищется — закрыто: pgstore.ReleaseUnspawned перечитывает run_attempts.unit_name for update В ТОЙ ЖЕ транзакции, что и деньги, и отказывает (ErrAttemptSpawned), если юнит есть; реконсилятор откладывает расчёт до следующего прохода, где снапшот уже содержит юнит и попытка рассчитывается по своей базовой линии. Пин: runs.TestAStaleSnapshotDoesNotGiveBackTheHoldOfAnAttemptThatSpent (холд не возвращается целиком в проходе со старым снапшотом; следующий свип списывает ровно потраченное) |
fixed(дофикс P4, дерево сессии) | приёмка P4 (F5, исполнением) |
| PD-149 | bug | minor | internal/config/config.go loadRunner |
Относительный TM_PLATFORM_STATE_DIR не абсолютизировался, а маркер пишется и читается из РАЗНЫХ рабочих каталогов: ExecStopPost исполняется юнитом, у которого WorkingDirectory — каталог книги, а демон читает от своего cwd. Конец прогона становится невидим, реконсилятор перезапускает прогон бесконечно — закрыто: filepath.IsAbs на буте, отказ с именем переменной; пин config.TestARelativeStateDirectoryIsRefusedAtBoot |
fixed(дофикс P4, дерево сессии) | приёмка P4 (F6) |
| PD-150 | standards | minor | internal/pgstore/sink.go ApplyStatus |
«Метка давности» ре-синка была обещана промтом («честно, с меткой давности») и не построена: now в ApplyStatus не использовался, поля свежести не было, и у читателя замершей проекции карантинной попытки не было ничего, что сказало бы, насколько старые цифры он видит — закрыто: runs.last_resync_at (миграция 00012) пишется КАЖДЫМ ре-синком; пин pgstore.TestAResyncRecordsWhenItWasTaken (нет метки до первого · метка равна времени вызова · вторая переписывает первую). ⚠ Названная девиация: на провод метка НЕ выходит — в контракте v0 у прогона поля свежести нет; читается оператором и той ручкой, которая появится вместе с полем |
fixed(дофикс P4, дерево сессии; половина «на провод» — за контрактом) | приёмка P4 (F7) |
| PD-151 | doc | minor | docs/platform-PROGRESS.md раздел «Сессия P4» |
Числа отчёта расходились с истиной, а одно заявление было внутренне противоречиво: тело журнала давало «105 → 203, +98» (истина на момент приёмки — 242/+137/−0), «36 новых строк = 25+10» (истина — 26 закрыто + 10 открыто), и «22 новых пина, каждый проверен своей посадкой (9/10, затем 9/9)» — 22 не покрываются девятью прогонами — закрыто: числа пересчитаны ИСПОЛНЕНИЕМ и приведены с командой (105 → 261, удалённых 0, добавленных 156 — итог дофикса с ре-чеком V2); арифметика 36 = 26 + 10 исправлена; заявление «каждый» снято (см. PD-142). Шапка журнала была права и не тронута |
fixed(дофикс P4, дерево сессии) | приёмка P4 (F8) |
| PD-152 | bug | info | internal/runs/reconcile.go outcome |
stopped для остановки, которую мы попросили, на реальном движке недостижим: tmctl ЛОВИТ SIGTERM и выходит кодом 1, поэтому ветка «не вышел сам + $SERVICE_RESULT=success» срабатывает только для процесса, умершего ОТ сигнала. Проба пака показала stopped на фейке, который именно так и умирал. Следствие: пользовательский стоп приедет как failed. ⚠ Дофикс 09.08 расширил строку: то же самое ломает ШТАТНУЮ ПЕРЕЗАГРУЗКУ. При ребуте пользовательский менеджер останавливает юниты корректно, ExecStopPost ОТРАБАТЫВАЕТ и маркер пишется — ревьюер снял живьём на этом хосте (транзиентный юнит, процесс ловит TERM и выходит 1): RESULT=exit-code CODE=exited STATUS=1. То есть на буте свип видит маркер и закрывает все живые прогоны как failed вместо перезапуска, а путь строки 138 покрывает только потерю питания (нет маркера) — и собственный тест TestARunInterruptedByARebootComesBackWithTheBudgetItHasLeft моделирует именно её. Закрывается в паке ручек стопа — попытка помечается «stop requested» до сигнала — и приходом различимого кода выхода graceful-stop у движка (строка бэклога движку, заводит оркестратор); платформенной догадки здесь быть не должно |
open | приёмка P4 (N1) |
| PD-153 | bug | info | internal/runs/reconcile.go spawnGrace |
Грация спавна мерится от run_attempts.started_at, а не от момента заявки права на спавн: окно между RecordSpawn и возвратом systemd-run не покрыто, и второй инстанс платформы, у которого грация уже истекла, может решить, что попытка потеряна, и перезапустить прогон, который вот-вот стартует. Дёшево закрывается временем заявки, записываемым в RecordSpawn, и грацией от него |
open | приёмка P4 (N2) |
| PD-154 | bug | info | internal/runs/reconcile.go settle |
runs.settled_at может остаться NULL между Settle и MarkSettled: это два вызова, и падение между ними оставляет прогон с закрытой резервацией и без отметки. Потребителей у отметки сегодня нет (рабочий список расчёта построен на открытой резервации, а не на ней), деньги целы и второй расчёт отвергается самой резервацией. ⚠ Дофикс 09.08 добавил вторую половину той же строки: settle прерванной попытки ЖИВОГО прогона (путь UnsettledRuns) ставит settled_at прогону, который ещё идёт. Потребителей у колонки по-прежнему нет, дрейф только операторский. Заведено как известность, а не как долг: закрывается вместе с эскроу (строка 136) |
open | приёмка P4 (N3) |
| PD-155 | doc | info | deploy/README.md |
Смена TM_PLATFORM_STATE_DIR осиротляет exit-маркеры идущих прогонов: маркер пишется по пути, вычисленному при спавне, а читается по пути из текущей конфигурации, поэтому после смены каталога конец прогона невидим и прогон перезапускается — закрыто: строка в deploy/README.md — менять каталог только при отсутствии живых прогонов |
fixed(дофикс P4, дерево сессии) | приёмка P4 (N4) |
| PD-156 | bug | info | internal/runs/spawn.go spawnAttempt |
Отказ ДЕПЛОЯ проверялся после вызова движка. Дофикс перенёс чтение денежного отсчёта в начало спавна и тем поставил его ПЕРЕД дешёвыми отказами «нечем записать конец юнита» / «нечем передать потолок»: инстанс с неполной конфигурацией платил бы секундами CPU движка (status --json пере-нарезает исходник, строка 100) за каждый прогон на каждом свипе, чтобы прийти к ответу, зависящему только от конфигурации — закрыто: runs.runnable() вызывается первым в spawnAttempt, spec и Start; пин TestAMisconfiguredDeploymentIsRefusedWithoutAskingTheEngine (посадка «убрать ранний отказ» падает) |
fixed(дофикс P4, дерево сессии) | собственная сверка диффа дофикса |
| PD-157 | bug | minor | internal/runs/spawn.go, cmd/tmplatformctl/runs.go book add |
ДНЕВНОЙ потолок книги --ceiling-usd не перекрывает, а платформа его не видит и не задаёт. Движок требует хотя бы один из book_usd/day_usd (backend/internal/config/book.go:250, Р7), флаг переопределяет только книжный (D39.122 прямо: «День-потолок не перекрывается»), а book.yaml пишет ОПЕРАТОР — платформа его не правит (D39.110 §2b) и в book add только проверяет наличие файла. Значит книга с низким day_usd останавливает прогон на лимите, которого платформа не выбирала: движок выходит кодом 1 (тот же путь, что у PD-113), прогон приезжает failed, а деньги пользователя целы и он не понимает, почему. В status --json дневной фигуры нет вовсе (есть book_ceiling_usd/ceiling_pct), поэтому даже диагностировать это платформа сегодня не может. Заведено, не построено: закрывать — либо проверкой day_usd при заведении книги, либо словом контракта о том, кто владеет потолками book.yaml у книг под платформой |
open | собственная сверка шва при F1 (чтение движка + D39.122) |
| PD-158 | bug | minor, деньги | internal/runs/spawn.go meter.bookCap |
Формула потолка из пинга ратификации (committed + reserved (из status --json) + прирост; сам D39.122 §2в ратифицирует ОБЯЗАННОСТЬ платформы пересчитывать «прирост → абсолют», буквальной формулы в решении нет — овер-атрибуцию поправило ревью доков) даёт прогону запас БОЛЬШЕ его холда, когда предыдущий процесс умер с незакрытой резервацией. Найдено сверкой формулы с кодом движка: store.Open — путь ЗАПИСИ, которым идёт каждый translate — выполняет recoverReservations и обнуляет reserved_usd книги ДО первой судимой резервации (backend/internal/store/store.go:88 и :214); tmctl status читает read-only и этот проход намеренно не делает (store.go:108 говорит об этом прямо). Значит цифра, которую видит платформа, — ОСТАТОК мёртвого процесса, и к моменту сравнения её уже нет: движок остановится позже на её величину, расчёт упрётся в потолок холда, а леджер запишет «capped at the hold», как будто перерасходовал движок. Величина ограничена размером остатка (обычно одна оценка вызова), но путь достижим на каждом резюме после падения. Сделано: bookCap считает committed + прирост — отклонение в консервативную сторону (более узкий потолок может только остановить прогон раньше, перерасхода не даёт), названо в коде и запинено TestAResumeIsGivenACapComputedFromTheMeterAsItStandsNow (второе утверждение: остаток НЕ раздул потолок). Сама цифра по-прежнему читается и обязательна (отсутствие ≠ ноль) — она и есть доказательство, что отклонение безопасно: на спавне другого писателя нет (эксклюзивный лок + один живой прогон на книгу), значит любой reserved — остаток. ⚠ Строка остаётся ОТКРЫТОЙ как вопрос: отклонение от ратифицированной формулы — не право зоны, нужен ответ оркестратора (принять формулу в виде committed + прирост либо назвать другой разбор) ЗАКРЫТО РАТИФИКАЦИЕЙ (оркестратор №15, 09.08, ре-чек V2): принята формула committed + прирост; аргументация подтверждена оркестратором исполнением обеих формул против гейта движка — ратифицированная переплачивала запасом ровно на leftover-reserved |
fixed(ратификация 09.08) | собственная сверка формулы с кодом движка при F1 |
| PD-159 | bug | major, деньги | internal/runs/reconcile.go settle, internal/pgstore/runs.go SpendBound |
Отложенный расчёт прогона оплачивал работу СЛЕДУЮЩЕГО прогона той же книги, и тот платил за неё ещё раз. Расчёт читает пожизненный счётчик КНИГИ в момент ПОВТОРА, а откладываться он вправе (движка не спросить). Завершённый-но-нерассчитанный прогон при этом не мешает новому: HasLiveRun смотрит только на finished_at. Замерено: прогон, стоивший $0.10, списан на $2.10 — своя трата плюс всё, что успел потратить преемник, — после чего преемник заплатил ту же сумму снова; переплата ограничена холдом. Найдено ДВУМЯ независимыми верификаторами самопроверки, каждый воспроизвёл исполнением, и третий раз воспроизведено мной перед починкой — закрыто: SpendBound — наименьшая базовая линия среди попыток этой книги, стартовавших ПОЗЖЕ; она снята до того, как та попытка что-либо добавила, и после того, как эта остановилась, поэтому является точной верхней границей. Расчёт берёт минимум из неё и текущего счётчика. Пин: TestADeferredSettlementIsNotChargedForTheNextRunOfTheSameBook (первый платит $0.10, второй — свои $2.00, баланс и леджер сходятся) |
fixed(дофикс P4, дерево сессии) | самопроверка дофикса (два верификатора, независимо, исполнением) |
| PD-160 | standards | minor (пин) | internal/runs/reconcile.go drainJournal |
Проводка «транзиентный сбой НЕ карантинит» не пинилась: пин стоял на чистой функции quarantines, а удаление ветки, которая её ВЫЗЫВАЕТ, переживало батарею. Регрессия этой формы тихо карантинит проекцию живого платящего прогона — то есть ровно тот дефект, который F2 объявил закрытым — закрыто: TestADeadlockDoesNotStopTheProjection гонит НАСТОЯЩИЙ дедлок через весь путь (транзакция берёт строки в обратном порядке, Postgres рвёт цикл; раунд, где жертвой стала наша сторона, и есть предмет теста) и утверждает на КАЖДОМ раунде, что попытка не в карантине. Посадка «убрать ветку» падает за 1.9 с |
fixed(дофикс P4, дерево сессии) | самопроверка дофикса (посадка) |
| PD-161 | bug | minor, деньги | internal/pgstore/runs.go RecordSpawn |
Повторная заявка права на спавн ПЕРЕЗАПИСЫВАЛА базовую линию. «Не удалось создать юнит» — не то же, что «юнит не создан»: systemd-run, убитый по таймауту ПОСЛЕ подачи запроса, рапортует ошибку и оставляет движок работать. Право отдаётся обратно (ReleaseSpawnClaim), следующий свип заявляет его снова и кладёт в базовую линию счётчик, который этот же движок двигает, — попытка потом оплачивает разницу от цифры, уже включающей её собственную работу (недоплата, которую никто не ищет) — закрыто: повторная заявка сохраняет и базовую линию, и записанный аргумент потолка (coalesce / case when), там где юнит действительно не создан значения совпадают. ⚠ Ре-чек V2 показал, что этим строка закрыта НАПОЛОВИНУ: в БД значения сохранялись, а движку на ретрае уходил потолок, пересчитанный по СВЕЖЕМУ счётчику — то есть включающий трату собственного «призрака», — и форензик-колонка 00011 на этом пути лгала (замерено приёмкой: handed 3.400000 против stored 3.000000). Закрыто по-настоящему: на ретрае (SpendBaseline != nil && CeilingArg > 0) спавн ПЕРЕДАЁТ сохранённый аргумент, а не пересчитанный. Пин: TestAReclaimedAttemptKeepsTheBaselineItFirstRecorded — теперь утверждает и базовую линию, и равенство handed == stored |
fixed(дофикс P4, дерево сессии) | самопроверка дофикса (ревью вне карты, чтением) |
| PD-162 | bug | minor | internal/runs/spawn.go journalSize, internal/runs/reconcile.go |
Книга, чей каталог удалён или перемещён, принимает прогон и заклинивает его навсегда с открытым холдом. journalSize мапит ENOENT в «ноль, ошибки нет» (законно для первого прогона), поэтому Start отдаёт 202 и берёт холд; дальше bookMeter вечно падает (спавн отказывает), либо расчёт вечно откладывается — ни один путь не приходит к терминальному состоянию: прогон вечно translating, деньги вечно в холде, пользователю видно только «идёт». Дизайн «холд лучше догадки» осознан (строка 136), но отсутствие И валидации каталога на старте, И эскалации после N неудач — дыра. Закрывать вместе с эскроу/uncertain либо проверкой каталога при допуске. ⚠ Ре-чек V2 расширил КЛАСС строки: обязательность reserved_usd даёт тот же клин без всякого удаления каталога — прогон, запиненный к СТАРОЙ сборке движка (строка 139), у которой поля ещё нет, вечно отказывает спавну с открытым холдом. Лечится тем же терминальным состоянием после N неудач; отказ сам по себе верен (без цифры потолок считать нечем) |
open | самопроверка дофикса (ревью вне карты) |
| PD-163 | bug | info | internal/pgstore/books.go ListBooks, GetBook |
Ревизия области читается ВТОРЫМ запросом после страницы, поэтому под конкурентной материализацией она новее строк: клиент, соблюдающий контрактное «отбрасывай чтение с меньшей ревизией», навсегда потеряет кадры между двумя запросами. Потребителя (SSE) сегодня нет; закрывать до первого потока — читать страницу и ревизию одной транзакцией | open | самопроверка дофикса (ревью вне карты) |
| PD-164 | bug | info | internal/runner/marker.go, internal/runs/reconcile.go |
Маркер, который существует, но не разбирается, вечно валит реконсиляцию своего прогона: аналога карантина у этого пути нет, и ошибка чтения маркера возвращается наверх на каждом свипе. Запись атомарна (temp+fsync+rename), так что нужен внешний фактор (правка оператором, битый том). Закрывать — так же, как журнал: неисправимая ошибка маркера должна вести к терминальному состоянию с причиной, а не к вечному повтору | open | самопроверка дофикса (ревью вне карты) |
| PD-165 | hardening | info | cmd/tmplatformd/runner.go markerArgv, internal/runner/runner.go quoteArgv |
Относительный TM_PLATFORM_CTL_BIN проходит os.Stat, но systemd требует АБСОЛЮТНЫЙ путь в ExecStopPost — каждый старт падает, и виден только цикл заявка-откат. Тот же класс: quoteArgv не экранирует $ (подстановка переменных systemd в Exec-строках), поэтому каталог состояния с таким символом молча ломает командную строку маркера. ⚠ % проверен и БЕЗОПАСЕН — спецификаторы в значениях --property= не раскрываются (замер §17); $ в этой сессии исполнением не проверялся. Лечится тем же filepath.IsAbs, что и StateDir (PD-149), плюс отказ на подозрительных символах |
open | самопроверка дофикса (ревью вне карты) |
| PD-166 | bug | info | internal/ingest/tail.go, internal/pgstore/sink.go Begin |
chunker_version из хендшейка теряется навсегда, если краш пришёлся между двумя стейтментами Begin (привязка engine_run_id и запись версии — два отдельных автокоммита): при повторном чтении своего же hello тейлер видит, что поток уже привязан, и Begin больше не зовёт. Сегодня поле никем не читается (нужно для строки 100), поэтому info; закрывать — одной транзакцией в Begin |
open | самопроверка дофикса (ревью вне карты) |
| PD-167 | doc | info | internal/pgstore/migrations/00009_runner.sql:37-39 |
Комментарий DDL обещает «хинт, не ведущий к seq = last_seq + 1, отбрасывается, и файл перечитывается с начала» — код так не делает: пропасть ведёт к карантину проекции и переходу на ре-синк. Расхождение док↔код, поведение верное — закрыто ре-чеком V2: комментарий приведён к тому, что делает тейлер. ⚠ Ре-чек назвал эту строку «застывшим комментарием миграции 00011»; предмет строки — комментарий 00009, а 00011 нёс отменённую формулу потолка и исправлен вместе с ней (обе миграции этим паком и написаны, нигде не применялись, поэтому их отпечатки в migrations.sha256 обновлены с явной причиной в шапке файла) |
fixed(ратификация + дофикс V2, дерево сессии) | самопроверка дофикса (ревью вне карты) |
| PD-168 | bug | minor, деньги | internal/runs/reconcile.go restart |
Бюджет перезапуска пересчитывается по ТЕКУЩЕЙ ставке, а не по той, под которую брался холд: s.Pricing.Ceiling(l.CeilingChapters) читает конфигурацию нынешнего деплоя. Смена TM_PLATFORM_USD_PER_CHAPTER между допуском и перезапуском ломает обе стороны — вверх: резервируется больше, чем пользователь видел на шкале (нарушение «явного согласия на оплату»); вниз: остаток уходит в минус и прогон ошибочно встаёт paused/credit_exhausted. Замерено верификатором: при удвоении ставки перезапуск зарезервировал $5.50 вместо $2.50. Исходная сумма восстановима без пересчёта — она лежит в холде первой попытки (reservations.ceiling_micro_usd / run_attempts.ceiling_micro_usd) |
open | самопроверка дофикса (два верификатора, один исполнением) |
| PD-169 | hardening | info | cmd/tmplatformd/runner.go свип |
Свип имеет один бюджет времени (2 минуты) на ВСЕ прогоны, а каждый спавн/расчёт стоит вызова tmctl status (~1.5 с CPU и больше). Десяток прогонов, чьи журналы нечитаемы, упирается в таймаут, и хвост списка (order by started_at — всегда один и тот же порядок) голодает неограниченно долго. Наблюдаемости, которая это показала бы, нет вовсе (П-11). Закрывать — бюджетом НА ПРОГОН плюс метрикой длительности свипа |
open | самопроверка дофикса (ревью вне карты) |
| PD-170 | hardening | info | internal/pgstore/credits.go Settle |
Settle отбрасывает флаг applied строки расчёта, тогда как releaseHold на соседней строке из того же флага делает ErrReleaseKeySpent (PD-97). Недостижимо без правки леджера в обход кода — резервация должна быть открыта, чтобы дойти сюда, — но асимметрия в денежном пути стоит строки: либо симметричный отказ, либо явная причина, почему здесь он не нужен |
open | самопроверка дофикса (ревью вне карты) |
| PD-171 | bug | minor, деньги | internal/runs/reconcile.go settle |
Счётчик книги НИЖЕ собственной базовой линии попытки списывал $0 молча. Это вырожденный случай — БД проекта подменили или восстановили из копии, — и клампить в ноль правильно (платить аккаунту за подмену файла код решать не вправе), но молчать нельзя: расчёт в ноль обнаруживался бы только по балансу — закрыто: WARN (не INFO: предмет — деньги) с фактом и без цифр (D39.84); пин TestAMeterThatWentBackwardsSettlesAtNothingAndSaysSo проверяет и отсутствие списания, и наличие строки, и что цифры в неё не попали |
fixed(дофикс V2, дерево сессии) | ре-чек V2 (оркестратор №15) |