textmachine/platform/docs/PLATFORM_DIRECTION.md

226 lines
28 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.

# Направление платформы: аутентификация, деньги, стандарты, скорость
> Ратифицировано оркестратором №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 и БЕЗ
уникального индекса, неизвестная пара всегда создаёт НОВЫЙ аккаунт. Цена и разбор —
`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 |
**Две трения `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 нет, потребителя у неё не существует.