textmachine/platform/deploy
2026-09-02 05:49:24 +03:00
..
README.md Cut documentation sediment in the platform zone: agents read each file end to end and removed layered history whose content lives in the decision journal 2026-09-02 05:49:24 +03:00
tmplatformd.service Land the platform runner pack: transient unit per run, River queue, reconciler with baseline settlement, journal tailer, five v0 endpoints, hardened by three review rounds 2026-08-09 16:48:46 +03:00

Развёртывание платформы

Одна 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/<user> даёт Permission denied; ProtectHome=tmpfs + BindPaths=<каталог> → каталог виден. Ресурсные потолки (MemoryMax=80%, OOMPolicy=continue) живьём не проверялись — вывод из systemd.resource-control(5)/systemd.service(5).

Откат релиза: не ниже версии 5

goose down до версии 4 и ниже НЕ РАБОТАЕТ на живой базе: down-путь 00005 восстанавливает users_email_key и email NOT NULL, а обе формы нарушают строки, которые пишет боевой код (неподтверждённая личность даёт email = NULL; один адрес законно принадлежит двум аккаунтам). Откат транзакционный, поэтому падение ничего не портит — но планировать откат ниже 5 нельзя, план отката — накатить вперёд. Разбор: 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 больше, чем дренаж платформы (15 с) плюс grace движка (30 с). Меньше — и systemd прибьёт tmctl посреди остановки, оставив лок проекта.
  • Секреты через LoadCredential=, а не через окружение: переменная окружения видна в /proc/<pid>/environ и наследуется каждым ребёнком-tmctl. Конфиг читает *_FILE первым.

Установка (набросок, исполняется владельцем)

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
# ⚠ БЕЗ ЭТОГО НИ ОДИН ПРОГОН НЕ СТАРТУЕТ. Прогоны — транзиентные юниты в пользовательском
# менеджере `tmplatform`, а он существует вне сессии входа только при включённом linger.
# Проверка: `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' '<oauth client secret>' > /etc/tmplatform/oidc_client_secret && chmod 0400 /etc/tmplatform/oidc_client_secret

/etc/tmplatform/env — несекретное окружение:

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. БЕЗ ЭТИХ ЧЕТЫРЁХ ИНСТАНС НЕ ЗАПУСТИТ НИ ОДНОГО ПЕРЕВОДА, и это не
# «неполный пример», а окружение, при котором каждый ОПЛАЧЕННЫЙ прогон падает.
TM_PLATFORM_ENGINE_BIN=/opt/textmachine/engine/<версия>/tmctl   # пусто = инстанс только читает
TM_PLATFORM_CTL_BIN=/opt/textmachine/bin/tmplatformctl          # его зовёт юнит как ExecStopPost
TM_PLATFORM_STATE_DIR=/var/lib/tmplatform/state                 # маркеры выхода; ТОЛЬКО абсолютный
TM_PLATFORM_ENGINE_KEYS_PATH=/etc/tmplatform/engine-keys        # KEY=VALUE, едет --keys-file

Почему каждая из четырёх обязательна, а не желательна:

  • ENGINE_BIN пусто — инстанс объявляет себя читающей репликой и не спавнит ничего;
  • CTL_BIN — путь, который юнит зовёт в ExecStopPost, чтобы записать маркер выхода; без него прогон завершается, а платформа об этом не узнаёт никогда;
  • STATE_DIR — каталог маркеров. ⚠ Дефолт лежит ВНЕ ReadWritePaths= юнита, а deploy/ ставит ProtectSystem=strict, поэтому без явного значения запись маркера запрещена файловой системой, а не логикой;
  • ENGINE_KEYS_PATH — ЕДИНСТВЕННЫЙ канал провайдерских ключей в движок (едет аргументом --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), поэтому теперь это значение ОПЕРАТОРА и платформа его не трогает.

# /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 с причиной. Починили файл — ближайший свип разбирает всё накопившееся.

Апгрейд ПЛАТФОРМЫ: миграция и бинарь едут вместе

Схема и бинарь платформы — один шаг, а не два. Любая миграция, снимающая колонку, делает перекрытие версий невозможным: инстанс старой сборки рядом с уже мигрированной базой отвечает 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; прежняя цитата «schema vN … expects vM» в этом доке была МЁРТВОЙ — движок её не печатает), а платформа зовёт status перед каждым спавном и на расчёте денег. Значит выкат нового движка на хост с существующими книгами запирает их до миграции файла проекта.

Проверено исполнением на стенде (P7): проектная БД, отведённая на схему v14 при бинаре v15, даёт tmctl status --json → exit 13 с этим токеном; tmctl migrate --config <book.yaml> делает пред-миграционный бэкап и переводит 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 после пяти попыток. Симптом — интейк массово отклоняет при здоровом на вид движке; лечение — выкатить платформенный билд, знающий новую форму. Правило то же, что у схемы хранилища: сначала платформа, потом движок, и оба конца этого правила теперь записаны.

