745 lines
115 KiB
Markdown
745 lines
115 KiB
Markdown
# Стек платформы — пины и обоснования
|
||
|
||
> Зонный документ `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)
|
||
|
||
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`=`### Политика коллизии почты` (раздел закрыт
|
||
вместе с эрой P0–P3, в живом журнале его нет).
|
||
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 (+15–25% → $0.0252–0.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`
|
||
(транзакция, последней писавшая строку) у книги и у кадра, который та же транзакция выпустила,
|
||
совпадает ровно тогда, когда обе записи сделала одна транзакция. Найдено тем, что два пина этого
|
||
же акта прошли под мутацией, которую сами называют.
|
||
|
||
## Инвентарь каналов движка (собран паком 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`.
|
||
|
||
**Гейты батареи — их ЧЕТЫРЕ, и без них `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). Число зависит
|
||
от дерева, и переносить его между ними нельзя.
|
||
|
||
⚠⚠ **ШАБЛОН КНИГИ ВЫШЕ НЕ ГОДИТСЯ ДЛЯ ВТОРОГО ГЕЙТА, и это даёт КРАСНУЮ батарею, а не скип.**
|
||
`backend/example/book.yaml` указывает на `configs/pipeline-c1.yaml`, то есть на ПЛАТНЫЙ DeepSeek, а
|
||
живой тест `internal/runner` `TestTheSnapshotGuardIsLoudWithoutTheFlagsAndPassesWithThem` (P10)
|
||
гоняет настоящий движок и падает на `tmctl: missing API keys (fill in backend/.env)` — при том что
|
||
его собственный комментарий обещает «free of provider keys and of paid calls». Тест прав: у стенда
|
||
есть $0-пара (`local-qwen3-8b`, провайдер `local` на `127.0.0.1:11434`, заглушку поднимает сам
|
||
тест), но в репо НЕТ пайплайна, который бы её называл. Рецепт:
|
||
|
||
```sh
|
||
# 1. пайплайн на $0-паре — РЯДОМ с prompts/, иначе `no prompt for pair "zh-ru"`:
|
||
# промпты резолвятся от каталога пайплайна, а не от каталога книги
|
||
sed -e 's/^\(\s*model:\s*\)deepseek-v4-flash.*/\1local-qwen3-8b/' \
|
||
-e 's/^\(\s*model:\s*\)deepseek-v4-pro.*/\1local-qwen3-8b/' \
|
||
<repo>/backend/configs/pipeline-c1.yaml > <repo-или-копия>/backend/configs/pipeline-zero.yaml
|
||
# 2. шаблон книги указывает на него
|
||
sed -e 's#^pipeline: .*#pipeline: <...>/backend/configs/pipeline-zero.yaml#' \
|
||
$W/book-template.yaml > $W/book-template-zero.yaml
|
||
export TM_PLATFORM_TEST_BOOK_TEMPLATE=$W/book-template-zero.yaml
|
||
```
|
||
|
||
⚠ Второй гейт гоняет НАСТОЯЩИЙ движок: платный пайплайн в шаблоне — это не только красная батарея,
|
||
это ещё и риск оплаченных вызовов. $0-пара обязательна не для удобства.
|
||
|
||
**Дев-демон и пробы.** Переменные демона: `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.
|
||
- **Postgres стенда переживает не всё:** сокет живёт в `/tmp`, и уборка `/tmp` (или smart shutdown)
|
||
роняет соединение — `pg_ctl … status` скажет «no server running». Поднимать заново по разделу выше.
|
||
- **Замер страницы библиотеки** (когда трогаете проекции книги): `go test ./internal/pgstore/
|
||
-bench LibraryPage -run xxx` — корпус 40 книг × 500 глав собирается сам, `vacuum analyze` внутри.
|