Files
healthlog/docs/architecture.md
T
av 3d24248075 docs: документация приведена к канону av-dev-pm 4
- каждая запись каталога задач получила тип вместо тега kind: и префикса
  заголовка; секция роадмапа «Разработка» стала «Сопровождением», порядок
  секций канонический
- поправлены протухшие факты: нереализованные маршруты Read API, MCP и
  `healthlog import`, словарь слоёв в инварианте, семантика гейта по покрытию
  диффа, периметр перестал дублировать security.md
- замер слияния переведён с находки 49 на находку 54, заполнены Purpose спек
  storage и parsing
2026-08-05 19:09:35 +03:00

164 KiB
Raw Blame History

Архитектура

Обзор: как сложено и где что работает. Поведение системы здесь не описывается — его нормативный дом openspec/specs/. Разделы, помеченные <!-- канон: поведение → … -->, ещё не разнесены: это долг переезда на канон 2026-08-03, он закрывается порциями по ходу задач и гейт от него не краснеет.

Назначение

healthlog принимает выгрузки Apple Health из приложения Health Auto Export (далее HAE) и родного экспорта Apple Health, хранит их и отдаёт другим приложениям — в том числе агентам, через MCP. Он не переименовывает поля и не интерпретирует значения; агрегаты считает только в ответе на запрос и только там, где род метрики измерен, а не угадан.

Принципы

  • Один статический бинарь (CGO_ENABLED=0), доставка — docker-образом.
  • Точки хранятся дословно. Часовой объект держит точки ровно в том виде, в каком их прислал HAE — без переименований, пересчётов и отбрасывания незнакомых полей. Поэтому хранилище само по себе является полной копией данных, а не производной выжимкой.
  • Хранилище — свёртка по журналу, а не единственная копия. Экспорт Apple это снапшот всей истории, доставки HAE после его даты — события поверх снапшота. Состояние всегда пересобираемо: import(экспорт) + replay(доставки). Отсюда срок жизни сырого архива — до следующего проверенного экспорта, а не произвольные две недели. Исключение названо вслух: stateOfMind в экспорт не попадает, см. «Хранилище».
  • Сохранили — значит приняли. Код ответа отражает доставку, а не разбор (см. «Приём»).
  • Ничего не теряем молча. Идентичность — устойчивые координаты (метрика + слой + начало + конец; у точки-измерения конец равен началу); source в ключ не входит, он нестабилен. Хеш канонизированного содержимого остаётся детектором изменений, чтобы не писать зря. При столкновении выигрывает более полная точка, а при равной полноте — стоящая позже в журнале: бедная доставка не должна стирать поля у богатой, но и устаревшее значение не должно пережить свой досчёт. Полнота — множество ключей с непустым значением, а не их число (см. «Разрешение столкновений»).
  • Дыры закрываются сами. Данные приходят несколькими проходами разной глубины, поэтому пропущенная доставка не оставляет постоянного пробела — см. «Модель синхронизации».
  • Форма Apple не транслируется. Значения отдаются такими, какими пришли; нормализовано только время. Единственное добавление — стабильный код рядом с переведённой строкой (см. «Категориальные значения»): он приписывается, а не подменяет.
  • Своей агрегации в хранении нет — есть слои. Метрика лежит в тех разрезах подробности, в которых пришла (перечень слоёв — database.md, таблица bucket); переагрегирования при записи не происходит никогда.
  • Агрегация в ответе — только измеренная. Род свёртки (сумма или среднее) выведен сверкой слоёв между собой, а не проставлен вручную. Где род неизвестен, агрегация не предлагается: отдаются значения как есть. Свёртка к запрошенной сетке объявлена контрактом и ещё не реализована — параметр bucket отвергается 400 (задача read-api-points-bucket).
  • Минимум компонентов — один процесс, SQLite, файлы. Без очередей и внешних зависимостей.

Формат Health Auto Export

Документация формата скудная: help.healthyapps.dev и wiki Lybron/health-auto-export. Ниже — то, на что мы опираемся; всё остальное уточняем по реальным пакетам.

Автоматизация 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 — какие устройства вложились в значение (составное, через |). Что ещё документация описывает неверно и как поток выглядит на самом деле — research/apple-health.md.

Даты приходят строкой с офсетом: 2026-07-31 12:00:00 +0300 — не RFC 3339.

Тренировка (v2) несёт стабильный id из HealthKit, start/end/duration, опционально route (точки GPS) и heartRateData.

Наша настройка: несуммированные данные (переключатель «Суммировать данные» выключен, группировка при этом недоступна). Причина — суммированные значения досчитываются задним числом: минутное ведро уезжает неполным и в следующей доставке приезжает полным (research/apple-health.md, находка 10). На несуммированных данных расхождений не наблюдалось (находка 3), поэтому идентичность по содержимому работает без оговорок. Заодно сохраняются детали, которые группировка съедает: эпизоды сна и межударные интервалы (находки 6, 19).

Чего это не даёт: настоящих сэмплов. Накопительные метрики (энергия, шаги, дистанция) в любом режиме приходят посекундной сеткой — нарезкой реальных сэмплов длиной 1–12 секунд, с сохранением итога и потерей границ интервала. Порядка 135 тысяч точек в сутки (находки 20, 23).