Порядок (действующий). ⚠ Ключевое: migrate гоняется НОВЫМ бинарём — старый уводит файл в свою же схему, то есть не делает ничего, и деадлок остаётся. Поэтому бинарь кладётся ДО миграции, а TM_PLATFORM_ENGINE_BIN переключается ПОСЛЕ.

# 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 больше нуля.

tmplatformctl runs                 # живые прогоны И застрявшие расчёты
tmplatformctl runs --stalled       # только те, что свип не доводит: сколько неудач подряд,
                                   # когда следующая попытка, что сказал движок, сколько держит

Колонка PHASE говорит, какая это половина, и лечение у них разное. live — прогон ещё идёт, и первый вопрос к systemd. settling — прогон УЖЕ кончился, движка нет, а его расчёт не сходится: деньги пользователя заморожены в открытой резервации, и вопрос к systemd бессмыслен.

Дальше решает человек, а не платформа: сперва спросить systemd (systemctl --user status <юнит>). Если процесс жив — остановить его и дать реконсилятору закрыть прогон штатно, это лучше во всём. Если движок не ответит уже никогда (каталог книги унесён, маунт отвалился):

tmplatformctl run abandon --run <id> --reason "почему" [--release-hold]

Команда ОТКАЖЕТ, если попытка ещё называет юнит или несёт базовую линию траты, — закрывать прогон над живым движком нельзя. --reason обязателен: это единственная запись о том, почему оплаченный прогон объявлен законченным. Холд возвращается ЦЕЛИКОМ в обоих случаях (к abandon допускается только попытка, не дошедшая до движка, значит она ничего не потратила); флаг решает лишь КОГДА — сразу или на ближайшем свипе. Он нужен, когда демон остановлен и свипа не будет.

Оговорка PD-418: ветвление run abandon идёт по runs.finished_at, а не по наличию осиротевшей попытки. Значит команда лечит settling-строку только у прогона, который УЖЕ кончился. Живой прогон с нерассчитанной ПРЕДЫДУЩЕЙ попыткой ушёл бы в живую ветку: осиротевший холд команда не тронет, а ответит про процесс. Сегодня это состояние недостижимо — единственный не-тестовый путь ко второй открытой резервации, reopen, отказывается стартовать следующую попытку, пока холд предыдущей открыт, — и пак P12 его достижимее НЕ сделал (блокированная расплата теперь считается неудачей и видна, но рестарт по-прежнему не происходит, PD-424). Оговорка стоит здесь, потому что документ иначе обещает оператору то, чего код не делает, и разойдётся заметно, если этот инвариант когда-нибудь ослабнет. Долговечное лечение — ветвить по НАЛИЧИЮ осиротевшей попытки вместо finished_at, как уже делает settling-ветвь StalledRuns.

Строка PHASE = settling — та же команда, другой исход. Прогон уже кончился, свипа, который довёл бы его расчёт, не будет никогда, поэтому run abandon закрывает деньги В СВОЕЙ транзакции независимо от флага: резервация закрывается, холд возвращается ЦЕЛИКОМ, прогон помечается рассчитанным. Целиком — потому что не хватает как раз движковой цифры (это и есть определение состояния), и суммы, которую платформа могла бы обосновать, не существует; альтернатива — деньги заморожены навсегда. Статус самого прогона НЕ переписывается: он уже закончился со своим исходом. Повторный вызов отвечает «its money is already closed», а не «нет такого прогона».

Книга, чью читательскую поверхность не удаётся построить. После пяти неудач долг списывается, книга остаётся с прежним текстом и попадает в список:

tmplatformctl books --abandoned    # книга, когда сдались, что сказал движок
tmplatformctl book refresh --book <id>   # попросить заново, с чистым бюджетом попыток

Гейдж — tm_platform_reading_surfaces_abandoned. Отказ хоста (движок не запускается, чужой лок, немигрированная схема) попытку НЕ тратит: он про все книги сразу, а не про эту.

Две ручки бюджета свипа (обе печатаются на буте с источником):

Переменная Дефолт Что это
TM_PLATFORM_SWEEP_BUDGET 2m сколько всего может занять один проход свипа
TM_PLATFORM_RUN_BUDGET 1m сколько может занять ОДИН прогон внутри прохода

⚠ Поднимать надо ПАРУ, а не одну: проход делится пополам между сверкой и расчётом денег, поэтому RUN_BUDGET выше половины SWEEP_BUDGET наблюдаемо ничего не меняет (PD-368).

Дев-стенд: живая платформа для фронта (П-16)

