textmachine/platform/docs/PLATFORM_DIRECTION.md

29 KiB
Raw Blame History

Направление платформы: аутентификация, деньги, стандарты, скорость

Ратифицировано оркестратором №14 (05.08.2026) по направлению владельца 04.08 и ресёрч-пакету из четырёх направлений (OAuth · биллинг · индустриальные стандарты · производительность фронта), каждое со скептик-пассом. Все пины и цены сверены ЖИВЬЁМ 0405.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. Коллизия почты решена ДО первого входа (миграция 00005): users.email — NULLABLE и БЕЗ уникального индекса, неизвестная пара всегда создаёт НОВЫЙ аккаунт. Цена и разбор — архив-слайс журнала зоны, platform/docs/archive/platform-PROGRESS-P0-P3.md:496=### Политика коллизии почты (в ЖИВОМ журнале раздела нет — он закрыт вместе с эрой P0P3); строка пина — STACK_DECISIONS.md §9.
  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); возврат автоматического гранта идёт ВМЕСТЕ с суточным агрегатным потолком, который его ограничивает, а тот принадлежит платежам. Платёжный провайдер не выбирается, не проектируется и в бэклог зоны как работа не заходит; вернуться — когда появится решение продавать.

Модель лимита — БАЛАНС кредитов, а не окна с обнулением (владелец 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). Настраивается пер-аккаунт; «накинуть кредитов» = одна запись леджера типа grant, отдельного кода фри-тира не существует. Это и есть стандартная механика (Modal и другие делают так же): начисление и есть весь фри-тир. Нужна минимальная админ-поверхность — защищённая ручка или CLI-команда, пишущая грант.

Схема (строить с П-7): credit_ledger (append-only, знаковые целые микро-доллары, типы grant|hold|hold_release|settlement|adjustment; UNIQUE(user_id, source, source_id) — ключ идемпотентности, скоупленный АККАУНТОМ (почему в нём обязателен user_id — комментарий миграции 00007_credits.sql); purchase добавится, если появится продажа), reservations (одна открытая на engine_run_id), account_balances (кэш в ТОЙ ЖЕ транзакции, что вставка в леджер, + тест-инвариант balance == SUM(ledger)). Правки строк не существует: ошибка чинится новой записью. Только целые микро-доллары; на шве расчёта округляем ВВЕРХ (леджер движка — нижняя граница).

Защита и свежесть — ДВА РАЗНЫХ механизма, строим ОБА (решение владельца 05.08 «сделаем нормально»; моё прочтение его слов — записано так, чтобы дешёво поправить, если прочитал не то):

  1. Защита — холд + потолок движку. В той же транзакции, что допускает задачу в очередь, ДО спавна tmctl ставим холд и передаём движку пер-книжный потолок ≤ остатка баланса. Жёсткий стоп исполняет САМ движок, у которого механизм уже есть ⇒ перерасход физически невозможен, даже если платформа во время прогона слепа. Это рубеж корректности, и он НЕ зависит от доставки событий (поток at-least-once и на краше теряет хвост).
  2. Свежесть — версионированное денежное событие в словаре потока. Движок эмитит НАКОПИТЕЛЬНЫЙ счётчик потраченного на границах стадии/волны; платформа материализует его существующим идемпотентным апсертом (engine_run_id, seq). Накопительная семантика делает повтор и дубль безвредными (берём максимум по прогону). Индикатор остатка перестаёт отставать на целую попытку.

Что это меняет в каноне и чего НЕ меняет. Отменяется частная доктрина «поток событий цифр не несёт» (записана в комменте 00003_usage.sql и в дизайн-ответе П-5). D39.84 НЕ затронут: он запрещает суммы на ПОЛЬЗОВАТЕЛЬСКОМ проводе, экране и в INFO-логах — здесь же внутренний канал движок→платформа в приватную таблицу; наружу по-прежнему уходит только процент остатка. ⚠ Запрет, который обязан ехать вместе с механизмом: ЭНФОРСМЕНТ на событии строить нельзя — иначе корректность квоты повиснет на доставке потока. Событие только показывает; останавливает потолок. Построено: событие spend в словаре потока — internal/ingest/events.go TypeSpend.

3. Стандарты и стек: что взять, что не брать

⚠⚠ ВЕРХНИЙ СЛОЙ — ЧИТАТЬ ПРЕЖДЕ ВСЕГО НИЖЕ. По sqlc действует НЕ то решение, что записано в этом разделе: владелец сказал БЕРЁМ (D39.153 п.6а, уточнение D39.154 п.10), и 29.08 пак принят и заленджен (D39.172). Устройство, пин и цена — STACK_DECISIONS.md, строка «Кодоген SQL»; граница инструмента и построенная взамен замена (гейт планируемости КАЖДОГО SQL пакета, включая склейки) — DEFECT_REGISTER.md, PD-44. Отказ P7 ниже остаётся как история пака, а не как действующее решение.

ПЕРЕ-ПОДПИСАНО D39.132 п.2б: oapi-codegen — КАНДИДАТ, решение за паком, который его берёт. Решение P7 по обоим — НЕ БРАТЬ, с доводом, а не молчанием (история пака P7, 20.08):

  • 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).

