# storage Specification

## Purpose

Витрина: как принятые точки и сущности ложатся, адресуются и выбираются между
версиями. Здесь живут идентичность точки по координатам, полнота и тай-брейк,
часовой объект, хранение сущностей с собственным `id`, отпечаток витрины и учёт
разобранности доставки. Правило одно на всё: содержимое хранится дословно, а
состояние остаётся свёрткой журнала в его порядке.

## Requirements
### Requirement: Идентичность точки по координатам

Система SHALL адресовать точку координатами
`метрика + слой + начало + конец`. У точки-измерения конец равен началу; у
точки-интервала — концу интервала. Ключ MUST быть одной формы для всех точек:
интервальная и точечная формы не встречаются вперемешку внутри одной метрики
одной доставки (проверено на всём корпусе), поэтому ветвление по «классу
метрики» не нужно и вводить его MUST NOT.

Поле `source` в ключ входить MUST NOT: оно нестабильно — то же измерение с тем
же значением приезжает то как `Apple Watch Ultra 3|iPad (Anton)`, то как
`Apple Watch Ultra 3`, потому что Health переосмысливает атрибуцию задним
числом.

Начало точки берётся из `start`, а при его отсутствии — из `date`; конец — из
`end`, а при его отсутствии — из начала. Измерено: `start`, когда он есть,
**всегда** совпадает с `date` (ноль исключений на 22 метриках), поэтому правило
не вводит второго источника метки — оно лишь закрывает случай, когда HAE
перестанет их дублировать.

Час объекта определяется по началу точки: интервал пересекает границы часов, и
любой другой выбор сделал бы принадлежность объекту зависящей от длительности.

Ключ по одной метке проверялся и отвергнут: он схлопывает записи сна. Измерено
на всех 94 доставках — 170 координат против 174 и **33 столкновения внутри
одной доставки**, где `received_at` общий, тай-брейк по нему неприменим в
принципе, и исход решал бы порядок элементов в JSON-массиве, а он нестабилен.
При этом разные интервалы под одной меткой всегда несут разное содержимое
(проверено по всем метрикам), то есть ключ с интервалом ничего не задваивает.

Идентичность по хешу содержимого проверялась и отвергнута: она задваивала
минутный слой целиком — 120 точек в часе вместо 60.

#### Scenario: Повторная доставка той же точки ничего не меняет

- **WHEN** точка с теми же координатами и тем же содержимым приезжает снова
- **THEN** хранилище не изменяется

#### Scenario: Смена источника не создаёт вторую точку

- **WHEN** точка с теми же координатами приезжает с другой строкой `source`
- **THEN** она остаётся одной точкой, а не превращается в две

#### Scenario: Записи с одной меткой и разными интервалами не схлопываются

- **WHEN** в доставке приходят точки `sleep_analysis` с одинаковым `date` и
  разными парами `start`/`end`
- **THEN** каждая сохраняется отдельной точкой

#### Scenario: Повтор записи в следующей доставке не задваивает

- **WHEN** точка с тем же началом и концом приезжает следующей доставкой
- **THEN** она остаётся одной точкой

#### Scenario: Точка-измерение адресуется вырожденным интервалом

- **WHEN** точка не несёт `end`
- **THEN** её конец равен началу, и ключ имеет ту же форму, что у интервала

### Requirement: Разрешение столкновений по полноте

Когда по одним координатам приходят разные содержимые, система SHALL оставлять
**более полную** точку — ту, чьё множество ключей с непустым значением является
**строгим надмножеством** множества другой, — а не последнюю пришедшую. Иначе
бедная доставка стирает у богатой поля, которых сама не несёт.

Полнота SHALL сравниваться множествами, а не их размером. Число сравнимо
всегда, и потому счётчик даёт ответ там, где ответа нет: точка с пятью полями,
не несущими содержания, побеждала бы настоящее измерение с двумя полями и
стирала бы его безвозвратно.

Измерено (находка 49): настоящих столкновений 2 897 из 444 256 координат
(0.65%); из них 981 различаются набором полей — это и есть область правила
полноты, — 1 916 несут равные наборы и разные значения, где исход решает
тай-брейк, а несравнимых наборов ноль.

Перемер 2026-08-04 на выросшем корпусе (155 доставок, 460 995 координат,
настоящий ключ со слоем) уточнил соотношение и сделал тай-брейк главным
разрядом правила, а не крайним: спорных координат 80 129, полнота отбрасывает
кого-то в 981 из них (1,2%), остальные 79 148 (98,8%) уходят в тай-брейк.
Смена тай-брейка меняет исход на 75 494 координатах, из них 71 773 —
`basal_energy_burned` слоя `raw`, то есть посекундная развёртка HAE, которую
Read API суммировать и так не имеет права.

Пустым значением MUST считаться `null`, пустая строка, число, равное нулю (в
любой записи), пустой объект и пустой массив: поле без содержания не делает
точку полнее точки, где этого поля нет вовсе. Пустота MUST определяться по
разобранному значению, а не по байтам: `0`, `0.0`, `0e0`, `-0` и `{ }` — та же
пустота, что `0` и `{}`.

`false` пустотой MUST NOT считаться: для булева поля это одно из двух значений,
а не отсутствие содержания (`isIndoor: false` — тренировка на улице).

Поле `source` в множество не входит — оно нестабильно и переписывается задним
числом (находка 36), так что о полноте измерения ничего не говорит.

Содержимое, которое не разбирается как JSON-объект, SHALL нести **пустое
множество** ключей: так оно проигрывает любой точке с содержанием и не
загрязняет наблюдение о несравнимых наборах.

Надмножество побеждает только тогда, когда оно **несёт то же содержание**:
значения ключей, содержательных у обеих точек, MUST совпадать (с точностью до
канонической формы). Иначе точки несут разные измерения, и надмножество имён
о полноте не говорит ничего — такая пара MUST разрешаться как равнополная.
Без этого условия точка `{date, qty:0.001, p1:null, p2:null}` вытесняла бы
`{date, qty:72.5}`, то есть точка, где ни одно значение не измерение,
стирала бы измерение — ровно то, ради отрицания чего правило переписано.

Если множества ключей с непустым значением **равны и значения совпали**,
система SHALL сравнить множества **всех** ключей, кроме `source`, и оставить
строгое надмножество. Без этого разряда правило теряло бы поля там, где
заведено их беречь: точка `{date, qty:10, Min:0, Max:0}` и точка
`{date, qty:10}` несут одинаковое содержание, и `Min` с `Max` исчезли бы из
витрины по жребию. Несравнимость на этом разряде исходом MUST NOT быть:
лишние ключи там заведомо пусты, объединять в них нечего.

**Если полнота ответа не дала — равные множества с разными значениями либо
несравнимые множества, — победителем SHALL быть точка, пришедшая разбираемой
доставкой, а не лежавшая в объекте.** Байтовый порядок канонических форм на
этом разряде отвергнут замером: он берёт меньшее значение в 1 847 случаях из
1 912 (находка 49), то есть системно хранит версию, которую источник уже
пересчитал. Ценой этого выбора час `2026-08-03T07:00Z` метрики `step_count`
остался с недосчитанным значением, сверка слоёв объявила метрику мгновенной
против 23 согласных часов, и род ушёл в `unknown` — Read API потерял право
суммировать шаги.

Значение точки в правило входить MUST NOT: «брать бо́льшее» верно для
накопительных метрик и неверно для мгновенных, которые источник досчитывает
вниз. Род метрики в правило входить MUST NOT тоже — род есть функция витрины, а
правило слияния, читающее собственную выдачу, перестаёт быть функцией префикса
журнала.

**Столкновение ВНУТРИ одной доставки SHALL разрешаться порядком канонических
форм**: провенанс у таких точек общий, различать их нечем, а порядок элементов
в JSON-массиве от HAE нестабилен. Точка, канонически совпавшая с сохранённой,
SHALL считаться пришедшей — иначе сохранённый кандидат проигрывал бы соседу по
доставке, которого обязан был обойти по байтам, и «внутри доставки решают
байты» нарушалось бы ровно тогда, когда доставка ничего не изменила. Побеждает
при этом сохранённое содержимое дословно: хеш не двигается, объект не
переписывается. Тем же правилом схлопываются две точки одного тела с совпавшей
канонической формой — в витрине остаются байты **встреченной первой**. Выбор
между ними ненаблюдаем по построению: различие при совпавшей канонической форме
это порядок ключей или последний разряд double, и хеш содержимого объекта
считается по канонической форме, а не по байтам.

Цена нового тай-брейка называется вслух и ограничивается разрядом, на котором
он работает. **Когда значения общих содержательных ключей разошлись, полнота
ответа не даёт по построению** — «надмножество имён о полноте не говорит
ничего», см. выше, — и пришедшая точка побеждает, даже если сохранённая несла
сверх того ключи **без содержания** (`Min:0`, `Max:0`). Такие ключи по
определению пустоты этого же требования содержания не несут, и второй разряд их
бережёт только там, где содержание совпало. Прежний байтовый порядок сохранял
их случайно — по тому, что каноническая форма с ключом `Max` сортируется раньше
формы с одним `date`, — и рассчитывать на такую защиту было нельзя.

**Класс входа, на котором правило ведёт себя хуже прежнего, называется здесь.**
Две автоматизации, чьи наборы метрик пересекаются, наполняют одну координату
разными значениями (находка 14); прежний тай-брейк давал на ней устойчивый
исход, новый — чередование по последней доставке, то есть перезапись объекта и
`WARN` на каждой доставке. Защита остаётся операционной («наборы метрик между
автоматизациями не пересекать»), система её не проверяет, и это записано, чтобы
следующий разбор не искал причину заново.

**Победитель SHALL быть функцией множества точек координаты и их происхождения
(доставка или витрина), а не порядка элементов внутри доставки.** Попарная
свёртка этого не даёт: полнота — частичный порядок, тай-брейк — тотальный, и
вместе они образуют нетранзитивное отношение победы (A превосходит B по
полноте, B бьёт C тай-брейком, C бьёт A тай-брейком). При таком цикле повторная
свёртка одной и той же доставки меняет содержимое объекта. Поэтому система
SHALL отбросить кандидатов, превзойдённых по полноте кем-то другим, и выбрать
победителя среди оставшихся по тотальному порядку — сперва происхождение,
затем каноническая форма.

**Правило тем самым есть явная функция порядка журнала, и цена этого называется
вслух.** Функцией множества оно быть перестало: «пришедшая побеждает» не
коммутативно. Витрина остаётся свёрткой журнала только пока порядок свёртки
равен порядку журнала — требование, которое capability приёма обязана
обеспечивать, а capability пересборки обязана проверять оракулом. Восстановить
коммутативность «для чистоты» MUST NOT: это откатило бы починку молча.