Поля start/end есть только у дискретных метрик (пульс, сатурация, сон, HRV); у накопительных — только date. Поэтому точка относится к часу по date, а вопрос о сэмплах, пересекающих границу часа, касается сотой доли данных (находка 21).

Модель синхронизации

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

По глубине — три прохода, догоняющие друг друга:

проход расписание период зачем
быстрый каждые 5 минут Since Last Sync свежесть
средний 34 раза в день 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).

Компоненты

Пакет — это реализация; что система делает, нормативно сказано в capability, и здесь стоит ссылка, а не пересказ требований.

Пакет Ответственность Capability
config загрузка и валидация TOML-конфига
logging сборка slog-логгера
ident генерация и разбор ULID
archive сырой архив: запись тела, чтение для reindex, ретеншен storage
hae разбор формата HAE, канонизация, хеш содержимого parsing
ingest use-case приёма, общий для HTTP и будущего CLI import ingest
fold свёртка одной доставки в часовые объекты storage, uncovered-sections
replay проигрывание журнала в витрину: состав, порядок, отчёт reindex
catalog каталог разрезов и измерение рода агрегации catalog
store SQLite: доставки, часовые объекты, тренировки, записи storage
points ряд точек метрики за период: выбор слоя, применимость рода points
httpapi приём, read API и форма провода ответов чтения ingest, catalog, read-api, points

Приём

запрос → токен → лимит тела, 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 (маршрут — задача stats-endpoint), а доразобрать их можно командой reindex.

Очередь свёртки — таблица, а не структура в памяти

Доставка ждёт свёртки в собственном статусе pending; канал между приёмом и воркером несёт один бит «есть работа». Это transactional outbox, он же «база как очередь заданий»: состояние задания пишется той же базой, что и факт события, а фоновый процесс выбирает необработанные строки.

Три следствия, ради которых так и сделано:

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

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

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

Классификацию исхода свёртки воркер и пересборка делят (internal/replay): второй классификатор разошёлся бы с первым молча, а по одному из его счётчиков (partial) принимается решение о судьбе тела в архиве.

Исход разбора отражает доставку, а не обстоятельства. Отмена и занятость базы статус не меняют — доставка остаётся pending и будет свёрнута снова; непонятое содержимое, невыводимый слой, нечитаемое тело, исчерпанный дедлайн и паника свёртки дают failed. Различение появилось не из аккуратности: failed из очереди выбывает навсегда и возвращается только пересборкой, а конкуренция за базу между приёмом и свёрткой стала штатной — без него занятость стирала бы доставку с полки молча. По той же причине учёт доставки идёт через транзакцию с повторами: одиночная вставка пересиживала бы только busy_timeout, после чего приём ответил бы 500 по доставке, тело которой уже на диске.

Паника свёртки перехватывается там же, где пишется исход разбора. Пока свёртка шла внутри обработчика, панику ловил транспорт и стоила она одного ответа; из фоновой горутины она валит процесс, а перезапуск берёт ту же доставку первой — дефект одной доставки становится циклом перезапуска, при котором приём не работает вовсе.

Предел порядка назван вслух. Метка приёма фиксируется раньше, чем строка учёта становится видимой, поэтому две одновременные доставки могут закоммитить строки в обратном порядке. Доставка без плотных метрик, свёрнутая раньше своей предшественницы, слоя не выведет и уйдёт в failed: её точки доедут только пересборкой. Окно узкое, и изменение его сужает, а не открывает, — но закрытие предела требует удерживать порядок на самом приёме, и это отдельный вопрос (задача journal-order-on-ingest).

Остановка формулируется инвариантом: приём прекращается раньше воркера, и после остановки не существует доставки, которая числится разобранной, а записана наполовину. Обещать «текущая доставка досворачивается» нельзя — бюджет остановки (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 каждые пять минут обесценил бы уровень.

Первая встреча имени — другое дело (uncovered-sections). Момент, когда поток принёс секцию, которой раньше не было, фиксировался колонкой, но не наблюдался ничем: увидеть его мог только тот, кто догадается заглянуть в базу. Теперь свёртка спрашивает журнал, встречалось ли имя в доставках строго раньше этой (пара (received_at, id), запросом вне транзакции записи), и первая встреча даёт WARN с именами отдельным атрибутом uncovered_new. Повторные молчат. Признак выводится, а не хранится: реестр был бы второй копией факта, обязанной сходиться с колонкой при каждой пересборке. Отсюда же идемпотентность — проигрывание полного журнала повторяет ровно те же события.

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

Перечень накопленного отдаёт healthlog uncovered — имя, число доставок, первая и последняя встреча, чтением только на чтение и с экранированием имён (ключ приходит из чужого тела). Границы у перечня три, и они названы, а не замолчаны: имя, вытесненное границей списка в 32 имени, в колонку не попадает вовсе; пересборка обнуляет колонку и заполняет её заново только по сохранившимся телам; а имя, секцию которого разбор научился покрывать, уходит из колонки при пересвёртке — то есть перечень отвечает о текущем состоянии покрытия, а не об истории.

Цена сверки измерена на синтетическом журнале годового объёма; числа и метод живут в одном месте — design.md изменения aktivnaya-proverka-novyh-sekcij, решение 3, — и здесь не дублируются. Правило из замера: ранний выход есть только у секции, приезжающей давно (строки просматриваются от старых к новым); у только что появившейся секции проход идёт почти по всему журналу на каждой доставке, пока её не покроет отдельная задача. Имён больше одного спрашиваются одним запросом — тридцать два запроса подряд стоили секунду с лишним на доставку.

Правило для будущих задач: покрыли секцию — пересверните. Список это снимок покрытия на момент свёртки; доставки, свёрнутые до того, как секция стала покрытой, останутся 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, id), а не по порядку файлов в каталоге. И живая свёртка обязана идти тем же порядком: проход воркера прекращается на первой отложенной доставке, а свёртка, всё-таки пошедшая вне порядка (конкурентный приём делает строку учёта видимой позже метки), пишет WARN — закрыть это окно можно только на приёме.

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 технически можно — и нельзя по инварианту: это была бы наша агрегация под видом присланной.

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

