63 KiB
Стек платформы — пины и обоснования
Зонный документ
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-адвизори — см. таблицу) — floorgo.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)
/healthz≠/readyz. Liveness ничего не трогает (БД лежит — процесс жив). Readiness с P2 спрашивает не «отвечает ли база», а «та ли это база, под которую собран бинарь»: пинг плюс сверкаgoose_db_versionс максимальной вшитой миграцией. Одного пинга было мало — он успешен и на Postgres без единой таблицы, то есть в нормальной середине выката, где миграция ещё не накачена (PD-68). ПустойTM_PLATFORM_DSN— легальный старт: сервис поднимается и честно говорит «не готов». Иначе супервизор убивает здоровый процесс за то, что база моргнула.- Ops-эндпоинты вне версионного префикса.
/healthz,/readyz— в корне; контрактная поверхность целиком под/v0(базовый путь спеки платформа ПОДТВЕРЖДАЕТ). - Один mux. Контрактные маршруты регистрируются с префиксом в паттерне, а не вложенным mux'ом
под
StripPrefix: вложенный получает КОПИЮ запроса, иRequest.Patternнаружу не возвращается — лог пришлось бы писать по сырому пути с id книг. Проверено исполнением. - Гард навешен на поддерево, а не на ручки. Неизвестный путь под
/v0отвечает 401 раньше 404: аноним не должен картографировать поверхность. - Заголовки безопасности — на каждом ответе (
X-Robots-Tag: noindex,Cache-Control: no-store,nosniff,no-referrer): ПТ-34 нельзя оставлять на дисциплину автора следующей ручки. - Батарея зоны —
make check(build · vet · fmt · lint · test -race), форма скопирована сbackend/Makefileвплоть до именования пропущенных тестов: тихий skip читается как покрытие. - Тесты с БД гейтятся
TM_PLATFORM_TEST_DSNи создают СВОЮ базу на прогон (дропают вt.Cleanup). Батарея на голом клоне зелёная и офлайн; с DSN — та же батарея плюс схема.
Что решено сессией P1 (05.08)
-
Миграции append-only, БЕЗ исключений — включая «до первого деплоя». Первая редакция этого пункта разрешала править их на месте, пока «ни одна среда их не применяла». Это опровергнуто исполнением: goose записывает только НОМЕР (ни имени, ни хеша), поэтому база, доехавшая до версии 3, на новом наборе рапортует «migrations applied» и не получает ни одной новой таблицы, а
DownToна ней ломается навсегда. Дев-воркфлоу из этого же документа создаёт ровно такую среду. Поэтому выпущенные00001–00003возвращены байт-в-байт, а всё новое приехало отдельными номерами (00004индексы ·00005вход ·00006снятие черновикаusage_windows·00007кредиты ·00008auth_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, перепроверено зоной, принято как цена правила. -
Ключ личности —
(provider, subject); почта не ключ.users.emailстала NULLABLE и БЕЗ уникального индекса; неизвестная пара всегда создаёт НОВЫЙ аккаунт. Разбор и цена решения — в журнале зоны, раздел «Политика коллизии почты». -
Админ-поверхность — CLI (
tmplatformctl), не HTTP-ручка. Ручке понадобилась бы вторая модель авторизации (роли, эскалация, отзыв админской куки) ради пяти операций (grant·adjust·balance·logins·revoke), тогда как граница доверия «есть шелл на машине и доступ к DSN» уже обеспечена машиной. Браузерная панель, если понадобится, обернёт те же вызовы стора. 10а. Имя провайдера —TM_PLATFORM_OIDC_PROVIDER, и оно должно меняться ВМЕСТЕ с издателем. Это первая половина ключа личности. НаправитьTM_PLATFORM_OIDC_ISSUERна другой IdP, оставив имя прежним, — значит сложитьsubнового провайдера в старое пространство имён, то есть тихо связать чужие аккаунты. Переменная называется здесь, потому что в деплой-примере её не было и оператору нечему было напомнить (найдено ревью P2). -
Секреты — через
*_FILE.TM_PLATFORM_DSN_FILEиTM_PLATFORM_OIDC_CLIENT_SECRET_FILEчитаются раньше одноимённых переменных: переменная окружения видна в/proc/<pid>/environи наследуется каждым ребёнком-tmctl. Это же форматLoadCredential=systemd (deploy/). -
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. -
Политика сессий: 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, ловит этот раздел, а не батарея. - Абсолютный срок — 30 суток. Отклонения нет. Было 90; 90 — это отклонение от SHOULD, а
обоснования у него не нашлось: на аккаунте лежит тратимый баланс, а повторный вход у уже
залогиненного в Google человека — один клик. Абсолютный срок не рвёт ПРОГОН: прогон живёт
серверным процессом и переживает истечение сессии. Значение переопределяется
-
Против 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) — раннер
-
Прогон — транзиентный юнит в СОБСТВЕННОМ 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, которыми сервис дотягивается до своей же пользовательской шины. Новых привилегий в рантайме — ноль: пользователь всегда вправе управлять своими юнитами. -
Юниты прогонов кладутся в СВОЙ срез
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» подряд красные. -
Конец юнита читается из МАРКЕРА, а не из 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доехали буквально), поэтому экранировать%не нужно и было бы неверно. Кавычки для аргументов с пробелами нужны — проверено. -
Маркер пишет
tmplatformctl exit-marker, а не однострочник в юните. Три причины, каждая кем-то оплаченная: у команды внутри свойства systemd своя кавычковая грамматика, и каталог состояния с пробелом молча распадается на два аргумента; запись обязана быть АТОМАРНОЙ, потому что читатель опрашивает и половина маркера читается как «прогон кончился»; функцию на Go можно протестировать, а строку в свойстве — нет. Команда работает БЕЗ базы и без секретов и диспетчеризуется до чтения DSN (пин —TestTheExitMarkerCommandNeedsNoDatabase). -
Очередь River v0.42.0 добавлена в
go.modи мигрируется СВОИМ мигратором. Её SQL принадлежит ей: копировать чужие миграции в наш append-only манифест значит держать снимок чужой схемы, который тихо перестаёт совпадать с читающей его библиотекой. Две летописи в одной базе — честная форма;/readyzпоэтому проверяет иgoose_db_version, и наличиеriver_job(класс PD-68: инстанс, ответивший «готов» и не умеющий принять прогон, соврал балансировщику). Пин —TestReadinessCoversTheQueueSchema.MaxAttempts: 1у задания — намеренно против рефлекса очередей: повтор здесь не доделывает потерянную работу, а СПАВНИТ второй движок; работу восстанавливает реконсилятор, который читает мир, а не доверяет представлению задания о нём. -
Ставка «главы → доллары» — константа платформы. Движковой поверхности оценки не существует, и выдумывать её запрещено промтом; провенанс числа —
docs/experiments/08-cost-model-v2.md(стандарт-микс $10.94 на 500 глав = $0.0219/глава) через ревизию D30.4 (+15–25% → $0.0252–0.0274), округлено ВВЕРХ до $0.03: заниженная ставка даёт пользователю выбрать больше глав, чем покрывают деньги, и прогон встаёт на потолке — ровно тот исход, ради предотвращения которого шкала и существует. ПереопределяетсяTM_PLATFORM_USD_PER_CHAPTER. Пин на полосу провенанса —pricing.TestTheDefaultRateStaysInTheBandItsProvenanceGives.
Что решено дофиксом P4 (09.08)
-
--ceiling-usd— это КНИЖНЫЙ потолок в силе, а не бюджет прогона, и пересчёт лежит на платформе. Флаг переопределяетceilings.book_usdи сравнивается с накопленнымcommitted + reservedкниги на КАЖДОЙ резервации (backend/internal/store/ledger.goReserve; сам флаг документирует это словами «not this run's increment»). Пользователь же покупает ПРИРОСТ в главах (D39.110), поэтому платформа обязана перевести одно в другое — эта обязанность и ратифицирована D39.122. Формула зоны:аргумент = committed + прирост×ставка. ⚠ Слагаемогоreservedв ней нет, вопреки букве пинга ратификации, и это НАЗВАННОЕ отклонение в консервативную сторону:store.Open(путь записи каждогоtranslate) обнуляет остаточныйreserved_usdкниги ДО первой судимой резервации (backend/internal/store/store.go:88,:214), а read-onlystatusэтот проход не делает — значит прочитанная цифра к моменту сравнения уже стёрта, и её добавление отдало бы прогону запас БОЛЬШЕ его холда. Разбор — PD-158; ратифицировано 09.08 (оркестратор пере-мерил обе формулы против гейта движка: ратифицированная переплачивала запасом ровно на leftover-reserved). Обе величины читаются ОДНИМ вызовомstatus --jsonперед стартом — они должны быть согласованы между собой, а второй вызов стоит секунды CPU на пере-нарезку.Три следствия, каждое построено:
reserved_usdлежит в аллоулисте УКАЗАТЕЛЕМ и обязателен (отсутствие ≠ ноль) — он не входит в потолок, но именно он доказывает, что отклонение безопасно: на спавне другого писателя нет, значит любой резерв по построению остаток; рестарт считает по СВЕЖЕМУ отсчёту, потому что прерванная попытка счётчик сдвинула; фактически ушедшее значение хранится (run_attempts.ceiling_arg_micro_usd) — после сдвига счётчика его нечем восстановить, а «какой лимит был у того процесса» это первый вопрос к прогону, вставшему рано.⚠ Тестировать это фейком, который лишь записывает аргумент, нельзя: фейк, который не может отказать, не проверяет ничего. Пины гоняют
ceilingJudge— фейк с правилом движка. -
Порядок блокировок в
pgstore— глобальный и записан в одном месте (lockBook):books → runs → run_attempts → account_balances → reservations. Не стилевое соглашение: транзакция, взявшая две таблицы в обратном порядке, дедлочит с любой другой, Postgres рвёт цикл откатом одной стороны, и цена — упавший свип или запрос. Замерено дважды: 41 дедлок на 300 раундов у пары Hold↔Settle (PD-26) и 258 из 300 у пары материализатор↔реконсилятор (PD-145).Порядок утверждается ПРЯМЫМ пином, а не конкурентным прогоном: тест держит замок книги, дожидается, пока операция реально заблокируется, и проверяет строку попытки через
for update nowait. Конкурентная проба оставлена, но она пробует — посадка «снять книгу-первой изRestartRun» её пережила, потому что рестарт берёт замок один раз за прогон. -
«Ошибка материализации» — два разных факта, и различает их
runs.quarantines. Транзиентное — повтор следующим свипом; сюда входят не только дедлок и сериализация (40P01/40001), но и всё, во что превращается ШТАТНЫЙ рестарт управляемого Postgres: класс 08,57P01/57P02/57P03,pgconn.SafeToRetry, любойnet.Error. Первая редакция знала только первые два, и рестарт базы карантинил проекцию живого платного прогона навсегда — снятия карантина в дереве нет (найдено ре-чеком V2, исполнением). Граница держится на типах: ошибка чтения ФАЙЛА —*fs.PathError, а онnet.Errorне удовлетворяет; пропасть, конфликт payload, битая строка — карантин ПОПЫТКИ (её проекции), прогон при этом продолжается и продолжает платить. До различения дедлок, который разрешился сам, ослеплял проекцию живого платного прогона навсегда.
Что решено сессией P5 (11.08) — загрузка книги, стоп/резюм, наблюдаемость
-
Метрики —
prometheus/client_golangv1.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 на каждый скрейп, отдал бы нагрузку на контрол-плейн тому, у кого есть доступ к эндпоинту.
-
Дедлайн чтения маршрута загрузки РАСШИРЯЕТСЯ, но никогда не снимается.
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из метрик» падает). -
Приём книги: строка ПЕРЕД байтами, файл в каталоге книги, разбор — отдельным шагом. Порядок «строка, потом байты» делает
uploadingсостоянием, которое кто-то может наблюдать (вторая вкладка видит книгу, пока файл ещё идёт), и — что важнее операционно — делает брошенную загрузку НАХОДИМОЙ: строка единственное, что говорит, чей каталог подBooksDir. Цена порядка названа: языки обязаны прийти ДО файла, потому что потоковый читатель отдаёт части в порядке провода; тем же правилом специфицирована браузерная загрузка S3, а в спеке нашего контракта его нет — заведено вопросом владельцу контракта (PD-172).Тело читается
r.MultipartReaderи льётся в файлsource.<расширение>; расширение сохраняется, потому что движок диспетчеризует читатель по нему (.epub→ epub, иначе текст), а не потому, что платформа знает форматы — списка форматов у неё нет и не должно быть. Разбор =tmctl manifest --json, $0-команда, которая и создаёт БД проекта; она даёт число глав и идентичность разреза (chunker_version,source_sha256).Два разных отказа и две разных судьбы файла. Движок ОТВЕТИЛ «не разобрать» (
*exec.ExitError) — это про книгу, терминально с первого ответа, каталог удаляется: перепарсить нечем, скачать нечем, а аутентифицированный маршрут, который пишет на диск оператора и ничего не убирает, был бы дырой, которую этот пак открыл бы сам. Движок НЕ ЗАПУСТИЛСЯ — это про хост: заявка возвращается, следующий свип пробует снова, и только после бюджета попыток книга становитсяrejectedс файлом НА МЕСТЕ (удалять чужую загрузку из-за своей поломки — не наше решение). -
Стоп различается НАМЕРЕНИЕМ, записанным до сигнала, а не догадкой по коду выхода. Движок ловит SIGTERM и выходит кодом 1, поэтому маркер пользовательского стопа и маркер аварии — одни и те же байты (PD-152). Платформа пишет
runs.stop_requested_atв Postgres и только потом просит systemd; на гонку «стоп против самостоятельного финиша» стоит сверка времени маркера, а чистые исходы движка (0/2/3) намерение перебивают — завершённый перевод не должен показываться отменённым. Следствия, каждое запинено: прогон с намерением НЕ перезапускается реконсилятором (иначе деньги уходят на работу, которую владелец только что отменил), живой юнит с намерением получает повторный стоп (закрывает «платформа умерла между записью и вызовом»), а стоп до спавна заканчивает прогон и возвращает холд целиком. ⚠ Что этим НЕ закрыто и названо: штатная перезагрузка хоста по-прежнему выглядит как авария — там намерения нет ни у кого, и различить это может только сам движок различимым кодом выхода (строка 165 единого бэклога).
Что решено сессией P6 (14.08) — потребительская половина шва, интейк формы Б, дев-стенд
-
gopkg.in/yaml.v3v3.0.1 переходит из транзитивных в ПРЯМЫЕ, и это не новая зависимость в графе. Она уже приезжала транзитивно (goose); прямой её делает рендер стартовогоbook.yaml(форма Б, D39.130). Тот же пин, что у движка — один YAML-парсер на репозиторий, иначе две стороны одного файла расходятся в краевых случаях, которые никто не ищет.Норма зоны требует stdlib прежде библиотеки, и здесь она соблюдена в обратную сторону: stdlib YAML не имеет, а альтернатива — шаблонизировать файл ТЕКСТОМ через
text/template— проиграла по трём измеримым свойствам, каждое из которых ломает книгу молча. Ключ, который оператор уже написал в шаблоне, текстовая подстановка ДУБЛИРУЕТ (а дубль ключа — жёсткая ошибка строгого декодера движка); комментарии и порядок ключей оператора не переживают перегенерацию, а файл после рендера принадлежит ему; и значение вроде123илиtrueуезжает как число или булево — книга с названием «123» тогда просто не грузится. Работа с УЗЛОМ (yaml.Node+!!str) решает все три, и стоит она одной библиотеки, которая уже была в графе.⚠ Названо ревью пака:
gopkg.in/yaml.v3v3.0.1 — последний релиз по этому пути модуля; живое продолжение переехало наgo.yaml.in/yaml/v3. Сегодня это не проблема (пин тот же, что у движка, и переезд одной зоны в одиночку как раз и создал бы два парсера в репозитории), но при следующем касании модуля движка вопрос переезда решается СРАЗУ ОБЕИМИ сторонами. -
Гейт «шаблон читается на КАЖДОМ рендере, а не на буте». Оператор, починивший битый шаблон, не должен ещё и перезапускать контрол-плейн: книги, которые ждали, разбираются ближайшим свипом. Цена — одно чтение маленького файла на попытку разбора, и она куплена сознательно.
-
Дев-вход (
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работать не может (законно, запускается), а всё остальное противоречит самому переключателю. -
Сид стенда ходит по HTTP теми же дверями, что пользователь (
tmplatformctl seed): дев-вход → аккаунт → грант со стороны админа → загрузка черезPOST /v0/booksс ожиданием конца интейка. Прямых INSERT нет намеренно — сид, который пишет строки сам, делает стенд, чьи книги никогда не проходили интейк, и первый же баг фронта оказывается в пути, который сид тихо обошёл. Это подтвердилось на первом же прогоне: сид упёрся в CSRF-слой (X-TM-Clientобязателен на unsafe- запросе с кукой) — ровно в ту дверь, которую обязан правильно открывать и фронт. -
Стрим называет ПЛАТФОРМА, а не движок (
TM_TRACE_IDв окружение юнита, строка 102 движка). Причина измерена, а не предположена: журнал — пер-КНИГА, и читатель, усыновляющий «первый hello на моём смещении», усыновлял чужой поток целиком (PD-200). Идентичность потока, выбранная до спавна и записанная в той же транзакции, что claim, убирает класс по построению.
Как поднять локально
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. Живёт в скрэтчпаде
сессии, поэтому умирает вместе с ним — для долгой работы предпочтителен способ выше.
⚠ Postgres на стенде отсутствует как системный пакет и sudo нет. Схема и запросы этой сессии
проверены на ЖИВОМ PostgreSQL 18.4, поднятом без root из бинарников zonky
(io.zonky.test.postgres, Maven Central) в скрэтчпаде — вне репозитория и вне зависимостей модуля.