textmachine/platform/docs/platform-PROGRESS.md

185 KiB
Raw Blame History

Журнал зоны «Платформа»

Весь прогресс платформы — ЗДЕСЬ (решение владельца 04.08): пинги, итоги сессий, открытые вопросы, предложения на ратификацию. В docs/PROGRESS.md платформа не пишет; оркестратор читает этот журнал при каждом лендинге зоны (свип «решений владельца» — норма D39.99 п.4).

Текущее состояние

  • P4 (0809.08) построила РАННЕР целиком (раздел «Сессия P4» ниже): транзиентный systemd-юнит на прогон · очередь River + воркер · реконсилятор (свип + бут) · тейлер events.jsonl с курсором · первые пять ручек /v0 по контракту 0.2.0 · дев-интейк книг. Батарея зелёная целиком с живым PostgreSQL 18.4 под -race, линтер 0 issues, make vuln чист, скипов ноль. Тестов 105 → 242 (удалённых ноль). Дерево НЕ закоммичено — ждёт приёмки.

  • Адверсариальное ревью (четыре независимых верификатора) нашло ТРИ денежные ошибки и три запирающие прогон — все закрыты в этом же дереве, с пинами и повторной живой пробой. Раздел «Адверсариальное ревью» ниже; главная — расчёт брал пожизненную трату КНИГИ вместо траты прогона (PD-124), и её независимо воспроизвели два верификатора.

  • Регистр после P4 — 143 строки (python3 docs/scripts/counts.py): 107 закрыто · 3 приняты риском · 1 закрыт ратификацией · 32 открыто (1 major — PD-113, 9 minor, 22 info). Закрыты в P4 шесть СТАРЫХ строк: PD-43 · PD-81 · PD-82 · PD-97 · PD-99 · PD-105 (ратификация D39.119 + реализация). Заведены тридцать шесть новых, PD-108…PD-143: двадцать пять найдены И закрыты внутри пака (пять — собственной самопроверкой, двадцать — адверсариальным ревью), десять оставлены открытыми вопросами и гейтами.

  • P3 (08.08) отработала фикс-лист приёмки P2 ЦЕЛИКОМ — все восемь пунктов (раздел «Сессия P3» ниже): PD-80 · PD-79 · PD-83/84/85 · PD-100 · PD-106 · PD-91 · PD-95 · PD-104 (часть док↔код). 22 посадки, 22 поймано; батарея зелёная офлайн и с живым PG 18.4 под -race, линтер 0 issues, make vuln чист. ПРИНЯТО и ЗАЛЕНДЕНО приёмкой №15 — D39.114, 99f049c (было: «дерево НЕ закоммичено — ждёт приёмки»). ⚠ Адверсариального ревью P3 не проводила (сессия без права на субагентов): проверено исполнением, но вторым читателем — нет.

  • Регистр после P3 — 107 строк (скриптом по таблице): 75 закрыто · 3 приняты риском · 1 закрыт ратификацией (PD-59) · 28 открыто — из них 1 major (PD-105, за границей пака), 7 minor, 20 info. Закрыты в P3 девять строк: PD-79 · PD-80 · PD-83 · PD-84 · PD-85 · PD-91 · PD-95 · PD-100 · PD-106. Открытые: PD-6 · PD-23 · PD-43 · PD-44 · PD-45 · PD-60 · PD-61 · PD-72 · PD-81 · PD-82 · PD-86…PD-90 · PD-92…PD-94 · PD-96…PD-99 · PD-101…PD-105 · PD-107. ⚠ PD-104 намеренно оставлен ОТКРЫТЫМ: расхождение док↔код починено (ячейка PD-30), но продуктовая половина — ноль на бете, агрегатный потолок фри-тира, счётчик — ждёт слова владельца, и закрывать строку по половине было бы ровно тем, за что заведён PD-83.

  • P1+P2 ПРИНЯТЫ и ЗАЛЕНДЕНЫ приёмкой №15 (07.08) — раздел «Ратификация приёмкой P2» ниже: вердикт, метод, что ратифицировано, фикс-лист, что опровергнуто. Два вопроса ушли владельцу: срок сессии 30 суток и агрегатный потолок фри-тира (PD-104).

  • P2 отработала очередь приёмки P1 целиком плюс ДВА собственных адверсариальных ревью (05.08) — разделы «Сессия P2» ниже. Риском приняты три строки (PD-22 ограничитель соединений на edge, PD-42 глобальный лимитер входа, PD-71 откат ниже версии 5); счёт регистра — строкой выше, здесь не дублируется. Открытыми на конец P2 были восемь: PD-6 (origin-чек SSE-хендшейка — строить нечего до SSE), PD-23 (ретеншен журнала входов — сам свип есть, строка про политику), PD-43 (денежный контур без вызывающих до воркера), PD-44 (sqlc — закрыт ратификацией, направление изменено), PD-45 (окно pid при сигнале группе), PD-72 — возврат общего лимита тела не наблюдаем, пока нет маршрута со своим потолком: тест обязан приехать вместе с загрузкой книги, PD-60 и PD-61 — свойства ШВА, работа строки 103 единого бэклога (обратное давление и сброс буфера эмиттера: платформенной части у них нет, пока эмиттера нет).

  • Закрыто в P2: вся очередь приёмки (PD-46…PD-58), три info-строки вне очереди (PD-50, PD-53, PD-56), три собственные находки — PD-62 (потерянный start_id), PD-63 (ClearReadDeadline воспроизводил PD-2), PD-64 (OOMPolicy=stop уронил бы контрол-плейн) — и шесть находок собственного адверсариального ревью (PD-65…PD-70), из которых одна major: скользящее окно бездействия для браузера не работало (PD-70).

  • Два значения изменены, не обоснованы: абсолютный срок сессии 90 → 30 суток (цифра NIST AAL1; отклонение без причины, выдерживающей проверку, — не отклонение, а недосмотр) и MemoryMax=2G80% (потолок машины вместо мнимого потолка сервиса). Оба переопределяются.

  • Построено в P1: вход через OIDC (П-6), кредитный леджер с резервациями (П-7), админ-CLI (П-8), деплой-юнит systemd, тест-пол на реальном http.Server, фаззинг NDJSON-декодера.

  • Стандарты зоны заведены (решение владельца 04.08): ENGINEERING_STANDARDS.md (критерии приёмки, индустриальные базовые линии) + DEFECT_REGISTER.md (отдельная колонка багов и уязвимостей).

  • P0 собран (сессия 04.08): модуль компилируется, батарея зоны make check зелёная, /healthz и /readyz проверены живым запуском против живого Postgres 18.4.

  • Стек запинен и live-сверен — STACK_DECISIONS.md (зонный).

  • Дизайн-ответы К-4 · К-7 · К-12 · форма П-5 — ниже, ПРЕДЛОЖЕНИЯМИ на ратификацию.

  • Контрактных ручек нет намеренно: они ждут ратификации К-4/К-7 (форма ответов) — это П-1.

Сессия P4 (0809.08): раннер — юнит-на-прогон, очередь, реконсилятор, тейлер, первые ручки /v0

Каждое число ниже — с командой, которой получено. Дерево не коммичено; в индексе ничего не держу.

Работа 1 — транзиентный systemd-юнит на прогон

internal/runner/runner.go (юнит), marker.go (маркер выхода), engine.go (инвокация движка).