Сравнение по `received_at` для тай-брейка не годится: у сохранённой точки нет
провенанса — ни времени приёма, ни идентификатора доставки, — и сравнивать не с
чем. Заводить его это изменение SHALL NOT: колонка провенанса на точку означала
бы смену формата содержимого объекта, миграцию и рост нижнего слоя, а
«пришедшая побеждает» даёт тот же исход, пока порядок свёртки равен порядку
журнала. Запрет этот **бюджетный, а не принципиальный**: он назван решением
владельца от 2026-08-04 и снимается тем же порядком. Если окно конкурентного
приёма закрыть на приёме не удастся, провенанс (на объект, не на точку)
остаётся единственным ходом, и спека обязана это допускать, а не запрещать
вечно.

Если множества **несравнимы** — каждое несёт ключ с непустым значением,
которого нет у другой, — система SHALL выбрать победителя тем же правилом, что
и при равных множествах, и MUST оставить наблюдение: счётчик в итоге разбора
доставки, координаты объекта и запись `WARN` без значений точки. Несравнимый
набор — частный случай столкновения: счётчик перезаписей растёт вместе с ним, а
координаты попадают в оба списка.

Объединять поля двух точек система SHALL NOT: на живом потоке несравнимые
наборы наблюдаются единицами (2 на 155 доставок), и реализация правила, которое
почти не срабатывает, стоила бы больше, чем счётчик, который скажет, если оно
станет массовым.

Победителем SHALL оставаться одна из пришедших точек **дословно**: правило
выбирает, а не конструирует. Каноническая форма существует только в момент
сравнения — вернуть её вместо исходных байт значило бы сохранить округлённое
число вместо присланного.

#### Scenario: Бедная точка не стирает поля богатой

- **WHEN** сохранена точка с `Avg`, `Min`, `Max` и `context`
- **AND** по тем же координатам приезжает точка только с `Avg`, `Min` и `Max`
- **THEN** сохранённая точка остаётся с `context`

#### Scenario: Поля без содержания полноты не добавляют

- **WHEN** сохранена точка с пятью полями, значения которых `0`, `{}` и `[]`
- **AND** по тем же координатам приезжает точка с `date` и ненулевым `qty`
- **THEN** остаётся точка с `date` и `qty`

#### Scenario: При равном содержании поля не теряются

- **WHEN** сохранена точка `{date, qty, Min:0, Max:0}`
- **AND** по тем же координатам приезжает точка `{date, qty}` с тем же `qty`
- **THEN** остаётся точка с `Min` и `Max`

#### Scenario: При разошедшихся значениях пустые ключи не удерживают точку

- **WHEN** сохранена точка `{date, qty:10, Min:0, Max:0}`
- **AND** по тем же координатам следующей доставкой приезжает точка
  `{date, qty:12}`
- **THEN** остаётся пришедшая точка, а `Min` и `Max` в витрине не остаются
- **AND** счётчик перезаписей растёт

#### Scenario: Одинаково полные точки с разными значениями

- **GIVEN** по координатам сохранена точка предыдущей доставки
- **WHEN** следующей доставкой приезжает точка с тем же множеством ключей и
  другим значением
- **THEN** в витрине остаётся пришедшая точка

#### Scenario: Одинаково полные точки внутри одной доставки

- **WHEN** в одном теле по одним координатам приезжают две точки с одинаковыми
  множествами ключей и разными значениями
- **THEN** остаётся точка с меньшей канонической формой
- **AND** исход не зависит от порядка этих точек в массиве

#### Scenario: Повторная присылка сохранённого содержимого ничего не переписывает

- **GIVEN** по координатам сохранена точка
- **WHEN** следующей доставкой приезжает точка с той же канонической формой и
  другими байтами
- **THEN** в витрине остаются сохранённые байты
- **AND** объект не переписывается

#### Scenario: Полнота сильнее происхождения

- **GIVEN** по координатам сохранена точка с `Avg`, `Min` и `Max`
- **WHEN** следующей доставкой приезжает точка только с `Avg` и теми же
  значениями общих ключей
- **THEN** остаётся сохранённая точка
- **AND** счётчик удержаний в итоге разбора доставки растёт

#### Scenario: Несравнимые множества считаются, а не сливаются

- **WHEN** по одним координатам приходят две точки, каждая из которых несёт
  ключ с непустым значением, которого нет у другой
- **THEN** остаётся ровно одна точка, выбранная тем же правилом
- **AND** счётчик несравнимых наборов в итоге разбора доставки растёт
- **AND** система пишет `WARN` с координатами объекта и без значений точки

#### Scenario: Содержимое, которое не разбирается в объект

- **WHEN** по координатам сталкиваются точка с непустыми полями и содержимое,
  не разбирающееся как JSON-объект
- **THEN** остаётся точка с полями
- **AND** счётчик несравнимых наборов не растёт

Столкновением SHALL считаться расхождение **канонических форм**, а не байтов.
Байты нестабильны — ради этого канонизация и заведена: из 81 952 повторно
приехавших точек 67 534 различаются лишь порядком ключей, ещё 63% — последним
разрядом double. Побайтовое сравнение давало бы тысячи ложных срабатываний на
каждом глубоком проходе, и настоящий отказ правила стал бы неотличим от нормы.

#### Scenario: Столкновение с различием содержимого оставляет след

- **WHEN** по одним координатам сохраняется точка, каноническая форма которой
  отличается от уже сохранённой
- **THEN** система пишет запись уровня `WARN` без значений точки
- **AND** запись несёт координаты объекта: метрику, слой и час
- **AND** увеличивает счётчик перезаписей в итоге разбора доставки

#### Scenario: Дребезг сериализации столкновением не считается

- **WHEN** та же точка приезжает с другим порядком ключей или отличаясь
  последним разрядом числа
- **THEN** счётчик перезаписей не растёт и `WARN` не пишется

Без этого следа допущение «меньше полей не значит новее» не получит ни одного
наблюдения, а отказ правила будет неотличим от нормальной работы до сверки с
экспортом Apple — то есть месяцами.

### Requirement: Хранение часовыми объектами

Система SHALL хранить точки часовыми объектами с ключом
`метрика + слой + час (UTC)`. Содержимое объекта — сжатый gzip блоб; точки
внутри упорядочены по времени.

Объект SHALL нести **единицы измерения** метрики. Внутри точки их нет — они
живут на уровне метрики (проверено: поле `units` не встретилось ни в одной
точке за 89 доставок), поэтому дословное хранение точек их не сохраняет. Без
колонки единицы восстановимы только из архива, а для метрик, переставших
приходить, — теряются навсегда.

Объект SHALL нести границы содержимого (первая и последняя метка) и
идентификатор доставки, создавшей его. Первое нужно каталогу разрезов, чтобы
не разжимать каждый блоб ради диапазона; второе — провенанс для разбора
слияний.

Запись — чтение объекта, слияние точек, запись обратно. Точки из объекта
MUST NOT удаляться. Содержимое объекта SHALL сериализоваться без
HTML-экранирования: `&`, `<` и `>` внутри точки обязаны храниться теми же
байтами, какими пришли, иначе «точка хранится дословно» перестаёт быть правдой,
а сравнение с последующей доставкой той же точки промахивается навсегда.

Доставка SHALL сворачиваться **одной транзакцией**. Транзакция на объект давала
недетерминированное частичное состояние: обход групп рандомизирован, и при
отказе посреди доставки набор уже записанных объектов каждый раз другой
(измерено: восемь прогонов одной доставки — семь разных состояний). Это ломает
инвариант «состояние пересобираемо».

Единицы измерения MUST NOT переписываться молча: при расхождении сохранённых и
пришедших единиц остаётся сохранённое значение, факт учитывается счётчиком и
попадает в запись уровня `WARN`. Внутри точки единиц нет, и у ранее сохранённых
точек не остаётся ничего, по чему их единицы восстановимы.

#### Scenario: Отказ посреди доставки не оставляет части объектов

- **WHEN** свёртка доставки прерывается на середине
- **THEN** не записывается ни один объект этой доставки

#### Scenario: Смена единиц не переподписывает сохранённые точки

- **WHEN** в объект приезжают точки в единицах, отличных от сохранённых
- **THEN** единицы объекта остаются прежними
- **AND** факт учитывается счётчиком и записью `WARN`

#### Scenario: Точки за один час ложатся в один объект

- **WHEN** приходят точки одной метрики и слоя за один час UTC
- **THEN** они хранятся одним объектом

#### Scenario: Дозапись в существующий час

- **WHEN** приходят новые точки за уже существующий час
- **THEN** объект перечитывается, точки сливаются, объект записывается обратно
- **AND** ранее сохранённые точки остаются в объекте

### Requirement: Хеш как детектор изменений

Система SHALL хранить хеш канонической формы объекта и пропускать запись, если
хеш не изменился. Хеш — детектор, а не ключ.

Это то, что делает широкие проходы синхронизации дешёвыми: глубокий проход
переприсылает неделю, но почти все сравнения сходятся и записи не происходит.

#### Scenario: Повторная присылка того же часа не пишет в базу

- **WHEN** приезжает доставка, целиком повторяющая уже сохранённый час
- **THEN** хеш совпадает и запись не выполняется

### Requirement: Признак запечатанного часа

Система SHALL хранить признак `sealed` у часового объекта и SHALL реагировать
на изменение запечатанного объекта сигналом, а не отказом.

Правило перевода часа в `sealed` в этой дельте **не определяется**: порог
глубины досчёта ставится по наблюдениям, которых пока нет (наблюдалось до
22 минут). До появления правила признак остаётся невыставленным, и сценарий
ниже проверяется только явной установкой в тесте — это осознанная граница, а
не упущение.

#### Scenario: Изменение запечатанного часа

- **WHEN** приходят точки за час, помеченный `sealed`
- **THEN** система пишет запись уровня `WARN`
- **AND** данные всё равно сохраняются

### Requirement: Значения точек не попадают в логи

Данные о здоровье чувствительнее токенов. Система MUST NOT писать значения
точек, содержимое сущностей и тела доставок в записи лога уровня выше `DEBUG`.

Содержимое сущности здесь не менее чувствительно, чем значение точки, а местами
более: маршрут тренировки — это геотрек до дома, а `labels` и `associations`
записи состояния разума — эмоциональные метки. Разрешены **координаты**:
идентификатор и род сущности, метка времени, идентификатор доставки — они
описывают, что случилось, а не что измерено.

Непокрытые секции называются в логе **именами ключей**: имя секции — это форма
пакета, а не измерение. Содержимое секции в лог не попадает ни при каком уровне
выше `DEBUG`. Имена идут структурным атрибутом, а не склейкой в текст сообщения:
кодировщик экранирует управляющие символы, и имя из чужого тела не разрывает
построчный разбор логов. То же относится к идентификатору сущности: он приходит
из чужого тела и ограничен по длине при разборе.

