41 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.5 (07.07) — floorgo.modоставлен общим с движком, а тулчейн сборки поднят до 1.26.5 (см. ниже).
Пины
| Что | Пин | Релиз пина | Зачем нам |
|---|---|---|---|
Go (язык, go.mod) |
1.26.4 | 02.06.2026 | Тот же floor, что у движка (backend/go.mod) — общий стенд собирает оба модуля одним тулчейном |
Go (тулчейн сборки, make tools-check) |
≥1.26.5 | 07.07.2026 | 1.26.5 несёт security-фиксы crypto/tls и os; сетевой модуль собирается ими, а не «чем-нибудь 1.26» |
| 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 НЕ добавлен: П-3 вне скоупа P0, а зависимость без кода — мусор в графе |
| 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, битая строка — карантин ПОПЫТКИ (её проекции), прогон при этом продолжается и продолжает платить. До различения дедлок, который разрешился сам, ослеплял проекцию живого платного прогона навсегда.
Как поднять локально
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) ·
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_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; половина конфигурации — отказ на старте.
Админ-команды: tmplatformctl grant --user <id> --usd 5 [--note ...] [--key ...] ·
balance --user <id> · logins --user <id> · revoke --user <id>.
Postgres на стенде без root
# бинарники 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-сокета.
⚠ psql в пакете zonky НЕТ — только initdb/pg_ctl/postgres; проверять из Go.
⚠ Postgres на стенде отсутствует как системный пакет и sudo нет. Схема и запросы этой сессии
проверены на ЖИВОМ PostgreSQL 18.4, поднятом без root из бинарников zonky
(io.zonky.test.postgres, Maven Central) в скрэтчпаде — вне репозитория и вне зависимостей модуля.