Привилегия-модель решена ДО постройки, и второй вариант отвергнут по СВОЙСТВУ, а не по вкусу. Выбран linger-пользователь: юниты создаются в СОБСТВЕННОМ менеджере платформы, новых привилегий в рантайме ноль. Polkit-вариант research/25 непригоден: polkit авторизует ГЛАГОЛ, а не свойстваStartTransientUnit отдаёт выбор ExecStart=/User= вызывающему, а транзиентный СИСТЕМНЫЙ юнит без User= идёт от root, то есть правило выдаёт сервису root и сузить это нечем. Плюс до systemd v257 в запрос авторизации не попадает даже ИМЯ юнита (systemd issue #17224 → PR #34651, влит 09.10.2024), так что на стенде (systemd 255, systemctl --version) «узкое правило» не узкое вовсе. Разбор — STACK_DECISIONS §15.

Замерено на стенде, не выведено из доки (все команды — systemd-run --user, стенд без root):

Свойство Как замерено Результат
Юнит переживает того, кто его создал спавнер-скрипт стартует юнит и выходит ActiveState=active, маркер success
--collect ⇒ состояние systemd не истина systemctl --user show после выхода LoadState=not-found, ExecMainStatus=0 для прогона, вышедшего с кодом 3
Маркер несёт исход ExecStopPost на трёх исходах exit-code/exited/3 · success/killed/TERM · oom-kill/killed/TERM
Лимиты в app.slice НЕ применяются cat app.slice/cgroup.subtree_control пусто; лист без memory.max; процесс, потрогавший 400 МиБ, пережил MemoryMax=64M
Лимиты в своём срезе применяются то же в tm-runs.slice memory.max=67108864, pids.max=32, тот же процесс — oom-kill, Memory peak: 64.0M
%-спецификаторы в --property= не раскрываются ExecStopPost с 100%_done/%n доехали буквально ⇒ экранировать % не нужно и было бы неверно
ProtectHome=yes прячет /run/user/<uid> проба под юнитом Permission denied ⇒ своя же шина недостижима; ProtectHome=tmpfs+BindPaths ⇒ сокет виден
Шине хватает DBUS_SESSION_BUS_ADDRESS env -u XDG_RUNTIME_DIR работает; без обоих — Failed to connect to bus

Ответ PD-13 живёт в cgroup ПРОГОНА и измерен. ⚠ И там же найдено собственным флейком: MemoryMax БЕЗ MemorySwapMax=0 не ограничивает прогон — ядро выдавливает страницы в своп (у среза swap peak: 692.5M), процесс выживает и тормозит до скорости диска. Для перевода это хуже отказа: тормозящий часами прогон продолжает платить за каждый прошедший вызов. Снято: MemorySwapMax=0 идёт вместе с MemoryMax (runner.go), три прогона пина подряд зелёные, три посадки «убрать своп-лимит» подряд красные.

Пиннинг версии (строка 139): run_attempts.engine_binary пишется ДО создания юнита, новая попытка его НАСЛЕДУЕТ, резюм исполняет именно его, а переход на другую сборку требует явного TM_PLATFORM_RESUME_MAY_CHANGE_ENGINE. ⚠ Первая редакция закрывала эту строку НАПОЛОВИНУ — путь читался каналом ремонта и не исполнялся резюмом; поймано моей же пост-сверкой диффа с промтом, не батареей (обе половины компилировались и всё было зелёным). PD-143.

Работа 2 — очередь River + воркер

go.mod: github.com/riverqueue/river v0.42.0 + riverdriver/riverpgxv5 (пин из STACK_DECISIONS подтверждён). internal/runs/queue.go.

Холд берётся ДО спавна и в ОДНОЙ транзакции с созданием прогона, попытки и записи очереди (pgstore.StartRun, runs.go:66): холд без прогона — деньги, зарезервированные ни за чем; прогон без холда — трата незарезервированного; запись очереди без обоих — воркер, спавнящий движок против книги, у которой нет бухгалтерии. Очередь вставляет своё задание через колбэк с той же pgx.Tx.

Гард «один живой прогон на книгу» — исполнением: частичный уникальный индекс runs_one_live_per_book мапится в доменную ErrRunInFlight (не сырой SQLSTATE), ручка отдаёт 409. Живая проба: второй POST .../runs на ту же книгу → 409, Reserved не сдвинулся.

MaxAttempts: 1 у задания — намеренно против рефлекса очередей: повтор здесь не доделывает работу, а спавнит ВТОРОЙ движок; восстановление — работа реконсилятора. Живая проба: повторная доставка задания на живой прогон не создала второго юнита (len(started) = 1).

Работа 3 — реконсилятор

internal/runs/reconcile.go. На КАЖДЫЙ выход юнита — через ExecStopPost-маркер, не D-Bus: сигнал, посланный в момент, когда платформа лежит (а она лежит несколько раз в неделю по замыслу), теряется; файл — нет. На БУТЕ тот же свип перезапускает прерванные прогоны (строка 138): истина — каталоги книг и Postgres, systemd спрашивается только «жив ли юнит», и это улика, не вердикт.

Разграничитель «не стартовал ещё» / «умер без слова» — время (spawnGrace 60 с). Перезапуск даёт новой попытке ОСТАТОК бюджета (budget RunSpent), иначе один прогон потратил бы потолок дважды; если остатка нет — paused/credit_exhausted, не failed.

Расчёт — из фигуры движка, прочитанной ПОСЛЕ выхода процесса (status --json, ратифицированный канал ремонта; лока нет, гадать не о чем). Не прочиталась — резервация остаётся ОТКРЫТОЙ и попадает в UnsettledRuns для следующего свипа. ⚠ Эскроу-половина research/25 (write-ahead intent, uncertain, closing) НЕ строилась: это строка 136 и её собственный промт.

Резюнк — честно редкий и честно грубый: по умолчанию 5 минут (TM_PLATFORM_RESYNC_EVERY), и только там, где чинить нечего — курсор не двигался ЛИБО материализация в карантине. Причина в цене: каждый вызов заново ингестит и режет исходник (1.41.5 с CPU на книге 23 МБ, строка 100).

Работа 4 — тейлер events.jsonl

internal/ingest/tail.go. Курсор (engine_run_id, seq) коммитится в ОДНОЙ транзакции с эффектом (pgstore.RunSink.Apply), байтовое смещение — хинт. Файла ещё нет (эмиттер = строка 103) — тейлер спокойно ждёт (ErrNoJournal не ошибка свипа).

PD-105 ратифицирован этим паком и реализован: дубль seqНОРМА, пропускается идемпотентно и без фатала; тот же seq с ДРУГИМ payload — ErrPayloadConflict → карантин ПРОЕКЦИИ (не прогона: движок тратит уже зарезервированные деньги, и наша неспособность читать его журнал — не повод это выбросить). Пропасть остаётся ошибкой. Строка PD-105 реестра обновлена.

Частичная последняя строка не читается как событие (писатель ещё пишет). Журнал пер-книжный и append-only, поэтому резюм дописывает ВТОРОЙ hello: читатель ведёт область и не судит чужие строки.

Работа 5 — ручки /v0 по контракту 0.2.0

internal/httpapi/v0.go: GET /books · GET /books/{bookId} · GET /books/{bookId}/run-options · POST /books/{bookId}/runs · GET /usage. Прогресс/статус прогона отдаётся внутри карточки книги — отдельной ручки прогона в спеке НЕТ, и выдумывать её я не стал.

CeilingBounds: максимум = Balance КАК ЕСТЬ (холд — дебет в момент взятия), подрезан И остатком книги; max_chapters: 0 легален. default_chapters = верх шкалы — продуктовая политика платформы (D39.115 §4), компромисс назван вслух в коде и вынесен вопросом ниже.

Интейк: дев-инструмент tmplatformctl book add (Go-подкоманда, а не шелл-скрипт: тестируется). POST /books (multipart) НЕ взят — обоснование строкой П-9 зонного бэклога.

Пересчёт «главы→доллары» — константа платформы $0.03, провенанс: exp08 v2 ($10.94 на 500 глав = $0.0219) через ревизию D30.4 (+1525% → $0.02520.0274), округление ВВЕРХ. Движковой поверхности оценки не выдумывал; потребность — строка П-10.

Работа 6 — регистр

Закрыты: PD-43 (денежный контур получил вызывающих) · PD-81 · PD-82 · PD-97 · PD-99 · PD-105. Заведены PD-108…PD-116 (из них PD-108/110/111/116 — дефекты, найденные и закрытые внутри этого же пака; PD-112…115 открыты вопросами). Строки эмиттер-стороны (PD-60/61) не тронуты.

Сквозная проба на боевом бинаре (не тестами)

Поднят tmplatformd против живого PG, с фейковым tmctl ($0, говорит ровно две поверхности: инвокацию с потолком и status --json).

Шаг Наблюдение
book add + GET /v0/books книга в библиотеке, status: not_started
GET run-options min 1 / max 333 / default 333 — $10 / $0.03 = 333, подрезано 500 главами книги
POST runs (100 глав) 202, форма Run по спеке; юнит tm-run-<id>-1.service active running
потолок, дошедший до движка --ceiling-usd 3.000000 = 100 × $0.03
второй POST на ту же книгу 409
/v0/usage при открытом холде remaining_percent: 70 (холд — дебет)
прогресс из журнала draft 4/10, edit 0/10 — пофазно, без затирания резюнком
выход юнита книга и прогон → ready, finished_at проставлен
расчёт грант $10 → движок отчитался $0.42 → баланс 9.580000 (не потолок $3)
гигиена логов grep -c 'ceiling-usd|3.000000|bk_' daemon.log = 0
systemd после --collect юнитов 0

Ратифицированное свойство D39.106 — прогон переживает деплой — проверено убийством платформы: pkill -9 по имени процесса → API отвечает 000, юнит active running, журнал за 6 с вырос 10 → 13 строк. Платформа поднята заново → карточка показала draft 21/300 (догнала всё, что было записано, пока её не было), через 6 с24/300, юнит ОДИН, в логе новой платформы grep -c 'run unit started' = 0 (второго движка не создано). Остановка через systemd → маркер success/killed/TERM → статус stopped (не failed).

Батарея и самопроверка

  • make check с TM_PLATFORM_TEST_DSN (живой PostgreSQL 18.4, -race): все 13 пакетов зелёные, линтер 0 issues. Прогнана дважды подряд.
  • Скипы: go test ./... -count=1 -v | grep -c -- '--- SKIP' = 0.
  • make vuln: No vulnerabilities found.
  • Дифф ^func Test ИСПОЛНЕНИЕМ (git grep '^func Test' HEAD -- 'platform/**/*.go' против того же грепа по дереву): 105 → 203, удалённых 0, добавленных 98. ⚠ Две мои промежуточные редакции этого счёта были неверны, и это записано, а не подчищено: сначала pathspec не тот, потом грep без *.go — второй ловил КОПИЮ теста, вставленную в этот же журнал приёмкой P1 (platform-PROGRESS.md:683), и рисовал ложное «удалён TestHoldAndSettleOnTheSameAttemptDoNotDeadlock». Тест на месте: credits_test.go:308.
  • Посадки (мутации): 12 из 12 в самопроверке; 139 в аудите ревью — 107 поймано, 32 пережили (8 не ослабления), по ним написано 22 новых пина, каждый проверен своей посадкой (сначала 9/10, затем 9/9 — две мои мутации были сформулированы мимо и переделаны). Итого посадок в паке — 33 моих, все пойманы, плюс аудит ревью., каждая восстанавливалась после прогона: холд вне транзакции допуска · мапинг runs_one_live_per_book · high-water mark в синке · greatest у spend · payload-конфликт · детект пропасти · частичная строка как событие · область чужого потока (обе стороны: mine := true и mine := false) · срез прогона · половина шкалы (та самая ошибка «минус Reserved») · грация спавна · потолок как failed · argv в INFO-логе · MemorySwapMax · compare-and-set права на спавн. ⚠ Одна посадка ПЕРЕЖИЛА первую редакцию пина и это записано, а не подчищено: high-water mark проверялся событием progress, а progress — присваивание, то есть идемпотентен сам по себе. Настоящий пин — считающий эффект (unit_done): TestARedeliveredCountingEventDoesNotCountTwice.

Девиации и то, чего не сделал

  1. 503 на старте прогона не входит в перечисленные спекой статусы операции — PD-112, вопрос владельцу контракта. Каждый разрешённый код в этой ситуации соврал бы.
  2. Стоп по потолку сегодня отдаётся как failed — PD-113, контрактно видимо. Движок возвращает потолок ошибкой (errReserveCeiling, сверено в HEAD), события потолка нет (строка 103). Ветка под событие построена и запинена; выдумывать порог «сколько процентов потолка = стоп» я не стал.
  3. POST /books не взят — П-9, с обоснованием. PD-72 закрывается вместе с ним.
  4. Эскроу/uncertain/closing не строились — строка 136, следующий денежный промт.
  5. Стоп/резюм прогона как ручки контракта не в списке работ промта — не строились; банк-стоп заканчивает попытку и прогон, резюм = новый прогон.
  6. Supervisor.Status дев-пути починен (PD-108) — он в моей зоне и это мой канал ремонта.

Адверсариальное ревью (author≠reviewer), и почему это главный результат пака

Четыре независимых верификатора, ни один не читал отчёт (его на тот момент не существовало): (1) промт+дифф вслепую · (2) охота за дефектами ВНЕ карты · (3) сверка провода с openapi 0.2.0 · (4) сила пинов посадками. Каждому запрещено писать в репозиторий; эксперименты — в копиях.

Нашли три ДЕНЕЖНЫЕ ошибки и три запирающие прогон. Все закрыты в этом дереве, каждая с пином.

Находка Чем это было Как закрыто
PD-124 (major) Расчёт брал committed_usd — ПОЖИЗНЕННУЮ сумму КНИГИ (SUM(committed_usd) … WHERE book_id) — как трату прогона. Второй прогон книги оплачивал первый заново; после того как сумма книги перерастает потолок, каждый прогон стоит ровно свой потолок. Воспроизвели ДВА верификатора независимо Попытка пишет базовую линию книги ДО старта (миграция 00010) и платит разницу; линию не прочитать = не стартуем
PD-125 (major) Перезапуск при отложенном расчёте брал ВТОРОЙ холд и терял первый навсегда: списки его не находили Перезапуск спрашивает AttemptReservationOpen; список несведённых ключуется на завершённости ПОПЫТКИ
PD-126 (major) Юнит, который не удалось создать, оставлял «право на спавн» — и каждый свип перезапускал прогон, съедая потолок по свипу за раз Неудавшийся Start снимает право; повторяется та же попытка
PD-127 (major) Одна нечитаемая строка журнала возвращала ошибку ДО чтения маркера: прогон навсегда translating с висящим холдом Любая ошибка журнала = карантин ПРОЕКЦИИ, жизненный цикл продолжается
PD-128 Грация спавна мерилась от старта ПРОГОНА ⇒ у перезапущенной попытки её не было Мерится от старта попытки
PD-129 Инверсия порядка блокировок booksruns между материализатором и финишером ⇒ взаимоблокировки Книга блокируется первой везде
PD-130/131 Любая ошибка чтения журнала выглядела как «догнали»; хендшейк не обязан был нести seq 1 Только настоящий EOF; seq != 1 = плохой хендшейк
PD-132 Холд прогона, который так и не стартовал, не возвращался Нет линии и нет юнита ⇒ холд возвращается целиком
PD-133/134/135 Инстанс без движка не отдавал даже библиотеку; пиннинг версии писался и не читался; прерванный прогон вставал paused без причины Чтения монтируются отдельно; EngineBinary читается и используется; PauseRun вместо FinishRun
PD-117…121 Потолок ниже минимума схемы отвечал 409 вместо 400 · Usage.paused_reason недостижим через путь реконсилятора · карточка несла ревизию ПРОГОНА (отстающую) · чужой курсор пагинации принимался · «exhausted» при остатке, на который прогон стартует Все пять закрыты, каждая с пином
PD-136/138 Деплой-юнит нёс обе диспозиции сразу; go.mod не тидинут Переписано; go mod tidy
PD-142 Аудит силы пинов: 139 посадок, 107 поймано, 32 пережили (8 из них — не ослабления: эквивалентный код или страховка DDL). Пережившие — не дефекты кода, а отсутствующие пины, часть на свойствах, объявленных закрытыми 22 новых пина, каждый проверен своей посадкой. Самые весомые: блокировка строки попытки под КОНКУРЕНЦИЕЙ (восемь горутин на одно событие — последовательная доставка посадку не ловила) и CSRF на контрактной поверхности (снятие переживало всё, а это старт платного прогона с амбиентной кукой)

Оставлено открытым и названо: PD-122 (Library.revision не монотонна при удалении книги — сегодня недостижимо, ручки удаления нет; правильное решение — свой счётчик области, это своя миграция и несколько путей записи, поэтому НЕ сделано, а объявлено гейтом) · PD-137 (упорядочение на пользовательский менеджер — UID site-specific, инструкция в шапке юнита) · PD-139 (пути книг в ERROR-логах — диспозицию надо принять осознанно) · PD-140 (Service.Stop не подключён: ручек стопа/резюма в скоупе промта не было) · PD-141 · PD-112/113/123.

Что ревью подтвердило, а не опровергло: привилегия-модель (с одним уточнением, принятым: «никакое правило не сузит» верно для ТРАНЗИЕНТНЫХ юнитов; шаблонный системный юнит с фиксированным User=, запускаемый StartUnit, — вариант, которого мой разбор не касался) · арифметика CeilingBounds (баланс КАК ЕСТЬ, без второго вычитания холдов — проверено на живом PG пятью раскладами) · отсутствие кросс-аккаунтных чтений и стартов · инвариант balance == SUM(ledger) (сломать не удалось) · правила тейлера по областям и курсору.

Возражение ревью, которое я принял только частично. Верификатор предложил различать стоп по потолку через ceiling_pct из status --json. Не взял: ceiling_pct считается против book_ceiling_usd, то есть против потолка ИЗ book.yaml, а наш потолок приходит аргументом прогона (строка 145), которого в HEAD нет — значит смысл поля зависит от ещё не залендённой формы. Взять его сейчас значило бы построить дискриминатор на поведении, которого я не могу проверить. Записано в PD-113 как названная и оценённая альтернатива.

Повторная сквозная проба на боевом бинаре после починок (чистая база, фейковый движок с НАКОПИТЕЛЬНЫМ счётчиком книги, как у настоящего): грант $10.00 → прогон 1 (движок насчитал $0.40) → баланс 9.600000 → прогон 2 на ТОЙ ЖЕ книге (счётчик книги $0.40 → $0.80) → баланс 9.200000, резервов ноль. До починки второй прогон списал бы $0.80.

Вопросы (канал вопросов промта; НЕ тихая интерпретация)

  1. 503 вне перечня спеки (PD-112) — вносить в спеку или назвать другой код?
  2. default_chapters = верх шкалы. Когда книга дороже баланса, верх шкалы резервирует ВЕСЬ баланс под одну книгу, и вторую начать нельзя (D39.110 §2в). Альтернатива — доля от максимума; какая именно — вопрос продуктовый, не инженерный.
  3. Конфигурация (вопрос ВЛАДЕЛЬЦА 08.08, PD-114). Ответ по существу: единого stdlib-пути в Go нет; мейнстрим — либо 12-factor «только окружение» (наш выбор, записан в доккомменте config.go, деплой уже использует systemd-нативные EnvironmentFile=+LoadCredential=), либо слоёная флаги > env > файл > дефолты (koanf/viper). Ниже нормы у нас другое: 27 переменных (грепнуто), ноль флагов у демона, нет печати эффективной конфигурации при старте. Вопрос владельца при этом нашёл реальный дефект в этом же паке: дефолт «$/глава» лежал в двух местах — исправлено, резолвится только в config.
  4. Абстрактный вопрос владельца, и он подтверждается фактом (PD-115). ENGINEERING_STANDARDS §2 называет внешнюю версионированную базовую линию ровно для ОДНОЙ оси — безопасности (ASVS 5.0 L2 + API Top-10 2023). Всё остальное — собственная проза зоны. Разница видна эмпирически: PD-57/PD-58 нашлись именно сверкой с RFC 9700/9207 и NIST SP 800-63B. Там, где эталона нет, сверять не с чем — грепнуто на 08.08: метрик и трейсинга ноль, процедуры бэкапа/восстановления в deploy/ нет, SLO не заданы. Предложение: §2 получает по эталону на ось; ратификация — оркестратора.

Ратификация приёмкой P3 (оркестратор №15, 08.08)

Вердикт: фикс-пак ПРИНЯТ и заленден. Восемь пунктов фикс-листа отработаны, девять строк регистра закрыты (PD-79 · PD-80 · PD-83 · PD-84 · PD-85 · PD-91 · PD-95 · PD-100 · PD-106). Приёмка по весу пака — точечные фиксы, не дизайн, — поэтому инлайн и своим исполнением, без панели.

Мои посадки — 8 из 8 поймано поимённо (в копии зоны вне репозитория; посадки ставились В СВОЙСТВО каждой закрытой строки, потому что предмет проверки здесь — именно карта пинов зоны): возврат null-литерала под снятие кавычек → money.TestSpendConvertsExactlyAndRoundsUp · одно ведро на две ручки → login.TestLoginCompletesAndCreatesOurOwnSession · очистка login-куки перед лимитером → TestARefusedCallbackKeepsTheLoginItRefused · сырой путь в RecoverTestPanicBecomesAProblemAndNamesTheRoute · снятие лимитера с колбэка → TestBothLegsOfSignInAreRateLimited · снятие EmailVerified с ветки ВОЗВРАЩАЮЩЕГОСЯ входа → pgstore.TestUnverifiedAddressStaysOffTheAccount · «после коммита можно отчитаться провалом» → TestACommittedWriteNeverReportsFailure · снятие обоих логов PD-100 → TestInfrastructureFailuresInTheCallbackAreLogged. ⚠ Первая редакция посадки PD-100 у меня была нацелена МИМО (регексп снял соседний лог, а не фикс) и «выжила»; точная посадка ловится — записано, потому что дисциплина «заявление=команда» действует и на приёмку.

Пере-прогнано мной: батарея офлайн и с живым PostgreSQL 18.4 под -race — все десять пакетов зелёные, включая новый cmd/tmplatformctl; линтер 0 issues; make vuln чист.

Пере-проверено исполнением, а не со слов: PD-80 на собранном бинаре — сорок запросов выжигают ведро /auth/login, после чего честный колбэк с живым state получает 400, а не 429, то есть бюджет завершения входа флудом начала больше не тратится (до фикса тот же сценарий давал 429 со стёртой login-кукой). PD-91 — замер зоны воспроизведён моими руками: systemd-run --user --wait -p ProtectSystem=strict -p ReadWritePaths=<несуществующий> даёт 226/NAMESPACE, тот же путь созданным даёт 0/SUCCESS. Способ проверки без sudo зона нашла сама, и это лучше, чем предполагала строка PD-91: свойства песочницы теперь ИЗМЕРЕНЫ, а не вычитаны; что НЕ проверено (юнит целиком, MemoryMax/OOMPolicy) названо на месте. PD-95 — доккоммент events.go теперь несёт ратифицированную форму D39.106 (events.jsonl каталога книги как outbox-проекция, тейл платформой).

Ратифицировано отдельно: PD-104 оставлен ОТКРЫТЫМ правильно. Зона починила половину док↔код (ячейка PD-30) и не закрыла строку, потому что продуктовая половина — ноль на бете, агрегатный потолок, счётчик аномалий — ждёт слова владельца. Это ровно то правило, за которое заведён PD-83 («свойство без пина закрытым не считается»), применённое к себе.

Оговорка зоны принята как честная: адверсариального ревью вторым читателем у P3 не было (сессия без права на субагентов). Второй читатель — эта приёмка; при следующем паке того же веса второй рубеж снова мой.

Сессия P3 (08.08): фикс-лист приёмки P2 отработан целиком

Что сделано: все восемь пунктов фикс-листа, в порядке приёмки. Каждый фикс сначала воспроизведён посадкой на копии зоны ВНЕ репозитория, потом починен, потом запинен тестом, который эту посадку ловит поимённо. 25 посадок — 25 поймано. Батарея: линтер 0 issues, все 10 пакетов зелёные офлайн и против живого PostgreSQL 18.4 (-race), make vuln чист.

Второй проход по собственному диффу (запрос владельца) снял четыре вещи и добавил четыре посадки. (1) money.UnmarshalJSON снимал кавычки strings.Trim — самодельный декодер строки JSON; заменён на encoding/json, и тогда добавленная мною ветка «пусто или null» оказалась лишней: обе формы и так ловит единственная проверка синтаксиса. (2) pgstore.ErrNoLoginState заведён мною алиасом на login.ErrNoState — два имени у одного значения против собственного прецедента зоны (auth.ErrNoSession возвращается напрямую); алиас удалён. (3) Два почти одинаковых теста лимитера слиты в табличный по двум ручкам, и вместе с ними ушёл бесхозный New(...) из третьего. (4) TestPanicBecomesAProblem оказался строгим подмножеством нового пина — свёрнуты в один с двумя случаями, включая «запрос, который не сматчил ни один паттерн».

Самое существенное из второго прохода — не стиль, а флейк: тесты лимитера строились на rate.NewLimiter(1, N), то есть на гонке с настенными часами — на медленной машине токен успевает восстановиться между опустошением ведра и проверкой, и пин зеленеет по неверной причине. Переведены на нулевую ставку: rate.NewLimiter(0, N) выдаёт свой burst и НЕ восстанавливается никогда (проверено отдельным прогоном на x/time v0.15.0, включая «сутки спустя»). Заодно ассерты усилены: «на 429 куку не трогают вовсе» вместо «не стирают», и уровень ERROR теперь часть сверяемой подстроки — посадка «уронить строку до DEBUG» её переживала.

Строка Что сделано Чем запинено
PD-80 (major, vuln) Два ведра вместо одного (startLimit/finishLimit) + проверка лимитера ПЕРЕД ClearLogin TestFloodingTheStartOfSignInDoesNotCloseTheEnd · TestARefusedCallbackKeepsTheLoginItRefused
PD-79 (деньги) Литерал null судится ДО снятия кавычек; строка "null" — отказ money.TestUnmarshalTellsTheNullLiteralFromTheWordNull · ingest.TestSpendRefusesNonsense
PD-83 / PD-84 / PD-85 Три пина на «закрыто, но не запинено» TestPanicBecomesAProblemAndNamesTheRoute · TestBothLegsOfSignInAreRateLimited · третий шаг в TestUnverifiedAddressStaysOffTheAccount
PD-100 Сбой стора и сбой discovery — в ERROR; на проводе прежний отказ. login.ErrNoState заведён у владельца интерфейса TestInfrastructureFailuresInTheCallbackAreLogged (3 случая, включая «обычное истечение НЕ авария»)
PD-106 Восемь тестов админ-CLI; несущее правило пинится через balanceReader TestACommittedWriteNeverReportsFailure и семь других
PD-91 Каталог создаётся явной командой, ProtectHome оставлен с названной ценой и выходом живой прогон systemd-run --user (не дока)
PD-95 Доккоммент пакета ingest переписан под D39.106 §2; Supervisor помечен дев-режимом — (док)
PD-104, только часть док↔код Ячейка PD-30 исправлена: «с нулём» относилось только к НЕподтверждённой личности — (док); строка ОСТАЁТСЯ открытой: продуктовая половина за владельцем

Что не трогали, как велено: PD-92 · PD-93 · PD-96 · PD-99 · PD-102 · PD-105 · PD-107 и все прочие открытые строки. Продуктовая часть PD-104 (ноль на бете, агрегатный потолок) ждёт владельца.

Три вещи, которые приёмке стоит перепроверить в первую очередь.

  1. Вторая копия снятого транспортного ответа в ЭТОМ журнале. Фикс-лист сказал «секцию журнала про stdout я уже пере-поставил — не трогай», и секция «Транспорт потока событий (вопрос зоны №4)» действительно помечена SUPERSEDED. Но блок «⚠ ОТВЕЧЕНО приёмкой (PD-59): переезд канала отклонён» в списке «Открытые вопросы после P1», п.4 — остался без пометки, а PD-59 сам снят D39.106 п.3. Это ровно тот текст, который эмиттер-сессия прочтёт как задание, то есть предмет PD-95. Текст оркестратора не переписан — над ним поставлен баннер SUPERSEDED с ратифицированной формой. Если это чужая зона правки — снимать баннер приёмке, не мне.
  2. Одна собственная посадка оказалась негодной, и это поймала мутация, а не чтение. Первая редакция теста разбора флагов CLI сверяла только «ошибка непуста» — а команда, прошедшая свои проверки, всё равно падала на соединении с несуществующей БД. Две посадки («grant без --usd идёт как ноль», «adjust больше не требует --note») её ПЕРЕЖИЛИ. Тест переписан на сверку сообщения; обе посадки теперь падают. Это форма ложно-зелёного прогона, которую стоит искать и в остальных моих тестах.
  3. PD-80 не закрывает отказ в обслуживании как класс. Раздельные вёдра убирают перенос исчерпания с одного конца входа на другой и делают отбитый колбэк восстановимым. Аноним по-прежнему может держать пустым КАЖДОЕ из двух вёдер по отдельности — это PD-42, принятый риском в форме «глобальный, не пер-адресный; место пер-адресного — edge». Ничего нового этой правкой не введено, но и «вход больше не выключается» — неверное чтение.

Оговорка о полноте. Код этой сессии никем, кроме автора, не отревьюен: адверсариальных ревью P3 не проводила — сессия шла без права на субагентов. Найденное фикс-листом закрыто и проверено исполнением; «дефектов больше нет» — утверждение, которого здесь нет.

Ратификация приёмкой P2 (оркестратор №15, 07.08)

Вердикт: P1 и P2 ПРИНЯТЫ и залендены — одним коммитом, 30 изменённых отслеживаемых файлов и 22 новых.Испр. оркестратором №15: раздел «Ратификация приёмкой P1» ниже говорит «P1 ПРИНЯТ и заленден» — заленден он НЕ был. До этого коммита git ls-files platform/internal/login возвращал ноль: в git уехали только P0 (eeeef89/954c034), направление (99c9cb0) и решения владельца (87be7b9/6469479). Строки регистра формулировку не завышали — они честно говорят fixed(P1, дерево сессии).

Уязвимости с потерей денег, данных или захватом сессии приёмка не нашла — и это ровно то, что фраза означает; отказ в обслуживании при этом ЕСТЬ (PD-80, класс vuln, воспроизведён на бинаре). Найденное — 29 строк регистра PD-79…PD-107. Счёт весов ПОСЛЕ эрраты (скриптом по колонкам): открытых 37 — 3 major (PD-80 доступность входа · PD-95 и PD-105 подняты эрратой как задание эмиттеру), 14 minor, 20 info. Лендинг не блокирует ничего. Блокируют дальнейшее ТРИ поимённо: PD-91 — первый реальный деплой (установка по наброску даёт нестартующий юнит) · PD-79 — постройку воркера (строковый "null" = ноль денег на пути расчёта) · PD-95 — выдачу промта эмиттера (доккоммент несёт снятую форму транспорта). Метод панели: семь линз с зажатыми промтами (отчётные доки зоны им запрещены), затем адверсариальный опровергатель на КАЖДУЮ находку весом minor и выше — 20 подтверждено, 2 опровергнуто, плюс мои собственные замеры.

Метод — исполнением, не чтением отчёта.

  • Батарея пере-прогнана мной: офлайн зелёная (линтер 0 issues); с живым PostgreSQL 18.4, поднятым без root по рецепту STACK_DECISIONS, — скипов НОЛЬ (26 БД-тестов отработали); make vuln (govulncheck v1.6.0) — чист.
  • Свои мутации по СВОЕЙ карте несущих свойств, не по таблице пинов зоны: 45 посадок в четыре батча — 33 поймано поимённо, 8 выжило, 4 моих посадки оказались негодными (разобраны ниже). Мутации ставились в КОПИИ зоны вне репозитория: незакоммиченное дерево сессии не трогалось, что сверено хешами диффа до и после.
  • Живой бинарь: healthz/readyz против живой БД · /v0/* → 401 problem+json · пять форм CSRF-пробы (кука без X-TM-Client 403 · с заголовком 401 · Sec-Fetch-Site: cross-site 403 · мусорный Bearer 401 · неразобранный Authorization 403) · редирект /auth/login с PKCE S256 и одноразовой кукой · ПТ-34-заголовки на каждом ответе.
  • PD-2 на том бинаре, который едет: 10 полу-кормленных POST отпущены на 30.0 с (в P0 держались, пока не уходил клиент).
  • RFC 9207 живьём: Google действительно шлёт iss (authorization_response_iss_parameter_supported: true, сверено мной у издателя); сорванный параметр → issuer_missing, чужой → issuer_mismatch, обмена кода в обоих случаях не было.
  • PD-71 пере-проверен своим прогоном на боевых данных (аккаунт с email = NULL + два аккаунта с общим подтверждённым адресом): DownTo(4) падает на users_email_key SQLSTATE 23505, три аккаунта целы, Up() возвращает схему на версию 8. Заявление зоны воспроизвелось дословно.
  • Фаззеры: FuzzSafeReturnTo 3.0 млн исполнений, FuzzDecoder 3.35 млн — крэшеров нет.
  • systemd-analyze verify (systemd 259, с подставленным существующим ExecStart=) — exit 0.
  • Цитаты норм сверены по первоисточникам, а не по пересказу: RFC 9700 §4.4.2 и §4.4.2.2, NIST SP 800-63B-4 §2.1.3, ASVS 5.0 7.1.1/7.1.2/7.1.3/7.6.1/7.6.2 — формулировки дословны, номера разделов верны, уровень L2 верен. Это несущая проверка: на этих цитатах стоит смена боевого значения 90 → 30 суток.
  • Границы зоны: backend/ не импортируется, SQLite движка не открывается, денежных величин в httpapi/auth/reqid нет; в дереве зоны только platform/* (чужое — живой полигон).

Ратифицировано (6 из 6).

  1. Абсолютный срок сессии 30 суток — ПРИНЯТО. Цифра — буква NIST SP 800-63B-4 §2.1.3 («A definite reauthentication overall timeout SHALL be established, which SHOULD be no more than 30 days at AAL1»), и у прежних 90 обоснования не было. ⚠ Видимое следствие продуктовое — вынесено владельцу (ниже).
  2. Против mix-up — iss авторизационного ответа (RFC 9207), миграция 00008 — ПРИНЯТО. Норма сверена: §4.4.2 включает требование со ВТОРОГО сервера, §4.4.2.2 объявляет раздельные redirect URI фолбэком («SHOULD therefore only be used if other options are not available»). Реализация проверена живьём, включая отказ на сорванном параметре.
  3. STACK_DECISIONS §13 (политика сессий) — ПРИНЯТО как документ соответствия ASVS 7.1.1/7.1.2/ 7.1.3. Рассогласование с федеративной сессией названо прямо — это и есть то, чего требует норма, а не то, что она запрещает.
  4. Ломающие изменения зоны — ПРИНЯТЫ: httpapi.NewServer без Timeouts (устранение КЛАССА PD-66 сильнее теста), Prober.PingReady, login.Fail без *http.Request. Внешних потребителей у этих подписей нет: фронт говорит с зоной по HTTP.
  5. MemoryMax=80% + явный OOMPolicy=continue — ПРИНЯТО. Аргумент сверен с systemd.resource-control(5) («last line of defense», OOM-killer внутри юнита) и systemd.service(5) (системный дефолт stop). ⚠ Под systemd не исполнялось — вывод из доки.
  6. PD-71 — ПРИНЯТ РИСКОМ в форме, которую предложила зона: правило append-only дороже доступности отката ниже версии 5, и место такой записи — deploy/README.md у оператора, а не сноска в архитектурном доке. Пере-проверено моим прогоном (см. выше).

Вынесено владельцу — два вопроса. (1) Сроки сессии: не заходивший месяц человек увидит экран входа; если это против замысла — одна переменная TM_PLATFORM_SESSION_MAX_AGE и явная запись отклонения в §13. (2) Новое, из PD-104: фри-тир печатается НЕАУТЕНТИФИЦИРОВАННЫМ потоком по $5 за каждую новую подтверждённую пару (provider, subject), и агрегатного потолка нет нигде — нужен ли суточный лимит грантов и счётчик аномалий до открытия беты.

Фикс-лист (порядок мой; строки регистра — носители).

  1. PD-80 — доступность входа. Единственная major. Ведро лимитера общее у /auth/login и /auth/callback, и 429 колбэка приходит уже ПОСЛЕ очистки login-куки: анонимный поток ~3 rps закрывает вход всем и добивает начатые входы. Воспроизведено мной на бинаре и независимо панелью. Фикс дешёвый: лимитер прежде очистки куки + раздельные ведра.
  2. PD-79 — деньги. Строковый "null" в committed_usd читается как ноль. Обязан быть закрыт ДО того, как появится вызывающий у Settle (то есть до воркера).
  3. PD-83/PD-84/PD-85 — «закрыто, но не запинено» по собственному правилу шапки регистра: половина PD-3, лимитер PD-29 и ветка обновления адреса.
  4. PD-91 — деплой. Установка по наброску даёт нестартующий юнит; и ProtectHome=yes против «книги в ~/books».
  5. PD-106 — админ-CLI, единственный писатель денег в дереве, не имеет ни одного теста — включая правило «после коммита нельзя отчитаться провалом», которое сам же называет несущим.
  6. PD-95 — переписать доккоммент events.go:6-12 под форму D39.106, и это ЕДИНСТВЕННОЕ, что тут осталось на зону. Ратифицированный транспорт: events.jsonl в каталоге книги как outbox-проекция коммитов SQLite, платформа его ТЕЙЛИТ, движок — транзиентный systemd-юнит и платформе не ребёнок; «родитель + пайп» и «выделенный fd» отвергнуты с нулём голосов (research/25 §Форма). Секцию журнала про stdout и строки PD-92/PD-105/PD-60 уже пере-поставил я — тебе их не трогать. Срок: ДО того, как владелец выдаст промт эмиттера, иначе та сессия прочтёт доккоммент как задание.
  7. PD-100 — колбэк глотает ошибку стора и ошибку discovery, репортя их обычным отказом. Это класс PD-5, который зона закрыла в auth/, но не в login/.
  8. Граница пака — жёсткая, это не «по весу строк». НЕ трогать: PD-92 · PD-93 · PD-96 · PD-99 · PD-102 · PD-105 · PD-107. Все они латентны за воркером и эмиттером, и правка сейчас означает угадывание формы, которую задаёт строка 103 единого бэклога. Остальные открытые строки реестра — тоже не в этом паке: он закрыт списком выше.

Опровергнуто приёмкой — включая свои промахи (дисциплина «заявление=команда» действует и на приёмку).

  • Панель: «удаление аккаунта падает на композитном FK даже при закрытых резервациях» — опровергнуто моим прогоном: delete from users проходит и без резервации, и с закрытой.
  • Панель: «money.UnmarshalJSON читает JSON null как ноль» — опровергнуто в этой форме: голый null даёт nil-указатель, защита работает; дыра — в строке "null" (PD-79, диагноз исправлен).
  • Панель: «WARN на каждый отбитый вход — неограниченная запись в лог» — опровергнуто: AccessLog и так пишет INFO-строку на КАЖДЫЙ запрос, так что нового канала WARN не создаёт.
  • Своя посадка «грант фри-тира не запинен» — НЕГОДНАЯ: грант живёт в ветке НОВОЙ личности, поэтому подмена его ключа на возвращающемся входе ничего не меняет. Корректная посадка (начислять на КАЖДОМ входе) ловится TestReturningIdentityKeepsItsAccountAndIsGrantedOnce — свойство запинено.
  • Своё «падение FuzzDecoder» — артефакт моего стенда (голод по CPU от параллельных батчей мутаций), не дефект: чистый прогон 3.35 млн исполнений зелёный.
  • PD-20 — калибровка, не находка: моя посадка «один сигнал вместо лестницы» выжила в одном прогоне, но дефект вероятностный (≈1 из 3 по замеру зоны), поэтому это свойство пина, а не новая дыра. Пин остаётся вероятностным — знать об этом важнее, чем завести строку.
  • TestMigrationsRollBackAndReapply откатывает ПУСТУЮ базу, поэтому для 00005 он не доказывает ничего (реальный откат невозможен по построению — PD-71). Норматив зоны «down-путь существует и гоняется тестом» выполнен буквой, но не смыслом; сказано здесь, чтобы это не читалось как покрытие.

Сессия P2: что изменилось в решениях

Очередь приёмки P1 отработана в её порядке. Каждый пункт сначала воспроизведён посадкой на своём стенде (PostgreSQL 18.4, Go 1.26.5) — включая те, где вердикт приёмки в итоге уточнён.

  1. ClearReadDeadline удалён, а не оставлен «страховкой». Приёмка проверила корректные запросы и заключила «код безвреден». На ПОЛУ-КОРМЛЕННОМ запросе он вреден: дренаж внутри записи заголовка — единственная граница соединения, снятие дедлайна до заголовка её убирает, и хендлер остаётся внутри WriteHeader через 4 с после ухода клиента (замерено). Случая, где функция помогает, нет: на корректном запросе net/http снимает дедлайн сам. PD-51 закрыт, PD-63 заведён.
  2. Абсолютный срок сессии 90 → 30 суток. ASVS 7.1.1 требует обосновать отклонение от NIST SP 800-63B; у 90 суток обоснования не нашлось (баланс тратимый, повторный вход у залогиненного в Google — один клик, прогон истечение сессии переживает). Обосновывать нечего — цифра выровнена по норме. Политика целиком (оба срока, одновременные сессии, рассогласование с федеративной) — STACK_DECISIONS §13.
  3. Против mix-up — iss авторизационного ответа (RFC 9207), решение принято до второго провайдера. Альтернатива «iss из ID-токена» при чистом code flow не работает: токен приходит после отдачи кода. Фолбэк «раздельные redirect URI» норма разрешает только когда другого нет, а Google RFC 9207 поддерживает (сверено живьём). auth_states.issuer + сверка до обмена кода + отказ на СОРВАННОМ параметре.
  4. MemoryMax — потолок машины, а не сервиса. По собственному аргументу зоны дети-tmctl живут в cgroup юнита, значит «2G на контрол-плейн» — это 2G на платформу и все прогоны вместе, а OOM-killer внутри юнита выберет движок с эксклюзивным локом. 80% + явный OOMPolicy=continue.
  5. Пин на СВОЙСТВО там, где слои перекрываются. У safeReturnTo четыре проверки, и снятие любой одной таблица входов переживала. Добавлены входы-различители плюс FuzzSafeReturnTo с НЕЗАВИСИМЫМ оракулом (ResolveReference против базового URL сайта) — 3,1 млн исполнений. Побочно установлено и записано в код: условия u.Scheme/u.Host/u.Opaque недостижимы как отказ, пин на них невозможен, оставлены бэкстопом.
  6. Состояние входа сравнивается структурой целиком. State.StartID не персистился — колонки не было, — и обе строки лога login_start_id в проде были пустыми, пока in-memory стор тестов показывал их заполненными. Тот же класс, что PD-46: свойство проверено не на том объекте. PD-62; теперь reflect.DeepEqual на всей структуре, следующее поле без колонки упадёт здесь же.

Что нашло собственное ревью P2 (author≠reviewer)

Пять ревьюверов по разным линзам, зажатые промты, журнал зоны и регистр от четырёх из пяти скрыты; каждая находка потом отдана адверсариальному верификатору с установкой «опровергни, по умолчанию считай неподтверждённой». 34 кандидата, 11 подтверждено, 23 опровергнуты с разбором. Из подтверждённых шесть потребовали правки кода:

  1. Обмен кода и JWKS шли без дедлайна (PD-65), хотя discovery рядом ограничивает себя пятью секундами. Набор ключей у go-oidc ОБЩИЙ — значит одна зависшая загрузка паркует все параллельные входы, а не только свой. Замерено верификатором на боевой проводке (httpClient = nil).
  2. Мой же фикс PD-46 закрывал половину и утверждал, что обе (PD-66). Тест смотрел на сервер, который сам и построил; проводка демона осталась ненаблюдаемой — подмена аргумента в main.go оставляла make check зелёным, а бинарь снова пиннил соединения. Закрыто устранением КЛАССА: NewServer больше не принимает Timeouts, передавать нечего.
  3. FOR UPDATE не был запинен, а комментарий утверждал обратное (PD-67). Последовательный тест лока не видит, а инвариант «кэш = леджер» тоже: без лока обе величины уезжают в минус ВМЕСТЕ.
  4. /readyz рапортовал «готов» на базе без схемы (PD-68) — то есть в нормальной середине выката, потому что миграция по инструкции отдельный шаг.
  5. Явный pool_max_conns из DSN молча отбрасывался (PD-69): сравнение с дефолтом pgx не отличает «оператор промолчал» от «оператор выбрал это же число».
  6. Скользящее окно бездействия для браузера не работало (PD-70, major). Серверная строка скользила, кука — нет: Max-Age пишется один раз, на входе. Человек, заходящий каждый день, выкидывался на 14-е сутки при живой сессии, а абсолютный срок не наступал никогда. Это делало ложным §13 — документ соответствия ASVS, написанный в этой же сессии двумя часами раньше.

Второе ревью: по коду, которым чинили первое (05.08)

Первое ревью смотрело дерево ДО своих же фиксов. Второй проход дан по ним — плюс отдельной линзой про воду. 42 кандидата, 22 подтверждено. Что из этого меняет решения:

  • Мой фикс PD-65 был неполон (PD-73). Дедлайн ограничивал ожидающего, а не саму загрузку ключей: Provider.Verifier берёт набор ключей, построенный на discovery, а go-oidc хранит его через context.WithoutCancel и ходит на http.DefaultClient. Зависшая загрузка держала вход и после выздоровления эндпоинта — до перезапуска процесса. Закрыто устранением класса: httpClient теперь никогда не nil, New ставит клиент с таймаутом, и его подхватывает NewProvider.
  • Скольжение окна залипало (PD-74, найдено двумя линзами независимо): Touch прижимает idle к абсолютному потолку, после чего условие «осталось меньше половины» истинно всегда — каждый запрос последней четверти жизни сессии становился записью в таблицу сессий и Set-Cookie.
  • CLI сообщал о применённом начислении как о провале (PD-75), а повтор без --key начислял второй раз — достаточно обрыва между записью и чтением баланса.
  • Утверждение «провайдерский токен не персистится» не могло упасть (PD-76): поле мока никто не заполнял. Это определяющее свойство пакета, названное первой строкой его доккоммента.
  • Штатная остановка прогона поднимала тревогу о сломанном синке (PD-77).
  • Четыре строки таблицы пинов обещали то, чего батарея не даёт. Две закрыты новыми тестами (гоночное потребление state; порядок блокировок), две — честной формулировкой: возврат общего лимита тела не наблюдаем до появления маршрута с бо́льшим потолком (PD-72), а вход «пароль содержит имя ключа» доказывает что-то только на машине, где наш дефолт не совпал с дефолтом pgxpool.
  • Свод воды (PD-78), каждый пункт проверен удалением: мемоизация mux в Routes молча игнорировала второй guard; шим httpapi.Fail существовал ради параметра, который никто не читал; upsertIdentityOnce дублировал inTx; Deps.APIPrefix — ручка без вызывателей; Ready делал два round trip на пробу; пять полей тестовых двойников писались и не читались.

Самопроверка после ревью (владелец 05.08: «нет ли велосипедов и о чём умолчал»)

Перечитывание СВОЕГО кода дало ещё пять правок, три из которых — дефекты, внесённые в этой же сессии и доехавшие бы до лендинга:

  • Кука при скольжении могла пережить абсолютный срок. Фикс PD-70 выдавал Max-Age = idle-TTL безусловно, поэтому сессия на 29-е сутки получала куку ещё на 14 — и каждый запрос после потолка становился 401 вместо чистого «вы вышли». Ровно то, из-за чего MaxAge изначально и брали по idle. Теперь min(idle, остаток абсолютного), случай запинен, посадка падает.
  • Фикс PD-68 ломал слой и тёк наружу. Ради строки «схема не накачена» в теле ответа httpapi импортировал pgstore — при том что Prober интерфейсом заведён именно чтобы этого не было, — а сама строка сообщала состояние выката на НЕаутентифицированной ручке. Причина уехала в ERROR-лог, импорт снят.
  • Два велосипеда. Разбор DSN руками (33 строки) — при том что pgxpool достаёт pool_* из RuntimeParams, и второй pgx.ParseConfig отвечает на вопрос точно, вместе с кавычками и service-файлами. И разбор номера миграции из имени файла — при том что есть goose.NumericComponent. Оба сняты, store.go короче на 44 строки.
  • latestMigration считался на КАЖДУЮ пробу готовности — величина времени сборки, теперь sync.OnceValues. Имя таблицы версий отдано goose.TableName(), а не захардкожено.
  • Ошибка разбора discovery больше не глотается. Флаг authorization_response_iss_parameter_supported с неверным типом молча выключал бы защиту от срыва параметра — теперь WARN.

Перепроверено, а не принято со слов: PD-71 (DownTo(4) падает на users_email_key, SQLSTATE 23505; Up() возвращает схему, все три аккаунта целы). Замеры ревью по PD-65 и PD-66 в регистре атрибутированы ревью — своими прогонами я их не воспроизводил.

Плюс PD-71 — принят риском: down-путь 00005 неисполним на данных, которые его же up-путь делает законными, поэтому откат ниже версии 5 недоступен. Править нельзя (append-only), поэтому записано оператору в deploy/README.md, а не спрятано.

Не трогали намеренно: PD-60 и PD-61 — обратное давление и сброс буфера эмиттера. Это свойства ШВА, назначать их платформе в одиночку нельзя: эмиттера нет, а выбор «блокировать движок или ронять события» меняет контракт. Идут строкой 103 единого бэклога вместе с транспортом (PD-59).

Замеры, которые не воспроизвелись: «41 взаимоблокировка на 300 раундов» (сессия P1) — остаётся со слов; действующий замер по PD-52 сделан заново. Замер приёмки «2 на 150» на этом стенде дал 510 на 150 — та же величина, другой стенд, поэтому в тест записан свой.

Приёмка P1: что изменилось в решениях

Пять независимых ревью (вход · деньги и SQL · стиль · логи · вне карты автора), четыре из пяти — исполнением. Находки — строками PD-24…PD-45 в регистре. Здесь только то, что поменяло РЕШЕНИЕ:

  1. Миграции append-only без исключений (было: «до первого деплоя правим на месте»). goose применяет по НОМЕРУ — ни имени, ни хеша: база на версии 3 приняла бы новый набор как применённый и не получила ни одной таблицы, DownTo на ней ломается навсегда. Гейт — migrations.sha256 + TestReleasedMigrationsAreUnchanged. Подробности в STACK_DECISIONS §8.
  2. Ключ идемпотентности леджера — (user_id, source, source_id), пустой ключ запрещён DDL. Ключ без аккаунта проглатывал грант, выданный другому.
  3. reservations.book_id — составной FK к books(id, owner_id) с RESTRICT. Каскад делал холд невозвратным, а освободившийся engine_run_id давал холд без списания. Владение книгой теперь проверяет база, а не вызывающий (это денежная форма API1 BOLA).
  4. lockBalance первым во всех денежных операциях — иначе Hold↔Settle дают взаимоблокировку. ⚠ Замер этой сессии «41 на 300 раундов» не воспроизвели ни приёмка, ни P2 — нагрузка не была описана; остаётся СО СЛОВ. Дефект при этом настоящий: воспроизведён независимо дважды, действующий замер — в PD-52.
  5. Расчёт capped потолком холда. Завышенное committed_usd уводило баланс в минус.
  6. Грант фри-тира — только подтверждённой личности (вопрос владельцу ниже).
  7. Имя провайдера — конфигурация, State.Provider сверяется в колбэке. Захардкоженное «google» при смене issuer кладёт чужие sub в старое пространство имён.
  8. Исход прогона читается из ProcessState, а не из ошибки Wait — иначе штатный SIGTERM помечает все идущие прогоны провалившимися.
  9. Лимит тела — пер-маршрутный, иначе загрузка книги не может поднять свой потолок.
  10. Просроченный дренаж — не отказ процесса (exit 0 + WARN): под Restart=on-failure штатная остановка читалась бы systemd как крах.

Отклонено: user_id в access-логе (предложение ревью логов). Норматив зоны запрещает id пользователей в логах; ответ на «кого задело» по дизайну живёт в login_events. Взят смежный вариант — исход аутентификации, он не PII.

Проверено и дефектов не дало: PKCE/nonce/одноразовость стейта под конкуренцией (6 колбэков с одним стейтом → 1 успех); провайдерские токены нигде не персистятся; параллельные первые входы (40 горутин → один аккаунт); инвариант balance == SUM(ledger); отсутствие секретов, PII и денег в логах.

Открытые вопросы после P1

Владельцу — оба ЗАКРЫТЫ приёмкой (05.08): грант только подтверждённой личности остаётся; два провайдера = два аккаунта на бете приемлемо, дефолт гранта $5. Правило гранта теперь и запинено — PD-48.

Новый вопрос владельцу, один — из PD-58. Абсолютный срок сессии снижен с 90 до 30 суток: это цифра NIST SP 800-63B-4 для AAL1, а обоснования у 90 не нашлось (на аккаунте тратимый баланс, повторный вход у уже залогиненного в Google — один клик, а идущий ПРОГОН истечение сессии переживает — он серверный процесс). Видимое следствие: человек, не заходивший месяц, увидит экран входа. Если это против замысла — это одна переменная TM_PLATFORM_SESSION_MAX_AGE и строка в STACK_DECISIONS §13, но тогда отклонение от нормы придётся записать туда явно.

Оркестратору — четыре.

  1. Спека не знает про /auth/*. Ручки входа (GET /auth/login, GET /auth/callback, POST /auth/logout, POST /auth/logout-all) живут ВНЕ версионного префикса, как /healthz: это не контрактная поверхность, а механика сессии. Если фронт должен на них ссылаться — нужна строка в спеке или в компаньоне. Наше предложение: описать их в компаньоне, в openapi.yaml не тащить.

  2. Требование X-TM-Client (пинг P0) всё ещё не в спеке. Повторяем: реализовано, значение любое, несущей является ПРИСУТСТВИЕ заголовка на небезопасных запросах cookie-пути.

  3. Форма ответа GET /v0/usage изменилась вместе с моделью денег (баланс вместо окон), а спека этого ещё не отражает. Ручку НЕ строили намеренно — контракт первичен. Предлагаемая форма в разделе «Что предлагаем в спеку» ниже.

  4. Транспорт потока событий: увести с stdout на выделенный дескриптор или сокет. Просьба завести это строкой к 103 единого бэклога, пока эмиттера нет — потом правка станет миграцией.

    ⚠⚠ ВОПРОС И ОТВЕТ НИЖЕ SUPERSEDED — D39.106 (05.08). Обе стороны спора сняты: канал не остаётся на stdout и не переезжает на fd/сокет — движок платформе вообще НЕ ребёнок. Форма: транзиентный systemd-юнит на прогон, events.jsonl в каталоге книги как outbox-проекция коммитов SQLite, платформа тейлит с курсором (engine_run_id, seq). «Родитель + пайп» — 0 голосов из 15, выделенный fd 3 — 0, stdout/journald как источник — 0 (research/25 §Форма). Пайп-путь supervisor.go = дев-режим. Живой носитель формы — доккоммент пакета internal/ingest (PD-95). Абзацы ниже оставлены как история спора; заданием они не являются. ⚠ Баннер поставлен сессией P3 08.08: эррата №15 пере-ставила ДРУГУЮ секцию (ниже, «Транспорт потока событий (вопрос зоны №4)»), эта копия ответа осталась без пометки.

    ОТВЕЧЕНО приёмкой (PD-59): диагноз принят, переезд канала отклонён. Мотив прецедентов (dpkg/gpg/systemd) к нам не переносится — там stdout занят, у нас платформа даёт движку выделенный пайп. ExtraFiles задокументирован четырьмя строками, цена ошибки замерена. Риск закрывается guard'ом в источнике. Триггеры пересмотра названы в разделе «Ратификация приёмкой P1», подраздел про транспорт.

    Формат менять НЕ предлагаем: NDJSON с версионным хендшейком в stdout — индустриальная норма для «долгая команда сообщает прогресс машине» (clig.dev: машиночитаемое — в stdout, сообщения — в stderr; так же go test -json, cargo --message-format, terraform -json, docker jsonmessage). Речь только о канале.

    Причина: stdout — общий ресурс процесса. Один fmt.Println в движке или в его зависимости ломает протокол, и сегодня от этого защищает правило в research/23 §2, а не механизм. Индустрия этот же вывод сделала: hashicorp/go-plugin (Terraform, Vault, Nomad, Packer) печатает в stdout ОДНУ строку хендшейка 1|3|unix|/path/to/socket|grpc и дальше уходит на unix-сокет; runc и containerd получают канал через --console-socket.

    Предлагаемая форма для нас: платформа передаёт путь/дескриптор в argv, движок пишет поток туда, stdout остаётся человеку. Полный RPC (go-plugin/gRPC) сейчас НЕ нужен — управление однонаправленное: старт argv, стоп сигналом, досинхронизация status --json. Триггеры, при которых он окупится, называем заранее: пауза/продолжение без убийства процесса · поднятие потолка на живом прогоне · подпись банка в работающий процесс вместо рестарта · управление потоком. Два таких требования — и переезд оправдан, причём механический: хендшейк уже есть.

Открытые вопросы к владельцу/оркестратору (P0, историческое)

Решения владельца 05.08 (закрыли всё, что висело по деньгам): оплаты нет и в бете не будет — пробные аккаунты на фри-тире, ключи предоплачены владельцем · модель лимита = БАЛАНС кредитов, а не окна с обнулением («не подписки, а покупка токенов как у OpenRouter») ⇒ resets_at/usage_windows в подписочной форме отменены, вопрос авто-резюме после сброса окон отпал вместе с окнами · фри-тир = грант в леджер из админки, дефолт $5, настраиваемый. ⚠ Мой вывод «покупателям из России платить нечем» СНЯТ как необоснованный: он был выведен из целевого языка перевода, а не установлен. Резервация решена оркестратором (вариант B — холд + пер-книжный потолок движку, жёсткий стоп исполняет движок). Разбор — PLATFORM_DIRECTION.md §2.

  1. Оркестратору (контракт). Поток событий привязан к ПРОГОНУ (/runs/{runId}/events), а статусы uploading/parsing существуют ДО прогона, и библиотека охватывает книги без прогонов. Живого канала у них нет вовсе. Нужна либо строка в спеке «до старта прогона состояние опрашивается», либо пользовательский поток (он же закрыл бы К-12 пушем). Решение — не наше.
  2. Оркестратору (спека). Третий слой CSRF из STACK §5 требует от браузерного клиента заголовок X-TM-Client на небезопасных запросах cookie-пути. Это требование к ФРОНТУ, и его место — в описании sessionCookie в спеке. Реализовано и проверено тестами.

Решения сессии P1 (аргументация)

Политика коллизии почты

Почта не является ключом ни в какой форме. Ключ личности — (provider, subject). users.email стала NULLABLE и потеряла уникальный индекс; неизвестная пара (provider, subject) ВСЕГДА создаёт новый аккаунт, какой бы адрес с ней ни пришёл. Присоединение второго провайдера к существующему аккаунту — отдельное аутентифицированное действие (его ещё нет), никогда не побочный эффект входа. Адрес аккаунта обновляется только из ПОДТВЕРЖДЁННОГО; неподтверждённый остаётся на личности и наверх не поднимается.

Почему не «связывать по подтверждённой почте». Связывание по адресу — это классический путь захвата аккаунта, и email_verified его не закрывает: адрес может смениться владельцем (Google предупреждает об этом прямым текстом), корпоративный домен может выдать освободившийся ящик другому сотруднику, а провайдер может пометить verified то, что он верифицировал по своим правилам, а не по нашим. Цена нашего решения — два аккаунта у одного человека при входе разными провайдерами. Это ДУБЛИКАТ: человек его видит, оператор может слить. Цена альтернативы — чужой аккаунт достаётся тому, кто получил адрес. Дубликат чинится, захват — нет.

Пин: TestIdentityNeverJoinsAccountsByEmail — два субъекта с одним адресом и третий с другим провайдером обязаны дать ТРИ аккаунта.

⚠ Побочное следствие для денег — вопрос владельцу выше (грант на аккаунт, аккаунтов может быть два).

Форма админ-поверхности — CLI, не защищённая ручка

tmplatformctl grant|balance|logins|revoke. HTTP-ручке ради четырёх операций понадобилась бы вторая модель авторизации: роли, их хранение, эскалация, отзыв админской сессии, отдельный CSRF-режим — и каждая из этих вещей может быть сделана неправильно. Граница доверия для этих операций уже есть и обеспечена машиной: чтобы выполнить их, нужен шелл на VM и доступ к DSN. Браузерная панель, если понадобится, обернёт ровно те же вызовы стора.

Идемпотентность у гранта — ОПТ-ИН через --key: ключ по умолчанию «аккаунт+дата» схлопывал два законных гранта одного дня, и второй рапортовал успех, ничего не начислив. Без ключа каждый вызов самостоятелен, а CLI печатает «применилось» или «ключ уже потрачен» по факту.

Форма журнала входов

Таблица login_events: время, провайдер, исход (success|denied), причина отказа, ПРЕФИКС адреса (/24 для IPv4, /48 для IPv6) и КЛАСС клиента (browser|desktop|other). Ни полного адреса, ни user-agent: журнал отвечает на вопрос «откуда примерно и чем», который человек и оператор реально задают, и не превращается в собственную базу слежки. Отказавшийся вход пишется без user_id — попытка была, аккаунта у неё нет. Ручка «отозвать все сессии» — POST /auth/logout-all, она же tmplatformctl revoke. ⚠ Ретенции у журнала пока нет — строка PD-23.

Что предлагаем в спеку (S3)

GET /v0/usage в кредитной модели — БЕЗ сумм и без resets_at:

Usage:
  required: [state, remaining_percent]
  properties:
    state:             { enum: [ok, low, exhausted] }   # low — порог показа предупреждения
    remaining_percent: { type: integer, minimum: 0, maximum: 100 }  # от последнего гранта
    paused_reason:     { enum: [credit_exhausted], nullable: true } # почему стоит прогон

Процент считается от суммы грантов аккаунта, а не от «лимита периода»: периодов больше нет. Run.paused_reason — то же значение на прогоне (колонка в схеме заводится вместе с ручкой).

Ратификация приёмкой P1 (оркестратор №14, 05.08)

Вердикт: P1 ПРИНЯТ и заленден.Испр. оркестратором №15: слово «заленден» здесь неверно — код P1 в git не уезжал, он ушёл туда вместе с P2 при приёмке 07.08 (раздел «Ратификация приёмкой P2» выше). Живой уязвимости приёмка не нашла. Найденное делится на три кучки: незапиненные свойства (строка регистра говорит «закрыто», посадка её переживает), неверные формулировки в доках и два несоответствия внешней норме — PD-57 (mix-up: реализована не та контрмера, которую требует RFC 9700 §2.1) и PD-58 (ASVS 5.0 L2 требует ДОКУМЕНТИРОВАТЬ сроки сессий; они существуют только литералами в коде). По правилу, которое сессия сама применила к PD-1 («свойство без пинящего теста закрытым не считается»), строки PD-30, PD-32, PD-37, PD-26 к своим фиксам не привязаны — заведены заново как PD-46…PD-59.

Метод. Батарея пере-прогнана мной: офлайн зелёная (0 issues линтера), с живым PostgreSQL 18.4 — скипов ноль, make vuln чист. Собственная посадка 43 мутаций (не по следам отчёта: по СВОЕЙ карте свойств несущего пути) — 31 поймана поимённо, 9 пережили, 3 не собрались; дерево после каждой восстановлено, побайтовая сверка с бэкапом в конце — совпадение. Плюс шесть собственных тестов-проб против живой БД и четыре живые пробы на собранном бинаре. Механику см. регистр.

Сверка с индустриальной нормой — отдельным проходом, по первоисточникам (её отсутствие владелец поймал на первой редакции этого раздела; тогда решения были проверены только ВНУТРИ репозитория — механика goose, схема, поведение net/httpа против внешних норм не сверялись):

Норма Что требует Как у нас
OIDC Core 1.0 §5.7 / §2 Стабильный идентификатор — только пара (iss, sub); email, phone_number, preferred_username MUST NOT использоваться как идентификатор (издатель вправе переиспользовать адрес между людьми) решение «почта не ключ» — не наше изобретение, а буква нормы. ⚠ ключуем по НАШЕМУ имени провайдера, не по iss; причина названа в коде (смена URL издателя не осиротит аккаунты) — сознательное отклонение
RFC 9700 §2.1.1 (BCP, янв. 2025) PKCE; nonce как альтернатива для OIDC-клиентов и то, и другое, реально проверяется настоящим издателем в тесте
RFC 9700 §2.1 Одноразовый state против CSRF; точное сравнение redirect URI одноразовость в БД одним DELETE … RETURNING + привязка к куке браузера
RFC 9700 §2.1 + RFC 9207 Против mix-up: SHOULD — iss из авторизационного ответа; MAY — раздельные redirect URI не выполнено — реализована собственная сверка, которая внутри одного хендлера сравнивает конфигурацию с собой: PD-57
ASVS 5.0 V7 · 7.2.3, 7.2.4, 7.4.1, 7.4.2 (L1) ≥128 бит энтропии; новый токен на аутентификации со сносом прежнего; отзыв прекращает использование; снос всех сессий при удалении аккаунта все четыре, 256 бит при требуемых 128, ротация запинена тестом
ASVS 5.0 V7 · 7.1.1, 7.1.2, 7.1.3/7.6.1 (L2) Сроки бездействия и абсолютный ДОКУМЕНТИРОВАНЫ с обоснованием отклонений от NIST SP 800-63B; политика одновременных сессий; согласование с федеративной сессией не выполнено — 14 суток/90 суток живут литералами в config.go, обоснования нет нигде: PD-58. Это несоответствие линии, которую зона объявила себе сама (ENGINEERING_STANDARDS §2)
Миграции Flyway/Liquibase хранят контрольные суммы и падают на расхождении; goose хранит только номер migrations.sha256 — не самодеятельность, а восполнение того, что другие инструменты дают из коробки
Деньги Резерв→захват (hold/capture) — стандарт платёжной механики форма стандартная. Леджер знаковый однозаписный с кэшем баланса вместо двойной записи — упрощение, оправданное отсутствием продаж; инвариант balance == SUM(ledger) его страхует

Подтверждено ИСПОЛНЕНИЕМ (не чтением отчёта):

  • PD-2 действительно закрыт на том бинаре, который едет. 25 полу-кормленных POST → сервер отпустил все 25 через 29.1 с (в P0 держал, пока не уходил клиент). ⚠ Но защита от повторного открытия стоит только на проводке — PD-46.
  • PD-25 в своей полной форме: один ключ идемпотентности на двух аккаунтах — оба применяются; повторный Hold после DeleteBook (когда строку резервации унесло) даёт ErrDuplicateHold, баланс не двигается, резервация не остаётся. Собственный тест.
  • PD-26 воспроизведён независимо — 2 взаимоблокировки на 150 раундов с инвертированным порядком, 0 с фиксом. Фикс несущий; ⚠ замер сессии «41 на 300» не воспроизведён (см. PD-52).
  • PD-27 держит и на переполнении: Settle с 1<<62 списывает ровно холд.
  • PD-24: манифест закрывает все три случая — правку, новый нелистанный файл и удаление.
  • PD-34: штатная остановка даёт exit 0; второй SIGTERM убивает (обработчик снят).
  • CSRF-слой живьём: кука без X-TM-Client → 403; с заголовком → 401; Sec-Fetch-Site: cross-site → 403; Origin: evil → 403; кука + мусорный Bearer → 401 (привилегии не даёт, PD-50).
  • Админ-CLI живьём: грант, повтор ключа (no-op), корректировка, balance == SUM(ledger), отказы на отрицательном гранте, корректировке без причины и неизвестном аккаунте.
  • 200 конкурентных денежных операций — 0 ошибок, кэш не разъехался с леджером.

Опровергнуто исполнением: обоснование ClearReadDeadline и строка «Поток переживает read-дедлайн» в таблице пинов — PD-51. net/http снимает read-дедлайн сам, до хендлера; тест не может упасть от выхолащивания функции. Код безвреден, ложны обоснование и пин.

Ратифицировано (4 из 4):

  1. Миграции append-only без исключений — ПРИНЯТО. Аргумент проверен: goose_db_version держит только номер. STACK_DECISIONS §8 — норма зоны.
  2. Почта не ключ ни в какой форме — ПРИНЯТО, и это прямо буква нормы, а не наш вкус: OIDC Core §5.7 — стабильный идентификатор только (iss, sub), email использовать как идентификатор MUST NOT, потому что издатель вправе переиспользовать адрес между людьми. Цена (дубль аккаунта у человека с двумя провайдерами) названа честно и меньше альтернативы (захват аккаунта по унаследованному адресу). ⚠ Отклонение: ключуем по НАШЕМУ имени провайдера, не по iss; причина в коде названа и принимается.
  3. Админ-поверхность — CLI — ПРИНЯТО. ⚠ Владельцу сказано прямо: «админка» сегодня = команда в шелле на машине, не веб-страница. Для беты этого достаточно; браузерная панель обернёт те же вызовы стора.
  4. sqlc (PD-44) — направление ИЗМЕНЕНО, а не долг. Проверено исполнением: sqlc v1.31.1 читает все goose-миграции зоны (на момент пробы семь) и генерирует под pgx/v5 код, почти совпадающий с рукописным (:execrowsRowsAffected). Две трения: он отвергает запрос, который Postgres принимает (неквалифицированный user_id в коррелированных подзапросах), и типизует колонки как pgtype/int64 — то есть money.MicroUSD на границе теряется без блока overrides. Решение: денежный пакет НЕ переписывать — он только что отревьюен и имеет батарею против живой БД, а обмен отревьюенного кода на сгенерированный без единого нового теста ничего не покупает. sqlc берётся на поверхность контрактных ручек/read-model (П-1), где запросов много и они меняются, — там ловится именно тот класс, ради которого он нужен (запрос ссылается на колонку, которую унесла миграция). Правка внесена в PLATFORM_DIRECTION.md §3.

Вопросы владельца — ответы даны, оба «да» с одной поправкой. Грант только подтверждённой личности остаётся (для Google это все настоящие аккаунты, а дыру саморегистрации закрывает); два провайдера = два аккаунта на бете приемлемо, дефолт гранта $5. ⚠ Само правило гранта тестом не защищено — PD-48, чинить независимо от ответа.

Регрессионный тест к PD-52 (написан приёмкой, воспроизводит цикл; вставить в internal/pgstore/, хелперы testDB/seedUser/exec уже есть):

// PD-26 в форме, которая действительно даёт цикл: Hold, переиспользующий attempt id, ждёт строку
// резервации по первичному ключу, уже держа лок баланса, а конкурентный Settle той же резервации
// держит строку и хочет баланс. С lockBalance первым в ОБОИХ цикл не складывается.
// Замерено приёмкой: с инверсией 2 взаимоблокировки на 150 раундов, с фиксом — 0.
func TestHoldAndSettleOnTheSameAttemptDoNotDeadlock(t *testing.T) {
	s, ctx := testDB(t)
	seedUser(t, s, ctx, "u1")
	exec(t, s, ctx, `insert into books (id, owner_id, title, source_lang, target_lang, status, workdir, engine_book_id)
	                 values ('bk1','u1','x','zh','ru','not_started','/srv/books/bk1','x')`)
	now := time.Now().UTC()
	if _, err := s.Grant(ctx, "u1", 100000*money.PerUSD, "admin", "g", "", now); err != nil {
		t.Fatal(err)
	}
	var mu sync.Mutex
	var deadlocks int
	note := func(err error) {
		if err != nil && strings.Contains(err.Error(), "deadlock") {
			mu.Lock()
			deadlocks++
			mu.Unlock()
		}
	}
	for i := range 150 {
		id := fmt.Sprintf("run-%d", i)
		if err := s.Hold(ctx, "u1", "bk1", id, money.PerUSD, now); err != nil {
			t.Fatal(err)
		}
		var wg sync.WaitGroup
		wg.Add(2)
		go func() { defer wg.Done(); note(s.Settle(ctx, id, money.PerUSD/2, now)) }()
		go func() { defer wg.Done(); note(s.Hold(ctx, "u1", "bk1", id, money.PerUSD, now)) }()
		wg.Wait()
	}
	a, err := s.ReadAccount(ctx, "u1")
	if err != nil {
		t.Fatal(err)
	}
	if a.Balance != a.LedgerSum {
		t.Fatalf("кэш разъехался с леджером: %s против %s", a.Balance.USD(), a.LedgerSum.USD())
	}
	if deadlocks > 0 {
		t.Fatalf("%d взаимоблокировок на 150 раундов", deadlocks)
	}
}

Что зона обязана сделать до следующего лендинга (порядок — мой). Все восемь отработаны в P2 в этом порядке; расхождения с формулировкой пунктов 5 и 7 — в разделе «Сессия P2» выше.

  1. PD-46 — тест на ЗНАЧЕНИЯ DefaultTimeouts(). Это буквально та дыра, в которой PD-2 прожил P0.
  2. PD-58 — обосновать 14 суток/90 суток письменно (ASVS 7.1.1 требует именно документа, а не значения), заодно 7.1.2 и 7.1.3. Самый дешёвый пункт списка и единственное несоответствие базовой линии, которую зона объявила себе сама.
  3. PD-48, PD-49, PD-47 — три пина к трём «закрытым» строкам регистра.
  4. PD-52 — регрессионный тест порядка блокировок (готовый выше).
  5. PD-51 — привести §12 и таблицу пинов к тому, что делает net/http; решить судьбу ClearReadDeadline (оставить как страховку — законно, но с честным комментарием).
  6. PD-54 — снять устаревшую подписочную форму /v0/usage из журнала, пока S3 её не прочитал.
  7. PD-55 — MemoryMax/TasksMax в юните: либо потолок для платформы отдельно от детей (Slice=/отдельный юнит), либо честный комментарий, что потолок общий на прогоны.
  8. PD-57 — решение по mix-up принять ЯВНО и до второго провайдера: чтение iss авторизационного ответа (норма) либо раздельные redirect URI (допустимая альтернатива).

Что НЕ блокирует лендинг, но блокирует первый реальный деплой: PD-58 (без документа сроков зона не соответствует линии, которую сама объявила), PD-55 (потолок памяти общий на платформу и все прогоны — OOM выберет движок с эксклюзивным локом). PD-57 — до второго провайдера.

Спека (мои решения как владельца контракта): /auth/* — в компаньон, в openapi.yaml не тащим (согласен: механика сессии, не контрактная поверхность). X-TM-Client и кредитная форма GET /v0/usage — уже в списке правок промта S3, отдельного действия зоне не нужно.

⚠⚠ ВСЯ СЕКЦИЯ НИЖЕ SUPERSEDED — D39.106 (05.08), эррата приёмки №15 от 07.08. Её вывод «канал остаётся stdout» снят решением того же дня: движок — транзиентный systemd-юнит на прогон, платформа ему НЕ родитель, события — events.jsonl в каталоге книги (append-only, outbox-проекция уже закоммиченных строк SQLite в той же транзакции, что чекпойнт), платформа тейлит журнал с курсором (engine_run_id, seq), коммитимым вместе с эффектом. В research/25 вариант «платформа — родитель + пайп (stdout/fd)» получил 0 голосов из 15; выделенный fd 3 — 0; stdout/journald как источник событий — 0. D39.106 п.3 дословно: «PD-59 superseded; пайп-путь supervisor.go P1 = дев-режим; ответ PD-13 переезжает на cgroup юнита прогона». Читать текст ниже как историю аргументации, не как действующее решение; носители работы — строка 103 единого бэклога (эмиттер) и строки 138/139 (реконсилятор · пиннинг версии). Приёмка №15 этот supersede при лендинге пропустила и в первой редакции PD-95 сама сослалась на снятый PD-59 — исправлено эрратой; SIGPIPE-замер зоны остаётся верным фактом, но он больше не несущий, потому что родителя у движка в проде нет.

Транспорт потока событий (вопрос зоны №4): диагноз ПРИНЯТ, переезд канала ОТКЛОНЁН. PD-59, PD-60, PD-61.

Этот пункт переписывался четырежды. Три первые редакции спорили о том, чьи прецеденты лучше, — это не инженерный аргумент. Четвёртая получена методом владельца: задача сформулирована АБСТРАКТНО (два Go-сервиса, родитель читает NDJSON у ребёнка, никакого контекста репозитория и никакого намёка на мою позицию) и отдана двум независимым чистым агентам — одному с доступом в сеть, другому только со своими знаниями. По самому каналу они разошлись (сетевой — переезжать, офлайновый — остаться), и именно поэтому упражнение оказалось полезным: ценность не в их вердикте, а в том, на чём они сошлись НЕЗАВИСИМО и чего не было ни в записке зоны, ни в трёх моих редакциях.

Сошлись на трёх вещах, и первая решает вопрос.

  1. SIGPIPE зависит от НОМЕРА дескриптора. os/signal: обрыв пайпа на fd 1 или 2 убивает программу сигналом; на любом другом дескрипторе запись просто возвращает EPIPE. Замерено мной, не со слов: поток на fd 1 — ребёнок убит broken pipe; на fd 3 — write вернул EPIPE и процесс спокойно доработал до конца. Для нас это не сноска, а деньги: сегодня падение платформы убивает движок на следующей же записи события; после переезда движок стал бы сиротой и часами жёг бы оплаченные вызовы, пока холд висит в леджере и закрыть его некому. То есть stdout даёт нам бесплатную остановку сироты, а предложенный переезд её ЛОМАЕТ. Свойство несущее и до сих пор нигде не записано.
  2. Настоящая защита — не выбор канала, а перехват на уровне дескриптора: dup(1) в приватный fd, затем dup3(2,1,0), в main движка. Он работает при ЛЮБОМ канале и герметичен там, где предложенный мной ранее os.Stdout = os.Stderr дыряв: переживает var out = os.Stdout, захваченный зависимостью, cgo и унаследованный fd 1 у внуков. Направлять fd 1 в /dev/null не надо — пусть посторонние записи видны в человеческом логе прогона.
  3. Дискриминатор, при котором переезд был бы прав: ребёнок исполняет чужой код, наследующий stdio (хуки, плагины, шелл-аут). Это ровно мотив dpkg --status-fd. Проверено: у нас нет — в backend/ нет ни одного exec.Command вне тестов и нет cgo.

Отсюда и вывод: обсуждали не тот вопрос. Канал остаётся stdout — теперь не «потому что переезд не окупается», а потому что переезд активно ухудшает поведение при падении платформы.

Диагноз зоны верен и не оспаривается. stdout — общий ресурс процесса; один посторонний Println в движке или в его зависимости ломает протокол, и защищает от этого правило в research/23 §2, а не механизм.

Прецеденты зоны не держат, но сильные существуют — и их мотив к нам не переносится. hashicorp/go-plugin уходит на сокет ради ДВУНАПРАВЛЕННОГО RPC (хендшейк он как раз держит в stdout); runc --console-socket — про передачу ДЕСКРИПТОРА pty через SCM_RIGHTS. Канонические прецеденты паттерна — другие: dpkg --status-fd n («Send machine-readable package status and progress information to file descriptor n», man dpkg(1)), gpg --status-fd, systemd NOTIFY_SOCKET. Но во всех трёх stdout ЗАНЯТ полезной нагрузкой — выводом операции, шифротекстом, выводом сервиса, — и статус выселяют потому, что ему негде жить. У нас платформа даёт tmctl выделенный пайп через cmd.StdoutPipe(); на этот stdout не претендует никто. Мотива нет.

Что говорит дока Go о качестве такого решения: ничего. os/exec.ExtraFiles — четыре строки доккоммента, из качества ровно одно: «not supported on Windows». Жизненный цикл пайпа на вызывающем. Цена ошибки замерена: не закрыл родительскую копию пишущего конца после Start() — EOF не приходит НИКОГДА, читатель висит на давно завершённом прогоне (StdoutPipe закрывает сам). Идиоматичный Go для «родитель читает поток ребёнка» — StdoutPipe, а ExtraFiles — нишевый механизм socket-activation и контейнерной обвязки.

Изъян нашёлся и в фолбэке, который приёмка предлагала ранее. Безусловный os.Stdout = os.Stderr в main движка сломал бы tmctl status --json: ре-синк читает именно stdout (supervisor.go:145, cmd.Output()). Guard обязан жить в области ТОЛЬКО потоковой команды.

Решение: канал не меняем. Переезд покупает защиту от класса, который в нашем процессе почти пуст (cgo в движке нет, детей на потоковом пути он не спавнит), а стоит: изменение CLI движка — то есть запрос через шов, — ручной жизненный цикл пайпа с измеренным режимом вечного зависания, Windows вне игры и неидиоматичный для Go паттерн. По норме владельца «механизм строится только там, где несёт качество/деньги, — не ради галочки» это механизм ради галочки.

Строка 103 единого бэклога заводится так:

  • канал остаётся stdout — из-за SIGPIPE-семантики, которая нам служит;
  • защита — перехват на уровне дескриптора в main движка, в области потоковой команды (безусловный вариант сломал бы tmctl status --json: ре-синк читает stdout, supervisor.go:145);
  • вместе с эмиттером задаются сброс буфера на всех путях выхода и политика обратного давления — PD-61 и PD-60; оба свойства дешевле назначить до постройки, чем мигрировать после;
  • переезд на --events-fd пересматривается по названным заранее триггерам: появление cgo в движке, спавн им собственных детей на потоковом пути, реальный инцидент порчи потока. Если когда-нибудь понадобится «платформа перезапустилась, прогон продолжается» — ответ не сокет, а журнал файлом с чекпойнтом смещения (PD-61).

Побочно упражнение проверило нас: ловушку bufio.Scanner (переполнение строки читается как чистый EOF, поток молча обрывается) оба агента назвали самым вероятным латентным багом такой системы — у нас она закрыта, Buffer поднят до 1 МиБ и sc.Err() проверяется (decoder.go:45,115).

Ратификация приёмкой (оркестратор №14, 04.08)

Вердикт: P0 ПРИНЯТ, заленден eeeef89. Метод: батарея пере-прогнана мной (офлайн зелёная; с живым PostgreSQL 18.4, поднятым без root, все три БД-гейченных теста зелёные — 6/6 констрейнтов сработали поимённо) · живые пробы бинаря (healthz 200 без БД · readyz 503 честно · Bearer без стора → 401, не паника · неизвестный путь под /v0 → 401 раньше 404 · CSRF: cookie-POST без X-TM-Client 403, cross-site 403, Bearer-POST не требует заголовка · SIGTERM → «shutting down» и чистый выход) · три СВОИ мутации в несущие свойства (см. ниже) · адверсариальный воркфлоу пяти линз со скептик-пассом (линзы анти-следовые: мнение до чтения отчёта, поиск вне его карты).

Что подтверждено исполнением: зонная дисциплина (в коммите только platform/*, чужого нет) · модуль-sibling, backend/internal не импортируется, go.work не заведён · живой SQLite движка нигде не открывается · event-sourcing не построен (high-water mark) · деньги отсутствуют на проводе и в INFO-логах (stderr движка уходит в ФАЙЛ попытки — верно) · ПТ-34-заголовки мидлварью на всём, не дисциплиной хендлера · имена полей status --json сверены 1:1 с pipeline/status.go:37-130 · экзит-коды сверены с cmd/tmctl/main.go:30-52 · языко-агностичность (пар-литералов нет) · пин линтера идентичен движковому.

Мои мутации (author≠reviewer): (1) Digest возвращает плейнтекст вместо SHA-256 — тесты ВЫЖИЛИ ⇒ свойство «в БД только хеш» истинно, но НЕ запинено (тест сверяет через ту же функцию); строка PD-1. (2) Снятие требования X-TM-Client — тест упал поимённо ✓. (3) Ослабление констрейнта units_translated_has_text до check (true) — тест упал поимённо ✓. Дерево после мутаций восстановлено байт-в-байт (sha256-сверка).

Главная находка приёмки — PD-2, ЖИВАЯ уязвимость, а не латентная. Отсутствие ReadTimeout позволяет пиннить соединения СЕГОДНЯ, без единой body-принимающей ручки: net/http дренирует непрочитанное тело <256 КБ внутри chunkWriter.writeHeader ДО отправки заголовка ответа, и это чтение наследует отсутствующий дедлайн. Репродуцировано мной на собранном бинаре (50 полу-кормленных POST: сервер залогировал 50×401 ms:0, клиенты получили ноль байт, fd 7→57 до закрытия КЛИЕНТОМ); скептик независимо пинил 500. Фикс — одна строка; гейт: закрыть первым шагом P1.

Ратификация дизайн-ответов — все четыре ПРИНЯТЫ, каждый с обязательной поправкой:

  • К-4 (ревизия пер-книжная, кадр SSE = books.revision) — ПРИНЯТ + три поправки. (а) Посылка «единственный писатель» неверна: HTTP-хендлеры тоже пишут книго-скоупное состояние (подпись банка обязана вернуть бампнутую ревизию). Нормативный механизм — не «единственность писателя», а блокировка строки книги, удерживаемая до коммита (update books set revision = revision + 1 сериализует и бамп, и порядок коммитов); материализатор и хендлеры обязаны ходить через неё. (б) Одна транзакция = одна ревизия, но НЕСКОЛЬКО кадров: докачка по Last-Event-ID обязана читать revision >= R (дельта-чтения идемпотентны), иначе теряются кадры-братья транзакции R. (в) Дельта- чтение не выражает УДАЛЕНИЯ (банк переписывается целиком, пере-чанковка заменяет главы/юниты) ⇒ после любой транзакции-замены сервер обязан выдать resync_required (событие в контракте есть).
  • К-7 (keyset-курсор на всех списках, next_cursor всегда) — ПРИНЯТ + две поправки. (а) Курсор НЕ привязывать к books.revision: тот бампается на каждой материализации, и на книге 5000+ глав правило «сменилась ревизия — начни цикл заново» даёт вечный рестарт пагинации во время прогона. Привязка — к СТРУКТУРНОЙ эпохе (поколение манифеста / books.chunker_version), которая меняется только при (пере-)разборе. (б) Отклонение протухшего курсора — обязанность СЕРВЕРА (MUST), не клиента: курсор непрозрачен, клиент не может её исполнить. Ключи сортировки для банка и замечаний назвать при правке спеки (для глав — (book_id, number)).
  • К-12 (опрос с Retry-After, 202+Location) — ПРИНЯТ. Аргумент структурный и верен: поток привязан к прогону, экспорт делают с законченной книги. Поправка формы: Retry-After на 200 стандартом не определён (RFC 9110 — 503 и 3xx), поэтому в спеке объявить его ЯВНЫМ заголовком этого ответа, а не полагаться на общую семантику.
  • П-5 (GET /v0/usage статусом, Run.paused_reason, потолок поднимает платформа) — ПРИНЯТ + три поправки. Несущий клейм ПЕРЕПРОВЕРЕН мной по коду и подтверждён: Ceilings объявлены (backend/internal/config/book.go:106), в канон BriefHash НЕ входят (:280-297, доккоммент :264 «wiring fields … deliberately excluded»), и ни один другой хеш их не сворачивает (снапшот волны pipeline/snapshot.go, кортеж чекпойнта stagerun.go:406-412) ⇒ поднятие потолка не двигает снапшот и не вызывает ре-билл. Поправки: (а) Run.paused_reason не имеет колонки — завести в схеме (собственный мандат сессии: у каждого поля ответа есть источник); (б) revision в ответе usage не имеет скоупа — назвать счётчик пользователя либо убрать поле; (в) формулировка «поток денег не несёт и НЕ ДОЛЖЕН» подана как следствие D39.84 — это не так: D39.84 запрещает суммы на ПОЛЬЗОВАТЕЛЬСКОМ проводе, экране и в INFO-логах, а внутренний поток движок→платформа в приватную таблицу — другая поверхность. Вопрос «нести ли версионированное денежное событие в словаре строки 103» ОТКРЫТ (см. ниже), а не закрыт.
  • Три находки сессии подтверждены и маршрутизированы: движковый run_id в кадре hello (иначе resume отбрасывается high-water mark'ом) — в строку 103 единого бэклога; статусы до прогона и библиотека без живого канала — правка спеки (S3); X-TM-Client в спеку — туда же, с уточнением «значение любое, несущей является ПРИСУТСТВИЕ заголовка».

Открытый канон-вопрос, поднятый приёмкой (нужно слово владельца/решение оркестратора): у платформы нет санкционированного источника денег ВО ВРЕМЯ попытки. Движок держит эксклюзивный лок (status --json физически недоступен), его stderr-INFO с ценами парсить запрещено (анти-паттерн research/23 §2 + запрет INFO-денег), а словарь строки 103 денег не несёт. Следствие: пер-пользовательские окна отстают на целую попытку (часы), и единственный он-лайн-гард — пер-книжные потолки, которые платформа обязана ставить консервативно. Варианты — версионированное денежное событие в словаре 103 · консервативная политика потолков · нарезка попыток — в ресёрч-пакете направления «биллинг».

Дизайн-ответы на ратификацию

К-4 — ревизия: пер-ресурсная со скоупом КНИГА; чтения её несут

Обе половины вопроса:

  1. Чтения ревизию несут — да. Без неё правило «отбрось чтение старше уже применённого события» нечем реализовать, а рефетч по возврату фокуса окна включён у react-query по умолчанию — то есть гонка на каждое переключение вкладки, а не редкий случай.
  2. Счётчик — один на КНИГУ. Все книго-скоупные чтения (карточка, главы, юниты, замечания, банк, прогон) возвращают ОДНО и то же число — books.revision, +1 за материализующую транзакцию; строки, которых она коснулась, штампуются новым значением. id SSE-кадра прогона — ТО ЖЕ число, поэтому события и чтения книги полностью упорядочены между собой.

Почему книга, а не сквозной счётчик:

  • сравнивать ревизии осмысленно только внутри скоупа, а книга — минимальный скоуп, в котором лежит всё, что поток может протухнуть;
  • единственный писатель на книгу уже гарантирован (сериализация очереди по book_id — П-3, плюс EXCLUSIVE-лок движка на файл проекта), поэтому счётчику не нужны ни блокировка, ни глобальная последовательность;
  • глобальный счётчик отвергнут по двум причинам: одна горячая последовательность на всех пользователей и утечка — по разрывам номеров любой клиент оценивает активность всей платформы;
  • у библиотеки (GET /books) свой скоуп — счётчик на пользователя (users.library_revision), потому что она охватывает книги.

Просим внести в спеку прозой: (i) revision монотонна В ПРЕДЕЛАХ скоупа ресурса и между скоупами не сравнивается; (ii) кадр потока и книго-скоупные чтения несут ОДИН счётчик; (iii) отбрасывание устаревшего чтения — обязанность клиента.

Побочная выгода: тот же штамп даёт докачку потока «строки книги с revision > X» БЕЗ журнала событий — реплей истории остаётся запрещённым (D39.85, контракт §2.11).

К-7 — курсор на каждом списке, дефолт «одна страница»

Замер (сериализация фикстур контрактной формы, случайные значения — не повторяющиеся, иначе gzip льстит): 2284 главы = 289 КБ JSON / 46 КБ gzip; 1200 терминов = 229 КБ / 40 КБ. Одним ответом влезает — но китайские вебновеллы на 5000+ глав норма, а банк растёт вместе с книгой, поэтому «всегда одним ответом» — это отложенное молчаливое обрезание.

Предложение: keyset-курсор на КАЖДОМ списочном ответе, параметры ?limit=&cursor=, поле next_cursor: string|null присутствует ВСЕГДА. Дефолты: главы 5000 (обычная книга = одна страница), банк 1000, замечания 500; юниты и библиотека курсор тоже несут, хотя практически не пагинируются.

  • Keyset, не offset: материализатор пишет параллельно чтению, а offset на пишущейся таблице пропускает и дублирует строки; keyset по (book_id, number) устойчив к дозаписи.
  • Поле с первого дня у всех списков — намеренно. Добавить его позже — минорное изменение, которое у клиента, его не читающего, молча отрезает хвост.
  • Курсор непрозрачный, кодирует последний ключ сортировки и revision; смена revision между страницами обязывает клиента начать цикл заново, иначе он склеит два состояния.

К-12 — опрос; причина структурная, а не вкусовая

Единственный поток контракта привязан к ПРОГОНУ, а экспорт делают с законченной книги — живого прогона обычно нет. Пуш завершения потребовал бы второго потока ради одного булева.

Предложение — индустриальный async request-reply: POST /books/{id}/exports202 + Location; GET /books/{id}/exports/{id}200 c ready:false и заголовком Retry-After, пока строится, и ready:true + url, когда готов. Интервал называет СЕРВЕР, клиент не угадывает. Просим добавить Retry-After в спеку. Появится пользовательский поток (вопрос 2 выше) — пуш поедет им, опрос останется фолбэком.

П-5 — форма API лимитов/использования

Подписочная форма ответа УДАЛЕНА отсюда (PD-54, закрыт в P2). Она несла окна, resets_at и usage_windows и отменена решением владельца 05.08 «не подписки, а баланс»; таблицу usage_windows снесла миграция 00006. Баннера было мало: S3 идёт в журнал ЗА ФОРМОЙ ручки и скопировал бы тело, а не баннер. Действующая форма одна — раздел «Что предлагаем в спеку (S3)» выше. Ниже осталось то, что от модели денег не зависит.

  • Сумм нет ни в каком виде. Процент — статус использования, а не деньги (D39.84 в силе, механика «как Claude Code» — D39.100/ПТ-35).
  • Стоп по потолку: BookStatus: paused + машинная причина, Run.paused_reason: фразу («перевод остановлен: кредит исчерпан») рисует клиент словами владельца (В-3), API несёт состояние. Без поля причины второй повод для паузы станет ломающим изменением.
  • Источник цифр. Поток событий денег не несёт и не должен (кадр ceiling — только факт), поэтому платформа метрит из tmctl status --json (committed_usd) на границах попыток и на ре-синке; хранит целыми микро-долларами в леджере (credit_ledger, миграция 00007).

АБЗАЦ НИЖЕ SUPERSEDED — D39.110 п.2(б) (07.08). «Платформа владеет book.yaml и правит ceilings.book_usd» отменено: потолок принадлежит ПРОГОНУ и едет аргументом прогона (движку это ещё предстоит уметь — строка 145 единого бэклога), а не постоянной записью в конфиге книги, иначе пользовательское число оседает в данных движка против D39.81/D39.85. И это НЕ «политика платформы, не кнопка на экране»: владелец 07.08 решил обратное — управляемая шкала В ИНТЕРФЕЙСЕ, от минимума до доступного остатка, в ГЛАВАХ (строка 126). Верным в абзаце остаётся только проверенный факт: Ceilings в BriefHash не входит, поэтому смена потолка не двигает снапшот и не вызывает ре-билл. ⚠ Помечено при повторной верификации: эррата 07.08 применила правило «грепать зонный вывод на supersede» к транспорту и пропустила его здесь же, на потолке.

  • Поднятие потолка — политика платформы, не кнопка на экране. Платформа сама владеет book.yaml, поднимает ceilings.book_usd и перезапускает прогон. Проверено кодом, что это безопасно: Ceilings объявлен в backend/internal/config/book.go:106, а в канон BriefHash (:280-297) НЕ входит — значит поднятие потолка не двигает brief_hash → снапшот и не вызывает ни дрифт, ни ре-билл. Риск «подняли лимит — переплатили книгу заново» снят фактом, не надеждой.

Что построено (P1)

Кусок Где Проверено ИСПОЛНЕНИЕМ
Конструкция сервера вынесена из main internal/httpapi/serve.go Тесты гоняют РЕАЛЬНЫЙ http.Server на loopback-порту; без этого PD-2 и PD-9 не видит ни один тест на mux под httptest
ReadTimeout + LimitBody (PD-2) serve.go, middleware.go Живая проба на бинаре: полу-кормленный POST отпускается на ReadTimeout (30.0 с)
Поток переживает ReadTimeout serve.go (без вспомогательной функции) net/http снимает дедлайн сам; помощник ClearReadDeadline УДАЛЁН — на полу-кормленном запросе он воспроизводил PD-2 (PD-63)
Дренаж по SIGTERM (PD-9) serve.go Тест: ctx-aware хендлер в полёте доигрывает и отдаёт 200
Вход через OIDC (П-6) internal/login/ Полный флоу против НАСТОЯЩЕГО OIDC-издателя, поднятого в тесте: discovery, JWKS, RS256-подпись, реальная проверка PKCE на токен-эндпоинте. 6 негативных сценариев (чужой nonce, чужая audience, протухший токен, подмена state, отсутствие куки, реплей)
Модель аккаунта migrations/00001, pgstore/identity.go Живой PG: три личности с одним адресом дают три аккаунта; неподтверждённый адрес не поднимается на аккаунт; state одноразовый и истекает
Кредитный леджер (П-7) migrations/00007_credits.sql, pgstore/credits.go Живой PG: инвариант balance == SUM(ledger) после каждого шага grant→hold→settle→release; повторный ключ — no-op; холд сверх баланса, чужая книга и повтор attempt-id отказаны
Деньги как тип internal/money/ big.Rat, округление к +∞, синтаксис ограничен регуляркой и длиной (big.Rat иначе принимает 0x10 и 1/3)
Админ-CLI (П-8) cmd/tmplatformctl/ Живая проба против живой БД: гранты, баланс с открытыми холдами, журнал входов, отзыв сессий
Фаззинг декодера internal/ingest/fuzz_test.go Оракулы — инварианты PD-10; make fuzz для углублённого прогона
Остановка прогона (PD-12/13/20) internal/ingest/ Тест с настоящим процессом: сбой синка завершает прогон; сигнал повторяется до подтверждения
Деплой-юнит (PD-13) deploy/tmplatformd.service systemd-analyze verify — exit 0. ⚠ Под systemd не запускался (нет sudo)

Какой тест что пинит (мандат приёмки §3.3)

Свойство несущего пути Пинящий тест Посадка, которую он ловит
В БД только SHA-256 токена pgstore.TestStoredCredentialIsAHashNotTheToken Digest возвращает плейнтекст (посадка приёмки P0 — теперь падает)
Соединение нельзя запиннить httpapi.TestHalfFedRequestIsDroppedByTheServer + TestTheServerTheDaemonRunsHasEveryDeadlineSet Снять любой дедлайн из serverWithTimeouts; обнулить DefaultTimeouts().Read или .Idle; добавить WriteTimeout (PD-46). Проводка демона больше не проверяется, а СДЕЛАНА невозможной: NewServer не принимает Timeouts (PD-66)
Поток переживает Read без действий хендлера httpapi.TestStreamOutlivesReadTimeout Снять Unwrap (тогда Flush не дотягивается до соединения — проверяется ошибка Flush, а не игнорируется)
Полу-кормленный СТРИМИНГОВЫЙ запрос всё равно отпускается httpapi.TestHalfFedStreamingRequestIsCutLoose Снять ReadTimeout; вернуть снятие дедлайна в хендлер (PD-63)
SIGTERM дренирует, а не рубит httpapi.TestShutdownDrainsInFlightRequests BaseContext = сигнальный ctx
Idle-истёкшая сессия не воскресает pgstore.TestTouchCannotResurrectAnIdleExpiredSession Убрать клаузу idle_expires_at из Touch
Поток идентифицирован и монотонен ingest.TestHandshakeMustIdentifyTheStream + FuzzDecoder Пустой engine_run_id, seq хендшейка ≠ 1, hello в середине
Деньги не дрейфуют ingest.TestSpendConvertsExactlyAndRoundsUp, money.TestParseUSDIsExactAndRoundsAwayFromZero float64 + умножение; округление к ближайшему
Баланс = сумма леджера pgstore.TestCreditLifecycleKeepsTheCacheEqualToTheLedger Писать кэш вне транзакции леджера
Повторный грант не кредитует дважды pgstore.TestGrantIsIdempotentBySource Снять on conflict / вынести обновление баланса из ветки «вставилось»
Холд защищает баланс pgstore.TestHoldRefusesMoreThanTheBalance Убрать проверку баланса. ⚠ for update этим тестом НЕ ловится (последовательный тест лока не видит) — он запинен строкой ниже
Почта не связывает аккаунты pgstore.TestIdentityNeverJoinsAccountsByEmail Резолв аккаунта по адресу; уникальный индекс на users.email
Вход даёт НАШУ сессию и убивает прежнюю login.TestLoginCompletesAndCreatesOurOwnSession, TestLoginRevokesThePresentedSession Не отзывать предъявленную сессию (фиксация сессии)
PKCE и nonce реально проверяются login.TestLoginCompletesAndCreatesOurOwnSession, TestCallbackRefusals Снять S256ChallengeOption; не сравнивать nonce
return_to не уводит с сайта login.TestReturnToNeverLeavesThisSite + FuzzSafeReturnTo Ослабить до HasPrefix("/"); снять второй декод; снять класс символов; снять protocol-relative. Фаззер судит независимым оракулом — ResolveReference против базового URL сайта (PD-47)
State одноразовый под КОНКУРЕНЦИЕЙ pgstore.TestOnlyOneRacingCallbackCanConsumeAState (+ последовательные login.TestStateCannotBeReplayed, pgstore.TestLoginStateIsSingleUseAndExpires) Разбить DELETE ... RETURNING на SELECT и DELETE — последовательные тесты этого не видят, гоночный ловит (3 колбэка из 4 съедали один state)
Прогон не переживает свой синк ingest.TestFailingSinkStopsTheRun Убрать stop() после сбоя Ingest; убрать повтор сигнала
Request-id не берётся у клиента reqid.TestRequestIDIsNeverTakenFromTheCaller Читать X-Request-Id из запроса
Секреты можно подать файлом config.TestSecretsCanComeFromFiles Читать только переменную окружения
Грант только подтверждённой личности login.TestSignupGrantGoesOnlyToAVerifiedIdentity Убрать условие EmailVerified (PD-48)
State не redeem-ится у другого провайдера login.TestStateFromAnotherProviderIsRefused Убрать сверку st.Provider (PD-49)
iss авторизационного ответа проверяется login.TestAuthorizationResponseIssuerIsChecked Убрать вызов checkIssuer или любую из его двух веток. ⚠ Потерю issuer в СТОРЕ ловит не он, а pgstore.TestLoginStateIsSingleUseAndExpires (сравнение структурой) — атрибуция важна ровно по причине PD-46
Состояние входа переживает стор ЦЕЛИКОМ pgstore.TestLoginStateIsSingleUseAndExpires Потерять любое поле login.State при записи или чтении — сравнение структурой, а не тремя полями (PD-62)
Порядок блокировок один во всех денежных путях pgstore.TestHoldAndSettleOnTheSameAttemptDoNotDeadlock Убрать lockBalance из closeReservation — 5 падений из 5 (PD-52)
Кука не участвует, если есть Authorization auth.TestAnAuthorizationHeaderTakesTheCookieOutOfPlay Падать обратно на куку при неразобранном заголовке (PD-50)
Лимит тела: вложение только УЖЕСТОЧАЕТ httpapi.TestBodyCapIsPerRouteBecauseNestingOnlyTightens, TestDefaultBodyCapStaysAContractSizedNumber Раздуть дефолт до аплоуд-размера. ⚠ Возврат ОБЩЕГО внешнего слоя в New не ловится ничем: наблюдаемым он станет только когда появится маршрут со своим бо́льшим потолком — до тех пор это открытая строка PD-72, а не обещание
Один ответ на несуществующий аккаунт pgstore.TestMoneyOperationsAgreeOnAMissingAccount Убрать мапинг констрейнта в ErrNoAccount (PD-56)
Сроки сессий в пределах объявленной линии config.TestSessionClocksStayWithinTheDeclaredBaseline Поднять абсолютный срок выше 30 суток NIST AAL1 (PD-58)
Зависший издатель не держит колбэк login.TestAStalledProviderDoesNotHoldTheCallback Убрать дедлайн из identifyобе ноги, token и keys (PD-65)
Кука браузера скользит вместе со строкой auth.TestSlidingTheIdleWindowRefreshesTheBrowsersCookie Не переиздавать куку при скольжении; переиздавать её на Bearer-пути (PD-70)
Два прогона не тратят один кредит pgstore.TestConcurrentHoldsCannotOvercommitAnAccount Убрать for update из lockBalance — 3 падения из 3, баланс в $2 (PD-67)
Готовность = схема, а не достижимость pgstore.TestReadinessRefusesADatabaseWithoutTheSchema Свести Ready к Ping (PD-68)
Клиент go-oidc ограничен по времени login.TestTheDefaultProviderClientIsBounded, TestAHungKeyFetchDoesNotPoisonLaterSignIns Отдать New клиент без таймаута — тогда зависшая загрузка ключей держит и все последующие входы (PD-73)
Скольжение не залипает на потолке auth.TestTheSlideStopsOnceItCannotMoveTheDeadline Убрать сверку IdleExpiresAt.Before(AbsoluteExpiresAt) — каждый запрос последней четверти жизни сессии становится записью (PD-74)
Провайдерский токен не доезжает до стора login.TestLoginCompletesAndCreatesOurOwnSession Записать в стор что-либо выданное провайдером; ⚠ до P2 эта проверка не могла упасть — поле мока никто не заполнял (PD-76)
Размеры пула из DSN переживают pgstore.TestExplicitPoolSizesInTheDSNSurvive Вернуть сравнение с дефолтом pgx. ⚠ Случай «пароль содержит имя ключа» ловит поиск подстроки только на машине, где наш дефолт НЕ совпал с дефолтом pgxpool (у него max(4, NumCPU)) — на 16-ядерном стенде он ничего не доказывает; несущее свойство даёт разбор через RuntimeParams, а не этот вход

Что построено (P0)

Кусок Где Проверено
Модуль, layout, батарея go.mod (sibling движка, гард D39.85 соблюдён), Makefile, .golangci.yml make check зелёный: build · vet · gofmt · lint 0 issues · go test -race
HTTP-скелет internal/httpapi/ Живой запуск: /healthz 200, /readyz 200 против живого PG, /v0/* 401 problem+json, graceful shutdown по SIGTERM
Заголовки ПТ-34 internal/httpapi/middleware.go Живой ответ несёт X-Robots-Tag: noindex, nofollow, Cache-Control: no-store, nosniff, no-referrer
Сессии П-1 internal/auth/ + internal/pgstore/sessions.go Тесты: обе презентации → principal, Bearer > cookie, истечение/отзыв/свип, токен в БД не попадает (только SHA-256), скольжение окна только во второй половине
CSRF internal/auth/csrf.go stdlib http.CrossOriginProtection + обязательный X-TM-Client на cookie-пути; 6 кейсов тестом + живой пробой (cookie-POST без заголовка → 403, cross-site → 403)
Схема read-model internal/pgstore/migrations/ Миграции применены на ЖИВОМ PostgreSQL 18.4 дважды (идемпотентность), все констрейнты сработали поимённо
Интерфейс NDJSON-ингеста internal/ingest/ Тесты: хендшейк обязателен, мажор отвергается, минор и незнакомый тип толерируются, разрыв/повтор seq ловятся; супервизор проверен НАСТОЯЩИМ процессом (exit 3 → bank_stop, stderr движка в файл, поток материализован)
Ре-синк internal/ingest/resync.go Тест на фикстуре в форме pipeline.StatusReport (имена полей сверены по backend/internal/pipeline/status.go:37-130, живого прогона не было): аллоулист берёт своё, деньги/снапшоты игнорируются

Не построено намеренно: материализатор Sink → Postgres (нужен ратифицированный словарь событий, иначе перепишется), контрактные ручки и SSE (П-1 после ратификации К-4/К-7), очередь River (П-3), брокер лимитов (П-2).

Находки (грунтованные)

  1. Ключ идемпотентности (run_id, seq) работает только если run_id — ДВИЖКОВЫЙ. Resume поднимает новый процесс, его seq стартует с 1; если ключом взять платформенный run, high-water mark отбросит весь поток второй попытки. Заведено в схеме: run_attempts.engine_run_id (unique)
    • last_seq. Просьба к строке 103: кадр hello обязан нести этот id; идеально — вместе со строкой 102 (внешний trace-контекст), тогда id назначает платформа и пространство ключей наше.
  2. Дыра контракта — статусы до прогона и библиотека без канала. См. вопрос 2 выше.
  3. Банк: канала нет — но не полностью. Подтверждаем находку оркестратора и уточняем состав: сегодня добываемы (а) ПРЕДЛОЖЕННЫЕ термины стопа — сайдкар/строка 101 и (б) термины, которые промотировала сама платформа — она же ПИШЕТ mined-delta и сид. Недобываемы auto/ruby-строки, материализованные внутри движка. То есть GET /bank частично реализуем уже сейчас; полностью — после артефакта экспорта банка.
  4. Ре-синк не восстанавливает пофазный прогресс: в status --json разбивки нет (строка 99). После обрыва и до следующего события прогресса клиент увидит агрегат. Записано в коде.
  5. status --json — ремонтный путь, не поллинг: каждый вызов заново ингестит и режет исходник (1.41.5 с CPU на книге 23 МБ — замер фронт-сессии 02.08, не наш; строка 100).
  6. Деньги движка живут в его stderr на уровне INFO. Поэтому супервизор пишет stderr движка в ФАЙЛ попытки и не тейлит его в структурный лог платформы — иначе суммы попадут в наш INFO (запрет D39.84 + норма P0-промта).
  7. Мелочи в чужой зоне (не трогали, лендить оркестратору): в ратифицированной копии docs/architecture/14-api-contract/openapi.yaml info.description всё ещё называет файл черновиком S3 и ссылается на ../API_CONTRACT_DRAFT.md; в README той же папки ссылка «нормативная поверхность → api-contract/openapi.yaml» бьёт мимо (файл лежит рядом: ./openapi.yaml). Решения D39.100 (paused, eta_seconds) в YAML ещё не внесены — это работа S3; схема платформы их уже держит.
  8. Стенд: Postgres как системного пакета нет и sudo нет, поэтому схема проверена на живом PostgreSQL 18.4, поднятом БЕЗ root из бинарников zonky в скрэтчпаде (вне репозитория и вне зависимостей модуля). Тесты с БД гейтятся TM_PLATFORM_TEST_DSN и создают свою базу на прогон.

Диспозиции бэклога зоны

ID Диспозиция
П-1 ПРОДОЛЖЕНА (P1). Добавлено: конструкция сервера с таймаутами, дренаж, снятие read-дедлайна для будущего SSE, вход как источник сессий. Осталось прежнее: контрактные ручки, SSE-эндпоинт, материализатор Sink → Postgres, воркер. Блокер тот же — словарь событий (строка 103)
П-1 (P0) НАЧАТА. Готово: каркас сессий (схема + мидлварь + CSRF), HTTP-скелет, схема read-model, интерфейс ингеста и ре-синка. Осталось: контрактные ручки, SSE-эндпоинт, материализатор Sink → Postgres, воркер. Блокеры: ратификация К-4/К-7 (форма ответов), словарь событий (строка 103)
П-2 Не трогали — гейт «до второго параллельного пользователя» в силе
П-3 Не строили. В схеме заведён гард: частичный уникальный индекс «один живой прогон на книгу» (runs_one_live_per_book) — то, что очередь обязана соблюдать, теперь отказывает база. River запинен, но в go.mod НЕ добавлен
П-4 ЗАМЕНЁН П-7. Черновик usage_windows удалён вместе с подписочной моделью (владелец 05.08)
П-5 ПЕРЕОПРЕДЕЛЁН. Окон нет, resets_at нет; форма ответа предложена выше («Что предлагаем в спеку»). Ручка НЕ построена: контракт первичен, ждём правки спеки
П-6 ЗАКРЫТ (P1). Вход через OIDC: PKCE + nonce + одноразовый state с привязкой к браузеру, наша серверная сессия, ротация на границе входа, журнал входов, «выйти везде». Токены провайдера не персистятся. Ждёт живого клиента Google (client_id/secret владельца) — код к этому готов, конфигурация проверена отказом на половинчатой настройке
П-7 ЗАКРЫТ по схеме и операциям (P1). Леджер, резервации, кэш баланса, инвариант balance == SUM(ledger), идемпотентность по (source, source_id). Не построено: постановка холда ВОРКЕРОМ перед спавном и передача потолка движку — это часть П-1/П-3, у которых нет воркера
П-8 ЗАКРЫТ (P1). tmplatformctl grant/balance/logins/revoke

Хроника

(записи сессий — сверху новые)

08.08.2026 — сессия P3 (платформа №4)

Фикс-лист приёмки P2 отработан целиком, восемь пунктов из восьми, в её порядке. Метод прежний: посадить дефект на копии зоны вне репозитория, убедиться, что он воспроизводится, починить, запинить тестом, который ловит эту посадку поимённо. 22 посадки, 22 поймано.

Единственная major (PD-80) закрыта двумя независимыми изменениями: раздельные вёдра лимитера и перенос проверки перед очисткой login-куки. Второе важнее первого — именно очистка делала отбитый вход невосстановимым.

Одна собственная посадка вскрыла ложно-зелёный тест ЭТОЙ же сессии: тест разбора флагов CLI сверял только «ошибка непуста», а команда падала на соединении с несуществующей БД, а не на аргументах. Две посадки его пережили; тест переписан на сверку сообщения. Это второй раз за две сессии, когда ложную зелень находит мутация, а не чтение.

PD-91 проверен живым прогоном под пользовательским systemd 259, а не выведен из доки: несуществующий ReadWritePaths без - даёт 226/NAMESPACE, ProtectHome=yes действительно закрывает /home, названный в юните выход (tmpfs + BindPaths=) действительно работает.

Найдена вторая, непомеченная копия снятого транспортного ответа в этом же журнале (п.4 «Открытых вопросов после P1»). Текст оркестратора не переписан — над ним поставлен баннер SUPERSEDED.

Дерево не коммичено. Адверсариального ревью P3 не проводила.

05.08.2026 — сессия P2 (платформа №3)

Отработана очередь приёмки P1 целиком (PD-46…PD-58) плюс три info-строки вне очереди (PD-50, PD-53, PD-56). Три собственные находки: PD-62 (start_id не персистился — обе строки лога login_start_id в проде пусты), PD-63 (ClearReadDeadline воспроизводил PD-2 на полу-кормленном запросе), PD-64 (OOMPolicy=stop уронил бы контрол-плейн из-за одного прогона).

Изменены два значения, а не обоснованы: абсолютный срок сессии 90 → 30 суток (NIST AAL1), MemoryMax 2G → 80%. Реализована контрмера RFC 9207 против mix-up (миграция 00008). Удалён ClearReadDeadline. Новых зависимостей P2 не добавила.

Собственное адверсариальное ревью (пять линз, зажатые промты, отчёт скрыт от четырёх из пяти; каждая находка через верификатора-опровергателя): 34 кандидата, 11 подтверждено, 23 опровергнуты. Шесть потребовали кода — PD-65…PD-70, включая major PD-70 (кука не скользила вместе с сессией) и PD-66 (мой же фикс PD-46 закрывал половину). PD-71 принят риском и записан оператору.

Открытыми оставлены PD-60 и PD-61 — свойства ШВА, решать их платформе в одиночку нельзя.

Дерево не коммичено — лендит оркестратор.

05.08.2026 — сессия P1 (платформа №2)

Закрыт регистр P0 (18 из 19; PD-6 ждёт SSE). Построены: вход через OIDC с PKCE/nonce/одноразовым стейтом и своей серверной сессией (П-6), кредитный леджер с резервациями и кэшем баланса (П-7), админ-CLI (П-8), деплой-юнит systemd, тест-пол на реальном http.Server, фаззинг декодера. Приёмка пятью независимыми ревью добавила PD-24…PD-45; изменения решений — раздел «Приёмка P1».

Не построено намеренно: GET /v0/usage (форма изменилась вместе с моделью денег, спека не правлена — контракт первичен), материализатор и SSE (ждут словарь событий строки 103), очередь.

Дерево не коммичено — лендит оркестратор.

04.08.2026 — сессия P0 (платформа №1)

Прочитано: CLAUDE.md, research/23, контракт 14-api-contract (README + openapi.yaml целиком), platform/BACKLOG.md, frontend/docs/STACK_DECISIONS.md §5, D39.81/84/85/99/100 по grep.

Сделано: стек live-сверен (три библиотечных пина §5 — pgx · goose · River — на 04.08 всё ещё последние; по Go последний патч 1.26.5 от 07.07, floor модуля оставлен общим с движком) → модуль наполнен → скелет HTTP + сессии + CSRF → схема read-model тремя миграциями → интерфейс ингеста/супервизии/ ре-синка → батарея зоны → дизайн-ответы (выше).

Ревью исполнением: make check зелёный; сервер поднят живьём против живого PostgreSQL 18.4 и опрошен curl'ом (healthz/readyz/401/CSRF-403); миграции применены дважды; констрейнты проверены поимённо через pgconn.PgError.ConstraintName; супервизор проверен настоящим процессом с контрактными кодами возврата.

Адверсариальная самопроверка (author≠reviewer) дала четыре правки, каждая внесена: (а) вложенный mux под StripPrefix терял Request.Pattern, из-за чего лог писался бы по сырому пути с id книг — проверено экспериментом, переделано на один mux; (б) отклонённые запросы (401/403) вообще не логировались, потому что лог висел на маршрутах, а гард стоял снаружи — лог поднят наружу, добавлен тест «денайл тоже виден»; (в) дефект, найденный запуском бинарника без БД: предъявленный Bearer уходил в nil-хранилище сессий и падал паникой в 500 — теперь отсутствие хранилища это отказ 401, как и любой другой промах (регрессионный тест на месте); (г) пин тулчейна поднят до 1.26.5 — в нём security-фиксы crypto/tls и os, а этот модуль сетевой (в go.mod floor остался 1.26.4, общий с движком). Плюс снят мёртвый код: crypto/rand.Read по доке ошибку не возвращает вовсе (падает), поэтому ветки её обработки убраны, а не оставлены изображать проверку.

Дерево не коммичено — лендит оркестратор.


Пинг оркестратора №15 (09.08, D39.122): движковый пак «блокеры контракта» ПРИНЯТ и заленден 0e69bc1 — пять поверхностей для платформы существуют. ФИНАЛЬНЫЕ формы (менялись трижды за приёмку — старые в переписке игнорировать):

  • Манифест глав: <project_db>.manifest.json, manifest_version: "tm-manifest-v2" (v1 движок сам отклоняет); id главы = 16 hex (стабилен через пере-нарезку); unit.id = <chapterID>:<cutTag>:<firstChunkIdx> — тег разреза 8 hex, при любой смене нарезки/данных пары unit.id УМИРАЮТ намеренно (id жив ⇒ якорь цел); поле heading — ВРЕМЕННЫЙ рендер движка «Глава N», НЕ метка книги (решение владельца 09.08; настоящие заголовки — строка 160 бэклога движка). $0-команда tmctl manifest строит дерево до первого прогона (первое касание создаёт БД проекта — то же поведение, что у status).
  • Прогресс: status --jsonprogress: {draft:{done,total}, edit:{done,total}} на книге и в каждом элементе chapters; done = «разрешено волной» (ok/flagged/skipped); волна, которой нет, — total: 0; процент не отгружается — собирает клиент.
  • Банк: <project_db>.bank.json (весь банк тремя статусами; id термов — длино-префиксированный хеш ключа уникальности, стабилен через пересборку) и <project_db>.bank-stop.json (полная таблица подписи; conf: null ≠ 0). Все сайдкары пишутся атомарно (temp+rename) — читать можно во время прогона.
  • Потолок (строка 145): tmctl translate|redrive --ceiling-usd <usd> — это КНИЖНЫЙ ПОТОЛОК В СИЛЕ, не бюджет прогона (ратифицировано D39.122): леджер сравнивает значение с накопленным committed+reserved книги. Пересчёт пользовательского «прирост в главах» (D39.110) в абсолют — ОБЯЗАННОСТЬ платформы: аргумент = committed_usd + reserved_usd (из status --json) + прирост×оценка. Значение ниже уже потраченного откажет первой же резервации. День-потолок не перекрывается. Опция по желанию зоны: движок готов провести --ceiling-usd и в status, чтобы мониторинг видел действующий потолок capped-прогона (сегодня status показывает книжный) — скажите, заведём строку.