## 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 ничего не знает. ### Разбор — синхронно в приёме, после записи в архив Тело сначала ложится на диск, потом разбирается. Отказ разбора не откатывает архив: журнал важнее витрины, и восстановить точки из тела можно всегда, а тело из точек — нет. Асинхронный разбор (очередь, воркер) отвергнут: он даёт окно, в котором доставка принята, но не разобрана, а сервис перезапущен — и мы теряем понимание, что доразобрать. Синхронный разбор при 42 МБ теле стоит секунд, а `read_timeout` уже пять минут. ### Ключ объекта — `(metric, layer, hour_utc)`, содержимое — gzip-BLOB Единица хранения — час, а не точка: 30 метрик × 24 часа × 365 ≈ 260 тыс. строк на слой в год независимо от плотности точек внутри. Строка на точку дала бы десятки миллионов. Плата: внутрь объекта не заглянуть средствами SQL. Для хранилища, отдающего диапазоны точек, это не потеря; каталог и свёртка получат свои производные структуры отдельной задачей. Сжатие наблюдалось около 25 раз — ~2 МБ в сутки вместо ~50 МБ. ### Слияние — по полноте, при равенстве — по `received_at` Координатный ключ означает перезапись значения. Кто побеждает — решает полнота: 0.66% координат несут разные содержимые, и разбор выборки показал, что почти всё это разный **набор полей** при одинаковом `qty`. Правило «последний победил» стирало бы `start`/`end` у уже сохранённой точки. Полнота считается по числу значащих полей точки, `source` в счёт не идёт. При равной полноте побеждает точка из доставки с большим `received_at` — это делает свёртку по журналу детерминированной: проигрывание архива обязано дать то же состояние, что приём в реальном времени. ### Слой выводится по метрике внутри доставки, а не по доставке целиком Правило проверено на всей истории (находка 33) и уже дважды ломалось на живых данных при более простых формулировках. Классификация доставки целиком сложила минутные точки с посекундными и удвоила сумму за час; классификация каждой метрики по отдельности растащила редкие метрики по трём слоям. Работающая формулировка: плотная метрика (≥10 точек) — сама по себе, редкая наследует самый мелкий слой среди плотных. ### Сводка сна — отдельное имя метрики и фиксированный слой `day` Разводить схемы на имена приходится потому, что правило вывода слоя на суточной сводке даёт `hour` (полночь выровнена по часу), хотя это суточный итог. Имя `sleep_analysis_summary` — наше, не Apple; инвариант «форма Apple не транслируется» это не нарушает: переименования полей внутри точки нет, разделяются только имена метрик, под которыми HAE смешал две схемы. ### `testdata` — реальные пакеты с вычищенными значениями Конвенция требует тестов на реальных пакетах; инвариант запрещает данным о здоровье попадать под контроль версий. Обе цели совместимы: в фикстурах сохраняется всё, что важно разбору, — порядок ключей, три формата времени, неразрывные пробелы в именах устройств, точность чисел, обе схемы сна, смешанная доставка, — а измеренные величины заменяются. Скрипт порождения фикстур из архива лежит в `tmp/research/` и позволяет собрать их заново, когда поток принесёт новую форму. Альтернатива — писать фикстуры руками по документации. Отвергнута ровно тем, ради чего заводилось исследование: документация врёт. ## Risks / Trade-offs **Разбор роняет приём** → разбор идёт после `f.Sync()` и переименования файла архива; паника в разборе перехватывается, доставка помечается `parse_status=failed`, ответ остаётся `200`. **Конкурентные доставки правят один час** → read-modify-write без блокировки теряет точки. Три автоматизации шлют одновременно, и перекрытие часов — норма, а не край. Запись объекта идёт в транзакции; при `SQLITE_BUSY` — повтор. Проверяется тестом с параллельной записью в один `hour_utc`. **Правило полноты ошибочно для метрики, где меньше полей значит новее** → таких в потоке не наблюдалось, но допущение не доказано. Помечается как предположение в спеке; расхождение всплывёт при сверке с экспортом Apple. **Вычищенные фикстуры прячут свойство реальных данных** → риск реален: именно дребезг последнего разряда double едва не увёл модель идентичности не туда. Смягчение — сохранять точность чисел как в оригинале и держать отдельный тест на канонизацию с настоящими значениями из находки 30. **Объект за час распухает** → в нижнем слое HRV несёт `heartbeatSeries`, 93% объёма метрики. Порог не выбран, поведение при большом объекте не определено; наблюдаемость размера объекта уходит в задачу про `/stats`. ## Migration Plan Миграция `00003_bucket.sql` — только добавление таблицы, существующие данные не трогает. Откат: сервис прежней версии игнорирует новую таблицу, доставки продолжают приниматься и складываться в архив, разбор просто не происходит. Накопленные 89 доставок этой миграцией не разбираются: их подхватит `reindex` отдельной задачей. До тех пор в хранилище только точки из доставок, пришедших после выката. ## Open Questions - **Порог `sealed`.** С какого возраста час считается запечатанным — ставим по факту: сначала `WARN` на изменение старых объектов, потом смотрим, какая глубина досчёта встречается в жизни (наблюдалось до 22 минут). - **Поведение при объекте необычного размера.** Отдельного решения пока нет; ждём наблюдаемости.