Частичный разбор уровня записи не повышает: `partial` — установившееся
состояние, а не сигнал. До покрытия `workouts` и `stateOfMind` частичной
числилась половина потока (53 доставки из 118); после покрытия непокрытая
секция живым потоком не приносилась ни разу на том же корпусе. Постоянный
`WARN` обесценил бы уровень.
Повышает уровень другое, и оснований два:

- срабатывание границ списка: тело с сотнями секций или с именем длиннее предела
  на HAE не похоже вовсе;
- **первая по журналу встреча имени непокрытой секции** — событие однократное за
  всю жизнь имени, и правила его живут в capability наблюдения за непокрытыми
  секциями. Здесь оно названо, чтобы перечень оснований оставался полным: иначе
  следующий читатель снимет ветвь как незаказанную.

#### Scenario: Разбор доставки логируется без значений

- **WHEN** доставка разобрана
- **THEN** запись лога содержит счётчики (метрик, точек, объектов, сущностей) и
  идентификатор доставки
- **AND** не содержит ни значений точек, ни имён устройств

#### Scenario: Удержанная обеднённая версия логируется координатами

- **WHEN** приехавшая версия сущности отклонена как теряющая содержание
- **THEN** запись `WARN` содержит идентификатор и род сущности
- **AND** не содержит ни точек маршрута, ни того, какие поля потерялись

#### Scenario: Непокрытые секции названы именами ключей

- **WHEN** доставка содержит непокрытую секцию
- **THEN** запись лога содержит имена непокрытых ключей отдельным атрибутом
- **AND** не содержит ничего из содержимого этих секций
- **AND** уровень записи из-за одной лишь частичности не повышается

#### Scenario: Границы списка сработали

- **WHEN** список непокрытых ключей усечён по числу имён или по длине имени
- **THEN** запись лога имеет уровень `WARN`
- **AND** содержит число отброшенных имён

#### Scenario: Имя непокрытой секции встречено впервые

- **WHEN** доставка принесла имя непокрытой секции, которого не было ни в одной
  доставке раньше неё в журнале
- **THEN** запись лога имеет уровень `WARN`
- **AND** содержит имя отдельным атрибутом новых секций

### Requirement: Учёт частично разобранной доставки

Система SHALL отличать доставку, разобранную целиком, от доставки, в теле
которой остались непокрытые разбором секции. Доставка с непустым списком
непокрытых ключей MUST получать статус `partial`, а не `parsed`.

Статусы разбора:

```
pending  этим разбором ещё не смотрели — или смотрели, но работа не сделана
         по обстоятельствам (см. ниже)
parsed   разобрано всё, что в теле было
partial  разобрано покрытое; в теле остались непокрытые секции
failed   разобрать не удалось, точек нет
```

Источник истины — список непокрытых ключей; статус производен от него и от
факта отказа, в порядке `failed` → `partial` → `parsed`. Приоритет назван явно,
чтобы читатели (ретеншен, статистика) спрашивали статус, а не сравнивали список
со строкой.

**Отказ обстоятельств статуса не меняет вовсе.** Отмена работы снаружи и
занятость базы дольше повторов транзакции означают «не сделано», а не «не
выходит»: доставка остаётся `pending` и будет свёрнута снова. Правило появилось
не из аккуратности — фоновая свёртка `failed` не подбирает никогда, и без этого
различения занятость базы (а с фоновой свёрткой конкуренция за неё штатная)
выводила бы доставку из очереди навсегда. Различение живёт **в одном месте**:
тот, кто пишет исход, и тот, кто классифицирует его в счётчики, спрашивают один
предикат.

Дедлайн самой свёртки к обстоятельствам MUST NOT относиться: доставка, не
уложившаяся в бюджет, не уложится в него и в следующий раз, а бесконечный повтор
заведомо безнадёжного — это очередь, которая не движется.

Отказы, случившиеся **до** чтения тела (учётной записи нет, соседний запрос не
прошёл), и отказ самой записи исхода статуса не меняют по другой причине —
записать его нечем. Доставка остаётся `pending`, что честно: этим разбором её не
досмотрели.

Список непокрытых ключей SHALL сохраняться рядом с доставкой — именами ключей,
без содержимого секций. Он же ответ на вопрос «что останется потерянным, если
тело удалить»: для `stateOfMind` доставки HAE единственный источник, в экспорте
Apple его нет (находка 46). Поэтому список MUST сохраняться и при отказе
разбора, если разбор успел его собрать: `failed` с непустым списком — законное
состояние.

Запись списка MUST замещать прежнее значение целиком, включая замещение пустым:
иначе доставка, все секции которой стали покрытыми, осталась бы `partial`
навсегда.

Список — снимок покрытия **на момент свёртки**. Задача, которая начинает
разбирать секцию, тем же изменением SHALL переводить `partial`-строки с этим
ключом в `pending`; ретеншену позволено смотреть на `partial` только при
соблюдении этого правила.

Статусы, поставленные разбором, который частичного исхода не различал, доверия
не заслуживают: под `parsed` у них лежат и полностью разобранные доставки, и
доставки без метрик вовсе. Такие строки MUST переводиться в `pending` — «этим
разбором ещё не смотрели». Число точек у них до пересвёртки остаётся прежним: оно
производно от объектов витрины, которые никуда не делись.

#### Scenario: Доставка с непокрытой секцией отмечается частичной

- **WHEN** разбор доставки вернул непустой список непокрытых ключей
- **THEN** `parse_status` доставки равен `partial`
- **AND** список непокрытых ключей сохранён вместе с доставкой
- **AND** точки покрытой секции сохранены как обычно

#### Scenario: Доставка без непокрытых секций остаётся `parsed`

- **WHEN** разбор доставки не дал непокрытых ключей
- **THEN** `parse_status` равен `parsed`
- **AND** сохранённый список непокрытых ключей пуст

#### Scenario: Отказ разбора сильнее частичности

- **WHEN** разбор доставки завершился ошибкой в самом разборе или в записи
  точек
- **THEN** `parse_status` равен `failed`
- **AND** список непокрытых ключей сохранён, если разбор успел его собрать

#### Scenario: Занятая база доставку из очереди не выводит

- **WHEN** разбор не состоялся из-за занятости базы или отмены работы снаружи
- **THEN** `parse_status` остаётся `pending`

#### Scenario: Пересвёртка после того, как секция стала покрытой

- **WHEN** доставка со статусом `partial` сворачивается повторно разбором,
  который эту секцию покрывает
- **THEN** `parse_status` становится `parsed`
- **AND** сохранённый список непокрытых ключей пуст

### Requirement: Хранение сущностей с собственным идентификатором

Система SHALL хранить тренировки и записи секций с собственным `id` **не**
часовыми объектами, а по одной строке на сущность: у них есть естественный
ключ, они редки (за двое суток потока — две тренировки и две записи состояния
разума), и группировать их по часам незачем.

Единиц хранения две:

```
тренировка   ключ  id
             заголовок колонками: имя, начало, конец, офсет зоны, длительность
запись       ключ  род секции + id
             заголовок колонками: род, метка времени, офсет зоны
```

Сущность SHALL нести **провенанс** — идентификатор доставки, чья версия лежит
сейчас, и метку приёма этой доставки. Он нужен не отчётности: по нему
разрешается тай-брейк между версиями равной полноты (см. «Замена версии
сущности…»), и без него `WARN` об удержанной обеднённой версии не связать с
телом в архиве.

Длительность тренировки SHALL допускать отсутствие значения, отличимое от нуля:
ноль — законная длительность, и потребитель, сложивший столбец, иначе не отличил
бы «источник не прислал» от «измерено ноль».

Ключ записи SHALL быть парой `род + id`, а не одним `id`. Собственный `id`
наблюдался живьём только у `stateOfMind`, где он UUID HealthKit; форма
идентификатора остальных пяти секций не наблюдалась никем, и короткий
несквозной `id` в двух разных секциях затёр бы одну запись другой молча. Пара
стоит ноль: запросы к записям всегда идут с родом.

Содержимое сущности SHALL храниться **дословно** — теми же байтами, какими
пришло, включая маршрут, внутренние ряды и сводки. Заголовок колонками
существует ради выборки по времени и не является разбором содержимого: любая
следующая колонка была бы решением за Apple о том, что в тренировке главное.

Ряд пульса внутри тренировки MUST лежать в её содержимом, а не в объектах
метрики `heart_rate`: это разные таблицы, и смешение задвоило бы ряд.

Сущности доставки SHALL записываться **той же транзакцией**, что и её точки.
Доставка — единица свёртки; частичное состояние ломает инвариант «состояние
пересобираемо», а наблюдение «секции не смешиваются в одной доставке» собрано
за двое суток и основанием для второй транзакции не является.

Система SHALL хранить рядом с сущностью хеш её канонического содержимого и
пропускать запись содержимого, если хеш не изменился. Тренировка
переприсылается каждой доставкой автоматизации, пока не доедет маршрут: на
живом архиве 44 доставленные копии дают три различных содержимых.

Сравнение SHALL начинаться с хеша, читаемого **без** содержимого сохранённой
сущности: маршрут доходит до мегабайта, разжимать и канонизировать его на каждой
из 44 копий не за чем.

Каноническая форма приехавшей сущности SHALL считаться **один раз на версию и
до входа в транзакцию**, а хеш SHALL браться из уже посчитанной формы. Внутри
транзакции канонизации приехавших версий быть MUST NOT: транзакция открывается
`immediate`, то есть блокирует запись, и повторяется до пяти раз при занятости
базы — измерено, что тело 40 МиБ даёт пик кучи 768 МиБ, а тело 63 МиБ удерживает
блокировку 5.019 с при `busy_timeout` 5000, после чего конкурентная доставка
исчерпывает повторы. Считать форму дважды (в хеше и в сравнении) система MUST
NOT: это ровно та же работа над теми же байтами.

Остаточный предел называется вслух: разбор **сохранённой** версии остаётся
внутри транзакции — её содержимое читается оттуда же и только когда хеш
разошёлся. Значит удержание блокировки по-прежнему пропорционально размеру
сохранённой сущности, и класс отказа «конкурентный приём исчерпал повторы → 500
по доставке, тело которой уже в архиве» этим требованием **не закрывается**, а
лишь становится различимым в логе. Закрыть его может только предел на размер
сущности вместе с потоковым расчётом — отдельная задача.

#### Scenario: Тренировка хранится одной строкой с маршрутом

- **WHEN** приезжает тренировка с маршрутом
- **THEN** она хранится одной строкой, адресуемой своим `id`
- **AND** маршрут и внутренние ряды лежат в её содержимом дословно

