textmachine/platform/docs/platform-PROGRESS.md

1058 lines
129 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

# Журнал зоны «Платформа»
> Весь прогресс платформы — ЗДЕСЬ (решение владельца 04.08): пинги, итоги сессий, открытые
> вопросы, предложения на ратификацию. В `docs/PROGRESS.md` платформа не пишет; оркестратор
> читает этот журнал при каждом лендинге зоны (свип «решений владельца» — норма D39.99 п.4).
## Текущее состояние
- **P1+P2 ПРИНЯТЫ и ЗАЛЕНДЕНЫ приёмкой №15 (07.08)** — раздел «Ратификация приёмкой P2» ниже:
вердикт, метод, что ратифицировано, фикс-лист, что опровергнуто. Два вопроса ушли владельцу:
срок сессии 30 суток и агрегатный потолок фри-тира (PD-104).
- **Регистр после приёмки — 107 строк** (скриптом по таблице): 66 закрыто · 3 приняты риском ·
1 закрыт ратификацией (PD-59) · **37 открыто** — из них **1 major** (PD-80), 17 minor, 19 info.
Прежние восемь (PD-6 · PD-23 · PD-43 · PD-44 · PD-45 · PD-60/61 · PD-72) плюс 29 новых
PD-79…PD-107. Первые в очереди зоны — фикс-лист приёмки, порядок там же.
- **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=2G`**80%** (потолок машины вместо мнимого потолка сервиса). Оба переопределяются.
- **Построено в 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.
## Ратификация приёмкой 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, дерево сессии)`.
**Живой уязвимости приёмка не нашла.** Найденное — 29 строк регистра **PD-79…PD-107**: одна major
(доступность входа, PD-80), остальные minor/info. Лендинг не блокирует ничего; три строки блокируют
первый реальный деплой и постройку воркера — названы в фикс-листе. Метод панели: семь линз с
зажатыми промтами (отчётные доки зоны им запрещены), затем адверсариальный опровергатель на КАЖДУЮ
находку весом 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.Ping``Ready`, `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-105:** фри-тир печатается НЕАУТЕНТИФИЦИРОВАННЫМ потоком по $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 — ПЕРВЫМ в промт эмиттера, и это эррата к самой приёмке.** Транспортная история зоны
устарела против D39.106 в двух местах: доккоммент `events.go` предлагает переезд на fd/сокет
`research/25` — 0 голосов), а секция журнала «Транспорт потока событий» доказывает «остаётся
stdout», опираясь на PD-59, который тем же D39.106 п.3 объявлен superseded вместе с пайп-путём
`supervisor.go` («дев-режим»). Ратифицированная форма — `events.jsonl` в каталоге книги, который
платформа ТЕЙЛИТ, а движок поднимается транзиентным systemd-юнитом и платформе не ребёнок.
**Я это при лендинге пропустил и в первой редакции PD-95 сам сослался на снятый PD-59**
поймано вопросом владельца 07.08, а не процессом; секция журнала помечена superseded, PD-95
переписан, PD-92 понижен до info (дефект дев-пути), PD-105 повышен (при тейле файла повторная
доставка строк — норма, значит норматив «дубль не ошибка» буквально верен), PD-60
переформулируется вместе со строкой 103.
7. Остальное — по весу строк.
**Опровергнуто приёмкой — включая свои промахи (дисциплина «заявление=команда» действует и на
приёмку).**
- Панель: «удаление аккаунта падает на композитном 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 опровергнуты с разбором**. Из
подтверждённых шесть потребовали правки кода:
7. **Обмен кода и JWKS шли без дедлайна** (PD-65), хотя discovery рядом ограничивает себя пятью
секундами. Набор ключей у go-oidc ОБЩИЙ — значит одна зависшая загрузка паркует все параллельные
входы, а не только свой. Замерено верификатором на боевой проводке (`httpClient` = nil).
8. **Мой же фикс PD-46 закрывал половину и утверждал, что обе** (PD-66). Тест смотрел на сервер,
который сам и построил; проводка демона осталась ненаблюдаемой — подмена аргумента в `main.go`
оставляла `make check` зелёным, а бинарь снова пиннил соединения. Закрыто устранением КЛАССА:
`NewServer` больше не принимает `Timeouts`, передавать нечего.
9. **`FOR UPDATE` не был запинен**, а комментарий утверждал обратное (PD-67). Последовательный тест
лока не видит, а инвариант «кэш = леджер» тоже: без лока обе величины уезжают в минус ВМЕСТЕ.
10. **`/readyz` рапортовал «готов» на базе без схемы** (PD-68) — то есть в нормальной середине
выката, потому что миграция по инструкции отдельный шаг.
11. **Явный `pool_max_conns` из DSN молча отбрасывался** (PD-69): сравнение с дефолтом pgx не
отличает «оператор промолчал» от «оператор выбрал это же число».
12. **Скользящее окно бездействия для браузера не работало** (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 единого бэклога, пока эмиттера нет — потом правка станет миграцией.
> ⚠ **ОТВЕЧЕНО приёмкой (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.
2. **Оркестратору (контракт).** Поток событий привязан к ПРОГОНУ (`/runs/{runId}/events`), а
статусы `uploading`/`parsing` существуют ДО прогона, и библиотека охватывает книги без прогонов.
Живого канала у них нет вовсе. Нужна либо строка в спеке «до старта прогона состояние
опрашивается», либо пользовательский поток (он же закрыл бы К-12 пушем). Решение — не наше.
3. **Оркестратору (спека).** Третий слой 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`:
```yaml
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` код, почти совпадающий с рукописным
(`:execrows` → `RowsAffected`). Две трения: он отвергает запрос, который 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` уже есть):
```go
// 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}/exports` → `202` +
`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`).
- **Поднятие потолка — политика платформы, не кнопка на экране.** Платформа сама владеет
`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` |
## Хроника
_(записи сессий — сверху новые)_
### 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` по доке ошибку
не возвращает вовсе (падает), поэтому ветки её обработки убраны, а не оставлены изображать проверку.
Дерево не коммичено — лендит оркестратор.