Files
healthlog/docs/architecture.md
T
av 8331328134 Дозакрыты находки ревью по слиянию сущностей
- Правило покрытия получило второй разряд (условный, как у точек), запрет
  вырождения формы и счёт содержательных элементов ряда: скелет из скаляров и
  ряд из null больше не затирают маршрут. Победитель внутри доставки стал
  функцией множества версий — общим помощником с точками, — а провенанс
  поднимается и при совпавшем хеше, иначе отложенная доставка возвращала витрину
  к прежнему содержимому.
- Одно поле не того типа больше не уносит сущность, а пропуски видны в учётной
  записи доставки (миграция 00008, NULL = «не измерялось»); каноническая форма
  считается один раз и вне транзакции; откат бинаря поверх новой схемы отказывает
  на старте; текст ошибки разбора не несёт значений из тела.
- Ревью кода профилем deep (девять проходов) нашло две регрессии и обе закрыты:
  безусловный второй разряд запирал законный досчёт навсегда, а выбор победителя
  был квадратичен по числу присланных версий одного ключа.
2026-08-02 16:38:18 +03:00

1285 lines
112 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# Архитектура
## Назначение
healthlog принимает выгрузки Apple Health из приложения Health Auto Export
(далее HAE) и родного экспорта Apple Health, хранит их и отдаёт другим
приложениям — в том числе агентам, через MCP. Он не переименовывает поля и не
интерпретирует значения; агрегаты считает только в ответе на запрос и только
там, где род метрики измерен, а не угадан.
## Принципы
- **Один статический бинарь** (`CGO_ENABLED=0`), доставка — docker-образом.
- **Точки хранятся дословно.** Часовой объект держит точки ровно в том виде,
в каком их прислал HAE — без переименований, пересчётов и отбрасывания
незнакомых полей. Поэтому хранилище само по себе является полной копией
данных, а не производной выжимкой.
- **Хранилище — свёртка по журналу, а не единственная копия.** Экспорт Apple
это снапшот всей истории, доставки HAE после его даты — события поверх
снапшота. Состояние всегда пересобираемо: `import(экспорт) + replay(доставки)`.
Отсюда срок жизни сырого архива — до следующего проверенного экспорта, а не
произвольные две недели. Исключение названо вслух: `stateOfMind` в экспорт не
попадает, см. «Хранилище».
- **Сохранили — значит приняли.** Код ответа отражает доставку, а не разбор
(см. «Приём»).
- **Ничего не теряем молча.** Идентичность — устойчивые координаты
(`метрика + слой + начало + конец`; у точки-измерения конец равен началу);
`source` в ключ не входит, он
нестабилен. Хеш канонизированного содержимого остаётся детектором изменений,
чтобы не писать зря. При столкновении выигрывает более полная точка, а не
последняя пришедшая: бедная доставка не должна стирать поля у богатой.
Полнота — **множество** ключей с непустым значением, а не их число (см.
«Разрешение столкновений»).
- **Дыры закрываются сами.** Данные приходят несколькими проходами разной
глубины, поэтому пропущенная доставка не оставляет постоянного пробела —
см. «Модель синхронизации».
- **Форма Apple не транслируется.** Значения отдаются такими, какими пришли;
нормализовано только время. Единственное добавление — стабильный код рядом
с переведённой строкой (см. «Категориальные значения»): он приписывается, а
не подменяет.
- **Своей агрегации в хранении нет — есть слои.** Метрика лежит в тех
разрезах подробности, в которых пришла (`sample`/`raw`/`minute`/`hour`);
переагрегирования при записи не происходит никогда.
- **Агрегация в ответе — только измеренная.** Read API умеет свести метрику к
запрошенной сетке, но род свёртки (сумма или среднее) выведен сверкой слоёв
между собой, а не проставлен вручную. Где род неизвестен, агрегация не
предлагается: отдаются значения как есть.
- **Минимум компонентов** — один процесс, SQLite, файлы. Без очередей и
внешних зависимостей.
## Формат Health Auto Export
Документация формата скудная: [help.healthyapps.dev](https://help.healthyapps.dev/en/health-auto-export/automations/rest-api/)
и [wiki Lybron/health-auto-export](https://github.com/Lybron/health-auto-export/wiki/API-Export---JSON-Format).
Ниже — то, на что мы опираемся; всё остальное уточняем по реальным пакетам.
Автоматизация HAE шлёт **POST** с JSON-телом и своими заголовками:
`automation-name`, `automation-id`, `automation-aggregation`,
`automation-period`, `session-id`. Свои заголовки (токен) добавляются в
настройках автоматизации. Большой экспорт может приехать несколькими
запросами (**Batch Requests**) — поэтому идемпотентность нужна на уровне
точки, а не пакета.
Мета-информации в **теле нет вообще** — только `{"data": {…секции…}}`. Из
заголовков в коде опираемся лишь на `automation-id` (стабильный UUID
автоматизации) и `session-id`: `automation-aggregation` и `automation-period`
называют настройку, а не фактический режим, и значение `Default` соответствует
трём разным поведениям (находка 31). Гранулярность и охват определяем по самим
данным. Полный набор заголовков сохраняется в `delivery.headers` — документация
заведомо неполна, и именно из незадокументированного вышли самые полезные
находки.
```
{"data": {"metrics": [...], "workouts": [...], "stateOfMind": [...],
"medications": [...], "symptoms": [...], "cycleTracking": [...],
"ecg": [...], "heartRateNotifications": [...]}}
```
Метрика — `{"name": "heart_rate", "units": "count/min", "data": [...]}`.
**Форма точки зависит от метрики**: обычная — `{qty, date}`, пульс —
`{Min, Avg, Max, date}`, давление — `{systolic, diastolic}`, сон — набор
интервалов и фаз, глюкоза — плюс `mealTime`. Единой формы значения нет;
общее — только момент времени.
Вопреки документации, в точке **есть поле `source`** — какие устройства
вложились в значение (составное, через `|`). Что ещё документация описывает
неверно и как поток выглядит на самом деле — [local-research.md](local-research.md).
Даты приходят строкой с офсетом: `2026-07-31 12:00:00 +0300` — не RFC 3339.
Тренировка (v2) несёт стабильный `id` из HealthKit, `start`/`end`/`duration`,
опционально `route` (точки GPS) и `heartRateData`.
**Наша настройка:** несуммированные данные (переключатель «Суммировать
данные» выключен, группировка при этом недоступна). Причина — суммированные
значения досчитываются задним числом: минутное ведро уезжает неполным и в
следующей доставке приезжает полным
([local-research.md](local-research.md), находка 10). На несуммированных
данных расхождений не наблюдалось (находка 3), поэтому идентичность по
содержимому работает без оговорок. Заодно сохраняются детали, которые
группировка съедает: эпизоды сна и межударные интервалы (находки 6, 19).
Чего это **не** даёт: настоящих сэмплов. Накопительные метрики (энергия,
шаги, дистанция) в любом режиме приходят посекундной сеткой — нарезкой
реальных сэмплов длиной 1–12 секунд, с сохранением итога и потерей границ
интервала. Порядка 135 тысяч точек в сутки (находки 20, 23).
Поля `start`/`end` есть только у дискретных метрик (пульс, сатурация, сон,
HRV); у накопительных — только `date`. Поэтому точка относится к часу **по
`date`**, а вопрос о сэмплах, пересекающих границу часа, касается сотой доли
данных (находка 21).
## Модель синхронизации
У модели два независимых измерения: **глубина окна** (как далеко назад
переспрашиваем) и **подробность** (в какой слой попадут данные). Проходы
задаются их сочетанием.
По глубине — три прохода, догоняющие друг друга:
| проход | расписание | период | зачем |
|---|---|---|---|
| быстрый | каждые 5 минут | Since Last Sync | свежесть |
| средний | 3–4 раза в день | **Today** | чинит пропуски за сутки |
| глубокий | раз в сутки | **Previous 7 Days** | чинит всё остальное |
По подробности — что в какой слой:
| подробность | набор метрик | слой |
|---|---|---|
| без группировки | только несуммируемые: сон, пульс, HRV | `raw` |
| минутная | все метрики здоровья | `minute` |
| часовая | все метрики здоровья | `hour` |
| ручной экспорт | всё, раз в 2–3 месяца | `sample` |
Набор нижнего слоя определяется не важностью метрики, а тем, **можно ли её
складывать**. Для пульса и HRV посекундная подробность несёт форму сигнала,
которой в минутном разрезе нет. Для шагов и энергии нижний слой HAE — это
интерполяция, которая не сходится в сверке (находка 34); держать её значило бы
хранить втрое больший объём ради худших чисел.
Секции без группировки в интерфейсе HAE (`stateOfMind`, `symptoms`, `ecg`,
`heartRateNotifications`, `cycleTracking`, `medications`) идут как есть — у них
подробности нет, есть только глубина окна.
Средний и глубокий проходы используют **фиксированные окна, а не метку
синхронизации** — это принципиально. Инкрементальный режим проверен и
**теряет данные**: в окне, которое он якобы покрыл, широкая выгрузка нашла
13 961 точку, включая фазы сна за три часа и весь глубокий сон той ночи
(находка 29). Фиксированное окно идемпотентно по построению и не зависит ни от
какой метки.
Расписание — пожелание, а не гарантия: iOS не даёт приложению запускаться в
заданное время, а к данным Health доступа нет вовсе, пока телефон заблокирован
(находка 28). Поэтому проходы привязываются к моментам, когда телефон заведомо
разблокирован (триггер из Shortcuts по времени суток), а поток считается
пачечным: тишина ночью, всплеск утром.
Гарантия починки:
```
дыра моложе суток → закроется в течение часа
дыра моложе недели → закроется в течение суток
дыра старше недели → не закроется; лечится `healthlog import`
```
Широкие проходы почти бесплатны именно из-за часовых объектов: глубокий проход
переприсылает неделю, но это 26 метрик × 168 часов ≈ 4400 сравнений хеша,
почти все из которых сойдутся, и записи не будет.
**Прежнее правило «настройки данных у всех проходов одинаковы» снято.** Оно
существовало потому, что метрика, приехавшая с разной группировкой,
перетирала сама себя по одному ключу (находка 14). С тех пор слой вошёл в
ключ, и минутная точка с часовой больше не сталкиваются — они в разных рядах.
Именно это и позволяет наполнять слои разными автоматизациями намеренно.
Условие, при котором это безопасно: слой выводится **из выравнивания меток, а
не из настройки автоматизации**. Перенастроил автоматизацию — данные просто
пойдут в другой слой, без порчи уже накопленного.
### Досчёт задним числом
Метрики правятся после факта, и глубина правки резко разная по классам:
- **Количественные** (пульс, шаги, энергия) человек руками не правит; они
опаздывают на часы. Наблюдались правки хвоста возрастом до 22 минут.
Недельного глубокого прохода достаточно.
- **Ручные записи** (`symptoms`, `medications`, `stateOfMind`,
`cycleTracking`) заводятся задним числом на недели и месяцы — симптом или
приём лекарства можно отметить за прошлую дату.
Поэтому окно досчёта **не единое**. Растягивать глубокий проход на месяц по
всем метрикам в минутном разрезе нельзя: тела запросов и так доходили до
42 МБ (находка 23), а месяц минутных данных — это десятки мегабайт на каждую
доставку. Вместо этого редкий широкий проход **только по ручным секциям**: их
единицы записей, и месячное окно там почти ничего не стоит.
Правило слияния одинаково для всех проходов, и порядок прихода значения не
имеет. Но «последние данные всегда актуализируют картину» — неверно и никогда
не было верным: при столкновении выигрывает более полная точка, а не последняя
пришедшая (см. «Разрешение столкновений»).
Автоматизации различимы по заголовку `automation-id`; имена стоит задать,
иначе `automation-name` приходит пустым (находка 12).
## Компоненты
| Пакет | Ответственность |
| ---------- | ------------------------------------------------------ |
| `config` | загрузка и валидация TOML-конфига |
| `logging` | сборка slog-логгера |
| `ident` | генерация и разбор ULID |
| `archive` | сырой архив: запись тела, чтение для reindex, ретеншен |
| `hae` | разбор формата HAE, канонизация, хеш содержимого |
| `ingest` | use-case приёма, общий для HTTP и CLI `import` |
| `fold` | свёртка одной доставки в часовые объекты |
| `replay` | проигрывание журнала в витрину: состав, порядок, отчёт |
| `store` | SQLite: доставки, часовые объекты, тренировки, записи |
| `httpapi` | приём и read API |
## Приём
```
запрос → токен → лимит тела, gzip → проверка формы JSON
→ запись тела в архив → строка в delivery → 200
фоновый воркер: разбор → запись в витрину
```
**Ответ отдаётся до свёртки, и это контракт, а не деталь реализации.** `200`
означает «тело сохранено и учтено»; разобрано ли оно, говорит
`delivery.parse_status`, и говорит позже. Причина измерена: свёртка 16 тысяч
точек занимает 11 секунд, а `WriteTimeout` в Go ставится в `readRequest` — то
есть до вызова обработчика — и потому является общим бюджетом на чтение тела,
запись архива, учёт и свёртку. Исчерпав его, сервер считает, что отдал `200`,
клиент получает обрыв, а `accessLog` пишет `status_code=200`: единственный канал
наблюдаемости врёт. Бьёт это по широким проходам — ровно по тем, ради которых
заведён инвариант «дыры закрываются сами».
Отсюда же второй бюджет: длинный дедлайн ответа выставляет **сам обработчик
приёма**, а не конфиг сервера. `write_timeout` глобален, и поднять его значило бы
снять защиту от застрявшей записи со всех маршрутов ради одного.
Код ответа определяется **доставкой**, не разбором:
- **400** — тело не разбирается как JSON ожидаемой верхнеуровневой формы.
Это проблема транспорта (обрыв, обрезанное тело), и отправителю о ней
надо сказать.
- **200** — тело сохранено в архив. Дальше даже полный провал разбора
(незнакомая метрика, новая форма точки) не меняет ответ: данные уже в
безопасности, исход разбора виден в логе, в `delivery.parse_status` и в
`/stats`, а доразобрать их можно командой `reindex`.
#### Очередь свёртки — таблица, а не структура в памяти
Доставка ждёт свёртки в собственном статусе `pending`; канал между приёмом и
воркером несёт один бит «есть работа». Это **transactional outbox**, он же «база
как очередь заданий»: состояние задания пишется той же базой, что и факт
события, а фоновый процесс выбирает необработанные строки.
Три следствия, ради которых так и сделано:
- **переполнять нечего** — доставка `pending` всегда, пока не свёрнута, поэтому
«очередь переполнена» невыразимо;
- **падение процесса очереди не теряет** — транзакция свёртки откатывается,
статус остаётся `pending`;
- **подбор `pending` при старте не является отдельным кодом** — это обычный
проход воркера, а не особый режим.
Отвергнут **канал идентификаторов в памяти**: он вводит второе, недолговечное
представление того же факта, и эти два расходятся при каждом падении; политика
переполнения всё равно требует подбора из базы, то есть того же кода — только в
двух экземплярах. Отвергнут и **опрос по таймеру вместо сигнала**: полпериода
задержки на каждую доставку без пользы. Тик при этом взят **в дополнение** к
сигналу: доставка, оставшаяся в очереди по обстоятельствам, иначе ждала бы
следующей доставки, а ночью телефон молчит часами.
Воркер один, и порядок у него тот же, что у пересборки — `(received_at, id)`:
слой доставки без плотных метрик наследуется от предшествующей доставки той же
автоматизации, то есть является функцией префикса журнала. Обещается достижимое:
в этом порядке сворачивается всё, что **видно воркеру** на момент выборки;
абсолютного порядка при конкурентных приёмах нет и быть не может без сериализации
самого приёма.
Классификацию исхода свёртки воркер и пересборка делят (`internal/replay`):
второй классификатор разошёлся бы с первым молча, а по одному из его счётчиков
(`partial`) принимается решение о судьбе тела в архиве.
**Исход разбора отражает доставку, а не обстоятельства.** Отмена и занятость
базы статус не меняют — доставка остаётся `pending` и будет свёрнута снова;
непонятое содержимое, невыводимый слой, нечитаемое тело, исчерпанный дедлайн и
паника свёртки дают `failed`. Различение появилось не из аккуратности: `failed`
из очереди выбывает навсегда и возвращается только пересборкой, а конкуренция за
базу между приёмом и свёрткой стала штатной — без него занятость стирала бы
доставку с полки молча. По той же причине учёт доставки идёт через транзакцию с
повторами: одиночная вставка пересиживала бы только `busy_timeout`, после чего
приём ответил бы `500` по доставке, тело которой уже на диске.
**Паника свёртки перехватывается там же, где пишется исход разбора.** Пока
свёртка шла внутри обработчика, панику ловил транспорт и стоила она одного
ответа; из фоновой горутины она валит процесс, а перезапуск берёт ту же доставку
первой — дефект одной доставки становится циклом перезапуска, при котором приём
не работает вовсе.
**Предел порядка назван вслух.** Метка приёма фиксируется раньше, чем строка
учёта становится видимой, поэтому две одновременные доставки могут закоммитить
строки в обратном порядке. Доставка без плотных метрик, свёрнутая раньше своей
предшественницы, слоя не выведет и уйдёт в `failed`: её точки доедут только
пересборкой. Окно узкое, и изменение его сужает, а не открывает, — но закрытие
предела требует удерживать порядок на самом приёме, и это отдельный вопрос
(беклог, блокеры).
Остановка формулируется **инвариантом**: приём прекращается раньше воркера, и
после остановки не существует доставки, которая числится разобранной, а записана
наполовину. Обещать «текущая доставка досворачивается» нельзя — бюджет остановки
(30 с) меньше бюджета свёртки (2 мин).
#### Частичный разбор
Разбор покрывает `metrics`, `workouts` и `stateOfMind`; `symptoms`, `ecg`,
`cycleTracking`, `medications` и `heartRateNotifications` проходят мимо. Живой
поток последних не приносил ни разу (118 доставок: 65 с метриками, 27 с
тренировками, 26 с состоянием разума), так что сегодня непокрытая секция —
редкость, а не половина потока, как было до покрытия сущностей.
Такая доставка получает статус `partial`, а имена непокрытых секций — колонку
`delivery.uncovered_sections`. Статус отвечает на вопрос «разобрано ли всё»,
список — «что именно осталось»; спрашивать полагается статус. Без этого
различения `parsed` означал бы «разобрано» и для доставки, из которой не
прочитано ни байта, а ретеншен, поверив ему, срезал бы тело — необратимо для
`stateOfMind`, которого в экспорте Apple нет.
Перечисление идёт **в том же проходе**, что и разбор метрик: значение
непокрытой секции проглатывается декодированием в выбрасываемый `RawMessage`,
поэтому копия одна, живёт до следующего члена и удерживается ноль (измерено:
тело 40 МиБ, из которых почти всё — непокрытая секция, удерживает 0 МиБ).
Пропуск ручным счётом глубины по токенам этого не даёт: делимитеры идут мимо
сканера, ограничитель вложенности `encoding/json` не работает, и тело из
вложенных скобок съедает память вместо отказа.
`partial` — не отклонение, а установившееся состояние, поэтому уровень лога от
него не растёт. Постоянный `WARN` каждые пять минут обесценил бы уровень.
**Правило для будущих задач: покрыли секцию — пересверните.** Список это снимок
покрытия на момент свёртки; доставки, свёрнутые до того, как секция стала
покрытой, останутся `partial` со старым списком, и ретеншен будет вечно щадить
ненужные тела. Задача, которая начинает разбирать секцию, тем же изменением
переводит `partial`-строки с этим ключом в `pending`. Так сделала миграция
`00007`, покрывшая `workouts` и `stateOfMind`.
**Следствие для ретеншена, названное вслух.** Пока `stateOfMind` был непокрыт,
его тела защищал сам статус `partial`. Теперь такая доставка получает `parsed` и
неотличима от доставки из метрик — а метрики восстановимы из экспорта Apple,
состояние разума нет (находка 46). Ретеншена в проекте нет, поэтому сегодня не
ломается ничего; но предусловие, которое задача ретеншена считала снятым, снова
открыто, и признак невосстановимости придётся завести отдельно от «непокрытости».
- **413** — тело больше допустимого. Граница стоит на **распакованном**
потоке, а не только на сжатом: `MaxBytesReader` поверх `r.Body` ограничивает
то, что приехало по сети, а в память попадает то, что из этого развернулось.
Измерено: 400 КиБ сжатого тела давали 400 МиБ и гигабайт выделений при
лимите в мегабайт. Потолок степени сжатия gzip около 1030:1, так что при
штатных 64 МиБ речь о десятках гигабайт на запрос, и параллельные
складываются. Цена отказа здесь наивысшая в проекте: приём — единственное
место, где поток вообще существует, и доставка, не попавшая в архив, не
попадает в журнал. Та же граница действует при чтении тела из архива —
иначе тело между двумя границами принималось бы с `200`, а потом вечно
валилось бы при каждой пересборке.
Причина такого разделения: неизвестно, шлёт ли HAE отклонённый пакет
повторно при периоде «Since Last Sync». Если не шлёт, строгий приём означал
бы дыру в истории. Многоуровневая синхронизация страхует тот же риск с другой
стороны — но полагаться только на неё нельзя: она чинит дыры за неделю, а не
за год.
## Хранилище
### Сырой архив и восстановление состояния
`raw/ГГГГ/ММ/ДД/<ulid>.json.gz` — тело запроса как пришло, не редактируется.
Два источника вместе образуют **полный журнал событий**, а хранилище —
свёртку по нему:
```
состояние = import(последний проверенный экспорт) ← снапшот всей истории
+ replay(доставки после его даты) ← хвост событий
```
Экспорт Apple — не просто «источник истины для нижнего слоя», а снапшот: он
содержит всю историю целиком (3.6 млн записей с 2019 года). Доставки HAE после
его даты — события поверх снапшота. Значит любое повреждение хранилища,
включая ошибку в нашем разборе любой давности, лечится пересборкой, а не
восстановлением из бекапа.
Отсюда три следствия, каждое из которых меняет реализацию.
**Срок жизни архива определяется циклом экспорта, а не календарём.** Прежние
14 дней были произвольным числом. Правильное правило: доставки хранятся **до
следующего проверенного экспорта**, иначе в журнале появится дыра между концом
ретеншена и датой снапшота. Цена измерена: поток даёт ~23 МБ архива в сутки,
то есть ~2 ГБ за квартал между экспортами. Это дёшево за возможность
пересобрать что угодно.
**Свёртка обязана быть детерминированной.** Проигрывание должно давать то же
состояние, что и приём в реальном времени. Слияние «выигрывает более полная
точка» коммутативно и порядка не требует; но когда две одинаково полные точки
несут разные значения, исход решает порядок — поэтому воспроизведение идёт
строго по `received_at`, а не по порядку файлов в каталоге.
**`reindex` и `import` — одна операция, а не две.** Восстановление это импорт
снапшота плюс проигрывание хвоста; отдельной «пересборки из архива» не
существует, она просто вырожденный случай с пустым снапшотом. Проигрывание
живёт в `internal/replay`; `healthlog import` добавит стадию снапшота **перед**
ним, а не заведёт вторую похожую операцию.
**Журналом считается архив, а не таблица доставок.** Перечислять строки
`delivery` значило бы пересобирать витрину из витрины. Тело может лежать в
архиве без учётной записи: приём кладёт его на диск раньше строки в базе
(обратный порядок дал бы учтённую доставку без данных), и отказ на вставке
оставляет тело без учёта — такое тело пересборка заводит заново, восстанавливая
метку приёма из ULID, а размер и хеш пересчитывая по распакованному телу.
Обратный случай — строка без тела — станет штатным вместе с ретеншеном и потому
считается, а не роняет прогон.
**Заголовков доставки в архиве нет**, и это named предел модели: они живут
только в `delivery`, поэтому пересборка читает рабочую базу, а полная потеря
базы деградирует вывод слоя навсегда. Закрывается это тем, что заголовки надо
класть в архив рядом с телом (так делает WARC) — отдельная задача беклога.
#### Пересборка идёт в отдельный файл, а подмену делает человек
Пересборка обязана начинаться с **пустой** витрины: точки из объекта не
удаляются никогда, поэтому проигрывание поверх накопленного оставило бы в ней
результат прежнего, неверного разбора — то есть не сделало бы того, ради чего
она существует.
Начать с пустой можно двумя способами, и выбран второй.
- **Очистить рабочую витрину и проиграть в неё же** — отвергнуто. Единственная
необратимая операция всей задачи (`DELETE FROM bucket`) выполнялась бы **до**
того, как станет известно, удалась ли пересборка; отказ на середине оставлял
бы витрину пустой наполовину в состоянии, неотличимом от нормального.
- **Собрать рядом и подменить** — взято. Это blue-green rebuild проекции,
стандартный приём event sourcing («вместо усечения существующей модели строим
новую в параллельном хранилище и переключаем чтение»); той же формы `_reindex`
с переключением алиаса в Elasticsearch и собственный `VACUUM INTO` SQLite.
Отказ становится бесплатным: рабочая база не тронута, промежуточный файл
удаляется.
- **Теневая таблица в той же базе** (`bucket_new` → переименование в
транзакции) — отвергнуто дважды. Имя `bucket` зашито литералом во весь слой
записи, то есть вариант требует параметризовать таблицей самый опасный код
проекта ради операции раз в полгода; и он не решает того, ради чего
затевался, — живой приём во время пересборки пишет в **старую** таблицу, и
при подмене его точки пропадают.
**Подмену рабочей базы делает человек, и это не лень.** Файл базы держит
открытым процесс сервиса, а переименование не касается уже открытого
дескриптора: процесс продолжит писать в отвязанный inode, читатели увидят новый
файл, данные разойдутся молча. Документация SQLite называет переименование
используемого файла прямой причиной порчи базы. Безопасная подмена требует
остановленного сервиса, а остановить его команда не может — сервисом управляет
окружение снаружи, и CLI, делающий вид, что управляет, обещал бы безопасность,
которой не обеспечивает. Поэтому команда печатает процедуру, а выполняет её
человек:
```
task down
healthlog reindex --config ./config.toml
mv ./data/healthlog.db.rebuild ./data/healthlog.db
rm -f ./data/healthlog.db-wal ./data/healthlog.db-shm
task up
```
Пересборка при этом **читает рабочую базу без наката миграций**: обычное
открытие мигрирует безусловно, а миграции меняют и данные (та, что ввела
частичный разбор, переписала `parse_status` у всех строк). Расхождение версии
схемы — отказ с указанием обеих, а не миграция под работающим сервисом.
**Оракул сходимости встроен в команду**: печатаются отпечаток рабочей витрины и
отпечаток пересобранной, снятые так, что первый берётся **до** проигрывания —
иначе под живым приёмом он движется, и ответ «разошлись» не значил бы ничего.
Пустой журнал при этом успехом не считается: отпечаток пустой витрины совпадает
с отпечатком пустой витрины, то есть выглядит идеальной сходимостью, а человек,
выполнивший напечатанную процедуру, заменил бы накопленное пустым.
Что пересборка **не** переносит: признак `sealed` (правила его выставления ещё
нет, переносить нечего) и производные от разбора поля учёта — `parse_status`,
`points`, `derived_layer`, `uncovered_sections`, `skipped_entities`. Перечень
пополняется **тем же изменением**, которое заводит новое поле: он единственное
место, где сказано, чему нельзя пережить пересборку.
Это не косметика. Доставка, чей повторный разбор отказал, отдала бы в
наследование слой прежнего разбора, и витрина снова стала бы функцией
предыдущего прогона, а не журнала. У числа пропущенных сущностей цена та же и
хуже: пустота у него означает «не измерялось», и перенесённое число выдавало бы
измерение прежнего разбора за измерение текущего — а по нему принимается
необратимое решение об удалении тела.
#### Что не восстанавливается, и это сказано вслух
Модель почти полна, но не полностью — умолчать об этом опаснее, чем признать.
**`stateOfMind` в экспорте отсутствует вовсе.** Проверено на свежем экспорте:
ни одного типа со словом `StateOfMind` (есть только `MindfulSession` — это
минуты осознанности, другое). Состояние разума живёт **только** в доставках
HAE. Значит для него доставки не хвост журнала, а единственный источник: либо
они не удаляются никогда, либо его история держится на самих сохранённых
записях и восстановлению не подлежит.
**Верхние слои за периоды с удалёнными доставками.** После проигрывания
снапшота у старого периода будет только слой `sample`; `minute` и `hour` за
него не воскреснут. Посчитать их вниз из `sample` технически можно — и нельзя
по инварианту: это была бы **наша** агрегация под видом присланной.
Поэтому правило: **восстановление не обязано быть побайтным, оно обязано быть
честным.** Каталог разрезов показывает, какие слои есть за какой период; после
пересборки старый период честно объявляет один слой вместо трёх, а не
притворяется, что ничего не изменилось.
### Устаревание нижнего слоя
Родной экспорт Apple Health точнее HAE (находка 34) и делается раз в 2–3
месяца. Данные HAE в нижнем слое старше последнего экспорта избыточны: тот же
период лежит в слое `sample` подробнее и честнее.
Два ограничения, без которых правило опасно:
**Пометка вешается по загруженному экспорту, а не по сделанному.** Условие —
экспорт разобран, и проверено, что он **покрывает период**: непрерывность по
дням и сходимость сумм с часовым слоем. Иначе срок жизни данных повисает на
ручной операции, которую можно забыть или сделать наполовину, — а этот
механизм уже протекал: «Since Last Sync» молча потерял 13 961 точку, включая
ночь сна целиком (находка 29).
**Чистится только нижний слой.** Разница между слоями — три порядка: `hour`
это ~100 координат в сутки, `minute` ~3 700, `raw` ~100 000 (находка 41).
Удаление верхних слоёв не экономит ничего, но ломает ответы на исторические
запросы. Всё давление по объёму создаёт нижний слой, и ровно там экспорт —
настоящее надмножество.
Пометка «устарело» **не равна удалению**: сперва данные помечаются и остаются
доступными, удаление — отдельный шаг с собственным сроком. Пока восстановление
из экспорта не проверено на живых данных хотя бы раз, удаление не включается
вовсе.
Оговорка: для секций, которых в экспорте нет (ЭКГ выгружается отдельными CSV,
судьба `stateOfMind` и лекарств не проверена), экспорт источником истины не
является и правило к ним неприменимо — их держим всегда.
**Это осознанная смена источника истины.** Пока тело в архиве, истина — оно;
после удаления истиной остаются часовые объекты. Инвариант, который держит
конструкцию: **объект хранит точки дословно**. Если разбор начнёт что-то
отбрасывать или нормализовать внутри точки, срок хранения архива станет
сроком жизни данных.
### Часовые объекты метрик
Точки метрик хранятся не по одной, а **пачками: один объект = одна метрика за
один час UTC**.
```
delivery(id, received_at, automation_name, automation_id, aggregation,
period, session_id, bytes, sha256, raw_path, parse_status, points,
headers, derived_layer, uncovered_sections, skipped_entities NULL)
bucket(metric, layer, hour_utc, units, payload BLOB, content_hash, points,
first_ts, last_ts, first_delivery_id, sealed, created_at, updated_at)
PK (metric, layer, hour_utc) WITHOUT ROWID
workout(id PK, name, start_utc, end_utc, tz_offset, duration_sec REAL NULL,
payload BLOB, content_hash, delivery_id, delivery_received_at,
created_at, updated_at)
INDEX (start_utc)
record(kind, id, ts_utc, tz_offset, payload BLOB, content_hash,
delivery_id, delivery_received_at, created_at, updated_at)
PK (kind, id) INDEX (kind, ts_utc)
```
Зачем пачками:
- **Строк на два порядка меньше** — 26 метрик × 24 часа = 624 объекта в сутки
вместо ~155 тысяч точек. За год 228 тысяч строк вместо 55 миллионов.
- **Дедупликация дешевеет во столько же раз.** Повторная доставка того же часа
— одно сравнение хеша вместо тысяч поисков по точкам. Это и делает широкие
проходы синхронизации почти бесплатными.
- **Хранение сжимается.** `payload` — gzip-BLOB: наблюдаемое сжатие такого
JSON — примерно 25 раз, то есть ~2 МБ в сутки вместо ~50 МБ. Цена: внутрь
объекта не заглянуть SQL-функциями, разбор только в приложении. Для
хранилища, которое отдаёт диапазоны точек, это не потеря.
### Слои гранулярности
Одна и та же метрика может приходить с разной подробностью: несуммированной,
минутной, часовой. Мы **не сводим их к одной** и не агрегируем сами — храним
разрезами и говорим клиенту, какие разрезы есть.
```
sample настоящие сэмплы HealthKit с интервалами start/end — только из
ручного экспорта Apple Health, HAE такого не отдаёт (находка 34)
raw метки на произвольной секунде heart_rate 00:02:07
minute метки выровнены на минуту heart_rate 00:02:00
hour метки выровнены на час heart_rate 00:00:00
```
Почему не переагрегируем **при записи**: правильный способ свёртки зависит от
метрики (сумма для энергии, среднее для пульса), и ошибка здесь необратима —
исходные точки уже не вернуть. При записи слои остаются раздельными всегда.
Свести их **в ответе** можно, и Read API это делает, — но род свёртки не
проставляется вручную, а **измеряется**: одна метрика лежит в минутном и
часовом разрезе одновременно, и если часовое значение сходится с суммой
минутных, метрика накопительная; если со средним — мгновенная. Форма точки
рода не выдаёт (`Avg`/`Min`/`Max` есть только у `heart_rate`), единицы дают
процентов девяносто и ломаются на краях — `six_minute_walking_test_distance`
в метрах складывать нельзя, а `walking_running_distance` в километрах можно
(находка 40). Где данных на сверку не хватило, род остаётся неизвестным и
агрегация по метрике не предлагается вовсе.
Отдельно: **нижний слой HAE не суммируется никогда.** Он не сэмплы, а
посекундная развёртка (находка 34) и в сверке не сходится — сумма по нему
даёт завышение. Накопительные метрики агрегируются только из `minute`,
`hour` или `sample`.
**Слой — это режим выгрузки, которым пришли данные**, а не измеренное
разрешение каждой метрики. Различие принципиально: частота метрик разная —
пульс идёт секундами, VO₂ max случается раз в неделю, — и выводить слой из
частоты значило бы дробить редкие метрики между слоями без всякого смысла.
Режим же общий для доставки, и редкая метрика просто наследует его.
**Определяется по данным, а не по заголовку.** `automation-aggregation`
непригоден: значение `Default` соответствует трём разным режимам сразу
(находка 31). Правило:
1. **Плотная метрика** (не меньше десяти точек в доставке) классифицируется
**сама по себе** по выравниванию своих меток — по **самому мелкому**
встретившемуся, а не преобладающему: метка ровно на часе одновременно
является и минутной, и у плотных метрик они перемешаны (`active_energy`
1320 минутных и 21 часовая). У десяти несуммированных точек шанс всем лечь
на ровную минуту исчезающе мал.
2. **Редкая метрика** (меньше десяти точек) наследует **преобладающий слой
доставки** — самый мелкий среди плотных. У неё выравнивание ничего не
доказывает, а Apple многие редкие показатели пишет прямо на границе часа.
3. Плотных метрик в доставке нет вовсе — слой наследуется от **предшествующей**
доставки той же автоматизации; если её не было, берём **надёжный** заголовок
(`Minutes``minute`, `Hours``hour`). Иначе точки не сохраняются вовсе:
молчаливый `raw` создал бы призрачный разрез, который поедет в каталог и в
правило Read API «самый мелкий слой, покрывающий диапазон».
Слово «предшествующей» в третьем пункте несёт вес: слой обязан быть функцией от
**префикса журнала**. Наследование от последней доставки вообще делает свёртку
зависящей от истории, и пересборка даёт не то состояние, что живой приём —
поймано прогоном архива, 1737 объектов против 1742 (docs/review-journal.md).
Классифицировать доставку целиком нельзя: при перенастройке автоматизации
приезжают **смешанные доставки**, где часть метрик уже минутная, а часть ещё
посекундная. Одна такая доставка, отнесённая к слою целиком, сложила минутные
точки с посекундными и удвоила сумму за час (находка 35).
Заголовок сохраняем и сверяем с выведенным; расхождение и смену режима у
автоматизации пишем `WARN` — так видна перенастройка, а не тихий дребезг.
Почему не по метрике отдельно (проверено на живых данных, находка 33): одна
доставка законно содержит метрики разной подробности — `apple_stand_hour`
почасовой по своей природе, `sleep_analysis` в минутном режиме превращается в
суточный агрегат на `00:00:00`, а `heart_rate` рядом с ними идёт с секундной
точностью. Классификация каждой по отдельности растащила бы одну выгрузку по
трём слоям.
**Исключение — `sleep_analysis`.** Под этим именем HAE шлёт две несовместимые
схемы: поэпизодную (`start`/`end`/`value`/`qty`) и суточную сводку
(`totalSleep`/`core`/`rem`/`deep`/`awake`, метка на местной полуночи). Общих
полей, кроме `date` и `source`, у них нет, источники тоже разные — эпизоды от
стороннего приложения, сводка от часов (находка 38). Правило выравнивания на
сводке даст `hour`, хотя это суточный итог, а не часовой разрез. Поэтому в
каталоге они разводятся на два имени — `sleep_analysis` и
`sleep_analysis_summary`, — и слой у сводки не выводится, а фиксирован как
`day`. Хранение остаётся дословным: разводятся имена, а не содержимое.
Пересборка применяет к уже разобранному **исправленный** разбор — это и есть
причина держать сырой архив. Точнее она именно этим, а не тем, что видит более
длинный ряд: слой обязан оставаться функцией **префикса** журнала, и наследование
«от последней доставки вообще» уже ловили дефектом (1737 объектов против 1742,
`docs/review-journal.md`).
Следствие: **пересечение наборов метрик между автоматизациями перестаёт быть
проблемой**. Минутный и несуммированный `heart_rate` наполняют разные слои и
не смешиваются в одном ряду; если же две автоматизации шлют одну метрику с
одинаковой гранулярностью, это честный дубликат, и его схлопывает хеш.
Слои считаются **вниз, но не вверх**: из `raw` получается `hour`, обратно —
нет. Поэтому самый мелкий слой стоит держать, пока он не станет дорог; цена
измерена — около 730 МБ в год против 20 МБ у минутного. Страховка на случай,
если мелкий слой всё-таки выключат: **ручной экспорт из Apple Health**
восстанавливает нижний слой целиком через `healthlog import`.
**Идентичность точки — координаты, а не содержимое.**
```
ключ: метрика + слой + начало + конец конец = начало, если end нет
значения: qty / Min / Avg / Max / source / … ← перезаписываются
```
**Ключ — интервал, а не метка.** Метка на записи сна не уникальна: под одним
`date` лежит до трёх записей, и это не дефект, а способ Apple выразить
вложенность «в кровати» и фазы внутри неё. Измерено на всём корпусе (находка
47): 174 координаты против 170 по метке, ноль столкновений против 33 **внутри
одной доставки**, где тай-брейк по времени приёма неприменим в принципе.
`value` в ключе ничего не добавляет.
Форма ключа **одна для всех точек**. Интервалы несут 22 метрики, а не только
сон; `start`, когда он есть, всегда равен `date`; обе формы точки не
смешиваются внутри метрики одной доставки. Поэтому отдельного класса «эпизодных
метрик» нет — нечего выводить и нечего поддерживать в каталоге и Read API. Час
объекта берётся по началу, иначе принадлежность объекту зависела бы от
длительности.
`HKObject.uuid` дал бы идентичность даром, но в выгрузку Apple он не попадает —
там у записи только `type`, `sourceName`, `sourceVersion`, `creationDate`,
`startDate`, `endDate`, `value`. Значит модель обязана выражаться через
`start`/`end`, иначе `import(экспорт)` не сойдётся с `replay(HAE)`.
Мы дважды пробовали адресовать точку хешем её содержимого и дважды получали
задвоение на живых данных:
- **числа сериализуются нестабильно** — 45 507 из 71 730 повторно приехавших
точек различались последним разрядом double (`0.09523182962471353` против
`…52`), то есть 63% повторов выглядели новыми (находка 30);
- **`source` нестабилен** — то же измерение с тем же значением приезжает то
как `Apple Watch Ultra 3|iPad (Anton)`, то как `Apple Watch Ultra 3`:
Health переосмысливает атрибуцию задним числом. Минутный слой за 31 июля
оказался задвоен целиком, 120 точек в часе вместо 60 (находка 36).
Хеш при этом остаётся — но как **детектор изменений**, а не как ключ: совпал с
сохранённым, значит писать нечего. Считается он по канонической форме с
рекурсивной сортировкой ключей и округлением чисел до ~12 значащих цифр
(иначе, см. выше, «изменилось» будет срабатывать всегда).
Объект целиком тоже адресуется хешем — им сравниваются часовые пачки, чтобы
широкий проход не переписывал неизменившееся.
**Запись — слияние, а не вставка.** Приход новых точек за уже существующий час
означает: прочитать объект, влить точки (объединение по хешу точки),
отсортировать по времени, записать обратно. Точки из объекта не удаляются
никогда.
**`sealed`** отмечает часы, которые уже не должны меняться (старше окна
досчёта). Изменение запечатанного объекта — не отказ, а **сигнал**: пишем
`WARN` и всё равно сохраняем. Так мы узнаём реальную глубину досчёта из
эксплуатации, а не из предположений.
#### Разрешение столкновений
По одним координатам приезжают разные содержимые: 2 897 случаев из 444 256
координат, 0.65% (находка 49). Выигрывает **более полная** точка, и полнота —
это сравнение **множеств** ключей с непустым значением, а не их числа.
Число сравнимо всегда и потому отвечает там, где ответа нет: точка
`{"qty":0,"a":0,"b":0,"c":{},"d":[]}` несла «пять значащих полей» против двух у
настоящего измерения и стирала его безвозвратно. Множества дают три исхода
вместо одного — надмножество, равенство, несравнимость, — и только первый
означает «полнее».
Пусто — `null`, пустая строка, нулевое число, пустой объект и пустой массив;
`false` содержателен (`isIndoor: false` — тренировка на улице). Считается по
разобранному значению, а не по байтам: иначе `0.0` и `{ }` прошли бы как
содержание. `source` не участвует — он нестабилен.
Надмножество побеждает только тогда, когда **несёт то же содержание**: значения
общих содержательных ключей должны совпасть. Иначе точки несут разные
измерения, и надмножество имён о полноте не говорит ничего — пара уходит в
тай-брейк. Без этого условия `{date, qty:0.001, p1:null, p2:null}` вытесняло бы
`{date, qty:72.5}`, то есть точка без единого измерения стирала бы измерение.
Разрядов сравнения два: сперва ключи с содержанием, при их равенстве (и
совпадении значений) — все ключи. Второй разряд бережёт поля, которые не несут
содержания, но и теряться не должны: `{date, qty:10, Min:0, Max:0}` не
проигрывает `{date, qty:10}` по жребию. Несравнимость на втором разряде исходом
не является: лишние ключи там заведомо пусты, объединять в них нечего.
**Победитель — функция множества точек, а не порядка их поступления.** Попарная
свёртка этого не даёт: полнота — частичный порядок, тай-брейк — тотальный, и
вместе они образуют нетранзитивное отношение победы, то есть цикл. При цикле
повторная свёртка одной и той же доставки меняет содержимое объекта, и витрина
перестаёт быть свёрткой журнала. Поэтому кандидаты координаты собираются
вместе: отбрасываются превзойдённые по полноте, среди оставшихся берётся
минимум по каноническому порядку. Обе операции зависят только от состава
множества.
**Несравнимые множества не сливаются, а считаются.** Объединение полей — самая
дорогая часть правила — на живом потоке не потребовалось ни разу (0 из 2 897),
поэтому вместо реализации стоит счётчик и `WARN` с координатами объекта. Если
событие наступит, оно будет видно, а не додумано заранее.
**Тай-брейк при равной полноте не выбран.** Сегодня это порядок канонических
форм, и он измеримо смещён: в 96% случаев берёт меньшее значение. Правильный
выбор зависит от рода метрики, а род измеряется сверкой слоёв между собой —
значит он и станет известен точно, вместо того чтобы быть угаданным.
### Категориальные значения
HAE отдаёт перечислимые значения строками из локали телефона, а не кодами:
фаза сна приезжает как «БДГ», контекст пульса — как «Сидячий образ жизни», тип
тренировки — как «В помещении Ходьба» (машинная калька с `Indoor Walk`). При
этом `stateOfMind` в том же пакете шлёт честные коды HealthKit
(`momentary_emotion`, `slightly_pleasant`, `drained`) — значит дело не в
приложении, а в том, что старые секции тянут строки из UI (находка 37).
Оставить как есть нельзя по трём причинам, и третья решающая:
1. Клиент вынужден угадывать словарь вместо того, чтобы сравнивать с кодом.
2. Смена языка телефона молча расколет историю: та же фаза сна станет другим
значением, и по координатному ключу это неотличимо от изменения данных.
3. **Родной экспорт Apple говорит кодами** (`HKCategoryValueSleepAnalysisAsleepREM`).
Он объявлен источником истины, и на нём держится ретеншен нижнего слоя —
но сверить покрытие по этим полям было бы нечем.
Поэтому строка **хранится дословно, а рядом кладётся выведенный код**:
```
value "БДГ" ← как прислал HAE
value_code "HKCategoryValueSleepAnalysisAsleepREM" ← выведено по словарю
```
Словарь ключуется парой `(локаль, строка)`; локаль берётся из
`Accept-Language`, который мы уже сохраняем (находка 32). Для незнакомой
строки код пустой — пустота честнее догадки, и она же видна в `/stats` как
список того, что пора добавить в словарь.
Дословность инварианта не нарушена: код **приписывается**, а не подменяет
строку. Обратное преобразование всегда возможно.
### Тренировки и прочие секции
Тренировка адресуется своим `id` из HealthKit, запись — парой `род + id`.
`record` держит секции с собственными идентификаторами; разбором покрыт пока
только `stateOfMind`, а `ecg`, `symptoms`, `cycleTracking`, `medications` и
`heartRateNotifications` остаются непокрытыми **намеренно**: живой поток не
приносил их ни разу, их формы никто не видел, а полнота покрытия HealthKit ради
полноты целью проекта не является. Модель под них заложена: **миграции схемы**
новая секция не требует — она добавляется строкой в множество покрытых имён.
Но не одной: правило «покрыли секцию — пересверните» (выше) требует ещё и
data-миграции, переводящей уже принятые `partial`-доставки с этим ключом в
`pending`, а после появления ретеншена — строки в перечне невосстановимого.
Три места, и первое из них — не самое важное.
Ключ записи — **пара**, а не один `id`: собственный `id` наблюдался живьём
только у `stateOfMind`, где он UUID, и короткий несквозной идентификатор в двух
разных секциях затёр бы одну запись другой молча.
Пачками они не хранятся: у них есть естественный ключ, они редки, и
группировать их по часам незачем.
**Тренировка не разворачивается.** Заголовок — колонками, всё остальное,
включая маршрут и внутренние ряды, — блобом `payload`. Структура тренировки
разнородна и избыточна (сводки дублируют ряды, находка 15); раскладывать её в
таблицы значило бы решить за Apple, что в ней главное. Колонок ровно столько,
сколько нужно выборке: имя, интервал, офсет зоны, длительность. Длительность
берётся из тела, а не считается как `end - start` (HAE шлёт 91.746 при
интервале в 91 секунду), и её отсутствие выражается пустотой, а не нулём — ноль
законная длительность.
**Пульс приедет дважды** — в общем потоке метрики `heart_rate` и внутри
объекта тренировки. Это ожидаемо, они лежат в разных таблицах и не
смешиваются.
**Значение заголовка не того типа стоит одного поля, а не сущности.** Пять полей
(`id`, `name`, `date`, `start`, `end`) читаются мягко: нестроковое значение
считается неприсланным. Иначе `name`, приехавшее числом, уносит тренировку
вместе с маршрутом, а доставка при этом числится разобранной. Мягкость сделана
через `json.Unmarshaler`, а не через разбор ошибки типа постфактум: библиотека
дозаполняет поля «как может», но не обязуется дозаполнить те, что стоят **после**
проблемного, — то есть исход перестал бы быть функцией тела.
Исключений два, и оба названы. `id`: без строкового идентификатора сущность не
адресуема, а приведение чужого значения к строке было бы выдумыванием
идентичности за источник. `start`: непонятое значение не откатывается на `date`
подстановка другого поля дала бы метку **другого момента времени**, неотличимую
от настоящей и ничем не считаемую.
**Граница правила: оно закрывает смену типа, но не смену формата строки.** А
наблюдался именно дрейф формата дат. Тренировка с датой в незнакомом формате
по-прежнему теряется целиком; закрыть это может только хранение сущности с
неразобранной меткой, и это отдельная задача. Пропуск при этом перестал быть
невидимым: число пропущенных сущностей лежит в учётной записи доставки, и
ретеншен, решающий «что потеряется, если тело удалить», больше не получает
ложное «терять нечего». Отсутствие значения в этой колонке означает «не
измерялось» и нулю не равно.
#### Замена версии сущности
«Перезаписывается» уточнено измерением. Тренировка приезжает повторно каждой
доставкой, пока источник её досчитывает: на живом архиве одна тренировка
приехала 26 раз в трёх различных содержимых — сперва добавились `stepCadence` и
`stepCount` вместе с изменившимся рядом `activeEnergy`, затем при том же наборе
полей досчитались `totalEnergy` и `basalEnergy`. То есть тренировка правится
задним числом ровно так же, как минутное ведро (находка 10), а набор её полей
за весь корпус ни разу не уменьшился.
Правило:
```
1. каноническая форма совпала с сохранённой → записи нет (хеш-детектор)
2. приехавшая несёт всё содержание сохранённой
и сверх того → приехавшая замещает целиком
3. приехавшая теряет содержание сохранённой → остаётся сохранённая,
счётчик + WARN
4. содержание равно → версия из более поздней
доставки ЖУРНАЛА
5. наборы несравнимы → остаётся сохранённая,
счётчик + WARN
```
**Содержание сравнивается множествами ключей и формой их значений — но не
значениями.** Правило полноты, принятое для точек, здесь неприменимо, и это
проверено выполненной командой: оно гасит отношение включения до «равенства»,
когда значения общих ключей разошлись, — а у сущности они расходятся всегда.
Обеднённая версия получила бы «равенство» и заместила бы сохранённую вместе с
маршрутом, причём тест на фикстуре с неизменёнными значениями остался бы
зелёным.
Условий покрытия четыре, все по **верхнему уровню**:
```
1. каждый содержательный ключ сохранённой есть у приехавшей и содержателен
2. каждый ключ сохранённой, даже пустой, есть у приехавшей
3. форма не вырождается: объект остаётся объектом, массив — массивом
4. верхнеуровневый массив не теряет ни длины, ни содержательных элементов
```
Условие 2 — тот же второй разряд, что у точек, и с тем же **условием**: оно
включается только при равенстве множеств содержательных ключей. Иначе ключ с
пустым значением исчезает по жребию тай-брейка — но и обратная крайность
проверена оракулом и отвергнута: безусловный второй разряд запирал законный
досчёт навсегда. Версия с `totalEnergy: null` и без маршрута оказывалась
несравнимой с версией, у которой маршрут приехал, а этого ключа нет, — и
маршрут не доезжал **никогда**, причём пересборка проигрывала то же поражение.
Второй разряд разрешает спор равных, а не отменяет первый.
Условие 3 закрывает «скелет»: тело, где каждый вложенный объект заменён числом,
а каждый массив — массивом той же длины из `null`, проходило все прежние
проверки и по тай-брейку журнала замещало настоящую тренировку целиком.
Условие 4 добавлено потому, что усечённый маршрут (три точки вместо 593) ключа
не теряет, а маршрут из `[null,null,null]` не теряет и длины — притом что
маршрут это 95% веса тренировки. Содержательность элемента — **та же пустота**,
что у поля точки; второй словарь пустоты дал бы два ответа на один вопрос. Цена
названа вслух: ряд настоящих нулей (`[0,0,0]`) считается лишённым содержания, и
версия с ним сохранённую не заместит. Ошибка направлена в безопасную сторону —
правило удерживает, а не затирает, — и видна счётчиком.
Условия 3 и 4 применяются к ключам, содержательным у сохранённой: у пустоты
формы нет, и требовать её сохранения значило бы отличать `[]` от `0` там, где ни
то, ни другое ничего не несёт.
Поле `source` в множества не входит — ни у точки, ни у сущности. Для точки
причина измерена (оно нестабильно и переписывается задним числом, находка 36);
для сущности она наследуется, и это сказано вслух, потому что список исключений
живёт в общем разборе: правка ради точек молча изменит правило удержания
сущностей. Верхнеуровневого `source` ни у тренировки, ни у `stateOfMind` живьём
не наблюдалось.
**Предел правила назван вслух и не закрывается: сокращение внутри элемента ряда
(точка маршрута без `altitude` при непустом элементе и той же длине) не ловится
ничем, кроме сверки с телом в архиве.** Поэлементная сверка содержимого
отвергнута ценой: она разворачивала бы каждый элемент маршрута в дерево
значений на каждое сравнение, а тело 40 МиБ уже даёт 768 МиБ пика.
**Проверить это правило отпечатком нельзя.** Живой приём и пересборка пользуются
одним правилом и одинаково сойдутся на одинаково удержанной версии — то есть
слишком строгое правило, замораживающее тренировку на старой версии, выглядело
бы идеальной сходимостью. Поэтому число удержаний идёт в отчёт пересборки и
печатается всегда, включая ноль: здесь ноль это утверждение, а не отсутствие
новостей.
**Тай-брейк при равном содержании — позиция доставки в журнале
`(received_at, id)`, а не порядок свёртки.** Напрашивавшееся «побеждает
приехавшая» отвергнуто: приехавшая есть функция порядка свёртки, а он порядку
журнала не равен (см. «Предел порядка назван вслух»). Доставка с более ранней
меткой, свёрнутая позже, вернула бы витрину к недосчитанной версии, и
пересборка разошлась бы с живым приёмом **молча** — в содержимом тренировки, где
это не видно ничем, кроме отпечатка. Поэтому сущность несёт провенанс:
доставку своей версии и её метку приёма. Тай-брейк по канонической форме (как у
точек) отвергнут по другой причине: он заморозил бы тренировку на произвольной
из версий навсегда, вместе с недосчитанной энергией.
Провенанс поднимается **и при совпавшем хеше**. Совпал хеш — содержимое то же,
писать нечего; но сохранённая позиция журнала участвует в тай-брейке пункта 4, и
если в ней осталась первая свёрнутая копия вместо победителя журнала, отложенная
доставка вернёт витрину к прежнему содержимому — то есть живая витрина
разойдётся с пересборкой молча. Обновляется только провенанс: метка изменения
содержимого не двигается, иначе она становится меткой касания строки и дребезжит
двадцать шесть раз на неизменившейся тренировке, а запрос «что изменилось с
момента X» получает шум, неотличимый от настоящего досчёта.
Слово «провенанс» у сущности и у часового объекта значит **разное**, и это
сказано вслух: у объекта хранится доставка, **создавшая** его, и она не
поднимается никогда; у сущности — доставка, **чья версия лежит сейчас**, и она
поднимается до максимума по журналу среди версий с этим содержимым. У объекта
нет замещения версии целиком, у сущности только оно и есть.
Версии одного ключа **внутри одной доставки** позициями не различаются, и
победитель среди них — **функция множества**, а не порядка элементов массива:
отбрасываются строго покрытые (покрыта другой и сама её не покрывает —
покрытие предпорядок, и наивное «выбросить всё покрытое» опустошило бы
множество), среди оставшихся берётся минимум канонической формы, а при равных
формах — минимум исходных байтов. Последний разряд не украшение: у сущностей
версии с равной формой не схлопываются, а порядок ключей в JSON от HAE
нестабилен — без него в витрину легли бы разные байты при одинаковом содержимом.
Механизм тот же, что у точек, и живёт он одним помощником на обе единицы
хранения: попарная свёртка здесь уже давала нетранзитивную победу, при которой
`[A,B,C]` и `[B,C,A]` выбирали разных победителей.
Отвергнут и **голый upsert по `id`** (так делает сервер HealthyApps поверх
MongoDB, и так просилось из слова «перезаписывается»): единственный наблюдённый
сценарий повторной присылки — рост, но маршрут стоит 95% содержимого, а
восстановление требует пересборки всего журнала. Условие пункта 3 стоит одного
сравнения множеств и делает событие наблюдаемым вместо необратимого.
Остаточный предел назван вслух: сравнение сохранённой с приехавшей попарно —
в витрине лежит победитель прошлых слияний, а не все кандидаты истории, —
поэтому при несравнимых наборах (пункт 5) исход зависит от порядка
проигрывания. Тот же предел есть у часового объекта. **Это единственная точка,
где витрина не является функцией множества доставок**, и потому утверждение
«перестановка порядка свёртки даёт один отпечаток» верно ровно при нулевом
счётчике несравнимых версий; при ненулевом расхождение законно и обязано идти
вместе с этим счётчиком.
Второй разряд условия покрытия делает пункт 5 чаще, чем он был: версия, принёсшая
новые содержательные ключи и потерявшая пустой, теперь несравнима вместо
«полнее». Плата принята сознательно — она направлена в сторону удержания, а не
затирания, — и её величину показывает счётчик удержаний в отчёте пересборки.
#### Отпечаток и отчёт пересборки идут за витриной
Отпечаток покрывает **все** единицы хранения и снимается одной транзакцией
чтения: отпечаток одних часовых объектов давал бы «состояние сошлось» при
разъехавшихся тренировках, а три запроса вне общей транзакции под живым приёмом
дали бы смесь снимков и ложное «разошлись». Отчёт `reindex` считает «было и
стало» по каждой единице и называет «покрыта новая секция» ожидаемым классом
расхождения — иначе первый прогон после такого изменения расходится
гарантированно, а человек читает это как дефект.
#### Предел, который придётся закрыть импортом
В `export.xml` у элемента `Workout` идентификатора нет вовсе —
`dogsheep/healthkit-to-sqlite` поэтому адресует тренировку **хешем содержимого**
(`hash_id` в sqlite-utils). Значит `import` снапшота задвоит тренировки,
приехавшие от HAE: та же дыра, что у точек, где её закрыли ключом
`start + end`. Сегодня импорта нет, и решать это до его формы значило бы
угадывать; предел записан в беклоге отдельной задачей.
### Время
Точка внутри объекта хранится дословно, вместе с исходной строкой даты. Для
адресации и выборок используется нормализованное время: `hour_utc` у объекта,
`ts_utc` + офсет исходной зоны у записей с собственным ключом. Офсет нужен,
чтобы клиент мог считать сутки и по UTC, и по местному времени: без него
суточные ряды незаметно поехали бы после смены часового пояса.
**Формат даты зависит от секции пакета**: метрики и тренировки шлют
`2026-07-31 21:03:51 +0300`, `stateOfMind` — RFC 3339 в UTC (`…T18:03:51Z`).
Одного парсера недостаточно (находка 16).
## Read API
```
GET /api/v1/metrics каталог: имя, units, род, слои с диапазонами
GET /api/v1/metrics/{name}?from&to&bucket&layer точки метрики, при желании свёрнутые
GET /api/v1/workouts?from&to заголовки тренировок
GET /api/v1/workouts/{id} тренировка целиком, с маршрутом
GET /api/v1/records/{kind}?from&to прочие секции
GET /api/v1/schema схемы всего, что есть в хранилище
GET /api/v1/metrics/{name}/schema схема и статистика одной метрики
GET /stats последняя доставка, счётчики, тишина по потоку
GET /healthz
```
Хранение пачками на контракт не влияет: `GET /metrics/{name}` собирает ответ
из часовых объектов, попавших в диапазон, и отдаёт точки. Клиент про объекты
не знает — это деталь хранения, а не API.
**Слои, наоборот, часть контракта.** Каталог показывает, какие разрезы есть и
за какой период:
```json
{"metric": "heart_rate", "units": "count/min", "aggregation": "instant",
"layers": [
{"layer": "raw", "from": "2026-07-30", "to": "2026-08-01", "points": 2078},
{"layer": "minute", "from": "2026-07-25", "to": "2026-08-01", "points": 14203}
]}
```
`aggregation` — измеренный род (`cumulative` / `instant` / `unknown`), от него
зависит, что вообще можно спросить.
Параметр `layer` выбирает разрез. Если он не указан — берём **самый мелкий
слой, покрывающий весь запрошенный диапазон**. Молча переключать слой на
границе периода нельзя: ряд поедет незаметно для клиента.
### Свёртка и размер ответа
Запросов к метрике ровно два, и это один запрос с необязательным параметром:
```
?from&to все значения за период вес, лекарства, симптомы
?from&to&bucket=day значения с разбивкой шаги, энергия
```
Главный потребитель — агент, у которого ограничен контекст. «Пульс за неделю»
без разбивки — это десятки тысяч точек в минутном слое и сотни тысяч в
нижнем. Правило:
- **Разбивка не задана, ответ не влезает** — сервер сам берёт сетку погрубее,
чтобы влезло, и называет её в ответе. Агент всегда получает ответ и может
переспросить уже.
- **Разбивка задана явно, ответ не влезает** — это ошибка, а не тихая
подмена. В теле ошибки — число точек по каждой доступной сетке, чтобы
второй запрос был заведомо успешным.
Различие существенно: «указали уровень» работает как информация в первом
случае и как защита во втором. Иначе агент, попросивший минутную сетку,
получил бы суточные суммы и заметил бы это, только прочитав поле, которое
вполне может не прочитать.
Свёртка применяет род из каталога: `cumulative` — сумма, `instant`
среднее с `min`/`max` рядом. При `unknown` свёртка не выполняется, а параметр
`bucket` отвергается ошибкой. Накопительные метрики никогда не сворачиваются
из нижнего слоя HAE — только из `minute`, `hour` или `sample`.
### Форма ответа
Нормализованная оболочка, сырое содержимое:
```json
{"layer": "minute", "bucket": "hour", "aggregation": "sum",
"points": [
{"ts": "2026-07-31T09:00:00Z", "tz_offset": 10800, "units": "count",
"values": {"qty": 812}}
]}
```
`layer`, `bucket` и `aggregation` присутствуют всегда, даже когда свёртки не
было (`"bucket": null`): клиент не должен выводить их наличием или
отсутствием поля.
Время приведено к единому виду, значения отданы как пришли: ни
переименований, ни пересчёта единиц. Метрик у Apple много и они разные —
семантику разбирает клиент по имени метрики. Полная нормализация означала бы,
что каждая новая метрика требует правки коллектора, а незнакомая теряется.
### MCP
Поверх Read API — адаптер MCP, чтобы агент подключался без промежуточного
кода. Инструментов ровно два, по числу форм запроса выше, плюс каталог.
Собственной логики в адаптере нет: он переводит вызовы в те же обработчики.
**Транспорт — HTTP** (Streamable HTTP), не stdio: сервис живёт на VPS, и агент
ходит к нему по сети. Отсюда следствия:
- MCP — это **эндпоинт того же процесса**, а не отдельная подкоманда: тот же
бинарь, тот же порт, тот же Caddy впереди с TLS.
- Аутентификация — **тот же токен чтения** в `Authorization: Bearer`, что и у
Read API. Отдельного контура доступа не заводим: MCP не даёт ничего, чего
не даёт HTTP, и права у них обязаны совпадать.
- Правило размера ответа (см. выше) здесь не украшение, а необходимость:
сетевой агент не имеет возможности «посмотреть поближе» иначе, чем
повторным вызовом.
## Самоописание
Сервис описывает свои данные сам: клиент (в том числе AI-агент) не должен
угадывать структуру по выборке — он запрашивает схему и сразу знает, что
лежит в наборе. Слоя два.
**Схема контракта** — форма конверта, который отдаёт API (`ts`, `tz_offset`,
`units`, `values`, заголовок тренировки, ошибка). Наша, статичная, пишется
руками.
**Каталог разрезов** — какие слои есть у метрики и за какие периоды. Отвечает
на вопрос «что вообще можно спросить», прежде чем клиент спросит.
**Схема содержимого** — что лежит внутри `values` у конкретной метрики.
**Выводится из данных**, а не ведётся вручную: метрик у Apple больше сотни, и
рукописный каталог описывал бы документацию HAE, а не то, что он реально
прислал. Выведенная схема производна ровно так же, как витрина: считается тем
же проходом разбора, инкрементально при приёме и целиком при `reindex`.
Незнакомая метрика описывает себя сама, без релиза.
Схема отдаётся **вместе со статистикой** — для потребителя она важнее
формального типа:
```json
{"metric": "heart_rate", "points": 412355,
"first_ts": "2019-03-02T…", "last_ts": "2026-07-31T…",
"units": ["count/min"],
"fields": {"Min": {"type": "number", "presence": 1.0},
"Avg": {"type": "number", "presence": 1.0},
"Max": {"type": "number", "presence": 1.0}}}
```
Вывод ограничен по глубине вложенности — иначе схема тренировки с маршрутом
разрослась бы до размеров самих данных. Форма точки маршрута при этом
описывается: блоб трека не непрозрачен, это массив однотипных объектов.
## Аутентификация
Статический токен в заголовке `Authorization: Bearer …`; список допустимых
токенов — в конфиге. HAE умеет слать произвольные заголовки, этого достаточно.
Токены **раздельные**: на запись (приём) и на чтение. Клиент, читающий
данные, не может писать. MCP пользуется токеном чтения — отдельного контура
у него нет, см. «MCP».
Наружу открыты два контура: приём (телефон) и чтение вместе с MCP (агенты и
приложения). Оба через Caddy с TLS, оба с разными токенами.
## Деплой
VPS **rivendell** (Timeweb), доступен всегда. Перед сервисом — **Caddy**, он
терминирует TLS; сам сервис слушает plain HTTP. Приём открыт наружу на
отдельном поддомене — телефон должен доставать до него из любой сети, иначе
экспорт копится и уезжает пачкой при возвращении домой.
Сборка — на локальной машине: статический бинарь и docker-образ; на сервер
едет готовый образ. Go-тулчейн на сервере не нужен.
Тома: каталог сырого архива и файл SQLite — на постоянном хранении, конфиг
(с токенами) — отдельно, `0600`.
**Откат бинаря поверх новой схемы отказывает на старте.** Версия схемы базы выше
версии, вшитой в бинарь, — отказ, а не повод мигрировать; в контейнере это
выглядит циклом перезапуска, и лечится возвратом бинаря вперёд. Версию читает
сам goose (`Provider.GetVersions`), а не собственный запрос: имя таблицы учёта и
правило «максимум = текущая версия» принадлежат ему, и рукописная копия
разошлась бы при обновлении зависимости — причём не отказом, а тем, что страж
перестал бы ловить.
Цена названа вслух, потому что она реальна: пока сервис не поднят, приём не
работает, а доставка, не попавшая в архив, в журнал не попадает вовсе — телефон
её не перешлёт. Выбор сделан так потому, что откат это действие оператора,
который в этот момент рядом и видит отказ немедленно, а дыры плотных метрик за
время простоя закроют широкий и глубокий проходы синхронизации. Не закроют
`stateOfMind`: у него доставки HAE единственный источник — это и есть цена
решения. Она меньше цены молчания: старый бинарь поверх новой схемы стартовал
бы успешно, незнакомые секции игнорировал и доставки за всё окно отката помечал
разобранными, а узнать об этом было бы неоткуда.
Открытие базы **только на чтение** (`reindex`, утилиты учёта) остаётся строгим:
там отказ даёт любое расхождение версий, включая базу старее бинаря — читать
колонки, которых ещё нет, нечем. База без журнала миграций отвергается сразу и
структурным вопросом к `sqlite_master`, а не через сам goose: тот при отсутствии
таблицы идёт её создавать, и на соединении «только чтение» это три секунды
повторов и ответ про права на файл вместо ответа про версию. Асимметрия только у
открытия с накатом.
**Понижение схемы не поддерживается: откат — только вперёд.** Подкоманды
миграции у бинаря нет, `goose` CLI в образ не кладётся, `-- +goose Down` в
миграциях существует для локальной разработки и на рабочей базе не исполнялся ни
разу. Значит после наката новой схемы возврат прежнего бинаря приёма не чинит —
чинит только выкатка вперёд. Это цена стража, названная целиком; чем её
смягчать, решает отдельная задача беклога.
## Открытые вопросы
- Механизм доставки образа и запуска на rivendell (compose руками / плейбук).
- **Предел размера ответа** — в точках или в оценке байт. Точки считать
проще, но у `heart_rate_variability` с `heartbeatSeries` точка на два
порядка тяжелее, чем у `step_count` (находка 39).
- **Хранить ли `heartbeatSeries` целиком.** 93% объёма HRV ради данных,
которых нет ни в одном из планируемых запросов.
- **Есть ли `stateOfMind`, симптомы и лекарства в родном экспорте.** От этого
зависит, применимо ли к ним устаревание нижнего слоя.
- **Как проверять покрытие экспортом** — до какой строгости. Непрерывности по
дням и сходимости сумм, вероятно, хватит, но порог не выбран.
- **Хранилище под аналитику.** Сейчас SQLite: часовые объекты дают ~260 тыс.
строк на слой в год независимо от плотности точек, а плоская таблица по
грубым слоям (`hour` ~36 тыс. строк в год, `minute` ~1.4 млн) делает
`GROUP BY` дешёвым без разжатия блобов. DuckDB рассматривался и отложен:
чистого Go-драйвера нет, любой требует cgo, что стоит нам
`CGO_ENABLED=0` и одного статического бинаря. Дверь при этом открыта —
DuckDB читает и parquet, и файл SQLite напрямую, так что выгрузка в parquet
остаётся отдельной командой на случай тяжёлой аналитики снаружи.