textmachine/platform/docs/STACK_DECISIONS.md

69 KiB
Raw Blame History

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

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

Общая записка по обоим новым сервисам — frontend/docs/STACK_DECISIONS.md §5 (02.08). Здесь — платформенная часть с датами релизов и сверкой. Что изменилось за два дня: три библиотечных пина §5 (pgx · goose · River) на 04.08 всё ещё последние; по Go последним патчем стенда идёт 1.26.6 (13.08, поднят security-адвизори — см. таблицу) — floor go.mod оставлен общим с движком.

Пины

Что Пин Релиз пина Зачем нам
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 РЕАЛЬНО держится (первая редакция этого подъёма не держала ничего — регекс go1\.26\.([5-9]…) принимал ту самую 1.26.5, а GO_MIN_VERSION жил только в echo): 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. Протокольный риск (PKCE, JWKS с рефетчем по kid, проверка подписи/issuer/audience/exp) отдан библиотекам, интеграция и модель аккаунта — наши. Транзитивно приходит go-jose/v4 v4.1.4
Рейт-лимит в процессе golang.org/x/time v0.15.0 11.02.2026 rate.Limiter на ОБЕИХ неаутентифицированных ручках, которые ПИШУТ: /auth/login (строка состояния) и /auth/callback (строка журнала на каждом отказе — замерено ~880 строк/с с одного хоста, пока лимита не было). Долговечные пер-пользовательские лимиты — в Postgres, когда появятся
Линтер golangci-lint 2.12.2 06.05.2026 Тот же пин, что у движка: находки версионно-зависимы, разъезд пинов = разные гейты в одном репо
Уязвимости govulncheck v1.6.0 09.07.2026 Отдельная цель make vuln, не часть check: ей нужна сеть, а батарея обязана быть зелёной на голом клоне офлайн

Redis нет — архитектурное «нет» из §5 в силе: очередь, лизы и рейт-лимиты живут в том же 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 на ней ломается навсегда. Дев-воркфлоу из этого же документа создаёт ровно такую среду. Поэтому выпущенные 0000100003 возвращены байт-в-байт, а всё новое приехало отдельными номерами (00004 индексы · 00005 вход · 00006 снятие черновика usage_windows · 00007 кредиты · 00008 auth_states.issuer и .start_id). Гейт, которого не хватало: 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). Найдено ревью P2, перепроверено зоной, принято как цена правила.

  2. Ключ личности — (provider, subject); почта не ключ. users.email стала NULLABLE и БЕЗ уникального индекса; неизвестная пара всегда создаёт НОВЫЙ аккаунт. Разбор и цена решения — в журнале зоны, раздел «Политика коллизии почты».

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

  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.

    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 требуют ДОКУМЕНТА, и выше он написан; сами механизмы требуют другие две строки, и на них до 08.08 не ссылался ни один наш док. Дословно (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. Первая редакция знала только первые два, и рестарт базы карантинил проекцию живого платного прогона навсегда — снятия карантина в дереве нет (найдено ре-чеком V2, исполнением). Граница держится на типах: ошибка чтения ФАЙЛА — *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 (базовые единицы — секунды и байты; _total у счётчиков; единица не в лейбле) плюс «четыре золотых сигнала» на вопрос «что мерить». Пин на форму — 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; security-ось ревью атакует именно этот список.

    Второй гейт того же класса, из приёмки 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. «Есть ли у пайплайна редактор» — свойство КНИГИ (books.edit_wave), монотонное, от объявления движка. Движок называет форму сам: пайплайн без редактора даёт волне edit знаменатель ноль (beginWaves). Читать это с последнего прогона НЕЛЬЗЯ — последний прогон самый новый, а только что допущенный ещё ничего не объявил, и chapters_done падал в ноль в момент допуска. Флаг только растёт: книга, прошедшая редактирующий пайплайн, остаётся такой, иначе полу-сделанные главы начали бы считаться сделанными. От этого же флага зависит БАЗЛАЙН полосы прогона: базлайн и числитель обязаны считать один проход, оба конца этого правила — в StartRun и в снятии стопа подписи.

  4. Утверждение про АТОМАРНОСТЬ пишется через xmin. Пин, проверяющий конечное состояние, не видит выноса записи из транзакции во второй оператор — состояние то же, меняется окно. xmin (транзакция, последней писавшая строку) у книги и у кадра, который та же транзакция выпустила, совпадает ровно тогда, когда обе записи сделала одна транзакция. Найдено тем, что два пина этого же акта прошли под мутацией, которую сами называют.

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

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_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_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, и это читается как дефект кода, которого нет.
  • Демон стенда — ОТДЕЛЬНЫЙ бинарь и сам не пересобирается: убить по имени → go build -o <путь>/tmplatformd ./cmd/tmplatformd → поднять. Иначе живая проба меряет вчерашний код.
  • Правил миграцию — пере-создай базу стенда (dropdb tmstand && createdb tmstand): goose ключуется НОМЕРОМ и правку уже применённого файла не видит (то же правило, что в deploy/README.md). После пере-создания базы старый cookie-jar мёртв — повторить /auth/dev-login.
  • Красный runner.TestARunIsBoundedByItsOwnCgroup — обычно НЕ регрессия кода: у tm.slice опустел cgroup.subtree_control. Лечение без root: echo "+memory +pids" > /sys/fs/cgroup/user.slice/user-1000.slice/user@1000.service/tm.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 внутри.

⚠ Postgres на стенде отсутствует как системный пакет и sudo нет. Схема и запросы этой сессии проверены на ЖИВОМ PostgreSQL 18.4, поднятом без root из бинарников zonky (io.zonky.test.postgres, Maven Central) в скрэтчпаде — вне репозитория и вне зависимостей модуля.