## MODIFIED Requirements

### Requirement: Разбор секций с собственными идентификаторами

Система SHALL разбирать секции тела, элементы которых несут собственный `id`, в
**сущности**, а не в точки: у сущности нет ни слоя, ни координатного ключа
`метрика + слой + начало + конец` — её адресует сам `id`.

Покрываются две такие секции: `data.workouts` и `data.stateOfMind`. Секции
`ecg`, `symptoms`, `cycleTracking`, `medications` и `heartRateNotifications`
покрытыми MUST NOT становиться: живой поток не приносил их ни разу (118
доставок), их форма никем не наблюдалась, а полнота покрытия HealthKit ради
полноты целью проекта не является. Они остаются в списке непокрытых, и доставка
с ними остаётся `partial`.

Из тренировки разбор SHALL брать только то, по чему потом идёт выборка:
идентификатор, имя, начало, конец, офсет исходной зоны и длительность. Всё
остальное — включая маршрут, внутренние ряды (`heartRateData`,
`activeEnergy`, `heartRateRecovery`) и сводки — MUST храниться содержимым
сущности **дословно**, теми же байтами, какими пришло. Раскладывать структуру
тренировки по колонкам значило бы решить за Apple, что в ней главное: сводки
дублируют ряды (`distance` — это сумма `walkingAndRunningDistance`), а набор
полей зависит от типа тренировки (у уличной есть `route`, `avgSpeed`,
`flightsClimbed`, у домашней — `temperature`, `humidity`, `intensity`).

**Значение заголовка не того ТИПА SHALL стоить одного поля, а не сущности.**
Каждое поле заголовка читается мягко: строка берётся, когда значение является
строкой, и считается отсутствующей во всех прочих случаях. Правило уже записано
для длительности («нечисловое значение — это пропуск ОДНОГО поля, а не сломанная
сущность») и распространяется на весь заголовок. Иначе `name`, приехавшее
числом, уносит тренировку вместе с маршрутом, а доставка при этом числится
разобранной.

Мягкость MUST достигаться конструкцией, которая **не полагается на дозаполнение
остальных полей** библиотекой разбора: `encoding/json` при несовпадении типа
«skips that field and completes the unmarshaling as best it can», но тут же
оговаривает, что дозаполнение полей **после** проблемного не гарантировано.
Разбор, построенный на распознавании ошибки типа постфактум, перестал бы быть
функцией тела: одна и та же тренировка давала бы разный заголовок.

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

Идентификатор исключением из мягкости MUST быть: сущность без строкового `id`
не адресуема, и приведение чужого нестрокового значения к строке было бы
выдумыванием идентичности за источник. Такая сущность пропускается тем же
счётчиком, что и сущность без `id`.

Началом сущности при **присутствующем, но непрочитанном** `start` подставляться
`date` MUST NOT — включая `start: null`.
«Значение не той формы» и «значения нет» здесь различаются: фолбэк на `date`
существует для сущностей, у которых `start` не прислан вовсе, а подстановка
другого поля вместо непонятого даёт метку **другого момента времени**, ничем не
отличимую от настоящей. Такой `start` SHALL считаться неразбираемой меткой —
тем же исходом и тем же счётчиком, что метка незнакомого формата. Различать
надо именно «ключ был», а не «значение не той формы»: `null` тоже не даёт
строки, и без этого различения он молча уводил бы тренировку на другой момент
времени.

Мягкое чтение SHALL задавать поле целиком на каждое вхождение ключа, а не
накапливать признаки между вызовами. JSON допускает повтор ключа с семантикой
«побеждает последнее», и разбор ей уже следует; накопленный признак сделал бы
заголовок функцией истории вызовов, а не тела.

Элемент секции, не являющийся объектом JSON, SHALL уходить в счётчик «не
разобралось как объект» — включая `null`. Разбор в структуру на `null` ошибки не
даёт, поэтому такой элемент без явной проверки попадал бы в счётчик «нет `id`»,
и сменившаяся форма СЕКЦИИ диагностировалась бы как сменившаяся форма
ИДЕНТИФИКАТОРА — ради различения которых два счётчика и заведены.

Длительность SHALL браться из тела, а не вычисляться из начала и конца: HAE
шлёт `91.746` секунды при интервале в 91 секунду, и вычисленное значение молча
разошлось бы с присланным. Отсутствие или нечисловое значение длительности
сущность MUST NOT отбрасывать; такая длительность SHALL быть выражена
отсутствием значения, а не нулём — ноль является законной длительностью, и
потребитель не отличил бы «источник не прислал» от «измерено ноль».

