textmachine/platform/docs/PLATFORM_DIRECTION.md

273 lines
36 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.**Коллизия почты — дыра, найденная скептиком:** в `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. Стандарты и стек: что взять, что не брать
> ⚠⚠ **ВЕРХНИЙ СЛОЙ 22.08 — ЧИТАТЬ ПРЕЖДЕ ВСЕГО НИЖЕ. По `sqlc` действует НЕ то решение, что записано
> в этом разделе:** владелец 20.08 сказал **БЕРЁМ** (D39.153 п.6а), а 22.08 уточнил — **отдельной
> сессией, не внутри содержательного пака** (D39.154 п.10): правок много, мешать их с другой работой
> нельзя. Граница взятия НАЗВАНА паком P8-FIX и она узкая: пять файлов без единой склейки —
> `credits.go`(15) · `identity.go`(13) · `idempotency.go`(7) · `sessions.go`(5) · `observe.go`(1) =
> **41 запрос**; остальное (25 склеек из 147, 15 фрагментов-констант, read-модель) для sqlc
> недостижимо по построению. Носитель работы — `BACKLOG.md` **П-19**. Отказ P7 ниже остаётся как
> история пака, а не как действующее решение. ⚠ Тем же паком построена ЗАМЕНА того, ради чего sqlc
> звали: гейт `pgstore.TestEverySQLStatementParsesAgainstTheMigratedSchema` планирует ЖИВЫМ Postgres
> каждый собранный запрос пакета против мигрированной схемы — то есть покрывает и склейки, которых
> sqlc не видит, и именно в них случились оба рантайм-падения зоны.
>
> ⚠ **ПЕРЕ-ПОДПИСАНО 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).
Следствие, которое обязана знать каждая следующая сессия: **любое условное требование спеки обязано
получить носителя в схеме или явную проверку в хендлере** — генерированный код его не несёт.
⚠ Пример, на котором это правило было записано, УСТАРЕЛ 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 —
церемония на этом размере.
**Базовые линии безопасности:** 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.41.5 с ре-ингеста движка на книге 23 МБ.
Бюджеты (в батарею фронта по мере появления экранов): DOM ≤1400 узлов на тяжёлом экране ·
виртуализованный список ≤60 строк независимо от размера коллекции · медиана кадра прокрутки ≤17 мс,
p95 ≤33 мс · ни одной длинной задачи >200 мс на взаимодействие · страница списка ≤300 КБ ·
стартовый JS-роут ≤200 КБ gzip · ≤4 применения событий в секунду. ⚠ Инструмент — расширение уже
существующего Playwright-харнесса (CDP-метрики + longtask), библиотеку `web-vitals` НЕ брать:
RUM в MVP нет, потребителя у неё не существует.