121 KiB
Стек платформы — пины и обоснования
Зонный документ
platform/. Пины сверены ЖИВЬЁМ 04.08 и 05.08.2026 (Go-прокси@latest, postgresql.org, go.dev/dl) — версии по памяти не называются. Порядок ратификации новой зависимости —ENGINEERING_STANDARDS.md§1.Общая записка по обоим новым сервисам —
frontend/docs/STACK_DECISIONS.md§5 (02.08); здесь — платформенная часть с датами релизов и сверкой.
Указатель решений по ПРЕДМЕТУ (не по сессии)
⚠ Секции «Что решено сессией P…» — это НЕ хроника, а РЕЕСТР: решения нумерованы сплошь 1–36 без пропусков, и группировка по сессиям — лишь атрибуция (кто и когда принял). Указатель добавлен 06.09, потому что ось «по сессии» заставляла искать правило перебором семи секций; резать эти секции НЕЛЬЗЯ — там живой свод правил зоны.
| Предмет | Решения |
|---|---|
| HTTP-поверхность, маршруты, заголовки | 1 · 2 · 3 · 4 · 5 |
| Батарея и тесты с БД | 6 · 7 |
| Миграции и схема (append-only) | 8 |
| Личность, сессии, вход, дев-двери | 9 · 13 · 14 · 30 · 31 |
| Админ-поверхность и секреты | 10 · 11 |
| Таймауты сервера и дедлайны чтения | 12 · 25 |
| Раннер: systemd, срез, маркер конца, трассировка | 15 · 16 · 17 · 18 · 32 |
| Очередь задач | 19 |
| Деньги и потолки | ⛔ 20 — ОТМЕНЕНО паком «форма заказа» 05.09 · 21 |
| Порядок блокировок в сторе | 22 |
| Материализация read-модели и её долг | 23 · 33 · 34 |
| Метрики, наблюдаемость, атомарность | 24 · 36 |
| Приём книги и различение стопа | 26 · 27 |
| Зависимости | 28 |
| Шаблоны и рендер | 29 |
| Свойства книги (редактор пайплайна) | 35 |
Пины
| Что | Пин | Релиз пина | Зачем нам |
|---|---|---|---|
Go (язык, go.mod) |
1.26.4 | 02.06.2026 | Тот же floor, что у движка (backend/go.mod) — общий стенд собирает оба модуля одним тулчейном |
Go (тулчейн сборки, make version-check + toolchain в go.mod) |
≥1.26.6 | 13.08.2026 | Поднят с 1.26.5 не по вкусу, а по make vuln: база адвизори опубликовала пять уязвимостей stdlib против 1.26.5 — net/http, crypto/tls, net/url, encoding/xml, encoding/asn1 (GO-2026-6218/6090/6089/6088/5972), все закрыты в 1.26.6, и две трассируются в пути, которые эта служба зовёт (pgstore.Open → pgx.ParseConfig → asn1.Unmarshal). На 1.26.6 батарея зелёная и скан чист. ⚠ Как floor РЕАЛЬНО держится: make version-check СРАВНИВАЕТ версии (sort -V, префиксы rc/devel отвергаются), и go.mod несёт toolchain go1.26.6 — его читает всякая сборка, даже мимо make: при GOTOOLCHAIN=auto хост скачает нужный тулчейн, при =local остановится с ошибкой. Само сравнение запинено (internal/gates) |
| PostgreSQL | 18.x (проверено на 18.4), floor 16 (⚠ ничем НЕ гейчен, в отличие от Go-floor выше: Open только разбирает DSN, Ready сверяет goose_db_version И наличие схемы очереди (select to_regclass('river_job')), но не версию сервера, server_version в зоне не читается нигде — Postgres ниже 16 поднимет службу и упадёт впервые на запросе, чьего синтаксиса не знает) |
18.4 — май 2026 | 18 — текущая мажорная (19 в бете, в прод не берём); floor 16, потому что River тестируется на трёх последних мажорных |
| HTTP | stdlib net/http + ServeMux |
— | Роутер-библиотека не нужна: ServeMux с 1.22 умеет метод+wildcards, а Request.Pattern даёт лог по маршруту, не по пути |
| CSRF | stdlib http.CrossOriginProtection |
Go 1.25 | Ровно тот механизм, что описан в §5 (Sec-Fetch-Site → Origin), теперь в тулчейне — свой велосипед не пишем |
| Postgres-драйвер | github.com/jackc/pgx/v5 v5.10.0 |
03.06.2026 | Живой pool, pgconn.PgError для проверки констрейнтов, stdlib для goose |
| Миграции | github.com/pressly/goose/v3 v3.27.3 |
22.07.2026 | Библиотекой + embed.FS; WithSessionLocker = advisory-лок, две реплики выкатываются по очереди |
| Очередь | github.com/riverqueue/river v0.42.0 |
31.07.2026 | В go.mod и в работе с P4; устройство и цена формы — §19 |
| OIDC-вход | golang.org/x/oauth2 v0.36.0 + github.com/coreos/go-oidc/v3 v3.20.0 |
11.02.2026 · 08.07.2026 | Ратифицировано PLATFORM_DIRECTION.md §1 (там же — что отдано библиотекам и что пишем сами); сверено живьём 05.08. Транзитивно приходит go-jose/v4 v4.1.4 |
| Рейт-лимит в процессе | golang.org/x/time v0.15.0 |
11.02.2026 | rate.Limiter на ОБЕИХ неаутентифицированных ручках, которые ПИШУТ: /auth/login (строка состояния) и /auth/callback (строка журнала на каждом отказе — замерено ~880 строк/с с одного хоста, пока лимита не было) |
| Линтер | golangci-lint 2.12.2 |
06.05.2026 | Тот же пин, что у движка: находки версионно-зависимы, разъезд пинов = разные гейты в одном репо |
| Кодоген SQL | sqlc v1.31.1 |
22.04.2026 | Инструмент разработчика, НЕ зависимость модуля — в go.mod не входит, рантайм-граф не растёт ни на один пакет; пин держит make tools-check, как у линтера, и по более острой причине: генерённый код лежит В ДЕРЕВЕ, поэтому другая версия молча даёт другой диф и sqlc diff краснеет на чистом клоне. Зачем: генерирует слой запросов свободного от склейки блока internal/pgstore (50 запросов из queries/*.sql) — SQL и его Scan перестают быть двумя списками, которые сверяет человек ПО ПОЗИЦИИ. Взят по решению владельца 20.08 (D39.153 п.6а, уточнение D39.154), носитель — BACKLOG.md П-19. ⚠ Довод «гейт sqlgate_test.go это уже закрыл» ПРОВЕРЕН и не подтвердился: гейт получает только СТРОКУ SQL и Go-сторону вызова не видит — шесть посаженных перестановок целей Scan и сломанных арностей ВЫЖИЛИ на полной батарее (замер 29.08, platform-PROGRESS.md). Схему читает ТОТ ЖЕ каталог migrations/, что гейтит манифест (второго носителя нет); деньги держит подстановочный override *.*_micro_usd → money.MicroUSD — без него генератор выдаёт голый int64, с ним снятие override становится ОШИБКОЙ СБОРКИ. Актуальность генерации гейчена дважды: sqlc diff в make check и pgstore.TestEveryGeneratedQueryMatchesItsSourceFile в батарее, который работает и без установленного sqlc |
| Уязвимости | govulncheck v1.6.0 |
09.07.2026 | Отдельная цель make vuln, не часть check: ей нужна сеть, а батарея обязана быть зелёной на голом клоне офлайн |
⚠ Граница набора sqlc проходит по СБОРКЕ запросов read-модели, а не по всякому запросу, до
которого read-модель дотягивается. lockBook конвертирован, хотя среди звавших есть
internal/pgstore/readmodel.go: сам он — цельный литерал select id from books where id = $1 for update, исполняемый в ЧУЖОЙ транзакции (internal/pgstore/credits.go, греп func lockBook; оператор
— internal/pgstore/queries/credits.sql, греп for update), и конвертация меняет способ исполнения
одного оператора, не трогая склейку. Довода нет в D39.172 — он принят паком sqlc и перенесён сюда
при выносе эры, чтобы не жить только в архиве.
Redis нет — архитектурное «нет» в силе, с доводом: иначе он приползёт по частям. Дом решения —
platform/README.md (греп Redis не заводим нигде); очередь, лизы и рейт-лимиты живут в том же 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на ней ломается навсегда. Дев-воркфлоу из этого же документа создаёт ровно такую среду. Гейт:migrations.sha256+ тестTestReleasedMigrationsAreUnchanged— чтобы изменить выпущенную миграцию, надо осознанно изменить строку в манифесте, где это видно ревьюеру. Апгрейд со старого релиза проверен исполнением (TestDatabaseAtAnOlderReleaseCatchesUp), down-путь — тоже.⚠ Цена правила, названная честно: откат НИЖЕ версии 15 недоступен, а ниже 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). Принято как цена правила.⚠ ГРАНИЦА УЕХАЛА С 5 НА 15 ТЕМ ЖЕ МЕХАНИЗМОМ, и до 04.09 доки этого не знали. Down-путь
00015сужаетruns_paused_reason_checkобратно к одномуcredit_exhausted, а его же up-путь легализовалdaily_ceilingиceiling_unknown— оба пишет боевой код черезinternal/pgstore/books.goCeilingPause. ⚠ Пол зависит от ДАННЫХ, а не от схемы:DownTo(<15)падает только там, где такая пауза случалась, — поэтому тестовая база, в которой её не было, честно катится до 5 (internal/pgstore/pg_test.go), и зелёный тест НЕ опровергает границу. Носитель — открытый рядPD-218. -
Ключ личности —
(provider, subject); почта не ключ.users.emailстала NULLABLE и БЕЗ уникального индекса; неизвестная пара всегда создаёт НОВЫЙ аккаунт. Разбор и цена решения —platform/docs/archive/platform-PROGRESS-P0-P3.md:496=### Политика коллизии почты(раздел закрыт вместе с эрой P0–P3, в живом журнале его нет). -
Админ-поверхность — CLI (
tmplatformctl), не HTTP-ручка. Ручке понадобилась бы вторая модель авторизации (роли, эскалация, отзыв админской куки) ради ПЯТИ операций, какими они были на дату решения (grant·adjust·balance·logins·revoke); на 04.09 их двенадцать — прибавилисьbook add·book refresh·books·runs·run abandon·run unquarantine·seed, то есть довод не устарел, а усилился. Тогда как граница доверия «есть шелл на машине и доступ к DSN» уже обеспечена машиной. Браузерная панель, если понадобится, обернёт те же вызовы стора. 10а. Имя провайдера —TM_PLATFORM_OIDC_PROVIDER, и оно должно меняться ВМЕСТЕ с издателем. Это первая половина ключа личности. НаправитьTM_PLATFORM_OIDC_ISSUERна другой IdP, оставив имя прежним, — значит сложитьsubнового провайдера в старое пространство имён, то есть тихо связать чужие аккаунты. Переменная называется здесь, потому что в деплой-примере её не было и оператору нечему было напомнить. -
Секреты — через
*_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.Отзыв прекращает и уже установленные длинноживущие каналы, а не только новые запросы. Открытый
GET /v0/books/{bookId}/eventsпере-спрашивает свою сессию на каждом тике опроса и завершается терминальным кадромsession_ended(канон 0.8.0), когда сессия отозвана или исчерпала АБСОЛЮТНЫЙ срок. Замерено живьём:tmplatformctl revoke --user→ поток кончился через одну секунду; на демоне с абсолютным сроком 40 с и сроком бездействия 10 с поток кончился ровно на 40 с. ⚠ Срок БЕЗДЕЙСТВИЯ к этому каналу не применяется, и это не упущение. Окно бездействия скользит на ЗАПРОСЕ, а поток — один запрос на всю свою жизнь, поэтому поток по построению не может сдвинуть собственный дедлайн; гашение по нему обрывало бы связь пользователю, который сидит и смотрит. Поток ограничен ровно теми двумя фактами, на которые он повлиять не может: отзывом и абсолютным сроком. ⚠ Остаток, названный прямо:SweepSessionsраз в час удаляет строки и по бездействию тоже, а отсутствие строки обязано значить «мертва» (иначе отозванная сессия жила бы до свипа) — поэтому сессия, протухшая по бездействию и подметённая, теряет поток с опозданием до часа. К этому моменту любой другой её запрос — 401.7.1.3 / 7.6.1, согласование с федеративной сессией. Наша сессия живёт СВОЕЙ жизнью: RP-initiated logout и back-channel logout не реализованы. Следствия названы прямо: выход из Google не завершает нашу сессию, и отзыв доступа на стороне Google — тоже. Единственные границы — наши два срока и наш отзыв. Это и есть причина, по которой абсолютный срок выровнен по NIST, а не растянут: пока нет канала «IdP сказал, что сессия кончилась», абсолютный срок — единственное, что вообще ограничивает жизнь сессии после события на стороне провайдера.
7.6.2 выполнено: сессия создаётся только в колбэке потока, который человек начал явным действием, и провайдер показывает свой экран согласия. Без взаимодействия сессия не появляется.
7.3.1 / 7.3.2 — про МЕХАНИЗМ, а не про документ, которого требуют 7.1.x. Дословно (ASVS 5.0 V7, обе — уровень 2): 7.3.1 — «Verify that there is an inactivity timeout such that re-authentication is enforced according to risk analysis and documented security decisions»; 7.3.2 — то же про «absolute maximum session lifetime». ⚠ Читать точно: они требуют не КОНКРЕТНОЙ величины, а того, чтобы механизм СУЩЕСТВОВАЛ и принуждал к повторной аутентификации согласно задокументированному решению — то есть согласно тексту выше. Обе выполнены и запинены: срок бездействия и абсолютный срок — два независимых условия одного запроса в
pgstore/sessions.go,Touchзажимает новый дедлайн абсолютным потолком, и на границе стоитTestSessionClocksStayWithinTheDeclaredBaseline(internal/config): поднятие ДЕФОЛТА выше 30 суток роняет батарею. Значение из окружения тест видит только если оно задано в среде прогона — оператора, поднявшегоTM_PLATFORM_SESSION_MAX_AGE, ловит этот раздел, а не батарея. - Абсолютный срок — 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у задания — намеренно против рефлекса очередей: повтор здесь не доделывает потерянную работу, а СПАВНИТ второй движок; работу восстанавливает реконсилятор, который читает мир, а не доверяет представлению задания о нём. -
⚠⚠ РЕШЕНИЕ ОТМЕНЕНО ПАКОМ «ФОРМА ЗАКАЗА» 05.09 — СТАВКИ БОЛЬШЕ НЕТ, и посылка, на которой она стояла, была неверна. Дословный довод ниже начинается со слов «движковой поверхности оценки не существует»: она СУЩЕСТВУЕТ с лендинга
81a89e9—manifest --jsonпубликуетexpected_usdпо главам,book_once_usdиstep_max_usd, выведенные из ТЕКСТА, калибровки пары и собственных цен движка. КонстантаDefaultPerChapterи переменнаяTM_PLATFORM_USD_PER_CHAPTERудалены; книга, которую движок не оценил, теперь ОТКАЗЫВАЕТСЯ продаваться, а не продаётся по догадке. Что осталось настройкой оператора — только запас над оценкой,TM_PLATFORM_HOLD_FACTOR_PERCENT(дефолт 125, провенанс и деривация —pricing.DefaultHoldFactorPercent; число ВЫВЕДЕНО, не измерено, его заменяет замер строки бэклога 281). ⚠ Цена самой константы названа замером: она была занижена ×4.47 (D39.179п.1) и делала последние главы ЛЮБОЙ книги непокупаемыми при любом балансе (PD-440). Ниже — прежняя редакция, она объясняет, откуда взялось число, и читается как ИСТОРИЯ. Ставка «главы → доллары» — константа платформы. Движковой поверхности оценки не существует, и выдумывать её запрещено промтом; провенанс числа —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:110,:278), а 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. ⚠ Пока карантин стоит, проекция живого ПЛАТНОГО прогона слепа, а прогон идёт и тратит; снятие построено паком P13 (PD-426,fixed(6ae3e76)) —tmplatformctl run unquarantine --run <id>(internal/pgstore/runs.goUnquarantine) чиститquarantine_reasonЖИВОЙ попытки и НЕ трогает курсор: те же нечитаемые байты следующий свип карантинит снова с той же причиной, и это честный ответ, а не сбой команды. Три отказа своими словами: нет такого прогона · у прогона нет живой попытки · попытка не в карантине. Пины —TestLiftingAQuarantineClearsItAndSaysWhatItWasиTestALiftedQuarantineMaterializesTheJournalAgainFromTheCursor; рантбук —deploy/README.md§«Застрявшая работа». Граница держится на типах: ошибка чтения ФАЙЛА —*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 плюс «четыре золотых сигнала» на вопрос «что мерить» — обе нормой в
ENGINEERING_STANDARDS.md§2. Пин на форму —metrics.TestTheRunnersStateIsExposedWithItsUnits, он же ловит единицу в имени.Кардинальность: лейбл несёт ПАТТЕРН маршрута, никогда путь — сырой путь это библиотека пользователя в индексе оператора (PD-3) и неограниченное число рядов; неразобранный запрос сворачивается в один ряд
(unmatched), потому что там лейбл выбирает не сервер. Пин —TestRequestsAreCountedByRoutePatternAndNeverByPath.Отдельный слушатель (
TM_PLATFORM_METRICS_ADDR, дефолт127.0.0.1:9464), а не маршрут под/v0. Контрактная поверхность отвечает 401 раньше 404, чтобы аноним не картографировал её (§4), а экспозиция несёт ту же породу фактов — глубину очереди, число прогонов, размер деплоя. Выходов два: вторая модель авторизации для скрейпера или привязка туда, где достаёт только хост; второе — то, что делает каждый контрол-плейн, и это строка деплоя вместо ещё одной вещи, которую надо не сломать (та же логика, по которой админ-поверхность стала CLI, §10). Не поднявшийся слушатель — WARN, не фатал: телеметрия это как за сервисом смотрят, а не как он служит.Значения снимает СВИП, а не скрейп. Гейджи ставятся раз в проход реконсилятора, который и так читает базу по таймеру; коллектор, ходящий в Postgres на каждый скрейп, отдал бы нагрузку на контрол-плейн тому, у кого есть доступ к эндпоинту.
-
Дедлайн чтения маршрута загрузки РАСШИРЯЕТСЯ, но никогда не снимается.
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.⚠ Второй гейт того же класса, из приёмки 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, убирает класс по построению.
Что решено актом 5 P7 (20.08) — правила, которые НЕ выводятся из одной функции
-
Каждая транзакция, ЗАКАНЧИВАЮЩАЯ прогон, обязана оставить книгу должной материализацию. Таких транзакций три —
FinishRun,PauseRun,FinishUnspawnedStop, — и правило записано фрагментомowesAReadingSurface, который несут все три, потому что забыть его в четвёртой ничего не мешает. Цена забывания названа замером: книга уезжаетfinished_at-нутой и НЕ должной, очередь дрейна ключуется этой колонкой и больше на закрытую книгу не смотрит — то есть прогон, за который пользователь заплатил, не показывает свой текст никогда. Две из трёх и забыли; пин —pgstore.TestEveryEndingOfARunLeavesTheBookOwingASurface, по таблице из трёх концовок. -
books.read_model_owed_at— это СРОК, а не момент возникновения. Тот, кто берётся платить долг, отодвигает срок (аренда,ClaimReadModelDebt), очередь берёт только<= now(), неоплаченный долг едет в конец (DeferReadModelDebt), погашение сверяет метку. Три следствия, каждое было дефектом до того, как правило записали: интейк и свип не читают движком одну книгу одновременно (а читали — свип каждые 15 с, материализация до 5 минут); книга, про которую движок ответить не может, не держит голову очереди вечно; долг, поставленный ПОЗЖЕ, переживает материализацию, начатую раньше. Каждый писатель этой колонки обязан идти через сверку метки — безусловная запись возвращает любой из трёх. -
«Есть ли у пайплайна редактор» — свойство КНИГИ, от объявления движка; но фактов ДВА, и в этом вся правка (пере-подписано по решению владельца D39.165 §2). Движок называет форму сам: пайплайн без редактора даёт волне
editзнаменатель ноль (beginWaves). Читать это с последнего ПРОГОНА по-прежнему НЕЛЬЗЯ — последний прогон самый новый, а только что допущенный ещё ничего не объявил, иchapters_doneпадал в ноль в момент допуска (это часть правила не менялась).books.edit_wave— ИСТОРИЧЕСКИЙ факт, монотонный, пин D39.153 §4б стоит и не отменён: книга, прошедшая редактирующий пайплайн, остаётся такой. Он больше НЕ авторитет для счёта.books.epoch_editor+books.shape_epoch(миграция 00030) — ЭПОХА: форма как она стоит СЕЙЧАС (присваивается, не накапливается) и счётчик пересечений границы. Через эпоху считается ПОЖИЗНЕННЫЙ СЧЁТ КНИГИ — и ТОЛЬКО он.- Полоса ПРОГОНА остаётся на монотонном
books.edit_wave. Это не оговорка, а замеренное ограничение:epoch_editorприсваивается и ходит в обе стороны, поэтому полоса, читающая эпоху, перестаёт быть монотонной — один прогон читал 4/4, затем 2/2 на своих же двух попытках при неизменнойstructure_version. Канон держит полосу одной монотонной дробью (строка 200), так что читать её через эпоху НЕЛЬЗЯ, как бы соблазнительно ни выглядела симметрия со счётом книги. Цена ограничения названа и открыта строкойPD-435: на деплое, где редактора убрали, полоса тарифицирует edit-волну, которой не будет, и до единицы не доходит. Правильный носитель для неё — форма, под которой работает ЭТОТ прогон, записанная на самом прогоне; вStartRunона ещё не известна (движок объявляет её первым progress-событием — это ровноPD-401), поэтому это отдельная работа, а не хвост этой. - Пер-главный счётчик (
units_doneна проводе) — третий читатель и НЕ выводится ни из того, ни из другого целиком: правилоchapterUnitsDoneнаписано ОДИН раз и вычисляется ПО ГЛАВЕ (для прогона с подписью глава, которой не касался редактор, считается по черновику). Книго-широкий предикат здесь запрещён: кадры глав шлются поглавно, и он менял бы ответ про главы, которых не касалось ни одно событие. Почему разделено: один факт отвечал на два вопроса и мог быть прав только для одного. Прочитанный как авторитет счёта, монотонный флаг неверен в ОБЕ стороны — вниз он не ходит: редактора убрали ⇒ счёт мёрзнет против edit-колонки, которую больше никто не заполнит, полоса ни одного прогона не доходит до единицы, а шкала покупки снова продаёт переведённое (PD-403, это деньги); редактора добавили ⇒ флаг переворачивается на первом progress-событии и книга 7/10 показывает 0/10 назад внутри однойstructure_version, что канон запрещал прямо (PD-404). Решение владельца 28.08: смена формы конвейера — СОБЫТИЕ КНИГИ, как пере-нарезка, и счёт легально пересчитывается на границе;shape_epoch— то, чем клиент отличает законный пересчёт от хода назад (канон 0.9.0, поле непрозрачное, саму форму на провод не выносим). ⚠ Парности числителя и базлайна (PD-401, базлайны берутся вStartRunи не пере-снимаются) для полосы МАЛО: её монотонность держит именно монотонный флаг.
-
Утверждение про АТОМАРНОСТЬ пишется через
xmin. Пин, проверяющий конечное состояние, не видит выноса записи из транзакции во второй оператор — состояние то же, меняется окно.xmin(транзакция, последней писавшая строку) у книги и у кадра, который та же транзакция выпустила, совпадает ровно тогда, когда обе записи сделала одна транзакция. Найдено тем, что два пина этого же акта прошли под мутацией, которую сами называют.
Инвентарь каналов движка (собран паком P8-FIX 22.08 ЧТЕНИЕМ кода движка)
ДРУГОГО носителя у этой таблицы в репозитории нет (грепом —
research/23описывает ФОРМУ шва, а не перечень каналов с их атомарностью). Собран чтениемbackend/, не по нашим докам. Ценность в двух колонках, которых нельзя получить из кода платформы: атомарность записи (какие сайдкары можно читать на живом прогоне, а какие рвутся) и какие каналы движка платформа не потребляет вовсе. ⚠ Якоря в таблице — на код ДВИЖКА, он живёт своей жизнью: при расхождении первичен код. ⚠ Канал<project_db>.bank-stop.jsonСНЯТ из таблицы 02.09: его больше нет. Файл и его писатель снесены вместе с пер-термной моделью подписи (D39.158); провереноgrep -rc 'bank-stop.json' backend/ --include='*.go'→ ноль вхождений. Читателя в зоне у него не было никогда, так что ничего платформенного на нём не стояло.
| Канал | Писатель в движке | Атомарность | Читатель/писатель платформы | Согласовано? |
|---|---|---|---|---|
<project_db>.manifest.json |
pipeline/manifest.go writeFileAtomic |
атомарно (temp+Sync+rename, pipeline/artifact.go) |
internal/runner/engine.go (tmctl manifest) → ingest.DecodeManifest → internal/books/parse.go, internal/readmodel |
✅ и с P8-FIX строже: ingest.Manifest.Whole() зеркалит BookManifest.selfConsistent движка по тем правилам, которые платформа читает (главы, юниты, нумерация). Верхняя граница чанков движка counterpart'а не имеет НАМЕРЕННО — платформа счётчиков чанков не берёт |
<project_db>.bank.json |
pipeline/bankexport.go writeFileAtomic |
атомарно | internal/runner/artifacts.go → ingest.DecodeBank → pgstore.SaveBank |
✅ |
<project_db>.bank-stop.txt |
pipeline/mining.go os.WriteFile (греп bankStopTablePath) |
НЕ атомарно (усечение первым делом) | читателя нет | ✅ и это важное правило, а не наблюдение: брать его на ЖИВОМ прогоне нельзя — прочитаешь обрезанный файл без всякой ошибки |
<project_db>.mined-signature.yaml |
pipeline/mining.go writeFileAtomic (греп signatureMapPath) |
атомарно | читателя нет | ✅ ⚠ испр. 02.09: писатель сменился на атомарный, прежняя строка «os.WriteFile, НЕ атомарно» была протухшей |
<project_db>.auto-bank.yaml |
pipeline/mining.go writeFileAtomic (греп autoBankPath) |
атомарно | читателя нет | ✅ то же исправление |
<book_id>.mined-delta.yaml — путь ДЕРИВИРОВАННЫЙ, в book.yaml не объявляется (backend/internal/config/book.go MinedDelta string \yaml:"-"``, «DERIVED, never read from the file») |
писатель В ДВИЖКЕ ЕСТЬ: pipeline/bankdecisions.go writeDecisionFiles (глагол tmctl bank-apply); читатель loadMinedDelta; формат seed.File (terms:), грузится membank.LoadGlossarySeed, Source пере-штампуется на "mined" |
атомарно (стейдж + переименование, каталог книги синкается) | платформа их НЕ пишет и писать не должна — pipeline/status.go: «the engine remains their only writer»; её половина канала — документ решений, строкой ниже |
✅ разрыв ЗАКРЫТ (D39.156 п.3 · D39.166). ⚠ Ключи mined_delta:/mined_rejects: в book.yaml РЕТАЙРНУТЫ: непустое значение — жёсткая ошибка загрузки конфига (config/book.go, RetiredMinedDelta), то есть ключ в шаблоне платформы уронил бы КАЖДУЮ новую книгу |
<book_id>.mined-rejects.yaml — путь деривируется так же (MinedRejects string \yaml:"-"``) |
писатель тот же (writeDecisionFiles); читатель loadMinedRejects; формат rejects: [{src, note}] — ФИЛЬТР ПРЕДЛОЖЕНИЙ, в банк не входит |
атомарно, переименовывается ПЕРВЫМ | платформа не пишет (тот же запрет) | ✅ то же: закрыт D39.156 п.3 / D39.166, ключ ретайрнут |
tmctl bank-apply + документ решений |
зона ПИШЕТ (internal/runs/bank.go decisionsFile → файл во временном каталоге), движок отвечает отчётом tm-bank-report-v1 на stdout |
документ пишется целиком до вызова; отчёт — одноразовая выдача на вызов | internal/runner/bankapply.go → ingest.DecodeBankReport → internal/runs/bank.go bankVerdict |
⚠ ЕДИНСТВЕННЫЙ канал, по которому платформа ПИШЕТ в проект движка, — отсюда и своя дверь (POST /books/{bookId}/bank/corrections), и свой класс отказов, и класс write_incomplete = exit 15. ⚠ Пост-verb факт этого канала (bank_moved_at) пишется на ОТДЕЛЬНОМ, отцепленном контексте — иначе обрыв клиента теряет его навсегда (PD-425) |
tmctl build + сайдкар <project_db>.book.<fmt> |
движок (pipeline, пак «писатель книги», D39.175) |
файл пишется целиком до публикации пути; ПУТИ публикуются в StatusArtifacts.book_files (status --json / manifest --json), stdout — конверт tm-build-v1 |
internal/runner/build.go (BuildArgs/Build) → ingest.DecodeBuild → internal/exports (04.09, пак «закрыть цикл») |
✅ Читатель есть с 04.09, и он СТРОИТ, а не подбирает (D39.175 п.2). Три правила канала, каждое куплено кодом движка: (1) --out обязателен. Без него build пишет рядом с БД и УДАЛЯЕТ форматы, о которых не просили (pipeline/bookbuild.go, цикл RemovedFiles) — то есть экспорт txt снёс бы операторский epub, а два экспорта одной книги затирали бы артефакт друг друга. С --out уборки нет вовсе, каждый экспорт — свой неизменяемый файл, и TTL с GC становятся платформенными. (2) --partial обязателен — дверь ВСЕГДА строит (D39.178 п.1), и exit 16 через неё недостижим по построению: увидели 16 — значит флаг не доехал, это дефект нашей проводки, а не книга с дырами. (3) --keys-file НЕ передаётся: глагол $0 и без ключей, движок отказывает во флаге на всём, кроме translate (D20.4). ⚠ Ловушка exit 11 УЧТЕНА, а не унаследована: у build он значит «в книге ноль выходных юнитов», и дверь кладёт его в свой код book_empty, не приближаясь к словарю интейка, который на 11 УДАЛЯЕТ загрузку. BuildReport сверяется и уходит ОПЕРАТОРУ в лог (config_drift/stale_unknown/удалённые копии), на провод не идёт |
tmctl export --json --pairs |
движок (pipeline/export.go) |
— (одноразовая выдача на вызов) | internal/runner/engine.go ExportArgs/Export → ingest.DecodeExport → internal/readmodel |
✅ ⚠ Единственный канал, несущий ТЕКСТ пары — исходник и перевод; манифест несёт только структуру |
events.jsonl (NDJSON эмиттера) |
движок, StreamVersion 1.2 (замерено живым прогоном 04.09: grep -o '"stream_version":"[^"]*"' events.jsonl; прежняя строка говорила 1.1 и протухла) |
append-only | internal/ingest/tail.go + pgstore.RunSink |
✅ |
exit-коды tmctl |
контракт движка | — | ingest.OutcomeOf, internal/runs/reconcile.go outcome |
✅ |
tmctl status --json |
движок | — | internal/runner/engine.go → ingest.StatusReport; расчёт денег |
✅ ⚠ при неудаче зовётся не подряд, а с бэкоффом отсрочки |
book.yaml |
оператор (шаблон) + платформа ОДИН раз (internal/books/render.go, O_EXCL) |
— | платформа пишет пять фиксированных ключей: book_id · title · source_lang · target_lang · source_file |
⚠ декодер движка СТРОГИЙ (config/book.go, dec.KnownFields(true)): незнакомый ключ — жёсткая ошибка, не предупреждение. Значит любое расширение набора ключей платформой это РАТИФИКАЦИЯ, а не правка: сборка движка, которая ключа ещё или уже не знает, перестанет грузить КАЖДУЮ новую книгу. Прямо относится к развилке 199(а) |
⚠ Два следствия, которые стоит держать в голове при любой работе со швом. Первое: относительные
пути в book.yaml резолвятся от каталога КНИГИ (config/book.go resolve()), поэтому один шаблон
на все книги даёт пер-книжные пути без всякой подстановки. Второе: объявленный ключ с
НЕсуществующим файлом валит загрузку конфига целиком — то есть «объявим ключ, файл создадим потом»
не работает, движок просто не стартует.
Стенд разработчика — воспроизводимый рецепт
⚠
platform/README.mdотсылает за рецептом стенда именно сюда — единственный экземпляр.
Собрать бинари и шаблон книги. Каталог — ЛЮБОЙ вне /tmp (иначе не переживёт уборку):
W=~/tmstand-work && mkdir -p $W/stand/{books,state}
cd <repo>/backend && go build -o $W/tmctl ./cmd/tmctl
cd <repo>/platform && go build -o $W/tmplatformd ./cmd/tmplatformd
cd <repo>/platform && go build -o $W/tmplatformctl ./cmd/tmplatformctl
# шаблон книги = backend/example/book.yaml с ДВУМЯ путями, переписанными в абсолютные
sed -e 's#^pipeline: ../configs/#pipeline: <repo>/backend/configs/#' \
-e 's#^models: ../configs/#models: <repo>/backend/configs/#' \
<repo>/backend/example/book.yaml > $W/book-template.yaml
⚠ Эти два пути — единственное отличие шаблона от репо-оригинала, и сторона у них такая: в ОРИГИНАЛЕ
они относительные (../configs/…), а в ШАБЛОНЕ обязаны стать АБСОЛЮТНЫМИ. Относительный путь
резолвится от рабочего каталога КНИГИ, а конфигов там нет, поэтому движок отвечает «не могу прочитать
файл моделей» — и падает не тест, а замер. ⚠ Прежняя редакция этой строки говорила «относительные —
единственное отличие шаблона», то есть называла сторону наоборот; спешно прочитанная, она стоила
приёмке 11.09 восьми красных живых проб internal/runner, которые были дефектом шаблона, а не дерева.
Проверка на месте, если сомневаетесь: grep -E '^(pipeline|models):' <шаблон> обязан показать пути,
начинающиеся со слэша. Замерено 11.09 на рабочем шаблоне стенда: оба абсолютные, и
books.TestTheRenderedConfigurationIsOneTheEngineActuallyLoads с ним зелёный против свежесобранного
tmctl.
⭐ ШАБЛОН ВТОРОГО ГЕЙТА — НАСТОЯЩИЙ backend/example/book.yaml (с абсолютными pipeline:/models:),
и других вариантов нет. Здесь стояло «решение о дефолте» из развилки двух шаблонов и названная цена
этого решения; развилки больше нет, и цена вместе с ней. Разбор — ниже по разделу, «РАЗВИЛКИ ШАБЛОНОВ
НЕТ» и «$0-ШАБЛОНА НЕ НУЖНО».
⛔ Что стережёт деньги: гард согласия спрашивает о РЕНДЕРЕ, который проба кладёт рядом с книгой, а не о шаблоне. Шаблон движок не грузит вовсе — проба выводит из него $0-пайплайн сама, — и вендор попадает к провайдеру только одним способом: пережив дериват. Гард от имён ключей не зависит и такой промах увидит. До 11.09 он смотрел на шаблон: отказывал стендам, чья настоящая конфигурация была бесплатной, и не мог увидеть единственный случай, в котором деньги тратятся.
Гейты батареи — их ЧЕТЫРЕ, и без них make check МОЛЧА скипует ~290 тестов (вся читающая
модель, миграции и шов). «Зелёная батарея» без них не значит ничего, поэтому check сам печатает
имена скипнутых: TM_PLATFORM_TEST_DSN (Postgres) · пара TM_PLATFORM_TEST_ENGINE_BIN +
TM_PLATFORM_TEST_BOOK_TEMPLATE (живой рендер конфигурации и живой прогон движка) · ДОСТИЖИМЫЙ
пользовательский менеджер systemd (/run/user/<uid>; без него три теста internal/runner
скипаются молча — PD-374) · хост обязан РЕАЛЬНО применять MemoryMax к транзиентному юниту.
⚠ Первые три условия печатает make conditions — с состоянием на ЭТОМ хосте и пакетами, которые
каждое открывает; перечень выводится из самих тестовых исходников по ВЫЗОВУ хелпера, а не по имени в
прозе: os.Getenv("TM_PLATFORM_TEST_…") даёт переменные, exec.LookPath("…") — бинари, без которых
тесты скипаются молча (systemd-run и python3 у internal/runner, make у internal/gates), плюс
проба systemdOrSkip на достижимый пользовательский менеджер. check печатает этот перечень над
списком скипов (P13). ⚠ Двух вещей в нём нет по построению: CREATEDB у роли DSN назван строкой, но не
пробуется (спрашивается только при создании скретч-базы), а четвёртое условие — реальное применение
MemoryMax — пробой не предсказывается вовсе (PD-423), его знает только сам тест
TestARunIsBoundedByItsOwnCgroup.
⚠⚠ И ещё ДВА условия, которых в перечне выше нет, а в выдаче они есть — замер 11.09, пак «правда у
двери». Со всеми четырьмя гейтами поднятыми батарея дала 20 ok · 0 FAIL · 5 скипов, и оба условия
этих пяти — ниже. Пишутся сюда потому, что этот раздел объявлен ЕДИНСТВЕННЫМ носителем числа, а сессия,
честно сверившаяся с ним, назвала бы «условие одно» там, где их два.
- Артефакт контраста движка: пять живых движковых проб
internal/runnerскипаются со словами «this deployment's pipeline enables the bank contour and names<репо>/backend/configs/mining-contrast.zh.txt, which is not on this host». Файл — чужой зоны, и его отсутствие не поломка платформы; носитель — строка бэклога 251 (артефакт контраста банкового контура), сверено по трекеру 11.09. ⚠ И «пять скипов» НЕ значит «живая движковая половина недостижима»: с шаблоном, чьиpipeline:/models:абсолютные, пробыinternal/runnerдают 42 PASS · 0 FAIL при тех же пяти скипах (замер приёмки №23, 11.09). - Локальный адрес провайдера занят:
runner.TestTheSnapshotGuardIsLoudWithoutTheFlagsAndPassesWithThemумеет скипнуться ВТОРЫМ условием —listen tcp 127.0.0.1:11434: bind: address already in use(чужой стенд на том же хосте). ⚠ Один тест с ДВУМЯ условиями скипа: какое он назовёт, решает состояние хоста в эту минуту, и два честных прогона одной смены назвали разные. Считать условия по ОДНОМУ прогону поэтому нельзя.
⚠⚠ Движковый бинарь второго гейта обязан быть СОБРАН ИЗ ТЕКУЩЕГО backend/, а не переиспользован
со стенда (cd <repo>/backend && go build -o $W/tmctl ./cmd/tmctl, PD-432). Цена пропуска
названа замером: стендовый tmctl от 24.08 против сегодняшнего
backend/configs/models.yaml дал ТРИ красных теста в internal/books и internal/runner с
сообщением tmctl: config: parse …/models.yaml: yaml: unmarshal errors: line 137: field system_messages not found in type config.CapabilitiesConfig. Диагноз стоит времени именно потому,
что выглядит как дефект зоны: падает платформенный тест, а лжёт бинарь движка, собранный до того,
как в конфиг движка приехало поле. То же правило и той же причины — для КОНФИГОВ стенда, если они
скопированы рядом с бинарём: стендовая копия backend/configs без появившегося позже
langpacks/ru (PD-432).
⛔ Команда-проверка четвёртого условия СНЯТА и не подлежит восстановлению без нового диагноза.
Прежняя редакция предлагала одну строку: cut -d: -f3 /proc/self/cgroup не должен давать
/init.scope. Она ОПРОВЕРГНУТА — второй точкой PD-423 (29.08: тест зелен 5 из 5 в изоляции
при /init.scope, прямая проба systemd-run --user --scope показала, что процесс ВСЁ-ТАКИ попадает
внутрь user@<uid>.service) и третьей точкой пака P12 (30.08: у оболочки cut -d: -f3 /proc/self/cgroup = /, и TestARunIsBoundedByItsOwnCgroup при этом зелен в полной батарее со
скипами 0). То есть cgroup ВЫЗЫВАЮЩЕГО процесса условие не предсказывает — команда даёт ложный
отрицательный, и сессия, честно исполнившая её, объявит гейт невыполненным на хосте, где он выполнен.
Судить по самому тесту: TestARunIsBoundedByItsOwnCgroup зелен при 0 скипов ⇒ условие есть.
Красный — сначала проверять СРЕДУ (cgroup.subtree_control целевого среза, рецепт в «граблях»
ниже), и только потом искать дефект в своём диффе. Кандидат на замену команды —
состояние cgroup.subtree_control среза tm-runs.slice в момент прогона — назван КАНДИДАТОМ и
только: диагноз PD-423 не установлен, и вносить его в рецепт как проверку нельзя.
Ожидание при всех четырёх: 20 пакетов (go list ./...), exit 0, линтер «0 issues» — и скипов 0
ТОЛЬКО при условиях 29.08; сегодня их ПЯТЬ, и это не поломка. ⛔ Две разные величины в одном
разделе — ловушка, и вот развилка: число скипов держат не четыре гейта, а ещё и два условия,
которых на дереве 29.08 не было (артефакт контраста банкового контура — строка бэклога 251 — и
занятый локальный адрес провайдера). Оба названы выше, в блоке «И ещё ДВА условия»; читать надо
ЕГО, а не эту строку, и сверять счёт скипов с ним. Замерено 29.08: с гейтами — 0 скипов на обоих деревьях; без них — exit 0 и 287 скипов на HEAD
fbe6cf3, 304 на дереве пака P11 (пак добавил 17 пинов, гейченных тем же DSN). Число зависит
от дерева, и переносить его между ними нельзя.
⚠ Число «0 FAIL» выше СНЯТО: его поставила приёмка смены №23 (оркестратор textmachine-11), и замер
зоны 11.09 его опровергает. Оркестратор подтвердил это своим словом и просил записать без деликатности:
следующая сессия, прочитав «0 FAIL», получит красное и решит, что сломала сама.
⭐ РАЗВИЛКИ ШАБЛОНОВ НЕТ: гейт поднимается НАСТОЯЩИМ backend/example/book.yaml, и это единственная
конфигурация. Здесь стояли таблица из двух шаблонов, выбор между ними и принятый размен. Всё трое
снято: выбор был ложным, потому что $0-шаблон никогда не берёг денег (следующий абзац). Что замерено и
остаётся верным:
| конфигурация | internal/runner |
почему |
|---|---|---|
настоящий pipeline-c1.yaml (единственная) |
ЗЕЛЁНЫЙ ЦЕЛИКОМ (в батарее: 20 пакетов, 0 FAIL) | проекция цены читается (live projection: expected 2.006241 of which book-level 2.000000, $0: только tmctl manifest --json), а живая проба бежит на $0-паре, которую выводит сама |
| $0-шаблон, собиравшийся прежними рецептами | 2 КРАСНЫХ, а второй рецепт вдобавок не грузится движком | свободная пара законно стоит ноль, а два теста читают проекцию цены (StepMaxUSD:0); второй рецепт давал escalate_to must differ from the primary model, EXIT=10 |
⛔ И размен «мина снапшот-гарда не стережётся по умолчанию» СНЯТ вместе с развилкой (строка П-23 закрыта): гард спрашивает о РЕНДЕРЕ, рендер и правда $0, поэтому живая проба бежит по умолчанию и мина стережётся. Размен был куплен за риск, которого на этом пути нет.
⭐ $0-ШАБЛОНА НЕ НУЖНО, И РЕЦЕПТ ЕГО СБОРКИ СНЯТ ЦЕЛИКОМ (11.09). Здесь стояло два рецепта подряд —
первый оставлял вендоров под ключами эскалации, второй производил пайплайн, который движок отказывается
грузить (stage "draft": escalate_to must differ from the primary model, EXIT=10). Обе починки лечили
симптом, потому что посылка была ложной.
⛔ ПОСЫЛКА, КОТОРУЮ ДЕРЕВО ОПРОВЕРГАЕТ, И ПИСАЛ ЭТО КОММЕНТАРИЙ САМОГО ХЕЛПЕРА. Живая проба НЕ
получает пайплайн шаблона: writeProbeBook БЕЗУСЛОВНО выводит из него $0-пайплайн (zeroCostPipeline) и
указывает книгу на него — стадии, гейты и блок эскалации переписываются, budget_usd зануляется,
chains удаляются. Его собственный комментарий говорит это прямо, включая «a paid model, on every stand
built by the recipe in STACK_DECISIONS». ⇒ $0-шаблон никогда не был тем, что берегло деньги, и рецепт
существовал под ошибку читателя, а не под задачу.
⇒ Второй гейт поднимается НАСТОЯЩИМ backend/example/book.yaml с абсолютными pipeline:/models:,
и ничем больше. Он грузится, даёт настоящую проекцию цены, и живая проба всё равно бежит на $0-паре,
потому что выводит её сама. ⚠ Прежняя запись «тест падает на missing API keys» относилась к миру, где
хелпер этого не делал; сегодня она неверна.
⛔ ЧТО СТЕРЕЖЁТ ДЕНЬГИ ВМЕСТО РЕЦЕПТА. Гард согласия (internal/runner/liveconsent_test.go) спрашивает
о РЕНДЕРЕ — о файле, который проба кладёт рядом с книгой, — а не о шаблоне: шаблон движок не грузит, и
вендор попадает к провайдеру только одним способом, ПЕРЕЖИВ дериват. Дериват — рерайтер ПО КЛЮЧАМ, то
есть ровно того класса, который однажды уже промахнулся; гард от ключей не зависит и его будущий промах
увидит. Запинено с обеих сторон: runner.TestTheGuardSeesAVendorThatSurvivedTheZeroCostDerivation и
runner.TestTheRenderOfAnOrdinaryPaidTemplateComesOutFree.
Дев-демон и пробы. Переменные демона: TM_PLATFORM_DSN · _MIGRATE=1 · _ADDR=127.0.0.1:8099 ·
_DEV_LOGIN · _INSECURE_COOKIES=1 · _BOOKS_DIR · _STATE_DIR · _ENGINE_BIN ·
_BOOK_TEMPLATE · _CTL_BIN · _LANGUAGE_PAIRS='zh>ru,ja>ru:unavailable' · _METRICS_ADDR.
Посев — tmplatformctl seed --url http://127.0.0.1:8099 (кладёт cookie рядом); дальше пробы ходят
с --noproxy '*', cookie-jar'ом и заголовком X-TM-Client.
Как поднять локально
export TM_PLATFORM_DSN='postgres://user@host:5432/tmplatform?sslmode=disable'
export TM_PLATFORM_ADDR=127.0.0.1:8099 # ⚠ ЯВНО — дефолт 8080 не твой личный порт, см. ниже
make check # батарея зоны
go build -o /tmp/tmplatformd ./cmd/tmplatformd # ⚠ не `go run`: нужен ЗНАЕМЫЙ pid
TM_PLATFORM_MIGRATE=1 /tmp/tmplatformd & DAEMON=$!
# ⚠ СМОУК ОПОЗНАЁТ ПРОЦЕСС, А НЕ ОТВЕТ: слушатель на порту обязан быть НАШИМ pid.
ss -ltnp "sport = :8099" | grep -q "pid=$DAEMON," \
|| { echo "на 8099 отвечает НЕ наш демон — дальше не идти"; kill $DAEMON 2>/dev/null; }
curl -s --noproxy '*' 127.0.0.1:8099/healthz # ok
curl -s --noproxy '*' 127.0.0.1:8099/readyz # ready
⚠ ЭТОТ БЛОК БЫЛ ГЕЙТОМ, ПЕЧАТАВШИМ ЗЕЛЁНОЕ, НИЧЕГО НЕ ПРОВЕРИВ, и это замерено исполнением
04.09, а не выведено рассуждением. Прежняя редакция шла go run ./cmd/tmplatformd без
TM_PLATFORM_ADDR, то есть на дефолт 127.0.0.1:8080 (internal/config/config.go:281). На машине
разработки в этот момент четвёртые сутки жил ЧУЖОЙ tmplatformd на том же порту со своей базой.
Итог: наш демон умирал на bind: address already in use, а следующие две строки рецепта отвечали
ok и ready — от чужого процесса. Смоук-тест проходил при мёртвом собственном демоне.
⚠ Три правки лечат три РАЗНЫЕ половины, и ни одна не заменяет другую: (1) явный _ADDR уводит
с общего дефолта; (2) опознание по pid отвечает на вопрос, который 200 не отвечает в принципе —
ЧЕЙ это процесс; (3) --noproxy '*' нужен потому, что при заданных http_proxy/https_proxy
голый curl на 127.0.0.1 уходит во внешний прокси (no_proxy=<local> curl не понимает) и
возвращает отказ, неотличимый от мёртвого стенда. Первая без второй лишь переносит коллизию на
новый номер.
⚠ Дефолт 8080 в коде НЕ меняется, и это решение, а не недосмотр. Коллизия здесь —
tmplatformd против tmplatformd, то есть дефолта против самого себя: любой другой номер даст ту
же аварию на втором одновременном стенде, а цену смены заплатят все существующие деплои и доки,
говорящие 8080. Чинится не номер, а посылка «ответ по адресу = мой сервис».
Переменные: TM_PLATFORM_ADDR · TM_PLATFORM_DSN (или _DSN_FILE) · TM_PLATFORM_MIGRATE ·
TM_PLATFORM_TRUSTED_ORIGINS (через запятую) · TM_PLATFORM_SESSION_IDLE ·
TM_PLATFORM_SESSION_MAX_AGE · TM_PLATFORM_INSECURE_COOKIES (dev, по HTTP; вместе с OIDC, чей
callback не http://, — отказ на старте: §30) ·
TM_PLATFORM_OIDC_ISSUER · _OIDC_CLIENT_ID · _OIDC_CLIENT_SECRET (или _FILE) ·
_OIDC_REDIRECT_URL · TM_PLATFORM_AFTER_LOGIN · TM_PLATFORM_SIGNUP_GRANT_USD.
Приём книги: TM_PLATFORM_BOOKS_DIR (только АБСОЛЮТНЫЙ путь, корень каталогов книг; пусто =
инстанс загрузок не принимает и POST /books не смонтирован) · TM_PLATFORM_MAX_UPLOAD_BYTES
(дефолт 64 МиБ — потолок ОДНОЙ загрузки, §25) · TM_PLATFORM_UPLOAD_DEADLINE (дефолт 10 минут —
сколько тело одной загрузки вправе идти) · TM_PLATFORM_BOOK_TEMPLATE (только АБСОЛЮТНЫЙ путь;
стартовый book.yaml, из которого рендерится конфигурация каждой новой книги — §28 и deploy/;
пусто = книги провижинит оператор руками, и книга без конфигурации ЖДЁТ, а не отклоняется).
Дев-стенд: TM_PLATFORM_DEV_LOGIN (личность дев-входа; вместе с OIDC — отказ на старте, §30).
Наблюдаемость: TM_PLATFORM_METRICS_ADDR (дефолт 127.0.0.1:9464; пусто = метрики не отдаются).
Раннер: TM_PLATFORM_ENGINE_BIN (версионированный путь tmctl; пусто = инстанс только читает) ·
TM_PLATFORM_ENGINE_KEYS_PATH (только АБСОЛЮТНЫЙ путь к файлу провайдерских ключей деплоя,
KEY=VALUE; едет движку АРГУМЕНТОМ --keys-file на translate — строка 211, ключи не проходят ни
через процесс платформы, ни через окружение юнита; пусто = флаг не передаётся и движок зависит от
.env рядом с book.yaml, которого SaaS-путь не пишет — WARN на буте; ⚠ суффикс _PATH, потому что
*_FILE в этой зоне значит «файл со ЗНАЧЕНИЕМ секрета», а тут значение — сам путь) ·
TM_PLATFORM_STATE_DIR (только АБСОЛЮТНЫЙ путь: маркер пишет юнит из каталога книги, читает демон
из своего — относительный назвал бы два разных файла; отказ на буте) · TM_PLATFORM_CTL_BIN ·
TM_PLATFORM_ENGINE_CEILING_ARG (шаблон с {{usd}}; ДЕФОЛТ — --ceiling-usd {{usd}}, залендённая
форма строки 145; переопределяется для сборки движка с другим написанием флага) · TM_PLATFORM_RUN_MEMORY_MAX · TM_PLATFORM_RUN_TASKS_MAX ·
TM_PLATFORM_RUN_WORKERS · TM_PLATFORM_SWEEP_EVERY · TM_PLATFORM_RESYNC_EVERY ·
TM_PLATFORM_SWEEP_BUDGET (дефолт 2 мин — что может занять ОДИН проход свипа) ·
TM_PLATFORM_RUN_BUDGET (дефолт 60 с — что может занять один прогон внутри прохода; ⚠ поле
существовало и НИКЕМ не присваивалось до P8-FIX, PD-331) ·
TM_PLATFORM_HOLD_FACTOR_PERCENT (дефолт 125 — запас холда над оценкой движка в процентах; ⚠ ЗАМЕНИЛ
TM_PLATFORM_USD_PER_CHAPTER, который задавал ЦЕНУ главы и удалён вместе со ставкой, п.20; значение
ниже 100 отбивается на СТАРТЕ, а не на первой покупке) · TM_PLATFORM_RESUME_MAY_CHANGE_ENGINE (по умолчанию НЕТ: резюм идёт
на той сборке движка, с которой прогон начался — строка 139).
Дверь выдачи: TM_PLATFORM_EXPORT_FORMATS (через запятую, порядок = порядок в export_formats;
ПУСТО = дверь не смонтирована и export_formats пуст — тот же вид, что у любого непостроенного
маршрута; ⚠ это ДЕКЛАРАЦИЯ ДЕПЛОЯ, а не открытие: список форматов движка — Go-переменная
bookfile.Formats в модуле, который этому импортировать нельзя (D39.85), и $0-команды, которая
публиковала бы его ДАННЫМИ, у движка нет; та же форма и по той же причине, что
TM_PLATFORM_LANGUAGE_PAIRS; формат, объявленный здесь и незнакомый движку, приходит ОТКАЗАВШИМСЯ
экспортом с кодом deployment_error, а не пустым обещанием на /capabilities) ·
TM_PLATFORM_EXPORTS_DIR (только АБСОЛЮТНЫЙ путь; пусто = <STATE_DIR>/exports. ⚠ Абсолютность
здесь острее, чем у STATE_DIR: путь уходит движку аргументом --out, а движок работает с
каталогом КНИГИ как рабочим — относительный писал бы артефакты внутрь чужого проекта, в то самое
дерево, куда платформе писать нельзя, D39.110) · TM_PLATFORM_EXPORT_TTL (дефолт 24ч; ограничивает
ДИСК, а не доступ — ссылка аутентифицируется на каждом запросе).
Вход монтируется, только если задана ВСЯ четвёрка OIDC; половина конфигурации — отказ на старте.
⚠ Все они печатаются на старте с источником (default/environment/file) — PD-114; секреты и
денежные суммы печатаются фактом наличия, без значения.
Админ-команды (список сверен с usage самого бинаря, cmd/tmplatformctl/main.go:44-62, — прежняя
редакция была короче на шесть): tmplatformctl grant --user <id> --usd <amount> [--note ...] [--key ...] ·
adjust --user <id> --usd <amount> --note <text> [--key ...] (КОРРЕКЦИЯ баланса второй строкой леджера, со знаком; деньги кладёт grant — строки леджера не редактируются, cmd/tmplatformctl/main.go:135,160) · balance --user <id> · logins --user <id> [--limit <n>] ·
revoke --user <id> · book add (дев-интейк) · book refresh --book <id> (попросить читающую
поверхность заново у книги, на которой её бросили) · books [--migratable] [--abandoned] (какие книги
безопасно мигрировать при апгрейде движка, какие потеряли поверхность — deploy/README.md) ·
runs [--stalled] · run abandon --run <id> --reason <text> [--release-hold] (терминальный вердикт
оператора застрявшему прогону) · run unquarantine --run <id> (материализовать журнал карантинной
попытки заново) · seed (дев-стенд: аккаунт, кредит и книга через ЖИВОЙ интейк, §31).
⚠ exit-marker <path> <unit> в этот список не входит: её зовёт systemd как ExecStopPost, не оператор.
⚠⚠ УСЛОВИЯ СТЕНДА КЛЮЧУЮТСЯ ПОЛЬЗОВАТЕЛЕМ И $HOME, А НЕ ИМЕНЕМ МАШИНЫ. Замерено 04.09: две
смены на ОДНОМ hostname (DESKTOP-IN1MCEA) видят противоположные условия — у одного пользователя
sqlc лежит в ~/go/bin и make tools-check на дефолтном PATH падает, у другого он в
~/.local/bin и та же цель даёт 0; живой сокет Postgres у одного 55433, у другого 5432. Пока
записка о хосте ключевалась hostname, две правдивые записи читались как спор о фактах. Снимай
ключ командой hostname; whoami; echo $HOME и сверяй по второму и третьему полю. ⚠ Правило целиком
стоит здесь; замер обеих сторон лежит в архиве (archive/platform-PROGRESS-P9-P13.md, «Условия стенда
различаются ПОЛЬЗОВАТЕЛЕМ») и читается как археология, а не как инструкция.
Postgres на стенде без root
Основной способ (13.08): ПОЛНЫЙ дистрибутив в домашнем каталоге, тоже без sudo.
# micromamba — один статический бинарь с conda-forge; прокси стенда пускает conda-forge
curl -sL -o mm.tar.bz2 https://conda.anaconda.org/conda-forge/linux-64/micromamba-2.9.0-0.tar.bz2
tar -xjf mm.tar.bz2 bin/micromamba && install -m755 bin/micromamba ~/.local/bin/micromamba
MAMBA_ROOT_PREFIX=$HOME/.local/share/mamba \
~/.local/bin/micromamba create -y -p ~/.local/pgsql -c conda-forge 'postgresql=18.4'
~/.local/pgsql/bin/initdb -D ~/.local/share/tmstand/pgdata -U postgres -A trust \
--no-locale --encoding=UTF8
~/.local/pgsql/bin/pg_ctl -D ~/.local/share/tmstand/pgdata \
-l ~/.local/share/tmstand/pg.log -o "-k /tmp -p 55433 -c listen_addresses=''" start
export TM_PLATFORM_TEST_DSN='postgres://postgres@/postgres?host=/tmp&port=55433&sslmode=disable'
164 МБ, ставится в ~/.local (та же схема, что у Go и Node на этой машине), sudo не нужен, и в
пакете есть psql, pg_dump, pg_basebackup, contrib — то есть БД можно смотреть руками. Порт
55433 выбран, чтобы не спорить со стендом-скрэтчпадом на 55432, если тот поднят.
⚠ Проверено исполнением 13.08: батарея зоны make check под -race на этом стенде — EXIT=0,
скипов 0, все пакеты зелёные. Стенд переживает уборку скрэтчпадов сессий; после рестарта машины
поднимается тем же pg_ctl … start.
Запасной способ — бинарники io.zonky.test.postgres с Maven Central в скрэтчпад:
pg/bin/initdb -D pgdata -U postgres -A trust --no-locale --encoding=UTF8
pg/bin/pg_ctl -D pgdata -l pg.log -o "-k /tmp -p 55432 -c listen_addresses=" start
export TM_PLATFORM_TEST_DSN='postgres://postgres@/postgres?host=/tmp&port=55432&sslmode=disable'
⚠ Сокет кладём в /tmp (-k): полный путь скрэтчпада длиннее лимита Unix-сокета.
⚠ В пакете zonky ТОЛЬКО initdb/pg_ctl/postgres — проверено содержимым архива
(1071 файл, в bin/ три штуки); psql нет, смотреть БД приходится из Go. Живёт в скрэтчпаде
сессии, поэтому умирает вместе с ним — для долгой работы предпочтителен способ выше.
Грабли стенда, каждая стоила времени
curlтолько с--noproxy '*'— иначе прокси стенда отвечает 403, и это читается как дефект кода, которого нет.- Демон стенда — ОТДЕЛЬНЫЙ бинарь и сам не пересобирается: убить (⚠ ПО PID, см. ниже — не по имени) →
go build -o <путь>/tmplatformd ./cmd/tmplatformd→ поднять. Иначе живая проба меряет вчерашний код. - Правил миграцию — пере-создай базу стенда (
dropdb tmstand && createdb tmstand): goose ключуется НОМЕРОМ и правку уже применённого файла не видит (то же правило, что вdeploy/README.md). После пере-создания базы старый cookie-jar мёртв — повторить/auth/dev-login. - Красный
runner.TestARunIsBoundedByItsOwnCgroup— обычно НЕ регрессия кода: уtm-runs.sliceопустелcgroup.subtree_control. Лечение без root:echo "+memory +pids" > /sys/fs/cgroup/user.slice/user-1000.slice/user@1000.service/tm-runs.slice/cgroup.subtree_control. - Демон нельзя убивать
pkill -f <путь к бинарю>— шаблон совпадает с собственной командной строкой оболочки, и она убивает сама себя (exit 144). Убивать по PID. - ⛔ TCP-ПРОБА СТЕНДА ДАЁТ
Connection refusedНА ЖИВОМ СЕРВЕРЕ, И ЭТО НЕ ПОЛОМКА, А ЕГО УСТРОЙСТВО. Рецепт выше поднимает Postgres с-c listen_addresses='', то есть он слушает ТОЛЬКО Unix-сокет. Проба127.0.0.1:55433отвечает отказом всегда, и «порт закрыт» неотличимо в её выводе от «сервер намеренно не слушает TCP» — вопрос задан не тому предмету. Замер 17.09, обе стороны в один момент:/dev/tcp/127.0.0.1/55433→Connection refused, при этом/tmp/.s.PGSQL.55433на месте иpsql 'postgres://postgres@/postgres?host=/tmp&port=55433&sslmode=disable' -c 'select 1'→1. ⇒ живость стенда проверяется тем же DSN, которым его читают тесты, либоpg_ctl … status, а не портом. Цена ошибки названа тем, кто её совершил: объявив стенд недоступным, приёмка пере-сняла бы 4 PASS как 4 SKIP и сравнила бы две стороны РАЗНЫМИ приборами. - Postgres стенда переживает не всё: сокет живёт в
/tmp, и уборка/tmp(или smart shutdown) роняет соединение —pg_ctl … statusскажет «no server running». Поднимать заново по разделу выше. - Замер страницы библиотеки (когда трогаете проекции книги):
go test ./internal/pgstore/ -bench LibraryPage -run xxx— корпус 40 книг × 500 глав собирается сам,vacuum analyzeвнутри.