## ADDED 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 браться из тела, а не вычисляться из начала и конца: 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 пропускаться со счётчиком, не роняя разбор остального: тело остаётся в архиве, и доставку подберёт пересборка, когда разбор научится её понимать. Отсутствие покрытой секции в теле ошибкой быть 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` либо `id` пуст - **THEN** сущность в результат разбора не попадает - **AND** факт учитывается счётчиком, а разбор остальных сущностей продолжается #### Scenario: Элемент секции не является объектом - **WHEN** элемент покрытой секции не разбирается как объект JSON - **THEN** сущность в результат разбора не попадает - **AND** факт учитывается **отдельным** счётчиком, а соседние сущности разбираются как обычно Отдельным, а не общим с «нет `id`»: доставка, где не разобрался сам элемент, — это сменившаяся форма секции, а доставка без `id` — сменившаяся форма идентификатора. Ронять из-за такого элемента всю доставку нельзя тем более: `failed` фоновая свёртка не подбирает никогда, и вместе с одной кривой тренировкой в него уехали бы записи `stateOfMind` той же доставки. #### Scenario: Сущность без разбираемой метки времени пропускается - **WHEN** элемент покрытой секции несёт `id`, но его метка времени не разбирается ни одним из поддерживаемых форматов - **THEN** сущность в результат разбора не попадает - **AND** факт учитывается счётчиком #### 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** сущностей из неё разбор не отдаёт ## MODIFIED Requirements ### Requirement: Отказ разбора остаётся всё или ничего Разбор SHALL оставаться операцией «всё или ничего»: ошибка, встреченная **после** того, как покрытая секция уже разобрана (обрезанное тело, мусор в следующем члене), MUST NOT оставлять в результате ни точек, ни сущностей — доставка считается неразобранной целиком. Иначе часть данных оказалась бы в витрине под статусом, по которому доставку никто не подберёт, и свёртка перестала бы быть детерминированной по журналу. Правило SHALL распространяться и на невыводимый слой: доставка, у которой есть метрики, но слой их не определяется, не сохраняет и своих сущностей, хотя слоя у сущности нет. Соблазн «сущности от слоя не зависят, запишем их» ломает то же «всё или ничего» — доставка получила бы `failed` при частично записанной витрине, и повторная свёртка перестала бы быть no-op. Цена названа вслух: если такая доставка когда-нибудь принесёт `stateOfMind`, его записи доедут не сразу, а пересборкой; тело при этом остаётся в архиве, и `failed` ретеншену трогать нельзя. Повтор ключа покрытой секции в одном объекте `data` SHALL давать объединение секций, а не победу последней: молча терять данные нельзя. То же SHALL относиться к повтору самого члена `data` в теле — результаты **накапливаются**, включая список непокрытых ключей. #### Scenario: Тело оборвано после секции метрик - **WHEN** тело содержит целую секцию `metrics`, а следующий член `data` оборван - **THEN** разбор завершается ошибкой и точек не отдаёт #### Scenario: Тело оборвано после секции тренировок - **WHEN** тело содержит целую секцию `workouts`, а следующий член `data` оборван - **THEN** разбор завершается ошибкой и сущностей не отдаёт #### Scenario: Невыводимый слой не сохраняет и сущностей - **WHEN** доставка несёт метрики, слой которых не определяется, и вместе с ними секцию `stateOfMind` - **THEN** разбор завершается ошибкой, ни точек, ни записей не отдаёт - **AND** список непокрытых ключей переживает отказ #### Scenario: Секция метрик встречается дважды - **WHEN** объект `data` содержит два ключа `metrics` - **THEN** точки обеих секций попадают в результат #### Scenario: Секция тренировок встречается дважды - **WHEN** объект `data` содержит два ключа `workouts` - **THEN** сущности обеих секций попадают в результат #### Scenario: Член `data` встречается дважды - **WHEN** тело содержит два члена `data`, из которых первый несёт непокрытую секцию, а второй — покрытую - **THEN** данные покрытой секции попадают в результат - **AND** имя непокрытой секции остаётся в списке непокрытых ключей ### Requirement: Разбор форматов времени Система SHALL разбирать метку формата `2026-07-31 21:03:51 +0300` и приводить её к UTC, сохраняя офсет исходной зоны. В секции `data.metrics` других форматов меток не встречается. Система SHALL разбирать вторым форматом RFC 3339 в UTC (`2026-07-31T18:03:51Z`): им приходят метки секции `data.stateOfMind`, тогда как тренировки и метрики шлют первый формат. Оба формата SHALL приниматься **у любой** метки сущности, а не приписываться секции жёстко: формы однозначны и не пересекаются, а HAE выравнивает секции между собой по ходу своих обновлений — `stateOfMind` уже шлёт стабильные коды HealthKit там, где старые секции шлют переводы. Приписанный секции формат ломался бы молча в день такого выравнивания. Метка RFC 3339 в UTC даёт офсет `0`, и это MUST означать «источник прислал UTC», а не «человек находился в нулевой зоне»: местной зоны у секции `stateOfMind` в потоке нет вовсе. Метка **точки** при этом остаётся строгой — один формат, — и асимметрия намеренная. По метке точки выводится слой, причём по метке в **исходной зоне**; терпимость к RFC 3339 означала бы, что метка в UTC тихо портит выравнивание и часовая выгрузка складывается с минутной (наблюдалось: удвоение суммы за час). У сущности слоя нет, и терять на строгости нечего, а у точки строгий парсер отдаёт непонятую метку в счётчик пропусков — тело остаётся в архиве, и пересборка вернёт его, когда формат станет известен. Unix-эпоха дробным числом (`1785446196.4132624`) встречается **внутри** `heartbeatSeries` и меткой точки не является. Система MUST NOT преобразовывать её: элементы серии проходят как исходные байты. Преобразование во `time.Unix` и обратно не гарантирует дословности, а серия составляет 93% объёма метрики `heart_rate_variability`. Время внутри маршрута тренировки (`route[].timestamp`) меткой сущности тоже не является и MUST проходить дословно, не разбираясь. #### Scenario: Локальное время со смещением - **WHEN** метка имеет вид `2026-07-31 21:03:51 +0300` - **THEN** точка получает время в UTC и офсет `+10800` секунд #### Scenario: RFC 3339 в UTC - **WHEN** метка сущности имеет вид `2026-07-31T18:03:51Z` - **THEN** сущность получает время в UTC и офсет `0` #### Scenario: Тренировка со временем в формате метрик - **WHEN** тренировка несёт `start` вида `2026-08-01 10:04:31 +0300` - **THEN** сущность получает время в UTC и офсет `+10800` секунд #### Scenario: Время внутри серии ударов - **WHEN** точка метрики `heart_rate_variability` содержит `heartbeatSeries` - **THEN** элементы серии сохраняются исходными байтами вместе с их эпохой - **AND** серия не разворачивается в отдельные точки - **AND** эпоха внутри серии не разбирается и не преобразуется #### Scenario: Время внутри маршрута не разбирается - **WHEN** тренировка содержит `route` с полем `timestamp` у каждой точки - **THEN** точки маршрута сохраняются исходными байтами - **AND** их метки не разбираются и не преобразуются ### Requirement: Перечисление непокрытых секций доставки Разбор SHALL перечислять верхнеуровневые ключи объекта `data` и возвращать вызывающему те из них, которые он не покрывает. Содержимое непокрытой секции MUST NOT удерживаться после того, как разбор прошёл мимо неё: тела доходят до 42 МиБ, и удержание кучи здесь — часть контракта, а не деталь реализации. Покрытых ключей сегодня три — `metrics`, `workouts` и `stateOfMind`. Разбор и перечисление MUST ходить по одному объявленному множеству покрытых имён: состояние «секция разбирается, но числится непокрытой» невыразимо по построению. Непокрытым ключ считается независимо от того, что лежит внутри: содержимое не интерпретируется, поэтому и о пустоте секции разбор честно ничего не знает. Измерено на живом архиве — пустых секций HAE не присылает ни разу (118 доставок). Список SHALL быть каноничен: имена отсортированы, повторов нет. Порядок ключей в JSON от HAE нестабилен, а значение уезжает в базу и сравнивается между доставками. Отсутствие непокрытых ключей и отсутствие секции `metrics` — разные события, и оба нормальны: половина потока состоит из доставок без метрик вовсе (53 из 118). #### Scenario: Незнакомая секция попадает в список непокрытых - **WHEN** тело содержит `data.ecg` наряду с `data.metrics` - **THEN** разбор возвращает `ecg` в списке непокрытых ключей - **AND** точки секции `metrics` разбираются как обычно #### Scenario: Доставка из одних тренировок непокрытых ключей не даёт - **WHEN** тело содержит только `data.workouts` - **THEN** разбор завершается без ошибки, точек нет, тренировки разобраны - **AND** список непокрытых ключей пуст #### Scenario: Доставка из одного состояния разума непокрытых ключей не даёт - **WHEN** тело содержит только `data.stateOfMind` - **THEN** разбор завершается без ошибки, записи разобраны - **AND** список непокрытых ключей пуст #### Scenario: Доставка из одних метрик непокрытых ключей не даёт - **WHEN** единственный ключ `data` — `metrics` - **THEN** список непокрытых ключей пуст #### Scenario: Один и тот же набор секций даёт один и тот же список - **WHEN** два тела несут те же секции в разном порядке, а одно из них повторяет непокрытый ключ дважды - **THEN** списки непокрытых ключей у них совпадают #### Scenario: Содержимое непокрытой секции не удерживается в памяти - **WHEN** тело в десятки мегабайт состоит преимущественно из непокрытой секции - **THEN** после разбора удержано не больше четырёх размеров тела — та же граница, что и для тела из метрик - **AND** содержимое непокрытой секции в результат разбора не попадает