## 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** смещение не меняется, если то же значение сделать длиннее