## 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-points`. Два эндпоинта,
  введённые раньше конверта, задали бы контракт мимоходом.
- **Секции, которых поток не приносил** (`ecg`, `symptoms`, `cycleTracking`,
  `medications`, `heartRateNotifications`). Модель под них закладывается —
  таблица `record` ключуется родом секции, — но разбор не пишется вслепую: их
  формы никто не видел, а задача `unseen-sections-check` существует ровно про
  момент, когда они появятся.
- **Разворачивание маршрута** в таблицу точек — отдельная идея беклога, у неё нет
  клиента.
- **Словарь категориальных значений** (`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`, «Предел порядка назван вслух»; открытый блокер
`journal-order-on-ingest.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). До этой задачи защита
работала побочным эффектом непокрытости. Ретеншена в проекте нет, поэтому здесь
ничего не ломается сегодня; но предусловие, которое задача
`raw-archive-retention` считала снятым, снова открыто, и это записывается в
её файл тем же изменением.

### 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 строкам.