Началом сущности SHALL быть `start`, при его отсутствии — `date`. Конец берётся
из `end`; при отсутствии или неразбираемости конца он SHALL равняться началу, а
истина остаётся в содержимом. Вырождение интервала здесь безопасно, в отличие от
точки: ключ сущности — `id`, схлопывать координаты нечем. Офсет исходной зоны
SHALL браться из начала: колонка одна, а тренировка через смену зоны дала бы
два разных.

Из записи разбор SHALL брать идентификатор, род секции, метку времени и офсет;
всё остальное хранится дословно. Род записи SHALL быть верхнеуровневым ключом
секции HAE **дословно** (`stateOfMind`, не `state_of_mind`): инвариант «форма
Apple не транслируется» относится и к именам секций.

Длина идентификатора SHALL быть ограничена, и сущность с более длинным `id`
SHALL пропускаться тем же счётчиком, что и сущность без `id`. Идентификатор
приходит из тела, которым отправитель управляет целиком, а уезжает и в ключ
таблицы, и в записи лога; правило то же, что уже действует для имён непокрытых
секций.

Ряд пульса **внутри** тренировки MUST NOT попадать в метрику `heart_rate`:
это разные сущности хранилища. Пульс приезжает дважды — в общем потоке метрик и
внутри тренировки, — и смешение задвоило бы ряд.

Сущность без `id` либо без разбираемой метки времени SHALL пропускаться со
счётчиком, не роняя разбор остального: тело остаётся в архиве, и доставку
подберёт пересборка, когда разбор научится её понимать. Счётчики пропусков SHALL
доходить до учётной записи доставки, а не только до лога, — иначе ретеншен,
решающий по базе, получит ответ «терять нечего» там, где потеряна тренировка.

Отсутствие покрытой секции в теле ошибкой быть MUST NOT: доставки из одних
метрик — большинство потока.

#### Scenario: Тренировка разбирается вместе с маршрутом

- **WHEN** тело содержит `data.workouts` с тренировкой, несущей `route`
- **THEN** разбор отдаёт сущность с идентификатором, именем, началом, концом,
  офсетом и длительностью
- **AND** её содержимое несёт маршрут и внутренние ряды исходными байтами

#### Scenario: Ряд пульса тренировки не становится метрикой

- **WHEN** тренировка содержит `heartRateData`
- **THEN** точки этого ряда не попадают в точки метрик
- **AND** остаются внутри содержимого сущности

#### Scenario: Запись состояния разума разбирается

- **WHEN** тело содержит `data.stateOfMind` с элементом, несущим `id` и `start`
- **THEN** разбор отдаёт запись с родом `stateOfMind`, идентификатором, меткой
  времени и содержимым исходными байтами

#### Scenario: Имя не той формы не уносит тренировку

- **WHEN** у тренировки с корректными `id` и `start` поле `name` приехало
  числом
- **THEN** тренировка попадает в результат разбора с пустым именем
- **AND** её содержимое сохраняется дословно, включая маршрут

#### Scenario: Конец не той формы не уносит тренировку

- **WHEN** у тренировки с корректными `id` и `start` поле `end` приехало числом
- **THEN** тренировка попадает в результат разбора, а конец равен началу

#### Scenario: Сущность без идентификатора пропускается

- **WHEN** элемент покрытой секции не несёт `id`, либо `id` пуст, либо `id`
  приехал не строкой
- **THEN** сущность в результат разбора не попадает
- **AND** факт учитывается счётчиком, а разбор остальных сущностей продолжается

#### Scenario: Элемент секции не является объектом

- **WHEN** элемент покрытой секции не разбирается как объект JSON
- **THEN** сущность в результат разбора не попадает
- **AND** факт учитывается **отдельным** счётчиком, а соседние сущности
  разбираются как обычно

Отдельным, а не общим с «нет `id`»: доставка, где не разобрался сам элемент, —
это сменившаяся форма секции, а доставка без `id` — сменившаяся форма
идентификатора. Ронять из-за такого элемента всю доставку нельзя тем более:
`failed` фоновая свёртка не подбирает никогда, и вместе с одной кривой
тренировкой в него уехали бы записи `stateOfMind` той же доставки.

#### Scenario: Элемент секции не объект — свой счётчик