Версия витрины и обслуживание журнала

Два механизма живут рядом и держатся друг за друга: один говорит читателю «в базу никто не коммитил», второй разбирает журнал, в который эти коммиты легли.

Версия витрины: пара «поколение + счётчик»

Читающие маршруты обязаны уметь отвечать «не изменилось» без сборки ответа — самый частый запрос трёх потребителей это повтор неизменившегося. Признак изменения берётся у SQLite: PRAGMA data_version меняется, когда в базу закоммитило другое соединение.

Голым значением его брать нельзя, и обе причины измерены на стенде проекта:

  • счётчик несравним между соединениями. На одном состоянии базы два соединения пула отвечают разными числами, а любое свежее соединение отвечает одним и тем же значением независимо от содержимого. Версия из пула давала бы не только ложную инвалидацию (не страшно), но и одинаковые метки на разных состояниях — то есть подтверждение неизменности на изменившихся данных.
  • счётчик не переживает переоткрытия. После рестарта он начинается заново.

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

Три свойства щупа названы вслух, потому что каждое из них можно нарушить незаметно:

  • щуп не пишет — собственный коммит соединения его версию не двигает;
  • щуп не удерживает транзакцию: только QueryRowContext(...).Scan(...), никаких QueryContext и BeginTx. Иначе единственное долгоживущее соединение процесса становится вечным читателем — тем самым, из-за которого чекпойнт перестаёт продвигаться;
  • щуп непригоден только после закрытия (sql.ErrConnDone). Отмена запроса клиентом соединение не убивает (измерено), и считать её смертью щупа значило бы менять поколение на каждом оборванном запросе — механизм схлопывался бы под той самой нагрузкой, ради которой заведён.

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

Подписывается не ответ, а чтение целиком. Версия снимается до и после чтения, и метка выдаётся, только если обе пробы совпали. Порядок здесь не стилистический: версия, снятая ПОСЛЕ чтения, пометила бы устаревший снимок свежим номером и заперла бы клиента на нём навсегда; версия, снятая только ДО, допускает два разных ответа под одной меткой. Правило живёт в одном месте (store.VersionedRead), потому что Read API точек и MCP берут ту же машинерию, а вторая реализация «по образцу» отличалась бы ровно на этот порядок.

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

Обслуживание журнала WAL

wal_autocheckpoint включён по умолчанию и срабатывает по концу записи. Отсюда дыра: всплеск, раздувший журнал, оставляет его неразобранным до следующей доставки — а поток пачечный, ночью телефон молчит часами. Поэтому рядом с воркером свёртки живёт горутина, раз в минуту делающая PRAGMA wal_checkpoint(PASSIVE).

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

Признак беды — не флаг занятости. Пассивный чекпойнт не идёт дальше снимка самого старого активного читателя и ошибки при этом не возвращает: измерено busy=0 при 6256 страницах в журнале и пяти перенесённых. Признаком служит пара чисел — страниц больше порога и перенесено меньше, чем лежало.

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

Порог и journal_size_limit — одно число (64 МиБ), выраженное в двух видах: предел возвращает файл, порог сообщает, что вернуть его не выходит. Двумя константами они разъехались бы молча.

Предела роста журнала это не даёт, и умалчивать об этом нельзя. Измерено: под удерживаемым читателем файл вырос до 51 МБ при лимите 8 МиБ — лимит действует только после полного чекпойнта, усечение делает первая запись за ним. Пока читатель держит снимок, журнал растёт, и единственный исход — WARN владельцу. Аварийный клапан (блокирующий TRUNCATE по порогу размера, как у Litestream) не взят по названной причине: он двигает версию витрины.

Строка о непродвижении пишется при входе в состояние и повторяется, только когда журнал вырос вдвое; возврат к норме — отдельная строка. Признак заведён ради состояния, которое само не проходит (в Go самый частый вечный читатель — незакрытый sql.Rows), а строка в минуту дала бы 1440 одинаковых записей в сутки.