#### Scenario: Ряд пульса тренировки не попадает в метрику

- **WHEN** тренировка несёт `heartRateData`
- **THEN** объектов метрики `heart_rate` эта доставка не создаёт

#### Scenario: Записи разных родов с одинаковым id не сталкиваются

- **WHEN** две записи разных родов приезжают с одним и тем же `id`
- **THEN** в хранилище лежат обе

#### Scenario: Повторная присылка той же тренировки не пишет в базу

- **WHEN** приезжает тренировка, содержимое которой совпадает с сохранённым, и
  её доставка стоит в журнале не позже сохранённой
- **THEN** хеш совпадает и запись не выполняется

#### Scenario: Отказ посреди доставки не оставляет части сущностей

- **WHEN** свёртка доставки прерывается на середине
- **THEN** не записывается ни одна сущность этой доставки

#### Scenario: Каноническая форма сущности считается один раз

- **WHEN** доставка с сущностью сворачивается, и транзакция повторяется из-за
  занятости базы
- **THEN** каноническая форма приехавшей сущности не пересчитывается ни на
  повторе, ни отдельно от хеша

### Requirement: Замена версии сущности не теряет содержания

Сущность с собственным `id` SHALL замещаться **целиком**, а не сливаться по
полям: она приезжает повторно, пока источник её досчитывает. Замер на живом
архиве: одна тренировка приехала 26 раз в трёх различных содержимых — сперва
добавились `stepCadence` и `stepCount` вместе с изменившимся рядом
`activeEnergy`, затем при том же наборе полей досчитались `totalEnergy` и
`basalEnergy`.

Замещение MUST быть условным: приехавшая версия побеждает, **если не теряет
содержания** сохранённой. Порядок разбора:

```
1. хеш канонического содержимого совпал        → содержимое не пишется,
                                                  провенанс поднимается до
                                                  более поздней позиции журнала
2. содержание приехавшей покрывает сохранённую
   и сверх того                                → приехавшая замещает целиком
3. приехавшая теряет содержание сохранённой    → остаётся сохранённая,
                                                  счётчик + WARN
4. содержание сравнимо, наборы равны           → версия из более поздней
                                                  доставки журнала
5. наборы несравнимы                           → остаётся сохранённая,
                                                  счётчик + WARN
```

**Содержание сравнивается множествами ключей и формой их значений — но не
значениями.** Сравнение полноты, принятое для точек, здесь неприменимо: оно
гасит отношение включения, когда значения общих содержательных ключей
разошлись, а у сущности они расходятся **всегда** — источник её досчитывает.
Проверено: сохранённая тренировка с маршрутом против приехавшей без маршрута
даёт «надмножество» при неизменных значениях и «равенство» при изменившихся, то
есть на живых данных защита не сработала бы вовсе, а тест на фикстуре с
неизменёнными значениями остался бы зелёным. Условия «значения общих ключей
совпали» здесь быть MUST NOT.

Покрытие SHALL проверяться четырьмя условиями, все — по верхнему уровню
содержимого:

1. каждый ключ сохранённой **с непустым значением** есть у приехавшей и тоже
   непуст;
2. **при равенстве множеств содержательных ключей** — каждый ключ сохранённой,
   включая пустые, есть у приехавшей. Тот же второй разряд записан для точек, и
   с тем же условием: иначе ключ с пустым значением исчезает по жребию
   тай-брейка. Безусловным он быть MUST NOT — проверено оракулом: версия с
   пустым ключом и без маршрута оказывалась несравнимой с законным досчётом, у
   которого маршрут приехал, а этого ключа нет, и маршрут не доезжал НИКОГДА;
3. форма значения не вырождается: где у сохранённой объект, у приехавшей MUST
   быть объект; где массив — массив. Версия, подменившая объект или массив
   скаляром, покрывающей быть MUST NOT — иначе «скелет» из скаляров и
   `null`-ов той же длины признаётся равным настоящей тренировке и выигрывает
   тай-брейк журнала;
4. верхнеуровневый массив не теряет ни длины, ни **содержательных элементов**:
   усечённый маршрут (три точки вместо 593) ключа не теряет, а маршрут из
   `[null,null,null]` не теряет и длины — притом что маршрут это 95%
   содержимого тренировки. Досчёт ряды удлиняет, поэтому и укорачивание, и
   опустошение элементов — законные признаки «приехало меньше».

Содержательность элемента ряда SHALL определяться **той же пустотой**, что и
содержательность поля точки: `null`, пустая строка, ноль в любой записи, пустой
объект, пустой массив; `false` содержателен. Второй словарь пустоты в проекте
завёл бы два ответа на один вопрос. Цена этого выбора называется вслух: ряд из
настоящих нулей (`[0,0,0]`) считается лишённым содержания, поэтому версия с
таким рядом сохранённую не заместит. Ошибка направлена в безопасную сторону —
правило удерживает, а не затирает, — и событие видно счётчиком; наблюдённые ряды
HAE состоят из объектов, а не из чисел.

Условия 3 и 4 применяются к ключам, содержательным у сохранённой версии.
Ключ, содержания не несущий, проверяется только на присутствие (условие 2):
формы у пустоты нет, и требовать её сохранения означало бы отличать `[]` от `0`
там, где ни то, ни другое ничего не несёт.

Предел правила называется вслух и не закрывается: сокращение **внутри**
элемента ряда (точка маршрута без `altitude` при непустом элементе и той же
длине) не ловится ничем, кроме сверки с телом в архиве.

Содержимое сущности, не разбирающееся как объект JSON, SHALL давать пустые
множества ключей — то же правило, что для точки: такая версия проигрывает любой
версии с содержанием и не загрязняет наблюдение о несравнимых наборах.

Единственная причина повторной присылки — доезжающий маршрут, то есть рост:
обратного за 44 доставленные копии не случилось ни разу. Но восстановление
требует пересборки всего журнала, поэтому событие делается наблюдаемым, а не
необратимым.

**Тай-брейк при равных наборах — позиция доставки в журнале `(received_at, id)`,
а не порядок свёртки.** Порядок свёртки приведён к порядку журнала требованием
capability приёма, но равенство это неполное: строка учёта становится видимой
воркеру только после записи тела, и при конкурентном приёме остаётся окно, в
котором доставка с более ранней меткой сворачивается позже. Она вернула бы
витрину к недосчитанной версии, и пересборка разошлась бы с живым приёмом молча,
в содержимом тренировки. Хранимая позиция журнала снимает это целиком: исход
зависит от журнала, а не от того, кто раньше добрался до базы, — то есть у
сущности гарантия строго сильнее, чем у точки, и держится она на колонке
провенанса, которой у точки нет.

Ровно поэтому **провенанс сущности SHALL обновляться и тогда, когда хеш
совпал**: сохранённая позиция журнала участвует в тай-брейке пункта 4, и если
в ней осталась первая свёрнутая копия вместо победителя журнала, отложенная
доставка вернёт витрину к прежнему содержимому — то есть живая витрина
разойдётся с пересборкой. Обновление MUST касаться **только** провенанса;
содержимое при совпавшем хеше не переписывается, счётчик записанных сущностей
не растёт (он считает содержимое витрины, и его сравнимость с прежними замерами
важнее учёта обновления), и метка изменения содержимого не двигается тоже:
иначе она стала бы меткой касания строки и дребезжала бы двадцать шесть раз на
неизменившейся тренировке, а потребитель запроса «что изменилось с момента X»
получил бы шум, неотличимый от настоящего досчёта. Провенанс несёт собственную
метку — времени приёма своей доставки, — и для тай-брейка её достаточно.

Обновление провенанса SHALL быть идемпотентным: равные позиции журнала (та же
доставка, свёрнутая повторно) ничего не меняют.

Слово «провенанс» у сущности и у часового объекта означает **разное**, и это
называется вслух: у объекта хранится доставка, **создавшая** его, и она не
поднимается никогда; у сущности — доставка, **чья версия лежит сейчас**, и она
поднимается до максимума по журналу среди версий с этим содержимым. Причина в
том, что у объекта нет замещения версии целиком, а у сущности только оно и есть.

Чтение сохранённой версии, сравнение и запись результата SHALL идти **одной
транзакцией**: хеш и провенанс, на которых держится весь тай-брейк, читаются
там же, где пишется исход. Оптимистичное чтение до транзакции допустимо только
с перепроверкой обоих внутри — иначе две конкурентные свёртки одной сущности
прочитают одну и ту же старую позицию, обе решат «я позже», и победит та, что
закоммитила последней: исход снова станет функцией порядка коммитов, а не
журнала, причём молча.

Отличие от точки — в **механизме**, а не в намерении, и критерий выбора между
ними записан здесь, чтобы третья единица хранения не открывала спор заново. Обе
предпочитают позднюю версию: у сущности — по хранимой позиции журнала, у точки —
по происхождению кандидата (пришла доставкой или лежала в объекте). Различает их
одно: у сущности есть колонка провенанса, у точки её нет и рамка решения
владельца заводить её запретила. Отсюда и разная сила гарантии, названная выше.
Порядок канонических форм у обеих остался тем же и там же — тай-брейком
**внутри одной доставки**, где провенанс общий и различать нечем. Тай-брейк по
канонической форме между доставками заморозил бы тренировку на произвольной из
версий навсегда, вместе с недосчитанной энергией, а у точки — систематически
хранил бы меньшее значение (находка 49).

Версии одного ключа **внутри одной доставки** позициями не различаются, и
победитель среди них SHALL быть **функцией множества версий, а не порядка
элементов массива**: сперва отбрасываются строго покрытые кем-то из остальных,
среди оставшихся берётся минимум канонической формы. «Строго покрыта» означает
«покрыта другой версией и сама её не покрывает»: покрытие — предпорядок, две
версии могут покрывать друг друга взаимно, и отбрасывание всего покрытого
опустошило бы множество, потеряв обе. Порядок при этом обязан быть **тотальным
до конца**: при совпавших канонических формах решает минимум исходных байтов —
иначе победителем оказывается тот, кто стоял в массиве раньше, а порядок ключей
в JSON от HAE нестабилен, и в хранилище легли бы разные байты при одинаковом
содержимом. Попарная свёртка здесь
неверна ровно так же, как она была неверна для точек: покрытие — частичный
порядок, тай-брейк — тотальный, и вместе они дают нетранзитивное отношение
победы, при котором `[A,B,C]` и `[B,C,A]` дают разных победителей, а порядок
элементов в JSON-массиве нестабилен. Сворачиваться между собой такие версии
SHALL до сравнения с сохранённой.

