# Развёртывание платформы Одна VM, systemd, бинари артефактами CI (`PLATFORM_DIRECTION.md` §3). Не Kubernetes: дети-`tmctl` живут часами и держат эксклюзивный лок на файлах книги на локальном диске — оркестратор, способный переселить под посреди прогона, этой нагрузке враждебен. ## Файлы - `tmplatformd.service` — юнит контрольной панели. Тело юнита проверено `systemd-analyze verify` (systemd 259) — exit 0, без замечаний. ⚠ Проверять надо с ПОДСТАВЛЕННЫМ существующим `ExecStart=`: дословно юнит даёт exit 1, потому что `verify` проверяет и наличие бинаря, а `/usr/local/bin/tmplatformd` на стенде нет. ⚠ **юнит целиком под systemd не запускался** — нет sudo и нет бинаря на стенде. Отдельные свойства песочницы при этом проверены ЖИВЫМ прогоном в пользовательском systemd 259 (`systemd-run --user --wait`), а не вычитаны из доки: `ProtectSystem=strict` + `ReadWritePaths=` на несуществующем пути → **`226/NAMESPACE`, юнит не стартует**; тот же путь созданным → `0/SUCCESS`; префикс `-` на несуществующем → `0/SUCCESS` (строка игнорируется — потому мы её и не префиксуем); `ProtectHome=yes` → `/home` пуст, чтение `/home/` даёт `Permission denied`; `ProtectHome=tmpfs` + `BindPaths=<каталог>` → каталог виден. Ресурсные потолки (`MemoryMax=80%`, `OOMPolicy=continue`) живьём не проверялись — вывод из `systemd.resource-control(5)`/`systemd.service(5)`. ## Откат релиза: не ниже версии 15 ⚠ **Граница была названа «5» и это уже неверно.** `goose down` до версии 14 и ниже НЕ РАБОТАЕТ на живой базе: down-путь `00015` сужает `runs_paused_reason_check` обратно к одному `credit_exhausted`, а боевой код пишет ещё два значения — `daily_ceiling` и `ceiling_unknown` (оба через `internal/pgstore/books.go` `CeilingPause`). Значит `DownTo(<15)` на базе, где такая пауза случалась, падает РАНЬШЕ, чем дойдёт до `00005`. Носитель — открытый ряд `PD-218`. Ниже 5 не работает и подавно: down-путь `00005` восстанавливает `users_email_key` и `email NOT NULL`, а обе формы нарушают строки, которые пишет боевой код (неподтверждённая личность даёт `email = NULL`; один адрес законно принадлежит двум аккаунтам). Откат транзакционный, поэтому падение ничего не портит — но планировать откат ниже 15 нельзя, план отката — накатить вперёд. Разбор: `docs/STACK_DECISIONS.md` §8. ## Что юнит закрывает содержательно - ⚠ **ПЕРЕ-ДИСПОЗИЦИЯ (P4, D39.106): прогон НЕ ребёнок этого юнита и не живёт в его cgroup.** Каждый прогон — транзиентный юнит в СОБСТВЕННОМ systemd-менеджере пользователя `tmplatform`, в срезе `tm-runs.slice`, потому что перевод обязан пережить деплой платформы. Следствия для установки: обязателен `loginctl enable-linger tmplatform`; юниту нужен доступ к своей же пользовательской шине (`ProtectHome=tmpfs` + `BindPaths=/run/user/%U` + `DBUS_SESSION_BUS_ADDRESS`); ресурсные потолки НИЖЕ ограничивают только контрол-плейн. - **PD-13 (осиротевшие процессы движка)** закрыт теперь ТАМ, где прогон: собственным cgroup прогона (`TM_PLATFORM_RUN_MEMORY_MAX`/`_TASKS_MAX`), и это ИЗМЕРЕНО — в дефолтном `app.slice` пользовательского менеджера лимиты принимаются и не применяются (`STACK_DECISIONS` §16). Дев-путь супервизора с группой процессов остаётся дев-путём. - **`TimeoutStopSec=90`** ограничивает остановку САМОГО ДЕМОНА: дренаж HTTP плюс останов очереди, с запасом. ⚠ Прежняя редакция объясняла его «grace движка 30 с» — такой грации нет: `stopGrace` равен 10 МИНУТАМ (`internal/runner/runner.go:59`=`const stopGrace = 10 * time.Minute`) и штампуется как `TimeoutStopSec` на ТРАНЗИЕНТНОМ юните прогона, а не на этом. ⚠ Но и обратное неверно: четыре движковых вызова — `manifest`/`status`, сборка экспорта, `bank-apply` и сам `systemd-run` — идут ПРЯМЫМИ ДЕТЬМИ демона, лежат в его cgroup, и этот таймаут на них распространяется. Разбор — комментарий в `deploy/tmplatformd.service` над строкой `TimeoutStopSec=`. - **Секреты через `LoadCredential=`,** а не через окружение: переменная окружения видна в `/proc//environ` и наследуется каждым ребёнком-`tmctl`. Конфиг читает `*_FILE` первым. ## Установка (набросок, исполняется владельцем) ```sh useradd --system --home-dir /srv/textmachine tmplatform # ⚠ Каталог создаём ЯВНО: --system не создаёт домашний каталог, а `ReadWritePaths=/srv/textmachine` # при `ProtectSystem=strict` на несуществующем пути валит сборку mount-namespace — юнит не стартует # вовсе (PD-91, systemd.exec(5)). Это первая команда, которую пропускают, читая набросок сверху вниз. install -d -m0750 -o tmplatform -g tmplatform /srv/textmachine install -D -m0755 tmplatformd /usr/local/bin/tmplatformd install -D -m0755 tmplatformctl /usr/local/bin/tmplatformctl # ⚠ КЛИЕНТСКИЕ ТУЛЫ POSTGRES — их ставит НЕ пакет базы, и на хосте контрол-плейна их обычно нет # вовсе (база живёт отдельно). Без них точки восстановления НЕ СНИМАЮТСЯ. Замер: рецепт, исполненный # дословно в чистом контейнере, дал здоровый на вид инстанс, чей первый бэкап-пасс упал # `exec: "pg_dump": executable file not found in $PATH`. # ⚠ Отказ виден и БЕЗ отдельной строки на буте — первый проход идёт при старте, — но говорит он # только СИМПТОМ; строка демона называет ТУЛ и правило мажоров, то есть лекарство. # ⚠ МАЖОР обязан совпадать с мажором СЕРВЕРА: pg_dump отказывается снимать дамп с сервера новее себя. apt-get install -y postgresql-client-18 # или postgresql18 из репозитория PGDG — по мажору сервера # ⚠ ДЛЯ БОЕВОГО ДЕПЛОЯ ОБЯЗАТЕЛЬНО. Прогоны — транзиентные юниты в пользовательском менеджере # `tmplatform`, а он ПЕРЕЖИВАЕТ выход из сессии только при включённом linger. # ⚠ Формулировка «без этого не стартует НИ ОДИН прогон» снята 04.09 как ложная для стенда: в # ЖИВОЙ сессии менеджер уже есть, и замер зоны (H2) дал `Linger=no` при шести стартовавших # прогонах. Сессия, честно исполнившая прежнюю проверку, решала, что стенд сломан. # Проверка: `systemctl --user -M tmplatform@ is-system-running` отвечает, а не «Failed to connect». loginctl enable-linger tmplatform # Движок кладётся по ВЕРСИОНИРОВАННОМУ пути: попытка прогона пиннится к тому, с которого началась # (строка 139), поэтому выкат новой версии не подменяет исполняемое у идущего прогона. install -D -m0755 tmctl /opt/textmachine/engine/<версия>/tmctl install -d -m0700 -o root -g root /etc/tmplatform printf '%s' 'postgres://...' > /etc/tmplatform/dsn && chmod 0400 /etc/tmplatform/dsn printf '%s' '' > /etc/tmplatform/oidc_client_secret && chmod 0400 /etc/tmplatform/oidc_client_secret ``` `/etc/tmplatform/env` — несекретное окружение. ⚠ **Это файл для `EnvironmentFile=` systemd, а НЕ шелл-скрипт: `source /etc/tmplatform/env` его ЛОМАЕТ.** systemd разбирает `KEY=VALUE` сам и шелла не зовёт, поэтому `zh>ru` для него — значение; шелл прочтёт `>` как ПЕРЕНАПРАВЛЕНИЕ, создаст рядом файл `ru` и оставит `TM_PLATFORM_LANGUAGE_PAIRS=zh`, после чего демон откажется стартовать, жалуясь на пары. Поймано исполнением рецепта в контейнере 05.09. Проверять окружение — `systemctl show tmplatformd -p Environment`, а не `source`: ``` TM_PLATFORM_ADDR=127.0.0.1:8080 TM_PLATFORM_TRUSTED_ORIGINS=https://app.example.org TM_PLATFORM_OIDC_ISSUER=https://accounts.google.com TM_PLATFORM_OIDC_CLIENT_ID=... TM_PLATFORM_OIDC_REDIRECT_URL=https://app.example.org/auth/callback TM_PLATFORM_AFTER_LOGIN=/library TM_PLATFORM_LANGUAGE_PAIRS=zh>ru TM_PLATFORM_BOOKS_DIR=/srv/textmachine/books TM_PLATFORM_BOOK_TEMPLATE=/srv/textmachine/book-template.yaml TM_PLATFORM_METRICS_ADDR=127.0.0.1:9464 # ⚠⚠ ДОПИСАНО 29.08, ПЕРЕ-СНЯТО 04.09. Прежняя редакция говорила «без этих четырёх инстанс не # запустит ни одного перевода» — неверно для ДВУХ из четырёх, разбор ниже под блоком. Держи их # все, но знай, ЧТО именно ломает каждая: считать обязательными все четыре дешевле не выходит — # оператор, увидев, что без CTL_BIN всё работает, перестаёт верить и остальным трём. TM_PLATFORM_ENGINE_BIN=/opt/textmachine/engine/<версия>/tmctl # пусто = инстанс только читает TM_PLATFORM_CTL_BIN=/usr/local/bin/tmplatformctl # его зовёт юнит как ExecStopPost TM_PLATFORM_STATE_DIR=/var/lib/tmplatform/state # маркеры выхода; ТОЛЬКО абсолютный TM_PLATFORM_ENGINE_KEYS_PATH=/etc/tmplatform/engine-keys # KEY=VALUE, едет --keys-file # Дверь выдачи книги файлом (04.09). НЕОБЯЗАТЕЛЬНА: пусто = дверь не смонтирована и # `export_formats` пуст — клиент видит это на /capabilities и кнопки не рисует. TM_PLATFORM_EXPORT_FORMATS=epub,txt # что УМЕЕТ ЭТОТ движок; порядок = порядок в /capabilities TM_PLATFORM_EXPORTS_DIR=/var/lib/tmplatform/exports # ТОЛЬКО абсолютный; пусто = /exports TM_PLATFORM_EXPORT_TTL=24h # сколько живут артефакт и ссылка # ⛔ ТОЧКИ ВОССТАНОВЛЕНИЯ (05.09). Пусто = деплой НЕ ХРАНИТ НИ ОДНОЙ КОПИИ оплаченной работы и # денежного реестра; демон говорит это WARN'ом на каждом старте. Разбор — раздел «Бэкап и # восстановление» ниже, там же требование к УСТРОЙСТВУ (другой диск) и рецепт восстановления. TM_PLATFORM_BACKUP_DIR=/var/backups/tmplatform # ТОЛЬКО абсолютный; пусто = точек нет TM_PLATFORM_BACKUP_EVERY=6h # как часто снимается точка TM_PLATFORM_BACKUP_KEEP=14 # сколько поколений живёт: 14 × 6h = 84 ч, значит самая # старая ЖИВАЯ точка — 13 интервалов, ≈ 3,25 суток TM_PLATFORM_PGDUMP_BIN=pg_dump # МАЖОР обязан совпадать с сервером TM_PLATFORM_PGRESTORE_BIN=pg_restore ``` ⚠ **Что из этой четвёрки ДЕЙСТВИТЕЛЬНО гейтит прогон — одна переменная, а не четыре.** Остальные три ломаются иначе, и знать чем дешевле, чем считать их все обязательными: - **`ENGINE_BIN`** — ЕДИНСТВЕННАЯ, чьё отсутствие останавливает прогоны прямо: пусто ⇒ инстанс объявляет себя читающей репликой, не спавнит ничего и не принимает загрузок (`cmd/tmplatformd/runner.go:71`=`the library is served read-only`); - **`CTL_BIN`** — путь, который юнит зовёт в `ExecStopPost`, чтобы записать маркер выхода. ⚠ **ПУСТО — НЕ отказ:** берётся `tmplatformctl` РЯДОМ с демоном (`internal/config/config.go:528`=`MarkerBinary: l.env("TM_PLATFORM_CTL_BIN", "")`, сиблинг-дефолт `cmd/tmplatformd/runner.go:601`=`bin = filepath.Join(filepath.Dir(self), "tmplatformctl")`), а рантбук ставит его туда же, куда и демона (`install -D -m0755 tmplatformctl /usr/local/bin/tmplatformctl` в блоке установки выше). Отказывает **НЕВЕРНЫЙ** путь: `os.Stat` не находит его на буте (`runner.go:87` — WARN, не паника), `MarkerArgv` остаётся пустым, и каждый старт/резюм отвечает `503` (`internal/runs/runs.go:415`=`func (s *Service) runnable() error {` `ErrRunnerIncomplete` → `internal/httpapi/v0.go:997`=`errors.Is(err, runs.ErrRunnerIncomplete)`). ⚠ Прежняя редакция объясняла его ОТСУТСТВИЕ симптомом «прогон завершается, а платформа не узнаёт» — такого исхода в коде нет: ровно чтобы он не наступил, зона и отказывает на старте (`internal/runs/runs.go:151`=`ErrRunnerIncomplete is a DEPLOYMENT that cannot start runs`); - **`STATE_DIR`** — каталог маркеров. ⚠ **Прежняя редакция объясняла его ложно** («дефолт вне `ReadWritePaths=`, запись запрещена файловой системой»): дефолт — `/var/lib/tmplatform` (`internal/config/config.go:527`=`StateDir: l.env("TM_PLATFORM_STATE_DIR", "/var/lib/tmplatform")`), а это ровно то, что юниту даёт `StateDirectory=tmplatform` (строка `StateDirectory=tmplatform` в `deploy/tmplatformd.service`), — systemd создаёт каталог и делает его записываемым ПОД `ProtectSystem=strict`, а сам маркер пишет `ExecStopPost` ТРАНЗИЕНТНОГО юнита прогона, на котором песочницы нет вовсе. ⚠ Задавать явно всё же стоит, но по ТРЁМ ДРУГИМ причинам, и они настоящие: путь обязан быть абсолютным (демон отказывает на старте, `internal/config/config.go:549`=`TM_PLATFORM_STATE_DIR must be an absolute path`) · каталог обязан принадлежать пользователю ПРОГОНОВ — по его владельцу админ-CLI отличает свой systemd от чужого и без этого отказывается судить о судьбе прогона (`cmd/tmplatformctl/runs.go:360`=`notTheRunsOwnManager` `notTheRunsOwnManager`) · и он не должен меняться при живых прогонах: смена осиротляет exit-маркеры идущих (`PD-155`); - **`EXPORT_FORMATS`** — не из четвёрки и не обязательна, но у неё своя ловушка: это ДЕКЛАРАЦИЯ ОПЕРАТОРА о том, что умеет РАЗВЁРНУТЫЙ здесь движок, а не открытие. Список форматов живёт Go-переменной внутри движка, импортировать который платформе запрещено (`D39.85`), и $0-команды, публикующей его данными, у движка нет. Объявите формат, которого этот движок не знает, — и заказ на него вернётся ОТКАЗАВШИМСЯ экспортом с кодом `deployment_error`: честно, но чинить это вам. ⚠ `EXPORTS_DIR` абсолютен по причине острее, чем у `STATE_DIR`: путь уходит движку аргументом `--out`, а движок работает с каталогом КНИГИ как рабочим, поэтому относительный писал бы артефакты внутрь чужого проекта. ⚠ **И он обязан быть ЗАПИСЫВАЕМ ВНУТРИ ПЕСОЧНИЦЫ ЮНИТА** — для `BOOKS_DIR` эта оговорка написана, для экспорта её не было. Дефолт `/exports` лежит под `StateDirectory=tmplatform` и потому пишется; любой путь вне его надо добавить в `ReadWritePaths=`. ⚠ Симптом НЕ тот, которого ждёшь: каталог создаёт САМ ДЕМОН НА БУТЕ (`cmd/tmplatformd/runner.go:280`=`exports directory %s: %w`), и невозможность создать — **ОТКАЗ СТАРТА** демона (`exports directory <путь>: …`), а не отказ отдельного экспорта. Каталог, который есть, но доступен только на чтение, роняет создание подкаталога КНИГИ на первом же экспорте (`internal/exports/exports.go:358`=`os.MkdirAll(filepath.Dir(out), 0o750)`), и код отказа там `build_failed`, а не `deployment_error`; - **`ENGINE_KEYS_PATH`** (четвёртая из «четвёрки»; **старту НЕ мешает** — демон пишет один WARN и поднимается здоровым, `cmd/tmplatformd/runner.go:99`=`no TM_PLATFORM_ENGINE_KEYS_PATH`, принимает книгу и БЕРЁТ ХОЛД, а падает первый платный вызов, то есть уже после резервирования денег пользователя — замер H1 зоны) — ЕДИНСТВЕННЫЙ канал провайдерских ключей в движок (едет аргументом `--keys-file`, мимо процесса платформы и мимо окружения юнита). Без него движок ищет `.env` рядом с `book.yaml`, которого SaaS-путь не пишет, и каждый платный прогон падает `exit 10` («missing API keys»). На буте это WARN, а не отказ, — то есть тихо. ⚠ **`TM_PLATFORM_SIGNUP_GRANT_USD` здесь НЕТ намеренно** (PD-104, слово владельца 16.08): на бете грант по умолчанию НОЛЬ и начисляется руками — `tmplatformctl grant`. Строка `=5` в этом блоке включала обратно ровно тот самообслуживаемый безлимитный грант, который дефолт выключает. ⚠ **`TM_PLATFORM_LANGUAGE_PAIRS` обязателен, и правило ЖЁСТКОЕ** (акт 5 P7). Инстанс, который ПРИНИМАЕТ ЗАГРУЗКИ и не объявил ни одной ДОСТУПНОЙ пары, **не стартует вовсе** (`internal/config/config.go`, проверка `IntakeEnabled() && len(AvailablePairs()) == 0`); объявленная, но недоступная пара отклоняется интейком кодом `unsupported_pair`. То есть «не объявлено ничего» и «объявлена эта пара» — разные ответы, и деплой больше не может принять книгу, которую он способен только провалить. ## Шаблон книги (`TM_PLATFORM_BOOK_TEMPLATE`) — артефакт ОПЕРАТОРА Загруженной книге нужен `book.yaml`, и пишет его платформа — ОДИН раз, при первом разборе, из этого шаблона (форма Б, D39.130). Дальше файл принадлежит оператору: платформа его не читает и никогда не перезаписывает (`O_EXCL`), поэтому правка потолка или пайплайна в конкретной книге переживает всё. Платформа подставляет ровно то, что знает только она: `book_id` · `title` · `source_lang` · `target_lang` · `source_file`. Всё остальное едет из шаблона нетронутым — включая ключи, о которых эта версия платформы не слышала, и включая `genre`: он ушёл из контракта и из интейка в 0.3.0 (Б-23), поэтому теперь это значение ОПЕРАТОРА и платформа его не трогает. ```yaml # /srv/textmachine/book-template.yaml pipeline: /opt/textmachine/configs/pipeline-c1.yaml models: /opt/textmachine/configs/models.yaml langpack_root: /opt/textmachine/configs/langpacks audience: взрослые читатели вебновелл venuti: 0.6 honorifics: keep transcription: palladius footnotes: minimal ceilings: book_usd: 25 ``` ⚠ **Пути внутри шаблона — АБСОЛЮТНЫЕ.** Движок резолвит относительный путь от каталога КНИГИ, а не от каталога шаблона, поэтому `../configs/pipeline.yaml` в шаблоне указывает на разные файлы для каждой книги и ни на один из них — правильно. ⚠ **`ceilings.day_usd` в шаблоне НЕ НУЖЕН — и в примере его нет намеренно** (решение владельца 15.08). Трату прогона уже жёстко ограничивает купленный пользователем объём: платформа держит холд и передаёт движку `--ceiling-usd`, флаш к этому холду. Дневная ось поверх этого — второй лимит на ту же трату, и стоит он дороже, чем даёт: остановленный им прогон приезжает `paused` (не `failed`), но продолжить его платформа не может — `resume` отвечает 409, потому что следующая попытка упёрлась бы в тот же лимит в тот же день, а пользователю эта пауза необъяснима: лимит не его и он его не видит. Движка это не касается — `ceilings.day_usd` у него остаётся, и вписать его в конкретную книгу руками оператор вправе. Именно на этот случай платформенная обработка `daily_ceiling`/409 из P6 и остаётся предохранителем: она делает такую остановку различимой и честной, а не дефолтом, который её вызывает. Битый или отсутствующий шаблон — **беда деплоя, а не книги**: загрузки не отклоняются и не удаляются, книги ждут в `parsing` (метрика `tm_platform_books_in_intake{status="parsing"}`), в логе — ERROR с причиной. Починили файл — ближайший свип разбирает всё накопившееся. ## ⛔ Артефакт контраста: положить ДО первой выкатки, иначе прогоны отказаны Боевой конфиг пайплайна включает банковый контур, а у контура ровно ОДИН вход, которого **нет в git и не должно быть**: словарь частотности слов ИСХОДНОГО языка, `mining.contrast_path` (`backend/configs/pipeline-c1.yaml`, греп `contrast_path` — там же движковая половина разбора, здесь она не дублируется). **Что будет без него.** Загрузка конфига проходит: ключ задан, а ФАЙЛ проверяется отдельно и только на ПИШУЩЕМ пути (`backend/internal/config/pipeline.go`, греп `CheckMiningContrast`; зовётся из `backend/internal/pipeline/runner.go` под `if forWrite`). Значит инстанс поднимается здоровым, принимает загрузки, режет книги, строит выдачи — и **отказывает КАЖДОМУ `translate` и КАЖДОМУ резюму** классом конфигурации (`exit 10`), до того как потрачен хоть один цент. По полосе кодов это «деплой сломан, чинит человек, книги ждут» (`docs/architecture/17-seam-inbound-law.md` п.2), и пользователь видит прогон, который не начинается. ⚠ Симптом НЕ «контур не работает тихо»: тихой эта поломка была бы БЕЗ проверки — тогда движок купил бы всю черновую волну и упал на стопе майнинга. Проверка сделана ровно чтобы этого не было, и её цена — отказ на старте, который надо уметь прочитать. **Что положить.** Файл кладётся РЯДОМ С КОНФИГОМ ПАЙПЛАЙНА: путь в конфиге относительный и резолвится от каталога того файла, а не от каталога книги и не от рабочего каталога процесса (`backend/internal/config/pipeline.go`, греп `resolvePrompt`). То есть при `pipeline: /opt/textmachine/configs/pipeline-c1.yaml` и `contrast_path: mining-contrast.zh.txt` файл обязан лежать по `/opt/textmachine/configs/mining-contrast.zh.txt`. **Откуда взять и чем проверить.** Артефакт генерируется из jieba 0.42.1; процедура и SHA пина — `docs/experiments/16-bank-mining.md`. Проверка выкатки — не `ls`, а $0-глагол движка на любой заведённой книге: ```sh # Кладём рядом с конфигом пайплайна, которым живёт шаблон книги. install -m0644 mining-contrast.zh.txt /opt/textmachine/configs/mining-contrast.zh.txt # Проверка ДО первого платного прогона: --dry-run ничего не покупает и ничего не меняет, но идёт по # ПИШУЩЕМУ пути, то есть через ту же проверку. /opt/textmachine/engine/<версия>/tmctl redrive --config /srv/textmachine/books//book.yaml --dry-run ``` ⚠ **ЭТА ПРОВЕРКА ОТКРЫВАЕТ ПРОЕКТ НА ЗАПИСЬ, и на книге с идущим прогоном её запускать нельзя.** `redrive` даже с `--dry-run` идёт пишущим путём (`pipeline.NewRunner`), а значит берёт эксклюзивный flock проекта и мигрирует файл при необходимости. На занятой книге это отказ класса 12 («ничего не произошло, подожди») — не порча, но и не проверка. Гоняйте на книге, по которой прогонов нет: `tmplatformctl books` покажет, у кого их нет. ⚠ **СУДИТЬ ПО СООБЩЕНИЮ, А НЕ ПО КОДУ ВЫХОДА — оба исхода дают `exit 10`.** Замерено 05.09 обеими ветками: ``` артефакта НЕТ: exit=10 tmctl: the bank contour is enabled and `mining.contrast_path` names … which is not readable: … no such file or directory артефакт ЕСТЬ: exit=10 tmctl: missing API keys (fill in backend/.env): … ``` Вторая строка — это УСПЕХ проверки контраста: `CheckMiningContrast` стоит в пишущей ветке ПЕРЕД проверкой ключей (`backend/internal/pipeline/runner.go`, греп `CheckMiningContrast`), поэтому дойти до жалобы на ключи можно только миновав контраст. Ключей у `redrive` в SaaS-развёртывании и не будет: они едут `--keys-file`, а его движок принимает только у `translate`. ⚠ **ДВЕ РАЗНЫЕ ОШИБКИ РАЗВЁРТЫВАНИЯ, И ГРОМКАЯ — НЕ ТА, О КОТОРОЙ ДУМАЕШЬ.** ⛔ Прежняя редакция этого абзаца утверждала обратное и была НЕВЕРНА: она называла тихой потерю КЛЮЧА и громкой потерю файла. Замер обеих веток (05.09, `tmctl manifest --json` на одной книге): | что потеряно | код выхода | что видит оператор | |---|---|---| | **строчка `contrast_path` в конфиге** | **`10`** | конфиг НЕ ЗАГРУЖАЕТСЯ вовсе, отказ на ЛЮБОМ пути, включая `$0`-читающие | | **сам файл артефакта** | **`0`** | всё работает, манифест отдаётся; отказывает только ПИШУЩИЙ путь — каждый `translate` и резюм | Причина в коде и она намеренная: пустой ключ судится ПРИ ЗАГРУЗКЕ (`backend/internal/config/pipeline.go`, греп `mining.contrast_path` is not set), а наличие ФАЙЛА — только на пишущем пути (`CheckMiningContrast`), потому что `LoadPipeline` лежит и на `$0`-поверхностях, и отказывать `status` из-за отсутствующего файла данных — ошибка класса D20.4. ⇒ **Забытая строчка в конфиге ловится немедленно и всем; забытый файл — только первым платным прогоном.** Опасен второй, и именно поэтому раздел выше велит класть артефакт ПЕРЕД выкаткой. ⚠ **Порядок в первой выкатке: артефакт → шаблон книги → демон.** Книга, заведённая раньше файла, не ломается и ничего не теряет — отказ приходит на старте прогона, а не на интейке, — но пользователь успевает нажать «перевести» и получить отказ, за который платить некому. ## Апгрейд ПЛАТФОРМЫ: миграция и бинарь едут вместе ⚠ **Схема и бинарь платформы — один шаг, а не два.** Любая миграция, снимающая колонку, делает перекрытие версий невозможным: инстанс старой сборки рядом с уже мигрированной базой отвечает 500 на КАЖДОЕ чтение книги. Так было у 00016 (`books.note_count`, `books.genre`, `chapters.heading`, таблица `notes`) и у 00021 (`chapters.units_done`, акт 5 P7) — проверять надо на КАЖДОЙ. Порядок: остановить старую версию → накатить миграцию (`TM_PLATFORM_MIGRATE=1` на одном инстансе или `goose` руками) → поднять новую. Роллинг-апгрейд с перекрытием версий на этом шаге не поддерживается; дефолт `TM_PLATFORM_MIGRATE` выключен именно поэтому — накат должен быть решением, а не побочным эффектом старта реплики. ⚠ **Отредактированная миграция не перезапускается.** goose ключуется НОМЕРОМ и не хранит ни имени, ни контрольной суммы, так что правка уже применённого файла невидима: на стенде это лечится сносом базы, в проде — гейтом `internal/pgstore/migrations.sha256`, который делает правку выпущенной миграции видимой ревьюеру. Поймано живой пробой P7 (правка 00016 после её применения на стенде). ## Апгрейд ДВИЖКА: порядок против деадлока (строка 174 единого бэклога) Read-only команды движка отказывают файлу проекта СТАРЕЕ бинаря — **exit 13 и токен `schema_mismatch found=N expected=M` на stderr** (форма финализирована D39.134 п.4), а платформа зовёт `status` перед каждым спавном и на расчёте денег. Значит выкат нового движка на хост с существующими книгами запирает их до миграции файла проекта. ⚠ **Проверено исполнением на стенде (P7):** проектная БД, отведённая на схему v14 при бинаре v15, даёт `tmctl status --json` → exit 13 с этим токеном; `tmctl migrate --config ` делает пред-миграционный бэкап и переводит v14 → v15; тот же `status` после неё — exit 0. ⚠ **`tmctl migrate` СУЩЕСТВУЕТ и заленден** — $0-команда движка, открывающая файл проекта на запись без прогона: `backend/cmd/tmctl/migrate.go`, в диспетчере `main.go`, свой код выхода (`exitSchemaMismatch = 13`, «run `tmctl migrate`»). ⚠⚠ **ВТОРОЙ, НЕЗАВИСИМЫЙ шов — ФОРМА МАНИФЕСТА движка, и у её бампа безопасного порядка НЕТ ни в одну сторону.** Платформа с P12 сверяет `manifest_version` с известной ей формой (`ingest.KnownManifestVersion`, зеркало `manifestVersion` движка) и на незнакомую отвечает НЕ-деструктивным классом `parser_unavailable` — файл пользователя цел, и это осознанный выбор: без сверки переименованный ключ декодируется в нули, а ноль глав интейк читает как «источник прочли, книги нет», то есть УДАЛЯЕТ аплоад (`PD-213`). Но класс всё равно ТЕРМИНАЛЕН по бюджету попыток: каждая загруженная книга уходит в `rejected` после пяти попыток. Гейт — СТРОГОЕ РАВЕНСТВО одной константе (`internal/ingest/manifest.go`, греп `m.Version != KnownManifestVersion`), окна двух форм в нём нет, поэтому односторонняя выкатка смертельна с ОБЕИХ сторон: движок впереди платформы ⇒ платформа не знает новую форму; платформа впереди движка ⇒ платформа не знает СТАРУЮ, и в `parser_unavailable` уезжает каждая книга ещё не обновлённого движка. Симптом один и тот же — интейк массово отклоняет при здоровом на вид движке. ⚠ Прежняя редакция этого абзаца советовала «сначала платформа, потом движок» и ссылалась на правило схемы хранилища — это было неверно дважды (`PD-436`): у формы манифеста порядка нет, а правило схемы (раздел ниже) — «движок первым → `migrate` → платформа», про другой шов. **Бамп формы манифеста — стоп-мир:** закрыть приём (`systemctl stop tmplatformd`; остановленный демон не принимает загрузок), выкатить ОБА билда, открыть приём. Безопасный порядок появится только вместе с окном двух форм в гейте (константа → набор известных форм) — сегодня его нет и это не заказано. ⚠ **Гейт версии стоит ТОЛЬКО на интейке** (`ingest.Manifest.Readable`, единственный вызывающий — `internal/books/parse.go`). Материализация читающей поверхности на конце прогона версию НЕ сверяет — она требует лишь самосогласованности документа (`manifest.Whole()` в `internal/readmodel`) и спрашивает манифест у ДЕПЛОЙНОГО бинаря (`TM_PLATFORM_ENGINE_BIN`), а не у того, к которому запинен прогон. Значит живой прогон стоп-мир переживает, а его книга в худшем случае теряет СВЕЖЕСТЬ: незнакомая форма декодируется в нули, `Whole()` отказывается переписывать дерево, долг откладывается и после пяти попыток списывается — книга остаётся с прежним текстом (`books --abandoned`, `book refresh`). Дренаж «трёх фактов» (шаг 1 ниже) обязателен не поэтому, а из-за схемы хранилища ДВИЖКА — это соседний раздел и другой отказ (exit 13). Порядок (действующий). ⚠ Ключевое: **`migrate` гоняется НОВЫМ бинарём** — старый уводит файл в свою же схему, то есть не делает ничего, и деадлок остаётся. Поэтому бинарь кладётся ДО миграции, а `TM_PLATFORM_ENGINE_BIN` переключается ПОСЛЕ. ```sh # 0. Закрыть приём. Между списком и миграцией окно, в которое пользователь может стартовать прогон # на книге, только что названной безопасной; остановленный демон не принимает ни стартов, ни # резюмов. Идущие прогоны переживают это по построению — они в своём менеджере (P4, D39.106). systemctl stop tmplatformd # 1. Дренаж. «Прогонов нет» — это ТРИ факта, а не один: нет живых юнитов, нет открытых # (резюмируемых) попыток И нет незакрытых холдов. tmplatformctl books # колонка MIGRATE и причина, если «no» # 2. Новый бинарь — на ВЕРСИОНИРОВАННЫЙ путь. Безопасно ещё до переключения: идущие попытки # запинены к своим путям, а этот никто пока не зовёт. install -D -m0755 tmctl /opt/textmachine/engine/<новая версия>/tmctl # 3. Мигрируем ЕГО бинарём и только те книги, которые команда назвала безопасными. for d in $(tmplatformctl books --migratable); do /opt/textmachine/engine/<новая версия>/tmctl migrate --config "$d/book.yaml" done # 4. И только теперь новая версия становится рабочей: TM_PLATFORM_ENGINE_BIN в /etc/tmplatform/env, # затем поднять демон. systemctl start tmplatformd ``` Почему шаг 1 — про три факта, а не про один. Остановленная попытка ждёт резюма на бинаре, к которому она ЗАПИНЕНА (строка 139): миграция уводит файл вперёд, старый бинарь его больше не откроет, и резюм пришлось бы форсить на новую сборку — риск пере-оплаты уже купленных вызовов. По той же причине блокирует НЕЗАКРЫТЫЙ холд: расчёт денег читает `committed_usd` тем же запиненным бинарём, и после миграции цифру уже не прочитать. Все три предиката запинены тестом (`TestEachOfTheThreeBlockersAloneKeepsABookOutOfTheMigrationList`) — каждый по отдельности, потому что «блокер, который тихо перестал блокировать» виден только на уже прошедшем апгрейде. Книга, застрявшая на `daily_ceiling` или на вечно незакрытом холде, блокирует апгрейд бессрочно, и выхода для оператора сегодня нет (строка регистра PD-217): такую книгу придётся либо дождаться, либо разрешить вручную в БД. Форсирующего флага у команды нет намеренно — он бы и был тем самым способом пере-оплатить купленное. ⚠ **Хвост, который НЕ построен:** самолечение платформы — поймала `schema_mismatch` → зовёт `migrate`, если у книги нет открытых попыток → повтор, без стоп-мира. Строка регистра PD-201. ## Застрявшая работа: что оператор делает, когда свип не справляется Диагноз и терминальный вердикт для двух состояний, которые свип не закрывает сам (P8-FIX). **Прогон, который реконсилятор не может закончить.** Признак: растёт `tm_platform_sweep_unfinished_total`, гейдж `tm_platform_runs_stalled` больше нуля. ```sh tmplatformctl runs # живые прогоны И застрявшие расчёты tmplatformctl runs --stalled # только те, что свип не доводит: сколько неудач подряд, # когда следующая попытка, что сказал движок, сколько держит ``` ⚠ **Колонка `PHASE` говорит, какая это половина, и лечение у них разное.** `live` — прогон ещё идёт, и первый вопрос к systemd. `settling` — прогон УЖЕ кончился, движка нет, а его расчёт не сходится: деньги пользователя заморожены в открытой резервации, и вопрос к systemd бессмыслен. Дальше решает человек, а не платформа: сперва спросить systemd (`systemctl --user status <юнит>`). Если процесс жив — остановить его и дать реконсилятору закрыть прогон штатно, это лучше во всём. Если движок не ответит уже никогда (каталог книги унесён, маунт отвалился): ```sh tmplatformctl run abandon --run --reason "почему" [--release-hold] ``` `--reason` обязателен: это единственная запись о том, почему оплаченный прогон объявлен законченным. ⚠⚠ **С 04.09 КОМАНДА СПРАШИВАЕТ SYSTEMD САМА, и это меняет то, что оператор делает руками** (`PD-424`, живая половина). Раньше она отказывала любой попытке, которая ещё называет юнит или несёт базовую линию траты, и правильно отказывала: платформа не может отличить НАЗВАННЫЙ юнит от РАБОТАЮЩЕГО. Чего она не делала — не спрашивала. Теперь делает, и ответов **ЧЕТЫРЕ**: - **юнита нет** — прогон закрывается, деньги закрываются в этой же транзакции; - **юнит активен** — ОТКАЗ, с тем же советом, что и раньше: остановите его и дайте реконсилятору закрыть прогон штатно; - **systemd недоступен** — тоже ОТКАЗ. Отсутствие ответа не есть отсутствие процесса, и цена ошибки здесь — живой движок, который продолжает тратить против книжного потолка, когда холд аккаунта уже закрыт: этот расход не оплатит никто, и провайдеру платит ДЕПЛОЙ. Ограничен он потолком того же прогона, но он есть; - **это НЕ ТОТ systemd** — ОТКАЗ, и он самый неочевидный из четырёх. `systemctl --user` отвечает про менеджер ТОГО, КТО СПРАШИВАЕТ, а прогоны живут в менеджере пользователя демона; юнит, о котором чужой менеджер никогда не слышал, и юнит, который кончился, выглядят одинаково (`ActiveState=inactive`, выход 0). Поэтому команда сперва устанавливает, ЧЕЙ это менеджер. ⚠ **И ПЯТЫЙ ОТКАЗ, добавленный 07.09: доказательство ПРОТУХЛО.** Ответ systemd снимается до того, как транзакция берёт книгу, и мир в этом окне может уехать двумя способами: расплата разблокировалась и `restart` открыл НОВУЮ попытку, либо ту же попытку заново заявили на спавн. Команда несёт с ответом id попытки и счётчик заявок, транзакция сверяет оба под блокировкой книги и отказывает при расхождении. Что делать оператору: посмотреть `tmplatformctl runs --stalled` ещё раз и, если прогон всё ещё там, повторить команду — проверка стоит одного повтора и существует ровно затем, чтобы юниту, поднявшемуся в этом окне, не закрыли деньги под ним (`PD-424`). ⚠⚠ **ЧТО ИЗ ЭТОГО СЛЕДУЕТ ДЛЯ ОБОЛОЧКИ ОПЕРАТОРА: `TM_PLATFORM_STATE_DIR` ДЕПЛОЯ ДОЛЖЕН БЫТЬ ЭКСПОРТИРОВАН, иначе команда ОТКАЖЕТ.** Каталог состояния пишут и демон, и `ExecStopPost` каждого прогона, оба под пользователем прогонов, — поэтому его владелец и есть тот единственный факт, по которому CLI отличает свой менеджер от чужого. Не задана переменная или каталог принадлежит другому uid — отказ с обоими номерами и с рецептом `systemctl --user -M @ status <юнит>`. Это не придирка: без неё команда закрыла бы деньги живого прогона по ответу, которого никто не проверял. Второе условие проверяет уже не команда, а хранилище, внутри транзакции: прогон должен провалить реконсиляцию столько раз подряд, сколько нужно, чтобы попасть в `runs --stalled`. Ниже этого порога — отказ: видеть, как прогон идёт не так, и иметь право его уничтожить — разные разрешения, и свип, который закрыл бы его правильно, ещё работает. **Что при этом происходит с деньгами — два случая, и они не равны.** Попытка, вообще не дошедшая до движка, ничего не потратила: холд возвращается ЦЕЛИКОМ, а `--release-hold` решает лишь КОГДА — сразу или на ближайшем свипе (он нужен, когда демон остановлен и свипа не будет). Попытка, которая до движка дошла и о чём-то отчиталась, СПИСЫВАЕТСЯ по последней цифре потока (та же арифметика, что печатает колонка SPENT), а возвращается только остаток; здесь флаг не спрашивают — свипа, который довёл бы этот расчёт, не будет. Попытка без базовой линии ценится нечем — холд целиком, как в settling-ветви. ⚠ **Колонка SPENT печатает `≥` перед суммой, и знак не декоративный:** цифра — счётчик движка, а вызов, отменённый в полёте, записан в нём нулём, поэтому она НИЖНЯЯ ГРАНИЦА (`PD-441`). Списывать холд «по ней как по цене» нельзя; строка леджера называет свой базис словами. ✅ **Оговорка `PD-418` СНЯТА 04.09 — ветвление ушло с `runs.finished_at` на НАЛИЧИЕ осиротевшей попытки**, то есть на то самое долговечное лечение, которое эта оговорка называла. `run abandon` спрашивает про осиротевшую попытку ПЕРВЫМ делом и лечит её одинаково — кончился прогон или нет. ⚠ Живой прогон при этом НЕ заканчивается и его собственная попытка не трогается: застряли деньги ОДНОЙ попытки, а не перевод, и заканчивать перевод из-за них значило бы отнять у пользователя работу, о которой он не просил. Состояние по-прежнему недостижимо на не-тестовом пути (`reopen` не стартует следующую попытку, пока холд предыдущей открыт), но документ больше не обещает того, чего код не делает, — а если инвариант когда-нибудь ослабнет, команда уже готова. **Строка `PHASE = settling` — та же команда, другой исход.** Прогон уже кончился, свипа, который довёл бы его расчёт, не будет никогда, поэтому `run abandon` закрывает деньги В СВОЕЙ транзакции независимо от флага: резервация закрывается, холд возвращается ЦЕЛИКОМ, прогон помечается рассчитанным. Целиком — потому что не хватает как раз движковой цифры (это и есть определение состояния), и суммы, которую платформа могла бы обосновать, не существует; альтернатива — деньги заморожены навсегда. Статус самого прогона НЕ переписывается: он уже закончился со своим исходом. Повторный вызов отвечает «its money is already closed», а не «нет такого прогона». **Прогон, чей экран замер: проекция в карантине.** Признак: гейдж `tm_platform_quarantined_attempts` больше нуля, а в `tmplatformctl runs` у строки непустая колонка `QUARANTINE` — причина, по которой платформа перестала читать журнал этой попытки (строка, которую эта сборка не может прочитать: разрыв `seq`, другой payload под тем же `seq`, чужой мажор потока, битая строка). Движок при этом идёт и тратит холд — карантин останавливает только проекцию, свежесть экрана падает на медленный ресинк. Деньги целы. Когда причина ушла (сборка платформы обновлена, чужой `tmctl` в каталоге книги остановлен), проекцию возвращает человек: ```sh tmplatformctl run unquarantine --run # печатает причину и курсор, с которого свип читает дальше ``` Курсор команда НЕ трогает: следующий свип читает журнал с того места, где проекция остановилась, и если те же байты по-прежнему нечитаемы — карантинит снова с той же причиной, и это честный ответ, а не сбой команды. Три отказа своими словами: нет такого прогона · у прогона нет живой попытки (законченный прогон не материализуется, снимать нечего) · попытка не в карантине. ⚠ **Что с P13 карантин больше НЕ вызывает, и что вызывает по-прежнему** — разница в том, удалось ли прочитать, ЧЕЙ это handshake. У попытки, **чей поток платформа именовала**, чужой handshake (ручной `tmctl` оператора в каталоге книги, чужой мажор потока, пустой `engine_run_id`, `seq` не 1) карантина больше не даёт: владелец распознаётся до проверки формы, а чтение ОСТАНАВЛИВАЕТСЯ на этой строке — курсор в чужую область не переезжает, поэтому и следующий свип не примет чужие события за наши (`PD-214`, `PD-426`, `PD-438`). ⚠ **Битая строка — по-прежнему карантин, и это по построению:** `engine_run_id` лежит ВНУТРИ payload, значит у строки, которая не распарсилась, владельца просто нет, и пропустить её как чужую нельзя. Карантинят и настоящие сигналы порчи НАШЕГО потока: разрыв `seq`, другой payload под тем же `seq`, строка длиннее буфера. ⚠ **Что видно оператору у остановленной («припаркованной») попытки:** проекция стоит и `tm_platform_tailer_lag_bytes` растёт, но свежесть НЕ теряется — платформа спрашивает движок напрямую (`tmctl status`), как делает для карантина, и в лог идёт WARN с прогоном, попыткой и смещением. **У парковки СВОИ три сигнала, и это не карантинные:** колонка `PARKED` в `tmplatformctl runs` печатает, СКОЛЬКО ВРЕМЕНИ попытка стоит; гейдж `tm_platform_parked_attempts` считает её отдельной серией; колонка `run_attempts.parked_at` хранит момент. ⛔ Карантинных сигналов у неё по-прежнему нет и не будет: `QUARANTINE` пуста, `tm_platform_quarantined_attempts` её не считает, `run unquarantine` отвечает «не в карантине» — и это правильно, потому что снимать парковку не надо: её ставит и снимает один и тот же проход свипа, тогда как карантин снимает ЧЕЛОВЕК. ⚠ **Ждать, что она сама рассосётся, НЕЛЬЗЯ:** «чужой» handshake может писать живой процесс этой же попытки — платформа отдаёт каждому её спавну один и тот же id потока, а движок на повторе минтит свежий, — так что прогон способен простоять припаркованным весь свой срок. Разбор и радиус — `PD-438`. ⚠ Исключение — попытка БЕЗ `engine_run_id` (заведена сборкой до того, как платформа стала именовать поток): она усыновляет первый встреченный handshake и потому проверяет его полностью, так что для неё чужой мажор по-прежнему терминален для проекции — эта команда её и возвращает. **Книга, чью читательскую поверхность не удаётся построить.** После пяти неудач долг списывается, книга остаётся с прежним текстом и попадает в список: ```sh tmplatformctl books --abandoned # книга, когда сдались, что сказал движок tmplatformctl book refresh --book # попросить заново, с чистым бюджетом попыток ``` Гейдж — `tm_platform_reading_surfaces_abandoned`. Отказ хоста (движок не запускается, чужой лок, немигрированная схема) попытку НЕ тратит: он про все книги сразу, а не про эту. **Две ручки бюджета свипа** (обе печатаются на буте с источником): | Переменная | Дефолт | Что это | |---|---|---| | `TM_PLATFORM_SWEEP_BUDGET` | `2m` | сколько всего может занять один проход свипа | | `TM_PLATFORM_RUN_BUDGET` | `1m` | сколько может занять ОДИН прогон внутри прохода | ⚠ Поднимать надо ПАРУ, а не одну: проход делится пополам между сверкой и расчётом денег, поэтому `RUN_BUDGET` выше половины `SWEEP_BUDGET` наблюдаемо ничего не меняет (`PD-368`). ## Дев-стенд: живая платформа для фронта (П-16) Фронт разрабатывается против ЖИВОЙ платформы, а не против своих моков. Рецепт целиком: ```sh # 1. Postgres без root (docs/STACK_DECISIONS.md §«Postgres на стенде без root»). ~/.local/pgsql/bin/pg_ctl -D ~/.local/share/tmstand/pgdata \ -l ~/.local/share/tmstand/pg.log -o "-k /tmp -p 55433 -c listen_addresses=''" start # 2. Окружение стенда. ⚠ ДВА гейта на старте, оба про «стендовое окружение уехало в прод»: # TM_PLATFORM_DEV_LOGIN вместе с OIDC — отказ; TM_PLATFORM_INSECURE_COOKIES вместе с OIDC, # чей callback НЕ http:// — тоже отказ (боевой вход в куках без Secure и с выключенным HSTS). # Локальный провайдер по http на стенде при этом законен и работает. export TM_PLATFORM_DSN='postgres://postgres@/tmstand?host=/tmp&port=55433&sslmode=disable' export TM_PLATFORM_ADDR=127.0.0.1:8080 export TM_PLATFORM_MIGRATE=1 export TM_PLATFORM_INSECURE_COOKIES=1 # дев-профиль куки: __Host- требует Secure export TM_PLATFORM_DEV_LOGIN=dev@stand # вход без внешнего провайдера — ТОЛЬКО стенд export TM_PLATFORM_BOOKS_DIR=$HOME/.local/share/tmstand/books export TM_PLATFORM_STATE_DIR=$HOME/.local/share/tmstand/state export TM_PLATFORM_ENGINE_BIN=$HOME/.local/bin/tmctl export TM_PLATFORM_CTL_BIN=$PWD/tmplatformctl # абсолютный: systemd отвергает иной export TM_PLATFORM_BOOK_TEMPLATE=$HOME/.local/share/tmstand/book-template.yaml # ⚠ ОБЯЗАТЕЛЬНО, как и в бою: без хотя бы одной ДОСТУПНОЙ пары демон, принимающий загрузки, не # стартует. Правило и его причина — блок ⚠ `TM_PLATFORM_LANGUAGE_PAIRS` выше. export TM_PLATFORM_LANGUAGE_PAIRS='zh>ru' mkdir -p "$TM_PLATFORM_BOOKS_DIR" "$TM_PLATFORM_STATE_DIR" # 3. Демон (мигрирует сам при TM_PLATFORM_MIGRATE=1). ./tmplatformd & DAEMON=$! # 3а. ⚠ ОПОЗНАТЬ ПРОЦЕСС ДО СИДА. Слушатель на порту обязан быть ЭТИМ pid: `200` от /healthz # доказывает лишь, что по адресу кто-то есть, а сид ниже дарит деньги и грузит книгу. sleep 1 ss -ltnp "sport = :${TM_PLATFORM_ADDR##*:}" | grep -q "pid=$DAEMON," || { echo "на $TM_PLATFORM_ADDR отвечает НЕ этот демон (свой умер на bind?) — сид отменён" exit 1 } # 4. Сид: аккаунт + кредит + демо-книга ЧЕРЕЗ ЖИВОЙ ИНТЕЙК (не INSERT). # ⚠ Адрес берётся из переменной, а не пишется вторым литералом: разъехавшиеся копии одного # адреса — это и есть способ засеять чужой стенд, не заметив. ./tmplatformctl seed --url "http://$TM_PLATFORM_ADDR" # 5. Фронт ходит на стенд своим дев-прокси (TM_PLATFORM на его стороне). ``` ⚠ **Шаг 3а появился не из осторожности, а по замеру 04.09.** На машине разработки четвёртые сутки жил ЧУЖОЙ `tmplatformd` на `127.0.0.1:8080` со своей базой. Демон этого рецепта умирает в такой ситуации на `bind: address already in use` — молча, потому что запущен фоном, — и **шаг 4 уезжает в чужую базу**: он выдаёт `$25` кредита и грузит книгу через живой интейк, то есть пишет деньги и данные в чужой деплой, отвечающий по тому же адресу. Проверено исполнением: `/healthz` и `/readyz` на 8080 отвечали `ok` и `ready` при мёртвом собственном демоне. ⚠ **Порт `8080` при этом остаётся дефолтом зоны сознательно** — коллизия здесь `tmplatformd` против `tmplatformd`, и любой другой номер даст ту же аварию на втором одновременном стенде; чинится не номер, а посылка «по адресу отвечают — значит это мой сервис». Разбор той же поломки со стороны дев-рецепта — `docs/STACK_DECISIONS.md` §«Как поднять локально». Сессию в браузер выдаёт тот же вызов, что делает сид, — один раз из консоли приложения: ```js await fetch('/auth/dev-login', {method: 'POST'}) ``` ⚠ **Дев-вход не должен доехать до прода, и это гарантировано ЧЕТЫРЬМЯ независимыми свойствами** — перечень и довод каждого лежат единственным носителем в `docs/STACK_DECISIONS.md` §30, разбор в доккомментарии `internal/login/dev.go`. На каждый выданный сеанс пишется WARN. ⚠ **`TM_PLATFORM_BOOKS_DIR` — каталог, который сервис ПИШЕТ и, на отклонённом интейке, УДАЛЯЕТ.** Всё под ним создано загрузкой книг; книга, заведённая дев-инструментом с чужим `--workdir`, туда не попадает и не удаляется никогда (гард `books.owns`). Каталог должен существовать и принадлежать `tmplatform`, и он обязан быть в `ReadWritePaths=` юнита — при `ProtectSystem=strict` отсутствующий путь валит сборку mount-namespace целиком (PD-91, та же грабля, что и с `/srv/textmachine`): ```sh install -d -m0750 -o tmplatform -g tmplatform /srv/textmachine/books ``` ⚠ **`/metrics` не аутентифицирован** — его защищает только адрес привязки, и дефолт `127.0.0.1` снаружи недостижим. Скрейпер живёт на том же хосте либо ходит через тот же edge, что и API; что именно открывает внешний адрес и почему риск принят — `PD-179`, разбор `docs/STACK_DECISIONS.md` §24. ⚠ **Загрузка книги идёт минуты, и это касается edge-прокси.** Маршрут `POST /v0/books` принимает до `TM_PLATFORM_MAX_UPLOAD_BYTES` (64 МиБ) и держит соединение до `TM_PLATFORM_UPLOAD_DEADLINE` (10 минут). Прокси перед сервисом обязан разрешать столько же: у него свои `client_max_body_size` и свои таймауты чтения, и молчаливо режет их он, а не мы. ⛔ **И ПОСЛЕ последнего байта маршрут МОЛЧИТ ещё до 3 мин 40 с — это отдельный таймаут прокси, не тот, что выше.** Приняв тело, сервис доводит книгу до ответа: переводит строку в `parsing`, синхронно режет книгу движком, записывает исход, перечитывает строку и пишет квитанцию идемпотентности. Верхняя граница всего этого — `books.UploadSettle` = **220 с** (`internal/books/parse.go`), и это ГРАНИЦА, а не оценка: хвост целиком идёт под одним дедлайном (`walk`), и шаг, добавленный в него завтра, её не сдвинет. ⇒ **у прокси таймаут ОТВЕТА (`proxy_read_timeout` у nginx, `timeout server` у HAProxy) обязан быть не меньше `TM_PLATFORM_UPLOAD_DEADLINE + 220 с`** — на дефолтах это 10 мин + 3 мин 40 с = **13 мин 40 с**, и брать с запасом. Дефолт у nginx — 60 с, то есть НИЖЕ этой границы более чем вдвое. ⚠ Цена ошибки несимметрична и потому её стоит назвать: прокси, оборвавший ожидание, вернёт человеку ошибку на книге, которая на самом деле **ПРИНЯТА** — она уже в библиотеке и её дорежет очередь, — а человек, повторив «неудавшуюся» загрузку, получит вторую копию книги. Поднимаете `TM_PLATFORM_UPLOAD_DEADLINE` — поднимайте и этот таймаут на ту же величину; сам сервис за прокси не отвечает и молча его не подвинет. ⚠ Число берётся ИЗ КОДА, не отсюда: `grep -n 'UploadSettle =' internal/books/parse.go` и сумма его слагаемых там же. Бут отказывается стартовать, если `TM_PLATFORM_UPLOAD_DEADLINE` плюс этот хвост не влезает в самое узкое из трёх окон приёма (`internal/config/config.go`), так что расходиться с кодом эта величина может только в одну сторону — в бо́льшую осторожность прокси. ⚠ **`TM_PLATFORM_MAX_CUTS` (дефолт 4) — сколько книг хост режет ОДНОВРЕМЕННО**, считая все три пути сразу: загрузку, которая режет свою книгу в запросе, воркеров очереди и страховочный свип. Это **сайзинг, а не отказ**: упёршаяся загрузка ждёт слот, а не получает ошибку, и если не дождалась — книга принимается `parsing`, её дорезает очередь. Поднимать имеет смысл ровно настолько, насколько хост тянет одновременных `tmctl manifest`: разбор идёт обычным дочерним процессом БЕЗ cgroup и без `MemoryMax` (в отличие от прогонов), то есть этот потолок — единственная граница памяти, какая у приёма есть. Насыщение видно метриками `tm_platform_cuts_in_flight` / `tm_platform_cuts_waiting` против `tm_platform_cut_slots` и счётчиками `tm_platform_cut_waits_total`, `tm_platform_cut_slot_timeouts_total`; растущий второй счётчик означает, что латентность приёма делает ПОТОЛОК, а не движок. Ноль бут отвергает: «без лимита» и «не режем ничего» — противоположные прочтения одного значения. ⚠ **`TM_PLATFORM_STATE_DIR` меняется только когда живых прогонов нет.** Exit-маркер пишется по пути, вычисленному при СПАВНЕ, а читается по пути из текущей конфигурации: после смены каталога конец идущего прогона становится невидим, и реконсилятор перезапускает его как потерянный (PD-155). Путь обязан быть абсолютным — относительный демон отвергает на старте, потому что маркер пишет юнит из каталога книги, а читает демон из своего. Миграции выкатываются ОДИН раз, не каждой репликой: `TM_PLATFORM_MIGRATE=1 tmplatformd` разово либо отдельный шаг деплоя. `goose` держит advisory-лок, так что параллельный запуск не гонка, но и не норма. ## Бэкап и восстановление ⛔ **До 05.09 копий не было НИ У ЧЕГО** (строка бэклога 269): весь оплаченный перевод жил в SQLite книги на локальном диске, кредиты и холды — в Postgres, и одна потеря диска стирала оба без возможности восстановить или доказать, кто сколько купил. Это единственная поломка деплоя **без частичного исхода**, поэтому механизм здесь нарочно нехитрый: целые файлы, целый дамп, манифест, и восстановление, которое оператор делает `pg_restore` и `cp`, а не нашим инструментом. **Включается ОДНОЙ переменной, и без неё демон говорит об этом на каждом старте:** ``` no TM_PLATFORM_BACKUP_DIR: this deployment keeps NO restore point of the paid translations or of the credit ledger; losing this host's disk loses both irrecoverably ``` ⚠ **ТРЕБОВАНИЕ, КОТОРОЕ СЛУЖБА ПРОВЕРИТЬ НЕ МОЖЕТ И НЕ ПРИТВОРЯЕТСЯ: `TM_PLATFORM_BACKUP_DIR` обязан лежать на ДРУГОМ УСТРОЙСТВЕ** (или синхронизироваться прочь с хоста — rsync/rclone по таймеру, объектное хранилище). Каталог под `/var/lib/tmplatform` формально работает и переживает ошибку человека, но НЕ переживает диск — то есть не закрывает то, ради чего заведён. Проверить это из процесса нельзя (bind-mount и симлинк обманут любую пробу), поэтому это половина сделки, исполняемая оператором. ⚠ **Каталог обязан быть записываем ВНУТРИ ПЕСОЧНИЦЫ ЮНИТА** — та же оговорка, что у `BOOKS_DIR` и `EXPORTS_DIR`: при `ProtectSystem=strict` путь вне `StateDirectory=`/`ReadWritePaths=` недоступен на запись. Добавьте свой путь в юнит и создайте каталог ДО первого старта (`PD-91`: отсутствующий путь в `ReadWritePaths=` валит сборку mount-namespace целиком, юнит не стартует вовсе): ```sh install -d -m0750 -o tmplatform -g tmplatform /var/backups/tmplatform # в deploy/tmplatformd.service, рядом с ReadWritePaths=/srv/textmachine: # ReadWritePaths=/var/backups/tmplatform ``` ### Что такое точка восстановления Каталог, публикуемый переименованием из `.partial-<штамп>` — то есть точка, которая ЕСТЬ, это точка, которая ДОПИСАНА: ``` /var/backups/tmplatform/20260905T120000Z/ manifest.json версия документа, время, полный список файлов с sha256 и размером, признак полноты и инструкции по восстановлению ВНУТРИ самой точки postgres.dump pg_dump --format=custom всей базы (аккаунты, кредиты, холды, книги, поток) books// каталог книги, а её проектная база — СОГЛАСОВАННОЙ КОПИЕЙ под именем project.db ``` ⚠ **Копию базы книги делает ДВИЖОК, а не платформа, и это не формальность двух зон.** Платформе запрещено открывать SQLite движка (`D39.85`), и это же — правильная инженерия: живой SQLite, скопированный побайтно во время прогона, рвётся молча. Платформа зовёт `tmctl backup`, который сперва гоняет `PRAGMA integrity_check`, потом `VACUUM INTO` — то есть копирует то, что никто не пишет, — и **забирает** получившийся файл к себе, чтобы каталог книги не рос на целую базу за проход. ⚠ **Чего в точке НЕТ, и это решения, а не забывчивость:** собранные экспорты (эфемерны по TTL и пересобираются из книги) · маркеры выхода `STATE_DIR` (живут длину одного прогона) · собственный каталог `backups/` внутри книги · **ДОТФАЙЛЫ** · живой `*.db` и его `-wal`/`-shm`. ⚠ **Дотфайлы исключены НЕ ради чистоты, а потому что это СЕКРЕТ:** конвенция движка — `.env` рядом с `book.yaml` с ПРОВАЙДЕРСКИМИ КЛЮЧАМИ (`backend/cmd/tmctl/main.go`, греп `loadDotEnv`), а этот раздел велит увозить каталог точек ПРОЧЬ С ХОСТА. Бэкап, уносящий ключи деплоя туда, куда уезжают бэкапы, — это утечка учётных данных по расписанию. ⚠ **Подкаталоги книги, наоборот, копируются РЕКУРСИВНО** (кроме `backups/`): книжный оверлей `langpack_extend` и `glossary_seed` законно живут в подкаталоге, и «полная» точка без них была бы потерей книжного канона молча. ⚠ **Всё пропущенное ИМЕНУЕТСЯ в манифесте** (`books[].not_copied`) с причиной — чтобы через полгода читатель не выводил правила из того, что уцелело. ⚠ **И ТОЧКА — НЕ МГНОВЕНИЕ ВРЕМЕНИ, а последовательность:** сперва снимается денежный реестр, потом книги одна за другой. На большой библиотеке между дампом и последней книгой проходят минуты, а то и десятки минут. `complete: true` означает «всё, что должно было попасть, попало», а НЕ «всё снято в один момент»: прогон, начавшийся после дампа, будет виден в книге и не виден в реестре. Для восстановления это безвредно — реконсилятор доводит расчёт по exit-маркеру и журналу, — но читать точку как согласованный снимок нельзя. ⚠ **Про последний — точнее, потому что первая редакция этого абзаца говорила «наши копии их замещают», и это НЕВЕРНО.** `<каталог книги>/backups/` — это ПРЕДРЕЙСОВЫЕ точки, которые движок снимает своим гардом перед каждым платным прогоном (`backend/cmd/tmctl/backup.go`, греп `preflightBackup`). Они отвечают на ДРУГОЙ вопрос: «откатить вот этот прогон», тогда как точка здесь снимается по расписанию и может уже содержать то, что плохой прогон натворил. Диск они не переживают — и в этом смысле не заменяют точку, — но и точка не заменяет их. Поэтому они не копируются (копировать каждую прошлую копию на каждом проходе — это квадратичный рост) и не удаляются. ⚠ **И операторский факт, который стоит знать до того, как кончится диск: их не чистит НИКТО.** Проверено грепом по обеим зонам: единственные писатели — `backupCmd` и `preflightBackup` в `backend/cmd/tmctl/backup.go` (плюс `tmctl migrate`), удаляющего кода нет ни в движке, ни в платформе. То есть каждый платный прогон и каждая миграция книги оставляют ПОЛНУЮ копию её базы навсегда. Пока книг единицы, это дёшево; на большой библиотеке чистить придётся руками (`find /*/backups -name '*.db' -mtime +30`), и решение «сколько предрейсовых точек держать» ещё не принято ни одной зоной. ⚠ **Пропущенная книга помечается пропуском, а не умалчивается:** `manifest.json` несёт `complete` и причину по каждой книге. Дамп Postgres — наоборот: **если он не удался, точка НЕ публикуется вовсе**, потому что копия без денежного реестра не отвечает на вопрос «кто сколько заплатил». ### Расписание, поколения, наблюдение Точка снимается СВОЕЙ горутиной со своим тикером, а НЕ пассом реконсилятора — и это исправление, а не вкус: такт реконсилятора последователен, поэтому копирование целых баз задержало бы расчёт денег, ретраи интейка, сборку мусора экспортов и обновление ВСЕХ гейджей на всю свою длину. До часа четыре раза в сутки — это не доля такта, это остановка контрол-плейна. Срок пасс решает сам, по самой свежей точке на диске, поэтому расписание переживает рестарт без своего состояния. Дефолт: каждые `6h`, `14` поколений = 84 ч истории. ⚠ **Пасс, не уложившийся в бюджет (`60 мин`), НЕ публикует НИЧЕГО.** Обрезать было бы хуже: книги перечисляются от старых к новым, значит обрезанная точка теряла бы ровно те, над которыми идёт оплаченная работа, — и при этом обнуляла бы возраст. Метрика ровно одна и она про возраст: **`tm_platform_backup_age_seconds`** — сколько назад снята самая свежая **ПОЛНАЯ** точка; `+Inf`, если такой нет вовсе. ⚠ **Именно ПОЛНАЯ, и это половина ценности гейджа:** точка, не сумевшая скопировать книгу, публикуется (девять спасённых книг лучше нуля), но бэкапом ЭТОЙ книги она не является — и если бы её штамп обнулял возраст, деплой с одной вечно некопируемой книгой выглядел бы свежезабэкапленным во всех четырнадцати поколениях. Счётчик успехов на этот вопрос не отвечает: джоба, падающая неделю, и джоба, которой никогда не было, выглядят в нём одинаково. ```sh tmplatformctl backup # снять точку СЕЙЧАС (перед миграцией, перед апгрейдом) tmplatformctl backup --list # что есть: когда, сколько книг, полна ли, сколько байт tmplatformctl backup --verify latest # пере-хешировать точку против её манифеста tmplatformctl backup --list --dir <путь> # то же на КОПИИ точки, без окружения деплоя ``` ⚠ `--list`/`--verify` работают без DSN и без рабочего окружения намеренно: точка, увезённая на другую машину, — всё ещё точка, и «что у нас есть» спрашивают не в лучший день. ### Восстановление — исполняется руками, командой `restore` не заменяется Команды `restore` здесь НЕТ сознательно: восстановление затирает живую базу и живую библиотеку, судить о полноте копии может только человек, а односложная команда для этого — односложная команда для уничтожения текущего состояния по ошибке. Те же шаги лежат ВНУТРИ точки (`manifest.json`, поле `notes`), чтобы пережить переезд копии подальше от этого репозитория. ```sh # 0. Убедиться в самой копии ПЕРЕД тем, как что-то трогать. tmplatformctl backup --verify 20260905T120000Z --dir /var/backups/tmplatform # 1. Остановить приём: демон не должен писать в то, что восстанавливается. systemctl stop tmplatformd # 1а. ⚠ И ЭТОГО МАЛО: ПРОГОНЫ ДЕМОНУ НЕ ДЕТИ. Каждый — транзиентный юнит в СОБСТВЕННОМ менеджере # пользователя `tmplatform` и переживает остановку контрол-плейна по построению (D39.106, §«Что # юнит закрывает содержательно» выше). Восстановление делают в худший день — именно тогда # вероятен ИДУЩИЙ оплаченный прогон, и шаг 4 подсунул бы файл под открытым дескриптором. # Спросить и, если есть, остановить — и только потом трогать каталоги: systemctl --user -M tmplatform@ list-units 'tm-run-*' --no-legend # что живо в ИХ менеджере systemctl --user -M tmplatform@ stop 'tm-run-.service' # каждый, что нашёлся # ⚠ Останов прогона НЕ теряет его работу: `SIGTERM` = «дочти чанк и отпусти лок», а реконсилятор # закроет его штатно после подъёма демона. Терять нечего, ждать — до одного чанка. # 2. Денежный реестр и вся библиотека — одним дампом. # ⚠ DSN в рекомендуемой раскладке лежит ФАЙЛОМ (`TM_PLATFORM_DSN_FILE=/etc/tmplatform/dsn`), а не # переменной в шелле оператора: в худший день `$TM_PLATFORM_DSN` пуст, и шаг падает первым. pg_restore --clean --if-exists --no-owner -d "$(cat /etc/tmplatform/dsn)" /var/backups/tmplatform/20260905T120000Z/postgres.dump # 3. Каждая книга — на свой рабочий каталог (его называет колонка workdir и манифест точки). cp -a /var/backups/tmplatform/20260905T120000Z/books//. /srv/textmachine/books// # 4. ⚠ СНАЧАЛА УБРАТЬ ХВОСТЫ SQLite, ПОТОМ КЛАСТЬ БАЗУ. Точка НЕ несёт `-wal`/`-shm` (они и есть та # несогласованность, ради которой копию делает движок), а оставшийся в каталоге старый `-wal` от # упавшего процесса SQLite при открытии применит ПОВЕРХ восстановленной базы. На чистом хосте их # нет; на хосте, где чинят одну книгу, — есть. rm -f /srv/textmachine/books//*.db-wal /srv/textmachine/books//*.db-shm # И ТОЛЬКО ТЕПЕРЬ — проектная база. В точке она лежит под ИМЕНЕМ project.db; на месте она # обязана называться так, как её называет `project_db` в book.yaml ЭТОЙ книги, а если ключа там # нет — .db (движковый дефолт, backend/internal/config/book.go, греп `b.ProjectDB =`). mv /srv/textmachine/books//project.db /srv/textmachine/books//.db chown -R tmplatform:tmplatform /srv/textmachine/books/ # 5. ⚠ ОТОЗВАТЬ УЧЁТНЫЕ ДАННЫЕ, ЕСЛИ ОТЗЫВ СЛУЧИЛСЯ ПОСЛЕ СНЯТИЯ ТОЧКИ. Дамп несёт таблицу сессий # целиком, поэтому восстановление ВОСКРЕШАЕТ всё, что было живо в момент снятия — включая # браузерную сессию или bearer-токен, отозванные позже. Это не дефект бэкапа (копия обязана быть # копией), это шаг чек-листа: tmplatformctl revoke --user # для каждого, кому отзывали доступ после штампа точки # 6. Поднять и проверить: баланс на месте, книга на месте, движок открывает её файл. systemctl start tmplatformd tmplatformctl balance --user /opt/textmachine/engine/<версия>/tmctl status --config /srv/textmachine/books//book.yaml ``` ⚠ **Шаг 4 — единственный, где оператор должен посмотреть в `book.yaml`.** Платформа НЕ выводит имя проектной базы сама (закон шва п.1 запрещает ей выводить чужие пути), поэтому и точка хранит копию под нейтральным именем, а не угадывает исходное. ⚠ **Восстановление предъявлено ИСПОЛНЕНИЕМ и запинено тестом, а не описано:** `internal/backup/backup_live_test.go` `TestARestorePointCanActuallyBeRestored` снимает точку настоящим `pg_dump`, восстанавливает её в базу, которая этого деплоя никогда не видела, и читает оттуда БАЛАНС и КНИГУ. Гейт — `TM_PLATFORM_TEST_DSN` плюс `pg_dump`/`pg_restore` на `PATH`. ⚠ **Мажор `pg_dump` обязан совпадать с мажором сервера** — иначе инструмент отказывается снимать дамп с сервера новее себя, а симптом («бэкапы падают») никто не связывает с апгрейдом Postgres, случившимся месяцем раньше. На стенде из рецепта ниже бинарь лежит в `~/.local/pgsql/bin`, и его надо назвать явно через `TM_PLATFORM_PGDUMP_BIN`. ## Где на сервере лежат книги `/srv/textmachine` — корень библиотеки НА СЕРВЕРЕ. Решение владельца «книги живут в `~/books`» относится к машине разработки: под этим юнитом домашние каталоги недоступны и детям-`tmctl` тоже (`ProtectHome=yes`, проверено живым прогоном — §«Файлы»), поэтому библиотека под домашним каталогом на сервере не «сработает медленнее», а НЕ НАЙДЁТСЯ. Кому это нужно, юнит называет ровно две строки замены (`ProtectHome=tmpfs` + `BindPaths=`); других изменений не требуется. ## Дверь для не-браузерного клиента Контракт объявляет `bearerToken` с первой версии, и сервер его ПРИНИМАЕТ на каждом `/v0`-маршруте (`internal/auth/middleware.go`, `Present`), но до 05.09 выдать его было нечем — канон так о себе и писал. Практическое следствие: CLI, десктоп, интеграция и смоук боевого хоста войти не могли ВООБЩЕ, а единственная работавшая дверь — `/auth/dev-login` — прод-конфигурацией **запрещена**: демон с настоящим OIDC и дев-входом рядом не стартует (`internal/config/config.go`, греп `DEV_LOGIN is a development sign-in`). ```sh # ⚠ Запускать С ОКРУЖЕНИЕМ ДЕПЛОЯ (тем же `EnvironmentFile=`, что у юнита): сроки сессии команда # читает из СВОЕГО окружения, и без него токен получит дефолты, а не политику этого деплоя. # Команда ПЕЧАТАЕТ применённые сроки — сверяйте их глазами, это и есть проверка. tmplatformctl token issue --user [--client <слово>] > /root/token.txt # ПЕРВАЯ строка файла — токен; дальше комментарии: аккаунт, сроки, как слать. # ⚠ НЕ `curl -H "Authorization: Bearer $TOKEN"`: аргументы процесса читает любой на хосте # (/proc//cmdline), и это тот же класс, из-за которого DSN не едет в argv у pg_dump. # Заголовок подаётся файлом: printf 'Authorization: Bearer %s' "$(head -1 /root/token.txt)" > /root/hdr && chmod 0400 /root/hdr curl -H @/root/hdr https://app.example.org/v0/books tmplatformctl revoke --user # отзыв — тот же, что у браузерной сессии ``` ⚠ **Это ОБЫЧНАЯ сессия, а не второй сорт credential'а:** те же 256 бит из `crypto/rand`, в БД только sha256, те же два срока (idle скользит, absolute нет), тот же немедленный отзыв, та же строка в журнале входов (`tmplatformctl logins --user`, провайдер `operator`). Отдельный «API-ключ» был бы второй моделью сессии со своей политикой сроков и своим путём отзыва — а первое, что забывают построить для второй модели, это отзыв. ⚠ **Токен печатается ОДИН раз и не восстанавливается:** хранится только его дайджест, и это ровно то свойство, из-за которого украденный дамп базы не даёт рабочего доступа. Потерянный токен — выпускается заново, не ищется. ⚠ **Почему выдача живёт в админ-CLI, а не ручкой HTTP.** Она не даёт полномочий, которых у оператора не было: этим же инструментом он пишет денежный реестр и отзывает сессии. Ручка же была бы НОВЫМ полномочием, достижимым по сети, и потребовала бы минора канона ради маршрута, который до разморозки фронта некому нажать. Браузерная половина («создать токен» кнопкой) строится вместе с фронтом. ## Чего здесь ещё нет TLS и домен (перед юнитом предполагается edge-прокси), ограничитель соединений на edge, ротация логов. ⚠ **Бэкапы из этого списка УШЛИ 05.09** — раздел «Бэкап и восстановление» выше; осталось операторское: увозить каталог точек прочь с хоста и следить за `tm_platform_backup_age_seconds`.