diff --git a/platform/BACKLOG.md b/platform/BACKLOG.md index 3fc46eae..79f37319 100644 --- a/platform/BACKLOG.md +++ b/platform/BACKLOG.md @@ -18,6 +18,8 @@ | П-7 | **Кредитный баланс и фри-тир** (решения владельца 05.08; ПРОДАЖИ НЕТ — бета на предоплаченных ключах владельца): append-only леджер в целых микро-долларах, грант фри-тира из админки (дефолт $5, настраиваемый), холд ДО спавна прогона + пер-книжный потолок движку (жёсткий стоп исполняет движок), расчёт на границе попытки. ⚠ Окон с обнулением НЕТ — баланс, а не подписка: черновик `usage_windows` в подписочной форме заменяется. Платёжный провайдер не проектируется до решения продавать. Разбор — `docs/PLATFORM_DIRECTION.md` §2 | P1+, после П-6 | владелец 04–05.08 | | П-8 | **Минимальная админ-поверхность**: начислить/списать кредиты аккаунту (одна запись леджера), посмотреть баланс и состояние прогонов. Защищённая ручка либо CLI — форму предлагает сессия. Без неё фри-тир неуправляем | вместе с П-7 | владелец 05.08 | | П-5 | **API лимитов/использования + оповещение стопа по потолку** (решение владельца 04.08, D39.100/ПТ-35, механизм «как Claude Code»): страница лимитов в настройках читает СТАТУС использования (не суммы — деньги на провод не идут, D39.84); стоп по потолку → статус `paused` + оповещение «перевод остановлен: лимиты исчерпаны»; сырьё у движка есть (`request_log`/`ledger`, потолки конфига), форму API предлагает P0 | Ф3, вместе с П-1 | D39.100, ПТ-35, контракт К-8 | -| П-9 | **Загрузка книги `POST /books` (multipart)** — единственная ручка контракта 0.2.0, которую пак раннера НЕ взял. Причина названа, а не умолчана: это не «ещё один хендлер», а хранилище (куда лёг файл, кто его чистит при отказе), разбор (статусы `uploading`/`parsing`/`rejected` существуют в схеме и ни одним писателем не заполняются), каталог проекта движка (кто пишет `book.yaml` — платформа его НЕ правит, D39.110) и пер-маршрутный потолок тела вместе с тестом, которого ждёт PD-72. Пока её нет, книги заводятся дев-инструментом `tmplatformctl book add`, и библиотека читает их так же, как читала бы загруженные: read-model один. ⚠ PD-72 закрывается ВМЕСТЕ с этой ручкой, не раньше | Ф3, следующий пак | сессия P4 (границей промта D39.119 §5а) | +| П-9 | **Загрузка книги `POST /books` (multipart)** — единственная ручка контракта 0.2.0, которую пак раннера НЕ взял. Причина названа, а не умолчана: это не «ещё один хендлер», а хранилище (куда лёг файл, кто его чистит при отказе), разбор (статусы `uploading`/`parsing`/`rejected` существуют в схеме и ни одним писателем не заполняются), каталог проекта движка (кто пишет `book.yaml` — платформа его НЕ правит, D39.110) и пер-маршрутный потолок тела вместе с тестом, которого ждёт PD-72. Пока её нет, книги заводятся дев-инструментом `tmplatformctl book add`, и библиотека читает их так же, как читала бы загруженные: read-model один. ⚠ PD-72 закрывается ВМЕСТЕ с этой ручкой, не раньше — **ИСПОЛНЕНО P5 (11.08), с одной НАЗВАННОЙ половиной на ратификации:** ручка построена потоково (`r.MultipartReader`, пер-маршрутный потолок тела, свой дедлайн чтения), хранилище — каталог книги под `TM_PLATFORM_BOOKS_DIR`, статусы `uploading → parsing → not_started \| rejected` получили писателей, разбор зовёт `tmctl manifest`, PD-72 закрыт своим тестом. НЕ построено и вынесено вопросом в журнал: кто пишет стартовый `book.yaml` при интейке (D39.110 §2b) — шов `books.ErrNotProvisioned` назван, без конфигурации книга отказывает разбором | Ф3, следующий пак | сессия P4 (границей промта D39.119 §5а), исполнено P5 | | П-10 | **Честная оценка «$/глава» от ДВИЖКА.** Сейчас ставка — константа платформы ($0.03, провенанс exp08 v2 через D30.4, `STACK_DECISIONS` §20), и это осознанная бета-мера: движковой поверхности оценки не существует, а выдумывать её запрещено. Ставка решает только ДЛИНУ шкалы (деньги защищены холдом и потолком движка), но на книге, которая заметно дороже или дешевле средней, шкала врёт пользователю о том, сколько глав он покупает. Нужна оценка от движка по конкретной книге — запрос уходит строкой ЕДИНОГО бэклога через оркестратора, не сюда | когда-нибудь (до первого платящего) | сессия P4 | -| П-11 | **Наблюдаемость раннера.** Метрик и трейсинга в зоне нет вовсе (грепнуто: ни prometheus, ни otel, ни expvar, ни pprof), а у раннера появились величины, которые без них не видны: глубина очереди, возраст незакрытых холдов, число прогонов в карантине, отставание тейлера, длительность свипа. Сегодня всё это читается только глазами по логам и SQL. Связано с PD-115 (у зоны нет внешнего эталона ни по одной оси, кроме безопасности) | Ф3 | сессия P4 | +| П-11 | **Наблюдаемость раннера.** Метрик и трейсинга в зоне нет вовсе (грепнуто: ни prometheus, ни otel, ни expvar, ни pprof), а у раннера появились величины, которые без них не видны: глубина очереди, возраст незакрытых холдов, число прогонов в карантине, отставание тейлера, длительность свипа. Сегодня всё это читается только глазами по логам и SQL. Связано с PD-115 (у зоны нет внешнего эталона ни по одной оси, кроме безопасности) — **ИСПОЛНЕНО P5 (11.08):** `prometheus/client_golang` v1.24.1 на отдельном слушателе `TM_PLATFORM_METRICS_ADDR`; глубина очереди · возраст самого старого открытого холда · карантины · отставание тейлера · книги в интейке · длительность свипа и счётчик недоведённых проходов · запросы и задержки по паттерну маршрута. Ось наблюдаемости получила внешний эталон (практики именования Prometheus + золотые сигналы, `ENGINEERING_STANDARDS` §2) — половина PD-115 | Ф3 | сессия P4, исполнено P5 | +| П-12 | **Квота интейка и ретеншен отклонённых книг.** `POST /books` даёт аутентифицированному аккаунту писать на диск оператора: один аплоад ограничен (64 МиБ), число аплоадов — ничем. Отклонённая по вине источника книга каталог теряет, отклонённая по вине деплоя — сохраняет намеренно, и не чистит их никто. Нужны лимит книг на аккаунт (сколько книг входит во фри-тир — продуктовая политика, не инженерная) и свип ретеншена. Строка регистра — PD-175 | до первого чужого пользователя | сессия P5 | +| П-13 | **Оценка размера книги в символах — у ДВИЖКА.** `character_count` контракта платформа считает потоково на приёме (байты, не являющиеся продолжением UTF-8), что точно для UTF-8 и приблизительно для GB18030/UTF-16, которые движок принимает и декодирует сам. Манифест несёт `source_bytes` и `encoding`, но не число символов. Запрос уходит строкой ЕДИНОГО бэклога через оркестратора; строка регистра — PD-177 | когда-нибудь | сессия P5 | diff --git a/platform/Makefile b/platform/Makefile index 405c2abe..b7aa747a 100644 --- a/platform/Makefile +++ b/platform/Makefile @@ -3,13 +3,24 @@ # under you on someone else's machine is not a gate. Shape mirrors backend/Makefile deliberately. GO ?= go -# go.mod's floor is 1.26.4 (the engine's), but the BUILD toolchain floor here is 1.26.5: it carries -# the crypto/tls and os security fixes, and this module is the one exposed to the network. -GO_MIN_VERSION := 1.26.5 +# go.mod's floor is 1.26.4 (the engine's), but the BUILD toolchain floor here is HIGHER, and for one +# reason: this module is the one exposed to the network, so it takes the standard library's security +# fixes as soon as they are released. +# +# ⚠ Raised 1.26.5 → 1.26.6 on 13.08 by `make vuln`, not by choice: the advisory database published +# five standard-library vulnerabilities against 1.26.5 — net/http, crypto/tls, net/url, encoding/xml, +# encoding/asn1 (GO-2026-6218/6090/6089/6088/5972) — all fixed in 1.26.6, and govulncheck traces two +# of them into paths this service calls. The same battery is green on 1.26.6 and the scan is clean. +# HOW the floor is actually enforced, because the first version of this raise enforced nothing: the +# check below COMPARES versions (its predecessor was a regex that matched the very 1.26.5 it was +# written to refuse), and go.mod carries `toolchain go1.26.6`, which every build reads whether or not +# it calls make — with GOTOOLCHAIN=auto such a host fetches this toolchain, with =local it stops. +# The comparison itself is pinned in internal/gates. +GO_MIN_VERSION := 1.26.6 GOLANGCI_LINT ?= golangci-lint GOLANGCI_VERSION := 2.12.2 -.PHONY: build vet fmt lint test check tools-check vuln fuzz +.PHONY: build vet fmt lint test check tools-check version-check vuln fuzz build: tools-check $(GO) build ./... @@ -21,9 +32,26 @@ vet: fmt: @test -z "$$(gofmt -l .)" || { echo "gofmt: not formatted:"; gofmt -l .; exit 1; } -tools-check: - @$(GO) version | grep -qE 'go1\.26\.([5-9]|[0-9]{2,})|go1\.(2[7-9]|[3-9][0-9])' || { \ - echo "Go $(GO_MIN_VERSION)+ required (security fixes in a network-facing module); got: $$($(GO) version)"; exit 1; } +# GO_VERSION is what the gate JUDGES, and it is a variable so the judgement can be tested with +# versions this host does not have: `make version-check GO_VERSION=go1.26.5` must fail. +GO_VERSION ?= $$($(GO) env GOVERSION) + +# version-check COMPARES rather than matches. The predecessor of this rule was a regex over +# `go version`, and it accepted the very release it was written to refuse — 1.26.5 matched +# `go1\.26\.([5-9]|…)` — while GO_MIN_VERSION lived only in the failure message. A comparison has no +# such gap: `sort -V` orders 1.26.10 above 1.26.9, which a regex over digits gets wrong too, and it +# is the same tool the release engineering of this repo already relies on. +version-check: + @have="$$(printf '%s' "$(GO_VERSION)" | sed 's/^go//')"; \ + case "$$have" in *[!0-9.]*) \ + echo "Go $(GO_MIN_VERSION)+ required, and a release: a prerelease or development toolchain does not carry the fixes its number promises; got: go$$have"; \ + exit 1;; esac; \ + lowest="$$(printf '%s\n%s\n' "$$have" "$(GO_MIN_VERSION)" | sort -V | head -1)"; \ + test "$$lowest" = "$(GO_MIN_VERSION)" || { \ + echo "Go $(GO_MIN_VERSION)+ required (standard-library security fixes in a network-facing module); got: go$$have"; \ + exit 1; } + +tools-check: version-check @$(GOLANGCI_LINT) --version 2>/dev/null | grep -q " $(GOLANGCI_VERSION) " || { \ echo "golangci-lint $(GOLANGCI_VERSION) required (findings are version-dependent)."; \ echo "install: https://github.com/golangci/golangci-lint/releases/tag/v$(GOLANGCI_VERSION)"; exit 1; } diff --git a/platform/README.md b/platform/README.md index 13933fc9..1abd16cc 100644 --- a/platform/README.md +++ b/platform/README.md @@ -14,6 +14,11 @@ Бинари: `cmd/tmplatformd` (сервис) и `cmd/tmplatformctl` (админ: гранты, КОРРЕКТИРОВКИ (`adjust`), баланс, журнал входов, отзыв сессий, дев-интейк `book add`, и `exit-marker` — его зовёт systemd на конце прогона). Деплой — `deploy/`. +Метрики — отдельным слушателем (`TM_PLATFORM_METRICS_ADDR`, дефолт `127.0.0.1:9464`), формат +Prometheus; на контрактную поверхность они не выходят и наружу не привязываются (`docs/STACK_DECISIONS.md` §24). +Эффективная конфигурация печатается на старте с источником каждой настройки; секреты и денежные +суммы — фактом наличия, без значения (PD-114). + ⚠ **Прогон — транзиентный systemd-юнит, а не ребёнок сервиса** (D39.106). Установка требует `loginctl enable-linger`, иначе не стартует ни один прогон; почему именно так и что было измерено — `docs/STACK_DECISIONS.md` §15–20. @@ -35,12 +40,21 @@ - аутентификация и аккаунты — **есть (P1)**: вход через OIDC даёт только СОБЫТИЕ входа, сессия своя; ключ личности `(provider, subject)`, почта не ключ. Оплаты нет и в бете не будет (владелец 05.08): аккаунты живут на кредитном балансе, фри-тир — запись `grant` в леджер; -- библиотека книг: чья книга, права доступа, хранение исходников и экспортов; +- библиотека книг: чья книга, права доступа, хранение исходников и экспортов — **приём есть (P5)**: + `POST /v0/books` принимает multipart потоково со своим потолком тела и своим дедлайном чтения, + кладёт исходник в каталог книги под `TM_PLATFORM_BOOKS_DIR` и ведёт книгу по статусам + `uploading → parsing → not_started | rejected`; разбор — $0-команда движка `tmctl manifest`. + ⚠ Кто кладёт стартовый `book.yaml` в каталог новой книги — открытый вопрос на ратификации + (D39.110 §2b): без него разбор честно отказывает, шов назван `books.ErrNotProvisioned`; - учёт денег **на пользователя** — **схема и операции есть (P1)**: append-only леджер в целых микро-долларах, резервации, кэш баланса с инвариантом `balance == SUM(ledger)`. Защита прогона — холд ДО спавна плюс книжный потолок движку (жёсткий стоп исполняет движок). ⚠ Потолок, который получает движок, — это НАКОПЛЕННЫЙ потолок КНИГИ (`committed + reserved` за всю её историю плюс купленный прирост), а не бюджет прогона: платформа переводит одно в другое сама (D39.122); +- остановка и продолжение перевода — **есть (P5)**: `POST /v0/runs/{id}/stop` пишет НАМЕРЕНИЕ стопа + в Postgres до сигнала (иначе стоп и авария — один и тот же выход движка, PD-152) и просит systemd; + `/resume` открывает новую попытку с ОСТАТКОМ бюджета прогона, переиспользуя механику + перезапуска реконсилятора; - очередь задач и запуск воркеров, статусы прогонов, ретраи — **есть (P4)**: River на том же Postgres; задание очереди выдаёт только РАЗРЕШЕНИЕ стартовать, а жизнь прогона ведёт реконсилятор, который читает мир (Postgres · журнал книги · маркер выхода) и не ждёт процесса; diff --git a/platform/cmd/tmplatformd/main.go b/platform/cmd/tmplatformd/main.go index b56a7d9e..0d50385d 100644 --- a/platform/cmd/tmplatformd/main.go +++ b/platform/cmd/tmplatformd/main.go @@ -7,6 +7,7 @@ import ( "context" "errors" "log/slog" + "net" "net/http" "os" "os/signal" @@ -17,6 +18,7 @@ import ( "textmachine/platform/internal/config" "textmachine/platform/internal/httpapi" "textmachine/platform/internal/login" + "textmachine/platform/internal/metrics" "textmachine/platform/internal/pgstore" "textmachine/platform/internal/reqid" ) @@ -40,10 +42,20 @@ func run(log *slog.Logger) error { if err != nil { return err } + // Before anything else is decided: an environment-only configuration leaves no artifact to read + // back, so a deployment whose EnvironmentFile= never loaded looks exactly like one whose values + // were chosen (register row PD-114, ratified 09.08). Secrets and money amounts are named, never + // printed. + cfg.LogEffective(log) ctx, stop := signal.NotifyContext(context.Background(), os.Interrupt, syscall.SIGTERM) defer stop() + // One registry for the process, whether or not anything scrapes it: the instruments are wired + // into the request path and the sweep either way, and a nil registry would make the telemetry a + // second code path that only runs in production. + telemetry := metrics.New() + var db *pgstore.Store if cfg.DSN == "" { // Deliberate: the process still serves liveness so a supervisor can start it before the @@ -72,7 +84,8 @@ func run(log *slog.Logger) error { Log: log, Deny: httpapi.ProblemHandler(http.StatusUnauthorized, "Session missing or invalid"), } - deps := httpapi.Deps{Log: log, Auth: authn, TrustedOrigins: cfg.TrustedOrigins, HSTS: !cfg.InsecureCookies} + deps := httpapi.Deps{Log: log, Auth: authn, TrustedOrigins: cfg.TrustedOrigins, + HSTS: !cfg.InsecureCookies, Observe: telemetry.Middleware()} if db != nil { deps.DB = db authn.Sessions = db @@ -107,7 +120,7 @@ func run(log *slog.Logger) error { } if db != nil { - stopRunner, err := startRunner(ctx, cfg, db, log, &deps) + stopRunner, err := startRunner(ctx, cfg, db, log, telemetry, &deps) if err != nil { return err } @@ -119,6 +132,9 @@ func run(log *slog.Logger) error { return err } + stopMetrics := serveMetrics(ctx, cfg.MetricsAddr, telemetry, log) + defer stopMetrics() + // A second signal must kill rather than wait: once the drain starts, the handler is // unregistered and the next SIGTERM goes back to being fatal. go func() { @@ -135,6 +151,57 @@ func run(log *slog.Logger) error { return srv.Run(ctx, ln) } +// serveMetrics exposes the exposition endpoint on a listener of its OWN, and returns the function +// that stops it. +// +// Separate from the API on purpose, and the reason is that this surface has no session behind it. +// The contract's surface answers 401 before 404 so that an anonymous caller cannot map it (STACK §4), +// and metrics carry operational shape — queue depth, run counts, the size of this deployment — +// which is the same class of fact. The two ways out of that are inventing a second authorization +// model for scrapers or binding the endpoint where only the host can reach it; the second is what +// every control plane does, and it is one line of deployment rather than one more thing to get +// right. Default 127.0.0.1; empty turns it off. +// +// A listener that cannot bind is a WARN and not a fatal: telemetry is how a service is watched, not +// how it serves, and taking the control plane down because a port is busy would make the watching +// more dangerous than the not-watching. +func serveMetrics(ctx context.Context, addr string, m *metrics.Metrics, log *slog.Logger) func() { + if addr == "" { + log.Warn("no TM_PLATFORM_METRICS_ADDR: this instance exposes no metrics") + return func() {} + } + mux := http.NewServeMux() + mux.Handle("GET /metrics", m.Handler()) + srv := &http.Server{ + Addr: addr, + Handler: mux, + // The same deadlines the API carries, and for once a WriteTimeout too: nothing here streams, + // and a scrape that hangs is a connection held for nothing. + ReadHeaderTimeout: 5 * time.Second, + ReadTimeout: 10 * time.Second, + WriteTimeout: 30 * time.Second, + IdleTimeout: 60 * time.Second, + BaseContext: func(net.Listener) context.Context { return context.WithoutCancel(ctx) }, + } + var lc net.ListenConfig + ln, err := lc.Listen(ctx, "tcp", addr) + if err != nil { + log.Warn("metrics endpoint could not be served", "addr", addr, "err", err) + return func() {} + } + log.Info("serving metrics", "addr", ln.Addr().String()) + go func() { + if err := srv.Serve(ln); err != nil && !errors.Is(err, http.ErrServerClosed) { + log.Error("metrics endpoint stopped", "err", err) + } + }() + return func() { + stop, cancel := context.WithTimeout(context.WithoutCancel(ctx), 5*time.Second) + defer cancel() + _ = srv.Shutdown(stop) + } +} + // sweepSessions deletes rows past their absolute expiry (PD-7). A failed sweep is logged and // retried on the next tick: it is housekeeping, and it must never take the service down. func sweepSessions(ctx context.Context, db *pgstore.Store, log *slog.Logger) { diff --git a/platform/cmd/tmplatformd/runner.go b/platform/cmd/tmplatformd/runner.go index 1f70f386..8c56974c 100644 --- a/platform/cmd/tmplatformd/runner.go +++ b/platform/cmd/tmplatformd/runner.go @@ -9,8 +9,11 @@ import ( "path/filepath" "time" + "textmachine/platform/internal/books" "textmachine/platform/internal/config" "textmachine/platform/internal/httpapi" + "textmachine/platform/internal/jobs" + "textmachine/platform/internal/metrics" "textmachine/platform/internal/money" "textmachine/platform/internal/pgstore" "textmachine/platform/internal/pricing" @@ -18,16 +21,17 @@ import ( "textmachine/platform/internal/runs" ) -// startRunner wires the run lifecycle and returns the function that stops it. +// startRunner wires the run lifecycle and the book intake, and returns the function that stops them. // // The reads are mounted whether or not runs can be spawned: a library and a book card are not run // machinery, and an instance without an engine binary is a perfectly useful read replica. What an // unconfigured runner refuses is starting a run, and it says so at boot rather than at the first // click. -func startRunner(ctx context.Context, cfg config.Config, db *pgstore.Store, log *slog.Logger, deps *httpapi.Deps) (func(), error) { +func startRunner(ctx context.Context, cfg config.Config, db *pgstore.Store, log *slog.Logger, + m *metrics.Metrics, deps *httpapi.Deps) (func(), error) { deps.Library = db if !cfg.RunsEnabled() { - log.Warn("no TM_PLATFORM_ENGINE_BIN: the library is served read-only and no run can be started") + log.Warn("no TM_PLATFORM_ENGINE_BIN: the library is served read-only, no run can be started and no book can be uploaded") return func() {}, nil } model, err := pricing.New(money.MicroUSD(cfg.Runner.PerChapterMicroUSD)) @@ -73,16 +77,31 @@ func startRunner(ctx context.Context, cfg config.Config, db *pgstore.Store, log }, Log: log, } - queue, err := runs.NewQueue(db.Pool(), svc, log, cfg.Runner.Workers) + intake, err := startIntake(cfg, db, rn, log, deps) + if err != nil { + return nil, err + } + // A typed nil is not nil once it is inside an interface, and the queue decides which workers to + // register by exactly that check. Spelled out so an instance without intake registers no parse + // worker rather than one that calls a nil service. + var parser jobs.Parser + if intake != nil { + parser = intake + } + queue, err := jobs.New(db.Pool(), svc, parser, log, cfg.Runner.Workers) if err != nil { return nil, err } svc.Queue = queue deps.Runs = svc + if intake != nil { + intake.Queue = queue + } if err := queue.Start(ctx); err != nil { return nil, fmt.Errorf("start queue: %w", err) } - go sweepRuns(ctx, svc, cfg.Runner.SweepEvery, log) + go sweep(ctx, sweeps{runs: svc, books: intake, db: db, metrics: m}, + cfg.Runner.SweepEvery, log) return func() { // The queue is drained; the RUNS are not touched. They are transient units, not children, // and outliving this process is the whole point of the seam (D39.106). @@ -94,19 +113,95 @@ func startRunner(ctx context.Context, cfg config.Config, db *pgstore.Store, log }, nil } -// sweepRuns runs the reconciler at boot and then on a ticker. +// startIntake wires the book upload, or explains at boot why this deployment takes none. +// +// A nil service leaves POST /books unmounted, which is the same shape every unbuilt contract route +// has: an instance that accepted a file it had nowhere to put would take the whole upload before it +// could say so. +func startIntake(cfg config.Config, db *pgstore.Store, engine books.Manifester, log *slog.Logger, + deps *httpapi.Deps) (*books.Service, error) { + if !cfg.IntakeEnabled() { + log.Warn("no TM_PLATFORM_BOOKS_DIR: this instance accepts no book uploads") + return nil, nil + } + // Created at boot, where a permission problem is an operator's to see, rather than on the first + // upload, where it would be one user's mysterious failure. + if err := os.MkdirAll(cfg.Intake.BooksDir, 0o750); err != nil { + return nil, fmt.Errorf("books directory %s: %w", cfg.Intake.BooksDir, err) + } + svc := &books.Service{ + Store: db, + Engine: engine, + Cfg: books.Config{ + BooksDir: cfg.Intake.BooksDir, + EngineBinary: cfg.Runner.EngineBinary, + }, + Log: log, + } + deps.Intake = svc + deps.Upload = httpapi.UploadLimits{ + MaxBytes: cfg.Intake.MaxUploadBytes, + Deadline: cfg.Intake.UploadDeadline, + } + return svc, nil +} + +// sweepBudget is what ONE pass of the RUN sweep may take. Per pass rather than per tick: see the +// note inside sweep(). +const sweepBudget = 2 * time.Minute + +// intakeSweepBudget is the same for the intake, and it is larger because the work inside it is: +// re-driving one book's parse is an engine call the queue itself bounds at jobs.JobTimeout, and a +// pass shorter than that turns "this book is large" into "this host cannot run the engine". +const intakeSweepBudget = jobs.JobTimeout + time.Minute + +// sweeps is everything one tick of the reconciler covers. +type sweeps struct { + runs *runs.Service + books *books.Service + db *pgstore.Store + metrics *metrics.Metrics +} + +// sweep runs the reconcilers at boot and then on a ticker. // // The boot pass is not a special case and is not skipped: after a reboot every transient unit is // gone, and this is what notices and restarts the runs they were carrying (unified backlog row 138). -func sweepRuns(ctx context.Context, svc *runs.Service, every time.Duration, log *slog.Logger) { - sweep := func() { - c, cancel := context.WithTimeout(ctx, 2*time.Minute) +// The intake's own pass rides the same ticker — it answers the same question about a different +// object, "whose walk stopped and nobody is coming back for it". +func sweep(ctx context.Context, s sweeps, every time.Duration, log *slog.Logger) { + // EACH pass gets its OWN budget, and that is not tidiness: sharing one deadline let the runs pass + // spend all of it — two runs whose engine hangs are two minutes — and hand an already-expired + // context to the intake sweep and to the telemetry, every tick, for as long as those runs sat + // there. The starvation the per-run budget bounds inside one pass would simply have moved up a + // level, and the metric that shows it would have stopped updating with it. + pass := func(name string, budget time.Duration, fn func(context.Context) error) { + c, cancel := context.WithTimeout(ctx, budget) defer cancel() - if err := svc.Sweep(c); err != nil && !errors.Is(err, context.Canceled) { - log.Error("run sweep failed", "err", err) + start := time.Now() + err := fn(c) + // A pass that ran out of its budget left work untouched, and the list is ordered the same way + // every time, so the tail starves. Counted rather than only logged: this is the number that + // says whether it is happening (register row PD-169). + s.metrics.ObserveSweep(name, time.Since(start), errors.Is(err, context.DeadlineExceeded)) + if err != nil && !errors.Is(err, context.Canceled) { + log.Error(name+" sweep failed", "err", err) } } - sweep() + one := func() { + pass("runs", sweepBudget, s.runs.Sweep) + if s.books != nil { + // The intake's pass is allowed MORE than the runs' pass, and the number is not a taste: one + // book's parse is the same engine call the queue gives `jobs.JobTimeout`, and a pass that + // cannot contain one kills it — which the intake then counts as a host that cannot run the + // engine and spends an attempt on. The pass has to be able to hold at least one book. + pass("intake", intakeSweepBudget, s.books.Sweep) + } + c, cancel := context.WithTimeout(ctx, sweepBudget) + defer cancel() + observe(c, s, log) + } + one() t := time.NewTicker(every) defer t.Stop() for { @@ -114,11 +209,35 @@ func sweepRuns(ctx context.Context, svc *runs.Service, every time.Duration, log case <-ctx.Done(): return case <-t.C: - sweep() + one() } } } +// observe publishes the state of the control plane. A failure to measure never fails the sweep: it +// is one WARN and the next tick tries again. +func observe(ctx context.Context, s sweeps, log *slog.Logger) { + o, err := s.db.Observe(ctx) + if err != nil { + log.Warn("the control plane's own state could not be read", "err", err) + return + } + s.metrics.ObserveRunner(metrics.Runner{ + QueueDepth: o.QueueDepth, + OldestHoldSeconds: o.OldestHoldSeconds, + QuarantinedAttempts: o.QuarantinedAttempts, + LiveRuns: o.LiveRuns, + BooksUploading: o.BooksUploading, + BooksParsing: o.BooksParsing, + }) + lag, err := s.runs.Lag(ctx) + if err != nil { + log.Warn("the tailer's lag could not be read", "err", err) + return + } + s.metrics.ObserveTailerLag(lag) +} + // markerArgv is the command systemd runs when a unit ends. It defaults to the admin CLI next to the // running daemon, because that is the one binary guaranteed to be the same build as this process. func markerArgv(configured string) ([]string, error) { diff --git a/platform/deploy/README.md b/platform/deploy/README.md index 40ab9604..148a7ac9 100644 --- a/platform/deploy/README.md +++ b/platform/deploy/README.md @@ -78,8 +78,31 @@ TM_PLATFORM_OIDC_CLIENT_ID=... TM_PLATFORM_OIDC_REDIRECT_URL=https://app.example.org/auth/callback TM_PLATFORM_AFTER_LOGIN=/library TM_PLATFORM_SIGNUP_GRANT_USD=5 +TM_PLATFORM_BOOKS_DIR=/srv/textmachine/books +TM_PLATFORM_METRICS_ADDR=127.0.0.1:9464 ``` +⚠ **`TM_PLATFORM_BOOKS_DIR` — каталог, который сервис ПИШЕТ и, на отклонённом интейке, УДАЛЯЕТ.** +Всё под ним создано загрузкой книг; книга, заведённая дев-инструментом с чужим `--workdir`, туда не +попадает и не удаляется никогда (гард `books.owns`). Каталог должен существовать и принадлежать +`tmplatform`, и он обязан быть в `ReadWritePaths=` юнита — при `ProtectSystem=strict` отсутствующий +путь валит сборку mount-namespace целиком (PD-91, та же грабля, что и с `/srv/textmachine`): + +```sh +install -d -m0750 -o tmplatform -g tmplatform /srv/textmachine/books +``` + +⚠ **`/metrics` не аутентифицирован** — его защищает только адрес привязки. Дефолт `127.0.0.1`, то +есть снаружи он недостижим; поднять его на внешний адрес значит открыть операционную форму деплоя +(глубина очереди, число прогонов, возраст холдов) всем, кто до него дотянется. Скрейпер живёт на том +же хосте либо ходит через тот же edge, что и API. Разбор — `docs/STACK_DECISIONS.md` §24, строка +риска — PD-179. + +⚠ **Загрузка книги идёт минуты, и это касается edge-прокси.** Маршрут `POST /v0/books` принимает до +`TM_PLATFORM_MAX_UPLOAD_BYTES` (64 МиБ) и держит соединение до `TM_PLATFORM_UPLOAD_DEADLINE` +(10 минут). Прокси перед сервисом обязан разрешать столько же: у него свои `client_max_body_size` и +свои таймауты чтения, и молчаливо режет их он, а не мы. + ⚠ **`TM_PLATFORM_STATE_DIR` меняется только когда живых прогонов нет.** Exit-маркер пишется по пути, вычисленному при СПАВНЕ, а читается по пути из текущей конфигурации: после смены каталога конец идущего прогона становится невидим, и реконсилятор перезапускает его как потерянный (PD-155). Путь diff --git a/platform/docs/DEFECT_REGISTER.md b/platform/docs/DEFECT_REGISTER.md index 9e476301..2c9a001f 100644 --- a/platform/docs/DEFECT_REGISTER.md +++ b/platform/docs/DEFECT_REGISTER.md @@ -7,6 +7,92 @@ > `hardening` — защита в глубину / латентное · `doc` — док лжёт о коде · `standards` — расхождение с > объявленной нормой зоны (введены приёмкой P2; словарь отставал от строк — испр. оркестратором №15). Статус: `open` · `fixed()` · `accepted-risk(<кем, когда>)`. +> **Форма файла (пинг оркестратора №16, 09.08).** Таблица разложена на секции — открытые по весу, принятый риск, закрытые по эрам паков, — а построчная форма `| PD-N | … |` сохранена: `docs/scripts/counts.py` ключуется формой строки, а не позицией, и секции его не ломают. ID остаётся стабильным навсегда, поэтому строка не переезжает между секциями иначе как при смене статуса, и внутри секции строки идут по номеру. + +## Открытые — major + +Несущий путь или контрактно видимое поведение. Каждая строка здесь — то, что решается до следующего пака, а не «когда-нибудь». + +| ID | Класс | Серьёзность | Где | Суть | Статус | Источник | +|---|---|---|---|---|---|---| +| PD-113 | bug | **major (контрактно видимый)** | `internal/runs/reconcile.go` `outcome`, движок `stagerun.go` | **Стоп по потолку сегодня НЕразличим от инфраструктурного отказа, и контракт при этом запрещает называть его `failed`.** Движок возвращает потолок ошибкой (`errReserveCeiling`, сверено в HEAD), а `exitCode` мапит всё нераспознанное в 1 — значит по коду выхода «деньги кончились» и «упало» это одно и то же число; события потолка не существует (строка 103). Платформа честно ставит `failed`, хотя `BookStatus` требует `paused` и «никогда не `failed`», потому что стоп резюмируем. Единственный путь, которым платформа СЕГОДНЯ узнаёт о потолке, — событие потока, которого нет; ветка под него построена и запинена (`TestACeilingHaltPausesTheRunWithItsReason`, `TestWhatTheUnitDidBecomesTheProductStatus`, случай «a ceiling halt survives any exit»). Закрывается приходом эмиттера (строка 103); ⚠ до тех пор экран покажет «ошибка» там, где верно «остановлено: лимиты» | open | сессия P4 (сверка контракта с кодом движка) | + +## Открытые — minor + +| ID | Класс | Серьёзность | Где | Суть | Статус | Источник | +|---|---|---|---|---|---|---| +| PD-60 | bug | minor | `internal/ingest/supervisor.go`, шов | ⚠ **ПЕРЕ-ДИСПОЗИЦИЯ (эррата №15, 07.08): постановка строки устарела.** Она рассуждает про 64 КиБ пайпа, а ратифицированный транспорт (D39.106 п.2) — тейл `events.jsonl` с курсором: у файла обратного давления в этом смысле нет вовсе, зато появляются свои свойства (fsync-политика, ротация, отставание тейлера, поведение при заполненном диске). Строка живёт, но переформулируется вместе со строкой 103 — не «блокировать движок или ронять события», а «что делает движок, когда журнал не пишется». **Обратное давление не спроектировано, и канал тут ни при чём.** Пайп держит 64 КиБ; если синк платформы встанет на Postgres, движок заблокируется в `write(2)` — на часы, без контекста и дедлайна, прервать нечем. Сегодня не проявляется только потому, что материализатора ещё нет: `Ingest` кормит `Sink` синхронно, и латентность БД станет латентностью движка. Нужна ограниченная очередь у читателя и ЯВНАЯ политика на её переполнение: блокировать движок (корректно, но прогресс прогона привязан к доступности БД) или ронять события с маркером `events_dropped` (быстро, но журнал начинает врать). Выбрать и записать — обе позиции законны, молчаливой третьей нет | open | приёмка P1 (абстрактный разбор двумя агентами, оба независимо) | +| PD-89 | hardening | minor | `cmd/tmplatformctl/main.go:143-150` | **Сминченный ключ идемпотентности не печатается при ошибке записи:** PD-75 закрыл путь ПОСЛЕ коммита, но неоднозначный обрыв НА коммите остался — оператор видит ошибку, повторяет без `--key`, `newKey()` чеканит новый ключ, второе начисление проходит. Фикс: печатать ключ вместе с ошибкой, чтобы повтор был с тем же `--key` | open | приёмка P2 (панель) | +| PD-101 | bug | minor | `internal/login/login.go:507` | `login_events.ip_prefix` берётся из `r.RemoteAddr`, а в задуманном деплое перед сервисом стоит edge-прокси ⇒ префикс всегда сеть прокси. Журнал входов заведён как ответ на «откуда примерно я входил» — в шипуемой форме он систематически отвечает неверно. `X-Forwarded-For`/`Forwarded` нигде не читаются и доверенного прокси в конфиге нет (это правильный дефолт: доверять заголовку без edge нельзя) — значит решение про edge и про этот столбец принимается вместе | open | приёмка P2 (панель) | +| PD-102 | doc | minor | `internal/httpapi/serve.go:36-38` | Доккоммент `DefaultTimeouts` утверждает, что «an upload extends its own deadline as it makes progress» — это НЕВЕРНО: `ReadTimeout` в `net/http` (Go 1.26.5, `server.go:990` `wholeReqDeadline = t0.Add(ReadTimeout)`) выставляется один раз и по мере прихода байтов не продлевается. Комментарий несущий: он объясняет, почему `Read` короткий, и на нём будущая ручка загрузки книги (23 МБ по контракту) построит неверное ожидание — ей понадобится собственный дедлайн через `ResponseController`, а не «прогресс продлевает» | open | приёмка P2 (панель, сверено с исходником Go) | +| PD-103 | hardening | minor | `internal/auth/middleware.go:43,66` | У обращений к БД на аутентифицированном пути (`Lookup`/`Touch`) нет собственного дедлайна — только голый `r.Context()`, а `WriteTimeout` у сервера отсутствует по проекту (SSE) и `TimeoutHandler` в цепочке нет. Зависший Postgres паркует хендлеры и ждущих в пуле, пока клиент сам не уйдёт. `readyz` свой таймаут получил (PD-14) — горячий путь нет | open | приёмка P2 (панель) | +| PD-104 | bug | **minor, расхождение док↔код** | `internal/login/login.go:285-288`, `internal/config/config.go:73` | **Фри-тир начисляется АВТОМАТИЧЕСКИ, а реестр обещает обратное.** Код: дефолт `SignupGrantMicroUSD: 5 * 1_000_000` (`config.go:73`) проведён в демона (`main.go:99`) и логин отдаёт его в стор на каждой новой подтверждённой паре `(provider, subject)` — аккаунт создаётся С $5. Строка PD-30 при закрытии утверждает «аккаунт создаётся с нулём, начисление руками из админки» — один из двух текстов лжёт, и это чинится независимо от продуктового решения. Ограничитель у автогранта один — лимитер входа; агрегатного потолка, счётчика и алерта нет (грепнуто). **Разбор нормы и предложение «на бете дефолт в НОЛЬ» — D39.110 п.3, здесь не дублируется; ждёт слова владельца** — **ПОЛОВИНА ЗАКРЫТА (док↔код):** ячейка PD-30 исправлена — «аккаунт с нулём» относилось только к НЕподтверждённой личности, подтверждённая получает автогрант (дефолт $5). ⚠ Продуктовая часть (ноль на бете, агрегатный потолок, счётчик) — НЕ закрыта: ждёт слова владельца, носитель прежний | open | приёмка P2 (панель; расхождение — оркестратор №15) | +| PD-115 | standards | minor | `docs/ENGINEERING_STANDARDS.md` §2 | **Внешняя версионированная базовая линия объявлена ровно для ОДНОЙ оси — безопасности** (ASVS 5.0 L2 + OWASP API Top-10 2023, с указанием глав). Отказоустойчивость, наблюдаемость и контракт-первичность описаны собственной прозой зоны без внешнего эталона, а конфигурация, релиз/откат, ёмкость и восстановление не описаны вовсе. Разница не теоретическая: PD-57 и PD-58 нашлись ИМЕННО сверкой кода с RFC 9700/9207 и NIST SP 800-63B — механизм работает там, где эталон есть, и не может сработать там, где его нет. Грепнуто на 08.08: метрик и трейсинга ноль (ни prometheus, ни otel, ни expvar, ни pprof), процедуры бэкапа/восстановления в `deploy/README.md` нет, SLO не заданы. Предложение зоны: §2 получает по эталону на ось (наблюдаемость, ops, конфигурация) — **направление РАТИФИЦИРОВАНО 09.08 (оркестратор №15 по делегации владельца); носитель работы — эта строка, исполнение — своими паками** ⚠ Уточнено паком P5: ось НАБЛЮДАЕМОСТИ эталон получила — практики именования Prometheus (базовые единицы, `_total` у счётчиков, единица не в лейбле) плюс «четыре золотых сигнала» на вопрос «что мерить», записано в `STACK_DECISIONS` §24 и пинится `metrics.TestTheRunnersStateIsExposedWithItsUnits`. Оси ops/конфигурация/восстановление эталона по-прежнему не имеют — строка открыта ими | open (наблюдаемость закрыта P5; ops и конфигурация — нет) | абстрактный вопрос владельца 08.08 + сессия P4 | +| PD-157 | bug | minor | `internal/runs/spawn.go`, `cmd/tmplatformctl/runs.go` `book add` | **ДНЕВНОЙ потолок книги `--ceiling-usd` не перекрывает, а платформа его не видит и не задаёт.** Движок требует хотя бы один из `book_usd`/`day_usd` (`backend/internal/config/book.go:250`, Р7), флаг переопределяет только книжный (D39.122 прямо: «День-потолок не перекрывается»), а `book.yaml` пишет ОПЕРАТОР — платформа его не правит (D39.110 §2b) и в `book add` только проверяет наличие файла. Значит книга с низким `day_usd` останавливает прогон на лимите, которого платформа не выбирала: движок выходит кодом 1 (тот же путь, что у PD-113), прогон приезжает `failed`, а деньги пользователя целы и он не понимает, почему. В `status --json` дневной фигуры нет вовсе (есть `book_ceiling_usd`/`ceiling_pct`), поэтому даже диагностировать это платформа сегодня не может. Заведено, не построено: закрывать — либо проверкой `day_usd` при заведении книги, либо словом контракта о том, кто владеет потолками `book.yaml` у книг под платформой | open | собственная сверка шва при F1 (чтение движка + D39.122) | +| PD-162 | bug | minor | `internal/runs/spawn.go` `journalSize`, `internal/runs/reconcile.go` | **Книга, чей каталог удалён или перемещён, принимает прогон и заклинивает его навсегда с открытым холдом.** `journalSize` мапит ENOENT в «ноль, ошибки нет» (законно для первого прогона), поэтому `Start` отдаёт 202 и берёт холд; дальше `bookMeter` вечно падает (спавн отказывает), либо расчёт вечно откладывается — ни один путь не приходит к терминальному состоянию: прогон вечно `translating`, деньги вечно в холде, пользователю видно только «идёт». Дизайн «холд лучше догадки» осознан (строка 136), но отсутствие И валидации каталога на старте, И эскалации после N неудач — дыра. Закрывать вместе с эскроу/`uncertain` либо проверкой каталога при допуске. ⚠ Ре-чек V2 расширил КЛАСС строки: обязательность `reserved_usd` даёт тот же клин без всякого удаления каталога — прогон, запиненный к СТАРОЙ сборке движка (строка 139), у которой поля ещё нет, вечно отказывает спавну с открытым холдом. Лечится тем же терминальным состоянием после N неудач; отказ сам по себе верен (без цифры потолок считать нечем) ⚠ Сужено паком P5 с одной стороны и НЕ закрыто с другой: у ИНТЕЙКА терминальное состояние после N неудач теперь есть (`books.parseAttempts` = 5 → `rejected` с причиной `parser_unavailable`), и прогон на книге, не прошедшей интейк, отвергается до денег (`runs.ErrBookNotReady`). Клин ЖИВОГО прогона на удалённом каталоге не тронут: там нужен тот же счётчик неудач на стороне реконсилятора либо эскроу-половина строки 136 ⚠ Дополнено кросс-семейным ревью: терминальность интейка теперь НЕ применяется к `not_configured` — книга без конфигурации ждёт в `parsing` вместо уничтожения, потому что это состояние ДЕПЛОЯ, а не книги. Цена названа: пока развилка `book.yaml` не ратифицирована, такие книги копятся, и видит их метрика `tm_platform_books_in_intake{status="parsing"}` | open | самопроверка дофикса (ревью вне карты) | +| PD-168 | bug | minor, деньги | `internal/runs/reconcile.go` `restart` | **Бюджет перезапуска пересчитывается по ТЕКУЩЕЙ ставке, а не по той, под которую брался холд:** `s.Pricing.Ceiling(l.CeilingChapters)` читает конфигурацию нынешнего деплоя. Смена `TM_PLATFORM_USD_PER_CHAPTER` между допуском и перезапуском ломает обе стороны — вверх: резервируется больше, чем пользователь видел на шкале (нарушение «явного согласия на оплату»); вниз: остаток уходит в минус и прогон ошибочно встаёт `paused/credit_exhausted`. Замерено верификатором: при удвоении ставки перезапуск зарезервировал $5.50 вместо $2.50. Исходная сумма восстановима без пересчёта — она лежит в холде первой попытки (`reservations.ceiling_micro_usd` / `run_attempts.ceiling_micro_usd`) | open | самопроверка дофикса (два верификатора, один исполнением) | +| PD-172 | standards | minor | `docs/architecture/14-api-contract/openapi.yaml` `createBook` | **Проводное правило `POST /books` в контракте не записано: часть `file` ОБЯЗАНА идти последней.** Потоковый читатель (`r.MultipartReader`) отдаёт части в порядке провода, а строка книги — то, что делает загрузку видимой во время приёма и находимой, если она оборвалась, — не может быть записана до языков, которые в ней обязательны. Тем же правилом специфицирована браузерная загрузка S3 («the file or content must be the last field in the form»). Платформа отвечает 400 на форму, приславшую поля после файла, и это поведение спека не описывает. Вопрос владельцу контракта (прецедент 503/PD-112), не правка спеки зоной | open | сессия P5 (сверка контракта с построенным маршрутом) | +| PD-174 | standards | minor | `internal/httpapi/v0.go` `contractRoutes` | **`POST /books` на инстансе без интейка отвечает 404, которого спека для этого маршрута не предусматривает.** Форма унаследована от уже принятого решения зоны — непостроенный/несмонтированный маршрут остаётся охраняемым 404 (`d.Runs == nil` так же), и это честнее 503 на КАЖДЫЙ аплоад, который инстанс всё равно не примет. Но контрактно это тот же класс, что PD-112: код ответа, которого в перечне операции нет. Вопрос владельцу контракта — назвать поведение для инстанса, не принимающего загрузки ⚠ Дополнено адверсариальным ревью P5: тот же класс есть и у РЕЗЮМА — `503` на деплое без маркер-команды или без шаблона потолка, тогда как 0.2.1 ратифицировала 503 только для СТАРТА прогона. Вопрос один на оба маршрута | open | сессия P5 (самопроверка против спеки) | +| PD-175 | hardening | minor | `internal/books/`, `internal/httpapi/v0.go` `createBook` | **Квоты на интейк нет: аутентифицированный аккаунт может писать на диск оператора неограниченно.** Пер-маршрутный потолок (PD-72) ограничивает ОДИН аплоад (64 МиБ по умолчанию), число аплоадов — ничто: ни лимита книг на аккаунт, ни ретеншена. Отклонённая по вине ИСТОЧНИКА книга свой файл теряет (каталог удаляется), а отклонённая по вине ДЕПЛОЯ — сохраняет намеренно (удалять чужую загрузку из-за своей поломки нельзя), и такие каталоги не чистит никто. Лечится квотой на аккаунт плюс свипом ретеншена по `rejected`; и то и другое — продуктовая политика (сколько книг входит в фри-тир), поэтому заведено, а не выбрано зоной ⚠ Третья половина того же вопроса — УДАЛЕНИЕ книги: `pgstore.DeleteBook` убирает строку и закрытые резервации и НЕ трогает каталог книги на диске, а ручки удаления в контракте нет вовсе (гейт PD-122). То есть сегодня утечки нет, потому что удалять нечем; день, когда ручка появится, — это и день, когда каталог обязан уходить вместе со строкой, и гард «только под `BooksDir`» для этого уже есть (`books.owns`). Диспозиция: закрывать ВМЕСТЕ с ручкой удаления, не раньше и не позже ⚠ Дополнено адверсариальным ревью P5 двумя фактами, которые делают строку острее, чем она написана: (1) пока развилка `book.yaml` не ратифицирована, ЛЮБАЯ загрузка приходит к `rejected/not_configured` — то есть путь «интейк пишет на диск и никто не убирает» сегодня ординарный, а не краевой; (2) у `rejected` нет ВЫХОДА вовсе: ни перепарса, ни удаления в контракте нет, строка остаётся в библиотеке навсегда. Обе половины закрываются тем же — квотой на аккаунт плюс свипом ретеншена, и обе продуктовые ⚠ Дополнено кросс-семейным ревью (Fable, 11.08): крэш-окно «строка закоммичена — каталог ещё не снесён» оставляет каталог-сироту, которого не найдёт никто (`StuckIntake` берёт только `uploading` и `parsing`). У отказа порядок перевёрнут в самоизлечивающийся — сначала каталог, потом строка, — а у брошенной загрузки перевернуть нельзя (каталог можно сносить только убедившись, что строки нет), так что окно там остаётся и закрывается тем же свипом ретеншена, что и вся строка | open | сессия P5 (самопроверка, ось «что этот маршрут создаёт») | + +## Открытые — info + +| ID | Класс | Серьёзность | Где | Суть | Статус | Источник | +|---|---|---|---|---|---|---| +| PD-6 | hardening | info | `internal/auth/csrf.go:51` | GET освобождён от CSRF (верно), но SSE-хендшейк — GET с амбиентной кукой: origin-чек хендшейка потока (STACK §5) не покрыт ничем. Закрыть при постройке SSE (P1) | open | приёмка P0 (security-линза) | +| PD-23 | hardening | info | `internal/pgstore/migrations/00001_identity.sql` | Журнал входов растёт без ретенции и чистится только каскадом при удалении аккаунта. Нужен свип по возрасту (год?) — вопрос политики, не кода | open | самопроверка P1 | +| PD-44 | hardening | info | `internal/pgstore/` | `sqlc` не взят, хотя направление §3 предписывает взять его ДО появления денежных таблиц. Весь денежный SQL — сырые строки pgx. Нужна ратификация: адаптировать денежный пакет под sqlc в следующей сессии либо поправить направление | open | ревью «вне карты» | +| PD-45 | hardening | info | `internal/ingest/procgroup_unix.go` | `syscall.Kill(-pid, SIGINT)` идёт мимо `os.Process`, поэтому в узком окне между проверкой живости и сигналом ребёнок может быть пожат, и сигнал уйдёт в переиспользованную группу. Окно ~микросекунды и родитель ещё не звал `Wait`; переписывать на pidfd-путь — отдельная работа | open | ревью «вне карты» | +| PD-61 | bug | info | шов, строка 103 | Два свойства эмиттера, которые надо задать ДО его постройки, иначе они станут миграцией. **(а) Сброс буфера на выходе:** `bufio.Writer` вокруг потока плюс `os.Exit`/`log.Fatal` пропускает `defer` и теряет последние события — ровно те, что сообщают об окончании прогона. **(б) Хвост при падении платформы:** содержимое непрочитанного пайпа умирает вместе с читателем. Если требование «платформа перезапустилась, прогон продолжается» когда-нибудь появится, ответ — НЕ сокет (он даёт переподключение без возобновления), а журнал файлом: движок дописывает NDJSON в `/events.ndjson`, платформа тейлит его с чекпойнтом смещения в Postgres. Это переживает и падение платформы, и даёт реплей бесплатно. Оба агента пришли к этому независимо; прецедент — Bazel Build Event Protocol (файл или gRPC, не пайп родителя) | open | приёмка P1 (абстрактный разбор) | +| PD-86 | hardening | info | `internal/pgstore/sessions.go:23,50` | **Два клауза-близнеца не запинены, и абсолютный потолок держится ТРАНЗИТИВНО:** снятие `absolute_expires_at > $2` из `Lookup` батарею переживает, потому что потолок навязывается через `least($3, absolute_expires_at)` в `Touch` (это запинено — `TestSessionLifecycle`). Снятие `revoked_at is null` из `Touch` тоже переживает (класс PD-4). Дефекта сегодня нет ни в одном; риск в том, что каждый слой по отдельности выглядит избыточным, а вместе они — единственное, что ограничивает жизнь сессии | open | приёмка P2 (посадки мутаций) | +| PD-87 | hardening | info | `internal/httpapi/server.go:82`, `internal/login/login.go:31` | Ещё два незапиненных: снятие `LimitBody` с поддерева `/auth` и `stateTTL` 10 мин → 240 ч проходят батарею. Первое — родня PD-72 (та про общий внешний слой, эта про конкретное поддерево), второе — окно жизни неиспользованного авторизационного запроса | open | приёмка P2 (посадки мутаций) | +| PD-88 | bug | info | `internal/auth/cookie.go:62-66` | **TTL меньше секунды выпускает куку БЕЗ атрибута `Max-Age`:** `int(ttl.Seconds())` даёт 0, а Go при `MaxAge == 0` атрибут опускает ⇒ кука становится браузер-сессионной. Достижимо в последнюю секунду абсолютного срока (скольжение выдаёт `min(idle, остаток абсолютного)` при гарде `ttl > 0`) — то есть ровно тот исход, который самопроверка P2 называла нежелательным: кука переживает сессию, и следующий запрос даёт 401 вместо чистого «вы вышли». Подтверждено исполнением (ttl 500 мс/999 мс) | open | приёмка P2 (панель ×2, подтверждено исполнением) | +| PD-90 | bug | info | `cmd/tmplatformctl/main.go:112,134` | `grant` и `adjust` делят пространство ключей `source="admin"`: `--key`, потраченный грантом, молча гасит корректировку с тем же ключом. CLI честно скажет «ключ уже потрачен», но оператор ждал другой операции | open | приёмка P2 (панель) | +| PD-92 | bug | info | `internal/ingest/supervisor.go:118-121` | **Дренаж стоит ДО `cmd.Wait()`, поэтому `WaitDelay` его не размораживает:** `io.Copy(io.Discard, stdout)` ждёт EOF, а EOF придёт только когда закроются ВСЕ копии пишущего конца пайпа; внук, унаследовавший stdout и игнорирующий SIGINT, вешает `Run` навсегда — backstop `WaitDelay` действует внутри `Wait`, до которого управление не доходит. ⚠ Сегодня недостижимо и это пере-проверено приёмкой: в `backend/` вне тестов нет ни одного `exec.Command` и нет cgo. ⚠ **Пере-диспозиция (эррата №15): вес понижен до info** — весь пайп-путь `supervisor.go` объявлен ДЕВ-РЕЖИМОМ (D39.106 п.3), в проде родителя у движка нет и `cmd.StdoutPipe()` не существует; чинить только если дев-путь остаётся | open | приёмка P2 (панель, граница зоны пере-проверена) | +| PD-93 | bug | info | `internal/ingest/supervisor.go:109` | Фикс PD-77 («наша остановка — не сломанный синк») сверяет только `context.Canceled` и пропускает `context.DeadlineExceeded`: как только у `runCtx` появится дедлайн (потолок времени прогона — очевидная будущая ручка), штатное истечение снова поднимет ERROR «stream could not be materialized» | open | приёмка P2 (панель) | +| PD-94 | bug | info | `internal/httpapi/middleware.go:61-70` | **`Recover` глотает `http.ErrAbortHandler`** — sentinel, которым хендлер намеренно обрывает соединение (`net/http` его не логирует и рвёт коннект). Замерено приёмкой: паника `ErrAbortHandler` превращается в 500 с problem-телом, то есть усечённый поток становится неотличим от полного. Латентно (сегодня им никто не паникует), но именно SSE-хендлер — типовой его пользователь | open | приёмка P2 (замер оркестратора №15) | +| PD-96 | hardening | info | `internal/httpapi/server.go:39-43`, `internal/auth/csrf.go:28` | **`TrustedOrigins` обещает отдельно развёрнутый фронт, но CORS-слоя нет вовсе.** Живая проба: preflight `OPTIONS` с `Origin: https://app.example.org` получает 401 от гарда (браузерный preflight креденшелов не носит и не должен), заголовков `Access-Control-*` нет ни на одном ответе. Сценарий «фронт на другом origin» браузером сегодня неисполним: либо CORS приезжает вместе с контрактными ручками (П-1), либо фронт живёт на том же origin, и тогда `TrustedOrigins` — мёртвая ручка | open | приёмка P2 (панель + живая проба) | +| PD-98 | doc | info | `internal/pgstore/store.go:75-79` | Случай «схема НОВЕЕ бинаря» в `Ready` беззвучен — признано ⚠-комментарием на месте, но ни одной строки лога: оператор, запустивший старый бинарь на новой схеме, сигнала не получит | open | приёмка P2 (панель) | +| PD-107 | hardening | info | `internal/pgstore/migrations/00007_credits.sql:85`, `00002_readmodel.sql:13` | **Удаление аккаунта обходит защиту PD-25:** составной FK `reservations → books(id, owner_id) on delete restrict` блокирует `DeleteBook`, но `users` каскадит в `reservations` НАПРЯМУЮ, поэтому `delete from users` уносит и ОТКРЫТУЮ резервацию. Замерено приёмкой: аккаунт с открытым холдом удаляется. Учётной дыры нет — леджер и кэш баланса каскадятся тем же удалением, — но прогон, идущий против этого холда, останется без того, кто его закроет. Кода удаления аккаунта в дереве нет вовсе (грепнуто) ⇒ строка = гейт перед появлением такой операции (и перед ASVS 7.4.2 в полной форме). ⚠ Заодно ОПРОВЕРГНУТА обратная версия этой находки от панели («удаление падает на композитном FK даже при закрытых резервациях») — мой прогон: удаляется и с закрытой резервацией, и без неё | open | приёмка P2 (замер оркестратора №15; версия панели опровергнута) | +| PD-122 | bug | info | `internal/pgstore/books.go` `ListBooks` | **`Library.revision` НЕ монотонна: она выведена как `max(books.revision)` по книгам аккаунта и падает, когда удаляется книга, державшая максимум.** Контракт требует монотонности внутри области и предписывает клиенту ОТБРАСЫВАТЬ чтение с меньшей ревизией — то есть после такого удаления библиотека замирает, пока чей-нибудь книжный счётчик не перерастёт старый максимум. Замерено ревью: 10 → 0 после удаления книги. ⚠ Сегодня недостижимо через API: ручки удаления книги нет вовсе (`DeleteBook` есть в сторе, маршрута нет). Правильное решение — собственный счётчик области у аккаунта, который двигается на изменение состава и статусов. ⚠ Уточнено ревью доков 09.08: КОЛОНКА уже есть — `users.library_revision` из `00001_identity.sql:13`, и её не читает и не пишет ни один Go-путь (грепнуто), так что нужна не миграция, а пути записи и чтения; правка нескольких мест, поэтому она НЕ сделана в этом паке, а названа. ⚠ Дополнено дофиксом 09.08: ревизия не двигается и на ДОБАВЛЕНИИ книги — `AddBook` пишет новой книге `revision = 0` и счётчика области не трогает, а спека описывает ревизию библиотеки как «membership and statuses». Класс тот же и решение то же: собственный счётчик области. Гейт: закрыть ДО появления удаления книги или любого второго писателя состава ⚠ Сужено паком P5: ДОБАВЛЕНИЕ книги ревизию области двигает — новая книга входит в библиотеку с `revision = max(revision по владельцу) + 1` (`pgstore.nextLibraryRevision`, обе точки входа: дев-интейк и загрузка), и каждая смена статуса интейка её тоже двигает; пин `books.TestABookThatJoinsTheLibraryMovesItsRevision`, посадка «входить с нулём» падает. Открытым остаётся исходный случай: УДАЛЕНИЕ книги может уронить максимум, и лечится это собственным счётчиком области. Ручки удаления по-прежнему нет ⚠ ПЕРЕ-ФОРМУЛИРОВАНА дофиксом приёмки (FP5-1): клейм «добавление закрыто» был ЛОЖЕН. Пол области поднимался при удалении, а вставка читала только максимум — книга, загруженная после отменённой загрузки, входила НА или НИЖЕ пола, и одна ревизия отвечала за три разных состояния библиотеки. Теперь вставка берёт `greatest(max, пол) + 1`, пин `books.TestABookThatJoinsAfterACancelledUploadStillMovesTheRevision` гоняет именно сценарий «отмена → повторная загрузка». ОТКРЫТЫМ остаётся: смена статуса книги, которая НЕ самая новая, ревизию области не двигает — ни одна из половин не растёт; лечится тем же счётчиком области, который двигают все писатели состава и статусов ⚠ Дополнено ре-чеком (FP5-11): правило «брать следующий номер БИБЛИОТЕКИ» применено и к пяти писателям жизненного цикла прогона (старт, реоткрытие, пауза, два закрытия) — без них staleness оставалась на статусах прогона: книга не-максимума аккаунта уходила `translating`, а библиотека не двигалась. Пин `pgstore.TestARunTransitionOnAnOlderBookStillMovesTheLibrary`. Прогресс-путь синка НАМЕРЕННО не тронут (главная шкала считается от книжной до инкремента; прогресс бывает только у книги с идущим прогоном, а её старт уже поднял номер) | open (добавление закрыто P5; удаление — нет) | адверсариальное ревью (исполнением) | +| PD-123 | doc | info | `internal/pgstore/migrations/00009_runner.sql:11` | `runs.ceiling_chapters` имеет `default 0`, а контракт объявляет `Run.ceiling_chapters` `minimum: 1`. Сегодня недостижимо: единственный путь вставки — `StartRun`, и он отказывает на неположительном значении. Строка заведена как гейт: строка прогона, записанная мимо `StartRun` (миграция данных, правка оператором), спроецируется на провод нулём, которого схема клиента не допускает | open | адверсариальное ревью (чтение схемы) | +| PD-137 | hardening | info | `deploy/tmplatformd.service` `[Unit]` | `BindPaths=/run/user/%U` требует существования каталога на старте юнита, а создаёт его logind вместе с пользовательским менеджером; в `[Unit]` упорядочения на него нет. На первом бутe это гонка, которую лечит `Restart=on-failure` (сервис поднимается со второй попытки). Строка не закрыта кодом намеренно: UID сервисного пользователя site-specific, поэтому `After=user@.service` добавляется установкой — инструкция вписана в шапку юнита | open | адверсариальное ревью (чтение) | +| PD-139 | hardening | info | `internal/runs/reconcile.go` ERROR-строки | Путь каталога книги попадает в ERROR-логи внутри обёрнутых ошибок (`*fs.PathError` тейлера, обёртки спавна). PD-99 закрывал ДРУГОЕ — argv на INFO, — и та половина проверена (`grep` по логу сквозной пробы = 0). Здесь диспозиция иная и её надо принять осознанно: оператор чинит именно этот путь, а ERROR — не INFO. Заведено, чтобы это было решением, а не побочным эффектом; если норма зоны распространяется и на ERROR, путь придётся заменить на id прогона ⚠ Дополнено P5: у класса появилась ВТОРАЯ площадка — интейк. Путь книги уходит в ERROR только в двух местах и намеренно: терминальный отказ разбора (`err` движка несёт путь исходника) и неудавшееся удаление каталога. На INFO/WARN идентификатора книги нет вовсе, и это запинено `books.TestNoBookIdentifierReachesAnInfoLine`. Диспозиция по-прежнему нужна одна на класс | open | адверсариальное ревью (чтение) | +| PD-141 | bug | info | `internal/pgstore/runs.go` `PauseRun` | `PauseRun` возвращает nil, когда строка прогона уже завершена, поэтому вызывающий рапортует паузу, которой не произошло. Идемпотентность здесь нужна (свип повторяется), но молчаливая — нет: различить «поставил паузу» и «было уже поздно» вызывающий не может | open | адверсариальное ревью (чтение) | +| PD-152 | bug | info | `internal/runs/reconcile.go` `outcome` | **`stopped` для остановки, которую мы попросили, на реальном движке недостижим:** `tmctl` ЛОВИТ SIGTERM и выходит кодом 1, поэтому ветка «не вышел сам + `$SERVICE_RESULT=success`» срабатывает только для процесса, умершего ОТ сигнала. Проба пака показала `stopped` на фейке, который именно так и умирал. Следствие: пользовательский стоп приедет как `failed`. ⚠ **Дофикс 09.08 расширил строку: то же самое ломает ШТАТНУЮ ПЕРЕЗАГРУЗКУ.** При ребуте пользовательский менеджер останавливает юниты корректно, `ExecStopPost` ОТРАБАТЫВАЕТ и маркер пишется — ревьюер снял живьём на этом хосте (транзиентный юнит, процесс ловит TERM и выходит 1): `RESULT=exit-code CODE=exited STATUS=1`. То есть на буте свип видит маркер и закрывает все живые прогоны как `failed` вместо перезапуска, а путь строки 138 покрывает только потерю питания (нет маркера) — и собственный тест `TestARunInterruptedByARebootComesBackWithTheBudgetItHasLeft` моделирует именно её. Закрывается в паке ручек стопа — попытка помечается «stop requested» до сигнала — и приходом различимого кода выхода graceful-stop у движка (строка бэклога движку, заводит оркестратор); платформенной догадки здесь быть не должно ⚠ **Половина закрыта паком P5 (D39.128-ветка), половина осталась и это названо:** ПОЛЬЗОВАТЕЛЬСКИЙ стоп больше не приезжает как `failed` — платформа пишет намерение стопа (`runs.stop_requested_at`, миграция 00014) ДО сигнала и классифицирует маркер по нему (`outcome`/`stoppedOnRequest`), с гардом гонки «стоп против самостоятельного финиша» по времени маркера и чистым исходом движка (0/2/3) поверх намерения; пины `TestARunTheUserStoppedIsNotReportedAsFailed`, `TestARunThatEndedBeforeTheStopKeepsItsOwnOutcome`, `TestACleanExitOutranksAStopThatArrivedTooLate`. **ШТАТНАЯ ПЕРЕЗАГРУЗКА хоста по-прежнему закрывает живые прогоны как `failed`** — намерения там нет ни у кого, и платформенной догадки здесь быть не должно: остаётся за различимым кодом выхода graceful-stop у движка (строка 165 единого бэклога) | open (пользовательский стоп закрыт P5; ребут — за движком) | приёмка P4 (N1) | +| PD-153 | bug | info | `internal/runs/reconcile.go` `spawnGrace` | **Грация спавна мерится от `run_attempts.started_at`, а не от момента заявки права на спавн:** окно между `RecordSpawn` и возвратом `systemd-run` не покрыто, и второй инстанс платформы, у которого грация уже истекла, может решить, что попытка потеряна, и перезапустить прогон, который вот-вот стартует. Дёшево закрывается временем заявки, записываемым в `RecordSpawn`, и грацией от него ⚠ Сужено дофиксом приёмки: у СТОП-пути окно закрыто с обеих сторон — заявка на спавн не выдаётся прогону с висящим интентом, а закрытие «стопа до спавна» спрашивает systemd про детерминированное имя юнита, если у попытки есть базовая линия (то есть заявка когда-то бралась). Само окно PD-153 — грация от `started_at`, а не от момента заявки — не тронуто | open | приёмка P4 (N2) | +| PD-154 | bug | info | `internal/runs/reconcile.go` `settle` | **`runs.settled_at` может остаться NULL между `Settle` и `MarkSettled`:** это два вызова, и падение между ними оставляет прогон с закрытой резервацией и без отметки. Потребителей у отметки сегодня нет (рабочий список расчёта построен на открытой резервации, а не на ней), деньги целы и второй расчёт отвергается самой резервацией. ⚠ Дофикс 09.08 добавил вторую половину той же строки: `settle` прерванной попытки ЖИВОГО прогона (путь `UnsettledRuns`) ставит `settled_at` прогону, который ещё идёт. Потребителей у колонки по-прежнему нет, дрейф только операторский. Заведено как известность, а не как долг: закрывается вместе с эскроу (строка 136) | open | приёмка P4 (N3) | +| PD-163 | bug | info | `internal/pgstore/books.go` `ListBooks`, `GetBook` | **Ревизия области читается ВТОРЫМ запросом после страницы,** поэтому под конкурентной материализацией она новее строк: клиент, соблюдающий контрактное «отбрасывай чтение с меньшей ревизией», навсегда потеряет кадры между двумя запросами. Потребителя (SSE) сегодня нет; закрывать до первого потока — читать страницу и ревизию одной транзакцией | open | самопроверка дофикса (ревью вне карты) | +| PD-164 | bug | info | `internal/runner/marker.go`, `internal/runs/reconcile.go` | **Маркер, который существует, но не разбирается, вечно валит реконсиляцию своего прогона:** аналога карантина у этого пути нет, и ошибка чтения маркера возвращается наверх на каждом свипе. Запись атомарна (temp+fsync+rename), так что нужен внешний фактор (правка оператором, битый том). Закрывать — так же, как журнал: неисправимая ошибка маркера должна вести к терминальному состоянию с причиной, а не к вечному повтору | open | самопроверка дофикса (ревью вне карты) | +| PD-165 | hardening | info | `cmd/tmplatformd/runner.go` `markerArgv`, `internal/runner/runner.go` `quoteArgv` | **Относительный `TM_PLATFORM_CTL_BIN` проходит `os.Stat`, но systemd требует АБСОЛЮТНЫЙ путь в `ExecStopPost`** — каждый старт падает, и виден только цикл заявка-откат. Тот же класс: `quoteArgv` не экранирует `$` (подстановка переменных systemd в Exec-строках), поэтому каталог состояния с таким символом молча ломает командную строку маркера. ⚠ `%` проверен и БЕЗОПАСЕН — спецификаторы в значениях `--property=` не раскрываются (замер §17); `$` в этой сессии исполнением не проверялся. Лечится тем же `filepath.IsAbs`, что и `StateDir` (PD-149), плюс отказ на подозрительных символах | open | самопроверка дофикса (ревью вне карты) | +| PD-166 | bug | info | `internal/ingest/tail.go`, `internal/pgstore/sink.go` `Begin` | **`chunker_version` из хендшейка теряется навсегда,** если краш пришёлся между двумя стейтментами `Begin` (привязка `engine_run_id` и запись версии — два отдельных автокоммита): при повторном чтении своего же `hello` тейлер видит, что поток уже привязан, и `Begin` больше не зовёт. Сегодня поле никем не читается (нужно для строки 100), поэтому info; закрывать — одной транзакцией в `Begin` | open | самопроверка дофикса (ревью вне карты) | +| PD-170 | hardening | info | `internal/pgstore/credits.go` `Settle` | `Settle` отбрасывает флаг `applied` строки расчёта, тогда как `releaseHold` на соседней строке из того же флага делает `ErrReleaseKeySpent` (PD-97). Недостижимо без правки леджера в обход кода — резервация должна быть открыта, чтобы дойти сюда, — но асимметрия в денежном пути стоит строки: либо симметричный отказ, либо явная причина, почему здесь он не нужен | open | самопроверка дофикса (ревью вне карты) | +| PD-173 | standards | info | `docs/architecture/14-api-contract/openapi.yaml` `Book` | **У отклонённой книги нет ПРИЧИНЫ на проводе.** `BookStatus.rejected` описан как «file could not be parsed», а почему именно — не поле контракта. Платформа хранит собственный закрытый словарь причин (`books.ReasonSourceUnreadable` · `ReasonNotConfigured` · `ReasonParserUnavailable`, колонка `books.reject_reason`, миграция 00013) для оператора и НЕ проецирует его: выдумывать поле не право зоны (прецедент PD-150 — `last_resync_at`). Следствие для пользователя: экран покажет «не удалось разобрать» без различения «файл не тот» и «наш движок был недоступен» — а это разные советы | open | сессия P5 | +| PD-176 | hardening | info | `internal/httpapi/middleware.go` `LimitBody`, `internal/metrics` | **Сигнал `requestTooLarge` от `http.MaxBytesReader` до сервера не доходит через наши обёртки.** `MaxBytesReader` пытается сказать *`net/http`* «запрос слишком большой» приведением `ResponseWriter` к НЕЭКСПОРТИРУЕМОМУ интерфейсу пакета `net/http`; наши обёртки (`statusRecorder`, `metrics.recorder`) его удовлетворить не могут в принципе — метод неэкспортируемый, а значит квалифицирован чужим пакетом. Следствие мягкое и замерено рассуждением по коду `net/http`: 413 отдаётся штатно, а соединение закрывается не немедленным сигналом, а обычным путём — после хендлера сервер дренирует остаток тела и, не сумев дочитать, закрывает соединение, причём дренаж ограничен `ReadTimeout` (PD-2). Лечение (если понадобится): ставить лимит без обёрток над `ResponseWriter` либо закрывать соединение самим через `Connection: close` | open | сессия P5 (чтение stdlib при постройке маршрута) | +| PD-177 | bug | info | `internal/books/books.go` `counter` | **`character_count` для не-UTF-8 источника — оценка, а не счёт.** Интейк считает символы потоково как байты, не являющиеся продолжением UTF-8 (`b&0xC0 != 0x80`), что для валидного UTF-8 ТОЧНО равно числу рун и не требует состояния между чанками. Движок принимает и GB18030, и UTF-16, декодируя их сам — на таком файле цифра неверна (для UTF-16 занижена примерно вдвое). Точный ответ есть на шаг позже: манифест несёт `source_bytes` и `encoding`, но не число символов. Лечится либо запросом числа символов у движка (строка единого бэклога), либо перерасчётом после разбора | open | сессия P5 (названо при постройке) | + +## Принятый риск + +Не дефекты, а решения: цена названа и принята, чтобы это не выяснилось молчанием. + +| ID | Класс | Серьёзность | Где | Суть | Статус | Источник | +|---|---|---|---|---|---|---| +| PD-22 | hardening | info | `deploy/` | Ограничителя одновременных соединений нет ни в процессе, ни описанного edge-прокси: `ReadTimeout` ограничивает УДЕРЖАНИЕ одного соединения 30 секундами, но не их число. Осознанно оставлено деплой-слою (`LimitNOFILE`, edge) — строка заведена, чтобы это было решением, а не забывчивостью | accepted-risk(платформа P1, 05.08) | самопроверка P1 | +| PD-42 | hardening | info | `internal/login/login.go` | Лимит `/auth/login` глобальный: один хост держит ведро пустым и выключает вход всем (замерено: 8 отказов из 10 у «легитимного» пользователя при фоне 5 rps). Пер-адресный лимит здесь неверен, пока нет доверенного edge-прокси — за прокси RemoteAddr один на всех. Место лимита — edge | accepted-risk(платформа P1, 05.08) | ревью безопасности (исполнением) | +| PD-71 | bug | info | `internal/pgstore/migrations/00005_identity_oauth.sql:72` | **Down-путь `00005` не исполним на данных, которые его же up-путь делает законными**, поэтому откат ниже версии 5 недоступен. Он восстанавливает `users_email_key` и `email NOT NULL`, а боевой код пишет `email = NULL` у неподтверждённой личности и кладёт один подтверждённый адрес на два аккаунта (следствие «почта не ключ»). **Перепроверено моим прогоном, не принято со слов ревью:** три реальных аккаунта (один с `email = NULL`, два с общим подтверждённым адресом) — `DownTo(5)` проходит, `DownTo(4)` падает с `could not create unique index "users_email_key" (SQLSTATE 23505)`; первым срабатывает индекс, до `NOT NULL` выполнение не доходит. Данные целы — down транзакционный, `Up()` вернул схему на версию 8 со всеми тремя аккаунтами, — но плана отката ниже 5 не существует. Править `00005` запрещает append-only, а чужой down-текст новая миграция не заменяет — **принято как ЦЕНА ПРАВИЛА:** записано в `STACK_DECISIONS §8` и в `deploy/README.md` разделом «Откат релиза: не ниже версии 5», чтобы оператор не узнал это в момент отката | accepted-risk(зона P2, 05.08) | ревью P2 (линза sql-money) | +| PD-179 | hardening | info | `cmd/tmplatformd/main.go` `serveMetrics`, `deploy/` | **Эндпоинт `/metrics` не аутентифицирован и защищён только адресом привязки.** Дефолт `127.0.0.1:9464`, то есть снаружи недостижим; экспозиция несёт операционную форму деплоя (глубина очереди, число прогонов, возраст холдов), но не пользовательские данные и не деньги. Второй модели авторизации ради скрейпера зона не заводит — это ровно тот довод, по которому админ-поверхность стала CLI (§10). Риск принят: оператор, поднявший `TM_PLATFORM_METRICS_ADDR` на внешний адрес, открывает её сам, и об этом сказано в `deploy/README.md` | accepted-risk(платформа P5, 11.08) | сессия P5 | + +## Закрытые ратификацией + +Строки, у которых лечением был не код, а решение владельца контракта или оркестратора. + +| ID | Класс | Серьёзность | Где | Суть | Статус | Источник | +|---|---|---|---|---|---|---| +| PD-59 | bug | info | `docs/platform-PROGRESS.md`, вопрос оркестратору №4 | **Канал не меняем — но решает это не тот довод, который обсуждали.** Вопрос вынесен абстрактно (без контекста репозитория) двум независимым агентам, с доступом в сеть и без. По каналу они РАЗОШЛИСЬ, зато независимо сошлись на трёх вещах, которых не было ни в записке зоны, ни в первых трёх редакциях приёмки. **(1) SIGPIPE зависит от НОМЕРА дескриптора** (`os/signal`: обрыв на fd 1/2 убивает процесс, на любом другом — возвращает `EPIPE`). **Замерено:** поток на fd 1 → ребёнок УБИТ `broken pipe`; на fd 3 → `write` вернул EPIPE и процесс доработал до конца. Для нас это деньги: сегодня падение платформы убивает движок на следующей же записи события, а после переезда движок станет сиротой и часами будет жечь оплаченные вызовы, пока холд висит в леджере и некому его закрыть. Свойство несущее и нигде не записано. **(2) Настоящая защита — не выбор канала, а перехват на уровне дескриптора** в `main` движка: `dup(1)` в приватный fd, затем `dup3(2,1,0)`. Он герметичен там, где предложенный приёмкой `os.Stdout = os.Stderr` дыряв: переживает `var out = os.Stdout` в зависимости, cgo и унаследованный fd 1 у внуков. **(3) Дискриминатор, при котором переезд был бы прав** — «ребёнок исполняет чужой код, наследующий stdio». **Проверено: у нас нет** — `grep` по `backend/` не находит ни одного `exec.Command` вне тестов и ни одного cgo. Плюс сверено: ловушка `bufio.Scanner`, которую оба назвали самым вероятным латентным багом (переполнение строки читается как чистый EOF), у нас закрыта — `Buffer` поднят до 1 МиБ и `sc.Err()` проверяется (`decoder.go:45,115`) | **закрыт ратификацией**, работа уходит строкой 103 | приёмка P1 (четвёртая итерация: два независимых агента + замер SIGPIPE) | +| PD-99 | hardening | info | `internal/ingest/supervisor.go:102` | INFO-лог «engine started» пишет `args` целиком. Сегодня безвредно, но воркер будет передавать движку идентификатор книги и потолок аргументами ⇒ book-id и денежная сумма попадут в INFO платформы (D39.84 + норма зоны «id книги в логи не текут»). Закрыть вместе с воркером: логировать имя команды, не argv — **закрыто:** INFO-строка старта несёт имя команды и НЕ несёт argv (`runner.Start`, а также дев-путь `ingest/supervisor.go`), поэтому ни id книги, ни потолок в долларах в поток INFO не попадают. Пин — `runner.TestTheStartLineNamesTheCommandAndNotItsArguments` (посадка «вернуть \"args\"» падает). Живая проба на боевом бинаре: `grep -c 'ceiling-usd\|3.000000\|bk_' daemon.log` = **0** за полный прогон | fixed(P4, дерево сессии) | приёмка P2 (панель) | + +## Закрытые — эра P1 (вход · кредиты · админ-CLI) + | ID | Класс | Серьёзность | Где | Суть | Статус | Источник | |---|---|---|---|---|---|---| | PD-1 | hardening | minor | `internal/pgstore/pg_test.go:89` | Свойство «в БД только SHA-256, не токен» НЕ запинено тестом: посадка «`Digest` возвращает плейнтекст» выживает — тест сверяет хранимое через тот же `auth.Digest` (self-consistent). Нужен тест с НЕЗАВИСИМО вычисленным хешом либо ассерт «плейнтекст в БД не находится» — **закрыто:** `internal/pgstore/pg_test.go` — `TestStoredCredentialIsAHashNotTheToken`: оракул SHA-256 считается в тесте, плюс поиск плейнтекста в отрендеренной строке. Посадка «`Digest` возвращает плейнтекст» ПАДАЕТ (проверено) | fixed(P1, дерево сессии) | приёмка P0 (посадка №1) | @@ -14,7 +100,6 @@ | PD-3 | bug | minor | `internal/httpapi/middleware.go:57` | `Recover` логирует сырой `r.URL.Path` на ERROR — id книг/прогонов утекают в лог, против собственной дисциплины AccessLog (route-pattern, не путь) — **закрыто:** `Recover` логирует `route`, не `r.URL.Path` | fixed(P1, дерево сессии) | приёмка P0 (security-линза) | | PD-4 | hardening | minor | `internal/pgstore/sessions.go:41` | WHERE у `Touch` слабее, чем у `Lookup` (нет `idle_expires_at > now`): прямой вызов воскресил бы idle-истёкшую сессию. Через `Require` недостижимо (Touch только после успешного Lookup) — одна строка защиты в глубину — **закрыто:** клауза `idle_expires_at > $2` добавлена; пин — `TestTouchCannotResurrectAnIdleExpiredSession` (посадка падает) | fixed(P1, дерево сессии) | приёмка P0 (security-линза) | | PD-5 | bug | minor | `internal/auth/middleware.go:36,45` | Ошибки стора невидимы: сбойный `Lookup` → 401 без единой строки лога (аутентификационный DB-outage выглядит как шторм 401), `Touch` глотается `_ =`. На проводе различать нельзя (оракул) — но лог обязан различать — **закрыто:** `Authenticator.Log`: сбой `Lookup` (кроме `ErrNoSession`) и сбой `Touch` уходят в ERROR с `request_id`; на проводе по-прежнему неразличимо | fixed(P1, дерево сессии) | приёмка P0 (security+blind линзы) | -| PD-6 | hardening | info | `internal/auth/csrf.go:51` | GET освобождён от CSRF (верно), но SSE-хендшейк — GET с амбиентной кукой: origin-чек хендшейка потока (STACK §5) не покрыт ничем. Закрыть при постройке SSE (P1) | open | приёмка P0 (security-линза) | | PD-7 | bug | info | `internal/pgstore/sessions.go:78` | `DeleteExpiredSessions` никем не вызывается — свип запланировать в P1 (периодическая джоба воркера) — **закрыто:** свип сессий раз в час в демоне (`sweepSessions`), плюс свип брошенных логинов раз в 15 минут | fixed(P1, дерево сессии) | приёмка P0 | | PD-8 | hardening | info | `internal/auth/session.go:18` | Писателя куки ещё нет; `__Host-` требует Secure ⇒ локальный dev по HTTP куку не поставит. Решить формой в P1 (dev-профиль), префикс не ослаблять в проде — **закрыто:** `auth.Cookies{Insecure}` — dev-профиль меняет ИМЯ вместе с атрибутами (`tm_session` без `__Host-`), `TM_PLATFORM_INSECURE_COOKIES=1`, демон предупреждает в лог | fixed(P1, дерево сессии) | приёмка P0 | | PD-9 | bug | minor | `cmd/tmplatformd/main.go:81` | `BaseContext` возвращает signal-контекст ⇒ SIGTERM мгновенно рубит контексты ВСЕХ in-flight запросов, и 15-секундный дренаж `Shutdown` мёртв для ctx-aware хендлеров. Fix: BaseContext без signal-ctx; сигнал ведёт только Shutdown — **закрыто:** `BaseContext` — собственный контекст, отменяется ПОСЛЕ `Shutdown`; пин — `TestShutdownDrainsInFlightRequests` (посадка «BaseContext = сигнальный ctx» падает) | fixed(P1, дерево сессии) | приёмка P0 (faults-линза) | @@ -23,15 +108,13 @@ | PD-12 | bug | info | `internal/ingest/supervisor.go:82-84` | Сбой Sink в начале прогона ⇒ платформа дренирует ВЕСЬ оставшийся поток в `io.Discard` часами: ceiling/bank_stop-события выбрасываются, никто не оповещён. Нужна политика «БД платформы упала посреди прогона» (ретраи синка / деградация с алармом) — дизайн-вопрос P1 — **закрыто:** сбой синка ОСТАНАВЛИВАЕТ прогон (`stop()` после `Ingest`), а не дренирует его в `io.Discard`; пин — `TestFailingSinkStopsTheRun`. Политика ретраев самого синка — при постройке материализатора | fixed(P1, дерево сессии) | приёмка P0 (faults-линза) | | PD-13 | bug | info | `internal/ingest/supervisor.go:64` | Краш платформы осиротляет процесс движка: ни process-group, ни pidfile, ни пути реаттача (поток невосстановим, повторный спавн упрётся в EXCLUSIVE-лок). Дизайн супервизии P1 — **закрыто:** группа процессов (`Setpgid` + сигнал группе) закрывает обычную остановку; краш платформы закрывает cgroup юнита — `deploy/tmplatformd.service` (проверен `systemd-analyze verify`, живого прогона под systemd не было) | fixed(P1, дерево сессии) | приёмка P0 (faults-линза) | | PD-14 | hardening | info | `internal/httpapi/server.go:79` | `readyz`: ping без собственного таймаута (WriteTimeout нет намеренно — SSE), эндпоинт неаутентифицирован и без rate-limit — задушить дешёво; таймаут на ping + прикрыть на ops-слое — **закрыто:** собственный таймаут 2 с на ping; rate-limit на ops-слое (edge), в зоне не строим | fixed(P1, дерево сессии) | приёмка P0 | -| PD-15 | bug | info | `internal/ingest/resync.go:32` | Деньги в ре-синке — float64, а `usage_windows` хранит micro-USD именно против дрейфа: дрейф входит шагом раньше (JSON-парс + суммирование дельт). Принять осознанно или считать в целых — **закрыто:** деньги на шве — `money.MicroUSD` через `big.Rat`, округление ВВЕРХ; пины — `TestSpendConvertsExactlyAndRoundsUp`, `TestParseUSDIsExactAndRoundsAwayFromZero` | fixed(P1, дерево сессии) | приёмка P0 (faults-линза) | +| PD-15 | bug | info | `internal/ingest/resync.go:32` | Деньги в ре-синке — float64, а `usage_windows` хранит micro-USD именно против дрейфа: дрейф входит шагом раньше (JSON-парс + суммирование дельт). Принять осознанно или считать в целых — **закрыто:** деньги на шве — `money.MicroUSD` через `big.Rat`, округление ВВЕРХ; пины — `TestSpendConvertsExactlyAndRoundsUp`, `TestParseUSDIsExactAndRoundsUp` | fixed(P1, дерево сессии) | приёмка P0 (faults-линза) | | PD-16 | bug | minor | `internal/httpapi/server.go:81` | `readyz` глотает ошибку ping вопреки собственному комменту «the reason stays in the log» — лога нет — **закрыто:** ошибка ping уходит в ERROR | fixed(P1, дерево сессии) | приёмка P0 (blind-линза) | | PD-17 | bug | minor | `Makefile:41-42` | Баннер «did NOT run (no database)» печатается и при ПРОГНАННЫХ БД-тестах (безусловный); батарея гоняет сьют дважды ради имён скипов (второй прогон без -race) — **закрыто:** один прогон сьюта под `-race`, баннер печатается только при наличии скипов | fixed(P1, дерево сессии) | приёмка P0 (blind-линза) | | PD-18 | bug | info | `internal/pgstore/migrations/00002_readmodel.sql:9,139` | Коммент шапки «engine vocabulary never crosses this seam» противоречит `notes.reason` (движковая причина хранится, не проецируется); коммент переписать честно — **закрыто:** шапка миграции переписана: исключение (`notes.reason`) названо там же | fixed(P1, дерево сессии) | приёмка P0 (canon-линза) | | PD-19 | bug | info | `internal/ingest/resync.go:44` | `WorstFlagReason` задокументирован «stored», а колонки в `chapters` нет — доккоммент или схема, одно из двух — **закрыто:** `WorstFlagReason` убран из аллоулиста — в контракте v0 у главы нет читателя для него | fixed(P1, дерево сессии) | приёмка P0 (canon-линза) | | PD-20 | bug | minor | `internal/ingest/supervisor.go:78` | Один сигнал остановки ТЕРЯЕТСЯ, если послан в первые миллисекунды жизни ребёнка: воспроизведено на стенде отдельным экспериментом (8 запусков, промах на нулевой задержке) и как флейк собственного теста PD-12 (1 падение из 3). Последствие серьёзнее самого промаха: единственный оставшийся механизм — SIGKILL по `WaitDelay`, а движок держит ЭКСКЛЮЗИВНЫЙ лок на файле проекта, и после kill лок остаётся — **закрыто:** `askToStop` повторяет SIGINT на 30/120/400 мс с проверкой «процесс ещё наш» через `os.Process`; пин — `TestFailingSinkStopsTheRun` (25 прогонов подряд зелёные, до фикса падал) | fixed(P1, дерево сессии) | самопроверка P1 (флейк собственного теста) | | PD-21 | vuln | minor | `internal/login/login.go:safeReturnTo` | Открытый редирект в `?return_to`: `/\evil.example` проходил проверку — `url.Parse` читает это как обычный путь, а браузер нормализует `\` в `/` и получает протокол-относительный URL, то есть чужой хост. Найдено ПОСАДКОЙ мутации: ослабление проверки тест пережило, значит тест был слабый — **закрыто:** аллоулист (первый символ `/`, второй не `/`, обратных слэшей нет, `Scheme`/`Host`/`Opaque` пусты), тест переписан на «каждый враждебный вход даёт ПУСТО»; посадка теперь падает. Дефект не покидал дерево сессии | fixed(P1, дерево сессии) | самопроверка P1 (посадка мутации) | -| PD-22 | hardening | info | `deploy/` | Ограничителя одновременных соединений нет ни в процессе, ни описанного edge-прокси: `ReadTimeout` ограничивает УДЕРЖАНИЕ одного соединения 30 секундами, но не их число. Осознанно оставлено деплой-слою (`LimitNOFILE`, edge) — строка заведена, чтобы это было решением, а не забывчивостью | accepted-risk(платформа P1, 05.08) | самопроверка P1 | -| PD-23 | hardening | info | `internal/pgstore/migrations/00001_identity.sql` | Журнал входов растёт без ретенции и чистится только каскадом при удалении аккаунта. Нужен свип по возрасту (год?) — вопрос политики, не кода | open | самопроверка P1 | | PD-24 | bug | **major** | `internal/pgstore/migrations/` | Переиспользование номера миграции: удалённый `00003_usage.sql` и новый `00003_credits.sql` заняли одну версию. goose применяет ТОЛЬКО по номеру (ни имени, ни хеша), поэтому база, доехавшая до версии 3, рапортует «migrations applied» и не получает ни одной новой таблицы, вход и кредиты падают в рантайме, а `DownTo` на ней ломается навсегда. Обоснование «до деплоя правим на месте» было допущением без механизма — **закрыто:** выпущенные 00001–00003 возвращены байт-в-байт, новое приехало номерами 00004–00007; гейт `migrations.sha256` + `TestReleasedMigrationsAreUnchanged`; апгрейд со старого релиза пинится `TestDatabaseAtAnOlderReleaseCatchesUp` | fixed(P1, дерево сессии) | ревью «вне карты» (исполнением) | | PD-25 | bug | **major** | `internal/pgstore/credits.go`, `00007_credits.sql` | Ключ идемпотентности леджера не содержал `user_id`: грант с ключом, потраченным на другом аккаунте, молча проглатывался, а CLI печатал «granted». Плюс каскад удаления книги уносил ОТКРЫТУЮ резервацию, оставляя строку `hold` в леджере (деньги списаны, вернуть нечем), после чего освободившийся `engine_run_id` давал холд БЕЗ списания, а его релиз печатал деньги — **закрыто:** ключ стал `(user_id, source, source_id)`, пустой ключ запрещён DDL, `book_id` перешёл на составной FK к `books(id, owner_id)` с `on delete restrict`, `appendLedger` возвращает «применилось», `Hold` падает при повторе. Пины: `TestBookWithAnOpenHoldCannotBeDeleted`, `TestSecondHoldOnOneAttemptIsRefused`, `TestGrantIsIdempotentBySource` | fixed(P1, дерево сессии) | ревью денежного пути (исполнением) | | PD-26 | bug | minor | `internal/pgstore/credits.go` | Инверсия порядка блокировок Hold↔Settle/Release: 41 взаимоблокировка на 300 раундов, замерено. `Settle`/`Release` брали строку резервации раньше баланса — **закрыто:** `lockBalance` первым во всех операциях | fixed(P1, дерево сессии) | ревью денежного пути (исполнением) | @@ -50,10 +133,11 @@ | PD-39 | bug | info | `internal/money/money.go` | Док обещал округление «от нуля», код округляет к `+∞`; отрицательные дроби не были покрыты тестом вовсе. Плюс `USD()` на `MinInt64` печатал мусор, а вход не имел ограничения длины (2 МБ → 6.1 с и сообщение об ошибке на 2 МБ) — **закрыто:** док приведён к коду, отрицательные кейсы запинены, потолок длины 64 символа, рендер без отрицания | fixed(P1, дерево сессии) | ревью денежного пути · ревью стиля | | PD-40 | bug | info | `internal/ingest/resync.go` | Отсутствующий/`null`/пустой `committed_usd` декодировался в `0` — неотличимо от «попытка не стоила ничего»; на пути расчёта это освободило бы холд и не списало ничего — **закрыто:** `Spend` стал указателем, пустая строка — ошибка | fixed(P1, дерево сессии) | ревью «вне карты» | | PD-41 | bug | info | `internal/login/login.go`, `internal/httpapi/` | Поверхность `/auth/*` отвечала stdlib-телами `text/plain` на 404/405 вопреки нормативу «ответы problem+json»; ошибки стора и сработавший лимитер не логировались; паника писалась без стека; успешный вход не оставлял следа, а недоступность провайдера классифицировалась как «токен отвергнут» — **закрыто:** метод проверяется в обёртке с problem+json, добавлены `login succeeded`, `sign-in rate limit engaged`, `provider_unreachable`, стек паники, `login_start_id` для склейки двух половин входа | fixed(P1, дерево сессии) | ревью логов (исполнением) | -| PD-42 | hardening | info | `internal/login/login.go` | Лимит `/auth/login` глобальный: один хост держит ведро пустым и выключает вход всем (замерено: 8 отказов из 10 у «легитимного» пользователя при фоне 5 rps). Пер-адресный лимит здесь неверен, пока нет доверенного edge-прокси — за прокси RemoteAddr один на всех. Место лимита — edge | accepted-risk(платформа P1, 05.08) | ревью безопасности (исполнением) | -| PD-43 | bug | info | `internal/pgstore/credits.go` | Денежный контур не имеет ни одного вызывающего вне тестов: `Hold`/`Settle`/`Release` не зовутся, `Sink` не реализован, `TypeSpend` не декодируется. При первом реальном прогоне баланс не изменится. Ожидаемо — воркера нет (П-1/П-3), но заведено строкой, чтобы это было решением, а не сюрпризом — **закрыто:** денежный контур получил вызывающих: `runs.Service.Start` берёт холд в ОДНОЙ транзакции с созданием прогона и записью очереди (`pgstore.StartRun`), реконсилятор закрывает его `Settle` по фигуре движка. Пины: `pgstore.TestAdmittingARunWritesTheRunTheAttemptAndTheHoldTogether` (посадка «убрать holdTx из транзакции» падает), `TestARunThatCannotBePaidForLeavesNothingBehind`, `runs.TestARunThatEndsIsFinishedAndSettledAtWhatTheEngineSpent`. Живая проба: грант $10 → прогон с потолком 100 глав → холд $3.00 → движок отчитался $0.42 → баланс $9.58 | fixed(P4, дерево сессии) | ревью «вне карты» | -| PD-44 | hardening | info | `internal/pgstore/` | `sqlc` не взят, хотя направление §3 предписывает взять его ДО появления денежных таблиц. Весь денежный SQL — сырые строки pgx. Нужна ратификация: адаптировать денежный пакет под sqlc в следующей сессии либо поправить направление | open | ревью «вне карты» | -| PD-45 | hardening | info | `internal/ingest/procgroup_unix.go` | `syscall.Kill(-pid, SIGINT)` идёт мимо `os.Process`, поэтому в узком окне между проверкой живости и сигналом ребёнок может быть пожат, и сигнал уйдёт в переиспользованную группу. Окно ~микросекунды и родитель ещё не звал `Wait`; переписывать на pidfd-путь — отдельная работа | open | ревью «вне карты» | + +## Закрытые — эра P2 (фикс-пак приёмки P1) + +| ID | Класс | Серьёзность | Где | Суть | Статус | Источник | +|---|---|---|---|---|---|---| | PD-46 | hardening | minor | `internal/httpapi/serve.go:33-40` | **Запинена ПРОВОДКА `ReadTimeout`, но не ЗНАЧЕНИЕ, с которым едет демон.** Тесты строят свой `Timeouts` (`fastTimeouts`), поэтому посадка «`DefaultTimeouts().Read = 0`» проходит ВСЮ батарею зелёной — а `main.go:121` берёт именно `DefaultTimeouts()`. Посадка «убрать `ReadTimeout` из `NewServer`» ловится (проверено), то есть дыра ровно в дефолтах. Это форма, в которой PD-2 пережил P0: свойство проверено не на том объекте, который едет в прод. Фикс — тест на сами значения `DefaultTimeouts` — **закрыто:** `httpapi.TestTheServerTheDaemonRunsHasEveryDeadlineSet` утверждает не литералы, а сам `*http.Server`, который строит `NewServer(…, DefaultTimeouts())`: каждый дедлайн >0, `WriteTimeout` ОБЯЗАН быть нулём (иначе резал бы SSE), `ReadHeaderTimeout <= ReadTimeout`, grace >0. Закрывает обе половины — значение и проводку. Пять посадок поймано поимённо: `DefaultTimeouts().Read=0`, снятие `ReadTimeout` из `NewServer`, снятие `IdleTimeout`, добавление `WriteTimeout` «для симметрии», снятие `Unwrap` | fixed(P2, дерево сессии) | приёмка P1 (посадка M23/M43) | | PD-47 | bug | minor | `internal/login/login_test.go:266` | **Закрытие PD-37 заявлено неверно:** «в тесты добавлены процент-кодированные входы» — их там нет (список: `//evil.example/`, `https://…`, `http:/…`, `/\evil.example`, `/\/evil.example`, `/\tevil`, `evil.example`, ``). Посадка «судить только сырую форму, без второго декода» батарею ПЕРЕЖИВАЕТ. Побочно: посадка «убрать обратный слэш из `ContainsAny`» тоже переживает — на тестовых входах её дублирует проверка `s[1]`. Эксплуатируемого редиректа нет; не запинена именно та защита, ради которой заведён PD-37 — **закрыто:** в таблицу добавлены процент-кодированные входы (`/%5c/`, `/%5C/`, `/%09`, `/%00`, `/%0d%0a`) — их ловит ТОЛЬКО второй декод — и `/%2f/evil.example`, который ловит ТОЛЬКО проверка `s[1]` на декодированной форме; плюс `FuzzSafeReturnTo`, который пинит СВОЙСТВО независимым оракулом (`url.URL.ResolveReference` после браузерной нормализации `\`→`/`), 3,1 млн исполнений без контрпримера. Посадки «судить только сырую форму», «убрать класс символов», «убрать protocol-relative» падают каждая. ⚠ Побочно установлено: условия `u.Scheme/u.Host/u.Opaque` НЕДОСТИЖИМЫ как отказ (при `raw[0]=='/'` схемы и Opaque не бывает, Host требует `//`), пин на них невозможен — оставлены бэкстопом, это названо в коде | fixed(P2, дерево сессии) | приёмка P1 (посадки M15/M16) | | PD-48 | hardening | minor | `internal/login/login.go:268-271` | **Правило PD-30 «грант только подтверждённой личности» не запинено ничем:** удаление `if !claims.EmailVerified { grant = 0 }` проходит все тесты `internal/login`. `pgstore.TestUnverifiedAddressStaysOffTheAccount` пинит другое свойство (адрес не поднимается на аккаунт), денежное — никто. По правилу шапки этого файла PD-30 закрытым не считается — **закрыто:** `login.TestSignupGrantGoesOnlyToAVerifiedIdentity` гоняет обе ветки через настоящий поток и сверяет САМ грант, дошедший до стора (`memStore` теперь его запоминает — раньше отбрасывал, потому правило и было незапинено). Посадка «убрать условие `EmailVerified`» падает | fixed(P2, дерево сессии) | приёмка P1 (посадка M14) | @@ -67,9 +151,6 @@ | PD-56 | bug | info | `internal/pgstore/credits.go:35-63` | `Grant`/`Adjust` на несуществующий аккаунт отдают оператору сырую ошибку Postgres с именем констрейнта (`credit_ledger_user_id_fkey`), тогда как `Balance` на том же входе отдаёт `ErrNoAccount`. Живая проба CLI. Косметика админ-поверхности, но опечатка в id читается как поломка БД — **закрыто:** `appendLedger` мапит нарушение `credit_ledger_user_id_fkey` в `ErrNoAccount`; `pgstore.TestMoneyOperationsAgreeOnAMissingAccount` требует одного ответа от `Grant`/`Adjust`/`Balance`/`ReadAccount`. Посадка «убрать сверку констрейнта» падает | fixed(P2, дерево сессии) | приёмка P1 (живая проба CLI) | | PD-57 | hardening | minor | `internal/login/login.go:239-242` | **Защита от IdP mix-up не та, что требует действующая норма.** RFC 9700 §2.1 (OAuth Security BCP, янв. 2025) — клиент SHOULD применять параметр `iss` из авторизационного ответа (RFC 9207) либо иной контрмер НА ОСНОВЕ `iss`; MAY — различные redirect URI на провайдера. Реализована собственная сверка `st.Provider` с `h.cfg.Provider`, а внутри одного хендлера это сравнение конфигурации с самой собой: `start` пишет туда то же значение. `iss` авторизационного ответа не читается вообще (`iss` ID-токена библиотека проверяет — это другой шаг и другой момент). Сегодня не эксплуатируемо: провайдер один, код всегда редимится у него же. Заведено потому, что регистр объявляет PD-32 закрытием «класса IdP mix-up», а против нормы это неверно, и при втором провайдере выбор (`iss` или раздельные redirect URI) должен быть ОСОЗНАННЫМ, а не побочным эффектом конфигурации — **закрыто реализацией нормы, а не обещанием.** Первоисточники сверены: RFC 9700 §4.4.2 («When an OAuth client can only interact with one authorization server, a mix-up defense is not required» — то есть СЕГОДНЯ несоответствия нет, требование включается со вторым сервером), §4.4.2.2 объявляет раздельные redirect URI фолбэком («SHOULD therefore only be used if other options are not available»); альтернатива «`iss` из ID-токена» нам не подходит — при чистом code flow токен приходит уже ПОСЛЕ отдачи кода. Выбран `iss` авторизационного ответа: **Google его шлёт** (`authorization_response_iss_parameter_supported: true`, сверено живьём). Сделано: `auth_states.issuer` (миграция 00008), сверка до обмена кода, отказ на СОРВАННОМ параметре у поддерживающего провайдера (RFC 9207 §2.4). Пин — `login.TestAuthorizationResponseIssuerIsChecked` (4 случая); посадки «убрать вызов», «убрать ветку несовпадения», «убрать ветку сорванного параметра», «потерять issuer в сторе» падают | fixed(P2, дерево сессии) | приёмка P1 (сверка с RFC 9700 §2.1 / RFC 9207) | | PD-58 | hardening | minor | `internal/config/config.go:60-61` | **Несоответствие собственной объявленной базовой линии.** `ENGINEERING_STANDARDS §2` берёт ASVS 5.0 L2, а L2 требует ДОКУМЕНТИРОВАТЬ сроки: 7.1.1 (срок бездействия и абсолютный предел + обоснование отклонений от NIST SP 800-63B), 7.1.2 (политика одновременных сессий), 7.1.3/7.6.1 (согласование срока НАШЕЙ сессии со сроком федеративной — у нас наша живёт своей жизнью, RP-initiated/back-channel logout нет). Сроки 14 суток бездействия и 90 суток абсолютных существуют только литералами в коде, обоснования нет ни в одном доке (проверено grep). Механические требования V7 при этом ВЫПОЛНЕНЫ и проверены: 7.2.3 энтропия (256 бит при требуемых 128), 7.2.4 ротация токена на аутентификации, 7.4.1 отзыв, 7.4.2 снос сессий при удалении аккаунта. ⚠ 7.4.5 (системный отзыв админом) покрыт только пер-пользовательским `revoke`; 7.5.2 (пользователь видит свои сессии) — работа П-1 — **закрыто, и значение выровнено вместо сочинения оправдания.** Тексты сверены дословно: ASVS 5.0 7.1.1/7.1.2/7.1.3, 7.6.1/7.6.2 и NIST SP 800-63B-4 §2.1.3 («overall timeout … SHOULD be no more than 30 days at AAL1; an inactivity timeout MAY be applied but is not required»). Абсолютный срок 90 суток был отклонением от SHOULD без причины, выдерживающей проверку, — снижен до **30 суток**; бездействие 14 суток остаётся и строже нормы. Документ — `STACK_DECISIONS §13`: уровень AAL1, оба срока, политика одновременных сессий (лимита нет — контракт предусматривает куку и Bearer одновременно; вместо лимита отзыв, «выйти везде» и журнал), рассогласование с федеративной сессией названо прямо (RP-initiated/back-channel logout нет), 7.6.2 выполнено. Пин — `config.TestSessionClocksStayWithinTheDeclaredBaseline` | fixed(P2, дерево сессии) | приёмка P1 (сверка с ASVS 5.0 V7) | -| PD-59 | bug | info | `docs/platform-PROGRESS.md`, вопрос оркестратору №4 | **Канал не меняем — но решает это не тот довод, который обсуждали.** Вопрос вынесен абстрактно (без контекста репозитория) двум независимым агентам, с доступом в сеть и без. По каналу они РАЗОШЛИСЬ, зато независимо сошлись на трёх вещах, которых не было ни в записке зоны, ни в первых трёх редакциях приёмки. **(1) SIGPIPE зависит от НОМЕРА дескриптора** (`os/signal`: обрыв на fd 1/2 убивает процесс, на любом другом — возвращает `EPIPE`). **Замерено:** поток на fd 1 → ребёнок УБИТ `broken pipe`; на fd 3 → `write` вернул EPIPE и процесс доработал до конца. Для нас это деньги: сегодня падение платформы убивает движок на следующей же записи события, а после переезда движок станет сиротой и часами будет жечь оплаченные вызовы, пока холд висит в леджере и некому его закрыть. Свойство несущее и нигде не записано. **(2) Настоящая защита — не выбор канала, а перехват на уровне дескриптора** в `main` движка: `dup(1)` в приватный fd, затем `dup3(2,1,0)`. Он герметичен там, где предложенный приёмкой `os.Stdout = os.Stderr` дыряв: переживает `var out = os.Stdout` в зависимости, cgo и унаследованный fd 1 у внуков. **(3) Дискриминатор, при котором переезд был бы прав** — «ребёнок исполняет чужой код, наследующий stdio». **Проверено: у нас нет** — `grep` по `backend/` не находит ни одного `exec.Command` вне тестов и ни одного cgo. Плюс сверено: ловушка `bufio.Scanner`, которую оба назвали самым вероятным латентным багом (переполнение строки читается как чистый EOF), у нас закрыта — `Buffer` поднят до 1 МиБ и `sc.Err()` проверяется (`decoder.go:45,115`) | **закрыт ратификацией**, работа уходит строкой 103 | приёмка P1 (четвёртая итерация: два независимых агента + замер SIGPIPE) | -| PD-60 | bug | minor | `internal/ingest/supervisor.go`, шов | ⚠ **ПЕРЕ-ДИСПОЗИЦИЯ (эррата №15, 07.08): постановка строки устарела.** Она рассуждает про 64 КиБ пайпа, а ратифицированный транспорт (D39.106 п.2) — тейл `events.jsonl` с курсором: у файла обратного давления в этом смысле нет вовсе, зато появляются свои свойства (fsync-политика, ротация, отставание тейлера, поведение при заполненном диске). Строка живёт, но переформулируется вместе со строкой 103 — не «блокировать движок или ронять события», а «что делает движок, когда журнал не пишется». **Обратное давление не спроектировано, и канал тут ни при чём.** Пайп держит 64 КиБ; если синк платформы встанет на Postgres, движок заблокируется в `write(2)` — на часы, без контекста и дедлайна, прервать нечем. Сегодня не проявляется только потому, что материализатора ещё нет: `Ingest` кормит `Sink` синхронно, и латентность БД станет латентностью движка. Нужна ограниченная очередь у читателя и ЯВНАЯ политика на её переполнение: блокировать движок (корректно, но прогресс прогона привязан к доступности БД) или ронять события с маркером `events_dropped` (быстро, но журнал начинает врать). Выбрать и записать — обе позиции законны, молчаливой третьей нет | open | приёмка P1 (абстрактный разбор двумя агентами, оба независимо) | -| PD-61 | bug | info | шов, строка 103 | Два свойства эмиттера, которые надо задать ДО его постройки, иначе они станут миграцией. **(а) Сброс буфера на выходе:** `bufio.Writer` вокруг потока плюс `os.Exit`/`log.Fatal` пропускает `defer` и теряет последние события — ровно те, что сообщают об окончании прогона. **(б) Хвост при падении платформы:** содержимое непрочитанного пайпа умирает вместе с читателем. Если требование «платформа перезапустилась, прогон продолжается» когда-нибудь появится, ответ — НЕ сокет (он даёт переподключение без возобновления), а журнал файлом: движок дописывает NDJSON в `/events.ndjson`, платформа тейлит его с чекпойнтом смещения в Postgres. Это переживает и падение платформы, и даёт реплей бесплатно. Оба агента пришли к этому независимо; прецедент — Bazel Build Event Protocol (файл или gRPC, не пайп родителя) | open | приёмка P1 (абстрактный разбор) | | PD-62 | bug | minor | `internal/pgstore/identity.go:26-35`, миграция `00005` | **`login.State.StartID` не персистился: колонки под него не было.** Поле заведено в P1 и логируется колбэком как `login_start_id` — то есть ОБЕ строки лога, которые должны сшивать две половины входа, в проде пустые. Батарея этого не видела, потому что тесты `internal/login` ходят в in-memory стор, который хранит структуру целиком: свойство проверялось не на том объекте, который едет (тот же класс, что PD-46). Воспроизведено против живой БД раунд-трипом `PutLoginState`→`TakeLoginState`: положили `REQ-ABC123`, получили `""` — **закрыто:** колонки `start_id` и `issuer` добавлены миграцией `00008`, `Put`/`Take` их несут; пин — `pgstore.TestLoginStateIsSingleUseAndExpires` сравнивает структуру ЦЕЛИКОМ (`reflect.DeepEqual`), поэтому следующее поле без колонки упадёт здесь же. Посадки «потерять start_id» и «потерять issuer» падают | fixed(P2, дерево сессии) | сессия P2 (найдено при правке PD-57) | | PD-63 | vuln | minor | `internal/httpapi/serve.go:118-127` (удалён) | **`ClearReadDeadline` воспроизводил PD-2 — тем самым вызовом, который был заведён как его исправление.** Доккоммент объявлял его ОБЯЗАТЕЛЬНЫМ для стримингового хендлера. На полу-кормленном запросе (тело анонсировано, не дослано) дренаж внутри записи заголовка ответа — единственная граница соединения, и ограничен он `ReadTimeout`; снятие дедлайна ДО записи заголовка эту границу убирает. Замерено: хендлер остаётся внутри `WriteHeader` и через 4 с после ухода клиента, соединение держится. Вызов после флаша бесполезен — контекст уже отменён дренажем. Приёмка (PD-51) заключила «код безвреден», проверив только корректные запросы; случая, где функция помогает, нет вовсе — **закрыто:** функция УДАЛЕНА, §12 переписан, пин — `httpapi.TestHalfFedStreamingRequestIsCutLoose` (контекст стримингового хендлера отменяется в пределах `Read`) | fixed(P2, дерево сессии) | сессия P2 (собственный замер при верификации PD-51) | | PD-64 | bug | minor | `deploy/tmplatformd.service` | **Дефолтный `OOMPolicy=stop` уронил бы платформу из-за одного прожорливого прогона.** Следствие того же факта, что PD-55 (дети-`tmctl` живут в cgroup юнита), но в той строке не названо: по man 5 systemd.service дефолт берётся из `DefaultOOMPolicy=` (системный — `stop`), а `stop` означает «the unit's processes are terminated cleanly by the service manager» — то есть OOM-килл ОДНОГО `tmctl` останавливает контрол-плейн и все остальные прогоны, после чего юнит уходит в `oom-kill` failed и его подхватывает `Restart=on-failure` — **закрыто:** `OOMPolicy=continue` проставлен явно с обоснованием; платформа переживает килл ребёнка и штатно закрывает его резервацию. ⚠ Под systemd не проверялось (нет sudo) — вывод из доки; `systemd-analyze verify` (systemd 259) — exit 0 | fixed(P2, дерево сессии) | сессия P2 (ревью деплой-юнита при PD-55) | @@ -79,65 +160,51 @@ | PD-68 | bug | minor | `internal/httpapi/server.go:111` | **`/readyz` рапортовал «готов» на базе БЕЗ схемы.** Готовность доказывалась одним `Ping`, который успешен на любом достижимом Postgres, включая пустой. `Migrate` выключен по умолчанию, а deploy-инструкция делает миграцию отдельным шагом — значит «процесс поднят, схема не накачена» это НОРМАЛЬНАЯ середина выката, и инстанс в этом окне отвечал 200 `ready`, проваливая каждый запрос, который затем обслуживал — **закрыто:** `Store.Ready` сверяет `goose_db_version` с максимальным номером миграции, вшитой в бинарь; схема ВПЕРЕДИ бинаря готовности не отменяет (иначе выкат ронял бы старый инстанс). Пин — `pgstore.TestReadinessRefusesADatabaseWithoutTheSchema`, посадка «свести готовность к `Ping`» падает. ⚠ Первая редакция фикса печатала причину В ТЕЛО ответа и ради этого тащила `pgstore` в `httpapi` — и слой, и утечка состояния выката на НЕаутентифицированной ручке; снято при самопроверке, причина уходит в ERROR-лог | fixed(P2, дерево сессии) | ревью P2 (линза вне карты) | | PD-69 | bug | minor | `internal/pgstore/store.go:42` | **Явный `pool_max_conns` из DSN молча отбрасывался.** Проверка «MaxConns равен дефолту pgxpool» не отличает «оператор не выбирал» от «оператор выбрал ровно это число»: pgxpool кладёт свой дефолт в то же поле, что `ParseConfig` заполняет из `pool_max_conns`. Оператор, порезавший реплику под бюджет `max_connections`, получал наш 16 вместо своих 8 — и наоборот на 32-ядерной машине. С `pool_min_conns` хуже: дефолт pgx равен 0, поэтому явный 0 не мог пережить проверку НИКОГДА — **закрыто:** вопрос «упоминает ли DSN этот ключ» задан ПАРСЕРУ pgx, а не значению: `pgxpool` достаёт `pool_*` из `RuntimeParams` и удаляет их, поэтому второй `pgx.ParseConfig` их ещё видит — обе формы DSN, кавычки и service-файлы бесплатно. ⚠ Первая редакция фикса разбирала DSN РУКАМИ (33 строки собственного парсера) — велосипед, найден при самопроверке и снят; наши дефолты применяются только там, где оператор промолчал. Пин — `pgstore.TestExplicitPoolSizesInTheDSNSurvive`, включая случай, сломавший ПРЕДЫДУЩУЮ эвристику: пароль, содержащий имя ключа | fixed(P2, дерево сессии) | ревью P2 (линза вне карты) | | PD-70 | bug | **major** | `internal/auth/middleware.go:57`, `internal/auth/cookie.go:62` | **Скользящее окно бездействия для БРАУЗЕРА не работало: Max-Age куки пишется один раз, на входе, и больше никем.** Серверная строка скользила (`Touch`), кука — нет, а `SetSession` зовётся ровно из одного места — колбэка входа. Следствие: для куки — единственной презентации, которую код вообще умеет выдавать, — срок жизни сессии был ФИКСИРОВАННЫЕ 14 суток от входа независимо от активности; человек, заходящий каждый день, выкидывался на 14-е сутки при живой серверной сессии, а абсолютный срок не мог наступить никогда. Это же делало ложным §13 — документ соответствия ASVS 7.1.1, который зона только что написала — **закрыто:** при скольжении окна кука переиздаётся с тем же токеном (ротация — акт границы входа, не скольжения); пин — `auth.TestSlidingTheIdleWindowRefreshesTheBrowsersCookie` (четыре случая: кука во второй половине окна, свежая кука, Bearer, упор в абсолютный срок). ⚠ Первая редакция фикса выдавала `Max-Age` равный idle-TTL безусловно — то есть кука могла пережить абсолютный срок и превратить каждый следующий запрос в 401 вместо чистого «вы вышли»; поймано самопроверкой, срок теперь берётся как `min(idle, остаток абсолютного)` | fixed(P2, дерево сессии) | ревью P2 (линза doc-vs-code) | -| PD-71 | bug | info | `internal/pgstore/migrations/00005_identity_oauth.sql:72` | **Down-путь `00005` не исполним на данных, которые его же up-путь делает законными**, поэтому откат ниже версии 5 недоступен. Он восстанавливает `users_email_key` и `email NOT NULL`, а боевой код пишет `email = NULL` у неподтверждённой личности и кладёт один подтверждённый адрес на два аккаунта (следствие «почта не ключ»). **Перепроверено моим прогоном, не принято со слов ревью:** три реальных аккаунта (один с `email = NULL`, два с общим подтверждённым адресом) — `DownTo(5)` проходит, `DownTo(4)` падает с `could not create unique index "users_email_key" (SQLSTATE 23505)`; первым срабатывает индекс, до `NOT NULL` выполнение не доходит. Данные целы — down транзакционный, `Up()` вернул схему на версию 8 со всеми тремя аккаунтами, — но плана отката ниже 5 не существует. Править `00005` запрещает append-only, а чужой down-текст новая миграция не заменяет — **принято как ЦЕНА ПРАВИЛА:** записано в `STACK_DECISIONS §8` и в `deploy/README.md` разделом «Откат релиза: не ниже версии 5», чтобы оператор не узнал это в момент отката | accepted-risk(зона P2, 05.08) | ревью P2 (линза sql-money) | -| PD-72 | hardening | info | `internal/httpapi/server.go:88` | **Отсутствие ОБЩЕГО лимита тела над маршрутами не наблюдаемо ничем.** Пер-маршрутность (PD-35/PD-53) держится на том, что вложенный `MaxBytesReader` только УЖЕСТОЧАЕТ: это запинено `TestBodyCapIsPerRouteBecauseNestingOnlyTightens`. Но возврат внешнего слоя в `New` батарею переживает, потому что ни один маршрут не просит потолок БОЛЬШЕ дефолтного — наблюдаемым дефект станет ровно тогда, когда появится загрузка книги. Строка заведена, чтобы это не выяснилось молча: тест обязан приехать ВМЕСТЕ с маршрутом загрузки | open | ревью P2 (линза doc-vs-code) | | PD-73 | vuln | minor | `internal/login/login.go:67-74`, `:126` | **Дедлайн `identify` ограничивал ОЖИДАЮЩЕГО, а не саму загрузку ключей — то есть мой фикс PD-65 был неполон.** `Provider.Verifier` берёт набор ключей, построенный на discovery, а go-oidc хранит его через `context.WithoutCancel` и ходит за ключами на `http.DefaultClient`, у которого таймаута нет. Загрузка, которая зависла, продолжает висеть после того, как ожидающий сдался, и все последующие входы встают на тот же `inflight` — то есть вход не поднимается и после того, как эндпоинт выздоровел, вплоть до перезапуска процесса. Воспроизведено ревью на боевой проводке — **закрыто:** `New` ВСЕГДА ставит `httpClient` с таймаутом `providerTimeout`, клиент передаётся `NewProvider` безусловно (`oidc.ClientContext`), и его подхватывает набор ключей; nil-случая больше нет — класс устранён, а не покрыт тестом. Пины — `TestTheDefaultProviderClientIsBounded` (посадка «клиент без таймаута» падает) и `TestAHungKeyFetchDoesNotPoisonLaterSignIns` (вход ПОСЛЕ выздоровления эндпоинта обязан пройти) | fixed(P2, дерево сессии) | ревью P2 (линза the-fixes) | | PD-74 | bug | minor | `internal/auth/middleware.go:57` | **Скольжение окна залипало на последней четверти жизни сессии: каждый запрос становился записью.** `Touch` прижимает новый дедлайн через `least(now+IdleTTL, absolute_expires_at)`, поэтому как только `now+IdleTTL` перевалил за абсолютный потолок, `idle_expires_at` больше не двигается — а условие «осталось меньше половины окна» с этого момента истинно ВСЕГДА. На горячем пути это UPDATE по первичному ключу таблицы сессий и `Set-Cookie` на каждом аутентифицированном запросе (после PD-70 — ещё и кука). Найдено двумя линзами независимо — **закрыто:** скольжение выполняется только пока `IdleExpiresAt` строго меньше `AbsoluteExpiresAt`; пин — `auth.TestTheSlideStopsOnceItCannotMoveTheDeadline` (пять чтений дают ноль записей, а сессия с запасом по-прежнему скользит) | fixed(P2, дерево сессии) | ревью P2 (линзы session-security и вне карты, независимо) | | PD-75 | bug | minor | `cmd/tmplatformctl/main.go:151` | **CLI сообщал о ПРИМЕНЁННОМ начислении как о провале, а повтор начислял второй раз.** `write` выполняет денежную операцию, затем отдельным запросом читает баланс, и ошибку ЧТЕНИЯ возвращает как результат команды. Оператор видит ошибку, повторяет — а `--key` необязателен, и без него `newKey` чеканит новый ключ идемпотентности, поэтому второй прогон начисляет ещё раз. Достаточно обрыва соединения между двумя запросами — **закрыто:** после коммита команда не может отчитаться провалом; баланс читается как любезность, его отказ печатается предупреждением на той же строке | fixed(P2, дерево сессии) | ревью P2 (линза вне карты) | | PD-76 | bug | minor | `internal/login/login_test.go` | **Определяющее свойство пакета — «ничего выданного провайдером не персистится» — проверялось утверждением, которое не могло упасть.** `memStore.notes` объявлено и не заполнялось ни одним методом, поэтому `strings.Join(notes)` всегда пусто, а `Contains` всегда ложно. Свойство названо в доккомменте пакета первой строкой — **закрыто:** мок пишет в `saw` КАЖДУЮ строку, которую поток ему передал, а утверждение проверяет и непустоту записи, и отсутствие среди неё и access-токена, и любого JWT-образного значения. Посадка «положить в стор сырой id-токен» падает | fixed(P2, дерево сессии) | ревью P2 (линза water) | | PD-77 | bug | info | `internal/ingest/supervisor.go:104` | **Штатная остановка живого прогона поднимала тревогу о сломанном синке.** `Ingest` проверяет `ctx.Err()` в начале цикла и возвращает `context.Canceled` как СВОЮ ошибку; `Run` отличить это от отказавшего синка не мог и на обычном SIGTERM писал ERROR «stream could not be materialized», который по замыслу означает «платформа ослепла, пока тратятся деньги», плюс звал `stop()` на уже останавливающемся прогоне — **закрыто:** отменённый `runCtx` больше не считается отказом синка | fixed(P2, дерево сессии) | ревью P2 (линза вне карты) | | PD-78 | hardening | info | `internal/login/login.go` (было), `internal/httpapi/problem.go` (было), `internal/pgstore/identity.go`, `internal/httpapi/server.go` | **Свод воды и дублей, найденный линзой лаконичности; каждый пункт проверен удалением.** (а) `login.Routes` мемоизировал mux через `sync.Once` — при этом ВТОРОЙ и последующие `guard` молча игнорировались, то есть это была не оптимизация, а ловушка; снято. (б) `login.Fail` носил `*http.Request`, который никто не читал, и ради несовпадения сигнатур существовал шим `httpapi.Fail`; параметр и шим удалены, `WriteProblem` подключён напрямую. (в) `upsertIdentityOnce` держал собственный begin/rollback/commit при наличии `inTx` — второй экземпляр того же кода. (г) `Deps.APIPrefix` — ручка, которую не выставлял ни один вызыватель; заменена константой. (д) `Ready` делал `Ping` и следом запрос — два round trip на пробу каждые несколько секунд. (е) пять полей тестовых двойников, которые писались и не читались; `blockingSink` не блокировал. (ж) `money.USD` считал руками с комментарием про переполнение `MinInt64` — заменён на `big.Rat.FloatString(6)`, проверено побайтовое совпадение на всём диапазоне | fixed(P2, дерево сессии) | ревью P2 (линза water) + самопроверка | + +## Закрытые — эра P3 (деплой и фикс-пак приёмки P2) + +| ID | Класс | Серьёзность | Где | Суть | Статус | Источник | +|---|---|---|---|---|---|---| | PD-79 | bug | minor | `internal/money/money.go:33-36` | **Строковый `"null"` читается как НОЛЬ денег.** Кавычки снимаются `strings.Trim` ДО проверки `s == "null"`, поэтому `"committed_usd":"null"` даёт настоящий `0` и НЕПУСТОЙ указатель, тогда как доккоммент поля обещает отказ на «absent, null and empty». Замерено приёмкой на живом декодере: голый `null` и отсутствие поля дают nil (защита работает), `""` даёт ошибку, а `"null"` — `Spend = 0 micro-USD, NON-NIL`. На пути расчёта это «попытка стоила ничего»: холд освобождается, списания нет. Латентно до воркера; чинится перестановкой проверки перед `Trim` — **закрыто:** литерал `null` судится ДО раскавычивания и оставляет значение нетронутым; кавычки снимает `encoding/json`, а не `strings.Trim` — слово `null`, пустая строка и экранированная цифра выходят тем, чем являются, и каждое встречает ту же единственную проверку синтаксиса, поэтому отдельной ветки «пусто или null» не нужно вовсе. Пины: `money.TestUnmarshalTellsTheNullLiteralFromTheWordNull` (обе формы плюс невмешательство в значение) и `ingest.TestSpendRefusesNonsense` на шве. Обе посадки — «снять кавычки первыми» и «слово `null` есть ноль» — поймать поимённо | fixed(P3, дерево сессии) | приёмка P2 (замер оркестратора №15 + панель) | | PD-80 | vuln | **major** | `internal/login/login.go:158,217-226` | **Вход выключается тремя запросами в секунду, и 429 колбэка ДОБИВАЕТ начатые входы.** Ведро `rate.NewLimiter(2, 20)` одно на `/auth/login` И `/auth/callback` (`login.go:122`, единственный лимитер в зоне), а колбэк стирает login-куку ПЕРВОЙ строкой — до своей проверки лимитера. Следствие: анонимный поток на `/auth/login` не только закрывает вход всем (это PD-42, принято риском в форме «глобальный, не пер-адресный»), но и делает начатый вход невосстановимым: 429 приходит уже с `Set-Cookie: __Host-tm_login=; Max-Age=0`, поэтому повтор того же колбэка не пройдёт и после наполнения ведра. **Воспроизведено приёмкой на боевом бинаре:** 19 из 40 `/auth/login` прошли, дальше 429; честный колбэк с живым state получил 429 и стёртую куку. Независимо измерено панелью. Фикс дешёвый: лимитер прежде очистки куки + раздельные ведра для начала и конца входа; пер-адресный лимит остаётся вопросом edge (PD-42) — **закрыто:** два ведра вместо одного (`startLimit`/`finishLimit`, те же rate/burst — не делится именно ИСЧЕРПАНИЕ), и проверка лимитера ПЕРЕД `ClearLogin`. Пины: `TestFloodingTheStartOfSignInDoesNotCloseTheEnd` (поток на `/auth/login` не закрывает честный колбэк) и `TestARefusedCallbackKeepsTheLoginItRefused` (429 не стирает куку, состояние не съедено, повтор после снятия лимита доходит до 303). Посадки «одно ведро» и «очистка выше лимитера» падают | fixed(P3, дерево сессии) | приёмка P2 (живая проба + панель, две независимые линзы) | -| PD-81 | standards | minor | `internal/pgstore/credits.go:169-178` | **Заявленный `ErrDuplicateHold` на реальном пути недостижим:** при ЖИВОЙ резервации повторный `Hold` падает на первичном ключе `reservations_pkey` (`00007_credits.sql:66`) и уходит наверх сырой ошибкой Postgres SQLSTATE 23505; объявленная ошибка приходит только когда строку резервации уже смахнули, а ключ леджера остался. Замерено приёмкой на живом PG в обеих формах. Деньги целы (`balance == SUM(ledger)`, транзакция откатывается), но воркеру не на что смотреть, кроме текста ошибки — **закрыто:** при ЖИВОЙ резервации коллизия `reservations_pkey` мапится в `ErrDuplicateHold` (`credits.go` `holdTx`); объявленная ошибка стала достижимой на реальном пути. Пин — `TestASecondHoldOnALiveReservationIsADuplicateNotASqlstate` (сверяет и то, что отказ не двинул деньги) | fixed(P4, дерево сессии) | приёмка P2 (замер оркестратора №15 + панель) | -| PD-82 | bug | info | `internal/pgstore/credits.go:236-239` | `Hold` на НЕСУЩЕСТВУЮЩИЙ аккаунт отдаёт `ErrInsufficientCredit` (в `lockBalance` `ErrNoRows` трактуется как «нет кредита»), а не `ErrNoAccount`: обещание PD-56 «один ответ на несуществующий аккаунт» покрывает `Grant`/`Adjust`/`Balance`/`ReadAccount` и на `Hold` не распространяется. Замерено приёмкой — **закрыто:** `lockBalance` при отсутствии строки баланса спрашивает, существует ли аккаунт, и отвечает `ErrNoAccount` против `ErrInsufficientCredit`. ⚠ Одним запросом это не выражается: Postgres запрещает `FOR UPDATE` на nullable-стороне внешнего соединения — проверено, поэтому вторая проверка идёт только на редком пути. Пин — `TestMoneyOperationsTellAMissingAccountFromAnEmptyOne` | fixed(P4, дерево сессии) | приёмка P2 (замер оркестратора №15) | | PD-83 | hardening | minor | `internal/httpapi/middleware.go:64` | **Фикс PD-3 не запинен в собственном месте:** посадка «`Recover` логирует `r.URL.Path` вместо `routeOf(r)`» батарею ПЕРЕЖИВАЕТ, тогда как та же посадка в `AccessLog` ловится поимённо (`TestAccessLogNamesTheRouteNotThePath`). По правилу шапки этого файла половина PD-3 закрытой не считается — **закрыто:** `TestPanicBecomesAProblemAndNamesTheRoute` — паника за мультиплексором с `{book}` в паттерне; сверяется и `route`, и отсутствие идентификатора книги во ВСЕЙ строке (в ней же стек). Посадка `r.URL.Path` падает | fixed(P3, дерево сессии) | приёмка P2 (посадка мутации) | | PD-84 | hardening | minor | `internal/login/login.go:222` | **Лимитер колбэка (фикс PD-29) не запинен:** удаление всей проверки `h.limiter.Allow()` из `callback` оставляет батарею зелёной. Замер PD-29 (~880 строк/с с одного хоста) означает, что регрессия здесь тихо возвращает неаутентифицированного писателя в таблицу журнала — **закрыто:** `TestBothLegsOfSignInAreRateLimited` — десять колбэков подряд обязаны упереться в 429. Посадка «удалить проверку целиком» падает; её же ловит `TestARefusedCallbackKeepsTheLoginItRefused` | fixed(P3, дерево сессии) | приёмка P2 (посадка мутации) | | PD-85 | hardening | minor | `internal/pgstore/identity.go:126-131` | **«Неподтверждённый адрес не поднимается на аккаунт» запинено только на ветке НОВОЙ личности:** снятие условия `in.EmailVerified` в ветке ВОЗВРАЩАЮЩЕГОСЯ входа (обновление `users.email`) проходит батарею — `TestUnverifiedAddressStaysOffTheAccount` покрывает первый вход и переход в verified, но не обратный случай — **закрыто:** `TestUnverifiedAddressStaysOffTheAccount` продлён третьим шагом — ВОЗВРАЩАЮЩИЙСЯ вход с новым НЕподтверждённым адресом: `users.email` не двигается, `identities.email` записывает то, что пришло. Посадка «снять `in.EmailVerified` в ветке возвращающегося» падает | fixed(P3, дерево сессии) | приёмка P2 (посадка мутации) | -| PD-86 | hardening | info | `internal/pgstore/sessions.go:23,50` | **Два клауза-близнеца не запинены, и абсолютный потолок держится ТРАНЗИТИВНО:** снятие `absolute_expires_at > $2` из `Lookup` батарею переживает, потому что потолок навязывается через `least($3, absolute_expires_at)` в `Touch` (это запинено — `TestSessionLifecycle`). Снятие `revoked_at is null` из `Touch` тоже переживает (класс PD-4). Дефекта сегодня нет ни в одном; риск в том, что каждый слой по отдельности выглядит избыточным, а вместе они — единственное, что ограничивает жизнь сессии | open | приёмка P2 (посадки мутаций) | -| PD-87 | hardening | info | `internal/httpapi/server.go:82`, `internal/login/login.go:31` | Ещё два незапиненных: снятие `LimitBody` с поддерева `/auth` и `stateTTL` 10 мин → 240 ч проходят батарею. Первое — родня PD-72 (та про общий внешний слой, эта про конкретное поддерево), второе — окно жизни неиспользованного авторизационного запроса | open | приёмка P2 (посадки мутаций) | -| PD-88 | bug | info | `internal/auth/cookie.go:62-66` | **TTL меньше секунды выпускает куку БЕЗ атрибута `Max-Age`:** `int(ttl.Seconds())` даёт 0, а Go при `MaxAge == 0` атрибут опускает ⇒ кука становится браузер-сессионной. Достижимо в последнюю секунду абсолютного срока (скольжение выдаёт `min(idle, остаток абсолютного)` при гарде `ttl > 0`) — то есть ровно тот исход, который самопроверка P2 называла нежелательным: кука переживает сессию, и следующий запрос даёт 401 вместо чистого «вы вышли». Подтверждено исполнением (ttl 500 мс/999 мс) | open | приёмка P2 (панель ×2, подтверждено исполнением) | -| PD-89 | hardening | minor | `cmd/tmplatformctl/main.go:143-150` | **Сминченный ключ идемпотентности не печатается при ошибке записи:** PD-75 закрыл путь ПОСЛЕ коммита, но неоднозначный обрыв НА коммите остался — оператор видит ошибку, повторяет без `--key`, `newKey()` чеканит новый ключ, второе начисление проходит. Фикс: печатать ключ вместе с ошибкой, чтобы повтор был с тем же `--key` | open | приёмка P2 (панель) | -| PD-90 | bug | info | `cmd/tmplatformctl/main.go:112,134` | `grant` и `adjust` делят пространство ключей `source="admin"`: `--key`, потраченный грантом, молча гасит корректировку с тем же ключом. CLI честно скажет «ключ уже потрачен», но оператор ждал другой операции | open | приёмка P2 (панель) | | PD-91 | doc | minor | `deploy/README.md:33-42`, `deploy/tmplatformd.service:43,48` | **Установка, исполненная дословно, даёт нестартующий юнит:** `/srv/textmachine` не создаётся ни одной командой наброска, а `ReadWritePaths=` без префикса `-` на несуществующем пути валит сборку mount-namespace при `ProtectSystem=strict`. Заодно `ProtectHome=yes` против решения владельца «книги живут в `~/books`»: детям-`tmctl` домашние каталоги под этим юнитом недоступны — либо книги переезжают в `/srv/textmachine`, либо юнит получает `BindPaths=`. ⚠ Вывод из `systemd.exec(5)`, под systemd не исполнялось (sudo нет) — **закрыто:** каталог `/srv/textmachine` создаётся явной командой наброска (`--system` домашний каталог не создаёт), префикс `-` намеренно НЕ ставится (сервис без записываемого каталога обязан падать на старте, а не на первой записи через часы), `ProtectHome=yes` оставлен с названной ценой и двухстрочным выходом (`ProtectHome=tmpfs` + `BindPaths=`), корень библиотеки на СЕРВЕРЕ — `/srv/textmachine`, `~/books` объявлено конвенцией машины разработки. **Проверено живым прогоном** `systemd-run --user` (systemd 259), а не докой: несуществующий путь без `-` → `226/NAMESPACE`, созданный → `0/SUCCESS`, с `-` → `0/SUCCESS` (строка игнорируется); `ProtectHome=yes` → `Permission denied` на `/home/`; `tmpfs`+`BindPaths` → каталог виден | fixed(P3, дерево сессии) | приёмка P2 (панель, сверено с докой) | -| PD-92 | bug | info | `internal/ingest/supervisor.go:118-121` | **Дренаж стоит ДО `cmd.Wait()`, поэтому `WaitDelay` его не размораживает:** `io.Copy(io.Discard, stdout)` ждёт EOF, а EOF придёт только когда закроются ВСЕ копии пишущего конца пайпа; внук, унаследовавший stdout и игнорирующий SIGINT, вешает `Run` навсегда — backstop `WaitDelay` действует внутри `Wait`, до которого управление не доходит. ⚠ Сегодня недостижимо и это пере-проверено приёмкой: в `backend/` вне тестов нет ни одного `exec.Command` и нет cgo. ⚠ **Пере-диспозиция (эррата №15): вес понижен до info** — весь пайп-путь `supervisor.go` объявлен ДЕВ-РЕЖИМОМ (D39.106 п.3), в проде родителя у движка нет и `cmd.StdoutPipe()` не существует; чинить только если дев-путь остаётся | open | приёмка P2 (панель, граница зоны пере-проверена) | -| PD-93 | bug | info | `internal/ingest/supervisor.go:109` | Фикс PD-77 («наша остановка — не сломанный синк») сверяет только `context.Canceled` и пропускает `context.DeadlineExceeded`: как только у `runCtx` появится дедлайн (потолок времени прогона — очевидная будущая ручка), штатное истечение снова поднимет ERROR «stream could not be materialized» | open | приёмка P2 (панель) | -| PD-94 | bug | info | `internal/httpapi/middleware.go:61-70` | **`Recover` глотает `http.ErrAbortHandler`** — sentinel, которым хендлер намеренно обрывает соединение (`net/http` его не логирует и рвёт коннект). Замерено приёмкой: паника `ErrAbortHandler` превращается в 500 с problem-телом, то есть усечённый поток становится неотличим от полного. Латентно (сегодня им никто не паникует), но именно SSE-хендлер — типовой его пользователь | open | приёмка P2 (замер оркестратора №15) | | PD-95 | doc | **major (для промта эмиттера)** | `internal/ingest/events.go:6-12`, `docs/platform-PROGRESS.md` §«Транспорт потока событий» | **Транспортная история зоны устарела против D39.106 в ДВУХ местах, и обе версии не совпадают с ратифицированной формой.** Ратифицировано (D39.106 п.2 + `research/25` §Форма): движок — транзиентный systemd-юнит на прогон, платформа ему **НЕ родитель**; события — `events.jsonl` в каталоге книги, append-only, как outbox-проекция уже закоммиченных строк SQLite (та же транзакция, что чекпойнт); платформа **тейлит** журнал, курсор `(engine_run_id, seq)` коммитится в одной Postgres-транзакции с эффектом. В `research/25` вариант «платформа — родитель + пайп (stdout/fd)» получил **0 голосов из 15** («время жизни движка — подмножество платформы: деплой/рестарт убивает или осиротляет прогон»), выделенный fd 3 — **0**, stdout/journald как источник событий — **0**. Что в зоне: (а) доккоммент `events.go` предлагает переезд на fd/сокет — отклонённая форма; (б) журнал зоны длинно доказывает «канал остаётся stdout» и объявляет переезд отклонённым, ссылаясь на PD-59, который **сам superseded** тем же D39.106 п.3 («PD-59 superseded; пайп-путь `supervisor.go` P1 = дев-режим; ответ PD-13 переезжает на cgroup юнита прогона»). Оба текста прочтёт эмиттер-сессия как задание. ⚠ **Приёмка №15 это пропустила и в первой редакции строки сама сослалась на снятый PD-59 — исправлено здесь же** — **закрыто:** доккоммент пакета переписан под D39.106 §2 — транзиентный systemd-юнит на прогон, платформа НЕ родитель, `events.jsonl` в каталоге книги как outbox-проекция коммитов SQLite, тейл с курсором `(engine_run_id, seq)`, повторное чтение строк — норма (PD-105). Отвергнутые формы перечислены со счётом голосов, чтобы не вернулись свежей идеей. Доккоммент `Supervisor` помечен ДЕВ-РЕЖИМОМ там же, где он описывает пайп. ⚠ В журнале зоны нашлась ВТОРАЯ копия снятого ответа — блок «ОТВЕЧЕНО приёмкой (PD-59)» в списке «Открытые вопросы после P1» п.4: эррата №15 пере-ставила другую секцию, эту не тронула. Текст оркестратора не переписан — над ним поставлен баннер SUPERSEDED с ратифицированной формой; проверить принадлежность правки — за приёмкой | fixed(P3, дерево сессии) | приёмка P2 (эррата оркестратора №15, 07.08) | -| PD-96 | hardening | info | `internal/httpapi/server.go:39-43`, `internal/auth/csrf.go:28` | **`TrustedOrigins` обещает отдельно развёрнутый фронт, но CORS-слоя нет вовсе.** Живая проба: preflight `OPTIONS` с `Origin: https://app.example.org` получает 401 от гарда (браузерный preflight креденшелов не носит и не должен), заголовков `Access-Control-*` нет ни на одном ответе. Сценарий «фронт на другом origin» браузером сегодня неисполним: либо CORS приезжает вместе с контрактными ручками (П-1), либо фронт живёт на том же origin, и тогда `TrustedOrigins` — мёртвая ручка | open | приёмка P2 (панель + живая проба) | -| PD-97 | hardening | info | `internal/pgstore/credits.go:212-216` | `Settle`/`Release` отбрасывают флаг `applied` у `hold_release`: если ключ `("run_release", engineRunID)` уже потрачен, резервация закроется, а деньги не вернутся — тихий no-op на денежном пути. Требует нештатной последовательности (закрытие, смахивание строки, повторное открытие того же `engine_run_id`), но ровно на такой последовательности стоит `ErrDuplicateHold` — **закрыто:** `releaseHold` судит флаг `applied`; потраченный ключ релиза = `ErrReleaseKeySpent` и ОТКАТ транзакции, поэтому резервация остаётся ОТКРЫТОЙ и видимой оператору вместо тихого закрытия без возврата денег. Пин — `TestAReleaseWhoseKeyWasSpentIsRefusedRatherThanSilent` | fixed(P4, дерево сессии) | приёмка P2 (панель) | -| PD-98 | doc | info | `internal/pgstore/store.go:75-79` | Случай «схема НОВЕЕ бинаря» в `Ready` беззвучен — признано ⚠-комментарием на месте, но ни одной строки лога: оператор, запустивший старый бинарь на новой схеме, сигнала не получит | open | приёмка P2 (панель) | -| PD-99 | hardening | info | `internal/ingest/supervisor.go:102` | INFO-лог «engine started» пишет `args` целиком. Сегодня безвредно, но воркер будет передавать движку идентификатор книги и потолок аргументами ⇒ book-id и денежная сумма попадут в INFO платформы (D39.84 + норма зоны «id книги в логи не текут»). Закрыть вместе с воркером: логировать имя команды, не argv — **закрыто:** INFO-строка старта несёт имя команды и НЕ несёт argv (`runner.Start`, а также дев-путь `ingest/supervisor.go`), поэтому ни id книги, ни потолок в долларах в поток INFO не попадают. Пин — `runner.TestTheStartLineNamesTheCommandAndNotItsArguments` (посадка «вернуть \"args\"» падает). Живая проба на боевом бинаре: `grep -c 'ceiling-usd\|3.000000\|bk_' daemon.log` = **0** за полный прогон | fixed(P4, дерево сессии) | приёмка P2 (панель) | | PD-100 | bug | minor | `internal/login/login.go:245-261` | **Класс PD-5 закрыт в `auth/`, но не в `login/`:** колбэк глотает ошибку стора (`TakeLoginState`) и ошибку discovery, репортя их как обычный отказ (`unknown_state` / `discovery_failed`) — сама ошибка не доезжает ни до одной строки лога, хотя `pgstore/identity.go` намеренно отличает «состояния нет» от инфраструктурного сбоя. Аутентификационный DB-outage снова выглядит штормом обычных отказов — **закрыто:** сбой стора и сбой discovery уходят в ERROR; на проводе и в журнале — прежний отказ. `login.ErrNoState` заведён у владельца интерфейса (как `auth.ErrNoSession`), `pgstore.ErrNoLoginState` — то же значение под прежним именем. Пин `TestInfrastructureFailuresInTheCallbackAreLogged`: три случая, включая «обычное истечение НЕ логируется как авария» — ловит и посадку «логировать всегда» | fixed(P3, дерево сессии) | приёмка P2 (панель) | -| PD-101 | bug | minor | `internal/login/login.go:507` | `login_events.ip_prefix` берётся из `r.RemoteAddr`, а в задуманном деплое перед сервисом стоит edge-прокси ⇒ префикс всегда сеть прокси. Журнал входов заведён как ответ на «откуда примерно я входил» — в шипуемой форме он систематически отвечает неверно. `X-Forwarded-For`/`Forwarded` нигде не читаются и доверенного прокси в конфиге нет (это правильный дефолт: доверять заголовку без edge нельзя) — значит решение про edge и про этот столбец принимается вместе | open | приёмка P2 (панель) | -| PD-102 | doc | minor | `internal/httpapi/serve.go:36-38` | Доккоммент `DefaultTimeouts` утверждает, что «an upload extends its own deadline as it makes progress» — это НЕВЕРНО: `ReadTimeout` в `net/http` (Go 1.26.5, `server.go:990` `wholeReqDeadline = t0.Add(ReadTimeout)`) выставляется один раз и по мере прихода байтов не продлевается. Комментарий несущий: он объясняет, почему `Read` короткий, и на нём будущая ручка загрузки книги (23 МБ по контракту) построит неверное ожидание — ей понадобится собственный дедлайн через `ResponseController`, а не «прогресс продлевает» | open | приёмка P2 (панель, сверено с исходником Go) | -| PD-103 | hardening | minor | `internal/auth/middleware.go:43,66` | У обращений к БД на аутентифицированном пути (`Lookup`/`Touch`) нет собственного дедлайна — только голый `r.Context()`, а `WriteTimeout` у сервера отсутствует по проекту (SSE) и `TimeoutHandler` в цепочке нет. Зависший Postgres паркует хендлеры и ждущих в пуле, пока клиент сам не уйдёт. `readyz` свой таймаут получил (PD-14) — горячий путь нет | open | приёмка P2 (панель) | -| PD-104 | bug | **minor, расхождение док↔код** | `internal/login/login.go:285-288`, `internal/config/config.go:73` | **Фри-тир начисляется АВТОМАТИЧЕСКИ, а реестр обещает обратное.** Код: дефолт `SignupGrantMicroUSD: 5 * 1_000_000` (`config.go:73`) проведён в демона (`main.go:99`) и логин отдаёт его в стор на каждой новой подтверждённой паре `(provider, subject)` — аккаунт создаётся С $5. Строка PD-30 при закрытии утверждает «аккаунт создаётся с нулём, начисление руками из админки» — один из двух текстов лжёт, и это чинится независимо от продуктового решения. Ограничитель у автогранта один — лимитер входа; агрегатного потолка, счётчика и алерта нет (грепнуто). **Разбор нормы и предложение «на бете дефолт в НОЛЬ» — D39.110 п.3, здесь не дублируется; ждёт слова владельца** — **ПОЛОВИНА ЗАКРЫТА (док↔код):** ячейка PD-30 исправлена — «аккаунт с нулём» относилось только к НЕподтверждённой личности, подтверждённая получает автогрант (дефолт $5). ⚠ Продуктовая часть (ноль на бете, агрегатный потолок, счётчик) — НЕ закрыта: ждёт слова владельца, носитель прежний | open | приёмка P2 (панель; расхождение — оркестратор №15) | -| PD-105 | standards | **major (для промта эмиттера)** | `internal/ingest/decoder.go:96` | **Декодер и норматив зоны расходятся на дубле `seq`:** декодер объявляет его фатальным `ErrStreamGap`, а `ENGINEERING_STANDARDS §2` ратифицирует «at-least-once — норма, дубль — не ошибка». После фикса PD-12 цена выросла: сбой ингеста ОСТАНАВЛИВАЕТ прогон, поэтому одна задублированная строка убивает платный прогон, хотя ратифицированный путь ремонта — `status --json`. Внутри одного пайпа передоставки нет, так что отказ декодера защитим; непропорциональна РЕАКЦИЯ. Разрешать ратификацией вместе с промтом эмиттера (строка 103), не молча. ⚠ **Пере-диспозиция (эррата №15): вес ПОВЫШЕН до major-для-эмиттера** — при ратифицированном транспорте (тейл `events.jsonl` с курсором, D39.106) повторное чтение строк после краша читателя — НОРМА, а не аномалия пайпа, поэтому норматив «at-least-once, дубль не ошибка» буквально верен, и фатальный отказ декодера прямо ему противоречит — **ЗАКРЫТО РАТИФИКАЦИЕЙ (D39.119) И РЕАЛИЗАЦИЕЙ.** Транспорт — тейл `events.jsonl` с курсором, поэтому повторное чтение строк НОРМА: `ingest.Tail` пропускает `seq <= last_seq` идемпотентно и не возвращает ошибку, а `pgstore.RunSink.Apply` пере-проверяет тот же high-water mark ВНУТРИ транзакции эффекта. Тот же `seq` с ДРУГИМ payload = `ErrPayloadConflict` → карантин ПОПЫТКИ, то есть её проекции: материализация останавливается, а жизненный цикл прогона продолжается — движок тратит зарезервированные деньги, и наша неспособность прочитать журнал не повод их выбросить (`pgstore.Quarantine` пишет только `run_attempts.quarantine_reason`, свежесть переходит на ре-синк). Сверка по sha256 строки (`run_attempts.last_line_sha256`). Пропасть (`seq > last+1`) осталась ошибкой — строки потеряны, читать дальше нечего. Пины: `ingest.TestARedeliveredLineIsNormalAndChangesNothing` · `TestTheSameSeqWithADifferentPayloadIsRefused` · `TestALostLineIsReportedRatherThanSkipped` · `pgstore.TestARedeliveredCountingEventDoesNotCountTwice` (⚠ последний написан ПОСЛЕ того, как посадка пережила первую версию пина: прогресс — присваивание и потому идемпотентен сам по себе, считающий эффект — `unit_done` — нет). Фатальный `ErrStreamGap` на дубле в `decoder.go` остаётся только на ДЕВ-пути пайпа, где передоставки нет | fixed(P4, дерево сессии) | приёмка P2 (панель) | | PD-106 | standards | minor | `cmd/tmplatformctl/` | **Админ-CLI — единственный писатель денег в дереве — не имеет ни одного теста.** В том числе не покрыто правило, которое он сам называет несущим («после коммита команда не может отчитаться провалом», фикс PD-75), и разбор флагов, и формат вывода. Батарея зоны его не видит вовсе (`[no test files]`) — **закрыто:** `cmd/tmplatformctl/main_test.go` — восемь тестов. Несущее правило («после коммита ничего не отчитывается провалом») пинится через `balanceReader` — интерфейс с одним методом, заведён ровно затем, что правило нельзя проверить на сторе, который всегда работает. Плюс: спент-ключ → «no-op», сбой ДО коммита → ошибка и ни строки вывода, ключ без `--key` уникален на 100 прогонах, разбор флагов десятью случаями и сквозной прогон пяти команд по живой БД. ⚠ Первая редакция теста флагов сверяла лишь «ошибка непуста» — две посадки её ПЕРЕЖИЛИ (команда падала на соединении, а не на аргументах); тест переписан на сверку сообщения | fixed(P3, дерево сессии) | приёмка P2 (панель) | -| PD-107 | hardening | info | `internal/pgstore/migrations/00007_credits.sql:85`, `00002_readmodel.sql:13` | **Удаление аккаунта обходит защиту PD-25:** составной FK `reservations → books(id, owner_id) on delete restrict` блокирует `DeleteBook`, но `users` каскадит в `reservations` НАПРЯМУЮ, поэтому `delete from users` уносит и ОТКРЫТУЮ резервацию. Замерено приёмкой: аккаунт с открытым холдом удаляется. Учётной дыры нет — леджер и кэш баланса каскадятся тем же удалением, — но прогон, идущий против этого холда, останется без того, кто его закроет. Кода удаления аккаунта в дереве нет вовсе (грепнуто) ⇒ строка = гейт перед появлением такой операции (и перед ASVS 7.4.2 в полной форме). ⚠ Заодно ОПРОВЕРГНУТА обратная версия этой находки от панели («удаление падает на композитном FK даже при закрытых резервациях») — мой прогон: удаляется и с закрытой резервацией, и без неё | open | приёмка P2 (замер оркестратора №15; версия панели опровергнута) | + +## Закрытые — эра P4 (раннер: юнит, очередь, реконсилятор, тейлер) + +| ID | Класс | Серьёзность | Где | Суть | Статус | Источник | +|---|---|---|---|---|---|---| +| PD-43 | bug | info | `internal/pgstore/credits.go` | Денежный контур не имеет ни одного вызывающего вне тестов: `Hold`/`Settle`/`Release` не зовутся, `Sink` не реализован, `TypeSpend` не декодируется. При первом реальном прогоне баланс не изменится. Ожидаемо — воркера нет (П-1/П-3), но заведено строкой, чтобы это было решением, а не сюрпризом — **закрыто:** денежный контур получил вызывающих: `runs.Service.Start` берёт холд в ОДНОЙ транзакции с созданием прогона и записью очереди (`pgstore.StartRun`), реконсилятор закрывает его `Settle` по фигуре движка. Пины: `pgstore.TestAdmittingARunWritesTheRunTheAttemptAndTheHoldTogether` (посадка «убрать holdTx из транзакции» падает), `TestARunThatCannotBePaidForLeavesNothingBehind`, `runs.TestARunThatEndsIsFinishedAndSettledAtWhatTheEngineSpent`. Живая проба: грант $10 → прогон с потолком 100 глав → холд $3.00 → движок отчитался $0.42 → баланс $9.58 | fixed(P4, дерево сессии) | ревью «вне карты» | +| PD-81 | standards | minor | `internal/pgstore/credits.go:169-178` | **Заявленный `ErrDuplicateHold` на реальном пути недостижим:** при ЖИВОЙ резервации повторный `Hold` падает на первичном ключе `reservations_pkey` (`00007_credits.sql:66`) и уходит наверх сырой ошибкой Postgres SQLSTATE 23505; объявленная ошибка приходит только когда строку резервации уже смахнули, а ключ леджера остался. Замерено приёмкой на живом PG в обеих формах. Деньги целы (`balance == SUM(ledger)`, транзакция откатывается), но воркеру не на что смотреть, кроме текста ошибки — **закрыто:** при ЖИВОЙ резервации коллизия `reservations_pkey` мапится в `ErrDuplicateHold` (`credits.go` `holdTx`); объявленная ошибка стала достижимой на реальном пути. Пин — `TestASecondHoldOnALiveReservationIsADuplicateNotASqlstate` (сверяет и то, что отказ не двинул деньги) | fixed(P4, дерево сессии) | приёмка P2 (замер оркестратора №15 + панель) | +| PD-82 | bug | info | `internal/pgstore/credits.go:236-239` | `Hold` на НЕСУЩЕСТВУЮЩИЙ аккаунт отдаёт `ErrInsufficientCredit` (в `lockBalance` `ErrNoRows` трактуется как «нет кредита»), а не `ErrNoAccount`: обещание PD-56 «один ответ на несуществующий аккаунт» покрывает `Grant`/`Adjust`/`Balance`/`ReadAccount` и на `Hold` не распространяется. Замерено приёмкой — **закрыто:** `lockBalance` при отсутствии строки баланса спрашивает, существует ли аккаунт, и отвечает `ErrNoAccount` против `ErrInsufficientCredit`. ⚠ Одним запросом это не выражается: Postgres запрещает `FOR UPDATE` на nullable-стороне внешнего соединения — проверено, поэтому вторая проверка идёт только на редком пути. Пин — `TestMoneyOperationsTellAMissingAccountFromAnEmptyOne` | fixed(P4, дерево сессии) | приёмка P2 (замер оркестратора №15) | +| PD-97 | hardening | info | `internal/pgstore/credits.go:212-216` | `Settle`/`Release` отбрасывают флаг `applied` у `hold_release`: если ключ `("run_release", engineRunID)` уже потрачен, резервация закроется, а деньги не вернутся — тихий no-op на денежном пути. Требует нештатной последовательности (закрытие, смахивание строки, повторное открытие того же `engine_run_id`), но ровно на такой последовательности стоит `ErrDuplicateHold` — **закрыто:** `releaseHold` судит флаг `applied`; потраченный ключ релиза = `ErrReleaseKeySpent` и ОТКАТ транзакции, поэтому резервация остаётся ОТКРЫТОЙ и видимой оператору вместо тихого закрытия без возврата денег. Пин — `TestAReleaseWhoseKeyWasSpentIsRefusedRatherThanSilent` | fixed(P4, дерево сессии) | приёмка P2 (панель) | +| PD-105 | standards | **major (для промта эмиттера)** | `internal/ingest/decoder.go:96` | **Декодер и норматив зоны расходятся на дубле `seq`:** декодер объявляет его фатальным `ErrStreamGap`, а `ENGINEERING_STANDARDS §2` ратифицирует «at-least-once — норма, дубль — не ошибка». После фикса PD-12 цена выросла: сбой ингеста ОСТАНАВЛИВАЕТ прогон, поэтому одна задублированная строка убивает платный прогон, хотя ратифицированный путь ремонта — `status --json`. Внутри одного пайпа передоставки нет, так что отказ декодера защитим; непропорциональна РЕАКЦИЯ. Разрешать ратификацией вместе с промтом эмиттера (строка 103), не молча. ⚠ **Пере-диспозиция (эррата №15): вес ПОВЫШЕН до major-для-эмиттера** — при ратифицированном транспорте (тейл `events.jsonl` с курсором, D39.106) повторное чтение строк после краша читателя — НОРМА, а не аномалия пайпа, поэтому норматив «at-least-once, дубль не ошибка» буквально верен, и фатальный отказ декодера прямо ему противоречит — **ЗАКРЫТО РАТИФИКАЦИЕЙ (D39.119) И РЕАЛИЗАЦИЕЙ.** Транспорт — тейл `events.jsonl` с курсором, поэтому повторное чтение строк НОРМА: `ingest.Tail` пропускает `seq <= last_seq` идемпотентно и не возвращает ошибку, а `pgstore.RunSink.Apply` пере-проверяет тот же high-water mark ВНУТРИ транзакции эффекта. Тот же `seq` с ДРУГИМ payload = `ErrPayloadConflict` → карантин ПОПЫТКИ, то есть её проекции: материализация останавливается, а жизненный цикл прогона продолжается — движок тратит зарезервированные деньги, и наша неспособность прочитать журнал не повод их выбросить (`pgstore.Quarantine` пишет только `run_attempts.quarantine_reason`, свежесть переходит на ре-синк). Сверка по sha256 строки (`run_attempts.last_line_sha256`). Пропасть (`seq > last+1`) осталась ошибкой — строки потеряны, читать дальше нечего. Пины: `ingest.TestARedeliveredLineIsNormalAndChangesNothing` · `TestTheSameSeqWithADifferentPayloadIsRefused` · `TestALostLineIsReportedRatherThanSkipped` · `pgstore.TestARedeliveredCountingEventDoesNotCountTwice` (⚠ последний написан ПОСЛЕ того, как посадка пережила первую версию пина: прогресс — присваивание и потому идемпотентен сам по себе, считающий эффект — `unit_done` — нет). Фатальный `ErrStreamGap` на дубле в `decoder.go` остаётся только на ДЕВ-пути пайпа, где передоставки нет | fixed(P4, дерево сессии) | приёмка P2 (панель) | | PD-108 | bug | **major** | `internal/ingest/supervisor.go:161` (было), `internal/runner/engine.go` | **Ратифицированный канал ремонта не мог работать НИ РАЗУ: `tmctl status --json` звался БЕЗ обязательного `--config`.** Движок требует его на каждой команде, которая трогает книгу, и падает на разборе аргументов до того, как увидит книгу. Найдено чтением `cmd/tmctl/invocation.go` (не доки) и **воспроизведено исполнением на бинаре, собранном из HEAD** в скрэтчпаде: `tmctl status --json` → `tmctl: --config book.yaml is required`, exit 1; с `--config <путь>` доходит до чтения файла. Дефект латентный ровно потому, что вызывающих у канала не было (PD-43) — то есть первый же резюнк воркера получил бы отказ вместо отчёта — **закрыто:** прод-путь `runner.StatusArgs`/`runner.Status` всегда несёт `--config /book.yaml`; дев-путь `Supervisor.Status` исправлен там же. Пин — `runner.TestEveryEngineInvocationNamesTheBookConfig` | fixed(P4, дерево сессии) | сессия P4 (ревью вне карты: чтение парсера движка + проба на HEAD-бинаре) | | PD-109 | bug | minor | `internal/runs/reconcile.go` | **Периодический резюнк ЗАТИРАЛ более точную проекцию потока своей грубой.** `status --json` не делит стадии по волнам (строка 99), поэтому его агрегат, положенный поверх «draft 7/20 ∥ edit 1/20», заменял пофазные счётчики одним числом — а отчёт движка, который ещё не досчитал, заменял их НУЛЯМИ. Плюс вторая половина: сторож «поток уже говорил» читал `LastSeq` из СНИМКА свипа, взятого ДО тейла, поэтому прогон, чьи первые события пришли в этом же свипе, выглядел молчащим. **Найдено живой пробой сквозного прогона**, не тестом: карточка книги показала `edit 10/10` через секунды после того, как журнал сказал `edit 0/10` — **закрыто:** резюнк работает только там, где чинить нечего (курсор не двигался ЛИБО материализация в карантине), сторож судит курсор ПОСЛЕ тейла, и `ApplyStatus` не опускает счётчики (`greatest`). Пины — `runs.TestALiveRunIsResyncedAtMostOncePerInterval` и `TestTheSweepMaterializesWhateverTheJournalHasGained` | fixed(P4, дерево сессии) | сессия P4 (живая проба сквозного прогона) | | PD-110 | bug | minor | `internal/ingest/tail.go` | **Строки ДРУГОЙ попытки судились против НАШЕГО курсора.** Журнал пер-книжный и append-only, значит резюм дописывает второй `hello` со своим `engine_run_id` и seq, начинающимся заново; строка при этом не несёт идентификатора потока — его говорит только последний хендшейк выше. Первая редакция тейлера этого не отслеживала, поэтому `seq 2` предыдущей попытки встречался с нашим `seq 2` и читался как ИЗМЕНЁННЫЙ payload, то есть как сигнал порчи: здоровый резюмнутый прогон отправлял сам себя в карантин. Найдено собственным тестом до всякой интеграции — **закрыто:** читатель ведёт область (`mine`), и до хендшейка, который он ПРИЗНАЛ своим, ничего не материализуется и ничего не судится. Пины — `TestAnotherAttemptsStreamInTheSameJournalIsSkipped` · `TestARereadFromTheStartDoesNotMistakeAnotherAttemptForCorruption` · `TestEventsBeforeAnyHandshakeAreNotJudgedAgainstOurCursor` (обе посадки — `mine := true` и `mine := false` — падают) | fixed(P4, дерево сессии) | сессия P4 (собственный тест) | | PD-111 | bug | minor | `internal/ingest/tail.go` | **`seq` хендшейка не персистился, поэтому ЛЮБОЙ резюм после него читался как пропасть.** `hello` — это seq 1 потока, но первая редакция обрабатывала его отдельно и курсор не двигала: `last_seq` оставался нулём при уже сдвинутом байтовом хинте, и следующая же строка (`seq 2`) давала `seq 2 after 0` → карантин на ровном месте — **закрыто:** хендшейк проходит те же правила, что любая строка, и двигает курсор; эффекта на read-model у него нет, эффект на курсор и есть смысл. Пин — `TestAHalfWrittenLineIsLeftForNextTime` (сверяет применённые seq 1,2 и продолжение 3,4 после дозаписи) | fixed(P4, дерево сессии) | сессия P4 (собственный тест) | -| PD-112 | standards | minor | `internal/httpapi/v0.go`, контракт 0.2.0 | **Реализация отдаёт статусы, которых спека у операций НЕ перечисляет — четыре класса, все проверены исполнением адверсариальным ревью:** `503` на старте прогона (деплой не может передать потолок/записать конец юнита) · `403` от CSRF-слоя на любом небезопасном запросе (спека описывает требование `X-TM-Client` в `securitySchemes`, но статуса ему не даёт) · `500` у любой операции при отказе стора (спека не перечисляет 5xx нигде) · `404` у `GET /usage` при отсутствующем аккаунте (спека даёт только 200/401; практически недостижимо — сессия ссылается на строку `users` внешним ключом). Ниже — исходная постановка по `503`. **Отказ `503` на старте прогона НЕ входит в перечисленные спекой статусы операции** (`400/401/404/409`). Он поставлен осознанно: когда деплой не может передать движку потолок (строка 145) или записать конец юнита, запрос ВАЛИДЕН, объект существует и состояния конфликта нет — то есть каждый из разрешённых кодов сообщил бы неправду. Правка спеки — не право зоны (правило промта: расхождение = вопрос оркестратору). Строка ждала решения владельца контракта — **закрыто РАТИФИКАЦИЕЙ (оркестратор №15, 09.08): `503` вносится в спеку правкой владельца контракта при лендинге; код зоны не меняется** | fixed(ратификация 09.08; правка спеки за оркестратором) | сессия P4 (самопроверка против спеки) | -| PD-113 | bug | **major (контрактно видимый)** | `internal/runs/reconcile.go` `outcome`, движок `stagerun.go` | **Стоп по потолку сегодня НЕразличим от инфраструктурного отказа, и контракт при этом запрещает называть его `failed`.** Движок возвращает потолок ошибкой (`errReserveCeiling`, сверено в HEAD), а `exitCode` мапит всё нераспознанное в 1 — значит по коду выхода «деньги кончились» и «упало» это одно и то же число; события потолка не существует (строка 103). Платформа честно ставит `failed`, хотя `BookStatus` требует `paused` и «никогда не `failed`», потому что стоп резюмируем. Единственный путь, которым платформа СЕГОДНЯ узнаёт о потолке, — событие потока, которого нет; ветка под него построена и запинена (`TestACeilingHaltPausesTheRunWithItsReason`, `TestWhatTheUnitDidBecomesTheProductStatus`, случай «a ceiling halt survives any exit»). Закрывается приходом эмиттера (строка 103); ⚠ до тех пор экран покажет «ошибка» там, где верно «остановлено: лимиты» | open | сессия P4 (сверка контракта с кодом движка) | -| PD-114 | standards | minor | `internal/config/`, `deploy/` | **Конфигурация: 27 переменных окружения, НИ ОДНОГО флага у демона и никакой печати эффективной конфигурации при старте** (грепнуто). Выбор «только окружение» сам по себе мейнстрим (12-factor) и записан решением в доккомменте `config.go`; деплой использует systemd-нативные `EnvironmentFile=`+`LoadCredential=`. Ниже нормы другое: у оператора нет ни `-version`, ни «проверить конфиг и выйти», ни строки «вот что реально применилось» — не подхватившийся `EnvironmentFile` обнаруживается по поведению, а плоское пространство из 27 имён это та точка, где обычно переходят на файл. ⚠ Вопрос ВЛАДЕЛЬЦА (08.08), и он же нашёл этим вопросом реальный дефект в этом паке: дефолт «$/глава» лежал в двух местах (config клал ноль, разрешал вызыватель) — исправлено, дефолт резолвится только в `config`. **Ратифицировано 09.08 (оркестратор №15 по делегации владельца): «только окружение» ОСТАЁТСЯ; строка переформулирована в задачу — печать ЭФФЕКТИВНОЙ конфигурации при старте с редакцией секретов** (значение каждой настройки и откуда оно взялось: дефолт · переменная · файл; `*_FILE` печатается фактом наличия, не содержимым). Остаётся открытой до постройки | open (переформулирована ратификацией 09.08) | вопрос владельца 08.08 + сессия P4 | -| PD-115 | standards | minor | `docs/ENGINEERING_STANDARDS.md` §2 | **Внешняя версионированная базовая линия объявлена ровно для ОДНОЙ оси — безопасности** (ASVS 5.0 L2 + OWASP API Top-10 2023, с указанием глав). Отказоустойчивость, наблюдаемость и контракт-первичность описаны собственной прозой зоны без внешнего эталона, а конфигурация, релиз/откат, ёмкость и восстановление не описаны вовсе. Разница не теоретическая: PD-57 и PD-58 нашлись ИМЕННО сверкой кода с RFC 9700/9207 и NIST SP 800-63B — механизм работает там, где эталон есть, и не может сработать там, где его нет. Грепнуто на 08.08: метрик и трейсинга ноль (ни prometheus, ни otel, ни expvar, ни pprof), процедуры бэкапа/восстановления в `deploy/README.md` нет, SLO не заданы. Предложение зоны: §2 получает по эталону на ось (наблюдаемость, ops, конфигурация) — **направление РАТИФИЦИРОВАНО 09.08 (оркестратор №15 по делегации владельца); носитель работы — эта строка, исполнение — своими паками** | open (направление ратифицировано 09.08) | абстрактный вопрос владельца 08.08 + сессия P4 | | PD-116 | bug | minor | `internal/pgstore/runs.go` `RecordSpawn`, `internal/runs/spawn.go` | **Спавн попытки мог прийти ОДНОВРЕМЕННО из воркера очереди и из реконсилятора, и оба видели «не запущено».** Воркер получает прогон заданием, реконсилятор находит его неспавненным на своём проходе — обе ветки законны и обе читали `unit_name` до записи. Дальше их спасала только уникальность ИМЕНИ юнита у systemd: второй `systemd-run` падал с «unit already exists». Выживание по чужому правилу — не корректность, и оно перестаёт работать в день, когда именование поменяется (например, резюм получит суффикс). Найдено собственным ревью кода на конкурентность, до отчёта — **закрыто:** `RecordSpawn` стал compare-and-set (`where id = $1 and unit_name is null`) и возвращает, досталось ли право; проигравший НЕ стартует и это не ошибка. Пин — `runs.TestOnlyOneOfTwoConcurrentSpawnersStartsTheEngine` (восемь конкурентных спавнеров, ровно один юнит); посадка «убрать `and unit_name is null`» падает | fixed(P4, дерево сессии) | сессия P4 (самопроверка на гонки) | | PD-117 | bug | minor | `internal/httpapi/v0.go` `startRun` | **Потолок ниже минимума схемы отвечал `409`, а не `400`.** `RunRequest.ceiling_chapters` объявлен `minimum: 1`, и запрос с `0` или отрицательным — МАЛФОРМИРОВАННЫЙ; `409` же определён как «границы сдвинулись между чтением run-options и этим вызовом», поэтому клиент, получивший его, пере-читает run-options и повторяет запрос, который не может пройти НИКОГДА. Замерено ревью: `{"ceiling_chapters":0}` → 202 у хендлера и `409` после сервиса — **закрыто:** минимум схемы судится в хендлере, до сервиса. Пин — `TestACeilingBelowTheSchemaMinimumIsARejectedRequestAndNotAMovedBound` | fixed(P4, дерево сессии) | адверсариальное ревью (сверка со спекой, исполнением) | | PD-118 | bug | minor | `internal/pgstore/books.go` `ReadUsage`, `runs.go` `PauseRun` | **`Usage.paused_reason` был НЕДОСТИЖИМ через собственный путь паузы платформы.** `ReadUsage` требовал `finished_at is null`, а `PauseRun` — путь реконсилятора — ставит `finished_at` тем же запросом, что и паузу. Значит поле заполнялось только когда стоп пришёл событием потока (`sink.go`, `finished_at` не трогает) и молчало, когда паузу вызвала платформа. Замерено ревью на живом PG — **закрыто:** состояние читается по ПОСЛЕДНЕМУ прогону каждой книги (lateral), без условия на `finished_at`. Пин — `TestTheAccountReportsAPauseTheReconcilerCaused` | fixed(P4, дерево сессии) | адверсариальное ревью (исполнением) | | PD-119 | bug | minor | `internal/httpapi/v0.go` `getBook` | **Карточка книги несла ревизию ПРОГОНА, которая отстаёт от книжной.** Контракт: счётчик ОДИН на книгу и «каждое книго-скоупное чтение и id каждого кадра потока несут одно и то же число». `unit_done` двигает `books.revision` и `chapters.revision`, но не `runs.revision`, поэтому клиент, применивший кадр `id=2`, получал в карточке `0` и — по правилу самого контракта — обязан был чтение ОТБРОСИТЬ: карточка не обновлялась всю серию unit-done. Замерено ревью через настоящий `RunSink` (0→1→2 у книги при 0 у прогона) — **закрыто:** и `BookDetail.revision`, и `Run.revision` проецируются из счётчика КНИГИ. Пин — `TestTheCardsRevisionIsTheBooksAndNotTheRuns` | fixed(P4, дерево сессии) | адверсариальное ревью (исполнением) | | PD-120 | vuln | minor | `internal/pgstore/books.go` курсор пагинации | **Курсор из ЧУЖОЙ библиотеки принимался молча.** Контракт прямо возлагает отказ на СЕРВЕР («rejecting a cursor from a dead epoch is the SERVER's duty, MUST, answered 400»), потому что клиенту токен непрозрачен по построению. Курсор нёс только `(added_at, id)` и не нёс метки коллекции, поэтому токен, построенный на библиотеке другого аккаунта, отдавал окно СВОИХ книг вызывающего вместо `400`. Замерено ревью на живом PG (чужой курсор → `err=nil`, 3 строки). Утечки чужих данных нет — выборка всегда `owner_id = $1`, — но клиент получает не то окно и обнаружить это не может — **закрыто:** курсор несёт метку области (`sha256("library"+owner)`, первые 8 байт), чужая метка = `ErrBadCursor` → 400. Пин — `TestACursorFromAnotherLibraryIsRefused` (плюс проверка, что свой курсор по-прежнему работает) | fixed(P4, дерево сессии) | адверсариальное ревью (исполнением) | | PD-121 | bug | minor | `internal/httpapi/v0.go` `usageState` | **`/usage` говорил «exhausted» там, где прогон стартует.** Доля округляется ВНИЗ, поэтому $9 остатка от гранта $1000 дают `0%`, а состояние выводилось из доли: экран аккаунта показывал «ничего не осталось», пока `run-options` на той же секунде отдавал шкалу в 300 глав и прогон запускался. Замерено ревью — **закрыто:** «exhausted» — факт о балансе (`Usage.Spendable`), а не следствие округления; оба экрана отвечают из одного факта. Пины — `TestASmallRemainderIsLowAndNotExhausted`, `pgstore.TestASmallRemainderOfALargeGrantIsStillSpendable` | fixed(P4, дерево сессии) | адверсариальное ревью (исполнением) | -| PD-122 | bug | info | `internal/pgstore/books.go` `ListBooks` | **`Library.revision` НЕ монотонна: она выведена как `max(books.revision)` по книгам аккаунта и падает, когда удаляется книга, державшая максимум.** Контракт требует монотонности внутри области и предписывает клиенту ОТБРАСЫВАТЬ чтение с меньшей ревизией — то есть после такого удаления библиотека замирает, пока чей-нибудь книжный счётчик не перерастёт старый максимум. Замерено ревью: 10 → 0 после удаления книги. ⚠ Сегодня недостижимо через API: ручки удаления книги нет вовсе (`DeleteBook` есть в сторе, маршрута нет). Правильное решение — собственный счётчик области у аккаунта, который двигается на изменение состава и статусов. ⚠ Уточнено ревью доков 09.08: КОЛОНКА уже есть — `users.library_revision` из `00001_identity.sql:13`, и её не читает и не пишет ни один Go-путь (грепнуто), так что нужна не миграция, а пути записи и чтения; правка нескольких мест, поэтому она НЕ сделана в этом паке, а названа. ⚠ Дополнено дофиксом 09.08: ревизия не двигается и на ДОБАВЛЕНИИ книги — `AddBook` пишет новой книге `revision = 0` и счётчика области не трогает, а спека описывает ревизию библиотеки как «membership and statuses». Класс тот же и решение то же: собственный счётчик области. Гейт: закрыть ДО появления удаления книги или любого второго писателя состава | open | адверсариальное ревью (исполнением) | -| PD-123 | doc | info | `internal/pgstore/migrations/00009_runner.sql:11` | `runs.ceiling_chapters` имеет `default 0`, а контракт объявляет `Run.ceiling_chapters` `minimum: 1`. Сегодня недостижимо: единственный путь вставки — `StartRun`, и он отказывает на неположительном значении. Строка заведена как гейт: строка прогона, записанная мимо `StartRun` (миграция данных, правка оператором), спроецируется на провод нулём, которого схема клиента не допускает | open | адверсариальное ревью (чтение схемы) | | PD-124 | bug | **major, деньги** | `internal/runs/reconcile.go` расчёт | **Расчёт брал ПОЖИЗНЕННУЮ трату КНИГИ и выставлял её как трату прогона.** `committed_usd` из `status --json` движок считает как `SELECT COALESCE(SUM(committed_usd),0) FROM spend WHERE book_id = ?` (`backend/internal/store/ledger.go` в HEAD, «for a book across all days») — сумма по книге за всю историю. Значит каждый следующий прогон книги оплачивал заново всё, что она стоила раньше; перерасход ограничен холдом (`Settle` каппит), и в леджере он выглядит строкой «capped at the hold», то есть как перерасход ДВИЖКА, а не как арифметика платформы. Замерено двумя независимыми верификаторами на живом PG: прогоны по $1.00 и $0.50 списали $2.50; после того как пожизненная сумма книги перерастает потолок, каждый прогон стоит ровно свой потолок независимо от работы — **закрыто:** попытка записывает БАЗОВУЮ ЛИНИЮ книги перед стартом (`spend_baseline_micro_usd`, миграция 00010, читается `status --json` ДО создания юнита) и платит РАЗНИЦУ; базовая линия не прочиталась = попытка не стартует (платный прогон, который нельзя корректно выставить, хуже прогона, стартующего свипом позже). Пин — `runs.TestASecondRunOnABookIsChargedOnlyForWhatItSpent` | fixed(P4, дерево сессии) | адверсариальное ревью ×2, независимо, исполнением | | PD-125 | bug | **major, деньги** | `internal/runs/reconcile.go` `restart` | **Перезапуск при недоступном расчёте открывал ВТОРОЙ холд и терял первый навсегда.** `settle` законно ОТКЛАДЫВАЕТ (движка не спросить) и возвращает nil; `restart` читал это как успех, брал новый холд на остаток и уходил дальше, а старая резервация оставалась открытой — и не попадала ни в один список: `UnsettledRuns` фильтровал по ЗАВЕРШЁННОСТИ ПРОГОНА, а `ListLiveRuns` берёт только попытку с `ended_at is null`. Замерено: прогон с потолком $3.00 показал $6.00 зарезервированных и закончил с $3.00, навсегда снятыми с баланса, при нуле в списке несведённых — **закрыто:** перезапуск СПРАШИВАЕТ (`AttemptReservationOpen`) и откладывается, пока предыдущая попытка не сведена; `UnsettledRuns` теперь ключуется на ЗАВЕРШЁННОСТИ ПОПЫТКИ, поэтому брошенная резервация видна и при живом прогоне. Пины — `TestARestartIsDeferredWhileTheInterruptedAttemptIsUnsettled`, `TestAnInterruptedAttemptsHoldIsStillFoundWhileItsRunGoesOn` | fixed(P4, дерево сессии) | адверсариальное ревью ×2, независимо, исполнением | | PD-126 | bug | **major, деньги** | `internal/runs/spawn.go` | **Юнит, который НЕ удалось создать, съедал бюджет прогона по свипу за раз.** Право на спавн записывалось до `Runner.Start`, и при отказе `systemd-run` оставалась запись «юнит есть» без юнита и без маркера — то есть в точности форма прерванного прогона. Каждый свип перезапускал прогон: расчёт, новый холд, отказ спавна, снова. Замерено: шесть свипов — попытка 7 и $0.60 списано за движок, который ни разу не стартовал; при `SweepEvery=15s` весь потолок уходит за минуты — **закрыто:** неудавшийся `Start` СНИМАЕТ право (`ReleaseSpawnClaim`), и следующий свип повторяет ту же попытку вместо перезапуска прогона. Пин — `TestAUnitThatCannotBeCreatedDoesNotEatTheRunsBudget` (шесть свипов: попытка остаётся первой, баланс не двигается) | fixed(P4, дерево сессии) | адверсариальное ревью (исполнением) | | PD-127 | bug | **major** | `internal/runs/reconcile.go` `drainJournal` | **Одна нечитаемая строка журнала запирала прогон навсегда.** Тейл шёл ДО чтения маркера и возвращал ошибку из всей сверки, а карантинились только пропасть и конфликт payload; малформированная строка, строка длиннее буфера, подменённый файл и битый хендшейк возвращали жёсткую ошибку каждый свип. Замерено: пять свипов — статус `translating`, `finished_at` пуст, холд $3.00 держится, при том что маркер на диске и движок давно вышел — **закрыто:** ЛЮБАЯ неустранимая ошибка журнала = карантин ПРОЕКЦИИ, а жизненный цикл (маркер, живость, расчёт) продолжается; отмена контекста карантином не считается. Пин — `TestAnUnreadableJournalDoesNotStopTheRunFromFinishing` | fixed(P4, дерево сессии) | адверсариальное ревью (исполнением) | | PD-128 | bug | minor | `internal/runs/reconcile.go` грация спавна | **Грация мерилась от старта ПРОГОНА, а не попытки**, поэтому у перезапущенной попытки её не было вовсе: она наследует `started_at` многочасовой давности и признаётся потерянной, как только systemd не успел ответить. Замерено: через секунду после перезапуска — попытка 3 и три созданных юнита — **закрыто:** `run_attempts.started_at` читается отдельным полем и грация мерится от него. Пин — `TestAnAdmittedRunIsGivenTimeBeforeItIsPresumedLost` (снимок с часовым прогоном и пятисекундной попыткой) | fixed(P4, дерево сессии) | адверсариальное ревью (исполнением) | -| PD-129 | bug | minor | `internal/pgstore/sink.go`, `runs.go` | **Инверсия порядка блокировок между материализатором и финишером:** `RunSink` берёт `books … for update` и затем правит `runs`, а `FinishRun`/`PauseRun` правили `runs` и затем `books`. Два реконсилятора на одном прогоне (перекрытие поколений деплоя) дают взаимоблокировку в обе стороны — замерено ревью, `SQLSTATE 40P01` на обеих формулировках. Порчи нет (Postgres откатывает одну сторону), цена — провалившийся проход свипа и секунда детекта — ⚠ **ПЕРЕ-ДИСПОЗИЦИЯ 09.08: строка была закрыта ЛОЖНО.** Правка P4 привела к книге-первой только `FinishRun`/`PauseRun`; сам материализатор (`RunSink.Apply`) продолжал брать `run_attempts … for update` ПЕРВЫМ, а `RestartRun` — обновлять попытку до всего остального, и приёмка воспроизвела дедлок через реальные API (258 из 300 пар). Закрывающая формулировка описывала половину правки как целое. Действительно закрыто дофиксом — см. PD-145 | fixed(дофикс P4, дерево сессии; см. PD-145) | адверсариальное ревью (исполнением) | | PD-130 | bug | minor | `internal/ingest/tail.go` `readLine` | **Любая ошибка чтения превращалась в `io.EOF`,** то есть в «догнали, нового нет»: отказ диска читался бы как тишина, материализация вставала бы молча и ни один свип не сказал бы почему — **закрыто:** только настоящий EOF означает «догнали»; всё прочее возвращается ошибкой и уходит в карантин с причиной | fixed(P4, дерево сессии) | адверсариальное ревью (чтение кода) | | PD-131 | bug | minor | `internal/ingest/tail.go` | **Хендшейк не обязан был нести `seq 1`.** На этом транспорте `hello` ДВИГАЕТ курсор, поэтому `hello` с seq 0 оставлял курсор нулём, а первое настоящее событие отбрасывалось как его дубль; отрицательный seq уходил в ветку «уже применено». Пайп-декодер это требование имел всегда (`decoder.go`), файловый читатель — нет — **закрыто:** `seq != 1` у хендшейка = `ErrBadHandshake` | fixed(P4, дерево сессии) | адверсариальное ревью (чтение кода) | | PD-132 | bug | minor, деньги | `internal/runs/reconcile.go` `settle` | **Холд прогона, который так и не стартовал, не возвращался.** После введения базовой линии (PD-124) попытка без неё не сводилась вовсе, а попытка, которую никогда не спавнили, базовой линии и не имеет — её холд оставался зарезервированным навсегда. Найдено собственным тестом при починке PD-124 — **закрыто:** нет базовой линии И нет имени юнита ⇒ попытка не выполнялась, холд возвращается ЦЕЛИКОМ (`Release`); нет базовой линии, но юнит был ⇒ расчёт удерживается с ERROR-строкой, а не угадывается. Пин — `TestTheHoldOfARunThatNeverStartedComesBackWhole` | fixed(P4, дерево сессии) | самопроверка при починке PD-124 | @@ -145,13 +212,16 @@ | PD-134 | bug | minor | `internal/pgstore/runs.go`, `internal/runs/spawn.go` | **Пиннинг версии движка (строка 139) записывался и НИКОГДА не читался:** `run_attempts.engine_binary` не входил в выборку реконсилятора, а спавн и канал ремонта брали путь из ТЕКУЩЕГО конфига. Пин, который никто не читает, — это колонка, а не пин: резюм исполнял бы то, что выкатили сегодня, а `status --json` спрашивал бы о книге бинарь другой версии — **закрыто:** `EngineBinary` читается в `LiveRun` и используется и резюмом, и каналом ремонта; конфиг остаётся фолбэком только для ещё не спавненной попытки | fixed(P4, дерево сессии) | адверсариальное ревью (чтение кода) | | PD-135 | bug | minor | `internal/runs/reconcile.go` `restart` | Прерванный прогон без остатка бюджета помечался `paused` БЕЗ `paused_reason` (`FinishRun` его не трогает), тогда как контракт описывает `PausedReason` как причину паузы, и экрану сказать нечего — **закрыто:** используется `PauseRun`, который причину ставит | fixed(P4, дерево сессии) | адверсариальное ревью (чтение кода) | | PD-136 | doc | minor | `deploy/tmplatformd.service` | Юнит нёс ОБЕ диспозиции сразу: старый абзац подавал `ProtectHome=yes` как «нужную позу на сервере» прямо над строками, ставящими `tmpfs`+`BindPaths`, а рассуждение о ресурсных потолках всё ещё исходило из модели «дети живут в cgroup этого юнита», снятой D39.106. Оператор, читающий сверху вниз, получал противоречивые инструкции в одном файле — **закрыто:** снятые абзацы удалены, потолки прямо названы границей КОНТРОЛ-ПЛЕЙНА, прогоны — своим срезом | fixed(P4, дерево сессии) | адверсариальное ревью (чтение) | -| PD-137 | hardening | info | `deploy/tmplatformd.service` `[Unit]` | `BindPaths=/run/user/%U` требует существования каталога на старте юнита, а создаёт его logind вместе с пользовательским менеджером; в `[Unit]` упорядочения на него нет. На первом бутe это гонка, которую лечит `Restart=on-failure` (сервис поднимается со второй попытки). Строка не закрыта кодом намеренно: UID сервисного пользователя site-specific, поэтому `After=user@.service` добавляется установкой — инструкция вписана в шапку юнита | open | адверсариальное ревью (чтение) | | PD-138 | standards | info | `go.mod` | Прямые зависимости (`riverqueue/river`, `riverdriver/riverpgxv5`) стояли помеченными `// indirect`: `make check` тидинесс не проверяет, поэтому батарея этого не видела — **закрыто:** `go mod tidy`. ⚠ Строка оставлена как заявка: гейта на `go mod tidy` в батарее по-прежнему нет | fixed(P4, дерево сессии) | адверсариальное ревью | -| PD-139 | hardening | info | `internal/runs/reconcile.go` ERROR-строки | Путь каталога книги попадает в ERROR-логи внутри обёрнутых ошибок (`*fs.PathError` тейлера, обёртки спавна). PD-99 закрывал ДРУГОЕ — argv на INFO, — и та половина проверена (`grep` по логу сквозной пробы = 0). Здесь диспозиция иная и её надо принять осознанно: оператор чинит именно этот путь, а ERROR — не INFO. Заведено, чтобы это было решением, а не побочным эффектом; если норма зоны распространяется и на ERROR, путь придётся заменить на id прогона | open | адверсариальное ревью (чтение) | -| PD-140 | bug | info | `internal/runs/reconcile.go` `Stop`, `internal/httpapi/` | `Service.Stop` построен и не подключён ни к чему: `httpapi.Runs` даёт только `Bounds`/`Start`, у `tmplatformctl` команды остановки нет. То есть контрол-плейн не умеет остановить прогон, который сам же запустил. Ручки `POST /runs/{id}/stop` и `/resume` в список работ промта не входили, поэтому это НЕ девиация пака, а честно названный хвост: остановить прогон сегодня можно только `systemctl --user stop`. Гейт: закрыть вместе с ручками стопа/резюма | open | адверсариальное ревью (чтение) | -| PD-141 | bug | info | `internal/pgstore/runs.go` `PauseRun` | `PauseRun` возвращает nil, когда строка прогона уже завершена, поэтому вызывающий рапортует паузу, которой не произошло. Идемпотентность здесь нужна (свип повторяется), но молчаливая — нет: различить «поставил паузу» и «было уже поздно» вызывающий не может | open | адверсариальное ревью (чтение) | | PD-142 | standards | minor | вся зона, тесты | ⚠ **Заявление «22 новых пина, каждый проверен своей посадкой» СНЯТО дофиксом 09.08 как непроверяемое в этом объёме:** прогонов посадок было девять (9/10, затем 9/9), то есть «каждый из 22» ими не покрывался, а поимённого списка соответствия пин↔посадка сессия не вела. Проверено исполнением и названо поимённо другое: 33 посадки самопроверки пака и 24 посадки дофикса (список — журнал, раздел «Дофикс P4»). **Аудит силы пинов посадками (139 мутаций, четвёртый верификатор): 107 поймано, 32 пережили, из них 8 — не ослабления** (эквивалентный код либо страховка DDL-констрейнтом). Пережившие — не дефекты КОДА, а отсутствующие пины на свойства, часть которых объявлена закрытой; по правилу шапки этого файла такое свойство закрытым не считается. **Закрыто: написаны 22 новых пина**; про «каждый проверен собственной посадкой» — см. начало строки, заявление снято (прогонов посадок было девять: 9/10, потом 9/9 после исправления двух ошибочно сформулированных мутаций). Самые весомые: блокировка строки попытки под КОНКУРЕНЦИЕЙ (`TestConcurrentDeliveriesOfOneEventCountItOnce` — восемь горутин на одно событие; последовательная доставка поглощается одним high-water mark и посадку не ловила) · CSRF на КОНТРАКТНОЙ поверхности (`TestACrossSiteRequestCannotStartARun` — снятие CSRF из гарда `/v0` переживало всё, а это старт платного прогона с амбиентной кукой) · `RunSpent` считает только свой прогон и не считает открытые холды · обе ветки отложенного расчёта · `MarkSettled` одноразов · хендшейк второго движка отвергается · монотонность байтового хинта · пустой payload · ETA-ноль · черновой юнит не «сделан» · `paused` при нехватке баланса · `principal` падает ЗАКРЫТО · внутренний текст не течёт в `Problem`. ⚠ Одна посадка («убрать `and ended_at is null` из `RestartRun`») пережила и НЕ является ослаблением: `unique (run_id, attempt_no)` отвергает всех проигравших гонку, так что ровно один перезапуск проходит и без неё — записано, а не подчищено | fixed(P4, дерево сессии) | адверсариальное ревью (аудит посадками) | | PD-143 | bug | minor | `internal/runs/spawn.go` `spec`, `internal/pgstore/runs.go` `RestartRun` | **Пиннинг версии движка (строка 139) был закрыт НАПОЛОВИНУ: запиненный путь читался каналом ремонта, но НЕ исполнялся резюмом.** `spec()` брал `Cfg.EngineBinary`, а `RestartRun` не переносил `engine_binary` в новую попытку, поэтому перезапущенный прогон шёл на том бинаре, который выкачен СЕЙЧАС, — то есть перевод продолжала другая программа, и «резюм другой версией только явным флагом» не выполнялось. Найдено собственной пост-сверкой диффа с промтом (не ревью и не батареей: обе половины компилировались и все тесты были зелёными) — **закрыто:** новая попытка НАСЛЕДУЕТ `engine_binary` предыдущей, `spec()` исполняет запиненный путь, а переход на другую сборку требует явного `TM_PLATFORM_RESUME_MAY_CHANGE_ENGINE`. Пины — `runs.TestAResumeStaysOnTheEngineBuildTheRunStartedWith` и `TestAResumeMovesToANewEngineBuildOnlyWhenItIsAllowed`; обе посадки («`spec` берёт из конфига», «`RestartRun` не наследует») падают | fixed(P4, дерево сессии) | пост-сверка диффа с промтом | + +## Закрытые — дофикс P4 и ре-чек V2 + +| ID | Класс | Серьёзность | Где | Суть | Статус | Источник | +|---|---|---|---|---|---|---| +| PD-112 | standards | minor | `internal/httpapi/v0.go`, контракт 0.2.0 | **Реализация отдаёт статусы, которых спека у операций НЕ перечисляет — четыре класса, все проверены исполнением адверсариальным ревью:** `503` на старте прогона (деплой не может передать потолок/записать конец юнита) · `403` от CSRF-слоя на любом небезопасном запросе (спека описывает требование `X-TM-Client` в `securitySchemes`, но статуса ему не даёт) · `500` у любой операции при отказе стора (спека не перечисляет 5xx нигде) · `404` у `GET /usage` при отсутствующем аккаунте (спека даёт только 200/401; практически недостижимо — сессия ссылается на строку `users` внешним ключом). Ниже — исходная постановка по `503`. **Отказ `503` на старте прогона НЕ входит в перечисленные спекой статусы операции** (`400/401/404/409`). Он поставлен осознанно: когда деплой не может передать движку потолок (строка 145) или записать конец юнита, запрос ВАЛИДЕН, объект существует и состояния конфликта нет — то есть каждый из разрешённых кодов сообщил бы неправду. Правка спеки — не право зоны (правило промта: расхождение = вопрос оркестратору). Строка ждала решения владельца контракта — **закрыто РАТИФИКАЦИЕЙ (оркестратор №15, 09.08): `503` вносится в спеку правкой владельца контракта при лендинге; код зоны не меняется** | fixed(ратификация 09.08; правка спеки за оркестратором) | сессия P4 (самопроверка против спеки) | +| PD-129 | bug | minor | `internal/pgstore/sink.go`, `runs.go` | **Инверсия порядка блокировок между материализатором и финишером:** `RunSink` берёт `books … for update` и затем правит `runs`, а `FinishRun`/`PauseRun` правили `runs` и затем `books`. Два реконсилятора на одном прогоне (перекрытие поколений деплоя) дают взаимоблокировку в обе стороны — замерено ревью, `SQLSTATE 40P01` на обеих формулировках. Порчи нет (Postgres откатывает одну сторону), цена — провалившийся проход свипа и секунда детекта — ⚠ **ПЕРЕ-ДИСПОЗИЦИЯ 09.08: строка была закрыта ЛОЖНО.** Правка P4 привела к книге-первой только `FinishRun`/`PauseRun`; сам материализатор (`RunSink.Apply`) продолжал брать `run_attempts … for update` ПЕРВЫМ, а `RestartRun` — обновлять попытку до всего остального, и приёмка воспроизвела дедлок через реальные API (258 из 300 пар). Закрывающая формулировка описывала половину правки как целое. Действительно закрыто дофиксом — см. PD-145 | fixed(дофикс P4, дерево сессии; см. PD-145) | адверсариальное ревью (исполнением) | | PD-144 | bug | **major, деньги/шов** | `internal/runs/spawn.go`, `internal/ingest/resync.go` | **Движку передавался ПРИРОСТ там, где его флаг означает НАКОПЛЕННЫЙ книжный потолок.** `--ceiling-usd` переопределяет `ceilings.book_usd` и сравнивается с `committed + reserved` книги на КАЖДОЙ резервации (`backend/internal/store/ledger.go` `Reserve`, `backend/cmd/tmctl/invocation.go` — «It caps the book's CUMULATIVE committed+reserved spend, not this run's increment»). Значит второй прогон книги, у которой накоплено ≥ прироста, отвергается первой же резервацией: движок выходит кодом 1, платформа обязана назвать это `failed`, работа не сделана, ретраи идентичны. Приёмка доказала обе стороны исполнением; сквозная проба пака этого не видела, потому что её фейк кумулятив не моделировал — **закрыто:** `runs.meter.bookCap` = `committed + прирост` (ратифицировано 09.08 ре-чеком V2 после PD-158; `reserved` в сумму НЕ входит), обе величины читаются ОДНИМ вызовом `status --json` перед стартом (`bookMeter`), `reserved_usd` внесён в аллоулист УКАЗАТЕЛЕМ (отсутствие ≠ ноль, как у committed), рестарт получает свежий отсчёт по тому же пути, фактически ушедшее значение хранится (`run_attempts.ceiling_arg_micro_usd`, миграция 00011). Пины: `runs.TestTheSecondRunOfABookIsGivenTheCumulativeCapAndNotItsOwnIncrement` и `TestAResumeIsGivenACapComputedFromTheMeterAsItStandsNow` — оба через фейк `ceilingJudge`, который отвергает потолок ПО ПРАВИЛУ ДВИЖКА; `TestAnAttemptWhoseMeterCannotBeReadIsNotStartedAtAll` покрывает отсутствующий `reserved_usd`. Проба приёмки на этом дереве: `--ceiling-usd 6.000000` при committed книги $3 | fixed(дофикс P4, дерево сессии) | приёмка P4 (F1, двусторонним исполнением) | | PD-145 | bug | **major** | `internal/pgstore/sink.go` `Apply`, `runs.go` `RestartRun`, `internal/runs/reconcile.go` | **Инверсия блокировок из PD-129 была жива, а транзиентный сбой из-за неё уходил в КАРАНТИН.** `RunSink.Apply` брал `run_attempts … for update` первым, `RestartRun` правил попытку до всего остального, а `FinishRun`/`PauseRun` берут книгу первой — приёмка воспроизвела 258 дедлоков на 300 пар через реальные API. Усилитель хуже самого дедлока: `40P01` из `Apply` попадал в ветку «любая ошибка журнала = карантин», то есть проекция ЖИВОГО платного прогона слепла навсегда из-за блокировки, которая разрешилась сама — **закрыто:** порядок написан в одном месте и стал глобальным (`pgstore.lockBook`: books → runs → run_attempts → account_balances → reservations), книга блокируется первой в `Apply`, `RestartRun`, `StartRun` и `DeleteBook` (последний найден собственной сверкой всех транзакций пакета: цикла для него нет, но инвариант, у которого есть исключение, перестаёт быть инвариантом); классификация ошибки вынесена в `runs.quarantines`. ⚠ **Формулировка «карантин остаётся только логическим ошибкам» была НЕВЕРНА в части и исправлена ре-чеком V2:** первая редакция `IsTransient` знала только про дедлок и сериализацию, поэтому обрыв соединения с Postgres — то есть ШТАТНЫЙ рестарт управляемой базы (57P01/57P02/57P03, класс 08, сетевой сброс) — по-прежнему карантинил проекцию живого платного прогона НАВСЕГДА (пути снятия карантина в дереве нет). Доказано исполнением приёмкой. Теперь `IsTransient` покрывает класс 08, 57P0x, `pgconn.SafeToRetry` и любой `net.Error`; ошибки чтения файла — `*fs.PathError` и `net.Error` не удовлетворяют, поэтому битый журнал по-прежнему останавливает проекцию, как и должен. Кейсы внесены в таблицу пина. Пины: `pgstore.TestTheMaterializerTakesTheBookBeforeTheAttempt` и `TestARestartTakesTheBookBeforeTheAttempt` (порядок утверждается ПРЯМО — блокировка книги удерживается, операция обязана ждать её, а строка попытки обязана остаться свободной под `for update nowait`), `TestAMaterializerAndAReconcilerOnOneRunDoNotDeadlock` (конкурентный, 60×3), `runs.TestOnlyAJournalWeCannotReadStopsTheProjection` | fixed(дофикс P4, дерево сессии) | приёмка P4 (F2, исполнением) | | PD-146 | standards | **major (пин)** | `internal/runs/spawn.go` `bookMeter` | **Сердце PD-124 не было запинено: посадка «нечитаемый отсчёт → (0, nil)» пережила ПОЛНУЮ батарею.** С ней расчёт идёт против базовой линии 0, то есть прогон оплачивает всю пожизненную трату книги — ровно тот дефект, который PD-124 объявил закрытым. Отказ спавну был построен и не проверен ни одним тестом — **закрыто:** `TestAnAttemptWhoseMeterCannotBeReadIsNotStartedAtAll` — три формы нечитаемости (вызов упал · нет `committed_usd` · нет `reserved_usd`), и в каждой утверждается, что юнит не создан И попытка не заклеймлена (`unit_name` пуст), значит следующий свип её повторит; хвост теста показывает, что после починки движка та же попытка стартует | fixed(дофикс P4, дерево сессии) | приёмка P4 (F3, посадка) | @@ -160,23 +230,50 @@ | PD-149 | bug | minor | `internal/config/config.go` `loadRunner` | **Относительный `TM_PLATFORM_STATE_DIR` не абсолютизировался, а маркер пишется и читается из РАЗНЫХ рабочих каталогов:** ExecStopPost исполняется юнитом, у которого `WorkingDirectory` — каталог книги, а демон читает от своего cwd. Конец прогона становится невидим, реконсилятор перезапускает прогон бесконечно — **закрыто:** `filepath.IsAbs` на буте, отказ с именем переменной; пин `config.TestARelativeStateDirectoryIsRefusedAtBoot` | fixed(дофикс P4, дерево сессии) | приёмка P4 (F6) | | PD-150 | standards | minor | `internal/pgstore/sink.go` `ApplyStatus` | **«Метка давности» ре-синка была обещана промтом («честно, с меткой давности») и не построена:** `now` в `ApplyStatus` не использовался, поля свежести не было, и у читателя замершей проекции карантинной попытки не было ничего, что сказало бы, насколько старые цифры он видит — **закрыто:** `runs.last_resync_at` (миграция 00012) пишется КАЖДЫМ ре-синком; пин `pgstore.TestAResyncRecordsWhenItWasTaken` (нет метки до первого · метка равна времени вызова · вторая переписывает первую). ⚠ Названная девиация: на провод метка НЕ выходит — в контракте v0 у прогона поля свежести нет; читается оператором и той ручкой, которая появится вместе с полем | fixed(дофикс P4, дерево сессии; половина «на провод» — за контрактом) | приёмка P4 (F7) | | PD-151 | doc | minor | `docs/platform-PROGRESS.md` раздел «Сессия P4» | **Числа отчёта расходились с истиной, а одно заявление было внутренне противоречиво:** тело журнала давало «105 → 203, +98» (истина на момент приёмки — 242/+137/−0), «36 новых строк = 25+10» (истина — 26 закрыто + 10 открыто), и «22 новых пина, каждый проверен своей посадкой (9/10, затем 9/9)» — 22 не покрываются девятью прогонами — **закрыто:** числа пересчитаны ИСПОЛНЕНИЕМ и приведены с командой (`105 → 261`, удалённых 0, добавленных 156 — итог дофикса с ре-чеком V2); арифметика 36 = 26 + 10 исправлена; заявление «каждый» снято (см. PD-142). Шапка журнала была права и не тронута | fixed(дофикс P4, дерево сессии) | приёмка P4 (F8) | -| PD-152 | bug | info | `internal/runs/reconcile.go` `outcome` | **`stopped` для остановки, которую мы попросили, на реальном движке недостижим:** `tmctl` ЛОВИТ SIGTERM и выходит кодом 1, поэтому ветка «не вышел сам + `$SERVICE_RESULT=success`» срабатывает только для процесса, умершего ОТ сигнала. Проба пака показала `stopped` на фейке, который именно так и умирал. Следствие: пользовательский стоп приедет как `failed`. ⚠ **Дофикс 09.08 расширил строку: то же самое ломает ШТАТНУЮ ПЕРЕЗАГРУЗКУ.** При ребуте пользовательский менеджер останавливает юниты корректно, `ExecStopPost` ОТРАБАТЫВАЕТ и маркер пишется — ревьюер снял живьём на этом хосте (транзиентный юнит, процесс ловит TERM и выходит 1): `RESULT=exit-code CODE=exited STATUS=1`. То есть на буте свип видит маркер и закрывает все живые прогоны как `failed` вместо перезапуска, а путь строки 138 покрывает только потерю питания (нет маркера) — и собственный тест `TestARunInterruptedByARebootComesBackWithTheBudgetItHasLeft` моделирует именно её. Закрывается в паке ручек стопа — попытка помечается «stop requested» до сигнала — и приходом различимого кода выхода graceful-stop у движка (строка бэклога движку, заводит оркестратор); платформенной догадки здесь быть не должно | open | приёмка P4 (N1) | -| PD-153 | bug | info | `internal/runs/reconcile.go` `spawnGrace` | **Грация спавна мерится от `run_attempts.started_at`, а не от момента заявки права на спавн:** окно между `RecordSpawn` и возвратом `systemd-run` не покрыто, и второй инстанс платформы, у которого грация уже истекла, может решить, что попытка потеряна, и перезапустить прогон, который вот-вот стартует. Дёшево закрывается временем заявки, записываемым в `RecordSpawn`, и грацией от него | open | приёмка P4 (N2) | -| PD-154 | bug | info | `internal/runs/reconcile.go` `settle` | **`runs.settled_at` может остаться NULL между `Settle` и `MarkSettled`:** это два вызова, и падение между ними оставляет прогон с закрытой резервацией и без отметки. Потребителей у отметки сегодня нет (рабочий список расчёта построен на открытой резервации, а не на ней), деньги целы и второй расчёт отвергается самой резервацией. ⚠ Дофикс 09.08 добавил вторую половину той же строки: `settle` прерванной попытки ЖИВОГО прогона (путь `UnsettledRuns`) ставит `settled_at` прогону, который ещё идёт. Потребителей у колонки по-прежнему нет, дрейф только операторский. Заведено как известность, а не как долг: закрывается вместе с эскроу (строка 136) | open | приёмка P4 (N3) | | PD-155 | doc | info | `deploy/README.md` | **Смена `TM_PLATFORM_STATE_DIR` осиротляет exit-маркеры идущих прогонов:** маркер пишется по пути, вычисленному при спавне, а читается по пути из текущей конфигурации, поэтому после смены каталога конец прогона невидим и прогон перезапускается — **закрыто:** строка в `deploy/README.md` — менять каталог только при отсутствии живых прогонов | fixed(дофикс P4, дерево сессии) | приёмка P4 (N4) | | PD-156 | bug | info | `internal/runs/spawn.go` `spawnAttempt` | **Отказ ДЕПЛОЯ проверялся после вызова движка.** Дофикс перенёс чтение денежного отсчёта в начало спавна и тем поставил его ПЕРЕД дешёвыми отказами «нечем записать конец юнита» / «нечем передать потолок»: инстанс с неполной конфигурацией платил бы секундами CPU движка (`status --json` пере-нарезает исходник, строка 100) за каждый прогон на каждом свипе, чтобы прийти к ответу, зависящему только от конфигурации — **закрыто:** `runs.runnable()` вызывается первым в `spawnAttempt`, `spec` и `Start`; пин `TestAMisconfiguredDeploymentIsRefusedWithoutAskingTheEngine` (посадка «убрать ранний отказ» падает) | fixed(дофикс P4, дерево сессии) | собственная сверка диффа дофикса | -| PD-157 | bug | minor | `internal/runs/spawn.go`, `cmd/tmplatformctl/runs.go` `book add` | **ДНЕВНОЙ потолок книги `--ceiling-usd` не перекрывает, а платформа его не видит и не задаёт.** Движок требует хотя бы один из `book_usd`/`day_usd` (`backend/internal/config/book.go:250`, Р7), флаг переопределяет только книжный (D39.122 прямо: «День-потолок не перекрывается»), а `book.yaml` пишет ОПЕРАТОР — платформа его не правит (D39.110 §2b) и в `book add` только проверяет наличие файла. Значит книга с низким `day_usd` останавливает прогон на лимите, которого платформа не выбирала: движок выходит кодом 1 (тот же путь, что у PD-113), прогон приезжает `failed`, а деньги пользователя целы и он не понимает, почему. В `status --json` дневной фигуры нет вовсе (есть `book_ceiling_usd`/`ceiling_pct`), поэтому даже диагностировать это платформа сегодня не может. Заведено, не построено: закрывать — либо проверкой `day_usd` при заведении книги, либо словом контракта о том, кто владеет потолками `book.yaml` у книг под платформой | open | собственная сверка шва при F1 (чтение движка + D39.122) | | PD-158 | bug | minor, деньги | `internal/runs/spawn.go` `meter.bookCap` | **Формула потолка из пинга ратификации (`committed + reserved (из status --json) + прирост`; сам D39.122 §2в ратифицирует ОБЯЗАННОСТЬ платформы пересчитывать «прирост → абсолют», буквальной формулы в решении нет — овер-атрибуцию поправило ревью доков) даёт прогону запас БОЛЬШЕ его холда, когда предыдущий процесс умер с незакрытой резервацией.** Найдено сверкой формулы с кодом движка: `store.Open` — путь ЗАПИСИ, которым идёт каждый `translate` — выполняет `recoverReservations` и обнуляет `reserved_usd` книги ДО первой судимой резервации (`backend/internal/store/store.go:88` и `:214`); `tmctl status` читает read-only и этот проход намеренно не делает (`store.go:108` говорит об этом прямо). Значит цифра, которую видит платформа, — ОСТАТОК мёртвого процесса, и к моменту сравнения её уже нет: движок остановится позже на её величину, расчёт упрётся в потолок холда, а леджер запишет «capped at the hold», как будто перерасходовал движок. Величина ограничена размером остатка (обычно одна оценка вызова), но путь достижим на каждом резюме после падения. **Сделано: `bookCap` считает `committed + прирост`** — отклонение в консервативную сторону (более узкий потолок может только остановить прогон раньше, перерасхода не даёт), названо в коде и запинено `TestAResumeIsGivenACapComputedFromTheMeterAsItStandsNow` (второе утверждение: остаток НЕ раздул потолок). Сама цифра по-прежнему читается и обязательна (отсутствие ≠ ноль) — она и есть доказательство, что отклонение безопасно: на спавне другого писателя нет (эксклюзивный лок + один живой прогон на книгу), значит любой `reserved` — остаток. ⚠ **Строка остаётся ОТКРЫТОЙ как вопрос:** отклонение от ратифицированной формулы — не право зоны, нужен ответ оркестратора (принять формулу в виде `committed + прирост` либо назвать другой разбор) **ЗАКРЫТО РАТИФИКАЦИЕЙ (оркестратор №15, 09.08, ре-чек V2):** принята формула `committed + прирост`; аргументация подтверждена оркестратором исполнением обеих формул против гейта движка — ратифицированная переплачивала запасом ровно на leftover-reserved | fixed(ратификация 09.08) | собственная сверка формулы с кодом движка при F1 | | PD-159 | bug | **major, деньги** | `internal/runs/reconcile.go` `settle`, `internal/pgstore/runs.go` `SpendBound` | **Отложенный расчёт прогона оплачивал работу СЛЕДУЮЩЕГО прогона той же книги, и тот платил за неё ещё раз.** Расчёт читает пожизненный счётчик КНИГИ в момент ПОВТОРА, а откладываться он вправе (движка не спросить). Завершённый-но-нерассчитанный прогон при этом не мешает новому: `HasLiveRun` смотрит только на `finished_at`. Замерено: прогон, стоивший $0.10, списан на $2.10 — своя трата плюс всё, что успел потратить преемник, — после чего преемник заплатил ту же сумму снова; переплата ограничена холдом. Найдено ДВУМЯ независимыми верификаторами самопроверки, каждый воспроизвёл исполнением, и третий раз воспроизведено мной перед починкой — **закрыто:** `SpendBound` — наименьшая базовая линия среди попыток этой книги, стартовавших ПОЗЖЕ; она снята до того, как та попытка что-либо добавила, и после того, как эта остановилась, поэтому является точной верхней границей. Расчёт берёт минимум из неё и текущего счётчика. Пин: `TestADeferredSettlementIsNotChargedForTheNextRunOfTheSameBook` (первый платит $0.10, второй — свои $2.00, баланс и леджер сходятся) | fixed(дофикс P4, дерево сессии) | самопроверка дофикса (два верификатора, независимо, исполнением) | | PD-160 | standards | minor (пин) | `internal/runs/reconcile.go` `drainJournal` | **Проводка «транзиентный сбой НЕ карантинит» не пинилась: пин стоял на чистой функции `quarantines`, а удаление ветки, которая её ВЫЗЫВАЕТ, переживало батарею.** Регрессия этой формы тихо карантинит проекцию живого платящего прогона — то есть ровно тот дефект, который F2 объявил закрытым — **закрыто:** `TestADeadlockDoesNotStopTheProjection` гонит НАСТОЯЩИЙ дедлок через весь путь (транзакция берёт строки в обратном порядке, Postgres рвёт цикл; раунд, где жертвой стала наша сторона, и есть предмет теста) и утверждает на КАЖДОМ раунде, что попытка не в карантине. Посадка «убрать ветку» падает за 1.9 с | fixed(дофикс P4, дерево сессии) | самопроверка дофикса (посадка) | | PD-161 | bug | minor, деньги | `internal/pgstore/runs.go` `RecordSpawn` | **Повторная заявка права на спавн ПЕРЕЗАПИСЫВАЛА базовую линию.** «Не удалось создать юнит» — не то же, что «юнит не создан»: `systemd-run`, убитый по таймауту ПОСЛЕ подачи запроса, рапортует ошибку и оставляет движок работать. Право отдаётся обратно (`ReleaseSpawnClaim`), следующий свип заявляет его снова и кладёт в базовую линию счётчик, который этот же движок двигает, — попытка потом оплачивает разницу от цифры, уже включающей её собственную работу (недоплата, которую никто не ищет) — **закрыто:** повторная заявка сохраняет и базовую линию, и записанный аргумент потолка (`coalesce` / `case when`), там где юнит действительно не создан значения совпадают. ⚠ **Ре-чек V2 показал, что этим строка закрыта НАПОЛОВИНУ:** в БД значения сохранялись, а движку на ретрае уходил потолок, пересчитанный по СВЕЖЕМУ счётчику — то есть включающий трату собственного «призрака», — и форензик-колонка 00011 на этом пути лгала (замерено приёмкой: handed 3.400000 против stored 3.000000). Закрыто по-настоящему: на ретрае (`SpendBaseline != nil && CeilingArg > 0`) спавн ПЕРЕДАЁТ сохранённый аргумент, а не пересчитанный. Пин: `TestAReclaimedAttemptKeepsTheBaselineItFirstRecorded` — теперь утверждает и базовую линию, и равенство handed == stored | fixed(дофикс P4, дерево сессии) | самопроверка дофикса (ревью вне карты, чтением) | -| PD-162 | bug | minor | `internal/runs/spawn.go` `journalSize`, `internal/runs/reconcile.go` | **Книга, чей каталог удалён или перемещён, принимает прогон и заклинивает его навсегда с открытым холдом.** `journalSize` мапит ENOENT в «ноль, ошибки нет» (законно для первого прогона), поэтому `Start` отдаёт 202 и берёт холд; дальше `bookMeter` вечно падает (спавн отказывает), либо расчёт вечно откладывается — ни один путь не приходит к терминальному состоянию: прогон вечно `translating`, деньги вечно в холде, пользователю видно только «идёт». Дизайн «холд лучше догадки» осознан (строка 136), но отсутствие И валидации каталога на старте, И эскалации после N неудач — дыра. Закрывать вместе с эскроу/`uncertain` либо проверкой каталога при допуске. ⚠ Ре-чек V2 расширил КЛАСС строки: обязательность `reserved_usd` даёт тот же клин без всякого удаления каталога — прогон, запиненный к СТАРОЙ сборке движка (строка 139), у которой поля ещё нет, вечно отказывает спавну с открытым холдом. Лечится тем же терминальным состоянием после N неудач; отказ сам по себе верен (без цифры потолок считать нечем) | open | самопроверка дофикса (ревью вне карты) | -| PD-163 | bug | info | `internal/pgstore/books.go` `ListBooks`, `GetBook` | **Ревизия области читается ВТОРЫМ запросом после страницы,** поэтому под конкурентной материализацией она новее строк: клиент, соблюдающий контрактное «отбрасывай чтение с меньшей ревизией», навсегда потеряет кадры между двумя запросами. Потребителя (SSE) сегодня нет; закрывать до первого потока — читать страницу и ревизию одной транзакцией | open | самопроверка дофикса (ревью вне карты) | -| PD-164 | bug | info | `internal/runner/marker.go`, `internal/runs/reconcile.go` | **Маркер, который существует, но не разбирается, вечно валит реконсиляцию своего прогона:** аналога карантина у этого пути нет, и ошибка чтения маркера возвращается наверх на каждом свипе. Запись атомарна (temp+fsync+rename), так что нужен внешний фактор (правка оператором, битый том). Закрывать — так же, как журнал: неисправимая ошибка маркера должна вести к терминальному состоянию с причиной, а не к вечному повтору | open | самопроверка дофикса (ревью вне карты) | -| PD-165 | hardening | info | `cmd/tmplatformd/runner.go` `markerArgv`, `internal/runner/runner.go` `quoteArgv` | **Относительный `TM_PLATFORM_CTL_BIN` проходит `os.Stat`, но systemd требует АБСОЛЮТНЫЙ путь в `ExecStopPost`** — каждый старт падает, и виден только цикл заявка-откат. Тот же класс: `quoteArgv` не экранирует `$` (подстановка переменных systemd в Exec-строках), поэтому каталог состояния с таким символом молча ломает командную строку маркера. ⚠ `%` проверен и БЕЗОПАСЕН — спецификаторы в значениях `--property=` не раскрываются (замер §17); `$` в этой сессии исполнением не проверялся. Лечится тем же `filepath.IsAbs`, что и `StateDir` (PD-149), плюс отказ на подозрительных символах | open | самопроверка дофикса (ревью вне карты) | -| PD-166 | bug | info | `internal/ingest/tail.go`, `internal/pgstore/sink.go` `Begin` | **`chunker_version` из хендшейка теряется навсегда,** если краш пришёлся между двумя стейтментами `Begin` (привязка `engine_run_id` и запись версии — два отдельных автокоммита): при повторном чтении своего же `hello` тейлер видит, что поток уже привязан, и `Begin` больше не зовёт. Сегодня поле никем не читается (нужно для строки 100), поэтому info; закрывать — одной транзакцией в `Begin` | open | самопроверка дофикса (ревью вне карты) | | PD-167 | doc | info | `internal/pgstore/migrations/00009_runner.sql:37-39` | Комментарий DDL обещает «хинт, не ведущий к `seq = last_seq + 1`, отбрасывается, и файл перечитывается с начала» — код так не делает: пропасть ведёт к карантину проекции и переходу на ре-синк. Расхождение док↔код, поведение верное — **закрыто ре-чеком V2:** комментарий приведён к тому, что делает тейлер. ⚠ Ре-чек назвал эту строку «застывшим комментарием миграции 00011»; предмет строки — комментарий 00009, а 00011 нёс отменённую формулу потолка и исправлен вместе с ней (обе миграции этим паком и написаны, нигде не применялись, поэтому их отпечатки в `migrations.sha256` обновлены с явной причиной в шапке файла) | fixed(ратификация + дофикс V2, дерево сессии) | самопроверка дофикса (ревью вне карты) | -| PD-168 | bug | minor, деньги | `internal/runs/reconcile.go` `restart` | **Бюджет перезапуска пересчитывается по ТЕКУЩЕЙ ставке, а не по той, под которую брался холд:** `s.Pricing.Ceiling(l.CeilingChapters)` читает конфигурацию нынешнего деплоя. Смена `TM_PLATFORM_USD_PER_CHAPTER` между допуском и перезапуском ломает обе стороны — вверх: резервируется больше, чем пользователь видел на шкале (нарушение «явного согласия на оплату»); вниз: остаток уходит в минус и прогон ошибочно встаёт `paused/credit_exhausted`. Замерено верификатором: при удвоении ставки перезапуск зарезервировал $5.50 вместо $2.50. Исходная сумма восстановима без пересчёта — она лежит в холде первой попытки (`reservations.ceiling_micro_usd` / `run_attempts.ceiling_micro_usd`) | open | самопроверка дофикса (два верификатора, один исполнением) | -| PD-169 | hardening | info | `cmd/tmplatformd/runner.go` свип | **Свип имеет один бюджет времени (2 минуты) на ВСЕ прогоны, а каждый спавн/расчёт стоит вызова `tmctl status` (~1.5 с CPU и больше).** Десяток прогонов, чьи журналы нечитаемы, упирается в таймаут, и хвост списка (`order by started_at` — всегда один и тот же порядок) голодает неограниченно долго. Наблюдаемости, которая это показала бы, нет вовсе (П-11). Закрывать — бюджетом НА ПРОГОН плюс метрикой длительности свипа | open | самопроверка дофикса (ревью вне карты) | -| PD-170 | hardening | info | `internal/pgstore/credits.go` `Settle` | `Settle` отбрасывает флаг `applied` строки расчёта, тогда как `releaseHold` на соседней строке из того же флага делает `ErrReleaseKeySpent` (PD-97). Недостижимо без правки леджера в обход кода — резервация должна быть открыта, чтобы дойти сюда, — но асимметрия в денежном пути стоит строки: либо симметричный отказ, либо явная причина, почему здесь он не нужен | open | самопроверка дофикса (ревью вне карты) | | PD-171 | bug | minor, деньги | `internal/runs/reconcile.go` `settle` | **Счётчик книги НИЖЕ собственной базовой линии попытки списывал $0 молча.** Это вырожденный случай — БД проекта подменили или восстановили из копии, — и клампить в ноль правильно (платить аккаунту за подмену файла код решать не вправе), но молчать нельзя: расчёт в ноль обнаруживался бы только по балансу — **закрыто:** WARN (не INFO: предмет — деньги) с фактом и без цифр (D39.84); пин `TestAMeterThatWentBackwardsSettlesAtNothingAndSaysSo` проверяет и отсутствие списания, и наличие строки, и что цифры в неё не попали | fixed(дофикс V2, дерево сессии) | ре-чек V2 (оркестратор №15) | + +## Закрытые — третий раунд P5 (ре-чек оркестратора, 14.08) + +| ID | Класс | Вес | Где | Что и чем закрыто | Статус | Кем найдено | +|---|---|---|---|---|---|---| +| PD-197 | standards | **high** | `Makefile` `tools-check`, `go.mod` | **Гейт тулчейна не гейтил, а три дока утверждали обратное.** Подъём floor 1.26.5 → 1.26.6 (пять адвизори stdlib) был сделан переменной `GO_MIN_VERSION`, которую читало только сообщение об ошибке, тогда как проверкой оставался регекс `go1\.26\.([5-9]|[0-9]{2,})` — он принимал ровно ту 1.26.5, ради отказа от которой floor и поднимали, и ставил 1.26.10 ниже 1.26.9. Клейм «хост на 1.26.5 получит отказ» стоял в журнале ×2, `STACK_DECISIONS` и комменте Makefile — **закрыто:** цель `version-check` СРАВНИВАЕТ версии (`sort -V`, пререлизы `rc`/`devel` отвергаются отдельно) и берёт версию из переменной, чтобы её судили версиями, которых на хосте нет; `go.mod` получил `toolchain go1.26.6` — его читает всякая сборка, мимо make тоже (`GOTOOLCHAIN=auto` скачает, `=local` остановится). Пины `gates.TestTheToolchainGateComparesVersionsRatherThanMatchingThem` (таблица из 11 версий) и `gates.TestGoModPinsTheSameToolchainTheBatteryDemands`, обе посадки падают; три клейма переписаны на описание механизма | fixed(третий раунд, дерево сессии) | ре-чек оркестратора (FP5-9) | +| PD-198 | doc | info | `internal/pgstore/runs.go` `PauseRun`, `internal/books/parse.go` | **Комментарии описывали до-фиксное поведение — в том числе на пути, который удаляет файл пользователя.** `PauseRun` обещал проверку стопа «в том же стейтменте» (стоит отдельный `select … for update` в той же транзакции); `parse.go` объявлял отказ источника «терминальным с первого ответа», хотя дофикс провёл КАЖДЫЙ ответ движка через бюджет попыток — **закрыто:** оба текста приведены к коду; правок поведения не потребовалось | fixed(третий раунд, дерево сессии) | ре-чек оркестратора (хвосты а, б) | + +## Закрытые — дофикс-2 P5 (кросс-семейное ревью дофикса, 13.08) + +| ID | Класс | Вес | Где | Что и чем закрыто | Статус | Кем найдено | +|---|---|---|---|---|---|---| +| PD-192 | bug | **medium-high** | `internal/books/parse.go` `manifest`, `Parse` | **Пропавший КОРЕНЬ хранилища читался как вина каждой книги.** `os.Stat(workdir)` даёт ENOENT и на снесённом каталоге книги, и на несмонтированном `BooksDir`; первое терминально по устройству (FP5-3), значит размонтированный том заставлял ОДИН проход свипа терминально отклонить ВСЕ книги в интейке с `source_unreadable` — причиной, которая винит файл пользователя и не имеет обратного хода (PD-175). Путь создан моим же фиксом FP5-3 — **закрыто:** `ErrStorageGone` отличён от `ErrDirectoryGone` (корень спрашивается прежде, чем винить книгу), причина `storage_unavailable` не терминальна, бюджета не тратит и на книге не хранится; предикат `waitsForTheDeployment` собрал оба «ждущих деплой» случая. Пин `books.TestAVanishedStorageRootIsNotEveryBooksFault`, посадка падает ⚠ **Дополнено ре-чеком (FP5-10): первая редакция закрывала не тот сценарий.** Гард спрашивал `Stat(BooksDir)`, а том, смонтированный РОВНО в `BooksDir`, оставляет после размонтирования пустой mountpoint — `Stat` успешен, и все книги снова терминально отклонялись; бут безусловным `MkdirAll` пересоздавал корень и маскировал пропажу. Теперь решение принимается по СЕНТИНЕЛУ провижининга `.tmplatform-books`, который пишет только первая загрузка (`markStorage`, `O_EXCL`) и не пишет бут: ни unmount, ни `MkdirAll` его не подделывают. Пин `books.TestAnUnmountedVolumeLooksLikeAnEmptyRootAndStillIsNotTheBooksFault`; первая редакция ПИНА посадку пережила (без сентинела ждёт всё) — добавлено утверждение, что загрузка сентинел пишет, иначе терялась терминальность крэш-окна FP5-3 | fixed(третий раунд, дерево сессии) | кросс-семейное ревью дофикса (Fable 5, линза интейка) | +| PD-193 | bug | **medium, деньги** | `internal/pgstore/runs.go` `PauseRun` | **Третий закрывающий путь без гарда живой попытки** (после PD-181 и FP5-2): пауза не проверяла, что закрываемая попытка ещё жива и принадлежит этому прогону (апдейт попытки шёл даже без `run_id`). Проход старого поколения при перекрывающемся деплое паузит прогон, уже рестартованный в живую попытку 2: прогон отвечает `paused/credit_exhausted` под тратящим движком, а холд попытки 2 не виден ни в `ListLiveRuns`, ни в `UnsettledRuns` — **закрыто:** тот же `exists (a.id = $4 and a.run_id = runs.id and a.ended_at is null)`, что у соседей, плюс `run_id` в апдейте попытки. Пин `pgstore.TestAPauseFromAnOldSnapshotDoesNotCloseARunOverALiveAttempt`, посадка падает | fixed(дофикс-2, дерево сессии) | кросс-семейное ревью дофикса (Fable 5, линза денег), воспроизведено дважды | +| PD-194 | bug | minor | `internal/pgstore/sink.go` `Begin` | **Синк законченной попытки усыновлял хендшейк следующей.** Стоп ДО спавна оставляет попытку закрытой и без `engine_run_id` — форма, невозможная до P5; устаревший материализатор биндил на неё `engine_run_id` попытки-заместителя и материализовал тот же журнал второй раз, удваивая счётчики глав и юнитов (деньги не двигались) — **закрыто:** бинд отказан для законченной попытки. Пин `pgstore.TestAMaterializerOfAnEndedAttemptDoesNotAdoptTheNextAttemptsEngine`, посадка падает | fixed(дофикс-2, дерево сессии) | кросс-семейное ревью дофикса (Fable 5, линза денег) | +| PD-195 | bug | minor, деньги | `internal/runs/reconcile.go` `settle` | **Возврат холда «прогона, который не запускался», ждал ответа движка.** Ветка «юнита не было — вернуть холд целиком» стояла ПОСЛЕ обязательного `tmctl status`, а хост, производящий эту ситуацию, — ровно тот, где движок не запускается: `Status` падает на каждом проходе, и деньги остаются зарезервированными навсегда (видимыми, но запертыми) — **закрыто:** ветка идёт до вызова движка; `ReleaseUnspawned` перепроверяет строку под замком, поэтому снапшот, который успели заспавнить, отвергается там. Пин `runs.TestTheHoldOfARunThatNeverStartedComesBackOnAHostWhoseEngineCannotAnswer`, посадка падает | fixed(дофикс-2, дерево сессии) | кросс-семейное ревью дофикса (гипотеза), подтверждено зоной исполнением | + +## Закрытые — эра P5 (загрузка книги · стоп и резюм · наблюдаемость) + +| ID | Класс | Серьёзность | Где | Суть | Статус | Источник | +|---|---|---|---|---|---|---| +| PD-72 | hardening | info | `internal/httpapi/server.go:88` | **Отсутствие ОБЩЕГО лимита тела над маршрутами не наблюдаемо ничем.** Пер-маршрутность (PD-35/PD-53) держится на том, что вложенный `MaxBytesReader` только УЖЕСТОЧАЕТ: это запинено `TestBodyCapIsPerRouteBecauseNestingOnlyTightens`. Но возврат внешнего слоя в `New` батарею переживает, потому что ни один маршрут не просит потолок БОЛЬШЕ дефолтного — наблюдаемым дефект станет ровно тогда, когда появится загрузка книги. Строка заведена, чтобы это не выяснилось молча: тест обязан приехать ВМЕСТЕ с маршрутом загрузки — **закрыто:** маршрут загрузки приехал ВМЕСТЕ со своим потолком и своим тестом. `POST /v0/books` регистрируется `guard(d.Upload.MaxBytes, …)`, пин — `httpapi.TestAnUploadLargerThanTheRouteAllowsIsRefusedAsTooLarge` (413 + problem+json на теле выше потолка И 201 на теле ниже него: потолок, который отвергает всё, не потолок), плюс `TestTheUploadLimitBelongsToTheUploadRouteAlone` — заявка на прогон с телом выше ДЕФОЛТНОГО потолка отвергается, то есть поднятие лимита одного маршрута не подняло его для остальных. Посадка «вернуть `DefaultMaxBody` на маршрут загрузки» падает | fixed(P5, дерево сессии) | ревью P2 (линза doc-vs-code) | +| PD-114 | standards | minor | `internal/config/`, `deploy/` | **Конфигурация: 27 переменных окружения, НИ ОДНОГО флага у демона и никакой печати эффективной конфигурации при старте** (грепнуто). Выбор «только окружение» сам по себе мейнстрим (12-factor) и записан решением в доккомменте `config.go`; деплой использует systemd-нативные `EnvironmentFile=`+`LoadCredential=`. Ниже нормы другое: у оператора нет ни `-version`, ни «проверить конфиг и выйти», ни строки «вот что реально применилось» — не подхватившийся `EnvironmentFile` обнаруживается по поведению, а плоское пространство из 27 имён это та точка, где обычно переходят на файл. ⚠ Вопрос ВЛАДЕЛЬЦА (08.08), и он же нашёл этим вопросом реальный дефект в этом паке: дефолт «$/глава» лежал в двух местах (config клал ноль, разрешал вызыватель) — исправлено, дефолт резолвится только в `config`. **Ратифицировано 09.08 (оркестратор №15 по делегации владельца): «только окружение» ОСТАЁТСЯ; строка переформулирована в задачу — печать ЭФФЕКТИВНОЙ конфигурации при старте с редакцией секретов** (значение каждой настройки и откуда оно взялось: дефолт · переменная · файл; `*_FILE` печатается фактом наличия, не содержимым). Остаётся открытой до постройки — **закрыто:** `config.Config.Settings` несёт КАЖДУЮ прочитанную переменную с источником (`default` · `environment` · `file`), `LogEffective` печатает их построчно на старте. Редакция двойная и обе — правила проекта, а не вкус: секрет (DSN несёт пароль) и ЛЮБАЯ денежная сумма (D39.84/PD-99) печатаются фактом наличия и источником, без значения. Пины: `TestEverySettingThisServiceReadsIsPrinted` (сверка со СПИСКОМ `TM_PLATFORM_*` из исходника `config.go` — переменная, добавленная без записи, роняет батарею в том же коммите), `TestASecretIsNamedAndNeverPrinted`, `TestAConfiguredAmountIsNeverPrinted` | fixed(P5, дерево сессии) | вопрос владельца 08.08 + сессия P4 | +| PD-140 | bug | info | `internal/runs/reconcile.go` `Stop`, `internal/httpapi/` | `Service.Stop` построен и не подключён ни к чему: `httpapi.Runs` даёт только `Bounds`/`Start`, у `tmplatformctl` команды остановки нет. То есть контрол-плейн не умеет остановить прогон, который сам же запустил. Ручки `POST /runs/{id}/stop` и `/resume` в список работ промта не входили, поэтому это НЕ девиация пака, а честно названный хвост: остановить прогон сегодня можно только `systemctl --user stop`. Гейт: закрыть вместе с ручками стопа/резюма — **закрыто:** ручки `POST /v0/runs/{runId}/stop` и `/resume` построены по спеке (202 + `Run`, 404 чужому, 409 на невозможное действие), `runs.Service.Stop` записывает НАМЕРЕНИЕ стопа в Postgres ДО сигнала и зовёт systemd, `Resume` переиспользует механику перезапуска реконсилятора (`reopen`) вместо второй копии денежной арифметики. Пины: `TestTheStopIsRecordedBeforeSystemdIsAsked` (хук внутри фейка systemd читает БД и требует, чтобы намерение уже было), `TestAStopTheUnitDidNotTakeIsAskedAgainByTheSweep`, `TestResumeContinuesTheRunWithWhatIsLeftOfItsBudget`, `TestARunOfAnotherAccountCannotBeStoppedOrResumed` | fixed(P5, дерево сессии) | адверсариальное ревью (чтение) | +| PD-169 | hardening | info | `cmd/tmplatformd/runner.go` свип | **Свип имеет один бюджет времени (2 минуты) на ВСЕ прогоны, а каждый спавн/расчёт стоит вызова `tmctl status` (~1.5 с CPU и больше).** Десяток прогонов, чьи журналы нечитаемы, упирается в таймаут, и хвост списка (`order by started_at` — всегда один и тот же порядок) голодает неограниченно долго. Наблюдаемости, которая это показала бы, нет вовсе (П-11). Закрывать — бюджетом НА ПРОГОН плюс метрикой длительности свипа — **закрыто:** бюджет НА ПРОГОН (`runs.Config.RunBudget`, дефолт 60 с) плюс метрика длительности свипа и счётчик недоведённых проходов (`tm_platform_sweep_duration_seconds`, `tm_platform_sweep_unfinished_total`). Пин — `TestOneSlowRunDoesNotEatThePassOfTheWholeSweep`: первый прогон висит дольше бюджета, второй в том же проходе всё равно реконсилируется | fixed(P5, дерево сессии) | самопроверка дофикса (ревью вне карты) | +| PD-181 | bug | **minor, деньги** | `internal/pgstore/sink.go` `FinishRun`, `internal/runs/reconcile.go` `finishStopped` | **Устаревший снапшот свипа мог до-финишировать уже РЕЗЮМИРОВАННЫЙ прогон и оставить холд новой попытки вне всех списков.** Маркер завершённой попытки остаётся на диске (их никто не удаляет), а `FinishRun` гардил только `finished_at is null` — проход, держащий снапшот попытки 1, закрывал прогон, который стоп-резюм успел вернуть к жизни; попытка 2 с открытой резервацией не попадала ни в `ListLiveRuns` (там `finished_at is null`), ни в `UnsettledRuns` (там `ended_at is not null`). Достижимо стало ровно с появлением резюма, то есть этим паком — **закрыто:** `FinishRun` пишет только если закрываемая попытка ещё живая, и возвращает признак «закрыл», по которому вызыватель решает, считать ли деньги; пин `runs.TestAStaleSweepDoesNotReFinishAResumedRunFromTheOldAttemptsMarker`, посадка «снять гард» падает | fixed(P5, дерево сессии) | кросс-семейное ревью P5 (Fable, исполнением) | +| PD-182 | bug | **minor, деньги** | `internal/runs/reconcile.go` `finishStopped` | **Стоп закрывал прогон под ЖИВЫМ движком, если заявка на спавн была отдана назад после того, как юнит уже создался.** `ReleaseSpawnClaim` обнуляет `unit_name`, когда «юнит не удалось создать», а это не то же самое, что «не создался» — `systemd-run`, убитый после запроса, оставляет движок работать (зона это уже знает: ради этого случая сохраняется базовая линия). Путь стопа читал пустое имя как «процесса не было» и закрывал прогон; движок продолжал тратить, сигнала до него не доходило, следующий прогон книги впитывал его трату в свою базовую линию — **закрыто:** ненулевая `spend_baseline` при пустом имени юнита считается надгробием попытки спавна, имя юнита детерминировано, и платформа спрашивает systemd `Alive` прежде чем закрывать; живой юнит получает стоп. Пин `runs.TestAStopDoesNotCloseARunWhoseGivenBackClaimLeftAnEngineRunning` | fixed(P5, дерево сессии) | кросс-семейное ревью P5 (Fable, исполнением) | +| PD-183 | bug | **minor** | `internal/books/parse.go`, `internal/jobs/jobs.go` | **Грация клейма разбора (10 мин) была КОРОЧЕ таймаута задания очереди (15 мин): свип воровал клейм у живого парса.** В окне 10–15 минут на одной проектной директории оказывались два `tmctl manifest`; проигравший умирал на эксклюзивном локе движка с exit 1, а exit 1 — это то, чем движок говорит «источник не разобрать», и книга отклонялась ТЕРМИНАЛЬНО с удалением исходника — **закрыто:** грация написана как `jobs.JobTimeout + 5m`, пара утверждается тестом `books.TestTheClaimGraceOutlivesTheQueuesJobTimeout`; вдобавок терминальная запись возможна только пока клейм ещё наш (`parse_started_at = <наш>`), а один exit 1 больше не терминален вовсе | fixed(P5, дерево сессии) | кросс-семейное ревью P5 (Fable, исполнением) | +| PD-184 | bug | info | `internal/metrics/metrics.go` | **Лейбл `method` брался из запроса как есть.** Для маршрута он свёрнут в `(unmatched)`, а метод — токен, который выбирает вызывающий, и на неразобранном запросе он его же и придумывает: число рядов становится чужим ресурсом — **закрыто:** закрытый список методов, всё прочее — `(other)`; пин `metrics.TestAnUnroutedRequestGetsOneSeriesAndNotOnePerPath` | fixed(P5, дерево сессии) | кросс-семейное ревью P5 (Fable) | +| PD-186 | bug | **minor, деньги** | `internal/pgstore/sink.go` `FinishUnspawnedStop` | **Закрытие «стопа до спавна» не имело гарда живой попытки** — того самого, что получил `FinishRun` (PD-181). Устаревший проход мог закрыть уже РЕЗЮМИРОВАННЫЙ прогон, и холд второй попытки выпадал из обоих списков; вдобавок каждый следующий резюм отвечал 409 навсегда, потому что продолжать было бы уже завершённый прогон — **закрыто:** попытка обязана быть живой (`ended_at is null`) и принадлежать этому прогону; пин `runs.TestAStaleUnspawnedStopDoesNotCloseAResumedRun`, посадка «снять гард» падает | fixed(P5-дофикс, дерево сессии) | приёмка P5 (FP5-2) | +| PD-187 | bug | minor | `internal/books/parse.go` `reject`, `manifest` | **Крэш между сносом каталога и записью строки оставлял книгу в `parsing` НАВСЕГДА.** Порядок «каталог, потом строка» был выбран как самоизлечивающийся, и посылка была ложной: снесённый каталог читался как «нет конфигурации», а эта причина терминальной не становится никогда — книга ждала конфигурацию, которую некуда положить — **закрыто:** пропавший КАТАЛОГ отличён от отсутствующей конфигурации (`ErrDirectoryGone`) и терминален сразу; пин `books.TestABookWhoseDirectoryIsGoneIsRejectedRatherThanLeftWaiting` | fixed(P5-дофикс, дерево сессии) | приёмка P5 (FP5-3) | +| PD-188 | bug | minor | `internal/books/parse.go`, `internal/pgstore/books.go` `ClaimParse` | **Ожидание конфигурации ЖГЛО бюджет разбора.** `ClaimParse` считает каждую заявку, а книга без конфигурации заявляется раз в грацию бесконечно — после пяти циклов ожидания бюджет был исчерпан, и ПЕРВЫЙ же ответ движка становился терминальным мгновенно, с удалением исходника; движок отдаёт exit 1 и на опечатку в `book.yaml` — **закрыто:** попытка возвращается (`RefundParseAttempt`), когда движок не был спрошен вовсе; пин `books.TestWaitingForAConfigurationDoesNotBringDeletionCloser` | fixed(P5-дофикс, дерево сессии) | приёмка P5 (FP5-4) | +| PD-189 | bug | minor | `cmd/tmplatformd/runner.go`, `internal/books/parse.go` `Sweep` | **Бэкстоп гнал разбор под бюджетом прохода (2 мин) против 15 минут очереди.** Восстановление большой книги убивалось дедлайном свипа, убийство читалось как «хост не может запустить движок», попытка списывалась — и так каждый проход, пока книга не отклонялась ЗА СВОЙ РАЗМЕР; вдобавок запись отказа шла на просроченном контексте и терялась — **закрыто:** у прохода интейка свой бюджет (`jobs.JobTimeout + 1m`), у каждой книги внутри — свой (`jobs.JobTimeout`), терминальные записи идут на контексте, переживающем дедлайн; пин `books.TestOneBookInTheSweepGetsABudgetAParseCanLiveIn` ⚠ Дополнено ре-чеком (хвост в): бюджет КНИГИ не равен бюджету ПРОХОДА — вторая книга прохода получала остаток от первой и жгла попытку на обрезанном дедлайне. Проход, которому осталось меньше `jobs.JobTimeout`, книгу больше не НАЧИНАЕТ (клейм берётся внутри `Parse`, поэтому отложенная книга не тратит ничего). Пин `books.TestAPassTooShortForAParseStartsNoneAtAll` | fixed(третий раунд, дерево сессии) | приёмка P5 (FP5-5) | +| PD-190 | bug | info | `internal/httpapi/v0.go` `uploadFailed` | **Просроченный дедлайн загрузки уходил 500.** Медленный клиент — не сломанный сервис, и 500 говорит клиенту обратное о том, помогает ли повтор — **закрыто:** 408 problem+json; вопрос о коде вне перечня спеки внесён в пакет PD-180; пин `httpapi.TestAnUploadThatOutlivesItsDeadlineIsNotAnInternalError` | fixed(P5-дофикс, дерево сессии) | приёмка P5 (FP5-6) | +| PD-191 | bug | minor | `internal/pgstore/runs.go` `PauseRun` | **Стоп, пришедший в окно расчёта, отвечал `paused/credit_exhausted` вместо `stopped`** — то есть «кончились деньги» вместо «владелец остановил» — **закрыто:** пауза отказывает при висящем интенте (`ErrStopRequested`), реконсилятор заканчивает прогон стопом; пин `runs.TestAStopDuringSettlementOutranksThePause` | fixed(P5-дофикс, дерево сессии) | приёмка P5 (FP5-8а) | +| PD-178 | standards | info | `internal/runner/systemd_test.go`, `Makefile` | **Гейт systemd-тестов сверял ТЕКСТ сообщения, а не способность хоста, и перестал гейтить.** `systemdOrSkip` скипал по подстроке «Failed to connect to bus», а systemd 259 отвечает «Failed to connect to user scope bus» — на хосте без пользовательского менеджера три теста ПАДАЛИ вместо скипа. ⚠ Первая редакция этой строки утверждала, что формы скипа для systemd в зоне нет вовсе — неверно: форма была и сломалась о чужую правку строки. ⚠ Вторая посылка тоже устарела: состояние пользовательского менеджера на стенде ПЛАВАЕТ между сессиями — в одной его нет (`Linger=no`, sudo нет), в следующей он жив (`systemctl --user show` отвечает `Version=259.5`) и все три теста проходят; замерено обоими способами в один день — **закрыто:** гейт спрашивает СПОСОБНОСТЬ (доходит ли процесс до своего менеджера), скип громкий и поимённый, как у БД-тестов; пин `runner.TestTheSystemdGateAsksAboutTheCapabilityAndNotAMessage` падает, если гейт снова начнёт сверять текст | fixed(P5-дофикс, дерево сессии) | сессия P5 (батарея на стенде) | +| PD-180 | standards | info | `docs/architecture/14-api-contract/openapi.yaml` `createBook` | **Ответ 201 несёт `parsing`, а не `uploading`, и «responds immediately» недостижимо как класс.** Спека описывает createBook словами «Responds immediately; the book enters `uploading`», но ответ по HTTP не может уйти раньше, чем прочитано тело: к моменту 201 файл уже принят, и честный статус — `parsing`. `uploading` при этом РЕАЛЬНО наблюдаем, но только параллельным чтением библиотеки (пин `books.TestABookIsVisibleAsUploadingWhileItsFileIsStillArriving`). Сюда же — отказы интейка, которых спека не описывает: больше 16 частей формы и поле длиннее 1 КиБ дают 400, тело сверх потолка — 413 с порогом, которого в контракте нет, а тело медленнее дедлайна маршрута отвечало обрывом соединения без problem-ответа (дофикс это исправил, см. ниже). Вопрос владельцу контракта одним пакетом с PD-172/PD-174 ⚠ Дополнено дофиксом приёмки: в тот же пакет вопросов входит **408** на просроченный дедлайн загрузки — тело, не успевшее прийти за отведённое маршруту время, это медленный клиент, и 408 по RFC 9110 §15.5.9 говорит именно это, включая то, что лечение — повтор; кода 408 в перечне операции нет | open | адверсариальное ревью P5 (сверка провода со спекой) | +| PD-185 | bug | info | `internal/pgstore/sink.go`, `internal/runs/runs.go` `readyToTranslate` | **Статус `finalizing` есть в контракте, в DDL и в обоих аллоулистах — писателя нет ни одного.** Тот же класс, что интейк-статусы до этого пака: слово контракта без пути, который его пишет. Сегодня недостижим, поэтому вреда нет; лечится либо писателем (финальная волна движка), либо снятием из аллоулистов, когда станет ясно, что его не будет | open | кросс-семейное ревью P5 (Fable) | +| PD-196 | bug | minor | `internal/books/parse.go` `defer_`, движок | **Опечатка оператора в рукописном `book.yaml` стоит файла пользователя.** Движок отображает ВСЕ свои отказы на exit 1 (его собственный комментарий), поэтому «этот источник нечитаем» неотличимо от «конфиг синтаксически битый»: после пяти циклов книга отклоняется как `source_unreadable` и исходник удаляется. FP5-4 закрыл только «конфигурации нет вовсе». Настоящее лечение — различающий код выхода или `--dry-run` на стороне ДВИЖКА, правкой платформы не закрывается; до тех пор цена названа вслух, а не спрятана в комментарий | open | кросс-семейное ревью дофикса (Fable 5) | diff --git a/platform/docs/ENGINEERING_STANDARDS.md b/platform/docs/ENGINEERING_STANDARDS.md index 686ddadf..e6fce48d 100644 --- a/platform/docs/ENGINEERING_STANDARDS.md +++ b/platform/docs/ENGINEERING_STANDARDS.md @@ -40,9 +40,19 @@ - Отказ зависимости — деградация с диагностикой, не тишина: проглоченная ошибка без лога и без комментария-обоснования = дефект (класс PD-5/PD-16). -**Наблюдаемость:** структурные логи (route-pattern, не сырой путь — id пользователя/книги в логи не -текут), `X-Request-Id` на каждом ответе, ошибки видимы на своём уровне. Money-числа — только в -приватных таблицах, ни в логах, ни в ответах (кроме P-5-статуса процентами). +**Наблюдаемость** — базовая линия **практики именования Prometheus** (базовые единицы: секунды и +байты; `_total` у счётчиков; единица в имени, не в лейбле) + **четыре золотых сигнала** SRE на +вопрос «что мерить» (внесено P5 по ратифицированному направлению PD-115: у оси появился внешний +эталон, а не только собственная проза зоны). Конкретика: +- структурные логи (route-pattern, не сырой путь — id пользователя/книги в логи не текут), + `X-Request-Id` на каждом ответе, ошибки видимы на своём уровне; +- метрики отдаются в формате Prometheus на ОТДЕЛЬНОМ слушателе (`STACK_DECISIONS` §24); лейбл несёт + паттерн маршрута, никогда путь — иначе это и неограниченная кардинальность, и библиотека + пользователя в индексе оператора; +- Money-числа — только в приватных таблицах, ни в логах, ни в ответах, ни в метриках (кроме + P-5-статуса процентами); +- ⚠ оси **ops** (релиз/откат, ёмкость, восстановление) и **конфигурация** внешнего эталона + по-прежнему не имеют — PD-115 открыт ими. **Контракт-первичность:** поверхность = `docs/architecture/14-api-contract/openapi.yaml`; расхождение кода со спекой = дефект чей-то один: либо спека правится через ратификацию, либо код. Генерация diff --git a/platform/docs/STACK_DECISIONS.md b/platform/docs/STACK_DECISIONS.md index cb1eefeb..339031de 100644 --- a/platform/docs/STACK_DECISIONS.md +++ b/platform/docs/STACK_DECISIONS.md @@ -6,15 +6,15 @@ > > Общая записка по обоим новым сервисам — `frontend/docs/STACK_DECISIONS.md` §5 (02.08). Здесь — > платформенная часть с датами релизов и сверкой. Что изменилось за два дня: три библиотечных пина -> §5 (pgx · goose · River) на 04.08 всё ещё последние; по Go последним патчем стенда идёт 1.26.5 -> (07.07) — floor `go.mod` оставлен общим с движком, а тулчейн сборки поднят до 1.26.5 (см. ниже). +> §5 (pgx · goose · River) на 04.08 всё ещё последние; по Go последним патчем стенда идёт **1.26.6** +> (13.08, поднят security-адвизори — см. таблицу) — floor `go.mod` оставлен общим с движком. ## Пины | Что | Пин | Релиз пина | Зачем нам | |---|---|---|---| | Go (язык, `go.mod`) | **1.26.4** | 02.06.2026 | Тот же floor, что у движка (`backend/go.mod`) — общий стенд собирает оба модуля одним тулчейном | -| Go (тулчейн сборки, `make tools-check`) | **≥1.26.5** | 07.07.2026 | 1.26.5 несёт security-фиксы `crypto/tls` и `os`; сетевой модуль собирается ими, а не «чем-нибудь 1.26» | +| Go (тулчейн сборки, `make tools-check`) | **≥1.26.6** | 13.08.2026 | Поднят с 1.26.5 не по вкусу, а по `make vuln`: база адвизори опубликовала пять уязвимостей stdlib против 1.26.5 — `net/http`, `crypto/tls`, `net/url`, `encoding/xml`, `encoding/asn1` (GO-2026-6218/6090/6089/6088/5972), все закрыты в 1.26.6, и две трассируются в пути, которые эта служба зовёт (`pgstore.Open → pgx.ParseConfig → asn1.Unmarshal`). На 1.26.6 батарея зелёная и скан чист. ⚠ Как floor РЕАЛЬНО держится (первая редакция этого подъёма не держала ничего — регекс `go1\.26\.([5-9]…)` принимал ту самую 1.26.5, а `GO_MIN_VERSION` жил только в echo): `make version-check` СРАВНИВАЕТ версии (`sort -V`, префиксы `rc`/`devel` отвергаются), и `go.mod` несёт `toolchain go1.26.6` — его читает всякая сборка, даже мимо make: при `GOTOOLCHAIN=auto` хост скачает нужный тулчейн, при `=local` остановится с ошибкой. Само сравнение запинено (`internal/gates`) | | PostgreSQL | **18.x** (проверено на 18.4), floor **16** | 18.4 — май 2026 | 18 — текущая мажорная (19 в бете, в прод не берём); floor 16, потому что River тестируется на трёх последних мажорных | | HTTP | stdlib `net/http` + `ServeMux` | — | Роутер-библиотека не нужна: `ServeMux` с 1.22 умеет метод+wildcards, а `Request.Pattern` даёт лог по маршруту, не по пути | | CSRF | stdlib `http.CrossOriginProtection` | Go 1.25 | Ровно тот механизм, что описан в §5 (Sec-Fetch-Site → Origin), теперь в тулчейне — свой велосипед не пишем | @@ -221,6 +221,28 @@ 23. **«Ошибка материализации» — два разных факта, и различает их `runs.quarantines`.** Транзиентное — повтор следующим свипом; сюда входят не только дедлок и сериализация (`40P01`/`40001`), но и всё, во что превращается ШТАТНЫЙ рестарт управляемого Postgres: класс 08, `57P01`/`57P02`/`57P03`, `pgconn.SafeToRetry`, любой `net.Error`. Первая редакция знала только первые два, и рестарт базы карантинил проекцию живого платного прогона навсегда — снятия карантина в дереве нет (найдено ре-чеком V2, исполнением). Граница держится на типах: ошибка чтения ФАЙЛА — `*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 (базовые единицы — секунды и байты; `_total` у счётчиков; единица не в лейбле) плюс «четыре золотых сигнала» на вопрос «что мерить». Пин на форму — `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 единого бэклога). + ## Как поднять локально ```sh @@ -236,6 +258,11 @@ curl -s localhost:8080/readyz # ready `TM_PLATFORM_SESSION_MAX_AGE` · `TM_PLATFORM_INSECURE_COOKIES` (dev, по HTTP) · `TM_PLATFORM_OIDC_ISSUER` · `_OIDC_CLIENT_ID` · `_OIDC_CLIENT_SECRET` (или `_FILE`) · `_OIDC_REDIRECT_URL` · `TM_PLATFORM_AFTER_LOGIN` · `TM_PLATFORM_SIGNUP_GRANT_USD`. +Приём книги: `TM_PLATFORM_BOOKS_DIR` (только АБСОЛЮТНЫЙ путь, корень каталогов книг; пусто = +инстанс загрузок не принимает и `POST /books` не смонтирован) · `TM_PLATFORM_MAX_UPLOAD_BYTES` +(дефолт 64 МиБ — потолок ОДНОЙ загрузки, §25) · `TM_PLATFORM_UPLOAD_DEADLINE` (дефолт 10 минут — +сколько тело одной загрузки вправе идти). +Наблюдаемость: `TM_PLATFORM_METRICS_ADDR` (дефолт `127.0.0.1:9464`; пусто = метрики не отдаются). Раннер: `TM_PLATFORM_ENGINE_BIN` (версионированный путь tmctl; пусто = инстанс только читает) · `TM_PLATFORM_STATE_DIR` (только АБСОЛЮТНЫЙ путь: маркер пишет юнит из каталога книги, читает демон из своего — относительный назвал бы два разных файла; отказ на буте) · `TM_PLATFORM_CTL_BIN` · @@ -246,20 +273,50 @@ curl -s localhost:8080/readyz # ready на той сборке движка, с которой прогон начался — строка 139). Вход монтируется, только если задана ВСЯ четвёрка OIDC; половина конфигурации — отказ на старте. +⚠ Все они печатаются на старте с источником (`default`/`environment`/`file`) — PD-114; секреты и +денежные суммы печатаются фактом наличия, без значения. + Админ-команды: `tmplatformctl grant --user --usd 5 [--note ...] [--key ...]` · `balance --user ` · `logins --user ` · `revoke --user `. ### 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 -# бинарники io.zonky.test.postgres с Maven Central, распакованные в скрэтчпад pg/bin/initdb -D pgdata -U postgres -A trust --no-locale --encoding=UTF8 pg/bin/pg_ctl -D pgdata -l pg.log -o "-k /tmp -p 55432 -c listen_addresses=" start export TM_PLATFORM_TEST_DSN='postgres://postgres@/postgres?host=/tmp&port=55432&sslmode=disable' ``` ⚠ Сокет кладём в `/tmp` (`-k`): полный путь скрэтчпада длиннее лимита Unix-сокета. -⚠ `psql` в пакете zonky НЕТ — только `initdb`/`pg_ctl`/`postgres`; проверять из Go. +⚠ В пакете zonky ТОЛЬКО `initdb`/`pg_ctl`/`postgres` — проверено содержимым архива +(1071 файл, в `bin/` три штуки); `psql` нет, смотреть БД приходится из Go. Живёт в скрэтчпаде +сессии, поэтому умирает вместе с ним — для долгой работы предпочтителен способ выше. ⚠ Postgres на стенде отсутствует как системный пакет и sudo нет. Схема и запросы этой сессии проверены на ЖИВОМ PostgreSQL **18.4**, поднятом без root из бинарников zonky diff --git a/platform/docs/platform-PROGRESS.md b/platform/docs/platform-PROGRESS.md index 59be3388..6d50973c 100644 --- a/platform/docs/platform-PROGRESS.md +++ b/platform/docs/platform-PROGRESS.md @@ -6,15 +6,32 @@ ## Текущее состояние +- **P5 ПОСТРОЕН и ДОФИКШЕН по приёмке — дерево передаётся на лендинг (11.08).** `POST /v0/books` + потоково с пер-маршрутным потолком тела и своим дедлайном чтения · статусы + `uploading → parsing → not_started | rejected` получили писателей, разбор — $0-команда движка + `tmctl manifest` · `POST /v0/runs/{id}/stop|resume` с намерением стопа в Postgres ДО сигнала · + метрики Prometheus на отдельном слушателе · печать эффективной конфигурации. +- **Приёмка 11.08 (8 находок) → дофикс → кросс-семейное ревью дофикса (5 находок) → ре-чек + оркестратора (3 находки, 2 high) → ТРЕТИЙ РАУНД ЗАКРЫТ (14.08)**: разделы «Третий раунд», + «Дофикс-2» и «Дофикс P5» ниже, движение — записками-планами. Батарея на стенде `~/.local/pgsql` + (порт 55433) под `-race`: EXIT=0, 16 пакетов, линтер 0 issues, скипов 0, `make vuln` чист, тестов + **261 → 359**. Посадки: 10/10 · 6/6 · 6/6 (две пережившие за все раунды — оба раза виноват был + слепой пин, переписан пин, не мутация); списки — в журнале. + ⚠ **Тулчейн 1.26.6 НА РАТИФИКАЦИЮ**, и вчерашний клейм про него был ЛОЖЕН: гейт принимал 1.26.5 + (регекс вместо сравнения). Теперь floor держат два механизма — `make version-check` сравнивает + версии, `go.mod` несёт `toolchain go1.26.6`, который читает всякая сборка; при `GOTOOLCHAIN=auto` + хост скачает нужный тулчейн, при `=local` остановится. Само сравнение запинено (`internal/gates`). + **ВОПРОС на ратификацию — кто пишет `book.yaml` при интейке**: половина П-9 стоит на нём, раздел + «Развилка» ниже. Дерево не коммичено. - **P4 «раннер» + дофикс + V2 ПРИНЯТЫ и ЗАЛЕНДЕНЫ — D39.123, код `d29e30c`** (ревью-шапка ниже): транзиентный systemd-юнит на прогон · очередь River с холдом-до-спавна в одной транзакции · реконсилятор с базовой линией расчёта · тейлер `events.jsonl` с карантином проекции · пять ручек `/v0` контракта **0.2.1** · дев-интейк. Батарея с живым PG 18.4 под `-race` зелёная, скипов 0, тестов зоны **261**. Формула аргумента потолка — `committed + прирост` (PD-158, ратифицирована ПОПРАВКОЙ к пингу оркестратора). -- **Регистр — 171 строка** (`python3 docs/scripts/counts.py`): 124 закрыто · 1 закрыт ратификацией (PD-59) · 3 риском · 43 открыто - (**1 major — PD-113**: стоп по потолку отдаётся `failed` до эмиттера; после PD-158 его цена - выросла — потолок срабатывает ровно на исчерпании холда). +- **Регистр — 198 строк** (`python3 docs/scripts/counts.py`): 145 закрыто · 1 закрыт ратификацией + (PD-59) · 4 риском · 48 открыто (**1 major — PD-113**: стоп по потолку отдаётся `failed` до + эмиттера). Форма — 13 секций (пинг оркестратора №16). - **Промт P5 ВЫДАН оркестратором 10.08** (`PLATFORM_P5_SESSION_PROMPT.md`, D39.128): П-9 `POST /books` (+PD-72 потолок тела; развилка «кто пишет book.yaml при интейке» — вопросом в этот журнал ДО стройки той половины) · стоп/резюм-ручки PD-140 (различение «потолок vs авария» — @@ -25,6 +42,812 @@ (D39.107/109/112/114); разделы — в [archive/platform-PROGRESS-P0-P3.md](archive/platform-PROGRESS-P0-P3.md). Стек и рецепт стенда — `STACK_DECISIONS.md`; критерии приёмки — `ENGINEERING_STANDARDS.md`. +## Третий раунд (14.08): ре-чек оркестратора FP5-9…FP5-11 и хвосты + +Все три находки подтвердились на коде до правки; возражений нет. Две из трёх — мои вчерашние фиксы, +которые ЗАЯВЛЕНЫ шире, чем сделаны, и это ровно тот класс, за который приёмка уже била. + +### FP5-9 [HIGH] Гейт тулчейна не отказывал 1.26.5 — и три дока это утверждали + +Проверено исполнением: `make version-check GO_VERSION=go1.26.5` на прежнем правиле проходил. +Регекс `go1\.26\.([5-9]|[0-9]{2,})` принимал ровно ту версию, ради отказа от которой floor и +поднимали, а `GO_MIN_VERSION` жил только в тексте сообщения. Тот же регекс ставил 1.26.10 НИЖЕ +1.26.9. Клейм «хост на 1.26.5 получит отказ» был ложен в трёх местах (журнал ×2, `STACK_DECISIONS`, +коммент Makefile) — гейт «поднят» был только на словах. + +Сделано, двумя независимыми механизмами: + +1. `make version-check` СРАВНИВАЕТ версии (`sort -V`), а не матчит; префиксы `rc`/`devel` отвергаются + отдельной веткой — пререлиз не несёт фиксов, которые обещает его номер. Цель отделена от + `tools-check` и берёт версию из переменной `GO_VERSION`, чтобы её можно было судить версиями, + которых на хосте нет. +2. `go.mod` получил `toolchain go1.26.6`. **Решение и обоснование** (оркестратор просил решить): floor + ЯЗЫКА (`go 1.26.4`) остаётся общим с движком — один стенд собирает оба модуля; директива + `toolchain` поднимает только тулчейн, то есть stdlib, которым линкуется сетевой модуль. Её читает + ВСЯКАЯ сборка, включая ту, что не зовёт make: при `GOTOOLCHAIN=auto` (дефолт) хост скачает 1.26.6 + вместо того, чтобы собрать уязвимый бинарь, при `=local` — остановится с ошибкой, называющей + версию. Обе ветки — тот ответ, который нужен; ни одна не зависит от того, прочитал ли кто-то + Makefile. Цена названа: офлайн-хост с `=local` и только 1.26.5 теперь не соберёт зону — это и есть + гейт. + +Пин — на СРАВНЕНИЕ, а не на прогон: новый пакет `internal/gates` гоняет `version-check` таблицей +версий, которых на этой машине нет (1.26.4/1.26.5/1.25.9/1.9.9/rc/devel — отказ; +1.26.6/1.26.7/1.26.10/1.27.0/2.0.0 — приём), и вторым тестом требует, чтобы `go.mod` и +`GO_MIN_VERSION` называли одну версию. Две посадки падают. Три ложных клейма переписаны на описание +механизма. Строка PD-197. + +### FP5-10 [HIGH] Гард корня не переживал unmount и маскировался бутовым `MkdirAll` + +Тоже подтверждено: `Stat(BooksDir)` отвечает «есть» про пустой mountpoint, оставшийся после +размонтирования тома, смонтированного РОВНО в `BooksDir`, — а бут безусловным `MkdirAll` пересоздаёт +корень после рестарта. То есть мой вчерашний фикс закрывал только форму «каталог удалён вместе с +родителем» и не закрывал ту, ради которой писался. + +Сделано по кандидату оркестратора — **сентинел провижининга**: файл `.tmplatform-books` в корне, +который пишет ПЕРВАЯ загрузка (`markStorage`, `O_EXCL`, поэтому две одновременные загрузки обе правы) +и не пишет больше никто — в частности, не пишет бут. Решение «хранилище на месте» принимается по +СЕНТИНЕЛУ (`storageIsThere`), а не по существованию каталога: ни unmount, ни `MkdirAll` его не +подделывают. Хост, который никогда не принимал загрузок, отвечает «нет» — безопасная сторона: у него +нет книг, которые можно отклонить. + +Пин — `books.TestAnUnmountedVolumeLooksLikeAnEmptyRootAndStillIsNotTheBooksFault`: удаляем всё под +корнем и пересоздаём сам корень (ровно то, что оставляют unmount и бут), десять циклов ожидания — +книга жива, бюджет цел, движок не спрошен, сентинел не воскрес. **Первая редакция этого пина посадку +ПЕРЕЖИЛА** (мутация «не писать сентинел вовсе»): без сентинела всё ждёт, и тест этого не отличал — +добавлено утверждение, что принятая загрузка сентинел ПИШЕТ, иначе терминальность крэш-окна (FP5-3) +теряется. Обе посадки падают. PD-192 дополнена. + +### FP5-11 Правило ревизии применено не ко всем писателям + +Пять писателей жизненного цикла ПРОГОНА остались на `revision + 1` против правила, которое дофикс +ввёл для трёх интейковых: старт, реоткрытие, пауза (`runs.go`) и два закрытия (`sink.go`). Значит +staleness библиотеки, которую вчера закрыли на статусах интейка, оставалась на статусах прогона: +книга не-максимума аккаунта уходила `translating` — библиотека не двигалась. + +Сделано: все пять берут `nextRevisionOfThisBooksLibrary`. Пин — +`pgstore.TestARunTransitionOnAnOlderBookStillMovesTheLibrary` (вторая книга держит максимум 41, +прогон стартует на первой; проверяются обе половины жизненного цикла — старт и пауза). Посадка +падает. + +⚠ **Мотивированное исключение, названное вслух:** два писателя `books.revision` НЕ переведены — +прогресс-путь синка (`bumpBook` на `unitDone`/`progress`). Причина не вкусовая: `unitDone` вычисляет +`chapters.revision` из `books.revision + 1` ДО инкремента, и прыжок книги через пол развязал бы +книжную и главную шкалы, а это отдельная scope-семантика контракта. Она и не нужна: прогресс +существует только у книги, у которой ИДЁТ прогон, а старт прогона теперь сам поднимает книгу на +уровень библиотеки — дальше `+1` монотонен и виден. Если правило когда-нибудь понадобится и там, +оно приедет вместе с проверкой главной шкалы, а не молча. + +### Хвосты ре-чека + +- **(а)** Коммент `PauseRun` обещал проверку «в том же стейтменте», а стоит отдельный + `select … for update` в той же транзакции. Текст приведён к коду (транзакция и её замки). +- **(б)** Комменты `parse.go` («terminal on the first answer») описывали до-дофиксное поведение на + пути, который УДАЛЯЕТ файл пользователя, — самое опасное место для устаревшего текста. Переписаны: + через бюджет попыток идёт КАЖДЫЙ ответ движка, удаляет только последний. +- **(в)** Остаток FP5-5 закрыт кодом, а не строкой: проход, которому осталось меньше, чем нужно + разбору, книгу НЕ НАЧИНАЕТ (клейм берётся внутри `Parse`, поэтому отложенная книга не тратит + ничего) и говорит об этом одной строкой лога вместо ошибки на книгу. Пин — + `books.TestAPassTooShortForAParseStartsNoneAtAll`, посадка падает. +- **(г)** Мета-пин systemd-гейта перечислителен — принято как названный остаток: он ловит возврат к + сверке сообщений по словам systemd и требует, чтобы гейт вообще спрашивал менеджер, но исчерпать + все будущие формулировки перечислением нельзя. + +### Посадки третьего раунда + +| Посадка (что откатывается) | Пин, который обязан упасть | Итог | +|---|---|---| +| гейт снова матчит регексом (принимает 1.26.5) | `gates.TestTheToolchainGateComparesVersionsRatherThanMatchingThem` | поймана | +| `go.mod` снова без `toolchain` | `gates.TestGoModPinsTheSameToolchainTheBatteryDemands` | поймана | +| гард снова смотрит на существование корня | `books.TestAnUnmountedVolumeLooksLikeAnEmptyRootAndStillIsNotTheBooksFault` | поймана | +| сентинел не пишется загрузкой | тот же | ПЕРЕЖИЛА → пин усилен → поймана | +| старт прогона снова `revision + 1` | `pgstore.TestARunTransitionOnAnOlderBookStillMovesTheLibrary` | поймана | +| проход снова начинает книгу, на которую нет времени | `books.TestAPassTooShortForAParseStartsNoneAtAll` | поймана | + +### Батарея третьего раунда + +- `make check` с живым PG 18.4 (стенд `~/.local/pgsql`, порт 55433) под `-race`: **EXIT=0, 16 + пакетов, скипов 0**, линтер **0 issues**, `make vuln` — чисто. +- Тесты `^func Test` исполнением: **354 → 359** (+5, удалённых 0). +- Посадки: **6 из 6** (одна со второго захода, см. таблицу). + +### Записка-план третьего раунда: ID → статус → улика + +| ID | Статус | Улика | +|---|---|---| +| FP5-9 | ЗАКРЫТО | `version-check` сравнивает (`sort -V`) + `toolchain go1.26.6` в `go.mod`; `internal/gates` ×2 пина, две посадки падают; три клейма переписаны. PD-197 | +| FP5-10 | ЗАКРЫТО | сентинел `.tmplatform-books`, пишет только загрузка; `books.TestAnUnmountedVolumeLooksLikeAnEmptyRootAndStillIsNotTheBooksFault`; две посадки падают. PD-192 дополнена | +| FP5-11 | ЗАКРЫТО с названным исключением | пять писателей прогона на `nextRevisionOfThisBooksLibrary`; `pgstore.TestARunTransitionOnAnOlderBookStillMovesTheLibrary`; посадка падает. Прогресс-путь синка НЕ тронут — обоснование выше. PD-122 дополнена | +| хвост (а) | ЗАКРЫТО | текст `PauseRun` приведён к коду | +| хвост (б) | ЗАКРЫТО | текст `parse.go` про терминальность приведён к коду. PD-198 | +| хвост (в) | ЗАКРЫТО кодом | проход не начинает книгу без полного бюджета; `books.TestAPassTooShortForAParseStartsNoneAtAll`; посадка падает. PD-189 дополнена | +| хвост (г) | ПРИНЯТО как остаток | перечислительность мета-пина названа, не «закрыта» | + + +## Дофикс-2 (13.08): кросс-семейное ревью САМОГО дофикса + +Пропуск, который я закрыл только по вопросу владельца: дофикс приёмки был проверен исполнением +(пины · посадки · батарея · живая проба), но НЕ был отревьюен независимо — author≠reviewer для +дофикс-диффа не соблюдался. Запущены два ревьюера другой семьи (Fable 5, я Opus), read-only по +дереву, каждый со своей линзой — «деньги и гонки» и «интейк, потеря данных, правдивость чисел». +Вернули **пять дефектов с воспроизведением, одну гипотезу и два расхождения отчётности**. Все +подтверждены мной на коде до правки; возражений нет. + +### Н1 [medium-high, интейк] Пропавший КОРЕНЬ хранилища читался как вина каждой книги + +`os.Stat(workdir)` даёт ENOENT и когда пропал каталог одной книги, и когда не смонтирован сам +`BooksDir`. Первое — терминальный конец этой книги (и лечение крэш-окна отказа, FP5-3), второе — +беда хоста. Читая второе как первое, ОДИН проход свипа терминально отклонял ВСЕ книги в интейке с +`source_unreadable` — причиной, которая винит файл пользователя, терминальна по устройству и не +имеет обратного хода (ни перепарса, ни удаления, PD-175). То есть мой же фикс FP5-3 создал путь +массового уничтожения загрузок из-за размонтированного тома. + +Сделано: `ErrStorageGone` отличён от `ErrDirectoryGone` — корень спрашивается ПЕРЕД тем, как винить +книгу; причина `storage_unavailable` никогда не терминальна, бюджет не тратит (движка не звали) и на +книге не хранится. Предикат `waitsForTheDeployment` собрал оба таких случая в одном месте. +Пин — `books.TestAVanishedStorageRootIsNotEveryBooksFault` (две книги, снесённый корень, десять +циклов ожидания: обе живы, бюджет цел, движок не спрошен ни разу, том вернулся — следующий проход +разбирает). Посадка падает. Строка PD-192. + +### Н2 [minor, ревизия] Пол ревизии глотал весь статусный проход книги + +Продолжение FP5-1 на соседнем пути: вставка научилась брать `greatest(max, пол) + 1`, а смены +статуса остались на `revision + 1`. Книга, загруженная ДО чужой отменённой загрузки, идёт +`uploading → parsing → not_started` тремя инкрементами СВОЕГО счётчика и всё ещё под полом — +`greatest(max, пол)` не двигается вовсе, а клиент по контракту отбрасывает чтение, которое не выше +применённого. Экран, только что загрузивший книгу, показывает её «приходящей», пока библиотеку не +сдвинет постороннее событие. Монотонность при этом не нарушена — это staleness, и именно поэтому +прошлый круг её не заметил. + +Сделано: три писателя статуса (`StartParsing`, `FinishParse`, `RejectBook`) берут следующий номер +БИБЛИОТЕКИ, а не свой + 1 (`nextRevisionOfThisBooksLibrary`, владелец читается из обновляемой +строки). Пин — `books.TestAStatusChangeIsVisibleEvenUnderTheLibrarysFloor`, посадка падает. PD-122 +переформулирована: остаток — собственный счётчик области (двое ОДНОВРЕМЕННЫХ писателей могут +вычислить одно и то же число; значение при этом растёт, поэтому клиент перезапрашивает). + +### М1 [medium, деньги] `PauseRun` — третий закрывающий путь без гарда живой попытки + +Тот же класс, что PD-181 и FP5-2, на пути, который прошлый круг не проверил: пауза закрывала прогон, +не спрашивая, жива ли ещё закрываемая попытка И этого ли она прогона (апдейт попытки шёл даже без +`run_id`). Сценарий ревьюера: два поколения реконсилятора при перекрывающемся деплое, у старого ниже +`PerChapter` в конфиге → его проход считает остаток исчерпанным и паузит прогон, который уже +рестартован в живую попытку 2. Итог — прогон `paused/credit_exhausted` под живым тратящим движком, а +открытый холд попытки 2 не виден НИ в `ListLiveRuns` (прогон завершён), НИ в `UnsettledRuns` (попытка +не завершена). + +Сделано: тот же `exists (… a.id = $4 and a.run_id = runs.id and a.ended_at is null)`, что у соседей, +плюс `run_id` в апдейте попытки. Пин — `pgstore.TestAPauseFromAnOldSnapshotDoesNotCloseARunOverALiveAttempt` +(проверяет и то, что прогон жив, и то, что холд живой попытки виден в списке). Посадка падает. PD-193. + +### М2 [low, проекция] Синк законченной попытки усыновлял чужой хендшейк + +Форма, которой до P5 не существовало: стоп ДО спавна оставляет попытку закрытой и без +`engine_run_id`. Устаревший материализатор с такой попыткой принимал hello попытки, которая её +заменила, биндил чужой `engine_run_id` на себя и материализовал тот же журнал второй раз — счётчики +глав и юнитов удваивались. Деньги не двигались. + +Сделано: бинд отказан для законченной попытки (`ended_at is null` в CAS). Пин — +`pgstore.TestAMaterializerOfAnEndedAttemptDoesNotAdoptTheNextAttemptsEngine`, посадка падает. PD-194. + +### М3 [low, деньги] Возврат холда «прогона, который не запускался», ждал ответа движка + +Ревьюер дал это гипотезой; я перепроверил и подтвердил. `settle` сперва требовал успешного +`tmctl status`, и только потом ветка «юнита не было — вернуть холод целиком». Но хост, который +производит эту ситуацию, — ровно тот, где движок не запускается: `Status` падает на каждом проходе, +и деньги прогона, который не начинался, остаются зарезервированными навсегда (видимыми, но +запертыми). + +Сделано: ветка «нет юнита и нет базовой линии» идёт ДО вызова движка; `ReleaseUnspawned` +перепроверяет строку под замком, поэтому снапшот, который успели заспавнить, отвергается там и +settle идёт обычным путём. Пин — +`runs.TestTheHoldOfARunThatNeverStartedComesBackOnAHostWhoseEngineCannotAnswer` (движок отвечает +«no such file» на любой запрос; холд обязан вернуться, движок — не быть спрошенным). Посадка падает. +PD-195. + +### Хвосты того же круга + +- **Н3.** `StartParsing` и оба `abandon` в `Accept` шли на `context.WithoutCancel` без своего + дедлайна: отсоединённый — не значит бесконечный, повисший запрос держал бы горутину запроса, + которого уже нет. Переведены на `writeCtx` (30 с). **Пина нет и не заявляется**: это граница, а не + наблюдаемое поведение — чтобы его пинить, нужен стор, умеющий висеть. +- **Н4.** Опечатка оператора в рукописном `book.yaml` по-прежнему стоит файла пользователя: движок + отвечает exit 1 на всё, через пять циклов книга отклоняется как `source_unreadable` и исходник + удаляется. FP5-4 закрыл только «конфигурации нет вовсе». Заведено отдельной строкой риска PD-196, + а не спрятано в комментарий: настоящее лечение — различающий код выхода или `--dry-run` на стороне + движка, это не правка платформы. +- **Н5.** Пин FP5-7 сверял ДВЕ точные формулировки старого стринг-матча — третье написание прошло бы + мимо. Теперь тест разбирает файл гейта парсером и смотрит только КОД (комментарии там цитируют ту + самую строку — текстовый скан принял бы историю за дефект), запрещает в коде слова systemd и + требует, чтобы гейт вообще спрашивал менеджер. Посадка «вернуть матч по сообщению» падает. +- **Отчётность.** Ревьюер не смог проверить клейм «посадки 10 из 10»: списки лежали в скрэтчпаде + сессии, а не в дереве. Исправлено — списки посадок теперь ниже, в самом журнале. +- Ловушка счёта, в которую ревьюер едва не попал и которую стоит знать: `git grep '^func Test' HEAD -- + platform` даёт 262 — лишняя строка из код-блока в архивном доке; счёт по `*_test.go` даёт 261. + +### Списки посадок (то, чего не хватало отчёту) + +| Раунд | Посадка (что откатывается) | Пин, который обязан упасть | Итог | +|---|---|---|---| +| Дофикс | вставка снова читает только `max(revision)` | `TestABookThatJoinsAfterACancelledUploadStillMovesTheRevision` | поймана | +| Дофикс | `FinishUnspawnedStop` без гарда живой попытки | `TestAStaleUnspawnedStopDoesNotCloseAResumedRun` | поймана | +| Дофикс | пропавший каталог снова = «нет конфигурации» | `TestABookWhoseDirectoryIsGoneIsRejectedRatherThanLeftWaiting` | поймана | +| Дофикс | ожидание конфигурации снова жжёт бюджет | `TestWaitingForAConfigurationDoesNotBringDeletionCloser` | поймана | +| Дофикс | проход интейка снова под бюджетом прогонов | `TestOneBookInTheSweepGetsABudgetAParseCanLiveIn` | поймана | +| Дофикс | просроченный дедлайн снова 500 | `TestAnUploadThatOutlivesItsDeadlineIsNotAnInternalError` | поймана | +| Дофикс | гейт systemd снова по тексту сообщения | `TestTheSystemdGateAsksAboutTheCapabilityAndNotAMessage` | поймана | +| Дофикс | пауза перебивает стоп | `TestAStopDuringSettlementOutranksThePause` | поймана | +| Дофикс | потолок частей формы снова `n > cap` | `TestAFormWithTooManyPartsIsRefusedAtTheCap` | ПЕРЕЖИЛА → пин переписан → поймана | +| Дофикс | wire-тест ревизии без различающей силы | `TestTheCardProjectsTheRevisionTheStoreGaveAndInventsNone` | поймана | +| Дофикс-2 | пропавший корень снова читается как вина книги | `TestAVanishedStorageRootIsNotEveryBooksFault` | поймана | +| Дофикс-2 | смена статуса снова `revision + 1` | `TestAStatusChangeIsVisibleEvenUnderTheLibrarysFloor` | поймана | +| Дофикс-2 | пауза снова без гарда живой попытки | `TestAPauseFromAnOldSnapshotDoesNotCloseARunOverALiveAttempt` | поймана | +| Дофикс-2 | синк снова биндит законченную попытку | `TestAMaterializerOfAnEndedAttemptDoesNotAdoptTheNextAttemptsEngine` | поймана | +| Дофикс-2 | возврат холда снова ждёт ответа движка | `TestTheHoldOfARunThatNeverStartedComesBackOnAHostWhoseEngineCannotAnswer` | поймана | +| Дофикс-2 | гейт systemd снова сверяет сообщение (усиленный пин) | тот же | поймана | + +### Батарея дофикса-2 + +- `make check` с живым PG 18.4 под `-race`: **EXIT=0, 15 пакетов, скипов 0**, линтер **0 issues**. +- Тесты `^func Test` исполнением: **349 → 354** (+5 пинов, удалённых 0). +- Посадки: **6 из 6** (таблица выше, нижние шесть строк). +- ⚠ **`make vuln` покраснел между двумя прогонами ОДНОГО дня, и не из-за дерева.** База адвизори + опубликовала пять уязвимостей stdlib против Go **1.26.5** — `net/http`, `crypto/tls`, `net/url`, + `encoding/xml`, `encoding/asn1` (GO-2026-6218 / 6090 / 6089 / 6088 / 5972), все закрыты в + **1.26.6**, и govulncheck трассирует две из них в пути, которые эта служба зовёт (например + `pgstore.Open → pgx.ParseConfig → asn1.Unmarshal`). Поставил 1.26.6 в `~/.local` (sha256 сверен с + go.dev), поднял `GO_MIN_VERSION` в Makefile 1.26.5 → 1.26.6 с обоснованием на месте, обновил пин в + `STACK_DECISIONS.md`. На 1.26.6 батарея зелёная и скан чист. + **НА РАТИФИКАЦИЮ:** хост сборки, оставшийся на 1.26.5, должен получать отказ. + ⚠ **Правка 14.08: в этой редакции он его НЕ получал** — гейт сравнивал регексом и принимал 1.26.5 + (ре-чек, FP5-9). Как floor держится теперь — раздел «Третий раунд» выше. + +### Записка-план дофикса-2: ID → статус → улика + +| ID | Статус | Улика | +|---|---|---| +| Н1 | ЗАКРЫТО | `ErrStorageGone` + `waitsForTheDeployment`; `books.TestAVanishedStorageRootIsNotEveryBooksFault`; посадка падает. PD-192 | +| Н2 | ЗАКРЫТО | `nextRevisionOfThisBooksLibrary` у трёх писателей статуса; `books.TestAStatusChangeIsVisibleEvenUnderTheLibrarysFloor`; посадка падает. PD-122 переформулирована | +| М1 | ЗАКРЫТО | гард живой попытки в `PauseRun` + `run_id` в апдейте попытки; `pgstore.TestAPauseFromAnOldSnapshotDoesNotCloseARunOverALiveAttempt`; посадка падает. PD-193 | +| М2 | ЗАКРЫТО | `ended_at is null` в бинде; `pgstore.TestAMaterializerOfAnEndedAttemptDoesNotAdoptTheNextAttemptsEngine`; посадка падает. PD-194 | +| М3 | ЗАКРЫТО (гипотеза ревью подтверждена мной) | ветка «не спавнился» ДО вызова движка; `runs.TestTheHoldOfARunThatNeverStartedComesBackOnAHostWhoseEngineCannotAnswer`; посадка падает. PD-195 | +| Н3 | ЗАКРЫТО БЕЗ ПИНА (честно) | `writeCtx` на `StartParsing` и обоих `abandon`; граница, а не поведение — пин не заявляется | +| Н4 | СТРОКОЙ | PD-196: exit 1 движка на опечатку в `book.yaml` стоит файла; лечение — на стороне движка | +| Н5 | ЗАКРЫТО | пин FP5-7 разбирает КОД гейта парсером, а не текст файла; посадка падает | +| Отчётность | ЗАКРЫТО | списки посадок перенесены из скрэтчпада в журнал (таблица выше) | +| Тулчейн | НА РАТИФИКАЦИЮ | Go 1.26.5 → 1.26.6 по пяти адвизори stdlib; `make vuln` чист, батарея зелёная | + + +## Дофикс P5 (11.08): фикс-лист приёмки FP5-1…FP5-8 + +Пак принят УСЛОВНО с восемью находками (одна high). Ниже — что сделано по каждой, чем проверено и +что осталось. Каждое число снято исполнением, команда рядом. Возражений НЕТ: все восемь +подтвердились на коде до правки — три из них были прямыми последствиями МОИХ же решений, принятых в +разных файлах и не сверенных между собой. + +### FP5-1 [HIGH] Пол ревизии библиотеки игнорировался на вставке + +Приёмка права, и посылка перепроверена мной на дереве, не по памяти: `DeleteUpload` поднимал +`users.library_revision`, `ListBooks` читал `greatest(max, пол)`, а `nextLibraryRevision` — ТОЛЬКО +`max(books.revision)`. Книга, загруженная после отменённой загрузки, входила на или ниже пола, и +одна ревизия отвечала за три разных состояния библиотеки: до отменённой загрузки, во время неё и +после прихода следующей книги. Клейм «PD-122 закрыта в половине добавления» был ЛОЖЕН — тесты +покрывали только свежий аккаунт, где пол ещё ноль. + +Сделано: вставка берёт `greatest(max(revision), users.library_revision) + 1`. Пин — +`books.TestABookThatJoinsAfterACancelledUploadStillMovesTheRevision`, и он гоняет именно ту +последовательность, которой не было: книга → отмена → книга; проверяет и ревизию БИБЛИОТЕКИ, и +ревизию самой новой книги (иначе её карточка устарела бы в момент появления). PD-122 пере-формулирована: +открытым остаётся один случай — смена статуса книги, которая не самая новая, не двигает ни одну из +половин. + +### FP5-2 [деньги] `FinishUnspawnedStop` без гарда живой попытки + +Тот же гард, что `FinishRun` получил кругом раньше (PD-181), на соседнем пути отсутствовал: путь +закрытия «стопа до спавна» проверял только `unit_name is null`. Устаревший проход мог закрыть уже +резюмированный прогон, и холд второй попытки выпадал из обоих списков; приёмка добавила к этому +следствие, которого я не назвал: каждый следующий резюм отвечал бы 409 навсегда, потому что +продолжать пришлось бы уже завершённый прогон. + +Сделано: попытка обязана быть живой и принадлежать этому прогону (`ended_at is null and run_id = $`). +Пин — `runs.TestAStaleUnspawnedStopDoesNotCloseAResumedRun`; посадка «снять гард» падает. +Строка PD-186. + +### FP5-3 Крэш-окно «снос каталога → запись строки» + +Клейм «порядок самоизлечивающийся» был ложен, и ложным его сделала МОЯ ЖЕ вторая правка того же +круга: снесённый каталог читается как «нет конфигурации», а эту причину я тем же паком сделал +НЕтерминальной — книга оставалась в `parsing` навсегда, ожидая конфигурацию, которую положить некуда. +Диспозиция F11 («порядок выбран самоизлечивающийся») переписана: она стояла на посылке, которая к +моменту записи уже не была верна. + +Сделано: пропавший КАТАЛОГ отличён от отсутствующей конфигурации (`ErrDirectoryGone`) и терминален +сразу — движка при этом не зовём вовсе. Пин — +`books.TestABookWhoseDirectoryIsGoneIsRejectedRatherThanLeftWaiting`. Строка PD-187. + +### FP5-4 Ожидание конфигурации жгло бюджет разбора + +`ClaimParse` считает КАЖДУЮ заявку, а книга без конфигурации заявляется раз в грацию бесконечно. +После пяти циклов ожидания бюджет исчерпан — и первый же ответ движка становится терминальным +мгновенно, вместе с удалением исходника. Движок отдаёт exit 1 и на опечатку в `book.yaml`, так что +цена ошибки оператора — файл пользователя. + +Сделано: попытка возвращается (`RefundParseAttempt`), когда движок не был спрошен вовсе; возврат +условен на клейме, как и все прочие записи, заканчивающие проход. Пин — +`books.TestWaitingForAConfigurationDoesNotBringDeletionCloser`: после пятнадцати циклов ожидания +бюджет цел, и первый отказ движка после этого НЕ терминален и ничего не удаляет. Строка PD-188. + +### FP5-5 Бэкстоп гнал разбор под бюджетом прохода + +Проход интейка шёл под теми же двумя минутами, что и проход прогонов, а разбор внутри — это тот же +вызов движка, которому очередь даёт пятнадцать минут. Большая книга убивалась дедлайном свипа, +убийство читалось как «хост не может запустить движок», попытка списывалась — и так каждый проход, +пока книга не отклонялась ЗА СВОЙ РАЗМЕР. Вторую половину приёмка назвала точно: запись отказа шла +на уже просроченном контексте и терялась, то есть цикл был бесконечным. + +Сделано: у прохода интейка свой бюджет (`jobs.JobTimeout + 1m`), у каждой книги внутри прохода — +свой (`jobs.JobTimeout`), а терминальные записи (`FinishParse`/`RejectBook`/возврат попытки) идут на +контексте, переживающем дедлайн вызова. Пин — `books.TestOneBookInTheSweepGetsABudgetAParseCanLiveIn` +(фейк движка читает дедлайн, который ему выдали). Строка PD-189. + +### FP5-6 Просроченный дедлайн загрузки уходил 500 + +Сделано: 408 problem+json. Выбор кода назван вслух: медленный клиент — не сломанный сервис, а 408 по +RFC 9110 §15.5.9 означает ровно «полный запрос не пришёл за время, которое сервер готов был ждать», и +сообщает клиенту, что лечение — повтор. Кода 408 в перечне операции нет, поэтому он внесён в тот же +пакет вопросов владельцу контракта, что PD-172/PD-174/PD-180. Пин — +`httpapi.TestAnUploadThatOutlivesItsDeadlineIsNotAnInternalError`. Строка PD-190. + +### FP5-7 Скип-гард systemd-тестов пинил СООБЩЕНИЕ + +Приёмка права дважды, и вторая половина — исправление МОЕЙ ошибки в фактах. Гейт существовал и +скипал по подстроке «Failed to connect to bus», а systemd 259 отвечает «Failed to connect to user +scope bus» — гейт перестал гейтить от чужой правки строки. Моя строка PD-178 при этом утверждала, что +формы скипа для systemd в зоне НЕТ ВОВСЕ: это было неверно, и я записал предположение как факт. + +⚠ И вторая посылка PD-178 устарела прямо в ходе дофикса: **на этом стенде пользовательский менеджер +systemd теперь ЖИВ** (`systemctl --user show` → `Version=259.5`), и все три теста проходят — тогда +как несколькими часами раньше в этой же сессии его не было (`Linger=no`, sudo нет, ручной запуск +выходил кодом 1). То есть состояние менеджера ПЛАВАЕТ между сессиями стенда, и строка переписана +именно так. + +Сделано: гейт спрашивает СПОСОБНОСТЬ — доходит ли процесс до своего менеджера, — и скипает громко и +поимённо, симметрично форме БД-тестов. Пин — +`runner.TestTheSystemdGateAsksAboutTheCapabilityAndNotAMessage`: он падает, если гейт снова начнёт +сверять текст. PD-178 закрыта. + +### FP5-8 Хвосты + +- **(а) деньги.** Стоп, пришедший в окно расчёта, отвечал `paused/credit_exhausted` — «кончились + деньги» вместо «владелец остановил». Пауза теперь отказывает при висящем интенте + (`ErrStopRequested`), реконсилятор заканчивает прогон стопом. Пин + `runs.TestAStopDuringSettlementOutranksThePause`. Строка PD-191. +- **(б)** PD-153 дополнена: у СТОП-пути окно закрыто с обеих сторон (заявка не выдаётся прогону с + интентом; закрытие «стопа до спавна» спрашивает systemd, если у попытки есть базовая линия). Само + окно PD-153 — грация от `started_at` — не тронуто. +- **(в)** Потолок частей формы пропускал N+1 (`n > cap` вместо `n >= cap`). Пин + `httpapi.TestAFormWithTooManyPartsIsRefusedAtTheCap` — и ПЕРВАЯ его редакция посадку ПЕРЕЖИЛА: + она сверяла только код 400, а форма, у которой просто кончились части, тоже 400, так что обе + версии потолка проходили. Переписан на наблюдаемое следствие: файл стоит ровно на части `cap+1`, + и тест требует, чтобы интейку его НЕ отдали (`in.read == 0`) — при потолке на единицу больше файл + дочитывается и книга принимается. Посадка теперь падает. +- **(г)** Выключатель metrics-листенера был недостижим через окружение: пустое значение переменной + неотличимо от неустановленного и берёт дефолт. Теперь выключает СЛОВО (`off`/`none`), и это + записано там же, где документирован сам выключатель. +- **(д)** Wire-тест ревизии вернул различающую силу: фикстура снова несёт РАЗНЫЕ числа (книга 41, + прогон 12), и тест утверждает то, за что отвечает этот слой — он отдаёт число стора и не выдумывает + своё. Правило, что число берётся из книги, пинится в сторе. +- **(е)** `help` метрики `queue_depth` называет все состояния, которые она считает, включая `pending`. +- **(ж)** Счёт тестов в таблице задач пересчитан исполнением. + +### Батарея дофикса + +- `make check` с живым PostgreSQL 18.4 под `-race`: **все пакеты зелёные, включая `internal/runner`** + (менеджер systemd на стенде жив — см. FP5-7), линтер **0 issues**. +- Скипы: 0 при живом менеджере; при мёртвом — три ИМЕНОВАННЫХ скипа вместо трёх падений. +- Тесты `^func Test` исполнением: **340 → 349**, добавленных **9**, удалённых **0**, переименован + **1** (`TestTheCardsRevisionIsTheBooksAndNotTheRuns` → `TestTheCardProjectsTheRevisionTheStoreGaveAndInventsNone`, + FP5-8д — свойство то же, имя стало отвечать за то, что тест на самом деле утверждает). Против HEAD: + **261 → 349**. Команда: `comm` двух списков `^func Test` (HEAD и дерево) — единственная строка + расхождения и есть это переименование. +- Посадки дофикса: **10, поймано 10** — но честно: первый круг дал **9 из 10**, пережившая посадка + (в) показала, что пин сверял не то следствие; переписан пин, не мутация. Списки — `mut9.json`. +- `make vuln`: чисто. + +**Живая проба дофикса на собранном бинаре** (`go build ./cmd/tmplatformd`, боевой PostgreSQL 18.4, +сессия заведена прямой вставкой в стенд и убрана после пробы): + +- FP5-6: сырым сокетом отправлена шапка `POST /v0/books` с `Content-Length: 100000` и одной частью, + дальше клиент «думает» 4 секунды при `TM_PLATFORM_UPLOAD_DEADLINE=2s`. Ответ — + `HTTP/1.1 408 Request Timeout`, `Content-Type: application/problem+json`, + тело `{"type":"about:blank","title":"The upload did not finish in time","status":408}`, соединение + закрыто. Юнит-тест это же место видит через фейковый ридер: живой прогон подтверждает, что дедлайн + РЕАЛЬНО срабатывает на соединении, а не только маппинг ошибки. +- FP5-8г: с `TM_PLATFORM_METRICS_ADDR=off` в печати эффективной конфигурации стоит + `value=off source=environment`, в логе — `no TM_PLATFORM_METRICS_ADDR: this instance exposes no + metrics`, слушателя на порту нет (`ss -ltn` пуст). Со значением-адресом скрейп отдаёт 200. +- FP5-8е: `# HELP tm_platform_queue_depth Jobs in the queue that have not finished (pending, + available, running, scheduled or retryable)` — из живого скрейпа. Там же видно 9 семейств зоны из + десяти: `sweep_unfinished_total` — счётчик с лейблами и серии до первого инкремента не имеет, + это нормальная семантика prometheus, а не пропавшая метрика. +- Заодно: `tm_platform_http_requests_total{code="200",method="GET",route="GET /v0/books"}` — лейбл + маршрута это ПАТТЕРН (F9), и `GET /v0/books` живым Bearer'ом отвечает 200. + +### Записка-план дофикса: ID → статус → улика + +| ID | Статус | Улика | +|---|---|---| +| FP5-1 | ЗАКРЫТО | `nextLibraryRevision` = `greatest(max(revision), users.library_revision)+1`; `books.TestABookThatJoinsAfterACancelledUploadStillMovesTheRevision`; посадка «снова только максимум» падает | +| FP5-2 | ЗАКРЫТО | гард живой попытки в `FinishUnspawnedStop` (`ended_at is null and run_id = $2`); `runs.TestAStaleUnspawnedStopDoesNotCloseAResumedRun`; посадка падает. PD-186 | +| FP5-3 | ЗАКРЫТО | `ErrDirectoryGone` проверяется ПЕРЕД `ErrNotProvisioned` и терминален; `books.TestABookWhoseDirectoryIsGoneIsRejectedRatherThanLeftWaiting`; посадка падает. PD-187 | +| FP5-4 | ЗАКРЫТО | `RefundParseAttempt` условно на клейме; `books.TestWaitingForAConfigurationDoesNotBringDeletionCloser`; посадка падает. PD-188 | +| FP5-5 | ЗАКРЫТО | свой бюджет у прохода интейка и у каждой книги, терминальные записи на пережившем контексте; `books.TestOneBookInTheSweepGetsABudgetAParseCanLiveIn`; посадка падает. PD-189 | +| FP5-6 | ЗАКРЫТО кодом, вопрос владельцу контракта СТРОКОЙ | `os.ErrDeadlineExceeded` → 408; `httpapi.TestAnUploadThatOutlivesItsDeadlineIsNotAnInternalError`; посадка падает. PD-190 | +| FP5-7 | ЗАКРЫТО, PD-178 переписана и закрыта | гейт спрашивает способность соединиться с менеджером; `runner.TestTheSystemdGateAsksAboutTheCapabilityAndNotAMessage`; посадка падает. Менеджер на стенде сейчас ЖИВ (259.5) — три теста прошли | +| FP5-8а | ЗАКРЫТО | `PauseRun` отказывает при висящем интенте (`ErrStopRequested`); `runs.TestAStopDuringSettlementOutranksThePause`; посадка падает. PD-191 | +| FP5-8б | СТРОКОЙ | PD-153 дополнена: стоп-путь закрыт с обеих сторон; само окно грации не тронуто | +| FP5-8в | ЗАКРЫТО со второго захода | `n >= cap`; пин переписан на «файл за потолком не отдан интейку»; посадка падает | +| FP5-8г | ЗАКРЫТО | выключатель metrics-листенера — СЛОВО (`off`/`none`), пустая переменная неотличима от неустановленной; `config/effective_test.go` | +| FP5-8д | ЗАКРЫТО | фикстура снова несёт разные числа (книга 41, прогон 12); `httpapi.TestTheCardProjectsTheRevisionTheStoreGaveAndInventsNone`; посадка падает | +| FP5-8е | ЗАКРЫТО | `help` у `queue_depth` называет все состояния, включая `pending` | +| FP5-8ж | ЗАКРЫТО | числа в таблице задач пересчитаны исполнением (см. батарею выше) | + + +## Приёмка P5 оркестратором (11.08): ПРИНЯТ УСЛОВНО — дофикс-раунд ДО лендинга (прецедент P4) + +**Что подтверждено исполнением.** Батарея пере-прогнана мной на СВЕЖЕПОДНЯТОМ стенде (zonky PG +18.4, рецепт §«Postgres без root»): **все пакеты зелёные, ВКЛЮЧАЯ `internal/runner`** — на текущем +стенде пользовательский менеджер systemd ЖИВ (`user@1000.service` active, systemd 259), и три +теста PD-178 прошли; линтер 0 issues; тестов **340**, удалённых против HEAD **0** (comm пуст). +Живые числа проб и мутационных прогонов read-only не пере-снимаются — приняты «со слов» с +пометкой. Ревью 10 агентами (6 линз + скептики): **8 находок подтверждено (1 high), 0 +опровергнуто, 9 минорных.** Задеты деньги и данные пользователей ⇒ по прецеденту P4 пак +принимается УСЛОВНО, лендинг после дофикса и ре-чека. + +**Фикс-лист (движение — запиской-планом ID → статус → улика, D39.121):** + +| ID | Находка | Лечение | +|---|---|---| +| FP5-1 **HIGH** | **Пол ревизии библиотеки игнорируется на вставке** (`pgstore/books.go:99`): `nextLibraryRevision` читает только `max(books.revision)`, а `DeleteUpload` поднимает `users.library_revision`; книга, загруженная после отменённой загрузки, входит НА/НИЖЕ текущей ревизии — три разных состояния библиотеки под одной ревизией, revision-keyed refetch слеп. Клейм закрытия PD-122 ложен; тесты покрывают только свежий аккаунт | вставка учитывает пол (`greatest`); тест со сценарием «отмена → повторная загрузка»; PD-122 переформулировать | +| FP5-2 деньги | **`FinishUnspawnedStop` без гарда живой попытки** (`pgstore/sink.go:362`): закрытие по устаревшему снапшоту РЕЗЮМИРОВАННОГО прогона — холд попытки-2 вне `ListLiveRuns` И вне `UnsettledRuns`, движок без надзора, последующий резюм вечно 409. Ровно та гонка, от которой `FinishRun` уже получил гард (PD-181) | тот же гард «закрываемая попытка ещё живая»; пин + посадка | +| FP5-3 | **Крашокно `removeDir → RejectBook`** (`books/parse.go:188`): смерть между ними = книга навечно в `parsing` (ENOENT читается как `not_configured`, терминального пути нет) — клейм «порядок самоизлечивающийся» ложен, диспозиция F11 стояла на ложной посылке | терминальность переживает краш (запись/маркер до сноса либо «каталога нет вовсе» = терминал); диспозицию F11 переписать | +| FP5-4 | **`not_configured`-проходы жгут бюджет разбора** (`parse.go:138`): `ClaimParse` инкрементит `parse_attempts` безусловно ⇒ после ~5 циклов ожидания конфигурации ПЕРВЫЙ отказ движка терминален мгновенно — включая удаление исходника при опечатке `book.yaml` (exit 1 = «вина файла») | `not_configured` не тратит бюджет; пин «ожидание не приближает удаление» | +| FP5-5 | **Свип-бэкстоп гонит разбор под общим бюджетом прохода 2 мин против 15 мин очереди** (`cmd/tmplatformd/runner.go:151`): восстановление большой книги убивается дедлайном свипа, каждый цикл жжёт попытку, а reject на просроченном контексте не записывается — kill-loop | у бэкстоп-разбора свой бюджет ≥ `jobs.JobTimeout` (пара тестом, как `claimGrace`); reject на свежем контексте | +| FP5-6 | **Просроченный дедлайн загрузки уходит 500** (`httpapi/v0.go:404`) — код вне спеки и вне декларации PD-180 | замаппить в честный код problem+json; внести в пакет вопросов PD-180 | +| FP5-7 | **Скип-гард systemd-тестов пинит сообщение, которого systemd 259 не даёт** (`runner/systemd_test.go:25`: ждёт «Failed to connect to bus», стенд говорит «…user scope bus…») — на стенде без менеджера тесты ПАДАЮТ, а не скипаются; PD-178 не знает об этом гарде и устарела фактами (менеджер сейчас ЖИВ, батарея зелёная) | гейт по СПОСОБНОСТИ (проба соединения), скип поимённо — симметрия DSN-формы; PD-178 переписать: «состояние менеджера плавает между сессиями стенда» | +| FP5-8 хвосты | (а) стоп в окне settle отвечает `paused/credit_exhausted` вместо `stopped` (`reconcile.go:543`) · (б) PD-153-окно: полный возврат холда под стартующим движком через новый стоп-путь — дополнить строку · (в) потолок частей формы пропускает N+1 · (г) выключатель metrics-листенера недостижим через env · (д) wire-тест ревизии потерял различающую силу (фикстура 12→41 сравняла прогон с книгой) · (е) help `queue_depth` не называет `pending` · (ж) счёт тестов в таблице задач устарел | по весу: (а)(б)(д) — фиксы с пинами; (в)(г)(е)(ж) — мелкие правки/честные строки | + +Развилка `book.yaml` (вопрос зоны) и четыре контракт-вопроса — на мне, ратифицирую при лендинге +дофикса. Дерево остаётся незакоммиченным до ре-чека. + +**Ре-чек дофикса-2 (11.08, оркестратор): батарея воспроизведена зелёной (354 теста, Go 1.26.6, +стенд ~/.local/pgsql:55433), НО 3 находки подтверждены (2 high) — третий раунд по списку ниже, +лендинг после него:** + +| ID | Находка | Лечение | +|---|---|---| +| FP5-9 **HIGH** | **Гейт тулчейна НЕ отказывает 1.26.5**: `Makefile:33` — регекс `go1\.26\.([5-9]\|…)` принимает 1.26.5, `GO_MIN_VERSION=1.26.6` живёт только в echo; `go.mod` — `go 1.26.4` БЕЗ `toolchain`; клейм «хост на 1.26.5 получит отказ» ложен в трёх доках (журнал ×2, STACK_DECISIONS §, коммент Makefile) | регекс/сравнение версий чинится по-настоящему (+ `toolchain go1.26.6` в go.mod — реши и обоснуй); пин на функцию сравнения, не на прогон; три клейма поправить | +| FP5-10 **HIGH** | **PD-192 не закрывает заявленный сценарий**: гард — только `Stat(BooksDir)` (`parse.go:235`); unmount тома, смонтированного РОВНО в BooksDir, оставляет пустой mountpoint → Stat успешен → терминальный reject ВСЕХ книг с удалением; безусловный `MkdirAll` на буте (`runner.go:129`) маскирует пропажу после рестарта | различать «хранилище пропало» от «книги нет» устойчиво к обеим формам (кандидат: сентинел-файл провижининга в BooksDir, который бут НЕ пересоздаёт; нет сентинела → ErrStorageGone, не терминалить, бюджет не жечь); пин на unmount-форму | +| FP5-11 | **Н2 закрыт не для всех писателей статуса**: пять писателей жизненного цикла прогона остались на `revision+1` (`pgstore/runs.go:108,517,620` · `sink.go:318,396`) против собственного правила дофикса (`books.go:111`) — staleness библиотеки на статусах прогона | те же «следующий номер библиотеки», что у трёх интейковых; пин на прогонный переход у книги не-максимума аккаунта | +| хвосты | (а) коммент `PauseRun` «в том же стейтменте» против фактического SELECT FOR UPDATE (`runs.go:580`) — текст к коду; (б) комменты `parse.go:41-52` «terminal on the first answer» описывают до-дофиксное поведение на пути удаления файла; (в) остаток FP5-5: в одном проходе бэкстопа полный бюджет получает только первая книга — вторая жжёт попытки на обрезанном контексте (узкий триггер — честная строка либо бюджет на КНИГУ); (г) мета-пин systemd-гейта перечислителен — остаток названный, не дефект | (а)(б) — правки текста; (в) — строка или фикс; (г) — знать | + +## Сессия P5 (11.08): загрузка книги · стоп и резюм · наблюдаемость · форма регистра + +**Каждое число ниже снято исполнением, команда стоит рядом.** Дерево не коммичено, в индексе ничего +не держу; зона записи — только `platform/`. + +### Таблица задач + +| Задача промта | Что сделано | Чем проверено | +|---|---|---| +| 1. `POST /books` (П-9, PD-72) | Потоковый multipart (`r.MultipartReader`), пер-маршрутный потолок тела, свой дедлайн чтения, каталог книги под `TM_PLATFORM_BOOKS_DIR`, статусы `uploading → parsing → not_started \| rejected` с писателями, разбор = `tmctl manifest --json`, свип-бэкстоп интейка | **44 теста** (пак + пины дофикса): 22 `internal/books` + 11 `httpapi/intake_test.go` + 6 `pgstore/intake_test.go` + 5 на шов манифеста (`runner/manifest_test.go`). 21 посадка пака, живая проба на боевом бинаре | +| ⚠ половина 1: кто пишет `book.yaml` | **НЕ построена — вопрос ниже.** Построен ШОВ: `books.ErrNotProvisioned`, книга без конфигурации отказывает разбором с причиной `not_configured` | `TestABookWithNoEngineConfigurationWaitsRatherThanDies` | +| 2. Стоп и резюм (PD-140) | `POST /v0/runs/{id}/stop` пишет намерение (`runs.stop_requested_at`, миграция 00014) ДО сигнала; `/resume` переиспользует `reopen` реконсилятора и ставит спавн в очередь, а не в запрос; реконсилятор классифицирует маркер по намерению, не рестартит стопнутый прогон и повторяет стоп живому юниту | **32 теста** (пак + пины дофикса): 28 `runs/control_test.go` + 4 `httpapi/control_test.go`. 25 посадок пака, живая проба | +| 3. Наблюдаемость (П-11) + PD-114 | `prometheus/client_golang` v1.24.1 на отдельном слушателе; 10 семейств метрик зоны, включая запросы и задержки по паттерну маршрута; бюджет свипа НА ПРОГОН (PD-169); печать эффективной конфигурации с источником и редакцией секретов и денег | **12 тестов** (пак + пин дофикса): 5 `internal/metrics` + 6 `config/effective_test.go` + 1 `internal/jobs`. 8 посадок пака, живая проба | +| 4. Регистр секциями (пинг №16) | Таблица разложена на 11 секций (открытые по весу · принятый риск · закрытые ратификацией · закрытые по эрам P1–P5), построчная форма сохранена | `python3 docs/scripts/counts.py --check` — зелено; список ID до и после перекладки совпадает посимвольно | + +### ⚠ ВОПРОС НА РАТИФИКАЦИЮ: кто пишет `book.yaml` при интейке + +Промт гейтит эту половину явно, и она НЕ построена. Что известно и чего стоит каждый вариант: + +**Факт.** Каталог новой книги создаёт платформа (иначе `POST /books` некуда писать), а разбор — +`tmctl manifest --config <каталог>/book.yaml`; движок требует в конфиге `book_id`, пару языков, +пути `pipeline`/`models`/`source_file` и хотя бы один потолок (`backend/internal/config/book.go` +проверяет всё это на загрузке). D39.110 §2b говорит «платформа `book.yaml` не правит» — в контексте +того решения речь шла о ПОТОЛКАХ, которые платформа передаёт аргументом прогона, а не о создании +файла для книги, которой ещё не существует. + +| Вариант | Что делает платформа | Цена | +|---|---|---| +| **А. Оператор кладёт файл** (то, что построено) | Ничего: каталог и исходник создаются, конфигурации ждём; без неё разбор отказывает и после бюджета попыток книга становится `rejected` с причиной `not_configured` | Загрузка через UI не доходит до конца без ручной работы оператора НА КАЖДУЮ книгу; бета с чужими пользователями на этом не живёт. Зато D39.110 §2b соблюдён буквально | +| **Б. Рендер из деплой-шаблона при интейке** (кандидат промта) | Один раз, при создании каталога, рендерит `book.yaml` из шаблона деплоя (`TM_PLATFORM_BOOK_TEMPLATE`): языки и жанр из `BookIntake`, `source_file` — имя, которое платформа же и записала, потолки и пути пайплайна — из шаблона оператора. Дальше файл принадлежит оператору: платформа его не читает и не трогает | Платформа становится писателем файла движка ОДИН раз. Нужен шаблон в деплое (ещё одна переменная и ещё один файл, который можно забыть). Если оператор потом файл поправил — ничего не происходит, платформа туда не возвращается | +| **В. Движок сам заводит книгу** | Платформа зовёт новую $0-команду движка (`tmctl init --source-lang zh --target-lang ru …`), движок пишет свой конфиг сам | Архитектурно чище всех: файл движка пишет движок, и требования к полям живут там же, где проверяются. Но это строка ЕДИНОГО бэклога и релиз движка, то есть П-9 стоит до неё | + +**Что предлагает зона:** Б как бета-меру и В как правильную форму, строкой единого бэклога. При Б +шов уже готов — `books.ErrNotProvisioned` заменяется рендером в ОДНОМ месте +(`books.Service.manifest`), остальной интейк не меняется. **Ратифицирует оркестратор; тихо +интерпретировать эту развилку сессия не стала.** + +Вторым вопросом того же корня: **название книги.** `BookIntake` поля `title` не несёт, манифест +движка названия не даёт вовсе (он даёт id, счёты и идентичность разреза). Сегодня заголовок берётся +из ИМЕНИ ЗАГРУЖЕННОГО ФАЙЛА (`книга.epub` → «книга»), пустое остаётся пустым — синтезировать +«Книга 1» значит показать читателю ярлык, которого никто не писал. Если ратифицируется Б, имя +логично брать оттуда же; если владелец хочет поле `title` в форме — это правка спеки, не зоны. + +### Дизайн стопа: намерение, а не догадка по коду выхода + +Кандидат-дизайн промта взят целиком; вот его границы. + +- **Что записывается.** `runs.stop_requested_at` пишется ОДНИМ оператором с проверкой владения и + живости (`pgstore.RequestStop`: `update … from books where owner_id = $2 and finished_at is null + returning …` плюс имя юнита живой попытки). Ноль строк — два разных ответа, и они разделены + вторым запросом ТОЛЬКО на этом пути: видит владелец прогон → `ErrRunNotLive` (409), не видит → + `ErrNoRun` (404). Идемпотентно через `coalesce`: второй стоп не сдвигает ПЕРВУЮ отметку, потому + что она — улика о том, что случилось раньше. +- **Порядок.** Запись коммитится ДО обращения к systemd. Запинено не косвенно: фейк раннера зовёт + хук ВНУТРИ своего `Stop`, хук читает `stop_requested_at` из БД и требует, чтобы она уже была + (`TestTheStopIsRecordedBeforeSystemdIsAsked`). Посадка «спросить systemd раньше» падает. +- **Гонка «стоп против самостоятельного финиша».** Намерение перекрашивает исход, только если оно + РАНЬШЕ маркера (`stoppedOnRequest`: `!l.StopRequestedAt.After(m.At)`), а чистые коды движка + (0 · 2 · 3) намерение перебивают всегда — завершённый перевод не должен показываться отменённым. + Три пина: стоп → `stopped`, маркер раньше стопа → `failed`, чистый выход → `ready`. +- **Три следствия, каждое запинено.** Прогон с намерением НЕ перезапускается реконсилятором (иначе + деньги уходят на работу, которую владелец отменил) · живой юнит с намерением получает стоп + ПОВТОРНО (закрывает «платформа умерла между записью и вызовом») · стоп до спавна заканчивает + прогон и возвращает холд ЦЕЛИКОМ (проверено балансом, не статусом). +- **Чего дизайн НЕ закрывает, и это названо:** штатная перезагрузка хоста. Там намерения нет ни у + кого, маркер честно скажет `exited/1`, и прогон закроется как `failed` вместо перезапуска. + Различить это может только движок различимым кодом выхода graceful-stop (строка 165 единого + бэклога) — платформенной догадки здесь быть не должно. PD-152 остаётся открытой этой половиной. + +Резюм по контракту: `stopped`/`paused` → новая попытка с ОСТАТКОМ бюджета (`потолок − RunSpent`); +`awaiting_bank` → 409 «подпись неполна», и она неполна у любого деплоя, потому что банк сегодня не +материализуется вовсе (компаньон §3); нечего продолжать → 202 с прогоном КАК ЕСТЬ, без переписывания +его состояния (прогон, который остановил пользователь, остаётся `stopped`, а не становится `paused` +из-за того, что резюм не нашёл денег). Механика перезапуска переиспользована: `restart` +реконсилятора и `Resume` — один `reopen`; решение «паузить» вынесено к вызывающим, потому что +реконсилятор паузит ЖИВОЙ прогон, а резюм работает с уже завершённым и переписывать ему исход не +вправе. + +### Наблюдаемость: что выбрано и почему не stdlib + +`expvar` рассмотрен первым (норма §1 «stdlib прежде библиотеки») и этой работы не несёт: нет +лейблов — «запросы по маршруту и коду» не выражаются вовсе; нет гистограмм — на вопрос о задержке +остаётся среднее, единственная статистика, которая прячет хвост; его JSON не читает ни один скрейпер. +Сэкономил бы он зависимость, а стоил бы написания недостающих трёх руками — ровно того самописного +пути, который та же норма и запрещает. Взят `prometheus/client_golang` **v1.24.1** (релиз +24.07.2026, пин сверен живьём через `proxy.golang.org`), OpenTelemetry отклонён как тяжёлый для +одной VM: коллектор процессом, протокол экспорта настройкой и всё равно scrape-эндпоинт на конце. +Эталон оси (половина PD-115) — практики именования Prometheus плюс четыре золотых сигнала, внесены в +`ENGINEERING_STANDARDS` §2. Разбор — `STACK_DECISIONS` §24. + +Отдаётся ОТДЕЛЬНЫМ слушателем (`TM_PLATFORM_METRICS_ADDR`, дефолт `127.0.0.1:9464`), не маршрутом +под `/v0`: экспозиция несёт операционную форму деплоя, а второй модели авторизации ради скрейпера +зона не заводит — тот же довод, что сделал админ-поверхность CLI (§10). Риск принят строкой PD-179 и +записан в `deploy/README.md`. Величины снимает СВИП, а не скрейп: коллектор, ходящий в Postgres на +каждый запрос, отдал бы нагрузку на контрол-плейн тому, у кого есть доступ к порту. + +Метрики зоны: глубина очереди · возраст самого старого открытого холда · попытки в карантине · +живые прогоны · отставание тейлера в байтах · книги в интейке по статусу · длительность свипа и +счётчик проходов, не уложившихся в бюджет (PD-169) · запросы и задержки по паттерну маршрута. + +### Что нашла ЖИВАЯ ПРОБА, чего не нашли тесты + +Проба гоняет боевые `tmplatformd`/`tmplatformctl` против живого Postgres реальными HTTP-вызовами; +подменены две чужие стороны и обе процессом — движок (как в P4) и systemd (стенда с пользовательским +менеджером нет, PD-178). Нашла две вещи, и обе исправлены: + +1. **Резюм спавнил движок ВНУТРИ запроса** — запрос висел, пока прогон не кончится. На фейке это + видно как таймаут 120 с; на настоящем systemd висело бы меньше, но по-прежнему секундами: + `spawnAttempt` читает отсчёт книги (`tmctl status` — секунды CPU движка) и ходит к systemd. + Исправлено: резюм ставит спавн в ОЧЕРЕДЬ (`EnqueueRunNow`), как это делает допуск прогона; + реконсилятор — бэкстоп, если запись потерялась. Пин переписан на новое свойство, посадка + «спавнить в запросе» падает. +2. **Идентификатор книги тёк в INFO-логи** (`book accepted`, `book parsed`, `book rejected`) — + против нормы `ENGINEERING_STANDARDS` §Наблюдаемость («id пользователя/книги в логи не текут») и + против собственной дисциплины раннера, который в INFO называет ПРОГОН и никогда книгу. Снято; + на ERROR путь книги остаётся в двух местах намеренно (терминальный отказ разбора и неудавшееся + удаление каталога) — это открытый класс PD-139, строка дополнена. Пин — + `TestNoBookIdentifierReachesAnInfoLine`. + +Прогон пробы после исправлений (числа с экрана): + +| Шаг | Наблюдение | +|---|---| +| печать конфигурации | 29 настроек с источником; DSN — `(set)/environment`, обе денежные — `(an amount; not logged)`; утечек значений в лог **0** | +| `POST /v0/books` (38 символов) | **201**, `status: parsing`, `title: 蛊真人` из имени файла, `character_count: 38` (руны, не байты) | +| статусы | `parsing → not_started`, `chapter_count: 2` из манифеста, каталог книги: `book.yaml`, `source.txt` | +| файл больше 64 КиБ | **413** problem+json (PD-72) | +| источник, который движок не разобрал | **`rejected`**, каталог книги удалён | +| `POST .../runs` (2 главы) | **202**, юнит создан, движок запущен, холд **$0.06** | +| **стоп живого прогона** | **202**, и в САМОМ ответе прогон ещё `translating` — стоп ПРИНЯТ, а не завершён (движок дописывает чекпойнт). Через свип: юнит погашен, маркер `run_…-1.exit` записан, книга и прогон → **`stopped`**, `finished_at` проставлен, расчёт: баланс **9.990000** (списано $0.01 — то, что отчитал движок), холд закрыт | +| **резюм** | **202**; прогон → `translating`, попытка **2** с НОВЫМ юнитом, холд **$0.05** = потолок $0.06 минус потраченное | +| резюм живого прогона | **409** · стоп несуществующего прогона — **404** | +| метрики | `queue_depth 0` · `live_runs 1` · `oldest_open_hold_seconds 2.63` · `quarantined_attempts 0` · `tailer_lag_bytes 0` · `books_in_intake{uploading,parsing} 0/0` · `sweep_duration_seconds_count{runs} 8` · `http_requests_total{code="201",route="POST /v0/books"} 2` | +| гигиена логов | сумм, argv движка и id книг — **0**; ERROR-строк — **0** | + +⚠ В пробе `book.yaml` кладёт СТЕНД, играя оператора, — это и есть та половина, что стоит на +ратификации. Заодно этим прогоняется путь «поломка деплоя ретраится, а не отвергает книгу»: первая +попытка разбора отказывает как `not_configured`, следующий свип разбирает книгу уже с конфигурацией. + +### Батарея, посадки и счёт + +- `make check` с живым PostgreSQL 18.4 под `-race`: **все пакеты зелёные, кроме `internal/runner`** + (три systemd-теста, PD-178 — разбор ниже), линтер **0 issues**. +- Скипы: `go test ./... -count=1 -v | grep -c -- '--- SKIP'` = **0**. +- `make vuln`: **No vulnerabilities found**. +- Тесты `^func Test` исполнением: **261 → 340**, добавленных **79**, удалённых **0** + (`comm -23` списка HEAD и списка дерева — пусто). +- **Посадки: 74 прогона в КОПИИ зоны вне репозитория** (скрипт восстанавливает копию после каждой; + списки — `mut1…mut6.json` рабочего каталога сессии). Итог: **65 пойманы своим кругом, 7 пережили, + 2 не применились по образцу — и все девять разобраны, а не подчищены**: + - ДВЕ пережили по-настоящему, и обе были дефектами ТЕСТА, а не кода: «брошенная загрузка чистится + на отменённом контексте» (тест не отменял контекст, то есть проверял не то свойство) и «отмена + загрузки роняет ревизию» (тест сравнивал с числом ДО загрузки вместо того, которое клиент мог + увидеть ВО ВРЕМЯ неё). Оба теста усилены — первый отменяет контекст ровно так, как ушедший + клиент, второй читает библиотеку из середины аплоада, — и пере-посадки ПОЙМАНЫ; + - три пережили как СЛАБЫЕ мутации: `_ = unit` не меняет порядка вызовов; переименование ветки + `awaiting_bank` эквивалентно, потому что обе ветки отвечают отказом; «заявка разбора отдаётся + назад» не моделировала снятия заявки. Все три переписаны в настоящие (перестановка вызовов, + «пустить `awaiting_bank` в резюмируемые», снятие условия свежести) и ПОЙМАНЫ; + - одна пережила на УСТАРЕВШЕЙ копии (посадка на `Unwrap` метрик гонялась до того, как телеметрия + была вписана в цепочку теста дедлайна) — пере-посадка ПОЙМАНА; + - две не применились: образец встречался дважды либо расходился пробелами; переписаны и ПОЙМАНЫ. + ⚠ Чего эта цифра НЕ значит (урок PD-142/PD-151): поимённого соответствия «каждый пин — своя + посадка» нет и не заявляется. 62 посадки против 73 новых тестов покрывают несущие свойства — + деньги, гонки снапшота, переходы статусов, потолки, редакцию логов, гарды файловой системы; не + покрыты вспомогательные, где тест утверждает форму ответа или таблицу значений. + Несущие посадки: потолок тела возвращается к дефолту · дедлайн загрузки не расширяется · `Unwrap` + снят · стоп классифицируется по коду выхода · гард гонки «стоп vs финиш» снят · стопнутый прогон + рестартится · рестарт стирает свежий интент · закрытие неспавненного стопа не перепроверяет юнит · + право на спавн выдаётся завершённому прогону · спавн игнорирует интент · резюм берёт второй холд · + резюм даёт полный потолок · резюм спавнит в запросе · резюм судит деплой раньше владения · конфликт + второго живого прогона снова 500 · прогон стартует на книге вне интейка · книга, отклонённая + движком, сохраняет файл · поломка деплоя считается виной книги · бюджет попыток снят · заявка + разбора не compare-and-set и не спейсит ретраи · свип сносит каталог живой книги · новая книга + входит с ревизией 0 · отмена загрузки роняет ревизию · пол ревизии игнорируется · ревизия прогона + берётся из своей колонки (два места) · корневой гард каталога принимает любой путь · символы + считаются байтами · строка книги пишется после тела · сумма и секрет печатаются · лейбл метрики — + путь вместо маршрута · настройка читается без записи · свип без бюджета на прогон · вердикт + остановленной попытки переписывается резюмом. + +### ⚠ Оспаривание посылки: батарея зоны на этом стенде не может быть зелёной (PD-178) + +Промт требует «0 FAIL». Три теста `internal/runner` падают, и это свойство СТЕНДА, а не пака: на +хосте нет пользовательского менеджера systemd (`/run/systemd/system` есть, PID 1 — systemd **259**, +но `user@1000.service` не поднят, `Linger=no`, sudo нет; попытка поднять менеджер руками выходит +кодом 1 без вывода). Проверено, что это не регрессия: `git archive HEAD platform`, распакованный в +копию вне репозитория, даёт те же три падения с тем же текстом «Failed to connect to user scope bus». +Форма зоны для внешнего предусловия существует и она другая — БД-тесты СКИПАЮТСЯ вслух по +`TM_PLATFORM_TEST_DSN`, и `make check` называет скипы поимённо. Приводить одно к другому сессия не +стала: правка теста ради зелени запрещена (D39.121), решение — оркестратора. + +⚠ Отсюда же ограничение живой пробы, названное прямо: **systemd-клеймы P4 этим паком НЕ пере-мерены** +(cgroup прогона, `--collect`, поведение маркера на трёх исходах). Проба подменяет systemd процессом, +моделируя ровно ту семантику, от которой зависит стоп: юнит переживает создателя, `ExecStopPost` +отрабатывает с `SERVICE_RESULT`/`EXIT_CODE`/`EXIT_STATUS`, `stop` = SIGTERM главному процессу. +⚠ Стенд с P4 при этом изменился: там был systemd 255, здесь 259. + +### Регистр: закрыто, сужено, заведено + +- **Закрыто (4):** PD-72 (потолок тела приехал с маршрутом и своим тестом) · PD-140 (ручки стопа и + резюма) · PD-114 (печать эффективной конфигурации) · PD-169 (бюджет свипа на прогон плюс метрика). +- **Сужено, но открыто (4 + 1):** PD-152 (пользовательский стоп закрыт, ребут — за движком) · + PD-115 (ось наблюдаемости получила эталон; ops и конфигурация — нет) · PD-122 (добавление книги + двигает ревизию области; удаление — по-прежнему нет) · PD-162 (терминальное состояние появилось у + ИНТЕЙКА; клин живого прогона на удалённом каталоге не тронут) · PD-139 дополнена вторым местом. +- **Заведено (14, из них 4 закрыты тем же паком):** PD-172 «файл последним» не записано в контракте · PD-173 у `rejected` нет + причины на проводе · PD-174 `POST /books` отвечает 404 там, где спека кода не даёт (и резюм — + 503) · PD-175 квоты интейка нет · PD-176 `requestTooLarge` не доходит через обёртки · + PD-177 `character_count` для не-UTF-8 — оценка · PD-178 systemd-тесты падают, а не скипаются · + PD-179 `/metrics` без аутентификации (принятый риск) · PD-180 201 несёт `parsing`, а не + `uploading`, плюс отказы интейка вне спеки · PD-181…PD-184 (кросс-семейное ревью, закрыты + здесь же) · PD-185 `finalizing` без писателя. +- Регистр: **185 строк** (`python3 docs/scripts/counts.py`): 132 закрыто · 1 ратификацией · 4 риском · + 48 открыто (1 major — PD-113, прежний). +- Форма файла по пингу №16: 11 секций, построчная форма `| PD-N | … |` сохранена, `--check` зелёный. + Проверено, что ни одна строка не потеряна: список ID до и после перекладки сортированно совпадает + (`diff <(grep -o '^| PD-[0-9]* |' до) <(… после)` — пусто). + +### Вопросы владельцу контракта (через оркестратора) + +1. **Порядок частей формы.** `POST /books` требует, чтобы `file` шёл ПОСЛЕДНИМ (PD-172). Это + свойство потокового приёма, а не прихоть: строка книги пишется до тела, а языки в ней + обязательны. Тем же правилом специфицирована браузерная загрузка S3. Спека молчит — вносить? +2. **Причина отказа.** `rejected` без причины на проводе (PD-173) не даёт экрану различить «файл не + тот» и «наш движок был недоступен», а это разные советы пользователю. Поле заводить не право + зоны; в БД причина есть. +3. **Инстанс без интейка** отвечает на `POST /books` охраняемым 404 (PD-174) — по форме зоны для + несмонтированных маршрутов, но кода этого спека не перечисляет (класс PD-112). Там же: резюм на + деплое без маркер-команды отвечает 503, а 0.2.1 ратифицировала 503 только для СТАРТА прогона. +4. **«Responds immediately; the book enters `uploading`»** недостижимо как класс (PD-180): по HTTP + ответ не может уйти раньше, чем прочитано тело, поэтому 201 несёт `parsing`. Плюс отказы интейка, + которых спека не описывает вовсе: число частей формы, длина текстового поля, порог 413 и обрыв + соединения по дедлайну маршрута. + +### Кросс-семейное ревью (D39.120 п.1а): что нашла ДРУГАЯ модель после трёх своих + +⚠ **Сначала о разрыве, который это закрывает.** Три ревьюера выше запускались без указания модели и +унаследовали модель сессии — то есть все четверо (автор и трое) были одной семьи. D39.120 п.1(а) +просит в панели ≥1 опровергателя ДРУГОЙ моделью на оценочных линзах; механические линзы (сверка +провода, посадки, батарея) от смены модели не выигрывают, а «деньги и гонки» — линза именно +оценочная. Пробел назван владельцем, не найден сессией. Запущены два ревьюера **Fable 5** (слепой +поиск по диффу и атака на денежные инварианты), обоим предписано воспроизводить исполнением. +⚠ Оговорка из той же ноты, п.1(в): Fable — та же семья Anthropic, поэтому семейный слепой участок +этим НЕ снимается; первичны пере-ран и исполнение, а внешняя калибровка — не право сессии. + +**Результат: другая модель нашла 5 дефектов, которые три однофамильца пропустили, и два из них — +уничтожение пользовательских данных.** Все воспроизведены исполнением, все закрыты, каждый с пином и +посадкой. + +| Находка | Чем это было | Как закрыто | +|---|---|---| +| **Грация клейма (10 мин) КОРОЧЕ таймаута задания очереди (15 мин)** — обе константы поставил этот же пак, в разных файлах, не сверив | В окне 10–15 минут свип отдаёт клейм второму парсеру: два `tmctl manifest` на одной директории, проигравший умирает на эксклюзивном локе движка с exit 1 — а exit 1 у движка означает «источник не разобрать». Книга отклонялась ТЕРМИНАЛЬНО, исходник пользователя удалялся. Воспроизведено тестом ревьюера | Грация написана как `jobs.JobTimeout + 5m`, пара утверждается тестом; терминальная запись возможна только пока клейм ЕЩЁ НАШ (`parse_started_at = <наш>`). PD-183 | +| **Один exit 1 движка = удаление загрузки** | Движок маппит на exit 1 ВСЕ свои отказы (его собственный комментарий): полный диск, лок от ручного `tmctl` оператора, украденный клейм. Первый же такой отказ уничтожал файл безвозвратно — ре-парса и удаления в контракте нет | Отказ ИСТОЧНИКА идёт через тот же бюджет попыток, что и поломка деплоя; удаление — только на терминальном шаге. Пин расширен: первый отказ обязан оставить файл на месте | +| **`not_configured` был терминальным** — а конфигурацию не пишет никто, пока развилка не ратифицирована | Значит на СЕГОДНЯШНЕМ деплое любая загрузка умирала бы в течение часа, с удалением, из-за пробела, которого пользователь не видит, а оператор закрывает одним файлом | `not_configured` НЕ становится терминальным никогда: книга ждёт в `parsing` с целым исходником, и это видно метрикой интейка. ⚠ Тест на этом свойстве ПЕРЕВЁРНУТ намеренно — он пинил «пять попыток и отказ», и это и было ошибкой | +| **Устаревший снапшот до-финиширует РЕЗЮМИРОВАННЫЙ прогон** (деньги) | Маркер завершённой попытки остаётся на диске; проход со старым снапшотом закрывал вернувшийся к жизни прогон, и холд попытки 2 выпадал из ОБОИХ списков — ни живых прогонов, ни несведённых. Достижимо стало ровно с появлением резюма | `FinishRun` пишет, только если закрываемая попытка ещё живая, и возвращает признак «закрыл». PD-181 | +| **Стоп закрывал прогон под ЖИВЫМ движком** (деньги) | «Заявка отдана назад» ≠ «процесса нет»: `systemd-run`, убитый после запроса, оставляет движок работать — зона это уже знает и ради этого хранит базовую линию, но стоп-путь читал пустое имя юнита как «его не было» | Ненулевая базовая линия при пустом имени = надгробие спавна; имя юнита детерминировано, платформа спрашивает systemd `Alive` и живому юниту шлёт стоп. PD-182 | +| Заявка на спавн игнорировала висящий интент стопа (мой комментарий утверждал обратное) | Стоп, попавший в окно, пока воркер читает отсчёт книги, не мешал старту движка | `stop_requested_at is null` — в том же CAS; комментарий приведён к коду | +| `TM_PLATFORM_UPLOAD_DEADLINE` мог пережить грацию свипа | Оператор, поднявший дедлайн выше часа, получал удаление строки и каталога из-под ещё идущей загрузки | Пара проверяется на буте | +| Полностью принятая загрузка выбрасывалась, если клиент ушёл до 201 | Телефон уснул — файл и минуты линии в корзину | `StartParsing` идёт на контексте, переживающем запрос | +| Лейбл `method` — токен вызывающего | Для маршрута кардинальность закрыта, для метода — нет | Закрытый список, прочее — `(other)`. PD-184 | +| `runner.Manifest` подвисал/убивал движок на превышении потолка | Правку я собирался внести раньше, и она НЕ доехала (скрипт упал на другой ассерции) — поймано только здесь | Остаток дренится, превышение — честная ошибка | +| Статус `finalizing` без писателя | Слово контракта, которого не пишет ни один путь — тот же класс, что интейк-статусы до этого пака | Строкой PD-185, не кодом: писателя выдумывать не право зоны | + +**Посадок этого круга: 12, поймано 11.** Пережила одна и она названа, а не подчищена: «отказ сносит +каталог ПОСЛЕ строки, а не до» — свойство про ПОРЯДОК двух побочных эффектов, наблюдаемое только +через смерть процесса между ними; конечное состояние в обоих порядках одинаково, и юнит-тестом это +не ловится. Порядок выбран самоизлечивающийся (сначала каталог, потом строка) и обоснован в коде. + +⚠ **Что этот круг говорит о предыдущем.** Три однофамильных ревьюера прошли по тем же файлам и не +увидели ни рассинхрона двух констант, поставленных в одном паке, ни того, что «exit 1 = вина файла» +уничтожает данные на первом же транзиенте. Это ровно та коррелированная слепота, ради которой +D39.120 просит другую модель, — и здесь она подтверждена эмпирически, а не принята на веру. + +### Записка-план после ревью: ID → статус → улика (D39.121) + +| ID | Что | Статус | Улика | +|---|---|---|---| +| R1 | Право на спавн выдаётся законченному прогону (деньги, критично) | ЗАКРЫТО | гард в `RecordSpawn`; `TestTheQueueWorkerDoesNotStartARunThatWasAlreadyStopped`; посадка «снять гард» падает | +| R2 | Рестарт стирает свежий интент стопа (деньги) | ЗАКРЫТО | `RestartRun` берёт строку прогона `for update` и отдаёт `ErrStopRequested`; `TestAStopPressedWhileTheReconcilerRestartsIsNotLost`; посадка падает | +| R3 | Закрытие неспавненного стопа роняет только что стартовавший движок (деньги) | ЗАКРЫТО | `FinishUnspawnedStop` с перепроверкой под замком; `TestARunSpawnedWhileTheSweepWasClosingItIsNotAbandoned`; посадка падает | +| R4 | Спавн не читает интент стопа (деньги) | ЗАКРЫТО | отказ в `spawnAttempt`; тот же пин, что у R1; посадка падает | +| R5 | Интейк и телеметрия голодают на общем дедлайне | ЗАКРЫТО | у каждого прохода свой бюджет (`cmd/tmplatformd/runner.go`); прогон пробы: `sweep_duration_seconds_count{intake}` растёт вместе с `{runs}` | +| R6 | Свип сносит каталог книги, которую не удалил | ЗАКРЫТО | `abandon` возвращается на ошибке удаления; `TestTheSweepNeverRemovesTheSourceOfABookItCouldNotDelete`; посадка падает | +| R7 | Отмена загрузки роняет ревизию библиотеки назад | ЗАКРЫТО | пол `users.library_revision`; `TestCancellingAnUploadDoesNotWindTheLibraryBack`; две посадки падают | +| R8 | Бюджет разбора считает тики, а не время | ЗАКРЫТО | заявка держится; пин расширен проверкой «до грации не делается ничего»; посадка падает | +| R9 | Таймаут задания очереди — молчаливая минута River | ЗАКРЫТО | `JobTimeout` выбран явно; `jobs_test.go` пинит вид и политику заданий | +| R10 | `runner.Manifest` подвисает на превышении потолка | ЗАКРЫТО | остаток дренится, превышение — ошибка; `TestAManifestThatIsNotJSONIsAnError` и соседние гоняют реальный процесс | +| R11 | Ревизия прогона на проводе — из его колонки | ЗАКРЫТО | стор отдаёт книжную на всех путях; `pgstore.TestEveryRunTheStoreHandsOutCarriesItsBooksRevision`, `runs.TestEveryRunCarryingAnswerUsesTheBooksRevision`; посадка падает | +| R12 | Резюм судит деплой раньше владения | ЗАКРЫТО | порядок переставлен; `TestOwnershipIsJudgedBeforeTheDeploymentsHealth` | +| R13 | Резюм второго живого прогона книги → 500 | ЗАКРЫТО | `ErrRunInFlight` → 409; `TestResumeIsRefusedWhenTheBookHasAnotherLiveRun` | +| R14 | Два резюма подряд → 404 проигравшему | ЗАКРЫТО | проигравший перечитывает прогон; `TestTwoResumesOfOneRunTakeOneHoldAndBothAnswer` | +| R15 | Каталог-сирота, если строка исчезла между телом и `parsing` | ЗАКРЫТО | `Accept` убирает за собой и на этой ветке | +| R16 | 201 несёт `parsing`, а не `uploading` | СТРОКОЙ, не кодом | PD-180: по HTTP ответ не может уйти раньше тела; `uploading` наблюдаем параллельным чтением, пин есть | +| R17 | Отказы интейка, которых спека не описывает (части формы, длина поля, порог 413, обрыв по дедлайну) | СТРОКОЙ | PD-180, вопросом владельцу контракта одним пакетом с PD-172/PD-174 | +| R18 | 503 на резюме у несконфигурированного деплоя | СТРОКОЙ | PD-174 расширен: класс тот же, что у ратифицированного 503 на старте | +| R19 | Любая загрузка сегодня приходит к `rejected`, и выхода из `rejected` нет | СТРОКОЙ | PD-175 расширен; корень — развилка `book.yaml`, вопрос выше | +| R20 | Пути книг в ERROR-логах интейка | СТРОКОЙ | PD-139 дополнена; на INFO/WARN идентификаторов нет, и это запинено | +| R21 | `requestTooLarge` не доходит через обёртки | СТРОКОЙ | PD-176: 413 отдаётся штатно, соединение закрывается обычным путём под `ReadTimeout` | +| R22 | Порядок блокировок в `RestartRun` (строка прогона бралась последней) | ЗАКРЫТО попутно | R2 берёт её сразу после книги — документированный порядок восстановлен | +| F1 | Грация клейма короче таймаута задания: свип крадёт парс, книга терминально отклоняется с удалением файла | ЗАКРЫТО | `claimGrace = jobs.JobTimeout + 5m`; `books.TestTheClaimGraceOutlivesTheQueuesJobTimeout`; посадка падает | +| F2 | Один exit 1 движка уничтожал загрузку | ЗАКРЫТО | отказ источника идёт через бюджет; удаление только на терминальном шаге; посадка «отклонять с первого раза» падает | +| F3 | `not_configured` терминален — то есть каждая сегодняшняя загрузка обречена | ЗАКРЫТО (тест перевёрнут намеренно) | `TestABookWithNoEngineConfigurationWaitsRatherThanDies`; посадка «отклонять после бюджета» падает | +| F4 | Устаревший снапшот до-финиширует резюмированный прогон, холд вне списков | ЗАКРЫТО | гард живой попытки в `FinishRun`; `TestAStaleSweepDoesNotReFinishAResumedRunFromTheOldAttemptsMarker`; посадка падает | +| F5 | Стоп закрывает прогон под живым движком после отданной заявки | ЗАКРЫТО | `Alive` по детерминированному имени юнита; `TestAStopDoesNotCloseARunWhoseGivenBackClaimLeftAnEngineRunning`; посадка падает | +| F6 | Заявка на спавн игнорирует интент стопа | ЗАКРЫТО | условие в CAS; `TestAStopCommittedWhileTheWorkerReadsTheMeterStopsTheSpawn`; посадка падает | +| F7 | Дедлайн загрузки мог пережить грацию свипа | ЗАКРЫТО | проверка пары на буте; `config.TestAnUploadDeadlineLongerThanTheSweepsGraceIsRefused`; посадка падает | +| F8 | Принятая загрузка выбрасывалась, если клиент ушёл до 201 | ЗАКРЫТО | контекст переживает запрос; `TestAnUploadSurvivesAClientThatHangsUpAfterTheLastByte`; посадка падает | +| F9 | Лейбл `method` — токен вызывающего | ЗАКРЫТО | закрытый список; посадка падает | +| F10 | `runner.Manifest` подвисает на превышении потолка | ЗАКРЫТО | дренаж остатка + ошибка; правка была задумана раньше и НЕ доехала — поймана только кросс-семейным кругом | +| F11 | Порядок «каталог до строки» в отказе | ⚠ ПЕРЕСМОТРЕНО дофиксом → FP5-3 | диспозиция «порядок самоизлечивающийся» стояла на посылке, которую тот же пак отменил (`not_configured` стал НЕтерминальным), поэтому крэш-окно оставляло книгу в `parsing` навсегда; закрыто отдельной причиной `ErrDirectoryGone` (PD-187), пин и посадка есть | +| F12 | `finalizing` — статус без писателя | СТРОКОЙ | PD-185 | + +### Что НЕ делалось (по промту) + +Эскроу/`uncertain`/`closing` (строка 136) · формула потолка PD-158 и SpendBound PD-159 · ставка +$0.03 · эмиттер-сторона движка и словарь `events.go` · П-2 · платёжный провайдер · удаление аккаунта +(PD-107) · счётчик ревизии ОБЛАСТИ целиком (PD-122 сужена, не закрыта) · правка контракта (четыре +вопроса выше вместо неё). + ## Ратификация приёмкой P4 (оркестратор №15, 09.08) **Вердикт: P4 + дофикс + V2 ПРИНЯТЫ и ЗАЛЕНДЕНЫ — D39.123, код `d29e30c` (53 файла, +9867/−170, diff --git a/platform/go.mod b/platform/go.mod index 2ffb4aa1..5c386037 100644 --- a/platform/go.mod +++ b/platform/go.mod @@ -2,10 +2,21 @@ module textmachine/platform go 1.26.4 +// The LANGUAGE floor stays the engine's (one stand builds both modules). The TOOLCHAIN is pinned +// higher and separately, because it is what supplies the standard library this network-facing module +// links: 1.26.5 carries five advisories govulncheck traces into paths this service calls +// (GO-2026-6218/6090/6089/6088/5972), all fixed in 1.26.6. With the default GOTOOLCHAIN=auto a host +// on an older toolchain fetches this one instead of building a vulnerable binary; with +// GOTOOLCHAIN=local it stops with an error naming the version. Both are the answer we want, and +// neither depends on anybody reading a Makefile. `make version-check` is the same floor for the +// battery, which runs where GOTOOLCHAIN may be pinned to local. +toolchain go1.26.6 + require ( github.com/coreos/go-oidc/v3 v3.20.0 github.com/jackc/pgx/v5 v5.10.0 github.com/pressly/goose/v3 v3.27.3 + github.com/prometheus/client_golang v1.24.1 github.com/riverqueue/river v0.42.0 github.com/riverqueue/river/riverdriver/riverpgxv5 v0.42.0 golang.org/x/oauth2 v0.36.0 @@ -13,13 +24,19 @@ require ( ) require ( + github.com/beorn7/perks v1.0.1 // indirect + github.com/cespare/xxhash/v2 v2.3.0 // indirect github.com/davecgh/go-spew v1.1.1 // indirect github.com/go-jose/go-jose/v4 v4.1.4 // indirect github.com/jackc/pgpassfile v1.0.0 // indirect github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761 // indirect github.com/jackc/puddle/v2 v2.2.2 // indirect github.com/mfridman/interpolate v0.0.2 // indirect + github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822 // indirect github.com/pmezard/go-difflib v1.0.0 // indirect + github.com/prometheus/client_model v0.6.2 // indirect + github.com/prometheus/common v0.70.1 // indirect + github.com/prometheus/procfs v0.21.1 // indirect github.com/riverqueue/river/riverdriver v0.42.0 // indirect github.com/riverqueue/river/rivershared v0.42.0 // indirect github.com/riverqueue/river/rivertype v0.42.0 // indirect @@ -32,6 +49,8 @@ require ( go.uber.org/goleak v1.3.0 // indirect go.uber.org/multierr v1.11.0 // indirect golang.org/x/sync v0.22.0 // indirect + golang.org/x/sys v0.47.0 // indirect golang.org/x/text v0.40.0 // indirect + google.golang.org/protobuf v1.36.11 // indirect gopkg.in/yaml.v3 v3.0.1 // indirect ) diff --git a/platform/go.sum b/platform/go.sum index 29a7fce7..7cb6f4ed 100644 --- a/platform/go.sum +++ b/platform/go.sum @@ -1,3 +1,7 @@ +github.com/beorn7/perks v1.0.1 h1:VlbKKnNfV8bJzeqoa4cOKqO6bYr3WgKZxO8Z16+hsOM= +github.com/beorn7/perks v1.0.1/go.mod h1:G2ZrVWU2WbWT9wwq4/hrbKbnv/1ERSJQ0ibhJ6rlkpw= +github.com/cespare/xxhash/v2 v2.3.0 h1:UL815xU9SqsFlibzuggzjXhog7bL6oX9BbNZnL2UFvs= +github.com/cespare/xxhash/v2 v2.3.0/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XLFGgcrjCOs= github.com/coreos/go-oidc/v3 v3.20.0 h1:EtE0WIBHk03N+DqGkY4+UONzzZHk7amKt6IyNd7OsZE= github.com/coreos/go-oidc/v3 v3.20.0/go.mod h1:DYCf24+ncYi+XkIH97GY1+dqoRlbaSI26KVTCI9SrY4= github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= @@ -7,6 +11,8 @@ github.com/dustin/go-humanize v1.0.1 h1:GzkhY7T5VNhEkwH0PVJgjz+fX1rhBrR7pRT3mDkp github.com/dustin/go-humanize v1.0.1/go.mod h1:Mu1zIs6XwVuF/gI1OepvI0qD18qycQx+mFykh5fBlto= github.com/go-jose/go-jose/v4 v4.1.4 h1:moDMcTHmvE6Groj34emNPLs/qtYXRVcd6S7NHbHz3kA= github.com/go-jose/go-jose/v4 v4.1.4/go.mod h1:x4oUasVrzR7071A4TnHLGSPpNOm2a21K9Kf04k1rs08= +github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8= +github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU= github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0= github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= github.com/jackc/pgerrcode v0.0.0-20240316143900-6e2875d9b438 h1:Dj0L5fhJ9F82ZJyVOmBx6msDp/kfd1t9GRfny/mfJA0= @@ -19,20 +25,34 @@ github.com/jackc/pgx/v5 v5.10.0 h1:VhSvgU2jSli8o3AqIEOTJr7rZwAEUVo4E4XhR94Zfr0= github.com/jackc/pgx/v5 v5.10.0/go.mod h1:mal1tBGAFfLHvZzaYh77YS/eC6IX9OWbRV1QIIM0Jn4= github.com/jackc/puddle/v2 v2.2.2 h1:PR8nw+E/1w0GLuRFSmiioY6UooMp6KJv0/61nB7icHo= github.com/jackc/puddle/v2 v2.2.2/go.mod h1:vriiEXHvEE654aYKXXjOvZM39qJ0q+azkZFrfEOc3H4= +github.com/klauspost/compress v1.19.1 h1:VsB4HPswih7mmZ8WleSFQ75c/Ui1M4trX5oAsJnhSlk= +github.com/klauspost/compress v1.19.1/go.mod h1:cwPg85FWrGar70rWktvGQj8/hthj3wpl0PGDogxkrSQ= github.com/kr/pretty v0.3.0 h1:WgNl7dwNpEZ6jJ9k1snq4pZsg7DOEN8hP9Xw0Tsjwk0= github.com/kr/pretty v0.3.0/go.mod h1:640gp4NfQd8pI5XOwp5fnNeVWj67G7CFk/SaSQn7NBk= github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY= github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE= +github.com/kylelemons/godebug v1.1.0 h1:RPNrshWIDI6G2gRW9EHilWtl7Z6Sb1BR0xunSBf0SNc= +github.com/kylelemons/godebug v1.1.0/go.mod h1:9/0rRGxNHcop5bhtWyNeEfOS8JIWk580+fNqagV/RAw= github.com/mattn/go-isatty v0.0.23 h1:cYwCQTQf3HB6xUC+BtyCLZNr7IzbOmoZbmssVNzSyiQ= github.com/mattn/go-isatty v0.0.23/go.mod h1:nMCL3Zebbrt45jsMDgnfIwz6ydEQApk5oEI3HqDio6A= github.com/mfridman/interpolate v0.0.2 h1:pnuTK7MQIxxFz1Gr+rjSIx9u7qVjf5VOoM/u6BbAxPY= github.com/mfridman/interpolate v0.0.2/go.mod h1:p+7uk6oE07mpE/Ik1b8EckO0O4ZXiGAfshKBWLUM9Xg= +github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822 h1:C3w9PqII01/Oq1c1nUAm88MOHcQC9l5mIlSMApZMrHA= +github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822/go.mod h1:+n7T8mK8HuQTcFwEeznm/DIxMOiR9yIdICNftLE1DvQ= github.com/ncruces/go-strftime v1.0.0 h1:HMFp8mLCTPp341M/ZnA4qaf7ZlsbTc+miZjCLOFAw7w= github.com/ncruces/go-strftime v1.0.0/go.mod h1:Fwc5htZGVVkseilnfgOVb9mKy6w1naJmn9CehxcKcls= github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= github.com/pressly/goose/v3 v3.27.3 h1:pIglVHjw99r4e/hDHHwbl9vfOsDMqUokfkXo6+n/RxA= github.com/pressly/goose/v3 v3.27.3/go.mod h1:Dag+xpV6o20HR2LFY1j0q6MDwc3f7vPUFDA77R+0yGY= +github.com/prometheus/client_golang v1.24.1 h1:JnJkREXzWxUdCuPFpIWZiPispT9xVV59uiuyR2bPlnU= +github.com/prometheus/client_golang v1.24.1/go.mod h1:F+oSRECHg4sse5ucfYpYDeIv/hu68Zo0uoHKetWnzcE= +github.com/prometheus/client_model v0.6.2 h1:oBsgwpGs7iVziMvrGhE53c/GrLUsZdHnqNwqPLxwZyk= +github.com/prometheus/client_model v0.6.2/go.mod h1:y3m2F6Gdpfy6Ut/GBsUqTWZqCUvMVzSfMLjcu6wAwpE= +github.com/prometheus/common v0.70.1 h1:1HvjP4D5oL3t8RsPlwxA9onvvStjtIHYE5XuuwOi/PY= +github.com/prometheus/common v0.70.1/go.mod h1:VdFUQDMZK3VLkurFUVhia6uys/0suUp86TJz5qbJRhc= +github.com/prometheus/procfs v0.21.1 h1:GljZCt+zSTS+NZq88cyQ1LjZ+RCHp3uVuabBWA5+OJI= +github.com/prometheus/procfs v0.21.1/go.mod h1:aB55Cww9pdSJVHk0hUf0inxWyyjPogFIjmHKYgMKmtY= github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec h1:W09IVJc94icq4NjY3clb7Lk8O1qJ8BdBEF8z0ibU0rE= github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec/go.mod h1:qqbHyh8v60DhA7CoWK5oRCqLrMHRGoxYCSS9EjAz6Eo= github.com/riverqueue/river v0.42.0 h1:WDx0F0loJg41XvkHVyQVr9PUN2p9SPLr2Tx7zs5ONlY= @@ -71,6 +91,8 @@ go.uber.org/goleak v1.3.0 h1:2K3zAYmnTNqV73imy9J1T3WC+gmCePx2hEGkimedGto= go.uber.org/goleak v1.3.0/go.mod h1:CoHD4mav9JJNrW/WLlf7HGZPjdw8EucARQHekz1X6bE= go.uber.org/multierr v1.11.0 h1:blXXJkSxSSfBVBlC76pxqeO+LN3aDfLQo+309xJstO0= go.uber.org/multierr v1.11.0/go.mod h1:20+QtiLqy0Nd6FdQB9TLXag12DsQkrbs3htMFfDN80Y= +go.yaml.in/yaml/v2 v2.4.4 h1:tuyd0P+2Ont/d6e2rl3be67goVK4R6deVxCUX5vyPaQ= +go.yaml.in/yaml/v2 v2.4.4/go.mod h1:gMZqIpDtDqOfM0uNfy0SkpRhvUryYH0Z6wdMYcacYXQ= golang.org/x/oauth2 v0.36.0 h1:peZ/1z27fi9hUOFCAZaHyrpWG5lwe0RJEEEeH0ThlIs= golang.org/x/oauth2 v0.36.0/go.mod h1:YDBUJMTkDnJS+A4BP4eZBjCqtokkg1hODuPjwiGPO7Q= golang.org/x/sync v0.22.0 h1:SZjpbeLmrCk4xhRSZFNZW5gFUeCeFgjekvI/+gfScek= @@ -81,6 +103,8 @@ golang.org/x/text v0.40.0 h1:Ub2Z6/xjgF1WrYQz2nuITOEegKFtiIy+rieRJ5lHZKs= golang.org/x/text v0.40.0/go.mod h1:hpnzDAfGV753zIKo+wk3u1bVKCGPbrnF7+7LBF/UHVY= golang.org/x/time v0.15.0 h1:bbrp8t3bGUeFOx08pvsMYRTCVSMk89u4tKbNOZbp88U= golang.org/x/time v0.15.0/go.mod h1:Y4YMaQmXwGQZoFaVFk4YpCt4FLQMYKZe9oeV/f4MSno= +google.golang.org/protobuf v1.36.11 h1:fV6ZwhNocDyBLK0dj+fg8ektcVegBBuEolpbTQyBNVE= +google.golang.org/protobuf v1.36.11/go.mod h1:HTf+CrKn2C3g5S8VImy6tdcUvCska2kB7j23XfzDpco= gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c h1:Hei/4ADfdWqJk1ZMxUNpqntNwaWcugrBjAiHlqqRiVk= gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c/go.mod h1:JHkPIbrfpd72SG/EVd6muEfDQjcINNoR0C8j2r3qZ4Q= diff --git a/platform/internal/books/books.go b/platform/internal/books/books.go new file mode 100644 index 00000000..cdb1f250 --- /dev/null +++ b/platform/internal/books/books.go @@ -0,0 +1,386 @@ +// Package books owns a book's INTAKE: receiving the file the user uploads, putting it where the +// engine will look for it, and turning it into a chapter tree. +// +// The shape mirrors the run lifecycle deliberately (package runs): the request only gets the book as +// far as "the bytes are here", and everything after that is a step some later sweep can finish. A +// parse is one $0 call of the engine that takes seconds on a large book, and holding a request open +// for it — or losing the book when the process doing it is restarted — are the two failures this +// split exists to avoid. +package books + +import ( + "context" + "errors" + "fmt" + "io" + "io/fs" + "log/slog" + "os" + "path/filepath" + "regexp" + "strings" + "time" + "unicode" + "unicode/utf8" + + "textmachine/platform/internal/ingest" + "textmachine/platform/internal/pgstore" +) + +// Manifester is the engine's $0 producer of a chapter tree: `tmctl manifest`. An interface so the +// intake's decisions can be pinned without an engine binary. +type Manifester interface { + Manifest(ctx context.Context, binary, workdir string) (ingest.Manifest, error) +} + +// Enqueuer hands a book to the queue inside the caller's transaction. +type Enqueuer interface { + EnqueueParse(ctx context.Context, tx pgstore.Tx, bookID string) error +} + +// Config is what an operator chooses about intake. +type Config struct { + // BooksDir is the root the platform creates book directories under. Absolute, and NOT the place + // an operator keeps hand-made books: everything below it is written and, on a rejected intake, + // removed by this service. + BooksDir string + // EngineBinary is the versioned tmctl path used to parse. Intake is not mounted without it: a + // deployment that cannot parse would take uploads it can only reject. + EngineBinary string +} + +// Service is the intake. +type Service struct { + Store *pgstore.Store + Engine Manifester + Queue Enqueuer + Cfg Config + Log *slog.Logger + // Now is injectable so the graces below are testable without sleeping. + Now func() time.Time +} + +func (s *Service) now() time.Time { + if s.Now != nil { + return s.Now() + } + return time.Now() +} + +func (s *Service) log() *slog.Logger { + if s.Log == nil { + return slog.New(slog.DiscardHandler) + } + return s.Log +} + +// ErrBadIntake is a request this route cannot make a book out of. It is the contract's 400. +var ErrBadIntake = errors.New("books: the intake form is not usable") + +// SourceName is the file name the intake writes a book's source under, extension aside. The name is +// FIXED and predictable because the engine finds the source through `source_file:` in the book's own +// config, which somebody else writes (see the provisioning seam in parse.go). +const SourceName = "source" + +// maxTitle bounds the title taken from the uploaded file's name. The library lists it, and the +// client-supplied name is otherwise the one unbounded string on that screen. +const maxTitle = 200 + +// langCode is the contract's LangCode: a code, never a name (§LangCode). Validated here as well as +// in the read model because this is where a value from a browser enters. +var langCode = regexp.MustCompile(`^[a-z]{2,3}(-[A-Za-z0-9]{2,8})*$`) + +// Intake is one accepted call of POST /books. +type Intake struct { + UserID string + SourceLang string + TargetLang string + Genre string + // Filename is the name the client gave the part. It is used for two things and trusted for + // neither: a title for the library and the extension that tells the engine which reader to use. + Filename string + // File is the body of the upload, already bounded by the route's own limit. It is streamed to + // disk and never held in memory. + File io.Reader +} + +// Accept receives one book. +// +// The order — row, then bytes — is what makes `uploading` a state anything can observe: a second tab +// listing the library while a 60 MB file is still on the wire sees the book with its languages and +// without its size. It also makes an abandoned upload FINDABLE, which is the half that matters +// operationally: the row is the only record that a directory under BooksDir belongs to anyone. +func (s *Service) Accept(ctx context.Context, in Intake) (pgstore.Book, error) { + if !langCode.MatchString(in.SourceLang) || !langCode.MatchString(in.TargetLang) { + return pgstore.Book{}, fmt.Errorf("%w: the languages must be codes", ErrBadIntake) + } + if s.Cfg.BooksDir == "" { + return pgstore.Book{}, errors.New("books: no books directory is configured") + } + id := pgstore.NewBookID() + dir := filepath.Join(s.Cfg.BooksDir, id) + if err := os.MkdirAll(dir, 0o750); err != nil { + return pgstore.Book{}, fmt.Errorf("books: create book directory: %w", err) + } + // The first upload is what marks this storage as ours — see storageIsThere. Written here and + // nowhere else, and deliberately NOT at boot: a marker the boot recreates says "the storage is + // here" about a directory the boot itself just made. + if err := s.markStorage(); err != nil { + s.removeDir(dir) + return pgstore.Book{}, err + } + // The created row is not kept: what the caller gets back is the row as it stands AFTER the file + // landed, and between the two the status has moved from `uploading` to `parsing`. + _, err := s.Store.CreateUpload(ctx, id, pgstore.NewUpload{ + OwnerID: in.UserID, + Title: titleFrom(in.Filename), + SourceLang: in.SourceLang, + TargetLang: in.TargetLang, + Genre: in.Genre, + Workdir: dir, + Now: s.now(), + }) + if err != nil { + s.removeDir(dir) + return pgstore.Book{}, err + } + characters, err := s.receive(filepath.Join(dir, SourceName+extensionOf(in.Filename)), in.File) + if err != nil { + // The upload did not finish, so there is nothing to parse and nothing to keep. The row is + // DELETED rather than rejected: `rejected` means the file could not be parsed (contract + // §BookStatus), and there is no delete handle in the contract for the user to clear a row an + // abandoned upload would otherwise leave in their library forever. + // + // On its own context: the ordinary cause of getting here is the client going away, and the + // request's context is already cancelled by then. + c, cancel := writeCtx(ctx) + defer cancel() + s.abandon(c, id, dir) + return pgstore.Book{}, err + } + // On a context that outlives the request: every byte is in, and a client that hung up while + // waiting for the 201 must not cost the upload it already finished. The ROW is what makes the + // book findable, so it is written even when nobody is left to read the answer. Bounded on its own + // (writeCtx): detached is not the same as unlimited, and a hung statement here would hold the + // goroutine of a request that is already over. + start, cancelStart := writeCtx(ctx) + defer cancelStart() + book, err := s.Store.StartParsing(start, id, characters, s.enqueue) + if err != nil { + // The row is gone or unreachable, and the directory holds a file nothing points at — which is + // the one thing the row-first order exists to prevent, so it is undone here too. The ordinary + // cause is the sweep having abandoned this upload while it was still arriving. + c, cancel := writeCtx(ctx) + defer cancel() + s.abandon(c, id, dir) + return pgstore.Book{}, err + } + // No book id: an INFO line must not identify a user's library (ENGINEERING_STANDARDS + // §Наблюдаемость, the same rule that keeps raw paths out of the access log — PD-3). The request + // id the handler carries is what ties this line to the response the user got. + s.log().InfoContext(ctx, "book accepted", "characters", characters) + return book, nil +} + +// receive streams the upload to disk and counts what went past. +// +// Streamed, never buffered: the route's limit is tens of megabytes and reading that into memory +// would make one upload per concurrent request the platform's memory profile. The file is created +// with O_EXCL so a book directory can never be written twice by two requests that somehow minted the +// same id. +func (s *Service) receive(path string, body io.Reader) (int64, error) { + f, err := os.OpenFile(path, os.O_WRONLY|os.O_CREATE|os.O_EXCL, 0o640) + if err != nil { + return 0, fmt.Errorf("books: create source file: %w", err) + } + c := &counter{w: f} + if _, err := io.Copy(c, body); err != nil { + f.Close() + // The error is returned as it came: the handler tells a body that outgrew the route's limit + // from a client that went away, and both are the caller's to name (http.MaxBytesError). + return 0, err + } + if err := f.Close(); err != nil { + return 0, fmt.Errorf("books: close source file: %w", err) + } + return c.characters, nil +} + +// counter writes through and counts CHARACTERS, which is what the contract's Book carries — a size +// in characters, known to the platform from intake and to nobody else (§Book.character_count). +// +// It counts every byte that is not a UTF-8 continuation byte, which is exactly the rune count of +// valid UTF-8 and needs no state across the chunk boundaries io.Copy hands it. ⚠ For a source in +// another encoding — the engine accepts GB18030 and UTF-16 and decodes them itself — the figure is +// an approximation of the character count, not the count. Naming a size for a file whose encoding is +// not yet known is what intake can do; the exact one would need the engine's decode, which happens a +// step later and reports bytes. +type counter struct { + w io.Writer + characters int64 +} + +func (c *counter) Write(p []byte) (int, error) { + n, err := c.w.Write(p) + for _, b := range p[:n] { + if b&0xC0 != 0x80 { + c.characters++ + } + } + return n, err +} + +func (s *Service) enqueue(ctx context.Context, tx pgstore.Tx, bookID string) error { + if s.Queue == nil { + return nil // no queue configured: the sweep picks the book up on its next pass + } + return s.Queue.EnqueueParse(ctx, tx, bookID) +} + +// abandon undoes an upload that did not finish. Both halves are best effort and both are logged: +// what must not happen is a silent leak of either the row or the directory. +func (s *Service) abandon(ctx context.Context, id, dir string) { + // The ROW is deleted first here, and unlike the reject path that order is forced: the directory + // may only go once it is certain nobody owns it. The sweep decides from a snapshot, and a request + // that finished in the meantime has moved the book to `parsing` — DeleteUpload's status guard then + // refuses, and removing the directory anyway would delete the source of a live book under it. + // + // The crash window that leaves is a directory with no row, and it is named rather than closed: + // nothing walks BooksDir looking for orphans (register row PD-175, where the retention sweep that + // would is filed). + if err := s.Store.DeleteUpload(ctx, id); err != nil { + if errors.Is(err, pgstore.ErrNoBook) { + // The ordinary race: another instance's sweep got there first, or the upload finished. Not + // an error — an ERROR line on a routine race is noise that teaches operators to skim. + s.log().InfoContext(ctx, "the abandoned upload was already gone; its directory is left alone") + return + } + s.log().ErrorContext(ctx, "an abandoned upload was not removed; its directory is left alone", "err", err) + return + } + s.removeDir(dir) +} + +// removeDir deletes a directory this service created, and NOTHING else. +// +// The guard is not ceremony: a book registered by the dev CLI carries a workdir the operator chose — +// their own project directory, with their own source and their own project database — and no path +// here may ever remove one. Everything under BooksDir was created by this service and holds nothing +// the platform did not put there. +func (s *Service) removeDir(dir string) { + if !s.owns(dir) { + s.log().Error("refusing to remove a directory this service did not create", "dir", dir) + return + } + if err := os.RemoveAll(dir); err != nil { + s.log().Error("book directory could not be removed", "err", err) + } +} + +// StorageMarker is the file that says "this is the storage this platform has been writing books +// into". Exported so an operator provisioning a volume by hand can put one there. +const StorageMarker = ".tmplatform-books" + +const storageMarkerText = `This directory holds book sources written by tmplatformd. +Its presence is what tells the intake that the storage is mounted: without it a book whose directory +is missing is treated as "the storage is gone" and WAITS, instead of being rejected with its source +deleted. Do not remove it while books live here. +` + +// markStorage writes the marker if it is not there. Idempotent and race-free by O_EXCL: two uploads +// arriving together are both correct, and the loser's EEXIST is the state it wanted. +func (s *Service) markStorage() error { + f, err := os.OpenFile(filepath.Join(s.Cfg.BooksDir, StorageMarker), + os.O_WRONLY|os.O_CREATE|os.O_EXCL, 0o640) + if errors.Is(err, fs.ErrExist) { + return nil + } + if err != nil { + return fmt.Errorf("books: mark the storage: %w", err) + } + defer f.Close() + if _, err := f.WriteString(storageMarkerText); err != nil { + return fmt.Errorf("books: mark the storage: %w", err) + } + return nil +} + +// storageIsThere reports whether the books storage is the one this platform wrote into. +// +// ⚠ It asks about the MARKER and not about the directory, and the difference is the whole point. A +// volume mounted AT BooksDir leaves an empty mountpoint behind when it is unmounted, so the directory +// still exists and `Stat` still succeeds — which is how the first version of this guard (PD-192) let +// an unmount reject every book in intake, with their sources deleted, exactly as if each book's own +// directory had been removed. The boot's MkdirAll made it worse by recreating the root after a +// restart. The marker is written by the FIRST upload and by nothing else, so neither an unmount nor a +// fresh MkdirAll can forge it (re-check of the dofix, FP5-10). +// +// A host that has never taken an upload has no marker either, and answers "not there" — which is the +// safe direction: it has no books to reject. +func (s *Service) storageIsThere() bool { + if s.Cfg.BooksDir == "" { + return false + } + _, err := os.Stat(filepath.Join(s.Cfg.BooksDir, StorageMarker)) + return err == nil +} + +func (s *Service) owns(dir string) bool { + if s.Cfg.BooksDir == "" || dir == "" { + return false + } + rel, err := filepath.Rel(s.Cfg.BooksDir, dir) + if err != nil { + return false + } + return rel != "." && rel != ".." && !strings.HasPrefix(rel, ".."+string(filepath.Separator)) +} + +// titleFrom is the book's name until something better exists. +// +// ⚠ Named limitation, not a design: `BookIntake` carries no title field (contract §BookIntake) and +// inventing one is not this zone's right, while the engine's manifest reports counts and identity +// and no title either. What is left is the name the user gave the file, which is a real thing they +// chose. An empty result stays empty: a synthesized "Book 1" would be a label nobody wrote. +func titleFrom(filename string) string { + name := filepath.Base(filepath.FromSlash(filename)) + if i := strings.LastIndexByte(name, '.'); i > 0 { + name = name[:i] + } + name = strings.Map(func(r rune) rune { + if unicode.IsControl(r) { + return -1 + } + return r + }, name) + name = strings.TrimSpace(name) + if name == "." || name == ".." || name == string(filepath.Separator) { + return "" + } + if utf8.RuneCountInString(name) > maxTitle { + name = string([]rune(name)[:maxTitle]) + } + return name +} + +// extensionOf is the one thing about the FORMAT the platform is allowed to know: the engine +// dispatches its reader by extension (`.epub` → the epub reader, anything else → plain text, +// backend/internal/chunk/ingest.go), so the extension has to survive intake or an EPUB is read as +// text. +// +// It is not a format allowlist, and that is deliberate: which formats exist is the engine's +// question, and a platform that refused an extension the engine had just learned would be a second +// place to teach. What it does refuse is a name that is not an extension — the value goes into a +// path, so anything but lowercase alphanumerics is dropped and the source becomes plain text. +func extensionOf(filename string) string { + ext := strings.ToLower(filepath.Ext(filepath.Base(filepath.FromSlash(filename)))) + if len(ext) < 2 || len(ext) > 9 { + return ".txt" + } + for _, r := range ext[1:] { + if (r < 'a' || r > 'z') && (r < '0' || r > '9') { + return ".txt" + } + } + return ext +} diff --git a/platform/internal/books/books_test.go b/platform/internal/books/books_test.go new file mode 100644 index 00000000..a144f105 --- /dev/null +++ b/platform/internal/books/books_test.go @@ -0,0 +1,1099 @@ +package books + +import ( + "bytes" + "context" + "crypto/rand" + "encoding/hex" + "encoding/json" + "errors" + "io" + "log/slog" + "net/url" + "os" + "os/exec" + "path/filepath" + "strings" + "sync" + "testing" + "time" + + "github.com/jackc/pgx/v5" + + "textmachine/platform/internal/ingest" + "textmachine/platform/internal/jobs" + "textmachine/platform/internal/pgstore" + "textmachine/platform/internal/runner" +) + +// Intake is a walk with three ends — parsed, rejected, or removed — and every one of them is reached +// by a DIFFERENT process from the one that started it. That is what these tests are about: the row, +// the directory and the file agree at each step, and no step is reachable only from the request that +// began it. +func intakeDB(t *testing.T) (*pgstore.Store, context.Context) { + t.Helper() + admin := os.Getenv("TM_PLATFORM_TEST_DSN") + if admin == "" { + t.Skip("TM_PLATFORM_TEST_DSN not set: the intake's statuses need a live Postgres") + } + ctx := t.Context() + var suffix [6]byte + if _, err := rand.Read(suffix[:]); err != nil { + t.Fatal(err) + } + name := "tm_books_test_" + hex.EncodeToString(suffix[:]) + conn, err := pgx.Connect(ctx, admin) + if err != nil { + t.Fatalf("connect: %v", err) + } + if _, err := conn.Exec(ctx, "create database "+pgx.Identifier{name}.Sanitize()); err != nil { + conn.Close(ctx) + t.Skipf("cannot create a scratch database (%v): grant CREATEDB or point the DSN at one", err) + } + t.Cleanup(func() { + c, cancel := context.WithTimeout(context.Background(), 15*time.Second) + defer cancel() + _, _ = conn.Exec(c, "drop database if exists "+pgx.Identifier{name}.Sanitize()+" with (force)") + conn.Close(c) + }) + u, err := url.Parse(admin) + if err != nil { + t.Fatal(err) + } + u.Path = "/" + name + if err := pgstore.Migrate(ctx, u.String()); err != nil { + t.Fatalf("migrate: %v", err) + } + s, err := pgstore.Open(ctx, u.String()) + if err != nil { + t.Fatal(err) + } + t.Cleanup(s.Close) + if _, err := s.Pool().Exec(ctx, `insert into users (id, email) values ('u1','u1@example.org')`); err != nil { + t.Fatal(err) + } + return s, ctx +} + +// fakeEngine stands in for `tmctl manifest`. It models the two answers that lead to different ends — +// a manifest, and a process that RAN and refused — because a fake that can only succeed proves +// nothing about the branch that rejects a book. +type fakeEngine struct { + mu sync.Mutex + manifest ingest.Manifest + err error + calls int + // onManifest runs INSIDE the call, which is where the deadline the caller granted is visible. + onManifest func(ctx context.Context) +} + +func (f *fakeEngine) Manifest(ctx context.Context, _, _ string) (ingest.Manifest, error) { + f.mu.Lock() + f.calls++ + hook := f.onManifest + m, err := f.manifest, f.err + f.mu.Unlock() + if hook != nil { + hook(ctx) + } + return m, err +} + +func (f *fakeEngine) set(m ingest.Manifest, err error) { + f.mu.Lock() + defer f.mu.Unlock() + f.manifest, f.err = m, err +} + +func (f *fakeEngine) called() int { + f.mu.Lock() + defer f.mu.Unlock() + return f.calls +} + +// refusal is what the engine's own "no" looks like from here: a process that ran and exited non-zero +// (backend cmd/tmctl maps every failure that is not one of its two sentinels onto exit 1). Produced +// by running a real command rather than by hand-building an ExitError, so the type the classifier +// keys on is the type a real refusal carries. +func refusal(t *testing.T) error { + t.Helper() + err := exec.CommandContext(t.Context(), "/bin/sh", "-c", "exit 1").Run() + if err == nil { + t.Fatal("the refusal helper did not fail") + } + return err +} + +type fixture struct { + svc *Service + store *pgstore.Store + engine *fakeEngine + ctx context.Context + root string + now time.Time +} + +func newFixture(t *testing.T) *fixture { + t.Helper() + store, ctx := intakeDB(t) + root := t.TempDir() + eng := &fakeEngine{manifest: ingest.Manifest{ + Version: "tm-manifest-v2", ChaptersTotal: 500, UnitsTotal: 950, + SourceSHA256: strings.Repeat("ab", 32), ChunkerVersion: "chunk-2026.07", + }} + now := time.Now().UTC().Truncate(time.Millisecond) + f := &fixture{store: store, engine: eng, ctx: ctx, root: root, now: now} + f.svc = &Service{ + Store: store, + Engine: eng, + Cfg: Config{BooksDir: root, EngineBinary: "/opt/engine/2026.08.01/tmctl"}, + Now: func() time.Time { return f.now }, + } + return f +} + +// accept runs one upload of the given bytes and returns the book. +func (f *fixture) accept(t *testing.T, filename, body string) pgstore.Book { + t.Helper() + b, err := f.svc.Accept(f.ctx, Intake{UserID: "u1", SourceLang: "zh", TargetLang: "ru", + Genre: "xianxia", Filename: filename, File: strings.NewReader(body)}) + if err != nil { + t.Fatal(err) + } + return b +} + +// provision puts an engine configuration in the book's directory. Who does this in production is the +// open question the intake stops at (ErrNotProvisioned); here it stands for the operator. +func (f *fixture) provision(t *testing.T, b pgstore.Book) string { + t.Helper() + dir := filepath.Join(f.root, b.ID) + if err := os.WriteFile(filepath.Join(dir, runner.ConfigFile), []byte("book_id: x\n"), 0o600); err != nil { + t.Fatal(err) + } + return dir +} + +func (f *fixture) libraryRevision(t *testing.T) int64 { + t.Helper() + lib, err := f.store.ListBooks(f.ctx, "u1", 50, "") + if err != nil { + t.Fatal(err) + } + return lib.Revision +} + +func (f *fixture) card(t *testing.T, id string) pgstore.Book { + t.Helper() + b, _, err := f.store.GetBook(f.ctx, "u1", id) + if err != nil { + t.Fatal(err) + } + return b +} + +// The whole walk, and the assertion is that each status is written by somebody: the row exists while +// the file arrives, the engine turns it into a chapter tree, and the library reads the result exactly +// as it reads a book the dev intake put there. +func TestAnUploadedBookWalksFromUploadingToNotStarted(t *testing.T) { + f := newFixture(t) + book := f.accept(t, "蛊真人.txt", "первая глава\fвторая глава") + if book.Status != "parsing" { + t.Fatalf("the response says %q; the file is in, so the book is being parsed", book.Status) + } + if book.Title != "蛊真人" { + t.Fatalf("title %q, want the name the user gave the file", book.Title) + } + // Characters, not bytes: the contract's figure is a size in characters and this source is Cyrillic. + if book.CharacterCount != 25 { + t.Fatalf("character_count %d, want 25 runes", book.CharacterCount) + } + f.provision(t, book) + if err := f.svc.Parse(f.ctx, book.ID); err != nil { + t.Fatal(err) + } + got := f.card(t, book.ID) + if got.Status != "not_started" || got.ChapterCount != 500 { + t.Fatalf("after parsing: %q with %d chapters, want not_started with 500", got.Status, got.ChapterCount) + } + // The manifest's identity is kept: it says WHICH cut of WHICH bytes produced that chapter count. + var sha []byte + var chunker string + if err := f.store.Pool().QueryRow(f.ctx, + `select source_sha256, chunker_version from books where id = $1`, book.ID).Scan(&sha, &chunker); err != nil { + t.Fatal(err) + } + if len(sha) != 32 || chunker != "chunk-2026.07" { + t.Fatalf("the manifest's provenance was not recorded: sha %d bytes, chunker %q", len(sha), chunker) + } +} + +// `uploading` is a state something can observe, and that is the whole reason the row is written +// before the body is read: a second reader of the library sees the book while its file is still on +// the wire. +func TestABookIsVisibleAsUploadingWhileItsFileIsStillArriving(t *testing.T) { + f := newFixture(t) + reading := make(chan struct{}) + release := make(chan struct{}) + body := &blockingReader{data: "the whole book", gate: reading, release: release} + done := make(chan error, 1) + go func() { + _, err := f.svc.Accept(f.ctx, Intake{UserID: "u1", SourceLang: "zh", TargetLang: "ru", + Filename: "book.txt", File: body}) + done <- err + }() + <-reading + lib, err := f.store.ListBooks(f.ctx, "u1", 10, "") + if err != nil { + t.Fatal(err) + } + if len(lib.Books) != 1 || lib.Books[0].Status != "uploading" { + t.Fatalf("the library shows %+v while the file is arriving, want one book in uploading", lib.Books) + } + close(release) + if err := <-done; err != nil { + t.Fatal(err) + } + if got := f.card(t, lib.Books[0].ID); got.Status != "parsing" { + t.Fatalf("after the body arrived the book is %q, want parsing", got.Status) + } +} + +// blockingReader hands over its first chunk and then waits, so a test can look at the world in the +// middle of an upload. +type blockingReader struct { + data string + gate chan struct{} + release chan struct{} + sent bool +} + +func (b *blockingReader) Read(p []byte) (int, error) { + if !b.sent { + b.sent = true + n := copy(p, b.data) + close(b.gate) + <-b.release + return n, nil + } + return 0, io.EOF +} + +// An upload that did not finish leaves NOTHING: not a row the user cannot delete — the contract has +// no handle for that — and not a directory nothing points at. +// +// The client going AWAY is the ordinary way to get here, so that is what the test does: the request's +// own context is cancelled before the body fails. Cleaning up on it would then clean up nothing. +func TestAnAbandonedUploadLeavesNeitherARowNorADirectory(t *testing.T) { + f := newFixture(t) + ctx, cancel := context.WithCancel(f.ctx) + defer cancel() + _, err := f.svc.Accept(ctx, Intake{UserID: "u1", SourceLang: "zh", TargetLang: "ru", + Filename: "book.txt", File: &failingReader{cancel: cancel}}) + if err == nil { + t.Fatal("an upload whose body failed was accepted") + } + lib, err := f.store.ListBooks(f.ctx, "u1", 10, "") + if err != nil { + t.Fatal(err) + } + if len(lib.Books) != 0 { + t.Fatalf("the library holds %+v after a failed upload", lib.Books) + } + entries, err := os.ReadDir(f.root) + if err != nil { + t.Fatal(err) + } + // Everything except the storage marker, which is the root's own and outlives every book: it is + // written by the first upload and never removed, because its absence is what tells the intake the + // volume is gone (FP5-10). + var left []string + for _, e := range entries { + if e.Name() != StorageMarker { + left = append(left, e.Name()) + } + } + if len(left) != 0 { + t.Fatalf("the books root holds %v after a failed upload", left) + } +} + +// failingReader is a client that goes away mid-body. gate/release, when set, let a test look at the +// world while the upload is still in flight. +type failingReader struct { + cancel context.CancelFunc + gate chan struct{} + release chan struct{} +} + +func (f failingReader) Read([]byte) (int, error) { + if f.gate != nil { + close(f.gate) + <-f.release + } + if f.cancel != nil { + f.cancel() + } + return 0, errors.New("the client went away") +} + +// A source the engine READ and refused is terminal, and its bytes go: nothing re-parses them, no +// path downloads them, and an authenticated route that writes to an operator's disk and never +// removes anything is a hole this one must not open. +func TestASourceTheEngineRefusesIsRejectedAndItsFileRemoved(t *testing.T) { + f := newFixture(t) + book := f.accept(t, "not-a-book.pdf", "%PDF-1.7 …") + dir := f.provision(t, book) + f.engine.set(ingest.Manifest{}, refusal(t)) + // ⚠ The FIRST refusal is not terminal, and that is the correction the cross-family review forced: + // the engine maps every failure it has onto exit 1 — a full disk, an operator's own tmctl holding + // the project lock, a claim taken over mid-parse — so rejecting on the first one turned any of + // those into the irreversible deletion of a user's upload. It spends an attempt instead. + if err := f.svc.Parse(f.ctx, book.ID); err != nil { + t.Fatal(err) + } + if got := f.card(t, book.ID); got.Status != "parsing" { + t.Fatalf("one refusal made the book %q; a single exit 1 must not be terminal", got.Status) + } + if _, err := os.Stat(filepath.Join(dir, SourceName+".pdf")); err != nil { + t.Fatalf("the source was deleted on the first refusal: %v", err) + } + for range parseAttempts { + f.now = f.now.Add(claimGrace + time.Minute) + if err := f.svc.Parse(f.ctx, book.ID); err != nil { + t.Fatal(err) + } + } + got := f.card(t, book.ID) + if got.Status != "rejected" { + t.Fatalf("a source the engine refused is %q, want rejected", got.Status) + } + if _, err := os.Stat(dir); !errors.Is(err, os.ErrNotExist) { + t.Fatalf("the rejected book's directory is still there: %v", err) + } + // The reason is the platform's own word and is kept for an operator — contract v0 has no field + // for it, so it is deliberately not on the wire. + var reason string + if err := f.store.Pool().QueryRow(f.ctx, + `select reject_reason from books where id = $1`, book.ID).Scan(&reason); err != nil { + t.Fatal(err) + } + if reason != ReasonSourceUnreadable { + t.Fatalf("reject_reason %q, want %q", reason, ReasonSourceUnreadable) + } +} + +// An engine that cannot be RUN is this host's problem and not this book's. The parse is retried — +// and bounded, because a book that waits forever is the failure this must not trade for (PD-162). +func TestAnEngineThatCannotBeRunIsRetriedAndThenGivenUpOn(t *testing.T) { + f := newFixture(t) + book := f.accept(t, "book.txt", "text") + f.provision(t, book) + f.engine.set(ingest.Manifest{}, &exec.Error{Name: "tmctl", Err: os.ErrNotExist}) + // ⚠ The clock moves between attempts because the CLAIM is what spaces them: a failed parse keeps + // it, so the book is not claimable again until the grace passes. Without that the five attempts + // would burn in five ticks of the sweep — 75 seconds — instead of in the time this budget is + // meant to bound, and an engine outage of a couple of minutes would reject every book waiting. + for i := 1; i < parseAttempts; i++ { + if err := f.svc.Parse(f.ctx, book.ID); err != nil { + t.Fatal(err) + } + if got := f.card(t, book.ID); got.Status != "parsing" { + t.Fatalf("attempt %d left the book %q, want it still parsing", i, got.Status) + } + // The very next call, before the grace, must do NOTHING at all. + before := f.engine.called() + if err := f.svc.Parse(f.ctx, book.ID); err != nil { + t.Fatal(err) + } + if f.engine.called() != before { + t.Fatalf("attempt %d was retried before the grace passed", i) + } + f.now = f.now.Add(claimGrace + time.Minute) + } + if err := f.svc.Parse(f.ctx, book.ID); err != nil { + t.Fatal(err) + } + got := f.card(t, book.ID) + if got.Status != "rejected" { + t.Fatalf("after %d attempts the book is %q, want rejected", parseAttempts, got.Status) + } + // The file STAYS: the fault was the deployment's, and deleting a user's upload because this host + // was misconfigured is not a decision to make on their behalf. + if _, err := os.Stat(filepath.Join(f.root, book.ID, SourceName+".txt")); err != nil { + t.Fatalf("the source of a book rejected for a deployment fault was removed: %v", err) + } +} + +// The seam of the open question: a book directory with no engine configuration cannot be parsed, and +// the platform does not write that file (D39.110 §2b). +// +// ⚠ This test asserts the OPPOSITE of what it did before the cross-family review, and the reversal is +// deliberate. It used to pin "five attempts, then rejected" — which meant that on any deployment +// without an answer to the `book.yaml` question (that is, every deployment today) EVERY upload was +// destroyed within the hour, over a gap the user cannot see and an operator can fix in a second. +// A missing configuration is a DEPLOYMENT state, so the book waits in `parsing`, visibly, with its +// source intact, and one file dropped in is all it takes. +func TestABookWithNoEngineConfigurationWaitsRatherThanDies(t *testing.T) { + f := newFixture(t) + book := f.accept(t, "book.txt", "text") + for range parseAttempts * 2 { + if err := f.svc.Parse(f.ctx, book.ID); err != nil { + t.Fatal(err) + } + f.now = f.now.Add(claimGrace + time.Minute) // the claim spaces the retries + } + if f.engine.called() != 0 { + t.Fatalf("the engine was asked %d times about a book it has no configuration for", f.engine.called()) + } + var status, reason string + if err := f.store.Pool().QueryRow(f.ctx, + `select status, reject_reason from books where id = $1`, book.ID).Scan(&status, &reason); err != nil { + t.Fatal(err) + } + if status != "parsing" || reason != "" { + t.Fatalf("a book with no configuration is %q/%q; it must WAIT, not be destroyed", status, reason) + } + if _, err := os.Stat(filepath.Join(f.root, book.ID, SourceName+".txt")); err != nil { + t.Fatalf("the source of a book waiting for its configuration was removed: %v", err) + } + // And the moment somebody provisions it, the very next pass parses it. + f.provision(t, pgstore.Book{ID: book.ID}) + f.now = f.now.Add(claimGrace + time.Minute) + if err := f.svc.Parse(f.ctx, book.ID); err != nil { + t.Fatal(err) + } + if got := f.card(t, book.ID); got.Status != "not_started" { + t.Fatalf("after provisioning the book is %q, want not_started", got.Status) + } +} + +// Two parsers on one book would be two writers of the engine's project file. The claim is a +// compare-and-set, and losing that race is the ordinary case rather than a failure. +func TestOnlyOneOfTwoParsersOfOneBookAsksTheEngine(t *testing.T) { + f := newFixture(t) + book := f.accept(t, "book.txt", "text") + f.provision(t, book) + var wg sync.WaitGroup + errs := make([]error, 8) + for i := range errs { + wg.Add(1) + go func() { + defer wg.Done() + errs[i] = f.svc.Parse(f.ctx, book.ID) + }() + } + wg.Wait() + for i, err := range errs { + if err != nil { + t.Errorf("parser %d: %v", i, err) + } + } + if n := f.engine.called(); n != 1 { + t.Fatalf("the engine was asked %d times for one book, want exactly 1", n) + } +} + +// The backstop: both halves of a stuck intake are reachable only from the sweep, because the process +// that could have finished them is the one that died. +func TestTheSweepFinishesAWalkNobodyElseCan(t *testing.T) { + f := newFixture(t) + stale := f.accept(t, "abandoned.txt", "half a file") + // This one never got past `uploading`: its request is gone. + if _, err := f.store.Pool().Exec(f.ctx, + `update books set status = 'uploading' where id = $1`, stale.ID); err != nil { + t.Fatal(err) + } + waiting := f.accept(t, "waiting.txt", "text") + f.provision(t, waiting) + f.now = f.now.Add(2 * time.Hour) // past both graces + if err := f.svc.Sweep(f.ctx); err != nil { + t.Fatal(err) + } + lib, err := f.store.ListBooks(f.ctx, "u1", 10, "") + if err != nil { + t.Fatal(err) + } + if len(lib.Books) != 1 || lib.Books[0].ID != waiting.ID { + t.Fatalf("the library holds %+v, want only the book that finished uploading", lib.Books) + } + if got := f.card(t, waiting.ID); got.Status != "not_started" { + t.Fatalf("the waiting book is %q, want the sweep to have parsed it", got.Status) + } + if _, err := os.Stat(filepath.Join(f.root, stale.ID)); !errors.Is(err, os.ErrNotExist) { + t.Fatalf("the abandoned upload's directory survived the sweep: %v", err) + } +} + +// Adding a book must MOVE the library's revision. The contract asks a client to drop a read whose +// revision is not above what it applied, so a book that joins at a number the library already had is +// a book the screen that uploaded it never sees (register row PD-122). +func TestABookThatJoinsTheLibraryMovesItsRevision(t *testing.T) { + f := newFixture(t) + before, err := f.store.ListBooks(f.ctx, "u1", 10, "") + if err != nil { + t.Fatal(err) + } + first := f.accept(t, "one.txt", "text") + after, err := f.store.ListBooks(f.ctx, "u1", 10, "") + if err != nil { + t.Fatal(err) + } + if after.Revision <= before.Revision { + t.Fatalf("the library revision stayed at %d after a book joined it", after.Revision) + } + f.provision(t, first) + if err := f.svc.Parse(f.ctx, first.ID); err != nil { + t.Fatal(err) + } + parsed, err := f.store.ListBooks(f.ctx, "u1", 10, "") + if err != nil { + t.Fatal(err) + } + if parsed.Revision <= after.Revision { + t.Fatalf("the library revision stayed at %d when a book's status changed", parsed.Revision) + } +} + +// A book the DEV intake registered carries a workdir the operator chose — their own project +// directory, with their own source in it. No path here may remove one. +func TestADirectoryOutsideTheBooksRootIsNeverRemoved(t *testing.T) { + f := newFixture(t) + outside := t.TempDir() + if err := os.WriteFile(filepath.Join(outside, "irreplaceable.txt"), []byte("x"), 0o600); err != nil { + t.Fatal(err) + } + f.svc.removeDir(outside) + if _, err := os.Stat(filepath.Join(outside, "irreplaceable.txt")); err != nil { + t.Fatalf("a directory outside the books root was removed: %v", err) + } + for _, dir := range []string{f.root, filepath.Join(f.root, ".."), "", "/"} { + if f.svc.owns(dir) { + t.Errorf("owns(%q) is true; only paths BELOW the root are this service's", dir) + } + } + if !f.svc.owns(filepath.Join(f.root, "bk_1")) { + t.Error("a book directory under the root is not recognised as this service's") + } +} + +// The two things the intake takes from a name it did not write. +func TestWhatTheUploadedFileNameIsAllowedToDecide(t *testing.T) { + titles := []struct{ in, want string }{ + {"蛊真人.epub", "蛊真人"}, + {"/etc/passwd", "passwd"}, + {"../../secret.txt", "secret"}, + {"", ""}, + {"no-extension", "no-extension"}, + {".hidden", ".hidden"}, + {strings.Repeat("x", 400) + ".txt", strings.Repeat("x", maxTitle)}, + {"line\nbreak.txt", "linebreak"}, + } + for _, tc := range titles { + if got := titleFrom(tc.in); got != tc.want { + t.Errorf("titleFrom(%q) = %q, want %q", tc.in, got, tc.want) + } + } + exts := []struct{ in, want string }{ + {"book.epub", ".epub"}, + {"BOOK.EPUB", ".epub"}, + {"book.txt", ".txt"}, + {"book", ".txt"}, + {"book.", ".txt"}, + {"book.tar.gz", ".gz"}, + {"evil.sh;rm -rf", ".txt"}, + {"evil../../x", ".txt"}, + {"book.verylongextension", ".txt"}, + } + for _, tc := range exts { + if got := extensionOf(tc.in); got != tc.want { + t.Errorf("extensionOf(%q) = %q, want %q", tc.in, got, tc.want) + } + } +} + +// The languages are CODES and they enter here from a browser. +func TestALanguageThatIsNotACodeIsRefused(t *testing.T) { + f := newFixture(t) + for _, bad := range []string{"", "Chinese", "zh_CN", "ZH", "../zh", "z"} { + _, err := f.svc.Accept(f.ctx, Intake{UserID: "u1", SourceLang: bad, TargetLang: "ru", + Filename: "b.txt", File: strings.NewReader("x")}) + if !errors.Is(err, ErrBadIntake) { + t.Errorf("source_lang %q: %v, want ErrBadIntake", bad, err) + } + } + if entries, err := os.ReadDir(f.root); err != nil || len(entries) != 0 { + t.Fatalf("a refused intake left %v in the books root (%v)", entries, err) + } +} + +// The engine dispatches its reader by EXTENSION (backend/internal/chunk/ingest.go: `.epub` → the +// epub reader, anything else → plain text), so the extension has to survive intake or an EPUB is +// read as text and the book comes out as one chapter of markup. +func TestTheUploadedFilesExtensionSurvivesIntake(t *testing.T) { + f := newFixture(t) + book := f.accept(t, "蛊真人.EPUB", "PK\x03\x04 not really an epub") + if _, err := os.Stat(filepath.Join(f.root, book.ID, SourceName+".epub")); err != nil { + t.Fatalf("the source was not written as source.epub: %v", err) + } +} + +// The zone's log discipline (ENGINEERING_STANDARDS §Наблюдаемость, PD-3): an INFO line does not +// identify a user's library. The intake writes several, so this asserts the whole walk at once. +func TestNoBookIdentifierReachesAnInfoLine(t *testing.T) { + f := newFixture(t) + var buf bytes.Buffer + f.svc.Log = slog.New(slog.NewJSONHandler(&buf, &slog.HandlerOptions{Level: slog.LevelInfo})) + book := f.accept(t, "book.txt", "text") + f.provision(t, book) + if err := f.svc.Parse(f.ctx, book.ID); err != nil { + t.Fatal(err) + } + f.now = f.now.Add(2 * time.Hour) + if err := f.svc.Sweep(f.ctx); err != nil { + t.Fatal(err) + } + for _, line := range strings.Split(strings.TrimSpace(buf.String()), "\n") { + if line == "" { + continue + } + var rec map[string]any + if err := json.Unmarshal([]byte(line), &rec); err != nil { + t.Fatal(err) + } + if rec["level"] != "INFO" && rec["level"] != "WARN" { + continue // an ERROR may carry the path an operator needs — the open class of PD-139 + } + if strings.Contains(line, book.ID) { + t.Errorf("a book id reached a %s line: %s", rec["level"], line) + } + } +} + +// The library's revision may never go BACKWARDS, and an abandoned upload is the one deletion this +// zone has: the book that joined at the top of the account's scale is removed when its request goes +// away, and the maximum would fall with it. A client that had applied the higher number must then, by +// the contract, drop every later read of the library — the screen keeps rendering a book that is gone. +func TestCancellingAnUploadDoesNotWindTheLibraryBack(t *testing.T) { + f := newFixture(t) + kept := f.accept(t, "kept.txt", "text") + f.provision(t, kept) + before, err := f.store.ListBooks(f.ctx, "u1", 10, "") + if err != nil { + t.Fatal(err) + } + // The number that matters is the one a CLIENT could have seen — the library WHILE the doomed + // upload was in it, not the library before it arrived. Read from inside the upload, which is + // exactly where a second tab would read it. + reading, release := make(chan struct{}), make(chan struct{}) + ctx, cancel := context.WithCancel(f.ctx) + defer cancel() + done := make(chan error, 1) + go func() { + _, err := f.svc.Accept(ctx, Intake{UserID: "u1", SourceLang: "zh", TargetLang: "ru", + Filename: "abandoned.txt", + File: &failingReader{cancel: cancel, gate: reading, release: release}}) + done <- err + }() + <-reading + during, err := f.store.ListBooks(f.ctx, "u1", 10, "") + if err != nil { + t.Fatal(err) + } + if during.Revision <= before.Revision { + t.Fatalf("the upload joined the library at revision %d, which is not above %d", + during.Revision, before.Revision) + } + close(release) + if err := <-done; err == nil { + t.Fatal("an upload whose body failed was accepted") + } + after, err := f.store.ListBooks(f.ctx, "u1", 10, "") + if err != nil { + t.Fatal(err) + } + if after.Revision < during.Revision { + t.Fatalf("the library revision went back from %d to %d when the upload was cancelled", + during.Revision, after.Revision) + } + if len(after.Books) != 1 || after.Books[0].ID != kept.ID { + t.Fatalf("the library holds %+v, want only the book that stayed", after.Books) + } +} + +// The sweep decides from a snapshot too, and its cure is the same: the directory of a book goes only +// when the ROW went with it. A request that finished while the sweep was looking moves the book to +// `parsing`, DeleteUpload's status guard then refuses — and removing the directory anyway would +// delete the source of a live book, leaving one that can only end rejected and an hour of somebody's +// upload gone without a word. +func TestTheSweepNeverRemovesTheSourceOfABookItCouldNotDelete(t *testing.T) { + f := newFixture(t) + book := f.accept(t, "book.txt", "text") // already `parsing`: its upload finished + f.svc.abandon(f.ctx, book.ID, filepath.Join(f.root, book.ID)) + if got := f.card(t, book.ID); got.Status != "parsing" { + t.Fatalf("the row is %q, want the parsing book it became", got.Status) + } + if _, err := os.Stat(filepath.Join(f.root, book.ID, SourceName+".txt")); err != nil { + t.Fatalf("the source of a book that was NOT deleted was removed: %v", err) + } +} + +// The two constants are a PAIR and neither package can see the other's reasoning, so the pair is +// asserted here. A claim grace shorter than the queue's job timeout means the backstop sweep takes a +// parse away from the worker that is still running it: both then meet on one project directory, the +// loser dies on the engine's exclusive lock with exit 1 — and exit 1 is how the engine says "I cannot +// cut this source". +func TestTheClaimGraceOutlivesTheQueuesJobTimeout(t *testing.T) { + if claimGrace <= jobs.JobTimeout { + t.Fatalf("claimGrace %s does not outlive the queue's job timeout %s: the sweep would steal a claim from a running parse", + claimGrace, jobs.JobTimeout) + } +} + +// Every byte is in and the client hung up while waiting for the 201 — a phone that went to sleep, a +// tab that was closed. The book must still be theirs: the row is what makes it findable at all, and +// throwing away a completed upload because nobody is left to read the answer costs the user the whole +// file and the whole wait. +func TestAnUploadSurvivesAClientThatHangsUpAfterTheLastByte(t *testing.T) { + f := newFixture(t) + ctx, cancel := context.WithCancel(f.ctx) + book, err := f.svc.Accept(ctx, Intake{UserID: "u1", SourceLang: "zh", TargetLang: "ru", + Filename: "book.txt", File: &hangUpReader{data: "первая глава", cancel: cancel}}) + if err != nil { + t.Fatalf("an upload whose client left after the last byte was dropped: %v", err) + } + if book.Status != "parsing" { + t.Fatalf("the book is %q, want parsing", book.Status) + } + if _, err := os.Stat(filepath.Join(f.root, book.ID, SourceName+".txt")); err != nil { + t.Fatalf("the received source is gone: %v", err) + } +} + +// hangUpReader delivers the whole body and then cancels the request's context, which is what a client +// that walks away after sending everything looks like from here. +type hangUpReader struct { + data string + cancel context.CancelFunc + sent bool +} + +func (h *hangUpReader) Read(p []byte) (int, error) { + if !h.sent { + h.sent = true + return copy(p, h.data), nil + } + h.cancel() + return 0, io.EOF +} + +// FP5-1 (acceptance, HIGH). A library revision must identify ONE state of the library. The floor a +// cancelled upload raises is only half the rule: if the next book joins from the maximum alone it +// lands at or below that floor, and the same number then answers for three different libraries — +// before the cancelled upload, during it, and after the next one arrived. +func TestABookThatJoinsAfterACancelledUploadStillMovesTheRevision(t *testing.T) { + f := newFixture(t) + first := f.accept(t, "kept.txt", "text") + f.provision(t, first) + before := f.libraryRevision(t) + + ctx, cancel := context.WithCancel(f.ctx) + defer cancel() + if _, err := f.svc.Accept(ctx, Intake{UserID: "u1", SourceLang: "zh", TargetLang: "ru", + Filename: "abandoned.txt", File: &failingReader{cancel: cancel}}); err == nil { + t.Fatal("an upload whose body failed was accepted") + } + afterCancel := f.libraryRevision(t) + if afterCancel < before { + t.Fatalf("the revision went back after a cancelled upload: %d → %d", before, afterCancel) + } + next := f.accept(t, "next.txt", "text") + afterNext := f.libraryRevision(t) + if afterNext <= afterCancel { + t.Fatalf("a book that joined after a cancelled upload left the revision at %d (was %d): the same number now answers for two different libraries", + afterNext, afterCancel) + } + // And the book itself carries a revision above the floor, so its own card is not stale on arrival. + var rev int64 + if err := f.store.Pool().QueryRow(f.ctx, `select revision from books where id = $1`, next.ID).Scan(&rev); err != nil { + t.Fatal(err) + } + if rev <= afterCancel { + t.Fatalf("the new book joined at revision %d, at or below the floor %d", rev, afterCancel) + } +} + +// FP5-4 (acceptance). Waiting for a configuration must not bring DELETION closer: every claim counted +// against the budget, so a book that waited out five graces had its budget spent before the engine +// was asked once — and the first answer, which the engine gives as exit 1 for a typo in `book.yaml` +// as much as for an unreadable file, was terminal on arrival and took the source with it. +func TestWaitingForAConfigurationDoesNotBringDeletionCloser(t *testing.T) { + f := newFixture(t) + book := f.accept(t, "book.txt", "text") + for range parseAttempts * 3 { + if err := f.svc.Parse(f.ctx, book.ID); err != nil { + t.Fatal(err) + } + f.now = f.now.Add(claimGrace + time.Minute) + } + var attempts int + if err := f.store.Pool().QueryRow(f.ctx, + `select parse_attempts from books where id = $1`, book.ID).Scan(&attempts); err != nil { + t.Fatal(err) + } + if attempts != 0 { + t.Fatalf("waiting for a configuration spent %d attempts of the budget", attempts) + } + // Now it is provisioned and the engine refuses the source once: with the budget intact, that first + // answer must NOT be terminal and must NOT delete anything. + f.provision(t, pgstore.Book{ID: book.ID}) + f.engine.set(ingest.Manifest{}, refusal(t)) + if err := f.svc.Parse(f.ctx, book.ID); err != nil { + t.Fatal(err) + } + if got := f.card(t, book.ID); got.Status != "parsing" { + t.Fatalf("the first engine refusal after a long wait made the book %q", got.Status) + } + if _, err := os.Stat(filepath.Join(f.root, book.ID, SourceName+".txt")); err != nil { + t.Fatalf("the source was deleted on the first refusal after a wait: %v", err) + } +} + +// FP5-3 (acceptance). The rejection path removes the directory BEFORE it writes the row, so a crash +// between the two leaves a book in `parsing` with nothing on disk. That must be terminal — otherwise +// the missing directory reads as "not configured", which is the one reason that never ends, and the +// book waits for a configuration nobody can put anywhere. +func TestABookWhoseDirectoryIsGoneIsRejectedRatherThanLeftWaiting(t *testing.T) { + f := newFixture(t) + book := f.accept(t, "book.txt", "text") + f.provision(t, book) + if err := os.RemoveAll(filepath.Join(f.root, book.ID)); err != nil { + t.Fatal(err) + } + if err := f.svc.Parse(f.ctx, book.ID); err != nil { + t.Fatal(err) + } + var status, reason string + if err := f.store.Pool().QueryRow(f.ctx, + `select status, reject_reason from books where id = $1`, book.ID).Scan(&status, &reason); err != nil { + t.Fatal(err) + } + if status != "rejected" || reason != ReasonSourceUnreadable { + t.Fatalf("a book with no directory is %q/%q, want rejected/%s", status, reason, ReasonSourceUnreadable) + } + if f.engine.called() != 0 { + t.Fatalf("the engine was asked %d times about a book with no directory", f.engine.called()) + } +} + +// FP5-5 (acceptance): the backstop's re-drive of a parse is the SAME engine call the queue gives +// fifteen minutes. Under the run sweep's two-minute pass it was killed by the deadline, the kill was +// read as a host that cannot run the engine, and the attempt was spent — every pass, until the book +// was rejected for being large. +func TestOneBookInTheSweepGetsABudgetAParseCanLiveIn(t *testing.T) { + f := newFixture(t) + book := f.accept(t, "book.txt", "text") + f.provision(t, book) + // The engine takes longer than a second and reports the deadline it was given. + var granted time.Duration + f.engine.onManifest = func(ctx context.Context) { + if dl, ok := ctx.Deadline(); ok { + granted = time.Until(dl) + } + } + f.now = f.now.Add(2 * time.Hour) // past the graces, so the sweep picks the book up + if err := f.svc.Sweep(f.ctx); err != nil { + t.Fatal(err) + } + // Not an equality: the deadline is read a few microseconds after it was granted. What matters is + // that a parse gets the same ORDER of time from the sweep as it does from the queue, rather than + // the two minutes the run pass runs on. + if granted < jobs.JobTimeout-time.Minute { + t.Fatalf("the sweep gave one book's parse %s; the same call gets %s from the queue", + granted, jobs.JobTimeout) + } +} + +// Cross-family review of the acceptance dofix (H1): the storage ROOT being gone — an unmounted +// volume, a deployment pointed at a path that is not there yet — looks exactly like one book's +// directory being gone, and the two mean opposite things. Read as the second, ONE sweep rejects +// every book in intake with "the source cannot be read": a reason that blames the user's file, is +// terminal by design, and has no way back. +func TestAVanishedStorageRootIsNotEveryBooksFault(t *testing.T) { + f := newFixture(t) + first := f.accept(t, "one.txt", "text") + second := f.accept(t, "two.txt", "text") + f.provision(t, first) + f.provision(t, second) + if err := os.RemoveAll(f.root); err != nil { + t.Fatal(err) + } + // Well past the budget: waiting for a host to come back must never bring a rejection closer. + for range parseAttempts * 2 { + for _, b := range []pgstore.Book{first, second} { + if err := f.svc.Parse(f.ctx, b.ID); err != nil { + t.Fatal(err) + } + } + f.now = f.now.Add(claimGrace + time.Minute) + } + if f.engine.called() != 0 { + t.Fatalf("the engine was asked %d times while the storage root was gone", f.engine.called()) + } + for _, b := range []pgstore.Book{first, second} { + var status, reason string + var attempts int + if err := f.store.Pool().QueryRow(f.ctx, + `select status, reject_reason, parse_attempts from books where id = $1`, b.ID). + Scan(&status, &reason, &attempts); err != nil { + t.Fatal(err) + } + if status != "parsing" || reason != "" { + t.Fatalf("book %s is %q/%q after the storage root vanished; the host is at fault, not the book", + b.ID, status, reason) + } + if attempts != 0 { + t.Fatalf("book %s spent %d attempts on a host that could not see its own storage: the first "+ + "real answer after this would be terminal on arrival", b.ID, attempts) + } + } + // And when the volume comes back, the very next pass parses the book. + if err := os.MkdirAll(filepath.Join(f.root, first.ID), 0o700); err != nil { + t.Fatal(err) + } + f.provision(t, first) + if err := f.svc.Parse(f.ctx, first.ID); err != nil { + t.Fatal(err) + } + if got := f.card(t, first.ID); got.Status != "not_started" { + t.Fatalf("after the storage came back the book is %q, want not_started", got.Status) + } +} + +// Cross-family review of the acceptance dofix (H2): the floor under the library's revision made the +// STATUS walk invisible. A book uploaded before somebody else's upload was cancelled carries a +// counter below that floor, so `uploading → parsing → not_started` — three increments of its own +// number — left `greatest(max, floor)` exactly where it was, and a client obeying the contract drops +// every read that is not above what it applied. The book would sit on the screen as "arriving" until +// an unrelated event moved the library's number. +func TestAStatusChangeIsVisibleEvenUnderTheLibrarysFloor(t *testing.T) { + f := newFixture(t) + book := f.accept(t, "book.txt", "text") + f.provision(t, book) + + // A cancelled upload raises the floor above the book's own counter. + ctx, cancel := context.WithCancel(f.ctx) + defer cancel() + if _, err := f.svc.Accept(ctx, Intake{UserID: "u1", SourceLang: "zh", TargetLang: "ru", + Filename: "abandoned.txt", File: &failingReader{cancel: cancel}}); err == nil { + t.Fatal("an upload whose body failed was accepted") + } + floor := f.libraryRevision(t) + var own int64 + if err := f.store.Pool().QueryRow(f.ctx, + `select revision from books where id = $1`, book.ID).Scan(&own); err != nil { + t.Fatal(err) + } + if own > floor { + t.Fatalf("the fixture does not reproduce the case: the book is at %d, above the floor %d", own, floor) + } + if err := f.svc.Parse(f.ctx, book.ID); err != nil { + t.Fatal(err) + } + if got := f.card(t, book.ID); got.Status != "not_started" { + t.Fatalf("the book is %q, want not_started", got.Status) + } + if got := f.libraryRevision(t); got <= floor { + t.Fatalf("the book finished parsing and the library still answers %d (was %d): the client drops "+ + "that read and never sees the status", got, floor) + } +} + +// Re-check of the dofix (FP5-10): the SHAPE the first guard missed. A volume mounted at BooksDir +// leaves an empty directory behind when it is unmounted — the mountpoint — and the boot recreates +// that directory anyway, so "does the root exist" answers yes about storage that is gone. Every book +// in intake would then be rejected as unreadable and its source deleted. The marker is what tells the +// two apart: it is written by an upload and cannot be forged by an unmount or by MkdirAll. +func TestAnUnmountedVolumeLooksLikeAnEmptyRootAndStillIsNotTheBooksFault(t *testing.T) { + f := newFixture(t) + book := f.accept(t, "one.txt", "text") + f.provision(t, book) + // The upload is what writes the marker, and nothing else does — asserted here rather than assumed, + // because a marker that is never written makes every missing directory look like an unmount and + // the crash window of the rejection path (FP5-3) would then wait for ever instead of closing. + if _, err := os.Stat(filepath.Join(f.root, StorageMarker)); err != nil { + t.Fatalf("an accepted upload left no storage marker: %v", err) + } + // Exactly what an unmount leaves: the root is there, everything under it is not — including the + // marker, which lived on the volume. + if err := os.RemoveAll(f.root); err != nil { + t.Fatal(err) + } + if err := os.MkdirAll(f.root, 0o750); err != nil { // the boot's own MkdirAll, or the mountpoint + t.Fatal(err) + } + for range parseAttempts * 2 { + if err := f.svc.Parse(f.ctx, book.ID); err != nil { + t.Fatal(err) + } + f.now = f.now.Add(claimGrace + time.Minute) + } + if f.engine.called() != 0 { + t.Fatalf("the engine was asked %d times about storage that is not mounted", f.engine.called()) + } + var status, reason string + var attempts int + if err := f.store.Pool().QueryRow(f.ctx, + `select status, reject_reason, parse_attempts from books where id = $1`, book.ID). + Scan(&status, &reason, &attempts); err != nil { + t.Fatal(err) + } + if status != "parsing" || reason != "" { + t.Fatalf("an unmounted volume left the book %q/%q: the storage is the host's problem, not the book's", + status, reason) + } + if attempts != 0 { + t.Fatalf("the book spent %d attempts while its storage was unmounted", attempts) + } + // And the marker is not something the platform re-creates behind an operator's back: only an + // upload writes it, so the volume coming back is what restores the answer. + if _, err := os.Stat(filepath.Join(f.root, StorageMarker)); err == nil { + t.Fatal("the marker was re-created while the volume was away: it can no longer tell mounted from not") + } +} + +// Re-check of the dofix (tail в): the rest of the same defect. A budget the BOOK gets is not a budget +// the PASS still has — the second book of a pass inherited whatever the first one left, so a slow +// first parse handed the second a stub of a deadline, the engine call died on it, and the attempt was +// spent on a timeout that says nothing about the book. A pass that cannot give a book its whole +// budget must not start it: the claim is taken inside Parse, so a book left for the next tick has +// spent nothing. +func TestAPassTooShortForAParseStartsNoneAtAll(t *testing.T) { + f := newFixture(t) + first := f.accept(t, "one.txt", "text") + second := f.accept(t, "two.txt", "text") + f.provision(t, first) + f.provision(t, second) + f.now = f.now.Add(2 * time.Hour) // past the graces, so the sweep picks both up + // What is left of a pass whose first book was slow. + ctx, cancel := context.WithTimeout(f.ctx, time.Minute) + defer cancel() + if err := f.svc.Sweep(ctx); err != nil { + t.Fatal(err) + } + if f.engine.called() != 0 { + t.Fatalf("the engine was called %d times on a pass shorter than a parse", f.engine.called()) + } + for _, b := range []pgstore.Book{first, second} { + var attempts int + var status string + if err := f.store.Pool().QueryRow(f.ctx, + `select parse_attempts, status from books where id = $1`, b.ID).Scan(&attempts, &status); err != nil { + t.Fatal(err) + } + if attempts != 0 || status != "parsing" { + t.Fatalf("book %s spent %d attempts and is %q after a pass that had no time to parse it", + b.ID, attempts, status) + } + } +} diff --git a/platform/internal/books/parse.go b/platform/internal/books/parse.go new file mode 100644 index 00000000..98e6b098 --- /dev/null +++ b/platform/internal/books/parse.go @@ -0,0 +1,342 @@ +package books + +import ( + "context" + "errors" + "fmt" + "io/fs" + "os" + "os/exec" + "path/filepath" + "time" + + "textmachine/platform/internal/ingest" + "textmachine/platform/internal/jobs" + "textmachine/platform/internal/pgstore" + "textmachine/platform/internal/runner" +) + +// Graces and budgets of the intake walk. Constants rather than settings: they are properties of the +// walk itself, and every one of them is a number an operator would have to reason about the +// reconciler to choose. +const ( + // claimGrace is how long a parse may be somebody's business before another pass may take it. It + // covers the ordinary case — the queue job is claimed in milliseconds — and the failure it exists + // for: the process holding the claim was restarted mid-parse. + // + // ⚠ It MUST outlive the queue's own job timeout, and it is written as that constant plus a margin + // so the two cannot drift apart. Shorter, the sweep steals the claim from a parse that is still + // legitimately running: thief and holder meet on one project directory, the loser dies on the + // engine's exclusive lock with exit 1 — and exit 1 is how the engine says "I cannot cut this + // source", so a book that was parsing perfectly well would be rejected for it. + claimGrace = jobs.JobTimeout + 5*time.Minute + // UploadGrace is how long a book may stay `uploading`. A request that is still arriving holds the + // row, so anything older than this is an upload whose request is gone — and that reasoning holds + // only while the route's own read deadline is SHORTER. Exported so the boot can refuse a + // configuration where it is not: an operator who raises TM_PLATFORM_UPLOAD_DEADLINE past this + // would have the sweep delete a book's row and directory out from under a request still writing + // into it. + UploadGrace = time.Hour + // parseAttempts is how many times a book may be handed to the engine before intake gives up. EVERY + // answer of the engine goes through it — a refusal of the source as much as a host that cannot run + // the binary — because the engine gives both the same exit code, so what this really bounds is how + // long a broken host re-runs the engine over every book uploaded to it, and how many chances a + // genuinely unreadable file gets before its source is removed. + parseAttempts = 5 +) + +// Reasons a book is rejected. The platform's OWN closed vocabulary: the engine's text reads like +// pipeline internals and never crosses this seam (contract §Problem, PT-33), and these are stored +// for an operator rather than projected — contract v0 gives a rejected book no reason field. +const ( + // ReasonSourceUnreadable — the engine ran and refused the file. Terminal, but NOT on the first + // answer: the engine maps every failure it has onto exit 1, so a refusal goes through the attempt + // budget like everything else and only the last one deletes the source (see defer_). + ReasonSourceUnreadable = "source_unreadable" + // ReasonNotConfigured — the book has no project configuration, so there is nothing to parse + // against. See ErrNotProvisioned: today that is a deployment's state, not a user's mistake. + ReasonNotConfigured = "not_configured" + // ReasonParserUnavailable — the engine could not be RUN, repeatedly, until the attempt budget was + // spent. + ReasonParserUnavailable = "parser_unavailable" + // ReasonStorageUnavailable — the storage root itself is not there. Never terminal, and never + // stored on a book: it says something about the host, and the book it happened to be read for is + // no more at fault than any other. See ErrStorageGone. + ReasonStorageUnavailable = "storage_unavailable" +) + +// ErrNotProvisioned is a book directory with no engine configuration in it. +// +// ⚠ This is the seam of an OPEN question, and it is named here rather than papered over. A book's +// project configuration (`book.yaml`) carries the pair, the ceilings and the paths the engine reads, +// and D39.110 §2b says the platform does not write that file. Creating a directory for an uploaded +// book therefore leaves somebody having to put the first one there, and who that is — the operator +// from a deployment template, or the platform rendering one at intake and never touching it again — +// is a ratification the zone asked for and does not have (zone journal, P5). +// +// Until it arrives the intake is complete up to this line: the file is received, stored, counted and +// handed to the engine, and a book whose directory carries no configuration is rejected with a +// reason that says so to an operator. +var ErrNotProvisioned = errors.New("books: the book has no engine configuration") + +// ErrDirectoryGone is a book whose whole project directory is missing. +// +// Told apart from ErrNotProvisioned deliberately, and the difference is terminal-vs-not: a missing +// CONFIGURATION is a deployment question somebody can still answer, while a missing DIRECTORY means +// the source this platform received is not there any more and no amount of waiting brings it back. +// It is also the crash window of the rejection path — the directory is removed before the row is +// written — and reading it as "not configured" left such a book in `parsing` for good. +var ErrDirectoryGone = errors.New("books: the book's directory is gone") + +// ErrStorageGone is the ROOT of the book storage missing — an unmounted volume, or a deployment +// pointed at a path that is not there yet. +// +// Told apart from ErrDirectoryGone because the two look identical from one book (both are ENOENT on +// the same Stat) and mean opposite things. A book's own directory being gone is that book's own +// terminal end; the root being gone is the host's, and reading it as the first would have ONE sweep +// reject EVERY book in intake with "the source cannot be read" — a reason that blames the user's +// file, is terminal by design, and has no way back (there is no re-parse and no un-reject). Found by +// cross-family review of the acceptance dofix. +var ErrStorageGone = errors.New("books: the book storage root is gone") + +// Parse turns a received file into a chapter tree, or into a reason it is not one. +// +// It is the body of the queue's worker AND of the backstop sweep, and it is safe to call twice: the +// claim is a compare-and-set, so the loser does nothing. Losing that race is the ordinary case, not +// a failure — two `tmctl manifest` processes on one project directory would be two writers of a file +// the engine holds exclusively. +func (s *Service) Parse(ctx context.Context, bookID string) error { + now := s.now() + claim, err := s.Store.ClaimParse(ctx, bookID, now, now.Add(-claimGrace)) + if errors.Is(err, pgstore.ErrParseClaimed) { + return nil + } + if err != nil { + return err + } + m, err := s.manifest(ctx, claim.Workdir) + if err == nil { + if m.ChaptersTotal < 1 { + // A source the engine read and found nothing in. The book's own fault — but it goes through + // the same budget as everything else, because "the engine answered no" and "the engine could + // not answer" are told apart by an exit code the engine gives BOTH. + return s.defer_(ctx, claim, ReasonSourceUnreadable) + } + // Counts and versions, no book id: the same rule as everywhere else on this side of the log + // (ENGINEERING_STANDARDS §Наблюдаемость). What an operator needs per book — including why a + // book was rejected — is a column, not a log line. + s.log().InfoContext(ctx, "book parsed", "chapters", m.ChaptersTotal, + "manifest_version", m.Version, "chunker", m.ChunkerVersion) + c, cancel := writeCtx(ctx) + defer cancel() + return s.Store.FinishParse(c, bookID, claim.At, pgstore.ParsedBook{ + Chapters: m.ChaptersTotal, + SourceSHA256: m.SourceSHA256Bytes(), + ChunkerVersion: m.ChunkerVersion, + }) + } + if errors.Is(err, ErrStorageGone) { + // The host cannot see its own storage. Nothing about this book is known yet, so it waits with + // the budget untouched — exactly like a book waiting for a configuration. + s.log().ErrorContext(ctx, "the book storage root is not there: intake waits rather than rejects", + "reason", ReasonStorageUnavailable) + return s.defer_(ctx, claim, ReasonStorageUnavailable) + } + if errors.Is(err, ErrDirectoryGone) { + // Nothing to wait for and nothing to retry: what this platform received is not on disk. It is + // also how the rejection path heals from a crash between removing the directory and writing + // the row — the next pass finds no directory and finishes the job. + s.log().ErrorContext(ctx, "book rejected: its directory is gone", "reason", ReasonSourceUnreadable) + return s.reject(ctx, claim, ReasonSourceUnreadable) + } + reason := ReasonParserUnavailable + switch { + case errors.Is(err, ErrNotProvisioned): + reason = ReasonNotConfigured + case refusedTheSource(err): + reason = ReasonSourceUnreadable + } + // ⚠ err can carry the book's path (the engine is asked about a directory), which is the open class + // of PD-139, so it travels only at WARN/ERROR and never at INFO. + return s.defer_(ctx, claim, reason) +} + +// defer_ spends one attempt of the budget and, when the budget is gone, ends the intake. +// +// ONE path for every way a parse can fail, and that is the correction the cross-family review +// forced: the engine maps ALL of its failures onto exit 1 (its own comment says so), so "this source +// cannot be cut" is not distinguishable from "the disk was full", "an operator's own tmctl held the +// lock", or "another pass of this platform took the claim and is holding it". Rejecting — and +// DELETING the user's file — on the first exit 1 turned any of those into irreversible data loss. +// Spending an attempt instead costs a broken host five engine calls spread over the claim grace and +// costs a genuinely unreadable book the same five before it is rejected. +// +// ⚠ `not_configured` is the one reason that NEVER becomes terminal. The platform does not write +// `book.yaml` (D39.110 §2b) and who does is a ratification this zone does not have, so a book whose +// directory has no configuration is waiting on a DEPLOYMENT question. Rejecting it — and it would be +// every uploaded book on a deployment without that answer — would destroy a user's upload over a +// gap they cannot see. It stays `parsing`, visible in the intake metric, and an operator dropping +// the file in is all it takes. +func (s *Service) defer_(ctx context.Context, claim pgstore.ParseClaim, reason string) error { + if !waitsForTheDeployment(reason) && claim.Attempts >= parseAttempts { + s.log().ErrorContext(ctx, "book rejected: intake has spent its attempts on it", + "attempts", claim.Attempts, "reason", reason) + return s.reject(ctx, claim, reason) + } + // The claim is NOT given back: it is what spaces the retries. Released, the sweep would re-offer + // the book on its very next tick (the staleness predicate falls back to `added_at`, which is + // already old), and the attempts would burn in as many ticks — 75 seconds at the default sweep + // interval — rather than over the time this budget is for. + // + // The ATTEMPT, though, is given back when the engine was never asked. `ClaimParse` counts every + // claim, and a book waiting for its configuration claims once per grace forever — so without this + // the budget was spent by WAITING, and the first real answer from the engine afterwards was + // terminal on arrival. A typo in a hand-written `book.yaml` would then delete the user's upload + // on the first attempt at reading it. Waiting must not bring deletion closer. + if waitsForTheDeployment(reason) { + c, cancel := writeCtx(ctx) + defer cancel() + if err := s.Store.RefundParseAttempt(c, claim.BookID, claim.At); err != nil { + s.log().ErrorContext(ctx, "the parse attempt could not be refunded", "err", err) + } + } + s.log().WarnContext(ctx, "parse deferred", "attempts", claim.Attempts, "reason", reason) + return nil +} + +// waitsForTheDeployment reports the reasons that never end an intake and never spend its budget: the +// engine was not asked at all, and what is missing belongs to the deployment — a configuration +// nobody has written yet, or the storage root itself. Both are answerable by a human, and both would +// otherwise destroy every upload on a host that is merely misconfigured. +func waitsForTheDeployment(reason string) bool { + return reason == ReasonNotConfigured || reason == ReasonStorageUnavailable +} + +// writeCtx is the context a TERMINAL write uses: detached from the caller's deadline and bounded on +// its own. +// +// The engine call this follows can legitimately consume the whole budget of the pass — and then the +// write that records what happened would run on an already-expired context and be lost, leaving the +// book to be retried and the attempt to be spent again, forever. What must survive is the record. +func writeCtx(ctx context.Context) (context.Context, context.CancelFunc) { + return context.WithTimeout(context.WithoutCancel(ctx), writeBudget) +} + +// writeBudget is what a terminal write gets. Short: it is one statement against a database this +// process is already connected to. +const writeBudget = 30 * time.Second + +// manifest asks the engine to cut the book, once its configuration is there to cut it against. +func (s *Service) manifest(ctx context.Context, workdir string) (ingest.Manifest, error) { + if s.Engine == nil { + return ingest.Manifest{}, errors.New("books: no engine is configured") + } + if _, err := os.Stat(workdir); err != nil { + if errors.Is(err, fs.ErrNotExist) { + // Which of the two absences this is decides whether the book dies, so it is decided by the + // SENTINEL rather than by the root's own existence (see storageIsThere). + if !s.storageIsThere() { + return ingest.Manifest{}, ErrStorageGone + } + return ingest.Manifest{}, ErrDirectoryGone + } + return ingest.Manifest{}, fmt.Errorf("books: read book directory: %w", err) + } + if _, err := os.Stat(filepath.Join(workdir, runner.ConfigFile)); err != nil { + if errors.Is(err, fs.ErrNotExist) { + return ingest.Manifest{}, ErrNotProvisioned + } + return ingest.Manifest{}, fmt.Errorf("books: read book configuration: %w", err) + } + return s.Engine.Manifest(ctx, s.Cfg.EngineBinary, workdir) +} + +// refusedTheSource tells the engine's ANSWER from the engine's absence. +// +// The engine maps every failure that is not one of its two sentinels onto exit 1 (backend +// cmd/tmctl/main.go exitCode), so "the source cannot be cut" arrives as an ExitError — a process +// that ran and said no. Everything else here is this host: no binary at that path, no permission to +// execute it, a working directory that is gone, a context that expired. Those retry; an answer does +// not. +func refusedTheSource(err error) bool { + var exit *exec.ExitError + if !errors.As(err, &exit) { + return false + } + // A signal is not an answer: an engine the machine killed said nothing about the book. + return exit.Exited() +} + +// reject records the terminal end of an intake and removes what is left of it. +// +// The source is deleted, and it is a decision rather than housekeeping: the file cannot be parsed, +// no path in the contract re-parses or downloads it, and an authenticated route that writes bytes to +// an operator's disk and never removes them is a hole this pack would otherwise be opening. The ROW +// stays — the user has to be able to see that the book they uploaded did not make it. +func (s *Service) reject(ctx context.Context, claim pgstore.ParseClaim, reason string) error { + // The DIRECTORY goes first and the row second, and the order is the crash window: dying between + // the two then leaves a book still `parsing` with no source, which the next attempt fails on and + // rejects properly. The other order leaves a directory with a full source and a row that says + // `rejected` — and nothing ever looks at a rejected book again, so those bytes stay forever. + // + // ⚠ Only where the source is the BOOK's fault. A deployment that could not run the engine keeps + // the file: deleting a user's upload because this host was misconfigured is not a decision to + // make on their behalf. + if reason == ReasonSourceUnreadable { + s.removeDir(claim.Workdir) + } + // On a context of its own: this write is the only record that the intake is over, and the engine + // call that led here may have used up everything the pass had. + c, cancel := writeCtx(ctx) + defer cancel() + return s.Store.RejectBook(c, claim.BookID, claim.At, reason) +} + +// Sweep finishes the intake walk of every book whose own process did not. +// +// The two halves are the two ways a walk stops: a request that went away mid-upload, and a parse +// whose process is gone. Neither is reachable from the thing that started it, which is what makes +// this the only cure — the same reason the run reconciler exists. +// +// One book's failure never stops the sweep: these are independent books of independent accounts. +func (s *Service) Sweep(ctx context.Context) error { + now := s.now() + stuck, err := s.Store.StuckIntake(ctx, now.Add(-UploadGrace), now.Add(-claimGrace)) + if err != nil { + return err + } + for _, b := range stuck { + if err := ctx.Err(); err != nil { + return err + } + switch b.Status { + case "uploading": + s.log().InfoContext(ctx, "removing an upload that never finished") + s.abandon(ctx, b.ID, b.Workdir) + case "parsing": + // Each book gets its OWN budget, and it has to be one a parse can live inside: the engine + // call is the same one the queue gives fifteen minutes. Sharing the pass's deadline meant a + // large book was killed by it, the kill was read as a host that cannot run the engine, and + // the attempt was spent — every pass, until the book was rejected for being big. + // + // ⚠ A budget the book gets is not a budget the PASS still has, and that gap was the rest of + // the same defect (re-check of the dofix): the second book of a pass inherited whatever the + // first one left, so a slow first parse handed the second a stub of a deadline and burned + // its attempt on the timeout. A book that cannot be given its whole budget is therefore not + // STARTED — the claim is taken inside Parse, so a book left for the next tick has spent + // nothing — and the pass says so once rather than per book. + if deadline, ok := ctx.Deadline(); ok && time.Until(deadline) < jobs.JobTimeout { + s.log().InfoContext(ctx, "intake pass ends early: what is left of it is shorter than a parse", + "remaining", time.Until(deadline).String()) + return nil + } + c, cancel := context.WithTimeout(ctx, jobs.JobTimeout) + err := s.Parse(c, b.ID) + cancel() + if err != nil { + s.log().ErrorContext(ctx, "book could not be parsed", "err", err) + } + } + } + return nil +} diff --git a/platform/internal/config/config.go b/platform/internal/config/config.go index b95962c4..5a8a2b4f 100644 --- a/platform/internal/config/config.go +++ b/platform/internal/config/config.go @@ -4,18 +4,22 @@ package config import ( "errors" "fmt" + "log/slog" "os" "path/filepath" "strconv" "strings" "time" + "textmachine/platform/internal/books" "textmachine/platform/internal/money" "textmachine/platform/internal/pricing" ) // Config is the whole configuration surface. Environment only: a control plane is deployed, not -// hand-run, and a settings file is one more thing to keep in sync with the deployment. +// hand-run, and a settings file is one more thing to keep in sync with the deployment. Ratified +// 09.08 (register row PD-114) together with the obligation this file now also carries — an operator +// must be able to read what actually applied, which is what Settings and LogEffective are for. type Config struct { Addr string // DSN is the Postgres connection string. Empty is allowed and means "start unready": the @@ -54,9 +58,18 @@ type Config struct { // SignupGrantMicroUSD is the credit a new account is created with (the free tier, owner 05.08: // default five dollars, settable per account by granting a different amount). SignupGrantMicroUSD int64 + // MetricsAddr is where the telemetry listener binds. A SEPARATE listener from the API on + // purpose — see the runner's own note — and empty turns it off. + MetricsAddr string // Runner is everything about starting engine runs. Runner RunnerConfig + // Intake is everything about receiving a book. + Intake IntakeConfig + + // Settings is every value above with where it came from, in the order it was read. It is what + // the boot line prints (PD-114) and it carries no secret and no money amount — see Setting. + Settings []Setting } // RunnerConfig is the runner's operator-facing surface. @@ -95,25 +108,72 @@ type RunnerConfig struct { Workers int } +// IntakeConfig is the book upload's operator-facing surface. +type IntakeConfig struct { + // BooksDir is the root under which the platform creates one directory per uploaded book. Empty + // means this deployment takes no uploads, and then POST /books is not mounted at all: an + // instance that accepted files it has nowhere to put would reject every one of them after the + // upload rather than before it. + // + // Absolute, for the same reason StateDir is: a relative root names a different directory to the + // daemon and to anything else that reads a book's workdir out of the database. + BooksDir string + // MaxUploadBytes caps ONE upload's request body, and it is the per-route limit PD-72 was opened + // for. It is a refusal and not a sizing: the acceptance corpus is a 23 MB book. + MaxUploadBytes int64 + // UploadDeadline is how long one upload's body may take to arrive. It REPLACES the server's + // 30-second ReadTimeout for that route alone and stays finite; clearing a deadline instead is + // what re-created PD-2 and is forbidden (STACK_DECISIONS §12). + UploadDeadline time.Duration +} + +// Setting is one resolved value as an operator may read it in a log. +type Setting struct { + // Key is the environment variable. + Key string + // Value is what applied — or a REDACTION where the value may not be logged. Two classes are + // redacted and both are rules of this project rather than taste: a secret (a DSN carries a + // password; §11 keeps it out of the process environment for the same reason), and any amount of + // MONEY, which does not reach an INFO log in any form (D39.84, PD-99). For those the source is + // the whole point — "did my file get picked up" is answerable without the value. + Value string + // Source is where the value came from: default, environment, or a *_FILE. + Source string +} + +const ( + sourceDefault = "default" + sourceEnv = "environment" + sourceFile = "file" +) + +// redacted values. They are written out rather than left empty so that a line always says something: +// an empty value column reads as "unset", which is a different fact. +const ( + valueSet = "(set)" + valueUnset = "(unset)" + valueAmount = "(an amount; not logged)" +) + // Load reads the environment. func Load() (Config, error) { + var l loader c := Config{ - Addr: env("TM_PLATFORM_ADDR", "127.0.0.1:8080"), - DSN: "", // read below: it may come from a file + Addr: l.env("TM_PLATFORM_ADDR", "127.0.0.1:8080"), + OIDCProvider: l.env("TM_PLATFORM_OIDC_PROVIDER", "google"), + OIDCIssuer: l.env("TM_PLATFORM_OIDC_ISSUER", ""), + OIDCClientID: l.env("TM_PLATFORM_OIDC_CLIENT_ID", ""), + OIDCRedirectURL: l.env("TM_PLATFORM_OIDC_REDIRECT_URL", ""), + AfterLogin: l.env("TM_PLATFORM_AFTER_LOGIN", "/"), + MetricsAddr: metricsAddr(l.env("TM_PLATFORM_METRICS_ADDR", "127.0.0.1:9464")), + SignupGrantMicroUSD: 5 * 1_000_000, // 14 days idle, 30 days absolute. The absolute one is the NIST SP 800-63B-4 AAL1 figure // ("SHOULD be no more than 30 days"), not a preference: it was 90 days, and a deviation from // a SHOULD needs a reason that survives inspection, which that one did not (PD-58, §13). - SessionIdleTTL: 14 * 24 * time.Hour, - SessionMaxAge: 30 * 24 * time.Hour, - OIDCProvider: env("TM_PLATFORM_OIDC_PROVIDER", "google"), - OIDCIssuer: os.Getenv("TM_PLATFORM_OIDC_ISSUER"), - OIDCClientID: os.Getenv("TM_PLATFORM_OIDC_CLIENT_ID"), - OIDCClientSecret: "", // read below: it may come from a file - OIDCRedirectURL: os.Getenv("TM_PLATFORM_OIDC_REDIRECT_URL"), - AfterLogin: env("TM_PLATFORM_AFTER_LOGIN", "/"), - SignupGrantMicroUSD: 5 * 1_000_000, + SessionIdleTTL: 14 * 24 * time.Hour, + SessionMaxAge: 30 * 24 * time.Hour, } - if raw := os.Getenv("TM_PLATFORM_TRUSTED_ORIGINS"); raw != "" { + if raw := l.env("TM_PLATFORM_TRUSTED_ORIGINS", ""); raw != "" { for _, o := range strings.Split(raw, ",") { if o = strings.TrimSpace(o); o != "" { c.TrustedOrigins = append(c.TrustedOrigins, o) @@ -121,38 +181,34 @@ func Load() (Config, error) { } } var err error - if c.Migrate, err = boolean("TM_PLATFORM_MIGRATE"); err != nil { + if c.Migrate, err = l.boolean("TM_PLATFORM_MIGRATE"); err != nil { return Config{}, err } - if c.InsecureCookies, err = boolean("TM_PLATFORM_INSECURE_COOKIES"); err != nil { + if c.InsecureCookies, err = l.boolean("TM_PLATFORM_INSECURE_COOKIES"); err != nil { return Config{}, err } - if c.DSN, err = secret("TM_PLATFORM_DSN"); err != nil { + if c.DSN, err = l.secret("TM_PLATFORM_DSN"); err != nil { return Config{}, err } - if c.OIDCClientSecret, err = secret("TM_PLATFORM_OIDC_CLIENT_SECRET"); err != nil { + if c.OIDCClientSecret, err = l.secret("TM_PLATFORM_OIDC_CLIENT_SECRET"); err != nil { return Config{}, err } - if c.SessionIdleTTL, err = duration("TM_PLATFORM_SESSION_IDLE", c.SessionIdleTTL); err != nil { + if c.SessionIdleTTL, err = l.duration("TM_PLATFORM_SESSION_IDLE", c.SessionIdleTTL); err != nil { return Config{}, err } - if c.SessionMaxAge, err = duration("TM_PLATFORM_SESSION_MAX_AGE", c.SessionMaxAge); err != nil { + if c.SessionMaxAge, err = l.duration("TM_PLATFORM_SESSION_MAX_AGE", c.SessionMaxAge); err != nil { return Config{}, err } if c.SessionIdleTTL > c.SessionMaxAge { return Config{}, fmt.Errorf("config: session idle TTL %s exceeds max age %s", c.SessionIdleTTL, c.SessionMaxAge) } - if raw := os.Getenv("TM_PLATFORM_SIGNUP_GRANT_USD"); raw != "" { - v, err := money.ParseUSD(raw) - if err != nil { - return Config{}, fmt.Errorf("config: TM_PLATFORM_SIGNUP_GRANT_USD: %w", err) - } - if v < 0 { - return Config{}, errors.New("config: TM_PLATFORM_SIGNUP_GRANT_USD cannot be negative") - } - c.SignupGrantMicroUSD = int64(v) + if c.SignupGrantMicroUSD, err = l.amount("TM_PLATFORM_SIGNUP_GRANT_USD", c.SignupGrantMicroUSD, false); err != nil { + return Config{}, err } - if err := c.loadRunner(); err != nil { + if err := c.loadRunner(&l); err != nil { + return Config{}, err + } + if err := c.loadIntake(&l); err != nil { return Config{}, err } // Half a login configuration is worse than none: the surface would mount and fail at the first @@ -167,9 +223,39 @@ func Load() (Config, error) { if set != 0 && set != len(oidc) { return Config{}, errors.New("config: OIDC needs all of TM_PLATFORM_OIDC_ISSUER, _CLIENT_ID, _CLIENT_SECRET, _REDIRECT_URL, or none") } + c.Settings = l.settings return c, nil } +// metricsAddr turns the operator's word for "do not serve metrics" into the empty address the +// listener understands. +// +// A word is needed because an EMPTY environment variable is indistinguishable from an unset one to +// os.Getenv, and unset takes the default — so without this the switch documented as "empty turns it +// off" could not be reached from the environment at all. +func metricsAddr(v string) string { + if strings.EqualFold(v, "off") || strings.EqualFold(v, "none") { + return "" + } + return v +} + +// LogEffective prints what actually applied, one line per setting (PD-114, ratified 09.08). +// +// It exists because an environment-only configuration has no artifact an operator can read back: a +// deployment whose EnvironmentFile= did not load looks exactly like one whose values were chosen, +// and the difference used to be discoverable only from behaviour. The line carries the SOURCE as +// well as the value, because "did my setting take effect" is the actual question and a value alone +// cannot answer it — a default and an environment variable holding the same string are the same line +// otherwise. +// +// Secrets and money amounts are redacted here rather than at the call site (see Setting.Value). +func (c Config) LogEffective(log *slog.Logger) { + for _, s := range c.Settings { + log.Info("config", "key", s.Key, "value", s.Value, "source", s.Source) + } +} + // LoginEnabled reports whether a sign-in provider is configured. func (c Config) LoginEnabled() bool { return c.OIDCIssuer != "" } @@ -177,17 +263,22 @@ func (c Config) LoginEnabled() bool { return c.OIDCIssuer != "" } // engine binary still serves every read: the library and the book card are not run machinery. func (c Config) RunsEnabled() bool { return c.Runner.EngineBinary != "" } +// IntakeEnabled reports whether this instance can receive a book. It needs somewhere to put the file +// AND an engine to cut it with: parsing is one call of tmctl, so a deployment without the binary +// could only take uploads it would have to reject. +func (c Config) IntakeEnabled() bool { return c.Intake.BooksDir != "" && c.RunsEnabled() } + // loadRunner reads the runner's settings. -func (c *Config) loadRunner() error { +func (c *Config) loadRunner(l *loader) error { r := RunnerConfig{ - EngineBinary: os.Getenv("TM_PLATFORM_ENGINE_BIN"), - StateDir: env("TM_PLATFORM_STATE_DIR", "/var/lib/tmplatform"), - MarkerBinary: os.Getenv("TM_PLATFORM_CTL_BIN"), - CeilingArg: env("TM_PLATFORM_ENGINE_CEILING_ARG", "--ceiling-usd {{usd}}"), + EngineBinary: l.env("TM_PLATFORM_ENGINE_BIN", ""), + StateDir: l.env("TM_PLATFORM_STATE_DIR", "/var/lib/tmplatform"), + MarkerBinary: l.env("TM_PLATFORM_CTL_BIN", ""), + CeilingArg: l.env("TM_PLATFORM_ENGINE_CEILING_ARG", "--ceiling-usd {{usd}}"), // A run gets a generous but finite share of the machine. The figure is a BACKSTOP, not a // sizing: a translation is bounded by its ceiling in dollars, and this bounds the one way a // single run can hurt the others regardless of money (PD-13). - MemoryMax: env("TM_PLATFORM_RUN_MEMORY_MAX", "4G"), + MemoryMax: l.env("TM_PLATFORM_RUN_MEMORY_MAX", "4G"), TasksMax: 64, SweepEvery: 15 * time.Second, ResyncEvery: 5 * time.Minute, @@ -203,56 +294,97 @@ func (c *Config) loadRunner() error { return fmt.Errorf("config: TM_PLATFORM_STATE_DIR must be an absolute path, got %q", r.StateDir) } var err error - if r.TasksMax, err = number("TM_PLATFORM_RUN_TASKS_MAX", r.TasksMax); err != nil { + if r.TasksMax, err = l.number("TM_PLATFORM_RUN_TASKS_MAX", r.TasksMax); err != nil { return err } - if r.AllowEngineVersionChange, err = boolean("TM_PLATFORM_RESUME_MAY_CHANGE_ENGINE"); err != nil { + if r.AllowEngineVersionChange, err = l.boolean("TM_PLATFORM_RESUME_MAY_CHANGE_ENGINE"); err != nil { return err } - if r.Workers, err = number("TM_PLATFORM_RUN_WORKERS", r.Workers); err != nil { + if r.Workers, err = l.number("TM_PLATFORM_RUN_WORKERS", r.Workers); err != nil { return err } - if r.SweepEvery, err = duration("TM_PLATFORM_SWEEP_EVERY", r.SweepEvery); err != nil { + if r.SweepEvery, err = l.duration("TM_PLATFORM_SWEEP_EVERY", r.SweepEvery); err != nil { return err } - if r.ResyncEvery, err = duration("TM_PLATFORM_RESYNC_EVERY", r.ResyncEvery); err != nil { + if r.ResyncEvery, err = l.duration("TM_PLATFORM_RESYNC_EVERY", r.ResyncEvery); err != nil { return err } - if raw := os.Getenv("TM_PLATFORM_USD_PER_CHAPTER"); raw != "" { - v, err := money.ParseUSD(raw) - if err != nil { - return fmt.Errorf("config: TM_PLATFORM_USD_PER_CHAPTER: %w", err) - } - if v <= 0 { - return errors.New("config: TM_PLATFORM_USD_PER_CHAPTER must be positive") - } - r.PerChapterMicroUSD = int64(v) + if r.PerChapterMicroUSD, err = l.amount("TM_PLATFORM_USD_PER_CHAPTER", r.PerChapterMicroUSD, true); err != nil { + return err } c.Runner = r return nil } -func number(key string, def int) (int, error) { - raw := os.Getenv(key) - if raw == "" { - return def, nil +// loadIntake reads the book upload's settings. +func (c *Config) loadIntake(l *loader) error { + in := IntakeConfig{ + BooksDir: l.env("TM_PLATFORM_BOOKS_DIR", ""), + // 64 MiB and ten minutes: the acceptance corpus is a 23 MB book, and ten minutes is what a + // file that size takes on an uplink an ordinary reader has. Both are refusals rather than + // promises — the numbers an operator raises when their users hit them. + MaxUploadBytes: 64 << 20, + UploadDeadline: 10 * time.Minute, } - v, err := strconv.Atoi(raw) - if err != nil || v <= 0 { - return 0, fmt.Errorf("config: %s must be a positive whole number, got %q", key, raw) + if in.BooksDir != "" && !filepath.IsAbs(in.BooksDir) { + return fmt.Errorf("config: TM_PLATFORM_BOOKS_DIR must be an absolute path, got %q", in.BooksDir) } - return v, nil + var err error + if in.MaxUploadBytes, err = l.bytes("TM_PLATFORM_MAX_UPLOAD_BYTES", in.MaxUploadBytes); err != nil { + return err + } + if in.UploadDeadline, err = l.duration("TM_PLATFORM_UPLOAD_DEADLINE", in.UploadDeadline); err != nil { + return err + } + // The two are a PAIR, and the boot is where a mismatched pair is cheap to find. The intake sweep + // treats a book that has been `uploading` longer than its grace as an upload whose request is + // gone — true only while one request cannot legitimately take that long. Longer, and the sweep + // deletes the row and the directory under a request that is still writing into them. + if in.UploadDeadline >= books.UploadGrace { + return fmt.Errorf("config: TM_PLATFORM_UPLOAD_DEADLINE (%s) must be shorter than the intake sweep's grace (%s)", + in.UploadDeadline, books.UploadGrace) + } + c.Intake = in + return nil +} + +// loader reads the environment and REMEMBERS what it read, so that the boot line can say where each +// value came from. Every setting of this service goes through it: a print that covers most of the +// configuration is worse than none, because the variable an operator is hunting is exactly the one +// nobody thought to record. +type loader struct{ settings []Setting } + +func (l *loader) record(key, value, source string) { + l.settings = append(l.settings, Setting{Key: key, Value: value, Source: source}) +} + +func (l *loader) env(key, def string) string { + if v := os.Getenv(key); v != "" { + l.record(key, v, sourceEnv) + return v + } + if def == "" { + l.record(key, valueUnset, sourceDefault) + } else { + l.record(key, def, sourceDefault) + } + return def } // Secret is the shared way to read a credential: from KEY, or preferably from the file named by // KEY_FILE. Exported so the admin CLI reads the DSN the same way the daemon does — an operator who // followed the deploy notes has it in a file, not in the environment. -func Secret(key string) (string, error) { return secret(key) } +func Secret(key string) (string, error) { + var l loader + return l.secret(key) +} // secret reads a value from KEY, or — preferably — from the file named by KEY_FILE. A secret in a // file does not show up in /proc//environ, is not inherited by child processes, and is exactly // what systemd's LoadCredential= hands over (deploy/tmplatformd.service). -func secret(key string) (string, error) { +// +// What is recorded is that it was set and WHERE from, never the value: a DSN carries a password. +func (l *loader) secret(key string) (string, error) { if path := os.Getenv(key + "_FILE"); path != "" { b, err := os.ReadFile(path) if err != nil { @@ -260,35 +392,67 @@ func secret(key string) (string, error) { // the error goes to the log. return "", fmt.Errorf("config: %s_FILE: %w", key, err) } + l.record(key, valueSet, sourceFile) return strings.TrimSpace(string(b)), nil } - return os.Getenv(key), nil + v := os.Getenv(key) + switch v { + case "": + l.record(key, valueUnset, sourceDefault) + default: + l.record(key, valueSet, sourceEnv) + } + return v, nil } // boolean reads a flag. strconv.ParseBool rather than a comparison with "1": an operator who wrote // `true` deserves an error or the truth, not a silent no. -func boolean(key string) (bool, error) { +func (l *loader) boolean(key string) (bool, error) { raw := os.Getenv(key) if raw == "" { + l.record(key, "false", sourceDefault) return false, nil } v, err := strconv.ParseBool(raw) if err != nil { return false, fmt.Errorf("config: %s: %q is not a boolean", key, raw) } + l.record(key, strconv.FormatBool(v), sourceEnv) return v, nil } -func env(key, def string) string { - if v := os.Getenv(key); v != "" { - return v - } - return def -} - -func duration(key string, def time.Duration) (time.Duration, error) { +func (l *loader) number(key string, def int) (int, error) { raw := os.Getenv(key) if raw == "" { + l.record(key, strconv.Itoa(def), sourceDefault) + return def, nil + } + v, err := strconv.Atoi(raw) + if err != nil || v <= 0 { + return 0, fmt.Errorf("config: %s must be a positive whole number, got %q", key, raw) + } + l.record(key, strconv.Itoa(v), sourceEnv) + return v, nil +} + +func (l *loader) bytes(key string, def int64) (int64, error) { + raw := os.Getenv(key) + if raw == "" { + l.record(key, strconv.FormatInt(def, 10), sourceDefault) + return def, nil + } + v, err := strconv.ParseInt(raw, 10, 64) + if err != nil || v <= 0 { + return 0, fmt.Errorf("config: %s must be a positive number of bytes, got %q", key, raw) + } + l.record(key, strconv.FormatInt(v, 10), sourceEnv) + return v, nil +} + +func (l *loader) duration(key string, def time.Duration) (time.Duration, error) { + raw := os.Getenv(key) + if raw == "" { + l.record(key, def.String(), sourceDefault) return def, nil } d, err := time.ParseDuration(raw) @@ -298,5 +462,32 @@ func duration(key string, def time.Duration) (time.Duration, error) { if d <= 0 { return 0, fmt.Errorf("config: %s must be positive", key) } + l.record(key, d.String(), sourceEnv) return d, nil } + +// amount reads a sum in dollars into micro-USD. positiveOnly refuses zero as well as negatives, +// which is the difference between a rate (a zero rate makes every scale infinite) and a grant (a +// deployment that grants nothing is a legitimate choice). +// +// The VALUE is never recorded — money does not reach an INFO log in any form (D39.84, PD-99) — and +// the source is, which is what an operator needs to see that their figure was picked up. +func (l *loader) amount(key string, def int64, positiveOnly bool) (int64, error) { + raw := os.Getenv(key) + if raw == "" { + l.record(key, valueAmount, sourceDefault) + return def, nil + } + v, err := money.ParseUSD(raw) + if err != nil { + return 0, fmt.Errorf("config: %s: %w", key, err) + } + switch { + case positiveOnly && v <= 0: + return 0, fmt.Errorf("config: %s must be positive", key) + case v < 0: + return 0, fmt.Errorf("config: %s cannot be negative", key) + } + l.record(key, valueAmount, sourceEnv) + return int64(v), nil +} diff --git a/platform/internal/config/effective_test.go b/platform/internal/config/effective_test.go new file mode 100644 index 00000000..197a66ce --- /dev/null +++ b/platform/internal/config/effective_test.go @@ -0,0 +1,192 @@ +package config + +import ( + "bytes" + "encoding/json" + "log/slog" + "os" + "regexp" + "sort" + "strings" + "testing" + "time" + + "textmachine/platform/internal/books" +) + +// PD-114. An environment-only configuration leaves no artifact to read back, so the boot line IS the +// artifact — and the two things it must never carry are a secret and a sum of money. + +func effective(t *testing.T) (map[string]Setting, string) { + t.Helper() + c, err := Load() + if err != nil { + t.Fatal(err) + } + var buf bytes.Buffer + c.LogEffective(slog.New(slog.NewJSONHandler(&buf, nil))) + by := map[string]Setting{} + for _, s := range c.Settings { + if _, dup := by[s.Key]; dup { + t.Errorf("%s is printed twice", s.Key) + } + by[s.Key] = s + } + return by, buf.String() +} + +// The completeness gate, and the reason it is written against the SOURCE rather than a list: a print +// that covers most of the configuration is worse than none, because the variable an operator is +// hunting is exactly the one nobody remembered to record. A setting added without a record fails +// here, in the same commit that adds it. +func TestEverySettingThisServiceReadsIsPrinted(t *testing.T) { + src, err := os.ReadFile("config.go") + if err != nil { + t.Fatal(err) + } + want := map[string]bool{} + for _, m := range regexp.MustCompile(`TM_PLATFORM_[A-Z0-9_]+`).FindAllString(string(src), -1) { + // A *_FILE variable is the alternative SOURCE of the key beside it, not a setting of its own: + // it is recorded as `source=file` on that key, and its path is never printed. + want[strings.TrimSuffix(m, "_FILE")] = true + } + got, _ := effective(t) + var missing []string + for k := range want { + if _, ok := got[k]; !ok { + missing = append(missing, k) + } + } + sort.Strings(missing) + if len(missing) != 0 { + t.Fatalf("read but never printed: %v", missing) + } + if len(got) != len(want) { + t.Fatalf("%d settings printed, %d read from the environment", len(got), len(want)) + } +} + +// A DSN carries a password and a client secret is a client secret. What the operator gets is the +// fact and the SOURCE, which is what answers "did my file get picked up". +func TestASecretIsNamedAndNeverPrinted(t *testing.T) { + path := t.TempDir() + "/dsn" + if err := os.WriteFile(path, []byte("postgres://u:hunter2@db/x\n"), 0o600); err != nil { + t.Fatal(err) + } + t.Setenv("TM_PLATFORM_DSN_FILE", path) + // The whole OIDC quartet, because half a login configuration is refused at boot. + t.Setenv("TM_PLATFORM_OIDC_CLIENT_SECRET", "s3cret") + t.Setenv("TM_PLATFORM_OIDC_ISSUER", "https://accounts.example.org") + t.Setenv("TM_PLATFORM_OIDC_CLIENT_ID", "client") + t.Setenv("TM_PLATFORM_OIDC_REDIRECT_URL", "https://app.example.org/auth/callback") + got, line := effective(t) + if got["TM_PLATFORM_DSN"].Source != sourceFile || got["TM_PLATFORM_DSN"].Value != valueSet { + t.Errorf("DSN printed as %+v", got["TM_PLATFORM_DSN"]) + } + if got["TM_PLATFORM_OIDC_CLIENT_SECRET"].Source != sourceEnv { + t.Errorf("client secret printed as %+v", got["TM_PLATFORM_OIDC_CLIENT_SECRET"]) + } + for _, leaked := range []string{"hunter2", "s3cret", path} { + if strings.Contains(line, leaked) { + t.Errorf("the boot line carries %q", leaked) + } + } + // And the value really was read, not lost by being redacted. + c, err := Load() + if err != nil { + t.Fatal(err) + } + if !strings.Contains(c.DSN, "hunter2") || c.OIDCClientSecret != "s3cret" { + t.Fatal("the secret was redacted out of the configuration itself, not only out of the log") + } +} + +// Money does not reach an INFO log in ANY form (D39.84, PD-99) — including the amounts an operator +// configured. The source is what they need, and the source is all they get. +func TestAConfiguredAmountIsNeverPrinted(t *testing.T) { + t.Setenv("TM_PLATFORM_SIGNUP_GRANT_USD", "7.50") + t.Setenv("TM_PLATFORM_USD_PER_CHAPTER", "0.0425") + got, line := effective(t) + for _, k := range []string{"TM_PLATFORM_SIGNUP_GRANT_USD", "TM_PLATFORM_USD_PER_CHAPTER"} { + if got[k].Source != sourceEnv { + t.Errorf("%s: source %q, want the environment it came from", k, got[k].Source) + } + if got[k].Value != valueAmount { + t.Errorf("%s: value %q, want it withheld", k, got[k].Value) + } + } + for _, amount := range []string{"7.50", "7500000", "0.0425", "42500"} { + if strings.Contains(line, amount) { + t.Errorf("the boot line carries the amount %q", amount) + } + } + c, err := Load() + if err != nil { + t.Fatal(err) + } + if c.SignupGrantMicroUSD != 7_500_000 || c.Runner.PerChapterMicroUSD != 42_500 { + t.Fatalf("the amounts did not apply: %d and %d", c.SignupGrantMicroUSD, c.Runner.PerChapterMicroUSD) + } +} + +// A default and an environment variable holding the same string are the same line without the +// source, and telling them apart is the whole question ("did my setting take effect"). +func TestTheSourceOfEveryValueIsPrintedBesideIt(t *testing.T) { + t.Setenv("TM_PLATFORM_ADDR", "127.0.0.1:8080") // exactly the default + got, line := effective(t) + if got["TM_PLATFORM_ADDR"].Source != sourceEnv { + t.Errorf("a value set to the default reads as %q", got["TM_PLATFORM_ADDR"].Source) + } + if got["TM_PLATFORM_AFTER_LOGIN"].Source != sourceDefault { + t.Errorf("an unset value reads as %q", got["TM_PLATFORM_AFTER_LOGIN"].Source) + } + // One line per setting, as structured fields rather than a rendered sentence. + var first map[string]any + if err := json.Unmarshal([]byte(strings.SplitN(line, "\n", 2)[0]), &first); err != nil { + t.Fatal(err) + } + for _, k := range []string{"key", "value", "source"} { + if _, ok := first[k]; !ok { + t.Errorf("the boot line has no %q field: %v", k, first) + } + } +} + +// An intake root that is not absolute names a different directory to every process that reads a +// book's workdir out of the database — the same class as PD-149 on the state directory, refused in +// the same place. +func TestARelativeBooksDirectoryIsRefusedAtBoot(t *testing.T) { + t.Setenv("TM_PLATFORM_BOOKS_DIR", "books") + if _, err := Load(); err == nil { + t.Fatal("a relative TM_PLATFORM_BOOKS_DIR was accepted") + } + t.Setenv("TM_PLATFORM_BOOKS_DIR", "/srv/tm/books") + c, err := Load() + if err != nil { + t.Fatal(err) + } + if c.IntakeEnabled() { + t.Fatal("intake is enabled without an engine binary: a deployment that cannot parse would take uploads it can only reject") + } + t.Setenv("TM_PLATFORM_ENGINE_BIN", "/opt/engine/2026.08.01/tmctl") + if c, err = Load(); err != nil || !c.IntakeEnabled() { + t.Fatalf("intake stayed off with a root and an engine: %v %+v", err, c.Intake) + } +} + +// The upload deadline and the intake sweep's grace are a pair: the sweep treats a book that has been +// `uploading` longer than its grace as an upload whose request is gone, which is only true while one +// request cannot legitimately take that long. Longer, and the sweep deletes the row and the directory +// out from under a request still writing into them — so the boot refuses the combination. +func TestAnUploadDeadlineLongerThanTheSweepsGraceIsRefused(t *testing.T) { + t.Setenv("TM_PLATFORM_BOOKS_DIR", "/srv/tm/books") + t.Setenv("TM_PLATFORM_ENGINE_BIN", "/opt/engine/2026.08.01/tmctl") + t.Setenv("TM_PLATFORM_UPLOAD_DEADLINE", (books.UploadGrace + time.Minute).String()) + if _, err := Load(); err == nil { + t.Fatal("an upload deadline longer than the sweep's grace was accepted") + } + t.Setenv("TM_PLATFORM_UPLOAD_DEADLINE", (books.UploadGrace - time.Minute).String()) + if _, err := Load(); err != nil { + t.Fatalf("a deadline inside the grace was refused: %v", err) + } +} diff --git a/platform/internal/gates/toolchain_test.go b/platform/internal/gates/toolchain_test.go new file mode 100644 index 00000000..955f073a --- /dev/null +++ b/platform/internal/gates/toolchain_test.go @@ -0,0 +1,97 @@ +// Package gates holds the tests of the zone's BUILD gates — the checks that run before any code +// does. They live in their own package because their subject is the Makefile and go.mod rather than +// any package's behaviour, and a gate nobody tests is a gate that quietly stops gating: this file +// exists because the toolchain floor was raised in three documents and one echo, while the check +// itself went on accepting the version it was raised against (re-check of the P5 dofix, FP5-9). +package gates + +import ( + "os" + "os/exec" + "path/filepath" + "regexp" + "strings" + "testing" +) + +// zoneRoot is where the Makefile and go.mod live, two levels up from this package. +const zoneRoot = "../.." + +// The gate is exercised with versions THIS HOST does not have. That is the whole point of asserting +// on the comparison rather than on a run: a gate tested only against the toolchain that happens to be +// installed proves nothing about the one it is meant to refuse. +func TestTheToolchainGateComparesVersionsRatherThanMatchingThem(t *testing.T) { + min := minVersion(t) + if min != "1.26.6" { + // Not a failure — the floor is allowed to move — but the table below is written against it. + t.Logf("GO_MIN_VERSION is %s; the cases below assume the 1.26.x floor", min) + } + for _, c := range []struct { + version string + accept bool + }{ + {"go1.26.6", true}, // the floor itself + {"go1.26.7", true}, // a later patch + {"go1.26.10", true}, // double digits: the regex this replaced ordered 1.26.10 BELOW 1.26.9 + {"go1.27.0", true}, // a later minor + {"go2.0.0", true}, + {"go1.26.5", false}, // the release the floor was raised against — the case that used to pass + {"go1.26.4", false}, + {"go1.25.9", false}, + {"go1.9.9", false}, // one-digit minor must not sort above 1.26 + {"go1.26.6rc1", false}, // a prerelease does not carry the fixes its number promises + {"devel go1.27-abcdef", false}, + } { + err := makeTarget(t, "version-check", "GO_VERSION="+c.version) + if c.accept && err != nil { + t.Errorf("the gate refused %s, which is at or above the floor %s: %v", c.version, min, err) + } + if !c.accept && err == nil { + t.Errorf("the gate ACCEPTED %s, below the floor %s", c.version, min) + } + } +} + +// The other half of the same floor: the Makefile is a convenience, go.mod is what every build reads — +// including one that never calls make. The two must name the same version, or a build that skips the +// battery gets a toolchain the battery would have refused. +func TestGoModPinsTheSameToolchainTheBatteryDemands(t *testing.T) { + src, err := os.ReadFile(filepath.Join(zoneRoot, "go.mod")) + if err != nil { + t.Fatal(err) + } + m := regexp.MustCompile(`(?m)^toolchain go(\S+)$`).FindSubmatch(src) + if m == nil { + t.Fatal("go.mod carries no toolchain directive: the floor then lives only in the Makefile, and a build that does not call make ignores it") + } + if got, want := string(m[1]), minVersion(t); got != want { + t.Errorf("go.mod pins the toolchain at %s and the battery demands %s", got, want) + } +} + +func minVersion(t *testing.T) string { + t.Helper() + src, err := os.ReadFile(filepath.Join(zoneRoot, "Makefile")) + if err != nil { + t.Fatal(err) + } + m := regexp.MustCompile(`(?m)^GO_MIN_VERSION := (\S+)$`).FindSubmatch(src) + if m == nil { + t.Fatal("the Makefile no longer declares GO_MIN_VERSION") + } + return string(m[1]) +} + +func makeTarget(t *testing.T, args ...string) error { + t.Helper() + if _, err := exec.LookPath("make"); err != nil { + t.Skip("make is not on this host: the build gates cannot be exercised") + } + cmd := exec.CommandContext(t.Context(), "make", args...) + cmd.Dir = zoneRoot + out, err := cmd.CombinedOutput() + if err != nil && !strings.Contains(string(out), "required") { + t.Fatalf("make %v failed for a reason other than the gate: %s", args, out) + } + return err +} diff --git a/platform/internal/httpapi/control_test.go b/platform/internal/httpapi/control_test.go new file mode 100644 index 00000000..85f02caa --- /dev/null +++ b/platform/internal/httpapi/control_test.go @@ -0,0 +1,169 @@ +package httpapi + +import ( + "bufio" + "bytes" + "errors" + "fmt" + "io" + "net" + "net/http" + "net/http/httptest" + "testing" + "time" + + "textmachine/platform/internal/metrics" + "textmachine/platform/internal/pgstore" + "textmachine/platform/internal/runs" +) + +// The stop and resume handles answer 202 with a Run, and the run they act on is the one in the path. +func TestStopAndResumeAnswerWithTheRunTheContractDescribes(t *testing.T) { + for _, tc := range []struct { + path string + seen func(*fakeRuns) string + }{ + {"stop", func(f *fakeRuns) string { return f.stopped }}, + {"resume", func(f *fakeRuns) string { return f.resumed }}, + } { + rn := &fakeRuns{run: pgstore.Run{ + ID: "run_9", Revision: 12, Status: "stopped", VerifyBank: true, CeilingChapters: 100, + StartedAt: time.Unix(0, 0).UTC(), + }} + w := call(t, v0Server(t, &fakeLibrary{}, rn), "POST", "/v0/runs/run_9/"+tc.path, "") + if w.Code != http.StatusAccepted { + t.Fatalf("%s: status %d, want 202: %s", tc.path, w.Code, w.Body) + } + got := decode(t, w) + for _, k := range []string{"id", "revision", "status", "verify_bank", "ceiling_chapters", + "paused_reason", "started_at"} { + if _, ok := got[k]; !ok { + t.Errorf("%s: the Run is missing the required field %q", tc.path, k) + } + } + if got["paused_reason"] != nil { + t.Errorf("%s: paused_reason is %v, want null outside a pause", tc.path, got["paused_reason"]) + } + if id := tc.seen(rn); id != "run_9" { + t.Errorf("%s: the service was asked about %q, not the run in the path", tc.path, id) + } + } +} + +// The four ways these two calls fail, each mapped to the code the contract names. 404 and 409 are +// the pair that matters: a run of another account is NOT FOUND, and one that cannot be acted on is a +// conflict — swapping them either tells a stranger the run exists or tells an owner theirs does not. +func TestTheControlHandlesMapEveryRefusalToItsContractCode(t *testing.T) { + cases := []struct { + err error + want int + }{ + {pgstore.ErrNoRun, http.StatusNotFound}, + {fmt.Errorf("%w: it is ready", runs.ErrNotStoppable), http.StatusConflict}, + {fmt.Errorf("%w: the glossary is not signed", runs.ErrNotResumable), http.StatusConflict}, + {errors.New("pgstore: dial tcp 127.0.0.1:5432: connection refused"), http.StatusInternalServerError}, + } + for _, tc := range cases { + h := v0Server(t, &fakeLibrary{}, &fakeRuns{err: tc.err}) + for _, path := range []string{"stop", "resume"} { + w := call(t, h, "POST", "/v0/runs/run_9/"+path, "") + if w.Code != tc.want { + t.Errorf("%s on %v: %d, want %d", path, tc.err, w.Code, tc.want) + } + if w.Header().Get("Content-Type") != "application/problem+json" { + t.Errorf("%s: content-type %q", path, w.Header().Get("Content-Type")) + } + if bytes.Contains(w.Body.Bytes(), []byte("5432")) { + t.Errorf("%s: the response carries internals: %s", path, w.Body) + } + } + } +} + +// Both handles END or RESTART a paid run, so both are unsafe requests on the cookie path and both +// need the CSRF header. The audit of P4 called this the weightiest of its findings: the check +// survives everything else being right. +func TestTheControlHandlesRequireTheCSRFHeaderOnTheCookiePath(t *testing.T) { + h := v0Server(t, &fakeLibrary{}, &fakeRuns{}) + for _, path := range []string{"/v0/runs/run_9/stop", "/v0/runs/run_9/resume", "/v0/books"} { + r := httptest.NewRequest("POST", path, nil) + r.AddCookie(&http.Cookie{Name: "__Host-tm_session", Value: "token"}) + w := httptest.NewRecorder() + h.ServeHTTP(w, r) + if w.Code != http.StatusForbidden { + t.Errorf("%s without X-TM-Client: %d, want 403", path, w.Code) + } + } +} + +// The upload's own deadline, on a REAL server: a book is minutes of a domestic uplink and the +// server's ReadTimeout — the whole of what bounds a half-fed request — covers the entire body. +// +// The negative half is the point of the test. The same slow body on a route that does NOT extend the +// deadline is cut off, which is what proves the extension is doing the work rather than the timeout +// being generous. +func TestTheUploadRouteExtendsItsOwnReadDeadlineAndOnlyItsOwn(t *testing.T) { + in := &fakeIntake{book: pgstore.Book{ID: "bk_1", Status: "parsing"}} + h := v0ServerWith(t, Deps{ + Library: &fakeLibrary{}, Runs: &fakeRuns{}, Intake: in, + Upload: UploadLimits{MaxBytes: 1 << 20, Deadline: 10 * time.Second}, + // With the telemetry wrapper in the chain, exactly as the daemon builds it. It is here on + // purpose: http.ResponseController walks the wrappers through Unwrap, and a wrapper that + // forgets it turns the deadline extension into a silent no-op — the upload would then die at + // the server's own ReadTimeout and nothing in this file would notice. + Observe: metrics.New().Middleware(), + }) + to := fastTimeouts() // Read: 250ms — far less than the body below takes to arrive + ln := start(t, h, to) + + ct, body := form(t, "book.txt", 2048, true) + raw, err := io.ReadAll(body) + if err != nil { + t.Fatal(err) + } + if code := slowPost(t, ln, "/v0/books", ct, raw); code != http.StatusCreated { + t.Fatalf("a slow upload answered %d, want 201: the route's own deadline did not apply", code) + } + // The same shape of body on the JSON route keeps the server's short ReadTimeout and dies with it. + payload := append([]byte(`{"verify_bank":false,"ceiling_chapters":1,"pad":"`), + append(bytes.Repeat([]byte("a"), 2048), []byte(`"}`)...)...) + if code := slowPost(t, ln, "/v0/books/bk_1/runs", "application/json", payload); code == http.StatusAccepted { + t.Fatal("a slow request on an ordinary route was served: the upload's deadline leaked onto it") + } +} + +// slowPost sends a request whose body arrives in two halves a second apart, which is a slow client +// and not a malicious one: the bytes keep coming, just not fast enough for a 250ms deadline. +func slowPost(t *testing.T, ln net.Listener, path, contentType string, body []byte) int { + t.Helper() + var d net.Dialer + conn, err := d.DialContext(t.Context(), "tcp", ln.Addr().String()) + if err != nil { + t.Fatalf("dial: %v", err) + } + defer conn.Close() + head := fmt.Sprintf("POST %s HTTP/1.1\r\nHost: x\r\nAuthorization: Bearer token\r\n"+ + "Content-Type: %s\r\nContent-Length: %d\r\n\r\n", path, contentType, len(body)) + if _, err := conn.Write([]byte(head)); err != nil { + t.Fatalf("write head: %v", err) + } + half := len(body) / 2 + if _, err := conn.Write(body[:half]); err != nil { + t.Fatalf("write first half: %v", err) + } + time.Sleep(time.Second) + if _, err := conn.Write(body[half:]); err != nil { + // A server that gave up has closed the connection; that is an answer too. + return 0 + } + if err := conn.SetReadDeadline(time.Now().Add(5 * time.Second)); err != nil { + t.Fatal(err) + } + resp, err := http.ReadResponse(bufio.NewReader(conn), nil) + if err != nil { + return 0 + } + defer resp.Body.Close() + _, _ = io.Copy(io.Discard, resp.Body) + return resp.StatusCode +} diff --git a/platform/internal/httpapi/intake_test.go b/platform/internal/httpapi/intake_test.go new file mode 100644 index 00000000..d5b677ca --- /dev/null +++ b/platform/internal/httpapi/intake_test.go @@ -0,0 +1,346 @@ +package httpapi + +import ( + "bytes" + "context" + "errors" + "fmt" + "io" + "mime/multipart" + "net/http" + "net/http/httptest" + "os" + "strings" + "testing" + "time" + + "textmachine/platform/internal/books" + "textmachine/platform/internal/pgstore" +) + +// The upload is the only streaming route on this surface, and the only one whose body limit is not +// the default. Both facts are asserted here on the REAL handler chain — through New, with the guard, +// the CSRF layer and the per-route cap in place — because a limit tested on a bare handler is a +// limit tested somewhere other than where it applies (the shape of PD-46). + +type fakeIntake struct { + book pgstore.Book + err error + got books.Intake + // read is what the service actually pulled out of the part, which is how a test tells "the file + // was streamed" from "the handler read it into memory and handed over a copy". + read int64 +} + +func (f *fakeIntake) Accept(_ context.Context, in books.Intake) (pgstore.Book, error) { + f.got = in + if in.File != nil { + n, err := io.Copy(io.Discard, in.File) + f.read = n + if err != nil { + return pgstore.Book{}, err + } + } + if f.err != nil { + return pgstore.Book{}, f.err + } + return f.book, nil +} + +// form builds a multipart body. The order of the parts is the caller's on purpose: the file MUST +// come last, and the test that says so builds it the other way round. +func form(t *testing.T, file string, size int, fieldsFirst bool) (string, io.Reader) { + t.Helper() + var buf bytes.Buffer + w := multipart.NewWriter(&buf) + writeFields := func() { + for k, v := range map[string]string{"source_lang": "zh", "target_lang": "ru", "genre": "xianxia"} { + if err := w.WriteField(k, v); err != nil { + t.Fatal(err) + } + } + } + if fieldsFirst { + writeFields() + } + part, err := w.CreateFormFile("file", file) + if err != nil { + t.Fatal(err) + } + if _, err := part.Write(bytes.Repeat([]byte("a"), size)); err != nil { + t.Fatal(err) + } + if !fieldsFirst { + writeFields() + } + if err := w.Close(); err != nil { + t.Fatal(err) + } + return w.FormDataContentType(), &buf +} + +func upload(t *testing.T, h http.Handler, contentType string, body io.Reader) *httptest.ResponseRecorder { + t.Helper() + r := httptest.NewRequest("POST", "/v0/books", body) + r.Header.Set("Content-Type", contentType) + r.Header.Set("Authorization", "Bearer token") + w := httptest.NewRecorder() + h.ServeHTTP(w, r) + return w +} + +func TestAnAcceptedUploadAnswersWithTheBookTheContractDescribes(t *testing.T) { + in := &fakeIntake{book: pgstore.Book{ + ID: "bk_7c1", Title: "蛊真人", SourceLang: "zh", TargetLang: "ru", Genre: "xianxia", + Status: "parsing", AddedAt: time.Unix(0, 0).UTC(), + }} + h := v0ServerWith(t, Deps{Library: &fakeLibrary{}, Intake: in}) + ct, body := form(t, "蛊真人.txt", 1024, true) + w := upload(t, h, ct, body) + if w.Code != http.StatusCreated { + t.Fatalf("status %d: %s", w.Code, w.Body) + } + got := decode(t, w) + // Every field the contract marks required on Book, present on the intake's own response. + for _, k := range []string{"id", "title", "source_lang", "target_lang", "status", "chapter_count", + "added_at", "progress", "note_count"} { + if _, ok := got[k]; !ok { + t.Errorf("the created book is missing the required field %q", k) + } + } + if got["status"] != "parsing" { + t.Errorf("status %v, want the state the book is actually in", got["status"]) + } + if in.got.SourceLang != "zh" || in.got.TargetLang != "ru" || in.got.Genre != "xianxia" { + t.Errorf("the form did not reach the service: %+v", in.got) + } + if in.got.Filename != "蛊真人.txt" { + t.Errorf("filename %q did not reach the service", in.got.Filename) + } + if in.read != 1024 { + t.Errorf("the service was handed %d bytes of the file, want 1024", in.read) + } +} + +// PD-72, and the reason the register kept the row open until this route landed: the per-route cap is +// only OBSERVABLE once a route asks for more than the default, and the test has to arrive with it. +func TestAnUploadLargerThanTheRouteAllowsIsRefusedAsTooLarge(t *testing.T) { + in := &fakeIntake{} + h := v0ServerWith(t, Deps{ + Library: &fakeLibrary{}, Intake: in, + Upload: UploadLimits{MaxBytes: 4096, Deadline: time.Minute}, + }) + ct, body := form(t, "book.txt", 16*1024, true) + w := upload(t, h, ct, body) + if w.Code != http.StatusRequestEntityTooLarge { + t.Fatalf("status %d, want 413: %s", w.Code, w.Body) + } + if ct := w.Header().Get("Content-Type"); ct != "application/problem+json" { + t.Errorf("content-type %q, want application/problem+json", ct) + } + problem := decode(t, w) + if problem["status"] != float64(http.StatusRequestEntityTooLarge) || problem["title"] == "" { + t.Errorf("problem body %v", problem) + } + // And a body UNDER the limit goes through the same route untouched: a cap that refuses + // everything is not a cap. + ct2, body2 := form(t, "book.txt", 1024, true) + if w := upload(t, h, ct2, body2); w.Code != http.StatusCreated { + t.Fatalf("a 1 KiB upload under a 4 KiB cap: %d %s", w.Code, w.Body) + } +} + +// The other half of the same property (PD-35): raising the limit for ONE route must not raise it for +// the rest of the surface. +func TestTheUploadLimitBelongsToTheUploadRouteAlone(t *testing.T) { + h := v0ServerWith(t, Deps{ + Library: &fakeLibrary{}, Runs: &fakeRuns{}, Intake: &fakeIntake{}, + Upload: UploadLimits{MaxBytes: 8 << 20, Deadline: time.Minute}, + }) + big := strings.Repeat("a", DefaultMaxBody+1) + w := call(t, h, "POST", "/v0/books/bk_1/runs", `{"verify_bank":false,"ceiling_chapters":1,"pad":"`+big+`"}`) + if w.Code == http.StatusAccepted { + t.Fatal("a run request larger than the default body cap was accepted: the upload's limit leaked onto every route") + } +} + +// A deployment with nowhere to put a file mounts no upload route, exactly like every other contract +// route it cannot serve — and an anonymous caller still meets 401 before 404. +func TestWithoutAnIntakeTheUploadRouteIsAGuarded404(t *testing.T) { + h := v0ServerWith(t, Deps{Library: &fakeLibrary{}}) + ct, body := form(t, "book.txt", 16, true) + if w := upload(t, h, ct, body); w.Code != http.StatusNotFound { + t.Fatalf("status %d, want 404 where no intake is configured", w.Code) + } + r := httptest.NewRequest("POST", "/v0/books", nil) + w := httptest.NewRecorder() + h.ServeHTTP(w, r) + if w.Code != http.StatusUnauthorized { + t.Fatalf("an anonymous caller got %d, want 401 before anything else", w.Code) + } +} + +// The wire rule a streaming reader forces: the languages have to be known before the file part is +// handed over, so a form that sends them afterwards is refused rather than half-processed. +func TestAFormWhoseFileComesBeforeItsFieldsIsRefused(t *testing.T) { + in := &fakeIntake{} + h := v0ServerWith(t, Deps{Library: &fakeLibrary{}, Intake: in}) + ct, body := form(t, "book.txt", 64, false) + w := upload(t, h, ct, body) + if w.Code != http.StatusBadRequest { + t.Fatalf("status %d, want 400: %s", w.Code, w.Body) + } + if in.read != 0 { + t.Errorf("the file was streamed before the form was known to be usable (%d bytes)", in.read) + } +} + +func TestAnUploadWithNoFileAtAllIsRefused(t *testing.T) { + h := v0ServerWith(t, Deps{Library: &fakeLibrary{}, Intake: &fakeIntake{}}) + var buf bytes.Buffer + w := multipart.NewWriter(&buf) + if err := w.WriteField("source_lang", "zh"); err != nil { + t.Fatal(err) + } + if err := w.Close(); err != nil { + t.Fatal(err) + } + if got := upload(t, h, w.FormDataContentType(), &buf); got.Code != http.StatusBadRequest { + t.Fatalf("status %d, want 400", got.Code) + } + // And a body that is not multipart at all. + r := httptest.NewRequest("POST", "/v0/books", strings.NewReader(`{"not":"multipart"}`)) + r.Header.Set("Content-Type", "application/json") + r.Header.Set("Authorization", "Bearer token") + rec := httptest.NewRecorder() + h.ServeHTTP(rec, r) + if rec.Code != http.StatusBadRequest { + t.Fatalf("a JSON body on the intake route: %d, want 400", rec.Code) + } +} + +// A field the contract does not name is IGNORED, like an unknown JSON property: inside 0.x a minor +// bump is where optional fields appear, and refusing the upload would break a client generated +// against a later version for something it was free to ignore. +func TestAnUnknownFormFieldDoesNotRefuseTheUpload(t *testing.T) { + in := &fakeIntake{book: pgstore.Book{ID: "bk_1", Status: "parsing"}} + h := v0ServerWith(t, Deps{Library: &fakeLibrary{}, Intake: in}) + var buf bytes.Buffer + w := multipart.NewWriter(&buf) + for _, kv := range [][2]string{{"source_lang", "zh"}, {"target_lang", "ru"}, {"title", "as yet unratified"}} { + if err := w.WriteField(kv[0], kv[1]); err != nil { + t.Fatal(err) + } + } + part, err := w.CreateFormFile("file", "b.txt") + if err != nil { + t.Fatal(err) + } + if _, err := part.Write([]byte("text")); err != nil { + t.Fatal(err) + } + if err := w.Close(); err != nil { + t.Fatal(err) + } + if got := upload(t, h, w.FormDataContentType(), &buf); got.Code != http.StatusCreated { + t.Fatalf("status %d, want 201: an unknown field must not refuse the upload", got.Code) + } +} + +// A form field long enough to be a payload of its own is refused rather than truncated. +func TestAFormFieldThatIsAPayloadIsRefused(t *testing.T) { + h := v0ServerWith(t, Deps{Library: &fakeLibrary{}, Intake: &fakeIntake{}, + Upload: UploadLimits{MaxBytes: 8 << 20, Deadline: time.Minute}}) + var buf bytes.Buffer + w := multipart.NewWriter(&buf) + if err := w.WriteField("genre", strings.Repeat("g", maxIntakeField+1)); err != nil { + t.Fatal(err) + } + if err := w.Close(); err != nil { + t.Fatal(err) + } + if got := upload(t, h, w.FormDataContentType(), &buf); got.Code != http.StatusBadRequest { + t.Fatalf("status %d, want 400", got.Code) + } +} + +// The service's own refusals reach the wire as the contract's codes and never as engine or database +// text (§Problem). +func TestWhatTheIntakeRefusesBecomesAProductPhrase(t *testing.T) { + cases := []struct { + err error + want int + }{ + {fmt.Errorf("%w: the languages must be codes", books.ErrBadIntake), http.StatusBadRequest}, + {pgstore.ErrNoAccount, http.StatusNotFound}, + {errors.New("pgstore: connection refused: dial tcp 127.0.0.1:5432"), http.StatusInternalServerError}, + } + for _, tc := range cases { + h := v0ServerWith(t, Deps{Library: &fakeLibrary{}, Intake: &fakeIntake{err: tc.err}}) + ct, body := form(t, "b.txt", 16, true) + w := upload(t, h, ct, body) + if w.Code != tc.want { + t.Errorf("%v -> %d, want %d", tc.err, w.Code, tc.want) + } + if strings.Contains(w.Body.String(), "pgstore") || strings.Contains(w.Body.String(), "5432") { + t.Errorf("the response carries internals: %s", w.Body) + } + } +} + +// FP5-8(в) (acceptance): the cap allowed one part past its own number. The assertion is not the +// status — a form that simply runs out of parts is a 400 too, so a status-only test says nothing — +// but that the part BEHIND the cap is never handed to the intake. The file sits at exactly +// maxIntakeParts+1 here: with the cap off by one it is reached, accepted and streamed, which is the +// work the cap exists to refuse. +func TestAFormWithTooManyPartsIsRefusedAtTheCap(t *testing.T) { + in := &fakeIntake{book: pgstore.Book{ID: "bk_cap"}} + h := v0ServerWith(t, Deps{Library: &fakeLibrary{}, Intake: in, + Upload: UploadLimits{MaxBytes: 8 << 20, Deadline: time.Minute}}) + var buf bytes.Buffer + w := multipart.NewWriter(&buf) + for k, v := range map[string]string{"source_lang": "zh", "target_lang": "ru"} { + if err := w.WriteField(k, v); err != nil { + t.Fatal(err) + } + } + for i := range maxIntakeParts - 2 { + if err := w.WriteField(fmt.Sprintf("filler-%d", i), "x"); err != nil { + t.Fatal(err) + } + } + fw, err := w.CreateFormFile("file", "book.txt") + if err != nil { + t.Fatal(err) + } + if _, err := fw.Write(bytes.Repeat([]byte("x"), 1024)); err != nil { + t.Fatal(err) + } + if err := w.Close(); err != nil { + t.Fatal(err) + } + got := upload(t, h, w.FormDataContentType(), &buf) + if got.Code != http.StatusBadRequest { + t.Fatalf("status %d with %d parts, want 400 at the cap of %d", got.Code, maxIntakeParts+1, maxIntakeParts) + } + if in.read != 0 { + t.Fatalf("the file behind the cap was streamed anyway: %d bytes", in.read) + } +} + +// FP5-6 (acceptance): a body that outlives the route's own deadline is the client being slow, not +// this service being broken — and a 500 tells the client the opposite of the truth about retrying. +func TestAnUploadThatOutlivesItsDeadlineIsNotAnInternalError(t *testing.T) { + h := v0ServerWith(t, Deps{Library: &fakeLibrary{}, Intake: &fakeIntake{err: os.ErrDeadlineExceeded}}) + ct, body := form(t, "book.txt", 64, true) + w := upload(t, h, ct, body) + if w.Code == http.StatusInternalServerError { + t.Fatalf("an expired upload deadline answered 500: %s", w.Body) + } + if w.Code != http.StatusRequestTimeout { + t.Fatalf("status %d, want 408", w.Code) + } + if w.Header().Get("Content-Type") != "application/problem+json" { + t.Errorf("content-type %q", w.Header().Get("Content-Type")) + } +} diff --git a/platform/internal/httpapi/server.go b/platform/internal/httpapi/server.go index e6b28726..2ecafafa 100644 --- a/platform/internal/httpapi/server.go +++ b/platform/internal/httpapi/server.go @@ -49,6 +49,40 @@ type Deps struct { // service started without a database serves — the routes below all read one. Library Library Runs Runs + // Intake is the book upload. Nil where this deployment has nowhere to put a file or no engine to + // cut it with, and then POST /books is a guarded 404 like every other unbuilt route: an instance + // that accepted uploads it could only reject would be worse than one that says it takes none. + Intake Intake + // Upload bounds the one route that carries a file. + Upload UploadLimits + // Observe wraps every request in this deployment's telemetry. A plain middleware rather than an + // interface so this package stays free of a metrics library; nil is a service that measures + // nothing. + Observe func(http.Handler) http.Handler +} + +// UploadLimits is what the intake route is allowed to cost. +// +// Both numbers are the operator's, and both are refusals rather than sizings: a book is tens of +// megabytes and minutes of a domestic connection, and the defaults of the surface — a one-megabyte +// body and a thirty-second read — are correct for every OTHER route and would make this one +// impossible. +type UploadLimits struct { + // MaxBytes caps the request body of the intake route (http.MaxBytesReader, the second half of + // PD-2 and the whole of PD-72). + MaxBytes int64 + // Deadline is how long the body of ONE upload may take to arrive. It replaces the server's + // ReadTimeout for this route through http.ResponseController and is still a finite bound — the + // forbidden thing is CLEARING the deadline, which puts a half-fed request back to being + // unbounded (STACK_DECISIONS §12, register row PD-51). + Deadline time.Duration +} + +// DefaultUploadLimits are the intake's bounds when an operator chooses none: a book file large +// enough for the acceptance corpus (23 MB) with room around it, and long enough to arrive over a +// slow domestic uplink. +func DefaultUploadLimits() UploadLimits { + return UploadLimits{MaxBytes: 64 << 20, Deadline: 10 * time.Minute} } // New builds the handler. @@ -71,10 +105,16 @@ func New(d Deps) (http.Handler, error) { // // The body limit is per ROUTE, not a blanket outer layer: MaxBytesReader wrapping an already // wrapped body keeps the tighter limit, so an upload route could never raise its own above a - // shared default. The book upload registers guard(maxUpload, …) when it lands. + // shared default. The book upload is the route that needed it and registers guard(MaxBytes, …). guard := func(maxBody int64, h http.Handler) http.Handler { return LimitBody(maxBody)(csrf(d.Auth.Require(h))) } + if d.Upload.MaxBytes <= 0 || d.Upload.Deadline <= 0 { + // A zero here would be a route with no bound at all — MaxBytesReader treats a negative count + // as zero and a zero deadline as none — so it is filled rather than trusted. The intake is the + // one route where an unset limit is a hole and not an inconvenience. + d.Upload = DefaultUploadLimits() + } mux := http.NewServeMux() mux.Handle("GET /healthz", http.HandlerFunc(healthz)) @@ -94,8 +134,13 @@ func New(d Deps) (http.Handler, error) { mux.Handle(APIPrefix+"/", guard(DefaultMaxBody, ProblemHandler(http.StatusNotFound, "Object not found"))) // Recover sits INSIDE AccessLog: a panic converted to a 500 still produces a log line, whereas - // a panic unwinding past the logger produces none. - return reqid.Middleware(SecurityHeaders(d.HSTS)(AccessLog(d.Log)(Recover(d.Log)(mux)))), nil + // a panic unwinding past the logger produces none. Telemetry sits beside the log and for the same + // reason — it reads the route the mux matched and the status the recovery produced. + served := http.Handler(Recover(d.Log)(mux)) + if d.Observe != nil { + served = d.Observe(served) + } + return reqid.Middleware(SecurityHeaders(d.HSTS)(AccessLog(d.Log)(served))), nil } // healthz is liveness: the process is up and serving. It touches nothing, so a database outage diff --git a/platform/internal/httpapi/v0.go b/platform/internal/httpapi/v0.go index 5f128132..f2099ee0 100644 --- a/platform/internal/httpapi/v0.go +++ b/platform/internal/httpapi/v0.go @@ -4,12 +4,17 @@ import ( "context" "encoding/json" "errors" + "fmt" + "io" "log/slog" "net/http" + "os" "strconv" + "strings" "time" "textmachine/platform/internal/auth" + "textmachine/platform/internal/books" "textmachine/platform/internal/pgstore" "textmachine/platform/internal/pricing" "textmachine/platform/internal/runner" @@ -28,6 +33,14 @@ type Library interface { type Runs interface { Bounds(ctx context.Context, userID, bookID string) (pricing.Bounds, error) Start(ctx context.Context, in runs.StartRequest) (pgstore.Run, error) + Stop(ctx context.Context, userID, runID string) (pgstore.Run, error) + Resume(ctx context.Context, userID, runID string) (pgstore.Run, error) +} + +// Intake is the book upload, as the HTTP layer needs to see it. The service takes a READER: the +// file is streamed to its place on disk and is never held in this process's memory. +type Intake interface { + Accept(ctx context.Context, in books.Intake) (pgstore.Book, error) } // contractRoutes registers the /v0 surface. @@ -36,7 +49,7 @@ type Runs interface { // ops endpoints: a nested mux behind StripPrefix hands the inner handler a copy of the request and // the pattern never comes back, which would put raw paths carrying book ids into the access log. func contractRoutes(mux *http.ServeMux, d Deps, guard func(int64, http.Handler) http.Handler) { - h := &v0{lib: d.Library, runs: d.Runs, log: d.Log} + h := &v0{lib: d.Library, runs: d.Runs, intake: d.Intake, upload: d.Upload, log: d.Log} // The READS need a read model and nothing else. An instance with no engine binary is a read // replica, not a broken one, and mounting nothing unless it could ALSO start runs made it answer // 404 to a library it was holding — while its own boot line said it was serving one. @@ -50,13 +63,26 @@ func contractRoutes(mux *http.ServeMux, d Deps, guard func(int64, http.Handler) if d.Runs != nil { mux.Handle("GET "+APIPrefix+"/books/{bookId}/run-options", guard(DefaultMaxBody, http.HandlerFunc(h.runOptions))) mux.Handle("POST "+APIPrefix+"/books/{bookId}/runs", guard(DefaultMaxBody, http.HandlerFunc(h.startRun))) + mux.Handle("POST "+APIPrefix+"/runs/{runId}/stop", guard(DefaultMaxBody, http.HandlerFunc(h.stopRun))) + mux.Handle("POST "+APIPrefix+"/runs/{runId}/resume", guard(DefaultMaxBody, http.HandlerFunc(h.resumeRun))) + } + // Intake needs somewhere to put a file and an engine to cut it with; an instance with neither is + // the read replica again, and its /books stays a guarded 404 for POST. + // + // This is the ONE route whose body limit is not the default, and it is why the limit is a + // per-route argument in the first place (PD-35/PD-72): a book is tens of megabytes and every + // other route on this surface carries a few kilobytes of JSON. + if d.Intake != nil { + mux.Handle("POST "+APIPrefix+"/books", guard(d.Upload.MaxBytes, http.HandlerFunc(h.createBook))) } } type v0 struct { - lib Library - runs Runs - log *slog.Logger + lib Library + runs Runs + intake Intake + upload UploadLimits + log *slog.Logger } // The wire shapes below are the contract's, field for field (openapi 0.2.0). They are written out @@ -169,14 +195,13 @@ func (h *v0) getBook(w http.ResponseWriter, r *http.Request) { h.fail(w, r, err) return } - // ONE counter per book, and it is the book's (contract §Revision: "every book-scoped read and the - // id of every stream frame of that book's run carry the same number"). Reading it off the run row - // made the card lag: a unit_done bumps the book and the chapter and not the run, so a client that - // had applied frame id=2 got revision 0 back and, obeying the contract, dropped the read. + // ONE counter per book (contract §Revision: "every book-scoped read and the id of every stream + // frame of that book's run carry the same number"). The store answers a run's revision from its + // BOOK on every path that hands one out — the card here, and the stop and resume handles — so this + // layer projects what it was given rather than carrying a second copy of the rule. out := wireBookDetail{Revision: book.Revision, Book: projectBook(book)} if run != nil { wr := projectRun(*run) - wr.Revision = book.Revision out.Run = &wr } writeJSON(w, r, http.StatusOK, out, h.log) @@ -238,6 +263,169 @@ func (h *v0) startRun(w http.ResponseWriter, r *http.Request) { writeJSON(w, r, http.StatusAccepted, projectRun(run), h.log) } +// stopRun is the product "stop" action (contract §stopRun). The engine stops gracefully on a signal +// and the reconciler turns the exit into a status; what this call does is record that the stop was +// OURS — which is the only way the exit can afterwards be told from a crash (register row PD-152). +func (h *v0) stopRun(w http.ResponseWriter, r *http.Request) { + user, ok := principal(w, r) + if !ok { + return + } + run, err := h.runs.Stop(r.Context(), user, r.PathValue("runId")) + if err != nil { + h.fail(w, r, err) + return + } + writeJSON(w, r, http.StatusAccepted, projectRun(run), h.log) +} + +// resumeRun continues a run that was stopped (contract §resumeRun). +func (h *v0) resumeRun(w http.ResponseWriter, r *http.Request) { + user, ok := principal(w, r) + if !ok { + return + } + run, err := h.runs.Resume(r.Context(), user, r.PathValue("runId")) + if err != nil { + h.fail(w, r, err) + return + } + writeJSON(w, r, http.StatusAccepted, projectRun(run), h.log) +} + +// maxIntakeField bounds one text field of the intake form, and maxIntakeParts how many parts may +// arrive before the file. Neither is the contract's business: they are what keeps a form with a +// megabyte-long genre, or a hundred thousand empty parts, from being work this process does. +const ( + maxIntakeField = 1 << 10 + maxIntakeParts = 16 +) + +// createBook receives a book (contract §createBook). +// +// It is the only route on this surface that streams. The body is read part by part through +// r.MultipartReader and the file is handed to the intake as a READER, so a 60 MB upload costs a +// buffer and not 60 MB of this process — which is what ParseMultipartForm, the reflex alternative, +// would have cost in memory or in a second copy through a temporary file. +// +// ⚠ The FILE PART MUST COME LAST. A streaming reader hands parts over in wire order, and the book's +// row — which is what makes an upload visible while it arrives and findable when it dies halfway — +// cannot be written before the languages that row requires. The same constraint is how S3's own +// browser upload is specified ("the file or content must be the last field in the form"). It is a +// wire rule the contract does not yet state; raised to the contract's owner rather than resolved +// here (zone journal, P5). +func (h *v0) createBook(w http.ResponseWriter, r *http.Request) { + user, ok := principal(w, r) + if !ok { + return + } + // A book is minutes of upload on a domestic connection, and the server's ReadTimeout — the whole + // of what bounds a half-fed request (PD-2) — covers the entire body. It is EXTENDED here for this + // route and never cleared: net/http's own documentation points at a per-request deadline for + // exactly this case, and clearing one instead is the mistake that re-created PD-2 (STACK §12). + if err := http.NewResponseController(w).SetReadDeadline(time.Now().Add(h.upload.Deadline)); err != nil { + // Not fatal: a ResponseWriter that cannot carry a deadline is a test double, and the real + // server's own timeout then still applies. + h.log.DebugContext(r.Context(), "upload deadline not extended", "err", err) + } + parts, err := r.MultipartReader() + if err != nil { + WriteProblem(w, http.StatusBadRequest, "Request could not be read", "") + return + } + in := books.Intake{UserID: user} + for n := 0; ; n++ { + if n >= maxIntakeParts { + WriteProblem(w, http.StatusBadRequest, "Request could not be read", "") + return + } + part, err := parts.NextPart() + if errors.Is(err, io.EOF) { + // Every field and no file: the form the contract requires was not sent. + WriteProblem(w, http.StatusBadRequest, "The upload is incomplete", "") + return + } + if err != nil { + h.uploadFailed(w, r, err) + return + } + if part.FormName() == "file" { + if in.SourceLang == "" || in.TargetLang == "" { + // Either the languages were not sent, or they were sent AFTER the file — and the + // second is indistinguishable from the first to a reader that streams. + WriteProblem(w, http.StatusBadRequest, "The upload is incomplete", "") + return + } + in.Filename, in.File = part.FileName(), part + break + } + value, err := readField(part) + if err != nil { + h.uploadFailed(w, r, err) + return + } + switch part.FormName() { + case "source_lang": + in.SourceLang = value + case "target_lang": + in.TargetLang = value + case "genre": + in.Genre = value + } + // An unknown field is IGNORED rather than refused, for the same reason an unknown JSON + // property is: inside 0.x a minor bump is where optional fields appear, and a server that + // rejected the whole upload would break a client generated against a later contract. + } + book, err := h.intake.Accept(r.Context(), in) + if err != nil { + h.uploadFailed(w, r, err) + return + } + writeJSON(w, r, http.StatusCreated, projectBook(book), h.log) +} + +// readField reads one text field of the form, refusing one that is too long rather than silently +// keeping its first kilobyte. +func readField(part io.Reader) (string, error) { + b, err := io.ReadAll(io.LimitReader(part, maxIntakeField+1)) + if err != nil { + return "", err + } + if len(b) > maxIntakeField { + return "", fmt.Errorf("%w: a form field is too long", books.ErrBadIntake) + } + return strings.TrimSpace(string(b)), nil +} + +// uploadFailed maps the ways an upload ends badly. +// +// The size refusal is the reason PD-72 was opened and is closed with this route: MaxBytesReader is +// what enforces the cap, and *http.MaxBytesError is how it says so — the same error whether the +// client announced the size or simply kept sending. +func (h *v0) uploadFailed(w http.ResponseWriter, r *http.Request, err error) { + var tooLarge *http.MaxBytesError + switch { + case errors.As(err, &tooLarge): + WriteProblem(w, http.StatusRequestEntityTooLarge, "The file is larger than this service accepts", "") + case errors.Is(err, os.ErrDeadlineExceeded): + // The body did not finish inside the route's own deadline. 408 is what RFC 9110 §15.5.9 calls + // exactly this ("the server did not receive a complete request message within the time it was + // prepared to wait"), and it tells a client that RETRYING is the remedy — which a 500 does not. + // ⚠ Outside the codes the spec enumerates for this operation; named in the question package of + // PD-180 rather than chosen silently. + h.log.InfoContext(r.Context(), "upload did not finish inside the route's deadline") + WriteProblem(w, http.StatusRequestTimeout, "The upload did not finish in time", "") + case errors.Is(err, books.ErrBadIntake): + WriteProblem(w, http.StatusBadRequest, "The upload is incomplete", "") + case errors.Is(err, context.Canceled), errors.Is(err, io.ErrUnexpectedEOF), errors.Is(err, io.EOF): + // The client went away mid-body. Nothing reaches it; the line is what an operator sees. + h.log.InfoContext(r.Context(), "upload did not finish", "err", err) + WriteProblem(w, http.StatusBadRequest, "The upload is incomplete", "") + default: + h.fail(w, r, err) + } +} + func (h *v0) usage(w http.ResponseWriter, r *http.Request) { user, ok := principal(w, r) if !ok { @@ -331,12 +519,20 @@ func pageLimit(w http.ResponseWriter, r *http.Request) (int, bool) { // the sentence a reader gets. func (h *v0) fail(w http.ResponseWriter, r *http.Request, err error) { switch { - case errors.Is(err, pgstore.ErrNoBook), errors.Is(err, pgstore.ErrNoAccount): + case errors.Is(err, pgstore.ErrNoBook), errors.Is(err, pgstore.ErrNoAccount), errors.Is(err, pgstore.ErrNoRun): WriteProblem(w, http.StatusNotFound, "Object not found", "") - case errors.Is(err, pgstore.ErrBadCursor): + case errors.Is(err, pgstore.ErrBadCursor), errors.Is(err, books.ErrBadIntake): WriteProblem(w, http.StatusBadRequest, "Request could not be read", "") case errors.Is(err, pgstore.ErrRunInFlight): WriteProblem(w, http.StatusConflict, "This book is already being translated", "") + case errors.Is(err, runs.ErrBookNotReady): + // The book is still being received or was rejected. 409 and not 404: the book exists and the + // client can see it — what it cannot do is start a translation of it yet. + WriteProblem(w, http.StatusConflict, "This book is not ready to be translated", "") + case errors.Is(err, runs.ErrNotStoppable): + WriteProblem(w, http.StatusConflict, "This run is not running", "") + case errors.Is(err, runs.ErrNotResumable): + WriteProblem(w, http.StatusConflict, "This translation cannot be continued yet", "") case errors.Is(err, runs.ErrCeilingOutOfBounds), errors.Is(err, pgstore.ErrInsufficientCredit): // 409 and not 400: the request was legal when the bounds were read, and a hold taken for // another book between that read and this call is what moved them (contract §startRun). diff --git a/platform/internal/httpapi/v0_test.go b/platform/internal/httpapi/v0_test.go index 3ab7a835..98a36038 100644 --- a/platform/internal/httpapi/v0_test.go +++ b/platform/internal/httpapi/v0_test.go @@ -47,6 +47,10 @@ type fakeRuns struct { run pgstore.Run err error got runs.StartRequest + // stopped and resumed record which run each control handle was called for, so a test can assert + // that the path parameter reaches the service rather than only that the status code is right. + stopped string + resumed string } func (f *fakeRuns) Bounds(context.Context, string, string) (pricing.Bounds, error) { @@ -58,17 +62,32 @@ func (f *fakeRuns) Start(_ context.Context, in runs.StartRequest) (pgstore.Run, return f.run, f.err } +func (f *fakeRuns) Stop(_ context.Context, _, runID string) (pgstore.Run, error) { + f.stopped = runID + return f.run, f.err +} + +func (f *fakeRuns) Resume(_ context.Context, _, runID string) (pgstore.Run, error) { + f.resumed = runID + return f.run, f.err +} + func v0Server(t *testing.T, lib Library, rn Runs) http.Handler { t.Helper() - h, err := New(Deps{ - Log: slog.New(slog.NewJSONHandler(&bytes.Buffer{}, nil)), - Auth: &auth.Authenticator{ - Sessions: liveSessions{}, IdleTTL: time.Hour, - Deny: ProblemHandler(http.StatusUnauthorized, "Session missing or invalid"), - }, - Library: lib, - Runs: rn, - }) + return v0ServerWith(t, Deps{Library: lib, Runs: rn}) +} + +// v0ServerWith builds the real handler chain with whatever this test wants mounted on it. Every +// wire test goes through New — not through a handler in isolation — because the guard, the body cap +// and the security headers are part of the surface being asserted. +func v0ServerWith(t *testing.T, d Deps) http.Handler { + t.Helper() + d.Log = slog.New(slog.NewJSONHandler(&bytes.Buffer{}, nil)) + d.Auth = &auth.Authenticator{ + Sessions: liveSessions{}, IdleTTL: time.Hour, + Deny: ProblemHandler(http.StatusUnauthorized, "Session missing or invalid"), + } + h, err := New(d) if err != nil { t.Fatal(err) } @@ -435,7 +454,16 @@ func TestAnUnknownRequestPropertyIsIgnoredRatherThanRefused(t *testing.T) { // ONE counter per book. Reading the card's revision off the RUN row made it lag: a unit_done bumps // the book and its chapter and not the run, so a client that had applied stream frame id=2 got 0 // back and — obeying the contract — dropped the read. -func TestTheCardsRevisionIsTheBooksAndNotTheRuns(t *testing.T) { +// ONE counter per book on the wire. ⚠ The RULE moved in P5 and this test moved with it: the store now +// answers a run's revision from its book on every path that hands one out — the card here and the +// stop/resume handles, which have no book to override from — so the pin on the rule itself lives in +// `pgstore.TestEveryRunTheStoreHandsOutCarriesItsBooksRevision` and `runs.TestEveryRunCarryingAnswerUsesTheBooksRevision`. +// What THIS layer owes is that it projects the number it was given and invents nothing, which is +// what a fake store can prove and the rule itself no longer is. +func TestTheCardProjectsTheRevisionTheStoreGaveAndInventsNone(t *testing.T) { + // The two numbers are DIFFERENT on purpose: equal ones cannot tell "projected what it was given" + // from "overrode it with the book's", which is exactly the discriminating power an earlier + // revision of this test lost when the fixture was levelled (found by acceptance). lib := &fakeLibrary{ book: pgstore.Book{ID: "bk_1", Revision: 41}, run: &pgstore.Run{ID: "run_1", Status: "translating", Revision: 12}, @@ -445,8 +473,9 @@ func TestTheCardsRevisionIsTheBooksAndNotTheRuns(t *testing.T) { t.Errorf("BookDetail.revision = %v, want the book's 41", got["revision"]) } run, _ := got["run"].(map[string]any) - if run["revision"] != float64(41) { - t.Errorf("Run.revision = %v, want the same book counter", run["revision"]) + if run["revision"] != float64(12) { + t.Errorf("Run.revision = %v, want the 12 the store handed over: this layer projects, it does not decide", + run["revision"]) } } diff --git a/platform/internal/ingest/manifest.go b/platform/internal/ingest/manifest.go new file mode 100644 index 00000000..45a43d7f --- /dev/null +++ b/platform/internal/ingest/manifest.go @@ -0,0 +1,54 @@ +package ingest + +import ( + "encoding/hex" + "encoding/json" + "fmt" +) + +// Manifest is the ALLOWLISTED summary of the engine's chapter manifest (`tmctl manifest --json`, +// unified backlog row 100, form ratified with D39.122 as `tm-manifest-v2`). +// +// What is deliberately absent is most of the document: the per-chapter and per-unit arrays carry the +// engine's stable ids, its dense ordinals and its rendered headings, and none of them has a reader +// on this side yet — the platform's chapter tree is written by the materializer from the stream, not +// from here. Taking them now would store engine vocabulary nobody asks for and would have to be +// maintained against a document that re-cuts itself. +// +// What IS taken is what intake decides with: how many chapters the book has (the ceiling scale is +// built on it), and the two identifiers that say WHICH cut of WHICH bytes produced that number. +type Manifest struct { + // Version is `manifest_version`. Recorded and logged rather than gated: the fields below are + // counts and identities whose meaning is stable, and the engine already refuses the version it + // cannot produce. A shape this platform truly could not read arrives as a zero chapter count, + // which is a loud failure and not a silent one. + Version string `json:"manifest_version"` + ChaptersTotal int `json:"chapters_total"` + UnitsTotal int `json:"units_total"` + // SourceSHA256 is the digest of the ingested source, hex. It answers "is the file on disk still + // the one that was cut" without the platform reading the file again. + SourceSHA256 string `json:"source_sha256"` + SourceBytes int64 `json:"source_bytes"` + // ChunkerVersion is one of the inputs of the manifest's validity key: a change re-numbers + // chapters, so a stored tree is only comparable within one of these (register row PD-166). + ChunkerVersion string `json:"chunker_version"` +} + +// SourceSHA256Bytes is the digest as the column stores it. An unparsable value is stored as nothing +// rather than as garbage: the field is evidence, and evidence that cannot be decoded is absence. +func (m Manifest) SourceSHA256Bytes() []byte { + b, err := hex.DecodeString(m.SourceSHA256) + if err != nil { + return nil + } + return b +} + +// DecodeManifest parses a manifest document. +func DecodeManifest(b []byte) (Manifest, error) { + var m Manifest + if err := json.Unmarshal(b, &m); err != nil { + return Manifest{}, fmt.Errorf("ingest: decode manifest: %w", err) + } + return m, nil +} diff --git a/platform/internal/jobs/jobs.go b/platform/internal/jobs/jobs.go new file mode 100644 index 00000000..8a5c0372 --- /dev/null +++ b/platform/internal/jobs/jobs.go @@ -0,0 +1,183 @@ +// Package jobs is the platform's queue: one River client, and the job kinds the control plane hands +// to itself. +// +// It knows nothing about what a job MEANS. Both workers below call one method of one service and +// return, because the work itself belongs to the package that owns the object — the run lifecycle to +// `runs`, the intake to `books` — and a queue that also owned the work would be a second place where +// a run can be started. +// +// One client for both kinds, deliberately: River runs its own maintenance (scheduler, rescuer, +// cleaner) per client, and a second client in one process would be a second set of those against the +// same tables for no gain. +package jobs + +import ( + "context" + "fmt" + "log/slog" + "time" + + "github.com/jackc/pgx/v5/pgxpool" + "github.com/riverqueue/river" + "github.com/riverqueue/river/riverdriver/riverpgxv5" + + "textmachine/platform/internal/pgstore" +) + +// Spawner starts the engine for a run that has already been admitted. +type Spawner interface { + Spawn(ctx context.Context, runID string) error +} + +// Parser turns a received book file into a chapter tree. +type Parser interface { + Parse(ctx context.Context, bookID string) error +} + +// SpawnArgs is one queued permission to start a run. +// +// The job carries an id and nothing else. Everything about the run — the book, the ceiling, the +// binary it is pinned to — is in Postgres, and a job that carried its own copy would be a second +// answer that goes stale the moment the run is restarted with a smaller budget. +type SpawnArgs struct { + RunID string `json:"run_id"` +} + +// Kind is River's name for this job type. +func (SpawnArgs) Kind() string { return "tm_spawn_run" } + +// InsertOpts pins the queue-level policy. +// +// MaxAttempts is 1 on purpose, and it is the opposite of the usual queue reflex. A retry here does +// not repeat lost work: the run row already exists and holds the account's money, and the thing that +// would be repeated is spawning an engine. What recovers a run whose spawn failed is the reconciler, +// which reads the world instead of assuming the job's view of it — and which is the ONLY component +// that can tell "the unit never started" from "the unit is running and this platform was restarted". +func (SpawnArgs) InsertOpts() river.InsertOpts { + return river.InsertOpts{MaxAttempts: 1, Queue: river.QueueDefault} +} + +// ParseArgs is one queued book waiting to be cut into chapters. +type ParseArgs struct { + BookID string `json:"book_id"` +} + +// Kind is River's name for this job type. +func (ParseArgs) Kind() string { return "tm_parse_book" } + +// InsertOpts pins the queue-level policy. +// +// MaxAttempts is 1 for the same reason as the spawn's, arrived at from the other direction: a parse +// is free and idempotent, so a retry would be harmless — but it would also be a SECOND recovery +// mechanism next to the intake sweep, which already re-claims a parse whose process is gone and +// which is the only one that can bound how many times a broken host re-runs the engine. +func (ParseArgs) InsertOpts() river.InsertOpts { + return river.InsertOpts{MaxAttempts: 1, Queue: river.QueueDefault} +} + +type spawnWorker struct { + river.WorkerDefaults[SpawnArgs] + svc Spawner +} + +func (w *spawnWorker) Work(ctx context.Context, job *river.Job[SpawnArgs]) error { + return w.svc.Spawn(ctx, job.Args.RunID) +} + +type parseWorker struct { + river.WorkerDefaults[ParseArgs] + svc Parser +} + +func (w *parseWorker) Work(ctx context.Context, job *river.Job[ParseArgs]) error { + return w.svc.Parse(ctx, job.Args.BookID) +} + +// JobTimeout bounds ONE job. Generous because the work is an engine call over a whole book, and +// finite because a worker that never returns is a worker slot that never comes back. +// +// Exported because it is half of a pair: the intake's claim grace must OUTLIVE it, or a backstop +// sweep takes the claim off a parse this queue is still running. The other half is written as this +// constant plus a margin. +const JobTimeout = 15 * time.Minute + +// Queue is the River client, wired to the services that do the work. +type Queue struct { + client *river.Client[pgstore.Tx] +} + +// New builds the queue and its worker pool. +// +// Concurrency is deliberately small: a spawn worker's whole job is to create a transient unit, and +// the runs themselves are bounded by their own cgroups and by the one-live-run-per-book index, not +// by how many workers exist. +func New(pool *pgxpool.Pool, spawner Spawner, parser Parser, log *slog.Logger, workers int) (*Queue, error) { + if workers <= 0 { + workers = 4 + } + w := river.NewWorkers() + if spawner != nil { + if err := river.AddWorkerSafely(w, &spawnWorker{svc: spawner}); err != nil { + return nil, fmt.Errorf("jobs: register spawn worker: %w", err) + } + } + if parser != nil { + if err := river.AddWorkerSafely(w, &parseWorker{svc: parser}); err != nil { + return nil, fmt.Errorf("jobs: register parse worker: %w", err) + } + } + c, err := river.NewClient(riverpgxv5.New(pool), &river.Config{ + Logger: log, + Workers: w, + Queues: map[string]river.QueueConfig{river.QueueDefault: {MaxWorkers: workers}}, + // CHOSEN, not inherited. River's own default is one minute, and both jobs here call the engine + // against a whole book: a parse ingests and cuts a source of up to the intake's limit, and a + // spawn reads the book's meter first (seconds of CPU on a 23 MB book, unified backlog row 100). + // A minute would kill those, and a killed process is indistinguishable from a host that cannot + // run the engine — so the intake would count a deployment fault against a book whose only sin + // was being large. + JobTimeout: JobTimeout, + }) + if err != nil { + return nil, fmt.Errorf("jobs: river client: %w", err) + } + return &Queue{client: c}, nil +} + +// EnqueueRun inserts the job in the CALLER's transaction, so the run row, its hold and its queue +// entry commit together or not at all. +func (q *Queue) EnqueueRun(ctx context.Context, tx pgstore.Tx, runID string) error { + if _, err := q.client.InsertTx(ctx, tx, SpawnArgs{RunID: runID}, nil); err != nil { + return fmt.Errorf("jobs: enqueue run %s: %w", runID, err) + } + return nil +} + +// EnqueueRunNow inserts the job on its own, outside any transaction. +// +// The caller is a RESUME: the run it names has already been re-opened and its money already held, in +// a transaction that is committed by the time this runs. A failed insert therefore costs one sweep +// interval and nothing else — the reconciler finds an attempt with no unit and spawns it — which is +// exactly what a lost queue entry costs on the admission path too. +func (q *Queue) EnqueueRunNow(ctx context.Context, runID string) error { + if _, err := q.client.Insert(ctx, SpawnArgs{RunID: runID}, nil); err != nil { + return fmt.Errorf("jobs: enqueue run %s: %w", runID, err) + } + return nil +} + +// EnqueueParse inserts the job in the CALLER's transaction, so a book that says it is being parsed +// and the job that parses it commit together. +func (q *Queue) EnqueueParse(ctx context.Context, tx pgstore.Tx, bookID string) error { + if _, err := q.client.InsertTx(ctx, tx, ParseArgs{BookID: bookID}, nil); err != nil { + return fmt.Errorf("jobs: enqueue parse %s: %w", bookID, err) + } + return nil +} + +// Start begins working jobs. +func (q *Queue) Start(ctx context.Context) error { return q.client.Start(ctx) } + +// Stop drains the workers. The runs themselves are untouched: they are transient units, not +// children, and outliving this process is what they are for. +func (q *Queue) Stop(ctx context.Context) error { return q.client.Stop(ctx) } diff --git a/platform/internal/jobs/jobs_test.go b/platform/internal/jobs/jobs_test.go new file mode 100644 index 00000000..0fb1204b --- /dev/null +++ b/platform/internal/jobs/jobs_test.go @@ -0,0 +1,37 @@ +package jobs + +import ( + "testing" + + "github.com/riverqueue/river" +) + +// Two properties of the queue that are one line each to write and expensive to get wrong, because +// both fail SILENTLY: a renamed kind orphans every job already in the table (River dispatches by +// that string, and a job whose kind no worker claims simply waits forever), and a MaxAttempts above +// one turns "start a run" into "start it again" — the reflex this zone deliberately refuses, since +// a retry here spawns a SECOND engine rather than finishing lost work. +func TestTheQueuesKindsAndRetryPolicyAreWhatTheRestOfTheSystemAssumes(t *testing.T) { + cases := []struct { + name string + kind string + opts river.InsertOpts + }{ + {"spawn", SpawnArgs{}.Kind(), SpawnArgs{}.InsertOpts()}, + {"parse", ParseArgs{}.Kind(), ParseArgs{}.InsertOpts()}, + } + want := map[string]string{"spawn": "tm_spawn_run", "parse": "tm_parse_book"} + for _, tc := range cases { + if tc.kind != want[tc.name] { + t.Errorf("%s kind is %q, want %q — a renamed kind orphans the jobs already queued under the old one", + tc.name, tc.kind, want[tc.name]) + } + if tc.opts.MaxAttempts != 1 { + t.Errorf("%s MaxAttempts is %d, want 1: recovery is the reconciler's job, not the queue's", + tc.name, tc.opts.MaxAttempts) + } + if tc.opts.Queue != river.QueueDefault { + t.Errorf("%s queue is %q, want the default one the client works", tc.name, tc.opts.Queue) + } + } +} diff --git a/platform/internal/metrics/metrics.go b/platform/internal/metrics/metrics.go new file mode 100644 index 00000000..2542b3ca --- /dev/null +++ b/platform/internal/metrics/metrics.go @@ -0,0 +1,231 @@ +// Package metrics is the platform's telemetry: the numbers an operator needs to see that the +// control plane is doing its job, in the form the ecosystem scrapes. +// +// # Why a Prometheus client and not expvar +// +// stdlib `expvar` is the alternative the zone's own norm points at first ("stdlib before a library", +// ENGINEERING_STANDARDS §1), and it does not carry this job: it has no labels, so "requests by route +// and status" cannot be expressed at all; no histograms, so a latency question is answerable only as +// an average, which is the one statistic that hides the tail; and its JSON is not a format any +// scraper reads without a translator. What it would save is a dependency, and what it would cost is +// writing the missing three by hand — which is the self-written path the same norm rules out. +// +// `prometheus/client_golang` v1.24.1 (released 24.07.2026, pin verified live) is the de-facto +// standard exposition, and the OpenTelemetry alternative is heavier for what this deployment is: a +// collector process to run, an export protocol to configure, and a scrape endpoint at the end of it +// anyway. The baseline this axis is measured against — the external one PD-115 asks for — is +// Prometheus's own naming practices (base units, `_total` on counters, no units in labels) and the +// four golden signals for WHAT to expose. +// +// # Cardinality +// +// Labels carry the route PATTERN, never the path: a raw path carries book and run ids, which is a +// user's library in an operator's index (PD-3) and an unbounded number of time series besides. +package metrics + +import ( + "net/http" + "strconv" + "time" + + "github.com/prometheus/client_golang/prometheus" + "github.com/prometheus/client_golang/prometheus/collectors" + "github.com/prometheus/client_golang/prometheus/promhttp" +) + +// namespace prefixes every series of this service. +const namespace = "tm_platform" + +// Metrics is the registry and the instruments of one process. +type Metrics struct { + registry *prometheus.Registry + + // The runner's state, refreshed by the sweep. Gauges rather than collectors that query on + // scrape: the sweep already reads the database on a timer, and a scrape that ran its own queries + // would let anyone with access to the endpoint set the load on the control plane's database. + queueDepth prometheus.Gauge + oldestHold prometheus.Gauge + quarantined prometheus.Gauge + liveRuns prometheus.Gauge + tailerLag prometheus.Gauge + booksInIntake *prometheus.GaugeVec + sweepDuration *prometheus.HistogramVec + sweepUnfinished *prometheus.CounterVec + + requests *prometheus.CounterVec + latency *prometheus.HistogramVec +} + +// New builds the instruments on a registry of this process's own. +// +// Its own, and not the package-global default: a global registry is shared with every library that +// ever registers into it, and a duplicate registration there is a panic at init in a process whose +// job is to stay up. +func New() *Metrics { + m := &Metrics{registry: prometheus.NewRegistry()} + m.queueDepth = prometheus.NewGauge(prometheus.GaugeOpts{ + Namespace: namespace, Name: "queue_depth", + Help: "Jobs in the queue that have not finished (pending, available, running, scheduled or retryable).", + }) + m.oldestHold = prometheus.NewGauge(prometheus.GaugeOpts{ + Namespace: namespace, Name: "oldest_open_hold_seconds", + Help: "Age of the oldest reservation still open. A hold outlives its run only when a settlement could not be made.", + }) + m.quarantined = prometheus.NewGauge(prometheus.GaugeOpts{ + Namespace: namespace, Name: "quarantined_attempts", + Help: "Live attempts whose journal is no longer being materialized. Such a run keeps going and keeps spending.", + }) + m.liveRuns = prometheus.NewGauge(prometheus.GaugeOpts{ + Namespace: namespace, Name: "live_runs", + Help: "Runs the database believes are still going.", + }) + m.tailerLag = prometheus.NewGauge(prometheus.GaugeOpts{ + Namespace: namespace, Name: "tailer_lag_bytes", + Help: "Largest number of bytes any live run's journal holds beyond the platform's cursor.", + }) + m.booksInIntake = prometheus.NewGaugeVec(prometheus.GaugeOpts{ + Namespace: namespace, Name: "books_in_intake", + Help: "Books that have not finished intake, by status.", + }, []string{"status"}) + m.sweepDuration = prometheus.NewHistogramVec(prometheus.HistogramOpts{ + Namespace: namespace, Name: "sweep_duration_seconds", + Help: "How long one pass of a sweep took.", + Buckets: sweepBuckets, + }, []string{"sweep"}) + m.sweepUnfinished = prometheus.NewCounterVec(prometheus.CounterOpts{ + Namespace: namespace, Name: "sweep_unfinished_total", + Help: "Passes that ran out of their time budget with work left. A rising count is starvation: the tail of the list is never reached (register row PD-169).", + }, []string{"sweep"}) + m.requests = prometheus.NewCounterVec(prometheus.CounterOpts{ + Namespace: namespace, Name: "http_requests_total", + Help: "Requests served, by route pattern, method and status.", + }, []string{"route", "method", "code"}) + m.latency = prometheus.NewHistogramVec(prometheus.HistogramOpts{ + Namespace: namespace, Name: "http_request_duration_seconds", + Help: "Time to serve a request, by route pattern and method.", + Buckets: latencyBuckets, + }, []string{"route", "method"}) + m.registry.MustRegister(m.queueDepth, m.oldestHold, m.quarantined, m.liveRuns, m.tailerLag, + m.booksInIntake, m.sweepDuration, m.sweepUnfinished, m.requests, m.latency, + // The runtime and the process itself: memory, goroutines, file descriptors, CPU. They are + // what answers "is this instance healthy" when none of the numbers above has moved. + collectors.NewGoCollector(), collectors.NewProcessCollector(collectors.ProcessCollectorOpts{})) + return m +} + +// sweepBuckets span one tick to well past the sweep's own budget: the question they answer is "is a +// pass still finishing", and the interesting shape is entirely in the tail. +var sweepBuckets = []float64{0.05, 0.1, 0.25, 0.5, 1, 2.5, 5, 10, 30, 60, 120, 300} + +// latencyBuckets carry both kinds of request this service serves: a read that must be milliseconds +// and an upload that is legitimately minutes. +var latencyBuckets = []float64{0.005, 0.01, 0.025, 0.05, 0.1, 0.25, 0.5, 1, 2.5, 5, 10, 30, 60, 300} + +// Runner is what the reconciler's sweep observed about the world. Written as one struct because the +// numbers are read together, in one query, and a set of them from different moments would describe a +// state that never existed. +type Runner struct { + QueueDepth int64 + OldestHoldSeconds float64 + QuarantinedAttempts int64 + LiveRuns int64 + BooksUploading int64 + BooksParsing int64 +} + +// ObserveRunner publishes one reading of the runner's state. +func (m *Metrics) ObserveRunner(r Runner) { + if m == nil { + return + } + m.queueDepth.Set(float64(r.QueueDepth)) + m.oldestHold.Set(r.OldestHoldSeconds) + m.quarantined.Set(float64(r.QuarantinedAttempts)) + m.liveRuns.Set(float64(r.LiveRuns)) + m.booksInIntake.WithLabelValues("uploading").Set(float64(r.BooksUploading)) + m.booksInIntake.WithLabelValues("parsing").Set(float64(r.BooksParsing)) +} + +// ObserveTailerLag publishes how far the furthest-behind live run's cursor is from the end of its +// journal, in bytes. +func (m *Metrics) ObserveTailerLag(bytes int64) { + if m == nil { + return + } + m.tailerLag.Set(float64(bytes)) +} + +// ObserveSweep records one pass. unfinished means the pass ran out of time with work left, which is +// the starvation the per-run budget bounds and this counter makes visible. +func (m *Metrics) ObserveSweep(name string, d time.Duration, unfinished bool) { + if m == nil { + return + } + m.sweepDuration.WithLabelValues(name).Observe(d.Seconds()) + if unfinished { + m.sweepUnfinished.WithLabelValues(name).Inc() + } +} + +// Handler serves the exposition endpoint. +func (m *Metrics) Handler() http.Handler { + return promhttp.HandlerFor(m.registry, promhttp.HandlerOpts{}) +} + +// Middleware counts and times every request. +// +// It sits where the access log sits — around the mux, so `Request.Pattern` is filled by the time it +// reads it, and outside the panic recovery, so a request that panicked is counted as the 500 it +// became. +func (m *Metrics) Middleware() func(http.Handler) http.Handler { + return func(next http.Handler) http.Handler { + if m == nil { + return next + } + return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + start := time.Now() + rec := &recorder{ResponseWriter: w, status: http.StatusOK} + next.ServeHTTP(rec, r) + route := r.Pattern + if route == "" { + // One series for everything unmatched, never the path: an unrouted request is where + // an attacker chooses the label, and a label an attacker chooses is unbounded + // cardinality. + route = "(unmatched)" + } + method := knownMethod(r.Method) + m.requests.WithLabelValues(route, method, strconv.Itoa(rec.status)).Inc() + m.latency.WithLabelValues(route, method).Observe(time.Since(start).Seconds()) + }) + } +} + +// knownMethod folds anything that is not a method this service serves into one label. A method is a +// token the CALLER chooses, and an unrouted request carries whatever they sent — so without this the +// series count is theirs to grow, exactly like the raw path would be. +func knownMethod(m string) string { + switch m { + case http.MethodGet, http.MethodHead, http.MethodPost, http.MethodPut, http.MethodPatch, + http.MethodDelete, http.MethodOptions: + return m + default: + return "(other)" + } +} + +// recorder captures the status code. +// +// Unwrap is NOT optional: http.ResponseController walks the wrappers through it, and the upload +// route reaches its own read deadline that way. A wrapper without it silently turns that call into +// "not supported" — and the upload then dies at the server's 30-second ReadTimeout instead. +type recorder struct { + http.ResponseWriter + status int +} + +func (r *recorder) WriteHeader(code int) { + r.status = code + r.ResponseWriter.WriteHeader(code) +} + +func (r *recorder) Unwrap() http.ResponseWriter { return r.ResponseWriter } diff --git a/platform/internal/metrics/metrics_test.go b/platform/internal/metrics/metrics_test.go new file mode 100644 index 00000000..12dd3e39 --- /dev/null +++ b/platform/internal/metrics/metrics_test.go @@ -0,0 +1,147 @@ +package metrics + +import ( + "net/http" + "net/http/httptest" + "strings" + "testing" + "time" +) + +// scrape renders the exposition the way a scraper would read it. +func scrape(t *testing.T, m *Metrics) string { + t.Helper() + w := httptest.NewRecorder() + m.Handler().ServeHTTP(w, httptest.NewRequest("GET", "/metrics", nil)) + if w.Code != http.StatusOK { + t.Fatalf("scrape: %d %s", w.Code, w.Body) + } + return w.Body.String() +} + +// Labels carry the route PATTERN and never the path. A raw path is a user's library in an operator's +// index (PD-3) and an unbounded number of series besides — the label would be chosen by whoever +// sends the request. +func TestRequestsAreCountedByRoutePatternAndNeverByPath(t *testing.T) { + m := New() + mux := http.NewServeMux() + mux.Handle("GET /v0/books/{bookId}", m.Middleware()(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { + w.WriteHeader(http.StatusNotFound) + }))) + mux.ServeHTTP(httptest.NewRecorder(), httptest.NewRequest("GET", "/v0/books/bk_secret", nil)) + + body := scrape(t, m) + if !strings.Contains(body, `tm_platform_http_requests_total{code="404",method="GET",route="GET /v0/books/{bookId}"} 1`) { + t.Errorf("the counter does not carry the route pattern:\n%s", body) + } + if strings.Contains(body, "bk_secret") { + t.Errorf("a book id reached the exposition:\n%s", body) + } + if !strings.Contains(body, "tm_platform_http_request_duration_seconds_bucket") { + t.Error("no latency histogram: an average is the one statistic that hides the tail") + } +} + +// An unrouted request is where the label would be chosen by an attacker, so there is exactly one +// series for all of them. +func TestAnUnroutedRequestGetsOneSeriesAndNotOnePerPath(t *testing.T) { + m := New() + h := m.Middleware()(http.NotFoundHandler()) + for _, p := range []string{"/../etc/passwd", "/random-1", "/random-2"} { + h.ServeHTTP(httptest.NewRecorder(), httptest.NewRequest("GET", p, nil)) + } + body := scrape(t, m) + if !strings.Contains(body, `route="(unmatched)"} 3`) { + t.Errorf("unmatched requests are not folded into one series:\n%s", body) + } + if strings.Contains(body, "random-1") { + t.Errorf("a caller-chosen path became a label:\n%s", body) + } + // The METHOD is a token the caller chooses too, and on an unrouted request it is theirs to invent. + for _, verb := range []string{"FOOBAR", "WHATEVER"} { + r := httptest.NewRequest("GET", "/nope", nil) + r.Method = verb + h.ServeHTTP(httptest.NewRecorder(), r) + } + body = scrape(t, m) + for _, verb := range []string{"FOOBAR", "WHATEVER"} { + if strings.Contains(body, verb) { + t.Errorf("a caller-chosen method became a label:\n%s", body) + } + } + if !strings.Contains(body, `method="(other)"`) { + t.Errorf("unknown methods are not folded into one series:\n%s", body) + } +} + +// The wrapper must stay transparent to http.ResponseController, which is how the upload route +// reaches its own read deadline. Asserted here as well as through the real server, because this is +// where the property lives. +func TestTheWrapperKeepsTheResponseControllerReachable(t *testing.T) { + m := New() + var sawFlush bool + h := m.Middleware()(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { + // httptest's recorder supports Flush and nothing else, which is enough: what is being + // asserted is that the controller finds the wrapped writer at all. + sawFlush = http.NewResponseController(w).Flush() == nil + })) + h.ServeHTTP(httptest.NewRecorder(), httptest.NewRequest("GET", "/", nil)) + if !sawFlush { + t.Fatal("the wrapper hides the ResponseWriter from http.ResponseController: Unwrap is missing") + } +} + +// The numbers П-11 was opened for: each one used to be answerable only in psql. +func TestTheRunnersStateIsExposedWithItsUnits(t *testing.T) { + m := New() + m.ObserveRunner(Runner{QueueDepth: 3, OldestHoldSeconds: 61.5, QuarantinedAttempts: 1, + LiveRuns: 2, BooksUploading: 4, BooksParsing: 5}) + m.ObserveTailerLag(4096) + m.ObserveSweep("runs", 1500*time.Millisecond, true) + body := scrape(t, m) + for _, want := range []string{ + "tm_platform_queue_depth 3", + "tm_platform_oldest_open_hold_seconds 61.5", + "tm_platform_quarantined_attempts 1", + "tm_platform_live_runs 2", + "tm_platform_tailer_lag_bytes 4096", + `tm_platform_books_in_intake{status="uploading"} 4`, + `tm_platform_books_in_intake{status="parsing"} 5`, + `tm_platform_sweep_unfinished_total{sweep="runs"} 1`, + `tm_platform_sweep_duration_seconds_sum{sweep="runs"} 1.5`, + } { + if !strings.Contains(body, want) { + t.Errorf("the exposition is missing %q", want) + } + } + // Base units and no unit inside a label, per the naming practices this axis is measured against. + // Judged over OUR namespace only: the Go collector's own series are its to name (and one of them + // is `go_memstats_mspan_inuse_bytes`, which is how this assertion first failed). + for _, line := range strings.Split(body, "\n") { + if !strings.HasPrefix(line, namespace+"_") { + continue + } + for _, wrong := range []string{"_ms ", "_ms{", "_millis", `unit="`} { + if strings.Contains(line, wrong) { + t.Errorf("%q is not a base-unit name", line) + } + } + } +} + +// A process that measures nothing must still serve: every instrument is nil-safe, because the +// alternative is a telemetry failure taking down the thing it measures. +func TestAServiceWithoutTelemetryStillWorks(t *testing.T) { + var m *Metrics + m.ObserveRunner(Runner{QueueDepth: 1}) + m.ObserveTailerLag(1) + m.ObserveSweep("runs", time.Second, false) + h := m.Middleware()(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { + w.WriteHeader(http.StatusTeapot) + })) + w := httptest.NewRecorder() + h.ServeHTTP(w, httptest.NewRequest("GET", "/", nil)) + if w.Code != http.StatusTeapot { + t.Fatalf("status %d through a nil registry", w.Code) + } +} diff --git a/platform/internal/pgstore/books.go b/platform/internal/pgstore/books.go index bf7eeca5..ec6bf734 100644 --- a/platform/internal/pgstore/books.go +++ b/platform/internal/pgstore/books.go @@ -57,11 +57,11 @@ type NewBook struct { // AddBook registers a book. `not_started` and not `uploading`: the file is already on disk, and a // status describing an upload this route never performed would be a lie the library then shows. func (s *Store) AddBook(ctx context.Context, in NewBook) (string, error) { - id := newID("bk") + id := NewBookID() const q = ` insert into books (id, owner_id, title, source_lang, target_lang, genre, status, - chapter_count, character_count, added_at, workdir, engine_book_id) - values ($1, $2, $3, $4, $5, $6, 'not_started', $7, $8, $9, $10, $11)` + chapter_count, character_count, added_at, workdir, engine_book_id, revision) + values ($1, $2, $3, $4, $5, $6, 'not_started', $7, $8, $9, $10, $11, ` + nextLibraryRevision + `)` // engine_book_id is the platform's id until the engine's handshake reports its own. It is not // left empty: the column is the join back to whatever the engine calls this book, and an empty // one would read as "the engine has no name for it" rather than "we have not been told yet". @@ -77,6 +77,323 @@ func (s *Store) AddBook(ctx context.Context, in NewBook) (string, error) { return id, nil } +// NewBookID mints a book identifier. Exported because the intake needs one BEFORE the row exists: +// the book's directory is named after it, and the row records that directory. +func NewBookID() string { return newID("bk") } + +// nextLibraryRevision is the revision a book JOINS the library with, as a SQL fragment over $2 (the +// owner). +// +// The library's revision is derived as the maximum over the account's books, and the contract asks +// a client to DROP a read whose revision is not above what it already applied. A book inserted at +// revision 0 therefore left the library's number unchanged, and the screen that had just uploaded +// it dropped the very read that carried it (register row PD-122). Starting above the account's +// current maximum is what makes an addition visible. +// +// It is the greatest of BOTH halves the library's own revision is read from — the maximum over the +// books and the account's floor — because taking only the maximum reproduced the very defect the +// floor exists to prevent, one step later: after a cancelled upload raised the floor above the +// maximum, the NEXT book joined below it, and the library then answered the same number for three +// different states of itself. Found by acceptance; the first version of this constant read only the +// maximum. +// +// Two concurrent additions can compute the same number, and that is harmless: the value still rises, +// so the client still refetches and still sees both. What stays open in PD-122 is the account-scope +// counter itself — one writer for membership and statuses — which is what would let two SIMULTANEOUS +// writers get distinct numbers instead of merely rising ones. +const nextLibraryRevision = `(select greatest( + coalesce((select max(revision) from books where owner_id = $2), 0), + coalesce((select library_revision from users where id = $2), 0)) + 1)` + +// nextRevisionOfThisBooksLibrary is the same number for a statement that has the book but not its +// owner: the owner is read from the row being updated. +// +// ⚠ A status change may NOT simply do `revision + 1`, and that is the same defect as the one above +// wearing different clothes. A book whose own counter sits below the account's floor — it was +// uploaded before another upload was cancelled — would walk `uploading → parsing → not_started` +// three increments deep and still be under the floor, so `greatest(max, floor)` would not move at +// all. A client obeying the contract drops every read that is not ABOVE what it applied, and the +// screen that uploaded the book would keep showing it as arriving until some unrelated event pushed +// the number. Taking the library's own next number instead makes every status visible exactly once. +const nextRevisionOfThisBooksLibrary = `(select greatest( + coalesce((select max(b.revision) from books b where b.owner_id = books.owner_id), 0), + coalesce((select u.library_revision from users u where u.id = books.owner_id), 0)) + 1)` + +// NewUpload is one book arriving through the contract's intake, before its file has been received. +type NewUpload struct { + OwnerID string + Title string + SourceLang string + TargetLang string + Genre string + // Workdir is the engine project directory this book will own. It is named after the book id and + // created before the row, so a row always points at a directory that exists. + Workdir string + Now time.Time +} + +// CreateUpload registers a book whose file is still arriving. +// +// `uploading` is a real, observable state and not a formality: the row is written BEFORE the body is +// read, so a 60 MB upload is visible in the library — with its languages, without its size — while +// it is still on the wire. The alternative, inserting once the bytes have landed, gives the status +// no writer at all and makes a half-finished upload invisible to everything, including the sweep +// that has to clean it up. +func (s *Store) CreateUpload(ctx context.Context, id string, in NewUpload) (Book, error) { + const q = ` + insert into books (id, owner_id, title, source_lang, target_lang, genre, status, + added_at, workdir, engine_book_id, revision) + values ($1, $2, $3, $4, $5, $6, 'uploading', $7, $8, $1, ` + nextLibraryRevision + `) + returning id, title, source_lang, target_lang, genre, status, chapter_count, character_count, + note_count, added_at, revision` + var b Book + err := s.pool.QueryRow(ctx, q, id, in.OwnerID, in.Title, in.SourceLang, in.TargetLang, in.Genre, + in.Now, in.Workdir). + Scan(&b.ID, &b.Title, &b.SourceLang, &b.TargetLang, &b.Genre, &b.Status, &b.ChapterCount, + &b.CharacterCount, &b.NoteCount, &b.AddedAt, &b.Revision) + if err != nil { + var pg *pgconn.PgError + if errors.As(err, &pg) && pg.ConstraintName == "books_owner_id_fkey" { + return Book{}, ErrNoAccount + } + return Book{}, fmt.Errorf("pgstore: create upload: %w", err) + } + return b, nil +} + +// StartParsing hands a received file to the parser: `uploading → parsing`, with the size the intake +// counted while the bytes went past. +// +// Conditional on the status, so an upload the sweep has already given up on cannot be resurrected by +// a request that finally finished. +// +// enqueue joins the caller's transaction for the same reason the run admission's does: a status that +// says "being parsed" with no job to do it waits for the backstop sweep's grace, and a job for a +// book that never became `parsing` is a worker with nothing to claim. +func (s *Store) StartParsing(ctx context.Context, id string, characters int64, + enqueue func(context.Context, Tx, string) error) (Book, error) { + var b Book + err := s.inTx(ctx, func(tx pgx.Tx) error { + const q = ` + update books set status = 'parsing', character_count = $2, + revision = ` + nextRevisionOfThisBooksLibrary + ` + where id = $1 and status = 'uploading' + returning id, title, source_lang, target_lang, genre, status, chapter_count, + character_count, note_count, added_at, revision` + err := tx.QueryRow(ctx, q, id, characters). + Scan(&b.ID, &b.Title, &b.SourceLang, &b.TargetLang, &b.Genre, &b.Status, &b.ChapterCount, + &b.CharacterCount, &b.NoteCount, &b.AddedAt, &b.Revision) + if errors.Is(err, pgx.ErrNoRows) { + return ErrNoBook + } + if err != nil { + return fmt.Errorf("pgstore: start parsing: %w", err) + } + if enqueue != nil { + return enqueue(ctx, tx, id) + } + return nil + }) + if err != nil { + return Book{}, err + } + return b, nil +} + +// ParseClaim is one book this process may parse. +type ParseClaim struct { + BookID string + Workdir string + Attempts int + // At is the stamp this claim was taken with. Every write that ENDS the parse carries it back, so + // a pass whose claim was taken over by another cannot record a verdict about work it is no longer + // doing — the same re-check-under-the-write the money paths use. + At time.Time +} + +// ErrParseClaimed is a parse someone else is already doing, or a book that is no longer waiting for +// one. The ordinary case, not a failure: the queue job and the backstop sweep both aim at the same +// book on purpose. +var ErrParseClaimed = errors.New("pgstore: the parse of this book is already claimed") + +// ClaimParse takes the right to parse a book, once. +// +// A compare-and-set rather than a plain read, for the same reason the spawn claim is one: the queue +// worker and the backstop sweep legitimately arrive together, and two `tmctl manifest` processes on +// one project directory would be two writers of one SQLite file — which is exactly what the engine's +// exclusive lock exists to refuse, noisily and after the work. +// +// staleAfter is what makes the claim recoverable: a process that died holding one leaves a stamp +// nothing clears, and without a grace the book would wait for parsing forever. +func (s *Store) ClaimParse(ctx context.Context, id string, now, staleBefore time.Time) (ParseClaim, error) { + const q = ` + update books set parse_started_at = $2, parse_attempts = parse_attempts + 1 + where id = $1 and status = 'parsing' + and (parse_started_at is null or parse_started_at < $3) + returning id, workdir, parse_attempts` + var c ParseClaim + err := s.pool.QueryRow(ctx, q, id, now, staleBefore).Scan(&c.BookID, &c.Workdir, &c.Attempts) + c.At = now + if errors.Is(err, pgx.ErrNoRows) { + return ParseClaim{}, ErrParseClaimed + } + if err != nil { + return ParseClaim{}, fmt.Errorf("pgstore: claim parse: %w", err) + } + return c, nil +} + +// RefundParseAttempt gives back an attempt that was claimed but never spent on the engine. +// +// The budget exists to bound how many times a broken HOST is asked to parse a book, and a book that +// is only waiting for its configuration asks nobody: counting those claims meant the budget ran out +// while nothing had been tried, and the first real answer from the engine was terminal the moment it +// arrived. Conditional on the claim, like every other write that ends a pass. +func (s *Store) RefundParseAttempt(ctx context.Context, id string, claimedAt time.Time) error { + _, err := s.pool.Exec(ctx, ` + update books set parse_attempts = greatest(parse_attempts - 1, 0) + where id = $1 and status = 'parsing' and parse_started_at = $2`, id, claimedAt) + if err != nil { + return fmt.Errorf("pgstore: refund parse attempt: %w", err) + } + return nil +} + +// ParsedBook is what the engine's manifest told the platform about a book it has now cut. +type ParsedBook struct { + Chapters int + SourceSHA256 []byte + ChunkerVersion string +} + +// FinishParse records a parsed book: `parsing → not_started`. +// +// Conditional on the CLAIM still being this caller's: a pass whose claim was taken over is not the +// pass whose answer counts, and two answers about one book are one answer too many. +func (s *Store) FinishParse(ctx context.Context, id string, claimedAt time.Time, in ParsedBook) error { + const q = ` + update books set status = 'not_started', chapter_count = $2, source_sha256 = $3, + chunker_version = $4, parse_started_at = null, + revision = ` + nextRevisionOfThisBooksLibrary + ` + where id = $1 and status = 'parsing' and parse_started_at = $5` + tag, err := s.pool.Exec(ctx, q, id, in.Chapters, in.SourceSHA256, in.ChunkerVersion, claimedAt) + if err != nil { + return fmt.Errorf("pgstore: finish parse: %w", err) + } + if tag.RowsAffected() == 0 { + return ErrNoBook + } + return nil +} + +// RejectBook is the terminal end of an intake that failed: `parsing → rejected`. +// +// The reason is the platform's own word for the class of failure and is not projected — contract v0 +// has no field for it (migration 00013). +// +// Conditional on the CLAIM, like FinishParse and for a sharper reason: rejecting is destructive — +// the book's source goes with it — and a pass whose claim was stolen may be holding an exit code +// that says nothing about the book. The engine maps every failure onto exit 1, including a lock held +// by the parse that took the claim over. +func (s *Store) RejectBook(ctx context.Context, id string, claimedAt time.Time, reason string) error { + const q = ` + update books set status = 'rejected', reject_reason = $2, parse_started_at = null, + revision = ` + nextRevisionOfThisBooksLibrary + ` + where id = $1 and status = 'parsing' and parse_started_at = $3` + tag, err := s.pool.Exec(ctx, q, id, reason, claimedAt) + if err != nil { + return fmt.Errorf("pgstore: reject book: %w", err) + } + if tag.RowsAffected() == 0 { + return ErrNoBook + } + return nil +} + +// DeleteUpload removes a book whose file never arrived. +// +// Not `rejected`: that status means "the file could not be parsed" (contract §BookStatus), and a +// request the client abandoned produced no file to judge. The user's library must not accumulate a +// permanent row for every cancelled upload — there is no delete handle in the contract to clear one +// with. +// +// Narrow on purpose: only a book still in `uploading`, and only one no run was ever started for, so +// this can never become a way to delete a book that has cost money. +// +// ⚠ It also carries the library's revision ACROSS the deletion, and that is not bookkeeping: the +// library's number is derived as the maximum over the account's books, so removing the newest one +// would lower it — and the contract asks a client to DROP a read whose revision is below what it +// applied (§Revision). The screen would then keep rendering a book that no longer exists until some +// other book's counter grew past it. The floor lives in `users.library_revision`, the account-scope +// counter that has existed unused since 00001 (register row PD-122). +func (s *Store) DeleteUpload(ctx context.Context, id string) error { + return s.inTx(ctx, func(tx pgx.Tx) error { + // Book first, as in every transaction that touches two of these tables (lockBook). `users` is + // taken after it, and no path in this package takes them the other way round. + if err := lockBook(ctx, tx, id); err != nil { + return err + } + var owner string + var revision int64 + err := tx.QueryRow(ctx, ` + delete from books where id = $1 and status = 'uploading' + and not exists (select 1 from runs where book_id = $1) + returning owner_id, revision`, id).Scan(&owner, &revision) + if errors.Is(err, pgx.ErrNoRows) { + return ErrNoBook + } + if err != nil { + return fmt.Errorf("pgstore: delete upload: %w", err) + } + if _, err := tx.Exec(ctx, ` + update users set library_revision = greatest(library_revision, $2) where id = $1`, + owner, revision+1); err != nil { + return fmt.Errorf("pgstore: carry the library revision: %w", err) + } + return nil + }) +} + +// IntakeBook is a book the backstop sweep has to make a decision about. +type IntakeBook struct { + ID string + Status string + Workdir string + Attempts int +} + +// StuckIntake lists books that stopped moving through intake. +// +// Both halves have the same cause and different cures: an `uploading` row whose request is gone is +// litter to remove, a `parsing` row whose claim went stale is work to retry. Neither is reachable +// from the request that created it — the process holding it is the one that died — so the sweep is +// the only thing that can finish the walk. +func (s *Store) StuckIntake(ctx context.Context, uploadingBefore, claimedBefore time.Time) ([]IntakeBook, error) { + // coalesce(claimed, added): an unclaimed parse is stuck when the BOOK has been waiting, a claimed + // one when the CLAIM has. Written as one expression because they are the same question — how long + // has this book had nobody working on it — and a NULL claim is simply the first answer to it. + const q = ` + select id, status, workdir, parse_attempts + from books + where (status = 'uploading' and added_at < $1) + or (status = 'parsing' and coalesce(parse_started_at, added_at) < $2) + order by added_at` + rows, err := s.pool.Query(ctx, q, uploadingBefore, claimedBefore) + if err != nil { + return nil, fmt.Errorf("pgstore: list stuck intake: %w", err) + } + defer rows.Close() + var out []IntakeBook + for rows.Next() { + var b IntakeBook + if err := rows.Scan(&b.ID, &b.Status, &b.Workdir, &b.Attempts); err != nil { + return nil, fmt.Errorf("pgstore: scan stuck intake: %w", err) + } + out = append(out, b) + } + return out, rows.Err() +} + // Progress is per phase, in units (contract §Progress). type Progress struct { DraftDone, DraftTotal int @@ -158,8 +475,15 @@ func (s *Store) ListBooks(ctx context.Context, userID string, limit int, cursor } // The revision of the LIBRARY scope — membership and statuses — and never compared with the // revision of a book (contract §Revision). - if err := s.pool.QueryRow(ctx, - `select coalesce(max(revision), 0) from books where owner_id = $1`, userID).Scan(&out.Revision); err != nil { + // + // The maximum over the account's books, FLOORED by the account's own counter. The floor is what + // makes the number monotonic across a deletion: the maximum alone falls when the newest book goes + // (DeleteUpload raises the floor as it deletes), and a library revision that goes backwards is a + // library the client stops rendering. + if err := s.pool.QueryRow(ctx, ` + select greatest(coalesce((select max(revision) from books where owner_id = $1), 0), + coalesce((select library_revision from users where id = $1), 0))`, + userID).Scan(&out.Revision); err != nil { return Library{}, fmt.Errorf("pgstore: library revision: %w", err) } return out, nil @@ -191,6 +515,13 @@ func (s *Store) GetBook(ctx context.Context, userID, bookID string) (Book, *Run, } if run != nil { b.Progress = run.progress + // ONE counter per book, and this is the single place that says so for every run this store + // hands out (§Revision: "every book-scoped read … carry the same number"). Read off the run's + // own column the card lagged — a unit_done bumps the book and the chapter and not the run, so + // a client that had applied frame id=2 got revision 0 back and, obeying the contract, dropped + // the read. The stop and resume handles answer from ReadRun/RequestStop, which select the + // book's revision for the same reason. + run.Revision = b.Revision return b, &run.Run, nil } return b, nil, nil @@ -225,6 +556,10 @@ func (s *Store) lastRun(ctx context.Context, bookID string) (*runWithProgress, e // ownership so a stranger's book is indistinguishable from a missing one. type BookRunContext struct { Workdir string + // Status is the book's own state. A run may only be started on a book whose intake finished: + // the directory of a book still `uploading` holds half a file and the directory of a `rejected` + // one may hold nothing at all. + Status string // ChaptersLeft is what is still untranslated, and it clamps the ceiling scale. Until the engine // persists a chapter manifest (unified backlog row 100) the platform knows the chapter COUNT // from intake and nothing per chapter, so a book with no materialized chapters answers with its @@ -236,14 +571,14 @@ type BookRunContext struct { // ReadBookForRun gathers the facts a run start is judged on. func (s *Store) ReadBookForRun(ctx context.Context, userID, bookID string) (BookRunContext, error) { const q = ` - select b.workdir, + select b.workdir, b.status, b.chapter_count - (select count(*) from chapters c where c.book_id = b.id and c.units_total > 0 and c.units_done >= c.units_total), exists (select 1 from runs r where r.book_id = b.id and r.finished_at is null) from books b where b.id = $1 and b.owner_id = $2` var out BookRunContext - err := s.pool.QueryRow(ctx, q, bookID, userID).Scan(&out.Workdir, &out.ChaptersLeft, &out.HasLiveRun) + err := s.pool.QueryRow(ctx, q, bookID, userID).Scan(&out.Workdir, &out.Status, &out.ChaptersLeft, &out.HasLiveRun) if errors.Is(err, pgx.ErrNoRows) { return BookRunContext{}, ErrNoBook } diff --git a/platform/internal/pgstore/intake_test.go b/platform/internal/pgstore/intake_test.go new file mode 100644 index 00000000..0a8bc4f5 --- /dev/null +++ b/platform/internal/pgstore/intake_test.go @@ -0,0 +1,287 @@ +package pgstore + +import ( + "context" + "errors" + "testing" + "time" +) + +// The store half of the two walks this pack added: a book through intake, and a run through a stop +// the user asked for. What is asserted here is what only SQL can get wrong — the conditions on the +// updates, which are what keep two processes from finishing one walk twice. + +func upload(t *testing.T, s *Store, ctx context.Context, owner string, now time.Time) Book { + t.Helper() + id := NewBookID() + b, err := s.CreateUpload(ctx, id, NewUpload{OwnerID: owner, Title: "蛊真人", SourceLang: "zh", + TargetLang: "ru", Workdir: "/srv/books/" + id, Now: now}) + if err != nil { + t.Fatal(err) + } + return b +} + +func TestABookWalksThroughIntakeOnlyForwards(t *testing.T) { + s, ctx := testDB(t) + now := fundedAccount(t, s, ctx, "u1", "10") + b := upload(t, s, ctx, "u1", now) + if b.Status != "uploading" { + t.Fatalf("a created upload is %q", b.Status) + } + if _, err := s.StartParsing(ctx, b.ID, 42, nil); err != nil { + t.Fatal(err) + } + // The same call again finds no book in `uploading`: a request that finally finished must not be + // able to resurrect an upload the sweep has already given up on. + if _, err := s.StartParsing(ctx, b.ID, 42, nil); !errors.Is(err, ErrNoBook) { + t.Fatalf("a second StartParsing gave %v, want ErrNoBook", err) + } + claim, err := s.ClaimParse(ctx, b.ID, now, now.Add(-time.Hour)) + if err != nil { + t.Fatal(err) + } + // A verdict may only be written by the pass that still HOLDS the claim: an answer from a pass + // whose claim was taken over is an answer about work it is no longer doing — and for a rejection + // it would also delete the book's source. + stale := claim.At.Add(-time.Minute) + if err := s.FinishParse(ctx, b.ID, stale, ParsedBook{Chapters: 7}); !errors.Is(err, ErrNoBook) { + t.Fatalf("a stale claim finished the parse: %v, want ErrNoBook", err) + } + if err := s.RejectBook(ctx, b.ID, stale, ReasonForTest); !errors.Is(err, ErrNoBook) { + t.Fatalf("a stale claim rejected the book: %v, want ErrNoBook", err) + } + if err := s.FinishParse(ctx, b.ID, claim.At, ParsedBook{Chapters: 500, ChunkerVersion: "chunk-1"}); err != nil { + t.Fatal(err) + } + if err := s.FinishParse(ctx, b.ID, claim.At, ParsedBook{Chapters: 7}); !errors.Is(err, ErrNoBook) { + t.Fatalf("a second FinishParse gave %v, want ErrNoBook", err) + } + // And a parsed book can no longer be rejected: `rejected` is an END of intake, not a state a + // later pass may drop a working book into. + if err := s.RejectBook(ctx, b.ID, claim.At, ReasonForTest); !errors.Is(err, ErrNoBook) { + t.Fatalf("rejecting a parsed book gave %v, want ErrNoBook", err) + } + got, _, err := s.GetBook(ctx, "u1", b.ID) + if err != nil { + t.Fatal(err) + } + if got.Status != "not_started" || got.ChapterCount != 500 || got.CharacterCount != 42 { + t.Fatalf("after the walk: %+v", got) + } +} + +// ReasonForTest stands for one of the platform's own reject reasons; the vocabulary itself lives in +// the intake package, and this file only needs a value. +const ReasonForTest = "source_unreadable" + +// The claim is what keeps two `tmctl manifest` processes off one project directory, and the grace is +// what keeps a claim from being permanent when the process holding it dies. +func TestAParseClaimIsTakenOnceAndRecoveredAfterTheGrace(t *testing.T) { + s, ctx := testDB(t) + now := fundedAccount(t, s, ctx, "u1", "10") + b := upload(t, s, ctx, "u1", now) + if _, err := s.StartParsing(ctx, b.ID, 1, nil); err != nil { + t.Fatal(err) + } + stale := now.Add(-time.Hour) + first, err := s.ClaimParse(ctx, b.ID, now, stale) + if err != nil { + t.Fatal(err) + } + if first.Attempts != 1 || first.Workdir == "" { + t.Fatalf("first claim: %+v", first) + } + if _, err := s.ClaimParse(ctx, b.ID, now, stale); !errors.Is(err, ErrParseClaimed) { + t.Fatalf("a second claim gave %v, want ErrParseClaimed", err) + } + // The holder is gone and its claim has aged past the grace: the next pass may take it, and the + // attempt counter — which is what bounds a broken host — keeps counting. An hour later, with the + // grace the intake actually uses, the first claim is well behind the line. + later := now.Add(time.Hour) + again, err := s.ClaimParse(ctx, b.ID, later, later.Add(-10*time.Minute)) + if err != nil { + t.Fatal(err) + } + if again.Attempts != 2 { + t.Fatalf("the attempt counter reads %d after a recovered claim", again.Attempts) + } + // ⚠ And the claim is what SPACES the retries: it stays held after a failed parse, so the book is + // not claimable again until the grace passes. Without that the sweep re-offers it on its very next + // tick — the staleness predicate falls back to `added_at`, which is already old — and the attempt + // budget burns in five ticks of the sweep instead of in the time it is meant to bound. + if _, err := s.ClaimParse(ctx, b.ID, later, later.Add(-10*time.Minute)); !errors.Is(err, ErrParseClaimed) { + t.Fatalf("a fresh claim was retakeable at once: %v, want ErrParseClaimed", err) + } +} + +// DeleteUpload is narrow on purpose: it is the cure for a request that went away, and it must never +// become a way to delete a book that has cost money. +func TestOnlyAnUnfinishedUploadCanBeDeleted(t *testing.T) { + s, ctx := testDB(t) + now := fundedAccount(t, s, ctx, "u1", "10") + b := upload(t, s, ctx, "u1", now) + if _, err := s.StartParsing(ctx, b.ID, 1, nil); err != nil { + t.Fatal(err) + } + if err := s.DeleteUpload(ctx, b.ID); !errors.Is(err, ErrNoBook) { + t.Fatalf("deleting a book that is being parsed gave %v, want ErrNoBook", err) + } + // A book back in `uploading` but with a run against it stays as well: the run's money is keyed to + // it, and the cascade would take the row out from under the ledger. + if _, err := s.pool.Exec(ctx, `update books set status = 'uploading' where id = $1`, b.ID); err != nil { + t.Fatal(err) + } + if _, err := s.StartRun(ctx, StartRunInput{UserID: "u1", BookID: b.ID, CeilingChapters: 1, + Ceiling: 1_000_000, Now: now}, 0, nil); err != nil { + t.Fatal(err) + } + if err := s.DeleteUpload(ctx, b.ID); !errors.Is(err, ErrNoBook) { + t.Fatalf("deleting a book with a run gave %v, want ErrNoBook", err) + } + other := upload(t, s, ctx, "u1", now) + if err := s.DeleteUpload(ctx, other.ID); err != nil { + t.Fatalf("an untouched upload could not be deleted: %v", err) + } +} + +// The stop intent: idempotent, first timestamp kept, and the two refusals told apart. +func TestAStopRequestIsIdempotentAndKeepsTheFirstTime(t *testing.T) { + s, ctx := testDB(t) + now := fundedAccount(t, s, ctx, "u1", "10") + fundedAccount(t, s, ctx, "u2", "10") + bookID, err := s.AddBook(ctx, NewBook{OwnerID: "u1", Title: "b", SourceLang: "zh", TargetLang: "ru", + ChapterCount: 10, Workdir: "/srv/books/b", Now: now}) + if err != nil { + t.Fatal(err) + } + started, err := s.StartRun(ctx, StartRunInput{UserID: "u1", BookID: bookID, CeilingChapters: 1, + Ceiling: 1_000_000, Now: now}, 0, nil) + if err != nil { + t.Fatal(err) + } + if _, _, err := s.RequestStop(ctx, "u2", started.ID, now); !errors.Is(err, ErrNoRun) { + t.Fatalf("a stranger's stop gave %v, want ErrNoRun", err) + } + run, unit, err := s.RequestStop(ctx, "u1", started.ID, now) + if err != nil { + t.Fatal(err) + } + if run.ID != started.ID || unit != "" { + t.Fatalf("stop returned %+v with unit %q (nothing was spawned)", run, unit) + } + if _, _, err := s.RequestStop(ctx, "u1", started.ID, now.Add(time.Hour)); err != nil { + t.Fatalf("a second stop: %v", err) + } + var at time.Time + if err := s.pool.QueryRow(ctx, `select stop_requested_at from runs where id = $1`, started.ID).Scan(&at); err != nil { + t.Fatal(err) + } + if !at.Equal(now) { + t.Fatalf("the stop is stamped %s, want the FIRST request at %s", at, now) + } + // Over means over: a finished run is a conflict, not a missing one. + if _, err := s.FinishRun(ctx, started.ID, started.AttemptID, "stopped", "success", nil, now); err != nil { + t.Fatal(err) + } + if _, _, err := s.RequestStop(ctx, "u1", started.ID, now); !errors.Is(err, ErrRunNotLive) { + t.Fatalf("stopping a finished run gave %v, want ErrRunNotLive", err) + } +} + +// A resumed run is LIVE again, and the three columns that say otherwise have to go with it: an +// unfinished run the one-live-per-book index cannot see, money marked resolved that the new attempt +// has not spent, and a stop request from the previous life that would classify this attempt's ending +// as a stop nobody asked for. +func TestReopeningARunClearsWhatSaidItWasOver(t *testing.T) { + s, ctx := testDB(t) + now := fundedAccount(t, s, ctx, "u1", "20") + bookID, err := s.AddBook(ctx, NewBook{OwnerID: "u1", Title: "b", SourceLang: "zh", TargetLang: "ru", + ChapterCount: 10, Workdir: "/srv/books/b", Now: now}) + if err != nil { + t.Fatal(err) + } + started, err := s.StartRun(ctx, StartRunInput{UserID: "u1", BookID: bookID, CeilingChapters: 1, + Ceiling: 1_000_000, Now: now}, 0, nil) + if err != nil { + t.Fatal(err) + } + if _, _, err := s.RequestStop(ctx, "u1", started.ID, now); err != nil { + t.Fatal(err) + } + if _, err := s.FinishRun(ctx, started.ID, started.AttemptID, "stopped", "exit-code", nil, now); err != nil { + t.Fatal(err) + } + if err := s.MarkSettled(ctx, started.ID, now); err != nil { + t.Fatal(err) + } + if _, err := s.RestartRun(ctx, RestartInput{RunID: started.ID, AttemptID: started.AttemptID, + UserID: "u1", BookID: bookID, Ceiling: 500_000, Now: now}); err != nil { + t.Fatal(err) + } + var finished, settled, stop *time.Time + var status, bookStatus string + if err := s.pool.QueryRow(ctx, ` + select r.finished_at, r.settled_at, r.stop_requested_at, r.status, b.status + from runs r join books b on b.id = r.book_id where r.id = $1`, started.ID). + Scan(&finished, &settled, &stop, &status, &bookStatus); err != nil { + t.Fatal(err) + } + if finished != nil || settled != nil || stop != nil { + t.Fatalf("a reopened run still says it is over: finished %v settled %v stop %v", finished, settled, stop) + } + if status != "translating" || bookStatus != "translating" { + t.Fatalf("run %q, book %q after a reopen", status, bookStatus) + } + // The verdict of the attempt that ended is NOT overwritten: how the run the user stopped actually + // ended is the one fact a resume must not erase. + var result string + if err := s.pool.QueryRow(ctx, + `select exit_result from run_attempts where id = $1`, started.AttemptID).Scan(&result); err != nil { + t.Fatal(err) + } + if result != "exit-code" { + t.Fatalf("the previous attempt's exit_result became %q", result) + } +} + +// One counter per book, and this store is where that rule lives: every Run it hands out — from the +// card, from a stop, from a plain read — carries its BOOK's revision. A run's own column lags by +// construction (a materialized unit bumps the book and the chapter, not the run), and the contract +// tells a client to DROP a read whose revision is below one it applied, so a handle answering from +// the run's column hands back a number the client is required to throw away. +func TestEveryRunTheStoreHandsOutCarriesItsBooksRevision(t *testing.T) { + s, ctx := testDB(t) + now := fundedAccount(t, s, ctx, "u1", "10") + bookID, err := s.AddBook(ctx, NewBook{OwnerID: "u1", Title: "b", SourceLang: "zh", TargetLang: "ru", + ChapterCount: 10, Workdir: "/srv/books/b", Now: now}) + if err != nil { + t.Fatal(err) + } + started, err := s.StartRun(ctx, StartRunInput{UserID: "u1", BookID: bookID, CeilingChapters: 1, + Ceiling: 1_000_000, Now: now}, 0, nil) + if err != nil { + t.Fatal(err) + } + // Something materializes: the book's counter moves and the run's own column stays where it was. + if _, err := s.pool.Exec(ctx, `update books set revision = revision + 7 where id = $1`, bookID); err != nil { + t.Fatal(err) + } + book, card, err := s.GetBook(ctx, "u1", bookID) + if err != nil || card == nil { + t.Fatalf("card: %v", err) + } + read, err := s.ReadRun(ctx, "u1", started.ID) + if err != nil { + t.Fatal(err) + } + stop, _, err := s.RequestStop(ctx, "u1", started.ID, now) + if err != nil { + t.Fatal(err) + } + for name, got := range map[string]int64{"card": card.Revision, "read": read.Revision, "stop": stop.Revision} { + if got != book.Revision { + t.Errorf("%s answered revision %d, the book is at %d", name, got, book.Revision) + } + } +} diff --git a/platform/internal/pgstore/migrations.sha256 b/platform/internal/pgstore/migrations.sha256 index 82d6241d..96bc6a23 100644 --- a/platform/internal/pgstore/migrations.sha256 +++ b/platform/internal/pgstore/migrations.sha256 @@ -25,3 +25,5 @@ c225e1a12bab8c62669848976096453aa84d66263fe211da0a5eb4b173ff2eff 00007_credits. 905b89bcecffd53cda7c628f7e1c3b9dad522a8947fbcd7dbe31dfb9644c644e 00010_spend_baseline.sql 76f3bb045cf3a2d232065418357f534752ddb06e28e4122e1d45c8f59c23abf8 00011_ceiling_argument.sql 282ff2d515d9de16f631a5cd77f676c7742337f96406b8876c900f0b44ea19a0 00012_resync_stamp.sql +1cb0d8127ea8ad0391b6829c2d09a7b776ebc9729002ae5db76afe5daa7a790b 00013_book_intake.sql +4281cfce2a34179333843f72257f3d9e62c71480a129168d613066aa8ad3687e 00014_stop_intent.sql diff --git a/platform/internal/pgstore/migrations/00013_book_intake.sql b/platform/internal/pgstore/migrations/00013_book_intake.sql new file mode 100644 index 00000000..ebcb5926 --- /dev/null +++ b/platform/internal/pgstore/migrations/00013_book_intake.sql @@ -0,0 +1,39 @@ +-- +goose Up + +-- The intake half of a book: what `POST /books` needs to move a row through +-- `uploading → parsing → not_started | rejected` and what a backstop sweep needs to finish that +-- walk when the process doing it died halfway. +-- +-- Until this pack the three intake statuses existed in the contract and in the DDL check and had no +-- writer at all: a book could only be registered by the dev CLI, already parsed, straight into +-- `not_started`. + +alter table books + -- How many times parsing has been CLAIMED for this book. Parsing is one $0 call of the engine + -- (`tmctl manifest`), and it fails in two different ways: the source cannot be cut (the book's + -- own fault, terminal) or the engine could not be run at all (the deployment's fault, and the + -- next sweep may well succeed). Only the second is retried, and only this counter bounds that + -- retry — without it a misconfigured host would re-run the engine over every uploaded book + -- forever. + add column parse_attempts integer not null default 0, + -- When the current parse was claimed. It is the CLAIM, not a timestamp for display: a book in + -- `parsing` whose claim is older than the grace is a parse whose process is gone, and the sweep + -- may take it again. Null means no claim is outstanding. + add column parse_started_at timestamptz, + -- Why a book was rejected, in the platform's own closed vocabulary — never the engine's text, + -- which reads like pipeline internals and must not cross the seam (contract §Problem, PT-33). + -- ⚠ NOT on the wire: contract v0 gives a book a `rejected` status and no reason field, so this + -- is written for an operator and for whatever later pack adds one. Inventing the field here is + -- not this zone's right (the precedent is last_resync_at, register row PD-150). + add column reject_reason text not null default ''; + +-- The sweep asks one question — "which books are stuck in intake" — every few seconds, and the +-- answer is a handful of rows in a table that grows with every book of every account. +create index books_intake_idx on books (status, added_at) where status in ('uploading', 'parsing'); + +-- +goose Down +drop index books_intake_idx; +alter table books + drop column reject_reason, + drop column parse_started_at, + drop column parse_attempts; diff --git a/platform/internal/pgstore/migrations/00014_stop_intent.sql b/platform/internal/pgstore/migrations/00014_stop_intent.sql new file mode 100644 index 00000000..baf744f4 --- /dev/null +++ b/platform/internal/pgstore/migrations/00014_stop_intent.sql @@ -0,0 +1,20 @@ +-- +goose Up + +-- When THIS platform asked the run to stop. +-- +-- It exists because the exit marker cannot answer "who ended this run". The engine catches SIGTERM +-- and exits with code 1, so `ExecStopPost` records `exit-code/exited/1` for a stop a user pressed +-- and for a crash alike (register row PD-152, measured on the stand). Guessing from the code would +-- be reading someone else's tea leaves; this column is the platform's OWN knowledge, written before +-- the signal is sent, and the reconciler classifies the marker by it. +-- +-- The order matters and is the whole point: the intent is committed BEFORE systemd is asked, so a +-- platform that dies between the two still knows, on its next sweep, that the run it finds stopped +-- was stopped on purpose — and that it must not restart it. +-- +-- Null means nobody asked. Cleared when a run is re-opened, because a resumed run has not been +-- asked to stop; a stale intent would classify its next ending as a stop nobody requested. +alter table runs add column stop_requested_at timestamptz; + +-- +goose Down +alter table runs drop column stop_requested_at; diff --git a/platform/internal/pgstore/observe.go b/platform/internal/pgstore/observe.go new file mode 100644 index 00000000..84f19d2c --- /dev/null +++ b/platform/internal/pgstore/observe.go @@ -0,0 +1,48 @@ +package pgstore + +import ( + "context" + "fmt" +) + +// Observations is one reading of the state an operator cannot otherwise see. +// +// Every figure here used to be answerable only by opening psql (zone backlog П-11): how much work is +// queued, whether money is stuck in a hold nothing closes, whether a live run's projection has been +// blinded, how many books are stuck in intake. They are read in ONE statement because a set of them +// taken at different moments describes a state the system was never in. +type Observations struct { + QueueDepth int64 + OldestHoldSeconds float64 + QuarantinedAttempts int64 + LiveRuns int64 + BooksUploading int64 + BooksParsing int64 +} + +// Observe reads the control plane's own state. +// +// The queue's table is asked for through to_regclass rather than assumed: River owns that schema and +// migrates it separately (STACK_DECISIONS §19), so an instance whose queue migration has not run +// answers a readiness probe about it and must not fail its telemetry over it as well. +func (s *Store) Observe(ctx context.Context) (Observations, error) { + const q = ` + select + (select case when to_regclass('river_job') is null then 0 else + (select count(*) from river_job + where state in ('available', 'running', 'scheduled', 'retryable', 'pending')) end), + (select coalesce(extract(epoch from (now() - min(opened_at))), 0) + from reservations where state = 'open'), + (select count(*) from run_attempts + where quarantine_reason is not null and ended_at is null), + (select count(*) from runs where finished_at is null), + (select count(*) from books where status = 'uploading'), + (select count(*) from books where status = 'parsing')` + var o Observations + err := s.pool.QueryRow(ctx, q).Scan(&o.QueueDepth, &o.OldestHoldSeconds, &o.QuarantinedAttempts, + &o.LiveRuns, &o.BooksUploading, &o.BooksParsing) + if err != nil { + return Observations{}, fmt.Errorf("pgstore: observe: %w", err) + } + return o, nil +} diff --git a/platform/internal/pgstore/runs.go b/platform/internal/pgstore/runs.go index 7c3b20da..25c29f92 100644 --- a/platform/internal/pgstore/runs.go +++ b/platform/internal/pgstore/runs.go @@ -105,7 +105,8 @@ func (s *Store) StartRun(ctx context.Context, in StartRunInput, journalOffset in return err } if _, err := tx.Exec(ctx, ` - update books set status = 'translating', revision = revision + 1 where id = $1`, in.BookID); err != nil { + update books set status = 'translating', + revision = `+nextRevisionOfThisBooksLibrary+` where id = $1`, in.BookID); err != nil { return fmt.Errorf("pgstore: mark book translating: %w", err) } if enqueue != nil { @@ -168,6 +169,13 @@ type LiveRun struct { // PausedReason is what the stream already said about this run. A run that reported a ceiling halt // is `paused` however its process then ended (contract §BookStatus: never `failed`). PausedReason string + // Status is the run's product status. The reconciler works on live runs, where it is + // `translating`; the resume path reads a run that has already ended and decides by it. + Status string + // StopRequestedAt is when THIS platform asked the run to stop, and it is the only thing that can + // tell a stop from a crash: the engine catches SIGTERM and exits 1, so the marker says + // `exit-code/exited/1` for both (register row PD-152). Nil means nobody asked. + StopRequestedAt *time.Time // StartedAt is the RUN's start; AttemptStartedAt is THIS attempt's. The reconciler's grace is // measured against the second: a restarted attempt inherits a start time hours old, and the // grace then expires before systemd has had a chance to create anything. @@ -202,7 +210,7 @@ func (s *Store) ListLiveRuns(ctx context.Context) ([]LiveRun, error) { // differ only in which runs they select, and a scan list copied twice is a scan list that drifts. const runColumns = ` select r.id, r.book_id, b.owner_id, b.workdir, r.verify_bank, r.ceiling_chapters, - coalesce(r.paused_reason, ''), r.started_at, + coalesce(r.paused_reason, ''), r.started_at, r.status, r.stop_requested_at, a.id, a.attempt_no, coalesce(a.unit_name, ''), coalesce(a.engine_run_id, ''), a.last_offset, a.last_seq, a.last_line_sha256, a.quarantine_reason is not null, coalesce(res.amount_micro_usd, 0), a.engine_binary, a.spend_baseline_micro_usd, @@ -210,8 +218,8 @@ const runColumns = ` from runs r join books b on b.id = r.book_id` -func (s *Store) queryRuns(ctx context.Context, tail string) ([]LiveRun, error) { - rows, err := s.pool.Query(ctx, runColumns+tail) +func (s *Store) queryRuns(ctx context.Context, tail string, args ...any) ([]LiveRun, error) { + rows, err := s.pool.Query(ctx, runColumns+tail, args...) if err != nil { return nil, fmt.Errorf("pgstore: list runs: %w", err) } @@ -222,7 +230,7 @@ func (s *Store) queryRuns(ctx context.Context, tail string) ([]LiveRun, error) { var ceiling, ceilingArg int64 var baseline *int64 if err := rows.Scan(&l.RunID, &l.BookID, &l.UserID, &l.Workdir, &l.VerifyBank, &l.CeilingChapters, - &l.PausedReason, &l.StartedAt, + &l.PausedReason, &l.StartedAt, &l.Status, &l.StopRequestedAt, &l.AttemptID, &l.AttemptNo, &l.UnitName, &l.EngineRunID, &l.Position.Offset, &l.Position.LastSeq, &l.Position.LastHash, &l.Quarantined, &ceiling, &l.EngineBinary, &baseline, &ceilingArg, &l.AttemptStartedAt); err != nil { @@ -282,6 +290,114 @@ func (s *Store) SpendBound(ctx context.Context, bookID string, attemptID int64) return &bound, nil } +// ErrRunNotLive is a run that cannot be stopped because it is already over. Distinct from ErrNoRun, +// which is a run this account cannot see at all: the first is the contract's 409 and the second its +// 404, and answering the wrong one either tells a stranger that a run exists or tells an owner that +// theirs does not. +var ErrRunNotLive = errors.New("pgstore: the run is not live") + +// ErrStopRequested is a restart refused because the run it would restart has been asked to stop. Not +// an error of the caller: the reconciler ends the run instead. +var ErrStopRequested = errors.New("pgstore: the run has been asked to stop") + +// RequestStop records that THIS platform asked a run to stop, and hands back the unit to ask. +// +// The order is the whole design (migration 00014): the intent is COMMITTED before systemd is +// touched, so a platform that dies between the two still knows on its next sweep that the run it +// finds ended was stopped on purpose — and, just as important, that it must not restart it. +// +// Idempotent by coalesce: pressing stop twice is one intent with the FIRST timestamp, because the +// timestamp is evidence about which of the two events came first and a later one would erase that. +// +// The revision it answers with is the BOOK's — see ReadRun for why every run-carrying response uses +// one counter. +func (s *Store) RequestStop(ctx context.Context, userID, runID string, now time.Time) (Run, string, error) { + const q = ` + update runs r set stop_requested_at = coalesce(r.stop_requested_at, $3) + from books b + where r.id = $1 and b.id = r.book_id and b.owner_id = $2 and r.finished_at is null + returning r.id, r.book_id, b.revision, r.status, r.verify_bank, r.ceiling_chapters, + coalesce(r.paused_reason, ''), r.started_at, r.finished_at, + coalesce((select a.unit_name from run_attempts a + where a.run_id = r.id and a.ended_at is null + order by a.attempt_no desc limit 1), '')` + var out Run + var unit string + err := s.pool.QueryRow(ctx, q, runID, userID, now).Scan(&out.ID, &out.BookID, &out.Revision, + &out.Status, &out.VerifyBank, &out.CeilingChapters, &out.PausedReason, &out.StartedAt, + &out.FinishedAt, &unit) + if errors.Is(err, pgx.ErrNoRows) { + // Nothing matched, and the two reasons need different answers. Asked separately and only on + // this path, so the ordinary stop stays one round trip. + return Run{}, "", s.whyNotLive(ctx, userID, runID) + } + if err != nil { + return Run{}, "", fmt.Errorf("pgstore: request stop: %w", err) + } + return out, unit, nil +} + +func (s *Store) whyNotLive(ctx context.Context, userID, runID string) error { + var visible bool + if err := s.pool.QueryRow(ctx, ` + select exists (select 1 from runs r join books b on b.id = r.book_id + where r.id = $1 and b.owner_id = $2)`, runID, userID).Scan(&visible); err != nil { + return fmt.Errorf("pgstore: read run: %w", err) + } + if visible { + return ErrRunNotLive + } + return ErrNoRun +} + +// ReadRunForResume loads a run and its LAST attempt, whether or not either is still live. +// +// The reconciler's own list is deliberately not reusable here: it selects the attempt that has not +// ended, and every run this call is about has ended. What resume needs is the state the run stopped +// in and the attempt whose money and journal position it stopped at. +func (s *Store) ReadRunForResume(ctx context.Context, userID, runID string) (LiveRun, error) { + rows, err := s.queryRuns(ctx, ` + join run_attempts a on a.run_id = r.id + and a.attempt_no = (select max(attempt_no) from run_attempts + where run_id = r.id) + left join reservations res on res.engine_run_id = r.id || '#' || a.attempt_no + and res.state = 'open' + where r.id = $1 and b.owner_id = $2`, runID, userID) + if err != nil { + return LiveRun{}, err + } + if len(rows) == 0 { + return LiveRun{}, ErrNoRun + } + return rows[0], nil +} + +// ReadRun is the run row as the contract projects it, read under the caller's ownership. +// +// ⚠ The revision is the BOOK's, not the `runs.revision` column, and that is the contract rather than +// a shortcut: "the counter is PER BOOK — every book-scoped read and the id of every stream frame of +// that book's run carry the same number" (§Revision), and a client MUST DROP a read whose revision is +// below one it has applied. The book card already answers this way (httpapi getBook); a handle that +// answered from the run's own column would hand the client a number below the card's and the client, +// obeying the contract, would drop the answer to the button it just pressed. +func (s *Store) ReadRun(ctx context.Context, userID, runID string) (Run, error) { + const q = ` + select r.id, r.book_id, b.revision, r.status, r.verify_bank, r.ceiling_chapters, + coalesce(r.paused_reason, ''), r.started_at, r.finished_at + from runs r join books b on b.id = r.book_id + where r.id = $1 and b.owner_id = $2` + var out Run + err := s.pool.QueryRow(ctx, q, runID, userID).Scan(&out.ID, &out.BookID, &out.Revision, &out.Status, + &out.VerifyBank, &out.CeilingChapters, &out.PausedReason, &out.StartedAt, &out.FinishedAt) + if errors.Is(err, pgx.ErrNoRows) { + return Run{}, ErrNoRun + } + if err != nil { + return Run{}, fmt.Errorf("pgstore: read run: %w", err) + } + return out, nil +} + // RestartInput is one interrupted attempt being replaced. type RestartInput struct { RunID string @@ -311,12 +427,48 @@ func (s *Store) RestartRun(ctx context.Context, in RestartInput) (LiveRun, error if err := lockBook(ctx, tx, in.BookID); err != nil { return err } + // The RUN row second — the order lockBook writes down, which this transaction used to take + // last, after the money. Taking it here also does the work below: what is read from the row is + // read under the lock that will do the writing. + var stopRequested, finished *time.Time + if err := tx.QueryRow(ctx, + `select stop_requested_at, finished_at from runs where id = $1 for update`, in.RunID). + Scan(&stopRequested, &finished); err != nil { + if errors.Is(err, pgx.ErrNoRows) { + return ErrNoRun + } + return fmt.Errorf("pgstore: lock run: %w", err) + } + // A stop asked for on a run that is STILL LIVE outranks a restart, and the check belongs here + // rather than in the caller's snapshot: the reconciler decides to restart, spends seconds + // settling (a `tmctl status` call), and the user presses stop inside that window. Restarting + // then cleared the fresh intent, opened a second attempt and took a new hold — the user had a + // 202 for a stop that never happened and paid for the work they had just cancelled. + // + // On a FINISHED run the same column is history: a resume re-opens a run that was stopped, and + // the intent belongs to the life that ended — it is cleared below with the rest of what said + // the run was over. + // + // A stop arriving DURING this transaction is not lost either: it waits on this row lock and + // lands on the re-opened run, which the next sweep then stops. + if stopRequested != nil && finished == nil { + return ErrStopRequested + } + // The previous attempt is closed if it is still open, and left exactly as it is if it is not. + // Both callers arrive here: the reconciler replaces an attempt that was INTERRUPTED and is + // still open, while a resume continues a run whose attempt already ended with a verdict of its + // own — and overwriting that verdict would erase how the run the user stopped actually ended. + // + // What serializes two callers is no longer this row but the next one: `unique (run_id, + // attempt_no)` lets exactly one of them insert attempt N+1, and the loser gets the same + // ErrNoRun it always got. var attemptNo int if err := tx.QueryRow(ctx, ` - update run_attempts set ended_at = $2, exit_result = 'interrupted' - where id = $1 and ended_at is null returning attempt_no`, in.AttemptID, in.Now).Scan(&attemptNo); err != nil { + update run_attempts set ended_at = coalesce(ended_at, $2), + exit_result = coalesce(exit_result, 'interrupted') + where id = $1 returning attempt_no`, in.AttemptID, in.Now).Scan(&attemptNo); err != nil { if errors.Is(err, pgx.ErrNoRows) { - return ErrNoRun // another sweep got there first + return ErrNoRun // the attempt is gone } return fmt.Errorf("pgstore: close interrupted attempt: %w", err) } @@ -333,17 +485,41 @@ func (s *Store) RestartRun(ctx context.Context, in RestartInput) (LiveRun, error from run_attempts prev where prev.id = $6 returning id, engine_binary`, in.RunID, next, in.Now, in.Offset, in.EngineBinary, in.AttemptID). Scan(&id, &pinned); err != nil { + if isUnique(err, "run_attempts_run_id_attempt_no_key") { + return ErrNoRun // another caller opened this attempt first + } return fmt.Errorf("pgstore: open next attempt: %w", err) } if err := holdTx(ctx, tx, in.UserID, in.BookID, engineRunKey(in.RunID, next), in.Ceiling, in.Now); err != nil { return err } + // finished_at, settled_at and stop_requested_at are CLEARED, and each for its own reason. The + // reconciler's restart works on a run where all three are already null, so there it changes + // nothing; a RESUME re-opens a run that ended, and leaving them would mean a live run the + // one-live-per-book index does not see, money marked resolved that this attempt has not spent + // yet, and a stop request from the previous life that would classify this attempt's ending as + // a stop nobody asked for. if _, err := tx.Exec(ctx, ` - update runs set status = 'translating', paused_reason = null, + update runs set status = 'translating', paused_reason = null, finished_at = null, + settled_at = null, stop_requested_at = null, revision = (select revision + 1 from books where id = runs.book_id) where id = $1`, in.RunID); err != nil { + // Clearing finished_at puts the run back under the one-live-run-per-book index, and the + // book may already have a NEWER live run — nothing stops an account starting one after it + // stopped this one. That is a conflict and not a failure: the whole transaction rolls back, + // so the hold taken three lines above is undone with it, and the caller gets the same error + // a second admission would have got. + if isUnique(err, "runs_one_live_per_book") { + return ErrRunInFlight + } return fmt.Errorf("pgstore: reopen run: %w", err) } + if _, err := tx.Exec(ctx, ` + update books set status = 'translating', + revision = `+nextRevisionOfThisBooksLibrary+` where id = $1`, + in.BookID); err != nil { + return fmt.Errorf("pgstore: mark book translating: %w", err) + } out = LiveRun{RunID: in.RunID, BookID: in.BookID, UserID: in.UserID, AttemptID: id, AttemptNo: next, Ceiling: in.Ceiling, StartedAt: in.Now, AttemptStartedAt: in.Now, EngineBinary: pinned, Position: Position{Offset: in.Offset}} @@ -403,21 +579,49 @@ func (s *Store) PauseRun(ctx context.Context, runID string, attemptID int64, rea } return fmt.Errorf("pgstore: lock book: %w", err) } + // A stop the user asked for outranks a pause, and the check belongs inside this TRANSACTION, + // under the same locks: the reconciler decides to pause after settling — seconds of a + // `tmctl status` call — and a stop landing inside that window would otherwise be answered with + // `paused/credit_exhausted`, which + // says the money ran out when what happened is that its owner stopped it. + var stopRequested *time.Time + if err := tx.QueryRow(ctx, + `select stop_requested_at from runs where id = $1 for update`, runID).Scan(&stopRequested); err != nil { + if errors.Is(err, pgx.ErrNoRows) { + return nil + } + return fmt.Errorf("pgstore: lock run: %w", err) + } + if stopRequested != nil { + return ErrStopRequested + } + // The attempt being closed must still be a LIVE attempt OF THIS RUN — the third path to carry + // the guard `FinishRun` (PD-181) and `FinishUnspawnedStop` (FP5-2) already have, and it was + // missing here. Without it a pass holding an old snapshot pauses a run whose attempt has since + // been restarted: the run reads `finished`, the SECOND attempt's hold stays open and falls out + // of both `ListLiveRuns` (the run is finished) and `UnsettledRuns` (the attempt is not), and an + // engine keeps spending under a run its owner is told ran out of money. Found by cross-family + // review of the acceptance dofix. if err := tx.QueryRow(ctx, ` update runs set status = 'paused', paused_reason = $2, finished_at = $3, revision = (select revision + 1 from books where id = runs.book_id) - where id = $1 and finished_at is null returning book_id`, runID, reason, now).Scan(&bookID); err != nil { + where id = $1 and finished_at is null + and exists (select 1 from run_attempts a + where a.id = $4 and a.run_id = runs.id and a.ended_at is null) + returning book_id`, runID, reason, now, attemptID).Scan(&bookID); err != nil { if errors.Is(err, pgx.ErrNoRows) { return nil } return fmt.Errorf("pgstore: pause run: %w", err) } if _, err := tx.Exec(ctx, ` - update run_attempts set ended_at = coalesce(ended_at, $2) where id = $1`, attemptID, now); err != nil { + update run_attempts set ended_at = coalesce(ended_at, $3) + where id = $1 and run_id = $2`, attemptID, runID, now); err != nil { return fmt.Errorf("pgstore: end attempt: %w", err) } if _, err := tx.Exec(ctx, ` - update books set status = 'paused', revision = revision + 1 where id = $1`, bookID); err != nil { + update books set status = 'paused', + revision = `+nextRevisionOfThisBooksLibrary+` where id = $1`, bookID); err != nil { return fmt.Errorf("pgstore: pause book: %w", err) } return nil @@ -467,12 +671,24 @@ type SpawnRecord struct { // Written BEFORE the unit is created, so a crash between the two leaves a record to reconcile rather // than an unattributable process. // -// claimed is false when the attempt already has a unit name. It is a COMPARE-AND-SET rather than a -// plain update because two callers legitimately reach here at once — the queue worker that was -// handed the run and the reconciler that found it unspawned — and the loser must not start a second -// engine. systemd would refuse the duplicate NAME, so the accident was survivable; surviving by -// someone else's uniqueness rule is not the same as being correct, and the day a resume changes the -// naming it stops holding. +// claimed is false when the attempt already has a unit name, when the attempt has ENDED, or when its +// run is over. It is a COMPARE-AND-SET rather than a plain update because two callers legitimately +// reach here at once — the queue worker that was handed the run and the reconciler that found it +// unspawned — and the loser must not start a second engine. systemd would refuse the duplicate NAME, +// so that accident was survivable; surviving by someone else's uniqueness rule is not the same as +// being correct, and the day a resume changes the naming it stops holding. +// +// ⚠ The three conditions BESIDES the unit name are money, and they were not here until a run could be +// stopped before it ever spawned. The window: a worker sits inside `bookMeter` for the seconds a +// `tmctl status` takes, the user stops the run, the sweep ends it and gives the whole hold back +// (nothing was spawned) — and the worker then wakes up and creates a unit for a run that is finished +// and settled. That engine would spend against its own book cap with NO open reservation, and +// nothing would ever look at it: the reconciler lists runs by `finished_at is null` and settlements +// by an open reservation, so it is in neither list. The stop INTENT is asked about one step earlier +// for the same reason: it lands while the worker is inside that same `bookMeter`, before any sweep +// could have finished the run, and a claim granted then starts an engine for work its owner +// cancelled before it began. All three are asked in the statement that claims, because a check made +// before it is a check with a window after it. func (s *Store) RecordSpawn(ctx context.Context, r SpawnRecord) (claimed bool, err error) { // What a PREVIOUS claim of this attempt decided is kept. The claim is given back when the unit // could not be created (ReleaseSpawnClaim), and "could not be created" is not the same as "was not @@ -487,7 +703,9 @@ func (s *Store) RecordSpawn(ctx context.Context, r SpawnRecord) (claimed bool, e ceiling_arg_micro_usd = case when spend_baseline_micro_usd is null then $5 else ceiling_arg_micro_usd end, spend_baseline_micro_usd = coalesce(spend_baseline_micro_usd, $6) - where id = $1 and unit_name is null`, + where id = $1 and unit_name is null and ended_at is null + and exists (select 1 from runs r where r.id = run_attempts.run_id + and r.finished_at is null and r.stop_requested_at is null)`, r.AttemptID, r.Unit, r.Binary, int64(r.Ceiling), int64(r.CeilingArg), int64(r.Baseline)) if err != nil { return false, fmt.Errorf("pgstore: record spawn: %w", err) diff --git a/platform/internal/pgstore/runs_test.go b/platform/internal/pgstore/runs_test.go index 4d8863b7..dad80c03 100644 --- a/platform/internal/pgstore/runs_test.go +++ b/platform/internal/pgstore/runs_test.go @@ -425,11 +425,11 @@ func TestFinishingARunTwiceChangesNothingTheSecondTime(t *testing.T) { t.Fatal(err) } code := 0 - if err := s.FinishRun(ctx, run.ID, run.AttemptID, "ready", "success", &code, now); err != nil { + if _, err := s.FinishRun(ctx, run.ID, run.AttemptID, "ready", "success", &code, now); err != nil { t.Fatal(err) } later := now.Add(time.Hour) - if err := s.FinishRun(ctx, run.ID, run.AttemptID, "failed", "exit-code", nil, later); err != nil { + if _, err := s.FinishRun(ctx, run.ID, run.AttemptID, "failed", "exit-code", nil, later); err != nil { t.Fatal(err) } var status string @@ -465,7 +465,7 @@ func TestAFinishedRunWithAnOpenHoldIsListedAsUnsettled(t *testing.T) { t.Fatal(err) } code := 0 - if err := s.FinishRun(ctx, run.ID, run.AttemptID, "ready", "success", &code, now); err != nil { + if _, err := s.FinishRun(ctx, run.ID, run.AttemptID, "ready", "success", &code, now); err != nil { t.Fatal(err) } open, err := s.UnsettledRuns(ctx) @@ -750,7 +750,7 @@ func TestTheSettlementWorklistAndItsStampAreBothIdempotent(t *testing.T) { t.Fatal(err) } code := 0 - if err := s.FinishRun(ctx, run.ID, run.AttemptID, "ready", "success", &code, now); err != nil { + if _, err := s.FinishRun(ctx, run.ID, run.AttemptID, "ready", "success", &code, now); err != nil { t.Fatal(err) } if err := s.Settle(ctx, ReservationKey(run.ID, 1), money.MicroUSD(100_000), now); err != nil { @@ -904,3 +904,106 @@ func TestOnlyOneSweepCanRestartAnInterruptedAttempt(t *testing.T) { t.Errorf("%d attempts after four concurrent restarts", attempts) } } + +// Cross-family review of the acceptance dofix (M1): a pause is a CLOSING path, and every closing path +// must check that the attempt it closes is still a live attempt of this run. `FinishRun` and +// `FinishUnspawnedStop` do; `PauseRun` did not, so a pass holding a snapshot of the attempt that was +// already restarted answered "the credit ran out" for a run whose second attempt is spending right +// now — and that second hold then belongs to no list at all. +func TestAPauseFromAnOldSnapshotDoesNotCloseARunOverALiveAttempt(t *testing.T) { + s, ctx := testDB(t) + now := fundedAccount(t, s, ctx, "u1", "10") + seedBook(t, s, ctx, "bk1", "u1", 500) + run, err := s.StartRun(ctx, StartRunInput{UserID: "u1", BookID: "bk1", CeilingChapters: 100, + Ceiling: money.MicroUSD(3_000_000), Now: now}, 0, nil) + if err != nil { + t.Fatal(err) + } + if err := s.Settle(ctx, ReservationKey(run.ID, 1), money.MicroUSD(500_000), now); err != nil { + t.Fatal(err) + } + next, err := s.RestartRun(ctx, RestartInput{RunID: run.ID, AttemptID: run.AttemptID, UserID: "u1", + BookID: "bk1", Ceiling: money.MicroUSD(1_000_000), Now: now}) + if err != nil { + t.Fatal(err) + } + if err := s.PauseRun(ctx, run.ID, run.AttemptID, PausedCreditExhausted, now); err != nil { + t.Fatal(err) + } + var status string + var finished *time.Time + if err := s.pool.QueryRow(ctx, + `select status, finished_at from runs where id = $1`, run.ID).Scan(&status, &finished); err != nil { + t.Fatal(err) + } + if finished != nil { + t.Fatalf("a stale pause closed the run as %q while attempt %d is live", status, next.AttemptID) + } + var ended *time.Time + if err := s.pool.QueryRow(ctx, + `select ended_at from run_attempts where id = $1`, next.AttemptID).Scan(&ended); err != nil { + t.Fatal(err) + } + if ended != nil { + t.Fatal("a stale pause ended the LIVE attempt") + } + // The money half of the same fact: the live attempt's hold must be findable. A closed run with an + // open hold is in neither list — not in the live runs (the run is finished) and not in the + // unsettled ones (the attempt is not) — which is how a hold is lost for good. + live, err := s.ListLiveRuns(ctx) + if err != nil { + t.Fatal(err) + } + found := false + for _, l := range live { + if l.AttemptID == next.AttemptID { + found = true + } + } + if !found { + t.Fatalf("the live attempt %d is not in ListLiveRuns after a stale pause", next.AttemptID) + } +} + +// Re-check of the dofix (FP5-11): the rule the intake writers got — take the LIBRARY's next number, +// not your own plus one — has to hold for the run's writers too. A book that is not the account's +// newest carries a counter below the library's, so `translating`, `paused` and `stopped` on it left +// `greatest(max, floor)` exactly where it was and the library screen never saw the run start. +func TestARunTransitionOnAnOlderBookStillMovesTheLibrary(t *testing.T) { + s, ctx := testDB(t) + now := fundedAccount(t, s, ctx, "u1", "10") + seedBook(t, s, ctx, "bk1", "u1", 500) + // A second, NEWER book carries the library's maximum, so bk1's own counter is not it. + seedBook(t, s, ctx, "bk2", "u1", 500) + exec(t, s, ctx, `update books set revision = 41 where id = 'bk2'`) + before, err := s.ListBooks(ctx, "u1", 50, "") + if err != nil { + t.Fatal(err) + } + run, err := s.StartRun(ctx, StartRunInput{UserID: "u1", BookID: "bk1", CeilingChapters: 10, + Ceiling: money.MicroUSD(300_000), Now: now}, 0, nil) + if err != nil { + t.Fatal(err) + } + after, err := s.ListBooks(ctx, "u1", 50, "") + if err != nil { + t.Fatal(err) + } + if after.Revision <= before.Revision { + t.Fatalf("a run started on an older book and the library still answers %d (was %d): a client "+ + "obeying the contract drops that read and never sees the book start translating", + after.Revision, before.Revision) + } + // And the same for the closing half of the lifecycle. + mid := after.Revision + if err := s.PauseRun(ctx, run.ID, run.AttemptID, PausedCreditExhausted, now); err != nil { + t.Fatal(err) + } + end, err := s.ListBooks(ctx, "u1", 50, "") + if err != nil { + t.Fatal(err) + } + if end.Revision <= mid { + t.Fatalf("the run was paused and the library still answers %d (was %d)", end.Revision, mid) + } +} diff --git a/platform/internal/pgstore/sink.go b/platform/internal/pgstore/sink.go index e5314f08..337676cb 100644 --- a/platform/internal/pgstore/sink.go +++ b/platform/internal/pgstore/sink.go @@ -34,15 +34,23 @@ func (s *Store) NewRunSink(attemptID int64, runID, bookID string) *RunSink { // The binding is refused if the attempt already carries a DIFFERENT id: that means two engine // processes wrote into one journal under one attempt, and materializing either of them would mix // two runs' counters into one projection. +// +// It is refused for an ENDED attempt too, and that half is newer than the rule above. A stop that +// landed before its engine was spawned leaves an attempt that is closed and carries no engine run id +// — a shape this zone could not produce before P5 — and an unbound, ended attempt would otherwise +// ADOPT the handshake of the attempt that replaced it: the same events would then be materialized +// twice, once per attempt, and every counter they increment would be counted twice. Found by +// cross-family review of the acceptance dofix. func (r *RunSink) Begin(ctx context.Context, h ingest.Hello) error { tag, err := r.store.pool.Exec(ctx, ` update run_attempts set engine_run_id = $2 - where id = $1 and (engine_run_id is null or engine_run_id = $2)`, r.attemptID, h.EngineRunID) + where id = $1 and ended_at is null + and (engine_run_id is null or engine_run_id = $2)`, r.attemptID, h.EngineRunID) if err != nil { return fmt.Errorf("pgstore: bind engine run: %w", err) } if tag.RowsAffected() == 0 { - return fmt.Errorf("pgstore: attempt %d is already bound to another engine run", r.attemptID) + return fmt.Errorf("pgstore: attempt %d is over, or already bound to another engine run", r.attemptID) } if h.ChunkerVersion != "" { if _, err := r.store.pool.Exec(ctx, @@ -257,11 +265,23 @@ func (s *Store) ApplyStatus(ctx context.Context, runID, bookID string, rep inges // FinishRun closes a run and its attempt: the read-model status, the end of the attempt and what // systemd said about it. Money is NOT touched here — see Settlement. -func (s *Store) FinishRun(ctx context.Context, runID string, attemptID int64, status, exitResult string, exitCode *int, now time.Time) error { +// +// closed is false when the write did not apply, and the caller must then do nothing else: the run was +// already finished, or — the case that made this a bool — the attempt it was asked to close is no +// longer the run's live one. +// +// ⚠ That second guard is money. A sweep decides from a snapshot and writes seconds later; between the +// two, the user can stop the run and RESUME it, and the resumed run is live again with a second +// attempt holding a second reservation. The old attempt's exit marker is still on disk — nothing +// deletes markers of attempts that ended — so the stale pass would close the run from it, leaving +// attempt 2's hold in NO worklist: `ListLiveRuns` selects runs with `finished_at is null` and +// `UnsettledRuns` attempts with `ended_at is not null`, and the resumed run matches neither once it +// has been re-finished. Reachable only since a run can come back to life at all, which is this pack. +func (s *Store) FinishRun(ctx context.Context, runID string, attemptID int64, status, exitResult string, exitCode *int, now time.Time) (closed bool, err error) { if !validRunStatus(status) { - return fmt.Errorf("pgstore: %q is not a run status", status) + return false, fmt.Errorf("pgstore: %q is not a run status", status) } - return s.inTx(ctx, func(tx pgx.Tx) error { + err = s.inTx(ctx, func(tx pgx.Tx) error { // The BOOK's row is locked first, here and in the materializer. The two used to take them in // opposite orders — the materializer books-then-runs, this one runs-then-books — and two // reconcilers on one run (overlapping deploy generations) then deadlocked in both directions; @@ -278,9 +298,14 @@ func (s *Store) FinishRun(ctx context.Context, runID string, attemptID int64, st if err := tx.QueryRow(ctx, ` update runs set status = $2, finished_at = $3, revision = (select revision + 1 from books where id = runs.book_id) - where id = $1 and finished_at is null returning book_id`, runID, status, now).Scan(&bookID); err != nil { + where id = $1 and finished_at is null + and exists (select 1 from run_attempts a + where a.id = $4 and a.run_id = runs.id and a.ended_at is null) + returning book_id`, runID, status, now, attemptID).Scan(&bookID); err != nil { if errors.Is(err, pgx.ErrNoRows) { - return nil // already finished: finishing is idempotent by design, the reconciler retries + // Already finished, or asked about an attempt that is no longer the live one. Both are + // "someone else got here first"; finishing is idempotent by refusal, not by repetition. + return nil } return fmt.Errorf("pgstore: finish run: %w", err) } @@ -290,11 +315,14 @@ func (s *Store) FinishRun(ctx context.Context, runID string, attemptID int64, st return fmt.Errorf("pgstore: finish attempt: %w", err) } if _, err := tx.Exec(ctx, ` - update books set status = $2, revision = revision + 1 where id = $1`, bookID, status); err != nil { + update books set status = $2, + revision = `+nextRevisionOfThisBooksLibrary+` where id = $1`, bookID, status); err != nil { return fmt.Errorf("pgstore: finish book: %w", err) } + closed = true return nil }) + return closed, err } func validRunStatus(s string) bool { @@ -305,6 +333,82 @@ func validRunStatus(s string) bool { return false } +// FinishUnspawnedStop closes a run that was stopped before its unit ever existed, and only if that is +// still true when the write happens. +// +// The re-check is the whole method. The reconciler decides from a snapshot in which the attempt had +// no unit; between that read and this write the queue worker can claim the attempt and create one, +// and closing the run then leaves an engine spending against a book with no open reservation and no +// list that looks at it — the reconciler lists by `finished_at is null` and settlement by an open +// reservation, so it would be in neither. `ReleaseUnspawned` already guards the MONEY of exactly this +// window under a lock (PD-159); this is the same guard for the lifecycle the money follows. +// +// finished is false when the attempt was spawned after all: the caller does nothing, and the next +// sweep meets an ordinary live run — with a unit to signal and an intent that says to. +func (s *Store) FinishUnspawnedStop(ctx context.Context, runID string, attemptID int64, now time.Time) (finished bool, err error) { + err = s.inTx(ctx, func(tx pgx.Tx) error { + // Book first, then the attempt: the order every transaction in this package takes (lockBook). + var bookID string + if err := tx.QueryRow(ctx, + `select id from books where id = (select book_id from runs where id = $1) for update`, + runID).Scan(&bookID); err != nil { + if errors.Is(err, pgx.ErrNoRows) { + return nil // the run is gone + } + return fmt.Errorf("pgstore: lock book: %w", err) + } + var unit *string + var ended *time.Time + if err := tx.QueryRow(ctx, + `select unit_name, ended_at from run_attempts where id = $1 and run_id = $2 for update`, + attemptID, runID).Scan(&unit, &ended); err != nil { + if errors.Is(err, pgx.ErrNoRows) { + return nil + } + return fmt.Errorf("pgstore: lock attempt: %w", err) + } + if unit != nil && *unit != "" { + return nil // spawned inside the window; not this path's business any more + } + if ended != nil { + // The attempt this pass is holding is over, which means the run moved on without it — a stop + // and a RESUME can both have happened since the snapshot was taken, and the run is live + // again on a second attempt holding a second reservation. Closing it from here would put + // that hold in no worklist at all. The same guard FinishRun carries (PD-181); acceptance + // found this path missing it. + return nil + } + tag, err := tx.Exec(ctx, ` + update runs set status = 'stopped', finished_at = $2, + revision = (select revision + 1 from books where id = runs.book_id) + where id = $1 and finished_at is null`, runID, now) + if err != nil { + return fmt.Errorf("pgstore: finish stopped run: %w", err) + } + if tag.RowsAffected() == 0 { + return nil // already finished by an earlier pass + } + if _, err := tx.Exec(ctx, ` + update run_attempts set ended_at = $2, exit_result = $3 + where id = $1 and ended_at is null`, attemptID, now, StopRequestedResult); err != nil { + return fmt.Errorf("pgstore: finish attempt: %w", err) + } + if _, err := tx.Exec(ctx, ` + update books set status = 'stopped', + revision = `+nextRevisionOfThisBooksLibrary+` where id = $1`, bookID); err != nil { + return fmt.Errorf("pgstore: finish book: %w", err) + } + finished = true + return nil + }) + return finished, err +} + +// StopRequestedResult is the exit_result of an attempt that ended by this platform's request without +// systemd getting to write a marker. Deliberately a value systemd cannot produce: an operator reading +// the column must be able to tell what the machine saw from what the platform concluded. +const StopRequestedResult = "stop-requested" + // MarkSettled records that the money of a run has been resolved. func (s *Store) MarkSettled(ctx context.Context, runID string, now time.Time) error { _, err := s.pool.Exec(ctx, `update runs set settled_at = $2 where id = $1 and settled_at is null`, runID, now) diff --git a/platform/internal/pgstore/sink_test.go b/platform/internal/pgstore/sink_test.go index 73e86b64..5a8667fc 100644 --- a/platform/internal/pgstore/sink_test.go +++ b/platform/internal/pgstore/sink_test.go @@ -194,7 +194,8 @@ func TestAMaterializerAndAReconcilerOnOneRunDoNotDeadlock(t *testing.T) { }() go func() { defer wg.Done() - errs <- s.FinishRun(ctx, runID, run.AttemptID, "ready", "exit-code", nil, now) + _, err := s.FinishRun(ctx, runID, run.AttemptID, "ready", "exit-code", nil, now) + errs <- err }() go func() { defer wg.Done() @@ -378,3 +379,33 @@ func TestAResyncRecordsWhenItWasTaken(t *testing.T) { t.Errorf("the second resync left the stamp at %v, want %s", stamp, later) } } + +// Cross-family review of the acceptance dofix (M2): a stop that lands before its engine was spawned +// leaves an attempt that is ENDED and carries no engine run id — a shape this zone could not produce +// before the stop path existed. An old materializer still holding that attempt would then bind it to +// the handshake of the attempt that REPLACED it and apply the same journal a second time, counting +// every unit and chapter twice. Binding is refused for an attempt that is over. +func TestAMaterializerOfAnEndedAttemptDoesNotAdoptTheNextAttemptsEngine(t *testing.T) { + s, ctx := testDB(t) + now := fundedAccount(t, s, ctx, "u1", "10") + seedBook(t, s, ctx, "bk1", "u1", 500) + run, err := s.StartRun(ctx, StartRunInput{UserID: "u1", BookID: "bk1", CeilingChapters: 10, + Ceiling: money.MicroUSD(300_000), Now: now}, 0, nil) + if err != nil { + t.Fatal(err) + } + // The stop-before-spawn shape: the attempt is closed without ever having been bound. + exec(t, s, ctx, `update run_attempts set ended_at = now() where id = $1`, run.AttemptID) + stale := s.NewRunSink(run.AttemptID, run.ID, "bk1") + if err := stale.Begin(ctx, ingest.Hello{EngineRunID: "eng-of-the-next-attempt"}); err == nil { + t.Fatal("an ended attempt adopted an engine run: its journal would be materialized twice") + } + var bound *string + if err := s.pool.QueryRow(ctx, + `select engine_run_id from run_attempts where id = $1`, run.AttemptID).Scan(&bound); err != nil { + t.Fatal(err) + } + if bound != nil { + t.Fatalf("the ended attempt was bound to %q", *bound) + } +} diff --git a/platform/internal/runner/engine.go b/platform/internal/runner/engine.go index dbd5f09f..c687178a 100644 --- a/platform/internal/runner/engine.go +++ b/platform/internal/runner/engine.go @@ -5,6 +5,7 @@ import ( "context" "errors" "fmt" + "io" "os/exec" "path/filepath" "strings" @@ -96,6 +97,57 @@ func StatusArgs(workdir string) []string { return []string{"status", "--config", filepath.Join(workdir, ConfigFile), "--json"} } +// ManifestArgs is the intake channel: `tmctl manifest --config --json`. +// +// $0 and key-less like `status` — it ingests and cuts the source and writes a sidecar, with no +// provider call and no wave — and it is the ONE engine command the platform runs before a book has +// ever been translated. ⚠ Its first touch of a project CREATES and migrates the engine's database +// (ratified D39.122), which is why intake needs the book's directory writable and why it is this +// command rather than `status`: `status` has the same side effect and answers a different question. +func ManifestArgs(workdir string) []string { + return []string{"manifest", "--config", filepath.Join(workdir, ConfigFile), "--json"} +} + +// maxManifest bounds what the platform will read from the manifest command. The document is counts +// and identities — no source text — so a real one is single-digit megabytes even for a 2283-chapter +// book; the cap is against a process that is not the engine, not against the engine. +const maxManifest = 64 << 20 + +// Manifest builds the book's chapter tree and returns the allowlisted summary of it. +func (r *Runner) Manifest(ctx context.Context, binary, workdir string) (ingest.Manifest, error) { + cmd := exec.CommandContext(ctx, binary, ManifestArgs(workdir)...) + cmd.Dir = workdir + var errOut bytes.Buffer + cmd.Stderr = &errOut + out, err := cmd.StdoutPipe() + if err != nil { + return ingest.Manifest{}, fmt.Errorf("runner: tmctl manifest: %w", err) + } + if err := cmd.Start(); err != nil { + return ingest.Manifest{}, fmt.Errorf("runner: tmctl manifest: %w", err) + } + doc, readErr := io.ReadAll(io.LimitReader(out, maxManifest+1)) + // Whatever is left is DRAINED, and that is not tidiness: a pipe nobody reads blocks the child on + // its next write and Wait then blocks on the child. Stopping at the cap without draining does not + // truncate the document — it hangs the caller, or, once the pipe is closed, kills the engine with + // EPIPE and reports that as a host which cannot run the engine. + _, _ = io.Copy(io.Discard, out) + if err := cmd.Wait(); err != nil { + // The engine's stderr is engine vocabulary and must not become a platform log line verbatim, + // let alone a product phrase; what travels is the fact and the first line. The error is + // returned UNWRAPPED as an *exec.ExitError underneath, because the caller tells "the engine + // answered no" from "the engine could not be run" by exactly that type. + return ingest.Manifest{}, fmt.Errorf("runner: tmctl manifest: %w: %s", err, firstLine(errOut.Bytes())) + } + if readErr != nil { + return ingest.Manifest{}, fmt.Errorf("runner: read manifest: %w", readErr) + } + if int64(len(doc)) > maxManifest { + return ingest.Manifest{}, fmt.Errorf("runner: the manifest is over %d bytes, which no book produces", maxManifest) + } + return ingest.DecodeManifest(doc) +} + // Status runs the reconciliation channel and decodes the allowlisted subset of its report. func (r *Runner) Status(ctx context.Context, binary, workdir string) (ingest.StatusReport, error) { cmd := exec.CommandContext(ctx, binary, StatusArgs(workdir)...) diff --git a/platform/internal/runner/manifest_test.go b/platform/internal/runner/manifest_test.go new file mode 100644 index 00000000..4966ffd2 --- /dev/null +++ b/platform/internal/runner/manifest_test.go @@ -0,0 +1,127 @@ +package runner + +import ( + "bytes" + "errors" + "go/parser" + "go/printer" + "go/token" + "os" + "os/exec" + "path/filepath" + "strings" + "testing" +) + +// The intake channel is the one engine command the platform runs before a book has ever been +// translated. These tests run a REAL process — a shell script standing in for tmctl — because what +// the caller decides on is the difference between "the engine answered no" and "the engine could not +// be run", and that difference is a type from os/exec rather than a value the platform chooses. + +// fakeEngine writes an executable that behaves as told and returns its path. +func fakeEngine(t *testing.T, script string) string { + t.Helper() + path := filepath.Join(t.TempDir(), "tmctl") + if err := os.WriteFile(path, []byte("#!/bin/sh\n"+script+"\n"), 0o755); err != nil { + t.Fatal(err) + } + return path +} + +func TestTheManifestCommandIsTheZeroCostOneAndCarriesTheBooksConfig(t *testing.T) { + got := ManifestArgs("/srv/books/bk_1") + want := []string{"manifest", "--config", "/srv/books/bk_1/book.yaml", "--json"} + if strings.Join(got, " ") != strings.Join(want, " ") { + t.Fatalf("argv %v, want %v", got, want) + } +} + +func TestAManifestIsReadForTheFourThingsIntakeDecidesWith(t *testing.T) { + // A document with far more in it than the platform takes: the per-chapter and per-unit arrays are + // the engine's and have no reader on this side. + bin := fakeEngine(t, `cat <<'JSON' +{"manifest_version":"tm-manifest-v2","book_id":"gu","key":"abcdef", + "chunker_version":"chunk-2026.07","source_sha256":"`+strings.Repeat("ab", 32)+`", + "source_bytes":24117248,"source_lang":"zh","target_lang":"ru","encoding":"utf-8", + "chapters_total":2283,"units_total":4402,"chunks_total":9130, + "chapters":[{"id":"0011223344556677","number":1,"heading":"Глава 1","units_total":2, + "chunks_total":4,"units":[{"id":"0011223344556677:aabbccdd:0","first_chunk_idx":0, + "chunk_count":2,"edit_unit_id":1}]}]} +JSON`) + r := New(nil) + m, err := r.Manifest(t.Context(), bin, t.TempDir()) + if err != nil { + t.Fatal(err) + } + if m.ChaptersTotal != 2283 || m.UnitsTotal != 4402 { + t.Errorf("counts %d/%d", m.ChaptersTotal, m.UnitsTotal) + } + if m.Version != "tm-manifest-v2" || m.ChunkerVersion != "chunk-2026.07" { + t.Errorf("identity %q/%q", m.Version, m.ChunkerVersion) + } + if len(m.SourceSHA256Bytes()) != 32 { + t.Errorf("the source digest did not decode: %q", m.SourceSHA256) + } +} + +// The classifier the intake keys on: a process that RAN and refused carries *exec.ExitError, and one +// that could not be run does not. Getting this backwards either retries a book forever or rejects a +// user's file because a host was misconfigured. +func TestARefusalAndAnAbsenceAreDifferentErrors(t *testing.T) { + r := New(nil) + refused := fakeEngine(t, `echo "chunk: ingest txt: not a book" >&2; exit 1`) + _, err := r.Manifest(t.Context(), refused, t.TempDir()) + var exit *exec.ExitError + if !errors.As(err, &exit) { + t.Fatalf("a refusal came back as %T: %v", err, err) + } + _, err = r.Manifest(t.Context(), filepath.Join(t.TempDir(), "no-such-binary"), t.TempDir()) + if errors.As(err, &exit) { + t.Fatalf("a missing binary came back as an exit status: %v", err) + } + // The engine's own words stay out of the platform's error, which is what a product phrase is + // eventually written from (contract §Problem). + if _, err := r.Manifest(t.Context(), refused, t.TempDir()); !strings.Contains(err.Error(), "tmctl manifest") { + t.Errorf("the error does not name what failed: %v", err) + } +} + +func TestAManifestThatIsNotJSONIsAnError(t *testing.T) { + r := New(nil) + bin := fakeEngine(t, `echo "=== MANIFEST: gu ==="`) + if _, err := r.Manifest(t.Context(), bin, t.TempDir()); err == nil { + t.Fatal("a human summary was accepted as a manifest document") + } +} + +// FP5-7 (acceptance): the gate on these tests must ask whether this host CAN run them, not whether +// systemd phrased its refusal the way it did in 2026. It skipped on "Failed to connect to bus" and +// systemd 259 answers "Failed to connect to user scope bus", so on a host with no user manager the +// tests failed instead of skipping — a gate that stopped gating when somebody else edited a string. +func TestTheSystemdGateAsksAboutTheCapabilityAndNotAMessage(t *testing.T) { + src, err := os.ReadFile("systemd_test.go") + if err != nil { + t.Fatal(err) + } + // The CODE only: the comments there explain the history of this very defect and quote the message + // the gate used to match, which a plain text scan would read as the defect itself. + file, err := parser.ParseFile(token.NewFileSet(), "systemd_test.go", src, 0) + if err != nil { + t.Fatal(err) + } + var code bytes.Buffer + if err := printer.Fprint(&code, token.NewFileSet(), file); err != nil { + t.Fatal(err) + } + // ⚠ The assertion is on the WORDS systemd could change, not on one spelling of the old check: + // pinning two exact expressions is a gate that a third spelling walks straight past (found by + // cross-family review of the acceptance dofix). Any occurrence of systemd's refusal text in this + // file means the gate is reading messages again. + if bytes.Contains(code.Bytes(), []byte("Failed to connect")) || + bytes.Contains(code.Bytes(), []byte("No medium found")) { + t.Fatal("the systemd gate matches a message again: it must skip on the failure to reach the manager, whatever systemd calls it") + } + if !bytes.Contains(code.Bytes(), []byte("systemctl")) { + t.Fatal("the systemd gate no longer asks the manager anything: a gate that probes nothing cannot know whether these tests can run") + } +} diff --git a/platform/internal/runner/systemd_test.go b/platform/internal/runner/systemd_test.go index 93518800..11603ec9 100644 --- a/platform/internal/runner/systemd_test.go +++ b/platform/internal/runner/systemd_test.go @@ -1,6 +1,7 @@ package runner import ( + "bytes" "errors" "fmt" "os" @@ -21,9 +22,16 @@ func systemdOrSkip(t *testing.T) { if _, err := exec.LookPath("systemd-run"); err != nil { t.Skip("no systemd-run: the transient-unit properties cannot be measured on this host") } + // The gate is the CAPABILITY, not a sentence: this used to skip on the words "Failed to connect to + // bus" and systemd 259 says "Failed to connect to user scope bus", so on a host without a manager + // the tests FAILED instead of skipping — a gate that stopped gating when someone else edited a + // string. What is asked instead is the thing these tests need: can this process reach its own user + // manager at all. The skip is loud, like the database one, because `make check` names skips and a + // silent one reads as coverage. out, err := exec.CommandContext(t.Context(), "systemctl", "--user", "show", "--property=Version").CombinedOutput() - if err != nil && strings.Contains(string(out), "Failed to connect to bus") { - t.Skip("no systemd user manager: the transient-unit properties cannot be measured on this host") + if err != nil { + t.Skipf("no reachable systemd user manager (%s): the transient-unit properties cannot be measured on this host", + bytes.TrimSpace(out)) } } diff --git a/platform/internal/runs/control_test.go b/platform/internal/runs/control_test.go new file mode 100644 index 00000000..c6a43689 --- /dev/null +++ b/platform/internal/runs/control_test.go @@ -0,0 +1,938 @@ +package runs + +import ( + "errors" + "sync" + "testing" + "time" + + "textmachine/platform/internal/ingest" + "textmachine/platform/internal/money" + "textmachine/platform/internal/pgstore" + "textmachine/platform/internal/runner" +) + +// The control handles — stop and resume — are the two places where a USER's decision reaches a run +// that is not this process's child. What they have to get right is not the systemd call, which is +// one line, but the record around it: a stop that is not written down before the signal cannot be +// told from a crash afterwards, and a resume that forgets the previous attempt's hold reserves the +// run's ceiling twice. + +// stopped drives a fixture to a run the user stopped: admitted, spawned, asked to stop, and then +// reconciled from the marker the unit left. It is the state resume starts from. +// +// spent is what the engine reports AFTER the spawn, which is the only order that models the real +// one: the spawn reads the book's meter to take this attempt's baseline, so a figure set before it +// would be read as money some earlier run had spent and this attempt would settle at nothing. +func (f *fixture) stopped(t *testing.T, chapters int, spent money.MicroUSD, marker runner.Marker) string { + t.Helper() + run, err := f.svc.Start(f.ctx, StartRequest{UserID: "u1", BookID: f.bookID(t), CeilingChapters: chapters}) + if err != nil { + t.Fatal(err) + } + if err := f.svc.Spawn(f.ctx, run.ID); err != nil { + t.Fatal(err) + } + if _, err := f.svc.Stop(f.ctx, "u1", run.ID); err != nil { + t.Fatal(err) + } + live := f.live(t) + marker.Unit = live.UnitName + if err := runner.WriteMarker(f.svc.markerPath(live.RunID, live.AttemptNo), marker); err != nil { + t.Fatal(err) + } + f.engine.set(statusSpending(spent), nil) + if err := f.svc.Sweep(f.ctx); err != nil { + t.Fatal(err) + } + return run.ID +} + +// secondBook registers another book of the same account, with a directory of its own. +func (f *fixture) secondBook(t *testing.T) string { + t.Helper() + id, err := f.store.AddBook(f.ctx, pgstore.NewBook{OwnerID: "u1", Title: "second", SourceLang: "zh", + TargetLang: "ru", ChapterCount: 100, Workdir: t.TempDir(), Now: f.now}) + if err != nil { + t.Fatal(err) + } + return id +} + +// lastAttempt is the snapshot a slow sweep pass would still be holding: the run as it was, with the +// attempt that was live at the time. +func (f *fixture) lastAttempt(t *testing.T, runID string) pgstore.LiveRun { + t.Helper() + l, err := f.store.ReadRunForResume(f.ctx, "u1", runID) + if err != nil { + t.Fatal(err) + } + return l +} + +func (f *fixture) run(t *testing.T, runID string) pgstore.Run { + t.Helper() + r, err := f.store.ReadRun(f.ctx, "u1", runID) + if err != nil { + t.Fatal(err) + } + return r +} + +// The heart of PD-152. The engine catches SIGTERM and exits 1, so the marker of a stop and the +// marker of a crash are the same bytes; what tells them apart is that the platform wrote down its +// own intent first. +func TestARunTheUserStoppedIsNotReportedAsFailed(t *testing.T) { + f := newFixture(t, "10", 500) + runID := f.stopped(t, 100, 0, runner.Marker{Result: "exit-code", Code: "exited", Status: "1", + At: f.now.Add(time.Second)}) + if got := f.run(t, runID); got.Status != "stopped" { + t.Fatalf("a stop the user asked for came back as %q, want stopped", got.Status) + } + // And the same marker WITHOUT the intent is still a failure: the discriminator has to be the + // record, not a new reading of the exit code. + g := newFixture(t, "10", 500) + run, err := g.svc.Start(g.ctx, StartRequest{UserID: "u1", BookID: g.bookID(t), CeilingChapters: 100}) + if err != nil { + t.Fatal(err) + } + if err := g.svc.Spawn(g.ctx, run.ID); err != nil { + t.Fatal(err) + } + live := g.live(t) + if err := runner.WriteMarker(g.svc.markerPath(live.RunID, live.AttemptNo), + runner.Marker{Unit: live.UnitName, Result: "exit-code", Code: "exited", Status: "1"}); err != nil { + t.Fatal(err) + } + if err := g.svc.Sweep(g.ctx); err != nil { + t.Fatal(err) + } + if got := g.run(t, run.ID); got.Status != "failed" { + t.Fatalf("a run nobody stopped came back as %q, want failed", got.Status) + } +} + +// The race the design owes an answer to: a run that finished by itself a moment before someone +// pressed stop was not stopped by them, and calling it `stopped` would hide a completed translation +// behind a cancelled one. +func TestARunThatEndedBeforeTheStopKeepsItsOwnOutcome(t *testing.T) { + f := newFixture(t, "10", 500) + // The marker is written a minute BEFORE the stop request the fixture makes. + runID := f.stopped(t, 100, 0, runner.Marker{Result: "exit-code", Code: "exited", Status: "1", + At: f.now.Add(-time.Minute)}) + if got := f.run(t, runID); got.Status != "failed" { + t.Fatalf("a run that had already ended came back as %q, want failed", got.Status) + } +} + +// The engine's own clean answers outrank a stop that arrived while it was already finishing: a +// translation that COMPLETED must not be shown as cancelled. +func TestACleanExitOutranksAStopThatArrivedTooLate(t *testing.T) { + f := newFixture(t, "10", 500) + runID := f.stopped(t, 100, 0, runner.Marker{Result: "exit-code", Code: "exited", Status: "0", + At: f.now.Add(time.Second)}) + if got := f.run(t, runID); got.Status != "ready" { + t.Fatalf("a run that finished cleanly came back as %q, want ready", got.Status) + } +} + +// The intent is committed BEFORE systemd is asked, and that order is the whole mechanism: a platform +// that died between the two must still know, on its next sweep, that the run was stopped on purpose. +func TestTheStopIsRecordedBeforeSystemdIsAsked(t *testing.T) { + f := newFixture(t, "10", 500) + run, err := f.svc.Start(f.ctx, StartRequest{UserID: "u1", BookID: f.bookID(t), CeilingChapters: 10}) + if err != nil { + t.Fatal(err) + } + if err := f.svc.Spawn(f.ctx, run.ID); err != nil { + t.Fatal(err) + } + var recordedFirst bool + f.runner.onStop = func(string) { + var at *time.Time + if err := f.store.Pool().QueryRow(f.ctx, + `select stop_requested_at from runs where id = $1`, run.ID).Scan(&at); err != nil { + t.Error(err) + return + } + recordedFirst = at != nil + } + if _, err := f.svc.Stop(f.ctx, "u1", run.ID); err != nil { + t.Fatal(err) + } + if !recordedFirst { + t.Fatal("systemd was asked to stop the unit before the intent was committed") + } +} + +// A stop that systemd did not take must not be lost. The intent is durable, so the next sweep asks +// again — which is the only thing that closes "the platform died between the write and the call". +func TestAStopTheUnitDidNotTakeIsAskedAgainByTheSweep(t *testing.T) { + f := newFixture(t, "10", 500) + run, err := f.svc.Start(f.ctx, StartRequest{UserID: "u1", BookID: f.bookID(t), CeilingChapters: 10}) + if err != nil { + t.Fatal(err) + } + if err := f.svc.Spawn(f.ctx, run.ID); err != nil { + t.Fatal(err) + } + if _, err := f.svc.Stop(f.ctx, "u1", run.ID); err != nil { + t.Fatal(err) + } + f.runner.alive = true + if err := f.svc.Sweep(f.ctx); err != nil { + t.Fatal(err) + } + if n := len(f.runner.stops()); n != 2 { + t.Fatalf("the unit was asked to stop %d times, want 2 (the handle and the sweep)", n) + } +} + +// The dangerous half: a stopped unit that left no marker looks exactly like a reboot, and the reboot +// path RESTARTS the run — which would spend the account's money on work its owner had just +// cancelled. +func TestAStoppedRunIsNotRestartedWhenItsMarkerIsMissing(t *testing.T) { + f := newFixture(t, "10", 500) + run, err := f.svc.Start(f.ctx, StartRequest{UserID: "u1", BookID: f.bookID(t), CeilingChapters: 100}) + if err != nil { + t.Fatal(err) + } + if err := f.svc.Spawn(f.ctx, run.ID); err != nil { + t.Fatal(err) + } + if _, err := f.svc.Stop(f.ctx, "u1", run.ID); err != nil { + t.Fatal(err) + } + // The unit is gone, no marker was written, and the spawn grace is long past. + f.runner.alive = false + f.svc.Now = func() time.Time { return f.now.Add(2 * time.Hour) } + if err := f.svc.Sweep(f.ctx); err != nil { + t.Fatal(err) + } + if got := f.run(t, run.ID); got.Status != "stopped" { + t.Fatalf("run is %q, want stopped", got.Status) + } + if n := len(f.runner.starts()); n != 1 { + t.Fatalf("%d units were started; a stopped run must not be restarted", n) + } + if acct := f.account(t); acct.Reserved != 0 { + t.Fatalf("the hold of a stopped run is still open: %s", acct.Reserved.USD()) + } +} + +// A run stopped before its unit ever existed: there is nothing to signal, and the money must come +// back whole rather than wait for a unit that will never be created. +func TestStoppingARunThatNeverSpawnedGivesTheHoldBackWhole(t *testing.T) { + f := newFixture(t, "10", 500) + run, err := f.svc.Start(f.ctx, StartRequest{UserID: "u1", BookID: f.bookID(t), CeilingChapters: 100}) + if err != nil { + t.Fatal(err) + } + if _, err := f.svc.Stop(f.ctx, "u1", run.ID); err != nil { + t.Fatal(err) + } + if n := len(f.runner.stops()); n != 0 { + t.Fatalf("systemd was asked about a unit that does not exist (%d times)", n) + } + if err := f.svc.Sweep(f.ctx); err != nil { + t.Fatal(err) + } + if got := f.run(t, run.ID); got.Status != "stopped" { + t.Fatalf("run is %q, want stopped", got.Status) + } + acct := f.account(t) + if acct.Reserved != 0 || acct.Balance != money.MicroUSD(10_000_000) { + t.Fatalf("the hold of a run that never started did not come back whole: balance %s reserved %s", + acct.Balance.USD(), acct.Reserved.USD()) + } + if n := len(f.runner.starts()); n != 0 { + t.Fatalf("a stopped run was spawned anyway (%d units)", n) + } +} + +// Ownership, on the handle that ENDS a paid run: another account's run is not "forbidden", it is not +// there at all (API1/BOLA, and the same rule the library reads by). +func TestARunOfAnotherAccountCannotBeStoppedOrResumed(t *testing.T) { + f := newFixture(t, "10", 500) + run, err := f.svc.Start(f.ctx, StartRequest{UserID: "u1", BookID: f.bookID(t), CeilingChapters: 10}) + if err != nil { + t.Fatal(err) + } + if _, err := f.store.Pool().Exec(f.ctx, + `insert into users (id, email) values ('u2','u2@example.org')`); err != nil { + t.Fatal(err) + } + if _, err := f.svc.Stop(f.ctx, "u2", run.ID); !errors.Is(err, pgstore.ErrNoRun) { + t.Fatalf("stop by a stranger: %v, want ErrNoRun", err) + } + if _, err := f.svc.Resume(f.ctx, "u2", run.ID); !errors.Is(err, pgstore.ErrNoRun) { + t.Fatalf("resume by a stranger: %v, want ErrNoRun", err) + } +} + +// Stopping a run that is already over is a conflict and NOT a not-found: the owner can see the run, +// and answering 404 would tell them their own run does not exist. +func TestStoppingARunThatIsOverIsAConflict(t *testing.T) { + f := newFixture(t, "10", 500) + runID := f.stopped(t, 10, 0, runner.Marker{Result: "exit-code", Code: "exited", Status: "1", + At: f.now.Add(time.Second)}) + if _, err := f.svc.Stop(f.ctx, "u1", runID); !errors.Is(err, ErrNotStoppable) { + t.Fatalf("stopping a finished run: %v, want ErrNotStoppable", err) + } +} + +// Resume is the reconciler's restart with a user behind it: a NEW attempt, holding what is LEFT of +// the run's budget. The money is the assertion — one run may not reserve its ceiling twice. +func TestResumeContinuesTheRunWithWhatIsLeftOfItsBudget(t *testing.T) { + f := newFixture(t, "10", 500) + spent := money.MicroUSD(500_000) + runID := f.stopped(t, 100, spent, runner.Marker{Result: "exit-code", Code: "exited", Status: "1", + At: f.now.Add(time.Second)}) + budget := f.svc.Pricing.Ceiling(100) + if acct := f.account(t); acct.Reserved != 0 || acct.Balance != money.MicroUSD(10_000_000)-spent { + t.Fatalf("before the resume: balance %s reserved %s", acct.Balance.USD(), acct.Reserved.USD()) + } + got, err := f.svc.Resume(f.ctx, "u1", runID) + if err != nil { + t.Fatal(err) + } + if got.Status != "translating" || got.FinishedAt != nil { + t.Fatalf("the resumed run is %+v, want a live translating run", got) + } + // The new attempt holds the REMAINDER, not the whole ceiling: the first attempt already spent + // part of it and settled at what it spent. + if acct := f.account(t); acct.Reserved != budget-spent { + t.Fatalf("the resumed run holds %s, want %s (the ceiling less what was already spent)", + acct.Reserved.USD(), (budget - spent).USD()) + } + // The engine is QUEUED, not started inside the request: reading the book's meter costs seconds of + // the engine's CPU and creating a unit is a round trip to systemd, and a call that answers 202 + // must not hold either. (Measured, not reasoned about: the live probe's resume held its request + // until the engine exited.) With no queue wired here, the reconciler is the backstop — which is + // the same backstop the admission path relies on. + live := f.live(t) + if live.AttemptNo != 2 || live.UnitName != "" { + t.Fatalf("the resumed run's attempt is %+v, want a second attempt waiting to be spawned", live) + } + if n := len(f.runner.starts()); n != 1 { + t.Fatalf("%d units started; the resume spawned the engine inside the request", n) + } + if err := f.svc.Sweep(f.ctx); err != nil { + t.Fatal(err) + } + if n := len(f.runner.starts()); n != 2 { + t.Fatalf("%d units started across the run, want 2 once the backstop ran", n) + } + // The book follows the run back into translation, and the library's revision moves with it. + book, _, err := f.store.GetBook(f.ctx, "u1", f.bookID(t)) + if err != nil { + t.Fatal(err) + } + if book.Status != "translating" { + t.Fatalf("the book of a resumed run is %q, want translating", book.Status) + } +} + +// The bank stop is the one resume the contract describes in detail, and its condition — a COMPLETE +// set of decisions — cannot be met by any deployment today: nothing materializes the bank at all +// (companion §3). Answering 202 would start a run that walks straight into the same stop. +func TestResumeRefusesABankStopWhileTheGlossaryIsUnsigned(t *testing.T) { + f := newFixture(t, "10", 500) + runID := f.stopped(t, 100, 0, runner.Marker{Result: "exit-code", Code: "exited", Status: "3", + At: f.now.Add(time.Second)}) + if got := f.run(t, runID); got.Status != "awaiting_bank" { + t.Fatalf("run after the bank stop is %q", got.Status) + } + if _, err := f.svc.Resume(f.ctx, "u1", runID); !errors.Is(err, ErrNotResumable) { + t.Fatalf("resume of a bank stop: %v, want ErrNotResumable", err) + } +} + +// "Resume on a run paused by a ceiling returns it to the same state" (contract §resumeRun). It is +// answered rather than refused, and nothing moves: no second hold, no second unit — and, the half +// that is easy to get wrong, the run's own state is not rewritten on the way out. A run the user +// stopped comes back stopped; calling it `paused` because a resume failed would replace what +// happened to it with why it could not continue. +func TestResumeOfARunWithNothingLeftReturnsItUnchanged(t *testing.T) { + f := newFixture(t, "10", 500) + // The attempt spends its entire ceiling, so the run has no budget left at all. + runID := f.stopped(t, 10, f.svc.Pricing.Ceiling(10), runner.Marker{Result: "exit-code", + Code: "exited", Status: "1", At: f.now.Add(time.Second)}) + before := f.account(t) + got, err := f.svc.Resume(f.ctx, "u1", runID) + if err != nil { + t.Fatalf("resume of an exhausted run: %v, want the run back unchanged", err) + } + if got.Status != "stopped" || got.PausedReason != "" { + t.Fatalf("the run came back as %q/%q, want the stopped run it was", got.Status, got.PausedReason) + } + if acct := f.account(t); acct.Reserved != 0 || acct.Balance != before.Balance { + t.Fatalf("a resume that changed nothing moved money: %+v -> %+v", before, acct) + } + if n := len(f.runner.starts()); n != 1 { + t.Fatalf("%d units started, want 1: nothing was resumed", n) + } +} + +// The reconciler's half of the same decision, which the shared body must not have taken away: a run +// that was LIVE and has nothing left is `paused` with the reason the contract has a word for, and a +// resume of that comes back paused too. +func TestAnInterruptedRunWithNothingLeftIsPausedAndStaysPausedThroughAResume(t *testing.T) { + f := newFixture(t, "10", 500) + run, err := f.svc.Start(f.ctx, StartRequest{UserID: "u1", BookID: f.bookID(t), CeilingChapters: 10}) + if err != nil { + t.Fatal(err) + } + if err := f.svc.Spawn(f.ctx, run.ID); err != nil { + t.Fatal(err) + } + // The engine spent the whole ceiling and then its unit vanished without a marker, which is what a + // reboot leaves behind. + f.engine.set(statusSpending(f.svc.Pricing.Ceiling(10)), nil) + f.runner.alive = false + f.svc.Now = func() time.Time { return f.now.Add(2 * time.Hour) } + if err := f.svc.Sweep(f.ctx); err != nil { + t.Fatal(err) + } + got := f.run(t, run.ID) + if got.Status != "paused" || got.PausedReason != pgstore.PausedCreditExhausted { + t.Fatalf("an interrupted run with no budget is %q/%q, want paused/credit_exhausted", + got.Status, got.PausedReason) + } + resumed, err := f.svc.Resume(f.ctx, "u1", run.ID) + if err != nil { + t.Fatalf("resume of a paused run: %v, want it back unchanged", err) + } + if resumed.Status != "paused" || resumed.PausedReason != pgstore.PausedCreditExhausted { + t.Fatalf("the paused run came back as %q/%q", resumed.Status, resumed.PausedReason) + } + if n := len(f.runner.starts()); n != 1 { + t.Fatalf("%d units started, want 1", n) + } +} + +// A settlement is allowed to defer, and a resume that ignored that would open a SECOND hold on a run +// whose first is still reserved. +func TestResumeRefusesWhileThePreviousAttemptIsStillHoldingMoney(t *testing.T) { + f := newFixture(t, "10", 500) + run, err := f.svc.Start(f.ctx, StartRequest{UserID: "u1", BookID: f.bookID(t), CeilingChapters: 100}) + if err != nil { + t.Fatal(err) + } + if err := f.svc.Spawn(f.ctx, run.ID); err != nil { + t.Fatal(err) + } + if _, err := f.svc.Stop(f.ctx, "u1", run.ID); err != nil { + t.Fatal(err) + } + live := f.live(t) + if err := runner.WriteMarker(f.svc.markerPath(live.RunID, live.AttemptNo), + runner.Marker{Unit: live.UnitName, Result: "exit-code", Code: "exited", Status: "1", + At: f.now.Add(time.Second)}); err != nil { + t.Fatal(err) + } + // The engine cannot be asked what it spent, so the run finishes and its hold stays open. + f.engine.set(statusSpending(0), errors.New("status unavailable")) + if err := f.svc.Sweep(f.ctx); err != nil { + t.Fatal(err) + } + if acct := f.account(t); acct.Reserved == 0 { + t.Fatal("the hold was released even though the engine could not be asked") + } + if _, err := f.svc.Resume(f.ctx, "u1", run.ID); !errors.Is(err, ErrNotResumable) { + t.Fatalf("resume while unsettled: %v, want ErrNotResumable", err) + } + if n := len(f.runner.starts()); n != 1 { + t.Fatalf("%d units started: an unsettled run must not be resumed", n) + } +} + +// A book that is still arriving, or one the engine refused, has no chapter tree to translate — and +// while it is `uploading` it has half a file on disk. The refusal happens BEFORE the money moves. +func TestARunIsRefusedOnABookThatIsNotThroughIntake(t *testing.T) { + f := newFixture(t, "10", 500) + for _, status := range []string{"uploading", "parsing", "rejected"} { + if _, err := f.store.Pool().Exec(f.ctx, + `update books set status = $1 where id = $2`, status, f.bookID(t)); err != nil { + t.Fatal(err) + } + _, err := f.svc.Start(f.ctx, StartRequest{UserID: "u1", BookID: f.bookID(t), CeilingChapters: 10}) + if !errors.Is(err, ErrBookNotReady) { + t.Fatalf("starting a run on a %s book: %v, want ErrBookNotReady", status, err) + } + if acct := f.account(t); acct.Reserved != 0 { + t.Fatalf("a refused run held money: %s", acct.Reserved.USD()) + } + } +} + +// statusSpending is a report of an engine that has committed exactly this much on the book. +func statusSpending(v money.MicroUSD) ingest.StatusReport { + return ingest.StatusReport{Spend: usd(v), Reserved: usd(0)} +} + +// PD-169. A pass of the sweep has one budget for every run in it, and the list is ordered the same +// way every time: a run whose engine hangs used to eat the pass, and the tail of the list — other +// people's books — was never reached at all. The per-run budget is what bounds that, and this is the +// assertion that it does. +func TestOneSlowRunDoesNotEatThePassOfTheWholeSweep(t *testing.T) { + f := newFixture(t, "20", 500) + f.svc.Cfg.RunBudget = 100 * time.Millisecond + // Two books, two live runs, and the sweep visits them in the order they started. + first, err := f.svc.Start(f.ctx, StartRequest{UserID: "u1", BookID: f.bookID(t), CeilingChapters: 10}) + if err != nil { + t.Fatal(err) + } + second := f.secondBook(t) + if _, err := f.svc.Start(f.ctx, StartRequest{UserID: "u1", BookID: second, CeilingChapters: 10}); err != nil { + t.Fatal(err) + } + // The engine hangs for the FIRST run only — until the context that carries it is cancelled. + f.engine.block(first.ID, f.workdir) + if err := f.svc.Sweep(f.ctx); err != nil { + t.Fatal(err) + } + // The second run was spawned in the same pass, which is the whole property: the slow one cost its + // own budget and nothing else's. + started := f.runner.starts() + if len(started) != 1 { + t.Fatalf("%d units started; the second run was starved by the first", len(started)) + } +} + +// Two clicks on one button. Both calls pass the state check together and race for attempt N+1 on the +// unique index; the loser must answer with the run the winner re-opened, not with a not-found — and +// exactly ONE hold may be taken. +func TestTwoResumesOfOneRunTakeOneHoldAndBothAnswer(t *testing.T) { + f := newFixture(t, "10", 500) + runID := f.stopped(t, 100, money.MicroUSD(500_000), runner.Marker{Result: "exit-code", + Code: "exited", Status: "1", At: f.now.Add(time.Second)}) + var wg sync.WaitGroup + runs := make([]pgstore.Run, 2) + errs := make([]error, 2) + for i := range runs { + wg.Add(1) + go func() { + defer wg.Done() + runs[i], errs[i] = f.svc.Resume(f.ctx, "u1", runID) + }() + } + wg.Wait() + for i, err := range errs { + if err != nil { + t.Fatalf("resume %d: %v, want the run back", i, err) + } + if runs[i].Status != "translating" { + t.Errorf("resume %d answered %q, want translating", i, runs[i].Status) + } + } + if acct := f.account(t); acct.Reserved != f.svc.Pricing.Ceiling(100)-money.MicroUSD(500_000) { + t.Fatalf("two resumes reserved %s, want exactly one remainder", acct.Reserved.USD()) + } + if acct := f.account(t); acct.Balance != acct.LedgerSum { + t.Fatalf("the cached balance and the ledger disagree: %s vs %s", acct.Balance.USD(), acct.LedgerSum.USD()) + } + var attempts int + if err := f.store.Pool().QueryRow(f.ctx, + `select count(*) from run_attempts where run_id = $1`, runID).Scan(&attempts); err != nil { + t.Fatal(err) + } + if attempts != 2 { + t.Fatalf("%d attempts after two resumes, want 2", attempts) + } +} + +// A stopped run can be resumed — unless the account started ANOTHER run of the same book in the +// meantime. Re-opening then puts two live runs under an index that forbids exactly that, and the +// answer has to be the conflict a second admission gets, not an internal error. The money is the +// other half: the hold of the refused resume is taken in the same transaction and dies with it. +func TestResumeIsRefusedWhenTheBookHasAnotherLiveRun(t *testing.T) { + f := newFixture(t, "10", 500) + stoppedRun := f.stopped(t, 10, money.MicroUSD(10_000), runner.Marker{Result: "exit-code", + Code: "exited", Status: "1", At: f.now.Add(time.Second)}) + if _, err := f.svc.Start(f.ctx, StartRequest{UserID: "u1", BookID: f.bookID(t), CeilingChapters: 10}); err != nil { + t.Fatal(err) + } + before := f.account(t) + if _, err := f.svc.Resume(f.ctx, "u1", stoppedRun); !errors.Is(err, pgstore.ErrRunInFlight) { + t.Fatalf("resume while another run is live: %v, want ErrRunInFlight", err) + } + acct := f.account(t) + if acct.Reserved != before.Reserved || acct.Balance != before.Balance { + t.Fatalf("a refused resume moved money: %+v -> %+v", before, acct) + } + if acct.Balance != acct.LedgerSum { + t.Fatalf("the cached balance and the ledger disagree: %s vs %s", acct.Balance.USD(), acct.LedgerSum.USD()) + } +} + +// One counter per book, and every run-carrying answer uses it (contract §Revision). The stop handle +// used to answer from the run's own column, which sits BELOW the card's number the moment anything +// materializes — and a client that has applied the card's revision is required by the contract to +// drop the lower one, i.e. to drop the answer to the button it just pressed. +func TestEveryRunCarryingAnswerUsesTheBooksRevision(t *testing.T) { + f := newFixture(t, "10", 500) + run, err := f.svc.Start(f.ctx, StartRequest{UserID: "u1", BookID: f.bookID(t), CeilingChapters: 10}) + if err != nil { + t.Fatal(err) + } + // Something materializes: the book's counter moves and the run's own column does not. + if _, err := f.store.Pool().Exec(f.ctx, + `update books set revision = revision + 5 where id = $1`, f.bookID(t)); err != nil { + t.Fatal(err) + } + book, card, err := f.store.GetBook(f.ctx, "u1", f.bookID(t)) + if err != nil || card == nil { + t.Fatalf("card: %v", err) + } + if card.Revision != book.Revision { + t.Fatalf("the card's run carries revision %d and its book %d: one counter per book", card.Revision, book.Revision) + } + stopped, err := f.svc.Stop(f.ctx, "u1", run.ID) + if err != nil { + t.Fatal(err) + } + if stopped.Revision != book.Revision { + t.Fatalf("stop answered revision %d, the card answers %d — the client drops the lower one", + stopped.Revision, book.Revision) + } + read, err := f.store.ReadRun(f.ctx, "u1", run.ID) + if err != nil { + t.Fatal(err) + } + if read.Revision != book.Revision { + t.Fatalf("ReadRun answered revision %d, the card answers %d", read.Revision, book.Revision) + } +} + +// A run that is not the caller's, or one that does not exist, is a 404 — even on a deployment that +// could not resume anything anyway. The other order answers "this service cannot start runs" to a +// question about someone else's run, which is both a worse answer and a small oracle. +func TestOwnershipIsJudgedBeforeTheDeploymentsHealth(t *testing.T) { + f := newFixture(t, "10", 500) + run, err := f.svc.Start(f.ctx, StartRequest{UserID: "u1", BookID: f.bookID(t), CeilingChapters: 10}) + if err != nil { + t.Fatal(err) + } + f.svc.Cfg.MarkerArgv = nil // a deployment that cannot record how a run ends + if _, err := f.svc.Resume(f.ctx, "u1", "run_does_not_exist"); !errors.Is(err, pgstore.ErrNoRun) { + t.Fatalf("resume of a missing run on an unwired deployment: %v, want ErrNoRun", err) + } + if _, err := f.svc.Resume(f.ctx, "u1", run.ID); !errors.Is(err, ErrRunnerIncomplete) { + t.Fatalf("resume of an OWN run on an unwired deployment: %v, want the deployment's refusal", err) + } +} + +// The reconciler decides from a SNAPSHOT and writes seconds later; in between, the user presses +// stop. Three windows, one cure — the row is re-read under the lock that does the write. Each of +// these was reproduced by an independent reviewer before it was closed. + +// Window 1 (money): a stop pressed while the reconciler is settling an interrupted attempt. The +// restart used to clear the fresh intent, open a second attempt and take a new hold — a 202 for a +// stop that never happened, and the user paying for the work they had just cancelled. +func TestAStopPressedWhileTheReconcilerRestartsIsNotLost(t *testing.T) { + f := newFixture(t, "10", 500) + run, err := f.svc.Start(f.ctx, StartRequest{UserID: "u1", BookID: f.bookID(t), CeilingChapters: 100}) + if err != nil { + t.Fatal(err) + } + if err := f.svc.Spawn(f.ctx, run.ID); err != nil { + t.Fatal(err) + } + // The unit is gone with no marker — the shape of a reboot — and the grace is past, so the + // reconciler will restart. The stop lands while it is settling. + f.runner.alive = false + f.svc.Now = func() time.Time { return f.now.Add(2 * time.Hour) } + f.engine.onStatus = func() { + f.engine.onStatus = nil + if _, err := f.svc.Stop(f.ctx, "u1", run.ID); err != nil { + t.Error(err) + } + } + if err := f.svc.Sweep(f.ctx); err != nil { + t.Fatal(err) + } + got := f.run(t, run.ID) + if got.Status != "stopped" { + t.Fatalf("the run is %q, want stopped: the stop outranks the restart", got.Status) + } + if n := len(f.runner.starts()); n != 1 { + t.Fatalf("%d units started; the restart went ahead over a stop", n) + } + if acct := f.account(t); acct.Reserved != 0 { + t.Fatalf("a run stopped mid-restart still holds %s", acct.Reserved.USD()) + } + if acct := f.account(t); acct.Balance != acct.LedgerSum { + t.Fatalf("balance %s and ledger %s disagree", acct.Balance.USD(), acct.LedgerSum.USD()) + } +} + +// Window 2 (money): the sweep is about to close a run that was stopped before it spawned, and the +// queue worker creates the unit in between. Closing it anyway strands a live engine that no list +// looks at — not the live runs (finished), not the unsettled ones (its hold was released). +func TestARunSpawnedWhileTheSweepWasClosingItIsNotAbandoned(t *testing.T) { + f := newFixture(t, "10", 500) + run, err := f.svc.Start(f.ctx, StartRequest{UserID: "u1", BookID: f.bookID(t), CeilingChapters: 100}) + if err != nil { + t.Fatal(err) + } + // The snapshot a sweep would decide from: taken while the attempt still had no unit. + live := f.live(t) + // The worker claims and creates the unit — BEFORE the stop, because the claim itself refuses once + // an intent is standing. This is the window that remains: the sweep is holding a snapshot older + // than the unit. + claimed, err := f.store.RecordSpawn(f.ctx, pgstore.SpawnRecord{AttemptID: live.AttemptID, + Unit: "tm-run-raced", Binary: "/opt/engine/tmctl", Ceiling: live.Ceiling, + CeilingArg: live.Ceiling, Baseline: 1}) + if err != nil || !claimed { + t.Fatalf("the worker could not claim the attempt: %v %v", claimed, err) + } + if _, err := f.svc.Stop(f.ctx, "u1", run.ID); err != nil { + t.Fatal(err) + } + if err := f.svc.finishStopped(f.ctx, live); err != nil { + t.Fatal(err) + } + got := f.run(t, run.ID) + if got.Status == "stopped" || got.FinishedAt != nil { + t.Fatalf("a run whose engine had just started was closed anyway: %+v", got) + } + if acct := f.account(t); acct.Reserved == 0 { + t.Fatal("the hold of a run whose engine is running was released") + } + // And the next pass does the right thing with it: a live unit plus a standing intent is a stop. + f.runner.alive = true + if err := f.svc.Sweep(f.ctx); err != nil { + t.Fatal(err) + } + if n := len(f.runner.stops()); n == 0 { + t.Fatal("the raced run was never asked to stop") + } +} + +// Window 3: a stop pressed on a run whose queue job has not been picked up yet. The worker must not +// start an engine for it — the stop arrived before the run did anything at all. +func TestTheQueueWorkerDoesNotStartARunThatWasAlreadyStopped(t *testing.T) { + f := newFixture(t, "10", 500) + run, err := f.svc.Start(f.ctx, StartRequest{UserID: "u1", BookID: f.bookID(t), CeilingChapters: 100}) + if err != nil { + t.Fatal(err) + } + if _, err := f.svc.Stop(f.ctx, "u1", run.ID); err != nil { + t.Fatal(err) + } + if err := f.svc.Spawn(f.ctx, run.ID); err != nil { + t.Fatal(err) + } + if n := len(f.runner.starts()); n != 0 { + t.Fatalf("%d units started for a run that was stopped before its worker ran", n) + } + // The claim itself refuses too, which is the half that covers an intent written after the read. + live := f.live(t) + if err := f.svc.Sweep(f.ctx); err != nil { + t.Fatal(err) + } + claimed, err := f.store.RecordSpawn(f.ctx, pgstore.SpawnRecord{AttemptID: live.AttemptID, + Unit: "tm-run-late", Binary: "/opt/engine/tmctl", Ceiling: live.Ceiling, CeilingArg: live.Ceiling}) + if err != nil { + t.Fatal(err) + } + if claimed { + t.Fatal("the spawn claim was granted for an attempt of a run that is over and settled") + } + if acct := f.account(t); acct.Reserved != 0 || acct.Balance != money.MicroUSD(10_000_000) { + t.Fatalf("the hold did not come back whole: balance %s reserved %s", + acct.Balance.USD(), acct.Reserved.USD()) + } +} + +// The window a RESUME opened and nothing else could: a run comes back to life, and the exit marker of +// the attempt that ended is still on disk — nothing deletes markers of attempts that are over. A pass +// holding the old snapshot would close the resumed run from it, and attempt 2's hold would then be +// in NO worklist at all: live runs are selected by `finished_at is null`, unsettled ones by an +// attempt with `ended_at`, and a re-finished resumed run matches neither. +func TestAStaleSweepDoesNotReFinishAResumedRunFromTheOldAttemptsMarker(t *testing.T) { + f := newFixture(t, "10", 500) + spent := money.MicroUSD(100_000) + runID := f.stopped(t, 100, spent, runner.Marker{Result: "exit-code", Code: "exited", Status: "1", + At: f.now.Add(time.Second)}) + stale := f.lastAttempt(t, runID) // the snapshot a slow pass is still holding: attempt 1 + if _, err := f.svc.Resume(f.ctx, "u1", runID); err != nil { + t.Fatal(err) + } + held := f.account(t).Reserved + if held == 0 { + t.Fatal("the resumed run holds nothing") + } + // The slow pass arrives with attempt 1 and its marker, which is still on disk. + if err := f.svc.reconcile(f.ctx, stale); err != nil { + t.Fatal(err) + } + got := f.run(t, runID) + if got.Status != "translating" || got.FinishedAt != nil { + t.Fatalf("a stale pass closed the resumed run: %+v", got) + } + if acct := f.account(t); acct.Reserved != held { + t.Fatalf("the resumed run's hold moved: %s → %s", held.USD(), acct.Reserved.USD()) + } + // And the run is still findable, which is the half that makes the money recoverable at all. + live, err := f.store.ListLiveRuns(f.ctx) + if err != nil { + t.Fatal(err) + } + if len(live) != 1 || live[0].AttemptNo != 2 { + t.Fatalf("live runs after the stale pass: %+v", live) + } +} + +// A claim that was GIVEN BACK is not proof that no engine exists: `systemd-run` killed after it had +// already asked leaves one running, which is why the spend baseline is kept on such an attempt. The +// stop path must ask systemd about the unit's deterministic name rather than close over it. +func TestAStopDoesNotCloseARunWhoseGivenBackClaimLeftAnEngineRunning(t *testing.T) { + f := newFixture(t, "10", 500) + run, err := f.svc.Start(f.ctx, StartRequest{UserID: "u1", BookID: f.bookID(t), CeilingChapters: 100}) + if err != nil { + t.Fatal(err) + } + // The unit could "not be created" — but it was: the claim is given back and the baseline stays. + f.runner.startErr = errors.New("systemd-run was killed after it had asked") + if err := f.svc.Spawn(f.ctx, run.ID); err == nil { + t.Fatal("a failed spawn reported success") + } + live := f.live(t) + if live.UnitName != "" || live.SpendBaseline == nil { + t.Fatalf("after a given-back claim: unit %q, baseline %v", live.UnitName, live.SpendBaseline) + } + if _, err := f.svc.Stop(f.ctx, "u1", run.ID); err != nil { + t.Fatal(err) + } + // systemd says the orphan is alive. + f.runner.alive = true + f.runner.startErr = nil + if err := f.svc.Sweep(f.ctx); err != nil { + t.Fatal(err) + } + got := f.run(t, run.ID) + if got.FinishedAt != nil { + t.Fatalf("the run was closed while its engine was still running: %+v", got) + } + if n := len(f.runner.stops()); n == 0 { + t.Fatal("the orphaned unit was never asked to stop") + } + if acct := f.account(t); acct.Reserved == 0 { + t.Fatal("the hold of a run whose engine is alive was released") + } +} + +// The window the queue worker opens: it reads the run, then spends seconds in `bookMeter` asking the +// engine what the book has cost. A stop committed inside that window has no unit to signal — so the +// only thing that can refuse the engine is the claim itself. +func TestAStopCommittedWhileTheWorkerReadsTheMeterStopsTheSpawn(t *testing.T) { + f := newFixture(t, "10", 500) + run, err := f.svc.Start(f.ctx, StartRequest{UserID: "u1", BookID: f.bookID(t), CeilingChapters: 100}) + if err != nil { + t.Fatal(err) + } + // The stop lands while the worker is inside the meter call, i.e. after it read its snapshot. + f.engine.onStatus = func() { + f.engine.onStatus = nil + if _, err := f.svc.Stop(f.ctx, "u1", run.ID); err != nil { + t.Error(err) + } + } + if err := f.svc.Spawn(f.ctx, run.ID); err != nil { + t.Fatal(err) + } + if n := len(f.runner.starts()); n != 0 { + t.Fatalf("%d units started for a run stopped while the worker was reading the meter", n) + } + if err := f.svc.Sweep(f.ctx); err != nil { + t.Fatal(err) + } + if got := f.run(t, run.ID); got.Status != "stopped" { + t.Fatalf("the run is %q, want stopped", got.Status) + } + if acct := f.account(t); acct.Reserved != 0 || acct.Balance != money.MicroUSD(10_000_000) { + t.Fatalf("the hold did not come back whole: balance %s reserved %s", + acct.Balance.USD(), acct.Reserved.USD()) + } +} + +// FP5-2 (acceptance, money). The guard FinishRun received (PD-181) was missing from the path that +// closes a stop which never spawned: a stale pass could close a RESUMED run through it, and attempt +// 2's hold would then be in no worklist at all — with the added twist that every later resume answers +// 409 forever, because the run it would continue is finished. +func TestAStaleUnspawnedStopDoesNotCloseAResumedRun(t *testing.T) { + f := newFixture(t, "10", 500) + run, err := f.svc.Start(f.ctx, StartRequest{UserID: "u1", BookID: f.bookID(t), CeilingChapters: 100}) + if err != nil { + t.Fatal(err) + } + if _, err := f.svc.Stop(f.ctx, "u1", run.ID); err != nil { + t.Fatal(err) + } + stale := f.live(t) // attempt 1, no unit — the snapshot a slow pass holds + if err := f.svc.Sweep(f.ctx); err != nil { + t.Fatal(err) + } + if _, err := f.svc.Resume(f.ctx, "u1", run.ID); err != nil { + t.Fatal(err) + } + held := f.account(t).Reserved + if held == 0 { + t.Fatal("the resumed run holds nothing") + } + // The slow pass arrives with attempt 1 and the intent that was on it. + if err := f.svc.finishStopped(f.ctx, stale); err != nil { + t.Fatal(err) + } + got := f.run(t, run.ID) + if got.Status != "translating" || got.FinishedAt != nil { + t.Fatalf("a stale unspawned-stop closed the resumed run: %+v", got) + } + if acct := f.account(t); acct.Reserved != held { + t.Fatalf("the resumed run's hold moved: %s → %s", held.USD(), acct.Reserved.USD()) + } + live, err := f.store.ListLiveRuns(f.ctx) + if err != nil { + t.Fatal(err) + } + if len(live) != 1 || live[0].AttemptNo != 2 { + t.Fatalf("live runs after the stale pass: %+v", live) + } +} + +// FP5-8(а) (acceptance). A stop that lands while the reconciler is settling an exhausted run must not +// come back as `paused/credit_exhausted`: that says the money ran out, when what happened is that the +// owner stopped it. +func TestAStopDuringSettlementOutranksThePause(t *testing.T) { + f := newFixture(t, "10", 500) + run, err := f.svc.Start(f.ctx, StartRequest{UserID: "u1", BookID: f.bookID(t), CeilingChapters: 10}) + if err != nil { + t.Fatal(err) + } + if err := f.svc.Spawn(f.ctx, run.ID); err != nil { + t.Fatal(err) + } + // The attempt spends the whole ceiling, so the restart path will find nothing left; the stop lands + // while that settlement is in flight. + f.engine.set(statusSpending(f.svc.Pricing.Ceiling(10)), nil) + f.engine.onStatus = func() { + f.engine.onStatus = nil + if _, err := f.svc.Stop(f.ctx, "u1", run.ID); err != nil { + t.Error(err) + } + } + f.runner.alive = false + f.svc.Now = func() time.Time { return f.now.Add(2 * time.Hour) } + if err := f.svc.Sweep(f.ctx); err != nil { + t.Fatal(err) + } + got := f.run(t, run.ID) + if got.Status != "stopped" || got.PausedReason != "" { + t.Fatalf("the run came back as %q/%q, want stopped", got.Status, got.PausedReason) + } + if acct := f.account(t); acct.Balance != acct.LedgerSum { + t.Fatalf("balance %s and ledger %s disagree", acct.Balance.USD(), acct.LedgerSum.USD()) + } +} diff --git a/platform/internal/runs/queue.go b/platform/internal/runs/queue.go deleted file mode 100644 index 2b7d6219..00000000 --- a/platform/internal/runs/queue.go +++ /dev/null @@ -1,91 +0,0 @@ -package runs - -import ( - "context" - "fmt" - "log/slog" - - "github.com/jackc/pgx/v5/pgxpool" - "github.com/riverqueue/river" - "github.com/riverqueue/river/riverdriver/riverpgxv5" - - "textmachine/platform/internal/pgstore" -) - -// SpawnArgs is one queued permission to start a run. -// -// The job carries an id and nothing else. Everything about the run — the book, the ceiling, the -// binary it is pinned to — is in Postgres, and a job that carried its own copy would be a second -// answer that goes stale the moment the run is restarted with a smaller budget. -type SpawnArgs struct { - RunID string `json:"run_id"` -} - -// Kind is River's name for this job type. -func (SpawnArgs) Kind() string { return "tm_spawn_run" } - -// InsertOpts pins the queue-level policy. -// -// MaxAttempts is 1 on purpose, and it is the opposite of the usual queue reflex. A retry here does -// not repeat lost work: the run row already exists and holds the account's money, and the thing that -// would be repeated is spawning an engine. What recovers a run whose spawn failed is the reconciler, -// which reads the world instead of assuming the job's view of it — and which is the ONLY component -// that can tell "the unit never started" from "the unit is running and this platform was restarted". -func (SpawnArgs) InsertOpts() river.InsertOpts { - return river.InsertOpts{MaxAttempts: 1, Queue: river.QueueDefault} -} - -// spawnWorker runs SpawnArgs jobs. -type spawnWorker struct { - river.WorkerDefaults[SpawnArgs] - svc *Service -} - -func (w *spawnWorker) Work(ctx context.Context, job *river.Job[SpawnArgs]) error { - return w.svc.Spawn(ctx, job.Args.RunID) -} - -// Queue is the River client, wired to this service. -type Queue struct { - client *river.Client[pgstore.Tx] -} - -// NewQueue builds the queue and its worker pool. -// -// Concurrency is deliberately small: a worker's whole job is to create a transient unit, and the -// runs themselves are bounded by their own cgroups and by the one-live-run-per-book index, not by -// how many workers exist. -func NewQueue(pool *pgxpool.Pool, svc *Service, log *slog.Logger, workers int) (*Queue, error) { - if workers <= 0 { - workers = 4 - } - w := river.NewWorkers() - if err := river.AddWorkerSafely(w, &spawnWorker{svc: svc}); err != nil { - return nil, fmt.Errorf("runs: register worker: %w", err) - } - c, err := river.NewClient(riverpgxv5.New(pool), &river.Config{ - Logger: log, - Workers: w, - Queues: map[string]river.QueueConfig{river.QueueDefault: {MaxWorkers: workers}}, - }) - if err != nil { - return nil, fmt.Errorf("runs: river client: %w", err) - } - return &Queue{client: c}, nil -} - -// EnqueueRun inserts the job in the CALLER's transaction, so the run row, its hold and its queue -// entry commit together or not at all. -func (q *Queue) EnqueueRun(ctx context.Context, tx pgstore.Tx, runID string) error { - if _, err := q.client.InsertTx(ctx, tx, SpawnArgs{RunID: runID}, nil); err != nil { - return fmt.Errorf("runs: enqueue %s: %w", runID, err) - } - return nil -} - -// Start begins working jobs. -func (q *Queue) Start(ctx context.Context) error { return q.client.Start(ctx) } - -// Stop drains the workers. The runs themselves are untouched: they are transient units, not -// children, and outliving this process is what they are for. -func (q *Queue) Stop(ctx context.Context) error { return q.client.Stop(ctx) } diff --git a/platform/internal/runs/reconcile.go b/platform/internal/runs/reconcile.go index b82f93a1..11fc34fd 100644 --- a/platform/internal/runs/reconcile.go +++ b/platform/internal/runs/reconcile.go @@ -38,7 +38,7 @@ func (s *Service) Sweep(ctx context.Context) error { if err := ctx.Err(); err != nil { return err } - if err := s.reconcile(ctx, l); err != nil { + if err := s.withBudget(ctx, func(ctx context.Context) error { return s.reconcile(ctx, l) }); err != nil { s.log().ErrorContext(ctx, "run could not be reconciled", "run", l.RunID, "err", err) } } @@ -50,13 +50,63 @@ func (s *Service) Sweep(ctx context.Context) error { if err := ctx.Err(); err != nil { return err } - if err := s.settle(ctx, u); err != nil { + if err := s.withBudget(ctx, func(ctx context.Context) error { return s.settle(ctx, u) }); err != nil { s.log().ErrorContext(ctx, "run could not be settled", "run", u.RunID, "err", err) } } return nil } +// runBudget is what ONE run may cost a pass of the sweep. +// +// It exists because the pass has a budget of its own and the work is not uniform: a spawn or a +// settlement is a `tmctl status` call, seconds of the engine's CPU each, and a handful of runs whose +// engine hangs used to spend the whole pass — after which the list, which is ordered the same way +// every time, was never reached past them. That is starvation with no upper bound, and it was +// invisible (register row PD-169; the counter that makes it visible is the sweep's own metric). +// +// Generous rather than tight: the call it bounds legitimately takes seconds on a large book, and the +// point is to bound the pathological case, not to race the ordinary one. +const defaultRunBudget = 60 * time.Second + +func (s *Service) runBudget() time.Duration { + if s.Cfg.RunBudget > 0 { + return s.Cfg.RunBudget + } + return defaultRunBudget +} + +// withBudget runs one item of a sweep under its own deadline. A cancelled item is not a failed one: +// the next pass reads the same world and tries again, and the money paths inside are transactional, +// so an item cut off mid-write leaves nothing half-done. +func (s *Service) withBudget(ctx context.Context, fn func(context.Context) error) error { + c, cancel := context.WithTimeout(ctx, s.runBudget()) + defer cancel() + return fn(c) +} + +// Lag is how far the furthest-behind live run's journal is beyond this platform's cursor, in bytes. +// +// It is the tailer's own health, and until this pack nothing reported it: a projection that has +// stopped moving and a run that is simply quiet look the same from outside. Read separately from the +// sweep rather than folded into it because it is telemetry — a failure to measure must not be able +// to affect what the sweep does. +func (s *Service) Lag(ctx context.Context) (int64, error) { + live, err := s.Store.ListLiveRuns(ctx) + if err != nil { + return 0, err + } + var worst int64 + for _, l := range live { + size, err := journalSize(l.Workdir) + if err != nil { + continue // a book whose directory is unreadable is the reconciler's problem, not this one's + } + worst = max(worst, size-l.Position.Offset) + } + return worst, nil +} + func (s *Service) reconcile(ctx context.Context, l pgstore.LiveRun) error { // The cursor this call MOVED, not the one the sweep's snapshot was taken with. Everything below // that asks "has the stream said anything" has to ask about now: the snapshot is read before the @@ -83,6 +133,12 @@ func (s *Service) reconcile(ctx context.Context, l pgstore.LiveRun) error { return err } if l.UnitName == "" { + if l.StopRequestedAt != nil { + // Stopped before it ever started: the request was admitted, the money was held, and the + // unit was never created. There is nothing to signal and nothing to wait for, so the run + // ends here and its hold comes back whole through the ordinary settlement. + return s.finishStopped(ctx, l) + } // Admitted and never spawned: the platform stopped between the transaction and the unit, or // the queue entry was lost. The reconciler is the backstop for both. return s.spawnAttempt(ctx, l) @@ -94,14 +150,84 @@ func (s *Service) reconcile(ctx context.Context, l pgstore.LiveRun) error { return err } if alive { + if l.StopRequestedAt != nil { + // The intent is committed and the unit is still there. Either the signal never went out — + // the platform died between writing the intent and asking systemd — or the engine is + // finishing the chunk it has already paid for. Asking again is free and idempotent, and it + // is the only thing that closes the first case. + if err := s.Runner.Stop(ctx, l.UnitName); err != nil { + return err + } + return nil + } return s.maybeResync(ctx, l) } if s.now().Sub(l.AttemptStartedAt) < spawnGrace { return nil // systemd has not started it yet } + if l.StopRequestedAt != nil { + // The unit is gone and left no marker, which is the shape of a reboot — but this run was + // asked to stop, and restarting it would spend the account's money on work its owner had + // just cancelled. The intent decides, because it is the one fact here that is ours. + return s.finishStopped(ctx, l) + } return s.restart(ctx, l) } +// finishStopped closes a run whose end this platform asked for and whose unit left no marker to read: +// it was never created, or the manager went away with it. +// +// The two cases are not the same write. Where a unit EXISTED the ordinary finish applies. Where it +// never did, the close has to re-check that under a lock: the queue worker may have claimed the +// attempt between this sweep's snapshot and now, and closing the run then strands a live engine that +// no list looks at any more. +func (s *Service) finishStopped(ctx context.Context, l pgstore.LiveRun) error { + if l.StopRequestedAt == nil { + // The SNAPSHOT may predate the request — that is the whole reason one of the two callers is + // here: the reconciler read the run, spent seconds settling, and the stop landed inside that + // window. Reaching this function is itself the establishment of the fact, so the local copy is + // made to say so rather than letting `outcome` read a nil and call the ending a failure. + now := s.now() + l.StopRequestedAt = &now + } + if l.UnitName != "" { + return s.finish(ctx, l, runner.Marker{Unit: l.UnitName, Result: pgstore.StopRequestedResult}) + } + // ⚠ An empty unit name is not proof that no process exists. `ReleaseSpawnClaim` clears the name + // when the unit could NOT be created — and "could not be created" is not "was not created": a + // systemd-run killed after it had already asked leaves an engine running, which is why the spend + // baseline is deliberately kept on such an attempt (RecordSpawn). That baseline is the tombstone, + // and where it exists the unit's name is derivable, so systemd is asked rather than assumed. + // Closing over a live engine would leave it spending against a run this platform calls finished. + if l.SpendBaseline != nil { + unit := unitName(l.RunID, l.AttemptNo) + alive, err := s.Runner.Alive(ctx, unit) + if err != nil { + // "I could not ask" is never "the run is gone" — the same rule the reconciler follows + // everywhere else. + return err + } + if alive { + s.log().InfoContext(ctx, "a claim was given back but its unit exists; stopping it instead of closing the run", + "run", l.RunID, "attempt", l.AttemptNo) + return s.Runner.Stop(ctx, unit) + } + } + finished, err := s.Store.FinishUnspawnedStop(ctx, l.RunID, l.AttemptID, s.now()) + if err != nil { + return err + } + if !finished { + // It was spawned after all. Nothing to do: the next pass sees a live unit and an intent that + // says to stop it. + s.log().InfoContext(ctx, "the stopped run was spawned after this sweep read it", "run", l.RunID) + return nil + } + s.log().InfoContext(ctx, "run finished", "run", l.RunID, "status", "stopped", + "result", pgstore.StopRequestedResult) + return s.settle(ctx, l) +} + // drainJournal applies whatever the engine has written since the cursor, and returns where the // cursor now stands. func (s *Service) drainJournal(ctx context.Context, l pgstore.LiveRun) (int64, error) { @@ -216,9 +342,18 @@ func (s *Service) dueForResync(runID string) bool { func (s *Service) finish(ctx context.Context, l pgstore.LiveRun, m runner.Marker) error { delete(s.resynced, l.RunID) // a finished run keeps no rate-limit entry: the map is per process status, exit := outcome(l, m) - if err := s.Store.FinishRun(ctx, l.RunID, l.AttemptID, status, m.Result, exit, s.now()); err != nil { + closed, err := s.Store.FinishRun(ctx, l.RunID, l.AttemptID, status, m.Result, exit, s.now()) + if err != nil { return err } + if !closed { + // The run was already finished, or this pass is holding a snapshot of an attempt that is no + // longer the live one — a stop and a resume can both have happened since it was taken. Doing + // anything further here would settle the money of a run somebody else is now running. + s.log().InfoContext(ctx, "the run moved on since this pass read it; nothing to finish", + "run", l.RunID, "attempt", l.AttemptNo) + return nil + } s.log().InfoContext(ctx, "run finished", "run", l.RunID, "status", status, "result", m.Result) // Settling immediately rather than waiting for the next sweep: the hold is the account's money // and every second it stays reserved is a second the user cannot start another book. @@ -238,29 +373,57 @@ func (s *Service) finish(ctx context.Context, l pgstore.LiveRun, m runner.Marker // verdict returns errReserveCeiling). The contract requires a ceiling stop to be `paused` and never // `failed`, so until the emitter lands the only ceiling stop this platform can report correctly is // one it heard about on the stream — which is exactly the branch below that reads paused_reason. +// +// A stop this platform ASKED for is a different question and is answered here, because the answer is +// ours: the engine catches SIGTERM and exits 1, so the marker cannot tell a stop from a crash — but +// the request was written down before the signal went out, and that record is evidence rather than +// inference (register row PD-152). func outcome(l pgstore.LiveRun, m runner.Marker) (status string, exitCode *int) { if l.PausedReason != "" { return "paused", nil } code, exited := m.Exited() - if !exited { - if m.Result == "success" { - return "stopped", nil // asked to stop, and it did + if exited { + switch code { + case 0, 2: + // 2 is "completed with flagged units": a finished translation whose notes carry the flags. + // The engine's own clean answer wins over a stop that arrived while it was already done. + return "ready", &code + case 3: + // The bank-signing stop. The ATTEMPT is over — the engine exits — and the run is left in + // a state `resume` continues from once the decisions are complete. + return "awaiting_bank", &code } - return "failed", nil } - switch code { - case 0, 2: - // 2 is "completed with flagged units": a finished translation whose notes carry the flags. - return "ready", &code - case 3: - // The bank-signing stop. The ATTEMPT is over — the engine exits — and so is this run row; - // the work is resumable and resuming it starts a new run, because the contract's resume - // handle is not part of this pack. - return "awaiting_bank", &code - default: + if stoppedOnRequest(l, m) { + // Exit 1 after our SIGTERM is the ordinary shape of a graceful stop; a kill after the stop + // timeout, or an OOM in the same window, is an ungraceful one. Both are the stop the user + // asked for, and the machine's own word for what happened stays in exit_result. + return "stopped", nil + } + if !exited && m.Result == "success" { + // Killed by a signal and systemd calls the result success: a stop nobody recorded, which is + // `systemctl stop` by hand on the host. + return "stopped", nil + } + if exited { return "failed", &code } + return "failed", nil +} + +// stoppedOnRequest reports whether this platform's own stop is what ended the attempt. +// +// The timestamp comparison is the race guard the design owes: a run that finished by itself a moment +// before someone pressed stop was not stopped by them, and calling it `stopped` would hide a +// completed translation behind a cancelled one. Marker.At is written by the unit as it dies, on the +// same host and by the same clock the request was stamped with. A marker with no timestamp is +// evidence we do not have, and then the intent — the only fact left — decides. +func stoppedOnRequest(l pgstore.LiveRun, m runner.Marker) bool { + if l.StopRequestedAt == nil { + return false + } + return m.At.IsZero() || !l.StopRequestedAt.After(m.At) } // settle resolves the money of an attempt whose process is gone. @@ -279,6 +442,23 @@ func (s *Service) settle(ctx context.Context, l pgstore.LiveRun) error { return nil } key := pgstore.ReservationKey(l.RunID, l.AttemptNo) + // An attempt with no unit never reached the engine, so there is nothing to ask it about — and + // asking anyway is what kept such a hold reserved for good on the very hosts that produce this + // case: a deployment whose engine cannot be run fails `Status` on every pass, and the money of a + // run that never ran would wait for an answer that never comes. The row is re-checked under the + // lock inside ReleaseUnspawned, so a snapshot that was spawned in the meantime is refused there + // and settles the ordinary way below. Found by cross-family review of the acceptance dofix. + if l.UnitName == "" && l.SpendBaseline == nil { + switch err := s.Store.ReleaseUnspawned(ctx, key, l.AttemptID, s.now()); { + case err == nil, errors.Is(err, pgstore.ErrNoReservation): + return s.Store.MarkSettled(ctx, l.RunID, s.now()) + case !errors.Is(err, pgstore.ErrAttemptSpawned): + return err + } + s.log().InfoContext(ctx, "settlement deferred: the attempt was spawned after this sweep read it", + "run", l.RunID, "attempt", l.AttemptNo) + return nil // the next sweep sees the unit and settles against its baseline + } rep, err := s.Engine.Status(ctx, s.engineBinary(l), l.Workdir) if err != nil { s.log().WarnContext(ctx, "settlement deferred: the engine's committed spend could not be read", @@ -367,34 +547,89 @@ func (s *Service) restart(ctx context.Context, l pgstore.LiveRun) error { if err := s.settle(ctx, l); err != nil { return err } + next, v, err := s.reopen(ctx, l) + if errors.Is(err, pgstore.ErrStopRequested) { + // The user pressed stop while this pass was settling. The intent outranks the restart, and it + // is still there — the refusal is what kept it — so the run ends here instead. + s.log().InfoContext(ctx, "restart abandoned: the run was asked to stop", "run", l.RunID) + return s.finishStopped(ctx, l) + } + if err != nil { + return err + } + switch v { + case exhausted: + // `paused` WITH its reason: the contract describes PausedReason as the reason a run is paused, + // and a paused run with a null reason gives the screen nothing to say. PauseRun is the path + // that sets it; FinishRun does not. Done HERE and not inside reopen because the other caller — + // a resume — works on a run that has already ended, and pausing one of those would rewrite how + // it ended. + // + // A stop that arrived while this pass was settling outranks the pause: the store refuses and + // the run ends as what it is, stopped. + if err := s.Store.PauseRun(ctx, l.RunID, l.AttemptID, pgstore.PausedCreditExhausted, s.now()); err != nil { + if errors.Is(err, pgstore.ErrStopRequested) { + s.log().InfoContext(ctx, "pause abandoned: the run was asked to stop", "run", l.RunID) + return s.finishStopped(ctx, l) + } + return err + } + return nil + case deferred: + return nil + } + s.log().InfoContext(ctx, "interrupted run restarted", "run", l.RunID, "attempt", next.AttemptNo) + return s.spawnAttempt(ctx, next) +} + +// verdict is what reopening a run came to. Three outcomes because the callers want different things +// from the two that are not "it started": the reconciler treats both as "nothing more this pass", +// while a user's resume must tell "your money is still being counted, ask again" from "there is +// nothing left of this run's budget". +type verdict int + +const ( + reopened verdict = iota + // deferred — the previous attempt's money is not settled yet. Temporary by construction: the + // settlement is retried by every sweep. + deferred + // exhausted — nothing is left of the run's budget, or the account cannot carry a new hold. What + // to DO about it differs by caller, so reopen only reports it. + exhausted +) + +// reopen closes an attempt and opens the next one with what is LEFT of the run's budget. +// +// It is the shared body of the reconciler's restart and of the contract's resume, and sharing it is +// the point: both are "this run continues in a new process", both must give the new attempt the +// remainder rather than the whole ceiling — one run may not spend its ceiling twice — and both must +// refuse to open a second hold while the first is still open. +func (s *Service) reopen(ctx context.Context, l pgstore.LiveRun) (pgstore.LiveRun, verdict, error) { // Settling is allowed to DEFER — the engine's figure may not be readable — and a deferral must not // become a second reservation. Asked rather than assumed: proceeding on an unsettled attempt holds // the ceiling twice and strands the first hold where no later sweep looks for it. open, err := s.Store.AttemptReservationOpen(ctx, l.RunID, l.AttemptNo) if err != nil { - return err + return pgstore.LiveRun{}, deferred, err } if open { - s.log().WarnContext(ctx, "restart deferred: the interrupted attempt is not settled yet", + s.log().WarnContext(ctx, "restart deferred: the previous attempt is not settled yet", "run", l.RunID, "attempt", l.AttemptNo) - return nil + return pgstore.LiveRun{}, deferred, nil } budget := s.Pricing.Ceiling(l.CeilingChapters) spent, err := s.Store.RunSpent(ctx, l.RunID) if err != nil { - return err + return pgstore.LiveRun{}, deferred, err } remaining := budget - spent if remaining <= 0 { - // `paused` WITH its reason: the contract describes PausedReason as the reason a run is paused, - // and a paused run with a null reason gives the screen nothing to say. PauseRun is the path - // that sets it; FinishRun does not. - s.log().InfoContext(ctx, "interrupted run has no budget left", "run", l.RunID) - return s.Store.PauseRun(ctx, l.RunID, l.AttemptID, pgstore.PausedCreditExhausted, s.now()) + s.log().InfoContext(ctx, "the run has no budget left", "run", l.RunID) + return pgstore.LiveRun{}, exhausted, nil } offset, err := journalSize(l.Workdir) if err != nil { - return err + return pgstore.LiveRun{}, deferred, err } // Nil = inherit the pinned build. A different one only when an operator asked for it (row 139). var version *string @@ -412,25 +647,142 @@ func (s *Service) restart(ctx context.Context, l pgstore.LiveRun) error { Now: s.now(), }) if errors.Is(err, pgstore.ErrInsufficientCredit) { - // The balance went elsewhere while this run was down. Honest state, not a failure: the run is - // paused for the reason the contract has a word for. - s.log().InfoContext(ctx, "interrupted run cannot be resumed on the current balance", "run", l.RunID) - return s.Store.PauseRun(ctx, l.RunID, l.AttemptID, pgstore.PausedCreditExhausted, s.now()) + // The balance went elsewhere while this run was down. Honest state, not a failure. + s.log().InfoContext(ctx, "the run cannot be continued on the current balance", "run", l.RunID) + return pgstore.LiveRun{}, exhausted, nil } if err != nil { - return err + return pgstore.LiveRun{}, deferred, err } - s.log().InfoContext(ctx, "interrupted run restarted", "run", l.RunID, "attempt", next.AttemptNo) - return s.spawnAttempt(ctx, next) + return next, reopened, nil } -// Stop asks a live run to shut down. The engine stops gracefully on SIGTERM and finishes the chunk -// it has already paid for; the reconciler turns the exit into a status. -func (s *Service) Stop(ctx context.Context, l pgstore.LiveRun) error { - if l.UnitName == "" { - return fmt.Errorf("runs: run %s has no unit to stop", l.RunID) +func (s *Service) enqueueNow(ctx context.Context, runID string) error { + if s.Queue == nil { + return nil // no queue configured: the reconciler still picks the run up on its next sweep } - return s.Runner.Stop(ctx, l.UnitName) + return s.Queue.EnqueueRunNow(ctx, runID) +} + +// Stop is the contract's stop handle (§stopRun). +// +// What it does is record the INTENT and then ask systemd, in that order, and the order is the whole +// mechanism: the engine catches SIGTERM and exits 1, so the exit marker says the same thing for a +// stop and for a crash (register row PD-152). The row written before the signal is what tells them +// apart afterwards — and what stops the reconciler restarting a run whose owner just cancelled it. +// +// The systemd call is allowed to fail without failing the request: the intent is committed, the +// reconciler re-issues the stop on its next pass, and answering an error to a stop that WILL happen +// would invite the user to press it again. +func (s *Service) Stop(ctx context.Context, userID, runID string) (pgstore.Run, error) { + run, unit, err := s.Store.RequestStop(ctx, userID, runID, s.now()) + if errors.Is(err, pgstore.ErrRunNotLive) { + // No status in the message: RequestStop answers a zero Run alongside this error, so anything + // read off it would be an empty string dressed up as a fact. + return pgstore.Run{}, ErrNotStoppable + } + if err != nil { + return pgstore.Run{}, err + } + s.log().InfoContext(ctx, "run stop requested", "run", runID) + if unit == "" { + // Admitted and not yet spawned. There is nothing to signal; the reconciler ends the run and + // gives the hold back on its next pass. + return run, nil + } + if err := s.Runner.Stop(ctx, unit); err != nil { + s.log().ErrorContext(ctx, "the stop was recorded but systemd did not take it; the reconciler will retry", + "run", runID, "err", err) + } + return run, nil +} + +// Resume is the contract's resume handle (§resumeRun): it continues a run that was stopped. +// +// The mechanics are the reconciler's — a new attempt with what is LEFT of the run's budget — because +// a resume and a restart are the same event seen from two sides, and a second implementation of +// "give this run another process" is a second place for the money to be got wrong. +// +// What this adds is the state machine the contract describes, and its shape is dictated by that text +// rather than chosen: +// - a run the user stopped continues; +// - a bank stop answers 409 while the set of decisions is incomplete — and it is incomplete for +// every book today, because nothing materializes the bank at all (companion §3: the export +// artifact the read needs does not exist); +// - a run paused by a ceiling "returns it to the same state", so it answers 202 with the run +// unchanged rather than pretending an action happened; +// - anything else — already going, finished, failed — is a 409. +func (s *Service) Resume(ctx context.Context, userID, runID string) (pgstore.Run, error) { + // Ownership FIRST, deployment health second. The other order answers "this service cannot start + // runs" to a caller asking about a run that is not theirs and to one asking about a run that does + // not exist — which is both a worse answer and a small oracle about the shape of the deployment. + l, err := s.Store.ReadRunForResume(ctx, userID, runID) + if err != nil { + return pgstore.Run{}, err + } + if err := s.runnable(); err != nil { + return pgstore.Run{}, err + } + switch l.Status { + case "stopped", "paused": + case "awaiting_bank": + // The stop clears ONLY on a complete set of decisions (contract §resumeRun, §BankDecisions). + // There is no channel that could make one complete yet, so this is the honest answer rather + // than a placeholder: resuming would hit the same stop and spend the start of a run doing it. + return pgstore.Run{}, fmt.Errorf("%w: the glossary is not signed", ErrNotResumable) + default: + return pgstore.Run{}, fmt.Errorf("%w: it is %s", ErrNotResumable, l.Status) + } + next, v, err := s.reopen(ctx, l) + if errors.Is(err, pgstore.ErrStopRequested) { + // Cannot happen through this path today — a resume works on a run that has ENDED and the guard + // only fires on a live one — but the answer is written rather than left to fall through as an + // internal error if some later path reaches it. + return pgstore.Run{}, fmt.Errorf("%w: it has been asked to stop", ErrNotResumable) + } + if errors.Is(err, pgstore.ErrNoRun) { + // Another caller re-opened this run first — two clicks on the same button reach here together, + // and the second loses the race for attempt N+1 on the unique index. Its answer is the WORK OF + // THE FIRST: the run is continuing, so reporting "no such run" would be both false and + // alarming, and re-opening again would take a second hold. + return s.Store.ReadRun(ctx, userID, runID) + } + if err != nil { + return pgstore.Run{}, err + } + switch v { + case deferred: + // The previous attempt's money is still being resolved. A second hold now would reserve the + // ceiling twice, so the answer is "not yet" and the sweep clears it. + return pgstore.Run{}, fmt.Errorf("%w: its previous attempt is still being settled", ErrNotResumable) + case exhausted: + // Nothing left to continue with: the call returns the run in the state it was in, and the state + // is NOT rewritten on the way out — a run the user stopped stays stopped, because a resume that + // changed nothing must not change what the run was. + // + // ⚠ This is NOT the whole of what the contract asks about `paused`. It says a resume must not be + // the remedy for a ceiling pause, and this branch only produces that answer when there is + // nothing left to spend. Today the only reachable `paused` is `credit_exhausted` from the + // reconciler, where continuing after a grant IS the right thing; a CEILING pause needs the + // engine's ceiling event, which does not exist (register row PD-113). When it lands, resume has + // to refuse for that reason instead of re-opening the run — a run whose ceiling nothing raised + // would otherwise walk into the same stop and, with no event to name it, come back `failed`. + return s.Store.ReadRun(ctx, userID, runID) + } + s.log().InfoContext(ctx, "run resumed", "run", runID, "attempt", next.AttemptNo) + // QUEUED, not spawned here. Starting the engine inline would put two slow things inside a request + // that answers 202: reading the book's meter costs seconds of the engine's CPU, and creating the + // unit is a round trip to systemd. The admission path queues for the same reason, and the + // reconciler is the backstop if the entry is lost. + if err := s.enqueueNow(ctx, next.RunID); err != nil { + // The run IS re-opened and the money IS held: the row says `translating` and the reconciler + // picks up an attempt with no unit on its next pass, exactly as it does after an admission. + // Reporting a failure here would tell the user nothing had started while the row said + // otherwise. + s.log().ErrorContext(ctx, "the resumed run could not be queued; the reconciler will retry", + "run", runID, "err", err) + } + return s.Store.ReadRun(ctx, userID, runID) } // attemptSpend turns the engine's figure into what THIS attempt cost. diff --git a/platform/internal/runs/reconcile_test.go b/platform/internal/runs/reconcile_test.go index 6a19d6f4..be96ecdb 100644 --- a/platform/internal/runs/reconcile_test.go +++ b/platform/internal/runs/reconcile_test.go @@ -30,6 +30,9 @@ type fakeRunner struct { alive bool aliveErr error startErr error + // onStop runs INSIDE the fake's Stop, which is what makes the ORDER of "write the intent, then + // ask systemd" assertable: by the time this runs, the database must already know. + onStop func(unit string) } func (f *fakeRunner) Start(_ context.Context, s runner.Spec) error { @@ -44,11 +47,21 @@ func (f *fakeRunner) Start(_ context.Context, s runner.Spec) error { func (f *fakeRunner) Stop(_ context.Context, unit string) error { f.mu.Lock() - defer f.mu.Unlock() + hook := f.onStop f.stopped = append(f.stopped, unit) + f.mu.Unlock() + if hook != nil { + hook(unit) + } return nil } +func (f *fakeRunner) stops() []string { + f.mu.Lock() + defer f.mu.Unlock() + return append([]string(nil), f.stopped...) +} + func (f *fakeRunner) Alive(context.Context, string) (bool, error) { return f.alive, f.aliveErr } func (f *fakeRunner) starts() []runner.Spec { @@ -64,13 +77,38 @@ type fakeEngine struct { report ingest.StatusReport err error calls int + // hangs is the workdir the engine refuses to answer about. + hangs string + // onStatus runs INSIDE the call, which is how a test opens the window the reconciler decides in: + // a status call is seconds of the engine's CPU, and a user can press stop inside it. + onStatus func() } -func (f *fakeEngine) Status(context.Context, string, string) (ingest.StatusReport, error) { +func (f *fakeEngine) Status(ctx context.Context, _, workdir string) (ingest.StatusReport, error) { + f.mu.Lock() + f.calls++ + hang := f.hangs != "" && f.hangs == workdir + hook := f.onStatus + rep, err := f.report, f.err + f.mu.Unlock() + if hook != nil { + hook() + } + if hang { + // An engine that never answers, which is what a hung `tmctl status` looks like from here. It + // ends when the CALLER's budget does, and that is the property being measured. + <-ctx.Done() + return ingest.StatusReport{}, ctx.Err() + } + return rep, err +} + +// block makes the engine hang for one book's directory. The run id is taken only to make the call +// site read like the thing it models. +func (f *fakeEngine) block(_ string, workdir string) { f.mu.Lock() defer f.mu.Unlock() - f.calls++ - return f.report, f.err + f.hangs = workdir } func (f *fakeEngine) set(rep ingest.StatusReport, err error) { diff --git a/platform/internal/runs/runs.go b/platform/internal/runs/runs.go index ff806ef5..f56c7361 100644 --- a/platform/internal/runs/runs.go +++ b/platform/internal/runs/runs.go @@ -59,6 +59,10 @@ type Config struct { // the engine is deployed more often than a translation finishes, so the quiet behaviour would be // for a resumed run to continue under a program nobody chose for it. AllowEngineVersionChange bool + // RunBudget is what ONE run may cost a pass of the sweep; zero takes the default. It bounds the + // starvation the pass itself cannot: the list is ordered the same way every time, so a handful of + // runs whose engine hangs used to mean the tail was never reached (register row PD-169). + RunBudget time.Duration // ResyncEvery is how often a live run is reconciled from `tmctl status --json`. It is a SLOW // poll on purpose: every call re-ingests and re-chunks the source (~1.4-1.5 s of CPU on a 23 MB // book, unified backlog row 100). Until the event emitter exists (row 103) it is the only thing @@ -83,9 +87,16 @@ type Service struct { resynced map[string]time.Time } -// Enqueuer hands a run to the queue inside the caller's transaction. +// Enqueuer hands a run to the queue. +// +// Two methods because the two callers need different atomicity, not different queues: an ADMISSION +// writes the run, its hold and its queue entry in one transaction (a job for a run that does not +// exist, or a hold with no job, are both ways to lose money or work), while a RESUME re-opens a run +// that already exists and only then asks for a worker. A lost entry costs a sweep interval either +// way — the reconciler is the backstop for both. type Enqueuer interface { EnqueueRun(ctx context.Context, tx pgstore.Tx, runID string) error + EnqueueRunNow(ctx context.Context, runID string) error } func (s *Service) now() time.Time { @@ -115,6 +126,18 @@ var ErrCeilingOutOfBounds = errors.New("runs: the requested ceiling is outside t // one during exactly the operation that causes it, a binary upgrade. var ErrRunnerIncomplete = errors.New("runs: this deployment cannot record how a run ends") +// ErrBookNotReady is a run asked for on a book whose intake has not finished — one still arriving, +// still being cut, or rejected. The contract's 409: the book exists and the client can see it, and +// what cannot happen yet is a translation of it. +var ErrBookNotReady = errors.New("runs: the book is not ready to be translated") + +// ErrNotStoppable is a stop asked for on a run that is already over. +var ErrNotStoppable = errors.New("runs: the run is not live") + +// ErrNotResumable is a resume that would not move the run: a run that is already going, one that +// finished, or a bank stop whose set of decisions is not complete (contract §resumeRun, 409). +var ErrNotResumable = errors.New("runs: the run cannot be continued") + // Bounds computes the run-ceiling scale for a book. func (s *Service) Bounds(ctx context.Context, userID, bookID string) (pricing.Bounds, error) { book, err := s.Store.ReadBookForRun(ctx, userID, bookID) @@ -154,6 +177,13 @@ func (s *Service) Start(ctx context.Context, in StartRequest) (pgstore.Run, erro if err != nil { return pgstore.Run{}, err } + if !readyToTranslate(book.Status) { + // A book whose intake has not finished has no chapter tree to translate and, while it is + // `uploading`, half a file on disk. Refused here rather than discovered by the engine inside a + // transient unit, where the only trace would be a marker reading "exit-code 1" — and after the + // account's money had already been held for it. + return pgstore.Run{}, fmt.Errorf("%w: it is %s", ErrBookNotReady, book.Status) + } if book.HasLiveRun { return pgstore.Run{}, pgstore.ErrRunInFlight } @@ -187,6 +217,21 @@ func (s *Service) Start(ctx context.Context, in StartRequest) (pgstore.Run, erro return started.Run, nil } +// readyToTranslate is the half of the book vocabulary a run may start from: everything a finished +// intake can leave behind, plus every state a previous run can leave a parsed book in. +// +// Written as an allowlist and not as "not uploading, not parsing, not rejected": a status added +// later is one this function has never thought about, and refusing it is the safe direction — the +// user gets a 409 they can report, rather than an engine started against a book in a state nobody +// designed for. +func readyToTranslate(status string) bool { + switch status { + case "not_started", "translating", "awaiting_bank", "finalizing", "ready", "paused", "stopped", "failed": + return true + } + return false +} + func (s *Service) enqueue(ctx context.Context, tx pgstore.Tx, runID string) error { if s.Queue == nil { return nil // no queue configured: the reconciler still picks the run up on its next sweep diff --git a/platform/internal/runs/spawn.go b/platform/internal/runs/spawn.go index 8e72a680..8f183623 100644 --- a/platform/internal/runs/spawn.go +++ b/platform/internal/runs/spawn.go @@ -52,6 +52,14 @@ func (s *Service) spawnAttempt(ctx context.Context, l pgstore.LiveRun) error { if err := s.runnable(); err != nil { return err } + // A run the user has already asked to stop is not started. The claim in RecordSpawn refuses the + // window this cannot see — the intent written after this read — but the ordinary case is a stop + // pressed on a queued run before its worker got to it, and starting an engine there would spend + // the account's money on work that was cancelled before it began. + if l.StopRequestedAt != nil { + s.log().InfoContext(ctx, "not starting a run that has been asked to stop", "run", l.RunID) + return nil + } // The engine's own money state for this book, read BEFORE anything is started, because afterwards // both of its numbers have already moved. It decides two different things: what this attempt will // owe when it ends (the difference from the committed figure) and what limit it may be given (the diff --git a/platform/internal/runs/sweep_test.go b/platform/internal/runs/sweep_test.go index 928d8e84..7d54cf56 100644 --- a/platform/internal/runs/sweep_test.go +++ b/platform/internal/runs/sweep_test.go @@ -1431,3 +1431,38 @@ func TestAResumeMovesToANewEngineBuildOnlyWhenItIsAllowed(t *testing.T) { t.Fatalf("the resumed attempt ran %q, want the new build", starts[len(starts)-1].Binary) } } + +// Cross-family review of the acceptance dofix (M3): the same hold, on the host that actually produces +// this case. An attempt is unspawned BECAUSE the engine could not be run — and settlement used to ask +// that same engine for a committed spend before it would give the money back, so every pass failed on +// the call and the hold of a run that never ran stayed reserved for good. +func TestTheHoldOfARunThatNeverStartedComesBackOnAHostWhoseEngineCannotAnswer(t *testing.T) { + f := newFixture(t, "10", 500) + run, err := f.svc.Start(f.ctx, StartRequest{UserID: "u1", BookID: f.bookID(t), CeilingChapters: 100}) + if err != nil { + t.Fatal(err) + } + live := f.live(t) + if live.UnitName != "" { + t.Fatal("the attempt was spawned; this test is about one that never was") + } + f.engine.set(ingest.StatusReport{}, errors.New("tmctl status: exec: no such file or directory")) + if err := runner.WriteMarker(f.svc.markerPath(run.ID, live.AttemptNo), + runner.Marker{Unit: "u", Result: "exit-code", Code: "exited", Status: "1"}); err != nil { + t.Fatal(err) + } + if err := f.svc.Sweep(f.ctx); err != nil { + t.Fatal(err) + } + acct := f.account(t) + if acct.Reserved != 0 { + t.Fatalf("reserved %s: the hold of a run that never started waits on an engine that cannot answer", + acct.Reserved.USD()) + } + if acct.Balance != money.MicroUSD(10_000_000) { + t.Errorf("balance %s: a run that never started must cost nothing", acct.Balance.USD()) + } + if f.engine.called() != 0 { + t.Errorf("the engine was asked %d times about an attempt that never reached it", f.engine.called()) + } +}