# platform — control plane (SaaS-слой) Зона записи сессии «Платформа». Что построено и каким паком — секция «Что здесь будет» ниже и шапка зонного журнала «Состояние зоны»; отработавшие эры вынесены срезами в `docs/archive/` и читаются только по конкретной ссылке. Направление зоны — `docs/PLATFORM_DIRECTION.md`, критерии приёмки — `docs/ENGINEERING_STANDARDS.md`, дефекты — `docs/DEFECT_REGISTER.md`, зонный журнал — `docs/platform-PROGRESS.md` (весь прогресс зоны здесь, решение владельца 04.08), стек — `docs/STACK_DECISIONS.md`. Батарея зоны: `make check` (build · vet · fmt · lint · test -race). `make vuln` и `make fuzz` — отдельными целями. `make conditions` печатает условия хоста, которые читают тесты (переменные, бинари на PATH, менеджер systemd), с их состоянием здесь и пакетами, которые каждое открывает; `check` печатает то же над списком скипов. ⚠ **Сколько у батареи условий — НЕ ЗДЕСЬ. Единственный носитель — `docs/STACK_DECISIONS.md`, раздел «Гейты батареи»**; здесь число сознательно не дублируем. Разошедшиеся копии дают ложную приёмку: сессия, честно исполнившая §3.1 по устаревшему списку, объявит «скипов ноль» при красном тесте, о котором её версия не знала. Без переменных окружения батарея МОЛЧА пропускает около трёхсот тестов и остаётся зелёной, поэтому «зелено» без сверки со списком условий не значит ничего. ⚠ **`TM_PLATFORM_LANGUAGE_PAIRS` — обязательная настройка деплоя** (форма `zh>ru,ja>ru:unavailable`): её отвечает `GET /capabilities`, по ней же интейк отклоняет неподдерживаемую пару кодом `unsupported_pair`. Какие пары существуют, решают ДАННЫЕ (пакет промптов оператора), а не список в Go. ⚠ **Пустой список отклоняет ВСЁ, а не пропускает всё** (акт 5 P7): «не объявлено ничего» и «объявлена эта пара» — разные ответы. Инстанс, принимающий загрузки без единой ДОСТУПНОЙ пары, не стартует вовсе — это второй конец того же правила, и он в силе и в бою, и в рецепте дев-стенда. Бинари: `cmd/tmplatformd` (сервис) и `cmd/tmplatformctl` (админ: гранты, КОРРЕКТИРОВКИ (`adjust`), баланс, журнал входов, отзыв сессий, дев-интейк `book add`, список книг для апгрейда движка `books` и список книг, чью читательскую поверхность построить не удалось (`books --abandoned` / `book refresh`), диагностика и терминальный вердикт по застрявшим прогонам (`runs [--stalled]` / `run abandon`, P8-FIX), снятие карантина проекции (`run unquarantine`, P13: колонка QUARANTINE в `runs` говорит, что снимать), сид дев-стенда `seed`, и `exit-marker` — его зовёт systemd на конце прогона). Что оператор делает, когда свип не справляется, — `deploy/README.md` §«Застрявшая работа». Деплой — `deploy/`. Метрики — отдельным слушателем (`TM_PLATFORM_METRICS_ADDR`, дефолт `127.0.0.1:9464`), формат Prometheus; на контрактную поверхность они не выходят и наружу не привязываются (`docs/STACK_DECISIONS.md` §24). Эффективная конфигурация печатается на старте с источником каждой настройки; секреты и денежные суммы — фактом наличия, без значения (PD-114). ⚠ **Прогон — транзиентный systemd-юнит, а не ребёнок сервиса** (D39.106). Установка требует `loginctl enable-linger`, иначе не стартует ни один прогон; почему именно так и что было измерено — `docs/STACK_DECISIONS.md` §15–20. ## Куда смотреть, чтобы понять состояние зоны | Вопрос | Ответ лежит здесь | |---|---| | что сделано последним паком и зачем | `docs/platform-PROGRESS.md` — состояние зоны и отчёт последнего пака; отработавшие эры вынесены срезами в `docs/archive/` | | статус конкретного дефекта | `docs/DEFECT_REGISTER.md` (источник истины; счёт — `python3 docs/scripts/counts.py` от корня репозитория) | | правила, которые переживают пак и не выводятся из одной функции | `docs/STACK_DECISIONS.md` §22 (порядок блокировок), §33–36 (долг материализации, форма пайплайна, атомарность) | | что зона обязана уметь и по какой норме | `docs/PLATFORM_DIRECTION.md`, `docs/ENGINEERING_STANDARDS.md` | | как это разворачивается и в каком порядке | `deploy/README.md` | | незакрытые куски работы | `BACKLOG.md` | ⚠ Рабочие записи конкретной сессии (`P7_*`, `*_HANDOFF`, планы актов) — **не состояние зоны**: они несут ход работы и полезны той же сессии после компакции. Все такие файлы уехали в `docs/archive/` при лендингах, и новые туда же; для лендинга их читать не нужно. ## ⚠ Git и зона (читать ДО первой строки кода) **Платформенная сессия не коммитит — лендит оркестратор.** Канон — `../CLAUDE.md`, §Гардрейлы; продублировано здесь, потому что зона живёт своим онбордингом. Направление зоны (вход · деньги · стандарты · скорость) — `docs/PLATFORM_DIRECTION.md`; стандарты и критерии приёмки — `docs/ENGINEERING_STANDARDS.md`; дефекты и уязвимости — `docs/DEFECT_REGISTER.md` (каждая находка получает строку ДО закрытия). 1. **Писать только внутрь `platform/`.** Ничего за её пределами — ни `docs/`, ни `backend/`, ни `frontend/`, ни корневых файлов. Нужна правка вне зоны — пинг владельцу, её сделает оркестратор. 2. **Чужие незакоммиченные файлы в дереве не трогать**: параллельные сессии — норма. 3. **НИКАКИХ** `git add -A`, `reset --hard`, `amend`/`rebase`, перезаписи истории и `checkout` поверх грязного дерева. 4. ⚠ Ревью-гард модулей (D39.85): путь Go-модуля платформы **никогда** не вкладывать под путь движка — иначе он получит доступ к `backend/internal/*` по правилу префикса. ## Что здесь будет Сервис между фронтом и движком перевода. Всё, что относится к ПОЛЬЗОВАТЕЛЯМ и не относится к переводу: - аутентификация и аккаунты — **есть (P1)**: вход через OIDC даёт только СОБЫТИЕ входа, сессия своя; ключ личности `(provider, subject)`, почта не ключ. Оплаты нет и в бете не будет (владелец 05.08): аккаунты живут на кредитном балансе, фри-тир — запись `grant` в леджер; - библиотека книг: чья книга, права доступа, хранение исходников и экспортов — **приём есть (P5)**: `POST /v0/books` принимает multipart потоково со своим потолком тела и своим дедлайном чтения, кладёт исходник в каталог книги под `TM_PLATFORM_BOOKS_DIR` и ведёт книгу по статусам `uploading → parsing → not_started | rejected`; разбор — $0-команда движка `tmctl manifest`. Стартовый `book.yaml` новой книги пишет ПЛАТФОРМА — один раз, из деплой-шаблона `TM_PLATFORM_BOOK_TEMPLATE` (форма Б, D39.130; построено P6). Дальше файл принадлежит оператору: платформа его не читает и не перезаписывает (D39.110 §2b в силе). Шаблона нет или он битый — книга ЖДЁТ человека, а не отклоняется; - учёт денег **на пользователя** — **схема и операции есть (P1)**: append-only леджер в целых микро-долларах, резервации, кэш баланса с инвариантом `balance == SUM(ledger)`. Защита прогона — холд ДО спавна плюс книжный потолок движку (жёсткий стоп исполняет движок). ⚠ Потолок, который получает движок, — это НАКОПЛЕННЫЙ потолок КНИГИ (`committed + reserved` за всю её историю плюс купленный прирост), а не бюджет прогона: платформа переводит одно в другое сама (D39.122); - остановка и продолжение перевода — **есть (P5)**: `POST /v0/runs/{id}/stop` пишет НАМЕРЕНИЕ стопа в Postgres до сигнала (иначе стоп и авария — один и тот же выход движка, PD-152) и просит systemd; `/resume` открывает новую попытку с ОСТАТКОМ бюджета прогона, переиспользуя механику перезапуска реконсилятора; - очередь задач и запуск воркеров, статусы прогонов, ретраи — **есть (P4)**: River на том же Postgres; задание очереди выдаёт только РАЗРЕШЕНИЕ стартовать, а жизнь прогона ведёт реконсилятор, который читает мир (Postgres · журнал книги · маркер выхода) и не ждёт процесса; - читающая поверхность контракта — **есть (P7)**: дерево глав и пары с текстом, замечания, ЧТЕНИЕ банка, `GET /capabilities`, машинная модель ошибок (`code` + `request_id`), условные чтения (`ETag`/304) и сжатие JSON, `Idempotency-Key` на трёх создающих вызовах. ⚠ Снята ПЕР-ТЕРМНАЯ МОДЕЛЬ ПОДПИСИ (22.08, `PD-370`, D39.144: подпись — это `resume`), а НЕ правка термина: дверь `POST /books/{bookId}/bank/corrections` построена паком P9 — `httpapi/bank.go`, маршрут в `contractSurface` (`httpapi/v0.go`), канон `14-api-contract/openapi.yaml`, акты D39.161/162/166, монтаж по `Capabilities.bank_corrections_enabled`; - ВЫДАЧА КНИГИ ФАЙЛОМ — **есть (04.09, пак «закрыть цикл»)**: `POST /books/{bookId}/exports` принимает формат из `export_formats`, отвечает `202` с адресом статуса в `Location` и строит асинхронно (River); `GET .../exports/{exportId}` — поллинг, который ВСЕГДА кончается (`pending` → `ready` → `expired`, либо `pending` → `failed`) и несёт `Retry-After`, пока сборка идёт; сам файл лежит по третьему адресу, `.../content`. ⚠ **Дверь СТРОИТ, а не подбирает**: зовёт `tmctl build --format --out <свой путь> --partial` и никогда не отдаёт файл, лежащий рядом с БД движка (там копия ПРЕЖНЕЙ сборки — D39.175 п.2). ⚠ **И строит ВСЕГДА** (D39.178 п.1, слово владельца 30.08): книга с дырами уходит С ПОМЕТКОЙ на первой странице и знаком на каждой дыре, отказ читателю не отдаётся; отказ по умолчанию (exit 16) остаётся операторской ручкой CLI. ⚠ **Ссылка АУТЕНТИФИЦИРОВАННАЯ, а не подписанная** — довод в шапке `internal/httpapi/exports.go`: она строго сильнее капабилити-токена (утёкшая ссылка бесполезна никому, кроме владельца) и не требует деплойного секрета, который никто не ротирует. Артефакт живёт `TM_PLATFORM_EXPORT_TTL`, потом свип забирает и строку, и байты; `BuildReport` (дрейф конфигурации, непроверяемая свежесть) уходит ОПЕРАТОРУ в лог, не читателю; - SSE-поток прогресса во фронт — **есть (P7)**: поток на КНИГЕ (не на прогоне), кадры минтит писатель в свою транзакцию, клиент продолжает по `Last-Event-ID` (⚠ **испр. 05.09: запрет «денег на проводе» ОТОЗВАН владельцем**, `D39.196` п.2 — баланс, потолок заказа и холд выходят ДЕНЬГАМИ на форме заказа; процент остатка живёт рядом как СИГНАЛ «мало/пусто», а не как замена суммы. Запрещены по-прежнему цены моделей, стоимость стадий и вызовов и структура наших расходов — ПТ-33/ПТ-35). ## Карта зоны: где что лежит Читать сверху вниз — это порядок, в котором запрос проходит систему. | Пакет | Что держит | |---|---| | `cmd/tmplatformd` | демон: сборка зависимостей, очередь, свипы. `runner.go` — единственное место, где зона склеивается | | `cmd/tmplatformctl` | админ-CLI и `exit-marker`, который systemd зовёт на конце прогона | | `internal/httpapi` | ВСЯ контрактная поверхность: `v0.go` (маршруты, библиотека, интейк, прогоны) · `reading.go` (главы, пары, замечания, банк) · `stream.go` (SSE) · `capabilities.go` · `problem.go` (модель ошибок) · `conditional.go` (ETag/304 + gzip; SSE через него НЕ проходит — потому и не сжимается) · `idempotency.go` · `project.go` (переводы словарей: read-модель → провод) · `bank.go` (дверь правок банка) · `exports.go` (дверь выдачи книги файлом) | | `internal/auth`, `internal/login` | сессии, CSRF, вход. Принципал создаётся ТОЛЬКО в мидлваре | | `internal/books` | интейк: приём файла, каталог книги, рендер стартового `book.yaml`, разбор через `tmctl manifest` | | `internal/runs` | жизнь прогона: допуск, спавн транзиентного юнита, реконсилятор, деньги на границе попытки | | `internal/readmodel` | материализатор читающей поверхности: манифест + экспорт + сайдкар банка → read-модель. Зовётся на ГРАНИЦАХ работы (конец интейка, конец прогона) — там же, где контракт объявляет свежесть перевода. Он же держит ОЧЕРЕДЬ долгов (`Drain`): граница ставит долг колонкой на книге, платящий берёт его в аренду, неоплаченный едет в конец очереди | | `internal/ingest` | словарь шва с движком: NDJSON-события, коды выхода, декодеры манифеста/экспорта/банка. Здесь же переводятся словари движка в контрактные | | `internal/runner` | как зовётся движок: транзиентный юнит, argv команд, маркер выхода, файловые сайдкары | | `internal/pgstore` | вся SQL. `readmodel.go` — чтения и проекции, `events.go` — буфер кадров потока, `sink.go` — материализатор шва, `credits.go` — деньги | | `internal/backup` | точки восстановления: `pg_dump` денежного реестра + движковый `tmctl backup` на каждую книгу + манифест с sha256 каждого файла, публикация переименованием из `.partial-`. Отдельный пакет и СВОЯ горутина, а не пасс свипа: такт реконсилятора последователен, и копирование целых баз задержало бы расчёт денег на всю свою длину. Восстановление — руками по рантбуку, команды `restore` здесь нет намеренно | | `internal/exports` | дверь выдачи: приём заказа, сборка книги файлом через `tmctl build --out`, жизнь артефакта и его GC. Отдельный пакет, а не угол `runs`: здесь ничего не стоит денег и не держит кредит | | `internal/pricing`, `internal/money` | форма заказа поверх ПРОЕКЦИИ ДВИЖКА (шкалы глав и per-chapter ставки больше нет — строка 280) и целые микро-доллары | | `internal/metrics`, `internal/reqid`, `internal/jobs`, `internal/config`, `internal/gates` | телеметрия, id запроса, очередь, конфигурация, гейты тулчейна | Каналы движка, которые зона ЗНАЕТ (не все потребляются — см. ниже), — СЕМЬ: `tmctl manifest --json` · `tmctl export --json --pairs` · `.bank.json` · `tmctl status --json` · `events.jsonl` · `tmctl build` · `tmctl bank-apply`. ⚠ Слов «и других нет» здесь нет намеренно, и второй копии перечня здесь тоже нет: атомарность каждого канала, колонка «потребляет ли платформа» и все оговорки лежат ЕДИНСТВЕННЫМ носителем в `docs/STACK_DECISIONS.md`, «Инвентарь каналов движка». Оттуда же два правила, которые дороже перечня: `tmctl build` (D39.175) читается с 04.09 — `internal/runner/build.go` → `internal/exports`, — и дверь `createExport`/`getExport` СТРОИТ, а не подбирает лежащий рядом файл: там копия прежней сборки (D39.175 п.2); `tmctl backup` читается с 05.09 — `internal/runner/backup.go` → `internal/backup`, — и это ВОСЬМОЙ движковый канал зоны; ⚠ единственный, чей ответ разбирается из ЧЕЛОВЕЧЕСКОЙ строки stdout, потому что у глагола нет ни `--out`, ни JSON-выхода (`PD-449`). `tmctl bank-apply` — ЕДИНСТВЕННЫЙ канал, по которому зона ПИШЕТ в проект движка, и потому у него своя дверь (`POST /books/{bookId}/bank/corrections`) и свой класс отказов. Живой SQLite движка не читается никогда (D39.85). ## Чего здесь НЕ будет Перевода. Движок (`../backend/`) остаётся как есть: один процесс на книгу, свой SQLite под эксклюзивным flock (`store.Open` — «один процесс владеет файлом проекта»). Платформа его ЗАПУСКАЕТ как воркер, а не поглощает. Причина та же, по которой движок не ветвится по паре языков: пользователи и квоты ничего не меняют в проводе запроса, значит им нечего делать в кодовой базе, где каждый байт свёрнут в снапшот-хеш. ## Известное требование к движку (не забыть) ⚠ **Глобальный брокер конкурентности** — единственная по-настоящему новая механика на стыке: `pipeline/ratelimit.go` строит рейт-гарды НА ПРОГОН, а лимит провайдера — на весь аккаунт. Постановка, замер и вес — строка `BACKLOG.md` П-2. ## Стек Пины, даты релизов и обоснования — [`docs/STACK_DECISIONS.md`](docs/STACK_DECISIONS.md) (зонный, live-сверка 04–05.08); общая записка по обоим новым сервисам — `../frontend/docs/STACK_DECISIONS.md` §5. Коротко: floor ЯЗЫКА в `go.mod` общий с движком, `toolchain` там же поднимает тулчейн выше (D39.130; `make version-check` СРАВНИВАЕТ версии, а не матчит) · стандартный `net/http` + `ServeMux` без роутер-библиотеки · PostgreSQL 18 + pgx · goose · очередь River на том же Postgres — **подключена и работает** (P4) · вход `x/oauth2` + `go-oidc/v3` · `x/time` для лимита на `/auth/login` · `govulncheck` отдельной целью. ⚠ Номера версий здесь намеренно не дублируются: их единственные носители — `go.mod` и таблица пинов строкой выше. **Redis не заводим нигде** — зафиксировано как архитектурное «нет», **иначе он приползёт по частям**: очередь, лизы и рейт-лимиты живут в том же Postgres. Прогресс наружу — SSE, события **пушит воркер**, а не фронт опрашивает read-model. Аутентификация — одна серверная сессия в Postgres, два способа предъявления: `__Host`-кука для браузера и `Authorization: Bearer` для десктопа и CLI; эндпоинты про куки не знают ничего. ⚠ **Откуда БЕРЁТСЯ Bearer (с 05.09):** его выдаёт оператор — `tmplatformctl token issue --user `; это обычная серверная сессия с теми же двумя сроками и тем же отзывом (`revoke --user`), плейнтекст печатается ОДИН раз, в БД только дайджест. До этого схему принимал сервер и не выдавал никто, и канон писал это про себя прямым текстом — то есть не-браузерный клиент войти не мог вообще (строка бэклога 270). Самообслуживаемой выдачи нет намеренно: кнопку нажимать некому, пока фронт заморожен.