Факт «в одном теле приехали две версии одного ключа с разным содержанием» SHALL
считаться **симметрично** и тоже быть функцией множества: считаются кандидаты,
чья каноническая форма отличается от формы победителя. Счётчик этот SHALL быть
ОТДЕЛЬНЫМ от счётчика удержаний: две версии в одном теле содержания не теряют —
победитель ложится в витрину целиком, — и одно число на два события отвечало бы
ни на одно. На счётчик удержаний опирается единственный контроль того, что
правило покрытия не стало слишком строгим; примесь делает его неотличимым от
шума.

Версии с совпавшей канонической формой SHALL схлопываться ДО выбора победителя.
Выбор квадратичен по числу кандидатов, а их число приходит из чужого тела; без
схлопывания тело в пределах приёма занимает свёртку на часы. Отбор SHALL видеть
отмену: иначе дедлайн свёртки, заведённый ровно против зависшей работы, не
значит ничего. Побайтовое различие при
совпавшей канонической форме событием MUST NOT считаться — порядок ключей в
JSON от HAE нестабилен и дребезг последнего разряда double тоже, так что
счётчик по байтам срабатывал бы на измеренной норме потока. Различие
**содержимого** при совпадающих множествах ключей и длинах массивов считаться
SHALL: сегодня ровно этот случай даёт ноль и молчащий счётчик.

Поля версий MUST NOT объединяться: несравнимые наборы (приехавшая принесла
новые ключи и потеряла старые) разрешаются в пользу сохранённой и считаются
тем же счётчиком. Объединение отвергнуто там же и по той же причине, что для
точек: на живом потоке событие не наступало, и вместо реализации заведено
наблюдение.

Исход SHALL быть функцией журнала в его порядке. Остаточный предел называется
вслух: сравнение сохранённой с приехавшей попарно — в витрине лежит победитель
прошлых слияний, а не все кандидаты истории, — поэтому при несравнимых наборах
(пункт 5) исход зависит от порядка проигрывания. Тот же предел есть у часового
объекта; пункты 1–4 от порядка свёртки не зависят, а пункт 5 сопровождается
счётчиком и `WARN`.

#### Scenario: Доехавший маршрут замещает тренировку без маршрута

- **WHEN** та же тренировка приезжает повторно, добавив `route`
- **THEN** в хранилище лежит версия с маршрутом

#### Scenario: Досчитанные значения при том же наборе полей побеждают

- **WHEN** та же тренировка приезжает повторно с тем же набором полей и
  изменившимися значениями, доставкой с более поздней позицией журнала
- **THEN** в хранилище лежит приехавшая версия

#### Scenario: Версия из более ранней доставки не откатывает витрину

- **WHEN** две доставки несут одну тренировку с равными наборами полей, и
  свёрнута сперва более поздняя по журналу, затем более ранняя
- **THEN** в хранилище лежит версия из более поздней доставки
- **AND** тот же исход даёт свёртка в обратном порядке

#### Scenario: Обеднённая версия сохранённую не затирает

- **WHEN** та же тренировка приезжает повторно **без** `route`, который был у
  сохранённой, **и** с изменившимися значениями общих полей
- **THEN** в хранилище остаётся сохранённая версия
- **AND** факт учитывается счётчиком и записью `WARN` с идентификатором
  тренировки

#### Scenario: Усечённый маршрут сохранённый не затирает

- **WHEN** та же тренировка приезжает повторно с тем же набором полей, но
  `route` короче сохранённого
- **THEN** в хранилище остаётся сохранённая версия
- **AND** факт учитывается тем же счётчиком

#### Scenario: Маршрут из пустых элементов сохранённый не затирает

- **WHEN** та же тренировка приезжает повторно с `route` той же длины, все
  элементы которого пусты (`null` либо пустой объект)
- **THEN** в хранилище остаётся сохранённая версия с координатами маршрута
- **AND** факт учитывается тем же счётчиком

#### Scenario: Скелет из скаляров сохранённую тренировку не затирает

- **WHEN** та же тренировка приезжает повторно, где каждый вложенный объект
  заменён числом, а каждый массив — массивом той же длины из `null`
- **THEN** в хранилище остаётся сохранённая версия
- **AND** факт учитывается тем же счётчиком

#### Scenario: Ключ с пустым значением не исчезает по жребию

- **WHEN** та же тренировка приезжает повторно без ключа, значение которого у
  сохранённой было пустым, при совпадающих содержательных ключах
- **THEN** в хранилище остаётся сохранённая версия
- **AND** факт учитывается тем же счётчиком удержаний

#### Scenario: Пустой ключ не запирает законный досчёт

- **WHEN** у сохранённой версии есть ключ с пустым значением, а приехавшая его
  не несёт, но приносит содержательный ключ, которого у сохранённой не было
- **THEN** приехавшая замещает сохранённую
- **AND** счётчик удержаний не растёт

#### Scenario: Две версии одной сущности в одном теле

- **WHEN** тело содержит два элемента секции с одним `id`
- **THEN** исход не зависит от их порядка в массиве
- **AND** счётчик различающихся версий тоже не зависит от их порядка

#### Scenario: Три версии одной сущности в одном теле

- **WHEN** тело содержит три элемента секции с одним `id`, из которых один
  покрывает второй, а третий несравним с обоими
- **THEN** победитель одинаков при любой перестановке этих трёх элементов

#### Scenario: Две версии разного содержания при равной длине массивов

- **WHEN** тело содержит два элемента секции с одним `id`, содержимое которых
  различается, но множества ключей и длины верхнеуровневых массивов совпадают
- **THEN** факт учитывается счётчиком различающихся версий

#### Scenario: Разные байты при совпавшей канонической форме событием не считаются

- **WHEN** тело содержит два элемента секции с одним `id`, различающихся только
  порядком ключей либо записью числа
- **THEN** счётчик различающихся версий не растёт
- **AND** в хранилище лежат одни и те же байты при любой перестановке элементов

#### Scenario: Повторная присылка обновляет провенанс

- **WHEN** та же сущность приезжает повторно с тем же содержимым доставкой,
  стоящей в журнале позже сохранённой
- **THEN** содержимое не переписывается
- **AND** провенанс сущности указывает на более позднюю доставку

#### Scenario: Отложенная доставка не возвращает витрину к прежнему содержимому

- **WHEN** журнал несёт содержимое A, затем B, затем снова A, и доставка с B
  свёрнута последней
- **THEN** содержимое сущности и отпечаток витрины совпадают со свёрткой того
  же журнала в его порядке

#### Scenario: Составной ключ не даёт коллизии отпечатка

- **WHEN** две витрины различаются только тем, где проходит граница между родом
  и идентификатором записи
- **THEN** отпечатки не совпадают

#### Scenario: Несравнимые наборы полей не объединяются

- **WHEN** приехавшая версия несёт содержательный ключ, которого нет у
  сохранённой, и теряет содержательный ключ, который у сохранённой есть
- **THEN** в хранилище остаётся сохранённая версия
- **AND** факт учитывается тем же счётчиком

#### Scenario: Повторная свёртка того же журнала состояния не меняет

- **WHEN** те же доставки сворачиваются повторно в том же порядке
- **THEN** содержимое сущностей не меняется

### Requirement: Отпечаток витрины покрывает все её сущности

Отпечаток витрины SHALL включать тренировки и записи наравне с часовыми
объектами: он единственный оракул сходимости пересборки, и отпечаток одних
объектов давал бы «состояние сошлось» при разъехавшихся тренировках — то есть
ломался бы молча ровно тем изменением, которое добавляет данные.

В отпечаток идут координаты сущности и хеш её содержимого. Значений он
раскрывать MUST NOT — как и для точек.

Порядок обхода SHALL быть детерминированным и заданным запросом, а не порядком
строк в файле базы. Строки разных разделов SHALL различаться константным
признаком раздела: без него строка одного раздела может совпасть со строкой
другого, и два разных состояния дали бы один отпечаток. По той же причине
составной ключ SHALL идти в отпечаток **отдельными полями с собственными
длинами**, а не склейкой: склейка выполняется до взятия длины, и пара
(`a`, `b/c`) даёт ту же строку, что (`a/b`, `c`).

Границу оракула стоит назвать вслух: в отпечаток идут координаты и хеш
содержимого, а **колонки заголовка** сущности (имя, конец интервала, офсет,
длительность) — нет. Они производны от содержимого, поэтому их расхождение
означает изменившийся код извлечения заголовка, а не разъехавшееся состояние; но
«отпечатки совпали» не является утверждением о них.

Все разделы SHALL читаться **одним снимком** базы. Отпечаток рабочей витрины
снимается под живым приёмом, и запросы вне общей транзакции чтения дали бы смесь
«объекты до» и «тренировки после» — то есть ложное расхождение у единственного
оракула сходимости.

#### Scenario: Расхождение тренировок видно в отпечатке

- **WHEN** две витрины совпадают по часовым объектам, но содержимое одной
  тренировки различается
- **THEN** отпечатки не совпадают

#### Scenario: Отпечаток одинаков при одинаковом содержимом

- **WHEN** та же витрина собрана повторно из того же журнала
- **THEN** отпечаток совпадает

#### Scenario: Запись во время снятия отпечатка не смешивает разделы

- **WHEN** отпечаток снимается, а параллельно коммитится свёртка
- **THEN** отпечаток отражает одно состояние базы, а не смесь снимков

### Requirement: Открытие базы отказывает при схеме из будущего

Открытие витрины SHALL сверять версию схемы базы с версией, вшитой в бинарь, до
наката миграций. Версия базы **выше** версии бинаря MUST быть отказом с
указанием обеих, а не поводом мигрировать: прецедент уже записан для открытия
только на чтение — «расхождение версий — отказ, а не повод мигрировать».

Без этого откат бинаря проходит молча: старый бинарь поверх новой схемы
стартует успешно, незнакомые секции игнорирует и доставки за окно отката
помечает разобранными — то есть ничто не намекает, что для этого окна нужна
пересборка. Класс «молчание», и цена его растёт вместе с ретеншеном: после
удаления тел окно становится невосстановимым.

Асимметрия относится **только к открытию с накатом миграций**: там версия базы
ниже версии бинаря отказом быть MUST NOT — ради этого случая миграции и
существуют. Открытие **только на чтение** сохраняет строгое равенство версий,
как уже нормировано пересборкой: утилита, которой достаточно прочитать учёт, на
базе старее бинаря читала бы колонки, которых там ещё нет. Ослабление этого
отказа настоящим требованием запрещено.

В одно место SHALL выноситься **чтение** версии, а не сравнение: сравнивают эти
два способа открытия по-разному, а читают одинаково. Отсутствие журнала
миграций (новая база) SHALL означать версию 0, и распознаваться это MUST по
структуре базы, а не по тексту ошибки драйвера — сообщения драйвера контрактом
не являются, и это уже записанное правило проекта.