Стек менять не надо: правильные куски уже взяты (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) КАНДИДАТ — решение за паком, который его возьмёт (D39.132 п.2б; отказ P7 и его довод — в баннере выше). Замер 05.08 ниже доказал, что инструмент РАБОТАЕТ, а не что его берут
sqlc на свободный от склейки блок pgstore (включая денежный) v1.31.1 ВЗЯТ и заленджен 29.08 (D39.172, PD-44 закрыт) — устройство и границы в STACK_DECISIONS.md, строка «Кодоген SQL»
golang.org/x/time/rate v0.15.0 лимиты в процессе; долговечные пер-пользовательские — в Postgres

Границы той пробы 05.08: sqlc читал ВСЕ goose-миграции зоны на тот момент — их было СЕМЬ; 00008 приехала позже и пробу не проходила (platform/internal/pgstore/migrations/, греп 00008_auth_state_issuer_and_start_id.sql).

Две трения sqlc, увиденные исполнением на пробе 05.08 (тот же пин 1.31.1, что взят): его анализатор отвергает запрос, который Postgres принимает (неквалифицированный user_id в коррелированных подзапросах) — переход означает правку существующего SQL, а не обёртку; и колонки типизуются как pgtype/int64, поэтому money.MicroUSD на границе теряется без блока overrides (он и стоит в platform/sqlc.yaml подстановкой *.*_micro_usd) — а единый денежный тип и есть то, ради чего заведены PD-15/PD-39.

Доказательство исполнением по кодогену (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). Следствие, которое обязана знать каждая следующая сессия: **любое условное требование спеки обязано получить носителя в схеме или явную проверку в хендлере** — генерированный код его не несёт. ⚠ Пример, на котором это правило было записано, УСТАРЕЛ 22.08: сама ручка решений снята вместе с пер-термной моделью подписи (D39.144, слово владельца 22.08), поэтому у констрейнтаbank_decisions_approve_has_dst` больше нет ни одного писателя в Go. Правило от этого не меняется — меняется только его иллюстрация.

Оставляем как есть: 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 — церемония на этом размере.

Базовые линии безопасностиENGINEERING_STANDARDS.md §2. ⚠ Поправка скептика, из-за которой карта глав там именно такая: ресёрч цитировал нумерацию ASVS 4.0 и пропустил НОВУЮ главу V10 «OAuth and OIDC», написанную ровно под наше направление. Сверх стандарта: govulncheck-гейт, пины Go-модулей с sumdb, Dependabot — да (граф крошечный, шума нет).

Тест-пол. Четыре его пункта исполнены и живут нормой, а не планом: тест на РЕАЛЬНО сконфигурированном http.Server вместо mux под httptest (PD-2, PD-9), инвариант сессии НЕЗАВИСИМЫМ оракулом (PD-1), фаззинг NDJSON-декодера (PD-10) — эти строки регистра fixed, каждая со своим пином; дисциплина посадок мутаций — ENGINEERING_STANDARDS.md §3. Полом остаётся только пятый пункт: контрактные тесты против OpenAPI-спеки и нагрузочные (k6) — при первых живых ручках, не раньше.

Деплой: одна VM + systemd-юниты, бинари артефактами CI; Postgres сперва там же, управляемый — при первой выручке. Не Kubernetes: дети-tmctl живут ЧАСАМИ, держат эксклюзивный лок на файлах книги на локальном диске, и любой оркестратор, способный переселить под посреди прогона, нам враждебен. Юниты написаны — deploy/tmplatformd.service, и они же ответ на PD-13 (осиротевшие процессы движка); cgroup-поддерево прогона — STACK_DECISIONS.md §1516.

4. Скорость: что это требует от платформы и контракта

Замер снял главный страх: пер-главный объём крошечный — ~3.4 тыс. символов исходника + ~10.2 тыс. перевода ≈ 27 КБ строк UTF-16 на главу, а контракт уже читает юниты ПО ГЛАВЕ, читает банк ДЕЛЬТОЙ (?after_version=) и отдаёт экспорт ссылкой. Риск не в экране чтения, а в четырёх местах, где сегодняшняя форма вынудила бы клиента держать книгу целиком. Требования, которые платформа обязана исполнить:

  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. Персист манифеста глав — ИСПОЛНЕН (таблица chapters, миграция 00002_readmodel.sql): только он держит чтения на read-модели вместо 1.41.5 с ре-ингеста движка на книге 23 МБ.

Бюджеты (в батарею фронта по мере появления экранов): DOM ≤1400 узлов на тяжёлом экране · виртуализованный список ≤60 строк независимо от размера коллекции · медиана кадра прокрутки ≤17 мс, p95 ≤33 мс · ни одной длинной задачи >200 мс на взаимодействие · страница списка ≤300 КБ · стартовый JS-роут ≤200 КБ gzip · ≤4 применения событий в секунду. ⚠ Инструмент — расширение уже существующего Playwright-харнесса (CDP-метрики + longtask), библиотеку web-vitals НЕ брать: RUM в MVP нет, потребителя у неё не существует.