# Направление платформы: аутентификация, деньги, стандарты, скорость > Ратифицировано оркестратором №14 (05.08.2026) по направлению владельца 04.08 и ресёрч-пакету > из четырёх направлений (OAuth · биллинг · индустриальные стандарты · производительность фронта), > каждое со скептик-пассом. **Все пины и цены сверены ЖИВЬЁМ 04–05.08.2026** против первоисточников > (proxy.golang.org, go.dev/dl, npm registry, вендорские доки); пин по памяти не называется — > перед установкой сверять заново. Что скептик опроверг — помечено ⚠ прямо в тексте. ## 1. Вход и аккаунты (П-6) **Форма: BFF — вход через OIDC, сессия остаётся НАША.** Google (и любой следующий провайдер) даёт только СОБЫТИЕ входа; его токены используются один раз в колбэке для доказательства личности и выбрасываются — ничего вендорского не персистится и не живёт в памяти дольше запроса (требование владельца о токенах исполнено буквально). Уже построенный слой сессий (`internal/auth` + `pgstore`) остаётся без изменений: он же несёт учёт токенов и мгновенный отзыв, которого self-verifying токен не даёт. Это ровно тот паттерн, который IETF BCP браузерных приложений (`draft-ietf-oauth-browser-based-apps-27`, 06.07.2026, в очереди RFC-редактора) называет строго рекомендуемым для приложений с персональными данными и деньгами. **Библиотеки (протокольный риск отдаём, интеграцию пишем сами):** | Что | Пин | Релиз | Лицензия | Почему | |---|---|---|---|---| | `golang.org/x/oauth2` | v0.36.0 | 11.02.2026 | BSD-3 | код-флоу + обмен + PKCE-хелперы; сопровождает Go-команда | | `github.com/coreos/go-oidc/v3` | v3.20.0 | 08.07.2026 | Apache-2.0 | discovery, JWKS с рефетчем по неизвестному kid, проверка подписи и claims | Граф прирастает на 4 модуля (go-oidc · go-jose/v4 · x/oauth2 · compute/metadata). **Отвергнуты:** `zitadel/oidc` (тянет второй роутер + otel + cors в stdlib-first граф), `markbates/goth` (последний тег ~год, сидит на jwx v1.2 при живом v3, свой session-store конфликтует с нашим), self-hosted IdP (Ory/Zitadel/Keycloak — второй stateful-сервис ради одного логина; Zitadel v4 к тому же AGPL), hosted IdP (связка PII + доступность, а свою сессию всё равно держать ради учёта токенов). **Решения модели аккаунта (их не делает ни одна библиотека — ратифицированы здесь):** 1. Ключ личности — пара `(provider, sub)` в таблице `identities`. Почта — только ПОДСКАЗКА для связывания и только при `email_verified` (Google прямо предупреждает: почта меняется, primary identifier из неё делать нельзя). 2. ⚠ **Коллизия почты — дыра, найденная скептиком:** в `00001_identity.sql` `users.email` NOT NULL с уникальным индексом по `lower(email)`. Что происходит, когда `(google, subX)` приносит почту, уже занятую другим пользователем, и обновляем ли мы `users.email` при каждом входе — обязано быть решено ДО первого входа: именно здесь тихий баг связывания становится захватом аккаунта. 3. Ротация идентификатора сессии на границе входа (защита от фиксации) — обязательна. 4. ⚠ **Журнал входов** (время, провайдер, класс устройства/IP) и ручка «отозвать все мои сессии» — день-один для сервиса, продающего токены: таблица сессий чистится свипом и аудитом не является. 5. Первая НЕаутентифицированная ручка (`/auth/login`) — первая же поверхность для ботов: лимит на исходящие state-записи обязателен вместе с ней. ## 2. Кредиты и лимиты (П-5/П-7) — решения владельца 05.08 **Оплаты нет и в бете не будет.** Первые аккаунты — пробные, ключи провайдеров предоплачены владельцем. ⚠ **Автоматического фри-тира на бете НЕТ** (слово владельца 16.08, D39.138 п.2л, PD-104): дефолт `TM_PLATFORM_SIGNUP_GRANT_USD` = **0**, кредит начисляется руками (`tmplatformctl grant`). Прежние «$5 по умолчанию» в этом разделе — протухший текст: возврат гранта идёт ВМЕСТЕ с суточным агрегатным потолком, который его ограничивает, а тот принадлежит платежам. Платёжный провайдер не выбирается, не проектируется и в бэклог зоны как работа не заходит; вернуться — когда появится решение продавать. ⚠ Прежняя редакция этого раздела несла вывод «покупателям из России платить нечем» — он был ВЫВЕДЕН из целевого языка перевода, а не установлен, и снят как необоснованный (владелец 05.08). Язык книги о географии плательщика не говорит. **Модель лимита — БАЛАНС кредитов, а не окна с обнулением** (владелец 05.08: «не подписки, а покупка токенов как у OpenRouter»). Следствия, несущие для схемы и контракта: - Понятия «окно сброса» и `resets_at` НЕТ. Черновик `usage_windows` (00003) в этой форме не годится: его `period=day|week` + `limit_micro_usd` — подписочная механика. Заменяется балансом и леджером. - ⚠ Это **отменяет оконную рамку П-5** (страница лимитов «как Claude Code», D39.100/ПТ-35) в части сброса окон. Что остаётся из D39.100 без изменений: суммы на экран не идут, стоп по исчерпании = статус `paused` + оповещение. Экран показывает ОСТАТОК (процентом от гранта), а не «сбросится через». - Вопрос «продолжать ли автоматически после сброса лимитов» отпадает вместе с окнами. Осталось: после пополнения баланса прогон возобновляется явным действием (кнопка/админ), а не сам. **Фри-тир = ГРАНТ в леджер, управляется из админки** (владелец 05.08). ⚠ Дефолт на бете — **НОЛЬ** (PD-104, слово владельца 16.08): начисление руками, возврат автоматических $5 — вместе с суточным агрегатным потолком, когда появятся платежи. Настраивается пер-аккаунт; «накинуть кредитов» = одна запись леджера типа `grant`, отдельного кода фри-тира не существует. Это и есть стандартная механика (Modal и другие делают так же): начисление и есть весь фри-тир. Нужна минимальная админ-поверхность — защищённая ручка или CLI-команда, пишущая грант. **Схема (строить с П-7):** `credit_ledger` (append-only, знаковые целые микро-доллары, типы `grant|hold|hold_release|settlement|adjustment`; `UNIQUE(user_id, source, source_id)` — ключ идемпотентности; ⚠ первая редакция этого абзаца называла `UNIQUE(source, source_id)`, и это ошибка: без `user_id` ключ, потраченный на одном аккаунте, проглатывает тот же ключ на другом, и второму сообщают «начислено», не начислив ничего (миграция `00007` и её комментарий); `purchase` добавится, если появится продажа), `reservations` (одна открытая на `engine_run_id`), `account_balances` (кэш в ТОЙ ЖЕ транзакции, что вставка в леджер, + тест-инвариант `balance == SUM(ledger)`). Правки строк не существует: ошибка чинится новой записью. Только целые микро-доллары; на шве расчёта округляем ВВЕРХ (леджер движка — нижняя граница). **Защита и свежесть — ДВА РАЗНЫХ механизма, строим ОБА (решение владельца 05.08 «сделаем нормально»; моё прочтение его слов — записано так, чтобы дешёво поправить, если прочитал не то):** 1. **Защита — холд + потолок движку (было «вариант B»).** В той же транзакции, что допускает задачу в очередь, ДО спавна `tmctl` ставим холд и передаём движку пер-книжный потолок ≤ остатка баланса. Жёсткий стоп исполняет САМ движок, у которого механизм уже есть ⇒ перерасход физически невозможен, даже если платформа во время прогона слепа. Это рубеж корректности, и он НЕ зависит от доставки событий (поток at-least-once и на краше теряет хвост). 2. **Свежесть — версионированное денежное событие в словаре потока (было «вариант A»).** Движок эмитит НАКОПИТЕЛЬНЫЙ счётчик потраченного на границах стадии/волны; платформа материализует его существующим идемпотентным апсертом `(engine_run_id, seq)`. Накопительная семантика делает повтор и дубль безвредными (берём максимум по прогону). Индикатор остатка перестаёт отставать на целую попытку. ⚠ **Что это меняет в каноне и чего НЕ меняет.** Отменяется частная доктрина «поток событий цифр не несёт» (записана в комменте `00003_usage.sql` и в дизайн-ответе П-5). **D39.84 НЕ затронут**: он запрещает суммы на ПОЛЬЗОВАТЕЛЬСКОМ проводе, экране и в INFO-логах — здесь же внутренний канал движок→платформа в приватную таблицу; наружу по-прежнему уходит только процент остатка. ⚠ **Запрет, который обязан ехать вместе с механизмом:** ЭНФОРСМЕНТ на событии строить нельзя — иначе корректность квоты повиснет на доставке потока. Событие только показывает; останавливает потолок. **Носитель работы движка — строка 103 единого бэклога** (словарь событий); заводится оркестратором отдельной строкой, платформа её не пишет. ## 3. Стандарты и стек: что взять, что не брать > ⚠ **ПЕРЕ-ПОДПИСАНО D39.132 п.2б** (баннер «ратификация за оркестратором» снят как отработанный): > `oapi-codegen` — КАНДИДАТ, решение за паком, который его берёт; `sqlc` привязан к P7; факт про River > поправлен. **Решение P7 по обоим — НЕ БРАТЬ, с доводом, а не молчанием:** > > - **`sqlc`.** Читающая поверхность добавила ~15 запросов, и ровно они — те, которые sqlc не > генерирует: запрос страницы и её ревизии живёт в ОДНОЙ транзакции с курсором, водяным знаком и > агрегатами первой страницы (`internal/pgstore/readmodel.go`), то есть выигрыш был бы на > `select … where id = $1`, которых в паке единицы. Цена реальна: денежные типы требуют `overrides`, > а генерённый слой пришлось бы всё равно оборачивать проекцией. Строка PD-44 остаётся открытой — > это отказ ЭТОГО пака, а не отмена направления. > - **`oapi-codegen`.** Довод «на P7 ручек станет больше, значит дешевле сейчас» проверен фактом: 20 > операций контракта, из них построено 14, и КАЖДАЯ требует рукописной проекции read-модели в > контрактные словари (`internal/httpapi/project.go`) — генератор даёт имена полей, а переводит > словари всё равно человек. Плюс замеренное 05.08: 3.1-условия (`if action=approve → dst`) > генератор игнорирует, и исполнителем правила остаётся констрейнт БД. Взамен пак поставил ДРУГОЙ > гейт от дрейфа — пофайловый дифф проводных структур против required-списка канона, исполненный > тестами (`httpapi.Test*CarriesEveryRequiredField`). > > - **`oapi-codegen` («ВЗЯТЬ — доказано исполнением»): НЕ ВЗЯТ.** Ручки P4 и P5 (`internal/httpapi/v0.go`) > написаны руками, проводные структуры выписаны полем в поле. `ENGINEERING_STANDARDS:59` при этом > называет его «кандидатом при P1» — то есть два дока зоны говорят про один инструмент разное. > Довод зоны ЗА пере-подпись в «кандидат»: доказательство исполнением 05.08 нашло и половину > против — генератор слеп к 3.1-условиям (`if action=promote → dst`), поэтому исполнителем правила > всё равно остаётся констрейнт БД, а сгенерированные типы всё равно требуют рукописной проекции. > На пяти ручках выигрыш — сверка имён полей; он реален, но это НЕ «доказано, берём». Довод ПРОТИВ > пере-подписи: читающая поверхность P7 добавит больше ручек, чем есть сейчас, и там цена дрейфа > растёт — тогда взять его дешевле сейчас, чем потом. > - **`sqlc` («до первого хендлера»): НЕ ВЗЯТ** — PD-44 открыт с P1, хендлеры P4/P5 приехали без него. > Довод зоны: срок «до первого хендлера» уже нарушен фактом, и повторять его — значит писать > заведомо неисполняемое; честная форма — либо привязать к П-1/P7 (там и появляется масса запросов, > ради которых он брался), либо снять. > - **`River (в go.mod не заводить, пока не подключён)`: УСТАРЕЛО как факт** — подключён с P4 и > мигрируется своим мигратором (`STACK_DECISIONS` §19). Правится ниже как факт, а не как решение. Стек менять не надо: правильные куски уже взяты (pgx · goose · River · stdlib ServeMux/CSRF · slog · опаковые сессии). Добавить четыре вещи, пока репозиторий — скелет: | Добавление | Пин | Статус | |---|---|---| | OIDC-вход | x/oauth2 v0.36.0 + go-oidc/v3 v3.20.0 | ратифицировано (§1) | | Кодоген сервера из ратифицированной спеки OpenAPI 3.1 | `oapi-codegen/v2` v2.8.0 (17.07.2026) | **ВЗЯТЬ — доказано исполнением 05.08** (ниже); фолбэк overlay 3.1→3.0 не понадобился. ⚠ **НЕ ИСПОЛНЕНО на 14.08** — см. баннер статуса выше | | ~~`sqlc` для денежных/квотных таблиц~~ → `sqlc` для поверхности контрактных ручек и read-model | v1.31.1 | **ПЕРЕСМОТРЕНО приёмкой P1 (05.08), доказано исполнением** — см. абзац ниже. Денежный пакет остаётся рукописным. ⚠ **НЕ ИСПОЛНЕНО на 14.08**, срок «до первого хендлера» пройден — см. баннер статуса выше (PD-44) | | `golang.org/x/time/rate` | v0.15.0 | лимиты в процессе; долговечные пер-пользовательские — в Postgres | **Пересмотр по `sqlc` (приёмка P1, 05.08, доказано исполнением вне репозитория).** Первая редакция этой строки требовала взять `sqlc` ДО денежных таблиц. Таблицы приехали без него (PD-44), и вопрос пришёл на ратификацию. Проверено: sqlc v1.31.1 читает все goose-миграции зоны (на момент пробы их было семь; `00008` приехала позже и пробу не проходила) и генерирует под `pgx/v5` код, почти совпадающий с рукописным (`:execrows` → `RowsAffected`, параметры структурой) — инструмент на этой схеме РАБОТАЕТ. Две трения, обе увидены исполнением: (1) его анализатор отвергает запрос, который Postgres принимает (неквалифицированный `user_id` в коррелированных подзапросах) — то есть переход означает правку существующего SQL, а не обёртку; (2) колонки типизуются как `pgtype`/`int64`, поэтому `money.MicroUSD` на границе теряется без блока `overrides` — а единый денежный тип и есть то, ради чего заведены PD-15/PD-39. **Решение: денежный пакет НЕ переписывать.** Он только что прошёл ревью и имеет батарею против живой БД; обмен отревьюенного кода на сгенерированный без единого нового теста ничего не покупает. `sqlc` берётся на поверхность контрактных ручек и read-model (П-1), где запросов много и они меняются вместе со схемой — там он ловит именно свой класс: запрос ссылается на колонку, которую унесла миграция. Блок `overrides` для денежных колонок пишется тогда же, до первого хендлера. **Доказательство исполнением по кодогену (05.08, $0, вне репозитория):** `oapi-codegen` v2.8.0 в режиме `std-http-server` + `strict-server` на ратифицированной копии `openapi.yaml` (983 строки, `openapi: 3.1.0`) — **exit 0, 2981 строка, `go build` чистый**; рантайм-граф прирастает одним модулем (`oapi-codegen/runtime`, транзитивно uuid + go-jsonmerge), роутер не тянется — совместимо с пином stdlib. Две проверки конструкций 3.1, на которых спотыкался генератор типов фронта: ✅ нулевой `kind` сгенерирован ПРАВИЛЬНО — `Kind *TermKind \`json:"kind"\`` без `omitempty`, то есть «поле присутствует всегда, значение может быть `null`», ровно правило контракта §2.8; ⚠ **условие `if action=promote → dst` НЕ исполняется генерированным кодом** (`Dst *string \`json:"dst,omitempty"\``) — та же слепота к 3.1-условиям, что у `openapi-typescript` (К-11). Следствие, которое обязана знать каждая следующая сессия: **констрейнт БД `bank_decisions_promote_has_dst` — ЕДИНСТВЕННЫЙ исполнитель этого правила на сервере**, не подстраховка; удалять или ослаблять его нельзя, а любое новое условное требование спеки обязано получить носителя в схеме или явную проверку в хендлере. **Оставляем как есть:** stdlib-роутинг (1.22+ закрыл разрыв; `Request.Pattern` — с 1.23), pgx без ORM, goose библиотекой, River (в `go.mod` с P4, подключён, мигрируется своим мигратором), «никакого Redis», опаковые серверные сессии (OIDC их НЕ заменяет), stdlib-CSRF, slog, свой RFC 9457 problem+json, zonky-стенд Postgres без root, пины линтера/govulncheck, изоляция графа зависимостей от движка. **Не берём:** ORM · OpenTelemetry (позже: один сервис, одна БД, APM-бэкенда нет) · Temporal · Vault · feature-flag-платформы · CodeQL (на приватном репо платный) · gosec отдельным гейтом · SBOM/SLSA — церемония на этом размере. **Базовые линии безопасности:** OWASP **ASVS 5.0.0**, целевой **L2** — ⚠ поправка скептика к карте глав: в 5.0 сессии это **V7**, аутентификация **V6**, и есть НОВАЯ глава **V10 «OAuth and OIDC»** — именно она написана под наше направление (ресёрч цитировал нумерацию 4.0 и главу V10 пропустил). Плюс OWASP API Security Top 10 (2023) — API1 BOLA это буквально проверка владения книгой на каждом `/v0`-маршруте, API4 — наш PD-2. Плюс уже действующие govulncheck-гейт и пины Go-модулей с sumdb. Dependabot — да (граф крошечный, шума нет). **Тест-пол — тот, что поймал бы дефекты этой приёмки** (иначе стандарт бесполезен): 1. Тест на РЕАЛЬНО сконфигурированном `http.Server`, а не на mux под `httptest`: полу-кормленный POST обязан получить закрытие по `ReadTimeout`; SIGTERM обязан дренировать запросы (сегодня падают оба — PD-2 и PD-9; ни один mux-тест этот класс не видит). 2. Инвариант сессии НЕЗАВИСИМЫМ оракулом: SHA-256 считается ВНЕ `auth.Digest`, плюс ассерт «плейнтекст токена в БД не находится» (PD-1: свойство истинно, но моя посадка его не уронила). 3. Нативный фаззинг `go test -fuzz` на NDJSON-декодере, оракулы — инварианты PD-10. 4. Дисциплина посадок мутаций (`ENGINEERING_STANDARDS.md` §3.3) как постоянная проверка того, что свойства ЗАПИНЕНЫ, а не просто истинны. 5. Контрактные тесты против OpenAPI-спеки и нагрузочные (k6) — при первых живых ручках, не раньше. **Деплой:** одна VM + systemd-юниты, бинари артефактами CI; Postgres сперва там же, управляемый — при первой выручке. Не Kubernetes: дети-`tmctl` живут ЧАСАМИ, держат эксклюзивный лок на файлах книги на локальном диске, и любой оркестратор, способный переселить под посреди прогона, нам враждебен. Юниты писать сейчас — они же честный ответ на PD-13 (осиротевшие процессы движка): прогон в cgroup-поддереве платформы, `KillMode=mixed`, `Restart=on-failure`, `LoadCredential=` для DSN и клиентского секрета OAuth, `MemoryMax`, песочница `ProtectSystem`/`PrivateTmp` бесплатно. ## 4. Скорость: что это требует от платформы и контракта Замер снял главный страх: **пер-главный объём крошечный** — ~3.4 тыс. символов исходника + ~10.2 тыс. перевода ≈ 27 КБ строк UTF-16 на главу, а контракт уже читает юниты ПО ГЛАВЕ, читает банк ДЕЛЬТОЙ (`?after_version=`) и отдаёт экспорт ссылкой. ⚠ Прежняя формулировка «подписывает банк частями» ЛОЖНА с D39.144: подпись — ОДИН акт над всем банком, а частичным является накопление РЕШЕНИЙ, что совсем другая вещь. Риск не в экране чтения, а в четырёх местах, где сегодняшняя форма вынудила бы клиента держать книгу целиком. Требования, которые платформа обязана исполнить: 1. **Сжатие ответов — ИСПОЛНЕНО P7** и записано нормой в канон 0.3.0, а не в этот док: JSON сжимается там же, где строится тело (`internal/httpapi/conditional.go`), `text/event-stream` — никогда, и это свойство структурное (поток через ту функцию не проходит), а не дисциплина. 2. **Условные запросы (`ETag`/`If-None-Match` → 304) — ИСПОЛНЕНО P7** на всех коллекциях, карточке книги и `/capabilities`. Валидатор — хеш ТЕЛА, поэтому страница 2 и дельта той же ревизии различаются, как того требует канон. 3. **Юниты — только пер-главно, никогда пер-книжно.** Это несущее свойство памяти всего дизайна (рабочий набор 27 КБ против ~62 МБ); одна «удобная» ручка обнулила бы его молча — ревью-вопрос на каждое изменение контракта. 4. **Экспорт — артефакт по ссылке, сборка текста книги на клиенте запрещена явно** (иначе та же проблема памяти заходит с чёрного хода на экране выгрузки). 5. **События потока сервер вправе склеивать — но ТОЛЬКО кадры состояния** (`status`, `progress`, `chapter`, `bank`); клиент обязан терпеть скачки счётчиков, иначе прогон на 9500 юнитов становится неограниченным источником рендера. ⚠ Сужено батчем 0.3.0 (research/28 Б-6в): `note` склеивать НЕЛЬЗЯ — это добавление, и склеенное замечание теряется навсегда, на живом соединении, без переподключения и потому без `resync_required`. 6. **Заголовок главы ограничить по длине на сервере** — единственная неограниченная строка в списке из 5000 строк, всё остальное там целые числа. 7. **Поиск.** Пер-книжного поиска в контракте нет вовсе. Развилка: серверный полнотекстовый (Postgres) · клиентский, но ТОЛЬКО в открытой главе / по списку заголовков (безопасен по памяти) · отложить. Решать при экране поиска, не раньше. 8. **Живой канал для до-прогонных состояний — ИСПОЛНЕН P7.** Поток стоит на КНИГЕ, а не на прогоне, поэтому `uploading`/`parsing` и появление дерева видны без опроса read-модели. ⚠ Акт 5 закрыл вторую половину, которая делала первую бесполезной (Ф-56): книга «в покое» — это ещё и «никакой материализации не должна», иначе поток слал `end` и отвечал 204 ровно в те секунды, пока главы вот-вот появятся. Остаётся не закрытым только УЧЁТНЫЙ поток (библиотека целиком, пуш экспорта К-12) — это пользовательский поток, а не книжный. 9. **SSE поверх HTTP/1.1** упирается в лимит ~6 соединений на источник при нескольких вкладках — на dev-стенде это выглядит как загадочное зависание; одна строка в доке стенда снимает класс. 10. **Персист манифеста глав (строка 100 единого бэклога) — несущий для латентности чтения:** только он держит чтения на read-модели вместо 1.4–1.5 с ре-ингеста движка на книге 23 МБ. Бюджеты (в батарею фронта по мере появления экранов): DOM ≤1400 узлов на тяжёлом экране · виртуализованный список ≤60 строк независимо от размера коллекции · медиана кадра прокрутки ≤17 мс, p95 ≤33 мс · ни одной длинной задачи >200 мс на взаимодействие · страница списка ≤300 КБ · стартовый JS-роут ≤200 КБ gzip · ≤4 применения событий в секунду. ⚠ Инструмент — расширение уже существующего Playwright-харнесса (CDP-метрики + longtask), библиотеку `web-vitals` НЕ брать: RUM в MVP нет, потребителя у неё не существует.