30 KiB
Направление платформы: аутентификация, деньги, стандарты, скорость
Ратифицировано оркестратором №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 + доступность, а свою сессию всё равно держать ради учёта токенов).
Решения модели аккаунта (их не делает ни одна библиотека — ратифицированы здесь):
- Ключ личности — пара
(provider, sub)в таблицеidentities. Почта — только ПОДСКАЗКА для связывания и только приemail_verified(Google прямо предупреждает: почта меняется, primary identifier из неё делать нельзя). - ⚠ Коллизия почты — дыра, найденная скептиком: в
00001_identity.sqlusers.emailNOT NULL с уникальным индексом поlower(email). Что происходит, когда(google, subX)приносит почту, уже занятую другим пользователем, и обновляем ли мыusers.emailпри каждом входе — обязано быть решено ДО первого входа: именно здесь тихий баг связывания становится захватом аккаунта. - Ротация идентификатора сессии на границе входа (защита от фиксации) — обязательна.
- ⚠ Журнал входов (время, провайдер, класс устройства/IP) и ручка «отозвать все мои сессии» — день-один для сервиса, продающего токены: таблица сессий чистится свипом и аудитом не является.
- Первая НЕаутентифицированная ручка (
/auth/login) — первая же поверхность для ботов: лимит на исходящие state-записи обязателен вместе с ней.
2. Кредиты и лимиты (П-5/П-7) — решения владельца 05.08
Оплаты нет и в бете не будет. Первые аккаунты — пробные, сидят на фри-тире, ключи провайдеров предоплачены владельцем. Платёжный провайдер не выбирается, не проектируется и в бэклог зоны как работа не заходит; вернуться — когда появится решение продавать. ⚠ Прежняя редакция этого раздела несла вывод «покупателям из России платить нечем» — он был ВЫВЕДЕН из целевого языка перевода, а не установлен, и снят как необоснованный (владелец 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). Дефолт $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 «сделаем нормально»; моё прочтение его слов — записано так, чтобы дешёво поправить, если прочитал не то):
- Защита — холд + потолок движку (было «вариант B»). В той же транзакции, что допускает задачу
в очередь, ДО спавна
tmctlставим холд и передаём движку пер-книжный потолок ≤ остатка баланса. Жёсткий стоп исполняет САМ движок, у которого механизм уже есть ⇒ перерасход физически невозможен, даже если платформа во время прогона слепа. Это рубеж корректности, и он НЕ зависит от доставки событий (поток at-least-once и на краше теряет хвост). - Свежесть — версионированное денежное событие в словаре потока (было «вариант A»). Движок
эмитит НАКОПИТЕЛЬНЫЙ счётчик потраченного на границах стадии/волны; платформа материализует его
существующим идемпотентным апсертом
(engine_run_id, seq). Накопительная семантика делает повтор и дубль безвредными (берём максимум по прогону). Индикатор остатка перестаёт отставать на целую попытку.
⚠ Что это меняет в каноне и чего НЕ меняет. Отменяется частная доктрина «поток событий цифр не
несёт» (записана в комменте 00003_usage.sql и в дизайн-ответе П-5). D39.84 НЕ затронут: он
запрещает суммы на ПОЛЬЗОВАТЕЛЬСКОМ проводе, экране и в INFO-логах — здесь же внутренний канал
движок→платформа в приватную таблицу; наружу по-прежнему уходит только процент остатка.
⚠ Запрет, который обязан ехать вместе с механизмом: ЭНФОРСМЕНТ на событии строить нельзя —
иначе корректность квоты повиснет на доставке потока. Событие только показывает; останавливает
потолок. Носитель работы движка — строка 103 единого бэклога (словарь событий); заводится
оркестратором отдельной строкой, платформа её не пишет.
3. Стандарты и стек: что взять, что не брать
⚠ СТАТУС ИСПОЛНЕНИЯ (сверено кодом сессией P6, 14.08) — две строки таблицы НЕ ИСПОЛНЕНЫ, и это предложение пере-подписать их, а не тихая девиация. Направление ратифицировано не зоной, поэтому зона не переписывает решение сама: ниже — факт и довод, ратификация за оркестратором при лендинге.
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 — да (граф крошечный, шума нет).
Тест-пол — тот, что поймал бы дефекты этой приёмки (иначе стандарт бесполезен):
- Тест на РЕАЛЬНО сконфигурированном
http.Server, а не на mux подhttptest: полу-кормленный POST обязан получить закрытие поReadTimeout; SIGTERM обязан дренировать запросы (сегодня падают оба — PD-2 и PD-9; ни один mux-тест этот класс не видит). - Инвариант сессии НЕЗАВИСИМЫМ оракулом: SHA-256 считается ВНЕ
auth.Digest, плюс ассерт «плейнтекст токена в БД не находится» (PD-1: свойство истинно, но моя посадка его не уронила). - Нативный фаззинг
go test -fuzzна NDJSON-декодере, оракулы — инварианты PD-10. - Дисциплина посадок мутаций (
ENGINEERING_STANDARDS.md§3.3) как постоянная проверка того, что свойства ЗАПИНЕНЫ, а не просто истинны. - Контрактные тесты против 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 на главу, а контракт уже читает юниты ПО ГЛАВЕ, подписывает банк частями и отдаёт экспорт ссылкой. Риск не в экране чтения, а в четырёх местах, где сегодняшняя форма вынудила бы клиента держать книгу целиком. Требования, которые платформа обязана исполнить:
- Сжатие ответов. stdlib
net/httpНЕ сжимает — значит все выкладки «289 КБ против 46 КБ gzip» аспирационны, покаContent-Encodingне назван требованием (платформа или edge). Самая дешёвая победа для «фронт должен летать»; исполнить с первыми списочными ручками. - Условные запросы (
ETag/If-None-Match→ 304) на списочных чтениях. Дополняет дельта-чтения и, в отличие от них, работает для банка, который переписывается целиком. - Юниты — только пер-главно, никогда пер-книжно. Это несущее свойство памяти всего дизайна (рабочий набор 27 КБ против ~62 МБ); одна «удобная» ручка обнулила бы его молча — ревью-вопрос на каждое изменение контракта.
- Экспорт — артефакт по ссылке, сборка текста книги на клиенте запрещена явно (иначе та же проблема памяти заходит с чёрного хода на экране выгрузки).
- События потока сервер ВПРАВЕ склеивать (рекомендация ~4 применения/с), клиент обязан терпеть скачки счётчиков — иначе прогон на 9500 юнитов становится неограниченным источником рендера.
- Заголовок главы ограничить по длине на сервере — единственная неограниченная строка в списке из 5000 строк, всё остальное там целые числа.
- Поиск. Пер-книжного поиска в контракте нет вовсе. Развилка: серверный полнотекстовый (Postgres) · клиентский, но ТОЛЬКО в открытой главе / по списку заголовков (безопасен по памяти) · отложить. Решать при экране поиска, не раньше.
- Живой канал для до-прогонных состояний (
uploading/parsing, библиотека без прогонов) — его нет; при этом контракт запрещает фронту опрашивать read-модель. Дыра одинаково латентностная и продуктовая; закрывается либо строкой в спеке «до старта прогона состояние опрашивается», либо пользовательским потоком (он же закрыл бы пуш экспорта К-12). - SSE поверх HTTP/1.1 упирается в лимит ~6 соединений на источник при нескольких вкладках — на dev-стенде это выглядит как загадочное зависание; одна строка в доке стенда снимает класс.
- Персист манифеста глав (строка 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 нет, потребителя у неё не существует.