## Context Разбор покрывает одну секцию тела — `metrics`. Остальное перечисляется в `delivery.uncovered_sections`, доставка получает `partial`, тело живёт в архиве. Замер по 118 доставкам архива: `metrics` — 65 доставок, `workouts` — 27, `stateOfMind` — 26; ни одна доставка не несла двух секций сразу. Тренировка и состояние разума устроены иначе, чем метрика, и это не стилистика: - у них есть **собственный `id`** (UUID из HealthKit) — координатный ключ `метрика + слой + начало + конец` им не нужен; - они **редки**: за двое суток потока — 2 разных тренировки и 2 разных записи состояния разума, при 44 и 52 доставленных копиях соответственно; - у них **нет слоя**: подробности выгрузки у этих секций в интерфейсе HAE не бывает, есть только глубина окна; - тренировка **тяжёлая**: маршрут — 95% её веса (190 КБ из 199,6 КБ у десятиминутной прогулки), и приезжает она повторно, пока маршрут не доедет. Замер поведения при переприсылке (тот же архив, группировка по `id`): ``` тренировка A 26 копий 3 различных содержимых поля росли, не убывали тренировка B 18 копий 1 содержимое маршрут с первой копии stateOfMind 26 и 26 копий, по 1 содержимому каждая ``` Что именно менялось у тренировки A между версиями: ``` версия 0 → 1 +stepCadence, +stepCount, изменилось значение activeEnergy версия 1 → 2 набор полей тот же, изменились totalEnergy и basalEnergy ``` То есть тренировка досчитывается задним числом ровно так же, как минутное ведро (находка 10), и при этом набор полей за весь корпус ни разу не уменьшился. ## Goals / Non-Goals **Goals:** - Тренировка лежит в витрине целиком, вместе с маршрутом и внутренними рядами, дословно и без интерпретации. - Состояние разума лежит записями — секция, которой нет в экспорте Apple, больше не зависит от того, что тело не удалили. - Свёртка остаётся детерминированной: `reindex` даёт то же состояние, что живой приём, и это проверяется отпечатком, а не «числом строк». - Правило «при столкновении выигрывает более полная версия» продолжает действовать и для сущности с собственным `id`. **Non-Goals:** - **Отдача наружу.** Read API в проекте нет вовсе; форма конверта, выбор слоя и предел размера ответа проектируются задачей `read-api-tochki`. Два эндпоинта, введённые раньше конверта, задали бы контракт мимоходом. - **Секции, которых поток не приносил** (`ecg`, `symptoms`, `cycleTracking`, `medications`, `heartRateNotifications`). Модель под них закладывается — таблица `record` ключуется родом секции, — но разбор не пишется вслепую: их формы никто не видел, а задача `proverka-novyh-sekcij` существует ровно про момент, когда они появятся. - **Разворачивание маршрута** в таблицу точек — отдельная идея беклога, у неё нет клиента. - **Словарь категориальных значений** (`name` тренировки — «В помещении Ходьба», машинная калька) — отдельная задача; здесь строка хранится дословно. ## Decisions ### 1. Две таблицы, а не одна с колонкой рода `workout` и `record` разведены, как и записано в `docs/architecture.md`. Общая таблица `entity(kind, id, …)` выглядит экономнее и хуже по существу: у тренировки есть заголовок, который нужен запросом «что было за период» — `name`, `start`, `end`, `duration`, — а у записи состояния разума его нет. Общая таблица либо теряет заголовок (тогда список тренировок требует разжатия каждого блоба), либо заводит колонки, пустые у пяти родов из шести. Отвергнуто и обратное — таблица на каждый род секции: шесть почти одинаковых таблиц, и каждая новая секция требует миграции. `record` ключуется родом, и новая секция добавляется одной строкой в множество покрытых имён. ### 2. Ключ `record` — `(kind, id)`, а не один `id` Отклонение от схемы, набросанной в `architecture.md` (`record(id PK, kind, …)`), и оно намеренное. У `stateOfMind` `id` — настоящий UUID HealthKit, но остальные пять секций живьём не видели никто: форма их идентификатора неизвестна, и короткий несквозной `id` в двух разных секциях молча затёр бы одну запись другой. Пара стоит ноль (запросы к `record` всегда идут с родом: `GET /records/{kind}`) и снимает целый класс. Ключ `workout` — `id`: род у него один. ### 3. Сущность заменяется целиком; побеждает не последняя, а не теряющая полей Центральное решение задачи. Тренировка «перезаписывается» (беклог, `architecture.md`), но инвариант проекта гласит «при столкновении выигрывает более полная точка, а не последняя пришедшая». Развилку решает замер выше. Правило: ``` 1. каноническая форма совпала с сохранённой → записи нет (хеш-детектор) 2. приехавшая несёт всё, что сохранённая, и сверх того → приехавшая замещает целиком 3. приехавшая теряет содержание сохранённой → остаётся сохранённая, счётчик + WARN 4. содержание сравнимо (наборы равны) → версия из БОЛЕЕ ПОЗДНЕЙ доставки журнала 5. наборы несравнимы → остаётся сохранённая, счётчик + WARN ``` **«Теряет содержание» считается по множеству ключей, а не по `canon.Relate`.** Это единственная деталь, где реализация не может переиспользовать правило точек как есть, и цена ошибки здесь — маршрут. Проверено выполненной командой на копии пакета `canon`: ``` сохранённая vs обеднённая, значения общих полей те же : superset сохранённая vs обеднённая, значения общих полей иные : equal сохранённая vs усечённый маршрут (2 точки → 1) : equal ``` `canon.Fields.Relate` гасит отношение включения до `equal`, когда значения общих содержательных ключей разошлись, — и это верно для точки (надмножество имён при других значениях означает другое измерение), но неверно для сущности: замер выше говорит, что между версиями тренировки значения меняются **всегда**. То есть настоящая обеднённая версия пришла бы с изменёнными значениями, получила бы `equal` и заместила бы сохранённую целиком, а тест на наивной фикстуре (значения не тронуты) остался бы зелёным. Поэтому в `canon` заводится вторая, явная операция — сравнение **множеств содержательных ключей** без условия о совпадении значений, поверх уже существующего внутреннего `relateKeys`. Именно в `canon`, а не в `store`: пакет заведён ради единственной реализации сравнения, и вторая копия разошлась бы с первой молча. Полнота меряется **верхним уровнем** ключей и длиной верхнеуровневых массивов. Второе добавлено намеренно: усечённый маршрут (3 точки вместо 593) ключа не теряет, поэтому одних множеств мало, а маршрут — 95% веса тренировки. Досчёт ряды удлиняет, а не укорачивает, так что укорачивание — законный сигнал «приехало меньше». Предел правила назван вслух: сокращение **внутри** элемента ряда (точка маршрута потеряла `altitude`) не ловится ничем. **Тай-брейк при равных наборах — позиция доставки в журнале, а не порядок свёртки.** У точки при равной полноте исход решает порядок канонических форм (`canon.Less`) — тай-брейк, который намеренно не выбран, пока не измерен род агрегации. Приложи его к тренировке — и на наших же данных версия 1 → 2 (набор полей тот же, досчитаны `totalEnergy` и `basalEnergy`) осталась бы на произвольной из двух **навсегда**: тренировка замерла бы с недосчитанной энергией. Причина расхождения содержательная: у точки на одной координате законно встречаются два разных измерения (разные устройства, пересэмплирование), и предпочитать позднее нет оснований; у сущности `id` — идентичность одного объекта HealthKit, и вторая версия есть тот же объект, пересчитанный источником. Отвергнуто и напрашивавшееся «побеждает приехавшая»: приехавшая — это функция **порядка свёртки**, а он не равен порядку журнала. Спека приёма говорит прямо, что воркер сворачивает в порядке `(received_at, id)` только среди **видимых** ему доставок, а абсолютного порядка при конкурентных приёмах не обещает (`docs/architecture.md`, «Предел порядка назван вслух»; открытый блокер `poryadok-zhurnala-na-priyome.md`). Доставка с более ранней меткой, свёрнутая позже, вернула бы витрину к недосчитанной версии — и `reindex` разошёлся бы с живым приёмом **молча**, в содержимом тренировки. Поэтому сущность несёт провенанс — `delivery_id` и `received_at` своей доставки, — а тай-брейк сравнивает пару `(received_at, id)`. Тогда исход при равных наборах зависит только от журнала, а не от того, кто раньше добрался до базы. Провенанс нужен и сам по себе: у часового объекта он обязателен («провенанс для разбора слияний»), а `WARN` об удержанной обеднённой версии без него не связать с телом в архиве. Две версии с одинаковым ключом **внутри одной доставки** (позиции равны) разрешаются минимумом канонической формы: порядок элементов в JSON-массиве нестабилен, и опираться на него нельзя. Почему **не** голый upsert по `id` (как делает сервер HealthyApps поверх MongoDB и как просилось из формулировки «перезаписывается»): единственный сценарий, ради которого тренировка приезжает повторно, — доезжающий маршрут, то есть рост. Обратное — приезд версии без маршрута — за 44 доставленные копии не случилось ни разу, но стоит 95% содержимого тренировки, а восстановление требует пересборки всего журнала. Условие пункта 3 стоит одного сравнения множеств и делает событие **наблюдаемым** вместо необратимого. Несравнимые наборы (приехавшая принесла новые ключи и потеряла старые) в пункте 5 разрешаются в пользу сохранённой: поля не объединяются, объединение отвергнуто там же, где для точек, — на живом потоке событие не наступало ни разу, и вместо реализации заведено наблюдение. **Остаточный предел назван вслух.** Слияние попарное — сохранённая против приехавшей, — поэтому при несравнимых наборах (пункт 5) исход зависит от порядка проигрывания. Тот же предел есть у часового объекта: в объекте лежит победитель прошлых слияний, а не все кандидаты истории. Пункты 2–4 от порядка свёртки не зависят, а пункт 5 сопровождается счётчиком и `WARN`, поэтому событие не будет молчаливым. ### 4. Свёртка доставки остаётся одной транзакцией `MergePoints` превращается в `Merge(ctx, Incoming{Points, Workouts, Records}, deliveryID)`: точки, тренировки и записи одной доставки пишутся одной транзакцией. Спека хранения требует этого прямо («Доставка SHALL сворачиваться одной транзакцией»), и требование не про точки, а про доставку: частичное состояние ломает инвариант «состояние пересобираемо». Наблюдение «ни одна доставка не несла двух секций сразу» (находка 50) собрано за двое суток и основанием для второй транзакции не является. Имя `MergePoints` уходит: метод перестал сливать одни точки, а два метода с двумя транзакциями были бы вторым способом делать то же самое. ### 5. `payload` — сжатый блоб, как у часового объекта Дословные байты сущности, gzip. Тот же приём и по той же причине, что у `bucket`: маршрут — 95% веса тренировки, JSON такого рода жмётся примерно в 25 раз, а прогулка в час даёт порядка мегабайта. Цена названа там же и здесь та же: внутрь `payload` не заглянуть SQL-функциями. Для хранилища, которое отдаёт тренировку целиком, это не потеря; заголовок, по которому идёт выборка, лежит колонками. Отвергнуто хранение текстом (как было набросано в `architecture.md`, `payload JSON`): второе кодирование для той же по природе величины стоило бы дороже любой выгоды от `json_extract`, а объём — сотни мегабайт в год против десятков. ### 6. Заголовок тренировки — ровно то, по чему идёт выборка `name`, `start_utc`, `end_utc`, `tz_offset`, `duration_sec`. Больше ничего: любая следующая колонка — это решение за Apple о том, что в тренировке главное (находка 15: сводки дублируют ряды, `distance` — это сумма `walkingAndRunningDistance`). `duration` берётся из тела, а не считается как `end - start`: HAE шлёт 91.746 секунды при интервале в 91 секунду, и вычисленное значение молча разошлось бы с присланным. Отсутствует или не число — колонка **`NULL`**, а не ноль: ноль — законная длительность, и потребитель, просуммировавший столбец, не отличил бы «источник не прислал» от «измерено ноль». Тело в `payload` дословно в любом случае. `end` нечитаем или отсутствует — `end_utc` равен `start_utc`. У точки вырождение интервала в мгновение запрещено, потому что схлопывает координату; у сущности ключ — `id`, схлопывать нечего, а истина остаётся в `payload`. Офсет берётся из `start`: колонка одна, а пробежка через смену зоны дала бы два разных. Метка записи — `start`, при его отсутствии `date`. `end` в заголовок не идёт: у рода `daily_mood` он может отстоять от начала на сутки, и вторая колонка понадобится вместе с запросом, которого пока нет. `name` локализован («В помещении Ходьба»); хранится дословно, стабильный код припишет задача словаря категориальных значений. Значение `kind` у записи — верхнеуровневый ключ секции HAE **дословно** (`stateOfMind`, не `state_of_mind`): инвариант «форма Apple не транслируется» относится и к именам секций, а переименование после мерджа стоило бы миграции данных. ### 7. Метка времени: у сущности оба известных формата, у точки — один `parseEntityTime` пробует формат HAE (`2026-07-31 21:03:51 +0300`), затем RFC 3339 (`2026-07-31T18:03:51Z`). Форматы измерены (находка 16: у тренировок первый, у `stateOfMind` второй) и не пересекаются. Отвергнуто приписывание формата секции: оно точнее описывает сегодняшний день и ломается молча в тот, когда HAE выровняет секции между собой — а он к этому идёт (`stateOfMind` уже шлёт честные коды HealthKit там, где старые секции шлют переводы, находка 37). Цена терпимости нулевая: неоднозначности между двумя формами нет. **Точка остаётся строгой, и это не забывчивость.** У точки по метке выводится слой, причём по метке **местной**: метка в UTC объявила бы часовую выгрузку минутной, и минутный слой сложился бы с часовым (находка 35 — ровно такое удвоение уже наблюдалось). Терпимый парсер там означал бы тихую порчу разреза; строгий отдаёт непонятую метку в счётчик пропусков и `WARN`, а тело остаётся в архиве. У сущности слоя нет, и терять на строгости нечего — асимметрия намеренная. Следствие, которое надо назвать вслух: у `stateOfMind` `tz_offset` всегда `0`, потому что HAE прислал UTC, а не потому, что человек был в Гринвиче. Местная зона этой секции в потоке отсутствует. ### 8. Отпечаток витрины покрывает сущности, и снимается одним снимком `Store.Fingerprint` — единственный оракул сходимости `reindex` и `task verify:archive`. Оставить его отпечатком одних часовых объектов значило бы получить «состояние сошлось» при разъехавшихся тренировках — то есть сломать проверку молча, ровно тем изменением, которое добавляет данные. Три раздела читаются **одной read-only транзакцией**. Сегодня отпечаток — один `SELECT`, то есть один снимок; три запроса подряд вне транзакции в режиме WAL дают три снимка, а рабочий отпечаток снимается под живым приёмом. Свёртка, закоммитившаяся между запросами, дала бы смесь «объекты до» и «тренировки после», то есть ложное «разошлись» у единственного оракула. Прецедент в проекте есть — `Store.Bucket` уже читает в `BeginTx(ReadOnly)`. Строки разделов идут с константным тегом впереди (`b|`, `w|`, `r|`): без него строка одного раздела может совпасть со строкой другого — та же причина, по которой поля переменной длины уже идут с длиной впереди. Отчёт `reindex` расширяется вместе с отпечатком: счётчики тренировок и записей «было и стало» рядом с числом объектов, и «покрыта новая секция» в перечне ожидаемых классов расхождения. Иначе первый же прогон после мерджа даст гарантированное расхождение отпечатков при неизменившемся числе объектов — и оракул выродится в шум ровно тогда, когда по нему принимается необратимое решение о подмене базы. ### 9. Покрытыми становятся ровно две секции `covered()` — множество из трёх имён: `metrics`, `workouts`, `stateOfMind`. Прочие секции с собственным `id` остаются в списке непокрытых, доставка с ними остаётся `partial`, тело — в архиве. Это честно: формы этих секций никто не видел, а «полнота покрытия HealthKit ради полноты» целью проекта не является (паспорт). Следствие, которое надо назвать вслух и передать дальше: доставка из одного `stateOfMind` теперь получает `parsed` с пустым списком непокрытых, то есть становится **неотличимой** от доставки из метрик — а метрики восстановимы из экспорта Apple, состояние разума нет (находка 46). До этой задачи защита работала побочным эффектом непокрытости. Ретеншена в проекте нет, поэтому здесь ничего не ломается сегодня; но предусловие, которое задача `retenshen-syrogo-arhiva` считала снятым, снова открыто, и это записывается в её файл тем же изменением. ### 9а. Отказ разбора остаётся «всё или ничего» — теперь и для сущностей Действующее требование сформулировано через точки, потому что другого результата у разбора не было. Три ветки надо назвать явно, иначе каждая решается реализацией молча: - **Тело оборвано после уже разобранной секции.** Разбор отдаёт ошибку и **ни точек, ни сущностей**: иначе часть данных легла бы в витрину под статусом, по которому доставку никто не подберёт. - **Слой метрик не выводится, а в теле есть сущности.** Доставка целиком уходит в `failed`, сущности не пишутся. Соблазн «сущностям слой не нужен, запишем их» ломает то же «всё или ничего»: доставка получила бы `failed` при частично записанной витрине, и повторная свёртка перестала бы быть no-op. Тело остаётся в архиве, доставку вернёт пересборка. Цена названа: если такая доставка когда-нибудь принесёт `stateOfMind`, его записи доедут не сразу, а ретеншен `failed`-тела трогать не вправе. - **Повтор ключа покрытой секции в одном `data`.** Секции **объединяются**, как уже задано для `metrics`. Заодно чинится существующий дефект уровнем выше: `decodeEnvelope` при повторе самого члена `data` результат второго члена **присваивает**, а не добавляет, и имена первого глушатся общим `seen` — тело с двумя `data` доезжает до `parsed` с молча потерянной секцией. ### 9б. Пределы на чужие строки `id` приходит из тела и ничем не ограничен, а уезжает и в первичный ключ, и в записи лога. Предел — 128 байт (UUID HealthKit — 36); сущность с более длинным `id` пропускается тем же счётчиком, что и сущность без `id`. Правило то же, что уже действует для имён непокрытых секций, и оно снимает класс, а не случай. ### 10. Миграция пересворачивает то, что стало покрытым Спека хранения уже требует: «Задача, которая начинает разбирать секцию, тем же изменением SHALL переводить `partial`-строки с этим ключом в `pending`». Миграция `00007` переводит в `pending` доставки, у которых в `uncovered_sections` встречается `workouts` или `stateOfMind`. Дальше их подберёт обычный проход фонового воркера — отдельного кода для этого не существует. Перевод точечный, а не «все `partial`»: список — снимок покрытия, и доставка с непокрытой `ecg` пересворачивать нечего. ## Risks / Trade-offs - **Приехала версия без маршрута, а поля при этом переименовались** → правило пункта 5 удержит сохранённую версию навсегда, и новых полей витрина не увидит. → Счётчик и `WARN` с `id` сущности; тело в архиве, `reindex` применит исправленное правило. Событие не наблюдалось ни разу. - **Сокращение внутри элемента ряда правилом не ловится.** Длина верхнеуровневых массивов сравнивается, а точка маршрута, потерявшая `altitude`, — нет. → Названо вслух; ловится только сверкой с телом в архиве. - **Маршрут удлиняет транзакцию свёртки.** Верхняя граница задаётся не примером, а пределом тела приёма: 64 МиБ распакованного тела из одних тренировок дают десятки мегабайт содержимого в одной транзакции, а задача беклога «Цена слияния на широкой доставке» уже описывает, как 63 МБ на одной координате держат транзакцию дольше `busy_timeout`. → Хеш-детектор снимает 41 запись из 44 на нашем корпусе, а сравнение начинается с узкого `SELECT content_hash`, без чтения и разжатия блоба. Отдельной задачи не заводим: случай выражается той же беклоговой задачей, что и точки. - **Канонизация маршрута разворачивает его в дерево `any`.** `canon.Form` материализует значение целиком — та самая форма, от которой отказался разбор тела (197 МиБ кучи против 54 МиБ на теле 42 МиБ). → Хеш приехавшей сущности считается **один раз на доставку**, до входа в транзакцию, а не на каждой из пяти попыток повтора при занятости базы. - **`import` родного экспорта Apple не даст `id` тренировки.** В `export.xml` элемент `Workout` идентификатора не несёт — `dogsheep/healthkit-to-sqlite` поэтому адресует тренировку **хешем содержимого** (`hash_id="id"` в sqlite-utils). Значит импорт снапшота задвоит тренировки, приехавшие от HAE, — ровно та же дыра, что у точек, где её закрыли ключом `start + end`. → Предел назван здесь и заводится задачей беклога; сегодня импорта нет, и решать это до его формы значило бы угадывать. - **`tz_offset` у записей состояния разума всегда ноль** — не потеря наша, а форма источника. → Названо в `docs/database.md`, чтобы клиент не считал по нему местные сутки. - **Фикстуры собираются из живого архива.** Тренировка несёт координаты маршрута, запись состояния разума — эмоциональные метки; и то и другое чувствительнее токенов. → Скрипт `tmp/research/fixtures.py` расширяется: вычищаются числа (включая широту и долготу), UUID, метки RFC 3339 и словарные значения `stateOfMind`; сохраняются форма литерала, структура и порядок ключей. Проверка «в индексе нет данных о здоровье» остаётся за гейтом. ## Migration Plan Миграция `00007_workout_record.sql`: 1. `CREATE TABLE workout` и `CREATE TABLE record` с индексами по времени. 2. `UPDATE delivery SET parse_status = 'pending'` для строк, чей `uncovered_sections` содержит `workouts` или `stateOfMind`. Откат (`Down`) снимает таблицы; восстановление содержимого — обычная пересборка из архива, витрина производна по построению. Строки, переведённые в `pending`, `Down` обратно не возвращает: какими они были, восстановить неоткуда, а `pending` консервативен — ретеншен его не трогает. Живой сервис миграцию переживает: новые таблицы никого не блокируют, `UPDATE` идёт по 118 строкам.