Как это решают другие

  • Документация SQLite (wal.html) называет наш случай дословно: при перекрывающихся читателях, среди которых всегда есть активный, чекпойнты не смогут завершиться, и файл журнала будет расти без границы. Оттуда же взято, что PASSIVE «делает столько, сколько может» и может не дойти до конца, а полнота проверяется равенством checkpointed == log.
  • Litestream — интервал чекпойнта минута, режим PASSIVE, блокирующий TRUNCATE только как клапан по порогу размера. Взят период и режим; не взят его совет отключать wal_autocheckpoint (он владеет чекпойнтами целиком, у нас автоматический — первая линия) и не взят клапан.
  • rqlite всегда просит TRUNCATE и ждёт читателя до 250 мс — продиктовано требованием нулевого журнала для снапшота Raft, которого у нас нет.
  • Гайды по SQLite в проде (Django, dj-lite) из всего этого ставят одно — journal_size_limit порядка 26–64 МБ. Взято 64 МиБ.
  • PRAGMA data_version: рекомендация держать для наблюдения отдельное соединение взята с форума SQLite. Отвергнуты: FileControlDataVersion драйвера (снимает требование «щуп не пишет», но стоит доступа через (*sql.Conn).Raw в самом чувствительном месте), счётчик изменений со страницы 1 (SQLITE_DBPAGE — в режиме WAL инкрементируется не на каждой транзакции), хеш файла базы (так делает Datasette в неизменяемом режиме — наша база пишется непрерывно) и собственный счётчик версии в таблице (второе производное состояние рядом с витриной и лишняя запись на каждый коммит).

Устаревание нижнего слоя

Родной экспорт 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. Плотных метрик в доставке нет вовсе — слой наследуется от предшествующей доставки той же автоматизации; если её не было, берём надёжный заголовок (Minutesminute, Hourshour). Иначе точки не сохраняются вовсе: молчаливый raw создал бы призрачный разрез, который поедет в каталог и в правило Read API выбора слоя (см. «Read API»).

