# Архитектура ## Назначение 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/ГГГГ/ММ/ДД/.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`. Последнее не косметика: доставка, чей повторный разбор отказал, отдала бы в наследование слой прежнего разбора, и витрина снова стала бы функцией предыдущего прогона, а не журнала. #### Что не восстанавливается, и это сказано вслух Модель почти полна, но не полностью — умолчать об этом опаснее, чем признать. **`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) 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 ради полноты целью проекта не является. Модель под них заложена — новая секция добавляется одной строкой в множество покрытых имён, а не миграцией. Ключ записи — **пара**, а не один `id`: собственный `id` наблюдался живьём только у `stateOfMind`, где он UUID, и короткий несквозной идентификатор в двух разных секциях затёр бы одну запись другой молча. Пачками они не хранятся: у них есть естественный ключ, они редки, и группировать их по часам незачем. **Тренировка не разворачивается.** Заголовок — колонками, всё остальное, включая маршрут и внутренние ряды, — блобом `payload`. Структура тренировки разнородна и избыточна (сводки дублируют ряды, находка 15); раскладывать её в таблицы значило бы решить за Apple, что в ней главное. Колонок ровно столько, сколько нужно выборке: имя, интервал, офсет зоны, длительность. Длительность берётся из тела, а не считается как `end - start` (HAE шлёт 91.746 при интервале в 91 секунду), и её отсутствие выражается пустотой, а не нулём — ноль законная длительность. **Пульс приедет дважды** — в общем потоке метрики `heart_rate` и внутри объекта тренировки. Это ожидаемо, они лежат в разных таблицах и не смешиваются. #### Замена версии сущности «Перезаписывается» уточнено измерением. Тренировка приезжает повторно каждой доставкой, пока источник её досчитывает: на живом архиве одна тренировка приехала 26 раз в трёх различных содержимых — сперва добавились `stepCadence` и `stepCount` вместе с изменившимся рядом `activeEnergy`, затем при том же наборе полей досчитались `totalEnergy` и `basalEnergy`. То есть тренировка правится задним числом ровно так же, как минутное ведро (находка 10), а набор её полей за весь корпус ни разу не уменьшился. Правило: ``` 1. каноническая форма совпала с сохранённой → записи нет (хеш-детектор) 2. приехавшая несёт всё содержание сохранённой и сверх того → приехавшая замещает целиком 3. приехавшая теряет содержание сохранённой → остаётся сохранённая, счётчик + WARN 4. содержание равно → версия из более поздней доставки ЖУРНАЛА 5. наборы несравнимы → остаётся сохранённая, счётчик + WARN ``` **Содержание сравнивается множеством ключей с непустым значением и длиной верхнеуровневых массивов — но не значениями.** Правило полноты, принятое для точек, здесь неприменимо, и это проверено выполненной командой: оно гасит отношение включения до «равенства», когда значения общих ключей разошлись, — а у сущности они расходятся всегда. Обеднённая версия получила бы «равенство» и заместила бы сохранённую вместе с маршрутом, причём тест на фикстуре с неизменёнными значениями остался бы зелёным. Длина массивов добавлена потому, что усечённый маршрут (три точки вместо 593) ключа не теряет, а теряет 95% веса тренировки. Предел правила назван вслух: сокращение **внутри** элемента ряда не ловится ничем, кроме сверки с телом в архиве. **Тай-брейк при равном содержании — позиция доставки в журнале `(received_at, id)`, а не порядок свёртки.** Напрашивавшееся «побеждает приехавшая» отвергнуто: приехавшая есть функция порядка свёртки, а он порядку журнала не равен (см. «Предел порядка назван вслух»). Доставка с более ранней меткой, свёрнутая позже, вернула бы витрину к недосчитанной версии, и пересборка разошлась бы с живым приёмом **молча** — в содержимом тренировки, где это не видно ничем, кроме отпечатка. Поэтому сущность несёт провенанс: доставку своей версии и её метку приёма. Тай-брейк по канонической форме (как у точек) отвергнут по другой причине: он заморозил бы тренировку на произвольной из версий навсегда, вместе с недосчитанной энергией. Две версии одного ключа **внутри одной доставки** позициями не различаются и разрешаются минимумом канонической формы: порядок элементов в JSON-массиве нестабилен. Отвергнут и **голый upsert по `id`** (так делает сервер HealthyApps поверх MongoDB, и так просилось из слова «перезаписывается»): единственный наблюдённый сценарий повторной присылки — рост, но маршрут стоит 95% содержимого, а восстановление требует пересборки всего журнала. Условие пункта 3 стоит одного сравнения множеств и делает событие наблюдаемым вместо необратимого. Остаточный предел назван вслух: слияние попарное, поэтому при несравнимых наборах (пункт 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`. ## Открытые вопросы - Механизм доставки образа и запуска на 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 остаётся отдельной командой на случай тяжёлой аналитики снаружи.