Читать версию система SHALL средствами того же инструмента миграций, которым их
накатывает, если он это умеет: имя таблицы учёта, имя колонки и правило
«максимум = текущая версия» принадлежат ему, и рукописная копия его приватной
схемы разошлась бы при обновлении зависимости — причём не отказом, а тем, что
страж перестал бы ловить. Если цена такого чтения неприемлема (например, оно
требует записи на соединении только для чтения), копия допустима, но SHALL жить
одной функцией с названной вслух причиной.

Эксплуатационная цена отказа называется вслух, потому что она реальна: сервис не
поднимется, а телефон шлёт непрерывно и молча, и доставка, не попавшая в архив,
в журнал не попадает вовсе. Выбор сделан так потому, что откат бинаря — действие
оператора, который в этот момент рядом и видит отказ сразу, а дыры плотных
метрик закрывают широкий и глубокий проходы синхронизации. Не закрывается ими
`stateOfMind`: у него доставки HAE единственный источник, и окно простоя для
него — потеря без возврата. Молчаливый старт при этом стоит дороже: он портит
витрину за всё окно отката, и узнать об этом неоткуда.

#### Scenario: Старый бинарь не открывает базу из будущего

- **WHEN** в журнале миграций базы стоит версия выше последней, вшитой в бинарь
- **THEN** открытие завершается отказом с указанием обеих версий
- **AND** миграции не накатываются

#### Scenario: Новая база открывается и мигрирует

- **WHEN** базы ещё нет либо журнал миграций пуст
- **THEN** открытие проходит и накатывает миграции до версии бинаря

#### Scenario: Открытие только на чтение остаётся строгим

- **WHEN** версия схемы базы ниже последней, вшитой в бинарь, и база
  открывается только на чтение
- **THEN** открытие завершается отказом с указанием обеих версий

### Requirement: Пропущенные сущности видны в учётной записи доставки

Учётная запись доставки SHALL нести число сущностей, которые разбор пропустил:
без `id`, с непомерно длинным `id`, с неразбираемой меткой времени или не
разобравшихся как объект.

Причина не в отчётности. Ретеншен сырого архива решает «что потеряется, если
тело удалить», **по базе**, и сегодня получает ответ «терять нечего» ровно там,
где потеряна тренировка с маршрутом: сущность в витрину не попала, список
непокрытых секций пуст, статус `parsed`. Лог здесь не годится — он ротируется,
а решение об удалении тела необратимо.

Число SHALL замещаться целиком при каждой свёртке доставки, включая замещение
нулём: иначе доставка, пропуски которой исчезли вместе с поумневшим разбором,
осталась бы помеченной навсегда. Записываться оно SHALL в обоих исходах свёртки
— и при успехе, и при отказе, если разбор успел досчитать, — тем же правилом,
каким уже записывается список непокрытых секций.

**«Не измерялось» SHALL быть отличимо от нуля, и на пути отказа тоже.** Разбор,
вернувший ошибку, отдаёт нулевые счётчики по построению, а не по измерению;
записать этот ноль значило бы объявить проверенной доставку, содержимое которой
никто не смотрел. Число SHALL записываться только когда разбор досчитал; во всех
прочих исходах колонка MUST оставаться нетронутой — той же идиомой, какой уже
сохраняется выведенный слой. Доставки, свёрнутые разбором,
который пропусков не считал, значения не имеют, и подстановка нуля объявила бы
их проверенными: ретеншен получил бы то самое ложное «терять нечего», ради
которого счётчик и заводится, — только теперь с видом измерения. Поэтому
колонка допускает отсутствие значения, миграция его не подставляет, а читатель,
принимающий по счётчику необратимое решение, SHALL трактовать отсутствие как
«не удалять». Замер на живом архиве (118 тел) даёт ноль пропусков всех классов,
то есть исторический корпус ничего не потерял, — но «ничего не потерял по
замеру» и «проверено этим разбором» это разные утверждения, и колонка обязана
их различать.

Счётчик — производное от разбора поле: пересборка витрины SHALL начинать его
пустым и переносить из журнала MUST NOT, иначе свежая витрина унаследует
измерение прежнего разбора.

Статус разбора от пропуска сущности меняться MUST NOT: `partial` определён
списком непокрытых секций, и второй источник истины для него завёл бы ровно то
расхождение читателей, которое учёт частичного разбора запрещает явно.

#### Scenario: Пропущенная сущность видна в учёте доставки

- **WHEN** тело несёт покрытую секцию, один элемент которой не разобрался
- **THEN** число пропущенных сущностей у доставки больше нуля
- **AND** соседние сущности той же секции сохранены

#### Scenario: Пересвёртка без пропусков обнуляет счётчик

- **WHEN** доставка с ненулевым числом пропущенных сущностей сворачивается
  повторно разбором, который эти элементы понимает
- **THEN** число пропущенных сущностей у доставки равно нулю

#### Scenario: Доставка, свёрнутая до появления счётчика, отличима от нулевой

- **WHEN** доставка была свёрнута разбором, который пропусков не считал, и с тех
  пор не пересворачивалась
- **THEN** её число пропущенных сущностей отсутствует, а не равно нулю

### Requirement: Перечисление разрезов не читает содержимое объектов

Хранилище SHALL отвечать на вопрос «какие слои есть у метрики, за какой период и
сколько в них точек» по учётным колонкам объекта, не разжимая `payload` и не
затрагивая страниц с содержимым. Тот же запрет действует на поиск часов, за
которые у метрики есть объекты сразу в двух слоях.

Запрет ограничен именно этими двумя выборками. Измерение рода обязано прочитать
значения точек, то есть разжать содержимое объектов окна, и требование его не
касается — иначе оно запрещало бы то, ради чего каталог существует.

Причина в форме таблицы: `bucket` объявлена `WITHOUT ROWID`, то есть строка
целиком, вместе со сжатым содержимым, живёт в дереве первичного ключа. Обход
всех строк ради агрегата тащил бы за собой страницы содержимого — при 260 тысячах
объектов за год это сотни мегабайт на каждый запрос каталога, притом что сам
ответ несёт три десятка строк.

Поэтому колонки, по которым отвечают эти выборки, MUST быть покрыты индексом, и
новая колонка, попадающая в ответ каталога, входит в него тем же изменением.

#### Scenario: Разрезы метрики за длинную историю

- **GIVEN** в витрине объекты за многие месяцы
- **WHEN** запрашиваются слои метрики с границами и числом точек
- **THEN** запрос отвечает по индексу, не читая содержимого объектов

#### Scenario: Общие часы двух слоёв

- **WHEN** запрашиваются самые свежие часы, за которые у метрики есть объекты и
  в минутном, и в часовом слое
- **THEN** запрос отвечает по индексу и читает не больше запрошенного числа
  часов

### Requirement: Объекты перечисленных часов читаются пакетом

Хранилище SHALL уметь отдать объекты двух слоёв за перечисленные часы одной
метрики **одним запросом**, а не по объекту за раз.

Чтение по одному даёт число обращений, растущее вместе с окном измерения, и
делает каждое обращение собственной транзакцией — то есть ответ, собранный из
разных снимков витрины под непрерывным приёмом.

#### Scenario: Окно из многих часов

- **GIVEN** запрошены объекты двух слоёв за сорок восемь часов
- **WHEN** выполняется выборка
- **THEN** число обращений к базе не зависит от числа часов

### Requirement: Хранилище отдаёт версию витрины

Система SHALL отдавать **версию витрины** — метку, которая MUST меняться при
любом коммите в базу и MUST NOT меняться, пока коммитов не было. Метка
предназначена условному запросу читающих маршрутов: равные метки означают, что
между их снятием в базу никто ничего не записал.

Метка MUST быть парой «поколение + счётчик». Счётчик — `PRAGMA data_version`,
поколение — идентификатор, выданный тому соединению, с которого счётчик
читается. Монотонной метка не является и сравнению на «новее» не подлежит:
гарантируется только неравенство.

Обе части обязательны, и каждая закрывает измеренный отказ:

- **счётчик несравним между соединениями.** На одном и том же состоянии базы
  два соединения одного пула отвечают разными числами, а любое свежее
  соединение отвечает одним и тем же значением независимо от содержимого базы.
  Поэтому счётчик MUST читаться с одного закреплённого соединения, которое
  ничем другим не занято: собственная запись соединения его версию не двигает,
  и щуп, участвующий в записи, молчал бы о собственных изменениях.
- **счётчик не переживает переоткрытия.** После рестарта он начинается заново,
  поэтому одно и то же значение до и после означает разные состояния витрины.
  Без поколения клиент со старой меткой получал бы «не изменилось» на
  изменившиеся данные — единственный по-настоящему опасный исход условного
  запроса.

Соединение-щуп MUST NOT удерживать открытую читающую транзакцию между снятиями
версии: каждое снятие завершается до возврата. Иначе щуп — единственное
долгоживущее соединение процесса — становится тем самым вечным читателем,
против которого заведён чекпойнт, и версия витрины отменяет обслуживание
журнала при полностью исправном обслуживании.

Поколение MUST меняться всякий раз, когда соединение-щуп создаётся заново.
Переиспользовать поколение MUST NOT: это ровно тот случай, ради которого оно
заведено.

**Непригодность щупа — узкий класс, а не любая ошибка.** Пересоздание
допускается только при отказе, означающем закрытое соединение; отмена запроса
клиентом, занятость базы и прочие обстоятельства (то, что проект уже отличает
предикатом «не сделано» против «не выходит») поколение менять MUST NOT.
Измерено: отмена контекста запроса щуп не убивает — следующий запрос на нём
проходит. Считай система смертью щупа любую ошибку, каждый оборванный клиентом
запрос обнулял бы метки всех потребителей, то есть механизм схлопывался бы под
той самой нагрузкой, ради которой заведён.

Закрытие хранилища MUST освобождать щуп раньше пула и MUST исключать его
пересоздание после закрытия. Закреплённое соединение переживает закрытие пула
(измерено), а SQLite делает финальный чекпойнт только при закрытии последнего
соединения: забытый щуп оставляет рядом с базой неразобранный `-wal`, и
пересборка, переносящая один файл базы, теряет хвост записей молча.

#### Scenario: Витрина не менялась

- **GIVEN** после первого запроса версии в базу никто не писал
- **WHEN** версия запрашивается второй раз
- **THEN** обе версии совпадают

#### Scenario: Свёртка записала объект

- **GIVEN** версия витрины снята
- **WHEN** фоновая свёртка закоммитила изменения в витрину
- **THEN** следующая снятая версия отличается от прежней

#### Scenario: База переоткрыта

