textmachine/platform/docs/STACK_DECISIONS.md

860 lines
131 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

# Стек платформы — пины и обоснования
> Зонный документ `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…» — это НЕ хроника, а РЕЕСТР:** решения нумерованы сплошь **136**
> без пропусков, и группировка по сессиям — лишь атрибуция (кто и когда принял). Указатель добавлен
> 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 |
| Мутационный харнесс: что считается поимкой | 37 |
## Пины
| Что | Пин | Релиз пина | Зачем нам |
|---|---|---|---|
| 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)
1. **`/healthz``/readyz`.** Liveness ничего не трогает (БД лежит — процесс жив). Readiness с P2
спрашивает не «отвечает ли база», а «та ли это база, под которую собран бинарь»: пинг плюс сверка
`goose_db_version` с максимальной вшитой миграцией. Одного пинга было мало — он успешен и на
Postgres без единой таблицы, то есть в нормальной середине выката, где миграция ещё не накачена
(PD-68). Пустой `TM_PLATFORM_DSN` — легальный старт: сервис поднимается и честно говорит «не
готов». Иначе супервизор убивает здоровый процесс за то, что база моргнула.
2. **Ops-эндпоинты вне версионного префикса.** `/healthz`, `/readyz` — в корне; контрактная
поверхность целиком под `/v0` (базовый путь спеки платформа ПОДТВЕРЖДАЕТ).
3. **Один mux.** Контрактные маршруты регистрируются с префиксом в паттерне, а не вложенным mux'ом
под `StripPrefix`: вложенный получает КОПИЮ запроса, и `Request.Pattern` наружу не возвращается —
лог пришлось бы писать по сырому пути с id книг. Проверено исполнением.
4. **Гард навешен на поддерево, а не на ручки.** Неизвестный путь под `/v0` отвечает 401 раньше 404:
аноним не должен картографировать поверхность.
5. **Заголовки безопасности — на каждом ответе** (`X-Robots-Tag: noindex`, `Cache-Control: no-store`,
`nosniff`, `no-referrer`): ПТ-34 нельзя оставлять на дисциплину автора следующей ручки.
6. **Батарея зоны — `make check`** (build · vet · fmt · lint · test -race), форма скопирована с
`backend/Makefile` вплоть до именования пропущенных тестов: тихий skip читается как покрытие.
7. **Тесты с БД гейтятся `TM_PLATFORM_TEST_DSN`** и создают СВОЮ базу на прогон (дропают в
`t.Cleanup`). Батарея на голом клоне зелёная и офлайн; с DSN — та же батарея плюс схема.
## Что решено сессией P1 (05.08)
8. **Миграции 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.go` `CeilingPause`. ⚠ **Пол зависит от ДАННЫХ, а не от схемы:**
> `DownTo(<15)` падает только там, где такая пауза случалась, — поэтому тестовая база,
> в которой её не было, честно катится до 5 (`internal/pgstore/pg_test.go`), и зелёный тест
> НЕ опровергает границу. Носитель — открытый ряд `PD-218`.
9. **Ключ личности — `(provider, subject)`; почта не ключ.** `users.email` стала NULLABLE и БЕЗ
уникального индекса; неизвестная пара всегда создаёт НОВЫЙ аккаунт. Разбор и цена решения —
`platform/docs/archive/platform-PROGRESS-P0-P3.md:496`=`### Политика коллизии почты` (раздел закрыт
вместе с эрой P0P3, в живом журнале его нет).
10. **Админ-поверхность — 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` нового провайдера в старое пространство имён, то есть тихо
связать чужие аккаунты. Переменная называется здесь, потому что в деплой-примере её не было и
оператору нечему было напомнить.
11. **Секреты — через `*_FILE`.** `TM_PLATFORM_DSN_FILE` и `TM_PLATFORM_OIDC_CLIENT_SECRET_FILE`
читаются раньше одноимённых переменных: переменная окружения видна в `/proc/<pid>/environ` и
наследуется каждым ребёнком-`tmctl`. Это же формат `LoadCredential=` systemd (`deploy/`).
12. **`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`.
13. **Политика сессий: 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`, ловит этот раздел, а не
батарея.
14. **Против 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) — раннер
15. **Прогон — транзиентный юнит в СОБСТВЕННОМ 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`, которыми сервис дотягивается до своей же пользовательской шины. Новых привилегий в рантайме — ноль: пользователь всегда вправе управлять своими юнитами.
16. **Юниты прогонов кладутся в СВОЙ срез `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`» подряд красные.
17. **Конец юнита читается из МАРКЕРА, а не из 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` доехали буквально), поэтому экранировать `%` не нужно и было бы неверно. Кавычки для аргументов с пробелами нужны — проверено.
18. **Маркер пишет `tmplatformctl exit-marker`, а не однострочник в юните.** Три причины, каждая кем-то оплаченная: у команды внутри свойства systemd своя кавычковая грамматика, и каталог состояния с пробелом молча распадается на два аргумента; запись обязана быть АТОМАРНОЙ, потому что читатель опрашивает и половина маркера читается как «прогон кончился»; функцию на Go можно протестировать, а строку в свойстве — нет. Команда работает БЕЗ базы и без секретов и диспетчеризуется до чтения DSN (пин — `TestTheExitMarkerCommandNeedsNoDatabase`).
19. **Очередь River v0.42.0 добавлена в `go.mod` и мигрируется СВОИМ мигратором.** Её SQL принадлежит ей: копировать чужие миграции в наш append-only манифест значит держать снимок чужой схемы, который тихо перестаёт совпадать с читающей его библиотекой. Две летописи в одной базе — честная форма; `/readyz` поэтому проверяет и `goose_db_version`, и наличие `river_job` (класс PD-68: инстанс, ответивший «готов» и не умеющий принять прогон, соврал балансировщику). Пин — `TestReadinessCoversTheQueueSchema`.
`MaxAttempts: 1` у задания — намеренно против рефлекса очередей: повтор здесь не доделывает потерянную работу, а СПАВНИТ второй движок; работу восстанавливает реконсилятор, который читает мир, а не доверяет представлению задания о нём.
20. ⚠⚠ **РЕШЕНИЕ ОТМЕНЕНО ПАКОМ «ФОРМА ЗАКАЗА» 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 (+1525% → $0.02520.0274), округлено ВВЕРХ до **$0.03**: заниженная ставка даёт пользователю выбрать больше глав, чем покрывают деньги, и прогон встаёт на потолке — ровно тот исход, ради предотвращения которого шкала и существует. Переопределяется `TM_PLATFORM_USD_PER_CHAPTER`. Пин на полосу провенанса — `pricing.TestTheDefaultRateStaysInTheBandItsProvenanceGives`.
## Что решено дофиксом P4 (09.08)
21. **`--ceiling-usd` — это КНИЖНЫЙ потолок в силе, а не бюджет прогона, и пересчёт лежит на платформе.** Флаг переопределяет `ceilings.book_usd` и сравнивается с накопленным `committed + reserved` книги на КАЖДОЙ резервации (`backend/internal/store/ledger.go` `Reserve`; сам флаг документирует это словами «not this run's increment»). Пользователь же покупает ПРИРОСТ в главах (D39.110), поэтому платформа обязана перевести одно в другое — эта обязанность и ратифицирована D39.122. Формула зоны: **`аргумент = committed + прирост×ставка`**. ⚠ Слагаемого `reserved` в ней нет, вопреки букве пинга ратификации, и это НАЗВАННОЕ отклонение в консервативную сторону: `store.Open` (путь записи каждого `translate`) обнуляет остаточный `reserved_usd` книги ДО первой судимой резервации (`backend/internal/store/store.go:110`, `:278`), а read-only `status` этот проход не делает — значит прочитанная цифра к моменту сравнения уже стёрта, и её добавление отдало бы прогону запас БОЛЬШЕ его холда. Разбор — PD-158; **ратифицировано 09.08** (оркестратор пере-мерил обе формулы против гейта движка: ратифицированная переплачивала запасом ровно на leftover-reserved). Обе величины читаются ОДНИМ вызовом `status --json` перед стартом — они должны быть согласованы между собой, а второй вызов стоит секунды CPU на пере-нарезку.
Три следствия, каждое построено: `reserved_usd` лежит в аллоулисте УКАЗАТЕЛЕМ и обязателен (отсутствие ≠ ноль) — он не входит в потолок, но именно он доказывает, что отклонение безопасно: на спавне другого писателя нет, значит любой резерв по построению остаток; рестарт считает по СВЕЖЕМУ отсчёту, потому что прерванная попытка счётчик сдвинула; фактически ушедшее значение хранится (`run_attempts.ceiling_arg_micro_usd`) — после сдвига счётчика его нечем восстановить, а «какой лимит был у того процесса» это первый вопрос к прогону, вставшему рано.
⚠ Тестировать это фейком, который лишь записывает аргумент, нельзя: фейк, который не может отказать, не проверяет ничего. Пины гоняют `ceilingJudge` — фейк с правилом движка.
22. **Порядок блокировок в `pgstore` — глобальный и записан в одном месте** (`lockBook`): `books → runs → run_attempts → account_balances → reservations`. Не стилевое соглашение: транзакция, взявшая две таблицы в обратном порядке, дедлочит с любой другой, Postgres рвёт цикл откатом одной стороны, и цена — упавший свип или запрос. Замерено дважды: 41 дедлок на 300 раундов у пары Hold↔Settle (PD-26) и 258 из 300 у пары материализатор↔реконсилятор (PD-145).
Порядок утверждается ПРЯМЫМ пином, а не конкурентным прогоном: тест держит замок книги, дожидается, пока операция реально заблокируется, и проверяет строку попытки через `for update nowait`. Конкурентная проба оставлена, но она пробует — посадка «снять книгу-первой из `RestartRun`» её пережила, потому что рестарт берёт замок один раз за прогон.
23. **«Ошибка материализации» — два разных факта, и различает их `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.go` `Unquarantine`) чистит `quarantine_reason` ЖИВОЙ попытки и НЕ трогает курсор: те же нечитаемые байты следующий свип карантинит снова с той же причиной, и это честный ответ, а не сбой команды. Три отказа своими словами: нет такого прогона · у прогона нет живой попытки · попытка не в карантине. Пины — `TestLiftingAQuarantineClearsItAndSaysWhatItWas` и `TestALiftedQuarantineMaterializesTheJournalAgainFromTheCursor`; рантбук — `deploy/README.md` §«Застрявшая работа». Граница держится на типах: ошибка чтения ФАЙЛА — `*fs.PathError`, а он `net.Error` не удовлетворяет; пропасть, конфликт payload, битая строка — карантин ПОПЫТКИ (её проекции), прогон при этом продолжается и продолжает платить.
## Что решено сессией P5 (11.08) — загрузка книги, стоп/резюм, наблюдаемость
24. **Метрики — `prometheus/client_golang` v1.24.1, на ОТДЕЛЬНОМ слушателе, и это выбор против stdlib.** Норма зоны требует stdlib прежде библиотеки (`ENGINEERING_STANDARDS` §1), поэтому первым рассмотрен `expvar` — и он этой работы не несёт: нет лейблов (⇒ «запросы по маршруту и коду» не выражаются вовсе), нет гистограмм (⇒ на вопрос о задержке остаётся среднее — единственная статистика, которая прячет хвост), а его JSON не читает ни один скрейпер без переводчика. Сэкономил бы он зависимость, а стоил бы написания недостающих трёх руками — то есть ровно того самописного пути, который та же норма и запрещает. OpenTelemetry для этого деплоя тяжелее: коллектор процессом, протокол экспорта настройкой, и всё равно scrape-эндпоинт на конце. Пин сверен живьём 11.08 (`proxy.golang.org/@latest`), релиз 24.07.2026.
**Внешний эталон оси (половина PD-115):** практики именования Prometheus плюс «четыре золотых сигнала» на вопрос «что мерить» — обе нормой в `ENGINEERING_STANDARDS.md` §2. Пин на форму — `metrics.TestTheRunnersStateIsExposedWithItsUnits`, он же ловит единицу в имени.
**Кардинальность:** лейбл несёт ПАТТЕРН маршрута, никогда путь — сырой путь это библиотека пользователя в индексе оператора (PD-3) и неограниченное число рядов; неразобранный запрос сворачивается в один ряд `(unmatched)`, потому что там лейбл выбирает не сервер. Пин — `TestRequestsAreCountedByRoutePatternAndNeverByPath`.
**Отдельный слушатель (`TM_PLATFORM_METRICS_ADDR`, дефолт `127.0.0.1:9464`), а не маршрут под `/v0`.** Контрактная поверхность отвечает 401 раньше 404, чтобы аноним не картографировал её (§4), а экспозиция несёт ту же породу фактов — глубину очереди, число прогонов, размер деплоя. Выходов два: вторая модель авторизации для скрейпера или привязка туда, где достаёт только хост; второе — то, что делает каждый контрол-плейн, и это строка деплоя вместо ещё одной вещи, которую надо не сломать (та же логика, по которой админ-поверхность стала CLI, §10). Не поднявшийся слушатель — WARN, не фатал: телеметрия это как за сервисом смотрят, а не как он служит.
**Значения снимает СВИП, а не скрейп.** Гейджи ставятся раз в проход реконсилятора, который и так читает базу по таймеру; коллектор, ходящий в Postgres на каждый скрейп, отдал бы нагрузку на контрол-плейн тому, у кого есть доступ к эндпоинту.
25. **Дедлайн чтения маршрута загрузки РАСШИРЯЕТСЯ, но никогда не снимается.** `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` из метрик» падает).
26. **Приём книги: строка ПЕРЕД байтами, файл в каталоге книги, разбор — отдельным шагом.** Порядок «строка, потом байты» делает `uploading` состоянием, которое кто-то может наблюдать (вторая вкладка видит книгу, пока файл ещё идёт), и — что важнее операционно — делает брошенную загрузку НАХОДИМОЙ: строка единственное, что говорит, чей каталог под `BooksDir`. Цена порядка названа: языки обязаны прийти ДО файла, потому что потоковый читатель отдаёт части в порядке провода; тем же правилом специфицирована браузерная загрузка S3, а в спеке нашего контракта его нет — заведено вопросом владельцу контракта (PD-172).
Тело читается `r.MultipartReader` и льётся в файл `source.<расширение>`; расширение сохраняется, потому что движок диспетчеризует читатель по нему (`.epub` → epub, иначе текст), а не потому, что платформа знает форматы — списка форматов у неё нет и не должно быть. Разбор = `tmctl manifest --json`, $0-команда, которая и создаёт БД проекта; она даёт число глав и идентичность разреза (`chunker_version`, `source_sha256`).
**Два разных отказа и две разных судьбы файла.** Движок ОТВЕТИЛ «не разобрать» (`*exec.ExitError`) — это про книгу, терминально с первого ответа, каталог удаляется: перепарсить нечем, скачать нечем, а аутентифицированный маршрут, который пишет на диск оператора и ничего не убирает, был бы дырой, которую этот пак открыл бы сам. Движок НЕ ЗАПУСТИЛСЯ — это про хост: заявка возвращается, следующий свип пробует снова, и только после бюджета попыток книга становится `rejected` с файлом НА МЕСТЕ (удалять чужую загрузку из-за своей поломки — не наше решение).
27. **Стоп различается НАМЕРЕНИЕМ, записанным до сигнала, а не догадкой по коду выхода.** Движок ловит SIGTERM и выходит кодом 1, поэтому маркер пользовательского стопа и маркер аварии — одни и те же байты (PD-152). Платформа пишет `runs.stop_requested_at` в Postgres и только потом просит systemd; на гонку «стоп против самостоятельного финиша» стоит сверка времени маркера, а чистые исходы движка (0/2/3) намерение перебивают — завершённый перевод не должен показываться отменённым. Следствия, каждое запинено: прогон с намерением НЕ перезапускается реконсилятором (иначе деньги уходят на работу, которую владелец только что отменил), живой юнит с намерением получает повторный стоп (закрывает «платформа умерла между записью и вызовом»), а стоп до спавна заканчивает прогон и возвращает холд целиком. ⚠ Что этим НЕ закрыто и названо: штатная перезагрузка хоста по-прежнему выглядит как авария — там намерения нет ни у кого, и различить это может только сам движок различимым кодом выхода (строка 165 единого бэклога).
## Что решено сессией P6 (14.08) — потребительская половина шва, интейк формы Б, дев-стенд
28. **`gopkg.in/yaml.v3` v3.0.1 переходит из транзитивных в ПРЯМЫЕ, и это не новая зависимость в
графе.** Она уже приезжала транзитивно (goose); прямой её делает рендер стартового `book.yaml`
(форма Б, D39.130). Тот же пин, что у движка — один YAML-парсер на репозиторий, иначе две
стороны одного файла расходятся в краевых случаях, которые никто не ищет.
Норма зоны требует stdlib прежде библиотеки, и здесь она соблюдена в обратную сторону: stdlib
YAML не имеет, а альтернатива — шаблонизировать файл ТЕКСТОМ через `text/template` — проиграла
по трём измеримым свойствам, каждое из которых ломает книгу молча. Ключ, который оператор уже
написал в шаблоне, текстовая подстановка ДУБЛИРУЕТ (а дубль ключа — жёсткая ошибка строгого
декодера движка); комментарии и порядок ключей оператора не переживают перегенерацию, а файл
после рендера принадлежит ему; и значение вроде `123` или `true` уезжает как число или булево —
книга с названием «123» тогда просто не грузится. Работа с УЗЛОМ (`yaml.Node` + `!!str`) решает
все три, и стоит она одной библиотеки, которая уже была в графе.
⚠ Названо ревью пака: **`gopkg.in/yaml.v3` v3.0.1 — последний релиз по этому пути модуля**;
живое продолжение переехало на `go.yaml.in/yaml/v3`. Сегодня это не проблема (пин тот же, что у
движка, и переезд одной зоны в одиночку как раз и создал бы два парсера в репозитории), но при
следующем касании модуля движка вопрос переезда решается СРАЗУ ОБЕИМИ сторонами.
29. **Гейт «шаблон читается на КАЖДОМ рендере, а не на буте».** Оператор, починивший битый шаблон,
не должен ещё и перезапускать контрол-плейн: книги, которые ждали, разбираются ближайшим свипом.
Цена — одно чтение маленького файла на попытку разбора, и она куплена сознательно.
30. **Дев-вход (`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` работать не может (законно, запускается), а всё остальное противоречит
самому переключателю.
31. **Сид стенда ходит по HTTP теми же дверями, что пользователь** (`tmplatformctl seed`): дев-вход →
аккаунт → грант со стороны админа → загрузка через `POST /v0/books` с ожиданием конца интейка.
Прямых INSERT нет намеренно — сид, который пишет строки сам, делает стенд, чьи книги никогда не
проходили интейк, и первый же баг фронта оказывается в пути, который сид тихо обошёл. Это
подтвердилось на первом же прогоне: сид упёрся в CSRF-слой (`X-TM-Client` обязателен на unsafe-
запросе с кукой) — ровно в ту дверь, которую обязан правильно открывать и фронт.
32. **Стрим называет ПЛАТФОРМА, а не движок** (`TM_TRACE_ID` в окружение юнита, строка 102 движка).
Причина измерена, а не предположена: журнал — пер-КНИГА, и читатель, усыновляющий «первый hello
на моём смещении», усыновлял чужой поток целиком (PD-200). Идентичность потока, выбранная до
спавна и записанная в той же транзакции, что claim, убирает класс по построению.
## Что решено актом 5 P7 (20.08) — правила, которые НЕ выводятся из одной функции
33. **Каждая транзакция, ЗАКАНЧИВАЮЩАЯ прогон, обязана оставить книгу должной материализацию.**
Таких транзакций три — `FinishRun`, `PauseRun`, `FinishUnspawnedStop`, — и правило записано
фрагментом `owesAReadingSurface`, который несут все три, потому что забыть его в четвёртой
ничего не мешает. Цена забывания названа замером: книга уезжает `finished_at`-нутой и НЕ
должной, очередь дрейна ключуется этой колонкой и больше на закрытую книгу не смотрит — то есть
прогон, за который пользователь заплатил, не показывает свой текст никогда. Две из трёх и
забыли; пин — `pgstore.TestEveryEndingOfARunLeavesTheBookOwingASurface`, по
таблице из трёх концовок.
34. **`books.read_model_owed_at` — это СРОК, а не момент возникновения.** Тот, кто берётся платить
долг, отодвигает срок (аренда, `ClaimReadModelDebt`), очередь берёт только `<= now()`,
неоплаченный долг едет в конец (`DeferReadModelDebt`), погашение сверяет метку. Три следствия,
каждое было дефектом до того, как правило записали: интейк и свип не читают движком одну книгу
одновременно (а читали — свип каждые 15 с, материализация до 5 минут); книга, про которую движок
ответить не может, не держит голову очереди вечно; долг, поставленный ПОЗЖЕ, переживает
материализацию, начатую раньше. Каждый писатель этой колонки обязан идти через сверку метки —
безусловная запись возвращает любой из трёх.
35. **«Есть ли у пайплайна редактор» — свойство КНИГИ, от объявления движка; но фактов ДВА, и в этом
вся правка (пере-подписано по решению владельца 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` и не пере-снимаются)
для полосы МАЛО: её монотонность держит именно монотонный флаг.
36. **Утверждение про АТОМАРНОСТЬ пишется через `xmin`.** Пин, проверяющий конечное состояние, не
видит выноса записи из транзакции во второй оператор — состояние то же, меняется окно. `xmin`
(транзакция, последней писавшая строку) у книги и у кадра, который та же транзакция выпустила,
совпадает ровно тогда, когда обе записи сделала одна транзакция. Найдено тем, что два пина этого
же акта прошли под мутацией, которую сами называют.
## Что решено паком «прогон не врёт о себе» (17.09)
37. **Поимкой считается ТОЛЬКО падение названного пина на зелёном базовом прогоне — четыре условия,
и все четыре исполняются кодом `tools/mutate.py`, а не дисциплиной автора отчёта.** Ряд бэклога
**486**. Два условия инструмент нёс и раньше (пакеты списком аргументов; ненулевой выход без ни
одной строки `--- FAIL` — это НЕ ИЗМЕРЕНО, потому что у сломанной команды и у пойманной мутации
один код возврата), два добавлены: **зелёный базовый прогон набора до первой посадки** (иначе
пакет, красный по своей причине, отчитается каждой посадкой как о поимке — база снимается один
раз на пару «пакеты + `-run`») и **падение ИМЕННО названного пина** (поле `pin` обязательно;
правый вердикт по неправой причине — дыра, а не поимка, `D39.217` п.2в).
**И пятое, про прибор, а не про засчёт: прогон идёт с `-race`,** потому что батарея зоны идёт
с ней. Без гонки мутация, которую ловит только детектор гонок, читается как выжившая — то есть
инструмент объявил бы дыру там, где приёмка её не увидит. Цена названа: прогон медленнее.
**Инструмент запинен САМ,** и это не ритуал: у мутационного харнесса неверный вердикт выглядит
как находка о предмете, поэтому проверить его можно только там, где правильный ответ известен
заранее. Фикстурное дерево лежит В РЕПОЗИТОРИИ (`internal/gates/testdata/mutantfixture` — свой
`go.mod`, одно правило, два теста на разные его половины и один красный по построению), каталог
из шести записей покрывает все четыре исхода, а `gates.TestTheMutationHarnessCountsOnlyWhatItCanMeasure`
сверяет вердикт каждой и итоговый счёт, плюс требует, чтобы копия вернулась байт-идентичной. ⚠ Из-за
этого у скрипта появился ТРЕТИЙ аргумент — корень оригинала: фикстура восстанавливается из своего
источника, а не из корня зоны.
**Что это дало в первый же прогон, замером, а не надеждой:** условие зелёной базы поймало дефект
ЭТОГО пака — правка общей формы чтения прогона (`runRow`) сломала латераль `lastRun`, у которой
ЯВНЫЙ список колонок, и пять записей каталога вернулись «НЕ ИЗМЕРЕНА: базовый прогон не зелёный».
Пины, зелёные час назад, этого не показывали — они были прогнаны ДО той правки. ⇒ базовый прогон
в инструменте стоит не ради строгости счёта, а потому что он единственный, кто спрашивает «а
предмет вообще исправен?» перед тем, как что-то измерять.
## Инвентарь каналов движка (собран паком 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` (иначе не переживёт уборку):
```sh
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 он смотрел на шаблон: отказывал стендам, чья настоящая конфигурация была бесплатной, и
не мог увидеть единственный случай, в котором деньги тратятся.
⚠⚠ **ЧИСЛО ГЕЙТОВ УСТАРЕЛО: ПЕРЕМЕННЫХ ШЕСТЬ, а не четыре — пере-снято 17.09 паком «прогон не врёт
о себе» ПРИБОРОМ, а не чтением.** `make conditions` печатает шесть `TM_PLATFORM_TEST_*`: к четырём
ниже прибавились `TM_PLATFORM_TEST_PGDUMP` и `TM_PLATFORM_TEST_PGRESTORE` (их читает
`internal/backup`) и `TM_PLATFORM_TEST_BANK_READOUT` (`internal/ingest`, оракул купленного прогона
прошлого пака) — итого шесть переменных плюс два условия хоста (достижимый пользовательский
systemd и реальное применение `MemoryMax`). ⇒ **сессия, честно сверившаяся с числом «четыре», не
поднимет два гейта и не узнает об этом: скипы она спишет на известные ей условия.** Прибор здесь
первичен, а не этот абзац: он выводит перечень из самих тестовых исходников по ВЫЗОВУ хелпера.
На этой машине поднимаются пять из шести (`BANK_READOUT` нечем — артефакт прошлого пака), и
`pg_dump`/`pg_restore` берутся из того же домашнего дистрибутива Postgres, что и стенд
(`~/.local/pgsql/bin`).
**Гейты батареи — ШЕСТЬ переменных и два условия хоста; без них `make check` МОЛЧА скипует ~290
тестов** (вся читающая модель, миграции и шов). ⚠ **испр. 17.09: в этом заголовке стояло «их ЧЕТЫРЕ»,
и поправка сперва была дописана абзацем ВЫШЕ — то есть заголовок, по которому этот раздел грепают,
продолжал называть неверное число.** Четыре ниже — те, что были на 29.08, и перечень их остаётся
верным; две прибавившиеся (`TM_PLATFORM_TEST_PGDUMP`/`_PGRESTORE` у `internal/backup` и
`TM_PLATFORM_TEST_BANK_READOUT` у `internal/ingest`) названы абзацем выше. Первичен ПРИБОР
(`make conditions`), а не любое число в этой прозе. «Зелёная батарея» без них не значит ничего, поэтому `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). Число зависит
от дерева, и переносить его между ними нельзя.
⚠⚠ **ЭТОТ АБЗАЦ САМ СНЯТ ЗАМЕРОМ 17.09 — читай ТРЕТЬЮ ТОЧКУ ниже ПРЕЖДЕ, чем действовать по нему:**
на сегодняшнем `HEAD` «0 FAIL» воспроизводится, и красного, которое абзац обещает, больше нет. Ниже —
его прежняя редакция, она объясняет, откуда взялось предупреждение, и читается как ИСТОРИЯ.
⚠ **Число «0 FAIL» выше СНЯТО: его поставила приёмка смены №23 (оркестратор `textmachine-11`), и замер
зоны 11.09 его опровергает.** Оркестратор подтвердил это своим словом и просил записать без деликатности:
следующая сессия, прочитав «0 FAIL», получит красное и решит, что сломала сама.
⭐ **И ТРЕТЬЯ ТОЧКА, 17.09: на сегодняшнем `HEAD` «0 FAIL» ВОСПРОИЗВОДИТСЯ.** Замер пака «прогон не
врёт о себе» на чистой зоне (`git status --porcelain -- platform` → 0 путей, поэтому базовый прогон и
есть рубеж `HEAD`): `MAKE_EXIT=0` · пакетов в `go list` **20** · вердиктов в логе **20** · `comm -23`
пуст · **0 FAIL** · **6 скипов** (пять живых движковых проб без артефакта контраста — строка бэклога
251 — и один оракул `BANK_READOUT`). ⇒ предупреждение выше относится к дереву 11.09, а не к сегодняшнему:
красное, которое оно обещает, с тех пор вылечено паками. **Не ищи у себя поломку, которой нет** — но и
не читай этот абзац как обещание: полноту сверяй СПИСКОМ на своём дереве, а не числом отсюда.
⭐ **РАЗВИЛКИ ШАБЛОНОВ НЕТ: гейт поднимается НАСТОЯЩИМ `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`.
## Как поднять локально
```sh
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.**
```sh
# 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 в скрэтчпад:**
```sh
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` внутри.
- ⛔ **ДВА ИЗМЕРИТЕЛЯ В ОДНОМ СТЕНДЕ ПОРТЯТ ЧИСЛА МОЛЧА, И ПОРЧА ВЫГЛЯДИТ КАК СВОЙСТВО ПРЕДМЕТА**
(замерено 17.09 приёмкой пака «прогон не врёт о себе», формулировка оркестратора очереди №23).
Стенд Postgres на машине ОДИН, а измерителей бывает несколько: зонная сессия гоняет свои пины,
приёмка — свой круг, соседняя зона — свою кампанию. Улика: четыре изолированных круга
`internal/books` дали `ok` по 2123 с, а пятый — `FAIL 600.009s`, то есть дефолтный таймаут
`go test`, со стеком в `pgxpool/pool.go:333` (выдача соединения из пула); в этот момент по машине
шли ещё три чужих прогона, а клиентских бэкендов у стенда было одиннадцать. ⇒ «пакет флейкует»
и «рядом кто-то мерил» в выдаче НЕРАЗЛИЧИМЫ, и второе читается как свойство кода.
**Что делать:** прогоны, делящие стенд, обязаны называть друг друга (пинг перед тяжёлым кругом), а
рядом с числом — говорить, что на машине шло; и прежде чем объявлять флейк, спросить сам стенд, кто
в нём: `psql <DSN> -tAc "select count(*) from pg_stat_activity where backend_type='client backend'"`.
⚠ Это не про вежливость: кампания мутаций, у которой БАЗОВЫЙ прогон упал от чужой нагрузки, вернёт
«НЕ ИЗМЕРЕНО», а неизмеренная запись хуже выжившей — она закрывает вопрос, не задав его.