textmachine/platform/README.md

182 lines
23 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.

# platform — control plane (SaaS-слой)
Зона записи сессии «Платформа». Что построено и каким паком — секция «Что здесь будет» ниже и
ВЕРХ зонного журнала: он обратно-хронологический, свежее — выше (раздел «Состояние эры P8» в конце файла — ИСТОРИЯ, не состояние). Направление зоны — `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` §1520.
## Куда смотреть, чтобы понять состояние зоны
| Вопрос | Ответ лежит здесь |
|---|---|
| что сделано последним паком и зачем | `docs/platform-PROGRESS.md`, **ВЕРХ файла** — журнал обратно-хронологический, свежее выше; отчёт последнего пака — в верхней трети, ниже могут стоять более свежие записи смены |
| статус конкретного дефекта | `docs/DEFECT_REGISTER.md` (источник истины; счёт — `python3 docs/scripts/counts.py` от корня репозитория) |
| правила, которые переживают пак и не выводятся из одной функции | `docs/STACK_DECISIONS.md` §22 (порядок блокировок), §3336 (долг материализации, форма пайплайна, атомарность) |
| что зона обязана уметь и по какой норме | `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
<f> --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` (⚠ денежные суммы на провод и на
экран НЕ идут — D39.84; пользователь видит ОСТАТОК процентом — окон со сбросом больше нет,
владелец 05.08).
## Карта зоны: где что лежит
Читать сверху вниз — это порядок, в котором запрос проходит систему.
| Пакет | Что держит |
|---|---|
| `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/exports` | дверь выдачи: приём заказа, сборка книги файлом через `tmctl build --out`, жизнь артефакта и его GC. Отдельный пакет, а не угол `runs`: здесь ничего не стоит денег и не держит кредит |
| `internal/pricing`, `internal/money` | шкала глав и целые микро-доллары |
| `internal/metrics`, `internal/reqid`, `internal/jobs`, `internal/config`, `internal/gates` | телеметрия, id запроса, очередь, конфигурация, гейты тулчейна |
Каналы движка, которые зона ЗНАЕТ (не все потребляются — см. ниже), — СЕМЬ: `tmctl manifest --json` ·
`tmctl export --json --pairs` · `<project_db>.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 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-сверка 0405.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; эндпоинты про куки не знают ничего.