From 21de1779baae77b58b640cb6ac0b2efafeddbde3 Mon Sep 17 00:00:00 2001 From: heaven Date: Wed, 2 Sep 2026 11:49:08 +0300 Subject: [PATCH] Second cut pass in the platform zone: restore what the review found lost, and take the sediment the first pass left behind --- platform/BACKLOG.md | 28 +-- platform/README.md | 53 +++-- platform/deploy/README.md | 64 +++--- platform/docs/DEFECT_REGISTER.md | 102 +++++----- platform/docs/ENGINEERING_STANDARDS.md | 3 +- platform/docs/PLATFORM_DIRECTION.md | 67 +++---- platform/docs/STACK_DECISIONS.md | 37 ++-- platform/docs/p8-review/README.md | 6 +- platform/docs/platform-PROGRESS.md | 257 ++++++++++--------------- 9 files changed, 270 insertions(+), 347 deletions(-) diff --git a/platform/BACKLOG.md b/platform/BACKLOG.md index 14a575eb..04535cfc 100644 --- a/platform/BACKLOG.md +++ b/platform/BACKLOG.md @@ -2,31 +2,31 @@ > Ведёт зона `platform/` (решение владельца 02.08, D39.84: фронт и платформа держат СВОИ бэклоги; единый бэклог `docs/PROGRESS.md` остаётся трекером движка/полигона/доков и фронт/платформа-строк не принимает). Нормы те же: ID стабилен навсегда, каждая петля получает диспозицию. Запросы к ДВИЖКУ сюда не пишутся — они заходят строками единого бэклога через оркестратора (пример: строки 99–102). Засеян оркестратором при лендинге D39.84 — дальше правит платформа-сессия. -> **Диспозиции после P1 (05.08)** — в журнале зоны, раздел «Диспозиции бэклога зоны» -> (`docs/platform-PROGRESS.md`). Коротко: П-6 и П-8 ЗАКРЫТЫ, П-7 закрыт по схеме и операциям +> **Диспозиции после P1 (05.08)** — раздел «Диспозиции бэклога зоны» архив-слайса зонного журнала +> (`docs/archive/platform-PROGRESS-P0-P3.md`). Коротко: П-6 и П-8 ЗАКРЫТЫ, П-7 закрыт по схеме и операциям > (постановка холда воркером — часть П-1), П-4 отменён и поглощён П-7, П-5 переопределён под > кредитную модель и ждёт правки спеки, П-1 продолжена, П-2/П-3 не трогали. > Дублировать их здесь не стали — у строки один источник истины. | ID | Хвост | Вес | Источник | |---|---|---|---| -| П-1 | **HTTP/SSE-слой и сервисная обвязка** (экс-строка 96 единого бэклога) — ⚠ **ЗАКРЫТА, живого хвоста нет (диспозиция 22.08, аудит доков).** Аутентификация, сессии и CSRF — P1; тейлер `events.jsonl` с курсором и карантином проекции, реконсилятор и пять ручек `/v0` — P4/P5; потребительская половина шва (коды выхода, фолд присваиванием, потолки) — P6. Последний остаток, читающая поверхность, был вынесен отдельной строкой **П-17** и исполнен паком P7 (D39.153), после чего «часть исполнена» перестало что-либо значить. Форма потока и аутентификации ратифицирована D39.84/D39.85 (`docs/research/23-engine-platform-seam.md`); порядок деплоя против schema-mismatch движка — `deploy/README.md`, §«Апгрейд ДВИЖКА» | закрыта | D39.81, D39.84, D39.85, STACK_DECISIONS §5 | +| П-1 | **HTTP/SSE-слой и сервисная обвязка** (экс-строка 96 единого бэклога) — ⚠ **ЗАКРЫТА, живого хвоста нет (диспозиция 22.08, аудит доков).** Что именно построено — `platform/README.md` §«Что здесь будет»; последний остаток, читающая поверхность, вынесен строкой **П-17** и исполнен P7 (D39.153). Форма потока и аутентификации ратифицирована D39.84/D39.85 (`docs/research/23-engine-platform-seam.md`); порядок деплоя против schema-mismatch движка — `deploy/README.md`, §«Апгрейд ДВИЖКА» | закрыта | D39.81, D39.84, D39.85, STACK_DECISIONS §5 | | П-2 | **Глобальный брокер рейт-лимитов провайдеров** (экс-строка 97): гарды движка per-процесс (`pipeline/ratelimit.go:11`, mistral ~48% отказов под параллелизмом), а лимит провайдера — на ВЕСЬ аккаунт: N прогонов = N независимых гардов против общего лимита; воркер отпрашивается у платформы перед вызовом | ДО второго параллельного пользователя | D39.81, D39.84 | -| П-3 | **Очередь и конкурентность по книге** — ⚠ **ИСПОЛНЕНО P4 в той форме, которая была нужна:** River на том же Postgres, `MaxAttempts: 1` (повтор не доделывает работу, а спавнит второй движок — работу восстанавливает реконсилятор), одновременность по книге закрыта не лизами, а частичным уникальным индексом `runs_one_live_per_book` в БД: двух живых прогонов на книге не бывает, потому что движок держит эксклюзивный лок на файле проекта. **ОСТАЮТСЯ** ровно те части, которые нужны только со ВТОРЫМ хостом: пиннинг книги к хосту и лизы `book_leases` с heartbeat — сегодня хост один, и лиза без второго хоста охраняет от самой себя | до второго хоста | STACK_DECISIONS §5 и §19, D39.84 | -| П-4 | **Учёт токенов/денег per-user + бюджет-гейт ДО старта задачи** (сырьё уже считает движок: `request_log` + `internal/ledger`; помнить: леджер = нижняя граница — строка 78 единого) | Ф3-пак платформы | platform/README, D39.84 | -| П-6 | **OAuth-вход популярных провайдеров (Google первым) + модель аккаунта** (направление владельца 04.08): authorization code + PKCE, валидация ID-токена, связывание аккаунта по ПОДТВЕРЖДЁННОЙ почте; собственная серверная сессия сохраняется (D39.84) — OAuth даёт только СОБЫТИЕ входа. Форма и библиотеки — по ресёрч-пакету направления (ратифицирует оркестратор); писатель куки с `__Host`-атрибутами и dev-профиль — здесь же (PD-8) | P1, вместе с П-1 | владелец 04.08 | -| П-7 | **Кредитный баланс и фри-тир** (решения владельца 05.08; ПРОДАЖИ НЕТ — бета на предоплаченных ключах владельца): append-only леджер в целых микро-долларах, грант фри-тира из админки (дефолт $5, настраиваемый), холд ДО спавна прогона + пер-книжный потолок движку (жёсткий стоп исполняет движок), расчёт на границе попытки. ⚠ «дефолт $5» ПРОТУХ: слово владельца 16.08 (D39.138 п.2л, PD-104) — грант по умолчанию НОЛЬ, начисление руками; возврат $5 идёт вместе с суточным агрегатным потолком. ⚠ Окон с обнулением НЕТ — баланс, а не подписка: черновик `usage_windows` в подписочной форме заменяется. Платёжный провайдер не проектируется до решения продавать. Разбор — `docs/PLATFORM_DIRECTION.md` §2 | P1+, после П-6 | владелец 04–05.08 | -| П-8 | **Минимальная админ-поверхность**: начислить/списать кредиты аккаунту (одна запись леджера), посмотреть баланс и состояние прогонов. Защищённая ручка либо CLI — форму предлагает сессия. Без неё фри-тир неуправляем | вместе с П-7 | владелец 05.08 | -| П-5 | **API лимитов/использования + оповещение стопа по потолку** (решение владельца 04.08, D39.100/ПТ-35, механизм «как Claude Code»): страница лимитов в настройках читает СТАТУС использования (не суммы — деньги на провод не идут, D39.84); стоп по потолку → статус `paused` + оповещение «перевод остановлен: лимиты исчерпаны»; сырьё у движка есть (`request_log`/`ledger`, потолки конфига), форму API предлагает P0 | Ф3, вместе с П-1 | D39.100, ПТ-35, контракт К-8 | -| П-9 | **Загрузка книги `POST /books` (multipart)** — **ИСПОЛНЕНО P5 (11.08):** ручка построена потоково (`r.MultipartReader`, пер-маршрутный потолок тела, свой дедлайн чтения), хранилище — каталог книги под `TM_PLATFORM_BOOKS_DIR`, статусы `uploading → parsing → not_started \| rejected` получили писателей, разбор зовёт `tmctl manifest`, PD-72 закрыт своим тестом. Развилка «кто пишет стартовый `book.yaml` при интейке» (D39.110 §2b, шов `books.ErrNotProvisioned`) **РЕШЕНА D39.130:** бета = форма Б (стройка — П-14); движковая форма В (`tmctl init`) = строка 170 единого | Ф3, следующий пак | сессия P4 (границей промта D39.119 §5а), исполнено P5 | +| П-3 | **Очередь и конкурентность по книге** — ⚠ **ИСПОЛНЕНО P4 в той форме, которая была нужна:** River на том же Postgres, `MaxAttempts: 1` (довод — `STACK_DECISIONS` §19), одновременность по книге закрыта не лизами, а частичным уникальным индексом `runs_one_live_per_book` в БД. **ОСТАЮТСЯ** ровно те части, которые нужны только со ВТОРЫМ хостом: пиннинг книги к хосту и лизы `book_leases` с heartbeat — сегодня хост один, и лиза без второго хоста охраняет от самой себя | до второго хоста | STACK_DECISIONS §5 и §19, D39.84 | +| П-4 | **Учёт токенов/денег per-user + бюджет-гейт ДО старта задачи** — ⚠ **ОТМЕНЁН и поглощён П-7** (диспозиция P1, шапка выше). ⚠ Оговорка жива и после поглощения: леджер = НИЖНЯЯ граница траты (строка 78 единого) | отменён | platform/README, D39.84 | +| П-6 | **OAuth-вход популярных провайдеров (Google первым) + модель аккаунта** (направление владельца 04.08) — ⚠ **ЗАКРЫТ P1:** authorization code + PKCE, собственная серверная сессия сохраняется (D39.84 — OAuth даёт только СОБЫТИЕ входа), ключ личности `(provider, subject)`; код — `internal/login/`, состав и живой остаток — диспозиция из шапки. Писатель куки с `__Host`-атрибутами и dev-профиль — PD-8 | P1, вместе с П-1 | владелец 04.08 | +| П-7 | **Кредитный баланс и фри-тир** (решения владельца 05.08; ПРОДАЖИ НЕТ — бета на предоплаченных ключах владельца): append-only леджер в целых микро-долларах, грант фри-тира из админки (**дефолт НОЛЬ**, начисление руками — `tmplatformctl grant`), холд ДО спавна прогона + пер-книжный потолок движку (жёсткий стоп исполняет движок), расчёт на границе попытки. ⚠ Прежний «дефолт $5» ОТМЕНЁН словом владельца 16.08 (D39.138 п.2л, PD-104); возврат $5 идёт вместе с суточным агрегатным потолком. ⚠ Окон с обнулением НЕТ — баланс, а не подписка: черновик `usage_windows` в подписочной форме заменяется. Платёжный провайдер не проектируется до решения продавать. Разбор — `docs/PLATFORM_DIRECTION.md` §2 | P1+, после П-6 | владелец 04–05.08 | +| П-8 | **Минимальная админ-поверхность**: начислить/списать кредиты аккаунту (одна запись леджера), посмотреть баланс и состояние прогонов — ⚠ **ЗАКРЫТ P1 формой CLI:** `tmplatformctl grant/adjust/balance/logins/revoke` (`cmd/tmplatformctl/`) | вместе с П-7 | владелец 05.08 | +| П-5 | **API лимитов/использования + оповещение стопа по потолку** (решение владельца 04.08, D39.100/ПТ-35, механизм «как Claude Code»): страница лимитов в настройках читает СТАТУС использования (не суммы — деньги на провод не идут, D39.84); стоп по потолку → статус `paused` + оповещение «перевод остановлен: лимиты исчерпаны». ⚠ **ПЕРЕОПРЕДЕЛЁН под кредитную модель (диспозиция P1, шапка выше):** окон и `resets_at` нет, форма ответа там же предложена, **ручка НЕ построена — ждёт правки спеки** | Ф3, вместе с П-1 | D39.100, ПТ-35, контракт К-8 | +| П-9 | **Загрузка книги `POST /books` (multipart)** — **ИСПОЛНЕНО P5 (11.08)**: состав в `platform/README.md` §«Что здесь будет»; PD-72 закрыт своим тестом. Развилка «кто пишет стартовый `book.yaml` при интейке» (D39.110 §2b, шов `books.ErrNotProvisioned`) **РЕШЕНА D39.130:** бета = форма Б (стройка — П-14); движковая форма В (`tmctl init`) = строка 170 единого | Ф3, следующий пак | сессия P4 (границей промта D39.119 §5а), исполнено P5 | | П-10 | **Честная оценка «$/глава» от ДВИЖКА.** Сейчас ставка — константа платформы ($0.03, провенанс exp08 v2 через D30.4, `STACK_DECISIONS` §20), и это осознанная бета-мера: движковой поверхности оценки не существует, а выдумывать её запрещено. Ставка решает только ДЛИНУ шкалы (деньги защищены холдом и потолком движка), но на книге, которая заметно дороже или дешевле средней, шкала врёт пользователю о том, сколько глав он покупает. Нужна оценка от движка по конкретной книге — запрос уходит строкой ЕДИНОГО бэклога через оркестратора, не сюда | когда-нибудь (до первого платящего) | сессия P4 | | П-11 | **Наблюдаемость раннера** — **ИСПОЛНЕНО P5 (11.08):** `prometheus/client_golang` v1.24.1 на отдельном слушателе `TM_PLATFORM_METRICS_ADDR`; глубина очереди · возраст самого старого открытого холда · карантины · отставание тейлера · книги в интейке · длительность свипа и счётчик недоведённых проходов · запросы и задержки по паттерну маршрута. Ось наблюдаемости получила внешний эталон (практики именования Prometheus + золотые сигналы, `ENGINEERING_STANDARDS` §2) — половина PD-115, вторая половина строки остаётся открытой | Ф3 | сессия P4, исполнено P5 | -| П-12 | **Квота интейка и ретеншен отклонённых книг.** `POST /books` даёт аутентифицированному аккаунту писать на диск оператора: один аплоад ограничен (64 МиБ), число аплоадов — ничем. Отклонённая по вине источника книга каталог теряет, отклонённая по вине деплоя — сохраняет намеренно, и не чистит их никто. Строка регистра — PD-175 ⚠ **ПРОДУКТОВАЯ ПОЛОВИНА СНЯТА ЦЕЛИКОМ — D39.176 п.1 (слово владельца 30.08), диспозиция записана паком P12 31.08:** продуктовых КВОТ НЕТ и не будет, фри-тир-лимиты не проектируются, живём на покупке API и зачислениях из админки. То есть «сколько книг входит во фри-тир» — больше НЕ развилка и НЕ ждёт владельца; вопроса нет. Остаётся ИНЖЕНЕРНАЯ гигиена, решаемая зоной без чьего-либо слова: ретеншен отклонённых (`rejected`) книг · свип каталогов-сирот · потолок диска. Гейт у неё один и он не продуктовый — открытая регистрация, которой в закрытой бете нет. | инженерная гигиена, до открытой регистрации | сессия P5; продуктовая половина снята D39.176 | +| П-12 | **Квота интейка и ретеншен отклонённых книг.** `POST /books` даёт аутентифицированному аккаунту писать на диск оператора: один аплоад ограничен (64 МиБ), число аплоадов — ничем. Отклонённая по вине источника книга каталог теряет, отклонённая по вине деплоя — сохраняет намеренно, и не чистит их никто. Строка регистра — PD-175 ⚠ **ПРОДУКТОВАЯ ПОЛОВИНА СНЯТА ЦЕЛИКОМ — D39.176 п.1 (слово владельца 30.08):** продуктовых КВОТ НЕТ и не будет, фри-тир-лимиты не проектируются, живём на покупке API и зачислениях из админки; «сколько книг входит во фри-тир» — больше НЕ развилка и НЕ ждёт владельца. Остаётся ИНЖЕНЕРНАЯ гигиена, решаемая зоной без чьего-либо слова: ретеншен отклонённых (`rejected`) книг · свип каталогов-сирот · потолок диска. Гейт у неё один и он не продуктовый — открытая регистрация, которой в закрытой бете нет. | инженерная гигиена, до открытой регистрации | сессия P5; продуктовая половина снята D39.176 | | П-13 | **Оценка размера книги в символах — у ДВИЖКА.** `character_count` контракта платформа считает потоково на приёме (байты, не являющиеся продолжением UTF-8), что точно для UTF-8 и приблизительно для GB18030/UTF-16, которые движок принимает и декодирует сам. Манифест несёт `source_bytes` и `encoding`, но не число символов. Запрос уходит строкой ЕДИНОГО бэклога через оркестратора; строка регистра — PD-177 **→ строка 171 единого заведена 14.08** | когда-нибудь | сессия P5 | | П-15 | **ИСПОЛНЕНО P6 (14.08).** Платформенная половина шва эмиттера (движковая залендена D39.131, `9cfe080`) | ИСПОЛНЕНО P6: коды выхода движка 4/5/10–19 через `ingest.OutcomeOf`, потолок = `paused` двумя каналами (PD-113 закрыт), интейк судит по коду (PD-196 закрыт), exit 5 без намерения = прерывание и перезапуск (PD-152 закрыт); фолд `unit_done` ПРИСВАИВАНИЕМ по тройке (chapter, unit, wave) через `unit_resolutions` (миграция 00015; ратифицировано D39.131 п.2г, at-least-once); StreamVersion 1.1, `Ceiling.Scope` (book\|day), внутренняя причина `daily_ceiling`, резюм дневного потолка = 409 (PD-157 половина); dev-супервизор читает ту же таблицу; foreign-hello adoption воспроизведён и закрыт (PD-200); порядок деплоя — `deploy/README.md` + `tmplatformctl books --migratable`. ⚠ Граница присваивания (PD-219): оно самолечит ПОВТОРНУЮ доставку, а не пропущенную — недодрейненный хвост новая попытка не перечитывает, её курсор начинается с размера журнала на допуске | приёмка D39.131 | -| П-14 | **ИСПОЛНЕНО P6 (14.08).** Стройка интейка формы Б (ратификация D39.130) | ИСПОЛНЕНО P6: рендер в ОДНОМ месте (`books.provision`), `TM_PLATFORM_BOOK_TEMPLATE`, работа с YAML-узлом (комментарии и незнакомые ключи оператора живы, значения — строки), `O_EXCL` (никогда не перезаписываем), битый шаблон = класс, который ЖДЁТ. Требования к полям — `backend/internal/config/book.go` как справочник; шаблон — деплой-артефакт оператора, не код. При приходе формы В (строка 170 единого, `tmctl init`) рендер заменяется вызовом движка. Проверено настоящим `tmctl manifest` (3 главы) | ратификация D39.130 (приёмка P5) | -| П-16 | **ИСПОЛНЕНО P6 (14.08).** Дев-сид стенда: тестовый пользователь, чтобы фронт разрабатывался на живой платформе, а не на своих моках (слово владельца 14.08). Границы: фикстуры фронта остаются батарее его гейтов (детерминированные состояния — не работа стенда); полный снос `frontend/src/mock/` — триггер Ф-29, после читающей поверхности | ИСПОЛНЕНО P6: `tmplatformctl seed` (дев-вход → аккаунт → грант → загрузка через ЖИВОЙ `POST /v0/books` → ожидание конца интейка), дев-вход `TM_PLATFORM_DEV_LOGIN` с четырьмя гардами непроходимости в проде, рецепт страницей в `deploy/README.md`. Прогнано на стенде целиком | владелец 14.08 | -| П-17 | **ИСПОЛНЕНО P7 (16–17.08).** Читающая поверхность — остаток П-1, названный отдельной строкой | ИСПОЛНЕНО P7: `listChapters`/`listUnits`/`listNotes`/`listBankTerms`/`streamBookEvents`/`getCapabilities` по канону 0.3.0 · модель ошибок с машинным `code` · условные чтения и сжатие · `Idempotency-Key` · `structure_version` · материализатор `internal/readmodel` (манифест + экспорт + сайдкар банка). ⚠ `submitBankDecisions` СНЯТ 22.08 вместе с пер-термной моделью подписи — D39.144, слово владельца; см. PD-370. **НЕ вошло и отложено в P8:** `updateBook`/`deleteBook`/`getRun`, экспорт (`createExport`/`getExport`), эскроу (П-18) | П-1, D39.84/85, контракт 14 | +| П-14 | **ИСПОЛНЕНО P6 (14.08).** Стройка интейка формы Б (ратификация D39.130) | ИСПОЛНЕНО P6: рендер в ОДНОМ месте (`books.provision`); правила шаблона и его отказов — `deploy/README.md` §«Шаблон книги», требования к полям — `backend/internal/config/book.go` как справочник. При приходе формы В (строка 170 единого, `tmctl init`) рендер заменяется вызовом движка. Проверено настоящим `tmctl manifest` (3 главы) | ратификация D39.130 (приёмка P5) | +| П-16 | **ИСПОЛНЕНО P6 (14.08).** Дев-сид стенда: тестовый пользователь, чтобы фронт разрабатывался на живой платформе, а не на своих моках (слово владельца 14.08). Границы: фикстуры фронта остаются батарее его гейтов (детерминированные состояния — не работа стенда); полный снос `frontend/src/mock/` — триггер Ф-29, после читающей поверхности | ИСПОЛНЕНО P6: `tmplatformctl seed` и дев-вход `TM_PLATFORM_DEV_LOGIN` с четырьмя гардами непроходимости в проде — рецепт и разбор гардов в `deploy/README.md` §«Дев-стенд». Прогнано на стенде целиком | владелец 14.08 | +| П-17 | **ИСПОЛНЕНО P7 (16–17.08).** Читающая поверхность — остаток П-1, названный отдельной строкой | ИСПОЛНЕНО P7 по канону 0.3.0; состав поверхности — `platform/README.md` §«Что здесь будет» и §«Карта зоны» (`internal/readmodel`). ⚠ `submitBankDecisions` СНЯТ 22.08 вместе с пер-термной моделью подписи — D39.144, слово владельца; см. PD-370. **НЕ вошло и отложено в P8:** `updateBook`/`deleteBook`/`getRun`, экспорт (`createExport`/`getExport`), эскроу (П-18) | П-1, D39.84/85, контракт 14 | | П-18 | **Эскроу денег шва: write-ahead intent · `uncertain` · `closing`** (строка 136 единого бэклога) — денежный промт, сознательно НЕ взятый ни P5, ни P6. Здесь же закрывается PD-154 (`settled_at` между `Settle` и `MarkSettled`): половина эскроу рядом с проектируемым целым — второй, более слабый ответ на тот же вопрос | денежный промт | строка 136, PD-154 | | П-19 | **Конверсия свободного от склейки блока под `sqlc`** (решение владельца 20.08 «БЕРЁМ»; граница названа P8-FIX, PD-44). Пять файлов без единой склейки — `credits.go`(15) · `identity.go`(13) · `idempotency.go`(7) · `sessions.go`(5) · `observe.go`(1) = **42 места / 41 текст / 40 КОНВЕРТИРУЕМЫХ** (⚠ испр. D39.172: `observe.go` структурно не конвертируется — River мигрирует `river_job` сам; худший позиционный дрейф набора, семь `int64` подряд, остался рукописным): это весь кусок пакета, где инструмент силён и ничего не ломает. Остальные 25 склеенных мест недостижимы по построению (`lastRun` — девять потребителей, `nextRevisionOfThisBooksLibrary` — восемь), и это ровно та часть, где рантайм-ошибки и случались. Состав пака: `sqlc.yaml` + пин версии инструмента (как у `golangci-lint`) + генерённый код в дереве + гейт «сгенерённое актуально» + `WithTx` для одиннадцати денежных запросов, идущих внутри ЧУЖОЙ транзакции. ⚠ Класс «нет такой колонки» УЖЕ закрыт постоянным гейтом (`TestEverySQLStatementParsesAgainstTheMigratedSchema`, 162 оператора, включая склеенные), поэтому этот пак покупает типизированные скан-структуры и раннюю обратную связь, а не корректность. **РЕШЕНО владельцем 22.08: ОТДЕЛЬНОЙ СЕССИЕЙ, не внутри пака** — изменений много, мешать их с чем-либо нельзя | отдельная сессия | PD-44, фикс-лист приёмки P7 п. 4, решение владельца 22.08 | | П-20 | **Хвост PD-162 после P8-FIX: холд прогона, чей движок не ответит НИКОГДА, возвращается только рукой.** Пак дал оператору и диагноз (`tmplatformctl runs --stalled`, гейдж `tm_platform_runs_stalled`), и терминальный вердикт (`run abandon [--release-hold]`), и это сознательная граница: автоматически решать про деньги прогона, о котором нельзя спросить, — та же ошибка «я не смог спросить = его нет», только со счётчиком впереди. Автоматический ответ на этот вопрос и есть эскроу — write-ahead intent · `uncertain` · `closing` (строка 136 единого бэклога, П-18). Пока эскроу нет, ручка остаётся ручной, и это записано, а не подразумевается | вместе с П-18 | P8-FIX | diff --git a/platform/README.md b/platform/README.md index be71599d..de259650 100644 --- a/platform/README.md +++ b/platform/README.md @@ -1,9 +1,9 @@ # platform — control plane (SaaS-слой) Зона записи сессии «Платформа». Что построено и каким паком — секция «Что здесь будет» ниже и -шапка «Текущее состояние» зонного журнала. Направление зоны — `docs/PLATFORM_DIRECTION.md`, критерии приёмки — -`docs/ENGINEERING_STANDARDS.md`, дефекты — `docs/DEFECT_REGISTER.md`, зонный журнал — -`docs/platform-PROGRESS.md` (весь прогресс зоны здесь, решение владельца 04.08), стек — +шапка «Текущее состояние» зонного журнала. Направление зоны — `docs/PLATFORM_DIRECTION.md`, +критерии приёмки — `docs/ENGINEERING_STANDARDS.md`, дефекты — `docs/DEFECT_REGISTER.md`, зонный +журнал — `docs/platform-PROGRESS.md` (весь прогресс зоны здесь, решение владельца 04.08), стек — `docs/STACK_DECISIONS.md`. Батарея зоны: `make check` (build · vet · fmt · lint · test -race). `make vuln` и `make fuzz` — @@ -19,9 +19,8 @@ её отвечает `GET /capabilities`, по ней же интейк отклоняет неподдерживаемую пару кодом `unsupported_pair`. Какие пары существуют, решают ДАННЫЕ (пакет промптов оператора), а не список в Go. ⚠ **Пустой список отклоняет ВСЁ, а не пропускает всё** (акт 5 P7): «не объявлено ничего» и «объявлена -эта пара» — разные ответы, и прежнее послабление заставляло деплой принимать книги, которые он мог -только провалить. Инстанс, принимающий загрузки без единой ДОСТУПНОЙ пары, не стартует вовсе — это -второй конец того же правила, и он в силе и в бою, и в рецепте дев-стенда. +эта пара» — разные ответы. Инстанс, принимающий загрузки без единой ДОСТУПНОЙ пары, не стартует +вовсе — это второй конец того же правила, и он в силе и в бою, и в рецепте дев-стенда. Бинари: `cmd/tmplatformd` (сервис) и `cmd/tmplatformctl` (админ: гранты, КОРРЕКТИРОВКИ (`adjust`), баланс, журнал входов, отзыв сессий, дев-интейк `book add`, список книг для апгрейда движка `books` и список книг, чью @@ -122,20 +121,16 @@ Prometheus; на контрактную поверхность они не вы | `internal/pricing`, `internal/money` | шкала глав и целые микро-доллары | | `internal/metrics`, `internal/reqid`, `internal/jobs`, `internal/config`, `internal/gates` | телеметрия, id запроса, очередь, конфигурация, гейты тулчейна | -Каналы движка, которыми зона пользуется, — СЕМЬ. ⚠ Слов «и других нет» здесь нет намеренно: -исчерпывающий перечень с атомарностью живёт в `docs/STACK_DECISIONS.md`, «Инвентарь каналов -движка», и список ниже СЧИТАЕТСЯ при правке вместе с ним. `tmctl manifest --json` (структура), -`tmctl export --json --pairs` -(ТЕКСТ пар — единственный носитель), `.bank.json` (банк), `tmctl status --json` (канал -ремонта; с лендингом `6ec9f8a` он ещё и оценивает пере-проход ДО покупки), `events.jsonl` (поток) и — -с лендингом пака «писатель книги» (D39.175) — `tmctl build` с файловым сайдкаром -`.book.`, чьи пути публикуются в `StatusArtifacts.book_files`. Шестой канал ещё никем -в зоне не потребляется: его будет читать дверь выдачи `createExport`/`getExport`, и она обязана -СТРОИТЬ (звать `tmctl build`), а не подбирать лежащий рядом файл — там копия прежней сборки -(D39.175 п.2). Седьмой — `tmctl bank-apply`: документ решений уходит движку файлом, отчёт -`tm-bank-report-v1` приходит обратно на stdout; это ЕДИНСТВЕННЫЙ канал, по которому зона ПИШЕТ в -проект движка, и потому у него своя дверь (`POST /books/{bookId}/bank/corrections`) и свой класс -отказов. Живой SQLite движка не читается никогда (D39.85). +Каналы движка, которыми зона пользуется, — СЕМЬ: `tmctl manifest --json` · +`tmctl export --json --pairs` · `.bank.json` · `tmctl status --json` · `events.jsonl` · +`tmctl build` · `tmctl bank-apply`. ⚠ Слов «и других нет» здесь нет намеренно, и второй копии +перечня здесь тоже нет: атомарность каждого канала, колонка «потребляет ли платформа» и все +оговорки лежат ЕДИНСТВЕННЫМ носителем в `docs/STACK_DECISIONS.md`, «Инвентарь каналов движка». Оттуда же два правила, которые +дороже перечня: `tmctl build` (D39.175) в зоне ещё НИКЕМ не читается, и его будущая дверь выдачи +`createExport`/`getExport` обязана СТРОИТЬ, а не подбирать лежащий рядом файл — там копия прежней +сборки (D39.175 п.2); `tmctl bank-apply` — ЕДИНСТВЕННЫЙ канал, по которому зона ПИШЕТ в проект +движка, и потому у него своя дверь (`POST /books/{bookId}/bank/corrections`) и свой класс отказов. +Живой SQLite движка не читается никогда (D39.85). ## Чего здесь НЕ будет @@ -147,21 +142,21 @@ Prometheus; на контрактную поверхность они не вы ## Известное требование к движку (не забыть) -⚠ **Глобальный брокер конкурентности.** `pipeline/ratelimit.go` строит рейт-гарды НА ПРОГОН, -а лимит провайдера — на весь аккаунт. N параллельных пользователей = N независимых гардов -против общего лимита (в комментарии там зафиксировано: mistral валит ~48% вызовов под -параллелизмом). Воркер должен отпрашиваться у платформы перед вызовом. Это единственная -по-настоящему новая механика на стыке. +⚠ **Глобальный брокер конкурентности** — единственная по-настоящему новая механика на стыке: +`pipeline/ratelimit.go` строит рейт-гарды НА ПРОГОН, а лимит провайдера — на весь аккаунт. +Постановка, замер и вес — строка `BACKLOG.md` П-2. ## Стек Пины, даты релизов и обоснования — [`docs/STACK_DECISIONS.md`](docs/STACK_DECISIONS.md) (зонный, live-сверка 04–05.08); общая записка по обоим новым сервисам — `../frontend/docs/STACK_DECISIONS.md` §5. -Коротко: Go 1.26.4 в `go.mod` — общий с движком floor ЯЗЫКА, при этом `toolchain go1.26.6` там же поднимает тулчейн (D39.130; `make version-check` СРАВНИВАЕТ версии, а не матчит) · стандартный `net/http` + `ServeMux` без -роутер-библиотеки · PostgreSQL 18 · pgx v5.10.0 · goose v3.27.3 · очередь River v0.42.0 на том же -Postgres — **подключена и работает** (P4) · вход `x/oauth2` v0.36.0 + `go-oidc/v3` v3.20.0 · -`x/time` v0.15.0 для лимита на `/auth/login` · `govulncheck` отдельной целью. +Коротко: floor ЯЗЫКА в `go.mod` общий с движком, `toolchain` там же поднимает тулчейн выше +(D39.130; `make version-check` СРАВНИВАЕТ версии, а не матчит) · стандартный `net/http` + `ServeMux` +без роутер-библиотеки · PostgreSQL 18 + pgx · goose · очередь River на том же Postgres — +**подключена и работает** (P4) · вход `x/oauth2` + `go-oidc/v3` · `x/time` для лимита на +`/auth/login` · `govulncheck` отдельной целью. ⚠ Номера версий здесь намеренно не дублируются: их +единственные носители — `go.mod` и таблица пинов строкой выше. **Redis не заводим нигде** — зафиксировано как архитектурное «нет». Прогресс наружу — SSE, события **пушит воркер**, а не фронт опрашивает read-model. diff --git a/platform/deploy/README.md b/platform/deploy/README.md index 3e604b0b..173db639 100644 --- a/platform/deploy/README.md +++ b/platform/deploy/README.md @@ -183,34 +183,34 @@ ceilings: ## Апгрейд ДВИЖКА: порядок против деадлока (строка 174 единого бэклога) -Read-only команды движка отказывают файлу проекта СТАРЕЕ бинаря — **exit 13 и токен -`schema_mismatch found=N expected=M` на stderr** (форма финализирована D39.134; прежняя -цитата «schema vN … expects vM» в этом доке была МЁРТВОЙ — движок её не печатает), а -платформа зовёт `status` перед каждым спавном и на расчёте денег. Значит выкат нового -движка на хост с существующими книгами запирает их до миграции файла проекта. +Read-only команды движка отказывают файлу проекта СТАРЕЕ бинаря — +**exit 13 и токен `schema_mismatch found=N expected=M` на stderr** +(форма финализирована D39.134 п.4), а платформа зовёт `status` перед +каждым спавном и на расчёте денег. Значит выкат нового движка на +хост с существующими книгами запирает их до миграции файла проекта. ⚠ **Проверено исполнением на стенде (P7):** проектная БД, отведённая на схему v14 при бинаре v15, даёт `tmctl status --json` → exit 13 с этим токеном; `tmctl migrate --config ` делает пред-миграционный бэкап и переводит v14 → v15; тот же `status` после неё — exit 0. -⚠ **`tmctl migrate` СУЩЕСТВУЕТ и заленден** — $0-команда движка, открывающая файл -проекта на запись без прогона: `backend/cmd/tmctl/migrate.go`, в диспетчере `main.go`, -свой код выхода (`exitSchemaMismatch = 13`, «run `tmctl migrate`»). Порядок ниже — -ДЕЙСТВУЮЩИЙ, а не «когда приедет». +⚠ **`tmctl migrate` СУЩЕСТВУЕТ и заленден** — $0-команда +движка, открывающая файл проекта на запись без прогона: +`backend/cmd/tmctl/migrate.go`, в диспетчере `main.go`, свой +код выхода (`exitSchemaMismatch = 13`, «run `tmctl migrate`»). -⚠⚠ **ВТОРАЯ, НЕЗАВИСИМАЯ причина не выкатывать движок раньше платформы: подъём ФОРМЫ -МАНИФЕСТА движка требует платформенного билда, иначе выбывает КАЖДАЯ новая книга.** -Платформа с P12 сверяет `manifest_version` с известной ей формой -(`ingest.KnownManifestVersion`, зеркало `manifestVersion` движка) и на незнакомую -отвечает НЕ-деструктивным классом `parser_unavailable` — файл пользователя цел, и это -осознанный выбор: без сверки переименованный ключ декодируется в нули, а ноль глав -интейк читает как «источник прочли, книги нет», то есть УДАЛЯЕТ аплоад (`PD-213`). Но -класс всё равно ТЕРМИНАЛЕН по бюджету попыток: движок впереди платформы ⇒ каждая -загруженная книга уходит в `rejected` после пяти попыток. Симптом — интейк массово -отклоняет при здоровом на вид движке; лечение — выкатить платформенный билд, знающий -новую форму. Правило то же, что у схемы хранилища: **сначала платформа, потом движок**, -и оба конца этого правила теперь записаны. +⚠⚠ **ВТОРАЯ, НЕЗАВИСИМАЯ причина не выкатывать движок раньше платформы: подъём +ФОРМЫ МАНИФЕСТА движка требует платформенного билда, иначе выбывает КАЖДАЯ +новая книга.** Платформа с P12 сверяет `manifest_version` с известной ей +формой (`ingest.KnownManifestVersion`, зеркало `manifestVersion` движка) и на +незнакомую отвечает НЕ-деструктивным классом `parser_unavailable` — файл +пользователя цел, и это осознанный выбор: без сверки переименованный ключ +декодируется в нули, а ноль глав интейк читает как «источник прочли, книги +нет», то есть УДАЛЯЕТ аплоад (`PD-213`). Но класс всё равно ТЕРМИНАЛЕН по +бюджету попыток: движок впереди платформы ⇒ каждая загруженная книга уходит в +`rejected` после пяти попыток. Симптом — интейк массово отклоняет при здоровом +на вид движке; лечение — выкатить платформенный билд, знающий новую форму. +Правило то же, что у схемы хранилища: **сначала платформа, потом движок**. Порядок (действующий). ⚠ Ключевое: **`migrate` гоняется НОВЫМ бинарём** — старый уводит файл в свою же схему, то есть не делает ничего, и деадлок остаётся. Поэтому бинарь @@ -359,9 +359,8 @@ export TM_PLATFORM_STATE_DIR=$HOME/.local/share/tmstand/state export TM_PLATFORM_ENGINE_BIN=$HOME/.local/bin/tmctl export TM_PLATFORM_CTL_BIN=$PWD/tmplatformctl # абсолютный: systemd отвергает иной export TM_PLATFORM_BOOK_TEMPLATE=$HOME/.local/share/tmstand/book-template.yaml -# ⚠ ОБЯЗАТЕЛЬНО, как и в бою: инстанс, принимающий загрузки, обязан объявить хотя бы одну ДОСТУПНУЮ -# пару, иначе демон отказывается стартовать. Пустой список — это «не объявлено ничего», а не -# «пускать всё»: без гейта стенд принимал книги, которые мог только провалить. +# ⚠ ОБЯЗАТЕЛЬНО, как и в бою: без хотя бы одной ДОСТУПНОЙ пары демон, принимающий загрузки, не +# стартует. Правило и его причина — блок ⚠ `TM_PLATFORM_LANGUAGE_PAIRS` выше. export TM_PLATFORM_LANGUAGE_PAIRS='zh>ru' mkdir -p "$TM_PLATFORM_BOOKS_DIR" "$TM_PLATFORM_STATE_DIR" @@ -396,11 +395,9 @@ await fetch('/auth/dev-login', {method: 'POST'}) install -d -m0750 -o tmplatform -g tmplatform /srv/textmachine/books ``` -⚠ **`/metrics` не аутентифицирован** — его защищает только адрес привязки. Дефолт `127.0.0.1`, то -есть снаружи он недостижим; поднять его на внешний адрес значит открыть операционную форму деплоя -(глубина очереди, число прогонов, возраст холдов) всем, кто до него дотянется. Скрейпер живёт на том -же хосте либо ходит через тот же edge, что и API. Разбор — `docs/STACK_DECISIONS.md` §24, строка -риска — PD-179. +⚠ **`/metrics` не аутентифицирован** — его защищает только адрес привязки, и дефолт `127.0.0.1` +снаружи недостижим. Скрейпер живёт на том же хосте либо ходит через тот же edge, что и API; что +именно открывает внешний адрес и почему риск принят — `PD-179`, разбор `docs/STACK_DECISIONS.md` §24. ⚠ **Загрузка книги идёт минуты, и это касается edge-прокси.** Маршрут `POST /v0/books` принимает до `TM_PLATFORM_MAX_UPLOAD_BYTES` (64 МиБ) и держит соединение до `TM_PLATFORM_UPLOAD_DEADLINE` @@ -420,11 +417,10 @@ install -d -m0750 -o tmplatform -g tmplatform /srv/textmachine/books ## Где на сервере лежат книги `/srv/textmachine` — корень библиотеки НА СЕРВЕРЕ. Решение владельца «книги живут в `~/books`» -относится к машине разработки: под этим юнитом домашние каталоги недоступны вовсе -(`ProtectHome=yes` подставляет пустой `/home` и детям-`tmctl` тоже), поэтому библиотека под -домашним каталогом на сервере просто не откроется — не «сработает медленнее», а не найдётся. -Оператору, которому это нужно, юнит называет ровно две строки замены (`ProtectHome=tmpfs` + -`BindPaths=`); других изменений не требуется. +относится к машине разработки: под этим юнитом домашние каталоги недоступны и детям-`tmctl` тоже +(`ProtectHome=yes`, проверено живым прогоном — §«Файлы»), поэтому библиотека под домашним каталогом +на сервере не «сработает медленнее», а НЕ НАЙДЁТСЯ. Кому это нужно, юнит называет ровно две строки +замены (`ProtectHome=tmpfs` + `BindPaths=`); других изменений не требуется. ## Чего здесь ещё нет diff --git a/platform/docs/DEFECT_REGISTER.md b/platform/docs/DEFECT_REGISTER.md index b20fd3a7..993a91f1 100644 --- a/platform/docs/DEFECT_REGISTER.md +++ b/platform/docs/DEFECT_REGISTER.md @@ -1,19 +1,17 @@ # Регистр дефектов и уязвимостей платформы > Заведён по решению владельца 04.08 («отдельно ведётся колонка багов и уязвимостей — тут опасно всё»). -> ⚠ **Токен `ОСПОРЕНО(PD-N)`** (введён паком P8-REVIEW 24.08): строка стоит `fixed`, а пак доказал, что её пин -> доказывает свойство СЛАБЕЕ, чем строка гласит. Статус при этом НЕ меняется — это акт лендинга, — а живой -> пробел несёт новая строка, номер которой стоит в скобках. Грепается: `grep -n 'ОСПОРЕНО(' …`. +> ⚠ **Токен `ОСПОРЕНО(PD-N)`** (введён паком P8-REVIEW 24.08; правило ратифицировано D39.159, оговорка к PD-1 +> дописана 27.08 по вычитке старшего): строка стоит `fixed`, а пак доказал, что её пин доказывает свойство +> СЛАБЕЕ, чем строка гласит. Статус при этом НЕ меняется — это акт лендинга — и строка **не пере-открывается**, +> пока описанный ею дефект из кода ушёл: иначе регистр объявляет вернувшимся баг, который не воспроизводится, +> и считает один пробел двумя открытыми. Живой пробел несёт открытая строка-преемник с ДВУСТОРОННЕЙ ссылкой, +> её номер стоит в скобках токена; пере-открытие — только когда дефект ВОСПРОИЗВОДИТСЯ. +> Грепается: `grep -n 'ОСПОРЕНО(' …`. > > Правила: каждая находка любой сессии/приёмки/аудита — строкой сюда ДО закрытия; ID стабилен навсегда; > закрытие — только с коммитом фикса и тестом, пинящим свойство (урок PD-1: свойство без пинящего теста -> считается НЕ закрытым). ⚠ **Оговорка к PD-1, ратифицирована D39.159 и дописана 27.08 по вычитке -> старшего:** строка `fixed`, чей пин доказывает МЕНЬШЕ, чем строка гласит, **не пере-открывается**, -> когда описанный ею дефект из кода ушёл, — иначе регистр объявляет вернувшимся баг, который не -> воспроизводится, и считает один пробел двумя открытыми. Живой пробел несёт открытая строка-преемник -> с ДВУСТОРОННЕЙ ссылкой и токеном `ОСПОРЕНО(PD-N)` в оспоренной строке. Пере-открытие — только когда -> дефект ВОСПРОИЗВОДИТСЯ. Без этой оговорки практика и буква правила спорили бы в одном файле, и -> следующая приёмка пере-судила бы спор с нуля. Класс: `vuln` — эксплуатируемо или ослабляет защиту · `bug` — неверное поведение · +> считается НЕ закрытым; оговорка к нему — токен `ОСПОРЕНО` выше). Класс: `vuln` — эксплуатируемо или ослабляет защиту · `bug` — неверное поведение · > `hardening` — защита в глубину / латентное · `doc` — док лжёт о коде · `standards` — расхождение с > объявленной нормой зоны (введены приёмкой P2; словарь отставал от строк — испр. оркестратором №15). Статус: `open` · `fixed()` · `accepted-risk(<кем, когда>)`. @@ -25,7 +23,7 @@ | ID | Класс | Серьёзность | Где | Суть | Статус | Источник | |---|---|---|---|---|---|---| -| 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` в родственной точке) ⚠ **ПЕРЕ-ДИСПОЗИЦИОНИРОВАНА паком P12 (30.08): направление ЕСТЬ** — D39.165 §1 дал три части: **(а)** цена от ОБЪЁМА ИСХОДНИКА, носитель уже существует и лучше знаков — `chapters.units_total`, который интейк пишет вызовом `tmctl manifest` за $0 ДО первой оплаты; посимвольных колонок заводить не надо; **(б)** настоящий стоп объёма — В ДВИЖКЕ рядом с денежным, обязан НАЗЫВАТЬ, по какому потолку встал, и проверяться ПЕРЕД началом юнита; **(в)** хвост качества — в тариф и МЕРИТЬ, срезы телеметрии уже есть. ⛔ **(г) константу по сегодняшним числам НЕ калибровать** — гейт строка бэклога 202. Что остаётся открытым: не решение, а МЕХАНИКА, и она — отдельный пак (слово оркестратора 28.08), паком P12 не тронута. | open | воркфлоу-ревью P9 28.08 (линза bar:double-count), стоп-решение сессии P9 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` в родственной точке) ⚠ **ПЕРЕ-ДИСПОЗИЦИОНИРОВАНА паком P12 (30.08): направление ЕСТЬ** — D39.165 §1, четыре части (цена от объёма исходника · стоп объёма в движке · хвост качества в тариф). ⛔ **(г) константу по сегодняшним числам НЕ калибровать** — гейт строка бэклога 202. Что остаётся открытым: не решение, а МЕХАНИКА, и она — отдельный пак (слово оркестратора 28.08), паком P12 не тронута. | open | воркфлоу-ревью P9 28.08 (линза bar:double-count), стоп-решение сессии P9 28.08: тянет архитектуру, не чинится в паке | | 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 не дошёл: он лечил вторую фазу (расплату) и её поверхности. ⚠ **СУЖЕНА пак P12 (30–31.08), НЕ ЗАКРЫТА — половина «невидим» вылечена, половина «ручки нет» стоит.** Приор зоны принят и исполнен: блокированная расплата — НЕУДАЧА владеющей фазы, и она считается. `restart` больше не выбрасывает вердикт `settle`: на `settlementBlocked` он возвращает `errSettlementBlocked` ДО `reopen` (прежний путь платил за запрос, чтобы узнать то, что `settle` только что сказал, писал WARN, винящий рестарт в блокировке расплаты, и отвечал `nil`, который вызывающий считал успешным проходом). `reconcileOne` ключует на этом сентинеле причину — ОДНА фраза на оба фазовых пути — и ЗАДЕРЖКУ: расписание РАСПЛАТЫ, а не живой фазы, потому что открытая резервация и есть resume-гейт пользователя, и получасовой живой бэкофф держал бы его resume за блокировку, с которой он ничего сделать не может. Вердикт `deferred` у `reopen` оставлен как был: после правки он достижим только на `settlementRaced` с моментально открытой резервацией — самоисправляется следующим проходом, и фаза расплаты прямо аргументирует, что гонка не должна попадать на счётчик оператора. Гард выключения (`ctx.Err()`) стоит: счёт, придуманный ОСТАНОВКОЙ демона, — тот самый дефект, который соседняя фаза уже нашла. Итог, проверенный пином `runs.TestALiveRunWhoseSettlementIsBlockedIsCountedAndReachesTheOperator`: `reconcile_failures` доходит до порога, строка появляется в `runs --stalled` (половина `live`, не `settling`), гейдж `tm_platform_runs_stalled` = 1, отсрочка не превышает `settlementBackoffCap`. Посадка M13 КРАСНАЯ адресно. ⚠ **ЧТО ОСТАЁТСЯ ОТКРЫТЫМ и почему не взято этим паком:** терминальная РУЧКА. `run abandon` ветвится по `runs.finished_at` и на живом прогоне уходит в живую ветку, а `AbandonRun` отказывает над попыткой, ещё называющей юнит. Ручка «процесса нет, закрой деньги» — НОВАЯ разрушительная операторская поверхность над деньгами, и её дизайн (кто вправе звать, чем доказывается отсутствие процесса, что будет, если процесс вернётся) — решение своего размера, а не хвост этой правки. Сегодня пользователь выводит прогон из состояния кнопкой Stop, и теперь об этом хотя бы говорят все три поверхности `PD-385`. | open | приёмка оркестратора №19 по паку P11 (охотник вне карты, воспроизведено 10 проходами) | ## Открытые — minor @@ -46,10 +44,10 @@ | 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-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` · `internal/runs/seam_test.go:146`=`func TestAResumeOfARunPausedAtALimitIsRefusedWhoseverLimitItWas` (переименование, коммит `9b23e8c`) · `httpapi.TestOnlyTheContractsOwnPausedReasonReachesTheWire` ⚠ Дополнено рефутером: контракт 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, сужено рефутером:** для половины «спавн отказал» (попытка до движка не дошла) построены ДИАГНОЗ (`runs --stalled`, гейдж `tm_platform_runs_stalled`) и РУЧНОЙ вердикт оператора (`tmplatformctl run abandon --run --reason [--release-hold]`, коммит `31f1f82`, строка П-20 зонного бэклога). **Автоматического терминального состояния после 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:564`=`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:1363`=`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`). У отказа порядок перевёрнут в самоизлечивающийся — сначала каталог, потом строка, — а у брошенной загрузки перевернуть нельзя (каталог можно сносить только убедившись, что строки нет), так что окно там остаётся и закрывается тем же свипом ретеншена, что и вся строка ⚠ **ПРОДУКТОВАЯ ПОЛОВИНА СНЯТА — D39.176 п.1 (слово владельца 30.08); диспозиция записана паком P12 31.08 по пингу аудита доков.** Квот НЕТ и не будет, фри-тир-лимиты не проектируются: живём на покупке API, бонусы зачисляются из админки. Вопрос «сколько книг во фри-тир» не ждёт владельца — его больше нет. Остаётся ИНЖЕНЕРНАЯ половина, и она НЕ требует ничьего слова: ретеншен `rejected`-книг, свип каталогов-сирот, потолок диска — зона решает сама. Гейт один и не продуктовый: открытая регистрация, которой в закрытой бете нет. Записано в СТРОКУ, а не только пингом, потому что пинг уезжает в архив (та же причина, по которой правится `П-12` бэклога). | open | сессия P5 (самопроверка, ось «что этот маршрут создаёт») | +| PD-175 | hardening | minor | `internal/books/`, `internal/httpapi/v0.go` `createBook` | **Квоты на интейк нет: аутентифицированный аккаунт может писать на диск оператора неограниченно.** Пер-маршрутный потолок (PD-72) ограничивает ОДИН аплоад (64 МиБ по умолчанию), число аплоадов — ничто: ни лимита книг на аккаунт, ни ретеншена. Отклонённая по вине ИСТОЧНИКА книга свой файл теряет (каталог удаляется), а отклонённая по вине ДЕПЛОЯ — сохраняет намеренно (удалять чужую загрузку из-за своей поломки нельзя), и такие каталоги не чистит никто. Лечится квотой на аккаунт плюс свипом ретеншена по `rejected`; и то и другое — продуктовая политика (сколько книг входит в фри-тир), поэтому заведено, а не выбрано зоной ⚠ Третья половина того же вопроса — УДАЛЕНИЕ книги: `pgstore.DeleteBook` убирает строку и закрытые резервации и НЕ трогает каталог книги на диске, а ручки удаления в контракте нет вовсе (гейт PD-122). То есть сегодня утечки нет, потому что удалять нечем; день, когда ручка появится, — это и день, когда каталог обязан уходить вместе со строкой, и гард «только под `BooksDir`» для этого уже есть (`books.owns`). Диспозиция: закрывать ВМЕСТЕ с ручкой удаления, не раньше и не позже ⚠ Дополнено адверсариальным ревью P5 двумя фактами, которые делают строку острее, чем она написана: (1) пока развилка `book.yaml` не ратифицирована, ЛЮБАЯ загрузка приходит к `rejected/not_configured` — то есть путь «интейк пишет на диск и никто не убирает» сегодня ординарный, а не краевой; (2) у `rejected` нет ВЫХОДА вовсе: ни перепарса, ни удаления в контракте нет, строка остаётся в библиотеке навсегда. ⚠ Дополнено кросс-семейным ревью (Fable, 11.08): крэш-окно «строка закоммичена — каталог ещё не снесён» оставляет каталог-сироту, которого не найдёт никто (`StuckIntake` берёт только `uploading` и `parsing`). У отказа порядок перевёрнут в самоизлечивающийся — сначала каталог, потом строка, — а у брошенной загрузки перевернуть нельзя (каталог можно сносить только убедившись, что строки нет), так что окно там остаётся и закрывается тем же свипом ретеншена, что и вся строка ⚠ **ПРОДУКТОВАЯ ПОЛОВИНА СНЯТА — D39.176 п.1 (слово владельца 30.08); диспозиция записана паком P12 31.08 по пингу аудита доков.** Квот НЕТ и не будет, фри-тир-лимиты не проектируются: живём на покупке API, бонусы зачисляются из админки. Вопрос «сколько книг во фри-тир» не ждёт владельца — его больше нет. Остаётся ИНЖЕНЕРНАЯ половина, и она НЕ требует ничьего слова: ретеншен `rejected`-книг, свип каталогов-сирот, потолок диска — зона решает сама. Гейт один и не продуктовый: открытая регистрация, которой в закрытой бете нет. | open | сессия P5 (самопроверка, ось «что этот маршрут создаёт») | | 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) | @@ -67,14 +65,14 @@ | ID | Класс | Серьёзность | Где | Суть | Статус | Источник | |---|---|---|---|---|---|---| -| PD-396 | standards | info | `internal/pgstore/books.go:311`=`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-396 | standards | info | `internal/pgstore/books.go:311`=`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`: та про потерю значения между автокоммитами, и её свойство построено. Диспозиция — вопрос владельца, а не зоны: либо назначить владельца колонки (один писатель), либо записать расхождение как ожидаемое до появления читателя Воспроизведение — две команды без конвейера (символ вертикальной черты в ячейку регистра не влезает): `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:1124`=`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` ⚠ **ПОЛОВИНА ЗАКРЫТА паком `sqlc` (`63fcee5`), и обе половины пере-проверены посадками — строка остаётся `open` ровно на остатке.** ЗАКРЫТ сам свип-предикат: `DeleteOldLoginEvents` теперь зовёт `pgstore.TestTheLoginJournalRetentionDeletesOnlyWhatIsOlderThanTheCutoff`, и он утверждает ЧИСЛО удалённых строк плюс границу (в фикстуре есть строка РОВНО на отсечке, потому что предикат строгий и без неё `<` неотличимо от `<=`). Мутация M16 (`and 1=0`) теперь красная адресно — «deleted 0 rows, want exactly 1». **НЕ закрыто и остаётся живым: сам СРОК хранения и вызов свипа.** Мутация M15 — `loginJournalRetention` 180 суток → 180 ЛЕТ (`cmd/tmplatformd/main.go:257`) — пере-прогнана 29.08 на полной батарее конвертированного дерева: **EXIT=0, ноль красных**. Причина остатка структурная и не лечится в `pgstore`: константа и `sweepLogins` живут в пакете `main`, куда тест `pgstore` не достаёт. То есть класс `PD-1` здесь снят с ЗАПРОСА и стоит на КОНФИГУРАЦИИ | 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`. Правило записано здесь, а не только в отчёте, потому что пак переживёт именно строка ⚠ **Рецепт вместе с доводом про маскировку дельты и правилом топичности вердикта записан пунктом 3 в `ENGINEERING_STANDARDS` §3** — долговечный зонный носитель, а не промт пака (промты после лендинга архивируются) | open | ревью-пак P8-REVIEW (координатор пака, цена замерена этой же сессией) | +| 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`, а не сообщает, что запущен вне репозитория. Стоило времени КАЖДОМУ, кто работал в копии — все девять субагентов пака и координатор, — и один агент едва не завёл ложный красный находкой. ⚠ **ДИСПОЗИЦИЯ 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 определяет полную приёмку через ТРИ ВНЕШНИХ условия. Ценность зоны — не герметичность, а громкий учёт непроверенного. **ЛЕЧЕНИЕ — РЕЦЕПТ, А НЕ ГЕЙТ** (сам рецепт и его довод — `ENGINEERING_STANDARDS` §3 п.3). Второй, необязательный шаг: резолвить канон маркер-обходом по образцу `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 решил НЕ БРАТЬ с доводом; и класс он не убивает, а переносит — генерат коммитится внутрь модуля, то есть второй источник истины) · вендорить снапшот канона в модуль (то же самое) ⚠ **Рецепт вместе с доводом про маскировку дельты и правилом топичности вердикта записан пунктом 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 (сверка) | @@ -155,22 +153,22 @@ | 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`). — **закрыто:** `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-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` 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-7 | bug | info | `internal/pgstore/sessions.go:78` | `DeleteExpiredSessions` никем не вызывается — **закрыто:** свип сессий раз в час в демоне (`sweepSessions`), плюс свип брошенных логинов раз в 15 минут | fixed(P1, дерево сессии) | приёмка P0 | +| PD-8 | hardening | info | `internal/auth/session.go:18` | Писателя куки ещё нет; `__Host-` требует Secure ⇒ локальный dev по HTTP куку не поставит. — **закрыто:** `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 хендлеров. — **закрыто:** `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-события выбрасываются, никто не оповещён. — **закрыто:** сбой синка ОСТАНАВЛИВАЕТ прогон (`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-13 | bug | info | `internal/ingest/supervisor.go:64` | Краш платформы осиротляет процесс движка: ни process-group, ни pidfile, ни пути реаттача (поток невосстановим, повторный спавн упрётся в EXCLUSIVE-лок) — **закрыто:** группа процессов (`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 — задушить дешёво — **закрыто:** собственный таймаут 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-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 (посадка мутации) | @@ -180,7 +178,7 @@ | 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-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, дерево сессии) | ревью безопасности (исполнением) | @@ -218,7 +216,7 @@ | 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`» падает. | 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-файлы бесплатно. Пин — `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-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, упор в абсолютный срок). ⚠ Срок куки — `min(idle, остаток абсолютного)`: безусловный idle-TTL дал бы куку, пережившую абсолютный срок, и 401 вместо чистого «вы вышли» | 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 (линза вне карты) | @@ -230,7 +228,7 @@ | 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-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`. На пути расчёта это «попытка стоила ничего»: холд освобождается, списания нет. Латентно до воркера — **закрыто:** литерал `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 и стёртую куку. Независимо измерено панелью. — **закрыто:** два ведра вместо одного (`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 (посадка мутации) | @@ -248,7 +246,7 @@ | 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-99 | hardening | info | `internal/ingest/supervisor.go:102` | INFO-лог «engine started» пишет `args` целиком. Сегодня безвредно, но воркер будет передавать движку идентификатор книги и потолок аргументами ⇒ book-id и денежная сумма попадут в INFO платформы (D39.84 + норма зоны «id книги в логи не текут») — **закрыто:** 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`. Внутри одного пайпа передоставки нет, так что отказ декодера защитим; непропорциональна РЕАКЦИЯ. ⚠ **Пере-диспозиция (эррата №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 /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 (живая проба сквозного прогона) | @@ -304,7 +302,7 @@ | 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(третий раунд, дерево сессии) | ре-чек оркестратора (FP5-9) | -| 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(третий раунд, дерево сессии) | ре-чек оркестратора (хвосты а, б) | +| 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 при лендинге:** переименование этой строки в PD-199 откачено (ID стабилен навсегда, коммит-первоисточник `69d485a`), содержимое заведённой рядом второй строки слито сюда | fixed(третий раунд, дерево сессии) | ре-чек оркестратора (хвосты а, б) | ## Закрытые — дофикс-2 P5 (кросс-семейное ревью дофикса, 13.08) @@ -340,14 +338,14 @@ | 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 — не «блокировать движок или ронять события», а «что делает движок, когда журнал не пишется». ⚠ **ЗАКРЫТИЕ ПО ФАКТУ КОДА (P6):** политика выбрана ЯВНО и залендена движком (D39.131) — эмиттер ДЕГРАДИРУЕТ ГРОМКО и прогон продолжается: `emit` не возвращает ошибку никогда, первый отказ пишется ERROR один раз на серию, строки остаются в outbox и повторяются целым префиксом, так что транзиентный ENOSPC/EIO самолечится, а fsync на событие сознательно не делается (журнал — проекция коммитов SQLite при `synchronous=NORMAL`, проекция не может быть durable сильнее источника). Блокировать прогон отвергнуто явно. У платформы обратного давления в этом смысле нет по построению: она ТЕЙЛИТ файл, а не кормится пайпом; её собственная деградация — карантин проекции с ре-синком (PD-105). Открытого остатка у строки нет | fixed(P6, дерево сессии) | приёмка P1 (абстрактный разбор двумя агентами, оба независимо) | +| PD-60 | bug | minor | `internal/ingest/supervisor.go`, шов | ⚠ **ПЕРЕ-ДИСПОЗИЦИЯ (эррата №15, 07.08): постановка строки устарела.** Она рассуждает про 64 КиБ пайпа, а ратифицированный транспорт (D39.106 п.2) — тейл `events.jsonl` с курсором: у файла обратного давления в этом смысле нет вовсе, зато появляются свои свойства (fsync-политика, ротация, отставание тейлера, поведение при заполненном диске). ⚠ **ЗАКРЫТИЕ ПО ФАКТУ КОДА (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 в `/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»). — **ЗАКРЫТО (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` моделирует именно её. ⚠ **Половина закрыта паком P5 (D39.128-ветка), половина осталась и это названо:** ПОЛЬЗОВАТЕЛЬСКИЙ стоп больше не приезжает как `failed` — платформа пишет намерение стопа (`runs.stop_requested_at`, миграция 00014) ДО сигнала и классифицирует маркер по нему (`outcome`/`stoppedOnRequest`), с гардом гонки «стоп против самостоятельного финиша» по времени маркера и чистым исходом движка (0/2/3) поверх намерения; пины `TestARunTheUserStoppedIsNotReportedAsFailed`, `TestARunThatEndedBeforeTheStopKeepsItsOwnOutcome`, `TestACleanExitOutranksAStopThatArrivedTooLate`. ⚠ **ОСТАТОК ЗАКРЫТ (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-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); `$` в этой сессии исполнением не проверялся. — **закрыто (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 закрыл только «конфигурации нет вовсе». — **ЗАКРЫТО (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 | @@ -386,11 +384,11 @@ |---|---|---|---|---|---|---| | 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 при закрытии утверждает «аккаунт создаётся с нулём, начисление руками из админки» — один из двух текстов лжёт, и это чинится независимо от продуктового решения. Ограничитель у автогранта один — лимитер входа; агрегатного потолка, счётчика и алерта нет (грепнуто). ⚠ **ЗАКРЫТО 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), не правка спеки зоной ⚠ **ЗАКРЫТО:** правило записано в каноне 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-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 на форму, приславшую поля после файла, и это поведение спека не описывает. ⚠ **ЗАКРЫТО:** правило записано в каноне 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`). Следствие для пользователя: экран покажет «не удалось разобрать» без различения «файл не тот» и «наш движок был недоступен» — а это разные советы ⚠ **ЗАКРЫТО:** канон 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 только для СТАРТА прогона. Вопрос один на оба маршрута ⚠ **ЗАКРЫТО каноном 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 в перечне операции нет ⚠ **ЗАКРЫТО каноном 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-174 | standards | minor | `internal/httpapi/v0.go` `contractRoutes` | **`POST /books` на инстансе без интейка отвечает 404, которого спека для этого маршрута не предусматривает.** Форма унаследована от уже принятого решения зоны — непостроенный/несмонтированный маршрут остаётся охраняемым 404 (`d.Runs == nil` так же), и это честнее 503 на КАЖДЫЙ аплоад, который инстанс всё равно не примет. Но контрактно это тот же класс, что PD-112: код ответа, которого в перечне операции нет. ⚠ Дополнено адверсариальным ревью P5: тот же класс есть и у РЕЗЮМА — `503` на деплое без маркер-команды или без шаблона потолка, тогда как 0.2.1 ратифицировала 503 только для СТАРТА прогона ⚠ **ЗАКРЫТО каноном 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-ответа (дофикс это исправил, см. ниже). ⚠ Дополнено дофиксом приёмки: в тот же пакет вопросов входит **408** на просроченный дедлайн загрузки — тело, не успевшее прийти за отведённое маршруту время, это медленный клиент, и 408 по RFC 9110 §15.5.9 говорит именно это, включая то, что лечение — повтор; кода 408 в перечне операции нет ⚠ **ЗАКРЫТО каноном 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`, что спека сама и предписывает клиенту рендерить для незнакомой причины. ⚠ Найдено ЖИВОЙ ПРОБОЙ, а не чтением: до фикса значение уезжало на провод, и клиент, сгенерированный по спеке, его бы отверг. ⚠ **ЗАКРЫТО ратификацией 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 (линза автомата состояний) | @@ -413,7 +411,7 @@ | 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` (ещё два процесса движка) не укладывался, и дерево оставалось пустым, а свип этого не повторяет — **закрыто:** свой отвязанный бюджет, как на границе прогона. Бэкстоп-свип для книг с пустым деревом был построен доработкой 20.08 и СНЯТ актом 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`. ⚠ **ПЕРЕ-ДИСПОЗИЦИЯ 22.08:** прежний остаток строки («честность `decline`»: отклонённый термин уезжал в банк авто-строкой) снят вместе с глаголом — `decline` больше нет ни в одной ручке зоны (PD-370). Живой остаток другой и он НЕ здесь: ратифицированная модель оставляет пер-термной ПРАВКУ («поправить/добавить термин»), ручки под неё нет ни в зоне, ни в каноне, и это строка 199(а) единого бэклога плюс контрактная половина PD-370. | fixed(приёмка P7, дерево сессии) | приёмка P7 | +| 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`. ⚠ **ПЕРЕ-ДИСПОЗИЦИЯ 22.08:** прежний остаток строки («честность `decline`»: отклонённый термин уезжал в банк авто-строкой) снят вместе с глаголом — `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) | @@ -533,10 +531,10 @@ ## Закрытые — эра 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` не прекращают. Пере-проверено трижды независимо (финдер, рефутер в отдельной копии, координатор); на демоне с `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:614`=`a book whose event stream can NEVER end`. То есть окно утечки не ограничено прогоном. **(2)** Вес отказавшего КОМПЕНСИРУЮЩЕГО контроля наследуется от рисков, которые он компенсирует, а не от схемы кадра: `STACK_DECISIONS` §13 отказывается и от лимита одновременных сессий, и от собственной границы федеративной сессии ИМЕННО в обмен на мгновенный отзыв и два срока — а открытый поток ускользает от всех трёх разом, и у §13 не остаётся содержания. **(3)** Провалено требование УРОВНЯ 1 при объявленном зоной L2 — это дыра ниже собственного пола, а не отклонение от лучших практик. ⚠ **ЗАКРЫТО ПАКОМ 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:461`=`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:564`=`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:896`=`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):** улика пере-снята на полной батарее — прогон `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-385 | bug | **major** | `internal/pgstore/runs.go:461`=`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:564`=`select finished_at from runs where id = $1` ⚠ якорь пере-нацелен паком P11: прежняя строка отвечала `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:896`=`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`. ⚠ **Пере-проверено на ПОЛНОЙ батарее координатором пака (мутация M11):** улика пере-снята на полной батарее — прогон `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:278`=`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:724`=`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-391 | bug | minor | `internal/pgstore/sink.go:724`=`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:398`=`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 — дисциплина кода, не схемы» рядом с обещанием. Воспроизведение: `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:227`=`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` ⚠ **ЗАКРЫТО ПАКОМ 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, линза денег под гонкой) | @@ -546,19 +544,17 @@ ## Закрытые — перенос по секциям (аудит 29.08: статус был переведён раньше, а строка осталась под открытым заголовком) -> Ни одна строка здесь не меняла СТАТУС этим переносом — каждая уже несла свой `fixed(...)` с актом, -> которым была закрыта. Двигалась только позиция: они лежали в секциях «Открытые», то есть человек, -> читающий открытый список по весу, видел закрытую работу и мог пойти чинить построенное — класс, -> который `ENGINEERING_STANDARDS` §3.8 называет стоившим зоне порядка десятой доли строк. Счёт по -> статусам от переноса не изменился (`counts.py` ключуется формой строки, а не секцией); изменилось -> то, что видит читатель. +> Ни одна строка здесь не меняла СТАТУС — каждая уже несла свой `fixed(...)`; двигалась только +> позиция. Читающий открытый список по весу видел закрытую работу и мог пойти чинить построенное — +> класс, который `ENGINEERING_STANDARDS` §3.8 называет стоившим зоне порядка десятой доли строк. +> Счёт по статусам от переноса не изменился (`counts.py` ключуется формой строки, а не секцией). | 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 своим пином). | fixed(D39.161 — контрактная половина; 22.08 слово владельца — зонная; перевод по аудиту 28.08) | поправка владельца 22.08; ратификация D39.144; аудит 31 агента 22.08 · минор D39.161 · аудит документации 28.08 | -| 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-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` и объясняется. ⚠ **Лечение ПОСТРОЕНО в этом же дереве и зелено:** базы снимаются на ФИКСИРОВАННЫХ колонках (`chapters_before` — редакторская, `draft_before` — черновая), пара «числитель+база» выбирается ЖИВЫМ флагом в момент чтения (`runDone`), миграция `00026` пере-снимает базы live-прогонов, порядок переворота запинен ровно как в проде — `TestAFlagThatFlipsAfterStartDoesNotStrandTheBar` Остаток и ПОСЛЕ лечения: на смешанном заделе (купленный диапазон шире начернённого) переворот двигает знаменатель вверх в окне первого progress-события — транзиентный провал доли, прежняя суть первой редакции ⚠ **ЗАКРЫТА лендингом P9 (58bae30, акт D39.162):** лечение (фиксированные базы + пара живым флагом + миграция 00026 + пин `TestAFlagThatFlipsAfterStartDoesNotStrandTheBar`) заленжено и зелено. Транзиентный остаток окна первого progress-события остаётся named-границей (PD-401-остаток в словаре зоны) | fixed(лендинг 58bae30, акт D39.162; перевод по аудиту 28.08) | пак P9: опровергатель формы полосы (первая редакция) · блокер ревьюера приёмки 27.08 (пере-формулировка) · аудит документации 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-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) называет отвергнутым. ⚠ **ЗАКРЫТА фикс-раундом 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-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` и объясняется. ⚠ Остаток и ПОСЛЕ лечения: на смешанном заделе (купленный диапазон шире начернённого) переворот двигает знаменатель вверх в окне первого progress-события — транзиентный провал доли ⚠ **ЗАКРЫТА лендингом P9 (58bae30, акт D39.162):** лечение (фиксированные базы + пара живым флагом + миграция 00026 + пин `TestAFlagThatFlipsAfterStartDoesNotStrandTheBar`) заленжено и зелено. Транзиентный остаток окна первого progress-события остаётся named-границей (PD-401-остаток в словаре зоны) | fixed(лендинг 58bae30, акт D39.162; перевод по аудиту 28.08) | пак P9: опровергатель формы полосы (первая редакция) · блокер ревьюера приёмки 27.08 (пере-формулировка) · аудит документации 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-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` попадают в свои корзины правильно. Дыра латентная: лишняя черта в одной из ТРЁХ последних колонок сдвинет уже их, и гейт снова промолчит. ⚠⚠ **ВТОРАЯ ПОЛОВИНА ПОСТРОЕНА 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` по-прежнему ОТКЛОНЕНА и теперь на точном основании: после уважения экранирования в бэклоге остаются пять строк с законной сырой чертой внутри инлайн-кода, и их хвост чист. ⚠ **ЗАКРЫТА 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-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`), значит снятие — правка с пином, а не вычёркивание. ⚠ Найдено САМОЙ контрактной сессией и принесено пингом: промт (§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-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, рефутер:** механизм, который строка описывает (крах между ДВУМЯ автокоммитами `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 | fixed(акт D39.162, перевод по аудиту 28.08) | самопроверка дофикса (ревью вне карты) · аудит документации 28.08 | | 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 (линза энтропии) | @@ -580,13 +576,13 @@ |---|---|---|---|---|---|---| | 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), …)` ровно с этой мотивировкой. ⚠ **ЗАКРЫТО пак P12 (30–31.08).** `RecordBankMove` идёт на СОБСТВЕННОМ контексте: `context.WithTimeout(context.WithoutCancel(ctx), recordBudget)` (`internal/runs/bank.go`, тот же класс, что `internal/httpapi/idempotency.go:151` `settleCtx` и `internal/books/parse.go:271` `writeCtx`). Детачится РОВНО запись и ничего больше — разбор шире: детач на двери снёс бы два намеренно построенных свойства (мёртвый клиент покидает очередь книги; бюджет verb'а), а детач самого verb'а не нужен, потому что ранний обрыв даёт SIGTERM → движок проверяет контекст ДО своих записей → exit 5 → `ErrBankUnavailable`, и не теряется ничего. `recordBudget` (10 с) а не 30-секундный `writeBudget` интейка: запись идёт ПОД блокировкой книги. Пин — `runs.TestTheBankMoveFactSurvivesAClientThatHungUp` (гейт `entered`/`release` держит verb в полёте, отмена приходит В ЭТОТ момент — контекст, отменённый ДО вызова, до записи не доходит вовсе, его съедает `lockBook`). Посадка M1 (снять `WithoutCancel`) — КРАСНАЯ адресно, соседний пин зелен. ⚠ **Битый якорь строки исправлен:** `internal/pgstore/idempotency.go:150` — это чтение ключа, никакого detach там нет; истинные якоря класса — `internal/httpapi/idempotency.go:151`, `internal/books/books.go:205,215` (тело `internal/books/parse.go:271`) и, чего строка не знала, `internal/runs/reconcile.go:119,277` — ТОТ ЖЕ пакет. ⚠ **Остаточное окно названо, а не закрыто:** смерть ПРОЦЕССА между verb и записью (SIGKILL, OOM, истёкший grace деплоя) по-прежнему теряет факт навсегда — `WithoutCancel` переживает клиента, не процесс. Окно сузилось с «вся фаза записи движка плюс round-trip БД» до «round-trip БД». Свипа быть не может: после факта у платформы нет свидетеля свёрнутой коррекции, ровно поэтому дверь штампует из СОБСТВЕННОЙ квитанции. | fixed(пак P12) | приёмка оркестратора №19 по паку P11 (охотник вне карты) | | 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) при этом честный — клиент получает от одной ревизии книги два разных бара. ⚠ **ЗАКРЫТА read-половина пак P12 (30–31.08)** (write-половина закрыта F12 ранее). Порядок «живой прогон ПЕРВЫМ» вынесен в одну константу `newestRun` = `order by (finished_at is null) desc, started_at desc, id desc limit 1` (`internal/pgstore/readmodel.go`) и вклеен во ВСЕ 10 мест склейки `lastRun` — плюс, и это половина, которой лечение read-модели не сделало бы: ТОТ ЖЕ порядок вклеен в `LatestRun` (`internal/pgstore/runs.go`), где `order by started_at desc` был выписан РУКОЙ. Оставить его — значило ПЕРЕВЕРНУТЬ дефект: экран пошёл бы за живым прогоном, а `Resume` отказал бы ему как «не последнему». Тотальность порядка обеспечивает `runs_one_live_per_book` (00002:66, уникальный по `book_id` при `finished_at is null`) — живой ровно один. Пины судят ПО МЕСТАМ, не зеленью батареи: `pgstore.TestALiveRunOutranksAFinishedOneOnEveryBookScopedRead` (карточка, её `Run`, строка библиотеки, `LatestRun`), `pgstore.TestTheStreamsFramesFollowTheLiveRunAndNotTheFinishedOne` (кадр статуса, `bookScope`, полоса) и `pgstore.TestWithNoLiveRunTheBookStillResolvesToTheNewestStarted` — обычный случай не тронут. Посадки M6 (снять живой терм) и M7 (расклеить `LatestRun`) — обе КРАСНЫЕ адресно. ⚠ **Цена, названная честно:** индекс `runs_book_idx (book_id, started_at desc)` этот порядок больше не обслуживает целиком — сортировка идёт по выражению. Латераль работает по ОДНОЙ книге, где прогонов единицы, так что замера это не потребовало; если у книги когда-нибудь станут сотни прогонов, носитель — новый индекс, а не откат порядка. | fixed(пак P12) | воркфлоу-ревью 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-прогонами воркфлоу. Корень продуктовый — смена формы конвейера на живой книге; лечение за решением владельца/оркестратора ⚠ **ЗАКРЫТА пак P12 (30–31.08), по решению владельца D39.165 §2** (регистр отставал от D-лога: «лечение за решением владельца» стояло, хотя направление дано 28.08). Носитель границы — НОВАЯ пара колонок `books.shape_epoch` + `books.epoch_editor` (миграция 00030): форма текущей эпохи ПРИСВАИВАЕТСЯ, счётчик считает пересечения. `books.edit_wave` остаётся и остаётся МОНОТОННЫМ — пин D39.153 §4б не тронут; уехал только пожизненный счёт, `finishedUnits` читает `epochWave` = `coalesce(b.epoch_editor, b.edit_wave, true)`. Движение `structure_version` отвергнуто: оно инвалидирует курсоры, идентификаторы пар и окна термов, а нарезка книги не менялась — это была бы ложь о структуре ради счётчика. ⚠ **ЗАКРЫТА ОДНА половина из двух, и вторая ОСТАЁТСЯ ОТКРЫТОЙ — см. `PD-435`.** Закрыт СЧЁТ КНИГИ: шкала покупки перестаёт продавать переведённое, `ChaptersLeft` падает, книга дочитывается. НЕ закрыта ПОЛОСА ПРОГОНА: на деплое, где редактора убрали, она по-прежнему тарифицирует edit-волну, которой не будет, и до единицы не доходит. ⚠ Полоса прогона ОСТАВЛЕНА на МОНОТОННОМ `books.edit_wave` — канон держит её одной монотонной дробью (строка 200), а эпоха ПРИСВАИВАЕТСЯ и ходит в обе стороны; попытка перевести полосу на эпоху была откачена этим же паком по замеру (`PD-435`). На эпоху уехал только пожизненный счёт книги. `PD-401` не вскрыт (парность числителя и базлайна не тронута) и теперь запинен с той стороны тоже: `pgstore.TestTheRunsBarIsMonotoneAcrossAShapeBoundaryItSpans`. Ложный абзац `sink.go` о собственном коде СНЕСЁН целиком, не пере-нацелен. Канон — минор **0.9.0** (ратификация оркестратора 30.08): фраза `chapters_done` называет ВТОРУЮ границу пересчёта рядом с пере-нарезкой, и появилось поле `Book.shape_epoch` — НЕПРОЗРАЧНЫЙ счётчик поколения; сама форма («есть ли редактор») на провод НЕ выносится, это было бы третьим исключением границы, которого D39.163 не даёт. `STACK_DECISIONS` §35 пере-подписан, прежняя формулировка оставлена с датой и причиной. Пины: `pgstore.TestRemovingTheEditorRecomputesTheBooksCountInsteadOfFreezingIt` и `pgstore.TestTheEpochMovesOnBoundariesAndNotOnEveryAnnouncement`. ⚠ **Первая редакция этой строки называла третьим пином `TestARunOnADeploymentThatDroppedTheEditorCanReachOne` — теста с таким именем в зоне НЕТ ни одного** (греп пуст): он существовал в откаченной редакции и имя пережило откат. Свойство, которое он обещал, несёт открытая `PD-435`, а не пин. Посадки M8 (счёт обратно на монотонный флаг) и M9 (эпоха накапливает вместо присвоения) — обе КРАСНЫЕ адресно. | fixed(пак P12) | воркфлоу-ревью P9 28.08 (линзы bar:stage-caption · bar:double-count · bar:monotonicity), диспозиция оркестратора 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-прогонами воркфлоу. Корень продуктовый — смена формы конвейера на живой книге. ⚠ **ЗАКРЫТА пак P12 (30–31.08), по решению владельца D39.165 §2.** Носитель границы — НОВАЯ пара колонок `books.shape_epoch` + `books.epoch_editor` (миграция 00030): форма текущей эпохи ПРИСВАИВАЕТСЯ, счётчик считает пересечения. `books.edit_wave` остаётся и остаётся МОНОТОННЫМ — пин D39.153 §4б не тронут; уехал только пожизненный счёт, `finishedUnits` читает `epochWave` = `coalesce(b.epoch_editor, b.edit_wave, true)`. Движение `structure_version` отвергнуто: оно инвалидирует курсоры, идентификаторы пар и окна термов, а нарезка книги не менялась — это была бы ложь о структуре ради счётчика. ⚠ **ЗАКРЫТА ОДНА половина из двух, и вторая ОСТАЁТСЯ ОТКРЫТОЙ — см. `PD-435`.** Закрыт СЧЁТ КНИГИ: шкала покупки перестаёт продавать переведённое, `ChaptersLeft` падает, книга дочитывается. НЕ закрыта ПОЛОСА ПРОГОНА: на деплое, где редактора убрали, она по-прежнему тарифицирует edit-волну, которой не будет, и до единицы не доходит. ⚠ Полоса прогона ОСТАВЛЕНА на МОНОТОННОМ `books.edit_wave` — канон держит её одной монотонной дробью (строка 200), а эпоха ПРИСВАИВАЕТСЯ и ходит в обе стороны; попытка перевести полосу на эпоху была откачена этим же паком по замеру (`PD-435`). На эпоху уехал только пожизненный счёт книги. `PD-401` не вскрыт (парность числителя и базлайна не тронута) и теперь запинен с той стороны тоже: `pgstore.TestTheRunsBarIsMonotoneAcrossAShapeBoundaryItSpans`. Ложный абзац `sink.go` о собственном коде СНЕСЁН целиком, не пере-нацелен. Канон — минор **0.9.0** (ратификация оркестратора 30.08): фраза `chapters_done` называет ВТОРУЮ границу пересчёта рядом с пере-нарезкой, и появилось поле `Book.shape_epoch` — НЕПРОЗРАЧНЫЙ счётчик поколения; сама форма («есть ли редактор») на провод НЕ выносится, это было бы третьим исключением границы, которого D39.163 не даёт. `STACK_DECISIONS` §35 пере-подписан, прежняя формулировка оставлена с датой и причиной. Пины: `pgstore.TestRemovingTheEditorRecomputesTheBooksCountInsteadOfFreezingIt` и `pgstore.TestTheEpochMovesOnBoundariesAndNotOnEveryAnnouncement`. Посадки M8 (счёт обратно на монотонный флаг) и M9 (эпоха накапливает вместо присвоения) — обе КРАСНЫЕ адресно. | fixed(пак P12) | воркфлоу-ревью 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`) ⚠ **ЗАКРЫТА пак P12 (30–31.08) ТЕМ ЖЕ носителем, что `PD-403`** — это одна арифметика с двух сторон, и закрывать её порознь значило бы оставить строку, чья репродукция больше не описывает дефект. Разрешённое движение флага (редактора ДОБАВИЛИ) по-прежнему пересчитывает счёт — так решил владелец, — но теперь пересчёт ОБЪЯВЛЯЕТ СЕБЯ: `books.shape_epoch` двигается той же транзакцией, и клиент видит новое поколение, а не ход счётчика назад. Крайний случай строки (книга, начерченная ПОЛНОСТЬЮ под `false`, недочитываемая редактором ни одним действием API) уходит с ней же: `ChaptersLeft` считается через эпоху, то есть после прихода редактора книга снова имеет что купить. Пин — `pgstore.TestAddingTheEditorMovesTheEpochWithTheCountItRecomputes`; посадка M9 КРАСНАЯ адресно. | fixed(пак P12) | воркфлоу-ревью 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 ⚠ **ЗАКРЫТА пак P12 (30–31.08), формой «отказ старта до дерева»** (из двух названных зоной лечений). Довод против второго — полосы из progress-событий: те колонки НИКТО не читает (`PD-411`), то есть «как раньше» означало бы построить полосу заново поверх мёртвого носителя; а разделение `nothingIsRunning` тронуло бы конъюнкт, общий с `ReadStream` и лизинг-дисциплиной §34. Отказ стоит ДО взятия холда: `internal/runs/runs.go` `Start`, предикат `book.ChapterCount > 0 && !book.HasTree` (новое поле `BookRunContext.HasTree`, `exists(select 1 from chapters …)`). Слово на проводе — существующее `book_not_ready` (409), НЕ новое: собственная глосса канона говорит «still arriving, still being CUT, or was rejected», а книга, должная дерево, ещё нарезается. Ложный комментарий `sink.go` («the counters the screen reads today come from the progress event») СНЕСЁН — он и был прикрытием этой строки. ⚠ **Что вскрылось при этом и названо строкой:** ВСЯ runs-батарея ездила на книге, объявляющей главы и не материализовавшей ни одной, — фикстуры РАСШИРЕНЫ деревом (`newFixture`, `secondBook`), не обойдены. Пин — `runs.TestARunIsRefusedOverABookWhoseChaptersWereNeverMaterialised` (проверяет и то, что отказ НЕ ДВИНУЛ ДЕНЬГИ) и `runs.TestABookThatDeclaresNoChaptersIsNotRefusedAsUnmaterialised` (книга, объявившая НОЛЬ глав, под гард не попадает — её отказывает то, что должно: покупать нечего); посадки M10 и M19 КРАСНЫЕ адресно. ⚠ **Названо адверсариальным проходом пака и подписано в коде, а не замолчано:** одна популяция этим отказом становится НЕзапускаемой до действия оператора — книга, чей долг читательской поверхности СПИСАН после исчерпания попыток (`AbandonReadModelDebt`): она никому ничего не должна, свип её не подберёт, и отказ стоит, пока не попросят заново. Ручка существует и названа прямо в комментарии гарда — `tmplatformctl book refresh --book `, список — `tmplatformctl books --abandoned`. До пака такая книга запускалась и показывала мёртвую полосу; теперь она отказывает и указывает на лечение. | fixed(пак P12) | воркфлоу-ревью P9 28.08 (линзы bar:stage-caption · bar:double-count), диспозиция оркестратора 28.08: строкой, достижимость замером (акт D39.162) | -| 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: миграцию на снос в этом паке не заводить) ⚠ **ЗАКРЫТА пак P12 (30–31.08)** (запрет сноса был пер-паковым, P9): колонки снесены миграцией 00031, оба писателя и оба лгущих комментария удалены. Четыре теста, наблюдавшие свойства ЧЕРЕЗ эти колонки, пере-нацелены на то, что progress-событие materialises на самом деле — ETA и объявленную ФОРМУ; это не ослабление, а восстановление предмета: у утверждения над снесённой колонкой предмета нет. ⚠ **СНОС ВСКРЫЛ ГАП, и он заведён отдельной строкой, а не замолчан:** РЕСИНК — канал починки для прогона, чей поток в карантине, — писал прогресс ТОЛЬКО в эти колонки. Он не трогает ни `unit_resolutions`, ни `chapters`, значит не материализует ничего, что читает экран, и полоса такого прогона стоит всю его жизнь, как бы исправно ни отвечал `tmctl status`. Мёртвые колонки это ПРЯТАЛИ: код выглядел так, будто канал починки чинит. | fixed(пак P12) | воркфлоу-ревью P9 28.08 (линзы race:flip-read-consistency · bar:double-count · bar:stage-caption), диспозиция оркестратора 28.08: отдельной строкой класса A | +| 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` защищает полосу, которой не существует, — следующая сессия будет искать монотонность бара не там, где он живёт. ⚠ **ЗАКРЫТА пак P12 (30–31.08)** (запрет сноса в P9 был пер-паковым — слово оркестратора 28.08): колонки снесены миграцией 00031, оба писателя и оба лгущих комментария удалены. Четыре теста, наблюдавшие свойства ЧЕРЕЗ эти колонки, пере-нацелены на то, что progress-событие materialises на самом деле — ETA и объявленную ФОРМУ; это не ослабление, а восстановление предмета: у утверждения над снесённой колонкой предмета нет. ⚠ **СНОС ВСКРЫЛ ГАП, и он заведён отдельной строкой, а не замолчан:** РЕСИНК — канал починки для прогона, чей поток в карантине, — писал прогресс ТОЛЬКО в эти колонки. Он не трогает ни `unit_resolutions`, ни `chapters`, значит не материализует ничего, что читает экран, и полоса такого прогона стоит всю его жизнь, как бы исправно ни отвечал `tmctl status`. Мёртвые колонки это ПРЯТАЛИ: код выглядел так, будто канал починки чинит. | fixed(пак P12) | воркфлоу-ревью P9 28.08 (линзы race:flip-read-consistency · bar:double-count · bar:stage-caption), диспозиция оркестратора 28.08: отдельной строкой класса A | | 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'ов и есть гонка, а не «что-то другое». ⚠ Направление, не решение: либо граница по ВРЕМЕНИ вместо числа раундов, либо ответ `ErrKeyInFlight` вместо 500 при исчерпании ⚠ **ЗАКРЫТА пак P12 (30–31.08), формой `ErrKeyInFlight`** (из двух названных зоной). Довод против границы по ВРЕМЕНИ — она дефект не закрывает: чем бы ни ограничивался цикл, он обязан чем-то ЗАВЕРШИТЬСЯ, и пока терминал — голый `fmt.Errorf`, это по-прежнему 500, просто реже; две «формы» лежат на разных осях — ответ обязателен, граница свободна. Плюс собственная часовая дисциплина файла: всё время в `ClaimIdempotency` берётся из ИНЖЕКТИРУЕМОГО `now`, а дедлайн по стенным часам внутри цикла ввёл бы второй, скрытый источник времени, непроверяемый под замороженными часами. Сделано: `pgstore.ErrKeyContended` ОБОРАЧИВАЕТ `ErrKeyInFlight` (лекарство у вызывающего то же — подождать `Retry-After` и повторить), словарь кодов контракта НЕ расширен — `idempotency_conflict`/`key_in_flight` уже есть; на HTTP один писатель ответа (`keyInFlight`), случай контенции стоит ПЕРЕД более широким и пишет WARN: громко у нас, тихо на проводе. ⚠ Пин НЕ ПРАВЛЕН (D39.121): `TestAClaimThatLostARaceToAReleaseIsRetriedAndNotAnError` принимал `ErrKeyInFlight` и по построению принимает обёртку. Новые пины: `pgstore.TestGivingUpOnAContendedKeyIsOneOfTheContractsOwnAnswers` и подтест «a claim that lost the race too often is told to wait, not answered 500». **Пере-замер серией ПОСЛЕ фикса:** `go test ./internal/pgstore -run '^TestAClaimThatLostARaceToAReleaseIsRetriedAndNotAnError$' -count=80` ⇒ **80 PASS, 0 FAIL, 0 SKIP** (счёт по `^--- PASS`, а не по коду выхода: all-skip читался бы как зелень). | fixed(пак P12) | попутная находка сессии P8-FIX (флейк батареи), передано оркестратору | | PD-431 | hardening | minor | `internal/pgstore/sessions.go:49` `Touch`, `internal/pgstore/queries/sessions.sql` `TouchSession`, пин отсутствует | **`Touch` выбрасывает `RowsAffected`, поэтому запрос, не обновивший НИ ОДНОЙ строки, неотличим от успеха — и батарея остаётся зелёной.** Замерено адверсариальным проходом пака `sqlc` 29.08: `where token_sha256 = $1` → `where 1 = 0 and token_sha256 = $1`, апдейт не трогает ничего — **18 пакетов, `EXIT=0`, ноль красных**. Контроль на том же стенде (обезвреженный `delete from reservations` в `DeleteBook`) даёт две красных, то есть харнесс работает и молчание относится к `Touch`, а не к прогону. Продуктовый смысл: скольжение окна бездействия — то, что держит активного пользователя в сессии, — не проверяет ни один живой тест; два его вызова (`pg_test.go:115`, `TestTouchCannotResurrectAnIdleExpiredSession`) решаются предикатами самого SQL и остаются истинными, если `Touch` не делает ничего. ⚠ **Класс, который ПОКРЫТИЕМ не ловится в принципе:** для `Exec`, чей тег отброшен, «оператор исполнился» — это всё, что покрытие когда-либо докажет; нужен мутационный проход по ЗАПИСЯМ. Именно поэтому пак `sqlc`, считавший непокрытые строки, нашёл три опасных оператора, а этот — четвёртый — не нашёл. ⚠ **ЗАКРЫТА пак P12 (30–31.08), ЧЕСТНОСТЬЮ ПРОВЕРКИ — поведение НЕ менялось** (слово оркестратора: ноль строк — законная гонка с отзывом). Новый сквозной пин `pgstore.TestTheIdleWindowSlidesThroughTheAuthenticatorAgainstALiveStore`: живой `*pgstore.Store` подставлен `auth.Authenticator` как его же `auth.SessionStore` — интерфейс он удовлетворял всегда, production не менялся, не хватало теста, который соединит два конца. Живёт в пакете `pgstore`, потому что `pgstore` импортирует `auth`, а не наоборот: тот же тест в `auth` — цикл импорта. Утверждение идёт через ПОЗДНИЙ `Lookup` за первоначальным дедлайном, а не через код ответа: ошибка `Touch` логируется и проглатывается, так что 200 не доказывает ничего. Второе утверждение — что скользнули ПРАВИЛЬНЫЕ часы (абсолютный потолок не двинулся). Посадка M12 — ИМЕННО та, которой строка заведена (`where 1 = 0 and token_sha256 = $1` в `TouchSession`, в исходнике и в генерённой константе): новый тест КРАСНЫЙ адресно, оба прежних `Touch`-теста ЗЕЛЁНЫЕ — то есть дельта ровно та, о которой строка. Добавлен второй пин на отзыв (`TestARevokedSessionIsDeniedThroughTheAuthenticatorAgainstALiveStore`). | fixed(пак P12) | адверсариальный проход пака `sqlc` (П-19), 29.08 | -| PD-432 | doc | minor | `docs/STACK_DECISIONS.md` §«Гейты батареи» (рецепт стенда), `~/.local/bin/tmctl` | **Рецепт второго гейта батареи не говорит, что движковый бинарь надо ПЕРЕСОБРАТЬ, и устаревший бинарь даёт КРАСНУЮ батарею, которая читается как дефект кода зоны.** Замерено 29.08 паком `sqlc`: `tmctl` стенда от 24.08 против сегодняшнего `backend/configs/models.yaml` — три красных теста в `internal/books` и `internal/runner`, сообщение `tmctl: config: parse …/models.yaml: yaml: unmarshal errors: line 137: field system_messages not found in type config.CapabilitiesConfig`. Диагноз стоит времени именно потому, что выглядит как ошибка платформы: падает платформенный тест, а лжёт бинарь движка, собранный до того, как в конфиг движка приехало поле. Лечение `go build -o <стенд>/tmctl ./cmd/tmctl` из `backend/` — после него те же пакеты зелёные. ⚠ Класс тот же, что у `PD-423`: условие батареи, которое рецепт называет неполно, и следующая сессия ищет дефект в своём диффе. Дописать в рецепт: движковый бинарь стенда обязан быть собран из ТЕКУЩЕГО `backend/`, а не переиспользован ⚠ **ЗАКРЫТА пак P12 (30–31.08):** в рецепт «Гейты батареи» дописано, что движковый бинарь второго гейта обязан быть СОБРАН из текущего `backend/`, с симптомом и командой. ⚠ **Той же правкой снята ОПРОВЕРГНУТАЯ команда-проверка четвёртого условия** (`cut -d: -f3 /proc/self/cgroup` ≠ `/init.scope`): к двум точкам `PD-423` пак добавил ТРЕТЬЮ — у оболочки этой сессии команда даёт `/`, а `TestARunIsBoundedByItsOwnCgroup` при этом зелен в полной батарее со скипами 0. Рецепт теперь велит судить по САМОМУ тесту; кандидат-замена (`cgroup.subtree_control` целевого среза) назван КАНДИДАТОМ и в рецепт не внесён — диагноз `PD-423` не установлен. ⚠ **Тем же классом найдено и починено на стенде: КОНФИГИ движка тоже протухают** — стендовая копия `backend/configs` не имела `langpacks/ru`, появившегося позже; правило дописано в рецепт рядом с бинарём. | fixed(пак P12) | пак `sqlc` (П-19), 29.08, найдено первым же прогоном полной батареи | +| PD-432 | doc | minor | `docs/STACK_DECISIONS.md` §«Гейты батареи» (рецепт стенда), `~/.local/bin/tmctl` | **Рецепт второго гейта батареи не говорит, что движковый бинарь надо ПЕРЕСОБРАТЬ, и устаревший бинарь даёт КРАСНУЮ батарею, которая читается как дефект кода зоны.** Замерено 29.08 паком `sqlc`: `tmctl` стенда от 24.08 против сегодняшнего `backend/configs/models.yaml` — три красных теста в `internal/books` и `internal/runner`, сообщение `tmctl: config: parse …/models.yaml: yaml: unmarshal errors: line 137: field system_messages not found in type config.CapabilitiesConfig`. Диагноз стоит времени именно потому, что выглядит как ошибка платформы: падает платформенный тест, а лжёт бинарь движка, собранный до того, как в конфиг движка приехало поле. ⚠ Класс тот же, что у `PD-423`: условие батареи, которое рецепт называет неполно, и следующая сессия ищет дефект в своём диффе. ⚠ **ЗАКРЫТА пак P12 (30–31.08):** в рецепт «Гейты батареи» дописано, что движковый бинарь второго гейта обязан быть СОБРАН из текущего `backend/`, с симптомом и командой. ⚠ **Той же правкой снята ОПРОВЕРГНУТАЯ команда-проверка четвёртого условия** (`cut -d: -f3 /proc/self/cgroup` ≠ `/init.scope`): к двум точкам `PD-423` пак добавил ТРЕТЬЮ — у оболочки этой сессии команда даёт `/`, а `TestARunIsBoundedByItsOwnCgroup` при этом зелен в полной батарее со скипами 0. Рецепт теперь велит судить по САМОМУ тесту; кандидат-замена (`cgroup.subtree_control` целевого среза) назван КАНДИДАТОМ и в рецепт не внесён — диагноз `PD-423` не установлен. ⚠ **Тем же классом найдено и починено на стенде: КОНФИГИ движка тоже протухают** — стендовая копия `backend/configs` не имела `langpacks/ru`, появившегося позже; правило дописано в рецепт рядом с бинарём. | fixed(пак P12) | пак `sqlc` (П-19), 29.08, найдено первым же прогоном полной батареи | | 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. ⚠ **ЗАКРЫТА пак P12 (30–31.08) по решению оркестратора: пол самосогласованности СИММЕТРИЧЕН, интейк отказывает.** `ingest.Manifest.Readable()` — ВТОРОЙ предикат рядом с `Whole()`, не расширение `DecodeManifest` (у того четыре пина намеренно декодируют частичные и чужие документы, и его собственная дока объявляет правило «значение не гейтим» решением). Стоит ПЕРВЫМ в `books.Parse`, до ветвей по `ChaptersTotal`: ниже этой черты документ, прочитанный неверно, неотличим от книги, в которой ничего нет, а ЭТО чтение удаляет аплоад. Класс — `parser_unavailable`: бюджет попыток тратится, файл остаётся. ⚠ **ПРАВКА ЗАПИНЕННОГО КОНТРАКТА, заказанная промтом P12 §3.8** — не подгонка под зелень: батарея интейка ездила на документах без списка глав, и фикстуры РАСШИРЕНЫ (`wholeManifest`), а не обойдены; поимённо пере-подписаны фикстуры `books_test.newFixture` и два манифеста `render_test`. Пин — `books.TestTheIntakeRefusesTheDocumentItsOwnMaterialiserWouldReject` (ровно документ строки: 120 глав, 400 пар, пустой список; проверяет и что `chapter_count` НЕ записан, и что файл цел); посадка M15 КРАСНАЯ адресно. | fixed(пак P12) | воркфлоу-ревью волны 2 (P8-FIX); рефутер подтвердил механику и опроверг предложенное лекарство | | 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` с известной, где незнакомая версия даёт НЕ-деструктивный класс ⚠ **ЗАКРЫТА пак P12 (30–31.08) тем же классом «незнакомое → не-деструктивно»,** как и предписывала строка. `ingest.KnownManifestVersion` (`tm-manifest-v2`, зеркало `backend/internal/pipeline/manifest.go:46`) сверяется в `Readable()` перед всем остальным, и незнакомая версия даёт `parser_unavailable` — файл цел. Это ФОРМЕННЫЙ гейт, а не пин значения: сама строка и дока поля правы, что пиннинг значения везде сделал бы каждый релиз движка релизом платформы, поэтому версия сверяется РОВНО в одном месте — там, где неверное чтение разрушительно. Пин — `books.TestAManifestShapeThisBuildDoesNotKnowNeverDeletesTheUpload`: v3-манифест переживает весь бюджет попыток с целым файлом, а ЗНАКОМАЯ форма с честно нулевыми счётчиками по-прежнему удаляет каталог (иначе гейт съел бы настоящий вердикт о тексте пользователя). Посадка M14 (снять сверку версии) КРАСНАЯ адресно. | fixed(пак P12) | адверсариальное ревью P6 (линза шва) | | PD-427 | doc | minor | `internal/ingest/resync.go:37-43` (аллоулист `StatusReport`), опровергнуто `backend/internal/pipeline/status.go` `projectStoredMemory` (лендинг `6ec9f8a`, D39.170) | **Комментарий несёт ПОСЫЛКУ, которую сняли, и читается как действующий довод.** Он объясняет, почему платформа сознательно НЕ берёт `rebill_units`/`rebill_usd` через шов: «status проецирует СОХРАНЁННУЮ память, и сразу после `bank-apply` — в единственный момент, когда согласие хотело бы цифру, — он честно читает ноль». Это было верно и ратифицировано (эррата 28.08-к). Движковый пак «деньги» починил ровно это: `foldMemoryForRead` стал ПЕРВЫМ ответом читающего пути, а `projectStoredMemory` понижена до фолбэка, и комментарий движка объявляет это дословно — «IT IS NO LONGER THE READ PATH'S FIRST ANSWER». Слепое окно закрыто, `status` отвечает «сколько будет стоить» ДО покупки, оставаясь $0-глаголом без записи. ⚠ **Комментарий неверен ДВАЖДЫ:** не только посылка, но и предсказанное лечение — он обещает, что «пара вернётся с движковым ГЛАГОЛОМ, который умеет свернуть и оценить коррекцию ВНЕ прогона», а нового глагола не появилось: починили существующий `status`. ⚠ **ПРОВОДКУ ПОЛЕЙ ЭТА СТРОКА НЕ ОТКРЫВАЕТ** (слово оркестратора при передаче): она гейчена вместе с `tmctl translate --max-units`, и тот гейт в силе — движковый потолок объёма на майнящей банк книге пробивался, лечение легло, но проводка ждёт отдельного решения. То есть предмет строки — ровно устаревший ДОВОД, а не отсутствие полей. Класс — «указатель пережил то, на что указывал», тот же, что `PD-310`/`PD-326`/`PD-366`, только в прозе шва. Зеркалит строку 234 единого бэклога ⚠ **ЗАКРЫТА пак P12 (30–31.08) — акт закрытия, не работа:** комментарий `internal/ingest/resync.go` уже исправлен 29.08 аудитом доков, снятая посылка из него ушла, предсказание про «новый движковый глагол» тоже. Проверено чтением обеих сторон. Проводку полей строка не открывала и не открывает — гейт `--max-units` в силе. | fixed(пак P12, акт закрытия) | оркестратор №19 при лендинге движкового пака (`6ec9f8a`), проверено чтением обеих сторон сессией P11 | diff --git a/platform/docs/ENGINEERING_STANDARDS.md b/platform/docs/ENGINEERING_STANDARDS.md index 9df77c40..594045c5 100644 --- a/platform/docs/ENGINEERING_STANDARDS.md +++ b/platform/docs/ENGINEERING_STANDARDS.md @@ -42,8 +42,7 @@ **Наблюдаемость** — базовая линия **практики именования Prometheus** (базовые единицы: секунды и байты; `_total` у счётчиков; единица в имени, не в лейбле) + **четыре золотых сигнала** SRE на -вопрос «что мерить» (внесено P5 по ратифицированному направлению PD-115: у оси появился внешний -эталон, а не только собственная проза зоны). Конкретика: +вопрос «что мерить» (`PD-115`). Конкретика: - структурные логи (route-pattern, не сырой путь — id пользователя/книги в логи не текут), `X-Request-Id` на каждом ответе, ошибки видимы на своём уровне; - метрики отдаются в формате Prometheus на ОТДЕЛЬНОМ слушателе (`STACK_DECISIONS` §24); лейбл несёт diff --git a/platform/docs/PLATFORM_DIRECTION.md b/platform/docs/PLATFORM_DIRECTION.md index 036d2e0e..fb09576f 100644 --- a/platform/docs/PLATFORM_DIRECTION.md +++ b/platform/docs/PLATFORM_DIRECTION.md @@ -50,9 +50,7 @@ hosted IdP (связка PII + доступность, а свою сессию п.2л, PD-104): дефолт `TM_PLATFORM_SIGNUP_GRANT_USD` = **0**, кредит начисляется руками (`tmplatformctl grant`); возврат автоматического гранта идёт ВМЕСТЕ с суточным агрегатным потолком, который его ограничивает, а тот принадлежит платежам. Платёжный провайдер не выбирается, не проектируется и в бэклог зоны как -работа не заходит; вернуться — когда появится решение продавать. ⚠ Прежняя редакция этого раздела -несла вывод «покупателям из России платить нечем» — он был ВЫВЕДЕН из целевого языка перевода, а не -установлен, и снят как необоснованный (владелец 05.08). Язык книги о географии плательщика не говорит. +работа не заходит; вернуться — когда появится решение продавать. **Модель лимита — БАЛАНС кредитов, а не окна с обнулением** (владелец 05.08: «не подписки, а покупка токенов как у OpenRouter»). Следствия, несущие для схемы и контракта: @@ -69,7 +67,7 @@ hosted IdP (связка PII + доступность, а свою сессию фри-тир. Нужна минимальная админ-поверхность — защищённая ручка или CLI-команда, пишущая грант. **Схема (строить с П-7):** `credit_ledger` (append-only, знаковые целые микро-доллары, типы -`grant|hold|hold_release|settlement|adjustment`; `UNIQUE(user_id, source, source_id)` — ключ идемпотентности; ⚠ первая редакция этого абзаца называла `UNIQUE(source, source_id)`, и это ошибка: без `user_id` ключ, потраченный на одном аккаунте, проглатывает тот же ключ на другом, и второму сообщают «начислено», не начислив ничего (миграция `00007` и её комментарий); +`grant|hold|hold_release|settlement|adjustment`; `UNIQUE(user_id, source, source_id)` — ключ идемпотентности, скоупленный АККАУНТОМ (почему в нём обязателен `user_id` — комментарий миграции `00007_credits.sql`); `purchase` добавится, если появится продажа), `reservations` (одна открытая на `engine_run_id`), `account_balances` (кэш в ТОЙ ЖЕ транзакции, что вставка в леджер, + тест-инвариант `balance == SUM(ledger)`). Правки строк не существует: ошибка чинится новой записью. Только целые @@ -78,13 +76,12 @@ hosted IdP (связка PII + доступность, а свою сессию **Защита и свежесть — ДВА РАЗНЫХ механизма, строим ОБА (решение владельца 05.08 «сделаем нормально»; моё прочтение его слов — записано так, чтобы дешёво поправить, если прочитал не то):** -1. **Защита — холд + потолок движку (было «вариант B»).** В той же транзакции, что допускает задачу - в очередь, ДО спавна `tmctl` ставим холд и передаём движку пер-книжный потолок ≤ остатка баланса. +1. **Защита — холд + потолок движку.** В той же транзакции, что допускает задачу в очередь, ДО спавна `tmctl` ставим холд и передаём движку пер-книжный потолок ≤ остатка баланса. Жёсткий стоп исполняет САМ движок, у которого механизм уже есть ⇒ перерасход физически невозможен, даже если платформа во время прогона слепа. Это рубеж корректности, и он НЕ зависит от доставки событий (поток at-least-once и на краше теряет хвост). -2. **Свежесть — версионированное денежное событие в словаре потока (было «вариант A»).** Движок - эмитит НАКОПИТЕЛЬНЫЙ счётчик потраченного на границах стадии/волны; платформа материализует его +2. **Свежесть — версионированное денежное событие в словаре потока.** Движок эмитит + НАКОПИТЕЛЬНЫЙ счётчик потраченного на границах стадии/волны; платформа материализует его существующим идемпотентным апсертом `(engine_run_id, seq)`. Накопительная семантика делает повтор и дубль безвредными (берём максимум по прогону). Индикатор остатка перестаёт отставать на целую попытку. @@ -100,16 +97,11 @@ hosted IdP (связка PII + доступность, а свою сессию ## 3. Стандарты и стек: что взять, что не брать > ⚠⚠ **ВЕРХНИЙ СЛОЙ — ЧИТАТЬ ПРЕЖДЕ ВСЕГО НИЖЕ. По `sqlc` действует НЕ то решение, что записано -> в этом разделе:** владелец 20.08 сказал **БЕРЁМ** (D39.153 п.6а), 22.08 уточнил — отдельной -> сессией (D39.154 п.10), и 29.08 пак **принят и заленджен** (`D39.172`): сорок запросов пяти файлов -> `pgstore` вынесены в `internal/pgstore/queries/`, пин 1.31.1, `sqlc diff` в `make check`. Граница -> узкая по построению, а не по вкусу: свободный от склейки блок; остальное (25 склеек из 147, 15 -> фрагментов-констант, read-модель) для sqlc недостижимо. Устройство, оговорки и цена — -> `STACK_DECISIONS.md`, строка «Кодоген SQL». Отказ P7 ниже остаётся как история пака, а не как -> действующее решение. ⚠ Тем же паком построена ЗАМЕНА того, ради чего sqlc -> звали: гейт `pgstore.TestEverySQLStatementParsesAgainstTheMigratedSchema` планирует ЖИВЫМ Postgres -> каждый собранный запрос пакета против мигрированной схемы — то есть покрывает и склейки, которых -> sqlc не видит, и именно в них случились оба рантайм-падения зоны. +> в этом разделе:** владелец сказал **БЕРЁМ** (`D39.153` п.6а, уточнение `D39.154` п.10), и 29.08 +> пак **принят и заленджен** (`D39.172`). Устройство, пин и цена — `STACK_DECISIONS.md`, строка +> «Кодоген SQL»; граница инструмента и построенная взамен замена (гейт планируемости КАЖДОГО SQL +> пакета, включая склейки) — `DEFECT_REGISTER.md`, `PD-44`. Отказ P7 ниже остаётся как история +> пака, а не как действующее решение. > > ⚠ **ПЕРЕ-ПОДПИСАНО D39.132 п.2б:** `oapi-codegen` — КАНДИДАТ, решение за паком, который его > берёт. **Решение P7 по обоим — НЕ БРАТЬ, с доводом, а не молчанием (история пака P7, 20.08):** @@ -129,7 +121,7 @@ hosted IdP (связка PII + доступность, а свою сессию > тестами (`httpapi.Test*CarriesEveryRequiredField`). Стек менять не надо: правильные куски уже взяты (pgx · goose · River · stdlib ServeMux/CSRF · slog · -опаковые сессии). Добавить четыре вещи, пока репозиторий — скелет: +опаковые сессии). Четыре добавления и их сегодняшний статус: | Добавление | Пин | Статус | |---|---|---| @@ -169,39 +161,30 @@ zonky-стенд Postgres без root, пины линтера/govulncheck, из feature-flag-платформы · CodeQL (на приватном репо платный) · gosec отдельным гейтом · SBOM/SLSA — церемония на этом размере. -**Базовые линии безопасности:** OWASP **ASVS 5.0.0**, целевой **L2** — ⚠ поправка скептика к карте -глав: в 5.0 сессии это **V7**, аутентификация **V6**, и есть НОВАЯ глава **V10 «OAuth and OIDC»** — -именно она написана под наше направление (ресёрч цитировал нумерацию 4.0 и главу V10 пропустил). -Плюс OWASP API Security Top 10 (2023) — API1 BOLA это буквально проверка владения книгой на каждом -`/v0`-маршруте, API4 — наш PD-2. Плюс уже действующие govulncheck-гейт и пины Go-модулей с sumdb. -Dependabot — да (граф крошечный, шума нет). +**Базовые линии безопасности** — `ENGINEERING_STANDARDS.md` §2. ⚠ Поправка скептика, из-за которой +карта глав там именно такая: ресёрч цитировал нумерацию ASVS 4.0 и пропустил НОВУЮ главу **V10 +«OAuth and OIDC»**, написанную ровно под наше направление. Сверх стандарта: govulncheck-гейт, пины +Go-модулей с sumdb, Dependabot — да (граф крошечный, шума нет). -**Тест-пол — тот, что поймал бы дефекты этой приёмки** (иначе стандарт бесполезен): -1. Тест на РЕАЛЬНО сконфигурированном `http.Server`, а не на mux под `httptest`: полу-кормленный POST - обязан получить закрытие по `ReadTimeout`; SIGTERM обязан дренировать запросы (сегодня падают оба — - PD-2 и PD-9; ни один mux-тест этот класс не видит). -2. Инвариант сессии НЕЗАВИСИМЫМ оракулом: SHA-256 считается ВНЕ `auth.Digest`, плюс ассерт «плейнтекст - токена в БД не находится» (PD-1: свойство истинно, но моя посадка его не уронила). -3. Нативный фаззинг `go test -fuzz` на NDJSON-декодере, оракулы — инварианты PD-10. -4. Дисциплина посадок мутаций (`ENGINEERING_STANDARDS.md` §3.3) как постоянная проверка того, что - свойства ЗАПИНЕНЫ, а не просто истинны. -5. Контрактные тесты против OpenAPI-спеки и нагрузочные (k6) — при первых живых ручках, не раньше. +**Тест-пол.** Четыре его пункта исполнены и живут нормой, а не планом: тест на РЕАЛЬНО +сконфигурированном `http.Server` вместо mux под `httptest` (`PD-2`, `PD-9`), инвариант сессии +НЕЗАВИСИМЫМ оракулом (`PD-1`), фаззинг NDJSON-декодера (`PD-10`) — эти строки регистра `fixed`, +каждая со своим пином; дисциплина посадок мутаций — `ENGINEERING_STANDARDS.md` §3. Полом остаётся +только пятый пункт: контрактные тесты против OpenAPI-спеки и нагрузочные (k6) — при первых живых +ручках, не раньше. **Деплой:** одна VM + systemd-юниты, бинари артефактами CI; Postgres сперва там же, управляемый — при первой выручке. Не Kubernetes: дети-`tmctl` живут ЧАСАМИ, держат эксклюзивный лок на файлах книги на локальном диске, и любой оркестратор, способный переселить под посреди прогона, нам враждебен. -Юниты писать сейчас — они же честный ответ на PD-13 (осиротевшие процессы движка): прогон в -cgroup-поддереве платформы, `KillMode=mixed`, `Restart=on-failure`, `LoadCredential=` для DSN и -клиентского секрета OAuth, `MemoryMax`, песочница `ProtectSystem`/`PrivateTmp` бесплатно. +Юниты написаны — `deploy/tmplatformd.service`, и они же ответ на `PD-13` (осиротевшие процессы +движка); cgroup-поддерево прогона — `STACK_DECISIONS.md` §15–16. ## 4. Скорость: что это требует от платформы и контракта Замер снял главный страх: **пер-главный объём крошечный** — ~3.4 тыс. символов исходника + ~10.2 тыс. перевода ≈ 27 КБ строк UTF-16 на главу, а контракт уже читает юниты ПО ГЛАВЕ, читает банк ДЕЛЬТОЙ -(`?after_version=`) и отдаёт экспорт ссылкой. ⚠ Прежняя формулировка «подписывает банк частями» -ЛОЖНА с D39.144: подпись — ОДИН акт над всем банком, а частичным является накопление РЕШЕНИЙ, что -совсем другая вещь. Риск не в экране чтения, а в четырёх местах, где сегодняшняя форма -вынудила бы клиента держать книгу целиком. Требования, которые платформа обязана исполнить: +(`?after_version=`) и отдаёт экспорт ссылкой. Риск не в экране чтения, а в четырёх местах, где +сегодняшняя форма вынудила бы клиента держать книгу целиком. Требования, которые платформа обязана исполнить: 1. **Сжатие ответов — ИСПОЛНЕНО P7** и записано нормой в канон 0.3.0, а не в этот док: JSON сжимается там же, где строится тело (`internal/httpapi/conditional.go`), `text/event-stream` — никогда, и diff --git a/platform/docs/STACK_DECISIONS.md b/platform/docs/STACK_DECISIONS.md index 7cdd9001..bf39c2ef 100644 --- a/platform/docs/STACK_DECISIONS.md +++ b/platform/docs/STACK_DECISIONS.md @@ -1,12 +1,11 @@ # Стек платформы — пины и обоснования > Зонный документ `platform/`. Пины сверены ЖИВЬЁМ 04.08 и 05.08.2026 (Go-прокси `@latest`, -> postgresql.org, go.dev/dl) — версии по памяти не называются. Библиотеки сессия не ратифицирует: -> таблица уходит оркестратору вместе с деревом. +> postgresql.org, go.dev/dl) — версии по памяти не называются. Порядок ратификации новой +> зависимости — `ENGINEERING_STANDARDS.md` §1. > -> Общая записка по обоим новым сервисам — `frontend/docs/STACK_DECISIONS.md` §5 (02.08). Здесь — -> платформенная часть с датами релизов и сверкой; floor `go.mod` оставлен общим с движком, тулчейн -> сборки поднят выше — см. таблицу. +> Общая записка по обоим новым сервисам — `frontend/docs/STACK_DECISIONS.md` §5 (02.08); здесь — +> платформенная часть с датами релизов и сверкой. ## Пины @@ -19,9 +18,9 @@ | CSRF | stdlib `http.CrossOriginProtection` | Go 1.25 | Ровно тот механизм, что описан в §5 (Sec-Fetch-Site → Origin), теперь в тулчейне — свой велосипед не пишем | | Postgres-драйвер | `github.com/jackc/pgx/v5` **v5.10.0** | 03.06.2026 | Живой pool, `pgconn.PgError` для проверки констрейнтов, `stdlib` для goose | | Миграции | `github.com/pressly/goose/v3` **v3.27.3** | 22.07.2026 | Библиотекой + `embed.FS`; `WithSessionLocker` = advisory-лок, две реплики выкатываются по очереди | -| Очередь | `github.com/riverqueue/river` **v0.42.0** | 31.07.2026 | В `go.mod` и в работе с P4 (§19): задание очереди выдаёт только РАЗРЕШЕНИЕ стартовать, а жизнь прогона ведёт реконсилятор. Мигрируется своим мигратором — две летописи в одной базе | -| OIDC-вход | `golang.org/x/oauth2` **v0.36.0** + `github.com/coreos/go-oidc/v3` **v3.20.0** | 11.02.2026 · 08.07.2026 | Ратифицировано `PLATFORM_DIRECTION.md` §1; сверено живьём 05.08. Протокольный риск (PKCE, JWKS с рефетчем по kid, проверка подписи/issuer/audience/exp) отдан библиотекам, интеграция и модель аккаунта — наши. Транзитивно приходит `go-jose/v4` v4.1.4 | -| Рейт-лимит в процессе | `golang.org/x/time` **v0.15.0** | 11.02.2026 | `rate.Limiter` на ОБЕИХ неаутентифицированных ручках, которые ПИШУТ: `/auth/login` (строка состояния) и `/auth/callback` (строка журнала на каждом отказе — замерено ~880 строк/с с одного хоста, пока лимита не было). Долговечные пер-пользовательские лимиты — в Postgres, когда появятся | +| Очередь | `github.com/riverqueue/river` **v0.42.0** | 31.07.2026 | В `go.mod` и в работе с P4; устройство и цена формы — §19 | +| OIDC-вход | `golang.org/x/oauth2` **v0.36.0** + `github.com/coreos/go-oidc/v3` **v3.20.0** | 11.02.2026 · 08.07.2026 | Ратифицировано `PLATFORM_DIRECTION.md` §1 (там же — что отдано библиотекам и что пишем сами); сверено живьём 05.08. Транзитивно приходит `go-jose/v4` v4.1.4 | +| Рейт-лимит в процессе | `golang.org/x/time` **v0.15.0** | 11.02.2026 | `rate.Limiter` на ОБЕИХ неаутентифицированных ручках, которые ПИШУТ: `/auth/login` (строка состояния) и `/auth/callback` (строка журнала на каждом отказе — замерено ~880 строк/с с одного хоста, пока лимита не было) | | Линтер | `golangci-lint` **2.12.2** | 06.05.2026 | Тот же пин, что у движка: находки версионно-зависимы, разъезд пинов = разные гейты в одном репо | | Кодоген SQL | `sqlc` **v1.31.1** | 22.04.2026 | **Инструмент разработчика, НЕ зависимость модуля** — в `go.mod` не входит, рантайм-граф не растёт ни на один пакет; пин держит `make tools-check`, как у линтера, и по более острой причине: генерённый код лежит В ДЕРЕВЕ, поэтому другая версия молча даёт другой диф и `sqlc diff` краснеет на чистом клоне. **Зачем:** генерирует слой запросов свободного от склейки блока `internal/pgstore` (40 запросов из `queries/*.sql`) — SQL и его `Scan` перестают быть двумя списками, которые сверяет человек ПО ПОЗИЦИИ. Взят по решению владельца 20.08 (`D39.153` п.6а, уточнение `D39.154`), носитель — `BACKLOG.md` П-19. ⚠ Довод «гейт `sqlgate_test.go` это уже закрыл» ПРОВЕРЕН и не подтвердился: гейт получает только СТРОКУ SQL и Go-сторону вызова не видит — шесть посаженных перестановок целей `Scan` и сломанных арностей ВЫЖИЛИ на полной батарее (замер 29.08, `platform-PROGRESS.md`). Схему читает ТОТ ЖЕ каталог `migrations/`, что гейтит манифест (второго носителя нет); деньги держит подстановочный override `*.*_micro_usd` → `money.MicroUSD` — без него генератор выдаёт голый `int64`, с ним снятие override становится ОШИБКОЙ СБОРКИ. Актуальность генерации гейчена дважды: `sqlc diff` в `make check` и `pgstore.TestEveryGeneratedQueryMatchesItsSourceFile` в батарее, который работает и без установленного sqlc | | Уязвимости | `govulncheck` **v1.6.0** | 09.07.2026 | Отдельная цель `make vuln`, не часть `check`: ей нужна сеть, а батарея обязана быть зелёной на голом клоне офлайн | @@ -68,7 +67,7 @@ > Значит `DownTo(<5)` на живой базе падает. Править `00005` нельзя — это и есть append-only, — > а новая миграция чужой down-текст не заменяет. Данные при этом целы: down транзакционный, > `Up()` возвращает схему на текущую версию (проверено прогоном: `DownTo(4)` падает на - > `users_email_key`, SQLSTATE 23505). Найдено ревью P2, перепроверено зоной, принято как цена правила. + > `users_email_key`, SQLSTATE 23505). Принято как цена правила. 9. **Ключ личности — `(provider, subject)`; почта не ключ.** `users.email` стала NULLABLE и БЕЗ уникального индекса; неизвестная пара всегда создаёт НОВЫЙ аккаунт. Разбор и цена решения — в журнале зоны, раздел «Политика коллизии почты». @@ -81,7 +80,7 @@ Это первая половина ключа личности. Направить `TM_PLATFORM_OIDC_ISSUER` на другой IdP, оставив имя прежним, — значит сложить `sub` нового провайдера в старое пространство имён, то есть тихо связать чужие аккаунты. Переменная называется здесь, потому что в деплой-примере её не было и - оператору нечему было напомнить (найдено ревью P2). + оператору нечему было напомнить. 11. **Секреты — через `*_FILE`.** `TM_PLATFORM_DSN_FILE` и `TM_PLATFORM_OIDC_CLIENT_SECRET_FILE` читаются раньше одноимённых переменных: переменная окружения видна в `/proc//environ` и наследуется каждым ребёнком-`tmctl`. Это же формат `LoadCredential=` systemd (`deploy/`). @@ -156,9 +155,8 @@ **7.6.2 выполнено:** сессия создаётся только в колбэке потока, который человек начал явным действием, и провайдер показывает свой экран согласия. Без взаимодействия сессия не появляется. - **7.3.1 / 7.3.2 — прицел, которого этому разделу не хватало.** 7.1.x требуют ДОКУМЕНТА, и выше - он написан; сами механизмы требуют другие две строки, и на них до 08.08 не ссылался ни один - наш док. Дословно (ASVS 5.0 V7, обе — уровень **2**): 7.3.1 — «Verify that there is an inactivity + **7.3.1 / 7.3.2 — про МЕХАНИЗМ, а не про документ, которого требуют 7.1.x.** Дословно + (ASVS 5.0 V7, обе — уровень **2**): 7.3.1 — «Verify that there is an inactivity timeout such that re-authentication is enforced according to risk analysis and documented security decisions»; 7.3.2 — то же про «absolute maximum session lifetime». ⚠ Читать точно: они требуют не КОНКРЕТНОЙ величины, а того, чтобы механизм СУЩЕСТВОВАЛ и принуждал к повторной @@ -236,7 +234,7 @@ 24. **Метрики — `prometheus/client_golang` v1.24.1, на ОТДЕЛЬНОМ слушателе, и это выбор против stdlib.** Норма зоны требует stdlib прежде библиотеки (`ENGINEERING_STANDARDS` §1), поэтому первым рассмотрен `expvar` — и он этой работы не несёт: нет лейблов (⇒ «запросы по маршруту и коду» не выражаются вовсе), нет гистограмм (⇒ на вопрос о задержке остаётся среднее — единственная статистика, которая прячет хвост), а его JSON не читает ни один скрейпер без переводчика. Сэкономил бы он зависимость, а стоил бы написания недостающих трёх руками — то есть ровно того самописного пути, который та же норма и запрещает. OpenTelemetry для этого деплоя тяжелее: коллектор процессом, протокол экспорта настройкой, и всё равно scrape-эндпоинт на конце. Пин сверен живьём 11.08 (`proxy.golang.org/@latest`), релиз 24.07.2026. - **Внешний эталон оси (половина PD-115):** практики именования Prometheus (базовые единицы — секунды и байты; `_total` у счётчиков; единица не в лейбле) плюс «четыре золотых сигнала» на вопрос «что мерить». Пин на форму — `metrics.TestTheRunnersStateIsExposedWithItsUnits`, он же ловит единицу в имени. + **Внешний эталон оси (половина PD-115):** практики именования Prometheus плюс «четыре золотых сигнала» на вопрос «что мерить» — обе нормой в `ENGINEERING_STANDARDS.md` §2. Пин на форму — `metrics.TestTheRunnersStateIsExposedWithItsUnits`, он же ловит единицу в имени. **Кардинальность:** лейбл несёт ПАТТЕРН маршрута, никогда путь — сырой путь это библиотека пользователя в индексе оператора (PD-3) и неограниченное число рядов; неразобранный запрос сворачивается в один ряд `(unmatched)`, потому что там лейбл выбирает не сервер. Пин — `TestRequestsAreCountedByRoutePatternAndNeverByPath`. @@ -284,8 +282,7 @@ СТАРТУЕТ (отказ конфигурации, не предупреждение) · личность берётся из конфигурации, а не из запроса, поэтому «войти кем-то другим» через него нельзя в принципе · провайдер называется `dev`, и в это пространство ключа `(provider, subject)` не может попасть ни один настоящий издатель. - Плюс WARN на буте и на каждый выданный сеанс. Разбор — доккоммент `internal/login/dev.go`; - security-ось ревью атакует именно этот список. + Плюс WARN на буте и на каждый выданный сеанс. Разбор — доккоммент `internal/login/dev.go`. ⚠ **Второй гейт того же класса, из приёмки P6 (PD-228):** дев-окружение экспортирует ДВЕ опасные переменные, и отказ по одной ловил «стенд уехал в прод» наполовину. Поэтому @@ -315,7 +312,7 @@ ничего не мешает. Цена забывания названа замером: книга уезжает `finished_at`-нутой и НЕ должной, очередь дрейна ключуется этой колонкой и больше на закрытую книгу не смотрит — то есть прогон, за который пользователь заплатил, не показывает свой текст никогда. Две из трёх и - забыли; поймало ревью, пин — `pgstore.TestEveryEndingOfARunLeavesTheBookOwingASurface`, по + забыли; пин — `pgstore.TestEveryEndingOfARunLeavesTheBookOwingASurface`, по таблице из трёх концовок. 34. **`books.read_model_owed_at` — это СРОК, а не момент возникновения.** Тот, кто берётся платить @@ -393,7 +390,7 @@ | `tmctl export --json --pairs` | движок (`pipeline/export.go`) | — (одноразовая выдача на вызов) | `internal/runner/engine.go` `ExportArgs`/`Export` → `ingest.DecodeExport` → `internal/readmodel` | ✅ ⚠ **Единственный канал, несущий ТЕКСТ пары** — исходник и перевод; манифест несёт только структуру | | `events.jsonl` (NDJSON эмиттера) | движок, StreamVersion 1.1 | append-only | `internal/ingest/tail.go` + `pgstore.RunSink` | ✅ | | exit-коды `tmctl` | контракт движка | — | `ingest.OutcomeOf`, `internal/runs/reconcile.go` `outcome` | ✅ | -| `tmctl status --json` | движок | — | `internal/runner/engine.go` → `ingest.StatusReport`; расчёт денег | ✅ форму P8-FIX не менял; изменил лишь то, СКОЛЬКО раз его зовут при неудаче (бэкофф отсрочки) | +| `tmctl status --json` | движок | — | `internal/runner/engine.go` → `ingest.StatusReport`; расчёт денег | ✅ ⚠ при неудаче зовётся не подряд, а с бэкоффом отсрочки | | `book.yaml` | оператор (шаблон) + **платформа ОДИН раз** (`internal/books/render.go`, `O_EXCL`) | — | платформа пишет пять фиксированных ключей: `book_id · title · source_lang · target_lang · source_file` | ⚠ **декодер движка СТРОГИЙ** (`config/book.go`, `dec.KnownFields(true)`): незнакомый ключ — жёсткая ошибка, не предупреждение. Значит любое расширение набора ключей платформой это РАТИФИКАЦИЯ, а не правка: сборка движка, которая ключа ещё или уже не знает, перестанет грузить КАЖДУЮ новую книгу. Прямо относится к развилке 199(а) | ⚠ **Два следствия, которые стоит держать в голове при любой работе со швом.** Первое: относительные @@ -438,8 +435,8 @@ sed -e 's#^pipeline: ../configs/#pipeline: /backend/configs/#' \ system_messages not found in type config.CapabilitiesConfig`. Диагноз стоит времени именно потому, что выглядит как дефект зоны: падает платформенный тест, а лжёт бинарь движка, собранный до того, как в конфиг движка приехало поле. То же правило и той же причины — для КОНФИГОВ стенда, если они -скопированы рядом с бинарём: пак P12 нашёл стендовую копию `backend/configs` без `langpacks/ru`, -появившегося позже. +скопированы рядом с бинарём: стендовая копия `backend/configs` без появившегося позже +`langpacks/ru` (`PD-432`). ⛔ **Команда-проверка четвёртого условия СНЯТА и не подлежит восстановлению без нового диагноза.** Прежняя редакция предлагала одну строку: `cut -d: -f3 /proc/self/cgroup` не должен давать diff --git a/platform/docs/p8-review/README.md b/platform/docs/p8-review/README.md index 4f3011b0..58b29855 100644 --- a/platform/docs/p8-review/README.md +++ b/platform/docs/p8-review/README.md @@ -11,9 +11,9 @@ Postgres 18.4 без root (`~/.local/pgsql`, сокет `/tmp`, порт 55433) `127.0.0.1:8099`, метрики `127.0.0.1:9464` · по своей копии дерева, своей базе и своей паре портов на каждого из восьми субагентов. -Базовая линия батареи на дереве репозитория, ТРИ гейта выполнены (DSN · пара -`TM_PLATFORM_TEST_ENGINE_BIN`+`_BOOK_TEMPLATE` · достижимый пользовательский менеджер systemd): -**18 пакетов, EXIT=0, скипов 0, линтер 0 issues** — `battery-final.log`. +Базовая линия снята на дереве репозитория при ТРЁХ выполненных гейтах (DSN · пара +`TM_PLATFORM_TEST_ENGINE_BIN`+`_BOOK_TEMPLATE` · достижимый менеджер systemd пользователя); +числа прогона — строка `battery-final.log` таблицы ниже. ## ⚠ Два правила, без которых мутации читаются неверно diff --git a/platform/docs/platform-PROGRESS.md b/platform/docs/platform-PROGRESS.md index 771ce34c..db3fe7d5 100644 --- a/platform/docs/platform-PROGRESS.md +++ b/platform/docs/platform-PROGRESS.md @@ -5,7 +5,6 @@ > читает этот журнал при каждом лендинге зоны (свип «решений владельца» — норма D39.99 п.4). - ## ПИНГ оркестратора №21 → зоне (31.08, СРОЧНО): ваша копия полосы отказов обещает то, что движок ОТОЗВАЛ Правку вносит ЗОНА. Пишу пингом, а не правкой, потому что `platform/` не моя зона — но откладывать @@ -950,12 +949,12 @@ HTTP-старт → настоящий спавн → настоящий вых книга, чтобы исполнялся фильтр `r.book_id`). Раунд первый доказал, что `min` исполняется как ВЫБОР, но не что исполняется книжный фильтр. Пропуск, а не подтверждение. -⚠ **ДВА флейка под нагрузкой, оба НЕ мой дифф.** При трёх параллельных батареях краснеют в ЧИСТЫХ -копиях `TestARunIsBoundedByItsOwnCgroup` (4 раза из 15) и -`TestAClaimThatLostARaceToAReleaseIsRetriedAndNotAnError` (2). В серийных прогонах — ни разу, ни у -меня, ни у оркестратора. Оба меряют ресурс, общий для копий на машине. Именно поэтому вердикт — -ДЕЛЬТА множеств, а не код выхода: флейк, попавший в чистую копию, вычитается из посаженной. Заведено -строкой `PD-420`. +⚠ **ДВА флейка под нагрузкой, оба НЕ мой дифф** — при трёх параллельных батареях краснеют в ЧИСТЫХ +копиях `TestARunIsBoundedByItsOwnCgroup` (**4 раза из 15**; строка `PD-423` несёт другой замер — красноту +в ИЗОЛЯЦИИ и её причину) и `TestAClaimThatLostARaceToAReleaseIsRetriedAndNotAnError` (**2 из 15**; +строка `PD-420`). В серийных прогонах — ни разу, ни у меня, ни у оркестратора: оба меряют ресурс, общий +для копий на машине. Отсюда и вердикт кампании — **ДЕЛЬТА множеств, а не код выхода**: флейк, попавший в +чистую копию, вычитается из посаженной. ⚠ **Одна посадка научила чинить не код, а ПИН.** `r_nocheck` в первом прогоне не дала ни одного названного красного — она ПОВЕСИЛА пакет `internal/httpapi` на десятиминутном таймауте Go, потому что @@ -1005,18 +1004,13 @@ HTTP-старт → настоящий спавн → настоящий вых 9. **Живые сценарии сняты на ДЕВ-профиле** (`INSECURE_COOKIES`, `DEV_LOGIN`), то есть на кукe без `__Host-` и без OIDC. Механизм отзыва от этого не зависит — он в сторе, — но «проверено на проде-подобном профиле» я сказать не могу. -10. **⛔ Я убила чужие процессы.** Гася свою мутационную кампанию, применила `pkill -f 'go test'` и - `pkill -9 -f '/exe/'` — на общей машине это шаблон «все, кто сейчас работает». Попала по шести - ревью-агентам сессии `textmachine-e4` в окне **01:36:20–01:37:45**, включая линзу, которая судит - покрытие посадкой мутаций: для неё убитый прогон читается как «мутация выжила», то есть я могла - подсунуть ей ложную НАХОДКУ. Сообщила ей сама, с точными границами; она пере-прогоняет. Своё - поведение изменила: PID заданий пишутся в файл, гашу построчно. Это моя ошибка целиком, и она - стоила чужого времени. -11. **Кампания посадок ДОШЛА до конца уже после первой редакции этого отчёта**, и её таблица — то - место, где отчёт дольше всего был неполон. Числа батареи, на которые ссылается таблица сдачи, - сняты в 02:04, а дерево правилось после (комментарии, дедлайн в тесте, текст миграции и её - контрольная сумма) — поэтому финальный прогон снят заново и таблица указывает на него. -12. **Не проверено вообще:** поведение при нескольких одновременных потоках одного пользователя под +10. **⛔ Я убила чужие процессы** `pkill -f 'go test'` / `pkill -9 -f '/exe/'`, гася свою мутационную + кампанию: шесть ревью-агентов сессии `textmachine-e4` в окне **01:36:20–01:37:45**, включая линзу + покрытия посадками — для неё убитый прогон читается как «мутация выжила», то есть я могла + подсунуть ей ложную НАХОДКУ. Сообщила ей сама с точными границами, она пере-прогнала; PID заданий + теперь пишутся в файл и гасятся построчно. Общий урок — в списке уроков `docs/PROGRESS.md` (греп + «`pkill` по имени процесса») и в `STACK_DECISIONS` (греп «Демон нельзя убивать»). +11. **Не проверено вообще:** поведение при нескольких одновременных потоках одного пользователя под отзывом (логически покрыто — проверка у каждого своя, — но живьём не снято) · `logout-all` на дев-профиле (`PD-379` мимоходом сообщает про 404; я его не пере-проверяла, это строка группы `PD-380…383`, которую пак не берёт) · поведение при недоступном Postgres в момент проверки @@ -1105,6 +1099,13 @@ FAIL/DATA RACE — 0; `golangci-lint` → **0 issues**; деплоя (byte no-op проставит факт по already_applied-ветке). - Консент-гейт движка живьём деньгами по-прежнему не пробит ($0-цены; кандидат строки 202) — из прежнего Obstacle, не изменилось. +- ⚠ **Для оркестратора — находка опровергателя P10, носителя ни в регистре, ни в бэклоге у неё нет:** + якоря §2.12 компаньона контракта (`docs/architecture/14-api-contract/README.md`, греп + `pipeline/status.go`) ПРОТУХЛИ — волновая машинерия **D39.122** увезла деньги в `ChapterPassport` и + `StatusReport` (греп `type ChapterPassport` в `backend/internal/pipeline/status.go`). Там же довод + опровергателя, ради которого правка §2.12 ОБЯЗАТЕЛЬНА: два денежных поля из пяти (`Spend`, + `Reserved`) уже легально пересекают шов в заленженном аллоулисте, поэтому безусловное «§2.12 + запрещает шов» делало бы их нарушениями задним числом. ## ⚠ ПОЧЕМУ ФОРМА P10 СО СМЕТОЙ БЫЛА ОТВЕРГНУТА — 42 находки/6 линз широкого самопрохода, три корня валят ФОРМУ (сессия платформы, 28.08) @@ -1204,94 +1205,36 @@ FAIL/DATA RACE — 0; `golangci-lint` → **0 issues**; ## ВОРКФЛОУ-РЕВЬЮ ДЕРЕВА P9 ОТРАБОТАНО — 16 линз, оба отложенных MAJOR подтверждены замером, сводка находок для оркестратора (сессия платформы, 28.08) -Заказ владельца (релей 28.08): адверсариальная вычитка дерева воркфлоу-оркестрацией — полоса ×4, -дверь ×3, миграция, раскладка кодов (`wf_cac14b2f-e84`) + гонки данных по 4 траекториям владельца, -баг-хант нового кода, стоимость per-request (`wf_155de7c3-bb4`). Раскладка моделей — по слову -владельца: 1×Fable на воркфлоу (самая тяжёлая линза), остальным явный opus/sonnet. 16/16 агентов -дошли (0 ошибок), ~2.83M токенов. Мандат: «найди, где рассуждение неверно», каждая находка — -severity + file:line + траектория с числами; PD-281, PD-401-остаток и пере-нарезка исключены -заданием как названные границы. **По находкам НИЧЕГО не чинил — жду слова оркестратора.** +Адверсариальная вычитка дерева воркфлоу-оркестрацией (заказ владельца, релей 28.08; журналы +`wf_cac14b2f-e84` и `wf_155de7c3-bb4` вне репо): 16 линз — полоса ×4, дверь ×3, миграция, раскладка +кодов, гонки данных по 4 траекториям владельца, баг-хант, стоимость per-request; раскладка моделей по +слову владельца (1×Fable на самую тяжёлую линзу, остальным явный opus/sonnet); 16/16 агентов дошли, +0 ошибок, ~2.83M токенов. Мандат: «найди, где рассуждение неверно». ⚠ Пометка «подтверждено прогоном» +в траекториях — исполнение АГЕНТОВ, не сессии; сессия пере-исполнила два клейма своего кода и одним +своим EXPLAIN — ось стоимости. **Лечение — таблица F1–F13 секцией выше, приёмка — D39.162.** -Дисциплина честности: пометка «подтверждено прогоном» в траекториях — исполнение АГЕНТОВ (их -overlay-тесты и /tmp-замеры в копиях дерева), не моё; я пере-исполнил чтением два клейма своего кода -(Р4, Р5 ниже — оба подтвердились) и одним своим EXPLAIN-замером ось стоимости (сошлось, ниже). -Полные результаты с траекториями сохранены вне репо: журналы воркфлоу в каталоге сессии -(`subagents/workflows/wf_*/journal.jsonl`). +**Куда уехала каждая находка** (33 штуки; здесь остаётся ровно то, чего нет ни в F-таблице, ни в +строках). MAJOR(а) «1 МиБ на двух документах» → **F1**, MAJOR(б) «мьютекс без контекста» → **F2**. +Свой код P9: Р1 → F3 · Р2 → F4 (и dispute про READ COMMITTED растворён — стейтмент один) · Р3 → +**`PD-410`** (архитектурное, отдельным паком) · **Р4 ОПРОВЕРГНУТА исполнением** — несогласие 1 выше и +D39.162, держать её как находку нельзя · Р5 → F5 · Р6 → F6-F8 и **`PD-407`** · Р7 → F9 и **`PD-409`** +· Р8 → редакция **`PD-400.2`**. Наследие платформы: Н1 → F12 и **`PD-402`** · Н2 → **`PD-403`** · +Н3 → **`PD-404`** · Н4 → **`PD-405`**, мёртвые колонки — **`PD-411`** · Н5 → F11 и **`PD-406`**. +Движковые: Д1 → строка **228** единого бэклога (пере-сформулирована бэкенд-сессией и принята), Д4 → +строка **227** (якорь находки был неверен и исправлен приёмкой), Д5 → F10. Ось стоимости: карточка в +5 сканов → **`PD-412`**, `emitProgress` под блокировкой → **`PD-413`** (оба несут замеры целиком); +строку **186** приёмка закрыть ОТКАЗАЛАСЬ — замер подтверждает шаги 1-2, шаги 3-5 живы (D39.162), и +сам замер стоит в строке. -### Два отложенных MAJOR ревьюера приёмки — оба ПОДТВЕРЖДЕНЫ, чинить +- cosmetic: `MkdirAll(StateDir)` на каждый вызов двери — место одному разу в конструкторе Service. -- **MAJOR(а) «1 МиБ на двух документах» — ПОДТВЕРЖДЁН тремя агентами с независимыми замерами.** - Механизм: `json.Marshal` в `ingest.EncodeDecisions` HTML-экранирует `&`/`<`/`>` (1 байт → 6) и - меняет конверт (+~25 Б), так что тело под проводным капом 1 МиБ рендерится в документ НАД - движковым капом 1 МиБ. Замер агента: 5000 declines с `note` из `&`×160 → тело 1 030 050 Б → - рендер 5 030 075 Б → движковый exit 14 → **409 `bank_corrections_refused` «split it» вместо - канонного 413**. Полоса законных тел широкая (~550 КБ плотного спецсимволами тела уже - перескакивает), а существующий пин `TestAnOversizedCorrectionDocumentAnswers413` дыру не ловит — - набит кириллицей, которая НЕ экранируется. ⚠ Конфликт линз: два агента первого воркфлоу положили - эту ось в «не опровергнуто», рассуждая от omitempty («рендер меньше тела») и НЕ меряя; замер двух - других бьёт рассуждение (один из «опровергателей» сам оговорил условие плотности `&<>`). - Бонус-cosmetic туда же: скраб пути (`runs/bank.go:146`) на этом отказе даёт задвоенную фразу «the - decision document the decision document» и пропускает на провод движковый префикс `pipeline:`. -- **MAJOR(б) «мьютекс без контекста, бюджет после захвата» — ПОДТВЕРЖДЁН** (`runs/bank.go:94` vs - `:126`, `lockBook` без ctx `:270-291`): K одинаковых ретраев дают очередь K×60 с; отменённый - клиентом запрос ЖДЁТ в очереди и, дождавшись, делает работу (чтение книги, temp-файл) за мёртвый - запрос; в ту же очередь встают Start и Resume — заявление «ждать один вызов движка» - (`runs.go:242-246`) держится только для одного ожидающего. Дёшево лечится ожиданием с - наблюдением ctx (канал вместо `sync.Mutex`). - -### Находки в МОЁМ коде P9 (сверх двух MAJOR; лечение — после слова оркестратора) +**Две движковые находки БЕЗ носителя — это и есть живой остаток секции** (чужая зона, лечить не мне; +пинг оркестратору): | # | Вес | Что | Где | |---|---|---|---| -| Р1 | breaks | **Окно стопа подписи**: `bank_stop` ставит `awaiting_bank` БЕЗ `finished_at`, а `HasLiveRun` читает `finished_at is null` → до ближайшего свипа (15 с штатно; до 30 мин на бэкоффе отсрочек) дверь отвечает **409 `run_in_flight` на экран подписи, который платформа сама только что объявила**; соседняя кнопка Resume в том же окне отказывает ДРУГИМ словом («being settled»). Мой довод в `runs/bank.go:110-112` («its run is closed») прямо противоречит `reconcile.go:1122`; состояние строится собственным тестом дерева (`control_test.go:598-603`). Нашли НЕЗАВИСИМО 3 агента | `runs/bank.go:109` | -| Р2 | breaks | **Миграция 00026 для прогонов в полёте**: пере-снятие баз посреди прогона, уже сделавшего p_e правок, оставляет его финал на `runDone−runTotal = −2·p_e` — полностью доделанный прогон закрывается, например, 5/7 навсегда. Двух-базовая схема корректна ровно потому, что базы берутся ДО работы прогона; миграция — единственное место, берущее их ПОСЛЕ. + dispute: READ COMMITTED между двумя UPDATE допускает двойное касание строки при конкурентном финише (при штатном деплое одной реплики окно закрыто остановкой старого демона — вес мал) | `migrations/00026:20-27` | -| Р3 | breaks | **`draftWork` не моделирует порядок волн движка**: continuation над недочерновленной книгой (`draft_before ≥ chapters_before+C`, напр. прогон-предшественник упёрся в потолок на черновике) даёт `draftWork=0`, движок продолжает ЧЕРНОВУЮ волну — полоса 0/C весь прогон, stage «editing» на прогоне, который только черновит. Зеркало дефекта, который draftWork чинил | `readmodel.go:460` | -| Р4 | breaks | **`limitedBuffer` НЕ убивает чайлда** при переполнении stdout — вопреки своему комментарию (контраст: `drain()` в `engine.go:262-272` при том же переполнении явно зовёт `Process.Kill`); чайлд виснет на записи в полный пайп до чужого дедлайна (~70 с под пер-книжным мьютексом), без дедлайна — навсегда. Подтвердил чтением. + dispute: stderr — НЕограниченный `bytes.Buffer` (не-тот бинарь по сконфигурированному пути может раздуть демона до OOM; читается всё равно только первая строка) | `runner/bankapply.go:98-111` | -| Р5 | breaks | **Presence-vs-null дыра валидации**: только окна (`nullableInt`) различают явный `null` от отсутствия; `src/sense/dst/kind/id` — голые `*string`, так что `{"id":"X","src":null}` и decline с `"dst":null,"kind":null` проходят гейты, которые канонный `oneOf`/`not-required` велит отбивать 400. Подтвердил чтением (сам вводил `nullableInt` только для окон). Порчи данных нет — лишнее значение не читается; дыра контрактная | `httpapi/bank.go:48-51, 221-224, 251-259` | -| Р6 | dispute | **Раскладка на ветке SIGKILL**: `ctx.Err()`-ветка (`runs/bank.go:132`) достижима ТОЛЬКО когда SIGTERM не отработал (процесс убит по `WaitDelay`) — а это единственный путь, оставляющий полу-приземлённую пару БЕЗ отчёта, т.е. адресат `bank_corrections_incomplete`, отвечаемый общим 503; мой комментарий описывает соседнюю ветку. + `bankStopGrace` 10 с КОРОЧЕ самого длинного непрерываемого участка движка (11.3 с на документе-максимуме) — грейс истекает до фазы записи. + `runBudget` — операторская ручка свипа: понижение (к чему подталкивают комментарии) делает легальный документ-максимум навсегда неприменимым (вечный 503 вместо «split it») | `runs/bank.go:132`, `runner/bankapply.go:40` | -| Р7 | cosmetic | Утечка `bank-corrections-*.json` в StateDir при нечистой смерти демона (SIGKILL/OOM/обрыв грейса деплоя): cleanup только на defer, никто каталог не подметает; до 1 МиБ пользовательских решений на файл, бессрочно | `runs/bank.go:250` | -| Р8 | — | **Формулировка PD-400.2 (accepted-risk) требует правки**: на мульти-реплике класс 12 может быть НАСТОЯЩИМ многочасовым прогоном соседней реплики (её Start видит свой мьютекс и чистый `runs`) — слово 503 «transient holder» и мой лог тогда ЛОЖНЫ, канонно верное слово — 409 `run_in_flight`. «Ни денег, ни порчи» держится; «ни ложного слова» — НЕТ. Риск остаётся принятым (v1 = одна реплика), но акт лендинга должен описать его честно | `runs/bank.go:27-39, 182-189` | - -### Находки в НАСЛЕДИИ платформы, вскрытые ревью (не код P9; полоса и дверь на них стоят) - -| # | Вес | Что | Где | -|---|---|---|---| -| Н1 | breaks | **Resume НЕ-последнего прогона** (Resume пропускает любой `stopped` без проверки последнести; `lastRun` = `started_at desc`; RestartRun не трогает `started_at`): (i) полоса возобновлённого открывается на ЧУЖОЙ работе — 2/2 при нуле своих пассов (клэмп режет только >1, не двойной счёт до 1); (ii) карточка, кадр статуса и КАЖДЫЙ progress-кадр живого прогона считаются по ЧУЖОМУ финишировавшему — «ready» и замороженный бар, пока живой прогон тратит деньги. 4 агента, overlay-прогоны | `reconcile.go:1216`, `readmodel.go:487`, `runs.go:843` | -| Н2 | breaks | **`edit_wave` sticky-true × оператор убрал редактора**: прогон делает 100% купленного и закрывается ready на 50% со stage «editing»; `chaptersDone` (edit-колонка) мёрзнет → `ChaptersLeft` не падает → **шкала повторно продаёт уже переведённые главы** (симптом PD-202, записанной fixed); follow-up прогон над этой покупкой читается 0/C с первой секунды. Обе фразы комментария `sink.go:406-409` о собственном коде ложны | `sink.go:415`, `readmodel.go:388` | -| Н3 | breaks | **Флип false→true уводит `Book.chapters_done` НАЗАД** (finishedUnits через ЖИВОЙ флаг): 40/100 → 0/100 одной транзакцией при росте ревизии — клиент обязан отрисовать спад; `ChaptersLeft` раздувается обратно → предлагает купить купленное. PD-316-класс на непокрытом триггере (пин ловит только admission). Крайний случай: книга, полностью начерченная под false, НЕдочитываема редактором никаким действием API (ChaptersLeft=0 → прогон не допускается → флип недостижим) | `sink.go:415`, `readmodel.go:388`, `books.go:981-983` | -| Н4 | breaks | **Книга без материализованного дерева**: полоса (вся из `chapters`) читает 0/total весь прогон при исправно доезжающих progress-событиях; комментарий `sink.go:219` («счётчики придут из progress-события») ложен уже в HEAD. + dispute: `runs.draft_done/draft_total/edit_done/edit_total` — 2 писателя, 0 читателей (PD-314-класс, лишняя запись на каждом событии под блокировкой книги; обоснование `greatest()` на `sink.go:444-446` защищает несуществующую полосу) | `sink.go:219, 137, 447` | -| Н5 | breaks | **SSE `hello` при переподключении несёт `id = state.Position` вместо предъявленного `last`** — WHATWG-клиент фиксирует новый Last-Event-ID ДО получения догона; обрыв сразу после hello теряет кадры навсегда (включая `note`, которые контракт запрещает терять) и обезоруживает дельта-ремонт (`revision` из hello выше потерянных строк). Лечение — одна строка: при resuming слать `last`. Код рядом (`stream.go:122-126`) сам знает правильное значение | `stream.go:99` | - -### Находки зоны ДВИЖКА (чужая зона — пинг оркестратору, мне не лечить) - -| # | Вес | Что | Где | -|---|---|---|---| -| Д1 | breaks | **Decline не энтити-широк на обратном пути**: отклонённая сущность возвращается в карту/auto-bank/редактору через свой АЛИАС (`reverseSectionTerms` фильтрует по одному ключу, а `mined_rejects` получает только `src`), и память предъявления глушит стоп об этом НАВСЕГДА — нарушен собственный контракт «a declined term never re-enters» (`miner_emit.go:59-63`); approve симметричной утечки не имеет (кластер через строку банка) | `mining.go:548`, `membank/decisions.go:598` | | Д2 | breaks | **Неидемпотентный decline** поверхности подписанного сида, имеющей строку в дельте: первый вызов ПРИНЯТ, повтор ТОГО ЖЕ документа — 409 (гейт `decisions.go:374` судит ДО-состояние, которое свёртка сама стирает); при классе 15 предписанный ре-сенд отбивается ЦЕЛИКОМ — **обещание сходимости 503-ретрая, на котором стоит синхронная дверь, ломается**. Доказано исполнением через СОБСТВЕННЫЙ оракул репозитория (оракул 4 фаззера); фаззер структурно не достаёт (фикс-книга не пересекает сид с дельтой). Лечение: судить по ПОСТ-состоянию, как соседний `refuseInertDeclines` | `membank/decisions.go:374` | | Д3 | breaks | **Потеря/порча маркера выхода после стопа** (совместная с платформой): рестарт/ретрай проходит границу банка НАСКВОЗЬ — память предъявления покрывает карту, движок «continuing» одним WARN себе в журнал — оплаченный `verify_bank` стоп исчезает молча и навсегда (память append-only). Гард `LiftBankStop` (`reconcile.go:1122-1126`) писан против движка ДО памяти v16 и этот путь не держит | `reconcile.go:424`, `mining.go:216-227` | -| Д4 | dispute | **`signature` в квитанции двери** считается от карты, которую переписывает ЛЮБАЯ граница майнинга (запись карты на `mining.go:191` — ВЫШЕ решения о стопе): `signature != null` не означает состоявшегося стопа; `surfaces`/`undecided` дрейфуют между двумя вызовами владельца; `undecided: 0` достижим при непредъявленных решениях (кап top-200 вытесняет). У карты ЕСТЬ идентификатор (`signaturemap.go:25-29`) — шов его не читает | `pipeline/bankdecisions.go:549-598` | -| Д5 | cosmetic | `spawn.go:159-165` (платформа, но про движок) цитирует упразднённый D39.144-контракт стопа («halts whenever undecided terms remain») — при памяти v16 фактически ложно и питает дырявый гард Д3 | `spawn.go:159-165` | - -### Ось «стоимость» — мои живые замеры + вердикт агента (сошлись) - -- **Строка 186 единого бэклога ПРОТУХЛА — рекомендация оркестратору: закрыть.** Механизм построен - и применён: агент прогрепал ВСЕ call sites `writeJSON` (коллекции, карточка, capabilities, POST - двери; SSE корректно НЕ сжимается). Мои замеры на стенде tmp9stand (команды и заголовки — сырьём): - банк 437 Б / 7.2 мс холодным, повтор с `If-None-Match` → **304 / 0 Б / 2.6 мс**; юниты главы - 3008 Б → **740 Б gzip (×4.1)**; gzip ниже порога 1024 Б честно не применяется; resume SSE за - головой → 204 / 1.3 мс. Взамен предлагаю ДВЕ новые стоимостные строки (ниже). -- **Карточка книги — 5 коррелированных сканов `chapters` на один GET** (3 в runRow: draftChapters - ×2 + editChapters, +2 в bookColumns: chaptersDone + noteCount), и те же 2 — на КАЖДУЮ строку - страницы библиотеки (×100). Эмпирика агента на живом PG 18.4, фикстура в форме миграции 00002, - книга 2283 главы: как написано — 1.359 мс; те же числа одним LATERAL `count(*) filter(...)` — - 0.307 мс (×4.4). Мой контрольный EXPLAIN на реальной схеме tmp9stand: **6 SubPlan-ов в плане, - 5 исполняются** (ELSE-ветка never executed) — форма подтверждена. PG повторные текстовые - вхождения подзапроса НЕ дедуплицирует (доказано side-effect-последовательностью), CASE-ветки - честно short-circuit. -- **`emitProgress` повторяет 3-скан агрегат по ВСЕЙ книге на каждое progress-событие** (движок шлёт - его на каждый разрешённый юнит каждой волны) — ПОД блокировкой строки книги: O(глав × юнитов) - вместо O(юнитов), на большой книге — секунды суммарного удержания блокировки за прогон. -- cosmetic: `MkdirAll(StateDir)` на каждый вызов двери — место одному разу в конструкторе Service. ### Чистые оси (проверено — не опровергнуто) @@ -1306,27 +1249,6 @@ bankCountsTx; проекции без утечек словаря/путей; bu термов через пересборку банка (id из ключа уникальности); сериализация чеканки кадров и штамповка ревизий чисты; чтения на одном снимке; идемпотентный ключ Start не клинит за очередью мьютекса. -### Финальная батарея по дереву (после всех приёмочных правок; дерево ревью НЕ меняло) - -`go test ./... -race -count=1 -v` с тремя гейтами → **EXIT=0, 18 пакетов ok, SKIP=0, -`^=== RUN` = 781**; DATA RACE — нет. (На сдаче было 775 — рост на приёмочных доборах, число -командой.) - -### Вопросы/предложения оркестратору - -1. Диспозиция Р1–Р8: что чиню в этом дереве до лендинга, что строками регистра. Готов завести - пакет строк PD-40x по всем группам после твоего вердикта (не завожу до слова — рядом лендинг). -2. PD-400.2: скорректировать формулировку accepted-risk по Р8 (снять «ни ложного слова», описать - мульти-репличный суб-кейс) ДО акта лендинга. -3. Строку 186 закрыть (замер выше), взамен — две стоимостные строки (5-скан карточка ×100 на - библиотеку; emitProgress O(глав×юнитов) под блокировкой) куда решишь. -4. Д1–Д5 — пинг бэкенду твоим каналом; Д2 ломает обещание, которое МОЯ дверь даёт на проводе - (сходимость ретрая 503) — до его лечения в движке слова канона §applyBankCorrections о ретрае - верны не для всех документов. -5. Н1–Н5 — наследие: Н1 (resume не-последнего) и Н5 (hello id, лечение в одну строку) выглядят - дешёвыми и болезненными; Н2/Н3 упираются в продуктовое решение о смене формы конвейера на - живой книге. - ## ПАК P9 ОТРАБОТАН — дверь правок банка смонтирована, ключи едут, полоса сквозная; цепь живого прогона ПРОБИТА живьём (сессия платформы, 27.08) Дерево передаётся на лендинг. Опись: `git status --short -- platform/` → 31 изменённый + 9 новых @@ -1543,6 +1465,16 @@ mutation-catch (спутать пары «числитель×база»); ко «до 17 раз» в `PD-377`, живая проба `PD-96`, экономика `PD-215`, кардинальность лейблов. Они остаются названными пробелами, а не тихими. +**Числа сдачи зоны — провенанс, другого носителя у них нет** (ни в `D39.159`, ни в +`platform/docs/p8-review/`, где лежит только сырьё): посадок мутаций **16** своих, все воспроизводимы +(`plant.py` знает `M1`–`M16` поимённо, логи рядом) — **7 поймано, 9 выжило**; агентских **42** по их +таблицам (`agent-mutations.md`) — **21/21**. Уникальных переживших инвариантов **15**, каждый несёт +строку регистра. +⚠ **Метод-урок той же сдачи, и место ему не здесь:** обрезанный по ширине вывод грепа читается как +ОТСУТСТВИЕ совпадения — так зона объявила «грепа в промте ноль» на строке, которую греп нашёл. Дом +урока — `platform/docs/ENGINEERING_STANDARDS.md` (дисциплина доказательства грепом); перенос за тем, +кто правит тот файл. + **Об ошибках, которые вы назвали сами** (пять, от овер-атрибуции `PLATFORM_DIRECTION` до неполной описи): называть их — правильно, и опись, недобравшая три файла из пяти, действительно стоила бы лендеру правки НОРМЫ. Продолжайте так же. @@ -1746,33 +1678,47 @@ D39.179 п.1 ратифицировал: DeepSeek пере-пинен под ц - **Потолок (строка 145): `tmctl translate|redrive --ceiling-usd ` — это КНИЖНЫЙ ПОТОЛОК В СИЛЕ, не бюджет прогона** (ратифицировано D39.122): леджер сравнивает значение с накопленным committed+reserved книги. Пересчёт пользовательского «прирост в главах» (D39.110) в абсолют — ОБЯЗАННОСТЬ платформы. **⚠ АМЕНДИРОВАНО D39.123 (PD-158): формула = `committed_usd + прирост×оценка`, БЕЗ reserved** — read-only `status` показывает leftover-reserved, который `store.Open` зануляет до первой судимой резервации; включение переплачивало бы запасом сверх холда (исполнено обеими формулами против гейта). `reserved_usd` читается обязательным полем как гард присутствия и улика leftover. Значение ниже уже потраченного откажет первой же резервации. День-потолок не перекрывается. Опция по желанию зоны: движок готов провести `--ceiling-usd` и в `status`, чтобы мониторинг видел действующий потолок capped-прогона (сегодня status показывает книжный) — скажите, заведём строку. - ## Пинг оркестратора №16 — 09.08.2026 (форма DEFECT_REGISTER, строка 167) -Реестр дефектов дорос до 171 строки одной таблицей; докс-аудит D39.125 (worksheet `docs/archive/reports/DOC_AUDIT_INVENTORY_2026-08-09.md`, жалоба «зонная изоляция прячет половины тем») просит форму: разложить таблицу на СЕКЦИИ (open по весу · accepted-risk · fixed по эрам паков), сохранив построчную форму `| PD-N | … |` — `docs/scripts/counts.py` ключуется формой строки, не позицией, секции его не ломают. Заведите П-строкой в свой BACKLOG, исполнение — попутно следующим паком, НЕ срочно. Вопросы — через владельца. +ИСПОЛНЕН: реестр разложен на секции (открытые по весу · принятый риск · закрытые по эрам паков) при +сохранённой построчной форме `| PD-N | … |`, на которой ключуется `docs/scripts/counts.py`. Заказ и +его причина — D39.125. ## Пинг оркестратора №17 — 15.08.2026 (migrate принят, D39.134 — движковая половина самолечения ФИНАЛЬНА) -Бэкенд-пак `tmctl migrate` принят и заленден (`d55edd4`): exit 13 = `schema_mismatch` финализирован, токен `schema_mismatch found=N expected=M` на stderr стабилен, write-путь тоже отказывает БД новее бинаря. Ваш PD-201 (самолечение «поймал 13 → migrate → повтор») теперь можно строить — движок больше не сдвинется под ногами; строку реестра, ссылающуюся на «движкового migrate ещё нет», обновите. Хвосты вашей зоны, найденные приёмкой (вход промта P7, исполнение попутно): (1) `deploy/README.md:131` цитирует МЁРТВЫЙ текст ошибки схемы («schema vN … expects vM») — движок его больше не печатает; (2) та же мёртвая цитата и уплывший якорь `store.go:135-143` в строке П-1 вашего BACKLOG; (3) комментарий-образец в `tmplatformctl` (`runs.go:45`) зовёт голый `tmctl` из PATH — копипаст воспроизводит тихий no-op СТАРЫМ бинарём при живом деадлоке, ваш же README:139-141 требует версионированный путь. Вопросы — через владельца. +Бэкенд-пак `tmctl migrate` принят и заленден (`d55edd4`): exit 13 = `schema_mismatch` финализирован, +токен `schema_mismatch found=N expected=M` на stderr стабилен, write-путь тоже отказывает БД новее +бинаря — то есть движок под ногами больше не сдвинется, и `PD-201` («поймал 13 → migrate → повтор») +можно строить; строка регистра открыта и несёт предмет. Три хвоста зоны, найденные той же приёмкой +(мёртвая цитата текста ошибки схемы в деплой-рантбуке и в строке П-1 бэклога; образец в +`tmplatformctl`, зовущий голый `tmctl` из PATH), с тех пор закрыты правками — рантбук цитату сам +называет мёртвой, образец несёт версионированный путь. ## Пинг оркестратора №17 (второй) — 15.08.2026 (S4 принят, контракт 0.2.3 в каноне — пять строк вашего регистра протухли; аудит корпуса) -Фронт S4 принят и заленден (D39.135), контракт **0.2.3** в каноне. Для вашего регистра: (1) **PD-172 · PD-173 · PD-174 · PD-180 — диспозиция «спек-правка 0.2.3 задачей S4» (D39.130 п.2в) ИСПОЛНЕНА** — правила стоят в `openapi.yaml` §createBook (PD-172 при этом уточнён ПО ВАШЕМУ коду: чтение останавливается на файле, обязательное после файла = 400 «как не слали», необязательное молча теряется — D39.135 п.2б); обновите статусы. (2) **PD-199 ратифицирован закрытым ещё D39.132 п.2а** («null на проводе подтверждён»), в регистре до сих пор open — привести. (3) **Ваше НОВОЕ обязательство в P7** (D39.135 п.2в): читать `BookIntake.title` (сегодня падает в «unknown field is IGNORED», `v0.go:369-376`) и проецировать `books.reject_reason` на провод (сегодня «kept for an operator and never projected», `v0.go:497`) — без этого 0.2.3 остаётся обещанием без носителя. (4) Кандидаты в P7 по аудиту корпуса 15.08: **PD-219** (упавший дрейн теряет хвост `unit_resolutions` навсегда — зона сама вешала его на читающую поверхность) · **PD-217** (книга на вечном холде блокирует апгрейд движка — вторая половина после D39.132) · **PD-162** (удалённый каталог книги = вечный прогон с открытым холдом). (5) ⚠ Ваш же журнал честно фиксирует: «`tmctl migrate` живьём не гонялся» — команда теперь СУЩЕСТВУЕТ и принята (D39.134); мнение оркестратора: прогнать деплой-рантбук end-to-end на дев-стенде настоящим migrate — обязательное предусловие первого выката, дешевле любого пака. Вопросы — через владельца. +Фронт S4 принят и заленден (**D39.135**), контракт 0.2.3 в каноне. Пять статусов регистра (`PD-172`, +`PD-173`, `PD-174`, `PD-180` по диспозиции D39.130 п.2в; `PD-199` по D39.132 п.2а) с тех пор приведены — +все пять стоят закрытыми эрой P7. Обязательство 0.2.3 (`BookIntake.title` и проекция +`books.reject_reason` на провод, D39.135 п.2в) исполнено — `reject_reason` живёт членом ответа книги. +Три кандидата аудита 15.08 (`PD-219` упавший дрейн теряет хвост `unit_resolutions` · `PD-217` книга на +вечном холде блокирует апгрейд движка · `PD-162` удалённый каталог книги = вечный прогон с открытым +холдом) ОСТАЮТСЯ ОТКРЫТЫМИ строками регистра — предмет там, не здесь. +⚠ **Живым остаётся одно:** ваш журнал фиксировал «`tmctl migrate` живьём не гонялся», команда с D39.134 +существует, и мнение оркестратора — прогнать деплой-рантбук end-to-end на дев-стенде настоящим migrate +как обязательное предусловие первого выката. ## Пинг оркестратора №17 (третий) — 16.08.2026 (контракт-ревью принято D39.138: ваша половина батча 0.3.0, PD-104 закрыт, попутные находки) -Контракт-ревью API v0 отработало отдельной сессией и ПРИНЯТО (отчёт — `docs/research/28-contract-review.md`; решения владельца — его §8, ратификация D39.138). ⚠ Читать ОРИГИНАЛ отчёта, не этот пересказ — он и есть носитель. -1. **PD-104 ЗАКРЫТ словом владельца 16.08** (§8 п.14): `SignupGrantMicroUSD` → **0** на бете, начисление руками; возврат $5 — вместе с суточным агрегатным потолком, когда появятся платежи. Исполнение — P7-однострочник конфига + тест (грант при неверифицированном email уже 0, `login.go:306-308`). Обновите регистр. -2. **Ваша половина батча 0.3.0 — ПОСЛЕ лендинга спеки; состав — §5 отчёта:** `wireProgress` → один счётчик (Б-0; ваши колонки и словарь шва НЕ трогаются — фазы остаются внутренним делом); словарь машинных `code` + `request_id` в `Problem` (Б-1/§8а: класс 1 — конкретика максимальная, класс 2 модельный — ОДИН грубый код без вариации); `GET /capabilities` (Б-2: пары/порог интейка/форматы/страницы/версия — всё уже есть в конфиге и константах); `WWW-Authenticate` на 401 и две причины 403 (Б-15); трейлинг-части интейка → 400 вместо тихой потери + `Location` на 201 + `Idempotency-Key` (Б-3); подрезание `limit` до максимума вместо сброса к дефолту (Б-10, `books.go:509-511`); `PATCH /books/{id}` title-only + `DELETE` + `GET /runs/{id}` + `Run.failure_reason` (Б-5/Б-8 — словарь исхода у движка уже есть); `blocked: {code, book_id}` на run-options/409 (В-6); отказ по неподдерживаемой паре кодом на интейке (сегодня ja/en-книга умирает `failed`-ом на валидации конфига движка в КОНЦЕ пути — денег не сгорает, но причина не доезжает). -3. **Сеть (§5б; вход до первой живой книги под фронтом, строка 186):** gzip на текстовых ответах (НЕ на SSE) и `ETag`/`If-None-Match`→304 на списочных GET — ваше же требование `PLATFORM_DIRECTION.md:202-205`, батчем оно уезжает В КОНТРАКТ. Числа: дерево 2283 глав = 250 КБ на кадр `status`, фокус-рефетч 12 вкладок = 562 КБ. +Контракт-ревью API v0 отработало отдельной сессией и ПРИНЯТО (носитель — `docs/research/28-contract-review.md`, +решения владельца — его §8, ратификация **D39.138**; читать ОРИГИНАЛ, не пересказ). Три пункта пинга с тех пор +ЗАКРЫТЫ и здесь остаются только именами: `PD-104` (грант 0 на бете, начисление руками — §8 п.14) стоит закрытым +эрой P7 · половина батча **0.3.0** (состав — §5 отчёта) построена, канон с тех пор ушёл на семь миноров вперёд · +шаги 1-2 сети (gzip и `ETag`/`304`) заленджены P7 (D39.153) и пере-проверены замером — числа живут строкой **186** +единого бэклога. Живыми остаются два пункта: + 4. **К-10 (пофазность у главы): НЕ СТРОИТЬ** — поправка приёмки: вердикт §6 отчёта («правка проекции») противоречит Б-0; фазы уходят с провода и у главы. 5. **Попутные находки §9** (сессия их адверсариально НЕ судила — проверьте у себя, заведите П/PD-строки по месту): (а) расчёт может висеть навсегда в двух легаси-путях (`reconcile.go:660-670` — попытка без базовой отметки; дев-путь без движка) — бюджета «сколько холд может висеть» не существует; (б) флаг «аккаунт остановлен» выводится сканом последних прогонов ВСЕХ книг (`pgstore/books.go:720-724`) — одна книга зажигает аккаунт; после PD-203 перечитать; (в) `ReadRunForSpawn` линеен по живым прогонам (`pgstore/runs.go:712`); (г) `/metrics` без аутентификации, защита — только 127.0.0.1 (`main.go:200-202`); (д) лимитер входа один на процесс (`login.go:128-129,165`) — один клиент упирает вход всем (выбор объяснён комментарием, следствие стоит записать); (е) сырой Go-текст ошибки лежит причиной карантина в БД (`reconcile.go:320`) — на провод не идёт. Вопросы — через владельца. -**Аддендум к пингу №17-третьему (16.08, свип планировочных доков D39.139):** (1) `docs/PLATFORM_DIRECTION.md` §2 и `BACKLOG.md` П-7 держат «фри-тир дефолт $5» — ПРОТУХЛО против PD-104 (слово владельца 16.08: грант 0 на бете, начисление руками; D39.138 п.2л) — поправить тексты при P7. (2) Баннер §3 PLATFORM_DIRECTION («ратификация за оркестратором») закрыт ещё D39.132 п.2б: oapi-codegen — кандидат при P7, sqlc — привязан к P7, River-факт поправлен; баннер снять/заменить ссылкой на ноту. (3) §4 п.5 («сервер ВПРАВЕ склеивать события») сузится батчем 0.3.0: склейка только снимкам, `note` не склеивать (research/28 Б-6в). Вопросы — через владельца. - -**Дозакладка к §4.6 промта P7 (16.08, аудит готовности зоны; релей владельца — подтверди эхом):** к списку протухших текстов зоны добавь: `PLATFORM_DIRECTION.md` §4 п.8 («живого канала до-прогонных состояний нет») — решено книжным SSE 0.3.0, опрос запрещён нормой · §4 п.10 («персист манифеста — недостающий») — построен D39.122 · `:184` ссылка на несуществующий §3.3 стандартов · `platform/README.md:3-4` шапка «зона на P1» и «один тест-гейт» (гейта два) · `BACKLOG.md` П-17 — состав пака у́же промта (промт первичен). Расхождение ENGINEERING_STANDARDS:59 против DIRECTION:128 по oapi-codegen решаешь ты (промт §4.6). Всё — попутно, не отдельным заходом. - - ## Пинг оркестратора №18 — 20.08.2026 (P7 акт 5 ПРИНЯТ С ФИКС-ЛИСТОМ и ЗАЛЕНДЕН; контракт 0.4.0 ратифицирован; тело — D39.152/D39.153) **Вердикт: ПРИНЯТЬ С ФИКС-ЛИСТОМ.** Панель шести линз в изолированных копиях (слепая · контракт-конформность · деньги · шов · вне карты · ревью канона 0.4.0 другой моделью) + пере-раны и собственные посадки оркестратора. Регрессов против HEAD нет, ни одной линзы с REJECT. @@ -1787,27 +1733,40 @@ D39.179 п.1 ратифицировал: DeepSeek пере-пинен под ц ### Фикс-лист приёмки — завести строками СВОЕГО бэклога и регистра (зона моя не пишет) -⚠ **ПЕРВЫМ — не из пака P7, но найдено вторым рубежом приёмки и проверено мной построчно: `Sweep` останавливается для ВСЕЙ инсталляции на двух медленных прогонах, и выхода нет.** `cmd/tmplatformd/runner.go:189` — `sweepBudget = 2 * time.Minute` на ВЕСЬ проход; `internal/runs/reconcile.go:70` — `defaultRunBudget = 60 * time.Second` на ОДИН прогон. 120/60 = 2: два прогона, выбравшие свой бюджет, съедают проход целиком, и тогда `ctx.Err()` (`reconcile.go:39,48`) обрывает цикл — **`UnsettledRuns`, единственный ретрай отложенного расчёта, не вызывается вообще**, а живые прогоны, стоящие ниже, не реконсилируются и не спавнятся. `ListLiveRuns` сортирует `order by r.started_at` (`pgstore/runs.go:249`), то есть заклиненный прогон — по построению самый старый — стоит в голове и голодит остальных КАЖДЫЙ проход, детерминированно. Ручки нет: `runs.Config.RunBudget` объявлен (`internal/runs/runs.go:62-65`), но `startRunner` его НЕ присваивает (`cmd/tmplatformd/runner.go:74-83`), переменной окружения нет ни для одного из двух чисел, а `tmplatformctl` не умеет ни закрыть прогон, ни вернуть холд. Цена: прогон вечно `finished_at is null` ⇒ холд не возвращается ⇒ `runs_one_live_per_book` не даёт запустить новый прогон этой книги. Деньги заморожены, книга заморожена, пользователю видно «идёт». -**Достижимо буднично, без экзотики:** `RunSink.Apply` берёт транзакцию с блокировкой книги на КАЖДУЮ строку журнала (`pgstore/sink.go:80-104`), поэтому после часа простоя демона две книги с бэклогом выбирают проход целиком — и всё время догона деньги всей инсталляции не считаются. -⚠ **`PD-169` в вашем регистре стоит `fixed(P5)` и этим ЛЖЁТ приёмке:** его пин гоняет `Sweep` вообще без дедлайна прохода, то есть доказывает пер-прогонный бюджет, а не выживание прохода. Пере-открыть. Код приехал в P6 и в дифф P7 не входил (`Sweep` против HEAD — identical), поэтому ни одна диффовая линза его увидеть не могла: тот же класс «композиция известных фактов», что и строка 198. ⚠ Наблюдаемость при этом ЕСТЬ и она ваша: `tm_platform_sweep_unfinished_total` растёт — то есть оператор увидит, что проход не дошёл до конца, и не сможет ничего сделать. Метрика без ручки — половина механизма. +⚠ Список ИСТОРИЧЕСКИЙ — запись о том, что было верно 20.08; задним числом он не переписывается. +Пропуски в нумерации — снятые пункты, чьё исполнение проверяемо и чей предмет живёт по адресу: +**п.1** `ContractVersion` (поднята F13 до 0.6.0, канон сегодня 0.9.0) · **п.2** пины на +`structure_version`/`revision` в кадре (стоят с названной пойманной мутацией в `pgstore/events_test.go`) · +**п.3** бюджет попыток материализации (`PD-357`, закрыт эрой P8-FIX) · **п.5** round-trip в +`SaveStructure` (`PD-297`, открытая строка несёт предмет) · **п.7** мусор `platform/ru` (снесён +оркестратором тем же заходом) · **п.8** ревью-пак четырёх осей (отработан паком P8-REVIEW, D39.159) · +**п.10** пол на пустой манифест (`readmodel.refreshStructure` сверяет `ChaptersTotal`, тест есть) · +**п.11** `PD-327` (строка закрыта эрой «самопроверка акта 5») · **п.13** оговорка `CreditHeldBy` +(запинена с названной пойманной мутацией в `pgstore/credits_test.go`). +⚠ **п.12 (изоляция читающих чтений) НЕ закрыт и потому остаётся дословно:** пина на +`RepeatableRead`+`ReadOnly` у `inReadTx` в дереве по-прежнему нет. +⚠ **ПЕРВЫМ — не из пака P7, но найдено вторым рубежом приёмки: `Sweep` останавливается для ВСЕЙ +инсталляции на двух медленных прогонах, и выхода нет** (бюджет прохода 2 мин против 60 с на прогон ⇒ +`UnsettledRuns`, единственный ретрай отложенного расчёта, не вызывается вообще; заклиненный прогон по +построению стоит в голове `order by started_at` и голодит остальных детерминированно; деньги и книга +заморожены, пользователю видно «идёт»; `tm_platform_sweep_unfinished_total` растёт — метрика без +ручки). Тело разбора — **D39.153 §8в**; складывающиеся бюджеты пяти проходов и лечение — строка +**`PD-386`**. ⚠ Попутно: `PD-169` стоял `fixed(P5)` и этим ЛГАЛ приёмке (его пин гонял `Sweep` вообще +без дедлайна прохода, то есть доказывал пер-прогонный бюджет, а не выживание прохода) — пере-открыт и +закрыт эрой P8-FIX. + +4. **sqlc — БЕРЁМ, слово владельца 20.08 («я вообще за»).** ИСПОЛНЕНО: `PD-44` закрыт эрой P12, пак + `sqlc` (П-19) отработан секцией выше. Заказанный тем же пунктом ПОСТОЯННЫЙ гейт — прогон каждого + собранного запроса через разбор Postgres против мигрированной схемы — построен + (`TestEverySQLStatementParsesAgainstTheMigratedSchema`). Разовый ответ, который он тогда дал — + 141 запрос, все планируются чисто — и поправка к нему («десять непокрытых» неверно, реально три) + живут телом **D39.153 §8г**. -1. **`ContractVersion` = "0.3.0" при формах 0.4.0** (`internal/httpapi/capabilities.go:13`, он же в каждом `hello` — `stream.go:100`). Ратификация 0.4.0 состоялась (D39.152) ⇒ константа обязана подняться. Комментарий над ней сам это требует: «raised in the same commit as the code that implements a new minor». Сегодня практического вреда нет (фронт заморожен на 0.2.3), но деплой объявляет версию, которую не отдаёт. **Первым пунктом.** -2. **Пины на два поля `EventBase`** (см. выше). ⚠ Попутно 21.08 усилен гейт якорей доков: `file:line` теперь может нести токен ожидания (`` `путь:12-14`=`подстрока` ``), и такой якорь сверяется ПО СОДЕРЖИМОМУ на каждом прогоне, включая коммиты, которые двигают ЦЕЛЬ. Ваши доки (`platform/docs/**`) вошли в область линта — раньше их не сканировал никто. -3. **Бюджет попыток материализации.** У долга нет предела и канала «признать безнадёжным»: claim → fail → defer(`now()`) → следующий свип через 15 с, вечно (`pgstore/books.go:538,557-564`). У интейка предел есть (`parseAttempts`=5). Три следствия: до 5 мин движковых процессов на проход бесконечно · поток такой книги НЕ заканчивается никогда (`AtRest` требует `read_model_owed_at is null`, `events.go:125`) · при живом манифесте и падающем экспорте `SaveStructure` коммитится каждый проход ⇒ `revision++` и кадр каждые 15 с. -4. **sqlc — БЕРЁМ, слово владельца 20.08 («я вообще за»).** Отступление P7 закрыто, PD-44 переоткрыть исполнением. ⚠ Ваше предложение «взять на однооператорных ручках P8» покупает инструмент туда, где не болит: рантайм-ошибки «нет такой колонки» случились в СКЛЕЕННОМ SQL read-модели (⚠ счёт: проверяемый носитель — ваш регистр PD-44 и журнал — несёт ДВЕ; ещё две вы назвали в ответе владельцу, в доки они не попали, поэтому опираемся на две — испр. 20.08) (`chapters_before`/`stop_for_signing` — `readmodel.go:441,446`, там же `note_count` и сломанный алиас `b`), куда sqlc по построению не дойдёт. Тем же паком — гейт, который туда дойдёт: прогонять КАЖДЫЙ собранный запрос через разбор Postgres (prepare/describe) против мигрированной схемы; склейка ему не мешает, он получает финальную строку. И ответить попутно на вопрос, которого мы не знаем: **есть ли в read-модели запрос, которого не касается ни один тест** — если есть, это не «медленная обратная связь», а дыра. ⚠ **Половина ответа УЖЕ получена вторым рубежом приёмки** (собранный SQL извлечён из пакета через `go/types` и прогнан `EXPLAIN (GENERIC_PLAN)` против мигрированной схемы): **141 запрос, все планируются чисто, остаточных «нет такой колонки» НЕТ**; тестом ЭТОГО пакета недостижимы десять, но по батарее ЦЕЛИКОМ реально не покрыты только **три** (`DeleteOldLoginEvents` · `UserByIdentity` · `Observe`; остальные семь покрыты на 58–87% тестами соседних пакетов — пере-мерено покрытием при аудите 20.08, первая редакция называла десять и это было неверно) — `RefundParseAttempt` · `StuckIntake` · `OpenReservations` · `ReleaseUnspawned` · `DeleteOldLoginEvents` · `UserByIdentity` · `Observe` · `SpendBound` · `AttemptReservationOpen` · `RunPausedReason`, и ни один из них не трогает схему 00016–00024. То есть дыры сегодня нет, а гейт нужен как ПОСТОЯННЫЙ — именно он и делает этот ответ воспроизводимым. -5. **PD-297** — round-trip на строку в `SaveStructure` под эксклюзивной блокировкой книги; мерить на корпусной книге до и после (корпусная книга появится на холодном прогоне движка). 6. **Труба доставки решений банка в движок** — единый бэклог, строка **199(а)**: перед resume писать решения в `mined_delta`/`mined_rejects`. Сегодня `action`/`dst` — write-only колонки (единственный SELECT `readmodel.go:336` проверяет лишь наличие строки), а воркер описан в комментарии вашей же миграции `00002_readmodel.sql:172-174` и не построен. -7. **Мусор:** пустой `platform/ru` (0 байт, обрубок редиректа) — в лендинг не взят, снесён оркестратором. `books.go:234-235` — задвоенная первая строка доккомментария, класс PD-310/326. -8. **Ревью-пак четырёх осей, которых не смотрел НИКТО** (ваш же obstacle): деньги и леджер целиком · вход/сессии/CSRF · очередь и джобы · метрики. Это не дофикс и не довесок — первый взгляд, отдельной работой. 9. **Один носитель на факт для замера 00016→00022 — но НЕ удалением числа.** Выигрыш назван ЧЕТЫРЬМЯ носителями: шапка этого журнала «83%» · `internal/pgstore/perf_test.go:13`=`96% of the page` «96%» · миграция 00022 «16.8 против 3.4, джойн сам 9 мс» · регистр PD-306 (повторяет 83% и 16.8/3.4). ⚠ Я сперва записал, что «96%» ни из чего не выводится — **это была моя ошибка, снята проверкой записей**: оно выводится точно из ВАШЕГО же замера в `archive/P7_ACT5_FIX_PLAN_2026-08-20.md:444-446`=`636 мс против` («636 мс против 24 мс», холодный корпус акта 4) = 96.2%, тогда как 83% и мой независимый ≈80% — с вакуумированного корпуса. То есть носители меряли РАЗНОЕ и все честны. Свести указанием УСЛОВИЙ замера при каждом числе, оставив нормативным один (миграция 00022 — она их и несёт); удалять «96%» как фантом НЕЛЬЗЯ. -10. **У материализатора нет ПОЛА на пустой манифест — латентная потеря всего текста книги.** `internal/readmodel/readmodel.go:215-232` строит `in.Chapters` только из `manifest.Chapters` и ни разу не сверяется со счётчиками того же документа (`ChaptersTotal`/`UnitsTotal` лежат рядом и печатаются в лог строкой ниже). Пустой список едет в `SaveStructure`, где `pgstore/readmodel.go:193-196` выполняет `delete from chapters where book_id = $1 and not (id = any($2))` — на пустом массиве предикат истинен для ВСЕХ глав, и каскад `chapters → units` сносит текст; при пустом `Key` вдобавок срабатывает `changed` и уходят все `unit_resolutions` (родня строки 198). Сегодняшним движком недостижимо (`buildManifest` всегда наполняет главы, файл пишется атомарно) ⇒ фикс-лист, не блокер. **Асимметрия и есть находка:** на ИНТЕЙКЕ ровно этот случай отловлен явно и прибит мутацией (`internal/books/parse.go:134-142`, `books_test.go:1296`) — «нет глав, но работа считается» там не признаётся правдой о книге. У материализатора такого пола нет, и теста на нулевой манифест в `readmodel_test.go` тоже нет. Лечится одной сверкой `len(manifest.Chapters)` против `manifest.ChaptersTotal`. -11. **`PD-327` в регистре стоит `open`, а канон 0.4.0 РАТИФИЦИРОВАН** (D39.152) — то есть условие её закрытия наступило. Закрыть строку регистра явно; сегодня расхождение видно скриптом (`counts.py --check` печатает PD-327 среди открытых), и это ровно тот класс, ради которого регистр объявлен источником истины по статусу. - 12. **Изоляция читающих чтений НЕ ЗАПИНЕНА (аудит 21.08, посадка мутации).** Снятие `RepeatableRead`+`ReadOnly` у `inReadTx` (`internal/pgstore/credits.go:492-493`) проходит ВСЮ батарею — при том, что под этим инвариантом лежат шесть ручек выдачи, а носитель прямо объясняет цену («every frame in that window was lost for good», `books.go:684-688`, PD-163). По вашей же норме PD-1 свойство без пинящего теста считается НЕ закрытым — а это тот самый класс, которым я мерил найденную дыру с `emitFrame`. Код приехал НОВЫМ в P7, то есть лежал внутри диффа, который читали шесть линз приёмки и я сам. -13. **`CreditHeldBy`: оговорка `and book_id <> $2` не запинена (аудит 21.08, посадка мутации).** Мутант, снимающий её, проходит батарею: пин, который выглядит покрывающим, её ни разу не исполняет — в фикстуре у исключаемой книги холда НЕТ (`internal/pgstore/credits_test.go:443-465`). Сумма кормит контрактный `blocked`, и раздутая сумма отправляет пользователя гасить прогон, который ничего не освободит — ровно тот вред, против которого написан комментарий над самим запросом (`credits.go:393-397`). - ### Что ушло в ЕДИНЫЙ бэклог (движок/шов/контракт — ваши строки туда не заходят, D39.84) **198** апгрейд движка стирает замечания и счётчики безвозвратно (ваша зачистка при ре-кате × announce-once движка — композиция, гейт холодного прогона) · **199** канал доставки правок банка · **200** сквозная полоса прогресса вместо пофазной (слово владельца 20.08) · **201** «Глава N» внутри текста экспорта · **202** живой прогон насквозь через API — ПОСЛЕ холодного прогона движка (слово владельца 20.08) · **203** хвосты контракта · **204** движок публикует причины флагов данными (релей §7в, ваш PD-246 — строка заведена в бэклоге ДВИЖКА, как вы и просили). ⚠ По релеям §7 сверено грепом при лендинге, а не по вашему списку: **(б) и (д) уже ИСПОЛНЕНЫ** синком 0.4.0, **(г) наполовину** (`unspecified` ратифицирован, открыта граница ступеней), **(и)** закрыт полем `stop_requested`. Реально открыты только (а)-канон-половина, (з) и договорная часть (к). @@ -1818,7 +1777,5 @@ D39.179 п.1 ратифицировал: DeepSeek пере-пинен под ц **Оговорка про пер-термные решения расширена на ВСЮ ручку** (была только на `decline`): инертны одинаково и `approve`, и `dst`. Оговорка временная — снимается исполнением строки 199(а). -⚠ **Исправление МОЕГО же пинга №15 (испр. оркестратором №18):** «Все сайдкары пишутся атомарно (temp+rename)» — НЕВЕРНО. Атомарны три из шести (`manifest.json`, `bank.json`, `bank-stop.json` — `backend/internal/pipeline/artifact.go:24-59`); `bank-stop.txt`, `mined-signature.yaml`, `auto-bank.yaml` пишутся `os.WriteFile` с усечением. Сегодня безвредно (читателей нет), но сессия, взявшая любой из них по той строке, получит усечённый документ на живом прогоне. - **Сессия P7 ЗАКРЫТА владельцем 20.08.** Промт отработан → `platform/docs/archive/`. Дальше — другие сессии.