690 KiB
Регистр дефектов и уязвимостей платформы
Заведён по решению владельца 04.08 («отдельно ведётся колонка багов и уязвимостей — тут опасно всё»). ⚠ Токен
ОСПОРЕНО(PD-N)(введён паком P8-REVIEW 24.08): строка стоитfixed, а пак доказал, что её пин доказывает свойство СЛАБЕЕ, чем строка гласит. Статус при этом НЕ меняется — это акт лендинга, — а живой пробел несёт новая строка, номер которой стоит в скобках. Грепается:grep -n 'ОСПОРЕНО(' ….Правила: каждая находка любой сессии/приёмки/аудита — строкой сюда ДО закрытия; ID стабилен навсегда; закрытие — только с коммитом фикса и тестом, пинящим свойство (урок PD-1: свойство без пинящего теста считается НЕ закрытым). ⚠ Оговорка к PD-1, ратифицирована D39.159 и дописана 27.08 по вычитке старшего: строка
fixed, чей пин доказывает МЕНЬШЕ, чем строка гласит, не пере-открывается, когда описанный ею дефект из кода ушёл, — иначе регистр объявляет вернувшимся баг, который не воспроизводится, и считает один пробел двумя открытыми. Живой пробел несёт открытая строка-преемник с ДВУСТОРОННЕЙ ссылкой и токеномОСПОРЕНО(PD-N)в оспоренной строке. Пере-открытие — только когда дефект ВОСПРОИЗВОДИТСЯ. Без этой оговорки практика и буква правила спорили бы в одном файле, и следующая приёмка пере-судила бы спор с нуля. Класс:vuln— эксплуатируемо или ослабляет защиту ·bug— неверное поведение ·hardening— защита в глубину / латентное ·doc— док лжёт о коде ·standards— расхождение с объявленной нормой зоны (введены приёмкой P2; словарь отставал от строк — испр. оркестратором №15). Статус:open·fixed(<commit>)·accepted-risk(<кем, когда>).
Форма файла (пинг оркестратора №16, 09.08). Таблица разложена на секции — открытые по весу, принятый риск, закрытые по эрам паков, — а построчная форма
| PD-N | … |сохранена:docs/scripts/counts.py(путь от КОРНЯ репозитория — скрипт зоныdocs/, зовётсяpython3 docs/scripts/counts.py --checkоттуда же) ключуется формой строки, а не позицией, и секции его не ломают. ID остаётся стабильным навсегда, поэтому строка не переезжает между секциями иначе как при смене статуса, и внутри секции строки идут по номеру.
Открытые — major
Несущий путь или контрактно видимое поведение. Каждая строка здесь — то, что решается до следующего пака, а не «когда-нибудь».
| ID | Класс | Серьёзность | Где | Суть | Статус | Источник |
|---|---|---|---|---|---|---|
| PD-370 | bug | major | internal/httpapi/reading.go, internal/httpapi/v0.go, internal/pgstore/readmodel.go, канон 14-api-contract §BankDecision |
Была построена и стоит в каноне модель, которую владелец отменил 16.08. D39.144 ратифицировал: подписывается ВЕСЬ банк ОДНИМ ОК, «пер-термная подпись = сотни кликов — НЕ модель продукта»; пер-термно существует не подпись, а ПРАВКА термина (до подписи) и правка с пере-генерацией задетых глав (после прочтения, гейчено 192). Гейт полноты нота сняла, а САМ ГЛАГОЛ остался — чистка дошла до resume и счётчиков и не дошла до словаря. Колонки были write-only: их не читал ни один SELECT, то есть реализации не существовало — ЗОННАЯ ПОЛОВИНА ЗАКРЫТА 22.08 по слову владельца: снят write-путь целиком (маршрут POST /books/{bookId}/bank/decisions, хендлер, wire-типы, метод интерфейса Library, SubmitBankDecisions, типы BankDecision/BankReceipt, UnknownTermError), на его месте — пометка для будущей сессии с ратифицированной моделью и с тем, что уже есть в схеме. Пол размера поверхности 15 → 14 сдвинут ЯВНО и с причиной в комментарии: гейт сработал, а не был подогнан. КОНТРАКТНАЯ ПОЛОВИНА ЗАКРЫТА 27.08 минором 0.5.0 (D39.161): путь POST /books/{bookId}/bank/decisions, глагол submitBankDecisions и три схемы снесены из канона — ноль вхождений, замер в docs/CONTRACT_MINOR_REPORT.md и пере-мер приёмки D39.161 п.2; счётчики pending_decisions/complete сняты со всех трёх носителей (BankPage, EventBank, квитанция). Преемник — дверь POST …/bank/corrections (выведена из tmctl bank-apply), СМОНТИРОВАНА паком P9 (D39.162); Capabilities.bank_corrections_enabled следует cfg.RunsEnabled(). Итог: зонная половина снята 22.08 (слово владельца), контрактная — 27.08 (D39.161); ⚠ НЕ наследовать этой строкой заказ на счётчики в wireBankPage — он жил отдельной строкой PD-399 (закрыта паком P9 своим пином). Статус переведён по аудиту документации 28.08 — обязательство висело неисполненным (класс строки 225); текст закрытия — контрактной сессии, перенос в зону — платформенной (регистр в её праве записи и был грязен её WIP) |
fixed(D39.161 — контрактная половина; 22.08 слово владельца — зонная; перевод по аудиту 28.08) | поправка владельца 22.08; ратификация D39.144; аудит 31 агента 22.08 · минор D39.161 · аудит документации 28.08 |
| PD-402 | bug | major | internal/runs/reconcile.go:1221 Resume (switch по статусу, «последний ли» не спрашивается), internal/pgstore/readmodel.go:484 lastRun (order by started_at desc), internal/pgstore/runs.go RestartRun (started_at не пере-штампуется) |
Возобновление НЕ-последнего прогона книги ломает полосу и карточку сразу двумя путями. (1) Полоса возобновлённого открывается на ЧУЖОЙ работе: базы не пере-снимаются (это правильно для последнего прогона), но между стопом и resume по книге легально прошёл ЦЕЛЫЙ другой прогон, и оба числителя уже накрутили его главы — на нажатие «продолжить» пользователь видит 100% при нуле собственной работы, клэмп режет только превышение единицы, не двойной счёт до неё. (2) lastRun выбирает строку по started_at desc, а RestartRun started_at не трогает — карточка книги, derivedStatus и КАЖДЫЙ progress-кадр живого прогона считаются по ЧУЖОМУ финишировавшему: экран показывает ready и замороженный бар, пока живой прогон тратит деньги невидимо. Найдено воркфлоу-ревью и подтверждено его overlay-прогонами (4 независимые линзы сошлись на одном корне); ответ на сам resume (ReadRun по id) при этом честный — клиент получает от одной ревизии книги два разных бара. Лечение — вопрос формы: запрет resume не-последнего ИЛИ live-приоритет в lastRun; решение следующего пака |
open | воркфлоу-ревью P9 28.08 (линзы bar:monotonicity · bar:double-count · bar:baselines-x-migration · bar:stage-caption), диспозиция оркестратора 28.08: строкой |
| PD-403 | bug | major | internal/pgstore/sink.go:415 recordWaveShape (edit_wave только растёт), internal/pgstore/readmodel.go:388 finishedUnits, internal/pgstore/books.go ReadBookForRun.ChaptersLeft |
Книга с уже сложившимся edit_wave=true на деплое, где оператор УБРАЛ редактора, продаёт уже переведённые главы повторно — и ни один прогон больше не доводит полосу до единицы. Безредакторный движок шлёт editTotal=0, но флаг не возвращается (or $2 пишет только false→true); прогон делает 100% купленного черновика и закрывается ready на 50% со stage editing; chaptersDone (edit-колонка) мёрзнет, ChaptersLeft не падает — шкала покупки предлагает те же главы снова (симптом PD-202, записанной fixed этим самым флагом), а follow-up прогон над этой покупкой читает 0/C с первой секунды. Комментарий sink.go:406-409 о собственном коде неверен обеими фразами (запрещённое направление называет разрешённым, «shows up as more chapters finished» не случается). Подтверждено overlay-прогонами воркфлоу. Корень продуктовый — смена формы конвейера на живой книге; лечение за решением владельца/оркестратора |
open | воркфлоу-ревью P9 28.08 (линзы bar:stage-caption · bar:double-count · bar:monotonicity), диспозиция оркестратора 28.08: строкой |
| PD-404 | bug | major | internal/pgstore/sink.go:415 recordWaveShape, internal/pgstore/readmodel.go:388 finishedUnits (живой флаг), потребители bookColumns.chaptersDone и ReadBookForRun.ChaptersLeft |
Единственное разрешённое движение флага (false→true, оператор ДОБАВИЛ редактора) уводит Book.chapters_done НАЗАД в пределах одного structure_version — то, что канон запрещает прямо. Книга с начерченным заделом: карточка 7/10; первое progress-событие прогона с редактором переворачивает флаг, finishedUnits переезжает на edit-колонку — 7 → 0 одной транзакцией при РАСТУЩЕЙ ревизии (клиент обязан отрисовать спад), ChaptersLeft раздувается обратно и шкала снова предлагает купить купленное. Класс PD-316 (записана fixed) на непокрытом триггере: её пин ловит только admission, а не движение самого флага. Крайний случай той же арифметики: книга, начерченная ПОЛНОСТЬЮ под false, редактором НЕдочитываема ни одним действием API — ChaptersLeft=0 не допускает прогон, а флип случается только в progress-событии допущенного прогона. Подтверждено воркфлоу на in-tree фикстуре (sink_test.go:701) |
open | воркфлоу-ревью P9 28.08 (линзы race:flip-read-consistency · bar:stage-caption · bar:baselines-x-migration), диспозиция оркестратора 28.08: строкой |
| PD-405 | bug | major | internal/pgstore/sink.go:219 (ложный комментарий-обоснование), internal/books/parse.go (инлайн-материализация после коммита not_started), internal/pgstore/books.go BooksOwedReadModel (nothingIsRunning) |
Прогон над книгой без материализованного дерева читает 0/total ВСЮ ЖИЗНЬ при исправно доезжающих progress-событиях — а комментарий, которым этот случай объявлен закрытым, описывает архитектуру, которой больше нет. Окно штатное: интейк коммитит not_started ДО материализации; если она упала/отложилась и пользователь успел стартовать прогон, долг дерева заморожен до конца прогона (nothingIsRunning), unitDone находит 0 строк глав и выходит, вся полоса выведена из chapters → 0/total до ready. Комментарий sink.go:219 «the counters the screen reads today come from the progress event» ложен — эти колонки не читает никто (носитель — PD-411). Достижимость ЗАМЕРЕНА логом живого стенда: на здоровом пути окно «not_started без строк chapters» живёт 0.018 с (обе загрузки пробоя P9: book parsed → the reading surface was refreshed, 0.0179 и 0.0189 с) — опасная форма только УПАВШАЯ/отложенная материализация, и тогда длина окна = вся жизнь прогона. Лечение — честная привязка полосы к бездеревному окну (отказ старта до дерева ИЛИ полоса из progress-событий как раньше); НЕ легло фикс-раундом — открыта целиком, замер достижимости ратифицирован актом D39.162 |
open | воркфлоу-ревью P9 28.08 (линзы bar:stage-caption · bar:double-count), диспозиция оркестратора 28.08: строкой, достижимость замером (акт D39.162) |
| PD-406 | bug | major | internal/httpapi/stream.go:137=helloID := state.Position |
Служебный кадр hello на ПЕРЕПОДКЛЮЧЕНИИ несёт id = state.Position (голова истории книги) вместо предъявленного клиентом last — и переводит Last-Event-ID браузера ВПЕРЁД через кадры, которые это соединение ещё не отдало. По WHATWG поле id фиксируется в last event ID buffer на диспатче; обрыв сразу после hello (прокси, вывод инстанса из ротации, ошибка первого ReadFrames) теряет кадры last+1..Position НАВСЕГДА — включая note, которые контракт запрещает терять («MUST NOT be coalesced or dropped»), без resync_required; усилитель — revision в data того же hello выше потерянных строк, так что предписанный контрактом дельта-догон ?after_version= возвращает пусто, обезоружены обе починки сразу. Это ровно вариант, который 14-api-contract/README.md (§Last-Event-ID) называет отвергнутым. Лечение — одна строка: при resuming слать hello с last (свежее подключение оставить как есть); код четырьмя строками ниже уже делает ровно этот выбор для from (stream.go:122-126). Свежий коннект не задет — там клиент читает коллекции целиком ⚠ ЗАКРЫТА фикс-раундом P9 (28.08, акт D39.162): при resuming hello несёт last клиента (свежий коннект — голову, как и from ниже); пин TestAResumingHelloCarriesTheClientsOwnWatermark (+ посадка «hello обратно на голову» поймана) |
fixed(фикс-раунд P9, D39.162) | воркфлоу-ревью P9 28.08 (линза race:flip-read-consistency), лечение по слову оркестратора 28.08 |
| PD-410 | bug | major | internal/pgstore/readmodel.go:460 draftWork (и вся модель «купленный диапазон в обеих волнах»), контраст: движку передаётся ТОЛЬКО --ceiling-usd (internal/runs/spawn.go bookCap), draft-волна фанится по ВСЕМ чанкам книги (backend/internal/pipeline/waverun.go) |
Полоса и платёжная модель считают, что прогон работает над диапазоном «C глав от edit-фронта» в обеих волнах, а движок гонит волны последовательно от СВОИХ фронтов до денег — и на continuation над недочерновленной книгой (draft_before ≥ chapters_before + C) формула даёт draftWork=0: движок тратит весь потолок на черновики, полоса стоит 0/C весь прогон со stage editing на прогоне, который только черновит. Зеркало дефекта, который draftWork чинил (тот закрывал «ready на 50%», этот открывает «0% при сделанной работе»). Это НЕ локальная ошибка формулы: любая полоса из (баз, C, D, E) где-то соврёт, пока платёжная модель («довести до конца C глав от e0») и движковый план работ («деньги в порядке волн от фронтов») не согласованы — лечение требует либо передавать движку план (диапазон/волны), либо выводить total полосы из движкового плана; ceiling_chapters сегодня до движка не доезжает вовсе. Архитектурное, ОТДЕЛЬНЫМ паком по слову оркестратора 28.08 («остановись и скажи»); подтверждено воркфлоу арифметикой на существующем зелёном пине (TestARunOverADraftedBacklogOwesOnlyTheLastPass держит draftWork=0 в родственной точке) |
open | воркфлоу-ревью P9 28.08 (линза bar:double-count), стоп-решение сессии P9 28.08: тянет архитектуру, не чинится в паке |
Открытые — minor
| ID | Класс | Серьёзность | Где | Суть | Статус | Источник |
|---|---|---|---|---|---|---|
| PD-375 | bug | minor | internal/runs/runs.go:323=if in.CeilingChapters < bounds.Min, internal/httpapi/v0_test.go:359, internal/runs/control_test.go |
Верхнюю половину проверки ceiling_chapters не исполняет НИ ОДИН тест: снятие второго операнда in.CeilingChapters > bounds.Max проходит ВСЮ батарею. Собственный доккомментарий называет проверку несущей — «число, решающее СКОЛЬКО ДЕНЕГ резервируется, не может быть выбрано вызывающим односторонне», — и канон требует того же от сервера (max_chapters уже прижат к остатку, «a client MUST NOT clamp it again»). Единственный тест, называющий ErrCeilingOutOfBounds, это таблица соответствия ошибки коду 409 в httpapi/v0_test.go, которая Start не зовёт, а самая тесная фикстура просит 10 глав у книги, где их 100. Код сегодня ВЕРЕН; дефект в том, что править эту строку можно безнаказанно. Замерено на живом стенде: чистая сборка отвечает 409 ceiling_unavailable/bounds_moved, сборка с мутацией отдаёт 202 и открывает резервацию 24000000 микро на книге из ТРЁХ глав, а движку уходит --ceiling-usd 24.200000. Вес: рефутер сузил major → minor, потому что код верен и ни один сегодняшний клиент до вреда не доходит. Воспроизведение: docs/p8-review/axis1-money/a1-mut5-ceiling-bound.sh, готовый пин docs/p8-review/axis1-money/r1_ceiling_bound_test.go.txt |
open | ревью-пак P8-REVIEW, ось 1 (посадка мутации + живая проба, подтверждено рефутером) |
| PD-377 | doc | minor | internal/pgstore/credits.go:163=The ceiling to hand the engine is the amount held, internal/runs/spawn.go bookCap, cmd/tmplatformctl/main.go balance |
Доккомментарий Hold на входе в денежный путь неверен ОБЕИМИ половинами с миграции 00011. Он обещает «the ceiling to hand the engine is the amount held; read it back with OpenReservations». Движку передаётся не сумма холда, а run_attempts.ceiling_arg_micro_usd = committed книги плюс прирост (spawn.go bookCap), и сама 00011 говорит это прямым текстом («It is NOT ceiling_micro_usd»); начиная со ВТОРОГО прогона книги числа расходятся тем сильнее, чем дороже книга — на стенде до 17 раз (60000 холда против 1060000 в аргументе). Вторая половина не работает даже механически: Reservation.Ceiling — поле, которое только сканируется и не читается никем, единственный потребитель OpenReservations печатает Amount/BookID/OpenedAt/EngineRunID. ⚠ Рефутер снял два из трёх исходных якорей: доккомментарии в применённых миграциях 00007/00009 зона не правит после лендинга (у всех 25 файлов миграций ровно по одному коммиту), а исправление уже лежит в следующем файле того же каталога. Остаётся Go-доккомментарий. Воспроизведение: docs/p8-review/axis1-money/a1-ceiling-column-doc.sql |
open | ревью-пак P8-REVIEW, ось 1 (живой стенд, сужено рефутером до одного якоря) |
| PD-386 | bug | minor | cmd/tmplatformd/runner.go:269=pass("runs", sweepBudget, s.runs.Sweep) и четыре следующих прохода того же one(), cmd/tmplatformd/runner_test.go |
Такт свипа последователен, и бюджеты пяти проходов СКЛАДЫВАЮТСЯ: сумма объявленных — 37 минут при TM_PLATFORM_SWEEP_EVERY 15 секунд. Пак P8-FIX дал каждому проходу свой бюджет и закрыл «один проход съедает дедлайн другого», но проходы по-прежнему идут подряд в одной горутине: runs 2м, readmodel 10м, intake 21м, idempotency 2м, observe 2м. Ничто эту сумму не ограничивает и ничто её не пинит — у функции sweep нет ни одного теста (в пакете два теста, оба про другое), и посадка «фаза runs уходит из головы такта в хвост» пережила ПОЛНУЮ батарею. Замерено пробой на реальной функции: за 4 секунды при такте 100 мс фаза прогонов отработала 40 раз сама по себе и 9 раз позади прохода материализации. Следствия по оси: калибровки самого лечения заданы в проходах и минутах и молча растягиваются (StalledAfter 5 «неудач подряд» и бэкофф, про который комментарий обещает «about a quarter of an hour», превращаются в часы); обещание Stop «the reconciler re-issues the stop on its next pass» задерживается на ту же величину; телеметрия стоит ПОСЛЕДНЕЙ. ⚠ Рефутер сузил: 37 минут — сумма ОБЪЯВЛЕННЫХ бюджетов, а не достижимая длительность (idempotency это один индексированный DELETE, а проход интейка сам себя режет). Воспроизведение: docs/p8-review/axis3-queue/probe_sweep_serialisation_test.go.txt |
open | ревью-пак P8-REVIEW, ось 3 (проба на реальной функции + посадка мутации, сужено рефутером) |
| PD-387 | doc | minor | deploy/README.md:288=сколько всего может занять один проход свипа, cmd/tmplatformd/runner.go one, internal/config/config.go SweepBudget |
Рантбук называет TM_PLATFORM_SWEEP_BUDGET ручкой «одного прохода свипа» и не говорит, КАКОГО из пяти, — а два бюджета из пяти оператору недоступны вовсе. Один такт прогоняет ПЯТЬ проходов подряд, и только три берут sweepBudget; проход материализации берёт refreshSweepBudget 10 минут, проход интейка — intakeSweepBudget = jobs.JobTimeout + readmodel.MaterializeBudget + минута = 21 минута, и обе константы оператору недоступны вовсе. Собственный доккомментарий кода при этом ТОЧЕН («SweepBudget is what ONE pass of the RUN sweep may take») — расходится именно операторская проза, и расходится в разделе «Застрявшая работа: что оператор делает, когда свип не справляется», то есть там, где по ней и будут действовать. Родня PD-368: та про то, что поднимать надо пару, эта про то, что ручка не покрывает такт ⚠ Заголовок ИСПРАВЛЕН после интервальной самоверификации, и первая редакция была завышена: она писала, что рантбук обещает ограничение ТАКТА целиком, а deploy/README.md:288 буквально говорит «сколько всего может занять один проход свипа» — то есть ровно то, что ручка и делает. Дефект уже и точнее: строка не называет, какой из ПЯТИ проходов такта она ограничивает, refreshSweepBudget и intakeSweepBudget не являются ручками вовсе, и раздел, в котором эта таблица стоит, — «Застрявшая работа: что оператор делает, когда свип не справляется». Арифметика 37 минут пере-проверена и держится: 3×2 + 10 + 21. Воспроизведение — docs/p8-review/axis3-queue/probe_sweep_serialisation_test.go.txt (последовательность тактов) плюс sed -n '264,272p' deploy/README.md |
open | ревью-пак P8-REVIEW, ось 3 (находка рефутера) |
| PD-389 | hardening | minor | cmd/tmplatformd/runner.go:320=s.metrics.ObserveRunner(metrics.Runner{, cmd/tmplatformd/runner.go pass, internal/metrics/metrics_test.go TestTheRunnersStateIsExposedWithItsUnits |
Шов телеметрии не покрыт НИЧЕМ, и это доказуемо без прогона батареи: sweep и observe — неэкспортируемые функции пакета main, то есть из другого пакета их не может вызвать ни один тест в принципе, а единственный тест-файл каталога несёт два теста, оба про другое. Проверено тремя посадками, пережившими полную батарею: StalledRuns: o.StalledRuns в ноль (наблюдаемая половина закрытого BLOCKER PD-346), errors.Is(err, context.DeadlineExceeded) в false (sweep_unfinished_total больше не может вырасти — PD-351 со стороны ВЫЗЫВАЮЩЕГО, куда пин TestAPassThatRanOutOfTimeSaysSo по построению не достаёт), и перестановка queue_depth с live_runs. Дыра шире шва: пин формы, на который ссылается STACK_DECISIONS §24, задаёт литерал metrics.Runner из ШЕСТИ полей из восьми — StalledRuns и AbandonedSurfaces в него не входят, поэтому мутация внутри самого ObserveRunner тоже выживает. Пере-проверено координатором пака независимо: снятие m.stalledRuns.Set(...) и снятие m.abandonedSurfaces.Set(...) по отдельности проходят ПОЛНУЮ батарею (18 пакетов), при том что снятие соседнего инкремента sweepUnfinished тем же пином ловится. То есть операторская ручка, построенная паком P8-FIX в ответ на PD-169, не пиньётся ничем. Воспроизведение: docs/p8-review/mutations-full.log и docs/p8-review/axis4-metrics/60-mutations.sh |
open | ревью-пак P8-REVIEW, ось 4 (посадки финдера, рефутера и координатора) |
| PD-390 | hardening | minor | cmd/tmplatformd/runner.go:317=log.Warn("the control plane's own state could not be read", "err", err), internal/pgstore/observe.go Observe, internal/metrics/metrics.go ObserveRunner |
Гейджи замирают при отказе телеметрического чтения, и признака устаревания в экспозиции нет. STACK_DECISIONS §24 объявляет «значения снимает СВИП, а не скрейп», но у снятого значения нет ни отметки свежести, ни счётчика неудач: observe() при ошибке пишет один WARN и возвращается, НЕ тронув ни одного гейджа, а Prometheus такой ряд устаревшим не помечает — цель жива, ряд на месте, значение старое. Живой замер: при сломанном чтении и одновременно вылеченном мире экспозиция продолжала утверждать «1 застрявший прогон, 1 карантин, 1 живой прогон, холд возрастом 1201 с», тогда как в базе застрявших было 0; при этом sweep_duration_seconds_count рос по всем четырём проходам, то есть все «жив ли свип» сигналы оставались зелёными. Observe — ОДИН стейтмент на все восемь чисел, поэтому любая его поломка гасит все гейджи разом, а сам observe() вызывается ВНЕ pass(), поэтому своего ряда в sweep_duration_seconds у него нет и его отказ там не виден. Обратное направление хуже: процесс, у которого чтение не удалось НИ РАЗУ, отдаёт нули как здоровье, и /readyz с /healthz при этом зелёные. ⚠ Вторая половина того же корня, найденная рефутером: инстанс-ЧИТАТЕЛЬ (пустой TM_PLATFORM_ENGINE_BIN — объявленная форма деплоя) не запускает свип вовсе, observe() не зовётся ни разу, а метрики созданы раньше и регистрируют все восемь гейджей безусловно, поэтому реплика уверенно отвечает runs_stalled 0, queue_depth 0, oldest_open_hold_seconds 0 про контрол-плейн, который она не измеряет — и тут нет даже WARN-строки. Лечится дёшево: *_last_success_timestamp_seconds либо счётчик неудач наблюдения плюс проведение observe через тот же pass. Воспроизведение: docs/p8-review/axis4-metrics/30-stale-gauges.sh, r1-boot-with-blind-telemetry.sh, r6-read-replica-zeroes.sh |
open | ревью-пак P8-REVIEW, ось 4 (живой замер, расширено рефутером) |
| PD-392 | hardening | minor | internal/metrics/metrics.go:80=Namespace: namespace, Name: "quarantined_attempts", internal/metrics/metrics.go oldest_open_hold_seconds, cmd/tmplatformctl/runs.go listRuns |
Две метрики без ручки — тот самый класс, за который PD-169 стоял BLOCKER'ом, в двух других местах. Help гейджа карантина сам называет цену («Such a run keeps going and keeps spending»), но команды, называющей строку за этим числом, нет: grep -rn quarantine cmd/ не даёт ни одного хита, и в таблице runs колонки карантина тоже нет. У возраста холда ручка формально есть — balance --user, — но она требует идентификатор аккаунта, которого гейдж не даёт, а документированный случай самого гейджа («A hold outlives its run only when a settlement could not be made») — это холд ЗАКОНЧЕННОГО прогона, которого список не показывает по построению. Живая проба на состоянии, произведённом ШТАТНЫМ операторским сценарием (abandon застрявшего прогона): при tm_platform_oldest_open_hold_seconds 10813 команды отвечают «no run is live», «no run is failing to reconcile» и «no book has been given up on», а единственный путь к строке — psql, то есть ровно то, что эти числа заводились заменить. Глобального списка открытых холдов в CLI нет. Воспроизведение: docs/p8-review/axis4-metrics/50-gauges-without-a-handle.sh и r5-hold-without-a-handle.sh ⚠ Общий корень с PD-385, и там же он взвешен: сужение ЭТОЙ строки опирается на операторскую поверхность, несостоятельность которой доказывает соседняя строка того же пака — круговое сужение разобрано в PD-385, поднятой до major |
open | ревью-пак P8-REVIEW, ось 4 (живая проба, подтверждено рефутером) |
| PD-89 | hardening | minor | cmd/tmplatformctl/main.go:143-150 |
Сминченный ключ идемпотентности не печатается при ошибке записи: PD-75 закрыл путь ПОСЛЕ коммита, но неоднозначный обрыв НА коммите остался — оператор видит ошибку, повторяет без --key, newKey() чеканит новый ключ, второе начисление проходит. Фикс: печатать ключ вместе с ошибкой, чтобы повтор был с тем же --key |
open | приёмка P2 (панель) |
| PD-101 | bug | minor | internal/login/login.go:507 |
login_events.ip_prefix берётся из r.RemoteAddr, а в задуманном деплое перед сервисом стоит edge-прокси ⇒ префикс всегда сеть прокси. Журнал входов заведён как ответ на «откуда примерно я входил» — в шипуемой форме он систематически отвечает неверно. X-Forwarded-For/Forwarded нигде не читаются и доверенного прокси в конфиге нет (это правильный дефолт: доверять заголовку без edge нельзя) — значит решение про edge и про этот столбец принимается вместе ⚠ ПАК P8-REVIEW 24.08: якорь дрейфанул и носителей ДВА. internal/login/login.go:507 сегодня это h.mu.Lock() внутри checkIssuer; чтение адреса живёт на :527=ev.IPPrefix = ipPrefix(r.RemoteAddr), и второй, строкой не названный, — internal/login/dev.go:195 с тем же выражением. Суть верна |
open | приёмка P2 (панель) |
| PD-102 | doc | minor | internal/httpapi/serve.go:36-38 |
Доккоммент DefaultTimeouts утверждает, что «an upload extends its own deadline as it makes progress» — это НЕВЕРНО: ReadTimeout в net/http (Go 1.26.5, server.go:990 wholeReqDeadline = t0.Add(ReadTimeout)) выставляется один раз и по мере прихода байтов не продлевается. Комментарий несущий: он объясняет, почему Read короткий, и на нём будущая ручка загрузки книги (23 МБ по контракту) построит неверное ожидание — ей понадобится собственный дедлайн через ResponseController, а не «прогресс продлевает» |
open | приёмка P2 (панель, сверено с исходником Go) |
| PD-103 | hardening | minor | internal/auth/middleware.go:43,66 |
У обращений к БД на аутентифицированном пути (Lookup/Touch) нет собственного дедлайна — только голый r.Context(), а WriteTimeout у сервера отсутствует по проекту (SSE) и TimeoutHandler в цепочке нет. Зависший Postgres паркует хендлеры и ждущих в пуле, пока клиент сам не уйдёт. readyz свой таймаут получил (PD-14) — горячий путь нет |
open | приёмка P2 (панель) |
| PD-115 | standards | minor | docs/ENGINEERING_STANDARDS.md §2 |
Внешняя версионированная базовая линия объявлена ровно для ОДНОЙ оси — безопасности (ASVS 5.0 L2 + OWASP API Top-10 2023, с указанием глав). Отказоустойчивость, наблюдаемость и контракт-первичность описаны собственной прозой зоны без внешнего эталона, а конфигурация, релиз/откат, ёмкость и восстановление не описаны вовсе. Разница не теоретическая: PD-57 и PD-58 нашлись ИМЕННО сверкой кода с RFC 9700/9207 и NIST SP 800-63B — механизм работает там, где эталон есть, и не может сработать там, где его нет. Грепнуто на 08.08: метрик и трейсинга ноль (ни prometheus, ни otel, ни expvar, ни pprof), процедуры бэкапа/восстановления в deploy/README.md нет, SLO не заданы. Предложение зоны: §2 получает по эталону на ось (наблюдаемость, ops, конфигурация) — направление РАТИФИЦИРОВАНО 09.08 (оркестратор №15 по делегации владельца); носитель работы — эта строка, исполнение — своими паками ⚠ Уточнено паком P5: ось НАБЛЮДАЕМОСТИ эталон получила — практики именования Prometheus (базовые единицы, _total у счётчиков, единица не в лейбле) плюс «четыре золотых сигнала» на вопрос «что мерить», записано в STACK_DECISIONS §24 и пинится metrics.TestTheRunnersStateIsExposedWithItsUnits. Оси ops/конфигурация/восстановление эталона по-прежнему не имеют — строка открыта ими |
open (наблюдаемость закрыта P5; ops и конфигурация — нет) | абстрактный вопрос владельца 08.08 + сессия P4 |
| PD-157 | bug | minor | internal/runs/spawn.go, cmd/tmplatformctl/runs.go book add |
ДНЕВНОЙ потолок книги --ceiling-usd не перекрывает, а платформа его не видит и не задаёт. Движок требует хотя бы один из book_usd/day_usd (backend/internal/config/book.go:321, Р7 — ⚠ якорь пере-нацелен оркестратором №19 при лендинге 27.08 с :250: требование уехало на 321 из-за лендинга бэкенда d1eb8a9, не из-за правки платформы), флаг переопределяет только книжный (D39.122 прямо: «День-потолок не перекрывается»), а book.yaml пишет ОПЕРАТОР — платформа его не правит (D39.110 §2b) и в book add только проверяет наличие файла. Значит книга с низким day_usd останавливает прогон на лимите, которого платформа не выбирала: движок выходит кодом 1 (тот же путь, что у PD-113), прогон приезжает failed, а деньги пользователя целы и он не понимает, почему. В status --json дневной фигуры нет вовсе (есть book_ceiling_usd/ceiling_pct), поэтому даже диагностировать это платформа сегодня не может. Заведено, не построено: закрывать — либо проверкой day_usd при заведении книги, либо словом контракта о том, кто владеет потолками book.yaml у книг под платформой ⚠ ПОЛОВИНА ЗАКРЫТА (P6): дневной потолок стал РАЗЛИЧИМ. Поток несёт ceiling.scope со значениями book и day (D39.131), платформа хранит внутреннюю причину daily_ceiling (миграция 00015) и НЕ проецирует её как credit_exhausted — иначе экран сказал бы «кончились деньги» об аккаунте, на котором деньги есть, и зажёгся бы аккаунт-флаг ReadUsage. Резюм такого прогона отвечает 409 с диагностикой, а не гоняет попытки в цикл: day_usd живёт в book.yaml оператора, платформа его не ставит и поднять не может, а граница дня принадлежит ледджеру ДВИЖКА — таймер здесь был бы догадкой, которая тратит спавны. ОСТАЁТСЯ открытым то, ради чего строка заведена: платформа по-прежнему не выбирает и не видит day_usd (в status --json его нет), поэтому книга с низким дневным потолком остановится на лимите, которого никто на этой стороне не назначал. Пины: pgstore.TestTheCeilingScopeDecidesWhichPauseTheRunGets · runs.TestAResumeOfARunTheEnginesDailyCeilingStoppedIsRefused · httpapi.TestOnlyTheContractsOwnPausedReasonReachesTheWire ⚠ ПАК P8-REVIEW 24.08: имя пина в этой строке МЁРТВОЕ. go test ./internal/runs/ -run TestAResumeOfARunTheEnginesDailyCeilingStoppedIsRefused даёт no tests to run; свойство запинено под другим именем — internal/runs/seam_test.go:146=func TestAResumeOfARunPausedAtALimitIsRefusedWhoseverLimitItWas, переименование приехало коммитом 9b23e8c. Суть строки верна и не оспаривается: day_usd платформе по-прежнему невидим ⚠ Дополнено рефутером: контракт 0.3.0 РАСШИРИЛ свойство — резюм отбивается ErrCeilingReached на ЛЮБУЮ паузу, а различение day против credit пинится не этим тестом, а pgstore.TestTheCeilingScopeDecidesWhichPauseTheRunGets и httpapi.TestOnlyTheContractsOwnPausedReasonReachesTheWire. Посадка мутации (снят ErrCeilingReached в ветке paused) роняет пин под НОВЫМ именем — свойство держится |
open | собственная сверка шва при F1 (чтение движка + D39.122) |
| PD-162 | bug | minor | internal/runs/spawn.go journalSize, internal/runs/reconcile.go |
Книга, чей каталог удалён или перемещён, принимает прогон и заклинивает его навсегда с открытым холдом. journalSize мапит ENOENT в «ноль, ошибки нет» (законно для первого прогона), поэтому Start отдаёт 202 и берёт холд; дальше bookMeter вечно падает (спавн отказывает), либо расчёт вечно откладывается — ни один путь не приходит к терминальному состоянию: прогон вечно translating, деньги вечно в холде, пользователю видно только «идёт». Дизайн «холд лучше догадки» осознан (строка 136), но отсутствие И валидации каталога на старте, И эскалации после N неудач — дыра. Закрывать вместе с эскроу/uncertain либо проверкой каталога при допуске. ⚠ Ре-чек V2 расширил КЛАСС строки: обязательность reserved_usd даёт тот же клин без всякого удаления каталога — прогон, запиненный к СТАРОЙ сборке движка (строка 139), у которой поля ещё нет, вечно отказывает спавну с открытым холдом. Лечится тем же терминальным состоянием после N неудач; отказ сам по себе верен (без цифры потолок считать нечем) ⚠ Сужено паком P5 с одной стороны и НЕ закрыто с другой: у ИНТЕЙКА терминальное состояние после N неудач теперь есть (books.parseAttempts = 5 → rejected с причиной parser_unavailable), и прогон на книге, не прошедшей интейк, отвергается до денег (runs.ErrBookNotReady). Клин ЖИВОГО прогона на удалённом каталоге не тронут: там нужен тот же счётчик неудач на стороне реконсилятора либо эскроу-половина строки 136 ⚠ Дополнено кросс-семейным ревью: терминальность интейка теперь НЕ применяется к not_configured — книга без конфигурации ждёт в parsing вместо уничтожения, потому что это состояние ДЕПЛОЯ, а не книги. Цена названа: пока развилка book.yaml не ратифицирована, такие книги копятся, и видит их метрика tm_platform_books_in_intake{status="parsing"} ⚠ ДИСПОЗИЦИЯ P7: не бралась. Клин живого прогона на удалённом каталоге лечится терминальным состоянием после N неудач у реконсилятора, а это половина эскроу (строка 136) — строить её рядом с проектируемым целым значит ставить второй, более слабый ответ на тот же вопрос. P7 добавил к строке одно наблюдение: материализатор читающей поверхности зовёт движок в том же каталоге и на ту же ошибку отвечает логом, не трогая деньги ⚠ ПАК P8-REVIEW 24.08: терминальный путь для ПОЛОВИНЫ строки ПОСТРОЕН. internal/pgstore/runs.go AbandonRun плюс tmplatformctl run abandon --run <id> --reason <text> [--release-hold], диагноз — runs --stalled и гейдж tm_platform_runs_stalled (коммит 31f1f82); зонный бэклог это знает строкой П-20, регистр нет. Гейд abandon пропускает ровно ту половину, где спавн ОТКАЗЫВАЕТ (попытка до движка не дошла) — она теперь и считается, и закрывается рукой вместе с холдом. Живым остаётся клин прогона, у которого ЕСТЬ юнит или базовая линия. Предложение пака: сузить до него. ⚠ И оговорка, которая сужение ограничивает: обещание «холд вернётся на ближайшем свипе» для этой популяции неверно — PD-391 ⚠⚠ МОЯ ДОПИСКА ВЫШЕ ЗАВЫШЕНА, сужаю по рефутеру. «Терминальный путь ПОСТРОЕН» неверно: построены ДИАГНОЗ (runs --stalled, гейдж) и РУЧНОЙ вердикт оператора для половины «спавн отказал». Автоматического терминального состояния после N неудач НЕТ и оно не планируется вне эскроу — это записанное решение (internal/runs/reconcile.go:249-257 «what it must not buy is this platform deciding on its own»). Проба end-to-end на стенде, восемь проходов при отказывающем движке: status=translating unit="" failures=8, гейдж 1, reserved=0.300000 — прогон висит и деньги заморожены, пока не придёт человек. Плюс возврат холда только с флагом: без --release-hold холд ждёт до 30 минут (PD-391). Значит сужать строку до «клина с юнитом или базовой линией» НЕЛЬЗЯ: беспризорный деплой на удалённом каталоге по-прежнему висит вечно ⚠ Границы улики: числа пробы (failures=8, гейдж 1, reserved=0.300000) сняты рефутером на его стенде, и ЛОГ прогона в артефакты не попал — пере-ранить их приёмка не сможет, воспроизводить придётся по описанию. Механизм при этом проверяем чтением: internal/pgstore/runs.go:567=select finished_at from runs where id = $1 ⚠ якорь пере-нацелен паком P11: прежняя строка отвечала ErrNoRun законченному прогону — она и была дефектом; теперь это ветвление, читаемое ПОД блокировкой книги |
open | самопроверка дофикса (ревью вне карты) |
| PD-168 | bug | minor, деньги | internal/runs/reconcile.go restart |
Бюджет перезапуска пересчитывается по ТЕКУЩЕЙ ставке, а не по той, под которую брался холд: s.Pricing.Ceiling(l.CeilingChapters) читает конфигурацию нынешнего деплоя. Смена TM_PLATFORM_USD_PER_CHAPTER между допуском и перезапуском ломает обе стороны — вверх: резервируется больше, чем пользователь видел на шкале (нарушение «явного согласия на оплату»); вниз: остаток уходит в минус и прогон ошибочно встаёт paused/credit_exhausted. Замерено верификатором: при удвоении ставки перезапуск зарезервировал $5.50 вместо $2.50. Исходная сумма восстановима без пересчёта — она лежит в холде первой попытки (reservations.ceiling_micro_usd / run_attempts.ceiling_micro_usd) ⚠ ПАК P8-REVIEW 24.08: якорь дрейфанул, и дефект ШИРЕ записанного. Пересчёт живёт не в restart, а в internal/runs/reconcile.go:1252=budget := s.Pricing.Ceiling(l.CeilingChapters) внутри reopen, и у reopen ДВА вызывающих — реконсиляторный restart и контрактный Resume. То есть смена TM_PLATFORM_USD_PER_CHAPTER между допуском и продолжением бьёт и по пользовательскому резюму, а строка описывает только перезапуск ⚠ Охват уточнён рефутером и ЗАМЕРЕН на стенде: Resume доходит до reopen только из stopped и awaiting_bank (paused отбивается раньше, reconcile.go:1225). Удвоение ставки между допуском и ПОЛЬЗОВАТЕЛЬСКИМ резюмом дало холд 5.500000 вместо ожидаемых 2.500000 — то есть денежный путь дёргает КЛИЕНТ, а не только реконсилятор |
open | самопроверка дофикса (два верификатора, один исполнением) |
| PD-175 | hardening | minor | internal/books/, internal/httpapi/v0.go createBook |
Квоты на интейк нет: аутентифицированный аккаунт может писать на диск оператора неограниченно. Пер-маршрутный потолок (PD-72) ограничивает ОДИН аплоад (64 МиБ по умолчанию), число аплоадов — ничто: ни лимита книг на аккаунт, ни ретеншена. Отклонённая по вине ИСТОЧНИКА книга свой файл теряет (каталог удаляется), а отклонённая по вине ДЕПЛОЯ — сохраняет намеренно (удалять чужую загрузку из-за своей поломки нельзя), и такие каталоги не чистит никто. Лечится квотой на аккаунт плюс свипом ретеншена по rejected; и то и другое — продуктовая политика (сколько книг входит в фри-тир), поэтому заведено, а не выбрано зоной ⚠ Третья половина того же вопроса — УДАЛЕНИЕ книги: pgstore.DeleteBook убирает строку и закрытые резервации и НЕ трогает каталог книги на диске, а ручки удаления в контракте нет вовсе (гейт PD-122). То есть сегодня утечки нет, потому что удалять нечем; день, когда ручка появится, — это и день, когда каталог обязан уходить вместе со строкой, и гард «только под BooksDir» для этого уже есть (books.owns). Диспозиция: закрывать ВМЕСТЕ с ручкой удаления, не раньше и не позже ⚠ Дополнено адверсариальным ревью P5 двумя фактами, которые делают строку острее, чем она написана: (1) пока развилка book.yaml не ратифицирована, ЛЮБАЯ загрузка приходит к rejected/not_configured — то есть путь «интейк пишет на диск и никто не убирает» сегодня ординарный, а не краевой; (2) у rejected нет ВЫХОДА вовсе: ни перепарса, ни удаления в контракте нет, строка остаётся в библиотеке навсегда. Обе половины закрываются тем же — квотой на аккаунт плюс свипом ретеншена, и обе продуктовые ⚠ Дополнено кросс-семейным ревью (Fable, 11.08): крэш-окно «строка закоммичена — каталог ещё не снесён» оставляет каталог-сироту, которого не найдёт никто (StuckIntake берёт только uploading и parsing). У отказа порядок перевёрнут в самоизлечивающийся — сначала каталог, потом строка, — а у брошенной загрузки перевернуть нельзя (каталог можно сносить только убедившись, что строки нет), так что окно там остаётся и закрывается тем же свипом ретеншена, что и вся строка |
open | сессия P5 (самопроверка, ось «что этот маршрут создаёт») |
| PD-367 | bug | minor | internal/books/parse.go:129, internal/ingest/manifest.go Whole |
Пол на самосогласованность манифеста стоит только у материализатора, а интейк тот же документ ПРИНИМАЕТ. Манифест {ChaptersTotal: 120, UnitsTotal: 400} с пустым списком глав Whole() отвергает, а books.Parse заводит книгу not_started с chapter_count=120 и пустым деревом — по такой книге можно СТАРТОВАТЬ и ОПЛАТИТЬ прогон (потолок считается от chapter_count). Воспроизведено ревью на живом Postgres. ⚠ Лекарство не построено и это решение, а не недоделка: контракт интейка с манифестом — только счётчики (FinishParse берёт три поля, дерево объявлено ОТДЕЛЬНОЙ границей со своим долгом), и вся батарея интейка ездит на документах без списка глав (books_test.go:145), то есть Whole() в parse.go разворачивает запиненный контракт. Нужно решение владельца/оркестратора: расширять контракт интейка или оставить асимметрию осознанной |
open | воркфлоу-ревью волны 2 (P8-FIX); рефутер подтвердил механику и опроверг предложенное лекарство |
| PD-369 | bug | minor | internal/pgstore/idempotency.go ClaimIdempotency, claimRounds |
Легитимный запрос получает 500 под конкуренцией на одном ключе идемпотентности. Ретрай проигранной гонки ограничен claimRounds = 3, а восемь одновременных попыток одного ключа, каждая из которых сразу отдаёт его назад (как делает любой 4xx), могут отобрать гонку у одного и того же проигравшего трижды подряд — и он получает не один из четырёх контрактных ответов, а внутреннюю ошибку. Замерено: 1–2 падения на ~80 прогонов собственного теста TestAClaimThatLostARaceToAReleaseIsRetriedAndNotAnError (то есть тест ФЛЕЙКОВЫЙ, и это сам сигнал). Комментарий над константой признаёт границу («a caller that loses three rounds is meeting something other than a race») — но восемь racer'ов и есть гонка, а не «что-то другое». ⚠ ВНЕ СКОУПА пака P8-FIX и НЕ ТРОГАЛОСЬ: файл не в диффе пака (git diff пуст, последний носитель — 9b23e8c); найдено попутно при прогоне батареи. Направление, не решение: либо граница по ВРЕМЕНИ вместо числа раундов, либо ответ ErrKeyInFlight вместо 500 при исчерпании |
open | попутная находка сессии P8-FIX (флейк батареи), передано оркестратору |
| PD-371 | bug | minor | internal/pgstore/runs.go RunsToReconcile, internal/runs/reconcile.go deferItem |
Исключение «стоп перевешивает отсрочку» обходит отсрочку БЕЗ ГРАНИЦЫ, и для прогонов с запрошенным стопом голодание PD-169 внутри фазы возвращается. Прогон, чей stop_requested_at не пуст, выбирается КАЖДЫМ проходом независимо от reconcile_after, сколько бы раз подряд он ни падал. Измерено живьём при приёмке (дев-демон на дереве пака, хост без пользовательской шины systemd, поэтому Runner.Alive падает по-настоящему): reconcile_after стоял на ~30 минут вперёд (NEXT TRY 14:21:21Z), а FAILS дорос до 6 за ~90 секунд, то есть на каждом 15-секундном такте — отсрочка не действовала ни разу. На ДЕШЁВОЙ ошибке это безвредно и было именно так в пробе. На дорогой (шина или чтение журнала книги висит до конца бюджета) прогон снова держит голову списка весь бюджет фазы реконсиляции, а класс запускает ЛЮБОЙ пользователь кнопкой «остановить». Что смягчает и почему это не блокер: расчёт денег живёт во ВТОРОЙ фазе и не страдает (это и есть половина лечения PD-169), счётчик всё равно растёт, гейдж tm_platform_runs_stalled и run abandon работают — проверено той же пробой. Комментарий у RunsToReconcile называет цену НЕ-исключения («стоп ждал бы истечения бэкоффа») и не называет цену исключения. Направление, не решение: исключать до пересечения StalledAfter, а дальше подчинять стоп общему бэкоффу — переиздание стопа идемпотентно, и на пятой неудаче подряд «переиздать немедленно» уже ничего не покупает |
open | приёмка P8-FIX (живая проба оркестратора №18, вне карты пака) |
| PD-372 | bug | minor | internal/pgstore/runs.go DeferRun, internal/pgstore/books.go truncateReason, internal/pgstore/isolation_test.go |
Починку текста ошибки пинит только САМА функция, но ни один из четырёх её вызовов. TestAnEnginesOwnErrorTextSurvivesBeingRecorded зовёт truncateReason напрямую и доказывает, что Postgres принимает её результат, — а того, что вызывающий её ЗОВЁТ, не проверяет ничто. Посажена мутация оркестратором вне списка автора: truncateReason(reason) → reason в DeferRun — батарея (./internal/pgstore/ + ./internal/runs/) осталась ЗЕЛЁНОЙ. Цена ровно та, которую комментарий этой же функции называет вслух: невалидный UTF-8 из stderr движка Postgres отвергает, запись отказа не проходит, счётчик не растёт и попытка держит голову списка вечно — то есть механизм PD-169 отключается тем самым текстом, ради которого заведён. Класс — PD-1 («свойство без пинящего теста не закрыто»), и он тут в форме «пин есть, но не на пути». Лечение дешёвое: провести один случай через DeferRun/DeferReadModelDebt и прочитать колонку назад |
open | приёмка P8-FIX (посадка мутации оркестратором №18) |
| PD-217 | bug | minor | internal/pgstore/books.go BooksForMigration, deploy/README.md |
Книга, застрявшая на daily_ceiling или на вечно незакрытом холде, блокирует апгрейд движка бессрочно, и выхода у оператора нет. Оба состояния снимаются только тем, чего платформа сделать не может: дневной потолок живёт в book.yaml оператора, и резюм по нему отказан 409; холд закрывается расчётом, который читает движок ЗАПИНЕННЫМ бинарём. Форсирующего флага у books --migratable нет намеренно — он и был бы способом пере-оплатить уже купленные вызовы. Лечится либо ручным разрешением в БД, либо каналом «признать попытку невосстановимой», которого в контракте нет ⚠ ПОЛОВИНА ЗАКРЫТА P7: paused вышел из предиката Resumable — с 0.3.0 прогон, остановленный ЛЮБЫМ потолком, resume продолжить не может вовсе (409 ceiling_reached), значит к старой сборке ничего не пришпилено и мигрировать такую книгу безопасно; лечение пользователя — НОВЫЙ прогон, который спавнится ТЕКУЩИМ бинарём. Книга на дневном потолке апгрейд больше не запирает. Связь названа в коде: вернётся резюм паузы — вернётся и предикат. Пин TestEachOfTheThreeBlockersAloneKeepsABookOutOfTheMigrationList (перечень «безопасных» проверяется целиком, а не по одной книге). ⚠ ОСТАЁТСЯ вторая половина: книга с вечно незакрытым холдом блокирует по-прежнему, и это правильно — расчёт читает движок запиненным бинарём |
open | приёмка P6 (дофикс, ФП-7) |
| PD-219 | bug | minor | internal/runs/reconcile.go drainJournal, reopen |
Перезапуск после упавшего дрейна теряет непримененный хвост журнала навсегда. Новая попытка получает курсор с РАЗМЕРА журнала на момент допуска, поэтому строки, которые прошлый дрейн не успел применить, не прочитает уже никто. Счётчики восстанавливает ре-синк, а unit_resolutions — нет: их единственный источник — поток. Сегодня невидимо (читающей поверхности единиц ещё нет), к P7 станет расхождением read-модели ⚠ ДИСПОЗИЦИЯ P7 (взято на учёт, НЕ закрыто): предсказание строки сбылось ровно наполовину, и половины теперь названы. Что САМОЛЕЧИТСЯ: текст и состояние пары приходят не из потока, а из tmctl export на границе работы (readmodel.Refresh), поэтому недодрейненный хвост их не искажает. Что НЕ лечится: unit_resolutions — единственный источник ЗАМЕЧАНИЙ и счётчиков главы, и потерянная строка это замечание, которого пользователь не увидит никогда, плюс units_done, занижённый навсегда. Лечение — перечитывание хвоста журнала при переоткрытии прогона (курсор новой попытки начинается с размера журнала на допуске); это работа того же класса, что эскроу (строка 136), и в P7 не бралась осознанно |
open | приёмка P6 (дофикс, ФП-7) |
| PD-380 | hardening | minor | internal/pgstore/sessions.go:98=if _, err := s.pool.Exec(ctx, q, digest, userID, now, now.Add(idleTTL), now.Add(maxAge)); err != nil {, internal/config/config_test.go |
Абсолютный потолок сессии не запинен в единственном месте, где он становится фактом в базе. CreateSession — единственный писатель sessions.absolute_expires_at, и мутация этого выражения проходит ПОЛНЫЙ пакет pgstore: ни один сессионный тест не краснеет. Пин, который STACK_DECISIONS §13 называет носителем потолка, смотрит только на результат config.Load() (что значение конфигурации не выше ASVS-предела), то есть проверяет НАСТРОЙКУ, а не то, что она доезжает до строки. Родня PD-86, но на шаг раньше: там не запинены клаузы ЧТЕНИЯ и потолок держится транзитивно через Touch, здесь не запинена сама ЗАПИСЬ, а транзитивной страховки у неё нет. Воспроизведение: docs/p8-review/axis2-auth/mutations-axis2.sh ⚠ Пере-проверено на ПОЛНОЙ батарее координатором пака (мутация M12): now.Add(maxAge) → now.Add(100*maxAge) в CreateSession, дельта против чистой копии ПУСТА на всех 18 пакетах. ⚠ Первая редакция этой посадки НЕ СОБРАЛАСЬ — комментарий вставлялся в середину строки и съедал хвост ; err != nil {; это ошибка харнесса, а не находка, и она названа в логе docs/p8-review/mutations-round2.log, чтобы приёмка не пере-открыла её как расхождение ⚠ ВЕС ПОДНЯТ info → minor закрывающим ревью, довод — симметрия с PD-375: форма идентична (единственная точка принуждения объявленной границы не исполняется ни одним тестом, мутация переживает ПОЛНУЮ батарею, транзитивной страховки нет — Touch зажимает по значению ИЗ ТОЙ ЖЕ испорченной строки), а вес расходился только по валюте: там деньги и minor, здесь механизм ASVS 7.3.2 уровня 2 при объявленной зоной базовой линии L2 и info. Асимметрия была отпечатком того самого храповика «вреда сегодня нет», который это ревью и нашло |
open | ревью-пак P8-REVIEW, ось 2 (посадка мутации, пере-посажена рефутером на полном пакете) |
| PD-401 | bug | minor | internal/pgstore/runs.go StartRun (снятие баз), internal/pgstore/sink.go recordWaveShape, internal/pgstore/readmodel.go runDone/runTotal |
Прогон, стартовавший при edit_wave = false и получивший переворот флага ПОСЛЕ старта, делает всю купленную работу с полосой на 0/N — и это навсегда, а не «окном». Механика: движок объявляет форму волн первым progress-событием ПОСЛЕ старта (recordWaveShape; флаг только растёт, STACK_DECISIONS §35), а базы полосы снимались в StartRun через флаг-зависимый предикат МОМЕНТА СТАРТА и не пере-базируются никогда — сидят на черновой колонке, числитель после переворота уезжает на редакторскую, разрыв не закрывается. Достижимо штатно: книга прочерчена на пайплайне без редактора, оператор ставит редактора, куплено продолжение; воспроизведено ревьюером приёмки заменой одной строки в пине пака (edit_wave = false до старта, переворот после → 0/4 при всей сделанной работе). Тот же симптом «полоса не достигает единицы», что у PD-281, — на том «промежуточном классе», которым диспозиция PD-281 и объясняется. ⚠ Первая редакция строки (заведена опровергателем пака) занижала вес (info) и формулировку: «скачок доли при done>0» и «окно секунды» — секунды живёт ПЕРЕВОРОТ, ущерб его переживает; пере-формулирована и пере-взвешена по слову оркестратора 27.08. ⚠ Лечение ПОСТРОЕНО в этом же дереве и зелено: базы снимаются на ФИКСИРОВАННЫХ колонках (chapters_before — редакторская, draft_before — черновая), пара «числитель+база» выбирается ЖИВЫМ флагом в момент чтения (runDone), миграция 00026 пере-снимает базы live-прогонов, порядок переворота запинен ровно как в проде — TestAFlagThatFlipsAfterStartDoesNotStrandTheBar; лендить ли лечение этим паком — решение владельца, состав собирает оркестратор, до лендинга строка open. Остаток и ПОСЛЕ лечения: на смешанном заделе (купленный диапазон шире начернённого) переворот двигает знаменатель вверх в окне первого progress-события — транзиентный провал доли, прежняя суть первой редакции ⚠ ЗАКРЫТА лендингом P9 (58bae30, акт D39.162): лечение (фиксированные базы + пара живым флагом + миграция 00026 + пин TestAFlagThatFlipsAfterStartDoesNotStrandTheBar) заленжено и зелено; тело само велело «до лендинга строка open» — лендинг прошёл, статус переведён по аудиту 28.08. Транзиентный остаток окна первого progress-события остаётся named-границей (PD-401-остаток в словаре зоны) |
fixed(лендинг 58bae30, акт D39.162; перевод по аудиту 28.08) |
пак P9: опровергатель формы полосы (первая редакция) · блокер ревьюера приёмки 27.08 (пере-формулировка) · аудит документации 28.08 |
| PD-407 | bug | minor | internal/runs/bank.go (ветка ctx.Err() после BankApply), internal/runner/bankapply.go:40 bankStopGrace |
Единственный путь двери правок, способный оставить полу-приземлённую пару файлов БЕЗ отчёта, отвечает общим 503 service_unavailable вместо своего слова bank_corrections_incomplete — а комментарий ветки обосновывает её контрактом SIGTERM, который сюда не попадает. Ветка ctx.Err() != nil достижима только при res.Exited=false, т.е. когда глагол НЕ вышел сам — SIGKILL по WaitDelay (грейс 10 с) или несостоявшийся старт; глагол, поймавший SIGTERM, выходит 5 и идёт через bankVerdict. SIGKILL между двумя rename — ровно класс 15, и адресат его слова другой («машина шлёт ТО ЖЕ, никто не пере-решает»). Смягчение: ре-сенд сходится байтовым no-op движка в обе стороны, цена — неточное слово. Рядом: bankStopGrace 10 с КОРОЧЕ самого длинного непрерываемого участка глагола (движковый замер: 5000 declines ≈ 11.3 с до пред-записной проверки ctx) — на документе-максимуме SIGKILL опережает штатный останов для большинства моментов отмены ⚠ ГРЕЙС-ПОЛОВИНА ЗАКРЫТА фикс-раундом P9 (28.08, D39.162): bankStopGrace поднят до 30 с, число обосновано движковым замером в комментарии константы (×2.5 запас на медленный хост); комментарий SIGKILL-ветки переписан честно (описывал соседний путь). ОТКРЫТЫМ остаётся слово: полу-приземлённая пара без отчёта по-прежнему отвечается общим 503 вместо bank_corrections_incomplete — смягчение (ре-сенд сходится) в силе |
open (грейс-половина закрыта D39.162) | воркфлоу-ревью P9 28.08 (линзы door:interleave · door:crash-windows), диспозиция оркестратора 28.08: строкой; грейс — фикс-раундом |
| PD-409 | hardening | minor | internal/runs/bank.go decisionsFile (bank-corrections-*.json в StateDir, удаление только defer cleanup()) |
Отрендеренный документ решений переживает любую нечистую смерть демона и остаётся в StateDir навсегда: ничто в дереве каталог не подметает. SIGKILL/OOM/паника/истёкший грейс деплоя посреди вызова двери — и файл до 1 МиБ с полными src/dst правленых терминов пользователя лежит под 0600 бессрочно в каталоге, где оператор считает маркеры прогонов; единственная существующая уборка (os.Remove exit-маркера в spawn.go) эти файлы не видит. Накапливается через деплои: N нечистых смертей = N сирот. Лечение — бут-подметание по маске или переезд под каталог с автоочисткой ⚠ ЗАКРЫТА фикс-раундом P9 (28.08, акт D39.162): Service.SweepCorrectionScratch() подметает по маске на буте (вызов в композиционном корне tmplatformd до подъёма HTTP, файлы вне маски не трогаются); пин TestBootSweepsOrphanedCorrectionDocuments (посадка «маска мимо» поймана). Честная оговорка: сам ВЫЗОВ из main юнитом не запинен — ловится чтением |
fixed(фикс-раунд P9, D39.162) | воркфлоу-ревью P9 28.08 (линзы door:crash-windows · door:lock-lifecycle), лечение по слову оркестратора 28.08 |
| PD-411 | standards | minor | internal/pgstore/sink.go:137 и :447 (два писателя), потребителей НЕТ (греп: ни одного select) |
runs.draft_done/draft_total/edit_done/edit_total — носитель с ДВУМЯ писателями и НУЛЁМ читателей: класс A второго яруса онтологии банка (18-bank-ontology.md), сосед PD-314 по форме. Полоса целиком выведена из chapters (runDone/runTotal), эти колонки не читает ни один SELECT — а пишутся они на КАЖДОМ progress-событии внутри транзакции, держащей блокировку строки книги, двумя РАЗНЫМИ дисциплинами (поток присваивает, resync берёт greatest), и обоснование greatest на sink.go:444-446 защищает полосу, которой не существует, — следующая сессия будет искать монотонность бара не там, где он живёт. Лечение: снос колонок миграцией + снос обоих писателей и лгущих комментариев; в паке P9 НЕ делается (слово оркестратора 28.08: миграцию на снос в этом паке не заводить) |
open | воркфлоу-ревью P9 28.08 (линзы race:flip-read-consistency · bar:double-count · bar:stage-caption), диспозиция оркестратора 28.08: отдельной строкой класса A |
| PD-412 | bug | minor | internal/pgstore/readmodel.go:434-481 (draftChapters ×3 текстовых вхождения, editChapters, chaptersDone, noteCount), internal/pgstore/books.go bookColumns/lastRunTx/listBooksTx |
Карточка книги стоит 5 коррелированных сканов chapters на один GET, и 2 из них повторяются на КАЖДУЮ строку страницы библиотеки (×100). PG повторные текстовые вхождения одного подзапроса НЕ дедуплицирует (доказано воркфлоу side-effect-последовательностью на живом PG 18.4), CASE-ветки честно short-circuit; замер на фикстуре в форме миграции 00002 и книге 2283 глав: выражение как написано — 1.359 мс, те же три числа одним LEFT JOIN LATERAL (count(*) filter (...)) — 0.307 мс (×4.4); контрольный EXPLAIN сессии на реальной схеме tmp9stand: 6 SubPlan в плане, 5 исполняются. Лечение названо (один LATERAL-проход рядом с существующим lastRun); проект уже применял этот класс лекарства уровнем ниже (миграция 00022, note_count) |
open | воркфлоу-ревью P9 28.08 (линза cost:per-request, замер) + контрольный EXPLAIN сессии P9, диспозиция оркестратора 28.08: строкой |
| PD-413 | bug | minor | internal/pgstore/sink.go:293-309 emitProgress (тот же 3-скан агрегат) под lockBook из RunSink.Apply (sink.go:80-104), источник событий: движок шлёт progress на КАЖДЫЙ разрешённый юнит каждой волны |
Каждое progress-событие пересобирает полосный агрегат по ВСЕЙ книге — под удержанной блокировкой строки книги: стоимость прогона растёт как O(глав × юнитов) вместо O(юнитов). Изменение счётчиков ОДНОЙ главы (unitDone трогает одну строку) на следующей же строке потока триггерит 3 полных скана chapters (≈1 мс на книге 2283 глав, замер воркфлоу), сериализуя относительно себя любого другого писателя книги (RequestStop, дверь правок, следующее событие) — за прогон это десятки тысяч повторов, секунды суммарного удержания блокировки. Лечение — то же, что у PD-412 (один LATERAL), плюс возможная инкрементальность; оркестратор 28.08: «вторая пахнет хуже первой» |
open | воркфлоу-ревью P9 28.08 (линза cost:per-request, замер), диспозиция оркестратора 28.08: строкой |
| PD-418 | bug | minor | internal/pgstore/runs.go StalledRuns (settling-ветвь), internal/pgstore/observe.go, AbandonRun (ветвление по runs.finished_at), deploy/README.md |
У settling-строки ЖИВОГО прогона нет ручки, а рантбук обещает оператору обратное. PD-385 называет ДВЕ популяции, и вторая — «прогон, который ЖИВ, но чья ПРЕДЫДУЩАЯ попытка не рассчиталась после рестарта». Пак P11 дал ей обе поверхности ВИДИМОСТИ (таблица и гейдж ключуются по концу ПОПЫТКИ, так что строка показывается), но run abandon ветвится по runs.finished_at и на живом прогоне уходит в живую ветку: осиротевший холд он не тронет, а ответит про процесс. Рантбук при этом описывает PHASE=settling как то, что лечится этой командой. ⚠ Сегодня состояние НЕДОСТИЖИМО и это часть строки, а не оговорка: единственный не-тестовый путь к второй открытой резервации — reopen, который отказывается стартовать следующую попытку, пока холд предыдущей открыт. То есть документ расходится с кодом на состоянии, которого код пока не производит, — и разойдётся заметно, если этот инвариант когда-нибудь ослабнет. Лечение — либо ветвление по НАЛИЧИЮ осиротевшей попытки вместо finished_at, либо оговорка в рантбуке |
open | пак P11 (самопроход, линза соответствия заказу) + приёмка оркестратора №19 |
| PD-419 | bug | minor | internal/pgstore/migrations_test.go TestReleasedMigrationsAreUnchanged, internal/pgstore/migrations.sha256 |
Гейт выпущенных миграций слеп к ПЕРЕ-ПОДПИСИ и по построению не может отличить её от нарушения. Он сверяет файлы против migrations.sha256, лежащего в ТОМ ЖЕ дереве, поэтому ловит ровно один сценарий: правку миграции тем, кто забыл про манифест. Автор, который правит ВЫПУЩЕННУЮ миграцию и пере-подписывает её строку одним движением, проходит молча. Оба отказа, ради которых гейт написан, остаются достижимыми через пере-подпись: файл, отредактированный после накатки, больше никогда не запускается (goose применяет по НОМЕРУ и хранит только его), а переиспользованный номер лишает базу отката. Комментарий гейта при этом заявляет «this is the check that makes that true rather than intended». Единственный носитель «что уже выпущено», не лежащий рядом с правкой, — git: гейт мог бы брать git show HEAD:…migrations.sha256 и требовать побайтового совпадения строк СУЩЕСТВУЮЩИХ там миграций, свободно допуская новые; прогон в дереве без git обязан тогда ГРОМКО скипаться, иначе гейт возвращается туда же, откуда ушёл |
open | приёмка оркестратора №19 по паку P11 (замечена на законной пере-подписи 00028) |
| PD-420 | bug | minor | internal/pgstore/runs_test.go TestAClaimThatLostARaceToAReleaseIsRetriedAndNotAnError |
Тест гонки холда против релиза краснеет под ПАРАЛЛЕЛЬНЫМИ батареями, а зона ратифицировала рецепт, который их требует. D39.159 §2 предписывает сажать мутации в КОПИЮ дерева, и всякая сессия, которая делает это всерьёз, гоняет несколько батарей разом. Замер пака P11: при трёх параллельных прогонах краснеет в ЧИСТЫХ копиях (2 раза из 15); в СЕРИЙНОМ прогоне зелен — пере-проверено трижды подряд отдельным прогоном, все три ok. Меряет ресурс, общий для копий на машине. ⚠ Цена не косметическая: красная ЧИСТАЯ копия маскирует дельту, а красный прогон посадки читается как «мутация поймана», когда она не поймана, — ровно та ложная улика, ради которой мутации и сажают. Обход пака — судить по ДЕЛЬТЕ множеств, а не по коду выхода; лечение — изоляция ресурса либо честный скип под нагрузкой ⚠ ПОПРАВКА, внесённая до сдачи: первая редакция этой строки называла вторым фигурантом TestARunIsBoundedByItsOwnCgroup — НЕВЕРНО, и это моя ошибка вывода из совпадения. Он краснеет не от нагрузки, а от состояния ХОСТА, и вынесен отдельной строкой PD-423 |
open | пак P11, мутационная кампания (15 прогонов) + пере-проверка серийными прогонами |
Открытые — info
| ID | Класс | Серьёзность | Где | Суть | Статус | Источник |
|---|---|---|---|---|---|---|
| PD-398 | standards | info | docs/scripts/counts.py malformed, строки PD-99 и PD-197 этого файла |
Гейт формы регистра ловит только НЕДОСТАЧУ ячеек, а не избыток — и в файле уже есть две строки, которые он пропускает. malformed() сравнивает if n < shape, поэтому строка с лишним символом вертикальной черты внутри инлайн-кода проходит молча: счёт ячеек через awk с разделителем-чертой даёт PD-99 (11 ячеек) и PD-197 (10), а counts.py --check печатает битая форма: []. Обе строки допаковые, обе сломаны греп-альтернацией и регекспом в тексте. ⚠ Что СМЯГЧАЕТ и почему это info, а не выше: col() считает колонки С КОНЦА, поэтому статус и вес таких строк читаются ВЕРНО, и сегодняшние числа зоны не врут — пере-проверено, PD-99 и PD-197 попадают в свои корзины правильно. Дыра латентная: лишняя черта в одной из ТРЁХ последних колонок сдвинет уже их, и гейт снова промолчит. Пак наткнулся на это ДВАЖДЫ собственной рукой, и второй раз — этой же строкой: первая её редакция цитировала команду счёта ВМЕСТЕ с разделителем-чертой и сломала себя ровно тем, что описывает. моя же правка PD-396 внесла конвейер в ячейку, счётчик весов выдал мусорный ключ вместо info, а битая форма осталась пустой — то есть симптом виден в СЧЁТЕ, а не в проверке формы, которая для этого и написана. Лечение однострочное и не моё: if n != shape вместо if n < shape (зона docs/, решение оркестратора). Родня по классу — весь этот пак: гейт, доказывающий меньше, чем читается ⚠ ПЕРЕ-ФОРМУЛИРОВАНА приёмкой (оркестратор №19, 27.08, D39.159 п.8): предложенная правка ОТКЛОНЕНА замером. n != shape краснит СЕМЬ законных строк (пять бэклога, две регистра), потому что колонки читаются С КОНЦА НАМЕРЕННО — counts.py:132 называет это прямо и приводит ровно этот случай (строка бэклога 127 несёт три слова через вертикальную черту внутри инлайн-кода). Избыток в ПЕРВОЙ содержательной ячейке легален, и числа из-за него не врут: проверено, статус и вес читаются из хвоста, PD-99 и PD-197 разбираются верно. Настоящий остаточный риск — избыток в ХВОСТОВОЙ ячейке: там лишняя вертикальная черта сдвинула бы именно статус или вес, и тихо. Ловится не счётом ячеек, а сверкой хвостового словаря (статус ∈ {open, fixed, accepted-risk}); сегодня у такой сверки два известных доброкачественных исключения — PD-59 и PD-273 несут статус прозой. Строка остаётся ОТКРЫТОЙ под эту формулировку ⚠⚠ ВТОРАЯ ПОЛОВИНА ПОСТРОЕНА 27.08 (оркестратор №19), смягчение строки ОПРОВЕРГНУТО. Довод «читаем с конца, значит лишняя черта безвредна» неверен: он защищает СТАТУС (третья с конца), но не ВЕС — тот читался col(l,6), то есть тоже с конца, а от конца он далеко, и любая лишняя черта в «сути» сдвигала его молча. Живой пример — сама PD-197: вес читался из ячейки «где». Эмпирика, принесённая зоной: три случая за двое суток, все у тех, кто в этот момент про этот класс ПИСАЛ (правка PD-396 · первая редакция этой строки · пере-формулировка её приёмкой), и во всех трёх «битая форма» оставалась пустой. Построено три вещи, каждая проверена исполнением: (1) парсер уважает markdown-экранирование — PD-99 несла корректно экранированные черты, и ломался парсер, а не строка; (2) ВЕС регистра читается с НАЧАЛА (cells(l)[3]), потому что ведущие ячейки коротки и черт не несут, хвостовые тоже, а свободный текст живёт в СЕРЕДИНЕ — оба конца безопасны, середина нет; (3) новый гейт tail_vocab судит СЛОВАРЬ хвоста (статус и вес), а не счёт ячеек, с одним грандфазерным исключением PD-59. Проверено подсадкой: черта в статусную ячейку краснит гейт немедленно; на чистом дереве EXIT=0, числа не сдвинулись (398/96, 3/27/66). ⚠ Правка n != shape по-прежнему ОТКЛОНЕНА и теперь на точном основании: после уважения экранирования в бэклоге остаются пять строк с законной сырой чертой внутри инлайн-кода, и их хвост чист. Строка остаётся ОТКРЫТОЙ ровно на одно: у построенного гейта нет автоматического пина — он проверен подсадкой руками. Закрывать её без пина значило бы нарушить правило PD-1 в строке, которая про это правило и есть ⚠ ЗАКРЫТА 27.08: пин появился. selftest_tail_vocab() в docs/scripts/counts.py гоняется на КАЖДОМ --check и несёт четыре утверждения — чистая строка молчит · сырая черта в СТАТУСНОЙ ячейке краснит · экранирование законно и лишней ячейкой не считается · черта в СЕРЕДИНЕ не сдвигает вес. Проверен ПОСАДКОЙ, а не заявлением: три мутации гейта (снять уважение к экранированию · вернуть вес на чтение с конца · обезвредить словарь статуса) — пин ловит все три, базовая линия молчит. ⚠ Первая редакция пина МОЛЧАЛА на второй мутации: утверждение про вес проверяло cells(), а мутация меняет то, чем пользуется register(), — пин смотрел не туда. Переписано на настоящий путь; называю, потому что пин, проверенный одним прогоном вместо посадки, — это ровно тот дефект, который эта строка и описывает |
fixed(приёмка 27.08, дерево сессии) | ревью-пак P8-REVIEW (наткнулся при правке собственной строки, подтверждено редакторским аудитом) |
| PD-399 | standards | minor | internal/pgstore/readmodel.go:330 bankCountsTx, internal/httpapi/reading_test.go:113 |
GET /books/{bookId}/bank шлёт два поля, которых канон 0.5.0 больше НЕ объявляет. Минор снёс pending_decisions и complete из BankPage и EventBank вместе с пер-термной моделью, которую они обслуживали, а проекция платформы их по-прежнему кладёт на провод, и это НЕ нули: bankCountsTx считает proposed-строки, и её собственный комментарий это говорит. Клиент 0.5.0 лишние поля игнорирует по общему правилу канона, поэтому вес minor, но аллоулист-норма нарушена, а комментарий «the wire fields are the canon's» с этого минора ЛОЖЕН. ⚠ Присутствие полей ЗАПИНЕНО (reading_test.go:113), значит снятие — правка с пином, а не вычёркивание. Лечение — пункт (2в) очереди D39.156, тем же паком, что монтирует дверь bank/corrections. ⚠ Найдено САМОЙ контрактной сессией и принесено пингом: промт (§3.2-бис) утверждал «поля навсегда нули», а нота D39.160 п.2 — «после сноса канон и деплой совпадут точно»; верно по ПУТЯМ, неверно по ПОЛЯМ. Ошибка оркестратора, исправлена эрратой ⚠ ЗАКРЫТА паком P9 (27.08): поля сняты со всех трёх носителей — BankCounts/payload() (кадр EventBank), wireBankPage/listBank (провод), подзапрос к мёртвой bank_decisions ушёл из bankCountsTx; пин reading_test.go ПЕРЕПИСАН и теперь пинит ОТСУТСТВИЕ снятых полей на первой странице (TestTheBankAggregatesRideOnTheFirstPageOnly) |
fixed(пак P9, дерево сессии) | пинг контрактной сессии при сдаче минора 27.08, пере-проверен приёмкой |
| PD-396 | standards | info | internal/pgstore/books.go:306=chunker_version = $4, parse_started_at = null,, internal/pgstore/sink.go:127=update books set chunker_version = $2 where id = $1, internal/ingest/resync.go:36=UnsignedBankTerms int json:"unsigned_bank_terms"`` |
Мёртвые поля шва: у books.chunker_version ДВА писателя и НОЛЬ читателей, и это второй экземпляр класса, первый назвал оркестратор. Колонку пишет интейк из манифеста движка (FinishParse) и пишет тейлер из хендшейка потока (effect); ни одного select по ней в зоне нет — грепом ноль. Сегодня это безвредно, но следствие названо ЗАРАНЕЕ, потому что оно семантическое, а не техническое: в день, когда читатель появится, РАСХОЖДЕНИЕ двух писателей (чанкер прогона против чанкера разбора) станет значением, и решать, какой из них правда, придётся задним числом — по колонке, у которой уже накоплена история из обоих источников. Родня — п.4 пинга оркестратора №19: ingest.StatusReport.UnsignedBankTerms разбирается из ответа движка и не используется НИ ОДНОЙ строкой продакшн-кода (грепом — только объявление и его доккомментарий, где поле описано как опора экрана подписи). Формулировка оркестратора применима дословно к обоим: мёртвое поле в структуре шва читается как контракт. ⚠ Заведено ОТДЕЛЬНОЙ строкой по прямому указанию закрывающего ревью старшей модели и с его же доводом: этой фразе НЕ место в PD-166 — та строка про потерю значения между двумя автокоммитами, и её свойство построено; держать дефект-строку открытой как плейсхолдер несуществующей фичи есть ровно та патология «open, а лекарство построено», против которой пак завёл шестнадцать дописок. Диспозиция — вопрос владельца, а не зоны: либо назначить владельца колонки (один писатель), либо записать расхождение как ожидаемое до появления читателя Воспроизведение — две команды без конвейера (символ вертикальной черты в ячейку регистра не влезает): grep -rn chunker_version platform/internal --include=*.go даёт два update и ни одного select, grep -rn UnsignedBankTerms platform/internal platform/cmd --include=*.go даёт только объявление и его доккомментарий |
open | ревью-пак P8-REVIEW (побочная находка сверки реестра, оформлена по указанию закрывающего ревью) |
| PD-378 | bug | info | internal/pgstore/books.go:1107=u.RemainingPercent = int(balance * 100 / granted), internal/httpapi/v0.go usageState, канон 14-api-contract remaining_percent |
/v0/usage отдаёт remaining_percent вне контрактных 0..100 и зажигает предупреждение «low» на полном счёте: balance * 100 переполняет int64. Порог измерен точно: баланс 92 233 720 368 547 758 микро ещё даёт 99%, следующий микро-доллар даёт минус 99. Ответ нарушает схему (minimum: 0, maximum: 100), и хуже того usageState видит отрицательное значение ниже порога lowCredit и отдаёт state: "low" — «денег почти нет» счёту на сто миллиардов. Замерено на проводе: до гранта {"state":"ok","remaining_percent":96}, после grant --usd 100000000000 → {"state":"low","remaining_percent":-84}; соседняя арифметика (pricing.Scale, balance) при том же балансе отвечает верно, то есть переполнение локально именно в этой строке. ⚠ Рефутер сузил minor → info: чтобы туда попасть, оператор должен добавить на счёт не меньше 92.23 млрд долларов, ни одна пользовательская ручка кредит не пишет; прецедент веса — PD-39. ⚠ Оговорка рефутера в другую сторону: более правдоподобный носитель — не разовая команда, а конфиг TM_PLATFORM_SIGNUP_GRANT_USD, у которого верхней границы нет и значение НАМЕРЕННО не печатается в стартовый лог, так что промах в нём сломал бы /usage каждому новому аккаунту невидимо. Воспроизведение: docs/p8-review/axis1-money/a1-usage-overflow.sh (сам откатывает грант) |
open | ревью-пак P8-REVIEW, ось 1 (живой провод, сужено рефутером с измеренным порогом) |
| PD-381 | hardening | info | internal/auth/middleware.go:38=a.Deny.ServeHTTP(w, r) и та же строка на :52, internal/auth/cookie.go ClearSession |
401 по мёртвой сессии не стирает куку: браузер продолжает слать отозванный токен до конца её Max-Age (по умолчанию 14 суток). Обе ветки отказа зовут a.Deny.ServeHTTP и к a.Cookies не обращаются, перекрытия выше по стеку нет — живой 401 не несёт ни одной строки Set-Cookie. Норму формулирует сам код: комментарий ClearLogin говорит, что кука, пережившая свой круг, это «a replay waiting for an accident», а PD-88 заведена ровно на тот исход, при котором кука переживает сессию. Дешёвое лечение — чистить куку на пути отказа, где она была предъявлена. Отдельно от PD-88 (та про Max-Age меньше секунды) и от PD-5/PD-70/PD-74/PD-103 Воспроизведение: docs/p8-review/axis2-auth/csrf-matrix.sh и session-clocks-probe.sh (обе пробы поднимают демон и печатают ПОЛНЫЕ заголовки ответа, включая отсутствие Set-Cookie на 401); проверка чтением — grep -n 'Cookies' internal/auth/middleware.go, ни одного вхождения на путях отказа |
open | ревью-пак P8-REVIEW, ось 2 (чтение + живая проба, подтверждено рефутером) |
| PD-382 | hardening | info | internal/auth/cookie.go:52=func (c Cookies) ClearSession(w http.ResponseWriter) { c.set(w, c.SessionName(), "", -time.Second) }, internal/auth/cookie.go ClearLogin |
Путь ИСТЕЧЕНИЯ куки не запинен: две мутации, стирающие выход из браузера, прошли батарею целиком. ClearSession и ClearLogin — единственные места, где кука получает отрицательный Max-Age, и порча этого выражения ничего не роняет. ⚠ Рефутер поправил ЦЕНУ, названную первой редакцией находки: set пишет значение вызывающего, а обе Clear-ручки передают ПУСТУЮ строку, поэтому мутант не перевыпускает куку с живым токеном — он оставляет пустую куку, и следующий запрос всё равно приходит без сессии. То есть вреда сегодня нет, а не запинено СВОЙСТВО «выход удаляет куку из браузера», и это класс PD-1, родня PD-86/PD-87. Воспроизведение: docs/p8-review/axis2-auth/mutations-axis2.sh ⚠ Пере-проверено на ПОЛНОЙ батарее координатором пака (мутации M13 и M14), и по дороге поймана СВОЯ ошибка метода: первый прогон M13 дал красный, но упавшим оказался TestAClaimThatLostARaceToAReleaseIsRetriedAndNotAnError — известный флейк PD-369, к ClearSession отношения не имеющий. То есть вердикт, вынесенный по ЦВЕТУ батареи, а не по ТОПИЧНОСТИ упавшего теста, даёт ложное «пойман» и тихо теряет находку. Пере-прогон обеих мутаций даёт пустую дельту против чистой копии. Правило записано здесь, потому что цена его забывания — потерянная находка о недостающем пине: docs/p8-review/mutations-round2.log |
open | ревью-пак P8-REVIEW, ось 2 (посадка мутации, цена поправлена рефутером) |
| PD-383 | hardening | info | cmd/tmplatformd/main.go:257=const loginJournalRetention = 180 * 24 * time.Hour, cmd/tmplatformd/main.go sweepLogins, internal/pgstore/identity.go DeleteOldLoginEvents |
Ретенция журнала входов работает и не запинена ничем: и срок хранения, и сам свип переживают батарею. Механизм построен (константа 180 суток, тикер 15 минут, delete from login_events where at < $1, монтируется в обеих ветках входа) и проверен ЖИВЬЁМ: строка возрастом 200 суток исчезла на ближайшем тике, демон напечатал "login sweep" events=1. Но ни срок, ни вызов не пинятся: DeleteOldLoginEvents не зовёт ни один тест, а sweepLogins — неэкспортируемая функция пакета main без теста. Класс PD-1 на механизме, который ЛЕЧИТ уже закрытую строку. Воспроизведение: docs/p8-review/pd23-retention-probe.sh и pd23-result.txt ⚠ Пере-проверено на ПОЛНОЙ батарее координатором пака (мутации M15 и M16): срок хранения поднят со 180 суток до 180 ЛЕТ и, отдельно, предикат свипа обезврежен (delete from login_events where at < $1 and 1=0) — обе дельты против чистой копии ПУСТЫ на всех 18 пакетах. Лог — docs/p8-review/mutations-round2.log |
open | ревью-пак P8-REVIEW, ось 2 (находка рефутера, живая проба координатора) |
| PD-388 | hardening | info | internal/readmodel/readmodel.go:198=if deadline, ok := ctx.Deadline(); ok && time.Until(deadline) < MaterializeBudget {, cmd/tmplatformd/runner.go refreshSweepBudget, internal/books/parse.go (запиненный близнец) |
Пара чисел в разных пакетах, не выводимая и не запиненная, и на неверной её стороне материализатор молча не делает ничего. Drain начинает книгу, только если у прохода осталось не меньше ЦЕЛОГО MaterializeBudget (5 минут), а число прохода живёт в cmd/tmplatformd голым литералом 10 минут, без ссылки на константу, которую обязано превышать. Это единственный член семьи без страховки: intakeSweepBudget ВЫВЕДЕН формулой и разъехаться не может, а claimGrace и выведен, и запинен отдельным тестом. Цена неверной стороны — не деградация, а полное молчаливое отключение: замерено пробой, проход 4м59с даёт claims=0, вызовов движка 0 и nil, ошибки нет, лога нет, sweep_unfinished_total не растёт. Мутация «refreshSweepBudget 10м → 1м» пережила ПОЛНУЮ батарею. ⚠ Вторая половина, найденная рефутером: сам гейт бюджета между книгами — живой носитель ЗАКРЫТОЙ PD-293, чья эррата прямо на него ссылается, — не покрыт ни одним тестом (во всём дереве нет теста, который даёт Drain дедлайн), поэтому его можно выключить целиком, и батарея останется зелёной; точный близнец у интейка при этом запинен своим TestAPassTooShortForAParseStartsNoneAtAll. ⚠ Рефутер опроверг приписку финдера «проход по построению начинает максимум 2 книги из 4»: гейт сравнивает остаток перед КАЖДОЙ книгой, и при проходе 10 минут стартуют все четыре. Воспроизведение: docs/p8-review/axis3-queue/probe_drain_budget_pair_test.go.txt |
open | ревью-пак P8-REVIEW, ось 3 (посадка мутации, расширено рефутером на носитель PD-293) |
| PD-393 | hardening | info | internal/metrics/metrics.go:187=if unfinished {, internal/metrics/metrics.go (два счётчика из двенадцати коллекторов), deploy/README.md (рецепт алерта) |
Счётчиков событий на весь демон два, и оба отвечают не на тот вопрос: «ошибки» из четырёх золотых сигналов закрыты только для HTTP. sweep_unfinished_total поднимается ИСКЛЮЧИТЕЛЬНО на context.DeadlineExceeded, поэтому проход, упавший обычной ошибкой, регистрируется как быстрый здоровый проход — замерено: 4 строки ERROR «runs sweep failed» в журнале и НИ ОДНОГО изменения в экспозиции, кроме счётчика длительности. Ни у чего остального счётчика нет вовсе: отказ спавна, неудача расчёта, карантин, ненулевой выход движка, упавший прогон. Всё состояние снимается гейджами раз в такт, поэтому событие, уместившееся между двумя проходами, в экспозиции не существует, и на вопрос «сколько прогонов сегодня упало» ответить нечем. Отдельная мелочь того же корня: sweep_unfinished_total — CounterVec, и пока он ни разу не вырос, семейства в экспозиции НЕТ вовсе, поэтому готовый рецепт алерта рантбука («растёт tm_platform_sweep_unfinished_total») даёт «no data», а не ноль; практика Prometheus велит инициализировать известные наборы лейблов нулём. ⚠ Речь о СЧЁТЧИКАХ СОБЫТИЙ, не о суммах денег: запрет D39.84 на денежные числа в метриках соблюдён, проверено. Воспроизведение: docs/p8-review/axis4-metrics/40-exposition-check.sh |
open | ревью-пак P8-REVIEW, ось 4 (живой замер, заголовок сужен рефутером) |
| PD-395 | doc | info | internal/gates/contract_test.go:14=const canonPath = zoneRoot + "/../docs/architecture/14-api-contract/openapi.yaml", docs/ENGINEERING_STANDARDS.md §3, промты ревью-паков зоны |
Копия platform/, вынутая из репозитория, даёт красную батарею по причине, не имеющей отношения к коду. Гейт контрактной версии читает канон по пути ВЫШЕ модуля (internal/gates/contract_test.go:14=const canonPath = zoneRoot + "/../docs/architecture/14-api-contract/openapi.yaml") и при его отсутствии t.Fatalf, а не сообщает, что запущен вне репозитория. Стоило времени КАЖДОМУ, кто работал в копии — все девять субагентов пака и координатор, — и один агент едва не завёл ложный красный находкой. ⚠ САМОЕ ОСТРОЕ следствие, найденное ревью старшей моделью и координатором пропущенное: в голой копии становятся НЕСУДИМЫ мутации самой ContractVersion — базовый красный того же теста маскирует дельту, вердикт выходит «ВЫЖИЛА», и протокол произвёл бы ЛОЖНУЮ находку «константа не запинена». То есть цена не только во времени. ⚠ ДИСПОЗИЦИЯ ПЕРЕСМОТРЕНА 24.08 после ревью старшей моделью, и первая редакция этой строки целилась НЕ ТУДА (адресовала гейт вместо рецепта). Три ошибки первой редакции сняты проверкой: (1) носители названы шире, чем есть — ENGINEERING_STANDARDS §3 копий на момент находки НЕ требовал (grep -ci 'копи' давал 0; ПОСЛЕ правки этого пака даёт 6 — рецепт туда и записан, см. ниже), норма копий живёт только в промте ревью-пака и в D39.113; значит сталкиваются не гейт и стандарт зоны, а гейт и РЕЦЕПТ ОДНОГО ПРОМТА. (2) «точечное решение, а не стиль» — НЕВЕРНО: в backend/internal/standdata/standdata.go живёт целый репо-паттерн чтения выше корня модуля с поиском корня по МАРКЕРУ и env-переопределением, и его потребители читают даже чужую зону (internal/miner/miner_parity_test.go читает eval/). Платформа реализовала тот же паттерн грубее — голым счётом .., — и комментарий standdata объясняет, чем именно это хуже. (3) довод «модуль обязан быть самодостаточным» к этой зоне не применяется: её собственный DoD определяет полную приёмку через ТРИ ВНЕШНИХ условия. Ценность зоны — не герметичность, а громкий учёт непроверенного. ЛЕЧЕНИЕ — РЕЦЕПТ, А НЕ ГЕЙТ, и оно проверено исполнением: cp -a --parents platform docs/architecture/14-api-contract <куда>/ даёт в копии ok textmachine/platform/internal/gates, тогда как голая копия даёт FAIL … the ratified canon could not be read. Одна строка. Гейт сохраняет зубы во ВСЕХ средах, мутации ContractVersion становятся судимыми, а забытый рецепт ломается ГРОМКО, а не тихо — решающее свойство для гейта, рождённого из «a human noticing failed twice». Второй, необязательный шаг: резолвить канон маркер-обходом по образцу standdata.Root() и дописать в сообщение Fatalf вторую гипотезу «ты вне репозитория» — это убивает стоимость повторной диагностики и остаётся падением, а не скипом. ОТВЕРГНУТО с доводами: гейт-переменная батареи с названным скипом (вне make гейт выключался бы сам — зона уже осудила эту форму словами internal/gates/toolchain_test.go:55-57 «the Makefile is a convenience… a build that skips the battery gets a toolchain the battery would have refused»; плюс это легализует поломку рецепта как «условие среды») · переезд проверки на репо-уровень в counts.py как ЗАМЕНА (единственная репо-точка принуждения НИКОГДА не блокирует, только предупреждает — гейт остался бы без зубов; как ВТОРАЯ сеть законен) · кодоген из спеки (посылка протухла: oapi-codegen пере-подписан D39.132 в КАНДИДАТА и P7 решил НЕ БРАТЬ с доводом; и класс он не убивает, а переносит — генерат коммитится внутрь модуля, то есть второй источник истины) · вендорить снапшот канона в модуль (то же самое) ⚠ Оговорка к слову «ГРОМКО», без которой оно обманывает: голая копия ломается громко только В ПАРЕ с правилом «вердикт судится по ТОПИЧНОСТИ упавшего теста, а не по цвету батареи». Без этого правила голая копия плюс суд по цвету по-прежнему дают ложную выжившую ContractVersion. Правило записано здесь, а не только в отчёте, потому что пак переживёт именно строка ⚠⚠ ВТОРАЯ ошибка первой редакции лечения, найденная ЗАКРЫВАЮЩИМ ревью старшей модели и уже исправленная: лечение было припарковано в САМОМ ЭФЕМЕРНОМ носителе. Рецепт правился только в промте этого пака — а промты паков после лендинга АРХИВИРУЮТСЯ (в platform/docs/archive/ их уже шесть). Долговечные носители при этом были пусты: ENGINEERING_STANDARDS §3 копий не упоминал вовсе, D39.113 знает изоляцию, но не путь канона. Следующий ревью-промт пишется из НОРМ, а не из архивного промта, значит ловушка взводилась бы заново и цена «пять агентов и координатор» платилась бы второй раз. Это тот же класс, что девять ⚠-дописок этого пака: лекарство есть, а знание о нём живёт не там, где его будут искать. Исправлено: рецепт вместе с доводом про маскировку дельты и правилом топичности вердикта записан пунктом 3 в ENGINEERING_STANDARDS §3 — свой зонный док, долговечный носитель. Промт пака поправлен тоже, но теперь он дублёр, а не единственный носитель |
open | ревью-пак P8-REVIEW (координатор пака, цена замерена этой же сессией) |
| PD-281 | bug | info | internal/pgstore/readmodel.go runProgress |
Полоса прогона над книгой, уже полной в считаемом проходе, стоит на 0/N и не достигает единицы — против канона §Progress («the fraction always reaches one»). Штатный цикл: книга доведена до конца → пользователь подписал решения → запустил прогон, чтобы движок их применил → прогресс платной работы невидим до ready. Остаток подхода «числитель считает ГЛАВЫ, законченные проходом», а не регрессия: до правки PD-263 бар был неверен в другую сторону. Лечение требует продуктового решения (считать главы, ПЕРЕразрешённые после started_at), а не тихой правки ⚠ Дописка пака P9 (27.08), диспозиция: НЕ ТРОНУТА. Сквозная полоса (строка 200) переписала runProgress в runDone/runTotal, но ровно для сценария этой строки — книга полна в считаемом (последнем) проходе, значит и в черновом — ничего не меняет: обе базы равны счёту глав, draftWork = C, полоса стоит 0/2C и до единицы не доходит; числитель по-прежнему считает завершения глав от баз прогона. Знаменатель сменился только для промежуточного класса «черновик впереди редактуры», который эта строка не описывает. Живой факт того же пробоя: старт нового прогона над ПОЛНОЙ книгой сегодня вообще отвечает 409 (шкале нечего продать, ChaptersLeft = 0) — то есть у правки готовой книги нет и входа, которым её сложили бы в прогон; это смежный продуктовый вопрос той же строки. Лечение прежнее — продуктовое решение «считать пере-разрешения после started_at». Канонному минору к полосе НЕ наследовать обещание «the fraction always reaches one» без этой оговорки |
open | приёмка правок P7 (fable-5) |
| PD-297 | bug | info | internal/pgstore/readmodel.go writeChapters/writeUnits |
Материализация дерева делает один round-trip на СТРОКУ под эксклюзивной блокировкой книги — на корпусной книге (2283 главы, ~7 тыс. пар) это ≈11 тыс. последовательных обращений, и всё это время за блокировкой стоят emitFrame потока, StartRun и фолд юнитов. Штатный инструмент — tx.SendBatch (pgx v5, уже драйвер модуля) или CopyFrom во временную таблицу. НЕ сделано осознанно: рефутеры первой приёмки понизили до DOUBT/LOW, цена не замерена на форме этого деплоя (unix-сокет против управляемого PG по TCP — разница на два порядка), а путь — самый опасный на запись. Мерить прежде правки: время удержания блокировки на 2283-главной книге до и после ⚠ P8-FIX: НЕ ВЗЯТ, причина названа и она не «не успели». Сама эта строка объявляет замер на здешнем стенде НЕпредставительным (unix-сокет против управляемого PG по TCP — разница на два порядка), а корпусной книги нет: она появляется на холодном прогоне движка, которым гейчена строка 202 единого бэклога (решение владельца 20.08). Мерить нечем и не на чем, а правка самого опасного на запись пути без замера — ровно то, что эта строка запрещает. Берётся вместе с холодным прогоном |
open | доработка 20.08 (сверка находок против дерева) |
| PD-298 | bug | info | internal/pgstore/readmodel.go ListNotes, internal/pgstore/sink.go unitDone |
Снятие флага с замечания дельта-чтение выразить не может. Резолюция, пере-разрешённая как не-flagged (редрайв), обновляет строку и двигает revision, но дельта фильтруется предикатом ur.flagged — строка не возвращается, и клиент никогда не узнаёт, что замечание снято: оно остаётся на экране навсегда. Канон §AfterVersion: «A DELETION cannot be expressed this way», и требует одного из двух ответов — resync_required либо 400 version_too_old; здесь не даётся ни один. ⚠ НЕ подтверждено, что движок вообще пере-издаёт unit_done для той же тройки (глава, юнит, волна) с flagged=false — комментарий sink.go это УТВЕРЖДАЕТ («a redrive re-attacks a flagged one»), но чтением движка не сверено. Порядок: сначала сверка у движка, потом либо счётчик замены для замечаний, либо строка «переход недостижим» ⚠ ДИСПОЗИЦИЯ АКТА 5 (сверка с движком контрактной сессией 20.08): переход НЕДОСТИЖИМ и механизма не строим. Движок объявляет вердикт юнита один раз на волну, его announce-once-леджер не пере-announce-ит, поэтому единственная пере-доставка — ТОТ ЖЕ вердикт. Инвариант записан в коде (sink.go unitDone): если что-то научится снимать флаг, фолд обязан выдать кадр — иначе счётчик разъедется со списком молча. Строка держится открытой этим долгом, а не живым дефектом |
open | приёмка P7 → доработка 20.08 (сверка) |
| PD-299 | standards | info | internal/httpapi/conditional.go acceptsGzip |
Accept-Encoding: identity;q=0 не отвечает 406. Клиент, потребовавший ЛЮБОГО кодирования кроме identity, получает identity. Половина RFC 9110 §12.5.3, которую правка PD-268 не закрыла: gzip-сторона (именованное кодирование выигрывает у *, нулевой вес — отказ) закрыта и пиньётся, эта — нет. Достижимо только специально сконструированным клиентом; ни один генерённый по контракту клиент так не делает |
open | доработка 20.08 (сверка находок против дерева) |
| PD-368 | hardening | info | internal/config/config.go, internal/runs/reconcile.go phaseBudget |
Пара TM_PLATFORM_SWEEP_BUDGET/TM_PLATFORM_RUN_BUDGET не проверяется на когерентность на буте, и поднять ОДИН из них — тихий no-op: фазе достаётся половина прохода, поэтому любое значение RunBudget от половины прохода и выше наблюдаемо неотличимо от дефолта. Ревью заходило сюда как в major («поднять бюджет = объявить здоровый прогон застрявшим») и это опровергнуто исполнением: поднятие ручки не меняет вообще ничего, вердикт при дефолтах существует и без оператора, а строгая проверка RunBudget < SweepBudget/2 отвергла бы сами шиппящиеся дефолты (60 с против 60 с). Остаётся эргономика: поднимать надо SweepBudget, и об этом не сказано нигде, кроме доккоммента ⚠ ПАК P8-REVIEW 24.08: остаток («поднимать надо SweepBudget, и об этом не сказано нигде, кроме доккомментария») УЖЕ ЗАКРЫТ, и закрыт тем же паком, чья приёмка эту строку написала. deploy/README.md несёт таблицу обеих ручек и прямо под ней ⚠-абзац «Поднимать надо ПАРУ, а не одну… RUN_BUDGET выше половины SWEEP_BUDGET наблюдаемо ничего не меняет», со ссылкой на этот же номер. Верным остаётся только то, что когерентность пары не проверяется на буте. Предложение пака: сузить до этого или закрыть. ⚠ Связанное, но ДРУГОЕ: сама ручка не покрывает такт свипа целиком — PD-387 ⚠⚠ Уточнение рефутера, меняющее диспозицию: ЗАКРЫВАТЬ строку ЦЕЛИКОМ нельзя, только сузить. Абзац рантбука приехал коммитом 31f1f82 — ТЕМ ЖЕ, который внёс саму строку, то есть остаток родился уже закрытым. А проверки когерентности пары на буте по-прежнему нет: internal/config/config.go:460-463 грузит обе ручки без сверки. Сузить до этого и оставить open. ⚠ И соседство: абзац стоит под строкой deploy/README.md:288, которая сама предмет открытой PD-387 |
open | воркфлоу-ревью волны 2 (P8-FIX), находка опровергнута, остаток зафиксирован |
| PD-373 | doc | info | internal/readmodel/readmodel.go:64=const maxAttempts = 5, deploy/README.md:273=После пяти неудач |
У числа попыток материализации ДВА носителя и ничего между ними. Код держит const maxAttempts = 5, рантбук оператора пишет «После пяти неудач долг списывается». Посажена мутация оркестратором вне списка автора: maxAttempts 5 → 500000, батарея зелёная — пин readmodel_test.go ездит for attempts := range maxAttempts, то есть доказывает МЕХАНИЗМ при любом значении константы, что само по себе правильно. Незакрытым остаётся другое: подняли константу — рантбук молча начал лгать оператору о том, когда платформа сдаётся. ⚠ Тот же ход у runs.StalledAfter проверен и НАРУШЕНИЯ НЕ ДАЛ: там носитель ровно один (рантбук пишет «сколько неудач подряд» без числа), мутация тоже выжила и это законно. Лечение — либо гейт на второй носитель ровно той формы, что пак построил для ContractVersion (internal/gates/contract_test.go читает канон, а не копию числа), либо число уходит из прозы |
open | приёмка P8-FIX (посадка мутации оркестратором №18) |
| PD-374 | doc | info | docs/STACK_DECISIONS.md «Гейты батареи», internal/runner/systemd_test.go systemdOrSkip, Makefile цель check |
Рецепт объявляет у батареи ДВА гейта и ждёт «скипов 0» — а условий три, и третье не названо. Кроме TM_PLATFORM_TEST_DSN и пары TM_PLATFORM_TEST_ENGINE_BIN/_BOOK_TEMPLATE есть systemdOrSkip: без ДОСТИЖИМОГО пользовательского менеджера systemd три теста internal/runner скипаются. Поймано пере-прогоном батареи при приёмке: на этом хосте /run/user/1000 не существует (сессия logind не поднята), поэтому «скипов 0» недостижимо в принципе — make check дал 18 пакетов, exit 0, линтер 0 issues и 3 скипа. Мимо: сама цель check печатает над списком скипов «set TM_PLATFORM_TEST_DSN», отправляя читателя к ручке, которая тут ни при чём. Отчёт пака честен и это подтверждает — он мерил отдельно и получил 0, что верно на хосте с живым менеджером. Лечение: назвать третий гейт в рецепте вместе с двумя и не обещать «скипов 0» без него; заодно сделать сообщение цели check не называющим одну переменную из трёх ⚠ ПАК P8-REVIEW 24.08: половина лечения УЖЕ в дереве. docs/ENGINEERING_STANDARDS.md §3 п.1 переписан и называет ТРИ условия поимённо, включая достижимый пользовательский менеджер systemd, со ссылкой на этот номер. Названные строкой носители не тронуты: docs/STACK_DECISIONS.md по-прежнему пишет «Гейты батареи — их ДВА» и «скипов 0», а сообщение цели check в Makefile называет одну переменную из трёх. Предложение пака: сузить до этих двух. ⚠ На ЭТОМ хосте все три условия выполнимы (/run/user/1000 жив), поэтому пак снял базовую линию со скипами 0 — см. docs/p8-review/battery-final.log |
open | приёмка P8-FIX (пере-прогон батареи оркестратором №18) |
| PD-6 | hardening | info | internal/auth/csrf.go:51 |
GET освобождён от CSRF (верно), но SSE-хендшейк — GET с амбиентной кукой: origin-чек хендшейка потока (STACK §5) не покрыт ничем. Закрыть при постройке SSE (P1) ⚠ ПАК P8-REVIEW 24.08: условие закрытия НАСТУПИЛО, лекарство не приехало, а защита оказалась ТРАНЗИТИВНОЙ. SSE построен (internal/httpapi/v0.go маршрут /books/{bookId}/events, internal/httpapi/stream.go streamEvents, коммит 9b23e8c), origin-чека не появилось: GET освобождён в internal/auth/csrf.go cookieUnsafe, а http.CrossOriginProtection судит только unsafe-методы. Живая проба: хендшейк с Origin: https://evil.example и амбиентной кукой отвечает 200 и стримит, без куки 401, заголовок X-TM-Client не требуется (docs/p8-review/sse-origin-probe.txt). ⚠ Вес поднимать НЕ предлагается, и это измеренная поправка к предложению аудита: браузерный случай сегодня закрыт ТРЕМЯ механизмами, ни один из которых не является чеком хендшейка — кука SameSite=Lax не уходит на кросс-сайтовый подзапрос, префикс __Host- не отдаёт её соседнему поддомену, а CORS-слоя нет вовсе (PD-96), поэтому кросс-origin EventSource браузер странице не отдаст. Это ровно класс PD-86: свойство держится, и держат его посторонние механизмы, ни один из которых не запинен как защита хендшейка. Диспозиция — вопрос приёмке: чек хендшейка или явное принятие с записью трёх носителей |
open | приёмка P0 (security-линза) |
| PD-23 | hardening | info | internal/pgstore/migrations/00001_identity.sql |
Журнал входов растёт без ретенции и чистится только каскадом при удалении аккаунта. Нужен свип по возрасту (год?) — вопрос политики, не кода ⚠ ПРОВЕРЕНО ПАКОМ P8-REVIEW 24.08 И ПРОТИВ ДЕРЕВА ЛОЖНО: ретенция ПОСТРОЕНА и РАБОТАЕТ. internal/pgstore/identity.go DeleteOldLoginEvents (delete from login_events where at < $1) зовётся из cmd/tmplatformd/main.go циклом sweepLogins каждые 15 минут с окном loginJournalRetention = 180 * 24 * time.Hour, и свип монтируется в ОБЕИХ ветках входа. Приехало коммитом 9b23e8c (лендинг P7), строка не обновлялась с P1. Доказано не чтением: строка возрастом 200 суток исчезла на ближайшем тике, демон напечатал "login sweep" events=1 (docs/p8-review/pd23-result.txt). Предложение пака: ЗАКРЫТЬ, срок 180 суток отметить как выбранную политику. Незапиненность самого механизма вынесена отдельной строкой PD-383 |
open | самопроверка P1 |
| PD-44 | hardening | info | internal/pgstore/ |
sqlc не взят, хотя направление §3 предписывает взять его ДО появления денежных таблиц. Весь денежный SQL — сырые строки pgx ⚠ P8-FIX: половина, которая ЛЕЧИТ класс, построена; сам инструмент — вопрос владельцу. Рантайм-ошибки «нет такой колонки» (r.stop_for_signing, chapters_before) случились в СКЛЕЕННОМ SQL read-модели, куда sqlc по построению не доходит, поэтому тем же пунктом заведён постоянный гейт, который доходит: pgstore.TestEverySQLStatementParsesAgainstTheMigratedSchema сворачивает КАЖДЫЙ SQL пакета из исходника (литералы, конкатенации, именованные константы) и планирует его Postgres'ом (explain (generic_plan)) против мигрированной схемы — 162 оператора, все планируются. Посадка мутации в СКЛЕЕННЫЙ фрагмент (c.units_edit_done → несуществующая колонка) гейтом ловится; несворачиваемый SQL — ОШИБКА гейта, а не пропуск (единственное исключение — store.go Ready, где имя таблицы принадлежит goose, и оно выписано таблицей в самом гейте). Тем же гейтом закрыт открытый вопрос фикс-листа «есть ли в read-модели запрос, которого не касается ни один тест»: теперь его касаются все, на каждом прогоне батареи. ГРАНИЦА sqlc, названная явно (промт §4.1 «реши сам и аргументируй»): склеек в пакете 25 мест из 147, фрагментов-констант 15, у lastRun девять потребителей, у nextRevisionOfThisBooksLibrary восемь — read-модель для sqlc недостижима, и это пере-считано, а не вспомнено. Свободных от склейки файлов целиком пять: credits.go(15) · identity.go(13) · idempotency.go(7) · sessions.go(5) · observe.go(1) = 41 запрос; это единственный кусок, где инструмент силён и ничего не ломает. Конверсия этих 41 — отдельный пак: одиннадцать из пятнадцати денежных запросов идут внутри ЧУЖОЙ транзакции (WithTx), генерённый код коммитится, нужен пин версии sqlc и гейт «сгенерённое актуально», а чинить он будет класс, который гейт выше уже закрыл. Взять его в этом паке на две-три ручки — ровно то, от чего предостерёг фикс-лист: купить инструмент туда, где не болит. Предложение зоны: sqlc следующим паком на этот блок из 41 запроса; решение — владельца ⚠ РЕШЕНО владельцем 22.08: sqlc берётся ОТДЕЛЬНОЙ СЕССИЕЙ, не внутри пака — изменений много (генерённый код в дереве, пин версии инструмента, гейт актуальности, WithTx для одиннадцати денежных запросов), и мешать их с содержательной работой нельзя. Граница неизменна: 41 запрос в 5 файлах (credits·identity·idempotency·sessions·observe), остальные 25 склеенных мест недостижимы по построению. |
open | ревью «вне карты»; гейт и граница — P8-FIX |
| PD-45 | hardening | info | internal/ingest/procgroup_unix.go |
syscall.Kill(-pid, SIGINT) идёт мимо os.Process, поэтому в узком окне между проверкой живости и сигналом ребёнок может быть пожат, и сигнал уйдёт в переиспользованную группу. Окно ~микросекунды и родитель ещё не звал Wait; переписывать на pidfd-путь — отдельная работа |
open | ревью «вне карты» |
| PD-86 | hardening | info | internal/pgstore/sessions.go:23,50 |
Два клауза-близнеца не запинены, и абсолютный потолок держится ТРАНЗИТИВНО: снятие absolute_expires_at > $2 из Lookup батарею переживает, потому что потолок навязывается через least($3, absolute_expires_at) в Touch (это запинено — TestSessionLifecycle). Снятие revoked_at is null из Touch тоже переживает (класс PD-4). Дефекта сегодня нет ни в одном; риск в том, что каждый слой по отдельности выглядит избыточным, а вместе они — единственное, что ограничивает жизнь сессии |
open | приёмка P2 (посадки мутаций) |
| PD-87 | hardening | info | internal/httpapi/server.go:82, internal/login/login.go:31 |
Ещё два незапиненных: снятие LimitBody с поддерева /auth и stateTTL 10 мин → 240 ч проходят батарею. Первое — родня PD-72 (та про общий внешний слой, эта про конкретное поддерево), второе — окно жизни неиспользованного авторизационного запроса |
open | приёмка P2 (посадки мутаций) |
| PD-88 | bug | info | internal/auth/cookie.go:62-66 |
TTL меньше секунды выпускает куку БЕЗ атрибута Max-Age: int(ttl.Seconds()) даёт 0, а Go при MaxAge == 0 атрибут опускает ⇒ кука становится браузер-сессионной. Достижимо в последнюю секунду абсолютного срока (скольжение выдаёт min(idle, остаток абсолютного) при гарде ttl > 0) — то есть ровно тот исход, который самопроверка P2 называла нежелательным: кука переживает сессию, и следующий запрос даёт 401 вместо чистого «вы вышли». Подтверждено исполнением (ttl 500 мс/999 мс) |
open | приёмка P2 (панель ×2, подтверждено исполнением) |
| PD-90 | bug | info | cmd/tmplatformctl/main.go:112,134 |
grant и adjust делят пространство ключей source="admin": --key, потраченный грантом, молча гасит корректировку с тем же ключом. CLI честно скажет «ключ уже потрачен», но оператор ждал другой операции ⚠ ПАК P8-REVIEW 24.08: якорь дрейфанул. cmd/tmplatformctl/main.go:112 сегодня это case "books":; пара, которую строка описывает, живёт на :149=return store.Grant(ctx, *user, micro, "admin", id, *note, now) и :171=return store.Adjust(ctx, *user, micro, "admin", id, *note, now). Суть верна: одно пространство ключей source="admin" |
open | приёмка P2 (панель) |
| PD-92 | bug | info | internal/ingest/supervisor.go:118-121 |
Дренаж стоит ДО cmd.Wait(), поэтому WaitDelay его не размораживает: io.Copy(io.Discard, stdout) ждёт EOF, а EOF придёт только когда закроются ВСЕ копии пишущего конца пайпа; внук, унаследовавший stdout и игнорирующий SIGINT, вешает Run навсегда — backstop WaitDelay действует внутри Wait, до которого управление не доходит. ⚠ Сегодня недостижимо и это пере-проверено приёмкой: в backend/ вне тестов нет ни одного exec.Command и нет cgo. ⚠ Пере-диспозиция (эррата №15): вес понижен до info — весь пайп-путь supervisor.go объявлен ДЕВ-РЕЖИМОМ (D39.106 п.3), в проде родителя у движка нет и cmd.StdoutPipe() не существует; чинить только если дев-путь остаётся |
open | приёмка P2 (панель, граница зоны пере-проверена) |
| PD-93 | bug | info | internal/ingest/supervisor.go:109 |
Фикс PD-77 («наша остановка — не сломанный синк») сверяет только context.Canceled и пропускает context.DeadlineExceeded: как только у runCtx появится дедлайн (потолок времени прогона — очевидная будущая ручка), штатное истечение снова поднимет ERROR «stream could not be materialized» |
open | приёмка P2 (панель) |
| PD-94 | bug | info | internal/httpapi/middleware.go:61-70 |
Recover глотает http.ErrAbortHandler — sentinel, которым хендлер намеренно обрывает соединение (net/http его не логирует и рвёт коннект). Замерено приёмкой: паника ErrAbortHandler превращается в 500 с problem-телом, то есть усечённый поток становится неотличим от полного. Латентно (сегодня им никто не паникует), но именно SSE-хендлер — типовой его пользователь |
open | приёмка P2 (замер оркестратора №15) |
| PD-96 | hardening | info | internal/httpapi/server.go:39-43, internal/auth/csrf.go:28 |
TrustedOrigins обещает отдельно развёрнутый фронт, но CORS-слоя нет вовсе. Живая проба: preflight OPTIONS с Origin: https://app.example.org получает 401 от гарда (браузерный preflight креденшелов не носит и не должен), заголовков Access-Control-* нет ни на одном ответе. Сценарий «фронт на другом origin» браузером сегодня неисполним: либо CORS приезжает вместе с контрактными ручками (П-1), либо фронт живёт на том же origin, и тогда TrustedOrigins — мёртвая ручка |
open | приёмка P2 (панель + живая проба) |
| PD-98 | doc | info | internal/pgstore/store.go:75-79 |
Случай «схема НОВЕЕ бинаря» в Ready беззвучен — признано ⚠-комментарием на месте, но ни одной строки лога: оператор, запустивший старый бинарь на новой схеме, сигнала не получит |
open | приёмка P2 (панель) |
| PD-107 | hardening | info | internal/pgstore/migrations/00007_credits.sql:85, 00002_readmodel.sql:13 |
Удаление аккаунта обходит защиту PD-25: составной FK reservations → books(id, owner_id) on delete restrict блокирует DeleteBook, но users каскадит в reservations НАПРЯМУЮ, поэтому delete from users уносит и ОТКРЫТУЮ резервацию. Замерено приёмкой: аккаунт с открытым холдом удаляется. Учётной дыры нет — леджер и кэш баланса каскадятся тем же удалением, — но прогон, идущий против этого холда, останется без того, кто его закроет. Кода удаления аккаунта в дереве нет вовсе (грепнуто) ⇒ строка = гейт перед появлением такой операции (и перед ASVS 7.4.2 в полной форме). ⚠ Заодно ОПРОВЕРГНУТА обратная версия этой находки от панели («удаление падает на композитном FK даже при закрытых резервациях») — мой прогон: удаляется и с закрытой резервацией, и без неё |
open | приёмка P2 (замер оркестратора №15; версия панели опровергнута) |
| PD-122 | bug | info | internal/pgstore/books.go ListBooks |
Library.revision НЕ монотонна: она выведена как max(books.revision) по книгам аккаунта и падает, когда удаляется книга, державшая максимум. Контракт требует монотонности внутри области и предписывает клиенту ОТБРАСЫВАТЬ чтение с меньшей ревизией — то есть после такого удаления библиотека замирает, пока чей-нибудь книжный счётчик не перерастёт старый максимум. Замерено ревью: 10 → 0 после удаления книги. ⚠ Сегодня недостижимо через API: ручки удаления книги нет вовсе (DeleteBook есть в сторе, маршрута нет). Правильное решение — собственный счётчик области у аккаунта, который двигается на изменение состава и статусов. ⚠ Уточнено ревью доков 09.08: КОЛОНКА уже есть — users.library_revision из 00001_identity.sql:13, и её не читает и не пишет ни один Go-путь (грепнуто), так что нужна не миграция, а пути записи и чтения; правка нескольких мест, поэтому она НЕ сделана в этом паке, а названа. ⚠ Дополнено дофиксом 09.08: ревизия не двигается и на ДОБАВЛЕНИИ книги — AddBook пишет новой книге revision = 0 и счётчика области не трогает, а спека описывает ревизию библиотеки как «membership and statuses». Класс тот же и решение то же: собственный счётчик области. Гейт: закрыть ДО появления удаления книги или любого второго писателя состава ⚠ Сужено паком P5: ДОБАВЛЕНИЕ книги ревизию области двигает — новая книга входит в библиотеку с revision = max(revision по владельцу) + 1 (pgstore.nextLibraryRevision, обе точки входа: дев-интейк и загрузка), и каждая смена статуса интейка её тоже двигает; пин books.TestABookThatJoinsTheLibraryMovesItsRevision, посадка «входить с нулём» падает. Открытым остаётся исходный случай: УДАЛЕНИЕ книги может уронить максимум, и лечится это собственным счётчиком области. Ручки удаления по-прежнему нет ⚠ ПЕРЕ-ФОРМУЛИРОВАНА дофиксом приёмки (FP5-1): клейм «добавление закрыто» был ЛОЖЕН. Пол области поднимался при удалении, а вставка читала только максимум — книга, загруженная после отменённой загрузки, входила НА или НИЖЕ пола, и одна ревизия отвечала за три разных состояния библиотеки. Теперь вставка берёт greatest(max, пол) + 1, пин books.TestABookThatJoinsAfterACancelledUploadStillMovesTheRevision гоняет именно сценарий «отмена → повторная загрузка». ОТКРЫТЫМ остаётся: смена статуса книги, которая НЕ самая новая, ревизию области не двигает — ни одна из половин не растёт; лечится тем же счётчиком области, который двигают все писатели состава и статусов ⚠ Дополнено ре-чеком (FP5-11): правило «брать следующий номер БИБЛИОТЕКИ» применено и к пяти писателям жизненного цикла прогона (старт, реоткрытие, пауза, два закрытия) — без них staleness оставалась на статусах прогона: книга не-максимума аккаунта уходила translating, а библиотека не двигалась. Пин pgstore.TestARunTransitionOnAnOlderBookStillMovesTheLibrary. Прогресс-путь синка НАМЕРЕННО не тронут (главная шкала считается от книжной до инкремента; прогресс бывает только у книги с идущим прогоном, а её старт уже поднял номер) |
open (добавление закрыто P5; удаление — нет) | адверсариальное ревью (исполнением) |
| PD-123 | doc | info | internal/pgstore/migrations/00009_runner.sql:11 |
runs.ceiling_chapters имеет default 0, а контракт объявляет Run.ceiling_chapters minimum: 1. Сегодня недостижимо: единственный путь вставки — StartRun, и он отказывает на неположительном значении. Строка заведена как гейт: строка прогона, записанная мимо StartRun (миграция данных, правка оператором), спроецируется на провод нулём, которого схема клиента не допускает |
open | адверсариальное ревью (чтение схемы) |
| PD-137 | hardening | info | deploy/tmplatformd.service [Unit] |
BindPaths=/run/user/%U требует существования каталога на старте юнита, а создаёт его logind вместе с пользовательским менеджером; в [Unit] упорядочения на него нет. На первом бутe это гонка, которую лечит Restart=on-failure (сервис поднимается со второй попытки). Строка не закрыта кодом намеренно: UID сервисного пользователя site-specific, поэтому After=user@<uid>.service добавляется установкой — инструкция вписана в шапку юнита |
open | адверсариальное ревью (чтение) |
| PD-139 | hardening | info | internal/runs/reconcile.go ERROR-строки |
Путь каталога книги попадает в ERROR-логи внутри обёрнутых ошибок (*fs.PathError тейлера, обёртки спавна). PD-99 закрывал ДРУГОЕ — argv на INFO, — и та половина проверена (grep по логу сквозной пробы = 0). Здесь диспозиция иная и её надо принять осознанно: оператор чинит именно этот путь, а ERROR — не INFO. Заведено, чтобы это было решением, а не побочным эффектом; если норма зоны распространяется и на ERROR, путь придётся заменить на id прогона ⚠ Дополнено P5: у класса появилась ВТОРАЯ площадка — интейк. Путь книги уходит в ERROR только в двух местах и намеренно: терминальный отказ разбора (err движка несёт путь исходника) и неудавшееся удаление каталога. На INFO/WARN идентификатора книги нет вовсе, и это запинено books.TestNoBookIdentifierReachesAnInfoLine. Диспозиция по-прежнему нужна одна на класс |
open | адверсариальное ревью (чтение) |
| PD-153 | bug | info | internal/runs/reconcile.go spawnGrace |
Грация спавна мерится от run_attempts.started_at, а не от момента заявки права на спавн: окно между RecordSpawn и возвратом systemd-run не покрыто, и второй инстанс платформы, у которого грация уже истекла, может решить, что попытка потеряна, и перезапустить прогон, который вот-вот стартует. Дёшево закрывается временем заявки, записываемым в RecordSpawn, и грацией от него ⚠ Сужено дофиксом приёмки: у СТОП-пути окно закрыто с обеих сторон — заявка на спавн не выдаётся прогону с висящим интентом, а закрытие «стопа до спавна» спрашивает systemd про детерминированное имя юнита, если у попытки есть базовая линия (то есть заявка когда-то бралась). Само окно PD-153 — грация от started_at, а не от момента заявки — не тронуто |
open | приёмка P4 (N2) |
| PD-154 | bug | info | internal/runs/reconcile.go settle |
runs.settled_at может остаться NULL между Settle и MarkSettled: это два вызова, и падение между ними оставляет прогон с закрытой резервацией и без отметки. Потребителей у отметки сегодня нет (рабочий список расчёта построен на открытой резервации, а не на ней), деньги целы и второй расчёт отвергается самой резервацией. ⚠ Дофикс 09.08 добавил вторую половину той же строки: settle прерванной попытки ЖИВОГО прогона (путь UnsettledRuns) ставит settled_at прогону, который ещё идёт. Потребителей у колонки по-прежнему нет, дрейф только операторский. Заведено как известность, а не как долг: закрывается вместе с эскроу (строка 136) ⚠ ПЕРЕ-ДИСПОЗИЦИЯ (P6): остаётся открытой в прежней формулировке. Пак трогал settle (запиненный бинарь, аддендум оркестратора 14.08) и окно не закрывал: закрытие требует write-ahead intent и состояния closing, то есть эскроу строки 136, а строить половину эскроу рядом с проектируемым целым — это второй, более слабый ответ на тот же вопрос. Потребителей у колонки по-прежнему ноль |
open | приёмка P4 (N3) |
| PD-166 | bug | info | internal/ingest/tail.go, internal/pgstore/sink.go Begin |
chunker_version из хендшейка теряется навсегда, если краш пришёлся между двумя стейтментами Begin (привязка engine_run_id и запись версии — два отдельных автокоммита): при повторном чтении своего же hello тейлер видит, что поток уже привязан, и Begin больше не зовёт. Сегодня поле никем не читается (нужно для строки 100), поэтому info; закрывать — одной транзакцией в Begin ⚠ ПАК P8-REVIEW 24.08: первая фраза сужена построенным. У колонки books.chunker_version появился ВТОРОЙ независимый писатель — интейк (internal/pgstore/books.go FinishParse пишет её из манифеста движка). Значит крах между двумя стейтментами Begin оставляет не пустую колонку, а СТАРОЕ значение разбора: теряется дельта, а не значение. Читателя по-прежнему нет, вес info верен ⚠⚠ МОЯ ДОПИСКА ВЫШЕ ОПРОВЕРГНУТА РЕФУТЕРОМ, и опровергнута верно — снимаю её. Механизм, который строка описывает (крах между ДВУМЯ автокоммитами Begin), СЕГОДНЯ НЕДОСТИЖИМ: запись версии переехала из Begin в effect и идёт в ОДНОЙ транзакции с курсором (internal/pgstore/sink.go:105-127), а сам Begin объявлен legacy (sink.go:33-38) и для попыток этой сборки недостижим — engine_run_id присваивается в INSERT попытки и бэкфилится в RecordSpawn. Доказано исполнением: при уцелевшей привязке и нулевом курсоре хендшейк идёт в Apply, а не в Begin (Begin calls=0 Apply calls=1), то есть терять между двумя стейтментами нечего. Посадка мутации (вырезан case ingest.TypeHello из effect) роняет TestTheChunkerVersionOfTheStreamReachesTheBook — атомарный писатель есть и запинен. Правильная диспозиция — не «сузить до потери дельты», а ЗАКРЫТЬ как построенное: атомарность, которой строка требовала, существует. Читателя у колонки по-прежнему нет, и это отдельный факт, а не этот дефект ⚠ ЗАКРЫТА актом лендинга P9 (D39.162): акт объявил закрытие состоявшимся, статус переведён по аудиту документации 28.08 — обязательство акта висело неисполненным (класс «строка open при легшем лечении», носитель — строка бэклога 225) |
fixed(акт D39.162, перевод по аудиту 28.08) | самопроверка дофикса (ревью вне карты) · аудит документации 28.08 |
| PD-170 | hardening | info | internal/pgstore/credits.go Settle |
Settle отбрасывает флаг applied строки расчёта, тогда как releaseHold на соседней строке из того же флага делает ErrReleaseKeySpent (PD-97). Недостижимо без правки леджера в обход кода — резервация должна быть открыта, чтобы дойти сюда, — но асимметрия в денежном пути стоит строки: либо симметричный отказ, либо явная причина, почему здесь он не нужен |
open | самопроверка дофикса (ревью вне карты) |
| PD-176 | hardening | info | internal/httpapi/middleware.go LimitBody, internal/metrics |
Сигнал requestTooLarge от http.MaxBytesReader до сервера не доходит через наши обёртки. MaxBytesReader пытается сказать net/http «запрос слишком большой» приведением ResponseWriter к НЕЭКСПОРТИРУЕМОМУ интерфейсу пакета net/http; наши обёртки (statusRecorder, metrics.recorder) его удовлетворить не могут в принципе — метод неэкспортируемый, а значит квалифицирован чужим пакетом. Следствие мягкое и замерено рассуждением по коду net/http: 413 отдаётся штатно, а соединение закрывается не немедленным сигналом, а обычным путём — после хендлера сервер дренирует остаток тела и, не сумев дочитать, закрывает соединение, причём дренаж ограничен ReadTimeout (PD-2). Лечение (если понадобится): ставить лимит без обёрток над ResponseWriter либо закрывать соединение самим через Connection: close |
open | сессия P5 (чтение stdlib при постройке маршрута) |
| PD-177 | bug | info | internal/books/books.go counter |
character_count для не-UTF-8 источника — оценка, а не счёт. Интейк считает символы потоково как байты, не являющиеся продолжением UTF-8 (b&0xC0 != 0x80), что для валидного UTF-8 ТОЧНО равно числу рун и не требует состояния между чанками. Движок принимает и GB18030, и UTF-16, декодируя их сам — на таком файле цифра неверна (для UTF-16 занижена примерно вдвое). Точный ответ есть на шаг позже: манифест несёт source_bytes и encoding, но не число символов. Лечится либо запросом числа символов у движка (строка единого бэклога), либо перерасчётом после разбора |
open | сессия P5 (названо при постройке) |
| PD-201 | bug | info | deploy/README.md, движок tmctl migrate |
Самолечение деплой-деадлока v15 ЖДЁТ движковую половину. Read-only status отказывает файлу проекта старее бинаря, платформа зовёт его перед каждым спавном и на расчёте денег — значит выкат движка запирает книги до tmctl migrate. Порядок апгрейда записан и исполним (tmplatformctl books --migratable пропускает книги с живыми прогонами, резюмируемыми попытками и незакрытыми холдами — все три блокируют по РАЗНЫМ причинам, аддендум оркестратора 14.08). Чего нет: движковый migrate придёт с машиноразличимой ошибкой «версия не совпала», и тогда платформа сможет лечиться сама — поймала → migrate (если у книги нет открытых попыток) → повтор. Вслепую не строится: без формы ошибки любой матчер был бы догадкой по тексту. ⚠ Проверка исполнением самого шага migrate тоже ждёт и НЕ помечена сделанной ⚠ ПОЛОВИНА ЗАКРЫТА P7 (проверка исполнением): рантбук деплоя прогнан end-to-end на стенде живым migrate — проектная БД, отведённая на схему v14 при бинаре v15, даёт tmctl status --json exit 13 с токеном schema_mismatch found=14 expected=15; tmctl migrate делает пред-миграционный бэкап и переводит v14 → v15; тот же status после — exit 0. Мёртвая цитата ошибки вычищена из deploy/README.md и из П-1 бэклога, пример в tmplatformctl/runs.go переписан на ВЕРСИОНИРОВАННЫЙ путь бинаря. ⚠ ОСТАЁТСЯ сам автомат самолечения («поймал 13 → migrate → повтор») — он не построен: строить его правильно значит решать, кто имеет право мигрировать книгу с открытыми попытками, а это тот же вопрос, что у books --migratable |
open | аддендум оркестратора 14.08 (ресёрч деплоя) |
| PD-203 | bug | info | internal/pgstore/books.go ReadUsage |
Аккаунт объявляется исчерпанным по паузе ОДНОГО прогона. /usage ставит paused_reason аккаунта, если у какой-нибудь книги последний прогон стоит paused/credit_exhausted — а это потолок ПРОГОНА (сколько глав купил пользователь), а не баланс: на аккаунте может лежать сколько угодно денег, и другой прогон стартует. Контракт про это поле говорит «Set when the account itself is in a halted state». Существовало до этого пака и не им создано; отдельной строкой, потому что различение потолков (PD-199) сделало вопрос «чей это потолок» отвечаемым ⚠ СУЖЕНО P7, не закрыто: Usage.halt_reason получил СВОЙ словарь (AccountHaltReason), то есть прогонная причина больше не может доехать до аккаунтного поля как чужое слово; и PD-241 убрал самый частый ложный источник — стоп пользователя, приезжавший credit_exhausted. Сам предикат («последний прогон ЛЮБОЙ книги стоит paused/credit_exhausted») не тронут: он про потолок ПРОГОНА, а не про баланс, и правильный ответ — читать баланс аккаунта, а не сканировать книги ⚠ ПРОВЕРЕНО ПАКОМ P8-REVIEW 24.08: диспозиция «сам предикат не тронут» ЛОЖНА против того самого коммита, которым P7 и заленджен. internal/pgstore/books.go ReadUsage читает БАЛАНС аккаунта, а не сканирует книги, и аккаунтная причина ставится только при balance <= 0; ⚠-комментарий на месте прямо описывает замену («It used to be read off the paused reason of each book's latest run»). Единственный писатель Usage.PausedReason — эта строка, проверено грепом по PausedCreditExhausted. Приехало 9b23e8c (git log -L по функции). Предложение пака: ЗАКРЫТЬ |
open | сессия P6 (самопроверка вокруг PD-199) |
| PD-204 | standards | info | internal/runner/systemd_test.go мета-пин |
Мета-пин systemd-гейта ПЕРЕЧИСЛЯЕТ формы отказа вместо того, чтобы спрашивать способность. Хвост (г) третьего раунда, у которого не было носителя. Сам гейт вылечен (PD-178: спрашивает, доходит ли процесс до своего менеджера), а тест, который его сторожит, по-прежнему знает список сообщений — у стража та же болезнь, от которой лечили охраняемого. Принято как ОСТАТОК с названной ценой: systemd меняет тексты между версиями, и мета-пин протухнет молча; вреда сегодня нет, потому что он не гейт, а страж гейта. Заведён по пингу оркестратора 14.08 | open | третий раунд P5, хвост (г) |
| PD-205 | hardening | info | internal/login/dev.go login, internal/auth/csrf.go cookieUnsafe |
Дев-вход защищён от login-CSRF только ОДНИМ слоем из двух. auth.CSRF требует заголовок X-TM-Client лишь на unsafe-запросе, который УЖЕ несёт сессионную куку, а на входе куки по определению нет — значит остаётся только http.CrossOriginProtection, и клиент, не присылающий ни Sec-Fetch-Site, ни Origin (тот самый браузер до 2023, ради которого второй слой и заведён), может кросс-сайтом ввести браузер жертвы в ДЕВ-аккаунт. Последствие на стенде ничтожно — аккаунт один и общий, — но свойство слабее, чем «POST, значит безопасно», и записано, а не подразумевается. ⚠ Тот же класс у боевого /auth/login (он вообще GET) и по той же причине; лечится либо требованием заголовка на login-маршрутах, либо явным принятием |
open | адверсариальное ревью P6 (кросс-семейное, Fable) |
| PD-212 | bug | info | движок cmd/tmctl/main.go exitCode, internal/runs/reconcile.go outcome |
Непойманная паника движка неотличима от «завершено с флагами». Go-рантайм завершает процесс с кодом РОВНО 2 на непойманной панике, а 2 — это CompletedWithFlags, единственный «успешный» код контракта; recover в cmd/tmctl/internal/pipeline отсутствует (грепнуто ревьюером). Платформа читает только $EXIT_CODE/$EXIT_STATUS и записывает ready для прогона, который упал посреди работы. Деньги целы (расчёт берёт цифру из status --json), врёт статус. Лечится НЕ здесь: либо recover в main движка, либо другой номер для флагов — запрос уходит строкой единого бэклога через оркестратора. ⚠ Платформа МОГЛА бы различить по отсутствию терминальной строки finished в журнале, но сознательно не судит прогон по строке, которую крэш обрезает |
open | адверсариальное ревью P6 (линза шва) |
| PD-213 | hardening | info | internal/ingest/manifest.go, internal/books/parse.go |
Форма манифеста не версионируется на стороне платформы — латентная мина на УДАЛЕНИЕ файла. DecodeManifest не сверяет manifest_version ни с чем; json.Unmarshal тихо игнорирует незнакомые поля и оставляет отсутствующие нулями, поэтому смена формы движком (tm-manifest-v2 → v3, переименование chapters_total) даст валидный разбор с ChaptersTotal = 0. А ноль глав интейк трактует как «источник прочли, книги нет» — тот же терминал и тот же бюджет, что exit 11, то есть после пяти попыток файл пользователя УДАЛЯЕТСЯ, хотя движок книгу прекрасно разобрал. Сегодня формы совпадают поле-в-поле, так что не эксплуатируется; в отличие от потока (мажор отвергается) и от полосы кодов (незнакомый номер безопасен), у манифеста аналога нет. Лечить сверкой manifest_version с известной, где незнакомая версия даёт НЕ-деструктивный класс |
open | адверсариальное ревью P6 (линза шва) |
| PD-214 | bug | info | internal/ingest/tail.go apply |
Чужая или битая hello-строка в общем журнале книги гасит материализацию СВОЕГО потока. Версия, непустой engine_run_id и seq == 1 проверяются для ЛЮБОЙ hello-строки ДО того, как код решает, чья она: ветка «not mine» стоит после них. Значит чужой процесс (ручной tmctl оператора в каталоге книги, старый мажор, баг чужой сборки) валит Tail ошибкой, а quarantines() считает её терминальной — проекция здорового платящего прогона уходит в карантин НАВСЕГДА (снятия карантина в дереве нет), свежесть падает на медленный ре-синк. Данные целы, деньги целы. Лечится порядком: при известном want чужой id распознаётся ДО валидации хендшейка |
open | адверсариальное ревью P6 (линза шва) |
| PD-215 | bug | info | internal/runs/reconcile.go restart, interruptedBySomeoneElse |
У петли перезапусков нет ни счётчика, ни бэк-оффа. Каждый цикл — строка run_attempts, три строки леджера (холд/возврат/расчёт), два вызова tmctl status и транзиентный юнит. Источник, который на каждой попытке тратит ~0 (движок, мгновенно падающий по внешней причине), крутит это вечно: remaining не убывает, значит exhausted никогда не наступает. Денег не теряется, но это неограниченная работа и рост таблиц. Класс существовал и до пака (путь ребута, строка 138), а ветка «exit 5 без намерения = прерывание» его РАСШИРИЛА. Лечить счётчиком перезапусков на прогон или бэк-оффом по времени последней попытки |
open | адверсариальное ревью P6 (линза денег и гонок) |
| PD-216 | bug | info | internal/runs/reconcile.go outcome (полоса отказов) |
Exit 12 (project_locked) закрывает прогон терминально, хотя это единственный класс полосы, который проходит САМ. Обоснование «отказ воспроизводим по построению, поэтому перезапуск — петля» верно для 10/11/19 и неверно для лока: другой процесс отпустит проект. Денег не теряется (холд возвращается целиком, списывается 0), но прогон убит и пользователь покупает новый. Не исправлено намеренно: перезапуск на 12 без бэк-оффа — это PD-215 в чистом виде, поэтому оба лечатся вместе |
open | адверсариальное ревью P6 (линза денег и гонок) |
| PD-243 | hardening | info | internal/ingest/resync.go DecodeStatus |
Декодер принимает null, {} и объект сплошь незнакомых полей за валидный отчёт. На денежных путях это перекрыто (PD-40: Spend == nil откладывает расчёт) и при exit 2 — согласием отчёта (PD-237); остаётся eta_seconds, который присваивается безусловно, так что пустой отчёт стирает ETA живого прогона. Лечится проверкой того же класса, что в манифесте: отчёт обязан называть книгу |
open | кросс-семейное ревью дофикса P6 (линза шва) |
| PD-244 | bug | info | internal/runs/reconcile.go settle |
Единственный выход settle, который оставляет холд открытым молча: при s.Engine == nil возвращается nil — ни возврата, ни MarkSettled, ни строки в логе. В проде недостижимо (cmd/tmplatformd/runner.go всегда ставит Engine), но это ровно та тишина, из-за которой класс PD-162 искали глазами |
open | кросс-семейное ревью дофикса P6 (денежная линза) |
| PD-246 | standards | info | internal/ingest/notes.go, движковый pipeline/status.go flagReasonSeverity |
Платформа держит РУКОПИСНУЮ КОПИЮ закрытого словаря чужой зоны, и единственный страж — строка в логе. Флаг-причины принадлежат движку (flagReasonSeverity, 15 значений), карта «причина → контрактный код» живёт на платформе, а выпускаются две программы независимо. Значит существует окно, в котором движок уже эмитит причину, которую эта сборка не знает, и расхождение видно только по ERROR в логе — то есть тогда, когда кто-то его прочитает. ⚠ Проводу окно закрыто и это НЕ дефект: причина вне карты проецируется кодом unspecified, а канон уже предписывает клиенту нейтральную фразу для незнакомого кода — то есть значение отдано ветке, которая ратифицирована, а не изобретено правило. Открытым остаётся ДВОЕ: (1) фразы для unspecified в приложении А нет (пишет владелец, строка 148) — как и ступеней у всех 15 строк, где граница attention/glance сегодня догадка платформы; (2) гейта на расхождение нет и со стороны платформы быть не может — импортировать backend/internal запрещено ревью-гардом модулей (D39.85). Предложение зоны (пинг оркестратору, строкой в бэклог ДВИЖКА): публиковать список флаг-причин данными — артефакт рядом с манифестом либо tmctl flag-reasons --json ($0, таблица уже существует), — тогда тест платформы читает его и ПАДАЕТ, если в карте нет строки. Класс «словарь разъехался тихо» закрывается насовсем. ⚠ Обратное решение — чтобы движок эмитил сразу контрактный код — отвергнуто с доводом: это зеркальная утечка, продуктовое слово (source_residue, term_not_applied) поехало бы в движок, который о контракте знать не должен, и отменило бы причину самого переименования ⚠ ЗАКРЫТА ЧАСТЬ ПРО ПЛЕЙСХОЛДЕР (акт 5, ответ контрактной сессии 20.08): unspecified ратифицирован как ОБЯЗАННОСТЬ сервера, а не строка чужого словаря — то, что зона уже отдаёт, стало легальным. Открытым остаётся то, ради чего строка заведена: рукописная копия закрытого словаря движка и отсутствие стража, кроме строки в логе |
open | сессия P7 (самопроверка против приложения А) |
| PD-247 | bug | info | internal/httpapi/stream.go pump |
Живой поток ОПРАШИВАЕТ базу дважды в секунду на каждое открытое соединение (кадры + состояние книги). Кадры минтят писатели в своих транзакциях и в других процессах, поэтому подписки у этой стороны нет; выбран опрос, а не LISTEN/NOTIFY, потому что кадр это ПОКА, и секунда задержки на поке не наблюдаема рядом с переводом. Цена названа числом: 12 вкладок на книгу = 24 запроса/с к Postgres, оба по индексу и по одной книге. Лечится pg_notify в emitFrame + один слушатель на процесс — работа на полдня, которая нужна не раньше второго десятка одновременных читателей |
open | сессия P7 (собственная оценка) |
| PD-248 | bug | info | internal/readmodel/readmodel.go Refresh |
Материализация читающей поверхности стоит ДВУХ полных ре-чанков исходника — tmctl manifest --json и tmctl export --json --pairs, каждый из которых заново ингестит и режет книгу (~1,4–1,5 с CPU на 23 МБ, строка 100 единого бэклога). Зовётся на границах работы (конец интейка, конец прогона), то есть не на запрос пользователя, но на книге в 2283 главы это секунды CPU и десятки мегабайт JSON через пайп на каждый конец прогона. Дешевле было бы читать сайдкар манифеста напрямую (он уже лежит рядом с БД проекта) и просить у движка экспорт ТОЛЬКО изменившихся глав — второго канала у движка нет, это запрос строкой единого бэклога Доработка 20.08: интейк больше не платит за ТРЕТЬЮ ре-нарезку — books.Parse передаёт уже прочитанный манифест в readmodel.RefreshCut; остаются два (манифест + экспорт), и это цена самих каналов. |
open | сессия P7 (собственная оценка) |
| PD-249 | bug | info | internal/pgstore/runs.go ReadRunForSpawn |
Чтение прогона под спавн линейно по числу ЖИВЫХ прогонов: читает их все и ищет нужный в цикле. Сегодня незаметно (живых прогонов единицы), но это O(живых) на КАЖДУЮ задачу спавна, и растёт ровно тогда, когда платформа становится нужной. Находка §9 контракт-ревью (research/28), проверена чтением кода: запрос действительно без предиката по id | open | research/28 §9 (пинг оркестратора №17), сверено P7 |
| PD-250 | vuln | minor | cmd/tmplatformd/main.go (слушатель метрик) |
/metrics отдаётся БЕЗ аутентификации; вся защита — привязка к 127.0.0.1. Для одной VM это честная граница, и она записана (STACK §24). Но на хосте с несколькими пользователями любой локальный процесс читает оперативную картину сервиса, а на деплое, где слушатель однажды переедет на 0.0.0.0 «чтобы Prometheus дотянулся», защиты не останется вовсе. Денег в метриках нет (D39.84), поэтому это minor, а не major. Лечение — bearer-токен на слушателе или mTLS, решать при первом внешнем Prometheus ⚠ ПАК P8-REVIEW 24.08: это ТОТ ЖЕ факт, что PD-179, и он стоит в регистре в ДВУХ статусах одновременно (accepted-risk(платформа P5, 11.08) против open). Код и ручка одни: cmd/tmplatformd/main.go serveMetrics без аутентификации, дефолт 127.0.0.1:9464; рантбук deploy/README.md называет строкой риска именно PD-179. Своя добавка у этой строки есть (многопользовательский хост), но статус один факт должен нести один. Предложение пака: свести ⚠⚠ Условие сведения, найденное рефутером: у PD-179 довод про ОДНУ VM, а добавка этой строки — многопользовательский хост, где любой локальный непривилегированный процесс скрейпит экспозицию, — в PD-179 ОТСУТСТВУЕТ. Плюс при сведении из выборок безопасности исчезает класс vuln (у PD-179 он hardening). Сводить только ВМЕСТЕ с перенесённой фразой и с пометкой класса |
open | research/28 §9 (пинг оркестратора №17), сверено P7 |
| PD-251 | bug | info | internal/login/login.go (лимитер входа) |
Лимитер входа один на ПРОЦЕСС, а не на адрес: один клиент, долбящий /auth/login, расходует общее ведро и запирает вход всем. Выбор объяснён комментарием (за прокси адрес клиента без доверенного X-Forwarded-For — это адрес прокси, и пер-адресное ведро тогда защищает не то), но следствие не было записано. Лечение появляется вместе с доверенным заголовком прокси на деплое ⚠ ПАК P8-REVIEW 24.08: это ТОТ ЖЕ факт, что PD-42, и он стоит в регистре в ДВУХ статусах одновременно. PD-42 (accepted-risk(платформа P1, 05.08)) описывает то же самое ведро с замером «8 отказов из 10 при фоне 5 rps», и код один — internal/login/login.go startLimit с комментарием «Deliberately GLOBAL rather than per-address». Клейм этой строки «следствие не было записано» неверен: оно записано раньше и принято. Предложение пака: свести — либо закрыть эту дублем, либо снять принятие риска с PD-42 ⚠⚠ Условие сведения, найденное рефутером и обязательное к переносу: НИ ОДНА из двух строк не называет ВТОРОЕ глобальное ведро. Кроме startLimit на /auth/login есть finishLimit на /auth/callback (internal/login/login.go:71,129,232) — тот же дефект на второй половине потока входа, и он не покрыт ни одной строкой регистра. Сводить PD-251 в PD-42 можно только назвав в объединённой строке ОБА ведра и ПЕРЕ-подтвердив принятие риска явно, а не унаследовав его: иначе открытый баг молча превращается в принятый риск, а половина дефекта исчезает из регистра вовсе |
open | research/28 §9 (пинг оркестратора №17), сверено P7 |
| PD-252 | bug | info | internal/runs/reconcile.go (карантин проекции) |
Сырой Go-текст ошибки сохраняется причиной карантина в пользовательской строке БД. На провод он не идёт (карантин не проецируется ни одним полем контракта), но это движковый и внутренний текст в таблице, которую читают дампами; тот же класс, что Problem.detail, только в базе. Лечение — закрытый словарь причин карантина, как у books.reject_reason |
open | research/28 §9 (пинг оркестратора №17), сверено P7 |
| PD-254 | standards | info | internal/httpapi/problem.go WriteStatusProblem, codeForStatus |
Два писателя ошибок на одну форму тела. WriteProblem выводит статус ИЗ кода (пара не может разойтись), а WriteStatusProblem идёт обратно — от статуса к коду — потому что вне версионного префикса (/auth, /readyz) есть статусы, которых в словаре контракта нет вовсе (405, 429). Свести в один писатель можно только назначив форму ответа для этих двух статусов, а это ратификация, не правка зоны. Пока — два пути и обратная функция рядом с прямой ⚠ ЗАКРЫТО решением контрактной сессии 17.08 (релей владельца): поверхность входа отвечает тем же конвертом и БЕЗ машинного кода — это ратифицированное решение, а не пробел («различать причины отказа клиент не может по замыслу», компаньон §2.14 с 0.2.3), а единственный осмысленный для пользователя случай 429 машинен без словаря, потому что лечение едет в Retry-After. Следствие для кода: писатель входа кода не эмитит, codeForStatus и writeRaw удалены — обратной функции, то есть второго источника истины, больше нет. Пин httpapi.TestTheSignInSurfaceAnswersTheSameEnvelopeWithoutAVersionedCode (шесть статусов). Отвергнуто с доводом: расширение ErrorCode (значения, недостижимые на описываемой им поверхности, ломают инвариант «код называет свой статус») и словарь в компаньоне (документ, не нормативный для формы, стал бы нормативным с чёрного хода) |
fixed(P7, дерево сессии) | кросс-модельное ревью P7 (линза скоупа) |
| PD-255 | doc | info | internal/pgstore/migrations/00016_read_surface.sql, internal/pgstore/readmodel.go |
Плотность комментариев выше нормы зоны («одна-две строки почему», владелец 26.07): миграция 00016 — 250 строк, из них около половины проза; у readmodel.go многие символы несут абзацы. Часть прозы несущая (порядок delete/insert в двух таблицах — ровно то, на чём пак и споткнулся), часть — эссе. Подрезано самое тяжёлое; остальное — предмет решения владельца о норме, а не тихой правки ⚠ ЗАКРЫТО решением владельца 21.08: НОРМА СМЕНИЛАСЬ, и строка была открыта против снятой формулы. Счёт строк снят как негодный гейт — комментарий на три строки может быть нужен, на одну достаточен; режется ВОДА (пересказ решений, провенанс, изложение исследования вместо ссылки), а всё, что из одной функции НЕ выводится — порядок блокировок, инварианты между таблицами, цена забывания, вендор-квирк — остаётся, сколько бы строк ни заняло. Формулировка — docs/architecture/12-go-style-notes §1. Обе названные здесь прозы под новой нормой законны: порядок delete/insert между двумя таблицами из функции не выводится, а сам дефект пака это доказал. Тихой правки не было и не будет |
fixed(решение владельца 21.08) | кросс-модельное ревью P7 (линза энтропии) |
| PD-256 | bug | info | internal/ingest/export.go DecodeExport |
Сигнал дрейфа конфигурации движка не читается. tmctl export отдаёт config_drift/current_snapshot — «текущая конфигурация рендерит не тот снапшот, что несут сохранённые строки», то есть диспозиции могут не описывать то, что прогон делал. Платформа материализует текст без этого признака: на проводе места для него нет (и не должно быть — это операторский факт), но в логе материализатора он был бы уместен |
open | кросс-модельное ревью P7 (линза шва) |
| PD-345 | hardening | info | internal/runner/engine.go readEngine, firstLine |
stderr движка читается БЕЗ потолка, вплотную к намеренному потолку на stdout. doc берётся через io.LimitReader(out, limit+1) и переполнение отдельно судится (drain, «a cap that is at the mercy of the thing it bounds is not a cap»), а errOut — простой bytes.Buffer, и firstLine отдаёт из него префикс любой длины. Асимметрия и есть находка: рядом стоит комментарий, объясняющий, почему потолок обязателен, и на соседнем канале его нет. Сегодня цена ограничена (процесс короткий, движок свой), но строка уезжает в ошибку, в лог и — до P8-FIX — в колонку БД (это половина PD-340, там закрыта на стороне хранения). ⚠ Найдено ревью P8-FIX и НЕ чинится этим паком: вне его скоупа, названо, чтобы не потеряться |
open | ревью P8-FIX (контракт-конформность, соседство с PD-340) |
| PD-245 | standards | info | internal/ingest/supervisor.go |
У дев-супервизора нет ни одного потребителя вне собственных тестов (grep по зоне): это дев-путь D39.106 §3, который пережил постройку продового шва. Его чинят и держат в шаге с продовым (PD-224, PD-237) — но либо он должен быть подключён к дев-режиму демона, либо снят вместе со своими тестами; сейчас это код, который стоит сопровождения и ничего не обслуживает |
open | кросс-семейное ревью дофикса P6 (линза шва) |
| PD-218 | bug | info | internal/pgstore/migrations/00015_seam_ceiling_and_units.sql |
Down-путь 00015 падает на данных, которые накатанная схема уже допускает: он сужает runs_paused_reason_check обратно к одному значению, а строки с daily_ceiling/ceiling_unknown к этому моменту существуют. Откат транзакционный, поэтому падение ничего не портит, но плана отката ниже 15 нет — как и ниже 5 (deploy/README.md). Лечится либо переводом таких строк в down-пути, либо честной записью «ниже 15 не откатываемся» |
open | приёмка P6 (дофикс, ФП-7) |
| PD-220 | hardening | info | internal/config/config.go Load |
Резерв имени dev сравнивается байт-в-байт: TM_PLATFORM_OIDC_PROVIDER=Dev проходит гейт. Сегодня инертно, и ровно по той же причине: Postgres сравнивает identities.provider тоже байт-в-байт, поэтому в пространство имён дев-входа такой издатель не попадает. Станет опасным в день, когда сравнение личности станет регистронезависимым |
open | приёмка P6 (дофикс, ФП-7) |
| PD-221 | bug | info | internal/runs/spawn.go engineStreamID, internal/pgstore/runs.go EngineStreamID |
Имя потока переиспользуется при повторном спавне ТОЙ ЖЕ попытки: оно детерминировано по паре (прогон, номер попытки), а движок отвергает id, который уже писал события этой книги, и чеканит свой (store.EventsUsed, pipeline/events.go). Тогда платформа не узнаёт собственный поток и живёт на медленном ре-синке — свежесть, не деньги. Ре-спавн одной попытки бывает после отданной назад заявки на спавн |
open | приёмка P6 (дофикс, ФП-7) |
| PD-222 | standards | info | cmd/tmplatformctl/seed.go |
HTTP-променад сида не покрыт тестом: вход, грант, загрузка и ожидание интейка проверены только живым прогоном на стенде (P6), автоматически — лишь разбор аргументов (TestASubjectThatIsOnlyPaddingIsNoSubject). Дев-инструмент, но именно он — единственный потребитель контракта в репозитории, и его поломка видна только тому, кто поднимет стенд |
open | приёмка P6 (дофикс, ФП-7) |
| PD-400 | doc | info | internal/runs/bank.go bankVerdict (класс 12) и шапка файла, internal/runner/engine.go Manifest/Export |
Слово run_in_flight у двери правок покрывало больше, чем прогон; пер-книжный мьютекс внутрипроцессный. Разобрана на две половины по слову владельца 27.08 («техдолг в паке не держим»), диспозиция каждой явная. (1) ЗАКРЫТА этим же деревом: раскладка слова сделана точной по построению — run_in_flight отвечается ТОЛЬКО из проверки собственной строки прогона под мьютексом, а класс 12 от самого глагола под тем же мьютексом прогоном быть НЕ МОЖЕТ (Start/Resume ждут этот мьютекс, реконсилер рестартует только живые строки, которые проверка видит) — это транзиентный держатель флока (границная материализация, ручной tmctl), и он едет 503 service_unavailable «занято, повтори позже», а не словом про прогон, которого нет; пин — кейс «a held project = a transient holder» в TestBankVerdictKeepsTheRemediesApart. (2) ГРАНИЦА v1, названная с условием и ценой (на перевод в «Принятый риск» словом лендинга; ⚠ цена ПЕРЕ-ОПИСАНА по воркфлоу-ревью 28.08 и слову оркестратора — прежняя редакция говорила «ложного слова нет», и это неверно): мьютекс runs.Service.books — внутрипроцессный; вторая реплика платформы над одним хранилищем сужает сериализацию до пер-репличной. Цена ДВУСТОРОННЯЯ: (а) translate, заспавненный в флок глагола, умирает холостой попыткой — деньги и данные целы; (б) обратная сторона ТОЙ ЖЕ гонки: глагол проигрывает флок НАСТОЯЩЕМУ прогону, который соседняя реплика допустила своим мьютексом по чистой на тот миг таблице, — и класс 12 тогда отвечается 503 «транзиентный держатель, повтори позже» про живой многочасовой прогон, то есть ЛОЖНЫМ словом (канонно верное там — 409 run_in_flight). Принятие риска включает эту ложь, а не только холостую попытку; оговорка внесена и в комментарий ветки класса 12 (bank.go). Условие: граница ПЕРЕСТАЁТ держать в день второй реплики; лечение тогда — арбитр в хранилище, не больший мьютекс. Сегодняшний деплой однорепличный по всей зоне (in-memory resynced в том же сервисе — тот же допуск) |
accepted-risk(акт D39.162; половина 1 — fixed тем же актом; перевод по аудиту 28.08. Цена риска — по пере-описанию Р8 в теле: «ни денег, ни порчи» держится, «ни ложного слова» — НЕТ, мульти-репличный класс 12 может ответить 503 про живой прогон) | пак P9: опровергатель раскладки кодов (находка 2) · разбор по слову владельца 27.08 · воркфлоу-ревью 28.08 (линза code-layout): цена включает ложное слово · аудит документации 28.08 |
| PD-408 | doc | info | internal/runs/bank.go (бюджет двери = s.runBudget()), internal/runner/bankapply.go (errOut без лимита; Stderr: firstLine) |
Две операционные оговорки двери правок, названные воркфлоу-ревью; обе — цена конфигурации, не дефект пути. (1) Бюджет двери — та же ручка TM_PLATFORM_RUN_BUDGET, что у прохода свипа; движок выбирал потолок 5000 решений против ЖЁСТКИХ 60 с («пять раз внутри бюджета»), и оператор, понизивший ручку (к чему соседние комментарии подталкивают), делает легальный документ-максимум навсегда неприменимым — вечный 503 вместо «разбей документ»; связка ручки и капа нигде не названа. (2) stderr глагола читается в НЕограниченный bytes.Buffer, хотя потребляется только первая строка, — не-тот бинарь по сконфигурированному пути (полудеплой, обёртка) может раздуть демона до OOM за 60-секундный бюджет; stdout той же команды капнут 64 МиБ |
open | воркфлоу-ревью P9 28.08 (линзы door:lock-lifecycle · door:crash-windows), диспозиция оркестратора 28.08: строкой |
| PD-423 | standards | minor | internal/runner/systemd_test.go TestARunIsBoundedByItsOwnCgroup, docs/STACK_DECISIONS.md «Гейты батареи» |
Батарея зоны требует ЧЕТВЁРТОГО условия хоста, которого рецепт не называет: пользовательский менеджер systemd должен РЕАЛЬНО применять MemoryMax к транзиентным юнитам. Замерено на этом хосте 29.08: тест трижды подряд зелен в полных батареях (baseline2, final2, final4), затем пять раз подряд красен в изоляции — при неизменном коде пакета, которого пак не касался вовсе. Причина установлена ВНЕ батареи и вне Go: systemd-run --user --scope -p MemoryMax=64M … даёт процессу спокойно занять 400 МиБ и выйти с кодом 0. ⚠ МЕХАНИЗМ уточнён приёмкой оркестратора №19, и уточнение решает, воспроизводимо ли это: «systemd не применяет потолок» верно по симптому и мимо по причине. Свойство ДОЕХАЛО — systemctl --user show <scope> -p MemoryMax печатает 67108864; делегирование в порядке — memory pids и в cgroup.controllers, и в subtree_control. Пропал не потолок, а cgroup-КАТАЛОГ: в app.slice нет ни одного scope-каталога, а cut -d: -f3 /proc/self/cgroup для оболочки даёт /init.scope. То есть вызывающий процесс живёт ВНЕ user@<uid>.service; systemd-run --user заводит юнит в модели менеджера, а процесс остаётся в исходном cgroup, и лимита не получает никто — молча. ⚠ Отсюда и наблюдение «условие отваливается между двумя прогонами одной сессии»: оно зависит от того, из какого cgroup стартовал прогон. Тест при этом ПРАВ и его сообщение точное («check that the leaf cgroup of tm-runs.slice has memory.max»): он ловит ровно то, ради чего написан, — что потолок памяти прогона на этом хосте иллюзорен. ⚠ Следствие для процесса, а не только для теста: рецепт STACK_DECISIONS называет три условия батареи, а их четыре, и четвёртое — свойство ХОСТА, которое может отвалиться между двумя прогонами в одной сессии, что здесь и произошло. Сессия, наступившая на это, потратит время на поиск дефекта в своём диффе. ⚠ Условие для рецепта формулируется НЕ так, как оно там сейчас стоит. «Достижимый пользовательский менеджер systemd» выполнено — менеджер отвечает, tm-runs.slice виден, — и всё равно лимит не применяется. Правильная формулировка: вызывающий процесс обязан жить ВНУТРИ user@<uid>.service (проверка: cut -d: -f3 /proc/self/cgroup не должен давать /init.scope). Оболочка, поднятая вне пользовательского входа — под WSL это обычный случай, — проходит первую проверку и валит вторую, и следующий читатель решит, что условие выполнено, и пойдёт искать дефект в Go. Лечение: назвать условие в рецепте именно так и дать тесту различать «хост не применяет лимит» (честный скип с причиной) и «раннер не передал лимит» (настоящий отказ) |
open | пак P11 (финальная батарея; воспроизведено голым systemd-run вне Go) |
| PD-424 | bug | major | internal/runs/reconcile.go reopen (вердикт deferred) и reconcileOne, internal/pgstore/runs.go StalledRuns, internal/pgstore/observe.go |
ЖИВОЙ прогон с намертво заблокированной расплатой невидим на ВСЕХ поверхностях PD-385 и не поддаётся run abandon — счётчик неудач не растёт НИКОГДА. Цепь, воспроизведённая охотником приёмки на десяти проходах: юнит исчез без маркера → restart → settle возвращает settlementBlocked, nil (не ошибку) → reopen отказывается открыть новую попытку поверх незакрытого холда и возвращает вердикт deferred → case deferred: return nil → reconcileOne считает проход УСПЕШНЫМ и вдобавок зовёт ClearRunDeferral. Итог: reconcile_failures 0, reconcile_after NULL, StalledRuns(5) пуст, гейдж 0, холд заморожен, движок дёргается каждым проходом. Вывести из состояния может только пользователь, нажав Stop, — и никто ему об этом не говорит. ⚠ Это ТА ЖЕ болезнь, что PD-384/PD-385, но в ЖИВОЙ фазе, куда пак P11 не дошёл: он лечил вторую фазу (расплату) и её поверхности. ⚠ Почему пак не взял её в себя (решение сессии, не пропуск): лечение упирается в вопрос, которого нет ни в одной строке заказа, — чем должен считаться вердикт deferred для СЧЁТЧИКА: не-событием (как сейчас), успехом или неудачей. Это дизайн реконсиляционной фазы, а пак был про фазу расплаты; вмешаться в него по дороге значило бы «распухнуть вокруг самого срочного», против чего промт предупреждает прямо. Форма лечения, которую сессия предлагает: reopen, вернувший deferred, обязан сообщать established=false (тогда ClearRunDeferral хотя бы не стирает счёт), а сам блокированный расчёт — считаться неудачей ТОЙ фазы, которая им владеет |
open | приёмка оркестратора №19 по паку P11 (охотник вне карты, воспроизведено 10 проходами) |
| PD-425 | bug | major, деньги | internal/httpapi/bank.go:149 (r.Context()), internal/runs/bank.go RecordBankMove |
Дверь банковских коррекций теряет пост-verb факт НАВСЕГДА при обрыве клиента, и следующая покупка платит за это холдом. Хендлер берёт r.Context(), и RecordBankMove пишется на нём же; клиент закрыл вкладку до ответа — контекст отменён, запись не легла, bank_moved_at остаётся NULL. Второго писателя у факта нет, свипа нет, то есть состояние вечное. Деньги дальше: re_pass отвечает 409, а ОБЫЧНЫЙ прогон допускается с resnapshot=false и убивается снапшот-гардом движка ПОСЛЕ взятия холда — холд взят, попытка потрачена впустую. ⚠ Средство, которое дверь называет, недостижимо по построению: она отвечает 503 со смыслом «клиент пере-шлёт», а запись падает именно потому, что клиента уже нет. ⚠ Шаблон лечения лежит В ЭТОЙ ЖЕ ЗОНЕ и здесь не применён: internal/pgstore/idempotency.go:150 и books.go:205,215 делают WithTimeout(WithoutCancel(ctx), …) ровно с этой мотивировкой. ⚠ Не предмет пака P11 (дверь построена паком P9/P10), поэтому заведено строкой, а не починено: правка денежного пути чужого пака требует своих пинов и своей приёмки |
open | приёмка оркестратора №19 по паку P11 (охотник вне карты) |
| PD-426 | bug | minor | internal/pgstore/runs.go Quarantine, internal/ingest/tail.go (четыре отказа выше ветки default) |
Карантин проекции не снимается НИЧЕМ, а попасть в него можно по чужому законному handshake'у. Первое: quarantine_reason пишется, и во всём дереве нет ни одного места, которое его очищает, — то есть состояние терминально для проекции живого оплаченного прогона. Второе: в ingest/tail.go четыре отказа стоят ВЫШЕ ветки default, которая говорит «другой поток начинается здесь, нас не касается», при том что журнал ПЕР-КНИЖНЫЙ и append-only, так что чужие handshake'ы в нём законны. Вместе: чужой handshake в журнале книги карантинит проекцию прогона, за который заплачено, навсегда. ⚠ Не предмет пака P11 (тейлер и карантин — эры P4/P5), заведено строкой |
open | приёмка оркестратора №19 по паку P11 (охотник вне карты) |
| PD-421 | hardening | info | internal/pgstore/sessions.go StillLive и SweepSessions, docs/STACK_DECISIONS.md §13 |
Открытый поток теряет свою сессию по ПОДМЕТАНИЮ строки, а не по клаузе бездействия, — и это остаток закрытия PD-379, названный прямо. Проверка живости потока намеренно НЕ содержит клаузы idle_expires_at: окно бездействия скользит на ЗАПРОСЕ, а поток — один запрос на всю жизнь, поэтому гашение по idle рвало бы связь активному читателю. Но SweepSessions раз в час УДАЛЯЕТ строки и по бездействию тоже, а «строки нет» ОБЯЗАНО значить «мертва» — иначе отозванная сессия держала бы поток до свипа, то есть дыра ровно в час. Следствие: сессия, протухшая по бездействию и подметённая, теряет поток с опозданием до часа. Это не idle гасит поток, а отсутствие строки; к тому моменту любой другой запрос того же вызывающего — 401. Лечение, если сочтётся недопустимым, — скольжение окна бездействия ИЗ потока, но это правка ПОЛИТИКИ §13: открытая вкладка держала бы сессию до абсолютного потолка, а это слово владельца |
open | пак P11 (назван при закрытии PD-379) |
| PD-422 | bug | info | internal/runs/runs.go:288 if book.BankMoved, internal/pgstore/books.go:1030 |
--resnapshot платформа передаёт УСЛОВНО, а условие ставит только ПРАВКА банка — рост авто-банка от майнинга его не ставит. Флаг выводится под if book.BankMoved, а единственный писатель bank_moved_at — дверь правок банка. На книге, которая МАЙНИТ банк, вторая покупка без правок банка идёт без флага, и движковый джоб-гард останавливает прогон (exit 1 ⇒ failed на стороне платформы): авто-банк растёт от покупки к покупке, edit-снапшот съезжает, а гард банк-онли-движение от смены конфига не отличает. ⚠ Сегодня БЕСПРЕДМЕТНО: проводка tmctl translate --max-units на платформе гейчена оркестратором до лечения, а без неё вторая покупка этой формы не возникает. Строка заведена, чтобы условность не всплыла сюрпризом при снятии гейта. Найдено бэкенд-сессией textmachine-e4 (пак «деньги»), проверено чтением платформенной стороны сессией P11 |
open | бэкенд-пак «деньги» + пак P11 (сверка шва) |
Принятый риск
Не дефекты, а решения: цена названа и принята, чтобы это не выяснилось молчанием.
| ID | Класс | Серьёзность | Где | Суть | Статус | Источник |
|---|---|---|---|---|---|---|
| PD-22 | hardening | info | deploy/ |
Ограничителя одновременных соединений нет ни в процессе, ни описанного edge-прокси: ReadTimeout ограничивает УДЕРЖАНИЕ одного соединения 30 секундами, но не их число. Осознанно оставлено деплой-слою (LimitNOFILE, edge) — строка заведена, чтобы это было решением, а не забывчивостью |
accepted-risk(платформа P1, 05.08) | самопроверка P1 |
| PD-42 | hardening | info | internal/login/login.go |
Лимит /auth/login глобальный: один хост держит ведро пустым и выключает вход всем (замерено: 8 отказов из 10 у «легитимного» пользователя при фоне 5 rps). Пер-адресный лимит здесь неверен, пока нет доверенного edge-прокси — за прокси RemoteAddr один на всех. Место лимита — edge |
accepted-risk(платформа P1, 05.08) | ревью безопасности (исполнением) |
| PD-71 | bug | info | internal/pgstore/migrations/00005_identity_oauth.sql:72 |
Down-путь 00005 не исполним на данных, которые его же up-путь делает законными, поэтому откат ниже версии 5 недоступен. Он восстанавливает users_email_key и email NOT NULL, а боевой код пишет email = NULL у неподтверждённой личности и кладёт один подтверждённый адрес на два аккаунта (следствие «почта не ключ»). Перепроверено моим прогоном, не принято со слов ревью: три реальных аккаунта (один с email = NULL, два с общим подтверждённым адресом) — DownTo(5) проходит, DownTo(4) падает с could not create unique index "users_email_key" (SQLSTATE 23505); первым срабатывает индекс, до NOT NULL выполнение не доходит. Данные целы — down транзакционный, Up() вернул схему на версию 8 со всеми тремя аккаунтами, — но плана отката ниже 5 не существует. Править 00005 запрещает append-only, а чужой down-текст новая миграция не заменяет — принято как ЦЕНА ПРАВИЛА: записано в STACK_DECISIONS §8 и в deploy/README.md разделом «Откат релиза: не ниже версии 5», чтобы оператор не узнал это в момент отката |
accepted-risk(зона P2, 05.08) | ревью P2 (линза sql-money) |
| PD-179 | hardening | info | cmd/tmplatformd/main.go serveMetrics, deploy/ |
Эндпоинт /metrics не аутентифицирован и защищён только адресом привязки. Дефолт 127.0.0.1:9464, то есть снаружи недостижим; экспозиция несёт операционную форму деплоя (глубина очереди, число прогонов, возраст холдов), но не пользовательские данные и не деньги. Второй модели авторизации ради скрейпера зона не заводит — это ровно тот довод, по которому админ-поверхность стала CLI (§10). Риск принят: оператор, поднявший TM_PLATFORM_METRICS_ADDR на внешний адрес, открывает её сам, и об этом сказано в deploy/README.md |
accepted-risk(платформа P5, 11.08) | сессия P5 |
Закрытые ратификацией
Строки, у которых лечением был не код, а решение владельца контракта или оркестратора.
| ID | Класс | Серьёзность | Где | Суть | Статус | Источник |
|---|---|---|---|---|---|---|
| PD-59 | bug | info | docs/platform-PROGRESS.md, вопрос оркестратору №4 |
Канал не меняем — но решает это не тот довод, который обсуждали. Вопрос вынесен абстрактно (без контекста репозитория) двум независимым агентам, с доступом в сеть и без. По каналу они РАЗОШЛИСЬ, зато независимо сошлись на трёх вещах, которых не было ни в записке зоны, ни в первых трёх редакциях приёмки. (1) SIGPIPE зависит от НОМЕРА дескриптора (os/signal: обрыв на fd 1/2 убивает процесс, на любом другом — возвращает EPIPE). Замерено: поток на fd 1 → ребёнок УБИТ broken pipe; на fd 3 → write вернул EPIPE и процесс доработал до конца. Для нас это деньги: сегодня падение платформы убивает движок на следующей же записи события, а после переезда движок станет сиротой и часами будет жечь оплаченные вызовы, пока холд висит в леджере и некому его закрыть. Свойство несущее и нигде не записано. (2) Настоящая защита — не выбор канала, а перехват на уровне дескриптора в main движка: dup(1) в приватный fd, затем dup3(2,1,0). Он герметичен там, где предложенный приёмкой os.Stdout = os.Stderr дыряв: переживает var out = os.Stdout в зависимости, cgo и унаследованный fd 1 у внуков. (3) Дискриминатор, при котором переезд был бы прав — «ребёнок исполняет чужой код, наследующий stdio». Проверено: у нас нет — grep по backend/ не находит ни одного exec.Command вне тестов и ни одного cgo. Плюс сверено: ловушка bufio.Scanner, которую оба назвали самым вероятным латентным багом (переполнение строки читается как чистый EOF), у нас закрыта — Buffer поднят до 1 МиБ и sc.Err() проверяется (decoder.go:45,115) |
закрыт ратификацией, работа уходит строкой 103 | приёмка P1 (четвёртая итерация: два независимых агента + замер SIGPIPE) |
Закрытые — эра P1 (вход · кредиты · админ-CLI)
| ID | Класс | Серьёзность | Где | Суть | Статус | Источник |
|---|---|---|---|---|---|---|
| PD-1 | hardening | minor | internal/pgstore/pg_test.go:89 |
Свойство «в БД только SHA-256, не токен» НЕ запинено тестом: посадка «Digest возвращает плейнтекст» выживает — тест сверяет хранимое через тот же auth.Digest (self-consistent). Нужен тест с НЕЗАВИСИМО вычисленным хешом либо ассерт «плейнтекст в БД не находится» — закрыто: internal/pgstore/pg_test.go — TestStoredCredentialIsAHashNotTheToken: оракул SHA-256 считается в тесте, плюс поиск плейнтекста в отрендеренной строке. Посадка «Digest возвращает плейнтекст» ПАДАЕТ (проверено) |
fixed(P1, дерево сессии) | приёмка P0 (посадка №1) |
| PD-2 | vuln | major, ЖИВАЯ (не латентная) | cmd/tmplatformd/main.go:73-82 |
Нет ReadTimeout ⇒ соединения пиннятся уже СЕГОДНЯ, без единой body-принимающей ручки: net/http дренирует непрочитанное тело <256 КБ ВНУТРИ chunkWriter.writeHeader до отправки заголовка ответа (net/http/server.go:1389-1435), и этот чтение-шаг наследует отсутствующий дедлайн. Репродуцировано оркестратором на собранном бинаре: 50 полу-кормленных POST на охраняемый /v0/* → сервер отработал и залогировал 50×401 ms:0, клиенты получили НОЛЬ байт, fd 7→57 и держались, пока не закрыл КЛИЕНТ (агент-скептик независимо пинил 500 соединений). Ограничителя соединений и документированного edge-прокси в зоне нет. Фикс — одна строка (ReadTimeout; для будущего SSE — per-conn дедлайны через ResponseController). Вторая половина (MaxBytesReader) сегодня не эксплуатируема (ни один хендлер не читает body) — гейт P1: закрыть ДО первого POST-хендлера — закрыто: ReadTimeout 30 с в httpapi.DefaultTimeouts; пин — TestHalfFedRequestIsDroppedByTheServer на РЕАЛЬНОМ http.Server. Живая проба: полу-кормленный POST теперь отпускается через 30.0 с (был бесконечно). Вторая половина закрыта LimitBody на поддереве /v0 и /auth. Побочное обязательство «ReadTimeout рубил бы и SSE» ОПРОВЕРГНУТО в P2 (PD-51/PD-63): net/http снимает дедлайн сам, помощник ClearReadDeadline удалён как воспроизводивший ровно этот дефект; поток пинит TestStreamOutlivesReadTimeout |
fixed(P1, дерево сессии) | приёмка P0 (security-линза + скептик + собственная репродукция) |
| PD-3 | bug | minor | internal/httpapi/middleware.go:57 |
Recover логирует сырой r.URL.Path на ERROR — id книг/прогонов утекают в лог, против собственной дисциплины AccessLog (route-pattern, не путь) — закрыто: Recover логирует route, не r.URL.Path |
fixed(P1, дерево сессии) | приёмка P0 (security-линза) |
| PD-4 | hardening | minor | internal/pgstore/sessions.go:41 |
WHERE у Touch слабее, чем у Lookup (нет idle_expires_at > now): прямой вызов воскресил бы idle-истёкшую сессию. Через Require недостижимо (Touch только после успешного Lookup) — одна строка защиты в глубину — закрыто: клауза idle_expires_at > $2 добавлена; пин — TestTouchCannotResurrectAnIdleExpiredSession (посадка падает) |
fixed(P1, дерево сессии) | приёмка P0 (security-линза) |
| PD-5 | bug | minor | internal/auth/middleware.go:36,45 |
Ошибки стора невидимы: сбойный Lookup → 401 без единой строки лога (аутентификационный DB-outage выглядит как шторм 401), Touch глотается _ =. На проводе различать нельзя (оракул) — но лог обязан различать — закрыто: Authenticator.Log: сбой Lookup (кроме ErrNoSession) и сбой Touch уходят в ERROR с request_id; на проводе по-прежнему неразличимо |
fixed(P1, дерево сессии) | приёмка P0 (security+blind линзы) |
| PD-7 | bug | info | internal/pgstore/sessions.go:78 |
DeleteExpiredSessions никем не вызывается — свип запланировать в P1 (периодическая джоба воркера) — закрыто: свип сессий раз в час в демоне (sweepSessions), плюс свип брошенных логинов раз в 15 минут |
fixed(P1, дерево сессии) | приёмка P0 |
| PD-8 | hardening | info | internal/auth/session.go:18 |
Писателя куки ещё нет; __Host- требует Secure ⇒ локальный dev по HTTP куку не поставит. Решить формой в P1 (dev-профиль), префикс не ослаблять в проде — закрыто: auth.Cookies{Insecure} — dev-профиль меняет ИМЯ вместе с атрибутами (tm_session без __Host-), TM_PLATFORM_INSECURE_COOKIES=1, демон предупреждает в лог |
fixed(P1, дерево сессии) | приёмка P0 |
| PD-9 | bug | minor | cmd/tmplatformd/main.go:81 |
BaseContext возвращает signal-контекст ⇒ SIGTERM мгновенно рубит контексты ВСЕХ in-flight запросов, и 15-секундный дренаж Shutdown мёртв для ctx-aware хендлеров. Fix: BaseContext без signal-ctx; сигнал ведёт только Shutdown — закрыто: BaseContext — собственный контекст, отменяется ПОСЛЕ Shutdown; пин — TestShutdownDrainsInFlightRequests (посадка «BaseContext = сигнальный ctx» падает) |
fixed(P1, дерево сессии) | приёмка P0 (faults-линза) |
| PD-10 | bug | minor | internal/ingest/decoder.go:42,61,67 |
Три ужесточения декодера: (а) hello с пустым engine_run_id принимается — а это половина ключа идемпотентности; (б) seq самого hello не пинится к 1 — потеря пре-хендшейковых строк недетектируема; (в) mid-stream hello (любой версии, вкл. мажор 9.9) уходит в Sink как обычное событие — version-гейт держит только строку 1 — закрыто: три ужесточения + ErrBadHandshake/ErrRepeatedHello; пины — TestHandshakeMustIdentifyTheStream и FuzzDecoder (4.4 млн исполнений, инварианты — оракулы) |
fixed(P1, дерево сессии) | приёмка P0 (faults-линза) |
| PD-11 | bug | minor | internal/pgstore/migrations/00002_readmodel.sql:137,177 |
Неиндексированные FK-каскады: notes.chapter_id и bank_decisions.term_id — каскадное удаление сканирует таблицы — закрыто: notes_chapter_idx + bank_decisions_term_idx; notes.unit_id уже был |
fixed(P1, дерево сессии) | приёмка P0 (faults-линза) |
| PD-12 | bug | info | internal/ingest/supervisor.go:82-84 |
Сбой Sink в начале прогона ⇒ платформа дренирует ВЕСЬ оставшийся поток в io.Discard часами: ceiling/bank_stop-события выбрасываются, никто не оповещён. Нужна политика «БД платформы упала посреди прогона» (ретраи синка / деградация с алармом) — дизайн-вопрос P1 — закрыто: сбой синка ОСТАНАВЛИВАЕТ прогон (stop() после Ingest), а не дренирует его в io.Discard; пин — TestFailingSinkStopsTheRun. Политика ретраев самого синка — при постройке материализатора |
fixed(P1, дерево сессии) | приёмка P0 (faults-линза) |
| PD-13 | bug | info | internal/ingest/supervisor.go:64 |
Краш платформы осиротляет процесс движка: ни process-group, ни pidfile, ни пути реаттача (поток невосстановим, повторный спавн упрётся в EXCLUSIVE-лок). Дизайн супервизии P1 — закрыто: группа процессов (Setpgid + сигнал группе) закрывает обычную остановку; краш платформы закрывает cgroup юнита — deploy/tmplatformd.service (проверен systemd-analyze verify, живого прогона под systemd не было) |
fixed(P1, дерево сессии) | приёмка P0 (faults-линза) |
| PD-14 | hardening | info | internal/httpapi/server.go:79 |
readyz: ping без собственного таймаута (WriteTimeout нет намеренно — SSE), эндпоинт неаутентифицирован и без rate-limit — задушить дешёво; таймаут на ping + прикрыть на ops-слое — закрыто: собственный таймаут 2 с на ping; rate-limit на ops-слое (edge), в зоне не строим |
fixed(P1, дерево сессии) | приёмка P0 |
| PD-15 | bug | info | internal/ingest/resync.go:32 |
Деньги в ре-синке — float64, а usage_windows хранит micro-USD именно против дрейфа: дрейф входит шагом раньше (JSON-парс + суммирование дельт). Принять осознанно или считать в целых — закрыто: деньги на шве — money.MicroUSD через big.Rat, округление ВВЕРХ; пины — TestSpendConvertsExactlyAndRoundsUp, TestParseUSDIsExactAndRoundsUp |
fixed(P1, дерево сессии) | приёмка P0 (faults-линза) |
| PD-16 | bug | minor | internal/httpapi/server.go:81 |
readyz глотает ошибку ping вопреки собственному комменту «the reason stays in the log» — лога нет — закрыто: ошибка ping уходит в ERROR |
fixed(P1, дерево сессии) | приёмка P0 (blind-линза) |
| PD-17 | bug | minor | Makefile:41-42 |
Баннер «did NOT run (no database)» печатается и при ПРОГНАННЫХ БД-тестах (безусловный); батарея гоняет сьют дважды ради имён скипов (второй прогон без -race) — закрыто: один прогон сьюта под -race, баннер печатается только при наличии скипов |
fixed(P1, дерево сессии) | приёмка P0 (blind-линза) |
| PD-18 | bug | info | internal/pgstore/migrations/00002_readmodel.sql:9,139 |
Коммент шапки «engine vocabulary never crosses this seam» противоречит notes.reason (движковая причина хранится, не проецируется); коммент переписать честно — закрыто: шапка миграции переписана: исключение (notes.reason) названо там же |
fixed(P1, дерево сессии) | приёмка P0 (canon-линза) |
| PD-19 | bug | info | internal/ingest/resync.go:44 |
WorstFlagReason задокументирован «stored», а колонки в chapters нет — доккоммент или схема, одно из двух — закрыто: WorstFlagReason убран из аллоулиста — в контракте v0 у главы нет читателя для него |
fixed(P1, дерево сессии) | приёмка P0 (canon-линза) |
| PD-20 | bug | minor | internal/ingest/supervisor.go:78 |
Один сигнал остановки ТЕРЯЕТСЯ, если послан в первые миллисекунды жизни ребёнка: воспроизведено на стенде отдельным экспериментом (8 запусков, промах на нулевой задержке) и как флейк собственного теста PD-12 (1 падение из 3). Последствие серьёзнее самого промаха: единственный оставшийся механизм — SIGKILL по WaitDelay, а движок держит ЭКСКЛЮЗИВНЫЙ лок на файле проекта, и после kill лок остаётся — закрыто: askToStop повторяет SIGINT на 30/120/400 мс с проверкой «процесс ещё наш» через os.Process; пин — TestFailingSinkStopsTheRun (25 прогонов подряд зелёные, до фикса падал) |
fixed(P1, дерево сессии) | самопроверка P1 (флейк собственного теста) |
| PD-21 | vuln | minor | internal/login/login.go:safeReturnTo |
Открытый редирект в ?return_to: /\evil.example проходил проверку — url.Parse читает это как обычный путь, а браузер нормализует \ в / и получает протокол-относительный URL, то есть чужой хост. Найдено ПОСАДКОЙ мутации: ослабление проверки тест пережило, значит тест был слабый — закрыто: аллоулист (первый символ /, второй не /, обратных слэшей нет, Scheme/Host/Opaque пусты), тест переписан на «каждый враждебный вход даёт ПУСТО»; посадка теперь падает. Дефект не покидал дерево сессии |
fixed(P1, дерево сессии) | самопроверка P1 (посадка мутации) |
| PD-24 | bug | major | internal/pgstore/migrations/ |
Переиспользование номера миграции: удалённый 00003_usage.sql и новый 00003_credits.sql заняли одну версию. goose применяет ТОЛЬКО по номеру (ни имени, ни хеша), поэтому база, доехавшая до версии 3, рапортует «migrations applied» и не получает ни одной новой таблицы, вход и кредиты падают в рантайме, а DownTo на ней ломается навсегда. Обоснование «до деплоя правим на месте» было допущением без механизма — закрыто: выпущенные 00001–00003 возвращены байт-в-байт, новое приехало номерами 00004–00007; гейт migrations.sha256 + TestReleasedMigrationsAreUnchanged; апгрейд со старого релиза пинится TestDatabaseAtAnOlderReleaseCatchesUp |
fixed(P1, дерево сессии) | ревью «вне карты» (исполнением) |
| PD-25 | bug | major | internal/pgstore/credits.go, 00007_credits.sql |
Ключ идемпотентности леджера не содержал user_id: грант с ключом, потраченным на другом аккаунте, молча проглатывался, а CLI печатал «granted». Плюс каскад удаления книги уносил ОТКРЫТУЮ резервацию, оставляя строку hold в леджере (деньги списаны, вернуть нечем), после чего освободившийся engine_run_id давал холд БЕЗ списания, а его релиз печатал деньги — закрыто: ключ стал (user_id, source, source_id), пустой ключ запрещён DDL, book_id перешёл на составной FK к books(id, owner_id) с on delete restrict, appendLedger возвращает «применилось», Hold падает при повторе. Пины: TestBookWithAnOpenHoldCannotBeDeleted, TestSecondHoldOnOneAttemptIsRefused, TestGrantIsIdempotentBySource |
fixed(P1, дерево сессии) | ревью денежного пути (исполнением) |
| PD-26 | bug | minor | internal/pgstore/credits.go |
Инверсия порядка блокировок Hold↔Settle/Release: 41 взаимоблокировка на 300 раундов, замерено. Settle/Release брали строку резервации раньше баланса — закрыто: lockBalance первым во всех операциях |
fixed(P1, дерево сессии) | ревью денежного пути (исполнением) |
| PD-27 | bug | minor | internal/pgstore/credits.go |
Settle принимал любую сумму: одно завышенное committed_usd уводило баланс в минус, дальше каждый прогон получал ErrInsufficientCredit без диагностики — закрыто: расчёт capped потолком холда, факт записан в note; пин TestSettlementIsCappedAtTheHold |
fixed(P1, дерево сессии) | ревью денежного пути · ревью «вне карты» |
| PD-28 | bug | minor | internal/ingest/supervisor.go |
cmd.Wait() на отменённой команде возвращает context.Canceled, а не *ExitError, поэтому исход читался как failed: штатный SIGTERM пометил бы ВСЕ идущие прогоны провалившимися — закрыто: исход из ProcessState, факт остановки едет в ошибке; пин TestStoppedRunKeepsTheEnginesOutcome |
fixed(P1, дерево сессии) | ревью стиля (клейм) + собственная проверка исполнением |
| PD-29 | vuln | minor | internal/login/login.go |
GET /auth/callback — неаутентифицированная ручка, ПИШУЩАЯ в БД, без лимита и без ретеншена: замерено 2000 строк за 2.28 с с одного хоста (~76 млн строк/сутки), строки отказов недостижимы через API и не удалялись никогда — закрыто: лимитер на колбэке, ретеншен журнала 180 дней свипом |
fixed(P1, дерево сессии) | ревью безопасности (исполнением) |
| PD-30 | vuln | minor | internal/pgstore/identity.go |
Грант фри-тира выдавался за каждую новую пару (provider, subject) без учёта email_verified: провайдер с саморегистрацией превращал каждый новый sub в $5, потолок задавал только глобальный лимитер (~$864k/сутки на бумаге) — закрыто: грант только подтверждённой личности — НЕПОДТВЕРЖДЁННАЯ создаёт аккаунт с нулём, и его начисляют руками из админки. ⚠ Исправлено 08.08 (PD-104): прежняя редакция этой ячейки говорила «аккаунт создаётся с нулём» без оговорки и противоречила коду — ПОДТВЕРЖДЁННАЯ личность получает автогрант TM_PLATFORM_SIGNUP_GRANT_USD (дефолт $5, config.go), закрыт был только путь саморегистрации. ⚠ Продуктовое следствие — вопрос владельцу в журнале |
fixed(P1, дерево сессии) | ревью безопасности (исполнением) |
| PD-31 | bug | minor | internal/login/login.go |
discover держал мьютекс на время сетевого вызова без таймаута: шесть параллельных входов при медленном IdP заняли 4/8/12/16/20/24 с вместо ~4 — закрыто: запрос вне лока, свой таймаут 5 с |
fixed(P1, дерево сессии) | ревью безопасности (исполнением) |
| PD-32 | vuln | minor | internal/login/login.go, cmd/tmplatformd/main.go |
Имя провайдера захардкожено "google" независимо от issuer, а State.Provider писался и не сверялся: смена issuer тихо кладёт чужие sub в старое пространство имён (новые аккаунты, старые недостижимы), а при двух провайдерах стейт одного редимится колбэком другого (IdP mix-up) — закрыто: TM_PLATFORM_OIDC_PROVIDER, сверка st.Provider в колбэке |
fixed(P1, дерево сессии) | ревью безопасности · ревью «вне карты» |
| PD-33 | vuln | minor | internal/auth/csrf.go |
Требование X-TM-Client снималось ЛЮБЫМ заголовком Authorization, включая мусорный: покрытие CSRF-слоя выбирал атакующий (сегодня упиралось в 401, но пережило бы любое послабление в present) — закрыто: снимает только валидный Bearer, через ту же функцию, что аутентифицирует |
fixed(P1, дерево сессии) | ревью безопасности (исполнением) |
| PD-34 | bug | minor | internal/httpapi/serve.go, cmd/tmplatformd/main.go |
Две регрессии остановки: второй SIGTERM больше не прерывал дренаж (процесс жил ровно 15 с), а просроченный дренаж возвращал ошибку и давал exit 1 — при Restart=on-failure штатная остановка читается systemd как крах — закрыто: сигнал разрегистрируется при начале дренажа, просрочка логируется WARN и даёт exit 0, добавлена строка stopped |
fixed(P1, дерево сессии) | ревью «вне карты» (исполнением) |
| PD-35 | bug | minor | internal/httpapi/middleware.go |
Лимит тела стоял самым внешним слоем, поэтому обещанное «ручка загрузки регистрирует свой, больший лимит» не работало: вложенный MaxBytesReader не может ослабить внешний, а контракт требует загрузку книги (23 МБ) — закрыто: лимит стал пер-маршрутным аргументом guard |
fixed(P1, дерево сессии) | ревью «вне карты» |
| PD-36 | hardening | minor | internal/httpapi/middleware.go |
Не было HSTS, CSP и запрета фрейминга; __Host- защищает запись куки, а не первый навигационный запрос — закрыто: Content-Security-Policy: default-src 'none'; frame-ancestors 'none', X-Frame-Options: DENY, HSTS в прод-профиле (в dev выключен: пин политики на localhost — долгая ошибка) |
fixed(P1, дерево сессии) | ревью безопасности |
| PD-37 | bug | minor | internal/login/login.go |
safeReturnTo заявляла защиту, которой не давала: проверка обратного слэша работала по уже раскодированной строке, а браузер декодирует цель редиректа ещё раз (/%5c/evil.example). Эксплуатируемого редиректа не получено, но три проверки из четырёх держались на поведении браузера — закрыто: проверка обеих форм, теста добавлены процент-кодированные входы |
fixed(P1, дерево сессии) | ревью безопасности (исполнением) |
| PD-38 | hardening | info | internal/pgstore/sessions.go, 00005_identity_oauth.sql |
Отозванные сессии не удалялись до абсолютного срока (90 дней); журнал входов каскадно стирался вместе с аккаунтом, хотя объявлен доказательством для расследования — закрыто: свип берёт отозванные и idle-протухшие, login_events.user_id перешёл на on delete set null (строка анонимизируется, не уничтожается) |
fixed(P1, дерево сессии) | ревью безопасности |
| PD-39 | bug | info | internal/money/money.go |
Док обещал округление «от нуля», код округляет к +∞; отрицательные дроби не были покрыты тестом вовсе. Плюс USD() на MinInt64 печатал мусор, а вход не имел ограничения длины (2 МБ → 6.1 с и сообщение об ошибке на 2 МБ) — закрыто: док приведён к коду, отрицательные кейсы запинены, потолок длины 64 символа, рендер без отрицания |
fixed(P1, дерево сессии) | ревью денежного пути · ревью стиля |
| PD-40 | bug | info | internal/ingest/resync.go |
Отсутствующий/null/пустой committed_usd декодировался в 0 — неотличимо от «попытка не стоила ничего»; на пути расчёта это освободило бы холд и не списало ничего — закрыто: Spend стал указателем, пустая строка — ошибка |
fixed(P1, дерево сессии) | ревью «вне карты» |
| PD-41 | bug | info | internal/login/login.go, internal/httpapi/ |
Поверхность /auth/* отвечала stdlib-телами text/plain на 404/405 вопреки нормативу «ответы problem+json»; ошибки стора и сработавший лимитер не логировались; паника писалась без стека; успешный вход не оставлял следа, а недоступность провайдера классифицировалась как «токен отвергнут» — закрыто: метод проверяется в обёртке с problem+json, добавлены login succeeded, sign-in rate limit engaged, provider_unreachable, стек паники, login_start_id для склейки двух половин входа |
fixed(P1, дерево сессии) | ревью логов (исполнением) |
Закрытые — эра P2 (фикс-пак приёмки P1)
| ID | Класс | Серьёзность | Где | Суть | Статус | Источник |
|---|---|---|---|---|---|---|
| PD-46 | hardening | minor | internal/httpapi/serve.go:33-40 |
Запинена ПРОВОДКА ReadTimeout, но не ЗНАЧЕНИЕ, с которым едет демон. Тесты строят свой Timeouts (fastTimeouts), поэтому посадка «DefaultTimeouts().Read = 0» проходит ВСЮ батарею зелёной — а main.go:121 берёт именно DefaultTimeouts(). Посадка «убрать ReadTimeout из NewServer» ловится (проверено), то есть дыра ровно в дефолтах. Это форма, в которой PD-2 пережил P0: свойство проверено не на том объекте, который едет в прод. Фикс — тест на сами значения DefaultTimeouts — закрыто: httpapi.TestTheServerTheDaemonRunsHasEveryDeadlineSet утверждает не литералы, а сам *http.Server, который строит NewServer(…, DefaultTimeouts()): каждый дедлайн >0, WriteTimeout ОБЯЗАН быть нулём (иначе резал бы SSE), ReadHeaderTimeout <= ReadTimeout, grace >0. Закрывает обе половины — значение и проводку. Пять посадок поймано поимённо: DefaultTimeouts().Read=0, снятие ReadTimeout из NewServer, снятие IdleTimeout, добавление WriteTimeout «для симметрии», снятие Unwrap |
fixed(P2, дерево сессии) | приёмка P1 (посадка M23/M43) |
| PD-47 | bug | minor | internal/login/login_test.go:266 |
Закрытие PD-37 заявлено неверно: «в тесты добавлены процент-кодированные входы» — их там нет (список: //evil.example/, https://…, http:/…, /\evil.example, /\/evil.example, /\tevil, evil.example, ``). Посадка «судить только сырую форму, без второго декода» батарею ПЕРЕЖИВАЕТ. Побочно: посадка «убрать обратный слэш из ContainsAny» тоже переживает — на тестовых входах её дублирует проверка s[1]. Эксплуатируемого редиректа нет; не запинена именно та защита, ради которой заведён PD-37 — закрыто: в таблицу добавлены процент-кодированные входы (/%5c/, /%5C/, /%09, /%00, /%0d%0a) — их ловит ТОЛЬКО второй декод — и /%2f/evil.example, который ловит ТОЛЬКО проверка s[1] на декодированной форме; плюс FuzzSafeReturnTo, который пинит СВОЙСТВО независимым оракулом (url.URL.ResolveReference после браузерной нормализации \→/), 3,1 млн исполнений без контрпримера. Посадки «судить только сырую форму», «убрать класс символов», «убрать protocol-relative» падают каждая. ⚠ Побочно установлено: условия u.Scheme/u.Host/u.Opaque НЕДОСТИЖИМЫ как отказ (при raw[0]=='/' схемы и Opaque не бывает, Host требует //), пин на них невозможен — оставлены бэкстопом, это названо в коде |
fixed(P2, дерево сессии) | приёмка P1 (посадки M15/M16) |
| PD-48 | hardening | minor | internal/login/login.go:268-271 |
Правило PD-30 «грант только подтверждённой личности» не запинено ничем: удаление if !claims.EmailVerified { grant = 0 } проходит все тесты internal/login. pgstore.TestUnverifiedAddressStaysOffTheAccount пинит другое свойство (адрес не поднимается на аккаунт), денежное — никто. По правилу шапки этого файла PD-30 закрытым не считается — закрыто: login.TestSignupGrantGoesOnlyToAVerifiedIdentity гоняет обе ветки через настоящий поток и сверяет САМ грант, дошедший до стора (memStore теперь его запоминает — раньше отбрасывал, потому правило и было незапинено). Посадка «убрать условие EmailVerified» падает |
fixed(P2, дерево сессии) | приёмка P1 (посадка M14) |
| PD-49 | hardening | minor | internal/login/login.go:239-242 |
Вторая половина PD-32 не запинена: удаление сверки st.Provider != h.cfg.Provider проходит все тесты. Сегодня провайдер один, поэтому свойство латентное — но заведено оно ровно под появление второго (IdP mix-up) — закрыто: login.TestStateFromAnotherProviderIsRefused подменяет провайдера в сохранённой строке состояния — форма, которую даёт появление второго провайдера, — и требует 400, отсутствия сессии, причины state_from_another_provider в журнале и НУЛЯ обращений к token endpoint. Посадка «убрать сверку» падает. Норму при этом закрывает не она, а PD-57 |
fixed(P2, дерево сессии) | приёмка P1 (посадка M19) |
| PD-50 | hardening | info | internal/auth/csrf.go:55-60 |
Закрытие PD-33 сформулировано сильнее кода: «снимает только ВАЛИДНЫЙ Bearer» — на деле Present только ПАРСИТ, поэтому Authorization: Bearer <мусор> требование X-TM-Client снимает. Привилегии это не даёт, проверено живой пробой (кука + мусорный Bearer + без заголовка → 401, не хендлер): безопасность держит правило «Bearer побеждает куку» в Present, а не «валидность». Посадка «снимать любым непустым Authorization» батарею переживает. Фикс — либо тест, либо честная формулировка доккоммента — закрыто формулировкой + пином: доккоммент cookieUnsafe переписан на то, что верно (Present ПАРСИТ, не валидирует; безопасность держит правило «есть Authorization ⇒ кука не участвует», а не валидность). auth.TestAnAuthorizationHeaderTakesTheCookieOutOfPlay пинит именно это на пяти формах заголовка; посадка «падать обратно на куку при неразобранном заголовке» падает |
fixed(P2, дерево сессии) | приёмка P1 (посадка M30 + живая проба) |
| PD-51 | bug | minor | internal/httpapi/serve.go:118-127, STACK_DECISIONS §12 |
Механизм заявлен неверно. Утверждение «ReadTimeout убил бы и поток, поэтому стриминговый хендлер ОБЯЗАН снять read-дедлайн» на Go 1.26.5 не подтверждается: connReader.startBackgroundRead сам делает SetReadDeadline(time.Time{}) (net/http/server.go:687-698) и для запроса без тела вызывается ДО хендлера (:2062). Проверено исполнением на трёх формах запроса (GET без тела · POST с непрочитанным телом · POST с вычитанным телом) — поздний кадр доезжает во всех шести комбинациях, звали ClearReadDeadline или нет. Следствие: TestStreamOutlivesReadTimeout НЕ МОЖЕТ упасть от выхолащивания ClearReadDeadline (проверено); он пинит только Unwrap (эта посадка ловится). Код безвреден, ложны обоснование и строка в таблице пинов — закрыто, и вывод приёмки уточнён исполнением: механизм подтверждён (startBackgroundRead снимает дедлайн сам, server.go:687-698, для запроса без остатка тела — до хендлера, :2059-2062; по ходу хендлера не перевзводится — проверено по всем call sites). Но «код безвреден» неверно: см. PD-63. ClearReadDeadline УДАЛЁН, STACK_DECISIONS §12 переписан, TestStreamOutlivesReadTimeout переписан на настоящее свойство (поток переживает Read БЕЗ действий хендлера) и пинит Unwrap через ошибку Flush |
fixed(P2, дерево сессии) | приёмка P1 (посадка M24/M42 + отдельная проба) |
| PD-52 | hardening | minor | internal/pgstore/credits.go:233-243 |
Порядок блокировок (PD-26) не запинен ни одним тестом — снятие lockBalance из closeReservation батарею переживает. Дефект воспроизведён приёмкой НЕЗАВИСИМО, в форме, которая действительно даёт цикл: конкурентные Settle(run-1) и повторный Hold(run-1) — 2 взаимоблокировки на 150 раундов с инверсией, 0 с фиксом. Регрессионный тест написан приёмкой и лежит готовым к вставке в docs/platform-PROGRESS.md, раздел «Ратификация приёмкой P1». ⚠ Замер сессии «41 на 300» воспроизвести не удалось — их нагрузка не описана; принимается СО СЛОВ — закрыто: тест приёмки вставлен как pgstore.TestHoldAndSettleOnTheSameAttemptDoNotDeadlock. ⚠ Замер приёмки не копировался, а ПЕРЕПРОВЕРЕН на своём стенде (PostgreSQL 18.4): с инверсией падает 5 прогонов из 5, 5–10 взаимоблокировок на 150 раундов; с фиксом 5 прогонов из 5 зелёные. Замер сессии P1 «41 на 300» так и не воспроизведён и остаётся СО СЛОВ |
fixed(P2, дерево сессии) | приёмка P1 (посадка M07 + собственная репродукция) |
| PD-53 | hardening | info | internal/httpapi/server.go:73-75 |
DefaultMaxBody не запинен: поднятие лимита поддерева до 1 ГиБ батарею переживает. Пер-маршрутность лимита (PD-35) — тоже только на ревью — закрыто: httpapi.TestBodyCapIsPerRouteBecauseNestingOnlyTightens фиксирует исполнением ПРИЧИНУ пер-маршрутности — вложенный БОЛЬШИЙ лимит не поднимает внешний, — поэтому возврат общего слоя молча урезал бы аплоуд-маршрут; TestDefaultBodyCapStaysAContractSizedNumber держит дефолт в полосе контрактного размера (посадка «1 ГиБ» падает), не превращаясь в change-detector на точное число |
fixed(P2, дерево сессии) | приёмка P1 (посадка M26) |
| PD-54 | bug | minor | зонный журнал, секция эры P0 (⚠ якорь на строку снят 22.08: описанное тело было УДАЛЕНО при закрытии строки, а сам журнал с тех пор дважды срезан в слайсы — адресовать было нечего) | В журнале ДВЕ несовместимые формы GET /v0/usage: новая кредитная (строка 172) и старая подписочная (строка 329) с resets_at, windows[{period}] и хранением в usage_windows — таблице, которую снесла миграция 00006. Секция P0-эры не помечена superseded, а S3 идёт читать журнал именно за формой ручки — закрыто: подписочное тело ответа УДАЛЕНО из журнала, а не помечено баннером: S3 идёт туда за формой ручки и скопировал бы тело. Осталась одна форма — кредитная, в разделе «Что предлагаем в спеку (S3)»; из П-5 сохранены абзацы, не зависящие от модели денег, ссылка на хранение переведена на credit_ledger |
fixed(P2, дерево сессии) | приёмка P1 (свип доков) |
| PD-55 | bug | info | deploy/tmplatformd.service |
MemoryMax=2G объявлен как «bounds the control plane», но ограничивает cgroup ЮНИТА — а по собственному аргументу этого же файла (закрытие PD-13) в этом cgroup живёт каждый ребёнок-tmctl. Значит потолок общий на платформу и все идущие прогоны, и OOM-killer выберет самый жирный процесс — движок, который держит ЭКСКЛЮЗИВНЫЙ лок на файле проекта: ровно тот исход, ради которого запрещён SIGKILL. То же про TasksMax=512. Латентно до появления воркера. ⚠ Под systemd не проверялось (нет sudo) — вывод из семантики MemoryMax=, не из замера — закрыто: семантика сверена по man 5 systemd.resource-control («absolute limit on memory usage of the executed processes in this unit… out-of-memory killer is invoked inside the unit»). MemoryMax=2G заменён на MemoryMax=80% — потолок машины, а не сервиса, как «last line of defense» и без знания о железе; TasksMax=512 оставлен с честным комментарием, что покрывает платформу и прогоны вместе; ограничение ОДНОГО прогона названо работой воркера (transient scope). Побочно найдено и закрыто следствие, которого в этой строке не было, — PD-64. ⚠ Под systemd не запускалось (нет sudo); systemd-analyze verify (systemd 259) — exit 0 |
fixed(P2, дерево сессии) | приёмка P1 (ревью деплой-юнита) |
| PD-56 | bug | info | internal/pgstore/credits.go:35-63 |
Grant/Adjust на несуществующий аккаунт отдают оператору сырую ошибку Postgres с именем констрейнта (credit_ledger_user_id_fkey), тогда как Balance на том же входе отдаёт ErrNoAccount. Живая проба CLI. Косметика админ-поверхности, но опечатка в id читается как поломка БД — закрыто: appendLedger мапит нарушение credit_ledger_user_id_fkey в ErrNoAccount; pgstore.TestMoneyOperationsAgreeOnAMissingAccount требует одного ответа от Grant/Adjust/Balance/ReadAccount. Посадка «убрать сверку констрейнта» падает |
fixed(P2, дерево сессии) | приёмка P1 (живая проба CLI) |
| PD-57 | hardening | minor | internal/login/login.go:239-242 |
Защита от IdP mix-up не та, что требует действующая норма. RFC 9700 §2.1 (OAuth Security BCP, янв. 2025) — клиент SHOULD применять параметр iss из авторизационного ответа (RFC 9207) либо иной контрмер НА ОСНОВЕ iss; MAY — различные redirect URI на провайдера. Реализована собственная сверка st.Provider с h.cfg.Provider, а внутри одного хендлера это сравнение конфигурации с самой собой: start пишет туда то же значение. iss авторизационного ответа не читается вообще (iss ID-токена библиотека проверяет — это другой шаг и другой момент). Сегодня не эксплуатируемо: провайдер один, код всегда редимится у него же. Заведено потому, что регистр объявляет PD-32 закрытием «класса IdP mix-up», а против нормы это неверно, и при втором провайдере выбор (iss или раздельные redirect URI) должен быть ОСОЗНАННЫМ, а не побочным эффектом конфигурации — закрыто реализацией нормы, а не обещанием. Первоисточники сверены: RFC 9700 §4.4.2 («When an OAuth client can only interact with one authorization server, a mix-up defense is not required» — то есть СЕГОДНЯ несоответствия нет, требование включается со вторым сервером), §4.4.2.2 объявляет раздельные redirect URI фолбэком («SHOULD therefore only be used if other options are not available»); альтернатива «iss из ID-токена» нам не подходит — при чистом code flow токен приходит уже ПОСЛЕ отдачи кода. Выбран iss авторизационного ответа: Google его шлёт (authorization_response_iss_parameter_supported: true, сверено живьём). Сделано: auth_states.issuer (миграция 00008), сверка до обмена кода, отказ на СОРВАННОМ параметре у поддерживающего провайдера (RFC 9207 §2.4). Пин — login.TestAuthorizationResponseIssuerIsChecked (4 случая); посадки «убрать вызов», «убрать ветку несовпадения», «убрать ветку сорванного параметра», «потерять issuer в сторе» падают |
fixed(P2, дерево сессии) | приёмка P1 (сверка с RFC 9700 §2.1 / RFC 9207) |
| PD-58 | hardening | minor | internal/config/config.go:60-61 |
Несоответствие собственной объявленной базовой линии. ENGINEERING_STANDARDS §2 берёт ASVS 5.0 L2, а L2 требует ДОКУМЕНТИРОВАТЬ сроки: 7.1.1 (срок бездействия и абсолютный предел + обоснование отклонений от NIST SP 800-63B), 7.1.2 (политика одновременных сессий), 7.1.3/7.6.1 (согласование срока НАШЕЙ сессии со сроком федеративной — у нас наша живёт своей жизнью, RP-initiated/back-channel logout нет). Сроки 14 суток бездействия и 90 суток абсолютных существуют только литералами в коде, обоснования нет ни в одном доке (проверено grep). Механические требования V7 при этом ВЫПОЛНЕНЫ и проверены: 7.2.3 энтропия (256 бит при требуемых 128), 7.2.4 ротация токена на аутентификации, 7.4.1 отзыв, 7.4.2 снос сессий при удалении аккаунта. ⚠ 7.4.5 (системный отзыв админом) покрыт только пер-пользовательским revoke; 7.5.2 (пользователь видит свои сессии) — работа П-1 — закрыто, и значение выровнено вместо сочинения оправдания. Тексты сверены дословно: ASVS 5.0 7.1.1/7.1.2/7.1.3, 7.6.1/7.6.2 и NIST SP 800-63B-4 §2.1.3 («overall timeout … SHOULD be no more than 30 days at AAL1; an inactivity timeout MAY be applied but is not required»). Абсолютный срок 90 суток был отклонением от SHOULD без причины, выдерживающей проверку, — снижен до 30 суток; бездействие 14 суток остаётся и строже нормы. Документ — STACK_DECISIONS §13: уровень AAL1, оба срока, политика одновременных сессий (лимита нет — контракт предусматривает куку и Bearer одновременно; вместо лимита отзыв, «выйти везде» и журнал), рассогласование с федеративной сессией названо прямо (RP-initiated/back-channel logout нет), 7.6.2 выполнено. Пин — config.TestSessionClocksStayWithinTheDeclaredBaseline |
fixed(P2, дерево сессии) | приёмка P1 (сверка с ASVS 5.0 V7) |
| PD-62 | bug | minor | internal/pgstore/identity.go:26-35, миграция 00005 |
login.State.StartID не персистился: колонки под него не было. Поле заведено в P1 и логируется колбэком как login_start_id — то есть ОБЕ строки лога, которые должны сшивать две половины входа, в проде пустые. Батарея этого не видела, потому что тесты internal/login ходят в in-memory стор, который хранит структуру целиком: свойство проверялось не на том объекте, который едет (тот же класс, что PD-46). Воспроизведено против живой БД раунд-трипом PutLoginState→TakeLoginState: положили REQ-ABC123, получили "" — закрыто: колонки start_id и issuer добавлены миграцией 00008, Put/Take их несут; пин — pgstore.TestLoginStateIsSingleUseAndExpires сравнивает структуру ЦЕЛИКОМ (reflect.DeepEqual), поэтому следующее поле без колонки упадёт здесь же. Посадки «потерять start_id» и «потерять issuer» падают |
fixed(P2, дерево сессии) | сессия P2 (найдено при правке PD-57) |
| PD-63 | vuln | minor | internal/httpapi/serve.go:118-127 (удалён) |
ClearReadDeadline воспроизводил PD-2 — тем самым вызовом, который был заведён как его исправление. Доккоммент объявлял его ОБЯЗАТЕЛЬНЫМ для стримингового хендлера. На полу-кормленном запросе (тело анонсировано, не дослано) дренаж внутри записи заголовка ответа — единственная граница соединения, и ограничен он ReadTimeout; снятие дедлайна ДО записи заголовка эту границу убирает. Замерено: хендлер остаётся внутри WriteHeader и через 4 с после ухода клиента, соединение держится. Вызов после флаша бесполезен — контекст уже отменён дренажем. Приёмка (PD-51) заключила «код безвреден», проверив только корректные запросы; случая, где функция помогает, нет вовсе — закрыто: функция УДАЛЕНА, §12 переписан, пин — httpapi.TestHalfFedStreamingRequestIsCutLoose (контекст стримингового хендлера отменяется в пределах Read) |
fixed(P2, дерево сессии) | сессия P2 (собственный замер при верификации PD-51) |
| PD-64 | bug | minor | deploy/tmplatformd.service |
Дефолтный OOMPolicy=stop уронил бы платформу из-за одного прожорливого прогона. Следствие того же факта, что PD-55 (дети-tmctl живут в cgroup юнита), но в той строке не названо: по man 5 systemd.service дефолт берётся из DefaultOOMPolicy= (системный — stop), а stop означает «the unit's processes are terminated cleanly by the service manager» — то есть OOM-килл ОДНОГО tmctl останавливает контрол-плейн и все остальные прогоны, после чего юнит уходит в oom-kill failed и его подхватывает Restart=on-failure — закрыто: OOMPolicy=continue проставлен явно с обоснованием; платформа переживает килл ребёнка и штатно закрывает его резервацию. ⚠ Под systemd не проверялось (нет sudo) — вывод из доки; systemd-analyze verify (systemd 259) — exit 0 |
fixed(P2, дерево сессии) | сессия P2 (ревью деплой-юнита при PD-55) |
| PD-65 | vuln | minor | internal/login/login.go:367-382 |
Обмен кода и загрузка JWKS шли БЕЗ дедлайна, тогда как discovery на том же пути ограничивает себя пятью секундами и называет причину («http.DefaultClient не имеет собственного таймаута, а вызов делается, пока человек ждёт»). В проде httpClient равен nil, поэтому обмен идёт на http.DefaultClient, а go-oidc строит набор ключей от context.Background(); WriteTimeout у сервера нет по проекту — значит издатель, который принял соединение и не отвечает, держит хендлер, пока клиент сам не уйдёт. Хуже того, набор ключей ОБЩИЙ: одна зависшая загрузка паркует ВСЕ параллельные входы (замерено ревью: два независимых входа ждали 12 с за одной загрузкой) — закрыто: identify ограничен providerTimeout 10 с; пин — login.TestAStalledProviderDoesNotHoldTheCallback на обеих ногах (token и keys), посадка «убрать дедлайн» падает |
fixed(P2, дерево сессии) | ревью P2 (линза oidc-security, подтверждено верификатором на боевой проводке) |
| PD-66 | bug | minor | internal/httpapi/serve_test.go, cmd/tmplatformd/main.go:121 |
Мой собственный фикс PD-46 закрывал только половину и утверждал, что обе. Тест строил свой сервер NewServer(…, DefaultTimeouts()) и на него же смотрел; проводка демона осталась ненаблюдаемой, а cmd/tmplatformd тестов не имеет. Замерено ревью: замена аргумента на Timeouts{Shutdown: 15s} оставляет make check зелёным (0 issues) и бинарь снова пиннит соединения — PD-2 в полном объёме. То есть ровно та форма, которую PD-46 и называл: свойство проверено не на том объекте — закрыто устранением КЛАССА, а не тестом: NewServer больше не принимает Timeouts и берёт DefaultTimeouts() сам, передавать нечего; коротким дедлайнам тестов служит неэкспортируемый serverWithTimeouts |
fixed(P2, дерево сессии) | ревью P2 (линза net-http) |
| PD-67 | vuln | minor | internal/pgstore/credits.go:236 |
FOR UPDATE не был запинен ничем, а комментарий теста утверждал обратное («Mutation caught: … or the FOR UPDATE that serialises it»). Последовательный тест лока не видит по построению, а инвариант «кэш = леджер» его тоже не ловит: без лока кэш и леджер уезжают ВМЕСТЕ, оба в минус. Лок — единственное, что мешает двум прогонам потратить один и тот же кредит — закрыто: pgstore.TestConcurrentHoldsCannotOvercommitAnAccount — 60 раундов по два конкурентных холда, каждый по отдельности посильный, вместе нет; посадка «убрать for update» падает 3 прогона из 3, баланс уходит в −$2. Комментарий последовательного теста исправлен |
fixed(P2, дерево сессии) | ревью P2 (линза sql-money) |
| PD-68 | bug | minor | internal/httpapi/server.go:111 |
/readyz рапортовал «готов» на базе БЕЗ схемы. Готовность доказывалась одним Ping, который успешен на любом достижимом Postgres, включая пустой. Migrate выключен по умолчанию, а deploy-инструкция делает миграцию отдельным шагом — значит «процесс поднят, схема не накачена» это НОРМАЛЬНАЯ середина выката, и инстанс в этом окне отвечал 200 ready, проваливая каждый запрос, который затем обслуживал — закрыто: Store.Ready сверяет goose_db_version с максимальным номером миграции, вшитой в бинарь; схема ВПЕРЕДИ бинаря готовности не отменяет (иначе выкат ронял бы старый инстанс). Пин — pgstore.TestReadinessRefusesADatabaseWithoutTheSchema, посадка «свести готовность к Ping» падает. ⚠ Первая редакция фикса печатала причину В ТЕЛО ответа и ради этого тащила pgstore в httpapi — и слой, и утечка состояния выката на НЕаутентифицированной ручке; снято при самопроверке, причина уходит в ERROR-лог |
fixed(P2, дерево сессии) | ревью P2 (линза вне карты) |
| PD-69 | bug | minor | internal/pgstore/store.go:42 |
Явный pool_max_conns из DSN молча отбрасывался. Проверка «MaxConns равен дефолту pgxpool» не отличает «оператор не выбирал» от «оператор выбрал ровно это число»: pgxpool кладёт свой дефолт в то же поле, что ParseConfig заполняет из pool_max_conns. Оператор, порезавший реплику под бюджет max_connections, получал наш 16 вместо своих 8 — и наоборот на 32-ядерной машине. С pool_min_conns хуже: дефолт pgx равен 0, поэтому явный 0 не мог пережить проверку НИКОГДА — закрыто: вопрос «упоминает ли DSN этот ключ» задан ПАРСЕРУ pgx, а не значению: pgxpool достаёт pool_* из RuntimeParams и удаляет их, поэтому второй pgx.ParseConfig их ещё видит — обе формы DSN, кавычки и service-файлы бесплатно. ⚠ Первая редакция фикса разбирала DSN РУКАМИ (33 строки собственного парсера) — велосипед, найден при самопроверке и снят; наши дефолты применяются только там, где оператор промолчал. Пин — pgstore.TestExplicitPoolSizesInTheDSNSurvive, включая случай, сломавший ПРЕДЫДУЩУЮ эвристику: пароль, содержащий имя ключа |
fixed(P2, дерево сессии) | ревью P2 (линза вне карты) |
| PD-70 | bug | major | internal/auth/middleware.go:57, internal/auth/cookie.go:62 |
Скользящее окно бездействия для БРАУЗЕРА не работало: Max-Age куки пишется один раз, на входе, и больше никем. Серверная строка скользила (Touch), кука — нет, а SetSession зовётся ровно из одного места — колбэка входа. Следствие: для куки — единственной презентации, которую код вообще умеет выдавать, — срок жизни сессии был ФИКСИРОВАННЫЕ 14 суток от входа независимо от активности; человек, заходящий каждый день, выкидывался на 14-е сутки при живой серверной сессии, а абсолютный срок не мог наступить никогда. Это же делало ложным §13 — документ соответствия ASVS 7.1.1, который зона только что написала — закрыто: при скольжении окна кука переиздаётся с тем же токеном (ротация — акт границы входа, не скольжения); пин — auth.TestSlidingTheIdleWindowRefreshesTheBrowsersCookie (четыре случая: кука во второй половине окна, свежая кука, Bearer, упор в абсолютный срок). ⚠ Первая редакция фикса выдавала Max-Age равный idle-TTL безусловно — то есть кука могла пережить абсолютный срок и превратить каждый следующий запрос в 401 вместо чистого «вы вышли»; поймано самопроверкой, срок теперь берётся как min(idle, остаток абсолютного) |
fixed(P2, дерево сессии) | ревью P2 (линза doc-vs-code) |
| PD-73 | vuln | minor | internal/login/login.go:67-74, :126 |
Дедлайн identify ограничивал ОЖИДАЮЩЕГО, а не саму загрузку ключей — то есть мой фикс PD-65 был неполон. Provider.Verifier берёт набор ключей, построенный на discovery, а go-oidc хранит его через context.WithoutCancel и ходит за ключами на http.DefaultClient, у которого таймаута нет. Загрузка, которая зависла, продолжает висеть после того, как ожидающий сдался, и все последующие входы встают на тот же inflight — то есть вход не поднимается и после того, как эндпоинт выздоровел, вплоть до перезапуска процесса. Воспроизведено ревью на боевой проводке — закрыто: New ВСЕГДА ставит httpClient с таймаутом providerTimeout, клиент передаётся NewProvider безусловно (oidc.ClientContext), и его подхватывает набор ключей; nil-случая больше нет — класс устранён, а не покрыт тестом. Пины — TestTheDefaultProviderClientIsBounded (посадка «клиент без таймаута» падает) и TestAHungKeyFetchDoesNotPoisonLaterSignIns (вход ПОСЛЕ выздоровления эндпоинта обязан пройти) |
fixed(P2, дерево сессии) | ревью P2 (линза the-fixes) |
| PD-74 | bug | minor | internal/auth/middleware.go:57 |
Скольжение окна залипало на последней четверти жизни сессии: каждый запрос становился записью. Touch прижимает новый дедлайн через least(now+IdleTTL, absolute_expires_at), поэтому как только now+IdleTTL перевалил за абсолютный потолок, idle_expires_at больше не двигается — а условие «осталось меньше половины окна» с этого момента истинно ВСЕГДА. На горячем пути это UPDATE по первичному ключу таблицы сессий и Set-Cookie на каждом аутентифицированном запросе (после PD-70 — ещё и кука). Найдено двумя линзами независимо — закрыто: скольжение выполняется только пока IdleExpiresAt строго меньше AbsoluteExpiresAt; пин — auth.TestTheSlideStopsOnceItCannotMoveTheDeadline (пять чтений дают ноль записей, а сессия с запасом по-прежнему скользит) |
fixed(P2, дерево сессии) | ревью P2 (линзы session-security и вне карты, независимо) |
| PD-75 | bug | minor | cmd/tmplatformctl/main.go:151 |
CLI сообщал о ПРИМЕНЁННОМ начислении как о провале, а повтор начислял второй раз. write выполняет денежную операцию, затем отдельным запросом читает баланс, и ошибку ЧТЕНИЯ возвращает как результат команды. Оператор видит ошибку, повторяет — а --key необязателен, и без него newKey чеканит новый ключ идемпотентности, поэтому второй прогон начисляет ещё раз. Достаточно обрыва соединения между двумя запросами — закрыто: после коммита команда не может отчитаться провалом; баланс читается как любезность, его отказ печатается предупреждением на той же строке |
fixed(P2, дерево сессии) | ревью P2 (линза вне карты) |
| PD-76 | bug | minor | internal/login/login_test.go |
Определяющее свойство пакета — «ничего выданного провайдером не персистится» — проверялось утверждением, которое не могло упасть. memStore.notes объявлено и не заполнялось ни одним методом, поэтому strings.Join(notes) всегда пусто, а Contains всегда ложно. Свойство названо в доккомменте пакета первой строкой — закрыто: мок пишет в saw КАЖДУЮ строку, которую поток ему передал, а утверждение проверяет и непустоту записи, и отсутствие среди неё и access-токена, и любого JWT-образного значения. Посадка «положить в стор сырой id-токен» падает |
fixed(P2, дерево сессии) | ревью P2 (линза water) |
| PD-77 | bug | info | internal/ingest/supervisor.go:104 |
Штатная остановка живого прогона поднимала тревогу о сломанном синке. Ingest проверяет ctx.Err() в начале цикла и возвращает context.Canceled как СВОЮ ошибку; Run отличить это от отказавшего синка не мог и на обычном SIGTERM писал ERROR «stream could not be materialized», который по замыслу означает «платформа ослепла, пока тратятся деньги», плюс звал stop() на уже останавливающемся прогоне — закрыто: отменённый runCtx больше не считается отказом синка |
fixed(P2, дерево сессии) | ревью P2 (линза вне карты) |
| PD-78 | hardening | info | internal/login/login.go (было), internal/httpapi/problem.go (было), internal/pgstore/identity.go, internal/httpapi/server.go |
Свод воды и дублей, найденный линзой лаконичности; каждый пункт проверен удалением. (а) login.Routes мемоизировал mux через sync.Once — при этом ВТОРОЙ и последующие guard молча игнорировались, то есть это была не оптимизация, а ловушка; снято. (б) login.Fail носил *http.Request, который никто не читал, и ради несовпадения сигнатур существовал шим httpapi.Fail; параметр и шим удалены, WriteProblem подключён напрямую. (в) upsertIdentityOnce держал собственный begin/rollback/commit при наличии inTx — второй экземпляр того же кода. (г) Deps.APIPrefix — ручка, которую не выставлял ни один вызыватель; заменена константой. (д) Ready делал Ping и следом запрос — два round trip на пробу каждые несколько секунд. (е) пять полей тестовых двойников, которые писались и не читались; blockingSink не блокировал. (ж) money.USD считал руками с комментарием про переполнение MinInt64 — заменён на big.Rat.FloatString(6), проверено побайтовое совпадение на всём диапазоне |
fixed(P2, дерево сессии) | ревью P2 (линза water) + самопроверка |
Закрытые — эра P3 (деплой и фикс-пак приёмки P2)
| ID | Класс | Серьёзность | Где | Суть | Статус | Источник |
|---|---|---|---|---|---|---|
| PD-79 | bug | minor | internal/money/money.go:33-36 |
Строковый "null" читается как НОЛЬ денег. Кавычки снимаются strings.Trim ДО проверки s == "null", поэтому "committed_usd":"null" даёт настоящий 0 и НЕПУСТОЙ указатель, тогда как доккоммент поля обещает отказ на «absent, null and empty». Замерено приёмкой на живом декодере: голый null и отсутствие поля дают nil (защита работает), "" даёт ошибку, а "null" — Spend = 0 micro-USD, NON-NIL. На пути расчёта это «попытка стоила ничего»: холд освобождается, списания нет. Латентно до воркера; чинится перестановкой проверки перед Trim — закрыто: литерал null судится ДО раскавычивания и оставляет значение нетронутым; кавычки снимает encoding/json, а не strings.Trim — слово null, пустая строка и экранированная цифра выходят тем, чем являются, и каждое встречает ту же единственную проверку синтаксиса, поэтому отдельной ветки «пусто или null» не нужно вовсе. Пины: money.TestUnmarshalTellsTheNullLiteralFromTheWordNull (обе формы плюс невмешательство в значение) и ingest.TestSpendRefusesNonsense на шве. Обе посадки — «снять кавычки первыми» и «слово null есть ноль» — поймать поимённо |
fixed(P3, дерево сессии) | приёмка P2 (замер оркестратора №15 + панель) |
| PD-80 | vuln | major | internal/login/login.go:158,217-226 |
Вход выключается тремя запросами в секунду, и 429 колбэка ДОБИВАЕТ начатые входы. Ведро rate.NewLimiter(2, 20) одно на /auth/login И /auth/callback (login.go:122, единственный лимитер в зоне), а колбэк стирает login-куку ПЕРВОЙ строкой — до своей проверки лимитера. Следствие: анонимный поток на /auth/login не только закрывает вход всем (это PD-42, принято риском в форме «глобальный, не пер-адресный»), но и делает начатый вход невосстановимым: 429 приходит уже с Set-Cookie: __Host-tm_login=; Max-Age=0, поэтому повтор того же колбэка не пройдёт и после наполнения ведра. Воспроизведено приёмкой на боевом бинаре: 19 из 40 /auth/login прошли, дальше 429; честный колбэк с живым state получил 429 и стёртую куку. Независимо измерено панелью. Фикс дешёвый: лимитер прежде очистки куки + раздельные ведра для начала и конца входа; пер-адресный лимит остаётся вопросом edge (PD-42) — закрыто: два ведра вместо одного (startLimit/finishLimit, те же rate/burst — не делится именно ИСЧЕРПАНИЕ), и проверка лимитера ПЕРЕД ClearLogin. Пины: TestFloodingTheStartOfSignInDoesNotCloseTheEnd (поток на /auth/login не закрывает честный колбэк) и TestARefusedCallbackKeepsTheLoginItRefused (429 не стирает куку, состояние не съедено, повтор после снятия лимита доходит до 303). Посадки «одно ведро» и «очистка выше лимитера» падают |
fixed(P3, дерево сессии) | приёмка P2 (живая проба + панель, две независимые линзы) |
| PD-83 | hardening | minor | internal/httpapi/middleware.go:64 |
Фикс PD-3 не запинен в собственном месте: посадка «Recover логирует r.URL.Path вместо routeOf(r)» батарею ПЕРЕЖИВАЕТ, тогда как та же посадка в AccessLog ловится поимённо (TestAccessLogNamesTheRouteNotThePath). По правилу шапки этого файла половина PD-3 закрытой не считается — закрыто: TestPanicBecomesAProblemAndNamesTheRoute — паника за мультиплексором с {book} в паттерне; сверяется и route, и отсутствие идентификатора книги во ВСЕЙ строке (в ней же стек). Посадка r.URL.Path падает |
fixed(P3, дерево сессии) | приёмка P2 (посадка мутации) |
| PD-84 | hardening | minor | internal/login/login.go:222 |
Лимитер колбэка (фикс PD-29) не запинен: удаление всей проверки h.limiter.Allow() из callback оставляет батарею зелёной. Замер PD-29 (~880 строк/с с одного хоста) означает, что регрессия здесь тихо возвращает неаутентифицированного писателя в таблицу журнала — закрыто: TestBothLegsOfSignInAreRateLimited — десять колбэков подряд обязаны упереться в 429. Посадка «удалить проверку целиком» падает; её же ловит TestARefusedCallbackKeepsTheLoginItRefused |
fixed(P3, дерево сессии) | приёмка P2 (посадка мутации) |
| PD-85 | hardening | minor | internal/pgstore/identity.go:126-131 |
«Неподтверждённый адрес не поднимается на аккаунт» запинено только на ветке НОВОЙ личности: снятие условия in.EmailVerified в ветке ВОЗВРАЩАЮЩЕГОСЯ входа (обновление users.email) проходит батарею — TestUnverifiedAddressStaysOffTheAccount покрывает первый вход и переход в verified, но не обратный случай — закрыто: TestUnverifiedAddressStaysOffTheAccount продлён третьим шагом — ВОЗВРАЩАЮЩИЙСЯ вход с новым НЕподтверждённым адресом: users.email не двигается, identities.email записывает то, что пришло. Посадка «снять in.EmailVerified в ветке возвращающегося» падает |
fixed(P3, дерево сессии) | приёмка P2 (посадка мутации) |
| PD-91 | doc | minor | deploy/README.md:33-42, deploy/tmplatformd.service:43,48 |
Установка, исполненная дословно, даёт нестартующий юнит: /srv/textmachine не создаётся ни одной командой наброска, а ReadWritePaths= без префикса - на несуществующем пути валит сборку mount-namespace при ProtectSystem=strict. Заодно ProtectHome=yes против решения владельца «книги живут в ~/books»: детям-tmctl домашние каталоги под этим юнитом недоступны — либо книги переезжают в /srv/textmachine, либо юнит получает BindPaths=. ⚠ Вывод из systemd.exec(5), под systemd не исполнялось (sudo нет) — закрыто: каталог /srv/textmachine создаётся явной командой наброска (--system домашний каталог не создаёт), префикс - намеренно НЕ ставится (сервис без записываемого каталога обязан падать на старте, а не на первой записи через часы), ProtectHome=yes оставлен с названной ценой и двухстрочным выходом (ProtectHome=tmpfs + BindPaths=), корень библиотеки на СЕРВЕРЕ — /srv/textmachine, ~/books объявлено конвенцией машины разработки. Проверено живым прогоном systemd-run --user (systemd 259), а не докой: несуществующий путь без - → 226/NAMESPACE, созданный → 0/SUCCESS, с - → 0/SUCCESS (строка игнорируется); ProtectHome=yes → Permission denied на /home/<user>; tmpfs+BindPaths → каталог виден |
fixed(P3, дерево сессии) | приёмка P2 (панель, сверено с докой) |
| PD-95 | doc | major (для промта эмиттера) | internal/ingest/events.go:6-12, docs/platform-PROGRESS.md §«Транспорт потока событий» |
Транспортная история зоны устарела против D39.106 в ДВУХ местах, и обе версии не совпадают с ратифицированной формой. Ратифицировано (D39.106 п.2 + research/25 §Форма): движок — транзиентный systemd-юнит на прогон, платформа ему НЕ родитель; события — events.jsonl в каталоге книги, append-only, как outbox-проекция уже закоммиченных строк SQLite (та же транзакция, что чекпойнт); платформа тейлит журнал, курсор (engine_run_id, seq) коммитится в одной Postgres-транзакции с эффектом. В research/25 вариант «платформа — родитель + пайп (stdout/fd)» получил 0 голосов из 15 («время жизни движка — подмножество платформы: деплой/рестарт убивает или осиротляет прогон»), выделенный fd 3 — 0, stdout/journald как источник событий — 0. Что в зоне: (а) доккоммент events.go предлагает переезд на fd/сокет — отклонённая форма; (б) журнал зоны длинно доказывает «канал остаётся stdout» и объявляет переезд отклонённым, ссылаясь на PD-59, который сам superseded тем же D39.106 п.3 («PD-59 superseded; пайп-путь supervisor.go P1 = дев-режим; ответ PD-13 переезжает на cgroup юнита прогона»). Оба текста прочтёт эмиттер-сессия как задание. ⚠ Приёмка №15 это пропустила и в первой редакции строки сама сослалась на снятый PD-59 — исправлено здесь же — закрыто: доккоммент пакета переписан под D39.106 §2 — транзиентный systemd-юнит на прогон, платформа НЕ родитель, events.jsonl в каталоге книги как outbox-проекция коммитов SQLite, тейл с курсором (engine_run_id, seq), повторное чтение строк — норма (PD-105). Отвергнутые формы перечислены со счётом голосов, чтобы не вернулись свежей идеей. Доккоммент Supervisor помечен ДЕВ-РЕЖИМОМ там же, где он описывает пайп. ⚠ В журнале зоны нашлась ВТОРАЯ копия снятого ответа — блок «ОТВЕЧЕНО приёмкой (PD-59)» в списке «Открытые вопросы после P1» п.4: эррата №15 пере-ставила другую секцию, эту не тронула. Текст оркестратора не переписан — над ним поставлен баннер SUPERSEDED с ратифицированной формой; проверить принадлежность правки — за приёмкой |
fixed(P3, дерево сессии) | приёмка P2 (эррата оркестратора №15, 07.08) |
| PD-100 | bug | minor | internal/login/login.go:245-261 |
Класс PD-5 закрыт в auth/, но не в login/: колбэк глотает ошибку стора (TakeLoginState) и ошибку discovery, репортя их как обычный отказ (unknown_state / discovery_failed) — сама ошибка не доезжает ни до одной строки лога, хотя pgstore/identity.go намеренно отличает «состояния нет» от инфраструктурного сбоя. Аутентификационный DB-outage снова выглядит штормом обычных отказов — закрыто: сбой стора и сбой discovery уходят в ERROR; на проводе и в журнале — прежний отказ. login.ErrNoState заведён у владельца интерфейса (как auth.ErrNoSession), pgstore.ErrNoLoginState — то же значение под прежним именем. Пин TestInfrastructureFailuresInTheCallbackAreLogged: три случая, включая «обычное истечение НЕ логируется как авария» — ловит и посадку «логировать всегда» |
fixed(P3, дерево сессии) | приёмка P2 (панель) |
| PD-106 | standards | minor | cmd/tmplatformctl/ |
Админ-CLI — единственный писатель денег в дереве — не имеет ни одного теста. В том числе не покрыто правило, которое он сам называет несущим («после коммита команда не может отчитаться провалом», фикс PD-75), и разбор флагов, и формат вывода. Батарея зоны его не видит вовсе ([no test files]) — закрыто: cmd/tmplatformctl/main_test.go — восемь тестов. Несущее правило («после коммита ничего не отчитывается провалом») пинится через balanceReader — интерфейс с одним методом, заведён ровно затем, что правило нельзя проверить на сторе, который всегда работает. Плюс: спент-ключ → «no-op», сбой ДО коммита → ошибка и ни строки вывода, ключ без --key уникален на 100 прогонах, разбор флагов десятью случаями и сквозной прогон пяти команд по живой БД. ⚠ Первая редакция теста флагов сверяла лишь «ошибка непуста» — две посадки её ПЕРЕЖИЛИ (команда падала на соединении, а не на аргументах); тест переписан на сверку сообщения |
fixed(P3, дерево сессии) | приёмка P2 (панель) |
Закрытые — эра P4 (раннер: юнит, очередь, реконсилятор, тейлер)
| ID | Класс | Серьёзность | Где | Суть | Статус | Источник |
|---|---|---|---|---|---|---|
| PD-43 | bug | info | internal/pgstore/credits.go |
Денежный контур не имеет ни одного вызывающего вне тестов: Hold/Settle/Release не зовутся, Sink не реализован, TypeSpend не декодируется. При первом реальном прогоне баланс не изменится. Ожидаемо — воркера нет (П-1/П-3), но заведено строкой, чтобы это было решением, а не сюрпризом — закрыто: денежный контур получил вызывающих: runs.Service.Start берёт холд в ОДНОЙ транзакции с созданием прогона и записью очереди (pgstore.StartRun), реконсилятор закрывает его Settle по фигуре движка. Пины: pgstore.TestAdmittingARunWritesTheRunTheAttemptAndTheHoldTogether (посадка «убрать holdTx из транзакции» падает), TestARunThatCannotBePaidForLeavesNothingBehind, runs.TestARunThatEndsIsFinishedAndSettledAtWhatTheEngineSpent. Живая проба: грант $10 → прогон с потолком 100 глав → холд $3.00 → движок отчитался $0.42 → баланс $9.58 |
fixed(P4, дерево сессии) | ревью «вне карты» |
| PD-81 | standards | minor | internal/pgstore/credits.go:169-178 |
Заявленный ErrDuplicateHold на реальном пути недостижим: при ЖИВОЙ резервации повторный Hold падает на первичном ключе reservations_pkey (00007_credits.sql:66) и уходит наверх сырой ошибкой Postgres SQLSTATE 23505; объявленная ошибка приходит только когда строку резервации уже смахнули, а ключ леджера остался. Замерено приёмкой на живом PG в обеих формах. Деньги целы (balance == SUM(ledger), транзакция откатывается), но воркеру не на что смотреть, кроме текста ошибки — закрыто: при ЖИВОЙ резервации коллизия reservations_pkey мапится в ErrDuplicateHold (credits.go holdTx); объявленная ошибка стала достижимой на реальном пути. Пин — TestASecondHoldOnALiveReservationIsADuplicateNotASqlstate (сверяет и то, что отказ не двинул деньги) |
fixed(P4, дерево сессии) | приёмка P2 (замер оркестратора №15 + панель) |
| PD-82 | bug | info | internal/pgstore/credits.go:236-239 |
Hold на НЕСУЩЕСТВУЮЩИЙ аккаунт отдаёт ErrInsufficientCredit (в lockBalance ErrNoRows трактуется как «нет кредита»), а не ErrNoAccount: обещание PD-56 «один ответ на несуществующий аккаунт» покрывает Grant/Adjust/Balance/ReadAccount и на Hold не распространяется. Замерено приёмкой — закрыто: lockBalance при отсутствии строки баланса спрашивает, существует ли аккаунт, и отвечает ErrNoAccount против ErrInsufficientCredit. ⚠ Одним запросом это не выражается: Postgres запрещает FOR UPDATE на nullable-стороне внешнего соединения — проверено, поэтому вторая проверка идёт только на редком пути. Пин — TestMoneyOperationsTellAMissingAccountFromAnEmptyOne |
fixed(P4, дерево сессии) | приёмка P2 (замер оркестратора №15) |
| PD-97 | hardening | info | internal/pgstore/credits.go:212-216 |
Settle/Release отбрасывают флаг applied у hold_release: если ключ ("run_release", engineRunID) уже потрачен, резервация закроется, а деньги не вернутся — тихий no-op на денежном пути. Требует нештатной последовательности (закрытие, смахивание строки, повторное открытие того же engine_run_id), но ровно на такой последовательности стоит ErrDuplicateHold — закрыто: releaseHold судит флаг applied; потраченный ключ релиза = ErrReleaseKeySpent и ОТКАТ транзакции, поэтому резервация остаётся ОТКРЫТОЙ и видимой оператору вместо тихого закрытия без возврата денег. Пин — TestAReleaseWhoseKeyWasSpentIsRefusedRatherThanSilent |
fixed(P4, дерево сессии) | приёмка P2 (панель) |
| PD-99 | hardening | info | internal/ingest/supervisor.go:102 |
INFO-лог «engine started» пишет args целиком. Сегодня безвредно, но воркер будет передавать движку идентификатор книги и потолок аргументами ⇒ book-id и денежная сумма попадут в INFO платформы (D39.84 + норма зоны «id книги в логи не текут»). Закрыть вместе с воркером: логировать имя команды, не argv — закрыто: INFO-строка старта несёт имя команды и НЕ несёт argv (runner.Start, а также дев-путь ingest/supervisor.go), поэтому ни id книги, ни потолок в долларах в поток INFO не попадают. Пин — runner.TestTheStartLineNamesTheCommandAndNotItsArguments (посадка «вернуть "args"» падает). Живая проба на боевом бинаре: grep -c 'ceiling-usd|3.000000|bk_' daemon.log = 0 за полный прогон |
fixed(P4, дерево сессии) | приёмка P2 (панель) |
| PD-105 | standards | major (для промта эмиттера) | internal/ingest/decoder.go:96 |
Декодер и норматив зоны расходятся на дубле seq: декодер объявляет его фатальным ErrStreamGap, а ENGINEERING_STANDARDS §2 ратифицирует «at-least-once — норма, дубль — не ошибка». После фикса PD-12 цена выросла: сбой ингеста ОСТАНАВЛИВАЕТ прогон, поэтому одна задублированная строка убивает платный прогон, хотя ратифицированный путь ремонта — status --json. Внутри одного пайпа передоставки нет, так что отказ декодера защитим; непропорциональна РЕАКЦИЯ. Разрешать ратификацией вместе с промтом эмиттера (строка 103), не молча. ⚠ Пере-диспозиция (эррата №15): вес ПОВЫШЕН до major-для-эмиттера — при ратифицированном транспорте (тейл events.jsonl с курсором, D39.106) повторное чтение строк после краша читателя — НОРМА, а не аномалия пайпа, поэтому норматив «at-least-once, дубль не ошибка» буквально верен, и фатальный отказ декодера прямо ему противоречит — ЗАКРЫТО РАТИФИКАЦИЕЙ (D39.119) И РЕАЛИЗАЦИЕЙ. Транспорт — тейл events.jsonl с курсором, поэтому повторное чтение строк НОРМА: ingest.Tail пропускает seq <= last_seq идемпотентно и не возвращает ошибку, а pgstore.RunSink.Apply пере-проверяет тот же high-water mark ВНУТРИ транзакции эффекта. Тот же seq с ДРУГИМ payload = ErrPayloadConflict → карантин ПОПЫТКИ, то есть её проекции: материализация останавливается, а жизненный цикл прогона продолжается — движок тратит зарезервированные деньги, и наша неспособность прочитать журнал не повод их выбросить (pgstore.Quarantine пишет только run_attempts.quarantine_reason, свежесть переходит на ре-синк). Сверка по sha256 строки (run_attempts.last_line_sha256). Пропасть (seq > last+1) осталась ошибкой — строки потеряны, читать дальше нечего. Пины: ingest.TestARedeliveredLineIsNormalAndChangesNothing · TestTheSameSeqWithADifferentPayloadIsRefused · TestALostLineIsReportedRatherThanSkipped · pgstore.TestARedeliveredCountingEventDoesNotCountTwice (⚠ последний написан ПОСЛЕ того, как посадка пережила первую версию пина: прогресс — присваивание и потому идемпотентен сам по себе, считающий эффект — unit_done — нет). Фатальный ErrStreamGap на дубле в decoder.go остаётся только на ДЕВ-пути пайпа, где передоставки нет |
fixed(P4, дерево сессии) | приёмка P2 (панель) |
| PD-108 | bug | major | internal/ingest/supervisor.go:161 (было), internal/runner/engine.go |
Ратифицированный канал ремонта не мог работать НИ РАЗУ: tmctl status --json звался БЕЗ обязательного --config. Движок требует его на каждой команде, которая трогает книгу, и падает на разборе аргументов до того, как увидит книгу. Найдено чтением cmd/tmctl/invocation.go (не доки) и воспроизведено исполнением на бинаре, собранном из HEAD в скрэтчпаде: tmctl status --json → tmctl: --config book.yaml is required, exit 1; с --config <путь> доходит до чтения файла. Дефект латентный ровно потому, что вызывающих у канала не было (PD-43) — то есть первый же резюнк воркера получил бы отказ вместо отчёта — закрыто: прод-путь runner.StatusArgs/runner.Status всегда несёт --config <workdir>/book.yaml; дев-путь Supervisor.Status исправлен там же. Пин — runner.TestEveryEngineInvocationNamesTheBookConfig |
fixed(P4, дерево сессии) | сессия P4 (ревью вне карты: чтение парсера движка + проба на HEAD-бинаре) |
| PD-109 | bug | minor | internal/runs/reconcile.go |
Периодический резюнк ЗАТИРАЛ более точную проекцию потока своей грубой. status --json не делит стадии по волнам (строка 99), поэтому его агрегат, положенный поверх «draft 7/20 ∥ edit 1/20», заменял пофазные счётчики одним числом — а отчёт движка, который ещё не досчитал, заменял их НУЛЯМИ. Плюс вторая половина: сторож «поток уже говорил» читал LastSeq из СНИМКА свипа, взятого ДО тейла, поэтому прогон, чьи первые события пришли в этом же свипе, выглядел молчащим. Найдено живой пробой сквозного прогона, не тестом: карточка книги показала edit 10/10 через секунды после того, как журнал сказал edit 0/10 — закрыто: резюнк работает только там, где чинить нечего (курсор не двигался ЛИБО материализация в карантине), сторож судит курсор ПОСЛЕ тейла, и ApplyStatus не опускает счётчики (greatest). Пины — runs.TestALiveRunIsResyncedAtMostOncePerInterval и TestTheSweepMaterializesWhateverTheJournalHasGained |
fixed(P4, дерево сессии) | сессия P4 (живая проба сквозного прогона) |
| PD-110 | bug | minor | internal/ingest/tail.go |
Строки ДРУГОЙ попытки судились против НАШЕГО курсора. Журнал пер-книжный и append-only, значит резюм дописывает второй hello со своим engine_run_id и seq, начинающимся заново; строка при этом не несёт идентификатора потока — его говорит только последний хендшейк выше. Первая редакция тейлера этого не отслеживала, поэтому seq 2 предыдущей попытки встречался с нашим seq 2 и читался как ИЗМЕНЁННЫЙ payload, то есть как сигнал порчи: здоровый резюмнутый прогон отправлял сам себя в карантин. Найдено собственным тестом до всякой интеграции — закрыто: читатель ведёт область (mine), и до хендшейка, который он ПРИЗНАЛ своим, ничего не материализуется и ничего не судится. Пины — TestAnotherAttemptsStreamInTheSameJournalIsSkipped · TestARereadFromTheStartDoesNotMistakeAnotherAttemptForCorruption · TestEventsBeforeAnyHandshakeAreNotJudgedAgainstOurCursor (обе посадки — mine := true и mine := false — падают) |
fixed(P4, дерево сессии) | сессия P4 (собственный тест) |
| PD-111 | bug | minor | internal/ingest/tail.go |
seq хендшейка не персистился, поэтому ЛЮБОЙ резюм после него читался как пропасть. hello — это seq 1 потока, но первая редакция обрабатывала его отдельно и курсор не двигала: last_seq оставался нулём при уже сдвинутом байтовом хинте, и следующая же строка (seq 2) давала seq 2 after 0 → карантин на ровном месте — закрыто: хендшейк проходит те же правила, что любая строка, и двигает курсор; эффекта на read-model у него нет, эффект на курсор и есть смысл. Пин — TestAHalfWrittenLineIsLeftForNextTime (сверяет применённые seq 1,2 и продолжение 3,4 после дозаписи) |
fixed(P4, дерево сессии) | сессия P4 (собственный тест) |
| PD-116 | bug | minor | internal/pgstore/runs.go RecordSpawn, internal/runs/spawn.go |
Спавн попытки мог прийти ОДНОВРЕМЕННО из воркера очереди и из реконсилятора, и оба видели «не запущено». Воркер получает прогон заданием, реконсилятор находит его неспавненным на своём проходе — обе ветки законны и обе читали unit_name до записи. Дальше их спасала только уникальность ИМЕНИ юнита у systemd: второй systemd-run падал с «unit already exists». Выживание по чужому правилу — не корректность, и оно перестаёт работать в день, когда именование поменяется (например, резюм получит суффикс). Найдено собственным ревью кода на конкурентность, до отчёта — закрыто: RecordSpawn стал compare-and-set (where id = $1 and unit_name is null) и возвращает, досталось ли право; проигравший НЕ стартует и это не ошибка. Пин — runs.TestOnlyOneOfTwoConcurrentSpawnersStartsTheEngine (восемь конкурентных спавнеров, ровно один юнит); посадка «убрать and unit_name is null» падает |
fixed(P4, дерево сессии) | сессия P4 (самопроверка на гонки) |
| PD-117 | bug | minor | internal/httpapi/v0.go startRun |
Потолок ниже минимума схемы отвечал 409, а не 400. RunRequest.ceiling_chapters объявлен minimum: 1, и запрос с 0 или отрицательным — МАЛФОРМИРОВАННЫЙ; 409 же определён как «границы сдвинулись между чтением run-options и этим вызовом», поэтому клиент, получивший его, пере-читает run-options и повторяет запрос, который не может пройти НИКОГДА. Замерено ревью: {"ceiling_chapters":0} → 202 у хендлера и 409 после сервиса — закрыто: минимум схемы судится в хендлере, до сервиса. Пин — TestACeilingBelowTheSchemaMinimumIsARejectedRequestAndNotAMovedBound |
fixed(P4, дерево сессии) | адверсариальное ревью (сверка со спекой, исполнением) |
| PD-118 | bug | minor | internal/pgstore/books.go ReadUsage, runs.go PauseRun |
Usage.paused_reason был НЕДОСТИЖИМ через собственный путь паузы платформы. ReadUsage требовал finished_at is null, а PauseRun — путь реконсилятора — ставит finished_at тем же запросом, что и паузу. Значит поле заполнялось только когда стоп пришёл событием потока (sink.go, finished_at не трогает) и молчало, когда паузу вызвала платформа. Замерено ревью на живом PG — закрыто: состояние читается по ПОСЛЕДНЕМУ прогону каждой книги (lateral), без условия на finished_at. Пин — TestTheAccountReportsAPauseTheReconcilerCaused |
fixed(P4, дерево сессии) | адверсариальное ревью (исполнением) |
| PD-119 | bug | minor | internal/httpapi/v0.go getBook |
Карточка книги несла ревизию ПРОГОНА, которая отстаёт от книжной. Контракт: счётчик ОДИН на книгу и «каждое книго-скоупное чтение и id каждого кадра потока несут одно и то же число». unit_done двигает books.revision и chapters.revision, но не runs.revision, поэтому клиент, применивший кадр id=2, получал в карточке 0 и — по правилу самого контракта — обязан был чтение ОТБРОСИТЬ: карточка не обновлялась всю серию unit-done. Замерено ревью через настоящий RunSink (0→1→2 у книги при 0 у прогона) — закрыто: и BookDetail.revision, и Run.revision проецируются из счётчика КНИГИ. Пин — TestTheCardsRevisionIsTheBooksAndNotTheRuns |
fixed(P4, дерево сессии) | адверсариальное ревью (исполнением) |
| PD-120 | vuln | minor | internal/pgstore/books.go курсор пагинации |
Курсор из ЧУЖОЙ библиотеки принимался молча. Контракт прямо возлагает отказ на СЕРВЕР («rejecting a cursor from a dead epoch is the SERVER's duty, MUST, answered 400»), потому что клиенту токен непрозрачен по построению. Курсор нёс только (added_at, id) и не нёс метки коллекции, поэтому токен, построенный на библиотеке другого аккаунта, отдавал окно СВОИХ книг вызывающего вместо 400. Замерено ревью на живом PG (чужой курсор → err=nil, 3 строки). Утечки чужих данных нет — выборка всегда owner_id = $1, — но клиент получает не то окно и обнаружить это не может — закрыто: курсор несёт метку области (sha256("library"+owner), первые 8 байт), чужая метка = ErrBadCursor → 400. Пин — TestACursorFromAnotherLibraryIsRefused (плюс проверка, что свой курсор по-прежнему работает) |
fixed(P4, дерево сессии) | адверсариальное ревью (исполнением) |
| PD-121 | bug | minor | internal/httpapi/v0.go usageState |
/usage говорил «exhausted» там, где прогон стартует. Доля округляется ВНИЗ, поэтому $9 остатка от гранта $1000 дают 0%, а состояние выводилось из доли: экран аккаунта показывал «ничего не осталось», пока run-options на той же секунде отдавал шкалу в 300 глав и прогон запускался. Замерено ревью — закрыто: «exhausted» — факт о балансе (Usage.Spendable), а не следствие округления; оба экрана отвечают из одного факта. Пины — TestASmallRemainderIsLowAndNotExhausted, pgstore.TestASmallRemainderOfALargeGrantIsStillSpendable |
fixed(P4, дерево сессии) | адверсариальное ревью (исполнением) |
| PD-124 | bug | major, деньги | internal/runs/reconcile.go расчёт |
Расчёт брал ПОЖИЗНЕННУЮ трату КНИГИ и выставлял её как трату прогона. committed_usd из status --json движок считает как SELECT COALESCE(SUM(committed_usd),0) FROM spend WHERE book_id = ? (backend/internal/store/ledger.go в HEAD, «for a book across all days») — сумма по книге за всю историю. Значит каждый следующий прогон книги оплачивал заново всё, что она стоила раньше; перерасход ограничен холдом (Settle каппит), и в леджере он выглядит строкой «capped at the hold», то есть как перерасход ДВИЖКА, а не как арифметика платформы. Замерено двумя независимыми верификаторами на живом PG: прогоны по $1.00 и $0.50 списали $2.50; после того как пожизненная сумма книги перерастает потолок, каждый прогон стоит ровно свой потолок независимо от работы — закрыто: попытка записывает БАЗОВУЮ ЛИНИЮ книги перед стартом (spend_baseline_micro_usd, миграция 00010, читается status --json ДО создания юнита) и платит РАЗНИЦУ; базовая линия не прочиталась = попытка не стартует (платный прогон, который нельзя корректно выставить, хуже прогона, стартующего свипом позже). Пин — runs.TestASecondRunOnABookIsChargedOnlyForWhatItSpent |
fixed(P4, дерево сессии) | адверсариальное ревью ×2, независимо, исполнением |
| PD-125 | bug | major, деньги | internal/runs/reconcile.go restart |
Перезапуск при недоступном расчёте открывал ВТОРОЙ холд и терял первый навсегда. settle законно ОТКЛАДЫВАЕТ (движка не спросить) и возвращает nil; restart читал это как успех, брал новый холд на остаток и уходил дальше, а старая резервация оставалась открытой — и не попадала ни в один список: UnsettledRuns фильтровал по ЗАВЕРШЁННОСТИ ПРОГОНА, а ListLiveRuns берёт только попытку с ended_at is null. Замерено: прогон с потолком $3.00 показал $6.00 зарезервированных и закончил с $3.00, навсегда снятыми с баланса, при нуле в списке несведённых — закрыто: перезапуск СПРАШИВАЕТ (AttemptReservationOpen) и откладывается, пока предыдущая попытка не сведена; UnsettledRuns теперь ключуется на ЗАВЕРШЁННОСТИ ПОПЫТКИ, поэтому брошенная резервация видна и при живом прогоне. Пины — TestARestartIsDeferredWhileTheInterruptedAttemptIsUnsettled, TestAnInterruptedAttemptsHoldIsStillFoundWhileItsRunGoesOn |
fixed(P4, дерево сессии) | адверсариальное ревью ×2, независимо, исполнением |
| PD-126 | bug | major, деньги | internal/runs/spawn.go |
Юнит, который НЕ удалось создать, съедал бюджет прогона по свипу за раз. Право на спавн записывалось до Runner.Start, и при отказе systemd-run оставалась запись «юнит есть» без юнита и без маркера — то есть в точности форма прерванного прогона. Каждый свип перезапускал прогон: расчёт, новый холд, отказ спавна, снова. Замерено: шесть свипов — попытка 7 и $0.60 списано за движок, который ни разу не стартовал; при SweepEvery=15s весь потолок уходит за минуты — закрыто: неудавшийся Start СНИМАЕТ право (ReleaseSpawnClaim), и следующий свип повторяет ту же попытку вместо перезапуска прогона. Пин — TestAUnitThatCannotBeCreatedDoesNotEatTheRunsBudget (шесть свипов: попытка остаётся первой, баланс не двигается) |
fixed(P4, дерево сессии) | адверсариальное ревью (исполнением) |
| PD-127 | bug | major | internal/runs/reconcile.go drainJournal |
Одна нечитаемая строка журнала запирала прогон навсегда. Тейл шёл ДО чтения маркера и возвращал ошибку из всей сверки, а карантинились только пропасть и конфликт payload; малформированная строка, строка длиннее буфера, подменённый файл и битый хендшейк возвращали жёсткую ошибку каждый свип. Замерено: пять свипов — статус translating, finished_at пуст, холд $3.00 держится, при том что маркер на диске и движок давно вышел — закрыто: ЛЮБАЯ неустранимая ошибка журнала = карантин ПРОЕКЦИИ, а жизненный цикл (маркер, живость, расчёт) продолжается; отмена контекста карантином не считается. Пин — TestAnUnreadableJournalDoesNotStopTheRunFromFinishing |
fixed(P4, дерево сессии) | адверсариальное ревью (исполнением) |
| PD-128 | bug | minor | internal/runs/reconcile.go грация спавна |
Грация мерилась от старта ПРОГОНА, а не попытки, поэтому у перезапущенной попытки её не было вовсе: она наследует started_at многочасовой давности и признаётся потерянной, как только systemd не успел ответить. Замерено: через секунду после перезапуска — попытка 3 и три созданных юнита — закрыто: run_attempts.started_at читается отдельным полем и грация мерится от него. Пин — TestAnAdmittedRunIsGivenTimeBeforeItIsPresumedLost (снимок с часовым прогоном и пятисекундной попыткой) |
fixed(P4, дерево сессии) | адверсариальное ревью (исполнением) |
| PD-130 | bug | minor | internal/ingest/tail.go readLine |
Любая ошибка чтения превращалась в io.EOF, то есть в «догнали, нового нет»: отказ диска читался бы как тишина, материализация вставала бы молча и ни один свип не сказал бы почему — закрыто: только настоящий EOF означает «догнали»; всё прочее возвращается ошибкой и уходит в карантин с причиной |
fixed(P4, дерево сессии) | адверсариальное ревью (чтение кода) |
| PD-131 | bug | minor | internal/ingest/tail.go |
Хендшейк не обязан был нести seq 1. На этом транспорте hello ДВИГАЕТ курсор, поэтому hello с seq 0 оставлял курсор нулём, а первое настоящее событие отбрасывалось как его дубль; отрицательный seq уходил в ветку «уже применено». Пайп-декодер это требование имел всегда (decoder.go), файловый читатель — нет — закрыто: seq != 1 у хендшейка = ErrBadHandshake |
fixed(P4, дерево сессии) | адверсариальное ревью (чтение кода) |
| PD-132 | bug | minor, деньги | internal/runs/reconcile.go settle |
Холд прогона, который так и не стартовал, не возвращался. После введения базовой линии (PD-124) попытка без неё не сводилась вовсе, а попытка, которую никогда не спавнили, базовой линии и не имеет — её холд оставался зарезервированным навсегда. Найдено собственным тестом при починке PD-124 — закрыто: нет базовой линии И нет имени юнита ⇒ попытка не выполнялась, холд возвращается ЦЕЛИКОМ (Release); нет базовой линии, но юнит был ⇒ расчёт удерживается с ERROR-строкой, а не угадывается. Пин — TestTheHoldOfARunThatNeverStartedComesBackWhole |
fixed(P4, дерево сессии) | самопроверка при починке PD-124 |
| PD-133 | bug | minor | internal/httpapi/v0.go contractRoutes |
Инстанс без движка не отдавал НИЧЕГО, вопреки собственной строке лога. Маршруты монтировались только когда есть И read-model, И жизненный цикл прогонов, поэтому инстанс с библиотекой и без раннера отвечал 404 на /v0/books, а его же стартовая строка говорила «библиотека отдаётся только на чтение». Замерено ревью — закрыто: ЧТЕНИЯ монтируются при наличии read-model, ручки прогона — при наличии жизненного цикла. Пин — TestAnInstanceWithoutARunnerStillServesTheLibrary |
fixed(P4, дерево сессии) | адверсариальное ревью (исполнением) |
| PD-134 | bug | minor | internal/pgstore/runs.go, internal/runs/spawn.go |
Пиннинг версии движка (строка 139) записывался и НИКОГДА не читался: run_attempts.engine_binary не входил в выборку реконсилятора, а спавн и канал ремонта брали путь из ТЕКУЩЕГО конфига. Пин, который никто не читает, — это колонка, а не пин: резюм исполнял бы то, что выкатили сегодня, а status --json спрашивал бы о книге бинарь другой версии — закрыто: EngineBinary читается в LiveRun и используется и резюмом, и каналом ремонта; конфиг остаётся фолбэком только для ещё не спавненной попытки |
fixed(P4, дерево сессии) | адверсариальное ревью (чтение кода) |
| PD-135 | bug | minor | internal/runs/reconcile.go restart |
Прерванный прогон без остатка бюджета помечался paused БЕЗ paused_reason (FinishRun его не трогает), тогда как контракт описывает PausedReason как причину паузы, и экрану сказать нечего — закрыто: используется PauseRun, который причину ставит |
fixed(P4, дерево сессии) | адверсариальное ревью (чтение кода) |
| PD-136 | doc | minor | deploy/tmplatformd.service |
Юнит нёс ОБЕ диспозиции сразу: старый абзац подавал ProtectHome=yes как «нужную позу на сервере» прямо над строками, ставящими tmpfs+BindPaths, а рассуждение о ресурсных потолках всё ещё исходило из модели «дети живут в cgroup этого юнита», снятой D39.106. Оператор, читающий сверху вниз, получал противоречивые инструкции в одном файле — закрыто: снятые абзацы удалены, потолки прямо названы границей КОНТРОЛ-ПЛЕЙНА, прогоны — своим срезом |
fixed(P4, дерево сессии) | адверсариальное ревью (чтение) |
| PD-138 | standards | info | go.mod |
Прямые зависимости (riverqueue/river, riverdriver/riverpgxv5) стояли помеченными // indirect: make check тидинесс не проверяет, поэтому батарея этого не видела — закрыто: go mod tidy. ⚠ Строка оставлена как заявка: гейта на go mod tidy в батарее по-прежнему нет |
fixed(P4, дерево сессии) | адверсариальное ревью |
| PD-142 | standards | minor | вся зона, тесты | ⚠ Заявление «22 новых пина, каждый проверен своей посадкой» СНЯТО дофиксом 09.08 как непроверяемое в этом объёме: прогонов посадок было девять (9/10, затем 9/9), то есть «каждый из 22» ими не покрывался, а поимённого списка соответствия пин↔посадка сессия не вела. Проверено исполнением и названо поимённо другое: 33 посадки самопроверки пака и 24 посадки дофикса (список — журнал, раздел «Дофикс P4»). Аудит силы пинов посадками (139 мутаций, четвёртый верификатор): 107 поймано, 32 пережили, из них 8 — не ослабления (эквивалентный код либо страховка DDL-констрейнтом). Пережившие — не дефекты КОДА, а отсутствующие пины на свойства, часть которых объявлена закрытой; по правилу шапки этого файла такое свойство закрытым не считается. Закрыто: написаны 22 новых пина; про «каждый проверен собственной посадкой» — см. начало строки, заявление снято (прогонов посадок было девять: 9/10, потом 9/9 после исправления двух ошибочно сформулированных мутаций). Самые весомые: блокировка строки попытки под КОНКУРЕНЦИЕЙ (TestConcurrentDeliveriesOfOneEventCountItOnce — восемь горутин на одно событие; последовательная доставка поглощается одним high-water mark и посадку не ловила) · CSRF на КОНТРАКТНОЙ поверхности (TestACrossSiteRequestCannotStartARun — снятие CSRF из гарда /v0 переживало всё, а это старт платного прогона с амбиентной кукой) · RunSpent считает только свой прогон и не считает открытые холды · обе ветки отложенного расчёта · MarkSettled одноразов · хендшейк второго движка отвергается · монотонность байтового хинта · пустой payload · ETA-ноль · черновой юнит не «сделан» · paused при нехватке баланса · principal падает ЗАКРЫТО · внутренний текст не течёт в Problem. ⚠ Одна посадка («убрать and ended_at is null из RestartRun») пережила и НЕ является ослаблением: unique (run_id, attempt_no) отвергает всех проигравших гонку, так что ровно один перезапуск проходит и без неё — записано, а не подчищено |
fixed(P4, дерево сессии) | адверсариальное ревью (аудит посадками) |
| PD-143 | bug | minor | internal/runs/spawn.go spec, internal/pgstore/runs.go RestartRun |
Пиннинг версии движка (строка 139) был закрыт НАПОЛОВИНУ: запиненный путь читался каналом ремонта, но НЕ исполнялся резюмом. spec() брал Cfg.EngineBinary, а RestartRun не переносил engine_binary в новую попытку, поэтому перезапущенный прогон шёл на том бинаре, который выкачен СЕЙЧАС, — то есть перевод продолжала другая программа, и «резюм другой версией только явным флагом» не выполнялось. Найдено собственной пост-сверкой диффа с промтом (не ревью и не батареей: обе половины компилировались и все тесты были зелёными) — закрыто: новая попытка НАСЛЕДУЕТ engine_binary предыдущей, spec() исполняет запиненный путь, а переход на другую сборку требует явного TM_PLATFORM_RESUME_MAY_CHANGE_ENGINE. Пины — runs.TestAResumeStaysOnTheEngineBuildTheRunStartedWith и TestAResumeMovesToANewEngineBuildOnlyWhenItIsAllowed; обе посадки («spec берёт из конфига», «RestartRun не наследует») падают |
fixed(P4, дерево сессии) | пост-сверка диффа с промтом |
Закрытые — дофикс P4 и ре-чек V2
| ID | Класс | Серьёзность | Где | Суть | Статус | Источник |
|---|---|---|---|---|---|---|
| PD-112 | standards | minor | internal/httpapi/v0.go, контракт 0.2.0 |
Реализация отдаёт статусы, которых спека у операций НЕ перечисляет — четыре класса, все проверены исполнением адверсариальным ревью: 503 на старте прогона (деплой не может передать потолок/записать конец юнита) · 403 от CSRF-слоя на любом небезопасном запросе (спека описывает требование X-TM-Client в securitySchemes, но статуса ему не даёт) · 500 у любой операции при отказе стора (спека не перечисляет 5xx нигде) · 404 у GET /usage при отсутствующем аккаунте (спека даёт только 200/401; практически недостижимо — сессия ссылается на строку users внешним ключом). Ниже — исходная постановка по 503. Отказ 503 на старте прогона НЕ входит в перечисленные спекой статусы операции (400/401/404/409). Он поставлен осознанно: когда деплой не может передать движку потолок (строка 145) или записать конец юнита, запрос ВАЛИДЕН, объект существует и состояния конфликта нет — то есть каждый из разрешённых кодов сообщил бы неправду. Правка спеки — не право зоны (правило промта: расхождение = вопрос оркестратору). Строка ждала решения владельца контракта — закрыто РАТИФИКАЦИЕЙ (оркестратор №15, 09.08): 503 вносится в спеку правкой владельца контракта при лендинге; код зоны не меняется |
fixed(ратификация 09.08; правка спеки за оркестратором) | сессия P4 (самопроверка против спеки) |
| PD-129 | bug | minor | internal/pgstore/sink.go, runs.go |
Инверсия порядка блокировок между материализатором и финишером: RunSink берёт books … for update и затем правит runs, а FinishRun/PauseRun правили runs и затем books. Два реконсилятора на одном прогоне (перекрытие поколений деплоя) дают взаимоблокировку в обе стороны — замерено ревью, SQLSTATE 40P01 на обеих формулировках. Порчи нет (Postgres откатывает одну сторону), цена — провалившийся проход свипа и секунда детекта — ⚠ ПЕРЕ-ДИСПОЗИЦИЯ 09.08: строка была закрыта ЛОЖНО. Правка P4 привела к книге-первой только FinishRun/PauseRun; сам материализатор (RunSink.Apply) продолжал брать run_attempts … for update ПЕРВЫМ, а RestartRun — обновлять попытку до всего остального, и приёмка воспроизвела дедлок через реальные API (258 из 300 пар). Закрывающая формулировка описывала половину правки как целое. Действительно закрыто дофиксом — см. PD-145 |
fixed(дофикс P4, дерево сессии; см. PD-145) | адверсариальное ревью (исполнением) |
| PD-144 | bug | major, деньги/шов | internal/runs/spawn.go, internal/ingest/resync.go |
Движку передавался ПРИРОСТ там, где его флаг означает НАКОПЛЕННЫЙ книжный потолок. --ceiling-usd переопределяет ceilings.book_usd и сравнивается с committed + reserved книги на КАЖДОЙ резервации (backend/internal/store/ledger.go Reserve, backend/cmd/tmctl/invocation.go — «It caps the book's CUMULATIVE committed+reserved spend, not this run's increment»). Значит второй прогон книги, у которой накоплено ≥ прироста, отвергается первой же резервацией: движок выходит кодом 1, платформа обязана назвать это failed, работа не сделана, ретраи идентичны. Приёмка доказала обе стороны исполнением; сквозная проба пака этого не видела, потому что её фейк кумулятив не моделировал — закрыто: runs.meter.bookCap = committed + прирост (ратифицировано 09.08 ре-чеком V2 после PD-158; reserved в сумму НЕ входит), обе величины читаются ОДНИМ вызовом status --json перед стартом (bookMeter), reserved_usd внесён в аллоулист УКАЗАТЕЛЕМ (отсутствие ≠ ноль, как у committed), рестарт получает свежий отсчёт по тому же пути, фактически ушедшее значение хранится (run_attempts.ceiling_arg_micro_usd, миграция 00011). Пины: runs.TestTheSecondRunOfABookIsGivenTheCumulativeCapAndNotItsOwnIncrement и TestAResumeIsGivenACapComputedFromTheMeterAsItStandsNow — оба через фейк ceilingJudge, который отвергает потолок ПО ПРАВИЛУ ДВИЖКА; TestAnAttemptWhoseMeterCannotBeReadIsNotStartedAtAll покрывает отсутствующий reserved_usd. Проба приёмки на этом дереве: --ceiling-usd 6.000000 при committed книги $3 |
fixed(дофикс P4, дерево сессии) | приёмка P4 (F1, двусторонним исполнением) |
| PD-145 | bug | major | internal/pgstore/sink.go Apply, runs.go RestartRun, internal/runs/reconcile.go |
Инверсия блокировок из PD-129 была жива, а транзиентный сбой из-за неё уходил в КАРАНТИН. RunSink.Apply брал run_attempts … for update первым, RestartRun правил попытку до всего остального, а FinishRun/PauseRun берут книгу первой — приёмка воспроизвела 258 дедлоков на 300 пар через реальные API. Усилитель хуже самого дедлока: 40P01 из Apply попадал в ветку «любая ошибка журнала = карантин», то есть проекция ЖИВОГО платного прогона слепла навсегда из-за блокировки, которая разрешилась сама — закрыто: порядок написан в одном месте и стал глобальным (pgstore.lockBook: books → runs → run_attempts → account_balances → reservations), книга блокируется первой в Apply, RestartRun, StartRun и DeleteBook (последний найден собственной сверкой всех транзакций пакета: цикла для него нет, но инвариант, у которого есть исключение, перестаёт быть инвариантом); классификация ошибки вынесена в runs.quarantines. ⚠ Формулировка «карантин остаётся только логическим ошибкам» была НЕВЕРНА в части и исправлена ре-чеком V2: первая редакция IsTransient знала только про дедлок и сериализацию, поэтому обрыв соединения с Postgres — то есть ШТАТНЫЙ рестарт управляемой базы (57P01/57P02/57P03, класс 08, сетевой сброс) — по-прежнему карантинил проекцию живого платного прогона НАВСЕГДА (пути снятия карантина в дереве нет). Доказано исполнением приёмкой. Теперь IsTransient покрывает класс 08, 57P0x, pgconn.SafeToRetry и любой net.Error; ошибки чтения файла — *fs.PathError и net.Error не удовлетворяют, поэтому битый журнал по-прежнему останавливает проекцию, как и должен. Кейсы внесены в таблицу пина. Пины: pgstore.TestTheMaterializerTakesTheBookBeforeTheAttempt и TestARestartTakesTheBookBeforeTheAttempt (порядок утверждается ПРЯМО — блокировка книги удерживается, операция обязана ждать её, а строка попытки обязана остаться свободной под for update nowait), TestAMaterializerAndAReconcilerOnOneRunDoNotDeadlock (конкурентный, 60×3), runs.TestOnlyAJournalWeCannotReadStopsTheProjection |
fixed(дофикс P4, дерево сессии) | приёмка P4 (F2, исполнением) |
| PD-146 | standards | major (пин) | internal/runs/spawn.go bookMeter |
Сердце PD-124 не было запинено: посадка «нечитаемый отсчёт → (0, nil)» пережила ПОЛНУЮ батарею. С ней расчёт идёт против базовой линии 0, то есть прогон оплачивает всю пожизненную трату книги — ровно тот дефект, который PD-124 объявил закрытым. Отказ спавну был построен и не проверен ни одним тестом — закрыто: TestAnAttemptWhoseMeterCannotBeReadIsNotStartedAtAll — три формы нечитаемости (вызов упал · нет committed_usd · нет reserved_usd), и в каждой утверждается, что юнит не создан И попытка не заклеймлена (unit_name пуст), значит следующий свип её повторит; хвост теста показывает, что после починки движка та же попытка стартует |
fixed(дофикс P4, дерево сессии) | приёмка P4 (F3, посадка) |
| PD-147 | standards | major (пин) | internal/runs/spawn.go spawnAttempt |
Очистка устаревшего exit-маркера перед стартом не была запинена: удаление os.Remove(spec.ExitMarker) пережило полную батарею. Залежавшийся маркер той же попытки читается как её окончание при первом же взгляде реконсилятора: прогон, движок которого только что запущен, будет завершён и рассчитан заживо — закрыто: TestAStaleExitMarkerIsClearedBeforeTheUnitStarts — маркер создаётся ДО спавна, после спавна обязан отсутствовать, а свип обязан оставить прогон живым с открытым холдом |
fixed(дофикс P4, дерево сессии) | приёмка P4 (F4, посадка) |
| PD-148 | bug | major, деньги | internal/runs/reconcile.go settle, internal/pgstore/credits.go |
Расчёт по УСТАРЕВШЕМУ снапшоту свипа возвращал холд целиком попытке, которая потратила. Свип реконсилит по списку, прочитанному в начале прохода; быстрый прогон успевает стартовать, потратить и выйти, пока свип идёт по предыдущим — и ветка «попытки не было» судила по l.UnitName == "" из снапшота, хотя в БД спавн уже записан. Приёмка доказала исполнением: charged 0.000000 за попытку, потратившую 0.500000; недоплата безвозвратна и никем не ищется — закрыто: pgstore.ReleaseUnspawned перечитывает run_attempts.unit_name for update В ТОЙ ЖЕ транзакции, что и деньги, и отказывает (ErrAttemptSpawned), если юнит есть; реконсилятор откладывает расчёт до следующего прохода, где снапшот уже содержит юнит и попытка рассчитывается по своей базовой линии. Пин: runs.TestAStaleSnapshotDoesNotGiveBackTheHoldOfAnAttemptThatSpent (холд не возвращается целиком в проходе со старым снапшотом; следующий свип списывает ровно потраченное) |
fixed(дофикс P4, дерево сессии) | приёмка P4 (F5, исполнением) |
| PD-149 | bug | minor | internal/config/config.go loadRunner |
Относительный TM_PLATFORM_STATE_DIR не абсолютизировался, а маркер пишется и читается из РАЗНЫХ рабочих каталогов: ExecStopPost исполняется юнитом, у которого WorkingDirectory — каталог книги, а демон читает от своего cwd. Конец прогона становится невидим, реконсилятор перезапускает прогон бесконечно — закрыто: filepath.IsAbs на буте, отказ с именем переменной; пин config.TestARelativeStateDirectoryIsRefusedAtBoot |
fixed(дофикс P4, дерево сессии) | приёмка P4 (F6) |
| PD-150 | standards | minor | internal/pgstore/sink.go ApplyStatus |
«Метка давности» ре-синка была обещана промтом («честно, с меткой давности») и не построена: now в ApplyStatus не использовался, поля свежести не было, и у читателя замершей проекции карантинной попытки не было ничего, что сказало бы, насколько старые цифры он видит — закрыто: runs.last_resync_at (миграция 00012) пишется КАЖДЫМ ре-синком; пин pgstore.TestAResyncRecordsWhenItWasTaken (нет метки до первого · метка равна времени вызова · вторая переписывает первую). ⚠ Названная девиация: на провод метка НЕ выходит — в контракте v0 у прогона поля свежести нет; читается оператором и той ручкой, которая появится вместе с полем |
fixed(дофикс P4, дерево сессии; половина «на провод» — за контрактом) | приёмка P4 (F7) |
| PD-151 | doc | minor | docs/platform-PROGRESS.md раздел «Сессия P4» |
Числа отчёта расходились с истиной, а одно заявление было внутренне противоречиво: тело журнала давало «105 → 203, +98» (истина на момент приёмки — 242/+137/−0), «36 новых строк = 25+10» (истина — 26 закрыто + 10 открыто), и «22 новых пина, каждый проверен своей посадкой (9/10, затем 9/9)» — 22 не покрываются девятью прогонами — закрыто: числа пересчитаны ИСПОЛНЕНИЕМ и приведены с командой (105 → 261, удалённых 0, добавленных 156 — итог дофикса с ре-чеком V2); арифметика 36 = 26 + 10 исправлена; заявление «каждый» снято (см. PD-142). Шапка журнала была права и не тронута |
fixed(дофикс P4, дерево сессии) | приёмка P4 (F8) |
| PD-155 | doc | info | deploy/README.md |
Смена TM_PLATFORM_STATE_DIR осиротляет exit-маркеры идущих прогонов: маркер пишется по пути, вычисленному при спавне, а читается по пути из текущей конфигурации, поэтому после смены каталога конец прогона невидим и прогон перезапускается — закрыто: строка в deploy/README.md — менять каталог только при отсутствии живых прогонов |
fixed(дофикс P4, дерево сессии) | приёмка P4 (N4) |
| PD-156 | bug | info | internal/runs/spawn.go spawnAttempt |
Отказ ДЕПЛОЯ проверялся после вызова движка. Дофикс перенёс чтение денежного отсчёта в начало спавна и тем поставил его ПЕРЕД дешёвыми отказами «нечем записать конец юнита» / «нечем передать потолок»: инстанс с неполной конфигурацией платил бы секундами CPU движка (status --json пере-нарезает исходник, строка 100) за каждый прогон на каждом свипе, чтобы прийти к ответу, зависящему только от конфигурации — закрыто: runs.runnable() вызывается первым в spawnAttempt, spec и Start; пин TestAMisconfiguredDeploymentIsRefusedWithoutAskingTheEngine (посадка «убрать ранний отказ» падает) |
fixed(дофикс P4, дерево сессии) | собственная сверка диффа дофикса |
| PD-158 | bug | minor, деньги | internal/runs/spawn.go meter.bookCap |
Формула потолка из пинга ратификации (committed + reserved (из status --json) + прирост; сам D39.122 §2в ратифицирует ОБЯЗАННОСТЬ платформы пересчитывать «прирост → абсолют», буквальной формулы в решении нет — овер-атрибуцию поправило ревью доков) даёт прогону запас БОЛЬШЕ его холда, когда предыдущий процесс умер с незакрытой резервацией. Найдено сверкой формулы с кодом движка: store.Open — путь ЗАПИСИ, которым идёт каждый translate — выполняет recoverReservations и обнуляет reserved_usd книги ДО первой судимой резервации (backend/internal/store/store.go:88 и :214); tmctl status читает read-only и этот проход намеренно не делает (store.go:108 говорит об этом прямо). Значит цифра, которую видит платформа, — ОСТАТОК мёртвого процесса, и к моменту сравнения её уже нет: движок остановится позже на её величину, расчёт упрётся в потолок холда, а леджер запишет «capped at the hold», как будто перерасходовал движок. Величина ограничена размером остатка (обычно одна оценка вызова), но путь достижим на каждом резюме после падения. Сделано: bookCap считает committed + прирост — отклонение в консервативную сторону (более узкий потолок может только остановить прогон раньше, перерасхода не даёт), названо в коде и запинено TestAResumeIsGivenACapComputedFromTheMeterAsItStandsNow (второе утверждение: остаток НЕ раздул потолок). Сама цифра по-прежнему читается и обязательна (отсутствие ≠ ноль) — она и есть доказательство, что отклонение безопасно: на спавне другого писателя нет (эксклюзивный лок + один живой прогон на книгу), значит любой reserved — остаток. ⚠ Строка остаётся ОТКРЫТОЙ как вопрос: отклонение от ратифицированной формулы — не право зоны, нужен ответ оркестратора (принять формулу в виде committed + прирост либо назвать другой разбор) ЗАКРЫТО РАТИФИКАЦИЕЙ (оркестратор №15, 09.08, ре-чек V2): принята формула committed + прирост; аргументация подтверждена оркестратором исполнением обеих формул против гейта движка — ратифицированная переплачивала запасом ровно на leftover-reserved |
fixed(ратификация 09.08) | собственная сверка формулы с кодом движка при F1 |
| PD-159 | bug | major, деньги | internal/runs/reconcile.go settle, internal/pgstore/runs.go SpendBound |
Отложенный расчёт прогона оплачивал работу СЛЕДУЮЩЕГО прогона той же книги, и тот платил за неё ещё раз. Расчёт читает пожизненный счётчик КНИГИ в момент ПОВТОРА, а откладываться он вправе (движка не спросить). Завершённый-но-нерассчитанный прогон при этом не мешает новому: HasLiveRun смотрит только на finished_at. Замерено: прогон, стоивший $0.10, списан на $2.10 — своя трата плюс всё, что успел потратить преемник, — после чего преемник заплатил ту же сумму снова; переплата ограничена холдом. Найдено ДВУМЯ независимыми верификаторами самопроверки, каждый воспроизвёл исполнением, и третий раз воспроизведено мной перед починкой — закрыто: SpendBound — наименьшая базовая линия среди попыток этой книги, стартовавших ПОЗЖЕ; она снята до того, как та попытка что-либо добавила, и после того, как эта остановилась, поэтому является точной верхней границей. Расчёт берёт минимум из неё и текущего счётчика. Пин: TestADeferredSettlementIsNotChargedForTheNextRunOfTheSameBook (первый платит $0.10, второй — свои $2.00, баланс и леджер сходятся) ⚠ ПАК P8-REVIEW 24.08: пин этой строки доказывает свойство СЛАБЕЕ, чем она гласит. Формула «НАИМЕНЬШАЯ базовая линия среди попыток, стартовавших позже» не исполняется: названный пин кладёт РОВНО ОДНУ более позднюю попытку, а на множестве из одного элемента min и max совпадают, и мутация min → max в internal/pgstore/runs.go:595 проходит батарею. Живой пробел вынесен строкой PD-376 с готовым пином; статус этой строки паком НЕ менялся — если приёмка считает, что правило PD-1 требует пере-открытия, это одна правка, улика уже на месте ⚠ ОСПОРЕНО(PD-376) — греппируемый токен по предложению ревью старшей моделью: спор о том, закрыта ли эта строка, иначе живёт только прозой и тихо истекает, если приёмка пропустит вопрос. |
fixed(дофикс P4, дерево сессии) | самопроверка дофикса (два верификатора, независимо, исполнением) |
| PD-160 | standards | minor (пин) | internal/runs/reconcile.go drainJournal |
Проводка «транзиентный сбой НЕ карантинит» не пинилась: пин стоял на чистой функции quarantines, а удаление ветки, которая её ВЫЗЫВАЕТ, переживало батарею. Регрессия этой формы тихо карантинит проекцию живого платящего прогона — то есть ровно тот дефект, который F2 объявил закрытым — закрыто: TestADeadlockDoesNotStopTheProjection гонит НАСТОЯЩИЙ дедлок через весь путь (транзакция берёт строки в обратном порядке, Postgres рвёт цикл; раунд, где жертвой стала наша сторона, и есть предмет теста) и утверждает на КАЖДОМ раунде, что попытка не в карантине. Посадка «убрать ветку» падает за 1.9 с |
fixed(дофикс P4, дерево сессии) | самопроверка дофикса (посадка) |
| PD-161 | bug | minor, деньги | internal/pgstore/runs.go RecordSpawn |
Повторная заявка права на спавн ПЕРЕЗАПИСЫВАЛА базовую линию. «Не удалось создать юнит» — не то же, что «юнит не создан»: systemd-run, убитый по таймауту ПОСЛЕ подачи запроса, рапортует ошибку и оставляет движок работать. Право отдаётся обратно (ReleaseSpawnClaim), следующий свип заявляет его снова и кладёт в базовую линию счётчик, который этот же движок двигает, — попытка потом оплачивает разницу от цифры, уже включающей её собственную работу (недоплата, которую никто не ищет) — закрыто: повторная заявка сохраняет и базовую линию, и записанный аргумент потолка (coalesce / case when), там где юнит действительно не создан значения совпадают. ⚠ Ре-чек V2 показал, что этим строка закрыта НАПОЛОВИНУ: в БД значения сохранялись, а движку на ретрае уходил потолок, пересчитанный по СВЕЖЕМУ счётчику — то есть включающий трату собственного «призрака», — и форензик-колонка 00011 на этом пути лгала (замерено приёмкой: handed 3.400000 против stored 3.000000). Закрыто по-настоящему: на ретрае (SpendBaseline != nil && CeilingArg > 0) спавн ПЕРЕДАЁТ сохранённый аргумент, а не пересчитанный. Пин: TestAReclaimedAttemptKeepsTheBaselineItFirstRecorded — теперь утверждает и базовую линию, и равенство handed == stored |
fixed(дофикс P4, дерево сессии) | самопроверка дофикса (ревью вне карты, чтением) |
| PD-167 | doc | info | internal/pgstore/migrations/00009_runner.sql:37-39 |
Комментарий DDL обещает «хинт, не ведущий к seq = last_seq + 1, отбрасывается, и файл перечитывается с начала» — код так не делает: пропасть ведёт к карантину проекции и переходу на ре-синк. Расхождение док↔код, поведение верное — закрыто ре-чеком V2: комментарий приведён к тому, что делает тейлер. ⚠ Ре-чек назвал эту строку «застывшим комментарием миграции 00011»; предмет строки — комментарий 00009, а 00011 нёс отменённую формулу потолка и исправлен вместе с ней (обе миграции этим паком и написаны, нигде не применялись, поэтому их отпечатки в migrations.sha256 обновлены с явной причиной в шапке файла) |
fixed(ратификация + дофикс V2, дерево сессии) | самопроверка дофикса (ревью вне карты) |
| PD-171 | bug | minor, деньги | internal/runs/reconcile.go settle |
Счётчик книги НИЖЕ собственной базовой линии попытки списывал $0 молча. Это вырожденный случай — БД проекта подменили или восстановили из копии, — и клампить в ноль правильно (платить аккаунту за подмену файла код решать не вправе), но молчать нельзя: расчёт в ноль обнаруживался бы только по балансу — закрыто: WARN (не INFO: предмет — деньги) с фактом и без цифр (D39.84); пин TestAMeterThatWentBackwardsSettlesAtNothingAndSaysSo проверяет и отсутствие списания, и наличие строки, и что цифры в неё не попали |
fixed(дофикс V2, дерево сессии) | ре-чек V2 (оркестратор №15) |
Закрытые — третий раунд P5 (ре-чек оркестратора, 14.08)
| ID | Класс | Вес | Где | Что и чем закрыто | Статус | Кем найдено |
|---|---|---|---|---|---|---|
| PD-197 | standards | major (гейт) | Makefile tools-check, go.mod |
Гейт тулчейна не гейтил, а три дока утверждали обратное. Подъём floor 1.26.5 → 1.26.6 (пять адвизори stdlib) был сделан переменной GO_MIN_VERSION, которую читало только сообщение об ошибке, тогда как проверкой оставался регекс `go1.26.([5-9] |
[0-9]{2,})— он принимал ровно ту 1.26.5, ради отказа от которой floor и поднимали, и ставил 1.26.10 ниже 1.26.9. Клейм «хост на 1.26.5 получит отказ» стоял в журнале ×2,STACK_DECISIONSи комменте Makefile — **закрыто:** цельversion-check СРАВНИВАЕТ версии (sort -V, пререлизы rc/develотвергаются отдельно) и берёт версию из переменной, чтобы её судили версиями, которых на хосте нет;go.modполучилtoolchain go1.26.6 — его читает всякая сборка, мимо make тоже (GOTOOLCHAIN=autoскачает,=localостановится). Пиныgates.TestTheToolchainGateComparesVersionsRatherThanMatchingThem(таблица из 11 версий) иgates.TestGoModPinsTheSameToolchainTheBatteryDemands`, обе посадки падают; три клейма переписаны на описание механизма |
fixed(третий раунд, дерево сессии) |
| PD-198 | doc | info | internal/pgstore/runs.go PauseRun, internal/books/parse.go |
Комментарии описывали до-фиксное поведение — в том числе на пути, который удаляет файл пользователя. PauseRun обещал проверку стопа «в том же стейтменте» (стоит отдельный select … for update в той же транзакции); parse.go объявлял отказ источника «терминальным с первого ответа», хотя дофикс провёл КАЖДЫЙ ответ движка через бюджет попыток — закрыто: оба текста приведены к коду; правок поведения не потребовалось. ⚠ Дописка: P6 переписал текст parse.go ещё раз под полосу отказов, где терминален ровно один класс (PD-196). ⚠ испр. оркестратором №16 15.08 при лендинге: сессия P6 переименовала эту строку в PD-199 и завела под номером PD-198 вторую строку про ту же половину parse.go с ложным обоснованием «номер строки не получил» — переименование откачено (ID стабилен навсегда, коммит-первоисточник 69d485a), содержимое второй строки слито сюда; номер PD-199 остаётся за открытой строкой daily_ceiling |
fixed(третий раунд, дерево сессии) | ре-чек оркестратора (хвосты а, б) |
Закрытые — дофикс-2 P5 (кросс-семейное ревью дофикса, 13.08)
| ID | Класс | Вес | Где | Что и чем закрыто | Статус | Кем найдено |
|---|---|---|---|---|---|---|
| PD-192 | bug | major, данные пользователя | internal/books/parse.go manifest, Parse |
Пропавший КОРЕНЬ хранилища читался как вина каждой книги. os.Stat(workdir) даёт ENOENT и на снесённом каталоге книги, и на несмонтированном BooksDir; первое терминально по устройству (FP5-3), значит размонтированный том заставлял ОДИН проход свипа терминально отклонить ВСЕ книги в интейке с source_unreadable — причиной, которая винит файл пользователя и не имеет обратного хода (PD-175). Путь создан моим же фиксом FP5-3 — закрыто: ErrStorageGone отличён от ErrDirectoryGone (корень спрашивается прежде, чем винить книгу), причина storage_unavailable не терминальна, бюджета не тратит и на книге не хранится; предикат waitsForTheDeployment собрал оба «ждущих деплой» случая. Пин books.TestAVanishedStorageRootIsNotEveryBooksFault, посадка падает ⚠ Дополнено ре-чеком (FP5-10): первая редакция закрывала не тот сценарий. Гард спрашивал Stat(BooksDir), а том, смонтированный РОВНО в BooksDir, оставляет после размонтирования пустой mountpoint — Stat успешен, и все книги снова терминально отклонялись; бут безусловным MkdirAll пересоздавал корень и маскировал пропажу. Теперь решение принимается по СЕНТИНЕЛУ провижининга .tmplatform-books, который пишет только первая загрузка (markStorage, O_EXCL) и не пишет бут: ни unmount, ни MkdirAll его не подделывают. Пин books.TestAnUnmountedVolumeLooksLikeAnEmptyRootAndStillIsNotTheBooksFault; первая редакция ПИНА посадку пережила (без сентинела ждёт всё) — добавлено утверждение, что загрузка сентинел пишет, иначе терялась терминальность крэш-окна FP5-3 |
fixed(третий раунд, дерево сессии) | кросс-семейное ревью дофикса (Fable 5, линза интейка) |
| PD-193 | bug | major, деньги | internal/pgstore/runs.go PauseRun |
Третий закрывающий путь без гарда живой попытки (после PD-181 и FP5-2): пауза не проверяла, что закрываемая попытка ещё жива и принадлежит этому прогону (апдейт попытки шёл даже без run_id). Проход старого поколения при перекрывающемся деплое паузит прогон, уже рестартованный в живую попытку 2: прогон отвечает paused/credit_exhausted под тратящим движком, а холд попытки 2 не виден ни в ListLiveRuns, ни в UnsettledRuns — закрыто: тот же exists (a.id = $4 and a.run_id = runs.id and a.ended_at is null), что у соседей, плюс run_id в апдейте попытки. Пин pgstore.TestAPauseFromAnOldSnapshotDoesNotCloseARunOverALiveAttempt, посадка падает |
fixed(дофикс-2, дерево сессии) | кросс-семейное ревью дофикса (Fable 5, линза денег), воспроизведено дважды |
| PD-194 | bug | minor | internal/pgstore/sink.go Begin |
Синк законченной попытки усыновлял хендшейк следующей. Стоп ДО спавна оставляет попытку закрытой и без engine_run_id — форма, невозможная до P5; устаревший материализатор биндил на неё engine_run_id попытки-заместителя и материализовал тот же журнал второй раз, удваивая счётчики глав и юнитов (деньги не двигались) — закрыто: бинд отказан для законченной попытки. Пин pgstore.TestAMaterializerOfAnEndedAttemptDoesNotAdoptTheNextAttemptsEngine, посадка падает |
fixed(дофикс-2, дерево сессии) | кросс-семейное ревью дофикса (Fable 5, линза денег) |
| PD-195 | bug | minor, деньги | internal/runs/reconcile.go settle |
Возврат холда «прогона, который не запускался», ждал ответа движка. Ветка «юнита не было — вернуть холд целиком» стояла ПОСЛЕ обязательного tmctl status, а хост, производящий эту ситуацию, — ровно тот, где движок не запускается: Status падает на каждом проходе, и деньги остаются зарезервированными навсегда (видимыми, но запертыми) — закрыто: ветка идёт до вызова движка; ReleaseUnspawned перепроверяет строку под замком, поэтому снапшот, который успели заспавнить, отвергается там. Пин runs.TestTheHoldOfARunThatNeverStartedComesBackOnAHostWhoseEngineCannotAnswer, посадка падает |
fixed(дофикс-2, дерево сессии) | кросс-семейное ревью дофикса (гипотеза), подтверждено зоной исполнением |
Закрытые — эра P5 (загрузка книги · стоп и резюм · наблюдаемость)
| ID | Класс | Серьёзность | Где | Суть | Статус | Источник |
|---|---|---|---|---|---|---|
| PD-72 | hardening | info | internal/httpapi/server.go:88 |
Отсутствие ОБЩЕГО лимита тела над маршрутами не наблюдаемо ничем. Пер-маршрутность (PD-35/PD-53) держится на том, что вложенный MaxBytesReader только УЖЕСТОЧАЕТ: это запинено TestBodyCapIsPerRouteBecauseNestingOnlyTightens. Но возврат внешнего слоя в New батарею переживает, потому что ни один маршрут не просит потолок БОЛЬШЕ дефолтного — наблюдаемым дефект станет ровно тогда, когда появится загрузка книги. Строка заведена, чтобы это не выяснилось молча: тест обязан приехать ВМЕСТЕ с маршрутом загрузки — закрыто: маршрут загрузки приехал ВМЕСТЕ со своим потолком и своим тестом. POST /v0/books регистрируется guard(d.Upload.MaxBytes, …), пин — httpapi.TestAnUploadLargerThanTheRouteAllowsIsRefusedAsTooLarge (413 + problem+json на теле выше потолка И 201 на теле ниже него: потолок, который отвергает всё, не потолок), плюс TestTheUploadLimitBelongsToTheUploadRouteAlone — заявка на прогон с телом выше ДЕФОЛТНОГО потолка отвергается, то есть поднятие лимита одного маршрута не подняло его для остальных. Посадка «вернуть DefaultMaxBody на маршрут загрузки» падает |
fixed(P5, дерево сессии) | ревью P2 (линза doc-vs-code) |
| PD-114 | standards | minor | internal/config/, deploy/ |
Конфигурация: 27 переменных окружения, НИ ОДНОГО флага у демона и никакой печати эффективной конфигурации при старте (грепнуто). Выбор «только окружение» сам по себе мейнстрим (12-factor) и записан решением в доккомменте config.go; деплой использует systemd-нативные EnvironmentFile=+LoadCredential=. Ниже нормы другое: у оператора нет ни -version, ни «проверить конфиг и выйти», ни строки «вот что реально применилось» — не подхватившийся EnvironmentFile обнаруживается по поведению, а плоское пространство из 27 имён это та точка, где обычно переходят на файл. ⚠ Вопрос ВЛАДЕЛЬЦА (08.08), и он же нашёл этим вопросом реальный дефект в этом паке: дефолт «$/глава» лежал в двух местах (config клал ноль, разрешал вызыватель) — исправлено, дефолт резолвится только в config. Ратифицировано 09.08 (оркестратор №15 по делегации владельца): «только окружение» ОСТАЁТСЯ; строка переформулирована в задачу — печать ЭФФЕКТИВНОЙ конфигурации при старте с редакцией секретов (значение каждой настройки и откуда оно взялось: дефолт · переменная · файл; *_FILE печатается фактом наличия, не содержимым). Остаётся открытой до постройки — закрыто: config.Config.Settings несёт КАЖДУЮ прочитанную переменную с источником (default · environment · file), LogEffective печатает их построчно на старте. Редакция двойная и обе — правила проекта, а не вкус: секрет (DSN несёт пароль) и ЛЮБАЯ денежная сумма (D39.84/PD-99) печатаются фактом наличия и источником, без значения. Пины: TestEverySettingThisServiceReadsIsPrinted (сверка со СПИСКОМ TM_PLATFORM_* из исходника config.go — переменная, добавленная без записи, роняет батарею в том же коммите), TestASecretIsNamedAndNeverPrinted, TestAConfiguredAmountIsNeverPrinted |
fixed(P5, дерево сессии) | вопрос владельца 08.08 + сессия P4 |
| PD-140 | bug | info | internal/runs/reconcile.go Stop, internal/httpapi/ |
Service.Stop построен и не подключён ни к чему: httpapi.Runs даёт только Bounds/Start, у tmplatformctl команды остановки нет. То есть контрол-плейн не умеет остановить прогон, который сам же запустил. Ручки POST /runs/{id}/stop и /resume в список работ промта не входили, поэтому это НЕ девиация пака, а честно названный хвост: остановить прогон сегодня можно только systemctl --user stop. Гейт: закрыть вместе с ручками стопа/резюма — закрыто: ручки POST /v0/runs/{runId}/stop и /resume построены по спеке (202 + Run, 404 чужому, 409 на невозможное действие), runs.Service.Stop записывает НАМЕРЕНИЕ стопа в Postgres ДО сигнала и зовёт systemd, Resume переиспользует механику перезапуска реконсилятора (reopen) вместо второй копии денежной арифметики. Пины: TestTheStopIsRecordedBeforeSystemdIsAsked (хук внутри фейка systemd читает БД и требует, чтобы намерение уже было), TestAStopTheUnitDidNotTakeIsAskedAgainByTheSweep, TestResumeContinuesTheRunWithWhatIsLeftOfItsBudget, TestARunOfAnotherAccountCannotBeStoppedOrResumed |
fixed(P5, дерево сессии) | адверсариальное ревью (чтение) |
| PD-178 | standards | info | internal/runner/systemd_test.go, Makefile |
Гейт systemd-тестов сверял ТЕКСТ сообщения, а не способность хоста, и перестал гейтить. systemdOrSkip скипал по подстроке «Failed to connect to bus», а systemd 259 отвечает «Failed to connect to user scope bus» — на хосте без пользовательского менеджера три теста ПАДАЛИ вместо скипа. ⚠ Первая редакция этой строки утверждала, что формы скипа для systemd в зоне нет вовсе — неверно: форма была и сломалась о чужую правку строки. ⚠ Вторая посылка тоже устарела: состояние пользовательского менеджера на стенде ПЛАВАЕТ между сессиями — в одной его нет (Linger=no, sudo нет), в следующей он жив (systemctl --user show отвечает Version=259.5) и все три теста проходят; замерено обоими способами в один день — закрыто: гейт спрашивает СПОСОБНОСТЬ (доходит ли процесс до своего менеджера), скип громкий и поимённый, как у БД-тестов; пин runner.TestTheSystemdGateAsksAboutTheCapabilityAndNotAMessage падает, если гейт снова начнёт сверять текст |
fixed(P5-дофикс, дерево сессии) | сессия P5 (батарея на стенде) |
| PD-181 | bug | minor, деньги | internal/pgstore/sink.go FinishRun, internal/runs/reconcile.go finishStopped |
Устаревший снапшот свипа мог до-финишировать уже РЕЗЮМИРОВАННЫЙ прогон и оставить холд новой попытки вне всех списков. Маркер завершённой попытки остаётся на диске (их никто не удаляет), а FinishRun гардил только finished_at is null — проход, держащий снапшот попытки 1, закрывал прогон, который стоп-резюм успел вернуть к жизни; попытка 2 с открытой резервацией не попадала ни в ListLiveRuns (там finished_at is null), ни в UnsettledRuns (там ended_at is not null). Достижимо стало ровно с появлением резюма, то есть этим паком — закрыто: FinishRun пишет только если закрываемая попытка ещё живая, и возвращает признак «закрыл», по которому вызыватель решает, считать ли деньги; пин runs.TestAStaleSweepDoesNotReFinishAResumedRunFromTheOldAttemptsMarker, посадка «снять гард» падает |
fixed(P5, дерево сессии) | кросс-семейное ревью P5 (Fable, исполнением) |
| PD-182 | bug | minor, деньги | internal/runs/reconcile.go finishStopped |
Стоп закрывал прогон под ЖИВЫМ движком, если заявка на спавн была отдана назад после того, как юнит уже создался. ReleaseSpawnClaim обнуляет unit_name, когда «юнит не удалось создать», а это не то же самое, что «не создался» — systemd-run, убитый после запроса, оставляет движок работать (зона это уже знает: ради этого случая сохраняется базовая линия). Путь стопа читал пустое имя как «процесса не было» и закрывал прогон; движок продолжал тратить, сигнала до него не доходило, следующий прогон книги впитывал его трату в свою базовую линию — закрыто: ненулевая spend_baseline при пустом имени юнита считается надгробием попытки спавна, имя юнита детерминировано, и платформа спрашивает systemd Alive прежде чем закрывать; живой юнит получает стоп. Пин runs.TestAStopDoesNotCloseARunWhoseGivenBackClaimLeftAnEngineRunning |
fixed(P5, дерево сессии) | кросс-семейное ревью P5 (Fable, исполнением) |
| PD-183 | bug | minor | internal/books/parse.go, internal/jobs/jobs.go |
Грация клейма разбора (10 мин) была КОРОЧЕ таймаута задания очереди (15 мин): свип воровал клейм у живого парса. В окне 10–15 минут на одной проектной директории оказывались два tmctl manifest; проигравший умирал на эксклюзивном локе движка с exit 1, а exit 1 — это то, чем движок говорит «источник не разобрать», и книга отклонялась ТЕРМИНАЛЬНО с удалением исходника — закрыто: грация написана как jobs.JobTimeout + 5m, пара утверждается тестом books.TestTheClaimGraceOutlivesTheQueuesJobTimeout; вдобавок терминальная запись возможна только пока клейм ещё наш (parse_started_at = <наш>), а один exit 1 больше не терминален вовсе |
fixed(P5, дерево сессии) | кросс-семейное ревью P5 (Fable, исполнением) |
| PD-184 | bug | info | internal/metrics/metrics.go |
Лейбл method брался из запроса как есть. Для маршрута он свёрнут в (unmatched), а метод — токен, который выбирает вызывающий, и на неразобранном запросе он его же и придумывает: число рядов становится чужим ресурсом — закрыто: закрытый список методов, всё прочее — (other); пин metrics.TestAnUnroutedRequestGetsOneSeriesAndNotOnePerPath |
fixed(P5, дерево сессии) | кросс-семейное ревью P5 (Fable) |
| PD-186 | bug | minor, деньги | internal/pgstore/sink.go FinishUnspawnedStop |
Закрытие «стопа до спавна» не имело гарда живой попытки — того самого, что получил FinishRun (PD-181). Устаревший проход мог закрыть уже РЕЗЮМИРОВАННЫЙ прогон, и холд второй попытки выпадал из обоих списков; вдобавок каждый следующий резюм отвечал 409 навсегда, потому что продолжать было бы уже завершённый прогон — закрыто: попытка обязана быть живой (ended_at is null) и принадлежать этому прогону; пин runs.TestAStaleUnspawnedStopDoesNotCloseAResumedRun, посадка «снять гард» падает |
fixed(P5-дофикс, дерево сессии) | приёмка P5 (FP5-2) |
| PD-187 | bug | minor | internal/books/parse.go reject, manifest |
Крэш между сносом каталога и записью строки оставлял книгу в parsing НАВСЕГДА. Порядок «каталог, потом строка» был выбран как самоизлечивающийся, и посылка была ложной: снесённый каталог читался как «нет конфигурации», а эта причина терминальной не становится никогда — книга ждала конфигурацию, которую некуда положить — закрыто: пропавший КАТАЛОГ отличён от отсутствующей конфигурации (ErrDirectoryGone) и терминален сразу; пин books.TestABookWhoseDirectoryIsGoneIsRejectedRatherThanLeftWaiting |
fixed(P5-дофикс, дерево сессии) | приёмка P5 (FP5-3) |
| PD-188 | bug | minor | internal/books/parse.go, internal/pgstore/books.go ClaimParse |
Ожидание конфигурации ЖГЛО бюджет разбора. ClaimParse считает каждую заявку, а книга без конфигурации заявляется раз в грацию бесконечно — после пяти циклов ожидания бюджет был исчерпан, и ПЕРВЫЙ же ответ движка становился терминальным мгновенно, с удалением исходника; движок отдаёт exit 1 и на опечатку в book.yaml — закрыто: попытка возвращается (RefundParseAttempt), когда движок не был спрошен вовсе; пин books.TestWaitingForAConfigurationDoesNotBringDeletionCloser |
fixed(P5-дофикс, дерево сессии) | приёмка P5 (FP5-4) |
| PD-189 | bug | minor | cmd/tmplatformd/runner.go, internal/books/parse.go Sweep |
Бэкстоп гнал разбор под бюджетом прохода (2 мин) против 15 минут очереди. Восстановление большой книги убивалось дедлайном свипа, убийство читалось как «хост не может запустить движок», попытка списывалась — и так каждый проход, пока книга не отклонялась ЗА СВОЙ РАЗМЕР; вдобавок запись отказа шла на просроченном контексте и терялась — закрыто: у прохода интейка свой бюджет (jobs.JobTimeout + 1m), у каждой книги внутри — свой (jobs.JobTimeout), терминальные записи идут на контексте, переживающем дедлайн; пин books.TestOneBookInTheSweepGetsABudgetAParseCanLiveIn ⚠ Дополнено ре-чеком (хвост в): бюджет КНИГИ не равен бюджету ПРОХОДА — вторая книга прохода получала остаток от первой и жгла попытку на обрезанном дедлайне. Проход, которому осталось меньше jobs.JobTimeout, книгу больше не НАЧИНАЕТ (клейм берётся внутри Parse, поэтому отложенная книга не тратит ничего). Пин books.TestAPassTooShortForAParseStartsNoneAtAll |
fixed(третий раунд, дерево сессии) | приёмка P5 (FP5-5) |
| PD-190 | bug | info | internal/httpapi/v0.go uploadFailed |
Просроченный дедлайн загрузки уходил 500. Медленный клиент — не сломанный сервис, и 500 говорит клиенту обратное о том, помогает ли повтор — закрыто: 408 problem+json; вопрос о коде вне перечня спеки внесён в пакет PD-180; пин httpapi.TestAnUploadThatOutlivesItsDeadlineIsNotAnInternalError |
fixed(P5-дофикс, дерево сессии) | приёмка P5 (FP5-6) |
| PD-191 | bug | minor | internal/pgstore/runs.go PauseRun |
Стоп, пришедший в окно расчёта, отвечал paused/credit_exhausted вместо stopped — то есть «кончились деньги» вместо «владелец остановил» — закрыто: пауза отказывает при висящем интенте (ErrStopRequested), реконсилятор заканчивает прогон стопом; пин runs.TestAStopDuringSettlementOutranksThePause |
fixed(P5-дофикс, дерево сессии) | приёмка P5 (FP5-8а) |
Закрытые — эра P6 (потребительская половина шва эмиттера · интейк формы Б · дев-стенд)
| ID | Класс | Серьёзность | Где | Суть | Статус | Источник |
|---|---|---|---|---|---|---|
| PD-200 | bug | minor | internal/ingest/tail.go, internal/runs/spawn.go |
Тейлер УСЫНОВЛЯЛ чужой поток, лежащий на смещении попытки — и это было три дефекта в одной строке, а не наблюдение. Воспроизведено: журнал книги, в котором до нашего hello лежит чужой (tmctl, запущенный оператором руками в каталоге книги), давал (1) adoption чужого engine_run_id через Begin, (2) материализацию его событий на НАШУ попытку — включая ceiling, то есть paused у прогона, которого никто не паузил, и (3) отказ на нашем СОБСТВЕННОМ hello («seq 3, want 1») с карантином проекции платящего прогона навсегда. Закрыто: платформа НАЗЫВАЕТ поток до создания юнита (runs.engineStreamID, отдаётся движку как TM_TRACE_ID, строка 102) и пишет имя в той же транзакции, что claim; mine теперь спрашивает про ПРИМЕНЁННЫЕ строки (LastSeq > 0), а не про байтовый хинт, который у новой попытки ненулевой до первого чтения. Пины: ingest.TestAForeignStreamAtOurOffsetIsNeverAdopted · пин окружения юнита в runs. Живая проба: engine_run_id = tm-stream-run_…-1 в БД стенда до старта движка |
fixed(P6, дерево сессии) | watch-пункт промта P6, воспроизведён |
| PD-60 | bug | minor | internal/ingest/supervisor.go, шов |
⚠ ПЕРЕ-ДИСПОЗИЦИЯ (эррата №15, 07.08): постановка строки устарела. Она рассуждает про 64 КиБ пайпа, а ратифицированный транспорт (D39.106 п.2) — тейл events.jsonl с курсором: у файла обратного давления в этом смысле нет вовсе, зато появляются свои свойства (fsync-политика, ротация, отставание тейлера, поведение при заполненном диске). Строка живёт, но переформулируется вместе со строкой 103 — не «блокировать движок или ронять события», а «что делает движок, когда журнал не пишется». Обратное давление не спроектировано, и канал тут ни при чём. Пайп держит 64 КиБ; если синк платформы встанет на Postgres, движок заблокируется в write(2) — на часы, без контекста и дедлайна, прервать нечем. Сегодня не проявляется только потому, что материализатора ещё нет: Ingest кормит Sink синхронно, и латентность БД станет латентностью движка. Нужна ограниченная очередь у читателя и ЯВНАЯ политика на её переполнение: блокировать движок (корректно, но прогресс прогона привязан к доступности БД) или ронять события с маркером events_dropped (быстро, но журнал начинает врать). Выбрать и записать — обе позиции законны, молчаливой третьей нет ⚠ ЗАКРЫТИЕ ПО ФАКТУ КОДА (P6): политика выбрана ЯВНО и залендена движком (D39.131) — эмиттер ДЕГРАДИРУЕТ ГРОМКО и прогон продолжается: emit не возвращает ошибку никогда, первый отказ пишется ERROR один раз на серию, строки остаются в outbox и повторяются целым префиксом, так что транзиентный ENOSPC/EIO самолечится, а fsync на событие сознательно не делается (журнал — проекция коммитов SQLite при synchronous=NORMAL, проекция не может быть durable сильнее источника). Блокировать прогон отвергнуто явно. У платформы обратного давления в этом смысле нет по построению: она ТЕЙЛИТ файл, а не кормится пайпом; её собственная деградация — карантин проекции с ре-синком (PD-105). Открытого остатка у строки нет |
fixed(P6, дерево сессии) | приёмка P1 (абстрактный разбор двумя агентами, оба независимо) |
| PD-61 | bug | info | шов, строка 103 | Два свойства эмиттера, которые надо задать ДО его постройки, иначе они станут миграцией. (а) Сброс буфера на выходе: bufio.Writer вокруг потока плюс os.Exit/log.Fatal пропускает defer и теряет последние события — ровно те, что сообщают об окончании прогона. (б) Хвост при падении платформы: содержимое непрочитанного пайпа умирает вместе с читателем. Если требование «платформа перезапустилась, прогон продолжается» когда-нибудь появится, ответ — НЕ сокет (он даёт переподключение без возобновления), а журнал файлом: движок дописывает NDJSON в <jobdir>/events.ndjson, платформа тейлит его с чекпойнтом смещения в Postgres. Это переживает и падение платформы, и даёт реплей бесплатно. Оба агента пришли к этому независимо; прецедент — Bazel Build Event Protocol (файл или gRPC, не пайп родителя) ⚠ ЗАКРЫТИЕ ПО ФАКТУ КОДА (P6): оба свойства заданы до постройки и построены. (а) Буфера НЕТ ВОВСЕ — runevents.Journal пишет строку одним write(2) без bufio, поэтому os.Exit/log.Fatal/паника не могут потерять ни finished, ни ceiling: терять нечего. (б) Ответ — журнал файлом с курсором (engine_run_id, seq), ровно как строка и предлагала; ратифицирован D39.106 §2 и построен обеими сторонами. Открытого остатка нет |
fixed(P6, дерево сессии) | приёмка P1 (абстрактный разбор) |
| PD-113 | bug | major (контрактно видимый) | internal/runs/reconcile.go outcome, движок stagerun.go |
Стоп по потолку сегодня НЕразличим от инфраструктурного отказа, и контракт при этом запрещает называть его failed. Движок возвращает потолок ошибкой (errReserveCeiling, сверено в HEAD), а exitCode мапит всё нераспознанное в 1 — значит по коду выхода «деньги кончились» и «упало» это одно и то же число; события потолка не существует (строка 103). Платформа честно ставит failed, хотя BookStatus требует paused и «никогда не failed», потому что стоп резюмируем. Единственный путь, которым платформа СЕГОДНЯ узнаёт о потолке, — событие потока, которого нет; ветка под него построена и запинена (TestACeilingHaltPausesTheRunWithItsReason, TestWhatTheUnitDidBecomesTheProductStatus, случай «a ceiling halt survives any exit»). Закрывается приходом эмиттера (строка 103); ⚠ до тех пор экран покажет «ошибка» там, где верно «остановлено: лимиты» — ЗАКРЫТО (P6, потребительская половина шва): потолочный стоп приезжает paused ДВУМЯ независимыми каналами и ни по одному не failed — событием ceiling потока и кодом выхода 4, потому что журнал, который не удалось записать, всё равно оставляет код. Причина пишется вместе со статусом (pgstore.RunEnding.PausedReason), а не вторым оператором, и читается ПОСЛЕ дренажа журнала, а не из снапшота свипа. Пины: runs.TestWhatTheUnitDidBecomesTheProductStatus (таблица с exit 4 и обеими причинами) · runs.TestACeilingHaltWithNoEventIsPausedWithItsReasonAndNotRestarted (сквозь свип: paused, причина, НЕ перезапущен, холд закрыт) · runs.TestACeilingEventArrivingInThisVerySweepStillDecidesTheEnding (стейл-снапшот). Живая проба на стенде с фейком-движком, имеющим правило потолка: ceiling{scope:book} + exit 4 → paused/credit_exhausted, прогресс 4/6 из потока, расчёт $0.08 из $5.10 |
fixed(P6, дерево сессии) | сессия P4 (сверка контракта с кодом движка) |
| PD-141 | bug | info | internal/pgstore/runs.go PauseRun |
PauseRun возвращает nil, когда строка прогона уже завершена, поэтому вызывающий рапортует паузу, которой не произошло. Идемпотентность здесь нужна (свип повторяется), но молчаливая — нет: различить «поставил паузу» и «было уже поздно» вызывающий не может — закрыто (P6): PauseRun возвращает paused bool, вызывающий логирует «run paused» только по нему, а отказ называет вслух. Пин — тест стейл-паузы в pgstore утверждает именно paused == false |
fixed(P6, дерево сессии) | адверсариальное ревью (чтение) |
| PD-152 | bug | info | internal/runs/reconcile.go outcome |
stopped для остановки, которую мы попросили, на реальном движке недостижим: tmctl ЛОВИТ SIGTERM и выходит кодом 1, поэтому ветка «не вышел сам + $SERVICE_RESULT=success» срабатывает только для процесса, умершего ОТ сигнала. Проба пака показала stopped на фейке, который именно так и умирал. Следствие: пользовательский стоп приедет как failed. ⚠ Дофикс 09.08 расширил строку: то же самое ломает ШТАТНУЮ ПЕРЕЗАГРУЗКУ. При ребуте пользовательский менеджер останавливает юниты корректно, ExecStopPost ОТРАБАТЫВАЕТ и маркер пишется — ревьюер снял живьём на этом хосте (транзиентный юнит, процесс ловит TERM и выходит 1): RESULT=exit-code CODE=exited STATUS=1. То есть на буте свип видит маркер и закрывает все живые прогоны как failed вместо перезапуска, а путь строки 138 покрывает только потерю питания (нет маркера) — и собственный тест TestARunInterruptedByARebootComesBackWithTheBudgetItHasLeft моделирует именно её. Закрывается в паке ручек стопа — попытка помечается «stop requested» до сигнала — и приходом различимого кода выхода graceful-stop у движка (строка бэклога движку, заводит оркестратор); платформенной догадки здесь быть не должно ⚠ Половина закрыта паком P5 (D39.128-ветка), половина осталась и это названо: ПОЛЬЗОВАТЕЛЬСКИЙ стоп больше не приезжает как failed — платформа пишет намерение стопа (runs.stop_requested_at, миграция 00014) ДО сигнала и классифицирует маркер по нему (outcome/stoppedOnRequest), с гардом гонки «стоп против самостоятельного финиша» по времени маркера и чистым исходом движка (0/2/3) поверх намерения; пины TestARunTheUserStoppedIsNotReportedAsFailed, TestARunThatEndedBeforeTheStopKeepsItsOwnOutcome, TestACleanExitOutranksAStopThatArrivedTooLate. ШТАТНАЯ ПЕРЕЗАГРУЗКА хоста по-прежнему закрывает живые прогоны как failed — намерения там нет ни у кого, и платформенной догадки здесь быть не должно: остаётся за различимым кодом выхода graceful-stop у движка (строка 165 единого бэклога) ⚠ ОСТАТОК ЗАКРЫТ (P6): различимый код пришёл (exit 5 = пойманный сигнал, D39.131), и платформа читает его так: с ЗАПИСАННЫМ намерением — пользовательский стоп, без него — ПРЕРЫВАНИЕ, то есть путь перезапуска строки 138, а не похороны. Решение названо асимметрией: прочитать ребут как стоп пользователя значит оставить все прогоны хоста мёртвыми после штатной перезагрузки, прочитать ручной systemctl stop как прерывание стоит одного перезапуска. Пины: runs.TestAGracefulSignalIsAStopOnlyWhenThisPlatformAskedForOne · TestAGracefulSignalNobodyAskedForBringsTheRunBack · TestAGracefulSignalWeAskedForEndsTheRun. Живая проба на стенде: systemctl --user stop → попытка 1 interrupted, попытка 2 открыта; наш /stop → stopped, второй попытки нет |
fixed(P6, дерево сессии) | приёмка P4 (N1) |
| PD-163 | bug | info | internal/pgstore/books.go ListBooks, GetBook |
Ревизия области читается ВТОРЫМ запросом после страницы, поэтому под конкурентной материализацией она новее строк: клиент, соблюдающий контрактное «отбрасывай чтение с меньшей ревизией», навсегда потеряет кадры между двумя запросами. Потребителя (SSE) сегодня нет; закрывать до первого потока — читать страницу и ревизию одной транзакцией — закрыто (P6): страница и ревизия области читаются ОДНОЙ транзакцией (ListBooks → listBooksTx), и то же сделано карточке книги (GetBook + lastRunTx), где ревизия книги читалась до строк прогона |
fixed(P6, дерево сессии) | самопроверка дофикса (ревью вне карты) |
| PD-164 | bug | info | internal/runner/marker.go, internal/runs/reconcile.go |
Маркер, который существует, но не разбирается, вечно валит реконсиляцию своего прогона: аналога карантина у этого пути нет, и ошибка чтения маркера возвращается наверх на каждом свипе. Запись атомарна (temp+fsync+rename), так что нужен внешний фактор (правка оператором, битый том). Закрывать — так же, как журнал: неисправимая ошибка маркера должна вести к терминальному состоянию с причиной, а не к вечному повтору — закрыто (P6): неразбираемый маркер отделён от нечитаемого — runner.ErrBadMarker против ошибки ввода-вывода — и ведёт к терминальному концу прогона с exit_result = marker-unreadable (значение, которого systemd не производит), а не к вечному повтору. Ошибка ЧТЕНИЯ по-прежнему возвращается наверх и повторяется, как и должна. Пин — runs.TestAnUnreadableExitMarkerEndsTheRunInsteadOfWedgingIt (плюс «холд закрыт») |
fixed(P6, дерево сессии) | самопроверка дофикса (ревью вне карты) |
| PD-165 | hardening | info | cmd/tmplatformd/runner.go markerArgv, internal/runner/runner.go quoteArgv |
Относительный TM_PLATFORM_CTL_BIN проходит os.Stat, но systemd требует АБСОЛЮТНЫЙ путь в ExecStopPost — каждый старт падает, и виден только цикл заявка-откат. Тот же класс: quoteArgv не экранирует $ (подстановка переменных systemd в Exec-строках), поэтому каталог состояния с таким символом молча ломает командную строку маркера. ⚠ % проверен и БЕЗОПАСЕН — спецификаторы в значениях --property= не раскрываются (замер §17); $ в этой сессии исполнением не проверялся. Лечится тем же filepath.IsAbs, что и StateDir (PD-149), плюс отказ на подозрительных символах — закрыто (P6), причём двумя разными ответами: относительный TM_PLATFORM_CTL_BIN теперь отвергается на буте (markerArgv, пин TestARelativeExitMarkerCommandIsRefusedAtBoot) — воспроизведено исполнением, systemd 259 отвечает «neither a valid executable name nor an absolute path» и не стартует юнит целиком. А $ ЗАМЕРЕН и оказался безопасен: через systemd-run --user --property=ExecStopPost=… путь с $dir доехал ЛИТЕРАЛЬНО, и доехал даже при --setenv=dir=EXPANDED (маркер лёг в …/dollar$dir/, не в …/dollarEXPANDED/) — то есть экранирование в $$ было бы ошибкой, а не защитой. Тот же результат, что у % (§17) |
fixed(P6, дерево сессии) | самопроверка дофикса (ревью вне карты) |
| PD-196 | bug | minor | internal/books/parse.go defer_, движок |
Опечатка оператора в рукописном book.yaml стоит файла пользователя. Движок отображает ВСЕ свои отказы на exit 1 (его собственный комментарий), поэтому «этот источник нечитаем» неотличимо от «конфиг синтаксически битый»: после пяти циклов книга отклоняется как source_unreadable и исходник удаляется. FP5-4 закрыл только «конфигурации нет вовсе». Настоящее лечение — различающий код выхода или --dry-run на стороне ДВИЖКА, правкой платформы не закрывается; до тех пор цена названа вслух, а не спрятана в комментарий — ЗАКРЫТО (P6, потребительская половина): движковая половина приехала полосой отказов (D39.131), и интейк судит по КОДУ, а не по типу ошибки: source_unreadable — ТОЛЬКО exit 11, config_invalid (10) — деплойный класс, который ждёт человека и не тратит бюджет попыток, всё прочее (12 · 19 · обычный exit 1 · сигнал · движок не запустился) — parser_unavailable, который отклоняет книгу, но НЕ удаляет её файл. Пины: books.TestOnlyTheOneRefusalClassAboutTheTextEverCostsTheUpload (четыре класса × «файл на месте») · TestAnEngineThatDidNotAnswerIsNeverAVerdictAboutTheBook · ingest.TestOutcomeOfCoversTheWholeExitContract. Живая проба: настоящий tmctl без ключей провайдера вышел 10 на стенде — прогон failed, холд вернулся целиком, файл цел |
fixed(P6, дерево сессии) | кросс-семейное ревью дофикса (Fable 5) |
| PD-206 | vuln | minor (условная) | internal/config/config.go Load, internal/login/dev.go |
Имя провайдера дев-входа не было зарезервировано, и TM_PLATFORM_OIDC_PROVIDER — свободный ввод оператора. Ключ личности — (provider, subject); издатель, настроенный под именем dev, кладёт свои sub в то же пространство, куда пишет дев-стенд, и sub, совпавший с дев-субъектом, разрешается в ДЕВ-аккаунт с его засеянным кредитом. Класс PD-32/§10a (тихое связывание чужих аккаунтов), но здесь — одна переменная окружения. Четвёртое из четырёх заявленных свойств недостижимости дев-входа в проде было без этого ЛОЖНЫМ. Закрыто: Load отвергает TM_PLATFORM_OIDC_PROVIDER == login.DevProvider; заодно TM_PLATFORM_DEV_LOGIN тримится, иначе значение из пробелов монтировало вход с личностью «пробел». Пины — config.TestAnIssuerCannotBeConfiguredUnderTheDevelopmentProvidersName и TestAWhitespaceDevelopmentSubjectMountsNothing, обе посадки падают. Плюс второй, ИЗБЫТОЧНЫЙ гард в main.go (ветка дев-входа стоит первой в switch, и без него регресс одной строки конфига дал бы дев-входу перекрыть боевой OIDC) — он по построению недостижим, пока держится первый, поэтому теста не имеет, и это названо |
fixed(P6, дерево сессии) | адверсариальное ревью P6 (кросс-семейное, Fable) |
| PD-207 | bug | minor, деньги | internal/pgstore/runs.go RestartRun, internal/runs/reconcile.go restart |
Устаревший снапшот свипа ВОСКРЕШАЛ прогон, который другое поколение уже закрыло. RestartRun отказывал только по намерению стопа на ЖИВОМ прогоне (stopRequested != nil && finished == nil), а на прогоне уже ЗАВЕРШЁННОМ проходил: чистил finished_at, брал новый холд и спавнил движок для прогона, владельцу которого уже сказали, что тот кончился. Воспроизведено: пользователь жмёт стоп, поколение A закрывает прогон как stopped, поколение B со снапшотом ДО стопа видит маркер exit 5 без намерения и перезапускает. Класс PD-181, и эта сессия его РАСШИРИЛА, добавив ветку «exit 5 без намерения = перезапуск». Закрыто: RestartInput.OnlyIfLive — реконсилятор просит гарантию «прогон ещё жив», резюм (который работает по определению с завершённым) не просит. Пин — runs.TestAStalePassDoesNotResurrectARunAnotherPassHasFinished, посадка падает с «stopped → translating» |
fixed(P6, дерево сессии) | самоаудит лендинг-диффа P6 |
| PD-208 | bug | info | cmd/tmplatformctl/seed.go |
Сид рапортовал «кредит начислен» о начислении, которого не было. Он звал store.Grant напрямую и ВЫБРАСЫВАЛ возвращаемый applied, а ключ идемпотентности детерминирован по аккаунту — значит второй прогон seed на том же стенде печатал «granted», начислив ноль. Тот же класс, ради которого в этом же пакете уже был написан хелпер write (его собственный комментарий: «Applied» и «ключ уже потрачен» — разные исходы, и оператору говорят, какой он получил), плюс вторая половина: неудачное ЧТЕНИЕ баланса после закоммиченной записи роняло весь сид, тогда как write печатает предупреждение и продолжает. Закрыто: сид зовёт write; проверено исполнением на стенде — второй прогон печатает «no-op: key … was already used» |
fixed(P6, дерево сессии) | адверсариальное ревью P6 (линза повторного использования) |
| PD-209 | bug | minor, деньги/статус | internal/runs/reconcile.go outcome, reconcile, restart; internal/pgstore/books.go |
Причина daily_ceiling стиралась ДВУМЯ путями, и оба возвращали ложный «кредит кончился». (A) exit 4 БЕЗ материализованного события закрывался как credit_exhausted — а это не редкий угол: карантинная попытка не дренится вовсе, значит КАЖДЫЙ её потолочный стоп шёл этим путём. (B) прогон, чей поток уже сказал ceiling (синк ставит paused без finished_at, то есть прогон остаётся живым), при исчезнувшем без маркера юните уходил в restart, а его ветка исчерпания жёстко зашивала credit_exhausted поверх. Последствия обе: ReadUsage зажигает АККАУНТНЫЙ halted-флаг на аккаунте с деньгами, и гард резюма дневного потолка обходится — резюм разрешён, новая попытка мгновенно упирается в тот же дневной лимит, цикл повторяем пользователем. Закрыто: третье значение ceiling_unknown (миграция 00015) для потолка, чей scope платформа не установила — резюмируемо, но НЕ зажигает аккаунтный флаг; прогон с уже названной причиной закрывается как пауза, а не перезапускается. Пины: runs.TestACeilingHaltWithNoEventIsPausedWithItsReasonAndNotRestarted · TestACeilingThatTheStreamReportedSurvivesAUnitThatVanished · таблица TestWhatTheUnitDidBecomesTheProductStatus; три посадки падают, четвёртая (фолбэк причины в ветке исчерпания) ПЕРЕЖИВАЕТ по построению и это названо в коде |
fixed(P6, дерево сессии) | адверсариальное ревью P6 (линза денег и гонок) |
| PD-210 | bug | minor, шов | internal/pgstore/runs.go StartRun/RestartRun/RecordSpawn, internal/pgstore/sink.go Begin/effect |
Окно усыновления чужого потока оставалось между ДОПУСКОМ и спавном. Имя потока писалось при RecordSpawn, а допущенная попытка уже попадает в ListLiveRuns и уже дренится — с want == "", то есть с усыновлением первого встречного hello. Окно — секунды внутри tmctl status или целый интервал свипа; coalesce(engine_run_id, …) в RecordSpawn при этом ОТКАЗЫВАЛСЯ исправить усыновлённое чужое имя на собственное, и прогон слеп на всю жизнь, а чужой ceiling материализовался на него (с новым outcome это перебивает даже чистый exit 0). Закрыто: имя даётся в той же вставке, что создаёт строку попытки (pgstore.EngineStreamID — формат переехал туда, где известен id прогона), RecordSpawn присваивает, а не сохраняет чужое. Побочное следствие, найденное тем же ревью: Begin стал недостижим для новых попыток, и вместе с ним тихо выключилось обновление chunker_version — эффект перенесён в effect, куда hello приезжает обычным событием. Пины: runs.TestAnAttemptIsNamedBeforeAnythingCanBeSpawnedForIt · pgstore.TestTheChunkerVersionOfTheStreamReachesTheBook; обе посадки падают |
fixed(P6, дерево сессии) | адверсариальное ревью P6 (линза денег и гонок) |
| PD-211 | bug | info | internal/pgstore/sink.go unitDone |
Апсерт разрешённого юнита был «побеждает последний ПРИШЕДШИЙ», а не последний по времени. Именованный остаток эмиттера — строка, закоммиченная в outbox и не дошедшая до файла, — переанонсируется СЛЕДУЮЩИМ процессом: приезжает со своим seq (то есть проходит high-water mark) и несёт время СТАРОГО события. Юнит, передреденный внутри предыдущей попытки из flagged в shipped, регрессировал обратно. Счётчики не страдают (они считают строки) — страдает ровно та диспозиция, из которой читающая поверхность выводит состояние юнита. Закрыто: where excluded.at >= unit_resolutions.at; пин pgstore.TestAReAnnouncedOlderResolutionDoesNotOverwriteANewerOne, посадка падает |
fixed(P6, дерево сессии) | адверсариальное ревью P6 (линза денег и гонок) |
Закрытые — дофикс P6 (приёмка оркестратора №16, 15.08)
| ID | Класс | Серьёзность | Где | Суть | Статус | Источник |
|---|---|---|---|---|---|---|
| PD-224 | bug | major (деньги, несущий путь) | internal/runner/engine.go Status, internal/ingest/supervisor.go Status |
status --json с exit 2 читался как ОТКАЗ, а движок так отвечает про любую книгу с помеченной единицей — отчёт при этом уже напечатан (cmd/tmctl/render.go renderStatusJSON). Следствия на ОБЫЧНОМ пути: расчёт денег такого прогона откладывался вечно (холд висел — класс PD-162), bookMeter отказывал каждому следующему прогону книги, ре-синк умирал; дев-путь имел тот же дефект — закрыто: exit 2 при разбираемом отчёте = ответ, exit 2 с нечитаемым stdout остаётся отказом, знание живёт одним местом (ingest.CompletedWithFlags). Перечень команд, способных выйти 2, снят чтением движка: translate, status, redrive; manifest — нет. Пины runner.TestAFlaggedBookStillAnswersAboutItsMoney и сквозной runs.TestTheMoneyOfAFlaggedBookIsSettledThroughTheRealExitContract (настоящий процесс, деньги сверены балансом и суммой леджера) |
fixed(дофикс P6, дерево сессии) | приёмка P6 (ФП-1) |
| PD-225 | doc | minor | deploy/README.md |
Порядок апгрейда движка гонял migrate СТАРЫМ бинарём: шаг миграции стоял ДО установки нового, то есть был no-op, и деадлок v15 оставался; там же «ДВА факта» вместо трёх — закрыто: новый бинарь на версионированный путь ДО миграции, migrate его явным путём, TM_PLATFORM_ENGINE_BIN переключается после; окно между списком и миграцией закрыто остановкой демона на время апгрейда; три факта названы тремя |
fixed(дофикс P6, дерево сессии) | приёмка P6 (ФП-2) |
| PD-226 | standards | minor | internal/pgstore/books.go BooksForMigration |
Денежный гейт миграции книг не был запинен: посадка «убрать блокер незакрытого холда» пережила ВСЮ батарею — закрыто: таблица по трём блокерам, каждый по отдельности переводит вердикт книги в «нельзя», и список, который читает цикл деплой-заметки, содержит только свободную книгу; все три посадки падают. Пин pgstore.TestEachOfTheThreeBlockersAloneKeepsABookOutOfTheMigrationList |
fixed(дофикс P6, дерево сессии) | приёмка P6 (ФП-3) |
| PD-227 | bug | minor | internal/runs/reconcile.go reconcile |
Ветка нечитаемого маркера хоронила потолочную паузу как failed с пустой причиной: единственная из веток конца попытки, которая не перечитывала причину после дрейна, — а маркер, который не разбирается, не несёт и кода выхода, так что поток остаётся единственным свидетелем (воспроизведено приёмкой на живом PG) — закрыто: обе маркерные ветки слиты, перечит один и общий. Пин runs.TestACeilingSurvivesAMarkerThatCannotBeRead |
fixed(дофикс P6, дерево сессии) | приёмка P6 (ФП-4) |
| PD-228 | vuln | minor (условная) | internal/config/config.go Load |
TM_PLATFORM_INSECURE_COOKIES не отвергался рядом с боевым OIDC: гейт ловил только связку с DEV_LOGIN, а дев-рецепт экспортирует обе переменные, так что «стендовое окружение уехало в прод» ловилось наполовину — демон стартовал с боевым Google-входом, куками без Secure и без __Host-, с выключенным HSTS (воспроизведено приёмкой живым демоном) — закрыто: судит не догадка, а собственный адрес деплоя: callback OIDC и есть публичный URL платформы, поэтому http там = стенд (законно, работает), всё прочее — отказ на буте. Пин config.TestCookiesWithoutSecureAreRefusedNextToAProductionProvider |
fixed(дофикс P6, дерево сессии) | приёмка P6 (ФП-5) |
| PD-229 | bug | minor | internal/pgstore/books.go CeilingPause |
Незнакомый scope потолка читался как credit_exhausted и зажигал аккаунтный halted-флаг (ReadUsage ключится ровно на это значение) на аккаунте, у которого деньги есть, — при том что этот же пак завёл ceiling_unknown ровно для «потолок был, чей — не установлено» — закрыто: book и day называются, всё остальное = ceiling_unknown; резюмируемость не меняется ни при одном из трёх, а ложного «кредит кончился» больше нет. Пин — таблица pgstore.TestTheCeilingScopeDecidesWhichPauseTheRunGets |
fixed(дофикс P6, дерево сессии) | приёмка P6 (ФП-6) |
| PD-230 | bug | info | cmd/tmplatformctl/seed.go |
Сид не тримил --subject, а демон тримит: падённое значение одной переменной окружения давало вход под одной личностью и поиск другой — аккаунт создавался, одноразовый грант сгорал, сид обрывался (воспроизведено приёмкой) — закрыто: одна нормализация с обеих сторон. Пин TestASubjectThatIsOnlyPaddingIsNoSubject |
fixed(дофикс P6, дерево сессии) | приёмка P6 (ФП-6) |
| PD-231 | doc | info | internal/pgstore/sink.go unitDone |
Гард at >= обосновывался несуществующим поведением движка: «переанонс несёт СТАРОЕ время события» — по коду движка неверно, at штампует анонсирующий процесс, а непроецированная строка мёртвого прогона стирается ForgetEvents и анонсируется заново со своим временем — закрыто: гард остаётся (потребитель at-least-once обязан быть независимым от порядка доставки), довод приведён к факту здесь и в пинящем тесте |
fixed(дофикс P6, дерево сессии) | приёмка P6 (ФП-6) |
| PD-232 | doc | info | internal/runs/reconcile.go Resume |
Комментарий про book-ceiling утверждал «remaining ноль и резюм ничего не меняет» — по арифметике остаток положителен, потому что движок останавливается на невместившемся резервировании, а не на самом потолке — закрыто: комментарий приведён к факту, сам чурн заведён строкой PD-223 | fixed(дофикс P6, дерево сессии) | приёмка P6 (ФП-6) |
| PD-233 | bug | minor | internal/runs/reconcile.go outcome |
Exit 5 со стопом, записанным ПОЗЖЕ маркера, закрывался failed: намерение снимало «прерывание», а сравнение времён отвергало «стоп» — при том что exit 5 недостижим для прогона, кончившегося сам, и два сравниваемых штампа приходят с разных часов (маркер пишет хост юнита, намерение — платформа) — закрыто: exit 5 при записанном намерении = stopped независимо от порядка; чистые концы 0/2/3 отвечены раньше в том же switch и не сдвинулись, что пинится отдельно |
fixed(дофикс P6, дерево сессии) | приёмка P6 (ФП-6) |
| PD-234 | doc | info | docs/platform-PROGRESS.md |
Базис «396 → 403» в шапке P6 не воспроизводился (в HEAD 359 ^func Test, откуда 396 — неизвестно) — закрыто: числа пересчитаны, а рядом со строкой написана команда счёта, чтобы следующая сессия сверяла, а не переписывала |
fixed(дофикс P6, дерево сессии) | приёмка P6 (ФП-8) |
| PD-235 | standards | info | internal/config/config_test.go |
Подтест-пассажир в пине нового гейта: строка «callback пуст» оставалась зелёной при УДАЛЁННОМ гейте — её отвергает проверка неполного OIDC этажом выше, то есть про сам гейт она не говорила ничего. Класс тот же, что «слепой пин» из P6: покрытие, которого нет — закрыто: строка убрана из цикла (неполный OIDC пинится своим тестом), у гейта осталось два настоящих свидетеля, оба падают на посадке | fixed(дофикс P6, дерево сессии) | кросс-семейное ревью дофикса P6 (security-линза) |
| PD-236 | bug | minor, контрактно видимый | internal/runs/reconcile.go freshPausedReason |
Одна неудачная перечитка причины хоронила потолочную паузу как failed без причины. Перечит падал обратно на снапшот — а он пуст ровно в том случае, ради которого перечит и существует (событие приехало в ЭТОТ дрейн), — и вызывающие закрывали прогон на этой пустоте. На ветке испорченного маркера кода выхода нет, а сама ветка закрывает прогон по построению (PD-164), значит следующего прохода не будет: обычный перезапуск Postgres превращал резюмируемый стоп в failed. Тот же глоток заново вооружал перезапуск, который ветка «поток сказал ceiling» построена предотвращать — закрыто: ошибка чтения возвращается наверх, ничего не решается на неустановленном факте. Пин runs.TestAnEndingIsNeverDecidedFromAReadThatFailed |
fixed(дофикс P6, дерево сессии) | кросс-семейное ревью дофикса P6 (линза автомата состояний) |
| PD-237 | bug | minor, деньги | internal/runner/engine.go Status |
Exit 2 принимался по коду и разбору, без согласия самого отчёта. Движок отдаёт этот код из status --json при одном условии — flagged > 0, — и оно не проверялось: измерено ревью, что отчёт с flagged:0, отчёт про ЧУЖУЮ книгу и документ без book_id при exit 2 РАССЧИТЫВАЛИСЬ (2.5 USD против холда) вместо отложенного расчёта. Достижимо не движком, а тем, что стоит на запиненном пути и им не является: обёртка, подменённый на месте каталог версии, недокатанный деплой — закрыто: три условия вместе (код, разбор, согласие отчёта). Пин runner.TestExitTwoIsTakenOnlyFromAReportThatAgreesWithIt |
fixed(дофикс P6, дерево сессии) | кросс-семейное ревью дофикса P6 (денежная линза) |
| PD-238 | bug | minor | internal/ingest/manifest.go DecodeManifest |
Документ, который не манифест, приезжал ПУСТЫМ манифестом — а пустой манифест удаляет загрузку пользователя. {}, null и любой объект незнакомых полей декодировались в нули, интейк читал нулевые главы как «движок разрезал источник и книги в нём нет» и сносил каталог. Версия намеренно не гейтится по ЗНАЧЕНИЮ (иначе всякий релиз движка — релиз платформы), и в этом зазоре единственным сторожем деструктивного пути было имя JSON-поля — закрыто: требуется НАЛИЧИЕ manifest_version (не значение), а интейк отдельно различает «глав нет» и «глав нет, но юниты есть» — второе не вина книги. Пины ingest.TestADocumentThatIsNotAManifestIsNotAnEmptyManifest, books.TestAManifestThatContradictsItselfNeverCostsTheUpload |
fixed(дофикс P6, дерево сессии) | кросс-семейное ревью дофикса P6 (линза шва) |
| PD-239 | hardening | info | internal/runner/engine.go Manifest |
Интейк держался на непроверяемом инварианте чужой зоны: «manifest не может выйти 2» — верно сегодня (сентинел строится в четырёх местах render.go, ни одно не манифестное), но в движке этого не пинит ничто, а цена ошибки — вся книжная очередь хоста, потратившая бюджет попыток на документ, который движок уже напечатал — закрыто: правило читается, а не предполагается: exit 2 = команда отработала, и на интейковом канале тоже. Пин runner.TestAManifestThatCompletesWithFlagsIsStillAManifest |
fixed(дофикс P6, дерево сессии) | кросс-семейное ревью дофикса P6 (линза шва) |
| PD-240 | hardening | info | internal/runner/engine.go Status, drain |
Канал расчёта читал stdout без потолка, тогда как соседний Manifest кап имеет (64 МиБ) — при том что Status зовётся на каждом завершившемся прогоне. Найдено ДВУМЯ линзами независимо. Написание пина вскрыло вторую половину: дренаж, который «нельзя не делать» (иначе EPIPE), на бесконечном писателе не возвращается никогда, то есть кап был во власти того, что ограничивает — закрыто: кап 16 МиБ (замерено: настоящая книга в 2283 главы даёт 1.1 МБ), за капом процесс убивается, а не дренится; обе команды ходят через один drain. Пин runner.TestAnEndlessStatusIsRefusedRatherThanRead (посадка виснет и падает по таймауту) |
fixed(дофикс P6, дерево сессии) | кросс-семейное ревью дофикса P6 (линзы шва и денег) |
Закрытые — эра P7 (читающая поверхность контракта 0.3.0)
⚠ Из этой эры ОТКРЫТЫМИ остались
PD-281·PD-297·PD-298·PD-299— их тела живут в секции «Открытые — info», куда перенесены оркестратором №18 22.08. Строка со статусомopenпод заголовком «Закрытые» невидима тому, кто читает открытые, а именно так этот файл и читают.
| ID | Класс | Серьёзность | Где | Суть | Статус | Источник |
|---|---|---|---|---|---|---|
| PD-185 | bug | info | internal/pgstore/sink.go, internal/runs/runs.go readyToTranslate |
Статус finalizing есть в контракте, в DDL и в обоих аллоулистах — писателя нет ни одного. Тот же класс, что интейк-статусы до этого пака: слово контракта без пути, который его пишет. Сегодня недостижим, поэтому вреда нет; лечится либо писателем (финальная волна движка), либо снятием из аллоулистов, когда станет ясно, что его не будет — закрыто: 0.3.0 снял значение из контракта, P7 снял его из обоих Go-аллоулистов и из обоих CHECK-констрейнтов (миграция 00016), грепом finalizing в internal/ и cmd/ пусто |
fixed(P7, дерево сессии) | кросс-семейное ревью P5 (Fable) |
| PD-104 | bug | minor, расхождение док↔код | internal/login/login.go:285-288, internal/config/config.go:73 |
Фри-тир начисляется АВТОМАТИЧЕСКИ, а реестр обещает обратное. Код: дефолт SignupGrantMicroUSD: 5 * 1_000_000 (config.go:73) проведён в демона (main.go:99) и логин отдаёт его в стор на каждой новой подтверждённой паре (provider, subject) — аккаунт создаётся С $5. Строка PD-30 при закрытии утверждает «аккаунт создаётся с нулём, начисление руками из админки» — один из двух текстов лжёт, и это чинится независимо от продуктового решения. Ограничитель у автогранта один — лимитер входа; агрегатного потолка, счётчика и алерта нет (грепнуто). Разбор нормы и предложение «на бете дефолт в НОЛЬ» — D39.110 п.3, здесь не дублируется; ждёт слова владельца — ПОЛОВИНА ЗАКРЫТА (док↔код): ячейка PD-30 исправлена — «аккаунт с нулём» относилось только к НЕподтверждённой личности, подтверждённая получает автогрант (дефолт $5). ⚠ Продуктовая часть (ноль на бете, агрегатный потолок, счётчик) — НЕ закрыта: ждёт слова владельца, носитель прежний ⚠ ЗАКРЫТО P7 словом владельца 16.08 (D39.138 п.2л): дефолт SignupGrantMicroUSD = 0 (config.go), начисление на бете — руками через tmplatformctl grant; возврат $5 идёт вместе с суточным агрегатным потолком, когда появятся платежи. Пин — config.TestSignupGrantIsParsedNotGuessed (пинит ИМЕННО ноль, чтобы восстановление дефолта мимо ратификации падало); протухшие «$5» вычищены из PLATFORM_DIRECTION.md §2 и BACKLOG.md П-7 |
fixed(P7, дерево сессии) | приёмка P2 (панель; расхождение — оркестратор №15) |
| PD-172 | standards | minor | docs/architecture/14-api-contract/openapi.yaml createBook |
Проводное правило POST /books в контракте не записано: часть file ОБЯЗАНА идти последней. Потоковый читатель (r.MultipartReader) отдаёт части в порядке провода, а строка книги — то, что делает загрузку видимой во время приёма и находимой, если она оборвалась, — не может быть записана до языков, которые в ней обязательны. Тем же правилом специфицирована браузерная загрузка S3 («the file or content must be the last field in the form»). Платформа отвечает 400 на форму, приславшую поля после файла, и это поведение спека не описывает. Вопрос владельцу контракта (прецедент 503/PD-112), не правка спеки зоной ⚠ ДИСПОЗИЦИЯ D39.130: вопрос владельцу контракта принят и превращён в спек-правку 0.2.3, которую делает задача S4 фронт-сессии; зона правку спеки не делает и ждёт её ⚠ ЗАКРЫТО: правило записано в каноне 0.3.0 (§createBook: «The file part MUST come LAST… A part sent after the file is refused, never ignored: 400, invalid_request, с errors[]»), и P7 привёл к нему код — часть после файла ПРОВАЛИВАЕТ чтение, поэтому загрузка откатывается вместе с отказом (иначе пользователь получал 400 И книгу). Пин httpapi.TestAPartAfterTheFileIsRefusedAndTheBookIsNotKept, живая проба на стенде |
fixed(P7, дерево сессии) | сессия P5 (сверка контракта с построенным маршрутом) |
| PD-173 | standards | info | docs/architecture/14-api-contract/openapi.yaml Book |
У отклонённой книги нет ПРИЧИНЫ на проводе. BookStatus.rejected описан как «file could not be parsed», а почему именно — не поле контракта. Платформа хранит собственный закрытый словарь причин (books.ReasonSourceUnreadable · ReasonNotConfigured · ReasonParserUnavailable, колонка books.reject_reason, миграция 00013) для оператора и НЕ проецирует его: выдумывать поле не право зоны (прецедент PD-150 — last_resync_at). Следствие для пользователя: экран покажет «не удалось разобрать» без различения «файл не тот» и «наш движок был недоступен» — а это разные советы ⚠ ДИСПОЗИЦИЯ D39.130: вопрос владельцу контракта принят и превращён в спек-правку 0.2.3, которую делает задача S4 фронт-сессии; зона правку спеки не делает и ждёт её ⚠ ЗАКРЫТО: канон 0.3.0 завёл Book.reject_reason со словарём RejectReason, P7 проецирует внутреннюю причину на него (httpapi.contractRejectReason) — с одним переименованием по эффекту: parser_unavailable → processing_failed (ФБ-9: имя значения это то, на что клиент вешает фразу, и оно не должно называть наш компонент) |
fixed(P7, дерево сессии) | сессия P5 |
| PD-174 | standards | minor | internal/httpapi/v0.go contractRoutes |
POST /books на инстансе без интейка отвечает 404, которого спека для этого маршрута не предусматривает. Форма унаследована от уже принятого решения зоны — непостроенный/несмонтированный маршрут остаётся охраняемым 404 (d.Runs == nil так же), и это честнее 503 на КАЖДЫЙ аплоад, который инстанс всё равно не примет. Но контрактно это тот же класс, что PD-112: код ответа, которого в перечне операции нет. Вопрос владельцу контракта — назвать поведение для инстанса, не принимающего загрузки ⚠ Дополнено адверсариальным ревью P5: тот же класс есть и у РЕЗЮМА — 503 на деплое без маркер-команды или без шаблона потолка, тогда как 0.2.1 ратифицировала 503 только для СТАРТА прогона. Вопрос один на оба маршрута ⚠ ДИСПОЗИЦИЯ D39.130: вопрос владельцу контракта принят и превращён в спек-правку 0.2.3, которую делает задача S4 фронт-сессии; зона правку спеки не делает и ждёт её ⚠ ЗАКРЫТО каноном 0.3.0: createBook объявил 404 для деплоя, который книг не принимает (intake_enabled у GET /capabilities), а 503 объявлен ответом и startRun, и resumeRun. Обе формы теперь в перечне операций, вопрос закрыт ратификацией, а не кодом |
fixed(P7, ратификация 0.3.0) | сессия P5 (самопроверка против спеки) |
| PD-180 | standards | info | docs/architecture/14-api-contract/openapi.yaml createBook |
Ответ 201 несёт parsing, а не uploading, и «responds immediately» недостижимо как класс. Спека описывает createBook словами «Responds immediately; the book enters uploading», но ответ по HTTP не может уйти раньше, чем прочитано тело: к моменту 201 файл уже принят, и честный статус — parsing. uploading при этом РЕАЛЬНО наблюдаем, но только параллельным чтением библиотеки (пин books.TestABookIsVisibleAsUploadingWhileItsFileIsStillArriving). Сюда же — отказы интейка, которых спека не описывает: больше 16 частей формы и поле длиннее 1 КиБ дают 400, тело сверх потолка — 413 с порогом, которого в контракте нет, а тело медленнее дедлайна маршрута отвечало обрывом соединения без problem-ответа (дофикс это исправил, см. ниже). Вопрос владельцу контракта одним пакетом с PD-172/PD-174 ⚠ Дополнено дофиксом приёмки: в тот же пакет вопросов входит 408 на просроченный дедлайн загрузки — тело, не успевшее прийти за отведённое маршруту время, это медленный клиент, и 408 по RFC 9110 §15.5.9 говорит именно это, включая то, что лечение — повтор; кода 408 в перечне операции нет ⚠ ДИСПОЗИЦИЯ D39.130: вопрос владельцу контракта принят и превращён в спек-правку 0.2.3, которую делает задача S4 фронт-сессии; зона правку спеки не делает и ждёт её ⚠ ЗАКРЫТО каноном 0.3.0: «Responds immediately» снято, 201 описан как несущий parsing дословно; 408, 413 и 400-отказы интейка объявлены ответами операции, перечень помечен НЕисчерпывающим (авторитет — code ответа) |
fixed(P7, ратификация 0.3.0) | адверсариальное ревью P5 (сверка провода со спекой) |
| PD-199 | standards | minor | internal/httpapi/v0.go contractPausedReason, контракт openapi.yaml §PausedReason |
У платформы две причины паузы, у контракта одна — и вторая на провод не выходит. PausedReason спеки перечисляет credit_exhausted; с приходом ceiling.scope (D39.131) платформа различает потолок, который поставила САМА, и дневной потолок из book.yaml оператора, который она не выбирала и на котором у аккаунта деньги ЕСТЬ. Проецировать второй как первый нельзя — экран скажет «кончились деньги», и зажжётся аккаунт-флаг ReadUsage; выдумывать слово контракта не право зоны (прецеденты PD-173, PD-150). Поэтому внутренняя причина daily_ceiling хранится и проецируется как null, что спека сама и предписывает клиенту рендерить для незнакомой причины. ⚠ Найдено ЖИВОЙ ПРОБОЙ, а не чтением: до фикса значение уезжало на провод, и клиент, сгенерированный по спеке, его бы отверг. Вопрос владельцу контракта: назвать причину (тогда правка — одна функция) или подтвердить null ⚠ ЗАКРЫТО ратификацией D39.132 п.2а («null на проводе подтверждён») — статус приведён P7; проекция осталась той же (httpapi.contractPausedReason), а Usage получил СВОЙ словарь AccountHaltReason, чтобы прогонная причина больше не могла зажечь аккаунтный флаг |
fixed(P7, ратификация D39.132) | сессия P6 (живая проба на стенде) |
| PD-241 | bug | minor | internal/runs/reconcile.go outcome |
Стоп, о котором попросил ПОЛЬЗОВАТЕЛЬ, переименовывается в paused/credit_exhausted, если в тот же дрейн приехал потолок: причина паузы проверяется раньше намерения стопа, и аккаунтный halted-флаг (ReadUsage ключится на credit_exhausted) зажигается на аккаунте, у которого 97% баланса на месте (воспроизведено ревью). Денег не теряется и прогон резюмируем, но /usage говорит «кредит кончился» человеку, у которого он есть. Порядок «причина раньше намерения» — ратифицированное решение P6, и молча его переворачивать этим касанием я не стал; денежно-видимая половина — это PD-203 ⚠ ЗАКРЫТО P7 (ратификация D39.132 п.2д «следующим касанием зоны» — это касание): порядок в outcome() перевёрнут — намерение стопа проверяется ПОСЛЕ собственных окончаний движка и ДО причины паузы, так что стоп пользователя приезжает stopped без причины, а аккаунтный флаг не зажигается. Пин runs.TestAStopTheUserAskedForOutranksACeilingThatArrivedWithIt (все три причины паузы + контроль: без намерения решает потолок) |
fixed(P7, дерево сессии) | кросс-семейное ревью дофикса P6 (линза автомата состояний) |
| PD-223 | bug | minor | internal/runs/reconcile.go Resume, reopen |
Резюм прогона, остановленного ПЛАТФОРМЕННЫМ потолком, — цикл «холд-спавн-пауза» за клик. Движок останавливается на резервировании, которое не помещается, то есть НЕ доходит до своего потолка: расчёт списывает меньше холда, у прогона остаётся положительный остаток (обычно меньше одного вызова), exhausted не наступает, и резюм открывает попытку, которая упирается в тот же потолок сразу. Денег провайдера не тратится; цена — tmctl status и транзиентный юнит на клик. Порог здесь угадывать нельзя: размер резервирования — число движка, платформе не видное ⚠ ЗАКРЫТО P7 сменой контракта: резюм прогона, остановленного ЛЮБЫМ потолком, теперь отвечает 409 run_not_resumable · cause.code: ceiling_reached (канон 0.3.0 §resumeRun), то есть цикла «холд-спавн-пауза за клик» не существует — лечение это НОВЫЙ прогон с бОльшим потолком. Пин runs.TestAResumeOfARunPausedAtALimitIsRefusedWhoseverLimitItWas |
fixed(P7, дерево сессии) | приёмка P6 (дофикс, ФП-6) |
| PD-242 | bug | info | internal/runs/reconcile.go Resume |
ceiling_unknown проходит гейт резюма, который отказывает daily_ceiling: дневной потолок, приехавший ТОЛЬКО кодом выхода (карантин проекции — случай, который собственный комментарий outcome называет не-редким), записывается как «чей потолок, не установлено» и резюмируется свободно, упираясь в тот же лимит того же дня. Не регресс (прежнее credit_exhausted резюмировалось так же) и тот же класс чурна, что PD-223 ⚠ ЗАКРЫТО P7 тем же ходом, что PD-223: гейт больше не различает причины — резюм отказан при ЛЮБОЙ паузе, поэтому ceiling_unknown не может его пройти |
fixed(P7, дерево сессии) | кросс-семейное ревью дофикса P6 (линза автомата состояний) |
| PD-257 | bug | major | internal/pgstore/readmodel.go writeChapters, migrations/00002:109 |
Пере-нарезка со сдвигом нумерации валила читающую модель НАВСЕГДА. Id главы у движка — хеш её текста и переживает ре-кат, number позиционный и сдвигается; апсерт по id ставил номер, который ещё держит соседка, и unique (book_id, number) ронял всю транзакцию SaveStructure. Вход детерминирован → дерево замирало молча, structure_version не двигался, клиенту никто не говорил пере-синхронизироваться. Достижимо апгрейдом движка/лангпака (правило заголовков роняет заголовочную главу). Delete-first НЕ лечит: сталкиваются ВЫЖИВШИЕ главы — закрыто: констрейнт стал deferrable initially deferred (00018); пины pgstore.TestARecutThatShiftsChapterNumbersIsMaterialized (вставка в начало И обратная форма) |
fixed(приёмка P7, дерево сессии) | приёмка P7 (воркфлоу 12 осей + кросс-модель) |
| PD-258 | bug | major | internal/readmodel/readmodel.go refreshStructure, pgstore.writeUnits |
Упавший tmctl export затирал текст книги пустотой. Дерево и пары — два вызова движка; при отказе второго все пары уходили в SaveStructure с пустыми source/target и pending, а кат не менялся ⇒ те же id ⇒ ветка do update перезаписывала переведённую книгу пустыми строками до следующего успешного экспорта — закрыто: Structure.TextRead несёт факт «канал ответил», апсерт трогает текст только при нём; пин pgstore.TestAFailedExportDoesNotEraseTheTextAlreadyMaterialized |
fixed(приёмка P7, дерево сессии) | приёмка P7 (воркфлоу 12 осей + кросс-модель) |
| PD-259 | bug | minor | internal/pgstore/idempotency.go ClaimIdempotency |
Две ОДНОВРЕМЕННЫЕ первые попытки давали 500 вместо 409. select ... for update по несуществующей строке не блокирует ничего, обе доходили до голого insert, проигравшая ловила 23505 — ни один из двух конфликтов контракта, наружу internal_error на том самом случае, ради которого заголовок существует — закрыто: on conflict (user_id, key) do nothing + перечитывание (идиома identities того же пакета); пин pgstore.TestTwoSimultaneousFirstClaimsAnswerInFlightAndNeverAnUnexpectedError (8 гонщиков) |
fixed(приёмка P7, дерево сессии) | приёмка P7 (воркфлоу 12 осей + кросс-модель) |
| PD-260 | bug | minor | internal/httpapi/v0.go createBook/startRun, httpapi/idempotency.go |
Оборвавшийся клиент получал ВТОРУЮ книгу. complete/release писали на r.Context(), который отменяет ровно тот клиент, который будет ретраить: ключ висел «в полёте» claimStale=30 мин, потом takeover переделывал работу. Рядом в зоне уже жило решение того же случая (books.writeCtx) — закрыто: settleCtx = WithoutCancel + свой дедлайн |
fixed(приёмка P7, дерево сессии) | приёмка P7 (воркфлоу 12 осей + кросс-модель) |
| PD-261 | bug | minor | internal/pgstore/migrations/00017:30, httpapi/idempotency.go |
path в первичном ключе неограничен: длинный адрес переполняет кортеж btree, клейм падает, и ручка отвечает 500 там, где контракт обещает 409 — закрыто: путь КЛЮЧУЕТСЯ дайджестом (path_sha256), сам остаётся читаемой колонкой (00018); пин TestAnOverlongPathDoesNotBreakTheClaim. ⚠ Первая редакция правки выбросила путь из ключа целиком и этим сломала канон («scoped to (principal, method, path); the same key on another operation is another key»); поймано приёмкой правок — пятью независимыми осями сразу, — откачено, область восстановлена; пин TestOneKeyOnAnotherOperationIsAnotherKey |
fixed(приёмка P7, дерево сессии) | приёмка P7 (воркфлоу 12 осей + кросс-модель) |
| PD-263 | bug | minor | internal/pgstore/runs.go StartRun, ReleaseBankStop |
Полоса прогона и её база считали РАЗНЫЕ проходы. chapters_before снимался по edit-колонке, а первый сегмент прогона со стопом на подпись считает по draft-колонке ⇒ новый прогон над уже начерновленной книгой открывался на полном потолке — ровно тот дефект, ради которого колонку и заводили. Симметрично: при снятии стопа числитель переключается на edit, а база оставалась draft'овой — закрыто: база снимается тем же предикатом, что числитель, и ПЕРЕ-снимается в ReleaseBankStop; пин pgstore.TestTheBarAndItsBaselineCountTheSamePass |
fixed(приёмка P7, дерево сессии) | приёмка P7 (воркфлоу 12 осей + кросс-модель) |
| PD-264 | bug | minor | internal/pgstore/readmodel.go writeChapters |
После пере-нарезки глава наследовала прогресс и замечания чужой главы. unit_resolutions адресованы движковыми (номер главы, ординал), которые новый кат пере-указывает, и не чистились — закрыто: ре-кат удаляет резолюции ушедшего ката (переводить их не по чему); пин pgstore.TestARecutDoesNotLetAChapterInheritTheProgressOfTheOneBeforeIt |
fixed(приёмка P7, дерево сессии) | приёмка P7 (воркфлоу 12 осей + кросс-модель) |
| PD-265 | bug | minor | internal/httpapi/stream.go pump |
Кадры, подрезанные буфером при ЖИВОМ соединении, пропадали молча — без resync_required, хотя note доставляется однажды и молчаливый пропуск = замечание потеряно навсегда — закрыто: state.Oldest > from+1 в цикле даёт тот же ответ, что и на рукопожатии; пин httpapi.TestFramesPrunedWhileTheConnectionIsOpenAskTheClientToResync (без него мутация проходила батарею) |
fixed(приёмка P7, дерево сессии) | приёмка P7 (воркфлоу 12 осей + кросс-модель) |
| PD-266 | bug | minor | internal/httpapi/stream.go stream.write |
На маршруте потока не было дедлайна записи: клиент, открывший поток и переставший читать, держал горутину, соединение и слот сервера вечно — тихий уход не отменяет r.Context() — закрыто: SetWriteDeadline на каждый кадр через ResponseController; пин httpapi.TestEveryFrameIsWrittenUnderADeadline |
fixed(приёмка P7, дерево сессии) | приёмка P7 (воркфлоу 12 осей + кросс-модель) |
| PD-267 | bug | info | internal/httpapi/stream.go streamEvents |
Last-Event-ID больше позиции книги принимался и отвечал 204. Такого id сервер не выдавал (реальный триггер — восстановление БД из бэкапа двигает event_position назад), и 204 велит клиенту прекратить переподключение по числу, которое нельзя проверить — закрыто: id, с которого сервер продолжить не может, отвечается resync_required — так канон и требует («rather than silently starting from now»); ветка 204 для «at or past на книге в покое» сохранена, она тоже каноническая. ⚠ Первая редакция правки стартовала свежий поток и этим теряла кадры молча — поймано приёмкой правок тремя осями; пин httpapi.TestAnEventIDTheServerCannotResumeFromIsToldToResync |
fixed(приёмка P7, дерево сессии) | приёмка P7 (воркфлоу 12 осей + кросс-модель) |
| PD-268 | bug | minor | internal/runs/reconcile.go finish, Sweep |
Материализация читающей поверхности голодала свип. refreshReadModel брал 5 минут (WithoutCancel) ВНУТРИ пер-прогонного бюджета в 60 с, то есть уходил из-под withBudget и держал расчёт денег всех прогонов позади себя — ровно та голодовка, ради которой withBudget и заведён (PD-169) — закрыто: работа копится (deferRefresh) и сливается ОТДЕЛЬНЫМ проходом демона (DrainRefresh, pass("readmodel", …)). ⚠ Первая редакция ставила defer drainRefresh внутрь Sweep — а defer отрабатывает ДО возврата, то есть работа оставалась в бюджете прохода и ничего не лечила; поймано приёмкой правок |
fixed(приёмка P7, дерево сессии) | приёмка P7 (воркфлоу 12 осей + кросс-модель) |
| PD-269 | bug | minor | internal/runner/artifacts.go projectDB |
Банк не читался на штатном деплое. Требовался ключ project_db, который движок делает НЕОБЯЗАТЕЛЬНЫМ (дефолт <book_id>.db, backend/internal/config/book.go:163), а канонический шаблон оператора его не содержит — закрыто: тот же дефолт, что у движка; пин runner.TestTheProjectDatabaseDefaultsTheWayTheEngineDefaultsIt |
fixed(приёмка P7, дерево сессии) | приёмка P7 (воркфлоу 12 осей + кросс-модель) |
| PD-270 | bug | info | internal/httpapi/problem.go WriteProblem |
Problem без Code уходил как 500 БЕЗ обязательного code. Канон требует код на каждом ответе /v0, 500 включая; omitempty существует ради поверхности, которую канон не описывает, и протекал обратно. Живого вызова не было — латентная ловушка, бьющая по принципу самого файла («расходящаяся пара непредставима») — закрыто: пустой код → internal_error; пин httpapi.TestAProblemWithNoCodeStillAnswersOne |
fixed(приёмка P7, дерево сессии) | приёмка P7 (воркфлоу 12 осей + кросс-модель) |
| PD-271 | bug | info | internal/httpapi/conditional.go acceptsGzip, writeJSON |
Договорённость о кодировании решалась по ПЕРВОМУ токену (*, gzip;q=0 читалось как согласие) и HEAD не получал ни ETag, ни 304, хотя маршрутизатор отдаёт ему тот же обработчик (Go 1.22+) — закрыто: именованное кодирование выигрывает у * в любом порядке; валидатор отвечает и на HEAD; пины TestTheNamedCodingOutranksTheWildcardInEitherOrder, TestHeadCarriesTheSameValidatorAsGet |
fixed(приёмка P7, дерево сессии) | приёмка P7 (воркфлоу 12 осей + кросс-модель) |
| PD-272 | bug | info | internal/pgstore/readmodel.go scope.tag, SubmitBankDecisions |
Курсор не был привязан к КНИГЕ (только к версии структуры) и принимался на другой книге; решения банка брали блокировки строк в порядке клиентского массива, и два пересекающихся сабмита взаимно блокировались — закрыто: книга входит в тег области; решения применяются в порядке term_id, отказ по-прежнему называет клиентский индекс; пин pgstore.TestACursorMintedForOneBookIsRefusedOnAnother |
fixed(приёмка P7, дерево сессии) | приёмка P7 (воркфлоу 12 осей + кросс-модель) |
| PD-273 | standards | info | internal/runs/reconcile.go outcome, контракт §Run |
ЗАКРЫТ решением владельца 17.08: статус остаётся awaiting_bank, лечение — в КОНТРАКТЕ. Разбор: нажать «стоп» на прогоне, УЖЕ стоящем в awaiting_bank, нельзя (RequestStop требует finished_at is null) ⇒ случай ровно один — гонка: стоп запрошен во время перевода, а движок в этом окне домайнил банк и вышел кодом 3. Оба состояния продолжаемы, но ярлык несёт ПРОВОДКУ: ReleaseBankStop вызывается только при l.Status == "awaiting_bank", поэтому переименование в stopped оставило бы bank_released = false, вернуло бы --verify-bank на resume и пере-открыло бы PD-277. Плюс awaiting_bank информативнее: работа стоит на самой дешёвой точке — черновик и майнинг оплачены, редакторская волна не начата. Настоящая дыра — ПРОВОД: Run не умеет сказать «остановлено по вашей просьбе» (status + stop_for_signing = опция запуска, а не состояние), поэтому честную фразу клиент нарисовать не может. Строка бэклога для оркестратора — archive/P7_ACCEPTANCE_HANDOFF_2026-08-17.md §7(и) |
closed (решение владельца 17.08) | приёмка P7 |
| PD-274 | doc | major | deploy/README.md:80, docs/PLATFORM_DIRECTION.md:68 |
Деплой-нота включала обратно грант, который PD-104 выключил. В копируемом блоке окружения стояло TM_PLATFORM_SIGNUP_GRANT_USD=5, то есть развёртывание по ноте давало каждой новой личности безлимитный самообслуживаемый грант; в PLATFORM_DIRECTION.md §2 «Дефолт $5» пережил правку первого абзаца. Плюс TM_PLATFORM_LANGUAGE_PAIRS не назван вовсе, а пустой список делает /capabilities и интейк противоположными — закрыто: строка снята с объяснением, пары внесены, оба текста актуализированы |
fixed(приёмка P7, дерево сессии) | приёмка P7 (воркфлоу 12 осей + кросс-модель) |
| PD-275 | standards | minor | internal/pgstore/readmodel_test.go, httpapi/reading_test.go, httpapi/problem_test.go |
Три пина не проверяли того, что объявляли. TestAMissingBankReadOutSavesNothing падал на шаг раньше (нет book.yaml) и в ветку ErrNoBank не входил; TestALimitIsClampedOrIgnoredButNeverRefused не мог наблюдать подрезку (фейк выбрасывал limit); title(code) == "" недостижимо ни для одного кода. Плюс подсистема Idempotency-Key целиком без тестов — закрыто: три пина переписаны на наблюдаемое свойство, подсистема ключей закрыта семью тестами, карта замечаний (ingest/notes.go) — тремя |
fixed(приёмка P7, дерево сессии) | приёмка P7 (воркфлоу 12 осей + кросс-модель) |
| PD-276 | bug | info | internal/books/parse.go Parse |
Материализация на границе интейка шла на контексте задачи, уже потраченном разбором: на большой книге Refresh (ещё два процесса движка) не укладывался, и дерево оставалось пустым, а свип этого не повторяет — закрыто: свой отвязанный бюджет, как на границе прогона. Бэкстоп-свип для книг с chapter_count > 0 и пустым деревом построен доработкой 20.08 (books.materializeMissingTrees, pgstore.BooksWithNoTree; пин TestABookWhoseTreeNeverLandedIsMaterializedByTheSweep) ⚠ ЭРРАТА (акт 5): названный здесь бэкстоп и его пин БОЛЬШЕ НЕ СУЩЕСТВУЮТ. books.materializeMissingTrees, pgstore.BooksWithNoTree и TestABookWhoseTreeNeverLandedIsMaterializedByTheSweep удалены вместе с механизмом, который их заменил: долг на материализацию стал колонкой (PD-302), и он покрывает не только «дерева нет вовсе», но и «дерево на границу старше». Свойство закрыто, доказательство переехало: books.TestAParsedBookOwesAReadingSurfaceUntilOneIsMaterialized (плюс проверка, что метка ставится ТОЙ ЖЕ транзакцией, что и конец разбора) |
fixed(приёмка P7 + доработка 20.08, дерево сессии) | приёмка P7 (воркфлоу 12 осей + кросс-модель) |
| PD-277 | bug | major | internal/runs/spawn.go, шов с движком |
Экран подписи банка не был подключён к движку: resume перезапускал движок с тем же --verify-bank, и тот немедленно вставал в тот же стоп — подпись не двигала ничего. Отчёт пака помечал пункт «исполнен», сверив НЕ ту ветку движка (if !r.VerifyBank, по которой resume не идёт). ⚠ Приёмка сперва оформила это как вопрос владельцу («нужен новый канал в движок») — рамка была неверна: строка единого бэклога 191(в) (слово владельца 16.08, D39.144) уже требовала «проводку resume = снятие стопа до движка», а движок без флага уже делает модель владельца — неподписанное едет авто-строками с пометкой ⟨проверить⟩ (backend/internal/pipeline/mining.go:211-231, D39.42 п.3) — закрыто: на resume флаг не передаётся (l.VerifyBank && !l.BankReleased, bank_released протянут в LiveRun); пин runs.TestAResumedRunIsSpawnedWithoutTheSigningStop. ⚠ ОСТАТОК, не блокер: decline пользователя до движка не доезжает — движок читает mined_rejects, платформа этот файл не пишет, поэтому отклонённый термин уедет в банк авто-строкой. Территория строки бэклога 192 («пост-ридинговый цикл»), отложенной владельцем до полигонных итогов ⚠ ПЕРЕ-ДИСПОЗИЦИЯ 22.08: остаток этой строки («честность decline») относил работу к отложенной 192 — он снят вместе с глаголом: decline больше нет ни в одной ручке зоны (PD-370). Живой остаток другой и он НЕ здесь: ратифицированная модель оставляет пер-термной ПРАВКУ («поправить/добавить термин»), ручки под неё нет ни в зоне, ни в каноне, и это строка 199(а) единого бэклога плюс контрактная половина PD-370. |
fixed(приёмка P7, дерево сессии) | приёмка P7 |
| PD-278 | bug | minor | internal/pgstore/readmodel.go SaveStructure |
Первая материализация книги трактовалась как ПЕРЕ-нарезка и стирала работу оплаченного прогона. storedKey = '' ≠ ключу манифеста ⇒ ветка ре-ката удаляла ВСЕ unit_resolutions книги. Достижимо штатно: интейк-материализация упала (PD-276), пользователь прогнал книгу, и первый успешный SaveStructure стирал резолюции завершённого прогона — карточка 0 глав, все замечания потеряны, восстановление только новым прогоном за деньги. Регрессия правки PD-264 — закрыто: удаление при storedKey != "" && ключ сменился; пин pgstore.TestTheFirstMaterializationDoesNotDiscardAFinishedRunsWork |
fixed(приёмка правок P7, дерево сессии) | приёмка правок P7 (9 осей + fable-5) |
| PD-279 | bug | minor | internal/pgstore/migrations/00018_recut_and_key_scope.sql Down |
Down-путь 00018 не исполнялся на тех данных, которые Up впервые легально принимает: строка с путём длиннее ~2704 байт не влезает в восстанавливаемый старый первичный ключ, и goose DownTo(17) падал детерминированно — то есть откат релиза ломался ровно в аварии, ради которой откат существует. Регрессия правки PD-261 — закрыто: Down снимает такие строки перед пересборкой ключа (таблица — кэш с ретенцией 24 ч); проверено живым PG в цикле UP → данные → DOWN → UP |
fixed(приёмка правок P7, дерево сессии) | приёмка правок P7 (9 осей + fable-5) |
| PD-280 | standards | info | internal/httpapi/idempotency.go, v0.go intakeFingerprint |
HTTP-половина Idempotency-Key не имела тестов, и именно там жили три дефекта: что попадает в отпечаток, какая область у клейма и на каком контексте ключ закрывается — из pgstore это не наблюдаемо (там опаковый дайджест) — закрыто: три пина (TestTheSameUploadReFramedIsStillTheSameRequest, TestTheClaimCarriesTheRequestsOwnOperation, TestAnOverlongKeyIsRefusedBeforeAnythingIsClaimed) |
fixed(приёмка правок P7, дерево сессии) | приёмка правок P7 (9 осей + fable-5) |
| PD-283 | bug | minor | internal/httpapi/stream.go pump |
Подрезка буфера ДО чтения кадров пропускалась молча: сверка state.Oldest > from+1 делалась ПОСЛЕ того, как водяной знак перепрыгнул через дыру, поэтому Oldest оказывался ПОЗАДИ него и условие не срабатывало никогда — то есть правка PD-265 не закрывала собственный сценарий, а кадры note, которые канон запрещает терять, терялись. Батарея не видела: единственный пин стоял на подрезке МЕЖДУ чтениями — закрыто: дыра опознаётся по голове пачки (frames[0].Position > from+1; позиции непрерывны — единственный писатель emitFrame), кадры этой пачки не отправляются; пин TestAHoleAtTheHeadOfTheBatchAsksTheClientToResync |
fixed(доработка 20.08, дерево сессии) | доработка 20.08 (сверка находок против дерева) |
| PD-284 | bug | minor | internal/pgstore/idempotency.go CompleteIdempotency |
Квитанция писалась в строку, которая этой попытке уже не принадлежит. where не различал ни живую строку от исчезнувшей, ни свой клейм от перехваченного: воспроизведено живым PG — попытка, у которой клейм забрали по протуханию, дописывала свой ответ, и клиент получал квитанцию ЧУЖОГО запроса (status=201 location=/v0/books/bk_old). Тег команды при этом выбрасывался в _, поэтому «квитанция не записана» было ненаблюдаемо — закрыто: and finished_at is null + отдельная ErrClaimLost; окно перехвата у ЖИВОЙ попытки закрыто загрузочной проверкой TM_PLATFORM_UPLOAD_DEADLINE < pgstore.ClaimStale (та же форма, что уже стояла для UploadGrace); пин TestAnUploadDeadlineLongerThanEitherWindowIsRefused ⚠ ЭРРАТА (акт 5): закрытие было ПРЕЖДЕВРЕМЕННЫМ. and finished_at is null закрывает только «загрузка дольше окна» и ничего не говорит про ВЛАДЕЛЬЦА клейма: строка перехвачена, а finished_at у преемника законно пуст. Целиком закрыто токеном клейма — PD-300 |
fixed(доработка 20.08, дерево сессии) | доработка 20.08 (сверка находок против дерева) |
| PD-285 | standards | minor | internal/runs/reconcile_test.go, internal/httpapi/idempotency_test.go |
Два пина утверждали свойство, которого у кода нет. TestAResumedRunIsSpawnedWithoutTheSigningStop гонял фикстуру, стартующую прогон БЕЗ stop_for_signing, поэтому подмена l.VerifyBank && !l.BankReleased → l.VerifyBank его не роняла — пин PD-277 был пустым. TestTheSameUploadReFramedIsStillTheSameRequest объявлял «тот же файл в другой оболочке — тот же запрос», тогда как боевой вызов кладёт в отпечаток r.ContentLength, который оболочку считает — закрыто: первый проходит весь путь (первая попытка обязана НЕСТИ флаг, вторая — нет), второй переименован и утверждает то, что код делает, с честной границей PD-262 |
fixed(доработка 20.08, дерево сессии) | доработка 20.08 (сверка находок против дерева) |
| PD-286 | bug | info | internal/httpapi/stream.go write, streamEvents |
Ошибка Flush выбрасывалась, а на маленьком кадре это единственный вызов, который видит залипший сокет: Fprintf пишет в bufio и возвращает nil, поэтому поток рапортовал «кадр ушёл» о кадре, который не ушёл, и залипший клиент получал writeTimeout на каждый heartbeat вместо одного. Плюс HEAD на маршрут потока проходил в насос и держал горутину до конца прогона (RFC 9110 §9.3.2: HEAD отдаёт те же заголовки и не тело) — закрыто: ошибка Flush убивает поток, HEAD отвечает заголовками; пины TestAFrameThatCouldNotBeFlushedEndsTheStream, TestHeadOnTheStreamAnswersTheHeadersAndNothingElse |
fixed(доработка 20.08, дерево сессии) | доработка 20.08 (сверка находок против дерева) |
| PD-287 | bug | minor | internal/runs/runs.go Bounds, Start |
blocked называл чужую книгу всякий раз, когда у аккаунта есть открытый холд, независимо от того, укорачивает ли он шкалу, — а канон §RunOptions обещает, что поле объясняет ИМЕННО укорачивание («why the scale is smaller than the account could otherwise afford»). Пользователь книги из трёх глав видел «другая книга держит кредит» и шёл останавливать прогон впустую. Симметрично CreditHeldError в Start срабатывал на ЛЮБОМ выходе за шкалу, включая выход по размеру книги. Bounds не имел ни одного теста — закрыто: CreditHeldBy возвращает и СУММУ чужих холдов, поле заполняется только если возврат этой суммы удлинил бы шкалу (в Start — только если без неё запрос бы прошёл); пин TestBlockedNamesAnotherBookOnlyWhenItsHoldShortensTheScale |
fixed(доработка 20.08, дерево сессии) | доработка 20.08 (сверка находок против дерева) |
| PD-288 | bug | info | internal/pgstore/readmodel.go noteCount vs ListNotes |
Book.note_count и GET /notes описывали разные множества: счётчик считал все флагнутые резолюции книги без джойна, список — только те, чья глава существует (INNER JOIN, иначе chapter_id нечем заполнить). Карточка обещала N замечаний, список отдавал меньше с next_cursor: null. Достижимо штатно, пока дерево не материализовано (PD-276) — закрыто: счётчик считается тем же джойном |
fixed(доработка 20.08, дерево сессии) | доработка 20.08 (сверка находок против дерева) |
| PD-289 | bug | info | internal/pgstore/sink.go unitDone |
Кадры строились из ОТВЕРГНУТОЙ доставки: апсерт резолюции отбрасывает событие старше сохранённого (where excluded.at >= unit_resolutions.at), но тег команды не проверялся — счётчики главы пересчитывались и emitChapter слал кадр note, собранный из полей устаревшего события, под тем же id замечания. Кадр хранится wire-ready и реплеится дословно, поэтому расхождение пережило бы правку — закрыто: RowsAffected() == 0 ⇒ ничего не изменилось, ничего не объявляется |
fixed(доработка 20.08, дерево сессии) | доработка 20.08 (сверка находок против дерева) |
| PD-290 | bug | info | internal/pgstore/credits.go inTx → inReadTx, гейты ListNotes/ListBank |
Гейт дельта-чтения и строки, которыми он управляет, читались в РАЗНЫХ снапшотах: READ COMMITTED берёт снапшот на каждый стейтмент, поэтому замена банка/дерева между проверкой version_too_old и запросом строк не отвергалась и не отражалась — короткий список, который выглядит полным (класс PD-163, объявленный закрытым «одной транзакцией») — закрыто: пути чтения открываются repeatable read + read only (одна функция inReadTx; read-only повторяемое чтение не может прерваться, в отличие от serializable) |
fixed(доработка 20.08, дерево сессии) | доработка 20.08 (сверка находок против дерева) |
| PD-291 | bug | info | internal/readmodel/readmodel.go, internal/pgstore/readmodel.go writeUnits |
Юнит, который есть в манифесте и выпал из экспорта (кат сдвинулся между двумя вызовами движка), перезаписывал сохранённый текст пустым pending: флаг «текст прочитан» был на всей структуре, а не на паре. Правка PD-266 закрывала только полный отказ экспорта — закрыто: флаг переехал на ПАРУ (StructureUnit.TextKnown), структурный снят как избыточный; пин TestAPairTheExportDidNotCarryDoesNotSpeakAboutItsText |
fixed(доработка 20.08, дерево сессии) | доработка 20.08 (сверка находок против дерева) |
| PD-292 | standards | info | internal/pgstore/runs.go RestartRun, internal/runs/reconcile.go Resume |
Снятие стопа банка было ОТДЕЛЬНЫМ писателем после переоткрытия прогона: отказ между двумя записями оставлял возобновлённый прогон с bank_released = false — весь второй сегмент бар считал draft-колонку против купленного потолка, и канала починки не было (свип это поле не трогает, повторный resume отказал бы: прогон уже translating) — закрыто: снятие уехало ВНУТРЬ транзакции RestartRun (LiftBankStop), метод ReleaseBankStop снят, пин переписан на реальный путь |
fixed(доработка 20.08, дерево сессии) | доработка 20.08 (сверка находок против дерева) |
| PD-293 | bug | info | internal/runs/reconcile.go DrainRefresh |
Проход материализации не мог быть подрезан своим бюджетом: DrainRefresh не смотрел ctx между книгами, а каждая книга берёт отвязанные 5 минут, поэтому K медленных книг переезжали объявленные 10 минут прохода и задерживали интейк, телеметрию и СЛЕДУЮЩИЙ такт свипа — то есть голодание PD-169 переехало уровнем выше — закрыто: проверка между книгами, недоделанное возвращается в очередь (deferRefresh дедуплицирует по книге) ⚠ ЭРРАТА (акт 5): механизм заменён, свойство сохранено. DrainRefresh/deferRefresh удалены вместе с очередью в памяти; проверка бюджета между книгами живёт в readmodel.Drain, а «недоделанное возвращается» стало долговечным долгом с арендой (PD-302, PD-320, PD-322) ⚠ ПАК P8-REVIEW 24.08: живой носитель, который эррата этой строки прямо называет (проверка бюджета между книгами в readmodel.Drain), не покрыт НИ ОДНИМ тестом — во всём дереве нет теста, который вообще даёт Drain дедлайн, поэтому гейт можно выключить целиком и батарея останется зелёной; точный близнец у интейка при этом запинен своим тестом. Вынесено строкой PD-388; статус паком не менялся ⚠ ОСПОРЕНО(PD-388) — греппируемый токен по предложению ревью старшей моделью: спор о том, закрыта ли эта строка, иначе живёт только прозой и тихо истекает, если приёмка пропустит вопрос. |
fixed(доработка 20.08, дерево сессии) | доработка 20.08 (сверка находок против дерева) |
| PD-294 | standards | info | internal/pgstore/readmodel.go SubmitBankDecisions |
Транзакция решений банка не брала блокировку книги первой — против глобального порядка зоны (credits.go lockBook: books → runs → run_attempts → account_balances → reservations). Она берёт блокировки bank_decisions и, через внешний ключ, bank_terms, которые SaveBank держит, уже взяв книгу; ретрая нет, IsTransient сюда не подключён, поэтому взаимоблокировка ушла бы клиенту как 500 — закрыто: lockBook в начале транзакции |
fixed(доработка 20.08, дерево сессии) | доработка 20.08 (сверка находок против дерева) |
| PD-295 | bug | info | internal/pgstore/migrations/00016_read_surface.sql |
Индекс unit_resolutions_book_idx (00015) стал строгим префиксом-дубликатом индекса, который добавляет 00016, и не снимался: второй индекс на самом горячем пути записи (строка на юнит на волну) — закрыто: drop index в Up, воссоздание в Down; заодно снято утверждение о «пине 18.4», которого нет в ратифицированной таблице стека (пол — 16). Перефингерпринт 00016 объяснён в шапке migrations.sha256 |
fixed(доработка 20.08, дерево сессии) | доработка 20.08 (сверка находок против дерева) |
| PD-296 | standards | info | internal/httpapi/v0.go contractSurface, internal/httpapi/problem.go codes |
Два места, которые надо править парой, правились по одному. Маршруты регистрировались дюжиной вызовов mux.Handle, а тест «каждый контрактный маршрут требует сессии» ходил по литеральному списку из четырёх — семь новых маршрутов пака не покрывал никто. Коды ошибок несли статус и заголовок в двух отдельных switch, а тест — в третьем, рукописном: новая константа не покрывалась ничем — закрыто: маршруты и коды стали по ОДНОЙ таблице, тесты ходят по ней; счётчик кодов пинит закрытый словарь 0.3.0 в 16 значений |
fixed(доработка 20.08, дерево сессии) | доработка 20.08 (сверка находок против дерева) |
Закрытые — акт 5 P7 (ревью акта 4 + ответ контрактной сессии, 20.08)
Находки адверсариального ревью собственного диффа акта 4 (9 линз × 2 рефутера) и работа, которую принёс ответ контрактной сессии на записку зоны. Каждая строка закрыта посадкой мутации: код испорчен названным образом, пин обязан упасть, код возвращён.
| ID | Класс | Серьёзность | Где | Суть | Статус | Источник |
|---|---|---|---|---|---|---|
| PD-202 | bug | info | internal/pgstore/readmodel.go finishedUnits, sink.go unitDone |
chapters.units_done не двигался вовсе у пайплайна без волны редактора. Колонка считала ТОЛЬКО волну edit, поэтому на деплое без редактора ни одна глава не была «сделана» никогда: полоса книги стояла на нуле, а ChaptersLeft не подрезался — сервис бесконечно предлагал купить уже переведённые главы. ⚠ Это ДЕНЬГИ, а не полоса. Закрыто формой, которую задала контрактная сессия: «сделано» = ПОСЛЕДНИЙ проход, который книга на ЭТОМ деплое реально получает, и форму движок объявляет сам — у пайплайна без редактора знаменатель волны edit равен нулю (beginWaves), значит runs.edit_total = 0 при ненулевом draft_total и есть «редактора нет». Читают одно выражение все пять мест (chaptersDone, runProgress, emitChapter, ReadBookForRun, bookScope.wave); до первого отчёта прогона форма НЕИЗВЕСТНА и ответ остаётся волной edit — неизвестное не должно читаться как «сделано». Колонка-дубль units_done снесена (00021, PD-314). Пин: pgstore.TestADraftOnlyDeploymentStillClampsTheScale |
fixed(акт 5) | вопрос зоны P7 → ответ контрактной сессии 20.08 (E-G) |
| PD-253 | standards | info | internal/pgstore/readmodel.go ListUnits, канон §listUnits |
410 Gone отвечал и на главу, которой никогда не было. Расхождение снято НЕ кодом: контрактная сессия приняла довод зоны — различать «была и больше нет» от «не было никогда» невыполнимо без надгробий, которых у платформы нет и заводить которые дороже вопроса, — и изменила канон в нашу сторону. Расхождение перестало быть расхождением |
fixed(канон 0.4.0) | приёмка P7 (акт 1) → ответ контрактной сессии 20.08 (L) |
| PD-262 | bug | minor | internal/httpapi/v0.go intakeFingerprint, internal/httpapi/idempotency.go |
Тождество интейка решалось по ОБЪЯВЛЕННОМУ, и объявленное его не несёт. Content-Length стоял вместо размера файла и не закрывал ни одной половины: он считает и multipart-обвязку (повтор из другой клиентской библиотеки — «другой запрос»), а chunked-тело не объявляет ничего вовсе — две РАЗНЫЕ книги под одним ключом с одинаковым объявлением были неразличимы, и вторая получала Location первой. Закрыто нормой 0.4.0 («метаданные + имя файла + СОДЕРЖИМОЕ»): Content-Length из отпечатка убран, файл дайджестится на лету (io.TeeReader), дайджест хранится с квитанцией (00023), а повтор ЧИТАЕТСЯ и сверяется до того, как ему что-то ответят — совпал, реплей; не совпал, 409 key_reused; не прочитан — не реплей. Пины: httpapi.TestTheFingerprintIsExactlyTheDeclaredParts, httpapi.TestTheClaimsFourAnswersReachTheClient/another_FILE…; живая проба 20.08: тот же файл → 201 с тем же id, другой файл → 409 key_reused, книг в библиотеке одна |
fixed(акт 5) | приёмка P7 (акт 1) → норма 0.4.0 (E) |
| PD-282 | standards | minor | internal/runs/reconcile.go Resume, канон §resumeRun |
resume прогона, потратившего весь купленный потолок, отвечал 202 и НИЧЕГО не менял — молчаливый no-op, который тот же раздел канона запрещает в соседней строке. Контрактная сессия ратифицировала обратное обещание: 202 означает, что работа реально переоткрыта, и такой вызов — 409 run_not_resumable, cause: ceiling_reached, в ЛЮБОМ статусе. Закрыто вместе с разведением двух exhausted (PD-312). Пин: runs.TestResumeOfARunWithNothingLeftIsRefusedWithTheCeilingReached |
fixed(акт 5) | триаж акта 4 → ответ контрактной сессии 20.08 (A) |
| PD-300 | bug | minor | internal/pgstore/idempotency.go, internal/httpapi/idempotency.go |
Ключ идемпотентности не знал своего владельца, и обе половины стоили пользователю книги. Строка несёт (user, method, path, key) и не несёт, КАКАЯ попытка её держит: (1) попытка, застрявшая дольше ClaimStale, наконец падала и её defer release УДАЛЯЛ живую строку преемника, который уже создавал книгу — третья попытка получала свежий клейм и делала работу второй раз; (2) у попытки забрали клейм, а она дописывала СВОЙ ответ, и клиент реплеил Location книги, которой у него нет. and finished_at is null не сужает ни одну: строка преемника законно не завершена. Обе воспроизведены ревьюером на живом PG. Закрыто токеном клейма (00020): ClaimIdempotency минтит его и ПЕРЕ-минтит при перехвате, complete и release его предъявляют, несовпадение → ErrClaimLost. ⚠ Сравнивать вместо токена claimed_at нельзя — Go даёт наносекунды, Postgres хранит микросекунды. Пины: pgstore.TestAnAttemptThatLostItsClaimCanNeitherAnswerForItNorTakeItAway, httpapi.TestEveryEndingPresentsTheClaimItWasGranted |
fixed(акт 5) | ревью акта 4 (2 линзы, воспроизведено на живом PG) |
| PD-301 | bug | major | internal/pgstore/events.go ReadStream, internal/httpapi/stream.go |
Поток говорил end раньше, чем появлялось дерево — то есть ровно Ф-56, ради которой он и строился. books.Parse коммитит FinishParse (parsing → not_started) и только ПОТОМ зовёт материализацию — два процесса движка на собственном бюджете. В этом окне «в покое» было истинно, насос слал end, а автоматический реконнект браузера получал 204 «не переподключайся»: клиент переставал смотреть ровно тогда, когда главы вот-вот появятся. Та же дыра на границе ПРОГОНА: прогон закрыт, текст ещё не материализован, готовый ОПЛАЧЕННЫЙ перевод до читателя не доезжает. Закрыто долгом (PD-302): «в покое» теперь означает «и материализация не должна». Пин: pgstore.TestABookThatOwesAReadingSurfaceIsNotAtRest; живая проба 20.08: при непогашенном долге поток НЕ шлёт end, реконнект отвечает 200 вместо 204 |
fixed(акт 5) | ревью акта 4 (замерено на живом PG) |
| PD-302 | bug | minor | internal/runs/reconcile.go (deferRefresh/DrainRefresh), internal/books/parse.go |
Долг на материализацию жил только в памяти процесса, и терялся двумя достижимыми путями: Refresh вернул ошибку — ветка логировала и НИЧЕГО не ставила обратно; демон перезапустился — слайс умер с процессом. В обоих случаях дерево от прошлой границы ЕСТЬ, поэтому единственный бэкстоп («дерева нет вовсе») книгу не видел, и текст оплаченного прогона не доезжал до читателя никогда. Закрыто ДОЛГОВЕЧНЫМ долгом: колонка books.read_model_owed_at (00019) ставится в транзакциях FinishParse и FinishRun, гасится по РАВЕНСТВУ метки (долг, поставленный позже, переживает материализацию), очередь берётся из БД. Механизмов стало на два меньше: runs.pendingRefresh/DrainRefresh/deferRefresh/Reader и books.materializeMissingTrees/BooksWithNoTree удалены, дрейн переехал в readmodel.Drain — пакет, чья это работа. Пины: runs.TestAFinishedRunLeavesItsBookOwingAReadingSurface, books.TestAParsedBookOwesAReadingSurfaceUntilOneIsMaterialized, pgstore.TestADebtStampedDuringAMaterializationSurvivesIt, readmodel.TestTheDrainMaterializesEveryOwedBookAndKeepsWhatFailed |
fixed(акт 5) | ревью акта 4 (2 линзы) |
| PD-303 | bug | minor | internal/readmodel/readmodel.go refreshStructure |
Упавшее чтение пар отчитывалось УСПЕХОМ: отказ Export только логировался, refreshStructure возвращал nil, и интейк считал материализацию состоявшейся — книга получала полное дерево пустых пар, а читатель не видел даже собственного исходника. Закрыто возвратом отказа вызывающему (errors.Join): неполная материализация НЕ гасит долг, и книга остаётся в очереди дрейна, пока один проход не ответит по всем трём каналам. Пин: readmodel.TestAPartialMaterializationDoesNotDischargeTheDebt |
fixed(акт 5) | ревью акта 4 |
| PD-304 | bug | minor | internal/pgstore/books.go ReadUsage |
Usage зажигал остановку АККАУНТА от потолка ОДНОГО прогона. Предикат сканировал paused_reason последнего прогона каждой книги, а credit_exhausted там значит «этот прогон потратил своё». Замерено: счёт $10, прогон на 10 глав ($0.30) — провод отвечал {"state":"ok","remaining_percent":97,…,"halt_reason":"credit_exhausted"}, то есть пользователю с деньгами говорили, что денег нет, рядом с процентом, говорящим обратное. Канон предупреждает об этом дословно (§AccountHaltReason). Закрыто чтением остановки С АККАУНТА: нечего тратить — остановлен, и ничем иным. Пины: pgstore.TestUsageIsAShareAndAnAccountWithNoGrantsIsExhausted, pgstore.TestAPauseTheReconcilerCausedIsVisibleOnTheRun; живая проба 20.08: $10 → halt_reason: null |
fixed(акт 5) | ревью акта 4 (замерено на проводе) |
| PD-305 | standards | minor | httpapi.TestTheClaimCarriesTheRequestsOwnOperation, pgstore.TestAnOverlongPathDoesNotBreakTheClaim, pgstore.TestASecondRunsBarStartsAtZeroOverAHalfFinishedBook, config.TestAnUploadDeadlineLongerThanEitherWindowIsRefused, httpapi.TestEveryCodeNamesExactlyOneStatus |
Пять пинов проходили под мутацией, которую сами называют — каждый проверен ревьюером исполнением. Причины разные и все поучительные: единственный гоняемый маршрут имел путь, совпадающий с паттерном · 4000 повторяющихся байт СЖИМАЮТСЯ в индексном кортеже и до предела btree не доходят · фикстура заканчивала ровно одну главу и покупала ровно одну, поэтому кламп не связывал · оба значения цикла нарушали ВТОРУЮ проверку, поэтому снятие первой ничего не меняло · statusOf(c) это буквально codes[c].status, то есть сравнение таблицы с собой (регрессия акта 4: слияние статуса и заголовка сделало пин тавтологией). Закрыты по-разному, но каждый — так, чтобы названная мутация падала: новый пин на маршрут, где путь ≠ паттерн · несжимаемый путь (падает index row size 4040 exceeds btree maximum 2704) · новый пин, где прогон покупает МЕНЬШЕ, чем книга успевает закончить · одна проверка против ТЕСНЕЙШЕГО окна вместо двух · сверка с ТРАНСКРИПЦИЕЙ канона §ErrorCode, а не с той же таблицей |
fixed(акт 5) | ревью акта 4 (все пять проверены исполнением) |
| PD-306 | bug | minor | internal/pgstore/readmodel.go noteCount |
note_count стоил 83% времени GET /v0/books — регрессия акта 4. Джойн на chapters добавили, чтобы счётчик и список замечаний описывали одно множество (PD-288), и решение не было замерено. Замер зоны на корпусе 40 книг × 500 глав (по 1000 замечаний, после vacuum analyze): 16.8 мс на страницу против 3.4 мс со счётчиком, заменённым литералом, из них 9 мс — сам джойн. Стоимость неустранима по форме: счёт идёт по КАЖДОМУ замечанию каждой книги страницы. Закрыто счётчиком на главе (00022, возвращает колонку, снесённую 00016 — вместе с писателем, которого ей не хватало): фолд unitDone и пере-расчёт writeChapters ведут его в тех же операторах, что и волновые счётчики, из тех же строк, а согласие счётчика со СПИСКОМ становится конструктивным — замечание, у главы которого нет строки, не имеет ни адреса на проводе, ни счётчика. Замер после: 6.6 мс на страницу. Пины: pgstore.TestTheCardsNoteCountAndTheNotesListDescribeOneSet, pgstore.TestAFlaggedUnitMovesTheChaptersNoteCounter, бенчмарк pgstore.BenchmarkLibraryPage |
fixed(акт 5) | ревью акта 4 (замерено) |
| PD-307 | bug | minor | internal/pgstore/credits.go CreditHeldBy |
blocked называл СТАРЕЙШИЙ холд, а не тот, что укоротил шкалу — регрессия акта 4. Решение «укорачивает ли» стало приниматься по СУММЕ чужих холдов (верно), а книга по-прежнему выбиралась order by opened_at limit 1. Замерено: $10, на книге A держится $0.03 (открыт первым), на книге B — $9.60; blocked называл A, и пользователь отменял прогон, который ничего не освободит. Закрыто выбором книги с НАИБОЛЬШЕЙ суммой холдов (group by book_id order by sum(...) desc, min(opened_at)) — ничьи решаются старшинством, чтобы ответ был устойчив между двумя чтениями. Пин: pgstore.TestTheHoldThatIsNamedIsTheOneThatWouldFreeTheMost |
fixed(акт 5) | ревью акта 4 (замерено) |
| PD-308 | standards | minor | internal/httpapi/v0.go startRun |
startRun отвечал 413 — кодом, которого канон у этой операции не объявляет (400/401/403/404/409/503), а §ErrorCode определяет payload_too_large как «свыше intake_max_bytes», то есть про ЗАГРУЗКУ. Соседняя JSON-запись на то же условие отвечала 400, значит одна из двух врала о том, что клиент сделал не так. Закрыто приведением к 400 invalid_request. Пин: httpapi.TestAnOverlongRunRequestIsInvalidAndNotTooLarge; живая проба 20.08 |
fixed(акт 5) | ревью акта 4 (2 линзы) |
| PD-309 | standards | minor | internal/httpapi/stream.go pump |
Границы дыры в потоке не были запинены на ОДИН кадр. emitFrame минтит по кадру за раз, поэтому обычная дыра у живого соединения шириной ровно в один кадр, а пин PD-283 гоняет дыру в 189 кадров — обе границы отвечают на такую одинаково, и сдвиг любой из них на единицу батарея не замечала. Закрыто табличным пином на четыре случая по обеим границам. Пин: httpapi.TestTheGapBoundariesAreExactAtOneFrame |
fixed(акт 5) | ревью акта 4 |
| PD-310 | doc | info | internal/pgstore/runs.go, internal/pgstore/books.go, internal/httpapi/problem.go, internal/books/render.go |
Проход по болтливости обрубил четыре комментария на середине — осиротевшая строка без подлежащего у EngineStreamID, непарная скобка и склеенное надвое предложение в WriteStatusProblem, удвоенная клауза в render.go, док-блок StuckIntake, приклеенный к чужой функции. Тот же дефект был в акте 3. Закрыто починкой всех пяти; правило записано там же, где ошиблись: при таком проходе читать РЕЗУЛЬТАТ целиком, а не только удаляемое |
fixed(акт 5) | ревью акта 4 (2 линзы) |
| PD-311 | hardening | minor | internal/config/config.go loadIntake |
Загрузочная граница дедлайна оставляла секунду. Проверка сравнивала дедлайн с окном и не оставляла ничего на работу ПОСЛЕ чтения тела: принимался 29m59s, а терминальные записи интейка идут на собственный бюджет и квитанция ключа пишется после них — клейм становился перехватываемым за мгновение до того, как его завершили. Плюс проверок было ДВЕ, и та, что слабее, не могла сработать никогда (ClaimStale теснее UploadGrace), а пин её «покрывал» вторым значением, которое ловила соседняя. Закрыто одной проверкой против ТЕСНЕЙШЕГО окна с явным запасом books.UploadSettle. Пин: config.TestAnUploadDeadlineIsRefusedUnlessTheWholeUploadFitsTheTighterWindow |
fixed(акт 5) | ревью акта 4 |
| PD-312 | bug | minor | internal/runs/reconcile.go reopen, internal/httpapi/v0.go |
Два РАЗНЫХ факта возвращались одним вердиктом exhausted: «прогон потратил свой потолок» и «счёт не тянет холд». Лечения у них противоположные — новый прогон против пополнения, — а ответ был один, поэтому пользователя с непотраченными главами отправляли покупать прогон, который ему не нужен. Разведено на ceilingSpent и creditUnavailable; второму контрактная сессия завела cause.code: credit_unavailable (второй уровень открыт, типов не двигает). Реконсилятор по-прежнему паузит оба одинаково — это не конфляция, а его работа: состояние, в котором владелец может действовать. Пин: runs.TestResumeOverAnEmptyAccountSaysTheCreditIsUnavailableAndNotTheCeiling (включая «пополнил — тот же вызов продолжает тот же прогон») |
fixed(акт 5) | вопрос владельца 20.08 → норма 0.4.0 (A) |
| PD-313 | standards | minor | internal/pgstore/books.go Run, internal/httpapi/project.go |
Клик пользователя «стоп» не доезжал до клиента. Столбец runs.stop_requested_at был, на проводе его не было, и никакой status его не заменяет: стоп не мгновенен, поэтому стоп, попросленный во время перевода, встречается с собственной остановкой прогона на подписи банка — прогон приезжает awaiting_bank, а этот статус предлагает ПРОДОЛЖИТЬ на клик, который значил «останови». Закрыто ОБЯЗАТЕЛЬНЫМ булевым Run.stop_requested (0.4.0, единственная ломающая правка), снимается при resume вместе с намерением. Побочно: три места читали Run тремя одинаковыми списками из четырнадцати колонок — сведены в runRow+scanRun. Пины: pgstore.TestTheStopIntentTravelsOnEveryRunAndAResumeClearsIt, httpapi (обязательность поля); живая проба 20.08 |
fixed(акт 5) | норма 0.4.0 (J) |
| PD-314 | standards | info | internal/pgstore/migrations/00021_drop_units_done.sql |
chapters.units_done был байт-в-байт дублем units_edit_done и писался двумя местами. После PD-202 читателей у него не осталось вовсе, а имя, читающееся как «сделано», над значением «отредактировано» — ровно та ловушка, из которой вырос PD-202. Колонка снесена вместе с обоими писателями |
fixed(акт 5) | уборка зоны при PD-202 |
Закрытые — самопроверка акта 5 (адверсариальное ревью собственного диффа, 20.08)
Ревью зоны против СОБСТВЕННОЙ работы акта 5: 9 линз × 2 рефутера + критик полноты, 84 агента, 0 ошибок. 37 находок, 18 пережили хотя бы одного рефутера, и часть из них — дефекты, которые внесли правки самого акта 5. Три из восемнадцати воспроизведены агентами исполнением на живом Postgres, две — посадкой мутации в дерево. Отдельно ценно то, что ревью поймало КЛАСС, ради которого этот пак его и гоняет: два пина, написанные в акте 5, проходили под мутацией, которую сами называют.
| ID | Класс | Серьёзность | Где | Суть | Статус | Источник |
|---|---|---|---|---|---|---|
| PD-315 | bug | major | internal/pgstore/sink.go FinishUnspawnedStop, internal/pgstore/runs.go PauseRun |
Транзакций, которые ЗАКАНЧИВАЮТ прогон, три, а долг на материализацию ставила одна. Собственная посылка акта («обе границы ставят его в транзакции, которая границу закрывает») оказалась ложной: пауза на потолке и стоп, приехавший на попытку без юнита, оставляли книгу finished_at-нутой и НЕ должной. Оба случая с текстом: прогон, вставший на потолке, перевёл всё до него; стоп на попытке, у которой сняли клейм спавна, ловит движок, который реконсилятор сам же и рассчитывает (спенд-базлайн там держится намеренно). Читателя нет — очередь дрейна ключуется этой колонкой, и на закрытую книгу больше не смотрит никто. Закрыто фрагментом owesAReadingSurface, который несут все три, и пином по ТАБЛИЦЕ из трёх концовок. Пин: pgstore.TestEveryEndingOfARunLeavesTheBookOwingASurface |
fixed(акт 5, самопроверка) | ревью акта 5, линза «долг» (2/2 рефутера не опровергли) |
| PD-316 | bug | major | internal/pgstore/readmodel.go finishedUnits |
Форма пайплайна читалась с ПОСЛЕДНЕГО прогона, а последний — самый НОВЫЙ, и только что допущенный не объявил ничего. На деплое без редактора chapters_done каждой книги падал в ноль в момент создания прогона и поднимался обратно на первом progress-событии — счётчик, которому канон запрещает ходить назад, и вместе с ним шкала покупки, которая в этот момент снова предлагает уже переведённое. Закрыто переносом факта на КНИГУ (миграция 00024, books.edit_wave): пишется объявлением движка через оба канала (поток и resync), монотонно — книга, прошедшая редактирующий пайплайн, остаётся такой. Пины: pgstore.TestAdmittingARunDoesNotWalkTheBooksProgressBackwards, pgstore.TestTheWaveShapeIsWrittenByTheEngineAndOnlyGrows |
fixed(акт 5, самопроверка) | ревью акта 5, линза «волны» (2/2) |
| PD-317 | bug | major | internal/pgstore/runs.go StartRun, RestartRun |
Базлайн полосы и её числитель считали РАЗНЫЕ проходы. Числитель научился про деплой без редактора, а chapters_before в обоих местах по-прежнему называл units_edit_done — второй прогон над уже начерновленной книгой открывался на своём потолке, и снятие стопа подписи открывало второй сегмент там же. Воспроизведено ревьюером исполнением. Закрыто одним выражением на оба места. ⚠ Первый написанный на это пин ПРОШЁЛ под своей мутацией — фикстура останавливалась на шаг раньше того места, где перекос виден. Пины: pgstore.TestASecondRunOnADraftOnlyDeploymentStillOpensAtZero, pgstore.TestLiftingTheStopOnADraftOnlyDeploymentRetakesTheRightBaseline |
fixed(акт 5, самопроверка) | ревью акта 5, линзы «волны» и «доки» |
| PD-318 | standards | major | internal/books/books_test.go, internal/runs/reconcile_test.go |
Два пина акта 5 проходили под мутацией, которую сами называют. Оба утверждали КОНЕЧНОЕ состояние («книга должна поверхность»), а названная мутация — вынос метки из транзакции границы во второй оператор — конечное состояние не меняет: меняется окно, в котором книга разобрана и не должна ничего, а колонка — единственный ретрай. Ревьюер посадил обе мутации и обе прошли всю батарею. Закрыто утверждением про АТОМАРНОСТЬ: xmin (транзакция, последней писавшая строку) у книги и у кадра, который та же транзакция выпустила, обязан совпадать. ⚠ Первая редакция этого пина в прогонах сравнивала книгу с самим ПРОГОНОМ и падала на зелёном дереве: расчёт штампует прогон ещё раз мгновением позже |
fixed(акт 5, самопроверка) | ревью акта 5, линза «пины» (исполнением, 2/2) |
| PD-319 | bug | major | internal/pgstore/idempotency.go ClaimIdempotency |
Клейм, проигравший гонку ОСВОБОЖДЕНИЮ, отвечал 500 — на том самом случае, ради которого заголовок существует. Две попытки приходят вместе; победитель вставки падает быстро (любой 4xx возвращает ключ немедленно), и пере-чтение проигравшего на свежем READ COMMITTED снапшоте не находит ничего. Это тот же 500, который двумя строками выше закрывал on conflict do nothing. Воспроизведено ревьюером на живом PG (6 из 12 гонщиков). Закрыто повтором клейма с начала: пропавшая строка — не ответ никому, ключ просто снова свободен. Пин: pgstore.TestAClaimThatLostARaceToAReleaseIsRetriedAndNotAnError |
fixed(акт 5, самопроверка) | ревью акта 5, линза «идемпотентность» (2/2) |
| PD-320 | bug | minor | internal/pgstore/books.go BooksOwedReadModel, internal/books/parse.go |
Дрейн получал книгу, которую интейк в этот момент материализует. Долг становится виден в момент коммита разбора, а его собственный плательщик только входит в чтение движка (до 5 минут); свип идёт каждые 15 секунд в том же процессе и клейма у списка не было. Любая книга, материализующаяся дольше интервала свипа, читалась движком ДВАЖДЫ одновременно — ровно та цена, которую этот же пак экономил RefreshCut-ом (PD-248). Закрыто арендой: ClaimReadModelDebt отодвигает срок долга, очередь берёт только то, что <= now(), интейк клеймит свой долг перед материализацией. Пины: pgstore.TestAClaimedDebtLeavesTheQueueUntilItsWindowLapses, readmodel.TestTheDrainSkipsABookSomebodyElseIsAlreadyMaterializing, books.TestTheIntakeClaimsItsOwnDebtBeforeMaterializing |
fixed(акт 5, самопроверка) | ревью акта 5, линза «SQL» |
| PD-321 | bug | minor | internal/readmodel/readmodel.go refresh |
Погашение долга шло на том же контексте, что и чтения движка, которые его исчерпали. На большой книге чтения законно съедают весь бюджет, и запись «сделано» после них не доезжает — материализация СЛУЧИЛАСЬ и не записана, поэтому следующий проход делает две полных ре-нарезки заново, и так каждый проход. Закрыто отвязанным коротким бюджетом — то же правило, по которому живут терминальные записи интейка. Пин: readmodel.TestTheDischargeSurvivesAContextTheReadsUsedUp |
fixed(акт 5, самопроверка) | ревью акта 5, зонд линзы «долг» |
| PD-322 | bug | minor | internal/readmodel/readmodel.go Drain |
Книга, про которую движок не может ответить никогда, держала голову очереди вечно. Очередь берётся старейшими долгами, поэтому четырёх таких книг (каталог, который оператор перенёс; проектная база, которую эта сборка не читает) хватало, чтобы всё, что за ними, не получило оплаченный текст вовсе. Закрыто переносом неоплаченного долга в КОНЕЦ очереди — с той же сверкой метки, что и погашение. Пины: pgstore.TestADeferredDebtGoesToTheBackAndNeverOverwritesANewerOne, readmodel.TestTheDrainMaterializesEveryOwedBookAndKeepsWhatFailed |
fixed(акт 5, самопроверка) | собственная сверка зоны при чтении своего же дрейна |
| PD-323 | standards | minor | internal/httpapi/v0.go createBook |
Повтор под завершённым ключом ИГНОРИРОВАЛ часть, присланную после файла, и отвечал 201, тогда как то же тело под свежим ключом — 400 missing_or_late. Одни и те же байты были законны или нет в зависимости от того, какой ключ на них надет, и это противоречило собственному док-комментарию маршрута. Закрыто проверкой хвоста и на пути реплея. Пин: httpapi.TestTheClaimsFourAnswersReachTheClient/another_FILE…; живая проба 20.08 |
fixed(акт 5, самопроверка) | ревью акта 5, линза «идемпотентность» |
| PD-324 | bug | minor | internal/pgstore/books.go ReadUsage |
Доля считалась только по строкам kind = 'grant', а на бете грант при регистрации НОЛЬ и оператор пополняет счёт через adjust. Замерено на проводе: счёт с $20, с которого можно стартовать прогоны, отвечал {"state":"low","remaining_percent":0} — та же «у вас нет денег» пользователю с деньгами, что и PD-304, с другого конца. Знаменатель стал «всё, что когда-либо ДОБАВИЛИ» (гранты и положительные корректировки); отрицательная корректировка остаётся на стороне трат. Пин: pgstore.TestTheShareCountsEveryWayCreditWasAdded; живая проба 20.08 |
fixed(акт 5, самопроверка) | ревью акта 5, критик полноты (взаимодействие двух правок, которого не видела ни одна линза) |
| PD-325 | doc | major | deploy/README.md §дев-стенд |
Загрузочный гейт пар, который завёл этот же акт, отвергал собственный рецепт стенда: копипаст-блок «Рецепт целиком» ставит BOOKS_DIR и ENGINE_BIN, но не TM_PLATFORM_LANGUAGE_PAIRS — демон не поднимался вовсе, и следующий шаг рецепта (tmplatformctl seed) бил в мёртвый порт. Боевой блок в том же файле пары получил, стендовый — нет. Это рецепт, которым поднимается ФРОНТ. Закрыто |
fixed(акт 5, самопроверка) | ревью акта 5, критик полноты |
| PD-326 | doc | info | internal/pgstore/events.go, internal/books/books.go, internal/httpapi/v0.go, internal/runs/reconcile.go |
Четыре комментария, лгущих о коде под ними — тот же класс, что PD-310, найденный тем же ревью в собственных правках акта: nothingIsRunning описывал материализатор как читающий его «по отрицанию» (полярность одна и та же, и инженер, действующий по комментарию, отправил бы дрейн читать движок ровно во время живого прогона) · BooksOwedReadModel утверждал, что чтение движка берёт проект эксклюзивно (ревьюер проверил ДВИЖОК: NewReadOnlyRunner открывает БЕЗ flock, намеренно) · canTranslate нёс две первых строки док-комментария · блок проекций объявлял себя «канон 0.3.0, поле в поле», уже содержа stop_requested, которого в 0.3.0 нет · ветка стопа банка всё ещё обещала, что резюм ждёт ПОЛНОГО набора решений — гейт, снятый D39.144 и удалённый из кода этим же паком |
fixed(акт 5, самопроверка) | ревью акта 5, линзы «доки» и «SQL» |
| PD-327 | standards | minor | internal/httpapi/idempotency.go, internal/httpapi/v0.go intakeFingerprint |
Тождество интейка теперь решается СОДЕРЖИМЫМ файла, а ратифицированный 0.3.0 сравнение по байтам запрещает («compared over the DECLARED parts — the metadata and the file's name and size — and never over the bytes themselves»). Новое поведение — это норма 0.4.0 (§E ответа контрактной сессии) и безопасная сторона: правило 0.3.0 и есть то, что позволяло двум разным книгам делить один ключ (PD-262). Строка заведена не как дефект, а как зависимость от ратификации: до лендинга 0.4.0 оркестратором зона отвечает 409 key_reused там, где действующий канон обещает реплей. Закрывается лендингом канона ⚠ ЗАКРЫТО P8-FIX: условие закрытия наступило — канон 0.4.0 РАТИФИЦИРОВАН (D39.152), а константа, которой деплой объявляет версию клиенту, поднята 0.3.0 → 0.4.0 (internal/httpapi/capabilities.go). Чтобы это не повторилось, поднятие ГЕЙЧЕНО: gates.TestTheAnnouncedContractVersionIsTheOneTheCanonRatified читает info.version из САМОГО канона, а не из копии числа — прежние тесты сверяли провод с константой и потому проходили при любом её значении (посадка «вернуть 0.3.0» гейтом ловится) |
fixed(P8-FIX) | ревью акта 5, линза «провод» |
Закрытые — эра P8-FIX (фикс-лист приёмки P7, 21–22.08)
Пак фикс-листа приёмки P7 (пинг оркестратора №18) плюс врезанный первым блокер выката, который в паке P7 не рождался и прожил два пака помеченным закрытым.
| ID | Класс | Серьёзность | Где | Суть | Статус | Источник |
|---|---|---|---|---|---|---|
| PD-169 | bug | BLOCKER | cmd/tmplatformd/runner.go свип, internal/runs/reconcile.go, internal/pgstore/runs.go |
ЭТА СТРОКА СОЛГАЛА ДВУМ ПРИЁМКАМ. Она стояла fixed(P5), а её пин (TestOneSlowRunDoesNotEatThePassOfTheWholeSweep) гоняет Sweep ВООБЩЕ БЕЗ дедлайна прохода — то есть доказывает пер-прогонный бюджет и по построению не может увидеть проход. Живое свойство: sweepBudget 2 мин на проход против defaultRunBudget 60 с на прогон = двух медленных прогонов хватает, чтобы съесть проход целиком, после чего ctx.Err() обрывает цикл и UnsettledRuns — единственный ретрай отложенного расчёта — не вызывается ВООБЩЕ. Не для этих двух книг: для ВСЕЙ инсталляции, каждый проход, потому что order by r.started_at ставит заклиненный прогон (по построению самый старый) в голову списка детерминированно. Цена: холды чужих аккаунтов не возвращаются, а книга с незакрытым прогоном не пускает новый (runs_one_live_per_book) — деньги заморожены, книга заморожена, пользователю видно «идёт». Ручки не было: runs.Config.RunBudget объявлен и НИКЕМ не присваивался, переменной окружения нет ни для одного из двух чисел, tmplatformctl не умеет ни закрыть прогон, ни вернуть холд — наблюдаемость (tm_platform_sweep_unfinished_total) росла, а сделать было нельзя ничего — ЗАКРЫТО P8-FIX четырьмя механизмами, а не подъёмом константы: (1) проход РАЗДЕЛЁН на фазы, реконсиляция получает половину и не может съесть расчёт (runs.phaseBudget); (2) прогон, ВЫБРАВШИЙ свой бюджет или упавший, получает отсрочку с растущим бэкоффом (run_attempts.reconcile_after, миграция 00025) и перестаёт держать голову списка — свип читает RunsToReconcile, а не ListLiveRuns; (3) после runs.StalledAfter неудач подряд прогон считается ЗАСТРЯВШИМ: гейдж tm_platform_runs_stalled и tmplatformctl runs --stalled называют строку, число неудач, последнюю ошибку и что она держит; (4) терминальный вердикт — ОПЕРАТОРА: tmplatformctl run abandon --run <id> [--release-hold], который отказывается закрывать прогон, чья попытка ещё называет юнит. Оба числа стали ручками (TM_PLATFORM_SWEEP_BUDGET, TM_PLATFORM_RUN_BUDGET — второе присваивается впервые). Пины: TestTheSettlementPhaseIsReachedWhenTheRunPhaseSpendsThePass (проход с НАСТОЯЩИМ дедлайном и ДВУМЯ зависшими прогонами — та самая арифметика) · TestARunTheSweepCannotFinishStopsHoldingTheHeadOfTheList · TestARunThatKeepsFailingBecomesTheOperatorsProblem · TestAbandoningAStalledRunIsRefusedOverALiveUnitAndAlwaysGivesTheHoldBack. Прежний пин P5 НЕ удалён: он доказывает другое, настоящее свойство |
fixed(P8-FIX) | второй рубеж приёмки P7 (пинг №18, врезан первым) |
| PD-328 | bug | minor | internal/httpapi/capabilities.go, internal/httpapi/stream.go |
Деплой объявлял клиенту версию контракта, которой не отдаёт: ContractVersion = 0.3.0 при формах 0.4.0 и ратифицированном каноне (D39.152). Пока мажор 0, различие МИНОРА несёт ломающие правки по замыслу, и клиент, сгенерированный под объявленную версию, ОТКАЗЫВАЕТСЯ работать. Практического вреда не было (фронт заморожен на 0.2.3), но это ровно тот класс, что живёт до первого выката. Тесты, которые «покрывали» константу, сверяли ПРОВОД С НЕЙ ЖЕ — self-consistent, проходят при любом значении (класс PD-1) — починено подъёмом до 0.4.0 плюс гейт против САМОГО канона (internal/gates/contract_test.go читает info.version из docs/architecture/14-api-contract/openapi.yaml); отсутствие канона = падение, а не скип |
fixed(P8-FIX) | фикс-лист приёмки, п. 1 |
| PD-329 | hardening | minor | internal/pgstore/events.go emitFrame |
Два поля, которые канон требует на КАЖДОМ кадре (EventBase), не были запинены ничем: снятие revision и structure_version проходило ВСЮ батарею (воспроизведено посадкой обеих мутаций 21.08 — 18 пакетов, exit 0). Асимметрия и есть причина: поля штампуются в ОДНОМ месте, поэтому каждый кадр получает их даром, и батарея, читающая кадры ради их собственных payload'ов, от этого не выигрывает ничего. Цена на проводе: клиент применяет кадр, только если его ревизия не ниже удерживаемой, — кадр без ревизии либо отбрасывается всяким читателем, либо применяется вне порядка — закрыто pgstore.TestEveryFrameCarriesTheBooksRevisionAndStructureVersion, написанным над БУФЕРОМ, а не над одним эмиттером: кадр нового вида покрыт в день, когда его заводят. Обе мутации ловятся |
fixed(P8-FIX) | фикс-лист приёмки, п. 2 (дыра найдена посадкой оркестратора) |
| PD-330 | bug | minor | internal/readmodel/readmodel.go Drain, internal/pgstore/books.go |
У долга материализации не было ни предела попыток, ни канала «признать безнадёжным»: claim → fail → DeferReadModelDebt пишет now() → свип через 15 с → вечно. «Конец очереди» был очередью из одного. Три следствия, каждое непрерывное: до 5 минут движковых процессов на проход навсегда · поток такой книги НЕ заканчивается НИКОГДА (AtRest требует read_model_owed_at is null) — браузер переподключается к ней вечно · при живом манифесте и падающем экспорте SaveStructure коммитится каждый проход ⇒ revision++ и кадр каждые 15 с о том, что ничего не изменилось. У интейка предел был всегда (parse_attempts=5) — закрыто бэкоффом с потолком плюс списанием после readmodel.maxAttempts (то же число 5 и по той же причине: один вопрос — один ответ). Сдаться здесь БЕЗОПАСНО в отличие от прогона: деньги той границы уже рассчитаны, теряется свежесть текста; и это не окончательно — следующая граница работы ставит свежий долг и обнуляет бюджет (owesAReadingSurface). Оператору: гейдж tm_platform_reading_surfaces_abandoned, tmplatformctl books --abandoned, tmplatformctl book refresh --book <id>. Миграция 00025 |
fixed(P8-FIX) | фикс-лист приёмки, п. 3 |
| PD-331 | bug | info | internal/runs/runs.go:62-65, cmd/tmplatformd/runner.go |
Ручка, которая существовала в структуре, была задокументирована и ничего не делала: runs.Config.RunBudget объявлен с доккоммментом про голодание, а startRunner его НЕ присваивал — единственным ограничителем одного прогона внутри прохода был пакетный дефолт, и ни один деплой не мог его изменить. Класс «мёртвое поле, читающееся как механизм» — присвоено, плюс TM_PLATFORM_RUN_BUDGET и TM_PLATFORM_SWEEP_BUDGET (оба печатаются на буте с источником, PD-114) |
fixed(P8-FIX) | фикс-лист приёмки, врезка про блокер свипа |
| PD-332 | bug | minor | internal/readmodel/readmodel.go refreshStructure, internal/ingest/manifest.go |
У материализатора не было ПОЛА на пустой манифест — латентная потеря всего текста книги. in.Chapters строился только из manifest.Chapters и ни разу не сверялся со счётчиками того же документа, лежащими рядом; пустой список едет в SaveStructure, где delete from chapters where book_id = $1 and not (id = any($2)) на пустом массиве истинен для ВСЕХ глав, каскад сносит текст, а при пустом Key уходят ещё и все unit_resolutions. Сегодняшним движком недостижимо, и именно поэтому опасно молча: ловится не сломанный движок, а документ, который ЭТА сборка прочла неверно (переименованный ключ декодируется аллоулистом в пустой список). Асимметрия и была находкой: на ИНТЕЙКЕ ровно этот случай отловлен и прибит мутацией с P6 — там «нет глав» это вердикт, УДАЛЯЮЩИЙ загрузку — закрыто ingest.Manifest.Whole(), зеркалом собственного правила движка (BookManifest.selfConsistent, backend/internal/pipeline/manifest.go:460), плюс отказ на нулевом счёте глав; оба входа (Refresh и RefreshCut интейка) под одним полом. Пины — readmodel.TestAManifestThatDoesNotDescribeItselfNeverReachesTheTree (четыре формы) и TestTheIntakesOwnCutIsHeldToTheSameFloor |
fixed(P8-FIX) | фикс-лист приёмки, п. 10 |
| PD-333 | hardening | minor | internal/pgstore/credits.go inReadTx |
Изоляция читающих чтений не была запинена ничем: снятие RepeatableRead+ReadOnly проходит ВСЮ батарею (воспроизведено посадкой 21.08), при том что под инвариантом лежат шесть ручек выдачи, а носитель прямо называет цену — «every frame in that window was lost for good» (PD-163): READ COMMITTED берёт снапшот НА ОПЕРАТОР, страница и ревизия, которой она подписана, приезжают из двух миров, и клиент, соблюдающий контрактное «отбрасывай чтение с меньшей ревизией», теряет всё, что материализовалось между ними — закрыто двумя пинами, потому что свойств два и падают они независимо: TestAReadingTransactionIsOneSnapshotAndRefusesToWrite проверяет ПОВЕДЕНИЕ против Postgres (чужой коммит невидим второму чтению; запись внутри = SQLSTATE 25006), а TestEveryPagedReadingHandleTakesTheReadingTransaction — что шесть ручек через него и ходят (ручка, переведённая на inTx, в тесте без конкурентного писателя читает столь же правильно). Четыре посадки, четыре поймано |
fixed(P8-FIX) | аудит 21.08, п. 12 фикс-листа |
| PD-334 | bug | minor, деньги | internal/pgstore/credits.go CreditHeldBy |
Оговорка and book_id <> $2 не исполнялась ни одним тестом: пин, который выглядит покрывающим, в своей фикстуре даёт исключаемой книге НОЛЬ холдов, поэтому мутант, снимающий оговорку, проходит батарею (воспроизведено 21.08). Сумма кормит контрактный blocked, то есть ответ на вопрос «почему шкала короче, чем аккаунт может себе позволить»: с собственным холдом книги в сумме экран приглашает пользователя погасить прогон ЭТОЙ ЖЕ книги ради ЭТОЙ ЖЕ книги — а холд уже вычтен из баланса, так что совет не может оказаться верным даже случайно — закрыто TestABooksOwnHoldIsNotWhatIsHoldingItDown с фикстурой, где у спрашиваемой книги холд САМЫЙ БОЛЬШОЙ, поэтому забывчивый ответ называет её же |
fixed(P8-FIX) | аудит 21.08, п. 13 фикс-листа |
| PD-335 | doc | info | internal/books/books.go canTranslate |
Задвоенная первая строка доккомментария («reports whether this deployment declares the pair» + «judges an upload against what this deployment declared») — класс PD-310/326, след прохода по болтливости. Снята одна из двух, вторая отделена пустой строкой от абзаца, который к ней и относится ⚠ Якорь фикс-листа указывал на pgstore/books.go:234-235; в дереве это internal/books/books.go:234-235 — файлов с этим именем два |
fixed(P8-FIX) | фикс-лист приёмки, п. 7 |
| PD-336 | doc | info | internal/pgstore/perf_test.go, internal/pgstore/migrations/00022_chapter_note_count.sql |
Один замер рассказан четырьмя носителями тремя разными числами, и ни одно не несло УСЛОВИЙ: «83%» (журнал, регистр PD-306), «96% of the page» (perf_test.go), «16.8 против 3.4, джойн сам 9 мс» (миграция 00022). Числа честны и меряют РАЗНОЕ: 96% — холодный, невакуумированный корпус акта 4 (636 мс против 24), 80–83% — вакуумированный корпус 40×500 — сведено указанием условий при каждом числе; нормативным для вакуумированных цифр остаётся миграция 00022, которая их и несёт вместе со своими условиями, остальные на неё указывают. Удалять «96%» как фантом НЕЛЬЗЯ — оно выводится из своего замера. ⚠ Расхождение с буквой пинга названо: пинг просил свести условия В миграцию 00022, но она РЕЛИЗНАЯ и гейт неизменности (migrations.sha256) её правку запрещает; правка гейта ради этого была бы подгонкой под зелень. Поэтому условия дописаны в носители, которые править законно |
fixed(P8-FIX) | фикс-лист приёмки, п. 9 |
| PD-337 | bug | info | internal/runs/reconcile.go phaseBudget |
Дефект СОБСТВЕННОЙ правки этого пака, найденный её же пином: доля фазы считалась абсолютным дедлайном от s.now() — инъектируемых часов сервиса, — тогда как контекст истекает по РЕАЛЬНОМУ времени. Фаза получала не половину прохода, а разницу между двумя часами: в пине это дало проход вдвое длиннее заказанного, на деплое с замороженных часов не бывает — но пин, замерявший длительность, поймал это до лендинга. Урок записан там же, где ошиблись |
fixed(P8-FIX) | самопроверка P8-FIX |
| PD-338 | bug | info | internal/pgstore/sqlgate_test.go |
Первая редакция гейта SQL проверяла не тот SQL: сбор именованных констант спускался ast.Inspect внутрь тел функций, а q — имя дюжины разных операторов в пакете, поэтому запрос мог быть просверен против ЧУЖОГО текста и пройти. Тот же класс, ради которого гейт и написан (PD-169: проверка, доказывающая свойство слабее объявленного). Поймано первым же прогоном самого гейта; область собрана из ТОП-УРОВНЕВЫХ деклараций плюс локальных констант функции |
fixed(P8-FIX) | самопроверка P8-FIX |
| PD-339 | bug | minor | internal/runs/reconcile.go reconcileOne |
Дефект собственной правки, менявший смысл всего механизма: отсрочка ключевалась на ОШИБКЕ реконсиляции, а самый частый клин — зависший tmctl status живого прогона — ошибки НЕ возвращает: maybeResync её глотает намеренно и правильно («a status call that fails is not a run that failed»). То есть прогон, выедающий бюджет молча, остался бы в голове списка навсегда — ровно случай, ради которого механизм строится. Найдено пином, не чтением. Условие теперь «ошибка ИЛИ выбран собственный бюджет», причём ctx.Err() == nil отделяет «прогон потратил своё» от «проход кончился», чтобы прогон не наказывался за чужую занятость |
fixed(P8-FIX) | самопроверка P8-FIX |
| PD-340 | bug | major | internal/pgstore/books.go truncateReason, internal/pgstore/runs.go DeferRun |
Дефект СОБСТВЕННОГО кода пака, и самоподрывной: обрезка причины отказа шла ПОБАЙТОВО. В эти колонки попадает первая строка stderr ДВИЖКА дословно и без ограничения длины (runner.firstLine), а продукт переводит с китайского и японского — сообщение, цитирующее текст книги, длинное и многобайтовое по природе. Срез через середину руны даёт невалидный UTF-8, Postgres такой text отвергает (SQLSTATE 22021) — и отказывает та самая запись, которая фиксирует неудачу: срок не сдвинут, счётчик не вырос, элемент снова в голове очереди, навсегда. То есть механизм пака воссоздавал бы ровно то голодание, которое чинит, своей же бухгалтерией. Плюс сырой stderr вообще не обязан быть валидным UTF-8 — поэтому починка это strings.ToValidUTF8 ПЕРЕД срезом и срез по границе руны. Пин — TestAnEnginesOwnErrorTextSurvivesBeingRecorded, и он утверждает не про строку, а про то, что Postgres её принимает: правило чужое, и тест, меряющий только строку, прошёл бы при любой кодировке. Обе мутации ловятся |
fixed(P8-FIX) | самопроверка P8-FIX, ревьюер контракт-конформности |
| PD-341 | bug | info | internal/pgstore/books.go AbandonReadModelDebt |
Два числа об одном факте: books --abandoned печатал на единицу меньше, чем лог рядом. Списание не инкрементило read_model_attempts, а строка лога берёт Attempts+1 — оператор видел «4 попытки» под сообщением «attempts=5». Попытка, исчерпавшая бюджет, — тоже попытка; счётчик теперь считает её. Пин — TestAWrittenOffDebtLetsTheStreamEndAndTheNextBoundaryBringsItBack (сверяет ПЯТЬ) |
fixed(P8-FIX) | самопроверка P8-FIX |
| PD-342 | hardening | minor | internal/pgstore/runs.go AbandonRun |
Контрактно видимый исход операторского вердикта не был закреплён ничем: мутант, кладущий status='stopped', failure_reason=null, проходил всю батарею. Канон требует, чтобы статус книги был статусом её текущего или последнего прогона («a client holding both never has to decide which wins»), так что расхождение — не косметика, а два ответа без правила выбора. Закрыто ассертом внутри TestAbandoningAStalledRunIsRefusedOverALiveUnitAndAlwaysGivesTheHoldBack: сверяются и статус прогона, и причина, и равенство статусу книги |
fixed(P8-FIX) | ревьюер контракт-конформности |
| PD-343 | hardening | minor | internal/httpapi/stream.go base |
Вторая половина EventBase не была закреплена ничем — и это ДРУГОЕ место, чем то, что чинил пункт 2 фикс-листа. Поля штампуются двумя независимыми писателями: исторические кадры — pgstore.emitFrame (запинен PD-329), СВЯЗНЫЕ (hello, resync_required, end) — httpapi.base. У второго покрытие было тоньше: единственный тест смотрел у hello только contract и structure_version, а end и resync_required не смотрел никто. Канон требует оба поля на КАЖДОМ кадре. Закрыто TestEveryConnectionFrameCarriesTheBooksRevisionAndStructureVersion по двум формам связи; посадка «base без revision» ловится |
fixed(P8-FIX) | ревьюер контракт-конформности |
| PD-344 | standards | info | internal/readmodel/readmodel_test.go, internal/ingest/manifest.go, internal/pgstore/sqlgate_test.go |
Три пина этого же пака измеряли МЕНЬШЕ, чем заявляли — тот самый класс, ради которого пак и существует: (а) рост бэкоффа материализации проверялся у ФУНКЦИИ, поэтому плоский retryIn(1) в её ВЫЗОВЕ переживал батарею; (б) книжная сверка UnitsTotal в Manifest.Whole() не проверялась — её случай неотличим от по-главной проверки только на первый взгляд: каждая глава может описывать себя верно, пока документ в целом называет другое число пар, а это ровно то, что делает переименованное поле; (в) таблица исключений гейта SQL ключевалась НОМЕРОМ СТРОКИ, то есть переехавший на неё вызов был бы проверен чужим текстом. Всё три закрыты; у (в) добавлен гейт «исключение, которое больше не нужно, — ошибка», чтобы мёртвая строка таблицы не прикрывала то, что под неё переедет |
fixed(P8-FIX) | ревьюер контракт-конформности |
| PD-346 | bug | BLOCKER | internal/runs/reconcile.go reconcileOne |
Механизм, ради которого написан весь пак, НЕ ВКЛЮЧАЛСЯ НА ЕГО СОБСТВЕННЫХ ДЕФОЛТАХ — и это было бы вторым PD-169 подряд. Отсрочка ставилась только если item.Err() != nil && ctx.Err() == nil, то есть «элемент выбрал СВОЙ бюджет, пока у фазы время ещё было». Но контекст элемента — ПОТОМОК фазового и создаётся ПОЗЖЕ, поэтому дедлайн фазы никогда не позже; на залендённых числах (проход 2 мин ⇒ фаза 60 с против бюджета прогона 60 с) это один и тот же миг. Квалификатор был ложен ровно тогда, когда прогон завис, срабатывала другая ветка — и зависший прогон записывался как УСПЕШНО сверенный, обнуляя накопленный счётчик. Механизмы 2, 3 и 4 (отсрочка · гейдж runs_stalled · tmplatformctl runs/run abandon) на любом дефолтном деплое были недостижимы, при том что ВСЕ их пины проходили: каждый из них выставлял бюджет прогона много меньше прохода. Найдено ревьюером, написавшим пин на РЕАЛЬНОМ соотношении — починено снятием квалификатора: истечение бюджета И ЕСТЬ неудача. То, ради чего квалификатор стоял (не наказывать прогон, оказавшийся последним в занятом тике), отдано осознанно по асимметрии: незаслуженная отсрочка стоит минуту и стирается первым же успехом, пропущенная — вечность; а прогон, обрезанный пять проходов подряд, это и есть голодание, о котором оператор обязан услышать. Пин — TestTheDeferralEngagesAtTheRatioThisZoneShips, написанный на shipped-соотношении и только на нём |
fixed(P8-FIX) | ревьюер «шов и деньги», волна 1 |
| PD-347 | bug | major | internal/pgstore/runs.go AbandonRun |
Новая операторская ручка брала блокировки ПРОТИВ глобального порядка (§22 books → runs → run_attempts → …): for update of r, a и только потом lockBook — ровно инверсия, которую lockBook называет причиной «258 дедлоков из 300». Замерено ревьюером: 7 сорванных транзакций на 60 конкурентных пар с FinishRun и 3 с PauseRun — обе на пути расчёта денег. Деньги не бились (кэш сходился с леджером), цена — сорванный проход свипа или отказ операторской команды сырым текстом Postgres. Починено: book_id читается без блокировки, книга блокируется ПЕРВОЙ, прогон и попытка пере-читаются под ней (та же форма, что у FinishRun) |
fixed(P8-FIX) | ревьюер «шов и деньги» |
| PD-348 | bug | major, деньги | internal/pgstore/runs.go AbandonRun |
Ручка могла закрыть прогон, чей движок ЖИВ, и отдать его холд целиком. Гейд читал только unit_name, а ReleaseSpawnClaim обнуляет имя, СОХРАНЯЯ spend_baseline_micro_usd — и RecordSpawn прямо пишет, что это надгробие возможно живого процесса («could not be created is not was not created»): systemd-run, убитый после того, как уже попросил, оставляет движок работать. Реконсилятор этот случай отрабатывает (finishStopped спрашивает systemd), новая ручка — нет. Итог по замеру ревьюера: status=failed, холд возвращён, прогон вне ListLiveRuns и UnsettledRuns — движок, если жив, тратит против книжного потолка без резервации и вне всех списков. Починено: гейд читает ОБА свидетельства, а сообщение называет выводимое имя юнита, потому что оператору идти к systemctl |
fixed(P8-FIX) | ревьюер «шов и деньги» |
| PD-349 | bug | minor | internal/pgstore/runs.go AbandonRun |
Протухший paused_reason оставался на failed-прогоне и уезжал на провод — канон 0.4.0 разрешает машинную причину только при status: paused («null otherwise»). Живой прогон получает её из журнала (событие ceiling), а ручка меняла статус и колонку не трогала; FinishRun ровно для этого держит nullif($5,''). Замерено ревьюером на проводе: status=failed paused_reason=credit_exhausted. Починено обнулением в том же операторе |
fixed(P8-FIX) | ревьюер «шов и деньги» |
| PD-350 | bug | major | internal/ingest/manifest.go Whole |
У нового пола не было свидетеля для полей-ИДЕНТИЧНОСТЕЙ — и это тот самый класс, ради которого пол написан. Пол сверял только списки, у которых рядом напечатан счётчик; у chapters[].id и units[].id счётчика нет, а derivedID берёт строку дословно. Переименование любого из двух ключей декодируется аллоулистом в "" для ВСЕХ строк, все они схлопываются в один производный id, и replacement-запись сносит остальную книгу — при документе, проходящем каждый объявленный им счёт. Замерено ревьюером: 3 пары → 1 строка, err=nil; 2 главы → 1 глава. Починено отказом на пустом id: движок пустого не выдаёт никогда (buildManifest), так что цена нулевая, и это единственная проверка здесь, которую счётчики сделать не могут |
fixed(P8-FIX) | ревьюер «шов и деньги» |
| PD-351 | bug | minor | internal/runs/reconcile.go, cmd/tmplatformd/runner.go pass |
Пак чинил класс и по дороге снёс единственный сигнал, которым этот класс виден. tm_platform_sweep_unfinished_total поднимается вызывающим по errors.Is(err, context.DeadlineExceeded), а разделённый на фазы Sweep начал возвращать nil в обеих фазах — счётчик, который PD-169 называет наблюдаемостью голодания, перестал мочь вырасти вообще. Вместе с PD-346 (гейдж застрявших оставался нулём) инсталляция теряла ОБА сигнала. Починено: фаза, не дошедшая до конца списка, возвращает обёрнутый DeadlineExceeded с числами «дошли до N из M»; расчётная фаза не дублирует счёт. Пин — TestAPassThatRanOutOfTimeSaysSo |
fixed(P8-FIX) | ревьюер «шов и деньги» |
| PD-352 | bug | info | cmd/tmplatformctl/runs.go listRuns, internal/pgstore/runs.go StalledRuns |
tmplatformctl runs без флага не показывал ни одного здорового живого прогона, хотя строка usage обещает «live runs and what the reconciler cannot finish»: пол листинга был 1 неудача. То есть на работающем деплое команда отвечала «no run is failing to reconcile» — и тот же ответ давала, пока прогон УЖЕ клинил, а отсрочка его ещё не посчитала. Плюс в строке не было spend_micro_usd: решение «вернуть холд ЦЕЛИКОМ» принималось вслепую к тому, сколько работы списывается (колонка писалась и не читалась нигде). Починено обоим; пин — TestTheRunsListingShowsLiveRunsAndNarrowsToStalledOnes на уровне КОМАНДЫ |
fixed(P8-FIX) | ревьюер «шов и деньги» |
| PD-353 | bug | BLOCKER | internal/runs/reconcile.go settlePhase, internal/pgstore/sink.go UnsettledRuns |
Блокер пака был починен на ПОЛОВИНЕ прохода. Разделение на фазы не даёт одной фазе съесть другую и ничего не делает с голоданием ВНУТРИ фазы, а список расчёта денег упорядочен так же — order by a.ended_at, старейший первым. Одна завершённая попытка, чей tmctl status не отвечает (проект на отвалившемся маунте — и ровно то состояние, которое оставляет run abandon), съедала весь бюджет расчёта каждый проход: холды ЧУЖИХ аккаунтов по уже законченным книгам не возвращались, пока тот проект недоступен. По ошибке это не ловилось: settle при нечитаемой трате возвращает nil намеренно и правильно, то есть клин рапортовал УСПЕХ, потратив проход — закрыто тем же механизмом, что и первая фаза: сигналом стало ИСТЕЧЕНИЕ БЮДЖЕТА, UnsettledRuns фильтруется отсрочкой, DeferRun перестал требовать живую попытку (множества фаз не пересекаются по ended_at). Пин TestASettlementNobodyCanFinishStopsHoldingTheHeadOfTheMoneyList; пин волны 1, закреплявший ОБРАТНОЕ («асимметрия списков»), развёрнут вместе с механизмом и это названо в нём вслух |
fixed(P8-FIX) | воркфлоу-ревью волны 2: ПЯТЬ независимых линз, включая Fable 5 |
| PD-354 | bug | major | internal/runs/reconcile.go reconcileOne, maybeResync |
Счётчик неудач не мог дорасти до собственного порога. Дорогой вопрос движку задаётся не чаще ResyncEvery (дефолт 5 мин) и пропускается совсем, пока движется поток, — поэтому прогон с зависшим status падал, получал отсрочку, возвращался через минуту на проход, который движка НЕ СПРАШИВАЛ, и обнулял счётчик этой тишиной. 1,0,1,0 навсегда: StalledAfter недостижим, гейдж пуст, бэкофф не растёт, оператору не говорят — то есть механизмы 3 и 4 пака были мертвы на любой инсталляции — закрыто правилом «счётчик снимается УЛИКОЙ, а не её отсутствием»: reconcile сообщает, установил ли проход хоть что-нибудь (журнал сдвинулся · движок ответил · попытка закончилась), и только тогда зовётся ClearRunDeferral. Пин TestTheFailureCountSurvivesThePassesThatAskTheEngineNothing |
fixed(P8-FIX) | воркфлоу-ревью волны 2 |
| PD-355 | bug | major, деньги | internal/pgstore/runs.go StalledRuns, cmd/tmplatformctl/runs.go |
Колонка SPENT операторской таблицы показывала пожизненную трату КНИГИ, а не этой попытки. В run_attempts.spend_micro_usd лежит цифра движка дословно, а она кумулятивна по книге за все прогоны (ingest.Spend), — то есть на второй книге оператор, решающий --release-hold, видел всё, что книга стоила с загрузки. Таблица заведена ровно ради этого решения — закрыто разностью с базовой линией (та же арифметика, что у attemptSpend, с тем же клампом), а попытка БЕЗ базовой линии печатает ?, а не ноль: это ровно тот случай, где settle отказывается считать деньги вообще |
fixed(P8-FIX) | воркфлоу-ревью волны 2 |
| PD-356 | bug | major | internal/pgstore/readmodel.go SaveStructure, internal/readmodel/readmodel.go |
Провалившийся экспорт на СМЕНЕ КАДРА стирал весь оплаченный текст книги. TextKnown бережёт пару только там, где её строка ВЫЖИВАЕТ: при смене кадра меняются все идентичности, старые строки удаляются каскадом, новые вставляются с тем, что принёс вызывающий, — то есть с пустотой, если манифест прочёлся, а экспорт нет. Дальше списание долга (PD-330) замораживало пустую поверхность навсегда, опровергая собственное обоснование «книга сохраняет прежнюю поверхность». Замерено ревью: тот же кадр — текст цел, новый кадр — source="", target="" — закрыто отказом: Structure.TextRead отличает «экспорт ответил пусто» от «экспорт не ответил», и разрушительная замена на второй не делается (ErrTextUnknownForANewCut), долг остаётся следующему дрейну |
fixed(P8-FIX) | воркфлоу-ревью волны 2 |
| PD-357 | bug | minor | internal/readmodel/readmodel.go giveUpOrRetry, internal/ingest/exit.go |
Бюджет попыток книги тратился на общехостовые отказы: ни одной ветки по ПРИЧИНЕ — подменённый бинарь движка, чужой лок проекта, немигрированная схема стоили книге попытку из пяти, а применимы они ко всем книгам хоста разом, так что несколько проходов списали бы всю библиотеку. Соседний пакет держит ровно эту норму и называет её вслух (books.waitsForTheDeployment) — закрыто предикатом ingest.DeploymentFault (там, где живёт контракт кодов выхода) и pgstore.AttemptCost: такой отказ откладывается с бэкоффом, но попытки не стоит. Пин TestABrokenDeploymentDoesNotSpendABooksAttempts (три формы) |
fixed(P8-FIX) | воркфлоу-ревью волны 2 |
| PD-358 | bug | minor | cmd/tmplatformctl/runs.go oneLine |
Заголовок книги из загрузки пользователя подделывал строки и колонки операторских таблиц, а stderr движка резался ПО БАЙТУ. Интейк вычищает управляющие символы только из заголовка, выведенного из ИМЕНИ ФАЙЛА, поэтому названный руками приезжает в таблицу с табами и переводами строк; продукт переводит zh/ja, так что срез по фиксированному смещению попадает внутрь трёхбайтовой руны примерно в двух случаях из трёх — закрыто заменой управляющих символов и срезом по границе руны. Пин TestTheOperatorsTableCannotBeForgedByABooksOwnText; ESC пинится на самой функции, потому что tabwriter съедает таб в отступ и подделку в выводе не видно. ⚠ Две линзы ревью разошлись во ВЕСЕ этой находки (одна сочла микрополировкой, вторая довела цепочку до многобайтового stderr движка); починено как дешёвое и бесспорное |
fixed(P8-FIX) | воркфлоу-ревью волны 2 |
| PD-359 | hardening | minor | internal/pgstore/books.go truncateReason |
NUL обходил починку PD-340: strings.ToValidUTF8 чинит невалидный UTF-8, а U+0000 валиден — и Postgres отвергает его тем же SQLSTATE 22021, то есть падает ровно та запись, которая фиксирует отказ. ⚠ Живого входа не найдено и это сказано прямо: движок держит NUL-гейт на всех ветках декода и не цитирует байты файла в сообщении об отказе; закрыта дыра в защите, а не воспроизведённый дефект |
fixed(P8-FIX) | воркфлоу-ревью волны 2 |
| PD-360 | bug | minor | internal/pgstore/sqlgate_test.go |
Собственный гейт пака давал ложную зелень: таблица исключений unresolvable ключёвана функцией, поэтому ВТОРОЙ несворачиваемый запрос в той же функции молча проверялся текстом первого и ещё и увеличивал отчёт «N statements planned» — подделка выглядела как рост покрытия. Бьёт по объявленному свойству самого гейта («запись пишется руками, значит второй такой сайт — чьё-то решение, а не тихая дыра») — закрыто счётом сайтов: запись покрывает ровно один, второй сообщается |
fixed(P8-FIX) | воркфлоу-ревью волны 2 |
| PD-361 | bug | minor | internal/pgstore/runs.go AbandonRun, cmd/tmplatformctl/runs.go |
--release-hold не мог изменить исход по деньгам, а его ветка оставляла прогон нерассчитанным навсегда. Гейд живости допускает к abandon только попытку, не дошедшую до движка, — значит она ничего не потратила и холд возвращается ЦЕЛИКОМ обычным расчётом на следующем проходе; флаг решает только КОГДА. При этом доккоммент обещал «холд остаётся открытым, пока есть шанс, что движок ответит» — то, что гейд уже сделал невозможным, — а ветка флага закрывала резервацию, минуя список расчёта, и settled_at не ставил никто — закрыто: обещание исправлено, ветка сама штампует settled_at, флаг оставлен и назван тем, чем является (нужен, когда демон остановлен — состояние, ради которого этот CLI и существует). Пин переименован по факту: TestAbandoningAStalledRunIsRefusedOverALiveUnitAndAlwaysGivesTheHoldBack ⚠ ПАК P8-REVIEW 24.08: пин этой строки доказывает возврат холда на прогоне, которого команда не обслуживает — он берёт прогон, созданный Start и брошенный сразу, у которого reconcile_failures 0 и reconcile_after NULL. У всей популяции, ради которой построен run abandon, отсрочка стоит, и холд ждёт до 30 минут. Вынесено строкой PD-391; статус паком не менялся ⚠ ОСПОРЕНО(PD-391) — греппируемый токен по предложению ревью старшей моделью: спор о том, закрыта ли эта строка, иначе живёт только прозой и тихо истекает, если приёмка пропустит вопрос. |
fixed(P8-FIX) | воркфлоу-ревью волны 2 |
| PD-362 | bug | minor | internal/runs/reconcile.go ranOut, reconcileOne |
Штатная остановка демона читалась как голодание инсталляции: обе фазы сообщали об исчерпании через DeadlineExceeded, а отмена контекста — тот же ctx.Done(), поэтому обычный рестарт поднимал tm_platform_sweep_unfinished_total и, этажом ниже, писал каждому недообработанному прогону отказ, которого у него не было — закрыто различением: голодание — только истечение СОБСТВЕННЫХ часов фазы. Пин TestAPassTheDaemonStoppedIsNotReportedAsStarvation |
fixed(P8-FIX) | воркфлоу-ревью волны 2 |
| PD-363 | bug | minor | internal/pgstore/runs.go RunsToReconcile |
Отсрочка не снималась НОВОЙ уликой: пользователь жал стоп, движок умирал, маркер лежал на диске — а прогон висел translating с зарезервированным холдом до конца бэкоффа (минуты на прогоне, отложенном пару раз), потому что свип — единственный читатель живых прогонов, а отсрочка есть утверждение о ПРОШЛОМ. Регрессия этого же пака: до него Sweep читал ListLiveRuns, и стоп отрабатывал на ближайшем тике — закрыто одним предикатом: интент на стоп перевешивает отсрочку |
fixed(P8-FIX) | воркфлоу-ревью волны 2 |
| PD-364 | bug | info | internal/pgstore/migrations/00025_stalled_work.sql |
Индекс, который не мог обслужить запрос, о котором его комментарий говорил «зеркалит». Замерено ревью: планировщик берёт существующий run_attempts_live_idx (ended_at is null), новый индекс не сканируется ни разу, стоит одну пустую страницу, а стоимость свипа ограничена числом ОДНОВРЕМЕННО живых прогонов, а не длиной истории — индекс УДАЛЁН, а не переописан; обе фазы свипа упираются в уже существующие индексы. ⚠ Миграция правится в СВОЁМ ЖЕ незакоммиченном паке (не выкачена нигде), отпечаток в migrations.sha256 пере-записан — это НЕ правка релизной миграции |
fixed(P8-FIX) | воркфлоу-ревью волны 2 |
| PD-365 | hardening | info | cmd/tmplatformd/runner.go |
Присвоение операторских ручек в демоне не было засвидетельствовано ничем: startRunner требует БД, шину systemd и очередь, поэтому его конфиг-литерал наблюдаем только прогоном всего демона — именно так RunBudget и прожил целый пак объявленным, задокументированным и равным нулю (PD-331). Снятие обеих строк оставляло батарею зелёной — закрыто выносом отображения в чистую runsConfig и пином TestTheOperatorsRunnerKnobsReachTheReconciler; посадка ловится |
fixed(P8-FIX) | воркфлоу-ревью волны 2 |
| PD-366 | bug | info | internal/pgstore/perf_test.go |
Комментарий лгал о коде под ним (класс PD-310/PD-326): условия замера объявляли, что бенчмарк этого файла воспроизводит ХОЛОДНЫЙ корпус, «потому что сеет и меряет без вакуума», — а corpus заканчивается vacuum analyze, то есть меряет ровно вакуумированный мир и его нормативные 16.8/3.4 — формулировка исправлена по факту |
fixed(P8-FIX) | воркфлоу-ревью волны 2 |
Закрытые — эра P11 (отзыв сессии на открытом потоке · застрявший расчёт · денежные пины)
| PD-379 | vuln | major | internal/httpapi/stream.go:160=h.pump(r.Context(), s, who, bookID, state, last, resuming) ⚠ якорь пере-нацелен паком P11: сигнатуру pump изменил он сам (принципал вместо голого id — в этом и лечение), internal/httpapi/server.go guard, internal/auth/session.go SessionStore | Открытый поток событий переживает и отзыв сессии, и оба её потолка: «выйти везде» не выключает уже установленный канал. Аутентификация происходит РОВНО ОДИН РАЗ, в auth.Authenticator.Require внутри guard; дальше streamEvents уходит в pump, и цикл до конца соединения читает только ReadFrames/ReadStream, строку сессии не смотрит ни разу. Значит POST /auth/logout, POST /auth/logout-all, tmplatformctl revoke и оба потолка (idle и абсолютный) уже открытый GET /v0/books/{bookId}/events не прекращают. Пере-проверено ТРИЖДЫ независимо: финдером, рефутером в отдельной копии и координатором пака — у координатора revoke --user отчитался «revoked 4 sessions», новый запрос той же кукой дал 401, а тот же поток через 9 секунд ПОСЛЕ отзыва отдал настоящий кадр данных event: status. На демоне с SESSION_IDLE=5s/SESSION_MAX_AGE=10s поток жив на +40 с. Бьёт по объявленной норме: ASVS 5.0 7.4.1 — требование УРОВНЯ 1 при объявленном зоной L2, и STACK_DECISIONS §13 отказывается от лимита одновременных сессий ИМЕННО в обмен на мгновенный отзыв. Довод СУЖЕНИЯ, который был снят поднятием веса ниже, гласил: кадры несут счётчики и идентификаторы и не несут текста, а соединение само закрывается, когда книга приходит в покой. ⚠ Побочно, тем же прогоном: /auth/logout-all на ДЕВ-профиле не смонтирован вовсе (404) — из пары ручек, которой §13 обосновывает свою политику, на стенде доступна одна. Воспроизведение: docs/p8-review/axis2-auth/sse-outlives-revocation.sh и sse-outlives-absolute-ceiling.sh, снимок координатора docs/p8-review/sse-outlives-revocation.txt ⚠ ВЕС ПОДНЯТ minor → major ПОСЛЕ РЕВЬЮ СТАРШЕЙ МОДЕЛЬЮ (fable-5), и поднят по трём доводам, которых сужение не учло. (1) Граница «соединение само закрывается, когда книга приходит в покой» — не гарантия кода: у книги, чей долг материализации списан как неоплатный, поток НЕ КОНЧАЕТСЯ НИКОГДА, и это собственный комментарий зоны — internal/pgstore/books.go:609=a book whose event stream can NEVER end. То есть окно утечки не ограничено прогоном. (2) Вес отказавшего КОМПЕНСИРУЮЩЕГО контроля наследуется от рисков, которые он компенсирует, а не от схемы кадра: STACK_DECISIONS §13 отказывается и от лимита одновременных сессий, и от собственной границы федеративной сессии ИМЕННО в обмен на мгновенный отзыв и два срока — а открытый поток ускользает от всех трёх разом, и у §13 не остаётся содержания. (3) Провалено требование УРОВНЯ 1 при объявленном зоной L2 — это дыра ниже собственного пола, а не отклонение от лучших практик. ⚠ Следствие, которое надо решить вместе с этой строкой: STACK_DECISIONS §13 становится доком, который ЛЖЁТ о коде («мгновенный отзыв» читается как факт), и эрратой это не помечено. Правку §13 пак не делал — это диспозиция приёмки. Цена лечения названа и она мала: pump и так ходит в базу раз в секунду (ReadStream с проверкой владельца на каждом тике), проверка живости сессии — одна выборка на биение. ⚠ Эта строка ОПРОВЕРГАЕТ прежнюю галочку зоны, и сказать это обязан именно пак: docs/archive/platform-PROGRESS-P0-P3.md:572 держит таблицу соответствия, где ASVS 5.0 V7 · 7.2.3, 7.2.4, 7.4.1, 7.4.2 (L1) отмечено выполненным со словами «отзыв прекращает использование». Против дерева это неверно для единственного длинноживущего канала платформы. Найдено интервальной самоверификацией пака ⚠ ЗАКРЫТО ПАКОМ P11 (29.08). auth.Principal несёт непубличную способность пере-спросить свою сессию, pump зовёт её ПЕРВЫМ ДЕЛОМ на каждом тике, отказ даёт терминальный кадр session_ended (канон 0.8.0) с watermark СОЕДИНЕНИЯ, а не головой истории. Запрос стора StillLive намеренно БЕЗ клаузы idle: окно бездействия скользит на запросе, а поток — один запрос на всю жизнь, поэтому гашение по idle рвало бы связь активному читателю. Доказано ДВУМЯ раздельными живыми сценариями: (а) длинные потолки + tmplatformctl revoke → поток кончился через 1 с (~/tm-p11/probes/a-revocation-ends-the-stream.txt); (б) idle 10 с / абсолютный 40 с, БЕЗ отзыва → поток пережил окно бездействия и кончился ровно на потолке (b-the-ceiling-ends-the-stream.txt). Посадки r_nocheck, r_idle, r_head, r_open, r_wirename — пойманы. Эррата STACK_DECISIONS §13 снята, галочка ASVS 7.4.1 в архиве восстановлена. ⚠ Остаток отдельной строкой: строку сессии удаляет часовой свип и по бездействию тоже, поэтому «строки нет» обязано значить «мертва» | fixed(пак P11 — лендинг оркестратора; статус проставлен зоной по норме §3.6 «закрытие дефекта — коммит + пинящий тест») | ревью-пак P8-REVIEW, ось 2 (живая проба, подтверждено рефутером и координатором) |
| PD-385 | bug | major | internal/pgstore/runs.go:464=where a.ended_at is null and a.reconcile_failures >= $1 ⚠ якорь пере-нацелен паком P11: прежняя строка (where r.finished_at is null and …) была ОДНИМ предикатом на обе половины и её больше нет — выборка разложена на две ветви, и это ровно лечение, internal/pgstore/observe.go (гейдж), internal/pgstore/runs.go AbandonRun | Прогон, чей РАСЧЁТ доведён до StalledAfter, не виден операторским поверхностям порога, а лог-строка на пересечении порога шлёт оператора именно туда. Обе фазы делят один счётчик через общий deferItem, но операторская половина построена только для ЖИВЫХ прогонов: StalledRuns джойнит a.ended_at is null и фильтрует r.finished_at is null, гейдж tm_platform_runs_stalled считает по тому же предикату, а AbandonRun читает where id = $1 and finished_at is null и отвечает ErrNoRun. Живая проба на состоянии, выращенном штатными путями (интейк, HTTP-старт, отказ спавна, run abandon): runs --stalled отвечает «no run is failing to reconcile», runs — «no run is live», run abandon — «is not a live run», гейдж 0, при этом в базе settled_at NULL, reconcile_failures 5 и открытая резервация на 90000 микро. Тот же слепой угол закрывает прогон, который ЖИВ, но чья ПРЕДЫДУЩАЯ попытка не рассчиталась после рестарта. ⚠ Рефутер опроверг заголовочный абсолют «невидим ВСЕМ поверхностям»: tmplatformctl balance --user печатает этот холд строкой, tm_platform_oldest_open_hold_seconds растёт без потолка, а tmplatformctl books показывает «WHY NOT: unsettled hold»; ноль в улике финдера был артефактом его фикстуры. Остаётся то, ради чего строка заведена: три поверхности ПОРОГА слепы, терминальной ручки для такого прогона нет, а ERROR на пороге называет команду, которая на нём молчит. Воспроизведение: docs/p8-review/axis3-queue/live-stalled-settlement.sh ⚠ ВЕС ПОДНЯТ minor → major ЗАКРЫВАЮЩИМ РЕВЬЮ СТАРШЕЙ МОДЕЛИ, и довод не про эту строку в одиночку, а про КРУГОВОЕ сужение четырёх строк пака. PD-384 сужен до minor тем, что холд «виден» гейджу tm_platform_oldest_open_hold_seconds и команде balance --user. Но PD-392 доказывает ЖИВОЙ ПРОБОЙ, что у этого гейджа ручки НЕТ: идентификатора он не даёт, balance --user требует аккаунт, которого гейдж не называет, глобального списка открытых холдов в CLI нет, а документированный случай самого гейджа это ровно данная популяция — при oldest_open_hold_seconds 10813 все три команды отвечают «no run is live», «no run is failing to reconcile», «no book has been given up on». PD-390 доказывает, что тот же гейдж умеет ЗАМИРАТЬ и отдавать нули как здоровье. PD-389 — что его сеттеры не запинены ничем. То есть каждое из четырёх сужений держится поверхностью, несостоятельность которой доказывает соседняя строка ТОГО ЖЕ пака, и по кругу. А терминальной ручки для этой популяции нет ПО ПОСТРОЕНИЮ: internal/pgstore/runs.go:567=select finished_at from runs where id = $1 ⚠ якорь пере-нацелен паком P11: прежняя строка отвечала ErrNoRun законченному прогону — она и была дефектом; теперь это ветвление, читаемое ПОД блокировкой книги отвечает ErrNoRun законченному прогону. Следствие, названное прямо: для всей популяции «закончен, но не рассчитан» деньги пользователя заморожены бессрочно, поверхности ПОРОГА слепы, ERROR на пороге называет команду, которая откажет, и единственный выход — сырой SQL в проде. Составной инвариант, на котором принят пак P8-FIX (D39.154: гейдж плюс runs --stalled плюс run abandon как ответ на PD-169), для этой популяции ЛОЖЕН ЦЕЛИКОМ — а «решается до следующего пака» есть определение major-секции самого регистра. Носителем major сделана ЭТА строка как самая полная по улике (живая проба на состоянии из штатных путей плюс отказ ручки); PD-384 и PD-392 несут ссылку сюда, чтобы не плодить второй major на тот же корень ⚠ ЗАКРЫТО ПАКОМ P11 (29.08). Популяция «кончился, а деньги нет» вошла в StalledRuns (колонка PHASE), в гейдж tm_platform_runs_stalled и получила терминальную ручку: run abandon закрывает КАЖДЫЙ осиротевший холд прогона, снимает отсрочку и штампует settled_at; допуск сужен до reconcile_failures >= 1, иначе команда отдавала бы целиком холд расчёта, который просто ещё не закрылся. Доказано до/после на состоянии из ШТАТНЫХ путей (интейк → HTTP-старт → спавн → выход движка → снят запиненный бинарь): было «no run is live» / «is not a live run» / гейдж 0 при открытой резервации 90000 микро, стало строка settling с холдом и возврат денег целиком (~/tm-p11/probes/pd385-before.txt, pd385-after.txt). ⚠ Вторая названная строкой популяция — ЖИВОЙ прогон с нерассчитанной ПРЕДЫДУЩЕЙ попыткой — получила обе поверхности видимости, но не ручку: отдельной строкой | fixed(пак P11 — лендинг оркестратора; статус проставлен зоной) | ревью-пак P8-REVIEW, ось 3 (живая проба, сужено рефутером) |
| PD-376 | bug | minor, деньги | internal/pgstore/runs.go:899=select min(a.spend_baseline_micro_usd), пин internal/runs/sweep_test.go TestADeferredSettlementIsNotChargedForTheNextRunOfTheSameBook | PD-159 стоит fixed, а её пин доказывает свойство СЛАБЕЕ, чем читается: слово min не исполняет никто, и мутация min → max проходит батарею. Строка PD-159 закрывает двойную оплату формулой «SpendBound — НАИМЕНЬШАЯ базовая линия среди попыток этой книги, стартовавших ПОЗЖЕ», а названный ею пин кладёт в книгу РОВНО ОДНУ более позднюю попытку — на множестве из одного элемента min и max совпадают. Прогон ./internal/pgstore/ и ./internal/runs/ под мутацией зелёный; независимый пин на той же мутации падает (the bound is 0.500000, want 0.200000), то есть мутация поведенческая, а не эквивалентная. Достижимость: при монотонном росте книжного счётчика min и max расходятся уже при ДВУХ более поздних попытках, а две даёт один преемник, переживший рестарт или резюм; тогда границей становится базовая линия, УЖЕ содержащая трату предыдущего прогона — это ровно PD-159 на одну попытку дальше. Переплата ограничена холдом. Класс — «реестр умеет врать», тот же разбор, каким был найден PD-169. Готовый пин: docs/p8-review/axis1-money/a1_spendbound_test.go.txt и независимый r1_spendbound_test.go.txt. ⚠ Статус PD-159 этим паком НЕ менялся: пере-открывать её или оставить закрытой с этой строкой как носителем живого пробела — диспозиция приёмки ⚠ Пере-проверено на ПОЛНОЙ батарее координатором пака (мутация M11): первая улика была снята на пакетном сабсете ./internal/pgstore/ ./internal/runs/, и упрёк «сабсет слабее полной батареи» справедлив. Прогон go test ./... -count=1 со всеми тремя гейтами под той же мутацией даёт ПУСТУЮ дельту против чистой базовой линии той же копии — то есть мутацию не ловит ни один из 18 пакетов. Лог — docs/p8-review/mutations-round2.log ⚠ ЗАКРЫТО ПАКОМ P11 (29.08). Взят готовый пин пака — вариант r1_ как более сильный (ходит настоящими дверями StartRun/RecordSpawn, наименьшая базовая линия стоит В СЕРЕДИНЕ, так что «первая поздняя» и «последняя поздняя» тоже падают) — и усилен ВТОРОЙ книгой того же аккаунта, чтобы исполнялся и фильтр r.book_id. Посадка min→max: чистая копия EXIT=0, с мутацией EXIT=1, единственный красный — этот пин. ⚠ ДИСПОЗИЦИЯ, которую строка оставляла приёмке: PD-159 НЕ пере-открывается. Основание прежнее (D39.159 §5): дефект из кода ушёл, недоставало ПИНА, — а теперь пин есть, то есть пробел закрыт там, где он был. Двусторонняя ссылка сохраняется: PD-159 несёт ОСПОРЕНО(PD-376), эта строка называет PD-159 | fixed(пак P11 — лендинг оркестратора; статус проставлен зоной) | ревью-пак P8-REVIEW, ось 1 (посадка мутации, подтверждено рефутером собственным пином) |
| PD-384 | bug | minor | internal/runs/reconcile.go:262=case overran: (settleOne) ⚠ якорь пере-нацелен паком P11: прежнее if !overran { было САМИМ дефектом — судить по цене вместо вердикта — и заменено свитчем по вердикту, internal/runs/reconcile.go settle (три тихих return nil) | Расчёт денег, упавший ДЁШЕВО, не считается никогда: порог StalledAfter для него недостижим. Вторая фаза считает неудачу ТОЛЬКО по исчерпанию бюджета (overran := errors.Is(item.Err(), context.DeadlineExceeded), дальше if !overran { return }), а settle возвращает nil БЫСТРО в трёх случаях: движок не ответил, в отчёте нет committed, попытка без базовой линии. Каждый может быть ПОСТОЯННЫМ — запиненный бинарь движка снесён при выкате, проект заменён под платформой, попытка старой схемы. Тогда цикл вечен: reconcile_failures остаётся 0, reconcile_after NULL, гейдж и tmplatformctl runs --stalled пусты, холд заморожен. Замерено пробой: пять проходов одного нерассчитываемого прогона дали 5 вызовов движка, reconcile_failures=0, StalledRuns(5)=0. Плюс цена: settle зовёт tmctl status НА КАЖДОМ проходе без рейт-лимита, тогда как соседний maybeResync имеет dueForResync ровно из-за этой цены. ⚠ Рефутер сузил вес major → minor: холд ВИДЕН двум поверхностям, которых финдер не спросил — гейдж tm_platform_oldest_open_hold_seconds и tmplatformctl balance --user, печатающий каждый открытый холд суммой, книгой и id прогона; плюс каждый проход пишет WARN с id прогона. Воспроизведение: docs/p8-review/axis3-queue/probe_settlement_surface_test.go.txt ⚠ Общий корень с PD-385, и там же он взвешен: сужение ЭТОЙ строки опирается на операторскую поверхность, несостоятельность которой доказывает соседняя строка того же пака — круговое сужение разобрано в PD-385, поднятой до major ⚠ ЗАКРЫТО ПАКОМ P11 (29.08). Неудачей считается ВЕРДИКТ расчёта, а не только исчерпание бюджета: settle вернул три состояния (закрыт · гонка · не вычислим), settleOne судит по ним, поэтому все три тихих return nil теперь доходят до порога. Цена, названная строкой, закрыта тем же ходом: отсрочка ограничивает tmctl status вместо вызова каждым проходом. ⚠ Первая неудача НЕ откладывается — открытая резервация это ворота РЕЗЮМА пользователя (reopen отказывает, пока холд предыдущей попытки открыт), и минута ожидания после секундной аварии была бы регрессом; бэкофф идёт со второй и капнут пятью минутами, а не тридцатью. Посадка r_firstfast | fixed(пак P11 — лендинг оркестратора; статус проставлен зоной) | ревью-пак P8-REVIEW, ось 3 (проба на реальном сторе, сужено рефутером) |
| PD-391 | bug | minor | internal/pgstore/sink.go:696=where a.reconcile_after is null or a.reconcile_after <= $1, internal/pgstore/runs.go AbandonRun, cmd/tmplatformctl/runs.go (сообщение), deploy/README.md | run abandon не возвращает холд «на ближайшем свипе»: отсрочка застрявшей попытки остаётся, и деньги ждут до 30 минут. AbandonRun завершает прогон и попытку, но run_attempts.reconcile_after не трогает, а UnsettledRuns по нему фильтрует. Застрявший прогон по построению всегда отсрочен: deferItem ставит now + backoff(failures+1), а backoff при пяти неудачах упирается в потолок 30 минут. Значит для ВСЕЙ популяции, ради которой команда построена, обещание CLI «its hold comes back whole on the next sweep» и та же фраза рантбука ложны: кредит остаётся вычтенным, tm_platform_oldest_open_hold_seconds продолжает расти ПОСЛЕ действия оператора, и оператор читает это как «я сделал, не помогло». Пин PD-361 доказывает свойство слабее: он берёт прогон, созданный Start и брошенный СРАЗУ, у которого reconcile_failures 0 и reconcile_after NULL. Живая проба на состоянии, выращенном штатным механизмом: через 45 секунд и три свипа холд открыт, balance печатает «reserved 3.000000», гейдж 2439 с; ручной update run_attempts set reconcile_after = now() закрывает холд в тот же свип. Лечится одной строкой в той же транзакции — снять отсрочку вместе с терминальным вердиктом. Воспроизведение: docs/p8-review/axis4-metrics/20-abandon-keeps-the-hold.sh и r3-abandon-hold.sh ⚠ ЗАКРЫТО ПАКОМ P11 (29.08). AbandonRun снимает reconcile_after в ОБЕИХ ветках, живой и расчётной. Пин растит прогон до пяти неудач штатными путями и сверяет холд ПОСЛЕ свипа на НЕДВИНУТЫХ часах — то есть исполняет ровно то обещание CLI, которое было ложным. Названные строкой носители обещания исправлены: deploy/README.md и сообщение команды. Посадка m391_defer — поймана топично | fixed(пак P11 — лендинг оркестратора; статус проставлен зоной) | ревью-пак P8-REVIEW, ось 4 (живая проба, подтверждено рефутером на состоянии из штатного пути) |
| PD-397 | hardening | info | internal/pgstore/credits.go:52-53=A ledger row is never edited: the correction is another row, internal/pgstore/credits.go:399=The two are never written apart, миграция internal/pgstore/migrations/00007_credits.sql | Два самых сильных денежных инварианта объявлены ПРОЗОЙ и держатся ТОЛЬКО кодом — схема их не навязывает. Adjust обещает «леджер не правится, коррекция это ещё одна строка, и именно это делает сумму воспроизводимой»; appendLedger обещает «кэш и леджер никогда не пишутся врозь, потому что отстающий кэш — это второй ответ про деньги». Проба прямым SQL по стенду показывает, что DDL допускает нарушение обоих: UPDATE и DELETE строки леджера ПРИНЯТЫ, кэш баланса выставляется ЛОЖЬЮ и ОТРИЦАТЕЛЬНЫМ тоже. Пере-проверено координатором пака независимо от агента — все четыре приняты, и откат пробы сам же оставил расхождение кэша с леджером в 1 микро-доллар, которое поймало только сведение двумя путями, а не база. ⚠ Что схема при этом ДЕРЖИТ и что находкой НЕ является (иначе строка читается как «денежных констрейнтов нет»): знак по каждому виду строки, обязательная нота у коррекции, закрытый словарь видов, непустые source/source_id, уникальность ключа идемпотентности в пределах аккаунта, положительность сумм резервации, согласованность состояния и времени закрытия, владение книгой через композитный внешний ключ, и переполнение bigint в кэше. То есть DDL закрывает ФОРМУ строки и не закрывает ИСТОРИЮ. Цена названа и она не про сегодняшний код: пути правки леджера в Go нет, поэтому эксплуатации нет — опасны миграция данных, операторский psql и будущий инструмент, каждый из которых по построению идёт мимо кода, а прозу в доккомментарии не читает. Лечится либо триггером на update/delete по credit_ledger, либо явной записью «append-only — дисциплина кода, не схемы» рядом с обещанием. ⚠ Заведено ЗАПОЗДАЛО и это отдельный факт: работа была сделана агентом оси 1 по прямому требованию промта («попробуй нарушить каждый прямым SQL; констрейнт, которого нет, это находка»), артефакт docs/p8-review/axis1-money/constraint-probe.out лежал в сдаче, а строки не имел — нашёл редакторский аудит полноты. Воспроизведение: docs/p8-review/axis1-money/constraint-probe.sh ⚠ ЗАКРЫТО ПАКОМ P11 (29.08), и закрыто ВТОРЫМ из двух предложенных строкой способов. Триггер на update/delete по credit_ledger ОТКЛОНЁН с двумя основаниями, проверенными в дереве: он сработает на КАСКАДНОМ удалении пользователя, которое миграция 00007 объявляет границей append-only, и сломает законную фикстуру TestAReleaseWhoseKeyWasSpentIsRefusedRatherThanSilent, которая правит леджер намеренно. Вместо него: проза Adjust и appendLedger сделана честной («держит КОД, а не схема», с перечнем того, что схема ДЕРЖИТ), плюс ГЕЙТ TestTheLedgerIsAppendOnlyInTheCodeThatWritesIt — ни один update/delete по credit_ledger (в том числе схемо-квалифицированный) не написан ни в одном из 172 SQL пакета. Посадка r_ledgeredit настоящей формой (tx.Exec внутри appendLedger). ⚠ Что осталось НЕзакрытым и названо: миграция данных, операторский psql и будущий инструмент идут мимо пакета по построению | fixed(пак P11 — лендинг оркестратора; статус проставлен зоной; ⚠ закрыт РАЗБОРОМ с отказом от триггера, см. тело) | ревью-пак P8-REVIEW, ось 1 (проба агента, пере-проверена координатором; строка заведена по аудиту полноты) |
| PD-394 | hardening | info | internal/pgstore/credits.go:225=if spent < 0 {, констрейнт credit_ledger_sign в internal/pgstore/migrations/00007_credits.sql | Гард отрицательного расхода в Settle не запинен: снятие проходит ПОЛНУЮ батарею (18 пакетов). Класс тот же, что у PD-333/PD-334 — оговорка денежного пути, которую ни один тест не исполняет. Цена НАЗВАНА и она ограничена схемой, а не кодом: отрицательный spent дал бы settlement с положительной суммой, а это ловит констрейнт credit_ledger_sign — проверено прямым INSERT на стенде, Postgres отвечает violates check constraint "credit_ledger_sign". То есть сегодня вреда нет, и защита ТРАНЗИТИВНА: держит её схема, а не гард, который для этого написан. Родня PD-86 (там потолок сессии держится через соседнюю функцию). Достижимость самого отрицательного значения сегодня нулевая — единственный источник attemptSpend клампит в ноль, и этот кламп запинен. Воспроизведение: docs/p8-review/plant.py (мутация M4) и mutations-full.log ⚠ Улика воспроизводима из артефактов: plant.py знает эту мутацию под именем M4, лог — docs/p8-review/mutations-full.log ⚠ ЗАКРЫТО ПАКОМ P11 (29.08). Гард получил ИМЯ (ErrNegativeSpend) и пин на errors.Is. ⚠ Имя понадобилось не для красоты: ПЕРВАЯ редакция пина проверяла лишь «вернулась ошибка» — и посаженная мутация её прошла, потому что ошибку вернул констрейнт credit_ledger_sign, то есть ровно та транзитивная защита, о которой строка и написана. Посадка r_negative на исправленном пине — поймана | fixed(пак P11 — лендинг оркестратора; статус проставлен зоной) | ревью-пак P8-REVIEW, ось 1 (посадка мутации координатора) |
| PD-414 | bug | minor, деньги | internal/pgstore/credits.go Settle (appendLedger для run_settle) | Settle ВЫБРАСЫВАЛ флаг applied своей леджер-записи — единственный из трёх вызовов appendLedger, который его не читал. Соседи проверяют: holdTx отвечает ErrDuplicateHold, releaseHold — ErrReleaseKeySpent С ОТКАТОМ (PD-97). Здесь потраченный ключ run_settle НЕ СПИСЫВАЛ НИЧЕГО, при том что холд уже возвращён целиком, а вызывающему возвращался nil: аккаунт получает работу даром. ⚠ Достижимость сегодня НУЛЕВАЯ, и это записано, чтобы приёмка не искала траекторию: резервация закрывается под state = 'open', поэтому второй Settle получает ErrNoReservation и сюда не доходит, а потратить ключ можно только пере-открыв резервацию на той же попытке — что holdTx отказывает ровно по этой причине. Класс — ровно PD-394: неисполняемая сегодня оговорка денежного пути. Найдено самопроходом пака P11 (линза денег), не строкой заказа ⚠ ЗАКРЫТО ПАКОМ P11 (29.08): флаг читается, новый сентинел ErrSettlementKeySpent, откат как у releaseHold; пин TestASettlementWhoseKeyWasSpentIsRefusedRatherThanSilent, посадка r_applied | fixed(пак P11 — лендинг оркестратора; статус проставлен зоной) | самопроход пака P11 (§4.5, линза денег под гонкой) |
| PD-415 | doc | minor | docs/STACK_DECISIONS.md «Стенд разработчика», internal/runner/translate_resnapshot_live_test.go | Рецепт стенда давал КРАСНУЮ батарею, а не скип, и красноту эту следующая сессия принимала за свою поломку. Рецепт рендерит шаблон книги из backend/example/book.yaml, а тот указывает на configs/pipeline-c1.yaml — ПЛАТНЫЙ DeepSeek. Живой тест P10 TestTheSnapshotGuardIsLoudWithoutTheFlagsAndPassesWithThem, включаемый вторым гейтом, гоняет настоящий движок и падает tmctl: missing API keys (fill in backend/.env) — при том что его собственный комментарий обещает «free of provider keys and of paid calls». Тест прав: у стенда есть $0-пара (local-qwen3-8b, провайдер на 127.0.0.1:11434, заглушку тест поднимает сам), но в репо НЕТ пайплайна, который бы её называл. ⚠ Цена не только во времени: гейт гоняет НАСТОЯЩИЙ движок, то есть платный пайплайн в шаблоне — ещё и риск оплаченных вызовов ⚠ ЗАКРЫТО ПАКОМ P11 (29.08): раздел переписан — три гейта вместо двух, замеренные числа (0 скипов с гейтами, 287 без на HEAD и 304 на дереве пака) и рецепт $0-шаблона, который обязан лежать РЯДОМ с prompts/ (промпты резолвятся от каталога пайплайна). Пере-проверено чужими руками: оркестратор при приёмке наступил на ту же граблю и вышел по предупреждению за минуту | fixed(пак P11 — лендинг оркестратора; статус проставлен зоной) | пак P11, сборка стенда |
| PD-416 | bug | minor | internal/pgstore/sqlgate_test.go usedException | Дефект, который пак P11 ВНЁС в чужой гейт, и который поймала его же мутационная обвязка. Счётчик исполнения исключений unresolvable был ПАКЕТНОГО уровня, а collectSQL с приходом второго гейта (TestTheLedgerIsAppendOnlyInTheCodeThatWritesIt) стал вызываться дважды за прогон: общий счётчик читал первый визит ВТОРОГО вызывающего как второй визит ПЕРВОГО и падал с «Ready holds a second statement this gate cannot read» о функции, которая держит ровно одно. Проявлялось как КРАСНЫЕ ЧИСТЫЕ копии в мутационной кампании там, где копируемое дерево было зелёным, — то есть маскировало дельту, что для кампании худший исход. ⚠ ЗАКРЫТО ПАКОМ P11 (29.08): счётчик стал per-extraction (newExceptionCounter), инвариант «одно исключение — один сайт, каждое исключение исполнено» сохранён полностью | fixed(пак P11 — лендинг оркестратора; статус проставлен зоной) | мутационная обвязка пака P11 |
| PD-417 | bug | minor | internal/pgstore/runs.go StalledRuns, internal/pgstore/observe.go, миграция 00028_settling_attempts.sql | Расширение операторских выборок на вторую популяцию (PD-385) оставило их без индекса: последовательный проход по всем попыткам на запросе, который рантбук советует для cron, и на гейдже, который демон гоняет каждые 15 секунд вечно. У живой половины индекс был с 00009 (run_attempts_live_idx, частичный по ended_at is null), у settling-половины — никакого. Замер на 200 000 попыток, explain (analyze, buffers), таблица 2×2: без индекса плохи ОБЕ формы (3647 буферов дизъюнкцией, 3653 через UNION ALL), с индексом хороши обе (10 и 18). То есть катастрофу снимает ИНДЕКС, а не форма запроса — первая редакция комментария приписывала заслугу форме, и это исправлено в трёх носителях (код, текст миграции, отчёт). UNION ALL оставлен по другому доводу: каждая ветвь несёт путь доступа СТРУКТУРНО, тогда как BitmapOr — выбор планировщика по статистике, а статистика ЗДОРОВОГО деплоя (пустая застрявшая популяция) ровно обратна той, на которой мерилось. Класс — PD-364 с другой стороны: там индекс не мог обслужить свой запрос, тут запрос перерос свои индексы ⚠ ЗАКРЫТО ПАКОМ P11 (29.08) миграцией 00028 (частичный индекс по ended_at is not null and reconcile_failures >= 1) плюс двумя ветвями | fixed(пак P11 — лендинг оркестратора; статус проставлен зоной) | самопроход пака P11 (линза цены в БД), пере-замерено по требованию артефакта |