- **GIVEN** версия витрины снята, база закрыта и открыта заново
- **WHEN** версия снимается снова на том же файле
- **THEN** она отличается от снятой до переоткрытия

#### Scenario: Соединение-щуп стало непригодным

- **GIVEN** соединение, с которого читается счётчик, закрыто
- **WHEN** версия запрашивается снова
- **THEN** запрос отвечает версией НОВОГО поколения, а не отказом

#### Scenario: Запрос версии оборван клиентом

- **GIVEN** запрос версии отменён контекстом
- **WHEN** версия запрашивается следующим запросом
- **THEN** поколение остаётся прежним

#### Scenario: Щуп не мешает разбирать журнал

- **GIVEN** версия снималась много раз подряд
- **WHEN** выполняется пассивный чекпойнт и других читателей нет
- **THEN** журнал перенесён целиком

#### Scenario: Хранилище закрыто

- **GIVEN** хранилище закрыто
- **WHEN** запрашивается версия витрины
- **THEN** запрос отказывает и нового соединения к базе не открывает, а рядом с
  базой не остаётся файла журнала

### Requirement: Чтение подписывается версией только целиком

Система SHALL снимать версию витрины **до и после** чтения, которое ею
подписывается, и SHALL отдавать версию, только если обе пробы совпали. При
расхождении версии нет, и это не отказ: ответ уходит полным, просто без метки.

Версия, снятая ПОСЛЕ чтения, MUST NOT выставляться на его результате: она
пометила бы устаревший снимок свежим номером и заперла бы клиента на нём
навсегда — единственный по-настоящему опасный исход всей конструкции. Версия,
снятая только ДО, допускает два разных ответа под одной меткой.

Правило MUST существовать в одном экземпляре: Read API точек и MCP заявлены
потребителями той же машинерии, и вторая её реализация «по образцу»
отличалась бы от первой ровно на этот порядок — а тест первой этого не
увидел бы.

Отказ пробы версией не является и чтение не отменяет: маршрут деградирует до
полного ответа, а не до отказа.

#### Scenario: Витрина стояла всё время чтения

- **WHEN** чтение выполнено и обе пробы дали одну версию
- **THEN** версия отдана

#### Scenario: Витрина изменилась во время чтения

- **GIVEN** между пробами в базу закоммитили
- **THEN** версии нет, а результат чтения отдан целиком

#### Scenario: Само чтение отказало

- **WHEN** чтение вернуло ошибку
- **THEN** ошибка отдана вызывающему, а не подменена отсутствием версии

### Requirement: Журнал WAL разбирается по таймеру, и его непродвижение видно

Система SHALL выполнять `PRAGMA wal_checkpoint(PASSIVE)` **раз в минуту**, пока
сервис работает. Автоматический чекпойнт SQLite MUST NOT считаться достаточным:
он срабатывает по концу записи, а поток пачечный — журнал, раздутый всплеском,
иначе остаётся неразобранным до следующей доставки, и ночью это часы.

Режим MUST быть `PASSIVE`. `TRUNCATE` и `RESTART` применять MUST NOT: они
двигают счётчик версии витрины, то есть каждый тик обнулял бы условный запрос у
всех потребителей, а `TRUNCATE` вдобавок ждёт читателей.

Пассивный чекпойнт не идёт дальше снимка самого старого активного читателя и
**ошибки при этом не возвращает**: измерено `busy=0` при 6256 страницах в
журнале и 5 перенесённых. Поэтому система SHALL считать признаком беды пару
чисел — страниц в журнале больше **16384** (64 МиБ при странице в 4 КиБ) **и**
перенесено меньше, чем лежало, — и MUST сообщать об этом владельцу уровнем
`WARN`. Флаг занятости признаком «не продвинулись» служить MUST NOT: он молчит
ровно в измеренном случае удерживаемого читателя.

**Зато флаг занятости выражает другое, и это MUST читаться: исход не измерен.**
Не взяв блокировку чекпойнта, SQLite отдаёт `busy=1` и **`-1` вместо обоих
чисел** — измерено, 1492 таких тика из 5502 при писателе и чекпойнте в цикле.
Сравнивать `-1` на шкале страниц MUST NOT: `-1 >= -1` истинно, то есть
незамеренный тик читался бы как «журнал разобран целиком», владельцу уходила бы
строка о выздоровлении посреди болезни, а подавитель повторов сбрасывался бы —
и вместо задуманного молчания получалась бы пара строк в минуту. Незамеренный
исход MUST не менять ни объявленного состояния, ни накопленного о нём.

Размер страницы MUST браться у самой базы, а не предполагаться: он фиксируется
при создании файла, и база, созданная чужим инструментом, сместила бы порог в
разы. Неизвестен — признак молчит.

Порог MUST быть выражен через ту же величину, что и предел файла журнала: это
одно число в двух ролях (предел возвращает файл, порог сообщает, что вернуть
его не выходит), и двумя разошедшимися константами признак стал бы либо
недостижимым, либо шумным — молча.

**Строка о непродвижении не пишется на каждый тик.** Признак заведён ради
состояния, которое само не проходит (вечный читатель живёт до конца процесса), а
строка в минуту дала бы 1440 одинаковых записей в сутки. Система SHALL сообщать
о входе в состояние и повторять, только когда журнал заметно вырос; возврат к
норме MUST быть отдельным событием — молчание иначе неотличимо от «сервис
перестал проверять».

Отказ чекпойнта MUST NOT прекращать цикл и MUST быть виден записью лога:
обслуживание, умершее от временного отказа базы, молча перестало бы разбирать
журнал до конца жизни процесса. Отмена контекста отказом при этом не является.

Файл журнала MUST иметь названный предел (`journal_size_limit`, 64 МиБ).
Предел **роста этим не даётся, и это сказано вслух**: измерено — под
удерживаемым читателем файл вырос до 51 МБ при пределе 8 МиБ, и успешный
чекпойнт его не укоротил; усечение делает первая запись после полного
чекпойнта. Пока читатель держит снимок, журнал растёт, и единственный исход —
`WARN` владельцу.

#### Scenario: Журнал разбирается в тишине

- **GIVEN** доставок нет, а в журнале остались неразобранные страницы
- **WHEN** проходит период чекпойнта
- **THEN** страницы перенесены в базу без единой новой записи

#### Scenario: Читатель держит снимок

- **GIVEN** идёт запись, и читающая транзакция удерживает старый снимок
- **WHEN** выполняется пассивный чекпойнт
- **THEN** он завершается без ошибки, переносит меньше, чем лежит в журнале, и
  флаг занятости остаётся снятым

#### Scenario: Чекпойнт не взял блокировку

- **GIVEN** о непродвижении журнала уже сказано
- **WHEN** очередной чекпойнт возвращает признак занятости и `-1` вместо чисел
- **THEN** ни строки о выздоровлении, ни строки о беде не пишется, а
  накопленное состояние не меняется

#### Scenario: Журнал невелик

- **GIVEN** в журнале меньше страниц, чем названный порог
- **WHEN** чекпойнт не смог перенести всё
- **THEN** строка `WARN` не пишется

#### Scenario: Состояние держится

- **GIVEN** о непродвижении журнала уже сказано
- **WHEN** следующий чекпойнт застаёт журнал того же размера
- **THEN** строка не повторяется

#### Scenario: Журнал разобрался

- **GIVEN** о непродвижении журнала было сказано
- **WHEN** очередной чекпойнт переносит журнал ЦЕЛИКОМ
- **THEN** о возврате к норме сказано один раз

#### Scenario: Журнал стал мал, но не перенесён

- **GIVEN** о непродвижении журнала было сказано
- **WHEN** очередной чекпойнт переносит не всё, а журнал при этом ниже порога
- **THEN** о возврате к норме не сообщается: перенос — это то, что проверено, а
  размер ниже порога — нет

#### Scenario: Чекпойнт отказал

- **GIVEN** очередной чекпойнт вернул ошибку
- **WHEN** наступает следующий период
- **THEN** отказ виден строкой лога, а чекпойнт выполняется снова

### Requirement: Обслуживание журнала останавливается дренированием

Система SHALL останавливать периодический чекпойнт осознанно: горутина MUST
получать отмену и MUST быть дождана вместе с воркером свёртки — база
закрывается только после выхода **обеих**. Обрывать её выходом процесса
MUST NOT, а закрывать базу по выходу одной из двух MUST NOT: закрытая из-под
воркера, она даёт `ERROR` по доставке, с которой всё в порядке.

Идущий чекпойнт при отмене прерывается, и терять ему нечего: перенос страниц
идемпотентен, исхода разбора чекпойнт не пишет, а следующий старт берёт журнал с
того же места. Прерывание по отмене отказом MUST NOT считаться.

Исчерпание бюджета остановки MUST называть этап, не утверждая большего, чем
проверено: ждут двоих, и назвать виновным одного из них — догадка.

Отдельного чекпойнта на остановке система выполнять MUST NOT: закрытие
последнего соединения к базе SQLite делает его само. Условие названо в
требовании о версии витрины — щуп обязан быть закрыт раньше пула, иначе
последнего соединения не наступает вовсе.

**Остаток назван вслух: в ветке исчерпанного бюджета база не закрывается, а
значит финального чекпойнта не наступает и рядом с ней остаётся `-wal`.**
Данные при этом целы — следующее открытие проиграет журнал, — но файл базы в
этом состоянии переносить без его `-wal` нельзя. Процедура подмены при
пересборке этого и требует: она удаляет `-wal` старой базы вместе с ней самой.

#### Scenario: Сервис останавливается

- **WHEN** сервис получает сигнал остановки
- **THEN** горутина чекпойнта завершается до закрытия базы

#### Scenario: Чекпойнт идёт в момент остановки

- **GIVEN** чекпойнт выполняется, когда пришла отмена
- **WHEN** он прерывается
- **THEN** отказ не пишется, новый цикл не начинается, и горутина выходит

#### Scenario: Бюджет остановки исчерпан

- **GIVEN** фоновые горутины не вышли в бюджет
- **WHEN** сервис завершается
- **THEN** база не закрывается, а запись лога называет этап, не указывая
  виновной горутины

### Requirement: Реестр наблюдённых категориальных значений

Хранилище SHALL держать реестр категориальных значений, которые приносил поток:
одна строка на тройку `(метрика или секция, поле, значение)`, с выведенным кодом
HealthKit рядом. Значение хранится **дословно**, тем же текстом, каким пришло.

Локаль в ключ MUST NOT входить. Она живёт в заголовке доставки, которого нет в
сыром архиве, — ключ с локалью сделал бы состояние функцией от того, уцелела ли
строка учёта: усыновлённая доставка положила бы вторую строку с пустой локалью.
Язык, на котором приехала строка, восстанавливается по доставке провенанса.

