## 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** файла по пути назначения не остаётся
