diff --git a/platform/cmd/tmplatformctl/backup_test.go b/platform/cmd/tmplatformctl/backup_test.go index 5d37dab7..b4fdde4f 100644 --- a/platform/cmd/tmplatformctl/backup_test.go +++ b/platform/cmd/tmplatformctl/backup_test.go @@ -1,6 +1,7 @@ package main import ( + "errors" "os" "path/filepath" "strings" @@ -69,11 +70,19 @@ func TestBackupSaysWhenItDoesNotKnowWhereToLook(t *testing.T) { // command for that is a one-word command for destroying the current state by mistake. The procedure // lives in deploy/README.md and inside the point's own manifest. func TestThereIsNoOneWordRestoreCommand(t *testing.T) { - t.Setenv("TM_PLATFORM_DSN", "") + // ⚠ WITH a database, and that is not decoration: with `TM_PLATFORM_DSN` unset the dispatcher + // refuses at the DSN gate before it ever looks at the subcommand, so the test would pass over a + // `restore` command that existed. Acceptance caught exactly that — the assertion below now + // reaches the thing it is about. + t.Setenv("TM_PLATFORM_DSN", freshDB(t)) var out strings.Builder - if err := run([]string{"restore"}, &out); err == nil { + err := run([]string{"restore"}, &out) + if err == nil { t.Fatal("a `restore` command exists; the runbook's deliberate absence has been undone") } + if !errors.Is(err, errUsage) { + t.Errorf("`restore` was refused for some reason other than being unknown: %v", err) + } if !strings.Contains(usage, "backup") { t.Error("usage does not mention the backup command at all") } diff --git a/platform/deploy/README.md b/platform/deploy/README.md index aad68303..727462e1 100644 --- a/platform/deploy/README.md +++ b/platform/deploy/README.md @@ -285,6 +285,12 @@ install -m0644 mining-contrast.zh.txt /opt/textmachine/configs/mining-contrast.z /opt/textmachine/engine/<версия>/tmctl redrive --config /srv/textmachine/books//book.yaml --dry-run ``` +⚠ **ЭТА ПРОВЕРКА ОТКРЫВАЕТ ПРОЕКТ НА ЗАПИСЬ, и на книге с идущим прогоном её запускать нельзя.** +`redrive` даже с `--dry-run` идёт пишущим путём (`pipeline.NewRunner`), а значит берёт эксклюзивный +flock проекта и мигрирует файл при необходимости. На занятой книге это отказ класса 12 («ничего не +произошло, подожди») — не порча, но и не проверка. Гоняйте на книге, по которой прогонов нет: +`tmplatformctl books` покажет, у кого их нет. + ⚠ **СУДИТЬ ПО СООБЩЕНИЮ, А НЕ ПО КОДУ ВЫХОДА — оба исхода дают `exit 10`.** Замерено 05.09 обеими ветками: ``` @@ -297,15 +303,21 @@ install -m0644 mining-contrast.zh.txt /opt/textmachine/configs/mining-contrast.z до жалобы на ключи можно только миновав контраст. Ключей у `redrive` в SaaS-развёртывании и не будет: они едут `--keys-file`, а его движок принимает только у `translate`. -⚠ **И ЕЩЁ ОДНО СЛЕДСТВИЕ, КОТОРОЕ ВИДНО НЕ В ОТКАЗЕ, А В ДЕНЬГАХ.** Книжное слагаемое цены -(`book_once_usd` в `manifest --json`) условно ровно на этой паре фактов — контур включён И -`contrast_path` задан. Конфиг деплоя, где ключ ПОТЕРЯН (а не файл), проходит загрузку и отдаёт -книжное слагаемое НУЛЁМ: платформа увидит книгу дешевле, чем она есть. То есть «забыл строчку в -конфиге» и «положил файл не туда» дают РАЗНЫЕ симптомы — второй громкий (`exit 10`), первый тихий и -денежный. ⚠ Само число — **граница, а не прогноз**: вход терминолога производится из черновиков, -которых в момент публикации числа нет, поэтому движок честно называет лишь сумму бюджетов обеих -стадий, которую не превысит. Для вопроса «какой потолок нужен книге» это и есть полезная цифра; как -«сколько будет в счёте» её читать нельзя. +⚠ **ДВЕ РАЗНЫЕ ОШИБКИ РАЗВЁРТЫВАНИЯ, И ГРОМКАЯ — НЕ ТА, О КОТОРОЙ ДУМАЕШЬ.** ⛔ Прежняя редакция +этого абзаца утверждала обратное и была НЕВЕРНА: она называла тихой потерю КЛЮЧА и громкой потерю +файла. Замер обеих веток (05.09, `tmctl manifest --json` на одной книге): + +| что потеряно | код выхода | что видит оператор | +|---|---|---| +| **строчка `contrast_path` в конфиге** | **`10`** | конфиг НЕ ЗАГРУЖАЕТСЯ вовсе, отказ на ЛЮБОМ пути, включая `$0`-читающие | +| **сам файл артефакта** | **`0`** | всё работает, манифест отдаётся; отказывает только ПИШУЩИЙ путь — каждый `translate` и резюм | + +Причина в коде и она намеренная: пустой ключ судится ПРИ ЗАГРУЗКЕ +(`backend/internal/config/pipeline.go`, греп `mining.contrast_path` is not set), а наличие ФАЙЛА — +только на пишущем пути (`CheckMiningContrast`), потому что `LoadPipeline` лежит и на `$0`-поверхностях, +и отказывать `status` из-за отсутствующего файла данных — ошибка класса D20.4. +⇒ **Забытая строчка в конфиге ловится немедленно и всем; забытый файл — только первым платным +прогоном.** Опасен второй, и именно поэтому раздел выше велит класть артефакт ПЕРЕД выкаткой. ⚠ **Порядок в первой выкатке: артефакт → шаблон книги → демон.** Книга, заведённая раньше файла, не ломается и ничего не теряет — отказ приходит на старте прогона, а не на интейке, — но пользователь @@ -744,6 +756,12 @@ install -d -m0750 -o tmplatform -g tmplatform /var/backups/tmplatform потерей книжного канона молча. ⚠ **Всё пропущенное ИМЕНУЕТСЯ в манифесте** (`books[].not_copied`) с причиной — чтобы через полгода читатель не выводил правила из того, что уцелело. +⚠ **И ТОЧКА — НЕ МГНОВЕНИЕ ВРЕМЕНИ, а последовательность:** сперва снимается денежный реестр, потом +книги одна за другой. На большой библиотеке между дампом и последней книгой проходят минуты, а то и +десятки минут. `complete: true` означает «всё, что должно было попасть, попало», а НЕ «всё снято в +один момент»: прогон, начавшийся после дампа, будет виден в книге и не виден в реестре. Для +восстановления это безвредно — реконсилятор доводит расчёт по exit-маркеру и журналу, — но читать +точку как согласованный снимок нельзя. ⚠ **Про последний — точнее, потому что первая редакция этого абзаца говорила «наши копии их замещают», и это НЕВЕРНО.** `<каталог книги>/backups/` — это ПРЕДРЕЙСОВЫЕ точки, которые движок @@ -808,6 +826,16 @@ tmplatformctl backup --verify 20260905T120000Z --dir /var/backups/tmplatform # 1. Остановить приём: демон не должен писать в то, что восстанавливается. systemctl stop tmplatformd +# 1а. ⚠ И ЭТОГО МАЛО: ПРОГОНЫ ДЕМОНУ НЕ ДЕТИ. Каждый — транзиентный юнит в СОБСТВЕННОМ менеджере +# пользователя `tmplatform` и переживает остановку контрол-плейна по построению (D39.106, §«Что +# юнит закрывает содержательно» выше). Восстановление делают в худший день — именно тогда +# вероятен ИДУЩИЙ оплаченный прогон, и шаг 4 подсунул бы файл под открытым дескриптором. +# Спросить и, если есть, остановить — и только потом трогать каталоги: +systemctl --user -M tmplatform@ list-units 'tm-run-*' --no-legend # что живо в ИХ менеджере +systemctl --user -M tmplatform@ stop 'tm-run-.service' # каждый, что нашёлся +# ⚠ Останов прогона НЕ теряет его работу: `SIGTERM` = «дочти чанк и отпусти лок», а реконсилятор +# закроет его штатно после подъёма демона. Терять нечего, ждать — до одного чанка. + # 2. Денежный реестр и вся библиотека — одним дампом. # ⚠ DSN в рекомендуемой раскладке лежит ФАЙЛОМ (`TM_PLATFORM_DSN_FILE=/etc/tmplatform/dsn`), а не # переменной в шелле оператора: в худший день `$TM_PLATFORM_DSN` пуст, и шаг падает первым. @@ -827,7 +855,13 @@ rm -f /srv/textmachine/books//*.db-wal /srv/textmachine/books/ mv /srv/textmachine/books//project.db /srv/textmachine/books//.db chown -R tmplatform:tmplatform /srv/textmachine/books/ -# 5. Поднять и проверить: баланс на месте, книга на месте, движок открывает её файл. +# 5. ⚠ ОТОЗВАТЬ УЧЁТНЫЕ ДАННЫЕ, ЕСЛИ ОТЗЫВ СЛУЧИЛСЯ ПОСЛЕ СНЯТИЯ ТОЧКИ. Дамп несёт таблицу сессий +# целиком, поэтому восстановление ВОСКРЕШАЕТ всё, что было живо в момент снятия — включая +# браузерную сессию или bearer-токен, отозванные позже. Это не дефект бэкапа (копия обязана быть +# копией), это шаг чек-листа: +tmplatformctl revoke --user # для каждого, кому отзывали доступ после штампа точки + +# 6. Поднять и проверить: баланс на месте, книга на месте, движок открывает её файл. systemctl start tmplatformd tmplatformctl balance --user /opt/textmachine/engine/<версия>/tmctl status --config /srv/textmachine/books//book.yaml diff --git a/platform/docs/DEFECT_REGISTER.md b/platform/docs/DEFECT_REGISTER.md index eda07422..bdca1b15 100644 --- a/platform/docs/DEFECT_REGISTER.md +++ b/platform/docs/DEFECT_REGISTER.md @@ -48,7 +48,7 @@ | PD-375 | bug | minor | `internal/runs/runs.go:354`=`if in.CeilingChapters < bounds.Min`, `internal/httpapi/v0_test.go:359`, `internal/runs/control_test.go` | **Верхнюю половину проверки `ceiling_chapters` не исполняет НИ ОДИН тест: снятие второго операнда `in.CeilingChapters > bounds.Max` проходит ВСЮ батарею.** Собственный доккомментарий называет проверку несущей — «число, решающее СКОЛЬКО ДЕНЕГ резервируется, не может быть выбрано вызывающим односторонне», — и канон требует того же от сервера (`max_chapters` уже прижат к остатку, «a client MUST NOT clamp it again»). Единственный тест, называющий `ErrCeilingOutOfBounds`, это таблица соответствия ошибки коду 409 в `httpapi/v0_test.go`, которая `Start` не зовёт, а самая тесная фикстура просит 10 глав у книги, где их 100. Код сегодня ВЕРЕН; дефект в том, что править эту строку можно безнаказанно. Замерено на живом стенде: чистая сборка отвечает `409 ceiling_unavailable/bounds_moved`, сборка с мутацией отдаёт 202 и открывает резервацию `24000000` микро на книге из ТРЁХ глав, а движку уходит `--ceiling-usd 24.200000`. Вес: рефутер сузил major → minor, потому что код верен и ни один сегодняшний клиент до вреда не доходит. Воспроизведение: `docs/p8-review/axis1-money/a1-mut5-ceiling-bound.sh`, готовый пин `docs/p8-review/axis1-money/r1_ceiling_bound_test.go.txt` | open | ревью-пак P8-REVIEW, ось 1 (посадка мутации + живая проба, подтверждено рефутером) | | PD-377 | doc | minor | `internal/pgstore/credits.go:162`=`The ceiling to hand the engine is the amount held`, `internal/runs/spawn.go` `bookCap`, `cmd/tmplatformctl/main.go` `balance` | **Доккомментарий `Hold` на входе в денежный путь неверен ОБЕИМИ половинами с миграции 00011.** Он обещает «the ceiling to hand the engine is the amount held; read it back with OpenReservations». Движку передаётся не сумма холда, а `run_attempts.ceiling_arg_micro_usd` = committed книги плюс прирост (`spawn.go` `bookCap`), и сама 00011 говорит это прямым текстом («It is NOT ceiling_micro_usd»); начиная со ВТОРОГО прогона книги числа расходятся тем сильнее, чем дороже книга — на стенде до 17 раз (`60000` холда против `1060000` в аргументе). Вторая половина не работает даже механически: `Reservation.Ceiling` — поле, которое только сканируется и не читается никем, единственный потребитель `OpenReservations` печатает `Amount`/`BookID`/`OpenedAt`/`EngineRunID`. ⚠ Рефутер снял два из трёх исходных якорей: доккомментарии в применённых миграциях `00007`/`00009` зона не правит после лендинга (у всех 25 файлов миграций ровно по одному коммиту), а исправление уже лежит в следующем файле того же каталога. Остаётся Go-доккомментарий. Воспроизведение: `docs/p8-review/axis1-money/a1-ceiling-column-doc.sql` | open | ревью-пак P8-REVIEW, ось 1 (живой стенд, сужено рефутером до одного якоря) | | PD-386 | bug | minor | `cmd/tmplatformd/runner.go:475`=`out := []sweepPass{{"runs", sweepBudget, s.runs.Sweep}}` и четыре следующих прохода того же `one()`, `cmd/tmplatformd/runner_test.go` | **Такт свипа последователен, и бюджеты пяти проходов СКЛАДЫВАЮТСЯ: сумма объявленных — 37 минут при `TM_PLATFORM_SWEEP_EVERY` 15 секунд.** Пак P8-FIX дал каждому проходу свой бюджет и закрыл «один проход съедает дедлайн другого», но проходы по-прежнему идут подряд в одной горутине: runs 2м, readmodel 10м, intake 21м, idempotency 2м, observe 2м. Ничто эту сумму не ограничивает и ничто её не пинит — у функции `sweep` нет ни одного теста (в пакете два теста, оба про другое), и посадка «фаза runs уходит из головы такта в хвост» пережила ПОЛНУЮ батарею. Замерено пробой на реальной функции: за 4 секунды при такте 100 мс фаза прогонов отработала 40 раз сама по себе и 9 раз позади прохода материализации. Следствия по оси: калибровки самого лечения заданы в проходах и минутах и молча растягиваются (`StalledAfter` 5 «неудач подряд» и бэкофф, про который комментарий обещает «about a quarter of an hour», превращаются в часы); обещание `Stop` «the reconciler re-issues the stop on its next pass» задерживается на ту же величину; телеметрия стоит ПОСЛЕДНЕЙ. ⚠ Рефутер сузил: 37 минут — сумма ОБЪЯВЛЕННЫХ бюджетов, а не достижимая длительность (idempotency это один индексированный DELETE, а проход интейка сам себя режет). Воспроизведение: `docs/p8-review/axis3-queue/probe_sweep_serialisation_test.go.txt` | open | ревью-пак P8-REVIEW, ось 3 (проба на реальной функции + посадка мутации, сужено рефутером) | -| PD-387 | doc | minor | `deploy/README.md:580`=`сколько всего может занять один проход свипа`, `cmd/tmplatformd/runner.go` `one`, `internal/config/config.go` `SweepBudget` | **Рантбук называет `TM_PLATFORM_SWEEP_BUDGET` ручкой «одного прохода свипа» и не говорит, КАКОГО из пяти, — а два бюджета из пяти оператору недоступны вовсе.** Один такт прогоняет ПЯТЬ проходов подряд, и только три берут `sweepBudget`; проход материализации берёт `refreshSweepBudget` 10 минут, проход интейка — `intakeSweepBudget` = `jobs.JobTimeout` + `readmodel.MaterializeBudget` + минута = 21 минута, и обе константы оператору недоступны вовсе. Собственный доккомментарий кода при этом ТОЧЕН («SweepBudget is what ONE pass of the RUN sweep may take») — расходится именно операторская проза, и расходится в разделе «Застрявшая работа: что оператор делает, когда свип не справляется», то есть там, где по ней и будут действовать. Родня `PD-368`: та про то, что поднимать надо пару, эта про то, что ручка не покрывает такт ⚠ Арифметика 37 минут пере-проверена и держится: 3×2 + 10 + 21. Воспроизведение — `docs/p8-review/axis3-queue/probe_sweep_serialisation_test.go.txt` (последовательность тактов) плюс `sed -n '264,272p' deploy/README.md` | open | ревью-пак P8-REVIEW, ось 3 (находка рефутера) | +| PD-387 | doc | minor | `deploy/README.md:592`=`сколько всего может занять один проход свипа`, `cmd/tmplatformd/runner.go` `one`, `internal/config/config.go` `SweepBudget` | **Рантбук называет `TM_PLATFORM_SWEEP_BUDGET` ручкой «одного прохода свипа» и не говорит, КАКОГО из пяти, — а два бюджета из пяти оператору недоступны вовсе.** Один такт прогоняет ПЯТЬ проходов подряд, и только три берут `sweepBudget`; проход материализации берёт `refreshSweepBudget` 10 минут, проход интейка — `intakeSweepBudget` = `jobs.JobTimeout` + `readmodel.MaterializeBudget` + минута = 21 минута, и обе константы оператору недоступны вовсе. Собственный доккомментарий кода при этом ТОЧЕН («SweepBudget is what ONE pass of the RUN sweep may take») — расходится именно операторская проза, и расходится в разделе «Застрявшая работа: что оператор делает, когда свип не справляется», то есть там, где по ней и будут действовать. Родня `PD-368`: та про то, что поднимать надо пару, эта про то, что ручка не покрывает такт ⚠ Арифметика 37 минут пере-проверена и держится: 3×2 + 10 + 21. Воспроизведение — `docs/p8-review/axis3-queue/probe_sweep_serialisation_test.go.txt` (последовательность тактов) плюс `sed -n '264,272p' deploy/README.md` | open | ревью-пак P8-REVIEW, ось 3 (находка рефутера) | | PD-389 | hardening | minor | `cmd/tmplatformd/runner.go:563`=`s.metrics.ObserveRunner(metrics.Runner{`, `cmd/tmplatformd/runner.go` `pass`, `internal/metrics/metrics_test.go` `TestTheRunnersStateIsExposedWithItsUnits` | **Шов телеметрии не покрыт НИЧЕМ, и это доказуемо без прогона батареи: `sweep` и `observe` — неэкспортируемые функции пакета `main`, то есть из другого пакета их не может вызвать ни один тест в принципе,** а единственный тест-файл каталога несёт два теста, оба про другое. Проверено тремя посадками, пережившими полную батарею: `StalledRuns: o.StalledRuns` в ноль (наблюдаемая половина закрытого BLOCKER `PD-346`), `errors.Is(err, context.DeadlineExceeded)` в false (`sweep_unfinished_total` больше не может вырасти — `PD-351` со стороны ВЫЗЫВАЮЩЕГО, куда пин `TestAPassThatRanOutOfTimeSaysSo` по построению не достаёт), и перестановка `queue_depth` с `live_runs`. Дыра шире шва: пин формы, на который ссылается `STACK_DECISIONS` §24, задаёт литерал `metrics.Runner` из ШЕСТИ полей из восьми — `StalledRuns` и `AbandonedSurfaces` в него не входят, поэтому мутация внутри самого `ObserveRunner` тоже выживает. Пере-проверено координатором пака независимо: снятие `m.stalledRuns.Set(...)` и снятие `m.abandonedSurfaces.Set(...)` по отдельности проходят ПОЛНУЮ батарею (18 пакетов), при том что снятие соседнего инкремента `sweepUnfinished` тем же пином ловится. То есть операторская ручка, построенная паком P8-FIX в ответ на `PD-169`, не пиньётся ничем. Воспроизведение: `docs/p8-review/mutations-full.log` и `docs/p8-review/axis4-metrics/60-mutations.sh` | open | ревью-пак P8-REVIEW, ось 4 (посадки финдера, рефутера и координатора) | | PD-390 | hardening | minor | `cmd/tmplatformd/runner.go:560`=`log.Warn("the control plane's own state could not be read", "err", err)`, `internal/pgstore/observe.go` `Observe`, `internal/metrics/metrics.go` `ObserveRunner` | **Гейджи замирают при отказе телеметрического чтения, и признака устаревания в экспозиции нет.** `STACK_DECISIONS` §24 объявляет «значения снимает СВИП, а не скрейп», но у снятого значения нет ни отметки свежести, ни счётчика неудач: `observe()` при ошибке пишет один WARN и возвращается, НЕ тронув ни одного гейджа, а Prometheus такой ряд устаревшим не помечает — цель жива, ряд на месте, значение старое. Живой замер: при сломанном чтении и одновременно вылеченном мире экспозиция продолжала утверждать «1 застрявший прогон, 1 карантин, 1 живой прогон, холд возрастом 1201 с», тогда как в базе застрявших было 0; при этом `sweep_duration_seconds_count` рос по всем четырём проходам, то есть все «жив ли свип» сигналы оставались зелёными. `Observe` — ОДИН стейтмент на все восемь чисел, поэтому любая его поломка гасит все гейджи разом, а сам `observe()` вызывается ВНЕ `pass()`, поэтому своего ряда в `sweep_duration_seconds` у него нет и его отказ там не виден. Обратное направление хуже: процесс, у которого чтение не удалось НИ РАЗУ, отдаёт нули как здоровье, и `/readyz` с `/healthz` при этом зелёные. ⚠ Вторая половина того же корня, найденная рефутером: инстанс-ЧИТАТЕЛЬ (пустой `TM_PLATFORM_ENGINE_BIN` — объявленная форма деплоя) не запускает свип вовсе, `observe()` не зовётся ни разу, а метрики созданы раньше и регистрируют все восемь гейджей безусловно, поэтому реплика уверенно отвечает `runs_stalled 0`, `queue_depth 0`, `oldest_open_hold_seconds 0` про контрол-плейн, который она не измеряет — и тут нет даже WARN-строки. Лечится дёшево: `*_last_success_timestamp_seconds` либо счётчик неудач наблюдения плюс проведение `observe` через тот же `pass`. Воспроизведение: `docs/p8-review/axis4-metrics/30-stale-gauges.sh`, `r1-boot-with-blind-telemetry.sh`, `r6-read-replica-zeroes.sh` | open | ревью-пак P8-REVIEW, ось 4 (живой замер, расширено рефутером) | | PD-392 | hardening | minor | `internal/metrics/metrics.go:91`=`Namespace: namespace, Name: "quarantined_attempts"`, `internal/metrics/metrics.go` `oldest_open_hold_seconds`, `cmd/tmplatformctl/runs.go` `listRuns` | **Две метрики без ручки — тот самый класс, за который `PD-169` стоял BLOCKER'ом, в двух других местах.** Help гейджа карантина сам называет цену («Such a run keeps going and keeps spending»), но команды, называющей строку за этим числом, нет: `grep -rn quarantine cmd/` не даёт ни одного хита, и в таблице `runs` колонки карантина тоже нет. У возраста холда ручка формально есть — `balance --user`, — но она требует идентификатор аккаунта, которого гейдж не даёт, а документированный случай самого гейджа («A hold outlives its run only when a settlement could not be made») — это холд ЗАКОНЧЕННОГО прогона, которого список не показывает по построению. Живая проба на состоянии, произведённом ШТАТНЫМ операторским сценарием (abandon застрявшего прогона): при `tm_platform_oldest_open_hold_seconds 10813` команды отвечают «no run is live», «no run is failing to reconcile» и «no book has been given up on», а единственный путь к строке — psql, то есть ровно то, что эти числа заводились заменить. Глобального списка открытых холдов в CLI нет. Воспроизведение: `docs/p8-review/axis4-metrics/50-gauges-without-a-handle.sh` и `r5-hold-without-a-handle.sh` ⚠ **Общий корень с `PD-385`, и там же он взвешен:** сужение ЭТОЙ строки опирается на операторскую поверхность, несостоятельность которой доказывает соседняя строка того же пака — круговое сужение разобрано в `PD-385`, поднятой до major ⚠ **ПАК P13 03.09: половина про КАРАНТИН закрыта в дереве, строка сужается до возраста холда.** `grep -rn quarantine cmd/` теперь даёт хиты: `tmplatformctl runs` печатает колонку QUARANTINE с причиной, `tmplatformctl run unquarantine --run ` снимает её (`PD-426`). Ручки для `oldest_open_hold_seconds` по-прежнему нет — этот остаток и держит строку открытой. Статус — акт лендинга | open | ревью-пак P8-REVIEW, ось 4 (живая проба, подтверждено рефутером) | @@ -89,7 +89,7 @@ | PD-298 | bug | info | `internal/pgstore/readmodel.go` `ListNotes`, `internal/pgstore/sink.go` `unitDone` | **Снятие флага с замечания дельта-чтение выразить не может.** Резолюция, пере-разрешённая как не-`flagged` (редрайв), обновляет строку и двигает `revision`, но дельта фильтруется предикатом `ur.flagged` — строка не возвращается, и клиент никогда не узнаёт, что замечание снято: оно остаётся на экране навсегда. Канон §AfterVersion: «A DELETION cannot be expressed this way», и требует одного из двух ответов — `resync_required` либо `400 version_too_old`; здесь не даётся ни один. ⚠ НЕ подтверждено, что движок вообще пере-издаёт `unit_done` для той же тройки (глава, юнит, волна) с `flagged=false` — комментарий `sink.go` это УТВЕРЖДАЕТ («a redrive re-attacks a flagged one»), но чтением движка не сверено. Порядок: сначала сверка у движка, потом либо счётчик замены для замечаний, либо строка «переход недостижим» ⚠ **ДИСПОЗИЦИЯ АКТА 5 (сверка с движком контрактной сессией 20.08): переход НЕДОСТИЖИМ и механизма не строим.** Движок объявляет вердикт юнита один раз на волну, его announce-once-леджер не пере-announce-ит, поэтому единственная пере-доставка — ТОТ ЖЕ вердикт. Инвариант записан в коде (`sink.go` `unitDone`): если что-то научится снимать флаг, фолд обязан выдать кадр — иначе счётчик разъедется со списком молча. Строка держится открытой этим долгом, а не живым дефектом | open | приёмка P7 → доработка 20.08 (сверка) | | PD-299 | standards | info | `internal/httpapi/conditional.go` `acceptsGzip` | **`Accept-Encoding: identity;q=0` не отвечает `406`.** Клиент, потребовавший ЛЮБОГО кодирования кроме identity, получает identity. Половина RFC 9110 §12.5.3, которую правка PD-268 не закрыла: gzip-сторона (именованное кодирование выигрывает у `*`, нулевой вес — отказ) закрыта и пиньётся, эта — нет. Достижимо только специально сконструированным клиентом; ни один генерённый по контракту клиент так не делает | open | доработка 20.08 (сверка находок против дерева) | | PD-368 | hardening | info | `internal/config/config.go`, `internal/runs/reconcile.go` `phaseBudget` | **Пара `TM_PLATFORM_SWEEP_BUDGET`/`TM_PLATFORM_RUN_BUDGET` не проверяется на когерентность на буте, и поднять ОДИН из них — тихий no-op:** фазе достаётся половина прохода, поэтому любое значение `RunBudget` от половины прохода и выше наблюдаемо неотличимо от дефолта. Ревью заходило сюда как в major («поднять бюджет = объявить здоровый прогон застрявшим») и **это опровергнуто исполнением**: поднятие ручки не меняет вообще ничего, вердикт при дефолтах существует и без оператора, а строгая проверка `RunBudget < SweepBudget/2` отвергла бы сами шиппящиеся дефолты (60 с против 60 с). Остаётся эргономика: поднимать надо `SweepBudget`, и об этом не сказано нигде, кроме доккоммента ⚠ **ПАК P8-REVIEW 24.08: остаток («поднимать надо `SweepBudget`, и об этом не сказано нигде, кроме доккомментария») УЖЕ ЗАКРЫТ, и закрыт тем же паком, чья приёмка эту строку написала.** `deploy/README.md` несёт таблицу обеих ручек и прямо под ней ⚠-абзац «Поднимать надо ПАРУ, а не одну… `RUN_BUDGET` выше половины `SWEEP_BUDGET` наблюдаемо ничего не меняет», со ссылкой на этот же номер. Верным остаётся только то, что когерентность пары не проверяется на буте. ⚠ Связанное, но ДРУГОЕ: сама ручка не покрывает такт свипа целиком — `PD-387` ⚠⚠ **Уточнение рефутера, меняющее диспозицию: ЗАКРЫВАТЬ строку ЦЕЛИКОМ нельзя, только сузить.** Абзац рантбука приехал коммитом `31f1f82` — ТЕМ ЖЕ, который внёс саму строку, то есть остаток родился уже закрытым. А проверки когерентности пары на буте по-прежнему нет: `internal/config/config.go:460-463` грузит обе ручки без сверки. Сузить до этого и оставить `open`. ⚠ И соседство: абзац стоит под строкой `deploy/README.md` (греп `деньги В СВОЕЙ транзакции`), которая сама предмет открытой `PD-387` | open | воркфлоу-ревью волны 2 (P8-FIX), находка опровергнута, остаток зафиксирован | -| PD-373 | doc | info | `internal/readmodel/readmodel.go:64`=`const maxAttempts = 5`, `deploy/README.md:565`=`После пяти неудач` | **У числа попыток материализации ДВА носителя и ничего между ними.** Код держит `const maxAttempts = 5`, рантбук оператора пишет «После пяти неудач долг списывается». **Посажена мутация оркестратором вне списка автора:** `maxAttempts` 5 → 500000, батарея зелёная — пин `readmodel_test.go` ездит `for attempts := range maxAttempts`, то есть доказывает МЕХАНИЗМ при любом значении константы, что само по себе правильно. Незакрытым остаётся другое: подняли константу — рантбук молча начал лгать оператору о том, когда платформа сдаётся. ⚠ Тот же ход у `runs.StalledAfter` проверен и НАРУШЕНИЯ НЕ ДАЛ: там носитель ровно один (рантбук пишет «сколько неудач подряд» без числа), мутация тоже выжила и это законно. Лечение — либо гейт на второй носитель ровно той формы, что пак построил для `ContractVersion` (`internal/gates/contract_test.go` читает канон, а не копию числа), либо число уходит из прозы | open | приёмка P8-FIX (посадка мутации оркестратором №18) | +| PD-373 | doc | info | `internal/readmodel/readmodel.go:64`=`const maxAttempts = 5`, `deploy/README.md:577`=`После пяти неудач` | **У числа попыток материализации ДВА носителя и ничего между ними.** Код держит `const maxAttempts = 5`, рантбук оператора пишет «После пяти неудач долг списывается». **Посажена мутация оркестратором вне списка автора:** `maxAttempts` 5 → 500000, батарея зелёная — пин `readmodel_test.go` ездит `for attempts := range maxAttempts`, то есть доказывает МЕХАНИЗМ при любом значении константы, что само по себе правильно. Незакрытым остаётся другое: подняли константу — рантбук молча начал лгать оператору о том, когда платформа сдаётся. ⚠ Тот же ход у `runs.StalledAfter` проверен и НАРУШЕНИЯ НЕ ДАЛ: там носитель ровно один (рантбук пишет «сколько неудач подряд» без числа), мутация тоже выжила и это законно. Лечение — либо гейт на второй носитель ровно той формы, что пак построил для `ContractVersion` (`internal/gates/contract_test.go` читает канон, а не копию числа), либо число уходит из прозы | open | приёмка P8-FIX (посадка мутации оркестратором №18) | | PD-374 | doc | info | `docs/STACK_DECISIONS.md` «Гейты батареи», `internal/runner/systemd_test.go` `systemdOrSkip`, `Makefile` цель `check` | **Рецепт объявляет у батареи ДВА гейта и ждёт «скипов 0» — а условий три, и третье не названо.** Кроме `TM_PLATFORM_TEST_DSN` и пары `TM_PLATFORM_TEST_ENGINE_BIN`/`_BOOK_TEMPLATE` есть `systemdOrSkip`: без ДОСТИЖИМОГО пользовательского менеджера systemd три теста `internal/runner` скипаются. Поймано пере-прогоном батареи при приёмке: на этом хосте `/run/user/1000` не существует (сессия logind не поднята), поэтому «скипов 0» недостижимо в принципе — `make check` дал 18 пакетов, exit 0, линтер 0 issues и **3 скипа**. Мимо: сама цель `check` печатает над списком скипов «set TM_PLATFORM_TEST_DSN», отправляя читателя к ручке, которая тут ни при чём. Отчёт пака честен и это подтверждает — он мерил отдельно и получил 0, что верно на хосте с живым менеджером. Лечение: назвать третий гейт в рецепте вместе с двумя и не обещать «скипов 0» без него; заодно сделать сообщение цели `check` не называющим одну переменную из трёх ⚠ **ПАК P8-REVIEW 24.08: половина лечения УЖЕ в дереве.** `docs/ENGINEERING_STANDARDS.md` §3 п.1 переписан и называет ТРИ условия поимённо, включая достижимый пользовательский менеджер systemd, со ссылкой на этот номер. Названные строкой носители не тронуты: `docs/STACK_DECISIONS.md` по-прежнему пишет «Гейты батареи — их ДВА» и «скипов 0», а сообщение цели `check` в `Makefile` называет одну переменную из трёх. Предложение пака: сузить до этих двух. ⚠ На ЭТОМ хосте все три условия выполнимы (`/run/user/1000` жив), поэтому пак снял базовую линию со скипами 0 — см. `docs/p8-review/battery-final.log` ⚠ **ПАК P13 03.09: половина `Makefile` вылечена в дереве.** Цель `check` над списком скипов печатает условия хоста через новую цель `make conditions`, и перечень ВЫВОДИТСЯ из тестовых исходников по ВЫЗОВУ, а не по имени в прозе: `os.Getenv("TM_PLATFORM_TEST_…")` даёт переменные, `exec.LookPath("…")` — бинари, которых требуют хелперы (`systemd-run`, `python3`, `make`), плюс своя проба `systemdOrSkip` (достижимый пользовательский менеджер systemd); отдельной строкой назван CREATEDB, который спрашивается только при создании скретч-базы и пробой не проверяется. Литерала с одной переменной больше нет. Гейт `internal/gates` `TestTheBatteryNamesEveryHostConditionItsTestsRead` держит цель к исходникам: краснеет, когда перечень перестаёт выводиться (литеральный список), когда колонка состояния печатает слово вместо ответа хоста и когда колонка «read by» приписывает условие пакету, который его не читает. ⚠ Новая переменная в любом тесте гейт НЕ краснит — цель и гейт выводят её из одних и тех же исходников, поэтому она появляется в обоих; краснеет именно ОТКАЗ от вывода. `STACK_DECISIONS` «Гейты батареи» уже называет четыре условия. Статус — акт лендинга | open | приёмка P8-FIX (пере-прогон батареи оркестратором №18) | | PD-6 | hardening | info | `internal/auth/csrf.go:51` | GET освобождён от CSRF (верно), но SSE-хендшейк — GET с амбиентной кукой: origin-чек хендшейка потока (STACK §5) не покрыт ничем. Закрыть при постройке SSE (P1) ⚠ **ПАК P8-REVIEW 24.08: условие закрытия НАСТУПИЛО, лекарство не приехало, а защита оказалась ТРАНЗИТИВНОЙ.** SSE построен (`internal/httpapi/v0.go` маршрут `/books/{bookId}/events`, `internal/httpapi/stream.go` `streamEvents`, коммит `9b23e8c`), origin-чека не появилось: GET освобождён в `internal/auth/csrf.go` `cookieUnsafe`, а `http.CrossOriginProtection` судит только unsafe-методы. Живая проба: хендшейк с `Origin: https://evil.example` и амбиентной кукой отвечает 200 и стримит, без куки 401, заголовок `X-TM-Client` не требуется (`docs/p8-review/sse-origin-probe.txt`). ⚠ Вес поднимать НЕ предлагается, и это измеренная поправка к предложению аудита: браузерный случай сегодня закрыт ТРЕМЯ механизмами, ни один из которых не является чеком хендшейка — кука `SameSite=Lax` не уходит на кросс-сайтовый подзапрос, префикс `__Host-` не отдаёт её соседнему поддомену, а CORS-слоя нет вовсе (`PD-96`), поэтому кросс-origin `EventSource` браузер странице не отдаст. Это ровно класс `PD-86`: свойство держится, и держат его посторонние механизмы, ни один из которых не запинен как защита хендшейка. Диспозиция — вопрос приёмке: чек хендшейка или явное принятие с записью трёх носителей | open | приёмка P0 (security-линза) | | PD-23 | hardening | info | `internal/pgstore/migrations/00001_identity.sql` | Журнал входов растёт без ретенции и чистится только каскадом при удалении аккаунта. Нужен свип по возрасту (год?) — вопрос политики, не кода ⚠ **ПРОВЕРЕНО ПАКОМ P8-REVIEW 24.08 И ПРОТИВ ДЕРЕВА ЛОЖНО: ретенция ПОСТРОЕНА и РАБОТАЕТ.** `internal/pgstore/identity.go` `DeleteOldLoginEvents` (`delete from login_events where at < $1`) зовётся из `cmd/tmplatformd/main.go` циклом `sweepLogins` каждые 15 минут с окном `loginJournalRetention = 180 * 24 * time.Hour`, и свип монтируется в ОБЕИХ ветках входа. Приехало коммитом `9b23e8c` (лендинг P7), строка не обновлялась с P1. Доказано не чтением: строка возрастом 200 суток исчезла на ближайшем тике, демон напечатал `"login sweep" events=1` (`docs/p8-review/pd23-result.txt`). Предложение пака: ЗАКРЫТЬ, срок 180 суток отметить как выбранную политику. Незапиненность самого механизма вынесена отдельной строкой `PD-383` | open | самопроверка P1 | @@ -617,4 +617,4 @@ | ID | Класс | Серьёзность | Где | Суть | Статус | Источник | |---|---|---|---|---|---|---| | PD-442 | bug | minor | `internal/pgstore/migrations/00002_readmodel.sql` (таблица `exports`), канон `14-api-contract/openapi.yaml` §Export | **В схеме с самой первой миграции читающей модели жила таблица `exports`, у которой НЕТ ни одного читателя и ни одного писателя, и её форма ПРОТИВОРЕЧИТ ратифицированному контракту.** Она несла `ready boolean` плюс `failed_reason text`, а канон объясняет прямо, почему так нельзя: «A state and not a boolean: a boolean merges three situations into "not ready" and a poll on it never ends» (§`Export.state`). То есть будущая дверь выдачи, честно построенная на существующей таблице, отдала бы поллинг, который не кончается — ровно тот дефект, который канон и запрещает. Обнаружено при постройке двери: `sqlc generate` отказался с `relation "exports" already exists`, и это единственный сторож, который у класса был. Проверка отсутствия потребителей: `grep -rn '\bexports\b' --include=*.go internal/ cmd/` до пака давал ОДИН хит и тот в комментарии (`internal/httpapi/capabilities.go`). ⚠ Мигрировать было нечего — ни строки ни разу не записывалось, — поэтому `00032_exports.sql` таблицу СНОСИТ и создаёт заново в форме канона; down-путь восстанавливает форму `00002` дословно, потому что откат обязан вернуть то, что выпущенная миграция оставила. Пин формы: `internal/pgstore/exports_test.go` (четыре состояния, партиальные индексы свипа, каскад с книгой) | fixed(дерево пака «закрыть цикл», 04.09) | пак «закрыть цикл» 04.09 (найдено постройкой двери выдачи) | -| PD-447 | doc | minor | `docs/STACK_DECISIONS.md` §«Как поднять локально» и `deploy/README.md` шаг 3–4 (оба исправлены); носители дефолта — `internal/config/config.go:316`=`TM_PLATFORM_ADDR` и `cmd/tmplatformctl/seed.go:41`=`base URL of the running tmplatformd` | **Оба стендовых рецепта зоны принимали ОТВЕТ ПО АДРЕСУ за доказательство того, что отвечает СВОЙ процесс, и потому проходили при мёртвом собственном демоне.** Замерено исполнением 04.09: на машине разработки четвёртые сутки жил чужой `tmplatformd` на `127.0.0.1:8080` со своей базой; демон рецепта умирает в этой ситуации на `bind: address already in use` — молча, он запущен фоном, — а смоук следующей строкой получает `healthz=200` и `readyz=ready` ОТ ЧУЖОГО ПРОЦЕССА. ⚠ **Дороже смоука — шаг сида:** `tmplatformctl seed` дарит `$25` кредита и грузит книгу ЧЕРЕЗ ЖИВОЙ ИНТЕЙК, то есть по этому рецепту деньги и данные уезжают в чужой деплой. Второй адрес там же был написан отдельным литералом (`--url http://127.0.0.1:8080`), что и есть штатный способ разъехаться с `TM_PLATFORM_ADDR` незаметно. ⚠ **Три лечения на три РАЗНЫЕ половины, ни одно не заменяет другое:** явный `_ADDR` уводит с общего дефолта; проба по `pid` слушателя отвечает на вопрос, на который `200` не отвечает в принципе — ЧЕЙ это процесс; `--noproxy '*'` нужен потому, что при заданных `http_proxy` голый `curl` на `127.0.0.1` уходит во внешний прокси. Проба предъявлена в обе стороны: свой pid на своём порту — проходит, чужой демон против своего pid — отвергается. ⚠ **Дефолт `8080` в коде НЕ меняется, и это решение:** коллизия — `tmplatformd` против `tmplatformd`, любой другой номер даст ту же аварию на втором одновременном стенде, а цену смены заплатят все существующие деплои и доки. Чинится посылка, а не номер. **Вес minor, а не major, по радиусу:** пишет только дев-стенды, боевого пути этим рецептом нет, деньги — стендовый грант. | fixed(`adf5e53`) | найдено оркестратором №22 при независимой проверке отзыва причины смертей демона, 04.09; починено зоной | +| PD-447 | doc | minor | `docs/STACK_DECISIONS.md` §«Как поднять локально» и `deploy/README.md` шаг 3–4 (оба исправлены); носители дефолта — `internal/config/config.go:317`=`TM_PLATFORM_ADDR` и `cmd/tmplatformctl/seed.go:41`=`base URL of the running tmplatformd` | **Оба стендовых рецепта зоны принимали ОТВЕТ ПО АДРЕСУ за доказательство того, что отвечает СВОЙ процесс, и потому проходили при мёртвом собственном демоне.** Замерено исполнением 04.09: на машине разработки четвёртые сутки жил чужой `tmplatformd` на `127.0.0.1:8080` со своей базой; демон рецепта умирает в этой ситуации на `bind: address already in use` — молча, он запущен фоном, — а смоук следующей строкой получает `healthz=200` и `readyz=ready` ОТ ЧУЖОГО ПРОЦЕССА. ⚠ **Дороже смоука — шаг сида:** `tmplatformctl seed` дарит `$25` кредита и грузит книгу ЧЕРЕЗ ЖИВОЙ ИНТЕЙК, то есть по этому рецепту деньги и данные уезжают в чужой деплой. Второй адрес там же был написан отдельным литералом (`--url http://127.0.0.1:8080`), что и есть штатный способ разъехаться с `TM_PLATFORM_ADDR` незаметно. ⚠ **Три лечения на три РАЗНЫЕ половины, ни одно не заменяет другое:** явный `_ADDR` уводит с общего дефолта; проба по `pid` слушателя отвечает на вопрос, на который `200` не отвечает в принципе — ЧЕЙ это процесс; `--noproxy '*'` нужен потому, что при заданных `http_proxy` голый `curl` на `127.0.0.1` уходит во внешний прокси. Проба предъявлена в обе стороны: свой pid на своём порту — проходит, чужой демон против своего pid — отвергается. ⚠ **Дефолт `8080` в коде НЕ меняется, и это решение:** коллизия — `tmplatformd` против `tmplatformd`, любой другой номер даст ту же аварию на втором одновременном стенде, а цену смены заплатят все существующие деплои и доки. Чинится посылка, а не номер. **Вес minor, а не major, по радиусу:** пишет только дев-стенды, боевого пути этим рецептом нет, деньги — стендовый грант. | fixed(`adf5e53`) | найдено оркестратором №22 при независимой проверке отзыва причины смертей демона, 04.09; починено зоной | diff --git a/platform/docs/platform-PROGRESS.md b/platform/docs/platform-PROGRESS.md index 1af9faf7..c80727e8 100644 --- a/platform/docs/platform-PROGRESS.md +++ b/platform/docs/platform-PROGRESS.md @@ -8,6 +8,82 @@ > тобой. Разбор — секция «Соседи». Плюс строка **283** (структура глав: не-CJK txt приезжает одним > полотном) — пак её не касался, и она гейтит бету для не-CJK и EPUB-входов. +### ⛔ ДОФИКС ПО ПРИЁМКЕ (05.09, после `7e2226a`) — пять major и восемь мелких + +Приёмка: 20 находок, 5 снято скептиками, 15 устояло, из них ПЯТЬ major. **Каждую проверил сам, +прежде чем чинить** — ни одна не принята со слов. Ни одна не опровергнута: все пять подтвердились. + +**`PV2-02` · книга в интейке не попадала в точку НИ ОДНИМ файлом, а точка звалась полной.** Ветка +«базы ещё нет» возвращалась ДО копирования файлов, так что не уезжали ни `source.*`, ни `book.yaml`, +ни журнал. ⚠ И `parsing` — не пятиминутное окно: `not_configured`/`storage_unavailable` никогда не +становятся терминальными, поэтому на хосте со сломанным шаблоном ВСЕ загрузки стоят там сколько +угодно — и это ровно те файлы, у которых нет второй копии нигде. **Починено разделением: файлы +копируются ВСЕГДА и безусловно, вопрос про базу задаётся отдельно; `Skipped` теперь значит «нет +проектной базы», а не «нет ничего».** Пин `TestABookStillInIntakeStillGetsItsSourceIntoThePoint`. + +**`PV2-03` · ложная посылка в дискриминаторе, ценой — навсегда красный алерт.** Я обосновал пропуск +`rejected` тем, что «каталог уходит вместе с исходником». Дерево: каталог сносится ТОЛЬКО при +`ReasonSourceUnreadable`; второй терминальный путь (бюджет попыток исчерпан, движок не запускается) +файл ОСТАВЛЯЕТ. Такая книга проваливалась между ветвями → дыра → `complete=false` навсегда → гейдж +`+Inf` навсегда, и защита `prune` «не выбрасывать самую свежую ПОЛНУЮ точку» становилась холостой: +полных точек не существовало. **Починено; пин `TestARejectedBookThatKeptItsDirectoryIsNotAHole` +проверяет и то, что гейдж снова ОТДАЁТ возраст.** + +**`PV2-04` · рецепт восстановления гасил демона, но прогоны ему НЕ дети.** Мой же документ семьюстами +строками выше объясняет, что прогон — транзиентный юнит в СОБСТВЕННОМ менеджере и переживает деплой +по построению. Шага «спросить и остановить прогоны» не было, а восстановление делают в худший день — +именно тогда вероятен идущий оплаченный прогон, и `mv` подсунул бы файл под открытым дескриптором. +**Шаг 1а добавлен**, с оговоркой, что останов прогона работу не теряет. + +**`PV2-01` · гард на управляющие символы получила ОДНА дверь из двух — и отчёт звал это починенным.** +Я экспортировала `MaxTitle` ровно ради «одной границы на две двери», и тут же оставила интейк с одной +только длиной: титул, который пользователь ВВЁЛ, уходил мимо фильтра, а `NUL` доезжал до Postgres +(`invalid byte sequence for encoding "UTF8": 0x00`), U+2028 приземлялся в имени книги. Класс — +«поправил читателя, оставил писателя» на двух писателях ОДНОЙ колонки. **Починено; интейк ЧИСТИТ (а не +отказывает — тело мультипарта уже принято, ронять из-за одной руны десятки мегабайт нельзя), дверь +переименования по-прежнему ОТКАЗЫВАЕТ с указателем.** Пин `TestBothTitleWritersAgreeAboutControlCharacters` +держит обе половины, включая ту, что bidi-марки и ZWJ трогать нельзя. + +**`F2`/`PV2-05` · абзац рантбука про «тихий денежный симптом» был ИНВЕРТИРОВАН, и это моя ошибка того +самого класса, за который я ловила других.** Я написала оркестратору «симптом пере-проверен по коду с +замером обеих веток». Пере-проверена была ветка ФАЙЛА; ветка КЛЮЧА написана, а не проверена. Замер +(`tmctl manifest --json`, одна книга, обе ветки): + +| что потеряно | код выхода | что видит оператор | +|---|---|---| +| строчка `contrast_path` | **10** | конфиг НЕ ЗАГРУЖАЕТСЯ, отказ на ЛЮБОМ пути, включая `$0` | +| сам файл артефакта | **0** | всё работает; отказывает только ПИШУЩИЙ путь | + +То есть громкий и тихий были поменяны местами. **Абзац заменён таблицей замера.** ⚠ Заодно снята +ссылка на `book_once_usd`: в ЗАКОММИЧЕННОМ движке этого поля нет (`F1`) — проверено тем же прогоном. + +**Предъявлено после дофикса:** батарея `MAKE-EXIT=0`, 20 пакетов, **скипов ноль**, линтер 0 issues, +`counts.py --lint` 0 красных якорей в `platform/` (четыре уехали от МОИХ ЖЕ правок дофикса и +пере-нацелены), `--check` литералы сходятся. Восстановление рехёрсено **в пятый раз, на дофикснутом +коде и по ОБНОВЛЁННОМУ рецепту**: точка снята (1 книга, `complete=true`), база дропнута до нуля +таблиц, каталог книги снесён, `pg_restore` + `cp -a` + снятие хвостов `-wal` + переименование — баланс +$25, книга «Мастер Гу», движок открывает файл 2/2. +⛔ **ЧЕГО В ЭТОЙ ПРОБЕ НЕТ, и я говорю это прямо: шаг 1а (остановить прогоны в ИХ менеджере) НЕ +ИСПОЛНЕН.** На стенде нет пользователя `tmplatform`, и команда честно отвечает +`Failed to connect to bus: Operation not permitted`. То есть исправление `PV2-04` — это ТЕКСТ рецепта, +выведенный из кода (прогон — транзиентный юнит в своём менеджере, D39.106) и из моего же документа, +а НЕ проверенный исполнением шаг. Проверить его может только боевой хост с настоящим `tmplatform`. +Записываю в препятствия, а не в предъявленное. + +**Мелкие, взятые все восемь:** `F1` (выше) · `F3` тест «нет команды `restore`» упирался в DSN-гейт и +не увидел бы подкоманду — теперь гоняется С базой и требует именно `errUsage` · `F8` моя вставка +разлучила ЧУЖОЙ доккомментарий с его функцией — тест переехал в конец файла · `F9` арифметика +«четверо суток» → 84 ч / ≈3,25 суток, в трёх носителях · `F11` мой же рецепт проверки +(`redrive --dry-run`) открывает проект на ЗАПИСЬ и берёт flock — оговорка добавлена · `PV2-06` +`dumpArgs` терял из DSN всё кроме host/port/user/dbname, то есть `sslmode=verify-full` тихо +становился `prefer` — **теперь пересылается ВЕСЬ DSN минус пароль**, пин требует `sslmode`, +`sslrootcert`, `connect_timeout` и запрещает пароль в argv · `PV2-08` восстановление воскрешает +отозванные учётные данные — шаг чек-листа · `PV2-09` точка НЕ мгновение времени (реестр снимается +до книг) — оговорка. + +**Снято скептиками, не чинила** (пять): `PATCH` на непатчабельные члены · «EPUB с новым `dc:title` не +доехал» · `BACKUP_DIR` вне песочницы · «у токена нет своего отзыва» · односторонность `--verify`. + ### Комплектность против заказа: пункт → носитель → проба | Пункт пака | Носитель | Чем предъявлено | @@ -278,7 +354,7 @@ tmctl build --config <книга> --format epub --out … --partial **Где живут точки и как часто.** Каталог называет ОПЕРАТОР (`TM_PLATFORM_BACKUP_DIR`), дефолта нет и не будет: любой придуманный нами путь лёг бы на тот же диск, потерю которого механизм и должен -пережить. Каждые `6h`, `14` поколений ≈ четверо суток истории — интервал, который переживает +пережить. Каждые `6h`, `14` поколений = 84 ч истории (самая старая ЖИВАЯ точка — 13 интервалов, ≈ 3,25 суток) — интервал, который переживает обнаружение аварии после выходных. ⚠ Требование «другое устройство» служба проверить НЕ МОЖЕТ (bind-mount и симлинк обманут любую пробу) — записано половиной сделки оператора и названо на буте. @@ -3595,7 +3671,7 @@ migrate → повтор») можно строить; строка открыт приёмкой (мёртвая цитата текста ошибки схемы в рантбуке и в строке П-1; образец в `tmplatformctl`, зовущий голый `tmctl` из PATH), закрыты правками и проверяются грепом: `grep -rn 'expects vM' platform/deploy platform/BACKLOG.md` — ноль строк; рантбук несёт живой токен -(`platform/deploy/README.md:335`=`schema_mismatch found=N expected=M`); образец несёт +(`platform/deploy/README.md:347`=`schema_mismatch found=N expected=M`); образец несёт версионированный путь (`platform/cmd/tmplatformctl/runs.go:53`=`The VERSIONED path and never a bare`). ⚠ **Живым остаётся мнение оркестратора:** прогнать деплой-рантбук end-to-end на дев-стенде diff --git a/platform/internal/backup/backup.go b/platform/internal/backup/backup.go index 7aeed70e..3bc56798 100644 --- a/platform/internal/backup/backup.go +++ b/platform/internal/backup/backup.go @@ -68,6 +68,7 @@ import ( "io" "io/fs" "log/slog" + "net/url" "os" "os/exec" "path/filepath" @@ -75,8 +76,6 @@ import ( "strings" "time" - "github.com/jackc/pgx/v5/pgconn" - "textmachine/platform/internal/books" "textmachine/platform/internal/pgstore" "textmachine/platform/internal/runner" @@ -185,7 +184,10 @@ type BookEntry struct { // Files are everything copied out of the book's directory, the consistent project database // among them (ProjectDBName). Files []File `json:"files"` - // Skipped says why this book carries no files. Empty on a book that was copied. + // Skipped says why this book carries no PROJECT DATABASE in the point — NOT that it carries + // nothing. A book whose intake has not finished has its source, its `book.yaml` and its journal in + // here and no database, because no database exists yet; `Files` is what it does carry. Empty when + // the database is present. Skipped string `json:"skipped,omitempty"` // NotCopied names what inside the directory was deliberately left out, with the reason. It is // recorded rather than assumed known: a restore point that quietly omits something is the object @@ -405,6 +407,8 @@ func restoreNotes() []string { "Restore Postgres with: pg_restore --clean --if-exists --no-owner -d " + DumpFile, "Restore a book by copying " + BooksDir + "// back to its workdir, then renaming " + ProjectDBName + " to the file that book's book.yaml names in `project_db` — absent that key, .db.", + "A book directory with NO " + ProjectDBName + " is not an error: that book's intake had not finished, so no " + + "project database existed yet. Restore its files as they are.", "The engine's own pre-run restore points (a book's backups/ subdirectory) are NOT in here and are NOT superseded by these: " + "they are a different guarantee (a rollback point taken BEFORE a paid run, on the same disk) and are left untouched.", "Built exports and run-state markers are NOT in here: both are rebuildable and short-lived by design.", @@ -453,10 +457,25 @@ func (s *Service) dumpPostgres(ctx context.Context, staging string) (File, error // (config.Config.DSN). It travels in the child's environment instead, which is the channel // PostgreSQL's own tools document for it (PGPASSWORD) and which is scoped to the child. func dumpArgs(dsn, out string) (args []string, env []string, err error) { - cfg, err := pgconn.ParseConfig(dsn) - if err != nil { - // The DSN itself is NOT in the message: it carries a password. - return nil, nil, fmt.Errorf("backup: the configured DSN cannot be parsed for pg_dump: %w", err) + // ⚠ THE WHOLE CONNECTION STRING IS FORWARDED, MINUS THE PASSWORD — it is not rebuilt from parts. + // An earlier version passed host/port/user/dbname as four flags and dropped everything else, + // which silently included `sslmode`: a deployment whose DSN says `verify-full` would have had its + // nightly dump go out under libpq's default `prefer`, i.e. TLS optional and the certificate never + // checked. Nothing would have said so — pg_dump succeeds either way. The same loss applies to + // `sslrootcert`, `connect_timeout`, `application_name` and anything else an operator put there. + // + // The password is the one part that may not travel: a connection string on the command line is + // readable by every process on the host through /proc//cmdline. It goes in the child's + // environment instead, which is the channel PostgreSQL's own tools document for it. + u, perr := url.Parse(dsn) + if perr != nil || u.Scheme == "" { + // A key/value DSN (`host=… port=…`) is legal for libpq and cannot be edited as a URL. Refused + // rather than mangled: the alternative is a dump taken under settings nobody chose. + return nil, nil, fmt.Errorf("backup: the configured DSN is not a URL this platform can hand to %s without rewriting it; use the postgres:// form", "pg_dump") + } + password, _ := u.User.Password() + if u.User != nil { + u.User = url.User(u.User.Username()) } args = []string{ "--format=custom", @@ -464,12 +483,9 @@ func dumpArgs(dsn, out string) (args []string, env []string, err error) { // No prompt, ever: this runs unattended, and a tool that stops for a password is a pass that // hangs until its budget kills it. "--no-password", - "--host=" + cfg.Host, - "--port=" + fmt.Sprint(cfg.Port), - "--username=" + cfg.User, - "--dbname=" + cfg.Database, + "--dbname=" + u.String(), } - env = append(os.Environ(), "PGPASSWORD="+cfg.Password) + env = append(os.Environ(), "PGPASSWORD="+password) return args, env, nil } @@ -481,27 +497,46 @@ func (s *Service) copyBook(ctx context.Context, staging string, b pgstore.BookDi return entry, nil } if _, err := os.Stat(b.Workdir); errors.Is(err, fs.ErrNotExist) { - // ⚠ A MISSING DIRECTORY IS TWO DIFFERENT FACTS, told apart by the book's own status — the same - // discriminator, and for the same reason, as the engine's refusal below. + // ⚠ A MISSING DIRECTORY IS TWO DIFFERENT FACTS, told apart by the book's own status. // - // A REJECTED upload takes its directory with it, so a row outliving one is ordinary and the - // point stays complete. Any other status means the directory should be there and is not: a - // books volume that did not mount, or a tree that moved while the absolute workdirs in - // Postgres stayed where they were. Calling that an ordinary skip is how a deployment comes to - // publish points holding only the ledger, all marked complete, while the age gauge stays green - // and retention quietly evicts the last point that held any paid work. + // A REJECTED intake may take its directory with it, so a row outliving one is ordinary. Any + // other status means the directory should be there and is not: a books volume that did not + // mount, or a tree that moved while the absolute workdirs in Postgres stayed where they were. + // Calling that an ordinary skip is how a deployment comes to publish points holding only the + // ledger, all marked complete, while the age gauge stays green. if b.Status == statusRejected { - entry.Skipped = "the book's intake was rejected, so its directory went with its source" + entry.Skipped = "the book's intake was rejected and its directory is gone" return entry, nil } return entry, fmt.Errorf("the book's directory %s is not there, and its status (%s) says it should be", b.Workdir, b.Status) } else if err != nil { return entry, fmt.Errorf("stat the book's directory: %w", err) } + + dest := filepath.Join(staging, BooksDir, b.ID) + if err := os.MkdirAll(dest, 0o750); err != nil { + return entry, fmt.Errorf("create the book's directory in the restore point: %w", err) + } + + // ⚠ THE DIRECTORY'S FILES ARE COPIED FIRST AND UNCONDITIONALLY, BEFORE ANY QUESTION ABOUT THE + // DATABASE. An earlier version answered "this book has no project database yet" by returning + // immediately, and so took NOTHING — not the uploaded source, not `book.yaml`, not the journal — + // while the point still called itself complete. + // + // That was wrong on this package's own terms: the source is the one file in the directory nobody + // can be asked to supply again, and a book can sit in `parsing` indefinitely rather than for a + // moment — the intake's `not_configured` and `storage_unavailable` never become terminal + // (internal/books/parse.go), so a host with a broken book template holds every upload in that + // state. Those uploads are precisely the ones with no other copy anywhere. + files, err := s.copyBookFiles(staging, b.Workdir, dest, &entry.NotCopied) + if err != nil { + return entry, err + } + entry.Files = files + if s.Cfg.EngineBinary == "" { - // NOT a skip: an instance with no engine cannot copy ANY book, so every book would be - // "skipped" and the point would be published holding nothing but the ledger. Refused as a - // fault of the deployment, which is what it is. + // NOT a skip: an instance with no engine cannot copy ANY book's database, so every book would + // be "skipped" and the point would claim to be a backup of a library it never opened. return entry, errors.New("this instance has no engine binary, so no consistent database copy can be asked for") } res, err := s.Engine.Backup(ctx, s.Cfg.EngineBinary, b.Workdir) @@ -511,30 +546,28 @@ func (s *Service) copyBook(ctx context.Context, staging string, b pgstore.BookDi if !res.Exited || res.ExitCode != 0 { // ⚠ THE ENGINE'S REFUSAL IS TWO DIFFERENT FACTS AND THEY MUST NOT BE ONE SKIP. // - // A book whose intake has not finished has no project database at all, and the engine says so - // rather than failing — an ordinary state that leaves the point COMPLETE. Every other refusal - // is a book that HAS paid work which this point does not contain, and calling that "skipped" - // the same way is how a point comes to say `complete: true` over a hole. + // A book whose intake has not finished — or was rejected — has no project database at all, and + // the engine says so rather than failing: an ordinary state that leaves the point COMPLETE, + // because the book's FILES are already in it and there is no paid work to lose. Every other + // refusal is a book that HAS paid work which this point does not contain, and calling that + // "skipped" the same way is how a point comes to say `complete: true` over a hole. // // The discriminator is the PLATFORM's own fact — the book's intake status — and never the // engine's exit code or its prose: this side cannot tell "no database yet" from "a broken // deployment" by a number, and reading it out of a message would be the same unversioned // coupling PD-449 already carries once. - if notYetCut(b.Status) { - entry.Skipped = fmt.Sprintf("the book's intake has not finished (%s), so it has no project database yet: %s", + if notYetCut(b.Status) || b.Status == statusRejected { + entry.Skipped = fmt.Sprintf("no project database yet (status %s), but the book's own files ARE in this point: %s", b.Status, res.Stderr) return entry, nil } return entry, fmt.Errorf("the engine made no copy of a book that should have one (exit %d): %s", res.ExitCode, res.Stderr) } - dest := filepath.Join(staging, BooksDir, b.ID) - if err := os.MkdirAll(dest, 0o750); err != nil { - return entry, fmt.Errorf("create the book's directory in the restore point: %w", err) - } - // The consistent snapshot first, under the fixed name the manifest's notes tell an operator to - // rename back. The engine's copy is then removed: it was made for this pass, and leaving it - // would grow the book's own directory by a whole database every cycle. + + // The consistent snapshot, under the fixed name the manifest's notes tell an operator to rename + // back. The engine's copy is then removed: it was made for this pass, and leaving it would grow + // the book's own directory by a whole database every cycle. f, err := copyInto(staging, res.Path, filepath.Join(dest, ProjectDBName)) if err != nil { return entry, err @@ -544,11 +577,6 @@ func (s *Service) copyBook(ctx context.Context, staging string, b pgstore.BookDi s.log().Warn("the engine's copy could not be removed after it was taken into the restore point", "path", res.Path, "err", err) } - rest, err := s.copyBookFiles(staging, b.Workdir, dest, &entry.NotCopied) - if err != nil { - return entry, err - } - entry.Files = append(entry.Files, rest...) return entry, nil } diff --git a/platform/internal/backup/backup_test.go b/platform/internal/backup/backup_test.go index b52d007f..fce5409e 100644 --- a/platform/internal/backup/backup_test.go +++ b/platform/internal/backup/backup_test.go @@ -741,6 +741,89 @@ func TestARunOfDegradedPointsCannotEvictTheLastCompleteOne(t *testing.T) { } } +// ⛔ PV2-02: A BOOK STILL IN INTAKE MUST GET ITS FILES INTO THE POINT. It has no project database +// yet, and an earlier version answered that by taking NOTHING — not the uploaded source, not +// `book.yaml`, not the journal — while the point still called itself complete. +// +// ⚠ `parsing` is not a five-minute window: the intake's `not_configured` and `storage_unavailable` +// never become terminal (internal/books/parse.go), so a host with a broken book template holds every +// upload in that state indefinitely. Those uploads are exactly the ones with no other copy anywhere, +// and this package's own comment calls the source irreplaceable. +// +// Mutation caught: returning from copyBook before copyBookFiles on the not-yet-cut branch. +func TestABookStillInIntakeStillGetsItsSourceIntoThePoint(t *testing.T) { + root := t.TempDir() + for _, status := range []string{"uploading", "parsing"} { + b := aBook(t, root, "bk_"+status) + b.Status = status + svc, _ := service(t, []pgstore.BookDirectory{b}, &fakeEngine{refuse: true}, false) + res, err := svc.Take(t.Context()) + if err != nil { + t.Fatalf("%s: %v", status, err) + } + if !res.Complete { + t.Errorf("%s: a book that legitimately has no database made the point incomplete", status) + } + // THE POINT: its own files are here, whatever the database situation. + for _, want := range []string{"source.txt", "book.yaml", "events.jsonl"} { + p := filepath.Join(res.Path, BooksDir, b.ID, want) + if _, err := os.Stat(p); err != nil { + t.Errorf("%s: %s is not in the point — the user's own upload was dropped: %v", status, want, err) + } + } + m, err := ReadManifest(res.Path) + if err != nil { + t.Fatal(err) + } + if len(m.Books[0].Files) == 0 { + t.Errorf("%s: the manifest lists no files for a book whose directory was full", status) + } + if m.Books[0].Skipped == "" { + t.Errorf("%s: the absent database is not explained", status) + } + } +} + +// ⛔ PV2-03: A REJECTED BOOK THAT KEPT ITS DIRECTORY IS NOT A HOLE. Only one of the two terminal +// reject paths removes the directory — `ReasonSourceUnreadable` — while the other (the attempt budget +// exhausted because the engine would not run) deliberately KEEPS the user's file +// (internal/books/parse.go, `if reason == ReasonSourceUnreadable { s.removeDir(...) }`). +// +// Such a book has a directory and no database, which fell between the branches and made every point +// incomplete FOREVER: the age gauge then reads +Inf permanently, the one alertable signal of real +// backup loss is stuck red and worthless, and prune's "never evict the newest COMPLETE point" becomes +// vacuous because no complete point exists. And the cause that produces such books — the engine not +// running — is the same deployment incident. +// +// Mutation caught: dropping `|| b.Status == statusRejected` from the engine-refusal branch. +func TestARejectedBookThatKeptItsDirectoryIsNotAHole(t *testing.T) { + root := t.TempDir() + b := aBook(t, root, "bk_rejected_kept") + b.Status = "rejected" // its directory is right there: the reject path kept the user's file + svc, dest := service(t, []pgstore.BookDirectory{b}, &fakeEngine{refuse: true}, false) + at := time.Date(2026, 9, 5, 12, 0, 0, 0, time.UTC) + svc.Now = func() time.Time { return at } + res, err := svc.Take(t.Context()) + if err != nil { + t.Fatal(err) + } + if !res.Complete { + t.Fatal("a rejected book that kept its directory made the point incomplete — the gauge would stick at +Inf forever") + } + // The gauge must actually report an age, which is the half that matters operationally. + age, has, err := svc.Age(at.Add(time.Hour)) + if err != nil || !has || age != time.Hour { + t.Errorf("the age gauge has no complete point to report: age=%v has=%v err=%v", age, has, err) + } + // And the user's file the deployment deliberately kept is in the point. + if _, err := os.Stat(filepath.Join(res.Path, BooksDir, b.ID, "source.txt")); err != nil { + t.Errorf("the source the reject path kept was not backed up: %v", err) + } + if points, _ := List(dest); len(points) != 1 { + t.Errorf("points: %v", points) + } +} + // Verify is what makes the manifest a guarantee rather than a description: a copy that rotted on the // disk is indistinguishable from a good one by size alone, and the moment to find out is not the // moment you need it. @@ -868,12 +951,29 @@ func TestThePasswordTravelsInTheEnvironmentAndNeverInTheArgv(t *testing.T) { t.Error("the password did not reach the child's environment, so the dump would prompt and hang") } joined := strings.Join(args, " ") - for _, want := range []string{"--host=db.example", "--port=6432", "--username=alice", "--dbname=tm", - "--format=custom", "--file=/tmp/out.dump", "--no-password"} { + for _, want := range []string{"db.example", "6432", "alice", "/tm", "--format=custom", + "--file=/tmp/out.dump", "--no-password"} { if !strings.Contains(joined, want) { t.Errorf("missing %s in %q", want, joined) } } + // ⛔ AND EVERY OTHER PARAMETER THE OPERATOR PUT IN THE DSN, `sslmode` above all. An earlier version + // rebuilt the connection from host/port/user/dbname and dropped the rest, so a deployment whose + // DSN says `verify-full` had its nightly dump go out under libpq's default `prefer` — TLS optional, + // certificate unchecked — and nothing said so, because pg_dump succeeds either way. + strict, _, err := dumpArgs("postgres://alice:s3cr3t@db.example:6432/tm?sslmode=verify-full&sslrootcert=/etc/ca.pem&connect_timeout=9", "/tmp/o") + if err != nil { + t.Fatal(err) + } + sj := strings.Join(strict, " ") + for _, want := range []string{"sslmode=verify-full", "sslrootcert=/etc/ca.pem", "connect_timeout=9"} { + if !strings.Contains(sj, want) { + t.Errorf("the DSN's %s did not reach pg_dump — the dump would run under libpq defaults: %q", want, sj) + } + } + if strings.Contains(sj, "s3cr3t") { + t.Errorf("forwarding the DSN carried the password onto the argv: %q", sj) + } // A DSN that cannot be parsed must not put itself into the error: that is the same leak by // another door. _, _, err = dumpArgs("://not a dsn:hunter2@", "/tmp/x") diff --git a/platform/internal/books/books.go b/platform/internal/books/books.go index 5e251e1e..b99b6905 100644 --- a/platform/internal/books/books.go +++ b/platform/internal/books/books.go @@ -438,11 +438,45 @@ func (s *Service) owns(dir string) bool { // parse overwrites it. func (in Intake) title() string { if t := strings.TrimSpace(in.Title); t != "" { + // ⚠ THE SAME CLEANING THE DERIVED BRANCH GETS, and it was missing here — the defect the + // exported MaxTitle above exists to prevent, one field over. A title the user TYPED went + // through length bounding alone, so a control character reached Postgres, which cannot hold + // U+0000 in a `text` column: measured on a live database as + // `ERROR: invalid byte sequence for encoding "UTF8": 0x00`, while U+2028 landed and the book + // lived with a line separator in its name. The rename door refuses both (httpapi, the PATCH + // handler); a deployment where one writer of a column is stricter than the other is the class + // this file's own comment calls "how the intake and the patch come to disagree". + // + // Cleaned rather than refused HERE, unlike at the rename door, and the difference is the + // contract's: `BookIntake.title` is one field of a multipart upload whose body has already + // been received, and failing the whole upload over a stray character would throw away tens of + // megabytes the user has just sent. The rename door has nothing to throw away, so it refuses + // and says which member was wrong. + if t = withoutControls(t); t == "" { + // Nothing legible was left, so this is the same as having named nothing at all. + return titleFrom(in.Filename) + } return boundedTitle(t) } return titleFrom(in.Filename) } +// withoutControls drops what may not appear in a display name: Unicode Cc (C0 and C1 — NUL, the +// newline, the escape) and the two line separators U+2028/U+2029. +// +// ⚠ AND DELIBERATELY NOT THE FORMAT CATEGORY (Cf). U+200E/U+200F and ZWJ/ZWNJ are ordinary content in +// Hebrew, Arabic, Devanagari and Persian, and dropping "everything invisible" would quietly mangle a +// legitimate title in a language pair this repository does not contain yet — the generality invariant, +// not a hypothetical. The engine's own inbound fence draws the line in the same place. +func withoutControls(s string) string { + return strings.TrimSpace(strings.Map(func(r rune) rune { + if unicode.IsControl(r) || r == '\u2028' || r == '\u2029' { + return -1 + } + return r + }, s)) +} + // titleFrom is the book's name derived from the file's own. func titleFrom(filename string) string { name := filepath.Base(filepath.FromSlash(filename)) diff --git a/platform/internal/books/counter_test.go b/platform/internal/books/counter_test.go index 2ada6521..c7dbc9b5 100644 --- a/platform/internal/books/counter_test.go +++ b/platform/internal/books/counter_test.go @@ -6,6 +6,7 @@ import ( "io" "strings" "testing" + "unicode" "unicode/utf8" ) @@ -72,3 +73,52 @@ func TestTheIntakeCounterCountsTheWriteStreamAndNotCharacters(t *testing.T) { t.Errorf("a UTF-16 source counted %d, exactly its character count — that is not what this counter does and the note on `counter` would be wrong", got) } } + +// ⛔ BOTH WRITERS OF `books.title` MUST AGREE ABOUT WHAT MAY BE IN IT. This file's own `MaxTitle` +// exists because "two copies of the number is how the intake and the patch come to disagree" — and +// the intake then disagreed with the rename door about something worse than a number: a title the +// user TYPED went through length bounding alone, so U+0000 reached Postgres, which cannot hold it in +// a `text` column, and U+2028 landed in a book's name. +// +// Found by acceptance, not by this session, in the very place the session had argued the border must +// be single — the class "fixed the reader, left the writer" applied to two writers of one column. +// +// Mutation caught: dropping the withoutControls call from Intake.title. +func TestBothTitleWritersAgreeAboutControlCharacters(t *testing.T) { + for name, bad := range map[string]string{ + "NUL": "\u0000", + "newline": "\u000a", + "escape": "\u001b", + "line separator": "\u2028", + "paragraph separator": "\u2029", + } { + got := (Intake{Title: "be" + bad + "fore", Filename: "fallback.txt"}).title() + for _, r := range got { + if unicode.IsControl(r) || r == 0x2028 || r == 0x2029 { + t.Errorf("%s: the intake stored %q, carrying U+%04X — the rename door refuses this", name, got, r) + } + } + if got != "before" { + t.Errorf("%s: got %q, want the title with only the offending rune removed", name, got) + } + } + + // A title that is NOTHING BUT control characters leaves nothing legible, so the book is named from + // its file — the same answer as naming nothing at all. + if got := (Intake{Title: "\u0000\u000a", Filename: "Мастер Гу.txt"}).title(); got != "Мастер Гу" { + t.Errorf("a title of only control characters gave %q, want the name derived from the file", got) + } + + // ⚠ And the half that must NOT be cleaned: bidi marks and the zero-width joiners are ordinary + // content in Hebrew, Arabic, Devanagari and Persian. Dropping "everything invisible" would mangle + // a legitimate title in a pair this repository does not carry yet. + for name, title := range map[string]string{ + "right-to-left mark": "\u200fdavar", + "zero-width joiner": "ka\u200dsha", + "soft hyphen": "a\u00adb", + } { + if got := (Intake{Title: title, Filename: "f.txt"}).title(); got != title { + t.Errorf("%s: a legitimate title was altered: %q -> %q", name, title, got) + } + } +} diff --git a/platform/internal/config/config.go b/platform/internal/config/config.go index 3bbe95ba..de6f88b9 100644 --- a/platform/internal/config/config.go +++ b/platform/internal/config/config.go @@ -198,7 +198,8 @@ type BackupConfig struct { Dir string // Every is how often a restore point is taken; Keep is how many survive. // - // Six hours and fourteen points — four days of history — because the two costs pull opposite + // Six hours and fourteen points — 84 hours of history, of which the oldest LIVE point is 13 + // intervals back, about 3.25 days — because the two costs pull opposite // ways and neither is free: each point costs a full `VACUUM INTO` per book plus a whole dump, and // each one kept costs that much disk. Four days is what covers a fault discovered after a // weekend, which is the interval that actually decides whether a copy is still there when diff --git a/platform/internal/metrics/metrics_test.go b/platform/internal/metrics/metrics_test.go index 2f4071b8..fc27d43f 100644 --- a/platform/internal/metrics/metrics_test.go +++ b/platform/internal/metrics/metrics_test.go @@ -23,39 +23,6 @@ func scrape(t *testing.T, m *Metrics) 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. -// The gauge's whole argument for being alertable is that a deployment with NO complete restore point -// reads +Inf rather than 0 or NaN: `> threshold` is FALSE for both of those, so an instance that has -// never made a backup would sit under every alert ever written for this series. The doc comment names -// that as the wrong answer; nothing checked it. -func TestTheBackupAgeGaugeIsInfiniteWhenThereIsNoPoint(t *testing.T) { - m := New() - read := func() float64 { - fams, err := m.registry.Gather() - if err != nil { - t.Fatal(err) - } - for _, f := range fams { - if f.GetName() == "tm_platform_backup_age_seconds" { - return f.GetMetric()[0].GetGauge().GetValue() - } - } - t.Fatal("the gauge is not registered") - return 0 - } - // Before anything has observed: a fresh process must already say "no copy", not "zero seconds old". - if v := read(); !math.IsInf(v, 1) { - t.Errorf("a fresh registry reads %v; an alert on `> threshold` would never fire", v) - } - m.ObserveBackupAge(0, false) - if v := read(); !math.IsInf(v, 1) { - t.Errorf("no restore point reads %v, want +Inf", v) - } - m.ObserveBackupAge(90*time.Minute, true) - if v := read(); v != 5400 { - t.Errorf("age = %v seconds, want 5400 (the base unit is seconds, not minutes)", v) - } -} - func TestRequestsAreCountedByRoutePatternAndNeverByPath(t *testing.T) { m := New() mux := http.NewServeMux() @@ -179,3 +146,36 @@ func TestAServiceWithoutTelemetryStillWorks(t *testing.T) { t.Fatalf("status %d through a nil registry", w.Code) } } + +// The gauge's whole argument for being alertable is that a deployment with NO complete restore point +// reads +Inf rather than 0 or NaN: `> threshold` is FALSE for both of those, so an instance that has +// never made a backup would sit under every alert ever written for this series. The doc comment names +// that as the wrong answer; nothing checked it. +func TestTheBackupAgeGaugeIsInfiniteWhenThereIsNoPoint(t *testing.T) { + m := New() + read := func() float64 { + fams, err := m.registry.Gather() + if err != nil { + t.Fatal(err) + } + for _, f := range fams { + if f.GetName() == "tm_platform_backup_age_seconds" { + return f.GetMetric()[0].GetGauge().GetValue() + } + } + t.Fatal("the gauge is not registered") + return 0 + } + // Before anything has observed: a fresh process must already say "no copy", not "zero seconds old". + if v := read(); !math.IsInf(v, 1) { + t.Errorf("a fresh registry reads %v; an alert on `> threshold` would never fire", v) + } + m.ObserveBackupAge(0, false) + if v := read(); !math.IsInf(v, 1) { + t.Errorf("no restore point reads %v, want +Inf", v) + } + m.ObserveBackupAge(90*time.Minute, true) + if v := read(); v != 5400 { + t.Errorf("age = %v seconds, want 5400 (the base unit is seconds, not minutes)", v) + } +}