Реестр SHALL нести выведенный код и провенанс **первой** встречи — метку приёма
и идентификатор доставки, в которой строка появилась впервые по порядку журнала.
Первая встреча отвечает на вопрос «когда сменился язык телефона»; счётчик встреч
не заводится вовсе — он не идемпотентен при повторной свёртке той же доставки, а
значит сделал бы состояние зависящим от числа прогонов.

Пустой код — законное состояние строки: он означает «словарь этой строки не
знает», и перечень таких строк есть заявка на пополнение словаря.

Словарь, по которому выводится код, SHALL жить в бинаре, а не в таблице базы и
не в строках миграции. Таблица, наполняемая руками, стала бы входом, которого
нет в журнале, и `import + replay` перестал бы задавать состояние однозначно;
данные, засеянные миграцией, живут в двух местах сразу и расходятся с бинарём
молча после первой же правки словаря.

Строки реестра SHALL писаться **той же транзакцией**, что и точки доставки.
Доставка — единица свёртки; частичное состояние «точки легли, реестр нет»
повторная свёртка не чинит: хеш содержимого сойдётся, и объекты переписываться
не станут.

Обслуживания у реестра нет: строки не удаляются. Строка, единственные доставки
которой выпали из журнала подрезкой архива, переживёт их в рабочей витрине и не
появится в пересобранной. Это тот же класс, что «верхние слои за периоды с
удалёнными доставками», и он MUST называться, а не досчитываться.

#### Scenario: Фаза сна попадает в реестр с кодом

- **WHEN** свёрнута доставка с фазой сна «Во сне» и локалью `ru`
- **THEN** в реестре есть строка `sleep_analysis / value / «Во сне»` с кодом
  `HKCategoryValueSleepAnalysisAsleepUnspecified`
- **AND** содержимое точки в часовом объекте не изменилось

#### Scenario: Та же строка без заголовка локали даёт ту же строку реестра

- **WHEN** та же строка приехала доставкой без `Accept-Language`
- **THEN** второй строки в реестре не появляется

#### Scenario: Строка без кода в реестре видна

- **WHEN** свёрнута доставка с `heart_rate.context`, которого словарь не знает
- **THEN** в реестре есть строка с этим значением и пустым кодом

#### Scenario: Отказ слияния не оставляет строк реестра

- **WHEN** слияние доставки отказало
- **THEN** реестр не содержит значений этой доставки

### Requirement: Реестр детерминирован по журналу

Повторная свёртка той же доставки MUST оставлять реестр без изменений, а
провенанс первой встречи MUST быть **минимумом** по порядку журнала
(`received_at`, затем идентификатор доставки), а не значением последней записи.

Проигрывание журнала в любом порядке доставок, дающем тот же префикс, SHALL
приводить реестр к тому же состоянию. Реестр — свёртка по журналу, как и всё
остальное в витрине.

Выведенный код SHALL обновляться при каждой встрече строки: словарь живёт в
бинаре, и пересборка обязана давать код по текущему словарю, а не по тому,
который действовал при первой свёртке. Строка, переставшая приезжать, держит код
прежнего словаря до пересборки — это осознанная цена материализации, и на
сходимость она не влияет, потому что код в отпечаток не входит.

#### Scenario: Повторная свёртка ничего не меняет

- **WHEN** одна и та же доставка свёрнута дважды
- **THEN** строки реестра и их провенанс совпадают с состоянием после первой
  свёртки

#### Scenario: Провенанс — самая ранняя доставка

- **WHEN** одна строка приехала сперва поздней доставкой, затем ранней
- **THEN** провенансом остаётся ранняя по журналу

#### Scenario: Порядок свёртки на реестр не влияет

- **WHEN** три доставки свёрнуты во всех шести порядках
- **THEN** реестр и его раздел отпечатка совпадают у всех шести прогонов

#### Scenario: Пересборка даёт тот же реестр

- **WHEN** журнал проигран в свежую витрину
- **THEN** реестр пересобранной витрины совпадает с реестром исходной

### Requirement: Отпечаток покрывает наблюдение реестра, но не выведенный код

Отпечаток витрины SHALL покрывать реестр категориальных значений отдельным
разделом: ключ и провенанс первой встречи. Иначе недетерминированная запись
реестра прошла бы мимо единственного оракула сходимости.

Выведенный код в отпечаток входить MUST NOT. Ключ и провенанс — функция журнала;
код — функция журнала **и версии словаря в бинаре**. Включённый в отпечаток, он
заставил бы всякое пополнение словаря давать расхождение при побайтно совпавшем
журнале, а пополнение объявлено рабочим циклом. Человек, принимающий по
отпечатку необратимое решение о подмене базы, читал бы это как дефект.
Правильность вывода кода проверяется тестами словаря — это другой вопрос, и
смешение обесценило бы оракул.

Раздел SHALL быть отличим от прочих признаком впереди строки, а поля переменной
длины SHALL идти с длиной впереди: два разных состояния витрины не имеют права
дать один отпечаток.

Цена названа вслух: у витрины, свёрнутой до этого изменения, реестр пуст, и
первая же сверка отпечатков после выкатки покажет расхождение. Это законное
расхождение, а не дефект; отчёт пересборки обязан назвать его ожидаемым классом
и напечатать счётчик строк реестра.

#### Scenario: Разошедшееся наблюдение меняет отпечаток

- **WHEN** у двух витрин совпадают объекты и сущности, но в реестре одной есть
  строка, которой нет в другой
- **THEN** отпечатки различны

#### Scenario: Расхождение только по коду отпечаток не двигает

- **WHEN** две витрины несут те же строки реестра с тем же провенансом, но
  разными кодами
- **THEN** отпечатки совпадают

#### Scenario: Значение с разделителем границу поля не подделывает

- **WHEN** значение содержит признак раздела, цифры и нулевой байт
- **THEN** отпечаток отличается от отпечатка витрины с другим разбиением тех же
  байтов по полям

#### Scenario: Пустой реестр отпечаток не ломает

- **WHEN** в витрине нет ни одной строки реестра
- **THEN** отпечаток считается и совпадает с отпечатком такой же витрины без
  реестра

### Requirement: Значения категориальных строк не попадают в логи

Сами наблюдённые строки и выведенные коды MUST NOT попадать в записи лога выше
`DEBUG`; в журнал SHALL уходить только счётчики — сколько значений наблюдалось и
сколько осталось без кода. Основание: строки категориальных значений — данные о
здоровье, контекст пульса и фаза сна описывают человека не меньше, чем число.

Проверка отсутствия строки в логе SHALL разбирать запись и сравнивать значения
полей, а не искать подстроку в сыром буфере: служебная метка времени содержит
произвольные цифры, и поиск по буферу делает тест флаки по построению.

#### Scenario: Свёртка доставки со сном не пишет строк в лог

- **WHEN** свёрнута доставка с фазами сна и контекстом пульса
- **THEN** в разобранных записях лога нет ни одной наблюдённой строки и ни
  одного кода
- **AND** счётчики значений без кода в записи присутствуют
### Requirement: Проигрыш пришедшей точки считается отдельно

Итог разбора доставки SHALL нести счётчик координат, где пришедшая точка
проиграла сохранённой, и координаты первых таких объектов — метрику, слой и
час, без значений точек.

Счётчик SHALL быть отдельным от счётчика перезаписей. Перезаписи считают
столкновение в обе стороны (на корпусе 2026-08-04 они сработали бы около 84 000
раз), и отличить по ним «оставили пришедшую» от «выбросили пришедшую» нельзя —
то есть единственное событие, ради наблюдения за которым правило и переписано,
остаётся невидимым.

Под новым правилом пришедшая точка проигрывает ровно тогда, когда сохранённая
**строго полнее**. То есть счётчик меряет одно направление правила полноты — то,
в котором оно спорит с журналом; случай «пришедшая строго полнее» им не
считается, потому что там полнота и журнал согласны. Молчащий счётчик означает,
что правило полноты перестало спорить вовсе, и это событие для разбора, а не
отказ.

Счётчик SHALL быть виден не только в логе свёртки, но и в отчёте пересборки —
рядом с удержанными версиями сущностей и по той же причине: сходимость
отпечатка правило удержания не проверяет по построению, живой приём и пересборка
пользуются одним правилом и одинаково сойдутся на одинаково удержанной точке.

Значений точек счётчик и его координаты содержать MUST NOT — данные о здоровье
чувствительнее токенов.

#### Scenario: Удержание сохранённой точки видно в итоге доставки

- **GIVEN** по координатам сохранена точка, строго более полная, чем пришедшая
- **WHEN** доставка сворачивается
- **THEN** счётчик удержаний растёт
- **AND** координаты объекта попадают в список удержаний
- **AND** счётчик остаётся нулевым, когда побеждает пришедшая точка

### Requirement: Потеря содержания пришедшей точкой считается и не молчит

Система SHALL считать отдельным счётчиком координаты, где победителем оказалась
**пришедшая** точка, а у проигравшей был ключ с непустым значением, которого у
победительницы нет. Координаты таких объектов SHALL попадать в лог, и система
SHALL писать `WARN` — без значений точек.

Это единственное направление, в котором новое правило способно потерять
содержание, и защитить его нечем **по построению**: разряд полноты в этом
случае погашен — значения общих содержательных ключей разошлись, и надмножество
имён о полноте не говорит ничего. До смены тай-брейка тот же исход случался по
жребию байтового порядка и так же молча; разница в том, что теперь он
детерминирован, а значит либо не случается вовсе, либо случается всегда.

Счётчик SHALL быть отдельным и от перезаписей, и от удержаний: перезаписи
считают столкновение в обе стороны, удержания — противоположное направление, и
ни один из них на вопрос «потеряли ли мы содержание» не отвечает.

Запрещать такое слияние система SHALL NOT: измерено — 2 координаты из 80 129
спорных на живом корпусе, и обе те же, что дают несравнимые наборы. Событие
обратимо пересборкой, пока жив архив, поэтому вместо запрета — наблюдение, тем
же решением и по той же причине, по какой отложено объединение полей.

Значений точек ни счётчик, ни координаты, ни запись содержать MUST NOT.

#### Scenario: Пришедшая точка унесла содержательный ключ сохранённой

- **GIVEN** сохранена точка `{date, asleep:7.5, rem:1.2}`
- **WHEN** следующей доставкой приезжает точка `{date, asleep:7.4}`
- **THEN** в витрине остаётся пришедшая точка
- **AND** счётчик потери содержания растёт, а счётчик удержаний — нет
- **AND** система пишет `WARN` с координатами объекта и без значений точек

#### Scenario: Пришедшая точка ничего не унесла

- **WHEN** множества ключей у сохранённой и пришедшей совпадают, а значения
  различаются
- **THEN** счётчик потери содержания не растёт
