## Context

Точки хранятся дословно: `bucket.payload` — исходные байты точек, сжатые gzip;
`content_hash` — детектор изменений по канонической форме этих же байтов.
Пересборка обязана давать то же состояние (`import + replay`), и единственный
её оракул — отпечаток витрины.

Локаль приезжает заголовком `Accept-Language` **доставки** (находка 32,
измеренное значение — `ru`). Часовой объект при этом собирается из точек многих
доставок, а из провенанса у него только `first_delivery_id`. Значит локаль
точки после слияния восстановить нечем — вывод кода обязан происходить в
разборе, пока доставка ещё цела.

Словарь фаз сна выведен измерением (находка 43) и переписыванию не подлежит.
Коды HealthKit при этом сами не вечны: те же записи сна приезжают как
`…Asleep` в экспорте 2021 года и как `…AsleepUnspecified` в экспорте 2026-го.

## Goals / Non-Goals

**Goals:**

- Рядом с локализованной строкой появляется стабильный код HealthKit, выведенный,
  а не угаданный.
- Незнакомая строка видна, а не молчит.
- Переименование кода самой Apple не раскалывает историю.
- Состояние остаётся детерминированной свёрткой по журналу.

**Non-Goals:**

- Словарь для `heart_rate.context` и типов тренировок. Он не выведен: экспорт
  Apple хранит контекст пульса метаданным-числом, а не `HKCategoryValue*`, и
  сопоставление с «Сидячий образ жизни» было бы догадкой. Поля объявляются
  категориальными **сейчас**, чтобы их строки попали в реестр с пустым кодом —
  это и есть заявка на будущий вывод.
- Показ реестра в `/stats` и в Read API: ни того, ни другого ещё нет
  (`stats-endpoint`, `read-api-points`).
- Импорт родного экспорта Apple. Синонимы кодов заводятся ради него, но
  разбирать экспорт эта задача не начинает. Отсюда прямое следствие для
  приёмки: сверка «поток против экспорта на живой базе» этой задачей
  недостижима — оракула нет, и это названо в отчёте, а не обойдено.
- Отдача кода в ответе чтения. Форма ответа — забота Read API; хранилище отдаёт
  строку и реестр, по которому код сопоставляется.
- Обслуживание реестра: удаления строк нет, реестр накопителен. Строка,
  единственные доставки которой выпали из журнала ретеншеном, переживёт их в
  рабочей витрине и не появится в пересобранной. Это тот же класс, что и
  «верхние слои за периоды с удалёнными доставками» из инварианта, и он
  называется вслух, а не досчитывается.

## Decisions

### 1. Код кладётся в реестр наблюдённых значений, а не внутрь точки

Архитектура рисовала `value` и `value_code` соседними полями одной записи. Форма
принята другая — **таблица `category_value`, ключ `(metric, field, value)`**, а
точка не меняется вовсе.

Рассмотрены три формы, компромисс каждой назван:

**(1) Код — поле внутри точки.** Отвергнута. Точка хранится **исходными
байтами**; дописать в неё ключ можно только пересериализацией, а она теряет
литерал (`1.0` → `1`, целые больше 2^53 сдвигаются, невалидный UTF-8 →
U+FFFD) — ровно то, от чего `Point.Raw` защищает. Побайтовая врезка в чужой
JSON — фокус, а не решение. Параллельный массив кодов в `bucket` завёл бы
производную величину в путь слияния и хеширования: правило полноты, тай-брейк и
`content_hash` пришлось бы учить носить код, не давая ему влиять на исход.
Правка на поверхности `critical`-инвариантов ради нуля новых сведений — код есть
**функция** от того, что уже лежит. Плюс пополнение словаря переписывало бы
каждый объект с фазами сна.

**(2) Код нигде не хранится, выводится на чтении.** Отвергнута, но по одной
причине и с названной ценой: тогда код недостижим ничем, кроме бинаря. Владелец
сегодня читает витрину `sqlite` на хосте (`Read API` ещё нет), а вся задача
затевается против того, что «клиент угадывает словарь». Реестр без кода
сообщает только «такая строка была» — это половина ответа. Цена отказа
названа в решении 6: код объявлен **кэшем** и из отпечатка исключён, поэтому
недостатки материализации (отпечаток как функция версии бинаря, пересборка
после каждого пополнения) не наступают.

**(3) Реестр с материализованным кодом.** Принята. Что теряется: потребитель
делает соединение по `(metric, field, value)` вместо чтения одного поля, а
код в базе может отставать от словаря в бинаре ровно для тех строк, которые
перестали приезжать. Отставание лечится пересборкой и не влияет на сходимость.