Слово «предшествующей» в третьем пункте несёт вес: слой обязан быть функцией от префикса журнала. Наследование от последней доставки вообще делает свёртку зависящей от истории, и пересборка даёт не то состояние, что живой приём — поймано прогоном архива, 1737 объектов против 1742 (docs/review.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.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 и всё равно сохраняем. Так мы узнаём реальную глубину досчёта из эксплуатации, а не из предположений.

Разрешение столкновений

По одним координатам приезжают разные содержимые: спорных координат 80 129 из 460 995 (17,4%), и полнота отбрасывает кого-то лишь в 981 из них (1,2%) — остальное решает тай-брейк (research/apple-health.md, находка 54; прежняя оценка «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} по жребию. Несравнимость на втором разряде исходом не является: лишние ключи там заведомо пусты, объединять в них нечего.

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

Несравнимые множества не сливаются, а считаются. Объединение полей — самая дорогая часть правила — на живом корпусе наступило дважды на 155 доставок (находка 54), поэтому вместо реализации стоит счётчик и WARN с координатами объекта. Событие видно, а не додумано заранее.

Тай-брейк при равной полноте — пришедшая доставка. Порядок канонических форм отвергнут замером: он берёт меньшее значение в 96% случаев (находка 49) и стоил step_count его рода. Значение точки в правило не входит («брать бо́льшее» неверно для мгновенных метрик), род метрики — тоже: род есть функция витрины, а правило, читающее собственную выдачу, перестаёт быть функцией префикса журнала. Байтовый порядок остался тай-брейком внутри одной доставки, где провенанс общий.

Цена названа вслух: правило перестало быть функцией множества и стало явной функцией порядка журнала. Витрина остаётся свёрткой журнала ровно потому, что порядок свёртки приведён к порядку журнала (см. «Свёртка обязана быть детерминированной»).

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

точка сущность (workout, record)
разряд полноты множества ключей с непустым значением покрытие содержания
тай-брейк равной полноты происхождение кандидата: пришедшая побеждает хранимая позиция журнала (received_at, id)
внутри одной доставки порядок канонических форм он же
гарантия верна, пока порядок свёртки равен порядку журнала верна всегда
в остаточном окне конкурентного приёма расходится, пишет WARN, лечится reindex не расходится
почему так провенанса у точки нет, и заводить его дорого: колонка на точку меняет формат содержимого объекта колонка провенанса уже есть

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

Измерение рода агрегации

Род метрики — cumulative, instant или unknown — выводится сверкой минутного слоя с часовым. Правило целиком:

час пригоден, если
   у метрики есть объекты обоих слоёв за этот час
   час не позже текущего времени плюс час
   единицы обоих объектов совпадают
   часовой объект несёт ровно одну точку, и она несёт значение
   метка этой точки совпадает с началом часа
   у минутного объекта не меньше двух точек со значением
   сумма минутных отличима от их среднего

вердикт пригодного часа
   часовое ≈ сумма минутных   → cumulative
   часовое ≈ среднее минутных → instant
   иначе                      → свидетельства нет

вердикт метрики
   ≥3 согласных часа и ни одного противоречащего → род
   иначе                                         → unknown

Измерено на живом архиве (123 доставки, 31 метрика): 7 накопительных, 9 мгновенных, 15 неизвестных, противоречащих часов ноль. Каталог собирается за 68 мс.

Горизонт обязателен, и это условие корректности, а не защита от вредителя. Час объекта берётся из метки в теле доставки, а тело не наше: без верхней границы одна доставка с метками в будущем занимает окно целиком и подменяет измеренный род — путь построен и прогнан, мгновенная метрика объявлялась накопительной при нуле противоречащих часов. Данные, помеченные будущим, пишутся WARN: сбитые часы телефона и чужое тело в приёме лечатся не кодом.

Единицы обеих сторон обязаны совпасть. Мгновенная метрика в count/min минутным слоем и в count/hour часовым даёт в полном часе часовое = 60 · среднее = сумма, то есть уверенный ложный cumulative. Единогласие такого случая не ловит: противоречия нет, есть молчание.

Каждая часть правила стоит своей причины.

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

Выравнивание часовой метки закрывает получасовые пояса. Слой выводится по выравниванию метки в исходной зоне, а объект адресуется часом UTC: в зоне +0530 часовая точка попадает на середину часа UTC и описывает не тот интервал, который покрывают минутные точки того же объекта.

Единогласие, а не большинство. Противоречащий час означает, что одна из гипотез для метрики ложна; большинство голосов объявляло бы род при известном контрпримере. Измеренная цена — ноль. Наличие противоречащих часов пишется WARN: род — свойство, на котором Read API строит арифметику года.

Порог в три часа — потому что один совпавший час остаётся свидетельством одного часа. Цена измерена: порог уводит в unknown метрики с единственным согласным часом.

Допуск сравнения — относительный, 1e-9, и один на все три сравнения. Разные допуски у «сходимости» и «различимости» породили бы час, подтверждающий обе гипотезы, и его исход определил бы порядок веток кода. Величина названа числом, потому что от неё зависят счётчики основания в ответе: вердикты одинаковы при допуске от 1e-9 до 1e-3, а число согласных часов у heart_rate при этом меняется вдвое. Абсолютного порога нет: около нуля относительное сравнение вырождается в сторону «не сходится», то есть даёт «свидетельства нет», а не ложный род.

Родов два, а не четыре. HealthKit различает cumulative, discreteArithmetic, discreteTemporallyWeighted (пульс) и discreteEquivalentContinuousLevel (аудиоэкспозиция). Взять весь словарь напрашивалось и отвергнуто измерением: часовой слой HAE считается арифметически, а не по Apple. Прямое свидетельство — environmental_audio_exposure, которую Apple усредняет логарифмически: её часовое значение сходится с обычным арифметическим средним минутных. Стили, которые в наших данных ничем не проявляются, можно было бы только разметить руками — то есть вернуться к тому, от чего уходит вся конструкция.

Род нигде не хранится, а считается на запрос по окну в 48 самых свежих общих часов. Хранимое значение было бы вторым производным состоянием рядом с витриной: его пришлось бы пересчитывать после каждой свёртки, вносить в перечень непереносимого пересборкой и объяснять, на каком составе данных оно снято, — причём устаревшее выглядело бы ровно как свежее. Вычисленный на запрос род есть функция витрины, а витрина — функция журнала.

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

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

Как это решают другие и почему не подошло

Prior art здесь обширный, и весь он про объявление рода, а не про измерение.

  • HealthKit зашивает HKQuantityAggregationStyle в тип метрики, а HKStatistics возвращает nil на свёртку, не отвечающую стилю. Второе взято как принцип («род не тот — свёртки нет»), первое неприменимо: HAE тип не шлёт.
  • Home Assistant получает state_class от интеграции и при его смене требует удалить долгосрочную статистику вручную. Взято признание, что смена рода — событие, а не уточнение поля; отвергнуто объявление: объявить некому.
  • Graphite выводит aggregationMethod регуляркой по имени метрики. Отвергнуто: противоречит инварианту «форма Apple не транслируется» и не работает на именах HAE вовсе.
  • Prometheus и остальные принимают тип от отправителя; заголовок HAE врёт уже про слой, оснований верить ему про род нет. Детекция сброса счётчика (rate, total_increasing) отвечает на другой вопрос — «был ли рестарт у известного счётчика», — и к данным Apple неприменима: монотонного накопителя в них нет.
  • xFilesFactor (Graphite) и xff (RRDtool) — доля заполненности, ниже которой свёртка не делается. Измерению порог не нужен: у него две конкурирующие гипотезы, и неполный час не сходится ни с одной сам собой (у step_count 41 час пригоден и 24 дали вердикт — остальные и есть неполные). Свёртке в ответе порог понадобится, и вместе с ним выбор полярности: Graphite задаёт долю обязательно известных (0.5 при роллапе и 0 при рендере), RRDtool — долю допустимо неизвестных, то есть ровно наоборот. Обе величины выглядят как «0.5», означая противоположное. Решение принимает задача Read API.

Готовой практики вывода рода из данных не нашлось ни одной: у всех перечисленных есть привилегия, которой нет у нас — поставщик объявляет тип на входе, — и все за неё платят (Prometheus теряет тип на remote write, Home Assistant требует ручного удаления статистики). Мы платим измерением.

Категориальные значения

HAE отдаёт перечислимые значения строками из локали телефона, а не кодами: фаза сна приезжает как «БДГ», контекст пульса — как «Сидячий образ жизни», тип тренировки — как «В помещении Ходьба» (машинная калька с Indoor Walk). При этом stateOfMind в том же пакете шлёт честные коды HealthKit (momentary_emotion, slightly_pleasant, drained) — значит дело не в приложении, а в том, что старые секции тянут строки из UI (находка 37).

Оставить как есть нельзя по трём причинам, и третья решающая:

  1. Клиент вынужден угадывать словарь вместо того, чтобы сравнивать с кодом.
  2. Смена языка телефона молча расколет историю: та же фаза сна станет другим значением, и по координатному ключу это неотличимо от изменения данных.
  3. Родной экспорт Apple говорит кодами (HKCategoryValueSleepAnalysisAsleepREM). Он объявлен источником истины, и на нём держится ретеншен нижнего слоя — но сверить покрытие по этим полям было бы нечем.

Поэтому строка хранится дословно, а рядом кладётся выведенный код — отдельной строкой реестра category_value, а не полем внутри точки:

category_value   sleep_analysis / value / "БДГ" → HKCategoryValueSleepAnalysisAsleepREM
точка            {"date": …, "value": "БДГ", …}   ← не тронута

Рядом, а не внутри, по трём причинам: точка хранится исходными байтами и дописать в неё ключ можно только пересериализацией; параллельный массив кодов в bucket завёл бы производную величину в путь слияния и хеширования; пополнение словаря переписывало бы каждый объект с фазами сна. Обоснование целиком — в журнале решений.

Ключ реестра — (метрика, поле, значение). Словарь при этом ключуется парой (локаль, строка), локаль берётся из Accept-Language (находка 32) и в ключ реестра не входит: заголовков в сыром архиве нет, и ключ с локалью сделал бы состояние функцией от того, уцелела ли учётная строка. Локаль сужает поиск; её отсутствие вывода не отменяет, если строка однозначна по всем локалям.

Словарь и таблица синонимов кодов живут в бинаре (internal/healthkit), а не в базе: словарь, наполняемый руками, стал бы входом, которого нет в журнале, и import + replay перестал бы задавать состояние однозначно. Синонимы нужны потому, что коды тоже не вечны: Apple переименовала …Asleep в …AsleepUnspecified и переписывает историю при выгрузке (находка 43).

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

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

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

Тренировки и прочие секции

Тренировка адресуется своим 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&layer    точки метрики за период (bucket — соседняя задача, пока 400)
GET /healthz

Целевая поверхность шире реализованной. Маршрутов ниже в роутере ещё нет, и запрос к ним получает 404:

GET /api/v1/workouts?from&to               заголовки тренировок          → read-api-workouts
GET /api/v1/workouts/{id}                  тренировка целиком, с маршрутом → read-api-workouts
GET /api/v1/records/{kind}?from&to         прочие секции                 → read-api-records
GET /api/v1/schema                         схемы всего, что есть в хранилище → цель self-description
GET /api/v1/metrics/{name}/schema          схема и статистика одной метрики  → цель self-description
GET /stats                                 последняя доставка, счётчики, тишина → stats-endpoint

Хранение пачками на контракт не влияет: GET /metrics/{name} собирает ответ из часовых объектов, попавших в диапазон, и отдаёт точки. Клиент про объекты не знает — это деталь хранения, а не API.

Слои, наоборот, часть контракта. Каталог показывает, какие разрезы есть и за какой период:

{"metric": "heart_rate", "units": ["count/min"],
 "aggregation": {"style": "instant", "hours": 48, "compared": 48,
                 "agreeing": 20, "conflicting": 0,
                 "first_hour": "2026-07-31T09:00:00Z",
                 "last_hour": "2026-08-02T14:00:00Z"},
 "layers": [
   {"layer": "minute", "from": "2026-07-25T00:01:00Z", "to": "2026-08-01T23:59:00Z", "points": 14203},
   {"layer": "raw",    "from": "2026-07-30T00:00:07Z", "to": "2026-08-01T23:59:58Z", "points": 2078}
 ]}

aggregation.style — измеренный род (cumulative / instant / unknown), от него зависит, что вообще можно спросить. Рядом лежит основание: сколько общих часов попало в окно, сколько из них оказалось пригодными, сколько дали преобладающий вердикт и сколько противоречили. Одного числа не хватало — «часов было 48, а пригодным не оказалось ни одного» и «часов не было вовсе» разные события, и различать их клиент обязан без второго запроса. Поле названо style, а не kind: kind в проекте уже занят родом секции записи.

units — множество: единицы на живом потоке не менялись ни разу (находка 48), но одна форма поля для обоих случаев честнее строки, которая при расхождении молча выберет одно из двух. На слой при этом приходится ровно один элемент layers.

Границы слоя — метки первой и последней точки, включительно; first_hour и last_hourярлыки часов окна измерения. Имена разные потому, что разная семантика: одно имя для двух смыслов в одном ответе стоило бы клиенту ошибки на час, заметной только расхождением сумм.

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

Параметр layer выбирает разрез. Если он не указан — берём слой с наибольшим охватом внутри запрошенного периода, а при равном охвате самый мелкий (порядок samplerawminutehourday). Молча переключать слой на границе периода нельзя: ряд поедет незаметно для клиента, и ряд из одного ответа всегда собран из одного слоя.

Охват — длина пересечения отрезка «первая метка слоя … последняя метка слоя» с периодом; слой с пустым пересечением выбывает. Меряется он метками точек, а не часами объектов. Почему прежняя формулировка («самый мелкий, покрывающий весь диапазон») пересмотрена, почему мера именно такая и во что она обошлась — ADR-2026-08-04-sloy-vybiraetsya-po-ohvatu-tochek.

Словарь слоёв при этом один (hae.Layers): из него выводятся и порядок, и перечень слоёв в выборке охватов, и проверка параметра запроса, и текст отказа клиенту.

Условный запрос

Ресурсы чтения отвечают 304 Not Modified на If-None-Match с непротухшей меткой и не открывают снимок витрины вовсе.

Метка собирается из всего, от чего зависит ответ. У каталога это версия витрины (см. «Версия витрины и обслуживание журнала») и горизонт измерения: горизонт едет вместе с часами, и метка из будущего, лежащая в витрине, въезжает в окно сама, без единого коммита. Путь построен враждебным проходом ревью и прогнан: та же версия витрины, cumulative против unknown. Горизонт входит в метку огрублённым до часа — огрубление точное, потому что метки объектов лежат ровно на часах; цена — один полный ответ в час на потребителя.

Форма метки слабая (W/"…"): она выведена из состояния, а не из байтов ответа — так предписывает общая практика для валидаторов такого рода. На исход 304 это не влияет, If-None-Match сравнивается слабо в любом случае.

Область действия метки — часть самой метки. Она действительна в пределах одного ресурса, поэтому маршрут, чей ответ есть функция параметров (точки), и транспорт без адреса вовсе (MCP) кладут в неё канонизированную форму запроса. Прозой это требовать бесполезно — прозу компилятор не проверяет, а забыть область значит однажды ответить 304 на чужой набор данных; поэтому она параметр помощника, а не забота вызывающего.

Три правила разбора, каждое из которых легко нарушить: неразбираемое условие даёт 200, а не 400; * совпадает с любой существующей меткой, а при её отсутствии условие не выполнено; 304 уходит без тела и без представленческих заголовков. Токен чтения проверяется раньше условия: 304 без токена подтверждал бы состояние витрины тому, кому она не открыта.

Ответы чтения помечаются Cache-Control: private, no-cache. До появления валидатора эвристическое кеширование посредником было маловероятным; с меткой ответ становится штатно кешируемым, а при выключенной проверке токенов в запросе нет и Authorization.

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

Свёртка и размер ответа

Запросов к метрике ровно два, и это один запрос с необязательным параметром:

?from&to                    все значения за период     вес, лекарства, симптомы
?from&to&bucket=day         значения с разбивкой       шаги, энергия

Главный потребитель — агент, у которого ограничен контекст. «Пульс за неделю» без разбивки — это десятки тысяч точек в минутном слое и сотни тысяч в нижнем. Правило:

  • Разбивка не задана, ответ не влезает — сервер сам берёт сетку погрубее, чтобы влезло, и называет её в ответе. Агент всегда получает ответ и может переспросить уже.
  • Разбивка задана явно, ответ не влезает — это ошибка, а не тихая подмена. В теле ошибки — число точек по каждой доступной сетке, чтобы второй запрос был заведомо успешным.

Различие существенно: «указали уровень» работает как информация в первом случае и как защита во втором. Иначе агент, попросивший минутную сетку, получил бы суточные суммы и заметил бы это, только прочитав поле, которое вполне может не прочитать.

Свёртка применяет род из каталога: cumulative — сумма, instant — среднее с min/max рядом. При unknown свёртка не выполняется, а параметр bucket отвергается ошибкой. Порог заполненности ведра (xFilesFactor) и его полярность выбирает эта же задача — см. «Измерение рода агрегации». Накопительные метрики никогда не сворачиваются из нижнего слоя HAE — только из minute, hour или sample.

Форма ответа

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

{"metric": "heart_rate",
 "from": "2026-07-31T00:00:00Z", "to": "2026-08-01T00:00:00Z",
 "layer": "minute", "bucket": null,
 "aggregation": {"style": "instant", "applicable": true,
                 "last_hour": "2026-08-02T14:00:00Z"},
 "points": [
   {"ts": "2026-07-31T09:00:00Z", "ts_end": "2026-07-31T09:00:00Z",
    "tz_offset": 10800, "units": "count", "values": {"qty": 812}}
 ]}

Все поля присутствуют ВСЕГДА, даже когда сообщить нечего: клиент не должен выводить исход наличием или отсутствием поля. bucket равен null, когда свёртки не было; layernull, когда слой выбирала система и выбирать было не из чего (явно запрошенный слой уезжает всегда, в том числе при пустом ряде).

aggregationобъект, а не строка. Строка называла бы только применённую свёртку, а инвариант требует, чтобы клиент видел ещё и основание (решение и разбор чужих API — ADR-2026-08-04-otvet-tochek-nesyot-rod-i-ego-primenimost):

  • style — измеренный род метрики, тот же словарь, что у каталога;
  • applicable — применим ли род к отданному ряду. Род есть свойство метрики, слой — свойство ряда, и сочетание {"layer": "raw", "style": "cumulative"} законно и штатно: оно приглашает потребителя сложить интерполяцию самому и завысить втрое. Система при этом не складывает ничего — а потребитель об инварианте не знает;
  • last_hour — ярлык самого свежего часа окна измерения. Окно считается в общих часах, а не в часах календаря: выключенная минутная автоматизация HAE останавливает их пополнение, окно замирает и продолжает объявлять род. Это единственный след.

ts_end — конец координаты точки; у точки-измерения равен ts. Он есть потому, что идентичность точки — интервал, а не метка: под одной меткой лежит до трёх записей сна, и конверт с одним ts предлагал бы клиенту различать их, разбирая дословное содержимое.

Принадлежность точки периоду определяется её началом — тем же правилом, каким час объекта берётся по началу. Цена названа: «сон за ночь с полуночи» не увидит эпизод, начавшийся в 23:40.

Время приведено к единому виду, значения отданы как пришли: ни переименований, ни пересчёта единиц, ни экранирования (сериализатор ответа HTML-символы не экранирует — иначе & в имени источника уезжал бы как \u0026, и обещание дословности переставало быть правдой). Метрик у Apple много и они разные — семантику разбирает клиент по имени метрики. Полная нормализация означала бы, что каждая новая метрика требует правки коллектора, а незнакомая теряется.

Форма провода

Форму ответа объявляет транспорт, а не домен. Каждый читающий маршрут internal/httpapi держит собственные типы с json-тегами и переводит в них доменное значение присваиванием поле в поле; доменные типы (internal/catalog и далее) json-тегов не несут и до сериализации не доезжают. То же правило покрывает тело отказа. MCP собственной формы не объявляет — адаптер переводит вызовы в те же обработчики.

Цена названа с обеих сторон, потому что она обратная, а не односторонняя.

  • Домен = провод (как было у каталога): формы объявлены один раз, перевода нет, ноль строк на маршрут. Платим тем, что публичный контракт меняется правкой домена молча — переименованием поля, разъединением встроенной структуры (плоскость aggregation была следствием встраивания Basis), появлением внутреннего поля. Ни одна из трёх правок транспорт не трогает.
  • Раздельно (взято): контракт меняется только правкой транспорта, то есть действием. Платим двумя вещами. Форма объявлена дважды — типы и перевод на каждый маршрут. И цена обратная: новое поле домена в ответ само не попадёт, его обязан перечислить перевод; поле, не доехавшее до клиента, — такой же дефект, как поле, уехавшее случайно, просто другой.

Развилку решил факт, а не вкус: провод точек обещан как {ts, tz_offset, units, values}, а store.Point несёт {Start, End, OffsetSeconds, Raw} — эти наборы не совпадают ни одним именем, и доменный тип формой провода там быть не может. Хранилище, кстати, уже живёт по этому правилу: формат payload объявлен отдельным неэкспортированным storedPoint, а encodePayload переводит в него полем в поле.

Сторожей два, и роли у них разные. Обход графа типов ответа (внутренний тест httpapi) утверждает, что домен до энкодера не доезжает — отсюда и следует, что переименование поля домена байт не меняет; рядом стоит заведомо красный случай, потому что проверка, доказывающая отсутствие, зелена и будучи сломанной. Байтовый литерал на каждую различимую форму ответа — детектор изменения формы: он краснеет в момент правки. Источником истины контракта он не является — им станет рукописная OpenAPI-спека, и сверку с маршрутами внесёт в гейт отдельная задача.

Разбор чужих решений (домен = провод у wtf и Prometheus; раздельно у Gitea, Docker и go-kit; версионирование с конверсией у Kubernetes; отвергнутый apidiff, который смены json-тега не видит вовсе) — design.md изменения. Ссылка markdown-ссылкой намеренно: инлайн-код docs.py check не проверяет, а путь угадывался до архивации.

MCP

Поверх Read API встанет адаптер MCP, чтобы агент подключался без промежуточного кода — кода адаптера сегодня нет, это задача mcp-server цели read-api. Инструментов ровно два, по числу форм запроса выше, плюс каталог. Собственной логики в адаптере нет: он переводит вызовы в те же обработчики.

Транспорт — 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. Незнакомая метрика описывает себя сама, без релиза.

Схема отдаётся вместе со статистикой — для потребителя она важнее формального типа:

{"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}}}

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

Аутентификация

Периметр, модель угроз и разграничение контуров — security.md, разделы «Периметр» и «Что разграничивает доступ»; сегодняшний контур отличается от целевого, и сказано это там. Здесь важно одно следствие для компоновки: MCP — эндпоинт того же процесса и того же контура чтения, отдельного контура доступа у него нет (см. «MCP»).

Деплой

Целевая раскладка; сегодняшний контур — security.md, «Периметр», статус работ — tasks/ROADMAP.md, «Сопровождение».

VPS rivendell (Timeweb), доступен всегда. Перед сервисом — Caddy, он терминирует TLS; сам сервис слушает plain HTTP. Приём открыт наружу на отдельном поддомене — телефон должен доставать до него из любой сети, иначе экспорт копится и уезжает пачкой при возвращении домой.

Сборка — на локальной машине: статический бинарь и docker-образ; на сервер едет готовый образ. Go-тулчейн на сервере не нужен.

Тома: каталог сырого архива и файл SQLite — на постоянном хранении, конфиг (с токенами) — отдельно, 0600.

Откат бинаря поверх новой схемы отказывает на старте — правило нормировано в storage, требование «Открытие базы отказывает при схеме из будущего»; здесь только следствия для деплоя. Версия схемы базы выше версии, вшитой в бинарь, — отказ, а не повод мигрировать; в контейнере это выглядит циклом перезапуска, и лечится возвратом бинаря вперёд. Версию читает сам 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 остаётся отдельной командой на случай тяжёлой аналитики снаружи.