Фронт разрабатывается против ЖИВОЙ платформы, а не против своих моков. Рецепт целиком:

# 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
# ⚠ ОБЯЗАТЕЛЬНО, как и в бою: инстанс, принимающий загрузки, обязан объявить хотя бы одну ДОСТУПНУЮ
#    пару, иначе демон отказывается стартовать. Пустой список — это «не объявлено ничего», а не
#    «пускать всё»: без гейта стенд принимал книги, которые мог только провалить.
export TM_PLATFORM_LANGUAGE_PAIRS='zh>ru'
mkdir -p "$TM_PLATFORM_BOOKS_DIR" "$TM_PLATFORM_STATE_DIR"

# 3. Демон (мигрирует сам при TM_PLATFORM_MIGRATE=1).
./tmplatformd &

# 4. Сид: аккаунт + кредит + демо-книга ЧЕРЕЗ ЖИВОЙ ИНТЕЙК (не INSERT).
./tmplatformctl seed --url http://127.0.0.1:8080

# 5. Фронт ходит на стенд своим дев-прокси (TM_PLATFORM на его стороне).

Сессию в браузер выдаёт тот же вызов, что делает сид, — один раз из консоли приложения:

await fetch('/auth/dev-login', {method: 'POST'})

Дев-вход не должен доехать до прода, и это гарантировано четырьмя независимыми свойствами (разбор — internal/login/dev.go): он не монтируется без TM_PLATFORM_DEV_LOGIN; вместе с OIDC демон НЕ СТАРТУЕТ; личность берётся из конфигурации, а не из запроса, поэтому войти «кем-то другим» через него нельзя в принципе; провайдер называется dev и в это пространство имён не может попасть ни один настоящий издатель. На каждый выданный сеанс пишется WARN.

TM_PLATFORM_BOOKS_DIR — каталог, который сервис ПИШЕТ и, на отклонённом интейке, УДАЛЯЕТ. Всё под ним создано загрузкой книг; книга, заведённая дев-инструментом с чужим --workdir, туда не попадает и не удаляется никогда (гард books.owns). Каталог должен существовать и принадлежать tmplatform, и он обязан быть в ReadWritePaths= юнита — при ProtectSystem=strict отсутствующий путь валит сборку mount-namespace целиком (PD-91, та же грабля, что и с /srv/textmachine):

install -d -m0750 -o tmplatform -g tmplatform /srv/textmachine/books

/metrics не аутентифицированего защищает только адрес привязки. Дефолт 127.0.0.1, то есть снаружи он недостижим; поднять его на внешний адрес значит открыть операционную форму деплоя (глубина очереди, число прогонов, возраст холдов) всем, кто до него дотянется. Скрейпер живёт на том же хосте либо ходит через тот же edge, что и API. Разбор — docs/STACK_DECISIONS.md §24, строка риска — PD-179.

Загрузка книги идёт минуты, и это касается edge-прокси. Маршрут POST /v0/books принимает до TM_PLATFORM_MAX_UPLOAD_BYTES (64 МиБ) и держит соединение до TM_PLATFORM_UPLOAD_DEADLINE (10 минут). Прокси перед сервисом обязан разрешать столько же: у него свои client_max_body_size и свои таймауты чтения, и молчаливо режет их он, а не мы.

TM_PLATFORM_STATE_DIR меняется только когда живых прогонов нет. Exit-маркер пишется по пути, вычисленному при СПАВНЕ, а читается по пути из текущей конфигурации: после смены каталога конец идущего прогона становится невидим, и реконсилятор перезапускает его как потерянный (PD-155). Путь обязан быть абсолютным — относительный демон отвергает на старте, потому что маркер пишет юнит из каталога книги, а читает демон из своего.

Миграции выкатываются ОДИН раз, не каждой репликой: TM_PLATFORM_MIGRATE=1 tmplatformd разово либо отдельный шаг деплоя. goose держит advisory-лок, так что параллельный запуск не гонка, но и не норма.

Где на сервере лежат книги

/srv/textmachine — корень библиотеки НА СЕРВЕРЕ. Решение владельца «книги живут в ~/books» относится к машине разработки: под этим юнитом домашние каталоги недоступны вовсе (ProtectHome=yes подставляет пустой /home и детям-tmctl тоже), поэтому библиотека под домашним каталогом на сервере просто не откроется — не «сработает медленнее», а не найдётся. Оператору, которому это нужно, юнит называет ровно две строки замены (ProtectHome=tmpfs + BindPaths=); других изменений не требуется.

Чего здесь ещё нет

TLS и домен (перед юнитом предполагается edge-прокси), ограничитель соединений на edge, ротация логов, бэкап Postgres. Всё это — работа с первым реальным деплоем, не раньше.