Files
healthlog/openspec/changes/archive/2026-08-02-dozakryt-nahodki-sushchnostej/specs/parsing/spec.md
T
av 8331328134 Дозакрыты находки ревью по слиянию сущностей
- Правило покрытия получило второй разряд (условный, как у точек), запрет
  вырождения формы и счёт содержательных элементов ряда: скелет из скаляров и
  ряд из null больше не затирают маршрут. Победитель внутри доставки стал
  функцией множества версий — общим помощником с точками, — а провенанс
  поднимается и при совпавшем хеше, иначе отложенная доставка возвращала витрину
  к прежнему содержимому.
- Одно поле не того типа больше не уносит сущность, а пропуски видны в учётной
  записи доставки (миграция 00008, NULL = «не измерялось»); каноническая форма
  считается один раз и вне транзакции; откат бинаря поверх новой схемы отказывает
  на старте; текст ошибки разбора не несёт значений из тела.
- Ревью кода профилем deep (девять проходов) нашло две регрессии и обе закрыты:
  безусловный второй разряд запирал законный досчёт навсегда, а выбор победителя
  был квадратичен по числу присланных версий одного ключа.
2026-08-02 16:38:18 +03:00

22 KiB
Raw Blame History

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