Prior art: FHIR `ConceptMap` (отображение «чужая система значений → своя») и
`CodeSystem` с `replaced-by` для устаревших имён — ровно два наших отношения,
разведённые по разным сущностям. Форму берём, реализацию FHIR — нет: она стоит
дороже всего проекта.

### 2. Словарь и синонимы — код бинаря, реестр — база

`internal/healthkit` держит две таблицы Go: `(локаль, строка) → код` и
`алиас → канонический код`. В базе — только наблюдённое.

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

Почему не таблица в базе, наполняемая руками: словарь стал бы входом, которого
нет в журнале, и `import + replay` перестал бы задавать состояние однозначно.
`stateOfMind` уже единственная дыра в журнале; вторую заводить незачем.

Это решение нормируется требованием, а не остаётся в дизайне: иначе следующий
автор заведёт рядом ту самую таблицу словаря, наполняемую руками, — и обоснование
против неё останется в архиве изменения, куда он не пойдёт.

### 3. Локаль сужает поиск и в ключ реестра не входит

Локаль нормализуется по правилу lookup RFC 4647: первый тег списка, подтеги
отсекаются, регистр свёрнут (`RU-ru,ru;q=0.9` → `ru`). Свёртка регистра
обязательна — теги BCP 47 регистронезависимы, и без неё `RU` и `ru` были бы
разными языками.

Разрешение кода идёт тремя разрядами:

```
1. (локаль, строка) есть в словаре            → её код
2. локали нет или пары нет, но строка known
   во всех локалях даёт ОДИН код               → этот код
3. иначе                                       → пустой код
```

Второй разряд — не поблажка, а требование пересобираемости. Заголовки запроса в
сыром архиве **не лежат**: они были заголовками запроса, а не телом. Доставка,
восстановленная из осиротевшего тела (`replay.adopt`), приезжает без
`Accept-Language`, и правило «нет локали — нет кода» сделало бы состояние
функцией от того, уцелела ли строка учёта, — то есть сломало бы `import +
replay` на ровном месте.

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

Что теряется: «на каком языке приехала эта строка» больше не колонка. Ответ
остаётся достижим по ссылке — `first_delivery_id` → `delivery.headers`, ровно
тем же приёмом, каким провенанс устроен у часового объекта. Побочная выгода:
у тройки-ключа неоднозначности не бывает по построению, поэтому читателю
нечего разрешать и нечего угадывать.

Своя нормализация, а не `golang.org/x/text/language`: измеренное значение
заголовка — `ru`, а x/text тянет в статический бинарь таблицы CLDR ради разбора
одной строки. Разборщика `Accept-Language` в стандартной библиотеке нет.
Отвергнуто по цене, не по качеству — если появится согласование весов `q`,
решение стоит пересмотреть.

### 4. Синонимы — плоская карта в один шаг

`Canonical(код)` — одно чтение карты `алиас → канонический код`. Зовётся и
внутри `Code(...)`, поэтому «две формы сходятся в один код» верно по
построению, а не по дисциплине автора словаря. Направление одно — старое имя к
новому: `HKCategoryValueSleepAnalysisAsleep` →
`HKCategoryValueSleepAnalysisAsleepUnspecified` (находка 43).

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

### 5. Какие поля категориальны — знание HAE, какой у них код — знание HealthKit

Список пар `(метрика/секция, поле)` живёт в `internal/hae`: имена `value`,
`context`, `name` принадлежат формату HAE. В ключ реестра идёт **то же** имя
метрики, которым адресуется часовой объект: разделение схем под одним именем
(`sleep_analysis` → `sleep_analysis_summary`) тогда проходит по обеим единицам
хранения одновременно и лечится одной пересборкой.

Словарь живёт в `internal/healthkit`: он понадобится импорту родного экспорта,
который про HAE не знает ничего. Пакет `healthkit` не зависит ни от чего
внутреннего.

Поля читаются **тем же** разбором заголовка точки, что и метки, — второго
прохода по данным нет. Значения берутся `json.RawMessage` и принимаются только
как JSON-строка: объяви поле `string`, и точка, у которой `value` пришло числом,
перестала бы разбираться вовсе — новый путь потери данных ради удобства
структуры.

Имя тренировки при этом берётся из уже разобранного заголовка сущности, а не из
её сырых байт. `softString` превращает значение не того типа в `""`, отличить
«имени не было» от «имя приехало числом» на нём нечем — и не нужно: пустая
строка категориальным значением не считается, так что оба случая дают один
исход, и он верный. Второй разбор сущности стоил бы полного прохода по
мегабайтному маршруту ради поля в её заголовке.

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

### 6. Реестр — единица хранения; в отпечаток идёт наблюдение, а не код

Реестр входит в отпечаток витрины отдельным разделом (`c`). Иначе
недетерминированный upsert прошёл бы мимо единственного оракула сходимости.

