reindex: пересборка витрины проигрыванием журнала

- `healthlog reindex` собирает витрину из журнала (тела архива + учёт
  доставок) в ОТДЕЛЬНЫЙ файл базы, строго по `(received_at, id)`; рабочую
  базу читает без наката миграций и не трогает вовсе. Подмену делает
  человек при остановленном сервисе: переименование поверх открытого
  дескриптора портит базу молча.
- Журналом считается архив, а не таблица доставок: тело без учётной записи
  заводится заново (метка из ULID, размер и хеш по распакованному телу),
  запись без тела переносится, но не сворачивается. Оракул сходимости
  встроен — два отпечатка и «объектов было/стало»; пустой журнал успехом не
  считается.
- Прогон живого архива переехал на новый пакет: второго проигрывателя
  журнала в проекте не осталось, а его утверждение о ключе сна перестало
  быть константой, протухающей с каждой доставкой.
This commit is contained in:
av
2026-08-02 09:07:46 +03:00
parent 84bcbbea5c
commit 5ae0c5ff81
36 changed files with 4452 additions and 258 deletions
@@ -0,0 +1,2 @@
schema: spec-driven
created: 2026-08-02
@@ -0,0 +1,346 @@
## Context
Витрина объявлена свёрткой по журналу
(`import(экспорт) + replay(доставки по received_at)`), но кода свёртки нет:
`fold.Fold` умеет свернуть **одну** доставку по её идентификатору, а того, кто
перечислит журнал и позовёт её по каждой записи, не существует. Следствия уже
наблюдаемы: после миграции 00005 доставки числятся `pending` (этим разбором не
смотрели), и подобрать их некому; а точки, разобранные прежним кодом, лежат в
объектах и не удаляются никогда — исправление разбора к ним не применится.
Что уже сделано и на что опираемся:
- `fold.Fold(ctx, deliveryID)` читает тело **из архива**, а не из памяти —
ровно потому, что путь чтения у приёма и у пересборки обязан быть один.
- Слияние точек — функция множества кандидатов, а не порядка (частичный
порядок полноты + тотальный тай-брейк), поэтому повторная свёртка той же
доставки ничего не меняет.
- Вывод слоя зависит от **префикса журнала**: доставка без плотных метрик
наследует слой предшествующей доставки той же автоматизации
(`LastDerivedLayer` с границей по `(received_at, id)`).
- `store.Fingerprint` даёт отпечаток витрины по координатам и хешам объектов,
не раскрывая значений. Он уже служит оракулом в `task verify:archive`.
Ограничения окружения: сервис живёт в контейнере и держит базу открытой,
телефон шлёт молча и непрерывно, объём архива за квартал — порядка 2 ГБ.
## Goals / Non-Goals
**Goals:**
- Проиграть журнал целиком и получить состояние, совпадающее с накопленным
приёмом; повторный прогон ничего не меняет.
- Считать журналом **архив**, а не таблицу доставок: тело без учётной записи
тоже событие.
- Дать оракул сходимости прямо в команде — сравнение отпечатков, а не «глазом
по логам».
- Оставить дверь для `healthlog import`: пересборка из архива это вырожденный
случай с пустым снапшотом, а не отдельная утилита.
**Non-Goals:**
- **Подмена рабочей базы.** Команда не заменяет файл базы и не останавливает
сервис (см. решение 2).
- **Импорт родного экспорта Apple.** Стадия снапшота в этой дельте пуста.
- **Ретеншен архива.** Пересборка тел не удаляет; она их только читает.
- **Восстановление верхних слоёв за периоды с удалёнными телами.** Считать их
вниз из `sample` запрещено инвариантом — это была бы наша агрегация под видом
присланной.
- **Онлайн-пересборка под живым приёмом.** Пересборка идёт в отдельный файл, и
доставки, приехавшие во время неё, в него не попадают; это названная граница,
а не дефект (см. риски).
## Три формы решения и компромисс каждой
Рассматривались три, а не одна; выбрана вторая.
**A. Очистить рабочую витрину и проиграть журнал в неё же.** Дёшево, второй
базы нет, результат применяется сам собой. Компромисс: единственная необратимая
операция всей задачи (`DELETE FROM bucket`) выполняется **до** того, как станет
известно, удалась ли пересборка. Отказ на середине оставляет витрину пустой
наполовину, и это состояние ничем не отличается от нормального. Под живым
сервисом — ещё и окно, в котором история отдаётся полупустой как полная.
**B. Собрать витрину в отдельный файл базы, подмену оставить человеку**
(выбрано). Отказ бесплатен: рабочая база не тронута, временный файл удаляется;
результат можно сверить с рабочим прежде, чем применять. Компромисс: результат
не применяется сам — нужна процедура из четырёх команд с остановкой сервиса, а
доставки, приехавшие во время сборки, в новый файл не попадают и подбираются
только следующим прогоном.
**C. Теневая таблица внутри той же базы: собрать в `bucket_new`, затем
переименовать в транзакции.** Подмена атомарна средствами самой SQLite, вторая
база не нужна, остановка сервиса теоретически не требуется. Компромисс
решающий: имя `bucket` зашито литералом во весь слой записи (`internal/store`),
и вариант требует параметризовать таблицей всю запись — то есть переписать
самый опасный код проекта ради операции, которая выполняется раз в полгода.
Вдобавок он не решает того, ради чего затевался: живой приём во время
пересборки пишет в **старую** таблицу, и при подмене его точки пропадают, —
значит приём всё равно надо останавливать, и сверх B вариант не даёт ничего.
## Decisions
### 1. Пересборка идёт в отдельный файл базы, а не поверх рабочей
Пересборка обязана начинаться с **пустой** витрины: точки из объекта не
удаляются никогда, поэтому проигрывание поверх накопленного оставило бы в нём
результат старого, неверного разбора — то есть не сделало бы ровно того, ради
чего задача и заведена.
Начать с пустой витрины можно двумя способами: очистить рабочую таблицу и
проиграть журнал в неё же, либо собрать новую витрину рядом и подменить.
Взято второе. Prior art здесь однозначен и стар: это blue-green rebuild
проекции — «вместо усечения существующей модели строим новую в параллельном
хранилище и переключаем чтение, когда она догонит»
([Rebuilding Event-Driven Read Models](https://www.architecture-weekly.com/p/rebuilding-event-driven-read-models),
[Projections and Read Models](https://event-driven.io/en/projections_and_read_models_in_event_driven_architecture/));
тем же приёмом работает `_reindex` + переключение алиаса в Elasticsearch, и та
же форма у собственного `VACUUM INTO` SQLite — «собери целую копию в новый
файл».
Причина предпочесть его здесь конкретнее общей моды: усечение рабочей витрины —
единственная **необратимая** операция во всей задаче, и она наступает **до**
того, как станет известно, что пересборка вообще удалась. Отказ на середине
(битое тело, отменённый контекст, кончившееся место) оставил бы витрину пустой
наполовину, причём в состоянии, которое ничем не отличается от нормального.
Сборка рядом делает отказ бесплатным: рабочая база не тронута, временный файл
удаляется.
Отвергнуто: очистка рабочей витрины с проигрыванием в неё же. Причина —
названа выше; плюс под живым сервисом это ещё и окно, в котором Read API
отдавал бы полупустую историю как полную.
### 2. Подмену рабочей базы делает человек, а не команда
Файл базы держит открытым процесс сервиса. В POSIX переименование не касается
уже открытого дескриптора: процесс продолжит писать в отвязанный inode, а
читатели увидят новый файл — данные разойдутся молча. Документация SQLite
говорит об этом прямо: переименование или удаление файла базы во время записи
оставляет журнал под чужим именем и **портит базу**
([How To Corrupt An SQLite Database File](https://www.sqlite.org/howtocorrupt.html)),
и в форуме проекта то же короче: «никогда не безопасно переименовывать
используемый файл sqlite3».
Значит безопасная подмена требует, чтобы сервис был остановлен. Остановить его
команда не может: сервисом управляет docker compose снаружи, и CLI, который
делает вид, что управляет, обещал бы безопасность, которой не обеспечивает.
Поэтому подмена остаётся процедурой человека (`task down``mv``task up`), а
команда печатает её в отчёте буквально.
Это же совпадает с правилом проекта: спрашиваем про необратимое. Перезапись
рабочей базы — ровно оно.
Отвергнуто: флаг `--replace`, делающий подмену сам. Причина — безопасен он
только при остановленном сервисе, а проверить это изнутри нечем; флаг,
безопасный лишь при невыраженном условии, хуже его отсутствия.
### 3. Журнал перечисляется по архиву, а учёт по нему сверяется
Перечислять только строки `delivery` значило бы пересобирать витрину из
**витрины**. Тело может лежать в архиве без учётной записи: приём пишет тело
на диск раньше строки в базе — намеренно, обратный порядок дал бы учтённую
доставку без данных, — и на отказе вставки в `internal/ingest` уже записано
обещание, что такое тело подберёт пересборка.
Поэтому вход пересборки — объединение двух множеств:
```
тело в архиве + строка delivery → штатная доставка, метаданные из строки
тело в архиве, строки нет → заводится заново: id из имени файла,
received_at из метки ULID, bytes и sha256
пересчитываются по телу
строка есть, тела нет → считается и называется в отчёте, не отказ
```
Третий случай станет штатным, когда появится ретеншен архива: тела до даты
проверенного экспорта срезаются, а строки живут дольше. Отказом он быть не
должен уже сейчас.
Метка приёма для тела без записи берётся из **ULID**, а не из даты каталога:
каталог даёт сутки, а порядок внутри суток важен — от него зависит наследование
слоя. ULID монотонен по времени создания, а создаётся идентификатор в приёме
непосредственно перед меткой `received_at`.
Заголовки доставки при этом не восстанавливаются: **в архиве их нет вовсе**.
Для штатных доставок они берутся из рабочей базы; у подобранного тела их не
будет, и вывод слоя для него опустится на общее правило (нет автоматизации —
нечего наследовать, нет заголовка — нечем подтвердить). Это честная деградация,
и она названа в спеке. Устранять её (класть заголовки в архив рядом с телом —
так делает WARC) в этой дельте нельзя: правка пути приёма стоит дороже всей
остальной задачи.
### 4. Порядок — строго `(received_at, id)`
Слияние точек коммутативно, но слой — нет: он функция префикса журнала. Порядок
обхода каталога (`filepath.Walk` по датам) совпадает с хронологией только
случайно, а внутри суток не даёт ничего. Сортировка по `(received_at, id)`
доопределяет и совпадение меток: `received_at` усечён до секунды, и доставки в
одной секунде без второго ключа шли бы в произвольном порядке.
### 5. Отчёт печатается человеку, оракул — отпечаток, исход — код возврата
Итог пересборки — счётчики (доставок проиграно, свёрнуто, отказов, тел без
учёта, строк без тел, пропущенных файлов, объектов) и **два отпечатка**: рабочей
витрины и пересобранной. Совпали — состояние воспроизводимо; разошлись — это
либо исправленный разбор (ожидаемо), либо расхождение, которое надо смотреть.
Отпечаток значений точек не раскрывает: содержимое входит в него хешем.
Отпечаток рабочей витрины снимается **до** проигрывания, а число доставок — до
и после. Без этого оракул под живым приёмом отвечает «разошлись» всегда: любая
доставка, приехавшая за время прогона, двигает рабочую витрину. Оракул, который
врёт без предупреждения, перестают читать — и он не сработает ровно тогда, когда
разбор действительно разойдётся.
**Отдельное решение — что считать успехом.** Расхождение отпечатков успехом быть
не перестаёт: оно и есть смысл пересборки. А вот пустой журнал успехом не
является, хотя выглядит идеально: отпечаток пустой витрины совпадает с
отпечатком пустой витрины. Все умолчания подыгрывают такому запуску — конфиг
необязателен, и без него пути указывают в рабочий каталог процесса, а каталог
архива по этому пути пересборка **не создаёт**. Поэтому пустой журнал, ноль
свёрнутых доставок, отмена и ошибка окружения дают ненулевой код и не печатают
процедуру подмены; отказ отдельной доставки — не даёт, он штатный.
**Потоки разведены:** отчёт — в stdout человеческим текстом, прогресс — в
stderr. Прогон на полном архиве молчит минутами, и зависший неотличим от
идущего; смешивать прогресс с отчётом нельзя, иначе отчёт нельзя перенаправить.
Рендер отчёта принимает `io.Writer` и не знает про `os.Stdout` — иначе проверка
«отчёт не раскрывает данных о здоровье» превращается в тест на глобальном
состоянии, а это единственная защита новой поверхности вывода.
Логи свёртки при этом остаются логами и пишутся `slog`, как при приёме: один
чекпоинт на доставку, без значений точек. Запрет на имена метрик относится к
отчёту, а не к логу: координаты столкновения разрешены спекой хранения явно.
### 6. Новый пакет `internal/replay`, а не метод у `fold`
`fold` отвечает за одну доставку и ничего не знает ни про каталог архива, ни
про порядок. Проигрывание журнала — другая ответственность: перечислить,
упорядочить, догрузить недостающий учёт, свести отчёт. Имя `replay`, а не
`reindex`, потому что это половина формулы `import + replay`: задача про родной
экспорт добавит стадию снапшота **перед** проигрыванием и переиспользует ту же
операцию, а не заведёт вторую похожую.
### 7. Прогон живого архива переезжает на пересборку — второго проигрывателя не остаётся
`internal/fold/replay_test.go` (он же `task verify:archive`) сегодня проигрывает
живой архив **своими руками**: свой обход каталога, свой порядок (сортировка
путей), свой синтез учёта (все тела под одной автоматизацией). Это и есть второй
проигрыватель, и его правила уже расходятся с дельтой: порядок не
`(received_at, id)`, случаев «учёт без тела» и «имя не тело» у него нет вовсе.
После появления `internal/replay` он продолжил бы зеленеть, проверяя путь,
которым `healthlog reindex` не ходит. Цена ошибки здесь известна: именно этот
прогон поймал дефект `LastDerivedLayer` — тот, из-за которого пересборка давала
1742 объекта вместо 1737 (`docs/review-journal.md`).
Поэтому прогон переписывается поверх `replay.Run`, а его утверждения остаются на
месте — они и есть его ценность. Одно из них по дороге пришлось переписать:
константа «174 координаты `sleep_analysis`» снята на 94 доставках и протухла на
116, потому что производна от размера корпуса. Утверждается теперь само
свойство — координат строго больше, чем различных меток, — а измеренные числа
печатаются.
`task verify:archive` сохраняет имя и смысл, отдельной задачи «прогон
пересборки» в `Taskfile.yml` не появляется.
Отвергнуто: (б) заменить прогон вызовом самой команды на живом архиве — тогда
измеренные утверждения умирают, а остаётся «отработало без ошибки»; (в) держать
оба проигрывателя — каждый будущий правщик порядка или подбора обязан править
два места, а расхождение между ними не поймает никто.
### 8. Рабочая база открывается на чтение и без миграций
Обычное открытие (`store.Open`) накатывает миграции безусловно, а миграции здесь
меняют и **данные**: та, что ввела частичный разбор, переписала `parse_status` у
всех строк. То есть штатный путь чтения нарушал бы собственное требование
«рабочую базу не трогаем», и приёмочный сценарий этого не заметил бы — отпечаток
считается по объектам, а не по учёту.
Вводится отдельный конструктор чтения: без наката миграций, с проверкой версии
схемы. Расхождение версий — отказ с указанием обеих, а не молчаливая миграция
под работающим сервисом.
Отвергнуто: соглашение «запускать на одной версии бинаря». Договорённость с
самим собой не является механизмом, а цена нарушения — DDL под живым приёмом.
### 9. Тождество файла назначения — по файлу, а не по строке пути
Сравнение путей строкой не отвечает на вопрос «это тот же файл»: `..` в пути,
симлинк, другой префикс монтирования внутри контейнера дают ту же цель при
другой строке. Ошибка здесь означает проигрывание журнала прямо в живую рабочую
базу — то самое необратимое, ради предотвращения которого выбрана сборка рядом.
Тождество определяется свойствами файла (устройство и inode); когда файла
назначения ещё нет — свойствами родительского каталога и именем.
### 10. Идемпотентность держится существующим слиянием, а не новым кодом
Повторный прогон не меняет состояния потому, что победитель координаты —
функция множества кандидатов. Пересборка не добавляет к этому ничего своего и
не имеет права: любая её собственная «оптимизация» вроде пропуска доставок по
`parse_status` сделала бы результат зависящим от предыдущего прогона. Поэтому
проигрываются **все** доставки, а дешевизну повтора обеспечивает хеш-детектор
объекта.
## Risks / Trade-offs
- **Доставки, приехавшие во время пересборки, в новый файл не попадут** → они
остаются в архиве и в рабочей базе; после подмены их тела окажутся телами без
учётной записи, и следующий прогон их подберёт (решение 3). Штатная процедура
— остановить сервис на время подмены; окно в минуты закрывают средний и
глубокий проходы синхронизации, у которых окна фиксированные.
- **Остановка сервиса на время подмены — окно, в котором доставка не
принимается** → переживает ли «Since Last Sync» неудачную отправку,
неизвестно (открытый вопрос `docs/local-research.md`). Поэтому на остановку
полагаться нельзя, и страхует её другое: средний и глубокий проходы
синхронизации работают **фиксированными окнами** (сутки и неделя), то есть
переприсылают период целиком независимо от того, что было доставлено.
Практический вывод для процедуры: подменять базу стоит минутами, а не часами,
и не откладывать перезапуск.
- **Пересборка читает рабочую базу, пока сервис в неё пишет** → чтение под WAL
безопасно, но `store.Open` накатывает миграции. На актуальной схеме это
no-op; на устаревшей — миграция под живым трафиком, чего команда не ожидает.
Смягчение: подмена и пересборка выполняются на одной версии бинаря, как и
сказано в процедуре.
- **`sealed` в пересобранной витрине пуст** → правила его выставления ещё нет
(порог глубины досчёта не выбран), так что переносить нечего. Когда правило
появится, признак станет функцией от часа и воспроизведётся сам. Пока это
означает: отпечатки разойдутся, если кто-то выставил `sealed` руками.
- **Время прогона растёт линейно по архиву** → 2 ГБ за квартал, чтение и
разбор каждого тела. Для ручной операции приемлемо; порционность
(«разбивать реплей на куски») из prior art не берём — она нужна миллиардам
событий, а не сотням тел.
- **Отчёт печатает пути и счётчики** → путей внутри `./data` в отчёте
достаточно, чтобы человек сделал `mv`, но ни имён метрик, ни значений точек в
нём нет.
## Migration Plan
Миграций схемы нет. Процедура применения пересобранной витрины (её же печатает
команда):
```
task down # сервис отпускает файл базы И перестаёт принимать
healthlog reindex --config ./config.toml # собирает ./data/healthlog.db.rebuild
mv ./data/healthlog.db.rebuild ./data/healthlog.db
rm -f ./data/healthlog.db-wal ./data/healthlog.db-shm
task up
```
Порядок здесь существен: сервис останавливается **до** пересборки, а не после
неё. Доставки, приехавшие за время прогона, в собранный файл не попадут, и
подмена стёрла бы их учёт вместе с заголовками. Команда это ловит — печатает
разницу числа доставок и в таком случае процедуру подмены не печатает вовсе, —
но платить за это лишним прогоном не нужно. Пересборка без подмены (сверка
отпечатков) при живом сервисе, наоборот, безопасна и полезна.
Откат: рабочая база не тронута до `mv`, поэтому откат — не делать `mv`. После
`mv` откат — повторная пересборка из того же архива: журнал не изменился.
## Open Questions
- Заголовки доставки в архиве не лежат, поэтому пересборка «с нуля», без
рабочей базы, деградирует по выводу слоя. Класть ли рядом с телом его
заголовки (как WARC) — отдельная задача беклога, не эта дельта.
- Нужен ли режим «догнать только неразобранное» для фонового подбора
`pending` — это половина задачи «разнести ответ приёма и свёртку»; здесь не
решается, но `replay` даёт ей готовую операцию.
@@ -0,0 +1,69 @@
## Why
Разбор пишется по реальным данным и будет ошибаться — это норма. Без
пересборки ошибка разбора становится потерей данных: исправленный код не
применится к тому, что уже разобрано неверно, а точки из объекта не удаляются
никогда. Сегодня журнал есть (116 тел в архиве), а кода, который его
проигрывает, нет: после миграции 00005 доставки числятся `pending`, и подобрать
их некому.
Оговорка, которую легко прочитать наоборот: пересборка точнее приёма **не тем,
что видит более длинный ряд**. Слой обязан быть функцией префикса журнала, и
наследование «от последней доставки вообще» уже ловили дефектом — 1737 объектов
против 1742. Точнее она ровно тем, что применяет **исправленный** разбор к
тому, что уже разобрано неверно.
## What Changes
- Новая подкоманда `healthlog reindex`: собирает витрину из журнала —
`import(снапшот) + replay(доставки по received_at)` — и пишет её в **отдельный
файл базы**, не трогая рабочую. Снапшот в этой дельте пуст: `import` появится
вместе с задачей про родной экспорт Apple, и та встроится сюда же, а не
заведёт вторую операцию.
- Журналом считается **архив**, а не таблица доставок: тела, у которых учётной
записи нет (приём успел записать тело и упал на вставке строки), заводятся
заново по имени файла. Это обещание, уже записанное в `internal/ingest`.
- Порядок проигрывания — строго `(received_at, id)`, а не порядок обхода
каталога: слой наследуется от предшествующей доставки той же автоматизации,
и порядок входит в результат.
- Оракул сходимости встроен в команду: отпечаток пересобранной витрины
печатается рядом с отпечатком рабочей, и команда прямо говорит, совпали они
или нет. Отпечаток значений точек не раскрывает.
- Подмена рабочей базы пересобранной **остаётся за человеком** и в команду не
входит: сервис держит открытый дескриптор, и `rename` поверх него оставил бы
процесс писать в отвязанный inode — молча.
- Границы, названные вслух: `sealed` в пересобранной витрине пуст (правила его
выставления ещё нет), а заголовки доставок берутся из рабочей базы — в архиве
их нет вовсе.
- Прогон живого архива (`task verify:archive`) переезжает на новый код: сегодня
он **второй проигрыватель журнала** со своим порядком и своим синтезом учёта,
и после появления настоящей пересборки зеленел бы, проверяя путь, которым
команда не ходит.
## Capabilities
### New Capabilities
- `reindex`: пересборка витрины проигрыванием журнала — состав журнала,
порядок, детерминированность, отчёт и его оракул, граница «что не
восстанавливается».
### Modified Capabilities
Изменённых нет. Правило «тело без учётной записи заводится заново» могло бы
показаться правилом учёта, но оно описывает состав журнала при проигрывании и
живёт в `reindex`; дублировать его в `storage` значило бы завести два места, где
сказано одно и то же. Схема БД, слияние точек и вывод слоя не меняются: вся
дельта — новый потребитель существующей свёртки.
## Impact
- Новый пакет `internal/replay` — проигрывание журнала поверх существующего
`internal/fold`; собственного разбора и собственного слияния не заводит.
- Новый файл `cmd/healthlog/reindex.go`, строка в `main.go`.
- `internal/store`: перечисление доставок в порядке журнала, очистка витрины,
чтение отпечатка (уже есть).
- `internal/ident`: время создания из ULID — метка приёма для тела без учётной
записи.
- Схема БД не меняется, миграций нет.
- `README.md` (`reindex` перестаёт быть «в планах»), `docs/architecture.md`
(почему подмена базы не автоматизируется), `docs/plan.md`, `docs/backlog`.
@@ -0,0 +1,430 @@
## ADDED Requirements
### Requirement: Пересборка витрины проигрыванием журнала
Система SHALL уметь собрать витрину заново, проиграв журнал целиком:
`import(снапшот) + replay(доставки)`. Стадия снапшота в этой дельте пуста —
пересборка из архива есть вырожденный случай с пустым снапшотом, — и отдельной
операции «пересборка из архива» рядом с импортом экспорта заводить MUST NOT.
Проигрываться SHALL **все** доставки журнала, а не только те, чей
`parse_status` говорит о неразобранности. Отбор по учётному статусу сделал бы
результат функцией предыдущего прогона, а не журнала; дешевизну повторного
проигрывания обеспечивает хеш-детектор объекта, а не пропуск доставок.
Пересборка собственного разбора и собственного слияния иметь MUST NOT: она
зовёт тот же код, что и приём, по идентификатору доставки, и тело читает из
архива тем же путём, с тем же пределом размера распакованного тела. Второй путь
разбора разошёлся бы с первым молча, а другой предел означал бы, что тело,
принятое со `200`, вечно отказывает на каждой пересборке.
#### Scenario: Пересобранная витрина совпадает с накопленной приёмом
- **GIVEN** рабочая витрина накоплена тем же разбором, приём во время
накопления шёл последовательно, и за время пересборки новых доставок не
приезжало
- **WHEN** журнал проигрывается заново с пустой витрины
- **THEN** отпечаток пересобранной витрины совпадает с отпечатком накопленной
#### Scenario: Повторная пересборка ничего не меняет
- **WHEN** пересборка того же журнала выполняется второй раз
- **THEN** отпечаток витрины не меняется
#### Scenario: Разобранная доставка проигрывается наравне с неразобранной
- **WHEN** в журнале есть доставки со статусом `parsed` и со статусом `pending`
- **THEN** проигрываются обе
### Requirement: Порядок проигрывания задаётся журналом
Система SHALL проигрывать доставки строго в порядке `(received_at, id)`, а не
в порядке обхода каталога архива.
Порядок входит в результат: слой доставки без плотных метрик наследуется от
**предшествующей** доставки той же автоматизации, то есть слой есть функция
префикса журнала. Обход каталога совпадает с хронологией только по датам
каталогов и внутри суток не упорядочивает ничего.
Второй ключ обязателен, а не для красоты: `received_at` хранится с секундной
точностью, и доставки одной секунды без него шли бы в неопределённом порядке —
а значит два прогона одного журнала могли бы разойтись.
Проигрывание SHALL быть последовательным. Распараллеливать его MUST NOT: слой
есть функция префикса журнала, а запись часового объекта — чтение, слияние и
запись обратно.
#### Scenario: Порядок не зависит от раскладки файлов в архиве
- **WHEN** тела одного журнала лежат в архиве так, что порядок обхода каталога
не совпадает с хронологией приёма
- **THEN** доставка без плотных метрик получает слой предшествующей ей по
`received_at` доставки той же автоматизации, а не слой соседа по каталогу
#### Scenario: Доставки одной секунды упорядочены идентификатором
- **WHEN** две доставки имеют одинаковый `received_at`
- **THEN** порядок между ними задаётся идентификатором и одинаков в каждом
прогоне
### Requirement: Журналом считается архив, а не таблица доставок
Система SHALL брать состав журнала из **сырого архива**, сверяя его с учётом
доставок, а не перечислять только строки `delivery`. Иначе витрина
пересобиралась бы из витрины.
Тело, у которого учётной записи нет, SHALL заводиться заново и проигрываться
наравне с остальными: приём кладёт тело на диск раньше строки в базе — обратный
порядок дал бы учтённую доставку без данных, — поэтому отказ на вставке строки
оставляет тело в архиве без учёта.
Для такого тела идентификатор берётся из имени файла, метка приёма — из времени
создания в ULID, приведённого к **UTC**. Дата каталога меткой служить MUST NOT:
она задаёт сутки, а порядок нужен внутри суток. Размер и хеш пересчитываются по
**распакованному** телу — так же, как их считает приём; иначе в тех же колонках
появились бы значения другой природы.
Заголовки доставки при этом восстановлены быть не могут — **в архиве их нет**.
Подобранное тело SHALL проигрываться без них, с честной деградацией вывода
слоя: наследовать не от чего и подтверждать нечем.
Строка учёта, у которой тела в архиве нет, отказом быть MUST NOT: она станет
штатной, когда появится ретеншен архива. Такая строка SHALL считаться и
называться в отчёте.
Файл архива, не подходящий под форму тела (чужое расширение, остаток `*.tmp`
от прерванной записи, имя не разбирается как ULID), SHALL пропускаться и
учитываться счётчиком. Молчаливый пропуск недопустим: тело есть, а в отчёте его
нет. Тело, чей идентификатор уже встретился в другом каталоге, SHALL
пропускаться тем же порядком: вторая запись журнала с тем же ключом сорвала бы
весь прогон, то есть один посторонний файл лишал бы пересборки всё остальное.
Каждый пропуск, повтор и неудачный подбор SHALL оставлять запись в логе с путём
файла — путь в архиве это дата и идентификатор, измерений в нём нет. Без неё
счётчик в отчёте не на что раскрыть, а отчёт при этом отсылает человека
разбираться по логу.
Ошибка **чтения** каталога архива, включая отсутствие самого корня, SHALL быть
отказом всей пересборки, а не пустым журналом. Нечитаемый каталог означает
«неизвестно, есть ли там тела», а не «тел нет». Каталог архива пересборка
создавать MUST NOT — создание превратило бы запуск не из того каталога в
успешный прогон по пустому журналу.
#### Scenario: Тело без учётной записи подбирается
- **WHEN** в архиве лежит тело, для которого строки `delivery` нет
- **THEN** доставка заводится заново с идентификатором из имени файла и меткой
приёма из ULID в UTC
- **AND** её размер и хеш посчитаны по распакованному телу
- **AND** её точки попадают в витрину
#### Scenario: Учётная запись без тела не роняет пересборку
- **WHEN** у строки `delivery` нет тела в архиве
- **THEN** пересборка продолжается
- **AND** факт учитывается счётчиком в отчёте
- **AND** сама запись переносится в базу назначения, но не сворачивается
#### Scenario: Файл, не являющийся телом, считается отдельно
- **WHEN** в архиве лежит файл, чьё имя не разбирается как ULID либо чьё
расширение не соответствует форме тела
- **THEN** он пропускается и учитывается счётчиком, а пересборка продолжается
#### Scenario: Повтор идентификатора не срывает прогон
- **WHEN** одно и то же имя тела встречается в двух каталогах суток
- **THEN** проигрывается первое, второе учитывается счётчиком
- **AND** пересборка доходит до конца
#### Scenario: Каталог архива не читается
- **WHEN** корня архива нет либо подкаталог не читается
- **THEN** команда завершается ошибкой и витрину не собирает
### Requirement: Пересборка не трогает рабочую базу
Система SHALL собирать витрину в **отдельный файл базы** и MUST NOT записывать
в рабочую базу ничего — ни объектов, ни строк учёта, ни миграций схемы.
Пересборка обязана начинаться с пустой витрины: точки из объекта не удаляются
никогда, поэтому проигрывание поверх накопленного оставило бы результат
прежнего, неверного разбора. Но очистка рабочей витрины необратима и наступает
**до** того, как известно, что пересборка удалась: отказ на середине (битое
тело, отменённый контекст, кончившееся место) оставил бы витрину пустой
наполовину в состоянии, неотличимом от нормального.
Рабочая база SHALL открываться **только для чтения и без наката миграций**.
Обычное открытие накатывает миграции безусловно, а миграции меняют и данные (та,
что ввела частичный разбор, переписала `parse_status` у всех строк) — то есть
штатный путь чтения нарушал бы запрет выше. Хуже: свежий бинарь мигрировал бы
схему под работающим старым сервисом.
Расхождение версии схемы рабочей базы с версией, которую знает бинарь, SHALL
быть отказом с указанием обеих версий, а не поводом мигрировать.
#### Scenario: Рабочая база остаётся нетронутой
- **WHEN** пересборка отработала успешно
- **THEN** отпечаток рабочей витрины не изменился
- **AND** учёт доставок в рабочей базе не изменился
- **AND** собранная витрина лежит в отдельном файле
#### Scenario: Отказ посреди пересборки не портит рабочую базу
- **WHEN** пересборка прерывается на середине журнала
- **THEN** рабочая витрина и учёт доставок в рабочей базе остаются такими же,
какими были
#### Scenario: Схема рабочей базы старше бинаря
- **WHEN** версия схемы рабочей базы не совпадает с версией бинаря
- **THEN** команда завершается ошибкой, называя обе версии
- **AND** не пишет в рабочую базу ни одной строки
### Requirement: Файл назначения и его жизненный цикл
Файл назначения по умолчанию SHALL быть соседом рабочей базы — так подмена
остаётся переименованием внутри одной файловой системы.
Тождество файла назначения с рабочей базой SHALL определяться **по файлу, а не
по строке пути**: путь через `..`, симлинк или другой префикс монтирования
дают то же тождество при разных строках. Совпадение MUST быть отказом: иначе
проигрывание пошло бы прямо в живую рабочую базу — ровно то, что запрещено выше.
Сборка SHALL идти под временным именем, а переименование в файл назначения быть
**последним шагом успешного прогона**. При любом ином исходе — отказ, отмена,
падение — файла по пути назначения появляться MUST NOT, а временный SHALL
убираться вместе со спутниками журнала SQLite.
Полусобранная база выглядит как обычная: это ровно то состояние, ради отрицания
которого отвергнута очистка рабочей витрины. Обломок по пути назначения ещё и
приучил бы обходить защиту от перезаписи флагом принудительности.
Существующий файл назначения перезаписываться молча MUST NOT: это отказ, если
человек явно не потребовал перезаписи. Затребованная перезапись SHALL давать ту
же витрину, что и сборка в отсутствующий файл, — сборка всегда начинается с
пустой витрины, а не дописывается в чужое содержимое.
#### Scenario: Файл назначения совпадает с рабочей базой
- **WHEN** файл назначения — тот же файл, что рабочая база, пусть и по другому
пути
- **THEN** команда завершается ошибкой и не пишет ничего
#### Scenario: Файл назначения уже существует
- **WHEN** файл назначения существует, а перезапись не затребована явно
- **THEN** команда завершается ошибкой и существующий файл не трогает
#### Scenario: Затребованная перезапись даёт ту же витрину
- **WHEN** пересборка выполняется поверх существующего файла назначения с
явно затребованной перезаписью
- **THEN** отпечаток собранной витрины совпадает с отпечатком сборки того же
журнала в отсутствующий файл
#### Scenario: Прерванная пересборка не оставляет файла назначения
- **WHEN** пересборка прерывается на середине журнала
- **THEN** файла по пути назначения не существует
### Requirement: База назначения пригодна к подмене
База назначения SHALL нести полноценный учёт доставок, а не только объекты
витрины: подменяется файл базы **целиком**, а не одна таблица.
Состав переноса нормируется явно, потому что колонки `delivery` двух разных
родов:
```
факты журнала id, received_at, automation_name, automation_id, aggregation,
period, session_id, bytes, sha256, raw_path, headers
← переносятся дословно
производные parse_status, points, derived_layer, uncovered_sections
← начинаются пустыми
```
Факты журнала SHALL переноситься дословно, включая записи, тела которых в
архиве уже нет. Заголовки восстановлению не подлежат ничем — в архиве их нет, —
и неполный перенос уничтожил бы их первой же подменой, а с ними и вывод слоя
для **всех** доставок, не только подобранных. Запись без тела при этом не
сворачивается и в наследовании слоя не участвует: выведенного слоя у неё нет.
Производные от разбора поля MUST начинаться пустыми. Перенос `derived_layer`
особенно опасен и незаметен: доставка, чей повторный разбор отказал (штатный
исход, когда слой не выводится), сохранила бы слой **прежнего** разбора, и
следующая доставка той же автоматизации унаследовала бы его. Витрина снова стала
бы функцией предыдущего прогона, а не журнала, причём оба прогона были бы
самосогласованы — проверка «повторная пересборка ничего не меняет» этого не
ловит.
#### Scenario: Учёт переносится полностью
- **WHEN** пересборка завершилась
- **THEN** число строк учёта в базе назначения равно числу строк рабочей базы
плюс число подобранных тел
- **AND** заголовки перенесённых доставок совпадают с рабочей базой дословно
#### Scenario: Слой прошлого разбора в наследование не попадает
- **WHEN** в рабочей базе у доставок проставлен `derived_layer`
- **THEN** отпечаток пересобранной витрины совпадает с отпечатком пересборки
того же журнала из учёта без проставленных слоёв
### Requirement: Подмену рабочей базы делает человек
Система SHALL оставлять замену рабочей базы пересобранной человеку и
выполнять её сама MUST NOT.
Файл базы держит открытым процесс сервиса, а переименование не касается уже
открытого дескриптора: процесс продолжит писать в отвязанный inode, читатели
увидят новый файл, и данные разойдутся молча. Документация SQLite называет
переименование используемого файла прямой причиной порчи базы. Безопасная
подмена требует остановленного сервиса, а остановить его команда не может:
сервисом управляет окружение снаружи.
Отчёт SHALL печатать процедуру подмены буквально — команды, а не намёк, — и
только тогда, когда прогон признан успешным (см. «Отчёт, оракул и исход
команды»).
#### Scenario: Отчёт называет процедуру подмены
- **WHEN** прогон признан успешным
- **THEN** отчёт содержит путь собранного файла и команды подмены
### Requirement: Отчёт, оракул и исход команды
Система SHALL завершать пересборку отчётом, который несёт счётчики
(проиграно, свёрнуто, отказов по классам, тел без учётной записи, строк без
тела, пропущенных файлов, повторов, объектов **до и после**) и **два
отпечатка** — рабочей витрины и пересобранной, — с прямым ответом, совпали они
или нет.
Отказы SHALL считаться **по классам**: слой не выводится, содержимое не
разбирается, всё прочее. Невыведенный слой есть в каждом журнале и штатен;
общий счётчик отправлял бы человека искать дефект там, где его нет. Отдельно
называть человеку следует только нештатные отказы.
Число объектов «было и стало» SHALL печататься рядом с отпечатками: отпечатки
отвечают «да/нет», а решение о подмене необратимо, и по «да/нет» нельзя
судить о **направлении** расхождения. Именно пара чисел — 1737 против 1742 —
поймала прошлый дефект наследования слоя.
Отпечаток здесь оракул, а не украшение: число объектов к правилу разрешения
столкновений нечувствительно — на координате всегда ровно одна точка, и правило
выбирает, какая, а не сколько. «Объектов столько же» совпало бы и при заведомо
сломанном правиле.
Отпечаток рабочей витрины SHALL сниматься **до** начала проигрывания, а число
доставок в рабочей базе — до и после. Ненулевая разница SHALL называться в
отчёте, и при ней процедура подмены печататься MUST NOT: доставки, приехавшие за
время прогона, есть в рабочей базе и в архиве, но не в собранном файле, и
подмена стёрла бы их учёт вместе с заголовками, которых в архиве нет.
Величины, которые не снимались, отчёт печатать MUST NOT. При отмене отпечаток
пересобранной витрины и число доставок после прогона не измеряются вовсе —
печатать их сравнение значило бы выдать неизмеренное за измеренное, причём в
единственном оракуле задачи. Ожидаемые классы расхождения (новые доставки за время прогона,
непереносимый признак запечатанного часа, исправленный разбор) SHALL называться
отдельно от самого факта расхождения.
**Исход команды.** Расхождение отпечатков отказом быть MUST NOT: после
исправления разбора оно ожидаемо и есть сам смысл пересборки. Отказ отдельной
доставки отказом команды тоже MUST NOT быть: доставка, слой которой не
выводится, — штатный исход.
Отказом команды SHALL быть: пустой журнал, отсутствие хотя бы одной свёрнутой
доставки, отмена и любая ошибка окружения. Пустая витрина совпадает по
отпечатку с пустой витриной, поэтому прогон по пустому журналу выглядит
идеальной сходимостью — а все умолчания подыгрывают такому запуску: конфига
может не быть вовсе, и тогда пути указывают в рабочий каталог процесса. Человек,
выполнивший напечатанную процедуру, заменил бы витрину пустой.
Отчёт значений точек, имён метрик, имён устройств и содержимого тел содержать
MUST NOT: отпечаток берёт содержимое хешем. Ограничение относится к отчёту в
стандартном выводе; лог свёртки живёт по правилам спеки хранения, где координаты
столкновения (метрика, слой, час) разрешены явно.
Отчёт идёт в стандартный вывод человеческим текстом. Прогресс длинного прогона
SHALL идти в поток ошибок, а не смешиваться с отчётом: прогон на полном архиве
молчит минутами, и зависший неотличим от идущего.
#### Scenario: Отчёт сравнивает отпечатки
- **WHEN** пересборка завершилась
- **THEN** отчёт содержит отпечаток рабочей витрины и отпечаток пересобранной
- **AND** прямо называет, совпали они или нет
- **AND** называет, изменилось ли число доставок в рабочей базе за время прогона
#### Scenario: Расхождение отпечатков не является отказом
- **WHEN** отпечаток пересобранной витрины отличается от рабочей, и при этом
хотя бы одна доставка свёрнута
- **THEN** команда завершается успешно, а расхождение названо в отчёте
#### Scenario: Пустой журнал — отказ, а не идеальная сходимость
- **WHEN** в архиве не нашлось ни одного тела
- **THEN** команда завершается ненулевым кодом
- **AND** процедуры подмены не печатает
#### Scenario: Ни одна доставка не свернулась
- **WHEN** журнал непуст, но свернуть не удалось ни одной доставки
- **THEN** команда завершается ненулевым кодом
- **AND** процедуры подмены не печатает
#### Scenario: Приезд доставок за время прогона отменяет подмену
- **WHEN** число доставок в рабочей базе за время прогона изменилось
- **THEN** отчёт называет разницу
- **AND** процедуры подмены не печатает
#### Scenario: Отчёт после отмены не сравнивает неизмеренного
- **WHEN** прогон отменён
- **THEN** отчёт не содержит ни ответа о совпадении отпечатков, ни разницы
числа доставок
#### Scenario: Рабочей базы нет вовсе
- **WHEN** файла рабочей базы не существует
- **THEN** пересборка идёт по одним подобранным телам
- **AND** отчёт называет, что сверять не с чем и что заголовки доставок не
восстанавливаются
#### Scenario: Отчёт не раскрывает данных о здоровье
- **WHEN** отчёт напечатан
- **THEN** он не содержит ни значений точек, ни имён метрик, ни имён устройств
### Requirement: Отказ на одной доставке не останавливает пересборку
Система SHALL продолжать проигрывание, когда отдельная доставка не сворачивается
(тело не читается, тело больше предела, слой не выводится, содержимое не
разбирается), и учитывать такие доставки счётчиком отказов.
Останавливаться на первой нельзя: журнал заведомо содержит доставки, слой
которых не выводится, — это штатный исход, а не поломка, и он не должен лишать
пересборки остальные тела.
Отмена, наоборот, останавливать проигрывание SHALL: это требование прекратить
работу, а не свойство доставки. Источник отмены SHALL быть назван: команду
прерывает человек, и без перевода сигнала прерывания в отмену контекста
требование к поведению по отмене недостижимо в эксплуатации — процесс умирает
мимо всей логики. По отмене команда SHALL напечатать частичный отчёт и
завершиться ненулевым кодом.
#### Scenario: Битое тело не срывает прогон
- **WHEN** одно из тел архива не распаковывается
- **THEN** остальные доставки проигрываются
- **AND** отказ учитывается счётчиком в отчёте
#### Scenario: Отмена прекращает проигрывание
- **WHEN** сигнал прерывания приходит посреди журнала
- **THEN** проигрывание прекращается, печатается частичный отчёт
- **AND** команда завершается ненулевым кодом
- **AND** файла по пути назначения не остаётся
@@ -0,0 +1,127 @@
## 1. Опоры в существующих пакетах
- [x] 1.1 `internal/ident`: `TimeOf(id string) (time.Time, error)` — время
создания из ULID, **в UTC**, усечённое до секунды (как `store.Now`).
`ulid.Time` внутри зовёт `time.Unix` и отдаёт локальную зону, а
`Truncate` зону не нормализует — значит `.UTC()` обязателен явно.
- [x] 1.2 `internal/archive`: перечисление тел — обход корня, отбор форм тела,
возврат относительных путей и отдельно — пропущенных файлов. Ошибка
чтения каталога (включая отсутствие корня) возвращается наружу, а не
превращается в пустой список; каталог не создаётся.
- [x] 1.3 `internal/store`: `ListDeliveries(ctx)` — доставки в порядке
`(received_at, id)` со **всеми фактами журнала**, включая `headers`.
- [x] 1.4 `internal/store`: открытие рабочей базы **только для чтения и без
наката миграций** + проверка версии схемы; расхождение — ошибка,
называющая обе версии.
## 2. Проигрывание журнала — `internal/replay`
- [x] 2.1 Собрать вход: объединить тела архива с учётом доставок; развести
четыре случая (штатная / тело без учёта / учёт без тела / файл не тело) и
отсортировать по `(received_at, id)`.
- [x] 2.2 Перенести учёт в базу назначения по нормированному составу: факты
журнала дословно (включая `headers`), производные от разбора —
пустыми (`parse_status=pending`, `points=0`, `derived_layer=''`,
`uncovered_sections='[]'`). Подобранные тела завести заново: id из имени
файла, метка из ULID в UTC, `bytes`/`sha256` — по **распакованному** телу.
- [x] 2.3 Проиграть журнал последовательно: `fold.Fold` по каждой доставке,
`fold.New` собирается с тем же пределом тела, что и приём
(`cfg.Ingest.MaxBodyMB`). Отказ одной доставки не прекращает прогон,
отмена контекста — прекращает.
- [x] 2.4 Собрать отчёт данными: счётчики по классам, отпечаток пересобранной
витрины, признак отмены.
## 3. Команда `healthlog reindex`
- [x] 3.1 `cmd/healthlog/reindex.go`: флаги `--config`, `--out`, `--force`;
умолчание `--out` — сосед рабочей базы; тождество с рабочей базой
проверяется **по файлу** (`os.SameFile`), а для несуществующего файла —
по родительскому каталогу и имени; `flag.ErrHelp` не превращается в
`fatal`.
- [x] 3.2 Жизненный цикл файла назначения: сборка под временным именем,
переименование — последний шаг успеха; при любом ином исходе файла по
пути назначения нет, временный и его спутники (`-wal`, `-shm`) убраны.
- [x] 3.3 Отмена: `signal.NotifyContext(SIGINT, SIGTERM)`, частичный отчёт,
ненулевой код.
- [x] 3.4 `func writeReport(w io.Writer, …)` — отчёт в stdout, прогресс в
stderr; процедура подмены печатается только при успешном исходе.
- [x] 3.5 Коды возврата: успех — журнал непуст и свёрнута хотя бы одна
доставка; ненулевой — пустой журнал, ноль свёрнутых, отмена, ошибка
окружения. Отказ отдельной доставки исхода команды не меняет.
- [x] 3.6 Подключить подкоманду в `main.go`.
## 4. Проверки
- [x] 4.1 Сходимость: живой приём N доставок → пересборка в отдельную базу →
отпечатки совпали; второй прогон → отпечаток не изменился.
- [x] 4.2 Порядок: журнал, у которого раскладка файлов расходится с
хронологией, даёт доставке без плотных метрик слой **предшествующей** по
`received_at`, а не соседа по каталогу; доставки одной секунды упорядочены
идентификатором.
- [x] 4.3 Состав журнала: тело без учётной записи подбирается, точки доезжают,
`bytes`/`sha256` посчитаны по распакованному телу; учётная запись без тела
считается и не роняет прогон; файл не-тело считается отдельно; нечитаемый
каталог — отказ команды.
- [x] 4.4 Учёт в базе назначения: число строк и `headers` совпадают с рабочей
плюс подобранные; проставленный в рабочей базе `derived_layer` на
результат не влияет (отпечаток тот же, что из учёта без слоёв).
- [x] 4.5 Отказы и обратимость: битое тело не срывает прогон; отмена прекращает
проигрывание и не оставляет файла назначения; рабочая база после
прерванной пересборки не изменилась ни витриной, ни учётом.
- [x] 4.6 Аргументы: `--out` — тот же файл, что рабочая база, по другому пути;
существующий файл без `--force`; `--force` даёт ту же витрину, что сборка
в отсутствующий файл.
- [x] 4.7 Исход команды: пустой журнал — ненулевой код и без процедуры подмены;
ноль свёрнутых — то же.
- [x] 4.8 Отчёт не несёт значений точек, имён метрик и имён устройств.
- [x] 4.9 Прогон живого архива переписан поверх `replay` (см. 6.5), измеренные
утверждения сохранены.
## 5. Приёмочные критерии из ревью дизайна
Рубрика порождена проходом `healthlog-review-rubric` до чтения предложения.
Пункты, уже закрытые разделами выше, отмечены ссылкой.
- [x] 5.1 **Идемпотентность прогона.** Второй прогон даёт то же состояние не
только по отпечатку витрины, но и по учёту доставок в базе назначения
(число строк, `id`, `received_at`, `bytes`, `sha256` подобранных тел).
- [x] 5.2 **Тотальный детерминированный порядок.** Результат не зависит от
порядка обхода каталога, часового пояса процесса и числа перечитываний
каталога (см. 4.2).
- [x] 5.3 **Независимость от «сейчас».** Ни одно поле, влияющее на отпечаток, не
производно от времени прогона: метки берутся из события, а не из
`store.Now`. Проигрывание последовательно.
- [x] 5.4 **Незавершённая сборка неотличимой от завершённой быть не может**
(см. 3.2, 4.5).
- [x] 5.5 **Отмена доводится до конца и различима снаружи** (см. 3.3).
- [x] 5.6 **Оракул успеха не сводится к «ошибок не было»** (см. 3.5, 4.7).
- [x] 5.7 **Политика частичного отказа явная и счётная.** У каждого класса
отказа свой счётчик, сумма счётчиков сходится с числом входов.
- [x] 5.8 **Рабочая база открывается только на чтение и без миграций**
(см. 1.4).
- [x] 5.9 **Расход памяти не растёт с объёмом архива.** Тела читаются по
одному, предел распакованного тела тот же, что у приёма (см. 2.3);
превышение — учтённый отказ доставки, а не падение прогона.
- [x] 5.10 **Длинный прогон наблюдаем** (см. 3.4).
- [x] 5.11 **Аргументы безопасны и обратимы** (см. 3.1, 4.6).
- [x] 5.12 **Невосстановимое названо, а не досчитано.** Отчёт называет классы
ожидаемого расхождения (новые доставки за время прогона, непереносимый
`sealed`, исправленный разбор) отдельно от самого факта расхождения.
- [x] 5.13 **Ни отчёт, ни лог выше `DEBUG` не несут данных о здоровье**
(см. 4.8).
## 6. Документация
- [x] 6.1 `docs/architecture.md`: почему подмена базы не автоматизируется
(открытый дескриптор, порча базы SQLite) и почему пересборка идёт в
отдельный файл (blue-green rebuild проекции); отвергнутые варианты — с
причиной. Там же — правка утверждения «пересчёт при `reindex` идёт по всей
истории и потому точнее»: точнее не длина ряда, а исправленный разбор.
- [x] 6.2 `README.md`: `reindex` перестаёт быть «в планах», процедура применения.
- [x] 6.3 `docs/plan.md`: `reindex` вычеркнут из остатка шага «Разбор и
хранилище».
- [x] 6.4 Беклог: задача удалена, заведена новая — «заголовки доставки в архиве
рядом с телом» (prior art: WARC), с указанием, что без неё пересборка без
рабочей базы деградирует по выводу слоя.
- [x] 6.5 `task verify:archive` переезжает на `internal/replay`: отдельной
задачи прогона не заводим, второго проигрывателя в проекте не остаётся.