textmachine/platform/docs/STACK_DECISIONS.md

93 KiB
Raw Blame History

Стек платформы — пины и обоснования

Зонный документ platform/. Пины сверены ЖИВЬЁМ 04.08 и 05.08.2026 (Go-прокси @latest, postgresql.org, go.dev/dl) — версии по памяти не называются. Порядок ратификации новой зависимости — ENGINEERING_STANDARDS.md §1.

Общая записка по обоим новым сервисам — frontend/docs/STACK_DECISIONS.md §5 (02.08); здесь — платформенная часть с датами релизов и сверкой.

Пины

Что Пин Релиз пина Зачем нам
Go (язык, go.mod) 1.26.4 02.06.2026 Тот же floor, что у движка (backend/go.mod) — общий стенд собирает оба модуля одним тулчейном
Go (тулчейн сборки, make version-check + toolchain в go.mod) ≥1.26.6 13.08.2026 Поднят с 1.26.5 не по вкусу, а по make vuln: база адвизори опубликовала пять уязвимостей stdlib против 1.26.5 — net/http, crypto/tls, net/url, encoding/xml, encoding/asn1 (GO-2026-6218/6090/6089/6088/5972), все закрыты в 1.26.6, и две трассируются в пути, которые эта служба зовёт (pgstore.Open → pgx.ParseConfig → asn1.Unmarshal). На 1.26.6 батарея зелёная и скан чист. ⚠ Как floor РЕАЛЬНО держится: make version-check СРАВНИВАЕТ версии (sort -V, префиксы rc/devel отвергаются), и go.mod несёт toolchain go1.26.6его читает всякая сборка, даже мимо make: при GOTOOLCHAIN=auto хост скачает нужный тулчейн, при =local остановится с ошибкой. Само сравнение запинено (internal/gates)
PostgreSQL 18.x (проверено на 18.4), floor 16 18.4 — май 2026 18 — текущая мажорная (19 в бете, в прод не берём); floor 16, потому что River тестируется на трёх последних мажорных
HTTP stdlib net/http + ServeMux Роутер-библиотека не нужна: ServeMux с 1.22 умеет метод+wildcards, а Request.Pattern даёт лог по маршруту, не по пути
CSRF stdlib http.CrossOriginProtection Go 1.25 Ровно тот механизм, что описан в §5 (Sec-Fetch-Site → Origin), теперь в тулчейне — свой велосипед не пишем
Postgres-драйвер github.com/jackc/pgx/v5 v5.10.0 03.06.2026 Живой pool, pgconn.PgError для проверки констрейнтов, stdlib для goose
Миграции github.com/pressly/goose/v3 v3.27.3 22.07.2026 Библиотекой + embed.FS; WithSessionLocker = advisory-лок, две реплики выкатываются по очереди
Очередь github.com/riverqueue/river v0.42.0 31.07.2026 В go.mod и в работе с P4; устройство и цена формы — §19
OIDC-вход golang.org/x/oauth2 v0.36.0 + github.com/coreos/go-oidc/v3 v3.20.0 11.02.2026 · 08.07.2026 Ратифицировано PLATFORM_DIRECTION.md §1 (там же — что отдано библиотекам и что пишем сами); сверено живьём 05.08. Транзитивно приходит go-jose/v4 v4.1.4
Рейт-лимит в процессе golang.org/x/time v0.15.0 11.02.2026 rate.Limiter на ОБЕИХ неаутентифицированных ручках, которые ПИШУТ: /auth/login (строка состояния) и /auth/callback (строка журнала на каждом отказе — замерено ~880 строк/с с одного хоста, пока лимита не было)
Линтер golangci-lint 2.12.2 06.05.2026 Тот же пин, что у движка: находки версионно-зависимы, разъезд пинов = разные гейты в одном репо
Кодоген SQL sqlc v1.31.1 22.04.2026 Инструмент разработчика, НЕ зависимость модуля — в go.mod не входит, рантайм-граф не растёт ни на один пакет; пин держит make tools-check, как у линтера, и по более острой причине: генерённый код лежит В ДЕРЕВЕ, поэтому другая версия молча даёт другой диф и sqlc diff краснеет на чистом клоне. Зачем: генерирует слой запросов свободного от склейки блока internal/pgstore (40 запросов из queries/*.sql) — SQL и его Scan перестают быть двумя списками, которые сверяет человек ПО ПОЗИЦИИ. Взят по решению владельца 20.08 (D39.153 п.6а, уточнение D39.154), носитель — BACKLOG.md П-19. ⚠ Довод «гейт sqlgate_test.go это уже закрыл» ПРОВЕРЕН и не подтвердился: гейт получает только СТРОКУ SQL и Go-сторону вызова не видит — шесть посаженных перестановок целей Scan и сломанных арностей ВЫЖИЛИ на полной батарее (замер 29.08, platform-PROGRESS.md). Схему читает ТОТ ЖЕ каталог migrations/, что гейтит манифест (второго носителя нет); деньги держит подстановочный override *.*_micro_usdmoney.MicroUSD — без него генератор выдаёт голый int64, с ним снятие override становится ОШИБКОЙ СБОРКИ. Актуальность генерации гейчена дважды: sqlc diff в make check и pgstore.TestEveryGeneratedQueryMatchesItsSourceFile в батарее, который работает и без установленного sqlc
Уязвимости govulncheck v1.6.0 09.07.2026 Отдельная цель make vuln, не часть check: ей нужна сеть, а батарея обязана быть зелёной на голом клоне офлайн

Redis нет — архитектурное «нет» в силе, с доводом: иначе он приползёт по частям. Дом решения — platform/README.md (греп Redis не заводим нигде); очередь, лизы и рейт-лимиты живут в том же Postgres.

Что решено этой сессией (сверх §5)

  1. /healthz/readyz. Liveness ничего не трогает (БД лежит — процесс жив). Readiness с P2 спрашивает не «отвечает ли база», а «та ли это база, под которую собран бинарь»: пинг плюс сверка goose_db_version с максимальной вшитой миграцией. Одного пинга было мало — он успешен и на Postgres без единой таблицы, то есть в нормальной середине выката, где миграция ещё не накачена (PD-68). Пустой TM_PLATFORM_DSN — легальный старт: сервис поднимается и честно говорит «не готов». Иначе супервизор убивает здоровый процесс за то, что база моргнула.
  2. Ops-эндпоинты вне версионного префикса. /healthz, /readyz — в корне; контрактная поверхность целиком под /v0 (базовый путь спеки платформа ПОДТВЕРЖДАЕТ).
  3. Один mux. Контрактные маршруты регистрируются с префиксом в паттерне, а не вложенным mux'ом под StripPrefix: вложенный получает КОПИЮ запроса, и Request.Pattern наружу не возвращается — лог пришлось бы писать по сырому пути с id книг. Проверено исполнением.
  4. Гард навешен на поддерево, а не на ручки. Неизвестный путь под /v0 отвечает 401 раньше 404: аноним не должен картографировать поверхность.
  5. Заголовки безопасности — на каждом ответе (X-Robots-Tag: noindex, Cache-Control: no-store, nosniff, no-referrer): ПТ-34 нельзя оставлять на дисциплину автора следующей ручки.
  6. Батарея зоны — make check (build · vet · fmt · lint · test -race), форма скопирована с backend/Makefile вплоть до именования пропущенных тестов: тихий skip читается как покрытие.
  7. Тесты с БД гейтятся TM_PLATFORM_TEST_DSN и создают СВОЮ базу на прогон (дропают в t.Cleanup). Батарея на голом клоне зелёная и офлайн; с DSN — та же батарея плюс схема.

Что решено сессией P1 (05.08)

  1. Миграции append-only, БЕЗ исключений — включая «до первого деплоя». Причина: goose записывает только НОМЕР (ни имени, ни хеша), поэтому база, доехавшая до версии 3, на новом наборе рапортует «migrations applied» и не получает ни одной новой таблицы, а DownTo на ней ломается навсегда. Дев-воркфлоу из этого же документа создаёт ровно такую среду. Гейт: migrations.sha256 + тест TestReleasedMigrationsAreUnchanged — чтобы изменить выпущенную миграцию, надо осознанно изменить строку в манифесте, где это видно ревьюеру. Апгрейд со старого релиза проверен исполнением (TestDatabaseAtAnOlderReleaseCatchesUp), down-путь — тоже.

    Цена правила, названная честно: откат НИЖЕ версии 5 недоступен. Down-путь 00005 восстанавливает users_email_key и email NOT NULL — ровно то, что его же up-путь снял, — а обе эти формы нарушаются строками, которые пишет боевой код: email = NULL у неподтверждённой личности и один подтверждённый адрес на двух аккаунтах (прямое следствие «почта не ключ»). Значит DownTo(<5) на живой базе падает. Править 00005 нельзя — это и есть append-only, — а новая миграция чужой down-текст не заменяет. Данные при этом целы: down транзакционный, Up() возвращает схему на текущую версию (проверено прогоном: DownTo(4) падает на users_email_key, SQLSTATE 23505). Принято как цена правила.

  2. Ключ личности — (provider, subject); почта не ключ. users.email стала NULLABLE и БЕЗ уникального индекса; неизвестная пара всегда создаёт НОВЫЙ аккаунт. Разбор и цена решения — platform/docs/archive/platform-PROGRESS-P0-P3.md:496=### Политика коллизии почты (раздел закрыт вместе с эрой P0P3, в живом журнале его нет).

  3. Админ-поверхность — CLI (tmplatformctl), не HTTP-ручка. Ручке понадобилась бы вторая модель авторизации (роли, эскалация, отзыв админской куки) ради пяти операций (grant · adjust · balance · logins · revoke), тогда как граница доверия «есть шелл на машине и доступ к DSN» уже обеспечена машиной. Браузерная панель, если понадобится, обернёт те же вызовы стора. 10а. Имя провайдера — TM_PLATFORM_OIDC_PROVIDER, и оно должно меняться ВМЕСТЕ с издателем. Это первая половина ключа личности. Направить TM_PLATFORM_OIDC_ISSUER на другой IdP, оставив имя прежним, — значит сложить sub нового провайдера в старое пространство имён, то есть тихо связать чужие аккаунты. Переменная называется здесь, потому что в деплой-примере её не было и оператору нечему было напомнить.

  4. Секреты — через *_FILE. TM_PLATFORM_DSN_FILE и TM_PLATFORM_OIDC_CLIENT_SECRET_FILE читаются раньше одноимённых переменных: переменная окружения видна в /proc/<pid>/environ и наследуется каждым ребёнком-tmctl. Это же формат LoadCredential= systemd (deploy/).

  5. ReadTimeout есть, WriteTimeout нет. Первый закрывает PD-2 (проверено живой пробой); второй зарезал бы SSE на фиксированном возрасте.

    Поток при этом от хендлера ничего не требует: net/http снимает read-дедлайн САМconnReader.startBackgroundRead делает SetReadDeadline нулевым временем (server.go:687-698), и для запроса без остатка тела это происходит ДО хендлера, иначе на EOF тела (:2059-2062); по ходу хендлера дедлайн не перевзводится. Проверено исполнением на шести комбинациях (GET без тела · POST с непрочитанным телом · POST с вычитанным).

    Снимать дедлайн руками ЗАПРЕЩЕНО, и это не стилистика. На полу-кормленном запросе (тело анонсировано и не дослано) дренаж внутри записи заголовка ответа — единственное, что ограничивает соединение, и ограничен он как раз ReadTimeout. Снятие дедлайна до записи заголовка убирает эту границу: замерено — хендлер остаётся внутри WriteHeader и через 4 с после ухода клиента, то есть PD-2 воспроизводится тем самым вызовом, который был заведён как его исправление. Поэтому httpapi.ClearReadDeadline удалён (PD-51): случая, где он помогает, нет — на корректном запросе это no-op, на полу-кормленном вред. Unwrap в обёртках остаётся обязательным: через него поток дотягивается до Flush, и это запинено проверкой ошибки Flush в TestStreamOutlivesReadTimeout.

  6. Политика сессий: 14 суток бездействия, 30 суток абсолютных. Раздел существует потому, что ENGINEERING_STANDARDS §2 объявил зоне ASVS 5.0 L2, а 7.1.1 требует не значения, а ДОКУМЕНТ: «the user's session inactivity timeout and absolute maximum session lifetime are documented … includes justification for any deviations from NIST SP 800-63B re-authentication requirements».

    Уровень — AAL1. Второго фактора со своей стороны мы не проверяем; что там делает Google — его дело и в нашу гарантию не входит.

    Сверка с NIST SP 800-63B-4 §2.1.3 (AAL1), дословно: «A definite reauthentication overall timeout SHALL be established, which SHOULD be no more than 30 days at AAL1. An inactivity timeout MAY be applied but is not required at AAL1.»

    • Абсолютный срок — 30 суток. Отклонения нет. Было 90; 90 — это отклонение от SHOULD, а обоснования у него не нашлось: на аккаунте лежит тратимый баланс, а повторный вход у уже залогиненного в Google человека — один клик. Абсолютный срок не рвёт ПРОГОН: прогон живёт серверным процессом и переживает истечение сессии. Значение переопределяется TM_PLATFORM_SESSION_MAX_AGE, и если владелец хочет 90 — это одна переменная и запись здесь.
    • Срок бездействия — 14 суток. На AAL1 он не требуется вообще (MAY), так что наличие строже нормы. Скользит только во второй половине окна — чтобы каждый запрос не писал в БД.

    7.1.2, одновременные сессии. Ограничения нет, и это решение, а не умолчание: контракт предусматривает две презентации одной личности одновременно (кука в браузере, Bearer в десктопе/CLI — D39.84), поэтому лимит ломал бы штатный сценарий. Что стоит вместо лимита: своя строка на каждый вход, мгновенный отзыв любой из них, POST /auth/logout-all и tmplatformctl revoke как «выйти везде», журнал login_events как ответ на «откуда входили». ⚠ Показ пользователю списка его сессий (ASVS 7.5.2) не сделан — это работа П-1.

    Отзыв прекращает и уже установленные длинноживущие каналы, а не только новые запросы. Открытый GET /v0/books/{bookId}/events пере-спрашивает свою сессию на каждом тике опроса и завершается терминальным кадром session_ended (канон 0.8.0), когда сессия отозвана или исчерпала АБСОЛЮТНЫЙ срок. Замерено живьём: tmplatformctl revoke --user → поток кончился через одну секунду; на демоне с абсолютным сроком 40 с и сроком бездействия 10 с поток кончился ровно на 40 с. ⚠ Срок БЕЗДЕЙСТВИЯ к этому каналу не применяется, и это не упущение. Окно бездействия скользит на ЗАПРОСЕ, а поток — один запрос на всю свою жизнь, поэтому поток по построению не может сдвинуть собственный дедлайн; гашение по нему обрывало бы связь пользователю, который сидит и смотрит. Поток ограничен ровно теми двумя фактами, на которые он повлиять не может: отзывом и абсолютным сроком. ⚠ Остаток, названный прямо: SweepSessions раз в час удаляет строки и по бездействию тоже, а отсутствие строки обязано значить «мертва» (иначе отозванная сессия жила бы до свипа) — поэтому сессия, протухшая по бездействию и подметённая, теряет поток с опозданием до часа. К этому моменту любой другой её запрос — 401.

    7.1.3 / 7.6.1, согласование с федеративной сессией. Наша сессия живёт СВОЕЙ жизнью: RP-initiated logout и back-channel logout не реализованы. Следствия названы прямо: выход из Google не завершает нашу сессию, и отзыв доступа на стороне Google — тоже. Единственные границы — наши два срока и наш отзыв. Это и есть причина, по которой абсолютный срок выровнен по NIST, а не растянут: пока нет канала «IdP сказал, что сессия кончилась», абсолютный срок — единственное, что вообще ограничивает жизнь сессии после события на стороне провайдера.

    7.6.2 выполнено: сессия создаётся только в колбэке потока, который человек начал явным действием, и провайдер показывает свой экран согласия. Без взаимодействия сессия не появляется.

    7.3.1 / 7.3.2 — про МЕХАНИЗМ, а не про документ, которого требуют 7.1.x. Дословно (ASVS 5.0 V7, обе — уровень 2): 7.3.1 — «Verify that there is an inactivity timeout such that re-authentication is enforced according to risk analysis and documented security decisions»; 7.3.2 — то же про «absolute maximum session lifetime». ⚠ Читать точно: они требуют не КОНКРЕТНОЙ величины, а того, чтобы механизм СУЩЕСТВОВАЛ и принуждал к повторной аутентификации согласно задокументированному решению — то есть согласно тексту выше. Обе выполнены и запинены: срок бездействия и абсолютный срок — два независимых условия одного запроса в pgstore/sessions.go, Touch зажимает новый дедлайн абсолютным потолком, и на границе стоит TestSessionClocksStayWithinTheDeclaredBaseline (internal/config): поднятие ДЕФОЛТА выше 30 суток роняет батарею. Значение из окружения тест видит только если оно задано в среде прогона — оператора, поднявшего TM_PLATFORM_SESSION_MAX_AGE, ловит этот раздел, а не батарея.

  7. Против IdP mix-up — параметр iss авторизационного ответа (RFC 9207), а не раздельные redirect URI. Решение принято ДО второго провайдера намеренно: пока провайдер один, сверка «конфигурация против самой себя» выглядит работающей и перестаёт ею быть ровно в момент, когда появляется второй (PD-57).

    Что говорит норма. RFC 9700 §4.4.2: «When an OAuth client can only interact with one authorization server, a mix-up defense is not required. In scenarios where an OAuth client interacts with two or more authorization servers, however, clients MUST prevent mix-up attacks», и обе защиты требуют одного и того же: хранить издателя, которому ушёл запрос, и привязать это к браузеру. §4.4.2.2 (раздельные redirect URI) — фолбэк: «SHOULD therefore only be used if other options are not available».

    Почему iss, а не redirect URI. Альтернатива «iss из ID-токена» нам не подходит: у нас чистый code flow, ID-токен приходит от token endpoint, то есть ПОСЛЕ того, как код уже отдан — а утечка кода не туда и есть содержание атаки. Фолбэк с раздельными URI не нужен: Google поддерживает RFC 9207 — в его discovery-документе authorization_response_iss_parameter_supported: true (сверено живьём 05.08, https://accounts.google.com/.well-known/openid-configuration).

    Что сделано: auth_states.issuer хранит издателя, которому ушёл запрос (миграция 00008), а колбэк сверяет с ним iss ответа простым строковым сравнением до обмена кода (RFC 9207 §2.4) и отказывает, если параметр СОРВАН, когда провайдер по discovery его шлёт — иначе снятие параметра и есть обход проверки. Отдельно осталась сверка st.Provider с конфигурацией: это наш ключ маршрутизации, а не идентификатор из нормы, и отвечает она на другой вопрос.

Что решено сессией P4 (08.08) — раннер

  1. Прогон — транзиентный юнит в СОБСТВЕННОМ systemd-менеджере платформы (linger-пользователь), не в системном через polkit.

    research/25 §Опс называет два варианта и требует решить ДО постройки. Второй отвергнут не по вкусу, а по свойству polkit: polkit авторизует ГЛАГОЛ, а не свойства. StartTransientUnit позволяет ВЫЗЫВАЮЩЕМУ выбрать ExecStart= и User=, а транзиентный юнит в системном менеджере без User= идёт от root. Значит любое правило, разрешающее сервису создавать транзиентные системные юниты, выдаёт ему root, и сузить это правилом нельзя — свойства в запрос авторизации не входят. Вдобавок до systemd v257 в запрос не попадает даже ИМЯ юнита (systemd issue #17224, закрыт PR #34651, влит 09.10.2024), поэтому action.lookup("unit") не определён и «узкое» правило на стенде (systemd 255) не узкое вовсе.

    Цена выбранного: одно root-действие при установке (loginctl enable-linger) и две строки в deploy/tmplatformd.service, которыми сервис дотягивается до своей же пользовательской шины. Новых привилегий в рантайме — ноль: пользователь всегда вправе управлять своими юнитами.

  2. Юниты прогонов кладутся в СВОЙ срез tm-runs.slice, и это не косметика. Замерено на стенде (systemd 255, ядро WSL2 6.18): в дефолтном app.slice пользовательского менеджера у листового cgroup НЕТ ни одного управляющего файла — app.slice/cgroup.subtree_control пуст, поэтому MemoryMax= и TasksMax= систем-д ПРИНИМАЕТ, отдаёт обратно в systemctl show и не применяет: процесс, потрогавший 400 МиБ страниц, пережил MemoryMax=64M. В своём срезе лист получает настоящие memory.max=67108864 и pids.max, а тот же процесс получает oom-kill на пределе (SERVICE_RESULT=oom-kill, Memory peak: 64.0M). Это и есть ответ PD-13 — измеренный, а не объявленный. Пин — runner.TestARunIsBoundedByItsOwnCgroup; посадка «вернуть app.slice» падает.

    MemorySwapMax=0 идёт ВМЕСТЕ с MemoryMax, и это не украшение. Найдено собственным флейком: тот же пин прошёл трижды и упал на четвёртый, а у среза в systemctl status обнаружился swap peak: 692.5M — то есть на пределе ядро выдавливало страницы в СВОП, процесс выживал и замедлялся до скорости диска. Для перевода это хуже отказа, который заменяет: тормозящий часами прогон продолжает платить за каждый вызов, который всё-таки проходит. Со снятым свопом лимит — то, чем себя называет: три прогона подряд зелёные, три посадки «убрать MemorySwapMax» подряд красные.

  3. Конец юнита читается из МАРКЕРА, а не из systemd. Юниты запускаются с --collect, поэтому после выхода они выгружаются: systemctl show отвечает LoadState=not-found и ExecMainStatus=0 и для прогона, вышедшего с кодом 3, и для имени, которого никогда не было (замерено). ExecStopPost= пишет маркер атомарно (temp+rename+fsync) и несёт $SERVICE_RESULT/$EXIT_CODE/$EXIT_STATUS — замерено на всех трёх исходах: exit-code/exited/3, success/killed/TERM (наша остановка), oom-kill/killed/TERM. D-Bus-сигнал отвергнут по прямому доводу research/25: платформа лежит несколько раз в неделю ПО ЗАМЫСЛУ, а сигнал, посланный в этот момент, теряется — файл переживает.

    ⚠ Замерено там же: значения --property= от systemd-run спецификаторы НЕ раскрывают (100%_done и %n доехали буквально), поэтому экранировать % не нужно и было бы неверно. Кавычки для аргументов с пробелами нужны — проверено.

  4. Маркер пишет tmplatformctl exit-marker, а не однострочник в юните. Три причины, каждая кем-то оплаченная: у команды внутри свойства systemd своя кавычковая грамматика, и каталог состояния с пробелом молча распадается на два аргумента; запись обязана быть АТОМАРНОЙ, потому что читатель опрашивает и половина маркера читается как «прогон кончился»; функцию на Go можно протестировать, а строку в свойстве — нет. Команда работает БЕЗ базы и без секретов и диспетчеризуется до чтения DSN (пин — TestTheExitMarkerCommandNeedsNoDatabase).

  5. Очередь River v0.42.0 добавлена в go.mod и мигрируется СВОИМ мигратором. Её SQL принадлежит ей: копировать чужие миграции в наш append-only манифест значит держать снимок чужой схемы, который тихо перестаёт совпадать с читающей его библиотекой. Две летописи в одной базе — честная форма; /readyz поэтому проверяет и goose_db_version, и наличие river_job (класс PD-68: инстанс, ответивший «готов» и не умеющий принять прогон, соврал балансировщику). Пин — TestReadinessCoversTheQueueSchema.

    MaxAttempts: 1 у задания — намеренно против рефлекса очередей: повтор здесь не доделывает потерянную работу, а СПАВНИТ второй движок; работу восстанавливает реконсилятор, который читает мир, а не доверяет представлению задания о нём.

  6. Ставка «главы → доллары» — константа платформы. Движковой поверхности оценки не существует, и выдумывать её запрещено промтом; провенанс числа — docs/experiments/08-cost-model-v2.md (стандарт-микс $10.94 на 500 глав = $0.0219/глава) через ревизию D30.4 (+1525% → $0.02520.0274), округлено ВВЕРХ до $0.03: заниженная ставка даёт пользователю выбрать больше глав, чем покрывают деньги, и прогон встаёт на потолке — ровно тот исход, ради предотвращения которого шкала и существует. Переопределяется TM_PLATFORM_USD_PER_CHAPTER. Пин на полосу провенанса — pricing.TestTheDefaultRateStaysInTheBandItsProvenanceGives.

Что решено дофиксом P4 (09.08)

  1. --ceiling-usd — это КНИЖНЫЙ потолок в силе, а не бюджет прогона, и пересчёт лежит на платформе. Флаг переопределяет ceilings.book_usd и сравнивается с накопленным committed + reserved книги на КАЖДОЙ резервации (backend/internal/store/ledger.go Reserve; сам флаг документирует это словами «not this run's increment»). Пользователь же покупает ПРИРОСТ в главах (D39.110), поэтому платформа обязана перевести одно в другое — эта обязанность и ратифицирована D39.122. Формула зоны: аргумент = committed + прирост×ставка. ⚠ Слагаемого reserved в ней нет, вопреки букве пинга ратификации, и это НАЗВАННОЕ отклонение в консервативную сторону: store.Open (путь записи каждого translate) обнуляет остаточный reserved_usd книги ДО первой судимой резервации (backend/internal/store/store.go:88, :214), а read-only status этот проход не делает — значит прочитанная цифра к моменту сравнения уже стёрта, и её добавление отдало бы прогону запас БОЛЬШЕ его холда. Разбор — PD-158; ратифицировано 09.08 (оркестратор пере-мерил обе формулы против гейта движка: ратифицированная переплачивала запасом ровно на leftover-reserved). Обе величины читаются ОДНИМ вызовом status --json перед стартом — они должны быть согласованы между собой, а второй вызов стоит секунды CPU на пере-нарезку.

    Три следствия, каждое построено: reserved_usd лежит в аллоулисте УКАЗАТЕЛЕМ и обязателен (отсутствие ≠ ноль) — он не входит в потолок, но именно он доказывает, что отклонение безопасно: на спавне другого писателя нет, значит любой резерв по построению остаток; рестарт считает по СВЕЖЕМУ отсчёту, потому что прерванная попытка счётчик сдвинула; фактически ушедшее значение хранится (run_attempts.ceiling_arg_micro_usd) — после сдвига счётчика его нечем восстановить, а «какой лимит был у того процесса» это первый вопрос к прогону, вставшему рано.

    ⚠ Тестировать это фейком, который лишь записывает аргумент, нельзя: фейк, который не может отказать, не проверяет ничего. Пины гоняют ceilingJudge — фейк с правилом движка.

  2. Порядок блокировок в pgstore — глобальный и записан в одном месте (lockBook): books → runs → run_attempts → account_balances → reservations. Не стилевое соглашение: транзакция, взявшая две таблицы в обратном порядке, дедлочит с любой другой, Postgres рвёт цикл откатом одной стороны, и цена — упавший свип или запрос. Замерено дважды: 41 дедлок на 300 раундов у пары Hold↔Settle (PD-26) и 258 из 300 у пары материализатор↔реконсилятор (PD-145).

    Порядок утверждается ПРЯМЫМ пином, а не конкурентным прогоном: тест держит замок книги, дожидается, пока операция реально заблокируется, и проверяет строку попытки через for update nowait. Конкурентная проба оставлена, но она пробует — посадка «снять книгу-первой из RestartRun» её пережила, потому что рестарт берёт замок один раз за прогон.

  3. «Ошибка материализации» — два разных факта, и различает их runs.quarantines. Транзиентное — повтор следующим свипом; сюда входят не только дедлок и сериализация (40P01/40001), но и всё, во что превращается ШТАТНЫЙ рестарт управляемого Postgres: класс 08, 57P01/57P02/57P03, pgconn.SafeToRetry, любой net.Error. ⚠ Снятия карантина в дереве нет, поэтому ошибочный карантин необратим и ослепляет проекцию живого платного прогона навсегда. Граница держится на типах: ошибка чтения ФАЙЛА — *fs.PathError, а он net.Error не удовлетворяет; пропасть, конфликт payload, битая строка — карантин ПОПЫТКИ (её проекции), прогон при этом продолжается и продолжает платить.

Что решено сессией P5 (11.08) — загрузка книги, стоп/резюм, наблюдаемость

  1. Метрики — prometheus/client_golang v1.24.1, на ОТДЕЛЬНОМ слушателе, и это выбор против stdlib. Норма зоны требует stdlib прежде библиотеки (ENGINEERING_STANDARDS §1), поэтому первым рассмотрен expvar — и он этой работы не несёт: нет лейблов (⇒ «запросы по маршруту и коду» не выражаются вовсе), нет гистограмм (⇒ на вопрос о задержке остаётся среднее — единственная статистика, которая прячет хвост), а его JSON не читает ни один скрейпер без переводчика. Сэкономил бы он зависимость, а стоил бы написания недостающих трёх руками — то есть ровно того самописного пути, который та же норма и запрещает. OpenTelemetry для этого деплоя тяжелее: коллектор процессом, протокол экспорта настройкой, и всё равно scrape-эндпоинт на конце. Пин сверен живьём 11.08 (proxy.golang.org/@latest), релиз 24.07.2026.

    Внешний эталон оси (половина PD-115): практики именования Prometheus плюс «четыре золотых сигнала» на вопрос «что мерить» — обе нормой в ENGINEERING_STANDARDS.md §2. Пин на форму — metrics.TestTheRunnersStateIsExposedWithItsUnits, он же ловит единицу в имени.

    Кардинальность: лейбл несёт ПАТТЕРН маршрута, никогда путь — сырой путь это библиотека пользователя в индексе оператора (PD-3) и неограниченное число рядов; неразобранный запрос сворачивается в один ряд (unmatched), потому что там лейбл выбирает не сервер. Пин — TestRequestsAreCountedByRoutePatternAndNeverByPath.

    Отдельный слушатель (TM_PLATFORM_METRICS_ADDR, дефолт 127.0.0.1:9464), а не маршрут под /v0. Контрактная поверхность отвечает 401 раньше 404, чтобы аноним не картографировал её (§4), а экспозиция несёт ту же породу фактов — глубину очереди, число прогонов, размер деплоя. Выходов два: вторая модель авторизации для скрейпера или привязка туда, где достаёт только хост; второе — то, что делает каждый контрол-плейн, и это строка деплоя вместо ещё одной вещи, которую надо не сломать (та же логика, по которой админ-поверхность стала CLI, §10). Не поднявшийся слушатель — WARN, не фатал: телеметрия это как за сервисом смотрят, а не как он служит.

    Значения снимает СВИП, а не скрейп. Гейджи ставятся раз в проход реконсилятора, который и так читает базу по таймеру; коллектор, ходящий в Postgres на каждый скрейп, отдал бы нагрузку на контрол-плейн тому, у кого есть доступ к эндпоинту.

  2. Дедлайн чтения маршрута загрузки РАСШИРЯЕТСЯ, но никогда не снимается. ReadTimeout сервера (30 с) покрывает ВЕСЬ запрос вместе с телом — это и есть закрытие PD-2, — а книга это десятки мегабайт и минуты бытового аплинка. Маршрут POST /books ставит собственный конечный дедлайн через http.ResponseController (TM_PLATFORM_UPLOAD_DEADLINE, дефолт 10 минут); §12 запрещает СНЯТИЕ дедлайна, и запрет в силе — снятый дедлайн возвращает полу-кормленный запрос к неограниченному удержанию, а расширенный оставляет его ограниченным. Первоисточник тот же, что и у самого запрета: доккоммент net/http.Server.ReadTimeout называет пер-запросный дедлайн именно для этого случая («Because ReadTimeout does not let Handlers make per-request decisions on each request body's acceptable deadline or upload rate…»). Пин — TestTheUploadRouteExtendsItsOwnReadDeadlineAndOnlyItsOwn, и он двусторонний: тот же медленный запрос на ОБЫЧНОМ маршруте обязан быть отрезан. ⚠ Следствие для обёрток: любая обёртка ResponseWriter обязана нести Unwrap, иначе расширение молча становится no-op (посадка «убрать Unwrap из метрик» падает).

  3. Приём книги: строка ПЕРЕД байтами, файл в каталоге книги, разбор — отдельным шагом. Порядок «строка, потом байты» делает uploading состоянием, которое кто-то может наблюдать (вторая вкладка видит книгу, пока файл ещё идёт), и — что важнее операционно — делает брошенную загрузку НАХОДИМОЙ: строка единственное, что говорит, чей каталог под BooksDir. Цена порядка названа: языки обязаны прийти ДО файла, потому что потоковый читатель отдаёт части в порядке провода; тем же правилом специфицирована браузерная загрузка S3, а в спеке нашего контракта его нет — заведено вопросом владельцу контракта (PD-172).

    Тело читается r.MultipartReader и льётся в файл source.<расширение>; расширение сохраняется, потому что движок диспетчеризует читатель по нему (.epub → epub, иначе текст), а не потому, что платформа знает форматы — списка форматов у неё нет и не должно быть. Разбор = tmctl manifest --json, $0-команда, которая и создаёт БД проекта; она даёт число глав и идентичность разреза (chunker_version, source_sha256).

    Два разных отказа и две разных судьбы файла. Движок ОТВЕТИЛ «не разобрать» (*exec.ExitError) — это про книгу, терминально с первого ответа, каталог удаляется: перепарсить нечем, скачать нечем, а аутентифицированный маршрут, который пишет на диск оператора и ничего не убирает, был бы дырой, которую этот пак открыл бы сам. Движок НЕ ЗАПУСТИЛСЯ — это про хост: заявка возвращается, следующий свип пробует снова, и только после бюджета попыток книга становится rejected с файлом НА МЕСТЕ (удалять чужую загрузку из-за своей поломки — не наше решение).

  4. Стоп различается НАМЕРЕНИЕМ, записанным до сигнала, а не догадкой по коду выхода. Движок ловит SIGTERM и выходит кодом 1, поэтому маркер пользовательского стопа и маркер аварии — одни и те же байты (PD-152). Платформа пишет runs.stop_requested_at в Postgres и только потом просит systemd; на гонку «стоп против самостоятельного финиша» стоит сверка времени маркера, а чистые исходы движка (0/2/3) намерение перебивают — завершённый перевод не должен показываться отменённым. Следствия, каждое запинено: прогон с намерением НЕ перезапускается реконсилятором (иначе деньги уходят на работу, которую владелец только что отменил), живой юнит с намерением получает повторный стоп (закрывает «платформа умерла между записью и вызовом»), а стоп до спавна заканчивает прогон и возвращает холд целиком. ⚠ Что этим НЕ закрыто и названо: штатная перезагрузка хоста по-прежнему выглядит как авария — там намерения нет ни у кого, и различить это может только сам движок различимым кодом выхода (строка 165 единого бэклога).

Что решено сессией P6 (14.08) — потребительская половина шва, интейк формы Б, дев-стенд

  1. gopkg.in/yaml.v3 v3.0.1 переходит из транзитивных в ПРЯМЫЕ, и это не новая зависимость в графе. Она уже приезжала транзитивно (goose); прямой её делает рендер стартового book.yaml (форма Б, D39.130). Тот же пин, что у движка — один YAML-парсер на репозиторий, иначе две стороны одного файла расходятся в краевых случаях, которые никто не ищет.

    Норма зоны требует stdlib прежде библиотеки, и здесь она соблюдена в обратную сторону: stdlib YAML не имеет, а альтернатива — шаблонизировать файл ТЕКСТОМ через text/template — проиграла по трём измеримым свойствам, каждое из которых ломает книгу молча. Ключ, который оператор уже написал в шаблоне, текстовая подстановка ДУБЛИРУЕТ (а дубль ключа — жёсткая ошибка строгого декодера движка); комментарии и порядок ключей оператора не переживают перегенерацию, а файл после рендера принадлежит ему; и значение вроде 123 или true уезжает как число или булево — книга с названием «123» тогда просто не грузится. Работа с УЗЛОМ (yaml.Node + !!str) решает все три, и стоит она одной библиотеки, которая уже была в графе.

    ⚠ Названо ревью пака: gopkg.in/yaml.v3 v3.0.1 — последний релиз по этому пути модуля; живое продолжение переехало на go.yaml.in/yaml/v3. Сегодня это не проблема (пин тот же, что у движка, и переезд одной зоны в одиночку как раз и создал бы два парсера в репозитории), но при следующем касании модуля движка вопрос переезда решается СРАЗУ ОБЕИМИ сторонами.

  2. Гейт «шаблон читается на КАЖДОМ рендере, а не на буте». Оператор, починивший битый шаблон, не должен ещё и перезапускать контрол-плейн: книги, которые ждали, разбираются ближайшим свипом. Цена — одно чтение маленького файла на попытку разбора, и она куплена сознательно.

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

    Второй гейт того же класса, из приёмки P6 (PD-228): дев-окружение экспортирует ДВЕ опасные переменные, и отказ по одной ловил «стенд уехал в прод» наполовину. Поэтому TM_PLATFORM_INSECURE_COOKIES (куки без Secure и без __Host-, HSTS выключен) рядом с OIDC тоже отказ на буте — но судит не догадка о деплое, а его собственный адрес: TM_PLATFORM_OIDC_REDIRECT_URL и есть публичный URL платформы, поэтому http:// там означает стенд, где Secure работать не может (законно, запускается), а всё остальное противоречит самому переключателю.

  4. Сид стенда ходит по HTTP теми же дверями, что пользователь (tmplatformctl seed): дев-вход → аккаунт → грант со стороны админа → загрузка через POST /v0/books с ожиданием конца интейка. Прямых INSERT нет намеренно — сид, который пишет строки сам, делает стенд, чьи книги никогда не проходили интейк, и первый же баг фронта оказывается в пути, который сид тихо обошёл. Это подтвердилось на первом же прогоне: сид упёрся в CSRF-слой (X-TM-Client обязателен на unsafe- запросе с кукой) — ровно в ту дверь, которую обязан правильно открывать и фронт.

  5. Стрим называет ПЛАТФОРМА, а не движок (TM_TRACE_ID в окружение юнита, строка 102 движка). Причина измерена, а не предположена: журнал — пер-КНИГА, и читатель, усыновляющий «первый hello на моём смещении», усыновлял чужой поток целиком (PD-200). Идентичность потока, выбранная до спавна и записанная в той же транзакции, что claim, убирает класс по построению.

Что решено актом 5 P7 (20.08) — правила, которые НЕ выводятся из одной функции

  1. Каждая транзакция, ЗАКАНЧИВАЮЩАЯ прогон, обязана оставить книгу должной материализацию. Таких транзакций три — FinishRun, PauseRun, FinishUnspawnedStop, — и правило записано фрагментом owesAReadingSurface, который несут все три, потому что забыть его в четвёртой ничего не мешает. Цена забывания названа замером: книга уезжает finished_at-нутой и НЕ должной, очередь дрейна ключуется этой колонкой и больше на закрытую книгу не смотрит — то есть прогон, за который пользователь заплатил, не показывает свой текст никогда. Две из трёх и забыли; пин — pgstore.TestEveryEndingOfARunLeavesTheBookOwingASurface, по таблице из трёх концовок.

  2. books.read_model_owed_at — это СРОК, а не момент возникновения. Тот, кто берётся платить долг, отодвигает срок (аренда, ClaimReadModelDebt), очередь берёт только <= now(), неоплаченный долг едет в конец (DeferReadModelDebt), погашение сверяет метку. Три следствия, каждое было дефектом до того, как правило записали: интейк и свип не читают движком одну книгу одновременно (а читали — свип каждые 15 с, материализация до 5 минут); книга, про которую движок ответить не может, не держит голову очереди вечно; долг, поставленный ПОЗЖЕ, переживает материализацию, начатую раньше. Каждый писатель этой колонки обязан идти через сверку метки — безусловная запись возвращает любой из трёх.

  3. «Есть ли у пайплайна редактор» — свойство КНИГИ, от объявления движка; но фактов ДВА, и в этом вся правка (пере-подписано по решению владельца D39.165 §2). Движок называет форму сам: пайплайн без редактора даёт волне edit знаменатель ноль (beginWaves). Читать это с последнего ПРОГОНА по-прежнему НЕЛЬЗЯ — последний прогон самый новый, а только что допущенный ещё ничего не объявил, и chapters_done падал в ноль в момент допуска (это часть правила не менялась).

    • books.edit_wave — ИСТОРИЧЕСКИЙ факт, монотонный, пин D39.153 §4б стоит и не отменён: книга, прошедшая редактирующий пайплайн, остаётся такой. Он больше НЕ авторитет для счёта.
    • books.epoch_editor + books.shape_epoch (миграция 00030) — ЭПОХА: форма как она стоит СЕЙЧАС (присваивается, не накапливается) и счётчик пересечений границы. Через эпоху считается ПОЖИЗНЕННЫЙ СЧЁТ КНИГИ — и ТОЛЬКО он.
    • Полоса ПРОГОНА остаётся на монотонном books.edit_wave. Это не оговорка, а замеренное ограничение: epoch_editor присваивается и ходит в обе стороны, поэтому полоса, читающая эпоху, перестаёт быть монотонной — один прогон читал 4/4, затем 2/2 на своих же двух попытках при неизменной structure_version. Канон держит полосу одной монотонной дробью (строка 200), так что читать её через эпоху НЕЛЬЗЯ, как бы соблазнительно ни выглядела симметрия со счётом книги. Цена ограничения названа и открыта строкой PD-435: на деплое, где редактора убрали, полоса тарифицирует edit-волну, которой не будет, и до единицы не доходит. Правильный носитель для неё — форма, под которой работает ЭТОТ прогон, записанная на самом прогоне; в StartRun она ещё не известна (движок объявляет её первым progress-событием — это ровно PD-401), поэтому это отдельная работа, а не хвост этой.
    • Пер-главный счётчик (units_done на проводе) — третий читатель и НЕ выводится ни из того, ни из другого целиком: правило chapterUnitsDone написано ОДИН раз и вычисляется ПО ГЛАВЕ (для прогона с подписью глава, которой не касался редактор, считается по черновику). Книго-широкий предикат здесь запрещён: кадры глав шлются поглавно, и он менял бы ответ про главы, которых не касалось ни одно событие. Почему разделено: один факт отвечал на два вопроса и мог быть прав только для одного. Прочитанный как авторитет счёта, монотонный флаг неверен в ОБЕ стороны — вниз он не ходит: редактора убрали ⇒ счёт мёрзнет против edit-колонки, которую больше никто не заполнит, полоса ни одного прогона не доходит до единицы, а шкала покупки снова продаёт переведённое (PD-403, это деньги); редактора добавили ⇒ флаг переворачивается на первом progress-событии и книга 7/10 показывает 0/10 назад внутри одной structure_version, что канон запрещал прямо (PD-404). Решение владельца 28.08: смена формы конвейера — СОБЫТИЕ КНИГИ, как пере-нарезка, и счёт легально пересчитывается на границе; shape_epoch — то, чем клиент отличает законный пересчёт от хода назад (канон 0.9.0, поле непрозрачное, саму форму на провод не выносим). ⚠ Парности числителя и базлайна (PD-401, базлайны берутся в StartRun и не пере-снимаются) для полосы МАЛО: её монотонность держит именно монотонный флаг.
  4. Утверждение про АТОМАРНОСТЬ пишется через xmin. Пин, проверяющий конечное состояние, не видит выноса записи из транзакции во второй оператор — состояние то же, меняется окно. xmin (транзакция, последней писавшая строку) у книги и у кадра, который та же транзакция выпустила, совпадает ровно тогда, когда обе записи сделала одна транзакция. Найдено тем, что два пина этого же акта прошли под мутацией, которую сами называют.

Инвентарь каналов движка (собран паком P8-FIX 22.08 ЧТЕНИЕМ кода движка)

ДРУГОГО носителя у этой таблицы в репозитории нет (грепом — research/23 описывает ФОРМУ шва, а не перечень каналов с их атомарностью). Собран чтением backend/, не по нашим докам. Ценность в двух колонках, которых нельзя получить из кода платформы: атомарность записи (какие сайдкары можно читать на живом прогоне, а какие рвутся) и какие каналы движка платформа не потребляет вовсе. ⚠ Якоря в таблице — на код ДВИЖКА, он живёт своей жизнью: при расхождении первичен код.

Канал Писатель в движке Атомарность Читатель/писатель платформы Согласовано?
<project_db>.manifest.json pipeline/manifest.go writeFileAtomic атомарно (temp+Sync+rename, pipeline/artifact.go) internal/runner/engine.go (tmctl manifest) → ingest.DecodeManifestinternal/books/parse.go, internal/readmodel и с P8-FIX строже: ingest.Manifest.Whole() зеркалит BookManifest.selfConsistent движка по тем правилам, которые платформа читает (главы, юниты, нумерация). Верхняя граница чанков движка counterpart'а не имеет НАМЕРЕННО — платформа счётчиков чанков не берёт
<project_db>.bank.json pipeline/bankexport.go writeFileAtomic атомарно internal/runner/artifacts.goingest.DecodeBankpgstore.SaveBank
<project_db>.bank-stop.json pipeline/mining.go writeFileAtomic атомарно читателя НЕТ (грепом по зоне — ноль вхождений) ⚠ канал существует и не потребляется: полная таблица подписи платформе сегодня не нужна, она проецирует банк
<project_db>.bank-stop.txt pipeline/mining.go os.WriteFile НЕ атомарно (усечение первым делом) читателя нет и это важное правило, а не наблюдение: брать его на ЖИВОМ прогоне нельзя — прочитаешь обрезанный файл без всякой ошибки
<project_db>.mined-signature.yaml pipeline/mining.go os.WriteFile НЕ атомарно читателя нет то же правило
<project_db>.auto-bank.yaml pipeline/mining.go os.WriteFile НЕ атомарно читателя нет то же правило
mined_delta (путь из book.yaml) писателя в движке НЕТ — только читатель loadMinedDelta; формат seed.File (terms:), грузится membank.LoadGlossarySeed, Source пере-штампуется на "mined" писателя НЕТ и у платформы разрыв — это строка 199(а) единого бэклога, развилка ждёт ратификации
mined_rejects читатель loadMinedRejects; формат rejects: [{src, note}] — это ФИЛЬТР ПРЕДЛОЖЕНИЙ, в банк не входит писателя нет тот же разрыв
tmctl bank-apply + документ решений зона ПИШЕТ (internal/runs/bank.go decisionsFile → файл во временном каталоге), движок отвечает отчётом tm-bank-report-v1 на stdout документ пишется целиком до вызова; отчёт — одноразовая выдача на вызов internal/runner/bankapply.goingest.DecodeBankReportinternal/runs/bank.go bankVerdict ⚠ ЕДИНСТВЕННЫЙ канал, по которому платформа ПИШЕТ в проект движка, — отсюда и своя дверь (POST /books/{bookId}/bank/corrections), и свой класс отказов, и класс write_incomplete = exit 15. ⚠ Пост-verb факт этого канала (bank_moved_at) пишется на ОТДЕЛЬНОМ, отцепленном контексте — иначе обрыв клиента теряет его навсегда (PD-425)
tmctl build + сайдкар <project_db>.book.<fmt> движок (pipeline, пак «писатель книги», D39.175) файл пишется целиком до публикации пути; ПУТИ публикуются в StatusArtifacts.book_files (status --json / manifest --json), stdout — конверт tm-build-v1 читателя НЕТ (грепом по зоне — ноль вхождений) Читателя ещё нет. Его будет читать дверь выдачи createExport/getExport, и она обязана СТРОИТЬ — звать tmctl buildа НЕ подбирать файл, лежащий рядом с БД: там копия ПРЕЖНЕЙ сборки (D39.175 п.2, слово владельца). Сверять BuildReport (config_drift/stale_unknown). ⚠ Новый класс отказа движка: exit 16 book_incomplete — книга с дырами без --partial; раскладка на провод — при постройке двери. ⚠ И ловушка на будущее: интейк на exit 11 действует ДЕСТРУКТИВНО, а tmctl build книги из нуля юнитов выходит именно 11 — сегодня недостижимо (интейк зовёт manifest, не build), учесть при подключении build к автоматике
tmctl export --json --pairs движок (pipeline/export.go) — (одноразовая выдача на вызов) internal/runner/engine.go ExportArgs/Exportingest.DecodeExportinternal/readmodel Единственный канал, несущий ТЕКСТ пары — исходник и перевод; манифест несёт только структуру
events.jsonl (NDJSON эмиттера) движок, StreamVersion 1.1 append-only internal/ingest/tail.go + pgstore.RunSink
exit-коды tmctl контракт движка ingest.OutcomeOf, internal/runs/reconcile.go outcome
tmctl status --json движок internal/runner/engine.goingest.StatusReport; расчёт денег ⚠ при неудаче зовётся не подряд, а с бэкоффом отсрочки
book.yaml оператор (шаблон) + платформа ОДИН раз (internal/books/render.go, O_EXCL) платформа пишет пять фиксированных ключей: book_id · title · source_lang · target_lang · source_file декодер движка СТРОГИЙ (config/book.go, dec.KnownFields(true)): незнакомый ключ — жёсткая ошибка, не предупреждение. Значит любое расширение набора ключей платформой это РАТИФИКАЦИЯ, а не правка: сборка движка, которая ключа ещё или уже не знает, перестанет грузить КАЖДУЮ новую книгу. Прямо относится к развилке 199(а)

Два следствия, которые стоит держать в голове при любой работе со швом. Первое: относительные пути в book.yaml резолвятся от каталога КНИГИ (config/book.go resolve()), поэтому один шаблон на все книги даёт пер-книжные пути без всякой подстановки. Второе: объявленный ключ с НЕсуществующим файлом валит загрузку конфига целиком — то есть «объявим ключ, файл создадим потом» не работает, движок просто не стартует.

Стенд разработчика — воспроизводимый рецепт

platform/README.md отсылает за рецептом стенда именно сюда — единственный экземпляр.

Собрать бинари и шаблон книги. Каталог — ЛЮБОЙ вне /tmp (иначе не переживёт уборку):

W=~/tmstand-work && mkdir -p $W/stand/{books,state}
cd <repo>/backend  && go build -o $W/tmctl         ./cmd/tmctl
cd <repo>/platform && go build -o $W/tmplatformd   ./cmd/tmplatformd
cd <repo>/platform && go build -o $W/tmplatformctl ./cmd/tmplatformctl

# шаблон книги = backend/example/book.yaml с ДВУМЯ путями, переписанными в абсолютные
sed -e 's#^pipeline: ../configs/#pipeline: <repo>/backend/configs/#' \
    -e 's#^models: ../configs/#models: <repo>/backend/configs/#' \
    <repo>/backend/example/book.yaml > $W/book-template.yaml

⚠ Относительные pipeline:/models: — единственное отличие шаблона от репо-оригинала; без правки движок не находит конфиги из чужого рабочего каталога.

Гейты батареи — их ЧЕТЫРЕ, и без них make check МОЛЧА скипует ~290 тестов (вся читающая модель, миграции и шов). «Зелёная батарея» без них не значит ничего, поэтому check сам печатает имена скипнутых: TM_PLATFORM_TEST_DSN (Postgres) · пара TM_PLATFORM_TEST_ENGINE_BIN + TM_PLATFORM_TEST_BOOK_TEMPLATE (живой рендер конфигурации и живой прогон движка) · ДОСТИЖИМЫЙ пользовательский менеджер systemd (/run/user/<uid>; без него три теста internal/runner скипаются молча — PD-374) · хост обязан РЕАЛЬНО применять MemoryMax к транзиентному юниту.

⚠⚠ Движковый бинарь второго гейта обязан быть СОБРАН ИЗ ТЕКУЩЕГО backend/, а не переиспользован со стенда (cd <repo>/backend && go build -o $W/tmctl ./cmd/tmctl, PD-432). Цена пропуска названа замером: стендовый tmctl от 24.08 против сегодняшнего backend/configs/models.yaml дал ТРИ красных теста в internal/books и internal/runner с сообщением tmctl: config: parse …/models.yaml: yaml: unmarshal errors: line 137: field system_messages not found in type config.CapabilitiesConfig. Диагноз стоит времени именно потому, что выглядит как дефект зоны: падает платформенный тест, а лжёт бинарь движка, собранный до того, как в конфиг движка приехало поле. То же правило и той же причины — для КОНФИГОВ стенда, если они скопированы рядом с бинарём: стендовая копия backend/configs без появившегося позже langpacks/ru (PD-432).

Команда-проверка четвёртого условия СНЯТА и не подлежит восстановлению без нового диагноза. Прежняя редакция предлагала одну строку: cut -d: -f3 /proc/self/cgroup не должен давать /init.scope. Она ОПРОВЕРГНУТА — второй точкой PD-423 (29.08: тест зелен 5 из 5 в изоляции при /init.scope, прямая проба systemd-run --user --scope показала, что процесс ВСЁ-ТАКИ попадает внутрь user@<uid>.service) и третьей точкой пака P12 (30.08: у оболочки cut -d: -f3 /proc/self/cgroup = /, и TestARunIsBoundedByItsOwnCgroup при этом зелен в полной батарее со скипами 0). То есть cgroup ВЫЗЫВАЮЩЕГО процесса условие не предсказывает — команда даёт ложный отрицательный, и сессия, честно исполнившая её, объявит гейт невыполненным на хосте, где он выполнен. Судить по самому тесту: TestARunIsBoundedByItsOwnCgroup зелен при 0 скипов ⇒ условие есть. Красный — сначала проверять СРЕДУ (cgroup.subtree_control целевого среза, рецепт в «граблях» ниже), и только потом искать дефект в своём диффе. Кандидат на замену команды — состояние cgroup.subtree_control среза tm-runs.slice в момент прогона — назван КАНДИДАТОМ и только: диагноз PD-423 не установлен, и вносить его в рецепт как проверку нельзя.

Ожидание при всех четырёх: 18 пакетов, exit 0, скипов 0, линтер «0 issues». Замерено 29.08: с гейтами — 0 скипов на обоих деревьях; без них — exit 0 и 287 скипов на HEAD fbe6cf3, 304 на дереве пака P11 (пак добавил 17 пинов, гейченных тем же DSN). Число зависит от дерева, и переносить его между ними нельзя.

⚠⚠ ШАБЛОН КНИГИ ВЫШЕ НЕ ГОДИТСЯ ДЛЯ ВТОРОГО ГЕЙТА, и это даёт КРАСНУЮ батарею, а не скип. backend/example/book.yaml указывает на configs/pipeline-c1.yaml, то есть на ПЛАТНЫЙ DeepSeek, а живой тест internal/runner TestTheSnapshotGuardIsLoudWithoutTheFlagsAndPassesWithThem (P10) гоняет настоящий движок и падает на tmctl: missing API keys (fill in backend/.env) — при том что его собственный комментарий обещает «free of provider keys and of paid calls». Тест прав: у стенда есть $0-пара (local-qwen3-8b, провайдер local на 127.0.0.1:11434, заглушку поднимает сам тест), но в репо НЕТ пайплайна, который бы её называл. Рецепт:

# 1. пайплайн на $0-паре — РЯДОМ с prompts/, иначе `no prompt for pair "zh-ru"`:
#    промпты резолвятся от каталога пайплайна, а не от каталога книги
sed -e 's/^\(\s*model:\s*\)deepseek-v4-flash.*/\1local-qwen3-8b/' \
    -e 's/^\(\s*model:\s*\)deepseek-v4-pro.*/\1local-qwen3-8b/' \
    <repo>/backend/configs/pipeline-c1.yaml > <repo-или-копия>/backend/configs/pipeline-zero.yaml
# 2. шаблон книги указывает на него
sed -e 's#^pipeline: .*#pipeline: <...>/backend/configs/pipeline-zero.yaml#' \
    $W/book-template.yaml > $W/book-template-zero.yaml
export TM_PLATFORM_TEST_BOOK_TEMPLATE=$W/book-template-zero.yaml

⚠ Второй гейт гоняет НАСТОЯЩИЙ движок: платный пайплайн в шаблоне — это не только красная батарея, это ещё и риск оплаченных вызовов. $0-пара обязательна не для удобства.

Дев-демон и пробы. Переменные демона: TM_PLATFORM_DSN · _MIGRATE=1 · _ADDR=127.0.0.1:8099 · _DEV_LOGIN · _INSECURE_COOKIES=1 · _BOOKS_DIR · _STATE_DIR · _ENGINE_BIN · _BOOK_TEMPLATE · _CTL_BIN · _LANGUAGE_PAIRS='zh>ru,ja>ru:unavailable' · _METRICS_ADDR. Посев — tmplatformctl seed --url http://127.0.0.1:8099 (кладёт cookie рядом); дальше пробы ходят с --noproxy '*', cookie-jar'ом и заголовком X-TM-Client.

Как поднять локально

export TM_PLATFORM_DSN='postgres://user@host:5432/tmplatform?sslmode=disable'
make check                        # батарея зоны
TM_PLATFORM_MIGRATE=1 go run ./cmd/tmplatformd   # миграции + сервер на 127.0.0.1:8080
curl -s localhost:8080/healthz    # ok
curl -s localhost:8080/readyz     # ready

Переменные: TM_PLATFORM_ADDR · TM_PLATFORM_DSN (или _DSN_FILE) · TM_PLATFORM_MIGRATE · TM_PLATFORM_TRUSTED_ORIGINS (через запятую) · TM_PLATFORM_SESSION_IDLE · TM_PLATFORM_SESSION_MAX_AGE · TM_PLATFORM_INSECURE_COOKIES (dev, по HTTP; вместе с OIDC, чей callback не http://, — отказ на старте: §30) · TM_PLATFORM_OIDC_ISSUER · _OIDC_CLIENT_ID · _OIDC_CLIENT_SECRET (или _FILE) · _OIDC_REDIRECT_URL · TM_PLATFORM_AFTER_LOGIN · TM_PLATFORM_SIGNUP_GRANT_USD. Приём книги: TM_PLATFORM_BOOKS_DIR (только АБСОЛЮТНЫЙ путь, корень каталогов книг; пусто = инстанс загрузок не принимает и POST /books не смонтирован) · TM_PLATFORM_MAX_UPLOAD_BYTES (дефолт 64 МиБ — потолок ОДНОЙ загрузки, §25) · TM_PLATFORM_UPLOAD_DEADLINE (дефолт 10 минут — сколько тело одной загрузки вправе идти) · TM_PLATFORM_BOOK_TEMPLATE (только АБСОЛЮТНЫЙ путь; стартовый book.yaml, из которого рендерится конфигурация каждой новой книги — §28 и deploy/; пусто = книги провижинит оператор руками, и книга без конфигурации ЖДЁТ, а не отклоняется). Дев-стенд: TM_PLATFORM_DEV_LOGIN (личность дев-входа; вместе с OIDC — отказ на старте, §30). Наблюдаемость: TM_PLATFORM_METRICS_ADDR (дефолт 127.0.0.1:9464; пусто = метрики не отдаются). Раннер: TM_PLATFORM_ENGINE_BIN (версионированный путь tmctl; пусто = инстанс только читает) · TM_PLATFORM_ENGINE_KEYS_PATH (только АБСОЛЮТНЫЙ путь к файлу провайдерских ключей деплоя, KEY=VALUE; едет движку АРГУМЕНТОМ --keys-file на translate — строка 211, ключи не проходят ни через процесс платформы, ни через окружение юнита; пусто = флаг не передаётся и движок зависит от .env рядом с book.yaml, которого SaaS-путь не пишет — WARN на буте; ⚠ суффикс _PATH, потому что *_FILE в этой зоне значит «файл со ЗНАЧЕНИЕМ секрета», а тут значение — сам путь) · TM_PLATFORM_STATE_DIR (только АБСОЛЮТНЫЙ путь: маркер пишет юнит из каталога книги, читает демон из своего — относительный назвал бы два разных файла; отказ на буте) · TM_PLATFORM_CTL_BIN · TM_PLATFORM_ENGINE_CEILING_ARG (шаблон с {{usd}}; ДЕФОЛТ — --ceiling-usd {{usd}}, залендённая форма строки 145; переопределяется для сборки движка с другим написанием флага) · TM_PLATFORM_RUN_MEMORY_MAX · TM_PLATFORM_RUN_TASKS_MAX · TM_PLATFORM_RUN_WORKERS · TM_PLATFORM_SWEEP_EVERY · TM_PLATFORM_RESYNC_EVERY · TM_PLATFORM_SWEEP_BUDGET (дефолт 2 мин — что может занять ОДИН проход свипа) · TM_PLATFORM_RUN_BUDGET (дефолт 60 с — что может занять один прогон внутри прохода; ⚠ поле существовало и НИКЕМ не присваивалось до P8-FIX, PD-331) · TM_PLATFORM_USD_PER_CHAPTER · TM_PLATFORM_RESUME_MAY_CHANGE_ENGINE (по умолчанию НЕТ: резюм идёт на той сборке движка, с которой прогон начался — строка 139). Вход монтируется, только если задана ВСЯ четвёрка OIDC; половина конфигурации — отказ на старте.

Все они печатаются на старте с источником (default/environment/file) — PD-114; секреты и денежные суммы печатаются фактом наличия, без значения.

Админ-команды: tmplatformctl grant --user <id> --usd 5 [--note ...] [--key ...] · balance --user <id> · logins --user <id> · revoke --user <id> · books [--migratable] (какие книги безопасно мигрировать при апгрейде движка — deploy/README.md) · seed (дев-стенд: аккаунт, кредит и книга через ЖИВОЙ интейк, §31).

Postgres на стенде без root

Основной способ (13.08): ПОЛНЫЙ дистрибутив в домашнем каталоге, тоже без sudo.

# micromamba — один статический бинарь с conda-forge; прокси стенда пускает conda-forge
curl -sL -o mm.tar.bz2 https://conda.anaconda.org/conda-forge/linux-64/micromamba-2.9.0-0.tar.bz2
tar -xjf mm.tar.bz2 bin/micromamba && install -m755 bin/micromamba ~/.local/bin/micromamba

MAMBA_ROOT_PREFIX=$HOME/.local/share/mamba \
  ~/.local/bin/micromamba create -y -p ~/.local/pgsql -c conda-forge 'postgresql=18.4'

~/.local/pgsql/bin/initdb -D ~/.local/share/tmstand/pgdata -U postgres -A trust \
  --no-locale --encoding=UTF8
~/.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
export TM_PLATFORM_TEST_DSN='postgres://postgres@/postgres?host=/tmp&port=55433&sslmode=disable'

164 МБ, ставится в ~/.local (та же схема, что у Go и Node на этой машине), sudo не нужен, и в пакете есть psql, pg_dump, pg_basebackup, contrib — то есть БД можно смотреть руками. Порт 55433 выбран, чтобы не спорить со стендом-скрэтчпадом на 55432, если тот поднят. ⚠ Проверено исполнением 13.08: батарея зоны make check под -race на этом стенде — EXIT=0, скипов 0, все пакеты зелёные. Стенд переживает уборку скрэтчпадов сессий; после рестарта машины поднимается тем же pg_ctl … start.

Запасной способ — бинарники io.zonky.test.postgres с Maven Central в скрэтчпад:

pg/bin/initdb -D pgdata -U postgres -A trust --no-locale --encoding=UTF8
pg/bin/pg_ctl -D pgdata -l pg.log -o "-k /tmp -p 55432 -c listen_addresses=" start
export TM_PLATFORM_TEST_DSN='postgres://postgres@/postgres?host=/tmp&port=55432&sslmode=disable'

⚠ Сокет кладём в /tmp (-k): полный путь скрэтчпада длиннее лимита Unix-сокета. ⚠ В пакете zonky ТОЛЬКО initdb/pg_ctl/postgres — проверено содержимым архива (1071 файл, в bin/ три штуки); psql нет, смотреть БД приходится из Go. Живёт в скрэтчпаде сессии, поэтому умирает вместе с ним — для долгой работы предпочтителен способ выше.

Грабли стенда, каждая стоила времени

  • curl только с --noproxy '*' — иначе прокси стенда отвечает 403, и это читается как дефект кода, которого нет.
  • Демон стенда — ОТДЕЛЬНЫЙ бинарь и сам не пересобирается: убить (⚠ ПО PID, см. ниже — не по имени) → go build -o <путь>/tmplatformd ./cmd/tmplatformd → поднять. Иначе живая проба меряет вчерашний код.
  • Правил миграцию — пере-создай базу стенда (dropdb tmstand && createdb tmstand): goose ключуется НОМЕРОМ и правку уже применённого файла не видит (то же правило, что в deploy/README.md). После пере-создания базы старый cookie-jar мёртв — повторить /auth/dev-login.
  • Красный runner.TestARunIsBoundedByItsOwnCgroup — обычно НЕ регрессия кода: у tm-runs.slice опустел cgroup.subtree_control. Лечение без root: echo "+memory +pids" > /sys/fs/cgroup/user.slice/user-1000.slice/user@1000.service/tm-runs.slice/cgroup.subtree_control.
  • Демон нельзя убивать pkill -f <путь к бинарю> — шаблон совпадает с собственной командной строкой оболочки, и она убивает сама себя (exit 144). Убивать по PID.
  • Postgres стенда переживает не всё: сокет живёт в /tmp, и уборка /tmp (или smart shutdown) роняет соединение — pg_ctl … status скажет «no server running». Поднимать заново по разделу выше.
  • Замер страницы библиотеки (когда трогаете проекции книги): go test ./internal/pgstore/ -bench LibraryPage -run xxx — корпус 40 книг × 500 глав собирается сам, vacuum analyze внутри.