**В раздел идут ключ и провенанс, но не `code`.** Ключ и провенанс — функция
журнала; `code` — функция журнала **и версии словаря в бинаре**. Включи его в
отпечаток, и он перестал бы отвечать на свой единственный вопрос («дал ли
повтор журнала то же состояние») ровно тогда, когда его задают: всякое
пополнение словаря — а оно объявлено рабочим циклом — давало бы расхождение
при побайтно совпавшем журнале, и человек, принимающий необратимое решение о
подмене базы, читал бы это как дефект. Правильность вывода кода проверяется
тестами словаря, а не оракулом сходимости журнала: это разные вопросы, и
смешивать их — способ обесценить оракул.

Прецедент цены назван: как с `workout`/`record` (миграция 00007), первая сверка
после выкатки покажет расхождение, потому что рабочая витрина получит реестр
только с пересворачиванием. Отчёт `reindex` обязан назвать это ожидаемым
классом и напечатать счётчик строк реестра «до и после» — иначе расхождение
безадресно: числа объектов, тренировок и записей не изменятся.

Провенанс — **только первая встреча** (`first_seen_utc`, `first_delivery_id`,
минимум по журналу `(received_at, id)`). Минимум идемпотентен при повторной
свёртке той же доставки; счётчик встреч не идемпотентен и потому не заводится
вовсе.

Границы, потому что тело контролирует отправитель целиком:

```
maxCategoricalValues    64 различных значения на доставку   измерено ~11
maxCategoricalValueLen  128 байт на значение                измерено 36 («Сидячий образ жизни»)
```

Числа названы здесь, а не «по аналогии с 32/64 у непокрытых секций»: там имена
секций короткие и латинские, здесь — русские фразы в UTF-8, и предел в 32 байта
отбросил бы две из трёх измеренных строк контекста пульса.

Слишком длинное значение **отбрасывается со счётчиком, а не обрезается**:
обрезанная строка неотличима от настоящей и попала бы в ключ реестра как
самостоятельное значение. Точка при этом хранится целиком — теряется запись в
реестре, а не данные.

При переполнении границы числа уцелевший набор — **функция множества, а не
порядка элементов на проводе**: наблюдения сортируются по
`(метрика, поле, значение)` и берутся первые `maxCategoricalValues`. Иначе то
же содержимое, переприсланное в другом порядке ключей (порядок у HAE
нестабилен, находка 2), давало бы другой реестр — и расхождение вышло бы как
«пересборка не сошлась», без адреса.

### 7. Запись реестра идёт в транзакции слияния

Реестр обновляется тем же `store.Merge`, что и объекты: доставка либо свёрнута
целиком, либо не свёрнута. Отдельный вызов дал бы состояние «точки легли,
реестр нет», которое повторная свёртка не чинит — хеш содержимого сойдётся, и
объекты переписываться не станут. Строк на доставку единицы, удержание
блокировки не растёт.

## Risks / Trade-offs

- **Строки локали — данные о здоровье** («Сидячий образ жизни» — контекст
  пульса) → в лог уходят только счётчики; значения и коды не попадают в атрибуты
  свёртки ни на каком уровне. Проверяется тем же приёмом, что и значения точек:
  разбором записи, а не поиском подстроки в сыром буфере.
- **Отправитель раздувает реестр** тысячей различных «фаз сна» → границы на
  число и длину, отброшенное считается, уцелевшее детерминировано.
- **Первая сверка отпечатков после выкатки разойдётся** → названо в миграции, в
  спеке, в отчёте `reindex` отдельным классом и счётчиком.
- **Код в базе отстаёт от словаря в бинаре** для строк, переставших приезжать →
  осознанная цена материализации (решение 1, форма 2); лечится пересборкой,
  сходимость не задевает, потому что `code` вне отпечатка.
- **Словарь покрывает только фазы сна** → `heart_rate.context` и имена
  тренировок попадут в реестр с пустым кодом. Счётчик неизвестных строк поэтому
  ненулевой в установившемся режиме, и как сигнал «появилось новое» он не
  годится; сигналом служит **новая строка в реестре**, а не ненулевой счётчик.
- **Словарь набирался литералами Go, а сверяться будет с байтами тела** →
  приёмочный тест берёт строку **из пакета `testdata`**, а не из константы
  теста. Прецедент: Apple шлёт неразрывные пробелы внутри строк (находка 24);
  в наблюдённых категориальных полях их сегодня нет (проверено `grep` по
  `testdata`), но литерал, набранный руками, эту проверку не заменяет.
- **Реестр не сравнивается с усечённым журналом** → при подрезке архива строки,
  чьи доставки выпали, останутся в рабочей витрине и не появятся в
  пересобранной. Названо в Non-Goals; ретеншена архива пока нет.
