change razbor-metrik-v-obekty заархивирован

Дельты влиты в openspec/specs (parsing, storage), задача убрана из беклога,
план отражает сделанную часть шага 3.

Не закрыт один пункт: живая доставка с телефона не разобрана — поток молчит
с 17:13, пауза началась до перезапуска сервиса.
This commit is contained in:
av
2026-08-01 19:03:46 +03:00
parent cd7a4c1493
commit 37413bb551
11 changed files with 487 additions and 41 deletions
@@ -0,0 +1,2 @@
schema: spec-driven
created: 2026-08-01
@@ -0,0 +1,286 @@
## Context
Приём принимает пакеты Health Auto Export и складывает тела в архив
(`internal/ingest`, `internal/archive`). Разбора нет: в SQLite только строки
`delivery`. Накоплено 89 доставок, 16 МБ архива, поток идёт непрерывно.
Правила разбора выведены измерением, а не спроектированы: 46 находок в
`docs/local-research.md`. Половина расходится с документацией HAE, поэтому
источник истины по формату — живые пакеты, а не документация.
Ограничение, определяющее форму решения: **сервис нельзя останавливать**.
Телефон шлёт непрерывно и молча; доставка, не попавшая в архив, не попадает в
журнал вовсе — телефон её не перешлёт. Значит разбор не имеет права уронить
приём.
## Goals / Non-Goals
**Goals:**
- Точки из секции `metrics` попадают в хранилище с правильным слоем.
- Повторные и пересекающиеся доставки не задваивают и не затирают данные.
- Разбор отделён от хранения: импорт родного экспорта Apple будет другим
разбором поверх того же хранилища.
- Ошибка разбора не влияет ни на код ответа приёма, ни на сохранность архива.
**Non-Goals:**
- Тренировки и секции с собственными `id` — другая модель хранения.
- `healthlog reindex` — накопленные 89 доставок доедут отдельной задачей.
- Словарь категориальных значений: строка пока хранится дословно и без кода.
- Род агрегации и каталог разрезов.
- Своя агрегация при записи: слои не сводятся друг к другу никогда.
## Decisions
### Разбор — отдельный пакет `internal/hae`, а не метод `ingest`
`ingest` — use-case приёма: сохранить тело, записать доставку. Разбор формата
живёт своей жизнью: у него будет второй потребитель (`reindex`) и второй
источник (родной экспорт Apple, свой пакет). Втянуть разбор в `ingest` значит
получить пакет, который меняется по двум несвязанным причинам.
Альтернатива — разбор внутри `store`. Отвергнута: `store` не должен знать
формат HAE, иначе импорт из Apple потребует второй реализации хранения.
Граница: `hae.Parse(body []byte, hdr Meta) (Parsed, error)` возвращает точки с
уже выведенным слоем и нормализованным временем. Дальше их принимает `store`,
который о HAE ничего не знает.
**Стратегия декодирования — часть контракта, а не деталь.** Конверт
разбирается в структуру с `Data []json.RawMessage` на метрику; точка
декодируется по одной и сразу отбрасывается. Измерено: разбор тела 42 МиБ в
`map[string]any` удерживает 197 МиБ кучи против 54 МиБ у этой формы. Вместе с
самим телом и удвоением в чтении пик доходит до ~300 МиБ на доставку — при
трёх автоматизациях и неизвестном размере VPS это OOM ровно на пике потока,
когда терять доставки дороже всего.
Сигнатура при этом остаётся `[]byte`: тело уже целиком в памяти после чтения
запроса, `io.Reader` добавил бы второй буфер и ничего не сэкономил, а правило
вывода слоя (≥10 точек по всей доставке) всё равно требует двух проходов.
### Дом канонизации — общий, а не внутри разбора
Канонизация, полнота точки и хеш живут в **нейтральном** пакете, который
импортируют и `hae`, и `store`. Иначе граница «`store` о HAE не знает»
оставляет их без дома: слияние происходит в `store`, после него хеш надо
пересчитать, а канонизация лежала бы в `hae`.
Альтернатива — вторая реализация канонизации для импорта родного экспорта
Apple — отвергнута: две реализации разойдутся на дребезге последнего разряда
double, и хеш-детектор начнёт видеть изменения там, где их нет. Глубокий
проход из почти бесплатного превратится в перезапись недели на каждом прогоне.
Каноническая форма существует только в момент вычисления хеша. Хранимая форма
— исходные байты точки: числа читаются литералом (`json.Number`), потому что
обход через `float64` теряет `1.0``1` и сдвигает целые больше 2^53, а
невалидный UTF-8 в именах устройств заменяется на U+FFFD. Сортировку ключей
делает `encoding/json`, своей писать не надо; собственным остаётся округление
до двенадцати значащих цифр.
### Разбор — функция от доставки в архиве, а не от тела в памяти
Разбор адресуется **идентификатором доставки**, тело читается из архива. Приём
сворачивает одну доставку, будущий `reindex` — все; код один.
Первая редакция дизайна отвергала это как «асинхронный разбор с очередью»,
которым оно не является: проход по архиву — детерминированная свёртка, ровно
то, чем система объявлена в `docs/architecture.md`
(`состояние = import(снапшот) + replay(доставки)`). Прежняя форма давала два
кода для одной операции — разбор при приёме и будущую пересборку, — и они
разошлись бы на первом же расхождении.
Плата: тело перечитывается с диска сразу после записи. Для 42 МБ это
страничный кэш, то есть несущественно.
### Свёртка вызывается синхронно, сразу после записи в архив
Тело ложится на диск, потом сворачивается — но уже как доставка из архива, а
не как буфер в памяти (см. выше). Отказ свёртки не откатывает архив: журнал
важнее витрины, восстановить точки из тела можно всегда, тело из точек — нет.
Синхронно, а не фоновым воркером: очередь дала бы окно «принято, но не
свёрнуто» при перезапуске, и понадобилось бы отдельное состояние «что
досворачивать». Свёртка одной доставки стоит секунд, а `read_timeout` уже пять
минут.
Работа после записи в архив идёт на контексте, **отвязанном от запроса**
(`context.WithoutCancel` с собственным дедлайном): иначе обрыв соединения
клиентом или Caddy на середине свёртки оставит часть объектов записанной, а
доставку — со статусом, по которому её никто не подберёт.
### Ключ объекта — `(metric, layer, hour_utc)`, содержимое — gzip-BLOB
Единица хранения — час, а не точка: 30 метрик × 24 часа × 365 ≈ 260 тыс. строк
на слой в год независимо от плотности точек внутри. Строка на точку дала бы
десятки миллионов.
Плата: внутрь объекта не заглянуть средствами SQL. Для хранилища, отдающего
диапазоны точек, это не потеря; каталог и свёртка получат свои производные
структуры отдельной задачей.
Сжатие наблюдалось около 25 раз — ~2 МБ в сутки вместо ~50 МБ.
### Координата — интервал, у измерения вырожденный
Ключ `метрика + слой + метка` верен для точки-измерения и неверен для
точки-интервала: под одной меткой лежит до трёх записей сна. Ключ единый:
```
координата = метрика + слой + начало + конец конец = начало, если end нет
```
Замер по 94 доставкам (находка 47): метка одна даёт 170 координат сна и 33
столкновения **внутри одной доставки**, интервал — 174 и ноль; `value` в ключе
не добавляет ни одной координаты.
Первая редакция вводила отдельный класс «эпизодных схем» с признаком «`start` и
`end`, отличные от `date`». Перепись по всем 22 метрикам с интервалами его
опровергла:
- **`start` всегда равен `date`** — признак не сработал бы ни разу;
- **интервалы несёт не только сон**, а 22 метрики, так что «эпизодная схема» —
не класс, а норма;
- **обе формы точки не смешиваются** внутри метрики одной доставки, поэтому
единый ключ не разорвёт надвое точку, приехавшую то с `end`, то без;
- **разные интервалы под одной меткой всегда несут разное содержимое**
(проверено по всем метрикам), так что ключ с интервалом не задваивает
поправленное задним числом.
Одна форма ключа вместо двух убирает из кода ветвление и понятие, которое
пришлось бы поддерживать в каталоге, Read API и импорте экспорта Apple.
Почему не «принять потерю и писать `WARN`»: в дублях внутри одной доставки
`received_at` общий, и тай-брейк по времени приёма неприменим в принципе —
исход решал бы порядок элементов в JSON-массиве, а он нестабилен (находка 2).
Свёртка перестала бы быть детерминированной: пересборка из архива давала бы не
то состояние, что живой приём.
Почему не append-only по хешу содержимого: это вторая модель идентичности в
`store` ради случая, которого можно избежать, и эпизоды не схлопывались бы
никогда — даже когда повтор действительно повтор, а их здесь 1706 из 1880.
Проверено против чужих решений (находка 47): единственная принятая схема
дедупликации Apple Health — `Start + End + тип`, без источника в ключе;
популярные ингесторы поверх InfluxDB ключуют по метке и теряют эпизоды
молчаливым last-write-wins движка. `HKObject.uuid` дал бы идентичность даром,
но **в выгрузку Apple он не попадает** — значит модель обязана выражаться через
`start`/`end`, иначе `import(экспорт)` не сойдётся с `replay(HAE)`.
### Слияние — по полноте, при равенстве — по `received_at`
Координатный ключ означает перезапись значения. Кто побеждает — решает
полнота: 0.66% координат несут разные содержимые, и разбор выборки показал,
что почти всё это разный **набор полей** при одинаковом `qty`. Правило «последний
победил» стирало бы `start`/`end` у уже сохранённой точки.
Полнота считается по числу значащих полей точки, `source` в счёт не идёт.
При **равной** полноте исход обязан быть детерминированным и не зависеть от
порядка доставок. Первая редакция предписывала сравнение по `received_at`
оно неисполнимо: у сохранённой точки нет провенанса, сравнивать не с чем. Хуже,
что четверть доставок несёт столкновения **внутри себя**, где `received_at`
вообще один. Поэтому исход определяется свойством самих значений (порядком
канонических форм), а не порядком событий.
Столкновение с различием содержимого оставляет след — `WARN` и счётчик. Иначе
допущение «меньше полей не значит новее», объявленное риском, не получит ни
одного наблюдения.
### Слой выводится по метрике внутри доставки, а не по доставке целиком
Правило проверено на всей истории (находка 33) и уже дважды ломалось на живых
данных при более простых формулировках. Классификация доставки целиком
сложила минутные точки с посекундными и удвоила сумму за час; классификация
каждой метрики по отдельности растащила редкие метрики по трём слоям.
Работающая формулировка: плотная метрика (≥10 точек) — сама по себе, редкая
наследует самый мелкий слой среди плотных.
Третий шаг — доставка без плотных метрик вовсе — наследует последний надёжно
выведенный слой той же автоматизации. Первая редакция заменила его заголовком,
и это была регрессия: измерено 2 такие доставки из 89, обе с заголовком
`Default`, который не означает режима. Наследовать нечего и заголовок
ненадёжен — точки не сохраняются, доставка ждёт пересборки; молчаливый `raw`
создал бы призрачный разрез, который поедет в каталог и в выбор слоя Read API.
### Сводка сна — отдельное имя метрики и фиксированный слой `day`
Разводить схемы на имена приходится потому, что правило вывода слоя на суточной
сводке даёт `hour` (полночь выровнена по часу), хотя это суточный итог. Имя
`sleep_analysis_summary` — наше, не Apple; инвариант «форма Apple не
транслируется» это не нарушает: переименования полей внутри точки нет,
разделяются только имена метрик, под которыми HAE смешал две схемы.
### `testdata` — реальные пакеты с вычищенными значениями
Конвенция требует тестов на реальных пакетах; инвариант запрещает данным о
здоровье попадать под контроль версий. Обе цели совместимы: в фикстурах
сохраняется всё, что важно разбору, — порядок ключей, три формата времени,
неразрывные пробелы в именах устройств, точность чисел, обе схемы сна,
смешанная доставка, — а измеренные величины заменяются.
Скрипт порождения фикстур из архива лежит в `tmp/research/` и позволяет собрать
их заново, когда поток принесёт новую форму.
Альтернатива — писать фикстуры руками по документации. Отвергнута ровно тем,
ради чего заводилось исследование: документация врёт.
## Risks / Trade-offs
**Разбор роняет приём** → разбор идёт после `f.Sync()` и переименования файла
архива; паника в разборе перехватывается, доставка помечается
`parse_status=failed`, ответ остаётся `200`.
**Конкурентные доставки правят один час** → read-modify-write теряет точки, и
наивное «транзакция плюс повтор» **измеримо не работает**. Замер на
`modernc.org/sqlite` с DSN проекта, 4 горутины × 200 слияний в одну строку:
```
txlock=deferred без повтора 91 из 800
txlock=deferred с повтором 242 из 800 (31078 повторов)
txlock=immediate без повтора 800 из 800
```
Причина: код отказа — `517` (`SQLITE_BUSY_SNAPSHOT`), и `busy_timeout` его не
покрывает, SQLite возвращает его немедленно. Поэтому: `_txlock=immediate` в
DSN; повтор оборачивает **всю тройку** чтение-слияние-запись, а не только
запись (иначе повтор перезапишет чужие точки уже прочитанным состоянием —
классический lost update); путь «хеш совпал, писать нечего» идёт под
`TxOptions{ReadOnly: true}`, чтобы не сериализоваться на write-lock;
распознавание — `errors.As` на `*sqlite.Error` с кодами 5 и 517, обёрнутое в
`store`, чтобы драйвер не торчал наружу.
Тест обязан быть с настоящей конкуренцией и проверкой суммы: две горутины в
удачном порядке проходят и на сломанной реализации.
**Правило полноты ошибочно для метрики, где меньше полей значит новее**
таких в потоке не наблюдалось, но допущение не доказано. Помечается как
предположение в спеке; расхождение всплывёт при сверке с экспортом Apple.
**Вычищенные фикстуры прячут свойство реальных данных** → риск реален: именно
дребезг последнего разряда double едва не увёл модель идентичности не туда.
Смягчение — сохранять точность чисел как в оригинале и держать отдельный тест
на канонизацию с настоящими значениями из находки 30.
**Объект за час распухает** → в нижнем слое HRV несёт `heartbeatSeries`, 93%
объёма метрики. Порог не выбран, поведение при большом объекте не определено;
наблюдаемость размера объекта уходит в задачу про `/stats`.
## Migration Plan
Миграция `00003_bucket.sql` — только добавление таблицы, существующие данные не
трогает. Откат: сервис прежней версии игнорирует новую таблицу, доставки
продолжают приниматься и складываться в архив, разбор просто не происходит.
Накопленные 89 доставок этой миграцией не разбираются: их подхватит `reindex`
отдельной задачей. До тех пор в хранилище только точки из доставок, пришедших
после выката.
## Open Questions
- **Порог `sealed`.** С какого возраста час считается запечатанным — ставим по
факту: сначала `WARN` на изменение старых объектов, потом смотрим, какая
глубина досчёта встречается в жизни (наблюдалось до 22 минут).
- **Поведение при объекте необычного размера.** Отдельного решения пока нет;
ждём наблюдаемости.
@@ -0,0 +1,81 @@
## Why
Приём работает третьи сутки, но дальше архива данные не идут: 89 доставок лежат
телами `.json.gz`, а в SQLite только строки `delivery`. Ни одна из трёх целей
проекта — агент-медик, трекер тренировок, фитнес-игра — не может прочитать
ничего, потому что читать нечего. Каталог, Read API, MCP и OpenAPI упираются в
эту задачу.
Правила разбора не надо изобретать: они выведены измерением на живом потоке и
записаны в `docs/local-research.md` (находки 30, 33, 35, 36, 38, 39, 41).
Задача — перенести их в код, а не спроектировать заново.
## What Changes
- Секция `metrics` тела доставки разбирается в **точки**: метрика, слой, метка
времени, содержимое как пришло.
- **Слой выводится из выравнивания меток**, а не из заголовка HAE: плотная
метрика (≥10 точек) классифицируется сама, редкая наследует преобладающий
слой доставки. Заголовок `automation-aggregation` непригоден — значение
`Default` соответствует трём разным режимам.
- Точки складываются в **часовые объекты** (`bucket`, ключ `метрика + слой +
час`), содержимое — gzip-BLOB. Запись — read-modify-write со слиянием.
- **Идентичность точки — координаты** (`метрика + слой + начало + конец`; у
точки-измерения конец равен началу). Ключ одной формы для всех точек: под
одной меткой лежит до трёх записей сна, и 33 столкновения происходят внутри
одной доставки, где тай-брейк по времени приёма неприменим. `source` в ключ
не входит: он нестабилен и меняется задним числом. При столкновении
выигрывает **более полная** точка, а не последняя пришедшая.
- **Канонизация с округлением** чисел до ~12 значащих цифр; хеш канонической
формы — детектор изменений, а не ключ.
- `sleep_analysis` разводится на два имени: поэпизодное и суточную сводку —
под одним именем HAE шлёт две несовместимые схемы.
- Метка точки разбирается из локального времени со смещением и хранится в UTC
плюс офсет исходной зоны. Эпоха внутри `heartbeatSeries` **не разбирается** —
серия проходит исходными байтами; RFC 3339 встречается только в
`stateOfMind` и нормируется вместе с ним, отдельной задачей.
- Разбор не влияет на код ответа приёма: непонятое содержимое по-прежнему
даёт 200, исход разбора виден в `delivery.parse_status` и в логе.
Не входит в изменение (сознательно, чтобы задача мерджилась целиком):
- **тренировки и секции с собственными `id`** (`workouts`, `stateOfMind`,
прочие `record`) — отдельная задача, у них другая модель хранения;
- **`healthlog reindex`** — отдельная задача. Следствие: разбираются только
доставки, пришедшие после выката; накопленные 89 доедут пересборкой.
Сходимость на них проверяется скриптом поверх архива, а не командой сервиса;
- **словарь категориальных значений** (переведённые строки → коды HealthKit) —
отдельная задача; пока строка хранится дословно и без кода рядом;
- **род агрегации и каталог разрезов** — отдельная задача, она следующая.
## Capabilities
### New Capabilities
- `parsing`: превращение тела доставки Health Auto Export в точки — вывод
слоя, разбор трёх форматов времени, канонизация содержимого, разделение
схем под одним именем метрики. Отдельно от хранения потому, что импорт
родного экспорта Apple будет другим разбором поверх того же хранилища.
- `storage`: идентичность точки, слияние и хранение часовыми объектами —
координатный ключ, правило разрешения столкновений, хеш как детектор
изменений, признак запечатанного часа.
### Modified Capabilities
Нет: `openspec/specs/` пуст, приём кодом существует, но спекой не описан и в
этом изменении не трогается.
## Impact
- Новые пакеты `internal/hae` (разбор), `internal/canon` (каноническая форма,
полнота, хеш — общий дом для разбора и хранения) и расширение
`internal/store` (объекты).
- Миграция `internal/store/migrations/00003_bucket.sql` — первая миграция после
приёма; вместе с ней заводится `docs/database.md` (ER-схема), которую требует
шаг гейта `er-schema`.
- `internal/ingest` получает шаг разбора после записи в архив; контракт приёма
не меняется.
- Появляется `testdata` с реальными пакетами HAE. Значения в них **вычищаются**:
структура, порядок ключей, форматы времени, неразрывные пробелы в именах
устройств и точность чисел сохраняются, измеренные величины заменяются —
данные о здоровье не попадают под контроль версий.
@@ -0,0 +1,256 @@
## ADDED Requirements
### Requirement: Разбор секции метрик
Система SHALL разбирать секцию `data.metrics` тела доставки Health Auto Export
в точки. Точка несёт имя метрики, единицы, слой, метку времени и содержимое в
том виде, в каком его прислал HAE.
Хранимая форма точки — **исходные байты**, как они пришли в теле доставки.
Система MUST NOT пересобирать содержимое повторной сериализацией разобранных
значений: обход через `map[string]any` теряет литерал (`1.0` становится `1`,
целые больше 2^53 сдвигаются, невалидный UTF-8 заменяется на U+FFFD), и потеря
не видна тестам на фикстурах — они сравнивают разобранное с разобранным.
Отсюда же следует, что «служебных» полей у точки нет: отбрасывать нечего,
нормализованное время добавляется рядом с исходным содержимым, а не вместо.
#### Scenario: Метрика с точками разбирается в точки
- **WHEN** тело содержит `data.metrics[]` с непустым `data[]`
- **THEN** каждая точка с непустым `date` становится точкой хранилища
- **AND** её содержимое сохраняется исходными байтами, без пересборки
#### Scenario: Незнакомая метрика не ломает разбор
- **WHEN** приходит метрика с именем, которого разбор не знает
- **THEN** её точки разбираются наравне с остальными
- **AND** разбор не завершается ошибкой
#### Scenario: Точка без метки времени пропускается
- **WHEN** точка не содержит `date` либо `date` не разбирается ни одним из
поддерживаемых форматов
- **THEN** точка не попадает в хранилище
- **AND** факт учитывается в итоге разбора доставки
#### Scenario: Точка с нечитаемым концом интервала пропускается
- **WHEN** точка несёт `end`, который не разбирается
- **THEN** точка не попадает в хранилище
- **AND** факт учитывается отдельным счётчиком
Вырождать такую точку в мгновенную нельзя: две записи с общим началом получили
бы одну координату, и одна исчезла бы молча. Тело остаётся в архиве.
### Requirement: Вывод слоя гранулярности
Система SHALL выводить слой точки из **выравнивания меток времени**, а не из
заголовка доставки. Заголовок `automation-aggregation` непригоден: значение
`Default` соответствует трём разным режимам выгрузки.
Выводимые слои: `raw` (метка на произвольной секунде), `minute` (секунды
нулевые), `hour` (секунды и минуты нулевые). Слой `day` в перечисление входит,
но **не выводится** — он назначается схемам с фиксированной гранулярностью
(см. «Разделение схем под одним именем метрики»).
Выравнивание SHALL считаться по метке в **исходной зоне**, а не по метке в UTC.
HAE строит сетку по местному времени; ровный местный час в зоне со смещением на
половину (`+0530`, `+0545`, `+0930`) даёт UTC-метку на середине часа, и часовая
выгрузка целиком уехала бы в слой `minute` — где столкнулась бы с настоящей
минутной автоматизацией и завысила сумму минутного слоя вдвое.
Слоем метрики SHALL становиться **самое мелкое** выравнивание среди её меток, а
не преобладающее. Измерено: у плотных метрик выравнивания перемешаны
(`active_energy` — 1320 минутных меток и 21 часовая, `heart_rate` — 654
посекундных и 10 минутных), потому что метка ровно на часе одновременно
является и минутной. Метрика, у которой хоть одна метка стоит на середине часа,
часовой не является.
Разделение схем под одним именем выполняется **до** вывода слоя, и точки с
назначенным слоем в определении преобладающего слоя доставки не участвуют:
суточных сводок сна бывает больше порога плотности, и их полуночные метки
иначе назначили бы всей доставке слой `hour`.
Классификация MUST быть **по метрике внутри доставки**, а не по доставке
целиком: при перенастройке автоматизации приезжают смешанные доставки, и
отнесение такой доставки к одному слою складывает минутные точки с
посекундными.
#### Scenario: Плотная метрика классифицируется сама
- **WHEN** в доставке у метрики не меньше десяти точек с метками
- **THEN** слой определяется выравниванием её собственных меток
#### Scenario: Зона с получасовым смещением не делает часовую выгрузку минутной
- **WHEN** метки стоят на ровном местном часе, а смещение зоны равно `+0530`
- **THEN** слой метрики `hour`
#### Scenario: Одна метка на середине часа делает метрику минутной
- **WHEN** у плотной метрики десять меток стоят ровно на часе, а одна — на
середине часа
- **THEN** слой метрики `minute`, а не `hour`
#### Scenario: Редкая метрика наследует преобладающий слой
- **WHEN** в доставке у метрики меньше десяти точек
- **THEN** она получает самый мелкий слой среди плотных метрик этой доставки
- **AND** её собственное выравнивание во внимание не принимается
#### Scenario: Доставка, где всем метрикам слой назначен схемой
- **WHEN** в доставке нет метрик, которым слой надо выводить, — все точки
принадлежат схемам с фиксированной гранулярностью
- **THEN** доставка сохраняется, а слой доставки не выводится и не требуется
Иначе доставка автоматизации, настроенной только на сон, отвергалась бы
целиком — и необратимо: исход детерминирован, и пересборка повторяла бы его
вечно.
#### Scenario: В доставке нет плотных метрик
- **WHEN** ни у одной метрики доставки нет десяти точек
- **THEN** слой наследуется от последнего надёжно выведенного слоя той же
автоматизации (`automation-id`) среди доставок, **предшествующих** этой
- **AND** если наследовать нечего, слой берётся из **надёжного** заголовка
(`Minutes``minute`, `Hours``hour`)
Граница «предшествующих» обязательна: слой обязан быть функцией от префикса
журнала. Наследование от последней доставки вообще делает свёртку зависящей от
истории, и пересборка даёт не то состояние, что живой приём — измерено на
архиве, 1737 объектов против 1742.
#### Scenario: Пересборка журнала даёт то же состояние
- **WHEN** те же доставки сворачиваются повторно в том же порядке
- **THEN** число объектов и их содержимое не меняются
#### Scenario: Наследовать нечего и заголовок ненадёжен
- **WHEN** плотных метрик нет, предыдущего слоя автоматизации нет, а заголовок
равен `Default`
- **THEN** точки доставки не сохраняются, а исход учитывается счётчиком и
записью `WARN`
- **AND** тело остаётся в архиве, откуда доставку подберёт пересборка
Молчаливый выбор `raw` в этой ветке недопустим: заголовок `Default` наблюдался
одновременно у посекундного, минутного и часового режимов, поэтому он не
доказывает ничего, а призрачный `raw`-разрез попадёт в каталог и в правило
Read API «самый мелкий слой, покрывающий диапазон».
#### Scenario: Выведенный слой расходится с надёжным заголовком
- **WHEN** выведенный слой не совпадает с заголовком `Minutes` или `Hours`
- **THEN** система пишет запись уровня `WARN`
- **AND** сохраняет точки по выведенному слою, а не по заголовку
#### Scenario: Заголовок `Default` в сравнении не участвует
- **WHEN** заголовок доставки равен `Default`
- **THEN** расхождение не фиксируется и `WARN` не пишется
Иначе сигнал утонул бы в собственном шуме: `Default` не означает режима, и
сравнение с ним давало бы `WARN` на каждой доставке потока в пять минут.
### Requirement: Разбор форматов времени
Система SHALL разбирать метку точки формата `2026-07-31 21:03:51 +0300` и
приводить её к UTC, сохраняя офсет исходной зоны. В секции `data.metrics`
других форматов меток не встречается.
Unix-эпоха дробным числом (`1785446196.4132624`) встречается **внутри**
`heartbeatSeries` и меткой точки не является. Система MUST NOT преобразовывать
её: элементы серии проходят как исходные байты. Преобразование во `time.Unix`
и обратно не гарантирует дословности, а серия составляет 93% объёма метрики
`heart_rate_variability`.
RFC 3339 (`2026-07-31T18:03:51Z`) в этой дельте не нормируется: он встречается
только в `data.stateOfMind`, которая выведена из scope. Требование к нему
появится вместе с задачей про секции с собственными `id` — вместе с данными,
на которых его можно проверить.
#### Scenario: Локальное время со смещением
- **WHEN** метка имеет вид `2026-07-31 21:03:51 +0300`
- **THEN** точка получает время в UTC и офсет `+10800` секунд
#### Scenario: Время внутри серии ударов
- **WHEN** точка метрики `heart_rate_variability` содержит `heartbeatSeries`
- **THEN** элементы серии сохраняются исходными байтами вместе с их эпохой
- **AND** серия не разворачивается в отдельные точки
- **AND** эпоха внутри серии не разбирается и не преобразуется
### Requirement: Разделение схем под одним именем метрики
Система SHALL разводить на разные имена метрики те схемы, которые Health Auto
Export шлёт под одним именем, чтобы одно имя означало одну схему.
Под именем `sleep_analysis` приезжают две несовместимые схемы: поэпизодная
(`start`/`end`/`value`/`qty`) и суточная сводка
(`totalSleep`/`core`/`rem`/`deep`/`awake` с меткой на местной полуночи). Общих
полей, кроме `date` и `source`, у них нет.
Схема точки SHALL определяться по самой точке, а не по её месту в исходном
массиве. Список разобранных точек отфильтрован пропусками, и соответствие по
индексу съезжало бы от одной пропущенной точки: эпизод уезжал бы под имя
суточной сводки со слоем `day`, сводка — под имя эпизода. Метрика и слой входят
в координату, поэтому ошибка необратима — точки из объекта не удаляются, и
пересборка воспроизвела бы её.
#### Scenario: Пропущенная точка не сдвигает разметку схем
- **WHEN** в метрике `sleep_analysis` перед суточной сводкой стоит точка без
разбираемой метки
- **THEN** сводка всё равно сохраняется под именем `sleep_analysis_summary` со
слоем `day`
#### Scenario: Поэпизодная запись сна
- **WHEN** точка `sleep_analysis` содержит поле `value`
- **THEN** она сохраняется под именем `sleep_analysis`
#### Scenario: Суточная сводка сна
- **WHEN** точка `sleep_analysis` содержит поле `totalSleep`
- **THEN** она сохраняется под именем `sleep_analysis_summary`
- **AND** её слой фиксирован как `day`, а не выводится из выравнивания
### Requirement: Канонизация содержимого
Система SHALL вычислять каноническую форму содержимого точки для сравнения и
хеширования: сортировка ключей и округление чисел до двенадцати значащих цифр.
Каноническая форма существует **только в момент вычисления хеша** и хранимую
форму не заменяет никогда: хранится исходные байты (см. «Разбор секции
метрик»). Числа при канонизации читаются литералом, а не через `float64`, —
иначе округление применится к уже испорченному значению.
Сортировка ключей — свойство `encoding/json`, своей реализации не требует.
Собственным остаётся только округление.
Без округления сравнение бесполезно: 63% повторно приехавших точек различались
последним разрядом double при одинаковом измерении.
#### Scenario: Повтор с иным порядком ключей опознаётся как тот же
- **WHEN** та же точка приезжает с другим порядком ключей в JSON
- **THEN** её каноническая форма совпадает с сохранённой
#### Scenario: Дребезг последнего разряда не считается изменением
- **WHEN** значение отличается только за пределами двенадцатой значащей цифры
- **THEN** каноническая форма совпадает с сохранённой
### Requirement: Разбор не влияет на код ответа приёма
Система MUST сохранять правило «сохранили — значит приняли»: исход разбора не
меняет код ответа на доставку.
#### Scenario: Содержимое не разобралось
- **WHEN** тело сохранено в архив, но разбор его содержимого не удался
- **THEN** ответ на приём остаётся `200`
- **AND** исход виден в `delivery.parse_status` и в записи лога
@@ -0,0 +1,211 @@
## ADDED 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 оставлять
**более полную** точку — ту, у которой больше значащих полей, — а не последнюю
пришедшую. Иначе бедная доставка стирает у богатой поля, которых сама не несёт:
0.66% координат различаются именно набором полей при одинаковом значении.
Если полнота равна, а значения различаются, исход MUST быть детерминированным
и не зависеть от порядка, в котором доставки дошли до хранилища: свёртка по
журналу обязана давать то же состояние, что приём в реальном времени.
Сравнение по `received_at` для этого не годится: у сохранённой точки нет
провенанса — ни времени приёма, ни идентификатора доставки, — и сравнивать
не с чем. Детерминизм обеспечивается свойством самих значений (например,
порядком канонических форм), а не порядком событий.
#### Scenario: Бедная точка не стирает поля богатой
- **WHEN** сохранена точка с `Avg`, `Min`, `Max` и `context`
- **AND** по тем же координатам приезжает точка только с `Avg`, `Min` и `Max`
- **THEN** сохранённая точка остаётся с `context`
#### Scenario: Одинаково полные точки с разными значениями
- **WHEN** по одним координатам приходят две одинаково полные точки с разными
значениями
- **THEN** исход определяется детерминированно и не зависит от порядка
воспроизведения доставок
Столкновением 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`.
#### Scenario: Разбор доставки логируется без значений
- **WHEN** доставка разобрана
- **THEN** запись лога содержит счётчики (метрик, точек, объектов) и
идентификатор доставки
- **AND** не содержит ни значений точек, ни имён устройств
@@ -0,0 +1,96 @@
## 1. Фикстуры и схема
- [x] 1.1 Скрипт `tmp/research/fixtures.py`: собирает фикстуры из `data/raw`, вычищая измеренные значения и сохраняя порядок ключей, точность чисел, неразрывные пробелы и форматы времени
- [x] 1.2 Набор `internal/hae/testdata`: минутная доставка, посекундная, часовая, смешанная (перенастройка автоматизации), обе схемы `sleep_analysis`, точка с `heartbeatSeries`, доставка без плотных метрик с заголовком `Default`
- [x] 1.3a Фикстура с эпизодами сна: три точки с одной меткой `date` и разными парами `start`/`end`, включая эпизод, пересекающий границу часа (в архиве такие есть — вычистить значения, интервалы сохранить)
- [x] 1.3 Рукотворная фикстура с **выдуманным полем точки** — скрипт из архива её не породит, а без неё требование «незнакомое поле сохраняется» останется без теста
- [x] 1.4 Миграция `internal/store/migrations/00003_bucket.sql`: таблица `bucket` (`metric`, `layer`, `hour_utc`, `units`, `payload` BLOB, `content_hash`, `points`, `first_ts`, `last_ts`, `first_delivery_id`, `sealed`, `created_at`, `updated_at`), уникальность по `(metric, layer, hour_utc)`
- [x] 1.5 `docs/database.md` — ER-схема с `delivery` и `bucket` (шаг гейта `er-schema` требует её при изменении миграций)
## 2. Канонизация — общий дом
- [x] 2.1 Пакет `internal/canon`: каноническая форма (числа читаются литералом через `json.Number`, округление до 12 значащих цифр — именованная константа со ссылкой на находку 30), полнота точки, хеш
- [x] 2.2 Сортировку ключей **не писать** — её делает `encoding/json`
- [x] 2.3 Тест канонизации на настоящих парах чисел из находки 30: все три схлопываются
- [x] 2.4 Тест: значение с `1.0`, целым больше 2^53 и невалидным UTF-8 переживает хранение дословно
## 3. Разбор — пакет `internal/hae`
- [x] 3.1 Декодирование конверта в структуру с `Data []json.RawMessage`; точка декодируется по одной. Тест удержания кучи на теле в десятки МиБ
- [x] 3.2 Разбор метки `2006-01-02 15:04:05 -0700` в UTC с сохранением офсета; неразобранная метка — пропуск точки со счётчиком, не паника
- [x] 3.3 Эпоха внутри `heartbeatSeries` **не разбирается**: элементы проходят исходными байтами
- [x] 3.4 Вывод слоя: плотная метрика (≥10 точек) сама, редкая наследует преобладающий, доставка без плотных — наследует последний слой автоматизации по `automation-id`
- [x] 3.5 Наследовать нечего и заголовок `Default` — точки не сохраняются, `WARN` и счётчик
- [x] 3.6 `WARN` о расхождении слоя — только против `Minutes`/`Hours`; `Default` в сравнении не участвует
- [x] 3.7 Разделение `sleep_analysis` на поэпизодную и `sleep_analysis_summary` со слоем `day`; выполняется **до** вывода слоя, точки с назначенным слоем в голосовании не участвуют
- [x] 3.8 `recover` **внутри** `hae.Parse` — превращает панику разбора в ошибку пакета; паника из `store` наверх не перехватывается
- [x] 3.9 Контракт: `error` ненулевая только когда точек нет вовсе; частичные исходы — счётчиками в результате
- [x] 3.10 `FuzzParse` и таблица враждебных входов: усечённое тело, `null` вместо объекта, массив вместо `data`, число вместо строки даты
- [x] 3.11 Тесты разбора на фикстурах 1.2–1.3, включая смешанную доставку и обе схемы сна
## 4. Хранение — часовые объекты
- [x] 4.1 Модель объекта в `internal/store`; содержимое — исходные байты точек, gzip-BLOB, точки упорядочены по времени
- [x] 4.2 Слияние: координатный ключ, победа более полной точки, при равной полноте — детерминированный исход по порядку канонических форм
- [x] 4.3 Столкновение с различием канонической формы — `WARN` без значений и счётчик перезаписей
- [x] 4.4 Ключ точки — `метрика + слой + начало + конец` одной формы для всех точек: начало из `start`, иначе из `date`; конец из `end`, иначе равен началу. Час объекта — по началу. Ветвления по «классу метрики» быть не должно
- [x] 4.4a Тест на фикстуре 1.3a: три записи с одной меткой и разными интервалами дают три точки, а не одну; повтор той же тройки следующей доставкой не задваивает; точка без `end` кладётся вырожденным интервалом
- [x] 4.5 Хеш объекта как детектор изменений: совпал — записи нет
- [x] 4.6 `_txlock=immediate` в DSN; повтор оборачивает **всю доставку** целиком. Путь «хеш совпал» под `TxOptions{ReadOnly: true}` **не сделан** — вынесен блокером `otvet-i-svyortka` вместе с остальной ценой синхронной свёртки (измерено: 52 мс на неизменившийся плотный час под write-lock)
- [x] 4.7 Распознавание занятости — `errors.As` на `*sqlite.Error`, коды 5 и 517, обёрнуто в `store`
- [x] 4.8 Тест конкурентной записи: N горутин × M слияний в один `hour_utc`, проверка **суммы** точек, под `-race`
- [x] 4.9 Тест идемпотентности: повторное слияние того же набора не меняет ни содержимое, ни хеш
- [x] 4.10 Тесты сценариев слияния: «бедная точка не стирает поля богатой», «смена `source` не создаёт вторую точку»
## 5. Сшивка с приёмом
- [x] 5.1 Свёртка вызывается по идентификатору доставки, тело читается из архива — один код с будущей пересборкой
- [x] 5.2 Работа после записи в архив — на `context.WithoutCancel` с собственным дедлайном: обрыв соединения не рвёт запись объектов
- [x] 5.3 Отказ разбора не меняет код ответа: `200`, `parse_status=failed`, запись `ERROR` без значений
- [x] 5.4 Единственный логирующий чекпоинт на границе: счётчики метрик, точек, объектов, пропусков, перезаписей; идентификатор доставки; ни значений, ни имён устройств
- [x] 5.5 Тест приёма: битый JSON — 400, непонятое содержимое — 200 с `parse_status=failed`
## 6. Сходимость на реальных данных
- [x] 6.1 Проверка сходимости — не отдельным скриптом, а `task verify:archive` поверх `internal/fold/replay_test.go`: одна реализация вместо двух, и она гоняет ровно продакшн-путь
- [x] 6.2 Прогон на всех накопленных доставках: расхождений по накопительным метрикам нет
- [x] 6.3 `task gate` зелёный; `task restart` поднимает сервис
- [ ] 6.3a **Не закрыто:** живая доставка с телефона не разобрана — поток молчит с 17:13 (пауза началась ЗА ЧАС до перезапуска, то есть не из-за него). Сервис поднят и здоров, миграции на живой базе применились, схема проверена чтением. Пункт закрывается первой же пришедшей доставкой
## 7. Приёмочные критерии (рубрика ревью дизайна)
Порождена проходом `healthlog-review-rubric` **до** чтения предложения. Каждый
пункт проверяем: понятно, каким тестом его провалить.
- [x] 7.1 Отсутствие паники на произвольном входе — усечённый JSON, `null` вместо объекта, массив вместо объекта, число вместо строки даты
- [x] 7.2 Незнакомое поле точки переживает round-trip дословно
- [x] 7.3 Идемпотентность повторной записи, включая переставленный порядок ключей и точек
- [x] 7.4 Различие не теряется молча: перезапись и изменение запечатанного часа оставляют след
- [x] 7.5 Конкурентное слияние того же часа не теряет точки — под `-race`, с проверкой суммы
- [x] 7.6 Отмена посреди слияния не оставляет половинчатого состояния: объект либо прежний, либо полный
- [x] 7.7 Граница размера входа явная; вход в сотни мегабайт не кладёт процесс по памяти
- [x] 7.8 Ошибки различимы по типу, а не по тексту (`errors.Is`/`errors.As`)
- [x] 7.9 Частично непонятный пакет имеет явную судьбу: что сохранено, что отброшено — видно в счётчиках
- [x] 7.10 Время нормализовано без потери зоны
- [x] 7.11 Слой выводится из данных, а не из заголовка; неопределимый слой имеет явную судьбу
- [x] 7.12 Парсер детерминирован и чист: ни `time.Now`, ни генерации id; два вызова на одном входе равны
- [x] 7.13 (добавлено после снятия блокера) Точка-интервал не схлопывается по метке: прогон архива даёт 174 координаты сна, а не 170
## 8. Отработка ревью кода (профиль `deep`)
Триаж свёл 62 сырые находки к 33 причинам: 3 блокера, 4 «сейчас», 2 развилки.
- [x] 8.1 Схема точки сна определяется по самой точке, а не по индексу в исходном массиве (подтверждено шестью проходами)
- [x] 8.2 Доставка сворачивается одной транзакцией: частичное состояние было недетерминированным (8 прогонов — 7 состояний)
- [x] 8.3 Граница размера на распакованном теле: 400 КиБ gzip разворачивались в 400 МиБ мимо лимита
- [x] 8.4 Столкновение — расхождение канонических форм, а не байтов; `WARN` с координатами объекта
- [x] 8.5 `encodePayload` без HTML-экранирования: `&`, `<`, `>` хранятся дословно
- [x] 8.6 Доставка из одних суточных сводок больше не отвергается целиком
- [x] 8.7 Выравнивание считается по местной метке: получасовые зоны уводили часовую выгрузку в `minute`
- [x] 8.8 Нечитаемый `end` пропускает точку со счётчиком, а не вырождает интервал
- [x] 8.9 Единицы не переписываются молча: сохранённое побеждает, расхождение — счётчик и `WARN`
- [x] 8.10 Счётчик считает сохранённые точки, а не присланные
- [x] 8.11 Все выходы `Fold` с ошибкой логируются; исход пишется на контексте, переживающем отмену; слой не затирается
- [x] 8.12 Признаки — атрибутами всегда, уровень выбирается отдельно; `WARN` на отброшенных всех точках
- [x] 8.13 Регрессионные тесты на каждое исправление; тест «значения точек не попадают в лог» с перехватывающим handler
- [x] 8.14 Развилки вынесены блокерами: `otvet-i-svyortka`, `pravilo-sliyaniya-tochek`, `edinicy-metriki-v-razreze`, `nerazobrannye-sekcii-dostavki`