- **WHEN** элемент покрытой секции пришёл как `null`, строка, число или массив
- **THEN** факт учитывается счётчиком «не разобралось как объект»
- **AND** счётчик «нет `id`» не растёт

#### Scenario: Повтор ключа метки решается последним значением

- **WHEN** у элемента ключ `start` встречается дважды, и валидная метка стоит
  последней
- **THEN** сущность попадает в результат разбора с этой меткой

#### Scenario: Сущность без разбираемой метки времени пропускается

- **WHEN** элемент покрытой секции несёт `id`, но его метка времени не
  разбирается ни одним из поддерживаемых форматов либо пришла не строкой
  (включая `null`)
- **THEN** сущность в результат разбора не попадает
- **AND** факт учитывается счётчиком
- **AND** поле `date` вместо непонятого `start` не подставляется

#### Scenario: Сущность со слишком длинным идентификатором пропускается

- **WHEN** элемент покрытой секции несёт `id` длиннее предела
- **THEN** сущность в результат разбора не попадает
- **AND** факт учитывается тем же счётчиком, что и отсутствие `id`

#### Scenario: Длительность берётся из тела, а не из интервала

- **WHEN** тренировка несёт `duration` равный `91.746` при интервале
  `start`/`end` в 91 секунду
- **THEN** длительность сущности равна `91.746`

#### Scenario: Тренировка без длительности сохраняется без неё

- **WHEN** тренировка не несёт `duration` либо оно не является числом
- **THEN** сущность сохраняется, а её длительность остаётся незаполненной
- **AND** нулём она MUST NOT становиться

#### Scenario: Нечитаемый конец тренировки не отбрасывает её

- **WHEN** тренировка несёт `end`, который не разбирается
- **THEN** конец сущности равен её началу
- **AND** исходное значение остаётся в содержимом дословно

#### Scenario: Незнакомое поле тренировки переживает разбор

- **WHEN** тренировка несёт поле, которого разбор не знает
- **THEN** оно сохраняется в содержимом сущности дословно
- **AND** разбор не завершается ошибкой

#### Scenario: Непокрытая секция с собственными id остаётся непокрытой

- **WHEN** тело содержит `data.ecg`
- **THEN** `ecg` попадает в список непокрытых ключей
- **AND** сущностей из неё разбор не отдаёт

## ADDED Requirements

### Requirement: Диагностика разбора не несёт значений из тела

Сообщение об ошибке разбора MUST NOT содержать значений из тела доставки. Оно
SHALL называть **тип** встреченного токена и смещение во входе — по смещению
место находится в теле, лежащем в архиве, а значение из тела в логе не имеет
права быть в принципе.

Тип SHALL называться словарём JSON (`object`, `array`, `string`, `number`,
`bool`, `null`), а не именем типа языка реализации: имя типа для делимитера не
говорит ничего — какая скобка встретилась вместо ожидаемой, из него не следует,
— а сам делимитер принадлежит фиксированному набору и содержимого не раскрывает,
поэтому печатается значением.

Смещение SHALL указывать на место **перед** виновным токеном и от длины его
значения зависеть MUST NOT. Декодер сообщает позицию как конец последнего
возвращённого токена, поэтому взятая после чтения она отличалась бы от начала
проблемы ровно на длину значения — то есть на восемь мегабайт в том самом
случае, ради которого требование написано, и обещание «место находится в теле»
не выполнялось бы.

Это не стиль, а тот же инвариант, что уже записан для точек: данные о здоровье
чувствительнее токенов, тела запросов пишутся только на `DEBUG` и с обрезкой.
Подстановка токена целиком инвариант обходит: тело в 8 МиБ даёт текст ошибки в
8 МиБ, который уходит атрибутом `error` на уровень `WARN` — то есть содержимое
доставки оказывается в логе полностью и без обрезки.

Предел SHALL держаться самим сообщением, а не обрезкой на стороне
логирующего: обрезка живёт в другом месте и о новой ошибке разбора не узнает.

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

#### Scenario: Огромное значение не доезжает до текста ошибки

- **WHEN** тело содержит на месте ожидаемого объекта строку в несколько
  мегабайт
- **THEN** разбор завершается ошибкой
- **AND** длина текста ошибки не зависит от длины этого значения
- **AND** текст называет тип токена словарём JSON и смещение перед токеном
- **AND** смещение не меняется, если то же значение сделать длиннее
